From 9d28e12cd7eb33526683709bd06bc568c2042868 Mon Sep 17 00:00:00 2001 From: menzelj Date: Sun, 30 Aug 2026 20:04:10 +0200 Subject: [PATCH] Add persistent action status banner and fix stacked toast overlap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stack actions (start/stop/pull/update/…) now surface a dismissible status banner on the stack detail page instead of relying on the transient top-right toast alone. Also enable toast expand mode so multiple notifications no longer collapse behind each other. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016pMmFFkdfxkoYjcEcpZTa5 --- .../components/stacks/ActionStatusBanner.tsx | 43 +++++++++++++++++++ frontend/src/hooks/useStackActions.ts | 33 +++++++++++++- frontend/src/main.tsx | 2 +- frontend/src/pages/StackDetail.tsx | 5 +++ 4 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/stacks/ActionStatusBanner.tsx 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) => (