fix(routes): подтвердить применение и восстановление рекурсивных маршрутов
This commit is contained in:
1 parent
36c6a21ca5
commit
5b3133ce73
19 files changed
+659
-28
No files matched your search
@@ -23,7 +23,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: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/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 && tsx src/services/recursive-route-catalog.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: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:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
|
||||
|
||||
@@ -2,8 +2,10 @@ import assert from "node:assert/strict"
|
||||
import Fastify from "fastify"
|
||||
import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod"
|
||||
import { createRecursiveRoutes, type RecursiveRouteDependencies } from "./recursive-routes.js"
|
||||
import { RecursiveApplyError } from "../services/recursive-apply.js"
|
||||
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: "Тест", enabled: true, type: "home-router" } as NonNullable<Awaited<ReturnType<RecursiveRouteDependencies["getServer"]>>>
|
||||
let rawReads = 0, lateReadFailure = false, applyError: Error | undefined
|
||||
let lookup = 0, writes = 0, revisions = 0, fail = false, stored = [row], payload: unknown = [row], revisionServer = 1, section = "recursive-routes"
|
||||
const observed = { ".id": "*1", static: "true", "dst-address": row.dstAddress, gateway: row.gateway, distance: "0", scope: "0", "target-scope": "0", "routing-table": "main", "check-gateway": "ping", disabled: "false", comment: "MikrotikManager:recursive Тест" }
|
||||
let rawPayload: unknown = [observed], rawFailure = false, savedSnapshots = 0
|
||||
@@ -12,12 +14,13 @@ const app = Fastify()
|
||||
app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
|
||||
await app.register(createRecursiveRoutes({
|
||||
getServer: async id => { lookup++; return id === 1 ? server : undefined },
|
||||
getRoutes: async () => stored,
|
||||
readRaw: async () => { if (rawFailure) throw new Error("Тест опроса"); return rawPayload },
|
||||
getRoutes: async () => { if (lateReadFailure) throw Error("Сбой чтения после применения"); return stored },
|
||||
readRaw: async () => { rawReads++; if (rawFailure) throw new Error("Тест опроса"); return rawPayload },
|
||||
saveObserved: async (_id, rows) => { savedSnapshots++; stored = rows as typeof row[] },
|
||||
apply: async (device, rows, source) => {
|
||||
assert.equal(device.id, 1); writes++; applied.push({ rows: rows as typeof row[], source })
|
||||
if (fail) throw new Error("Тест частичного применения")
|
||||
if (applyError) throw applyError
|
||||
stored = rows as typeof row[]; return { pushed: rows.length, deleted: 0 }
|
||||
},
|
||||
getRevision: async () => {
|
||||
@@ -32,6 +35,7 @@ const invalid = [
|
||||
{ serverId: 1, routes: [{ ...row, distance: -1 }] }, { serverId: 1, routes: [{ ...row, scope: 256 }] },
|
||||
{ serverId: 1, routes: [{ ...row, targetScope: 1.5 }] }, { serverId: 1, routes: [{ ...row, gateway: "" }] },
|
||||
{ serverId: 1, routes: [row, row] }, { serverId: 1, routes: [row], source: "rollback" },
|
||||
...[{...row,dstAddress:"999.1.1.1/24"},{...row,dstAddress:"10.0.0.0/33"},{...row,gateway:"ether1"},{...row,gateway:"192.0.2.1%"},{...row,checkGateway:"anything"}].map(route=>({serverId:1,routes:[route]})),
|
||||
]
|
||||
for (const body of invalid) {
|
||||
const result = await app.inject({ method: "PUT", url: "/api/recursive-routes", payload: body })
|
||||
@@ -83,6 +87,22 @@ 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")
|
||||
for (const type of ["home-router", "exit-node"] as const) {
|
||||
server.enabled=false;server.type=type
|
||||
const counts={writes,rawReads,savedSnapshots}
|
||||
for(const url of ["/recursive-routes/catalog?serverId=1","/recursive-routes/gateways/catalog?serverId=1","/recursive-routes?serverId=1","/recursive-routes/gateways?serverId=1"])assert.equal((await app.inject({method:"GET",url:"/api"+url})).statusCode,409)
|
||||
assert.equal((await app.inject({method:"PUT",url:"/api/recursive-routes",payload:{serverId:1,routes:[]}})).statusCode,409)
|
||||
assert.equal((await app.inject({method:"POST",url:"/api/recursive-routes/revisions/rev/restore",payload:{serverId:1}})).statusCode,409)
|
||||
assert.deepEqual({writes,rawReads,savedSnapshots},counts)
|
||||
}
|
||||
server.enabled=true
|
||||
for(const state of ["partial","unknown","applied"] as const){
|
||||
applyError=new RecursiveApplyError("Сбой операции",{state,confirmedWrites:1,createdIds:["*1"],deletedIds:[],needsReview:true,metadataRecorded:false})
|
||||
for(const restore of [false,true]){const r=await app.inject({method:restore?"POST":"PUT",url:restore?"/api/recursive-routes/revisions/rev/restore":"/api/recursive-routes",payload:restore?{serverId:1}:{serverId:1,routes:[]}});assert.equal(r.statusCode,502);assert.equal(r.json().state,state);assert.equal(r.json().confirmedWrites,1);assert.equal(r.json().ok,undefined)}
|
||||
}
|
||||
applyError=undefined;lateReadFailure=true
|
||||
const late=await app.inject({method:"PUT",url:"/api/recursive-routes",payload:{serverId:1,routes:[row]}});assert.equal(late.statusCode,502);assert.equal(late.json().state,"applied");assert.equal(late.json().metadataRecorded,true)
|
||||
lateReadFailure=false
|
||||
await app.close()
|
||||
console.log("recursive routes HTTP: invalid/explicit-empty/zero/copy/error/restore ownership and payload passed without DB/RouterOS")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { recursiveApplyBodySchema, recursiveTargetSchema, recursiveInputRowsSchema } from '../services/recursive-route-validation.js'
|
||||
import { parseRecursiveCatalog, parseRecursiveGatewayCatalog } from "../services/recursive-route-catalog.js"
|
||||
import { executeRecursiveApply, RecursiveApplyError } from "../services/recursive-apply.js"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { z } from "zod"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
@@ -10,7 +11,6 @@ import { parseDbServerId } from "../utils/server-id.js"
|
||||
import { managedRecursiveComment } from "../managed-markers.js"
|
||||
import {
|
||||
mapRosManagedRoutes,
|
||||
planRecursiveApply,
|
||||
userRecursiveComment,
|
||||
} from "../services/config-apply-plan.js"
|
||||
import {
|
||||
@@ -123,10 +123,11 @@ function toRouterPayload(route: RecursiveRouteDto): Record<string, string> {
|
||||
}
|
||||
|
||||
async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
|
||||
await db.delete(recursiveRoutes).where(eq(recursiveRoutes.serverId, serverId))
|
||||
await db.transaction(async tx => {
|
||||
await tx.delete(recursiveRoutes).where(eq(recursiveRoutes.serverId, serverId))
|
||||
if (routes.length === 0) return
|
||||
const now = new Date().toISOString()
|
||||
await db.insert(recursiveRoutes).values(
|
||||
await tx.insert(recursiveRoutes).values(
|
||||
routes.map((r, i) => ({
|
||||
serverId,
|
||||
sortOrder: i,
|
||||
@@ -144,6 +145,7 @@ async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
|
||||
updatedAt: now,
|
||||
})),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function applyRecursiveToServer(
|
||||
@@ -152,16 +154,7 @@ async function applyRecursiveToServer(
|
||||
source: ConfigRevisionSource,
|
||||
): Promise<{ pushed: number; deleted: number }> {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const existing = await client.get<RosRoute[]>("/ip/route")
|
||||
const { deleteIds } = planRecursiveApply(existing)
|
||||
|
||||
for (const route of routes) {
|
||||
await client.post("/ip/route", toRouterPayload(route))
|
||||
}
|
||||
for (const id of deleteIds) {
|
||||
await client.delete(`/ip/route/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
return executeRecursiveApply(client, routes.map(toRouterPayload), async () => {
|
||||
await replaceDbRoutes(server.id, routes)
|
||||
await appendRevisionIfChanged({
|
||||
serverId: server.id,
|
||||
@@ -169,11 +162,18 @@ async function applyRecursiveToServer(
|
||||
source,
|
||||
payload: canonicalRecursiveRoutes(routes),
|
||||
})
|
||||
return { pushed: routes.length, deleted: deleteIds.length }
|
||||
})
|
||||
}
|
||||
|
||||
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
|
||||
|
||||
function recursiveFailureReceipt(error: unknown, result?: { pushed: number; deleted: number }) {
|
||||
if (error instanceof RecursiveApplyError) return error.receipt
|
||||
// Native operations and metadata completed; a later cache read can still fail.
|
||||
if (result) return { state: "applied", confirmedWrites: result.pushed + result.deleted, needsReview: true, metadataRecorded: true }
|
||||
return undefined
|
||||
}
|
||||
|
||||
export interface RecursiveRouteDependencies {
|
||||
getServer: (id: number) => Promise<ServerRow | undefined>
|
||||
readRaw: (server: ServerRow) => Promise<unknown>
|
||||
@@ -201,6 +201,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
||||
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" })
|
||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||
try {
|
||||
const routes = mergeCachedCountry(parseRecursiveCatalog(await deps.readRaw(server)), await deps.getRoutes(serverId))
|
||||
await deps.saveObserved(serverId, routes)
|
||||
@@ -214,6 +215,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
||||
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" })
|
||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||
try {
|
||||
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server)) })
|
||||
} catch (error) {
|
||||
@@ -227,6 +229,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
||||
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||
const server = await deps.getServer(serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
@@ -257,6 +260,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
||||
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||
const server = await deps.getServer(serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||
|
||||
const cached = await deps.getRoutes(serverId)
|
||||
try {
|
||||
@@ -290,14 +294,17 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
||||
const body = parsed.data, serverId = body.serverId
|
||||
const server = await deps.getServer(serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||
const routes = body.routes.map(r => ({ ...r, comment: userRecursiveComment(r.comment) }))
|
||||
const source: ConfigRevisionSource = body.source === "copy" ? "copy" : "apply"
|
||||
let result: { pushed: number; deleted: number } | undefined
|
||||
try {
|
||||
const result = await deps.apply(server, routes, source)
|
||||
result = await deps.apply(server, routes, source)
|
||||
const stored = await deps.getRoutes(serverId)
|
||||
return reply.send({ ok: true, serverId, routes: stored, pushedRoutes: result.pushed })
|
||||
} catch (err) {
|
||||
return reply.status(500).send({ error: String(err) })
|
||||
const receipt = recursiveFailureReceipt(err, result)
|
||||
return reply.status(receipt ? 502 : 500).send({ error: String(err), serverId, ...receipt })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -323,15 +330,18 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
||||
if (!parsedRoutes.success) return reply.status(409).send({ error: "Версия содержит некорректные маршруты; восстановление не выполнено" })
|
||||
const server = await deps.getServer(rev.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||
const routes: RecursiveRouteDto[] = parsedRoutes.data.map((row, index) => ({
|
||||
...row, id: `rev-${index}`, comment: userRecursiveComment(row.comment),
|
||||
}))
|
||||
let result: { pushed: number; deleted: number } | undefined
|
||||
try {
|
||||
const result = await deps.apply(server, routes, "rollback")
|
||||
result = await deps.apply(server, routes, "rollback")
|
||||
const stored = await deps.getRoutes(server.id)
|
||||
return reply.send({ ok: true, serverId: server.id, routes: stored, pushedRoutes: result.pushed })
|
||||
} catch (err) {
|
||||
return reply.status(500).send({ error: String(err) })
|
||||
const receipt = recursiveFailureReceipt(err, result)
|
||||
return reply.status(receipt ? 502 : 500).send({ error: String(err), serverId: server.id, ...receipt })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict"
|
||||
import type { MikrotikClient } from "./mikrotik.js"
|
||||
import { MikrotikError } from "./mikrotik.js"
|
||||
import { executeRecursiveApply, RecursiveApplyError } from "./recursive-apply.js"
|
||||
const owned = { ".id": "*old", static: "true", "dst-address": "198.18.88.0/24", gateway: "192.0.2.1", distance: "1", scope: "30", "target-scope": "10", "routing-table": "main", "check-gateway": "none", disabled: "false", comment: "MikrotikManager:recursive test" }
|
||||
let rows: unknown = [owned, { ".id": "*foreign", comment: "Keep default route" }], events: string[] = [], puts = 0
|
||||
let failAt = 0, rejection: Error = new Error("ACK потерян"), badAck = false, failDelete = false, failPersist = false
|
||||
const client: Pick<MikrotikClient,"get"|"put"|"delete"> = {
|
||||
get: async <T>() => rows as T,
|
||||
put: async <T>() => { puts++; events.push("put:"+puts); if(puts===failAt)throw rejection; return (badAck?{}:{".id":"*new"+puts}) as T },
|
||||
delete: async path => { events.push("delete:"+path); if(failDelete)throw new MikrotikError(403,path,"rejected") },
|
||||
}
|
||||
const persist=async()=>{events.push("persist");if(failPersist)throw Error("История недоступна")}
|
||||
const reset=()=>{events=[];puts=0;failAt=0;badAck=false;failDelete=false;failPersist=false;rows=[owned,{".id":"*foreign",comment:"Keep default route"}]}
|
||||
const payloads=[{"dst-address":"198.18.88.1/32"},{"dst-address":"198.18.88.2/32"}]
|
||||
reset();assert.deepEqual(await executeRecursiveApply(client,payloads,persist),{pushed:2,deleted:1});assert.deepEqual(events,["put:1","put:2","delete:/ip/route/*old","persist"])
|
||||
for(const invalid of [{},[owned,owned],[{...owned,static:undefined}],[{...owned,blackhole:"true"}]]){reset();rows=invalid;await assert.rejects(executeRecursiveApply(client,payloads,persist));assert.deepEqual(events,[])}
|
||||
async function expectState(state:string,confirmed:number){await assert.rejects(executeRecursiveApply(client,payloads,persist),(e:unknown)=>{assert(e instanceof RecursiveApplyError);assert.equal(e.receipt.state,state);assert.equal(e.receipt.confirmedWrites,confirmed);assert.equal(e.receipt.metadataRecorded,false);return true})}
|
||||
reset();failAt=1;rejection=new MikrotikError(401,"/ip/route","denied");await expectState("rejected",0);assert.deepEqual(events,["put:1"])
|
||||
reset();failAt=2;rejection=new MikrotikError(403,"/ip/route","denied");await expectState("partial",1);assert.deepEqual(events,["put:1","put:2"])
|
||||
reset();failAt=2;rejection=new Error("ACK второго PUT потерян");await expectState("unknown",1);assert.deepEqual(events,["put:1","put:2"])
|
||||
reset();badAck=true;await expectState("unknown",0);assert.deepEqual(events,["put:1"])
|
||||
reset();failDelete=true;await expectState("partial",2);assert.deepEqual(events,["put:1","put:2","delete:/ip/route/*old"])
|
||||
reset();failPersist=true;await expectState("applied",3)
|
||||
reset();rows=[];assert.deepEqual(await executeRecursiveApply(client,[],persist),{pushed:0,deleted:0});assert.deepEqual(events,["persist"])
|
||||
console.log("Recursive apply: strict ownership before PUT, foreign retention, partial/unknown/rejected/applied, no retry/delete after rejection")
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { MikrotikClient } from "./mikrotik.js"
|
||||
import { MikrotikError } from "./mikrotik.js"
|
||||
import { parseRecursiveCatalog } from "./recursive-route-catalog.js"
|
||||
|
||||
export class RecursiveApplyError extends Error {
|
||||
constructor(message: string, public readonly receipt: {
|
||||
state: "partial" | "unknown" | "rejected" | "applied"
|
||||
confirmedWrites: number
|
||||
createdIds: string[]
|
||||
deletedIds: string[]
|
||||
needsReview: boolean
|
||||
metadataRecorded: boolean
|
||||
}) { super(message); this.name = "RecursiveApplyError" }
|
||||
}
|
||||
|
||||
export async function executeRecursiveApply(
|
||||
client: Pick<MikrotikClient, "get" | "put" | "delete">,
|
||||
payloads: Record<string, string>[],
|
||||
persist: () => Promise<void>,
|
||||
) {
|
||||
// The editor replaces every owned row: reject unsupported ownership before any write.
|
||||
const existing = parseRecursiveCatalog(await client.get<unknown>("/ip/route"))
|
||||
const createdIds: string[] = [], deletedIds: string[] = []
|
||||
let nativeComplete = false
|
||||
try {
|
||||
for (const payload of payloads) {
|
||||
const result = await client.put<unknown>("/ip/route", payload)
|
||||
if (!result || typeof result !== "object" || !(".id" in result) || typeof result[".id"] !== "string" || !result[".id"].trim()) throw new Error("RouterOS не подтвердил ID созданного маршрута")
|
||||
createdIds.push(result[".id"])
|
||||
}
|
||||
for (const route of existing) {
|
||||
await client.delete("/ip/route/" + encodeURIComponent(route.id))
|
||||
deletedIds.push(route.id)
|
||||
}
|
||||
nativeComplete = true
|
||||
await persist()
|
||||
return { pushed: createdIds.length, deleted: deletedIds.length }
|
||||
} catch (error) {
|
||||
const rejected = error instanceof MikrotikError && error.statusCode >= 400 && error.statusCode < 500
|
||||
const confirmedWrites = createdIds.length + deletedIds.length
|
||||
const state = nativeComplete ? "applied" : rejected ? (confirmedWrites ? "partial" : "rejected") : "unknown"
|
||||
throw new RecursiveApplyError(`Подтверждено записей RouterOS: ${confirmedWrites}. Обновите каталог перед повторной попыткой. ${String(error)}`, {
|
||||
state, confirmedWrites, createdIds, deletedIds, needsReview: state !== "rejected", metadataRecorded: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
import { z } from "zod"
|
||||
import { isIP } from "node:net"
|
||||
const destination = z.string().trim().refine(value => {
|
||||
const [address, prefix, extra] = value.split("/")
|
||||
return extra === undefined && isIP(address ?? "") === 4 && (prefix === undefined || /^\d+$/.test(prefix) && Number(prefix) <= 32)
|
||||
}, "Нужен IPv4 адрес или префикс")
|
||||
const gateway = z.string().trim().refine(value => {
|
||||
const [address, iface, extra] = value.split("%")
|
||||
return extra === undefined && isIP(address ?? "") === 4 && (iface === undefined || Boolean(iface) && !/\s/.test(iface))
|
||||
}, "Нужен IPv4 шлюз с необязательным интерфейсом")
|
||||
const serverId = z.union([z.number().int().positive(), z.string().trim().regex(/^\d+$/)])
|
||||
.transform(Number).refine(value => Number.isSafeInteger(value) && value > 0)
|
||||
export const recursiveTargetSchema = z.object({ serverId })
|
||||
export const recursiveInputRowSchema = z.object({
|
||||
id: z.string().optional().default(""),
|
||||
dstAddress: z.string().trim().min(1), gateway: z.string().trim().min(1),
|
||||
dstAddress: destination, gateway,
|
||||
distance: z.number().int().min(0).max(255),
|
||||
scope: z.number().int().min(0).max(255).nullable(),
|
||||
targetScope: z.number().int().min(0).max(255).nullable(),
|
||||
routingTable: z.string().trim().min(1), checkGateway: z.string(),
|
||||
routingTable: z.string().trim().min(1), checkGateway: z.enum(["none", "arp", "ping", "bfd"]),
|
||||
country: z.string(), comment: z.string(), disabled: z.boolean(),
|
||||
})
|
||||
export const recursiveInputRowsSchema = z.array(recursiveInputRowSchema)
|
||||
|
||||
@@ -749,3 +749,35 @@ POST optimize при отказе записи возвращает502 и reques
|
||||
**Runtime.** Backend ux-optimizer87c/version1.0.0-test.optimizer87c, image sha256:22cc9a09a89038e58c15dfd6224191d1fab7ad63f7af6f80ecc9e7577483d390; frontend ux-optimizer87/version1.0.0-test.optimizer87, image sha256:22f05c341a7dc0e40e04f23b0da61ba5cfbf31d741d4e22b0c9bcd71c85e0c49. Backend/PG healthy, frontend served/browser passed. Backend entry/node --check/pg_restore18.6, frontend entry/node --check и compose --wait passed. Три backend builds87/87b/87c по двум реальным flag incompatibilities; frontend build один, apt cache сохранён. Context backend routing83 +2 routes, затем4 файлов final-c; override SHA c04289ab57294d57cc9aed292e247e144c171ade3d1101f6ebb84129e41b065be; frontend ipsec77 +OSPF page, override dab57aad2cb248553b0fee9da73069f7a31cb627b26c414f03320315a299756db. Labels не Git SHA; это рабочие файлы до локального коммита. .env backup приватный, миграций нет. Один локальный commit без push.
|
||||
|
||||
Продолжение88 — recursive routes, затем диагностика. BFD sustained/alternate-path failover, IPv6/scale/maximum throughput, Portal SSO/browser auth и другие внешние ограничения сохраняются; lab AUTH_REQUIRED=false, RBAC checked отдельно. API /get и .proplist/query подтверждены [RouterOS REST API](https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST%2BAPI) и реальным устройством. Квота23% использовано, этап12.5 открыт.
|
||||
|
||||
## 12.5.88 — рекурсивные маршруты: реальные операции и история (2026-10-09)
|
||||
|
||||
**Исправления.** Все6 путей catalog/gateways/catalog/legacy reads/PUT/restore проверяют enabled=false и возвращают409 до опроса/применения любого типа устройства. История остаётся доступным чтением БД. Создание RouterOS route исправлено с POST без add на PUT. Весь управляемый набор проходит strict parse до записей: неподдерживаемая owned row не исключается молча перед удалением. IPv4 destination/gateway и check-gateway проверяются до native writes. Замена кэша выполняется в транзакции вместо независимых delete/insert.
|
||||
|
||||
Request-local receipt сохраняет подтверждённые createdIds/deletedIds и число записей. RouterOS4xx после успешных шагов → partial; первый4xx → rejected; потерянный ACK/неправильный ответ/transport → unknown; ошибка кэша/истории после native completion → applied/metadataRecorded=false. После успешного apply, но отказа заключительного чтения кэша → applied/metadataRecorded=true. Ошибки операций возвращают502, последующие записи не выполняются, automatic retry/rollback не добавлены. Старые/default/foreign routes не удаляются при отказе создания новых; полная distributed transaction/idempotency не заявляется.
|
||||
|
||||
| Проверка | Подтверждённый результат |
|
||||
| --- | --- |
|
||||
| Disabled Home Router и Exit Node | По6 запросов409; контрольный HTTP probe0 |
|
||||
| Native PUT + strict catalog | Формат/ID/flags подтверждены на двух CHR отдельным малым probe |
|
||||
| Recursive resolution | gateway198.18.189.peer → resolver host route → immediate-gw198.18.88.peer%ownGRE |
|
||||
| Data plane | Own loopback Ping3/3 в обе стороны в окончательном run |
|
||||
| API/history | Apply, изменение distance/disabled, restore своей версии; invalid CIDR400 без записи |
|
||||
| Fault injection | partial1/unknown1/applied2 подтверждённых записей; независимый native GET |
|
||||
| Browser | 4/4 ПК/320px, две реальные формы добавления; reload context, pageerror0 |
|
||||
|
||||
**Стенд.** Свои GRE и link198.18.88.0/30, bridge loopbacks198.18.188.1/32↔2/32 и probe IP198.18.189.1/32↔2/32. Неуправляемый собственный resolver /32 имеет scope10; управляемый remote loopback /32, gateway probe IP, scope30/target-scope11/distance150/check-gateway none. Native immediate-gw доказал двухступенчатое разрешение через GRE, Ping подтвердил передачу. Это проверка recursion/data plane, не loss-based failover или SLA. Default/public/metadata routes и195.195.195.195/32 сохранены.
|
||||
|
||||
**Доказательства.** [Native/API/DB отчёт](ux-ui/acceptance/recursive-routes-2026-10-09.json). Production API применил конфигурацию обоих устройств, прочитал каталог, изменил distance/disabled и восстановил только созданную в этом run версию. Revisions baseline≤35 проверен, чтобы тестовые записи не вызвали pruning старой истории. HTTP409 проверены на двух собственных disabled DB rows с probe на private Docker DNS; fake rows и их revisions удалены каскадно.
|
||||
|
||||
Ошибки инъецированы в тот же native executor: второй PUT отклонён до передачи → partial и сохранён старый маршрут; второй PUT выполнен, ACK заменён исключением → unknown, фактические3 owned rows прочитаны отдельно; persist callback отказал после replace → applied, новый набор уже на RouterOS. API-обёртка этих receipts и поздний getRoutes отказ проверены HTTP/unit. Реальный отказ PostgreSQL/физический TCP disconnect не создавался.
|
||||
|
||||
**Браузер.** Настоящий backend/frontend без mocks: два устройства×1280/320. Раскрытый endpoint и URL/reload, document fits, затем на320px заполнена и отправлена форма: gateway probe IP/scope30/target11/none/distance152, новый private prefix198.18.188.250/32. API подтвердил два маршрута, sheet закрылась, две группы видны. [Browser отчёт](ux-ui/acceptance/browser-recursive-2026-10-09.json), [ПК](ux-ui/screens/recursive-real-server1-1280-2026-10-09.png), [320px форма](ux-ui/screens/recursive-real-form-server1-320-2026-10-09.png) просмотрена. Общие Data Grid Filtering2/Sheet8/Frame из плана сохранены, frontend source не менялся.
|
||||
|
||||
**Проверки/оснастка.** 29 профильных E2E data/values/links passed. Backend recursive HTTP/catalog/executor tests, TypeScript, scoped ESLint/diff passed. Проверены invalid before writes, foreign retention, create/delete ordering, unsupported owned preflight, malformed ACK, rejected/partial/unknown/applied, поздняя ошибка чтения и disabled обоих типов. Первый native run Ping0/3: обратный маршрут ещё не создан; порядок исправлен на предварительное применение обеих сторон. Второй начальный Ping2/3: ограниченное ожидание готовности пути добавлено, до3 коротких попыток с сохранением результатов. Финальный run получил3/3 с первой попытки на обоих CHR. Ошибки оснастки не считаются приёмкой; оба cleanup passed.
|
||||
|
||||
**Очистка.** Native managed routes сверены по уникальному name-prefix, остальные свои GRE/bridge/IP/resolver по exact ID/атрибутам. Свои объекты удалены, baseline IDs4 коллекций×2 совпали. Исходно пустой recursive cache восстановлен; удалены только новые собственные revisions после проверки payload/comment и ID, прежние revision IDs сохранены. Последовательности PG увеличились из-за тестовых insert; это не побитовый restore БД. Оба CHR доступны, credentials неизменны, fixture containers удалены.
|
||||
|
||||
**Runtime/фиксация.** Одна backend сборка ux-recursive88/version1.0.0-test.recursive88, image sha256:d358ec1d6ea87ea7f29a12cc4fd3c1a6a7929b854bcdc732d956c702fe4dc587. Backend/PG healthy, frontend ux-optimizer87 сохранён и принят браузером. Context optimizer87 final-c +6 backend файлов, override SHA de4caf2aced38ba7e490f6a459a19d359cc92cb5b098564b799c05bc86be1365; label не Git SHA, рабочее состояние до коммита. Entry/node --check/pg_restore18.6, compose --wait passed, apt cached. .env backup приватный, миграций нет. Один локальный commit без push. REST метод подтверждён [RouterOS REST API](https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST%2BAPI).
|
||||
|
||||
Следующий89 — диагностика/терминал: реальный запуск, ограниченные команды, ошибки, права и контекст. Routing alternate-path/BFD sustained failover, IPv6/scale/physical disconnect/durable retry/Portal SSO и остальные внешние границы остаются открытыми. Квота38% использовано после блока; этап12.5 открыт.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# План реализации UX/UI по пятичасовым окнам
|
||||
|
||||
Актуальное продолжение 2026-10-09: 12.5.87 принял production OSPF optimizer preview/apply/repeat на двух своих GRE парах, partial/unknown с проверкой native costs и4 live UI проверки1280/320. Исправлены права кнопки и чтение эффективного disabled OSPF templates/instances/areas. Baseline7 коллекций×2 восстановлен. Backend ux-optimizer87c/frontend ux-optimizer87, healthy/served. Следующий12.5.88 — рекурсивные маршруты: запрет опроса выключенных устройств, реальные чтение/применение/история и достоверность поздних ошибок; далее диагностика. Квота23% использовано после блока, этап12.5 открыт.
|
||||
Актуальное продолжение 2026-10-09: 12.5.88 завершил рекурсивные маршруты: disabled guard всех6 native путей, PUT/строгий preflight/кэш transaction/partial-unknown-applied receipts. Реальный recursive gateway через свой GRE, Ping3/3×2, API apply/restore,12 disabled409/probe0 и4 live UI cases с2 добавлениями на320px. Native baseline4 коллекций×2 и прежние cache/history восстановлены. Backend ux-recursive88/frontend ux-optimizer87. Следующий12.5.89 — реальная диагностика и терминал, права/ошибки/контекст; затем оставшиеся routing failover и внешние границы. Квота38% использовано; этап12.5 открыт.
|
||||
|
||||
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-09. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
|
||||
|
||||
|
||||
+34
-2
@@ -1,6 +1,6 @@
|
||||
# UX/UI: журнал реализации
|
||||
|
||||
Актуальное продолжение 2026-10-09: 12.5.87 принял production OSPF optimizer preview/apply/repeat на двух своих GRE парах, partial/unknown с проверкой native costs и4 live UI проверки1280/320. Исправлены права кнопки и чтение эффективного disabled OSPF templates/instances/areas. Baseline7 коллекций×2 восстановлен. Backend ux-optimizer87c/frontend ux-optimizer87, healthy/served. Следующий12.5.88 — рекурсивные маршруты: запрет опроса выключенных устройств, реальные чтение/применение/история и достоверность поздних ошибок; далее диагностика. Квота23% использовано после блока, этап12.5 открыт.
|
||||
Актуальное продолжение 2026-10-09: 12.5.88 завершил рекурсивные маршруты: disabled guard всех6 native путей, PUT/строгий preflight/кэш transaction/partial-unknown-applied receipts. Реальный recursive gateway через свой GRE, Ping3/3×2, API apply/restore,12 disabled409/probe0 и4 live UI cases с2 добавлениями на320px. Native baseline4 коллекций×2 и прежние cache/history восстановлены. Backend ux-recursive88/frontend ux-optimizer87. Следующий12.5.89 — реальная диагностика и терминал, права/ошибки/контекст; затем оставшиеся routing failover и внешние границы. Квота38% использовано; этап12.5 открыт.
|
||||
|
||||
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
|
||||
|
||||
@@ -36,7 +36,39 @@
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–87 и ограничения ниже. Runtime backend ux-optimizer87c/frontend ux-optimizer87. |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–88 и ограничения ниже. Runtime backend ux-recursive88/frontend ux-optimizer87. |
|
||||
|
||||
## 12.5.88 — рекурсивные маршруты: реальные операции и история (2026-10-09)
|
||||
|
||||
**Исправления.** Все6 путей catalog/gateways/catalog/legacy reads/PUT/restore проверяют enabled=false и возвращают409 до опроса/применения любого типа устройства. История остаётся доступным чтением БД. Создание RouterOS route исправлено с POST без add на PUT. Весь управляемый набор проходит strict parse до записей: неподдерживаемая owned row не исключается молча перед удалением. IPv4 destination/gateway и check-gateway проверяются до native writes. Замена кэша выполняется в транзакции вместо независимых delete/insert.
|
||||
|
||||
Request-local receipt сохраняет подтверждённые createdIds/deletedIds и число записей. RouterOS4xx после успешных шагов → partial; первый4xx → rejected; потерянный ACK/неправильный ответ/transport → unknown; ошибка кэша/истории после native completion → applied/metadataRecorded=false. После успешного apply, но отказа заключительного чтения кэша → applied/metadataRecorded=true. Ошибки операций возвращают502, последующие записи не выполняются, automatic retry/rollback не добавлены. Старые/default/foreign routes не удаляются при отказе создания новых; полная distributed transaction/idempotency не заявляется.
|
||||
|
||||
| Проверка | Подтверждённый результат |
|
||||
| --- | --- |
|
||||
| Disabled Home Router и Exit Node | По6 запросов409; контрольный HTTP probe0 |
|
||||
| Native PUT + strict catalog | Формат/ID/flags подтверждены на двух CHR отдельным малым probe |
|
||||
| Recursive resolution | gateway198.18.189.peer → resolver host route → immediate-gw198.18.88.peer%ownGRE |
|
||||
| Data plane | Own loopback Ping3/3 в обе стороны в окончательном run |
|
||||
| API/history | Apply, изменение distance/disabled, restore своей версии; invalid CIDR400 без записи |
|
||||
| Fault injection | partial1/unknown1/applied2 подтверждённых записей; независимый native GET |
|
||||
| Browser | 4/4 ПК/320px, две реальные формы добавления; reload context, pageerror0 |
|
||||
|
||||
**Стенд.** Свои GRE и link198.18.88.0/30, bridge loopbacks198.18.188.1/32↔2/32 и probe IP198.18.189.1/32↔2/32. Неуправляемый собственный resolver /32 имеет scope10; управляемый remote loopback /32, gateway probe IP, scope30/target-scope11/distance150/check-gateway none. Native immediate-gw доказал двухступенчатое разрешение через GRE, Ping подтвердил передачу. Это проверка recursion/data plane, не loss-based failover или SLA. Default/public/metadata routes и195.195.195.195/32 сохранены.
|
||||
|
||||
**Доказательства.** [Native/API/DB отчёт](ux-ui/acceptance/recursive-routes-2026-10-09.json). Production API применил конфигурацию обоих устройств, прочитал каталог, изменил distance/disabled и восстановил только созданную в этом run версию. Revisions baseline≤35 проверен, чтобы тестовые записи не вызвали pruning старой истории. HTTP409 проверены на двух собственных disabled DB rows с probe на private Docker DNS; fake rows и их revisions удалены каскадно.
|
||||
|
||||
Ошибки инъецированы в тот же native executor: второй PUT отклонён до передачи → partial и сохранён старый маршрут; второй PUT выполнен, ACK заменён исключением → unknown, фактические3 owned rows прочитаны отдельно; persist callback отказал после replace → applied, новый набор уже на RouterOS. API-обёртка этих receipts и поздний getRoutes отказ проверены HTTP/unit. Реальный отказ PostgreSQL/физический TCP disconnect не создавался.
|
||||
|
||||
**Браузер.** Настоящий backend/frontend без mocks: два устройства×1280/320. Раскрытый endpoint и URL/reload, document fits, затем на320px заполнена и отправлена форма: gateway probe IP/scope30/target11/none/distance152, новый private prefix198.18.188.250/32. API подтвердил два маршрута, sheet закрылась, две группы видны. [Browser отчёт](ux-ui/acceptance/browser-recursive-2026-10-09.json), [ПК](ux-ui/screens/recursive-real-server1-1280-2026-10-09.png), [320px форма](ux-ui/screens/recursive-real-form-server1-320-2026-10-09.png) просмотрена. Общие Data Grid Filtering2/Sheet8/Frame из плана сохранены, frontend source не менялся.
|
||||
|
||||
**Проверки/оснастка.** 29 профильных E2E data/values/links passed. Backend recursive HTTP/catalog/executor tests, TypeScript, scoped ESLint/diff passed. Проверены invalid before writes, foreign retention, create/delete ordering, unsupported owned preflight, malformed ACK, rejected/partial/unknown/applied, поздняя ошибка чтения и disabled обоих типов. Первый native run Ping0/3: обратный маршрут ещё не создан; порядок исправлен на предварительное применение обеих сторон. Второй начальный Ping2/3: ограниченное ожидание готовности пути добавлено, до3 коротких попыток с сохранением результатов. Финальный run получил3/3 с первой попытки на обоих CHR. Ошибки оснастки не считаются приёмкой; оба cleanup passed.
|
||||
|
||||
**Очистка.** Native managed routes сверены по уникальному name-prefix, остальные свои GRE/bridge/IP/resolver по exact ID/атрибутам. Свои объекты удалены, baseline IDs4 коллекций×2 совпали. Исходно пустой recursive cache восстановлен; удалены только новые собственные revisions после проверки payload/comment и ID, прежние revision IDs сохранены. Последовательности PG увеличились из-за тестовых insert; это не побитовый restore БД. Оба CHR доступны, credentials неизменны, fixture containers удалены.
|
||||
|
||||
**Runtime/фиксация.** Одна backend сборка ux-recursive88/version1.0.0-test.recursive88, image sha256:d358ec1d6ea87ea7f29a12cc4fd3c1a6a7929b854bcdc732d956c702fe4dc587. Backend/PG healthy, frontend ux-optimizer87 сохранён и принят браузером. Context optimizer87 final-c +6 backend файлов, override SHA de4caf2aced38ba7e490f6a459a19d359cc92cb5b098564b799c05bc86be1365; label не Git SHA, рабочее состояние до коммита. Entry/node --check/pg_restore18.6, compose --wait passed, apt cached. .env backup приватный, миграций нет. Один локальный commit без push. REST метод подтверждён [RouterOS REST API](https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST%2BAPI).
|
||||
|
||||
Следующий89 — диагностика/терминал: реальный запуск, ограниченные команды, ошибки, права и контекст. Routing alternate-path/BFD sustained failover, IPv6/scale/physical disconnect/durable retry/Portal SSO и остальные внешние границы остаются открытыми. Квота38% использовано после блока; этап12.5 открыт.
|
||||
|
||||
## 12.5.87 — OSPF optimizer: права, реальные costs и частичный результат (2026-10-09)
|
||||
|
||||
|
||||
@@ -2058,3 +2058,35 @@ POST optimize при отказе записи возвращает502 и reques
|
||||
**Runtime.** Backend ux-optimizer87c/version1.0.0-test.optimizer87c, image sha256:22cc9a09a89038e58c15dfd6224191d1fab7ad63f7af6f80ecc9e7577483d390; frontend ux-optimizer87/version1.0.0-test.optimizer87, image sha256:22f05c341a7dc0e40e04f23b0da61ba5cfbf31d741d4e22b0c9bcd71c85e0c49. Backend/PG healthy, frontend served/browser passed. Backend entry/node --check/pg_restore18.6, frontend entry/node --check и compose --wait passed. Три backend builds87/87b/87c по двум реальным flag incompatibilities; frontend build один, apt cache сохранён. Context backend routing83 +2 routes, затем4 файлов final-c; override SHA c04289ab57294d57cc9aed292e247e144c171ade3d1101f6ebb84129e41b065be; frontend ipsec77 +OSPF page, override dab57aad2cb248553b0fee9da73069f7a31cb627b26c414f03320315a299756db. Labels не Git SHA; это рабочие файлы до локального коммита. .env backup приватный, миграций нет. Один локальный commit без push.
|
||||
|
||||
Продолжение88 — recursive routes, затем диагностика. BFD sustained/alternate-path failover, IPv6/scale/maximum throughput, Portal SSO/browser auth и другие внешние ограничения сохраняются; lab AUTH_REQUIRED=false, RBAC checked отдельно. API /get и .proplist/query подтверждены [RouterOS REST API](https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST%2BAPI) и реальным устройством. Квота23% использовано, этап12.5 открыт.
|
||||
|
||||
## 12.5.88 — рекурсивные маршруты: реальные операции и история (2026-10-09)
|
||||
|
||||
**Исправления.** Все6 путей catalog/gateways/catalog/legacy reads/PUT/restore проверяют enabled=false и возвращают409 до опроса/применения любого типа устройства. История остаётся доступным чтением БД. Создание RouterOS route исправлено с POST без add на PUT. Весь управляемый набор проходит strict parse до записей: неподдерживаемая owned row не исключается молча перед удалением. IPv4 destination/gateway и check-gateway проверяются до native writes. Замена кэша выполняется в транзакции вместо независимых delete/insert.
|
||||
|
||||
Request-local receipt сохраняет подтверждённые createdIds/deletedIds и число записей. RouterOS4xx после успешных шагов → partial; первый4xx → rejected; потерянный ACK/неправильный ответ/transport → unknown; ошибка кэша/истории после native completion → applied/metadataRecorded=false. После успешного apply, но отказа заключительного чтения кэша → applied/metadataRecorded=true. Ошибки операций возвращают502, последующие записи не выполняются, automatic retry/rollback не добавлены. Старые/default/foreign routes не удаляются при отказе создания новых; полная distributed transaction/idempotency не заявляется.
|
||||
|
||||
| Проверка | Подтверждённый результат |
|
||||
| --- | --- |
|
||||
| Disabled Home Router и Exit Node | По6 запросов409; контрольный HTTP probe0 |
|
||||
| Native PUT + strict catalog | Формат/ID/flags подтверждены на двух CHR отдельным малым probe |
|
||||
| Recursive resolution | gateway198.18.189.peer → resolver host route → immediate-gw198.18.88.peer%ownGRE |
|
||||
| Data plane | Own loopback Ping3/3 в обе стороны в окончательном run |
|
||||
| API/history | Apply, изменение distance/disabled, restore своей версии; invalid CIDR400 без записи |
|
||||
| Fault injection | partial1/unknown1/applied2 подтверждённых записей; независимый native GET |
|
||||
| Browser | 4/4 ПК/320px, две реальные формы добавления; reload context, pageerror0 |
|
||||
|
||||
**Стенд.** Свои GRE и link198.18.88.0/30, bridge loopbacks198.18.188.1/32↔2/32 и probe IP198.18.189.1/32↔2/32. Неуправляемый собственный resolver /32 имеет scope10; управляемый remote loopback /32, gateway probe IP, scope30/target-scope11/distance150/check-gateway none. Native immediate-gw доказал двухступенчатое разрешение через GRE, Ping подтвердил передачу. Это проверка recursion/data plane, не loss-based failover или SLA. Default/public/metadata routes и195.195.195.195/32 сохранены.
|
||||
|
||||
**Доказательства.** [Native/API/DB отчёт](ux-ui/acceptance/recursive-routes-2026-10-09.json). Production API применил конфигурацию обоих устройств, прочитал каталог, изменил distance/disabled и восстановил только созданную в этом run версию. Revisions baseline≤35 проверен, чтобы тестовые записи не вызвали pruning старой истории. HTTP409 проверены на двух собственных disabled DB rows с probe на private Docker DNS; fake rows и их revisions удалены каскадно.
|
||||
|
||||
Ошибки инъецированы в тот же native executor: второй PUT отклонён до передачи → partial и сохранён старый маршрут; второй PUT выполнен, ACK заменён исключением → unknown, фактические3 owned rows прочитаны отдельно; persist callback отказал после replace → applied, новый набор уже на RouterOS. API-обёртка этих receipts и поздний getRoutes отказ проверены HTTP/unit. Реальный отказ PostgreSQL/физический TCP disconnect не создавался.
|
||||
|
||||
**Браузер.** Настоящий backend/frontend без mocks: два устройства×1280/320. Раскрытый endpoint и URL/reload, document fits, затем на320px заполнена и отправлена форма: gateway probe IP/scope30/target11/none/distance152, новый private prefix198.18.188.250/32. API подтвердил два маршрута, sheet закрылась, две группы видны. [Browser отчёт](ux-ui/acceptance/browser-recursive-2026-10-09.json), [ПК](ux-ui/screens/recursive-real-server1-1280-2026-10-09.png), [320px форма](ux-ui/screens/recursive-real-form-server1-320-2026-10-09.png) просмотрена. Общие Data Grid Filtering2/Sheet8/Frame из плана сохранены, frontend source не менялся.
|
||||
|
||||
**Проверки/оснастка.** 29 профильных E2E data/values/links passed. Backend recursive HTTP/catalog/executor tests, TypeScript, scoped ESLint/diff passed. Проверены invalid before writes, foreign retention, create/delete ordering, unsupported owned preflight, malformed ACK, rejected/partial/unknown/applied, поздняя ошибка чтения и disabled обоих типов. Первый native run Ping0/3: обратный маршрут ещё не создан; порядок исправлен на предварительное применение обеих сторон. Второй начальный Ping2/3: ограниченное ожидание готовности пути добавлено, до3 коротких попыток с сохранением результатов. Финальный run получил3/3 с первой попытки на обоих CHR. Ошибки оснастки не считаются приёмкой; оба cleanup passed.
|
||||
|
||||
**Очистка.** Native managed routes сверены по уникальному name-prefix, остальные свои GRE/bridge/IP/resolver по exact ID/атрибутам. Свои объекты удалены, baseline IDs4 коллекций×2 совпали. Исходно пустой recursive cache восстановлен; удалены только новые собственные revisions после проверки payload/comment и ID, прежние revision IDs сохранены. Последовательности PG увеличились из-за тестовых insert; это не побитовый restore БД. Оба CHR доступны, credentials неизменны, fixture containers удалены.
|
||||
|
||||
**Runtime/фиксация.** Одна backend сборка ux-recursive88/version1.0.0-test.recursive88, image sha256:d358ec1d6ea87ea7f29a12cc4fd3c1a6a7929b854bcdc732d956c702fe4dc587. Backend/PG healthy, frontend ux-optimizer87 сохранён и принят браузером. Context optimizer87 final-c +6 backend файлов, override SHA de4caf2aced38ba7e490f6a459a19d359cc92cb5b098564b799c05bc86be1365; label не Git SHA, рабочее состояние до коммита. Entry/node --check/pg_restore18.6, compose --wait passed, apt cached. .env backup приватный, миграций нет. Один локальный commit без push. REST метод подтверждён [RouterOS REST API](https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST%2BAPI).
|
||||
|
||||
Следующий89 — диагностика/терминал: реальный запуск, ограниченные команды, ошибки, права и контекст. Routing alternate-path/BFD sustained failover, IPv6/scale/physical disconnect/durable retry/Portal SSO и остальные внешние границы остаются открытыми. Квота38% использовано после блока; этап12.5 открыт.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Тестовый сервер UX/UI — 2026-10-09
|
||||
|
||||
Сервер161.104.46.222 предоставлен для временного тестирования. Backend ux-optimizer87c/version1.0.0-test.optimizer87c, frontend ux-optimizer87/version1.0.0-test.optimizer87; backend/PG healthy, frontend HTTP/browser passed. Основной блок IPsec77 принят в границах журнала, own fixtures/fake DB rows удалены; каталог flash CHR2 сохранён из-за неподтверждённого ownership. SSH/оба CHR доступны, пароли прежние.
|
||||
Сервер161.104.46.222 предоставлен для временного тестирования. Backend ux-recursive88/version1.0.0-test.recursive88, frontend ux-optimizer87/version1.0.0-test.optimizer87; backend/PG healthy, frontend HTTP/browser passed. Основной блок IPsec77 принят в границах журнала, own fixtures/fake DB rows удалены; каталог flash CHR2 сохранён из-за неподтверждённого ownership. SSH/оба CHR доступны, пароли прежние.
|
||||
|
||||
| Параметр | Проверенное состояние |
|
||||
| --- | --- |
|
||||
@@ -713,3 +713,5 @@ flowchart LR
|
||||
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
|
||||
|
||||
12.5.87 (2026-10-09): OSPF optimizer native/API и4 browser checks passed, собственные объекты очищены, baseline7 коллекций×2 сохранён. Подробности/runtime происхождение — в журнале.
|
||||
|
||||
12.5.88 (2026-10-09): recursion/GRE/Ping, apply/restore и4 browser checks passed; native baseline4 коллекций×2, cache/history сохранены. Backend recursive88, frontend optimizer87.
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"date": "2026-10-09",
|
||||
"phase": "2 320",
|
||||
"cases": [
|
||||
{
|
||||
"serverId": 1,
|
||||
"width": 1280,
|
||||
"recursiveRouteVisible": true,
|
||||
"urlRestore": true,
|
||||
"documentFits": true,
|
||||
"realBrowserAddReceipt": false
|
||||
},
|
||||
{
|
||||
"serverId": 1,
|
||||
"width": 320,
|
||||
"recursiveRouteVisible": true,
|
||||
"urlRestore": true,
|
||||
"documentFits": true,
|
||||
"realBrowserAddReceipt": true
|
||||
},
|
||||
{
|
||||
"serverId": 2,
|
||||
"width": 1280,
|
||||
"recursiveRouteVisible": true,
|
||||
"urlRestore": true,
|
||||
"documentFits": true,
|
||||
"realBrowserAddReceipt": false
|
||||
},
|
||||
{
|
||||
"serverId": 2,
|
||||
"width": 320,
|
||||
"recursiveRouteVisible": true,
|
||||
"urlRestore": true,
|
||||
"documentFits": true,
|
||||
"realBrowserAddReceipt": true
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
{
|
||||
"date": "2026-10-09",
|
||||
"name": "mm-rec88-1791554488522",
|
||||
"devices": [
|
||||
{
|
||||
"serverId": 1,
|
||||
"applied": {
|
||||
"ok": true,
|
||||
"serverId": 1,
|
||||
"routes": [
|
||||
{
|
||||
"id": "11",
|
||||
"dstAddress": "198.18.188.2/32",
|
||||
"gateway": "198.18.189.2",
|
||||
"distance": 150,
|
||||
"scope": 30,
|
||||
"targetScope": 11,
|
||||
"routingTable": "main",
|
||||
"checkGateway": "none",
|
||||
"country": "",
|
||||
"comment": "mm-rec88-1791554488522",
|
||||
"disabled": false
|
||||
}
|
||||
],
|
||||
"pushedRoutes": 1
|
||||
},
|
||||
"catalog": {
|
||||
"serverId": 1,
|
||||
"routes": [
|
||||
{
|
||||
"id": "*80000014",
|
||||
"dstAddress": "198.18.188.2/32",
|
||||
"gateway": "198.18.189.2",
|
||||
"distance": 150,
|
||||
"disabled": false,
|
||||
"routingTable": "main",
|
||||
"checkGateway": "none",
|
||||
"scope": 30,
|
||||
"targetScope": 11,
|
||||
"country": "",
|
||||
"comment": "mm-rec88-1791554488522"
|
||||
}
|
||||
],
|
||||
"live": true,
|
||||
"stale": false
|
||||
},
|
||||
"recursiveNative": {
|
||||
".id": "*80000014",
|
||||
"active": "true",
|
||||
"check-gateway": "none",
|
||||
"comment": "MikrotikManager:recursive mm-rec88-1791554488522",
|
||||
"disabled": "false",
|
||||
"distance": "150",
|
||||
"dst-address": "198.18.188.2/32",
|
||||
"dynamic": "false",
|
||||
"gateway": "198.18.189.2",
|
||||
"immediate-gw": "198.18.88.2%mm-rec88-1791554488522",
|
||||
"inactive": "false",
|
||||
"routing-table": "main",
|
||||
"scope": "30",
|
||||
"static": "true",
|
||||
"target-scope": "11"
|
||||
},
|
||||
"pingReceived": 3,
|
||||
"historyRestore": true,
|
||||
"invalidInputNoWrites": true
|
||||
},
|
||||
{
|
||||
"serverId": 2,
|
||||
"applied": {
|
||||
"ok": true,
|
||||
"serverId": 2,
|
||||
"routes": [
|
||||
{
|
||||
"id": "18",
|
||||
"dstAddress": "198.18.188.1/32",
|
||||
"gateway": "198.18.189.1",
|
||||
"distance": 150,
|
||||
"scope": 30,
|
||||
"targetScope": 11,
|
||||
"routingTable": "main",
|
||||
"checkGateway": "none",
|
||||
"country": "",
|
||||
"comment": "mm-rec88-1791554488522",
|
||||
"disabled": false
|
||||
}
|
||||
],
|
||||
"pushedRoutes": 1
|
||||
},
|
||||
"catalog": {
|
||||
"serverId": 2,
|
||||
"routes": [
|
||||
{
|
||||
"id": "*80000012",
|
||||
"dstAddress": "198.18.188.1/32",
|
||||
"gateway": "198.18.189.1",
|
||||
"distance": 150,
|
||||
"disabled": false,
|
||||
"routingTable": "main",
|
||||
"checkGateway": "none",
|
||||
"scope": 30,
|
||||
"targetScope": 11,
|
||||
"country": "",
|
||||
"comment": "mm-rec88-1791554488522"
|
||||
}
|
||||
],
|
||||
"live": true,
|
||||
"stale": false
|
||||
},
|
||||
"recursiveNative": {
|
||||
".id": "*80000012",
|
||||
"active": "true",
|
||||
"check-gateway": "none",
|
||||
"comment": "MikrotikManager:recursive mm-rec88-1791554488522",
|
||||
"disabled": "false",
|
||||
"distance": "150",
|
||||
"dst-address": "198.18.188.1/32",
|
||||
"dynamic": "false",
|
||||
"gateway": "198.18.189.1",
|
||||
"immediate-gw": "198.18.88.1%mm-rec88-1791554488522",
|
||||
"inactive": "false",
|
||||
"routing-table": "main",
|
||||
"scope": "30",
|
||||
"static": "true",
|
||||
"target-scope": "11"
|
||||
},
|
||||
"pingReceived": 3,
|
||||
"historyRestore": true,
|
||||
"invalidInputNoWrites": true
|
||||
}
|
||||
],
|
||||
"disabled": [
|
||||
{
|
||||
"type": "home-router",
|
||||
"path": "/recursive-routes/catalog",
|
||||
"status": 409
|
||||
},
|
||||
{
|
||||
"type": "home-router",
|
||||
"path": "/recursive-routes/gateways/catalog",
|
||||
"status": 409
|
||||
},
|
||||
{
|
||||
"type": "home-router",
|
||||
"path": "/recursive-routes",
|
||||
"status": 409
|
||||
},
|
||||
{
|
||||
"type": "home-router",
|
||||
"path": "/recursive-routes/gateways",
|
||||
"status": 409
|
||||
},
|
||||
{
|
||||
"type": "home-router",
|
||||
"path": "/recursive-routes",
|
||||
"method": "PUT",
|
||||
"status": 409
|
||||
},
|
||||
{
|
||||
"type": "home-router",
|
||||
"path": "/recursive-routes/revisions/7c8be412-5139-455a-a910-f61d20a9159c/restore",
|
||||
"method": "POST",
|
||||
"status": 409
|
||||
},
|
||||
{
|
||||
"type": "exit-node",
|
||||
"path": "/recursive-routes/catalog",
|
||||
"status": 409
|
||||
},
|
||||
{
|
||||
"type": "exit-node",
|
||||
"path": "/recursive-routes/gateways/catalog",
|
||||
"status": 409
|
||||
},
|
||||
{
|
||||
"type": "exit-node",
|
||||
"path": "/recursive-routes",
|
||||
"status": 409
|
||||
},
|
||||
{
|
||||
"type": "exit-node",
|
||||
"path": "/recursive-routes/gateways",
|
||||
"status": 409
|
||||
},
|
||||
{
|
||||
"type": "exit-node",
|
||||
"path": "/recursive-routes",
|
||||
"method": "PUT",
|
||||
"status": 409
|
||||
},
|
||||
{
|
||||
"type": "exit-node",
|
||||
"path": "/recursive-routes/revisions/940ee856-a382-4713-b100-a548b70505c2/restore",
|
||||
"method": "POST",
|
||||
"status": 409
|
||||
}
|
||||
],
|
||||
"faults": [
|
||||
{
|
||||
"mode": "partial",
|
||||
"receipt": {
|
||||
"state": "partial",
|
||||
"confirmedWrites": 1,
|
||||
"createdIds": [
|
||||
"*80000017"
|
||||
],
|
||||
"deletedIds": [],
|
||||
"needsReview": true,
|
||||
"metadataRecorded": false
|
||||
},
|
||||
"nativeIds": [
|
||||
"*80000016",
|
||||
"*80000017"
|
||||
],
|
||||
"nativeCount": 2,
|
||||
"faultInjection": true,
|
||||
"physicalDisconnect": false
|
||||
},
|
||||
{
|
||||
"mode": "unknown",
|
||||
"receipt": {
|
||||
"state": "unknown",
|
||||
"confirmedWrites": 1,
|
||||
"createdIds": [
|
||||
"*80000019"
|
||||
],
|
||||
"deletedIds": [],
|
||||
"needsReview": true,
|
||||
"metadataRecorded": false
|
||||
},
|
||||
"nativeIds": [
|
||||
"*80000018",
|
||||
"*80000019",
|
||||
"*8000001A"
|
||||
],
|
||||
"nativeCount": 3,
|
||||
"faultInjection": true,
|
||||
"physicalDisconnect": false
|
||||
},
|
||||
{
|
||||
"mode": "applied",
|
||||
"receipt": {
|
||||
"state": "applied",
|
||||
"confirmedWrites": 2,
|
||||
"createdIds": [
|
||||
"*8000001C"
|
||||
],
|
||||
"deletedIds": [
|
||||
"*8000001B"
|
||||
],
|
||||
"needsReview": true,
|
||||
"metadataRecorded": false
|
||||
},
|
||||
"nativeIds": [
|
||||
"*8000001C"
|
||||
],
|
||||
"nativeCount": 1,
|
||||
"faultInjection": true,
|
||||
"physicalDisconnect": false
|
||||
}
|
||||
],
|
||||
"cleanup": [
|
||||
{
|
||||
"serverId": 1,
|
||||
"dbCacheEmpty": true,
|
||||
"historyBaseline": true,
|
||||
"/interface/gre": true,
|
||||
"/interface/bridge": true,
|
||||
"/ip/address": true,
|
||||
"/ip/route": true
|
||||
},
|
||||
{
|
||||
"serverId": 2,
|
||||
"dbCacheEmpty": true,
|
||||
"historyBaseline": true,
|
||||
"/interface/gre": true,
|
||||
"/interface/bridge": true,
|
||||
"/ip/address": true,
|
||||
"/ip/route": true
|
||||
}
|
||||
],
|
||||
"probeRequests": 0,
|
||||
"pingAttempts": {
|
||||
"1": [
|
||||
{
|
||||
"received": 3,
|
||||
"sent": 3
|
||||
}
|
||||
],
|
||||
"2": [
|
||||
{
|
||||
"received": 3,
|
||||
"sent": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
"nativeProbe": {
|
||||
"date": "2026-10-09",
|
||||
"out": [
|
||||
{
|
||||
"serverId": 1,
|
||||
"raw": {
|
||||
".id": "*8000000C",
|
||||
"active": "true",
|
||||
"check-gateway": "none",
|
||||
"comment": "MikrotikManager:recursive mm-rec88-probe-1791553666907",
|
||||
"disabled": "false",
|
||||
"distance": "150",
|
||||
"dst-address": "198.18.88.250/32",
|
||||
"dynamic": "false",
|
||||
"gateway": "194.226.121.1",
|
||||
"immediate-gw": "194.226.121.1%ether1",
|
||||
"inactive": "false",
|
||||
"routing-table": "main",
|
||||
"scope": "30",
|
||||
"static": "true",
|
||||
"target-scope": "10"
|
||||
},
|
||||
"mapped": {
|
||||
"id": "*8000000C",
|
||||
"dstAddress": "198.18.88.250/32",
|
||||
"gateway": "194.226.121.1",
|
||||
"distance": 150,
|
||||
"disabled": false,
|
||||
"routingTable": "main",
|
||||
"checkGateway": "none",
|
||||
"scope": 30,
|
||||
"targetScope": 10,
|
||||
"country": "",
|
||||
"comment": "mm-rec88-probe-1791553666907"
|
||||
}
|
||||
},
|
||||
{
|
||||
"serverId": 2,
|
||||
"raw": {
|
||||
".id": "*8000000C",
|
||||
"active": "true",
|
||||
"check-gateway": "none",
|
||||
"comment": "MikrotikManager:recursive mm-rec88-probe-1791553667075",
|
||||
"disabled": "false",
|
||||
"distance": "150",
|
||||
"dst-address": "198.18.88.250/32",
|
||||
"dynamic": "false",
|
||||
"gateway": "104.165.244.1",
|
||||
"immediate-gw": "104.165.244.1%ether1",
|
||||
"inactive": "false",
|
||||
"routing-table": "main",
|
||||
"scope": "30",
|
||||
"static": "true",
|
||||
"target-scope": "10"
|
||||
},
|
||||
"mapped": {
|
||||
"id": "*8000000C",
|
||||
"dstAddress": "198.18.88.250/32",
|
||||
"gateway": "104.165.244.1",
|
||||
"distance": 150,
|
||||
"disabled": false,
|
||||
"routingTable": "main",
|
||||
"checkGateway": "none",
|
||||
"scope": 30,
|
||||
"targetScope": 10,
|
||||
"country": "",
|
||||
"comment": "mm-rec88-probe-1791553667075"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"initialAttempts": [
|
||||
{
|
||||
"result": "Ping0/3 before reverse route was configured",
|
||||
"cleanupPassed": true
|
||||
},
|
||||
{
|
||||
"result": "Initial Ping2/3 during path startup",
|
||||
"cleanupPassed": true
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"backend": "ux-recursive88",
|
||||
"frontend": "ux-optimizer87",
|
||||
"labelsAreGitSha": false
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Reference in new issue
Block a user