"use client" import { useCallback, useEffect, useMemo, 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 { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" interface BackendServer { id: number name: string host: string type?: Server["type"] site?: string country: string asn?: string enabled: boolean status?: Server["status"] latency?: number | null } interface ContainersApiResponse { containers: RouterContainer[] } function mapBackendServer(s: BackendServer): Server { 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 ?? "online", latency: s.latency ?? null, sessions: 0, } } function statusConfig(status: RouterContainer["status"]) { return { 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: RouterContainer, 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: RouterContainer | 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: RouterContainer server?: Server 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) return (
{container.name} {cfg.label}
} /> {container.status === "running" ? ( Остановить ) : ( Запустить )} Логи Редактировать Экспорт .rsc Перезапустить Удалить
{container.image}:{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 } = useDataSource() const isLive = mode === "live" const [search, setSearch] = useState("") const [statusFilter, setStatusFilter] = useState("all") const [exportContainer, setExportContainer] = useState(null) const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) const [liveContainers, setLiveContainers] = useState([]) const [liveServers, setLiveServers] = useState([]) const [loading, setLoading] = useState(false) const [busyId, setBusyId] = useState(null) const [liveError, setLiveError] = useState(null) const loadLive = useCallback(async () => { if (!isLive) return setLoading(true) setLiveError(null) try { const [cRes, sRes] = await Promise.all([ requestJson(backendUrl, "/api/containers"), requestJson(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 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, type: s.type, enabled: s.enabled, meta: String(displayContainers.filter((c) => c.serverId === s.id).length), })) ), [displayServers, displayContainers]) 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: RouterContainer, action: "start" | "stop" | "restart" | "remove") { if (!isLive || !c.rosId) { 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 && liveError && ( Ошибка загрузки: {liveError} )} {isLive && !loading && displayContainers.length === 0 && !liveError && (
Контейнеры не найдены. Нужен пакет container (RouterOS 7.4+).
)} {mode === "mock" && ( Моковые данные )} , iconClassName: "text-muted-foreground", }, { id: "running", label: "Running", value: running, icon: , iconClassName: "text-success", }, { id: "stopped", label: "Stopped", value: stopped, icon: , iconClassName: "text-muted-foreground", }, { id: "errors", label: "Ошибок", value: 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) => ( ))}
{filtered.length} контейнеров
{filtered.length === 0 ? (

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

Добавьте первый контейнер или измените фильтр

) : (
{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} /> ) }