import { useState, useEffect, useRef, type ReactNode } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { useIsMobile } from '@/hooks/use-is-mobile'; import { Masthead, MobileSubTabs } from '@/components/mobile/mobile-ui'; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from "@/components/ui/tabs"; import { springs } from '@/lib/motion'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { ConfirmModal } from "@/components/ui/modal"; import { ScrollArea } from "@/components/ui/scroll-area"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Eye, Loader2, History, FolderOpen, Search } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { SeverityBadge } from '@/components/ui/SeverityBadge'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from './NodeManager'; import { SENCHO_OPEN_STACK_EVENT, type SenchoOpenStackDetail } from '@/lib/events'; import type { ScanSummary } from '@/types/security'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { formatBytes } from '@/lib/utils'; import { cn } from '@/lib/utils'; import { ReclaimHero } from './resources/ReclaimHero'; import { FootprintTreemap } from './resources/FootprintTreemap'; import { ImageDetailsSheet } from './resources/ImageDetailsSheet'; import { RollbackGenerationsTab, type RollbackGeneration } from './resources/RollbackGenerationsTab'; import { TableSkeleton } from './resources/TableSkeleton'; import { VolumeBrowserSheet } from './resources/VolumeBrowserSheet'; import { VolumeNameLabel } from './resources/VolumeNameLabel'; import { useTableSort } from '@/hooks/useTableSort'; import { SortableTableHead } from '@/components/ui/sortable-table'; import { isPrunePlan, type PrunePlan, type PruneScope, type PruneTarget } from '@/lib/prunePlan'; // ── Interfaces ───────────────────────────────────────────────────────────────── interface UsageData { reclaimableImages: number; reclaimableContainers: number; reclaimableVolumes: number; reclaimableImageCount: number; reclaimableContainerCount: number; reclaimableVolumeCount: number; managedImageBytes: number; unmanagedImageBytes: number; managedVolumeBytes: number; unmanagedVolumeBytes: number; } interface DockerImage { Id: string; RepoTags: string[]; Size: number; Containers: number; usedByStacks: string[]; managedBy: string | null; managedStatus: 'managed' | 'unmanaged' | 'unused'; isSencho: boolean; /** True when a rollback hold protects this image from pruning; additive, independent of managedStatus. */ rollbackProtected: boolean; rollbackProtectionKind?: 'stack' | 'service'; } interface DockerVolume { Name: string; Driver: string; Mountpoint: string; Size: number; CreatedAt: string | null; managedBy: string | null; managedStatus: 'managed' | 'unmanaged'; isSencho: boolean; } export interface DockerNetwork { Id: string; Name: string; Driver: string; Scope: string; managedBy: string | null; managedStatus: 'managed' | 'unmanaged' | 'system'; isSencho: boolean; } interface UnmanagedContainer { Id: string; Names: string[]; State: string; Status: string; Image: string; } type ResourceFilter = 'all' | 'managed' | 'unmanaged'; const PLAN_PREVIEW_CAP = 30; function PrunePlanPreview({ plan, loading, error, }: { plan: PrunePlan | null; loading: boolean; error: string | null; }) { if (loading) { return

Building prune plan...

