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
+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,
};
}
}