fix(ui): сохранить достоверные состояния контейнеров
This commit is contained in:
1 parent
644ddb64b3
commit
860add8edf
9 files changed
+189
-93
No files matched your search
@@ -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<RouterContainer, "status" | "startOnBoot"> & { status: RouterContainer["status"] | "unknown"; startOnBoot: boolean | null }
|
||||
type ContainerServer = Omit<Server, "status"> & {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, Server>): string {
|
||||
function generateContainerRsc(c: ContainerView, serverById: Record<string, ContainerServer>): 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<string, Server>
|
||||
serverById: Record<string, ContainerServer>
|
||||
}) {
|
||||
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 (
|
||||
<Frame dense className="w-full overflow-hidden">
|
||||
@@ -193,7 +198,7 @@ function ContainerCard({
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7 shrink-0" disabled={busy}>
|
||||
<Button variant="ghost" size="icon" aria-label={"Действия контейнера " + container.name} className="size-7 shrink-0" disabled={busy}>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
@@ -226,7 +231,7 @@ function ContainerCard({
|
||||
<div className="flex items-center gap-2">
|
||||
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs text-foreground/80">
|
||||
{container.image}:<span className="text-muted-foreground">{container.tag}</span>
|
||||
{container.image || "Образ неизвестен"}{container.tag && <>:<span className="text-muted-foreground">{container.tag}</span></>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -296,61 +301,65 @@ function ContainerCard({
|
||||
}
|
||||
|
||||
export default function ContainersPage() {
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
if (!prefsHydrated) return <p role="status">Загрузка настроек контейнеров…</p>
|
||||
return <ContainersContent key={JSON.stringify([mode, backendUrl])} />
|
||||
}
|
||||
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<RouterContainer["status"] | "all">("all")
|
||||
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null)
|
||||
const [statusFilter, setStatusFilter] = useState<ContainerView["status"] | "all">("all")
|
||||
const [exportContainer, setExportContainer] = useState<ContainerView | null>(null)
|
||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||
|
||||
const [liveContainers, setLiveContainers] = useState<RouterContainer[]>([])
|
||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [state, setState] = useState<ContainersState | null>(null)
|
||||
const [lastStates, setLastStates] = useState<Record<number, ContainerServerState>>({})
|
||||
const [liveServers, setLiveServers] = useState<ContainerServer[]>([])
|
||||
const [loading, setLoading] = useState(isLive && (canRead || canReadServers))
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
const [liveError, setLiveError] = useState<string | null>(null)
|
||||
|
||||
const [serversError, setServersError] = useState<string | null>(null)
|
||||
const [readAt, setReadAt] = useState<number | null>(null)
|
||||
const [serversReadAt, setServersReadAt] = useState<number | null>(null)
|
||||
const loadController = useRef<AbortController | null>(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<ContainersApiResponse>(backendUrl, "/api/containers"),
|
||||
requestJson<BackendServer[]>(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<unknown>(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}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<Button size="sm" disabled title="Создание контейнеров ещё не подключено">
|
||||
<BoxIcon className="size-4" />Новый контейнер
|
||||
</Button>
|
||||
</>
|
||||
@@ -448,17 +457,15 @@ export default function ContainersPage() {
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{isLive && liveError && (
|
||||
<Alert variant="warning" className="py-2">
|
||||
<AlertCircleIcon />
|
||||
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{isLive && !loading && displayContainers.length === 0 && !liveError && (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
Контейнеры не найдены. Нужен пакет container (RouterOS 7.4+).
|
||||
</div>
|
||||
)}
|
||||
{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>}
|
||||
{isLive && readAt && <p role="status" className="text-xs text-muted-foreground">Каталог контейнеров получен: {new Date(readAt).toLocaleTimeString("ru-RU")}</p>}
|
||||
{isLive && serversReadAt && <p role="status" className="text-xs text-muted-foreground">Серверы получены: {new Date(serversReadAt).toLocaleTimeString("ru-RU")}</p>}
|
||||
{isLive && liveError && <Alert variant="warning" role="alert" aria-label="Каталог контейнеров"><AlertDescription>{state ? "Последние данные сохранены." : "Контейнеры пока неизвестны."} {liveError}</AlertDescription></Alert>}
|
||||
{isLive && serversError && <Alert variant="warning" role="alert" aria-label="Серверы контейнеров"><AlertDescription>{serversReadAt ? "Последний каталог сохранён." : "Каталог серверов пока неизвестен."} {serversError}</AlertDescription></Alert>}
|
||||
{isLive && relevantStates?.map(node => node.availability === "error" || node.failures.length ? <Alert key={node.serverId} variant="warning" role="alert" aria-label={"Контейнеры сервера " + node.serverId}><AlertDescription>Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: {node.failures.map(f => f.resource + ": " + f.error).join("; ")}. {node.availability === "available" ? "Контейнеры получены; настройки загружены частично." : lastStates[node.serverId]?.containers.length ? "Последние контейнеры сохранены." : "Список неизвестен."}</AlertDescription></Alert> : node.availability === "unsupported" ? <p key={node.serverId} role="status">Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: пакет container недоступен.</p> : null)}
|
||||
{isLive && selectedServerId !== ALL_SERVERS_ID && !displayServers.some(server => server.id === selectedServerId) && <p role="alert">Выбранный сервер недоступен в каталоге. Выберите другое устройство; фильтр сохранён.</p>}
|
||||
{mode === "mock" && (
|
||||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
Моковые данные
|
||||
@@ -471,28 +478,28 @@ export default function ContainersPage() {
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего",
|
||||
value: scoped.length,
|
||||
value: known ? scoped.length : "—",
|
||||
icon: <BoxIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "running",
|
||||
label: "Running",
|
||||
value: running,
|
||||
value: known ? running : "—",
|
||||
icon: <PlayIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "stopped",
|
||||
label: "Stopped",
|
||||
value: stopped,
|
||||
value: known ? stopped : "—",
|
||||
icon: <StopCircleIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "errors",
|
||||
label: "Ошибок",
|
||||
value: errors,
|
||||
value: known ? errors : "—",
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: "text-destructive",
|
||||
variant: errors > 0 ? "destructive" : "default",
|
||||
@@ -537,23 +544,23 @@ export default function ContainersPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span>
|
||||
<span className="text-sm text-muted-foreground ml-auto">{known ? filtered.length : "—"} контейнеров</span>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
{filtered.length === 0 ? (!known ? <p role="status">Список контейнеров пока неизвестен.</p> : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||
<BoxIcon className="size-10 mb-3 opacity-20" />
|
||||
<p className="text-sm font-medium">Контейнеры не найдены</p>
|
||||
<p className="text-xs mt-1">Добавьте первый контейнер или измените фильтр</p>
|
||||
<p className="text-xs mt-1">{scoped.length ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}</p>
|
||||
</div>
|
||||
) : (
|
||||
)) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filtered.map((c) => (
|
||||
<ContainerCard
|
||||
key={c.id}
|
||||
container={c}
|
||||
server={serverById[c.serverId]}
|
||||
live={isLive}
|
||||
live={isLive && !!nodeFresh(c.serverId)}
|
||||
busy={busyId === c.id}
|
||||
onExport={() => setExportContainer(c)}
|
||||
onStart={() => { void mutate(c, "start") }}
|
||||
|
||||
+10
-1
@@ -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 — отказ чтения. Сбой одного устройства сохраняет остальные.
|
||||
|
||||
@@ -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/БД остаётся внешней приёмкой.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}},
|
||||
})
|
||||
@@ -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<unknown>(baseUrl, "/api/containers/state", options))
|
||||
}
|
||||
@@ -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<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/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)
|
||||
})
|
||||
@@ -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<void>(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<void>(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()
|
||||
})
|
||||
@@ -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<string,unknown>={}) {
|
||||
return {collectedAt:"2026-10-01T00:00:00Z",servers:[{serverId:1,collectedAt:"2026-10-01T00:00:00Z",availability:"available",containers,failures:[],...overrides}]}
|
||||
}
|
||||
Reference in new issue
Block a user