feat(app): add OSPF Tools page and integrate it into the main layout and language support
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
This commit is contained in:
@@ -53,6 +53,7 @@ import FirewallPage from './FirewallPage.jsx';
|
||||
import SettingsPage from './SettingsPage.jsx';
|
||||
import ResourceStatsPage from './ResourceStatsPage.jsx';
|
||||
import RouteOptimizerPage from './RouteOptimizerPage.jsx';
|
||||
import OspfToolsPage from './OspfToolsPage.jsx';
|
||||
import './App.css';
|
||||
import { NotifyProvider } from './components/NotifyProvider.jsx';
|
||||
import ToastContainer from './components/ToastContainer.jsx';
|
||||
@@ -75,7 +76,7 @@ function LanguageProvider({ children }) {
|
||||
dashboard: 'Панель', trafficTraffic: 'Расход трафика', resourceStats: 'Статистика ресурсов', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
|
||||
routeOptimizer: 'Оптимизация маршрутов ИИ',
|
||||
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
|
||||
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall',
|
||||
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall', ospfTools: 'Инструменты OSPF',
|
||||
light: 'Светлая', dark: 'Тёмная',
|
||||
layoutSidebar: 'Сайдбар', layoutHorizontal: 'Верхнее меню'
|
||||
},
|
||||
@@ -84,7 +85,7 @@ function LanguageProvider({ children }) {
|
||||
dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', resourceStats: 'Resource Stats', networkMap: 'Network Map', uptimeMonitor: 'Uptime Monitor', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
|
||||
routeOptimizer: 'AI Route Optimizer',
|
||||
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
|
||||
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', firewall: 'Firewall',
|
||||
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', firewall: 'Firewall', ospfTools: 'OSPF Tools',
|
||||
light: 'Light', dark: 'Dark',
|
||||
layoutSidebar: 'Sidebar', layoutHorizontal: 'Top menu'
|
||||
}
|
||||
@@ -289,6 +290,7 @@ function MainLayout() {
|
||||
items: [
|
||||
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload },
|
||||
{ id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase },
|
||||
{ id: 'ospf-tools', title: t('ospfTools'), path: '/ospf-tools', icon: IconRoute2 },
|
||||
{ id: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork },
|
||||
{ id: 'firewall', title: t('firewall'), path: '/firewall', icon: IconShield },
|
||||
{ id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork },
|
||||
@@ -466,6 +468,7 @@ function MainLayout() {
|
||||
<Route path="/network-config" element={<NetworkConfigManager />} />
|
||||
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
||||
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
||||
<Route path="/ospf-tools" element={<OspfToolsPage />} />
|
||||
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||
<Route path="/firewall" element={<FirewallPage />} />
|
||||
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
||||
@@ -643,6 +646,7 @@ function MainLayout() {
|
||||
<Route path="/network-config" element={<NetworkConfigManager />} />
|
||||
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
||||
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
||||
<Route path="/ospf-tools" element={<OspfToolsPage />} />
|
||||
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||
<Route path="/firewall" element={<FirewallPage />} />
|
||||
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import api from './lib/api.js';
|
||||
import { useNotify } from './components/NotifyProvider.jsx';
|
||||
import FormModal from './components/FormModal.jsx';
|
||||
@@ -125,78 +125,6 @@ const GATEWAY_TEMPLATES = [
|
||||
},
|
||||
];
|
||||
|
||||
const OSPF_COST_STEP = 10;
|
||||
|
||||
function isUnknownOspfInterfaceName(name) {
|
||||
const value = String(name || '').trim();
|
||||
if (!value) return true;
|
||||
if (value.startsWith('*')) return true; // служебные ссылки RouterOS вида *50
|
||||
if (value.toLowerCase() === 'unknown') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildOspfTemplatesFromRouterResults(routerResults = [], servers = []) {
|
||||
const serverById = new Map((servers || []).map((s) => [String(s.id || s.ip || s.dns || ''), s]));
|
||||
const byComposite = new Map();
|
||||
|
||||
(routerResults || []).forEach((result) => {
|
||||
const serverId = String(result?.serverId || '').trim();
|
||||
if (!serverId) return;
|
||||
const server = serverById.get(serverId) || {
|
||||
id: serverId,
|
||||
ip: String(result?.serverLabel || serverId),
|
||||
dns: String(result?.serverLabel || ''),
|
||||
};
|
||||
const identity = getOspfRouterIdentity(server);
|
||||
const templates = Array.isArray(result?.templates) ? result.templates : [];
|
||||
|
||||
templates.forEach((tpl) => {
|
||||
const interfaceName = String(tpl?.interfaceName || '').trim();
|
||||
if (isUnknownOspfInterfaceName(interfaceName)) return;
|
||||
const area = String(tpl?.area || '').trim() || 'backbone';
|
||||
const numericCost = Number(tpl?.cost);
|
||||
const cost = Number.isFinite(numericCost) && numericCost >= 0 ? numericCost : OSPF_COST_STEP;
|
||||
|
||||
const compositeKey = `${identity.key}::${area.toLowerCase()}::${interfaceName.toUpperCase()}`;
|
||||
if (byComposite.has(compositeKey)) return;
|
||||
|
||||
byComposite.set(compositeKey, {
|
||||
key: compositeKey,
|
||||
rosId: tpl?.id || null,
|
||||
serverId,
|
||||
routerKey: identity.key,
|
||||
routerLabel: identity.label,
|
||||
interfaceName,
|
||||
area,
|
||||
cost,
|
||||
active: !Boolean(tpl?.disabled),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(byComposite.values());
|
||||
}
|
||||
|
||||
function moveInArray(arr, fromIndex, toIndex) {
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex >= arr.length || toIndex >= arr.length) return arr;
|
||||
const copy = [...arr];
|
||||
const [moved] = copy.splice(fromIndex, 1);
|
||||
copy.splice(toIndex, 0, moved);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function getOspfRouterIdentity(server) {
|
||||
if (!server || typeof server !== 'object') {
|
||||
return { key: 'unknown', label: 'Unknown router' };
|
||||
}
|
||||
const host = String(server.mikrotikHost || server.ip || server.dns || server.id || '').trim();
|
||||
const port = String(server.mikrotikPort || 80).trim();
|
||||
const user = String(server.mikrotikUser || 'admin').trim();
|
||||
const key = `${host}::${port}::${user}`;
|
||||
const label = host || String(server.dns || server.ip || server.id || 'Unknown router');
|
||||
return { key, label };
|
||||
}
|
||||
|
||||
// Пустые объекты
|
||||
const getEmptyGateway = () => ({
|
||||
id: `gw-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
|
||||
@@ -235,7 +163,6 @@ const getDefaultConfig = () => ({
|
||||
gateways: [],
|
||||
tunnelInterfaces: [],
|
||||
ipPools: [],
|
||||
ospfInterfaceTemplates: [],
|
||||
});
|
||||
|
||||
function NetworkConfigManager() {
|
||||
@@ -295,10 +222,6 @@ function NetworkConfigManager() {
|
||||
const [templateMtu, setTemplateMtu] = useState(''); // MTU для шаблона
|
||||
const [templateTunnelCount, setTemplateTunnelCount] = useState(1); // Количество туннелей для создания
|
||||
const [templateServerPairs, setTemplateServerPairs] = useState([]); // Массив пар серверов для множественного создания
|
||||
const [ospfDraggingItem, setOspfDraggingItem] = useState(null);
|
||||
const [ospfLoading, setOspfLoading] = useState(false);
|
||||
const [ospfSaving, setOspfSaving] = useState(false);
|
||||
const ospfAutoLoadedRef = useRef(false);
|
||||
|
||||
// === IPSec Passwords Management ===
|
||||
const [ipsecPasswords, setIpsecPasswords] = useState([]);
|
||||
@@ -362,7 +285,6 @@ function NetworkConfigManager() {
|
||||
gateways: Array.isArray(migratedGateways) ? migratedGateways : [],
|
||||
tunnelInterfaces: Array.isArray(data.tunnelInterfaces) ? data.tunnelInterfaces : [],
|
||||
ipPools: Array.isArray(data.ipPools) ? data.ipPools : [],
|
||||
ospfInterfaceTemplates: Array.isArray(data.ospfInterfaceTemplates) ? data.ospfInterfaceTemplates : [],
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.response?.status !== 404) {
|
||||
@@ -559,195 +481,6 @@ function NetworkConfigManager() {
|
||||
return result;
|
||||
}, [config.ipPools, serverFilter, searchTerm, servers]);
|
||||
|
||||
const ospfTemplatesByRouter = useMemo(() => {
|
||||
const grouped = {};
|
||||
(config.ospfInterfaceTemplates || []).forEach((item) => {
|
||||
const sid = String(item?.serverId || '');
|
||||
if (!sid) return;
|
||||
const server = getServerInfo(sid);
|
||||
const identity = getOspfRouterIdentity(server || { id: sid, ip: sid });
|
||||
if (!grouped[identity.key]) {
|
||||
grouped[identity.key] = {
|
||||
routerKey: identity.key,
|
||||
routerLabel: identity.label,
|
||||
serverIds: new Set(),
|
||||
representativeServerId: sid,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
grouped[identity.key].serverIds.add(sid);
|
||||
grouped[identity.key].items.push(item);
|
||||
});
|
||||
|
||||
const groups = Object.values(grouped).map((group) => {
|
||||
const areasMap = {};
|
||||
group.items.forEach((item) => {
|
||||
const area = String(item?.area || '').trim() || 'backbone';
|
||||
if (!areasMap[area]) areasMap[area] = [];
|
||||
areasMap[area].push(item);
|
||||
});
|
||||
|
||||
const areas = Object.entries(areasMap)
|
||||
.map(([area, items]) => ({
|
||||
area,
|
||||
items: [...items].sort((a, b) => Number(a?.cost || 0) - Number(b?.cost || 0)),
|
||||
}))
|
||||
.sort((a, b) => a.area.localeCompare(b.area));
|
||||
|
||||
return {
|
||||
...group,
|
||||
serverIds: Array.from(group.serverIds),
|
||||
areas,
|
||||
};
|
||||
});
|
||||
|
||||
if (serverFilter) {
|
||||
const filterId = String(serverFilter);
|
||||
return groups.filter((group) => group.serverIds.includes(filterId));
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [config.ospfInterfaceTemplates, serverFilter, servers]);
|
||||
|
||||
const handleDropOspfTemplate = (routerKey, area, targetKey) => {
|
||||
if (!ospfDraggingItem || String(ospfDraggingItem.routerKey) !== String(routerKey)) return;
|
||||
if (String(ospfDraggingItem.area || '') !== String(area || '')) return;
|
||||
const draggedKey = String(ospfDraggingItem.key || '');
|
||||
const destinationKey = String(targetKey || '');
|
||||
if (!draggedKey || !destinationKey || draggedKey === destinationKey) {
|
||||
setOspfDraggingItem(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setConfig((prev) => {
|
||||
const current = (prev.ospfInterfaceTemplates || [])
|
||||
.filter((item) => {
|
||||
const sid = String(item.serverId || '');
|
||||
const server = getServerInfo(sid);
|
||||
const identity = getOspfRouterIdentity(server || { id: sid, ip: sid });
|
||||
const itemArea = String(item?.area || '').trim() || 'backbone';
|
||||
return identity.key === String(routerKey) && itemArea === String(area || 'backbone');
|
||||
})
|
||||
.sort((a, b) => Number(a?.cost || 0) - Number(b?.cost || 0));
|
||||
const fromIndex = current.findIndex((item) => String(item.key || '') === draggedKey);
|
||||
const toIndex = current.findIndex((item) => String(item.key || '') === destinationKey);
|
||||
if (fromIndex < 0 || toIndex < 0) return prev;
|
||||
|
||||
const reordered = moveInArray(current, fromIndex, toIndex).map((item, idx) => ({
|
||||
...item,
|
||||
cost: (idx + 1) * OSPF_COST_STEP,
|
||||
}));
|
||||
|
||||
const otherServers = (prev.ospfInterfaceTemplates || []).filter((item) => {
|
||||
const sid = String(item.serverId || '');
|
||||
const server = getServerInfo(sid);
|
||||
const identity = getOspfRouterIdentity(server || { id: sid, ip: sid });
|
||||
const itemArea = String(item?.area || '').trim() || 'backbone';
|
||||
return identity.key !== String(routerKey) || itemArea !== String(area || 'backbone');
|
||||
});
|
||||
|
||||
return {
|
||||
...prev,
|
||||
ospfInterfaceTemplates: [...otherServers, ...reordered],
|
||||
};
|
||||
});
|
||||
|
||||
setOspfDraggingItem(null);
|
||||
};
|
||||
|
||||
const handleLoadOspfFromMikrotik = async ({ silent = false } = {}) => {
|
||||
setOspfLoading(true);
|
||||
try {
|
||||
const response = await api.get('/mikrotik/ospf-interface-templates');
|
||||
const results = Array.isArray(response.data?.results) ? response.data.results : [];
|
||||
const errors = Array.isArray(response.data?.errors) ? response.data.errors : [];
|
||||
const loadedTemplates = buildOspfTemplatesFromRouterResults(results, servers);
|
||||
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
ospfInterfaceTemplates: loadedTemplates,
|
||||
}));
|
||||
|
||||
if (!silent && results.length > 0) {
|
||||
notify.success(`OSPF загружен с HOME роутеров: ${results.length}`);
|
||||
} else if (!silent) {
|
||||
notify.warning('Не удалось получить OSPF шаблоны ни с одного HOME роутера');
|
||||
}
|
||||
if (!silent && errors.length > 0) {
|
||||
notify.warning(`Ошибки чтения OSPF: ${errors.length} роутер(ов)`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading OSPF templates:', error);
|
||||
if (!silent) {
|
||||
notify.error('Не удалось загрузить OSPF Interface Template с MikroTik');
|
||||
}
|
||||
} finally {
|
||||
setOspfLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveOspfToMikrotik = async () => {
|
||||
if (ospfTemplatesByRouter.length === 0) {
|
||||
notify.warning('Нет OSPF шаблонов для сохранения');
|
||||
return;
|
||||
}
|
||||
|
||||
setOspfSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
servers: ospfTemplatesByRouter.map((group) => {
|
||||
const dedup = new Map();
|
||||
(group.areas || []).forEach((areaGroup) => {
|
||||
(areaGroup.items || []).forEach((item) => {
|
||||
const interfaceName = String(item.interfaceName || '').trim();
|
||||
const area = String(item.area || '').trim() || 'backbone';
|
||||
if (!interfaceName) return;
|
||||
const key = `${area.toUpperCase()}::${interfaceName.toUpperCase()}`;
|
||||
if (!dedup.has(key)) {
|
||||
dedup.set(key, {
|
||||
rosId: item.rosId || null,
|
||||
interfaceName,
|
||||
area,
|
||||
cost: item.cost,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
serverId: group.representativeServerId,
|
||||
templates: Array.from(dedup.values()),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await api.post('/mikrotik/ospf-interface-templates/apply', payload);
|
||||
const summary = response.data?.summary || {};
|
||||
const errors = Array.isArray(response.data?.errors) ? response.data.errors : [];
|
||||
notify.success(
|
||||
`OSPF сохранен: обновлено ${summary.updated || 0}, пропущено ${summary.skipped || 0}, не найдено ${summary.missing || 0}`
|
||||
);
|
||||
if (Number(summary.invalid || 0) > 0) {
|
||||
notify.warning(`Некорректных записей OSPF: ${summary.invalid}`);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
notify.warning(`Ошибки применения OSPF: ${errors.length} роутер(ов)`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error applying OSPF templates:', error);
|
||||
notify.error('Не удалось сохранить OSPF Interface Template в MikroTik');
|
||||
} finally {
|
||||
setOspfSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'interfaces') return;
|
||||
if (!Array.isArray(servers) || servers.length === 0) return;
|
||||
if (ospfAutoLoadedRef.current) return;
|
||||
ospfAutoLoadedRef.current = true;
|
||||
handleLoadOspfFromMikrotik({ silent: true });
|
||||
}, [activeTab, servers]);
|
||||
|
||||
// === Группировка по серверам ===
|
||||
const gatewaysByServer = useMemo(() => {
|
||||
const grouped = {};
|
||||
@@ -3933,111 +3666,13 @@ function NetworkConfigManager() {
|
||||
{activeTab === 'interfaces' && (
|
||||
<>
|
||||
<div className="card mb-3">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h3 className="card-title mb-0">OSPF Interface Template (HOME)</h3>
|
||||
<div className="text-muted small">
|
||||
Только для HOME роутеров. Источник данных — MikroTik, группировка по Area. Чем меньше cost, тем выше приоритет.
|
||||
</div>
|
||||
<div className="card-body d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
<div className="text-muted small">
|
||||
Настройки OSPF вынесены в отдельный раздел проекта.
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<span className="badge bg-blue-lt text-blue">
|
||||
{config.ospfInterfaceTemplates?.length || 0}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
onClick={handleLoadOspfFromMikrotik}
|
||||
disabled={ospfLoading}
|
||||
title="Загрузить актуальные OSPF Interface Template с HOME роутеров"
|
||||
>
|
||||
<IconRefresh size={14} className={`me-1 ${ospfLoading ? 'spin' : ''}`} />
|
||||
Загрузить из MikroTik
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSaveOspfToMikrotik}
|
||||
disabled={ospfSaving || (config.ospfInterfaceTemplates?.length || 0) === 0}
|
||||
title="Применить cost из шаблона на HOME роутеры"
|
||||
>
|
||||
<IconDatabase size={14} className={`me-1 ${ospfSaving ? 'spin' : ''}`} />
|
||||
Сохранить OSPF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{ospfTemplatesByRouter.length === 0 ? (
|
||||
<div className="text-muted">
|
||||
OSPF Interface Template не найдены на HOME роутерах.
|
||||
</div>
|
||||
) : (
|
||||
<div className="row g-3">
|
||||
{ospfTemplatesByRouter.map((group) => {
|
||||
const areas = group.areas || [];
|
||||
return (
|
||||
<div key={`ospf-router-${group.routerKey}`} className="col-12 col-xl-6">
|
||||
<div className="card card-sm border">
|
||||
<div className="card-header">
|
||||
<div className="fw-semibold">{group.routerLabel}</div>
|
||||
</div>
|
||||
<div className="card-body p-0">
|
||||
{areas.length === 0 ? (
|
||||
<div className="list-group-item text-muted small">
|
||||
Нет доступных интерфейсов для OSPF шаблонов.
|
||||
</div>
|
||||
) : (
|
||||
areas.map((areaGroup) => (
|
||||
<div key={`${group.routerKey}-${areaGroup.area}`} className="border-top">
|
||||
<div className="px-3 py-2 bg-light text-muted small fw-semibold">
|
||||
Area: {areaGroup.area}
|
||||
</div>
|
||||
<div className="list-group list-group-flush">
|
||||
{areaGroup.items.map((template) => (
|
||||
<div
|
||||
key={template.key}
|
||||
className="list-group-item d-flex align-items-center justify-content-between"
|
||||
draggable
|
||||
onDragStart={() =>
|
||||
setOspfDraggingItem({
|
||||
routerKey: group.routerKey,
|
||||
area: areaGroup.area,
|
||||
key: template.key,
|
||||
})
|
||||
}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() =>
|
||||
handleDropOspfTemplate(group.routerKey, areaGroup.area, template.key)
|
||||
}
|
||||
onDragEnd={() => setOspfDraggingItem(null)}
|
||||
style={{ cursor: 'grab' }}
|
||||
>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<span className="text-muted small">::</span>
|
||||
<code>{template.interfaceName}</code>
|
||||
<span
|
||||
className={`badge ${template.active ? 'bg-green-lt text-green' : 'bg-secondary-lt text-secondary'}`}
|
||||
title={template.active ? 'Интерфейс активен в OSPF template' : 'Интерфейс отключен в OSPF template'}
|
||||
>
|
||||
{template.active ? 'Активен' : 'Неактивен'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="badge bg-primary-lt text-primary">
|
||||
cost {template.cost}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<a className="btn btn-outline-primary btn-sm" href="/ospf-tools">
|
||||
Открыть Инструменты OSPF
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { IconDatabase, IconRefresh, IconRoute2 } from '@tabler/icons-react';
|
||||
import PageHeader from './components/PageHeader.jsx';
|
||||
import api from './lib/api.js';
|
||||
import { useNotify } from './components/NotifyProvider.jsx';
|
||||
|
||||
const OSPF_COST_STEP = 10;
|
||||
|
||||
function moveInArray(arr, fromIndex, toIndex) {
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex >= arr.length || toIndex >= arr.length) return arr;
|
||||
const copy = [...arr];
|
||||
const [moved] = copy.splice(fromIndex, 1);
|
||||
copy.splice(toIndex, 0, moved);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function isUnknownOspfInterfaceName(name) {
|
||||
const value = String(name || '').trim();
|
||||
if (!value) return true;
|
||||
if (value.startsWith('*')) return true;
|
||||
if (value.toLowerCase() === 'unknown') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function getOspfRouterIdentity(server) {
|
||||
if (!server || typeof server !== 'object') {
|
||||
return { key: 'unknown', label: 'Unknown router' };
|
||||
}
|
||||
const host = String(server.mikrotikHost || server.ip || server.dns || server.id || '').trim();
|
||||
const port = String(server.mikrotikPort || 80).trim();
|
||||
const user = String(server.mikrotikUser || 'admin').trim();
|
||||
const key = `${host}::${port}::${user}`;
|
||||
const label = host || String(server.dns || server.ip || server.id || 'Unknown router');
|
||||
return { key, label };
|
||||
}
|
||||
|
||||
function buildOspfTemplatesFromRouterResults(routerResults = [], servers = []) {
|
||||
const serverById = new Map((servers || []).map((s) => [String(s.id || s.ip || s.dns || ''), s]));
|
||||
const byComposite = new Map();
|
||||
|
||||
(routerResults || []).forEach((result) => {
|
||||
const serverId = String(result?.serverId || '').trim();
|
||||
if (!serverId) return;
|
||||
const server = serverById.get(serverId) || {
|
||||
id: serverId,
|
||||
ip: String(result?.serverLabel || serverId),
|
||||
dns: String(result?.serverLabel || ''),
|
||||
};
|
||||
const identity = getOspfRouterIdentity(server);
|
||||
const templates = Array.isArray(result?.templates) ? result.templates : [];
|
||||
|
||||
templates.forEach((tpl) => {
|
||||
const interfaceName = String(tpl?.interfaceName || '').trim();
|
||||
if (isUnknownOspfInterfaceName(interfaceName)) return;
|
||||
const area = String(tpl?.area || '').trim() || 'backbone';
|
||||
const numericCost = Number(tpl?.cost);
|
||||
const cost = Number.isFinite(numericCost) && numericCost >= 0 ? numericCost : OSPF_COST_STEP;
|
||||
|
||||
const compositeKey = `${identity.key}::${area.toLowerCase()}::${interfaceName.toUpperCase()}`;
|
||||
if (byComposite.has(compositeKey)) return;
|
||||
|
||||
byComposite.set(compositeKey, {
|
||||
key: compositeKey,
|
||||
rosId: tpl?.id || null,
|
||||
serverId,
|
||||
routerKey: identity.key,
|
||||
routerLabel: identity.label,
|
||||
interfaceName,
|
||||
area,
|
||||
cost,
|
||||
active: !Boolean(tpl?.disabled),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(byComposite.values());
|
||||
}
|
||||
|
||||
export default function OspfToolsPage() {
|
||||
const notify = useNotify();
|
||||
const [servers, setServers] = useState([]);
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dragging, setDragging] = useState(null);
|
||||
|
||||
const groupedByRouter = useMemo(() => {
|
||||
const grouped = {};
|
||||
(templates || []).forEach((item) => {
|
||||
const sid = String(item?.serverId || '');
|
||||
const server = servers.find((s) => String(s.id || s.ip || s.dns || '') === sid);
|
||||
const identity = getOspfRouterIdentity(server || { id: sid, ip: sid });
|
||||
if (!grouped[identity.key]) {
|
||||
grouped[identity.key] = {
|
||||
routerKey: identity.key,
|
||||
routerLabel: identity.label,
|
||||
representativeServerId: sid,
|
||||
areas: {},
|
||||
};
|
||||
}
|
||||
if (!grouped[identity.key].areas[item.area]) grouped[identity.key].areas[item.area] = [];
|
||||
grouped[identity.key].areas[item.area].push(item);
|
||||
});
|
||||
|
||||
return Object.values(grouped).map((group) => ({
|
||||
...group,
|
||||
areas: Object.entries(group.areas)
|
||||
.map(([area, areaItems]) => ({
|
||||
area,
|
||||
items: [...areaItems].sort((a, b) => Number(a?.cost || 0) - Number(b?.cost || 0)),
|
||||
}))
|
||||
.sort((a, b) => a.area.localeCompare(b.area)),
|
||||
}));
|
||||
}, [templates, servers]);
|
||||
|
||||
const loadServers = async () => {
|
||||
try {
|
||||
const response = await api.get('/servers');
|
||||
setServers(Array.isArray(response.data) ? response.data : []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load servers:', error);
|
||||
setServers([]);
|
||||
}
|
||||
};
|
||||
|
||||
const loadOspf = async ({ silent = false } = {}) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get('/mikrotik/ospf-interface-templates');
|
||||
const results = Array.isArray(response.data?.results) ? response.data.results : [];
|
||||
const errors = Array.isArray(response.data?.errors) ? response.data.errors : [];
|
||||
setTemplates(buildOspfTemplatesFromRouterResults(results, servers));
|
||||
|
||||
if (!silent) {
|
||||
if (results.length > 0) notify.success(`OSPF загружен: ${results.length} роутер(ов)`);
|
||||
else notify.warning('OSPF Interface Template не найдены');
|
||||
if (errors.length > 0) notify.warning(`Ошибки чтения OSPF: ${errors.length}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load OSPF:', error);
|
||||
if (!silent) notify.error('Не удалось загрузить OSPF с MikroTik');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadServers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (servers.length === 0) return;
|
||||
loadOspf({ silent: true });
|
||||
}, [servers.length]);
|
||||
|
||||
const onDrop = (routerKey, area, targetKey) => {
|
||||
if (!dragging || dragging.routerKey !== routerKey || dragging.area !== area) return;
|
||||
if (dragging.key === targetKey) {
|
||||
setDragging(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setTemplates((prev) => {
|
||||
const current = prev
|
||||
.filter((item) => item.routerKey === routerKey && String(item.area) === String(area))
|
||||
.sort((a, b) => Number(a?.cost || 0) - Number(b?.cost || 0));
|
||||
const fromIndex = current.findIndex((item) => item.key === dragging.key);
|
||||
const toIndex = current.findIndex((item) => item.key === targetKey);
|
||||
if (fromIndex < 0 || toIndex < 0) return prev;
|
||||
|
||||
const reordered = moveInArray(current, fromIndex, toIndex).map((item, idx) => ({
|
||||
...item,
|
||||
cost: (idx + 1) * OSPF_COST_STEP,
|
||||
}));
|
||||
|
||||
const other = prev.filter(
|
||||
(item) => !(item.routerKey === routerKey && String(item.area) === String(area))
|
||||
);
|
||||
return [...other, ...reordered];
|
||||
});
|
||||
setDragging(null);
|
||||
};
|
||||
|
||||
const saveOspf = async () => {
|
||||
if (groupedByRouter.length === 0) {
|
||||
notify.warning('Нет OSPF данных для сохранения');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
servers: groupedByRouter.map((group) => ({
|
||||
serverId: group.representativeServerId,
|
||||
templates: group.areas.flatMap((areaGroup) =>
|
||||
areaGroup.items.map((item) => ({
|
||||
rosId: item.rosId || null,
|
||||
interfaceName: item.interfaceName,
|
||||
area: item.area,
|
||||
cost: item.cost,
|
||||
}))
|
||||
),
|
||||
})),
|
||||
};
|
||||
|
||||
const response = await api.post('/mikrotik/ospf-interface-templates/apply', payload);
|
||||
const summary = response.data?.summary || {};
|
||||
notify.success(`OSPF сохранен: обновлено ${summary.updated || 0}, пропущено ${summary.skipped || 0}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to save OSPF:', error);
|
||||
notify.error('Не удалось сохранить OSPF в MikroTik');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ospf-tools-page">
|
||||
<PageHeader
|
||||
title="Инструменты OSPF"
|
||||
icon={<IconRoute2 size={24} />}
|
||||
actions={(
|
||||
<div className="btn-list">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => loadOspf()}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconRefresh size={16} className={`me-1 ${loading ? 'spin' : ''}`} />
|
||||
Загрузить из MikroTik
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={saveOspf}
|
||||
disabled={saving || groupedByRouter.length === 0}
|
||||
>
|
||||
<IconDatabase size={16} className={`me-1 ${saving ? 'spin' : ''}`} />
|
||||
Сохранить OSPF
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="text-muted small">
|
||||
Данные OSPF берутся напрямую с MikroTik. Группировка: роутер → Area → интерфейсы.
|
||||
Чем меньше cost, тем выше приоритет.
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{groupedByRouter.length === 0 ? (
|
||||
<div className="text-muted">OSPF Interface Template не найдены.</div>
|
||||
) : (
|
||||
<div className="row g-3">
|
||||
{groupedByRouter.map((routerGroup) => (
|
||||
<div key={routerGroup.routerKey} className="col-12 col-xl-6">
|
||||
<div className="card card-sm border">
|
||||
<div className="card-header fw-semibold">{routerGroup.routerLabel}</div>
|
||||
<div className="card-body p-0">
|
||||
{routerGroup.areas.map((areaGroup) => (
|
||||
<div key={`${routerGroup.routerKey}-${areaGroup.area}`} className="border-top">
|
||||
<div className="px-3 py-2 bg-light text-muted small fw-semibold">
|
||||
Area: {areaGroup.area}
|
||||
</div>
|
||||
<div className="list-group list-group-flush">
|
||||
{areaGroup.items.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="list-group-item d-flex align-items-center justify-content-between"
|
||||
draggable
|
||||
onDragStart={() => setDragging({
|
||||
routerKey: routerGroup.routerKey,
|
||||
area: areaGroup.area,
|
||||
key: item.key,
|
||||
})}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => onDrop(routerGroup.routerKey, areaGroup.area, item.key)}
|
||||
onDragEnd={() => setDragging(null)}
|
||||
style={{ cursor: 'grab' }}
|
||||
>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<span className="text-muted small">::</span>
|
||||
<code>{item.interfaceName}</code>
|
||||
<span className={`badge ${item.active ? 'bg-green-lt text-green' : 'bg-secondary-lt text-secondary'}`}>
|
||||
{item.active ? 'Активен' : 'Неактивен'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="badge bg-primary-lt text-primary">cost {item.cost}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user