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 00000000..ed53354a Binary files /dev/null and b/docs/images/stack-view/identity-header.png differ diff --git a/e2e/stacks.spec.ts b/e2e/stacks.spec.ts index 643adea7..90908553 100644 --- a/e2e/stacks.spec.ts +++ b/e2e/stacks.spec.ts @@ -64,10 +64,11 @@ test.describe('Stack management', () => { // 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'} + +
+ + ); + })()}
)}