From 82aabfe64caa43548757f0f13fcaadde1b5a0518 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 18 Apr 2026 23:38:56 -0400 Subject: [PATCH] feat(stack-view): identity header with health state and action hierarchy (#688) * feat(stack-view): identity header with health state and action hierarchy Redesign the stack view header around three questions: what is this, is it healthy, what does it do. Surface Docker healthcheck state, primary image tag, and image digest; group actions by frequency. - Backend: extend /api/stacks/:name/containers with healthStatus (healthy / unhealthy / starting / none), Image, and ImageID by inspecting each container in parallel. - Frontend: replace flat CardHeader with breadcrumb, italic serif title, colored state pill with pulse, and a mono image/digest line with a one-click copy button for the full digest. - Frontend: action hierarchy - primary cyan Restart/Start, outline Stop and Update, and an overflow menu for Rollback, Scan config, and Delete. - Docs: new Stack header section and updated controlling-a-running-stack tables showing primary/secondary/overflow grouping. * test(e2e): open overflow menu to reach stack Delete action Destructive actions now live under the stack toolbar overflow menu rather than as a flat top-level button, so the delete flow must click More actions before selecting the Delete menu item. --- backend/src/services/DockerController.ts | 27 ++- docs/features/stack-management.mdx | 50 +++-- docs/images/stack-view/identity-header.png | Bin 0 -> 14729 bytes e2e/stacks.spec.ts | 9 +- frontend/src/components/EditorLayout.tsx | 244 +++++++++++++++------ 5 files changed, 244 insertions(+), 86 deletions(-) create mode 100644 docs/images/stack-view/identity-header.png diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index a6f3b573..c6418dc7 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -801,7 +801,7 @@ class DockerController { // Map to frontend's expected interface // Note: docker compose ps returns Name (singular), but frontend expects Names (array) // Dockerode returns Names with leading slash, so we add it for compatibility - return containers.map((c) => { + const mapped = containers.map((c) => { let Ports: { PrivatePort: number, PublicPort: number }[] = []; if (c.Publishers && Array.isArray(c.Publishers)) { Ports = c.Publishers @@ -817,11 +817,12 @@ class DockerController { Ports }; }); + return await this.enrichContainers(mapped); } // SMART FALLBACK: Trigger when docker compose ps returns empty // This handles legacy containers with incorrect project labels - return await this.smartFallback(stackName, stackDir); + return await this.enrichContainers(await this.smartFallback(stackName, stackDir)); } catch (error) { // If command fails (e.g., stack not deployed, invalid YAML, missing env_file) @@ -829,10 +830,30 @@ class DockerController { console.error(`Docker Compose Error for ${stackName}:`, execError.stderr || execError.message); // Try smart fallback even on error - return await this.smartFallback(stackName, stackDir); + return await this.enrichContainers(await this.smartFallback(stackName, stackDir)); } } + /** + * Inspect each container to attach healthcheck status, image tag, and image digest. + * Each mapper catches its own errors, so Promise.all never rejects (allSettled adds ceremony with no behavior change). + */ + private async enrichContainers(list: T[]): Promise> { + return Promise.all(list.map(async (c) => { + const base = { ...c, healthStatus: 'none' as const }; + if (!c.Id) return base; + try { + const info = await this.docker.getContainer(c.Id).inspect(); + const health = info.State?.Health?.Status; + const healthStatus: 'healthy' | 'unhealthy' | 'starting' | 'none' = + health === 'healthy' || health === 'unhealthy' || health === 'starting' ? health : 'none'; + return { ...c, healthStatus, Image: info.Config?.Image, ImageID: info.Image }; + } catch { + return base; + } + })); + } + /** * Smart Fallback: Find legacy containers by parsing compose YAML definitions. * This handles containers that were deployed with incorrect project labels diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 79d8d074..73cf9c1d 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -98,35 +98,51 @@ All discovered stacks appear in the left sidebar. Each shows a color-coded statu Use the **search box** above the list to filter stacks by name. +## Stack header + +Opening a stack puts identity and state front and center. + + + Stack identity header with breadcrumb, title, state pill, image digest, and action row + + +The header answers three questions at a glance: + +- **What is this?** A breadcrumb (`node › stacks › name`) and the stack name as the title. +- **Is it healthy?** A pulsing pill to the right of the title reports the live state: + - `running · healthy` (green) when at least one container reports a passing healthcheck. + - `running · unhealthy` (red) when any container reports a failing healthcheck. + - `running · starting` (amber) during the Docker healthcheck start period. + - `running` (green) when no healthcheck is defined. + - `exited` (red) when no containers are up. +- **What does it ship?** A mono line under the title shows the primary image tag and the short image digest, with a copy button to grab the full digest. + ## Deploying a stack Select a stack and click **Start** (or **Deploy** from the context menu) in the stack header. This runs `docker compose up -d`, pulling images if needed and creating or recreating containers. - - Stack editor with control buttons - - ## Controlling a running stack -The stack header shows different actions depending on whether the stack is running or stopped. +The stack header groups actions by frequency of use. The most common action is the filled cyan button on the left, everyday secondaries sit next to it, and destructive or occasional actions are tucked behind the `⋯` overflow. **When running:** -| Button | Command | What it does | -|--------|---------|--------------| -| **Stop** | `docker compose stop` | Stops containers without removing them. State is preserved. | -| **Restart** | `docker compose restart` | Restarts all containers in the stack. | -| **Update** | `docker compose pull` + `up -d` | Pulls the latest image tags and recreates containers. | -| **Rollback** | Restores backup | Reverts compose and env files to the pre-deploy snapshot and redeploys. Only shown when a backup exists (Skipper+). | -| **Delete** | `down --volumes` + removes files | Stops and removes containers and volumes, then deletes the stack directory. | +| Placement | Button | Command | What it does | +|-----------|--------|---------|--------------| +| Primary | **Restart** | `docker compose restart` | Restarts all containers in the stack. | +| Secondary | **Stop** | `docker compose stop` | Stops containers without removing them. State is preserved. | +| Secondary | **Update** | `docker compose pull` + `up -d` | Pulls the latest image tags and recreates containers. | +| Overflow `⋯` | **Rollback** | Restores backup | Reverts compose and env files to the pre-deploy snapshot and redeploys. Only shown when a backup exists (Skipper+). | +| Overflow `⋯` | **Scan config** | Trivy config scan | Scans the compose file for misconfigurations (Skipper+, admin). | +| Overflow `⋯` | **Delete** | `down --volumes` + removes files | Stops and removes containers and volumes, then deletes the stack directory. | **When stopped:** -| Button | Command | What it does | -|--------|---------|--------------| -| **Start** | `docker compose up -d` | Starts the stack. | -| **Update** | `docker compose pull` + `up -d` | Pulls the latest image tags and recreates containers. | -| **Delete** | Removes files | Deletes the stack directory. | +| Placement | Button | Command | What it does | +|-----------|--------|---------|--------------| +| Primary | **Start** | `docker compose up -d` | Starts the stack. | +| Secondary | **Update** | `docker compose pull` + `up -d` | Pulls the latest image tags and recreates containers. | +| Overflow `⋯` | **Delete** | Removes files | Deletes the stack directory. | **Delete** is irreversible. It removes the stack directory, including `compose.yaml`, `.env`, and any bind-mounted files stored there. Back up important files before deleting. diff --git a/docs/images/stack-view/identity-header.png b/docs/images/stack-view/identity-header.png new file mode 100644 index 0000000000000000000000000000000000000000..ed53354ab62e807c83bcaeb020e4b43f13fe47ce GIT binary patch literal 14729 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 { // Click on the stack to open the editor await page.getByText(TEST_STACK).first().click(); - // The toolbar Delete button has the Lucide Trash2 icon - const deleteBtn = page.locator('button:has(.lucide-trash-2)'); - await expect(deleteBtn).toBeVisible({ timeout: 10_000 }); - await deleteBtn.click(); + // Destructive actions live under the overflow menu in the stack toolbar + await page.getByRole('button', { name: 'More actions' }).click(); + const deleteItem = page.getByRole('menuitem', { name: /delete/i }); + await expect(deleteItem).toBeVisible({ timeout: 10_000 }); + await deleteItem.click(); // AlertDialog confirmation await expect(page.getByRole('alertdialog')).toBeVisible({ timeout: 5_000 }); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 8e46d388..fdb51aa8 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -21,7 +21,7 @@ import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highli import { CursorProvider, Cursor, CursorContainer, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; -import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { LabelPill, LabelDot } from './LabelPill'; import { type Label as StackLabel } from './label-types'; @@ -73,6 +73,9 @@ interface ContainerInfo { State: string; Status?: string; Ports?: { PrivatePort: number, PublicPort: number }[]; + healthStatus?: 'healthy' | 'unhealthy' | 'starting' | 'none'; + Image?: string; + ImageID?: string; } interface StackStatus { @@ -101,12 +104,69 @@ const formatBytes = (bytes: number) => { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; +type StackPill = { label: string; dotClass: string; className: string; pulse: boolean }; + +const getStackStatePill = (containers: ContainerInfo[]): StackPill | null => { + if (!containers || containers.length === 0) return null; + const running = containers.some(c => c.State === 'running'); + if (!running) { + return { + label: 'exited', + dotClass: 'bg-destructive', + className: 'border-destructive/40 bg-destructive/10 text-destructive', + pulse: false, + }; + } + const anyUnhealthy = containers.some(c => c.healthStatus === 'unhealthy'); + const anyStarting = containers.some(c => c.healthStatus === 'starting'); + const anyHealthy = containers.some(c => c.healthStatus === 'healthy'); + if (anyUnhealthy) { + return { + label: 'running · unhealthy', + dotClass: 'bg-destructive', + className: 'border-destructive/40 bg-destructive/10 text-destructive', + pulse: true, + }; + } + if (anyStarting) { + return { + label: 'running · starting', + dotClass: 'bg-warning', + className: 'border-warning/40 bg-warning/10 text-warning', + pulse: true, + }; + } + if (anyHealthy) { + return { + label: 'running · healthy', + dotClass: 'bg-success', + className: 'border-success/40 bg-success/10 text-success', + pulse: true, + }; + } + return { + label: 'running', + dotClass: 'bg-success', + className: 'border-success/40 bg-success/10 text-success', + pulse: true, + }; +}; + export default function EditorLayout() { const { isAdmin, can } = useAuth(); const { isPaid, license } = useLicense(); const { status: trivy } = useTrivyStatus(); const [stackMisconfigScanning, setStackMisconfigScanning] = useState(false); const [stackMisconfigScanId, setStackMisconfigScanId] = useState(null); + const [copiedDigest, setCopiedDigest] = useState(null); + const copiedDigestTimerRef = useRef(null); + useEffect(() => { + return () => { + if (copiedDigestTimerRef.current !== null) { + window.clearTimeout(copiedDigestTimerRef.current); + } + }; + }, []); const { nodes, activeNode, setActiveNode, nodeMeta } = useNodes(); // Stable ref so notification callbacks always read the latest nodes list // without needing nodes in their dependency arrays (which would cause loops). @@ -2555,81 +2615,141 @@ export default function EditorLayout() {
- {/* Stack Name */} - {stackName} + {/* Identity block */} +
+
+ {(activeNode?.name || 'local')} stacks {stackName} +
+
+ {stackName} + {(() => { + const pill = getStackStatePill(safeContainers); + if (!pill) return null; + return ( + + + ); + })()} +
+ {(() => { + const first = safeContainers[0]; + if (!first?.Image) return null; + const digest = first.ImageID ? first.ImageID.replace(/^sha256:/, '').slice(0, 12) : ''; + return ( +
+ image · {first.Image} + {digest && first.ImageID && ( + <> + · + digest {digest} + + + )} +
+ ); + })()} +
{/* Action Bar */} {can('stack:deploy', 'stack', stackName) && (
{isRunning ? ( - <> - - - + ) : ( - )} + {isRunning && ( + + )} - {trivy.available && isAdmin && isPaid && ( - + + + {canRollback && ( + + +
+ {loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'} + {backupInfo.timestamp && ( + {new Date(backupInfo.timestamp).toLocaleString()} + )} +
+
)} - {stackMisconfigScanning ? 'Scanning...' : 'Scan config'} - - )} - {isPaid && backupInfo.exists && ( - - - - - - - {backupInfo.timestamp - ? `Roll back to backup from ${new Date(backupInfo.timestamp).toLocaleString()}` - : 'Roll back to previous deployment'} - - - - )} - + {canScan && ( + + {stackMisconfigScanning ? ( + + ) : ( + + )} + {stackMisconfigScanning ? 'Scanning...' : 'Scan config'} + + )} + {hasOverflowExtras && } + { + setStackToDelete(selectedFile); + setDeleteDialogOpen(true); + }} + > + + {loadingAction === 'delete' ? 'Deleting...' : 'Delete'} + +
+ + ); + })()}
)}