diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index c883cd54..2cee46d5 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -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. + + + Stack view with the anatomy panel on the right showing services, ports, volumes, env file, network, and source + + +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. diff --git a/docs/images/stack-view/anatomy-panel.png b/docs/images/stack-view/anatomy-panel.png new file mode 100644 index 00000000..5f6e0ebf Binary files /dev/null and b/docs/images/stack-view/anatomy-panel.png differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8568d737..9207940c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 21def523..c39a4d51 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 8d5a0caf..8cff9f78 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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(null); const [isEditing, setIsEditing] = useState(false); + const [editingCompose, setEditingCompose] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [remoteStackResults, setRemoteStackResults] = useState>>({}); 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() { ) : !isLoading && selectedFile && activeView === 'editor' ? ( -
- {/* Left Column (Command Center & Terminal) */} -
- {/* Command Center Card */} - +
+ {/* Left column: identity + health strip + logs, stacked */} +
+ {/* Command Center Card (identity + health strip) */} +
{/* Identity block */} @@ -2935,8 +2938,8 @@ export default function EditorLayout() { - {/* Logs Section */} -
+ {/* Logs Section (fills remaining left-column height) */} +

Logs

@@ -2978,8 +2981,9 @@ export default function EditorLayout() {
- {/* Right Column (The Editor) */} - + {/* Right column: anatomy panel by default, Monaco editor when editing */} + {editingCompose ? ( +
setActiveTab(value as 'compose' | 'env')}> @@ -3010,8 +3014,9 @@ export default function EditorLayout() { )}
- {can('stack:edit', 'stack', stackName) && ( -
+
+ {can('stack:edit', 'stack', stackName) && ( + <>
)} -
- )} + + )} + +
{activeTab === 'env' && ( @@ -3099,6 +3119,19 @@ export default function EditorLayout() {
+ ) : ( + setEditingCompose(true)} + onOpenGitSource={() => setGitSourceOpen(true)} + onApplyUpdate={() => { void updateStack(); }} + canEdit={can('stack:edit', 'stack', stackName)} + /> + )}
) : activeView === 'global-observability' ? ( diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx new file mode 100644 index 00000000..c030ced2 --- /dev/null +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -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; + volumes: Record; + 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; + 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; + 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): 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); + } + 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(); + const defaulted = new Set(); + 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; + const servicesObj = (root.services && typeof root.services === 'object') + ? root.services as Record + : {}; + const serviceNames = Object.keys(servicesObj); + + const ports: Record = {}; + const volumes: Record = {}; + let restart: string | null = null; + const envFilesSet = new Set(); + const networksSet = new Set(); + + for (const name of serviceNames) { + const svc = servicesObj[name]; + if (!svc || typeof svc !== 'object') continue; + const a = parseServiceBlock(svc as Record); + 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 { + const keys = new Set(); + 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 ( +
+ {label} +
{children}
+
+ ); +} + +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(null); + const [updatePreview, setUpdatePreview] = useState(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 ( +
+
+ anatomy + {canEdit && ( + + )} +
+
+ {!anatomy ? ( +
Unable to parse compose.yaml.
+ ) : ( + <> + + {anatomy.services.length === 0 ? ( + none defined + ) : ( +
+ {anatomy.services.map(s => ( + + {s} + + ))} +
+ )} +
+ + {Object.keys(anatomy.ports).length === 0 ? ( + none + ) : ( +
+ {Object.entries(anatomy.ports).flatMap(([svc, rows]) => + rows.map((r, i) => ( +
+ {anatomy.services.length > 1 && ( + {svc} + )} + {r.host} + + {r.container}/{r.proto} +
+ )), + )} +
+ )} +
+ + {Object.keys(anatomy.volumes).length === 0 ? ( + none + ) : ( +
+ {Object.entries(anatomy.volumes).flatMap(([svc, rows]) => + rows.map((r, i) => ( +
+ {anatomy.services.length > 1 && ( + {svc} + )} + {r.host} + + {r.container} +
+ )), + )} +
+ )} +
+ + {anatomy.restart ?? default} + + + {!firstEnvFile ? ( + none + ) : ( +
+
+ {firstEnvFile} + · {envVarCount} var{envVarCount === 1 ? '' : 's'} +
+ {missingVars.length > 0 && ( +
+ {missingVars.map(v => ( + {'${'}{v}{'}'} missing + ))} +
+ )} +
+ )} +
+ + {networkName} · bridge + + + + + + )} + {hasUpdate && updatePreview && ( +
+
+
+
+ Update available + {updatePreview.summary.current_tag && updatePreview.summary.next_tag && ( + + {' · '} + {updatePreview.summary.current_tag} + {' -> '} + {updatePreview.summary.next_tag} + + )} +
+
+ {[ + bumpLabel, + bannerLeadIn, + updatePreview.changelog ? updatePreview.changelog.split(/[.\n]/)[0] : '', + ].filter(Boolean).join(' · ')} +
+ {blocked && updatePreview.summary.blocked_reason && ( +
{updatePreview.summary.blocked_reason}
+ )} +
+ {canEdit && !blocked && ( + + )} +
+
+ )} +
+ {anatomy && anatomy.services.length > 0 && ( +
+ + {Object.keys(anatomy.ports).length > 0 ? 'exposed' : 'no ports'} + + {primaryHostPort && ( + + + :{primaryHostPort} + + )} +
+ )} +
+ ); +}