feat: bound container health pane so logs stay visible on large stacks (#1556)

* feat: bound container health pane so logs stay visible on large stacks

On the stack overview page, multi-container stacks (8+ services) pushed the
logs pane off-screen because the Command Center card had shrink-0 and no
scroll mechanism. This reworks the desktop layout for stacks with more than
one container:

- The Command Center card is bounded at max-h-[42%] so it never consumes
  the full left column. The container list scrolls internally via Sencho's
  existing Radix ScrollArea.
- A summary strip shows total / running / paused / unhealthy counts above
  the list, with a Compact / Detailed density toggle. Detailed (default)
  shows CPU / Mem / Net sparklines; Compact hides them for a denser list.
- The logs pane has a guaranteed min-h-[180px] so it is always reachable.
- Single-container stacks are untouched: the card keeps its original
  shrink-0 behaviour, and no summary strip or density toggle appears.

Mobile is unaffected (it already uses segmented Health / Logs / Compose
panes).

* fix: key ContainersHealth by node+stack so density resets on navigation

The density toggle state (compact/detailed) is local to ContainersHealth.
Without a key, selecting Compact on one multi-container stack would persist
when navigating to a single-container stack, hiding sparklines while also
hiding the toggle itself. Keying by node+stack ensures a clean remount.

Also added test coverage for zero containers and density reset on remount.

* fix: remove unused rerender variable from ContainersHealth test
This commit is contained in:
Anso
2026-07-05 02:53:26 -04:00
committed by GitHub
parent bb35c1bc92
commit fbe98676cb
6 changed files with 221 additions and 5 deletions
+1 -1
View File
@@ -203,7 +203,7 @@ The same links button appears on each container row and on the update cards in [
## Container health strip
Below the header, each container in the stack gets a single row that answers "is this piece working, and how do I reach it?" without expanding anything.
Below the header, each container in the stack gets a single row that answers "is this piece working, and how do I reach it?" When the stack has multiple containers, a summary strip appears above the list showing total, running, paused, and unhealthy counts, along with a **Compact / Detailed** toggle. Compact mode shows status, name, uptime, port, and action buttons; detailed mode (the default) adds CPU, memory, and network I/O sparklines.
<Frame>
<img src="/images/stack-view/containers.png" alt="CONTAINERS section showing a container card with health badge, uptime, port mapping, open link, and CPU, memory, and network stat tiles" />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 257 KiB

@@ -38,6 +38,7 @@ import ErrorBoundary from '../ErrorBoundary';
import StackAnatomyPanel from '../StackAnatomyPanel';
import { StackFileExplorer } from '@/components/files/StackFileExplorer';
import { useIsMobile } from '@/hooks/use-is-mobile';
import { ScrollArea } from '../ui/scroll-area';
import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks';
import { MobileStackDetail } from './MobileStackDetail';
import { RecoveryChip } from './RecoveryChip';
@@ -355,7 +356,7 @@ export function EditorView(props: EditorViewProps) {
{/* Command Center Card (identity + health strip). Hidden when
the logs are expanded so the logs pane fills the column. */}
{!logsExpanded && (
<Card className="rounded-xl border-muted bg-card shrink-0">
<Card className={`rounded-xl border-muted bg-card ${safeContainers.length > 1 ? 'flex flex-col min-h-0 max-h-[42%]' : 'shrink-0'}`}>
<CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
@@ -407,6 +408,23 @@ export function EditorView(props: EditorViewProps) {
panelStartedAt={panelStartedAt}
variant="band"
/>
{safeContainers.length > 1 ? (
<CardContent className="p-4 pt-2 flex-1 min-h-0">
<ScrollArea className="h-full">
<ContainersHealth
safeContainers={safeContainers}
containerStats={containerStats}
containerStatsError={containerStatsError}
isAdmin={isAdmin}
activeNode={activeNode}
openLogViewer={openLogViewer}
openBashModal={openBashModal}
serviceAction={serviceAction}
key={`${activeNode?.id ?? 'local'}:${stackName}`}
/>
</ScrollArea>
</CardContent>
) : (
<CardContent className="p-4 pt-2">
<ContainersHealth
safeContainers={safeContainers}
@@ -417,12 +435,17 @@ export function EditorView(props: EditorViewProps) {
openLogViewer={openLogViewer}
openBashModal={openBashModal}
serviceAction={serviceAction}
key={`${activeNode?.id ?? 'local'}:${stackName}`}
/>
</CardContent>
)}
</Card>
)}
{/* Logs Section (fills remaining left-column height) */}
{/* Logs Section (fills remaining left-column height). On multi-
container stacks a min-h guarantees logs are never hidden. */}
{safeContainers.length > 1 ? (
<div className="flex-1 min-h-[180px] flex flex-col">
<StackLogsSection
stackName={stackName}
logsMode={logsMode}
@@ -430,6 +453,16 @@ export function EditorView(props: EditorViewProps) {
logsExpanded={logsExpanded}
onToggleLogsExpand={() => setLogsExpanded((v) => !v)}
/>
</div>
) : (
<StackLogsSection
stackName={stackName}
logsMode={logsMode}
setLogsMode={setLogsMode}
logsExpanded={logsExpanded}
onToggleLogsExpand={() => setLogsExpanded((v) => !v)}
/>
)}
</div>
)}
@@ -225,6 +225,7 @@ export function MobileStackDetail(props: EditorViewProps) {
openLogViewer={openLogViewer}
openBashModal={openBashModal}
serviceAction={serviceAction}
key={`${activeNode?.id ?? 'local'}:${stackName}`}
/>
</div>
)}
@@ -80,3 +80,140 @@ describe('ContainersHealth published port link', () => {
expect(screen.getByText(/8080 → 80\/tcp/)).toBeInTheDocument();
});
});
describe('density toggle and summary strip', () => {
function makeContainer(overrides: Partial<ContainerInfo> = {}): ContainerInfo {
return {
Id: overrides.Id || 'abc',
Names: overrides.Names || ['/app'],
State: overrides.State || 'running',
Status: overrides.Status || 'Up 1 hour',
Image: overrides.Image || 'nginx',
...overrides,
} as unknown as ContainerInfo;
}
function renderMany(containers: ContainerInfo[]) {
return render(
<ContainersHealth
safeContainers={containers}
containerStats={{}}
containerStatsError={null}
isAdmin
activeNode={LOCAL_NODE}
openLogViewer={vi.fn()}
openBashModal={vi.fn()}
serviceAction={vi.fn()}
/>,
);
}
it('does not render summary strip or density toggle for a single container', () => {
renderMany([makeContainer()]);
expect(screen.queryByText(/container/)).toBeNull();
expect(screen.queryByRole('button', { name: 'Compact view' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Detailed view' })).toBeNull();
});
it('renders summary counts for multiple containers', () => {
renderMany([
makeContainer({ Id: 'a', State: 'running' }),
makeContainer({ Id: 'b', State: 'running' }),
makeContainer({ Id: 'c', State: 'paused' }),
]);
expect(screen.getByText(/3 containers/i)).toBeInTheDocument();
expect(screen.getByText(/2 up/i)).toBeInTheDocument();
expect(screen.getByText(/1 paused/i)).toBeInTheDocument();
});
it('shows unhealthy count in summary', () => {
renderMany([
makeContainer({ Id: 'a', State: 'running', healthStatus: 'healthy' }),
makeContainer({ Id: 'b', State: 'running', healthStatus: 'unhealthy' }),
]);
expect(screen.getByText(/1 unhealthy/i)).toBeInTheDocument();
});
it('renders density toggle buttons for multiple containers', () => {
renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]);
expect(screen.getByRole('button', { name: 'Compact view' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Detailed view' })).toBeInTheDocument();
});
it('detailed mode is the default', () => {
renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]);
const detailed = screen.getByRole('button', { name: 'Detailed view' });
expect(detailed).toHaveAttribute('aria-pressed', 'true');
});
it('hides sparkline grids in compact mode', () => {
renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]);
// Sparklines visible by default in detailed mode (two containers, two cpu labels)
expect(screen.getAllByText('cpu')).toHaveLength(2);
fireEvent.click(screen.getByRole('button', { name: 'Compact view' }));
// Sparkline labels hidden in compact mode
expect(screen.queryByText('cpu')).toBeNull();
});
it('shows sparkline grids again when switching back to detailed', () => {
renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]);
fireEvent.click(screen.getByRole('button', { name: 'Compact view' }));
expect(screen.queryByText('cpu')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Detailed view' }));
expect(screen.getAllByText('cpu')).toHaveLength(2);
});
it('keeps header row actions visible in compact mode', () => {
renderMany([
makeContainer({ Id: 'a', State: 'running', Service: 'web' }),
makeContainer({ Id: 'b', State: 'running' }),
]);
fireEvent.click(screen.getByRole('button', { name: 'Compact view' }));
// View logs button still present
expect(screen.getAllByRole('button', { name: 'View logs' })).toHaveLength(2);
});
it('renders empty state for zero containers without summary strip', () => {
renderMany([]);
expect(screen.getByText(/no containers running/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Compact view' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Detailed view' })).toBeNull();
});
it('resets density to detailed on remount (key change)', () => {
const { unmount } = render(
<ContainersHealth
safeContainers={[makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]}
containerStats={{}}
containerStatsError={null}
isAdmin
activeNode={LOCAL_NODE}
openLogViewer={vi.fn()}
openBashModal={vi.fn()}
serviceAction={vi.fn()}
/>,
);
// Switch to compact
fireEvent.click(screen.getByRole('button', { name: 'Compact view' }));
expect(screen.queryByText('cpu')).toBeNull();
// Simulate navigating to a single-container stack (new key)
unmount();
render(
<ContainersHealth
safeContainers={[makeContainer({ Id: 'x' })]}
containerStats={{}}
containerStatsError={null}
isAdmin
activeNode={LOCAL_NODE}
openLogViewer={vi.fn()}
openBashModal={vi.fn()}
serviceAction={vi.fn()}
/>,
);
// Density reset; single container shows sparklines
expect(screen.getByText('cpu')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Compact view' })).toBeNull();
});
});
@@ -16,6 +16,8 @@ import {
ArrowUpRight,
Copy,
CloudDownload,
Layers,
List,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '../ui/button';
@@ -339,6 +341,9 @@ export function ContainersHealth({
}: ContainersHealthProps) {
const [copiedUrlId, setCopiedUrlId] = useState<string | null>(null);
const copiedUrlTimerRef = useRef<number | null>(null);
// Compact mode hides sparkline grids across all containers for a denser
// list. Detailed mode (default) shows CPU / Mem / Net per container.
const [density, setDensity] = useState<'compact' | 'detailed'>('detailed');
useEffect(() => () => {
if (copiedUrlTimerRef.current !== null) window.clearTimeout(copiedUrlTimerRef.current);
}, []);
@@ -368,7 +373,46 @@ export function ContainersHealth({
{safeContainers.length === 0 ? (
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
) : (
<div className="flex flex-col gap-2">
<>
{/* Summary strip + density toggle appear only for multi-container
stacks; single-container stacks keep the original layout. */}
{safeContainers.length > 1 && (() => {
const total = safeContainers.length;
const running = safeContainers.filter(c => c.State === 'running').length;
const unhealthy = safeContainers.filter(c => c.healthStatus === 'unhealthy').length;
const paused = safeContainers.filter(c => c.State === 'paused').length;
return (
<div className="flex items-center justify-between mb-1 px-1">
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
<span>{total} container{total !== 1 ? 's' : ''}</span>
<span className="text-success/80">{running} up</span>
{paused > 0 && <span className="text-warning/80">{paused} paused</span>}
{unhealthy > 0 && <span className="text-destructive/80">{unhealthy} unhealthy</span>}
</div>
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
<button
type="button"
onClick={() => setDensity('compact')}
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'compact' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
aria-pressed={density === 'compact'}
aria-label="Compact view"
>
<List className="h-3 w-3" strokeWidth={1.5} />
</button>
<button
type="button"
onClick={() => setDensity('detailed')}
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'detailed' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
aria-pressed={density === 'detailed'}
aria-label="Detailed view"
>
<Layers className="h-3 w-3" strokeWidth={1.5} />
</button>
</div>
</div>
);
})()}
<div className="flex flex-col gap-2">
{safeContainers.map(container => {
let mainPort: number | undefined;
let mainPortPrivate: number | undefined;
@@ -517,7 +561,7 @@ export function ContainersHealth({
)}
</div>
</div>
{isActive ? (
{isActive && density === 'detailed' ? (
<div className="mt-2 grid grid-cols-3 gap-2">
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
<div className="flex flex-col">
@@ -552,6 +596,7 @@ export function ContainersHealth({
);
})}
</div>
</>
)}
</div>
);