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:
menzelj
2026-08-16 14:19:27 +00:00
co-authored by Claude Opus 5
parent 9119f94536
commit ecf780c5e6
6 changed files with 657 additions and 50 deletions
+4 -1
View File
@@ -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
+31 -1
View File
@@ -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
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.38.4"
APP_VERSION = "0.39.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.38.3",
"version": "0.39.0",
"type": "module",
"scripts": {
"dev": "vite",
+209 -46
View File
@@ -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>
+411
View File
@@ -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 = {
/** 0100, 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,
};
}
}