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 && (
+ onDismiss(status.id)}
+ className="ml-auto shrink-0 opacity-60 transition-opacity hover:opacity-100"
+ aria-label="Dismiss"
+ >
+
+
+ )}
+
+
+ );
+}
+
/** 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" && (
-
onDismiss(status.id)}
- className="shrink-0 opacity-60 transition-opacity hover:opacity-100"
- aria-label="Dismiss"
- >
-
-
+
+ {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" && (
+ onDismiss(status.id)}
+ className="shrink-0 opacity-60 transition-opacity hover:opacity-100"
+ aria-label="Dismiss"
+ >
+
+
+ )}
+
+ {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