Deploy console: real progress bar for image pulls (0.39.0)
Compose is now run with `--progress json` (probed once, falls back to the plain text stream on older compose/agents). The console folds the event stream into a weighted progress bar — download bytes per layer, then container create/start — with a per-image bar and a byte/layer counter, and renders the raw output one line per layer (updated in place) instead of a wall of scrolling text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,10 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
||||
output (image pulls, container creation) over a WebSocket in real time instead
|
||||
of a blind spinner; the deploy keeps running server-side if the modal is closed.
|
||||
Works for **remote** stacks too — the central app proxies the agent's deploy
|
||||
stream through to the browser.
|
||||
stream through to the browser. Compose runs with `--progress json`, so the
|
||||
console shows a **real progress bar** (download bytes per layer, weighted by
|
||||
layer size, then container create/start) plus a per-image bar; the raw output
|
||||
is kept below it and updates one line per layer instead of scrolling past.
|
||||
- **Monaco editor** — YAML editing with an `.env` tab and a **`docker run` →
|
||||
compose** converter.
|
||||
- **Dashboard** — system resource bar, stack grid with quick actions, and a
|
||||
|
||||
@@ -361,13 +361,43 @@ async def stream_compose(
|
||||
await proc.wait()
|
||||
|
||||
|
||||
_json_progress: Optional[bool] = None
|
||||
|
||||
|
||||
async def supports_json_progress() -> bool:
|
||||
"""Whether this Docker Compose understands ``--progress json``.
|
||||
|
||||
The JSON progress stream carries per-layer ``current``/``total`` bytes, which
|
||||
the deploy console turns into a real progress bar. Older compose releases
|
||||
reject the value, so probe once (cheap, no side effects) and cache it; on a
|
||||
negative result callers fall back to the plain text stream.
|
||||
"""
|
||||
global _json_progress
|
||||
if _json_progress is None:
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"docker", "compose", "--progress", "json", "version",
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
rc = await asyncio.wait_for(proc.wait(), timeout=15.0)
|
||||
_json_progress = rc == 0
|
||||
except Exception: # noqa: BLE001 - probe failure just disables the feature
|
||||
_json_progress = False
|
||||
return _json_progress
|
||||
|
||||
|
||||
async def stream_up(stack_id: str, override: Optional[str] = None):
|
||||
"""Run `compose up -d` streaming combined output, so the deploy console can
|
||||
show image-pull and container-create progress live.
|
||||
|
||||
Yields ``("log", line)`` for each output line, then ``("done", returncode)``.
|
||||
"""
|
||||
cmd = _compose_base_cmd(stack_id, override) + ["up", "-d", "--remove-orphans"]
|
||||
cmd = _compose_base_cmd(stack_id, override)
|
||||
if await supports_json_progress():
|
||||
# Global flag, must precede the subcommand.
|
||||
cmd += ["--progress", "json"]
|
||||
cmd += ["up", "-d", "--remove-orphans"]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the StackPilot release version."""
|
||||
|
||||
APP_VERSION = "0.38.4"
|
||||
APP_VERSION = "0.39.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.38.3",
|
||||
"version": "0.39.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CheckCircle2, Loader2, XCircle } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CheckCircle2, ChevronDown, ChevronRight, Loader2, XCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { DeployTracker, parseDeployLine, type DeployStats } from "@/lib/deployProgress";
|
||||
|
||||
const MAX_LINES = 2000;
|
||||
/** Compose emits status changes far faster than the UI needs to repaint. */
|
||||
const FLUSH_MS = 150;
|
||||
|
||||
type Phase = "running" | "success" | "failed" | "error";
|
||||
type LogLine = { id: string; text: string; tone: "plain" | "error" };
|
||||
|
||||
const EMPTY_STATS: DeployStats = {
|
||||
pct: 0,
|
||||
indeterminate: true,
|
||||
phase: "preparing",
|
||||
label: "Preparing…",
|
||||
detail: "",
|
||||
images: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Modal that runs `compose up -d` over the `/ws/deploy/{id}` WebSocket and
|
||||
* streams its output (image pulls, container creation) live, so the user sees
|
||||
* deploy progress instead of a blind spinner. The compose subprocess keeps
|
||||
* running on the server even if this modal is closed early.
|
||||
* Modal that runs `compose up -d` over the `/ws/deploy/{id}` WebSocket. The
|
||||
* stream is folded into a progress bar (image pulls weighted by download size,
|
||||
* then container create/start) with the raw output kept below it, so the deploy
|
||||
* is readable at a glance instead of a wall of scrolling text. The compose
|
||||
* subprocess keeps running on the server even if this modal is closed early.
|
||||
*/
|
||||
export function DeployConsole({
|
||||
stackId,
|
||||
@@ -22,14 +36,58 @@ export function DeployConsole({
|
||||
agentId?: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [lines, setLines] = useState<LogLine[]>([]);
|
||||
const [stats, setStats] = useState<DeployStats>(EMPTY_STATS);
|
||||
const [phase, setPhase] = useState<Phase>("running");
|
||||
const [errorDetail, setErrorDetail] = useState<string | null>(null);
|
||||
const [showLog, setShowLog] = useState(true);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const token = useAuthStore((s) => s.accessToken);
|
||||
|
||||
const tracker = useMemo(() => new DeployTracker(), []);
|
||||
const bufferRef = useRef<LogLine[]>([]);
|
||||
const indexRef = useRef(new Map<string, number>());
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
|
||||
// One line per layer/container, updated in place — the same way compose
|
||||
// renders on a TTY — so status changes replace their line instead of
|
||||
// rattling past. Lines without an id (plain text, errors) just append.
|
||||
const push = (id: string, text: string, tone: "plain" | "error") => {
|
||||
const buf = bufferRef.current;
|
||||
const known = id ? indexRef.current.get(id) : undefined;
|
||||
if (known !== undefined) {
|
||||
buf[known] = { id, text, tone };
|
||||
} else {
|
||||
buf.push({ id, text, tone });
|
||||
if (id) indexRef.current.set(id, buf.length - 1);
|
||||
}
|
||||
if (buf.length > MAX_LINES) {
|
||||
bufferRef.current = buf.slice(-MAX_LINES);
|
||||
const rebuilt = new Map<string, number>();
|
||||
bufferRef.current.forEach((l, i) => {
|
||||
if (l.id) rebuilt.set(l.id, i);
|
||||
});
|
||||
indexRef.current = rebuilt;
|
||||
}
|
||||
dirtyRef.current = true;
|
||||
};
|
||||
|
||||
const handleLine = (line: string) => {
|
||||
const parsed = parseDeployLine(line);
|
||||
if (!parsed) return;
|
||||
if (parsed.kind === "text") {
|
||||
push("", parsed.text, parsed.tone);
|
||||
return;
|
||||
}
|
||||
const ev = parsed.event;
|
||||
tracker.apply(ev);
|
||||
const details = ev.details && ev.details !== "0B" ? ` ${ev.details}` : "";
|
||||
push(ev.id, `${ev.id} ${ev.text}${details}`, ev.status === "error" ? "error" : "plain");
|
||||
};
|
||||
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const path =
|
||||
agentId != null
|
||||
@@ -41,13 +99,15 @@ export function DeployConsole({
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "log") {
|
||||
setLines((prev) => {
|
||||
const next = [...prev, msg.line as string];
|
||||
return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next;
|
||||
});
|
||||
handleLine(msg.line as string);
|
||||
} else if (msg.type === "done") {
|
||||
setPhase(msg.returncode === 0 ? "success" : "failed");
|
||||
const ok = msg.returncode === 0;
|
||||
tracker.finish(ok);
|
||||
dirtyRef.current = true;
|
||||
setPhase(ok ? "success" : "failed");
|
||||
} else if (msg.type === "error") {
|
||||
tracker.finish(false);
|
||||
dirtyRef.current = true;
|
||||
setPhase("error");
|
||||
setErrorDetail(msg.detail || "Deploy error");
|
||||
}
|
||||
@@ -57,53 +117,156 @@ export function DeployConsole({
|
||||
};
|
||||
ws.onclose = () => {
|
||||
// If the socket dropped before a done/error frame, surface it.
|
||||
setPhase((p) => (p === "running" ? "error" : p));
|
||||
setPhase((p) => {
|
||||
if (p !== "running") return p;
|
||||
tracker.finish(false);
|
||||
dirtyRef.current = true;
|
||||
return "error";
|
||||
});
|
||||
};
|
||||
return () => ws.close();
|
||||
}, [stackId, agentId, token]);
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
if (!dirtyRef.current) return;
|
||||
dirtyRef.current = false;
|
||||
setLines(bufferRef.current.slice());
|
||||
setStats(tracker.snapshot());
|
||||
}, FLUSH_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
ws.close();
|
||||
};
|
||||
}, [stackId, agentId, token, tracker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight;
|
||||
}, [lines]);
|
||||
}, [lines, showLog]);
|
||||
|
||||
const done = phase !== "running";
|
||||
const failed = phase === "failed" || phase === "error";
|
||||
const barTone = failed
|
||||
? "bg-red-500"
|
||||
: phase === "success"
|
||||
? "bg-sp-green"
|
||||
: "bg-accent dark:bg-accent-dark";
|
||||
const pct = phase === "success" ? 100 : Math.min(stats.pct, 100);
|
||||
const indeterminate = stats.indeterminate && !done;
|
||||
const label = failed ? "Deploy failed" : stats.label;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="flex max-h-[80vh] w-full max-w-3xl flex-col rounded-xl border border-slate-200 bg-card shadow-xl dark:border-slate-700 dark:bg-card-dark">
|
||||
<div className="flex items-center gap-2 border-b border-slate-200 px-5 py-3 dark:border-slate-700">
|
||||
{phase === "running" && (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-accent dark:text-accent-dark" />
|
||||
)}
|
||||
{phase === "success" && <CheckCircle2 className="h-5 w-5 text-green-500" />}
|
||||
{(phase === "failed" || phase === "error") && (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
<h2 className="sp-heading text-lg">
|
||||
{phase === "running" && `Deploying ${stackId}…`}
|
||||
{phase === "success" && `Deployed ${stackId} ✓`}
|
||||
{phase === "failed" && `Deploy of ${stackId} failed`}
|
||||
{phase === "error" && `Deploy of ${stackId} errored`}
|
||||
</h2>
|
||||
<div className="flex max-h-[85vh] w-full max-w-3xl flex-col overflow-hidden rounded-card border border-sp-border bg-sp-surface shadow-xl">
|
||||
{/* Header + progress */}
|
||||
<div className="border-b border-sp-border px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{phase === "running" && (
|
||||
<Loader2 className="h-5 w-5 shrink-0 animate-spin text-accent dark:text-accent-dark" />
|
||||
)}
|
||||
{phase === "success" && <CheckCircle2 className="h-5 w-5 shrink-0 text-sp-green" />}
|
||||
{failed && <XCircle className="h-5 w-5 shrink-0 text-red-500" />}
|
||||
<h2 className="sp-heading flex-1 truncate text-lg">
|
||||
{phase === "running" && `Deploying ${stackId}…`}
|
||||
{phase === "success" && `Deployed ${stackId}`}
|
||||
{phase === "failed" && `Deploy of ${stackId} failed`}
|
||||
{phase === "error" && `Deploy of ${stackId} errored`}
|
||||
</h2>
|
||||
<span className="sp-display shrink-0 text-lg tabular-nums">
|
||||
{indeterminate ? "…" : `${Math.round(pct)}%`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-sp-surface-2 ring-1 ring-inset ring-sp-border">
|
||||
{indeterminate ? (
|
||||
<div className="h-full w-1/3 rounded-full bg-accent animate-indeterminate dark:bg-accent-dark" />
|
||||
) : (
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-300 ease-out ${barTone}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-3 text-xs">
|
||||
<span className="shrink-0 text-sp-text-2">{label}</span>
|
||||
<span className="truncate text-right tabular-nums text-sp-text-3">
|
||||
{stats.detail}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={boxRef}
|
||||
className="min-h-[200px] flex-1 overflow-auto bg-slate-950 p-3 font-mono text-xs leading-relaxed text-slate-200"
|
||||
>
|
||||
{lines.length === 0 && phase === "running" ? (
|
||||
<span className="text-slate-500">Starting compose up…</span>
|
||||
) : (
|
||||
lines.map((l, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-all">
|
||||
{l}
|
||||
{/* Per-image pull progress */}
|
||||
{stats.images.length > 0 && (
|
||||
<div className="max-h-40 space-y-2 overflow-auto border-b border-sp-border bg-sp-surface-2 px-5 py-3">
|
||||
{stats.images.map((img) => (
|
||||
<div key={img.name} className="space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-3 text-xs">
|
||||
<span className="truncate font-medium text-sp-text-1">{img.name}</span>
|
||||
<span
|
||||
className={`shrink-0 tabular-nums ${
|
||||
img.error ? "text-red-500" : "text-sp-text-3"
|
||||
}`}
|
||||
>
|
||||
{img.detail}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-sp-border">
|
||||
{img.measured || img.error ? (
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-300 ease-out ${
|
||||
img.error ? "bg-red-500" : img.done ? "bg-sp-green" : "bg-accent dark:bg-accent-dark"
|
||||
}`}
|
||||
style={{ width: `${img.error ? 100 : Math.min(img.pct, 100)}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-1/4 rounded-full bg-accent animate-indeterminate dark:bg-accent-dark" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{errorDetail && <div className="mt-2 text-red-400">{errorDetail}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-slate-200 px-5 py-3 dark:border-slate-700">
|
||||
{/* Raw compose output */}
|
||||
<button
|
||||
onClick={() => setShowLog((v) => !v)}
|
||||
className="flex items-center gap-1 border-b border-sp-border px-5 py-2 text-left text-xs text-sp-text-2 hover:text-sp-text-1"
|
||||
>
|
||||
{showLog ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{showLog ? "Hide output" : "Show output"}
|
||||
<span className="text-sp-text-3">({lines.length})</span>
|
||||
</button>
|
||||
|
||||
{showLog && (
|
||||
<div
|
||||
ref={boxRef}
|
||||
className="min-h-[180px] flex-1 overflow-auto bg-slate-950 p-3 font-mono text-xs leading-relaxed text-slate-200"
|
||||
>
|
||||
{lines.length === 0 && phase === "running" ? (
|
||||
<span className="text-slate-500">Starting compose up…</span>
|
||||
) : (
|
||||
lines.map((l, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`whitespace-pre-wrap break-all ${
|
||||
l.tone === "error" ? "text-red-400" : ""
|
||||
}`}
|
||||
>
|
||||
{l.text}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{errorDetail && <div className="mt-2 text-red-400">{errorDetail}</div>}
|
||||
</div>
|
||||
)}
|
||||
{!showLog && errorDetail && (
|
||||
<div className="px-5 py-3 text-xs text-red-500">{errorDetail}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-sp-border px-5 py-3">
|
||||
<Button variant={done ? "primary" : "outline"} onClick={onClose}>
|
||||
{done ? "Close" : "Close (deploy continues)"}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Turns the raw `docker compose up` output stream into a progress model for the
|
||||
* deploy console.
|
||||
*
|
||||
* Compose is run with `--progress json` (compose ≥ 2.36), which emits one JSON
|
||||
* object per status change — including per-layer `current`/`total` bytes, so a
|
||||
* real percentage can be computed. Older compose (and older StackPilot agents)
|
||||
* emit the plain text form ` <id> <Status> <details>`; that is parsed too, but
|
||||
* without byte totals the bar falls back to layer/container counts.
|
||||
*/
|
||||
import { formatBytes } from "./utils";
|
||||
|
||||
export type DeployEvent = {
|
||||
id: string;
|
||||
parentId?: string;
|
||||
status: "working" | "done" | "error";
|
||||
text: string;
|
||||
details?: string;
|
||||
current?: number;
|
||||
total?: number;
|
||||
};
|
||||
|
||||
export type ParsedLine =
|
||||
| { kind: "event"; event: DeployEvent }
|
||||
| { kind: "text"; text: string; tone: "plain" | "error" };
|
||||
|
||||
// Longest first: the alternation is matched left-to-right, so "Pulling fs layer"
|
||||
// must be tried before "Pulling".
|
||||
const PLAIN_STATUSES = [
|
||||
"Pulling fs layer",
|
||||
"Verifying Checksum",
|
||||
"Download complete",
|
||||
"Pull complete",
|
||||
"Already exists",
|
||||
"Downloading",
|
||||
"Extracting",
|
||||
"Retrying",
|
||||
"Waiting",
|
||||
"Pulling",
|
||||
"Pulled",
|
||||
"Creating",
|
||||
"Created",
|
||||
"Recreating",
|
||||
"Recreated",
|
||||
"Starting",
|
||||
"Started",
|
||||
"Stopping",
|
||||
"Stopped",
|
||||
"Removing",
|
||||
"Removed",
|
||||
"Running",
|
||||
"Healthy",
|
||||
"Skipped",
|
||||
"Warning",
|
||||
"Error",
|
||||
];
|
||||
|
||||
const PLAIN_RE = new RegExp(`^\\s*(.*?)\\s+(${PLAIN_STATUSES.join("|")})\\s*(.*)$`);
|
||||
|
||||
const SIZE_RE = /^([\d.]+)\s*(B|kB|KB|MB|GB|TB|KiB|MiB|GiB|TiB)$/;
|
||||
const SIZE_UNITS: Record<string, number> = {
|
||||
B: 1,
|
||||
kB: 1e3,
|
||||
KB: 1e3,
|
||||
MB: 1e6,
|
||||
GB: 1e9,
|
||||
TB: 1e12,
|
||||
KiB: 1024,
|
||||
MiB: 1024 ** 2,
|
||||
GiB: 1024 ** 3,
|
||||
TiB: 1024 ** 4,
|
||||
};
|
||||
|
||||
function parseSize(raw: string): number | undefined {
|
||||
const m = SIZE_RE.exec(raw.trim());
|
||||
if (!m) return undefined;
|
||||
return parseFloat(m[1]) * SIZE_UNITS[m[2]];
|
||||
}
|
||||
|
||||
/** Parse one output line into a structured event, or plain text to log as-is. */
|
||||
export function parseDeployLine(line: string): ParsedLine | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (trimmed.startsWith("{")) {
|
||||
try {
|
||||
const j = JSON.parse(trimmed);
|
||||
if (j.error) {
|
||||
return { kind: "text", text: String(j.message ?? "error"), tone: "error" };
|
||||
}
|
||||
if (typeof j.id === "string" && typeof j.text === "string") {
|
||||
const status =
|
||||
j.status === "Done" ? "done" : j.status === "Error" ? "error" : "working";
|
||||
return {
|
||||
kind: "event",
|
||||
event: {
|
||||
id: j.id,
|
||||
parentId: typeof j.parent_id === "string" ? j.parent_id : undefined,
|
||||
status,
|
||||
text: j.text,
|
||||
details: typeof j.details === "string" ? j.details : undefined,
|
||||
current: typeof j.current === "number" ? j.current : undefined,
|
||||
total: typeof j.total === "number" ? j.total : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* not our JSON — fall through to plain parsing */
|
||||
}
|
||||
}
|
||||
|
||||
const m = PLAIN_RE.exec(line);
|
||||
if (m) {
|
||||
const [, id, text, rest] = m;
|
||||
const details = rest.trim();
|
||||
// Older compose renders ` [====> ] 3.4MB/28.5MB`; newer plain output just
|
||||
// gives the current size. Pick up whatever is there.
|
||||
const bar = /([\d.]+\s*[kKMGT]?i?B)\s*\/\s*([\d.]+\s*[kKMGT]?i?B)/.exec(details);
|
||||
const current = bar ? parseSize(bar[1]) : parseSize(details);
|
||||
const total = bar ? parseSize(bar[2]) : undefined;
|
||||
return {
|
||||
kind: "event",
|
||||
event: {
|
||||
id: id.trim(),
|
||||
status: text === "Error" ? "error" : "working",
|
||||
text,
|
||||
details: details || undefined,
|
||||
current,
|
||||
total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "text", text: line, tone: /error|failed/i.test(line) ? "error" : "plain" };
|
||||
}
|
||||
|
||||
// --- progress model ----------------------------------------------------------
|
||||
|
||||
type LayerPhase = "queued" | "downloading" | "downloaded" | "extracting" | "complete" | "exists";
|
||||
|
||||
/** Downloading dominates the wall-clock time of a pull; extraction is the rest. */
|
||||
const DOWNLOAD_SHARE = 0.85;
|
||||
|
||||
const LAYER_PHASES: Record<string, LayerPhase> = {
|
||||
"Pulling fs layer": "queued",
|
||||
Waiting: "queued",
|
||||
Retrying: "queued",
|
||||
Downloading: "downloading",
|
||||
"Verifying Checksum": "downloaded",
|
||||
"Download complete": "downloaded",
|
||||
Extracting: "extracting",
|
||||
"Pull complete": "complete",
|
||||
"Already exists": "exists",
|
||||
};
|
||||
|
||||
const STATIC_LAYER_FRACTION: Record<LayerPhase, number> = {
|
||||
queued: 0,
|
||||
downloading: 0, // computed from bytes
|
||||
downloaded: DOWNLOAD_SHARE,
|
||||
extracting: 0.93,
|
||||
complete: 1,
|
||||
exists: 1,
|
||||
};
|
||||
|
||||
const UNIT_FRACTIONS: Record<string, number> = {
|
||||
Creating: 0.35,
|
||||
Recreating: 0.35,
|
||||
Created: 0.6,
|
||||
Recreated: 0.6,
|
||||
Waiting: 0.7,
|
||||
Starting: 0.8,
|
||||
Started: 1,
|
||||
Running: 1,
|
||||
Healthy: 1,
|
||||
Skipped: 1,
|
||||
Stopping: 0.5,
|
||||
Stopped: 1,
|
||||
Removing: 0.5,
|
||||
Removed: 1,
|
||||
Error: 1,
|
||||
};
|
||||
|
||||
const IMAGE_TEXTS = new Set(["Pulling", "Pulled", "Pull complete", "Skipped"]);
|
||||
const UNIT_PREFIX = /^(Container|Network|Volume) /;
|
||||
|
||||
type Layer = { phase: LayerPhase; current: number; total: number; image?: string };
|
||||
type ImageEntry = { name: string; done: boolean; error?: string };
|
||||
type Unit = { kind: string; fraction: number; text: string; error?: string };
|
||||
|
||||
export type ImageRow = {
|
||||
name: string;
|
||||
pct: number;
|
||||
done: boolean;
|
||||
/** False when the stream carries no per-layer bytes for this image (old
|
||||
* compose / old agent): show the state instead of a misleading bar. */
|
||||
measured: boolean;
|
||||
error?: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type DeployStats = {
|
||||
/** 0–100, monotonic while running. */
|
||||
pct: number;
|
||||
/** No measurable work yet — show a sliding bar instead of a filled one. */
|
||||
indeterminate: boolean;
|
||||
phase: "preparing" | "pulling" | "creating" | "success" | "failed";
|
||||
label: string;
|
||||
detail: string;
|
||||
images: ImageRow[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Accumulates deploy events into an overall percentage. Kept outside React so
|
||||
* the (very chatty) event stream can be folded in without a render per line.
|
||||
*/
|
||||
export class DeployTracker {
|
||||
private layers = new Map<string, Layer>();
|
||||
private images = new Map<string, ImageEntry>();
|
||||
private units = new Map<string, Unit>();
|
||||
private maxPct = 0;
|
||||
private finished: "success" | "failed" | null = null;
|
||||
|
||||
apply(event: DeployEvent): void {
|
||||
const kind = this.classify(event);
|
||||
if (kind === "image") {
|
||||
const name = event.id.replace(/^Image /, "");
|
||||
const entry = this.images.get(event.id) ?? { name, done: false };
|
||||
entry.name = name;
|
||||
if (event.status === "error") entry.error = event.details || event.text;
|
||||
else if (event.status === "done" || event.text === "Pulled") entry.done = true;
|
||||
this.images.set(event.id, entry);
|
||||
return;
|
||||
}
|
||||
if (kind === "layer") {
|
||||
const layer = this.layers.get(event.id) ?? { phase: "queued", current: 0, total: 0 };
|
||||
if (event.parentId) layer.image = event.parentId;
|
||||
const phase = LAYER_PHASES[event.text];
|
||||
if (phase) layer.phase = phase;
|
||||
else if (event.status === "done") layer.phase = "complete";
|
||||
if (event.total && event.total > 0) layer.total = event.total;
|
||||
if (event.current && event.current > 0 && layer.phase === "downloading") {
|
||||
layer.current = Math.max(layer.current, event.current);
|
||||
}
|
||||
this.layers.set(event.id, layer);
|
||||
return;
|
||||
}
|
||||
const unitKind = UNIT_PREFIX.exec(event.id)?.[1] ?? "Container";
|
||||
const unit = this.units.get(event.id) ?? { kind: unitKind, fraction: 0, text: event.text };
|
||||
unit.text = event.text;
|
||||
if (event.status === "error") unit.error = event.details || event.text;
|
||||
// Prefer the status text: compose reports "Created" as a Done event, but a
|
||||
// created container is only half-way to running.
|
||||
const mapped = UNIT_FRACTIONS[event.text];
|
||||
const fraction = mapped ?? (event.status === "done" ? 1 : unit.fraction);
|
||||
unit.fraction = Math.max(unit.fraction, fraction);
|
||||
this.units.set(event.id, unit);
|
||||
}
|
||||
|
||||
finish(ok: boolean): void {
|
||||
this.finished = ok ? "success" : "failed";
|
||||
}
|
||||
|
||||
private classify(event: DeployEvent): "image" | "layer" | "unit" {
|
||||
if (UNIT_PREFIX.test(event.id)) return "unit";
|
||||
if (event.id.startsWith("Image ")) return "image";
|
||||
if (event.parentId || /^[0-9a-f]{10,}$/i.test(event.id)) return "layer";
|
||||
if (IMAGE_TEXTS.has(event.text)) return "image";
|
||||
return "unit";
|
||||
}
|
||||
|
||||
private layerFraction(layer: Layer): number {
|
||||
if (layer.phase === "downloading") {
|
||||
const frac = layer.total > 0 ? Math.min(layer.current / layer.total, 1) : 0;
|
||||
return DOWNLOAD_SHARE * frac;
|
||||
}
|
||||
return STATIC_LAYER_FRACTION[layer.phase];
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted pull progress: layers weigh what they cost (their download size),
|
||||
* unknown-size layers get the average of the known ones, and layers that were
|
||||
* already present weigh nothing because they cost no time.
|
||||
*/
|
||||
private pullProgress(only?: string): { pct: number; done: number; totalBytes: number; loaded: number } {
|
||||
const layers = [...this.layers.entries()]
|
||||
.filter(([, l]) => (only ? l.image === only : true))
|
||||
.map(([, l]) => l);
|
||||
// A layer whose size is still unknown is weighted like the smallest known
|
||||
// layer: guessing high would make the bar lurch when that layer completes.
|
||||
const known = layers.filter((l) => l.total > 0).map((l) => l.total);
|
||||
const unknownWeight = known.length ? Math.min(...known) : 1;
|
||||
|
||||
let weight = 0;
|
||||
let weighted = 0;
|
||||
let loaded = 0;
|
||||
let totalBytes = 0;
|
||||
let done = 0;
|
||||
for (const layer of layers) {
|
||||
const fraction = this.layerFraction(layer);
|
||||
if (fraction >= 1) done += 1;
|
||||
if (layer.total > 0) {
|
||||
totalBytes += layer.total;
|
||||
loaded += layer.phase === "downloading" ? layer.current : layer.total;
|
||||
}
|
||||
if (layer.phase === "exists") continue; // instant — no time cost
|
||||
const w = layer.total > 0 ? layer.total : unknownWeight;
|
||||
weight += w;
|
||||
weighted += w * fraction;
|
||||
}
|
||||
const pct = weight > 0 ? weighted / weight : layers.length ? 1 : 0;
|
||||
return { pct, done, totalBytes, loaded };
|
||||
}
|
||||
|
||||
snapshot(): DeployStats {
|
||||
const imageList = [...this.images.entries()];
|
||||
const allImagesDone = imageList.length > 0 && imageList.every(([, i]) => i.done);
|
||||
const pull = this.pullProgress();
|
||||
const pullPct = allImagesDone ? 1 : pull.pct;
|
||||
const hasPull = this.layers.size > 0 || imageList.length > 0;
|
||||
|
||||
const units = [...this.units.values()];
|
||||
const containers = units.filter((u) => u.kind === "Container");
|
||||
// Networks/volumes are created in milliseconds and their count says nothing
|
||||
// about the stack's size, so only containers move this part of the bar.
|
||||
const unitPct = containers.length
|
||||
? containers.reduce((a, u) => a + u.fraction, 0) / containers.length
|
||||
: units.length
|
||||
? 0.1
|
||||
: 0;
|
||||
|
||||
// The pull owns most of the bar; container create/start is the tail.
|
||||
let pct = hasPull ? 100 * (0.75 * pullPct + 0.25 * unitPct) : 100 * unitPct;
|
||||
this.maxPct = Math.max(this.maxPct, pct);
|
||||
// Never show a finished bar while compose is still working: containers can
|
||||
// still appear after the ones already reported.
|
||||
pct = this.finished === null ? Math.min(this.maxPct, 97) : this.maxPct;
|
||||
if (this.finished === "success") pct = 100;
|
||||
|
||||
const pulling = hasPull && !allImagesDone;
|
||||
const startedContainers = containers.filter((u) => u.fraction >= 1).length;
|
||||
|
||||
let phase: DeployStats["phase"] = "preparing";
|
||||
let label = "Preparing…";
|
||||
let detail = "";
|
||||
if (this.finished === "success") {
|
||||
phase = "success";
|
||||
label = "Deployed";
|
||||
detail = containers.length ? `${containers.length} container(s) running` : "";
|
||||
} else if (this.finished === "failed") {
|
||||
phase = "failed";
|
||||
label = "Deploy failed";
|
||||
} else if (pulling) {
|
||||
phase = "pulling";
|
||||
label = "Pulling images";
|
||||
const parts = [`${pull.done}/${this.layers.size} layers`];
|
||||
if (pull.totalBytes > 0) {
|
||||
parts.push(`${formatBytes(pull.loaded)} / ${formatBytes(pull.totalBytes)}`);
|
||||
}
|
||||
const pendingImage = imageList.find(([, i]) => !i.done)?.[1].name;
|
||||
if (pendingImage) parts.push(pendingImage);
|
||||
detail = parts.join(" · ");
|
||||
} else if (units.length) {
|
||||
const starting = units.some((u) => u.text === "Starting" || u.text === "Started");
|
||||
phase = "creating";
|
||||
label = starting ? "Starting containers" : "Creating containers";
|
||||
detail = containers.length
|
||||
? `${startedContainers}/${containers.length} containers`
|
||||
: `${units.length} resource(s)`;
|
||||
} else if (hasPull) {
|
||||
// Pull finished, container work not reported yet.
|
||||
phase = "creating";
|
||||
label = "Images ready";
|
||||
detail = `${imageList.length} image(s)`;
|
||||
}
|
||||
|
||||
const images: ImageRow[] = imageList.map(([id, entry]) => {
|
||||
const per = this.pullProgress(id);
|
||||
const own = [...this.layers.values()].filter((l) => l.image === id);
|
||||
const hasLayers = own.length > 0;
|
||||
const cached = hasLayers && own.every((l) => l.phase === "exists");
|
||||
const pctImage = entry.done ? 100 : hasLayers ? per.pct * 100 : 0;
|
||||
const bytes =
|
||||
per.totalBytes > 0
|
||||
? `${formatBytes(per.loaded)} / ${formatBytes(per.totalBytes)}`
|
||||
: cached
|
||||
? "up to date"
|
||||
: entry.done
|
||||
? "pulled"
|
||||
: hasLayers
|
||||
? `${per.done}/${own.length} layers`
|
||||
: "waiting…";
|
||||
return {
|
||||
name: entry.name,
|
||||
pct: pctImage,
|
||||
done: entry.done,
|
||||
measured: hasLayers || entry.done,
|
||||
error: entry.error,
|
||||
detail: entry.error ? entry.error : bytes,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
pct,
|
||||
indeterminate: !this.finished && pct <= 0,
|
||||
phase,
|
||||
label,
|
||||
detail,
|
||||
images,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user