639 lines
35 KiB
TypeScript
639 lines
35 KiB
TypeScript
"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<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
|
||
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 <CodeExportSheet open={!!selection} onClose={onClose} title="Снимок контейнера" description="Данные каталога; конфигурация устройства и резервная копия в снимок не входят."
|
||
className="data-[side=right]:w-full data-[side=right]:sm:max-w-xl min-w-0 [&_[data-slot=sheet-footer]]:flex-wrap"
|
||
beforeCode={<p className="text-xs text-muted-foreground">{selection?.source === "mock" ? "Демонстрационные данные." : "Чтение RouterOS: " + (selection?.collectedAt ? new Date(selection.collectedAt).toLocaleString("ru-RU") : "время неизвестно")}{selection?.failures.length ? " Настройки получены частично; ошибки включены в JSON." : ""}</p>}
|
||
returnFocus={() => Array.from(document.querySelectorAll<HTMLButtonElement>("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 (
|
||
<Frame dense className="w-full overflow-hidden">
|
||
<FramePanel className="p-0 overflow-hidden">
|
||
<div className="px-4 py-3 border-b flex items-center justify-between gap-2 bg-muted/10">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<span className={cn("size-2 rounded-full shrink-0", cfg.dot)} />
|
||
<span className="font-mono font-semibold text-sm truncate">{container.name}</span>
|
||
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded border shrink-0", cfg.badge)}>
|
||
{cfg.label}
|
||
</span>
|
||
</div>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger render={
|
||
<Button variant="ghost" size="icon" aria-label={"Действия контейнера " + container.name} className="size-11 sm:size-8 shrink-0" disabled={busy}>
|
||
<MoreHorizontalIcon aria-hidden="true" className="size-4" />
|
||
</Button>
|
||
} />
|
||
<DropdownMenuContent side="bottom" align="end">
|
||
{container.status === "running" ? (
|
||
<DropdownMenuItem disabled={!canMutate} onClick={onStop}>
|
||
<StopCircleIcon aria-hidden="true" className="size-4 text-warning" />Остановить
|
||
</DropdownMenuItem>
|
||
) : (
|
||
<DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onStart}>
|
||
<PlayIcon aria-hidden="true" className="size-4 text-success" />Запустить
|
||
</DropdownMenuItem>
|
||
)}
|
||
<DropdownMenuItem disabled><TerminalIcon aria-hidden="true" className="size-4" />Логи</DropdownMenuItem>
|
||
<DropdownMenuItem disabled><PencilIcon aria-hidden="true" className="size-4" />Редактировать</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={onExport}><CodeXmlIcon aria-hidden="true" className="size-4" />Снимок JSON</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onRestart}>
|
||
<PowerIcon aria-hidden="true" className="size-4" />Перезапустить
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem variant="destructive" disabled={!canMutate} onClick={onRemove}>
|
||
<Trash2Icon className="size-4" />Удалить
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
|
||
<div className="px-4 py-3 flex flex-col gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<BoxIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
||
<span className="font-mono text-xs text-foreground/80 min-w-0 [overflow-wrap:anywhere]">
|
||
{container.image || "Образ неизвестен"}{container.tag && <>:<span className="text-muted-foreground">{container.tag}</span></>}
|
||
</span>
|
||
</div>
|
||
|
||
{server && (
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<ServerIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
||
<Flag code={server.country} size={12} />
|
||
<span className="font-mono truncate" title={server.name}>{server.name}</span>
|
||
</div>
|
||
)}
|
||
|
||
{container.status === "running" && (container.uptime || container.cpu !== undefined || container.memMb !== undefined) && (
|
||
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
|
||
{container.uptime && (
|
||
<div className="flex items-center gap-1">
|
||
<ActivityIcon aria-hidden="true" className="size-3 text-success" />
|
||
<span>{container.uptime}</span>
|
||
</div>
|
||
)}
|
||
{container.cpu !== undefined && (
|
||
<div>
|
||
<span className="text-muted-foreground">CPU </span>
|
||
<span className={cn("font-mono font-medium",
|
||
container.cpu > 50 ? "text-warning" : "text-foreground")}>
|
||
{container.cpu}%
|
||
</span>
|
||
</div>
|
||
)}
|
||
{container.memMb !== undefined && (
|
||
<div>
|
||
<span className="text-muted-foreground">RAM </span>
|
||
<span className="font-mono font-medium">{container.memMb} МиБ</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{container.interfaces.length > 0 && (
|
||
<div className="flex flex-wrap gap-1">
|
||
{container.interfaces.map((i) => (
|
||
<span key={i} className="text-[10px] font-mono px-1.5 py-0.5 bg-muted border rounded text-muted-foreground">
|
||
{i}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{container.mounts.length > 0 && (
|
||
<div className="flex flex-col gap-1">
|
||
{container.mounts.map((m, idx) => (
|
||
<div key={idx} className="text-[11px] font-mono text-muted-foreground flex items-center gap-1">
|
||
<span className="text-muted-foreground/40">→</span>
|
||
<span>{m.dst}</span>
|
||
{m.src && <><span className="text-muted-foreground/40">←</span><span>{m.src}</span></>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{container.comment && (
|
||
<p className="text-xs text-muted-foreground border-t pt-2 [overflow-wrap:anywhere]">{container.comment}</p>
|
||
)}
|
||
</div>
|
||
</FramePanel>
|
||
</Frame>
|
||
)
|
||
}
|
||
|
||
export default function ContainersPage() { return <Suspense fallback={<p role="status">Загрузка контекста контейнеров…</p>}><ContainersSource /></Suspense> }
|
||
function ContainersSource() {
|
||
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 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<string, string | null>, 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<ContainerExport | null>(null)
|
||
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 [deleteTarget, setDeleteTarget] = useState<ContainerView | null>(null)
|
||
const deleteReturnLabel = useRef<string | null>(null)
|
||
useEffect(() => {
|
||
if (deleteTarget || busyId || !deleteReturnLabel.current) return
|
||
const frame = requestAnimationFrame(() => {
|
||
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === deleteReturnLabel.current)
|
||
;(target ?? Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(button => button.textContent?.trim() === "Обновить"))?.focus()
|
||
})
|
||
return () => cancelAnimationFrame(frame)
|
||
}, [deleteTarget, busyId])
|
||
const [operationError, setOperationError] = useState<string | null>(null)
|
||
const [operationNotice, setOperationNotice] = useState<string | null>(null)
|
||
const [commandNeedsRead, setCommandNeedsRead] = useState(false)
|
||
const mutationLock = useRef(false)
|
||
const mutationController = useRef<AbortController | null>(null)
|
||
const alive = useRef(true)
|
||
useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
|
||
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 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<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() && 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<ServerTileItem[]>(() => (
|
||
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")
|
||
|
||
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
|
||
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 (
|
||
<>
|
||
<ServerRailLayout
|
||
items={railItems}
|
||
selectedId={effectiveServerId}
|
||
onSelect={setSelectedServerId}
|
||
showAll
|
||
preserveSelection
|
||
allCount={displayServers.length}
|
||
loading={isLive && loading && displayServers.length === 0}
|
||
header={
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
|
||
actions={
|
||
<>
|
||
<ServerRailMobileButton />
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => { void loadLive() }}
|
||
disabled={!isLive || loading || !canRead && !canReadServers || !!busyId}
|
||
>
|
||
<RefreshCwIcon aria-hidden="true" className={cn("size-4", loading && "animate-spin")} />
|
||
Обновить
|
||
</Button>
|
||
<Button size="sm" disabled title="Создание контейнеров ещё не подключено">
|
||
<BoxIcon aria-hidden="true" className="size-4" />Новый контейнер
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
}
|
||
>
|
||
<div className="flex flex-col min-w-0 gap-5 [overflow-wrap:anywhere]">
|
||
|
||
{urlError && <Alert role="alert" aria-label="Контекст контейнеров" variant="warning"><AlertDescription>{urlError}<Button type="button" variant="outline" onClick={() => writeContext({server:null,status:null,q:null})}>Сбросить контекст</Button></AlertDescription></Alert>}
|
||
{operationNotice && <p role="status" className="text-sm [overflow-wrap:anywhere]">{operationNotice}</p>}
|
||
{operationError && !deleteTarget && <Alert role="alert" aria-label="Команда контейнера" variant="warning"><AlertDescription>{operationError}</AlertDescription></Alert>}
|
||
{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">
|
||
Моковые данные
|
||
</span>
|
||
)}
|
||
|
||
<KpiStatGrid
|
||
aria-label="Сводка контейнеров"
|
||
items={[
|
||
{
|
||
id: "all",
|
||
label: "Всего",
|
||
value: known ? scoped.length : "—",
|
||
icon: <BoxIcon aria-hidden="true" className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "running",
|
||
label: "Работает",
|
||
value: statusKnown ? running : "—",
|
||
icon: <PlayIcon aria-hidden="true" className="size-4" />,
|
||
iconClassName: "text-success",
|
||
},
|
||
{
|
||
id: "stopped",
|
||
label: "Остановлен",
|
||
value: statusKnown ? stopped : "—",
|
||
icon: <StopCircleIcon aria-hidden="true" className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "errors",
|
||
label: "Ошибок",
|
||
value: statusKnown ? errors : "—",
|
||
icon: <AlertCircleIcon aria-hidden="true" className="size-4" />,
|
||
iconClassName: "text-destructive",
|
||
variant: errors > 0 ? "destructive" : "default",
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<div className="flex items-start gap-3 rounded-lg bg-info/5 border border-info/20 px-4 py-3 text-sm">
|
||
<BoxIcon aria-hidden="true" className="size-5 text-info shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="font-medium text-foreground">Контейнеры доступны с RouterOS 7.4+</p>
|
||
<p className="text-muted-foreground text-xs mt-0.5">
|
||
Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>.
|
||
Сеть veth настраивается на устройстве. Создание контейнеров в интерфейсе пока недоступно.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-0 w-full sm:w-auto">
|
||
<SearchIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
||
<input
|
||
className="flex-1 min-w-0 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||
aria-label="Поиск контейнеров" placeholder="Поиск по имени, образу, серверу…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div role="group" aria-label="Статус контейнеров" className="flex flex-wrap items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||
{(["all", "running", "stopped", "error", "unknown"] as const).map((s) => (
|
||
<button key={s}
|
||
type="button" aria-pressed={statusFilter === s} onClick={() => setStatusFilter(s)}
|
||
className={cn(
|
||
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
|
||
statusFilter === s
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}>
|
||
{s === "all" ? "Все" : s === "running" ? "Работает" : s === "stopped" ? "Остановлен" : s === "unknown" ? "Неизвестно" : "Ошибка"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<span className="text-sm text-muted-foreground ml-auto">{known ? filtered.length : "—"} контейнеров</span>
|
||
</div>
|
||
|
||
{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 aria-hidden="true" className="size-10 mb-3 opacity-20" />
|
||
<p className="text-sm font-medium">Контейнеры не найдены</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 && !!nodeFresh(c.serverId)}
|
||
busy={!!busyId}
|
||
onExport={() => { const snapshot = lastStates[Number(c.serverId)]; setExportContainer({ container: c, collectedAt: snapshot?.collectedAt ?? null, failures: snapshot?.failures ?? [], source: isLive ? "live" : "mock" }) }}
|
||
onStart={() => { void mutate(c, "start") }}
|
||
onStop={() => { void mutate(c, "stop") }}
|
||
onRestart={() => { void mutate(c, "restart") }}
|
||
onRemove={() => { deleteReturnLabel.current = "Действия контейнера " + c.name; setOperationError(null); setDeleteTarget(c) }}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||
{[
|
||
{
|
||
title: "Установка пакета",
|
||
lines: [
|
||
"# Скачать пакет container:",
|
||
"# mikrotik.com → Software",
|
||
"",
|
||
"/system/package/install",
|
||
" container",
|
||
"",
|
||
"# Перезагрузить:",
|
||
"/system/reboot",
|
||
],
|
||
},
|
||
{
|
||
title: "Создать контейнер",
|
||
lines: [
|
||
"# veth интерфейс:",
|
||
"/interface/veth/add \\",
|
||
" name=veth-nginx \\",
|
||
" address=172.17.0.2/24 \\",
|
||
" gateway=172.17.0.1",
|
||
"",
|
||
"# Контейнер:",
|
||
"/container/add \\",
|
||
" remote-image=nginx:alpine \\",
|
||
" interface=veth-nginx",
|
||
],
|
||
},
|
||
{
|
||
title: "Управление",
|
||
lines: [
|
||
"# Список:",
|
||
"/container/print",
|
||
"",
|
||
"# Запуск:",
|
||
"/container/start 0",
|
||
"",
|
||
"# Остановка:",
|
||
"/container/stop 0",
|
||
"",
|
||
"# Логи:",
|
||
"/container/shell 0",
|
||
],
|
||
},
|
||
].map((b) => (
|
||
<div key={b.title}>
|
||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
||
<pre className="bg-muted rounded-md p-2.5 text-foreground text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
||
{b.lines.join("\n")}
|
||
</pre>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</OpsPanel>
|
||
|
||
</div>
|
||
</ServerRailLayout>
|
||
|
||
{deleteTarget && <AlertDialog open onOpenChange={open => { if (!open && !busyId) { setDeleteTarget(null); setOperationError(null) } }}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader><AlertDialogTitle>Удалить контейнер?</AlertDialogTitle><AlertDialogDescription>{deleteTarget.name} · сервер {serverById[deleteTarget.serverId]?.name ?? deleteTarget.serverId}. Будет отправлена команда удаления в RouterOS.</AlertDialogDescription></AlertDialogHeader>
|
||
{operationError && <p role="alert" aria-label="Удаление контейнера" className="text-sm text-destructive [overflow-wrap:anywhere]">{operationError}</p>}
|
||
<AlertDialogFooter>
|
||
{commandNeedsRead && <Button type="button" variant="outline" disabled={loading || !!busyId} onClick={() => { void loadLive() }}>Обновить каталог</Button>}
|
||
<AlertDialogCancel disabled={!!busyId}>Отмена</AlertDialogCancel>
|
||
<AlertDialogAction type="button" variant="destructive" disabled={!!busyId || !nodeFresh(deleteTarget.serverId) || !displayContainers.some(row => row.id === deleteTarget.id && row.rosId === deleteTarget.rosId)} aria-busy={!!busyId} onClick={() => { void mutate(deleteTarget, "remove") }}>{busyId ? "Удаление…" : "Удалить"}</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>}
|
||
<ExportSheet selection={exportContainer} onClose={() => setExportContainer(null)} />
|
||
</>
|
||
)
|
||
}
|