fix(ui): обновлять детали и пояснять модель карты сети

This commit is contained in:
Denozordec committed 2026-10-01 02:15:52 +07:00
1 parent 6cb27b2aab
commit 0cdb43a29d
6 files changed
+133 -63

No files matched your search

+66 -60
View File
@@ -436,7 +436,7 @@ function pingColor(ms: number | null) {
return "#fb923c"
}
function latencyColor(ms: number | null) {
if (ms == null) return "#f87171"
if (ms == null) return "var(--muted-foreground)"
if (ms > 60) return "#fbbf24"
return "#4ade80"
}
@@ -506,7 +506,7 @@ function PingBadge({ mx, my, ping, dl, ul, color }: {
</text>
{hasSpeed && (
<text textAnchor="middle" y="10" fontSize="7" fill="#64748b" fontFamily="ui-monospace,monospace">
{`↓${dl} ↑${ul}`}
{`↓${dl??"—"} ↑${ul??"—"}`}
</text>
)}
</g>
@@ -524,6 +524,7 @@ function GreEdgeMetricBadge({
rttFromMonitor,
throughputFromMonitor,
outerSummary,
probe,
}: {
mx: number
my: number
@@ -536,11 +537,9 @@ function GreEdgeMetricBadge({
throughputFromMonitor?: boolean
/** Внешние IP GRE и WAN — только в подсказке при наведении */
outerSummary?: string
probe?: GreSpeedProbeSnapshot
}) {
const hasSpeed =
dl != null &&
ul != null &&
(throughputFromMonitor || dl > 0 || ul > 0)
const hasSpeed = dl != null || ul != null
const bw = 62
const bh = 24 + (hasSpeed ? 14 : 0)
const v1 = pingMs == null ? "—" : `${pingMs} мс`
@@ -548,8 +547,9 @@ function GreEdgeMetricBadge({
(outerSummary ? `${outerSummary}. ` : "") +
(rttFromMonitor
? "RTT — ping/BT из «Мониторинг → скорость». "
: "RTT — модель по данным туннеля. ") +
: pingMs==null?"RTT неизвестен. ":"RTT — демонстрационная модель. ") +
(throughputFromMonitor ? "Строка ниже — TX/RX с пробы. " : "") +
(probe?"Проба "+probe.id+"; время Ping: "+(probe.lastPingAt?new Date(probe.lastPingAt).toLocaleString("ru-RU"):"неизвестно")+"; время TX/RX неизвестно. ":"") +
"Клик — панель справа."
return (
<g
@@ -679,7 +679,9 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o
const ts = TYPE_STYLE[n.type]
return (
<g transform={`translate(${n.x},${n.y})`}
<g role="button" aria-label={"Устройство "+n.name} aria-pressed={isSel} tabIndex={isVis?0:-1}
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}}
transform={`translate(${n.x},${n.y})`}
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
opacity={isVis ? 1 : 0.07}
onMouseDown={e => { e.stopPropagation(); onMouseDown(e) }}
@@ -691,8 +693,6 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o
>
{n.status === "online" && isVis && (
<circle r={ts.r + 14} fill={ss.glow} opacity="0.7">
<animate attributeName="r" values={`${ts.r+10};${ts.r+18};${ts.r+10}`} dur="3s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.8;0.15;0.8" dur="3s" repeatCount="indefinite" />
</circle>
)}
{n.status === "degraded" && isVis && (
@@ -753,13 +753,13 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o
)}
{!hideCatalogLatency && (
<text textAnchor="middle" y="12" fontSize="8.5" fill={latencyColor(n.latency)} fontFamily="ui-monospace,monospace">
{n.latency == null ? "офлайн" : `${n.latency} мс`}
{n.latency == null ? "—" : `${n.latency} мс`}
</text>
)}
{n.type === "home-router" && (
{(n.name && (
<text textAnchor="middle" y={hideCatalogLatency ? 15 : ts.r + 18} fontSize="8.5" fill="#94a3b8"
fontFamily="ui-monospace,monospace">{shortName(n.name)}</text>
)}
))}
</g>
)
}
@@ -1147,6 +1147,7 @@ function NetworkMapContent() {
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [mapServers, setMapServers] = useState<Server[]>([])
const [sessionCounts,setSessionCounts]=useState<Record<string,number|null>>({})
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
@@ -1206,6 +1207,7 @@ function NetworkMapContent() {
const mappedTunnels=snapshot.tunnels.map(apiGreToGreTunnel)
if(!current())return
setMapServers(rows.map(mapBackendRowToServer));setMapGreTunnels(mappedTunnels)
setSessionCounts(Object.fromEntries(rows.map(row=>[String(row.id),row.sessions??null])))
setSpeedProbes(snapshot.probes.map(probe=>({...probe,srcInterface:probe.srcInterface||undefined,dstInterface:probe.dstInterface||undefined})))
setHasSnapshot(true);setReadAt(new Date().toISOString());setDataError(null)
const hostSet = new Set<string>(collectGreEndpointHostnames(mappedTunnels))
@@ -1283,7 +1285,7 @@ function NetworkMapContent() {
() => computeNetworkMapLayout(mapServers, mapGreTunnels, greResolvedMap),
[mapServers, mapGreTunnels, greResolvedMap],
)
const wanJhEdges = useMemo(() => buildWanJhEdges(mapServers), [mapServers])
const wanJhEdges = useMemo(() => buildWanJhEdges(mapServers).map(edge=>({...edge,active:!useLiveData&&edge.active})), [mapServers,useLiveData])
const srvResMap = useMemo(() => buildServerResourceMap(mapServers, !useLiveData), [mapServers,useLiveData])
const homeRouters = useMemo(
() => mapServers.filter((s) => s.type === "home-router"),
@@ -1296,7 +1298,8 @@ function NetworkMapContent() {
const [isDragging, setIsDragging] = useState(false)
// ── Interaction ─────────────────────────────────────────────────────────────
const [selected, setSelected] = useState<Server | null>(null)
const [selectedStored, setSelected] = useState<Server | null>(null)
const selected=selectedStored?mapServers.find(server=>server.id===selectedStored.id)??null:null
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
/** Раскрытая страна (режим «Страны»): справа столбец её сервисов. Не персистится. */
const [expandedCountryId, setExpandedCountryId] = useState<string | null>(null)
@@ -1350,9 +1353,7 @@ function NetworkMapContent() {
if (sessionStorage.getItem(k) !== String(NETWORK_MAP_LAYOUT_REVISION)) {
// Важно: без явного сброса старые drag-координаты остаются в state
// и перекрывают новый авто-лейаут на live-данных.
setNodePositions({})
setSatPositions({})
setServicePositions({})
queueMicrotask(()=>{setNodePositions({});setSatPositions({});setServicePositions({})})
sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
}
} catch {
@@ -1363,7 +1364,7 @@ function NetworkMapContent() {
// ── Context menu ────────────────────────────────────────────────────────────
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
const [selectedGreEdge, setSelectedGreEdge] = useState<GreMapEdge | null>(null)
const [selectedGreStored, setSelectedGreEdge] = useState<GreMapEdge | null>(null)
// ── Toggles ─────────────────────────────────────────────────────────────────
const [filter, setFilter] = useState<FilterKey>("all")
@@ -1524,6 +1525,8 @@ function NetworkMapContent() {
[greEdges, speedProbes, greResolvedMap],
)
const selectedGreEdge=selectedGreStored?greEdges.find(edge=>greEdgeKey(edge)===greEdgeKey(selectedGreStored))??null:null
/** WAN→JH: не показывать GreEdgeMetricBadge со speed, если те же метрики уже на GRE (один бейдж на сегмент). */
const suppressWanJhSpeedBadgeDuplicate = useMemo(() => {
const s = new Set<string>()
@@ -1697,8 +1700,7 @@ function NetworkMapContent() {
const zoomRef = useRef(zoom)
const panRef = useRef(pan)
zoomRef.current = zoom
panRef.current = pan
useEffect(()=>{zoomRef.current=zoom;panRef.current=pan},[zoom,pan])
// ── SVG export ───────────────────────────────────────────────────────────
function exportSvg() {
@@ -1746,6 +1748,27 @@ function NetworkMapContent() {
return () => svg.removeEventListener("wheel", handler)
}, [])
// ── Zoom helpers ──────────────────────────────────────────────────────────
const applyZoomCenter=useCallback((factor: number) => {
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor))
setPan(p => ({
x: p.x + (W / zoom - W / newZoom) / 2,
y: p.y + (H / zoom - H / newZoom) / 2,
}))
setZoom(newZoom)
},[zoom])
const fitView=useCallback(() => {
setPan({ x: 0, y: 0 })
setZoom(1)
},[])
function focusNode(x: number, y: number) {
const newZoom = Math.min(ZOOM_MAX, Math.max(zoom * 1.8, 2))
setPan({ x: x - W / (2 * newZoom), y: y - H / (2 * newZoom) })
setZoom(newZoom)
}
// ── Keyboard shortcuts ────────────────────────────────────────────────────
useEffect(() => {
function onKey(e: KeyboardEvent) {
@@ -1766,28 +1789,7 @@ function NetworkMapContent() {
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [zoom, pan]) // eslint-disable-line react-hooks/exhaustive-deps -- applyZoomCenter/fitView ниже; handler обновляется через [zoom, pan]
// ── Zoom helpers ──────────────────────────────────────────────────────────
function applyZoomCenter(factor: number) {
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor))
setPan(p => ({
x: p.x + (W / zoom - W / newZoom) / 2,
y: p.y + (H / zoom - H / newZoom) / 2,
}))
setZoom(newZoom)
}
function fitView() {
setPan({ x: 0, y: 0 })
setZoom(1)
}
function focusNode(x: number, y: number) {
const newZoom = Math.min(ZOOM_MAX, Math.max(zoom * 1.8, 2))
setPan({ x: x - W / (2 * newZoom), y: y - H / (2 * newZoom) })
setZoom(newZoom)
}
}, [applyZoomCenter,fitView])
// ── Drag ─────────────────────────────────────────────────────────────────
function onSvgMouseDown(e: React.MouseEvent<SVGSVGElement>) {
@@ -1966,7 +1968,7 @@ function NetworkMapContent() {
setHoveredId(null)
}
const connectedTunnels = selected
const connectedTunnels = useMemo(()=>selected
? mapGreTunnels.filter((t) => {
if (t.serverId === selected.id) return true
const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
@@ -1983,7 +1985,7 @@ function NetworkMapContent() {
selectedWanIps.includes(local)
)
})
: []
: [],[selected,mapGreTunnels,mapServers,greResolvedMap])
const grePrimaryByGroupKey = useMemo(() => {
if (!selected) return new Map<string, string>()
const group = new Map<string, GreTunnel[]>()
@@ -2182,6 +2184,7 @@ function NetworkMapContent() {
{useLiveData&&flowLoading&&(showNetflow||showServices)&&<span role="status">Обновление NetFlow…</span>}
{useLiveData&&flowError&&(showNetflow||showServices)&&<span role="alert" className="text-destructive break-all">NetFlow: {flowError}. {flowReadAt?"Сохранён предыдущий снимок NetFlow; актуальность не подтверждена.":"Первоначальная загрузка NetFlow не подтверждена."}</span>}
{useLiveData&&dnsError&&<span role="alert" className="text-destructive break-all">DNS: {dnsError}. Связи по неизвестному адресу не подтверждены.</span>}
<span>Пунктир WAN→JH — модель связности; маршрут не подтверждён.</span>
{dataLoading && <span role="status">Обновление снимка карты…</span>}
{dataError && (
<span role="alert" className="text-[10px] text-destructive break-all">
@@ -2327,6 +2330,7 @@ function NetworkMapContent() {
dl={merged.dlMbps}
ul={merged.ulMbps}
onOpen={openGreDetail}
probe={spGre}
rttFromMonitor={rttFromMonitor}
throughputFromMonitor={throughputFromMonitor}
outerSummary={greOuterSummaryLine(e.tunnel, fromN, toN, greResolvedMap)}
@@ -2395,7 +2399,7 @@ function NetworkMapContent() {
const kj = wanJhEdgeMapKey(edge)
const sp = speedProbeByWanJhKey.get(kj)
const merged = mergeGreMetricsWithSpeedProbe(sp, {
pingMs: edge.pingMs,
pingMs: useLiveData?null:edge.pingMs,
dlMbps: null,
ulMbps: null,
})
@@ -2411,6 +2415,7 @@ function NetworkMapContent() {
dl={merged.dlMbps}
ul={merged.ulMbps}
onOpen={(ev) => openWanJhSpeedDetail(ev, edge)}
probe={sp}
rttFromMonitor={sp?.lastPingRttMs != null}
throughputFromMonitor={
sp?.lastTxAvgMbps != null || sp?.lastRxAvgMbps != null
@@ -2418,7 +2423,7 @@ function NetworkMapContent() {
/>
)
}
return <PingBadge mx={mx} my={my} ping={edge.pingMs} color={pingColor(edge.pingMs)} />
return <PingBadge mx={mx} my={my} ping={useLiveData?null:edge.pingMs} color={pingColor(useLiveData?null:edge.pingMs)} />
})()}
{showNetflow && hopHasRate(wanFlow) && (
<NetflowRateBadge
@@ -2744,7 +2749,7 @@ function NetworkMapContent() {
<line x1="38" y1="4" x2="60" y2="4" stroke={c}
strokeWidth={i === 0 ? 2 : 1.2} strokeDasharray={i === 0 ? "none" : "4 3"} opacity="0.8" />
<text x="66" y="8" fontSize="7.5" fill="#475569" fontFamily="system-ui">
{i === 0 ? "primary" : "backup"}
{useLiveData?"модель":i === 0 ? "primary (демо)" : "backup (демо)"}
</text>
</g>
))}
@@ -2901,7 +2906,7 @@ function NetworkMapContent() {
<div className="flex flex-col gap-0">
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">
{rttMon ? "Мониторинг (ping/BT)" : "Модель RTT"}
{rttMon ? "Мониторинг (ping/BT)" : useLiveData?"Ping неизвестен":"Модель RTT (демо)"}
</span>
<span
className="text-xs font-mono font-semibold"
@@ -2912,11 +2917,11 @@ function NetworkMapContent() {
</div>
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">
{bwMon ? "TX / RX (BT)" : "Скорость канала (модель)"}
{bwMon ? "TX / RX (BT)" : useLiveData?"TX / RX неизвестны":"Скорость канала (демо)"}
</span>
<span className="text-xs font-mono font-semibold text-sky-400">
{merged.dlMbps != null && merged.ulMbps != null
? `↓${merged.dlMbps} ↑${merged.ulMbps}`
{(merged.dlMbps != null || merged.ulMbps != null)
? `↓${merged.dlMbps??"—"} ↑${merged.ulMbps??"—"}`
: "—"}
</span>
</div>
@@ -2935,8 +2940,9 @@ function NetworkMapContent() {
<p className="text-[10px] text-muted-foreground pt-2 leading-snug">
{merged.hasSpeedMonitor
? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам. "
: "«Модель RTT» и «скорость канала» — демо до появления подходящей speed-пробы в «Мониторинг → скорость». "}
Поток — живой NetFlow за 5 мин (как в «Трафик»: без overlay/mesh), не ёмкость канала.
: useLiveData?"Измерения GRE неизвестны: нет подходящей пробы. ":"Измерения демонстрационные. "}
{spGr?"Проба "+spGr.id+" · время Ping: "+(spGr.lastPingAt?new Date(spGr.lastPingAt).toLocaleString("ru-RU"):"неизвестно")+" · время TX/RX неизвестно. ":""}
Поток — снимок NetFlow за 5 мин (как в «Трафик»: без overlay/mesh), не ёмкость канала.
</p>
</div>
</div>
@@ -3267,7 +3273,7 @@ function NetworkMapContent() {
["Модель", selected.model],
["RouterOS", selected.os],
...(selected.type !== "home-router"
? [["ASN", selected.asn], ["Сессий", String(selected.sessions)]]
? [["ASN", selected.asn], ["Сессий", useLiveData?(sessionCounts[selected.id]==null?"—":String(sessionCounts[selected.id])):String(selected.sessions)]]
: [["LAN", selected.lanSubnet ?? "—"]]),
].map(([k, v]) => (
<div key={k} className="flex items-center justify-between py-2 border-b border-border/50 last:border-0">
@@ -3278,7 +3284,7 @@ function NetworkMapContent() {
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Задержка</span>
<span className="text-xs font-mono font-semibold" style={{ color: latencyColor(selected.latency) }}>
{selected.latency == null ? "— офлайн" : `${selected.latency} мс`}
{selected.latency == null ? "—" : `${selected.latency} мс`}
</span>
</div>
</div>
@@ -3310,7 +3316,7 @@ function NetworkMapContent() {
"ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded-full",
isActive ? "bg-emerald-500/10 text-emerald-400" : "bg-muted text-muted-foreground",
)}>
{isActive ? "primary" : "backup"}
{useLiveData?"Аплинк каталога":isActive ? "primary (демо)" : "backup (демо)"}
</span>
</div>
<div className="flex flex-col gap-1">
@@ -3339,7 +3345,7 @@ function NetworkMapContent() {
{myEdges.length > 0 && (
<div className="mt-2 pt-2 border-t border-border/40">
<p className="text-[9px] uppercase text-muted-foreground tracking-wider mb-1.5">
Подключения к JH
Модель связей с JH · Ping модели
</p>
{myEdges.map((e) => {
const jh = mapServers.find(s => s.id === e.jhId)
@@ -3405,7 +3411,7 @@ function NetworkMapContent() {
"text-[10px] font-medium px-1.5 py-0.5 rounded-full",
isPrimary ? "bg-emerald-500/10 text-emerald-400" : "bg-muted text-muted-foreground",
)}>
{isPrimary ? "primary" : "backup"}
{isPrimary ? "Основная на схеме" : "Дополнительная на схеме"}
</span>
<span className="text-[10px] font-medium" style={{ color: ts.stroke }}>
{t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"}
@@ -3418,7 +3424,7 @@ function NetworkMapContent() {
</span>
{t.ipsec
? <span className="text-[10px] text-emerald-400 flex items-center gap-0.5"><LockIcon className="size-2.5" />IPsec</span>
: <span className="text-[10px] text-muted-foreground flex items-center gap-0.5"><LockOpenIcon className="size-2.5" />Plain</span>}
: <span className="text-[10px] text-muted-foreground flex items-center gap-0.5"><LockOpenIcon className="size-2.5" />{useLiveData?"IPsec неизвестен":"Plain (демо)"}</span>}
</div>
{showMetrics && (
<div className="flex items-center justify-between pt-1 border-t border-border/30">