fix(ui): подтверждать команды контейнеров ответом API
This commit is contained in:
1 parent
860add8edf
commit
c419a46955
7 files changed
+125
-25
No files matched your search
@@ -10,6 +10,7 @@ import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
@@ -24,8 +25,8 @@ import {
|
||||
} from "lucide-react"
|
||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { readContainersState } from "@/shared/api/containers"
|
||||
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
||||
import { mutateContainer, type ContainerAction, readContainersState } from "@/shared/api/containers"
|
||||
import { serverListSchema } from "@mmapp/contracts/servers"
|
||||
import type { ContainerServerState, ContainersState } from "@mmapp/contracts/containers"
|
||||
import { can } from "@/lib/auth"
|
||||
@@ -320,6 +321,14 @@ function ContainersContent() {
|
||||
const [liveServers, setLiveServers] = useState<ContainerServer[]>([])
|
||||
const [loading, setLoading] = useState(isLive && (canRead || canReadServers))
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<ContainerView | null>(null)
|
||||
const [operationError, setOperationError] = useState<string | null>(null)
|
||||
const [operationNotice, setOperationNotice] = useState<string | null>(null)
|
||||
const [commandNeedsRead, setCommandNeedsRead] = useState(false)
|
||||
const mutationLock = useRef(false)
|
||||
const mutationController = useRef<AbortController | null>(null)
|
||||
const alive = useRef(true)
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
|
||||
const [liveError, setLiveError] = useState<string | null>(null)
|
||||
const [serversError, setServersError] = useState<string | null>(null)
|
||||
const [readAt, setReadAt] = useState<number | null>(null)
|
||||
@@ -335,10 +344,13 @@ function ContainersContent() {
|
||||
const current = () => !controller.signal.aborted && loadRevision.current === revision
|
||||
setLoading(true)
|
||||
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
||||
let stateComplete = !canRead
|
||||
const tasks = await Promise.allSettled([
|
||||
canRead ? readContainersState(backendUrl, { signal }).then(result => {
|
||||
stateComplete = result.servers.every(node => node.availability !== "error")
|
||||
if (current()) {
|
||||
setState(result); setReadAt(Date.now()); setLiveError(null)
|
||||
if (result.servers.every(node => node.availability !== "error")) setCommandNeedsRead(false)
|
||||
setLastStates(previous => Object.fromEntries(result.servers.map(node => [node.serverId, node.availability === "error" ? previous[node.serverId] ?? node : node])))
|
||||
}
|
||||
}).catch(error => { if (current()) setLiveError(userApiError(error)); throw error }) : Promise.resolve(),
|
||||
@@ -349,7 +361,7 @@ function ContainersContent() {
|
||||
}).catch(error => { if (current()) setServersError(userApiError(error)); throw error }) : Promise.resolve(),
|
||||
])
|
||||
if (current()) setLoading(false)
|
||||
return current() && tasks.every(task => task.status === "fulfilled")
|
||||
return current() && stateComplete && tasks.every(task => task.status === "fulfilled")
|
||||
}, [isLive, backendUrl, canRead, canReadServers])
|
||||
useEffect(() => { let cancelled = false; queueMicrotask(() => { if (!cancelled) void loadLive() }); return () => { cancelled = true; loadController.current?.abort() } }, [loadLive])
|
||||
const displayContainers: ContainerView[] = useMemo(() => isLive ? Object.values(lastStates).flatMap(node => node.containers) : mockContainers, [isLive, lastStates])
|
||||
@@ -358,7 +370,7 @@ function ContainersContent() {
|
||||
const relevantStates = state?.servers.filter(node => selectedServerId === ALL_SERVERS_ID || String(node.serverId) === selectedServerId)
|
||||
const known = !isLive || !!state && (selectedServerId === ALL_SERVERS_ID || !!relevantStates?.length) && relevantStates!.every(node => node.availability !== "error")
|
||||
const stale = isLive && !!liveError
|
||||
const writeReady = !isLive || canWrite && !loading && !stale && !!serversReadAt && !serversError
|
||||
const writeReady = !isLive || canWrite && !commandNeedsRead && !loading && !stale && !!serversReadAt && !serversError
|
||||
const nodeFresh = (serverId: string) => !isLive || writeReady && state?.servers.some(node => String(node.serverId) === serverId && node.availability === "available" && displayServers.some(server => server.id === serverId))
|
||||
const scoped = useMemo(() => {
|
||||
if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
|
||||
@@ -401,25 +413,34 @@ function ContainersContent() {
|
||||
const stopped = scoped.filter((c) => c.status === "stopped").length
|
||||
const errors = scoped.filter((c) => c.status === "error").length
|
||||
|
||||
async function mutate(c: ContainerView, action: "start" | "stop" | "restart" | "remove") {
|
||||
if (!isLive || !c.rosId || !canWrite || !nodeFresh(c.serverId)) {
|
||||
toast.info("Действие доступно только в live-режиме")
|
||||
return
|
||||
}
|
||||
if (action === "remove" && !window.confirm(`Удалить контейнер ${c.name}?`)) return
|
||||
setBusyId(c.id)
|
||||
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
|
||||
mutationLock.current = true
|
||||
const controller = new AbortController()
|
||||
mutationController.current = controller
|
||||
const current = () => alive.current && !controller.signal.aborted
|
||||
setBusyId(c.id); setOperationError(null); setOperationNotice(null)
|
||||
try {
|
||||
await requestJson(backendUrl, `/api/servers/${c.serverId}/containers/${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rosId: c.rosId }),
|
||||
})
|
||||
const labels = { start: "запущен", stop: "остановлен", restart: "перезапущен", remove: "удалён" }
|
||||
toast.success(`${c.name}: ${labels[action]}`)
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка RouterOS")
|
||||
await mutateContainer(backendUrl, c.serverId, c.rosId, action, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
|
||||
if (!current()) return
|
||||
const labels = { start: "запуск", stop: "остановка", restart: "перезапуск", remove: "удаление" }
|
||||
const message = c.name + ": API подтвердил команду «" + labels[action] + "». Состояние проверяется повторным чтением."
|
||||
setOperationNotice(message)
|
||||
if (action === "remove") {
|
||||
setLastStates(previous => Object.fromEntries(Object.entries(previous).map(([id, node]) => [id, { ...node, containers: node.containers.filter(row => row.id !== c.id) }])))
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
toast.success("Команда подтверждена API.")
|
||||
const refreshed = await loadLive()
|
||||
if (current() && !refreshed) setOperationNotice(message + " Обновление каталогов не удалось; повторите чтение.")
|
||||
} catch (error) {
|
||||
if (!current()) return
|
||||
const uncertain = !(error instanceof ApiClientError && error.status >= 400 && error.status < 500)
|
||||
setCommandNeedsRead(uncertain)
|
||||
setOperationError(c.name + ": " + userApiError(error) + (uncertain ? " Результат команды не подтверждён; она могла выполниться частично. Обновите каталог перед новым действием." : " Команда отклонена API."))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
||||
if (alive.current) setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,6 +478,8 @@ function ContainersContent() {
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{operationNotice && <p role="status" className="text-sm">{operationNotice}</p>}
|
||||
{operationError && !deleteTarget && <Alert role="alert" aria-label="Команда контейнера" variant="warning"><AlertDescription>{operationError}</AlertDescription></Alert>}
|
||||
{isLive && !canRead && <p role="status">Для каталога контейнеров требуется mm:dashboard:read.</p>}
|
||||
{isLive && !canReadServers && <p role="status">Для каталога серверов требуется mm:servers:read. Команды недоступны.</p>}
|
||||
{isLive && loading && !state && <p role="status">Загрузка контейнеров…</p>}
|
||||
@@ -561,12 +584,12 @@ function ContainersContent() {
|
||||
container={c}
|
||||
server={serverById[c.serverId]}
|
||||
live={isLive && !!nodeFresh(c.serverId)}
|
||||
busy={busyId === c.id}
|
||||
busy={!!busyId}
|
||||
onExport={() => setExportContainer(c)}
|
||||
onStart={() => { void mutate(c, "start") }}
|
||||
onStop={() => { void mutate(c, "stop") }}
|
||||
onRestart={() => { void mutate(c, "restart") }}
|
||||
onRemove={() => { void mutate(c, "remove") }}
|
||||
onRemove={() => { setOperationError(null); setDeleteTarget(c) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -633,6 +656,17 @@ function ContainersContent() {
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
{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>
|
||||
{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>}
|
||||
<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>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>}
|
||||
<ExportSheet
|
||||
open={!!exportContainer}
|
||||
container={exportContainer}
|
||||
|
||||
@@ -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А.10; пользователи приняты локально, состояния контейнеров подключены |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.11; пользователи приняты локально, команды контейнеров проверены |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
|
||||
|
||||
|
||||
## Блок 11А.11 — подтверждённые команды контейнеров (2026-10-01)
|
||||
|
||||
- POST start/stop/restart/remove проходит через typed reader со строгим ok:true, проверкой serverId/rosId и 30s сроком. Single-flight ref блокирует все карточки; поздние ответы после ухода/смены backend не меняют UI.
|
||||
- Текст результата подтверждает команду API, фактический статус определяется повторным чтением. Ошибка refresh не отменяет подтверждение; подтверждённо удалённая карточка убирается до чтения.
|
||||
- Удаление использует устойчивый AlertDialog с target/ошибкой/повтором. Ожидание блокирует Escape/отмену. 4xx — отклонение, malformed/network/5xx — неподтверждённый или частичный результат; новое действие требует явного чтения каталога. Исчезнувший target повторно не отправляется.
|
||||
- 26 data/operations E2E, 8 context E2E, TypeScript, профильный lint и production frontend прошли. Модель сетевых команд/авторизации backend не менялась; реальные операции RouterOS требуют внешней приёмки.
|
||||
- Далее 11А.12: доступность, семантические токены, ПК/390/320 и достоверность экспорта/метрик. Аудит экспорта выявил шаблонные veth IP и команды, которые не являются экспортом реального устройства; это нельзя выдавать за резервную конфигурацию.
|
||||
|
||||
## Блок 11А.10 — frontend состояния контейнеров (2026-10-01)
|
||||
|
||||
- Live читает строгий /containers/state и отдельно /servers; slow/error одной зависимости не скрывает другую. Duplicate server IDs отклоняются. До подтверждения нет демо/ложного empty, неизвестные KPI и rail counts — —.
|
||||
|
||||
@@ -126,3 +126,12 @@
|
||||
- 14 data E2E, 4 source-context E2E, TypeScript, профильный lint и production frontend прошли.
|
||||
- Далее 11А.11: single-flight команд, строгий ok:true, подтверждение API отдельно от фактического состояния/refresh, диалог удаления и поздние мутации. Затем экспорт/доступность/ПК/390/320. Реальный RouterOS/БД остаётся внешней приёмкой.
|
||||
|
||||
|
||||
## Блок 11А.11 — подтверждённые команды контейнеров (2026-10-01)
|
||||
|
||||
- POST start/stop/restart/remove проходит через typed reader со строгим ok:true, проверкой serverId/rosId и 30s сроком. Single-flight ref блокирует все карточки; поздние ответы после ухода/смены backend не меняют UI.
|
||||
- Текст результата подтверждает команду API, фактический статус определяется повторным чтением. Ошибка refresh не отменяет подтверждение; подтверждённо удалённая карточка убирается до чтения.
|
||||
- Удаление использует устойчивый AlertDialog с target/ошибкой/повтором. Ожидание блокирует Escape/отмену. 4xx — отклонение, malformed/network/5xx — неподтверждённый или частичный результат; новое действие требует явного чтения каталога. Исчезнувший target повторно не отправляется.
|
||||
- 26 data/operations E2E, 8 context E2E, TypeScript, профильный lint и production frontend прошли. Модель сетевых команд/авторизации backend не менялась; реальные операции RouterOS требуют внешней приёмки.
|
||||
- Далее 11А.12: доступность, семантические токены, ПК/390/320 и достоверность экспорта/метрик. Аудит экспорта выявил шаблонные veth IP и команды, которые не являются экспортом реального устройства; это нельзя выдавать за резервную конфигурацию.
|
||||
|
||||
@@ -35,3 +35,5 @@ export const containersStateSchema = z.object({
|
||||
export type ContainerStateRow = z.infer<typeof containerStateRowSchema>
|
||||
export type ContainerServerState = z.infer<typeof containerServerStateSchema>
|
||||
export type ContainersState = z.infer<typeof containersStateSchema>
|
||||
|
||||
export const containerMutationAckSchema = z.object({ ok: z.literal(true) })
|
||||
@@ -1,5 +1,11 @@
|
||||
import { containersStateSchema } from "@mmapp/contracts/containers"
|
||||
import { containersStateSchema, containerMutationAckSchema } from "@mmapp/contracts/containers"
|
||||
import { requestJson } from "./http-client"
|
||||
export async function readContainersState(baseUrl: string, options?: RequestInit) {
|
||||
return containersStateSchema.parse(await requestJson<unknown>(baseUrl, "/api/containers/state", options))
|
||||
}
|
||||
|
||||
export type ContainerAction = "start" | "stop" | "restart" | "remove"
|
||||
export async function mutateContainer(baseUrl: string, serverId: string, rosId: string, action: ContainerAction, options?: RequestInit) {
|
||||
if (!/^[1-9]\d*$/.test(serverId) || !Number.isSafeInteger(Number(serverId)) || !rosId.trim()) throw new Error("Неверный контекст контейнера")
|
||||
return containerMutationAckSchema.parse(await requestJson<unknown>(baseUrl, `/api/servers/${encodeURIComponent(serverId)}/containers/${action}`, { ...options, method: "POST", body: JSON.stringify({ rosId }) }))
|
||||
}
|
||||
@@ -11,3 +11,12 @@ for(const change of ["backend","mode"])for(const fails of [false,true])test("К
|
||||
if(change==="backend")await expect(page.getByText(container.name,{exact:true})).toBeVisible();else await expect(page.getByText("Моковые данные",{exact:true})).toBeVisible()
|
||||
await expect(page.getByText("СТАРЫЙ КОНТЕЙНЕР",{exact:true})).toHaveCount(0);await expect(page.getByText(/СТАРАЯ ОШИБКА контейнеров/)).toHaveCount(0)
|
||||
})
|
||||
for(const action of ["stop","remove"])for(const fails of [false,true])test("Контейнеры: поздняя команда/"+action+"/"+fails,async({page})=>{
|
||||
let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(r=>release=r)
|
||||
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[server]:state()})})
|
||||
await page.route("**/api/servers/1/containers/"+action,async r=>{started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"СТАРАЯ КОМАНДА ОТКАЗ"}}:{headers,json:{ok:true}})}catch{}finally{completed=true}})
|
||||
await page.goto("/containers");await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await page.getByRole("menuitem",{name:action==="stop"?"Остановить":"Удалить",exact:true}).click()
|
||||
if(action==="remove")await page.getByRole("alertdialog").getByRole("button",{name:"Удалить",exact:true}).click()
|
||||
await expect.poll(()=>started).toBe(true);await page.goto("/settings");const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter");await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();release();await expect.poll(()=>completed).toBe(true)
|
||||
await expect(page.getByText(container.name,{exact:true})).toBeVisible();await expect(page.getByText(/СТАРАЯ КОМАНДА ОТКАЗ/)).toHaveCount(0);await expect(page.getByRole("status").filter({hasText:"API подтвердил команду"})).toHaveCount(0)
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
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))
|
||||
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 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)
|
||||
if(kind==="403"){await expect(dialog.getByRole("button",{name:"Удалить",exact:true})).toBeEnabled()}else{await expect(dialog.getByRole("button",{name:"Удалить",exact:true})).toBeDisabled();await expect(dialog).toContainText("Результат команды не подтверждён");await dialog.getByRole("button",{name:"Обновить каталог",exact:true}).click();await expect(dialog.getByRole("button",{name:"Удалить",exact:true})).toBeEnabled()}
|
||||
})
|
||||
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.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})=>{
|
||||
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()
|
||||
})
|
||||
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()
|
||||
})
|
||||
test("Команда контейнера: общий lock блокирует другие карточки",async({page})=>{
|
||||
let release!:()=>void,calls=0;const gate=new Promise<void>(r=>release=r);const other={...container,id:"1-*B",rosId:"*B",name:"container-other"}
|
||||
await page.route("**/api/containers/state",r=>r.fulfill({json:state([container,other])}));await page.route("**/api/servers/1/containers/stop",async r=>{calls++;await gate;await r.fulfill({json:{ok:true}})})
|
||||
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:"Связь потеряна"}})})
|
||||
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()
|
||||
})
|
||||
Reference in new issue
Block a user