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
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
View File
@@ -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,
+42 -9
View File
@@ -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
View File
@@ -1,3 +1,3 @@
"""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",
"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>
);
+15 -1
View File
@@ -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 ? (
+62 -14
View File
@@ -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),
};
}
+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 { 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}
+3 -4
View File
@@ -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