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={
+
+
+ {pingLoading ? 'Пинг…' : 'Обновить пинг'}
+
+ }
+ />
+ {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() {
Если ни один не выбран — учитываются все интерфейсы.
-
-
Учитывать интерфейсы
+
+ Учитывать интерфейсы
setTrafficInterfaceNamesSelected([...trafficInterfacesList])}
- disabled={saving || trafficInterfacesLoading || trafficInterfacesList.length === 0}
+ onClick={() => setTrafficInterfaceNamesSelected([...trafficAllInterfaceNames])}
+ disabled={saving || trafficInterfacesLoading || trafficAllInterfaceNames.length === 0}
>
Выбрать все
@@ -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) => (
-
-
- {
- if (e.target.checked) {
- setTrafficInterfaceNamesSelected((prev) => [...prev, name].sort((a, b) => a.localeCompare(b)));
- } else {
- setTrafficInterfaceNamesSelected((prev) => prev.filter((n) => n !== name));
- }
- }}
- disabled={saving}
- aria-label={`Интерфейс ${name}`}
- />
- {name}
-
+ {trafficJumphosts.length > 0 && (
+
+ {trafficJumphosts.map((jh) => (
+
+
+
+
+
+
+
+
+ {jh.name}
+
+ {jh.host && (
+
+ {jh.host}
+
+ )}
+
+ {!jh.error && (jh.interfaces?.length ?? 0) > 0 && (
+
{
+ const names = (jh.interfaces || []).map((i) => i.name);
+ setTrafficInterfaceNamesSelected((prev) => {
+ const next = new Set(prev);
+ const allChecked = names.every((n) => next.has(n));
+ if (allChecked) names.forEach((n) => next.delete(n));
+ else names.forEach((n) => next.add(n));
+ return Array.from(next).sort((a, b) => a.localeCompare(b));
+ });
+ }}
+ disabled={saving}
+ title="Выбрать / снять все на этом сервере"
+ >
+ {(jh.interfaces || []).every((i) => trafficInterfaceNamesSelected.includes(i.name))
+ ? 'Снять все'
+ : 'Выбрать все'}
+
+ )}
+
+
+ {jh.error && (
+
+ {jh.error}
+
+ )}
+ {!jh.error && (!jh.interfaces || jh.interfaces.length === 0) && (
+
Нет интерфейсов
+ )}
+ {!jh.error && (jh.interfaces?.length ?? 0) > 0 && (
+
+ {jh.interfaces.map((iface) => (
+
+
+ {
+ if (e.target.checked) {
+ setTrafficInterfaceNamesSelected((prev) => [...prev, iface.name].sort((a, b) => a.localeCompare(b)));
+ } else {
+ setTrafficInterfaceNamesSelected((prev) => prev.filter((n) => n !== iface.name));
+ }
+ }}
+ disabled={saving}
+ aria-label={`Интерфейс ${iface.name}`}
+ />
+ {iface.name}
+
+
+ ))}
+
+ )}
+
+
))}
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'] },