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:
@@ -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