diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index d7dc89a5..11e3e5c4 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -11,6 +11,7 @@ import {Dialog,DialogContent,DialogHeader,DialogTitle,DialogDescription} from "@ import {usePrefersReducedMotion} from "@/hooks/use-prefers-reduced-motion" import {toast} from "sonner" import { PageHeader } from "@/components/page-header" +import { NetworkMapLegend } from "@/components/network-map-legend" import { servers as mockServers, greTunnels as mockGreTunnels, @@ -556,7 +557,7 @@ function GreEdgeMetricBadge({ "Клик — панель справа." return ( {if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onOpen(e)}}} transform={`translate(${mx},${my})`} style={{ cursor: "pointer" }} @@ -564,6 +565,7 @@ function GreEdgeMetricBadge({ onClick={(e) => { e.stopPropagation(); onOpen(e) }} > {hint} + {if(onOpen&&(e.key==="Enter"||e.key===" ")){e.preventDefault();e.stopPropagation();onOpen(e)}}} transform={`translate(${mx},${my})`} style={{ cursor: onOpen ? "pointer" : "default" }} @@ -617,6 +619,7 @@ function NetflowRateBadge({ Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала — отдельно. + {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" }} @@ -699,6 +702,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat onMouseEnter={() => onHoverChange(true)} onMouseLeave={() => onHoverChange(false)} > + {n.status === "online" && isVis && ( @@ -707,7 +711,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat )} {isSel && ( - + )} @@ -807,7 +811,7 @@ 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" }} @@ -816,6 +820,7 @@ function ServiceNode({ onClick={(e) => { e.stopPropagation(); onClick() }} > {`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`} + {isSel && ( {if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onSelect()}}} transform={`translate(${x},${y})`} style={{ cursor: isDragged ? "grabbing" : "grab" }} onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }} onClick={e => { e.stopPropagation(); onSelect() }}> + {active && ( @@ -1718,6 +1724,7 @@ function NetworkMapContent() { if (!svg) return // Clone with full viewBox (reset pan/zoom) const clone = svg.cloneNode(true) as SVGSVGElement + clone.querySelectorAll(".network-map-focus-ring").forEach(ring => ring.remove()) clone.setAttribute("viewBox", `0 0 ${W} ${H}`) clone.setAttribute("width", String(W)) clone.setAttribute("height", String(H)) @@ -2252,10 +2259,7 @@ function NetworkMapContent() { {/* ── Canvas ── */}
-
- Обозначения карты -

HR · домашний роутер

JH · промежуточный узел

EN · выходной узел

Круг · устройство / WAN

Прямоугольник · сервис / страна

Пунктир WAN → JH · модель связности

Цвет · статус; неизвестно ≠ оффлайн

-
+

Стрелки перемещают карту, плюс и минус меняют масштаб, F вписывает объекты. Tab выбирает объект, Enter открывает детали.

