mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +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:
@@ -8,11 +8,13 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
const caps = vi.hoisted(() => ({ enabled: new Set<string>() }));
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
// hasCapability false keeps the rollback readiness section (tested in its own
|
||||
// file) out of these dossier-focused tests.
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 }, hasCapability: () => false }) }));
|
||||
// No capabilities by default, which keeps the rollback readiness section (tested
|
||||
// in its own file) out of these dossier-focused tests; individual tests enable
|
||||
// the capability for the gated storage export.
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 }, hasCapability: (c: string) => caps.enabled.has(c) }) }));
|
||||
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('@/lib/download', () => ({ downloadTextFile: vi.fn() }));
|
||||
|
||||
@@ -35,6 +37,7 @@ function jsonRes(body: unknown, ok = true) {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
caps.enabled.clear();
|
||||
});
|
||||
|
||||
describe('StackDossierPanel', () => {
|
||||
@@ -179,6 +182,49 @@ describe('StackDossierPanel', () => {
|
||||
expect(section).toHaveTextContent(':9001');
|
||||
});
|
||||
|
||||
it('does not fetch the storage inventory on export when compose-storage is absent', 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());
|
||||
const urls = vi.mocked(apiFetch).mock.calls.map(c => String(c[0]));
|
||||
expect(urls.some(u => u.includes('/storage'))).toBe(false);
|
||||
expect(vi.mocked(copyToClipboard).mock.calls[0][0]).not.toContain('## Storage portability');
|
||||
});
|
||||
|
||||
it('includes the storage section when the inventory loads, and omits it when the fetch fails', async () => {
|
||||
caps.enabled.add('compose-storage');
|
||||
const inventory = {
|
||||
renderable: true, stateful: true,
|
||||
mounts: [{ service: 'web', type: 'bind', source: '/srv/data', target: '/data', readOnly: false }],
|
||||
portability: { status: 'partially-portable', reasons: ['data lives on this node'] },
|
||||
};
|
||||
vi.mocked(apiFetch).mockImplementation(async (input: string) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/storage')) return jsonRes(inventory);
|
||||
if (url.includes('/dossier')) return jsonRes({ ...EMPTY_DOSSIER_FIELDS });
|
||||
return jsonRes(null, false); // networking + exposure: omitted
|
||||
});
|
||||
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('## Storage portability');
|
||||
|
||||
// Now the inventory fetch fails: the section must be omitted, not error the export.
|
||||
vi.mocked(copyToClipboard).mockClear();
|
||||
vi.mocked(apiFetch).mockImplementation(async (input: string) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/storage')) return jsonRes(null, false);
|
||||
if (url.includes('/dossier')) return jsonRes({ ...EMPTY_DOSSIER_FIELDS });
|
||||
return jsonRes(null, false);
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('dossier-copy-btn'));
|
||||
await waitFor(() => expect(copyToClipboard).toHaveBeenCalled());
|
||||
expect(vi.mocked(copyToClipboard).mock.calls[0][0]).not.toContain('## Storage portability');
|
||||
});
|
||||
|
||||
it('suppresses the warning when a reload fails, never showing the previous stack stale', async () => {
|
||||
vi.mocked(apiFetch)
|
||||
.mockResolvedValueOnce(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:9000' }))
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Covers the Storage panel: the portability verdict + per-service mounts, the
|
||||
* unrenderable banner, a load-failure retry, the static snapshot caveat, and the
|
||||
* admin-only snapshot coverage merge (the warning, the recent-snapshot line, the
|
||||
* hub-local `/fleet/snapshots/coverage` path, and that non-admins never fetch it).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const auth = vi.hoisted(() => ({ isAdmin: false }));
|
||||
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('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: auth.isAdmin }) }));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import StoragePanel from './StoragePanel';
|
||||
|
||||
interface Mount { service: string; type: string; source?: string; target: string; readOnly: boolean; probe: unknown; externalNamed: boolean }
|
||||
function mount(over: Partial<Mount> = {}): Mount {
|
||||
return { service: 'app', type: 'bind', target: '/data', readOnly: false, probe: null, externalNamed: false, ...over };
|
||||
}
|
||||
function inventory(over: Record<string, unknown> = {}) {
|
||||
return { stack: 'app', renderable: true, renderError: null, stateful: true, mounts: [], portability: { status: 'portable', reasons: [] }, ...over };
|
||||
}
|
||||
function jsonRes(body: unknown, ok = true) {
|
||||
return { ok, status: ok ? 200 : 500, json: async () => body, text: async () => '' } as unknown as Response;
|
||||
}
|
||||
|
||||
/** Route apiFetch by URL: /storage vs /fleet/snapshots/coverage. */
|
||||
function route(opts: { storage?: Response; coverage?: Response }) {
|
||||
vi.mocked(apiFetch).mockImplementation(((url: string) => {
|
||||
if (String(url).includes('/snapshots/coverage')) return Promise.resolve(opts.coverage ?? jsonRes({ latestAt: null }));
|
||||
return Promise.resolve(opts.storage ?? jsonRes(inventory()));
|
||||
}) as unknown as typeof apiFetch);
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.clearAllMocks(); auth.isAdmin = false; });
|
||||
|
||||
describe('StoragePanel', () => {
|
||||
it('renders the portability verdict and mounts grouped by service', async () => {
|
||||
route({ storage: jsonRes(inventory({
|
||||
portability: { status: 'node-bound', reasons: ['Binds host paths outside the stack directory.'] },
|
||||
mounts: [mount({ service: 'web', source: '/mnt/media', target: '/media' }), mount({ service: 'db', type: 'named', source: 'data', target: '/var/lib' })],
|
||||
})) });
|
||||
render(<StoragePanel stackName="app" />);
|
||||
const verdict = await screen.findByTestId('storage-portability');
|
||||
expect(verdict).toHaveAttribute('data-status', 'node-bound');
|
||||
expect(verdict).toHaveTextContent(/node-bound/i);
|
||||
expect(screen.getByTestId('storage-service-web')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('storage-service-db')).toBeInTheDocument();
|
||||
expect(screen.getByText(/\/var\/lib/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the cannot-render banner with the render error', async () => {
|
||||
route({ storage: jsonRes(inventory({ renderable: false, renderError: 'bad compose', stateful: false, portability: { status: 'unknown', reasons: [] } })) });
|
||||
render(<StoragePanel stackName="app" />);
|
||||
expect(await screen.findByText(/cannot render/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/bad compose/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a retry state and toasts when the load fails', async () => {
|
||||
route({ storage: jsonRes(null, false) });
|
||||
render(<StoragePanel stackName="app" />);
|
||||
expect(await screen.findByText(/Could not load the storage inventory/i)).toBeInTheDocument();
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
expect(screen.getByTestId('storage-retry-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('always shows the snapshots-cover-config caveat', async () => {
|
||||
route({ storage: jsonRes(inventory()) });
|
||||
render(<StoragePanel stackName="app" />);
|
||||
expect(await screen.findByText(/capture Compose and env files, not the data/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not fetch snapshot coverage for a non-admin', async () => {
|
||||
route({ storage: jsonRes(inventory()) });
|
||||
render(<StoragePanel stackName="app" />);
|
||||
await screen.findByTestId('storage-portability');
|
||||
await waitFor(() => {
|
||||
const urls = vi.mocked(apiFetch).mock.calls.map(c => String(c[0]));
|
||||
expect(urls.some(u => u.includes('/storage'))).toBe(true);
|
||||
expect(urls.some(u => u.includes('/snapshots/coverage'))).toBe(false);
|
||||
});
|
||||
expect(screen.queryByTestId('storage-snapshot-warning')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fetches coverage from the hub-local /fleet path and warns when an admin stack has no recent snapshot', async () => {
|
||||
auth.isAdmin = true;
|
||||
route({ storage: jsonRes(inventory({ stateful: true })), coverage: jsonRes({ latestAt: null }) });
|
||||
render(<StoragePanel stackName="app" />);
|
||||
expect(await screen.findByTestId('storage-snapshot-warning')).toBeInTheDocument();
|
||||
const coverageCall = vi.mocked(apiFetch).mock.calls.find(c => String(c[0]).includes('/snapshots/coverage'));
|
||||
expect(coverageCall).toBeDefined();
|
||||
// apiFetch prepends /api, so the call must NOT already carry it.
|
||||
expect(String(coverageCall![0]).startsWith('/fleet/snapshots/coverage')).toBe(true);
|
||||
expect(String(coverageCall![0]).startsWith('/api/')).toBe(false);
|
||||
expect((coverageCall![1] as { localOnly?: boolean }).localOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the warning and shows the last-snapshot line when a recent snapshot exists', async () => {
|
||||
auth.isAdmin = true;
|
||||
route({ storage: jsonRes(inventory({ stateful: true })), coverage: jsonRes({ latestAt: Date.now() - 1000 }) });
|
||||
render(<StoragePanel stackName="app" />);
|
||||
expect(await screen.findByText(/Last fleet snapshot/i)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('storage-snapshot-warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Check, TriangleAlert, Info, MapPin, HelpCircle, HardDrive, type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
// Mirrors the backend /storage payload (the frontend never imports backend).
|
||||
type PortabilityStatus = 'portable' | 'partially-portable' | 'node-bound' | 'unknown';
|
||||
type MountType = 'bind' | 'named' | 'anonymous' | 'tmpfs';
|
||||
type HostPathKind = 'file' | 'directory' | 'socket' | 'symlink' | 'missing' | 'unknown';
|
||||
|
||||
interface HostPathProbe {
|
||||
lexicalWithinStackDir: boolean;
|
||||
withinStackDir: boolean;
|
||||
exists: boolean;
|
||||
kind: HostPathKind;
|
||||
escapes: boolean;
|
||||
uid: number | null;
|
||||
gid: number | null;
|
||||
mode: string | null;
|
||||
}
|
||||
interface StorageMount {
|
||||
service: string;
|
||||
type: MountType;
|
||||
source?: string;
|
||||
target: string;
|
||||
readOnly: boolean;
|
||||
probe: HostPathProbe | null;
|
||||
externalNamed: boolean;
|
||||
}
|
||||
interface StorageInventory {
|
||||
stack: string;
|
||||
renderable: boolean;
|
||||
renderError: string | null;
|
||||
stateful: boolean;
|
||||
mounts: StorageMount[];
|
||||
portability: { status: PortabilityStatus; reasons: string[] };
|
||||
}
|
||||
|
||||
const RECENT_SNAPSHOT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
|
||||
const CARD_CLASS = 'rounded-lg border px-3 py-2.5';
|
||||
const CHIP_CLASS = 'rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide';
|
||||
|
||||
const STATUS_META: Record<PortabilityStatus, { label: string; tone: string; icon: LucideIcon }> = {
|
||||
'portable': { label: 'portable', tone: 'border-success/40 bg-success/[0.06] text-success', icon: Check },
|
||||
'partially-portable': { label: 'partially portable', tone: 'border-info/40 bg-info/[0.06] text-info', icon: Info },
|
||||
'node-bound': { label: 'node-bound', tone: 'border-warning/40 bg-warning/[0.06] text-warning', icon: MapPin },
|
||||
'unknown': { label: 'unknown', tone: 'border-muted bg-card/40 text-stat-subtitle', icon: HelpCircle },
|
||||
};
|
||||
|
||||
const isSocketMount = (m: StorageMount): boolean =>
|
||||
(m.source?.includes('docker.sock') ?? false) || m.target.includes('docker.sock');
|
||||
|
||||
function mountTypeLabel(m: StorageMount): string {
|
||||
if (isSocketMount(m)) return 'socket';
|
||||
return m.type;
|
||||
}
|
||||
|
||||
/** A short host-path status for a bind, or null for non-bind mounts. */
|
||||
function bindStatus(m: StorageMount): string | null {
|
||||
if (m.type !== 'bind' || !m.probe) return null;
|
||||
const p = m.probe;
|
||||
if (!p.lexicalWithinStackDir) return 'external';
|
||||
if (p.escapes) return 'symlink escapes';
|
||||
if (!p.exists) return 'missing';
|
||||
return p.kind;
|
||||
}
|
||||
|
||||
function MountRow({ mount }: { mount: StorageMount }) {
|
||||
const status = bindStatus(mount);
|
||||
const owner = mount.probe && mount.probe.uid !== null
|
||||
? `uid ${mount.probe.uid}${mount.probe.gid !== null ? `:${mount.probe.gid}` : ''}`
|
||||
: null;
|
||||
return (
|
||||
<div className="border-t border-muted py-2 first:border-t-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className={cn(CHIP_CLASS, 'bg-brand/15 text-brand')}>{mountTypeLabel(mount)}</span>
|
||||
<span className={cn(CHIP_CLASS, mount.readOnly ? 'bg-info/15 text-info' : 'bg-muted text-stat-subtitle')}>
|
||||
{mount.readOnly ? 'ro' : 'rw'}
|
||||
</span>
|
||||
{mount.externalNamed && <span className={cn(CHIP_CLASS, 'bg-warning/15 text-warning')}>external</span>}
|
||||
{status && <span className="font-mono text-[10px] text-stat-subtitle">{status}</span>}
|
||||
{owner && <span className="font-mono text-[10px] text-stat-subtitle">· {owner}</span>}
|
||||
</div>
|
||||
<div className="mt-1 min-w-0 font-mono text-[11px] text-foreground/90">
|
||||
{mount.source && <span className="text-stat-subtitle">{mount.source} → </span>}
|
||||
<span>{mount.target}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StoragePanel({ stackName }: { stackName: string }) {
|
||||
const { activeNode } = useNodes();
|
||||
const { isAdmin } = useAuth();
|
||||
const nodeId = activeNode?.id;
|
||||
|
||||
const [inventory, setInventory] = useState<StorageInventory | null>(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
// Recency is computed in the effect (impure `Date.now` belongs there, not in render).
|
||||
const [snapshot, setSnapshot] = useState<{ at: number | null; recent: boolean }>({ at: null, recent: false });
|
||||
|
||||
// Load the inventory when the stack or active node changes. Read-only.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
setLoadError(false);
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/storage`);
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
setLoadError(true);
|
||||
toast.error('Failed to load the storage inventory.');
|
||||
return;
|
||||
}
|
||||
setInventory((await res.json()) as StorageInventory);
|
||||
setLoadError(false);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLoadError(true);
|
||||
toast.error('Failed to load the storage inventory.');
|
||||
}
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [stackName, nodeId, reloadKey]);
|
||||
|
||||
// Snapshot coverage lives only in the hub database (admin-scoped), so it is
|
||||
// fetched with localOnly and merged client-side. Non-admins skip it and see
|
||||
// the static caveat only.
|
||||
useEffect(() => {
|
||||
if (!isAdmin || nodeId === undefined || nodeId === null) return;
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`/fleet/snapshots/coverage?nodeId=${nodeId}&stackName=${encodeURIComponent(stackName)}`,
|
||||
{ localOnly: true, signal: controller.signal },
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const at = typeof data?.latestAt === 'number' ? data.latestAt : null;
|
||||
setSnapshot({ at, recent: at !== null && Date.now() - at < RECENT_SNAPSHOT_WINDOW_MS });
|
||||
} catch {
|
||||
// Coverage is advisory; a failure simply leaves the warning unshown.
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [stackName, nodeId, isAdmin, reloadKey]);
|
||||
|
||||
const showSnapshotWarning = isAdmin && inventory?.stateful === true && !snapshot.recent;
|
||||
|
||||
const services = inventory ? [...new Set(inventory.mounts.map(m => m.service))] : [];
|
||||
|
||||
return (
|
||||
<div data-testid="storage-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
|
||||
<span className={LABEL_CLASS}>storage portability</span>
|
||||
|
||||
{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 the storage inventory.</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="storage-retry-btn"
|
||||
onClick={() => setReloadKey(k => k + 1)}
|
||||
className="font-mono text-[10px] uppercase tracking-wide text-destructive hover:underline"
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
) : !inventory ? (
|
||||
<div className="py-3 font-mono text-[11px] text-stat-subtitle">Loading storage…</div>
|
||||
) : !inventory.renderable ? (
|
||||
<div className={cn(CARD_CLASS, 'border-destructive/40 bg-destructive/[0.06] text-destructive')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<TriangleAlert className="h-4 w-4 shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-mono text-[11px] uppercase tracking-wide">cannot render</span>
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] leading-relaxed text-foreground/80">
|
||||
{inventory.renderError ?? 'Sencho could not render the effective Compose model.'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<PortabilityCard portability={inventory.portability} />
|
||||
|
||||
{inventory.mounts.length === 0 ? (
|
||||
<div className={cn(CARD_CLASS, 'border-muted bg-card/40 flex items-center gap-2 text-stat-subtitle')}>
|
||||
<HardDrive className="h-4 w-4" strokeWidth={1.5} />
|
||||
<span className="font-mono text-[11px]">This stack declares no mounts.</span>
|
||||
</div>
|
||||
) : (
|
||||
services.map(service => (
|
||||
<section key={service} data-testid={`storage-service-${service}`}>
|
||||
<div className={cn(LABEL_CLASS, 'mb-1.5')}>{service}</div>
|
||||
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
|
||||
{inventory.mounts.filter(m => m.service === service).map((m, i) => (
|
||||
<MountRow key={`${m.service}-${m.target}-${i}`} mount={m} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
|
||||
<section>
|
||||
<div className={cn(LABEL_CLASS, 'mb-1.5')}>snapshot coverage</div>
|
||||
{showSnapshotWarning && (
|
||||
<div data-testid="storage-snapshot-warning" className={cn(CARD_CLASS, 'mb-2 border-warning/40 bg-warning/[0.06] text-warning')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<TriangleAlert className="h-4 w-4 shrink-0" strokeWidth={1.5} />
|
||||
<span className="text-[12px] leading-relaxed text-foreground/90">
|
||||
This stack has persistent storage but no fleet snapshot in the last 7 days.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && inventory.stateful && snapshot.recent && snapshot.at && (
|
||||
<div className="mb-2 font-mono text-[11px] text-stat-subtitle">
|
||||
Last fleet snapshot {formatTimeAgo(snapshot.at)}.
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2 rounded-lg border border-muted bg-card/40 px-3 py-2 text-stat-subtitle">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" strokeWidth={1.5} />
|
||||
<span className="text-[11px] leading-relaxed">
|
||||
Fleet snapshots capture Compose and env files, not the data inside named volumes or bind mounts. Back up volume data separately before moving or restoring.
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PortabilityCard({ portability }: { portability: StorageInventory['portability'] }) {
|
||||
const meta = STATUS_META[portability.status] ?? STATUS_META.unknown;
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<div data-testid="storage-portability" data-status={portability.status} className={cn(CARD_CLASS, meta.tone)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-mono text-[11px] uppercase tracking-wide">{meta.label}</span>
|
||||
</div>
|
||||
{portability.reasons.length > 0 && (
|
||||
<ul className="mt-1.5 flex flex-col gap-1">
|
||||
{portability.reasons.map((r, i) => (
|
||||
<li key={i} className="text-[12px] leading-relaxed text-foreground/80">· {r}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user