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:
@@ -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
|
||||
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.
|
||||
- **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` →
|
||||
compose** converter.
|
||||
- **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/update POST /api/system/update (self-update)
|
||||
WS /ws/logs/{stack_id}[/{service}] WS /ws/events
|
||||
WS /ws/deploy/{stack_id} WS /ws/update/{stack_id}
|
||||
```
|
||||
|
||||
### Phase 2 endpoints
|
||||
|
||||
+68
-2
@@ -16,8 +16,14 @@ from sqlmodel import Session
|
||||
from auth import decode_token
|
||||
from database import engine
|
||||
from models.agent import Agent
|
||||
from models.setting import EVENT_STACK_ERROR, EVENT_STACK_START
|
||||
from services import audit_service, compose_service, exec_service, notify_service
|
||||
from models.setting import EVENT_PULL_FAILED, EVENT_STACK_ERROR, EVENT_STACK_START
|
||||
from services import (
|
||||
audit_service,
|
||||
compose_service,
|
||||
exec_service,
|
||||
notify_service,
|
||||
update_service,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("stackpilot.ws")
|
||||
|
||||
@@ -241,6 +247,66 @@ async def ws_agent_logs(
|
||||
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}")
|
||||
async def ws_agent_deploy(
|
||||
websocket: WebSocket,
|
||||
|
||||
@@ -387,17 +387,16 @@ async def supports_json_progress() -> bool:
|
||||
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)``.
|
||||
"""
|
||||
async def _stream_phase(
|
||||
stack_id: str, args: list[str], override: Optional[str], json_progress: bool
|
||||
):
|
||||
"""One compose subcommand, streamed. Yields ``("log", line)`` per output
|
||||
line, then ``("rc", returncode)`` exactly once."""
|
||||
cmd = _compose_base_cmd(stack_id, override)
|
||||
if await supports_json_progress():
|
||||
if json_progress:
|
||||
# Global flag, must precede the subcommand.
|
||||
cmd += ["--progress", "json"]
|
||||
cmd += ["up", "-d", "--remove-orphans"]
|
||||
cmd += args
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
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:
|
||||
yield ("log", raw.decode("utf-8", "replace").rstrip("\n"))
|
||||
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 ------------------------------------------------
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the StackPilot release version."""
|
||||
|
||||
APP_VERSION = "0.41.0"
|
||||
APP_VERSION = "0.42.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.41.0",
|
||||
"version": "0.42.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
} 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 ? (
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { streamCompose, type ProgressSnapshot } from "@/lib/composeStream";
|
||||
|
||||
export type StackActionPhase = "running" | "success" | "error";
|
||||
|
||||
@@ -12,6 +14,9 @@ export type StackActionStatus = {
|
||||
phase: StackActionPhase;
|
||||
message?: string;
|
||||
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
|
||||
@@ -25,9 +30,13 @@ const SUCCESS_CLEAR_MS = 5000;
|
||||
* 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
|
||||
* 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() {
|
||||
const qc = useQueryClient();
|
||||
const token = useAuthStore((s) => s.accessToken);
|
||||
const [busy, setBusy] = useState<Record<string, true>>({});
|
||||
const [statusMap, setStatusMap] = useState<Record<string, StackActionStatus>>({});
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
@@ -39,31 +48,63 @@ export function useStackActions() {
|
||||
[]
|
||||
);
|
||||
|
||||
const begin = (id: string, label: string) => {
|
||||
window.clearTimeout(timers.current[id]);
|
||||
delete timers.current[id];
|
||||
setBusy((b) => ({ ...b, [id]: true }));
|
||||
setStatusMap((s) => ({ ...s, [id]: { id, label, phase: "running", at: Date.now() } }));
|
||||
};
|
||||
|
||||
const succeed = (id: string, label: string) => {
|
||||
setStatusMap((s) => ({ ...s, [id]: { id, label, phase: "success", at: Date.now() } }));
|
||||
timers.current[id] = window.setTimeout(() => {
|
||||
delete timers.current[id];
|
||||
setStatusMap((s) => (s[id]?.phase === "success" ? omit(s, id) : s));
|
||||
}, SUCCESS_CLEAR_MS);
|
||||
qc.invalidateQueries({ queryKey: ["stacks"] });
|
||||
qc.invalidateQueries({ queryKey: ["stack", id] });
|
||||
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>
|
||||
) => {
|
||||
window.clearTimeout(timers.current[id]);
|
||||
delete timers.current[id];
|
||||
setBusy((b) => ({ ...b, [id]: true }));
|
||||
setStatusMap((s) => ({ ...s, [id]: { id, label, phase: "running", at: Date.now() } }));
|
||||
begin(id, label);
|
||||
const t = toast.loading(`${label} ${id}…`);
|
||||
try {
|
||||
await fn(id);
|
||||
toast.success(`${label} ${id} ✓`, { id: t });
|
||||
setStatusMap((s) => ({ ...s, [id]: { id, label, phase: "success", at: Date.now() } }));
|
||||
timers.current[id] = window.setTimeout(() => {
|
||||
delete timers.current[id];
|
||||
setStatusMap((s) => (s[id]?.phase === "success" ? omit(s, id) : s));
|
||||
}, SUCCESS_CLEAR_MS);
|
||||
qc.invalidateQueries({ queryKey: ["stacks"] });
|
||||
qc.invalidateQueries({ queryKey: ["stack", id] });
|
||||
qc.invalidateQueries({ queryKey: ["stack-updates"] });
|
||||
succeed(id, label);
|
||||
} catch (err) {
|
||||
const message = apiErrorMessage(err);
|
||||
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 {
|
||||
setBusy((b) => omit(b, id));
|
||||
}
|
||||
@@ -76,6 +117,7 @@ export function useStackActions() {
|
||||
);
|
||||
|
||||
const isBusy = useCallback((id: string) => busy[id] === true, [busy]);
|
||||
const statusFor = useCallback((id: string) => statusMap[id], [statusMap]);
|
||||
|
||||
const dismissStatus = useCallback((id: string) => {
|
||||
window.clearTimeout(timers.current[id]);
|
||||
@@ -86,12 +128,18 @@ export function useStackActions() {
|
||||
return {
|
||||
isBusy,
|
||||
statuses,
|
||||
statusFor,
|
||||
dismissStatus,
|
||||
start: (id: string) => run(id, "Starting", stacksApi.start),
|
||||
stop: (id: string) => run(id, "Stopping", stacksApi.stop),
|
||||
restart: (id: string) => run(id, "Restarting", stacksApi.restart),
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { toast } from "sonner";
|
||||
import { Card } from "@/components/ui";
|
||||
import { HostHeader } from "@/components/hosts/HostHeader";
|
||||
import { StacksTable } from "@/components/stacks/StacksTable";
|
||||
import { ActionStatusList } from "@/components/stacks/ActionStatusBanner";
|
||||
import { AttentionStrip } from "@/components/dashboard/AttentionStrip";
|
||||
import { FleetKpiRow } from "@/components/dashboard/FleetKpiRow";
|
||||
import { StackStatusBar } from "@/components/dashboard/StackStatusBar";
|
||||
@@ -22,7 +21,7 @@ import type { Agent } from "@/types";
|
||||
|
||||
export function Dashboard() {
|
||||
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 [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
@@ -113,8 +112,6 @@ export function Dashboard() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<ActionStatusList statuses={statuses} onDismiss={dismissStatus} />
|
||||
|
||||
{/* ---- Local host stacks ---- */}
|
||||
<section>
|
||||
{hasAgents ? <HostHeader /> : <h2 className="sp-label mb-3">This host</h2>}
|
||||
@@ -125,6 +122,8 @@ export function Dashboard() {
|
||||
hostMem={info.data?.ram.total ?? 0}
|
||||
isAdmin={isAdmin}
|
||||
isBusy={isBusy}
|
||||
statusFor={statusFor}
|
||||
onDismissStatus={dismissStatus}
|
||||
loading={stacks.isLoading}
|
||||
onStart={start}
|
||||
onStop={stop}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Plus, Search, HardDrive } from "lucide-react";
|
||||
import { Button, Input } from "@/components/ui";
|
||||
import { StacksTable } from "@/components/stacks/StacksTable";
|
||||
import { ActionStatusList } from "@/components/stacks/ActionStatusBanner";
|
||||
import { RestoreButton } from "@/components/stacks/BackupRestore";
|
||||
import { AgentStacksSection } from "@/components/stacks/AgentStacksSection";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
@@ -24,7 +23,7 @@ const STATUS_FILTERS: Record<string, StatusFilter> = {
|
||||
|
||||
export function Stacks() {
|
||||
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();
|
||||
// The dashboard explore bar deep-links here with ?q= / ?filter=.
|
||||
const [params] = useSearchParams();
|
||||
@@ -125,8 +124,6 @@ export function Stacks() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ActionStatusList statuses={statuses} onDismiss={dismissStatus} />
|
||||
|
||||
<section>
|
||||
{hasAgents && (
|
||||
<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}
|
||||
isAdmin={isAdmin}
|
||||
isBusy={isBusy}
|
||||
statusFor={statusFor}
|
||||
onDismissStatus={dismissStatus}
|
||||
loading={isLoading}
|
||||
showEdit
|
||||
showDelete
|
||||
|
||||
Reference in New Issue
Block a user