diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 8a4ddfe0..a6f3b573 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -51,6 +51,8 @@ export interface ClassifiedVolume { Name: string; Driver: string; Mountpoint: string; + Size: number; + CreatedAt: string | null; managedBy: string | null; managedStatus: 'managed' | 'unmanaged'; } @@ -124,40 +126,53 @@ class DockerController { public async getDiskUsage() { const df = await this.docker.df(); - const calculateReclaimableContainers = (items: any[]) => { - if (!items || !Array.isArray(items)) return 0; - return items.filter(i => i.State !== 'running').reduce((acc, item) => { + const reclaimableContainers = (items: any[]) => { + if (!items || !Array.isArray(items)) return { bytes: 0, count: 0 }; + const reclaimable = items.filter(i => i.State !== 'running'); + const bytes = reclaimable.reduce((acc, item) => { let size = item.SizeRw || item.SizeRootFs || 0; if (item.UsageData && typeof item.UsageData.Size === 'number') { size = item.UsageData.Size; } return acc + size; }, 0); + return { bytes, count: reclaimable.length }; }; - const calculateReclaimableImages = (items: any[]) => { - if (!items || !Array.isArray(items)) return 0; - return items.filter(i => i.Containers === 0).reduce((acc, item) => { + const reclaimableImages = (items: any[]) => { + if (!items || !Array.isArray(items)) return { bytes: 0, count: 0 }; + const reclaimable = items.filter(i => i.Containers === 0); + const bytes = reclaimable.reduce((acc, item) => { let size = item.VirtualSize || item.Size || item.SharedSize || 0; if (item.UsageData && typeof item.UsageData.Size === 'number') { size = item.UsageData.Size; } return acc + size; }, 0); + return { bytes, count: reclaimable.length }; }; - const calculateReclaimableVolumes = (items: any[]) => { - if (!items || !Array.isArray(items)) return 0; - return items.filter(i => i.UsageData?.RefCount === 0).reduce((acc, item) => { + const reclaimableVolumes = (items: any[]) => { + if (!items || !Array.isArray(items)) return { bytes: 0, count: 0 }; + const reclaimable = items.filter(i => i.UsageData?.RefCount === 0); + const bytes = reclaimable.reduce((acc, item) => { const size = item.UsageData?.Size || 0; return acc + size; }, 0); + return { bytes, count: reclaimable.length }; }; + const images = df.Images ? reclaimableImages(df.Images) : { bytes: 0, count: 0 }; + const containers = df.Containers ? reclaimableContainers(df.Containers) : { bytes: 0, count: 0 }; + const volumes = df.Volumes ? reclaimableVolumes(df.Volumes) : { bytes: 0, count: 0 }; + return { - reclaimableImages: df.Images ? calculateReclaimableImages(df.Images) : 0, - reclaimableContainers: df.Containers ? calculateReclaimableContainers(df.Containers) : 0, - reclaimableVolumes: df.Volumes ? calculateReclaimableVolumes(df.Volumes) : 0, + reclaimableImages: images.bytes, + reclaimableContainers: containers.bytes, + reclaimableVolumes: volumes.bytes, + reclaimableImageCount: images.count, + reclaimableContainerCount: containers.count, + reclaimableVolumeCount: volumes.count, }; } @@ -263,6 +278,8 @@ class DockerController { Name: vol.Name, Driver: vol.Driver, Mountpoint: vol.Mountpoint, + Size: vol.UsageData?.Size ?? 0, + CreatedAt: vol.CreatedAt ?? null, managedBy: stack, managedStatus, }; @@ -358,6 +375,9 @@ class DockerController { reclaimableImages: number; reclaimableContainers: number; reclaimableVolumes: number; + reclaimableImageCount: number; + reclaimableContainerCount: number; + reclaimableVolumeCount: number; managedImageBytes: number; unmanagedImageBytes: number; managedVolumeBytes: number; diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index 291720c1..84d34276 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -6,20 +6,26 @@ description: Browse, filter, and clean up Docker images, volumes, networks, and The **Resources** tab gives you a full view of everything Docker is storing on your host, broken down by type and ownership. - Resources Hub showing disk footprint, quick clean panel, and images table + Resources Hub with reclaim hero, disk footprint treemap, and quick clean panel +## Reclaim hero + +When there is reclaimable disk space (unused images, stopped containers, or dangling volumes), an amber banner leads the view with the total amount you can free and a breakdown of what contributes to it. Click **Review & prune** to jump straight into a confirmation dialog scoped to every reclaimable resource at once. + +The hero stays hidden when there is nothing to reclaim, keeping the view focused on the rest of your inventory. + ## Docker disk footprint -The stacked bar at the top visualizes how your Docker disk usage is distributed: +Below the hero, a three-tile treemap shows how your Docker disk usage is distributed: | Segment | Meaning | |---------|---------| -| **Sencho Managed** (green) | Images used by stacks in your `COMPOSE_DIR` | -| **External Projects** (orange) | Images used by Docker projects outside Sencho | -| **Reclaimable** (gray) | Unused images and dangling layers safe to delete | +| **Sencho Managed** (green) | Images and volumes used by stacks in your `COMPOSE_DIR` | +| **External** (amber) | Images and volumes used by Docker projects outside Sencho | +| **Reclaimable** (neutral) | Unused images, stopped containers, and dangling volumes safe to delete | -Click any segment to automatically filter the tabs below to that category. +Tile area is proportional to bytes. Click any tile to filter the tabs below to that category. ## Quick Clean panel @@ -55,6 +61,8 @@ Click the trash icon on any row to delete an individual image. Sencho will warn Lists all Docker volumes. Columns: name, driver, mount point, size, and managed status. +When any volumes are present, a two-card landing strip above the table highlights the **Largest 5** volumes by size and **Recently changed** volumes from the last 24 hours. This makes it easy to spot growing volumes or newly created data at a glance. + **Filter buttons:** `All` / `Managed` / `External` diff --git a/docs/images/resources/resources-reclaim.png b/docs/images/resources/resources-reclaim.png new file mode 100644 index 00000000..3a671702 Binary files /dev/null and b/docs/images/resources/resources-reclaim.png differ diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index dea679ba..10ee9113 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from "@/components/ui/tabs"; @@ -33,15 +33,33 @@ import { cn } from '@/lib/utils'; import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; import type { SenchoOpenLogsDetail } from '@/lib/events'; import { lazy, Suspense } from 'react'; +import { ReclaimHero } from './resources/ReclaimHero'; +import { FootprintTreemap } from './resources/FootprintTreemap'; +import { TabLanding, type TabLandingEntry } from './resources/TabLanding'; const NetworkTopologyView = lazy(() => import('./NetworkTopologyView')); +const RECENT_WINDOW_MS = 24 * 60 * 60 * 1000; + +function ageLabel(ms: number): string { + const minutes = Math.max(0, Math.round((Date.now() - ms) / 60000)); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + return `${days}d ago`; +} + // ── Interfaces ───────────────────────────────────────────────────────────────── interface UsageData { reclaimableImages: number; reclaimableContainers: number; reclaimableVolumes: number; + reclaimableImageCount: number; + reclaimableContainerCount: number; + reclaimableVolumeCount: number; managedImageBytes: number; unmanagedImageBytes: number; managedVolumeBytes: number; @@ -61,6 +79,8 @@ interface DockerVolume { Name: string; Driver: string; Mountpoint: string; + Size: number; + CreatedAt: string | null; managedBy: string | null; managedStatus: 'managed' | 'unmanaged'; } @@ -112,96 +132,6 @@ 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 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-success', label: 'Sencho Managed', filter: 'managed', hoverClass: 'hover:bg-success/80' }, - { bytes: unmanagedBytes, color: 'bg-warning', label: 'External Projects', filter: 'unmanaged', hoverClass: 'hover:bg-warning/80' }, - { 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 { @@ -700,6 +630,42 @@ export default function ResourcesView() { setVolumeFilter(filter); }; + const treemapFilterToResourceFilter = (filter: 'managed' | 'unmanaged' | 'reclaimable'): ResourceFilter => { + if (filter === 'managed') return 'managed'; + if (filter === 'unmanaged') return 'unmanaged'; + return 'unmanaged'; + }; + + const totalReclaimableBytes = (usage?.reclaimableImages ?? 0) + + (usage?.reclaimableContainers ?? 0) + + (usage?.reclaimableVolumes ?? 0); + + const handleReviewAndPrune = () => { + setConfirmPrune({ target: 'images', scope: 'all' }); + }; + + const volumeLandings = useMemo(() => { + const largest: TabLandingEntry[] = [...volumes] + .sort((a, b) => b.Size - a.Size) + .slice(0, 5) + .map(vol => ({ + key: vol.Name, + primary: vol.Name, + secondary: vol.Size > 0 ? formatBytes(vol.Size) : '-', + })); + const now = Date.now(); + const recent: TabLandingEntry[] = volumes + .filter(v => !!v.CreatedAt && now - new Date(v.CreatedAt).getTime() <= RECENT_WINDOW_MS) + .sort((a, b) => new Date(b.CreatedAt ?? 0).getTime() - new Date(a.CreatedAt ?? 0).getTime()) + .slice(0, 5) + .map(vol => ({ + key: vol.Name, + primary: vol.Name, + secondary: vol.CreatedAt ? ageLabel(new Date(vol.CreatedAt).getTime()) : '-', + })); + return { largest, recent }; + }, [volumes]); + return (
@@ -708,7 +674,7 @@ export default function ResourcesView() {

Resources Hub

{activeNode?.type === 'remote' && ( - - {activeNode.name} + · {activeNode.name} )} {trivy.available && isPaid && ( + ); +} + +export function FootprintTreemap({ + managedBytes, + unmanagedBytes, + reclaimableBytes, + onFilter, +}: FootprintTreemapProps) { + const total = managedBytes + unmanagedBytes + reclaimableBytes; + + if (total === 0) { + return ( +
+ + No disk usage data available. +
+ ); + } + + const share = (n: number) => (n / total) * 100; + + return ( +
+ onFilter('managed') : undefined} + className="row-span-2" + /> + onFilter('unmanaged') : undefined} + className="col-span-2" + /> + onFilter('reclaimable') : undefined} + className="col-span-2" + /> +
+ ); +} diff --git a/frontend/src/components/resources/ReclaimHero.tsx b/frontend/src/components/resources/ReclaimHero.tsx new file mode 100644 index 00000000..ccf7f24b --- /dev/null +++ b/frontend/src/components/resources/ReclaimHero.tsx @@ -0,0 +1,68 @@ +import { useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { Sparkles } from 'lucide-react'; +import { formatBytes } from '@/lib/utils'; + +interface ReclaimHeroProps { + bytes: number; + imageCount: number; + containerCount: number; + volumeCount: number; + onReview: () => void; + disabled?: boolean; +} + +export function ReclaimHero({ + bytes, + imageCount, + containerCount, + volumeCount, + onReview, + disabled, +}: ReclaimHeroProps) { + const composition = useMemo(() => { + const parts: string[] = []; + if (imageCount > 0) parts.push(`${imageCount} ${imageCount === 1 ? 'unused image' : 'unused images'}`); + if (containerCount > 0) parts.push(`${containerCount} ${containerCount === 1 ? 'stopped container' : 'stopped containers'}`); + if (volumeCount > 0) parts.push(`${volumeCount} ${volumeCount === 1 ? 'dangling volume' : 'dangling volumes'}`); + return parts.join(' · '); + }, [imageCount, containerCount, volumeCount]); + + if (bytes <= 0) { + return null; + } + + return ( +
+
+
+
+
+ + You can reclaim + + + {formatBytes(bytes)} + + {composition ? ( + + {composition} + + ) : null} +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/resources/TabLanding.tsx b/frontend/src/components/resources/TabLanding.tsx new file mode 100644 index 00000000..43f6e3a5 --- /dev/null +++ b/frontend/src/components/resources/TabLanding.tsx @@ -0,0 +1,109 @@ +import { useMemo } from 'react'; +import { cn } from '@/lib/utils'; + +export interface TabLandingEntry { + key: string; + primary: string; + secondary: string; +} + +interface LandingCardProps { + label: string; + subtitle?: string; + entries: TabLandingEntry[]; + emptyLabel: string; + onEntryClick?: (entry: TabLandingEntry) => void; + accent?: 'brand' | 'warning'; +} + +function LandingCard({ label, subtitle, entries, emptyLabel, onEntryClick, accent = 'brand' }: LandingCardProps) { + const accentClass = accent === 'warning' ? 'text-warning' : 'text-brand'; + return ( +
+
+ + {label} + + {subtitle ? ( + {subtitle} + ) : null} +
+ {entries.length === 0 ? ( +
+ {emptyLabel} +
+ ) : ( +
    + {entries.map(entry => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +interface TabLandingProps { + largestLabel?: string; + largestSubtitle?: string; + largestEntries: TabLandingEntry[]; + largestEmpty: string; + recentLabel?: string; + recentSubtitle?: string; + recentEntries: TabLandingEntry[]; + recentEmpty: string; + onLargestClick?: (entry: TabLandingEntry) => void; + onRecentClick?: (entry: TabLandingEntry) => void; +} + +export function TabLanding({ + largestLabel = 'Largest 5', + largestSubtitle, + largestEntries, + largestEmpty, + recentLabel = 'Recently changed', + recentSubtitle, + recentEntries, + recentEmpty, + onLargestClick, + onRecentClick, +}: TabLandingProps) { + const largestTop = useMemo(() => largestEntries.slice(0, 5), [largestEntries]); + const recentTop = useMemo(() => recentEntries.slice(0, 5), [recentEntries]); + + return ( +
+ + +
+ ); +}