mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 08:27:42 +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:
@@ -141,6 +141,7 @@
|
||||
"features/pilot-agent",
|
||||
"features/sencho-mesh",
|
||||
"features/fleet-view",
|
||||
"features/fleet-dossier",
|
||||
"features/fleet-federation",
|
||||
"features/fleet-actions",
|
||||
"features/fleet-sync",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: Fleet Dossier
|
||||
description: Export a whole-homelab Markdown dossier from your nodes and stacks, combining the facts Sencho derives with the operator notes you add, ready to commit to Git or store with your backups.
|
||||
---
|
||||
|
||||
The **Export Dossier** action in the Fleet view turns your entire fleet into a single documentation archive. It walks every node and every stack, pairs the operational facts Sencho derives from each Compose file with the notes you have written in each [Stack Dossier](/features/stack-dossier), and produces a folder of Markdown files you can commit to Git, drop into a wiki, or keep beside your backups.
|
||||
|
||||
It is the fleet-wide companion to the per-stack dossier: where the Stack Dossier documents one stack, the Fleet Dossier documents the whole homelab in one download, replacing the spreadsheet many self-hosters keep for ports, services, static IPs, VLANs, and firewall notes.
|
||||
|
||||
## What the export contains
|
||||
|
||||
The download is a `homelab-dossier.zip` with a browsable folder structure:
|
||||
|
||||
```text
|
||||
homelab-dossier/
|
||||
├─ index.md # generation time, Sencho version, node summary, skipped nodes
|
||||
├─ network.md # fleet-wide port, volume, and network maps + checklists
|
||||
├─ nodes/ # one page per node
|
||||
│ └─ local.md
|
||||
└─ stacks/ # one page per stack: generated anatomy + your notes
|
||||
├─ local--radarr.md
|
||||
└─ local--sonarr.md
|
||||
```
|
||||
|
||||
- **`index.md`** lists every node with its type, reachability, and stack count, plus an explicit section for any node that was skipped and why.
|
||||
- **`network.md`** aggregates across the fleet: a **port map**, **volume map**, and **network map**, an **environment checklist** by stack, an **access-URL inventory**, and a **VLAN / static IP / firewall** table. The last two are populated from your Stack Dossier notes.
|
||||
- **`nodes/<node>.md`** summarises a node and links to its stacks.
|
||||
- **`stacks/<node>--<stack>.md`** is the same combined document the Stack Dossier's own export produces: generated anatomy followed by your operator notes.
|
||||
|
||||
## Generated facts and your notes
|
||||
|
||||
Everything Sencho can derive (services, published ports, volumes, networks, restart policy, env file and its variable count, missing `${VAR}` references, and Git source) is generated live from each stack's current Compose file, so it never drifts. Everything Sencho cannot infer (purpose, owner, access URLs, static IP, VLAN, firewall, reverse proxy, backup, upgrade, and recovery notes) comes from the Stack Dossier you maintain per stack. Stacks without notes are still included, documented from their generated facts alone.
|
||||
|
||||
## Unreachable nodes
|
||||
|
||||
A remote node that is offline or unreachable when you export is never allowed to block the rest of the fleet. It appears in `index.md` under **Skipped nodes** with the reason, gets its own node page marked unreachable, and contributes no stack pages. Everything reachable still exports normally.
|
||||
|
||||
## Secrets are never included
|
||||
|
||||
As with the Stack Dossier, the generated facts only ever carry env variable **names** and **counts**, never `.env` values. Your operator notes are exported exactly as you wrote them, so treat them like any document you might share and avoid pasting secrets into them.
|
||||
|
||||
## Exporting
|
||||
|
||||
Administrators can export the fleet dossier:
|
||||
|
||||
1. Open the **Fleet** view.
|
||||
2. Click **Export Dossier** in the toolbar.
|
||||
3. Your browser downloads `homelab-dossier.zip`. Unzip it wherever you keep documentation.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="A node is listed as skipped in index.md">
|
||||
The node was offline or unreachable at export time. Its reason is shown in the **Skipped nodes** section and on its node page. Bring the node back online and export again to include its stacks.
|
||||
</Accordion>
|
||||
<Accordion title="A stack page says compose.yaml could not be parsed">
|
||||
That stack's Compose file is empty or not valid YAML, so its generated facts could not be built. The stack is still included with any operator notes you have written. Fix the Compose file and re-export to restore the full anatomy.
|
||||
</Accordion>
|
||||
<Accordion title="The Access URLs and VLAN / firewall tables are empty">
|
||||
Those sections come from Stack Dossier notes. Add access URLs, static IP, VLAN, or firewall notes to a stack's Dossier tab, then export again and they appear in `network.md` and on that stack's page.
|
||||
</Accordion>
|
||||
<Accordion title="The same stack name appears under two nodes">
|
||||
Dossiers and stacks are scoped per node, so a stack with the same name on two nodes exports as two separate pages, each prefixed with its node (for example `local--radarr.md` and `media-node--radarr.md`).
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@@ -52,6 +52,8 @@ Two actions in the tab header produce a single Markdown document combining the g
|
||||
|
||||
The export is clean Markdown that reads well in Git, Obsidian, BookStack, a README, or stored alongside a backup. The generated facts never include `.env` values, only variable names and counts. Your operator notes are exported exactly as you write them, so treat them like any document you might share and avoid pasting secrets you do not want in the export.
|
||||
|
||||
To export every stack across every node at once, use the [Fleet Dossier](/features/fleet-dossier) action in the Fleet view.
|
||||
|
||||
## Accessing the Dossier tab
|
||||
|
||||
1. Click any stack in the left sidebar to open it.
|
||||
|
||||
Generated
+7
@@ -36,6 +36,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"cronstrue": "^3.14.0",
|
||||
"date-fns": "^4.3.0",
|
||||
"fflate": "^0.8.2",
|
||||
"geist": "^1.7.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
@@ -5055,6 +5056,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"cronstrue": "^3.14.0",
|
||||
"date-fns": "^4.3.0",
|
||||
"fflate": "^0.8.2",
|
||||
"geist": "^1.7.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
|
||||
@@ -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