diff --git a/app/(main)/containers/page.tsx b/app/(main)/containers/page.tsx index f7258a51..429a64ac 100644 --- a/app/(main)/containers/page.tsx +++ b/app/(main)/containers/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { PageHeader } from "@/components/page-header" import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data" import type { RouterContainer, Server } from "@/lib/data" @@ -25,9 +25,17 @@ import { 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 { serverListSchema } from "@mmapp/contracts/servers" +import type { ContainerServerState, ContainersState } from "@mmapp/contracts/containers" +import { can } from "@/lib/auth" +import { userApiError } from "@/shared/api/users" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" +type ContainerView = Omit & { status: RouterContainer["status"] | "unknown"; startOnBoot: boolean | null } +type ContainerServer = Omit & {status: Server["status"] | null} + interface BackendServer { id: number name: string @@ -37,15 +45,11 @@ interface BackendServer { country: string asn?: string enabled: boolean - status?: Server["status"] + status?: Server["status"] | null latency?: number | null } -interface ContainersApiResponse { - containers: RouterContainer[] -} - -function mapBackendServer(s: BackendServer): Server { +function mapBackendServer(s: BackendServer): ContainerServer { return { id: String(s.id), name: s.name || s.host, @@ -57,14 +61,15 @@ function mapBackendServer(s: BackendServer): Server { asn: s.asn ?? "", type: s.type ?? "exit-node", enabled: s.enabled, - status: s.status ?? "online", + status: s.status ?? null, latency: s.latency ?? null, sessions: 0, } } -function statusConfig(status: RouterContainer["status"]) { +function statusConfig(status: ContainerView["status"]) { return { + unknown: { dot: "bg-muted-foreground", badge: "bg-muted text-muted-foreground border-border", label: "Неизвестно" }, running: { dot: "bg-emerald-500 animate-pulse", badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20", @@ -83,7 +88,7 @@ function statusConfig(status: RouterContainer["status"]) { }[status] } -function generateContainerRsc(c: RouterContainer, serverById: Record): string { +function generateContainerRsc(c: ContainerView, serverById: Record): string { const srv = serverById[c.serverId] const lines: string[] = [] lines.push(`# RouterOS Container — ${c.name}`) @@ -129,9 +134,9 @@ function ExportSheet({ open, container, onClose, serverById, }: { open: boolean - container: RouterContainer | null + container: ContainerView | null onClose: () => void - serverById: Record + serverById: Record }) { const code = useMemo( () => (container ? generateContainerRsc(container, serverById) : ""), @@ -167,8 +172,8 @@ function ContainerCard({ onRestart, onRemove, }: { - container: RouterContainer - server?: Server + container: ContainerView + server?: ContainerServer live: boolean busy: boolean onExport: () => void @@ -178,7 +183,7 @@ function ContainerCard({ onRemove: () => void }) { const cfg = statusConfig(container.status) - const canMutate = live && Boolean(container.rosId) + const canMutate = live && Boolean(container.rosId) && !busy return ( @@ -193,7 +198,7 @@ function ContainerCard({ + } /> @@ -226,7 +231,7 @@ function ContainerCard({
- {container.image}:{container.tag} + {container.image || "Образ неизвестен"}{container.tag && <>:{container.tag}}
@@ -296,61 +301,65 @@ function ContainerCard({ } export default function ContainersPage() { + const { mode, backendUrl, prefsHydrated } = useDataSource() + if (!prefsHydrated) return

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

+ return +} +function ContainersContent() { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" - + const canRead = can("mm:dashboard:read") + const canReadServers = can("mm:servers:read") + const canWrite = can("mm:servers:write") const [search, setSearch] = useState("") - const [statusFilter, setStatusFilter] = useState("all") - const [exportContainer, setExportContainer] = useState(null) + const [statusFilter, setStatusFilter] = useState("all") + const [exportContainer, setExportContainer] = useState(null) const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) - - const [liveContainers, setLiveContainers] = useState([]) - const [liveServers, setLiveServers] = useState([]) - const [loading, setLoading] = useState(false) + const [state, setState] = useState(null) + const [lastStates, setLastStates] = useState>({}) + const [liveServers, setLiveServers] = useState([]) + const [loading, setLoading] = useState(isLive && (canRead || canReadServers)) const [busyId, setBusyId] = useState(null) const [liveError, setLiveError] = useState(null) - + const [serversError, setServersError] = useState(null) + const [readAt, setReadAt] = useState(null) + const [serversReadAt, setServersReadAt] = useState(null) + const loadController = useRef(null) + const loadRevision = useRef(0) const loadLive = useCallback(async () => { - if (!isLive) return + if (!isLive) return false + loadController.current?.abort() + const controller = new AbortController() + loadController.current = controller + const revision = ++loadRevision.current + const current = () => !controller.signal.aborted && loadRevision.current === revision setLoading(true) - setLiveError(null) - try { - const [cRes, sRes] = await Promise.all([ - requestJson(backendUrl, "/api/containers"), - requestJson(backendUrl, "/api/servers"), - ]) - setLiveContainers(cRes.containers ?? []) - setLiveServers(sRes.filter((s) => s.enabled).map(mapBackendServer)) - } catch (e) { - setLiveError(e instanceof Error ? e.message : "Ошибка загрузки") - setLiveContainers([]) - } finally { - setLoading(false) - } - }, [isLive, backendUrl]) - - useEffect(() => { - if (!isLive) { - queueMicrotask(() => { - setLiveContainers([]) - setLiveServers([]) - setLiveError(null) - }) - return - } - queueMicrotask(() => { - void loadLive() - }) - }, [isLive, loadLive]) - - const displayContainers = isLive ? liveContainers : mockContainers - const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled) - - const effectiveServerId = - selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId) - ? selectedServerId - : ALL_SERVERS_ID - + const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) + const tasks = await Promise.allSettled([ + canRead ? readContainersState(backendUrl, { signal }).then(result => { + if (current()) { + setState(result); setReadAt(Date.now()); setLiveError(null) + 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(), + canReadServers ? requestJson(backendUrl, "/api/servers", { signal }).then(payload => { + const result = serverListSchema.parse(payload) + if (new Set(result.map(server => server.id)).size !== result.length) throw new Error("Каталог серверов содержит дубли ID") + if (current()) { setLiveServers(result.filter(server => server.enabled).map(mapBackendServer)); setServersReadAt(Date.now()); setServersError(null) } + }).catch(error => { if (current()) setServersError(userApiError(error)); throw error }) : Promise.resolve(), + ]) + if (current()) setLoading(false) + return current() && 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]) + const displayServers = isLive ? liveServers : mockServers.filter(server => server.enabled) + const effectiveServerId = selectedServerId + 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 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 return displayContainers.filter((c) => c.serverId === effectiveServerId) @@ -368,12 +377,12 @@ export default function ContainersPage() { host: s.host, site: s.site, country: s.country, - status: s.status, + status: s.status ?? "unknown", type: s.type, enabled: s.enabled, - meta: String(displayContainers.filter((c) => c.serverId === s.id).length), + meta: isLive && (!state || !state.servers.some(node => String(node.serverId) === s.id && node.availability !== "error")) ? "—" : String(displayContainers.filter((c) => c.serverId === s.id).length), })) - ), [displayServers, displayContainers]) + ), [displayServers, displayContainers, state, isLive]) const filtered = useMemo(() => { return scoped.filter((c) => { @@ -392,8 +401,8 @@ export default function ContainersPage() { const stopped = scoped.filter((c) => c.status === "stopped").length const errors = scoped.filter((c) => c.status === "error").length - async function mutate(c: RouterContainer, action: "start" | "stop" | "restart" | "remove") { - if (!isLive || !c.rosId) { + async function mutate(c: ContainerView, action: "start" | "stop" | "restart" | "remove") { + if (!isLive || !c.rosId || !canWrite || !nodeFresh(c.serverId)) { toast.info("Действие доступно только в live-режиме") return } @@ -433,12 +442,12 @@ export default function ContainersPage() { variant="outline" size="sm" onClick={() => { void loadLive() }} - disabled={!isLive || loading} + disabled={!isLive || loading || !canRead && !canReadServers || !!busyId} > Обновить - @@ -448,17 +457,15 @@ export default function ContainersPage() { >
- {isLive && liveError && ( - - - Ошибка загрузки: {liveError} - - )} - {isLive && !loading && displayContainers.length === 0 && !liveError && ( -
- Контейнеры не найдены. Нужен пакет container (RouterOS 7.4+). -
- )} + {isLive && !canRead &&

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

} + {isLive && !canReadServers &&

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

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

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

} + {isLive && readAt &&

Каталог контейнеров получен: {new Date(readAt).toLocaleTimeString("ru-RU")}

} + {isLive && serversReadAt &&

Серверы получены: {new Date(serversReadAt).toLocaleTimeString("ru-RU")}

} + {isLive && liveError && {state ? "Последние данные сохранены." : "Контейнеры пока неизвестны."} {liveError}} + {isLive && serversError && {serversReadAt ? "Последний каталог сохранён." : "Каталог серверов пока неизвестен."} {serversError}} + {isLive && relevantStates?.map(node => node.availability === "error" || node.failures.length ? Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: {node.failures.map(f => f.resource + ": " + f.error).join("; ")}. {node.availability === "available" ? "Контейнеры получены; настройки загружены частично." : lastStates[node.serverId]?.containers.length ? "Последние контейнеры сохранены." : "Список неизвестен."} : node.availability === "unsupported" ?

Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: пакет container недоступен.

: null)} + {isLive && selectedServerId !== ALL_SERVERS_ID && !displayServers.some(server => server.id === selectedServerId) &&

Выбранный сервер недоступен в каталоге. Выберите другое устройство; фильтр сохранён.

} {mode === "mock" && ( Моковые данные @@ -471,28 +478,28 @@ export default function ContainersPage() { { id: "all", label: "Всего", - value: scoped.length, + value: known ? scoped.length : "—", icon: , iconClassName: "text-muted-foreground", }, { id: "running", label: "Running", - value: running, + value: known ? running : "—", icon: , iconClassName: "text-success", }, { id: "stopped", label: "Stopped", - value: stopped, + value: known ? stopped : "—", icon: , iconClassName: "text-muted-foreground", }, { id: "errors", label: "Ошибок", - value: errors, + value: known ? errors : "—", icon: , iconClassName: "text-destructive", variant: errors > 0 ? "destructive" : "default", @@ -537,23 +544,23 @@ export default function ContainersPage() { ))}
- {filtered.length} контейнеров + {known ? filtered.length : "—"} контейнеров - {filtered.length === 0 ? ( + {filtered.length === 0 ? (!known ?

Список контейнеров пока неизвестен.

: (

Контейнеры не найдены

-

Добавьте первый контейнер или измените фильтр

+

{scoped.length ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}

- ) : ( + )) : (
{filtered.map((c) => ( setExportContainer(c)} onStart={() => { void mutate(c, "start") }} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 3863b066..003ef209 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А.9; пользователи приняты локально, state API контейнеров подготовлен | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.10; пользователи приняты локально, состояния контейнеров подключены | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11А.10 — frontend состояния контейнеров (2026-10-01) + +- Live читает строгий /containers/state и отдельно /servers; slow/error одной зависимости не скрывает другую. Duplicate server IDs отклоняются. До подтверждения нет демо/ложного empty, неизвестные KPI и rail counts — —. +- Последний успешный снимок сохраняется при общем отказе и отдельно по устройству при частичной ошибке. Неизвестный status виден явно; envs/mounts partial, unsupported и known empty различаются. Браузерное получение каждого каталога подписано. +- Ключ backend/mode и prefsHydrated изолируют контекст; AbortController/revision/30s защищают чтение. Исчезнувший выбранный сервер не заменяется «всеми». Права соответствуют существующим dashboard read/server read/write; без свежих необходимых каталогов команды блокируются. +- Неподключённая кнопка создания disabled. Существующая композиция Frame/KpiStatGrid/ServerRail сохранена; семантическая и мобильная приёмка впереди. +- 14 data E2E, 4 source-context E2E, TypeScript, профильный lint и production frontend прошли. +- Далее 11А.11: single-flight команд, строгий ok:true, подтверждение API отдельно от фактического состояния/refresh, диалог удаления и поздние мутации. Затем экспорт/доступность/ПК/390/320. Реальный RouterOS/БД остаётся внешней приёмкой. + ## Блок 11А.9 — достоверный backend каталог контейнеров (2026-10-01) - Добавлен GET /api/containers/state с общим collectedAt и состояниями каждого enabled сервера. available + [] означает подтверждённую пустую загрузку, unsupported — отсутствие /container, error — отказ чтения. Сбой одного устройства сохраняет остальные. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index 100dfe53..3805ad6a 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -116,3 +116,13 @@ - Backend build, профильный lint, новые service/HTTP tests и старый mapper test прошли. test:containers включён в общий test. Тесты без PostgreSQL/RouterOS; реальные чтения ещё требуют внешней приёмки. - Далее 11А.10: frontend state reader, независимые серверы/контейнеры, последний снимок, unknown KPI, свежесть и защита смены источника. Затем подтверждённые команды, доступность/ПК/телефон. + +## Блок 11А.10 — frontend состояния контейнеров (2026-10-01) + +- Live читает строгий /containers/state и отдельно /servers; slow/error одной зависимости не скрывает другую. Duplicate server IDs отклоняются. До подтверждения нет демо/ложного empty, неизвестные KPI и rail counts — —. +- Последний успешный снимок сохраняется при общем отказе и отдельно по устройству при частичной ошибке. Неизвестный status виден явно; envs/mounts partial, unsupported и known empty различаются. Браузерное получение каждого каталога подписано. +- Ключ backend/mode и prefsHydrated изолируют контекст; AbortController/revision/30s защищают чтение. Исчезнувший выбранный сервер не заменяется «всеми». Права соответствуют существующим dashboard read/server read/write; без свежих необходимых каталогов команды блокируются. +- Неподключённая кнопка создания disabled. Существующая композиция Frame/KpiStatGrid/ServerRail сохранена; семантическая и мобильная приёмка впереди. +- 14 data E2E, 4 source-context E2E, TypeScript, профильный lint и production frontend прошли. +- Далее 11А.11: single-flight команд, строгий ok:true, подтверждение API отдельно от фактического состояния/refresh, диалог удаления и поздние мутации. Затем экспорт/доступность/ПК/390/320. Реальный RouterOS/БД остаётся внешней приёмкой. + diff --git a/playwright.config.ts b/playwright.config.ts index cb78ccd2..b79ce9a0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test" export default defineConfig({ testDir: "./tests/e2e", - testIgnore: ["**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], + testIgnore: ["**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], fullyParallel: false, workers: 1, retries: 0, diff --git a/playwright.containers-context.config.ts b/playwright.containers-context.config.ts new file mode 100644 index 00000000..5f12d1ea --- /dev/null +++ b/playwright.containers-context.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "@playwright/test" +import base from "./playwright.config" +const server=base.webServer +if(!server||Array.isArray(server))throw new Error("Ожидается один локальный сервер") +export default defineConfig({ + ...base, use:{...base.use,baseURL:"http://127.0.0.1:3101"},testIgnore:[],testMatch:"containers-context.spec.ts", + webServer:{...server,command:"node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101",url:"http://127.0.0.1:3101",env:{...server.env,NEXT_PUBLIC_BACKEND_URL:"local"}}, +}) diff --git a/shared/api/containers.ts b/shared/api/containers.ts new file mode 100644 index 00000000..370fa26e --- /dev/null +++ b/shared/api/containers.ts @@ -0,0 +1,5 @@ +import { containersStateSchema } 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)) +} diff --git a/tests/e2e/containers-context.spec.ts b/tests/e2e/containers-context.spec.ts new file mode 100644 index 00000000..3847937f --- /dev/null +++ b/tests/e2e/containers-context.spec.ts @@ -0,0 +1,13 @@ +import {test,expect} from "@playwright/test" +import {container,server,state} from "./fixtures/containers" +const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"} +for(const change of ["backend","mode"])for(const fails of [false,true])test("Контейнеры: позднее чтение/"+change+"/"+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/containers/state",async r=>{if(new URL(r.request().url()).port!=="8000")return r.fallback();started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"СТАРАЯ ОШИБКА контейнеров"}}:{headers,json:state([{...container,name:"СТАРЫЙ КОНТЕЙНЕР"}])})}catch{}finally{completed=true}}) + await page.goto("/containers");await expect.poll(()=>started).toBe(true);await page.goto("/settings") + if(change==="backend"){const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter")}else await page.getByRole("button",{name:"Моковые",exact:true}).click() + await page.goto("/containers");release();await expect.poll(()=>completed).toBe(true) + 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) +}) diff --git a/tests/e2e/containers-data.spec.ts b/tests/e2e/containers-data.spec.ts new file mode 100644 index 00000000..d6a5c5d5 --- /dev/null +++ b/tests/e2e/containers-data.spec.ts @@ -0,0 +1,38 @@ +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)) +const refresh=(page:Page)=>page.getByRole("button",{name:"Обновить",exact:true}) +for(const kind of ["API","malformed","duplicate","foreign"] as const)test("Контейнеры: первоначальный отказ "+kind,async({page})=>{ + let fail=true;await page.route("**/api/containers/state",r=>r.fulfill(fail?(kind==="API"?{status:503,json:{error:"Каталог отказал"}}:{json:kind==="malformed"?{containers:[]}:state(kind==="duplicate"?[container,container]:[{...container,serverId:"2"}])}):{json:state()})) + await page.goto("/containers");await expect(page.getByRole("alert",{name:"Каталог контейнеров",exact:true})).toContainText("Контейнеры пока неизвестны");await expect(page.getByText("Контейнеры не найдены",{exact:true})).toHaveCount(0);await expect(page.getByText(container.name,{exact:true})).toHaveCount(0);await expect(page.getByRole("button",{name:"Новый контейнер",exact:true})).toBeDisabled() + fail=false;await refresh(page).click();await expect(page.getByText(container.name,{exact:true})).toBeVisible() +}) +for(const kind of ["API","malformed","duplicate"] as const)test("Контейнеры: независимый отказ серверов "+kind,async({page})=>{ + await page.route("**/api/servers",r=>r.fulfill(kind==="API"?{status:403,json:{error:"Серверы отказали"}}:{json:kind==="duplicate"?[server,server]:[{}]}));await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();await expect(page.getByRole("alert",{name:"Серверы контейнеров"})).toContainText("Каталог серверов пока неизвестен") + await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await expect(page.getByRole("menuitem",{name:"Остановить",exact:true})).toBeDisabled() +}) +test("Контейнеры: подтверждённое empty и unsupported",async({page})=>{ + let unsupported=false;await page.route("**/api/containers/state",r=>r.fulfill({json:state([],{availability:unsupported?"unsupported":"available"})}));await page.goto("/containers");await expect(page.getByText("Подтверждённый каталог пуст",{exact:true})).toBeVisible();await expect(page.getByText("Список контейнеров пока неизвестен",{exact:true})).toHaveCount(0);unsupported=true;await refresh(page).click();await expect(page.getByRole("status").filter({hasText:"пакет container недоступен"})).toBeVisible() +}) +test("Контейнеры: фоновый отказ сохраняет снимок",async({page})=>{ + let fail=false;await page.route("**/api/containers/state",r=>r.fulfill(fail?{status:503,json:{error:"ФОНОВЫЙ ОТКАЗ"}}:{json: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:"Каталог контейнеров",exact:true})).toContainText("Последние данные сохранены");await expect(page.getByText(container.name,{exact:true})).toBeVisible() +}) +test("Контейнеры: частичный отказ устройства сохраняет его прошлые строки",async({page})=>{ + 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 ОТКАЗ") +}) +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() +}) +test("Контейнеры: до ответа нет демо и ложного empty",async({page})=>{ + let release!:()=>void;const gate=new Promise(r=>release=r);await page.route("**/api/containers/state",async r=>{await gate;await r.fulfill({json:state()}).catch(()=>{})});await page.goto("/containers");await expect(page.getByRole("status").filter({hasText:"Загрузка контейнеров…"})).toBeVisible();await expect(page.getByText("Контейнеры не найдены",{exact:true})).toHaveCount(0);await expect(page.getByText(container.name,{exact:true})).toHaveCount(0);release();await expect(page.getByText(container.name,{exact:true})).toBeVisible() +}) +test("Контейнеры: нет server read — запрос и команды недоступны",async({page})=>{ + await page.addInitScript(()=>localStorage.setItem("mmapp_token","header."+btoa(JSON.stringify({sub:"test",apps:["mm"],permissions:["mm:dashboard:read"],exp:Math.floor(Date.now()/1000)+3600}))+".signature"));await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}}));let count=0;await page.route("**/api/servers",r=>{count++;return r.fulfill({json:[server]})});await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();await expect(page.getByRole("status").filter({hasText:"mm:servers:read"})).toBeVisible();expect(count).toBe(0);await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await expect(page.getByRole("menuitem",{name:"Остановить",exact:true})).toBeDisabled() +}) diff --git a/tests/e2e/fixtures/containers.ts b/tests/e2e/fixtures/containers.ts new file mode 100644 index 00000000..faa2ea8d --- /dev/null +++ b/tests/e2e/fixtures/containers.ts @@ -0,0 +1,6 @@ +import { server } from "./users" +export { server } +export const container = {id:"1-*A",rosId:"*A",name:"container-live",serverId:"1",image:"nginx",tag:"alpine",status:"running",envs:[],mounts:[],interfaces:["veth1"],startOnBoot:false,comment:"",cpu:0,memMb:0} +export function state(containers:unknown[]=[container], overrides:Record={}) { + return {collectedAt:"2026-10-01T00:00:00Z",servers:[{serverId:1,collectedAt:"2026-10-01T00:00:00Z",availability:"available",containers,failures:[],...overrides}]} +}