From f6f82245f7ab3cd50118137389d5d6920641bf0f Mon Sep 17 00:00:00 2001 From: menzelj Date: Mon, 31 Aug 2026 00:43:10 +0200 Subject: [PATCH] Keep the progress bar on one line, and portal modals out of the top bar (0.42.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stacks-list bar sat below the name and grew the row when an action started. It now runs inline to the right of the name and service count, filling the space before the CPU column, so the row keeps its height. Label, percentage and byte detail sit on that same line. Also fixes the self-update prompt being cut off at the top. The top bar is backdrop-blurred, and a non-none backdrop-filter makes an element the containing block for `position: fixed` descendants — so the dialog centred itself in the 60px header instead of the viewport and overflowed off-screen. ConfirmDialog and the update overlay now render through a portal on document.body. ConfirmDialog also scrolls itself rather than its backdrop, which would otherwise strand its top edge. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016pMmFFkdfxkoYjcEcpZTa5 --- backend/version.py | 2 +- frontend/package.json | 2 +- .../src/components/layout/VersionBadge.tsx | 12 +- .../components/stacks/ActionStatusBanner.tsx | 106 +++++++++++++++--- .../src/components/stacks/StacksTable.tsx | 29 +++-- frontend/src/components/ui/ConfirmDialog.tsx | 18 ++- 6 files changed, 134 insertions(+), 35 deletions(-) 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 ); }