Keep the progress bar on one line, and portal modals out of the top bar (0.42.1)
CI / build-and-push (push) Successful in 1m48s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pMmFFkdfxkoYjcEcpZTa5
This commit is contained in:
menzelj
2026-08-31 00:43:10 +02:00
co-authored by Claude Opus 5
parent 1e8d4248fd
commit f6f82245f7
6 changed files with 134 additions and 35 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.42.0"
APP_VERSION = "0.42.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.42.0",
"version": "0.42.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -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(
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-sm rounded-xl border border-slate-200 bg-card p-6 text-center shadow-xl dark:border-slate-700 dark:bg-card-dark">
{failed ? (
@@ -136,6 +139,7 @@ function UpdatingOverlay({ fromVersion }: { fromVersion: string }) {
</>
)}
</div>
</div>
</div>,
document.body
);
}
@@ -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 (
<div
className="flex min-w-0 flex-1 items-center gap-2"
title={detail ? `${label}${detail}` : label}
>
<span
className={`shrink-0 text-[11px] ${
status.phase === "error"
? "font-medium text-red-600 dark:text-red-400"
: status.phase === "success"
? "font-medium text-sp-green"
: "text-sp-text-2"
}`}
>
{label}
</span>
<div className="h-1.5 min-w-[48px] flex-1 overflow-hidden rounded-full bg-sp-surface-2 ring-1 ring-inset ring-sp-border">
{indeterminate ? (
<div className="h-full w-1/3 rounded-full bg-accent animate-indeterminate dark:bg-accent-dark" />
) : (
<div
className={`h-full rounded-full transition-[width] duration-300 ease-out ${BAR_TONE[status.phase]}`}
style={{ width: `${pct}%` }}
/>
)}
</div>
{!indeterminate && (
<span className="w-8 shrink-0 text-right text-[11px] tabular-nums text-sp-text-2">
{Math.round(pct)}%
</span>
)}
{detail && (
<span className="hidden max-w-[16rem] truncate text-[11px] tabular-nums text-sp-text-3 xl:block">
{detail}
</span>
)}
{done && onDismiss && (
<button
onClick={(e) => {
// The row's name is a link; don't navigate when dismissing.
e.preventDefault();
e.stopPropagation();
onDismiss(status.id);
}}
className="shrink-0 opacity-60 transition-opacity hover:opacity-100"
aria-label="Dismiss"
>
<X className="h-3 w-3" />
</button>
)}
</div>
);
}
/**
* 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 (
<div className="mt-1.5 w-full">
+18 -11
View File
@@ -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 (
<tr className="hover:bg-slate-50 dark:hover:bg-slate-800/50">
<td className="px-4 py-2.5">
<Link to={`${linkBase}/${stack.id}`} className="flex items-center gap-2">
<StatusDot status={stack.status} />
<span className="font-medium">{stack.name}</span>
<Badge status={stack.status}>{stack.status}</Badge>
<span className="text-xs text-slate-400">
{stack.running_count}/{stack.service_count} svc
</span>
{/* Name block stays fixed-width; a running action's bar fills the gap
to the CPU column, so the row never changes height. */}
<div className="flex items-center gap-2">
<Link
to={`${linkBase}/${stack.id}`}
className="flex shrink-0 items-center gap-2"
>
<StatusDot status={stack.status} />
<span className="font-medium">{stack.name}</span>
<Badge status={stack.status}>{stack.status}</Badge>
<span className="text-xs text-slate-400">
{stack.running_count}/{stack.service_count} svc
</span>
</Link>
{updateAvailable && !status && (
<span
title={
@@ -175,13 +182,13 @@ function StackRow({
? `Image update available:\n${update.stale_images.join("\n")}`
: "An image update is available for this stack"
}
className="inline-flex items-center gap-1 rounded-pill border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] font-semibold text-amber-600 dark:text-amber-400"
className="inline-flex shrink-0 items-center gap-1 rounded-pill border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] font-semibold text-amber-600 dark:text-amber-400"
>
<ArrowUpCircle className="h-3 w-3" /> Update
</span>
)}
</Link>
{status && <ActionProgressBar status={status} onDismiss={onDismissStatus} />}
{status && <ActionProgressInline status={status} onDismiss={onDismissStatus} />}
</div>
</td>
<td className="px-4 py-2.5">
{running && stats ? (
+15 -3
View File
@@ -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(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => !busy && onCancel()}
>
{/* The dialog scrolls itself rather than the backdrop: `items-center`
plus `overflow-y-auto` on one element makes the overflowing top
unreachable. */}
<div
className="w-full max-w-md rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark"
className="max-h-full w-full max-w-md overflow-y-auto rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark"
onClick={(e) => e.stopPropagation()}
>
<h2 className="sp-heading text-lg">{title}</h2>
@@ -41,6 +52,7 @@ export function ConfirmDialog({
</Button>
</div>
</div>
</div>
</div>,
document.body
);
}