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
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:
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the StackPilot release version."""
|
||||
|
||||
APP_VERSION = "0.42.0"
|
||||
APP_VERSION = "0.42.1"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user