feat(NetworkMapDashboard, NetworkMapUnifi): add stale data handling for ping and speed metrics, enhancing UI feedback with visual indicators
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s

This commit is contained in:
2026-02-18 15:13:23 +07:00
parent 8cc3575902
commit 9a8b72de82
2 changed files with 136 additions and 34 deletions
+75 -20
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import api from './lib/api.js'; 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 PageHeader from './components/PageHeader.jsx';
import NetworkMapUnifi from './NetworkMapUnifi.jsx'; import NetworkMapUnifi from './NetworkMapUnifi.jsx';
@@ -70,7 +70,11 @@ export default function NetworkMapDashboard() {
const [connections, setConnections] = useState([]); const [connections, setConnections] = useState([]);
const [pingMap, setPingMap] = useState({}); const [pingMap, setPingMap] = useState({});
const [speedMap, setSpeedMap] = useState({}); const [speedMap, setSpeedMap] = useState({});
const [pingStaleMap, setPingStaleMap] = useState({});
const [speedStaleMap, setSpeedStaleMap] = useState({});
const pingTimestampsRef = useRef({}); const pingTimestampsRef = useRef({});
const lastSuccessfulPingRef = useRef({});
const lastSuccessfulSpeedRef = useRef({});
const [pingCacheSeconds, setPingCacheSeconds] = useState(0); const [pingCacheSeconds, setPingCacheSeconds] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [pingLoading, setPingLoading] = useState(false); const [pingLoading, setPingLoading] = useState(false);
@@ -140,14 +144,31 @@ export default function NetworkMapDashboard() {
Date.now() - cache.updatedAt < cacheMaxAgeMs && Date.now() - cache.updatedAt < cacheMaxAgeMs &&
(Object.keys(cache.pingMap || {}).length > 0 || Object.keys(cache.speedMap || {}).length > 0); (Object.keys(cache.pingMap || {}).length > 0 || Object.keys(cache.speedMap || {}).length > 0);
if (cacheValid) { if (cacheValid) {
setPingMap(cache.pingMap || {}); const cachedPingMap = cache.pingMap || {};
setSpeedMap(cache.speedMap || {}); const cachedSpeedMap = cache.speedMap || {};
Object.keys(cache.pingMap || {}).forEach((k) => { setPingMap(cachedPingMap);
setSpeedMap(cachedSpeedMap);
// Сохраняем успешные значения из кеша
Object.keys(cachedPingMap).forEach((k) => {
if (typeof cachedPingMap[k] === 'number') {
lastSuccessfulPingRef.current[k] = cachedPingMap[k];
}
pingTimestampsRef.current[k] = cache.updatedAt; 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; cacheAppliedAtRef.current = cache.updatedAt;
// Сбрасываем флаги устаревших данных при загрузке валидного кеша
setPingStaleMap({});
setSpeedStaleMap({});
} else { } else {
setSpeedMap({}); setSpeedMap({});
// При невалидном кеше также сбрасываем флаги
setPingStaleMap({});
setSpeedStaleMap({});
} }
} catch (e) { } catch (e) {
console.error('NetworkMap fetch:', e); console.error('NetworkMap fetch:', e);
@@ -197,11 +218,23 @@ export default function NetworkMapDashboard() {
count: 3, count: 3,
}); });
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null; const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
pingTimestampsRef.current[ekey] = Date.now(); if (ms != null) {
setPingMap((prev) => ({ ...prev, [ekey]: ms })); pingTimestampsRef.current[ekey] = Date.now();
lastSuccessfulPingRef.current[ekey] = ms;
setPingMap((prev) => ({ ...prev, [ekey]: ms }));
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
}
} catch { } catch {
pingTimestampsRef.current[ekey] = Date.now(); 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) { } else if (canPingTo) {
@@ -215,11 +248,23 @@ export default function NetworkMapDashboard() {
count: 3, count: 3,
}); });
const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null; const ms = typeof data?.avgMs === 'number' ? Math.round(data.avgMs) : null;
pingTimestampsRef.current[ekey] = Date.now(); if (ms != null) {
setPingMap((prev) => ({ ...prev, [ekey]: ms })); pingTimestampsRef.current[ekey] = Date.now();
lastSuccessfulPingRef.current[ekey] = ms;
setPingMap((prev) => ({ ...prev, [ekey]: ms }));
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
}
} catch { } catch {
pingTimestampsRef.current[ekey] = Date.now(); 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 } { timeout: 120000 }
); );
if (data?.ok && (data.tcpDownloadBps != null || data.tcpUploadBps != null)) { if (data?.ok && (data.tcpDownloadBps != null || data.tcpUploadBps != null)) {
setSpeedMap((prev) => ({ const speedData = {
...prev, tcpDownloadBps: data.tcpDownloadBps,
[key]: { tcpUploadBps: data.tcpUploadBps,
tcpDownloadBps: data.tcpDownloadBps, cached: data.cached === true,
tcpUploadBps: data.tcpUploadBps, durationSeconds: data.durationSeconds,
cached: data.cached === true, };
durationSeconds: data.durationSeconds, lastSuccessfulSpeedRef.current[key] = speedData;
}, setSpeedMap((prev) => ({ ...prev, [key]: speedData }));
})); setSpeedStaleMap((prev) => ({ ...prev, [key]: false }));
} }
} catch { } 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) в один момент — только один тест, чтобы не делить канал // На каждой ноде (speedTestServerId) в один момент — только один тест, чтобы не делить канал
@@ -354,6 +407,8 @@ export default function NetworkMapDashboard() {
connections={connections} connections={connections}
pingMap={pingMap} pingMap={pingMap}
speedMap={speedMap} speedMap={speedMap}
pingStaleMap={pingStaleMap}
speedStaleMap={speedStaleMap}
/> />
)} )}
</div> </div>
+61 -14
View File
@@ -1,5 +1,5 @@
import { useMemo, useCallback, useState, useRef, useEffect } from 'react'; 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'; const FLAG_CDN = 'https://flagcdn.com';
@@ -86,7 +86,7 @@ function speedKey(key1, key2) {
return [String(key1), String(key2)].sort().join(':'); 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 containerRef = useRef(null);
const svgRef = useRef(null); const svgRef = useRef(null);
const [size, setSize] = useState({ w: 800, h: 520 }); 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 to = String(c.to);
const p1 = positions[from]; const p1 = positions[from];
const p2 = positions[to]; 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 pingLabel = typeof ms === 'number' ? `${ms} ms` : null;
const sk = speedKey(c.fromKey, c.toKey); const sk = speedKey(c.fromKey, c.toKey);
const speed = speedMap[sk]; const speed = speedMap[sk];
const speedStale = speedStaleMap[sk] === true;
const speedLabel = const speedLabel =
speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null) speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null)
? formatSpeedShort(speed.tcpDownloadBps, speed.tcpUploadBps) ? formatSpeedShort(speed.tcpDownloadBps, speed.tcpUploadBps)
@@ -225,12 +228,14 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
p2, p2,
pingLabel, pingLabel,
speedLabel, speedLabel,
pingStale,
speedStale,
speed, speed,
tunnelType: c.tunnelType, tunnelType: c.tunnelType,
raw: c, raw: c,
}; };
}).filter((e) => e.p1 && e.p2); }).filter((e) => e.p1 && e.p2);
}, [connections, positions, pingMap, speedMap]); }, [connections, positions, pingMap, speedMap, pingStaleMap, speedStaleMap]);
// Вычисляем старт/финиш и одну контрольную точку: // Вычисляем старт/финиш и одну контрольную точку:
// рёбра выходят из карточек под 90°, дальше сразу плавная дуга без прямых сегментов. // рёбра выходят из карточек под 90°, дальше сразу плавная дуга без прямых сегментов.
@@ -348,7 +353,10 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
opacity={dimFactor} 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; if (labels.length === 0) return null;
const totalH = labels.length * LABEL_H + (labels.length - 1) * LABEL_GAP; const totalH = labels.length * LABEL_H + (labels.length - 1) * LABEL_GAP;
const minDist = 70; const minDist = 70;
@@ -367,25 +375,49 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
used.push({ x, y }); used.push({ x, y });
const pad = 8; const pad = 8;
const staleColor = '#f59e0b';
return ( return (
<g> <g>
{labels.map((text, i) => { {labels.map((label, i) => {
const yy = y + i * (LABEL_H + LABEL_GAP) + LABEL_H / 2; 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 h = LABEL_H + 4;
const textX = x - (iconWidth / 2);
return ( return (
<g key={i}> <g key={i}>
<rect x={x - w / 2} y={yy - h / 2} width={w} height={h} rx={4} fill="rgba(15,23,42,0.92)" stroke={UNIFI_GREEN} strokeWidth={1} /> <rect
x={x - w / 2}
y={yy - h / 2}
width={w}
height={h}
rx={4}
fill="rgba(15,23,42,0.92)"
stroke={isStale ? staleColor : UNIFI_GREEN}
strokeWidth={1}
strokeDasharray={isStale ? '2,2' : 'none'}
/>
{isStale && (
<circle
cx={x - w / 2 + 10}
cy={yy}
r={5}
fill={staleColor}
opacity={0.9}
/>
)}
<text <text
x={x} x={textX}
y={yy} y={yy}
textAnchor="middle" textAnchor="middle"
dominantBaseline="middle" dominantBaseline="middle"
fill={UNIFI_GREEN} fill={isStale ? staleColor : UNIFI_GREEN}
fontSize={i === 0 ? 12 : 11} fontSize={i === 0 ? 12 : 11}
fontWeight={600} fontWeight={600}
> >
{text} {label.text}
</text> </text>
</g> </g>
); );
@@ -476,9 +508,12 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
{selectedEdge && (() => { {selectedEdge && (() => {
const fromServer = servers.find((s) => String(s.ip) === selectedEdge.from); const fromServer = servers.find((s) => String(s.ip) === selectedEdge.from);
const toServer = servers.find((s) => String(s.ip) === selectedEdge.to); 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 sk = speedKey(selectedEdge.raw?.fromKey, selectedEdge.raw?.toKey);
const speed = speedMap[sk] ?? selectedEdge.speed; const speed = speedMap[sk] ?? selectedEdge.speed;
const speedStale = speedStaleMap[sk] === true;
return ( return (
<div <div
className="network-map-edge-modal-backdrop" className="network-map-edge-modal-backdrop"
@@ -556,8 +591,14 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
</div> </div>
</div> </div>
<div className="mb-3"> <div className="mb-3">
<div className="text-muted text-uppercase mb-1" style={{ fontSize: 11 }}> <div className="text-muted text-uppercase mb-1 d-flex align-items-center" style={{ fontSize: 11 }}>
Пинг Пинг
{pingStale && (
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: 9 }}>
<IconClock size={10} className="me-1" />
Устаревшие данные
</span>
)}
</div> </div>
<div> <div>
{typeof pingMs === 'number' ? `${pingMs} ms` : 'нет данных'} {typeof pingMs === 'number' ? `${pingMs} ms` : 'нет данных'}
@@ -565,11 +606,17 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
</div> </div>
{speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null) && ( {speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null) && (
<div className="mb-1"> <div className="mb-1">
<div className="text-muted text-uppercase mb-1" style={{ fontSize: 11 }}> <div className="text-muted text-uppercase mb-1 d-flex align-items-center" style={{ fontSize: 11 }}>
Скорость по туннелю Скорость по туннелю
{speed.cached && ( {speed.cached && (
<span className="ms-1 badge bg-secondary" style={{ fontSize: 10 }}>кеш</span> <span className="ms-1 badge bg-secondary" style={{ fontSize: 10 }}>кеш</span>
)} )}
{speedStale && (
<span className="ms-1 badge bg-warning text-dark" style={{ fontSize: 9 }}>
<IconClock size={10} className="me-1" />
Устаревшие данные
</span>
)}
</div> </div>
<div> <div>
<div> <div>