diff --git a/backend/version.py b/backend/version.py index c5d3265..4de2d68 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.42.0" +APP_VERSION = "0.42.1" diff --git a/frontend/package.json b/frontend/package.json index 44675dc..d0f8f1b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.42.0", + "version": "0.42.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/layout/VersionBadge.tsx b/frontend/src/components/layout/VersionBadge.tsx index 4c56c70..9e64bc5 100644 --- a/frontend/src/components/layout/VersionBadge.tsx +++ b/frontend/src/components/layout/VersionBadge.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { useQuery } from "@tanstack/react-query"; import { ArrowUpCircle } from "lucide-react"; import { toast } from "sonner"; @@ -80,8 +81,10 @@ export function VersionBadge() { ); } -/** Full-screen wait state while the helper recreates the containers: - * polls /api/health until a different version answers, then reloads. */ +/** Full-screen wait state while the helper recreates the containers: polls + * /api/health until a different version answers, then reloads. Portalled for + * the same reason as ConfirmDialog — the backdrop-blurred top bar this is + * rendered from would otherwise be the containing block for `fixed`. */ function UpdatingOverlay({ fromVersion }: { fromVersion: string }) { const [failed, setFailed] = useState(false); const started = useRef(Date.now()); @@ -109,7 +112,7 @@ function UpdatingOverlay({ fromVersion }: { fromVersion: string }) { return () => clearInterval(timer); }, [fromVersion]); - return ( + return createPortal(
{failed ? ( @@ -136,6 +139,7 @@ function UpdatingOverlay({ fromVersion }: { fromVersion: string }) { )}
-
+ , + document.body ); } diff --git a/frontend/src/components/stacks/ActionStatusBanner.tsx b/frontend/src/components/stacks/ActionStatusBanner.tsx index fba151a..8d82133 100644 --- a/frontend/src/components/stacks/ActionStatusBanner.tsx +++ b/frontend/src/components/stacks/ActionStatusBanner.tsx @@ -14,10 +14,96 @@ const BAR_TONE = { error: "bg-red-500", } as const; +/** Bar geometry shared by both variants, derived from the action's phase. */ +function barState(status: StackActionStatus) { + const { phase, progress } = status; + const done = phase !== "running"; + return { + done, + pct: phase === "success" ? 100 : Math.min(progress?.pct ?? 0, 100), + // No stream (or nothing measurable yet) — slide instead of showing a lie. + indeterminate: !done && (progress?.indeterminate ?? true), + label: done + ? phase === "success" + ? `${status.label} — done` + : `${status.label} failed` + : (progress?.label ?? `${status.label}…`), + detail: phase === "error" ? (status.message ?? "") : (progress?.detail ?? ""), + }; +} + /** - * Progress bar for a running stack action, sized to sit inside a stacks-list - * row. Update streams real per-layer pull progress; the other actions have - * nothing to measure and show a sliding bar instead. + * Single-line progress bar that sits beside the stack name in a list row, + * filling the gap before the CPU column. Deliberately one line tall so a + * running action never changes the row's height. + */ +export function ActionProgressInline({ + status, + onDismiss, +}: { + status: StackActionStatus; + onDismiss?: (id: string) => void; +}) { + const { done, pct, indeterminate, label, detail } = barState(status); + + return ( +
+ + {label} + +
+ {indeterminate ? ( +
+ ) : ( +
+ )} +
+ {!indeterminate && ( + + {Math.round(pct)}% + + )} + {detail && ( + + {detail} + + )} + {done && onDismiss && ( + + )} +
+ ); +} + +/** + * Stacked progress bar for the status banner, where vertical space is not at + * a premium. Update streams real per-layer pull progress; the other actions + * have nothing to measure and show a sliding bar instead. */ export function ActionProgressBar({ status, @@ -26,18 +112,8 @@ export function ActionProgressBar({ status: StackActionStatus; onDismiss?: (id: string) => void; }) { - const { phase, progress } = status; - const done = phase !== "running"; - const pct = phase === "success" ? 100 : Math.min(progress?.pct ?? 0, 100); - // No stream (or nothing measurable yet) — slide instead of showing a lie. - const indeterminate = !done && (progress?.indeterminate ?? true); - - const label = done - ? phase === "success" - ? `${status.label} — done` - : `${status.label} failed` - : (progress?.label ?? `${status.label}…`); - const detail = phase === "error" ? (status.message ?? "") : (progress?.detail ?? ""); + const { phase } = status; + const { done, pct, indeterminate, label, detail } = barState(status); return (
diff --git a/frontend/src/components/stacks/StacksTable.tsx b/frontend/src/components/stacks/StacksTable.tsx index 8d61712..9d7372e 100644 --- a/frontend/src/components/stacks/StacksTable.tsx +++ b/frontend/src/components/stacks/StacksTable.tsx @@ -10,7 +10,7 @@ import { stacksApi } from "@/api/stacks"; import { apiErrorMessage } from "@/api/client"; import type { StackStats, StackSummary, StackUpdateInfo } from "@/types"; import type { StackActionStatus } from "@/hooks/useStackActions"; -import { ActionProgressBar } from "./ActionStatusBanner"; +import { ActionProgressInline } from "./ActionStatusBanner"; /** Stack list rendered as a table with live CPU/memory usage meters and inline * start/stop/restart, shared by the Dashboard and the Stacks page. */ @@ -161,13 +161,20 @@ function StackRow({ return ( - - - {stack.name} - {stack.status} - - {stack.running_count}/{stack.service_count} svc - + {/* Name block stays fixed-width; a running action's bar fills the gap + to the CPU column, so the row never changes height. */} +
+ + + {stack.name} + {stack.status} + + {stack.running_count}/{stack.service_count} svc + + {updateAvailable && !status && ( Update )} - - {status && } + {status && } +
{running && stats ? ( diff --git a/frontend/src/components/ui/ConfirmDialog.tsx b/frontend/src/components/ui/ConfirmDialog.tsx index cbd8958..474f6e7 100644 --- a/frontend/src/components/ui/ConfirmDialog.tsx +++ b/frontend/src/components/ui/ConfirmDialog.tsx @@ -1,6 +1,14 @@ import type { ReactNode } from "react"; +import { createPortal } from "react-dom"; import { Button } from "@/components/ui"; +/** + * Rendered through a portal on `document.body`: an ancestor with a transform, + * filter or backdrop-filter becomes the containing block for `position: fixed` + * descendants, which would pin the dialog to that ancestor's box instead of the + * viewport. The top bar is `backdrop-blur`, so a dialog opened from it (the + * self-update prompt) was centred in the 60px header and cut off at the top. + */ export function ConfirmDialog({ title, message, @@ -20,13 +28,16 @@ export function ConfirmDialog({ onCancel: () => void; children?: ReactNode; }) { - return ( + return createPortal(
!busy && onCancel()} > + {/* The dialog scrolls itself rather than the backdrop: `items-center` + plus `overflow-y-auto` on one element makes the overflowing top + unreachable. */}
e.stopPropagation()} >

{title}

@@ -41,6 +52,7 @@ export function ConfirmDialog({
-
+
, + document.body ); }