501 lines
20 KiB
React
501 lines
20 KiB
React
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||
import { IconDatabase, IconRefresh, IconRoute2, IconWand } 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: !tpl?.disabled,
|
||
});
|
||
});
|
||
});
|
||
|
||
return Array.from(byComposite.values());
|
||
}
|
||
|
||
function buildOptimizerProbabilityMap(routeOptimizerData, servers = []) {
|
||
const homes = Array.isArray(routeOptimizerData?.homes) ? routeOptimizerData.homes : [];
|
||
const byRef = new Map();
|
||
(servers || []).forEach((server) => {
|
||
[server?.id, server?.ip, server?.dns]
|
||
.filter(Boolean)
|
||
.forEach((ref) => byRef.set(String(ref), server));
|
||
});
|
||
|
||
const probabilities = {};
|
||
|
||
homes.forEach((entry) => {
|
||
const homeRefCandidates = [
|
||
entry?.home?.id,
|
||
entry?.home?.ip,
|
||
entry?.home?.dns,
|
||
].filter(Boolean).map(String);
|
||
const homeServer = homeRefCandidates.map((ref) => byRef.get(ref)).find(Boolean);
|
||
if (!homeServer) return;
|
||
|
||
const identity = getOspfRouterIdentity(homeServer);
|
||
const candidates = Array.isArray(entry?.homeToJumphostCandidates)
|
||
? [...entry.homeToJumphostCandidates]
|
||
: [];
|
||
candidates.sort((a, b) =>
|
||
Number(b?.probabilityOptimal || 0) - Number(a?.probabilityOptimal || 0) ||
|
||
Number(b?.score || 0) - Number(a?.score || 0)
|
||
);
|
||
|
||
const uniqueIface = [];
|
||
const seen = new Set();
|
||
candidates.forEach((candidate) => {
|
||
const iface = String(candidate?.interfaceName || '').trim();
|
||
if (!iface) return;
|
||
const key = iface.toUpperCase();
|
||
if (seen.has(key)) return;
|
||
seen.add(key);
|
||
uniqueIface.push(candidate);
|
||
});
|
||
|
||
uniqueIface.forEach((candidate, _idx) => {
|
||
const iface = String(candidate?.interfaceName || '').trim();
|
||
const hintKey = `${identity.key}::${iface.toUpperCase()}`;
|
||
const probabilityOptimal = Number(candidate?.probabilityOptimal || 0);
|
||
const prev = probabilities[hintKey];
|
||
if (prev == null || probabilityOptimal > prev) {
|
||
probabilities[hintKey] = probabilityOptimal;
|
||
}
|
||
});
|
||
});
|
||
|
||
return probabilities;
|
||
}
|
||
|
||
export default function OspfToolsPage() {
|
||
const notify = useNotify();
|
||
const [servers, setServers] = useState([]);
|
||
const [templates, setTemplates] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [optimizerLoading, setOptimizerLoading] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [dragging, setDragging] = useState(null);
|
||
const [optimizerProbabilities, setOptimizerProbabilities] = useState({});
|
||
|
||
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 optimalHints = useMemo(() => {
|
||
const hints = {};
|
||
|
||
groupedByRouter.forEach((routerGroup) => {
|
||
(routerGroup.areas || []).forEach((areaGroup) => {
|
||
const ranked = [...(areaGroup.items || [])]
|
||
.map((item) => {
|
||
const ifaceKey = `${routerGroup.routerKey}::${String(item.interfaceName || '').toUpperCase()}`;
|
||
const probabilityOptimal = Number(optimizerProbabilities[ifaceKey] || 0);
|
||
return { item, probabilityOptimal };
|
||
})
|
||
.sort((a, b) =>
|
||
Number(b.probabilityOptimal || 0) - Number(a.probabilityOptimal || 0) ||
|
||
String(a.item.interfaceName || '').localeCompare(String(b.item.interfaceName || ''))
|
||
);
|
||
|
||
ranked.forEach(({ item, probabilityOptimal }, idx) => {
|
||
const hintKey = `${routerGroup.routerKey}::${areaGroup.area}::${String(item.interfaceName || '').toUpperCase()}`;
|
||
hints[hintKey] = {
|
||
optimalCost: (idx + 1) * OSPF_COST_STEP,
|
||
probabilityOptimal,
|
||
};
|
||
});
|
||
});
|
||
});
|
||
|
||
return hints;
|
||
}, [groupedByRouter, optimizerProbabilities]);
|
||
const routerColumnClass = groupedByRouter.length > 1 ? 'col-12 col-xxl-6' : 'col-12';
|
||
|
||
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 = useCallback(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);
|
||
}
|
||
}, [servers, notify]);
|
||
|
||
const loadOptimizerHints = useCallback(async ({ silent = false } = {}) => {
|
||
setOptimizerLoading(true);
|
||
try {
|
||
const response = await api.get('/route-optimizer', { timeout: 30000 });
|
||
const probabilities = buildOptimizerProbabilityMap(response?.data, servers);
|
||
setOptimizerProbabilities(probabilities);
|
||
return probabilities;
|
||
} catch (error) {
|
||
console.error('Failed to load route optimizer hints:', error);
|
||
setOptimizerProbabilities({});
|
||
if (!silent) notify.warning('Не удалось загрузить рекомендации из оптимизатора маршрутов');
|
||
return {};
|
||
} finally {
|
||
setOptimizerLoading(false);
|
||
}
|
||
}, [servers, notify]);
|
||
|
||
useEffect(() => {
|
||
loadServers();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (servers.length === 0) return;
|
||
loadOspf({ silent: true });
|
||
loadOptimizerHints({ silent: true });
|
||
}, [servers.length, loadOspf, loadOptimizerHints]);
|
||
|
||
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 optimizeCosts = async () => {
|
||
let hints = optimalHints;
|
||
if (!hints || Object.keys(hints).length === 0) {
|
||
const probabilities = await loadOptimizerHints({ silent: true });
|
||
const refreshedHints = {};
|
||
groupedByRouter.forEach((routerGroup) => {
|
||
(routerGroup.areas || []).forEach((areaGroup) => {
|
||
const ranked = [...(areaGroup.items || [])]
|
||
.map((item) => {
|
||
const ifaceKey = `${routerGroup.routerKey}::${String(item.interfaceName || '').toUpperCase()}`;
|
||
const probabilityOptimal = Number((probabilities || {})[ifaceKey] || 0);
|
||
return { item, probabilityOptimal };
|
||
})
|
||
.sort((a, b) =>
|
||
Number(b.probabilityOptimal || 0) - Number(a.probabilityOptimal || 0) ||
|
||
String(a.item.interfaceName || '').localeCompare(String(b.item.interfaceName || ''))
|
||
);
|
||
ranked.forEach(({ item, probabilityOptimal }, idx) => {
|
||
const hintKey = `${routerGroup.routerKey}::${areaGroup.area}::${String(item.interfaceName || '').toUpperCase()}`;
|
||
refreshedHints[hintKey] = {
|
||
optimalCost: (idx + 1) * OSPF_COST_STEP,
|
||
probabilityOptimal,
|
||
};
|
||
});
|
||
});
|
||
});
|
||
hints = refreshedHints;
|
||
}
|
||
|
||
if (!hints || Object.keys(hints).length === 0) {
|
||
notify.warning('Нет рекомендаций оптимизатора для расчета cost');
|
||
return;
|
||
}
|
||
|
||
let changedCount = 0;
|
||
let hintedCount = 0;
|
||
const nextTemplates = (templates || []).map((item) => {
|
||
const hintKey = `${item.routerKey}::${item.area}::${String(item.interfaceName || '').toUpperCase()}`;
|
||
const hint = hints[hintKey];
|
||
if (!hint) return item;
|
||
hintedCount += 1;
|
||
const nextCost = Number(hint.optimalCost);
|
||
if (!Number.isFinite(nextCost) || nextCost < 0 || Number(item.cost) === nextCost) return item;
|
||
changedCount += 1;
|
||
return { ...item, cost: nextCost };
|
||
});
|
||
setTemplates(nextTemplates);
|
||
|
||
if (hintedCount === 0) {
|
||
notify.warning('Для интерфейсов на странице нет рекомендаций оптимизатора');
|
||
return;
|
||
}
|
||
if (changedCount === 0) {
|
||
notify.success('Текущие cost уже соответствуют оптимальным рекомендациям');
|
||
return;
|
||
}
|
||
notify.success(`Оптимизация применена: обновлено cost у ${changedCount} интерфейсов`);
|
||
};
|
||
|
||
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();
|
||
loadOptimizerHints({ silent: true });
|
||
}}
|
||
disabled={loading || optimizerLoading}
|
||
>
|
||
<IconRefresh size={16} className={`me-1 ${(loading || optimizerLoading) ? 'spin' : ''}`} />
|
||
Загрузить из MikroTik
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-outline-primary"
|
||
onClick={optimizeCosts}
|
||
disabled={loading || optimizerLoading || groupedByRouter.length === 0}
|
||
title="Проставить интерфейсам потенциально оптимальный cost"
|
||
>
|
||
<IconWand size={16} className="me-1" />
|
||
Оптимизировать
|
||
</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, тем выше приоритет. Рядом отображается потенциально оптимальный 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={routerColumnClass}>
|
||
<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) => (
|
||
(() => {
|
||
const hint = optimalHints[`${routerGroup.routerKey}::${areaGroup.area}::${String(item.interfaceName || '').toUpperCase()}`];
|
||
const hintMatches = hint && Number(hint.optimalCost) === Number(item.cost);
|
||
return (
|
||
<div
|
||
key={item.key}
|
||
className="list-group-item"
|
||
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 justify-content-between gap-2 flex-wrap">
|
||
<div className="d-flex align-items-center gap-2 flex-wrap">
|
||
<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>
|
||
<div className="d-flex align-items-center gap-2 ms-auto">
|
||
{hint && (
|
||
<span
|
||
className={`badge ${hintMatches ? 'bg-green-lt text-green' : 'bg-yellow-lt text-yellow'}`}
|
||
title={`Вероятность оптимальности: ${hint.probabilityOptimal}%`}
|
||
>
|
||
opt cost {hint.optimalCost}
|
||
</span>
|
||
)}
|
||
<span className="badge bg-primary-lt text-primary">cost {item.cost}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|