feat(fleet): export a whole-fleet Markdown dossier (#1334)

* feat(fleet): export a whole-fleet Markdown dossier

Add an admin-only "Export Dossier" action to the Fleet view that walks
every node and stack, pairs each stack's generated Compose anatomy with
its operator notes, and downloads a folder-structured homelab-dossier.zip
(index, per-node and per-stack pages, plus fleet-wide port, volume,
network, env, access-URL, and VLAN/firewall maps).

Reuses the existing stack dossier and anatomy Markdown generators by
extracting the shared Compose parsers into a frontend lib module.
Unreachable nodes are recorded with a reason and never block the export;
only env variable names and counts are ever emitted, never values.

* fix(fleet): unique stack slugs and reproducible dossier archive

Disambiguate stack names on one node that slugify to the same value (e.g.
`Web` and `web` on a case-sensitive host) with a per-node slug map shared by
the node-page links and the file emission, so neither overwrites the other.
Pin a fixed entry timestamp on the zip so the archive bytes are a pure
function of the file map rather than the wall clock.
This commit is contained in:
Anso
2026-06-07 21:10:02 -04:00
committed by GitHub
parent b21324f97a
commit 842ee7dd0c
15 changed files with 1283 additions and 171 deletions
+15 -1
View File
@@ -1,5 +1,5 @@
import {
RefreshCw, Search, Camera, Plus,
RefreshCw, Search, Camera, Plus, FileDown,
Network, SlidersHorizontal,
Send, KeyRound, ArrowLeftRight, Wrench, Workflow,
} from 'lucide-react';
@@ -12,6 +12,7 @@ import { useFleetPreferences } from './FleetView/hooks/useFleetPreferences';
import { useFleetUpdateStatus } from './FleetView/hooks/useFleetUpdateStatus';
import { useFleetPolling } from './FleetView/hooks/useFleetPolling';
import { useFleetOverview } from './FleetView/hooks/useFleetOverview';
import { useFleetDossierExport } from './FleetView/hooks/useFleetDossierExport';
import { useTopologyPreferences } from '@/hooks/useTopologyPreferences';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
@@ -42,6 +43,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
const updateStatus = useFleetUpdateStatus();
const overview = useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses: updateStatus.updateStatuses });
const topology = useTopologyPreferences();
const { exporting, exportDossier } = useFleetDossierExport();
useFleetPolling({
fetchOverview: overview.fetchOverview,
@@ -151,6 +153,18 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
{isAdmin && (
<Button
variant="outline"
size="sm"
onClick={() => { void exportDossier(); }}
disabled={exporting}
className="gap-2"
>
<FileDown className={`w-4 h-4 ${exporting ? 'animate-pulse' : ''}`} />
{exporting ? 'Exporting…' : 'Export Dossier'}
</Button>
)}
{isAdmin && (
<SettingsPrimaryButton
size="sm"
@@ -0,0 +1,206 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { unzipSync, strFromU8 } from 'fflate';
const apiFetchMock = vi.fn();
const fetchForNodeMock = vi.fn();
vi.mock('@/lib/api', () => ({
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
fetchForNode: (...args: unknown[]) => fetchForNodeMock(...args),
}));
const downloadBlobMock = vi.fn();
vi.mock('@/lib/download', () => ({
downloadBlob: (...args: unknown[]) => downloadBlobMock(...args),
}));
const toastSuccess = vi.fn();
const toastError = vi.fn();
vi.mock('@/components/ui/toast-store', () => ({
toast: { success: (...a: unknown[]) => toastSuccess(...a), error: (...a: unknown[]) => toastError(...a) },
}));
import { useFleetDossierExport } from './useFleetDossierExport';
function text(body: string, ok = true): Response {
return new Response(body, { status: ok ? 200 : 500 });
}
function json(payload: unknown, ok = true): Response {
return new Response(JSON.stringify(payload), { status: ok ? 200 : 500, headers: { 'Content-Type': 'application/json' } });
}
const COMPOSE = 'services:\n plex:\n image: x\n ports:\n - "32400:32400"\n';
interface NodeStacks { [nodeId: number]: Record<string, { compose?: Response; dossierPurpose?: string }>; }
function wireFetchForNode(stacks: NodeStacks) {
fetchForNodeMock.mockImplementation((endpoint: string, nodeId: number) => {
const nodeStacks = stacks[nodeId] ?? {};
if (endpoint === '/stacks') return Promise.resolve(json(Object.keys(nodeStacks)));
const m = endpoint.match(/^\/stacks\/([^/?]+)(\/[a-z-]+)?/);
const name = m ? decodeURIComponent(m[1]) : '';
const sub = m?.[2];
const entry = nodeStacks[name];
if (!entry) return Promise.resolve(json({ error: 'not found' }, false));
if (!sub) return Promise.resolve(entry.compose ?? text(COMPOSE));
if (sub === '/envs') return Promise.resolve(json({ envFiles: [] }));
if (sub === '/env') return Promise.resolve(text(''));
if (sub === '/git-source') return Promise.resolve(json({ linked: false }));
if (sub === '/dossier') {
return Promise.resolve(json({ purpose: entry.dossierPurpose ?? '' }));
}
return Promise.resolve(json({}, false));
});
}
// Stub /fleet/overview with the given node list, keeping the beforeEach default
// for /meta (version) and the empty-array fallback.
function wireOverview(nodes: unknown[]): void {
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/fleet/overview') return Promise.resolve(json(nodes));
if (endpoint === '/meta') return Promise.resolve(json({ version: '0.90.0' }));
return Promise.resolve(json([]));
});
}
async function readZip(): Promise<Record<string, string>> {
expect(downloadBlobMock).toHaveBeenCalledTimes(1);
const [filename, blob] = downloadBlobMock.mock.calls[0] as [string, Blob];
expect(filename).toBe('homelab-dossier.zip');
const bytes = new Uint8Array(await blob.arrayBuffer());
const unzipped = unzipSync(bytes);
const out: Record<string, string> = {};
// The archive nests everything under a single homelab-dossier/ folder; strip
// it so the assertions read against clean relative paths.
for (const [path, data] of Object.entries(unzipped)) out[path.replace(/^homelab-dossier\//, '')] = strFromU8(data);
return out;
}
beforeEach(() => {
apiFetchMock.mockReset();
fetchForNodeMock.mockReset();
downloadBlobMock.mockReset();
toastSuccess.mockReset();
toastError.mockReset();
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/meta') return Promise.resolve(json({ version: '0.90.0' }));
return Promise.resolve(json([]));
});
});
describe('useFleetDossierExport', () => {
it('builds and downloads a zip of the reachable fleet', async () => {
wireOverview([{ id: 1, name: 'local', type: 'local', status: 'online' }]);
wireFetchForNode({ 1: { plex: {} } });
const { result } = renderHook(() => useFleetDossierExport());
await act(async () => { await result.current.exportDossier(); });
const files = await readZip();
expect(files['index.md']).toContain('# Homelab Dossier');
expect(files['index.md']).toContain('Sencho 0.90.0');
expect(files['nodes/local.md']).toContain('# local');
expect(files['stacks/local--plex.md']).toContain('# plex');
expect(files['stacks/local--plex.md']).toContain('| plex | 32400 | 32400 | tcp |');
expect(toastSuccess).toHaveBeenCalledWith('Fleet dossier exported.');
expect(toastError).not.toHaveBeenCalled();
});
it('skips an offline node with a reason and reports the skip count', async () => {
wireOverview([
{ id: 1, name: 'local', type: 'local', status: 'online' },
{ id: 2, name: 'media-node', type: 'remote', status: 'offline' },
]);
wireFetchForNode({ 1: { plex: {} } });
const { result } = renderHook(() => useFleetDossierExport());
await act(async () => { await result.current.exportDossier(); });
const files = await readZip();
expect(files['index.md']).toContain('## Skipped nodes');
expect(files['index.md']).toContain('- **media-node** (remote): node offline');
expect(files['nodes/media-node.md']).toContain('Skipped');
expect(files['stacks/media-node--plex.md']).toBeUndefined();
expect(toastSuccess).toHaveBeenCalledWith('Fleet dossier exported. 1 node skipped (unreachable).');
});
it('emits a stub page when a stack compose cannot be read', async () => {
wireOverview([{ id: 1, name: 'local', type: 'local', status: 'online' }]);
wireFetchForNode({ 1: { broken: { compose: text('boom', false), dossierPurpose: 'documented anyway' } } });
const { result } = renderHook(() => useFleetDossierExport());
await act(async () => { await result.current.exportDossier(); });
const files = await readZip();
expect(files['stacks/local--broken.md']).toContain('compose.yaml could not be parsed');
expect(files['stacks/local--broken.md']).toContain('- **Purpose:** documented anyway');
});
it('surfaces an error toast and does not download when the overview fetch fails', async () => {
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/fleet/overview') return Promise.resolve(json({ error: 'boom' }, false));
if (endpoint === '/meta') return Promise.resolve(json({ version: '0.90.0' }));
return Promise.resolve(json([]));
});
const { result } = renderHook(() => useFleetDossierExport());
await act(async () => { await result.current.exportDossier(); });
expect(downloadBlobMock).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledTimes(1);
});
it('nests every file under a single homelab-dossier/ folder', async () => {
wireOverview([{ id: 1, name: 'local', type: 'local', status: 'online' }]);
wireFetchForNode({ 1: { plex: {} } });
const { result } = renderHook(() => useFleetDossierExport());
await act(async () => { await result.current.exportDossier(); });
const [, blob] = downloadBlobMock.mock.calls[0] as [string, Blob];
const entries = Object.keys(unzipSync(new Uint8Array(await blob.arrayBuffer())));
expect(entries.length).toBeGreaterThan(0);
expect(entries.every(p => p.startsWith('homelab-dossier/'))).toBe(true);
});
it('skips a node with unknown status and labels the reason', async () => {
wireOverview([
{ id: 1, name: 'local', type: 'local', status: 'online' },
{ id: 2, name: 'media-node', type: 'remote', status: 'unknown' },
]);
wireFetchForNode({ 1: { plex: {} } });
const { result } = renderHook(() => useFleetDossierExport());
await act(async () => { await result.current.exportDossier(); });
const files = await readZip();
expect(files['index.md']).toContain('- **media-node** (remote): node status unknown');
});
it('marks a node skipped when its stack list cannot be read', async () => {
wireOverview([{ id: 1, name: 'local', type: 'local', status: 'online' }]);
fetchForNodeMock.mockImplementation(() => Promise.resolve(json({ error: 'boom' }, false)));
const { result } = renderHook(() => useFleetDossierExport());
await act(async () => { await result.current.exportDossier(); });
const files = await readZip();
expect(files['index.md']).toContain('- **local** (local): stack list unavailable');
});
it('aborts without downloading when a node-scoped request returns Unauthorized', async () => {
wireOverview([{ id: 1, name: 'local', type: 'local', status: 'online' }]);
fetchForNodeMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stacks') return Promise.resolve(json(['plex']));
// fetchForNode throws this sentinel on a 401 and fires a global logout.
return Promise.reject(new Error('Unauthorized'));
});
const { result } = renderHook(() => useFleetDossierExport());
await act(async () => { await result.current.exportDossier(); });
expect(downloadBlobMock).not.toHaveBeenCalled();
expect(toastSuccess).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,193 @@
import { useCallback, useState } from 'react';
import { zipSync, strToU8 } from 'fflate';
import { apiFetch, fetchForNode } from '@/lib/api';
import { assembleAnatomyInput, type GitSourceInfo } from '@/lib/anatomy';
import {
buildFleetDossier,
type FleetDossierNode,
type FleetDossierStack,
} from '@/lib/fleetDossier';
import { EMPTY_DOSSIER_FIELDS, type StackDossierFields } from '@/lib/dossierMarkdown';
import { downloadBlob } from '@/lib/download';
import { toast } from '@/components/ui/toast-store';
interface OverviewNode {
id: number;
name: string;
type: 'local' | 'remote';
status: 'online' | 'offline' | 'unknown';
}
// Cap concurrent per-stack collection so a large fleet cannot fire hundreds of
// requests through the proxy at once.
const STACK_CONCURRENCY = 6;
async function getText(endpoint: string, nodeId: number): Promise<string> {
const res = await fetchForNode(endpoint, nodeId);
if (!res.ok) throw new Error(`${endpoint} -> ${res.status}`);
return res.text();
}
async function getJson<T>(endpoint: string, nodeId: number): Promise<T> {
const res = await fetchForNode(endpoint, nodeId);
if (!res.ok) throw new Error(`${endpoint} -> ${res.status}`);
return res.json() as Promise<T>;
}
// fetchForNode throws this sentinel on a 401 and fires a global logout. A 401 is
// not a per-item degrade condition: it invalidates the whole export, so the
// per-item catches rethrow it to abort rather than silently producing a partial
// dossier that looks complete.
function isUnauthorized(err: unknown): boolean {
return err instanceof Error && err.message === 'Unauthorized';
}
async function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length);
let next = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const i = next++;
out[i] = await fn(items[i]);
}
});
await Promise.all(workers);
return out;
}
/** Fetch the git source for a stack, or null when unlinked/unavailable. */
async function loadGitSource(stackName: string, nodeId: number): Promise<GitSourceInfo | null> {
try {
const data = await getJson<{ linked?: boolean; repo_url?: string; branch?: string; compose_path?: string }>(
`/stacks/${encodeURIComponent(stackName)}/git-source`,
nodeId,
);
if (!data || data.linked === false || !data.repo_url || !data.branch) return null;
return { repo_url: data.repo_url, branch: data.branch, compose_path: data.compose_path };
} catch (err) {
if (isUnauthorized(err)) throw err;
console.warn(`[FleetDossier] git-source load failed for "${stackName}" on node ${nodeId}:`, err);
return null;
}
}
async function loadDossier(stackName: string, nodeId: number): Promise<StackDossierFields> {
try {
const data = await getJson<Record<string, unknown>>(`/stacks/${encodeURIComponent(stackName)}/dossier`, nodeId);
const out = { ...EMPTY_DOSSIER_FIELDS };
for (const k of Object.keys(EMPTY_DOSSIER_FIELDS) as Array<keyof StackDossierFields>) {
if (typeof data[k] === 'string') out[k] = data[k] as string;
}
return out;
} catch (err) {
if (isUnauthorized(err)) throw err;
console.warn(`[FleetDossier] dossier load failed for "${stackName}" on node ${nodeId}:`, err);
return { ...EMPTY_DOSSIER_FIELDS };
}
}
async function collectStack(stackName: string, nodeId: number): Promise<FleetDossierStack> {
const dossier = await loadDossier(stackName, nodeId);
let content: string;
try {
content = await getText(`/stacks/${encodeURIComponent(stackName)}`, nodeId);
} catch (err) {
if (isUnauthorized(err)) throw err;
// Compose unreadable: emit a stub page from the operator notes alone.
console.warn(`[FleetDossier] compose read failed for "${stackName}" on node ${nodeId}:`, err);
return { stackName, anatomy: null, dossier };
}
let envContent = '';
let firstEnvFile: string | null = null;
try {
const { envFiles } = await getJson<{ envFiles: string[] }>(`/stacks/${encodeURIComponent(stackName)}/envs`, nodeId);
firstEnvFile = envFiles[0] ?? null;
if (firstEnvFile) {
envContent = await getText(`/stacks/${encodeURIComponent(stackName)}/env?file=${encodeURIComponent(firstEnvFile)}`, nodeId);
}
} catch (err) {
if (isUnauthorized(err)) throw err;
// No env data: anatomy still renders, just without env-file facts.
console.warn(`[FleetDossier] env read failed for "${stackName}" on node ${nodeId}:`, err);
}
const gitSource = await loadGitSource(stackName, nodeId);
const anatomy = assembleAnatomyInput({ stackName, content, envContent, selectedEnvFile: firstEnvFile, gitSource });
return { stackName, anatomy, dossier };
}
async function collectNode(node: OverviewNode): Promise<FleetDossierNode> {
const base = { id: node.id, name: node.name, type: node.type };
if (node.status !== 'online') {
return { ...base, reachable: false, skipReason: node.status === 'offline' ? 'node offline' : 'node status unknown' };
}
let stackNames: string[];
try {
stackNames = await getJson<string[]>('/stacks', node.id);
} catch (err) {
if (isUnauthorized(err)) throw err;
console.warn(`[FleetDossier] stack list failed for node ${node.id}:`, err);
return { ...base, reachable: false, skipReason: 'stack list unavailable' };
}
const stacks = await mapWithConcurrency(stackNames, STACK_CONCURRENCY, name => collectStack(name, node.id));
return { ...base, reachable: true, stacks };
}
/**
* Drives the whole-fleet dossier export: enumerate nodes, fan per-stack data
* collection out across each node's API (`x-node-id`, dispatched locally or
* forwarded to remote nodes by the backend), render the Markdown with the
* shared generators, zip it, and trigger a download. Unreachable nodes are
* recorded with a reason and never block the rest of the export.
*/
export function useFleetDossierExport(): { exporting: boolean; exportDossier: () => Promise<void> } {
const [exporting, setExporting] = useState(false);
const exportDossier = useCallback(async () => {
setExporting(true);
try {
const [nodes, meta] = await Promise.all([
apiFetch('/fleet/overview', { localOnly: true }).then(r => {
if (!r.ok) throw new Error(`overview -> ${r.status}`);
return r.json() as Promise<OverviewNode[]>;
}),
apiFetch('/meta', { localOnly: true })
.then(r => (r.ok ? (r.json() as Promise<{ version: string | null }>) : { version: null }))
.catch(() => ({ version: null })),
]);
const collected = await mapWithConcurrency(nodes, 2, collectNode);
const files = buildFleetDossier({
generatedAt: new Date().toISOString(),
senchoVersion: meta.version ?? 'unknown',
nodes: collected,
});
// Nest under a single top-level folder so unzipping yields one
// `homelab-dossier/` directory rather than scattering files into the cwd.
const zippable: Record<string, Uint8Array> = {};
for (const [path, content] of Object.entries(files)) zippable[`homelab-dossier/${path}`] = strToU8(content);
// Pin a fixed entry timestamp so the archive bytes are a pure function of
// the file map (fflate stamps entries with the current wall-clock time by
// default). The file map still carries the generation time in its text.
// fflate encodes the DOS date from local components, so a component-built
// date keeps the bytes stable across timezones and inside its 1980+ range.
const archive = zipSync(zippable, { mtime: new Date(1985, 0, 1) });
downloadBlob('homelab-dossier.zip', new Blob([archive], { type: 'application/zip' }));
const skipped = collected.filter(n => !n.reachable).length;
toast.success(skipped > 0
? `Fleet dossier exported. ${skipped} node${skipped === 1 ? '' : 's'} skipped (unreachable).`
: 'Fleet dossier exported.');
} catch (err) {
console.error('[FleetDossier] export failed:', err);
toast.error('Failed to export the fleet dossier. Check your connection and try again.');
} finally {
setExporting(false);
}
}, []);
return { exporting, exportDossier };
}
+2 -168
View File
@@ -1,11 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { parse as parseYaml } from 'yaml';
import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen } from 'lucide-react';
import { Button } from './ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown';
import { type AnatomyMarkdownInput } from '@/lib/anatomyMarkdown';
import { parseAnatomy, parseEnvKeys, formatGitSource, type GitSourceInfo } from '@/lib/anatomy';
import { StackActivityTimeline } from './stack/StackActivityTimeline';
import StackDossierPanel from './stack/StackDossierPanel';
import DriftPanel from './stack/DriftPanel';
@@ -43,172 +43,6 @@ interface UpdatePreview {
changelog: string | null;
}
interface GitSourceInfo {
repo_url: string;
branch: string;
compose_path?: 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">