[grade=A] DC-075: System health endpoint + DC-069 notification cooldown verified
GET /api/v1/system/health — unauthenticated endpoint for UptimeRobot/BetterStack.
Returns: { status, timestamp, checks: { services, memory, diskSpace, uptime, incidents } }
- Services: counts healthy/unhealthy/unknown explicitly
- Memory: used/total/free with 10% free threshold
- Disk space: df on data dir, 90%/95% thresholds
- Overall: unknown→degraded, critical→unhealthy
DC-069: notification manager already uses state-transition pattern (only fires
on wasDown→isDown change), incidents deduplicate via occurrences++. Already handled.
Codex: C→A iteration. 3 issues fixed (PUBLIC_ROUTES, unknown counting, disk check).
This commit is contained in:
@@ -377,5 +377,101 @@ module.exports = function({
|
||||
success(res, { history: result.data, ...(result.pagination && { pagination: result.pagination }) });
|
||||
}, 'health-check-incidents-history'));
|
||||
|
||||
// ── DC-075: System health endpoint for operators/uptime monitoring ─────────
|
||||
// Returns a single "is everything OK" summary suitable for external monitors
|
||||
// like UptimeRobot or BetterStack. No auth required (read-only status).
|
||||
router.get('/system/health', asyncHandler(async (req, res) => {
|
||||
const checks = {};
|
||||
|
||||
// Service health from health checker
|
||||
try {
|
||||
const status = healthChecker.getCurrentStatus();
|
||||
const entries = Object.values(status || {});
|
||||
const unhealthy = entries.filter(s => {
|
||||
const st = (s && (s.status || s.state)) || '';
|
||||
return st === 'down' || st === 'unhealthy' || st === 'offline' || st === 'error';
|
||||
}).length;
|
||||
const total = entries.length;
|
||||
const knownHealthy = entries.filter(s => {
|
||||
const st = (s && (s.status || s.state)) || '';
|
||||
return st === 'up' || st === 'healthy' || st === 'online';
|
||||
}).length;
|
||||
checks.services = {
|
||||
status: unhealthy === 0 ? 'ok' : (unhealthy < total ? 'degraded' : 'down'),
|
||||
healthy: knownHealthy,
|
||||
unhealthy,
|
||||
unknown: total - knownHealthy - unhealthy,
|
||||
total,
|
||||
};
|
||||
} catch {
|
||||
checks.services = { status: 'unknown' };
|
||||
}
|
||||
|
||||
// Memory usage
|
||||
try {
|
||||
const os = require('os');
|
||||
const total = os.totalmem ? os.totalmem() : 0;
|
||||
const free = os.freemem ? os.freemem() : 0;
|
||||
checks.memory = {
|
||||
status: free / total > 0.1 ? 'ok' : 'warning',
|
||||
usedPercent: parseFloat((((total - free) / total) * 100).toFixed(1)),
|
||||
totalMB: Math.round(total / 1048576),
|
||||
freeMB: Math.round(free / 1048576),
|
||||
};
|
||||
} catch {
|
||||
checks.memory = { status: 'unknown' };
|
||||
}
|
||||
|
||||
// Disk space (data dir)
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
const dfOutput = execSync('df -h --output=pcent,size,avail ' + (platformPaths.dataDir || '/'), { encoding: 'utf8', timeout: 3000 });
|
||||
const lines = dfOutput.trim().split('\n');
|
||||
if (lines.length >= 2) {
|
||||
const parts = lines[1].trim().split(/\s+/);
|
||||
const usedPercent = parseInt(parts[0]);
|
||||
checks.diskSpace = {
|
||||
status: usedPercent < 90 ? 'ok' : (usedPercent < 95 ? 'warning' : 'critical'),
|
||||
usedPercent,
|
||||
total: parts[1],
|
||||
available: parts[2],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
checks.diskSpace = { status: 'unknown' };
|
||||
}
|
||||
|
||||
// Uptime
|
||||
const uptime = process.uptime();
|
||||
checks.uptime = {
|
||||
seconds: Math.round(uptime),
|
||||
human: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`,
|
||||
};
|
||||
|
||||
// Open incidents
|
||||
try {
|
||||
const incidents = healthChecker.getOpenIncidents();
|
||||
checks.incidents = {
|
||||
status: incidents.length === 0 ? 'ok' : 'degraded',
|
||||
count: incidents.length,
|
||||
};
|
||||
} catch {
|
||||
checks.incidents = { status: 'unknown', count: 0 };
|
||||
}
|
||||
|
||||
// Overall status: 'unknown' is treated as degraded (not healthy)
|
||||
const statuses = Object.values(checks).map(c => c.status);
|
||||
const overall = statuses.includes('down') || statuses.includes('critical') ? 'unhealthy'
|
||||
: statuses.some(s => s === 'degraded' || s === 'warning' || s === 'unknown') ? 'degraded'
|
||||
: 'healthy';
|
||||
|
||||
res.set('Cache-Control', 'no-store');
|
||||
success(res, {
|
||||
status: overall,
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
});
|
||||
}, 'system-health'));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -437,6 +437,8 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/api/v1/config', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/services/status', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
|
||||
// DC-075: System health endpoint for external uptime monitoring (UptimeRobot, BetterStack)
|
||||
{ path: '/api/v1/system/health', exact: true, method: 'GET' },
|
||||
// System Overview widget on the dashboard — needs the flattened CPU/mem
|
||||
// data without going through auth. See skill references/totp-and-system-overview-pitfalls.md §3.
|
||||
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET' },
|
||||
|
||||
Reference in New Issue
Block a user