From 5191737d5365fcc607358993298aad40ab7c2d26 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Sun, 22 Mar 2026 03:17:54 -0400 Subject: [PATCH 1/2] feat(resources): managed/unmanaged resource separation across Resources Hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Classify all Docker images, volumes, and networks as managed (Sencho stack), external (other Compose project), or unused/system via a new getClassifiedResources() method and GET /api/system/resources endpoint - Add pruneManagedOnly() + getDiskUsageClassified() to DockerController - Prune buttons now default to Sencho-managed scope; "All Docker" is hidden in a ⋮ dropdown with a distinct destructive confirm dialog - Replace Reclaimable Space donut with interactive Docker Disk Footprint widget (stacked bar with clickable segments that filter resource tabs) - Add managed/external filter toggles and classification badges per tab - GET /api/stats now returns managed + unmanaged container counts; Home Dashboard Active Containers card subtitle shows "N managed · N external" - Rename "Ghost Containers" tab/copy to "Unmanaged Containers" throughout --- CHANGELOG.md | 9 + backend/src/index.ts | 65 +- backend/src/services/DockerController.ts | 192 +++++ frontend/src/components/HomeDashboard.tsx | 9 +- frontend/src/components/ResourcesView.tsx | 860 ++++++++++++++++------ 5 files changed, 879 insertions(+), 256 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67943b14..7d340c75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Resources Hub — Managed/Unmanaged Separation:** All Docker resources (images, volumes, networks) are now classified as `managed` (belonging to a Sencho stack), `external` (belonging to another Compose project), or `unused`/`system`. Classification is exposed via a new `GET /api/system/resources` endpoint that makes 4 parallel Docker API calls once and returns all three resource types in a single round trip. +- **Docker Disk Footprint widget:** Replaces the Reclaimable Space donut chart with an interactive horizontal stacked bar showing Sencho Managed vs External Projects vs Reclaimable bytes. Clicking a segment filters the Images and Volumes tabs simultaneously. +- **Scoped prune operations:** Quick Clean buttons now target Sencho-managed resources only by default ("Sencho only" label). An "All Docker" option is hidden in a `⋮` dropdown per prune target, with a distinct destructive confirmation dialog. The `POST /api/system/prune/system` endpoint accepts an optional `scope: 'managed' | 'all'` body parameter; a new `pruneManagedOnly()` method on `DockerController` filters by `com.docker.compose.project` label before removing resources. +- **Managed/External filter toggles:** Each resource tab (Images, Volumes, Networks) has a segmented pill control to show All / Managed / External resources with live counts. +- **Classification badges:** Each resource row displays a green "stack-name" badge for managed resources, an orange "External" badge for external, and a muted "System" badge for Docker-native networks (`bridge`, `host`, `none`). System networks have their delete button disabled. +- **Active Containers split stat:** `GET /api/stats` now returns `managed` and `unmanaged` container counts in addition to `active`/`exited`/`total`. The Home Dashboard "Active Containers" card subtitle shows "N managed · N external". +- **Renamed "Ghost Containers" → "Unmanaged Containers":** Tab label, dialog titles, toast messages, and empty-state copy updated throughout `ResourcesView`. + ### Security - **Fixed:** `POST /api/system/console-token` was missing `authMiddleware` — any unauthenticated client could generate console session tokens. Fixed by adding `authMiddleware` to the route. - **Fixed:** Remote node `api_url` was accepted without validation — an attacker could set it to `http://localhost:6379` to SSRF into internal services. Now validates: must be a well-formed `http://` or `https://` URL, and the hostname may not be `localhost`, `127.x.x.x`, `[::1]`, or `0.0.0.0`. diff --git a/backend/src/index.ts b/backend/src/index.ts index 9fe152a9..dcc7e27b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1112,15 +1112,26 @@ app.post('/api/convert', async (req: Request, res: Response) => { // Get all containers stats for dashboard app.get('/api/stats', async (req: Request, res: Response) => { try { - const dockerController = DockerController.getInstance(req.nodeId); - const containers = await dockerController.getRunningContainers(); + const [dockerController, knownStacks] = [ + DockerController.getInstance(req.nodeId), + await FileSystemService.getInstance(req.nodeId).getStacks(), + ]; const allContainers = await dockerController.getAllContainers(); + const knownSet = new Set(knownStacks); - const active = containers.length; - const exited = allContainers.filter((c: { State: string }) => c.State === 'exited').length; + const active = allContainers.filter((c: any) => c.State === 'running').length; + const exited = allContainers.filter((c: any) => c.State === 'exited').length; const total = allContainers.length; + const managed = allContainers.filter((c: any) => { + const project: string | undefined = c.Labels?.['com.docker.compose.project']; + return project && knownSet.has(project) && c.State === 'running'; + }).length; + const unmanaged = allContainers.filter((c: any) => { + const project: string | undefined = c.Labels?.['com.docker.compose.project']; + return project && !knownSet.has(project) && c.State === 'running'; + }).length; - res.json({ active, exited, total, inactive: total - active - exited }); + res.json({ active, managed, unmanaged, exited, total }); } catch (error) { res.status(500).json({ error: 'Failed to fetch stats' }); } @@ -1624,13 +1635,24 @@ app.post('/api/system/prune/orphans', async (req: Request, res: Response) => { app.post('/api/system/prune/system', async (req: Request, res: Response) => { try { - const { target } = req.body; // 'containers', 'images', 'networks', 'volumes' + const { target, scope } = req.body as { target: string; scope?: string }; if (!['containers', 'images', 'networks', 'volumes'].includes(target)) { return res.status(400).json({ error: 'Invalid prune target' }); } const dockerController = DockerController.getInstance(req.nodeId); - const result = await dockerController.pruneSystem(target); + const pruneScope = scope === 'managed' ? 'managed' : 'all'; + + let result: { success: boolean; reclaimedBytes: number }; + if (pruneScope === 'managed' && target !== 'containers') { + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + result = await dockerController.pruneManagedOnly( + target as 'images' | 'volumes' | 'networks', + knownStacks + ); + } else { + result = await dockerController.pruneSystem(target as 'containers' | 'images' | 'networks' | 'volumes'); + } res.json({ message: 'Prune completed', ...result }); } catch (error: any) { @@ -1641,8 +1663,8 @@ app.post('/api/system/prune/system', async (req: Request, res: Response) => { app.get('/api/system/docker-df', async (req: Request, res: Response) => { try { - const dockerController = DockerController.getInstance(req.nodeId); - const df = await dockerController.getDiskUsage(); + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const df = await DockerController.getInstance(req.nodeId).getDiskUsageClassified(knownStacks); res.json(df); } catch (error) { console.error('Failed to fetch docker disk usage:', error); @@ -1650,10 +1672,23 @@ app.get('/api/system/docker-df', async (req: Request, res: Response) => { } }); +// Single endpoint returning classified images, volumes, and networks in one call +app.get('/api/system/resources', async (req: Request, res: Response) => { + try { + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const result = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); + res.json(result); + } catch (error) { + console.error('Failed to fetch classified resources:', error); + res.status(500).json({ error: 'Failed to fetch resources' }); + } +}); + +// Keep legacy endpoints for backward compat with remote proxy routing app.get('/api/system/images', async (req: Request, res: Response) => { try { - const dockerController = DockerController.getInstance(req.nodeId); - const images = await dockerController.getImages(); + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const { images } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); res.json(images); } catch (error) { console.error('Failed to fetch images:', error); @@ -1663,8 +1698,8 @@ app.get('/api/system/images', async (req: Request, res: Response) => { app.get('/api/system/volumes', async (req: Request, res: Response) => { try { - const dockerController = DockerController.getInstance(req.nodeId); - const volumes = await dockerController.getVolumes(); + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const { volumes } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); res.json(volumes); } catch (error) { console.error('Failed to fetch volumes:', error); @@ -1674,8 +1709,8 @@ app.get('/api/system/volumes', async (req: Request, res: Response) => { app.get('/api/system/networks', async (req: Request, res: Response) => { try { - const dockerController = DockerController.getInstance(req.nodeId); - const networks = await dockerController.getNetworks(); + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const { networks } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks); res.json(networks); } catch (error) { console.error('Failed to fetch networks:', error); diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 36d90c2c..71a0ba94 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -11,6 +11,32 @@ import { NodeRegistry } from './NodeRegistry'; const execAsync = promisify(exec); const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose'; +export interface ClassifiedImage { + Id: string; + RepoTags: string[]; + Size: number; + Containers: number; + managedBy: string | null; + managedStatus: 'managed' | 'unmanaged' | 'unused'; +} + +export interface ClassifiedVolume { + Name: string; + Driver: string; + Mountpoint: string; + managedBy: string | null; + managedStatus: 'managed' | 'unmanaged'; +} + +export interface ClassifiedNetwork { + Id: string; + Name: string; + Driver: string; + Scope: string; + managedBy: string | null; + managedStatus: 'managed' | 'unmanaged' | 'system'; +} + class DockerController { private docker: Docker; private nodeId: number; @@ -112,6 +138,172 @@ class DockerController { return this.validateApiData(data); } + public async getClassifiedResources(knownStackNames: string[]): Promise<{ + images: ClassifiedImage[]; + volumes: ClassifiedVolume[]; + networks: ClassifiedNetwork[]; + }> { + const SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']); + const knownSet = new Set(knownStackNames); + + const [rawImages, rawVolumeData, rawNetworks, allContainers] = await Promise.all([ + this.docker.listImages({ all: false }), + this.docker.listVolumes(), + this.docker.listNetworks(), + this.docker.listContainers({ all: true }), + ]); + + const rawVolumes: any[] = (this.validateApiData(rawVolumeData)).Volumes || []; + + // Build imageId → project mapping from container labels + const imageToProject = new Map(); + for (const c of allContainers as any[]) { + const project: string | undefined = c.Labels?.['com.docker.compose.project']; + if (project && c.ImageID) imageToProject.set(c.ImageID, project); + } + + const images: ClassifiedImage[] = this.validateApiData(rawImages).map((img: any) => { + const project = imageToProject.get(img.Id) ?? null; + const managedStatus: ClassifiedImage['managedStatus'] = + img.Containers === 0 ? 'unused' : + project && knownSet.has(project) ? 'managed' : 'unmanaged'; + return { + Id: img.Id, + RepoTags: img.RepoTags ?? [], + Size: img.Size ?? 0, + Containers: img.Containers ?? 0, + managedBy: managedStatus === 'managed' ? project : null, + managedStatus, + }; + }); + + const volumes: ClassifiedVolume[] = rawVolumes.map((vol: any) => { + const project: string | undefined = vol.Labels?.['com.docker.compose.project']; + const managedStatus: ClassifiedVolume['managedStatus'] = + project && knownSet.has(project) ? 'managed' : 'unmanaged'; + return { + Name: vol.Name, + Driver: vol.Driver, + Mountpoint: vol.Mountpoint, + managedBy: managedStatus === 'managed' ? project! : null, + managedStatus, + }; + }); + + const networks: ClassifiedNetwork[] = this.validateApiData(rawNetworks).map((net: any) => { + if (SYSTEM_NETWORKS.has(net.Name)) { + return { Id: net.Id, Name: net.Name, Driver: net.Driver, Scope: net.Scope, managedBy: null, managedStatus: 'system' as const }; + } + const project: string | undefined = net.Labels?.['com.docker.compose.project']; + const managedStatus: ClassifiedNetwork['managedStatus'] = + project && knownSet.has(project) ? 'managed' : 'unmanaged'; + return { + Id: net.Id, + Name: net.Name, + Driver: net.Driver, + Scope: net.Scope, + managedBy: managedStatus === 'managed' ? project! : null, + managedStatus, + }; + }); + + return { images, volumes, networks }; + } + + public async pruneManagedOnly( + target: 'images' | 'volumes' | 'networks', + knownStackNames: string[] + ): Promise<{ success: boolean; reclaimedBytes: number }> { + const knownSet = new Set(knownStackNames); + let reclaimedBytes = 0; + + if (target === 'volumes') { + const rawVolumeData = await this.docker.listVolumes(); + const rawVolumes: any[] = (this.validateApiData(rawVolumeData)).Volumes || []; + const prunable = rawVolumes.filter((v: any) => { + const project: string | undefined = v.Labels?.['com.docker.compose.project']; + return project && knownSet.has(project) && (v.UsageData?.RefCount ?? 1) === 0; + }); + for (const vol of prunable) { + try { + await this.docker.getVolume(vol.Name).remove({ force: true }); + reclaimedBytes += vol.UsageData?.Size ?? 0; + } catch (e) { + console.error(`[pruneManagedOnly] Failed to remove volume ${vol.Name}:`, e); + } + } + } else if (target === 'networks') { + const rawNetworks = await this.docker.listNetworks(); + const prunable = (rawNetworks as any[]).filter((n: any) => { + const project: string | undefined = n.Labels?.['com.docker.compose.project']; + return project && knownSet.has(project); + }); + for (const net of prunable) { + try { + await this.docker.getNetwork(net.Id).remove({ force: true }); + } catch (e) { + console.error(`[pruneManagedOnly] Failed to remove network ${net.Name}:`, e); + } + } + } else if (target === 'images') { + const allContainers = await this.docker.listContainers({ all: true }); + const unmanagedImageIds = new Set(); + for (const c of allContainers as any[]) { + const project: string | undefined = c.Labels?.['com.docker.compose.project']; + if (!project || !knownSet.has(project)) unmanagedImageIds.add(c.ImageID); + } + const rawImages = await this.docker.listImages({ all: false }); + const prunable = (rawImages as any[]).filter((img: any) => + img.Containers === 0 && !unmanagedImageIds.has(img.Id) + ); + for (const img of prunable) { + try { + await this.docker.getImage(img.Id).remove({ force: true }); + reclaimedBytes += img.Size ?? 0; + } catch (e) { + console.error(`[pruneManagedOnly] Failed to remove image ${img.Id}:`, e); + } + } + } + + return { success: true, reclaimedBytes }; + } + + public async getDiskUsageClassified(knownStackNames: string[]): Promise<{ + reclaimableImages: number; + reclaimableContainers: number; + reclaimableVolumes: number; + managedImageBytes: number; + unmanagedImageBytes: number; + managedVolumeBytes: number; + unmanagedVolumeBytes: number; + }> { + const [base, classified] = await Promise.all([ + this.getDiskUsage(), + this.getClassifiedResources(knownStackNames), + ]); + + const managedImageBytes = classified.images + .filter(i => i.managedStatus === 'managed') + .reduce((acc, i) => acc + i.Size, 0); + const unmanagedImageBytes = classified.images + .filter(i => i.managedStatus === 'unmanaged') + .reduce((acc, i) => acc + i.Size, 0); + + const rawVolumeData = await this.docker.listVolumes(); + const rawVolumes: any[] = (this.validateApiData(rawVolumeData)).Volumes || []; + const knownSet = new Set(knownStackNames); + + const managedVolumeBytes = rawVolumes + .filter((v: any) => knownSet.has(v.Labels?.['com.docker.compose.project'] ?? '')) + .reduce((acc: number, v: any) => acc + (v.UsageData?.Size ?? 0), 0); + const unmanagedVolumeBytes = rawVolumes + .filter((v: any) => !knownSet.has(v.Labels?.['com.docker.compose.project'] ?? '')) + .reduce((acc: number, v: any) => acc + (v.UsageData?.Size ?? 0), 0); + + return { ...base, managedImageBytes, unmanagedImageBytes, managedVolumeBytes, unmanagedVolumeBytes }; + } + public async removeImage(id: string) { const image = this.docker.getImage(id); await image.remove({ force: true }); diff --git a/frontend/src/components/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index 0c12a009..c1901efe 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -13,9 +13,10 @@ import { Label } from './ui/label'; interface Stats { active: number; + managed: number; + unmanaged: number; exited: number; total: number; - inactive: number; } interface MetricPoint { @@ -66,7 +67,7 @@ export default function HomeDashboard() { const [convertedYaml, setConvertedYaml] = useState(''); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [newStackName, setNewStackName] = useState(''); - const [stats, setStats] = useState({ active: 0, exited: 0, total: 0, inactive: 0 }); + const [stats, setStats] = useState({ active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 }); const [systemStats, setSystemStats] = useState(null); const [metrics, setMetrics] = useState([]); @@ -221,7 +222,9 @@ export default function HomeDashboard() {
{stats.active}
-

