diff --git a/backend/package.json b/backend/package.json index 041f5d85..ce7928dc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/routes/recursive-routes.test.ts b/backend/src/routes/recursive-routes.test.ts index 16a3d24f..2f5b5e39 100644 --- a/backend/src/routes/recursive-routes.test.ts +++ b/backend/src/routes/recursive-routes.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) { diff --git a/backend/src/routes/recursive-routes.ts b/backend/src/routes/recursive-routes.ts index 3447bdc1..283118f4 100644 --- a/backend/src/routes/recursive-routes.ts +++ b/backend/src/routes/recursive-routes.ts @@ -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 - readRaw: (server: ServerRow) => Promise + readRaw: (server: ServerRow, purpose?: "routes" | "gateways") => Promise + readProperty: (server: ServerRow, id: string, property: RecursiveObservedProperty) => Promise saveObserved: (serverId: number, routes: RecursiveRouteDto[]) => Promise getRoutes: typeof mapDbRoutes apply: typeof applyRecursiveToServer @@ -187,7 +188,15 @@ export function createRecursiveRoutes(overrides: Partial (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("/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("/ip/route?.proplist=.id,gateway,active") + : client.post("/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 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 { + 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(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((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() diff --git a/backend/src/services/mikrotik.ts b/backend/src/services/mikrotik.ts index 176cdf59..8dd3c307 100644 --- a/backend/src/services/mikrotik.ts +++ b/backend/src/services/mikrotik.ts @@ -104,7 +104,7 @@ function rosRequest( function rosPost( params: MikrotikConnectParams, path: string, - body: Record, + body: Record, timeoutMs: number, externalSignal?: AbortSignal, ): Promise { @@ -595,7 +595,7 @@ export class MikrotikClient { return rosRequest(this.params, path, timeoutMs) as Promise } - async post(path: string, body: Record, timeoutMs = 15_000, signal?: AbortSignal): Promise { + async post(path: string, body: Record, timeoutMs = 15_000, signal?: AbortSignal): Promise { return rosPost(this.params, path, body, timeoutMs, signal) as Promise } diff --git a/backend/src/services/recursive-effective-properties.test.ts b/backend/src/services/recursive-effective-properties.test.ts new file mode 100644 index 00000000..00c361a2 --- /dev/null +++ b/backend/src/services/recursive-effective-properties.test.ts @@ -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)[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") diff --git a/backend/src/services/recursive-route-catalog.ts b/backend/src/services/recursive-route-catalog.ts index 648276f5..ea82268e 100644 --- a/backend/src/services/recursive-route-catalog.ts +++ b/backend/src/services/recursive-route-catalog.ts @@ -13,6 +13,44 @@ function rawRows(payload: unknown): Array> { } 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, +): Promise>> { + const rows = rawRows(payload).map(row => ({ ...row })) + const tasks: Array<{ row: Record; 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"]) diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 550518c8..c4970015 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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/неизвестных значений и различия потока и ёмкости. На ПК раскрыта, на телефоне изначально свёрнута, содержимое ограничено высотой со скроллом. diff --git a/docs/ux-ui/acceptance/prod271-recursive.json b/docs/ux-ui/acceptance/prod271-recursive.json new file mode 100644 index 00000000..5f16ec96 --- /dev/null +++ b/docs/ux-ui/acceptance/prod271-recursive.json @@ -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/" + ] +}