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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
This commit is contained in:
@@ -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;
|
||||||
|
if (ms != null) {
|
||||||
pingTimestampsRef.current[ekey] = Date.now();
|
pingTimestampsRef.current[ekey] = Date.now();
|
||||||
|
lastSuccessfulPingRef.current[ekey] = ms;
|
||||||
setPingMap((prev) => ({ ...prev, [ekey]: ms }));
|
setPingMap((prev) => ({ ...prev, [ekey]: ms }));
|
||||||
|
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
pingTimestampsRef.current[ekey] = Date.now();
|
pingTimestampsRef.current[ekey] = Date.now();
|
||||||
|
// При ошибке сохраняем последнее успешное значение с пометкой устаревшего
|
||||||
|
const lastSuccess = lastSuccessfulPingRef.current[ekey];
|
||||||
|
if (lastSuccess != null) {
|
||||||
|
setPingMap((prev) => ({ ...prev, [ekey]: lastSuccess }));
|
||||||
|
setPingStaleMap((prev) => ({ ...prev, [ekey]: true }));
|
||||||
|
} else {
|
||||||
setPingMap((prev) => ({ ...prev, [ekey]: null }));
|
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;
|
||||||
|
if (ms != null) {
|
||||||
pingTimestampsRef.current[ekey] = Date.now();
|
pingTimestampsRef.current[ekey] = Date.now();
|
||||||
|
lastSuccessfulPingRef.current[ekey] = ms;
|
||||||
setPingMap((prev) => ({ ...prev, [ekey]: ms }));
|
setPingMap((prev) => ({ ...prev, [ekey]: ms }));
|
||||||
|
setPingStaleMap((prev) => ({ ...prev, [ekey]: false }));
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
pingTimestampsRef.current[ekey] = Date.now();
|
pingTimestampsRef.current[ekey] = Date.now();
|
||||||
|
// При ошибке сохраняем последнее успешное значение с пометкой устаревшего
|
||||||
|
const lastSuccess = lastSuccessfulPingRef.current[ekey];
|
||||||
|
if (lastSuccess != null) {
|
||||||
|
setPingMap((prev) => ({ ...prev, [ekey]: lastSuccess }));
|
||||||
|
setPingStaleMap((prev) => ({ ...prev, [ekey]: true }));
|
||||||
|
} else {
|
||||||
setPingMap((prev) => ({ ...prev, [ekey]: null }));
|
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,
|
|
||||||
[key]: {
|
|
||||||
tcpDownloadBps: data.tcpDownloadBps,
|
tcpDownloadBps: data.tcpDownloadBps,
|
||||||
tcpUploadBps: data.tcpUploadBps,
|
tcpUploadBps: data.tcpUploadBps,
|
||||||
cached: data.cached === true,
|
cached: data.cached === true,
|
||||||
durationSeconds: data.durationSeconds,
|
durationSeconds: data.durationSeconds,
|
||||||
},
|
};
|
||||||
}));
|
lastSuccessfulSpeedRef.current[key] = speedData;
|
||||||
|
setSpeedMap((prev) => ({ ...prev, [key]: speedData }));
|
||||||
|
setSpeedStaleMap((prev) => ({ ...prev, [key]: false }));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
// При ошибке сохраняем последнее успешное значение с пометкой устаревшего
|
||||||
|
const lastSuccess = lastSuccessfulSpeedRef.current[key];
|
||||||
|
if (lastSuccess != null) {
|
||||||
|
setSpeedMap((prev) => ({ ...prev, [key]: lastSuccess }));
|
||||||
|
setSpeedStaleMap((prev) => ({ ...prev, [key]: true }));
|
||||||
|
} else {
|
||||||
setSpeedMap((prev) => ({ ...prev, [key]: null }));
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user