mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
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.
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
} 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';
|
||||
@@ -152,8 +153,9 @@ function DocDriftWarnings({ findings }: { findings: DocDriftFinding[] }) {
|
||||
}
|
||||
|
||||
export default function StackDossierPanel({ stackName, anatomy, canEdit }: StackDossierPanelProps) {
|
||||
const { activeNode } = useNodes();
|
||||
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.
|
||||
@@ -219,6 +221,19 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
|
||||
}
|
||||
};
|
||||
|
||||
// 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],
|
||||
@@ -265,8 +280,8 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
|
||||
const handleCopy = async () => {
|
||||
if (!anatomy) return;
|
||||
try {
|
||||
const networking = await loadNetworkingSummary();
|
||||
await copyToClipboard(buildStackDossierMarkdown(anatomy, fields, networking));
|
||||
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.');
|
||||
@@ -276,11 +291,11 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
|
||||
const handleDownload = async () => {
|
||||
if (!anatomy) return;
|
||||
try {
|
||||
const networking = await loadNetworkingSummary();
|
||||
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));
|
||||
downloadTextFile(`${base}-dossier.md`, buildStackDossierMarkdown(anatomy, fields, networking, storage));
|
||||
} catch {
|
||||
toast.error('Failed to download the dossier.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user