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.
This commit is contained in:
Anso
2026-04-18 23:38:56 -04:00
committed by GitHub
parent 88ec71fcaa
commit 82aabfe64c
5 changed files with 244 additions and 86 deletions
+24 -3
View File
@@ -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<T extends { Id?: string }>(list: T[]): Promise<Array<T & { healthStatus: 'healthy' | 'unhealthy' | 'starting' | 'none'; Image?: string; ImageID?: string }>> {
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
+33 -17
View File
@@ -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.
<Frame>
<img src="/images/stack-view/identity-header.png" alt="Stack identity header with breadcrumb, title, state pill, image digest, and action row" />
</Frame>
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.
<Frame>
<img src="/images/editor/editor-overview.png" alt="Stack editor with control buttons" />
</Frame>
## 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. |
<Warning>
**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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+5 -4
View File
@@ -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 });
+182 -62
View File
@@ -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<number | null>(null);
const [copiedDigest, setCopiedDigest] = useState<string | null>(null);
const copiedDigestTimerRef = useRef<number | null>(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() {
<Card className="rounded-xl border-muted bg-card">
<CardHeader className="p-4 pb-2">
<div className="flex flex-col gap-3">
{/* Stack Name */}
<CardTitle className="text-2xl font-medium">{stackName}</CardTitle>
{/* Identity block */}
<div className="flex flex-col gap-1.5">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
{(activeNode?.name || 'local')} <span className="text-muted-foreground/60"></span> stacks <span className="text-muted-foreground/60"></span> {stackName}
</div>
<div className="flex items-center gap-3 flex-wrap">
<CardTitle className="font-display italic text-3xl leading-none tracking-tight">{stackName}</CardTitle>
{(() => {
const pill = getStackStatePill(safeContainers);
if (!pill) return null;
return (
<span className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 ${pill.className}`}>
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${pill.dotClass} ${pill.pulse ? 'animate-[pulse_2.4s_ease-in-out_infinite]' : ''}`}
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em]">{pill.label}</span>
</span>
);
})()}
</div>
{(() => {
const first = safeContainers[0];
if (!first?.Image) return null;
const digest = first.ImageID ? first.ImageID.replace(/^sha256:/, '').slice(0, 12) : '';
return (
<div className="flex items-center gap-1.5 font-mono text-[11px] text-stat-subtitle">
<span>image <span className="text-muted-foreground/60">·</span> <span className="text-foreground/90">{first.Image}</span></span>
{digest && first.ImageID && (
<>
<span className="text-muted-foreground/60">·</span>
<span>digest <span className="text-foreground/90">{digest}</span></span>
<button
type="button"
aria-label={copiedDigest === first.ImageID ? 'Copied' : 'Copy digest'}
onClick={() => {
const id = first.ImageID as string;
void navigator.clipboard.writeText(id).then(() => {
setCopiedDigest(id);
if (copiedDigestTimerRef.current !== null) {
window.clearTimeout(copiedDigestTimerRef.current);
}
copiedDigestTimerRef.current = window.setTimeout(() => {
setCopiedDigest(prev => (prev === id ? null : prev));
copiedDigestTimerRef.current = null;
}, 1500);
});
}}
className="inline-flex h-4 w-4 items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors"
>
{copiedDigest === first.ImageID ? (
<Check className="h-3 w-3" strokeWidth={2} />
) : (
<Copy className="h-3 w-3" strokeWidth={1.5} />
)}
</button>
</>
)}
</div>
);
})()}
</div>
{/* Action Bar */}
{can('stack:deploy', 'stack', stackName) && (
<div className="flex items-center gap-2 flex-wrap">
{isRunning ? (
<>
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={stopStack} disabled={loadingAction !== null}>
<Square className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'stop' ? 'Stopping...' : 'Stop'}
</Button>
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={restartStack} disabled={loadingAction !== null}>
<RotateCw className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'restart' ? 'Restarting...' : 'Restart'}
</Button>
</>
<Button type="button" size="sm" className="rounded-lg bg-brand text-brand-foreground hover:bg-brand/90" onClick={restartStack} disabled={loadingAction !== null}>
<RotateCw className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'restart' ? 'Restarting...' : 'Restart'}
</Button>
) : (
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={deployStack} disabled={loadingAction !== null}>
<Button type="button" size="sm" className="rounded-lg bg-brand text-brand-foreground hover:bg-brand/90" onClick={deployStack} disabled={loadingAction !== null}>
<Play className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'deploy' ? 'Starting...' : 'Start'}
</Button>
)}
{isRunning && (
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={stopStack} disabled={loadingAction !== null}>
<Square className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'stop' ? 'Stopping...' : 'Stop'}
</Button>
)}
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={updateStack} disabled={loadingAction !== null}>
<CloudDownload className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'update' ? 'Updating...' : 'Update'}
</Button>
{trivy.available && isAdmin && isPaid && (
<Button
type="button"
size="sm"
variant="outline"
className="rounded-lg"
onClick={scanStackConfig}
disabled={loadingAction !== null || stackMisconfigScanning}
title="Scan compose configuration for misconfigurations"
>
{stackMisconfigScanning ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" strokeWidth={1.5} />
) : (
<ShieldCheck className="w-4 h-4 mr-2" strokeWidth={1.5} />
{(() => {
const canRollback = isPaid && backupInfo.exists;
const canScan = trivy.available && isAdmin && isPaid;
const hasOverflowExtras = canRollback || canScan;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" size="sm" variant="ghost" className="rounded-lg h-8 w-8 p-0" disabled={loadingAction !== null} aria-label="More actions">
<MoreVertical className="w-4 h-4" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{canRollback && (
<DropdownMenuItem onClick={rollbackStack} disabled={loadingAction !== null}>
<Undo2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
<div className="flex flex-col gap-0.5">
<span>{loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'}</span>
{backupInfo.timestamp && (
<span className="text-[10px] text-stat-subtitle font-mono">{new Date(backupInfo.timestamp).toLocaleString()}</span>
)}
</div>
</DropdownMenuItem>
)}
{stackMisconfigScanning ? 'Scanning...' : 'Scan config'}
</Button>
)}
{isPaid && backupInfo.exists && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={rollbackStack} disabled={loadingAction !== null}>
<Undo2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'}
</Button>
</TooltipTrigger>
<TooltipContent>
{backupInfo.timestamp
? `Roll back to backup from ${new Date(backupInfo.timestamp).toLocaleString()}`
: 'Roll back to previous deployment'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Button
type="button"
size="sm"
variant="ghost"
className="rounded-lg text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
disabled={loadingAction !== null}
onClick={() => {
setStackToDelete(selectedFile);
setDeleteDialogOpen(true);
}}
>
<Trash2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'delete' ? 'Deleting...' : 'Delete'}
</Button>
{canScan && (
<DropdownMenuItem onClick={scanStackConfig} disabled={loadingAction !== null || stackMisconfigScanning}>
{stackMisconfigScanning ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" strokeWidth={1.5} />
) : (
<ShieldCheck className="w-4 h-4 mr-2" strokeWidth={1.5} />
)}
{stackMisconfigScanning ? 'Scanning...' : 'Scan config'}
</DropdownMenuItem>
)}
{hasOverflowExtras && <DropdownMenuSeparator />}
<DropdownMenuItem
className="text-destructive focus:text-destructive focus:bg-destructive/10"
disabled={loadingAction !== null}
onClick={() => {
setStackToDelete(selectedFile);
setDeleteDialogOpen(true);
}}
>
<Trash2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'delete' ? 'Deleting...' : 'Delete'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
})()}
</div>
)}
</div>