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:
Anso
2026-04-18 03:00:45 -04:00
committed by GitHub
parent c3b06f4b13
commit 5f6fdfcba8
7 changed files with 411 additions and 119 deletions
+32 -12
View File
@@ -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;
+14 -6
View File
@@ -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

+87 -101
View File
@@ -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>
);
}