mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
842ee7dd0c
* 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.
192 lines
7.9 KiB
TypeScript
192 lines
7.9 KiB
TypeScript
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*=/);
|
|
});
|
|
});
|