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"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
|
import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
|
||||||
import type { RouterContainer, Server } 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 { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
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 { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
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 {
|
interface BackendServer {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@@ -37,15 +45,11 @@ interface BackendServer {
|
|||||||
country: string
|
country: string
|
||||||
asn?: string
|
asn?: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
status?: Server["status"]
|
status?: Server["status"] | null
|
||||||
latency?: number | null
|
latency?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContainersApiResponse {
|
function mapBackendServer(s: BackendServer): ContainerServer {
|
||||||
containers: RouterContainer[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapBackendServer(s: BackendServer): Server {
|
|
||||||
return {
|
return {
|
||||||
id: String(s.id),
|
id: String(s.id),
|
||||||
name: s.name || s.host,
|
name: s.name || s.host,
|
||||||
@@ -57,14 +61,15 @@ function mapBackendServer(s: BackendServer): Server {
|
|||||||
asn: s.asn ?? "",
|
asn: s.asn ?? "",
|
||||||
type: s.type ?? "exit-node",
|
type: s.type ?? "exit-node",
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
status: s.status ?? "online",
|
status: s.status ?? null,
|
||||||
latency: s.latency ?? null,
|
latency: s.latency ?? null,
|
||||||
sessions: 0,
|
sessions: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusConfig(status: RouterContainer["status"]) {
|
function statusConfig(status: ContainerView["status"]) {
|
||||||
return {
|
return {
|
||||||
|
unknown: { dot: "bg-muted-foreground", badge: "bg-muted text-muted-foreground border-border", label: "Неизвестно" },
|
||||||
running: {
|
running: {
|
||||||
dot: "bg-emerald-500 animate-pulse",
|
dot: "bg-emerald-500 animate-pulse",
|
||||||
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
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]
|
}[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 srv = serverById[c.serverId]
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
lines.push(`# RouterOS Container — ${c.name}`)
|
lines.push(`# RouterOS Container — ${c.name}`)
|
||||||
@@ -129,9 +134,9 @@ function ExportSheet({
|
|||||||
open, container, onClose, serverById,
|
open, container, onClose, serverById,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
container: RouterContainer | null
|
container: ContainerView | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
serverById: Record<string, Server>
|
serverById: Record<string, ContainerServer>
|
||||||
}) {
|
}) {
|
||||||
const code = useMemo(
|
const code = useMemo(
|
||||||
() => (container ? generateContainerRsc(container, serverById) : ""),
|
() => (container ? generateContainerRsc(container, serverById) : ""),
|
||||||
@@ -167,8 +172,8 @@ function ContainerCard({
|
|||||||
onRestart,
|
onRestart,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
container: RouterContainer
|
container: ContainerView
|
||||||
server?: Server
|
server?: ContainerServer
|
||||||
live: boolean
|
live: boolean
|
||||||
busy: boolean
|
busy: boolean
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
@@ -178,7 +183,7 @@ function ContainerCard({
|
|||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
}) {
|
}) {
|
||||||
const cfg = statusConfig(container.status)
|
const cfg = statusConfig(container.status)
|
||||||
const canMutate = live && Boolean(container.rosId)
|
const canMutate = live && Boolean(container.rosId) && !busy
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame dense className="w-full overflow-hidden">
|
<Frame dense className="w-full overflow-hidden">
|
||||||
@@ -193,7 +198,7 @@ function ContainerCard({
|
|||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger render={
|
<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" />
|
<MoreHorizontalIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
} />
|
} />
|
||||||
@@ -226,7 +231,7 @@ function ContainerCard({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
|
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
<span className="font-mono text-xs text-foreground/80">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -296,61 +301,65 @@ function ContainerCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ContainersPage() {
|
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 { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live"
|
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 [search, setSearch] = useState("")
|
||||||
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
|
const [statusFilter, setStatusFilter] = useState<ContainerView["status"] | "all">("all")
|
||||||
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null)
|
const [exportContainer, setExportContainer] = useState<ContainerView | null>(null)
|
||||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||||
|
const [state, setState] = useState<ContainersState | null>(null)
|
||||||
const [liveContainers, setLiveContainers] = useState<RouterContainer[]>([])
|
const [lastStates, setLastStates] = useState<Record<number, ContainerServerState>>({})
|
||||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
const [liveServers, setLiveServers] = useState<ContainerServer[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(isLive && (canRead || canReadServers))
|
||||||
const [busyId, setBusyId] = useState<string | null>(null)
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
const [liveError, setLiveError] = 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 () => {
|
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)
|
setLoading(true)
|
||||||
setLiveError(null)
|
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
||||||
try {
|
const tasks = await Promise.allSettled([
|
||||||
const [cRes, sRes] = await Promise.all([
|
canRead ? readContainersState(backendUrl, { signal }).then(result => {
|
||||||
requestJson<ContainersApiResponse>(backendUrl, "/api/containers"),
|
if (current()) {
|
||||||
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
|
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])))
|
||||||
setLiveContainers(cRes.containers ?? [])
|
}
|
||||||
setLiveServers(sRes.filter((s) => s.enabled).map(mapBackendServer))
|
}).catch(error => { if (current()) setLiveError(userApiError(error)); throw error }) : Promise.resolve(),
|
||||||
} catch (e) {
|
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(payload => {
|
||||||
setLiveError(e instanceof Error ? e.message : "Ошибка загрузки")
|
const result = serverListSchema.parse(payload)
|
||||||
setLiveContainers([])
|
if (new Set(result.map(server => server.id)).size !== result.length) throw new Error("Каталог серверов содержит дубли ID")
|
||||||
} finally {
|
if (current()) { setLiveServers(result.filter(server => server.enabled).map(mapBackendServer)); setServersReadAt(Date.now()); setServersError(null) }
|
||||||
setLoading(false)
|
}).catch(error => { if (current()) setServersError(userApiError(error)); throw error }) : Promise.resolve(),
|
||||||
}
|
])
|
||||||
}, [isLive, backendUrl])
|
if (current()) setLoading(false)
|
||||||
|
return current() && tasks.every(task => task.status === "fulfilled")
|
||||||
useEffect(() => {
|
}, [isLive, backendUrl, canRead, canReadServers])
|
||||||
if (!isLive) {
|
useEffect(() => { let cancelled = false; queueMicrotask(() => { if (!cancelled) void loadLive() }); return () => { cancelled = true; loadController.current?.abort() } }, [loadLive])
|
||||||
queueMicrotask(() => {
|
const displayContainers: ContainerView[] = useMemo(() => isLive ? Object.values(lastStates).flatMap(node => node.containers) : mockContainers, [isLive, lastStates])
|
||||||
setLiveContainers([])
|
const displayServers = isLive ? liveServers : mockServers.filter(server => server.enabled)
|
||||||
setLiveServers([])
|
const effectiveServerId = selectedServerId
|
||||||
setLiveError(null)
|
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")
|
||||||
return
|
const stale = isLive && !!liveError
|
||||||
}
|
const writeReady = !isLive || canWrite && !loading && !stale && !!serversReadAt && !serversError
|
||||||
queueMicrotask(() => {
|
const nodeFresh = (serverId: string) => !isLive || writeReady && state?.servers.some(node => String(node.serverId) === serverId && node.availability === "available" && displayServers.some(server => server.id === serverId))
|
||||||
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 scoped = useMemo(() => {
|
const scoped = useMemo(() => {
|
||||||
if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
|
if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
|
||||||
return displayContainers.filter((c) => c.serverId === effectiveServerId)
|
return displayContainers.filter((c) => c.serverId === effectiveServerId)
|
||||||
@@ -368,12 +377,12 @@ export default function ContainersPage() {
|
|||||||
host: s.host,
|
host: s.host,
|
||||||
site: s.site,
|
site: s.site,
|
||||||
country: s.country,
|
country: s.country,
|
||||||
status: s.status,
|
status: s.status ?? "unknown",
|
||||||
type: s.type,
|
type: s.type,
|
||||||
enabled: s.enabled,
|
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(() => {
|
const filtered = useMemo(() => {
|
||||||
return scoped.filter((c) => {
|
return scoped.filter((c) => {
|
||||||
@@ -392,8 +401,8 @@ export default function ContainersPage() {
|
|||||||
const stopped = scoped.filter((c) => c.status === "stopped").length
|
const stopped = scoped.filter((c) => c.status === "stopped").length
|
||||||
const errors = scoped.filter((c) => c.status === "error").length
|
const errors = scoped.filter((c) => c.status === "error").length
|
||||||
|
|
||||||
async function mutate(c: RouterContainer, action: "start" | "stop" | "restart" | "remove") {
|
async function mutate(c: ContainerView, action: "start" | "stop" | "restart" | "remove") {
|
||||||
if (!isLive || !c.rosId) {
|
if (!isLive || !c.rosId || !canWrite || !nodeFresh(c.serverId)) {
|
||||||
toast.info("Действие доступно только в live-режиме")
|
toast.info("Действие доступно только в live-режиме")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -433,12 +442,12 @@ export default function ContainersPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { void loadLive() }}
|
onClick={() => { void loadLive() }}
|
||||||
disabled={!isLive || loading}
|
disabled={!isLive || loading || !canRead && !canReadServers || !!busyId}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm">
|
<Button size="sm" disabled title="Создание контейнеров ещё не подключено">
|
||||||
<BoxIcon className="size-4" />Новый контейнер
|
<BoxIcon className="size-4" />Новый контейнер
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -448,17 +457,15 @@ export default function ContainersPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{isLive && liveError && (
|
{isLive && !canRead && <p role="status">Для каталога контейнеров требуется mm:dashboard:read.</p>}
|
||||||
<Alert variant="warning" className="py-2">
|
{isLive && !canReadServers && <p role="status">Для каталога серверов требуется mm:servers:read. Команды недоступны.</p>}
|
||||||
<AlertCircleIcon />
|
{isLive && loading && !state && <p role="status">Загрузка контейнеров…</p>}
|
||||||
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
{isLive && readAt && <p role="status" className="text-xs text-muted-foreground">Каталог контейнеров получен: {new Date(readAt).toLocaleTimeString("ru-RU")}</p>}
|
||||||
</Alert>
|
{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 && !loading && displayContainers.length === 0 && !liveError && (
|
{isLive && serversError && <Alert variant="warning" role="alert" aria-label="Серверы контейнеров"><AlertDescription>{serversReadAt ? "Последний каталог сохранён." : "Каталог серверов пока неизвестен."} {serversError}</AlertDescription></Alert>}
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
{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)}
|
||||||
Контейнеры не найдены. Нужен пакет container (RouterOS 7.4+).
|
{isLive && selectedServerId !== ALL_SERVERS_ID && !displayServers.some(server => server.id === selectedServerId) && <p role="alert">Выбранный сервер недоступен в каталоге. Выберите другое устройство; фильтр сохранён.</p>}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mode === "mock" && (
|
{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">
|
<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",
|
id: "all",
|
||||||
label: "Всего",
|
label: "Всего",
|
||||||
value: scoped.length,
|
value: known ? scoped.length : "—",
|
||||||
icon: <BoxIcon className="size-4" />,
|
icon: <BoxIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "running",
|
id: "running",
|
||||||
label: "Running",
|
label: "Running",
|
||||||
value: running,
|
value: known ? running : "—",
|
||||||
icon: <PlayIcon className="size-4" />,
|
icon: <PlayIcon className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "stopped",
|
id: "stopped",
|
||||||
label: "Stopped",
|
label: "Stopped",
|
||||||
value: stopped,
|
value: known ? stopped : "—",
|
||||||
icon: <StopCircleIcon className="size-4" />,
|
icon: <StopCircleIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "errors",
|
id: "errors",
|
||||||
label: "Ошибок",
|
label: "Ошибок",
|
||||||
value: errors,
|
value: known ? errors : "—",
|
||||||
icon: <AlertCircleIcon className="size-4" />,
|
icon: <AlertCircleIcon className="size-4" />,
|
||||||
iconClassName: "text-destructive",
|
iconClassName: "text-destructive",
|
||||||
variant: errors > 0 ? "destructive" : "default",
|
variant: errors > 0 ? "destructive" : "default",
|
||||||
@@ -537,23 +544,23 @@ export default function ContainersPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</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">
|
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||||
<BoxIcon className="size-10 mb-3 opacity-20" />
|
<BoxIcon className="size-10 mb-3 opacity-20" />
|
||||||
<p className="text-sm font-medium">Контейнеры не найдены</p>
|
<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>
|
||||||
) : (
|
)) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{filtered.map((c) => (
|
{filtered.map((c) => (
|
||||||
<ContainerCard
|
<ContainerCard
|
||||||
key={c.id}
|
key={c.id}
|
||||||
container={c}
|
container={c}
|
||||||
server={serverById[c.serverId]}
|
server={serverById[c.serverId]}
|
||||||
live={isLive}
|
live={isLive && !!nodeFresh(c.serverId)}
|
||||||
busy={busyId === c.id}
|
busy={busyId === c.id}
|
||||||
onExport={() => setExportContainer(c)}
|
onExport={() => setExportContainer(c)}
|
||||||
onStart={() => { void mutate(c, "start") }}
|
onStart={() => { void mutate(c, "start") }}
|
||||||
|
|||||||
+10
-1
@@ -27,7 +27,7 @@
|
|||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.9; пользователи приняты локально, state API контейнеров подготовлен |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.10; пользователи приняты локально, состояния контейнеров подключены |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 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)
|
## Блок 11А.9 — достоверный backend каталог контейнеров (2026-10-01)
|
||||||
|
|
||||||
- Добавлен GET /api/containers/state с общим collectedAt и состояниями каждого enabled сервера. available + [] означает подтверждённую пустую загрузку, unsupported — отсутствие /container, error — отказ чтения. Сбой одного устройства сохраняет остальные.
|
- Добавлен 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; реальные чтения ещё требуют внешней приёмки.
|
- Backend build, профильный lint, новые service/HTTP tests и старый mapper test прошли. test:containers включён в общий test. Тесты без PostgreSQL/RouterOS; реальные чтения ещё требуют внешней приёмки.
|
||||||
- Далее 11А.10: frontend state reader, независимые серверы/контейнеры, последний снимок, unknown KPI, свежесть и защита смены источника. Затем подтверждённые команды, доступность/ПК/телефон.
|
- Далее 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({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
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,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
retries: 0,
|
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