fix(ui): показывать достоверный снимок контейнера

This commit is contained in:
Denozordec committed 2026-10-01 11:02:31 +07:00
1 parent c419a46955
commit 3ceb8c038c
14 files changed
+117 -141

No files matched your search

+66 -131
View File
@@ -72,94 +72,33 @@ 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",
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: "Stopped",
label: "Остановлен",
},
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",
dot: "bg-destructive",
badge: "bg-destructive/10 text-destructive border-destructive/20",
label: "Ошибка",
},
}[status]
}
function generateContainerRsc(c: ContainerView, serverById: Record<string, ContainerServer>): 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<string, ContainerServer>
}) {
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,
},
]}
/>
)
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>}
formats={formats} />
}
function ContainerCard({
@@ -199,26 +138,26 @@ function ContainerCard({
</div>
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" aria-label={"Действия контейнера " + container.name} className="size-7 shrink-0" disabled={busy}>
<MoreHorizontalIcon className="size-4" />
<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 className="size-4 text-amber-500" />Остановить
<StopCircleIcon aria-hidden="true" className="size-4 text-warning" />Остановить
</DropdownMenuItem>
) : (
<DropdownMenuItem disabled={!canMutate} onClick={onStart}>
<PlayIcon className="size-4 text-emerald-500" />Запустить
<DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onStart}>
<PlayIcon aria-hidden="true" className="size-4 text-success" />Запустить
</DropdownMenuItem>
)}
<DropdownMenuItem disabled><TerminalIcon className="size-4" />Логи</DropdownMenuItem>
<DropdownMenuItem disabled><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</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} onClick={onRestart}>
<PowerIcon className="size-4" />Перезапустить
<DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onRestart}>
<PowerIcon aria-hidden="true" className="size-4" />Перезапустить
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={!canMutate} onClick={onRemove}>
@@ -230,17 +169,17 @@ function ContainerCard({
<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">
<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 className="size-3.5 shrink-0" />
<ServerIcon aria-hidden="true" className="size-3.5 shrink-0" />
<Flag code={server.country} size={12} />
<span className="font-mono">{server.name}</span>
<span className="font-mono truncate" title={server.name}>{server.name}</span>
</div>
)}
@@ -248,7 +187,7 @@ function ContainerCard({
<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" />
<ActivityIcon aria-hidden="true" className="size-3 text-success" />
<span>{container.uptime}</span>
</div>
)}
@@ -256,7 +195,7 @@ function ContainerCard({
<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 > 50 ? "text-warning" : "text-foreground")}>
{container.cpu}%
</span>
</div>
@@ -264,7 +203,7 @@ function ContainerCard({
{container.memMb !== undefined && (
<div>
<span className="text-muted-foreground">RAM </span>
<span className="font-mono font-medium">{container.memMb} МБ</span>
<span className="font-mono font-medium">{container.memMb} МиБ</span>
</div>
)}
</div>
@@ -293,7 +232,7 @@ function ContainerCard({
)}
{container.comment && (
<p className="text-xs text-muted-foreground border-t pt-2">{container.comment}</p>
<p className="text-xs text-muted-foreground border-t pt-2 [overflow-wrap:anywhere]">{container.comment}</p>
)}
</div>
</FramePanel>
@@ -314,7 +253,7 @@ function ContainersContent() {
const canWrite = can("mm:servers:write")
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<ContainerView["status"] | "all">("all")
const [exportContainer, setExportContainer] = useState<ContainerView | null>(null)
const [exportContainer, setExportContainer] = useState<ContainerExport | null>(null)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [state, setState] = useState<ContainersState | null>(null)
const [lastStates, setLastStates] = useState<Record<number, ContainerServerState>>({})
@@ -412,6 +351,7 @@ function ContainersContent() {
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
@@ -465,20 +405,20 @@ function ContainersContent() {
onClick={() => { void loadLive() }}
disabled={!isLive || loading || !canRead && !canReadServers || !!busyId}
>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
<RefreshCwIcon aria-hidden="true" className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
<Button size="sm" disabled title="Создание контейнеров ещё не подключено">
<BoxIcon className="size-4" />Новый контейнер
<BoxIcon aria-hidden="true" className="size-4" />Новый контейнер
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5">
<div className="flex flex-col min-w-0 gap-5 [overflow-wrap:anywhere]">
{operationNotice && <p role="status" className="text-sm">{operationNotice}</p>}
{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>}
@@ -502,67 +442,67 @@ function ContainersContent() {
id: "all",
label: "Всего",
value: known ? scoped.length : "—",
icon: <BoxIcon className="size-4" />,
icon: <BoxIcon aria-hidden="true" className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "running",
label: "Running",
value: known ? running : "—",
icon: <PlayIcon className="size-4" />,
label: "Работает",
value: statusKnown ? running : "—",
icon: <PlayIcon aria-hidden="true" className="size-4" />,
iconClassName: "text-success",
},
{
id: "stopped",
label: "Stopped",
value: known ? stopped : "—",
icon: <StopCircleIcon className="size-4" />,
label: "Остановлен",
value: statusKnown ? stopped : "—",
icon: <StopCircleIcon aria-hidden="true" className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "errors",
label: "Ошибок",
value: known ? errors : "—",
icon: <AlertCircleIcon className="size-4" />,
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-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 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-violet-600 dark:text-violet-400">Контейнеры доступны с RouterOS 7.4+</p>
<p className="font-medium text-info">Контейнеры доступны с 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 создаются автоматически.
Сеть 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" />
<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 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, образу, серверу…"
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 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) => (
<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}
onClick={() => setStatusFilter(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" ? "Running" : s === "stopped" ? "Stopped" : "Error"}
{s === "all" ? "Все" : s === "running" ? "Работает" : s === "stopped" ? "Остановлен" : s === "unknown" ? "Неизвестно" : "Ошибка"}
</button>
))}
</div>
@@ -572,7 +512,7 @@ function ContainersContent() {
{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 className="size-10 mb-3 opacity-20" />
<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>
@@ -585,7 +525,7 @@ function ContainersContent() {
server={serverById[c.serverId]}
live={isLive && !!nodeFresh(c.serverId)}
busy={!!busyId}
onExport={() => setExportContainer(c)}
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") }}
@@ -645,7 +585,7 @@ function ContainersContent() {
].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">
<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>
@@ -667,12 +607,7 @@ function ContainersContent() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>}
<ExportSheet
open={!!exportContainer}
container={exportContainer}
onClose={() => setExportContainer(null)}
serverById={serverById}
/>
<ExportSheet selection={exportContainer} onClose={() => setExportContainer(null)} />
</>
)
}