diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index f77b0057..5d72d59b 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -4,6 +4,11 @@ import { useState, useRef, useEffect, useMemo, useCallback, Suspense } from "rea import { useSearchParams } from "next/navigation" import {readNetworkMapUrl,patchNetworkMapUrl,type NetworkMapFilter} from "@/lib/network-map-url-state" import {Alert,AlertTitle,AlertDescription} from "@/components/ui/alert" +import {Frame,FramePanel} from "@/components/reui/frame" +import {Popover,PopoverTrigger,PopoverContent} from "@/components/ui/popover" +import {Dialog,DialogContent,DialogHeader,DialogTitle,DialogDescription} from "@/components/ui/dialog" +import {usePrefersReducedMotion} from "@/hooks/use-prefers-reduced-motion" +import {toast} from "sonner" import { PageHeader } from "@/components/page-header" import { servers as mockServers, @@ -71,7 +76,7 @@ import { DownloadIcon, XIcon, LockIcon, LockOpenIcon, WifiIcon, RefreshCwIcon, ZoomInIcon, ZoomOutIcon, Maximize2Icon, SearchIcon, LayersIcon, MapIcon, HomeIcon, TerminalIcon, ShieldIcon, - CableIcon, CopyIcon, ActivityIcon, ExternalLinkIcon, + CableIcon, ActivityIcon, } from "lucide-react" import { cn } from "@/lib/utils" import { formatServicePathLabel, formatServicePathTitle } from "@/lib/format-service-path-label" @@ -224,26 +229,26 @@ const ZOOM_MAX = 6 const STATUS_STYLE = { unknown: { fill: "var(--muted)", stroke: "var(--muted-foreground)", text: "var(--muted-foreground)", glow: "transparent" }, - online: { fill: "#0f2d1f", stroke: "#4ade80", text: "#4ade80", glow: "rgba(74,222,128,0.15)" }, - degraded: { fill: "#2d1e06", stroke: "#fbbf24", text: "#fbbf24", glow: "rgba(251,191,36,0.15)" }, - offline: { fill: "#2d0f0f", stroke: "#f87171", text: "#f87171", glow: "transparent" }, + online: { fill: "var(--status-online-bg)", stroke: "var(--status-online)", text: "var(--status-online)", glow: "color-mix(in oklch,var(--status-online) 15%,transparent)" }, + degraded: { fill: "var(--status-degraded-bg)", stroke: "var(--status-degraded)", text: "var(--status-degraded)", glow: "color-mix(in oklch,var(--status-degraded) 15%,transparent)" }, + offline: { fill: "var(--status-offline-bg)", stroke: "var(--status-offline)", text: "var(--status-offline)", glow: "transparent" }, } const TYPE_STYLE: Record = { // HR — визуальный корень топологии: крупнее и с более сильным акцентом. - "home-router": { label: "HR", fill: "#16a34a", r: 40 }, + "home-router": { label: "HR", fill: "var(--chart-2)", r: 40 }, // JH — агрегирующий слой по центру. - "jump-host": { label: "JH", fill: "#7c3aed", r: 34 }, + "jump-host": { label: "JH", fill: "var(--chart-1)", r: 34 }, // EN — крайние edge-узлы, менее акцентные. - "exit-node": { label: "EN", fill: "#0369a1", r: 30 }, + "exit-node": { label: "EN", fill: "var(--chart-3)", r: 30 }, } -const WAN_COLORS = ["#0ea5e9", "#f97316", "#a855f7", "#ec4899", "#14b8a6"] +const WAN_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"] const TUNNEL_STYLE = { - up: { stroke: "#4ade80", opacity: 0.5, dotOpacity: 0.85 }, - degraded: { stroke: "#fbbf24", opacity: 0.4, dotOpacity: 0.7 }, - down: { stroke: "#f87171", opacity: 0.15, dotOpacity: 0 }, + up: { stroke: "var(--status-online)", opacity: 0.5, dotOpacity: 0.85 }, + degraded: { stroke: "var(--status-degraded)", opacity: 0.4, dotOpacity: 0.7 }, + down: { stroke: "var(--status-offline)", opacity: 0.15, dotOpacity: 0 }, } const TYPE_LABELS: Record = { @@ -427,17 +432,18 @@ function drawableServiceEdges( function pingColor(ms: number | null) { if (ms == null) return "var(--muted-foreground)" - if (ms < 15) return "#4ade80" - if (ms < 50) return "#fbbf24" - return "#fb923c" + if (ms < 15) return "var(--status-online)" + if (ms < 50) return "var(--status-degraded)" + return "var(--status-degraded)" } function latencyColor(ms: number | null) { if (ms == null) return "var(--muted-foreground)" - if (ms > 60) return "#fbbf24" - return "#4ade80" + if (ms > 60) return "var(--status-degraded)" + return "var(--status-online)" } function shortName(name: string) { - return name.replace(/^(home|mt)-/, "").split("-").slice(0, 3).join("-") + const label = name.replace(/^(home|mt)-/, "").split("-").slice(0, 3).join("-") + return label.length > 24 ? label.slice(0, 23).trimEnd() + "…" : label } function greStatusToServerStatus(s: "up" | "down" | "degraded"): ServerStatus { @@ -495,13 +501,13 @@ function PingBadge({ mx, my, ping, dl, ul, color }: { WAN → JumpHost: latency (из каталога) дом + latency JH, без отдельного ping по ребру. Не RTT по GRE. + fill="var(--background)" stroke={color} strokeWidth="0.7" opacity="0.92" /> {ping == null ? "—" : `${ping} мс`} {hasSpeed && ( - + {`↓${dl??"—"} ↑${ul??"—"}`} )} @@ -527,7 +533,7 @@ function GreEdgeMetricBadge({ pingMs: number | null dl: number | null ul: number | null - onOpen: (e: React.MouseEvent) => void + onOpen: (e: React.SyntheticEvent) => void /** Данные из Мониторинг → скорость (uptime speed-probes) */ rttFromMonitor?: boolean throughputFromMonitor?: boolean @@ -549,6 +555,8 @@ function GreEdgeMetricBadge({ "Клик — панель справа." return ( {if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onOpen(e)}}} transform={`translate(${mx},${my})`} style={{ cursor: "pointer" }} onPointerDown={(e) => { e.stopPropagation() }} @@ -561,8 +569,8 @@ function GreEdgeMetricBadge({ width={bw} height={bh} rx="6" - fill="rgba(6,13,26,0.94)" - stroke="#fbbf24" + fill="var(--popover)" + stroke="var(--status-degraded)" strokeWidth="1.15" /> - {v1} + {v1} {hasSpeed && ( - {`↓${dl} ↑${ul}`} + {`↓${dl??"—"} ↑${ul??"—"}`} )} @@ -591,13 +599,15 @@ function NetflowRateBadge({ mx: number my: number hop: MatchedNetflowHop - onOpen?: (e: React.MouseEvent) => void + onOpen?: (e: React.SyntheticEvent) => void }) { const showDir = hop.bpsFwd > 0 && hop.bpsRev > 0 const bw = showDir ? 86 : 72 const bh = showDir ? 32 : 20 return ( {if(onOpen&&(e.key==="Enter"||e.key===" ")){e.preventDefault();e.stopPropagation();onOpen(e)}}} transform={`translate(${mx},${my})`} style={{ cursor: onOpen ? "pointer" : "default" }} onPointerDown={(e) => { e.stopPropagation() }} @@ -612,16 +622,16 @@ function NetflowRateBadge({ width={bw} height={bh} rx="6" - fill="rgba(6,13,26,0.94)" - stroke="#34d399" + fill="var(--popover)" + stroke="var(--status-online)" strokeWidth="1.15" /> - {formatNetflowRate(hop)} + {formatNetflowRate(hop)} {showDir && ( - {formatNetflowDir(hop)} + {formatNetflowDir(hop)} )} @@ -642,16 +652,16 @@ function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) { ].filter(Boolean) const bw = 148, bh = lines.length * 14 + 14 return ( - + )} {!hideCatalogLatency && ( @@ -753,7 +763,7 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o )} {(n.name && ( - {shortName(n.name)} ))} @@ -795,7 +805,8 @@ function ServiceNode({ const bh = MAP_SERVICE_NODE_H const flagIso = destMode === "countries" && iso && iso !== "Прочее" ? iso : "" return ( - {if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}} transform={`translate(${x},${y})`} style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }} opacity={isVis ? (dim ? 0.35 : 1) : 0.08} @@ -811,7 +822,7 @@ function ServiceNode({ height={bh + 12} rx="12" fill="none" - stroke="rgba(56,189,248,0.55)" + stroke="var(--chart-4)" strokeWidth="1.5" strokeDasharray="4 3" /> @@ -822,19 +833,19 @@ function ServiceNode({ width={bw} height={bh} rx="10" - fill="#08202c" - stroke="#22d3ee" + fill="var(--card)" + stroke="var(--chart-4)" strokeWidth={isSel ? 2.2 : 1.4} /> {flagIso ? - : } + : - + {label} - + {serviceSharePct(share)} @@ -883,9 +894,8 @@ function ServicePathList({ && highlight.serviceId === p.serviceId, ) return ( - - - - + {if(!open)onClose()}}> + document.querySelector("[data-map-device-actions]")??document.querySelector("[data-map-device-select]")}> + + Действия устройства · {menu.server.name} + {menu.server.host} + + + + + + + ) } @@ -1042,12 +1028,12 @@ function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, } return ( -
- + {/* simplified edges */} {greEdges.map((e) => ( (satPos[r.id] ?? []).map((p, i) => ( + fill={`color-mix(in oklch,${WAN_COLORS[i]} 20%,transparent)`} stroke={WAN_COLORS[i]} strokeWidth="3" opacity="0.7" /> )) )} {Object.entries(servicePos).map(([id, p]) => ( + fill="var(--card)" stroke="var(--chart-4)" strokeWidth="3" opacity="0.85" /> ))} {/* viewport rect */} + fill="var(--muted)" fillOpacity="0.1" stroke="var(--foreground)" strokeOpacity="0.6" strokeWidth="6" rx="6" /> - - minimap + minimap
) } @@ -1107,25 +1093,25 @@ function ZoomControls({ zoom, onZoomIn, onZoomOut, onFit }: { }) { return (
- - + {Math.round(zoom * 100)}% - -
-
) @@ -1373,6 +1359,8 @@ function NetworkMapContent() { // ── Context menu ──────────────────────────────────────────────────────────── const [ctxMenu, setCtxMenu] = useState(null) + const currentCtxServer=ctxMenu?mapServers.find(server=>server.id===ctxMenu.server.id):null + useEffect(()=>{if(ctxMenu&&!currentCtxServer)queueMicrotask(()=>setCtxMenu(null))},[ctxMenu,currentCtxServer]) const [selectedGreStored, setSelectedGreEdge] = useState(null) @@ -1384,6 +1372,7 @@ function NetworkMapContent() { const [showPingBadges, setShowPingBadges] = useState(true) const [showNetflow, setShowNetflow] = useState(true) const [showServices, setShowServices] = useState(true) + const reducedMotion=usePrefersReducedMotion() const [showAnimDots, setShowAnimDots] = useState(true) const [showMinimap, setShowMinimap] = useState(true) const [showHints, setShowHints] = useState(false) @@ -1723,8 +1712,16 @@ function NetworkMapContent() { clone.setAttribute("viewBox", `0 0 ${W} ${H}`) clone.setAttribute("width", String(W)) clone.setAttribute("height", String(H)) + clone.setAttribute("xmlns","http://www.w3.org/2000/svg") + const computed=getComputedStyle(svg) + const variables=new Set([...clone.outerHTML.matchAll(/var\((--[a-zA-Z0-9_-]+)/g)].map(match=>match[1])) + variables.add("--background") + for(const name of variables){const value=computed.getPropertyValue(name).trim();if(!value)continue;clone.style.setProperty(name,value);for(const match of value.matchAll(/var\((--[a-zA-Z0-9_-]+)/g))variables.add(match[1])} + clone.style.width=W+"px";clone.style.height=H+"px" + const background=document.createElementNS("http://www.w3.org/2000/svg","rect") + background.setAttribute("width",String(W));background.setAttribute("height",String(H));background.setAttribute("fill","var(--background)");clone.prepend(background) const blob = new Blob( - [`\n`, clone.outerHTML], + [`\n`, new XMLSerializer().serializeToString(clone)], { type: "image/svg+xml;charset=utf-8" }, ) const url = URL.createObjectURL(blob) @@ -1784,7 +1781,8 @@ function NetworkMapContent() { // ── Keyboard shortcuts ──────────────────────────────────────────────────── useEffect(() => { function onKey(e: KeyboardEvent) { - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + if (ctxMenu||showLayers) return + if (e.key!=="Escape"&&(e.ctrlKey||e.metaKey||e.altKey||e.target instanceof HTMLInputElement||e.target instanceof HTMLTextAreaElement||e.target instanceof HTMLSelectElement))return if (e.key === "Escape") { setSelected(null) updateUrl({wan:null}) @@ -1801,7 +1799,7 @@ function NetworkMapContent() { } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) - }, [applyZoomCenter,fitView,setSelected,updateUrl]) + }, [applyZoomCenter,fitView,setSelected,updateUrl,ctxMenu,showLayers]) // ── Drag ───────────────────────────────────────────────────────────────── function onSvgMouseDown(e: React.MouseEvent) { @@ -1969,7 +1967,7 @@ function NetworkMapContent() { )) } - function openWanJhSpeedDetail(ev: React.MouseEvent, edge: WanJhEdge) { + function openWanJhSpeedDetail(ev: React.SyntheticEvent, edge: WanJhEdge) { ev.stopPropagation() const home = mapServers.find((s) => s.id === edge.homeId) if (!home) return @@ -2045,32 +2043,32 @@ function NetworkMapContent() { // ═════════════════════════════════════════════════════════════════════════ return ( -
+
{useLiveData && ( )} - } /> {urlState.server&&((hasSnapshot||!useLiveData)&&!selected||selected&&selWanIdx!==null&&!selected.wanUplinks?.[selWanIdx])&&Объект карты недоступен{!selected?"Устройство "+urlState.server+" отсутствует в текущем снимке.":"Запрошенный WAN отсутствует у выбранного устройства."}} - {/* ── Toolbar ── */} -
+ + {/* Filter pills */} -
+
{filterBtns.map(b => ( - )}
+ {/* Dest overlay: services vs countries */}
{([ { value: "services" as const, label: "Сервисы" }, { value: "countries" as const, label: "Страны" }, ]).map((b) => ( -
{/* Layers dropdown */} -
- - {showLayers && ( -
setShowLayers(false)}> +
)} -
- )} -
+ + {/* Stats */} -
+
{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}. Связи по неизвестному адресу не подтверждены.} + {useLiveData&&flowError&&(showNetflow||showServices)&&NetFlow: {flowError}. {flowReadAt?"Сохранён предыдущий снимок NetFlow; актуальность не подтверждена.":"Первоначальная загрузка NetFlow не подтверждена."}} + {useLiveData&&dnsError&&DNS: {dnsError}. Связи по неизвестному адресу не подтверждены.} Пунктир WAN→JH — модель связности; маршрут не подтверждён. {dataLoading && Обновление снимка карты…} {dataError && ( - + {dataError}. {hasSnapshot ? "Последний полный снимок; актуальность не подтверждена." : "Первоначальная загрузка карты не подтверждена."} )} @@ -2221,15 +2218,15 @@ function NetworkMapContent() { {" "}GRE
-
+
{/* ── Main area ── */} -
+ {/* ── Canvas ── */} -
+
{ e.preventDefault(); onSvgMouseDown(e) }} @@ -2242,7 +2239,7 @@ function NetworkMapContent() { > - + {WAN_COLORS.map((_, i) => ( @@ -2293,7 +2290,7 @@ function NetworkMapContent() { tBadge, -normalPx - (normalPx === 0 ? 22 : 0), ) - function openGreDetail(ev: React.MouseEvent) { + function openGreDetail(ev: React.SyntheticEvent) { ev.stopPropagation() setSelectedGreEdge(e) setSelected(null) @@ -2320,7 +2317,7 @@ function NetworkMapContent() { /> GRE: подробнее - {showAnimDots && ts.dotOpacity > 0 && ( + {showAnimDots && !reducedMotion && ts.dotOpacity > 0 && ( @@ -2367,7 +2364,7 @@ function NetworkMapContent() { if (!rNode) return [] return (router.wanUplinks ?? []).map((wan, wIdx) => { const sat = effectiveSatPos[router.id]?.[wIdx] - const color = WAN_COLORS[wIdx] ?? "#888" + const color = WAN_COLORS[wIdx] ?? "var(--muted-foreground)" const vis = filter === "all" || filter === "home-router" || filter === "online" if (!sat) return null return ( @@ -2385,7 +2382,7 @@ function NetworkMapContent() { const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx] if (!jh || !satPos) return null const edgeKey = `${edge.homeId}-${edge.wanIdx}-${edge.jhId}` - const color = WAN_COLORS[edge.wanIdx] ?? "#888" + const color = WAN_COLORS[edge.wanIdx] ?? "var(--muted-foreground)" const vis = filter === "all" || filter === "home-router" || filter === "jump-host" || filter === "online" const { mx, my } = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.62, -17) const flowPos = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.38, 18) @@ -2402,7 +2399,7 @@ function NetworkMapContent() { opacity={edge.active ? 0.7 : 0.4} filter={isHL ? `url(#glow-wan-${edge.wanIdx})` : undefined} /> - {showAnimDots && edge.active && ( + {showAnimDots && !reducedMotion && edge.active && ( @@ -2475,7 +2472,7 @@ function NetworkMapContent() { return (router.wanUplinks ?? []).map((wan, wIdx) => { const sat = effectiveSatPos[router.id]?.[wIdx] if (!sat) return null - const color = WAN_COLORS[wIdx] ?? "#888" + const color = WAN_COLORS[wIdx] ?? "var(--muted-foreground)" const isActive = wanJhEdges.filter(e => e.homeId === router.id && e.wanIdx === wIdx).some(e => e.active) const isSatSel = selected?.id === router.id && selWanIdx === wIdx const satKey = `${router.id}-${wIdx}` @@ -2591,7 +2588,7 @@ function NetworkMapContent() { {pathTitle} - {showAnimDots && hopHasRate(hop) && ( - + {showAnimDots && !reducedMotion && hopHasRate(hop) && ( + @@ -2666,7 +2663,7 @@ function NetworkMapContent() { {pathTitle} - {showAnimDots && hopHasRate(hop) && ( - + {showAnimDots && !reducedMotion && hopHasRate(hop) && ( + @@ -2712,56 +2709,56 @@ function NetworkMapContent() { )} {/* ── Legend (viewport-fixed) ── */} - +
+ border border-border bg-popover/95 backdrop-blur-sm text-xs font-mono text-muted-foreground"> {nodes.filter(isVisible).length === 0 ? "Ничего не найдено" : `Найдено: ${nodes.filter(isVisible).length} узл${nodes.filter(isVisible).length === 1 ? "" : nodes.filter(isVisible).length < 5 ? "а" : "ов"}`} @@ -2840,38 +2837,36 @@ function NetworkMapContent() { {/* ── Side panel (узел или выбранное GRE-ребро) ── */} {(selectedGreEdge || selected || selectedService) && ( -
+
{selectedGreEdge ? ( <>
-

{selectedGreEdge.tunnel.name}

-

+

{selectedGreEdge.tunnel.name}

+

GRE · {selectedGreEdge.fromServer.name} ({TYPE_LABELS[selectedGreEdge.fromServer.type]}) ↔{" "} {selectedGreEdge.toServer.name} ({TYPE_LABELS[selectedGreEdge.toServer.type]})

- -
-
+
{(() => { const edge = selectedGreEdge const t = edge.tunnel @@ -2967,23 +2962,23 @@ function NetworkMapContent() {
На узле - {edge.fromServer.name} + {edge.fromServer.name}
Пир (каталог) - {edge.toServer.name} + {edge.toServer.name}
Включён - {t.enabled ? "да" : "нет"} + {t.enabled ? "да" : "нет"}
IPsec {t.ipsec ? ( - <> да + <>
@@ -2996,7 +2991,7 @@ function NetworkMapContent() {
Local outer - + {t.localAddress || "—"}
@@ -3008,7 +3003,7 @@ function NetworkMapContent() { )}
Remote outer - + {t.remoteAddress || "—"}
@@ -3026,7 +3021,7 @@ function NetworkMapContent() {
MTU / Keepalive - + {t.mtu} /{" "} {t.keepaliveInterval === 0 ? "выкл." : `${t.keepaliveInterval}s×${t.keepaliveRetries}`} @@ -3050,25 +3045,24 @@ function NetworkMapContent() { <>
- +
-

+

{selectedNestedService.label}

{`Сервис в ${destDisplayLabel(expandedCountry ?? undefined, "countries")} · ${selectedNestedService.category}`}

-
-
+
Доля страны @@ -3084,7 +3078,7 @@ function NetworkMapContent() { )}
Скорость - + {formatNetflowRate({ bytes: selectedNestedService.bytes, bps: selectedNestedService.bps, @@ -3117,10 +3111,10 @@ function NetworkMapContent() {
{destMode === "countries" && liveSelectedService.label !== "Прочее" && liveSelectedService.id !== "cc:other" ? - : } + :
-

+

{destDisplayLabel(liveSelectedService, destMode)}

@@ -3129,15 +3123,14 @@ function NetworkMapContent() { : `Конечный сервис · ${liveSelectedService.category}`}

-
-
+
Доля окна @@ -3177,7 +3170,7 @@ function NetworkMapContent() { )}
Скорость - + {formatNetflowRate({ bytes: liveSelectedService.bytes, bps: liveSelectedService.bps, @@ -3192,9 +3185,8 @@ function NetworkMapContent() {

Сервисы в стране

{expandedCountryGroup.services.map((svc) => ( -

{selected.host}

- -
-
+
{/* Badges */}
@@ -3291,7 +3284,7 @@ function NetworkMapContent() { ].map(([k, v]) => (
{k} - {v} + {v}
))}
@@ -3310,7 +3303,7 @@ function NetworkMapContent() {

{selected.wanUplinks.map((wan, wIdx) => { - const color = WAN_COLORS[wIdx] ?? "#888" + const color = WAN_COLORS[wIdx] ?? "var(--muted-foreground)" const myEdges = wanJhEdges.filter(e => e.homeId === selected.id && e.wanIdx === wIdx) const isActive = myEdges.some(e => e.active) const isSel = selWanIdx === wIdx @@ -3323,7 +3316,7 @@ function NetworkMapContent() { )} style={{ borderColor: isSel ? color : undefined }}>
- +
+ + {/* ── Context menu ── */} - {ctxMenu && setCtxMenu(null)} />} + {ctxMenu&¤tCtxServer&&setCtxMenu(null)} />}
) } diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 7536e4cd..cf0e377d 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -181,3 +181,12 @@ HTTP-фикстуры; локальная приёмка: 43 основных + | Community · [Data Grid Filtering 2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | [Снимок](ux-ui/screens/route-optimizer-community-desktop.png) | [390 px](ux-ui/screens/route-optimizer-community-mobile.png), [320 px](ux-ui/screens/route-optimizer-community-narrow.png) | Устройство → базовый расчёт → модельная рекомендация → закрепление | 47 основных + 8 source E2E, backend optimizer/live-maps, production frontend/backend прошли. Исходные значения отдельно от модели; Community apply недоступен, демо только симуляция. HTTP/DI фикстуры; реальный RouterOS/JWT/БД остаётся внешней приёмкой. Далее карта/терминал; ограничения и точное продолжение — в журнале. + +## Карта сети — промежуточные экраны 2026-10-01 + +| Экран / референс | ПК | Телефон | Схема | +| --- | --- | --- | --- | +| Оболочка · [Frame](https://reui.io/components/frame?ref=mcp) / [API](https://reui.io/docs/components/base/frame?ref=mcp) | [Обзор](ux-ui/screens/network-map-overview-desktop.png) | [390 px](ux-ui/screens/network-map-overview-mobile.png), [320 px](ux-ui/screens/network-map-overview-narrow.png) | Источник → свежесть → слои/фильтры → карта | +| Детали · Frame / [List 9](https://reui.io/preview/base/list-9?ref=mcp) | [Детали](ux-ui/screens/network-map-detail-desktop.png) | [390 px](ux-ui/screens/network-map-detail-mobile.png), [320 px](ux-ui/screens/network-map-detail-narrow.png) | Устройство → фактические поля → действия → возврат | + +Оболочка и клавиатурные действия проверены (35 + 12 E2E). Масштаб малой топологии требует следующего блока; это промежуточные снимки, не итоговая приёмка карты. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 73c3782b..4f355496 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,11 +26,20 @@ | 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.51; оптимизатор принят локально, карта сети в работе | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.52; оптимизатор принят локально, карта сети в работе | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | + +## Блок 10.52 — Frame, доступные действия и детали карты (2026-10-01) + +- Установленный ReUI Frame объединяет toolbar, SVG и детали с parent-owned высотой. На ПК детали справа; на телефоне занимают рабочую область, закрытие возвращает карту. Выбор устройства доступен вне SVG, длинные имена переносятся по словам, отсутствует горизонтальный overflow на 1440/390/320 px. +- Действия устройства используют стандартный Base UI Dialog с действительными ссылками и обработкой отказа clipboard. Слои — Popover; Escape возвращает фокус и сохраняет server URL. Узлы устройств/WAN/сервисов и метрики GRE/Flow доступны Enter/Space; кнопки названы, декоративные иконки скрыты от screen reader. +- SVG использует semantic tokens; reduced motion отключает animateMotion. Экспорт сохраняет CSS переменные/фон и сериализуется XMLSerializer как standalone SVG. Nullable UL не выводится строкой null; неизвестный IPsec явно подписан. +- 35 основных + 12 source E2E, TypeScript и lint прошли; ReUI Frame checklist прочитан. Первые регрессионные тесты выбирали скрытые option после добавления селектора: селекторы ограничены SVG, повтор чистый. 6 снимков проверены визуально. +- Далее 10.53: камера по границам объектов и читаемость малой топологии, pointer/touch и пустые состояния; текущие overview снимки ещё показывают слишком мелкий граф. Production build — после этого блока. Реальные RouterOS/JWT/БД не проверены. + ## Блок 10.51 — URL-контекст карты (2026-10-01) - URL сохраняет server/WAN, status filter, q и режим services/countries. Перезагрузка и история восстанавливают детали/срез; поиск заменяет текущий history entry. Посторонние query/hash сохраняются. Выбор GRE/сервиса пока локальный, pan/zoom/drag также локальные. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 0063efba..a5f8d40c 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -284,3 +284,12 @@ Shared контракт и backend observed исходные значения/д - 2 URL unit, 11 E2E URL/details (7 URL повторены после исправления тестового encoded-space matcher), TypeScript и lint прошли. В production ещё требуется общий build после адаптивной композиции. - Далее ReUI Frame, панели и интерактивные SVG controls: keyboard/focus, телефон, reduced motion, скриншоты и локальная приёмка карты. + + +## Блок 10.52 — Frame, доступные действия и детали карты (2026-10-01) + +- Установленный ReUI Frame объединяет toolbar, SVG и детали с parent-owned высотой. На ПК детали справа; на телефоне занимают рабочую область, закрытие возвращает карту. Выбор устройства доступен вне SVG, длинные имена переносятся по словам, отсутствует горизонтальный overflow на 1440/390/320 px. +- Действия устройства используют стандартный Base UI Dialog с действительными ссылками и обработкой отказа clipboard. Слои — Popover; Escape возвращает фокус и сохраняет server URL. Узлы устройств/WAN/сервисов и метрики GRE/Flow доступны Enter/Space; кнопки названы, декоративные иконки скрыты от screen reader. +- SVG использует semantic tokens; reduced motion отключает animateMotion. Экспорт сохраняет CSS переменные/фон и сериализуется XMLSerializer как standalone SVG. Nullable UL не выводится строкой null; неизвестный IPsec явно подписан. +- 35 основных + 12 source E2E, TypeScript и lint прошли; ReUI Frame checklist прочитан. Первые регрессионные тесты выбирали скрытые option после добавления селектора: селекторы ограничены SVG, повтор чистый. 6 снимков проверены визуально. +- Далее 10.53: камера по границам объектов и читаемость малой топологии, pointer/touch и пустые состояния; текущие overview снимки ещё показывают слишком мелкий граф. Production build — после этого блока. Реальные RouterOS/JWT/БД не проверены. diff --git a/docs/ux-ui/screens/network-map-detail-desktop.png b/docs/ux-ui/screens/network-map-detail-desktop.png new file mode 100644 index 00000000..e26a1e02 Binary files /dev/null and b/docs/ux-ui/screens/network-map-detail-desktop.png differ diff --git a/docs/ux-ui/screens/network-map-detail-mobile.png b/docs/ux-ui/screens/network-map-detail-mobile.png new file mode 100644 index 00000000..f22e346c Binary files /dev/null and b/docs/ux-ui/screens/network-map-detail-mobile.png differ diff --git a/docs/ux-ui/screens/network-map-detail-narrow.png b/docs/ux-ui/screens/network-map-detail-narrow.png new file mode 100644 index 00000000..a5be573e Binary files /dev/null and b/docs/ux-ui/screens/network-map-detail-narrow.png differ diff --git a/docs/ux-ui/screens/network-map-overview-desktop.png b/docs/ux-ui/screens/network-map-overview-desktop.png new file mode 100644 index 00000000..1af54dfa Binary files /dev/null and b/docs/ux-ui/screens/network-map-overview-desktop.png differ diff --git a/docs/ux-ui/screens/network-map-overview-mobile.png b/docs/ux-ui/screens/network-map-overview-mobile.png new file mode 100644 index 00000000..afd6f9f9 Binary files /dev/null and b/docs/ux-ui/screens/network-map-overview-mobile.png differ diff --git a/docs/ux-ui/screens/network-map-overview-narrow.png b/docs/ux-ui/screens/network-map-overview-narrow.png new file mode 100644 index 00000000..28e956d8 Binary files /dev/null and b/docs/ux-ui/screens/network-map-overview-narrow.png differ diff --git a/tests/e2e/network-map-context.spec.ts b/tests/e2e/network-map-context.spec.ts index a47b1788..27eaa0fe 100644 --- a/tests/e2e/network-map-context.spec.ts +++ b/tests/e2e/network-map-context.spec.ts @@ -11,7 +11,7 @@ for(const change of ["backend","mode"])for(const part of ["snapshot","flow","dns return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[row(u.port==="8101"?"Новая карта":"Прежняя карта")]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/traffic/flow/map-hops"?empty:p==="/api/network/resolve-hosts"?{results:{"router.example":"192.0.2.2"}}:{}}) }) await page.goto("/settings");await page.getByRole("link",{name:/^Карта сети/}).click() - await expect(page.getByText("Прежняя карта",{exact:true}).first()).toBeVisible() + await expect(page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Прежняя карта",{exact:true})).toBeVisible() await expect(page.getByText(/NetFlow · 5 мин · ответ/)).toBeVisible() const path=part==="snapshot"?"/api/servers":part==="flow"?"/api/traffic/flow/map-hops":"/api/network/resolve-hosts" await page.route("**/api/**",async r=>{ @@ -24,7 +24,7 @@ for(const change of ["backend","mode"])for(const part of ["snapshot","flow","dns if(change==="backend"){const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter")} else await page.getByRole("button",{name:"Моковые",exact:true}).click() await page.getByRole("link",{name:/^Карта сети/}).click() - if(change==="backend"){await expect(page.getByText("Новая карта",{exact:true}).first()).toBeVisible();await expect(page.getByText(/NetFlow · 5 мин · ответ/)).toBeVisible()} + if(change==="backend"){await expect(page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Новая карта",{exact:true})).toBeVisible();await expect(page.getByText(/NetFlow · 5 мин · ответ/)).toBeVisible()} else await expect(page.getByText("Демо · mock",{exact:true})).toBeVisible() const before=newReads;release();await expect.poll(()=>completed).toBe(true) await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve())))) @@ -32,6 +32,6 @@ for(const change of ["backend","mode"])for(const part of ["snapshot","flow","dns await expect(page.getByText("Поздняя карта",{exact:false})).toHaveCount(0) await expect(page.getByText("Поздний сервис",{exact:false})).toHaveCount(0) await expect(page.getByRole("alert").filter({hasText:"DNS не разрешён"})).toHaveCount(0) - if(change==="backend")await expect(page.getByText("Новая карта",{exact:true}).first()).toBeVisible() + if(change==="backend")await expect(page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Новая карта",{exact:true})).toBeVisible() expect(newReads).toBe(before) }) diff --git a/tests/e2e/network-map-data.spec.ts b/tests/e2e/network-map-data.spec.ts index 3ce7f59d..6c50bdd0 100644 --- a/tests/e2e/network-map-data.spec.ts +++ b/tests/e2e/network-map-data.spec.ts @@ -9,7 +9,7 @@ for(const part of ["servers","gre","probes"] as const)for(const malformed of [fa 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("group",{name:"Интерактивная карта сети"}).getByText("Роутер",{exact:true})).toBeVisible() await expect(page.getByRole("alert").filter({hasText:"Первоначальная загрузка карты"})).toHaveCount(0) }) for(const part of ["servers","gre","probes"] as const)test("Карта: частичный отказ сохраняет снимок "+part,async({page})=>{ @@ -17,10 +17,10 @@ for(const part of ["servers","gre","probes"] as const)test("Карта: част 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() + await expect(page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Последний роутер",{exact:true})).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.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Последний роутер",{exact:true})).toBeVisible() await expect(page.getByText("Новый роутер",{exact:true})).toHaveCount(0) }) @@ -30,16 +30,16 @@ test("Карта: неизвестный статус не становится await page.goto("/network-map") await expect(page.getByText("1 неизвестно",{exact:true})).toBeVisible() await expect(page.getByText("0 оффлайн",{exact:true})).toBeVisible() - await page.getByText("Роутер",{exact:true}).first().click() + await page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Роутер",{exact:true}).click() await expect(page.locator("span").filter({hasText:/^Неизвестно$/}).first()).toBeVisible() await page.getByRole("button",{name:"Неизвестно",exact:true}).click() - await expect(page.getByText("Роутер",{exact:true}).first()).toBeVisible() + await expect(page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Роутер",{exact:true})).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() + await page.goto("/network-map");await page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Роутер",{exact:true}).click() const panel=page.getByText("Ресурсы",{exact:true}).locator("..") await expect(panel.getByText("История CPU и диск API не передаются.",{exact:false})).toBeVisible() await expect(panel.locator("svg")).toHaveCount(0) diff --git a/tests/e2e/network-map-detail.spec.ts b/tests/e2e/network-map-detail.spec.ts index c3aefff1..3aa20a5d 100644 --- a/tests/e2e/network-map-detail.spec.ts +++ b/tests/e2e/network-map-detail.spec.ts @@ -19,7 +19,7 @@ test("Карта: модель WAN не утверждает активный м 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 page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Домашний",{exact:true}).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) }) @@ -31,7 +31,7 @@ test("Карта: выбранный GRE обновляется и неизве 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() + await page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Домашний",{exact:true}).click();await expect(page.getByText("IPsec неизвестен",{exact:true})).toBeVisible() }) test("Карта: GRE сохраняет частичное измерение, ноль Ping и дату пробы",async({page})=>{ diff --git a/tests/e2e/network-map-flow.spec.ts b/tests/e2e/network-map-flow.spec.ts index 4a87ea5c..4a89b4f5 100644 --- a/tests/e2e/network-map-flow.spec.ts +++ b/tests/e2e/network-map-flow.spec.ts @@ -24,7 +24,7 @@ test("Карта: NetFlow сохраняет сервис при фоновом 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() + await expect(page.getByRole("group",{name:"Интерактивная карта сети"}).getByText("Router",{exact:true})).toBeVisible() if(result==="success")await expect(page.getByRole("alert").filter({hasText:"DNS:"})).toHaveCount(0) else await expect(page.getByRole("alert").filter({hasText:"Связи по неизвестному адресу"})).toBeVisible() }) diff --git a/tests/e2e/network-map-layout.spec.ts b/tests/e2e/network-map-layout.spec.ts new file mode 100644 index 00000000..ecd12e55 --- /dev/null +++ b/tests/e2e/network-map-layout.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}})) + await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[{...home,name:"Домашний роутер с очень длинным названием и описанием устройства"},{...jh,name:"Промежуточный узел с длинной подписью"}]:p==="/api/filters/gre-tunnels"?{tunnels:[tunnel],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/traffic/flow/map-hops"?empty:p==="/api/auth/config"?{required:false}:{}})}) +}) +for(const width of [1440,390,320])test("Карта: доступные детали и адаптивная оболочка "+width,async({page})=>{ + await page.setViewportSize({width,height:900});await page.goto("/network-map") + await expect(page.getByRole("combobox",{name:"Устройство на карте"})).toBeEnabled() + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true) + await page.screenshot({path:"docs/ux-ui/screens/network-map-overview-"+(width===1440?"desktop":width===390?"mobile":"narrow")+".png"}) + await page.getByRole("combobox",{name:"Устройство на карте"}).selectOption("1") + const panel=page.getByRole("complementary",{name:"Детали объекта карты"}) + await expect(panel).toBeVisible();await expect(panel.getByText("Ресурсы",{exact:true})).toBeVisible() + const bounds=await panel.boundingBox(),map=width>=1024?await page.getByRole("group",{name:"Интерактивная карта сети"}).boundingBox():null + expect(bounds!.width).toBeGreaterThan(250);expect(bounds!.height).toBeGreaterThan(350) + if(width<1024)await expect(page.getByRole("group",{name:"Интерактивная карта сети"})).toBeHidden() + else expect(map!.height).toBeGreaterThan(0) + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true) + await page.screenshot({path:"docs/ux-ui/screens/network-map-detail-"+(width===1440?"desktop":width===390?"mobile":"narrow")+".png"}) + await panel.getByRole("button",{name:"Закрыть детали устройства"}).click() + await expect(panel).toHaveCount(0) + const node=page.getByRole("button",{name:/^Устройство Домашний/});await node.focus();await node.press("Enter");await expect(panel).toBeVisible() + await page.getByRole("button",{name:"Действия устройства",exact:true}).click() + const dialog=page.getByRole("dialog",{name:/^Действия устройства/});await expect(dialog).toBeVisible() + await expect(dialog.getByRole("link",{name:"GRE-туннели",exact:true})).toHaveAttribute("href","/gre?server=1") + await page.keyboard.press("Tab");expect(await dialog.evaluate(element=>element.contains(document.activeElement))).toBe(true) + await page.keyboard.press("Escape");await expect(dialog).toHaveCount(0);await expect(page.getByRole("button",{name:"Действия устройства",exact:true})).toBeFocused() + await expect(page).toHaveURL(/server=1/) + await page.getByRole("button",{name:"Слои",exact:true}).click();await expect(page.getByRole("dialog",{name:"Слои карты"})).toBeVisible() + await page.keyboard.press("Escape");await expect(page.getByRole("button",{name:"Слои",exact:true})).toBeFocused();await expect(page).toHaveURL(/server=1/) +}) +test("Карта: reduced motion и самостоятельный SVG экспорт",async({page})=>{ + await page.emulateMedia({reducedMotion:"reduce"});await page.goto("/network-map") + await expect(page.getByRole("button",{name:/^Устройство Домашний/})).toBeVisible() + await expect(page.locator("animateMotion")).toHaveCount(0) + const wait=page.waitForEvent("download");await page.getByRole("button",{name:"Экспорт SVG",exact:true}).click();const download=await wait + const stream=await download.createReadStream();let source="";for await(const chunk of stream!)source+=chunk.toString() + expect(source).toContain("--foreground:");expect(source).toContain("--background:");expect(source).toContain('xmlns="http://www.w3.org/2000/svg"') + const parsed=await page.evaluate(source=>{const doc=new DOMParser().parseFromString(source,"image/svg+xml");return {errors:doc.querySelectorAll("parsererror").length,width:doc.documentElement.getAttribute("width")}},source) + expect(parsed.errors).toBe(0);expect(parsed.width).toBe("1240") +})