Compare commits

...
4 Commits
Author SHA1 Message Date
Denozordec e6a2e4009d fix(routes): читать рекурсивные маршруты с незаданными свойствами
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 5m2s
Docker images / frontend-image (push) Successful in 2m46s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 1m54s
Docker images / publish-release (push) Successful in 15s
Docker images / notify-webhook (push) Successful in 2s
2026-10-16 00:28:45 +07:00
Denozordec eb24a08981 fix(map): восстановить легенду и убрать лишние рамки фокуса 2026-10-15 23:36:19 +07:00
Denozordec 1c11a133f3 chore(docs): зафиксировать обновление карты в рабочем окружении 2026-10-15 23:24:54 +07:00
Denozordec 0f0d3aa060 fix(filters): восстановить связь рекурсивного шлюза после опроса 2026-10-15 23:17:42 +07:00
17 changed files with 452 additions and 22 deletions

No files matched your search

+7 -1
View File
@@ -1676,11 +1676,17 @@ function FiltersPage() {
}
const openEdit = (rule: FilterRule) => {
if (mutationsLocked || applyLock.current) return
// The observed catalog can have new DB IDs after refresh. Recover only an
// explicit recursive reference, never reinterpret a named GRE interface.
const routes = recRoutesByServer[selectedServerId] ?? []
const recursive = isRecursiveGatewayRef(rule.gatewayTunnelId)
? routes.find(r => r.id === rule.gatewayTunnelId.slice(4)) ?? pickRecursiveRouteByGatewayHop(routes, rule.gateway)
: undefined
setSheetInitial({
action: rule.action ?? "route",
community: rule.community,
gateway: rule.gateway,
gatewayTunnelId: rule.gatewayTunnelId,
gatewayTunnelId: recursive ? `rec:${recursive.id}` : rule.gatewayTunnelId,
gatewayKind: isRecursiveGatewayRef(rule.gatewayTunnelId) ? "recursive" : "gre",
description: rule.description,
})
+14 -10
View File
@@ -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 (
<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)}}}
transform={`translate(${mx},${my})`}
style={{ cursor: "pointer" }}
@@ -564,6 +565,7 @@ function GreEdgeMetricBadge({
onClick={(e) => { e.stopPropagation(); onOpen(e) }}
>
<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
x={-bw / 2}
y={-bh / 2}
@@ -607,7 +609,7 @@ function NetflowRateBadge({
const bh = showDir ? 32 : 20
return (
<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)}}}
transform={`translate(${mx},${my})`}
style={{ cursor: onOpen ? "pointer" : "default" }}
@@ -617,6 +619,7 @@ function NetflowRateBadge({
<title>
Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала — отдельно.
</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
x={-bw / 2}
y={-bh / 2}
@@ -687,7 +690,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
const ts = TYPE_STYLE[n.type]
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()}}}
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)}
>
<circle className="network-map-focus-ring" r={ts.r + 6} fill="none" stroke="var(--ring)" strokeWidth="2" />
{n.status === "online" && isVis && (
<circle r={ts.r + 14} fill={ss.glow} opacity="0.7">
</circle>
@@ -707,7 +711,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
<circle r={ts.r + 12} fill={ss.glow} />
)}
{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} />
<g transform={`translate(${ts.r - 10},-${ts.r - 10})`}>
@@ -807,7 +811,7 @@ function ServiceNode({
const bh = MAP_SERVICE_NODE_H
const flagIso = destMode === "countries" && iso && iso !== "Прочее" ? iso : ""
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()}}}
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() }}
>
<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 && (
<rect
x={-bw / 2 - 6}
@@ -925,12 +930,13 @@ function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onPo
}) {
const r = 18
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()}}}
transform={`translate(${x},${y})`}
style={{ cursor: isDragged ? "grabbing" : "grab" }}
onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }}
onClick={e => { e.stopPropagation(); onSelect() }}>
<circle className="network-map-focus-ring" r={r + 4} fill="none" stroke="var(--ring)" strokeWidth="2" />
{active && (
<circle r={r + 6} fill={color} opacity="0.12">
</circle>
@@ -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 ── */}
<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">
<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>
<NetworkMapLegend />
<p id="network-map-help" className="sr-only">Стрелки перемещают карту, плюс и минус меняют масштаб, F вписывает объекты. Tab выбирает объект, Enter открывает детали.</p>
<svg
ref={svgRef} role="group" aria-label="Интерактивная карта сети" tabIndex={0} aria-describedby="network-map-help"
+12
View File
@@ -2,6 +2,18 @@
@import "tw-animate-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 *));
@theme inline {
+1 -1
View File
@@ -23,7 +23,7 @@
"test:traffic-flow": "tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
"test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/db/scheduler-retention-index.test.ts && tsx src/services/config-revisions.test.ts",
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-apply.test.ts",
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-effective-properties.test.ts && tsx src/services/recursive-apply.test.ts",
"test:config-sync": "tsx src/utils/server-id.test.ts && tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts && tsx src/services/gre-native-identity.test.ts && tsx src/services/gre-ipv6.test.ts && tsx src/routes/gre-ipv6-validation.test.ts && tsx src/services/network-disabled-server.test.ts",
"test:ros-file": "tsx src/services/ros-file-contents.test.ts && tsx src/services/mikrotik-response.test.ts",
"test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
@@ -9,6 +9,7 @@ let rawReads = 0, lateReadFailure = false, applyError: Error | undefined
let lookup = 0, writes = 0, revisions = 0, fail = false, stored = [row], payload: unknown = [row], revisionServer = 1, section = "recursive-routes"
const observed = { ".id": "*1", static: "true", "dst-address": row.dstAddress, gateway: row.gateway, distance: "0", scope: "0", "target-scope": "0", "routing-table": "main", "check-gateway": "ping", disabled: "false", comment: "MikrotikManager:recursive Тест" }
let rawPayload: unknown = [observed], rawFailure = false, savedSnapshots = 0
let propertyFailure = false, propertyReads = 0
const applied: Array<{ rows: typeof row[]; source: string }> = []
const app = Fastify()
app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
@@ -16,6 +17,7 @@ await app.register(createRecursiveRoutes({
getServer: async id => { lookup++; return id === 1 ? server : undefined },
getRoutes: async () => { if (lateReadFailure) throw Error("Сбой чтения после применения"); return stored },
readRaw: async () => { rawReads++; if (rawFailure) throw new Error("Тест опроса"); return rawPayload },
readProperty: async (_server, _id, property) => { propertyReads++; if (propertyFailure) throw Error("Свойство недоступно"); return property === "disabled" ? { ret: "false" } : [] },
saveObserved: async (_id, rows) => { savedSnapshots++; stored = rows as typeof row[] },
apply: async (device, rows, source) => {
assert.equal(device.id, 1); writes++; applied.push({ rows: rows as typeof row[], source })
@@ -85,6 +87,14 @@ rawFailure=true
assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})).statusCode,503)
rawFailure=false;rawPayload=[]
assert.deepEqual((await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})).json(),{serverId:1,routes:[],live:true,stale:false})
rawPayload=[{...observed,disabled:undefined,"check-gateway":undefined}]
const recovered=await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})
assert.equal(recovered.statusCode,200);assert.equal(recovered.json().routes[0].disabled,false);assert.equal(recovered.json().routes[0].checkGateway,"none");assert.equal(propertyReads,2)
propertyFailure=true
const beforePropertyFailure=savedSnapshots
const failedProperty=await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})
assert.equal(failedProperty.statusCode,503);assert.equal(failedProperty.json().routes,undefined);assert.equal(savedSnapshots,beforePropertyFailure)
propertyFailure=false
rawPayload=[{".id":"one",gateway:"192.0.2.1"}]
assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/gateways/catalog?serverId=1"})).json().gateways[0].status,"unknown")
for (const type of ["home-router", "exit-node"] as const) {
+15 -5
View File
@@ -1,5 +1,5 @@
import { recursiveApplyBodySchema, recursiveTargetSchema, recursiveInputRowsSchema } from '../services/recursive-route-validation.js'
import { parseRecursiveCatalog, parseRecursiveGatewayCatalog } from "../services/recursive-route-catalog.js"
import { parseRecursiveCatalog, parseRecursiveGatewayCatalog, readRecursiveEffectiveProperties, type RecursiveObservedProperty } from "../services/recursive-route-catalog.js"
import { executeRecursiveApply, RecursiveApplyError } from "../services/recursive-apply.js"
import { asc, eq } from "drizzle-orm"
import { z } from "zod"
@@ -176,7 +176,8 @@ function recursiveFailureReceipt(error: unknown, result?: { pushed: number; dele
export interface RecursiveRouteDependencies {
getServer: (id: number) => Promise<ServerRow | undefined>
readRaw: (server: ServerRow) => Promise<unknown>
readRaw: (server: ServerRow, purpose?: "routes" | "gateways") => Promise<unknown>
readProperty: (server: ServerRow, id: string, property: RecursiveObservedProperty) => Promise<unknown>
saveObserved: (serverId: number, routes: RecursiveRouteDto[]) => Promise<void>
getRoutes: typeof mapDbRoutes
apply: typeof applyRecursiveToServer
@@ -187,7 +188,15 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
const deps: RecursiveRouteDependencies = {
getServer: async id => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0],
getRoutes: mapDbRoutes, apply: applyRecursiveToServer, getRevision: getRevisionById,
readRaw: server => MikrotikClient.fromServer(server).get<unknown>("/ip/route"),
readRaw: (server, purpose) => {
const client = MikrotikClient.fromServer(server)
// Every ownership marker is a comment. Keep all commented rows, including
// unsupported ones that must block an unsafe replacement of the managed set.
return purpose === "gateways"
? client.get<unknown>("/ip/route?.proplist=.id,gateway,active")
: client.post<unknown>("/ip/route/print", { ".query": ["comment"] })
},
readProperty: (server, id, property) => MikrotikClient.fromServer(server).post("/ip/route/get", { ".id": id, "value-name": property }),
saveObserved: async (serverId, routes) => {
await replaceDbRoutes(serverId, routes)
await appendRevisionIfChanged({ serverId, section: "recursive-routes", source: "observed", payload: canonicalRecursiveRoutes(routes) })
@@ -203,7 +212,8 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
if (!server) return reply.status(404).send({ error: "Server not found" })
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
try {
const routes = mergeCachedCountry(parseRecursiveCatalog(await deps.readRaw(server)), await deps.getRoutes(serverId))
const observed = await readRecursiveEffectiveProperties(await deps.readRaw(server), (id, property) => deps.readProperty(server, id, property))
const routes = mergeCachedCountry(parseRecursiveCatalog(observed), await deps.getRoutes(serverId))
await deps.saveObserved(serverId, routes)
return reply.send({ serverId, routes, live: true, stale: false })
} catch (error) {
@@ -217,7 +227,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
if (!server) return reply.status(404).send({ error: "Server not found" })
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
try {
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server)) })
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server, "gateways")) })
} catch (error) {
return reply.status(503).send({ error: error instanceof Error ? error.message : "Не удалось прочитать шлюзы" })
}
@@ -3,6 +3,27 @@ import assert from "node:assert/strict"
import { createServer } from "node:http"
import { MikrotikClient } from "./mikrotik.js"
test("RouterOS print preserves native query arrays and property selection", async () => {
let received: unknown, target = "", method = ""
const server = createServer(async (req, res) => {
target = req.url ?? ""; method = req.method ?? ""
let body = ""; for await (const chunk of req) body += chunk.toString()
received = JSON.parse(body)
res.setHeader("content-type", "application/json"); res.end(JSON.stringify([{ ".id": "*1" }]))
})
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
const address = server.address(); assert(address && typeof address !== "string")
const client = new MikrotikClient({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" })
try {
const result = await client.post("/ip/route/print", { ".query": ["comment"], ".proplist": [".id", "gateway"] })
assert.deepEqual(received, { ".query": ["comment"], ".proplist": [".id", "gateway"] })
assert.equal(target, "/rest/ip/route/print"); assert.equal(method, "POST")
assert.deepEqual(result, [{ ".id": "*1" }])
} finally {
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
})
test("RouterOS shared REST pool bounds logins across client instances and closes idle sockets", async () => {
let active=0,peak=0,requests=0
const connections=new Set<import("node:net").Socket>()
+2 -2
View File
@@ -104,7 +104,7 @@ function rosRequest(
function rosPost(
params: MikrotikConnectParams,
path: string,
body: Record<string, string>,
body: Record<string, string | string[]>,
timeoutMs: number,
externalSignal?: AbortSignal,
): Promise<unknown> {
@@ -595,7 +595,7 @@ export class MikrotikClient {
return rosRequest(this.params, path, timeoutMs) as Promise<T>
}
async post<T>(path: string, body: Record<string, string>, timeoutMs = 15_000, signal?: AbortSignal): Promise<T> {
async post<T>(path: string, body: Record<string, string | string[]>, timeoutMs = 15_000, signal?: AbortSignal): Promise<T> {
return rosPost(this.params, path, body, timeoutMs, signal) as Promise<T>
}
@@ -0,0 +1,44 @@
import assert from "node:assert/strict"
import { readRecursiveEffectiveProperties, parseRecursiveCatalog } from "./recursive-route-catalog.js"
const row = { ".id": "*1", static: "true", "dst-address": "10.9.9.2/32", gateway: "192.0.2.1%gre-test", distance: "1", scope: "30", "target-scope": "10", "routing-table": "main", comment: "MikrotikManager:recursive Тест" }
const calls: string[] = []
const result = await readRecursiveEffectiveProperties([row], async (id, property) => {
calls.push(id + ":" + property)
return property === "disabled" ? { ret: "false" } : []
})
assert.equal(parseRecursiveCatalog(result)[0].disabled, false)
assert.equal(parseRecursiveCatalog(result)[0].checkGateway, "none")
assert.deepEqual(calls.sort(), ["*1:check-gateway", "*1:disabled"])
assert.equal("disabled" in row, false)
let reads = 0
const complete = { ...row, disabled: "true", "check-gateway": "ping" }
assert.deepEqual(await readRecursiveEffectiveProperties([complete, { ".id": "*other", comment: "user-owned" }], async () => { reads++; return null }), [complete, { ".id": "*other", comment: "user-owned" }])
assert.equal(reads, 0)
for (const payload of [null, {}, [null], [{ ...row, ".id": "" }], [row, row]]) {
await assert.rejects(readRecursiveEffectiveProperties(payload, async () => { reads++; return null }))
assert.equal(reads, 0)
}
for (const property of ["disabled", "check-gateway"] as const) {
for (const response of [null, {}, { ret: false }, { ret: "" }, { ret: "unknown" }, [{}], ...(property === "disabled" ? [[]] : [])]) {
const input = { ...complete }; delete (input as Record<string, unknown>)[property]
await assert.rejects(readRecursiveEffectiveProperties([input], async () => response), /Не подтверждён/)
}
}
await assert.rejects(readRecursiveEffectiveProperties([row], async () => { throw Error("RouterOS недоступен") }), /недоступен/)
const explicit = await readRecursiveEffectiveProperties([row], async (_id, property) => ({ ret: property === "disabled" ? "true" : "bfd" }))
assert.equal(parseRecursiveCatalog(explicit)[0].disabled, true)
assert.equal(parseRecursiveCatalog(explicit)[0].checkGateway, "bfd")
// Explicit invalid values are not replaced with defaults or re-read.
const invalid = { ...complete, disabled: "unknown" }
const untouched = await readRecursiveEffectiveProperties([invalid], async () => { throw Error("unexpected read") })
assert.throws(() => parseRecursiveCatalog(untouched))
let active = 0, maximum = 0
await readRecursiveEffectiveProperties(Array.from({ length: 12 }, (_, i) => ({ ...row, ".id": "*" + i })), async (_id, property) => {
maximum = Math.max(maximum, ++active)
await new Promise(resolve => setTimeout(resolve, 1)); active--
return { ret: property === "disabled" ? "false" : "arp" }
})
assert.equal(maximum, 2)
console.log("Recursive effective properties: omitted flags/unset check, strict envelopes, ownership, no mutation, errors and bounded reads passed")
@@ -13,6 +13,44 @@ function rawRows(payload: unknown): Array<Record<string, unknown>> {
}
function rosText(value: unknown): string | undefined { return typeof value === "string" ? value : undefined }
function text(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null }
export type RecursiveObservedProperty = "disabled" | "check-gateway"
/** RouterOS print can omit unset properties. Ask get for the effective value,
* rather than interpreting an incomplete snapshot as enabled/no-check. */
export async function readRecursiveEffectiveProperties(
payload: unknown,
readProperty: (id: string, property: RecursiveObservedProperty) => Promise<unknown>,
): Promise<Array<Record<string, unknown>>> {
const rows = rawRows(payload).map(row => ({ ...row }))
const tasks: Array<{ row: Record<string, unknown>; property: RecursiveObservedProperty }> = []
for (const row of rows) {
if (typeof row.comment !== "string" || !isOwnedRecursiveComment(row.comment)) continue
for (const property of ["disabled", "check-gateway"] as const) {
if (row[property] === undefined) tasks.push({ row, property })
}
}
let cursor = 0, failed = false
await Promise.all(Array.from({ length: Math.min(2, tasks.length) }, async () => {
while (!failed && cursor < tasks.length) {
const { row, property } = tasks[cursor++]!
try {
const response = await readProperty(String(row[".id"]), property)
// An empty successful /get reply means the optional check is unset.
// Do not treat null, malformed envelopes or failed reads as defaults.
const unset = property === "check-gateway" && Array.isArray(response) && response.length === 0
const value = response && typeof response === "object" && !Array.isArray(response) && "ret" in response ? response.ret : undefined
if (property === "disabled") {
if (typeof value !== "string" || observedBoolean(value) === null) throw new Error("Не подтверждён disabled маршрута " + row[".id"])
row.disabled = observedBoolean(value) ? "true" : "false"
} else {
if (!unset && (typeof value !== "string" || !["none", "arp", "ping", "bfd"].includes(value))) throw new Error("Не подтверждён check-gateway маршрута " + row[".id"])
row[property] = unset ? "none" : value
}
} catch (error) { failed = true; throw error }
}
}))
return rows
}
export function parseRecursiveCatalog(payload: unknown): MappedRecursiveRoute[] {
return rawRows(payload).filter(row => typeof row.comment === "string" && isOwnedRecursiveComment(row.comment)).map(row => {
const id = String(row[".id"])
+1 -1
View File
@@ -77,7 +77,7 @@ function FilterGatewayCell({
? recursiveRoutes.find((r) => r.id === rule.gatewayTunnelId.slice(4))
: undefined
const recRowByHop =
!isBlackhole && !(rule.gatewayTunnelId ?? "").trim() && rule.gateway.trim()
!isBlackhole && !recRowByRef && (isRecRef || !(rule.gatewayTunnelId ?? "").trim()) && rule.gateway.trim()
? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway)
: undefined
const recRow = recRowByRef ?? recRowByHop
+47
View File
@@ -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>
)
}
+32
View File
@@ -0,0 +1,32 @@
# Production frontend v2.0.1 — 2026-10-15
Владелец отдельно разрешил обновить только frontend и проверить карту. До обновления публичный release-manifest и OCI labels работающего контейнера подтверждали2.0.0/72bf648c. Релиз2.0.1/042407f0 опубликован CI2467 успешно; публикация образа не обновила работающий frontend автоматически. Причина отсутствия узлов в старой версии — отклонение PostgreSQL polledAt (блок265).
На production прочитаны настройки Compose только для сервиса frontend: проект cdn-mm, файл /opt/cdn-mm/docker-compose.yml, образ git.shx.one/denozord/mikrotikmanager-frontend:latest. Учётные данные/полная конфигурация не выводились.
Проверенный образ:
```text
git.shx.one/denozord/mikrotikmanager-frontend@sha256:73ac48979084c0c93b77e75308a9e2f002481586704d0c03fd9a7b5e39511647
version=2.0.1
revision=042407f03ed935d26576cbd31dbaaca0e24d8a84
```
После pull по digest и проверки OCI revision выполнен локальный tag этого образа в frontend:latest, затем:
```bash
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
```
Frontend started2026-10-15T16:22:46.626507761Z (23:22:46 Новосибирск), healthy. Публичный manifest200/version2.0.1. Backend/PostgreSQL сохранили container ID и StartedAt, не перезапускались; Compose-файл, БД, права и маршрутизаторы не менялись. Пересоздание frontend заняло около1,3с по Compose, это не измерение полной пользовательской загрузки.
В браузере после reload подтверждены4 узла (3JH/1EN),4online,25 загруженных GRE; нарисованы3/25 связи. Ошибка первоначальной валидации исчезла, выбор устройства и экспорт доступны. Осталось ограничение read-only: POST network/resolve-hosts запрещён mm:network:write; связи по неразрешённым FQDN не подтверждены. Права не расширялись, все25 туннелей не объявляются подтверждёнными линиями. API до обновления: servers171мс, GRE498мс, speed-probes56мс; исправленная схема принимает4/25/0. Это разовый замер, не гарантия всех задержек.
Старый образ оставлен локально для отката; автоматический prune не запускался. При отдельно согласованном откате:
```bash
docker tag sha256:59afb59c6b7322f339cc1b5cca9a0c9ae725fd04a45a135bf023ed81c6e528a2 git.shx.one/denozord/mikrotikmanager-frontend:latest
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
```
Откат возвращает и дефект карты. Исправление Filters0f0d3aa0 в образ2.0.1 не входит и остаётся локальным. Скриншот с production-данными сохранён приватно в игнорируемом tmp/prod-map269.jpg, в Git не включён.
+32 -2
View File
@@ -1,6 +1,8 @@
# UX/UI: журнал реализации
Актуальное состояние 2026-10-15: Production266 — с отдельного разрешения создан online-индекс retention; нагрузка БД существенно снизилась в двух повторных замерах. Карта исправлена локально (265), frontend-патч ещё не развёрнут. Проверка трафика и причин остальных тайм-аутов продолжается отдельно. Лабораторная приёмка264 завершена в документированных границах стенда:1396 основных UI и221 context-сценарий;120 live default states30 экранов. Runtime стенда backend258/frontend261. Непроверенные внешние группы сохранены в итоговом акте. Production v2.0.0 подтверждён через UI; [описание релиза](releases/2.0.0.md).
Актуальное состояние 2026-10-16: Production271 — подготовлено исправление чтения рекурсивных маршрутов JH, подтверждено native чтением всех3 JH. Локальные Filters268, визуальная карта270 и backend271 ещё не опубликованы; production frontend2.0.1 из269. Подробности и проверки ниже.
Предыдущее состояние 2026-10-15: Production269 — по отдельному разрешению обновлён production frontend до2.0.1; в браузере подтверждены3JH/1EN и25GRE в данных. Filters268 остаётся локальным;6 профильных E2E/TypeScript passed. Карта и миграция retention опубликованы в main; исправлен CI PostgreSQL, backend-test2467/8417 success. Карта принята в production с ограничением read-only DNS; Filters ещё не развёрнут. Online-индекс266 применён отдельно с разрешения владельца; нагрузка БД снизилась в двух повторных замерах. Проверка трафика и причин остальных тайм-аутов продолжается отдельно. Лабораторная приёмка264 завершена в документированных границах стенда:1396 основных UI и221 context-сценарий;120 live default states30 экранов. Runtime стенда backend258/frontend261. Непроверенные внешние группы сохранены в итоговом акте. Production v2.0.0 подтверждён через UI; [описание релиза](releases/2.0.0.md).
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
@@ -38,11 +40,39 @@
| 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-выпуск не выполнялся. |
## Production271 — чтение рекурсивных маршрутов JH (2026-10-16)
Production GET /recursive-routes/catalog на двух московских JH возвращал503 «Не подтверждены параметры управляемого маршрута», на новосибирском наблюдался отдельный10с timeout. Проверка native RouterOS чтением: список и GET конкретной строки не содержат disabled/check-gateway; /ip/route/get подтверждает disabled=false, незаданный check-gateway возвращает[]. Строгий parser сохранён; добавлено дочитывание отсутствующих полей только для собственных маршрутов, максимум2 запроса одновременно. Только успешный пустой get check-gateway означает none; null/неверный envelope/ошибка не становятся defaults. Explicit invalid значения не заменяются.
Нагрузка чтения: полные таблицы12441/24864/12451 строк занимали2,4/8,0/2,1с. Каталог теперь использует штатный POST print с .query=[comment] (все комментарии, включая старые метки и неподдерживаемые owned строки), а не static-only фильтр, который мог бы скрыть неподдерживаемый объект. Отбор вернул12/13/7 строк за0,5/1,7/0,5с. Каталог шлюзов сохраняет все маршруты, но запрашивает только .id/gateway/active. Тип POST body расширен под штатные строковые массивы query/proplist, сериализация проверена настоящим локальным HTTP.
Новый код в отдельном процессе на production проверен только чтением, без замены работающего backend:4/3/5 собственных маршрутов успешно разобраны за1,026/3,225/0,643с с7/4/3 дочитываниями. RouterOS-конфигурация не менялась, saveObserved/применение не вызывались. Это замер чтения/разбора, не всего UI; отдельная загрузка шлюзов в него не включена. Большой первоначальный tool input отклонён автоматической проверкой по размеру; читаемый скомпилированный скрипт передан частями через stdin, без файлов/секретов на сервере.
Проверки:4 backend scripts recursive-routes passed;11 HTTP client tests passed;15 page E2E passed; backend build/scoped ESLint/diff check passed. В HTTP tests подтверждены восстановление omitted fields и503 без записи cache при ошибке get. [Анонимизированный протокол](ux-ui/acceptance/prod271-recursive.json), [официальный REST API](https://manual.mikrotik.com/docs/developer-guides/rest-api/). Изменение локальное, без push/deploy. Следующий шаг — согласовать публикацию готовых268/270/271 и обновление backend/frontend; текущая 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-патч требует публикации.
## Production268 — рекурсивные шлюзы Filters после обновления каталога (2026-10-15)
По production-скриншоту Filters показывает «рекурсивный маршрут (нет строки в списке)», хотя каталог формы содержит шлюз. Найден механизм: legacy GET /recursive-routes пересоздаёт строки recursive_routes, а правила могут сохранять прежние rec:id; таблица допускала поиск по hop только при пустой ссылке. Для явной рекурсивной ссылки с отсутствующим ID теперь используется уже имеющийся выбор активного маршрута по dst-address/hop и минимальной distance в каталоге выбранного устройства. Редактирование восстанавливает актуальный rec:id и выделение карточки. Именованный GRE не переопределяется по совпавшему IP; при реальном отсутствии маршрута предупреждение сохранено. Точная ссылка по ID сохраняет приоритет.
6 E2E passed: две новые регрессии и существующие проверки gateway failure/save. Новый сценарий проверяет отображение, выделение, отсутствие записи до подтверждения и payload с новым rec:id; второй — настоящий orphan и GRE с совпавшим IP. TypeScript passed; scoped ESLint0 errors/11 прежних unused-import warnings в filters/page.tsx. Непривилегированный первый запуск E2E не поднял доступный локальный сервер и остановлен; проверки выполнены повторно с разрешением запуска локального сервера/браузера. Backend и production-конфигурация не менялись; механизм пересоздания cache ID этим frontend-патчем не перерабатывается. Исправление сохранено локально, без push/deploy. Следующий шаг — опубликовать этот патч и проверить production Filters после обновления frontend.
## Production267 — подключение PostgreSQL в CI (2026-10-15)
После push main backend-test2466/8410 завершился ECONNREFUSED: PostgreSQL публиковался на loopback Docker-хоста, но тестовый процесс работал в отдельном контейнере Gitea runner. Прежние SQL-тесты пропускали подключение; новый обязательный retention-тест выявил проблему. PostgreSQL теперь присоединяется к сети контейнера задания и доступен по уникальному DNS alias; для host runner сохранён вариант с назначенным Docker портом. Удаление общего mm-pg исключено, cleanup затрагивает только контейнер текущего запуска. Перед тестами выполняется SELECT1 из процесса задания; REQUIRE_TEST_POSTGRES=1 запрещает скрытый skip при недоступности/ошибке bootstrap.
Workflow YAML/scoped ESLint/diff check прошли. Проверены оба режима helper на недоступном localhost-порту: локально false/skip, в обязательном режиме исходная ошибка подключения выходит наружу. Полная CI-проверка сети и SQL-профилей должна подтвердиться следующим запуском; успешный тест retention на реальном тестовом PostgreSQL описан в266. Production этим блоком напрямую не изменялся.
Workflow YAML/scoped ESLint/diff check прошли. Проверены оба режима helper на недоступном localhost-порту: локально false/skip, в обязательном режиме исходная ошибка подключения выходит наружу. После push042407f0 [backend-test2467/8417](https://git.shx.one/denozord/MikrotikManager/actions/runs/2467/jobs/8417) завершён success, включая retention20тыс. строк; обязательное подключение из тестового процесса подтверждено. Production этим блоком напрямую не изменялся.
## Production266 — online-индекс retention и проверка нагрузки (2026-10-15)
@@ -0,0 +1,44 @@
{
"date": "2026-10-16",
"scope": "Local backend fix plus read-only verification against production JH; no application deployment or RouterOS configuration writes",
"causes": [
"RouterOS list omitted disabled and check-gateway; strict catalog parser returned 503",
"The page loaded full 12k-25k routing tables; an observed 8-second read approached the 10-second timeout"
],
"nativeObservations": {
"disabledGet": { "ret": "false" },
"unsetCheckGatewayGet": [],
"configurationWrites": 0
},
"fix": {
"routeList": "POST /ip/route/print with .query=[comment]; includes all ownership markers and unsupported commented rows",
"gatewayList": "GET /ip/route with .proplist=.id,gateway,active; retains all routes",
"missingProperties": "Read disabled/check-gateway via /ip/route/get only for owned rows with absent fields",
"maximumPropertyConcurrency": 2,
"emptySuccessfulCheckGet": "none",
"malformedAndFailedReads": "rejected, no successful empty snapshot or cache update"
},
"readOnlyVerification": [
{ "serverId": 2, "fullRows": 12441, "fullReadMs": 2394, "commentedRows": 12, "commentedReadMs": 500, "parsedOwnedRows": 4, "effectivePropertyReads": 7, "combinedReadAndParseMs": 1026 },
{ "serverId": 3, "fullRows": 24864, "fullReadMs": 7986, "commentedRows": 13, "commentedReadMs": 1697, "parsedOwnedRows": 3, "effectivePropertyReads": 4, "combinedReadAndParseMs": 3225 },
{ "serverId": 4, "fullRows": 12451, "fullReadMs": 2106, "commentedRows": 7, "commentedReadMs": 488, "parsedOwnedRows": 5, "effectivePropertyReads": 3, "combinedReadAndParseMs": 643 }
],
"checks": {
"recursiveBackendProfile": "4 scripts passed, including new effective-property tests and HTTP failure/no-cache coverage",
"routerOsHttpClient": "11 tests passed, including native query array serialization",
"recursivePageE2E": "15 passed",
"backendBuild": "passed",
"scopedEslint": "passed",
"gitDiffCheck": "passed"
},
"limitations": [
"Read-only native checks, not acceptance of a deployed HTTP/UI fix",
"One-off timings; gateway inventory remains a separate request and is not included in combinedReadAndParseMs",
"No broader timeout/connection-pool redesign or changes to native route application rules",
"Existing cache ID replacement behavior remains separate from this fix"
],
"sources": [
"https://manual.mikrotik.com/docs/developer-guides/rest-api/",
"https://manual.mikrotik.com/docs/cli-reference/ip/route/"
]
}
@@ -0,0 +1,58 @@
import { test, expect } from "@playwright/test"
test("Filters: обновлённый ID рекурсивного маршрута не скрывает шлюз и выбор в форме", async ({ page }) => {
const rule = { id: "rule-1", community: "65001:120", gateway: "10.9.9.2", gatewayTunnelId: "rec:42", action: "route", description: "Маршрут" }
let writes = 0
let savedGatewayRef: string | undefined
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (r.request().method() !== "GET") {
writes++
const payload = r.request().postDataJSON()
savedGatewayRef = payload.rules[0].gatewayTunnelId
return r.fulfill({ json: { ok: true, rules: payload.rules } })
}
return r.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [rule] }], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "jump-host", enabled: true, country: "RU", status: "online", latency: 1 }]
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [{ id: "99", dstAddress: "10.9.9.2/32", gateway: "192.0.2.2%gre-test", distance: 1, routingTable: "main", comment: "Рекурсивный тест", disabled: false }], stale: false }
: path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/filters")
const row = page.locator("tr[data-row-id]").first()
await expect(row).toContainText("10.9.9.2/32")
await expect(row).not.toContainText("нет строки в списке")
await row.getByRole("button", { name: "Редактировать правило", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("tab", { name: /Рекурсив/ })).toHaveAttribute("aria-selected", "true")
await expect(dialog.getByRole("button", { name: /10\.9\.9\.2\/32/ })).toHaveClass(/border-primary/)
await expect(dialog.getByText("Выберите рекурсивный маршрут", { exact: true })).toHaveCount(0)
expect(writes).toBe(0)
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
await expect(dialog).toHaveCount(0)
expect(writes).toBe(1)
expect(savedGatewayRef).toBe("rec:99")
})
test("Filters: отсутствующий маршрут и именованный GRE не подменяются по совпавшему IP", async ({ page }) => {
const rules = [
{ id: "orphan", community: "65001:120", gateway: "10.9.9.3", gatewayTunnelId: "rec:42", action: "route", description: "Нет маршрута" },
{ id: "gre", community: "65001:130", gateway: "10.9.9.2", gatewayTunnelId: "gre-test", action: "route", description: "Это GRE" },
]
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/filters/rules" ? { rulesets: [{ serverId: "1", rules }], live: true }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", type: "jump-host", enabled: true, country: "RU", status: "online", latency: 1 }]
: path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/recursive-routes" ? { routes: [{ id: "99", dstAddress: "10.9.9.2/32", gateway: "192.0.2.2", distance: 1, routingTable: "main", comment: "", disabled: false }], stale: false }
: path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/filters")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(2)
await expect(rows.nth(0)).toContainText("нет строки в списке")
await expect(rows.nth(1)).not.toContainText("recursive")
})
+74
View File
@@ -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")
})