From 5ede9e079deb0beefa96fc046bf4d8c7c0943eb9 Mon Sep 17 00:00:00 2001 From: shats Date: Tue, 17 Feb 2026 16:06:16 +0700 Subject: [PATCH] feat(App, GraphView, SettingsPage, CommandPalette): add Network Map dashboard, enhance GraphView with ping data, and update SettingsPage for jumphost management --- frontend/src/App.jsx | 10 +- frontend/src/GraphView.jsx | 24 ++- frontend/src/NetworkMapDashboard.jsx | 185 +++++++++++++++++++++ frontend/src/SettingsPage.jsx | 158 +++++++++++++----- frontend/src/components/CommandPalette.jsx | 1 + 5 files changed, 328 insertions(+), 50 deletions(-) create mode 100644 frontend/src/NetworkMapDashboard.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 81fd3b7..5e1e733 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -38,6 +38,7 @@ import NetworkConfigManager from './NetworkConfigManager'; import MikrotikTools from './MikrotikTools.jsx'; import Dashboard from './Dashboard'; import TrafficDashboard from './TrafficDashboard.jsx'; +import NetworkMapDashboard from './NetworkMapDashboard.jsx'; import MikrotikBackupsManager from './MikrotikBackupsManager.jsx'; import PingServicesManager from './PingServicesManager.jsx'; import SettingsPage from './SettingsPage.jsx'; @@ -58,7 +59,7 @@ function LanguageProvider({ children }) { const dict = { ru: { home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты', - dashboard: 'Панель', trafficTraffic: 'Расход трафика', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS', + dashboard: 'Панель', trafficTraffic: 'Расход трафика', networkMap: 'Карта сети', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS', communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL', easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', light: 'Светлая', dark: 'Тёмная', @@ -66,7 +67,7 @@ function LanguageProvider({ children }) { }, en: { home: 'Home', data: 'Data', management: 'Management', tools: 'Tools', - dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs', + dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', networkMap: 'Network Map', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs', communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs', easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', light: 'Light', dark: 'Dark', @@ -192,7 +193,8 @@ function MainLayout() { icon: IconHome, items: [ { id: 'dashboard', title: t('dashboard'), path: '/dashboard', icon: IconHome }, - { id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie } + { id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie }, + { id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork } ] }, { @@ -380,6 +382,7 @@ function MainLayout() { } /> } /> + } /> } /> } /> } /> @@ -537,6 +540,7 @@ function MainLayout() { } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/GraphView.jsx b/frontend/src/GraphView.jsx index 913befb..08eea2c 100644 --- a/frontend/src/GraphView.jsx +++ b/frontend/src/GraphView.jsx @@ -14,7 +14,10 @@ import '@xyflow/react/dist/style.css'; import { IconZoomIn, IconZoomOut, IconMaximize, IconMinimize, IconSearch, IconX, IconRefresh } from '@tabler/icons-react'; import Tooltip from './components/Tooltip.jsx'; -function GraphView({ servers, connections, onCreateConnection }) { +/** + * @param {Object} [pingMap] - опционально: ключ "fromIp:toIp" -> число (мс). Пинг между серверами для подписи на рёбрах. + */ +function GraphView({ servers, connections, onCreateConnection, pingMap }) { const flowRef = useRef(null); const instanceRef = useRef(null); const containerRef = useRef(null); @@ -144,17 +147,32 @@ function GraphView({ servers, connections, onCreateConnection }) { }, [servers, connections]); const initialEdgesData = useMemo(() => { + const getPingLabel = (fromIp, toIp) => { + if (!pingMap || typeof pingMap !== 'object') return ''; + const fwd = pingMap[`${fromIp}:${toIp}`]; + const rev = pingMap[`${toIp}:${fromIp}`]; + const parts = []; + if (typeof fwd === 'number') parts.push(`→ ${fwd} ms`); + if (typeof rev === 'number') parts.push(`← ${rev} ms`); + if (parts.length === 0) return ''; + return parts.join(' · '); + }; return (connections || []).map((c, idx) => { const style = getTunnelStyle(c.tunnelType); const baseLabel = c.tunnelType || 'TUNNEL'; const ipLabel = c.ipA && c.ipB ? `${c.ipA} ⇄ ${c.ipB}` : ''; + const pingLabel = getPingLabel(String(c.from), String(c.to)); + const labelParts = [baseLabel]; + if (ipLabel) labelParts.push(ipLabel); + if (pingLabel) labelParts.push(pingLabel); + const fullLabel = labelParts.join(' · '); const edgeId = `${c.from}-${c.to}-${idx}`; const isHighlighted = highlightedEdges.has(edgeId); return { id: edgeId, source: String(c.from), target: String(c.to), - label: ipLabel ? `${baseLabel} · ${ipLabel}` : baseLabel, + label: fullLabel, type: 'smoothstep', style: { stroke: style.color, @@ -176,7 +194,7 @@ function GraphView({ servers, connections, onCreateConnection }) { animated: false, }; }); - }, [connections, getTunnelStyle, highlightedEdges, highlightedNodeId, selectedNodeId]); + }, [connections, getTunnelStyle, highlightedEdges, highlightedNodeId, selectedNodeId, pingMap]); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdgesData); diff --git a/frontend/src/NetworkMapDashboard.jsx b/frontend/src/NetworkMapDashboard.jsx new file mode 100644 index 0000000..e422d48 --- /dev/null +++ b/frontend/src/NetworkMapDashboard.jsx @@ -0,0 +1,185 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import api from './lib/api.js'; +import { IconRefresh, IconTopologyRing } from '@tabler/icons-react'; +import PageHeader from './components/PageHeader.jsx'; +import GraphView from './GraphView.jsx'; + +/** Параллельно выполняем промисы с лимитом одновременных */ +async function runWithLimit(tasks, limit = 4) { + const results = []; + let index = 0; + async function runNext() { + const i = index++; + if (i >= tasks.length) return; + const task = tasks[i]; + try { + const value = await task(); + results[i] = { value }; + } catch (err) { + results[i] = { error: err }; + } + await runNext(); + } + const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => runNext()); + await Promise.all(workers); + return results; +} + +export default function NetworkMapDashboard() { + const [servers, setServers] = useState([]); + const [connections, setConnections] = useState([]); + const [pingMap, setPingMap] = useState({}); + const [loading, setLoading] = useState(true); + const [pingLoading, setPingLoading] = useState(false); + const [error, setError] = useState(null); + const pingAbortRef = useRef(false); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [serversRes, connRes] = await Promise.all([ + api.get('/servers'), + api.get('/server-connections'), + ]); + const serversList = Array.isArray(serversRes?.data) ? serversRes.data : []; + const connList = Array.isArray(connRes?.data) ? connRes.data : []; + setServers(serversList); + setConnections(connList); + } catch (e) { + console.error('NetworkMap fetch:', e); + setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить данные'); + setServers([]); + setConnections([]); + } finally { + setLoading(false); + } + }, []); + + const requestPings = useCallback(async () => { + if (connections.length === 0) return; + pingAbortRef.current = false; + setPingLoading(true); + const key = (a, b) => `${a}:${b}`; + const newMap = {}; + + const tasks = []; + connections.forEach((c) => { + const from = String(c.from); + const to = String(c.to); + if (!from || !to || from === to) return; + tasks.push(async () => { + if (pingAbortRef.current) return; + try { + const { data } = await api.post('/mikrotik/ping', { + serverId: from, + target: to, + count: 3, + }); + const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null; + return { key: key(from, to), ms }; + } catch { + return { key: key(from, to), ms: null }; + } + }); + tasks.push(async () => { + if (pingAbortRef.current) return; + try { + const { data } = await api.post('/mikrotik/ping', { + serverId: to, + target: from, + count: 3, + }); + const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null; + return { key: key(to, from), ms }; + } catch { + return { key: key(to, from), ms: null }; + } + }); + }); + + const results = await runWithLimit(tasks, 4); + if (pingAbortRef.current) return; + results.forEach((r) => { + const v = r?.value; + if (v && v.key != null) newMap[v.key] = v.ms ?? null; + }); + setPingMap((prev) => ({ ...prev, ...newMap })); + setPingLoading(false); + }, [connections]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + useEffect(() => { + if (!loading && servers.length > 0 && connections.length > 0) { + requestPings(); + } + return () => { + pingAbortRef.current = true; + }; + }, [loading, connections.length]); + + const handleRefreshPings = () => { + requestPings(); + }; + + if (loading) { + return ( +
+ } + meta="Серверы и пинг между ними" + /> +
Загрузка серверов и связей…
+
+ ); + } + + if (error) { + return ( +
+ } + meta="Серверы и пинг между ними" + /> +
{error}
+
+ ); + } + + return ( +
+ } + meta="Серверы и пинг между ними (на рёбрах — задержка в мс)" + actions={ + + } + /> + {servers.length === 0 ? ( +
+ Нет серверов. Добавьте серверы и связи в разделе «Серверы». +
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/src/SettingsPage.jsx b/frontend/src/SettingsPage.jsx index 3b04508..e0670b2 100644 --- a/frontend/src/SettingsPage.jsx +++ b/frontend/src/SettingsPage.jsx @@ -12,6 +12,7 @@ import { IconSearch, IconChartBar, IconRefresh, + IconServer, } from '@tabler/icons-react'; import FormField from './components/FormField'; import ErrorAlert from './components/ErrorAlert'; @@ -74,7 +75,7 @@ export default function SettingsPage() { const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState(''); const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState(''); const [trafficInterfaceNamesSelected, setTrafficInterfaceNamesSelected] = useState([]); - const [trafficInterfacesList, setTrafficInterfacesList] = useState([]); + const [trafficJumphosts, setTrafficJumphosts] = useState([]); const [trafficInterfacesLoading, setTrafficInterfacesLoading] = useState(false); const [trafficInterfacesError, setTrafficInterfacesError] = useState(''); const [serversList, setServersList] = useState([]); @@ -118,29 +119,41 @@ export default function SettingsPage() { try { const { data } = await api.get('/traffic/interface-stats'); const jumphosts = Array.isArray(data?.jumphosts) ? data.jumphosts : []; - const namesSet = new Set(); - for (const jh of jumphosts) { - if (Array.isArray(jh.interfaces)) { - for (const i of jh.interfaces) { - if (i?.name != null && String(i.name).trim()) { - namesSet.add(String(i.name).trim()); - } - } - } - } - setTrafficInterfacesList(Array.from(namesSet).sort((a, b) => a.localeCompare(b))); + const normalized = jumphosts.map((jh) => { + const interfaces = Array.isArray(jh.interfaces) ? jh.interfaces : []; + const names = interfaces + .filter((i) => i?.name != null && String(i.name).trim()) + .map((i) => ({ ...i, name: String(i.name).trim() })) + .sort((a, b) => a.name.localeCompare(b.name)); + return { + serverId: jh.serverId, + name: jh.name || jh.host || 'Jumphost', + host: jh.host, + error: jh.error, + interfaces: names, + }; + }); + setTrafficJumphosts(normalized); } catch (e) { setTrafficInterfacesError(e?.response?.data?.message || e?.message || 'Не удалось загрузить список интерфейсов'); - setTrafficInterfacesList([]); + setTrafficJumphosts([]); } finally { setTrafficInterfacesLoading(false); } }, []); useEffect(() => { - if (activeSection !== 'traffic-interfaces' || trafficInterfacesList.length > 0) return; + if (activeSection !== 'traffic-interfaces' || trafficJumphosts.length > 0) return; fetchTrafficInterfaces(); - }, [activeSection, trafficInterfacesList.length, fetchTrafficInterfaces]); + }, [activeSection, trafficJumphosts.length, fetchTrafficInterfaces]); + + const trafficAllInterfaceNames = useMemo(() => { + const set = new Set(); + for (const jh of trafficJumphosts) { + for (const i of jh.interfaces || []) set.add(i.name); + } + return Array.from(set).sort((a, b) => a.localeCompare(b)); + }, [trafficJumphosts]); const goToSection = (id) => { setActiveSection(id); @@ -592,14 +605,14 @@ export default function SettingsPage() { Если ни один не выбран — учитываются все интерфейсы.

-
- Учитывать интерфейсы +
+ Учитывать интерфейсы @@ -624,42 +637,99 @@ export default function SettingsPage() {
{trafficInterfacesError && ( -
+
{trafficInterfacesError}
)} - {trafficInterfacesLoading && trafficInterfacesList.length === 0 && ( + {trafficInterfacesLoading && trafficJumphosts.length === 0 && (
)} - {!trafficInterfacesLoading && trafficInterfacesList.length === 0 && !trafficInterfacesError && ( + {!trafficInterfacesLoading && trafficJumphosts.length === 0 && !trafficInterfacesError && (
- Нет доступных интерфейсов. Добавьте jumphost-серверы с MikroTik API и нажмите «Обновить». + Нет доступных серверов. Добавьте jumphost-серверы с MikroTik API и нажмите «Обновить».
)} - {trafficInterfacesList.length > 0 && ( -
- {trafficInterfacesList.map((name) => ( -
- + {trafficJumphosts.length > 0 && ( +
+ {trafficJumphosts.map((jh) => ( +
+
+
+ + + +
+

+ {jh.name} +

+ {jh.host && ( +
+ {jh.host} +
+ )} +
+ {!jh.error && (jh.interfaces?.length ?? 0) > 0 && ( + + )} +
+
+ {jh.error && ( +
+ {jh.error} +
+ )} + {!jh.error && (!jh.interfaces || jh.interfaces.length === 0) && ( +
Нет интерфейсов
+ )} + {!jh.error && (jh.interfaces?.length ?? 0) > 0 && ( +
+ {jh.interfaces.map((iface) => ( +
+ +
+ ))} +
+ )} +
+
))}
diff --git a/frontend/src/components/CommandPalette.jsx b/frontend/src/components/CommandPalette.jsx index c522c7f..6169569 100644 --- a/frontend/src/components/CommandPalette.jsx +++ b/frontend/src/components/CommandPalette.jsx @@ -41,6 +41,7 @@ function CommandPalette() { const commands = [ { icon: IconHome, label: 'Главная', description: 'Панель управления', action: () => navigate('/dashboard'), keywords: ['главная', 'панель', 'dashboard'] }, { icon: IconChartPie, label: 'Расход трафика', description: 'Статистика по интерфейсам MikroTik', action: () => navigate('/traffic'), keywords: ['трафик', 'traffic', 'mikrotik', 'интерфейсы'] }, + { icon: IconNetwork, label: 'Карта сети', description: 'Граф серверов и пинг между ними', action: () => navigate('/network-map'), keywords: ['карта', 'сеть', 'network', 'map', 'пинг', 'ping'] }, { icon: IconWorld, label: 'Домены', description: 'Управление доменами', action: () => navigate('/domains'), keywords: ['домены', 'domains'] }, { icon: IconNetwork, label: 'IP-диапазоны', description: 'Управление IP диапазонами', action: () => navigate('/ip-ranges'), keywords: ['ip', 'диапазоны', 'ranges'] }, { icon: IconNetwork, label: 'ASN', description: 'Управление Autonomous Systems', action: () => navigate('/asns'), keywords: ['asn', 'as', 'autonomous'] },