- {operationNotice &&
{operationNotice}
}
+ {operationNotice &&
{operationNotice}
}
{operationError && !deleteTarget &&
{operationError} }
{isLive && !canRead &&
Для каталога контейнеров требуется mm:dashboard:read.
}
{isLive && !canReadServers &&
Для каталога серверов требуется mm:servers:read. Команды недоступны.
}
@@ -502,67 +442,67 @@ function ContainersContent() {
id: "all",
label: "Всего",
value: known ? scoped.length : "—",
- icon:
,
+ icon:
,
iconClassName: "text-muted-foreground",
},
{
id: "running",
- label: "Running",
- value: known ? running : "—",
- icon:
,
+ label: "Работает",
+ value: statusKnown ? running : "—",
+ icon:
,
iconClassName: "text-success",
},
{
id: "stopped",
- label: "Stopped",
- value: known ? stopped : "—",
- icon:
,
+ label: "Остановлен",
+ value: statusKnown ? stopped : "—",
+ icon:
,
iconClassName: "text-muted-foreground",
},
{
id: "errors",
label: "Ошибок",
- value: known ? errors : "—",
- icon:
,
+ value: statusKnown ? errors : "—",
+ icon:
,
iconClassName: "text-destructive",
variant: errors > 0 ? "destructive" : "default",
},
]}
/>
-
-
+
+
-
Контейнеры доступны с RouterOS 7.4+
+
Контейнеры доступны с RouterOS 7.4+
Поддерживаются Docker-совместимые образы. Требуется установка пакета container.
- Интерфейсы veth создаются автоматически.
+ Сеть veth настраивается на устройстве. Создание контейнеров в интерфейсе пока недоступно.
-
-
+
+
setSearch(e.target.value)}
/>
-
- {(["all", "running", "stopped", "error"] as const).map((s) => (
+
+ {(["all", "running", "stopped", "error", "unknown"] as const).map((s) => (
setStatusFilter(s)}
+ type="button" aria-pressed={statusFilter === s} onClick={() => setStatusFilter(s)}
className={cn(
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
statusFilter === s
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}>
- {s === "all" ? "Все" : s === "running" ? "Running" : s === "stopped" ? "Stopped" : "Error"}
+ {s === "all" ? "Все" : s === "running" ? "Работает" : s === "stopped" ? "Остановлен" : s === "unknown" ? "Неизвестно" : "Ошибка"}
))}
@@ -572,7 +512,7 @@ function ContainersContent() {
{filtered.length === 0 ? (!known ?
Список контейнеров пока неизвестен.
: (
-
+
Контейнеры не найдены
{scoped.length ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}
@@ -585,7 +525,7 @@ function ContainersContent() {
server={serverById[c.serverId]}
live={isLive && !!nodeFresh(c.serverId)}
busy={!!busyId}
- onExport={() => setExportContainer(c)}
+ onExport={() => { const snapshot = lastStates[Number(c.serverId)]; setExportContainer({ container: c, collectedAt: snapshot?.collectedAt ?? null, failures: snapshot?.failures ?? [], source: isLive ? "live" : "mock" }) }}
onStart={() => { void mutate(c, "start") }}
onStop={() => { void mutate(c, "stop") }}
onRestart={() => { void mutate(c, "restart") }}
@@ -645,7 +585,7 @@ function ContainersContent() {
].map((b) => (
{b.title}
-
+
{b.lines.join("\n")}
@@ -667,12 +607,7 @@ function ContainersContent() {
}
-
setExportContainer(null)}
- serverById={serverById}
- />
+ setExportContainer(null)} />
>
)
}
diff --git a/backend/src/services/containers-state.test.ts b/backend/src/services/containers-state.test.ts
index d914c842..d9fd6a55 100644
--- a/backend/src/services/containers-state.test.ts
+++ b/backend/src/services/containers-state.test.ts
@@ -4,7 +4,7 @@ import { MikrotikError } from "./mikrotik.js"
import { containersStateSchema } from "@mmapp/contracts/containers"
const server = { id: 1, name: "Router" } as Parameters[0]
-const row = { ".id": "*A", name: "nginx", "remote-image": "nginx:alpine", interface: "veth1", envlist: "e", mounts: "m", status: "running", "start-on-boot": "yes", cpu: "0", "memory-high": "0" }
+const row = { ".id": "*A", name: "nginx", "remote-image": "nginx:alpine", interface: "veth1", envlist: "e", mounts: "m", status: "running", "start-on-boot": "yes", cpu: "0", "memory-current": "0", "memory-high": "52428800" }
const read = async (path: string): Promise => path === "/container" ? [row] : path === "/container/envs" ? [{name:"e",key:"A",value:"B"}] : [{name:"m",src:"/disk",dst:"/data"}]
const good = await readContainerServerState(server, read)
assert.equal(good.availability, "available"); assert.equal(good.containers[0].status,"running")
@@ -32,3 +32,6 @@ for (const value of [
{...aggregate,servers:[{...unsupported,failures:[{resource:"envs",error:"x"}]}]},
]) assert.equal(containersStateSchema.safeParse(value).success,false)
console.log("Containers state: independent devices, malformed/duplicate IDs, unknown status/boot, known empty, missing package, partial dependencies, zeros and envelope validation; no DB/RouterOS")
+
+const limitOnly=await readContainerServerState(server,async path=>path==="/container"?[{".id":"*C","memory-high":"52428800"}]:[]);assert.equal(limitOnly.containers[0].memMb,undefined)
+const modern=await readContainerServerState(server,async path=>path==="/container"?[{...row,envlist:"e,e2",mounts:undefined,mountlists:"m"}]:path==="/container/envs"?[{list:"e",key:"A",value:"1"},{list:"e2",key:"B",value:"2"}]:[{list:"m",src:"/disk",dst:"/data"}]);assert.equal(modern.containers[0].envs.length,2);assert.equal(modern.containers[0].mounts.length,1)
diff --git a/backend/src/services/containers-state.ts b/backend/src/services/containers-state.ts
index bdf13e22..e0c066eb 100644
--- a/backend/src/services/containers-state.ts
+++ b/backend/src/services/containers-state.ts
@@ -7,14 +7,14 @@ type ServerRow = Parameters[0]
type Reader = (path: string) => Promise
const rawSchema = z.array(z.object({
".id": z.string().min(1), name: z.string().optional(), tag: z.string().optional(), "remote-image": z.string().optional(),
- interface: z.string().optional(), envlist: z.string().optional(), mounts: z.string().optional(),
+ interface: z.string().optional(), envlist: z.string().optional(), mounts: z.string().optional(), mountlists: z.string().optional(),
cmd: z.string().optional(), "start-on-boot": z.string().optional(), comment: z.string().optional(),
- status: z.string().optional(), "memory-high": z.string().optional(), cpu: z.string().optional(),
+ status: z.string().optional(), "memory-high": z.string().optional(), "memory-current": z.string().optional(), cpu: z.string().optional(),
})).superRefine((rows, ctx) => {
if (new Set(rows.map(r => r[".id"])).size !== rows.length) ctx.addIssue({ code: "custom", message: "Дубли RouterOS ID" })
})
-const envSchema = z.array(z.object({ name: z.string().optional(), key: z.string().optional(), value: z.string().optional() }))
-const mountSchema = z.array(z.object({ name: z.string().optional(), src: z.string().optional(), dst: z.string().optional() }))
+const envSchema = z.array(z.object({ name: z.string().optional(), list: z.string().optional(), key: z.string().min(1), value: z.string() }).refine(row => !!(row.list || row.name), "Отсутствует список env"))
+const mountSchema = z.array(z.object({ name: z.string().optional(), list: z.string().optional(), src: z.string().optional(), dst: z.string().min(1) }).refine(row => !!(row.list || row.name), "Отсутствует список mounts"))
const errorMessage = (error: unknown) => error instanceof z.ZodError ? "Некорректный ответ RouterOS" : (error instanceof Error ? error.message.slice(0, 500) : "Ошибка RouterOS")
function missingPackage(error: unknown) {
return error instanceof MikrotikError && (error.statusCode === 404 || /no such command|unknown command/i.test(error.body))
@@ -36,10 +36,14 @@ export async function readContainerServerState(server: ServerRow, read: Reader):
if (envs.status === "rejected") failures.push({ resource: "envs", error: errorMessage(envs.reason) })
if (mounts.status === "rejected") failures.push({ resource: "mounts", error: errorMessage(mounts.reason) })
const containers = raw.map((row, index) => {
- const mapped = mapContainerRow(server, row, envs.status === "fulfilled" ? envs.value : [], mounts.status === "fulfilled" ? mounts.value : [], index)
+ const envRows = envs.status === "fulfilled" ? envs.value : []
+ const mountRows = mounts.status === "fulfilled" ? mounts.value : []
+ const mapped = mapContainerRow(server, { ...row, mounts: row.mountlists ?? row.mounts }, envRows.map(item => ({ ...item, name: item.list ?? item.name })), mountRows.map(item => ({ ...item, name: item.list ?? item.name })), index)
+ const envLists = new Set((row.envlist ?? "").split(",").map(name => name.trim()).filter(Boolean))
+ mapped.envs = envRows.filter(item => envLists.has(item.list ?? item.name ?? "")).map(item => ({ key: item.key, value: item.value }))
const status = (row.status ?? "").toLowerCase()
const boot = row["start-on-boot"]
- const mem = Number.parseInt(row["memory-high"] ?? "", 10)
+ const mem = Number(row["memory-current"]?.trim() || NaN)
return { ...mapped, image: row["remote-image"] ? mapped.image : "", tag: row["remote-image"] ? mapped.tag : "",
status: status === "running" || status === "stopped" ? status : status === "error" || status === "failed" ? "error" : "unknown",
startOnBoot: boot === "true" || boot === "yes" ? true : boot === "false" || boot === "no" ? false : null,
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index 9c97956c..1a2b345c 100644
--- a/docs/ux-ui-progress.md
+++ b/docs/ux-ui-progress.md
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
-| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.11; пользователи приняты локально, команды контейнеров проверены |
+| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.12; пользователи приняты локально, UX/экспорт контейнеров проверены |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
+## Блок 11А.12 — достоверный снимок и интерфейс контейнеров (2026-10-01)
+
+- Недостоверный генератор RSC (шаблонные IP veth, неподтверждённые настройки и некорректные env команды) заменён JSON-снимком каталога в существующем CodeExportSheet. Файл содержит наблюдаемые поля, collectedAt, source и partial failures; явно подписан как снимок, не конфигурация/backup.
+- memory-high — лимит памяти по [MikroTik Container](https://help.mikrotik.com/docs/spaces/ROS/pages/84901929/Container), расход RAM теперь только memory-current (МиБ); отсутствие метрики не становится 0. Новые RouterOS list/mountlists и несколько envlist обрабатываются; неполные env/mount ответы становятся partial error.
+- Семантические токены, русские статусы, aria имена/pressed, поиск/переносы и 44px меню на телефоне. Sheet на всю ширину, footer переносится на 320 px через props; vendor/theme и композиция Frame/ServerRail/KpiStatGrid не изменены. Неизвестный статус не предлагает запуск/перезапуск; KPI по статусам — —.
+- 30 уникальных data/operations/layout E2E суммарно прошли (последний полный прогон 29/30, оставшийся narrow исправлен и все 4 layout повторно прошли); 8 context E2E, backend tests/build, TypeScript, профильный lint и production frontend прошли. Скачанный JSON проверен; 6 снимков сохранены и просмотрены.
+- ReUI checklist проверен в границах адаптации. Далее 11А.13: URL-контекст и clipboard отказ/фокус, затем локальная приёмка контейнеров и аудит сертификатов. Общие ошибки Clipboard в CodeExportSheet требуют обработки; реальный RouterOS/БД ещё не проверен.
+
## Блок 11А.11 — подтверждённые команды контейнеров (2026-10-01)
- POST start/stop/restart/remove проходит через typed reader со строгим ok:true, проверкой serverId/rosId и 30s сроком. Single-flight ref блокирует все карточки; поздние ответы после ухода/смены backend не меняют UI.
diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md
index 38a1943e..99dcf630 100644
--- a/docs/ux-ui-stage-11a.md
+++ b/docs/ux-ui-stage-11a.md
@@ -135,3 +135,12 @@
- 26 data/operations E2E, 8 context E2E, TypeScript, профильный lint и production frontend прошли. Модель сетевых команд/авторизации backend не менялась; реальные операции RouterOS требуют внешней приёмки.
- Далее 11А.12: доступность, семантические токены, ПК/390/320 и достоверность экспорта/метрик. Аудит экспорта выявил шаблонные veth IP и команды, которые не являются экспортом реального устройства; это нельзя выдавать за резервную конфигурацию.
+
+## Блок 11А.12 — достоверный снимок и интерфейс контейнеров (2026-10-01)
+
+- Недостоверный генератор RSC (шаблонные IP veth, неподтверждённые настройки и некорректные env команды) заменён JSON-снимком каталога в существующем CodeExportSheet. Файл содержит наблюдаемые поля, collectedAt, source и partial failures; явно подписан как снимок, не конфигурация/backup.
+- memory-high — лимит памяти по [MikroTik Container](https://help.mikrotik.com/docs/spaces/ROS/pages/84901929/Container), расход RAM теперь только memory-current (МиБ); отсутствие метрики не становится 0. Новые RouterOS list/mountlists и несколько envlist обрабатываются; неполные env/mount ответы становятся partial error.
+- Семантические токены, русские статусы, aria имена/pressed, поиск/переносы и 44px меню на телефоне. Sheet на всю ширину, footer переносится на 320 px через props; vendor/theme и композиция Frame/ServerRail/KpiStatGrid не изменены. Неизвестный статус не предлагает запуск/перезапуск; KPI по статусам — —.
+- 30 уникальных data/operations/layout E2E суммарно прошли (последний полный прогон 29/30, оставшийся narrow исправлен и все 4 layout повторно прошли); 8 context E2E, backend tests/build, TypeScript, профильный lint и production frontend прошли. Скачанный JSON проверен; 6 снимков сохранены и просмотрены.
+- ReUI checklist проверен в границах адаптации. Далее 11А.13: URL-контекст и clipboard отказ/фокус, затем локальная приёмка контейнеров и аудит сертификатов. Общие ошибки Clipboard в CodeExportSheet требуют обработки; реальный RouterOS/БД ещё не проверен.
+
diff --git a/docs/ux-ui/screens/containers-export-desktop.png b/docs/ux-ui/screens/containers-export-desktop.png
new file mode 100644
index 00000000..5a4aa805
Binary files /dev/null and b/docs/ux-ui/screens/containers-export-desktop.png differ
diff --git a/docs/ux-ui/screens/containers-export-mobile.png b/docs/ux-ui/screens/containers-export-mobile.png
new file mode 100644
index 00000000..6f739507
Binary files /dev/null and b/docs/ux-ui/screens/containers-export-mobile.png differ
diff --git a/docs/ux-ui/screens/containers-export-narrow.png b/docs/ux-ui/screens/containers-export-narrow.png
new file mode 100644
index 00000000..852e2a6f
Binary files /dev/null and b/docs/ux-ui/screens/containers-export-narrow.png differ
diff --git a/docs/ux-ui/screens/containers-overview-desktop.png b/docs/ux-ui/screens/containers-overview-desktop.png
new file mode 100644
index 00000000..74372cf6
Binary files /dev/null and b/docs/ux-ui/screens/containers-overview-desktop.png differ
diff --git a/docs/ux-ui/screens/containers-overview-mobile.png b/docs/ux-ui/screens/containers-overview-mobile.png
new file mode 100644
index 00000000..9b555cc0
Binary files /dev/null and b/docs/ux-ui/screens/containers-overview-mobile.png differ
diff --git a/docs/ux-ui/screens/containers-overview-narrow.png b/docs/ux-ui/screens/containers-overview-narrow.png
new file mode 100644
index 00000000..b102d798
Binary files /dev/null and b/docs/ux-ui/screens/containers-overview-narrow.png differ
diff --git a/tests/e2e/containers-data.spec.ts b/tests/e2e/containers-data.spec.ts
index d6a5c5d5..a9debee7 100644
--- a/tests/e2e/containers-data.spec.ts
+++ b/tests/e2e/containers-data.spec.ts
@@ -25,7 +25,7 @@ test("Контейнеры: частичный отказ устройства
let fail=false;await page.route("**/api/containers/state",r=>r.fulfill({json:fail?state([],{availability:"error",failures:[{resource:"containers",error:"УСТРОЙСТВО ОТКАЗАЛО"}]}):state()}));await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();fail=true;await refresh(page).click();await expect(page.getByRole("alert",{name:"Контейнеры сервера 1",exact:true})).toContainText("Последние контейнеры сохранены");await expect(page.getByText(container.name,{exact:true})).toBeVisible();await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await expect(page.getByRole("menuitem",{name:"Остановить",exact:true})).toBeDisabled()
})
test("Контейнеры: неизвестный статус и partial зависимости видимы",async({page})=>{
- await page.route("**/api/containers/state",r=>r.fulfill({json:state([{...container,status:"unknown",startOnBoot:null,image:"",tag:""}],{failures:[{resource:"envs",error:"ENVS ОТКАЗ"}]})}));await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();await expect(page.getByText("Неизвестно",{exact:true})).toBeVisible();await expect(page.getByRole("alert",{name:"Контейнеры сервера 1"})).toContainText("ENVS ОТКАЗ")
+ await page.route("**/api/containers/state",r=>r.fulfill({json:state([{...container,status:"unknown",startOnBoot:null,image:"",tag:""}],{failures:[{resource:"envs",error:"ENVS ОТКАЗ"}]})}));await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();await expect(page.getByText("Неизвестно",{exact:true}).first()).toBeVisible();await expect(page.getByRole("alert",{name:"Контейнеры сервера 1"})).toContainText("ENVS ОТКАЗ")
})
test("Контейнеры: медленный каталог серверов не задерживает контейнеры",async({page})=>{
let release!:()=>void;const gate=new Promise(r=>release=r);await page.route("**/api/servers",async r=>{await gate;await r.fulfill({json:[server]}).catch(()=>{})});await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();release();await expect(refresh(page)).toBeEnabled()
diff --git a/tests/e2e/containers-layout.spec.ts b/tests/e2e/containers-layout.spec.ts
new file mode 100644
index 00000000..8c09b4c3
--- /dev/null
+++ b/tests/e2e/containers-layout.spec.ts
@@ -0,0 +1,17 @@
+import {test,expect} from "@playwright/test"
+import {container,server,state} from "./fixtures/containers"
+for(const [name,width] of [["desktop",1440],["mobile",390],["narrow",320]] as const)test("Контейнеры: клавиатура и адаптивность "+name,async({page})=>{
+ await page.setViewportSize({width,height:900})
+ const long={...container,name:"container-with-a-very-long-name-for-narrow-screens",image:"registry.example.test/project/long-container-image",comment:"Длинный комментарий для проверки переноса текста в карточке"}
+ 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,name:"Очень длинное имя сервера контейнеров"}]:state([long])}))
+ await page.goto("/containers");await expect(page.getByText(long.name,{exact:true})).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth+1)).toBe(true)
+ const search=page.getByRole("textbox",{name:"Поиск контейнеров"});await search.fill("нет-совпадений");await expect(page.getByText("Измените фильтр или поиск",{exact:true})).toBeVisible();await search.fill("")
+ const stopped=page.getByRole("group",{name:"Статус контейнеров"}).getByRole("button",{name:"Остановлен",exact:true});await stopped.focus();await page.keyboard.press("Enter");await expect(stopped).toHaveAttribute("aria-pressed","true");const all=page.getByRole("group",{name:"Статус контейнеров"}).getByRole("button",{name:"Все",exact:true});await all.click();await expect(all).toHaveAttribute("aria-pressed","true")
+ await page.screenshot({path:"docs/ux-ui/screens/containers-overview-"+name+".png",fullPage:true})
+ const menu=page.getByRole("button",{name:"Действия контейнера "+long.name});await menu.focus();await page.keyboard.press("Enter");await page.getByRole("menuitem",{name:"Снимок JSON",exact:true}).click();const dialog=page.getByRole("dialog");await expect(dialog).toContainText("конфигурация устройства и резервная копия");await expect.poll(async()=>{const bounds=await dialog.boundingBox();return !!bounds&&bounds.x>=0&&bounds.x+bounds.width<=width+1&&(width>=640||bounds.width>=width-2)}).toBe(true);expect(await dialog.evaluate(el=>el.scrollWidth<=el.clientWidth+1)).toBe(true)
+ const download=page.waitForEvent("download");await dialog.getByRole("button",{name:"Файл",exact:true}).click();const file=await download;expect(file.suggestedFilename()).toMatch(/\.json$/);const stream=await file.createReadStream();const chunks:Buffer[]=[];for await(const chunk of stream!)chunks.push(Buffer.from(chunk));const json=JSON.parse(Buffer.concat(chunks).toString("utf8"));expect(json.container.name).toBe(long.name);expect(json.collectedAt).toBe("2026-10-01T00:00:00Z");expect(json.source).toBe("live");expect(JSON.stringify(json)).not.toContain("172.17.0.2")
+ await page.screenshot({path:"docs/ux-ui/screens/containers-export-"+name+".png",fullPage:true});await dialog.getByRole("button",{name:"Закрыть",exact:true}).first().click();await expect(dialog).toHaveCount(0)
+})
+test("Контейнеры: неизвестный статус не предлагает запуск",async({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([{...container,status:"unknown"}])}));await page.goto("/containers");await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await expect(page.getByRole("menuitem",{name:"Запустить",exact:true})).toBeDisabled();await expect(page.getByRole("menuitem",{name:"Перезапустить",exact:true})).toBeDisabled()
+})
diff --git a/tests/e2e/containers-operations.spec.ts b/tests/e2e/containers-operations.spec.ts
index d619fc6d..e1518e62 100644
--- a/tests/e2e/containers-operations.spec.ts
+++ b/tests/e2e/containers-operations.spec.ts
@@ -16,7 +16,7 @@ test("Удаление контейнера: lock, Escape, один запрос
for(const [name,path] of [["Остановить","stop"],["Запустить","start"],["Перезапустить","restart"]] as const)test("Команда контейнера: подтверждение отдельно от состояния "+path,async({page})=>{
const initial={...container,status:path==="start"?"stopped":"running"}
await page.route("**/api/containers/state",r=>r.fulfill({json:state([initial])}));let payload:unknown
- await page.route("**/api/servers/1/containers/"+path,r=>{payload=r.request().postDataJSON();return r.fulfill({json:{ok:true}})});await page.goto("/containers");await action(page,name);await expect(page.getByRole("status").filter({hasText:"API подтвердил команду"})).toBeVisible();expect(payload).toEqual({rosId:"*A"});await expect(page.getByText(path==="start"?"Stopped":"Running",{exact:true}).first()).toBeVisible()
+ await page.route("**/api/servers/1/containers/"+path,r=>{payload=r.request().postDataJSON();return r.fulfill({json:{ok:true}})});await page.goto("/containers");await action(page,name);await expect(page.getByRole("status").filter({hasText:"API подтвердил команду"})).toBeVisible();expect(payload).toEqual({rosId:"*A"});await expect(page.getByText(path==="start"?"Остановлен":"Работает",{exact:true}).first()).toBeVisible()
})
for(const bad of [{ok:false},{},{ok:"true"}])test("Команда контейнера: неверное подтверждение "+JSON.stringify(bad),async({page})=>{
await page.route("**/api/servers/1/containers/stop",r=>r.fulfill({json:bad}));await page.goto("/containers");await action(page,"Остановить");await expect(page.getByRole("alert",{name:"Команда контейнера"})).toContainText("Результат команды не подтверждён");await expect(page.getByRole("status").filter({hasText:"API подтвердил команду"})).toHaveCount(0);await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await expect(page.getByRole("menuitem",{name:"Остановить",exact:true})).toBeDisabled()