mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
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:
@@ -102,6 +102,32 @@ type ResourceFilter = 'all' | 'managed' | 'unmanaged';
|
||||
type PruneTarget = 'containers' | 'images' | 'networks' | 'volumes';
|
||||
type PruneScope = 'managed' | 'all';
|
||||
|
||||
// Per-node, per-browser snooze for the reclaim banner. We store the reclaimable
|
||||
// byte total at the moment of dismissal; the banner returns only once the node's
|
||||
// reclaimable total grows past that snapshot, so a stable residue stays hidden.
|
||||
const heroDismissKey = (nodeId: string | number | undefined) => `sencho.reclaimHeroDismissed.${nodeId ?? 'local'}`;
|
||||
|
||||
function readHeroDismissed(nodeId: string | number | undefined): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(heroDismissKey(nodeId));
|
||||
if (raw === null) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
} catch {
|
||||
// localStorage is unavailable (private mode / blocked); treat as
|
||||
// not-dismissed so the banner still shows.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeHeroDismissed(nodeId: string | number | undefined, bytes: number): void {
|
||||
try {
|
||||
localStorage.setItem(heroDismissKey(nodeId), String(bytes));
|
||||
} catch {
|
||||
// Best-effort: if the write fails the banner simply reappears next load.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filter Toggle - Segmented Control ─────────────────────────────────────────
|
||||
|
||||
interface FilterToggleProps {
|
||||
@@ -367,6 +393,12 @@ export default function ResourcesView() {
|
||||
// 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);
|
||||
const [confirmReclaim, setConfirmReclaim] = useState(false);
|
||||
|
||||
// Reclaim banner visibility: the per-node opt-out setting (loaded in
|
||||
// fetchAllData) and the per-browser dismiss snapshot for the active node.
|
||||
const [reclaimHeroEnabled, setReclaimHeroEnabled] = useState(true);
|
||||
const [heroDismissedBytes, setHeroDismissedBytes] = useState<number | null>(null);
|
||||
|
||||
// Network create/inspect state
|
||||
const [showCreateNetwork, setShowCreateNetwork] = useState(false);
|
||||
@@ -399,11 +431,12 @@ export default function ResourcesView() {
|
||||
const generation = ++fetchGenerationRef.current;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [usageRes, resourcesRes, orphansRes, summariesRes] = await Promise.all([
|
||||
const [usageRes, resourcesRes, orphansRes, summariesRes, settingsRes] = await Promise.all([
|
||||
apiFetch('/system/docker-df'),
|
||||
apiFetch('/system/resources'),
|
||||
apiFetch('/system/orphans'),
|
||||
apiFetch('/security/image-summaries').catch(() => null),
|
||||
apiFetch('/settings').catch(() => null),
|
||||
]);
|
||||
|
||||
// Resolve every body before the staleness check so a stale
|
||||
@@ -412,9 +445,15 @@ export default function ResourcesView() {
|
||||
const resourcesData = resourcesRes.ok ? await resourcesRes.json() : null;
|
||||
const orphansData = orphansRes.ok ? await orphansRes.json() : null;
|
||||
const summariesData = summariesRes && summariesRes.ok ? await summariesRes.json() : null;
|
||||
const settingsData = settingsRes && settingsRes.ok ? await settingsRes.json() : null;
|
||||
|
||||
if (fetchGenerationRef.current !== generation) return;
|
||||
|
||||
// Set unconditionally: a failed /settings (settingsData null) must
|
||||
// reset to the default-on state for this node, not inherit the
|
||||
// previously active node's value. undefined !== '0' is true, so a
|
||||
// missing key or failed fetch fails open toward showing the banner.
|
||||
setReclaimHeroEnabled(settingsData?.reclaim_hero !== '0');
|
||||
if (usageData) setUsage(usageData);
|
||||
if (resourcesData) {
|
||||
setImages(resourcesData.images ?? []);
|
||||
@@ -437,6 +476,11 @@ export default function ResourcesView() {
|
||||
|
||||
useEffect(() => { fetchAllData(); }, [activeNode]);
|
||||
|
||||
// Load the per-node reclaim-banner dismiss snapshot when the node changes.
|
||||
useEffect(() => {
|
||||
setHeroDismissedBytes(readHeroDismissed(activeNode?.id));
|
||||
}, [activeNode?.id]);
|
||||
|
||||
// Cancel an in-flight scan poll on unmount or node switch; its result
|
||||
// belongs to the node it started on.
|
||||
useEffect(() => {
|
||||
@@ -682,21 +726,78 @@ export default function ResourcesView() {
|
||||
+ (usage?.reclaimableContainers ?? 0)
|
||||
+ (usage?.reclaimableVolumes ?? 0);
|
||||
|
||||
// Banner shows while the opt-out is on and the operator has not dismissed
|
||||
// this (or a larger) reclaimable total. A stable residue stays hidden; a
|
||||
// fresh pile pushes the total past the snapshot and the banner returns.
|
||||
const heroVisible = isAdmin && reclaimHeroEnabled
|
||||
&& (heroDismissedBytes === null || totalReclaimableBytes > heroDismissedBytes);
|
||||
|
||||
const handleReviewAndPrune = () => {
|
||||
setConfirmPrune({ target: 'images', scope: 'all' });
|
||||
setConfirmReclaim(true);
|
||||
};
|
||||
|
||||
const handleDismissHero = () => {
|
||||
writeHeroDismissed(activeNode?.id, totalReclaimableBytes);
|
||||
setHeroDismissedBytes(totalReclaimableBytes);
|
||||
};
|
||||
|
||||
// "Review & prune" reclaims everything the banner advertises. Order matters:
|
||||
// volumes first, while stopped containers still hold a reference to their
|
||||
// named volumes, so the prune only removes volumes that are already dangling
|
||||
// and a stopped stack's data is never cascaded into deletion. Containers
|
||||
// next, then images, so images a stopped container pinned become reclaimable.
|
||||
// Each failed target is reported by name and its server error logged (never a
|
||||
// false success); the reclaimed figure is shown only when the daemon reports
|
||||
// one (the containerd image store returns 0).
|
||||
const handleReclaimAll = async () => {
|
||||
setIsActioning(true);
|
||||
const loadingId = toast.loading('Reclaiming disk space...');
|
||||
const order: PruneTarget[] = ['volumes', 'containers', 'images'];
|
||||
let reclaimed = 0;
|
||||
const failed: PruneTarget[] = [];
|
||||
try {
|
||||
for (const target of order) {
|
||||
try {
|
||||
const res = await apiFetch('/system/prune/system', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target, scope: 'all' }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new Error(data?.error || `Failed to prune ${target}`);
|
||||
if (typeof data?.reclaimedBytes === 'number') reclaimed += data.reclaimedBytes;
|
||||
} catch (err) {
|
||||
console.error(`Failed to prune ${target}`, err);
|
||||
failed.push(target);
|
||||
}
|
||||
}
|
||||
const reclaimedLabel = reclaimed > 0 ? ` Freed ${formatBytes(reclaimed)}.` : '';
|
||||
if (failed.length === order.length) {
|
||||
toast.error('Failed to reclaim disk space.');
|
||||
} else if (failed.length > 0) {
|
||||
toast.warning(`Could not prune: ${failed.join(', ')}.${reclaimedLabel}`);
|
||||
} else {
|
||||
toast.success(`Reclaimed unused images, stopped containers, and dangling volumes.${reclaimedLabel}`);
|
||||
}
|
||||
await fetchAllData();
|
||||
} finally {
|
||||
toast.dismiss(loadingId);
|
||||
setIsActioning(false);
|
||||
setConfirmReclaim(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 h-full overflow-auto text-foreground flex flex-col gap-6 animate-in fade-in-0 duration-300">
|
||||
|
||||
{/* Reclaim hero */}
|
||||
{usage && isAdmin && (
|
||||
{heroVisible && usage && (
|
||||
<ReclaimHero
|
||||
bytes={totalReclaimableBytes}
|
||||
imageCount={usage.reclaimableImageCount}
|
||||
containerCount={usage.reclaimableContainerCount}
|
||||
volumeCount={usage.reclaimableVolumeCount}
|
||||
onReview={handleReviewAndPrune}
|
||||
onDismiss={handleDismissHero}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
@@ -1262,6 +1363,39 @@ export default function ResourcesView() {
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
|
||||
{/* Reclaim Confirm (banner "Review & prune") */}
|
||||
<ConfirmModal
|
||||
open={confirmReclaim}
|
||||
onOpenChange={(open) => !open && setConfirmReclaim(false)}
|
||||
variant="destructive"
|
||||
kicker="RESOURCES · PRUNE · IRREVERSIBLE"
|
||||
title="Reclaim disk space"
|
||||
hint="AFFECTS external Docker resources"
|
||||
confirmLabel={isActioning ? 'Reclaiming...' : `Reclaim ${formatBytes(totalReclaimableBytes)}`}
|
||||
confirming={isActioning}
|
||||
onConfirm={handleReclaimAll}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-stat-subtitle">
|
||||
<p>
|
||||
Removes every unused image, stopped container, and dangling volume on this node, including those from{' '}
|
||||
<span className="font-medium text-stat-value">external projects not managed by Sencho</span>.
|
||||
</p>
|
||||
{usage && (
|
||||
<ul className="flex flex-col gap-1 font-mono text-[12px] text-stat-subtitle/90">
|
||||
{usage.reclaimableImageCount > 0 && (
|
||||
<li>{usage.reclaimableImageCount} {usage.reclaimableImageCount === 1 ? 'unused image' : 'unused images'} · {formatBytes(usage.reclaimableImages)}</li>
|
||||
)}
|
||||
{usage.reclaimableContainerCount > 0 && (
|
||||
<li>{usage.reclaimableContainerCount} {usage.reclaimableContainerCount === 1 ? 'stopped container' : 'stopped containers'} · {formatBytes(usage.reclaimableContainers)}</li>
|
||||
)}
|
||||
{usage.reclaimableVolumeCount > 0 && (
|
||||
<li>{usage.reclaimableVolumeCount} {usage.reclaimableVolumeCount === 1 ? 'dangling volume' : 'dangling volumes'} · {formatBytes(usage.reclaimableVolumes)}</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
|
||||
{/* Delete Confirm */}
|
||||
<ConfirmModal
|
||||
open={!!confirmDelete}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* Coverage for ResourcesView hardening.
|
||||
*
|
||||
* Locks two correctness fixes that manual smoke testing cannot reliably catch:
|
||||
* Locks correctness fixes that manual smoke testing cannot reliably catch:
|
||||
* - M-1: a slow resource fetch for a previously-active node must not overwrite
|
||||
* the newly-selected node's data (node-switch generation guard).
|
||||
* - M-2: a failed prune must surface the server error, never a false success.
|
||||
* - Reclaim banner: "Review & prune" reclaims every advertised category and a
|
||||
* partial failure reports a warning, never a false success; dismiss snoozes
|
||||
* the banner until the reclaimable total grows past the dismissed snapshot.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
@@ -42,7 +45,18 @@ vi.mock('@/hooks/useTrivyStatus', () => ({
|
||||
// Heavy or portal-bound children are not under test; stub them to keep the
|
||||
// render tree light and deterministic.
|
||||
vi.mock('../VulnerabilityScanSheet', () => ({ VulnerabilityScanSheet: () => null }));
|
||||
vi.mock('../resources/ReclaimHero', () => ({ ReclaimHero: () => null }));
|
||||
// Testable stub: surfaces visibility (testid) and the two callbacks so the
|
||||
// snooze and reclaim-all flows can be driven without the real banner styling.
|
||||
// Mirrors the real component's bytes<=0 guard.
|
||||
vi.mock('../resources/ReclaimHero', () => ({
|
||||
ReclaimHero: ({ bytes, onReview, onDismiss }: { bytes: number; onReview: () => void; onDismiss: () => void }) =>
|
||||
bytes <= 0 ? null : (
|
||||
<div data-testid="reclaim-hero">
|
||||
<button onClick={onReview}>Review & prune</button>
|
||||
<button onClick={onDismiss}>Dismiss hero</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('../resources/FootprintTreemap', () => ({ FootprintTreemap: () => null }));
|
||||
vi.mock('../resources/ImageDetailsSheet', () => ({ ImageDetailsSheet: () => null }));
|
||||
vi.mock('../resources/VolumeBrowserSheet', () => ({ VolumeBrowserSheet: () => null }));
|
||||
@@ -82,8 +96,25 @@ beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
licenseState.isPaid = true;
|
||||
nodesState.activeNode = { id: 1 };
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// Reclaimable usage shape with a non-zero total so the banner is shown.
|
||||
function reclaimableUsage(images: number, volumes: number) {
|
||||
return {
|
||||
reclaimableImages: images,
|
||||
reclaimableContainers: 0,
|
||||
reclaimableVolumes: volumes,
|
||||
reclaimableImageCount: images > 0 ? 1 : 0,
|
||||
reclaimableContainerCount: 0,
|
||||
reclaimableVolumeCount: volumes > 0 ? 1 : 0,
|
||||
managedImageBytes: 0,
|
||||
unmanagedImageBytes: 0,
|
||||
managedVolumeBytes: 0,
|
||||
unmanagedVolumeBytes: 0,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('ResourcesView', () => {
|
||||
@@ -160,4 +191,173 @@ describe('ResourcesView', () => {
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Prune blew up'));
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports partial failure from "Review & prune" without a false success', async () => {
|
||||
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url === '/system/prune/system' && opts?.method === 'POST') {
|
||||
const target = (JSON.parse(String(opts.body)) as { target: string }).target;
|
||||
if (target === 'volumes') {
|
||||
return Promise.resolve(jsonResponse({ error: 'volume prune failed' }, { ok: false, status: 500 }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({ reclaimedBytes: 100 }));
|
||||
}
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ResourcesView />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Review & prune/ }));
|
||||
await user.click(await screen.findByRole('button', { name: /^Reclaim/ }));
|
||||
|
||||
await waitFor(() => expect(toast.warning).toHaveBeenCalled());
|
||||
const warningMsg = (toast.warning as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
||||
expect(warningMsg).toMatch(/volumes/);
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
// Volumes are pruned first (while stopped containers still protect their
|
||||
// named volumes), then containers, then images.
|
||||
const pruned = mockedFetch.mock.calls
|
||||
.filter(([u, o]) => u === '/system/prune/system' && (o as RequestInit)?.method === 'POST')
|
||||
.map(([, o]) => (JSON.parse(String((o as RequestInit).body)) as { target: string }).target);
|
||||
expect(pruned).toEqual(['volumes', 'containers', 'images']);
|
||||
});
|
||||
|
||||
it('reports an error when every prune fails, with no success or warning', async () => {
|
||||
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url === '/system/prune/system' && opts?.method === 'POST') {
|
||||
return Promise.resolve(jsonResponse({ error: 'daemon down' }, { ok: false, status: 500 }));
|
||||
}
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ResourcesView />);
|
||||
await user.click(await screen.findByRole('button', { name: /Review & prune/ }));
|
||||
await user.click(await screen.findByRole('button', { name: /^Reclaim/ }));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to reclaim disk space.'));
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(toast.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omits the reclaimed figure on full success when the daemon reports zero bytes', async () => {
|
||||
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url === '/system/prune/system' && opts?.method === 'POST') {
|
||||
return Promise.resolve(jsonResponse({ reclaimedBytes: 0 }));
|
||||
}
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ResourcesView />);
|
||||
await user.click(await screen.findByRole('button', { name: /Review & prune/ }));
|
||||
await user.click(await screen.findByRole('button', { name: /^Reclaim/ }));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
const msg = (toast.success as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
||||
expect(msg).not.toMatch(/Freed/);
|
||||
expect(toast.warning).not.toHaveBeenCalled();
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the reclaimed figure on full success when the daemon reports bytes', async () => {
|
||||
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url === '/system/prune/system' && opts?.method === 'POST') {
|
||||
return Promise.resolve(jsonResponse({ reclaimedBytes: 1048576 }));
|
||||
}
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ResourcesView />);
|
||||
await user.click(await screen.findByRole('button', { name: /Review & prune/ }));
|
||||
await user.click(await screen.findByRole('button', { name: /^Reclaim/ }));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
const msg = (toast.success as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
||||
expect(msg).toMatch(/Freed/);
|
||||
});
|
||||
|
||||
it('snoozes the banner on dismiss and brings it back when more space is reclaimable', async () => {
|
||||
let usage = reclaimableUsage(1000, 500); // 1500 B total
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(usage));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<ResourcesView />);
|
||||
await screen.findByTestId('reclaim-hero');
|
||||
|
||||
// Dismiss snapshots the current total; the banner hides.
|
||||
await user.click(screen.getByRole('button', { name: /Dismiss hero/ }));
|
||||
await waitFor(() => expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument());
|
||||
|
||||
// A larger reclaimable total on the same node pushes past the snapshot, so
|
||||
// the banner returns. Same node id keeps the snapshot; the new activeNode
|
||||
// object reference triggers a refetch.
|
||||
usage = reclaimableUsage(8000, 2000); // 10000 B total
|
||||
nodesState.activeNode = { id: 1 };
|
||||
rerender(<ResourcesView />);
|
||||
await screen.findByTestId('reclaim-hero');
|
||||
});
|
||||
|
||||
it('keeps the banner hidden after dismiss when the reclaimable total does not grow', async () => {
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<ResourcesView />);
|
||||
await screen.findByTestId('reclaim-hero');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Dismiss hero/ }));
|
||||
await waitFor(() => expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument());
|
||||
|
||||
// A stable residue (the same total on the same node) must stay dismissed
|
||||
// across a refetch, not re-nag. Force a refetch via a new activeNode ref.
|
||||
const dfCalls = () => mockedFetch.mock.calls.filter(([u]) => u === '/system/docker-df').length;
|
||||
const before = dfCalls();
|
||||
nodesState.activeNode = { id: 1 };
|
||||
rerender(<ResourcesView />);
|
||||
await waitFor(() => expect(dfCalls()).toBeGreaterThan(before));
|
||||
expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the banner on a node switch when /settings fails, instead of inheriting the previous node opt-out', async () => {
|
||||
let settingsOk = true;
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/settings') {
|
||||
return settingsOk
|
||||
? Promise.resolve(jsonResponse({ reclaim_hero: '0' }))
|
||||
: Promise.resolve(jsonResponse({}, { ok: false, status: 500 }));
|
||||
}
|
||||
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
||||
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [image('node-a-img:latest')], volumes: [], networks: [] }));
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
|
||||
// Node A has the banner turned off; once loaded it stays hidden.
|
||||
const { rerender } = render(<ResourcesView />);
|
||||
await screen.findByText('node-a-img:latest');
|
||||
expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument();
|
||||
|
||||
// Switch to node B with a failing /settings: the banner must show (fail
|
||||
// open), not carry over node A's disabled state.
|
||||
settingsOk = false;
|
||||
nodesState.activeNode = { id: 2 };
|
||||
rerender(<ResourcesView />);
|
||||
await screen.findByTestId('reclaim-hero');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -133,7 +133,7 @@ function SettingsSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
type SystemFields = Pick<PatchableSettings, 'host_cpu_limit' | 'host_ram_limit' | 'host_disk_limit' | 'host_alert_suppression_mins' | 'docker_janitor_gb' | 'global_crash' | 'prune_on_update' | 'mesh_auto_recreate'>;
|
||||
type SystemFields = Pick<PatchableSettings, 'host_cpu_limit' | 'host_ram_limit' | 'host_disk_limit' | 'host_alert_suppression_mins' | 'docker_janitor_gb' | 'global_crash' | 'prune_on_update' | 'mesh_auto_recreate' | 'reclaim_hero'>;
|
||||
|
||||
const DEFAULT_SYSTEM: SystemFields = {
|
||||
host_cpu_limit: DEFAULT_SETTINGS.host_cpu_limit,
|
||||
@@ -144,6 +144,7 @@ const DEFAULT_SYSTEM: SystemFields = {
|
||||
global_crash: DEFAULT_SETTINGS.global_crash,
|
||||
prune_on_update: DEFAULT_SETTINGS.prune_on_update,
|
||||
mesh_auto_recreate: DEFAULT_SETTINGS.mesh_auto_recreate,
|
||||
reclaim_hero: DEFAULT_SETTINGS.reclaim_hero,
|
||||
};
|
||||
|
||||
export function SystemSection({ onDirtyChange }: SystemSectionProps) {
|
||||
@@ -166,6 +167,7 @@ export function SystemSection({ onDirtyChange }: SystemSectionProps) {
|
||||
if (settings.global_crash !== baseline.global_crash) n++;
|
||||
if (settings.prune_on_update !== baseline.prune_on_update) n++;
|
||||
if (settings.mesh_auto_recreate !== baseline.mesh_auto_recreate) n++;
|
||||
if (settings.reclaim_hero !== baseline.reclaim_hero) n++;
|
||||
return n;
|
||||
}, [settings]);
|
||||
|
||||
@@ -202,6 +204,7 @@ export function SystemSection({ onDirtyChange }: SystemSectionProps) {
|
||||
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
|
||||
prune_on_update: (nodeData.prune_on_update as '0' | '1') ?? DEFAULT_SETTINGS.prune_on_update,
|
||||
mesh_auto_recreate: (nodeData.mesh_auto_recreate as '0' | '1') ?? DEFAULT_SETTINGS.mesh_auto_recreate,
|
||||
reclaim_hero: (nodeData.reclaim_hero as '0' | '1') ?? DEFAULT_SETTINGS.reclaim_hero,
|
||||
};
|
||||
setSettings(safe);
|
||||
serverSettingsRef.current = { ...safe };
|
||||
@@ -330,6 +333,15 @@ export function SystemSection({ onDirtyChange }: SystemSectionProps) {
|
||||
onChange={(next) => onSettingChange('prune_on_update', next ? '1' : '0')}
|
||||
/>
|
||||
</SettingsField>
|
||||
<SettingsField
|
||||
label="Show reclaimable-space banner"
|
||||
helper="Show the reclaimable-space banner at the top of the Resource Hub when this node has unused images, stopped containers, or dangling volumes to clear. On by default."
|
||||
>
|
||||
<TogglePill
|
||||
checked={settings.reclaim_hero === '1'}
|
||||
onChange={(next) => onSettingChange('reclaim_hero', next ? '1' : '0')}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
{/*
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface PatchableSettings {
|
||||
mesh_auto_recreate?: '0' | '1';
|
||||
scan_history_per_image_limit?: string;
|
||||
prune_on_update?: '0' | '1';
|
||||
reclaim_hero?: '0' | '1';
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
@@ -30,6 +31,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
mesh_auto_recreate: '0',
|
||||
scan_history_per_image_limit: '50',
|
||||
prune_on_update: '1',
|
||||
reclaim_hero: '1',
|
||||
};
|
||||
|
||||
export type SectionId =
|
||||
|
||||
Reference in New Issue
Block a user