From 1bce4e1ea2941d91f288fcbd837d28edd3266151 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 01:56:39 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B8=20=D1=81=D0=B2=D0=B5=D0=B6=D0=B5=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?NetFlow=20=D0=BA=D0=B0=D1=80=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/network-map/page.tsx | 96 +++++++++++++++++------------- docs/ux-ui-progress.md | 10 +++- docs/ux-ui-stage-10.md | 9 +++ lib/network-map-response.test.ts | 16 +++++ lib/network-map-response.ts | 10 ++++ tests/e2e/network-map-data.spec.ts | 4 +- tests/e2e/network-map-flow.spec.ts | 39 ++++++++++++ 7 files changed, 139 insertions(+), 45 deletions(-) create mode 100644 tests/e2e/network-map-flow.spec.ts diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index e91e278a..10288fc5 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -10,7 +10,7 @@ import { type ServerStatus, type ServerType, } from "@/lib/data" -import {validateNetworkMapSnapshot,type NetworkMapServerRow} from "@/lib/network-map-response" +import {validateNetworkMapSnapshot,networkMapFlowSchema,validateNetworkMapDns,type NetworkMapServerRow} from "@/lib/network-map-response" import { useDataSource } from "@/lib/data-source" import { requestJson } from "@/shared/api/http-client" import { @@ -58,7 +58,7 @@ import { matchNetflowForWan, type MatchedNetflowHop, } from "@/lib/map-netflow-hops" -import type { FlowMapCountryServiceGroup, FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow" +import type { FlowMapCountryServiceGroup, FlowMapHop, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow" import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon" import { CountryFlagSvg } from "@/components/network-map/country-flag-svg" import { Button } from "@/components/ui/button" @@ -1165,6 +1165,12 @@ function NetworkMapContent() { const [mapCountryLoaded, setMapCountryLoaded] = useState(true) /** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */ const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState>({}) + const [flowError,setFlowError]=useState(null) + const [flowReadAt,setFlowReadAt]=useState(null) + const [flowListener,setFlowListener]=useState(false) + const [flowLoading,setFlowLoading]=useState(false) + const [flowRefresh,setFlowRefresh]=useState(0) + const [dnsError,setDnsError]=useState(null) const [dataError, setDataError] = useState(null) const [dataLoading, setDataLoading] = useState(false) const [hasSnapshot,setHasSnapshot]=useState(false) @@ -1195,7 +1201,7 @@ function NetworkMapContent() { 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:"ошибка данных"))} + catch(error){throw new Error("Недостоверный снимок карты: "+(error instanceof Error?(error.name==="ZodError"?"неполные или некорректные поля API":error.message):"ошибка данных"))} const rows=snapshot.servers const mappedTunnels=snapshot.tunnels.map(apiGreToGreTunnel) if(!current())return @@ -1214,9 +1220,10 @@ function NetworkMapContent() { } } const hostnames = [...hostSet] + setGreResolvedIpv4ByHost({});setDnsError(null) if (hostnames.length > 0) { try { - const res = await apiFetch<{ results: Record }>( + const res = await apiFetch( "/api/network/resolve-hosts", { method: "POST", @@ -1225,10 +1232,14 @@ function NetworkMapContent() { }, ) if(!current())return - setGreResolvedIpv4ByHost(Object.fromEntries(greResolvedMapFromApi(res.results))) - } catch { + const results=validateNetworkMapDns(res,hostnames) + setGreResolvedIpv4ByHost(Object.fromEntries(greResolvedMapFromApi(results))) + const missing=Object.keys(results).filter(host=>results[host]===null) + setDnsError(missing.length?"DNS не разрешён: "+missing.join(", "):null) + } catch(error) { if(!current())return setGreResolvedIpv4ByHost({}) + setDnsError(error instanceof Error?(error.name==="ZodError"?"Некорректный ответ разрешения имён":error.message):"Ошибка DNS") } } else { setGreResolvedIpv4ByHost({}) @@ -1381,56 +1392,53 @@ function NetworkMapContent() { }) return } - if (!showNetflow && !showServices) { - queueMicrotask(() => { - setMapHops([]) - setMapServices([]) - setMapServiceEdges([]) - setMapServicePaths([]) - setMapCountries([]) - setMapCountryEdges([]) - setMapCountryPaths([]) - setMapCountryServiceGroups([]) - }) - return - } + if (!showNetflow && !showServices) return let cancelled = false + let epoch=0 + let inFlight=false let ac: AbortController | null = null const tick = () => { + if(cancelled||inFlight)return + inFlight=true + const request=++epoch + setFlowLoading(true) ac?.abort() ac = new AbortController() - apiFetch("/api/traffic/flow/map-hops?range=5m", { signal: ac.signal }) - .then((res) => { - if (cancelled) return - setMapHops(res.hops ?? []) - setMapServices(res.services ?? []) - setMapServiceEdges(res.serviceEdges ?? []) - setMapServicePaths(res.servicePaths ?? []) - setMapCountries(res.countries ?? []) - setMapCountryEdges(res.countryEdges ?? []) - setMapCountryPaths(res.countryPaths ?? []) - setMapCountryServiceGroups(res.countryServiceGroups ?? []) + apiFetch("/api/traffic/flow/map-hops?range=5m", { signal: ac.signal }) + .then((raw) => { + if (cancelled||request!==epoch) return + const res=networkMapFlowSchema.parse(raw) + setMapHops(res.hops) + setMapServices(res.services) + setMapServiceEdges(res.serviceEdges) + setMapServicePaths(res.servicePaths) + setMapCountries(res.countries) + setMapCountryEdges(res.countryEdges) + setMapCountryPaths(res.countryPaths) + setMapCountryServiceGroups(res.countryServiceGroups) if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct) - setMapNamedBytes(res.namedBytes ?? 0) - setMapTotalBytes(res.totalBytes ?? 0) + setMapNamedBytes(res.namedBytes) + setMapTotalBytes(res.totalBytes) if (res.asnLoaded != null) setMapAsnLoaded(res.asnLoaded) if (res.countryLoaded != null) setMapCountryLoaded(res.countryLoaded) - if (res.windowSec) setMapWindowSec(res.windowSec) + setMapWindowSec(res.windowSec) + setFlowListener(res.live);setFlowReadAt(new Date().toISOString());setFlowError(null) }) .catch((err: unknown) => { - if (cancelled) return + if (cancelled||request!==epoch) return const name = err instanceof Error ? err.name : "" if (name === "AbortError") return - }) + setFlowError(err instanceof Error?(err.name==="ZodError"?"Некорректный ответ: требуется полный снимок за 5 минут":err.message):"Ошибка NetFlow") + }).finally(()=>{inFlight=false;if(!cancelled&&request===epoch)setFlowLoading(false)}) } - tick() + queueMicrotask(()=>{if(!cancelled)tick()}) const id = window.setInterval(tick, 4000) return () => { - cancelled = true + cancelled = true;epoch++ ac?.abort() window.clearInterval(id) } - }, [useLiveData, showNetflow, showServices, apiFetch]) + }, [useLiveData, showNetflow, showServices, apiFetch,flowRefresh]) const effectiveSatPos = useMemo(() => { const out: Record = {} @@ -2029,7 +2037,7 @@ function NetworkMapContent() { actions={ <> {useLiveData && ( - @@ -2142,11 +2150,11 @@ function NetworkMapContent() { ))}

- {mapSharePct > 0 + {useLiveData&&!flowReadAt?"Порог доли неизвестен":mapSharePct > 0 ? `Порог доли ≥ ${mapSharePct}% · Настройки → NetFlow` : "Порог доли выключен (все узлы, макс. 20) · Настройки → NetFlow"}

- {destMode === "countries" && !mapCountryLoaded && ( + {destMode === "countries" && (!useLiveData||flowReadAt) && !mapCountryLoaded && (

GeoIP Country не загружен, страны из RIPE-кэша

@@ -2170,6 +2178,10 @@ function NetworkMapContent() { {useLiveData ? (readAt ? "Снимок API · ответ "+new Date(readAt).toLocaleTimeString("ru-RU")+" (время браузера)" : "Данные карты неизвестны") : "Демо · mock"} + {useLiveData&&{showNetflow||showServices?(flowReadAt?"NetFlow · 5 мин · ответ "+new Date(flowReadAt).toLocaleTimeString("ru-RU")+" (время браузера) · "+(flowListener?"приёмник включён":"приёмник выключен"):"Данные NetFlow неизвестны"):"NetFlow: отображение и обновление выключены"}} + {useLiveData&&flowLoading&&(showNetflow||showServices)&&Обновление NetFlow…} + {useLiveData&&flowError&&(showNetflow||showServices)&&NetFlow: {flowError}. {flowReadAt?"Сохранён предыдущий снимок NetFlow; актуальность не подтверждена.":"Первоначальная загрузка NetFlow не подтверждена."}} + {useLiveData&&dnsError&&DNS: {dnsError}. Связи по неизвестному адресу не подтверждены.} {dataLoading && Обновление снимка карты…} {dataError && ( @@ -3132,7 +3144,7 @@ function NetworkMapContent() { )} - {!mapAsnLoaded && destMode === "services" && ( + {!mapAsnLoaded && (!useLiveData||flowReadAt) && destMode === "services" && (
GeoLite2 ASN не загружена diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 1556668b..fbca9df6 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,11 +26,19 @@ | 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.47; оптимизатор принят локально, карта сети в работе | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.48; оптимизатор принят локально, карта сети в работе | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.48 — достоверность NetFlow и DNS карты (2026-10-01) + +- NetFlow принимает полный DTO backend за 5 минут: поля/flags/счётчики не заменяются default нулями. При отказе сохраняется предыдущий снимок, первоначальная неизвестность и время ответа браузера обозначены отдельно; статус приёмника не выдаётся за свежесть измерения. Обновить повторяет оба независимых чтения. +- Опрос не перекрывает и не отменяет медленный запрос каждые 4 секунды. Cleanup/epoch запрещают старый ответ; скрытие обеих flow групп приостанавливает обновление без потери последнего снимка. +- DNS envelope проверяет точный набор имён и IPv4/null. Новая topology не использует прежний DNS; отказ/неразрешённые имена явно предупреждают о неподтверждённых связях. Неполные API данные выводят краткую понятную ошибку вместо технического JSON Zod. +- 6 unit, 20 разных E2E (19 общих + новый slow, 8 Flow повторены после финального изменения) и TypeScript прошли. Новых lint проблем нет. Первые Flow фикстуры не перехватывали health и получили ECONNREFUSED; фикстуры исправлены, повтор чистый. +- Далее отдельные source/race E2E, достоверность модельных WAN линий/выбранных объектов, затем URL и доступность/адаптивность карты. + ## Блок 10.47 — измерения карты без синтетических live значений (2026-10-01) - CPU/RAM/uptime читаются независимо из API, сохраняют нули и неизвестность; HDD, температура и история CPU не генерируются для live. Указано фактическое polledAt либо неизвестное время. Демо-ресурсы доступны только с явным demo=true. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 493ee0c7..f03927df 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -251,3 +251,12 @@ Shared контракт и backend observed исходные значения/д - 4 unit, 19 E2E карты/дашборда и TypeScript прошли. Lint сравнен с HEAD: новых проблем нет; прежние prefer-const в layout и React правила карты будут устранены при её приёмке. - Далее Flow Map/DNS: строгие envelopes, частичный отказ/свежесть/гонки; модельные линии, URL-контекст, доступность и адаптивность ещё впереди. + +## Блок 10.48 — достоверность NetFlow и DNS карты (2026-10-01) + +- NetFlow принимает полный DTO backend за 5 минут: поля/flags/счётчики не заменяются default нулями. При отказе сохраняется предыдущий снимок, первоначальная неизвестность и время ответа браузера обозначены отдельно; статус приёмника не выдаётся за свежесть измерения. Обновить повторяет оба независимых чтения. +- Опрос не перекрывает и не отменяет медленный запрос каждые 4 секунды. Cleanup/epoch запрещают старый ответ; скрытие обеих flow групп приостанавливает обновление без потери последнего снимка. +- DNS envelope проверяет точный набор имён и IPv4/null. Новая topology не использует прежний DNS; отказ/неразрешённые имена явно предупреждают о неподтверждённых связях. Неполные API данные выводят краткую понятную ошибку вместо технического JSON Zod. +- 6 unit, 20 разных E2E (19 общих + новый slow, 8 Flow повторены после финального изменения) и TypeScript прошли. Новых lint проблем нет. Первые Flow фикстуры не перехватывали health и получили ECONNREFUSED; фикстуры исправлены, повтор чистый. +- Далее отдельные source/race E2E, достоверность модельных WAN линий/выбранных объектов, затем URL и доступность/адаптивность карты. + diff --git a/lib/network-map-response.test.ts b/lib/network-map-response.test.ts index d1893d50..71b7dc73 100644 --- a/lib/network-map-response.test.ts +++ b/lib/network-map-response.test.ts @@ -13,3 +13,19 @@ test("network map refuses partial GRE and foreign failure/probe contexts",()=>{ 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]}),/неизвестному/) }) + +import {networkMapFlowSchema,validateNetworkMapDns} from "./network-map-response" +test("map Flow envelope retains zeros and refuses missing metadata and contradictory totals",()=>{ + const empty={"hops":[],"live":false,"rangeMinutes":5,"windowSec":300,"totalBytes":0,"namedBytes":0,"unclassifiedBytes":0,"asnLoaded":false,"countryLoaded":false,"services":[],"serviceEdges":[],"servicePaths":[],"countries":[],"countryEdges":[],"countryPaths":[],"countryServiceGroups":[],"mapServiceMinSharePct":5,"dedupApplied":false,"excludeMeshApplied":false,"excludeOverlayApplied":false} + assert.equal(networkMapFlowSchema.parse(empty).totalBytes,0) + assert.equal(networkMapFlowSchema.parse(empty).live,false) + assert.throws(()=>networkMapFlowSchema.parse({...empty,totalBytes:undefined})) + assert.throws(()=>networkMapFlowSchema.parse({...empty,namedBytes:1})) + assert.throws(()=>networkMapFlowSchema.parse({...empty,rangeMinutes:10})) +}) +test("map DNS matches exactly requested names and accepts explicit unresolved null",()=>{ + assert.deepEqual(validateNetworkMapDns({results:{"router.example":null}},["router.example"]),{"router.example":null}) + assert.throws(()=>validateNetworkMapDns({results:{}},["router.example"])) + assert.throws(()=>validateNetworkMapDns({results:{"foreign.example":"192.0.2.1"}},["router.example"])) + assert.throws(()=>validateNetworkMapDns({results:{"router.example":"not-ip"}},["router.example"])) +}) diff --git a/lib/network-map-response.ts b/lib/network-map-response.ts index 1bb95a1b..105db863 100644 --- a/lib/network-map-response.ts +++ b/lib/network-map-response.ts @@ -18,3 +18,13 @@ export function validateNetworkMapSnapshot(servers:unknown,gre:unknown,probes:un 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} } + +import {flowMapHopsDtoSchema} from "@mmapp/contracts/traffic-flow" +import {isIpv4String} from "./gre-endpoint-resolve" +export const networkMapFlowSchema=flowMapHopsDtoSchema.required().refine(data=>data.rangeMinutes===5&&data.namedBytes<=data.totalBytes&&data.unclassifiedBytes<=data.totalBytes) +export function validateNetworkMapDns(raw:unknown,hosts:string[]){ + const data=z.object({results:z.record(z.string(),z.string().refine(isIpv4String).nullable())}).parse(raw) + const requested=new Set(hosts.map(host=>host.toLowerCase())) + if(Object.keys(data.results).length!==requested.size||Object.keys(data.results).some(host=>!requested.has(host)))throw new Error("DNS-ответ не соответствует запрошенным именам") + return data.results +} diff --git a/tests/e2e/network-map-data.spec.ts b/tests/e2e/network-map-data.spec.ts index 2aa3078a..3ce7f59d 100644 --- a/tests/e2e/network-map-data.spec.ts +++ b/tests/e2e/network-map-data.spec.ts @@ -6,11 +6,11 @@ for(const part of ["servers","gre","probes"] as const)for(const malformed of [fa 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.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) + 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 diff --git a/tests/e2e/network-map-flow.spec.ts b/tests/e2e/network-map-flow.spec.ts new file mode 100644 index 00000000..4a87ea5c --- /dev/null +++ b/tests/e2e/network-map-flow.spec.ts @@ -0,0 +1,39 @@ +import {test,expect} from "@playwright/test" +const empty={"hops":[],"live":false,"rangeMinutes":5,"windowSec":300,"totalBytes":0,"namedBytes":0,"unclassifiedBytes":0,"asnLoaded":false,"countryLoaded":false,"services":[],"serviceEdges":[],"servicePaths":[],"countries":[],"countryEdges":[],"countryPaths":[],"countryServiceGroups":[],"mapServiceMinSharePct":5,"dedupApplied":false,"excludeMeshApplied":false,"excludeOverlayApplied":false} +const flowPath="/api/traffic/flow/map-hops" +test.beforeEach(async({page})=>{await page.route("**/health",r=>r.fulfill({json:{ok:true}}))}) +const server={id:1,name:"Router",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:"online"} +for(const malformed of [false,true])test("Карта: NetFlow отказ и повтор "+malformed,async({page})=>{ + let failing=true + await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill(p===flowPath?(failing?(malformed?{json:{}}:{status:503,json:{error:"NetFlow недоступен"}}):{json:empty}):{json:p==="/api/servers"?[server]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/auth/config"?{required:false}:{}})}) + await page.goto("/network-map") + await expect(page.getByRole("alert").filter({hasText:"Первоначальная загрузка NetFlow"})).toBeVisible() + await expect(page.getByText("Данные NetFlow неизвестны",{exact:true})).toBeVisible() + failing=false;await page.getByRole("button",{name:"Обновить",exact:true}).click() + await expect(page.getByText(/NetFlow · 5 мин · ответ/)).toContainText("приёмник выключен") + await expect(page.getByRole("alert").filter({hasText:"NetFlow:"})).toHaveCount(0) +}) +test("Карта: NetFlow сохраняет сервис при фоновом сбое",async({page})=>{ + let failing=false + await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill(p===flowPath?(failing?{status:403,json:{error:"Нет доступа к NetFlow"}}:{json:{...empty,live:true,totalBytes:100,namedBytes:100,services:[{id:"svc:one",label:"Сервис сохранён",category:"Web",bytes:100,bps:0,share:1}]}}):{json:p==="/api/servers"?[server]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/auth/config"?{required:false}:{}})}) + 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:"Сохранён предыдущий снимок NetFlow"})).toBeVisible() + await expect(page.getByText("Сервис сохранён",{exact:true}).first()).toBeVisible() +}) +for(const result of ["missing","foreign","unresolved","success"])test("Карта: DNS "+result,async({page})=>{ + await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p===flowPath?empty:p==="/api/servers"?[{...server,wanUplinks:[{id:"w",name:"WAN",iface:"ether1",isp:"ISP",ip:"router.example",maxDl:100,maxUl:100}]}]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/network/resolve-hosts"?(result==="missing"?{}:{results:result==="foreign"?{"foreign.example":"192.0.2.2"}:{"router.example":result==="unresolved"?null:"192.0.2.2"}}):p==="/api/auth/config"?{required:false}:{}})}) + await page.goto("/network-map") + await expect(page.getByText("Router",{exact:true}).first()).toBeVisible() + if(result==="success")await expect(page.getByRole("alert").filter({hasText:"DNS:"})).toHaveCount(0) + else await expect(page.getByRole("alert").filter({hasText:"Связи по неизвестному адресу"})).toBeVisible() +}) + + test("Карта: медленный NetFlow не прерывается периодическим опросом",async({page})=>{ + let requests=0,release!:()=>void;const gate=new Promise(resolve=>{release=resolve}) + await page.clock.install() + await page.route("**/api/**",async r=>{const p=new URL(r.request().url()).pathname;if(p===flowPath){requests++;await gate;return r.fulfill({json:empty})}return r.fulfill({json:p==="/api/servers"?[server]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/auth/config"?{required:false}:{}})}) + await page.goto("/network-map");await expect.poll(()=>requests).toBe(1) + await page.clock.runFor(5000);expect(requests).toBe(1) + release();await expect(page.getByText(/NetFlow · 5 мин · ответ/)).toBeVisible() + })