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);