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 }) => (
onChange(key)}
className={cn(
'flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition-all duration-200',
value === key
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{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 (
{label}
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 (
{icon}
{label}
Sencho only
{target !== 'containers' && (
More options
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 && (
{
window.dispatchEvent(new CustomEvent(SENCHO_NAVIGATE_EVENT, {
detail: { view: 'security-history' },
}));
}}
title="View completed vulnerability scans and compare them"
aria-label="Open scan history"
>
Scan history
)}
{/* 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)} />;
})()}
setInspectImageId(img.Id)}
title="Inspect image"
aria-label={`Inspect ${img.RepoTags?.[0] || 'image'}`}
>
{trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== ':' && (
{scanningImageRef === img.RepoTags[0] ? (
) : (
)}
handleScanImage(img.RepoTags![0], { scanners: ['vuln'] })}
>
Scan (vulnerabilities)
{isPaid && (
handleScanImage(img.RepoTags![0], { scanners: ['vuln', 'secret'] })}
>
Full scan (vulnerabilities + secrets)
)}
)}
{isAdmin && setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}>
}
))}
)}
{/* 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 && (
setBrowseVolume(vol.Name)}
title="Browse volume contents"
aria-label={`Browse ${vol.Name}`}
>
)}
{isAdmin && setConfirmDelete({ type: 'volumes', id: vol.Name, name: vol.Name })}>
}
))}
)}
{/* Networks */}
{networkViewMode === 'list' && (
n.managedStatus === 'managed').length,
unmanaged: networks.filter(n => n.managedStatus !== 'managed').length,
}}
/>
)}
{isPaid && (
setNetworkViewMode('list')}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200',
networkViewMode === 'list' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
>
List
setNetworkViewMode('topology')}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200 flex items-center gap-1',
networkViewMode === 'topology' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
>
Topology
)}
{isAdmin && networkViewMode === 'list' && (
setShowCreateNetwork(true)}
>
Create Network
)}
{networkViewMode === '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}
handleInspectNetwork(net.Id)}
>
{inspectLoadingId === net.Id ? : }
{isAdmin && setConfirmDelete({ type: 'networks', id: net.Id, name: net.Name })}
>
}
))}
)}
)}
{/* Unmanaged Containers */}
0}
className="rounded border-border focus:ring-ring h-4 w-4 accent-foreground"
/>
Select all
{isAdmin &&
setBulkPurgeConfirm(true)}
disabled={selectedOrphans.length === 0 || isActioning}
>
{isActioning ? 'Purging...' : `Purge Selected (${selectedOrphans.length})`}
}
{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 */}
Name
setCreateNetworkForm(f => ({ ...f, name: e.target.value }))}
/>
Driver
({ 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, internal: v }))}
/>
Internal (no external access)
setCreateNetworkForm(f => ({ ...f, attachable: v }))}
/>
Attachable
setShowCreateNetwork(false)} disabled={isCreatingNetwork}>
Cancel
}
primary={
{isCreatingNetwork ? 'Creating...' : 'Create network'}
}
/>
{/* 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}
/>
);
}