mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 04:38:59 +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:
@@ -316,6 +316,49 @@ describe('DockerController - getDiskUsage', () => {
|
||||
expect(usage.reclaimableContainers).toBe(0);
|
||||
expect(usage.reclaimableVolumes).toBe(0);
|
||||
});
|
||||
|
||||
it('counts only prune-eligible container states (created/exited/dead)', async () => {
|
||||
// `docker container prune` removes stopped containers (created/exited/dead).
|
||||
// Paused and restarting containers survive it, so counting them would leave
|
||||
// a residue the banner can never clear no matter which prune runs.
|
||||
mockDocker.df.mockResolvedValue({
|
||||
Images: [],
|
||||
Volumes: [],
|
||||
Containers: [
|
||||
{ State: 'exited', SizeRw: 200 }, // prunable
|
||||
{ State: 'created', SizeRw: 50 }, // prunable
|
||||
{ State: 'dead', SizeRw: 25 }, // prunable
|
||||
{ State: 'paused', SizeRw: 1000 }, // survives prune
|
||||
{ State: 'restarting', SizeRw: 1000 }, // survives prune
|
||||
{ State: 'running', SizeRw: 1000 }, // in use
|
||||
],
|
||||
});
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const usage = await dc.getDiskUsage();
|
||||
|
||||
expect(usage.reclaimableContainers).toBe(275);
|
||||
expect(usage.reclaimableContainerCount).toBe(3);
|
||||
});
|
||||
|
||||
it('sizes stopped containers by the writable layer (SizeRw), not SizeRootFs', async () => {
|
||||
// SizeRootFs includes the read-only image layers, which removing a
|
||||
// container never frees. A stopped container that wrote nothing reclaims 0.
|
||||
mockDocker.df.mockResolvedValue({
|
||||
Images: [],
|
||||
Volumes: [],
|
||||
Containers: [
|
||||
{ State: 'exited', SizeRw: 0, SizeRootFs: 500_000_000 }, // wrote nothing
|
||||
{ State: 'exited', SizeRw: 1_500, SizeRootFs: 900_000 }, // writable layer only
|
||||
],
|
||||
});
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const usage = await dc.getDiskUsage();
|
||||
|
||||
expect(usage.reclaimableContainers).toBe(1_500);
|
||||
expect(usage.reclaimableContainerCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── pruneSystem ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -372,3 +372,31 @@ describe('Paid-only setting keys (audit_retention_days)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('reclaim_hero setting', () => {
|
||||
it('is allowlisted and seeds to "1" (banner on by default)', async () => {
|
||||
const res = await request(app).get('/api/settings').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reclaim_hero).toBe('1');
|
||||
});
|
||||
|
||||
it('accepts a well-formed write and rejects a non-enum value', async () => {
|
||||
const ok = await request(app)
|
||||
.patch('/api/settings')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ reclaim_hero: '0' });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().reclaim_hero).toBe('0');
|
||||
|
||||
const bad = await request(app)
|
||||
.patch('/api/settings')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ reclaim_hero: 'banana' });
|
||||
expect(bad.status).toBe(400);
|
||||
expect(bad.body.error).toBe('Validation failed');
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().reclaim_hero).toBe('0');
|
||||
|
||||
// Reset for any later reads of the shared test DB.
|
||||
DatabaseService.getInstance().updateGlobalSetting('reclaim_hero', '1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ const ALLOWED_SETTING_KEYS = new Set([
|
||||
'mesh_auto_recreate',
|
||||
'scan_history_per_image_limit',
|
||||
'prune_on_update',
|
||||
'reclaim_hero',
|
||||
]);
|
||||
|
||||
// Keys whose write requires a paid license, not just an admin role.
|
||||
@@ -48,6 +49,7 @@ const SettingsPatchSchema = z.object({
|
||||
mesh_auto_recreate: z.enum(['0', '1']),
|
||||
scan_history_per_image_limit: z.coerce.number().int().min(5).max(1000).transform(String),
|
||||
prune_on_update: z.enum(['0', '1']),
|
||||
reclaim_hero: z.enum(['0', '1']),
|
||||
}).partial();
|
||||
|
||||
export const settingsRouter = Router();
|
||||
|
||||
@@ -1256,6 +1256,7 @@ export class DatabaseService {
|
||||
stmt.run('deploy_block_honor_suppressions', '0');
|
||||
stmt.run('mesh_auto_recreate', '0');
|
||||
stmt.run('prune_on_update', '1');
|
||||
stmt.run('reclaim_hero', '1');
|
||||
|
||||
// Seed the default local node if none exists
|
||||
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
|
||||
|
||||
@@ -140,12 +140,18 @@ class DockerController {
|
||||
|
||||
const reclaimableContainers = (items: any[]) => {
|
||||
if (!items || !Array.isArray(items)) return { bytes: 0, count: 0 };
|
||||
const reclaimable = items.filter(i => i.State !== 'running');
|
||||
// Count only the containers `docker container prune` will actually
|
||||
// remove: created, exited, and dead. A paused or restarting container
|
||||
// survives the prune, so counting it leaves a residue the banner can
|
||||
// never clear no matter which prune the operator runs.
|
||||
const prunableStates = new Set(['created', 'exited', 'dead']);
|
||||
const reclaimable = items.filter(i => prunableStates.has(String(i.State).toLowerCase()));
|
||||
// Size by the writable layer only. `docker system df` reports a stopped
|
||||
// container's reclaimable as its SizeRw; SizeRootFs additionally includes
|
||||
// the read-only image layers, which removing the container never frees,
|
||||
// so using it over-reports what a prune actually reclaims.
|
||||
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;
|
||||
}
|
||||
const size = typeof item.SizeRw === 'number' && item.SizeRw > 0 ? item.SizeRw : 0;
|
||||
return acc + size;
|
||||
}, 0);
|
||||
return { bytes, count: reclaimable.length };
|
||||
|
||||
@@ -15,6 +15,10 @@ When there is reclaimable disk space (unused images, stopped containers, or dang
|
||||
|
||||
The hero stays hidden when there is nothing to reclaim, keeping the view focused on the rest of your inventory.
|
||||
|
||||
To set the banner aside without pruning, use the **×** in its top-right corner. It stays hidden on that browser until the reclaimable total grows past the amount it held when you dismissed it, so a small, stubborn remainder will not keep reappearing while a genuine new build-up still surfaces.
|
||||
|
||||
To keep the banner off for a node entirely, open **Settings → System → Docker hygiene** and switch off **Show reclaimable-space banner**. It is on by default and applies per node.
|
||||
|
||||
<Note>
|
||||
The banner and the **Review & prune** action are admin-only. Read-only roles still see the rest of the page but cannot trigger destructive operations.
|
||||
</Note>
|
||||
@@ -219,4 +223,7 @@ When no unmanaged containers are detected, the tab shows a success state with th
|
||||
<Accordion title="I expected to see the Sencho container in the Unmanaged tab and it is not there">
|
||||
The Unmanaged tab filters the running Sencho container out by design so it cannot be selected and purged. Other containers started outside Sencho (with `docker run` or by a Compose project outside `COMPOSE_DIR`) still appear normally. To inspect the Sencho container itself, use **Host Console** or `docker ps` on the host.
|
||||
</Accordion>
|
||||
<Accordion title="The reclaim banner will not go away, or shows a small amount that will not prune">
|
||||
The banner mirrors what Docker reports as reclaimable on the node, and it counts only the resources a standard prune can actually remove. After a prune, some storage drivers still report a few megabytes as reclaimable that the prune does not free. To stop the banner drawing attention to a stubborn remainder, dismiss it with the **×** in its top-right corner; it stays hidden until the reclaimable total grows past that point. To turn it off for the node entirely, switch off **Show reclaimable-space banner** in **Settings → System → Docker hygiene**.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -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