mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user