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