mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
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:
@@ -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 };
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { assembleAnatomyInput, parseAnatomy, parseEnvKeys, formatGitSource } from './anatomy';
|
||||
|
||||
const COMPOSE = `services:
|
||||
plex:
|
||||
image: plexinc/pms-docker
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "32400:32400"
|
||||
- "1900:1900/udp"
|
||||
volumes:
|
||||
- ./config:/config
|
||||
env_file: .env
|
||||
networks:
|
||||
- media
|
||||
networks:
|
||||
media:
|
||||
`;
|
||||
|
||||
describe('parseAnatomy', () => {
|
||||
it('extracts services, ports, volumes, restart, env file, and networks', () => {
|
||||
const a = parseAnatomy(COMPOSE)!;
|
||||
expect(a.services).toEqual(['plex']);
|
||||
expect(a.ports.plex).toEqual([
|
||||
{ host: '32400', container: '32400', proto: 'tcp' },
|
||||
{ host: '1900', container: '1900', proto: 'udp' },
|
||||
]);
|
||||
expect(a.volumes.plex).toEqual([{ host: './config', container: '/config' }]);
|
||||
expect(a.restart).toBe('unless-stopped');
|
||||
expect(a.envFiles).toEqual(['.env']);
|
||||
expect(a.networks).toContain('media');
|
||||
});
|
||||
|
||||
it('returns null for empty or unparseable compose', () => {
|
||||
expect(parseAnatomy('')).toBeNull();
|
||||
expect(parseAnatomy(' ')).toBeNull();
|
||||
expect(parseAnatomy('::: not yaml :::\n - [')).toBeNull();
|
||||
});
|
||||
|
||||
it('parses the 3-part bind-IP port form, picking host and container', () => {
|
||||
const a = parseAnatomy('services:\n web:\n image: x\n ports:\n - "127.0.0.1:8080:80"\n')!;
|
||||
expect(a.ports.web).toEqual([{ host: '8080', container: '80', proto: 'tcp' }]);
|
||||
});
|
||||
|
||||
it('parses long-syntax object ports and volumes', () => {
|
||||
const a = parseAnatomy(
|
||||
'services:\n app:\n image: x\n ports:\n - target: 80\n published: 8080\n protocol: udp\n volumes:\n - type: bind\n source: ./data\n target: /data\n',
|
||||
)!;
|
||||
expect(a.ports.app).toEqual([{ host: '8080', container: '80', proto: 'udp' }]);
|
||||
expect(a.volumes.app).toEqual([{ host: './data', container: '/data' }]);
|
||||
});
|
||||
|
||||
it('treats ${VAR:-default} as satisfied but ${VAR} as referenced', () => {
|
||||
const a = parseAnatomy('services:\n app:\n image: x\n environment:\n - A=${NEEDED}\n - B=${HAS:-fallback}\n')!;
|
||||
expect(a.referencedVars).toContain('NEEDED');
|
||||
expect(a.referencedVars).not.toContain('HAS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseEnvKeys', () => {
|
||||
it('collects keys and ignores comments and blank lines', () => {
|
||||
const keys = parseEnvKeys('# comment\nFOO=1\n\nBAR=2\n=novalue\n');
|
||||
expect(keys.has('FOO')).toBe(true);
|
||||
expect(keys.has('BAR')).toBe(true);
|
||||
expect(keys.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatGitSource', () => {
|
||||
it('formats a URL into host/repo#branch', () => {
|
||||
expect(formatGitSource({ repo_url: 'https://github.com/acme/stack.git', branch: 'main' }))
|
||||
.toBe('github.com/acme/stack#main');
|
||||
});
|
||||
|
||||
it('falls back to repo#branch for a non-URL', () => {
|
||||
expect(formatGitSource({ repo_url: 'git@host:repo', branch: 'dev' })).toBe('git@host:repo#dev');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assembleAnatomyInput', () => {
|
||||
it('builds the markdown input, computing env count and missing vars', () => {
|
||||
const input = assembleAnatomyInput({
|
||||
stackName: 'plex',
|
||||
content: 'services:\n plex:\n image: x\n env_file: .env\n environment:\n - TOKEN=${TOKEN}\n',
|
||||
envContent: 'OTHER=1\n',
|
||||
selectedEnvFile: '.env',
|
||||
gitSource: { repo_url: 'https://github.com/acme/plex.git', branch: 'main' },
|
||||
})!;
|
||||
expect(input.stackName).toBe('plex');
|
||||
expect(input.envFile).toBe('.env');
|
||||
expect(input.envVarCount).toBe(1);
|
||||
expect(input.missingVars).toEqual(['TOKEN']);
|
||||
expect(input.gitSource).toBe('github.com/acme/plex#main');
|
||||
});
|
||||
|
||||
it('returns null when compose cannot be parsed', () => {
|
||||
expect(assembleAnatomyInput({
|
||||
stackName: 's', content: '', envContent: '', selectedEnvFile: null, gitSource: null,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to <stack>_default when no network is declared', () => {
|
||||
const input = assembleAnatomyInput({
|
||||
stackName: 'web', content: 'services:\n web:\n image: x\n', envContent: '', selectedEnvFile: null, gitSource: null,
|
||||
})!;
|
||||
expect(input.networkName).toBe('web_default');
|
||||
});
|
||||
|
||||
it('never carries .env values, only key names and counts', () => {
|
||||
const input = assembleAnatomyInput({
|
||||
stackName: 's',
|
||||
content: 'services:\n s:\n image: x\n env_file: .env\n',
|
||||
envContent: 'SECRET=hunter2\nAPI_KEY=abc\n',
|
||||
selectedEnvFile: '.env',
|
||||
gitSource: null,
|
||||
})!;
|
||||
expect(input.envVarCount).toBe(2);
|
||||
expect(JSON.stringify(input)).not.toContain('hunter2');
|
||||
expect(JSON.stringify(input)).not.toContain('abc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Shared, pure Compose-anatomy parsing.
|
||||
*
|
||||
* The Stack Anatomy panel and the Fleet Dossier export must derive the exact
|
||||
* same facts from a stack's compose.yaml + env file, so the parsing lives here
|
||||
* as one source of truth rather than being duplicated. Every function is pure
|
||||
* and side-effect free, and only ever surfaces env variable names and counts,
|
||||
* never `.env` values, so no secret can leak downstream.
|
||||
*/
|
||||
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import type { AnatomyMarkdownInput, PortRow, VolumeRow } from './anatomyMarkdown';
|
||||
|
||||
export interface GitSourceInfo {
|
||||
repo_url: string;
|
||||
branch: string;
|
||||
compose_path?: string;
|
||||
}
|
||||
|
||||
export 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));
|
||||
}
|
||||
|
||||
export 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),
|
||||
};
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the {@link AnatomyMarkdownInput} the Markdown builders consume from a
|
||||
* stack's raw compose + env content. Returns null when compose.yaml cannot be
|
||||
* parsed, mirroring the Stack Anatomy panel's behaviour so the panel and the
|
||||
* Fleet Dossier export agree on every fact.
|
||||
*/
|
||||
export function assembleAnatomyInput(args: {
|
||||
stackName: string;
|
||||
content: string;
|
||||
envContent: string;
|
||||
selectedEnvFile: string | null;
|
||||
gitSource: GitSourceInfo | null;
|
||||
}): AnatomyMarkdownInput | null {
|
||||
const anatomy = parseAnatomy(args.content);
|
||||
if (!anatomy) return null;
|
||||
|
||||
const envKeys = parseEnvKeys(args.envContent);
|
||||
const missingVars = anatomy.referencedVars.filter(v => !envKeys.has(v));
|
||||
const firstEnvFile = anatomy.envFiles[0] ?? args.selectedEnvFile ?? null;
|
||||
const networkName = anatomy.networks.length > 0
|
||||
? anatomy.networks[0]
|
||||
: `${args.stackName}_default`;
|
||||
|
||||
return {
|
||||
stackName: args.stackName,
|
||||
services: anatomy.services,
|
||||
ports: anatomy.ports,
|
||||
volumes: anatomy.volumes,
|
||||
restart: anatomy.restart,
|
||||
envFile: firstEnvFile,
|
||||
envVarCount: envKeys.size,
|
||||
missingVars,
|
||||
networkName,
|
||||
gitSource: args.gitSource ? formatGitSource(args.gitSource) : null,
|
||||
};
|
||||
}
|
||||
@@ -67,7 +67,7 @@ const BLOCK_FIELDS: Array<[keyof StackDossierFields, string]> = [
|
||||
['custom_notes', 'Notes'],
|
||||
];
|
||||
|
||||
function operatorNotesSection(d: StackDossierFields): string | null {
|
||||
export function operatorNotesSection(d: StackDossierFields): string | null {
|
||||
const bullets = SHORT_FIELDS
|
||||
.filter(([k]) => d[k].trim() !== '')
|
||||
.map(([k, label]) => `- **${label}:** ${d[k].trim().replace(/\s*\r?\n\s*/g, ' ')}`);
|
||||
|
||||
@@ -4,7 +4,14 @@
|
||||
* e.g. AuditLogView). Suited to client-side text such as a Markdown export.
|
||||
*/
|
||||
export function downloadTextFile(filename: string, text: string, mime = 'text/markdown'): void {
|
||||
const blob = new Blob([text], { type: `${mime};charset=utf-8` });
|
||||
downloadBlob(filename, new Blob([text], { type: `${mime};charset=utf-8` }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download of an in-memory Blob (e.g. a zip archive built
|
||||
* client-side). Same object-URL + anchor-click idiom as {@link downloadTextFile}.
|
||||
*/
|
||||
export function downloadBlob(filename: string, blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildFleetDossier, type FleetDossierInput, type FleetDossierStack } from './fleetDossier';
|
||||
import { EMPTY_DOSSIER_FIELDS, type StackDossierFields } from './dossierMarkdown';
|
||||
import type { AnatomyMarkdownInput } from './anatomyMarkdown';
|
||||
|
||||
const fields = (over: Partial<StackDossierFields> = {}): StackDossierFields => ({ ...EMPTY_DOSSIER_FIELDS, ...over });
|
||||
|
||||
const plexAnatomy: AnatomyMarkdownInput = {
|
||||
stackName: 'plex',
|
||||
services: ['plex'],
|
||||
ports: { plex: [{ host: '32400', container: '32400', proto: 'tcp' }] },
|
||||
volumes: { plex: [{ host: './config', container: '/config' }] },
|
||||
restart: 'unless-stopped',
|
||||
envFile: '.env',
|
||||
envVarCount: 3,
|
||||
missingVars: ['CLAIM_TOKEN'],
|
||||
networkName: 'media',
|
||||
gitSource: null,
|
||||
};
|
||||
|
||||
const input = (): FleetDossierInput => ({
|
||||
generatedAt: '2026-06-07T00:00:00.000Z',
|
||||
senchoVersion: '0.90.0',
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'local',
|
||||
type: 'local',
|
||||
reachable: true,
|
||||
stacks: [
|
||||
{
|
||||
stackName: 'plex',
|
||||
anatomy: plexAnatomy,
|
||||
dossier: fields({
|
||||
purpose: 'Media server',
|
||||
access_urls: 'https://plex.example',
|
||||
static_ip: '10.0.10.4',
|
||||
vlan: '10',
|
||||
firewall_notes: '32400 open on LAN',
|
||||
}),
|
||||
},
|
||||
{ stackName: 'broken', anatomy: null, dossier: fields({ purpose: 'unparseable but documented' }) },
|
||||
],
|
||||
},
|
||||
{ id: 2, name: 'media-node', type: 'remote', reachable: false, skipReason: 'node offline' },
|
||||
],
|
||||
});
|
||||
|
||||
describe('buildFleetDossier', () => {
|
||||
it('emits index, network, a node page per node, and a stack page per reachable stack', () => {
|
||||
const files = buildFleetDossier(input());
|
||||
expect(Object.keys(files).sort()).toEqual([
|
||||
'index.md',
|
||||
'network.md',
|
||||
'nodes/local.md',
|
||||
'nodes/media-node.md',
|
||||
'stacks/local--broken.md',
|
||||
'stacks/local--plex.md',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists nodes and an explicit skipped section with reasons in index.md', () => {
|
||||
const md = buildFleetDossier(input())['index.md'];
|
||||
expect(md).toContain('# Homelab Dossier');
|
||||
expect(md).toContain('_Generated 2026-06-07T00:00:00.000Z · Sencho 0.90.0_');
|
||||
expect(md).toContain('| [local](nodes/local.md) | local | reachable | 2 |');
|
||||
expect(md).toContain('| [media-node](nodes/media-node.md) | remote | unreachable | - |');
|
||||
expect(md).toContain('## Skipped nodes');
|
||||
expect(md).toContain('- **media-node** (remote): node offline');
|
||||
});
|
||||
|
||||
it('omits the skipped section when every node is reachable', () => {
|
||||
const data = input();
|
||||
data.nodes = [data.nodes[0]];
|
||||
expect(buildFleetDossier(data)['index.md']).not.toContain('## Skipped nodes');
|
||||
});
|
||||
|
||||
it('reuses the stack dossier generator for a parseable stack', () => {
|
||||
const md = buildFleetDossier(input())['stacks/local--plex.md'];
|
||||
expect(md).toContain('# plex');
|
||||
expect(md).toContain('| plex | 32400 | 32400 | tcp |');
|
||||
expect(md).toContain('## Operator notes');
|
||||
expect(md).toContain('- **Purpose:** Media server');
|
||||
});
|
||||
|
||||
it('emits a stub page with operator notes when compose cannot be parsed', () => {
|
||||
const md = buildFleetDossier(input())['stacks/local--broken.md'];
|
||||
expect(md).toContain('# broken');
|
||||
expect(md).toContain('compose.yaml could not be parsed');
|
||||
expect(md).toContain('- **Purpose:** unparseable but documented');
|
||||
});
|
||||
|
||||
it('aggregates port, volume, network, env, access-URL, and infra maps in network.md', () => {
|
||||
const md = buildFleetDossier(input())['network.md'];
|
||||
expect(md).toContain('## Port map');
|
||||
expect(md).toContain('| local | plex | plex | 32400 | 32400 | tcp |');
|
||||
expect(md).toContain('## Volume map');
|
||||
expect(md).toContain('| local | plex | plex | ./config | /config |');
|
||||
expect(md).toContain('## Network map');
|
||||
expect(md).toContain('| local | plex | media |');
|
||||
expect(md).toContain('## Environment checklist');
|
||||
expect(md).toContain('| local | plex | .env | 3 | CLAIM_TOKEN |');
|
||||
expect(md).toContain('## Access URLs');
|
||||
expect(md).toContain('| local | plex | https://plex.example |');
|
||||
expect(md).toContain('## VLAN / static IP / firewall');
|
||||
expect(md).toContain('| local | plex | 10.0.10.4 | 10 | 32400 open on LAN |');
|
||||
});
|
||||
|
||||
it('renders _none_ for empty map sections', () => {
|
||||
const md = buildFleetDossier({
|
||||
generatedAt: 't', senchoVersion: 'v',
|
||||
nodes: [{ id: 1, name: 'n', type: 'local', reachable: true, stacks: [] }],
|
||||
})['network.md'];
|
||||
expect(md).toContain('## Port map\n\n_none_');
|
||||
});
|
||||
|
||||
it('disambiguates colliding node slugs with the node id', () => {
|
||||
const files = buildFleetDossier({
|
||||
generatedAt: 't', senchoVersion: 'v',
|
||||
nodes: [
|
||||
{ id: 1, name: 'Media Node', type: 'local', reachable: true, stacks: [] },
|
||||
{ id: 2, name: 'media node', type: 'remote', reachable: false, skipReason: 'x' },
|
||||
],
|
||||
});
|
||||
expect(files['nodes/media-node.md']).toBeDefined();
|
||||
expect(files['nodes/media-node-2.md']).toBeDefined();
|
||||
});
|
||||
|
||||
it('aggregates rows from every reachable node and stack in network.md', () => {
|
||||
const md = buildFleetDossier({
|
||||
generatedAt: 't', senchoVersion: 'v',
|
||||
nodes: [
|
||||
{
|
||||
id: 1, name: 'alpha', type: 'local', reachable: true,
|
||||
stacks: [{ stackName: 'plex', anatomy: plexAnatomy, dossier: fields() }],
|
||||
},
|
||||
{
|
||||
id: 2, name: 'beta', type: 'remote', reachable: true,
|
||||
stacks: [{
|
||||
stackName: 'grafana',
|
||||
anatomy: { ...plexAnatomy, stackName: 'grafana', ports: { grafana: [{ host: '3000', container: '3000', proto: 'tcp' }] }, volumes: {}, networkName: 'grafana_net' },
|
||||
dossier: fields(),
|
||||
}],
|
||||
},
|
||||
{ id: 3, name: 'gamma', type: 'remote', reachable: false, skipReason: 'node offline' },
|
||||
],
|
||||
})['network.md'];
|
||||
// One port row from each reachable node; the offline node contributes nothing.
|
||||
expect(md).toContain('| alpha | plex | plex | 32400 | 32400 | tcp |');
|
||||
expect(md).toContain('| beta | grafana | grafana | 3000 | 3000 | tcp |');
|
||||
expect(md).not.toContain('gamma');
|
||||
});
|
||||
|
||||
it('escapes pipe characters in node and stack names so tables stay intact', () => {
|
||||
const files = buildFleetDossier({
|
||||
generatedAt: 't', senchoVersion: 'v',
|
||||
nodes: [{
|
||||
id: 1, name: 'node|x', type: 'local', reachable: true,
|
||||
stacks: [{ stackName: 'app', anatomy: { ...plexAnatomy, stackName: 'app' }, dossier: fields() }],
|
||||
}],
|
||||
});
|
||||
expect(files['index.md']).toContain('node\\|x');
|
||||
expect(files['network.md']).toContain('| node\\|x | app |');
|
||||
});
|
||||
|
||||
it('disambiguates stack names on one node that slugify to the same value', () => {
|
||||
const mk = (name: string): FleetDossierStack => ({ stackName: name, anatomy: { ...plexAnatomy, stackName: name }, dossier: fields() });
|
||||
const files = buildFleetDossier({
|
||||
generatedAt: 't', senchoVersion: 'v',
|
||||
nodes: [{ id: 1, name: 'local', type: 'local', reachable: true, stacks: [mk('Web'), mk('web')] }],
|
||||
});
|
||||
// Both stacks keep their own page instead of one overwriting the other...
|
||||
expect(files['stacks/local--web.md']).toBeDefined();
|
||||
expect(files['stacks/local--web-2.md']).toBeDefined();
|
||||
expect(files['stacks/local--web.md']).toContain('# Web');
|
||||
expect(files['stacks/local--web-2.md']).toContain('# web');
|
||||
// ...and the node page links to each distinct file.
|
||||
expect(files['nodes/local.md']).toContain('(../stacks/local--web.md)');
|
||||
expect(files['nodes/local.md']).toContain('(../stacks/local--web-2.md)');
|
||||
});
|
||||
|
||||
it('never leaks .env values and is deterministic', () => {
|
||||
const data = input();
|
||||
// Even if anatomy somehow carried no values, prove the export contains only key names/counts.
|
||||
const a = buildFleetDossier(data);
|
||||
const b = buildFleetDossier(input());
|
||||
expect(a['network.md']).toBe(b['network.md']);
|
||||
expect(a['index.md']).toBe(b['index.md']);
|
||||
expect(JSON.stringify(a)).not.toMatch(/CLAIM_TOKEN\s*=/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Deterministic Markdown builder for the whole-fleet dossier export.
|
||||
*
|
||||
* Fans the per-stack Stack Dossier generator across every node and stack in the
|
||||
* fleet and adds fleet-level index and network/port/volume maps, producing a
|
||||
* folder of Markdown files an operator can commit to Git or store alongside
|
||||
* backups. Pure and side-effect free: the same input always yields the same
|
||||
* file map.
|
||||
*
|
||||
* Like the generators it reuses, it only ever receives env variable names and
|
||||
* counts, never `.env` values, and the operator-note fields carry no secrets,
|
||||
* so nothing sensitive can leak into the export.
|
||||
*/
|
||||
|
||||
import type { AnatomyMarkdownInput } from './anatomyMarkdown';
|
||||
import { buildStackDossierMarkdown, operatorNotesSection, type StackDossierFields } from './dossierMarkdown';
|
||||
|
||||
export interface FleetDossierStack {
|
||||
stackName: string;
|
||||
/** Generated anatomy, or null when the stack's compose.yaml could not be parsed. */
|
||||
anatomy: AnatomyMarkdownInput | null;
|
||||
dossier: StackDossierFields;
|
||||
}
|
||||
|
||||
interface FleetDossierNodeBase {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'local' | 'remote';
|
||||
}
|
||||
|
||||
/**
|
||||
* A node in the export: either reachable with its stacks, or skipped with a
|
||||
* reason. Modelled as a discriminated union so an unreachable node can never
|
||||
* carry stacks and a reachable node always has them.
|
||||
*/
|
||||
export type FleetDossierNode =
|
||||
| (FleetDossierNodeBase & { reachable: true; stacks: FleetDossierStack[] })
|
||||
| (FleetDossierNodeBase & { reachable: false; skipReason: string });
|
||||
|
||||
export interface FleetDossierInput {
|
||||
/** ISO timestamp the export was generated. */
|
||||
generatedAt: string;
|
||||
senchoVersion: string;
|
||||
nodes: FleetDossierNode[];
|
||||
}
|
||||
|
||||
/** Slugify a node or stack name into a safe, lowercase filename segment. */
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^[-.]+|[-.]+$/g, '') || 'unnamed';
|
||||
}
|
||||
|
||||
// Escape a value for a Markdown table cell: backslash first (so it cannot defeat
|
||||
// the pipe escaping), then pipes, then collapse line breaks onto one line.
|
||||
function cell(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\|/g, '\\|')
|
||||
.replace(/\r\n?|\n/g, ' ');
|
||||
}
|
||||
|
||||
/** Collapse a multi-line operator field to a single line for table display. */
|
||||
function inline(value: string): string {
|
||||
return value.trim().replace(/\s*\r?\n\s*/g, ' · ');
|
||||
}
|
||||
|
||||
/** Assign each node a unique slug, disambiguating collisions with the node id. */
|
||||
function nodeSlugs(nodes: FleetDossierNode[]): Map<number, string> {
|
||||
const used = new Set<string>();
|
||||
const map = new Map<number, string>();
|
||||
for (const node of nodes) {
|
||||
let slug = slugify(node.name);
|
||||
if (used.has(slug)) slug = `${slug}-${node.id}`;
|
||||
used.add(slug);
|
||||
map.set(node.id, slug);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each (distinct) stack name on one node to a unique slug, disambiguating
|
||||
* collisions with a numeric suffix. Stack names are unique per node, but two
|
||||
* names can slugify to the same value (e.g. `Web` and `web` on a case-sensitive
|
||||
* host), which would otherwise overwrite a stack's page in the file map.
|
||||
*/
|
||||
function stackSlugs(names: string[]): Map<string, string> {
|
||||
const used = new Set<string>();
|
||||
const map = new Map<string, string>();
|
||||
for (const name of names) {
|
||||
let slug = slugify(name);
|
||||
if (used.has(slug)) {
|
||||
let i = 2;
|
||||
while (used.has(`${slug}-${i}`)) i++;
|
||||
slug = `${slug}-${i}`;
|
||||
}
|
||||
used.add(slug);
|
||||
map.set(name, slug);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function stackPageMarkdown(stack: FleetDossierStack): string {
|
||||
if (stack.anatomy) {
|
||||
return `${buildStackDossierMarkdown(stack.anatomy, stack.dossier)}\n`;
|
||||
}
|
||||
// Compose could not be parsed: keep the operator's notes rather than dropping
|
||||
// the stack from the export entirely.
|
||||
const notes = operatorNotesSection(stack.dossier);
|
||||
const body = `# ${stack.stackName}\n\n_compose.yaml could not be parsed; showing operator notes only._`;
|
||||
return `${notes ? `${body}\n\n${notes}` : body}\n`;
|
||||
}
|
||||
|
||||
function nodePageMarkdown(node: FleetDossierNode, slug: string, slugForStack: Map<string, string>): string {
|
||||
const lines = [`# ${node.name}`, ''];
|
||||
lines.push(`- **Type:** ${node.type}`);
|
||||
lines.push(`- **Status:** ${node.reachable ? 'reachable' : 'unreachable'}`);
|
||||
if (!node.reachable) {
|
||||
lines.push(`- **Skipped:** ${node.skipReason}`);
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
lines.push(`- **Stacks:** ${node.stacks.length}`);
|
||||
lines.push('', '## Stacks');
|
||||
if (node.stacks.length === 0) {
|
||||
lines.push('_none_');
|
||||
} else {
|
||||
for (const stack of node.stacks) {
|
||||
lines.push(`- [${stack.stackName}](../stacks/${slug}--${slugForStack.get(stack.stackName)}.md)`);
|
||||
}
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function indexMarkdown(input: FleetDossierInput, slugs: Map<number, string>): string {
|
||||
const lines = ['# Homelab Dossier', '', `_Generated ${input.generatedAt} · Sencho ${input.senchoVersion}_`, ''];
|
||||
|
||||
lines.push('## Nodes', '', '| Node | Type | Status | Stacks |', '| --- | --- | --- | --- |');
|
||||
for (const node of input.nodes) {
|
||||
const slug = slugs.get(node.id)!;
|
||||
const status = node.reachable ? 'reachable' : 'unreachable';
|
||||
const count = node.reachable ? String(node.stacks.length) : '-';
|
||||
lines.push(`| [${cell(node.name)}](nodes/${slug}.md) | ${node.type} | ${status} | ${count} |`);
|
||||
}
|
||||
|
||||
if (input.nodes.some(n => !n.reachable)) {
|
||||
lines.push('', '## Skipped nodes', '');
|
||||
for (const node of input.nodes) {
|
||||
if (node.reachable) continue;
|
||||
lines.push(`- **${node.name}** (${node.type}): ${node.skipReason}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('', '## Network maps', '', '- [Port, volume, and network maps](network.md)');
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
interface StackRef { nodeName: string; stack: FleetDossierStack; }
|
||||
|
||||
function reachableStacks(input: FleetDossierInput): StackRef[] {
|
||||
const refs: StackRef[] = [];
|
||||
for (const node of input.nodes) {
|
||||
if (!node.reachable) continue;
|
||||
for (const stack of node.stacks) refs.push({ nodeName: node.name, stack });
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function table(header: string[], rows: string[][]): string {
|
||||
if (rows.length === 0) return '_none_';
|
||||
const head = `| ${header.join(' | ')} |`;
|
||||
const sep = `| ${header.map(() => '---').join(' | ')} |`;
|
||||
const body = rows.map(r => `| ${r.map(cell).join(' | ')} |`);
|
||||
return [head, sep, ...body].join('\n');
|
||||
}
|
||||
|
||||
function networkMarkdown(input: FleetDossierInput): string {
|
||||
const refs = reachableStacks(input);
|
||||
const lines = ['# Network Maps', '', `_Generated ${input.generatedAt} · Sencho ${input.senchoVersion}_`, ''];
|
||||
|
||||
const portRows: string[][] = [];
|
||||
const volumeRows: string[][] = [];
|
||||
const networkRows: string[][] = [];
|
||||
const envRows: string[][] = [];
|
||||
const accessRows: string[][] = [];
|
||||
const infraRows: string[][] = [];
|
||||
|
||||
for (const { nodeName, stack } of refs) {
|
||||
const { anatomy, dossier, stackName } = stack;
|
||||
if (anatomy) {
|
||||
for (const [svc, list] of Object.entries(anatomy.ports)) {
|
||||
for (const p of list) portRows.push([nodeName, stackName, svc, p.host, p.container, p.proto]);
|
||||
}
|
||||
for (const [svc, list] of Object.entries(anatomy.volumes)) {
|
||||
for (const v of list) volumeRows.push([nodeName, stackName, svc, v.host, v.container]);
|
||||
}
|
||||
networkRows.push([nodeName, stackName, anatomy.networkName]);
|
||||
envRows.push([
|
||||
nodeName,
|
||||
stackName,
|
||||
anatomy.envFile ?? 'none',
|
||||
String(anatomy.envVarCount),
|
||||
anatomy.missingVars.length > 0 ? anatomy.missingVars.join(', ') : 'none',
|
||||
]);
|
||||
}
|
||||
const accessUrls = inline(dossier.access_urls);
|
||||
if (accessUrls) accessRows.push([nodeName, stackName, accessUrls]);
|
||||
if (dossier.static_ip.trim() || dossier.vlan.trim() || dossier.firewall_notes.trim()) {
|
||||
infraRows.push([
|
||||
nodeName,
|
||||
stackName,
|
||||
dossier.static_ip.trim() || '-',
|
||||
dossier.vlan.trim() || '-',
|
||||
inline(dossier.firewall_notes) || '-',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('## Port map', '', table(['Node', 'Stack', 'Service', 'Host', 'Container', 'Protocol'], portRows), '');
|
||||
lines.push('## Volume map', '', table(['Node', 'Stack', 'Service', 'Host', 'Container'], volumeRows), '');
|
||||
lines.push('## Network map', '', table(['Node', 'Stack', 'Network'], networkRows), '');
|
||||
lines.push('## Environment checklist', '', table(['Node', 'Stack', 'Env file', 'Variables', 'Missing'], envRows), '');
|
||||
lines.push('## Access URLs', '', table(['Node', 'Stack', 'URLs'], accessRows), '');
|
||||
lines.push('## VLAN / static IP / firewall', '', table(['Node', 'Stack', 'Static IP', 'VLAN', 'Firewall'], infraRows));
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full fleet dossier as a map of relative file path to Markdown
|
||||
* content, ready to zip. Always emits `index.md` and `network.md`; emits one
|
||||
* `nodes/<slug>.md` per node and one `stacks/<node>--<stack>.md` per stack on a
|
||||
* reachable node.
|
||||
*/
|
||||
export function buildFleetDossier(input: FleetDossierInput): Record<string, string> {
|
||||
const slugs = nodeSlugs(input.nodes);
|
||||
const files: Record<string, string> = {
|
||||
'index.md': indexMarkdown(input, slugs),
|
||||
'network.md': networkMarkdown(input),
|
||||
};
|
||||
|
||||
for (const node of input.nodes) {
|
||||
const slug = slugs.get(node.id)!;
|
||||
// One stack-slug map per node, shared by the node-page links and the file
|
||||
// emission below so a slug collision never points a link at the wrong page
|
||||
// or silently overwrites a stack's file.
|
||||
const slugForStack = node.reachable ? stackSlugs(node.stacks.map(s => s.stackName)) : new Map<string, string>();
|
||||
files[`nodes/${slug}.md`] = nodePageMarkdown(node, slug, slugForStack);
|
||||
if (!node.reachable) continue;
|
||||
for (const stack of node.stacks) {
|
||||
files[`stacks/${slug}--${slugForStack.get(stack.stackName)}.md`] = stackPageMarkdown(stack);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
Reference in New Issue
Block a user