mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
feat(fleet): export a whole-fleet Markdown dossier (#1334)
* feat(fleet): export a whole-fleet Markdown dossier Add an admin-only "Export Dossier" action to the Fleet view that walks every node and stack, pairs each stack's generated Compose anatomy with its operator notes, and downloads a folder-structured homelab-dossier.zip (index, per-node and per-stack pages, plus fleet-wide port, volume, network, env, access-URL, and VLAN/firewall maps). Reuses the existing stack dossier and anatomy Markdown generators by extracting the shared Compose parsers into a frontend lib module. Unreachable nodes are recorded with a reason and never block the export; only env variable names and counts are ever emitted, never values. * fix(fleet): unique stack slugs and reproducible dossier archive Disambiguate stack names on one node that slugify to the same value (e.g. `Web` and `web` on a case-sensitive host) with a per-node slug map shared by the node-page links and the file emission, so neither overwrites the other. Pin a fixed entry timestamp on the zip so the archive bytes are a pure function of the file map rather than the wall clock.
This commit is contained in:
@@ -1,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">
|
||||
|
||||
Reference in New Issue
Block a user