diff --git a/frontend/src/components/stacks/ActionStatusBanner.tsx b/frontend/src/components/stacks/ActionStatusBanner.tsx
new file mode 100644
index 0000000..1f1f60e
--- /dev/null
+++ b/frontend/src/components/stacks/ActionStatusBanner.tsx
@@ -0,0 +1,43 @@
+import { CheckCircle2, Loader2, X, XCircle } from "lucide-react";
+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",
+} 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. */
+export function ActionStatusBanner({
+ status,
+ onDismiss,
+}: {
+ status: StackActionStatus;
+ onDismiss: () => void;
+}) {
+ 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" && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/hooks/useStackActions.ts b/frontend/src/hooks/useStackActions.ts
index 070a868..65d9d7e 100644
--- a/frontend/src/hooks/useStackActions.ts
+++ b/frontend/src/hooks/useStackActions.ts
@@ -1,28 +1,52 @@
-import { useState } from "react";
+import { useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client";
+export type StackActionPhase = "running" | "success" | "error";
+
+export type StackActionStatus = {
+ id: string;
+ label: string;
+ phase: StackActionPhase;
+ message?: string;
+ at: number;
+};
+
+/** Success banners self-clear so they don't linger forever; errors stay until
+ * dismissed or the next action overwrites them. */
+const SUCCESS_CLEAR_MS = 5000;
+
export function useStackActions() {
const qc = useQueryClient();
const [busyId, setBusyId] = useState(null);
+ const [status, setStatus] = useState(null);
+ const clearTimer = useRef();
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() });
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));
+ }, SUCCESS_CLEAR_MS);
qc.invalidateQueries({ queryKey: ["stacks"] });
qc.invalidateQueries({ queryKey: ["stack", id] });
qc.invalidateQueries({ queryKey: ["stack-updates"] });
} catch (err) {
- toast.error(apiErrorMessage(err), { id: t });
+ const message = apiErrorMessage(err);
+ toast.error(message, { id: t });
+ setStatus({ id, label, phase: "error", message, at: Date.now() });
} finally {
setBusyId(null);
}
@@ -30,6 +54,11 @@ export function useStackActions() {
return {
busyId,
+ status,
+ dismissStatus: () => {
+ window.clearTimeout(clearTimer.current);
+ setStatus(null);
+ },
start: (id: string) => run(id, "Starting", stacksApi.start),
stop: (id: string) => run(id, "Stopping", stacksApi.stop),
restart: (id: string) => run(id, "Restarting", stacksApi.restart),
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 2f30f97..7214f49 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -13,7 +13,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
-
+
);
diff --git a/frontend/src/pages/StackDetail.tsx b/frontend/src/pages/StackDetail.tsx
index 38cfa76..6e3b111 100644
--- a/frontend/src/pages/StackDetail.tsx
+++ b/frontend/src/pages/StackDetail.tsx
@@ -17,6 +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 { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
import { SecretsPanel } from "@/components/stacks/SecretsPanel";
import { BackupButton } from "@/components/stacks/BackupRestore";
@@ -91,6 +92,10 @@ export function StackDetail() {
)}
+ {actions.status && (
+
+ )}
+
{/* Tabs */}
{TABS.map((t) => (