; } if (error) { return

{error}

; } if (!plan) return null; if (plan.items.length === 0) { return

Nothing eligible to prune right now.

; } const shown = plan.items.slice(0, PLAN_PREVIEW_CAP); const remaining = plan.items.length - shown.length; return (

{plan.items.length} {plan.items.length === 1 ? 'item' : 'items'} {plan.reclaimableBytes > 0 ? ` · ${formatBytes(plan.reclaimableBytes)}` : ''}

{remaining > 0 && (

and {remaining} more

)}
); } // Per-node, per-browser snooze for the reclaim banner. We store the reclaimable // byte total at the moment of dismissal; the banner returns only once the node's // reclaimable total grows past that snapshot, so a stable residue stays hidden. const heroDismissKey = (nodeId: string | number | undefined) => `sencho.reclaimHeroDismissed.${nodeId ?? 'local'}`; function readHeroDismissed(nodeId: string | number | undefined): number | null { try { const raw = localStorage.getItem(heroDismissKey(nodeId)); if (raw === null) return null; const n = Number(raw); return Number.isFinite(n) ? n : null; } catch { // localStorage is unavailable (private mode / blocked); treat as // not-dismissed so the banner still shows. return null; } } function writeHeroDismissed(nodeId: string | number | undefined, bytes: number): void { try { localStorage.setItem(heroDismissKey(nodeId), String(bytes)); } catch { // Best-effort: if the write fails the banner simply reappears next load. } } // ── Filter Toggle - Segmented Control ───────────────────────────────────────── interface FilterToggleProps { value: ResourceFilter; onChange: (v: ResourceFilter) => void; counts: { all: number; managed: number; unmanaged: number }; } function FilterToggle({ value, onChange, counts }: FilterToggleProps) { const options: { key: ResourceFilter; label: string; count: number }[] = [ { key: 'all', label: 'All', count: counts.all }, { key: 'managed', label: 'Managed', count: counts.managed }, { key: 'unmanaged', label: 'External', count: counts.unmanaged }, ]; return (
{options.map(({ key, label, count }) => ( ))}
); } // ── Managed Status Badge ─────────────────────────────────────────────────────── function ManagedBadge({ status, managedBy, usedByStacks, onOpenStack }: { status: 'managed' | 'unmanaged' | 'unused' | 'system'; managedBy: string | null; /** When length > 1, render one chip per stack (images shared across stacks). */ usedByStacks?: string[]; /** When provided on a managed resource, the owning-stack badge becomes a link to that stack. */ onOpenStack?: (stack: string) => void; }) { if (status === 'managed') { const stacks = (usedByStacks && usedByStacks.length > 0) ? usedByStacks : (managedBy ? [managedBy] : []); const cls = "inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-success/25 bg-success/8 text-success text-[10px] font-medium"; return ( {stacks.map((stack) => { const inner = (<>{stack}); if (onOpenStack) { return ( Open stack {stack} ); } return {inner}; })} ); } if (status === 'unmanaged') { return ( External ); } if (status === 'system') { return ( System ); } return null; } // ── Sencho Self-Protection Badge ─────────────────────────────────────────────── function SenchoBadge() { return ( Sencho Protected · running Sencho instance ); } function RollbackProtectedBadge({ kind }: { kind?: 'stack' | 'service' }) { return ( Rollback protected {kind === 'stack' ? 'Held as a full-stack rollback point. See Resources → Rollback.' : 'Held for a pending per-service update rollback.'} ); } // ── Severity Badge ───────────────────────────────────────────────────────────── // ── Quick Clean Prune Button ─────────────────────────────────────────────────── interface PruneButtonProps { target: PruneTarget; icon: React.ReactNode; label: string; accentClass: string; onManaged: () => void; onAll: () => void; } function PruneButton({ target, icon, label, accentClass, onManaged, onAll }: PruneButtonProps) { return (
{target !== 'containers' && ( All Docker (includes external) )}
); } // Stable comparator maps for the resource tables (module scope so useTableSort // does not re-sort on every render). Mirrors the Security Images sort standard. const IMAGE_COMPARATORS: Record<'repo' | 'size' | 'status', (a: DockerImage, b: DockerImage) => number> = { repo: (a, b) => (a.RepoTags?.[0] || '').localeCompare(b.RepoTags?.[0] || ''), size: (a, b) => a.Size - b.Size, status: (a, b) => Number(a.Containers > 0) - Number(b.Containers > 0), }; const VOLUME_COMPARATORS: Record<'name' | 'driver', (a: DockerVolume, b: DockerVolume) => number> = { name: (a, b) => a.Name.localeCompare(b.Name), driver: (a, b) => a.Driver.localeCompare(b.Driver), }; // ── Main Component ───────────────────────────────────────────────────────────── interface ResourcesViewProps { /** Notifications + more-menu cluster for the mobile masthead, rehomed from the dropped TopBar. */ headerActions?: ReactNode; } export default function ResourcesView({ headerActions }: ResourcesViewProps = {}) { const isMobile = useIsMobile(); const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'unmanaged' | 'rollback'>('images'); const { isAdmin, can } = useAuth(); const canReadResources = can('stack:read'); const canDeployResources = can('stack:deploy'); const canEditSecurityPolicy = can('stack:edit'); const { activeNode } = useNodes(); const [usage, setUsage] = useState(null); const [images, setImages] = useState([]); const [volumes, setVolumes] = useState([]); const [networks, setNetworks] = useState([]); const [orphans, setOrphans] = useState>({}); const [rollbackGenerations, setRollbackGenerations] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isActioning, setIsActioning] = useState(false); // Filter state const [imageFilter, setImageFilter] = useState('all'); const [volumeFilter, setVolumeFilter] = useState('all'); // Search state const [imageSearch, setImageSearch] = useState(''); const [volumeSearch, setVolumeSearch] = useState(''); // Collapsible search: icon-only until clicked, stays open while query is active. const [imageSearchExpanded, setImageSearchExpanded] = useState(false); const [volumeSearchExpanded, setVolumeSearchExpanded] = useState(false); const imageSearchRef = useRef(null); const volumeSearchRef = useRef(null); useEffect(() => { if (imageSearchExpanded) imageSearchRef.current?.focus(); }, [imageSearchExpanded]); useEffect(() => { if (volumeSearchExpanded) volumeSearchRef.current?.focus(); }, [volumeSearchExpanded]); // Modal states const [confirmPrune, setConfirmPrune] = useState<{ target: PruneTarget; scope: PruneScope } | null>(null); const [confirmDelete, setConfirmDelete] = useState<{ type: 'images' | 'volumes' | 'networks'; id: string; name?: string } | null>(null); const [confirmReclaim, setConfirmReclaim] = useState(false); const [prunePlan, setPrunePlan] = useState(null); const [planLoading, setPlanLoading] = useState(false); const [planError, setPlanError] = useState(null); const planFetchGenRef = useRef(0); // Reclaim banner visibility: the per-node opt-in setting (loaded in // fetchAllData) and the per-browser dismiss snapshot for the active node. const [reclaimHeroEnabled, setReclaimHeroEnabled] = useState(false); const [heroDismissedBytes, setHeroDismissedBytes] = useState(null); // Classified image selection is node-bound so a node switch cannot leave // the previous node's usedByStacks visible beside a new node's inspect. const [inspectImage, setInspectImage] = useState<(DockerImage & { nodeId: string | number }) | null>(null); const [browseVolume, setBrowseVolume] = useState(null); // Unmanaged container state const [selectedOrphans, setSelectedOrphans] = useState([]); const [bulkPurgeConfirm, setBulkPurgeConfirm] = useState(false); // Vulnerability scanning state const { status: trivy } = useTrivyStatus(); const [scanSummaries, setScanSummaries] = useState>({}); const [scanningImageRef, setScanningImageRef] = useState(null); const [inspectScanId, setInspectScanId] = useState(null); // Holds the AbortController for the in-flight scan poll so it can be // cancelled; the scan keeps running server-side, only the client poll stops. const scanAbortRef = useRef(null); // Generation counter so a slow fetch for a previously-active node cannot // stomp the visible resources after the user switches nodes. Each call // claims a generation; only the latest call is allowed to write state. const fetchGenerationRef = useRef(0); const fetchAllData = async () => { const generation = ++fetchGenerationRef.current; setIsLoading(true); try { const [usageRes, resourcesRes, orphansRes, summariesRes, settingsRes, rollbackRes] = await Promise.all([ apiFetch('/system/docker-df'), apiFetch('/system/resources'), apiFetch('/system/orphans'), apiFetch('/security/image-summaries').catch(() => null), apiFetch('/settings').catch(() => null), apiFetch('/system/rollback/generations').catch(() => null), ]); // Resolve every body before the staleness check so a stale // generation cannot write any subset of the resource slices. const usageData = usageRes.ok ? await usageRes.json() : null; const resourcesData = resourcesRes.ok ? await resourcesRes.json() : null; const orphansData = orphansRes.ok ? await orphansRes.json() : null; const summariesData = summariesRes && summariesRes.ok ? await summariesRes.json() : null; const settingsData = settingsRes && settingsRes.ok ? await settingsRes.json() : null; const rollbackData = rollbackRes && rollbackRes.ok ? await rollbackRes.json() : null; if (fetchGenerationRef.current !== generation) return; // Set unconditionally: a failed /settings (settingsData null) or a // missing key falls back to the default-off state for this node // rather than inheriting the previously active node's value. setReclaimHeroEnabled(settingsData?.reclaim_hero === '1'); if (usageData) setUsage(usageData); if (resourcesData) { setImages(resourcesData.images ?? []); setVolumes(resourcesData.volumes ?? []); setNetworks(resourcesData.networks ?? []); } if (orphansData) { setOrphans(orphansData); setSelectedOrphans([]); } if (summariesData) setScanSummaries(summariesData); setRollbackGenerations(Array.isArray(rollbackData) ? rollbackData : []); } catch (err) { if (fetchGenerationRef.current !== generation) return; console.error('Failed to fetch data', err); toast.error('Failed to load resources data'); } finally { if (fetchGenerationRef.current === generation) setIsLoading(false); } }; useEffect(() => { fetchAllData(); }, [activeNode]); // Load the per-node reclaim-banner dismiss snapshot when the node changes. useEffect(() => { setHeroDismissedBytes(readHeroDismissed(activeNode?.id)); setInspectImage(null); }, [activeNode?.id]); // Cancel an in-flight scan poll on unmount or node switch; its result // belongs to the node it started on. useEffect(() => { return () => scanAbortRef.current?.abort(); }, [activeNode]); // Bump the fetch generation on unmount so a fetch that resolves after the // view is gone cannot run state setters or surface a load-error toast. useEffect(() => () => { fetchGenerationRef.current += 1; }, []); type PrunePlanRequest = { target?: PruneTarget; targets?: PruneTarget[]; scope: PruneScope }; const fetchPrunePlan = async (body: PrunePlanRequest) => { const generation = ++planFetchGenRef.current; setPlanLoading(true); setPlanError(null); setPrunePlan(null); try { const res = await apiFetch('/system/prune/plan', { method: 'POST', body: JSON.stringify(body), }); const data = await res.json().catch(() => null); if (planFetchGenRef.current !== generation) return null; if (!res.ok) { throw new Error(data?.error || 'Failed to build prune plan'); } if (!isPrunePlan(data)) throw new Error('The node returned a malformed prune plan'); setPrunePlan(data); return data; } catch (error) { if (planFetchGenRef.current !== generation) return null; const err = error as { message?: string }; const message = err?.message || 'Failed to build prune plan'; setPlanError(message); console.error('Failed to build prune plan', error); return null; } finally { if (planFetchGenRef.current === generation) setPlanLoading(false); } }; /** POST /prune/system with fingerprint. On stale 409, refresh the plan and * require another confirm rather than executing a set the user never saw. */ const executeFingerprintPrune = async (body: PrunePlanRequest, fingerprint: string) => { const res = await apiFetch('/system/prune/system', { method: 'POST', body: JSON.stringify({ ...body, planFingerprint: fingerprint }), }); const data = await res.json().catch(() => null); if (res.status === 409 && data?.code === 'PRUNE_PLAN_STALE') { await fetchPrunePlan(body); throw new Error('Prune plan changed; review the updated list and confirm again'); } return { res, data }; }; // Fetch an itemized plan whenever a prune confirm dialog opens. useEffect(() => { if (!confirmPrune) return; void fetchPrunePlan({ target: confirmPrune.target, scope: confirmPrune.scope }); }, [confirmPrune]); useEffect(() => { if (!confirmReclaim) return; void fetchPrunePlan({ targets: ['volumes', 'containers', 'images'], scope: 'all' }); }, [confirmReclaim]); useEffect(() => { if (confirmPrune || confirmReclaim) return; planFetchGenRef.current += 1; setPrunePlan(null); setPlanLoading(false); setPlanError(null); }, [confirmPrune, confirmReclaim]); const handlePrune = async () => { if (!confirmPrune || !prunePlan) return; setIsActioning(true); const loadingId = toast.loading(`Pruning ${confirmPrune.target}...`); try { const { res, data } = await executeFingerprintPrune( { target: confirmPrune.target, scope: confirmPrune.scope }, prunePlan.fingerprint, ); if (!res.ok) { throw new Error(data?.error || `Failed to prune ${confirmPrune.target}`); } const scopeLabel = confirmPrune.scope === 'managed' ? 'Sencho-managed' : 'all'; const reclaimed = typeof data?.reclaimedBytes === 'number' ? data.reclaimedBytes : undefined; const outcomes = Array.isArray(data?.outcomes) ? data.outcomes : []; const failed = outcomes.filter((o: { status?: string }) => o.status === 'failed'); const reclaimedLabel = reclaimed !== undefined ? ` Reclaimed ${formatBytes(reclaimed)}.` : ''; if (failed.length > 0 && failed.length === outcomes.length) { toast.error(`Failed to prune ${confirmPrune.target}.`); } else if (failed.length > 0) { toast.warning(`Some ${confirmPrune.target} could not be pruned.${reclaimedLabel}`); } else { toast.success(`Pruned ${scopeLabel} ${confirmPrune.target}.${reclaimedLabel}`); } await fetchAllData(); setConfirmPrune(null); } catch (error) { console.error('Failed to prune', error); const err = error as { message?: string }; toast.error(err?.message || `Failed to prune ${confirmPrune.target}`); // Keep the dialog open on stale-plan so the operator can re-confirm. } finally { toast.dismiss(loadingId); setIsActioning(false); } }; const handleDelete = async () => { if (!confirmDelete) return; setIsActioning(true); const loadingId = toast.loading(`Deleting ${confirmDelete.type.slice(0, -1)}...`); try { const res = await apiFetch(`/system/${confirmDelete.type}/delete`, { method: 'POST', body: JSON.stringify({ id: confirmDelete.id }) }); if (!res.ok) { const data = await res.json().catch(() => null); throw new Error(data?.error || `Failed to delete ${confirmDelete.type.slice(0, -1)}`); } toast.success(`Deleted ${confirmDelete.type.slice(0, -1)}`); await fetchAllData(); } catch (error) { const err = error as Record; toast.error(String(err?.message || `Failed to delete ${confirmDelete.type.slice(0, -1)}`)); } finally { toast.dismiss(loadingId); setIsActioning(false); setConfirmDelete(null); } }; const toggleOrphan = (id: string) => setSelectedOrphans(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]); const totalOrphansCount = Object.values(orphans).flat().length; const selectAllOrphans = () => { const allIds = Object.values(orphans).flat().map(c => c.Id); setSelectedOrphans(selectedOrphans.length === allIds.length ? [] : allIds); }; const handlePurgeOrphans = async () => { setIsActioning(true); const loadingId = toast.loading('Purging unmanaged containers...'); try { const res = await apiFetch('/system/prune/orphans', { method: 'POST', body: JSON.stringify({ containerIds: selectedOrphans }) }); if (!res.ok) { const data = await res.json().catch(() => null); throw new Error(data?.error || 'Failed to purge selected containers'); } toast.success(`Purged ${selectedOrphans.length} unmanaged container(s)`); await fetchAllData(); } catch (error) { const err = error as Record; toast.error(String(err?.message || 'Failed to purge selected containers.')); } finally { toast.dismiss(loadingId); setIsActioning(false); setBulkPurgeConfirm(false); } }; const handleScanImage = async ( imageRef: string, options: { force?: boolean; scanners?: ('vuln' | 'secret')[] } = {}, ) => { const { force = false, scanners } = options; // Supersede any prior in-flight scan poll before claiming this one. scanAbortRef.current?.abort(); const controller = new AbortController(); scanAbortRef.current = controller; const { signal } = controller; setScanningImageRef(imageRef); const loadingId = toast.loading(`Scanning ${imageRef}...`); try { const res = await apiFetch('/security/scan', { method: 'POST', body: JSON.stringify({ imageRef, force, scanners }), signal, }); const data = await res.json(); if (!res.ok) throw new Error(data?.error || 'Failed to start scan'); const scanId = data.scanId as number; const deadline = Date.now() + 5 * 60 * 1000; while (Date.now() < deadline) { await new Promise((resolve) => { if (signal.aborted) { resolve(); return; } const timer = setTimeout(resolve, 3000); signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true }); }); if (signal.aborted) return; const poll = await apiFetch(`/security/scans/${scanId}`, { signal }); if (signal.aborted) return; if (!poll.ok) continue; const poll_data = await poll.json(); if (signal.aborted) return; if (poll_data.status !== 'in_progress') { if (poll_data.status === 'failed') { throw new Error(poll_data.error || 'Scan failed'); } toast.success(`Scan complete: ${poll_data.total_vulnerabilities} vulnerabilities found`); setInspectScanId(scanId); const summariesRes = await apiFetch('/security/image-summaries', { signal }); if (signal.aborted) return; if (summariesRes.ok) { const summaries = await summariesRes.json(); if (signal.aborted) return; setScanSummaries(summaries ?? {}); } return; } } throw new Error('Scan timed out'); } catch (error) { if (signal.aborted) { // Suppress the toast for a deliberately cancelled poll, but keep // a breadcrumb so a real error racing the abort is not lost. console.debug('Scan poll aborted', error); return; } const err = error as { message?: string; error?: string; data?: { error?: string } }; toast.error(err?.message || err?.error || err?.data?.error || 'Scan failed'); } finally { toast.dismiss(loadingId); // Only the owning poll resets shared state; a superseded poll leaves // it to the scan that replaced it. if (scanAbortRef.current === controller) { scanAbortRef.current = null; setScanningImageRef(null); } } }; const filteredImages = images.filter(img => (imageFilter === 'managed' ? img.managedStatus === 'managed' : imageFilter === 'unmanaged' ? img.managedStatus !== 'managed' : true) && (imageSearch === '' || (img.RepoTags?.[0] || '').toLowerCase().includes(imageSearch.toLowerCase())) ); const filteredVolumes = volumes.filter(vol => (volumeFilter === 'managed' ? vol.managedStatus === 'managed' : volumeFilter === 'unmanaged' ? vol.managedStatus !== 'managed' : true) && (volumeSearch === '' || vol.Name.toLowerCase().includes(volumeSearch.toLowerCase())) ); const imageSort = useTableSort(filteredImages, IMAGE_COMPARATORS, 'repo'); const volumeSort = useTableSort(filteredVolumes, VOLUME_COMPARATORS, 'name'); const handleFootprintFilter = (filter: ResourceFilter) => { setImageFilter(filter); setVolumeFilter(filter); }; const treemapFilterToResourceFilter = (filter: 'managed' | 'unmanaged' | 'reclaimable'): ResourceFilter => { if (filter === 'managed') return 'managed'; if (filter === 'unmanaged') return 'unmanaged'; return 'unmanaged'; }; const totalReclaimableBytes = (usage?.reclaimableImages ?? 0) + (usage?.reclaimableContainers ?? 0) + (usage?.reclaimableVolumes ?? 0); // Banner shows while the opt-in is on and the operator has not dismissed // this (or a larger) reclaimable total. A stable residue stays hidden; a // fresh pile pushes the total past the snapshot and the banner returns. const heroVisible = isAdmin && reclaimHeroEnabled && (heroDismissedBytes === null || totalReclaimableBytes > heroDismissedBytes); const handleReviewAndPrune = () => { setConfirmReclaim(true); }; const handleDismissHero = () => { writeHeroDismissed(activeNode?.id, totalReclaimableBytes); setHeroDismissedBytes(totalReclaimableBytes); }; // "Review & prune" reclaims everything the banner advertises. Order matters: // volumes first, while stopped containers still hold a reference to their // named volumes, so the prune only removes volumes that are already dangling // and a stopped stack's data is never cascaded into deletion. Containers // next, then images, so images a stopped container pinned become reclaimable. // Each failed target is reported by name and its server error logged (never a // false success); the reclaimed figure is shown only when the daemon reports // one (the containerd image store returns 0). const handleReclaimAll = async () => { if (!prunePlan) return; setIsActioning(true); const loadingId = toast.loading('Reclaiming disk space...'); try { const { res, data } = await executeFingerprintPrune( { targets: ['volumes', 'containers', 'images'], scope: 'all' }, prunePlan.fingerprint, ); if (!res.ok) { throw new Error(data?.error || 'Failed to reclaim disk space'); } const reclaimed = typeof data?.reclaimedBytes === 'number' ? data.reclaimedBytes : 0; const reclaimedLabel = reclaimed > 0 ? ` Freed ${formatBytes(reclaimed)}.` : ''; const outcomes = Array.isArray(data?.outcomes) ? data.outcomes : []; const failed = outcomes.filter((o: { status?: string }) => o.status === 'failed'); if (failed.length > 0 && failed.length === outcomes.length) { toast.error('Failed to reclaim disk space.'); } else if (failed.length > 0) { toast.warning(`Some items could not be pruned.${reclaimedLabel}`); } else { toast.success(`Reclaimed unused images, stopped containers, and dangling volumes.${reclaimedLabel}`); } await fetchAllData(); setConfirmReclaim(false); } catch (error) { console.error('Failed to reclaim', error); const err = error as { message?: string }; toast.error(err?.message || 'Failed to reclaim disk space.'); // Keep the dialog open on stale-plan so the operator can re-confirm. } finally { toast.dismiss(loadingId); setIsActioning(false); } }; const mainContent = ( <> {/* Reclaim hero */} {heroVisible && usage && ( )} {/* Top row: Footprint + Quick Clean */}
{/* Disk Footprint */} Docker Disk Footprint Click a segment to filter the tabs below {usage ? ( handleFootprintFilter(treemapFilterToResourceFilter(f))} /> ) : ( )} {/* Quick Clean */} {isAdmin && Quick Clean Primary actions target Sencho-managed resources only. Use for all-Docker operations.
} label="Prune Unused Images" accentClass="text-brand" onManaged={() => setConfirmPrune({ target: 'images', scope: 'managed' })} onAll={() => setConfirmPrune({ target: 'images', scope: 'all' })} /> } label="Prune Unused Volumes" accentClass="text-brand" onManaged={() => setConfirmPrune({ target: 'volumes', scope: 'managed' })} onAll={() => setConfirmPrune({ target: 'volumes', scope: 'all' })} /> } label="Prune Dead Networks" accentClass="text-success" onManaged={() => setConfirmPrune({ target: 'networks', scope: 'managed' })} onAll={() => setConfirmPrune({ target: 'networks', scope: 'all' })} /> } label="Purge Unmanaged Containers" accentClass="text-warning" onManaged={() => setConfirmPrune({ target: 'containers', scope: 'managed' })} onAll={() => setConfirmPrune({ target: 'containers', scope: 'all' })} />
}
{/* Resource Tabs */} setResourceTab(v as typeof resourceTab)} className="flex-1 flex flex-col w-full min-h-[400px]" > {isMobile ? ( ) : (
{(['images', 'volumes'] as const).map(tab => ( {tab.charAt(0).toUpperCase() + tab.slice(1)} {tab === 'images' ? images.length : volumes.length} ))} Unmanaged {totalOrphansCount} Rollback {rollbackGenerations.length}
)}
{/* Images */}
{imageSearch !== '' || imageSearchExpanded ? (
setImageSearch(e.target.value)} onBlur={() => { if (imageSearch === '') setImageSearchExpanded(false); }} className="pl-9 h-9" />
) : ( Search images )} i.managedStatus === 'managed').length, unmanaged: images.filter(i => i.managedStatus !== 'managed').length, }} />
{trivy.available && ( )}
ID Action {isLoading ? : ( {imageSort.sorted.length === 0 ? ( No images found. ) : imageSort.sorted.map((img, i) => ( {img.Id.split(':')[1]?.substring(0, 12)} {img.RepoTags?.[0] || ':'} {formatBytes(img.Size)}
0 ? "default" : "secondary"} className="text-[10px] h-5"> {img.Containers > 0 ? "In Use" : "Unused"} window.dispatchEvent( new CustomEvent(SENCHO_OPEN_STACK_EVENT, { detail: { nodeId: activeNode.id, stackName: stack } }), ) : undefined} /> {img.isSencho && } {img.rollbackProtected && } {(() => { const tag = img.RepoTags?.[0]; const summary = tag ? scanSummaries[tag] : undefined; if (!summary) return null; return setInspectScanId(summary.scan_id)} />; })()}
Inspect image {trivy.available && canDeployResources && img.RepoTags?.[0] && img.RepoTags[0] !== ':' && ( Scan for vulnerabilities handleScanImage(img.RepoTags![0], { scanners: ['vuln'] })} > Scan (vulnerabilities) handleScanImage(img.RepoTags![0], { scanners: ['vuln', 'secret'] })} > Full scan (vulnerabilities + secrets) )} {isAdmin && ( {img.isSencho && Protected · running Sencho instance} )}
))}
)}
{/* Volumes */}
{volumeSearch !== '' || volumeSearchExpanded ? (
setVolumeSearch(e.target.value)} onBlur={() => { if (volumeSearch === '') setVolumeSearchExpanded(false); }} className="pl-9 h-9" />
) : ( Search volumes )} v.managedStatus === 'managed').length, unmanaged: volumes.filter(v => v.managedStatus !== 'managed').length, }} />
Mountpoint Status Action {isLoading ? : ( {volumeSort.sorted.length === 0 ? ( No volumes found. ) : volumeSort.sorted.map((vol, i) => ( {vol.Driver} {vol.Mountpoint}
{vol.isSencho && }
{canReadResources && ( Browse volume contents )} {isAdmin && ( {vol.isSencho && Protected · running Sencho instance} )}
))}
)}
{/* Unmanaged Containers */}
0} className="rounded border-border focus:ring-ring h-4 w-4 accent-foreground" /> Select all
{isAdmin && }
{totalOrphansCount === 0 ? (

No unmanaged containers

All running containers are managed by Sencho.

) : (
{Object.entries(orphans).map(([project, containers], gi) => (
{/* Project header */}
External Project: {project} {containers.length} container{containers.length !== 1 ? 's' : ''}
{containers.map((container: UnmanagedContainer) => (
toggleOrphan(container.Id)} className="rounded border-border h-4 w-4 accent-foreground" />
{container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12)} {container.State}
{container.Image}
))}
))}
)}
{/* Rollback */}
); const overlays = ( <> {/* ── Dialogs ── */} {/* Prune Confirm */} !open && setConfirmPrune(null)} variant="destructive" kicker="RESOURCES · PRUNE · IRREVERSIBLE" title={ confirmPrune?.scope === 'all' ? `Prune all Docker ${confirmPrune?.target}` : `Prune Sencho-managed ${confirmPrune?.target}` } hint={confirmPrune?.scope === 'all' ? 'AFFECTS external Docker resources' : 'KEEPS external resources'} confirmLabel={ isActioning ? 'Pruning...' : planLoading ? 'Preparing...' : prunePlan && prunePlan.reclaimableBytes > 0 ? `Prune · ${formatBytes(prunePlan.reclaimableBytes)}` : (confirmPrune?.scope === 'all' ? 'Prune all' : 'Prune') } confirming={isActioning} confirmDisabled={planLoading || !prunePlan || !!planError || (prunePlan?.items.length ?? 0) === 0} onConfirm={handlePrune} >

