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

This commit is contained in:
2026-03-05 16:38:35 +07:00
parent 817f550f4d
commit 99f8c5d0bf
3 changed files with 320 additions and 374 deletions
+6 -2
View File
@@ -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 />} />
+7 -372
View File
@@ -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>
+307
View File
@@ -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>
);
}