fix(ui): сохранить достоверные состояния контейнеров

This commit is contained in:
Denozordec committed 2026-10-01 10:47:11 +07:00
1 parent 644ddb64b3
commit 860add8edf
9 files changed
+189 -93

No files matched your search

+98 -91
View File
@@ -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") }}