{confirmPrune?.scope === 'all' ? ( <> Prunes all unused {confirmPrune?.target} from the Docker daemon, including those from{' '} external projects not managed by Sencho. ) : ( <> Removes only unused {confirmPrune?.target} belonging to your Sencho stacks. External Docker resources are{' '} not affected. )}

{/* Reclaim Confirm (banner "Review & prune") */} !open && setConfirmReclaim(false)} variant="destructive" kicker="RESOURCES · PRUNE · IRREVERSIBLE" title="Reclaim disk space" hint="AFFECTS external Docker resources" confirmLabel={ isActioning ? 'Reclaiming...' : planLoading ? 'Preparing...' : `Reclaim ${formatBytes(prunePlan?.reclaimableBytes ?? totalReclaimableBytes)}` } confirming={isActioning} confirmDisabled={planLoading || !prunePlan || !!planError || (prunePlan?.items.length ?? 0) === 0} onConfirm={handleReclaimAll} >

Removes every unused image, stopped container, and dangling volume on this node, including those from{' '} external projects not managed by Sencho.

{/* Delete Confirm */} !open && setConfirmDelete(null)} variant="destructive" kicker="RESOURCES · DELETE · IRREVERSIBLE" title={`Delete ${confirmDelete?.type.slice(0, -1) ?? ''}`} confirmLabel={isActioning ? 'Deleting...' : 'Delete'} confirming={isActioning} onConfirm={handleDelete} >

Permanently deletes{' '} {confirmDelete?.name || confirmDelete?.id.substring(0, 12)} .

{/* Unmanaged Container Purge Confirm */}

Force-stops and removes {selectedOrphans.length} container{selectedOrphans.length !== 1 ? 's' : ''} from external projects not managed by Sencho.

{/* Image Details Sheet */} setInspectImage(null)} onOpenStack={activeNode ? (stack) => window.dispatchEvent( new CustomEvent(SENCHO_OPEN_STACK_EVENT, { detail: { nodeId: activeNode.id, stackName: stack } }), ) : undefined} /> {/* Volume Browser Sheet */} setBrowseVolume(null)} /> setInspectScanId(null)} onRescan={canDeployResources ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined} canGenerateSbom={canReadResources} canExportSarif={canReadResources} canCompare canManageSuppressions={canEditSecurityPolicy} /> ); if (isMobile) { return (
0 ? 'Reclaimable' : 'Tidy'} stateTone={totalReclaimableBytes > 0 ? 'warning' : 'success'} live={false} meta={`${images.length} images · ${volumes.length} volumes · ${networks.length} networks`} right={headerActions} />
{mainContent}
{overlays}
); } return (
{mainContent} {overlays}
); }