Stream update progress into a bar on the stack's row (0.42.0)
CI / build-and-push (push) Successful in 1m55s

Update ran as a blocking POST with nothing to show but a spinner, so the
status added in 86c67df could only sit above the table as a banner.

Adds /ws/update/{stack_id}, streaming `compose pull` then `up -d` with
--progress json, and feeds it through the existing DeployTracker — the
same weighting the deploy console uses. The result renders as a progress
bar inside the stack's own row: percentage, phase label, and layer/byte
detail. Non-streaming actions (start/stop/restart/pull/down) reuse the
bar in its indeterminate form, so every row action looks consistent.

compose_service gains _stream_phase, shared by stream_up and the new
stream_update; a failed pull short-circuits before `up`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pMmFFkdfxkoYjcEcpZTa5
This commit is contained in:
menzelj
2026-08-31 00:33:34 +02:00
co-authored by Claude Opus 5
parent 86c67dfcea
commit 1e8d4248fd
11 changed files with 392 additions and 56 deletions
+87
View File
@@ -0,0 +1,87 @@
/**
* Drives a streaming compose WebSocket (`/ws/update/{id}`, `/ws/deploy/{id}`)
* and folds its output into a progress snapshot via {@link DeployTracker}.
*
* The deploy console renders the same stream with its full log view; this is
* the headless counterpart, for places that only want the progress bar — the
* stacks list, where an update needs to be legible at a glance in one row.
*/
import { DeployTracker, parseDeployLine } from "./deployProgress";
/** Compose emits status changes far faster than the UI needs to repaint. */
const FLUSH_MS = 150;
export type ProgressSnapshot = {
pct: number;
indeterminate: boolean;
label: string;
detail: string;
};
function snapshot(tracker: DeployTracker): ProgressSnapshot {
const s = tracker.snapshot();
return { pct: s.pct, indeterminate: s.indeterminate, label: s.label, detail: s.detail };
}
/**
* Opens `path`, reporting progress until compose finishes. Resolves on exit
* code 0, rejects with the failure reason otherwise. The compose subprocess
* keeps running server-side even if this socket is closed early.
*/
export function streamCompose(
path: string,
token: string,
onProgress: (p: ProgressSnapshot) => void
): Promise<void> {
return new Promise((resolve, reject) => {
const tracker = new DeployTracker();
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(
`${proto}://${window.location.host}${path}?token=${encodeURIComponent(token)}`
);
let dirty = false;
let settled = false;
const timer = window.setInterval(() => {
if (!dirty) return;
dirty = false;
onProgress(snapshot(tracker));
}, FLUSH_MS);
const settle = (err?: Error) => {
if (settled) return;
settled = true;
window.clearInterval(timer);
tracker.finish(!err);
onProgress(snapshot(tracker));
ws.close();
if (err) reject(err);
else resolve();
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "log") {
const parsed = parseDeployLine(msg.line as string);
if (parsed?.kind === "event") {
tracker.apply(parsed.event);
dirty = true;
}
} else if (msg.type === "done") {
if (msg.returncode === 0) settle();
else settle(new Error(`compose exited with code ${msg.returncode}`));
} else if (msg.type === "error") {
settle(new Error(msg.detail || "update failed"));
}
} catch {
/* ignore malformed frames */
}
};
ws.onerror = () => settle(new Error("connection to the server failed"));
// Reached normally after settle() closes the socket; the guard makes this
// a no-op then, so it only fires for a stream that dropped mid-update.
ws.onclose = () => settle(new Error("connection closed before the update finished"));
});
}