diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index 8ba74e70..f8129220 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -10,6 +10,7 @@ import { type ServerStatus, type ServerType, } from "@/lib/data" +import {validateNetworkMapSnapshot,type NetworkMapServerRow} from "@/lib/network-map-response" import { useDataSource } from "@/lib/data-source" import { requestJson } from "@/shared/api/http-client" import { @@ -78,45 +79,7 @@ import { Flag, countryName } from "@/components/flag" // ─── Backend → frontend (как /servers) ─────────────────────────────────────── -interface BackendServerRow { - id: number - name: string - host: string - port: number - useSsl: boolean - verifySsl: boolean - username: string - password: string - type: ServerType - site: string - country: string - asn: string - comment: string - enabled: boolean - lanSubnet?: string - wanUplinks?: Array<{ - id: string - name: string - isp: string - iface: string - ip: string - maxDl: number - maxUl: number - }> - status: "online" | "offline" | null - latency: number | null - os: string | null - model: string | null - sessions: number - /** Снапшот последнего poll (как в разделе «Серверы») */ - uptime?: string | null - cpuLoad?: number | null - freeMemory?: number | null - totalMemory?: number | null - polledAt?: string | null - createdAt: string - updatedAt: string -} +type BackendServerRow = NetworkMapServerRow function mapBackendRowToServer(s: BackendServerRow): Server { return { @@ -124,9 +87,9 @@ function mapBackendRowToServer(s: BackendServerRow): Server { name: s.name || s.host, host: s.host, type: s.type, - site: s.site, + site: s.site ?? "—", country: s.country, - asn: s.asn, + asn: s.asn ?? "—", model: s.model ?? "—", os: s.os ?? "—", enabled: s.enabled, @@ -1171,6 +1134,10 @@ function ZoomControls({ zoom, onZoomIn, onZoomOut, onFit }: { // ════════════════════════════════════════════════════════════════════════════ export default function NetworkMapPage() { + const {mode,backendUrl}=useDataSource() + return +} +function NetworkMapContent() { const { mode, backendUrl } = useDataSource() const useLiveData = mode === "live" const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) @@ -1196,6 +1163,10 @@ export default function NetworkMapPage() { const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState>({}) const [dataError, setDataError] = useState(null) const [dataLoading, setDataLoading] = useState(false) + const [hasSnapshot,setHasSnapshot]=useState(false) + const [readAt,setReadAt]=useState(null) + const reader=useRef({alive:true,epoch:0}) + useEffect(()=>{const state=reader.current;state.alive=true;return()=>{state.alive=false;state.epoch++}},[]) const greResolvedMap = useMemo( () => new Map(Object.entries(greResolvedIpv4ByHost)) as ReadonlyMap, @@ -1204,42 +1175,29 @@ export default function NetworkMapPage() { const loadLive = useCallback(async () => { if (!useLiveData) return + const epoch=++reader.current.epoch + const current=()=>reader.current.alive&&reader.current.epoch===epoch + if(!current())return setDataLoading(true) - setDataError(null) try { - const [rows, fr, spRes] = await Promise.all([ - apiFetch("/api/servers"), - apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels").catch(() => ({ tunnels: [] })), - apiFetch<{ - probes?: Array<{ - id: string - srcServerId: string - dstServerId: string - enabled?: boolean - srcInterface?: string - dstInterface?: string - lastTxAvgMbps?: number | null - lastRxAvgMbps?: number | null - lastPingRttMs?: number | null - }> - }>("/api/uptime/speed-probes").catch(() => ({ probes: [] })), + const parts=await Promise.allSettled([ + apiFetch("/api/servers"), + apiFetch("/api/filters/gre-tunnels"), + apiFetch("/api/uptime/speed-probes"), ]) - setMapServers(rows.map(mapBackendRowToServer)) - const mappedTunnels = (fr.tunnels ?? []).map(apiGreToGreTunnel) - setMapGreTunnels(mappedTunnels) - setSpeedProbes( - (spRes.probes ?? []).map((p) => ({ - id: p.id, - srcServerId: String(p.srcServerId), - dstServerId: String(p.dstServerId), - enabled: p.enabled !== false, - srcInterface: p.srcInterface || undefined, - dstInterface: p.dstInterface || undefined, - lastTxAvgMbps: p.lastTxAvgMbps, - lastRxAvgMbps: p.lastRxAvgMbps, - lastPingRttMs: p.lastPingRttMs, - })), - ) + if(!current())return + const labels=["Устройства","Каталог GRE","Измерения"] + const failed=parts.flatMap((part,index)=>part.status==="rejected"?[labels[index]+": "+(part.reason instanceof Error?part.reason.message:"ошибка API")]:[]) + if(failed.length)throw new Error(failed.join("; ")) + let snapshot:ReturnType + try {snapshot=validateNetworkMapSnapshot(...parts.map(part=>part.status==="fulfilled"?part.value:null) as [unknown,unknown,unknown])} + catch(error){throw new Error("Недостоверный снимок карты: "+(error instanceof Error?error.message:"ошибка данных"))} + const rows=snapshot.servers + const mappedTunnels=snapshot.tunnels.map(apiGreToGreTunnel) + if(!current())return + setMapServers(rows.map(mapBackendRowToServer));setMapGreTunnels(mappedTunnels) + 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(collectGreEndpointHostnames(mappedTunnels)) for (const row of rows) { if (!Array.isArray(row.wanUplinks)) continue @@ -1262,17 +1220,19 @@ export default function NetworkMapPage() { body: JSON.stringify({ hosts: hostnames }), }, ) + if(!current())return setGreResolvedIpv4ByHost(Object.fromEntries(greResolvedMapFromApi(res.results))) } catch { + if(!current())return setGreResolvedIpv4ByHost({}) } } else { setGreResolvedIpv4ByHost({}) } } catch (e) { - setDataError(e instanceof Error ? e.message : "Ошибка загрузки") + if(current())setDataError(e instanceof Error ? e.message : "Ошибка загрузки") } finally { - setDataLoading(false) + if(current())setDataLoading(false) } }, [apiFetch, useLiveData]) @@ -2058,13 +2018,13 @@ export default function NetworkMapPage() { // ═════════════════════════════════════════════════════════════════════════ return ( - + {useLiveData && ( - void loadLive()} disabled={dataLoading}> + void loadLive()} disabled={dataLoading}> Обновить @@ -2203,21 +2163,22 @@ export default function NetworkMapPage() { {/* Stats */} - {useLiveData ? "Живые данные · API" : "Демо · mock"} + {useLiveData ? (readAt ? "Снимок API · ответ "+new Date(readAt).toLocaleTimeString("ru-RU")+" (время браузера)" : "Данные карты неизвестны") : "Демо · mock"} + {dataLoading && Обновление снимка карты…} {dataError && ( - - {dataError} + + {dataError}. {hasSnapshot ? "Последний полный снимок; актуальность не подтверждена." : "Первоначальная загрузка карты не подтверждена."} )} - {mapServers.filter(s => s.status === "online").length} онлайн + {useLiveData && !hasSnapshot ? "—" : mapServers.filter(s => s.status === "online").length} онлайн - {mapServers.filter(s => s.status === "degraded").length} с проблемами + {useLiveData && !hasSnapshot ? "—" : mapServers.filter(s => s.status === "degraded").length} с проблемами - {mapServers.filter(s => s.status === "offline").length} оффлайн + {useLiveData && !hasSnapshot ? "—" : mapServers.filter(s => s.status === "offline").length} оффлайн {greEdges.length} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 80f10d87..86c878e7 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.44; оптимизатор принят локально, далее карта сети/терминал | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.45; оптимизатор принят локально, карта сети в работе | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.45 — согласованный снимок карты сети (2026-10-01) + +- Servers/GRE/probes публикуются вместе после строгой проверки схем, уникальности и владельцев. Частичный каталог GRE и неизвестные ссылки отклоняются; первоначальный отказ не становится пустым успешным ответом. При фоновом сбое сохраняется последний полный снимок с явным предупреждением. +- Время ответа браузера отделено от измерений. Новый источник пересоздаёт контекст; epoch/alive запрещают позднему ответу и DNS изменять новую карту. +- 2 unit, 9 HTTP E2E и TypeScript прошли. ESLint карты содержит 5 исходных ошибок и 1 warning; сравнение с HEAD подтвердило отсутствие новых. Их исправление включено в дальнейшую приёмку карты. Next dev имеет собственный пустой alert, поэтому тест восстановления проверяет именно сообщение карты. +- Далее неизвестный статус, ресурсы и GRE без синтетических live значений; Flow/DNS, URL/доступность и приёмка карты ещё не завершены. + ## Блок 10.44 — локальная приёмка оптимизатора (2026-10-01) - Схема расчёта использует общий WAN-аплинк, без утверждения о существующих WAN1/WAN2. Длинные имена/метаданные и badges переносятся; модельная категория/индекс имеют доступные подписи. Текст бейджей не сжимается/не переносится внутри них. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 0ef06908..9dba4dfb 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -228,3 +228,11 @@ Shared контракт и backend observed исходные значения/д ## 10.44. Локальная приёмка оптимизатора 47 разных основных + 8 source E2E, backend optimizer/live-maps, production frontend/backend прошли; девять финальных снимков, ReUI audit/TS/lint. Исходные метрики отдельно от модели, full receipts/URL/source, без mock WAN/foreign Community. Внешние RouterOS/DB/JWT ограничения в журнале. Далее карта сети и терминал, этап 10 ещё открыт. + +## Блок 10.45 — согласованный снимок карты сети (2026-10-01) + +- Servers/GRE/probes публикуются вместе после строгой проверки схем, уникальности и владельцев. Частичный каталог GRE и неизвестные ссылки отклоняются; первоначальный отказ не становится пустым успешным ответом. При фоновом сбое сохраняется последний полный снимок с явным предупреждением. +- Время ответа браузера отделено от измерений. Новый источник пересоздаёт контекст; epoch/alive запрещают позднему ответу и DNS изменять новую карту. +- 2 unit, 9 HTTP E2E и TypeScript прошли. ESLint карты содержит 5 исходных ошибок и 1 warning; сравнение с HEAD подтвердило отсутствие новых. Их исправление включено в дальнейшую приёмку карты. Next dev имеет собственный пустой alert, поэтому тест восстановления проверяет именно сообщение карты. +- Далее неизвестный статус, ресурсы и GRE без синтетических live значений; Flow/DNS, URL/доступность и приёмка карты ещё не завершены. + diff --git a/lib/network-map-response.test.ts b/lib/network-map-response.test.ts new file mode 100644 index 00000000..d1893d50 --- /dev/null +++ b/lib/network-map-response.test.ts @@ -0,0 +1,15 @@ +import {test} from "node:test" +import assert from "node:assert/strict" +import {validateNetworkMapSnapshot} from "./network-map-response" +const server={id:1,name:"Router",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:null,latency:0} +test("network map requires complete envelopes and preserves zero/null",()=>{ + const result=validateNetworkMapSnapshot([server],{tunnels:[],failures:[]},{probes:[]}) + assert.equal(result.servers[0].latency,0);assert.equal(result.servers[0].status,null) + for(const args of [[null,{tunnels:[]},{probes:[]}],[[server],{}, {probes:[]}],[[server],{tunnels:[]},{}],[[server,server],{tunnels:[]},{probes:[]}],[[{...server,freeMemory:20,totalMemory:10}],{tunnels:[]},{probes:[]}],[[{...server,status:undefined}],{tunnels:[]},{probes:[]}]])assert.throws(()=>validateNetworkMapSnapshot(...args as [unknown,unknown,unknown])) +}) +test("network map refuses partial GRE and foreign failure/probe contexts",()=>{ + assert.throws(()=>validateNetworkMapSnapshot([server],{tunnels:[],failures:[{serverId:"1",error:"offline"}]},{probes:[]}),/Неполный/) + assert.throws(()=>validateNetworkMapSnapshot([server],{tunnels:[],failures:[{serverId:"2",error:"offline"}]},{probes:[]}),/неизвестному/) + const probe={id:"p",srcServerId:"1",dstServerId:"2",srcInterface:"",dstInterface:"",enabled:true,lastTxAvgMbps:null,lastRxAvgMbps:null,lastPingRttMs:null,lastPingLossPct:null,lastPingAt:null} + assert.throws(()=>validateNetworkMapSnapshot([server],{tunnels:[]},{probes:[probe]}),/неизвестному/) +}) diff --git a/lib/network-map-response.ts b/lib/network-map-response.ts new file mode 100644 index 00000000..1bb95a1b --- /dev/null +++ b/lib/network-map-response.ts @@ -0,0 +1,20 @@ +import {z} from "zod" +import {optimizerServersSchema,optimizerProbesSchema} from "./route-optimizer-response" +import {filtersGreResponseSchema} from "./filters-response" +import {greResponseSchema} from "./gre-response" +export const networkMapServersSchema=z.array(optimizerServersSchema.element.extend({ + status:z.enum(["online","offline","degraded"]).nullable(), + asn:z.string().optional(),comment:z.string().optional(),lanSubnet:z.string().optional(),os:z.string().nullable().optional(), + sessions:z.number().int().nonnegative().nullable().optional(),uptime:z.string().nullable().optional(), + cpuLoad:z.number().finite().min(0).max(100).nullable().optional(),freeMemory:z.number().int().nonnegative().nullable().optional(),totalMemory:z.number().int().nonnegative().nullable().optional(), + polledAt:z.string().datetime({offset:true}).nullable().optional(), +})).refine(rows=>new Set(rows.map(row=>row.id)).size===rows.length && rows.every(row=>row.freeMemory==null||row.totalMemory==null||row.freeMemory<=row.totalMemory)) +export type NetworkMapServerRow=z.infer[number] +const mapGreSchema=z.object({tunnels:z.array(filtersGreResponseSchema.shape.tunnels.element).refine(rows=>new Set(rows.map(row=>JSON.stringify([row.serverId,row.id]))).size===rows.length),failures:greResponseSchema.shape.failures}).refine(data=>new Set(data.failures.map(row=>row.serverId)).size===data.failures.length&&!data.tunnels.some(tunnel=>data.failures.some(failure=>failure.serverId===tunnel.serverId))) +export function validateNetworkMapSnapshot(servers:unknown,gre:unknown,probes:unknown){ + const inventory=networkMapServersSchema.parse(servers),tunnels=mapGreSchema.parse(gre),measurements=optimizerProbesSchema.parse(probes) + const ids=new Set(inventory.map(row=>String(row.id))) + if(tunnels.tunnels.some(tunnel=>!ids.has(tunnel.serverId))||tunnels.failures.some(failure=>!ids.has(failure.serverId))||measurements.probes.some(probe=>!ids.has(probe.srcServerId)||!ids.has(probe.dstServerId)))throw new Error("Данные карты относятся к неизвестному устройству") + if(tunnels.failures.length)throw new Error("Неполный каталог GRE: "+tunnels.failures.map(failure=>failure.serverName||failure.serverId).join(", ")) + return {servers:inventory,tunnels:tunnels.tunnels,probes:measurements.probes} +} diff --git a/tests/e2e/network-map-data.spec.ts b/tests/e2e/network-map-data.spec.ts new file mode 100644 index 00000000..8b1f743e --- /dev/null +++ b/tests/e2e/network-map-data.spec.ts @@ -0,0 +1,25 @@ +import {test,expect} from "@playwright/test" +const paths={servers:"/api/servers",gre:"/api/filters/gre-tunnels",probes:"/api/uptime/speed-probes"} as const +const body=(path:string,name="Роутер")=>path===paths.servers?[{id:1,name,host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:"online",latency:0}]:path===paths.gre?{tunnels:[],failures:[]}:path===paths.probes?{probes:[]}:path==="/api/auth/config"?{required:false}:{} +for(const part of ["servers","gre","probes"] as const)for(const malformed of [false,true])test("Карта: первоначальный отказ и повтор "+part+"/"+malformed,async({page})=>{ + let failing=true + await page.route("**/health",r=>r.fulfill({json:{ok:true}})) + await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill(failing&&p===paths[part]?(malformed?{json:{}}:{status:403,json:{error:"Нет доступа к данным"}}):{json:body(p)})}) + await page.goto("/network-map") + await expect(page.getByRole("alert").filter({hasText:"Первоначальная загрузка"})).toBeVisible() + await expect(page.getByText("Данные карты неизвестны",{exact:true})).toBeVisible() + failing=false;await page.getByRole("button",{name:"Обновить",exact:true}).click() + await expect(page.getByText("Роутер",{exact:true}).first()).toBeVisible() + await expect(page.getByRole("alert").filter({hasText:"Первоначальная загрузка"})).toHaveCount(0) +}) +for(const part of ["servers","gre","probes"] as const)test("Карта: частичный отказ сохраняет снимок "+part,async({page})=>{ + let failing=false + await page.route("**/health",r=>r.fulfill({json:{ok:true}})) + await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill(failing&&p===paths[part]?{status:503,json:{error:"Источник недоступен"}}:{json:body(p,failing?"Новый роутер":"Последний роутер")})}) + await page.goto("/network-map") + await expect(page.getByText("Последний роутер",{exact:true}).first()).toBeVisible() + failing=true;await page.getByRole("button",{name:"Обновить",exact:true}).click() + await expect(page.getByRole("alert").filter({hasText:"Последний полный снимок"})).toBeVisible() + await expect(page.getByText("Последний роутер",{exact:true}).first()).toBeVisible() + await expect(page.getByText("Новый роутер",{exact:true})).toHaveCount(0) +})