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
@@ -8,6 +8,83 @@ const TONE = {
"border-red-300 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300",
} as const;
const BAR_TONE = {
running: "bg-accent dark:bg-accent-dark",
success: "bg-sp-green",
error: "bg-red-500",
} as const;
/**
* Progress bar for a running stack action, sized to sit inside a stacks-list
* row. Update streams real per-layer pull progress; the other actions have
* nothing to measure and show a sliding bar instead.
*/
export function ActionProgressBar({
status,
onDismiss,
}: {
status: StackActionStatus;
onDismiss?: (id: string) => void;
}) {
const { phase, progress } = status;
const done = phase !== "running";
const pct = phase === "success" ? 100 : Math.min(progress?.pct ?? 0, 100);
// No stream (or nothing measurable yet) — slide instead of showing a lie.
const indeterminate = !done && (progress?.indeterminate ?? true);
const label = done
? phase === "success"
? `${status.label} — done`
: `${status.label} failed`
: (progress?.label ?? `${status.label}`);
const detail = phase === "error" ? (status.message ?? "") : (progress?.detail ?? "");
return (
<div className="mt-1.5 w-full">
<div className="h-1.5 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 ${BAR_TONE[phase]}`}
style={{ width: `${pct}%` }}
/>
)}
</div>
<div className="mt-1 flex items-baseline gap-2 text-[11px]">
<span
className={
phase === "error"
? "shrink-0 font-medium text-red-600 dark:text-red-400"
: phase === "success"
? "shrink-0 font-medium text-sp-green"
: "shrink-0 text-sp-text-2"
}
>
{label}
</span>
{!done && !indeterminate && (
<span className="shrink-0 tabular-nums text-sp-text-2">{Math.round(pct)}%</span>
)}
{detail && (
<span className="truncate tabular-nums text-sp-text-3" title={detail}>
{detail}
</span>
)}
{done && onDismiss && (
<button
onClick={() => onDismiss(status.id)}
className="ml-auto shrink-0 opacity-60 transition-opacity hover:opacity-100"
aria-label="Dismiss"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
);
}
/** Persistent status line for one stack action (start/stop/pull/update/…),
* shown in addition to the transient toast so the outcome doesn't vanish
* with it — and so a failure stays readable until acknowledged. */
@@ -20,27 +97,32 @@ export function ActionStatusBanner({
}) {
return (
<div
className={`flex items-center gap-2 rounded-card border px-3 py-2 text-sm ${TONE[status.phase]}`}
className={`rounded-card border px-3 py-2 text-sm ${TONE[status.phase]}`}
>
{status.phase === "running" && (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-accent dark:text-accent-dark" />
)}
{status.phase === "success" && <CheckCircle2 className="h-4 w-4 shrink-0" />}
{status.phase === "error" && <XCircle className="h-4 w-4 shrink-0" />}
<span className="flex-1">
{status.phase === "running" && `${status.label} ${status.id}`}
{status.phase === "success" && `${status.label} ${status.id} — done`}
{status.phase === "error" &&
`${status.label} ${status.id} — failed${status.message ? `: ${status.message}` : ""}`}
</span>
{status.phase !== "running" && (
<button
onClick={() => onDismiss(status.id)}
className="shrink-0 opacity-60 transition-opacity hover:opacity-100"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
{status.phase === "running" && (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-accent dark:text-accent-dark" />
)}
{status.phase === "success" && <CheckCircle2 className="h-4 w-4 shrink-0" />}
{status.phase === "error" && <XCircle className="h-4 w-4 shrink-0" />}
<span className="flex-1">
{status.phase === "running" && `${status.label} ${status.id}`}
{status.phase === "success" && `${status.label} ${status.id} — done`}
{status.phase === "error" &&
`${status.label} ${status.id} — failed${status.message ? `: ${status.message}` : ""}`}
</span>
{status.phase !== "running" && (
<button
onClick={() => onDismiss(status.id)}
className="shrink-0 opacity-60 transition-opacity hover:opacity-100"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{status.phase === "running" && status.progress && (
<ActionProgressBar status={status} />
)}
</div>
);
+15 -1
View File
@@ -9,6 +9,8 @@ import { formatBytes } from "@/lib/utils";
import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client";
import type { StackStats, StackSummary, StackUpdateInfo } from "@/types";
import type { StackActionStatus } from "@/hooks/useStackActions";
import { ActionProgressBar } from "./ActionStatusBanner";
/** Stack list rendered as a table with live CPU/memory usage meters and inline
* start/stop/restart, shared by the Dashboard and the Stacks page. */
@@ -20,6 +22,8 @@ export function StacksTable({
hostMem,
isAdmin,
isBusy,
statusFor,
onDismissStatus,
loading,
linkBase = "/stacks",
showEdit = false,
@@ -37,6 +41,9 @@ export function StacksTable({
hostMem: number;
isAdmin: boolean;
isBusy: (id: string) => boolean;
/** Live status of an action running on this stack, if any. */
statusFor?: (id: string) => StackActionStatus | undefined;
onDismissStatus?: (id: string) => void;
loading: boolean;
linkBase?: string;
showEdit?: boolean;
@@ -77,6 +84,8 @@ export function StacksTable({
hostMem={hostMem}
isAdmin={isAdmin}
busy={isBusy(s.id)}
status={statusFor?.(s.id)}
onDismissStatus={onDismissStatus}
linkBase={linkBase}
showEdit={showEdit}
showDelete={showDelete}
@@ -100,6 +109,8 @@ function StackRow({
hostMem,
isAdmin,
busy,
status,
onDismissStatus,
linkBase,
showEdit,
showDelete,
@@ -115,6 +126,8 @@ function StackRow({
hostMem: number;
isAdmin: boolean;
busy: boolean;
status?: StackActionStatus;
onDismissStatus?: (id: string) => void;
linkBase: string;
showEdit: boolean;
showDelete: boolean;
@@ -155,7 +168,7 @@ function StackRow({
<span className="text-xs text-slate-400">
{stack.running_count}/{stack.service_count} svc
</span>
{updateAvailable && (
{updateAvailable && !status && (
<span
title={
update?.stale_images?.length
@@ -168,6 +181,7 @@ function StackRow({
</span>
)}
</Link>
{status && <ActionProgressBar status={status} onDismiss={onDismissStatus} />}
</td>
<td className="px-4 py-2.5">
{running && stats ? (