"use client" 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" 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 { 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 { 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 & { 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-emerald-500 animate-pulse", badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20", label: "Running", }, stopped: { dot: "bg-muted-foreground", badge: "bg-muted/50 text-muted-foreground border-border", label: "Stopped", }, error: { dot: "bg-red-500 animate-pulse", badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", label: "Error", }, }[status] } function generateContainerRsc(c: ContainerView, serverById: Record): string { const srv = serverById[c.serverId] const lines: string[] = [] lines.push(`# RouterOS Container — ${c.name}`) if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`) lines.push(`# Образ: ${c.image}:${c.tag}`) lines.push(`# RouterOS 7.4+ · /container`) lines.push(``) for (const iface of c.interfaces) { lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`) } lines.push(``) if (c.envs.length > 0) { lines.push(`/container/envs/add name=${c.name}-envs \\`) for (const { key, value } of c.envs) { lines.push(` ${key}="${value}" \\`) } lines.push(``) } for (const m of c.mounts) { lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`) if (m.src) lines.push(` src=${m.src} \\`) lines.push(` dst=${m.dst}`) lines.push(``) } lines.push(`/container/add \\`) lines.push(` remote-image=${c.image}:${c.tag} \\`) lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`) if (c.envs.length > 0) lines.push(` envlist=${c.name}-envs \\`) if (c.mounts.length > 0) lines.push(` mounts=${c.mounts.map((m) => `${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)}`).join(",")} \\`) if (c.cmd) lines.push(` cmd="${c.cmd}" \\`) if (c.startOnBoot) lines.push(` start-on-boot=yes \\`) if (c.comment) lines.push(` comment="${c.comment}" \\`) lines.push(` logging=yes`) return lines.join("\n") } function ExportSheet({ open, container, onClose, serverById, }: { open: boolean container: ContainerView | null onClose: () => void serverById: Record }) { const code = useMemo( () => (container ? generateContainerRsc(container, serverById) : ""), [container, serverById], ) return ( ) } 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}
} /> {container.status === "running" ? ( Остановить ) : ( Запустить )} Логи Редактировать Экспорт .rsc Перезапустить Удалить
{container.image || "Образ неизвестен"}{container.tag && <>:{container.tag}}
{server && (
{server.name}
)} {container.status === "running" && (container.uptime || container.cpu !== undefined || container.memMb !== undefined) && (
{container.uptime && (
{container.uptime}
)} {container.cpu !== undefined && (
CPU 50 ? "text-amber-500" : "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() { 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 [search, setSearch] = useState("") const [statusFilter, setStatusFilter] = useState("all") const [exportContainer, setExportContainer] = useState(null) const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) 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 [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)]) 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(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) }, [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 async function mutate(c: ContainerView, action: "start" | "stop" | "restart" | "remove") { if (!isLive || !c.rosId || !canWrite || !nodeFresh(c.serverId)) { toast.info("Действие доступно только в live-режиме") return } if (action === "remove" && !window.confirm(`Удалить контейнер ${c.name}?`)) return setBusyId(c.id) try { await requestJson(backendUrl, `/api/servers/${c.serverId}/containers/${action}`, { method: "POST", body: JSON.stringify({ rosId: c.rosId }), }) const labels = { start: "запущен", stop: "остановлен", restart: "перезапущен", remove: "удалён" } toast.success(`${c.name}: ${labels[action]}`) await loadLive() } catch (e) { toast.error(e instanceof Error ? e.message : "Ошибка RouterOS") } finally { setBusyId(null) } } return ( <> } /> } >
{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" && ( Моковые данные )} , iconClassName: "text-muted-foreground", }, { id: "running", label: "Running", value: known ? running : "—", icon: , iconClassName: "text-success", }, { id: "stopped", label: "Stopped", value: known ? stopped : "—", icon: , iconClassName: "text-muted-foreground", }, { id: "errors", label: "Ошибок", value: known ? errors : "—", icon: , iconClassName: "text-destructive", variant: errors > 0 ? "destructive" : "default", }, ]} />

Контейнеры доступны с RouterOS 7.4+

Поддерживаются Docker-совместимые образы. Требуется установка пакета container. Интерфейсы veth создаются автоматически.

setSearch(e.target.value)} />
{(["all", "running", "stopped", "error"] as const).map((s) => ( ))}
{known ? filtered.length : "—"} контейнеров
{filtered.length === 0 ? (!known ?

Список контейнеров пока неизвестен.

: (

Контейнеры не найдены

{scoped.length ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}

)) : (
{filtered.map((c) => ( setExportContainer(c)} onStart={() => { void mutate(c, "start") }} onStop={() => { void mutate(c, "stop") }} onRestart={() => { void mutate(c, "restart") }} onRemove={() => { void mutate(c, "remove") }} /> ))}
)}
{[ { 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) => (

{b.title}

                      {b.lines.join("\n")}
                    
))}
setExportContainer(null)} serverById={serverById} /> ) }