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)}` : ''}
{shown.map((item) => (
{item.target}
{' · '}
{item.name}
{item.sizeBytes != null && item.sizeBytes > 0 ? ` · ${formatBytes(item.sizeBytes)}` : ''}
))}
{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 }) => (
onChange(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 (
onOpenStack(stack)}>
{inner}
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 (
{icon}
{label}
Sencho only
{target !== 'containers' && (
More options
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"
/>
) : (
setImageSearchExpanded(true)} aria-label="Search images">
Search images
)}
i.managedStatus === 'managed').length,
unmanaged: images.filter(i => i.managedStatus !== 'managed').length,
}}
/>
{trivy.available && (
{
window.dispatchEvent(new CustomEvent(SENCHO_NAVIGATE_EVENT, {
detail: { view: 'security', tab: 'history' },
}));
}}
aria-label="Open scan history"
>
Scan history
)}
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)} />;
})()}
{
if (!activeNode) return;
setInspectImage({ ...img, nodeId: activeNode.id });
}}
aria-label={`Inspect ${img.RepoTags?.[0] || 'image'}`}
>
Inspect image
{trivy.available && canDeployResources && img.RepoTags?.[0] && img.RepoTags[0] !== ':' && (
{scanningImageRef === 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 && (
setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}
>
{img.isSencho && Protected · running Sencho instance }
)}
))}
)}
{/* Volumes */}
{volumeSearch !== '' || volumeSearchExpanded ? (
setVolumeSearch(e.target.value)}
onBlur={() => { if (volumeSearch === '') setVolumeSearchExpanded(false); }}
className="pl-9 h-9"
/>
) : (
setVolumeSearchExpanded(true)} aria-label="Search volumes">
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 && (
setBrowseVolume(vol.Name)}
aria-label={`Browse ${vol.Name}`}
>
Browse volume contents
)}
{isAdmin && (
setConfirmDelete({ type: 'volumes', id: vol.Name, name: vol.Name })}
>
{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 &&
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}
))}
))}
)}
{/* 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}
);
}