From 74151176d76ecdaf0485e9a6b68a66b837c5e8bf Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 14 Oct 2026 19:03:58 +0700 Subject: [PATCH] =?UTF-8?q?fix(containers):=20=D0=BF=D0=BE=D0=B4=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B0=D1=82=D1=8C=20=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D1=83=20=D0=B8=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/containers/page.tsx | 9 ++- backend/package.json | 2 +- .../acceptance/container-removal-live.mjs | 71 +++++++++++++++++++ .../acceptance/containers-lifecycle.mjs | 5 +- backend/src/routes/containers-state.test.ts | 12 ++++ backend/src/routes/containers.ts | 10 +-- .../src/services/container-removal.test.ts | 32 +++++++++ backend/src/services/container-removal.ts | 26 +++++++ backend/src/services/containers-live.ts | 7 +- deploy/TEST-CONTAINERS.md | 2 +- docs/ux-ui-external-acceptance.md | 16 ++++- docs/ux-ui-plan.md | 16 ++++- docs/ux-ui-progress.md | 16 ++++- docs/ux-ui-remaining-acceptance.md | 24 +++++-- docs/ux-ui-stage-12.md | 16 ++++- docs/ux-ui-test-server.md | 16 ++++- .../acceptance/containers248-deployment.json | 40 +++++++++++ .../acceptance/containers248-live-ui.json | 60 ++++++++++++++++ .../acceptance/containers248-native.json | 51 +++++++++++++ tests/e2e/containers-operations.spec.ts | 11 ++- 20 files changed, 414 insertions(+), 28 deletions(-) create mode 100644 backend/scripts/acceptance/container-removal-live.mjs create mode 100644 backend/src/services/container-removal.test.ts create mode 100644 backend/src/services/container-removal.ts create mode 100644 docs/ux-ui/acceptance/containers248-deployment.json create mode 100644 docs/ux-ui/acceptance/containers248-live-ui.json create mode 100644 docs/ux-ui/acceptance/containers248-native.json diff --git a/app/(main)/containers/page.tsx b/app/(main)/containers/page.tsx index 87de557c..4f5329cd 100644 --- a/app/(main)/containers/page.tsx +++ b/app/(main)/containers/page.tsx @@ -375,9 +375,12 @@ function ContainersContent() { const stopped = scoped.filter((c) => c.status === "stopped").length const errors = scoped.filter((c) => c.status === "error").length const statusKnown = known && scoped.every(container => container.status !== "unknown") + const currentDeleteTarget = deleteTarget ? displayContainers.find(row => row.id === deleteTarget.id && row.rosId === deleteTarget.rosId && row.serverId === deleteTarget.serverId) : undefined + const deleteStopped = currentDeleteTarget?.status === "stopped" async function mutate(c: ContainerView, action: ContainerAction) { if (!isLive || !c.rosId || !canWrite || !nodeFresh(c.serverId) || mutationLock.current || !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId)) return + if (action === "remove" && !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId && row.status === "stopped")) return mutationLock.current = true const controller = new AbortController() mutationController.current = controller @@ -623,12 +626,12 @@ function ContainersContent() { {deleteTarget && { if (!open && !busyId) { setDeleteTarget(null); setOperationError(null) } }}> - Удалить контейнер?{deleteTarget.name} · сервер {serverById[deleteTarget.serverId]?.name ?? deleteTarget.serverId}. Будет отправлена команда удаления в RouterOS. + Удалить контейнер?{deleteTarget.name} · сервер {serverById[deleteTarget.serverId]?.name ?? deleteTarget.serverId}. {deleteStopped ? "Будет удалён остановленный контейнер. Дополнительная очистка файлов не выполняется." : "Сначала закройте это окно, остановите контейнер через меню действий и обновите каталог. Удаление доступно только после подтверждённой остановки."} {operationError &&

{operationError}

} - {commandNeedsRead && } + {(commandNeedsRead || !deleteStopped) && } Отмена - row.id === deleteTarget.id && row.rosId === deleteTarget.rosId)} aria-busy={!!busyId} onClick={() => { void mutate(deleteTarget, "remove") }}>{busyId ? "Удаление…" : "Удалить"} + { void mutate(deleteTarget, "remove") }}>{busyId ? "Удаление…" : "Удалить"}
} diff --git a/backend/package.json b/backend/package.json index 14842874..fbb3adaf 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/routes/containers-state.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: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/scripts/acceptance/container-removal-live.mjs b/backend/scripts/acceptance/container-removal-live.mjs new file mode 100644 index 00000000..d332ac97 --- /dev/null +++ b/backend/scripts/acceptance/container-removal-live.mjs @@ -0,0 +1,71 @@ +// Real Portal login; credentials stay in the caller's ignored private fixture. +import fs from 'node:fs'; +import assert from 'node:assert/strict'; +import { chromium, expect } from '@playwright/test'; + +(async () => { + const phase = process.argv[2]; + assert(['guard', 'remove', 'repeat'].includes(phase)); + const login = JSON.parse(fs.readFileSync(process.env.MMAPP_PORTAL_FIXTURE || 'tmp/portal195-private.json')); + const own = JSON.parse(fs.readFileSync(process.env.MMAPP_CONTAINER_FIXTURE || 'tmp/state248-public.json')); + const reportPath = 'docs/ux-ui/acceptance/containers248-live-ui.json'; + const report = fs.existsSync(reportPath) ? JSON.parse(fs.readFileSync(reportPath)) : { date: '2026-10-14', block: '12.5.248', realPortalLogin: true, noMocks: true, steps: [], pageErrors: [] }; + const browser = await chromium.launch({ channel: 'msedge', headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + page.on('pageerror', error => report.pageErrors.push(error.message)); + await page.goto((process.env.MMAPP_TEST_URL || 'http://localhost:3000') + '/containers'); + await page.getByLabel('Email', { exact: true }).fill(login.email); + await page.getByLabel('Пароль', { exact: true }).fill(login.password); + await page.getByRole('button', { name: 'Войти', exact: true }).click(); + await expect(page.getByRole('navigation', { name: 'Путь страницы' })).toContainText('Контейнеры', { timeout: 30000 }); + const endpoint = `/api/servers/${own.serverId}/containers/remove`; + const direct = () => page.evaluate(async ({ endpoint, rosId }) => { + const r = await fetch(endpoint, { method: 'POST', headers: { Authorization: 'Bearer ' + localStorage.getItem('mmapp_token'), 'Content-Type': 'application/json' }, body: JSON.stringify({ rosId }) }); + const body = await r.json(); + return { status: r.status, ok: body.ok === true, error: body.error }; + }, { endpoint, rosId: own.rosId }); + if (phase === 'repeat') { + const result = await direct(); assert.equal(result.status, 409); assert.equal(result.ok, false); assert.match(result.error, /уже отсутствует/); + report.steps.push({ phase, status: result.status, absentConflict: true }); + } else { + await page.getByRole('button', { name: 'Действия контейнера ' + own.name, exact: true }).click(); + await page.getByRole('menuitem', { name: 'Удалить', exact: true }).click(); + const dialog = page.getByRole('alertdialog'); + await expect(dialog).toContainText(own.name); + if (phase === 'guard') { + report.layout = []; + for (const width of [1440, 390]) { + await page.setViewportSize({ width, height: 1000 }); + await expect(dialog).toContainText('после подтверждённой остановки'); + await expect(dialog.getByRole('button', { name: 'Удалить', exact: true })).toBeDisabled(); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth > innerWidth), { timeout: 5000 }).toBe(false); + const layout = await page.evaluate(() => ({ viewport: innerWidth, scrollWidth: document.documentElement.scrollWidth })); + (report.layout ??= []).push(layout); + if (layout.scrollWidth > layout.viewport) { + report.overflow = await page.evaluate(() => Array.from(document.querySelectorAll('*')).map(el => ({tag:el.tagName,slot:el.getAttribute('data-slot'),left:el.getBoundingClientRect().left,right:el.getBoundingClientRect().right,width:el.getBoundingClientRect().width,position:getComputedStyle(el).position})).filter(el=>el.right>innerWidth+1).slice(0,20)); + await page.screenshot({ path: `tmp/containers248-guard-${width}.png`, fullPage: true }); + } + assert.equal(layout.scrollWidth > layout.viewport, false); + } + const result = await direct(); assert.equal(result.status, 409); assert.equal(result.ok, false); + report.steps.push({ phase, viewports: [1440, 390], uiDeleteDisabled: true, backendConflict: result.status }); + } else { + await expect(dialog).toContainText('остановленный контейнер'); + const response = page.waitForResponse(r => new URL(r.url()).pathname === endpoint && r.request().method() === 'POST'); + await dialog.getByRole('button', { name: 'Удалить', exact: true }).click(); + const r = await response; assert.equal(r.request().postDataJSON().rosId, own.rosId); assert.equal(r.status(), 200); assert.equal((await r.json()).ok, true); + await expect(dialog).toBeHidden(); + await expect(page.getByRole('button', { name: 'Действия контейнера ' + own.name, exact: true })).toHaveCount(0); + report.steps.push({ phase, status: 200, receiptOk: true, targetMatched: true, rowAbsent: true }); + } + } + assert.deepEqual(report.pageErrors, []); + delete report.overflow; + if (report.failure?.phase === phase) { (report.harnessFailures ??= []).push(report.failure); delete report.failure; } + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n'); + console.log('Live container removal: ' + phase + ' passed'); + } catch (error) { + report.failure = { phase, name: error.name, ...(error.name === 'AssertionError' ? { actual: error.actual, expected: error.expected } : {}) }; fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n'); throw error; + } finally { await browser.close(); } +})().catch(error => { console.error('Live container removal failed: ' + error.name); process.exitCode = 1; }); diff --git a/backend/scripts/acceptance/containers-lifecycle.mjs b/backend/scripts/acceptance/containers-lifecycle.mjs index 02f550c4..7f7695b0 100644 --- a/backend/scripts/acceptance/containers-lifecycle.mjs +++ b/backend/scripts/acceptance/containers-lifecycle.mjs @@ -85,7 +85,10 @@ try { await reboot(state, 'uninstallReboot'); } else if (phase === 'final') { const p = await poll(() => client.get('/system/package', 4000), p => !packages(p).some(p => p.name === 'container')); - assert.deepEqual(packages(p), state.beforePackages); assert.equal(normalizedExport(await client.exportConfigScript()), state.beforeExport); + assert.deepEqual(packages(p), state.beforePackages); + // Automatic timezone discovery can settle after package removal/reboot. + // Wait for equality without excluding settings or logging the private export. + await poll(async () => normalizedExport(await client.exportConfigScript()), value => value === state.beforeExport); await control.get('/system/resource', 5000); const report = { date: '2026-10-14', block: '12.5.247', targetHost: server.host, controlHost: '194.226.121.150', stages: state.stages, installReboot: state.installReboot, uninstallReboot: state.uninstallReboot, originalPackagesMatched: true, originalNormalizedExportSha256: state.beforeExportSha256, originalNormalizedExportMatched: true, controlReachable: true, passwordChanges: 0, deviceModeChanges: 0, firmwareVersionChanged: false, passed: true }; fs.writeFileSync('/checks/containers247-native.json', JSON.stringify(report, null, 2) + '\n'); fs.unlinkSync(statePath); console.log(JSON.stringify(report)); diff --git a/backend/src/routes/containers-state.test.ts b/backend/src/routes/containers-state.test.ts index 216d1c45..8ec2b9d5 100644 --- a/backend/src/routes/containers-state.test.ts +++ b/backend/src/routes/containers-state.test.ts @@ -3,6 +3,7 @@ import Fastify from "fastify" import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod" import { createContainersRoutes } from "./containers.js" import { permissionForRequest } from "../lib/permissions.js" +import { ContainerRemovalConflict } from "../services/container-removal.js" const empty = {collectedAt:new Date().toISOString(),servers:[]} let value:unknown=empty,failed=false const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler) @@ -16,3 +17,14 @@ 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 +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"}) +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}) +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 c54eb2b2..df3c6752 100644 --- a/backend/src/routes/containers.ts +++ b/backend/src/routes/containers.ts @@ -12,6 +12,7 @@ import { stopContainer, } from "../services/containers-live.js" import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js" +import { ContainerRemovalConflict } from "../services/container-removal.js" const RosIdBodySchema = z.object({ rosId: z.string().min(1), @@ -20,7 +21,7 @@ const RosIdBodySchema = z.object({ type RosIdBody = z.infer type MutateFn = typeof startContainer -export function createContainersRoutes(deps: { getState?: typeof listContainersState } = {}): FastifyPluginAsyncZod { +export function createContainersRoutes(deps: { getState?: typeof listContainersState; getServer?: typeof getEnabledServerById; remove?: MutateFn } = {}): FastifyPluginAsyncZod { return async (app) => { app.get("/containers/state", async (_req, reply) => { const state = containersStateSchema.safeParse(await (deps.getState ?? listContainersState)()) @@ -44,13 +45,14 @@ export function createContainersRoutes(deps: { getState?: typeof listContainersS app.post(path, { schema: { params: ServerIdParamSchema, body: RosIdBodySchema } }, async (req, reply) => { const params = req.params as ServerIdParams const body = req.body as RosIdBody - 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" }) + if (!server.enabled) return reply.status(409).send({ error: "Сервер выключен. Операции с контейнерами недоступны." }) try { await fn(server, body.rosId) return reply.send({ ok: true }) } catch (err) { - return reply.status(502).send({ + return reply.status(err instanceof ContainerRemovalConflict ? 409 : 502).send({ error: err instanceof Error ? err.message : "Ошибка RouterOS", }) } @@ -60,7 +62,7 @@ export function createContainersRoutes(deps: { getState?: typeof listContainersS registerMutate("/servers/:id/containers/start", startContainer) registerMutate("/servers/:id/containers/stop", stopContainer) registerMutate("/servers/:id/containers/restart", restartContainer) - registerMutate("/servers/:id/containers/remove", removeContainer) + registerMutate("/servers/:id/containers/remove", deps.remove ?? removeContainer) } } diff --git a/backend/src/services/container-removal.test.ts b/backend/src/services/container-removal.test.ts new file mode 100644 index 00000000..84536eb0 --- /dev/null +++ b/backend/src/services/container-removal.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict" +import { ContainerRemovalConflict, removeStoppedContainer } from "./container-removal.js" + +const stopped = { ".id": "*A", stopped: "true" } +let deletes: string[] = [] +let reads: unknown[] = [] +const client = { get: async () => { const next = reads.shift(); if (next instanceof Error) throw next; return next }, delete: async (path: string) => { deletes.push(path) } } +for (const row of [{ ".id": "*A", running: "true" }, { ".id": "*A" }, { ...stopped, running: true }, { ...stopped, extracting: true }]) { + reads = [[row]]; deletes = [] + await assert.rejects(removeStoppedContainer(client, "*A"), ContainerRemovalConflict) + assert.deepEqual(deletes, []) +} +reads = [[]]; deletes = [] +await assert.rejects(removeStoppedContainer(client, "*A"), /уже отсутствует/) +assert.deepEqual(deletes, []) +for (const value of [[stopped, stopped], { containers: [] }, new Error("read offline")]) { + reads = [value]; deletes = [] + await assert.rejects(removeStoppedContainer(client, "*A")) + assert.deepEqual(deletes, []) +} +reads = [[stopped, { ".id": "*B", running: "true" }], [{ ".id": "*B", running: "true" }]]; deletes = [] +await removeStoppedContainer(client, "*A") +assert.deepEqual(deletes, ["/container/*A"]) +reads = [[]] +await assert.rejects(removeStoppedContainer(client, "*A"), ContainerRemovalConflict) +assert.equal(deletes.length, 1, "A repeated request never deletes another container") +for (const after of [[stopped], new Error("confirmation offline"), { containers: [] }]) { + reads = [[stopped], after]; deletes = [] + await assert.rejects(removeStoppedContainer(client, "*A")) + assert.equal(deletes.length, 1) +} +console.log("Container removal: running/unknown/conflicting/transient/absent/invalid/read failure cannot delete; stopped deletion confirmed; repeat cannot delete another target; failed confirmation never succeeds") diff --git a/backend/src/services/container-removal.ts b/backend/src/services/container-removal.ts new file mode 100644 index 00000000..c54ca449 --- /dev/null +++ b/backend/src/services/container-removal.ts @@ -0,0 +1,26 @@ +import { z } from "zod" +import { containerRuntimeStatus } from "./container-runtime-status.js" + +const rowsSchema = z.array(z.object({ ".id": z.string().min(1) }).passthrough()).superRefine((rows, ctx) => { + if (new Set(rows.map(row => row[".id"])).size !== rows.length) ctx.addIssue({ code: "custom", message: "Дубли RouterOS ID" }) +}) + +export class ContainerRemovalConflict extends Error {} + +type Client = { + get(path: string): Promise + delete(path: string): Promise +} + +/** Never stop implicitly: removal requires a fresh, unambiguous stopped state. */ +export async function removeStoppedContainer(client: Client, rosId: string): Promise { + const rows = rowsSchema.parse(await client.get("/container")) + const target = rows.find(row => row[".id"] === rosId) + if (!target) throw new ContainerRemovalConflict("Контейнер уже отсутствует. Обновите каталог.") + if (containerRuntimeStatus(target) !== "stopped") { + throw new ContainerRemovalConflict("Удаление доступно только после подтверждённой остановки контейнера. Остановите его и обновите каталог.") + } + await client.delete(`/container/${encodeURIComponent(rosId)}`) + const after = rowsSchema.parse(await client.get("/container")) + if (after.some(row => row[".id"] === rosId)) throw new Error("Удаление не подтверждено повторным чтением RouterOS. Обновите каталог перед новым действием.") +} diff --git a/backend/src/services/containers-live.ts b/backend/src/services/containers-live.ts index 7e6a637b..12fa0e65 100644 --- a/backend/src/services/containers-live.ts +++ b/backend/src/services/containers-live.ts @@ -174,10 +174,6 @@ export async function listContainersForServer(server: ServerRow): Promise{return boundedCompleteCount(()=>listContainersState(),completeContainerCount)} -function encodeRosId(rosId: string): string { - return encodeURIComponent(rosId) -} - export async function startContainer(server: ServerRow, rosId: string): Promise { const client = MikrotikClient.fromServer(server) await client.post("/container/start", { ".id": rosId }) @@ -195,7 +191,8 @@ export async function restartContainer(server: ServerRow, rosId: string): Promis export async function removeContainer(server: ServerRow, rosId: string): Promise { const client = MikrotikClient.fromServer(server) - await client.delete(`/container/${encodeRosId(rosId)}`) + const { removeStoppedContainer } = await import("./container-removal.js") + await removeStoppedContainer(client, rosId) } export async function getEnabledServerById(serverId: string | number) { diff --git a/deploy/TEST-CONTAINERS.md b/deploy/TEST-CONTAINERS.md index 74bd4527..f21da872 100644 --- a/deploy/TEST-CONTAINERS.md +++ b/deploy/TEST-CONTAINERS.md @@ -15,4 +15,4 @@ 5. Через реальный Portal/API/UI проверить start → running → stop → stopped → restart → running → remove, отрицательные права и повторное действие. Сверять native state, не только toast/HTTP ack. Отдельно фиксировать неизвестные промежуточные статусы. 6. Удалить только собственный контейнер, veth/bridge/IP/каталоги/image/import файлы и сравнить исходные конфигурации. Если согласовано полное возвращение пакетов — удалить только новый container package и выполнить второй контролируемый reboot; подтвердить исходный набор/версию. Сохранение пакета требует явного решения пользователя. -Текущий статус (247, 2026-10-14): **после явного согласия выполнены установка и удаление выбранного пакета с двумя перезагрузками**. Через настоящий Portal/UI подтверждены start/stop/start/restart; удаление работающего контейнера вернуло 502 и остаётся открытым. Собственные объекты удалены native API; исходные установленные пакеты и нормализованный экспорт совпали. Backend учитывает native running/stopped flags и envlists. E2E не стартовали из-за таймаута webServer. [Native-протокол](../docs/ux-ui/acceptance/containers247-native.json), [UI-протокол](../docs/ux-ui/acceptance/containers247-live-ui.json). Ранние формулировки подготовки выше отражают состояние до полученного согласия; пакет уже удалён. Следующий блок — исправление и приёмка удаления через UI. Полная история и границы — [журнал](../docs/ux-ui-progress.md). +Текущий статус (248, 2026-10-14): **после отдельного повторного согласия удаление через UI исправлено и принято**. Работающий контейнер блокируется UI и API409; после UI stop и native stopped выполнено UI remove200, отсутствие подтверждено повторным чтением. Повторный запрос даёт409. Start/stop/restart из247 и remove248 приняты в записанных границах. Backend учитывает native running/stopped flags и envlists. 29 E2E, backend-профиль, build и ESLint прошли. Собственные объекты и пакет удалены второй разрешённой перезагрузкой; исходные установленные пакеты и нормализованный экспорт совпали. [Native-протокол](../docs/ux-ui/acceptance/containers248-native.json), [UI-протокол](../docs/ux-ui/acceptance/containers248-live-ui.json). Ранние формулировки подготовки выше отражают состояние до полученного согласия; пакет уже удалён. Полная история и границы — [журнал](../docs/ux-ui-progress.md). diff --git a/docs/ux-ui-external-acceptance.md b/docs/ux-ui-external-acceptance.md index a47520f4..eba34121 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: 247 — разрешённый container-пакет из источника mikrotik.ltd установлен и удалён на 104.165.244.7 с двумя перезагрузками. Исправлена обработка native running/stopped flags. Через реальный Portal/UI прошли start, stop, повторный start и restart; remove работающего контейнера вернул 502 и остаётся открытым. Собственные объекты удалены через native API, исходный экспорт и пакеты совпали. Backend ux-containers247, frontend ux-backups237. Следующий 248 — исправление и приёмка удаления контейнера; отдельно выяснить расхождение авторизации REST/SSH. Этап 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 открыт. 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.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 не подменяется обещанием очистки. + +Проверки: test:containers включает новый removal-профиль в общий test:all/CI; проверены native running/unknown/conflicting/transient, отсутствие target, неверный каталог, read failure, удаление только выбранного stopped, повтор без удаления другого target и сбой подтверждения. HTTP проверяет disabled/missing server без dispatch, 409 conflict, 502 uncertainty, ok=true и существующий write-permission contract. Это изолированные service/HTTP проверки, не отдельный live RBAC сценарий. Backend/contracts build, production frontend build и профильный ESLint прошли. 29 уникальных E2E containers-data/operations прошли за 58.8 секунды; ранее в 247 webServer не стартовал, нынешний elevated запуск стартовал успешно, точная причина прошлого таймаута не доказана. В E2E обычный HTTP-путь с тестовыми ответами, не встроенные демонстрационные данные. Полная регрессия не повторялась. + +Пользователь отдельно разрешил повторную установку того же проверенного container NPK из источника 246 и две перезагрузки. Native baseline снят до передачи. Создан собственный mmc248-busybox с veth mmc248-e57b223b и pinned BusyBox digest из 247; core/option/version/device-mode/пароли/registry credentials/bridge/IP/NAT не менялись. Настоящий Portal без моков: UI start200 и native running; dialog1440/390 блокирует удаление и прямой API409; UI stop200 и native stopped; UI remove200/ok=true, строка отсутствует; повторный API409 «уже отсутствует». JS ошибок нет. При изменении viewport закрывающееся меню временно давало scrollWidth780 при width390; после штатной анимации ширина390. Helper ждёт стабилизации, ReUI styles/timings не менялись. Изначальный неверный heading locator исправлен на существующий breadcrumb. [Live UI](ux-ui/acceptance/containers248-live-ui.json). + +Собственные контейнер/veth/image/root/NPK отсутствуют. Container package удалён второй однократной подтверждённой перезагрузкой; исходные installed routeros/option7.24.5 и полный нормализованный export совпали. Автоматический timezone вернулся к исходному Europe/Warsaw после reboot; helper ждёт полного совпадения без исключения clock и не печатает приватный export при ошибке. Двойная запись create в native stages означает дополнительное read-only получение имени существующего объекта, не второе создание. Оба CHR доступны; приватные baseline удалены. SFTP с прежним паролем и прямой workstation REST200 подтвердились: один вход прервался auth timeout до завершения аутентификации, поэтому не означает неверный пароль. Причина прежнего401 не доказана. [Native/cleanup](ux-ui/acceptance/containers248-native.json). + +Backend и frontend ux-containers248 собраны/развёрнуты, backend healthy; frontend не имеет Docker healthcheck. Замороженные build inputs: backend523/frontend527 совпали, compiled backend1080 без missing/different. Поздние acceptance-only helper правки не входят в этот source audit; runtime TS/TSX соответствуют сборке. Изменены только два image tag в приватном .env; PostgreSQL/Portal не пересоздавались. Первая frontend сборка предшествовала уточнению текста о дополнительной очистке, выполнена финальная сборка из уточнённого context. [Deployment](ux-ui/acceptance/containers248-deployment.json). Подход с native флагами и envlists согласуется с [RouterOS manual](https://manual.mikrotik.com/docs/containers/); требование stopped — явная политика безопасного удаления приложения. + +Контейнерная приёмка start/stop/restart/remove закрыта в этих границах; работающий контейнер нужно сначала остановить. Следующий 249 — legacy GET контейнеров: проверить, что прямое чтение выбранного выключенного сервера не опрашивает RouterOS, затем согласовать статусы старого DTO с native flags. ACME Cloudflare token/test-domain, удалённая публикация Gitea, multi-instance/WAN/client OS и остальные ограничения матрицы остаются открытыми. Сам тестовый пакет снова удалён, новое применение без согласия не планируется. + ## 12.5.247 — фактические состояния и жизненный цикл контейнера (2026-10-14) Пользователь разрешил конкретный пакет 246 и две перезагрузки. На 104.165.244.7 установлен только container-7.24.5.npk из указанного источника mikrotik.ltd → MikroTikPatch, с ранее проверенным SHA256. Core/option/python/keygen/patch scripts не применялись; версия RouterOS осталась 7.24.5. Образ BusyBox 1.37.0 linux/amd64 закреплён digest busybox@sha256:bdf57e528e45e4433820e045b29b45978255a1c9e38353532d90a01445013f82e. Созданы только собственные контейнер mmc247-busybox, veth mmc247-39da5c21 и каталог; bridge, IP, NAT, маршруты и глобальные registry credentials не изменялись. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 64eef26f..661319c2 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,9 +1,23 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-14: 247 — разрешённый container-пакет из источника mikrotik.ltd установлен и удалён на 104.165.244.7 с двумя перезагрузками. Исправлена обработка native running/stopped flags. Через реальный Portal/UI прошли start, stop, повторный start и restart; remove работающего контейнера вернул 502 и остаётся открытым. Собственные объекты удалены через native API, исходный экспорт и пакеты совпали. Backend ux-containers247, frontend ux-backups237. Следующий 248 — исправление и приёмка удаления контейнера; отдельно выяснить расхождение авторизации REST/SSH. Этап 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 открыт. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-14. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. +## 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 не подменяется обещанием очистки. + +Проверки: test:containers включает новый removal-профиль в общий test:all/CI; проверены native running/unknown/conflicting/transient, отсутствие target, неверный каталог, read failure, удаление только выбранного stopped, повтор без удаления другого target и сбой подтверждения. HTTP проверяет disabled/missing server без dispatch, 409 conflict, 502 uncertainty, ok=true и существующий write-permission contract. Это изолированные service/HTTP проверки, не отдельный live RBAC сценарий. Backend/contracts build, production frontend build и профильный ESLint прошли. 29 уникальных E2E containers-data/operations прошли за 58.8 секунды; ранее в 247 webServer не стартовал, нынешний elevated запуск стартовал успешно, точная причина прошлого таймаута не доказана. В E2E обычный HTTP-путь с тестовыми ответами, не встроенные демонстрационные данные. Полная регрессия не повторялась. + +Пользователь отдельно разрешил повторную установку того же проверенного container NPK из источника 246 и две перезагрузки. Native baseline снят до передачи. Создан собственный mmc248-busybox с veth mmc248-e57b223b и pinned BusyBox digest из 247; core/option/version/device-mode/пароли/registry credentials/bridge/IP/NAT не менялись. Настоящий Portal без моков: UI start200 и native running; dialog1440/390 блокирует удаление и прямой API409; UI stop200 и native stopped; UI remove200/ok=true, строка отсутствует; повторный API409 «уже отсутствует». JS ошибок нет. При изменении viewport закрывающееся меню временно давало scrollWidth780 при width390; после штатной анимации ширина390. Helper ждёт стабилизации, ReUI styles/timings не менялись. Изначальный неверный heading locator исправлен на существующий breadcrumb. [Live UI](ux-ui/acceptance/containers248-live-ui.json). + +Собственные контейнер/veth/image/root/NPK отсутствуют. Container package удалён второй однократной подтверждённой перезагрузкой; исходные installed routeros/option7.24.5 и полный нормализованный export совпали. Автоматический timezone вернулся к исходному Europe/Warsaw после reboot; helper ждёт полного совпадения без исключения clock и не печатает приватный export при ошибке. Двойная запись create в native stages означает дополнительное read-only получение имени существующего объекта, не второе создание. Оба CHR доступны; приватные baseline удалены. SFTP с прежним паролем и прямой workstation REST200 подтвердились: один вход прервался auth timeout до завершения аутентификации, поэтому не означает неверный пароль. Причина прежнего401 не доказана. [Native/cleanup](ux-ui/acceptance/containers248-native.json). + +Backend и frontend ux-containers248 собраны/развёрнуты, backend healthy; frontend не имеет Docker healthcheck. Замороженные build inputs: backend523/frontend527 совпали, compiled backend1080 без missing/different. Поздние acceptance-only helper правки не входят в этот source audit; runtime TS/TSX соответствуют сборке. Изменены только два image tag в приватном .env; PostgreSQL/Portal не пересоздавались. Первая frontend сборка предшествовала уточнению текста о дополнительной очистке, выполнена финальная сборка из уточнённого context. [Deployment](ux-ui/acceptance/containers248-deployment.json). Подход с native флагами и envlists согласуется с [RouterOS manual](https://manual.mikrotik.com/docs/containers/); требование stopped — явная политика безопасного удаления приложения. + +Контейнерная приёмка start/stop/restart/remove закрыта в этих границах; работающий контейнер нужно сначала остановить. Следующий 249 — legacy GET контейнеров: проверить, что прямое чтение выбранного выключенного сервера не опрашивает RouterOS, затем согласовать статусы старого DTO с native flags. ACME Cloudflare token/test-domain, удалённая публикация Gitea, multi-instance/WAN/client OS и остальные ограничения матрицы остаются открытыми. Сам тестовый пакет снова удалён, новое применение без согласия не планируется. + ## 12.5.247 — фактические состояния и жизненный цикл контейнера (2026-10-14) Пользователь разрешил конкретный пакет 246 и две перезагрузки. На 104.165.244.7 установлен только container-7.24.5.npk из указанного источника mikrotik.ltd → MikroTikPatch, с ранее проверенным SHA256. Core/option/python/keygen/patch scripts не применялись; версия RouterOS осталась 7.24.5. Образ BusyBox 1.37.0 linux/amd64 закреплён digest busybox@sha256:bdf57e528e45e4433820e045b29b45978255a1c9e38353532d90a01445013f82e. Созданы только собственные контейнер mmc247-busybox, veth mmc247-39da5c21 и каталог; bridge, IP, NAT, маршруты и глобальные registry credentials не изменялись. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index f380600b..bccfd3f0 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-14: 247 — разрешённый container-пакет из источника mikrotik.ltd установлен и удалён на 104.165.244.7 с двумя перезагрузками. Исправлена обработка native running/stopped flags. Через реальный Portal/UI прошли start, stop, повторный start и restart; remove работающего контейнера вернул 502 и остаётся открытым. Собственные объекты удалены через native API, исходный экспорт и пакеты совпали. Backend ux-containers247, frontend ux-backups237. Следующий 248 — исправление и приёмка удаления контейнера; отдельно выяснить расхождение авторизации REST/SSH. Этап 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 открыт. Общий план: [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.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 не подменяется обещанием очистки. + +Проверки: test:containers включает новый removal-профиль в общий test:all/CI; проверены native running/unknown/conflicting/transient, отсутствие target, неверный каталог, read failure, удаление только выбранного stopped, повтор без удаления другого target и сбой подтверждения. HTTP проверяет disabled/missing server без dispatch, 409 conflict, 502 uncertainty, ok=true и существующий write-permission contract. Это изолированные service/HTTP проверки, не отдельный live RBAC сценарий. Backend/contracts build, production frontend build и профильный ESLint прошли. 29 уникальных E2E containers-data/operations прошли за 58.8 секунды; ранее в 247 webServer не стартовал, нынешний elevated запуск стартовал успешно, точная причина прошлого таймаута не доказана. В E2E обычный HTTP-путь с тестовыми ответами, не встроенные демонстрационные данные. Полная регрессия не повторялась. + +Пользователь отдельно разрешил повторную установку того же проверенного container NPK из источника 246 и две перезагрузки. Native baseline снят до передачи. Создан собственный mmc248-busybox с veth mmc248-e57b223b и pinned BusyBox digest из 247; core/option/version/device-mode/пароли/registry credentials/bridge/IP/NAT не менялись. Настоящий Portal без моков: UI start200 и native running; dialog1440/390 блокирует удаление и прямой API409; UI stop200 и native stopped; UI remove200/ok=true, строка отсутствует; повторный API409 «уже отсутствует». JS ошибок нет. При изменении viewport закрывающееся меню временно давало scrollWidth780 при width390; после штатной анимации ширина390. Helper ждёт стабилизации, ReUI styles/timings не менялись. Изначальный неверный heading locator исправлен на существующий breadcrumb. [Live UI](ux-ui/acceptance/containers248-live-ui.json). + +Собственные контейнер/veth/image/root/NPK отсутствуют. Container package удалён второй однократной подтверждённой перезагрузкой; исходные installed routeros/option7.24.5 и полный нормализованный export совпали. Автоматический timezone вернулся к исходному Europe/Warsaw после reboot; helper ждёт полного совпадения без исключения clock и не печатает приватный export при ошибке. Двойная запись create в native stages означает дополнительное read-only получение имени существующего объекта, не второе создание. Оба CHR доступны; приватные baseline удалены. SFTP с прежним паролем и прямой workstation REST200 подтвердились: один вход прервался auth timeout до завершения аутентификации, поэтому не означает неверный пароль. Причина прежнего401 не доказана. [Native/cleanup](ux-ui/acceptance/containers248-native.json). + +Backend и frontend ux-containers248 собраны/развёрнуты, backend healthy; frontend не имеет Docker healthcheck. Замороженные build inputs: backend523/frontend527 совпали, compiled backend1080 без missing/different. Поздние acceptance-only helper правки не входят в этот source audit; runtime TS/TSX соответствуют сборке. Изменены только два image tag в приватном .env; PostgreSQL/Portal не пересоздавались. Первая frontend сборка предшествовала уточнению текста о дополнительной очистке, выполнена финальная сборка из уточнённого context. [Deployment](ux-ui/acceptance/containers248-deployment.json). Подход с native флагами и envlists согласуется с [RouterOS manual](https://manual.mikrotik.com/docs/containers/); требование stopped — явная политика безопасного удаления приложения. + +Контейнерная приёмка start/stop/restart/remove закрыта в этих границах; работающий контейнер нужно сначала остановить. Следующий 249 — legacy GET контейнеров: проверить, что прямое чтение выбранного выключенного сервера не опрашивает RouterOS, затем согласовать статусы старого DTO с native flags. ACME Cloudflare token/test-domain, удалённая публикация Gitea, multi-instance/WAN/client OS и остальные ограничения матрицы остаются открытыми. Сам тестовый пакет снова удалён, новое применение без согласия не планируется. + ## 12.5.247 — фактические состояния и жизненный цикл контейнера (2026-10-14) Пользователь разрешил конкретный пакет 246 и две перезагрузки. На 104.165.244.7 установлен только container-7.24.5.npk из указанного источника mikrotik.ltd → MikroTikPatch, с ранее проверенным SHA256. Core/option/python/keygen/patch scripts не применялись; версия RouterOS осталась 7.24.5. Образ BusyBox 1.37.0 linux/amd64 закреплён digest busybox@sha256:bdf57e528e45e4433820e045b29b45978255a1c9e38353532d90a01445013f82e. Созданы только собственные контейнер mmc247-busybox, veth mmc247-39da5c21 и каталог; bridge, IP, NAT, маршруты и глобальные registry credentials не изменялись. diff --git a/docs/ux-ui-remaining-acceptance.md b/docs/ux-ui-remaining-acceptance.md index 4bc9d32a..aa53dd93 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: 247 — разрешённый container-пакет из источника mikrotik.ltd установлен и удалён на 104.165.244.7 с двумя перезагрузками. Исправлена обработка native running/stopped flags. Через реальный Portal/UI прошли start, stop, повторный start и restart; remove работающего контейнера вернул 502 и остаётся открытым. Собственные объекты удалены через native API, исходный экспорт и пакеты совпали. Backend ux-containers247, frontend ux-backups237. Следующий 248 — исправление и приёмка удаления контейнера; отдельно выяснить расхождение авторизации REST/SSH. Этап 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 открыт. -Это актуальная матрица после 12.5.247. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии. +Это актуальная матрица после 12.5.248. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии. -Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-containers247`, frontend `ux-backups237`; 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/frontend `ux-containers248`; production lab с195 использует AUTH_REQUIRED=true и Portal1.3.1. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись. + +## 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 не подменяется обещанием очистки. + +Проверки: test:containers включает новый removal-профиль в общий test:all/CI; проверены native running/unknown/conflicting/transient, отсутствие target, неверный каталог, read failure, удаление только выбранного stopped, повтор без удаления другого target и сбой подтверждения. HTTP проверяет disabled/missing server без dispatch, 409 conflict, 502 uncertainty, ok=true и существующий write-permission contract. Это изолированные service/HTTP проверки, не отдельный live RBAC сценарий. Backend/contracts build, production frontend build и профильный ESLint прошли. 29 уникальных E2E containers-data/operations прошли за 58.8 секунды; ранее в 247 webServer не стартовал, нынешний elevated запуск стартовал успешно, точная причина прошлого таймаута не доказана. В E2E обычный HTTP-путь с тестовыми ответами, не встроенные демонстрационные данные. Полная регрессия не повторялась. + +Пользователь отдельно разрешил повторную установку того же проверенного container NPK из источника 246 и две перезагрузки. Native baseline снят до передачи. Создан собственный mmc248-busybox с veth mmc248-e57b223b и pinned BusyBox digest из 247; core/option/version/device-mode/пароли/registry credentials/bridge/IP/NAT не менялись. Настоящий Portal без моков: UI start200 и native running; dialog1440/390 блокирует удаление и прямой API409; UI stop200 и native stopped; UI remove200/ok=true, строка отсутствует; повторный API409 «уже отсутствует». JS ошибок нет. При изменении viewport закрывающееся меню временно давало scrollWidth780 при width390; после штатной анимации ширина390. Helper ждёт стабилизации, ReUI styles/timings не менялись. Изначальный неверный heading locator исправлен на существующий breadcrumb. [Live UI](ux-ui/acceptance/containers248-live-ui.json). + +Собственные контейнер/veth/image/root/NPK отсутствуют. Container package удалён второй однократной подтверждённой перезагрузкой; исходные installed routeros/option7.24.5 и полный нормализованный export совпали. Автоматический timezone вернулся к исходному Europe/Warsaw после reboot; helper ждёт полного совпадения без исключения clock и не печатает приватный export при ошибке. Двойная запись create в native stages означает дополнительное read-only получение имени существующего объекта, не второе создание. Оба CHR доступны; приватные baseline удалены. SFTP с прежним паролем и прямой workstation REST200 подтвердились: один вход прервался auth timeout до завершения аутентификации, поэтому не означает неверный пароль. Причина прежнего401 не доказана. [Native/cleanup](ux-ui/acceptance/containers248-native.json). + +Backend и frontend ux-containers248 собраны/развёрнуты, backend healthy; frontend не имеет Docker healthcheck. Замороженные build inputs: backend523/frontend527 совпали, compiled backend1080 без missing/different. Поздние acceptance-only helper правки не входят в этот source audit; runtime TS/TSX соответствуют сборке. Изменены только два image tag в приватном .env; PostgreSQL/Portal не пересоздавались. Первая frontend сборка предшествовала уточнению текста о дополнительной очистке, выполнена финальная сборка из уточнённого context. [Deployment](ux-ui/acceptance/containers248-deployment.json). Подход с native флагами и envlists согласуется с [RouterOS manual](https://manual.mikrotik.com/docs/containers/); требование stopped — явная политика безопасного удаления приложения. + +Контейнерная приёмка start/stop/restart/remove закрыта в этих границах; работающий контейнер нужно сначала остановить. Следующий 249 — legacy GET контейнеров: проверить, что прямое чтение выбранного выключенного сервера не опрашивает RouterOS, затем согласовать статусы старого DTO с native flags. ACME Cloudflare token/test-domain, удалённая публикация Gitea, multi-instance/WAN/client OS и остальные ограничения матрицы остаются открытыми. Сам тестовый пакет снова удалён, новое применение без согласия не планируется. ## 12.5.247 — фактические состояния и жизненный цикл контейнера (2026-10-14) @@ -175,14 +189,14 @@ Portal offline JWT действует доexp после logout/role change; э | BGP | Established83, exact /32 exchange/API/UI/administrative withdrawal92, BFD primary GRE loss/alternate/recovery93; basic native IPv6 eBGP/AFI/NLRI/Ping/withdrawal/API/UI155; native IPv6 BGP/BFD scoped withdrawal/recovery160; private4-byte ASN/as4/API/UI162 | Hold timer/TCP loss безBFD принят104; packet loss в переходе, разные внешние peers/WAN policy, basic BFDv6 принят160; IPv6 outer/public WAN/scale. Два логических пути используют тот же public WAN; physical redundancy не принята | | Recursive routes | Production PUT/change/own history restore88, disabled guards, partial/unknown/applied outcomes, live формы/контекст | Автоматический rollback/retry, durable receipts и несколько экземпляров отсутствуют; PUT не является idempotent noop. Полная произвольная legacy catalog миграция не принята | | Diagnostics/terminal | Allowlist/native reads, Ping/trace/route/MTU/DNS, actual avg-rtt, disabled guards, JWT/live PC/mobile89; real default memory tail/log limits/API/UI133 | DNS использует backend resolver; MTU — ограниченная оценка, не гарантированный максимум. Stop ожидания не доказывает остановку RouterOS команды; arbitrary CLI и physical disconnect не приняты | -| Portal/интеграции | Portal1.3.1/issuer/login/RBAC/expiry/context195–199, actual Telegram201; Selectel S3 native/API/live limited restore/retention228–240; real GeoIP mirror242 | Offline JWT сохраняетдоступ доexp; S3 large-native/migration/multi-instance отдельно; containers package/lifecycle, Cloudflare DNS ACME test-domain/token, remote Gitea publishing не приняты; direct licensed MaxMind не реализован. ClientOS/scale/WAN отдельно | +| Portal/интеграции | Portal1.3.1/issuer/login/RBAC/expiry/context195–199, actual Telegram201; Selectel S3 native/API/live limited restore/retention228–240; real GeoIP mirror242 | Offline JWT сохраняетдоступ доexp; S3 large-native/migration/multi-instance отдельно; containers lifecycle248 принят в записанных границах; Cloudflare DNS ACME test-domain/token и remote Gitea publishing не приняты; direct licensed MaxMind не реализован. ClientOS/scale/WAN отдельно | | Общая приёмка | Локальная12.3.83; UI1244/context221/compiled984 parity139; профильные build/E2E/live проверки последующих блоков | Traffic initial live mock-id gate passed141; IPv6 special ranges/cache passed142; unknown RIPE coordinates passed143; IPv6 synthetic UDP/worker/SQL passed144; native CHR ULA/IPFIX/API/UI passed145; ICMPv6/IPv6 endpoint labels passed146; HTTP DTO validation passed147; scoped flow catalog/serverId guards passed148; strict monthly calendar passed149; Sessions labels passed150; application GRE static IPv6 lifecycle151 passed; WireGuard static IPv6 lifecycle152 и disabled SQL/API gates passed; OSPF/BFD boundary/warning153 passed; OSPFv3 routes154 accepted in recorded bounds; BGP IPv6 basic155 passed; bigint ID/SQL guards156 passed; общая UI1260/context221/backend24 regression157 passed; IPv6 BFD correlation158 profile/deployed passed; native OSPFv3/BFDv6 boundary/recovery159 passed; BGP IPv6/BFD withdrawal/recovery160 passed; selected BGP/alert unknown guards161 replay/profiles/deployed passed (SQL stability skipped); private4-byte ASN162 native/API/UI passed; duplicate BGP alert keys163 replay/profiles/deployed passed; nullable selected BGP164 replay/profiles/API/UI passed; collector165 parallel/current guards/read-only PG passed; positive partial/error SQL166/full24 with PG passed (optional network SQL not repeated); GRE/BGP freshness167/SQL8 passed; other source audit168 выявил identity/disabled/freshness gaps; локальный server/traffic identity guard169 passed без SQL/deploy, source/per-probe/freshness170/ownSQL16/compiled1008/deployed passed; source diagnostics171/42E2E/ownSQL metadata/live2 passed; monitoring history172/ownSQL26/compiled1020/deployed passed; Ping known/error173/ownHTTP-SQL8/85uniqueE2E/native4/4/deployed passed; traffic confidence174 baseline reproduced/local guard passed; owned SQL9/9358ms/deploy175/1024compiled/live smoke passed; legacy history/provenance open; compound RTT176 local/native replay2 passed, SQL10/deploy177/1024compiled/live smoke passed; history178/legacy provenance/current TTL/manual truth/SQL9+14/35unique E2E/native GET/deployed passed; live179 complete/disable-config/bounded fallback/ownSQL10/nativeSSE2/liveUI2 passed; volume180 exact/unit marker/ownSQL6/18E2E/API/liveUI/deployed passed; binding status181 native/ownSQL9/8E2E/deployed passed; peer atomicity182 ownSQL3/native/API/UI passed; peer failure warning183 ownSQL2/4E2E/deployed passed; inactive history184 ownSQL2/deployed passed; gap185/identity186/restore187/wave188/sourceguard189/confirmed peak190 passed; aggregate peaks191, request dedupe193, cross-process lock194/200, full regression192 (1286 unique E2E и25-й профиль privacy196 scoped), Portal195–199/Telegram201 passed; последующие изменения проверять отдельно; scoped axe не означает полную WCAG сертификацию | Доказательства и точные ограничения: [полная история этапа12](ux-ui-stage-12.md), [внешний протокол](ux-ui-external-acceptance.md), [BGP93](ux-ui/acceptance/bgp-failover-2026-10-10.json), [паспорт стенда](ux-ui-test-server.md). Невыполненные пункты не являются все блокерами данного лабораторного сценария: для выпуска необходимо явно определить требуемые конфигурации и принять их либо согласовать ограничение. ## Следующая последовательность — 2026-10-14 -1. **Контейнеры248:** start/stop/restart подтверждены в 247; remove работающего контейнера вернул 502 и требует исправления/приёмки UI. Очистка и исходная конфигурация подтверждены. Отдельно выяснить REST/SSH авторизацию и запуск E2E. CI245 принят; ACME и remote Gitea publishing требуют своих доступов/разрешённого scope. +1. **Контейнеры249:** lifecycle248 принят; проверить legacy чтение выключенного сервера и native статусы старого DTO. Package и собственные объекты очищены. CI245 принят; ACME и 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 6a161103..640cec0a 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: 247 — разрешённый container-пакет из источника mikrotik.ltd установлен и удалён на 104.165.244.7 с двумя перезагрузками. Исправлена обработка native running/stopped flags. Через реальный Portal/UI прошли start, stop, повторный start и restart; remove работающего контейнера вернул 502 и остаётся открытым. Собственные объекты удалены через native API, исходный экспорт и пакеты совпали. Backend ux-containers247, frontend ux-backups237. Следующий 248 — исправление и приёмка удаления контейнера; отдельно выяснить расхождение авторизации REST/SSH. Этап 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 открыт. План: [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.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 не подменяется обещанием очистки. + +Проверки: test:containers включает новый removal-профиль в общий test:all/CI; проверены native running/unknown/conflicting/transient, отсутствие target, неверный каталог, read failure, удаление только выбранного stopped, повтор без удаления другого target и сбой подтверждения. HTTP проверяет disabled/missing server без dispatch, 409 conflict, 502 uncertainty, ok=true и существующий write-permission contract. Это изолированные service/HTTP проверки, не отдельный live RBAC сценарий. Backend/contracts build, production frontend build и профильный ESLint прошли. 29 уникальных E2E containers-data/operations прошли за 58.8 секунды; ранее в 247 webServer не стартовал, нынешний elevated запуск стартовал успешно, точная причина прошлого таймаута не доказана. В E2E обычный HTTP-путь с тестовыми ответами, не встроенные демонстрационные данные. Полная регрессия не повторялась. + +Пользователь отдельно разрешил повторную установку того же проверенного container NPK из источника 246 и две перезагрузки. Native baseline снят до передачи. Создан собственный mmc248-busybox с veth mmc248-e57b223b и pinned BusyBox digest из 247; core/option/version/device-mode/пароли/registry credentials/bridge/IP/NAT не менялись. Настоящий Portal без моков: UI start200 и native running; dialog1440/390 блокирует удаление и прямой API409; UI stop200 и native stopped; UI remove200/ok=true, строка отсутствует; повторный API409 «уже отсутствует». JS ошибок нет. При изменении viewport закрывающееся меню временно давало scrollWidth780 при width390; после штатной анимации ширина390. Helper ждёт стабилизации, ReUI styles/timings не менялись. Изначальный неверный heading locator исправлен на существующий breadcrumb. [Live UI](ux-ui/acceptance/containers248-live-ui.json). + +Собственные контейнер/veth/image/root/NPK отсутствуют. Container package удалён второй однократной подтверждённой перезагрузкой; исходные installed routeros/option7.24.5 и полный нормализованный export совпали. Автоматический timezone вернулся к исходному Europe/Warsaw после reboot; helper ждёт полного совпадения без исключения clock и не печатает приватный export при ошибке. Двойная запись create в native stages означает дополнительное read-only получение имени существующего объекта, не второе создание. Оба CHR доступны; приватные baseline удалены. SFTP с прежним паролем и прямой workstation REST200 подтвердились: один вход прервался auth timeout до завершения аутентификации, поэтому не означает неверный пароль. Причина прежнего401 не доказана. [Native/cleanup](ux-ui/acceptance/containers248-native.json). + +Backend и frontend ux-containers248 собраны/развёрнуты, backend healthy; frontend не имеет Docker healthcheck. Замороженные build inputs: backend523/frontend527 совпали, compiled backend1080 без missing/different. Поздние acceptance-only helper правки не входят в этот source audit; runtime TS/TSX соответствуют сборке. Изменены только два image tag в приватном .env; PostgreSQL/Portal не пересоздавались. Первая frontend сборка предшествовала уточнению текста о дополнительной очистке, выполнена финальная сборка из уточнённого context. [Deployment](ux-ui/acceptance/containers248-deployment.json). Подход с native флагами и envlists согласуется с [RouterOS manual](https://manual.mikrotik.com/docs/containers/); требование stopped — явная политика безопасного удаления приложения. + +Контейнерная приёмка start/stop/restart/remove закрыта в этих границах; работающий контейнер нужно сначала остановить. Следующий 249 — legacy GET контейнеров: проверить, что прямое чтение выбранного выключенного сервера не опрашивает RouterOS, затем согласовать статусы старого DTO с native flags. ACME Cloudflare token/test-domain, удалённая публикация Gitea, multi-instance/WAN/client OS и остальные ограничения матрицы остаются открытыми. Сам тестовый пакет снова удалён, новое применение без согласия не планируется. + ## 12.5.247 — фактические состояния и жизненный цикл контейнера (2026-10-14) Пользователь разрешил конкретный пакет 246 и две перезагрузки. На 104.165.244.7 установлен только container-7.24.5.npk из указанного источника mikrotik.ltd → MikroTikPatch, с ранее проверенным SHA256. Core/option/python/keygen/patch scripts не применялись; версия RouterOS осталась 7.24.5. Образ BusyBox 1.37.0 linux/amd64 закреплён digest busybox@sha256:bdf57e528e45e4433820e045b29b45978255a1c9e38353532d90a01445013f82e. Созданы только собственные контейнер mmc247-busybox, veth mmc247-39da5c21 и каталог; bridge, IP, NAT, маршруты и глобальные registry credentials не изменялись. diff --git a/docs/ux-ui-test-server.md b/docs/ux-ui-test-server.md index 50733b64..c7529b83 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: 247 — разрешённый container-пакет из источника mikrotik.ltd установлен и удалён на 104.165.244.7 с двумя перезагрузками. Исправлена обработка native running/stopped flags. Через реальный Portal/UI прошли start, stop, повторный start и restart; remove работающего контейнера вернул 502 и остаётся открытым. Собственные объекты удалены через native API, исходный экспорт и пакеты совпали. Backend ux-containers247, frontend ux-backups237. Следующий 248 — исправление и приёмка удаления контейнера; отдельно выяснить расхождение авторизации REST/SSH. Этап 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 открыт. | Параметр | Проверенное состояние | | --- | --- | @@ -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.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 не подменяется обещанием очистки. + +Проверки: test:containers включает новый removal-профиль в общий test:all/CI; проверены native running/unknown/conflicting/transient, отсутствие target, неверный каталог, read failure, удаление только выбранного stopped, повтор без удаления другого target и сбой подтверждения. HTTP проверяет disabled/missing server без dispatch, 409 conflict, 502 uncertainty, ok=true и существующий write-permission contract. Это изолированные service/HTTP проверки, не отдельный live RBAC сценарий. Backend/contracts build, production frontend build и профильный ESLint прошли. 29 уникальных E2E containers-data/operations прошли за 58.8 секунды; ранее в 247 webServer не стартовал, нынешний elevated запуск стартовал успешно, точная причина прошлого таймаута не доказана. В E2E обычный HTTP-путь с тестовыми ответами, не встроенные демонстрационные данные. Полная регрессия не повторялась. + +Пользователь отдельно разрешил повторную установку того же проверенного container NPK из источника 246 и две перезагрузки. Native baseline снят до передачи. Создан собственный mmc248-busybox с veth mmc248-e57b223b и pinned BusyBox digest из 247; core/option/version/device-mode/пароли/registry credentials/bridge/IP/NAT не менялись. Настоящий Portal без моков: UI start200 и native running; dialog1440/390 блокирует удаление и прямой API409; UI stop200 и native stopped; UI remove200/ok=true, строка отсутствует; повторный API409 «уже отсутствует». JS ошибок нет. При изменении viewport закрывающееся меню временно давало scrollWidth780 при width390; после штатной анимации ширина390. Helper ждёт стабилизации, ReUI styles/timings не менялись. Изначальный неверный heading locator исправлен на существующий breadcrumb. [Live UI](ux-ui/acceptance/containers248-live-ui.json). + +Собственные контейнер/veth/image/root/NPK отсутствуют. Container package удалён второй однократной подтверждённой перезагрузкой; исходные installed routeros/option7.24.5 и полный нормализованный export совпали. Автоматический timezone вернулся к исходному Europe/Warsaw после reboot; helper ждёт полного совпадения без исключения clock и не печатает приватный export при ошибке. Двойная запись create в native stages означает дополнительное read-only получение имени существующего объекта, не второе создание. Оба CHR доступны; приватные baseline удалены. SFTP с прежним паролем и прямой workstation REST200 подтвердились: один вход прервался auth timeout до завершения аутентификации, поэтому не означает неверный пароль. Причина прежнего401 не доказана. [Native/cleanup](ux-ui/acceptance/containers248-native.json). + +Backend и frontend ux-containers248 собраны/развёрнуты, backend healthy; frontend не имеет Docker healthcheck. Замороженные build inputs: backend523/frontend527 совпали, compiled backend1080 без missing/different. Поздние acceptance-only helper правки не входят в этот source audit; runtime TS/TSX соответствуют сборке. Изменены только два image tag в приватном .env; PostgreSQL/Portal не пересоздавались. Первая frontend сборка предшествовала уточнению текста о дополнительной очистке, выполнена финальная сборка из уточнённого context. [Deployment](ux-ui/acceptance/containers248-deployment.json). Подход с native флагами и envlists согласуется с [RouterOS manual](https://manual.mikrotik.com/docs/containers/); требование stopped — явная политика безопасного удаления приложения. + +Контейнерная приёмка start/stop/restart/remove закрыта в этих границах; работающий контейнер нужно сначала остановить. Следующий 249 — legacy GET контейнеров: проверить, что прямое чтение выбранного выключенного сервера не опрашивает RouterOS, затем согласовать статусы старого DTO с native flags. ACME Cloudflare token/test-domain, удалённая публикация Gitea, multi-instance/WAN/client OS и остальные ограничения матрицы остаются открытыми. Сам тестовый пакет снова удалён, новое применение без согласия не планируется. + ## 12.5.247 — фактические состояния и жизненный цикл контейнера (2026-10-14) Пользователь разрешил конкретный пакет 246 и две перезагрузки. На 104.165.244.7 установлен только container-7.24.5.npk из указанного источника mikrotik.ltd → MikroTikPatch, с ранее проверенным SHA256. Core/option/python/keygen/patch scripts не применялись; версия RouterOS осталась 7.24.5. Образ BusyBox 1.37.0 linux/amd64 закреплён digest busybox@sha256:bdf57e528e45e4433820e045b29b45978255a1c9e38353532d90a01445013f82e. Созданы только собственные контейнер mmc247-busybox, veth mmc247-39da5c21 и каталог; bridge, IP, NAT, маршруты и глобальные registry credentials не изменялись. diff --git a/docs/ux-ui/acceptance/containers248-deployment.json b/docs/ux-ui/acceptance/containers248-deployment.json new file mode 100644 index 00000000..2fed1cf6 --- /dev/null +++ b/docs/ux-ui/acceptance/containers248-deployment.json @@ -0,0 +1,40 @@ +{ + "date": "2026-10-14", + "block": "12.5.248", + "containers": [ + { + "name": "mmapp-test-backend", + "image": "mmapp-test/backend:ux-containers248", + "imageId": "sha256:fbb37d3e7eba0a9290e1a42e8a37e268b564303fccf2da1e1f0ee6053a71c640", + "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": 523 + }, + { + "kind": "frontend", + "matched": 527 + } + ], + "compiledAudit": { + "matched": 1080, + "missing": 0, + "different": 0 + }, + "sourceScope": "frozen build contexts; later acceptance-only helpers excluded", + "backendBuildExit": 0, + "frontendFinalBuildExit": 0, + "previousBackend": "ux-containers247", + "previousFrontend": "ux-backups237" +} diff --git a/docs/ux-ui/acceptance/containers248-live-ui.json b/docs/ux-ui/acceptance/containers248-live-ui.json new file mode 100644 index 00000000..b3d318f7 --- /dev/null +++ b/docs/ux-ui/acceptance/containers248-live-ui.json @@ -0,0 +1,60 @@ +{ + "date": "2026-10-14", + "block": "12.5.248", + "realPortalLogin": true, + "noMocks": true, + "steps": [ + { + "phase": "start", + "status": 200, + "receiptOk": true, + "targetMatched": true + }, + { + "phase": "guard", + "viewports": [ + 1440, + 390 + ], + "uiDeleteDisabled": true, + "backendConflict": 409 + }, + { + "phase": "stop", + "status": 200, + "receiptOk": true, + "targetMatched": true + }, + { + "phase": "remove", + "status": 200, + "receiptOk": true, + "targetMatched": true, + "rowAbsent": true + }, + { + "phase": "repeat", + "status": 409, + "absentConflict": true + } + ], + "pageErrors": [], + "layout": [ + { + "viewport": 1440, + "scrollWidth": 1440 + }, + { + "viewport": 390, + "scrollWidth": 390 + } + ], + "harnessFailures": [ + { + "phase": "guard", + "name": "AssertionError", + "actual": true, + "expected": false + } + ] +} diff --git a/docs/ux-ui/acceptance/containers248-native.json b/docs/ux-ui/acceptance/containers248-native.json new file mode 100644 index 00000000..ad6be074 --- /dev/null +++ b/docs/ux-ui/acceptance/containers248-native.json @@ -0,0 +1,51 @@ +{ + "date": "2026-10-14", + "block": "12.5.248", + "targetHost": "104.165.244.7", + "controlHost": "194.226.121.150", + "stages": [ + { + "phase": "installed", + "packageInstalled": true, + "version": "7.24.5", + "controlReachable": true + }, + { + "phase": "create", + "initialStatus": "stopped" + }, + { + "phase": "create", + "initialStatus": "stopped", + "observedNativeStoppedFlag": "true" + }, + { + "phase": "running", + "nativeConfirmed": true + }, + { + "phase": "stopped", + "nativeConfirmed": true + }, + { + "phase": "cleanup-network", + "ownObjectsAbsent": true + } + ], + "installReboot": { + "requested": true, + "acknowledgement": "confirmed" + }, + "uninstallReboot": { + "requested": true, + "acknowledgement": "confirmed" + }, + "originalPackagesMatched": true, + "originalNormalizedExportSha256": "3d7d864c2e8d9e7c2bba3ef59aa64b7d05e2a660d4d307172c36c555184b6066", + "originalNormalizedExportMatched": true, + "controlReachable": true, + "passwordChanges": 0, + "deviceModeChanges": 0, + "firmwareVersionChanged": false, + "passed": true +} diff --git a/tests/e2e/containers-operations.spec.ts b/tests/e2e/containers-operations.spec.ts index e1518e62..a00833ce 100644 --- a/tests/e2e/containers-operations.spec.ts +++ b/tests/e2e/containers-operations.spec.ts @@ -1,8 +1,13 @@ import {test,expect,type Page} from "@playwright/test" import {container,server,state} from "./fixtures/containers" async function routes(page:Page){await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:new URL(r.request().url()).pathname==="/api/servers"?[server]:state()}))} -test.beforeEach(async({page})=>routes(page)) +test.beforeEach(async({page}, info)=>{await routes(page); if(info.title.startsWith("Удаление контейнера:")) await page.route("**/api/containers/state",r=>r.fulfill({json:state([{...container,status:"stopped"}])}))}) async function action(page:Page,name:string){await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await page.getByRole("menuitem",{name,exact:true}).click()} +for(const status of ["running","unknown","error"] as const)test("Удаление требует остановки: "+status,async({page})=>{ + let calls=0;await page.route("**/api/containers/state",r=>r.fulfill({json:state([{...container,status}])}));await page.route("**/api/servers/1/containers/remove",r=>{calls++;return r.fulfill({json:{ok:true}})}) + await page.goto("/containers");await action(page,"Удалить");const dialog=page.getByRole("alertdialog");await expect(dialog).toContainText("после подтверждённой остановки");await expect(dialog.getByRole("button",{name:"Удалить",exact:true})).toBeDisabled();expect(calls).toBe(0) + await page.route("**/api/containers/state",r=>r.fulfill({json:state([{...container,status:"stopped"}])}));await dialog.getByRole("button",{name:"Обновить каталог",exact:true}).click();await expect(dialog.getByRole("button",{name:"Удалить",exact:true})).toBeEnabled();expect(calls).toBe(0) +}) for(const kind of ["403","502","malformed"] as const)test("Удаление контейнера: ошибка сохраняет target "+kind,async({page})=>{ let calls=0;await page.route("**/api/servers/1/containers/remove",r=>{calls++;return r.fulfill(kind==="malformed"?{json:{ok:false}}:{status:Number(kind),json:{error:"УДАЛЕНИЕ ОТКАЗ"}})}) await page.goto("/containers");await action(page,"Удалить");const dialog=page.getByRole("alertdialog");await dialog.getByRole("button",{name:"Удалить",exact:true}).click();await expect(dialog.getByRole("alert",{name:"Удаление контейнера"})).toBeVisible();await expect(dialog).toContainText(container.name);expect(calls).toBe(1) @@ -10,7 +15,7 @@ for(const kind of ["403","502","malformed"] as const)test("Удаление ко }) test("Удаление контейнера: lock, Escape, один запрос и refresh failure",async({page})=>{ let release!:()=>void,calls=0,removed=false;const gate=new Promise(r=>release=r) - await page.route("**/api/containers/state",r=>r.fulfill(removed?{status:503,json:{error:"READ AFTER DELETE FAIL"}}:{json:state()}));await page.route("**/api/servers/1/containers/remove",async r=>{calls++;await gate;removed=true;await r.fulfill({json:{ok:true}})}) + await page.route("**/api/containers/state",r=>r.fulfill(removed?{status:503,json:{error:"READ AFTER DELETE FAIL"}}:{json:state([{...container,status:"stopped"}])}));await page.route("**/api/servers/1/containers/remove",async r=>{calls++;await gate;removed=true;await r.fulfill({json:{ok:true}})}) await page.goto("/containers");await action(page,"Удалить");const dialog=page.getByRole("alertdialog");const remove=dialog.getByRole("button",{name:"Удалить",exact:true});await remove.click();await expect(dialog.getByRole("button",{name:"Удаление…",exact:true})).toBeDisabled();await expect(dialog.getByRole("button",{name:"Отмена",exact:true})).toBeDisabled();await page.keyboard.press("Escape");await expect(dialog).toBeVisible();expect(calls).toBe(1);release();await expect(dialog).toHaveCount(0);await expect(page.getByRole("status").filter({hasText:"API подтвердил команду"})).toContainText("Обновление каталогов не удалось");await expect(page.getByText(container.name,{exact:true})).toHaveCount(0) }) for(const [name,path] of [["Остановить","stop"],["Запустить","start"],["Перезапустить","restart"]] as const)test("Команда контейнера: подтверждение отдельно от состояния "+path,async({page})=>{ @@ -27,6 +32,6 @@ test("Команда контейнера: общий lock блокирует д await page.goto("/containers");await action(page,"Остановить");await expect(page.getByRole("button",{name:"Действия контейнера "+other.name})).toBeDisabled();await expect(page.getByRole("button",{name:"Обновить",exact:true})).toBeDisabled();expect(calls).toBe(1);release();await expect(page.getByRole("button",{name:"Действия контейнера "+other.name})).toBeEnabled() }) test("Удаление контейнера: исчезнувший target не отправляется повторно",async({page})=>{ - let exists=true;await page.route("**/api/containers/state",r=>r.fulfill({json:state(exists?[container]:[])}));await page.route("**/api/servers/1/containers/remove",r=>{exists=false;return r.fulfill({status:502,json:{error:"Связь потеряна"}})}) + let exists=true;await page.route("**/api/containers/state",r=>r.fulfill({json:state(exists?[{...container,status:"stopped"}]:[])}));await page.route("**/api/servers/1/containers/remove",r=>{exists=false;return r.fulfill({status:502,json:{error:"Связь потеряна"}})}) await page.goto("/containers");await action(page,"Удалить");const dialog=page.getByRole("alertdialog");await dialog.getByRole("button",{name:"Удалить",exact:true}).click();await dialog.getByRole("button",{name:"Обновить каталог",exact:true}).click();await expect(dialog.getByRole("button",{name:"Удалить",exact:true})).toBeDisabled() })