Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m40s
Docker images / frontend-image (push) Successful in 2m50s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Added support for fetching and displaying server data in BGP, VXLAN, and OSPF pages, improving the overall user experience. - Introduced new backend routes for OSPF and VXLAN, allowing for better data management and retrieval. - Implemented mapping functions for backend server data to frontend types, ensuring consistency across components. - Enhanced the sidebar to display counts for BGP sessions, VXLAN tunnels, and containers, providing users with quick insights into their network status. - Updated tests to cover new functionalities and ensure reliability. Co-authored-by: Cursor <[email protected]>
638 lines
23 KiB
TypeScript
638 lines
23 KiB
TypeScript
"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, Server>): 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<string, Server>
|
||
}) {
|
||
const code = useMemo(
|
||
() => (container ? generateContainerRsc(container, serverById) : ""),
|
||
[container, serverById],
|
||
)
|
||
|
||
return (
|
||
<CodeExportSheet
|
||
open={open}
|
||
onClose={onClose}
|
||
title="Экспорт Container"
|
||
description="RouterOS 7.4+ · /container · /interface/veth"
|
||
formats={[
|
||
{
|
||
id: "rsc",
|
||
label: "MikroTik .rsc",
|
||
filename: `${container?.name ?? "container"}.rsc`,
|
||
code,
|
||
},
|
||
]}
|
||
/>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<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" className="size-7 shrink-0" disabled={busy}>
|
||
<MoreHorizontalIcon className="size-4" />
|
||
</Button>
|
||
} />
|
||
<DropdownMenuContent side="bottom" align="end">
|
||
{container.status === "running" ? (
|
||
<DropdownMenuItem disabled={!canMutate} onClick={onStop}>
|
||
<StopCircleIcon className="size-4 text-amber-500" />Остановить
|
||
</DropdownMenuItem>
|
||
) : (
|
||
<DropdownMenuItem disabled={!canMutate} onClick={onStart}>
|
||
<PlayIcon className="size-4 text-emerald-500" />Запустить
|
||
</DropdownMenuItem>
|
||
)}
|
||
<DropdownMenuItem disabled><TerminalIcon className="size-4" />Логи</DropdownMenuItem>
|
||
<DropdownMenuItem disabled><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem disabled={!canMutate} onClick={onRestart}>
|
||
<PowerIcon 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 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>
|
||
</span>
|
||
</div>
|
||
|
||
{server && (
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<ServerIcon className="size-3.5 shrink-0" />
|
||
<Flag code={server.country} size={12} />
|
||
<span className="font-mono">{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 className="size-3 text-emerald-500" />
|
||
<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-amber-500" : "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">{container.comment}</p>
|
||
)}
|
||
</div>
|
||
</FramePanel>
|
||
</Frame>
|
||
)
|
||
}
|
||
|
||
export default function ContainersPage() {
|
||
const { mode, backendUrl } = useDataSource()
|
||
const isLive = mode === "live"
|
||
|
||
const [search, setSearch] = useState("")
|
||
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
|
||
const [exportContainer, setExportContainer] = useState<RouterContainer | 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 [busyId, setBusyId] = useState<string | null>(null)
|
||
const [liveError, setLiveError] = useState<string | null>(null)
|
||
|
||
const loadLive = useCallback(async () => {
|
||
if (!isLive) return
|
||
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 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,
|
||
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 (
|
||
<>
|
||
<ServerRailLayout
|
||
items={railItems}
|
||
selectedId={effectiveServerId}
|
||
onSelect={setSelectedServerId}
|
||
showAll
|
||
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}
|
||
>
|
||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||
Обновить
|
||
</Button>
|
||
<Button size="sm">
|
||
<BoxIcon className="size-4" />Новый контейнер
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
}
|
||
>
|
||
<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>
|
||
)}
|
||
{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: scoped.length,
|
||
icon: <BoxIcon className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "running",
|
||
label: "Running",
|
||
value: running,
|
||
icon: <PlayIcon className="size-4" />,
|
||
iconClassName: "text-success",
|
||
},
|
||
{
|
||
id: "stopped",
|
||
label: "Stopped",
|
||
value: stopped,
|
||
icon: <StopCircleIcon className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "errors",
|
||
label: "Ошибок",
|
||
value: errors,
|
||
icon: <AlertCircleIcon className="size-4" />,
|
||
iconClassName: "text-destructive",
|
||
variant: errors > 0 ? "destructive" : "default",
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
|
||
<BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="font-medium text-violet-600 dark:text-violet-400">Контейнеры доступны с 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-[240px]">
|
||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||
<input
|
||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||
placeholder="Поиск по имени, образу, серверу…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||
{(["all", "running", "stopped", "error"] as const).map((s) => (
|
||
<button key={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" ? "Running" : s === "stopped" ? "Stopped" : "Error"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span>
|
||
</div>
|
||
|
||
{filtered.length === 0 ? (
|
||
<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>
|
||
</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}
|
||
busy={busyId === c.id}
|
||
onExport={() => setExportContainer(c)}
|
||
onStart={() => { void mutate(c, "start") }}
|
||
onStop={() => { void mutate(c, "stop") }}
|
||
onRestart={() => { void mutate(c, "restart") }}
|
||
onRemove={() => { void mutate(c, "remove") }}
|
||
/>
|
||
))}
|
||
</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-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
||
{b.lines.join("\n")}
|
||
</pre>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</OpsPanel>
|
||
|
||
</div>
|
||
</ServerRailLayout>
|
||
|
||
<ExportSheet
|
||
open={!!exportContainer}
|
||
container={exportContainer}
|
||
onClose={() => setExportContainer(null)}
|
||
serverById={serverById}
|
||
/>
|
||
</>
|
||
)
|
||
}
|