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)} />
</>
)
}
@@ -4,7 +4,7 @@ import { MikrotikError } from "./mikrotik.js"
import { containersStateSchema } from "@mmapp/contracts/containers"
const server = { id: 1, name: "Router" } as Parameters<typeof readContainerServerState>[0]
const row = { ".id": "*A", name: "nginx", "remote-image": "nginx:alpine", interface: "veth1", envlist: "e", mounts: "m", status: "running", "start-on-boot": "yes", cpu: "0", "memory-high": "0" }
const row = { ".id": "*A", name: "nginx", "remote-image": "nginx:alpine", interface: "veth1", envlist: "e", mounts: "m", status: "running", "start-on-boot": "yes", cpu: "0", "memory-current": "0", "memory-high": "52428800" }
const read = async (path: string): Promise<unknown> => path === "/container" ? [row] : path === "/container/envs" ? [{name:"e",key:"A",value:"B"}] : [{name:"m",src:"/disk",dst:"/data"}]
const good = await readContainerServerState(server, read)
assert.equal(good.availability, "available"); assert.equal(good.containers[0].status,"running")
@@ -32,3 +32,6 @@ for (const value of [
{...aggregate,servers:[{...unsupported,failures:[{resource:"envs",error:"x"}]}]},
]) assert.equal(containersStateSchema.safeParse(value).success,false)
console.log("Containers state: independent devices, malformed/duplicate IDs, unknown status/boot, known empty, missing package, partial dependencies, zeros and envelope validation; no DB/RouterOS")
const limitOnly=await readContainerServerState(server,async path=>path==="/container"?[{".id":"*C","memory-high":"52428800"}]:[]);assert.equal(limitOnly.containers[0].memMb,undefined)
const modern=await readContainerServerState(server,async path=>path==="/container"?[{...row,envlist:"e,e2",mounts:undefined,mountlists:"m"}]:path==="/container/envs"?[{list:"e",key:"A",value:"1"},{list:"e2",key:"B",value:"2"}]:[{list:"m",src:"/disk",dst:"/data"}]);assert.equal(modern.containers[0].envs.length,2);assert.equal(modern.containers[0].mounts.length,1)
+10 -6
View File
@@ -7,14 +7,14 @@ type ServerRow = Parameters<typeof mapContainerRow>[0]
type Reader = (path: string) => Promise<unknown>
const rawSchema = z.array(z.object({
".id": z.string().min(1), name: z.string().optional(), tag: z.string().optional(), "remote-image": z.string().optional(),
interface: z.string().optional(), envlist: z.string().optional(), mounts: z.string().optional(),
interface: z.string().optional(), envlist: z.string().optional(), mounts: z.string().optional(), mountlists: z.string().optional(),
cmd: z.string().optional(), "start-on-boot": z.string().optional(), comment: z.string().optional(),
status: z.string().optional(), "memory-high": z.string().optional(), cpu: z.string().optional(),
status: z.string().optional(), "memory-high": z.string().optional(), "memory-current": z.string().optional(), cpu: z.string().optional(),
})).superRefine((rows, ctx) => {
if (new Set(rows.map(r => r[".id"])).size !== rows.length) ctx.addIssue({ code: "custom", message: "Дубли RouterOS ID" })
})
const envSchema = z.array(z.object({ name: z.string().optional(), key: z.string().optional(), value: z.string().optional() }))
const mountSchema = z.array(z.object({ name: z.string().optional(), src: z.string().optional(), dst: z.string().optional() }))
const envSchema = z.array(z.object({ name: z.string().optional(), list: z.string().optional(), key: z.string().min(1), value: z.string() }).refine(row => !!(row.list || row.name), "Отсутствует список env"))
const mountSchema = z.array(z.object({ name: z.string().optional(), list: z.string().optional(), src: z.string().optional(), dst: z.string().min(1) }).refine(row => !!(row.list || row.name), "Отсутствует список mounts"))
const errorMessage = (error: unknown) => error instanceof z.ZodError ? "Некорректный ответ RouterOS" : (error instanceof Error ? error.message.slice(0, 500) : "Ошибка RouterOS")
function missingPackage(error: unknown) {
return error instanceof MikrotikError && (error.statusCode === 404 || /no such command|unknown command/i.test(error.body))
@@ -36,10 +36,14 @@ export async function readContainerServerState(server: ServerRow, read: Reader):
if (envs.status === "rejected") failures.push({ resource: "envs", error: errorMessage(envs.reason) })
if (mounts.status === "rejected") failures.push({ resource: "mounts", error: errorMessage(mounts.reason) })
const containers = raw.map((row, index) => {
const mapped = mapContainerRow(server, row, envs.status === "fulfilled" ? envs.value : [], mounts.status === "fulfilled" ? mounts.value : [], index)
const envRows = envs.status === "fulfilled" ? envs.value : []
const mountRows = mounts.status === "fulfilled" ? mounts.value : []
const mapped = mapContainerRow(server, { ...row, mounts: row.mountlists ?? row.mounts }, envRows.map(item => ({ ...item, name: item.list ?? item.name })), mountRows.map(item => ({ ...item, name: item.list ?? item.name })), index)
const envLists = new Set((row.envlist ?? "").split(",").map(name => name.trim()).filter(Boolean))
mapped.envs = envRows.filter(item => envLists.has(item.list ?? item.name ?? "")).map(item => ({ key: item.key, value: item.value }))
const status = (row.status ?? "").toLowerCase()
const boot = row["start-on-boot"]
const mem = Number.parseInt(row["memory-high"] ?? "", 10)
const mem = Number(row["memory-current"]?.trim() || NaN)
return { ...mapped, image: row["remote-image"] ? mapped.image : "", tag: row["remote-image"] ? mapped.tag : "",
status: status === "running" || status === "stopped" ? status : status === "error" || status === "failed" ? "error" : "unknown",
startOnBoot: boot === "true" || boot === "yes" ? true : boot === "false" || boot === "no" ? false : null,
+9 -1
View File
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.11; пользователи приняты локально, команды контейнеров проверены |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.12; пользователи приняты локально, UX/экспорт контейнеров проверены |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 11А.12 — достоверный снимок и интерфейс контейнеров (2026-10-01)
- Недостоверный генератор RSC (шаблонные IP veth, неподтверждённые настройки и некорректные env команды) заменён JSON-снимком каталога в существующем CodeExportSheet. Файл содержит наблюдаемые поля, collectedAt, source и partial failures; явно подписан как снимок, не конфигурация/backup.
- memory-high — лимит памяти по [MikroTik Container](https://help.mikrotik.com/docs/spaces/ROS/pages/84901929/Container), расход RAM теперь только memory-current (МиБ); отсутствие метрики не становится 0. Новые RouterOS list/mountlists и несколько envlist обрабатываются; неполные env/mount ответы становятся partial error.
- Семантические токены, русские статусы, aria имена/pressed, поиск/переносы и 44px меню на телефоне. Sheet на всю ширину, footer переносится на 320 px через props; vendor/theme и композиция Frame/ServerRail/KpiStatGrid не изменены. Неизвестный статус не предлагает запуск/перезапуск; KPI по статусам — —.
- 30 уникальных data/operations/layout E2E суммарно прошли (последний полный прогон 29/30, оставшийся narrow исправлен и все 4 layout повторно прошли); 8 context E2E, backend tests/build, TypeScript, профильный lint и production frontend прошли. Скачанный JSON проверен; 6 снимков сохранены и просмотрены.
- ReUI checklist проверен в границах адаптации. Далее 11А.13: URL-контекст и clipboard отказ/фокус, затем локальная приёмка контейнеров и аудит сертификатов. Общие ошибки Clipboard в CodeExportSheet требуют обработки; реальный RouterOS/БД ещё не проверен.
## Блок 11А.11 — подтверждённые команды контейнеров (2026-10-01)
- POST start/stop/restart/remove проходит через typed reader со строгим ok:true, проверкой serverId/rosId и 30s сроком. Single-flight ref блокирует все карточки; поздние ответы после ухода/смены backend не меняют UI.
+9
View File
@@ -135,3 +135,12 @@
- 26 data/operations E2E, 8 context E2E, TypeScript, профильный lint и production frontend прошли. Модель сетевых команд/авторизации backend не менялась; реальные операции RouterOS требуют внешней приёмки.
- Далее 11А.12: доступность, семантические токены, ПК/390/320 и достоверность экспорта/метрик. Аудит экспорта выявил шаблонные veth IP и команды, которые не являются экспортом реального устройства; это нельзя выдавать за резервную конфигурацию.
## Блок 11А.12 — достоверный снимок и интерфейс контейнеров (2026-10-01)
- Недостоверный генератор RSC (шаблонные IP veth, неподтверждённые настройки и некорректные env команды) заменён JSON-снимком каталога в существующем CodeExportSheet. Файл содержит наблюдаемые поля, collectedAt, source и partial failures; явно подписан как снимок, не конфигурация/backup.
- memory-high — лимит памяти по [MikroTik Container](https://help.mikrotik.com/docs/spaces/ROS/pages/84901929/Container), расход RAM теперь только memory-current (МиБ); отсутствие метрики не становится 0. Новые RouterOS list/mountlists и несколько envlist обрабатываются; неполные env/mount ответы становятся partial error.
- Семантические токены, русские статусы, aria имена/pressed, поиск/переносы и 44px меню на телефоне. Sheet на всю ширину, footer переносится на 320 px через props; vendor/theme и композиция Frame/ServerRail/KpiStatGrid не изменены. Неизвестный статус не предлагает запуск/перезапуск; KPI по статусам — —.
- 30 уникальных data/operations/layout E2E суммарно прошли (последний полный прогон 29/30, оставшийся narrow исправлен и все 4 layout повторно прошли); 8 context E2E, backend tests/build, TypeScript, профильный lint и production frontend прошли. Скачанный JSON проверен; 6 снимков сохранены и просмотрены.
- ReUI checklist проверен в границах адаптации. Далее 11А.13: URL-контекст и clipboard отказ/фокус, затем локальная приёмка контейнеров и аудит сертификатов. Общие ошибки Clipboard в CodeExportSheet требуют обработки; реальный RouterOS/БД ещё не проверен.
Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+1 -1
View File
@@ -25,7 +25,7 @@ test("Контейнеры: частичный отказ устройства
let fail=false;await page.route("**/api/containers/state",r=>r.fulfill({json:fail?state([],{availability:"error",failures:[{resource:"containers",error:"УСТРОЙСТВО ОТКАЗАЛО"}]}):state()}));await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();fail=true;await refresh(page).click();await expect(page.getByRole("alert",{name:"Контейнеры сервера 1",exact:true})).toContainText("Последние контейнеры сохранены");await expect(page.getByText(container.name,{exact:true})).toBeVisible();await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await expect(page.getByRole("menuitem",{name:"Остановить",exact:true})).toBeDisabled()
})
test("Контейнеры: неизвестный статус и partial зависимости видимы",async({page})=>{
await page.route("**/api/containers/state",r=>r.fulfill({json:state([{...container,status:"unknown",startOnBoot:null,image:"",tag:""}],{failures:[{resource:"envs",error:"ENVS ОТКАЗ"}]})}));await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();await expect(page.getByText("Неизвестно",{exact:true})).toBeVisible();await expect(page.getByRole("alert",{name:"Контейнеры сервера 1"})).toContainText("ENVS ОТКАЗ")
await page.route("**/api/containers/state",r=>r.fulfill({json:state([{...container,status:"unknown",startOnBoot:null,image:"",tag:""}],{failures:[{resource:"envs",error:"ENVS ОТКАЗ"}]})}));await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();await expect(page.getByText("Неизвестно",{exact:true}).first()).toBeVisible();await expect(page.getByRole("alert",{name:"Контейнеры сервера 1"})).toContainText("ENVS ОТКАЗ")
})
test("Контейнеры: медленный каталог серверов не задерживает контейнеры",async({page})=>{
let release!:()=>void;const gate=new Promise<void>(r=>release=r);await page.route("**/api/servers",async r=>{await gate;await r.fulfill({json:[server]}).catch(()=>{})});await page.goto("/containers");await expect(page.getByText(container.name,{exact:true})).toBeVisible();release();await expect(refresh(page)).toBeEnabled()
+17
View File
@@ -0,0 +1,17 @@
import {test,expect} from "@playwright/test"
import {container,server,state} from "./fixtures/containers"
for(const [name,width] of [["desktop",1440],["mobile",390],["narrow",320]] as const)test("Контейнеры: клавиатура и адаптивность "+name,async({page})=>{
await page.setViewportSize({width,height:900})
const long={...container,name:"container-with-a-very-long-name-for-narrow-screens",image:"registry.example.test/project/long-container-image",comment:"Длинный комментарий для проверки переноса текста в карточке"}
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:new URL(r.request().url()).pathname==="/api/servers"?[{...server,name:"Очень длинное имя сервера контейнеров"}]:state([long])}))
await page.goto("/containers");await expect(page.getByText(long.name,{exact:true})).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth+1)).toBe(true)
const search=page.getByRole("textbox",{name:"Поиск контейнеров"});await search.fill("нет-совпадений");await expect(page.getByText("Измените фильтр или поиск",{exact:true})).toBeVisible();await search.fill("")
const stopped=page.getByRole("group",{name:"Статус контейнеров"}).getByRole("button",{name:"Остановлен",exact:true});await stopped.focus();await page.keyboard.press("Enter");await expect(stopped).toHaveAttribute("aria-pressed","true");const all=page.getByRole("group",{name:"Статус контейнеров"}).getByRole("button",{name:"Все",exact:true});await all.click();await expect(all).toHaveAttribute("aria-pressed","true")
await page.screenshot({path:"docs/ux-ui/screens/containers-overview-"+name+".png",fullPage:true})
const menu=page.getByRole("button",{name:"Действия контейнера "+long.name});await menu.focus();await page.keyboard.press("Enter");await page.getByRole("menuitem",{name:"Снимок JSON",exact:true}).click();const dialog=page.getByRole("dialog");await expect(dialog).toContainText("конфигурация устройства и резервная копия");await expect.poll(async()=>{const bounds=await dialog.boundingBox();return !!bounds&&bounds.x>=0&&bounds.x+bounds.width<=width+1&&(width>=640||bounds.width>=width-2)}).toBe(true);expect(await dialog.evaluate(el=>el.scrollWidth<=el.clientWidth+1)).toBe(true)
const download=page.waitForEvent("download");await dialog.getByRole("button",{name:"Файл",exact:true}).click();const file=await download;expect(file.suggestedFilename()).toMatch(/\.json$/);const stream=await file.createReadStream();const chunks:Buffer[]=[];for await(const chunk of stream!)chunks.push(Buffer.from(chunk));const json=JSON.parse(Buffer.concat(chunks).toString("utf8"));expect(json.container.name).toBe(long.name);expect(json.collectedAt).toBe("2026-10-01T00:00:00Z");expect(json.source).toBe("live");expect(JSON.stringify(json)).not.toContain("172.17.0.2")
await page.screenshot({path:"docs/ux-ui/screens/containers-export-"+name+".png",fullPage:true});await dialog.getByRole("button",{name:"Закрыть",exact:true}).first().click();await expect(dialog).toHaveCount(0)
})
test("Контейнеры: неизвестный статус не предлагает запуск",async({page})=>{
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:new URL(r.request().url()).pathname==="/api/servers"?[server]:state([{...container,status:"unknown"}])}));await page.goto("/containers");await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await expect(page.getByRole("menuitem",{name:"Запустить",exact:true})).toBeDisabled();await expect(page.getByRole("menuitem",{name:"Перезапустить",exact:true})).toBeDisabled()
})
+1 -1
View File
@@ -16,7 +16,7 @@ test("Удаление контейнера: lock, Escape, один запрос
for(const [name,path] of [["Остановить","stop"],["Запустить","start"],["Перезапустить","restart"]] as const)test("Команда контейнера: подтверждение отдельно от состояния "+path,async({page})=>{
const initial={...container,status:path==="start"?"stopped":"running"}
await page.route("**/api/containers/state",r=>r.fulfill({json:state([initial])}));let payload:unknown
await page.route("**/api/servers/1/containers/"+path,r=>{payload=r.request().postDataJSON();return r.fulfill({json:{ok:true}})});await page.goto("/containers");await action(page,name);await expect(page.getByRole("status").filter({hasText:"API подтвердил команду"})).toBeVisible();expect(payload).toEqual({rosId:"*A"});await expect(page.getByText(path==="start"?"Stopped":"Running",{exact:true}).first()).toBeVisible()
await page.route("**/api/servers/1/containers/"+path,r=>{payload=r.request().postDataJSON();return r.fulfill({json:{ok:true}})});await page.goto("/containers");await action(page,name);await expect(page.getByRole("status").filter({hasText:"API подтвердил команду"})).toBeVisible();expect(payload).toEqual({rosId:"*A"});await expect(page.getByText(path==="start"?"Остановлен":"Работает",{exact:true}).first()).toBeVisible()
})
for(const bad of [{ok:false},{},{ok:"true"}])test("Команда контейнера: неверное подтверждение "+JSON.stringify(bad),async({page})=>{
await page.route("**/api/servers/1/containers/stop",r=>r.fulfill({json:bad}));await page.goto("/containers");await action(page,"Остановить");await expect(page.getByRole("alert",{name:"Команда контейнера"})).toContainText("Результат команды не подтверждён");await expect(page.getByRole("status").filter({hasText:"API подтвердил команду"})).toHaveCount(0);await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await expect(page.getByRole("menuitem",{name:"Остановить",exact:true})).toBeDisabled()