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
View File
@@ -34,6 +34,13 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
console shows a **real progress bar** (download bytes per layer, weighted by 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 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. is kept below it and updates one line per layer instead of scrolling past.
- **Inline update progress (0.42.0)** — hitting Update on a stack streams
`compose pull && up -d` over `/ws/update/{stack_id}` and folds it into a
**progress bar inside that stack's row** (same `--progress json` weighting as
the deploy console: "Pulling images · 3/7 layers · 88 MB / 190 MB"). Several
stacks can update at once — busy state and progress are tracked per stack, so
the rows advance independently. The REST `POST /api/stacks/{id}/update` stays
for non-interactive callers and as the fallback when no token is available.
- **Monaco editor** — YAML editing with an `.env` tab and a **`docker run` → - **Monaco editor** — YAML editing with an `.env` tab and a **`docker run` →
compose** converter. compose** converter.
- **Dashboard** — system resource bar, stack grid with quick actions, and a - **Dashboard** — system resource bar, stack grid with quick actions, and a
@@ -456,6 +463,7 @@ POST /api/stacks/convert (docker run → compose)
GET /api/system/info | gpus | devices GET /api/audit GET /api/system/info | gpus | devices GET /api/audit
GET /api/system/update POST /api/system/update (self-update) GET /api/system/update POST /api/system/update (self-update)
WS /ws/logs/{stack_id}[/{service}] WS /ws/events WS /ws/logs/{stack_id}[/{service}] WS /ws/events
WS /ws/deploy/{stack_id} WS /ws/update/{stack_id}
``` ```
### Phase 2 endpoints ### Phase 2 endpoints
+68 -2
View File
@@ -16,8 +16,14 @@ from sqlmodel import Session
from auth import decode_token from auth import decode_token
from database import engine from database import engine
from models.agent import Agent from models.agent import Agent
from models.setting import EVENT_STACK_ERROR, EVENT_STACK_START from models.setting import EVENT_PULL_FAILED, EVENT_STACK_ERROR, EVENT_STACK_START
from services import audit_service, compose_service, exec_service, notify_service from services import (
audit_service,
compose_service,
exec_service,
notify_service,
update_service,
)
logger = logging.getLogger("stackpilot.ws") logger = logging.getLogger("stackpilot.ws")
@@ -241,6 +247,66 @@ async def ws_agent_logs(
await websocket.close() await websocket.close()
@router.websocket("/ws/update/{stack_id}")
async def ws_update(
websocket: WebSocket,
stack_id: str,
token: str | None = Query(default=None),
):
"""Run `docker compose pull && up -d` and stream its output, so the stacks
list can render real update progress. Same audit/notify contract as the
REST `/update` endpoint, which stays for non-interactive callers."""
await websocket.accept()
if not await _authorize_admin(websocket, token):
return
username = decode_token(token, "access").get("sub", "unknown") if token else "unknown"
rc: int | None = None
disconnected = False
compose_service.mark_busy(stack_id)
try:
async for kind, payload in compose_service.stream_update(stack_id):
if kind == "log":
await websocket.send_text(json.dumps({"type": "log", "line": payload}))
else:
rc = payload
await websocket.send_text(json.dumps({"type": "done", "returncode": rc}))
except WebSocketDisconnect:
# Client navigated away; compose keeps running so the update finishes.
disconnected = True
except Exception as exc: # noqa: BLE001
with contextlib.suppress(Exception):
await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)}))
finally:
compose_service.clear_busy(stack_id)
ok = rc in (0, None)
try:
with Session(engine) as session:
audit_service.record(
session, user=username, action="stack.update", target=stack_id,
detail=f"rc={rc} (update stream)", ip="ws",
)
if ok:
await notify_service.notify(
EVENT_STACK_START, f"Stack '{stack_id}' updated",
"compose pull + up completed successfully.", session,
)
else:
await notify_service.notify(
EVENT_PULL_FAILED, f"Stack '{stack_id}' update failed",
"compose pull/up returned a non-zero exit code.", session,
)
except Exception: # noqa: BLE001 - audit/notify are best-effort
pass
if not disconnected:
with contextlib.suppress(Exception):
await websocket.close()
if ok:
update_service.refresh_stack_local(stack_id)
@router.websocket("/ws/agent-deploy/{agent_id}/{stack_id}") @router.websocket("/ws/agent-deploy/{agent_id}/{stack_id}")
async def ws_agent_deploy( async def ws_agent_deploy(
websocket: WebSocket, websocket: WebSocket,
+42 -9
View File
@@ -387,17 +387,16 @@ async def supports_json_progress() -> bool:
return _json_progress return _json_progress
async def stream_up(stack_id: str, override: Optional[str] = None): async def _stream_phase(
"""Run `compose up -d` streaming combined output, so the deploy console can stack_id: str, args: list[str], override: Optional[str], json_progress: bool
show image-pull and container-create progress live. ):
"""One compose subcommand, streamed. Yields ``("log", line)`` per output
Yields ``("log", line)`` for each output line, then ``("done", returncode)``. line, then ``("rc", returncode)`` exactly once."""
"""
cmd = _compose_base_cmd(stack_id, override) cmd = _compose_base_cmd(stack_id, override)
if await supports_json_progress(): if json_progress:
# Global flag, must precede the subcommand. # Global flag, must precede the subcommand.
cmd += ["--progress", "json"] cmd += ["--progress", "json"]
cmd += ["up", "-d", "--remove-orphans"] cmd += args
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*cmd, *cmd,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
@@ -407,7 +406,41 @@ async def stream_up(stack_id: str, override: Optional[str] = None):
async for raw in proc.stdout: async for raw in proc.stdout:
yield ("log", raw.decode("utf-8", "replace").rstrip("\n")) yield ("log", raw.decode("utf-8", "replace").rstrip("\n"))
await proc.wait() await proc.wait()
yield ("done", proc.returncode) yield ("rc", proc.returncode)
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)``.
"""
json_progress = await supports_json_progress()
async for kind, payload in _stream_phase(
stack_id, ["up", "-d", "--remove-orphans"], override, json_progress
):
yield ("done", payload) if kind == "rc" else ("log", payload)
async def stream_update(stack_id: str, override: Optional[str] = None):
"""Run `compose pull` then `compose up -d`, streaming both phases, so the
stacks list can show real update progress instead of a spinner.
Yields ``("log", line)`` for each output line of either phase, then
``("done", returncode)`` once. A failed pull short-circuits: recreating
containers on images that never came down would only make things worse.
"""
json_progress = await supports_json_progress()
rc = 0
for args in (["pull"], ["up", "-d", "--remove-orphans"]):
async for kind, payload in _stream_phase(stack_id, args, override, json_progress):
if kind == "log":
yield ("log", payload)
else:
rc = payload
if rc != 0:
break
yield ("done", rc)
# Convenience lifecycle wrappers ------------------------------------------------ # Convenience lifecycle wrappers ------------------------------------------------
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version.""" """Single source of truth for the StackPilot release version."""
APP_VERSION = "0.41.0" APP_VERSION = "0.42.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "stackpilot-frontend", "name": "stackpilot-frontend",
"private": true, "private": true,
"version": "0.41.0", "version": "0.42.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -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", "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; } 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/…), /** Persistent status line for one stack action (start/stop/pull/update/…),
* shown in addition to the transient toast so the outcome doesn't vanish * shown in addition to the transient toast so the outcome doesn't vanish
* with it — and so a failure stays readable until acknowledged. */ * with it — and so a failure stays readable until acknowledged. */
@@ -20,8 +97,9 @@ export function ActionStatusBanner({
}) { }) {
return ( return (
<div <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]}`}
> >
<div className="flex items-center gap-2">
{status.phase === "running" && ( {status.phase === "running" && (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-accent dark:text-accent-dark" /> <Loader2 className="h-4 w-4 shrink-0 animate-spin text-accent dark:text-accent-dark" />
)} )}
@@ -43,6 +121,10 @@ export function ActionStatusBanner({
</button> </button>
)} )}
</div> </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 { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client"; import { apiErrorMessage } from "@/api/client";
import type { StackStats, StackSummary, StackUpdateInfo } from "@/types"; 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 /** 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. */ * start/stop/restart, shared by the Dashboard and the Stacks page. */
@@ -20,6 +22,8 @@ export function StacksTable({
hostMem, hostMem,
isAdmin, isAdmin,
isBusy, isBusy,
statusFor,
onDismissStatus,
loading, loading,
linkBase = "/stacks", linkBase = "/stacks",
showEdit = false, showEdit = false,
@@ -37,6 +41,9 @@ export function StacksTable({
hostMem: number; hostMem: number;
isAdmin: boolean; isAdmin: boolean;
isBusy: (id: string) => 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; loading: boolean;
linkBase?: string; linkBase?: string;
showEdit?: boolean; showEdit?: boolean;
@@ -77,6 +84,8 @@ export function StacksTable({
hostMem={hostMem} hostMem={hostMem}
isAdmin={isAdmin} isAdmin={isAdmin}
busy={isBusy(s.id)} busy={isBusy(s.id)}
status={statusFor?.(s.id)}
onDismissStatus={onDismissStatus}
linkBase={linkBase} linkBase={linkBase}
showEdit={showEdit} showEdit={showEdit}
showDelete={showDelete} showDelete={showDelete}
@@ -100,6 +109,8 @@ function StackRow({
hostMem, hostMem,
isAdmin, isAdmin,
busy, busy,
status,
onDismissStatus,
linkBase, linkBase,
showEdit, showEdit,
showDelete, showDelete,
@@ -115,6 +126,8 @@ function StackRow({
hostMem: number; hostMem: number;
isAdmin: boolean; isAdmin: boolean;
busy: boolean; busy: boolean;
status?: StackActionStatus;
onDismissStatus?: (id: string) => void;
linkBase: string; linkBase: string;
showEdit: boolean; showEdit: boolean;
showDelete: boolean; showDelete: boolean;
@@ -155,7 +168,7 @@ function StackRow({
<span className="text-xs text-slate-400"> <span className="text-xs text-slate-400">
{stack.running_count}/{stack.service_count} svc {stack.running_count}/{stack.service_count} svc
</span> </span>
{updateAvailable && ( {updateAvailable && !status && (
<span <span
title={ title={
update?.stale_images?.length update?.stale_images?.length
@@ -168,6 +181,7 @@ function StackRow({
</span> </span>
)} )}
</Link> </Link>
{status && <ActionProgressBar status={status} onDismiss={onDismissStatus} />}
</td> </td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
{running && stats ? ( {running && stats ? (
+59 -11
View File
@@ -3,6 +3,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { stacksApi } from "@/api/stacks"; import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client"; import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import { streamCompose, type ProgressSnapshot } from "@/lib/composeStream";
export type StackActionPhase = "running" | "success" | "error"; export type StackActionPhase = "running" | "success" | "error";
@@ -12,6 +14,9 @@ export type StackActionStatus = {
phase: StackActionPhase; phase: StackActionPhase;
message?: string; message?: string;
at: number; at: number;
/** Only set for streamed actions (update); plain REST actions have no
* progress to report and render an indeterminate bar instead. */
progress?: ProgressSnapshot;
}; };
/** Success rows self-clear so they don't pile up; errors stay until dismissed /** Success rows self-clear so they don't pile up; errors stay until dismissed
@@ -25,9 +30,13 @@ const SUCCESS_CLEAR_MS = 5000;
* Update on several rows), so both busy state and status are keyed by stack id * Update on several rows), so both busy state and status are keyed by stack id
* rather than held as a single value — otherwise the first action to finish * rather than held as a single value — otherwise the first action to finish
* would clear the other rows' spinners and status. * would clear the other rows' spinners and status.
*
* Update runs over `/ws/update/{id}` rather than the REST endpoint, because
* only the stream carries the per-layer pull progress the row's bar shows.
*/ */
export function useStackActions() { export function useStackActions() {
const qc = useQueryClient(); const qc = useQueryClient();
const token = useAuthStore((s) => s.accessToken);
const [busy, setBusy] = useState<Record<string, true>>({}); const [busy, setBusy] = useState<Record<string, true>>({});
const [statusMap, setStatusMap] = useState<Record<string, StackActionStatus>>({}); const [statusMap, setStatusMap] = useState<Record<string, StackActionStatus>>({});
const timers = useRef<Record<string, number>>({}); const timers = useRef<Record<string, number>>({});
@@ -39,19 +48,14 @@ export function useStackActions() {
[] []
); );
const run = async ( const begin = (id: string, label: string) => {
id: string,
label: string,
fn: (id: string) => Promise<unknown>
) => {
window.clearTimeout(timers.current[id]); window.clearTimeout(timers.current[id]);
delete timers.current[id]; delete timers.current[id];
setBusy((b) => ({ ...b, [id]: true })); setBusy((b) => ({ ...b, [id]: true }));
setStatusMap((s) => ({ ...s, [id]: { id, label, phase: "running", at: Date.now() } })); setStatusMap((s) => ({ ...s, [id]: { id, label, phase: "running", at: Date.now() } }));
const t = toast.loading(`${label} ${id}`); };
try {
await fn(id); const succeed = (id: string, label: string) => {
toast.success(`${label} ${id}`, { id: t });
setStatusMap((s) => ({ ...s, [id]: { id, label, phase: "success", at: Date.now() } })); setStatusMap((s) => ({ ...s, [id]: { id, label, phase: "success", at: Date.now() } }));
timers.current[id] = window.setTimeout(() => { timers.current[id] = window.setTimeout(() => {
delete timers.current[id]; delete timers.current[id];
@@ -60,10 +64,47 @@ export function useStackActions() {
qc.invalidateQueries({ queryKey: ["stacks"] }); qc.invalidateQueries({ queryKey: ["stacks"] });
qc.invalidateQueries({ queryKey: ["stack", id] }); qc.invalidateQueries({ queryKey: ["stack", id] });
qc.invalidateQueries({ queryKey: ["stack-updates"] }); qc.invalidateQueries({ queryKey: ["stack-updates"] });
};
const fail = (id: string, label: string, message: string) =>
setStatusMap((s) => ({
...s,
[id]: { id, label, phase: "error", message, at: Date.now() },
}));
const run = async (
id: string,
label: string,
fn: (id: string) => Promise<unknown>
) => {
begin(id, label);
const t = toast.loading(`${label} ${id}`);
try {
await fn(id);
toast.success(`${label} ${id}`, { id: t });
succeed(id, label);
} catch (err) { } catch (err) {
const message = apiErrorMessage(err); const message = apiErrorMessage(err);
toast.error(message, { id: t }); toast.error(message, { id: t });
setStatusMap((s) => ({ ...s, [id]: { id, label, phase: "error", message, at: Date.now() } })); fail(id, label, message);
} finally {
setBusy((b) => omit(b, id));
}
};
const runStreamed = async (id: string, label: string, path: string) => {
begin(id, label);
const t = toast.loading(`${label} ${id}`);
try {
await streamCompose(path, token!, (progress) =>
setStatusMap((s) => (s[id] ? { ...s, [id]: { ...s[id], progress } } : s))
);
toast.success(`${label} ${id}`, { id: t });
succeed(id, label);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
toast.error(`${label} ${id} failed: ${message}`, { id: t });
fail(id, label, message);
} finally { } finally {
setBusy((b) => omit(b, id)); setBusy((b) => omit(b, id));
} }
@@ -76,6 +117,7 @@ export function useStackActions() {
); );
const isBusy = useCallback((id: string) => busy[id] === true, [busy]); const isBusy = useCallback((id: string) => busy[id] === true, [busy]);
const statusFor = useCallback((id: string) => statusMap[id], [statusMap]);
const dismissStatus = useCallback((id: string) => { const dismissStatus = useCallback((id: string) => {
window.clearTimeout(timers.current[id]); window.clearTimeout(timers.current[id]);
@@ -86,12 +128,18 @@ export function useStackActions() {
return { return {
isBusy, isBusy,
statuses, statuses,
statusFor,
dismissStatus, dismissStatus,
start: (id: string) => run(id, "Starting", stacksApi.start), start: (id: string) => run(id, "Starting", stacksApi.start),
stop: (id: string) => run(id, "Stopping", stacksApi.stop), stop: (id: string) => run(id, "Stopping", stacksApi.stop),
restart: (id: string) => run(id, "Restarting", stacksApi.restart), restart: (id: string) => run(id, "Restarting", stacksApi.restart),
pull: (id: string) => run(id, "Pulling", stacksApi.pull), pull: (id: string) => run(id, "Pulling", stacksApi.pull),
updateImages: (id: string) => run(id, "Updating", stacksApi.update_images), // Without a token the socket can't authenticate — fall back to the REST
// endpoint, which still updates, just without progress.
updateImages: (id: string) =>
token
? runStreamed(id, "Updating", `/ws/update/${id}`)
: run(id, "Updating", stacksApi.update_images),
down: (id: string) => run(id, "Tearing down", stacksApi.down), down: (id: string) => run(id, "Tearing down", stacksApi.down),
}; };
} }
+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"));
});
}
+3 -4
View File
@@ -5,7 +5,6 @@ import { toast } from "sonner";
import { Card } from "@/components/ui"; import { Card } from "@/components/ui";
import { HostHeader } from "@/components/hosts/HostHeader"; import { HostHeader } from "@/components/hosts/HostHeader";
import { StacksTable } from "@/components/stacks/StacksTable"; import { StacksTable } from "@/components/stacks/StacksTable";
import { ActionStatusList } from "@/components/stacks/ActionStatusBanner";
import { AttentionStrip } from "@/components/dashboard/AttentionStrip"; import { AttentionStrip } from "@/components/dashboard/AttentionStrip";
import { FleetKpiRow } from "@/components/dashboard/FleetKpiRow"; import { FleetKpiRow } from "@/components/dashboard/FleetKpiRow";
import { StackStatusBar } from "@/components/dashboard/StackStatusBar"; import { StackStatusBar } from "@/components/dashboard/StackStatusBar";
@@ -22,7 +21,7 @@ import type { Agent } from "@/types";
export function Dashboard() { export function Dashboard() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin"); const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const { isBusy, statuses, dismissStatus, start, stop, restart } = useStackActions(); const { isBusy, statusFor, dismissStatus, start, stop, restart } = useStackActions();
const qc = useQueryClient(); const qc = useQueryClient();
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
@@ -113,8 +112,6 @@ export function Dashboard() {
</> </>
)} )}
<ActionStatusList statuses={statuses} onDismiss={dismissStatus} />
{/* ---- Local host stacks ---- */} {/* ---- Local host stacks ---- */}
<section> <section>
{hasAgents ? <HostHeader /> : <h2 className="sp-label mb-3">This host</h2>} {hasAgents ? <HostHeader /> : <h2 className="sp-label mb-3">This host</h2>}
@@ -125,6 +122,8 @@ export function Dashboard() {
hostMem={info.data?.ram.total ?? 0} hostMem={info.data?.ram.total ?? 0}
isAdmin={isAdmin} isAdmin={isAdmin}
isBusy={isBusy} isBusy={isBusy}
statusFor={statusFor}
onDismissStatus={dismissStatus}
loading={stacks.isLoading} loading={stacks.isLoading}
onStart={start} onStart={start}
onStop={stop} onStop={stop}
+3 -4
View File
@@ -4,7 +4,6 @@ import { useQuery } from "@tanstack/react-query";
import { Plus, Search, HardDrive } from "lucide-react"; import { Plus, Search, HardDrive } from "lucide-react";
import { Button, Input } from "@/components/ui"; import { Button, Input } from "@/components/ui";
import { StacksTable } from "@/components/stacks/StacksTable"; import { StacksTable } from "@/components/stacks/StacksTable";
import { ActionStatusList } from "@/components/stacks/ActionStatusBanner";
import { RestoreButton } from "@/components/stacks/BackupRestore"; import { RestoreButton } from "@/components/stacks/BackupRestore";
import { AgentStacksSection } from "@/components/stacks/AgentStacksSection"; import { AgentStacksSection } from "@/components/stacks/AgentStacksSection";
import { stacksApi } from "@/api/stacks"; import { stacksApi } from "@/api/stacks";
@@ -24,7 +23,7 @@ const STATUS_FILTERS: Record<string, StatusFilter> = {
export function Stacks() { export function Stacks() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin"); const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const { isBusy, statuses, dismissStatus, start, stop, restart, updateImages } = const { isBusy, statusFor, dismissStatus, start, stop, restart, updateImages } =
useStackActions(); useStackActions();
// The dashboard explore bar deep-links here with ?q= / ?filter=. // The dashboard explore bar deep-links here with ?q= / ?filter=.
const [params] = useSearchParams(); const [params] = useSearchParams();
@@ -125,8 +124,6 @@ export function Stacks() {
)} )}
</div> </div>
<ActionStatusList statuses={statuses} onDismiss={dismissStatus} />
<section> <section>
{hasAgents && ( {hasAgents && (
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500"> <h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
@@ -141,6 +138,8 @@ export function Stacks() {
hostMem={info.data?.ram.total ?? 0} hostMem={info.data?.ram.total ?? 0}
isAdmin={isAdmin} isAdmin={isAdmin}
isBusy={isBusy} isBusy={isBusy}
statusFor={statusFor}
onDismissStatus={dismissStatus}
loading={isLoading} loading={isLoading}
showEdit showEdit
showDelete showDelete