From ecf780c5e62e8a0bad9868297e8590f6b4ebc9d3 Mon Sep 17 00:00:00 2001 From: menzelj Date: Sun, 16 Aug 2026 14:19:27 +0000 Subject: [PATCH] Deploy console: real progress bar for image pulls (0.39.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 5 +- backend/services/compose_service.py | 32 +- backend/version.py | 2 +- frontend/package.json | 2 +- .../src/components/stacks/DeployConsole.tsx | 255 +++++++++-- frontend/src/lib/deployProgress.ts | 411 ++++++++++++++++++ 6 files changed, 657 insertions(+), 50 deletions(-) create mode 100644 frontend/src/lib/deployProgress.ts diff --git a/README.md b/README.md index 269dcfd..33f4d6e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/services/compose_service.py b/backend/services/compose_service.py index 4a5c5b6..909676c 100644 --- a/backend/services/compose_service.py +++ b/backend/services/compose_service.py @@ -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, diff --git a/backend/version.py b/backend/version.py index df9ec5b..2da3d1a 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.38.4" +APP_VERSION = "0.39.0" diff --git a/frontend/package.json b/frontend/package.json index 20fbb40..b71b7a8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.38.3", + "version": "0.39.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/stacks/DeployConsole.tsx b/frontend/src/components/stacks/DeployConsole.tsx index c1ccaa4..4b88e2c 100644 --- a/frontend/src/components/stacks/DeployConsole.tsx +++ b/frontend/src/components/stacks/DeployConsole.tsx @@ -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([]); + const [lines, setLines] = useState([]); + const [stats, setStats] = useState(EMPTY_STATS); const [phase, setPhase] = useState("running"); const [errorDetail, setErrorDetail] = useState(null); + const [showLog, setShowLog] = useState(true); const boxRef = useRef(null); const token = useAuthStore((s) => s.accessToken); + const tracker = useMemo(() => new DeployTracker(), []); + const bufferRef = useRef([]); + const indexRef = useRef(new Map()); + 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(); + 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 (
-
-
- {phase === "running" && ( - - )} - {phase === "success" && } - {(phase === "failed" || phase === "error") && ( - - )} -

- {phase === "running" && `Deploying ${stackId}…`} - {phase === "success" && `Deployed ${stackId} ✓`} - {phase === "failed" && `Deploy of ${stackId} failed`} - {phase === "error" && `Deploy of ${stackId} errored`} -

+
+ {/* Header + progress */} +
+
+ {phase === "running" && ( + + )} + {phase === "success" && } + {failed && } +

+ {phase === "running" && `Deploying ${stackId}…`} + {phase === "success" && `Deployed ${stackId}`} + {phase === "failed" && `Deploy of ${stackId} failed`} + {phase === "error" && `Deploy of ${stackId} errored`} +

+ + {indeterminate ? "…" : `${Math.round(pct)}%`} + +
+ +
+ {indeterminate ? ( +
+ ) : ( +
+ )} +
+ +
+ {label} + + {stats.detail} + +
-
- {lines.length === 0 && phase === "running" ? ( - Starting compose up… - ) : ( - lines.map((l, i) => ( -
- {l} + {/* Per-image pull progress */} + {stats.images.length > 0 && ( +
+ {stats.images.map((img) => ( +
+
+ {img.name} + + {img.detail} + +
+
+ {img.measured || img.error ? ( +
+ ) : ( +
+ )} +
- )) - )} - {errorDetail &&
{errorDetail}
} -
+ ))} +
+ )} -
+ {/* Raw compose output */} + + + {showLog && ( +
+ {lines.length === 0 && phase === "running" ? ( + Starting compose up… + ) : ( + lines.map((l, i) => ( +
+ {l.text} +
+ )) + )} + {errorDetail &&
{errorDetail}
} +
+ )} + {!showLog && errorDetail && ( +
{errorDetail}
+ )} + +
diff --git a/frontend/src/lib/deployProgress.ts b/frontend/src/lib/deployProgress.ts new file mode 100644 index 0000000..2a9dca3 --- /dev/null +++ b/frontend/src/lib/deployProgress.ts @@ -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 `
`; 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 = { + 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 = { + "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 = { + queued: 0, + downloading: 0, // computed from bytes + downloaded: DOWNLOAD_SHARE, + extracting: 0.93, + complete: 1, + exists: 1, +}; + +const UNIT_FRACTIONS: Record = { + 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(); + private images = new Map(); + private units = new Map(); + 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, + }; + } +}