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'; import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from '@/components/NodeManager'; // 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 = { '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 (
{mountTypeLabel(mount)} {mount.readOnly ? 'ro' : 'rw'} {mount.externalNamed && external} {status && {status}} {owner && · {owner}}
{mount.source && {mount.source} → } {mount.target}
); } export default function StoragePanel({ stackName }: { stackName: string }) { const { activeNode } = useNodes(); const { isAdmin } = useAuth(); const nodeId = activeNode?.id; const [inventory, setInventory] = useState(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 (
storage portability {loadError ? (
Could not load the storage inventory.
) : !inventory ? (
Loading storage…
) : !inventory.renderable ? (
cannot render
{inventory.renderError ?? 'Sencho could not render the effective Compose model.'}
) : ( <> {inventory.mounts.length === 0 ? (
This stack declares no mounts.
) : ( services.map(service => (
{service}
{inventory.mounts.filter(m => m.service === service).map((m, i) => ( ))}
)) )}
snapshot coverage
{showSnapshotWarning && (
This stack has persistent storage but no fleet snapshot in the last 7 days.
{activeNode?.type !== 'remote' && ( )}
)} {isAdmin && inventory.stateful && snapshot.recent && snapshot.at && (
Last fleet snapshot {formatTimeAgo(snapshot.at)}.
)}
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.
)}
); } function PortabilityCard({ portability }: { portability: StorageInventory['portability'] }) { const meta = STATUS_META[portability.status] ?? STATUS_META.unknown; const Icon = meta.icon; return (
{meta.label}
{portability.reasons.length > 0 && (
    {portability.reasons.map((r, i) => (
  • · {r}
  • ))}
)}
); }