Currently running

+

+ {stats.managed} managed · {stats.unmanaged} external +

diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 86369c00..5e654665 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -1,40 +1,58 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { apiFetch } from '@/lib/api'; import { toast } from 'sonner'; -import { Trash2, HardDrive, Network, PackageMinus, MonitorX, PieChart as ChartIcon } from 'lucide-react'; +import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck } from 'lucide-react'; import { useNodes } from '@/context/NodeContext'; -import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'; import { formatBytes } from '@/lib/utils'; +import { cn } from '@/lib/utils'; + +// ── Interfaces ───────────────────────────────────────────────────────────────── interface UsageData { reclaimableImages: number; reclaimableContainers: number; reclaimableVolumes: number; + managedImageBytes: number; + unmanagedImageBytes: number; + managedVolumeBytes: number; + unmanagedVolumeBytes: number; } + interface DockerImage { Id: string; RepoTags: string[]; Size: number; Containers: number; + managedBy: string | null; + managedStatus: 'managed' | 'unmanaged' | 'unused'; } + interface DockerVolume { Name: string; Driver: string; Mountpoint: string; + managedBy: string | null; + managedStatus: 'managed' | 'unmanaged'; } + interface DockerNetwork { Id: string; Name: string; Driver: string; Scope: string; + managedBy: string | null; + managedStatus: 'managed' | 'unmanaged' | 'system'; } -interface OrphanContainer { + +interface UnmanagedContainer { Id: string; Names: string[]; State: string; @@ -42,40 +60,288 @@ interface OrphanContainer { Image: string; } +type ResourceFilter = 'all' | 'managed' | 'unmanaged'; +type PruneTarget = 'containers' | 'images' | 'networks' | 'volumes'; +type PruneScope = 'managed' | 'all'; + +// ── Disk Footprint Widget ────────────────────────────────────────────────────── + +interface FootprintWidgetProps { + usage: UsageData; + onFilter: (filter: ResourceFilter) => void; +} + +function FootprintWidget({ usage, onFilter }: FootprintWidgetProps) { + const [animated, setAnimated] = useState(false); + const ref = useRef(null); + + const managedBytes = usage.managedImageBytes + usage.managedVolumeBytes; + const unmanagedBytes = usage.unmanagedImageBytes + usage.unmanagedVolumeBytes; + const reclaimable = usage.reclaimableImages; + const total = managedBytes + unmanagedBytes + reclaimable; + + useEffect(() => { + // Trigger bar animation on mount + const t = setTimeout(() => setAnimated(true), 60); + return () => clearTimeout(t); + }, []); + + if (total === 0) { + return ( +
+ + No disk usage data available. +
+ ); + } + + const pct = (n: number) => `${Math.max(0, (n / total) * 100).toFixed(1)}%`; + + const segments: { bytes: number; color: string; label: string; filter: ResourceFilter | null; hoverClass: string }[] = [ + { bytes: managedBytes, color: 'bg-emerald-500', label: 'Sencho Managed', filter: 'managed', hoverClass: 'hover:bg-emerald-400' }, + { bytes: unmanagedBytes, color: 'bg-orange-500', label: 'External Projects', filter: 'unmanaged', hoverClass: 'hover:bg-orange-400' }, + { bytes: reclaimable, color: 'bg-muted-foreground/20', label: 'Reclaimable', filter: null, hoverClass: '' }, + ]; + + return ( +
+ {/* Stacked bar */} +
+ {segments.map((seg, i) => + seg.bytes > 0 ? ( +
seg.filter && onFilter(seg.filter)} + /> + ) : null + )} +
+ + {/* Legend */} +
+ {segments.map((seg, i) => + seg.bytes > 0 ? ( + + ) : null + )} +
+
+ ); +} + +// ── Filter Toggle — Segmented Control ───────────────────────────────────────── + +interface FilterToggleProps { + value: ResourceFilter; + onChange: (v: ResourceFilter) => void; + counts: { all: number; managed: number; unmanaged: number }; +} + +function FilterToggle({ value, onChange, counts }: FilterToggleProps) { + const options: { key: ResourceFilter; label: string; count: number }[] = [ + { key: 'all', label: 'All', count: counts.all }, + { key: 'managed', label: 'Managed', count: counts.managed }, + { key: 'unmanaged', label: 'External', count: counts.unmanaged }, + ]; + + return ( +
+
+ {options.map(({ key, label, count }) => ( + + ))} +
+
+ ); +} + +// ── Managed Status Badge ─────────────────────────────────────────────────────── + +function ManagedBadge({ status, managedBy }: { + status: 'managed' | 'unmanaged' | 'unused' | 'system'; + managedBy: string | null; +}) { + if (status === 'managed') { + return ( + + + {managedBy} + + ); + } + if (status === 'unmanaged') { + return ( + + + External + + ); + } + if (status === 'system') { + return ( + + + System + + ); + } + return null; +} + +// ── Quick Clean Prune Button ─────────────────────────────────────────────────── + +interface PruneButtonProps { + target: PruneTarget; + icon: React.ReactNode; + label: string; + accentClass: string; + onManaged: () => void; + onAll: () => void; +} + +function PruneButton({ target, icon, label, accentClass, onManaged, onAll }: PruneButtonProps) { + return ( +
+ + {target !== 'containers' && ( + + + + + + + + All Docker (includes external) + + + + )} +
+ ); +} + +// ── Table Skeleton ───────────────────────────────────────────────────────────── + +function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) { + return ( + + {Array.from({ length: rows }).map((_, r) => ( + + {Array.from({ length: cols }).map((_, c) => ( + + + + ))} + + ))} + + ); +} + +// ── Main Component ───────────────────────────────────────────────────────────── + export default function ResourcesView() { const { activeNode } = useNodes(); const [usage, setUsage] = useState(null); const [images, setImages] = useState([]); const [volumes, setVolumes] = useState([]); const [networks, setNetworks] = useState([]); - const [orphans, setOrphans] = useState>({}); + const [orphans, setOrphans] = useState>({}); const [isLoading, setIsLoading] = useState(true); const [isActioning, setIsActioning] = useState(false); - // Modal states - const [confirmPruneType, setConfirmPruneType] = useState<'containers' | 'images' | 'networks' | 'volumes' | null>(null); - const [confirmDelete, setConfirmDelete] = useState<{ type: 'images' | 'volumes' | 'networks', id: string, name?: string } | null>(null); + // Filter state + const [imageFilter, setImageFilter] = useState('all'); + const [volumeFilter, setVolumeFilter] = useState('all'); + const [networkFilter, setNetworkFilter] = useState('all'); - // Ghost hunting state + // 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); + + // Unmanaged container state const [selectedOrphans, setSelectedOrphans] = useState([]); const [bulkPurgeConfirm, setBulkPurgeConfirm] = useState(false); const fetchAllData = async () => { setIsLoading(true); try { - const [usageRes, imagesRes, volumesRes, networksRes, orphansRes] = await Promise.all([ + const [usageRes, resourcesRes, orphansRes] = await Promise.all([ apiFetch('/system/docker-df'), - apiFetch('/system/images'), - apiFetch('/system/volumes'), - apiFetch('/system/networks'), + apiFetch('/system/resources'), apiFetch('/system/orphans'), ]); setUsage(await usageRes.json()); - setImages(await imagesRes.json()); - setVolumes(await volumesRes.json()); - setNetworks(await networksRes.json()); + const resources = await resourcesRes.json(); + setImages(resources.images ?? []); + setVolumes(resources.volumes ?? []); + setNetworks(resources.networks ?? []); setOrphans(await orphansRes.json()); setSelectedOrphans([]); } catch (err) { @@ -86,30 +352,29 @@ export default function ResourcesView() { } }; - useEffect(() => { - fetchAllData(); - }, []); + useEffect(() => { fetchAllData(); }, [activeNode]); const handlePrune = async () => { - if (!confirmPruneType) return; + if (!confirmPrune) return; setIsActioning(true); try { const res = await apiFetch('/system/prune/system', { method: 'POST', - body: JSON.stringify({ target: confirmPruneType }) + body: JSON.stringify({ target: confirmPrune.target, scope: confirmPrune.scope }) }); const data = await res.json(); - if (data.reclaimedBytes !== undefined) { - toast.success(`Pruned ${confirmPruneType}. Reclaimed ${formatBytes(data.reclaimedBytes)}.`); - } else { - toast.success(`Pruned ${confirmPruneType}.`); - } + const scopeLabel = confirmPrune.scope === 'managed' ? 'Sencho-managed' : 'all'; + toast.success( + data.reclaimedBytes !== undefined + ? `Pruned ${scopeLabel} ${confirmPrune.target}. Reclaimed ${formatBytes(data.reclaimedBytes)}.` + : `Pruned ${scopeLabel} ${confirmPrune.target}.` + ); await fetchAllData(); } catch { - toast.error(`Failed to prune ${confirmPruneType}`); + toast.error(confirmPrune ? `Failed to prune ${confirmPrune.target}` : 'Prune failed'); } finally { setIsActioning(false); - setConfirmPruneType(null); + setConfirmPrune(null); } }; @@ -132,15 +397,13 @@ export default function ResourcesView() { } }; - const toggleOrphanSelection = (containerId: string) => { - setSelectedOrphans(prev => prev.includes(containerId) ? prev.filter(id => id !== containerId) : [...prev, containerId]); - }; + 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); - if (selectedOrphans.length === allIds.length) setSelectedOrphans([]); - else setSelectedOrphans(allIds); + setSelectedOrphans(selectedOrphans.length === allIds.length ? [] : allIds); }; const handlePurgeOrphans = async () => { @@ -151,7 +414,7 @@ export default function ResourcesView() { body: JSON.stringify({ containerIds: selectedOrphans }) }); if (!res.ok) throw new Error(); - toast.success(`Purged ${selectedOrphans.length} ghost container(s)`); + toast.success(`Purged ${selectedOrphans.length} unmanaged container(s)`); setBulkPurgeConfirm(false); await fetchAllData(); } catch { @@ -161,120 +424,139 @@ export default function ResourcesView() { } }; - const chartData = [ - { name: 'Unused Images', value: usage?.reclaimableImages || 0, color: '#3b82f6' }, - { name: 'Unused Volumes', value: usage?.reclaimableVolumes || 0, color: '#a855f7' }, - { name: 'Stopped Containers', value: usage?.reclaimableContainers || 0, color: '#f97316' }, - ]; + // Derived filtered lists + const filteredImages = images.filter(img => + imageFilter === 'managed' ? img.managedStatus === 'managed' : + imageFilter === 'unmanaged' ? img.managedStatus !== 'managed' : true + ); + const filteredVolumes = volumes.filter(vol => + volumeFilter === 'managed' ? vol.managedStatus === 'managed' : + volumeFilter === 'unmanaged' ? vol.managedStatus !== 'managed' : true + ); + const filteredNetworks = networks.filter(net => + networkFilter === 'managed' ? net.managedStatus === 'managed' : + networkFilter === 'unmanaged' ? net.managedStatus !== 'managed' : true + ); - const totalReclaimable = chartData.reduce((acc, curr) => acc + curr.value, 0); - - const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ name: string; value: number }> }) => { - if (active && payload && payload.length) { - return ( -
-

