diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 6c8594d0..5f8df0c9 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -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. CONTAINERS section showing a container card with health badge, uptime, port mapping, open link, and CPU, memory, and network stat tiles diff --git a/docs/images/stack-view/containers.png b/docs/images/stack-view/containers.png index 6e92e77b..962c9287 100644 Binary files a/docs/images/stack-view/containers.png and b/docs/images/stack-view/containers.png differ diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index adfa3f7b..5e157a74 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -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 && ( - + 1 ? 'flex flex-col min-h-0 max-h-[42%]' : 'shrink-0'}`}>
@@ -407,6 +408,23 @@ export function EditorView(props: EditorViewProps) { panelStartedAt={panelStartedAt} variant="band" /> + {safeContainers.length > 1 ? ( + + + + + + ) : ( + )} )} - {/* 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 ? ( +
setLogsExpanded((v) => !v)} /> +
+ ) : ( + setLogsExpanded((v) => !v)} + /> + )}
)} diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index e2a5a1cf..5126ab7b 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -225,6 +225,7 @@ export function MobileStackDetail(props: EditorViewProps) { openLogViewer={openLogViewer} openBashModal={openBashModal} serviceAction={serviceAction} + key={`${activeNode?.id ?? 'local'}:${stackName}`} />
)} diff --git a/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx b/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx index ab127ef1..0662d70a 100644 --- a/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx @@ -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 { + 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( + , + ); + } + + 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( + , + ); + // 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( + , + ); + // Density reset; single container shows sparklines + expect(screen.getByText('cpu')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Compact view' })).toBeNull(); + }); +}); diff --git a/frontend/src/components/EditorLayout/editor-view-blocks.tsx b/frontend/src/components/EditorLayout/editor-view-blocks.tsx index af513343..8230ba44 100644 --- a/frontend/src/components/EditorLayout/editor-view-blocks.tsx +++ b/frontend/src/components/EditorLayout/editor-view-blocks.tsx @@ -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(null); const copiedUrlTimerRef = useRef(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 ? (
No containers running for this stack.
) : ( -
+ <> + {/* 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 ( +
+
+ {total} container{total !== 1 ? 's' : ''} + {running} up + {paused > 0 && {paused} paused} + {unhealthy > 0 && {unhealthy} unhealthy} +
+
+ + +
+
+ ); + })()} +
{safeContainers.map(container => { let mainPort: number | undefined; let mainPortPrivate: number | undefined; @@ -517,7 +561,7 @@ export function ContainersHealth({ )}
- {isActive ? ( + {isActive && density === 'detailed' ? (
@@ -552,6 +596,7 @@ export function ContainersHealth({ ); })}
+ )}
);