- New src/config/disk-settings-loader.js runs once at boot (require'd into src/app.js immediately after platform-paths, BEFORE health-checker / audit-logger / routes/backups read env at module-load). - Routes the persisted values from <dataDir>/disk-settings.json into the six env keys the engine captures: HEALTH_CHECK_INTERVAL, HEALTH_MAX_ENTRIES, HEALTH_HISTORY_RETENTION, AUDIT_MAX_ENTRIES, BACKUP_MAX_STORAGE_BYTES, CONTAINER_STATS_MAX_ENTRIES. - Explicit process.env values WIN over persisted file (operator override). - Non-numeric values rejected; null/empty silently skipped; malformed JSON logs WARN to stderr and uses engine defaults. - Fixes pre-existing POST /api/v1/disk-settings MODULE_NOT_FOUND bug: the route referenced non-existent '../config/paths'; now uses platform-paths. - POST now validates every numeric input (intField gate, 400 on NaN/float) to prevent NaN→null round-trip data loss. - Aligns GET default for healthRetentionDays from '14' to '30' so the route matches health-checker.js:34 (engine) and the modal's ||30 fallback. - 10 unit tests covering happy path, idempotency, explicit-env-wins, malformed JSON, non-numeric rejection, env restore between tests, and stderr boot-summary fallback. GLM-5.3 round 1: B (route MODULE_NOT_FOUND + MEDIUM POST NaN→null + boot log LOW). GLM-5.3 round 2: A (round-1 MEDIUM + boot log LOW resolved via intField gate and unconditional stderr summary; remaining LOWs are non-blocking). Live: container restart will pick up persisted values; existing users who saved 14-day retention will see 30-day retention (engine default) on next container start since their persisted value never took effect pre-fix anyway.
121 lines
5.5 KiB
JavaScript
121 lines
5.5 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const platformPaths = require('../platform-paths');
|
|
|
|
// DC-048 — the canonical disk-settings.json path. Shared by GET + POST.
|
|
function getSettingsFile() {
|
|
return path.join(platformPaths.dataDir, 'disk-settings.json');
|
|
}
|
|
|
|
// GET current disk settings + actual disk usage
|
|
router.get('/', (req, res) => {
|
|
try {
|
|
const settings = {
|
|
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000'),
|
|
healthMaxEntries: parseInt(process.env.HEALTH_MAX_ENTRIES || '500'),
|
|
// DC-048 — align route default to engine default (health-checker.js:34
|
|
// reads 30 from env when unset; the route previously showed 14 as the
|
|
// "no override" value, which silently disagreed with the engine).
|
|
healthRetentionDays: parseInt(process.env.HEALTH_HISTORY_RETENTION || '30'),
|
|
statsMaxEntries: parseInt(process.env.CONTAINER_STATS_MAX_ENTRIES || '500'),
|
|
auditMaxEntries: parseInt(process.env.AUDIT_MAX_ENTRIES || '1000'),
|
|
backupMaxStorageBytes: parseInt(process.env.BACKUP_MAX_STORAGE_BYTES || '0'),
|
|
};
|
|
|
|
// Get actual disk usage
|
|
let diskUsage = { total: 0, used: 0, free: 0, dataDirSize: 0 };
|
|
try {
|
|
const { execSync } = require('child_process');
|
|
const dfOut = execSync("df -B1 /opt/dashcaddy/dashcaddy-api/data 2>/dev/null || df -B1 / 2>/dev/null").toString().trim().split('\n');
|
|
if (dfOut.length > 1) {
|
|
const parts = dfOut[1].split(/\s+/);
|
|
diskUsage.total = parseInt(parts[1]) || 0;
|
|
diskUsage.used = parseInt(parts[2]) || 0;
|
|
diskUsage.free = parseInt(parts[3]) || 0;
|
|
}
|
|
const duOut = execSync("du -sb /opt/dashcaddy/dashcaddy-api/data 2>/dev/null || echo 0").toString().trim().split(/\s+/);
|
|
diskUsage.dataDirSize = parseInt(duOut[0]) || 0;
|
|
} catch {}
|
|
|
|
// Load persisted settings
|
|
const settingsFile = getSettingsFile();
|
|
let persisted = {};
|
|
try { persisted = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); } catch {}
|
|
|
|
res.json({ success: true, current: { ...settings, ...persisted }, diskUsage });
|
|
} catch (e) {
|
|
res.status(500).json({ success: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// POST update settings
|
|
router.post('/', (req, res) => {
|
|
try {
|
|
const { healthInterval, healthMaxEntries, healthRetentionDays, statsMaxEntries, auditMaxEntries } = req.body;
|
|
|
|
// DC-048 — coerce + validate EVERY numeric input before persisting.
|
|
// Without this gate, parseInt('abc') === NaN → String(NaN) === 'NaN' →
|
|
// process.env.HEALTH_CHECK_INTERVAL becomes 'NaN' at runtime AND the
|
|
// persisted file gets JSON.stringify({x: NaN}) === {"x": null} which
|
|
// the loader silently drops on next boot. Validation now rejects the
|
|
// request with 400 BEFORE any env mutation or file write.
|
|
const intField = (name, value) => {
|
|
const n = Number(value);
|
|
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
|
throw new Error(`${name} must be an integer (received ${JSON.stringify(value)})`);
|
|
}
|
|
return n;
|
|
};
|
|
|
|
const updates = {};
|
|
if (healthInterval !== undefined) { const n = intField('healthInterval', healthInterval); updates.healthCheckInterval = n; process.env.HEALTH_CHECK_INTERVAL = String(n); }
|
|
if (healthMaxEntries !== undefined) { const n = intField('healthMaxEntries', healthMaxEntries); updates.healthMaxEntries = n; process.env.HEALTH_MAX_ENTRIES = String(n); }
|
|
if (healthRetentionDays !== undefined) { const n = intField('healthRetentionDays', healthRetentionDays); updates.healthRetentionDays = n; process.env.HEALTH_HISTORY_RETENTION = String(n); }
|
|
if (statsMaxEntries !== undefined) { const n = intField('statsMaxEntries', statsMaxEntries); updates.statsMaxEntries = n; process.env.CONTAINER_STATS_MAX_ENTRIES = String(n); }
|
|
if (auditMaxEntries !== undefined) { const n = intField('auditMaxEntries', auditMaxEntries); updates.auditMaxEntries = n; process.env.AUDIT_MAX_ENTRIES = String(n); }
|
|
|
|
// Persist to file
|
|
const settingsFile = getSettingsFile();
|
|
let existing = {};
|
|
try { existing = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); } catch {}
|
|
fs.writeFileSync(settingsFile, JSON.stringify({ ...existing, ...updates }, null, 2));
|
|
|
|
res.json({ success: true, updated: updates, message: 'Settings saved. Some changes apply on next container restart.' });
|
|
} catch (e) {
|
|
res.status(e.statusCode || 400).json({ success: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// POST trigger immediate cleanup
|
|
router.post('/cleanup', async (req, res) => {
|
|
try {
|
|
const results = { cleaned: {} };
|
|
|
|
// Clean health history
|
|
try {
|
|
const healthChecker = require('../monitoring/health-checker');
|
|
if (healthChecker.instance && healthChecker.instance.cleanupHistory) {
|
|
healthChecker.instance.cleanupHistory();
|
|
results.cleaned.healthHistory = 'Cleaned old entries';
|
|
}
|
|
} catch (e) { results.cleaned.healthHistory = 'Skipped: ' + e.message; }
|
|
|
|
// Clean container stats
|
|
try {
|
|
const resourceMonitor = require('../managers/resource-monitor');
|
|
if (resourceMonitor.instance && resourceMonitor.instance.cleanupOldStats) {
|
|
resourceMonitor.instance.cleanupOldStats();
|
|
results.cleaned.containerStats = 'Cleaned old entries';
|
|
}
|
|
} catch (e) { results.cleaned.containerStats = 'Skipped: ' + e.message; }
|
|
|
|
res.json({ success: true, results });
|
|
} catch (e) {
|
|
res.status(500).json({ success: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|