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") }}
|
||||
|
||||
Reference in new issue
Block a user