diff --git a/frontend/src/NetworkMapDashboard.jsx b/frontend/src/NetworkMapDashboard.jsx index 4a5c55b..7c243a9 100644 --- a/frontend/src/NetworkMapDashboard.jsx +++ b/frontend/src/NetworkMapDashboard.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import api from './lib/api.js'; -import { IconRefresh, IconTopologyRing } from '@tabler/icons-react'; +import { IconRefresh, IconTopologyRing, IconClock } from '@tabler/icons-react'; import PageHeader from './components/PageHeader.jsx'; import NetworkMapUnifi from './NetworkMapUnifi.jsx'; @@ -70,7 +70,11 @@ export default function NetworkMapDashboard() { const [connections, setConnections] = useState([]); const [pingMap, setPingMap] = useState({}); const [speedMap, setSpeedMap] = useState({}); + const [pingStaleMap, setPingStaleMap] = useState({}); + const [speedStaleMap, setSpeedStaleMap] = useState({}); const pingTimestampsRef = useRef({}); + const lastSuccessfulPingRef = useRef({}); + const lastSuccessfulSpeedRef = useRef({}); const [pingCacheSeconds, setPingCacheSeconds] = useState(0); const [loading, setLoading] = useState(true); const [pingLoading, setPingLoading] = useState(false); @@ -140,14 +144,31 @@ export default function NetworkMapDashboard() { Date.now() - cache.updatedAt < cacheMaxAgeMs && (Object.keys(cache.pingMap || {}).length > 0 || Object.keys(cache.speedMap || {}).length > 0); if (cacheValid) { - setPingMap(cache.pingMap || {}); - setSpeedMap(cache.speedMap || {}); - Object.keys(cache.pingMap || {}).forEach((k) => { + const cachedPingMap = cache.pingMap || {}; + const cachedSpeedMap = cache.speedMap || {}; + setPingMap(cachedPingMap); + setSpeedMap(cachedSpeedMap); + // Сохраняем успешные значения из кеша + Object.keys(cachedPingMap).forEach((k) => { + if (typeof cachedPingMap[k] === 'number') { + lastSuccessfulPingRef.current[k] = cachedPingMap[k]; + } pingTimestampsRef.current[k] = cache.updatedAt; }); + Object.keys(cachedSpeedMap).forEach((k) => { + if (cachedSpeedMap[k] && typeof cachedSpeedMap[k] === 'object') { + lastSuccessfulSpeedRef.current[k] = cachedSpeedMap[k]; + } + }); cacheAppliedAtRef.current = cache.updatedAt; + // Сбрасываем флаги устаревших данных при загрузке валидного кеша + setPingStaleMap({}); + setSpeedStaleMap({}); } else { setSpeedMap({}); + // При невалидном кеше также сбрасываем флаги + setPingStaleMap({}); + setSpeedStaleMap({}); } } catch (e) { console.error('NetworkMap fetch:', e); @@ -197,11 +218,23 @@ export default function NetworkMapDashboard() { count: 3, }); const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null; - pingTimestampsRef.current[ekey] = Date.now(); - setPingMap((prev) => ({ ...prev, [ekey]: ms })); + if (ms != null) { + pingTimestampsRef.current[ekey] = Date.now(); + lastSuccessfulPingRef.current[ekey] = ms; + setPingMap((prev) => ({ ...prev, [ekey]: ms })); + setPingStaleMap((prev) => ({ ...prev, [ekey]: false })); + } } catch { pingTimestampsRef.current[ekey] = Date.now(); - setPingMap((prev) => ({ ...prev, [ekey]: null })); + // При ошибке сохраняем последнее успешное значение с пометкой устаревшего + const lastSuccess = lastSuccessfulPingRef.current[ekey]; + if (lastSuccess != null) { + setPingMap((prev) => ({ ...prev, [ekey]: lastSuccess })); + setPingStaleMap((prev) => ({ ...prev, [ekey]: true })); + } else { + setPingMap((prev) => ({ ...prev, [ekey]: null })); + setPingStaleMap((prev) => ({ ...prev, [ekey]: false })); + } } }); } else if (canPingTo) { @@ -215,11 +248,23 @@ export default function NetworkMapDashboard() { count: 3, }); const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null; - pingTimestampsRef.current[ekey] = Date.now(); - setPingMap((prev) => ({ ...prev, [ekey]: ms })); + if (ms != null) { + pingTimestampsRef.current[ekey] = Date.now(); + lastSuccessfulPingRef.current[ekey] = ms; + setPingMap((prev) => ({ ...prev, [ekey]: ms })); + setPingStaleMap((prev) => ({ ...prev, [ekey]: false })); + } } catch { pingTimestampsRef.current[ekey] = Date.now(); - setPingMap((prev) => ({ ...prev, [ekey]: null })); + // При ошибке сохраняем последнее успешное значение с пометкой устаревшего + const lastSuccess = lastSuccessfulPingRef.current[ekey]; + if (lastSuccess != null) { + setPingMap((prev) => ({ ...prev, [ekey]: lastSuccess })); + setPingStaleMap((prev) => ({ ...prev, [ekey]: true })); + } else { + setPingMap((prev) => ({ ...prev, [ekey]: null })); + setPingStaleMap((prev) => ({ ...prev, [ekey]: false })); + } } }); } @@ -254,18 +299,26 @@ export default function NetworkMapDashboard() { { timeout: 120000 } ); if (data?.ok && (data.tcpDownloadBps != null || data.tcpUploadBps != null)) { - setSpeedMap((prev) => ({ - ...prev, - [key]: { - tcpDownloadBps: data.tcpDownloadBps, - tcpUploadBps: data.tcpUploadBps, - cached: data.cached === true, - durationSeconds: data.durationSeconds, - }, - })); + const speedData = { + tcpDownloadBps: data.tcpDownloadBps, + tcpUploadBps: data.tcpUploadBps, + cached: data.cached === true, + durationSeconds: data.durationSeconds, + }; + lastSuccessfulSpeedRef.current[key] = speedData; + setSpeedMap((prev) => ({ ...prev, [key]: speedData })); + setSpeedStaleMap((prev) => ({ ...prev, [key]: false })); } } catch { - setSpeedMap((prev) => ({ ...prev, [key]: null })); + // При ошибке сохраняем последнее успешное значение с пометкой устаревшего + const lastSuccess = lastSuccessfulSpeedRef.current[key]; + if (lastSuccess != null) { + setSpeedMap((prev) => ({ ...prev, [key]: lastSuccess })); + setSpeedStaleMap((prev) => ({ ...prev, [key]: true })); + } else { + setSpeedMap((prev) => ({ ...prev, [key]: null })); + setSpeedStaleMap((prev) => ({ ...prev, [key]: false })); + } } }); // На каждой ноде (speedTestServerId) в один момент — только один тест, чтобы не делить канал @@ -354,6 +407,8 @@ export default function NetworkMapDashboard() { connections={connections} pingMap={pingMap} speedMap={speedMap} + pingStaleMap={pingStaleMap} + speedStaleMap={speedStaleMap} /> )} diff --git a/frontend/src/NetworkMapUnifi.jsx b/frontend/src/NetworkMapUnifi.jsx index 235871f..cda5b42 100644 --- a/frontend/src/NetworkMapUnifi.jsx +++ b/frontend/src/NetworkMapUnifi.jsx @@ -1,5 +1,5 @@ import { useMemo, useCallback, useState, useRef, useEffect } from 'react'; -import { IconRefresh, IconMaximize, IconMinimize } from '@tabler/icons-react'; +import { IconRefresh, IconMaximize, IconMinimize, IconClock } from '@tabler/icons-react'; const FLAG_CDN = 'https://flagcdn.com'; @@ -86,7 +86,7 @@ function speedKey(key1, key2) { return [String(key1), String(key2)].sort().join(':'); } -export default function NetworkMapUnifi({ servers = [], connections = [], pingMap = {}, speedMap = {} }) { +export default function NetworkMapUnifi({ servers = [], connections = [], pingMap = {}, speedMap = {}, pingStaleMap = {}, speedStaleMap = {} }) { const containerRef = useRef(null); const svgRef = useRef(null); const [size, setSize] = useState({ w: 800, h: 520 }); @@ -209,10 +209,13 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa const to = String(c.to); const p1 = positions[from]; const p2 = positions[to]; - const ms = pingMap[edgePingKey(from, to)]; + const ekey = edgePingKey(from, to); + const ms = pingMap[ekey]; + const pingStale = pingStaleMap[ekey] === true; const pingLabel = typeof ms === 'number' ? `${ms} ms` : null; const sk = speedKey(c.fromKey, c.toKey); const speed = speedMap[sk]; + const speedStale = speedStaleMap[sk] === true; const speedLabel = speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null) ? formatSpeedShort(speed.tcpDownloadBps, speed.tcpUploadBps) @@ -225,12 +228,14 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa p2, pingLabel, speedLabel, + pingStale, + speedStale, speed, tunnelType: c.tunnelType, raw: c, }; }).filter((e) => e.p1 && e.p2); - }, [connections, positions, pingMap, speedMap]); + }, [connections, positions, pingMap, speedMap, pingStaleMap, speedStaleMap]); // Вычисляем старт/финиш и одну контрольную точку: // рёбра выходят из карточек под 90°, дальше сразу плавная дуга без прямых сегментов. @@ -348,7 +353,10 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa opacity={dimFactor} /> {(() => { - const labels = [e.pingLabel, e.speedLabel].filter(Boolean); + const labels = [ + e.pingLabel ? { text: e.pingLabel, stale: e.pingStale } : null, + e.speedLabel ? { text: e.speedLabel, stale: e.speedStale } : null, + ].filter(Boolean); if (labels.length === 0) return null; const totalH = labels.length * LABEL_H + (labels.length - 1) * LABEL_GAP; const minDist = 70; @@ -367,25 +375,49 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa used.push({ x, y }); const pad = 8; + const staleColor = '#f59e0b'; return ( - {labels.map((text, i) => { + {labels.map((label, i) => { const yy = y + i * (LABEL_H + LABEL_GAP) + LABEL_H / 2; - const w = Math.max(LABEL_W, Math.min(text.length * 6 + pad * 2, 160)); + const isStale = label.stale === true; + const textWidth = label.text.length * 6; + const iconWidth = isStale ? 14 : 0; + const w = Math.max(LABEL_W, Math.min(textWidth + pad * 2 + iconWidth, 180)); const h = LABEL_H + 4; + const textX = x - (iconWidth / 2); return ( - + + {isStale && ( + + )} - {text} + {label.text} ); @@ -476,9 +508,12 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa {selectedEdge && (() => { const fromServer = servers.find((s) => String(s.ip) === selectedEdge.from); const toServer = servers.find((s) => String(s.ip) === selectedEdge.to); - const pingMs = pingMap[edgePingKey(selectedEdge.from, selectedEdge.to)]; + const ekey = edgePingKey(selectedEdge.from, selectedEdge.to); + const pingMs = pingMap[ekey]; + const pingStale = pingStaleMap[ekey] === true; const sk = speedKey(selectedEdge.raw?.fromKey, selectedEdge.raw?.toKey); const speed = speedMap[sk] ?? selectedEdge.speed; + const speedStale = speedStaleMap[sk] === true; return (
-
+
Пинг + {pingStale && ( + + + Устаревшие данные + + )}
{typeof pingMs === 'number' ? `${pingMs} ms` : 'нет данных'} @@ -565,11 +606,17 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
{speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null) && (
-
+
Скорость по туннелю {speed.cached && ( кеш )} + {speedStale && ( + + + Устаревшие данные + + )}