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 SettingsPage from './SettingsPage.jsx';
|
||||||
import ResourceStatsPage from './ResourceStatsPage.jsx';
|
import ResourceStatsPage from './ResourceStatsPage.jsx';
|
||||||
import RouteOptimizerPage from './RouteOptimizerPage.jsx';
|
import RouteOptimizerPage from './RouteOptimizerPage.jsx';
|
||||||
|
import OspfToolsPage from './OspfToolsPage.jsx';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { NotifyProvider } from './components/NotifyProvider.jsx';
|
import { NotifyProvider } from './components/NotifyProvider.jsx';
|
||||||
import ToastContainer from './components/ToastContainer.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',
|
dashboard: 'Панель', trafficTraffic: 'Расход трафика', resourceStats: 'Статистика ресурсов', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
|
||||||
routeOptimizer: 'Оптимизация маршрутов ИИ',
|
routeOptimizer: 'Оптимизация маршрутов ИИ',
|
||||||
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
|
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: 'Тёмная',
|
light: 'Светлая', dark: 'Тёмная',
|
||||||
layoutSidebar: 'Сайдбар', layoutHorizontal: 'Верхнее меню'
|
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',
|
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',
|
routeOptimizer: 'AI Route Optimizer',
|
||||||
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
|
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',
|
light: 'Light', dark: 'Dark',
|
||||||
layoutSidebar: 'Sidebar', layoutHorizontal: 'Top menu'
|
layoutSidebar: 'Sidebar', layoutHorizontal: 'Top menu'
|
||||||
}
|
}
|
||||||
@@ -289,6 +290,7 @@ function MainLayout() {
|
|||||||
items: [
|
items: [
|
||||||
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload },
|
{ id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload },
|
||||||
{ id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase },
|
{ 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: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork },
|
||||||
{ id: 'firewall', title: t('firewall'), path: '/firewall', icon: IconShield },
|
{ id: 'firewall', title: t('firewall'), path: '/firewall', icon: IconShield },
|
||||||
{ id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork },
|
{ id: 'interface-speed', title: 'Скорость интерфейсов', path: '/interface-speed', icon: IconNetwork },
|
||||||
@@ -466,6 +468,7 @@ function MainLayout() {
|
|||||||
<Route path="/network-config" element={<NetworkConfigManager />} />
|
<Route path="/network-config" element={<NetworkConfigManager />} />
|
||||||
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
||||||
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
||||||
|
<Route path="/ospf-tools" element={<OspfToolsPage />} />
|
||||||
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||||
<Route path="/firewall" element={<FirewallPage />} />
|
<Route path="/firewall" element={<FirewallPage />} />
|
||||||
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
||||||
@@ -643,6 +646,7 @@ function MainLayout() {
|
|||||||
<Route path="/network-config" element={<NetworkConfigManager />} />
|
<Route path="/network-config" element={<NetworkConfigManager />} />
|
||||||
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
||||||
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
||||||
|
<Route path="/ospf-tools" element={<OspfToolsPage />} />
|
||||||
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||||
<Route path="/firewall" element={<FirewallPage />} />
|
<Route path="/firewall" element={<FirewallPage />} />
|
||||||
<Route path="/interface-speed" element={<InterfaceSpeedTest />} />
|
<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 api from './lib/api.js';
|
||||||
import { useNotify } from './components/NotifyProvider.jsx';
|
import { useNotify } from './components/NotifyProvider.jsx';
|
||||||
import FormModal from './components/FormModal.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 = () => ({
|
const getEmptyGateway = () => ({
|
||||||
id: `gw-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
|
id: `gw-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
|
||||||
@@ -235,7 +163,6 @@ const getDefaultConfig = () => ({
|
|||||||
gateways: [],
|
gateways: [],
|
||||||
tunnelInterfaces: [],
|
tunnelInterfaces: [],
|
||||||
ipPools: [],
|
ipPools: [],
|
||||||
ospfInterfaceTemplates: [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function NetworkConfigManager() {
|
function NetworkConfigManager() {
|
||||||
@@ -295,10 +222,6 @@ function NetworkConfigManager() {
|
|||||||
const [templateMtu, setTemplateMtu] = useState(''); // MTU для шаблона
|
const [templateMtu, setTemplateMtu] = useState(''); // MTU для шаблона
|
||||||
const [templateTunnelCount, setTemplateTunnelCount] = useState(1); // Количество туннелей для создания
|
const [templateTunnelCount, setTemplateTunnelCount] = useState(1); // Количество туннелей для создания
|
||||||
const [templateServerPairs, setTemplateServerPairs] = useState([]); // Массив пар серверов для множественного создания
|
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 ===
|
// === IPSec Passwords Management ===
|
||||||
const [ipsecPasswords, setIpsecPasswords] = useState([]);
|
const [ipsecPasswords, setIpsecPasswords] = useState([]);
|
||||||
@@ -362,7 +285,6 @@ function NetworkConfigManager() {
|
|||||||
gateways: Array.isArray(migratedGateways) ? migratedGateways : [],
|
gateways: Array.isArray(migratedGateways) ? migratedGateways : [],
|
||||||
tunnelInterfaces: Array.isArray(data.tunnelInterfaces) ? data.tunnelInterfaces : [],
|
tunnelInterfaces: Array.isArray(data.tunnelInterfaces) ? data.tunnelInterfaces : [],
|
||||||
ipPools: Array.isArray(data.ipPools) ? data.ipPools : [],
|
ipPools: Array.isArray(data.ipPools) ? data.ipPools : [],
|
||||||
ospfInterfaceTemplates: Array.isArray(data.ospfInterfaceTemplates) ? data.ospfInterfaceTemplates : [],
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response?.status !== 404) {
|
if (error.response?.status !== 404) {
|
||||||
@@ -559,195 +481,6 @@ function NetworkConfigManager() {
|
|||||||
return result;
|
return result;
|
||||||
}, [config.ipPools, serverFilter, searchTerm, servers]);
|
}, [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 gatewaysByServer = useMemo(() => {
|
||||||
const grouped = {};
|
const grouped = {};
|
||||||
@@ -3933,111 +3666,13 @@ function NetworkConfigManager() {
|
|||||||
{activeTab === 'interfaces' && (
|
{activeTab === 'interfaces' && (
|
||||||
<>
|
<>
|
||||||
<div className="card mb-3">
|
<div className="card mb-3">
|
||||||
<div className="card-header d-flex justify-content-between align-items-center">
|
<div className="card-body d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||||
<div>
|
<div className="text-muted small">
|
||||||
<h3 className="card-title mb-0">OSPF Interface Template (HOME)</h3>
|
Настройки OSPF вынесены в отдельный раздел проекта.
|
||||||
<div className="text-muted small">
|
|
||||||
Только для HOME роутеров. Источник данных — MikroTik, группировка по Area. Чем меньше cost, тем выше приоритет.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex align-items-center gap-2">
|
<a className="btn btn-outline-primary btn-sm" href="/ospf-tools">
|
||||||
<span className="badge bg-blue-lt text-blue">
|
Открыть Инструменты OSPF
|
||||||
{config.ospfInterfaceTemplates?.length || 0}
|
</a>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</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