fix(containers): исключить опрос выключенных серверов и ложные пустые данные

This commit is contained in:
Denozordec committed 2026-10-14 19:25:14 +07:00
1 parent 74151176d7
commit dc7b22940a
16 files changed
+470 -88

No files matched your search

+1 -1
View File
@@ -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",
+9 -2
View File
@@ -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<Awaited<ReturnType<NonNullable<Parameters<typeof createContainersRoutes>[0]>["getServer"]>>>),remove:async()=>{calls++;if(removalError)throw removalError}}),{prefix:"/api"})
await mutations.register(createContainersRoutes({getServer:async()=>missing?null:({id:1,enabled} as NonNullable<Awaited<ReturnType<NonNullable<Parameters<typeof createContainersRoutes>[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")
+15 -6
View File
@@ -21,7 +21,7 @@ const RosIdBodySchema = z.object({
type RosIdBody = z.infer<typeof RosIdBodySchema>
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) {
+30 -1
View File
@@ -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<void>(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<typeof listContainersForServer>[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<void>((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")
+39 -67
View File
@@ -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<ContainerLive[]> {
if (!server.enabled) throw new Error("Сервер выключен. Опрос контейнеров недоступен.")
const client = MikrotikClient.fromServer(server)
try {
const [raw, envsRaw, mountsRaw] = await Promise.all([
client.get<RosContainer[]>("/container"),
client.get<RosContainerEnv[]>("/container/envs").catch(() => [] as RosContainerEnv[]),
client.get<RosContainerMount[]>("/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<ContainerLive[]> {
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<ContainerLive[]> {
try {
return await fetchContainersForServer(server)
} catch {
return []
}
return fetchContainersForServer(server)
}
export async function countContainers():Promise<number|undefined>{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
}
@@ -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<typeof readContainerServerState>[0]
const server = { id: 1, name: "Router", enabled: true } as Parameters<typeof readContainerServerState>[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<unknown> => 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])
+2 -1
View File
@@ -26,6 +26,7 @@ function missingPackage(error: unknown) {
}
export async function readContainerServerState(server: ServerRow, read: Reader): Promise<ContainerServerState> {
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<typeof rawSchema>
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<ContainersState> {
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) })
}))
+15 -1
View File
@@ -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 не подменяется обещанием очистки.
+15 -1
View File
@@ -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 не подменяется обещанием очистки.
+15 -1
View File
@@ -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 не подменяется обещанием очистки.
+18 -4
View File
@@ -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 — отдельные группы по таблице выше. Ограничения текущего стенда не превращать в обещание приёмки всех конфигураций.
+15 -1
View File
@@ -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 не подменяется обещанием очистки.
+15 -1
View File
@@ -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 не подменяется обещанием очистки.
@@ -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
}
@@ -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
}
@@ -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
}