fix(map): восстановить легенду и убрать лишние рамки фокуса
This commit is contained in:
1 parent
1c11a133f3
commit
eb24a08981
5 files changed
+155
-10
No files matched your search
@@ -11,6 +11,7 @@ import {Dialog,DialogContent,DialogHeader,DialogTitle,DialogDescription} from "@
|
|||||||
import {usePrefersReducedMotion} from "@/hooks/use-prefers-reduced-motion"
|
import {usePrefersReducedMotion} from "@/hooks/use-prefers-reduced-motion"
|
||||||
import {toast} from "sonner"
|
import {toast} from "sonner"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { NetworkMapLegend } from "@/components/network-map-legend"
|
||||||
import {
|
import {
|
||||||
servers as mockServers,
|
servers as mockServers,
|
||||||
greTunnels as mockGreTunnels,
|
greTunnels as mockGreTunnels,
|
||||||
@@ -556,7 +557,7 @@ function GreEdgeMetricBadge({
|
|||||||
"Клик — панель справа."
|
"Клик — панель справа."
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
role="button" aria-label={"Метрики "+(outerSummary??"связи")} tabIndex={0} className="focus-visible:outline-2 focus-visible:outline-ring"
|
role="button" aria-label={"Метрики "+(outerSummary??"связи")} tabIndex={0} className="network-map-focusable"
|
||||||
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onOpen(e)}}}
|
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onOpen(e)}}}
|
||||||
transform={`translate(${mx},${my})`}
|
transform={`translate(${mx},${my})`}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
@@ -564,6 +565,7 @@ function GreEdgeMetricBadge({
|
|||||||
onClick={(e) => { e.stopPropagation(); onOpen(e) }}
|
onClick={(e) => { e.stopPropagation(); onOpen(e) }}
|
||||||
>
|
>
|
||||||
<title>{hint}</title>
|
<title>{hint}</title>
|
||||||
|
<rect className="network-map-focus-ring" x={-bw / 2 - 4} y={-bh / 2 - 4} width={bw + 8} height={bh + 8} rx="9" fill="none" stroke="var(--ring)" strokeWidth="2" />
|
||||||
<rect
|
<rect
|
||||||
x={-bw / 2}
|
x={-bw / 2}
|
||||||
y={-bh / 2}
|
y={-bh / 2}
|
||||||
@@ -607,7 +609,7 @@ function NetflowRateBadge({
|
|||||||
const bh = showDir ? 32 : 20
|
const bh = showDir ? 32 : 20
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
role={onOpen?"button":undefined} aria-label={onOpen?"Поток NetFlow между узлами":undefined} tabIndex={onOpen?0:undefined} className="focus-visible:outline-2 focus-visible:outline-ring"
|
role={onOpen?"button":undefined} aria-label={onOpen?"Поток NetFlow между узлами":undefined} tabIndex={onOpen?0:undefined} className="network-map-focusable"
|
||||||
onKeyDown={e=>{if(onOpen&&(e.key==="Enter"||e.key===" ")){e.preventDefault();e.stopPropagation();onOpen(e)}}}
|
onKeyDown={e=>{if(onOpen&&(e.key==="Enter"||e.key===" ")){e.preventDefault();e.stopPropagation();onOpen(e)}}}
|
||||||
transform={`translate(${mx},${my})`}
|
transform={`translate(${mx},${my})`}
|
||||||
style={{ cursor: onOpen ? "pointer" : "default" }}
|
style={{ cursor: onOpen ? "pointer" : "default" }}
|
||||||
@@ -617,6 +619,7 @@ function NetflowRateBadge({
|
|||||||
<title>
|
<title>
|
||||||
Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала — отдельно.
|
Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала — отдельно.
|
||||||
</title>
|
</title>
|
||||||
|
<rect className="network-map-focus-ring" x={-bw / 2 - 4} y={-bh / 2 - 4} width={bw + 8} height={bh + 8} rx="9" fill="none" stroke="var(--ring)" strokeWidth="2" />
|
||||||
<rect
|
<rect
|
||||||
x={-bw / 2}
|
x={-bw / 2}
|
||||||
y={-bh / 2}
|
y={-bh / 2}
|
||||||
@@ -687,7 +690,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
|
|||||||
const ts = TYPE_STYLE[n.type]
|
const ts = TYPE_STYLE[n.type]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g className="focus-visible:outline-2 focus-visible:outline-ring" role="button" aria-label={"Устройство "+n.name} aria-pressed={isSel} tabIndex={isVis?0:-1}
|
<g className="network-map-focusable" role="button" aria-label={"Устройство "+n.name} aria-pressed={isSel} tabIndex={isVis?0:-1}
|
||||||
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}}
|
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}}
|
||||||
transform={`translate(${n.x},${n.y})`}
|
transform={`translate(${n.x},${n.y})`}
|
||||||
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
|
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)}
|
onMouseEnter={() => onHoverChange(true)}
|
||||||
onMouseLeave={() => onHoverChange(false)}
|
onMouseLeave={() => onHoverChange(false)}
|
||||||
>
|
>
|
||||||
|
<circle className="network-map-focus-ring" r={ts.r + 6} fill="none" stroke="var(--ring)" strokeWidth="2" />
|
||||||
{n.status === "online" && isVis && (
|
{n.status === "online" && isVis && (
|
||||||
<circle r={ts.r + 14} fill={ss.glow} opacity="0.7">
|
<circle r={ts.r + 14} fill={ss.glow} opacity="0.7">
|
||||||
</circle>
|
</circle>
|
||||||
@@ -707,7 +711,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
|
|||||||
<circle r={ts.r + 12} fill={ss.glow} />
|
<circle r={ts.r + 12} fill={ss.glow} />
|
||||||
)}
|
)}
|
||||||
{isSel && (
|
{isSel && (
|
||||||
<circle r={ts.r + 10} fill="none" stroke="var(--foreground)" strokeWidth="1.5" strokeDasharray="4 3" />
|
<circle r={ts.r + 10} fill="none" stroke="var(--foreground)" strokeWidth="1.5" strokeDasharray="4 3" opacity="0.45" />
|
||||||
)}
|
)}
|
||||||
<circle r={ts.r} fill={ss.fill} stroke={ss.stroke} strokeWidth={isSel ? 2.5 : 1.8} />
|
<circle r={ts.r} fill={ss.fill} stroke={ss.stroke} strokeWidth={isSel ? 2.5 : 1.8} />
|
||||||
<g transform={`translate(${ts.r - 10},-${ts.r - 10})`}>
|
<g transform={`translate(${ts.r - 10},-${ts.r - 10})`}>
|
||||||
@@ -807,7 +811,7 @@ function ServiceNode({
|
|||||||
const bh = MAP_SERVICE_NODE_H
|
const bh = MAP_SERVICE_NODE_H
|
||||||
const flagIso = destMode === "countries" && iso && iso !== "Прочее" ? iso : ""
|
const flagIso = destMode === "countries" && iso && iso !== "Прочее" ? iso : ""
|
||||||
return (
|
return (
|
||||||
<g role="button" aria-label={(destMode==="countries"?"Страна ":"Сервис ")+label} aria-pressed={isSel} tabIndex={isVis?0:-1} className="focus-visible:outline-2 focus-visible:outline-ring"
|
<g role="button" aria-label={(destMode==="countries"?"Страна ":"Сервис ")+label} aria-pressed={isSel} tabIndex={isVis?0:-1} className="network-map-focusable"
|
||||||
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}}
|
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}}
|
||||||
transform={`translate(${x},${y})`}
|
transform={`translate(${x},${y})`}
|
||||||
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
|
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
|
||||||
@@ -816,6 +820,7 @@ function ServiceNode({
|
|||||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||||
>
|
>
|
||||||
<title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title>
|
<title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title>
|
||||||
|
<rect className="network-map-focus-ring" x={-bw / 2 - 3} y={-bh / 2 - 3} width={bw + 6} height={bh + 6} rx="9" fill="none" stroke="var(--ring)" strokeWidth="2" />
|
||||||
{isSel && (
|
{isSel && (
|
||||||
<rect
|
<rect
|
||||||
x={-bw / 2 - 6}
|
x={-bw / 2 - 6}
|
||||||
@@ -925,12 +930,13 @@ function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onPo
|
|||||||
}) {
|
}) {
|
||||||
const r = 18
|
const r = 18
|
||||||
return (
|
return (
|
||||||
<g role="button" aria-label={"Аплинк "+wan.name} aria-pressed={isSel} tabIndex={0} className="focus-visible:outline-2 focus-visible:outline-ring"
|
<g role="button" aria-label={"Аплинк "+wan.name} aria-pressed={isSel} tabIndex={0} className="network-map-focusable"
|
||||||
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onSelect()}}}
|
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onSelect()}}}
|
||||||
transform={`translate(${x},${y})`}
|
transform={`translate(${x},${y})`}
|
||||||
style={{ cursor: isDragged ? "grabbing" : "grab" }}
|
style={{ cursor: isDragged ? "grabbing" : "grab" }}
|
||||||
onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }}
|
onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }}
|
||||||
onClick={e => { e.stopPropagation(); onSelect() }}>
|
onClick={e => { e.stopPropagation(); onSelect() }}>
|
||||||
|
<circle className="network-map-focus-ring" r={r + 4} fill="none" stroke="var(--ring)" strokeWidth="2" />
|
||||||
{active && (
|
{active && (
|
||||||
<circle r={r + 6} fill={color} opacity="0.12">
|
<circle r={r + 6} fill={color} opacity="0.12">
|
||||||
</circle>
|
</circle>
|
||||||
@@ -1718,6 +1724,7 @@ function NetworkMapContent() {
|
|||||||
if (!svg) return
|
if (!svg) return
|
||||||
// Clone with full viewBox (reset pan/zoom)
|
// Clone with full viewBox (reset pan/zoom)
|
||||||
const clone = svg.cloneNode(true) as SVGSVGElement
|
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("viewBox", `0 0 ${W} ${H}`)
|
||||||
clone.setAttribute("width", String(W))
|
clone.setAttribute("width", String(W))
|
||||||
clone.setAttribute("height", String(H))
|
clone.setAttribute("height", String(H))
|
||||||
@@ -2252,10 +2259,7 @@ function NetworkMapContent() {
|
|||||||
|
|
||||||
{/* ── Canvas ── */}
|
{/* ── Canvas ── */}
|
||||||
<div ref={canvasRef} className={cn("flex-1 min-h-0 min-w-0 relative overflow-hidden bg-background select-none", (selected || selectedGreEdge || selectedService) && "hidden lg:block")}>
|
<div ref={canvasRef} className={cn("flex-1 min-h-0 min-w-0 relative overflow-hidden bg-background select-none", (selected || selectedGreEdge || selectedService) && "hidden lg:block")}>
|
||||||
<details className="absolute top-3 left-3 z-20 rounded-md border bg-popover p-2 text-xs shadow-sm">
|
<NetworkMapLegend />
|
||||||
<summary className="cursor-pointer focus-visible:outline-2 focus-visible:outline-ring">Обозначения карты</summary>
|
|
||||||
<div className="mt-2 space-y-1"><p>HR · домашний роутер</p><p>JH · промежуточный узел</p><p>EN · выходной узел</p><p>Круг · устройство / WAN</p><p>Прямоугольник · сервис / страна</p><p>Пунктир WAN → JH · модель связности</p><p>Цвет · статус; неизвестно ≠ оффлайн</p></div>
|
|
||||||
</details>
|
|
||||||
<p id="network-map-help" className="sr-only">Стрелки перемещают карту, плюс и минус меняют масштаб, F вписывает объекты. Tab выбирает объект, Enter открывает детали.</p>
|
<p id="network-map-help" className="sr-only">Стрелки перемещают карту, плюс и минус меняют масштаб, F вписывает объекты. Tab выбирает объект, Enter открывает детали.</p>
|
||||||
<svg
|
<svg
|
||||||
ref={svgRef} role="group" aria-label="Интерактивная карта сети" tabIndex={0} aria-describedby="network-map-help"
|
ref={svgRef} role="group" aria-label="Интерактивная карта сети" tabIndex={0} aria-describedby="network-map-help"
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
/* SVG focus follows the object shape, never the text/foreignObject bounding box. */
|
||||||
|
.network-map-focusable {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.network-map-focus-ring {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.network-map-focusable:focus-visible > .network-map-focus-ring {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
|
export function NetworkMapLegend() {
|
||||||
|
const details = useRef<HTMLDetailsElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.matchMedia("(max-width: 767px)").matches && details.current) details.current.open = false
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<details ref={details} open data-map-legend className="absolute left-3 top-3 z-20 max-w-[calc(100%-1.5rem)] rounded-lg border bg-popover text-popover-foreground shadow-sm">
|
||||||
|
<summary className="cursor-pointer px-3 py-2 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-ring">Легенда карты</summary>
|
||||||
|
<div role="region" aria-label="Легенда карты" className="flex max-h-[42vh] w-44 max-w-full flex-col gap-2 overflow-y-auto border-t px-3 py-2 text-[11px]">
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 font-medium text-muted-foreground">Статус устройства</p>
|
||||||
|
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5">
|
||||||
|
{[["var(--status-online)", "Онлайн"], ["var(--status-degraded)", "Внимание"], ["var(--status-offline)", "Оффлайн"], ["var(--muted-foreground)", "Неизвестно"]].map(([color, label]) => (
|
||||||
|
<span key={label} className="flex items-center gap-1.5"><span aria-hidden className="size-2 shrink-0 rounded-full" style={{ background: color }} />{label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<p className="font-medium text-muted-foreground">Тип узла</p>
|
||||||
|
{[["JH", "JumpHost", "var(--chart-1)"], ["EN", "Exit Node", "var(--chart-3)"], ["HR", "Home Router", "var(--chart-2)"]].map(([label, name, color]) => (
|
||||||
|
<span key={label} className="flex items-center gap-2"><Badge variant="secondary" style={{ color }} className="min-w-8 justify-center text-[10px]">{label}</Badge><span>{name}</span></span>
|
||||||
|
))}
|
||||||
|
<span className="flex items-center gap-2"><span aria-hidden className="mx-1 size-3 rounded-full border border-current" />WAN · аплинк</span>
|
||||||
|
<span className="flex items-center gap-2"><span aria-hidden className="mx-1 size-3 rounded border border-current" />Сервис / страна</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<p className="font-medium text-muted-foreground">Связи и измерения</p>
|
||||||
|
{[
|
||||||
|
["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]) => (
|
||||||
|
<span key={label} className="flex items-center gap-2"><svg aria-hidden width="28" height="12" className="shrink-0"><line x1="0" y1="6" x2="28" y2="6" stroke={color} strokeWidth="2" strokeDasharray={dash} /></svg>{label}</span>
|
||||||
|
))}
|
||||||
|
<p className="text-muted-foreground">RTT · задержка пробы. «—» · нет измерения.</p>
|
||||||
|
<p className="text-muted-foreground">NetFlow · трафик за 5 минут, не пропускная способность канала.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -38,6 +38,14 @@
|
|||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
|
| 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-выпуск не выполнялся. |
|
| 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)
|
## 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-патч требует публикации.
|
По повторной жалобе подтверждено: работающий 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-патч требует публикации.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
Reference in new issue
Block a user