mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
feat(resources): lead with reclaimable disk banner and per-tab landings (#678)
Replaces the stacked bar + legend with a reclaim-first layout: an amber hero banner surfaces the total reclaimable bytes and breakdown (unused images, stopped containers, dangling volumes) with a one-click review & prune CTA; a three-tile treemap replaces the stacked bar with proportional areas for Sencho-managed, External, and Reclaimable; and the Volumes tab gets a two-card landing highlighting the largest volumes by size and recently changed ones.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/resources/resources-overview.png" alt="Resources Hub showing disk footprint, quick clean panel, and images table" />
|
||||
<img src="/images/resources/resources-reclaim.png" alt="Resources Hub with reclaim hero, disk footprint treemap, and quick clean panel" />
|
||||
</Frame>
|
||||
|
||||
## 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`
|
||||
|
||||
<Warning>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
@@ -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 (
|
||||
<div className="flex flex-col items-center justify-center h-28 text-muted-foreground text-sm gap-2 animate-in fade-in-0 duration-300">
|
||||
<ShieldCheck className="w-8 h-8 opacity-40" />
|
||||
<span>No disk usage data available.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4 animate-in fade-in-0 duration-300">
|
||||
{/* Stacked bar */}
|
||||
<div className="relative flex h-4 w-full rounded-full overflow-hidden bg-muted gap-px">
|
||||
{segments.map((seg, i) =>
|
||||
seg.bytes > 0 ? (
|
||||
<div
|
||||
key={i}
|
||||
title={`${seg.label}: ${formatBytes(seg.bytes)}`}
|
||||
className={cn(
|
||||
seg.color, seg.hoverClass,
|
||||
'transition-all duration-700 ease-out',
|
||||
seg.filter ? 'cursor-pointer' : 'cursor-default',
|
||||
)}
|
||||
style={{
|
||||
width: animated ? pct(seg.bytes) : '0%',
|
||||
transitionDelay: `${i * 80}ms`,
|
||||
}}
|
||||
onClick={() => seg.filter && onFilter(seg.filter)}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="space-y-2.5">
|
||||
{segments.map((seg, i) =>
|
||||
seg.bytes > 0 ? (
|
||||
<button
|
||||
key={i}
|
||||
disabled={!seg.filter}
|
||||
onClick={() => seg.filter && onFilter(seg.filter)}
|
||||
className={cn(
|
||||
'flex items-center justify-between w-full text-sm group rounded-md px-1 py-0.5 -mx-1 transition-colors duration-150',
|
||||
seg.filter ? 'cursor-pointer hover:bg-muted/60' : 'cursor-default',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
<span className={cn('w-2.5 h-2.5 rounded-sm shrink-0', seg.color)} />
|
||||
<span className="font-medium text-xs tracking-wide">{seg.label}</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{formatBytes(seg.bytes)}
|
||||
</span>
|
||||
</button>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="p-6 h-full overflow-auto text-foreground flex flex-col gap-6 animate-in fade-in-0 duration-300">
|
||||
|
||||
@@ -708,7 +674,7 @@ export default function ResourcesView() {
|
||||
<HardDrive className="w-5 h-5 text-muted-foreground" />
|
||||
<h1 className="text-xl font-medium tracking-tight">Resources Hub</h1>
|
||||
{activeNode?.type === 'remote' && (
|
||||
<span className="text-sm text-muted-foreground">- {activeNode.name}</span>
|
||||
<span className="text-sm text-muted-foreground">· {activeNode.name}</span>
|
||||
)}
|
||||
{trivy.available && isPaid && (
|
||||
<Button
|
||||
@@ -729,6 +695,18 @@ export default function ResourcesView() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reclaim hero */}
|
||||
{usage && isAdmin && (
|
||||
<ReclaimHero
|
||||
bytes={totalReclaimableBytes}
|
||||
imageCount={usage.reclaimableImageCount}
|
||||
containerCount={usage.reclaimableContainerCount}
|
||||
volumeCount={usage.reclaimableVolumeCount}
|
||||
onReview={handleReviewAndPrune}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Top row: Footprint + Quick Clean */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
@@ -744,16 +722,14 @@ export default function ResourcesView() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{usage ? (
|
||||
<FootprintWidget usage={usage} onFilter={handleFootprintFilter} />
|
||||
<FootprintTreemap
|
||||
managedBytes={usage.managedImageBytes + usage.managedVolumeBytes}
|
||||
unmanagedBytes={usage.unmanagedImageBytes + usage.unmanagedVolumeBytes}
|
||||
reclaimableBytes={totalReclaimableBytes}
|
||||
onFilter={(f) => handleFootprintFilter(treemapFilterToResourceFilter(f))}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-full rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-[150px] w-full rounded-md" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -936,6 +912,16 @@ export default function ResourcesView() {
|
||||
|
||||
{/* Volumes */}
|
||||
<TabsContent value="volumes" className="m-0 border-0 p-0 animate-in fade-in-0 duration-200">
|
||||
{!isLoading && volumes.length > 0 && (
|
||||
<TabLanding
|
||||
largestSubtitle={`by size · ${volumes.length} total`}
|
||||
largestEntries={volumeLandings.largest}
|
||||
largestEmpty="No volumes on disk."
|
||||
recentSubtitle="last 24h"
|
||||
recentEntries={volumeLandings.recent}
|
||||
recentEmpty="No volumes created in the last 24h."
|
||||
/>
|
||||
)}
|
||||
<FilterToggle
|
||||
value={volumeFilter}
|
||||
onChange={setVolumeFilter}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { formatBytes, cn } from '@/lib/utils';
|
||||
|
||||
export type TreemapFilter = 'managed' | 'unmanaged' | 'reclaimable';
|
||||
|
||||
interface FootprintTreemapProps {
|
||||
managedBytes: number;
|
||||
unmanagedBytes: number;
|
||||
reclaimableBytes: number;
|
||||
onFilter?: (filter: TreemapFilter) => void;
|
||||
}
|
||||
|
||||
interface TileProps {
|
||||
label: string;
|
||||
bytes: number;
|
||||
share: number;
|
||||
tone: 'managed' | 'unmanaged' | 'reclaimable';
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Tile({ label, bytes, share, tone, onClick, className }: TileProps) {
|
||||
const toneBase = tone === 'managed'
|
||||
? 'bg-success/[0.08] border-success/25 text-success'
|
||||
: tone === 'unmanaged'
|
||||
? 'bg-warning/[0.08] border-warning/25 text-warning'
|
||||
: 'bg-muted/30 border-dashed border-border text-stat-subtitle';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={!onClick}
|
||||
className={cn(
|
||||
'group relative flex flex-col justify-between rounded-md border p-3 text-left transition-colors',
|
||||
toneBase,
|
||||
onClick ? 'hover:bg-muted/20 cursor-pointer' : 'cursor-default',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em]">{label}</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono tabular-nums text-xl leading-none text-stat-value">
|
||||
{formatBytes(bytes)}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] tabular-nums text-stat-subtitle">
|
||||
{share.toFixed(0)}% of footprint
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FootprintTreemap({
|
||||
managedBytes,
|
||||
unmanagedBytes,
|
||||
reclaimableBytes,
|
||||
onFilter,
|
||||
}: FootprintTreemapProps) {
|
||||
const total = managedBytes + unmanagedBytes + reclaimableBytes;
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-28 text-stat-subtitle text-sm gap-2">
|
||||
<ShieldCheck className="w-8 h-8 opacity-40" />
|
||||
<span>No disk usage data available.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const share = (n: number) => (n / total) * 100;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[2fr_1fr_1fr] grid-rows-2 gap-2 h-[150px]">
|
||||
<Tile
|
||||
label="Sencho managed"
|
||||
bytes={managedBytes}
|
||||
share={share(managedBytes)}
|
||||
tone="managed"
|
||||
onClick={onFilter ? () => onFilter('managed') : undefined}
|
||||
className="row-span-2"
|
||||
/>
|
||||
<Tile
|
||||
label="External"
|
||||
bytes={unmanagedBytes}
|
||||
share={share(unmanagedBytes)}
|
||||
tone="unmanaged"
|
||||
onClick={onFilter ? () => onFilter('unmanaged') : undefined}
|
||||
className="col-span-2"
|
||||
/>
|
||||
<Tile
|
||||
label="Reclaimable"
|
||||
bytes={reclaimableBytes}
|
||||
share={share(reclaimableBytes)}
|
||||
tone="reclaimable"
|
||||
onClick={onFilter ? () => onFilter('reclaimable') : undefined}
|
||||
className="col-span-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="relative shrink-0 overflow-hidden rounded-lg border border-warning/25 border-t-warning/35 bg-card shadow-card-bevel">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-warning/[0.08] via-warning/[0.02] to-transparent" />
|
||||
<div className="absolute inset-y-0 left-0 w-[3px] bg-warning" />
|
||||
<div className="relative grid grid-cols-[1fr_auto] items-center gap-6 py-5 pl-7 pr-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.22em] text-warning">
|
||||
You can reclaim
|
||||
</span>
|
||||
<span className="font-display italic text-4xl leading-none tracking-tight text-stat-value">
|
||||
{formatBytes(bytes)}
|
||||
</span>
|
||||
{composition ? (
|
||||
<span className="font-mono text-[11px] text-stat-subtitle/90">
|
||||
{composition}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 border-warning/40 text-warning hover:bg-warning/10 hover:text-warning hover:border-warning/60"
|
||||
onClick={onReview}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" strokeWidth={1.5} />
|
||||
Review & prune
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col rounded-md border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
|
||||
<div className="flex items-baseline gap-2 px-3 pt-2.5 pb-1.5">
|
||||
<span className={cn('font-mono text-[10px] uppercase tracking-[0.22em]', accentClass)}>
|
||||
{label}
|
||||
</span>
|
||||
{subtitle ? (
|
||||
<span className="font-mono text-[10px] text-stat-subtitle">{subtitle}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<div className="px-3 pb-3 text-[11px] font-mono text-stat-subtitle/80">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border/40 px-1 pb-1">
|
||||
{entries.map(entry => (
|
||||
<li key={entry.key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEntryClick?.(entry)}
|
||||
disabled={!onEntryClick}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-3 rounded-sm px-2 py-1 text-left transition-colors',
|
||||
onEntryClick ? 'hover:bg-muted/40 cursor-pointer' : 'cursor-default',
|
||||
)}
|
||||
>
|
||||
<span className="truncate font-mono text-[11px] text-stat-value">{entry.primary}</span>
|
||||
<span className="shrink-0 font-mono text-[10px] tabular-nums text-stat-subtitle">
|
||||
{entry.secondary}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-3 border-b">
|
||||
<LandingCard
|
||||
label={largestLabel}
|
||||
subtitle={largestSubtitle}
|
||||
entries={largestTop}
|
||||
emptyLabel={largestEmpty}
|
||||
onEntryClick={onLargestClick}
|
||||
accent="brand"
|
||||
/>
|
||||
<LandingCard
|
||||
label={recentLabel}
|
||||
subtitle={recentSubtitle}
|
||||
entries={recentTop}
|
||||
emptyLabel={recentEmpty}
|
||||
onEntryClick={onRecentClick}
|
||||
accent="warning"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user