fix(ui): flatten single-container Update onto the service row (#1759)q

* fix(ui): flatten single-container Update onto the service row

On multi-service stacks, put Update/Rebuild on the container card (left of
image source) when a service has one matching container, and keep the shared
header only for multi-replica services.

* fix(ui): show per-service Update only when an image update is confirmed

Registry services were always showing Update because eligibility checked
declaredImage/hasBuild only. Gate Update on a confirmed pending check so
the button clears after a successful recheck; keep Rebuild for build-backed
services.
This commit is contained in:
Anso
2026-08-03 10:10:09 -04:00
committed by GitHub
parent 5f5cd5ea51
commit 1c9060a855
3 changed files with 273 additions and 112 deletions
@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
vi.mock('../../Terminal', () => ({ default: () => null }));
vi.mock('../../StructuredLogViewer', () => ({ default: () => null }));
vi.mock('../../ImageSourceMenu', () => ({ ImageSourceMenu: () => null }));
vi.mock('../../ImageSourceMenu', () => ({ ImageSourceMenu: () => <button type="button" aria-label="Image source links" /> }));
import { ContainersHealth, type ContainersHealthProps } from '../editor-view-blocks';
import { copyToClipboard } from '@/lib/clipboard';
@@ -302,12 +302,12 @@ describe('declared-service headers (multi-service only)', () => {
expect(screen.getByRole('button', { name: 'View logs' })).toBeInTheDocument();
});
it('renders one header per declared service and groups containers under it', () => {
it('flattens single-container multi-service rows without a declared-service header', () => {
render(
<ContainersHealth
safeContainers={[
makeContainer({ Id: 'w1', Service: 'web', State: 'running' }),
makeContainer({ Id: 'd1', Service: 'db', State: 'running' }),
makeContainer({ Id: 'w1', Names: ['/web'], Service: 'web', State: 'running' }),
makeContainer({ Id: 'd1', Names: ['/db'], Service: 'db', State: 'running' }),
]}
containerStats={{}}
containerStatsError={null}
@@ -319,19 +319,47 @@ describe('declared-service headers (multi-service only)', () => {
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db', declaredImage: 'postgres:16' })]}
/>,
);
expect(screen.queryByText(/running$/)).toBeNull();
expect(screen.getByText('web')).toBeInTheDocument();
expect(screen.getByText('db')).toBeInTheDocument();
// Flattened rows restore the per-container Service actions kebab.
expect(screen.getAllByLabelText('Service actions')).toHaveLength(2);
// Per-container service menu is hidden inside a multi-service header group.
expect(screen.getAllByLabelText('Open bash shell')).toHaveLength(2);
// Registry services without a confirmed update hide the Update button.
expect(screen.queryByRole('button', { name: /^Update$/ })).toBeNull();
});
it('shows the Update badge only for the service with a confirmed update', () => {
it('hides Update for registry services without a confirmed pending update', () => {
render(
<ContainersHealth
safeContainers={[
makeContainer({ Id: 'w1', Service: 'web' }),
makeContainer({ Id: 'd1', Service: 'db' }),
makeContainer({ Id: 'w1', Names: ['/web'], Service: 'web' }),
makeContainer({ Id: 'd1', Names: ['/db'], Service: 'db' }),
]}
containerStats={{}}
containerStatsError={null}
isAdmin
activeNode={LOCAL_NODE}
openLogViewer={vi.fn()}
openBashModal={vi.fn()}
serviceAction={vi.fn()}
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db', declaredImage: 'postgres:16' })]}
serviceUpdateStatuses={[
status({ service: 'web', hasUpdate: false }),
status({ service: 'db', hasUpdate: false }),
]}
/>,
);
expect(screen.queryByRole('button', { name: /^Update$/ })).toBeNull();
expect(screen.queryByText('Update', { selector: 'span' })).toBeNull();
});
it('shows the Update badge and button on the flattened container card', () => {
render(
<ContainersHealth
safeContainers={[
makeContainer({ Id: 'w1', Names: ['/web'], Service: 'web' }),
makeContainer({ Id: 'd1', Names: ['/db'], Service: 'db' }),
]}
containerStats={{}}
containerStatsError={null}
@@ -347,7 +375,13 @@ describe('declared-service headers (multi-service only)', () => {
/>,
);
expect(screen.getByText('Update', { selector: 'span' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Update$/ })).toBeInTheDocument();
const updateBtn = screen.getByRole('button', { name: /^Update$/ });
expect(updateBtn).toBeInTheDocument();
const imageSource = screen.getAllByLabelText('Image source links')[0];
// Update sits left of ImageSourceMenu in the action row.
expect(
updateBtn.compareDocumentPosition(imageSource) & globalThis.Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it('uses Rebuild wording with no badge for a build-backed service without a detected update', () => {
@@ -355,8 +389,8 @@ describe('declared-service headers (multi-service only)', () => {
render(
<ContainersHealth
safeContainers={[
makeContainer({ Id: 'w1', Service: 'web' }),
makeContainer({ Id: 'd1', Service: 'db' }),
makeContainer({ Id: 'w1', Names: ['/web'], Service: 'web' }),
makeContainer({ Id: 'd1', Names: ['/db'], Service: 'db' }),
]}
containerStats={{}}
containerStatsError={null}
@@ -376,14 +410,15 @@ describe('declared-service headers (multi-service only)', () => {
expect(onRequestServiceUpdate).toHaveBeenCalledWith('web', 'rebuild');
});
it('moves Start/Stop/Restart to the declared-service header menu', async () => {
it('keeps Start/Stop/Restart on the multi-replica service header menu', async () => {
const user = userEvent.setup();
const serviceAction = vi.fn();
render(
<ContainersHealth
safeContainers={[
makeContainer({ Id: 'w1', Service: 'web', State: 'running' }),
makeContainer({ Id: 'd1', Service: 'db', State: 'running' }),
makeContainer({ Id: 'w1', Names: ['/web-1'], Service: 'web', State: 'running' }),
makeContainer({ Id: 'w2', Names: ['/web-2'], Service: 'web', State: 'running' }),
makeContainer({ Id: 'd1', Names: ['/db'], Service: 'db', State: 'running' }),
]}
containerStats={{}}
containerStatsError={null}
@@ -392,14 +427,80 @@ describe('declared-service headers (multi-service only)', () => {
openLogViewer={vi.fn()}
openBashModal={vi.fn()}
serviceAction={serviceAction}
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db' })]}
effectiveServices={[
spec({ name: 'web', expectedReplicas: 2 }),
spec({ name: 'db' }),
]}
/>,
);
// web keeps a header (2 containers); db is flattened (1 container).
expect(screen.getByText(/2\/2 running/i)).toBeInTheDocument();
// Only the web header kebab + the flattened db card kebab.
expect(screen.getAllByLabelText('Service actions')).toHaveLength(2);
await user.click(screen.getAllByLabelText('Service actions')[0]);
await user.click(await screen.findByRole('menuitem', { name: 'Restart service' }));
expect(serviceAction).toHaveBeenCalledWith('restart', 'web');
});
it('retains multi-replica header Update without leaking onto nested children', () => {
render(
<ContainersHealth
safeContainers={[
makeContainer({ Id: 'w1', Names: ['/web-1'], Service: 'web', State: 'running' }),
makeContainer({ Id: 'w2', Names: ['/web-2'], Service: 'web', State: 'running' }),
makeContainer({ Id: 'd1', Names: ['/db'], Service: 'db', State: 'running' }),
]}
containerStats={{}}
containerStatsError={null}
isAdmin
activeNode={LOCAL_NODE}
openLogViewer={vi.fn()}
openBashModal={vi.fn()}
serviceAction={vi.fn()}
effectiveServices={[
spec({ name: 'web', expectedReplicas: 2 }),
spec({ name: 'db', declaredImage: null }),
]}
serviceUpdateStatuses={[status({ service: 'web', hasUpdate: true })]}
/>,
);
expect(screen.getByText(/2\/2 running/i)).toBeInTheDocument();
// One Update badge + one Update button on the web header only (db ineligible).
expect(screen.getAllByText('Update', { selector: 'span' })).toHaveLength(1);
expect(screen.getAllByRole('button', { name: /^Update$/ })).toHaveLength(1);
// Nested web children hide the Service actions kebab (header owns it).
// web header kebab + db flattened kebab = 2.
expect(screen.getAllByLabelText('Service actions')).toHaveLength(2);
expect(screen.getAllByLabelText('Open bash shell')).toHaveLength(3);
});
it('renders a compact Update row for a declared service with zero containers', () => {
render(
<ContainersHealth
safeContainers={[
makeContainer({ Id: 'd1', Names: ['/db'], Service: 'db', State: 'running' }),
]}
containerStats={{}}
containerStatsError={null}
isAdmin
activeNode={LOCAL_NODE}
openLogViewer={vi.fn()}
openBashModal={vi.fn()}
serviceAction={vi.fn()}
// db is flattened but not update-eligible so only the empty web row
// owns the Update button under test.
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db', declaredImage: null })]}
serviceUpdateStatuses={[status({ service: 'web', hasUpdate: true })]}
/>,
);
expect(screen.queryByText(/No containers running for this service/i)).toBeNull();
expect(screen.queryByText(/running$/)).toBeNull();
expect(screen.getByText('web')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Update$/ })).toBeInTheDocument();
// Compact empty row + flattened db card each have a Service actions kebab.
expect(screen.getAllByLabelText('Service actions')).toHaveLength(2);
});
it('still surfaces summary strip and density toggle on multi-service stacks', () => {
render(
<ContainersHealth
@@ -307,6 +307,18 @@ export function StackIdentityHeader({
);
}
/** Optional per-card Update/Rebuild affordance for flattened single-container
* multi-service rows. Pass only from that call site; leave undefined on
* multi-replica nested children and the single-service flat path. */
export interface ServiceUpdateAffordance {
hasUpdate: boolean;
mode: 'update' | 'rebuild';
showUpdateAction: boolean;
busy: boolean;
replicaCopy: string;
onRequest: () => void;
}
export interface ContainersHealthProps {
safeContainers: ContainerInfo[];
containerStats: Record<string, ContainerStatsEntry>;
@@ -464,10 +476,73 @@ export function ContainersHealth({
) : null;
// One container card. `hideServiceMenu` drops the per-container
// Start/Stop/Restart kebab on multi-service stacks; the declared-service
// header above owns lifecycle actions. Child cards keep logs, shell, ports,
// and metrics only.
const renderContainerCard = (container: ContainerInfo, hideServiceMenu: boolean) => {
// Start/Stop/Restart kebab on multi-replica nested children; the
// declared-service header above owns lifecycle actions there. Flattened
// single-container multi-service rows pass updateAffordance and keep the
// kebab (`hideServiceMenu=false`). Single-service flat rows leave
// updateAffordance undefined.
const renderServiceUpdateButton = (affordance: ServiceUpdateAffordance) => {
if (!affordance.showUpdateAction) return null;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md px-2 max-md:h-11"
onClick={affordance.onRequest}
disabled={affordance.busy}
>
<CloudDownload className="h-3.5 w-3.5 mr-1.5" strokeWidth={1.5} />
{affordance.busy
? (affordance.mode === 'rebuild' ? 'Rebuilding...' : 'Updating...')
: (affordance.mode === 'rebuild' ? 'Rebuild' : 'Update')}
</Button>
</TooltipTrigger>
<TooltipContent>{affordance.replicaCopy}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
const renderServiceLifecycleMenu = (serviceName: string, isServiceActive: boolean) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
aria-label="Service actions"
>
<MoreVertical className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isServiceActive ? (
<>
<DropdownMenuItem onSelect={() => serviceAction('restart', serviceName)}>
Restart service
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => serviceAction('stop', serviceName)}>
Stop service
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onSelect={() => serviceAction('start', serviceName)}>
Start service
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
const renderContainerCard = (
container: ContainerInfo,
hideServiceMenu: boolean,
updateAffordance?: ServiceUpdateAffordance,
) => {
let mainPort: number | undefined;
let mainPortPrivate: number | undefined;
let mainPortProto: string | undefined;
@@ -517,7 +592,14 @@ export function ContainersHealth({
{badgeGlyph}
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<div className="truncate font-mono text-sm text-foreground">{containerName}</div>
<div className="flex min-w-0 items-center gap-2">
<div className="truncate font-mono text-sm text-foreground">{containerName}</div>
{updateAffordance?.hasUpdate && (
<span className="shrink-0 rounded-full border border-brand/30 bg-brand/10 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-brand">
Update
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-[11px] text-stat-subtitle">
{uptime ? <span>{uptime}</span> : <span>{(container.State || 'unknown').toLowerCase()}</span>}
{hcLabel ? <><span>·</span><span>{hcLabel}</span></> : null}
@@ -563,6 +645,7 @@ export function ContainersHealth({
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{updateAffordance ? renderServiceUpdateButton(updateAffordance) : null}
<ImageSourceMenu
imageRef={container.Image}
imageId={container.ImageID}
@@ -623,34 +706,10 @@ export function ContainersHealth({
</TooltipProvider>
)}
{!hideServiceMenu && container.Service && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
aria-label="Service actions"
>
<MoreVertical className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isActive ? (
<>
<DropdownMenuItem onSelect={() => serviceAction('restart', container.Service!)}>
Restart service
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => serviceAction('stop', container.Service!)}>
Stop service
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onSelect={() => serviceAction('start', container.Service!)}>
Start service
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
renderServiceLifecycleMenu(
container.Service,
isActive,
)
)}
</div>
</div>
@@ -750,13 +809,63 @@ export function ContainersHealth({
const busy = serviceUpdateInProgress?.service === spec.name;
const hasUpdate = status ? isConfirmedServiceUpdate(status) : false;
const mode: 'update' | 'rebuild' = !hasUpdate && spec.hasBuild ? 'rebuild' : 'update';
const showUpdateAction = spec.declaredImage !== null || spec.hasBuild;
// Registry Update only when a check confirmed a pending
// image update (clears after a successful recheck). Rebuild
// stays available for build-backed services without one.
// Stack-level Update in the identity header remains the
// always-on full-stack pull path.
const showUpdateAction = hasUpdate || spec.hasBuild;
const isServiceActive = group.some(c => c.State === 'running' || c.State === 'paused');
const runningCount = group.filter(c => c.State === 'running').length;
const replicaWord = spec.expectedReplicas === 1 ? 'replica' : 'replicas';
const replicaCopy = mode === 'rebuild'
? `Rebuilds all ${spec.expectedReplicas} ${replicaWord}`
: `Updates all ${spec.expectedReplicas} ${replicaWord}`;
const updateAffordance: ServiceUpdateAffordance = {
hasUpdate,
mode,
showUpdateAction,
busy,
replicaCopy,
onRequest: () => onRequestServiceUpdate?.(spec.name, mode),
};
// Single-container declared service: one flat card with
// Update left of ImageSourceMenu and the lifecycle kebab.
if (group.length === 1) {
return (
<div key={spec.name}>
{renderContainerCard(group[0], false, updateAffordance)}
</div>
);
}
// Zero containers: compact row (name + Update + kebab),
// not renderContainerCard (no ContainerInfo).
if (group.length === 0) {
return (
<div
key={spec.name}
className="flex items-center justify-between gap-3 rounded-lg border border-card-border bg-muted/40 px-3 py-2"
>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-sm font-medium text-foreground">{spec.name}</span>
{hasUpdate && (
<span className="rounded-full border border-brand/30 bg-brand/10 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-brand">
Update
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{renderServiceUpdateButton(updateAffordance)}
{renderServiceLifecycleMenu(spec.name, false)}
</div>
</div>
);
}
// Multi-replica: keep header + nested children (no
// updateAffordance on child cards).
return (
<div key={spec.name} className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3 rounded-lg border border-card-border bg-muted/40 px-3 py-2">
@@ -772,67 +881,13 @@ export function ContainersHealth({
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{showUpdateAction && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md px-2 max-md:h-11"
onClick={() => onRequestServiceUpdate?.(spec.name, mode)}
disabled={busy}
>
<CloudDownload className="h-3.5 w-3.5 mr-1.5" strokeWidth={1.5} />
{busy
? (mode === 'rebuild' ? 'Rebuilding...' : 'Updating...')
: (mode === 'rebuild' ? 'Rebuild' : 'Update')}
</Button>
</TooltipTrigger>
<TooltipContent>{replicaCopy}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
aria-label="Service actions"
>
<MoreVertical className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isServiceActive ? (
<>
<DropdownMenuItem onSelect={() => serviceAction('restart', spec.name)}>
Restart service
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => serviceAction('stop', spec.name)}>
Stop service
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onSelect={() => serviceAction('start', spec.name)}>
Start service
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{renderServiceUpdateButton(updateAffordance)}
{renderServiceLifecycleMenu(spec.name, isServiceActive)}
</div>
</div>
{group.length > 0 ? (
<div className="ml-2 flex flex-col gap-2 border-l border-hairline pl-3">
{group.map(container => renderContainerCard(container, true))}
</div>
) : (
<div className="ml-2 pl-3 font-mono text-xs text-muted-foreground">
No containers running for this service.
</div>
)}
<div className="ml-2 flex flex-col gap-2 border-l border-hairline pl-3">
{group.map(container => renderContainerCard(container, true))}
</div>
</div>
);
})}
@@ -844,6 +899,7 @@ export function ContainersHealth({
{safeContainers.map(container => renderContainerCard(container, false))}
</div>
)}
</div>
);
}