Files
sencho/frontend/src/lib/dossierMarkdown.ts
T
Anso 9ea2864d60 feat: per-stack storage inventory and portability guardrails (#1399)
* 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.
2026-06-20 15:06:26 -04:00

100 lines
3.3 KiB
TypeScript

/**
* 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';
import { networkExposureSection, type NetworkExposureSummary } from './networkExposureSummary';
import { storageSection, type StorageSummary } from './storageSummary';
/**
* 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'],
];
export 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,
networking?: NetworkExposureSummary | null,
storage?: StorageSummary | null,
): string {
const sections = [
buildStackAnatomyMarkdown(anatomy),
networkExposureSection(networking ?? null),
storageSection(storage ?? null),
operatorNotesSection(dossier),
].filter((s): s is string => s !== null && s !== '');
return sections.join('\n\n');
}