mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06: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 };
|
||||
|
||||
Reference in New Issue
Block a user