mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 09:46:47 +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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user