diff --git a/app/(main)/containers/page.tsx b/app/(main)/containers/page.tsx index 429a64ac..9157e681 100644 --- a/app/(main)/containers/page.tsx +++ b/app/(main)/containers/page.tsx @@ -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([]) const [loading, setLoading] = useState(isLive && (canRead || canReadServers)) const [busyId, setBusyId] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + const [operationError, setOperationError] = useState(null) + const [operationNotice, setOperationNotice] = useState(null) + const [commandNeedsRead, setCommandNeedsRead] = useState(false) + const mutationLock = useRef(false) + const mutationController = useRef(null) + const alive = useRef(true) + useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, []) const [liveError, setLiveError] = useState(null) const [serversError, setServersError] = useState(null) const [readAt, setReadAt] = useState(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() { >
+ {operationNotice &&

{operationNotice}

} + {operationError && !deleteTarget && {operationError}} {isLive && !canRead &&

Для каталога контейнеров требуется mm:dashboard:read.

} {isLive && !canReadServers &&

Для каталога серверов требуется mm:servers:read. Команды недоступны.

} {isLive && loading && !state &&

Загрузка контейнеров…

} @@ -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) }} /> ))}
@@ -633,6 +656,17 @@ function ContainersContent() { + {deleteTarget && { if (!open && !busyId) { setDeleteTarget(null); setOperationError(null) } }}> + + Удалить контейнер?{deleteTarget.name} · сервер {serverById[deleteTarget.serverId]?.name ?? deleteTarget.serverId}. Будет отправлена команда удаления в RouterOS. + {operationError &&

{operationError}

} + + {commandNeedsRead && } + Отмена + row.id === deleteTarget.id && row.rosId === deleteTarget.rosId)} aria-busy={!!busyId} onClick={() => { void mutate(deleteTarget, "remove") }}>{busyId ? "Удаление…" : "Удалить"} + +
+
} export type ContainerServerState = z.infer export type ContainersState = z.infer + +export const containerMutationAckSchema = z.object({ ok: z.literal(true) }) diff --git a/shared/api/containers.ts b/shared/api/containers.ts index 370fa26e..f547d902 100644 --- a/shared/api/containers.ts +++ b/shared/api/containers.ts @@ -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(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(baseUrl, `/api/servers/${encodeURIComponent(serverId)}/containers/${action}`, { ...options, method: "POST", body: JSON.stringify({ rosId }) })) +} diff --git a/tests/e2e/containers-context.spec.ts b/tests/e2e/containers-context.spec.ts index 3847937f..19d7884f 100644 --- a/tests/e2e/containers-context.spec.ts +++ b/tests/e2e/containers-context.spec.ts @@ -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(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) +}) diff --git a/tests/e2e/containers-operations.spec.ts b/tests/e2e/containers-operations.spec.ts new file mode 100644 index 00000000..d619fc6d --- /dev/null +++ b/tests/e2e/containers-operations.spec.ts @@ -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(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(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() +})