feat(App, GraphView, SettingsPage, CommandPalette): add Network Map dashboard, enhance GraphView with ping data, and update SettingsPage for jumphost management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
This commit is contained in:
@@ -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 (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Карта сети"
|
||||
icon={<IconTopologyRing size={24} />}
|
||||
meta="Серверы и пинг между ними"
|
||||
/>
|
||||
<div className="text-center py-5 text-muted">Загрузка серверов и связей…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Карта сети"
|
||||
icon={<IconTopologyRing size={24} />}
|
||||
meta="Серверы и пинг между ними"
|
||||
/>
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Карта сети"
|
||||
icon={<IconTopologyRing size={24} />}
|
||||
meta="Серверы и пинг между ними (на рёбрах — задержка в мс)"
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
onClick={handleRefreshPings}
|
||||
disabled={pingLoading}
|
||||
>
|
||||
<IconRefresh className={pingLoading ? 'spin me-2' : 'me-2'} size={18} />
|
||||
{pingLoading ? 'Пинг…' : 'Обновить пинг'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{servers.length === 0 ? (
|
||||
<div className="alert alert-info">
|
||||
Нет серверов. Добавьте серверы и связи в разделе «Серверы».
|
||||
</div>
|
||||
) : (
|
||||
<GraphView
|
||||
servers={servers}
|
||||
connections={connections}
|
||||
pingMap={pingMap}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user