diff --git a/backend/package.json b/backend/package.json index fbb3adaf..e47db51c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -36,7 +36,7 @@ "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/geoip-download.test.ts && 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/services/ospf-effective-disabled.test.ts && tsx src/routes/ospf-optimizer.test.ts", - "test:containers": "tsx src/services/containers-state.test.ts && tsx src/services/container-removal.test.ts && tsx src/routes/containers-state.test.ts", + "test:containers": "tsx src/services/containers-live.test.ts && tsx src/services/containers-state.test.ts && tsx src/services/container-removal.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", "test:all": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && 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:scheduler && 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 && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database && npm run test:server-privacy", diff --git a/backend/src/routes/containers-state.test.ts b/backend/src/routes/containers-state.test.ts index 8ec2b9d5..f666a836 100644 --- a/backend/src/routes/containers-state.test.ts +++ b/backend/src/routes/containers-state.test.ts @@ -17,14 +17,21 @@ 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") -let enabled = true, missing = false, calls = 0, removalError: Error | undefined +let enabled = true, missing = false, calls = 0, reads = 0, failRead = false, removalError: Error | undefined +const readError = new Error("RouterOS refused") const mutations = Fastify();mutations.setValidatorCompiler(validatorCompiler);mutations.setSerializerCompiler(serializerCompiler) -await mutations.register(createContainersRoutes({getServer:async()=>missing?null:({id:1,enabled} as NonNullable[0]>["getServer"]>>>),remove:async()=>{calls++;if(removalError)throw removalError}}),{prefix:"/api"}) +await mutations.register(createContainersRoutes({getServer:async()=>missing?null:({id:1,enabled} as NonNullable[0]>["getServer"]>>>),getLegacyCatalog:async()=>{if(failRead)throw readError;return []},getServerContainers:async()=>{reads++;if(failRead)throw readError;return []},remove:async()=>{calls++;if(removalError)throw removalError}}),{prefix:"/api"}) const remove = ()=>mutations.inject({method:"POST",url:"/api/servers/1/containers/remove",payload:{rosId:"*A"}}) enabled=false;assert.equal((await remove()).statusCode,409);assert.equal(calls,0) missing=true;assert.equal((await remove()).statusCode,404);assert.equal(calls,0) missing=false;enabled=true;removalError=new ContainerRemovalConflict("Остановите контейнер");assert.equal((await remove()).statusCode,409) removalError=new Error("read failed");assert.equal((await remove()).statusCode,502) removalError=undefined;assert.deepEqual((await remove()).json(),{ok:true}) +const readSelected=()=>mutations.inject({method:"GET",url:"/api/servers/1/containers"}) +enabled=false;assert.equal((await readSelected()).statusCode,409);assert.equal(reads,0) +missing=true;assert.equal((await readSelected()).statusCode,404);assert.equal(reads,0) +missing=false;enabled=true;assert.equal((await readSelected()).statusCode,200);assert.equal(reads,1) +failRead=true;assert.equal((await readSelected()).statusCode,502) +assert.equal((await mutations.inject({method:"GET",url:"/api/containers"})).statusCode,502) assert.equal(permissionForRequest("POST","/api/servers/1/containers/remove"),"mm:servers:write") await mutations.close();console.log("Containers mutations HTTP: disabled/missing server cannot dispatch; removal conflict 409, uncertainty 502, confirmed success and write permission") diff --git a/backend/src/routes/containers.ts b/backend/src/routes/containers.ts index df3c6752..d65e6a03 100644 --- a/backend/src/routes/containers.ts +++ b/backend/src/routes/containers.ts @@ -21,7 +21,7 @@ const RosIdBodySchema = z.object({ type RosIdBody = z.infer type MutateFn = typeof startContainer -export function createContainersRoutes(deps: { getState?: typeof listContainersState; getServer?: typeof getEnabledServerById; remove?: MutateFn } = {}): FastifyPluginAsyncZod { +export function createContainersRoutes(deps: { getState?: typeof listContainersState; getServer?: typeof getEnabledServerById; getLegacyCatalog?: typeof listContainers; getServerContainers?: typeof listContainersForServer; remove?: MutateFn } = {}): FastifyPluginAsyncZod { return async (app) => { app.get("/containers/state", async (_req, reply) => { const state = containersStateSchema.safeParse(await (deps.getState ?? listContainersState)()) @@ -29,16 +29,25 @@ export function createContainersRoutes(deps: { getState?: typeof listContainersS return reply.send(state.data) }) app.get("/containers", async (_req, reply) => { - const containers = await listContainers() - return reply.send({ containers }) + try { + const containers = await (deps.getLegacyCatalog ?? listContainers)() + return reply.send({ containers }) + } catch (error) { + return reply.status(502).send({ error: error instanceof Error ? error.message : "Не удалось получить контейнеры" }) + } }) app.get("/servers/:id/containers", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { const params = req.params as ServerIdParams - const server = await getEnabledServerById(params.id) + const server = await (deps.getServer ?? getEnabledServerById)(params.id) if (!server) return reply.status(404).send({ error: "Server not found" }) - const containers = await listContainersForServer(server) - return reply.send({ containers }) + if (!server.enabled) return reply.status(409).send({ error: "Сервер выключен. Опрос контейнеров недоступен." }) + try { + const containers = await (deps.getServerContainers ?? listContainersForServer)(server) + return reply.send({ containers }) + } catch (error) { + return reply.status(502).send({ error: error instanceof Error ? error.message : "Не удалось получить контейнеры" }) + } }) function registerMutate(path: string, fn: MutateFn) { diff --git a/backend/src/services/containers-live.test.ts b/backend/src/services/containers-live.test.ts index e44d0cf8..c82789b2 100644 --- a/backend/src/services/containers-live.test.ts +++ b/backend/src/services/containers-live.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict" -import { mapContainerRow } from "./containers-live.js" +import { mapContainerRow, listContainersForServer, getEnabledServerById } from "./containers-live.js" +import { createServer } from "node:http" const server = { id: 3, @@ -44,3 +45,31 @@ assert.deepEqual(row.mounts, [ assert.equal(row.startOnBoot, true) console.log("containers-live.test.ts: ok") +for(const [native,expected] of [[{running:"true"},"running"],[{stopped:"true"},"stopped"],[{},"unknown"],[{running:true,stopped:true},"unknown"]] as const){ + const result=mapContainerRow(server,{".id":"*B",...native},[],[],0) + assert.equal(result.status,expected);assert.equal(result.startOnBoot,null);assert.equal(result.image,"");assert.equal(result.tag,"") +} +const modern=mapContainerRow(server,{".id":"*C",envlists:"first,second",mountlists:"config","memory-current":"0","memory-high":"52428800",cpu:"0"},[{list:"first",key:"A",value:"1"},{list:"second",key:"B",value:"2"}],[{list:"config",dst:"/etc"}],0) +assert.equal(modern.envs.length,2);assert.equal(modern.mounts.length,1);assert.equal(modern.memMb,0);assert.equal(modern.cpu,0) +const limitOnly=mapContainerRow(server,{".id":"*D","memory-high":"52428800",cpu:"5bad"},[],[],0) +assert.equal(limitOnly.memMb,undefined);assert.equal(limitOnly.cpu,undefined) +for(const invalid of ["1abc","","0","-1","1.5","9007199254740993"])assert.equal(await getEnabledServerById(invalid),null) +let mode="ok",requests=0 +const router=createServer((req,res)=>{ + requests++;res.setHeader("Content-Type","application/json") + if(mode==="unauthorized"){res.writeHead(401);res.end(JSON.stringify({error:401,message:"Unauthorized"}));return} + if(mode==="unsupported"){res.writeHead(404);res.end(JSON.stringify({error:404,message:"no such command"}));return} + if(mode==="malformed"){res.end(JSON.stringify({containers:[]}));return} + if(mode==="dependency"&&req.url==="/rest/container/envs"){res.writeHead(500);res.end(JSON.stringify({error:500}));return} + res.end(JSON.stringify(req.url==="/rest/container"?[{".id":"*A",name:"native",running:"true"}]:[])) +}) +await new Promise(resolve=>router.listen(0,"127.0.0.1",resolve)) +const address=router.address();assert(address&&typeof address!=="string") +const device={...server,host:"127.0.0.1",port:address.port,useSsl:false,verifySsl:false,enabled:true,username:"fixture",password:"fixture"} as Parameters[0] +try{ + const before=requests;await assert.rejects(listContainersForServer({...device,enabled:false}),/выключен/);assert.equal(requests,before) + const rows=await listContainersForServer(device);assert.equal(rows[0].status,"running");assert.equal(rows[0].startOnBoot,null) + for(const failure of ["unauthorized","malformed","dependency"]){mode=failure;await assert.rejects(listContainersForServer(device),/Не удалось полностью/)} + mode="unsupported";assert.deepEqual(await listContainersForServer(device),[]) +}finally{await new Promise((resolve,reject)=>router.close(error=>error?reject(error):resolve()))} +console.log("Legacy containers: real loopback HTTP handles native flags, auth/malformed/partial errors; disabled server makes zero requests; invalid IDs never query DB") diff --git a/backend/src/services/containers-live.ts b/backend/src/services/containers-live.ts index 12fa0e65..0898d911 100644 --- a/backend/src/services/containers-live.ts +++ b/backend/src/services/containers-live.ts @@ -2,7 +2,9 @@ import {boundedCompleteCount,completeContainerCount} from "./complete-count.js" import { eq } from "drizzle-orm" import { db } from "../db/index.js" import { servers } from "../db/schema.js" -import { MikrotikClient, MikrotikError } from "./mikrotik.js" +import { MikrotikClient } from "./mikrotik.js" +import { containerRuntimeStatus } from "./container-runtime-status.js" +import { parseDbServerId } from "../utils/server-id.js" type ServerRow = typeof servers.$inferSelect @@ -13,28 +15,41 @@ interface RosContainer { "remote-image"?: string interface?: string envlist?: string + envlists?: string mounts?: string + mountlists?: string cmd?: string "start-on-boot"?: string comment?: string status?: string "memory-high"?: string + "memory-current"?: string cpu?: string + running?: string | boolean + stopped?: string | boolean + error?: string | boolean + failed?: string | boolean + extracting?: string | boolean + downloading?: string | boolean + starting?: string | boolean + stopping?: string | boolean } interface RosContainerEnv { name?: string + list?: string key?: string value?: string } interface RosContainerMount { name?: string + list?: string src?: string dst?: string } -export type ContainerLiveStatus = "running" | "stopped" | "error" +export type ContainerLiveStatus = "running" | "stopped" | "error" | "unknown" export type ContainerLive = { id: string @@ -48,24 +63,13 @@ export type ContainerLive = { mounts: { dst: string; src?: string }[] interfaces: string[] cmd?: string - startOnBoot: boolean + startOnBoot: boolean | null comment: string uptime?: string cpu?: number memMb?: number } -function rosYes(v: string | undefined): boolean { - return v === "true" || v === "yes" -} - -function mapStatus(raw: string | undefined): ContainerLiveStatus { - const s = (raw ?? "").toLowerCase() - if (s === "running") return "running" - if (s === "error" || s === "failed") return "error" - return "stopped" -} - function splitCsv(v: string | undefined): string[] { return (v ?? "") .split(",") @@ -82,17 +86,7 @@ function parseImageTag(c: RosContainer): { image: string; tag: string } { } return { image: remote, tag: (c.tag ?? "latest").trim() || "latest" } } - return { image: (c.name ?? "").trim(), tag: (c.tag ?? "latest").trim() || "latest" } -} - -function isMissingPackage(err: unknown): boolean { - if (err instanceof MikrotikError) { - if (err.statusCode === 404) return true - const body = err.body.toLowerCase() - return body.includes("no such command") || body.includes("not found") || body.includes("unknown") - } - const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase() - return msg.includes("no such command") || msg.includes("404") + return { image: "", tag: "" } } export function mapContainerRow( @@ -105,14 +99,13 @@ export function mapContainerRow( const rosId = String(c[".id"] ?? `c-${idx}`) const name = (c.name ?? "").trim() || `container-${idx + 1}` const { image, tag } = parseImageTag(c) - const envlist = (c.envlist ?? "").trim() - const mountNames = new Set(splitCsv(c.mounts)) - const envRows = envlist - ? envs.filter((e) => (e.name ?? "").trim() === envlist && (e.key ?? "").trim()) - : [] - const mountRows = mounts.filter((m) => mountNames.has((m.name ?? "").trim()) && (m.dst ?? "").trim()) - const cpuRaw = Number.parseInt(c.cpu ?? "", 10) - const memRaw = Number.parseInt(c["memory-high"] ?? "", 10) + const envNames = new Set(splitCsv(c.envlists ?? c.envlist)) + const mountNames = new Set(splitCsv(c.mountlists ?? c.mounts)) + const envRows = envs.filter(e => envNames.has((e.list ?? e.name ?? "").trim()) && (e.key ?? "").trim()) + const mountRows = mounts.filter((m) => mountNames.has((m.list ?? m.name ?? "").trim()) && (m.dst ?? "").trim()) + const cpuRaw = Number(c.cpu?.trim() || NaN) + const memRaw = Number(c["memory-current"]?.trim() || NaN) + const boot = c["start-on-boot"] return { id: `${server.id}-${rosId}`, rosId, @@ -120,56 +113,35 @@ export function mapContainerRow( serverId: String(server.id), image, tag, - status: mapStatus(c.status), + status: containerRuntimeStatus({ ...c }), envs: envRows.map((e) => ({ key: e.key ?? "", value: e.value ?? "" })), mounts: mountRows.map((m) => ({ dst: m.dst ?? "", src: m.src || undefined })), interfaces: splitCsv(c.interface), cmd: (c.cmd ?? "").trim() || undefined, - startOnBoot: rosYes(c["start-on-boot"]), + startOnBoot: boot === "true" || boot === "yes" ? true : boot === "false" || boot === "no" ? false : null, comment: c.comment ?? "", - cpu: Number.isFinite(cpuRaw) ? cpuRaw : undefined, - memMb: Number.isFinite(memRaw) ? Math.round(memRaw / (1024 * 1024)) || undefined : undefined, + cpu: Number.isFinite(cpuRaw) && cpuRaw >= 0 && cpuRaw <= 100 ? cpuRaw : undefined, + memMb: Number.isFinite(memRaw) && memRaw >= 0 ? Math.round(memRaw / (1024 * 1024)) : undefined, } } async function fetchContainersForServer(server: ServerRow): Promise { + if (!server.enabled) throw new Error("Сервер выключен. Опрос контейнеров недоступен.") const client = MikrotikClient.fromServer(server) - try { - const [raw, envsRaw, mountsRaw] = await Promise.all([ - client.get("/container"), - client.get("/container/envs").catch(() => [] as RosContainerEnv[]), - client.get("/container/mounts").catch(() => [] as RosContainerMount[]), - ]) - const list = Array.isArray(raw) ? raw : [] - const envs = Array.isArray(envsRaw) ? envsRaw : [] - const mounts = Array.isArray(mountsRaw) ? mountsRaw : [] - return list.map((c, idx) => mapContainerRow(server, c, envs, mounts, idx)) - } catch (err) { - if (isMissingPackage(err)) return [] - throw err - } + const { readContainerServerState } = await import("./containers-state.js") + const state = await readContainerServerState(server, path => client.get(path)) + if (state.availability === "error" || state.failures.length) throw new Error("Не удалось полностью получить контейнеры сервера " + server.name + ": " + state.failures.map(f => f.error).join("; ")) + return state.containers } export async function listContainers(): Promise { const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true)) - const results = await Promise.all( - enabledServers.map(async (server) => { - try { - return await fetchContainersForServer(server) - } catch { - return [] as ContainerLive[] - } - }), - ) + const results = await Promise.all(enabledServers.map(fetchContainersForServer)) return results.flat() } export async function listContainersForServer(server: ServerRow): Promise { - try { - return await fetchContainersForServer(server) - } catch { - return [] - } + return fetchContainersForServer(server) } export async function countContainers():Promise{return boundedCompleteCount(()=>listContainersState(),completeContainerCount)} @@ -196,8 +168,8 @@ export async function removeContainer(server: ServerRow, rosId: string): Promise } export async function getEnabledServerById(serverId: string | number) { - const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10) - if (!Number.isFinite(id)) return null + const id = parseDbServerId(serverId) + if (id === null) return null return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null } diff --git a/backend/src/services/containers-state.test.ts b/backend/src/services/containers-state.test.ts index c45672e4..f7c85a71 100644 --- a/backend/src/services/containers-state.test.ts +++ b/backend/src/services/containers-state.test.ts @@ -3,7 +3,7 @@ import { readContainerServerState, collectContainersState } from "./containers-s import { MikrotikError } from "./mikrotik.js" import { containersStateSchema } from "@mmapp/contracts/containers" -const server = { id: 1, name: "Router" } as Parameters[0] +const server = { id: 1, name: "Router", enabled: true } 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-current": "0", "memory-high": "52428800" } 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) @@ -49,3 +49,8 @@ const modern=await readContainerServerState(server,async path=>path==="/containe let factories=0 const setupFailure=await collectContainersState([server,{...server,id:2}],srv=>{if(srv.id===2)throw new Error("client setup refused");factories++;return read});assert.equal(setupFailure.servers[0].containers.length,1);assert.equal(setupFailure.servers[1].availability,"error");assert.equal(setupFailure.servers[1].failures[0].error,"client setup refused");assert.equal(factories,1) +let disabledReads=0 +const disabled=await readContainerServerState({...server,enabled:false},async()=>{disabledReads++;throw new Error("Must never poll disabled")}) +assert.equal(disabledReads,0);assert.equal(disabled.availability,"error");assert.match(disabled.failures[0].error,/выключен/) +const enabledOnly=await collectContainersState([server,{...server,id:2,enabled:false}],srv=>{assert.equal(srv.id,1);return read}) +assert.deepEqual(enabledOnly.servers.map(s=>s.serverId),[1]) diff --git a/backend/src/services/containers-state.ts b/backend/src/services/containers-state.ts index 57a734a7..e00b8c49 100644 --- a/backend/src/services/containers-state.ts +++ b/backend/src/services/containers-state.ts @@ -26,6 +26,7 @@ function missingPackage(error: unknown) { } export async function readContainerServerState(server: ServerRow, read: Reader): Promise { const base = { serverId: server.id, collectedAt: new Date().toISOString(), containers: [], failures: [] } + if (!server.enabled) return containerServerStateSchema.parse({ ...base, availability: "error", failures: [{ resource: "containers", error: "Сервер выключен. Опрос контейнеров недоступен." }] }) let raw: z.infer try { raw = rawSchema.parse(await read("/container")) } catch (error) { @@ -57,7 +58,7 @@ export async function readContainerServerState(server: ServerRow, read: Reader): 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 => { + const states = await Promise.all(servers.filter(server => server.enabled).map(server => { let reader: Reader | undefined return readContainerServerState(server, path => { reader ??= makeReader(server); return reader(path) }) })) diff --git a/docs/ux-ui-external-acceptance.md b/docs/ux-ui-external-acceptance.md index eba34121..992748ee 100644 --- a/docs/ux-ui-external-acceptance.md +++ b/docs/ux-ui-external-acceptance.md @@ -1,6 +1,6 @@ # UX/UI: протокол внешней приёмки 12.5 -Актуальное продолжение 2026-10-14: 248 — удаление контейнера исправлено и принято на реальном CHR: UI требует остановку, API независимо проверяет её, DELETE подтверждается повторным чтением. Running/unknown/error заблокированы; повтор отсутствующего target даёт 409. 29 E2E, backend-профиль, build и ESLint passed. Backend/frontend ux-containers248 развёрнуты; собственные объекты и пакет удалены двумя разрешёнными reboot, исходный export/packages совпали. Прежний пароль подтвердился SFTP и прямым REST200. Следующий 249 — аудит legacy чтения контейнеров и запрет опроса выключенного сервера. Этап 12.5 открыт. +Актуальное продолжение 2026-10-14: 249 — legacy GET контейнеров не опрашивают выключенный сервер и не превращают ошибки в пустой список. Единый mapper сохраняет unknown, native flags/envlists/mountlists и фактическую память. Backend ux-containers249/frontend ux-containers248; 25 backend-профилей passed за378124ms, три специализированные SQL-ветки явно skipped. Owned БД/роль/container удалены. Real Portal/read UI1440/390 passed, writes0. Следующий250 — ACME на собственном mmt* поддомене pngs.top; предоставленный токен проверен read-only, выпуск ещё не выполнен. Этап12.5 открыт. IPsec78: реальный PSK responder established, mode-config198.18.78.10 и online=true подтверждены API; точные собственные объекты удалены. Сертификатный/DN online, late failure и history restore остаются открытыми. [Отчёт](ux-ui/acceptance/ipsec-mode-config-2026-10-09.json), [журнал](ux-ui-progress.md). @@ -18,6 +18,20 @@ OSPF84: real GRE Full adjacency/private routes/Ping3/3 и4 live PC/320px URL/bro BFD85: Up/Full/own route/Ping,4 live UI и loss/withdrawal/recovery приняты; exact own cleanup8 коллекций×2 passed. Withdrawal5,245ms не подтверждает sub-second BFD timing; этот вопрос открыт. Runtime routing83 без новой сборки. [Отчёт](ux-ui/acceptance/ospf-bfd-loss-2026-10-09.json). +## 12.5.249 — достоверное legacy чтение контейнеров и общая backend-регрессия (2026-10-14) + +Аудит выявил: GET выбранного сервера обходил enabled gate; legacy mapper считал отсутствие status остановкой, memory-high — фактической памятью, скрывал ошибки загрузки пустым массивом. Прямой GET теперь даёт409 для выключенного и404 для отсутствующего сервера до dispatch; прямой service/read тоже не создаёт RouterOS клиент для disabled, aggregate исключает disabled до reader factory. Ошибки/частичные ответы legacy GET дают502; unsupported сохраняет прежний legacy [] для совместимости, подробное availability предоставляется /api/containers/state. ID разбирается общим строгим parseDbServerId, без принятия префикса1abc. + +Legacy service переиспользует канонический readContainerServerState вместо второго скрывающего ошибки опроса. Общий mapper учитывает running/stopped/error flags и unknown, envlists/mountlists и современные list aliases, nullable startOnBoot, memory-current с известным нулём; memory-high и имя контейнера не подменяют расход памяти и неизвестный образ. Десятичный/неверный CPU не обрезается parseInt. Изменение legacy status включает unknown; потребители нового UI уже используют этот статус. В репозитории frontend не обращается к старым GET. + +test:containers дополнен legacy service profile и включён в общий test:all/CI. Настоящий loopback HTTP-транспорт без DB: valid/native running;401/malformed/dependency error не становятся empty; unsupported сохраняет []. Disabled makes0 requests; invalid ID returnsnull до DB. HTTP проверяет selected disabled/missing без read dispatch и502 вместо fake success. Direct state иaggregate disabled guards проверены отдельно. Первый sandbox запуск loopback получил EACCES и не принят; elevated профиль прошёл. Contracts/backend build и ESLint passed; Docker Node22 профиль тоже exit0. + +Из-за изменения общей основы выполнены все25 профилей test:all на собственной ограниченной PostgreSQL БД/роли, без production DSN. Все exit0; сумма378124ms (6мин18с), это не число индивидуальных assertions. Три специальные SQL-ветки пропущены из-за отдельных env/namespace: Monthly149, IPv6 UDP/worker144, network-disabled-server152 (MMAPP_OWN_TEST_DB). Это известные явные ограничения текущего объединённого запуска, не приёмка этих веток. Остальные conditional skip строки сохраняются числом в протоколе, не превращаются в live доказательства. Собственная БД/роль/контейнер удалены, отсутствие проверено. [Регрессия](ux-ui/acceptance/containers249-regression.json). + +Backend ux-containers249 собран/развёрнут и healthy; frontend ux-containers248 сохранён, PostgreSQL/Portal не пересоздавались. Backend source524/compiled1080 совпали без missing/different; версия1.0.0-test.backup249 — метка лабораторной сборки, неGitSHA. [Deployment](ux-ui/acceptance/containers249-deployment.json). Настоящий Portal/read UI1440/390: typed state200 показывает оба CHR unsupported после удаления пакета248, legacy global/selected GET200[] совместимы, overflow/JS errors0, non-auth API writes0. Никаких новых native объектов/перезагрузок/изменений паролей. [Live UI](ux-ui/acceptance/containers249-live-ui.json). ReUI audit checklist для alert-dialog прочитан: используются существующие props/copy; vendored компоненты/стили/тайминги не менялись. + +Пользователь предоставил стенд ACME: pngs.top и разрешённые поддомены с префиксом mmt*. Read-only Cloudflare token verify и exact zone query вернули200/success; token/zone active, account matched. Токен/почта/account ID сохранены только в ignored private fixture, в Git не добавляются. Следующий250 — issuance/импорт/результат через реальный API/UI с очисткой собственных DNS/certificate/file объектов и возвратом исходных настроек. Пока реальная выдача не выполнена. Обнаружено, что текущий issue автоматически назначает сертификат www-ssl/api-ssl; перед применением подготовлен private baseline этих сервисов/сертификатов/файлов/settings. Remote publishing, multi-instance/WAN и прочие ограничения матрицы сохраняются. + ## 12.5.248 — подтверждённое удаление контейнера через UI (2026-10-14) В 247 UI отправлял DELETE работающего контейнера и получал 502. Теперь dialog использует последнее состояние выбранного serverId/rosId: удалить можно только stopped; для running/unknown/error показана инструкция остановки и обновления. Backend независимо читает native список и проверяет однозначное stopped, не останавливает контейнер скрыто. Отсутствующий target или неподходящее состояние дают 409 без DELETE; после DELETE каталог читается снова, сохранённый target/сбой/некорректный ответ не становятся ok=true. Выключенный сервер отклоняется до команды. Каталоги/файлы не удаляются отдельными командами приложения; поведение native remove не подменяется обещанием очистки. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 661319c2..d4beab96 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,9 +1,23 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-14: 248 — удаление контейнера исправлено и принято на реальном CHR: UI требует остановку, API независимо проверяет её, DELETE подтверждается повторным чтением. Running/unknown/error заблокированы; повтор отсутствующего target даёт 409. 29 E2E, backend-профиль, build и ESLint passed. Backend/frontend ux-containers248 развёрнуты; собственные объекты и пакет удалены двумя разрешёнными reboot, исходный export/packages совпали. Прежний пароль подтвердился SFTP и прямым REST200. Следующий 249 — аудит legacy чтения контейнеров и запрет опроса выключенного сервера. Этап 12.5 открыт. +Актуальное продолжение 2026-10-14: 249 — legacy GET контейнеров не опрашивают выключенный сервер и не превращают ошибки в пустой список. Единый mapper сохраняет unknown, native flags/envlists/mountlists и фактическую память. Backend ux-containers249/frontend ux-containers248; 25 backend-профилей passed за378124ms, три специализированные SQL-ветки явно skipped. Owned БД/роль/container удалены. Real Portal/read UI1440/390 passed, writes0. Следующий250 — ACME на собственном mmt* поддомене pngs.top; предоставленный токен проверен read-only, выпуск ещё не выполнен. Этап12.5 открыт. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-14. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. +## 12.5.249 — достоверное legacy чтение контейнеров и общая backend-регрессия (2026-10-14) + +Аудит выявил: GET выбранного сервера обходил enabled gate; legacy mapper считал отсутствие status остановкой, memory-high — фактической памятью, скрывал ошибки загрузки пустым массивом. Прямой GET теперь даёт409 для выключенного и404 для отсутствующего сервера до dispatch; прямой service/read тоже не создаёт RouterOS клиент для disabled, aggregate исключает disabled до reader factory. Ошибки/частичные ответы legacy GET дают502; unsupported сохраняет прежний legacy [] для совместимости, подробное availability предоставляется /api/containers/state. ID разбирается общим строгим parseDbServerId, без принятия префикса1abc. + +Legacy service переиспользует канонический readContainerServerState вместо второго скрывающего ошибки опроса. Общий mapper учитывает running/stopped/error flags и unknown, envlists/mountlists и современные list aliases, nullable startOnBoot, memory-current с известным нулём; memory-high и имя контейнера не подменяют расход памяти и неизвестный образ. Десятичный/неверный CPU не обрезается parseInt. Изменение legacy status включает unknown; потребители нового UI уже используют этот статус. В репозитории frontend не обращается к старым GET. + +test:containers дополнен legacy service profile и включён в общий test:all/CI. Настоящий loopback HTTP-транспорт без DB: valid/native running;401/malformed/dependency error не становятся empty; unsupported сохраняет []. Disabled makes0 requests; invalid ID returnsnull до DB. HTTP проверяет selected disabled/missing без read dispatch и502 вместо fake success. Direct state иaggregate disabled guards проверены отдельно. Первый sandbox запуск loopback получил EACCES и не принят; elevated профиль прошёл. Contracts/backend build и ESLint passed; Docker Node22 профиль тоже exit0. + +Из-за изменения общей основы выполнены все25 профилей test:all на собственной ограниченной PostgreSQL БД/роли, без production DSN. Все exit0; сумма378124ms (6мин18с), это не число индивидуальных assertions. Три специальные SQL-ветки пропущены из-за отдельных env/namespace: Monthly149, IPv6 UDP/worker144, network-disabled-server152 (MMAPP_OWN_TEST_DB). Это известные явные ограничения текущего объединённого запуска, не приёмка этих веток. Остальные conditional skip строки сохраняются числом в протоколе, не превращаются в live доказательства. Собственная БД/роль/контейнер удалены, отсутствие проверено. [Регрессия](ux-ui/acceptance/containers249-regression.json). + +Backend ux-containers249 собран/развёрнут и healthy; frontend ux-containers248 сохранён, PostgreSQL/Portal не пересоздавались. Backend source524/compiled1080 совпали без missing/different; версия1.0.0-test.backup249 — метка лабораторной сборки, неGitSHA. [Deployment](ux-ui/acceptance/containers249-deployment.json). Настоящий Portal/read UI1440/390: typed state200 показывает оба CHR unsupported после удаления пакета248, legacy global/selected GET200[] совместимы, overflow/JS errors0, non-auth API writes0. Никаких новых native объектов/перезагрузок/изменений паролей. [Live UI](ux-ui/acceptance/containers249-live-ui.json). ReUI audit checklist для alert-dialog прочитан: используются существующие props/copy; vendored компоненты/стили/тайминги не менялись. + +Пользователь предоставил стенд ACME: pngs.top и разрешённые поддомены с префиксом mmt*. Read-only Cloudflare token verify и exact zone query вернули200/success; token/zone active, account matched. Токен/почта/account ID сохранены только в ignored private fixture, в Git не добавляются. Следующий250 — issuance/импорт/результат через реальный API/UI с очисткой собственных DNS/certificate/file объектов и возвратом исходных настроек. Пока реальная выдача не выполнена. Обнаружено, что текущий issue автоматически назначает сертификат www-ssl/api-ssl; перед применением подготовлен private baseline этих сервисов/сертификатов/файлов/settings. Remote publishing, multi-instance/WAN и прочие ограничения матрицы сохраняются. + ## 12.5.248 — подтверждённое удаление контейнера через UI (2026-10-14) В 247 UI отправлял DELETE работающего контейнера и получал 502. Теперь dialog использует последнее состояние выбранного serverId/rosId: удалить можно только stopped; для running/unknown/error показана инструкция остановки и обновления. Backend независимо читает native список и проверяет однозначное stopped, не останавливает контейнер скрыто. Отсутствующий target или неподходящее состояние дают 409 без DELETE; после DELETE каталог читается снова, сохранённый target/сбой/некорректный ответ не становятся ok=true. Выключенный сервер отклоняется до команды. Каталоги/файлы не удаляются отдельными командами приложения; поведение native remove не подменяется обещанием очистки. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index bccfd3f0..b31ac57d 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-14: 248 — удаление контейнера исправлено и принято на реальном CHR: UI требует остановку, API независимо проверяет её, DELETE подтверждается повторным чтением. Running/unknown/error заблокированы; повтор отсутствующего target даёт 409. 29 E2E, backend-профиль, build и ESLint passed. Backend/frontend ux-containers248 развёрнуты; собственные объекты и пакет удалены двумя разрешёнными reboot, исходный export/packages совпали. Прежний пароль подтвердился SFTP и прямым REST200. Следующий 249 — аудит legacy чтения контейнеров и запрет опроса выключенного сервера. Этап 12.5 открыт. +Актуальное продолжение 2026-10-14: 249 — legacy GET контейнеров не опрашивают выключенный сервер и не превращают ошибки в пустой список. Единый mapper сохраняет unknown, native flags/envlists/mountlists и фактическую память. Backend ux-containers249/frontend ux-containers248; 25 backend-профилей passed за378124ms, три специализированные SQL-ветки явно skipped. Owned БД/роль/container удалены. Real Portal/read UI1440/390 passed, writes0. Следующий250 — ACME на собственном mmt* поддомене pngs.top; предоставленный токен проверен read-only, выпуск ещё не выполнен. Этап12.5 открыт. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). @@ -38,6 +38,20 @@ | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка12.3.83 в границах журнала. Внешняя12.5: блоки1–236 завершены в записанных scopes, этап открыт. Runtime backend ux-backup234/frontend ux-routing218; backend25/UI52 и дополнительные SQL/UDP222 прошли. | +## 12.5.249 — достоверное legacy чтение контейнеров и общая backend-регрессия (2026-10-14) + +Аудит выявил: GET выбранного сервера обходил enabled gate; legacy mapper считал отсутствие status остановкой, memory-high — фактической памятью, скрывал ошибки загрузки пустым массивом. Прямой GET теперь даёт409 для выключенного и404 для отсутствующего сервера до dispatch; прямой service/read тоже не создаёт RouterOS клиент для disabled, aggregate исключает disabled до reader factory. Ошибки/частичные ответы legacy GET дают502; unsupported сохраняет прежний legacy [] для совместимости, подробное availability предоставляется /api/containers/state. ID разбирается общим строгим parseDbServerId, без принятия префикса1abc. + +Legacy service переиспользует канонический readContainerServerState вместо второго скрывающего ошибки опроса. Общий mapper учитывает running/stopped/error flags и unknown, envlists/mountlists и современные list aliases, nullable startOnBoot, memory-current с известным нулём; memory-high и имя контейнера не подменяют расход памяти и неизвестный образ. Десятичный/неверный CPU не обрезается parseInt. Изменение legacy status включает unknown; потребители нового UI уже используют этот статус. В репозитории frontend не обращается к старым GET. + +test:containers дополнен legacy service profile и включён в общий test:all/CI. Настоящий loopback HTTP-транспорт без DB: valid/native running;401/malformed/dependency error не становятся empty; unsupported сохраняет []. Disabled makes0 requests; invalid ID returnsnull до DB. HTTP проверяет selected disabled/missing без read dispatch и502 вместо fake success. Direct state иaggregate disabled guards проверены отдельно. Первый sandbox запуск loopback получил EACCES и не принят; elevated профиль прошёл. Contracts/backend build и ESLint passed; Docker Node22 профиль тоже exit0. + +Из-за изменения общей основы выполнены все25 профилей test:all на собственной ограниченной PostgreSQL БД/роли, без production DSN. Все exit0; сумма378124ms (6мин18с), это не число индивидуальных assertions. Три специальные SQL-ветки пропущены из-за отдельных env/namespace: Monthly149, IPv6 UDP/worker144, network-disabled-server152 (MMAPP_OWN_TEST_DB). Это известные явные ограничения текущего объединённого запуска, не приёмка этих веток. Остальные conditional skip строки сохраняются числом в протоколе, не превращаются в live доказательства. Собственная БД/роль/контейнер удалены, отсутствие проверено. [Регрессия](ux-ui/acceptance/containers249-regression.json). + +Backend ux-containers249 собран/развёрнут и healthy; frontend ux-containers248 сохранён, PostgreSQL/Portal не пересоздавались. Backend source524/compiled1080 совпали без missing/different; версия1.0.0-test.backup249 — метка лабораторной сборки, неGitSHA. [Deployment](ux-ui/acceptance/containers249-deployment.json). Настоящий Portal/read UI1440/390: typed state200 показывает оба CHR unsupported после удаления пакета248, legacy global/selected GET200[] совместимы, overflow/JS errors0, non-auth API writes0. Никаких новых native объектов/перезагрузок/изменений паролей. [Live UI](ux-ui/acceptance/containers249-live-ui.json). ReUI audit checklist для alert-dialog прочитан: используются существующие props/copy; vendored компоненты/стили/тайминги не менялись. + +Пользователь предоставил стенд ACME: pngs.top и разрешённые поддомены с префиксом mmt*. Read-only Cloudflare token verify и exact zone query вернули200/success; token/zone active, account matched. Токен/почта/account ID сохранены только в ignored private fixture, в Git не добавляются. Следующий250 — issuance/импорт/результат через реальный API/UI с очисткой собственных DNS/certificate/file объектов и возвратом исходных настроек. Пока реальная выдача не выполнена. Обнаружено, что текущий issue автоматически назначает сертификат www-ssl/api-ssl; перед применением подготовлен private baseline этих сервисов/сертификатов/файлов/settings. Remote publishing, multi-instance/WAN и прочие ограничения матрицы сохраняются. + ## 12.5.248 — подтверждённое удаление контейнера через UI (2026-10-14) В 247 UI отправлял DELETE работающего контейнера и получал 502. Теперь dialog использует последнее состояние выбранного serverId/rosId: удалить можно только stopped; для running/unknown/error показана инструкция остановки и обновления. Backend независимо читает native список и проверяет однозначное stopped, не останавливает контейнер скрыто. Отсутствующий target или неподходящее состояние дают 409 без DELETE; после DELETE каталог читается снова, сохранённый target/сбой/некорректный ответ не становятся ok=true. Выключенный сервер отклоняется до команды. Каталоги/файлы не удаляются отдельными командами приложения; поведение native remove не подменяется обещанием очистки. diff --git a/docs/ux-ui-remaining-acceptance.md b/docs/ux-ui-remaining-acceptance.md index aa53dd93..d0d2a276 100644 --- a/docs/ux-ui-remaining-acceptance.md +++ b/docs/ux-ui-remaining-acceptance.md @@ -1,10 +1,24 @@ # Оставшаяся внешняя приёмка — 2026-10-14 -Актуальное продолжение 2026-10-14: 248 — удаление контейнера исправлено и принято на реальном CHR: UI требует остановку, API независимо проверяет её, DELETE подтверждается повторным чтением. Running/unknown/error заблокированы; повтор отсутствующего target даёт 409. 29 E2E, backend-профиль, build и ESLint passed. Backend/frontend ux-containers248 развёрнуты; собственные объекты и пакет удалены двумя разрешёнными reboot, исходный export/packages совпали. Прежний пароль подтвердился SFTP и прямым REST200. Следующий 249 — аудит legacy чтения контейнеров и запрет опроса выключенного сервера. Этап 12.5 открыт. +Актуальное продолжение 2026-10-14: 249 — legacy GET контейнеров не опрашивают выключенный сервер и не превращают ошибки в пустой список. Единый mapper сохраняет unknown, native flags/envlists/mountlists и фактическую память. Backend ux-containers249/frontend ux-containers248; 25 backend-профилей passed за378124ms, три специализированные SQL-ветки явно skipped. Owned БД/роль/container удалены. Real Portal/read UI1440/390 passed, writes0. Следующий250 — ACME на собственном mmt* поддомене pngs.top; предоставленный токен проверен read-only, выпуск ещё не выполнен. Этап12.5 открыт. -Это актуальная матрица после 12.5.248. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии. +Это актуальная матрица после 12.5.249. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии. -Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend/frontend `ux-containers248`; production lab с195 использует AUTH_REQUIRED=true и Portal1.3.1. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись. +Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-containers249`, frontend `ux-containers248`; production lab с195 использует AUTH_REQUIRED=true и Portal1.3.1. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись. + +## 12.5.249 — достоверное legacy чтение контейнеров и общая backend-регрессия (2026-10-14) + +Аудит выявил: GET выбранного сервера обходил enabled gate; legacy mapper считал отсутствие status остановкой, memory-high — фактической памятью, скрывал ошибки загрузки пустым массивом. Прямой GET теперь даёт409 для выключенного и404 для отсутствующего сервера до dispatch; прямой service/read тоже не создаёт RouterOS клиент для disabled, aggregate исключает disabled до reader factory. Ошибки/частичные ответы legacy GET дают502; unsupported сохраняет прежний legacy [] для совместимости, подробное availability предоставляется /api/containers/state. ID разбирается общим строгим parseDbServerId, без принятия префикса1abc. + +Legacy service переиспользует канонический readContainerServerState вместо второго скрывающего ошибки опроса. Общий mapper учитывает running/stopped/error flags и unknown, envlists/mountlists и современные list aliases, nullable startOnBoot, memory-current с известным нулём; memory-high и имя контейнера не подменяют расход памяти и неизвестный образ. Десятичный/неверный CPU не обрезается parseInt. Изменение legacy status включает unknown; потребители нового UI уже используют этот статус. В репозитории frontend не обращается к старым GET. + +test:containers дополнен legacy service profile и включён в общий test:all/CI. Настоящий loopback HTTP-транспорт без DB: valid/native running;401/malformed/dependency error не становятся empty; unsupported сохраняет []. Disabled makes0 requests; invalid ID returnsnull до DB. HTTP проверяет selected disabled/missing без read dispatch и502 вместо fake success. Direct state иaggregate disabled guards проверены отдельно. Первый sandbox запуск loopback получил EACCES и не принят; elevated профиль прошёл. Contracts/backend build и ESLint passed; Docker Node22 профиль тоже exit0. + +Из-за изменения общей основы выполнены все25 профилей test:all на собственной ограниченной PostgreSQL БД/роли, без production DSN. Все exit0; сумма378124ms (6мин18с), это не число индивидуальных assertions. Три специальные SQL-ветки пропущены из-за отдельных env/namespace: Monthly149, IPv6 UDP/worker144, network-disabled-server152 (MMAPP_OWN_TEST_DB). Это известные явные ограничения текущего объединённого запуска, не приёмка этих веток. Остальные conditional skip строки сохраняются числом в протоколе, не превращаются в live доказательства. Собственная БД/роль/контейнер удалены, отсутствие проверено. [Регрессия](ux-ui/acceptance/containers249-regression.json). + +Backend ux-containers249 собран/развёрнут и healthy; frontend ux-containers248 сохранён, PostgreSQL/Portal не пересоздавались. Backend source524/compiled1080 совпали без missing/different; версия1.0.0-test.backup249 — метка лабораторной сборки, неGitSHA. [Deployment](ux-ui/acceptance/containers249-deployment.json). Настоящий Portal/read UI1440/390: typed state200 показывает оба CHR unsupported после удаления пакета248, legacy global/selected GET200[] совместимы, overflow/JS errors0, non-auth API writes0. Никаких новых native объектов/перезагрузок/изменений паролей. [Live UI](ux-ui/acceptance/containers249-live-ui.json). ReUI audit checklist для alert-dialog прочитан: используются существующие props/copy; vendored компоненты/стили/тайминги не менялись. + +Пользователь предоставил стенд ACME: pngs.top и разрешённые поддомены с префиксом mmt*. Read-only Cloudflare token verify и exact zone query вернули200/success; token/zone active, account matched. Токен/почта/account ID сохранены только в ignored private fixture, в Git не добавляются. Следующий250 — issuance/импорт/результат через реальный API/UI с очисткой собственных DNS/certificate/file объектов и возвратом исходных настроек. Пока реальная выдача не выполнена. Обнаружено, что текущий issue автоматически назначает сертификат www-ssl/api-ssl; перед применением подготовлен private baseline этих сервисов/сертификатов/файлов/settings. Remote publishing, multi-instance/WAN и прочие ограничения матрицы сохраняются. ## 12.5.248 — подтверждённое удаление контейнера через UI (2026-10-14) @@ -196,7 +210,7 @@ Portal offline JWT действует доexp после logout/role change; э ## Следующая последовательность — 2026-10-14 -1. **Контейнеры249:** lifecycle248 принят; проверить legacy чтение выключенного сервера и native статусы старого DTO. Package и собственные объекты очищены. CI245 принят; ACME и remote Gitea publishing требуют своих доступов/разрешённого scope. +1. **ACME250:** стенд pngs.top доступен; разрешён mmt* scope, token/zone verified read-only. Проверить реальную выдачу/импорт/результат и очистку. Containers248–249 приняты; общая backend-регрессия249 passed с тремя записанными SQL skips. Remote Gitea publishing требует своего разрешённого scope. 2. **Профильный контроль:** для новых изменений выполнять затронутые SQL/API/UI проверки. Общая регрессия192 выполнена; не повторять её без изменений общей основы или нового основания. 3. **Внешние зависимости:** Portal issuer/login/revoke, Telegram bot/chat/разрешённое сообщение, клиентские ОС, независимые WAN/public IPv6 и scale/multi-instance — отдельные группы по таблице выше. Ограничения текущего стенда не превращать в обещание приёмки всех конфигураций. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 640cec0a..ac850b95 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -1,6 +1,6 @@ # Этап 12: итоговая приёмка UX/UI -Актуальное продолжение 2026-10-14: 248 — удаление контейнера исправлено и принято на реальном CHR: UI требует остановку, API независимо проверяет её, DELETE подтверждается повторным чтением. Running/unknown/error заблокированы; повтор отсутствующего target даёт 409. 29 E2E, backend-профиль, build и ESLint passed. Backend/frontend ux-containers248 развёрнуты; собственные объекты и пакет удалены двумя разрешёнными reboot, исходный export/packages совпали. Прежний пароль подтвердился SFTP и прямым REST200. Следующий 249 — аудит legacy чтения контейнеров и запрет опроса выключенного сервера. Этап 12.5 открыт. +Актуальное продолжение 2026-10-14: 249 — legacy GET контейнеров не опрашивают выключенный сервер и не превращают ошибки в пустой список. Единый mapper сохраняет unknown, native flags/envlists/mountlists и фактическую память. Backend ux-containers249/frontend ux-containers248; 25 backend-профилей passed за378124ms, три специализированные SQL-ветки явно skipped. Owned БД/роль/container удалены. Real Portal/read UI1440/390 passed, writes0. Следующий250 — ACME на собственном mmt* поддомене pngs.top; предоставленный токен проверен read-only, выпуск ещё не выполнен. Этап12.5 открыт. План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push. @@ -4152,6 +4152,20 @@ Real Portal/browser API, provider local verified: создан один job ро Runtime backend ux-backup234/frontend ux-routing218 healthy; новое application image для volume-only fix не требовалось. Пароли CHR не менялись, S3 mutations0 в235, configs body/JWT не опубликованы, private local manifest удалён. Исходные10 missing local files не восстановлены и records не удалены. Force-recreate — не host reboot/power loss/fsync/DR приёмка. Далее236 — UX недоступных local files, затем version retention границы и оставшиеся7 группы матрицы; multi-instance guard не принимается. Этап12.5 открыт. +## 12.5.249 — достоверное legacy чтение контейнеров и общая backend-регрессия (2026-10-14) + +Аудит выявил: GET выбранного сервера обходил enabled gate; legacy mapper считал отсутствие status остановкой, memory-high — фактической памятью, скрывал ошибки загрузки пустым массивом. Прямой GET теперь даёт409 для выключенного и404 для отсутствующего сервера до dispatch; прямой service/read тоже не создаёт RouterOS клиент для disabled, aggregate исключает disabled до reader factory. Ошибки/частичные ответы legacy GET дают502; unsupported сохраняет прежний legacy [] для совместимости, подробное availability предоставляется /api/containers/state. ID разбирается общим строгим parseDbServerId, без принятия префикса1abc. + +Legacy service переиспользует канонический readContainerServerState вместо второго скрывающего ошибки опроса. Общий mapper учитывает running/stopped/error flags и unknown, envlists/mountlists и современные list aliases, nullable startOnBoot, memory-current с известным нулём; memory-high и имя контейнера не подменяют расход памяти и неизвестный образ. Десятичный/неверный CPU не обрезается parseInt. Изменение legacy status включает unknown; потребители нового UI уже используют этот статус. В репозитории frontend не обращается к старым GET. + +test:containers дополнен legacy service profile и включён в общий test:all/CI. Настоящий loopback HTTP-транспорт без DB: valid/native running;401/malformed/dependency error не становятся empty; unsupported сохраняет []. Disabled makes0 requests; invalid ID returnsnull до DB. HTTP проверяет selected disabled/missing без read dispatch и502 вместо fake success. Direct state иaggregate disabled guards проверены отдельно. Первый sandbox запуск loopback получил EACCES и не принят; elevated профиль прошёл. Contracts/backend build и ESLint passed; Docker Node22 профиль тоже exit0. + +Из-за изменения общей основы выполнены все25 профилей test:all на собственной ограниченной PostgreSQL БД/роли, без production DSN. Все exit0; сумма378124ms (6мин18с), это не число индивидуальных assertions. Три специальные SQL-ветки пропущены из-за отдельных env/namespace: Monthly149, IPv6 UDP/worker144, network-disabled-server152 (MMAPP_OWN_TEST_DB). Это известные явные ограничения текущего объединённого запуска, не приёмка этих веток. Остальные conditional skip строки сохраняются числом в протоколе, не превращаются в live доказательства. Собственная БД/роль/контейнер удалены, отсутствие проверено. [Регрессия](ux-ui/acceptance/containers249-regression.json). + +Backend ux-containers249 собран/развёрнут и healthy; frontend ux-containers248 сохранён, PostgreSQL/Portal не пересоздавались. Backend source524/compiled1080 совпали без missing/different; версия1.0.0-test.backup249 — метка лабораторной сборки, неGitSHA. [Deployment](ux-ui/acceptance/containers249-deployment.json). Настоящий Portal/read UI1440/390: typed state200 показывает оба CHR unsupported после удаления пакета248, legacy global/selected GET200[] совместимы, overflow/JS errors0, non-auth API writes0. Никаких новых native объектов/перезагрузок/изменений паролей. [Live UI](ux-ui/acceptance/containers249-live-ui.json). ReUI audit checklist для alert-dialog прочитан: используются существующие props/copy; vendored компоненты/стили/тайминги не менялись. + +Пользователь предоставил стенд ACME: pngs.top и разрешённые поддомены с префиксом mmt*. Read-only Cloudflare token verify и exact zone query вернули200/success; token/zone active, account matched. Токен/почта/account ID сохранены только в ignored private fixture, в Git не добавляются. Следующий250 — issuance/импорт/результат через реальный API/UI с очисткой собственных DNS/certificate/file объектов и возвратом исходных настроек. Пока реальная выдача не выполнена. Обнаружено, что текущий issue автоматически назначает сертификат www-ssl/api-ssl; перед применением подготовлен private baseline этих сервисов/сертификатов/файлов/settings. Remote publishing, multi-instance/WAN и прочие ограничения матрицы сохраняются. + ## 12.5.248 — подтверждённое удаление контейнера через UI (2026-10-14) В 247 UI отправлял DELETE работающего контейнера и получал 502. Теперь dialog использует последнее состояние выбранного serverId/rosId: удалить можно только stopped; для running/unknown/error показана инструкция остановки и обновления. Backend независимо читает native список и проверяет однозначное stopped, не останавливает контейнер скрыто. Отсутствующий target или неподходящее состояние дают 409 без DELETE; после DELETE каталог читается снова, сохранённый target/сбой/некорректный ответ не становятся ok=true. Выключенный сервер отклоняется до команды. Каталоги/файлы не удаляются отдельными командами приложения; поведение native remove не подменяется обещанием очистки. diff --git a/docs/ux-ui-test-server.md b/docs/ux-ui-test-server.md index c7529b83..0f355747 100644 --- a/docs/ux-ui-test-server.md +++ b/docs/ux-ui-test-server.md @@ -1,6 +1,6 @@ # Тестовый сервер UX/UI — 2026-10-10 -Актуальное продолжение 2026-10-14: 248 — удаление контейнера исправлено и принято на реальном CHR: UI требует остановку, API независимо проверяет её, DELETE подтверждается повторным чтением. Running/unknown/error заблокированы; повтор отсутствующего target даёт 409. 29 E2E, backend-профиль, build и ESLint passed. Backend/frontend ux-containers248 развёрнуты; собственные объекты и пакет удалены двумя разрешёнными reboot, исходный export/packages совпали. Прежний пароль подтвердился SFTP и прямым REST200. Следующий 249 — аудит legacy чтения контейнеров и запрет опроса выключенного сервера. Этап 12.5 открыт. +Актуальное продолжение 2026-10-14: 249 — legacy GET контейнеров не опрашивают выключенный сервер и не превращают ошибки в пустой список. Единый mapper сохраняет unknown, native flags/envlists/mountlists и фактическую память. Backend ux-containers249/frontend ux-containers248; 25 backend-профилей passed за378124ms, три специализированные SQL-ветки явно skipped. Owned БД/роль/container удалены. Real Portal/read UI1440/390 passed, writes0. Следующий250 — ACME на собственном mmt* поддомене pngs.top; предоставленный токен проверен read-only, выпуск ещё не выполнен. Этап12.5 открыт. | Параметр | Проверенное состояние | | --- | --- | @@ -34,6 +34,20 @@ OSPF84: real GRE Full adjacency/private routes/Ping3/3 и4 live PC/320px URL/bro BFD85: Up/Full/own route/Ping,4 live UI и loss/withdrawal/recovery приняты; exact own cleanup8 коллекций×2 passed. Withdrawal5,245ms не подтверждает sub-second BFD timing; этот вопрос открыт. Runtime routing83 без новой сборки. [Отчёт](ux-ui/acceptance/ospf-bfd-loss-2026-10-09.json). +## 12.5.249 — достоверное legacy чтение контейнеров и общая backend-регрессия (2026-10-14) + +Аудит выявил: GET выбранного сервера обходил enabled gate; legacy mapper считал отсутствие status остановкой, memory-high — фактической памятью, скрывал ошибки загрузки пустым массивом. Прямой GET теперь даёт409 для выключенного и404 для отсутствующего сервера до dispatch; прямой service/read тоже не создаёт RouterOS клиент для disabled, aggregate исключает disabled до reader factory. Ошибки/частичные ответы legacy GET дают502; unsupported сохраняет прежний legacy [] для совместимости, подробное availability предоставляется /api/containers/state. ID разбирается общим строгим parseDbServerId, без принятия префикса1abc. + +Legacy service переиспользует канонический readContainerServerState вместо второго скрывающего ошибки опроса. Общий mapper учитывает running/stopped/error flags и unknown, envlists/mountlists и современные list aliases, nullable startOnBoot, memory-current с известным нулём; memory-high и имя контейнера не подменяют расход памяти и неизвестный образ. Десятичный/неверный CPU не обрезается parseInt. Изменение legacy status включает unknown; потребители нового UI уже используют этот статус. В репозитории frontend не обращается к старым GET. + +test:containers дополнен legacy service profile и включён в общий test:all/CI. Настоящий loopback HTTP-транспорт без DB: valid/native running;401/malformed/dependency error не становятся empty; unsupported сохраняет []. Disabled makes0 requests; invalid ID returnsnull до DB. HTTP проверяет selected disabled/missing без read dispatch и502 вместо fake success. Direct state иaggregate disabled guards проверены отдельно. Первый sandbox запуск loopback получил EACCES и не принят; elevated профиль прошёл. Contracts/backend build и ESLint passed; Docker Node22 профиль тоже exit0. + +Из-за изменения общей основы выполнены все25 профилей test:all на собственной ограниченной PostgreSQL БД/роли, без production DSN. Все exit0; сумма378124ms (6мин18с), это не число индивидуальных assertions. Три специальные SQL-ветки пропущены из-за отдельных env/namespace: Monthly149, IPv6 UDP/worker144, network-disabled-server152 (MMAPP_OWN_TEST_DB). Это известные явные ограничения текущего объединённого запуска, не приёмка этих веток. Остальные conditional skip строки сохраняются числом в протоколе, не превращаются в live доказательства. Собственная БД/роль/контейнер удалены, отсутствие проверено. [Регрессия](ux-ui/acceptance/containers249-regression.json). + +Backend ux-containers249 собран/развёрнут и healthy; frontend ux-containers248 сохранён, PostgreSQL/Portal не пересоздавались. Backend source524/compiled1080 совпали без missing/different; версия1.0.0-test.backup249 — метка лабораторной сборки, неGitSHA. [Deployment](ux-ui/acceptance/containers249-deployment.json). Настоящий Portal/read UI1440/390: typed state200 показывает оба CHR unsupported после удаления пакета248, legacy global/selected GET200[] совместимы, overflow/JS errors0, non-auth API writes0. Никаких новых native объектов/перезагрузок/изменений паролей. [Live UI](ux-ui/acceptance/containers249-live-ui.json). ReUI audit checklist для alert-dialog прочитан: используются существующие props/copy; vendored компоненты/стили/тайминги не менялись. + +Пользователь предоставил стенд ACME: pngs.top и разрешённые поддомены с префиксом mmt*. Read-only Cloudflare token verify и exact zone query вернули200/success; token/zone active, account matched. Токен/почта/account ID сохранены только в ignored private fixture, в Git не добавляются. Следующий250 — issuance/импорт/результат через реальный API/UI с очисткой собственных DNS/certificate/file объектов и возвратом исходных настроек. Пока реальная выдача не выполнена. Обнаружено, что текущий issue автоматически назначает сертификат www-ssl/api-ssl; перед применением подготовлен private baseline этих сервисов/сертификатов/файлов/settings. Remote publishing, multi-instance/WAN и прочие ограничения матрицы сохраняются. + ## 12.5.248 — подтверждённое удаление контейнера через UI (2026-10-14) В 247 UI отправлял DELETE работающего контейнера и получал 502. Теперь dialog использует последнее состояние выбранного serverId/rosId: удалить можно только stopped; для running/unknown/error показана инструкция остановки и обновления. Backend независимо читает native список и проверяет однозначное stopped, не останавливает контейнер скрыто. Отсутствующий target или неподходящее состояние дают 409 без DELETE; после DELETE каталог читается снова, сохранённый target/сбой/некорректный ответ не становятся ok=true. Выключенный сервер отклоняется до команды. Каталоги/файлы не удаляются отдельными командами приложения; поведение native remove не подменяется обещанием очистки. diff --git a/docs/ux-ui/acceptance/containers249-deployment.json b/docs/ux-ui/acceptance/containers249-deployment.json new file mode 100644 index 00000000..d0052adc --- /dev/null +++ b/docs/ux-ui/acceptance/containers249-deployment.json @@ -0,0 +1,34 @@ +{ + "date": "2026-10-14", + "block": "12.5.249", + "containers": [ + { + "name": "mmapp-test-backend", + "image": "mmapp-test/backend:ux-containers249", + "imageId": "sha256:b0d1526c683c0ce02dcac89087e13a917e27f743af192d2fda4fe26e4f11f056", + "running": true, + "health": "healthy" + }, + { + "name": "mmapp-test-frontend", + "image": "mmapp-test/frontend:ux-containers248", + "imageId": "sha256:131d762c00aabeb275b0b17591361a83df2d73a794b27ce06ffcdeaee6424a1e", + "running": true, + "health": "not-configured" + } + ], + "sourceAudit": [ + { + "kind": "backend", + "matched": 524 + } + ], + "compiledAudit": { + "matched": 1080, + "missing": 0, + "different": 0 + }, + "frontendChanged": false, + "buildExit": 0, + "containerProfileOnNode22": 0 +} diff --git a/docs/ux-ui/acceptance/containers249-live-ui.json b/docs/ux-ui/acceptance/containers249-live-ui.json new file mode 100644 index 00000000..bb8719cc --- /dev/null +++ b/docs/ux-ui/acceptance/containers249-live-ui.json @@ -0,0 +1,46 @@ +{ + "date": "2026-10-14", + "block": "12.5.249", + "realPortalLogin": true, + "noMocks": true, + "stateStatus": 200, + "availability": [ + { + "serverId": 1, + "availability": "unsupported" + }, + { + "serverId": 2, + "availability": "unsupported" + } + ], + "legacy": [ + { + "path": "/api/containers", + "status": 200, + "count": 0 + }, + { + "path": "/api/servers/1/containers", + "status": 200, + "count": 0 + }, + { + "path": "/api/servers/2/containers", + "status": 200, + "count": 0 + } + ], + "views": [ + { + "width": 1440, + "globalOverflow": false + }, + { + "width": 390, + "globalOverflow": false + } + ], + "pageErrors": [], + "nonAuthApiWrites": 0 +} diff --git a/docs/ux-ui/acceptance/containers249-regression.json b/docs/ux-ui/acceptance/containers249-regression.json new file mode 100644 index 00000000..d5152d2f --- /dev/null +++ b/docs/ux-ui/acceptance/containers249-regression.json @@ -0,0 +1,195 @@ +{ + "date": "2026-10-14", + "block": "12.5.249", + "scope": "full backend profile list on owned isolated PostgreSQL; no production DSN", + "database": "mm_accept_166_1791980090323", + "profiles": [ + { + "profile": "test:probes", + "passed": true, + "durationMs": 23417, + "skippedLineCount": 1, + "unavailableSqlBranches": [] + }, + { + "profile": "test:exec", + "passed": true, + "durationMs": 8748, + "skippedLineCount": 1, + "unavailableSqlBranches": [] + }, + { + "profile": "test:alert-engine", + "passed": true, + "durationMs": 22044, + "skippedLineCount": 0, + "unavailableSqlBranches": [] + }, + { + "profile": "test:alerts-telegram", + "passed": true, + "durationMs": 15207, + "skippedLineCount": 10, + "unavailableSqlBranches": [] + }, + { + "profile": "test:auth", + "passed": true, + "durationMs": 11062, + "skippedLineCount": 0, + "unavailableSqlBranches": [] + }, + { + "profile": "test:wireguard", + "passed": true, + "durationMs": 15491, + "skippedLineCount": 1, + "unavailableSqlBranches": [] + }, + { + "profile": "test:ipsec", + "passed": true, + "durationMs": 16407, + "skippedLineCount": 0, + "unavailableSqlBranches": [] + }, + { + "profile": "test:traffic-rate", + "passed": true, + "durationMs": 51136, + "skippedLineCount": 1, + "unavailableSqlBranches": [] + }, + { + "profile": "test:traffic-flow", + "passed": true, + "durationMs": 55279, + "skippedLineCount": 2, + "unavailableSqlBranches": [ + "Monthly SQL: skip (requires an owned isolated database)", + "IPv6 UDP/worker/SQL: skip (requires an owned isolated database)" + ] + }, + { + "profile": "test:users", + "passed": true, + "durationMs": 7848, + "skippedLineCount": 0, + "unavailableSqlBranches": [] + }, + { + "profile": "test:pg", + "passed": true, + "durationMs": 10026, + "skippedLineCount": 0, + "unavailableSqlBranches": [] + }, + { + "profile": "test:backups", + "passed": true, + "durationMs": 12245, + "skippedLineCount": 8, + "unavailableSqlBranches": [] + }, + { + "profile": "test:scheduler", + "passed": true, + "durationMs": 6936, + "skippedLineCount": 5, + "unavailableSqlBranches": [] + }, + { + "profile": "test:live-maps", + "passed": true, + "durationMs": 28667, + "skippedLineCount": 0, + "unavailableSqlBranches": [] + }, + { + "profile": "test:ospf-optimizer", + "passed": true, + "durationMs": 6977, + "skippedLineCount": 1, + "unavailableSqlBranches": [] + }, + { + "profile": "test:recursive-routes", + "passed": true, + "durationMs": 4117, + "skippedLineCount": 0, + "unavailableSqlBranches": [] + }, + { + "profile": "test:config-sync", + "passed": true, + "durationMs": 19206, + "skippedLineCount": 1, + "unavailableSqlBranches": [ + "network-disabled-server.test.ts: invalid IDs passed; own SQL/disabled API checks skipped (MMAPP_OWN_TEST_DB required)" + ] + }, + { + "profile": "test:ros-file", + "passed": true, + "durationMs": 1600, + "skippedLineCount": 1, + "unavailableSqlBranches": [] + }, + { + "profile": "test:containers", + "passed": true, + "durationMs": 8611, + "skippedLineCount": 0, + "unavailableSqlBranches": [] + }, + { + "profile": "test:certificates", + "passed": true, + "durationMs": 13336, + "skippedLineCount": 3, + "unavailableSqlBranches": [] + }, + { + "profile": "test:netflow-settings", + "passed": true, + "durationMs": 5398, + "skippedLineCount": 0, + "unavailableSqlBranches": [] + }, + { + "profile": "test:netflow-purge", + "passed": true, + "durationMs": 2367, + "skippedLineCount": 0, + "unavailableSqlBranches": [] + }, + { + "profile": "test:evolution", + "passed": true, + "durationMs": 5728, + "skippedLineCount": 1, + "unavailableSqlBranches": [] + }, + { + "profile": "test:system-database", + "passed": true, + "durationMs": 19615, + "skippedLineCount": 9, + "unavailableSqlBranches": [] + }, + { + "profile": "test:server-privacy", + "passed": true, + "durationMs": 6656, + "skippedLineCount": 0, + "unavailableSqlBranches": [] + } + ], + "passed": true, + "cleanup": { + "databaseRemoved": true, + "roleRemoved": true, + "containerRemoved": true + }, + "durationMs": 378124 +}