fix(api): сохранять достоверные параметры рекурсивных маршрутов

This commit is contained in:
Denozordec committed 2026-09-30 22:08:22 +07:00
1 parent 50fad3ac80
commit 54ef752a5b
17 files changed
+198 -45

No files matched your search

+11 -10
View File
@@ -7,7 +7,6 @@ import { PageHeader } from "@/components/page-header"
import { import {
RecursiveRoutesDataGrid, RecursiveRoutesDataGrid,
type RecursiveRouteGroup, type RecursiveRouteGroup,
inferCountry,
} from "@/components/data-grids/recursive-routes-data-grid" } from "@/components/data-grids/recursive-routes-data-grid"
import { FormField, SectionTitle } from "@/components/form-kit" import { FormField, SectionTitle } from "@/components/form-kit"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
@@ -47,7 +46,7 @@ interface GatewayOption {
id: string id: string
name: string name: string
ip: string ip: string
status: "up" | "down" status: "active" | "inactive" | "unknown"
} }
const COUNTRY_OPTIONS = [ const COUNTRY_OPTIONS = [
@@ -100,6 +99,7 @@ interface RouteForm {
interface RouteEndpointForm { interface RouteEndpointForm {
id: string id: string
disabled: boolean
gateway: string gateway: string
distance: number distance: number
scope: number | null scope: number | null
@@ -110,6 +110,7 @@ interface RouteEndpointForm {
const newEndpoint = (): RouteEndpointForm => ({ const newEndpoint = (): RouteEndpointForm => ({
id: `ep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, id: `ep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
disabled: false,
gateway: "", gateway: "",
distance: 1, distance: 1,
scope: null, scope: null,
@@ -241,7 +242,7 @@ function RouteSheet({
<div key={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3"> <div key={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide"> <span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Endpoint {idx + 1} Endpoint {idx + 1}{ep.disabled ? " · отключён" : ""}
</span> </span>
<button <button
type="button" type="button"
@@ -282,7 +283,6 @@ function RouteSheet({
{gateways.map((g) => { {gateways.map((g) => {
const value = `${g.ip}%${g.name}` const value = `${g.ip}%${g.name}`
const selected = ep.gateway === value const selected = ep.gateway === value
const country = inferCountry(g.name)
return ( return (
<button <button
key={`${ep.id}-${g.id}`} key={`${ep.id}-${g.id}`}
@@ -296,9 +296,9 @@ function RouteSheet({
)} )}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={cn("size-1.5 rounded-full shrink-0", g.status === "up" ? "bg-emerald-500" : "bg-red-500")} /> <span className={cn("size-1.5 rounded-full shrink-0", g.status === "active" ? "bg-success" : "bg-muted-foreground")} />
{country && <Flag code={country} className="shrink-0" />}
<span className="font-mono text-xs font-semibold flex-1 truncate">{g.name}</span> <span className="font-mono text-xs font-semibold flex-1 truncate">{g.name}</span>
<span className="text-xs text-muted-foreground">{g.status === "active" ? "Активный маршрут" : g.status === "inactive" ? "Неактивный маршрут" : "Статус неизвестен"}</span>
{selected && <CheckIcon className="size-3.5 text-primary shrink-0" />} {selected && <CheckIcon className="size-3.5 text-primary shrink-0" />}
</div> </div>
<div className="mt-1.5 grid grid-cols-[auto_auto_1fr] items-start gap-2 text-[11px] text-muted-foreground font-mono"> <div className="mt-1.5 grid grid-cols-[auto_auto_1fr] items-start gap-2 text-[11px] text-muted-foreground font-mono">
@@ -466,7 +466,7 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
const epoch = ++readEpoch.current const epoch = ++readEpoch.current
setOpError(null); setBusy("load"); setLiveStale(true) setOpError(null); setBusy("load"); setLiveStale(true)
try { try {
const parsed = recursiveReadSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes?serverId=${encodeURIComponent(selectedServerId)}`)) const parsed = recursiveReadSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/catalog?serverId=${encodeURIComponent(selectedServerId)}`))
if (!parsed.success) throw new Error("Некорректный снимок маршрутов в ответе API") if (!parsed.success) throw new Error("Некорректный снимок маршрутов в ответе API")
const res = parsed.data const res = parsed.data
if (String(res.serverId) !== selectedServerId) throw new Error("Маршруты принадлежат другому устройству") if (String(res.serverId) !== selectedServerId) throw new Error("Маршруты принадлежат другому устройству")
@@ -486,7 +486,7 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId)) return if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId)) return
const epoch = ++gatewayEpoch.current const epoch = ++gatewayEpoch.current
try { try {
const parsed = recursiveGatewaysSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/gateways?serverId=${encodeURIComponent(selectedServerId)}`)) const parsed = recursiveGatewaysSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/gateways/catalog?serverId=${encodeURIComponent(selectedServerId)}`))
if (!parsed.success) throw new Error("Некорректный список шлюзов в ответе API") if (!parsed.success) throw new Error("Некорректный список шлюзов в ответе API")
const res = parsed.data const res = parsed.data
if (String(res.serverId) !== selectedServerId) throw new Error("Шлюзы принадлежат другому устройству") if (String(res.serverId) !== selectedServerId) throw new Error("Шлюзы принадлежат другому устройству")
@@ -596,6 +596,7 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
comment: group.comment, comment: group.comment,
endpoints: endpoints.map(ep => ({ endpoints: endpoints.map(ep => ({
id: ep.id, id: ep.id,
disabled: ep.disabled,
gateway: ep.gateway, gateway: ep.gateway,
distance: ep.distance, distance: ep.distance,
scope: ep.scope, scope: ep.scope,
@@ -617,8 +618,8 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
routingTable: v.routingTable, routingTable: v.routingTable,
checkGateway: ep.checkGateway, checkGateway: ep.checkGateway,
comment: v.comment, comment: v.comment,
disabled: false, disabled: ep.disabled,
country: ep.country || inferCountry(ep.gateway) || "", country: ep.country || "",
}) })
let next: RecursiveRouteRow[] let next: RecursiveRouteRow[]
if (sheetMode === "create") { if (sheetMode === "create") {
+1 -1
View File
@@ -19,7 +19,7 @@
"test:traffic-flow": "tsx src/services/traffic-flow-parse.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/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/services/traffic-flow-parse.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/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", "test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts",
"test:pg": "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/services/config-revisions.test.ts", "test:pg": "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/services/config-revisions.test.ts",
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts", "test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts",
"test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts", "test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts",
"test:ros-file": "tsx src/services/ros-file-contents.test.ts", "test:ros-file": "tsx src/services/ros-file-contents.test.ts",
"test:backups": "tsx src/services/s3-backup-client.test.ts", "test:backups": "tsx src/services/s3-backup-client.test.ts",
@@ -5,12 +5,16 @@ import { createRecursiveRoutes, type RecursiveRouteDependencies } from "./recurs
const row = { id: "one", dstAddress: "10.0.0.0/24", gateway: "192.0.2.1", distance: 0, scope: 0, targetScope: 0, routingTable: "main", checkGateway: "ping", country: "", comment: "Маршрут", disabled: false } const row = { id: "one", dstAddress: "10.0.0.0/24", gateway: "192.0.2.1", distance: 0, scope: 0, targetScope: 0, routingTable: "main", checkGateway: "ping", country: "", comment: "Маршрут", disabled: false }
const server = { id: 1, name: "Тест" } as NonNullable<Awaited<ReturnType<RecursiveRouteDependencies["getServer"]>>> const server = { id: 1, name: "Тест" } as NonNullable<Awaited<ReturnType<RecursiveRouteDependencies["getServer"]>>>
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 Тест" }
let rawPayload: unknown = [observed], rawFailure = false, savedSnapshots = 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)
await app.register(createRecursiveRoutes({ await app.register(createRecursiveRoutes({
getServer: async id => { lookup++; return id === 1 ? server : undefined }, getServer: async id => { lookup++; return id === 1 ? server : undefined },
getRoutes: async () => stored, getRoutes: async () => stored,
readRaw: async () => { if (rawFailure) throw new Error("Тест опроса"); return rawPayload },
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 })
if (fail) throw new Error("Тест частичного применения") if (fail) throw new Error("Тест частичного применения")
@@ -65,6 +69,20 @@ assert.equal(restored.json().routes[0].distance, 0)
payload = [] payload = []
const restoredEmpty = await app.inject({ method: "POST", url: "/api/recursive-routes/revisions/rev/restore", payload: { serverId: 1 } }) const restoredEmpty = await app.inject({ method: "POST", url: "/api/recursive-routes/revisions/rev/restore", payload: { serverId: 1 } })
assert.deepEqual(restoredEmpty.json(), { ok: true, serverId: 1, routes: [], pushedRoutes: 0 }) assert.deepEqual(restoredEmpty.json(), { ok: true, serverId: 1, routes: [], pushedRoutes: 0 })
const catalog=await app.inject({ method: "GET", url: "/api/recursive-routes/catalog?serverId=1" })
assert.equal(catalog.statusCode,200); assert.equal(catalog.json().routes[0].distance,0); assert.equal(catalog.json().serverId,1)
const savedBefore=savedSnapshots
for(const bad of [[{...observed,distance:undefined}], {}, [{...observed,blackhole:"true"}]]) {
rawPayload=bad
const result=await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})
assert.equal(result.statusCode,503);assert.equal(result.json().routes,undefined);assert.equal(savedSnapshots,savedBefore)
}
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=[{".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")
await app.close() await app.close()
console.log("recursive routes HTTP: invalid/explicit-empty/zero/copy/error/restore ownership and payload passed without DB/RouterOS") console.log("recursive routes HTTP: invalid/explicit-empty/zero/copy/error/restore ownership and payload passed without DB/RouterOS")
+33
View File
@@ -1,4 +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 { asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
import { z } from "zod" import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
@@ -175,6 +176,8 @@ const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
export interface RecursiveRouteDependencies { export interface RecursiveRouteDependencies {
getServer: (id: number) => Promise<ServerRow | undefined> getServer: (id: number) => Promise<ServerRow | undefined>
readRaw: (server: ServerRow) => Promise<unknown>
saveObserved: (serverId: number, routes: RecursiveRouteDto[]) => Promise<void>
getRoutes: typeof mapDbRoutes getRoutes: typeof mapDbRoutes
apply: typeof applyRecursiveToServer apply: typeof applyRecursiveToServer
getRevision: typeof getRevisionById getRevision: typeof getRevisionById
@@ -184,10 +187,40 @@ 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"),
saveObserved: async (serverId, routes) => {
await replaceDbRoutes(serverId, routes)
await appendRevisionIfChanged({ serverId, section: "recursive-routes", source: "observed", payload: canonicalRecursiveRoutes(routes) })
},
...overrides, ...overrides,
} }
return async (app) => { return async (app) => {
app.get("/recursive-routes/catalog", async (req, reply) => {
const parsed = recursiveTargetSchema.safeParse(req.query)
if (!parsed.success) return reply.status(400).send({ error: "serverId is required" })
const serverId = parsed.data.serverId, server = await deps.getServer(serverId)
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const routes = mergeCachedCountry(parseRecursiveCatalog(await deps.readRaw(server)), await deps.getRoutes(serverId))
await deps.saveObserved(serverId, routes)
return reply.send({ serverId, routes, live: true, stale: false })
} catch (error) {
return reply.status(503).send({ error: error instanceof Error ? error.message : "Не удалось прочитать каталог маршрутов" })
}
})
app.get("/recursive-routes/gateways/catalog", async (req, reply) => {
const parsed = recursiveTargetSchema.safeParse(req.query)
if (!parsed.success) return reply.status(400).send({ error: "serverId is required" })
const serverId = parsed.data.serverId, server = await deps.getServer(serverId)
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server)) })
} catch (error) {
return reply.status(503).send({ error: error instanceof Error ? error.message : "Не удалось прочитать шлюзы" })
}
})
app.get("/recursive-routes/gateways", async (req, reply) => { app.get("/recursive-routes/gateways", async (req, reply) => {
const q = req.query as { serverId?: string | number } const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId) const serverId = parseDbServerId(q.serverId)
@@ -0,0 +1,20 @@
import assert from "node:assert/strict"
import { parseRecursiveCatalog, parseRecursiveGatewayCatalog } from "./recursive-route-catalog.js"
const row={".id":"*1",static:"true","dst-address":"10.0.0.0/24",gateway:"192.0.2.1%ether1",distance:"0",scope:"0","target-scope":"0","routing-table":"main","check-gateway":"none",disabled:"false",comment:"MikrotikManager:recursive Тест"}
const known=parseRecursiveCatalog([row])[0]
assert.equal(known.distance,0);assert.equal(known.scope,0);assert.equal(known.targetScope,0);assert.equal(known.disabled,false);assert.equal(known.checkGateway,"none")
const unset=parseRecursiveCatalog([{...row,scope:undefined,"target-scope":undefined}])[0]
assert.equal(unset.scope,null);assert.equal(unset.targetScope,null)
for(const field of ["distance","disabled","routing-table","check-gateway","static"]) assert.throws(()=>parseRecursiveCatalog([{...row,[field]:undefined}]),field)
for(const change of [{distance:"1x"},{distance:"-1"},{distance:"256"},{scope:"1x"},{"target-scope":"256"},{disabled:"unknown"},{"check-gateway":"unknown"},{gateway:"999.0.0.1"},{"dst-address":"10.0.0.0/33"},{dynamic:"true"},{blackhole:"true"}])assert.throws(()=>parseRecursiveCatalog([{...row,...change}]),JSON.stringify(change))
for(const bad of [{},[null],[{...row,".id":""}],[row,row],[{...row,comment:true}]]) assert.throws(()=>parseRecursiveCatalog(bad))
assert.deepEqual(parseRecursiveCatalog([]),[])
assert.deepEqual(parseRecursiveCatalog([{".id":"*other",comment:"user-owned",static:"false"}]),[])
assert.equal(parseRecursiveCatalog([{...row,disabled:"true"}])[0].disabled,true)
const gateway=(id:string,active?:string)=>({".id":id,gateway:"192.0.2.1%ether1",active})
assert.equal(parseRecursiveGatewayCatalog([gateway("a")])[0].status,"unknown")
assert.equal(parseRecursiveGatewayCatalog([gateway("a","false")])[0].status,"inactive")
assert.equal(parseRecursiveGatewayCatalog([gateway("a","false"),gateway("b")])[0].status,"unknown")
assert.equal(parseRecursiveGatewayCatalog([gateway("a"),gateway("b","true")])[0].status,"active")
assert.deepEqual(parseRecursiveGatewayCatalog([{".id":"a",gateway:"999.0.0.1"}]),[])
console.log("recursive catalog: observed zero/unset/disabled, complete ownership, malformed/refused defaults and gateway usage passed")
@@ -0,0 +1,46 @@
import { isIP } from "node:net"
import { observedInteger, observedBoolean } from "./ospf-observed-values.js"
import { isManagedRecursiveRoute, userRecursiveComment, type MappedRecursiveRoute } from "./config-apply-plan.js"
import { isOwnedRecursiveComment } from "../managed-markers.js"
function rawRows(payload: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(payload) || payload.some(row => row === null || typeof row !== "object" || Array.isArray(row))) throw new Error("Некорректный ответ маршрутов RouterOS")
const rows = payload as Array<Record<string, unknown>>
if (rows.some(row => row.comment !== undefined && typeof row.comment !== "string")) throw new Error("Некорректные метки владения маршрутов")
const ids = rows.map(row => row[".id"])
if (ids.some(id => typeof id !== "string" || !id.trim()) || new Set(ids).size !== ids.length) throw new Error("Неоднозначные ID маршрутов RouterOS")
return rows
}
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 function parseRecursiveCatalog(payload: unknown): MappedRecursiveRoute[] {
return rawRows(payload).filter(row => typeof row.comment === "string" && isOwnedRecursiveComment(row.comment)).map(row => {
const id = String(row[".id"])
// The editor replaces the entire managed set. Never silently omit an owned row.
const destination = text(row["dst-address"]), gateway = text(row.gateway)
const [ip, prefix] = destination?.split("/") ?? []
const gatewayIp = gateway?.split("%")[0]
if (!destination || !gateway || isIP(ip ?? "") !== 4 || prefix !== undefined && (!/^\d+$/.test(prefix) || Number(prefix) > 32) || isIP(gatewayIp ?? "") !== 4 || !isManagedRecursiveRoute(row) || observedBoolean(rosText(row.static)) !== true) throw new Error("Управляемый маршрут " + id + " не подтверждён как поддерживаемый статический маршрут")
const distance = observedInteger(rosText(row.distance), 0, 255), disabled = observedBoolean(rosText(row.disabled))
const routingTable = text(row["routing-table"]), checkGateway = text(row["check-gateway"])
if (distance === null || disabled === null || routingTable === null || !checkGateway || !["none", "arp", "ping", "bfd"].includes(checkGateway)) throw new Error("Не подтверждены параметры управляемого маршрута " + id)
const scope = row.scope === undefined || row.scope === "" ? null : observedInteger(rosText(row.scope), 0, 255)
const targetScope = row["target-scope"] === undefined || row["target-scope"] === "" ? null : observedInteger(rosText(row["target-scope"]), 0, 255)
if (row.scope !== undefined && row.scope !== "" && scope === null || row["target-scope"] !== undefined && row["target-scope"] !== "" && targetScope === null) throw new Error("Некорректный scope управляемого маршрута " + id)
return { id, dstAddress: String(row["dst-address"]), gateway: String(row.gateway), distance, disabled, routingTable, checkGateway, scope, targetScope, country: "", comment: userRecursiveComment(String(row.comment)) }
})
}
export type RecursiveGatewayObservation = { id: string; ip: string; name: string; status: "active" | "inactive" | "unknown" }
export function parseRecursiveGatewayCatalog(payload: unknown): RecursiveGatewayObservation[] {
const result = new Map<string, RecursiveGatewayObservation>()
for (const row of rawRows(payload)) {
if (typeof row.gateway !== "string") continue
const [ip, name = ip] = row.gateway.trim().split("%")
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(ip) || ip.split(".").some(part => Number(part) > 255) || !name) continue
const id = ip + "%" + name, active = observedBoolean(rosText(row.active))
const status = active === true ? "active" : active === false ? "inactive" : "unknown"
const previous = result.get(id)
result.set(id, { id, ip, name, status: previous?.status === "active" || status === "active" ? "active" : previous?.status === "unknown" || status === "unknown" ? "unknown" : "inactive" })
}
return [...result.values()]
}
@@ -85,8 +85,8 @@ function RouteGroupExpandedDetail({ group }: { group: RecursiveRouteGroup }) {
<div key={ep.id} className="rounded-lg border border-border bg-background px-4 py-3"> <div key={ep.id} className="rounded-lg border border-border bg-background px-4 py-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{(ep.country || inferCountry(ep.gateway)) && ( {ep.country && (
<Flag code={ep.country || inferCountry(ep.gateway) || ""} size={16} /> <Flag code={ep.country} size={16} />
)} )}
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide"> <span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">
Endpoint {idx + 1} Endpoint {idx + 1}
@@ -99,6 +99,7 @@ function RouteGroupExpandedDetail({ group }: { group: RecursiveRouteGroup }) {
<span>scope: {ep.scope ?? "—"}</span> <span>scope: {ep.scope ?? "—"}</span>
<span>t.scope: {ep.targetScope ?? "—"}</span> <span>t.scope: {ep.targetScope ?? "—"}</span>
<span>check: {ep.checkGateway || "—"}</span> <span>check: {ep.checkGateway || "—"}</span>
{ep.disabled && <span>Отключён</span>}
</div> </div>
</div> </div>
))} ))}
@@ -176,14 +177,14 @@ function RecursiveRoutesDataGrid({
const sorted = [...row.original.endpoints].sort((a, b) => a.distance - b.distance) const sorted = [...row.original.endpoints].sort((a, b) => a.distance - b.distance)
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{sorted.map((ep, idx) => { {sorted.map(ep => {
const code = ep.country || inferCountry(ep.gateway) const code = ep.country
return ( return (
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono"> <div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
<span <span aria-hidden="true"
className={cn( className={cn(
"size-1.5 rounded-full shrink-0", "size-1.5 rounded-full shrink-0",
idx === 0 ? "bg-emerald-500" : "bg-sky-500", ep.disabled ? "bg-muted-foreground" : "bg-info",
)} )}
/> />
{code ? ( {code ? (
+9 -1
View File
@@ -26,11 +26,19 @@
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.26; URL/source маршрутов, далее observed metadata | | 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.27; строгий каталог маршрутов, далее layout/keyboard |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 10.27 — строгий каталог рекурсивных маршрутов (2026-09-30)
- Новый /recursive-routes/catalog читает RouterOS напрямую и принимает только полный валидный managed-снимок. Неизвестные обязательные параметры или неподдерживаемая принадлежащая приложению запись дают 503, а не пустой список, который мог бы удалить маршруты при полном применении. Legacy GET сохранён для существующих потребителей Filters.
- Нули distance/scope/targetScope сохраняются; отсутствующие optional scope остаются null. Редактирование сохраняет disabled. Страна — только явная аннотация, без угадывания по имени шлюза.
- Gateway catalog сообщает использование шлюза в active/inactive маршрутах или неизвестность; это не проверка доступности шлюза. Основание полей: [RouterOS IP route](https://manual.mikrotik.com/docs/cli-reference/ip/route/).
- Проверки: 42 основных E2E и повторные 18 data/values (три новых), восемь source E2E, backend parser/HTTP/config-sync/build, TS/lint прошли. Перехват нового вложенного catalog в source тесте исправлен; повторный прогон 8/8. Production frontend (34 страницы) прошёл.
- Далее 10.28: адаптивность, keyboard и снимки. Реальные RouterOS/DB/JWT и совместимость полного ответа конкретной версии устройства требуют внешней приёмки. Последняя квота: 55% окна, 40% недели использовано. Локальный коммит без push.
## Блок 10.26 — URL и source/history рекурсивных маршрутов (2026-09-30) ## Блок 10.26 — URL и source/history рекурсивных маршрутов (2026-09-30)
- server/q/route/table сохранены в URL. Default устройство записывается через replace; выбор/раскрытие используют history, поиск replace. Чужие query-поля сохранены. Missing/invalid/repeated/oversized контекст блокирует срез; route требует table. Отсутствующая группа закрывается отдельно. - server/q/route/table сохранены в URL. Default устройство записывается через replace; выбор/раскрытие используют history, поиск replace. Чужие query-поля сохранены. Missing/invalid/repeated/oversized контекст блокирует срез; route требует table. Отсутствующая группа закрывается отдельно.
+4
View File
@@ -156,3 +156,7 @@ Strict receipts/serverId, общая блокировка save/delete/restore,
## 10.26. URL/history/source ## 10.26. URL/history/source
server/q/route/table принадлежат экрану; default replace, выбор/раскрытие history. Missing-device fallback панели запрещён. Source lock сохраняется над device context; late write освобождает lock и запускает чтение текущего среза только соответствующего источника. 38 разных основных + 8 source E2E, 2 unit, TS/lint и production frontend прошли. Далее observed metadata и layout. server/q/route/table принадлежат экрану; default replace, выбор/раскрытие history. Missing-device fallback панели запрещён. Source lock сохраняется над device context; late write освобождает lock и запускает чтение текущего среза только соответствующего источника. 38 разных основных + 8 source E2E, 2 unit, TS/lint и production frontend прошли. Далее observed metadata и layout.
## 10.27. Строгий наблюдаемый каталог
Отдельные catalog endpoints сохраняют legacy потребителей. Неполный owned snapshot отклоняется целиком до сохранения; нули, disabled и unset scope сохранены. Gateway active означает использование в маршруте, без заявления о reachability; страна только явная. 42 основных + повторные 18 data/values, 8 source, backend parser/HTTP/config-sync/build и TS/lint прошли. Далее визуальная приёмка. Реальная версия RouterOS требует отдельной проверки.
+1 -1
View File
@@ -16,7 +16,7 @@ export const recursiveReceiptSchema = z.object({
}).refine(data => data.pushedRoutes === data.routes.length) }).refine(data => data.pushedRoutes === data.routes.length)
export const recursiveGatewaysSchema = z.object({ export const recursiveGatewaysSchema = z.object({
serverId: z.number().int().positive(), gateways: z.array(z.object({ serverId: z.number().int().positive(), gateways: z.array(z.object({
id: z.string().min(1), name: z.string(), ip: z.string().min(1), status: z.enum(["up", "down"]), id: z.string().min(1), name: z.string(), ip: z.string().min(1), status: z.enum(["active", "inactive", "unknown"]),
})).refine(rows => new Set(rows.map(row => row.id)).size === rows.length), })).refine(rows => new Set(rows.map(row => row.id)).size === rows.length),
}) })
export type RecursiveRouteRow = z.infer<typeof recursiveRowSchema> export type RecursiveRouteRow = z.infer<typeof recursiveRowSchema>
+4 -4
View File
@@ -31,8 +31,8 @@ for (const section of ["wireguard", "ipsec", "gre", "firewall", "filters", "recu
: path === "/api/gre/tunnels" || path === "/api/filters/gre-tunnels" ? { tunnels: [] } : path === "/api/gre/tunnels" || path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/firewall/all" ? { rules: [], addressLists: [] } : path === "/api/firewall/all" ? { rules: [], addressLists: [] }
: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [] }], greTunnels: [], live: true } : path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [] }], greTunnels: [], live: true }
: path === "/api/recursive-routes" ? { serverId: 1, routes: [], live: true, stale: false } : path === "/api/recursive-routes/catalog" ? { serverId: 1, routes: [], live: true, stale: false }
: path === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] } : path === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] }
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : {} : path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : {}
return route.fulfill({ json }) return route.fulfill({ json })
}) })
@@ -95,8 +95,8 @@ for (const section of ["ipsec", "gre", "firewall", "filters", "recursive-routes"
: path === "/api/gre/tunnels" || path === "/api/filters/gre-tunnels" ? { tunnels: [] } : path === "/api/gre/tunnels" || path === "/api/filters/gre-tunnels" ? { tunnels: [] }
: path === "/api/firewall/all" ? { rules: [], addressLists: [] } : path === "/api/firewall/all" ? { rules: [], addressLists: [] }
: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [] }], greTunnels: [], live: true } : path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [] }], greTunnels: [], live: true }
: path === "/api/recursive-routes" ? { serverId: 1, routes: [], live: true, stale: false } : path === "/api/recursive-routes/catalog" ? { serverId: 1, routes: [], live: true, stale: false }
: path === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] } : path === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] }
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false }
: path === "/api/servers" ? [{ id: 1, name: "История роутер", host: "192.0.2.1", enabled: true, type: "exit-node", country: "RU", status: "online" }] : {} }) : path === "/api/servers" ? [{ id: 1, name: "История роутер", host: "192.0.2.1", enabled: true, type: "exit-node", country: "RU", status: "online" }] : {} })
}) })
+2 -2
View File
@@ -8,8 +8,8 @@ for (const width of [320, 390, 1024, 1440]) {
await page.route("**/health", route => route.fulfill({ json: { ok: true } })) await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => { await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname const path = new URL(route.request().url()).pathname
return route.fulfill({ json: path === "/api/recursive-routes" ? { serverId: 1, routes: [], live: true, stale: false } return route.fulfill({ json: path === "/api/recursive-routes/catalog" ? { serverId: 1, routes: [], live: true, stale: false }
: path === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] } : path === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] }
: path === "/api/auth/config" ? { required: false } : path === "/api/auth/config" ? { required: false }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.100", type: "exit-node", site: "TEST", country: "RU", asn: "64512", enabled: true, status: "online", latency: 1 }] : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.100", type: "exit-node", site: "TEST", country: "RU", asn: "64512", enabled: true, status: "online", latency: 1 }]
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : {} }) : path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : {} })
+5 -5
View File
@@ -6,14 +6,14 @@ for(const change of ["backend","mode"])for(const operation of ["read","write"])f
let newReads=0 let newReads=0
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}})) await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}))
await page.route("**/api/**",r=>{ await page.route("**/api/**",r=>{
const u=new URL(r.request().url()),p=u.pathname;if(p==="/api/recursive-routes"&&u.port==="8101")newReads++ const u=new URL(r.request().url()),p=u.pathname;if(p==="/api/recursive-routes/catalog"&&u.port==="8101")newReads++
return r.fulfill({headers,json:p==="/api/servers"?[device]:p==="/api/auth/config"?{required:false}:p==="/api/recursive-routes"?{serverId:1,routes:[row(u.port==="8101"?"Новый снимок":"Старый снимок")],live:true,stale:false}:p==="/api/recursive-routes/gateways"?{serverId:1,gateways:[]}:{} }) return r.fulfill({headers,json:p==="/api/servers"?[device]:p==="/api/auth/config"?{required:false}:p==="/api/recursive-routes/catalog"?{serverId:1,routes:[row(u.port==="8101"?"Новый снимок":"Старый снимок")],live:true,stale:false}:p==="/api/recursive-routes/gateways/catalog"?{serverId:1,gateways:[]}:{} })
}) })
await page.goto("/settings");await page.getByRole("link",{name:/^Рекурсивные маршруты/}).click() await page.goto("/settings");await page.getByRole("link",{name:/^Рекурсивные маршруты/}).click()
await expect(page.locator("tr[data-row-id]")).toContainText("Старый снимок") await expect(page.locator("tr[data-row-id]")).toContainText("Старый снимок")
let started=false,release!:()=>void;const gate=new Promise<void>(resolve=>{release=resolve}) let started=false,release!:()=>void;const gate=new Promise<void>(resolve=>{release=resolve})
await page.route("**/api/recursive-routes*",async r=>{ await page.route("**/api/recursive-routes**",async r=>{
const u=new URL(r.request().url());if(u.port!=="8000"||u.pathname!=="/api/recursive-routes"||r.request().method()!==(operation==="read"?"GET":"PUT"))return r.fallback() const u=new URL(r.request().url());if(u.port!=="8000"||u.pathname!==(operation==="read"?"/api/recursive-routes/catalog":"/api/recursive-routes")||r.request().method()!==(operation==="read"?"GET":"PUT"))return r.fallback()
started=true;await gate started=true;await gate
return r.fulfill(fails?{headers,status:503,json:{error:"Старая ошибка маршрутов"}}:{headers,json:operation==="read"?{serverId:1,routes:[],live:true,stale:false}:{ok:true,serverId:1,routes:r.request().postDataJSON().routes,pushedRoutes:r.request().postDataJSON().routes.length}}) return r.fulfill(fails?{headers,status:503,json:{error:"Старая ошибка маршрутов"}}:{headers,json:operation==="read"?{serverId:1,routes:[],live:true,stale:false}:{ok:true,serverId:1,routes:r.request().postDataJSON().routes,pushedRoutes:r.request().postDataJSON().routes.length}})
}) })
@@ -29,7 +29,7 @@ for(const change of ["backend","mode"])for(const operation of ["read","write"])f
await page.getByRole("link",{name:/^Рекурсивные маршруты/}).click() await page.getByRole("link",{name:/^Рекурсивные маршруты/}).click()
if(change==="backend")await expect(page.locator("tr[data-row-id]")).toContainText("Новый снимок") if(change==="backend")await expect(page.locator("tr[data-row-id]")).toContainText("Новый снимок")
else await expect(page.getByText("Раздел работает в режиме",{exact:false})).toBeVisible() else await expect(page.getByText("Раздел работает в режиме",{exact:false})).toBeVisible()
const before=newReads,response=page.waitForResponse(r=>new URL(r.url()).port==="8000"&&new URL(r.url()).pathname==="/api/recursive-routes"&&r.request().method()===(operation==="read"?"GET":"PUT")) const before=newReads,response=page.waitForResponse(r=>new URL(r.url()).port==="8000"&&new URL(r.url()).pathname===(operation==="read"?"/api/recursive-routes/catalog":"/api/recursive-routes")&&r.request().method()===(operation==="read"?"GET":"PUT"))
release();await(await response).finished() release();await(await response).finished()
await page.evaluate(()=>new Promise<void>(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve())))) await page.evaluate(()=>new Promise<void>(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve()))))
if(change==="backend")await expect(page.locator("tr[data-row-id]")).toContainText("Новый снимок") if(change==="backend")await expect(page.locator("tr[data-row-id]")).toContainText("Новый снимок")
+11 -11
View File
@@ -20,8 +20,8 @@ for (const result of ["success", "http", "false", "missing", "foreign", "count"]
saved = body.routes; return r.fulfill({ json: { ok: true, serverId: 1, routes: saved, pushedRoutes: saved.length } }) saved = body.routes; return r.fulfill({ json: { ok: true, serverId: 1, routes: saved, pushedRoutes: saved.length } })
} }
return r.fulfill({ json: p === "/api/servers" ? servers : p === "/api/auth/config" ? { required: false } return r.fulfill({ json: p === "/api/servers" ? servers : p === "/api/auth/config" ? { required: false }
: p === "/api/recursive-routes" ? { serverId: Number(u.searchParams.get("serverId")), routes: saved, live: true, stale: false } : p === "/api/recursive-routes/catalog" ? { serverId: Number(u.searchParams.get("serverId")), routes: saved, live: true, stale: false }
: p === "/api/recursive-routes/gateways" ? { serverId: Number(u.searchParams.get("serverId")), gateways: [] } : {} }) : p === "/api/recursive-routes/gateways/catalog" ? { serverId: Number(u.searchParams.get("serverId")), gateways: [] } : {} })
}) })
await page.goto("/recursive-routes") await page.goto("/recursive-routes")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1) await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
@@ -53,13 +53,13 @@ for (const bad of ["http", "foreign", "payload", "duplicate", "stale"] as const)
await page.route("**/health", r => r.fulfill({ json: { ok: true } })) await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => { await page.route("**/api/**", r => {
const p = new URL(r.request().url()).pathname const p = new URL(r.request().url()).pathname
if (p === "/api/recursive-routes") { if (p === "/api/recursive-routes/catalog") {
if (phase !== 1 && bad === "http") return r.fulfill({ status: 503, json: { error: "Чтение недоступно тест" } }) if (phase !== 1 && bad === "http") return r.fulfill({ status: 503, json: { error: "Чтение недоступно тест" } })
const valid = { serverId: 1, routes: phase === 0 ? [] : [routeRow()], live: true, stale: false } const valid = { serverId: 1, routes: phase === 0 ? [] : [routeRow()], live: true, stale: false }
return r.fulfill({ json: phase === 1 ? valid : bad === "foreign" ? { ...valid, serverId: 2 } : bad === "payload" ? { ...valid, routes: [{}] } return r.fulfill({ json: phase === 1 ? valid : bad === "foreign" ? { ...valid, serverId: 2 } : bad === "payload" ? { ...valid, routes: [{}] }
: bad === "duplicate" ? { ...valid, routes: [routeRow(), routeRow()] } : { ...valid, live: false, stale: true, error: "Кэш тест" } }) : bad === "duplicate" ? { ...valid, routes: [routeRow(), routeRow()] } : { ...valid, live: false, stale: true, error: "Кэш тест" } })
} }
return r.fulfill({ json: p === "/api/servers" ? [servers[0]] : p === "/api/auth/config" ? { required: false } : p === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] } : {} }) return r.fulfill({ json: p === "/api/servers" ? [servers[0]] : p === "/api/auth/config" ? { required: false } : p === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] } : {} })
}) })
await page.goto("/recursive-routes") await page.goto("/recursive-routes")
await expect(page.getByRole("button", { name: "Добавить", exact: true })).toBeDisabled() await expect(page.getByRole("button", { name: "Добавить", exact: true })).toBeDisabled()
@@ -77,9 +77,9 @@ test("Рекурсивные маршруты: чтение прежнего у
await page.route("**/health", r => r.fulfill({ json: { ok: true } })) await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", async r => { await page.route("**/api/**", async r => {
const u = new URL(r.request().url()), p = u.pathname, id = Number(u.searchParams.get("serverId")) const u = new URL(r.request().url()), p = u.pathname, id = Number(u.searchParams.get("serverId"))
if (p === "/api/recursive-routes") { if (id === 1) { requested = true; await gate } if (p === "/api/recursive-routes/catalog") { if (id === 1) { requested = true; await gate }
return r.fulfill({ json: { serverId: id, routes: [routeRow("r" + id, "10.0." + id + ".0/24")], live: true, stale: false } }) } return r.fulfill({ json: { serverId: id, routes: [routeRow("r" + id, "10.0." + id + ".0/24")], live: true, stale: false } }) }
return r.fulfill({ json: p === "/api/servers" ? servers : p === "/api/auth/config" ? { required: false } : p === "/api/recursive-routes/gateways" ? { serverId: id, gateways: [] } : {} }) return r.fulfill({ json: p === "/api/servers" ? servers : p === "/api/auth/config" ? { required: false } : p === "/api/recursive-routes/gateways/catalog" ? { serverId: id, gateways: [] } : {} })
}) })
await page.goto("/recursive-routes"); await expect.poll(() => requested).toBe(true) await page.goto("/recursive-routes"); await expect.poll(() => requested).toBe(true)
await page.getByRole("option").filter({ hasText: "Роутер 2" }).click() await page.getByRole("option").filter({ hasText: "Роутер 2" }).click()
@@ -88,12 +88,12 @@ test("Рекурсивные маршруты: чтение прежнего у
await expect(page.locator("tr[data-row-id]")).not.toContainText("10.0.1.0/24") await expect(page.locator("tr[data-row-id]")).not.toContainText("10.0.1.0/24")
}) })
test("Рекурсивные маршруты: редактирование поиска сохраняет скрытые endpoints и таблицы", async ({ page }) => { test("Рекурсивные маршруты: редактирование поиска сохраняет скрытые endpoints и таблицы", async ({ page }) => {
let saved: unknown; const rows = [{ ...routeRow(), gateway: "192.0.2.11" }, { ...routeRow("b"), gateway: "192.0.2.22" }, { ...routeRow("c", "10.0.1.0/24", "vpn"), gateway: "192.0.2.33" }] let saved: unknown; const rows = [{ ...routeRow(), gateway: "192.0.2.11" }, { ...routeRow("b"), gateway: "192.0.2.22", disabled: true }, { ...routeRow("c", "10.0.1.0/24", "vpn"), gateway: "192.0.2.33" }]
await page.route("**/health", r => r.fulfill({ json: { ok: true } })) await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => { await page.route("**/api/**", r => {
const p = new URL(r.request().url()).pathname const p = new URL(r.request().url()).pathname
if (p === "/api/recursive-routes" && r.request().method() === "PUT") { const next = r.request().postDataJSON().routes; saved = next; return r.fulfill({ json: { ok: true, serverId: 1, routes: next, pushedRoutes: next.length } }) } if (p === "/api/recursive-routes" && r.request().method() === "PUT") { const next = r.request().postDataJSON().routes; saved = next; return r.fulfill({ json: { ok: true, serverId: 1, routes: next, pushedRoutes: next.length } }) }
return r.fulfill({ json: p === "/api/servers" ? [servers[0]] : p === "/api/auth/config" ? { required: false } : p === "/api/recursive-routes" ? { serverId: 1, routes: rows, live: true, stale: false } : p === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] } : {} }) return r.fulfill({ json: p === "/api/servers" ? [servers[0]] : p === "/api/auth/config" ? { required: false } : p === "/api/recursive-routes/catalog" ? { serverId: 1, routes: rows, live: true, stale: false } : p === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] } : {} })
}) })
await page.goto("/recursive-routes"); await expect(page.locator("tr[data-row-id]")).toHaveCount(2) await page.goto("/recursive-routes"); await expect(page.locator("tr[data-row-id]")).toHaveCount(2)
await page.getByRole("textbox", { name: "Поиск маршрутов" }).fill("192.0.2.11") await page.getByRole("textbox", { name: "Поиск маршрутов" }).fill("192.0.2.11")
@@ -102,7 +102,7 @@ test("Рекурсивные маршруты: редактирование по
const sheet = page.getByRole("dialog") const sheet = page.getByRole("dialog")
await expect(sheet.getByPlaceholder("1.2.3.4%GW-NAME")).toHaveCount(2) await expect(sheet.getByPlaceholder("1.2.3.4%GW-NAME")).toHaveCount(2)
await sheet.getByRole("button", { name: "Сохранить", exact: true }).click(); await expect(sheet).toHaveCount(0) await sheet.getByRole("button", { name: "Сохранить", exact: true }).click(); await expect(sheet).toHaveCount(0)
expect(saved).toEqual(expect.arrayContaining([expect.objectContaining({ routingTable: "main", gateway: "192.0.2.11" }), expect.objectContaining({ routingTable: "main", gateway: "192.0.2.22" }), expect.objectContaining({ routingTable: "vpn", gateway: "192.0.2.33" })])) expect(saved).toEqual(expect.arrayContaining([expect.objectContaining({ routingTable: "main", gateway: "192.0.2.11" }), expect.objectContaining({ routingTable: "main", gateway: "192.0.2.22", disabled: true }), expect.objectContaining({ routingTable: "vpn", gateway: "192.0.2.33" })]))
expect(saved).toHaveLength(3) expect(saved).toHaveLength(3)
}) })
@@ -116,8 +116,8 @@ for (const allowed of [false, true]) test("Рекурсивные маршрут
const p = new URL(r.request().url()).pathname const p = new URL(r.request().url()).pathname
if (r.request().method() !== "GET") writes++ if (r.request().method() !== "GET") writes++
return r.fulfill({ json: p === "/api/auth/config" ? { required: true } : p === "/api/servers" ? [{ ...servers[0], enabled: !allowed }] return r.fulfill({ json: p === "/api/auth/config" ? { required: true } : p === "/api/servers" ? [{ ...servers[0], enabled: !allowed }]
: p === "/api/recursive-routes" ? { serverId: 1, routes: [routeRow()], live: true, stale: false } : p === "/api/recursive-routes/catalog" ? { serverId: 1, routes: [routeRow()], live: true, stale: false }
: p === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] } : p === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] }
: p.endsWith("/revisions") ? { revisions: [{ id: "rev", serverId: "1", section: "recursive-routes", source: "apply", fingerprint: "fixture", createdAt: "2026-09-30T00:00:00Z", note: null, itemCount: 1 }] } : {} }) : p.endsWith("/revisions") ? { revisions: [{ id: "rev", serverId: "1", section: "recursive-routes", source: "apply", fingerprint: "fixture", createdAt: "2026-09-30T00:00:00Z", note: null, itemCount: 1 }] } : {} })
}) })
await page.goto("/recursive-routes"); await expect(page.locator("tr[data-row-id]")).toHaveCount(1) await page.goto("/recursive-routes"); await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
+2 -2
View File
@@ -3,7 +3,7 @@ const devices=[1,2].map(id=>({id,name:"Роутер "+id,host:"192.0.2."+id,coun
const row=(id:number)=>({id:"r"+id,dstAddress:"10.0."+id+".0/24",gateway:"192.0.2."+id,distance:1,scope:30,targetScope:10,routingTable:"main",checkGateway:"ping",comment:"Маршрут "+id,disabled:false,country:"RU"}) const row=(id:number)=>({id:"r"+id,dstAddress:"10.0."+id+".0/24",gateway:"192.0.2."+id,distance:1,scope:30,targetScope:10,routingTable:"main",checkGateway:"ping",comment:"Маршрут "+id,disabled:false,country:"RU"})
async function setup(page:Page) { async function setup(page:Page) {
await page.route("**/health",r=>r.fulfill({json:{ok:true}})) await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>{const u=new URL(r.request().url()),p=u.pathname,id=Number(u.searchParams.get("serverId"));return r.fulfill({json:p==="/api/servers"?devices:p==="/api/auth/config"?{required:false}:p==="/api/recursive-routes"?{serverId:id,routes:[row(id)],live:true,stale:false}:p==="/api/recursive-routes/gateways"?{serverId:id,gateways:[]}:{} })}) await page.route("**/api/**",r=>{const u=new URL(r.request().url()),p=u.pathname,id=Number(u.searchParams.get("serverId"));return r.fulfill({json:p==="/api/servers"?devices:p==="/api/auth/config"?{required:false}:p==="/api/recursive-routes/catalog"?{serverId:id,routes:[row(id)],live:true,stale:false}:p==="/api/recursive-routes/gateways/catalog"?{serverId:id,gateways:[]}:{} })})
} }
test("Маршруты: устройство, поиск и раскрытие переживают reload/history",async({page})=>{ test("Маршруты: устройство, поиск и раскрытие переживают reload/history",async({page})=>{
await setup(page);await page.goto("/recursive-routes?server=2&q=10.0.2&route=10.0.2.0%2F24&table=main&keep=x") await setup(page);await page.goto("/recursive-routes?server=2&q=10.0.2&route=10.0.2.0%2F24&table=main&keep=x")
@@ -35,7 +35,7 @@ test("Маршруты: отсутствующая группа закрывае
}) })
for(const fails of [false,true])test("Маршруты: history сохраняет lock до завершения записи "+fails,async({page})=>{ for(const fails of [false,true])test("Маршруты: history сохраняет lock до завершения записи "+fails,async({page})=>{
await setup(page);let writes=0,reads=0;let release!:()=>void;const gate=new Promise<void>(resolve=>{release=resolve}) await setup(page);let writes=0,reads=0;let release!:()=>void;const gate=new Promise<void>(resolve=>{release=resolve})
await page.route("**/api/recursive-routes?**",r=>{reads++;const id=Number(new URL(r.request().url()).searchParams.get("serverId"));return r.fulfill({json:{serverId:id,routes:[row(id)],live:true,stale:false}})}) await page.route("**/api/recursive-routes/catalog?**",r=>{reads++;const id=Number(new URL(r.request().url()).searchParams.get("serverId"));return r.fulfill({json:{serverId:id,routes:[row(id)],live:true,stale:false}})})
await page.route("**/api/recursive-routes",async r=>{writes++;await gate;const next=r.request().postDataJSON().routes;return r.fulfill(fails?{status:503,json:{error:"Старая ошибка записи"}}:{json:{ok:true,serverId:1,routes:next,pushedRoutes:next.length}})}) await page.route("**/api/recursive-routes",async r=>{writes++;await gate;const next=r.request().postDataJSON().routes;return r.fulfill(fails?{status:503,json:{error:"Старая ошибка записи"}}:{json:{ok:true,serverId:1,routes:next,pushedRoutes:next.length}})})
await page.goto("/recursive-routes?server=2") await page.goto("/recursive-routes?server=2")
await page.getByRole("option").filter({hasText:"Роутер 1"}).click() await page.getByRole("option").filter({hasText:"Роутер 1"}).click()
+2 -2
View File
@@ -11,8 +11,8 @@ test("Рекурсивные маршруты: все группы и раскр
await page.route("**/health", route => route.fulfill({ json: { ok: true } })) await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => { await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname const path = new URL(route.request().url()).pathname
return route.fulfill({ json: path === "/api/recursive-routes" ? { serverId: 1, routes, live: true, stale: false } return route.fulfill({ json: path === "/api/recursive-routes/catalog" ? { serverId: 1, routes, live: true, stale: false }
: path === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] } : path === "/api/recursive-routes/gateways/catalog" ? { serverId: 1, gateways: [] }
: path === "/api/auth/config" ? { required: false } : path === "/api/auth/config" ? { required: false }
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.100", type: "exit-node", site: "TEST", country: "RU", asn: "64512", enabled: true, status: "online", latency: 1 }] : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.100", type: "exit-node", site: "TEST", country: "RU", asn: "64512", enabled: true, status: "online", latency: 1 }]
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : {} }) : path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : {} })
+22
View File
@@ -0,0 +1,22 @@
import {test,expect} from "@playwright/test"
for(const status of ["active","inactive","unknown"] as const)test("Маршруты: наблюдаемые нули, disabled и использование шлюза "+status,async({page})=>{
const item={id:"r",dstAddress:"10.0.0.0/24",gateway:"192.0.2.1%bridge1",distance:0,scope:0,targetScope:0,routingTable:"main",checkGateway:"none",comment:"Тест",disabled:true,country:""}
let submitted:unknown
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>{
const p=new URL(r.request().url()).pathname
if(p==="/api/recursive-routes"&&r.request().method()==="PUT"){const routes=r.request().postDataJSON().routes;submitted=routes;return r.fulfill({json:{ok:true,serverId:1,routes,pushedRoutes:routes.length}})}
return r.fulfill({json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[{id:1,name:"Роутер 1",host:"192.0.2.1",country:"RU",enabled:true,status:null}]:p==="/api/recursive-routes/catalog"?{serverId:1,routes:[item],live:true,stale:false}:p==="/api/recursive-routes/gateways/catalog"?{serverId:1,gateways:[{id:"gateway",ip:"192.0.2.1",name:"bridge1",status}]}:{}})
})
await page.goto("/recursive-routes")
await page.locator("tr[data-row-id]").click()
await expect(page.getByText("distance: 0",{exact:true})).toBeVisible();await expect(page.getByText("scope: 0",{exact:true})).toBeVisible();await expect(page.getByText("Отключён",{exact:true})).toBeVisible()
await page.getByRole("button",{name:"Редактировать маршрут 10.0.0.0/24 · main",exact:true}).click()
const sheet=page.getByRole("dialog")
await expect(sheet.getByRole("spinbutton").nth(0)).toHaveValue("0")
await expect(sheet.getByRole("spinbutton").nth(1)).toHaveValue("0")
await expect(sheet.getByRole("spinbutton").nth(2)).toHaveValue("0")
await expect(sheet.getByRole("button").filter({hasText:status==="active"?"Активный маршрут":status==="inactive"?"Неактивный маршрут":"Статус неизвестен"})).toHaveCount(1)
await sheet.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(sheet).toHaveCount(0)
expect(submitted).toEqual([expect.objectContaining({distance:0,scope:0,targetScope:0,disabled:true,country:"",checkGateway:"none"})])
})