diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index 10288fc5..f619065e 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -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 }: { {hasSpeed && ( - {`↓${dl} ↑${ul}`} + {`↓${dl??"—"} ↑${ul??"—"}`} )} @@ -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 ( {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 && ( - - )} {n.status === "degraded" && isVis && ( @@ -753,13 +753,13 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o )} {!hideCatalogLatency && ( - {n.latency == null ? "офлайн" : `${n.latency} мс`} + {n.latency == null ? "—" : `${n.latency} мс`} )} - {n.type === "home-router" && ( + {(n.name && ( {shortName(n.name)} - )} + ))} ) } @@ -1147,6 +1147,7 @@ function NetworkMapContent() { const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const [mapServers, setMapServers] = useState([]) + const [sessionCounts,setSessionCounts]=useState>({}) const [mapGreTunnels, setMapGreTunnels] = useState([]) const [speedProbes, setSpeedProbes] = useState([]) const [mapHops, setMapHops] = useState([]) @@ -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(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(null) + const [selectedStored, setSelected] = useState(null) + const selected=selectedStored?mapServers.find(server=>server.id===selectedStored.id)??null:null const [selectedService, setSelectedService] = useState(null) /** Раскрытая страна (режим «Страны»): справа столбец её сервисов. Не персистится. */ const [expandedCountryId, setExpandedCountryId] = useState(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(null) - const [selectedGreEdge, setSelectedGreEdge] = useState(null) + const [selectedGreStored, setSelectedGreEdge] = useState(null) // ── Toggles ───────────────────────────────────────────────────────────────── const [filter, setFilter] = useState("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() @@ -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) { @@ -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() const group = new Map() @@ -2182,6 +2184,7 @@ function NetworkMapContent() { {useLiveData&&flowLoading&&(showNetflow||showServices)&&Обновление NetFlow…} {useLiveData&&flowError&&(showNetflow||showServices)&&NetFlow: {flowError}. {flowReadAt?"Сохранён предыдущий снимок NetFlow; актуальность не подтверждена.":"Первоначальная загрузка NetFlow не подтверждена."}} {useLiveData&&dnsError&&DNS: {dnsError}. Связи по неизвестному адресу не подтверждены.} + Пунктир WAN→JH — модель связности; маршрут не подтверждён. {dataLoading && Обновление снимка карты…} {dataError && ( @@ -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 + return })()} {showNetflow && hopHasRate(wanFlow) && ( - {i === 0 ? "primary" : "backup"} + {useLiveData?"модель":i === 0 ? "primary (демо)" : "backup (демо)"} ))} @@ -2901,7 +2906,7 @@ function NetworkMapContent() {
- {rttMon ? "Мониторинг (ping/BT)" : "Модель RTT"} + {rttMon ? "Мониторинг (ping/BT)" : useLiveData?"Ping неизвестен":"Модель RTT (демо)"}
- {bwMon ? "TX / RX (BT)" : "Скорость канала (модель)"} + {bwMon ? "TX / RX (BT)" : useLiveData?"TX / RX неизвестны":"Скорость канала (демо)"} - {merged.dlMbps != null && merged.ulMbps != null - ? `↓${merged.dlMbps} ↑${merged.ulMbps}` + {(merged.dlMbps != null || merged.ulMbps != null) + ? `↓${merged.dlMbps??"—"} ↑${merged.ulMbps??"—"}` : "—"}
@@ -2935,8 +2940,9 @@ function NetworkMapContent() {

{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), не ёмкость канала.

@@ -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]) => (
@@ -3278,7 +3284,7 @@ function NetworkMapContent() {
Задержка - {selected.latency == null ? "— офлайн" : `${selected.latency} мс`} + {selected.latency == null ? "—" : `${selected.latency} мс`}
@@ -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 (демо)"}
@@ -3339,7 +3345,7 @@ function NetworkMapContent() { {myEdges.length > 0 && (

- Подключения к JH + Модель связей с JH · Ping модели

{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 ? "Основная на схеме" : "Дополнительная на схеме"} {t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"} @@ -3418,7 +3424,7 @@ function NetworkMapContent() { {t.ipsec ? IPsec - : Plain} + : {useLiveData?"IPsec неизвестен":"Plain (демо)"}}
{showMetrics && (
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 8e645da5..2cb752e7 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.49; оптимизатор принят локально, карта сети в работе | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.50; оптимизатор принят локально, карта сети в работе | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.50 — актуальные детали и модель связности карты (2026-10-01) + +- Открытые детали server/GRE заново разрешаются по стабильной идентичности в текущем снимке: свежие name/status/resources не остаются на сохранённом объекте. Unknown latency/sessions/IPsec выводятся как неизвестность, не offline/0/Plain. Primary/backup GRE — только приоритет визуализации; WAN API — аплинк каталога. +- Пунктир WAN→JH прямо обозначен моделью связности. Live модель не анимируется как активный маршрут и не показывает сумму latency как RTT линии; Ping модели подписан отдельно в деталях. Probe значения сохраняют нули/частичные DL/UL; подпись показывает probe ID, дату Ping и неизвестное время скорости. +- Узлы всех ролей получили имя устройства и keyboard Enter/Space. Пульсация размера нарушала стабильность области кнопки; удалена, имя видно и для JH/Exit. Прежние React set-state/refs/immutability/exhaustive-deps и prefer-const устранены без подавления lint. +- 24 E2E карты, TypeScript и lint карты/helpers/tests прошли. Новая проверка подтверждает 0 Ping, дробную DL при неизвестной UL и старую дату пробы. Реальная сеть остаётся внешней приёмкой. +- Далее URL-контекст, клавиатурные действия остальных объектов и адаптивность/Frame/screenshots карты. + ## Блок 10.49 — поздние ответы при смене источника карты (2026-10-01) - Добавлена отдельная source-конфигурация Playwright (:3101, исходный backend :8000 и новый :8101). 12 сценариев snapshot/Flow/DNS × backend/mock × успех/отказ подтверждают, что прежняя карта, сервис и ошибка не попадают в новый источник, а старый ответ не запускает дополнительные чтения. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 0aa263a0..ecd96510 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -267,3 +267,12 @@ Shared контракт и backend observed исходные значения/д - Все 12 E2E и lint новых тестов/config прошли. Default suite исключает source-файл; Next серверы запускаются последовательно. - Далее выбранные детали из текущего снимка, подписи модельных WAN связей и прежние lint ошибки карты; затем URL, клавиатура, телефон и визуальная приёмка. + +## Блок 10.50 — актуальные детали и модель связности карты (2026-10-01) + +- Открытые детали server/GRE заново разрешаются по стабильной идентичности в текущем снимке: свежие name/status/resources не остаются на сохранённом объекте. Unknown latency/sessions/IPsec выводятся как неизвестность, не offline/0/Plain. Primary/backup GRE — только приоритет визуализации; WAN API — аплинк каталога. +- Пунктир WAN→JH прямо обозначен моделью связности. Live модель не анимируется как активный маршрут и не показывает сумму latency как RTT линии; Ping модели подписан отдельно в деталях. Probe значения сохраняют нули/частичные DL/UL; подпись показывает probe ID, дату Ping и неизвестное время скорости. +- Узлы всех ролей получили имя устройства и keyboard Enter/Space. Пульсация размера нарушала стабильность области кнопки; удалена, имя видно и для JH/Exit. Прежние React set-state/refs/immutability/exhaustive-deps и prefer-const устранены без подавления lint. +- 24 E2E карты, TypeScript и lint карты/helpers/tests прошли. Новая проверка подтверждает 0 Ping, дробную DL при неизвестной UL и старую дату пробы. Реальная сеть остаётся внешней приёмкой. +- Далее URL-контекст, клавиатурные действия остальных объектов и адаптивность/Frame/screenshots карты. + diff --git a/lib/map-gre-speed-probe.ts b/lib/map-gre-speed-probe.ts index 4f0fd76f..5ed6065d 100644 --- a/lib/map-gre-speed-probe.ts +++ b/lib/map-gre-speed-probe.ts @@ -16,6 +16,7 @@ export interface GreSpeedProbeSnapshot { dstInterface?: string lastTxAvgMbps?: number | null lastRxAvgMbps?: number | null + lastPingAt?: string | null lastPingRttMs?: number | null } diff --git a/lib/network-map-layout.ts b/lib/network-map-layout.ts index 40c1f2ac..f7684aac 100644 --- a/lib/network-map-layout.ts +++ b/lib/network-map-layout.ts @@ -826,8 +826,8 @@ export function buildGreMapEdges( } for (const t of tunnels) { - let a = servers.find((s) => s.id === t.serverId) - let b = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost) + const a = servers.find((s) => s.id === t.serverId) + const b = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost) if (!a || !b) continue if (a.id === b.id) continue diff --git a/tests/e2e/network-map-detail.spec.ts b/tests/e2e/network-map-detail.spec.ts new file mode 100644 index 00000000..c3aefff1 --- /dev/null +++ b/tests/e2e/network-map-detail.spec.ts @@ -0,0 +1,46 @@ +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 wan={id:"w",name:"WAN",iface:"ether1",isp:"ISP",ip:"192.0.2.1",maxDl:100,maxUl:100} +const home={id:1,name:"Домашний",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:"online",latency:2,wanUplinks:[wan]} +const jh={id:2,name:"Jump",host:"192.0.2.2",type:"jump-host",country:"RU",enabled:true,status:"online",latency:3} +const tunnel={id:"t1",serverId:"1",name:"gre-home",localAddress:"192.0.2.1",remoteAddress:"192.0.2.2",localInnerIp:"10.0.0.1",remoteInnerIp:"10.0.0.2",poolId:"",ipsec:null,mtu:1476,keepaliveInterval:10,keepaliveRetries:3,dscp:"inherit",clampTcpMss:true,allowFastPath:false,comment:"",enabled:true,status:"up"} +test.beforeEach(async({page})=>{await page.route("**/health",r=>r.fulfill({json:{ok:true}}))}) +test("Карта: открытые детали обновляются вместе со снимком",async({page})=>{ + let changed=false + await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[{...jh,name:changed?"Новый Jump":"Jump",status:changed?null:"online",cpuLoad:changed?0:20}]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/traffic/flow/map-hops"?empty:p==="/api/auth/config"?{required:false}:{}})}) + await page.goto("/network-map");await page.getByRole("button",{name:"Устройство Jump",exact:true}).click() + await expect(page.getByText("20%",{exact:true})).toBeVisible() + const sessions=page.getByText("Сессий",{exact:true}).locator("..");await expect(sessions).toHaveText("Сессий—") + changed=true;await page.getByRole("button",{name:"Обновить",exact:true}).click() + await expect(page.getByText("0%",{exact:true})).toBeVisible();await expect(page.locator("span").filter({hasText:/^Неизвестно$/}).first()).toBeVisible() + await expect(page.getByText("Jump",{exact:true})).toHaveCount(0);await expect(page.getByText("Новый Jump",{exact:true}).last()).toBeVisible() +}) +test("Карта: модель WAN не утверждает активный маршрут и RTT линии",async({page})=>{ + await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[home,jh]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/traffic/flow/map-hops"?empty:p==="/api/auth/config"?{required:false}:{}})}) + await page.goto("/network-map");await expect(page.getByText("Пунктир WAN→JH — модель связности; маршрут не подтверждён.",{exact:true})).toBeVisible() + await expect(page.locator("svg text").filter({hasText:/^5 мс$/})).toHaveCount(0) + await page.getByText("Домашний",{exact:true}).first().click() + await expect(page.getByText("Аплинк каталога",{exact:true})).toBeVisible();await expect(page.getByText("Модель связей с JH · Ping модели",{exact:true})).toBeVisible() + await expect(page.getByText("primary",{exact:true})).toHaveCount(0);await expect(page.getByText("backup",{exact:true})).toHaveCount(0) +}) +test("Карта: выбранный GRE обновляется и неизвестный IPsec не означает Plain",async({page})=>{ + let changed=false + await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[home,jh]:p==="/api/filters/gre-tunnels"?{tunnels:[{...tunnel,name:changed?"gre-new":"gre-home",status:changed?"down":"up"}],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/traffic/flow/map-hops"?empty:p==="/api/auth/config"?{required:false}:{}})}) + await page.goto("/network-map") + await page.locator("g").filter({has:page.locator("title",{hasText:"RTT неизвестен."})}).last().click() + await expect(page.getByText("gre-home",{exact:true}).last()).toBeVisible();await expect(page.getByText("Ping неизвестен",{exact:true})).toBeVisible() + changed=true;await page.getByRole("button",{name:"Обновить",exact:true}).click() + await expect(page.getByText("gre-new",{exact:true}).last()).toBeVisible();await expect(page.getByText("Недоступен",{exact:true})).toBeVisible() + await page.getByText("Домашний",{exact:true}).first().click();await expect(page.getByText("IPsec неизвестен",{exact:true})).toBeVisible() +}) + +test("Карта: GRE сохраняет частичное измерение, ноль Ping и дату пробы",async({page})=>{ + const probe={id:"measured-probe",srcServerId:"1",dstServerId:"2",srcInterface:"gre-home",dstInterface:"",enabled:true,lastPingRttMs:0,lastPingLossPct:0,lastTxAvgMbps:0.125,lastRxAvgMbps:null,lastPingAt:"2020-01-01T00:00:00Z"} + await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[home,jh]:p==="/api/filters/gre-tunnels"?{tunnels:[tunnel],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[probe]}:p==="/api/traffic/flow/map-hops"?empty:p==="/api/auth/config"?{required:false}:{}})}) + await page.goto("/network-map") + await page.locator("g").filter({has:page.locator("title",{hasText:"Проба measured-probe"})}).last().click() + await expect(page.getByText("0 мс",{exact:true}).last()).toBeVisible() + await expect(page.getByText("↓0.125 ↑—",{exact:true}).last()).toBeVisible() + await expect(page.getByText("Проба measured-probe",{exact:false}).last()).toContainText("2020") + await expect(page.getByText("Проба measured-probe",{exact:false}).last()).toContainText("время TX/RX неизвестно") +})