import { useEffect, useMemo, useRef, useState } from 'react'; import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen } from 'lucide-react'; import { Button } from './ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { ScrollableTabRow } from './ui/ScrollableTabRow'; import { apiFetch } from '@/lib/api'; import { cn } from '@/lib/utils'; import { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown'; import { parseAnatomy, parseEnvKeys, formatGitSource, primaryPublishedHostPort, type GitSourceInfo } from '@/lib/anatomy'; import { buildServiceUrl } from '@/lib/serviceUrl'; import { StackActivityTimeline } from './stack/StackActivityTimeline'; import StackDossierPanel from './stack/StackDossierPanel'; import DriftPanel from './stack/DriftPanel'; import PreflightPanel from './stack/PreflightPanel'; import StoragePanel from './stack/StoragePanel'; import EnvironmentPanel from './stack/EnvironmentPanel'; import StackNetworkingPanel from './stack/StackNetworkingPanel'; import { useNodes } from '@/context/NodeContext'; import type { NotificationItem } from '@/components/dashboard/types'; interface StackAnatomyPanelProps { stackName: string; content: string; envContent: string; selectedEnvFile: string; gitSourcePending: boolean; onEditCompose: () => void; onOpenGitSource: () => void; onApplyUpdate: () => void; onOpenFiles?: () => void; canEdit: boolean; applying?: boolean; notifications?: NotificationItem[]; } 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; } /** Secret-safe effective facts from GET /stacks/:name/effective-anatomy. */ interface EffectiveAnatomyFacts { services: string[]; ports: Record; volumes: Record; restart: string | null; networks: string[]; } function Row({ label, children }: { label: string; children: React.ReactNode }) { return (
{label}
{children}
); } export default function StackAnatomyPanel({ stackName, content, envContent, selectedEnvFile, gitSourcePending, onEditCompose, onOpenGitSource, onApplyUpdate, onOpenFiles, canEdit, applying = false, notifications, }: 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 { hasCapability, activeNode } = useNodes(); const doctorEnabled = hasCapability('compose-doctor'); const networkingEnabled = hasCapability('compose-networking'); const storageEnabled = hasCapability('compose-storage'); const envInventoryEnabled = hasCapability('env-inventory'); const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo; multiFile: boolean } | null>(null); // Merged effective facts (services/ports/volumes/networks/restart) for a // multi-file Git stack, fetched from the backend's rendered model so the Dossier // and its doc-drift reflect every override file. Null for single-file / non-git // stacks and whenever the render is unavailable, where the root-only parse stands. const [effectiveAnatomy, setEffectiveAnatomy] = useState<({ stack: string } & EffectiveAnatomyFacts) | null>(null); const [updatePreview, setUpdatePreview] = useState(null); // Last preflight severity, used only to dot the Doctor tab. Radix mounts the // active tab content lazily, so the badge cannot come from PreflightPanel; the // parent reads the stored run once per stack/node change. const [preflightSeverity, setPreflightSeverity] = useState(null); const [scanStatus, setScanStatus] = useState<{ status: 'ok' | 'partial' | 'failed' | 'skipped' | null; attemptedAt?: number; errorMessage?: string | null; } | null>(null); // Best-effort badge: read the last stored preflight severity to dot the tab. // Skipped when the active node does not advertise the capability. useEffect(() => { // The dot and tab are gated on doctorEnabled, so a stale severity is never // shown; no synchronous reset needed when the capability is absent. if (!doctorEnabled) return; let cancelled = false; void (async () => { try { const res = await apiFetch(`/stacks/${stackName}/preflight`); if (cancelled || !res.ok) return; const data = await res.json(); if (!cancelled) setPreflightSeverity(typeof data?.highestSeverity === 'string' ? data.highestSeverity : null); } catch { if (!cancelled) setPreflightSeverity(null); } })(); return () => { cancelled = true; }; }, [stackName, activeNode?.id, doctorEnabled]); 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(); // An unlinked stack answers 200 { linked: false }; only render the // badge when an actual source is attached. if (data && data.linked === false) { setGitSource(null); } else { // More than one configured compose path means override files merge into // the deployed model, so the dossier must read the effective render. const multiFile = Array.isArray(data.compose_paths) && data.compose_paths.length > 1; setGitSource({ stack: stackName, info: { repo_url: data.repo_url, branch: data.branch, compose_path: data.compose_path }, multiFile, }); } } else { setGitSource(null); } } catch { if (!cancelled) setGitSource(null); } }; void run(); return () => { cancelled = true; }; }, [stackName]); // Multi-file Git stacks deploy a merged model, so the dossier reads the backend's // rendered effective facts instead of the root compose alone. useEffect(() => { // Single-file / non-git stacks keep the root-only parse and skip the fetch. // Any tagged result left from a previous stack is ignored downstream by the // stack-name guard, so there is no need to clear state synchronously here. if (!(gitSource?.stack === stackName && gitSource.multiFile)) return; let cancelled = false; const run = async () => { try { const res = await apiFetch(`/stacks/${stackName}/effective-anatomy`); if (cancelled) return; if (res.ok) { const data = await res.json(); // Adopt the merged facts only when the model actually rendered; on a render // error keep the root-only parse so the dossier never shows an empty summary. setEffectiveAnatomy(data && data.renderable ? { stack: stackName, services: Array.isArray(data.services) ? data.services : [], ports: data.ports ?? {}, volumes: data.volumes ?? {}, restart: data.restart ?? null, networks: Array.isArray(data.networks) ? data.networks : [], } : null); } else { setEffectiveAnatomy(null); } } catch { if (!cancelled) setEffectiveAnatomy(null); } }; void run(); return () => { cancelled = true; }; }, [stackName, activeNode?.id, gitSource]); 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]); // When an apply for the current stack finishes (applying true -> false on the same // stackName), re-check the preview: a landed update clears has_update so the banner // unmounts; if it did not land, or the re-check itself fails, the banner stays. // Tracking stackName alongside applying avoids treating a stack switch made while the // first stack is still applying as a completion for the newly selected stack. const prevApplyRef = useRef({ applying, stackName }); useEffect(() => { const prev = prevApplyRef.current; const finishedApplying = prev.applying && !applying && prev.stackName === stackName; prevApplyRef.current = { applying, stackName }; if (!finishedApplying) return; let cancelled = false; const run = async () => { try { const res = await apiFetch(`/stacks/${stackName}/update-preview`); if (cancelled) return; if (!res.ok) { // Re-check failed: keep the banner already shown rather than hiding a // possibly-still-pending update. The apply action reports its own outcome. console.error(`[StackAnatomyPanel] update-preview re-check returned ${res.status}; keeping the existing banner`); return; } const data = await res.json(); if (!cancelled) setUpdatePreview(data); } catch (err) { console.error('[StackAnatomyPanel] update-preview re-check failed:', err); } }; void run(); return () => { cancelled = true; }; }, [applying, stackName]); useEffect(() => { let cancelled = false; const run = async () => { try { const res = await apiFetch(`/stacks/${stackName}/scan-status`); if (cancelled) return; if (res.ok) { setScanStatus(await res.json()); } else { setScanStatus(null); } } catch { if (!cancelled) setScanStatus(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; // Only treat the fetched source as current when it belongs to the selected stack, so a // slow /git-source response for a previously selected stack cannot render or be exported here. const activeGitSource = gitSource?.stack === stackName ? gitSource.info : null; const primaryHostPort = useMemo( () => (anatomy ? primaryPublishedHostPort(anatomy.ports) : null), [anatomy], ); const primaryServiceUrl = useMemo( () => (primaryHostPort !== null ? buildServiceUrl({ node: activeNode, publicPort: primaryHostPort }) : null), [primaryHostPort, activeNode], ); // Assembled facts for this stack, passed to the Dossier tab for its read-only // summary and Markdown export. Null until compose parses. const anatomyInput = useMemo(() => { // Prefer the merged effective facts for multi-file Git stacks so the dossier and // its doc-drift reflect every override file; fall back to the root-only parse. // Env-derived fields (count, missing vars, env file) always come from the raw // parse, which reads the unresolved `${VAR}` references the render has substituted. // `anatomy` already carries the same structural fields (plus env-only extras we // read separately below), so the raw parse stands in directly when there are no // effective facts for this stack. const activeEffective = effectiveAnatomy?.stack === stackName ? effectiveAnatomy : null; const structural = activeEffective ?? anatomy; if (!structural) return null; return { stackName, services: structural.services, ports: structural.ports, volumes: structural.volumes, restart: structural.restart, envFile: firstEnvFile, envVarCount, missingVars, networkName: structural.networks.length > 0 ? structural.networks[0] : `${stackName}_default`, gitSource: activeGitSource ? formatGitSource(activeGitSource) : null, }; }, [effectiveAnatomy, anatomy, stackName, firstEnvFile, envVarCount, missingVars, activeGitSource]); 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 Activity Dossier Drift {envInventoryEnabled && ( Environment )} {networkingEnabled && ( Networking )} {doctorEnabled && ( Doctor {(preflightSeverity === 'blocker' || preflightSeverity === 'high') && ( )} )} {storageEnabled && ( Storage )}
{onOpenFiles && ( )} {canEdit && ( )}
n.stack_name === stackName)} />
{!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 && ( )}
)} {scanStatus && scanStatus.status && scanStatus.status !== 'ok' && (
scan {scanStatus.status === 'failed' && 'Last post-deploy scan failed.'} {scanStatus.status === 'partial' && 'Last post-deploy scan partially failed.'} {scanStatus.status === 'skipped' && 'Post-deploy scan did not run.'} {scanStatus.errorMessage ? ` ${scanStatus.errorMessage}` : ''}
)}
{anatomy && anatomy.services.length > 0 && (
{Object.keys(anatomy.ports).length > 0 ? 'exposed' : 'no ports'} {primaryHostPort !== null && ( primaryServiceUrl ? ( :{primaryHostPort} ) : ( :{primaryHostPort} ) )}
)}
{networkingEnabled && ( )} {envInventoryEnabled && ( )} {doctorEnabled && ( )} {storageEnabled && ( )}
); }