diff --git a/dashcaddy-api/routes/health.js b/dashcaddy-api/routes/health.js index 2035945..7ba2794 100644 --- a/dashcaddy-api/routes/health.js +++ b/dashcaddy-api/routes/health.js @@ -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; }; diff --git a/dashcaddy-api/src/utilities/middleware.js b/dashcaddy-api/src/utilities/middleware.js index 7c15ba0..9de422d 100644 --- a/dashcaddy-api/src/utilities/middleware.js +++ b/dashcaddy-api/src/utilities/middleware.js @@ -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' },