feat(stack-view): anatomy panel replaces always-open yaml (#690)

* feat(stack-view): anatomy panel replaces always-open yaml

Introduce StackAnatomyPanel as the default right-column surface of the stack
view, replacing the always-visible Monaco YAML editor. The panel parses
compose.yaml client-side and surfaces services, ports, volumes, restart
policy, env file with missing-variable detection, network, and git/local
source in a compact, read-only format. An inline banner surfaces pending
image updates with risk classification (patch/minor/major) and an apply
button gated on edit permissions.

Compose editing remains one click away: the anatomy header exposes an
"edit compose.yaml" toggle that slides the Monaco editor into the same
right-column slot with the full tabs, Git Source, Save, and Save & Deploy
toolbar. Closing the editor discards unsaved changes and restores the
anatomy view.

The overall stack view is now a two-column grid: left column stacks the
identity header, per-container health strip, and logs viewer; right
column holds the anatomy panel or the Monaco editor. Missing-variable
detection covers the full compose interpolation grammar, including
defaulted (:- / -) and required (:? / ?) modifiers.

* fix(stack-view): restore Git Source accessible name on anatomy source row

The anatomy panel's source row is the primary Git Source control on the
default stack view. Without an explicit accessible name, screen readers
announced only the raw git ref or 'local', and role-based locators could
not target it.
This commit is contained in:
Anso
2026-04-19 01:13:33 -04:00
committed by GitHub
parent a65a1c0e86
commit 9e41d5e6b8
6 changed files with 593 additions and 18 deletions
+24
View File
@@ -154,6 +154,30 @@ The structured viewer holds up to 10,000 lines; older entries are dropped from t
**Raw terminal** keeps the original xterm-based viewer for cases where ANSI art, control sequences, or interactive TTY output matter. Lines include the raw ISO timestamp.
## Anatomy panel
The right half of the stack view is the anatomy panel: a read-only summary of what the compose file declares, without making you open the YAML editor.
<Frame>
<img src="/images/stack-view/anatomy-panel.png" alt="Stack view with the anatomy panel on the right showing services, ports, volumes, env file, network, and source" />
</Frame>
Each row maps one compose concept to the value it resolves to right now:
- **Services** — Cyan pills, one per key under `services:`.
- **Ports** — `{host} → {container}/{proto}` per service, host port highlighted.
- **Volumes** — `{host-path} → {container-path}` per mount.
- **Restart** — The effective restart policy.
- **Env file** — Filename plus a count of defined variables. If the compose file references a `${VAR}` that the env file does not define and has no `:-default`, the missing name is called out inline in rose italic.
- **Network** — Derived network name and driver (default `{stack}_default · bridge`).
- **Source** — `git · {host}/{repo}#{branch}` when Git Source is configured, else `local`. The whole row is clickable and opens the Git Source dialog. A pulsing cyan dot appears when upstream has moved ahead of the working copy.
If an image update is available for the primary service, an inline banner appears below the rows with the version bump (`27.1.4 → 27.1.5`), risk classification (`safe · patch`, `minor`, or `major · review required`), and an **apply** button that runs the update against the current scheduled-task pipeline. Major bumps show a rose banner and require explicit review before applying.
### Editing compose.yaml
Anatomy is the default surface. When you need to change the compose file, click **edit compose.yaml** in the anatomy header. The Monaco editor slides in over the anatomy panel with the full editing toolbar (tabs for `compose.yaml` and `.env`, Git Source shortcut, Save / Save & Deploy). Close the editor with the `×` button in its header to return to the anatomy view; unsaved changes are discarded when you close.
## 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

+17 -1
View File
@@ -49,7 +49,8 @@
"react-use-measure": "^2.1.7",
"recharts": "^3.8.1",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"yaml": "^2.8.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -7187,6 +7188,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+2 -1
View File
@@ -53,7 +53,8 @@
"react-use-measure": "^2.1.7",
"recharts": "^3.8.1",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"yaml": "^2.8.3"
},
"overrides": {
"dompurify": "^3.3.3"
+49 -16
View File
@@ -50,6 +50,7 @@ import { GitSourcePanel } from './stack/GitSourcePanel';
import { AppStoreView } from './AppStoreView';
import { LogViewer } from './LogViewer';
import StructuredLogViewer from './StructuredLogViewer';
import StackAnatomyPanel from './StackAnatomyPanel';
import { Sparkline } from './ui/sparkline';
import { GlobalObservabilityView } from './GlobalObservabilityView';
import { FleetView } from './FleetView';
@@ -314,6 +315,7 @@ export default function EditorLayout() {
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops' | 'auto-updates' | 'security-history'>('dashboard');
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [editingCompose, setEditingCompose] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [remoteStackResults, setRemoteStackResults] = useState<Record<number, Array<{ file: string; status: 'running' | 'exited' | 'unknown' }>>>({});
const [remoteSearchLoading, setRemoteSearchLoading] = useState(false);
@@ -1071,6 +1073,7 @@ export default function EditorLayout() {
}
setIsFileLoading(true);
setIsEditing(false); // Reset to view mode when loading a new file
setEditingCompose(false); // Default back to anatomy on stack switch
try {
const res = await apiFetch(`/stacks/${filename}`);
const text = await res.text();
@@ -1381,9 +1384,9 @@ export default function EditorLayout() {
}
};
const updateStack = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const updateStack = async (e?: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
if (!selectedFile || isStackBusy(selectedFile)) return;
const stackFile = selectedFile;
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
@@ -2651,11 +2654,11 @@ export default function EditorLayout() {
</AdmiralGate>
) : !isLoading && selectedFile && activeView === 'editor' ? (
<ErrorBoundary>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-stretch">
{/* Left Column (Command Center & Terminal) */}
<div className="flex flex-col h-full gap-6">
{/* Command Center Card */}
<Card className="rounded-xl border-muted bg-card">
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2 min-h-[600px] h-[calc(100vh-160px)] max-h-[1040px]">
{/* Left column: identity + health strip + logs, stacked */}
<div className="flex flex-col gap-6 min-h-0">
{/* Command Center Card (identity + health strip) */}
<Card className="rounded-xl border-muted bg-card shrink-0">
<CardHeader className="p-4 pb-2">
<div className="flex flex-col gap-3">
{/* Identity block */}
@@ -2935,8 +2938,8 @@ export default function EditorLayout() {
</CardContent>
</Card>
{/* Logs Section */}
<div className="flex-1 min-h-[320px] flex flex-col gap-2">
{/* Logs Section (fills remaining left-column height) */}
<div className="flex-1 min-h-0 flex flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-stat-subtitle">Logs</h3>
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
@@ -2978,8 +2981,9 @@ export default function EditorLayout() {
</div>
</div>
{/* Right Column (The Editor) */}
<Card className="rounded-xl border-muted overflow-hidden flex flex-col h-full min-h-[600px] bg-card">
{/* Right column: anatomy panel by default, Monaco editor when editing */}
{editingCompose ? (
<Card className="rounded-xl border-muted overflow-hidden flex flex-col h-full min-h-0 bg-card">
<div className="p-4 border-b border-muted flex items-center justify-between shrink-0">
<div className="flex items-center gap-4">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env')}>
@@ -3010,8 +3014,9 @@ export default function EditorLayout() {
</Select>
)}
</div>
{can('stack:edit', 'stack', stackName) && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
{can('stack:edit', 'stack', stackName) && (
<>
<Button
size="sm"
variant="outline"
@@ -3054,8 +3059,23 @@ export default function EditorLayout() {
</DropdownMenu>
</div>
)}
</div>
)}
</>
)}
<Button
size="sm"
variant="ghost"
className="rounded-lg h-8 w-8 p-0"
onClick={() => {
if (isEditing) {
discardChanges();
}
setEditingCompose(false);
}}
aria-label="Close editor"
>
<X className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
</div>
<div className="flex-1 min-h-0 flex flex-col">
{activeTab === 'env' && (
@@ -3099,6 +3119,19 @@ export default function EditorLayout() {
</div>
</div>
</Card>
) : (
<StackAnatomyPanel
stackName={stackName}
content={content}
envContent={envContent}
selectedEnvFile={selectedEnvFile}
gitSourcePending={Boolean(gitSourcePendingMap[stackName])}
onEditCompose={() => setEditingCompose(true)}
onOpenGitSource={() => setGitSourceOpen(true)}
onApplyUpdate={() => { void updateStack(); }}
canEdit={can('stack:edit', 'stack', stackName)}
/>
)}
</div>
</ErrorBoundary>
) : activeView === 'global-observability' ? (
@@ -0,0 +1,501 @@
import { useEffect, useMemo, useState } from 'react';
import { parse as parseYaml } from 'yaml';
import { GitBranch, Pencil, ExternalLink, Rocket } from 'lucide-react';
import { Button } from './ui/button';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
interface StackAnatomyPanelProps {
stackName: string;
content: string;
envContent: string;
selectedEnvFile: string;
gitSourcePending: boolean;
onEditCompose: () => void;
onOpenGitSource: () => void;
onApplyUpdate: () => void;
canEdit: boolean;
}
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
interface UpdatePreviewSummary {
has_update: boolean;
primary_image: string | null;
current_tag: string | null;
next_tag: string | null;
semver_bump: SemverBump;
blocked: boolean;
blocked_reason: string | null;
}
interface UpdatePreview {
summary: UpdatePreviewSummary;
changelog: string | null;
}
interface GitSourceInfo {
repo_url: string;
branch: string;
compose_path?: string;
}
interface PortRow {
host: string;
container: string;
proto: string;
}
interface VolumeRow {
host: string;
container: string;
}
interface Anatomy {
services: string[];
ports: Record<string, PortRow[]>;
volumes: Record<string, VolumeRow[]>;
restart: string | null;
envFiles: string[];
networks: string[];
referencedVars: string[];
}
// Matches ${VAR}, ${VAR:-default}, ${VAR-default}, ${VAR:?err}, ${VAR?err}.
// Capture group 1 is the variable name, group 2 (optional) is the modifier form.
const INTERPOLATION_REGEX = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?])[^}]*)?\}/g;
function parsePortMapping(raw: unknown): PortRow | null {
if (typeof raw === 'string') {
const s = raw.replace(/^"|"$/g, '');
const protoMatch = s.match(/\/(tcp|udp)$/i);
const proto = protoMatch ? protoMatch[1].toLowerCase() : 'tcp';
const body = proto ? s.replace(/\/(tcp|udp)$/i, '') : s;
const parts = body.split(':');
if (parts.length === 2) return { host: parts[0], container: parts[1], proto };
if (parts.length === 3) return { host: parts[1], container: parts[2], proto };
return { host: body, container: body, proto };
}
if (raw && typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
const host = obj.published !== undefined ? String(obj.published) : '';
const container = obj.target !== undefined ? String(obj.target) : '';
const proto = obj.protocol ? String(obj.protocol) : 'tcp';
if (host && container) return { host, container, proto };
}
return null;
}
function parseVolumeMapping(raw: unknown): VolumeRow | null {
if (typeof raw === 'string') {
const parts = raw.split(':');
if (parts.length >= 2) return { host: parts[0], container: parts[1] };
return null;
}
if (raw && typeof raw === 'object') {
const obj = raw as Record<string, unknown>;
if (obj.source && obj.target) return { host: String(obj.source), container: String(obj.target) };
}
return null;
}
interface ServiceAnatomy {
ports: PortRow[];
volumes: VolumeRow[];
restart: string | null;
envFiles: string[];
networks: string[];
}
function parseServiceBlock(svc: Record<string, unknown>): ServiceAnatomy {
const ports: PortRow[] = Array.isArray(svc.ports)
? svc.ports.map(parsePortMapping).filter((r): r is PortRow => r !== null)
: [];
const volumes: VolumeRow[] = Array.isArray(svc.volumes)
? svc.volumes.map(parseVolumeMapping).filter((r): r is VolumeRow => r !== null)
: [];
const restart = typeof svc.restart === 'string' ? svc.restart : null;
const envFiles: string[] = typeof svc.env_file === 'string'
? [svc.env_file]
: Array.isArray(svc.env_file)
? svc.env_file.filter((e): e is string => typeof e === 'string')
: [];
let networks: string[] = [];
if (Array.isArray(svc.networks)) {
networks = svc.networks.filter((n): n is string => typeof n === 'string');
} else if (svc.networks && typeof svc.networks === 'object') {
networks = Object.keys(svc.networks as Record<string, unknown>);
}
return { ports, volumes, restart, envFiles, networks };
}
// `:-` and `-` forms supply a default value (no env entry required);
// `:?` and `?` forms signal a required variable (the user still needs to define it).
function extractInterpolations(yamlText: string): string[] {
const referenced = new Set<string>();
const defaulted = new Set<string>();
for (const m of yamlText.matchAll(INTERPOLATION_REGEX)) {
referenced.add(m[1]);
if (m[2] === ':-' || m[2] === '-') defaulted.add(m[1]);
}
return Array.from(referenced).filter(v => !defaulted.has(v));
}
function parseAnatomy(yamlText: string): Anatomy | null {
if (!yamlText.trim()) return null;
let parsed: unknown;
try {
parsed = parseYaml(yamlText);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') return null;
const root = parsed as Record<string, unknown>;
const servicesObj = (root.services && typeof root.services === 'object')
? root.services as Record<string, unknown>
: {};
const serviceNames = Object.keys(servicesObj);
const ports: Record<string, PortRow[]> = {};
const volumes: Record<string, VolumeRow[]> = {};
let restart: string | null = null;
const envFilesSet = new Set<string>();
const networksSet = new Set<string>();
for (const name of serviceNames) {
const svc = servicesObj[name];
if (!svc || typeof svc !== 'object') continue;
const a = parseServiceBlock(svc as Record<string, unknown>);
if (a.ports.length > 0) ports[name] = a.ports;
if (a.volumes.length > 0) volumes[name] = a.volumes;
if (restart === null && a.restart !== null) restart = a.restart;
for (const f of a.envFiles) envFilesSet.add(f);
for (const n of a.networks) networksSet.add(n);
}
if (root.networks && typeof root.networks === 'object' && !Array.isArray(root.networks)) {
for (const n of Object.keys(root.networks)) networksSet.add(n);
}
return {
services: serviceNames,
ports,
volumes,
restart,
envFiles: Array.from(envFilesSet),
networks: Array.from(networksSet),
referencedVars: extractInterpolations(yamlText),
};
}
function parseEnvKeys(envText: string): Set<string> {
const keys = new Set<string>();
for (const raw of envText.split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq <= 0) continue;
keys.add(line.slice(0, eq).trim());
}
return keys;
}
function formatGitSource(src: GitSourceInfo): string {
try {
const url = new URL(src.repo_url);
const host = url.host;
const repo = url.pathname.replace(/^\//, '').replace(/\.git$/, '');
return `${host}/${repo}#${src.branch}`;
} catch {
return `${src.repo_url}#${src.branch}`;
}
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[72px_1fr] gap-3 border-t border-muted py-2 first:border-t-0">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle pt-0.5">{label}</span>
<div className="min-w-0 text-[12px] text-foreground/90">{children}</div>
</div>
);
}
export default function StackAnatomyPanel({
stackName,
content,
envContent,
selectedEnvFile,
gitSourcePending,
onEditCompose,
onOpenGitSource,
onApplyUpdate,
canEdit,
}: StackAnatomyPanelProps) {
const anatomy = useMemo(() => parseAnatomy(content), [content]);
const envKeys = useMemo(() => parseEnvKeys(envContent), [envContent]);
const missingVars = useMemo(() => {
if (!anatomy) return [];
return anatomy.referencedVars.filter(v => !envKeys.has(v));
}, [anatomy, envKeys]);
const envVarCount = envKeys.size;
const [gitSource, setGitSource] = useState<GitSourceInfo | null>(null);
const [updatePreview, setUpdatePreview] = useState<UpdatePreview | null>(null);
useEffect(() => {
let cancelled = false;
const run = async () => {
try {
const res = await apiFetch(`/stacks/${stackName}/git-source`);
if (cancelled) return;
if (res.ok) {
const data = await res.json();
setGitSource({ repo_url: data.repo_url, branch: data.branch, compose_path: data.compose_path });
} else {
setGitSource(null);
}
} catch {
if (!cancelled) setGitSource(null);
}
};
void run();
return () => { cancelled = true; };
}, [stackName]);
useEffect(() => {
let cancelled = false;
const run = async () => {
try {
const res = await apiFetch(`/stacks/${stackName}/update-preview`);
if (cancelled) return;
if (res.ok) {
const data = await res.json();
setUpdatePreview(data);
} else {
setUpdatePreview(null);
}
} catch {
if (!cancelled) setUpdatePreview(null);
}
};
void run();
return () => { cancelled = true; };
}, [stackName]);
const networkName = anatomy && anatomy.networks.length > 0
? anatomy.networks[0]
: `${stackName}_default`;
const firstEnvFile = anatomy?.envFiles[0] ?? selectedEnvFile ?? null;
const primaryHostPort = useMemo(() => {
if (!anatomy) return null;
for (const svc of anatomy.services) {
const rows = anatomy.ports[svc];
if (rows && rows.length > 0) return rows[0].host;
}
return null;
}, [anatomy]);
const bump = updatePreview?.summary.semver_bump ?? 'none';
const hasUpdate = Boolean(updatePreview?.summary.has_update);
const blocked = Boolean(updatePreview?.summary.blocked);
const bannerSeverity: 'danger' | 'warn' | 'ok' = bump === 'major' || blocked
? 'danger'
: bump === 'minor' ? 'warn' : 'ok';
const bannerTone = bannerSeverity === 'danger'
? 'border-destructive/40 bg-destructive/[0.06] text-destructive'
: bannerSeverity === 'warn'
? 'border-warning/40 bg-warning/[0.06] text-warning'
: 'border-success/40 bg-success/[0.06] text-success';
const applyBtnTone = bannerSeverity === 'danger'
? 'border-destructive/40 text-destructive hover:bg-destructive/10'
: bannerSeverity === 'warn'
? 'border-warning/40 text-warning hover:bg-warning/10'
: 'border-success/40 text-success hover:bg-success/10';
const bumpLabel = bump === 'none' || bump === 'unknown' ? '' : `${bump}`;
const bannerLeadIn = blocked
? 'review required'
: bump === 'patch'
? 'safe to apply'
: bump === 'minor'
? 'review recommended'
: bump === 'major'
? 'breaking changes possible'
: '';
return (
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
<div className="flex items-center justify-between border-b border-muted px-3 py-2 gap-2">
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">anatomy</span>
{canEdit && (
<button
type="button"
onClick={onEditCompose}
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
>
<Pencil className="h-3 w-3" strokeWidth={1.5} />
edit compose.yaml
</button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3">
{!anatomy ? (
<div className="py-3 font-mono text-[11px] text-stat-subtitle">Unable to parse compose.yaml.</div>
) : (
<>
<Row label="services">
{anatomy.services.length === 0 ? (
<span className="text-stat-subtitle">none defined</span>
) : (
<div className="flex flex-wrap gap-1">
{anatomy.services.map(s => (
<span key={s} className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">
{s}
</span>
))}
</div>
)}
</Row>
<Row label="ports">
{Object.keys(anatomy.ports).length === 0 ? (
<span className="text-stat-subtitle">none</span>
) : (
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
{Object.entries(anatomy.ports).flatMap(([svc, rows]) =>
rows.map((r, i) => (
<div key={`${svc}-${i}`} className="flex items-center gap-1.5">
{anatomy.services.length > 1 && (
<span className="text-stat-subtitle">{svc}</span>
)}
<span className="font-semibold text-foreground">{r.host}</span>
<span className="text-stat-subtitle"></span>
<span>{r.container}/{r.proto}</span>
</div>
)),
)}
</div>
)}
</Row>
<Row label="volumes">
{Object.keys(anatomy.volumes).length === 0 ? (
<span className="text-stat-subtitle">none</span>
) : (
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
{Object.entries(anatomy.volumes).flatMap(([svc, rows]) =>
rows.map((r, i) => (
<div key={`${svc}-${i}`} className="flex items-center gap-1.5 min-w-0">
{anatomy.services.length > 1 && (
<span className="text-stat-subtitle shrink-0">{svc}</span>
)}
<span className="truncate text-foreground/90">{r.host}</span>
<span className="text-stat-subtitle shrink-0"></span>
<span className="truncate">{r.container}</span>
</div>
)),
)}
</div>
)}
</Row>
<Row label="restart">
<span className="font-mono text-[11px]">{anatomy.restart ?? <span className="text-stat-subtitle">default</span>}</span>
</Row>
<Row label="env_file">
{!firstEnvFile ? (
<span className="text-stat-subtitle">none</span>
) : (
<div className="flex flex-col gap-0.5">
<div className="font-mono text-[11px]">
<span className="text-foreground/90">{firstEnvFile}</span>
<span className="text-stat-subtitle"> · {envVarCount} var{envVarCount === 1 ? '' : 's'}</span>
</div>
{missingVars.length > 0 && (
<div className="flex flex-wrap gap-1 font-mono text-[11px] text-destructive">
{missingVars.map(v => (
<span key={v}>{'${'}{v}{'}'} missing</span>
))}
</div>
)}
</div>
)}
</Row>
<Row label="network">
<span className="font-mono text-[11px]">{networkName} <span className="text-stat-subtitle">· bridge</span></span>
</Row>
<Row label="source">
<button
type="button"
onClick={onOpenGitSource}
aria-label="Git Source"
className="inline-flex items-center gap-1.5 font-mono text-[11px] text-left hover:text-brand transition-colors"
>
<GitBranch className="h-3 w-3 shrink-0" strokeWidth={1.5} />
{gitSource ? (
<span className="truncate">git <span className="text-stat-subtitle">·</span> {formatGitSource(gitSource)}</span>
) : (
<span>local</span>
)}
{gitSourcePending && (
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-brand animate-pulse" />
)}
</button>
</Row>
</>
)}
{hasUpdate && updatePreview && (
<div className={cn('mt-3 mb-3 rounded-lg border p-3', bannerTone)}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-mono text-[11px] uppercase tracking-wide">
Update available
{updatePreview.summary.current_tag && updatePreview.summary.next_tag && (
<span className="text-foreground">
{' · '}
<span className="text-stat-subtitle">{updatePreview.summary.current_tag}</span>
{' -> '}
<span className="text-foreground font-semibold">{updatePreview.summary.next_tag}</span>
</span>
)}
</div>
<div className="mt-1 font-mono text-[11px] text-foreground/80 leading-relaxed">
{[
bumpLabel,
bannerLeadIn,
updatePreview.changelog ? updatePreview.changelog.split(/[.\n]/)[0] : '',
].filter(Boolean).join(' · ')}
</div>
{blocked && updatePreview.summary.blocked_reason && (
<div className="mt-1 font-mono text-[10px] text-destructive">{updatePreview.summary.blocked_reason}</div>
)}
</div>
{canEdit && !blocked && (
<Button
type="button"
size="sm"
variant="outline"
className={cn('shrink-0 h-7 gap-1', applyBtnTone)}
onClick={onApplyUpdate}
>
<Rocket className="h-3 w-3" strokeWidth={1.5} />
apply
</Button>
)}
</div>
</div>
)}
</div>
{anatomy && anatomy.services.length > 0 && (
<div className="border-t border-muted px-3 py-2 flex items-center justify-between">
<span className="font-mono text-[10px] uppercase tracking-wide text-stat-subtitle">
{Object.keys(anatomy.ports).length > 0 ? 'exposed' : 'no ports'}
</span>
{primaryHostPort && (
<span className="inline-flex items-center gap-1 font-mono text-[10px] text-stat-subtitle">
<ExternalLink className="h-3 w-3" strokeWidth={1.5} />
:{primaryHostPort}
</span>
)}
</div>
)}
</div>
);
}