mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +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.
100 lines
3.3 KiB
TypeScript
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');
|
|
}
|