diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index f5e12f3c..f4f2337e 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -705,7 +705,7 @@ function InterfacesTab({ const canOptimizeLive = isLive && dataReady && filterServerId !== ALL_SERVERS_ID async function handleLiveOptimize() { - if (!mounted.current || operationLock.current || !canOptimizeLive || !can("mm:servers:write")) { + if (!mounted.current || operationLock.current || !canOptimizeLive || !can("mm:network:write")) { toast.info("Выберите конкретный сервер для оптимизации OSPF") return } @@ -779,7 +779,7 @@ function InterfacesTab({ )} {isLive && filterServerId !== ALL_SERVERS_ID && ( - @@ -1198,7 +1198,7 @@ function OspfPageContent({ search, selectedNodeId, onSearch, onSelectNode, filte const unknownIds = useMemo(() => new Set(liveError ? liveServers.map(s => s.id) : (liveData?.failures ?? []).map(f => f.serverId)), [liveError, liveServers, liveData]) const incomplete = Boolean(contextError) || isLive && (!liveData || Boolean(liveError) || (filterServerId === ALL_SERVERS_ID ? unknownIds.size > 0 : unknownIds.has(Number(filterServerId)))) - const dataReady = !contextError && (!isLive || Boolean(liveData && !liveError && !loading && !writeBusy && !liveData.failures.length && liveServers.some(s => String(s.id) === filterServerId) && can("mm:servers:write") && liveData.interfaces.filter(row => String(row.serverId) === filterServerId).every(row => row.cost !== null && row.cost >= 1 && row.disabled !== null && row.inactive !== null))) + const dataReady = !contextError && (!isLive || Boolean(liveData && !liveError && !loading && !writeBusy && !liveData.failures.length && liveServers.some(s => String(s.id) === filterServerId) && can("mm:network:write") && liveData.interfaces.filter(row => String(row.serverId) === filterServerId).every(row => row.cost !== null && row.cost >= 1 && row.disabled !== null && row.inactive !== null))) // Derive frontend types from backend data or use mocks const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => { diff --git a/backend/package.json b/backend/package.json index 8c64f88c..2cda905a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,7 +35,7 @@ "test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts", "test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts", "test:geoip": "tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.geoip.test.ts", - "test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/routes/ospf-optimizer.test.ts", + "test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/services/ospf-effective-disabled.test.ts && tsx src/routes/ospf-optimizer.test.ts", "test:containers": "tsx src/services/containers-state.test.ts && tsx src/routes/containers-state.test.ts", "test:certificates": "tsx src/services/certificate-issue-execution.test.ts && tsx src/services/certificate-issue-request.test.ts && tsx src/routes/certificates-issue.test.ts && tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts", "test:system-database": "tsx src/services/system-database-partition-restore.test.ts && tsx src/services/system-database-cache.test.ts && tsx src/services/database-background-work.test.ts && tsx src/services/database-request-gate.test.ts && tsx src/services/traffic-flow-worker-shutdown.test.ts && tsx src/services/system-database-restore-execution.test.ts && tsx src/services/system-database-operation.test.ts && tsx src/routes/system-database.test.ts && tsx src/plugins/auth.system-database.test.ts" diff --git a/backend/src/routes/ospf-optimizer.test.ts b/backend/src/routes/ospf-optimizer.test.ts index 4873e4f0..a27918c0 100644 --- a/backend/src/routes/ospf-optimizer.test.ts +++ b/backend/src/routes/ospf-optimizer.test.ts @@ -3,14 +3,16 @@ import Fastify from "fastify" import {validatorCompiler,serializerCompiler} from "@fastify/type-provider-zod" import {createOspfRoutes} from "./ospf.js" import type {servers} from "../db/schema.js" +import {MikrotikError} from "../services/mikrotik.js" const row={id:"*1",interface:"ether1",currentCost:10,optimalCost:20,pingMs:0,dlMbps:0,ulMbps:0,score:0} let plan:unknown=[row],writes=0,enabled=true,exists=true,failWrite=false +let failAt:number|null=null,writeError:Error=new Error("Ответ записи потерян") const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler) await app.register(createOspfRoutes({ listEnabledServers:async()=>[],readCatalog:async()=>{throw new Error("unused")}, getOptimizationServer:async()=>exists?({id:1,name:"Роутер",host:"192.0.2.1",enabled} as typeof servers.$inferSelect):undefined, buildOptimizationPlan:async()=>plan as typeof row[], - setTemplateCost:async()=>{writes++;if(failWrite)throw new Error("Частичный сетевой отказ")}, + setTemplateCost:async()=>{writes++;if(failWrite)throw new Error("Частичный сетевой отказ");if(writes===failAt)throw writeError}, }),{prefix:"/api"}) const send=(preview=false,body:unknown={pingWeight:60})=>app.inject({method:"POST",url:"/api/servers/1/ospf/optimize"+(preview?"/preview":""),payload:body}) const preview=await send(true);assert.equal(preview.statusCode,200);assert.equal(preview.json().changedCount,1);assert.equal(preview.json().pingWeight,60);assert.equal(writes,0) @@ -26,6 +28,15 @@ for(const invalid of [null,{},[row,row],[{...row,currentCost:0}],[{...row,optima plan=[row];enabled=false;assert.equal((await send()).statusCode,409);assert.equal((await send(true)).statusCode,409) enabled=true;exists=false;assert.equal((await send()).statusCode,404);exists=true const before=writes;assert.equal((await send(false,{pingWeight:101})).statusCode,400);assert.equal(writes,before) -failWrite=true;const failed=await send();assert.equal(failed.statusCode,500);assert.ok(failed.json().error.includes("Частичный сетевой отказ")) +failWrite=true;const failed=await send();assert.equal(failed.statusCode,502);assert.ok(failed.json().error.includes("Частичный сетевой отказ"));assert.equal(failed.json().state,"unknown");assert.equal(failed.json().confirmedWrites,0);assert.equal(failed.json().needsReview,true) +failWrite=false +const second={...row,id:"*2",interface:"gre-test",currentCost:30,optimalCost:40} +plan=[row,second];failAt=writes+2;writeError=new MikrotikError(403,"/routing/ospf/interface-template/*2","rejected") +const partial=await send();assert.equal(partial.statusCode,502);assert.equal(partial.json().state,"partial");assert.equal(partial.json().confirmedWrites,1);assert.deepEqual(partial.json().applied,[{interface:row.interface,from:10,to:20}]);assert.equal(partial.json().failedInterface,second.interface);assert.equal(writes,failAt) +failAt=writes+2;writeError=new Error("Ответ второго PATCH потерян") +const uncertain=await send();assert.equal(uncertain.json().state,"unknown");assert.equal(uncertain.json().confirmedWrites,1);assert.equal(uncertain.json().applied.length,1);assert.equal(writes,failAt) +failAt=writes+1;writeError=new MikrotikError(401,"/routing/ospf/interface-template/*1","unauthorized") +const rejected=await send();assert.equal(rejected.json().state,"rejected");assert.equal(rejected.json().confirmedWrites,0);assert.equal(rejected.json().needsReview,false);assert.equal(writes,failAt) +failAt=null failWrite=false;plan=[];assert.equal((await send()).json().optimizedCount,0) await app.close();console.log("OSPF optimizer HTTP: invalid before write, readonly preview, disabled/missing/bad weight, network error and known empty; no DB/RouterOS") diff --git a/backend/src/routes/ospf.ts b/backend/src/routes/ospf.ts index 39602654..7a1f630e 100644 --- a/backend/src/routes/ospf.ts +++ b/backend/src/routes/ospf.ts @@ -2,7 +2,7 @@ import { and, desc, eq } from "drizzle-orm" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { db } from "../db/index.js" import { serverSnapshots, servers, trafficSamples, uptimeSpeedProbes } from "../db/schema.js" -import { MikrotikClient } from "../services/mikrotik.js" +import { MikrotikClient, MikrotikError } from "../services/mikrotik.js" import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js" import type { RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance, @@ -742,17 +742,20 @@ export function createOspfRoutes(dependencies: OspfCatalogDependencies = catalog if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server.enabled) return reply.status(409).send({error:"Устройство OSPF отключено"}) + const applied: Array<{ interface: string; from: number; to: number }> = [] + let pending: OspfRankedInterface | undefined try { const pingWeight = req.body.pingWeight const ranked = validateOspfOptimizationPlan(await (dependencies.buildOptimizationPlan ?? buildOspfOptimizationPlan)(server, pingWeight)) const client=dependencies.setTemplateCost?null:MikrotikClient.fromServer(server) const changed = ranked.filter((r) => r.currentCost !== r.optimalCost) - const applied: Array<{ interface: string; from: number; to: number }> = [] for (const item of changed) { + pending = item if(dependencies.setTemplateCost) await dependencies.setTemplateCost(server,item.id,item.optimalCost) else await client!.setOspfInterfaceTemplateCost(item.id,item.optimalCost) applied.push({ interface: item.interface, from: item.currentCost, to: item.optimalCost }) + pending = undefined } return reply.send({ @@ -764,6 +767,19 @@ export function createOspfRoutes(dependencies: OspfCatalogDependencies = catalog interfaces: ranked, }) } catch (err) { + // A lost acknowledgement does not prove that RouterOS rejected the write. + if (pending) { + const rejected = err instanceof MikrotikError && err.statusCode >= 400 && err.statusCode < 500 + return reply.status(502).send({ + error: `API не подтвердил завершение оптимизации OSPF. Обновите данные перед повторной попыткой. ${String(err)}`, + serverId: server.id, + state: rejected ? (applied.length ? "partial" : "rejected") : "unknown", + confirmedWrites: applied.length, + applied, + failedInterface: pending.interface, + needsReview: !rejected || applied.length > 0, + }) + } return reply.status(500).send({ error: String(err) }) } }, diff --git a/backend/src/services/mikrotik.ts b/backend/src/services/mikrotik.ts index 0d0fa1eb..1d743bd4 100644 --- a/backend/src/services/mikrotik.ts +++ b/backend/src/services/mikrotik.ts @@ -3,6 +3,7 @@ import https from "node:https" import { randomBytes } from "node:crypto" import type { Server } from "../db/schema.js" import { parseRosDataSizeBytes } from "./ros-metric-parse.js" +import { readOspfEffectiveDisabled } from "./ospf-effective-disabled.js" import type { RosIdentity, RosInterface, RosIpAddress, RosResource, RosBgpSession, @@ -639,15 +640,18 @@ export class MikrotikClient { } async getOspfAreas(): Promise { - return this.get("/routing/ospf/area") + return readOspfEffectiveDisabled(await this.get("/routing/ospf/area"), id => + this.post("/routing/ospf/area/get", { ".id": id, "value-name": "disabled" })) } async getOspfInterfaceTemplates(): Promise { - return this.get("/routing/ospf/interface-template") + return readOspfEffectiveDisabled(await this.get("/routing/ospf/interface-template"), id => + this.post("/routing/ospf/interface-template/get", { ".id": id, "value-name": "disabled" })) } async getOspfInstances(): Promise { - return this.get("/routing/ospf/instance") + return readOspfEffectiveDisabled(await this.get("/routing/ospf/instance"), id => + this.post("/routing/ospf/instance/get", { ".id": id, "value-name": "disabled" })) } async getBfdSessions(): Promise { diff --git a/backend/src/services/ospf-effective-disabled.test.ts b/backend/src/services/ospf-effective-disabled.test.ts new file mode 100644 index 00000000..25d92fc4 --- /dev/null +++ b/backend/src/services/ospf-effective-disabled.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict" +import { readOspfEffectiveDisabled } from "./ospf-effective-disabled.js" +let calls: string[] = [] +const input = [{ ".id": "*1", disabled: "true" }, { ".id": "*2" }, { ".id": "*3" }] +const rows = await readOspfEffectiveDisabled(input, async id => { calls.push(id); return { ret: id === "*2" ? "false" : "true" } }) +assert.deepEqual(rows.map(row => row.disabled), ["true", "false", "true"]) +assert.deepEqual(calls.sort(), ["*2", "*3"]); assert.equal("disabled" in input[1]!, false) +for (const payload of [null, {}, [null], [{ ".id": "" }], [{ ".id": "*1" }, { ".id": "*1" }]]) { + calls = []; await assert.rejects(readOspfEffectiveDisabled(payload, async id => { calls.push(id); return { ret: "false" } })); assert.equal(calls.length, 0) +} +for (const value of [null, [], {}, { ret: "" }, { ret: "maybe" }, { ret: false }]) await assert.rejects(readOspfEffectiveDisabled([{ ".id": "*1" }], async () => value)) +await assert.rejects(readOspfEffectiveDisabled([{ ".id": "*1" }], async () => { throw Error("RouterOS недоступен") }), /недоступен/) +let active = 0, maximum = 0 +await readOspfEffectiveDisabled(Array.from({ length: 20 }, (_, i) => ({ ".id": "*" + i })), async () => { + maximum = Math.max(maximum, ++active); await new Promise(resolve => setTimeout(resolve, 1)); active--; return { ret: "false" } +}) +assert.equal(maximum, 4) +console.log("OSPF omitted disabled: effective native value, no guessed false, malformed/error refusal, bounded reads") diff --git a/backend/src/services/ospf-effective-disabled.ts b/backend/src/services/ospf-effective-disabled.ts new file mode 100644 index 00000000..b7ff150d --- /dev/null +++ b/backend/src/services/ospf-effective-disabled.ts @@ -0,0 +1,20 @@ +import { observedBoolean } from "./ospf-observed-values.js" + +/** RouterOS can omit an unset disabled property while /get returns its effective value. */ +export async function readOspfEffectiveDisabled(payload: unknown, readDisabled: (id: string) => Promise): Promise { + if (!Array.isArray(payload) || payload.some(row => !row || typeof row !== "object" || Array.isArray(row) || typeof row[".id"] !== "string" || !row[".id"].trim())) throw new Error("Недостоверный каталог шаблонов OSPF") + if (new Set(payload.map(row => row[".id"])).size !== payload.length) throw new Error("Повторяющиеся ID шаблонов OSPF") + const rows: T[] = payload.map(row => ({ ...row })) + const missing = rows.filter(row => row.disabled === undefined) + let cursor = 0 + await Promise.all(Array.from({ length: Math.min(4, missing.length) }, async () => { + while (cursor < missing.length) { + const row = missing[cursor++]! + const response = await readDisabled(row[".id"]) + const value = response && typeof response === "object" && "ret" in response ? response.ret : undefined + if (typeof value !== "string" || observedBoolean(value) === null) throw new Error("Не подтверждён disabled шаблона OSPF " + row[".id"]) + row.disabled = observedBoolean(value) ? "true" : "false" + } + })) + return rows +} diff --git a/docs/ux-ui-external-acceptance.md b/docs/ux-ui-external-acceptance.md index 52c9ec0d..c61fd23c 100644 --- a/docs/ux-ui-external-acceptance.md +++ b/docs/ux-ui-external-acceptance.md @@ -720,3 +720,32 @@ flowchart LR **Результат.** [Очищенный отчёт](ux-ui/acceptance/ospf-bfd-timing-2026-10-09.json): HTTP samples, scoped native logs, initial-attempt summary, recovery и baseline. Все свои firewall/logging/OSPF/BFD/IP/bridge/GRE объекты удалены после exact-ID/атрибутов preflight. Исходные ID10 коллекций×2 совпали; оба CHR доступны. Продукт/зависимости/runtime не менялись, сборки/широкие регрессии не повторялись. Локальный коммит без push. Квота5% использовано после завершения. Настройки протокола подтверждены [MikroTik BFD](https://help.mikrotik.com/docs/spaces/ROS/pages/191299691/BFD), механика memory logging — [MikroTik Log](https://help.mikrotik.com/docs/spaces/ROS/pages/328094/Log). Продолжение87: собственные OSPF templates через production optimizer preview/apply, неизвестные/модельные метрики и частичный отказ без заявления полного успеха; далее recursive diagnostics. Этап12.5 остаётся открытым. + +## 12.5.87 — OSPF optimizer: права, реальные costs и частичный результат (2026-10-09) + +**Изменения.** Три проверки разрешения в OSPF UI приведены к backend mm:network:write. Роль только mm:servers:write не запускает оптимизацию, network writer может её выполнить. Существующие Frame/компоненты/композиция ReUI сохранены; skill ReUI применён для минимальной правки поведения. + +POST optimize при отказе записи возвращает502 и request-local receipt: state partial для подтверждённых шагов перед RouterOS4xx; unknown при потерянном ACK/transport/5xx; rejected при первом явном4xx. applied содержит только подтверждённые interfaces/from/to, confirmedWrites, failedInterface и needsReview. После первого отказа следующие записи не выполняются. Preflight/plan ошибки остаются500 без заявления о записи. Автоматического rollback/retry, durable receipt или межпроцессной блокировки не добавлено. UI сохраняет прежнее сообщение о неподтверждённой операции и повторный опрос. + +**RouterOS7.24.5.** GET может опустить disabled при unset и у interface-template, и у instance/area. GET disabled=false/proplist не раскрывает effective default: отдельный native POST /get с .id/value-name=disabled возвращает ret=false; при disable ret=true; при явно false GET уже содержит false. Добавлено чтение эффективного значения только для пропущенного disabled, до4 одновременно. Значение не угадывается по отсутствию или inactive. Неправильный ответ/отказ/повтор ID блокирует чтение. Явные значения сохраняются, исходный payload не мутируется. Большой набор unset потребует дополнительных RPC; maximum scale/performance этого пути не принят. + +| Проверка | До | После / доказательство | +| --- | --- | --- | +| Права кнопки OSPF | servers:write расходится с API | network:write; обе роли проверены на320px | +| Неявный disabled | strict preview500 | native /get подтверждает состояние templates/instances/areas | +| Частичный отказ | обычный500 без шагов | partial/unknown/rejected и подтверждённые изменения | +| Production preview/apply | Не принято на устройстве | preview без записи, два costs применены на каждом CHR, повтор0 | + +Схема: устройство → достоверные flags/costs → preview → последовательная запись → receipt → повторный опрос. Референс сохранён: [Data Grid Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp). [Экран ПК](ux-ui/screens/ospf-real-optimizer-server1-1280-2026-10-09.png), [320px](ux-ui/screens/ospf-real-optimizer-server1-320-2026-10-09.png); mobile просмотрен, кнопки/cur-opt помещаются в документе. + +**Реальная приёмка.** Свои GRE/link198.18.87.0/30/loopbacks198.18.187.1/32↔2/32, OSPF instances/area, templates с начальными costs50/60. Public interfaces не включались в OSPF, originate-default=never. Production preview сохранил native costs, apply изменил два собственных шаблона на10/20 согласно плану, repeat optimizedCount0. DTO observed остаётся отдельным от модельных ping/speeds; fallback не объявляется измерением. OSPF Full, private route active, Ping3/3 по первоначальной проверке пары. + +Изолированный экземпляр тех же compiled routes с настоящими PATCH собственных шаблонов: reject второго шага до передачи → partial/confirmed1, второй cost неизменён; исключение после подтверждённого native PATCH, вместо ACK вызывающему коду → unknown/confirmed1, отдельный GET подтверждает оба фактических изменения. Это инъекция ответа, не физический TCP disconnect. После тестов production API привёл costs к текущему плану. [Native/API/flags протокол](ux-ui/acceptance/ospf-optimizer-2026-10-09.json). + +**Браузер и локальные проверки.** Настоящий deployed frontend/backend без HTTP interception: два устройства×1280/320 =4/4, apply повтор0, receipt/server context, кнопка после опроса, reload URL, document fits/pageerror0. [Browser отчёт](ux-ui/acceptance/browser-ospf-optimizer-2026-10-09.json). Профильные E2E OSPF operations/optimizer apply16/16; optimizer identity/metrics/plan/effective disabled/HTTP, catalog tests и signed JWT RBAC passed. Backend build и scoped frontend tsc passed, Docker frontend production build прошёл. Обычный root tsc захватывает старые ignored tmp build copies и имеет исходные ошибки; scoped config исключает tmp. ESLint новых helper/test/UI прошёл; OSPF прежние2 unused warnings, mikrotik.ts прежние2 prefer-const errors подтверждены на HEAD, новых ошибок нет. Полная регрессия не повторялась. + +**Оснастка/очистка.** Первые два native runs отказались на отсутствующем disabled template, затем instance; оба exact cleanup7 коллекций×2 passed. Read-only discovery проверила proplist/filter/get; numbers оказался неподдерживаемым аргументом, рабочий .id проверен. Отдельная flags fixture cleaned baseline5 коллекций. Финальный run native/browser прошёл, own templates/areas/instances/IP/bridge/GRE удалены по точным ID/атрибутам, исходные IDs7 коллекций×2 совпали. 195.195.195.195/32/default/management routes сохранены. Оба CHR доступны, credentials не менялись, fixture контейнеров/DB записей не осталось. + +**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 открыт. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 5e5cd3d9..a30fd746 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-09: 12.5.86 разделил наблюдения BFD/OSPF и удаления маршрута: BFD-only fault 629/630/1845ms; GRE loss 622/622/2842ms при dead20s. Это первые HTTP-наблюдения, не точные event timestamps/SLA. BFD-only допускает повторный Full при доступных Hello; устойчивый failover не принят. Baseline10 коллекций×2 восстановлен. Runtime backend ux-routing83/frontend ux-ipsec77. Следующий12.5.87 — реальная приёмка OSPF optimizer preview/apply и достоверность частичных операций; затем recursive diagnostics. Квота после блока5% использовано; этап12.5 открыт. +Актуальное продолжение 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 открыт. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-09. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 431382a3..cf0d6cde 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-09: 12.5.86 разделил наблюдения BFD/OSPF и удаления маршрута: BFD-only fault 629/630/1845ms; GRE loss 622/622/2842ms при dead20s. Это первые HTTP-наблюдения, не точные event timestamps/SLA. BFD-only допускает повторный Full при доступных Hello; устойчивый failover не принят. Baseline10 коллекций×2 восстановлен. Runtime backend ux-routing83/frontend ux-ipsec77. Следующий12.5.87 — реальная приёмка OSPF optimizer preview/apply и достоверность частичных операций; затем recursive diagnostics. Квота после блока5% использовано; этап12.5 открыт. +Актуальное продолжение 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 открыт. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). @@ -36,7 +36,36 @@ | 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–86 и ограничения ниже. Runtime backend ux-routing83/frontend ux-ipsec77. | +| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–87 и ограничения ниже. Runtime backend ux-optimizer87c/frontend ux-optimizer87. | + +## 12.5.87 — OSPF optimizer: права, реальные costs и частичный результат (2026-10-09) + +**Изменения.** Три проверки разрешения в OSPF UI приведены к backend mm:network:write. Роль только mm:servers:write не запускает оптимизацию, network writer может её выполнить. Существующие Frame/компоненты/композиция ReUI сохранены; skill ReUI применён для минимальной правки поведения. + +POST optimize при отказе записи возвращает502 и request-local receipt: state partial для подтверждённых шагов перед RouterOS4xx; unknown при потерянном ACK/transport/5xx; rejected при первом явном4xx. applied содержит только подтверждённые interfaces/from/to, confirmedWrites, failedInterface и needsReview. После первого отказа следующие записи не выполняются. Preflight/plan ошибки остаются500 без заявления о записи. Автоматического rollback/retry, durable receipt или межпроцессной блокировки не добавлено. UI сохраняет прежнее сообщение о неподтверждённой операции и повторный опрос. + +**RouterOS7.24.5.** GET может опустить disabled при unset и у interface-template, и у instance/area. GET disabled=false/proplist не раскрывает effective default: отдельный native POST /get с .id/value-name=disabled возвращает ret=false; при disable ret=true; при явно false GET уже содержит false. Добавлено чтение эффективного значения только для пропущенного disabled, до4 одновременно. Значение не угадывается по отсутствию или inactive. Неправильный ответ/отказ/повтор ID блокирует чтение. Явные значения сохраняются, исходный payload не мутируется. Большой набор unset потребует дополнительных RPC; maximum scale/performance этого пути не принят. + +| Проверка | До | После / доказательство | +| --- | --- | --- | +| Права кнопки OSPF | servers:write расходится с API | network:write; обе роли проверены на320px | +| Неявный disabled | strict preview500 | native /get подтверждает состояние templates/instances/areas | +| Частичный отказ | обычный500 без шагов | partial/unknown/rejected и подтверждённые изменения | +| Production preview/apply | Не принято на устройстве | preview без записи, два costs применены на каждом CHR, повтор0 | + +Схема: устройство → достоверные flags/costs → preview → последовательная запись → receipt → повторный опрос. Референс сохранён: [Data Grid Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp). [Экран ПК](ux-ui/screens/ospf-real-optimizer-server1-1280-2026-10-09.png), [320px](ux-ui/screens/ospf-real-optimizer-server1-320-2026-10-09.png); mobile просмотрен, кнопки/cur-opt помещаются в документе. + +**Реальная приёмка.** Свои GRE/link198.18.87.0/30/loopbacks198.18.187.1/32↔2/32, OSPF instances/area, templates с начальными costs50/60. Public interfaces не включались в OSPF, originate-default=never. Production preview сохранил native costs, apply изменил два собственных шаблона на10/20 согласно плану, repeat optimizedCount0. DTO observed остаётся отдельным от модельных ping/speeds; fallback не объявляется измерением. OSPF Full, private route active, Ping3/3 по первоначальной проверке пары. + +Изолированный экземпляр тех же compiled routes с настоящими PATCH собственных шаблонов: reject второго шага до передачи → partial/confirmed1, второй cost неизменён; исключение после подтверждённого native PATCH, вместо ACK вызывающему коду → unknown/confirmed1, отдельный GET подтверждает оба фактических изменения. Это инъекция ответа, не физический TCP disconnect. После тестов production API привёл costs к текущему плану. [Native/API/flags протокол](ux-ui/acceptance/ospf-optimizer-2026-10-09.json). + +**Браузер и локальные проверки.** Настоящий deployed frontend/backend без HTTP interception: два устройства×1280/320 =4/4, apply повтор0, receipt/server context, кнопка после опроса, reload URL, document fits/pageerror0. [Browser отчёт](ux-ui/acceptance/browser-ospf-optimizer-2026-10-09.json). Профильные E2E OSPF operations/optimizer apply16/16; optimizer identity/metrics/plan/effective disabled/HTTP, catalog tests и signed JWT RBAC passed. Backend build и scoped frontend tsc passed, Docker frontend production build прошёл. Обычный root tsc захватывает старые ignored tmp build copies и имеет исходные ошибки; scoped config исключает tmp. ESLint новых helper/test/UI прошёл; OSPF прежние2 unused warnings, mikrotik.ts прежние2 prefer-const errors подтверждены на HEAD, новых ошибок нет. Полная регрессия не повторялась. + +**Оснастка/очистка.** Первые два native runs отказались на отсутствующем disabled template, затем instance; оба exact cleanup7 коллекций×2 passed. Read-only discovery проверила proplist/filter/get; numbers оказался неподдерживаемым аргументом, рабочий .id проверен. Отдельная flags fixture cleaned baseline5 коллекций. Финальный run native/browser прошёл, own templates/areas/instances/IP/bridge/GRE удалены по точным ID/атрибутам, исходные IDs7 коллекций×2 совпали. 195.195.195.195/32/default/management routes сохранены. Оба CHR доступны, credentials не менялись, fixture контейнеров/DB записей не осталось. + +**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.86 — BFD/OSPF: раздельные наблюдения реакции и маршрута (2026-10-09) diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 36c7ba30..4f470f65 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -2029,3 +2029,32 @@ HTTP sampling показал withdrawal за5245ms при OSPF dead interval4000 **Результат.** [Очищенный отчёт](ux-ui/acceptance/ospf-bfd-timing-2026-10-09.json): HTTP samples, scoped native logs, initial-attempt summary, recovery и baseline. Все свои firewall/logging/OSPF/BFD/IP/bridge/GRE объекты удалены после exact-ID/атрибутов preflight. Исходные ID10 коллекций×2 совпали; оба CHR доступны. Продукт/зависимости/runtime не менялись, сборки/широкие регрессии не повторялись. Локальный коммит без push. Квота5% использовано после завершения. Настройки протокола подтверждены [MikroTik BFD](https://help.mikrotik.com/docs/spaces/ROS/pages/191299691/BFD), механика memory logging — [MikroTik Log](https://help.mikrotik.com/docs/spaces/ROS/pages/328094/Log). Продолжение87: собственные OSPF templates через production optimizer preview/apply, неизвестные/модельные метрики и частичный отказ без заявления полного успеха; далее recursive diagnostics. Этап12.5 остаётся открытым. + +## 12.5.87 — OSPF optimizer: права, реальные costs и частичный результат (2026-10-09) + +**Изменения.** Три проверки разрешения в OSPF UI приведены к backend mm:network:write. Роль только mm:servers:write не запускает оптимизацию, network writer может её выполнить. Существующие Frame/компоненты/композиция ReUI сохранены; skill ReUI применён для минимальной правки поведения. + +POST optimize при отказе записи возвращает502 и request-local receipt: state partial для подтверждённых шагов перед RouterOS4xx; unknown при потерянном ACK/transport/5xx; rejected при первом явном4xx. applied содержит только подтверждённые interfaces/from/to, confirmedWrites, failedInterface и needsReview. После первого отказа следующие записи не выполняются. Preflight/plan ошибки остаются500 без заявления о записи. Автоматического rollback/retry, durable receipt или межпроцессной блокировки не добавлено. UI сохраняет прежнее сообщение о неподтверждённой операции и повторный опрос. + +**RouterOS7.24.5.** GET может опустить disabled при unset и у interface-template, и у instance/area. GET disabled=false/proplist не раскрывает effective default: отдельный native POST /get с .id/value-name=disabled возвращает ret=false; при disable ret=true; при явно false GET уже содержит false. Добавлено чтение эффективного значения только для пропущенного disabled, до4 одновременно. Значение не угадывается по отсутствию или inactive. Неправильный ответ/отказ/повтор ID блокирует чтение. Явные значения сохраняются, исходный payload не мутируется. Большой набор unset потребует дополнительных RPC; maximum scale/performance этого пути не принят. + +| Проверка | До | После / доказательство | +| --- | --- | --- | +| Права кнопки OSPF | servers:write расходится с API | network:write; обе роли проверены на320px | +| Неявный disabled | strict preview500 | native /get подтверждает состояние templates/instances/areas | +| Частичный отказ | обычный500 без шагов | partial/unknown/rejected и подтверждённые изменения | +| Production preview/apply | Не принято на устройстве | preview без записи, два costs применены на каждом CHR, повтор0 | + +Схема: устройство → достоверные flags/costs → preview → последовательная запись → receipt → повторный опрос. Референс сохранён: [Data Grid Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp). [Экран ПК](ux-ui/screens/ospf-real-optimizer-server1-1280-2026-10-09.png), [320px](ux-ui/screens/ospf-real-optimizer-server1-320-2026-10-09.png); mobile просмотрен, кнопки/cur-opt помещаются в документе. + +**Реальная приёмка.** Свои GRE/link198.18.87.0/30/loopbacks198.18.187.1/32↔2/32, OSPF instances/area, templates с начальными costs50/60. Public interfaces не включались в OSPF, originate-default=never. Production preview сохранил native costs, apply изменил два собственных шаблона на10/20 согласно плану, repeat optimizedCount0. DTO observed остаётся отдельным от модельных ping/speeds; fallback не объявляется измерением. OSPF Full, private route active, Ping3/3 по первоначальной проверке пары. + +Изолированный экземпляр тех же compiled routes с настоящими PATCH собственных шаблонов: reject второго шага до передачи → partial/confirmed1, второй cost неизменён; исключение после подтверждённого native PATCH, вместо ACK вызывающему коду → unknown/confirmed1, отдельный GET подтверждает оба фактических изменения. Это инъекция ответа, не физический TCP disconnect. После тестов production API привёл costs к текущему плану. [Native/API/flags протокол](ux-ui/acceptance/ospf-optimizer-2026-10-09.json). + +**Браузер и локальные проверки.** Настоящий deployed frontend/backend без HTTP interception: два устройства×1280/320 =4/4, apply повтор0, receipt/server context, кнопка после опроса, reload URL, document fits/pageerror0. [Browser отчёт](ux-ui/acceptance/browser-ospf-optimizer-2026-10-09.json). Профильные E2E OSPF operations/optimizer apply16/16; optimizer identity/metrics/plan/effective disabled/HTTP, catalog tests и signed JWT RBAC passed. Backend build и scoped frontend tsc passed, Docker frontend production build прошёл. Обычный root tsc захватывает старые ignored tmp build copies и имеет исходные ошибки; scoped config исключает tmp. ESLint новых helper/test/UI прошёл; OSPF прежние2 unused warnings, mikrotik.ts прежние2 prefer-const errors подтверждены на HEAD, новых ошибок нет. Полная регрессия не повторялась. + +**Оснастка/очистка.** Первые два native runs отказались на отсутствующем disabled template, затем instance; оба exact cleanup7 коллекций×2 passed. Read-only discovery проверила proplist/filter/get; numbers оказался неподдерживаемым аргументом, рабочий .id проверен. Отдельная flags fixture cleaned baseline5 коллекций. Финальный run native/browser прошёл, own templates/areas/instances/IP/bridge/GRE удалены по точным ID/атрибутам, исходные IDs7 коллекций×2 совпали. 195.195.195.195/32/default/management routes сохранены. Оба CHR доступны, credentials не менялись, fixture контейнеров/DB записей не осталось. + +**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 открыт. diff --git a/docs/ux-ui-test-server.md b/docs/ux-ui-test-server.md index eabe6998..dad6a285 100644 --- a/docs/ux-ui-test-server.md +++ b/docs/ux-ui-test-server.md @@ -1,6 +1,6 @@ # Тестовый сервер UX/UI — 2026-10-09 -Сервер161.104.46.222 предоставлен для временного тестирования. Backend ux-routing83/version1.0.0-test.routing83, frontend ux-ipsec77/version1.0.0-test.ipsec77; backend/PG healthy, frontend HTTP/browser passed. Основной блок IPsec77 принят в границах журнала, own fixtures/fake DB rows удалены; каталог flash CHR2 сохранён из-за неподтверждённого ownership. SSH/оба CHR доступны, пароли прежние. +Сервер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 доступны, пароли прежние. | Параметр | Проверенное состояние | | --- | --- | @@ -711,3 +711,5 @@ flowchart LR Актуальное продолжение 2026-10-09: 12.5.77 завершил укрупнённый основной блок IPsec: достоверное чтение, запрет опроса выключенных устройств, совместимый RouterOS7 мастер, раздельные ID клиентов, безопаснее удаление, URL устройства/вкладки, Docker обеих частей и реальная приёмка. Local30/30 + context6/6 + backend67, live browser4/4, PSK и сертификатный защищённый GRE/Ping3/3 на двух CHR; cleanup/DB controls passed. Runtime backend/frontend ux-ipsec77, version1.0.0-test.ipsec77. Следующий12.5.78 — остаточная профильная приёмка IPsec: сопоставление active-peer/online, выдача mode-config, поздние ошибки операций и история восстановления; затем маршрутизация. Этап12.5 открыт. Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md). + +12.5.87 (2026-10-09): OSPF optimizer native/API и4 browser checks passed, собственные объекты очищены, baseline7 коллекций×2 сохранён. Подробности/runtime происхождение — в журнале. diff --git a/docs/ux-ui/acceptance/browser-ospf-optimizer-2026-10-09.json b/docs/ux-ui/acceptance/browser-ospf-optimizer-2026-10-09.json new file mode 100644 index 00000000..bc7602b5 --- /dev/null +++ b/docs/ux-ui/acceptance/browser-ospf-optimizer-2026-10-09.json @@ -0,0 +1,39 @@ +{ + "date": "2026-10-09", + "phase": "2 320", + "cases": [ + { + "serverId": 1, + "width": 1280, + "realApplyReceipt": true, + "repeatedCount": 0, + "documentFits": true, + "urlRestore": true + }, + { + "serverId": 1, + "width": 320, + "realApplyReceipt": true, + "repeatedCount": 0, + "documentFits": true, + "urlRestore": true + }, + { + "serverId": 2, + "width": 1280, + "realApplyReceipt": true, + "repeatedCount": 0, + "documentFits": true, + "urlRestore": true + }, + { + "serverId": 2, + "width": 320, + "realApplyReceipt": true, + "repeatedCount": 0, + "documentFits": true, + "urlRestore": true + } + ], + "errors": [] +} \ No newline at end of file diff --git a/docs/ux-ui/acceptance/ospf-optimizer-2026-10-09.json b/docs/ux-ui/acceptance/ospf-optimizer-2026-10-09.json new file mode 100644 index 00000000..1c94899c --- /dev/null +++ b/docs/ux-ui/acceptance/ospf-optimizer-2026-10-09.json @@ -0,0 +1,830 @@ +{ + "date": "2026-10-09", + "name": "mm-ospf87-1791553001174", + "observations": [ + { + "serverId": 1, + "nativeNeighbor": { + ".id": "*F3FDDC00", + "router-id": "198.18.187.2", + "address": "198.18.87.2", + "interface": "mm-ospf87-1791553001174", + "state": "Full", + "state-changes": "6" + }, + "nativeRoute": { + ".id": "*2014C000", + "dst-address": "198.18.187.2/32", + "gateway": "198.18.87.2%mm-ospf87-1791553001174", + "ospf": "true", + "active": "true", + "distance": "110" + }, + "catalogNeighbors": [ + { + "id": "*F3FDDC00", + "serverId": 1, + "serverName": "CHR", + "serverSite": "", + "serverCountry": "", + "address": "198.18.87.2", + "routerId": "198.18.187.2", + "instance": "mm-ospf87-1791553001174", + "area": "mm-ospf87-1791553001174", + "areaId": "0.0.0.0", + "interface": "mm-ospf87-1791553001174", + "state": "Full", + "uptime": "4s", + "stateChanges": 6, + "priority": null + } + ], + "catalogRoutes": [ + { + "id": "*2014C000", + "serverId": 1, + "serverName": "CHR", + "serverSite": "", + "destination": "198.18.187.2/32", + "type": "Unknown", + "cost": null, + "nextHop": "198.18.87.2", + "via": "mm-ospf87-1791553001174", + "area": "" + } + ], + "pingReceived": 3 + }, + { + "serverId": 2, + "nativeNeighbor": { + ".id": "*F3FDDC00", + "router-id": "198.18.187.1", + "address": "198.18.87.1", + "interface": "mm-ospf87-1791553001174", + "state": "Full", + "state-changes": "5" + }, + "nativeRoute": { + ".id": "*2014C000", + "dst-address": "198.18.187.1/32", + "gateway": "198.18.87.1%mm-ospf87-1791553001174", + "ospf": "true", + "active": "true", + "distance": "110" + }, + "catalogNeighbors": [ + { + "id": "*F3FDDC00", + "serverId": 2, + "serverName": "CHR", + "serverSite": "", + "serverCountry": "", + "address": "198.18.87.1", + "routerId": "198.18.187.1", + "instance": "mm-ospf87-1791553001174", + "area": "mm-ospf87-1791553001174", + "areaId": "0.0.0.0", + "interface": "mm-ospf87-1791553001174", + "state": "Full", + "uptime": "4s", + "stateChanges": 5, + "priority": null + } + ], + "catalogRoutes": [ + { + "id": "*2014C000", + "serverId": 2, + "serverName": "CHR", + "serverSite": "", + "destination": "198.18.187.1/32", + "type": "Unknown", + "cost": null, + "nextHop": "198.18.87.1", + "via": "mm-ospf87-1791553001174", + "area": "" + } + ], + "pingReceived": 3 + } + ], + "cleanup": [ + { + "serverId": 1, + "/routing/ospf/interface-template": true, + "/routing/ospf/area": true, + "/routing/ospf/instance": true, + "/interface/gre": true, + "/interface/bridge": true, + "/ip/address": true, + "/ip/route": true + }, + { + "serverId": 2, + "/routing/ospf/interface-template": true, + "/routing/ospf/area": true, + "/routing/ospf/instance": true, + "/interface/gre": true, + "/interface/bridge": true, + "/ip/address": true, + "/ip/route": true + } + ], + "currentRoutes": [ + { + ".id": "*20145050", + "dst-address": "198.18.87.0/30", + "gateway": "mm-ospf87-1791553001174", + "active": "true", + "distance": "0", + "routing-table": "main" + }, + { + ".id": "*2014C000", + "dst-address": "198.18.187.1/32", + "gateway": "198.18.87.1%mm-ospf87-1791553001174", + "active": "true", + "ospf": "true", + "distance": "110", + "routing-table": "main" + }, + { + ".id": "*20145060", + "dst-address": "198.18.187.2/32", + "gateway": "mm-ospf87-1791553001174-loop", + "active": "true", + "distance": "0", + "routing-table": "main" + } + ], + "optimizer": [ + { + "serverId": 1, + "preview": { + "serverId": 1, + "serverName": "CHR", + "pingWeight": 60, + "interfacesTotal": 2, + "changedCount": 2, + "unchangedCount": 0, + "interfaces": [ + { + "observed": { + "ping": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "dl": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "ul": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + } + }, + "id": "*1A", + "interface": "mm-ospf87-1791553001174-loop", + "currentCost": 60, + "optimalCost": 10, + "score": 58, + "pingMs": 7, + "dlMbps": 10, + "ulMbps": 10 + }, + { + "observed": { + "ping": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "dl": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "ul": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + } + }, + "id": "*19", + "interface": "mm-ospf87-1791553001174", + "currentCost": 50, + "optimalCost": 20, + "score": 33, + "pingMs": 76, + "dlMbps": 10, + "ulMbps": 10 + } + ], + "changes": [ + { + "observed": { + "ping": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "dl": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "ul": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + } + }, + "id": "*1A", + "interface": "mm-ospf87-1791553001174-loop", + "currentCost": 60, + "optimalCost": 10, + "score": 58, + "pingMs": 7, + "dlMbps": 10, + "ulMbps": 10 + }, + { + "observed": { + "ping": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "dl": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "ul": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + } + }, + "id": "*19", + "interface": "mm-ospf87-1791553001174", + "currentCost": 50, + "optimalCost": 20, + "score": 33, + "pingMs": 76, + "dlMbps": 10, + "ulMbps": 10 + } + ] + }, + "applied": { + "serverId": 1, + "serverName": "CHR", + "pingWeight": 60, + "optimizedCount": 2, + "applied": [ + { + "interface": "mm-ospf87-1791553001174-loop", + "from": 60, + "to": 10 + }, + { + "interface": "mm-ospf87-1791553001174", + "from": 50, + "to": 20 + } + ], + "interfaces": [ + { + "observed": { + "ping": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "dl": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "ul": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + } + }, + "id": "*1A", + "interface": "mm-ospf87-1791553001174-loop", + "currentCost": 60, + "optimalCost": 10, + "score": 58, + "pingMs": 7, + "dlMbps": 10, + "ulMbps": 10 + }, + { + "observed": { + "ping": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "dl": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "ul": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + } + }, + "id": "*19", + "interface": "mm-ospf87-1791553001174", + "currentCost": 50, + "optimalCost": 20, + "score": 33, + "pingMs": 76, + "dlMbps": 10, + "ulMbps": 10 + } + ] + }, + "repeatedCount": 0, + "previewReadOnly": true, + "nativeCostsConfirmed": true + }, + { + "serverId": 2, + "preview": { + "serverId": 2, + "serverName": "CHR", + "pingWeight": 60, + "interfacesTotal": 2, + "changedCount": 2, + "unchangedCount": 0, + "interfaces": [ + { + "observed": { + "ping": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "dl": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "ul": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + } + }, + "id": "*18", + "interface": "mm-ospf87-1791553001174-loop", + "currentCost": 60, + "optimalCost": 10, + "score": 36, + "pingMs": 69, + "dlMbps": 10, + "ulMbps": 10 + }, + { + "observed": { + "ping": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "dl": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "ul": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + } + }, + "id": "*17", + "interface": "mm-ospf87-1791553001174", + "currentCost": 50, + "optimalCost": 20, + "score": 33, + "pingMs": 76, + "dlMbps": 10, + "ulMbps": 10 + } + ], + "changes": [ + { + "observed": { + "ping": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "dl": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "ul": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + } + }, + "id": "*18", + "interface": "mm-ospf87-1791553001174-loop", + "currentCost": 60, + "optimalCost": 10, + "score": 36, + "pingMs": 69, + "dlMbps": 10, + "ulMbps": 10 + }, + { + "observed": { + "ping": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "dl": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "ul": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + } + }, + "id": "*17", + "interface": "mm-ospf87-1791553001174", + "currentCost": 50, + "optimalCost": 20, + "score": 33, + "pingMs": 76, + "dlMbps": 10, + "ulMbps": 10 + } + ] + }, + "applied": { + "serverId": 2, + "serverName": "CHR", + "pingWeight": 60, + "optimizedCount": 2, + "applied": [ + { + "interface": "mm-ospf87-1791553001174-loop", + "from": 60, + "to": 10 + }, + { + "interface": "mm-ospf87-1791553001174", + "from": 50, + "to": 20 + } + ], + "interfaces": [ + { + "observed": { + "ping": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "dl": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "ul": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + } + }, + "id": "*18", + "interface": "mm-ospf87-1791553001174-loop", + "currentCost": 60, + "optimalCost": 10, + "score": 36, + "pingMs": 69, + "dlMbps": 10, + "ulMbps": 10 + }, + { + "observed": { + "ping": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "dl": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + }, + "ul": { + "value": null, + "source": "unknown", + "sourceId": null, + "measuredAt": null + } + }, + "id": "*17", + "interface": "mm-ospf87-1791553001174", + "currentCost": 50, + "optimalCost": 20, + "score": 33, + "pingMs": 76, + "dlMbps": 10, + "ulMbps": 10 + } + ] + }, + "repeatedCount": 0, + "previewReadOnly": true, + "nativeCostsConfirmed": true + } + ], + "injected": [ + { + "mode": "partial", + "response": { + "error": "API \u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u0435 \u043e\u043f\u0442\u0438\u043c\u0438\u0437\u0430\u0446\u0438\u0438 OSPF. \u041e\u0431\u043d\u043e\u0432\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u043f\u0435\u0440\u0435\u0434 \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0439 \u043f\u043e\u043f\u044b\u0442\u043a\u043e\u0439. MikrotikError: RouterOS API error 403 on /routing/ospf/interface-template/*19: injected rejection before transmission", + "serverId": 1, + "state": "partial", + "confirmedWrites": 1, + "applied": [ + { + "interface": "mm-ospf87-1791553001174-loop", + "from": 50, + "to": 10 + } + ], + "failedInterface": "mm-ospf87-1791553001174", + "needsReview": true + }, + "nativeCosts": [ + { + "id": "*1A", + "interface": "mm-ospf87-1791553001174-loop", + "cost": 10 + }, + { + "id": "*19", + "interface": "mm-ospf87-1791553001174", + "cost": 60 + } + ], + "faultInjection": true, + "physicalDisconnect": false + }, + { + "mode": "unknown", + "response": { + "error": "API \u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u0435 \u043e\u043f\u0442\u0438\u043c\u0438\u0437\u0430\u0446\u0438\u0438 OSPF. \u041e\u0431\u043d\u043e\u0432\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u043f\u0435\u0440\u0435\u0434 \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0439 \u043f\u043e\u043f\u044b\u0442\u043a\u043e\u0439. Error: injected lost acknowledgement after native PATCH", + "serverId": 1, + "state": "unknown", + "confirmedWrites": 1, + "applied": [ + { + "interface": "mm-ospf87-1791553001174-loop", + "from": 50, + "to": 10 + } + ], + "failedInterface": "mm-ospf87-1791553001174", + "needsReview": true + }, + "nativeCosts": [ + { + "id": "*1A", + "interface": "mm-ospf87-1791553001174-loop", + "cost": 10 + }, + { + "id": "*19", + "interface": "mm-ospf87-1791553001174", + "cost": 20 + } + ], + "faultInjection": true, + "physicalDisconnect": false + } + ], + "effectiveFlags": { + "name": "mm-flags87-1791552793732", + "reads": [ + { + "mode": "unset", + "row": { + ".id": "*18", + "area": "mm-flags87-1791552793732", + "comment": "mm-flags87-1791552793732", + "cost": "50", + "dead-interval": "40s", + "hello-interval": "10s", + "inactive": "false", + "instance-id": "0", + "interfaces": "mm-flags87-1791552793732", + "passive": "", + "priority": "128", + "retransmit-interval": "5s", + "transmit-delay": "1s", + "type": "broadcast" + }, + ".id": { + "ret": "false" + }, + "numbers": "RouterOS API error 400 on /routing/ospf/interface-template/get: {\"detail\":\"unknown parameter numbers\",\"error\":400,\"message\":\"Bad Request\"}", + "queryFalse": [], + "queryTrue": [] + }, + { + "mode": "true", + "row": { + ".id": "*18", + "area": "mm-flags87-1791552793732", + "comment": "mm-flags87-1791552793732", + "cost": "50", + "dead-interval": "40s", + "disabled": "true", + "hello-interval": "10s", + "inactive": "true", + "instance-id": "0", + "interfaces": "mm-flags87-1791552793732", + "passive": "", + "priority": "128", + "retransmit-interval": "5s", + "transmit-delay": "1s", + "type": "broadcast" + }, + ".id": { + "ret": "true" + }, + "numbers": "RouterOS API error 400 on /routing/ospf/interface-template/get: {\"detail\":\"unknown parameter numbers\",\"error\":400,\"message\":\"Bad Request\"}", + "queryFalse": [], + "queryTrue": [ + { + ".id": "*18", + "area": "mm-flags87-1791552793732", + "comment": "mm-flags87-1791552793732", + "cost": "50", + "dead-interval": "40s", + "disabled": "true", + "hello-interval": "10s", + "inactive": "true", + "instance-id": "0", + "interfaces": "mm-flags87-1791552793732", + "passive": "", + "priority": "128", + "retransmit-interval": "5s", + "transmit-delay": "1s", + "type": "broadcast" + } + ] + }, + { + "mode": "false", + "row": { + ".id": "*18", + "area": "mm-flags87-1791552793732", + "comment": "mm-flags87-1791552793732", + "cost": "50", + "dead-interval": "40s", + "disabled": "false", + "hello-interval": "10s", + "inactive": "false", + "instance-id": "0", + "interfaces": "mm-flags87-1791552793732", + "passive": "", + "priority": "128", + "retransmit-interval": "5s", + "transmit-delay": "1s", + "type": "broadcast" + }, + ".id": { + "ret": "false" + }, + "numbers": "RouterOS API error 400 on /routing/ospf/interface-template/get: {\"detail\":\"unknown parameter numbers\",\"error\":400,\"message\":\"Bad Request\"}", + "queryFalse": [ + { + ".id": "*18", + "area": "mm-flags87-1791552793732", + "comment": "mm-flags87-1791552793732", + "cost": "50", + "dead-interval": "40s", + "disabled": "false", + "hello-interval": "10s", + "inactive": "false", + "instance-id": "0", + "interfaces": "mm-flags87-1791552793732", + "passive": "", + "priority": "128", + "retransmit-interval": "5s", + "transmit-delay": "1s", + "type": "broadcast" + } + ], + "queryTrue": [] + } + ], + "cleanup": true, + "otherCollections": [ + { + "path": "/routing/ospf/instance", + "row": { + ".id": "*C", + "inactive": "false", + "name": "mm-flags87-1791552793732", + "originate-default": "never", + "router-id": "198.18.187.254", + "version": "2", + "vrf": "main" + }, + "effectiveDisabled": { + "ret": "false" + } + }, + { + "path": "/routing/ospf/area", + "row": { + ".id": "*D", + "area-id": "0.0.0.0", + "inactive": "false", + "instance": "mm-flags87-1791552793732", + "name": "mm-flags87-1791552793732", + "type": "default" + }, + "effectiveDisabled": { + "ret": "false" + } + } + ] + }, + "initialFailures": [ + { + "error": "Missing disabled on OSPF template", + "cleanupPassed": true + }, + { + "error": "Missing disabled on OSPF instance during Router ID owner resolution", + "cleanupPassed": true + } + ], + "runtime": { + "backend": "ux-optimizer87c", + "frontend": "ux-optimizer87", + "revisionLabelsAreGitSha": false + } +} \ No newline at end of file diff --git a/docs/ux-ui/screens/ospf-real-optimizer-server1-1280-2026-10-09.png b/docs/ux-ui/screens/ospf-real-optimizer-server1-1280-2026-10-09.png new file mode 100644 index 00000000..58042c5f Binary files /dev/null and b/docs/ux-ui/screens/ospf-real-optimizer-server1-1280-2026-10-09.png differ diff --git a/docs/ux-ui/screens/ospf-real-optimizer-server1-320-2026-10-09.png b/docs/ux-ui/screens/ospf-real-optimizer-server1-320-2026-10-09.png new file mode 100644 index 00000000..c2037a85 Binary files /dev/null and b/docs/ux-ui/screens/ospf-real-optimizer-server1-320-2026-10-09.png differ diff --git a/docs/ux-ui/screens/ospf-real-optimizer-server2-1280-2026-10-09.png b/docs/ux-ui/screens/ospf-real-optimizer-server2-1280-2026-10-09.png new file mode 100644 index 00000000..8f8d3406 Binary files /dev/null and b/docs/ux-ui/screens/ospf-real-optimizer-server2-1280-2026-10-09.png differ diff --git a/docs/ux-ui/screens/ospf-real-optimizer-server2-320-2026-10-09.png b/docs/ux-ui/screens/ospf-real-optimizer-server2-320-2026-10-09.png new file mode 100644 index 00000000..4e956337 Binary files /dev/null and b/docs/ux-ui/screens/ospf-real-optimizer-server2-320-2026-10-09.png differ diff --git a/tests/e2e/ospf-operations.spec.ts b/tests/e2e/ospf-operations.spec.ts index 50f674b2..1e7764f5 100644 --- a/tests/e2e/ospf-operations.spec.ts +++ b/tests/e2e/ospf-operations.spec.ts @@ -69,3 +69,30 @@ test("OSPF: read-права не разрешают оптимизацию ус await expect(page.getByRole("button", { name: "Оптимизировать OSPF", exact: true })).toBeDisabled() expect(writes).toBe(0) }) + +for (const permission of ["mm:network:write", "mm:servers:write"]) { + test("OSPF: право оптимизации совпадает с backend — " + permission, async ({ page }) => { + await page.setViewportSize({ width: 320, height: 1000 }) + const payload = { sub: "test", apps: ["mm"], permissions: ["mm:network:read", "mm:servers:read", permission], exp: 4102444800 } + await page.addInitScript(value => localStorage.setItem("mmapp_token", "test." + btoa(JSON.stringify(value)) + ".test"), payload) + let writes = 0 + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + if (path === "/api/servers/1/ospf/optimize") { writes++; return r.fulfill({ json: result }) } + return r.fulfill({ json: path === "/api/auth/config" ? { required: true } : path === "/api/servers" ? [device] : path === "/api/ospf/catalog" ? catalog : {} }) + }) + await page.goto("/ospf?server=1&tab=interfaces") + const button = page.getByRole("button", { name: "Оптимизировать OSPF", exact: true }) + if (permission === "mm:network:write") { + await expect(button).toBeEnabled(); await button.click() + await expect.poll(() => writes).toBe(1) + await expect(page.getByText("API подтвердил оптимизацию OSPF: 1 интерфейсов", { exact: true })).toBeVisible() + } else { + await expect(button).toBeDisabled() + await button.evaluate(el => (el as HTMLButtonElement).click()) + expect(writes).toBe(0) + } + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + }) +}