Add heartbeat monitoring, alert rules, cert badges, webhook templates, and status page visibility

- Heartbeat type: cron jobs POST to /api/heartbeat/:token to stay alive; scheduler marks
  them down if no ping is received within the configured timeout window
- Alert rules: per-resource consecutive_failures_threshold now respected in handleIncident,
  overriding the global setting; GET /api/resources/:id/alert-rules endpoint added
- SSL cert expiry badge: dashboard rows surface days_remaining for TLS monitors, turning
  orange at ≤30 days and red at ≤7 days
- Webhook payload templates: {{name}}/{{status}}/{{url}}/{{type}}/{{message}}/{{timestamp}}
  variables interpolated before POST; Slack-compatible text wrapping as fallback
- Status page visibility: is_public column per resource; GET /api/status-page returns only
  public resources; Status.js updated to use the new endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-31 09:08:15 -04:00
1 parent 34986b5760
commit a6cf6a9762
16 files changed
+554 -422

No files matched your search

+31 -4
View File
@@ -98,12 +98,36 @@ class MonitorService {
case 'icmp':
case 'ping':
return this.checkIcmp(resource, startTime, base);
case 'heartbeat':
return this.checkHeartbeat(resource, startTime, base);
default:
// Fallback to HTTP if unknown
return this.checkHttp(resource, startTime, base);
}
}
checkHeartbeat(resource, startTime, base) {
const now = Date.now();
const lastPing = resource.last_heartbeat_at
? new Date(resource.last_heartbeat_at).getTime()
: null;
const timeout = resource.heartbeat_timeout || 300000;
if (!lastPing) {
return { ...base, status: 'down', response_time: null, error_message: 'No heartbeat received yet' };
}
const elapsed = now - lastPing;
if (elapsed > timeout) {
return {
...base,
status: 'down',
response_time: elapsed,
error_message: `Missed heartbeat — ${Math.round(elapsed / 1000)}s since last ping (timeout: ${timeout / 1000}s)`,
};
}
return { ...base, status: 'up', response_time: elapsed };
}
async checkHttp(resource, startTime, base) {
let result = { ...base };
const circuitBreaker = this.getResourceCircuitBreaker(resource.id);
@@ -353,9 +377,12 @@ class MonitorService {
handleIncident(resourceId, isDown) {
const resource = db.prepare('SELECT * FROM resources WHERE id = ?').get(resourceId);
// Get incident failure threshold from settings (default 10)
// Per-resource threshold overrides global setting
const setting = db.prepare("SELECT value FROM settings WHERE key = 'incident_failure_threshold'").get();
const threshold = parseInt(setting?.value || '10');
const globalThreshold = parseInt(setting?.value || '10');
const threshold = (resource?.consecutive_failures_threshold > 0)
? resource.consecutive_failures_threshold
: globalThreshold;
const activeIncident = db.prepare(`
SELECT * FROM incidents
@@ -371,8 +398,8 @@ class MonitorService {
LIMIT ?
`).all(resourceId, threshold);
const consecutiveFailures = recentChecks.every(c => c.status === 'down') ? recentChecks.length :
recentChecks.findIndex(c => c.status === 'up') + 1;
const firstUpIdx = recentChecks.findIndex(c => c.status === 'up');
const consecutiveFailures = firstUpIdx === -1 ? recentChecks.length : firstUpIdx;
// Only trigger incident if we've hit the threshold
if (consecutiveFailures >= threshold && !activeIncident) {