Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6a2e4009d | ||
|
|
eb24a08981 | ||
|
|
1c11a133f3 | ||
|
|
0f0d3aa060 |
No files matched your search
@@ -1676,11 +1676,17 @@ function FiltersPage() {
|
|||||||
}
|
}
|
||||||
const openEdit = (rule: FilterRule) => {
|
const openEdit = (rule: FilterRule) => {
|
||||||
if (mutationsLocked || applyLock.current) return
|
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({
|
setSheetInitial({
|
||||||
action: rule.action ?? "route",
|
action: rule.action ?? "route",
|
||||||
community: rule.community,
|
community: rule.community,
|
||||||
gateway: rule.gateway,
|
gateway: rule.gateway,
|
||||||
gatewayTunnelId: rule.gatewayTunnelId,
|
gatewayTunnelId: recursive ? `rec:${recursive.id}` : rule.gatewayTunnelId,
|
||||||
gatewayKind: isRecursiveGatewayRef(rule.gatewayTunnelId) ? "recursive" : "gre",
|
gatewayKind: isRecursiveGatewayRef(rule.gatewayTunnelId) ? "recursive" : "gre",
|
||||||
description: rule.description,
|
description: rule.description,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: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: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: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: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: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",
|
"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"
|
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 Тест" }
|
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 rawPayload: unknown = [observed], rawFailure = false, savedSnapshots = 0
|
||||||
|
let propertyFailure = false, propertyReads = 0
|
||||||
const applied: Array<{ rows: typeof row[]; source: string }> = []
|
const applied: Array<{ rows: typeof row[]; source: string }> = []
|
||||||
const app = Fastify()
|
const app = Fastify()
|
||||||
app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
|
app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
|
||||||
@@ -16,6 +17,7 @@ await app.register(createRecursiveRoutes({
|
|||||||
getServer: async id => { lookup++; return id === 1 ? server : undefined },
|
getServer: async id => { lookup++; return id === 1 ? server : undefined },
|
||||||
getRoutes: async () => { if (lateReadFailure) throw Error("Сбой чтения после применения"); return stored },
|
getRoutes: async () => { if (lateReadFailure) throw Error("Сбой чтения после применения"); return stored },
|
||||||
readRaw: async () => { rawReads++; if (rawFailure) throw new Error("Тест опроса"); return rawPayload },
|
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[] },
|
saveObserved: async (_id, rows) => { savedSnapshots++; stored = rows as typeof row[] },
|
||||||
apply: async (device, rows, source) => {
|
apply: async (device, rows, source) => {
|
||||||
assert.equal(device.id, 1); writes++; applied.push({ rows: rows as typeof row[], 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)
|
assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})).statusCode,503)
|
||||||
rawFailure=false;rawPayload=[]
|
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})
|
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"}]
|
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")
|
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) {
|
for (const type of ["home-router", "exit-node"] as const) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { recursiveApplyBodySchema, recursiveTargetSchema, recursiveInputRowsSchema } from '../services/recursive-route-validation.js'
|
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 { executeRecursiveApply, RecursiveApplyError } from "../services/recursive-apply.js"
|
||||||
import { asc, eq } from "drizzle-orm"
|
import { asc, eq } from "drizzle-orm"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
@@ -176,7 +176,8 @@ function recursiveFailureReceipt(error: unknown, result?: { pushed: number; dele
|
|||||||
|
|
||||||
export interface RecursiveRouteDependencies {
|
export interface RecursiveRouteDependencies {
|
||||||
getServer: (id: number) => Promise<ServerRow | undefined>
|
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>
|
saveObserved: (serverId: number, routes: RecursiveRouteDto[]) => Promise<void>
|
||||||
getRoutes: typeof mapDbRoutes
|
getRoutes: typeof mapDbRoutes
|
||||||
apply: typeof applyRecursiveToServer
|
apply: typeof applyRecursiveToServer
|
||||||
@@ -187,7 +188,15 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
|||||||
const deps: RecursiveRouteDependencies = {
|
const deps: RecursiveRouteDependencies = {
|
||||||
getServer: async id => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0],
|
getServer: async id => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0],
|
||||||
getRoutes: mapDbRoutes, apply: applyRecursiveToServer, getRevision: getRevisionById,
|
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) => {
|
saveObserved: async (serverId, routes) => {
|
||||||
await replaceDbRoutes(serverId, routes)
|
await replaceDbRoutes(serverId, routes)
|
||||||
await appendRevisionIfChanged({ serverId, section: "recursive-routes", source: "observed", payload: canonicalRecursiveRoutes(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) return reply.status(404).send({ error: "Server not found" })
|
||||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||||
try {
|
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)
|
await deps.saveObserved(serverId, routes)
|
||||||
return reply.send({ serverId, routes, live: true, stale: false })
|
return reply.send({ serverId, routes, live: true, stale: false })
|
||||||
} catch (error) {
|
} 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) return reply.status(404).send({ error: "Server not found" })
|
||||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||||
try {
|
try {
|
||||||
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server)) })
|
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server, "gateways")) })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return reply.status(503).send({ error: error instanceof Error ? error.message : "Не удалось прочитать шлюзы" })
|
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 { createServer } from "node:http"
|
||||||
import { MikrotikClient } from "./mikrotik.js"
|
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 () => {
|
test("RouterOS shared REST pool bounds logins across client instances and closes idle sockets", async () => {
|
||||||
let active=0,peak=0,requests=0
|
let active=0,peak=0,requests=0
|
||||||
const connections=new Set<import("node:net").Socket>()
|
const connections=new Set<import("node:net").Socket>()
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ function rosRequest(
|
|||||||
function rosPost(
|
function rosPost(
|
||||||
params: MikrotikConnectParams,
|
params: MikrotikConnectParams,
|
||||||
path: string,
|
path: string,
|
||||||
body: Record<string, string>,
|
body: Record<string, string | string[]>,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
externalSignal?: AbortSignal,
|
externalSignal?: AbortSignal,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
@@ -595,7 +595,7 @@ export class MikrotikClient {
|
|||||||
return rosRequest(this.params, path, timeoutMs) as Promise<T>
|
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>
|
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 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 }
|
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[] {
|
export function parseRecursiveCatalog(payload: unknown): MappedRecursiveRoute[] {
|
||||||
return rawRows(payload).filter(row => typeof row.comment === "string" && isOwnedRecursiveComment(row.comment)).map(row => {
|
return rawRows(payload).filter(row => typeof row.comment === "string" && isOwnedRecursiveComment(row.comment)).map(row => {
|
||||||
const id = String(row[".id"])
|
const id = String(row[".id"])
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function FilterGatewayCell({
|
|||||||
? recursiveRoutes.find((r) => r.id === rule.gatewayTunnelId.slice(4))
|
? recursiveRoutes.find((r) => r.id === rule.gatewayTunnelId.slice(4))
|
||||||
: undefined
|
: undefined
|
||||||
const recRowByHop =
|
const recRowByHop =
|
||||||
!isBlackhole && !(rule.gatewayTunnelId ?? "").trim() && rule.gateway.trim()
|
!isBlackhole && !recRowByRef && (isRecRef || !(rule.gatewayTunnelId ?? "").trim()) && rule.gateway.trim()
|
||||||
? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway)
|
? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway)
|
||||||
: undefined
|
: undefined
|
||||||
const recRow = recRowByRef ?? recRowByHop
|
const recRow = recRowByRef ?? recRowByHop
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -1,6 +1,8 @@
|
|||||||
# UX/UI: журнал реализации
|
# 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).
|
Общий план: [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 |
|
| 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-выпуск не выполнялся. |
|
||||||
|
|
||||||
|
## 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)
|
## 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.
|
После 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)
|
## 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")
|
||||||
|
})
|
||||||
@@ -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