diff --git a/backend/version.py b/backend/version.py
index c57694b..2438dc6 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.40.2"
+APP_VERSION = "0.41.0"
diff --git a/frontend/package.json b/frontend/package.json
index a8a22c2..54804db 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
- "version": "0.40.2",
+ "version": "0.41.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/components/stacks/ActionStatusBanner.tsx b/frontend/src/components/stacks/ActionStatusBanner.tsx
index 1f1f60e..6ec9393 100644
--- a/frontend/src/components/stacks/ActionStatusBanner.tsx
+++ b/frontend/src/components/stacks/ActionStatusBanner.tsx
@@ -4,20 +4,24 @@ import type { StackActionStatus } from "@/hooks/useStackActions";
const TONE = {
running: "border-sp-border bg-sp-surface-2 text-sp-text-1",
success: "border-sp-green/30 bg-sp-green/10 text-sp-green",
- error: "border-red-300 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300",
+ error:
+ "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;
-/** Persistent status line for a stack action (start/stop/pull/update/…), shown
- * in addition to the transient toast so the outcome doesn't disappear with it. */
+/** 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. */
export function ActionStatusBanner({
status,
onDismiss,
}: {
status: StackActionStatus;
- onDismiss: () => void;
+ onDismiss: (id: string) => void;
}) {
return (
-
+
{status.phase === "running" && (
)}
@@ -31,7 +35,7 @@ export function ActionStatusBanner({
{status.phase !== "running" && (
);
}
+
+/** Stacked banners, one per stack currently being acted on. Renders nothing
+ * when idle. */
+export function ActionStatusList({
+ statuses,
+ onDismiss,
+}: {
+ statuses: StackActionStatus[];
+ onDismiss: (id: string) => void;
+}) {
+ if (statuses.length === 0) return null;
+ return (
+
+ {statuses.map((s) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/stacks/AgentStacksSection.tsx b/frontend/src/components/stacks/AgentStacksSection.tsx
index 74801c6..00c9c01 100644
--- a/frontend/src/components/stacks/AgentStacksSection.tsx
+++ b/frontend/src/components/stacks/AgentStacksSection.tsx
@@ -83,7 +83,7 @@ export function AgentStacksSection({ agent, isAdmin }: { agent: Agent; isAdmin:
hostCpus={sys.data?.cpu_cores ?? 0}
hostMem={sys.data?.mem_total ?? 0}
isAdmin={isAdmin}
- busyId={busyId}
+ isBusy={(id) => busyId === id}
loading={stacks.isLoading}
linkBase={`/hosts/${agent.id}/stacks`}
onStart={(id) => run("start", "Starting", id)}
diff --git a/frontend/src/components/stacks/StacksTable.tsx b/frontend/src/components/stacks/StacksTable.tsx
index 89888b1..10d7abe 100644
--- a/frontend/src/components/stacks/StacksTable.tsx
+++ b/frontend/src/components/stacks/StacksTable.tsx
@@ -19,7 +19,7 @@ export function StacksTable({
hostCpus,
hostMem,
isAdmin,
- busyId,
+ isBusy,
loading,
linkBase = "/stacks",
showEdit = false,
@@ -36,7 +36,7 @@ export function StacksTable({
hostCpus: number;
hostMem: number;
isAdmin: boolean;
- busyId: string | null;
+ isBusy: (id: string) => boolean;
loading: boolean;
linkBase?: string;
showEdit?: boolean;
@@ -76,7 +76,7 @@ export function StacksTable({
hostCpus={hostCpus}
hostMem={hostMem}
isAdmin={isAdmin}
- busy={busyId === s.id}
+ busy={isBusy(s.id)}
linkBase={linkBase}
showEdit={showEdit}
showDelete={showDelete}
diff --git a/frontend/src/hooks/useStackActions.ts b/frontend/src/hooks/useStackActions.ts
index 65d9d7e..41dd838 100644
--- a/frontend/src/hooks/useStackActions.ts
+++ b/frontend/src/hooks/useStackActions.ts
@@ -1,4 +1,4 @@
-import { useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { stacksApi } from "@/api/stacks";
@@ -14,31 +14,48 @@ export type StackActionStatus = {
at: number;
};
-/** Success banners self-clear so they don't linger forever; errors stay until
- * dismissed or the next action overwrites them. */
+/** Success rows self-clear so they don't pile up; errors stay until dismissed
+ * or until the same stack is acted on again. */
const SUCCESS_CLEAR_MS = 5000;
+/**
+ * Runs stack lifecycle actions and tracks their progress.
+ *
+ * Actions on different stacks run concurrently (the stacks list lets you hit
+ * 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.
+ */
export function useStackActions() {
const qc = useQueryClient();
- const [busyId, setBusyId] = useState
(null);
- const [status, setStatus] = useState(null);
- const clearTimer = useRef();
+ const [busy, setBusy] = useState>({});
+ const [statusMap, setStatusMap] = useState>({});
+ const timers = useRef>({});
+
+ useEffect(
+ () => () => {
+ Object.values(timers.current).forEach(window.clearTimeout);
+ },
+ []
+ );
const run = async (
id: string,
label: string,
fn: (id: string) => Promise
) => {
- window.clearTimeout(clearTimer.current);
- setBusyId(id);
- setStatus({ id, label, phase: "running", at: Date.now() });
+ 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 t = toast.loading(`${label} ${id}…`);
try {
await fn(id);
toast.success(`${label} ${id} ✓`, { id: t });
- setStatus({ id, label, phase: "success", at: Date.now() });
- clearTimer.current = window.setTimeout(() => {
- setStatus((s) => (s?.phase === "success" ? null : s));
+ 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] });
@@ -46,19 +63,30 @@ export function useStackActions() {
} catch (err) {
const message = apiErrorMessage(err);
toast.error(message, { id: t });
- setStatus({ id, label, phase: "error", message, at: Date.now() });
+ setStatusMap((s) => ({ ...s, [id]: { id, label, phase: "error", message, at: Date.now() } }));
} finally {
- setBusyId(null);
+ setBusy((b) => omit(b, id));
}
};
+ // Oldest first, so a row doesn't jump around as other actions finish.
+ const statuses = useMemo(
+ () => Object.values(statusMap).sort((a, b) => a.at - b.at),
+ [statusMap]
+ );
+
+ const isBusy = useCallback((id: string) => busy[id] === true, [busy]);
+
+ const dismissStatus = useCallback((id: string) => {
+ window.clearTimeout(timers.current[id]);
+ delete timers.current[id];
+ setStatusMap((s) => omit(s, id));
+ }, []);
+
return {
- busyId,
- status,
- dismissStatus: () => {
- window.clearTimeout(clearTimer.current);
- setStatus(null);
- },
+ isBusy,
+ statuses,
+ 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),
@@ -67,3 +95,8 @@ export function useStackActions() {
down: (id: string) => run(id, "Tearing down", stacksApi.down),
};
}
+
+function omit(obj: Record, key: string): Record {
+ const { [key]: _dropped, ...rest } = obj;
+ return rest;
+}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index 3521d49..d23a5aa 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -5,6 +5,7 @@ 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";
@@ -21,7 +22,7 @@ import type { Agent } from "@/types";
export function Dashboard() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
- const { busyId, start, stop, restart } = useStackActions();
+ const { isBusy, statuses, dismissStatus, start, stop, restart } = useStackActions();
const qc = useQueryClient();
const [refreshing, setRefreshing] = useState(false);
@@ -112,6 +113,8 @@ export function Dashboard() {
>
)}
+
+
{/* ---- Local host stacks ---- */}
{hasAgents ? : This host
}
@@ -121,7 +124,7 @@ export function Dashboard() {
hostCpus={info.data?.cpu_cores ?? 0}
hostMem={info.data?.ram.total ?? 0}
isAdmin={isAdmin}
- busyId={busyId}
+ isBusy={isBusy}
loading={stacks.isLoading}
onStart={start}
onStop={stop}
@@ -222,7 +225,7 @@ function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: bool
hostCpus={sys.data?.cpu_cores ?? 0}
hostMem={sys.data?.mem_total ?? 0}
isAdmin={isAdmin}
- busyId={busyId}
+ isBusy={(id) => busyId === id}
loading={stacks.isLoading}
linkBase={`/hosts/${agent.id}/stacks`}
onStart={(id) => run("start", "Starting", id)}
diff --git a/frontend/src/pages/StackDetail.tsx b/frontend/src/pages/StackDetail.tsx
index 6e3b111..d1f849d 100644
--- a/frontend/src/pages/StackDetail.tsx
+++ b/frontend/src/pages/StackDetail.tsx
@@ -17,7 +17,7 @@ import { Badge, Button, Card, Input, Spinner, StatusDot } from "@/components/ui"
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { LogViewer } from "@/components/stacks/LogViewer";
import { ContainerCard } from "@/components/stacks/ContainerCard";
-import { ActionStatusBanner } from "@/components/stacks/ActionStatusBanner";
+import { ActionStatusList } from "@/components/stacks/ActionStatusBanner";
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
import { SecretsPanel } from "@/components/stacks/SecretsPanel";
import { BackupButton } from "@/components/stacks/BackupRestore";
@@ -45,7 +45,7 @@ export function StackDetail() {
});
if (isLoading || !data) return ;
- const busy = actions.busyId === id;
+ const busy = actions.isBusy(id);
return (
@@ -92,9 +92,7 @@ export function StackDetail() {
)}
- {actions.status && (
-
- )}
+
{/* Tabs */}
diff --git a/frontend/src/pages/Stacks.tsx b/frontend/src/pages/Stacks.tsx
index 8ebe5a3..ecd6003 100644
--- a/frontend/src/pages/Stacks.tsx
+++ b/frontend/src/pages/Stacks.tsx
@@ -4,6 +4,7 @@ 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";
@@ -23,7 +24,8 @@ const STATUS_FILTERS: Record = {
export function Stacks() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
- const { busyId, start, stop, restart, updateImages } = useStackActions();
+ const { isBusy, statuses, dismissStatus, start, stop, restart, updateImages } =
+ useStackActions();
// The dashboard explore bar deep-links here with ?q= / ?filter=.
const [params] = useSearchParams();
const [q, setQ] = useState(params.get("q") ?? "");
@@ -123,6 +125,8 @@ export function Stacks() {
)}
+
+
{hasAgents && (
@@ -136,7 +140,7 @@ export function Stacks() {
hostCpus={info.data?.cpu_cores ?? 0}
hostMem={info.data?.ram.total ?? 0}
isAdmin={isAdmin}
- busyId={busyId}
+ isBusy={isBusy}
loading={isLoading}
showEdit
showDelete