From 86c67dfcea9e212773840a689de62860713fdabb Mon Sep 17 00:00:00 2001 From: menzelj Date: Mon, 31 Aug 2026 00:20:03 +0200 Subject: [PATCH] Show action status on the stacks list and dashboard, not just detail (0.41.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status banner added in 9d28e12 only rendered on the stack detail page, but Update is most often clicked from the stacks list — so in practice the status was invisible. Render it on the stacks list and dashboard too. Actions on different stacks run concurrently from the list, so busy state and status are now keyed by stack id instead of a single value: previously the first action to finish cleared every row's spinner, and each new action overwrote the previous one's status. StacksTable takes an isBusy(id) predicate in place of the single busyId prop. Also bumps the version so the newly version-tagged CI images (f8bfc91) actually differ from the running release — self-update compares tags against APP_VERSION, so shipping without a bump shows no update. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016pMmFFkdfxkoYjcEcpZTa5 --- backend/version.py | 2 +- frontend/package.json | 2 +- .../components/stacks/ActionStatusBanner.tsx | 35 +++++++-- .../components/stacks/AgentStacksSection.tsx | 2 +- .../src/components/stacks/StacksTable.tsx | 6 +- frontend/src/hooks/useStackActions.ts | 73 ++++++++++++++----- frontend/src/pages/Dashboard.tsx | 9 ++- frontend/src/pages/StackDetail.tsx | 8 +- frontend/src/pages/Stacks.tsx | 8 +- 9 files changed, 103 insertions(+), 42 deletions(-) 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