fix(resources): harden Resources Hub data race, prune errors, and scan lifecycle (#1251)

* fix(resources): harden Resources Hub data race, prune errors, and scan lifecycle

Guard fetchAllData with a generation counter so switching nodes mid-load
cannot let a stale fetch overwrite the newly selected node's resources.

handlePrune now checks res.ok and surfaces the server error instead of
showing a false success toast on a failed prune, matching the delete and
purge handlers.

Make the vulnerability-scan poll loop abort-aware via an AbortController
that cancels on unmount and on node switch, dismissing the loading toast
and avoiding state updates after the view is gone.

Add developer-mode diagnostic logging to the prune, network-create, and
volume list/read paths, gated by the existing developer_mode setting and
never logging file bodies or secrets.

Add regression tests covering the node-switch race and the prune error
path.

* fix(resources): make scan-poll abort cancel in-flight requests and guard parse window

Thread the scan AbortController signal into the scan POST, status poll, and
image-summaries fetches so an abort cancels the in-flight request and the
loading toast clears promptly instead of after the request settles.

Add abort checks after each response-body parse so an abort landing during
JSON parsing cannot fire a stale completion toast, open the scan sheet, or
write summaries for a node the user already left.

Bump the fetch generation on unmount so a fetchAllData that resolves after
the view is gone drops its state writes and load-error toast.

Add a regression test for the post-unmount load-failure path.
This commit is contained in:
Anso
2026-05-29 11:47:33 -04:00
committed by GitHub
parent 96c5f05cdc
commit eed7e04e71
4 changed files with 276 additions and 22 deletions
+20
View File
@@ -120,11 +120,17 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response
'docker disk usage',
);
}
if (isDebugEnabled()) {
console.debug('[Resources:debug] Prune dry-run', {
target, scope: pruneScope, reclaimableBytes: estimate.reclaimableBytes,
});
}
res.json({ message: 'Dry run', success: true, dryRun: true, reclaimedBytes: estimate.reclaimableBytes });
return;
}
console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`);
const pruneStartedAt = Date.now();
let result: { success: boolean; reclaimedBytes: number };
if (pruneScope === 'managed' && target !== 'containers') {
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
@@ -137,6 +143,11 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response
}
console.log(`[Resources] System prune completed: ${target}, reclaimed ${result.reclaimedBytes} bytes`);
if (isDebugEnabled()) {
console.debug('[Resources:debug] System prune', {
target, scope: pruneScope, ms: Date.now() - pruneStartedAt, reclaimedBytes: result.reclaimedBytes,
});
}
if (target === 'containers') {
invalidateNodeCaches(req.nodeId);
}
@@ -390,6 +401,15 @@ systemMaintenanceRouter.post('/networks', async (req: Request, res: Response) =>
if (attachable) options.Attachable = true;
const dockerController = DockerController.getInstance(req.nodeId);
if (isDebugEnabled()) {
console.debug('[Resources:debug] Network create', {
driver: options.Driver ?? 'bridge',
internal: !!options.Internal,
attachable: !!options.Attachable,
hasSubnet: !!subnet,
hasGateway: !!gateway,
});
}
const network = await dockerController.createNetwork(options);
console.log(`[Resources] Network created: ${sanitizeForLog(name)}`);
invalidateNodeCaches(req.nodeId);
+15
View File
@@ -3,6 +3,7 @@ import { VolumeBrowserService, isValidVolumeName, PathTraversalError, VolumeNotF
import { DatabaseService } from '../services/DatabaseService';
import { requireAdmin } from '../middleware/tierGates';
import { sanitizeForLog } from '../utils/safeLog';
import { isDebugEnabled } from '../utils/debug';
export const volumesRouter = Router();
@@ -27,7 +28,13 @@ volumesRouter.get('/:name/list', async (req: Request, res: Response) => {
const name = req.params.name as string;
if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' });
const path = readPathParam(req);
const startedAt = Date.now();
const entries = await VolumeBrowserService.getInstance(req.nodeId).listDir(name, path);
if (isDebugEnabled()) {
console.debug('[Volumes:debug] list', {
volume: sanitizeForLog(name), path: sanitizeForLog(path || '/'), entries: entries.length, ms: Date.now() - startedAt,
});
}
res.json(entries);
} catch (error: unknown) {
mapServiceError(error, res, 'Failed to list volume directory');
@@ -54,8 +61,16 @@ volumesRouter.get('/:name/read', async (req: Request, res: Response) => {
let outcome: 'success' | 'error' = 'error';
try {
if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' });
const startedAt = Date.now();
const result = await VolumeBrowserService.getInstance(req.nodeId).readFile(name, requestPath);
outcome = 'success';
if (isDebugEnabled()) {
// Never log the file body; only its shape.
console.debug('[Volumes:debug] read', {
volume: sanitizeForLog(name), path: sanitizeForLog(requestPath || '/'),
size: result.size, binary: result.binary, truncated: result.truncated, ms: Date.now() - startedAt,
});
}
res.json(result);
} catch (error: unknown) {
mapServiceError(error, res, 'Failed to read volume file');