From 644ddb64b3e4df9d8a75de021856536a26bae2b8 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 10:39:37 +0700 Subject: [PATCH] =?UTF-8?q?fix(api):=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=87=D0=B0=D1=81=D1=82=D0=B8=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D1=8B=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 5 +- backend/src/routes/containers-state.test.ts | 18 +++++++ backend/src/routes/containers.ts | 14 ++++- backend/src/services/containers-live.ts | 9 ++++ backend/src/services/containers-state.test.ts | 34 ++++++++++++ backend/src/services/containers-state.ts | 54 +++++++++++++++++++ docs/ux-ui-progress.md | 11 +++- docs/ux-ui-stage-11a.md | 10 ++++ packages/contracts/package.json | 4 ++ packages/contracts/src/containers.ts | 37 +++++++++++++ 10 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 backend/src/routes/containers-state.test.ts create mode 100644 backend/src/services/containers-state.test.ts create mode 100644 backend/src/services/containers-state.ts create mode 100644 packages/contracts/src/containers.ts diff --git a/backend/package.json b/backend/package.json index d315425a..8eecfa9c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,9 +26,10 @@ "test:ros-file": "tsx src/services/ros-file-contents.test.ts", "test:backups": "tsx src/services/s3-backup-client.test.ts", "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts", - "test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file", + "test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers", "test:geoip": "tsx src/services/traffic-flow-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/routes/ospf-optimizer.test.ts", + "test:containers": "tsx src/services/containers-state.test.ts && tsx src/routes/containers-state.test.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", diff --git a/backend/src/routes/containers-state.test.ts b/backend/src/routes/containers-state.test.ts new file mode 100644 index 00000000..216d1c45 --- /dev/null +++ b/backend/src/routes/containers-state.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod" +import { createContainersRoutes } from "./containers.js" +import { permissionForRequest } from "../lib/permissions.js" +const empty = {collectedAt:new Date().toISOString(),servers:[]} +let value:unknown=empty,failed=false +const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler) +await app.register(createContainersRoutes({getState:async()=>{if(failed)throw new Error("DB unavailable");return value as typeof empty}}),{prefix:"/api"}) +const read=()=>app.inject({method:"GET",url:"/api/containers/state"}) +assert.equal((await read()).statusCode,200);assert.deepEqual((await read()).json().servers,[]) +value={...empty,servers:[{serverId:1,collectedAt:empty.collectedAt,availability:"error",containers:[],failures:[{resource:"containers",error:"offline"}]}]} +assert.equal((await read()).statusCode,200);assert.equal((await read()).json().servers[0].availability,"error") +for(const bad of [{containers:[]},{...empty,servers:[{serverId:1}]},{...empty,collectedAt:"yesterday"}]){value=bad;assert.equal((await read()).statusCode,502)} +failed=true;assert.equal((await read()).statusCode,500) +assert.equal(permissionForRequest("GET","/api/containers/state"),"mm:dashboard:read") +assert.equal(permissionForRequest("POST","/api/servers/1/containers/start"),"mm:servers:write") +await app.close();console.log("Containers HTTP: explicit empty/partial error, invalid state 502, DB failure 500, existing permission contract") diff --git a/backend/src/routes/containers.ts b/backend/src/routes/containers.ts index d0a7aed1..c54eb2b2 100644 --- a/backend/src/routes/containers.ts +++ b/backend/src/routes/containers.ts @@ -1,8 +1,10 @@ +import { containersStateSchema } from "@mmapp/contracts/containers" import { z } from "zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { getEnabledServerById, listContainers, + listContainersState, listContainersForServer, removeContainer, restartContainer, @@ -18,7 +20,13 @@ const RosIdBodySchema = z.object({ type RosIdBody = z.infer type MutateFn = typeof startContainer -const containersRoutes: FastifyPluginAsyncZod = async (app) => { +export function createContainersRoutes(deps: { getState?: typeof listContainersState } = {}): FastifyPluginAsyncZod { + return async (app) => { + app.get("/containers/state", async (_req, reply) => { + const state = containersStateSchema.safeParse(await (deps.getState ?? listContainersState)()) + if (!state.success) return reply.status(502).send({ error: "Некорректное состояние каталога контейнеров" }) + return reply.send(state.data) + }) app.get("/containers", async (_req, reply) => { const containers = await listContainers() return reply.send({ containers }) @@ -55,4 +63,6 @@ const containersRoutes: FastifyPluginAsyncZod = async (app) => { registerMutate("/servers/:id/containers/remove", removeContainer) } -export default containersRoutes +} + +export default createContainersRoutes() diff --git a/backend/src/services/containers-live.ts b/backend/src/services/containers-live.ts index 5f78eb8c..fa8b05c1 100644 --- a/backend/src/services/containers-live.ts +++ b/backend/src/services/containers-live.ts @@ -213,3 +213,12 @@ export async function getEnabledServerById(serverId: string | number) { if (!Number.isFinite(id)) return null return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null } + +export async function listContainersState() { + const { collectContainersState } = await import("./containers-state.js") + const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true)) + return collectContainersState(enabledServers, server => { + const client = MikrotikClient.fromServer(server) + return path => client.get(path) + }) +} diff --git a/backend/src/services/containers-state.test.ts b/backend/src/services/containers-state.test.ts new file mode 100644 index 00000000..d914c842 --- /dev/null +++ b/backend/src/services/containers-state.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict" +import { readContainerServerState, collectContainersState } from "./containers-state.js" +import { MikrotikError } from "./mikrotik.js" +import { containersStateSchema } from "@mmapp/contracts/containers" + +const server = { id: 1, name: "Router" } as Parameters[0] +const row = { ".id": "*A", name: "nginx", "remote-image": "nginx:alpine", interface: "veth1", envlist: "e", mounts: "m", status: "running", "start-on-boot": "yes", cpu: "0", "memory-high": "0" } +const read = async (path: string): Promise => path === "/container" ? [row] : path === "/container/envs" ? [{name:"e",key:"A",value:"B"}] : [{name:"m",src:"/disk",dst:"/data"}] +const good = await readContainerServerState(server, read) +assert.equal(good.availability, "available"); assert.equal(good.containers[0].status,"running") +assert.equal(good.containers[0].cpu,0);assert.equal(good.containers[0].memMb,0);assert.equal(good.containers[0].startOnBoot,true) +assert.deepEqual(good.containers[0].envs,[{key:"A",value:"B"}]);assert.deepEqual(good.containers[0].mounts,[{src:"/disk",dst:"/data"}]) +const unknown = await readContainerServerState(server, async path => path === "/container" ? [{".id":"*B",name:"pending",status:"extracting"}] : []) +assert.equal(unknown.containers[0].status,"unknown");assert.equal(unknown.containers[0].startOnBoot,null);assert.equal(unknown.containers[0].image,"") +const empty = await readContainerServerState(server, async()=>[]) +assert.equal(empty.availability,"available");assert.equal(empty.containers.length,0) +for (const raw of [{containers:[]},[{}],[row,row]]) { + const result = await readContainerServerState(server, async()=>raw) + assert.equal(result.availability,"error");assert.equal(result.failures[0].resource,"containers") +} +for(const status of [401,403,500]){const result=await readContainerServerState(server,async()=>{throw new MikrotikError(status,"/container","refused")});assert.equal(result.availability,"error")} +const unsupported=await readContainerServerState(server,async()=>{throw new MikrotikError(404,"/container","no such command")});assert.equal(unsupported.availability,"unsupported");assert.deepEqual(unsupported.failures,[]) +for(const resource of ["envs","mounts"]){const result=await readContainerServerState(server,async path=>{if(path==="/container/"+resource)throw new Error("dependency refused");return read(path)});assert.equal(result.availability,"available");assert.equal(result.containers.length,1);assert.equal(result.failures[0].resource,resource)} +const badDependency=await readContainerServerState(server,async path=>path==="/container/envs"?{}:read(path));assert.equal(badDependency.failures[0].resource,"envs") +const aggregate=await collectContainersState([server,{...server,id:2}],srv=>srv.id===1?read:async()=>{throw new Error("offline")});assert.equal(aggregate.servers[0].containers.length,1);assert.equal(aggregate.servers[1].availability,"error") +assert.equal((await collectContainersState([],()=>read)).servers.length,0) +for (const value of [ + {...aggregate,servers:[good,good]}, + {...aggregate,servers:[{...good,serverId:2}]}, + {...aggregate,servers:[{...good,availability:"error",failures:[]}]}, + {...aggregate,servers:[{...good,containers:[good.containers[0],good.containers[0]]}]}, + {...aggregate,servers:[{...unsupported,failures:[{resource:"envs",error:"x"}]}]}, +]) assert.equal(containersStateSchema.safeParse(value).success,false) +console.log("Containers state: independent devices, malformed/duplicate IDs, unknown status/boot, known empty, missing package, partial dependencies, zeros and envelope validation; no DB/RouterOS") diff --git a/backend/src/services/containers-state.ts b/backend/src/services/containers-state.ts new file mode 100644 index 00000000..bdf13e22 --- /dev/null +++ b/backend/src/services/containers-state.ts @@ -0,0 +1,54 @@ +import { z } from "zod" +import { containersStateSchema, containerServerStateSchema, type ContainersState, type ContainerServerState } from "@mmapp/contracts/containers" +import { mapContainerRow } from "./containers-live.js" +import { MikrotikError } from "./mikrotik.js" + +type ServerRow = Parameters[0] +type Reader = (path: string) => Promise +const rawSchema = z.array(z.object({ + ".id": z.string().min(1), name: z.string().optional(), tag: z.string().optional(), "remote-image": z.string().optional(), + interface: z.string().optional(), envlist: z.string().optional(), mounts: z.string().optional(), + cmd: z.string().optional(), "start-on-boot": z.string().optional(), comment: z.string().optional(), + status: z.string().optional(), "memory-high": z.string().optional(), cpu: z.string().optional(), +})).superRefine((rows, ctx) => { + if (new Set(rows.map(r => r[".id"])).size !== rows.length) ctx.addIssue({ code: "custom", message: "Дубли RouterOS ID" }) +}) +const envSchema = z.array(z.object({ name: z.string().optional(), key: z.string().optional(), value: z.string().optional() })) +const mountSchema = z.array(z.object({ name: z.string().optional(), src: z.string().optional(), dst: z.string().optional() })) +const errorMessage = (error: unknown) => error instanceof z.ZodError ? "Некорректный ответ RouterOS" : (error instanceof Error ? error.message.slice(0, 500) : "Ошибка RouterOS") +function missingPackage(error: unknown) { + return error instanceof MikrotikError && (error.statusCode === 404 || /no such command|unknown command/i.test(error.body)) +} +export async function readContainerServerState(server: ServerRow, read: Reader): Promise { + const base = { serverId: server.id, collectedAt: new Date().toISOString(), containers: [], failures: [] } + let raw: z.infer + try { raw = rawSchema.parse(await read("/container")) } + catch (error) { + return containerServerStateSchema.parse({ ...base, collectedAt: new Date().toISOString(), availability: missingPackage(error) ? "unsupported" : "error", + failures: missingPackage(error) ? [] : [{ resource: "containers", error: errorMessage(error) }] }) + } + const collectedAt = new Date().toISOString() + const [envs, mounts] = await Promise.allSettled([ + read("/container/envs").then(value => envSchema.parse(value)), + read("/container/mounts").then(value => mountSchema.parse(value)), + ]) + const failures: ContainerServerState["failures"] = [] + if (envs.status === "rejected") failures.push({ resource: "envs", error: errorMessage(envs.reason) }) + if (mounts.status === "rejected") failures.push({ resource: "mounts", error: errorMessage(mounts.reason) }) + const containers = raw.map((row, index) => { + const mapped = mapContainerRow(server, row, envs.status === "fulfilled" ? envs.value : [], mounts.status === "fulfilled" ? mounts.value : [], index) + const status = (row.status ?? "").toLowerCase() + const boot = row["start-on-boot"] + const mem = Number.parseInt(row["memory-high"] ?? "", 10) + return { ...mapped, image: row["remote-image"] ? mapped.image : "", tag: row["remote-image"] ? mapped.tag : "", + status: status === "running" || status === "stopped" ? status : status === "error" || status === "failed" ? "error" : "unknown", + startOnBoot: boot === "true" || boot === "yes" ? true : boot === "false" || boot === "no" ? false : null, + cpu: mapped.cpu !== undefined && mapped.cpu >= 0 && mapped.cpu <= 100 ? mapped.cpu : undefined, + memMb: Number.isFinite(mem) && mem >= 0 ? Math.round(mem / (1024 * 1024)) : undefined } + }) + return containerServerStateSchema.parse({ serverId: server.id, collectedAt, availability: "available", containers, failures }) +} +export async function collectContainersState(servers: ServerRow[], makeReader: (server: ServerRow) => Reader): Promise { + const states = await Promise.all(servers.map(server => readContainerServerState(server, makeReader(server)))) + return containersStateSchema.parse({ collectedAt: new Date().toISOString(), servers: states }) +} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 6b145725..3863b066 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -27,7 +27,7 @@ | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | -| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.8; пользователи приняты локально, далее контейнеры | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.9; пользователи приняты локально, state API контейнеров подготовлен | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11А.9 — достоверный backend каталог контейнеров (2026-10-01) + +- Добавлен GET /api/containers/state с общим collectedAt и состояниями каждого enabled сервера. available + [] означает подтверждённую пустую загрузку, unsupported — отсутствие /container, error — отказ чтения. Сбой одного устройства сохраняет остальные. +- Загрузка envs/mounts независима, partial failures явно включены в контракт. Неизвестные status/startOnBoot не становятся stopped/false; отсутствующий образ не подменяется именем контейнера; нулевые CPU/RAM сохранены. +- Runtime контракт отклоняет отсутствующие/дубли RouterOS ID, чужой serverId, неверные состояния/даты и дубли устройств/ошибок. Новый reader не выдаёт синтетический rosId для сетевого действия. +- Старые /containers и /servers/:id/containers, mapper и сетевые операции сохранены для совместимости. RBAC соответствует существующим правилам: каталог dashboard read, команды server write; новых разрешений не введено. +- Backend build, профильный lint, новые service/HTTP tests и старый mapper test прошли. test:containers включён в общий test. Тесты без PostgreSQL/RouterOS; реальные чтения ещё требуют внешней приёмки. +- Далее 11А.10: frontend state reader, независимые серверы/контейнеры, последний снимок, unknown KPI, свежесть и защита смены источника. Затем подтверждённые команды, доступность/ПК/телефон. + ## Блок 11А.8 — доступность и локальная приёмка пользователей (2026-10-01) - Существующие ReUI Stepper/Data Grid/Frame сохранены. Семантические токены, явные имена действий/прав, aria-pressed/expanded, клавиатурное раскрытие строки, подпись текущего шага. Vendor компоненты и тема не изменены; checklist ReUI проверен. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index c0a452d1..100dfe53 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -106,3 +106,13 @@ - Пользователи приняты в границах локальных HTTP-подмен. Реальные Portal/PostgreSQL/RouterOS остаются внешней приёмкой; прежние DB binding тесты skipped. - Далее 11А.9: достоверный state API контейнеров. Аудит выявил превращение отказов устройств/envs/mounts в пустые списки, неизвестного статуса в stopped и недостоверное общее empty. Затем подключение frontend, операции и UX; сертификаты/backup ещё не приняты. + +## Блок 11А.9 — достоверный backend каталог контейнеров (2026-10-01) + +- Добавлен GET /api/containers/state с общим collectedAt и состояниями каждого enabled сервера. available + [] означает подтверждённую пустую загрузку, unsupported — отсутствие /container, error — отказ чтения. Сбой одного устройства сохраняет остальные. +- Загрузка envs/mounts независима, partial failures явно включены в контракт. Неизвестные status/startOnBoot не становятся stopped/false; отсутствующий образ не подменяется именем контейнера; нулевые CPU/RAM сохранены. +- Runtime контракт отклоняет отсутствующие/дубли RouterOS ID, чужой serverId, неверные состояния/даты и дубли устройств/ошибок. Новый reader не выдаёт синтетический rosId для сетевого действия. +- Старые /containers и /servers/:id/containers, mapper и сетевые операции сохранены для совместимости. RBAC соответствует существующим правилам: каталог dashboard read, команды server write; новых разрешений не введено. +- Backend build, профильный lint, новые service/HTTP tests и старый mapper test прошли. test:containers включён в общий test. Тесты без PostgreSQL/RouterOS; реальные чтения ещё требуют внешней приёмки. +- Далее 11А.10: frontend state reader, независимые серверы/контейнеры, последний снимок, unknown KPI, свежесть и защита смены источника. Затем подтверждённые команды, доступность/ПК/телефон. + diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 103177d6..8820d65a 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -57,6 +57,10 @@ "./statistics": { "types": "./dist/statistics.d.ts", "default": "./dist/statistics.js" + }, + "./containers": { + "types": "./dist/containers.d.ts", + "default": "./dist/containers.js" } }, "dependencies": { diff --git a/packages/contracts/src/containers.ts b/packages/contracts/src/containers.ts new file mode 100644 index 00000000..38259164 --- /dev/null +++ b/packages/contracts/src/containers.ts @@ -0,0 +1,37 @@ +import { z } from "zod" + +const serverId = z.number().int().positive().safe() +const time = z.string().datetime({ offset: true }) +export const containerStateRowSchema = z.object({ + id: z.string().min(1), rosId: z.string().min(1), name: z.string().min(1), + serverId: z.string().regex(/^[1-9]\d*$/), + image: z.string(), tag: z.string(), status: z.enum(["running", "stopped", "error", "unknown"]), + envs: z.array(z.object({ key: z.string().min(1), value: z.string() })), + mounts: z.array(z.object({ dst: z.string().min(1), src: z.string().optional() })), + interfaces: z.array(z.string().min(1)), cmd: z.string().optional(), + startOnBoot: z.boolean().nullable(), comment: z.string(), uptime: z.string().optional(), + cpu: z.number().finite().min(0).max(100).optional(), memMb: z.number().finite().nonnegative().optional(), +}) +export const containerServerStateSchema = z.object({ + serverId, collectedAt: time, availability: z.enum(["available", "unsupported", "error"]), + containers: z.array(containerStateRowSchema), + failures: z.array(z.object({ resource: z.enum(["containers", "envs", "mounts"]), error: z.string().min(1) })), +}).superRefine((value, ctx) => { + const ids = new Set() + for (const row of value.containers) { + if (row.serverId !== String(value.serverId) || row.id !== value.serverId + "-" + row.rosId || ids.has(row.rosId)) ctx.addIssue({ code: "custom", message: "Контейнеры не соответствуют устройству или содержат дубли" }) + ids.add(row.rosId) + } + const resources = value.failures.map(f => f.resource) + if (new Set(resources).size !== resources.length) ctx.addIssue({ code: "custom", message: "Дубли ошибок ресурсов" }) + if (value.availability !== "available" && value.containers.length) ctx.addIssue({ code: "custom", message: "Неуспешная загрузка содержит контейнеры" }) + if (value.availability === "error" && !resources.includes("containers") || value.availability === "available" && resources.includes("containers") || value.availability === "unsupported" && resources.length) ctx.addIssue({ code: "custom", message: "Состояние не соответствует ошибкам" }) +}) +export const containersStateSchema = z.object({ + collectedAt: time, servers: z.array(containerServerStateSchema), +}).superRefine((value, ctx) => { + if (new Set(value.servers.map(s => s.serverId)).size !== value.servers.length) ctx.addIssue({ code: "custom", message: "Дубли устройств" }) +}) +export type ContainerStateRow = z.infer +export type ContainerServerState = z.infer +export type ContainersState = z.infer