mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
9ea2864d60
* feat: per-stack storage inventory and portability guardrails Add a Storage tab to the stack Anatomy panel that derives a per-stack mount inventory (bind mounts, named/anonymous volumes, tmpfs, docker socket; read-only vs read-write; host-path existence, type, and owner) from the effective Compose model, and classifies the stack as Portable, Partially portable, Node-bound, or Unknown with the reasons behind it. - New GET /api/stacks/:stackName/storage route (stack:read, Community), served by an on-demand, non-persisted service that renders the effective model, probes within-stack bind sources (symlink-escape aware), and runs the deterministic portability classifier. - Extend the effective-model parser additively with a full per-mount inventory and service-level tmpfs, leaving the rule-facing binds/namedVolumes byte-identical for the existing preflight rules. - New anonymous-volume preflight finding. - Admin-visible "no recent snapshot" warning that reuses the existing hub-local snapshot-coverage endpoint, plus a static note distinguishing config snapshots from application-data backups. - Surface storage assumptions in the Stack Dossier markdown export. - Gate the tab behind a new compose-storage capability on both sides. * docs: phrase the Storage tab availability as current behavior Replace the "older Sencho version / until it is updated" wording in the Storage feature page with present-tense, capability-based phrasing.
401 lines
18 KiB
TypeScript
401 lines
18 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { Copy, Download, Save, TriangleAlert } 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 { buildNetworkExposureSummary, type NetworkExposureSummary } from '@/lib/networkExposureSummary';
|
|
import { buildStorageSummary, type StorageSummary } from '@/lib/storageSummary';
|
|
import { computeDocDrift, type DocDriftFinding } from '@/lib/docDrift';
|
|
import { RollbackReadinessSection } from './RollbackReadinessSection';
|
|
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>
|
|
);
|
|
}
|
|
|
|
// Documentation drift: ports the operator documented in access_urls that the
|
|
// stack does not publish. Read-only and advisory; it never edits notes or
|
|
// compose. Visual language matches the Drift tab's warning findings.
|
|
function DocDriftWarnings({ findings }: { findings: DocDriftFinding[] }) {
|
|
return (
|
|
<section data-testid="dossier-doc-drift">
|
|
<div className={cn(LABEL_CLASS, 'mb-1.5')}>documentation drift</div>
|
|
<div className="rounded-lg border border-warning/40 bg-warning/[0.06] px-3 py-1">
|
|
{findings.map(f => (
|
|
<div key={f.port} className="border-t border-warning/20 py-2 first:border-t-0">
|
|
<div className="flex items-center gap-2">
|
|
<TriangleAlert className="h-3.5 w-3.5 shrink-0 text-warning" strokeWidth={1.5} />
|
|
<span className="rounded-md bg-warning/15 px-1.5 py-0.5 font-mono text-[11px] text-warning">:{f.port}</span>
|
|
<span className="font-mono text-[10px] uppercase tracking-wide text-stat-subtitle">not published</span>
|
|
</div>
|
|
<div className="mt-1 text-[12px] text-foreground/90">{f.detail}</div>
|
|
<div className="mt-1 truncate font-mono text-[11px] text-stat-subtitle" title={f.source}>{f.source}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export default function StackDossierPanel({ stackName, anatomy, canEdit }: StackDossierPanelProps) {
|
|
const { activeNode, hasCapability } = useNodes();
|
|
const nodeId = activeNode?.id;
|
|
const storageEnabled = hasCapability('compose-storage');
|
|
// Identifies the dossier currently in view. Doc-drift renders only once the
|
|
// load for *this* key has succeeded (see loadedKey), so a switch-in-flight or
|
|
// a failed load never diffs new anatomy against the prior stack's fields.
|
|
const currentKey = `${nodeId ?? ''}::${stackName}`;
|
|
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);
|
|
// The (node, stack) whose dossier has successfully loaded. Set only on a
|
|
// successful fetch, so it lags during a switch and stays behind on a failed
|
|
// load, which is exactly when doc-drift must stay hidden.
|
|
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
|
|
|
// 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 next = pickFields(await res.json());
|
|
setServerFields(next);
|
|
setFields(next);
|
|
setLoadError(false);
|
|
setLoadedKey(`${nodeId ?? ''}::${stackName}`);
|
|
} catch {
|
|
if (!cancelled) {
|
|
setLoadError(true);
|
|
toast.error('Failed to load the stack dossier.');
|
|
}
|
|
}
|
|
};
|
|
void run();
|
|
return () => { cancelled = true; };
|
|
}, [stackName, nodeId, reloadKey]);
|
|
|
|
// Fetched only when the user exports, so opening the panel costs nothing.
|
|
// Fail-soft: if it is unavailable the export simply omits the section.
|
|
const loadNetworkingSummary = async (): Promise<NetworkExposureSummary | null> => {
|
|
try {
|
|
const [factsRes, exposureRes] = await Promise.all([
|
|
apiFetch(`/stacks/${stackName}/networking`),
|
|
apiFetch(`/stacks/${stackName}/exposure`),
|
|
]);
|
|
const facts = factsRes.ok ? await factsRes.json() : null;
|
|
const intents = exposureRes.ok ? (await exposureRes.json()).intents ?? [] : [];
|
|
return buildNetworkExposureSummary(facts, intents);
|
|
} catch (err) {
|
|
console.warn(`[Dossier] networking summary load failed for "${stackName}":`, err);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Fetched only on export, and only when the node advertises the capability.
|
|
// Fail-soft: an unavailable inventory simply omits the storage section.
|
|
const loadStorageSummary = async (): Promise<StorageSummary | null> => {
|
|
if (!storageEnabled) return null;
|
|
try {
|
|
const res = await apiFetch(`/stacks/${stackName}/storage`);
|
|
return res.ok ? buildStorageSummary(await res.json()) : null;
|
|
} catch (err) {
|
|
console.warn(`[Dossier] storage summary load failed for "${stackName}":`, err);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const dirty = useMemo(
|
|
() => FIELD_KEYS.some(k => fields[k] !== serverFields[k]),
|
|
[fields, serverFields],
|
|
);
|
|
|
|
// Recomputes live as the operator edits access_urls; depends on that single
|
|
// field so unrelated edits do not re-run the comparison.
|
|
const docDrift = useMemo(
|
|
() => computeDocDrift(anatomy, fields.access_urls),
|
|
[anatomy, fields.access_urls],
|
|
);
|
|
|
|
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 {
|
|
const [networking, storage] = await Promise.all([loadNetworkingSummary(), loadStorageSummary()]);
|
|
await copyToClipboard(buildStackDossierMarkdown(anatomy, fields, networking, storage));
|
|
toast.success('Stack dossier copied as Markdown.');
|
|
} catch {
|
|
toast.error('Failed to copy to clipboard.');
|
|
}
|
|
};
|
|
|
|
const handleDownload = async () => {
|
|
if (!anatomy) return;
|
|
try {
|
|
const [networking, storage] = await Promise.all([loadNetworkingSummary(), loadStorageSummary()]);
|
|
// 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, networking, storage));
|
|
} 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={() => { void 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>
|
|
|
|
{loadedKey === currentKey && docDrift.length > 0 && <DocDriftWarnings findings={docDrift} />}
|
|
|
|
<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>
|
|
|
|
<RollbackReadinessSection stackName={stackName} />
|
|
</div>
|
|
);
|
|
}
|