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} )}
)}
); }