mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
feat(stacks): add Stack Dossier tab with operator notes and Markdown export (#1326)
* feat(stacks): add Stack Dossier tab with operator notes and Markdown export Add a Dossier tab beside Anatomy and Activity on the stack detail panel. It shows a read-only summary auto-derived from the stack's Compose anatomy (services, ports, volumes, network, restart policy, env file, source) plus an editable form for the context Sencho cannot infer: purpose, owner, access URLs, static IP, VLAN, and firewall, reverse-proxy, backup, upgrade, recovery, and custom notes. Notes persist per stack and per node in a new stack_dossiers table, reached transparently through the remote-node proxy so a remote stack's dossier round-trips to the node that owns it. Reading a dossier needs stack read permission; saving needs stack edit. The tab exports a single Markdown document combining the generated facts and the operator notes, with copy-to-clipboard and download actions; env values are never exported, only variable names and counts. Available on all tiers. The standalone anatomy copy-as-Markdown shortcut is removed since the Dossier export supersedes it. * fix(stacks): gate dossier reads/writes on stack existence; clear dossier on node delete A dossier endpoint validated the stack name but not that the stack exists, so an editor could PUT a dossier for a name with no stack, leaving an orphan row that a later stack of the same name would inherit. Require the stack to exist (existing requireStackExists guard) on the dossier GET and PUT, returning 404 otherwise. Also clear a node's stack_dossiers rows when the node is deleted, alongside the other node-scoped cleanup, so removing a node leaves no orphan dossiers. Docs: scope the "no secret exported" statement to the generated facts (which only ever carry variable names and counts) and clarify that operator notes are exported exactly as written.
This commit is contained in:
@@ -1,14 +1,13 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen, Copy } from 'lucide-react';
|
||||
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 { copyToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { buildStackAnatomyMarkdown, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown';
|
||||
import { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown';
|
||||
import { StackActivityTimeline } from './stack/StackActivityTimeline';
|
||||
import StackDossierPanel from './stack/StackDossierPanel';
|
||||
import type { NotificationItem } from '@/components/dashboard/types';
|
||||
|
||||
interface StackAnatomyPanelProps {
|
||||
@@ -365,9 +364,11 @@ export default function StackAnatomyPanel({
|
||||
return null;
|
||||
}, [anatomy]);
|
||||
|
||||
const handleCopyMarkdown = async () => {
|
||||
if (!anatomy) return;
|
||||
const markdown = buildStackAnatomyMarkdown({
|
||||
// Assembled facts for this stack, passed to the Dossier tab for its read-only
|
||||
// summary and Markdown export. Null until compose parses.
|
||||
const anatomyInput = useMemo<AnatomyMarkdownInput | null>(() => {
|
||||
if (!anatomy) return null;
|
||||
return {
|
||||
stackName,
|
||||
services: anatomy.services,
|
||||
ports: anatomy.ports,
|
||||
@@ -378,14 +379,8 @@ export default function StackAnatomyPanel({
|
||||
missingVars,
|
||||
networkName,
|
||||
gitSource: activeGitSource ? formatGitSource(activeGitSource) : null,
|
||||
});
|
||||
try {
|
||||
await copyToClipboard(markdown);
|
||||
toast.success('Stack anatomy copied as Markdown.');
|
||||
} catch {
|
||||
toast.error('Failed to copy to clipboard.');
|
||||
}
|
||||
};
|
||||
};
|
||||
}, [anatomy, stackName, firstEnvFile, envVarCount, missingVars, networkName, activeGitSource]);
|
||||
|
||||
const bump = updatePreview?.summary.semver_bump ?? 'none';
|
||||
const hasUpdate = Boolean(updatePreview?.summary.has_update);
|
||||
@@ -421,19 +416,9 @@ export default function StackAnatomyPanel({
|
||||
<TabsList className="h-7 gap-0.5 bg-transparent border-none p-0">
|
||||
<TabsTrigger value="anatomy" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Anatomy</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
|
||||
<TabsTrigger value="dossier" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Dossier</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-3">
|
||||
{anatomy && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="anatomy-copy-md-btn"
|
||||
onClick={() => { void handleCopyMarkdown(); }}
|
||||
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
|
||||
>
|
||||
<Copy className="h-3 w-3" strokeWidth={1.5} />
|
||||
copy md
|
||||
</button>
|
||||
)}
|
||||
{onOpenFiles && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -636,6 +621,9 @@ export default function StackAnatomyPanel({
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="dossier" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<StackDossierPanel stackName={stackName} anatomy={anatomyInput} canEdit={canEdit} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Covers the Dossier editor: it loads saved fields, enables Save only after an
|
||||
* edit (then PUTs the document), renders read-only for users who cannot edit,
|
||||
* surfaces a distinct retry state on load failure (without blanking), keeps the
|
||||
* form dirty when a save fails, coerces non-string payloads, and wires the
|
||||
* copy/download exports.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 } }) }));
|
||||
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('@/lib/download', () => ({ downloadTextFile: vi.fn() }));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { downloadTextFile } from '@/lib/download';
|
||||
import StackDossierPanel from './StackDossierPanel';
|
||||
import { EMPTY_DOSSIER_FIELDS } from '@/lib/dossierMarkdown';
|
||||
import type { AnatomyMarkdownInput } from '@/lib/anatomyMarkdown';
|
||||
|
||||
const anatomy: AnatomyMarkdownInput = {
|
||||
stackName: 'web', services: ['web'], ports: {}, volumes: {}, restart: null,
|
||||
envFile: null, envVarCount: 0, missingVars: [], networkName: 'web_default', gitSource: null,
|
||||
};
|
||||
|
||||
function jsonRes(body: unknown, ok = true) {
|
||||
return { ok, status: ok ? 200 : 500, json: async () => body, text: async () => '' } as unknown as Response;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('StackDossierPanel', () => {
|
||||
it('loads saved fields into the form', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, purpose: 'Reverse proxy' }));
|
||||
render(<StackDossierPanel stackName="web" anatomy={anatomy} canEdit />);
|
||||
await waitFor(() =>
|
||||
expect((screen.getByTestId('dossier-field-purpose') as HTMLInputElement).value).toBe('Reverse proxy'),
|
||||
);
|
||||
});
|
||||
|
||||
it('enables save only after an edit and PUTs the document', async () => {
|
||||
vi.mocked(apiFetch).mockImplementation(async (_endpoint: string, opts?: { method?: string }) =>
|
||||
opts?.method === 'PUT'
|
||||
? jsonRes({ ...EMPTY_DOSSIER_FIELDS, owner: 'ops' })
|
||||
: jsonRes({ ...EMPTY_DOSSIER_FIELDS }),
|
||||
);
|
||||
render(<StackDossierPanel stackName="web" anatomy={anatomy} canEdit />);
|
||||
|
||||
const saveBtn = await screen.findByTestId('dossier-save-btn');
|
||||
expect(saveBtn).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByTestId('dossier-field-owner'), { target: { value: 'ops' } });
|
||||
expect(saveBtn).not.toBeDisabled();
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
const putCall = vi.mocked(apiFetch).mock.calls.find(([, o]) => (o as { method?: string } | undefined)?.method === 'PUT');
|
||||
expect(putCall).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders read-only (no save button, disabled inputs) for users who cannot edit', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS }));
|
||||
render(<StackDossierPanel stackName="web" anatomy={anatomy} canEdit={false} />);
|
||||
await screen.findByTestId('dossier-panel');
|
||||
expect(screen.queryByTestId('dossier-save-btn')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('dossier-field-purpose')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables the export buttons when compose cannot be parsed', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS }));
|
||||
render(<StackDossierPanel stackName="web" anatomy={null} canEdit />);
|
||||
await screen.findByTestId('dossier-panel');
|
||||
expect(screen.getByTestId('dossier-copy-btn')).toBeDisabled();
|
||||
expect(screen.getByTestId('dossier-download-btn')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows a retry state (not a blank form) when the load fails', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ error: 'down' }, false));
|
||||
render(<StackDossierPanel stackName="web" anatomy={anatomy} canEdit />);
|
||||
await screen.findByTestId('dossier-retry-btn');
|
||||
// The form must NOT render blank fields that could be mistaken for "no notes".
|
||||
expect(screen.queryByTestId('dossier-field-purpose')).not.toBeInTheDocument();
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the form dirty when a save fails', async () => {
|
||||
vi.mocked(apiFetch).mockImplementation(async (_endpoint: string, opts?: { method?: string }) =>
|
||||
opts?.method === 'PUT'
|
||||
? jsonRes({ error: 'boom' }, false)
|
||||
: jsonRes({ ...EMPTY_DOSSIER_FIELDS }),
|
||||
);
|
||||
render(<StackDossierPanel stackName="web" anatomy={anatomy} canEdit />);
|
||||
const saveBtn = await screen.findByTestId('dossier-save-btn');
|
||||
fireEvent.change(screen.getByTestId('dossier-field-owner'), { target: { value: 'ops' } });
|
||||
fireEvent.click(saveBtn);
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(saveBtn).not.toBeDisabled(); // still dirty: the failed save did not advance the baseline
|
||||
});
|
||||
|
||||
it('coerces non-string payload values to empty strings', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ purpose: null, owner: 5, custom_notes: 'keep' }));
|
||||
render(<StackDossierPanel stackName="web" anatomy={anatomy} canEdit />);
|
||||
await waitFor(() =>
|
||||
expect((screen.getByTestId('dossier-field-custom_notes') as HTMLTextAreaElement).value).toBe('keep'),
|
||||
);
|
||||
expect((screen.getByTestId('dossier-field-purpose') as HTMLInputElement).value).toBe('');
|
||||
expect((screen.getByTestId('dossier-field-owner') as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
|
||||
it('wires copy and download to the combined Markdown', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS }));
|
||||
render(<StackDossierPanel stackName="web" anatomy={anatomy} canEdit />);
|
||||
await screen.findByTestId('dossier-panel');
|
||||
|
||||
fireEvent.click(screen.getByTestId('dossier-copy-btn'));
|
||||
await waitFor(() => expect(copyToClipboard).toHaveBeenCalled());
|
||||
expect(vi.mocked(copyToClipboard).mock.calls[0][0]).toContain('# web');
|
||||
|
||||
fireEvent.click(screen.getByTestId('dossier-download-btn'));
|
||||
expect(downloadTextFile).toHaveBeenCalledWith('web-dossier.md', expect.stringContaining('# web'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Copy, Download, Save } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { downloadTextFile } from '@/lib/download';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
buildStackDossierMarkdown,
|
||||
EMPTY_DOSSIER_FIELDS,
|
||||
type StackDossierFields,
|
||||
} from '@/lib/dossierMarkdown';
|
||||
import type { AnatomyMarkdownInput } from '@/lib/anatomyMarkdown';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
interface StackDossierPanelProps {
|
||||
stackName: string;
|
||||
/** Generated anatomy for this stack, or null when compose.yaml cannot be parsed. */
|
||||
anatomy: AnatomyMarkdownInput | null;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
const FIELD_KEYS = Object.keys(EMPTY_DOSSIER_FIELDS) as Array<keyof StackDossierFields>;
|
||||
|
||||
// `max` caps mirror the backend dossier validation schema (routes/stacks.ts), so
|
||||
// the input stops at the limit instead of letting a save fail with a 400.
|
||||
const TEXT_FIELDS: Array<{ key: keyof StackDossierFields; label: string; placeholder: string; max: number }> = [
|
||||
{ key: 'purpose', label: 'purpose', placeholder: 'What this stack is for', max: 1000 },
|
||||
{ key: 'owner', label: 'owner', placeholder: 'Who maintains it', max: 1000 },
|
||||
{ key: 'static_ip', label: 'static ip', placeholder: 'e.g. 10.0.20.5', max: 255 },
|
||||
{ key: 'vlan', label: 'vlan', placeholder: 'e.g. 20 / iot', max: 255 },
|
||||
];
|
||||
|
||||
const TEXTAREA_FIELDS: Array<{ key: keyof StackDossierFields; label: string; placeholder: string; rows: number; max: number }> = [
|
||||
{ key: 'access_urls', label: 'access urls', placeholder: 'One URL per line', rows: 2, max: 2000 },
|
||||
{ key: 'firewall_notes', label: 'firewall', placeholder: 'Ports opened, rules, zones', rows: 2, max: 8000 },
|
||||
{ key: 'reverse_proxy_notes', label: 'reverse proxy', placeholder: 'Hostnames, upstreams, TLS', rows: 2, max: 8000 },
|
||||
{ key: 'backup_notes', label: 'backup', placeholder: 'What to back up and how', rows: 2, max: 8000 },
|
||||
{ key: 'upgrade_notes', label: 'upgrade', placeholder: 'Upgrade steps and gotchas', rows: 2, max: 8000 },
|
||||
{ key: 'recovery_notes', label: 'recovery', placeholder: 'How to rebuild from scratch', rows: 2, max: 8000 },
|
||||
{ key: 'custom_notes', label: 'notes', placeholder: 'Anything else worth recording', rows: 3, max: 8000 },
|
||||
];
|
||||
|
||||
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
|
||||
const MONO_FACT_CLASS = 'font-mono text-[11px]';
|
||||
const TEXTAREA_CLASS =
|
||||
'w-full rounded-md border border-glass-border bg-input px-3 py-2 text-[12px] shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 resize-y';
|
||||
const ACTION_CLASS =
|
||||
'inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors disabled:opacity-40 disabled:hover:text-stat-subtitle';
|
||||
|
||||
function pickFields(data: unknown): StackDossierFields {
|
||||
const out = { ...EMPTY_DOSSIER_FIELDS };
|
||||
if (data && typeof data === 'object') {
|
||||
const obj = data as Record<string, unknown>;
|
||||
for (const k of FIELD_KEYS) {
|
||||
if (typeof obj[k] === 'string') out[k] = obj[k] as string;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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">
|
||||
<span className={cn(LABEL_CLASS, 'pt-0.5')}>{label}</span>
|
||||
<div className="min-w-0 text-[12px] text-foreground/90">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneratedFacts({ anatomy }: { anatomy: AnatomyMarkdownInput }) {
|
||||
const portRows = Object.values(anatomy.ports).flat();
|
||||
const volumeCount = Object.values(anatomy.volumes).reduce((n, list) => n + list.length, 0);
|
||||
return (
|
||||
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
|
||||
<Row label="services">
|
||||
{anatomy.services.length === 0 ? (
|
||||
<span className="text-stat-subtitle">none defined</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{anatomy.services.map(s => (
|
||||
<span key={s} className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{s}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Row>
|
||||
<Row label="ports">
|
||||
{portRows.length === 0 ? (
|
||||
<span className="text-stat-subtitle">none</span>
|
||||
) : (
|
||||
<span className={MONO_FACT_CLASS}>
|
||||
{portRows.length} published <span className="text-stat-subtitle">· {portRows.map(r => `:${r.host}`).join(' ')}</span>
|
||||
</span>
|
||||
)}
|
||||
</Row>
|
||||
<Row label="volumes">
|
||||
<span className={MONO_FACT_CLASS}>{volumeCount === 0 ? <span className="text-stat-subtitle">none</span> : volumeCount}</span>
|
||||
</Row>
|
||||
<Row label="network">
|
||||
<span className={MONO_FACT_CLASS}>{anatomy.networkName} <span className="text-stat-subtitle">· bridge</span></span>
|
||||
</Row>
|
||||
<Row label="restart">
|
||||
<span className={MONO_FACT_CLASS}>{anatomy.restart ?? <span className="text-stat-subtitle">default</span>}</span>
|
||||
</Row>
|
||||
<Row label="env_file">
|
||||
{!anatomy.envFile ? (
|
||||
<span className="text-stat-subtitle">none</span>
|
||||
) : (
|
||||
<span className={MONO_FACT_CLASS}>
|
||||
{anatomy.envFile} <span className="text-stat-subtitle">· {anatomy.envVarCount} var{anatomy.envVarCount === 1 ? '' : 's'}</span>
|
||||
{anatomy.missingVars.length > 0 && (
|
||||
<span className="text-destructive"> · {anatomy.missingVars.length} missing</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Row>
|
||||
<Row label="source">
|
||||
<span className={MONO_FACT_CLASS}>
|
||||
{anatomy.gitSource ? <>git <span className="text-stat-subtitle">·</span> {anatomy.gitSource}</> : 'local'}
|
||||
</span>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StackDossierPanel({ stackName, anatomy, canEdit }: StackDossierPanelProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const nodeId = activeNode?.id;
|
||||
const [fields, setFields] = useState<StackDossierFields>(EMPTY_DOSSIER_FIELDS);
|
||||
// The last-saved values, kept in state so dirty-tracking compares against them
|
||||
// without reading a ref during render.
|
||||
const [serverFields, setServerFields] = useState<StackDossierFields>(EMPTY_DOSSIER_FIELDS);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
// Reload when the stack OR the active node changes: the same stack name can
|
||||
// exist on two nodes with independent dossiers, and apiFetch scopes by the
|
||||
// active node, so a node switch must refetch. On failure we keep the existing
|
||||
// values and show a distinct error state rather than blanking the form, so a
|
||||
// failed load can never be mistaken for an empty dossier or saved as one.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/dossier`);
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
setLoadError(true);
|
||||
toast.error('Failed to load the stack dossier.');
|
||||
return;
|
||||
}
|
||||
const loaded = pickFields(await res.json());
|
||||
setServerFields(loaded);
|
||||
setFields(loaded);
|
||||
setLoadError(false);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLoadError(true);
|
||||
toast.error('Failed to load the stack dossier.');
|
||||
}
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [stackName, nodeId, reloadKey]);
|
||||
|
||||
const dirty = useMemo(
|
||||
() => FIELD_KEYS.some(k => fields[k] !== serverFields[k]),
|
||||
[fields, serverFields],
|
||||
);
|
||||
|
||||
const setField = (key: keyof StackDossierFields, value: string) =>
|
||||
setFields(prev => ({ ...prev, [key]: value }));
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/dossier`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch((parseErr) => {
|
||||
console.error('[Dossier] save error response was not JSON:', parseErr);
|
||||
return {};
|
||||
});
|
||||
toast.error(err?.error || 'Failed to save the dossier.');
|
||||
return;
|
||||
}
|
||||
const saved = pickFields(await res.json());
|
||||
setServerFields(saved);
|
||||
setFields(saved);
|
||||
toast.success('Stack dossier saved.');
|
||||
} catch {
|
||||
// apiFetch throws a sentinel ('Unauthorized') or a network error here; show
|
||||
// a fixed friendly message rather than echoing an internal error string.
|
||||
toast.error('Failed to save the dossier. Check your connection and try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!anatomy) return;
|
||||
try {
|
||||
await copyToClipboard(buildStackDossierMarkdown(anatomy, fields));
|
||||
toast.success('Stack dossier copied as Markdown.');
|
||||
} catch {
|
||||
toast.error('Failed to copy to clipboard.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!anatomy) return;
|
||||
try {
|
||||
// Stack names are already constrained, but sanitize defensively so the
|
||||
// file always has a coherent, safe name ending in .md.
|
||||
const base = stackName.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '') || 'stack';
|
||||
downloadTextFile(`${base}-dossier.md`, buildStackDossierMarkdown(anatomy, fields));
|
||||
} catch {
|
||||
toast.error('Failed to download the dossier.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="dossier-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={LABEL_CLASS}>export</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" data-testid="dossier-copy-btn" onClick={() => { void handleCopy(); }} disabled={!anatomy || loadError} className={ACTION_CLASS}>
|
||||
<Copy className="h-3 w-3" strokeWidth={1.5} /> copy md
|
||||
</button>
|
||||
<button type="button" data-testid="dossier-download-btn" onClick={handleDownload} disabled={!anatomy || loadError} className={ACTION_CLASS}>
|
||||
<Download className="h-3 w-3" strokeWidth={1.5} /> download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div className={cn(LABEL_CLASS, 'mb-1.5')}>generated facts</div>
|
||||
{anatomy ? (
|
||||
<GeneratedFacts anatomy={anatomy} />
|
||||
) : (
|
||||
<div className="rounded-lg border border-muted bg-card/40 px-3 py-3 font-mono text-[11px] text-stat-subtitle">
|
||||
Unable to parse compose.yaml.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className={cn(LABEL_CLASS, 'mb-1.5')}>operator notes</div>
|
||||
{loadError ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/40 bg-destructive/[0.06] px-3 py-3">
|
||||
<span className="font-mono text-[11px] text-destructive">Could not load this dossier.</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dossier-retry-btn"
|
||||
onClick={() => setReloadKey(k => k + 1)}
|
||||
className="font-mono text-[10px] uppercase tracking-wide text-destructive hover:underline"
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{TEXT_FIELDS.map(({ key, label, placeholder, max }) => (
|
||||
<label key={key} className="flex flex-col gap-1">
|
||||
<span className={LABEL_CLASS}>{label}</span>
|
||||
<Input
|
||||
data-testid={`dossier-field-${key}`}
|
||||
value={fields[key]}
|
||||
onChange={e => setField(key, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={!canEdit}
|
||||
maxLength={max}
|
||||
className="h-8 text-[12px]"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{TEXTAREA_FIELDS.map(({ key, label, placeholder, rows, max }) => (
|
||||
<label key={key} className="flex flex-col gap-1">
|
||||
<span className={LABEL_CLASS}>{label}</span>
|
||||
<textarea
|
||||
data-testid={`dossier-field-${key}`}
|
||||
value={fields[key]}
|
||||
onChange={e => setField(key, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={!canEdit}
|
||||
rows={rows}
|
||||
maxLength={max}
|
||||
className={TEXTAREA_CLASS}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
{canEdit && (
|
||||
<div className="flex items-center justify-end gap-3 pt-1">
|
||||
{dirty && <span className="font-mono text-[10px] uppercase tracking-wide text-stat-subtitle">unsaved changes</span>}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dossier-save-btn"
|
||||
onClick={() => { void handleSave(); }}
|
||||
disabled={saving || !dirty}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-brand/40 px-3 py-1.5 font-mono text-[10px] uppercase tracking-wide text-brand transition-colors hover:bg-brand/10 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Save className={cn('h-3 w-3', saving && 'animate-pulse')} strokeWidth={1.5} />
|
||||
{saving ? 'saving…' : 'save'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildStackDossierMarkdown, EMPTY_DOSSIER_FIELDS, type StackDossierFields } from './dossierMarkdown';
|
||||
import type { AnatomyMarkdownInput } from './anatomyMarkdown';
|
||||
|
||||
const anatomy: AnatomyMarkdownInput = {
|
||||
stackName: 'plex',
|
||||
services: ['plex'],
|
||||
ports: { plex: [{ host: '32400', container: '32400', proto: 'tcp' }] },
|
||||
volumes: {},
|
||||
restart: 'unless-stopped',
|
||||
envFile: '.env',
|
||||
envVarCount: 4,
|
||||
missingVars: ['CLAIM_TOKEN'],
|
||||
networkName: 'plex_default',
|
||||
gitSource: null,
|
||||
};
|
||||
|
||||
const fields = (over: Partial<StackDossierFields> = {}): StackDossierFields => ({ ...EMPTY_DOSSIER_FIELDS, ...over });
|
||||
|
||||
describe('buildStackDossierMarkdown', () => {
|
||||
it('returns only the anatomy markdown when no operator notes are set', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields());
|
||||
expect(md).toContain('# plex');
|
||||
expect(md).toContain('## Services');
|
||||
expect(md).not.toContain('## Operator notes');
|
||||
});
|
||||
|
||||
it('appends an Operator notes section with the filled fields', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields({
|
||||
purpose: 'Media server',
|
||||
owner: 'home',
|
||||
static_ip: '10.0.10.4',
|
||||
backup_notes: 'rsync config nightly',
|
||||
}));
|
||||
expect(md).toContain('## Operator notes');
|
||||
expect(md).toContain('- **Purpose:** Media server');
|
||||
expect(md).toContain('- **Owner:** home');
|
||||
expect(md).toContain('- **Static IP:** 10.0.10.4');
|
||||
expect(md).toContain('### Backup\nrsync config nightly');
|
||||
});
|
||||
|
||||
it('omits empty operator fields', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields({ purpose: 'only this' }));
|
||||
expect(md).toContain('- **Purpose:** only this');
|
||||
expect(md).not.toContain('- **Owner:**');
|
||||
expect(md).not.toContain('### Firewall');
|
||||
});
|
||||
|
||||
it('keeps the generated anatomy facts in the combined export', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields({ purpose: 'p' }));
|
||||
expect(md).toContain('| plex | 32400 | 32400 | tcp |');
|
||||
expect(md).toContain('- Variables: 4');
|
||||
expect(md).toContain('- Missing: `CLAIM_TOKEN`');
|
||||
});
|
||||
|
||||
it('preserves multi-line access URLs as a block', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields({ access_urls: 'https://a.example\nhttps://b.example' }));
|
||||
expect(md).toContain('### Access URLs\nhttps://a.example\nhttps://b.example');
|
||||
});
|
||||
|
||||
it('collapses stray newlines in a single-line field into one bullet', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields({ purpose: 'line one\nline two' }));
|
||||
expect(md).toContain('- **Purpose:** line one line two');
|
||||
});
|
||||
|
||||
it('the generated facts never emit a .env assignment, only variable names and counts', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields({ custom_notes: 'see runbook' }));
|
||||
expect(md).toContain('- Variables: 4');
|
||||
expect(md).toContain('- Missing: `CLAIM_TOKEN`');
|
||||
expect(md).not.toMatch(/CLAIM_TOKEN\s*=/);
|
||||
});
|
||||
|
||||
it('exports operator notes verbatim (user-authored content is not redacted)', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields({ custom_notes: 'DB_PASSWORD=hunter2' }));
|
||||
expect(md).toContain('DB_PASSWORD=hunter2');
|
||||
});
|
||||
|
||||
it('is deterministic across distinct but equal inputs', () => {
|
||||
const f = fields({ purpose: 'x', firewall_notes: 'y' });
|
||||
expect(buildStackDossierMarkdown(anatomy, f)).toBe(buildStackDossierMarkdown(anatomy, { ...f }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Deterministic Markdown export for the Stack Dossier.
|
||||
*
|
||||
* Combines the generated stack anatomy (via the shared anatomy builder) with the
|
||||
* operator-authored notes into one document an operator can paste into Git,
|
||||
* Obsidian, BookStack, a README, or store alongside backups. Pure and
|
||||
* side-effect free: the same input always yields byte-identical output.
|
||||
*
|
||||
* Like the anatomy builder it reuses, this never receives `.env` values, so no
|
||||
* secret can leak into the exported text.
|
||||
*/
|
||||
|
||||
import { buildStackAnatomyMarkdown, type AnatomyMarkdownInput } from './anatomyMarkdown';
|
||||
|
||||
/**
|
||||
* Operator-authored dossier fields. Mirrors the backend `StackDossierFields`
|
||||
* shape (the operator-authored subset of a persisted dossier row); this is the
|
||||
* single frontend source of truth shared by the editor form, the API calls, and
|
||||
* this Markdown builder.
|
||||
*/
|
||||
export interface StackDossierFields {
|
||||
purpose: string;
|
||||
owner: string;
|
||||
access_urls: string;
|
||||
static_ip: string;
|
||||
vlan: string;
|
||||
firewall_notes: string;
|
||||
reverse_proxy_notes: string;
|
||||
backup_notes: string;
|
||||
upgrade_notes: string;
|
||||
recovery_notes: string;
|
||||
custom_notes: string;
|
||||
}
|
||||
|
||||
export const EMPTY_DOSSIER_FIELDS: StackDossierFields = {
|
||||
purpose: '',
|
||||
owner: '',
|
||||
access_urls: '',
|
||||
static_ip: '',
|
||||
vlan: '',
|
||||
firewall_notes: '',
|
||||
reverse_proxy_notes: '',
|
||||
backup_notes: '',
|
||||
upgrade_notes: '',
|
||||
recovery_notes: '',
|
||||
custom_notes: '',
|
||||
};
|
||||
|
||||
// Single-line facts render as bullets; their values get any stray line breaks
|
||||
// collapsed so a bullet can never spill into a broken list.
|
||||
const SHORT_FIELDS: Array<[keyof StackDossierFields, string]> = [
|
||||
['purpose', 'Purpose'],
|
||||
['owner', 'Owner'],
|
||||
['static_ip', 'Static IP'],
|
||||
['vlan', 'VLAN'],
|
||||
];
|
||||
|
||||
// Multi-line fields render as their own heading + body block, preserving the
|
||||
// operator's line structure (e.g. one access URL per line).
|
||||
const BLOCK_FIELDS: Array<[keyof StackDossierFields, string]> = [
|
||||
['access_urls', 'Access URLs'],
|
||||
['firewall_notes', 'Firewall'],
|
||||
['reverse_proxy_notes', 'Reverse proxy'],
|
||||
['backup_notes', 'Backup'],
|
||||
['upgrade_notes', 'Upgrade'],
|
||||
['recovery_notes', 'Recovery'],
|
||||
['custom_notes', 'Notes'],
|
||||
];
|
||||
|
||||
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, ' ')}`);
|
||||
const blocks = BLOCK_FIELDS
|
||||
.filter(([k]) => d[k].trim() !== '')
|
||||
.map(([k, label]) => `### ${label}\n${d[k].trim()}`);
|
||||
if (bullets.length === 0 && blocks.length === 0) return null;
|
||||
const parts = ['## Operator notes'];
|
||||
if (bullets.length > 0) parts.push(bullets.join('\n'));
|
||||
parts.push(...blocks);
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildStackDossierMarkdown(
|
||||
anatomy: AnatomyMarkdownInput,
|
||||
dossier: StackDossierFields,
|
||||
): string {
|
||||
const anatomyMarkdown = buildStackAnatomyMarkdown(anatomy);
|
||||
const notes = operatorNotesSection(dossier);
|
||||
return notes ? `${anatomyMarkdown}\n\n${notes}` : anatomyMarkdown;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Trigger a browser download of in-memory text as a file, using the standard
|
||||
* object-URL + anchor-click idiom (the same pattern used elsewhere for exports,
|
||||
* 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` });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
Reference in New Issue
Block a user