feat(resources): reclaim banner controls and accurate reclaim math (#1318)

* feat(resources): reclaim banner controls and accurate reclaim math

Make the Resources Hub reclaim banner match what it advertises and give
operators control over when it appears.

- "Review & prune" now reclaims every category the banner lists (unused
  images, stopped containers, and dangling volumes) instead of images
  only, so the banner clears in one action. Pruning runs volumes first,
  while stopped containers still reference their named volumes, so a
  stopped stack's data is never cascaded into deletion.
- Add a "Show reclaimable-space banner" toggle under Settings, System,
  Docker hygiene (on by default, per node) and a dismiss control on the
  banner that snoozes it until the reclaimable total grows again.
- Fix the reclaimable-space math: count only containers a prune can
  actually remove (created, exited, dead) and size them by their writable
  layer, so a small, un-prunable remainder no longer keeps the banner up.

* fix(resources): show the reclaim banner when the settings fetch fails

A failed or empty /settings load left the banner's enabled flag at the
previously active node's value, so switching from a node with the banner
turned off to a node whose /settings errored kept the new node's banner
hidden. Set the flag unconditionally after the staleness guard so a
failed fetch falls back to the default-on state for the current node.
This commit is contained in:
Anso
2026-06-05 18:59:33 -04:00
committed by GitHub
parent 716daf77d0
commit 308949282c
11 changed files with 457 additions and 12 deletions
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Sparkles } from 'lucide-react';
import { Sparkles, X } from 'lucide-react';
import { formatBytes } from '@/lib/utils';
interface ReclaimHeroProps {
@@ -9,6 +9,7 @@ interface ReclaimHeroProps {
containerCount: number;
volumeCount: number;
onReview: () => void;
onDismiss: () => void;
disabled?: boolean;
}
@@ -18,6 +19,7 @@ export function ReclaimHero({
containerCount,
volumeCount,
onReview,
onDismiss,
disabled,
}: ReclaimHeroProps) {
const composition = useMemo(() => {
@@ -36,6 +38,14 @@ export function ReclaimHero({
<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" />
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className="absolute right-2 top-2 z-10 rounded-md p-1 text-stat-subtitle/40 transition-colors hover:bg-warning/10 hover:text-stat-value focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50"
>
<X className="h-3.5 w-3.5" strokeWidth={1.5} />
</button>
<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">