{payload[0].name}

-

{formatBytes(payload[0].value)}

-
- ); - } - return null; + const handleFootprintFilter = (filter: ResourceFilter) => { + setImageFilter(filter); + setVolumeFilter(filter); }; - if (isLoading && !usage) { - return
Loading resources...
; - } - return ( -
-
- -

Resources Hub

+
+ + {/* Header */} +
+ +

Resources Hub

{activeNode?.type === 'remote' && ( - - {activeNode.name} + — {activeNode.name} )}
-
- - - - Reclaimable Space + {/* Top row: Footprint + Quick Clean */} +
+ + {/* Disk Footprint */} + + + + Docker Disk Footprint + + Click a segment to filter the tabs below + - - {totalReclaimable > 0 ? ( - - - - {chartData.map((entry, index) => ( - - ))} - - } /> - - + + {usage ? ( + ) : ( -
- Your system is clean +
+ +
+ + + +
)} -
- {chartData.filter(d => d.value > 0).map((entry, index) => ( -
- - {entry.name} -
- ))} -
- - - Quick Clean - Free up disk space with bulk prune operations + {/* Quick Clean */} + + + + Quick Clean + + + Primary actions target Sencho-managed resources only. + Use for all-Docker operations. + -
- - - - +
+ } + label="Prune Unused Images" + accentClass="text-blue-500" + onManaged={() => setConfirmPrune({ target: 'images', scope: 'managed' })} + onAll={() => setConfirmPrune({ target: 'images', scope: 'all' })} + /> + } + label="Prune Unused Volumes" + accentClass="text-purple-500" + onManaged={() => setConfirmPrune({ target: 'volumes', scope: 'managed' })} + onAll={() => setConfirmPrune({ target: 'volumes', scope: 'all' })} + /> + } + label="Prune Dead Networks" + accentClass="text-emerald-500" + onManaged={() => setConfirmPrune({ target: 'networks', scope: 'managed' })} + onAll={() => setConfirmPrune({ target: 'networks', scope: 'all' })} + /> + } + label="Purge Unmanaged Containers" + accentClass="text-orange-500" + onManaged={() => setConfirmPrune({ target: 'containers', scope: 'managed' })} + onAll={() => setConfirmPrune({ target: 'containers', scope: 'all' })} + />
- -
- - Images - Volumes - Networks - - Ghost Containers + {/* Resource Tabs */} + +
+ + {(['images', 'volumes', 'networks'] as const).map(tab => ( + + {tab} + + ))} + + Unmanaged {totalOrphansCount > 0 && ( - + {totalOrphansCount} )} @@ -282,158 +564,237 @@ export default function ResourcesView() {
-
- +
+ + {/* Images */} + + i.managedStatus === 'managed').length, + unmanaged: images.filter(i => i.managedStatus !== 'managed').length, + }} + /> - - ID - Repository:Tag - Size - Status - Action + + ID + Repository:Tag + Size + Status + Action - - {images.length === 0 ? ( - No images found. - ) : images.map((img) => ( - - {img.Id.split(':')[1]?.substring(0, 12)} - {img.RepoTags?.[0] || ':'} - {formatBytes(img.Size)} - - 0 ? "default" : "secondary"}> - {img.Containers > 0 ? "In Use" : "Unused"} - - - - - - - ))} - + {isLoading ? : ( + + {filteredImages.length === 0 ? ( + No images found. + ) : filteredImages.map((img, i) => ( + + {img.Id.split(':')[1]?.substring(0, 12)} + {img.RepoTags?.[0] || ':'} + {formatBytes(img.Size)} + +
+ 0 ? "default" : "secondary"} className="text-[10px] h-5"> + {img.Containers > 0 ? "In Use" : "Unused"} + + +
+
+ + + +
+ ))} +
+ )}
- + {/* Volumes */} + + v.managedStatus === 'managed').length, + unmanaged: volumes.filter(v => v.managedStatus !== 'managed').length, + }} + /> - - Name - Driver - Mountpoint - Action + + Name + Driver + Mountpoint + Status + Action - - {volumes.length === 0 ? ( - No volumes found. - ) : volumes.map((vol) => ( - - {vol.Name} - {vol.Driver} - {vol.Mountpoint} - - - - - ))} - + {isLoading ? : ( + + {filteredVolumes.length === 0 ? ( + No volumes found. + ) : filteredVolumes.map((vol, i) => ( + + {vol.Name} + {vol.Driver} + {vol.Mountpoint} + + + + + + ))} + + )}
- + {/* Networks */} + + n.managedStatus === 'managed').length, + unmanaged: networks.filter(n => n.managedStatus !== 'managed').length, + }} + /> - - ID - Name - Driver - Scope - Action + + ID + Name + Driver + Scope + Status + Action - - {networks.length === 0 ? ( - No networks found. - ) : networks.map((net) => ( - - {net.Id.substring(0, 12)} - {net.Name} - {net.Driver} - {net.Scope} - - - - - ))} - + {isLoading ? : ( + + {filteredNetworks.length === 0 ? ( + No networks found. + ) : filteredNetworks.map((net, i) => ( + + {net.Id.substring(0, 12)} + {net.Name} + {net.Driver} + {net.Scope} + + + + + + ))} + + )}
- -
-
+ {/* Unmanaged Containers */} + +
+
0} - className="rounded border-gray-300 focus:ring-primary h-4 w-4 ml-2" + className="rounded border-border focus:ring-ring h-4 w-4 accent-foreground" /> - Select All + Select all
{totalOrphansCount === 0 ? ( -
- -

No orphaned containers detected.

-

Your system is clean!

+
+
+ +
+

No unmanaged containers

+

All running containers are managed by Sencho.

) : ( -
- {Object.entries(orphans).map(([project, containers]) => ( -
-
- - Project: {project} +
+ {Object.entries(orphans).map(([project, containers], gi) => ( +
+ {/* Project header */} +
+ + External Project: + {project} + {containers.length} container{containers.length !== 1 ? 's' : ''}
-
- {containers.map((container: OrphanContainer) => ( -
+
+ {containers.map((container: UnmanagedContainer) => ( +
toggleOrphanSelection(container.Id)} - className="rounded border-gray-300 h-4 w-4" + onChange={() => toggleOrphan(container.Id)} + className="rounded border-border h-4 w-4 accent-foreground" />
- + {container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12)} - + {container.State}
-
- Image: {container.Image} +
+ {container.Image}
@@ -447,31 +808,53 @@ export default function ResourcesView() {
- {/* Prune Bulk Action Confirm Dialog */} - !open && setConfirmPruneType(null)}> - + {/* ── Dialogs ── */} + + {/* Prune Confirm */} + !open && setConfirmPrune(null)}> + - Prune {confirmPruneType} - - Are you sure you want to prune all unused {confirmPruneType}? This action cannot be undone and will permanently free up disk space. - + {confirmPrune?.scope === 'all' ? ( + <> + + + Prune All Docker {confirmPrune?.target} + + + This will prune all unused {confirmPrune?.target} from the Docker daemon — + including those from external projects not managed by Sencho. This cannot be undone. + + + ) : ( + <> + Prune Sencho-Managed {confirmPrune?.target} + + Only unused {confirmPrune?.target} belonging to your Sencho stacks will be removed. + External Docker resources are not affected. + + + )} Cancel - - {isActioning ? 'Pruning...' : 'Prune'} + + {isActioning ? 'Pruning...' : confirmPrune?.scope === 'all' ? 'Prune All' : 'Prune'} - {/* Granular Delete Confirm Dialog */} + {/* Delete Confirm */} !open && setConfirmDelete(null)}> Delete {confirmDelete?.type.slice(0, -1)} - Are you sure you want to delete {confirmDelete?.name || confirmDelete?.id.substring(0, 12)}? This cannot be undone. + Permanently delete {confirmDelete?.name || confirmDelete?.id.substring(0, 12)}? This cannot be undone. @@ -483,13 +866,14 @@ export default function ResourcesView() { - {/* Ghost Container Purge Confirm Dialog */} + {/* Unmanaged Container Purge Confirm */} - Purge Selected Ghosts + Purge Selected Unmanaged Containers - Are you sure you want to permanently remove the {selectedOrphans.length} selected orphan container(s)? + Permanently remove {selectedOrphans.length} container{selectedOrphans.length !== 1 ? 's' : ''} from external projects? + This will force-stop and remove them. This cannot be undone. From 0d5dc574a42e6550bf0e58cc5f60988a970201db Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Sun, 22 Mar 2026 03:21:36 -0400 Subject: [PATCH 2/2] fix(dashboard): correct stale Stats reset with inactive field --- frontend/src/components/HomeDashboard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index c1901efe..fc1d4927 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -73,7 +73,7 @@ export default function HomeDashboard() { // Fetch container stats - re-runs when active node changes so stale data is cleared immediately useEffect(() => { - setStats({ active: 0, exited: 0, total: 0, inactive: 0 }); + setStats({ active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 }); const fetchStats = async () => { try { const res = await apiFetch('/stats');