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

This commit is contained in:
Denozordec committed 2026-10-14 19:03:58 +07:00
1 parent b3d11276dd
commit 74151176d7
20 files changed
+414 -28

No files matched your search

+6 -3
View File
@@ -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 && <AlertDialog open onOpenChange={open => { if (!open && !busyId) { setDeleteTarget(null); setOperationError(null) } }}>
<AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>Удалить контейнер?</AlertDialogTitle><AlertDialogDescription>{deleteTarget.name} · сервер {serverById[deleteTarget.serverId]?.name ?? deleteTarget.serverId}. Будет отправлена команда удаления в RouterOS.</AlertDialogDescription></AlertDialogHeader>
<AlertDialogHeader><AlertDialogTitle>Удалить контейнер?</AlertDialogTitle><AlertDialogDescription>{deleteTarget.name} · сервер {serverById[deleteTarget.serverId]?.name ?? deleteTarget.serverId}. {deleteStopped ? "Будет удалён остановленный контейнер. Дополнительная очистка файлов не выполняется." : "Сначала закройте это окно, остановите контейнер через меню действий и обновите каталог. Удаление доступно только после подтверждённой остановки."}</AlertDialogDescription></AlertDialogHeader>
{operationError && <p role="alert" aria-label="Удаление контейнера" className="text-sm text-destructive [overflow-wrap:anywhere]">{operationError}</p>}
<AlertDialogFooter>
{commandNeedsRead && <Button type="button" variant="outline" disabled={loading || !!busyId} onClick={() => { void loadLive() }}>Обновить каталог</Button>}
{(commandNeedsRead || !deleteStopped) && <Button type="button" variant="outline" disabled={loading || !!busyId} onClick={() => { void loadLive() }}>Обновить каталог</Button>}
<AlertDialogCancel disabled={!!busyId}>Отмена</AlertDialogCancel>
<AlertDialogAction type="button" variant="destructive" disabled={!!busyId || !nodeFresh(deleteTarget.serverId) || !displayContainers.some(row => row.id === deleteTarget.id && row.rosId === deleteTarget.rosId)} aria-busy={!!busyId} onClick={() => { void mutate(deleteTarget, "remove") }}>{busyId ? "Удаление…" : "Удалить"}</AlertDialogAction>
<AlertDialogAction type="button" variant="destructive" disabled={!!busyId || !deleteStopped || !nodeFresh(deleteTarget.serverId)} aria-busy={!!busyId} onClick={() => { void mutate(deleteTarget, "remove") }}>{busyId ? "Удаление…" : "Удалить"}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>}
+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/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",
@@ -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; });
@@ -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));
@@ -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<Awaited<ReturnType<NonNullable<Parameters<typeof createContainersRoutes>[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")
+6 -4
View File
@@ -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<typeof RosIdBodySchema>
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)
}
}
@@ -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")
+26
View File
@@ -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<unknown>
delete(path: string): Promise<unknown>
}
/** Never stop implicitly: removal requires a fresh, unambiguous stopped state. */
export async function removeStoppedContainer(client: Client, rosId: string): Promise<void> {
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. Обновите каталог перед новым действием.")
}
+2 -5
View File
@@ -174,10 +174,6 @@ export async function listContainersForServer(server: ServerRow): Promise<Contai
export async function countContainers():Promise<number|undefined>{return boundedCompleteCount(()=>listContainersState(),completeContainerCount)}
function encodeRosId(rosId: string): string {
return encodeURIComponent(rosId)
}
export async function startContainer(server: ServerRow, rosId: string): Promise<void> {
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<void> {
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) {
+1 -1
View File
@@ -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).
+15 -1
View File
@@ -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 не изменялись.
+15 -1
View File
@@ -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 не изменялись.
+15 -1
View File
@@ -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 не изменялись.
+19 -5
View File
@@ -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 — отдельные группы по таблице выше. Ограничения текущего стенда не превращать в обещание приёмки всех конфигураций.
+15 -1
View File
@@ -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 не изменялись.
+15 -1
View File
@@ -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 не изменялись.
@@ -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"
}
@@ -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
}
]
}
@@ -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
}
+8 -3
View File
@@ -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<void>(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()
})