Add persistent action status banner and fix stacked toast overlap
CI / build-and-push (push) Successful in 1m54s
CI / build-and-push (push) Successful in 1m54s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016pMmFFkdfxkoYjcEcpZTa5
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d81c48a5c0
commit
9d28e12cd7
@@ -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 (
|
||||||
|
<div className={`flex items-center gap-2 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}
|
||||||
|
className="shrink-0 opacity-60 transition-opacity hover:opacity-100"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +1,52 @@
|
|||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
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() {
|
export function useStackActions() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState<StackActionStatus | null>(null);
|
||||||
|
const clearTimer = useRef<number>();
|
||||||
|
|
||||||
const run = async (
|
const run = async (
|
||||||
id: string,
|
id: string,
|
||||||
label: string,
|
label: string,
|
||||||
fn: (id: string) => Promise<unknown>
|
fn: (id: string) => Promise<unknown>
|
||||||
) => {
|
) => {
|
||||||
|
window.clearTimeout(clearTimer.current);
|
||||||
setBusyId(id);
|
setBusyId(id);
|
||||||
|
setStatus({ id, label, phase: "running", at: Date.now() });
|
||||||
const t = toast.loading(`${label} ${id}…`);
|
const t = toast.loading(`${label} ${id}…`);
|
||||||
try {
|
try {
|
||||||
await fn(id);
|
await fn(id);
|
||||||
toast.success(`${label} ${id} ✓`, { id: t });
|
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: ["stacks"] });
|
||||||
qc.invalidateQueries({ queryKey: ["stack", id] });
|
qc.invalidateQueries({ queryKey: ["stack", id] });
|
||||||
qc.invalidateQueries({ queryKey: ["stack-updates"] });
|
qc.invalidateQueries({ queryKey: ["stack-updates"] });
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
}
|
}
|
||||||
@@ -30,6 +54,11 @@ export function useStackActions() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
busyId,
|
busyId,
|
||||||
|
status,
|
||||||
|
dismissStatus: () => {
|
||||||
|
window.clearTimeout(clearTimer.current);
|
||||||
|
setStatus(null);
|
||||||
|
},
|
||||||
start: (id: string) => run(id, "Starting", stacksApi.start),
|
start: (id: string) => run(id, "Starting", stacksApi.start),
|
||||||
stop: (id: string) => run(id, "Stopping", stacksApi.stop),
|
stop: (id: string) => run(id, "Stopping", stacksApi.stop),
|
||||||
restart: (id: string) => run(id, "Restarting", stacksApi.restart),
|
restart: (id: string) => run(id, "Restarting", stacksApi.restart),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<App />
|
||||||
<Toaster position="top-right" richColors theme="system" />
|
<Toaster position="top-right" richColors theme="system" expand visibleToasts={5} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Badge, Button, Card, Input, Spinner, StatusDot } from "@/components/ui"
|
|||||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
import { LogViewer } from "@/components/stacks/LogViewer";
|
import { LogViewer } from "@/components/stacks/LogViewer";
|
||||||
import { ContainerCard } from "@/components/stacks/ContainerCard";
|
import { ContainerCard } from "@/components/stacks/ContainerCard";
|
||||||
|
import { ActionStatusBanner } from "@/components/stacks/ActionStatusBanner";
|
||||||
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
|
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
|
||||||
import { SecretsPanel } from "@/components/stacks/SecretsPanel";
|
import { SecretsPanel } from "@/components/stacks/SecretsPanel";
|
||||||
import { BackupButton } from "@/components/stacks/BackupRestore";
|
import { BackupButton } from "@/components/stacks/BackupRestore";
|
||||||
@@ -91,6 +92,10 @@ export function StackDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{actions.status && (
|
||||||
|
<ActionStatusBanner status={actions.status} onDismiss={actions.dismissStatus} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
|
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user