import { useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; 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 { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from "@/components/ui/modal"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Combobox } from "@/components/ui/combobox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { TogglePill } from "@/components/ui/toggle-pill"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Loader2, History, FolderOpen } from 'lucide-react'; import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from './NodeManager'; import type { ScanSummary, VulnSeverity } from '@/types/security'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { PaidGate } from './PaidGate'; import { CapabilityGate } from './CapabilityGate'; import LazyBoundary from './LazyBoundary'; import { formatBytes } from '@/lib/utils'; import { cn } from '@/lib/utils'; import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; import type { SenchoOpenLogsDetail } from '@/lib/events'; import { lazy, Suspense } from 'react'; import { ReclaimHero } from './resources/ReclaimHero'; import { FootprintTreemap } from './resources/FootprintTreemap'; import { ImageDetailsSheet } from './resources/ImageDetailsSheet'; import { VolumeBrowserSheet } from './resources/VolumeBrowserSheet'; import { NetworkDetailSheet, type NetworkInspectData } from './resources/NetworkDetailSheet'; const NetworkTopologyView = lazy(() => import('./NetworkTopologyView')); // ── 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; managedBy: string | null; managedStatus: 'managed' | 'unmanaged' | 'unused'; } interface DockerVolume { Name: string; Driver: string; Mountpoint: string; Size: number; CreatedAt: string | null; managedBy: string | null; managedStatus: 'managed' | 'unmanaged'; } const NETWORK_DRIVERS = ['bridge', 'overlay', 'macvlan', 'host', 'none'] as const; type NetworkDriver = (typeof NETWORK_DRIVERS)[number]; export interface DockerNetwork { Id: string; Name: string; Driver: string; Scope: string; managedBy: string | null; managedStatus: 'managed' | 'unmanaged' | 'system'; } interface UnmanagedContainer { Id: string; Names: string[]; State: string; Status: string; Image: string; } // NetworkInspectData is re-exported from ./resources/NetworkDetailSheet type ResourceFilter = 'all' | 'managed' | 'unmanaged'; type PruneTarget = 'containers' | 'images' | 'networks' | 'volumes'; type PruneScope = 'managed' | 'all'; // ── 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 }: { status: 'managed' | 'unmanaged' | 'unused' | 'system'; managedBy: string | null; }) { if (status === 'managed') { return ( {managedBy} ); } if (status === 'unmanaged') { return ( External ); } if (status === 'system') { return ( System ); } return null; } // ── Severity Badge ───────────────────────────────────────────────────────────── const SEVERITY_BADGE_CLASSES: Record = { CRITICAL: 'border-destructive/25 bg-destructive/8 text-destructive', HIGH: 'border-warning/25 bg-warning/8 text-warning', MEDIUM: 'border-warning/25 bg-warning/8 text-warning', LOW: 'border-border bg-muted/30 text-muted-foreground', UNKNOWN: 'border-border bg-muted/20 text-muted-foreground', CLEAN: 'border-success/25 bg-success/8 text-success', }; const SEVERITY_DOT_CLASSES: Record = { CRITICAL: 'bg-destructive', HIGH: 'bg-warning', MEDIUM: 'bg-warning', LOW: 'bg-muted-foreground/60', UNKNOWN: 'bg-muted-foreground/40', CLEAN: 'bg-success', }; function SeverityBadge({ summary, onClick }: { summary: ScanSummary; onClick: () => void }) { const key: VulnSeverity | 'CLEAN' = summary.highest_severity ?? 'CLEAN'; const label = key === 'CLEAN' ? 'Clean' : key; const [relative, setRelative] = useState(''); useEffect(() => { const compute = () => { const scanAge = Math.round((Date.now() - summary.scanned_at) / 60000); setRelative( scanAge < 1 ? 'just now' : scanAge < 60 ? `${scanAge}m ago` : scanAge < 1440 ? `${Math.round(scanAge / 60)}h ago` : `${Math.round(scanAge / 1440)}d ago`, ); }; compute(); const id = setInterval(compute, 60000); return () => clearInterval(id); }, [summary.scanned_at]); return (
Last scanned
{relative}
{summary.total > 0 && (
{summary.critical > 0 && {summary.critical}C} {summary.high > 0 && {summary.high}H} {summary.medium > 0 && {summary.medium}M} {summary.low > 0 && {summary.low}L}
)} {summary.total === 0 && (
No vulnerabilities
)}
); } // ── 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) )}
); } // ── Table Skeleton ───────────────────────────────────────────────────────────── function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) { return ( {Array.from({ length: rows }).map((_, r) => ( {Array.from({ length: cols }).map((_, c) => ( ))} ))} ); } // ── Main Component ───────────────────────────────────────────────────────────── export default function ResourcesView() { const { isAdmin } = useAuth(); const { activeNode } = useNodes(); const { isPaid } = useLicense(); const [networkViewMode, setNetworkViewMode] = useState<'list' | 'topology'>('list'); const [usage, setUsage] = useState(null); const [images, setImages] = useState([]); const [volumes, setVolumes] = useState([]); const [networks, setNetworks] = useState([]); const [orphans, setOrphans] = useState>({}); const [isLoading, setIsLoading] = useState(true); const [isActioning, setIsActioning] = useState(false); // Filter state const [imageFilter, setImageFilter] = useState('all'); const [volumeFilter, setVolumeFilter] = useState('all'); const [networkFilter, setNetworkFilter] = useState('all'); // 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); // Network create/inspect state const [showCreateNetwork, setShowCreateNetwork] = useState(false); const [createNetworkForm, setCreateNetworkForm] = useState<{ name: string; driver: NetworkDriver; subnet: string; gateway: string; internal: boolean; attachable: boolean }>({ name: '', driver: 'bridge', subnet: '', gateway: '', internal: false, attachable: false }); const [isCreatingNetwork, setIsCreatingNetwork] = useState(false); const [inspectNetwork, setInspectNetwork] = useState(null); const [inspectLoadingId, setInspectLoadingId] = useState(null); const [inspectImageId, setInspectImageId] = useState(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); const fetchAllData = async () => { setIsLoading(true); try { const [usageRes, resourcesRes, orphansRes, summariesRes] = await Promise.all([ apiFetch('/system/docker-df'), apiFetch('/system/resources'), apiFetch('/system/orphans'), apiFetch('/security/image-summaries').catch(() => null), ]); if (usageRes.ok) setUsage(await usageRes.json()); if (resourcesRes.ok) { const resources = await resourcesRes.json(); setImages(resources.images ?? []); setVolumes(resources.volumes ?? []); setNetworks(resources.networks ?? []); } if (orphansRes.ok) { setOrphans(await orphansRes.json()); setSelectedOrphans([]); } if (summariesRes && summariesRes.ok) { const data = await summariesRes.json(); setScanSummaries(data ?? {}); } } catch (err) { console.error('Failed to fetch data', err); toast.error('Failed to load resources data'); } finally { setIsLoading(false); } }; useEffect(() => { fetchAllData(); }, [activeNode]); const handlePrune = async () => { if (!confirmPrune) return; setIsActioning(true); const loadingId = toast.loading(`Pruning ${confirmPrune.target}...`); try { const res = await apiFetch('/system/prune/system', { method: 'POST', body: JSON.stringify({ target: confirmPrune.target, scope: confirmPrune.scope }) }); const data = await res.json(); const scopeLabel = confirmPrune.scope === 'managed' ? 'Sencho-managed' : 'all'; toast.success( data.reclaimedBytes !== undefined ? `Pruned ${scopeLabel} ${confirmPrune.target}. Reclaimed ${formatBytes(data.reclaimedBytes)}.` : `Pruned ${scopeLabel} ${confirmPrune.target}.` ); await fetchAllData(); } catch { toast.error(confirmPrune ? `Failed to prune ${confirmPrune.target}` : 'Prune failed'); } finally { toast.dismiss(loadingId); setIsActioning(false); setConfirmPrune(null); } }; 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; setScanningImageRef(imageRef); const loadingId = toast.loading(`Scanning ${imageRef}...`); try { const res = await apiFetch('/security/scan', { method: 'POST', body: JSON.stringify({ imageRef, force, scanners }), }); 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(r => setTimeout(r, 3000)); const poll = await apiFetch(`/security/scans/${scanId}`); if (!poll.ok) continue; const poll_data = await poll.json(); 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'); if (summariesRes.ok) { const summaries = await summariesRes.json(); setScanSummaries(summaries ?? {}); } return; } } throw new Error('Scan timed out'); } catch (error) { 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); setScanningImageRef(null); } }; const handleCreateNetwork = async () => { setIsCreatingNetwork(true); try { const res = await apiFetch('/system/networks', { method: 'POST', body: JSON.stringify({ name: createNetworkForm.name, driver: createNetworkForm.driver, subnet: createNetworkForm.subnet || undefined, gateway: createNetworkForm.gateway || undefined, internal: createNetworkForm.internal, attachable: createNetworkForm.attachable, }), }); if (!res.ok) { const data = await res.json(); throw new Error(data?.error || 'Failed to create network'); } toast.success(`Network "${createNetworkForm.name}" created`); setShowCreateNetwork(false); setCreateNetworkForm({ name: '', driver: 'bridge', subnet: '', gateway: '', internal: false, attachable: false }); await fetchAllData(); } catch (error) { const err = error as Record; toast.error(String(err?.message || err?.error || 'Something went wrong.')); } finally { setIsCreatingNetwork(false); } }; const handleInspectNetwork = async (id: string) => { setInspectLoadingId(id); try { const res = await apiFetch(`/system/networks/${id}`); if (!res.ok) throw new Error('Failed to inspect network'); const data = await res.json(); setInspectNetwork(data); } catch (error) { const err = error as Record; toast.error(String(err?.message || err?.error || 'Something went wrong.')); } finally { setInspectLoadingId(null); } }; // Derived filtered lists const filteredImages = images.filter(img => imageFilter === 'managed' ? img.managedStatus === 'managed' : imageFilter === 'unmanaged' ? img.managedStatus !== 'managed' : true ); const filteredVolumes = volumes.filter(vol => volumeFilter === 'managed' ? vol.managedStatus === 'managed' : volumeFilter === 'unmanaged' ? vol.managedStatus !== 'managed' : true ); const filteredNetworks = networks.filter(net => networkFilter === 'managed' ? net.managedStatus === 'managed' : networkFilter === 'unmanaged' ? net.managedStatus !== 'managed' : true ); 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); const handleReviewAndPrune = () => { setConfirmPrune({ target: 'images', scope: 'all' }); }; return (
{/* Reclaim hero */} {usage && isAdmin && ( )} {/* 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 */}
{(['images', 'volumes', 'networks'] as const).map(tab => ( {tab} ))} Unmanaged {totalOrphansCount > 0 && ( {totalOrphansCount} )} {trivy.available && isPaid && ( )}
{/* Images */} i.managedStatus === 'managed').length, unmanaged: images.filter(i => i.managedStatus !== 'managed').length, }} /> ID Repository:Tag Size Status Action {isLoading ? : ( {filteredImages.length === 0 ? ( No images found. ) : filteredImages.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"} {(() => { const tag = img.RepoTags?.[0]; const summary = tag ? scanSummaries[tag] : undefined; if (!summary) return null; return setInspectScanId(summary.scan_id)} />; })()}
{trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== ':' && ( handleScanImage(img.RepoTags![0], { scanners: ['vuln'] })} > Scan (vulnerabilities) {isPaid && ( handleScanImage(img.RepoTags![0], { scanners: ['vuln', 'secret'] })} > Full scan (vulnerabilities + secrets) )} )} {isAdmin && }
))}
)}
{/* Volumes */} v.managedStatus === 'managed').length, unmanaged: volumes.filter(v => v.managedStatus !== 'managed').length, }} /> Name Driver Mountpoint Status Action {isLoading ? : ( {filteredVolumes.length === 0 ? ( No volumes found. ) : filteredVolumes.map((vol, i) => ( {vol.Name} {vol.Driver} {vol.Mountpoint}
{isAdmin && ( )} {isAdmin && }
))}
)}
{/* Networks */}
{networkViewMode === 'list' && ( n.managedStatus === 'managed').length, unmanaged: networks.filter(n => n.managedStatus !== 'managed').length, }} /> )}
{isPaid && (
)} {isAdmin && networkViewMode === 'list' && ( )}
{networkViewMode === 'topology' ? (
Loading topology...
}> { window.dispatchEvent(new CustomEvent(SENCHO_OPEN_LOGS_EVENT, { detail: { containerId: id, containerName: name }, })); }} />
) : ( ID Name Driver Scope Status Actions {isLoading ? : ( {filteredNetworks.length === 0 ? ( No networks found. ) : filteredNetworks.map((net, i) => ( {net.Id.substring(0, 12)} {net.Name} {net.Driver} {net.Scope}
{isAdmin && }
))}
)}
)} {/* 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}
))}
))}
)}
{/* ── 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...' : (confirmPrune?.scope === 'all' ? 'Prune all' : 'Prune')} confirming={isActioning} 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. )}

{/* 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.

{/* Create Network Modal */}
setCreateNetworkForm(f => ({ ...f, name: e.target.value }))} />
({ value: d, label: d }))} value={createNetworkForm.driver} onValueChange={v => setCreateNetworkForm(f => ({ ...f, driver: (v || 'bridge') as NetworkDriver }))} placeholder="Select driver..." searchPlaceholder="Search drivers..." emptyText="No matching driver." />
setCreateNetworkForm(f => ({ ...f, subnet: e.target.value }))} />
setCreateNetworkForm(f => ({ ...f, gateway: e.target.value }))} />
setCreateNetworkForm(f => ({ ...f, internal: v }))} />
setCreateNetworkForm(f => ({ ...f, attachable: v }))} />
setShowCreateNetwork(false)} disabled={isCreatingNetwork}> Cancel } primary={ } />
{/* Image Details Sheet */} setInspectImageId(null)} /> {/* Volume Browser Sheet */} setBrowseVolume(null)} /> {/* Network detail sheet */} setInspectNetwork(null)} /> setInspectScanId(null)} onRescan={(imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); }} canGenerateSbom={isPaid} canCompare={isPaid} canManageSuppressions={isPaid && isAdmin} />
); }