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