"use client" import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react" import { useSearchParams } from "next/navigation" import { readContainersUrl, patchContainersUrl, type ContainerStatusFilter } from "@/lib/containers-url-state" import { PageHeader } from "@/components/page-header" import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data" import type { RouterContainer, Server } from "@/lib/data" import { Flag } from "@/components/flag" import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { Button } from "@/components/ui/button" import { Alert, AlertDescription } from "@/components/ui/alert" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog" import { cn } from "@/lib/utils" import { toast } from "sonner" import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu" import { BoxIcon, PlayIcon, StopCircleIcon, SearchIcon, MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon, CodeXmlIcon, ActivityIcon, ServerIcon, TerminalIcon, AlertCircleIcon, RefreshCwIcon, } from "lucide-react" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { useDataSource } from "@/lib/data-source" import { ApiClientError, requestJson } from "@/shared/api/http-client" import { mutateContainer, type ContainerAction, readContainersState } from "@/shared/api/containers" import { serverListSchema } from "@mmapp/contracts/servers" import type { ContainerServerState, ContainersState } from "@mmapp/contracts/containers" import { can } from "@/lib/auth" import { userApiError } from "@/shared/api/users" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" type ContainerView = Omit & { status: RouterContainer["status"] | "unknown"; startOnBoot: boolean | null } type ContainerServer = Omit & {status: Server["status"] | null} interface BackendServer { id: number name: string host: string type?: Server["type"] site?: string country: string asn?: string enabled: boolean status?: Server["status"] | null latency?: number | null } function mapBackendServer(s: BackendServer): ContainerServer { return { id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—", site: s.site ?? "", country: s.country || "UN", asn: s.asn ?? "", type: s.type ?? "exit-node", enabled: s.enabled, status: s.status ?? null, latency: s.latency ?? null, sessions: 0, } } function statusConfig(status: ContainerView["status"]) { return { unknown: { dot: "bg-muted-foreground", badge: "bg-muted text-muted-foreground border-border", label: "Неизвестно" }, running: { dot: "bg-success", badge: "bg-success/10 text-success border-success/20", label: "Работает", }, stopped: { dot: "bg-muted-foreground", badge: "bg-muted/50 text-muted-foreground border-border", label: "Остановлен", }, error: { dot: "bg-destructive", badge: "bg-destructive/10 text-destructive border-destructive/20", label: "Ошибка", }, }[status] } type ContainerExport = { container: ContainerView; collectedAt: string | null; failures: ContainerServerState["failures"]; source: "live" | "mock" } function ExportSheet({ selection, onClose }: {selection: ContainerExport | null; onClose: () => void}) { const formats = useMemo(() => [{ id: "json", label: "Снимок JSON", filename: "container-" + (selection?.container.id ?? "snapshot").replace(/[^a-zA-Z0-9_-]/g, "_") + ".json", code: selection ? JSON.stringify(selection, null, 2) : "", }], [selection]) return {selection?.source === "mock" ? "Демонстрационные данные." : "Чтение RouterOS: " + (selection?.collectedAt ? new Date(selection.collectedAt).toLocaleString("ru-RU") : "время неизвестно")}{selection?.failures.length ? " Настройки получены частично; ошибки включены в JSON." : ""}

} returnFocus={() => Array.from(document.querySelectorAll("button[aria-label]")).find(button => button.getAttribute("aria-label") === "Действия контейнера " + selection?.container.name) ?? null} formats={formats} /> } function ContainerCard({ container, server, live, busy, onExport, onStart, onStop, onRestart, onRemove, }: { container: ContainerView server?: ContainerServer live: boolean busy: boolean onExport: () => void onStart: () => void onStop: () => void onRestart: () => void onRemove: () => void }) { const cfg = statusConfig(container.status) const canMutate = live && Boolean(container.rosId) && !busy return (
{container.name} {cfg.label}
{server && (
)} {container.status === "running" && (container.uptime || container.cpu !== undefined || container.memMb !== undefined) && (
{container.uptime && (
)} {container.cpu !== undefined && (
CPU 50 ? "text-warning" : "text-foreground")}> {container.cpu}%
)} {container.memMb !== undefined && (
RAM {container.memMb} МиБ
)}
)} {container.interfaces.length > 0 && (
{container.interfaces.map((i) => ( {i} ))}
)} {container.mounts.length > 0 && (
{container.mounts.map((m, idx) => (
→ {m.dst} {m.src && <>←{m.src}}
))}
)} {container.comment && (

{container.comment}

)}
) } export default function ContainersPage() { return Загрузка контекста контейнеров…

}>
} function ContainersSource() { const { mode, backendUrl, prefsHydrated } = useDataSource() if (!prefsHydrated) return

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

return } function ContainersContent() { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" const canRead = can("mm:dashboard:read") const canReadServers = can("mm:servers:read") const canWrite = can("mm:servers:write") const params = useSearchParams() const context = readContainersUrl(params) const search = context.search const statusFilter = context.status const selectedServerId = context.server const urlError = context.error function writeContext(changes: Record, replace = false) { const href = patchContainersUrl(new URLSearchParams(window.location.search), changes) + window.location.hash if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href) } const setSearch = (value: string) => writeContext({ q: value }, true) const setStatusFilter = (value: ContainerStatusFilter) => writeContext({ status: value === "all" ? null : value }) const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value }) const [exportContainer, setExportContainer] = useState(null) const [state, setState] = useState(null) const [lastStates, setLastStates] = useState>({}) const [liveServers, setLiveServers] = useState([]) const [loading, setLoading] = useState(isLive && (canRead || canReadServers)) const [busyId, setBusyId] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) const deleteReturnLabel = useRef(null) useEffect(() => { if (deleteTarget || busyId || !deleteReturnLabel.current) return const frame = requestAnimationFrame(() => { const target = Array.from(document.querySelectorAll("button[aria-label]")).find(button => button.getAttribute("aria-label") === deleteReturnLabel.current) ;(target ?? Array.from(document.querySelectorAll("button")).find(button => button.textContent?.trim() === "Обновить"))?.focus() }) return () => cancelAnimationFrame(frame) }, [deleteTarget, busyId]) const [operationError, setOperationError] = useState(null) const [operationNotice, setOperationNotice] = useState(null) const [commandNeedsRead, setCommandNeedsRead] = useState(false) const mutationLock = useRef(false) const mutationController = useRef(null) const alive = useRef(true) useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, []) const [liveError, setLiveError] = useState(null) const [serversError, setServersError] = useState(null) const [readAt, setReadAt] = useState(null) const [serversReadAt, setServersReadAt] = useState(null) const loadController = useRef(null) const loadRevision = useRef(0) const loadLive = useCallback(async () => { if (!isLive) return 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) const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) let stateComplete = !canRead const tasks = await Promise.allSettled([ canRead ? readContainersState(backendUrl, { signal }).then(result => { stateComplete = result.servers.every(node => node.availability !== "error") if (current()) { setState(result); setReadAt(Date.now()); setLiveError(null) if (result.servers.every(node => node.availability !== "error")) setCommandNeedsRead(false) setLastStates(previous => Object.fromEntries(result.servers.map(node => [node.serverId, node.availability === "error" ? previous[node.serverId] ?? node : node]))) } }).catch(error => { if (current()) setLiveError(userApiError(error)); throw error }) : Promise.resolve(), canReadServers ? requestJson(backendUrl, "/api/servers", { signal }).then(payload => { const result = serverListSchema.parse(payload) if (new Set(result.map(server => server.id)).size !== result.length) throw new Error("Каталог серверов содержит дубли ID") if (current()) { setLiveServers(result.filter(server => server.enabled).map(mapBackendServer)); setServersReadAt(Date.now()); setServersError(null) } }).catch(error => { if (current()) setServersError(userApiError(error)); throw error }) : Promise.resolve(), ]) if (current()) setLoading(false) return current() && stateComplete && 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 && !urlError && !commandNeedsRead && !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) }, [displayContainers, effectiveServerId]) const serverById = useMemo( () => Object.fromEntries(displayServers.map((s) => [s.id, s])), [displayServers], ) const railItems = useMemo(() => ( displayServers.map((s) => ({ id: s.id, name: s.name, host: s.host, site: s.site, country: s.country, status: s.status ?? "unknown", type: s.type, enabled: s.enabled, 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, state, isLive]) const filtered = useMemo(() => { return scoped.filter((c) => { if (statusFilter !== "all" && c.status !== statusFilter) return false if (!search) return true const q = search.toLowerCase() return ( c.name.toLowerCase().includes(q) || c.image.toLowerCase().includes(q) || (serverById[c.serverId]?.name.toLowerCase().includes(q) ?? false) ) }) }, [search, statusFilter, scoped, serverById]) const running = scoped.filter((c) => c.status === "running").length const stopped = scoped.filter((c) => c.status === "stopped").length const errors = scoped.filter((c) => c.status === "error").length const statusKnown = known && scoped.every(container => container.status !== "unknown") const currentDeleteTarget = deleteTarget ? displayContainers.find(row => row.id === deleteTarget.id && row.rosId === deleteTarget.rosId && row.serverId === deleteTarget.serverId) : undefined const deleteStopped = currentDeleteTarget?.status === "stopped" async function mutate(c: ContainerView, action: ContainerAction) { if (!isLive || !c.rosId || !canWrite || !nodeFresh(c.serverId) || mutationLock.current || !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId)) return if (action === "remove" && !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId && row.status === "stopped")) return mutationLock.current = true const controller = new AbortController() mutationController.current = controller const current = () => alive.current && !controller.signal.aborted setBusyId(c.id); setOperationError(null); setOperationNotice(null) try { await mutateContainer(backendUrl, c.serverId, c.rosId, action, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) }) if (!current()) return const labels = { start: "запуск", stop: "остановка", restart: "перезапуск", remove: "удаление" } const message = c.name + ": API подтвердил команду «" + labels[action] + "». Состояние проверяется повторным чтением." setOperationNotice(message) if (action === "remove") { setLastStates(previous => Object.fromEntries(Object.entries(previous).map(([id, node]) => [id, { ...node, containers: node.containers.filter(row => row.id !== c.id) }]))) setDeleteTarget(null) } toast.success("Команда подтверждена API.") const refreshed = await loadLive() if (current() && !refreshed) setOperationNotice(message + " Обновление каталогов не удалось; повторите чтение.") } catch (error) { if (!current()) return const uncertain = !(error instanceof ApiClientError && error.status >= 400 && error.status < 500) setCommandNeedsRead(uncertain) setOperationError(c.name + ": " + userApiError(error) + (uncertain ? " Результат команды не подтверждён; она могла выполниться частично. Обновите каталог перед новым действием." : " Команда отклонена API.")) } finally { if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false } if (alive.current) setBusyId(null) } } return ( <> } /> } >
{urlError && {urlError}} {operationNotice &&

{operationNotice}

} {operationError && !deleteTarget && {operationError}} {isLive && !canRead &&

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

} {isLive && !canReadServers &&

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

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

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

} {isLive && readAt &&

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

} {isLive && serversReadAt &&

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

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

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

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

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

} {mode === "mock" && ( Моковые данные )}
{deleteTarget && { if (!open && !busyId) { setDeleteTarget(null); setOperationError(null) } }}> Удалить контейнер?{deleteTarget.name} · сервер {serverById[deleteTarget.serverId]?.name ?? deleteTarget.serverId}. {deleteStopped ? "Будет удалён остановленный контейнер. Дополнительная очистка файлов не выполняется." : "Сначала закройте это окно, остановите контейнер через меню действий и обновите каталог. Удаление доступно только после подтверждённой остановки."} {operationError &&

{operationError}

} {(commandNeedsRead || !deleteStopped) && } Отмена { void mutate(deleteTarget, "remove") }}>{busyId ? "Удаление…" : "Удалить"}
} setExportContainer(null)} /> ) }