diff --git a/README.md b/README.md index 047227c..b48897e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/routers/ws.py b/backend/routers/ws.py index f5659fb..d103d80 100644 --- a/backend/routers/ws.py +++ b/backend/routers/ws.py @@ -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, diff --git a/backend/services/compose_service.py b/backend/services/compose_service.py index 909676c..888f643 100644 --- a/backend/services/compose_service.py +++ b/backend/services/compose_service.py @@ -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 ------------------------------------------------ diff --git a/backend/version.py b/backend/version.py index 2438dc6..c5d3265 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.41.0" +APP_VERSION = "0.42.0" diff --git a/frontend/package.json b/frontend/package.json index 54804db..44675dc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.41.0", + "version": "0.42.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/stacks/ActionStatusBanner.tsx b/frontend/src/components/stacks/ActionStatusBanner.tsx index 6ec9393..fba151a 100644 --- a/frontend/src/components/stacks/ActionStatusBanner.tsx +++ b/frontend/src/components/stacks/ActionStatusBanner.tsx @@ -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 ( +
+
+ {indeterminate ? ( +
+ ) : ( +
+ )} +
+
+ + {label} + + {!done && !indeterminate && ( + {Math.round(pct)}% + )} + {detail && ( + + {detail} + + )} + {done && onDismiss && ( + + )} +
+
+ ); +} + /** 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 (
- {status.phase === "running" && ( - - )} - {status.phase === "success" && } - {status.phase === "error" && } - - {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}` : ""}`} - - {status.phase !== "running" && ( - +
+ {status.phase === "running" && ( + + )} + {status.phase === "success" && } + {status.phase === "error" && } + + {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}` : ""}`} + + {status.phase !== "running" && ( + + )} +
+ {status.phase === "running" && status.progress && ( + )}
); diff --git a/frontend/src/components/stacks/StacksTable.tsx b/frontend/src/components/stacks/StacksTable.tsx index 10d7abe..8d61712 100644 --- a/frontend/src/components/stacks/StacksTable.tsx +++ b/frontend/src/components/stacks/StacksTable.tsx @@ -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({ {stack.running_count}/{stack.service_count} svc - {updateAvailable && ( + {updateAvailable && !status && ( )} + {status && } {running && stats ? ( diff --git a/frontend/src/hooks/useStackActions.ts b/frontend/src/hooks/useStackActions.ts index 41dd838..7e9fbf3 100644 --- a/frontend/src/hooks/useStackActions.ts +++ b/frontend/src/hooks/useStackActions.ts @@ -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>({}); const [statusMap, setStatusMap] = useState>({}); const timers = useRef>({}); @@ -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 ) => { - 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), }; } diff --git a/frontend/src/lib/composeStream.ts b/frontend/src/lib/composeStream.ts new file mode 100644 index 0000000..9159ced --- /dev/null +++ b/frontend/src/lib/composeStream.ts @@ -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 { + 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")); + }); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index d23a5aa..faf7f21 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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() { )} - - {/* ---- Local host stacks ---- */}
{hasAgents ? :

This host

} @@ -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} diff --git a/frontend/src/pages/Stacks.tsx b/frontend/src/pages/Stacks.tsx index ecd6003..30b5236 100644 --- a/frontend/src/pages/Stacks.tsx +++ b/frontend/src/pages/Stacks.tsx @@ -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 = { 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() { )}
- -
{hasAgents && (

@@ -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