/** * Health Check Dashboard Module * Monitors service health, response times, and uptime * Provides SLA tracking and incident management */ const https = require('https'); const http = require('http'); const crypto = require('crypto'); const EventEmitter = require('events'); const fs = require('fs'); const path = require('path'); const paths = require('../../platform-paths'); // Persist health config + history alongside the other state files (services.json, // config.json) rather than next to the source. In a container that data dir is the // mounted /app/data volume, so uptime history survives container recreates/updates; // previously these defaulted to __dirname (unmounted /app) and every recreate wiped // the accumulated history, blanking the dashboard uptime bars. Explicit env vars // still override. const HEALTH_DATA_DIR = process.env.HEALTH_DATA_DIR || path.dirname(paths.configFile); const HEALTH_CONFIG_FILE = process.env.HEALTH_CONFIG_FILE || path.join(HEALTH_DATA_DIR, 'health-config.json'); const HEALTH_HISTORY_FILE = process.env.HEALTH_HISTORY_FILE || path.join(HEALTH_DATA_DIR, 'health-history.json'); // Legacy locations (next to the source) used before the data-dir default. Read these // once on first load if the new files are absent, so upgrading installs migrate their // accumulated history/config instead of starting empty. The next save() rewrites to // the new location. const LEGACY_HEALTH_CONFIG_FILE = path.join(__dirname, 'health-config.json'); const LEGACY_HEALTH_HISTORY_FILE = path.join(__dirname, 'health-history.json'); const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10); // 30 seconds const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff const MAX_ENTRIES_PER_SERVICE = parseInt(process.env.HEALTH_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10); // DC-086: hysteresis thresholds for badge display. // The raw probe result can flap on a single transient blip (Caddy reload, // container CPU steal, network hiccup, mid-flight TLS handshake). Showing // every probe result as-is to the dashboard creates the "perpetual flicker" // UX. Asymmetric thresholds: going red is slow (don't false-alarm), going // green is fast (don't keep showing red after recovery). // - DOWN_THRESHOLD = N consecutive "down" probes before the badge flips to red // - UP_THRESHOLD = N consecutive "up" probes before the badge flips back to green // Single probe flips to green on purpose — false-positive-green is much less // painful than perpetual-red (operators notice red, ignore green). function readPositiveIntEnv(name, fallback) { const raw = process.env[name]; if (raw === undefined || raw === '') return fallback; const value = Number(raw); return Number.isSafeInteger(value) && value >= 1 ? value : fallback; } const DOWN_THRESHOLD = readPositiveIntEnv('HEALTH_DOWN_THRESHOLD', 2); const UP_THRESHOLD = readPositiveIntEnv('HEALTH_UP_THRESHOLD', 1); class HealthChecker extends EventEmitter { constructor() { super(); this.config = this.loadConfig(); this.history = this.loadHistory(); this.currentStatus = new Map(); // DC-086: the status the dashboard SHOULD display (post-hysteresis). // Distinct from currentStatus, which is the latest raw probe result. this.displayedStatus = new Map(); // DC-086: counter of consecutive healthy/unhealthy probes since the // last displayed-status change. Reset to 0 whenever displayed status flips. this.consecutiveSinceChange = new Map(); this.incidents = []; this.checking = false; this.checkInterval = null; this.consecutiveFailures = new Map(); // serviceId -> failure count this.serviceTimers = new Map(); // serviceId -> timer for per-service backoff // Invalidate probe completions that race with removal/reconfiguration. this.serviceGenerations = new Map(); // serviceId -> configuration generation } /** * Start health checking */ start() { if (this.checking) return; this.checking = true; // Initial check this.checkAll(); // Schedule periodic checks this.checkInterval = setInterval(() => this.checkAll(), CHECK_INTERVAL); } /** * Stop health checking */ stop() { if (!this.checking) return; this.checking = false; if (this.checkInterval) { clearInterval(this.checkInterval); this.checkInterval = null; } // Clear per-service backoff timers for (const timer of this.serviceTimers.values()) { clearTimeout(timer); } this.serviceTimers.clear(); } /** * Get the backoff interval for a service based on consecutive failures. * Doubles the interval for each failure, capped at MAX_CHECK_INTERVAL. */ getBackoffInterval(serviceId) { const failures = this.consecutiveFailures.get(serviceId) || 0; if (failures === 0) return CHECK_INTERVAL; return Math.min(CHECK_INTERVAL * Math.pow(2, failures), MAX_CHECK_INTERVAL); } /** * Check all configured services */ async checkAll() { const services = Object.entries(this.config.services || {}); for (const [serviceId, config] of services) { if (config.enabled !== false) { try { await this.checkService(serviceId, config); } catch (error) { // Error logged via checkForIncidents } } } // Cleanup old history this.cleanupHistory(); } /** * Check a single service */ async checkService(serviceId, config) { const startTime = Date.now(); const generation = this.serviceGenerations.get(serviceId) || 0; try { const result = await this.performHealthCheck(config); const responseTime = Date.now() - startTime; const status = { serviceId, timestamp: new Date().toISOString(), status: result.healthy ? 'up' : 'down', responseTime, statusCode: result.statusCode, message: result.message, details: result.details }; if ((this.serviceGenerations.get(serviceId) || 0) !== generation) { return status; } // Track consecutive failures for exponential backoff if (result.healthy) { this.consecutiveFailures.delete(serviceId); } else { this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1); } const previousStatus = this.currentStatus.get(serviceId); this.recordStatus(serviceId, status); this.checkForIncidents(serviceId, status, config, previousStatus); return status; } catch (error) { const responseTime = Date.now() - startTime; // Increment failure count for backoff this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1); const status = { serviceId, timestamp: new Date().toISOString(), status: 'down', responseTime, error: error.message }; if ((this.serviceGenerations.get(serviceId) || 0) !== generation) { return status; } const previousStatus = this.currentStatus.get(serviceId); this.recordStatus(serviceId, status); this.checkForIncidents(serviceId, status, config, previousStatus); return status; } } /** * Perform actual health check */ async performHealthCheck(config) { const result = await this._doRequest(config, config.method || 'GET'); // Fall back to GET if HEAD is not supported if ((result.statusCode === 501 || result.statusCode === 405) && (config.method || '').toUpperCase() === 'HEAD') { return this._doRequest({ ...config, method: 'GET' }, 'GET'); } return result; } _doRequest(config, method) { return new Promise((resolve, reject) => { const url = new URL(config.url); const protocol = url.protocol === 'https:' ? https : http; // Merge user-supplied headers with the health-check marker. Caddy on // *.sami uses `forward_auth` for every non-API path and the auth gate // returns 401 for HEAD/GET without a session — without this marker the // probe never reaches the upstream service, and the authLimiter (20 req // / 15 min) on /auth/* would also rate-limit us after 20 probes. The // marker lets the Caddy snippet bypass forward_auth for probes that // originate from the local container network (see /etc/caddy/Caddyfile // `(dashcaddy_auth)` block). const headers = { ...(config.headers || {}), 'X-DashCaddy-HealthCheck': '1' }; const options = { hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname + url.search, method, timeout: config.timeout || 20000, headers, rejectUnauthorized: false // Trust internal CA certs (.sami TLD) }; const req = protocol.request(options, (res) => { let data = ''; res.on('data', chunk => { data += chunk; }); res.on('end', () => { const healthy = this.evaluateHealth(res.statusCode, data, config); resolve({ healthy, statusCode: res.statusCode, message: healthy ? 'Service is healthy' : 'Service check failed', details: { headers: res.headers ? { server: res.headers.server } : undefined, // Compact: disk explosion fix bodyLength: data.length } }); }); }); req.on('error', (error) => { reject(error); }); req.on('timeout', () => { req.destroy(); reject(new Error('Health check timeout')); }); if (config.body) { req.write(JSON.stringify(config.body)); } req.end(); }); } /** * Evaluate if service is healthy based on response */ evaluateHealth(statusCode, body, config) { // Check status code. Default expected codes include the usual 2xx/3xx // plus 401/403 (auth-walled UIs that still prove the service is up) and // 429 (rate-limited upstream — we hit the service, the service answered; // failing the check just because we're being throttled is wrong). const expectedCodes = config.expectedStatusCodes || [200, 201, 204, 301, 302, 303, 307, 308, 401, 403, 429]; if (!expectedCodes.includes(statusCode)) { return false; } // Check response body if pattern specified if (config.expectedBodyPattern) { const regex = new RegExp(config.expectedBodyPattern); if (!regex.test(body)) { return false; } } // Check response body contains expected text if (config.expectedBodyContains) { if (!body.includes(config.expectedBodyContains)) { return false; } } return true; } /** * Compute the displayed status for a service given the latest raw probe * result. Applies asymmetric hysteresis: * - Going DOWN: requires DOWN_THRESHOLD (default 2) consecutive "down" * probes since the last display-state change. A single blip keeps the * badge green. * - Going UP: requires UP_THRESHOLD (default 1) consecutive "up" probes. * Any single "up" after a down streak flips back to green so the badge * doesn't linger red after the service has recovered. * * Returns the displayed status object (same shape as the raw status) so * recordStatus can use it both for the displayed map and as the broadcast * payload when the displayed status actually changes. */ _computeDisplayedStatus(serviceId, rawStatus) { const currentDisplayed = this.displayedStatus.get(serviceId); const previousStatus = currentDisplayed ? currentDisplayed.status : null; // If no prior state, accept the raw probe as-is (first-check bootstrap). if (!previousStatus) { return rawStatus; } // Probe agrees with current displayed → no change, reset the counter so // a brief blip doesn't accumulate against the displayed state. if (rawStatus.status === previousStatus) { this.consecutiveSinceChange.set(serviceId, 0); return rawStatus; } // Probe disagrees with displayed. Bump the streak counter — this counts // CONSECUTIVE probes that disagree with what's shown, regardless of // whether the raw value itself changed between probes. That's what // makes "down, down" flip after threshold but "down, up, down" not flip. const prev = this.consecutiveSinceChange.get(serviceId) || 0; const next = prev + 1; if (rawStatus.status === 'down') { // Going DOWN: need DOWN_THRESHOLD consecutive probes that disagree // with the displayed "up" state. if (previousStatus === 'up' && next < DOWN_THRESHOLD) { this.consecutiveSinceChange.set(serviceId, next); // Keep the last internally-consistent displayed snapshot. Mixing the // raw failure metadata with status="up" would expose contradictory // API data (for example statusCode=500 on an "up" service). return currentDisplayed; } // Threshold met (or already down) — flip to red. this.consecutiveSinceChange.set(serviceId, 0); return rawStatus; } // rawStatus.status === 'up' (must be — the equal-to-displayed case above // already returned). Going UP after a down streak: need UP_THRESHOLD. if (previousStatus === 'down' && next < UP_THRESHOLD) { this.consecutiveSinceChange.set(serviceId, next); return currentDisplayed; } this.consecutiveSinceChange.set(serviceId, 0); return rawStatus; } /** * Record service status * * DC-086: history + consecutiveFailures are updated for EVERY probe * (operators want full probe history for postmortems). The dashboard's * `status-check` event is only emitted when the DISPLAYED status changes, * so the badge stops re-rendering on every probe. */ recordStatus(serviceId, status) { // Update current (raw) status — used by checkForIncidents and history. this.currentStatus.set(serviceId, status); // Add raw probe to history (full fidelity — operators rely on this). if (!this.history[serviceId]) { this.history[serviceId] = []; } this.history[serviceId].push(status); // Cap entries to prevent unbounded growth (disk explosion fix) if (this.history[serviceId].length > MAX_ENTRIES_PER_SERVICE) { this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE); } // Compute the post-hysteresis displayed status; only emit when it changes. // _computeDisplayedStatus compares the raw probe against the DISPLAYED // status (not the previous raw status), so the "consecutive since // change" counter doesn't depend on the order of writes here. const displayed = this._computeDisplayedStatus(serviceId, status); const previousDisplayed = this.displayedStatus.get(serviceId); const displayChanged = !previousDisplayed || previousDisplayed.status !== displayed.status; this.displayedStatus.set(serviceId, displayed); if (displayChanged) { // Emit with the displayed status so the dashboard renders the same // state the hysteresis just decided. The raw probe result is still // in `history` and `currentStatus` for anyone who wants it. this.emit('status-check', displayed); } // Save history periodically if (Math.random() < 0.05) { // 5% chance (every ~20 checks) this.saveHistory(); } } /** * Check for incidents (downtime, slow response, etc.) */ checkForIncidents(serviceId, status, config, previous = this.currentStatus.get(serviceId)) { // Check for status change (up -> down or down -> up) if (previous && previous.status !== status.status) { if (status.status === 'down') { this.createIncident(serviceId, 'outage', 'Service is down', status); } else if (status.status === 'up') { this.resolveIncident(serviceId, 'outage', status); } } // Check for slow response time const slowThreshold = config.slowResponseThreshold || 5000; // 5 seconds if (status.responseTime > slowThreshold) { this.createIncident(serviceId, 'slow-response', `Response time ${status.responseTime}ms exceeds threshold ${slowThreshold}ms`, status); } // Check SLA violations const sla = config.sla; if (sla) { const uptime = this.calculateUptime(serviceId, sla.period || 24); if (uptime < sla.target) { this.createIncident(serviceId, 'sla-violation', `Uptime ${uptime.toFixed(2)}% below SLA target ${sla.target}%`, status); } } } /** * Create a new incident */ createIncident(serviceId, type, message, status) { // Check if similar incident already exists const existing = this.incidents.find(i => i.serviceId === serviceId && i.type === type && i.status === 'open' ); if (existing) { // Update existing incident existing.lastOccurrence = status.timestamp; existing.occurrences++; return; } // Create new incident const incident = { id: `incident-${crypto.randomUUID()}`, serviceId, type, message, status: 'open', severity: this.calculateSeverity(type), createdAt: status.timestamp, lastOccurrence: status.timestamp, occurrences: 1, details: status }; this.incidents.push(incident); this.emit('incident-created', incident); this.emit('log', 'info', `Incident created: ${incident.id} - ${message}`); } /** * Resolve an incident */ resolveIncident(serviceId, type, status) { const incident = this.incidents.find(i => i.serviceId === serviceId && i.type === type && i.status === 'open' ); if (incident) { incident.status = 'resolved'; incident.resolvedAt = status.timestamp; incident.duration = new Date(incident.resolvedAt) - new Date(incident.createdAt); this.emit('incident-resolved', incident); this.emit('log', 'info', `Incident resolved: ${incident.id}`); } } /** * Calculate incident severity */ calculateSeverity(type) { switch (type) { case 'outage': return 'critical'; case 'sla-violation': return 'high'; case 'slow-response': return 'medium'; default: return 'low'; } } /** * Calculate uptime percentage for a service */ calculateUptime(serviceId, hours = 24) { const history = this.getServiceHistory(serviceId, hours); if (history.length === 0) return 100; const upChecks = history.filter(h => h.status === 'up').length; return (upChecks / history.length) * 100; } /** * Calculate average response time */ calculateAverageResponseTime(serviceId, hours = 24) { const history = this.getServiceHistory(serviceId, hours); if (history.length === 0) return 0; const total = history.reduce((sum, h) => sum + (h.responseTime || 0), 0); return total / history.length; } /** * Get service history for specified time period */ getServiceHistory(serviceId, hours = 24) { const cutoffTime = Date.now() - (hours * 60 * 60 * 1000); const history = this.history[serviceId] || []; return history.filter(h => new Date(h.timestamp).getTime() > cutoffTime ); } /** * Get current status for all services. * * DC-086: returns the DISPLAYED status (post-hysteresis), not the latest * raw probe. A page reload should show the same badge state the live * SSE stream is currently showing — otherwise an operator who reloads * the page after a single blip sees red even though the hysteresis kept * the badge green for them. */ getCurrentStatus() { const result = {}; for (const [serviceId, rawStatus] of this.currentStatus.entries()) { const config = this.config.services[serviceId]; const uptime24h = this.calculateUptime(serviceId, 24); const uptime7d = this.calculateUptime(serviceId, 168); const avgResponseTime = this.calculateAverageResponseTime(serviceId, 24); // Prefer the displayed status if we've already computed one; fall back // to the raw probe on the very first call (before recordStatus has run). const displayed = this.displayedStatus.get(serviceId) || rawStatus; result[serviceId] = { ...displayed, name: config?.name || serviceId, uptime: { '24h': uptime24h, '7d': uptime7d }, avgResponseTime, sla: config?.sla }; } return result; } /** * Get service statistics */ getServiceStats(serviceId, hours = 24) { const history = this.getServiceHistory(serviceId, hours); if (history.length === 0) return null; const upChecks = history.filter(h => h.status === 'up').length; const downChecks = history.length - upChecks; const responseTimes = history.map(h => h.responseTime || 0); return { serviceId, period: `${hours}h`, totalChecks: history.length, upChecks, downChecks, uptime: (upChecks / history.length) * 100, responseTime: { avg: responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length, min: Math.min(...responseTimes), max: Math.max(...responseTimes), p95: this.calculatePercentile(responseTimes, 95), p99: this.calculatePercentile(responseTimes, 99) } }; } /** * Calculate percentile */ calculatePercentile(values, percentile) { const sorted = values.slice().sort((a, b) => a - b); const index = Math.ceil((percentile / 100) * sorted.length) - 1; return sorted[index] || 0; } /** * Get open incidents */ getOpenIncidents() { return this.incidents.filter(i => i.status === 'open'); } /** * Get incident history */ getIncidentHistory(limit = 50) { return this.incidents.slice(-limit).reverse(); } /** * Configure health check for a service */ configureService(serviceId, config) { if (!this.config.services) { this.config.services = {}; } this.serviceGenerations.set(serviceId, (this.serviceGenerations.get(serviceId) || 0) + 1); this.config.services[serviceId] = { enabled: config.enabled !== false, name: config.name || serviceId, url: config.url, method: config.method || 'GET', timeout: config.timeout || 20000, expectedStatusCodes: config.expectedStatusCodes || [200], expectedBodyPattern: config.expectedBodyPattern, expectedBodyContains: config.expectedBodyContains, slowResponseThreshold: config.slowResponseThreshold || 5000, sla: config.sla, headers: config.headers || {}, body: config.body }; this.saveConfig(); } /** * Remove service configuration */ removeService(serviceId) { this.serviceGenerations.set(serviceId, (this.serviceGenerations.get(serviceId) || 0) + 1); if (this.config.services) { delete this.config.services[serviceId]; this.saveConfig(); } this.currentStatus.delete(serviceId); this.displayedStatus.delete(serviceId); this.consecutiveSinceChange.delete(serviceId); this.consecutiveFailures.delete(serviceId); const timer = this.serviceTimers.get(serviceId); if (timer) clearTimeout(timer); this.serviceTimers.delete(serviceId); delete this.history[serviceId]; } /** * Cleanup old history */ cleanupHistory() { const cutoffTime = Date.now() - (HISTORY_RETENTION_DAYS * 24 * 60 * 60 * 1000); for (const serviceId in this.history) { this.history[serviceId] = this.history[serviceId].filter(h => new Date(h.timestamp).getTime() > cutoffTime ); // Also cap total entries per service if (this.history[serviceId].length > MAX_ENTRIES_PER_SERVICE) { this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE); } } } /** * Load configuration */ loadConfig() { try { const file = fs.existsSync(HEALTH_CONFIG_FILE) ? HEALTH_CONFIG_FILE : (HEALTH_CONFIG_FILE !== LEGACY_HEALTH_CONFIG_FILE && fs.existsSync(LEGACY_HEALTH_CONFIG_FILE) ? LEGACY_HEALTH_CONFIG_FILE : null); if (file) { return JSON.parse(fs.readFileSync(file, 'utf8')); } } catch (error) { this.emit('log', 'error', `Error loading config: ${error.message}`); } return { services: {} }; } /** * Save configuration */ saveConfig() { try { fs.writeFileSync(HEALTH_CONFIG_FILE, JSON.stringify(this.config, null, 2)); } catch (error) { this.emit('log', 'error', `Error saving config: ${error.message}`); } } /** * Load history */ loadHistory() { try { const file = fs.existsSync(HEALTH_HISTORY_FILE) ? HEALTH_HISTORY_FILE : (HEALTH_HISTORY_FILE !== LEGACY_HEALTH_HISTORY_FILE && fs.existsSync(LEGACY_HEALTH_HISTORY_FILE) ? LEGACY_HEALTH_HISTORY_FILE : null); if (file) { return JSON.parse(fs.readFileSync(file, 'utf8')); } } catch (error) { this.emit('log', 'error', `Error loading history: ${error.message}`); } return {}; } /** * Save history */ saveHistory() { try { fs.writeFileSync(HEALTH_HISTORY_FILE, JSON.stringify(this.history)); // Compact JSON (no pretty-print) to reduce file size } catch (error) { this.emit('log', 'error', `Error saving history: ${error.message}`); } } } // Export singleton instance module.exports = new HealthChecker();