mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user