.network-map-focus-ring { + opacity: 1; +} + @custom-variant dark (&:is(.dark *)); @theme inline { diff --git a/components/network-map-legend.tsx b/components/network-map-legend.tsx new file mode 100644 index 00000000..449821f5 --- /dev/null +++ b/components/network-map-legend.tsx @@ -0,0 +1,47 @@ +"use client" + +import { useEffect, useRef } from "react" +import { Badge } from "@/components/ui/badge" + +export function NetworkMapLegend() { + const details = useRef(null) + useEffect(() => { + if (window.matchMedia("(max-width: 767px)").matches && details.current) details.current.open = false + }, []) + return ( +
+ Легенда карты +
+
+

Статус устройства

+
+ {[["var(--status-online)", "Онлайн"], ["var(--status-degraded)", "Внимание"], ["var(--status-offline)", "Оффлайн"], ["var(--muted-foreground)", "Неизвестно"]].map(([color, label]) => ( + {label} + ))} +
+
+
+

Тип узла

+ {[["JH", "JumpHost", "var(--chart-1)"], ["EN", "Exit Node", "var(--chart-3)"], ["HR", "Home Router", "var(--chart-2)"]].map(([label, name, color]) => ( + {label}{name} + ))} + WAN · аплинк + Сервис / страна +
+
+

Связи и измерения

+ {[ + ["GRE", "var(--status-online)", undefined], + ["GRE + IPsec", "var(--status-online)", "7 4"], + ["Поток NetFlow", "var(--chart-4)", "6 5"], + ["WAN → JH · модель", "var(--muted-foreground)", "5 4"], + ].map(([label, color, dash]) => ( + {label} + ))} +

RTT · задержка пробы. «—» · нет измерения.

+

NetFlow · трафик за 5 минут, не пропускная способность канала.

+
+
+ + ) +} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index e9491cd7..550518c8 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -38,6 +38,14 @@ | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Завершён2026-10-15 в границах предоставленного стенда:1396 UI/221 context,120 live states30 экранов, production builds/ReUI checklist и реальные интеграции. Ограничения — в [акте](ux-ui-acceptance-candidate.md); production-выпуск не выполнялся. | +## Production270 — легенда и контуры фокуса карты (2026-10-15) + +По замечанию владельца выполнено сравнение с последним доступным тегом v1.42.0. Там легенда была раскрытой графической панелью, а выбранный узел имел полупрозрачный пунктирный круг. Текущий свёрнутый текстовый details заменён компонентом NetworkMapLegend: образцы статусов, JH/EN/HR, формы WAN/сервисов, GRE/IPsec/NetFlow/модель WAN→JH, пояснения RTT/неизвестных значений и различия потока и ёмкости. На ПК раскрыта, на телефоне изначально свёрнута, содержимое ограничено высотой со скроллом. + +Прямоугольный outline по SVG bounding box заменён на focus-visible контур по форме устройства/WAN/сервиса/метрики. Мышь его не показывает, Tab/Enter работают; выделение узла сохранено с opacity0.45 как в1.42. Контуры фокуса удаляются из клона при экспорте SVG, чтобы CSS-зависимое скрытие не создавало лишние обводки в файле. ReUI-примитивы не перерабатывались; Badge используется штатный, цвета — существующие semantic tokens. + +Проверки:13 прежних data E2E и4 новых UI E2E passed (legend desktop/mobile, pointer/keyboard focus, экспорт). TypeScript и scoped ESLint passed. Первый прогон выявил неточность текстового locator вокруг Badge; подпись выделена отдельным span и сценарий повторно прошёл. Скриншоты ПК/телефона просмотрены, после просмотра мобильная легенда сделана свёрнутой. Превью tmp/map270-desktop.png и tmp/map270-mobile.png вне Git. Финальная семантическая правка цвета образца GRE не меняет геометрию/поведение. Изменения локальные, без push/deploy; production остаётся2.0.1 из269. Следующий шаг — публикация подготовленных Filters268 и визуального патча270 после разрешения владельца; отдельно остаются read-only DNS и трафик. + ## Production269 — фактическое обновление frontend и узлы карты (2026-10-15) По повторной жалобе подтверждено: работающий frontend всё ещё2.0.0/72bf648c, хотя все jobs CI2467 завершились success и2.0.1 опубликован. API принимает исправленная схема4servers/25GRE/0probes, разовые задержки171/498/56мс. После отдельного разрешения владельца обновлён только frontend по проверенному digest2.0.1/042407f0; новый контейнер healthy, backend/PostgreSQL ID и StartedAt сохранены. Публичный manifest2.0.1 и браузер подтвердили4 узла (3JH/1EN),25GRE в данных и3 отображённые связи. Ошибка первичной валидации исчезла. Права read-only блокируют DNS resolve-hosts (mm:network:write), поэтому все линии/FQDN ещё не подтверждены; права не менялись. [Процедура и откат](../deploy/PRODUCTION-FRONTEND-269.md), приватный скриншот tmp/prod-map269.jpg вне Git. Блок268 Filters остаётся локальным и в этом обновлении отсутствует. Следующее действие — отдельная проверка read-only разрешения DNS и продолжение трафика; готовый Filters-патч требует публикации. diff --git a/tests/e2e/network-map-legend.spec.ts b/tests/e2e/network-map-legend.spec.ts new file mode 100644 index 00000000..07d3bce9 --- /dev/null +++ b/tests/e2e/network-map-legend.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from "@playwright/test" +const emptyFlow = { 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 } + +test.beforeEach(async ({ page }) => { + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + return r.fulfill({ json: path === "/api/servers" ? [ + { id: 1, name: "Тест JH", host: "192.0.2.1", type: "jump-host", country: "RU", enabled: true, status: "online" }, + { id: 2, name: "Тест EN", host: "192.0.2.2", type: "exit-node", country: "SE", enabled: true, status: "online" }, + ] : path === "/api/filters/gre-tunnels" ? { tunnels: [], failures: [] } + : path === "/api/uptime/speed-probes" ? { probes: [] } + : path === "/api/traffic/flow/map-hops" ? emptyFlow + : path === "/api/auth/config" ? { required: false } : {} }) + }) +}) + +test("Карта: раскрытая легенда объясняет статусы, типы, линии и неизвестные измерения", async ({ page }) => { + await page.goto("/network-map") + const legend = page.getByRole("region", { name: "Легенда карты" }) + await expect(legend).toBeVisible() + for (const text of ["Онлайн", "Внимание", "Оффлайн", "Неизвестно", "JumpHost", "Exit Node", "Home Router", "GRE + IPsec", "Поток NetFlow"]) await expect(legend.getByText(text, { exact: true })).toBeVisible() + await expect(legend).toContainText("нет измерения") + await expect(legend).toContainText("не пропускная способность канала") + await page.locator("[data-map-legend] summary").click() + await expect(legend).not.toBeVisible() + await page.locator("[data-map-legend] summary").press("Enter") + await expect(legend).toBeVisible() + await legend.getByText("Онлайн", { exact: true }).click() + await page.screenshot({ path: "tmp/map270-desktop.png" }) +}) + +test("Карта: мышь не рисует прямоугольную рамку, клавиатурный фокус остаётся видимым", async ({ page }) => { + await page.goto("/network-map") + const node = page.getByRole("button", { name: "Устройство Тест JH", exact: true }) + await expect(node).toBeVisible() + await node.click() + await expect(node).toHaveCSS("outline-style", "none") + await expect(node.locator(":scope > .network-map-focus-ring")).toHaveCSS("opacity", "0") + // Return to this object through real keyboard navigation, not programmatic focus. + await node.press("Shift+Tab") + await page.keyboard.press("Tab") + await expect(node).toBeFocused() + await expect(node.locator(":scope > .network-map-focus-ring")).toHaveCSS("opacity", "1") + await node.press("Enter") + await expect(node).toHaveAttribute("aria-pressed", "false") +}) + +test("Карта: легенда помещается на телефоне и сворачивается", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto("/network-map") + const legend = page.getByRole("region", { name: "Легенда карты" }) + await expect(page.locator("[data-map-legend]")).not.toHaveAttribute("open", "") + await page.screenshot({ path: "tmp/map270-mobile.png" }) + await page.locator("[data-map-legend] summary").click() + await expect(legend).toBeVisible() + const bounds = await legend.boundingBox() + expect(bounds!.x).toBeGreaterThanOrEqual(0) + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390) + await page.locator("[data-map-legend] summary").click() + await expect(legend).not.toBeVisible() +}) + +test("Карта: экспорт не добавляет клавиатурные контуры всем объектам", async ({ page }) => { + await page.goto("/network-map") + await expect(page.getByRole("button", { name: "Устройство Тест JH", exact: true })).toBeVisible() + const download = page.waitForEvent("download") + await page.getByRole("button", { name: "Экспорт SVG", exact: true }).click() + const stream = await (await download).createReadStream() + let svg = "" + for await (const chunk of stream!) svg += chunk.toString() + expect(svg).not.toContain("network-map-focus-ring") + expect(svg).toContain("Тест JH") +})