Stream update progress into a bar on the stack's row (0.42.0)
CI / build-and-push (push) Successful in 1m55s
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user