fix(ui): показывать достоверный снимок контейнера
This commit is contained in:
1 parent
c419a46955
commit
3ceb8c038c
14 files changed
+117
-141
No files matched your search
+66
-131
@@ -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)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user