Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
455 lines
18 KiB
TypeScript
455 lines
18 KiB
TypeScript
"use client"
|
||
|
||
import { useMemo, useState } from "react"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { routerContainers, servers } from "@/lib/data"
|
||
import type { RouterContainer } 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 { cn } from "@/lib/utils"
|
||
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,
|
||
} from "lucide-react"
|
||
import {
|
||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||
SheetDescription, SheetFooter, SheetClose,
|
||
} from "@/components/ui/sheet"
|
||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||
|
||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function serverFor(id: string) {
|
||
return servers.find((s) => s.id === id)
|
||
}
|
||
|
||
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]
|
||
}
|
||
|
||
// ─── RSC generator ────────────────────────────────────────────────────────────
|
||
|
||
function generateContainerRsc(c: RouterContainer): string {
|
||
const srv = serverFor(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(``)
|
||
|
||
// interface
|
||
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(``)
|
||
|
||
// envs
|
||
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(``)
|
||
}
|
||
|
||
// mounts
|
||
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(``)
|
||
}
|
||
|
||
// container
|
||
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")
|
||
}
|
||
|
||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||
|
||
function ExportSheet({ open, container, onClose }: {
|
||
open: boolean; container: RouterContainer | null; onClose: () => void
|
||
}) {
|
||
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
|
||
|
||
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,
|
||
},
|
||
]}
|
||
/>
|
||
)
|
||
}
|
||
|
||
// ─── Container card ───────────────────────────────────────────────────────────
|
||
|
||
function ContainerCard({
|
||
container,
|
||
onExport,
|
||
}: {
|
||
container: RouterContainer
|
||
onExport: () => void
|
||
}) {
|
||
const srv = serverFor(container.serverId)
|
||
const cfg = statusConfig(container.status)
|
||
|
||
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">
|
||
<MoreHorizontalIcon className="size-4" />
|
||
</Button>
|
||
} />
|
||
<DropdownMenuContent side="bottom" align="end">
|
||
{container.status === "running" ? (
|
||
<DropdownMenuItem><StopCircleIcon className="size-4 text-amber-500" />Остановить</DropdownMenuItem>
|
||
) : (
|
||
<DropdownMenuItem><PlayIcon className="size-4 text-emerald-500" />Запустить</DropdownMenuItem>
|
||
)}
|
||
<DropdownMenuItem><TerminalIcon className="size-4" />Логи</DropdownMenuItem>
|
||
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem><PowerIcon className="size-4" />Перезапустить</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
|
||
<div className="px-4 py-3 flex flex-col gap-3">
|
||
{/* image */}
|
||
<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 */}
|
||
{srv && (
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<ServerIcon className="size-3.5 shrink-0" />
|
||
<Flag code={srv.country} size={12} />
|
||
<span className="font-mono">{srv.name}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* uptime + stats */}
|
||
{container.status === "running" && (
|
||
<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>
|
||
)}
|
||
|
||
{/* interfaces */}
|
||
{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>
|
||
)}
|
||
|
||
{/* mounts */}
|
||
{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 [search, setSearch] = useState("")
|
||
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
|
||
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null)
|
||
|
||
const filtered = useMemo(() => {
|
||
return routerContainers.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) ||
|
||
(serverFor(c.serverId)?.name.toLowerCase().includes(q) ?? false)
|
||
)
|
||
})
|
||
}, [search, statusFilter])
|
||
|
||
const running = routerContainers.filter((c) => c.status === "running").length
|
||
const stopped = routerContainers.filter((c) => c.status === "stopped").length
|
||
const errors = routerContainers.filter((c) => c.status === "error").length
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
|
||
actions={
|
||
<Button size="sm">
|
||
<BoxIcon className="size-4" />Новый контейнер
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
<div className="flex flex-col gap-5">
|
||
|
||
<KpiStatGrid
|
||
aria-label="Сводка контейнеров"
|
||
items={[
|
||
{
|
||
id: "all",
|
||
label: "Всего",
|
||
value: routerContainers.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",
|
||
},
|
||
]}
|
||
/>
|
||
|
||
{/* Info banner */}
|
||
<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>
|
||
|
||
{/* Toolbar */}
|
||
<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>
|
||
|
||
{/* Grid */}
|
||
{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}
|
||
onExport={() => setExportContainer(c)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* RouterOS reference */}
|
||
<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>
|
||
</div>
|
||
|
||
<ExportSheet
|
||
open={!!exportContainer}
|
||
container={exportContainer}
|
||
onClose={() => setExportContainer(null)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|