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:
Anso
2026-06-07 21:10:02 -04:00
committed by GitHub
parent b21324f97a
commit 842ee7dd0c
15 changed files with 1283 additions and 171 deletions
+15 -1
View File
@@ -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"