mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
41bf075eb0
* feat(recovery): make rollback-recovery image lifecycle visible and controllable GitHub discussion #1751 asked why Sencho creates sencho-rb/<id>/<service>:hold images during automatic updates and how to clean them up. That surfaced a real safety bug alongside the missing visibility: the manual single-image delete route did not consult the held-image predicate every other deletion path already honors, so a user could delete a rollback-protected image straight through the Images tab and silently break automatic recovery for that update. A short/truncated id also bypassed the predicate's full-id lookup. Fixes: - POST /images/delete now resolves the submitted id to its canonical form and checks the unified held-image predicate before deleting, returning 409 IMAGE_HELD_FOR_ROLLBACK for a protected image. - The Images tab no longer mislabels a protected image as plain "Unused"; a fully-synthetic hold image is kept out of the generic inventory entirely and surfaced instead in a new Resources -> Rollback tab, with an additive "Rollback protected" badge for images that still carry a normal tag too. New capability: - Two settings (Deploy Guardrails): superseded-generation retention (days, replaces a hardcoded 7) and a cap on retained generations per stack. - A new Resources -> Rollback tab lists every generation (stack, short id, state, retention) with an admin-gated manual release action, including releasing the current generation with an explicit warning that automatic rollback becomes unavailable until the next successful update. Release is a single atomic, server-revalidated transition so a stale UI read can never release a row that has since become ineligible. Also consolidated three near-duplicate implementations of the held-image predicate (two of which relied on a require() of a sibling .ts file that silently failed to resolve under the test runner and was never actually exercised by a real test before this change) into one shared module. Known follow-up, not fixed here: an orphaned sencho-rb tag whose recovery row no longer exists (DB restore, node re-add) is invisible in both the Images and Rollback tabs with no UI path to reclaim it. * fix(audit): add summary mapping for rollback generation release * fix(security): sanitize prune target in log sinks and cover release RBAC Closes two open js/log-injection findings on the system prune route by applying the same inline sanitizeForLog barrier the rest of the file already uses. The prune target is validated against an enum by parsePruneTargets before reaching these sinks, so the findings were false positives, but the barrier is cheap and removes the standing alerts on a file this change already touches. Also wraps the generation id in the release log line for consistency with the stack name beside it. Adds coverage for gaps a QA pass identified: - Release endpoint refuses a viewer and a deployer (Admin-only), leaving the generation and its artifacts untouched. - Viewer can still read the generations list, matching the sibling Resources routes. - The predicate the prune routes build reports full-stack rollback holds, not just service-scoped ones, and re-reads per call so a hold taken between plan and delete still gates the delete. - After releasing the current generation, no rollback point is claimed for the stack through any consumer of the current-generation lookup.
1550 lines
86 KiB
TypeScript
1550 lines
86 KiB
TypeScript
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 <p className="text-sm text-stat-subtitle">Building prune plan...</p>;
|
|
}
|
|
if (error) {
|
|
return <p className="text-sm text-destructive">{error}</p>;
|
|
}
|
|
if (!plan) return null;
|
|
if (plan.items.length === 0) {
|
|
return <p className="text-sm text-stat-subtitle">Nothing eligible to prune right now.</p>;
|
|
}
|
|
const shown = plan.items.slice(0, PLAN_PREVIEW_CAP);
|
|
const remaining = plan.items.length - shown.length;
|
|
return (
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-mono text-stat-subtitle/90">
|
|
{plan.items.length} {plan.items.length === 1 ? 'item' : 'items'}
|
|
{plan.reclaimableBytes > 0 ? ` · ${formatBytes(plan.reclaimableBytes)}` : ''}
|
|
</p>
|
|
<ul className="max-h-40 overflow-y-auto space-y-1 font-mono text-[12px] text-stat-subtitle/90">
|
|
{shown.map((item) => (
|
|
<li key={`${item.target}:${item.id}`} className="block truncate">
|
|
<span className="text-stat-subtitle/60">{item.target}</span>
|
|
{' · '}
|
|
{item.name}
|
|
{item.sizeBytes != null && item.sizeBytes > 0 ? ` · ${formatBytes(item.sizeBytes)}` : ''}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{remaining > 0 && (
|
|
<p className="text-xs text-stat-subtitle/70">and {remaining} more</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div className="flex items-center gap-1">
|
|
{options.map(({ key, label, count }) => (
|
|
<Button
|
|
key={key}
|
|
variant={value === key ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="h-7 text-xs px-2.5 gap-1.5"
|
|
onClick={() => onChange(key)}
|
|
>
|
|
{label}
|
|
<span className="font-mono tabular-nums text-[10px] opacity-70">{count}</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<span className="inline-flex items-center gap-1 flex-wrap">
|
|
{stacks.map((stack) => {
|
|
const inner = (<><span className="w-1.5 h-1.5 rounded-full bg-success shrink-0" />{stack}</>);
|
|
if (onOpenStack) {
|
|
return (
|
|
<TooltipProvider key={stack}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button type="button" className={`${cls} hover:bg-success/15 transition-colors`} onClick={() => onOpenStack(stack)}>
|
|
{inner}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Open stack {stack}</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
return <span key={stack} className={cls}>{inner}</span>;
|
|
})}
|
|
</span>
|
|
);
|
|
}
|
|
if (status === 'unmanaged') {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-warning/25 bg-warning/8 text-warning text-[10px] font-medium">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-warning shrink-0" />
|
|
External
|
|
</span>
|
|
);
|
|
}
|
|
if (status === 'system') {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-border bg-muted/40 text-muted-foreground text-[10px] font-medium">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40 shrink-0" />
|
|
System
|
|
</span>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── Sencho Self-Protection Badge ───────────────────────────────────────────────
|
|
|
|
function SenchoBadge() {
|
|
return (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-brand/25 bg-brand/8 text-brand text-[10px] font-medium">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-brand shrink-0" />
|
|
Sencho
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Protected · running Sencho instance</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
function RollbackProtectedBadge({ kind }: { kind?: 'stack' | 'service' }) {
|
|
return (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Badge variant="outline" className="text-[10px] h-5 gap-1 border-brand/40 text-brand">
|
|
<ShieldCheck className="w-3 h-3" strokeWidth={2} />
|
|
Rollback protected
|
|
</Badge>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{kind === 'stack'
|
|
? 'Held as a full-stack rollback point. See Resources → Rollback.'
|
|
: 'Held for a pending per-service update rollback.'}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<div className={cn(
|
|
'group flex flex-col rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel overflow-hidden',
|
|
'transition-colors duration-200 hover:border-t-card-border-hover',
|
|
)}>
|
|
<button
|
|
onClick={onManaged}
|
|
className="flex-1 flex flex-col items-center justify-center gap-2 p-3 pt-4 hover:bg-muted/40 transition-colors duration-150"
|
|
>
|
|
<span className={cn('transition-transform duration-200 group-hover:scale-110', accentClass)}>
|
|
{icon}
|
|
</span>
|
|
<span className="text-xs font-medium text-center leading-tight text-foreground">{label}</span>
|
|
<span className="text-[10px] text-brand font-mono tracking-wide">Sencho only</span>
|
|
</button>
|
|
{target !== 'containers' && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className={cn(
|
|
'flex items-center justify-center gap-1 border-t h-7 w-full text-[10px] text-muted-foreground',
|
|
'hover:bg-muted/40 hover:text-foreground transition-colors duration-150',
|
|
)}>
|
|
<MoreVertical className="w-3 h-3" />
|
|
<span>More options</span>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-52">
|
|
<DropdownMenuItem
|
|
className="text-destructive focus:text-destructive focus:bg-destructive/10 gap-2 text-xs"
|
|
onClick={onAll}
|
|
>
|
|
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
|
<span>All Docker <span className="text-muted-foreground">(includes external)</span></span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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<UsageData | null>(null);
|
|
const [images, setImages] = useState<DockerImage[]>([]);
|
|
const [volumes, setVolumes] = useState<DockerVolume[]>([]);
|
|
const [networks, setNetworks] = useState<DockerNetwork[]>([]);
|
|
const [orphans, setOrphans] = useState<Record<string, UnmanagedContainer[]>>({});
|
|
const [rollbackGenerations, setRollbackGenerations] = useState<RollbackGeneration[]>([]);
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isActioning, setIsActioning] = useState(false);
|
|
|
|
// Filter state
|
|
const [imageFilter, setImageFilter] = useState<ResourceFilter>('all');
|
|
const [volumeFilter, setVolumeFilter] = useState<ResourceFilter>('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<HTMLInputElement>(null);
|
|
const volumeSearchRef = useRef<HTMLInputElement>(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<PrunePlan | null>(null);
|
|
const [planLoading, setPlanLoading] = useState(false);
|
|
const [planError, setPlanError] = useState<string | null>(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<number | null>(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<string | null>(null);
|
|
|
|
// Unmanaged container state
|
|
const [selectedOrphans, setSelectedOrphans] = useState<string[]>([]);
|
|
const [bulkPurgeConfirm, setBulkPurgeConfirm] = useState(false);
|
|
|
|
// Vulnerability scanning state
|
|
const { status: trivy } = useTrivyStatus();
|
|
const [scanSummaries, setScanSummaries] = useState<Record<string, ScanSummary>>({});
|
|
const [scanningImageRef, setScanningImageRef] = useState<string | null>(null);
|
|
const [inspectScanId, setInspectScanId] = useState<number | null>(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<AbortController | null>(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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<void>((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 && (
|
|
<ReclaimHero
|
|
bytes={totalReclaimableBytes}
|
|
imageCount={usage.reclaimableImageCount}
|
|
containerCount={usage.reclaimableContainerCount}
|
|
volumeCount={usage.reclaimableVolumeCount}
|
|
onReview={handleReviewAndPrune}
|
|
onDismiss={handleDismissHero}
|
|
disabled={isLoading}
|
|
/>
|
|
)}
|
|
|
|
{/* Top row: Footprint + Quick Clean */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
|
|
{/* Disk Footprint */}
|
|
<Card className="col-span-1 border-card-border border-t-card-border-top shadow-card-bevel transition-colors hover:border-t-card-border-hover animate-in fade-in-0 slide-in-from-bottom-2 duration-300">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground tracking-wide uppercase">
|
|
Docker Disk Footprint
|
|
</CardTitle>
|
|
<CardDescription className="text-xs">
|
|
Click a segment to filter the tabs below
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{usage ? (
|
|
<FootprintTreemap
|
|
managedBytes={usage.managedImageBytes + usage.managedVolumeBytes}
|
|
unmanagedBytes={usage.unmanagedImageBytes + usage.unmanagedVolumeBytes}
|
|
reclaimableBytes={totalReclaimableBytes}
|
|
onFilter={(f) => handleFootprintFilter(treemapFilterToResourceFilter(f))}
|
|
/>
|
|
) : (
|
|
<Skeleton className="h-[150px] w-full rounded-md" />
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Quick Clean */}
|
|
{isAdmin && <Card className="col-span-1 md:col-span-2 border-card-border border-t-card-border-top shadow-card-bevel transition-colors hover:border-t-card-border-hover flex flex-col animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-75">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground tracking-wide uppercase">
|
|
Quick Clean
|
|
</CardTitle>
|
|
<CardDescription className="text-xs">
|
|
Primary actions target <span className="text-foreground font-medium">Sencho-managed</span> resources only.
|
|
Use <MoreVertical className="inline w-3 h-3" /> for all-Docker operations.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex-1 flex flex-col justify-center">
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
|
<PruneButton
|
|
target="images"
|
|
icon={<PackageMinus className="w-6 h-6" />}
|
|
label="Prune Unused Images"
|
|
accentClass="text-brand"
|
|
onManaged={() => setConfirmPrune({ target: 'images', scope: 'managed' })}
|
|
onAll={() => setConfirmPrune({ target: 'images', scope: 'all' })}
|
|
/>
|
|
<PruneButton
|
|
target="volumes"
|
|
icon={<HardDrive className="w-6 h-6" />}
|
|
label="Prune Unused Volumes"
|
|
accentClass="text-brand"
|
|
onManaged={() => setConfirmPrune({ target: 'volumes', scope: 'managed' })}
|
|
onAll={() => setConfirmPrune({ target: 'volumes', scope: 'all' })}
|
|
/>
|
|
<PruneButton
|
|
target="networks"
|
|
icon={<Network className="w-6 h-6" />}
|
|
label="Prune Dead Networks"
|
|
accentClass="text-success"
|
|
onManaged={() => setConfirmPrune({ target: 'networks', scope: 'managed' })}
|
|
onAll={() => setConfirmPrune({ target: 'networks', scope: 'all' })}
|
|
/>
|
|
<PruneButton
|
|
target="containers"
|
|
icon={<MonitorX className="w-6 h-6" />}
|
|
label="Purge Unmanaged Containers"
|
|
accentClass="text-warning"
|
|
onManaged={() => setConfirmPrune({ target: 'containers', scope: 'managed' })}
|
|
onAll={() => setConfirmPrune({ target: 'containers', scope: 'all' })}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>}
|
|
</div>
|
|
|
|
{/* Resource Tabs */}
|
|
<Tabs
|
|
value={resourceTab}
|
|
onValueChange={(v) => setResourceTab(v as typeof resourceTab)}
|
|
className="flex-1 flex flex-col w-full min-h-[400px]"
|
|
>
|
|
{isMobile ? (
|
|
<MobileSubTabs
|
|
ariaLabel="Resource sections"
|
|
active={resourceTab}
|
|
onSelect={setResourceTab}
|
|
tabs={[
|
|
{ value: 'images', label: 'Images', count: images.length },
|
|
{ value: 'volumes', label: 'Volumes', count: volumes.length },
|
|
{ value: 'unmanaged', label: 'Unmanaged', count: totalOrphansCount },
|
|
{ value: 'rollback', label: 'Rollback', count: rollbackGenerations.length },
|
|
]}
|
|
/>
|
|
) : (
|
|
<div className="flex items-center gap-3 mb-4 flex-wrap rounded-lg border border-card-border bg-card/40 px-2.5 py-1.5">
|
|
<TabsList className="border-transparent bg-transparent max-md:w-full max-md:overflow-x-auto max-md:[scrollbar-width:none]">
|
|
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
|
|
{(['images', 'volumes'] as const).map(tab => (
|
|
<TabsHighlightItem key={tab} value={tab}>
|
|
<TabsTrigger value={tab}>
|
|
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
|
<span className="ml-1.5 text-[10px] text-stat-subtitle tabular-nums">
|
|
{tab === 'images' ? images.length : volumes.length}
|
|
</span>
|
|
</TabsTrigger>
|
|
</TabsHighlightItem>
|
|
))}
|
|
<TabsHighlightItem value="unmanaged">
|
|
<TabsTrigger value="unmanaged" className="relative">
|
|
Unmanaged
|
|
<span className="ml-1.5 text-[10px] text-stat-subtitle tabular-nums">{totalOrphansCount}</span>
|
|
</TabsTrigger>
|
|
</TabsHighlightItem>
|
|
<TabsHighlightItem value="rollback">
|
|
<TabsTrigger value="rollback" className="relative">
|
|
Rollback
|
|
<span className="ml-1.5 text-[10px] text-stat-subtitle tabular-nums">{rollbackGenerations.length}</span>
|
|
</TabsTrigger>
|
|
</TabsHighlightItem>
|
|
</TabsHighlight>
|
|
</TabsList>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 relative">
|
|
|
|
{/* Images */}
|
|
<TabsContent value="images" className="m-0 border-0 p-0 animate-in fade-in-0 duration-200">
|
|
<div className="flex flex-wrap items-center gap-2 mb-4">
|
|
{imageSearch !== '' || imageSearchExpanded ? (
|
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
|
<Input
|
|
ref={imageSearchRef}
|
|
placeholder="Search images..."
|
|
value={imageSearch}
|
|
onChange={(e) => setImageSearch(e.target.value)}
|
|
onBlur={() => { if (imageSearch === '') setImageSearchExpanded(false); }}
|
|
className="pl-9 h-9"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="outline" size="sm" className="h-9 w-9 p-0 shrink-0" onClick={() => setImageSearchExpanded(true)} aria-label="Search images">
|
|
<Search className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Search images</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
<FilterToggle
|
|
value={imageFilter}
|
|
onChange={setImageFilter}
|
|
counts={{
|
|
all: images.length,
|
|
managed: images.filter(i => i.managedStatus === 'managed').length,
|
|
unmanaged: images.filter(i => i.managedStatus !== 'managed').length,
|
|
}}
|
|
/>
|
|
<div className="flex-1" />
|
|
{trivy.available && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9 gap-2 shrink-0"
|
|
onClick={() => {
|
|
window.dispatchEvent(new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, {
|
|
detail: { view: 'security', tab: 'history' },
|
|
}));
|
|
}}
|
|
aria-label="Open scan history"
|
|
>
|
|
<History className="w-4 h-4" strokeWidth={1.5} />
|
|
Scan history
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
|
<ScrollArea className="h-[62vh] max-md:h-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="hover:bg-transparent">
|
|
<TableHead className="w-[120px] text-[11px]">ID</TableHead>
|
|
<SortableTableHead label="Repository:Tag" columnKey="repo" activeKey={imageSort.sortKey} dir={imageSort.sortDir} onSort={imageSort.toggleSort} />
|
|
<SortableTableHead label="Size" columnKey="size" activeKey={imageSort.sortKey} dir={imageSort.sortDir} onSort={imageSort.toggleSort} />
|
|
<SortableTableHead label="Status" columnKey="status" activeKey={imageSort.sortKey} dir={imageSort.sortDir} onSort={imageSort.toggleSort} />
|
|
<TableHead className="text-right text-[11px]">Action</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
{isLoading ? <TableSkeleton cols={5} /> : (
|
|
<TableBody>
|
|
{imageSort.sorted.length === 0 ? (
|
|
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">No images found.</TableCell></TableRow>
|
|
) : imageSort.sorted.map((img, i) => (
|
|
<TableRow
|
|
key={img.Id}
|
|
className="animate-in fade-in-0 duration-200 hover:bg-muted/30 transition-colors"
|
|
style={{ animationDelay: `${Math.min(i * 20, 200)}ms` }}
|
|
>
|
|
<TableCell className="font-mono text-xs text-muted-foreground">{img.Id.split(':')[1]?.substring(0, 12)}</TableCell>
|
|
<TableCell className="font-medium">{img.RepoTags?.[0] || '<none>:<none>'}</TableCell>
|
|
<TableCell className="font-mono text-xs tabular-nums">{formatBytes(img.Size)}</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<Badge variant={img.Containers > 0 ? "default" : "secondary"} className="text-[10px] h-5">
|
|
{img.Containers > 0 ? "In Use" : "Unused"}
|
|
</Badge>
|
|
<ManagedBadge
|
|
status={img.managedStatus}
|
|
managedBy={img.managedBy}
|
|
usedByStacks={img.usedByStacks}
|
|
onOpenStack={activeNode ? (stack) => window.dispatchEvent(
|
|
new CustomEvent<SenchoOpenStackDetail>(SENCHO_OPEN_STACK_EVENT, { detail: { nodeId: activeNode.id, stackName: stack } }),
|
|
) : undefined}
|
|
/>
|
|
{img.isSencho && <SenchoBadge />}
|
|
{img.rollbackProtected && <RollbackProtectedBadge kind={img.rollbackProtectionKind} />}
|
|
{(() => {
|
|
const tag = img.RepoTags?.[0];
|
|
const summary = tag ? scanSummaries[tag] : undefined;
|
|
if (!summary) return null;
|
|
return <SeverityBadge summary={summary} onClick={() => setInspectScanId(summary.scan_id)} />;
|
|
})()}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={() => {
|
|
if (!activeNode) return;
|
|
setInspectImage({ ...img, nodeId: activeNode.id });
|
|
}}
|
|
aria-label={`Inspect ${img.RepoTags?.[0] || 'image'}`}
|
|
>
|
|
<Eye className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Inspect image</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
{trivy.available && canDeployResources && img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>' && (
|
|
<DropdownMenu>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
|
disabled={scanningImageRef === img.RepoTags[0]}
|
|
>
|
|
{scanningImageRef === img.RepoTags[0] ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
|
|
) : (
|
|
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
)}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Scan for vulnerabilities</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
onClick={() => handleScanImage(img.RepoTags![0], { scanners: ['vuln'] })}
|
|
>
|
|
Scan (vulnerabilities)
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => handleScanImage(img.RepoTags![0], { scanners: ['vuln', 'secret'] })}
|
|
>
|
|
Full scan (vulnerabilities + secrets)
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
{isAdmin && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className={img.isSencho ? 'cursor-not-allowed' : undefined}>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-destructive/60"
|
|
disabled={img.isSencho}
|
|
onClick={() => setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
{img.isSencho && <TooltipContent>Protected · running Sencho instance</TooltipContent>}
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
)}
|
|
</Table>
|
|
</ScrollArea>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Volumes */}
|
|
<TabsContent value="volumes" className="m-0 border-0 p-0 animate-in fade-in-0 duration-200">
|
|
<div className="flex flex-wrap items-center gap-2 mb-4">
|
|
{volumeSearch !== '' || volumeSearchExpanded ? (
|
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
|
<Input
|
|
ref={volumeSearchRef}
|
|
placeholder="Search volumes..."
|
|
value={volumeSearch}
|
|
onChange={(e) => setVolumeSearch(e.target.value)}
|
|
onBlur={() => { if (volumeSearch === '') setVolumeSearchExpanded(false); }}
|
|
className="pl-9 h-9"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="outline" size="sm" className="h-9 w-9 p-0 shrink-0" onClick={() => setVolumeSearchExpanded(true)} aria-label="Search volumes">
|
|
<Search className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Search volumes</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
<FilterToggle
|
|
value={volumeFilter}
|
|
onChange={setVolumeFilter}
|
|
counts={{
|
|
all: volumes.length,
|
|
managed: volumes.filter(v => v.managedStatus === 'managed').length,
|
|
unmanaged: volumes.filter(v => v.managedStatus !== 'managed').length,
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
|
<ScrollArea className="h-[62vh] max-md:h-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="hover:bg-transparent">
|
|
<SortableTableHead label="Name" columnKey="name" activeKey={volumeSort.sortKey} dir={volumeSort.sortDir} onSort={volumeSort.toggleSort} />
|
|
<SortableTableHead label="Driver" columnKey="driver" activeKey={volumeSort.sortKey} dir={volumeSort.sortDir} onSort={volumeSort.toggleSort} />
|
|
<TableHead className="hidden md:table-cell text-[11px]">Mountpoint</TableHead>
|
|
<TableHead className="text-[11px]">Status</TableHead>
|
|
<TableHead className="text-right text-[11px]">Action</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
{isLoading ? <TableSkeleton cols={5} /> : (
|
|
<TableBody>
|
|
{volumeSort.sorted.length === 0 ? (
|
|
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">No volumes found.</TableCell></TableRow>
|
|
) : volumeSort.sorted.map((vol, i) => (
|
|
<TableRow
|
|
key={vol.Name}
|
|
className="animate-in fade-in-0 duration-200 hover:bg-muted/30 transition-colors"
|
|
style={{ animationDelay: `${Math.min(i * 20, 200)}ms` }}
|
|
>
|
|
<TableCell className="font-mono text-xs max-w-[200px]"><VolumeNameLabel name={vol.Name} showChip /></TableCell>
|
|
<TableCell><Badge variant="outline" className="text-[10px] h-5">{vol.Driver}</Badge></TableCell>
|
|
<TableCell className="hidden md:table-cell text-xs text-muted-foreground truncate max-w-[300px]">{vol.Mountpoint}</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<ManagedBadge status={vol.managedStatus} managedBy={vol.managedBy} />
|
|
{vol.isSencho && <SenchoBadge />}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
{canReadResources && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={() => setBrowseVolume(vol.Name)}
|
|
aria-label={`Browse ${vol.Name}`}
|
|
>
|
|
<FolderOpen className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Browse volume contents</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
{isAdmin && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className={vol.isSencho ? 'cursor-not-allowed' : undefined}>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-destructive/60"
|
|
disabled={vol.isSencho}
|
|
onClick={() => setConfirmDelete({ type: 'volumes', id: vol.Name, name: vol.Name })}
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
{vol.isSencho && <TooltipContent>Protected · running Sencho instance</TooltipContent>}
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
)}
|
|
</Table>
|
|
</ScrollArea>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Unmanaged Containers */}
|
|
<TabsContent value="unmanaged" className="m-0 border-0 p-0 h-full flex flex-col animate-in fade-in-0 duration-200">
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
|
<div className="flex justify-between items-center px-4 py-2.5 border-b bg-muted/10 sticky top-0 z-10">
|
|
<div className="flex items-center gap-2.5">
|
|
<input
|
|
type="checkbox"
|
|
onChange={selectAllOrphans}
|
|
checked={selectedOrphans.length === totalOrphansCount && totalOrphansCount > 0}
|
|
className="rounded border-border focus:ring-ring h-4 w-4 accent-foreground"
|
|
/>
|
|
<span className="text-xs font-medium text-muted-foreground">Select all</span>
|
|
</div>
|
|
{isAdmin && <Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 text-xs gap-1.5 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
|
onClick={() => setBulkPurgeConfirm(true)}
|
|
disabled={selectedOrphans.length === 0 || isActioning}
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
{isActioning ? 'Purging...' : `Purge Selected (${selectedOrphans.length})`}
|
|
</Button>}
|
|
</div>
|
|
|
|
{totalOrphansCount === 0 ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center p-12 text-muted-foreground animate-in fade-in-0 duration-300">
|
|
<div className="w-12 h-12 rounded-full bg-success-muted flex items-center justify-center mb-3">
|
|
<ShieldCheck className="w-6 h-6 text-success" />
|
|
</div>
|
|
<p className="font-medium text-sm">No unmanaged containers</p>
|
|
<p className="text-xs mt-1 opacity-70">All running containers are managed by Sencho.</p>
|
|
</div>
|
|
) : (
|
|
<div className="p-4 space-y-3 pb-12">
|
|
{Object.entries(orphans).map(([project, containers], gi) => (
|
|
<div
|
|
key={project}
|
|
className="bg-card rounded-lg border shadow-card-bevel overflow-hidden text-sm animate-in fade-in-0 slide-in-from-bottom-1 duration-200"
|
|
style={{ animationDelay: `${gi * 60}ms` }}
|
|
>
|
|
{/* Project header */}
|
|
<div className="bg-warning/8 border-b border-warning/15 px-4 py-2 font-medium text-xs flex items-center gap-2">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-warning animate-pulse shrink-0" />
|
|
<span className="text-warning">External Project:</span>
|
|
<span className="font-mono text-foreground">{project}</span>
|
|
<span className="ml-auto text-muted-foreground font-normal">{containers.length} container{containers.length !== 1 ? 's' : ''}</span>
|
|
</div>
|
|
<div className="divide-y divide-border/50">
|
|
{containers.map((container: UnmanagedContainer) => (
|
|
<div
|
|
key={container.Id}
|
|
className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/30 transition-colors duration-150"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedOrphans.includes(container.Id)}
|
|
onChange={() => toggleOrphan(container.Id)}
|
|
className="rounded border-border h-4 w-4 accent-foreground"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs font-medium truncate">
|
|
{container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12)}
|
|
</span>
|
|
<Badge
|
|
variant={container.State === 'running' ? 'default' : 'secondary'}
|
|
className="text-[9px] h-4 px-1.5"
|
|
>
|
|
{container.State}
|
|
</Badge>
|
|
</div>
|
|
<div className="text-[11px] text-muted-foreground truncate mt-0.5 font-mono">
|
|
{container.Image}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Rollback */}
|
|
<TabsContent value="rollback" className="m-0 border-0 p-0 animate-in fade-in-0 duration-200">
|
|
<RollbackGenerationsTab
|
|
generations={rollbackGenerations}
|
|
isLoading={isLoading}
|
|
isAdmin={isAdmin}
|
|
nodeId={activeNode?.id}
|
|
onReleased={fetchAllData}
|
|
/>
|
|
</TabsContent>
|
|
</div>
|
|
</Tabs>
|
|
</>
|
|
);
|
|
|
|
const overlays = (
|
|
<>
|
|
{/* ── Dialogs ── */}
|
|
|
|
{/* Prune Confirm */}
|
|
<ConfirmModal
|
|
open={!!confirmPrune}
|
|
onOpenChange={(open) => !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}
|
|
>
|
|
<div className="space-y-3 text-sm text-stat-subtitle">
|
|
<p>
|
|
{confirmPrune?.scope === 'all' ? (
|
|
<>
|
|
Prunes <span className="font-medium text-stat-value">all</span> unused {confirmPrune?.target} from the Docker daemon, including those from{' '}
|
|
<span className="font-medium text-stat-value">external projects not managed by Sencho</span>.
|
|
</>
|
|
) : (
|
|
<>
|
|
Removes only unused {confirmPrune?.target} belonging to your Sencho stacks. External Docker resources are{' '}
|
|
<span className="font-medium text-stat-value">not affected</span>.
|
|
</>
|
|
)}
|
|
</p>
|
|
<PrunePlanPreview plan={prunePlan} loading={planLoading} error={planError} />
|
|
</div>
|
|
</ConfirmModal>
|
|
|
|
{/* Reclaim Confirm (banner "Review & prune") */}
|
|
<ConfirmModal
|
|
open={confirmReclaim}
|
|
onOpenChange={(open) => !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}
|
|
>
|
|
<div className="space-y-3 text-sm text-stat-subtitle">
|
|
<p>
|
|
Removes every unused image, stopped container, and dangling volume on this node, including those from{' '}
|
|
<span className="font-medium text-stat-value">external projects not managed by Sencho</span>.
|
|
</p>
|
|
<PrunePlanPreview plan={prunePlan} loading={planLoading} error={planError} />
|
|
</div>
|
|
</ConfirmModal>
|
|
|
|
{/* Delete Confirm */}
|
|
<ConfirmModal
|
|
open={!!confirmDelete}
|
|
onOpenChange={(open) => !open && setConfirmDelete(null)}
|
|
variant="destructive"
|
|
kicker="RESOURCES · DELETE · IRREVERSIBLE"
|
|
title={`Delete ${confirmDelete?.type.slice(0, -1) ?? ''}`}
|
|
confirmLabel={isActioning ? 'Deleting...' : 'Delete'}
|
|
confirming={isActioning}
|
|
onConfirm={handleDelete}
|
|
>
|
|
<p className="text-sm text-stat-subtitle">
|
|
Permanently deletes{' '}
|
|
<span className="font-mono font-medium text-stat-value">
|
|
{confirmDelete?.name || confirmDelete?.id.substring(0, 12)}
|
|
</span>.
|
|
</p>
|
|
</ConfirmModal>
|
|
|
|
{/* Unmanaged Container Purge Confirm */}
|
|
<ConfirmModal
|
|
open={bulkPurgeConfirm}
|
|
onOpenChange={setBulkPurgeConfirm}
|
|
variant="destructive"
|
|
kicker="RESOURCES · PURGE · IRREVERSIBLE"
|
|
title="Purge selected unmanaged containers"
|
|
hint={`AFFECTS ${selectedOrphans.length} container${selectedOrphans.length !== 1 ? 's' : ''}`}
|
|
confirmLabel={isActioning ? 'Purging...' : 'Purge'}
|
|
confirming={isActioning}
|
|
onConfirm={handlePurgeOrphans}
|
|
>
|
|
<p className="text-sm text-stat-subtitle">
|
|
Force-stops and removes {selectedOrphans.length} container{selectedOrphans.length !== 1 ? 's' : ''} from external projects not managed by Sencho.
|
|
</p>
|
|
</ConfirmModal>
|
|
|
|
{/* Image Details Sheet */}
|
|
<ImageDetailsSheet
|
|
image={inspectImage && activeNode && inspectImage.nodeId === activeNode.id ? inspectImage : null}
|
|
onClose={() => setInspectImage(null)}
|
|
onOpenStack={activeNode ? (stack) => window.dispatchEvent(
|
|
new CustomEvent<SenchoOpenStackDetail>(SENCHO_OPEN_STACK_EVENT, { detail: { nodeId: activeNode.id, stackName: stack } }),
|
|
) : undefined}
|
|
/>
|
|
|
|
{/* Volume Browser Sheet */}
|
|
<VolumeBrowserSheet volumeName={browseVolume} onClose={() => setBrowseVolume(null)} />
|
|
|
|
<VulnerabilityScanSheet
|
|
scanId={inspectScanId}
|
|
onClose={() => setInspectScanId(null)}
|
|
onRescan={canDeployResources ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined}
|
|
canGenerateSbom={canReadResources}
|
|
canExportSarif={canReadResources}
|
|
canCompare
|
|
canManageSuppressions={canEditSecurityPolicy}
|
|
/>
|
|
</>
|
|
);
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col">
|
|
<Masthead
|
|
kicker="resources · docker"
|
|
state={totalReclaimableBytes > 0 ? 'Reclaimable' : 'Tidy'}
|
|
stateTone={totalReclaimableBytes > 0 ? 'warning' : 'success'}
|
|
live={false}
|
|
meta={`${images.length} images · ${volumes.length} volumes · ${networks.length} networks`}
|
|
right={headerActions}
|
|
/>
|
|
<div className="flex-1 min-h-0 overflow-auto p-4 flex flex-col gap-4 [&_[data-radix-scroll-area-viewport]>div]:!block [&_table]:min-w-[640px]">
|
|
{mainContent}
|
|
</div>
|
|
{overlays}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 h-full overflow-auto text-foreground flex flex-col gap-6 animate-in fade-in-0 duration-300">
|
|
{mainContent}
|
|
{overlays}
|
|
</div>
|
|
);
|
|
}
|