fix(routes): читать рекурсивные маршруты с незаданными свойствами
This commit is contained in:
1 parent
eb24a08981
commit
6a921ed52f
9 files changed
+188
-9
No files matched your search
@@ -23,7 +23,7 @@
|
||||
"test:traffic-flow": "tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
|
||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
|
||||
"test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/db/scheduler-retention-index.test.ts && tsx src/services/config-revisions.test.ts",
|
||||
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-apply.test.ts",
|
||||
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-effective-properties.test.ts && tsx src/services/recursive-apply.test.ts",
|
||||
"test:config-sync": "tsx src/utils/server-id.test.ts && tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts && tsx src/services/gre-native-identity.test.ts && tsx src/services/gre-ipv6.test.ts && tsx src/routes/gre-ipv6-validation.test.ts && tsx src/services/network-disabled-server.test.ts",
|
||||
"test:ros-file": "tsx src/services/ros-file-contents.test.ts && tsx src/services/mikrotik-response.test.ts",
|
||||
"test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
|
||||
|
||||
@@ -9,6 +9,7 @@ let rawReads = 0, lateReadFailure = false, applyError: Error | undefined
|
||||
let lookup = 0, writes = 0, revisions = 0, fail = false, stored = [row], payload: unknown = [row], revisionServer = 1, section = "recursive-routes"
|
||||
const observed = { ".id": "*1", static: "true", "dst-address": row.dstAddress, gateway: row.gateway, distance: "0", scope: "0", "target-scope": "0", "routing-table": "main", "check-gateway": "ping", disabled: "false", comment: "MikrotikManager:recursive Тест" }
|
||||
let rawPayload: unknown = [observed], rawFailure = false, savedSnapshots = 0
|
||||
let propertyFailure = false, propertyReads = 0
|
||||
const applied: Array<{ rows: typeof row[]; source: string }> = []
|
||||
const app = Fastify()
|
||||
app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
|
||||
@@ -16,6 +17,7 @@ await app.register(createRecursiveRoutes({
|
||||
getServer: async id => { lookup++; return id === 1 ? server : undefined },
|
||||
getRoutes: async () => { if (lateReadFailure) throw Error("Сбой чтения после применения"); return stored },
|
||||
readRaw: async () => { rawReads++; if (rawFailure) throw new Error("Тест опроса"); return rawPayload },
|
||||
readProperty: async (_server, _id, property) => { propertyReads++; if (propertyFailure) throw Error("Свойство недоступно"); return property === "disabled" ? { ret: "false" } : [] },
|
||||
saveObserved: async (_id, rows) => { savedSnapshots++; stored = rows as typeof row[] },
|
||||
apply: async (device, rows, source) => {
|
||||
assert.equal(device.id, 1); writes++; applied.push({ rows: rows as typeof row[], source })
|
||||
@@ -85,6 +87,14 @@ rawFailure=true
|
||||
assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})).statusCode,503)
|
||||
rawFailure=false;rawPayload=[]
|
||||
assert.deepEqual((await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})).json(),{serverId:1,routes:[],live:true,stale:false})
|
||||
rawPayload=[{...observed,disabled:undefined,"check-gateway":undefined}]
|
||||
const recovered=await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})
|
||||
assert.equal(recovered.statusCode,200);assert.equal(recovered.json().routes[0].disabled,false);assert.equal(recovered.json().routes[0].checkGateway,"none");assert.equal(propertyReads,2)
|
||||
propertyFailure=true
|
||||
const beforePropertyFailure=savedSnapshots
|
||||
const failedProperty=await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})
|
||||
assert.equal(failedProperty.statusCode,503);assert.equal(failedProperty.json().routes,undefined);assert.equal(savedSnapshots,beforePropertyFailure)
|
||||
propertyFailure=false
|
||||
rawPayload=[{".id":"one",gateway:"192.0.2.1"}]
|
||||
assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/gateways/catalog?serverId=1"})).json().gateways[0].status,"unknown")
|
||||
for (const type of ["home-router", "exit-node"] as const) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { recursiveApplyBodySchema, recursiveTargetSchema, recursiveInputRowsSchema } from '../services/recursive-route-validation.js'
|
||||
import { parseRecursiveCatalog, parseRecursiveGatewayCatalog } from "../services/recursive-route-catalog.js"
|
||||
import { parseRecursiveCatalog, parseRecursiveGatewayCatalog, readRecursiveEffectiveProperties, type RecursiveObservedProperty } from "../services/recursive-route-catalog.js"
|
||||
import { executeRecursiveApply, RecursiveApplyError } from "../services/recursive-apply.js"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { z } from "zod"
|
||||
@@ -176,7 +176,8 @@ function recursiveFailureReceipt(error: unknown, result?: { pushed: number; dele
|
||||
|
||||
export interface RecursiveRouteDependencies {
|
||||
getServer: (id: number) => Promise<ServerRow | undefined>
|
||||
readRaw: (server: ServerRow) => Promise<unknown>
|
||||
readRaw: (server: ServerRow, purpose?: "routes" | "gateways") => Promise<unknown>
|
||||
readProperty: (server: ServerRow, id: string, property: RecursiveObservedProperty) => Promise<unknown>
|
||||
saveObserved: (serverId: number, routes: RecursiveRouteDto[]) => Promise<void>
|
||||
getRoutes: typeof mapDbRoutes
|
||||
apply: typeof applyRecursiveToServer
|
||||
@@ -187,7 +188,15 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
||||
const deps: RecursiveRouteDependencies = {
|
||||
getServer: async id => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0],
|
||||
getRoutes: mapDbRoutes, apply: applyRecursiveToServer, getRevision: getRevisionById,
|
||||
readRaw: server => MikrotikClient.fromServer(server).get<unknown>("/ip/route"),
|
||||
readRaw: (server, purpose) => {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
// Every ownership marker is a comment. Keep all commented rows, including
|
||||
// unsupported ones that must block an unsafe replacement of the managed set.
|
||||
return purpose === "gateways"
|
||||
? client.get<unknown>("/ip/route?.proplist=.id,gateway,active")
|
||||
: client.post<unknown>("/ip/route/print", { ".query": ["comment"] })
|
||||
},
|
||||
readProperty: (server, id, property) => MikrotikClient.fromServer(server).post("/ip/route/get", { ".id": id, "value-name": property }),
|
||||
saveObserved: async (serverId, routes) => {
|
||||
await replaceDbRoutes(serverId, routes)
|
||||
await appendRevisionIfChanged({ serverId, section: "recursive-routes", source: "observed", payload: canonicalRecursiveRoutes(routes) })
|
||||
@@ -203,7 +212,8 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||
try {
|
||||
const routes = mergeCachedCountry(parseRecursiveCatalog(await deps.readRaw(server)), await deps.getRoutes(serverId))
|
||||
const observed = await readRecursiveEffectiveProperties(await deps.readRaw(server), (id, property) => deps.readProperty(server, id, property))
|
||||
const routes = mergeCachedCountry(parseRecursiveCatalog(observed), await deps.getRoutes(serverId))
|
||||
await deps.saveObserved(serverId, routes)
|
||||
return reply.send({ serverId, routes, live: true, stale: false })
|
||||
} catch (error) {
|
||||
@@ -217,7 +227,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
|
||||
try {
|
||||
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server)) })
|
||||
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server, "gateways")) })
|
||||
} catch (error) {
|
||||
return reply.status(503).send({ error: error instanceof Error ? error.message : "Не удалось прочитать шлюзы" })
|
||||
}
|
||||
|
||||
@@ -3,6 +3,27 @@ import assert from "node:assert/strict"
|
||||
import { createServer } from "node:http"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
test("RouterOS print preserves native query arrays and property selection", async () => {
|
||||
let received: unknown, target = "", method = ""
|
||||
const server = createServer(async (req, res) => {
|
||||
target = req.url ?? ""; method = req.method ?? ""
|
||||
let body = ""; for await (const chunk of req) body += chunk.toString()
|
||||
received = JSON.parse(body)
|
||||
res.setHeader("content-type", "application/json"); res.end(JSON.stringify([{ ".id": "*1" }]))
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address(); assert(address && typeof address !== "string")
|
||||
const client = new MikrotikClient({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" })
|
||||
try {
|
||||
const result = await client.post("/ip/route/print", { ".query": ["comment"], ".proplist": [".id", "gateway"] })
|
||||
assert.deepEqual(received, { ".query": ["comment"], ".proplist": [".id", "gateway"] })
|
||||
assert.equal(target, "/rest/ip/route/print"); assert.equal(method, "POST")
|
||||
assert.deepEqual(result, [{ ".id": "*1" }])
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
}
|
||||
})
|
||||
|
||||
test("RouterOS shared REST pool bounds logins across client instances and closes idle sockets", async () => {
|
||||
let active=0,peak=0,requests=0
|
||||
const connections=new Set<import("node:net").Socket>()
|
||||
|
||||
@@ -104,7 +104,7 @@ function rosRequest(
|
||||
function rosPost(
|
||||
params: MikrotikConnectParams,
|
||||
path: string,
|
||||
body: Record<string, string>,
|
||||
body: Record<string, string | string[]>,
|
||||
timeoutMs: number,
|
||||
externalSignal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
@@ -595,7 +595,7 @@ export class MikrotikClient {
|
||||
return rosRequest(this.params, path, timeoutMs) as Promise<T>
|
||||
}
|
||||
|
||||
async post<T>(path: string, body: Record<string, string>, timeoutMs = 15_000, signal?: AbortSignal): Promise<T> {
|
||||
async post<T>(path: string, body: Record<string, string | string[]>, timeoutMs = 15_000, signal?: AbortSignal): Promise<T> {
|
||||
return rosPost(this.params, path, body, timeoutMs, signal) as Promise<T>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readRecursiveEffectiveProperties, parseRecursiveCatalog } from "./recursive-route-catalog.js"
|
||||
|
||||
const row = { ".id": "*1", static: "true", "dst-address": "10.9.9.2/32", gateway: "192.0.2.1%gre-test", distance: "1", scope: "30", "target-scope": "10", "routing-table": "main", comment: "MikrotikManager:recursive Тест" }
|
||||
const calls: string[] = []
|
||||
const result = await readRecursiveEffectiveProperties([row], async (id, property) => {
|
||||
calls.push(id + ":" + property)
|
||||
return property === "disabled" ? { ret: "false" } : []
|
||||
})
|
||||
assert.equal(parseRecursiveCatalog(result)[0].disabled, false)
|
||||
assert.equal(parseRecursiveCatalog(result)[0].checkGateway, "none")
|
||||
assert.deepEqual(calls.sort(), ["*1:check-gateway", "*1:disabled"])
|
||||
assert.equal("disabled" in row, false)
|
||||
|
||||
let reads = 0
|
||||
const complete = { ...row, disabled: "true", "check-gateway": "ping" }
|
||||
assert.deepEqual(await readRecursiveEffectiveProperties([complete, { ".id": "*other", comment: "user-owned" }], async () => { reads++; return null }), [complete, { ".id": "*other", comment: "user-owned" }])
|
||||
assert.equal(reads, 0)
|
||||
for (const payload of [null, {}, [null], [{ ...row, ".id": "" }], [row, row]]) {
|
||||
await assert.rejects(readRecursiveEffectiveProperties(payload, async () => { reads++; return null }))
|
||||
assert.equal(reads, 0)
|
||||
}
|
||||
for (const property of ["disabled", "check-gateway"] as const) {
|
||||
for (const response of [null, {}, { ret: false }, { ret: "" }, { ret: "unknown" }, [{}], ...(property === "disabled" ? [[]] : [])]) {
|
||||
const input = { ...complete }; delete (input as Record<string, unknown>)[property]
|
||||
await assert.rejects(readRecursiveEffectiveProperties([input], async () => response), /Не подтверждён/)
|
||||
}
|
||||
}
|
||||
await assert.rejects(readRecursiveEffectiveProperties([row], async () => { throw Error("RouterOS недоступен") }), /недоступен/)
|
||||
const explicit = await readRecursiveEffectiveProperties([row], async (_id, property) => ({ ret: property === "disabled" ? "true" : "bfd" }))
|
||||
assert.equal(parseRecursiveCatalog(explicit)[0].disabled, true)
|
||||
assert.equal(parseRecursiveCatalog(explicit)[0].checkGateway, "bfd")
|
||||
// Explicit invalid values are not replaced with defaults or re-read.
|
||||
const invalid = { ...complete, disabled: "unknown" }
|
||||
const untouched = await readRecursiveEffectiveProperties([invalid], async () => { throw Error("unexpected read") })
|
||||
assert.throws(() => parseRecursiveCatalog(untouched))
|
||||
let active = 0, maximum = 0
|
||||
await readRecursiveEffectiveProperties(Array.from({ length: 12 }, (_, i) => ({ ...row, ".id": "*" + i })), async (_id, property) => {
|
||||
maximum = Math.max(maximum, ++active)
|
||||
await new Promise(resolve => setTimeout(resolve, 1)); active--
|
||||
return { ret: property === "disabled" ? "false" : "arp" }
|
||||
})
|
||||
assert.equal(maximum, 2)
|
||||
console.log("Recursive effective properties: omitted flags/unset check, strict envelopes, ownership, no mutation, errors and bounded reads passed")
|
||||
@@ -13,6 +13,44 @@ function rawRows(payload: unknown): Array<Record<string, unknown>> {
|
||||
}
|
||||
function rosText(value: unknown): string | undefined { return typeof value === "string" ? value : undefined }
|
||||
function text(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null }
|
||||
export type RecursiveObservedProperty = "disabled" | "check-gateway"
|
||||
|
||||
/** RouterOS print can omit unset properties. Ask get for the effective value,
|
||||
* rather than interpreting an incomplete snapshot as enabled/no-check. */
|
||||
export async function readRecursiveEffectiveProperties(
|
||||
payload: unknown,
|
||||
readProperty: (id: string, property: RecursiveObservedProperty) => Promise<unknown>,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const rows = rawRows(payload).map(row => ({ ...row }))
|
||||
const tasks: Array<{ row: Record<string, unknown>; property: RecursiveObservedProperty }> = []
|
||||
for (const row of rows) {
|
||||
if (typeof row.comment !== "string" || !isOwnedRecursiveComment(row.comment)) continue
|
||||
for (const property of ["disabled", "check-gateway"] as const) {
|
||||
if (row[property] === undefined) tasks.push({ row, property })
|
||||
}
|
||||
}
|
||||
let cursor = 0, failed = false
|
||||
await Promise.all(Array.from({ length: Math.min(2, tasks.length) }, async () => {
|
||||
while (!failed && cursor < tasks.length) {
|
||||
const { row, property } = tasks[cursor++]!
|
||||
try {
|
||||
const response = await readProperty(String(row[".id"]), property)
|
||||
// An empty successful /get reply means the optional check is unset.
|
||||
// Do not treat null, malformed envelopes or failed reads as defaults.
|
||||
const unset = property === "check-gateway" && Array.isArray(response) && response.length === 0
|
||||
const value = response && typeof response === "object" && !Array.isArray(response) && "ret" in response ? response.ret : undefined
|
||||
if (property === "disabled") {
|
||||
if (typeof value !== "string" || observedBoolean(value) === null) throw new Error("Не подтверждён disabled маршрута " + row[".id"])
|
||||
row.disabled = observedBoolean(value) ? "true" : "false"
|
||||
} else {
|
||||
if (!unset && (typeof value !== "string" || !["none", "arp", "ping", "bfd"].includes(value))) throw new Error("Не подтверждён check-gateway маршрута " + row[".id"])
|
||||
row[property] = unset ? "none" : value
|
||||
}
|
||||
} catch (error) { failed = true; throw error }
|
||||
}
|
||||
}))
|
||||
return rows
|
||||
}
|
||||
export function parseRecursiveCatalog(payload: unknown): MappedRecursiveRoute[] {
|
||||
return rawRows(payload).filter(row => typeof row.comment === "string" && isOwnedRecursiveComment(row.comment)).map(row => {
|
||||
const id = String(row[".id"])
|
||||
|
||||
+13
-1
@@ -1,6 +1,8 @@
|
||||
# UX/UI: журнал реализации
|
||||
|
||||
Актуальное состояние 2026-10-15: Production269 — по отдельному разрешению обновлён production frontend до2.0.1; в браузере подтверждены3JH/1EN и25GRE в данных. Filters268 остаётся локальным;6 профильных E2E/TypeScript passed. Карта и миграция retention опубликованы в main; исправлен CI PostgreSQL, backend-test2467/8417 success. Карта принята в production с ограничением read-only DNS; Filters ещё не развёрнут. Online-индекс266 применён отдельно с разрешения владельца; нагрузка БД снизилась в двух повторных замерах. Проверка трафика и причин остальных тайм-аутов продолжается отдельно. Лабораторная приёмка264 завершена в документированных границах стенда:1396 основных UI и221 context-сценарий;120 live default states30 экранов. Runtime стенда backend258/frontend261. Непроверенные внешние группы сохранены в итоговом акте. Production v2.0.0 подтверждён через UI; [описание релиза](releases/2.0.0.md).
|
||||
Актуальное состояние 2026-10-16: Production271 — подготовлено исправление чтения рекурсивных маршрутов JH, подтверждено native чтением всех3 JH. Локальные Filters268, визуальная карта270 и backend271 ещё не опубликованы; production frontend2.0.1 из269. Подробности и проверки ниже.
|
||||
|
||||
Предыдущее состояние 2026-10-15: Production269 — по отдельному разрешению обновлён production frontend до2.0.1; в браузере подтверждены3JH/1EN и25GRE в данных. Filters268 остаётся локальным;6 профильных E2E/TypeScript passed. Карта и миграция retention опубликованы в main; исправлен CI PostgreSQL, backend-test2467/8417 success. Карта принята в production с ограничением read-only DNS; Filters ещё не развёрнут. Online-индекс266 применён отдельно с разрешения владельца; нагрузка БД снизилась в двух повторных замерах. Проверка трафика и причин остальных тайм-аутов продолжается отдельно. Лабораторная приёмка264 завершена в документированных границах стенда:1396 основных UI и221 context-сценарий;120 live default states30 экранов. Runtime стенда backend258/frontend261. Непроверенные внешние группы сохранены в итоговом акте. Production v2.0.0 подтверждён через UI; [описание релиза](releases/2.0.0.md).
|
||||
|
||||
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
|
||||
|
||||
@@ -38,6 +40,16 @@
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Завершён2026-10-15 в границах предоставленного стенда:1396 UI/221 context,120 live states30 экранов, production builds/ReUI checklist и реальные интеграции. Ограничения — в [акте](ux-ui-acceptance-candidate.md); production-выпуск не выполнялся. |
|
||||
|
||||
## Production271 — чтение рекурсивных маршрутов JH (2026-10-16)
|
||||
|
||||
Production GET /recursive-routes/catalog на двух московских JH возвращал503 «Не подтверждены параметры управляемого маршрута», на новосибирском наблюдался отдельный10с timeout. Проверка native RouterOS чтением: список и GET конкретной строки не содержат disabled/check-gateway; /ip/route/get подтверждает disabled=false, незаданный check-gateway возвращает[]. Строгий parser сохранён; добавлено дочитывание отсутствующих полей только для собственных маршрутов, максимум2 запроса одновременно. Только успешный пустой get check-gateway означает none; null/неверный envelope/ошибка не становятся defaults. Explicit invalid значения не заменяются.
|
||||
|
||||
Нагрузка чтения: полные таблицы12441/24864/12451 строк занимали2,4/8,0/2,1с. Каталог теперь использует штатный POST print с .query=[comment] (все комментарии, включая старые метки и неподдерживаемые owned строки), а не static-only фильтр, который мог бы скрыть неподдерживаемый объект. Отбор вернул12/13/7 строк за0,5/1,7/0,5с. Каталог шлюзов сохраняет все маршруты, но запрашивает только .id/gateway/active. Тип POST body расширен под штатные строковые массивы query/proplist, сериализация проверена настоящим локальным HTTP.
|
||||
|
||||
Новый код в отдельном процессе на production проверен только чтением, без замены работающего backend:4/3/5 собственных маршрутов успешно разобраны за1,026/3,225/0,643с с7/4/3 дочитываниями. RouterOS-конфигурация не менялась, saveObserved/применение не вызывались. Это замер чтения/разбора, не всего UI; отдельная загрузка шлюзов в него не включена. Большой первоначальный tool input отклонён автоматической проверкой по размеру; читаемый скомпилированный скрипт передан частями через stdin, без файлов/секретов на сервере.
|
||||
|
||||
Проверки:4 backend scripts recursive-routes passed;11 HTTP client tests passed;15 page E2E passed; backend build/scoped ESLint/diff check passed. В HTTP tests подтверждены восстановление omitted fields и503 без записи cache при ошибке get. [Анонимизированный протокол](ux-ui/acceptance/prod271-recursive.json), [официальный REST API](https://manual.mikrotik.com/docs/developer-guides/rest-api/). Изменение локальное, без push/deploy. Следующий шаг — согласовать публикацию готовых268/270/271 и обновление backend/frontend; текущая production-страница до этого сохраняет прежнюю ошибку. Остальные задержки/тайм-ауты не объявляются устранёнными.
|
||||
|
||||
## Production270 — легенда и контуры фокуса карты (2026-10-15)
|
||||
|
||||
По замечанию владельца выполнено сравнение с последним доступным тегом v1.42.0. Там легенда была раскрытой графической панелью, а выбранный узел имел полупрозрачный пунктирный круг. Текущий свёрнутый текстовый details заменён компонентом NetworkMapLegend: образцы статусов, JH/EN/HR, формы WAN/сервисов, GRE/IPsec/NetFlow/модель WAN→JH, пояснения RTT/неизвестных значений и различия потока и ёмкости. На ПК раскрыта, на телефоне изначально свёрнута, содержимое ограничено высотой со скроллом.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"date": "2026-10-16",
|
||||
"scope": "Local backend fix plus read-only verification against production JH; no application deployment or RouterOS configuration writes",
|
||||
"causes": [
|
||||
"RouterOS list omitted disabled and check-gateway; strict catalog parser returned 503",
|
||||
"The page loaded full 12k-25k routing tables; an observed 8-second read approached the 10-second timeout"
|
||||
],
|
||||
"nativeObservations": {
|
||||
"disabledGet": { "ret": "false" },
|
||||
"unsetCheckGatewayGet": [],
|
||||
"configurationWrites": 0
|
||||
},
|
||||
"fix": {
|
||||
"routeList": "POST /ip/route/print with .query=[comment]; includes all ownership markers and unsupported commented rows",
|
||||
"gatewayList": "GET /ip/route with .proplist=.id,gateway,active; retains all routes",
|
||||
"missingProperties": "Read disabled/check-gateway via /ip/route/get only for owned rows with absent fields",
|
||||
"maximumPropertyConcurrency": 2,
|
||||
"emptySuccessfulCheckGet": "none",
|
||||
"malformedAndFailedReads": "rejected, no successful empty snapshot or cache update"
|
||||
},
|
||||
"readOnlyVerification": [
|
||||
{ "serverId": 2, "fullRows": 12441, "fullReadMs": 2394, "commentedRows": 12, "commentedReadMs": 500, "parsedOwnedRows": 4, "effectivePropertyReads": 7, "combinedReadAndParseMs": 1026 },
|
||||
{ "serverId": 3, "fullRows": 24864, "fullReadMs": 7986, "commentedRows": 13, "commentedReadMs": 1697, "parsedOwnedRows": 3, "effectivePropertyReads": 4, "combinedReadAndParseMs": 3225 },
|
||||
{ "serverId": 4, "fullRows": 12451, "fullReadMs": 2106, "commentedRows": 7, "commentedReadMs": 488, "parsedOwnedRows": 5, "effectivePropertyReads": 3, "combinedReadAndParseMs": 643 }
|
||||
],
|
||||
"checks": {
|
||||
"recursiveBackendProfile": "4 scripts passed, including new effective-property tests and HTTP failure/no-cache coverage",
|
||||
"routerOsHttpClient": "11 tests passed, including native query array serialization",
|
||||
"recursivePageE2E": "15 passed",
|
||||
"backendBuild": "passed",
|
||||
"scopedEslint": "passed",
|
||||
"gitDiffCheck": "passed"
|
||||
},
|
||||
"limitations": [
|
||||
"Read-only native checks, not acceptance of a deployed HTTP/UI fix",
|
||||
"One-off timings; gateway inventory remains a separate request and is not included in combinedReadAndParseMs",
|
||||
"No broader timeout/connection-pool redesign or changes to native route application rules",
|
||||
"Existing cache ID replacement behavior remains separate from this fix"
|
||||
],
|
||||
"sources": [
|
||||
"https://manual.mikrotik.com/docs/developer-guides/rest-api/",
|
||||
"https://manual.mikrotik.com/docs/cli-reference/ip/route/"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user