From e47e2b4029ed4f8b2b367d70b378033c8c98e136 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 01:46:29 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=B4=D1=83=D0=BC=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5?= =?UTF-8?q?=20=D0=B8=D0=B7=D0=BC=D0=B5=D1=80=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=BA=D0=B0=D1=80=D1=82=D1=8B=20=D1=81=D0=B5?= =?UTF-8?q?=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/network-map/page.tsx | 49 ++++++++++++++++-------------- docs/ux-ui-progress.md | 9 +++++- docs/ux-ui-stage-10.md | 8 +++++ lib/map-gre-speed-probe.ts | 4 +-- lib/network-map-layout.ts | 32 ++++++++++++------- lib/network-map-metrics.test.ts | 21 +++++++++++++ tests/e2e/network-map-data.spec.ts | 14 +++++++++ 7 files changed, 100 insertions(+), 37 deletions(-) create mode 100644 lib/network-map-metrics.test.ts diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index 47b8790e..e91e278a 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -94,7 +94,7 @@ function mapBackendRowToServer(s: BackendServerRow): Server { os: s.os ?? "—", enabled: s.enabled, status: s.status ?? "unknown", - latency: s.latency != null ? Math.round(s.latency) : null, + latency: s.latency ?? null, sessions: s.sessions ?? 0, comment: s.comment || undefined, lanSubnet: s.lanSubnet || undefined, @@ -157,11 +157,13 @@ function makeApiFetch(backendUrl: string) { } } -function fmtMB(mb: number): string { +function fmtMB(mb: number | null): string { + if(mb==null)return "—" if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ` - return `${mb} МБ` + return Number(mb.toFixed(2))+" МБ" } -function fmtUptime(sec: number): string { +function fmtUptime(sec: number | null): string { + if(sec==null)return "—" const d = Math.floor(sec / 86400) const h = Math.floor((sec % 86400) / 3600) const m = Math.floor((sec % 3600) / 60) @@ -174,7 +176,8 @@ function resBarColor(pct: number): string { if (pct >= 70) return "bg-amber-500" return "bg-emerald-500" } -function resPctColor(pct: number): string { +function resPctColor(pct: number | null): string { + if(pct==null)return "text-muted-foreground" if (pct >= 85) return "text-red-400" if (pct >= 70) return "text-amber-400" return "text-emerald-400" @@ -427,7 +430,7 @@ function drawableServiceEdges( // ─── Helpers ────────────────────────────────────────────────────────────────── function pingColor(ms: number | null) { - if (ms == null) return "#f87171" + if (ms == null) return "var(--muted-foreground)" if (ms < 15) return "#4ade80" if (ms < 50) return "#fbbf24" return "#fb923c" @@ -1270,7 +1273,7 @@ function NetworkMapContent() { [mapServers, mapGreTunnels, greResolvedMap], ) const wanJhEdges = useMemo(() => buildWanJhEdges(mapServers), [mapServers]) - const srvResMap = useMemo(() => buildServerResourceMap(mapServers), [mapServers]) + const srvResMap = useMemo(() => buildServerResourceMap(mapServers, !useLiveData), [mapServers,useLiveData]) const homeRouters = useMemo( () => mapServers.filter((s) => s.type === "home-router"), [mapServers], @@ -1522,12 +1525,12 @@ function NetworkMapContent() { const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap) if (widx == null) continue const spGre = speedProbeByTunnelId.get(greEdgeKey(g)) - const merged = mergeGreMetricsWithSpeedProbe(spGre, greTunnelProbe(g.tunnel)) + const merged = mergeGreMetricsWithSpeedProbe(spGre, greTunnelProbe(g.tunnel,!useLiveData)) if (!merged.hasSpeedMonitor) continue s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`) } return s - }, [greEdges, speedProbeByTunnelId, greResolvedMap]) + }, [greEdges, speedProbeByTunnelId, greResolvedMap,useLiveData]) /** WAN→JH: скрываем саму линию, если тот же отрезок уже отрисован GRE (одна связь на карте). */ const suppressWanJhLineDuplicate = useMemo(() => { @@ -1559,7 +1562,7 @@ function NetworkMapContent() { const e = edgeByTunnel.get(tid) if (!e) return -1 const sp = speedProbeByTunnelId.get(tid) - const m = mergeGreMetricsWithSpeedProbe(sp, greTunnelProbe(e.tunnel)) + const m = mergeGreMetricsWithSpeedProbe(sp, greTunnelProbe(e.tunnel,!useLiveData)) let r = m.hasSpeedMonitor ? 1000 : 0 r += m.pingMs ?? 0 return r @@ -1574,7 +1577,7 @@ function NetworkMapContent() { } } return new Set(winners.values()) - }, [greEdges, speedProbeByTunnelId]) + }, [greEdges, speedProbeByTunnelId,useLiveData]) /** Несколько GRE между одной парой узлов — смещаем бейджи по дуге и по нормали, без наложения. */ const greBadgeStaggerByTunnelId = useMemo(() => { @@ -2235,7 +2238,7 @@ function NetworkMapContent() { fromN && toN && !isVisible(fromN) && !isVisible(toN) - const baseProbe = greTunnelProbe(e.tunnel) + const baseProbe = greTunnelProbe(e.tunnel,!useLiveData) const spGre = speedProbeByTunnelId.get(edgeId) const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe) const rttFromMonitor = spGre?.lastPingRttMs != null @@ -2845,7 +2848,7 @@ function NetworkMapContent() { const selectedEdgeId = greEdgeKey(edge) const localWanName = ifaceNameForGreOuterIp(edge.fromServer, t.localAddress, greResolvedMap) const remoteWanName = ifaceNameForGreOuterIp(edge.toServer, t.remoteAddress, greResolvedMap) - const baseProbe = greTunnelProbe(t) + const baseProbe = greTunnelProbe(t,!useLiveData) const spGr = speedProbeByTunnelId.get(selectedEdgeId) const merged = mergeGreMetricsWithSpeedProbe(spGr, baseProbe) const rttMon = spGr?.lastPingRttMs != null @@ -3369,7 +3372,7 @@ function NetworkMapContent() { fromServer && toServer ? `${fromServer.id}|${toServer.id}|${t.id}|${t.localAddress}|${t.remoteAddress}` : `${t.serverId}|${t.id}|${t.localAddress}|${t.remoteAddress}` - const baseProbe = greTunnelProbe(t) + const baseProbe = greTunnelProbe(t,!useLiveData) const groupKey = peer?.id ?? (normalizeGreEndpointAddr(t.remoteAddress) || t.remoteAddress) const isPrimary = grePrimaryByGroupKey.get(groupKey) === t.id const spGre = @@ -3462,8 +3465,8 @@ function NetworkMapContent() { const res = srvResMap[selected.id] if (!res) return null const cpuPct = res.cpu - const ramPct = Math.round(res.ramUsed / res.ramTotal * 100) - const hddPct = Math.round(res.hddUsed / res.hddTotal * 100) + const ramPct = res.ramUsed==null||res.ramTotal==null||res.ramTotal===0?null:Math.round(res.ramUsed/res.ramTotal*100) + const hddPct = res.hddUsed==null||res.hddTotal==null||res.hddTotal===0?null:Math.round(res.hddUsed/res.hddTotal*100) return (

@@ -3476,7 +3479,7 @@ function NetworkMapContent() { )} {res.fromPoll && (

- CPU, RAM и uptime — с последнего poll MikroTik. Диск в опросе не передаётся, шкала условная. + Последний poll: {res.measuredAt ? new Date(res.measuredAt).toLocaleString("ru-RU") : "время неизвестно"}. Неизвестные ресурсы — «—». История CPU и диск API не передаются.

)} @@ -3485,12 +3488,12 @@ function NetworkMapContent() {
CPU - {cpuPct}% + {cpuPct==null?"—":cpuPct+"%"}
- - + {cpuPct!=null&&} + {res.cpuHistory.length>1&&}
@@ -3502,7 +3505,7 @@ function NetworkMapContent() { {fmtMB(res.ramUsed)} / {fmtMB(res.ramTotal)} - + {ramPct!=null&&} {/* HDD (нет в GET /api/servers — всегда условная шкала) */} @@ -3510,7 +3513,7 @@ function NetworkMapContent() {
HDD - {res.fromPoll && ( + {!useLiveData && ( (оценка) )} @@ -3518,7 +3521,7 @@ function NetworkMapContent() { {fmtMB(res.hddUsed)} / {fmtMB(res.hddTotal)}
- + {hddPct!=null&&} {/* Misc metrics */} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index cf39dd02..1556668b 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,11 +26,18 @@ | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | -| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.46; оптимизатор принят локально, карта сети в работе | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.47; оптимизатор принят локально, карта сети в работе | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.47 — измерения карты без синтетических live значений (2026-10-01) + +- CPU/RAM/uptime читаются независимо из API, сохраняют нули и неизвестность; HDD, температура и история CPU не генерируются для live. Указано фактическое polledAt либо неизвестное время. Демо-ресурсы доступны только с явным demo=true. +- GRE fallback по id отключён по умолчанию, карта явно включает его только в mock. Общий helper также убрал выдуманный fallback из live internet path. Probe DL/UL не округляются до целых, неизвестный Ping имеет нейтральный цвет. Catalog latency сохраняет дробь; модель WAN→JH ещё требует отдельного пояснения. +- 4 unit, 19 E2E карты/дашборда и TypeScript прошли. Lint сравнен с HEAD: новых проблем нет; прежние prefer-const в layout и React правила карты будут устранены при её приёмке. +- Далее Flow Map/DNS: строгие envelopes, частичный отказ/свежесть/гонки; модельные линии, URL-контекст, доступность и адаптивность ещё впереди. + ## Блок 10.46 — неизвестный статус карты (2026-10-01) - Nullable API status преобразуется в unknown: нейтральный узел/бейдж, отдельные фильтр и счётчик. Неизвестность не увеличивает offline. Общий ServerStatus расширен; потребители users/optimizer передают неизвестность как null, dashboard SVG имеет нейтральный стиль. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 8fe2c98f..493ee0c7 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -243,3 +243,11 @@ Shared контракт и backend observed исходные значения/д - 104 E2E карты/дашборда/серверов/мониторинга и TypeScript прошли. Новых lint ошибок нет: карта — прежние 5 ошибок/1 warning, users — прежний set-state-in-effect, dashboard helper — прежние 2 unused warning. В серверном URL тесте был внутренний Next dev warning; сценарий завершился успешно. - Далее убрать синтетические live CPU/RAM/HDD/uptime/history и GRE, затем проверить Flow/DNS и доступность карты. + +## Блок 10.47 — измерения карты без синтетических live значений (2026-10-01) + +- CPU/RAM/uptime читаются независимо из API, сохраняют нули и неизвестность; HDD, температура и история CPU не генерируются для live. Указано фактическое polledAt либо неизвестное время. Демо-ресурсы доступны только с явным demo=true. +- GRE fallback по id отключён по умолчанию, карта явно включает его только в mock. Общий helper также убрал выдуманный fallback из live internet path. Probe DL/UL не округляются до целых, неизвестный Ping имеет нейтральный цвет. Catalog latency сохраняет дробь; модель WAN→JH ещё требует отдельного пояснения. +- 4 unit, 19 E2E карты/дашборда и TypeScript прошли. Lint сравнен с HEAD: новых проблем нет; прежние prefer-const в layout и React правила карты будут устранены при её приёмке. +- Далее Flow Map/DNS: строгие envelopes, частичный отказ/свежесть/гонки; модельные линии, URL-контекст, доступность и адаптивность ещё впереди. + diff --git a/lib/map-gre-speed-probe.ts b/lib/map-gre-speed-probe.ts index 08e5bc7f..4f0fd76f 100644 --- a/lib/map-gre-speed-probe.ts +++ b/lib/map-gre-speed-probe.ts @@ -315,9 +315,9 @@ export function mergeGreMetricsWithSpeedProbe( (sp.lastPingRttMs != null || sp.lastTxAvgMbps != null || sp.lastRxAvgMbps != null) const pingMs = sp?.lastPingRttMs ?? fallback.pingMs const dlMbps = - sp?.lastTxAvgMbps != null ? Math.round(sp.lastTxAvgMbps) : fallback.dlMbps + sp?.lastTxAvgMbps != null ? sp.lastTxAvgMbps : fallback.dlMbps const ulMbps = - sp?.lastRxAvgMbps != null ? Math.round(sp.lastRxAvgMbps) : fallback.ulMbps + sp?.lastRxAvgMbps != null ? sp.lastRxAvgMbps : fallback.ulMbps return { pingMs, dlMbps, ulMbps, hasSpeedMonitor } } diff --git a/lib/network-map-layout.ts b/lib/network-map-layout.ts index a1765ea8..40c1f2ac 100644 --- a/lib/network-map-layout.ts +++ b/lib/network-map-layout.ts @@ -10,14 +10,15 @@ import { wanUplinkIpMatchesGreOuter, } from "@/lib/gre-endpoint-resolve" -/** Визуализация метрик на GRE (нет отдельного API замеров для карты). */ +/** Неизвестные live метрики GRE; синтетические значения доступны только при demo=true. */ export interface TunnelProbe { pingMs: number | null dlMbps: number | null ulMbps: number | null } -export function greTunnelProbe(t: GreTunnel): TunnelProbe { +export function greTunnelProbe(t: GreTunnel, demo = false): TunnelProbe { + if (!demo) return {pingMs:null,dlMbps:null,ulMbps:null} if (t.status === "down" || !t.enabled) return { pingMs: null, dlMbps: null, ulMbps: null } const h = t.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0) @@ -89,15 +90,16 @@ export interface WanJhEdge { } export interface NetworkMapSrvRes { - cpu: number + cpu: number | null cpuHistory: number[] - ramUsed: number - ramTotal: number - hddUsed: number - hddTotal: number - uptimeSeconds: number + ramUsed: number | null + ramTotal: number | null + hddUsed: number | null + hddTotal: number | null + uptimeSeconds: number | null temp?: number - /** CPU/RAM/uptime сняты с последнего снапшота опроса; HDD в API нет — по-прежнему условная шкала. */ + /** Live значения сняты с последнего опроса; отсутствующие поля и HDD неизвестны. */ + measuredAt?: string | null fromPoll?: boolean } @@ -533,8 +535,16 @@ export function buildWanJhEdges(servers: Server[]): WanJhEdge[] { return edges } -/** Ресурсы для карточки узла на карте: при наличии снапшота опроса — как в разделе «Серверы», иначе демо по id. */ -export function buildServerResourceMap(servers: Server[]): Record { +/** Ресурсы API без синтетической истории и fallback; demo=true включает демонстрацию по id. */ +export function buildServerResourceMap(servers: Server[], demo = false): Record { + if (!demo) return Object.fromEntries(servers.map(s=>[s.id,{ + cpu:s.cpuLoad ?? null,cpuHistory:[], + ramTotal:s.totalMemory==null?null:s.totalMemory/(1024*1024), + ramUsed:s.totalMemory==null||s.freeMemory==null?null:(s.totalMemory-s.freeMemory)/(1024*1024), + hddUsed:null,hddTotal:null, + uptimeSeconds:s.uptime && /^(?:\d+[wdhms])+$/.test(s.uptime)?parseRouterosUptimeToSeconds(s.uptime):null, + measuredAt:s.polledAt ?? null,fromPoll:true, + } satisfies NetworkMapSrvRes])) return Object.fromEntries( servers.map((s) => { const h = s.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0) diff --git a/lib/network-map-metrics.test.ts b/lib/network-map-metrics.test.ts new file mode 100644 index 00000000..612f4ac4 --- /dev/null +++ b/lib/network-map-metrics.test.ts @@ -0,0 +1,21 @@ +import {test} from "node:test" +import assert from "node:assert/strict" +import {buildServerResourceMap,greTunnelProbe} from "./network-map-layout" +import {mergeGreMetricsWithSpeedProbe} from "./map-gre-speed-probe" +import {servers,greTunnels} from "./data" +test("live resources preserve zero, partial poll and unknowns without fake history",()=>{ + const base={...servers[0],cpuLoad:undefined,totalMemory:undefined,freeMemory:undefined,uptime:undefined,polledAt:undefined} + const unknown=buildServerResourceMap([base])[base.id] + assert.deepEqual(unknown,{cpu:null,cpuHistory:[],ramTotal:null,ramUsed:null,hddUsed:null,hddTotal:null,uptimeSeconds:null,measuredAt:null,fromPoll:true}) + const partial=buildServerResourceMap([{...base,cpuLoad:0,uptime:"0s",polledAt:"2020-01-01T00:00:00Z"}])[base.id] + assert.equal(partial.cpu,0);assert.equal(partial.uptimeSeconds,0);assert.equal(partial.measuredAt,"2020-01-01T00:00:00Z");assert.equal(partial.ramUsed,null) + const memory=buildServerResourceMap([{...base,totalMemory:1048576,freeMemory:1048576,uptime:"bad"}])[base.id] + assert.equal(memory.ramUsed,0);assert.equal(memory.ramTotal,1);assert.equal(memory.uptimeSeconds,null) + assert.ok(buildServerResourceMap([base],true)[base.id].cpuHistory.length>0) +}) +test("live GRE does not hash metrics and probe values retain zero and fractions",()=>{ + const fallback=greTunnelProbe(greTunnels[0]);assert.deepEqual(fallback,{pingMs:null,dlMbps:null,ulMbps:null}) + assert.equal(greTunnelProbe(greTunnels[0],true).pingMs!=null,true) + const merged=mergeGreMetricsWithSpeedProbe({id:"p",srcServerId:"1",dstServerId:"2",lastPingRttMs:0,lastTxAvgMbps:0,lastRxAvgMbps:0.125},fallback) + assert.deepEqual(merged,{pingMs:0,dlMbps:0,ulMbps:0.125,hasSpeedMonitor:true}) +}) diff --git a/tests/e2e/network-map-data.spec.ts b/tests/e2e/network-map-data.spec.ts index 66a4e2da..2aa3078a 100644 --- a/tests/e2e/network-map-data.spec.ts +++ b/tests/e2e/network-map-data.spec.ts @@ -35,3 +35,17 @@ test("Карта: неизвестный статус не становится await page.getByRole("button",{name:"Неизвестно",exact:true}).click() await expect(page.getByText("Роутер",{exact:true}).first()).toBeVisible() }) + +for(const polled of [false,true])test("Карта: реальные и неизвестные ресурсы "+polled,async({page})=>{ + await page.route("**/health",r=>r.fulfill({json:{ok:true}})) + await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;const data=body(p);return r.fulfill({json:p===paths.servers?[{...(data as object[])[0],...(polled?{cpuLoad:0,totalMemory:1048576,freeMemory:1048576,uptime:"0s",polledAt:"2020-01-01T00:00:00Z"}:{})}]:data})}) + await page.goto("/network-map");await page.getByText("Роутер",{exact:true}).first().click() + const panel=page.getByText("Ресурсы",{exact:true}).locator("..") + await expect(panel.getByText("История CPU и диск API не передаются.",{exact:false})).toBeVisible() + await expect(panel.locator("svg")).toHaveCount(0) + await expect(panel.getByText("HDD",{exact:true})).toBeVisible() + await expect(panel.getByText("— / —",{exact:true})).toHaveCount(polled?1:2) + if(polled){await expect(panel.getByText("0%",{exact:true})).toBeVisible();await expect(panel.getByText("0 МБ / 1 МБ",{exact:true})).toBeVisible();await expect(panel.getByText("0м",{exact:true})).toBeVisible()} + else await expect(panel.getByText("время неизвестно",{exact:false})).toBeVisible() + await expect(panel.getByText("демо-значения",{exact:false})).toHaveCount(0) +})