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

+9
View File
@@ -311,10 +311,19 @@ try {
db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('incident_failure_threshold', '10')").run();
} catch (err) {}
// Feature: heartbeat / cron-job monitoring
try { db.prepare("ALTER TABLE resources ADD COLUMN heartbeat_token TEXT").run(); } catch(e) {}
try { db.prepare("ALTER TABLE resources ADD COLUMN heartbeat_timeout INTEGER DEFAULT 300000").run(); } catch(e) {}
try { db.prepare("ALTER TABLE resources ADD COLUMN last_heartbeat_at DATETIME").run(); } catch(e) {}
// Feature: status page per-resource visibility
try { db.prepare("ALTER TABLE resources ADD COLUMN is_public INTEGER DEFAULT 1").run(); } catch(e) {}
// Helpful indexes for time-range queries
db.exec(`
CREATE INDEX IF NOT EXISTS idx_checks_resource_time ON checks(resource_id, checked_at DESC);
CREATE INDEX IF NOT EXISTS idx_incidents_resource_time ON incidents(resource_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_resources_heartbeat_token ON resources(heartbeat_token);
`);
module.exports = db;
+238 -220
View File
@@ -3,6 +3,7 @@ const cors = require('cors');
const path = require('path');
const WebSocket = require('ws');
const http = require('http');
const { randomUUID } = require('crypto');
require('dotenv').config();
const db = require('./database');
@@ -105,6 +106,123 @@ function getTimezoneOffset() {
return result;
}
// Build settings object merging DB overrides on top of env vars
function buildSettingsFromDb(dbSettings = {}) {
return {
email_enabled: (dbSettings.email_enabled || process.env.EMAIL_ENABLED) === 'true',
email_host: dbSettings.email_host || process.env.EMAIL_HOST || 'smtp.gmail.com',
email_port: parseInt(dbSettings.email_port || process.env.EMAIL_PORT) || 587,
email_user: dbSettings.email_user || process.env.EMAIL_USER || '',
email_pass: '',
email_from: dbSettings.email_from || process.env.EMAIL_FROM || '',
email_to: dbSettings.email_to || process.env.EMAIL_TO || '',
webhook_enabled: (dbSettings.webhook_enabled || process.env.WEBHOOK_ENABLED) === 'true',
webhook_url: dbSettings.webhook_url || process.env.WEBHOOK_URL || '',
check_interval: parseInt(dbSettings.check_interval || process.env.CHECK_INTERVAL) || 60000,
timeout: parseInt(dbSettings.timeout || process.env.TIMEOUT) || 5000,
timezone: dbSettings.timezone || process.env.TIMEZONE || 'UTC',
retention_days: parseInt(dbSettings.retention_days || process.env.RETENTION_DAYS) || 7,
auto_cleanup_enabled: (dbSettings.auto_cleanup_enabled || process.env.AUTO_CLEANUP_ENABLED) === 'true',
consecutive_failures: parseInt(dbSettings.consecutive_failures || process.env.CONSECUTIVE_FAILURES) || 3,
grace_period: parseInt(dbSettings.grace_period || process.env.GRACE_PERIOD) || 300,
downtime_threshold: parseInt(dbSettings.downtime_threshold || process.env.DOWNTIME_THRESHOLD) || 600,
alert_retry_count: parseInt(dbSettings.alert_retry_count || process.env.ALERT_RETRY_COUNT) || 3,
alert_retry_delay: parseInt(dbSettings.alert_retry_delay || process.env.ALERT_RETRY_DELAY) || 60,
fallback_webhook: dbSettings.fallback_webhook || process.env.FALLBACK_WEBHOOK || '',
global_quiet_hours_start: dbSettings.global_quiet_hours_start || process.env.GLOBAL_QUIET_HOURS_START || '',
global_quiet_hours_end: dbSettings.global_quiet_hours_end || process.env.GLOBAL_QUIET_HOURS_END || '',
escalation_hours: parseInt(dbSettings.escalation_hours || process.env.ESCALATION_HOURS) || 4,
default_sort: dbSettings.default_sort || process.env.DEFAULT_SORT || 'name',
items_per_page: parseInt(dbSettings.items_per_page || process.env.ITEMS_PER_PAGE) || 20,
refresh_interval: parseInt(dbSettings.refresh_interval || process.env.REFRESH_INTERVAL) || 5000,
theme: dbSettings.theme || process.env.THEME || 'light',
incident_failure_threshold: parseInt(dbSettings.incident_failure_threshold || process.env.INCIDENT_FAILURE_THRESHOLD) || 10,
webhook_template: dbSettings.webhook_template || process.env.WEBHOOK_TEMPLATE || '',
};
}
// Build dashboard payload (shared between HTTP route and WebSocket broadcast)
function getDashboardPayload() {
const resources = db.prepare('SELECT * FROM resources ORDER BY group_id, name').all();
const groups = db.prepare('SELECT * FROM groups ORDER BY name').all();
const overview = resources.map(resource => {
const lastCheck = monitorService.getLastCheck(resource.id);
const stats = monitorService.getResourceStats(resource.id, 24);
const activeIncident = db.prepare(`
SELECT * FROM incidents WHERE resource_id = ? AND resolved_at IS NULL
`).get(resource.id);
const recentChecks = db.prepare(`
SELECT response_time, status, REPLACE(checked_at, ' ', 'T') || 'Z' as checked_at
FROM checks WHERE resource_id = ?
ORDER BY checked_at DESC LIMIT 15
`).all(resource.id).reverse();
// Pull cert expiry from latest TLS check details
let certDaysRemaining = null;
if (resource.type === 'tls') {
const lastTlsCheck = db.prepare(`
SELECT details FROM checks WHERE resource_id = ? AND details IS NOT NULL ORDER BY checked_at DESC LIMIT 1
`).get(resource.id);
if (lastTlsCheck?.details) {
try {
const d = JSON.parse(lastTlsCheck.details);
if (typeof d.days_remaining === 'number') certDaysRemaining = d.days_remaining;
} catch {}
}
}
return {
id: resource.id,
name: resource.name,
url: resource.url,
type: resource.type,
group_id: resource.group_id,
enabled: resource.enabled,
check_interval: resource.check_interval,
timeout: resource.timeout,
http_keyword: resource.http_keyword,
http_headers: resource.http_headers,
quiet_hours_start: resource.quiet_hours_start,
quiet_hours_end: resource.quiet_hours_end,
cert_expiry_days: resource.cert_expiry_days,
sla_target: resource.sla_target,
email_to: resource.email_to,
maintenance_mode: resource.maintenance_mode,
is_public: resource.is_public !== 0,
heartbeat_token: resource.heartbeat_token,
heartbeat_timeout: resource.heartbeat_timeout,
consecutive_failures_threshold: resource.consecutive_failures_threshold,
response_time_threshold: resource.response_time_threshold,
certDaysRemaining,
status: lastCheck?.status || 'unknown',
uptime: stats.uptime,
avgResponseTime: stats.avgResponseTime,
lastCheck: lastCheck?.checked_at,
hasActiveIncident: !!activeIncident,
recentChecks,
};
});
return { resources: overview, groups };
}
// Validate resource body fields (shared between POST and PUT)
function validateResourceBody(body, requireUrl = true) {
const { name, url, type, check_interval, timeout, sla_target, retention_days } = body;
const isHeartbeat = (type || 'http') === 'heartbeat';
if (!name) return 'Name is required';
if (!isHeartbeat && requireUrl && !url) return 'URL is required';
if (!isHeartbeat && url) {
try { new URL(url); } catch (e) { return 'Invalid URL format'; }
}
if (check_interval && (isNaN(check_interval) || check_interval < 10000)) return 'Check interval must be at least 10000ms';
if (timeout && (isNaN(timeout) || timeout < 1000)) return 'Timeout must be at least 1000ms';
if (sla_target !== undefined && sla_target !== null && (isNaN(sla_target) || sla_target < 0 || sla_target > 100)) return 'SLA target must be between 0 and 100';
if (retention_days && (isNaN(retention_days) || retention_days < 1 || retention_days > 365)) return 'Retention days must be between 1 and 365';
return null;
}
app.use(cors());
app.use(express.json());
@@ -135,17 +253,6 @@ app.use((req, res, next) => {
next();
});
// Error tracking middleware
app.use((err, req, res, next) => {
metrics.trackError(err, {
method: req.method,
url: req.url,
ip: req.ip
});
res.status(500).json({ error: 'Internal server error' });
});
// API Routes
// Get all groups
@@ -204,42 +311,24 @@ app.get('/api/resources/:id', (req, res) => {
// Create resource
app.post('/api/resources', (req, res) => {
const { name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days } = req.body;
const { name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold } = req.body;
if (!name || !url) {
return res.status(400).json({ error: 'Name and URL are required' });
}
// Validate URL format
try {
new URL(url);
} catch (e) {
return res.status(400).json({ error: 'Invalid URL format' });
}
// Validate numeric fields
if (check_interval && (isNaN(check_interval) || check_interval < 10000)) {
return res.status(400).json({ error: 'Check interval must be at least 10000ms' });
}
if (timeout && (isNaN(timeout) || timeout < 1000)) {
return res.status(400).json({ error: 'Timeout must be at least 1000ms' });
}
if (sla_target && (isNaN(sla_target) || sla_target < 0 || sla_target > 100)) {
return res.status(400).json({ error: 'SLA target must be between 0 and 100' });
}
if (retention_days && (isNaN(retention_days) || retention_days < 1 || retention_days > 365)) {
return res.status(400).json({ error: 'Retention days must be between 1 and 365' });
}
const validationError = validateResourceBody(req.body);
if (validationError) return res.status(400).json({ error: validationError });
try {
const isHeartbeat = (type || 'http') === 'heartbeat';
const token = isHeartbeat ? randomUUID() : null;
const effectiveUrl = isHeartbeat ? `heartbeat://${name}` : url;
const stmt = db.prepare(`
INSERT INTO resources (name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO resources (name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_token, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
name,
url,
effectiveUrl,
type || 'http',
check_interval || 60000,
timeout || 5000,
@@ -252,7 +341,12 @@ app.post('/api/resources', (req, res) => {
sla_target || 99.9,
email_to || null,
maintenance_mode ? 1 : 0,
retention_days || null
retention_days || null,
is_public !== false ? 1 : 0,
token,
heartbeat_timeout || 300000,
consecutive_failures_threshold || 1,
response_time_threshold || null
);
// Invalidate related cache entries
@@ -262,9 +356,8 @@ app.post('/api/resources', (req, res) => {
// Audit log
auditLog.logResourceChange('create', result.lastInsertRowid, { name, url, type, enabled: true }, 'system', req.ip);
res.json({ id: result.lastInsertRowid, message: 'Resource created' });
res.json({ id: result.lastInsertRowid, message: 'Resource created', heartbeat_token: token });
} catch (err) {
// Helpful hint if migration missing
const hint = err.message.includes('no column named maintenance_mode')
? 'Database schema missing maintenance_mode. Restart server to run migrations.'
: undefined;
@@ -274,21 +367,28 @@ app.post('/api/resources', (req, res) => {
// Update resource
app.put('/api/resources/:id', (req, res) => {
const { name, url, type, check_interval, timeout, enabled, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days } = req.body;
const { name, url, type, check_interval, timeout, enabled, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold } = req.body;
const validationError = validateResourceBody(req.body);
if (validationError) return res.status(400).json({ error: validationError });
const existing = db.prepare('SELECT url, type, heartbeat_token FROM resources WHERE id = ?').get(req.params.id);
const isHeartbeat = (type || existing?.type || 'http') === 'heartbeat';
const effectiveUrl = isHeartbeat ? (existing?.url || `heartbeat://${name}`) : url;
const stmt = db.prepare(`
UPDATE resources
SET name = ?, url = ?, type = ?, check_interval = ?, timeout = ?, enabled = ?, group_id = ?, http_keyword = ?, http_headers = ?, quiet_hours_start = ?, quiet_hours_end = ?, cert_expiry_days = ?, sla_target = ?, email_to = ?, maintenance_mode = ?, retention_days = ?
UPDATE resources
SET name = ?, url = ?, type = ?, check_interval = ?, timeout = ?, enabled = ?, group_id = ?, http_keyword = ?, http_headers = ?, quiet_hours_start = ?, quiet_hours_end = ?, cert_expiry_days = ?, sla_target = ?, email_to = ?, maintenance_mode = ?, retention_days = ?, is_public = ?, heartbeat_timeout = ?, consecutive_failures_threshold = ?, response_time_threshold = ?
WHERE id = ?
`);
stmt.run(
name,
url,
type,
check_interval,
timeout,
enabled ? 1 : 0,
name,
effectiveUrl,
type,
check_interval,
timeout,
enabled ? 1 : 0,
group_id || null,
http_keyword || null,
http_headers || null,
@@ -299,10 +399,13 @@ app.put('/api/resources/:id', (req, res) => {
email_to || null,
maintenance_mode ? 1 : 0,
retention_days || null,
is_public !== false ? 1 : 0,
heartbeat_timeout || 300000,
consecutive_failures_threshold || 1,
response_time_threshold || null,
req.params.id
);
// Invalidate related cache entries
cache.invalidatePattern('history:');
cache.invalidatePattern('sla:');
@@ -382,68 +485,20 @@ app.patch('/api/resources/:id/maintenance-mode', (req, res) => {
// Delete resource
app.delete('/api/resources/:id', (req, res) => {
const resource = db.prepare('SELECT name, url FROM resources WHERE id = ?').get(req.params.id);
if (!resource) return res.status(404).json({ error: 'Resource not found' });
db.prepare('DELETE FROM resources WHERE id = ?').run(req.params.id);
// Invalidate related cache entries
cache.invalidatePattern('history:');
cache.invalidatePattern('sla:');
// Audit log
if (resource) {
auditLog.logResourceChange('delete', req.params.id, { name: resource.name, url: resource.url }, 'system', req.ip);
}
auditLog.logResourceChange('delete', req.params.id, { name: resource.name, url: resource.url }, 'system', req.ip);
res.json({ message: 'Resource deleted' });
});
// Get dashboard overview (grouped)
app.get('/api/dashboard', (req, res) => {
const resources = db.prepare('SELECT * FROM resources ORDER BY group_id, name').all();
const groups = db.prepare('SELECT * FROM groups ORDER BY name').all();
const overview = resources.map(resource => {
const lastCheck = monitorService.getLastCheck(resource.id);
const stats = monitorService.getResourceStats(resource.id, 24);
const activeIncident = db.prepare(`
SELECT * FROM incidents
WHERE resource_id = ? AND resolved_at IS NULL
`).get(resource.id);
const recentChecks = db.prepare(`
SELECT response_time, status, REPLACE(checked_at, ' ', 'T') || 'Z' as checked_at
FROM checks
WHERE resource_id = ?
ORDER BY checked_at DESC
LIMIT 15
`).all(resource.id).reverse();
return {
id: resource.id,
name: resource.name,
url: resource.url,
type: resource.type,
group_id: resource.group_id,
enabled: resource.enabled,
check_interval: resource.check_interval,
timeout: resource.timeout,
http_keyword: resource.http_keyword,
http_headers: resource.http_headers,
quiet_hours_start: resource.quiet_hours_start,
quiet_hours_end: resource.quiet_hours_end,
cert_expiry_days: resource.cert_expiry_days,
sla_target: resource.sla_target,
email_to: resource.email_to,
status: lastCheck?.status || 'unknown',
uptime: stats.uptime,
avgResponseTime: stats.avgResponseTime,
lastCheck: lastCheck?.checked_at,
hasActiveIncident: !!activeIncident,
recentChecks,
};
});
res.json({ resources: overview, groups });
res.json(getDashboardPayload());
});
// Get incidents
@@ -550,90 +605,20 @@ app.post('/api/notifications/clear', (req, res) => {
// Get settings
app.get('/api/settings', (req, res) => {
// Prevent browser caching - always fetch fresh data
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
// Try to get settings from database first, fallback to process.env
try {
const dbSettings = {};
const rows = db.prepare('SELECT key, value FROM settings').all();
for (const row of rows) {
dbSettings[row.key] = row.value;
}
// Build response with database values taking precedence over env vars
const settings = {
email_enabled: (dbSettings.email_enabled || process.env.EMAIL_ENABLED) === 'true',
email_host: dbSettings.email_host || process.env.EMAIL_HOST || 'smtp.gmail.com',
email_port: parseInt(dbSettings.email_port || process.env.EMAIL_PORT) || 587,
email_user: dbSettings.email_user || process.env.EMAIL_USER || '',
email_pass: '', // Don't send password to client
email_from: dbSettings.email_from || process.env.EMAIL_FROM || '',
email_to: dbSettings.email_to || process.env.EMAIL_TO || '',
webhook_enabled: (dbSettings.webhook_enabled || process.env.WEBHOOK_ENABLED) === 'true',
webhook_url: dbSettings.webhook_url || process.env.WEBHOOK_URL || '',
check_interval: parseInt(dbSettings.check_interval || process.env.CHECK_INTERVAL) || 60000,
timeout: parseInt(dbSettings.timeout || process.env.TIMEOUT) || 5000,
timezone: dbSettings.timezone || process.env.TIMEZONE || 'UTC',
// Data retention settings
retention_days: parseInt(dbSettings.retention_days || process.env.RETENTION_DAYS) || 7,
auto_cleanup_enabled: (dbSettings.auto_cleanup_enabled || process.env.AUTO_CLEANUP_ENABLED) === 'true',
// Incident thresholds
consecutive_failures: parseInt(dbSettings.consecutive_failures || process.env.CONSECUTIVE_FAILURES) || 3,
grace_period: parseInt(dbSettings.grace_period || process.env.GRACE_PERIOD) || 300,
downtime_threshold: parseInt(dbSettings.downtime_threshold || process.env.DOWNTIME_THRESHOLD) || 600,
// Alert retry logic
alert_retry_count: parseInt(dbSettings.alert_retry_count || process.env.ALERT_RETRY_COUNT) || 3,
alert_retry_delay: parseInt(dbSettings.alert_retry_delay || process.env.ALERT_RETRY_DELAY) || 60,
fallback_webhook: dbSettings.fallback_webhook || process.env.FALLBACK_WEBHOOK || '',
// Alert scheduling
global_quiet_hours_start: dbSettings.global_quiet_hours_start || process.env.GLOBAL_QUIET_HOURS_START || '',
global_quiet_hours_end: dbSettings.global_quiet_hours_end || process.env.GLOBAL_QUIET_HOURS_END || '',
escalation_hours: parseInt(dbSettings.escalation_hours || process.env.ESCALATION_HOURS) || 4,
// Dashboard customization
default_sort: dbSettings.default_sort || process.env.DEFAULT_SORT || 'name',
items_per_page: parseInt(dbSettings.items_per_page || process.env.ITEMS_PER_PAGE) || 20,
refresh_interval: parseInt(dbSettings.refresh_interval || process.env.REFRESH_INTERVAL) || 5000,
theme: dbSettings.theme || process.env.THEME || 'light',
incident_failure_threshold: parseInt(dbSettings.incident_failure_threshold || process.env.INCIDENT_FAILURE_THRESHOLD) || 10,
};
res.json(settings);
res.json(buildSettingsFromDb(dbSettings));
} catch (error) {
console.error('Error reading settings:', error);
// Fallback to env vars only if database read fails
const settings = {
email_enabled: process.env.EMAIL_ENABLED === 'true',
email_host: process.env.EMAIL_HOST || 'smtp.gmail.com',
email_port: parseInt(process.env.EMAIL_PORT) || 587,
email_user: process.env.EMAIL_USER || '',
email_pass: '', // Don't send password to client
email_from: process.env.EMAIL_FROM || '',
email_to: process.env.EMAIL_TO || '',
webhook_enabled: process.env.WEBHOOK_ENABLED === 'true',
webhook_url: process.env.WEBHOOK_URL || '',
check_interval: parseInt(process.env.CHECK_INTERVAL) || 60000,
timeout: parseInt(process.env.TIMEOUT) || 5000,
timezone: process.env.TIMEZONE || 'UTC',
retention_days: parseInt(process.env.RETENTION_DAYS) || 7,
auto_cleanup_enabled: process.env.AUTO_CLEANUP_ENABLED === 'true',
consecutive_failures: parseInt(process.env.CONSECUTIVE_FAILURES) || 3,
grace_period: parseInt(process.env.GRACE_PERIOD) || 300,
downtime_threshold: parseInt(process.env.DOWNTIME_THRESHOLD) || 600,
alert_retry_count: parseInt(process.env.ALERT_RETRY_COUNT) || 3,
alert_retry_delay: parseInt(process.env.ALERT_RETRY_DELAY) || 60,
fallback_webhook: process.env.FALLBACK_WEBHOOK || '',
global_quiet_hours_start: process.env.GLOBAL_QUIET_HOURS_START || '',
global_quiet_hours_end: process.env.GLOBAL_QUIET_HOURS_END || '',
escalation_hours: parseInt(process.env.ESCALATION_HOURS) || 4,
default_sort: process.env.DEFAULT_SORT || 'name',
items_per_page: parseInt(process.env.ITEMS_PER_PAGE) || 20,
refresh_interval: parseInt(process.env.REFRESH_INTERVAL) || 5000,
theme: process.env.THEME || 'light',
incident_failure_threshold: parseInt(process.env.INCIDENT_FAILURE_THRESHOLD) || 10,
};
res.json(settings);
res.json(buildSettingsFromDb({}));
}
});
@@ -672,6 +657,7 @@ app.post('/api/settings', (req, res) => {
refresh_interval,
theme,
incident_failure_threshold,
webhook_template,
} = req.body;
// Validate email configuration if enabled
@@ -738,6 +724,7 @@ ITEMS_PER_PAGE=${items_per_page || 20}
REFRESH_INTERVAL=${refresh_interval || 5000}
THEME=${theme || 'light'}
INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10}
WEBHOOK_TEMPLATE=${webhook_template || ''}
`;
try {
@@ -772,6 +759,7 @@ INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10}
process.env.REFRESH_INTERVAL = String(refresh_interval || 5000);
process.env.THEME = theme || 'light';
process.env.INCIDENT_FAILURE_THRESHOLD = String(incident_failure_threshold || 10);
process.env.WEBHOOK_TEMPLATE = webhook_template || '';
// Also save to database for persistence
const settingsTable = [
@@ -783,6 +771,7 @@ INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10}
{ key: 'email_to', value: email_to },
{ key: 'webhook_enabled', value: String(webhook_enabled) },
{ key: 'webhook_url', value: webhook_url },
{ key: 'webhook_template', value: webhook_template || '' },
{ key: 'timezone', value: timezone || 'UTC' },
{ key: 'retention_days', value: String(retention_days || 7) },
{ key: 'check_interval', value: String(check_interval || 60000) },
@@ -806,7 +795,7 @@ INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10}
for (const setting of settingsTable) {
db.prepare(`
INSERT OR REPLACE INTO settings (key, value, updated_at)
INSERT OR REPLACE INTO settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
`).run(setting.key, setting.value);
}
@@ -821,6 +810,7 @@ INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10}
email_to,
webhook_enabled: webhook_enabled === true || webhook_enabled === 'true',
webhook_url,
webhook_template: webhook_template || '',
});
res.json({ message: 'Settings saved successfully' });
@@ -1717,6 +1707,67 @@ app.post('/api/resources/:id/alert-rules', (req, res) => {
}
});
// Get alert rules for a resource
app.get('/api/resources/:id/alert-rules', (req, res) => {
const resource = db.prepare('SELECT consecutive_failures_threshold, response_time_threshold FROM resources WHERE id = ?').get(req.params.id);
if (!resource) return res.status(404).json({ error: 'Resource not found' });
res.json(resource);
});
// Heartbeat ping endpoint — cron jobs POST here to signal they're alive
app.post('/api/heartbeat/:token', (req, res) => {
const resource = db.prepare('SELECT id, name FROM resources WHERE heartbeat_token = ? AND enabled = 1').get(req.params.token);
if (!resource) return res.status(404).json({ error: 'Unknown heartbeat token' });
db.prepare('UPDATE resources SET last_heartbeat_at = CURRENT_TIMESTAMP WHERE id = ?').run(resource.id);
// Write an 'up' check so we have a record of the ping
db.prepare(`
INSERT INTO checks (resource_id, status, response_time, checked_at)
VALUES (?, 'up', 0, CURRENT_TIMESTAMP)
`).run(resource.id);
res.json({ ok: true, resource: resource.name });
});
// Public status page — only returns resources with is_public = 1
app.get('/api/status-page', (req, res) => {
try {
const resources = db.prepare('SELECT * FROM resources WHERE is_public = 1 AND enabled = 1 ORDER BY name').all();
const incidents = db.prepare(`
SELECT i.*, r.name AS resource_name FROM incidents i
JOIN resources r ON r.id = i.resource_id
WHERE r.is_public = 1 AND i.resolved_at IS NULL
ORDER BY i.started_at DESC
`).all();
const maintenanceWindows = db.prepare(`
SELECT mw.*, r.name AS resource_name FROM maintenance_windows mw
JOIN resources r ON r.id = mw.resource_id
WHERE r.is_public = 1
ORDER BY mw.start_time DESC
`).all();
const overview = resources.map(resource => {
const stats = monitorService.getResourceStats(resource.id, 24);
const lastCheck = monitorService.getLastCheck(resource.id);
return {
id: resource.id,
name: resource.name,
url: resource.type === 'heartbeat' ? null : resource.url,
type: resource.type,
status: lastCheck?.status || 'unknown',
uptime: stats.uptime,
avgResponseTime: stats.avgResponseTime,
lastCheck: lastCheck?.checked_at,
};
});
res.json({ resources: overview, incidents, maintenanceWindows });
} catch (error) {
res.status(500).json({ error: 'Failed to load status page data' });
}
});
// Get transaction steps for a resource
app.get('/api/resources/:id/transaction-steps', (req, res) => {
const { id } = req.params;
@@ -2061,6 +2112,12 @@ app.use(express.static(path.join(__dirname, '../client/build'), {
}
}));
// Error tracking middleware — must be after all routes
app.use((err, req, res, next) => {
metrics.trackError(err, { method: req.method, url: req.url, ip: req.ip });
res.status(500).json({ error: 'Internal server error' });
});
// Only serve index.html for non-file routes (no extension)
app.get('*', (req, res, next) => {
// If the request has a file extension, let static middleware handle it or 404
@@ -2073,46 +2130,7 @@ app.get('*', (req, res, next) => {
// WebSocket handler function
function broadcastDashboardUpdate() {
try {
const resources = db.prepare('SELECT * FROM resources ORDER BY group_id, name').all();
const groups = db.prepare('SELECT * FROM groups ORDER BY name').all();
const overview = resources.map(resource => {
const lastCheck = monitorService.getLastCheck(resource.id);
const stats = monitorService.getResourceStats(resource.id, 24);
const activeIncident = db.prepare(`
SELECT * FROM incidents
WHERE resource_id = ? AND resolved_at IS NULL
`).get(resource.id);
const recentChecks = db.prepare(`
SELECT response_time, status, REPLACE(checked_at, ' ', 'T') || 'Z' as checked_at
FROM checks
WHERE resource_id = ?
ORDER BY checked_at DESC
LIMIT 15
`).all(resource.id).reverse();
return {
id: resource.id,
name: resource.name,
url: resource.url,
type: resource.type,
group_id: resource.group_id,
enabled: resource.enabled,
status: lastCheck?.status || 'unknown',
uptime: stats.uptime,
avgResponseTime: stats.avgResponseTime,
lastCheck: lastCheck?.checked_at,
hasActiveIncident: !!activeIncident,
recentChecks,
};
});
const message = JSON.stringify({
type: 'dashboard',
data: { resources: overview, groups }
});
const message = JSON.stringify({ type: 'dashboard', data: getDashboardPayload() });
wsClients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
+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) {
+53 -19
View File
@@ -20,13 +20,14 @@ class NotificationService {
email_to: process.env.EMAIL_TO,
webhook_enabled: process.env.WEBHOOK_ENABLED === 'true',
webhook_url: process.env.WEBHOOK_URL,
webhook_template: process.env.WEBHOOK_TEMPLATE || '',
});
}
setConfig(config) {
this.emailEnabled = !!config.email_enabled;
this.webhookEnabled = !!config.webhook_enabled;
this.config = config;
this.config = { ...this.config, ...config };
// Recreate transporter when email config changes; skip if incomplete
if (
@@ -69,10 +70,12 @@ class NotificationService {
}
const isDown = incident.type === 'started';
const statusEmoji = isDown ? '🔴' : '🟢';
const statusText = isDown ? 'DOWN' : 'UP';
const message = `${statusEmoji} ${resource.name} is ${statusText}!\n\nURL: ${resource.url}\nCheck Type: ${resource.type || 'http'}\nTime: ${new Date().toLocaleString()}`;
const isSlow = incident.type === 'slow';
const statusEmoji = isDown ? '🔴' : isSlow ? '🟡' : '🟢';
const statusText = isDown ? 'DOWN' : isSlow ? 'SLOW' : 'UP';
const slowDetail = isSlow ? `\nResponse Time: ${incident.responseTime}ms (threshold: ${incident.threshold}ms)` : '';
const message = `${statusEmoji} ${resource.name} is ${statusText}!\n\nURL: ${resource.url}\nCheck Type: ${resource.type || 'http'}\nTime: ${new Date().toLocaleString()}${slowDetail}`;
const promises = [];
@@ -103,16 +106,18 @@ class NotificationService {
await Promise.allSettled(promises);
// Save notification to in-app notification center
const status = isDown ? 'down' : 'up';
const status = isDown ? 'down' : isSlow ? 'slow' : 'up';
this.saveNotification(resource, incident, status, message);
}
saveNotification(resource, incident, status, message) {
try {
const db = require('./database');
const title = status === 'down' ?
`🔴 ${resource.name} is DOWN` :
`🟢 ${resource.name} is UP`;
const title = status === 'down'
? `🔴 ${resource.name} is DOWN`
: status === 'slow'
? `🟡 ${resource.name} is SLOW`
: `🟢 ${resource.name} is UP`;
const result = db.prepare(`
INSERT INTO notifications (resource_id, incident_id, type, title, message, read)
@@ -179,12 +184,18 @@ class NotificationService {
? `<div style="margin-bottom: 12px;"><img src="cid:${logoCid}" alt="SkyWatch" style="height: 40px; width: 40px; border-radius: 8px;" /></div>`
: '';
const isSlow = type === 'slow';
const isDown = type === 'started';
const headingColor = isDown ? '#d32f2f' : isSlow ? '#f57c00' : '#388e3c';
const headingText = isDown ? '🔴 Alert' : isSlow ? '🟡 Slow Response' : '🟢 Recovered';
const statusLabel = isDown ? 'DOWN' : isSlow ? 'SLOW' : 'UP';
let htmlContent = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
${logoBlock}
<h2 style="color: ${type === 'started' ? '#d32f2f' : '#388e3c'};">${type === 'started' ? '🔴 Alert' : '🟢 Recovered'}</h2>
<h2 style="color: ${headingColor};">${headingText}</h2>
<p><strong>Resource:</strong> ${resource.name}</p>
<p><strong>Status:</strong> ${type === 'started' ? 'DOWN' : 'UP'}</p>
<p><strong>Status:</strong> ${statusLabel}</p>
<p><strong>URL:</strong> <a href="${resource.url}">${resource.url}</a></p>
<p><strong>Check Type:</strong> ${resource.type || 'http'}</p>
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
@@ -221,7 +232,7 @@ class NotificationService {
await this.transporter.sendMail({
from: this.config.email_from,
to: emailTo,
subject: `Alert: ${resource.name} is ${type === 'started' ? 'DOWN' : 'UP'}`,
subject: `Alert: ${resource.name} is ${type === 'started' ? 'DOWN' : type === 'slow' ? 'SLOW' : 'UP'}`,
text: message,
html: htmlContent,
attachments: hasLogo
@@ -282,13 +293,36 @@ class NotificationService {
async sendWebhook(resource, message, type) {
try {
await axios.post(this.config.webhook_url || process.env.WEBHOOK_URL, {
resource: resource.name,
url: resource.url,
status: type === 'started' ? 'down' : 'up',
message,
timestamp: new Date().toISOString(),
});
const webhookUrl = this.config.webhook_url || process.env.WEBHOOK_URL;
const status = type === 'started' ? 'down' : type === 'slow' ? 'slow' : 'up';
const template = this.config.webhook_template;
let payload;
if (template) {
const interpolated = template
.replace(/\{\{name\}\}/g, resource.name)
.replace(/\{\{status\}\}/g, status)
.replace(/\{\{url\}\}/g, resource.url)
.replace(/\{\{type\}\}/g, resource.type || 'http')
.replace(/\{\{message\}\}/g, message)
.replace(/\{\{timestamp\}\}/g, new Date().toISOString());
try {
payload = JSON.parse(interpolated);
} catch {
// Template is plain text (e.g. Slack text field), wrap it
payload = { text: interpolated };
}
} else {
payload = {
resource: resource.name,
url: resource.url,
status,
message,
timestamp: new Date().toISOString(),
};
}
await axios.post(webhookUrl, payload);
} catch (error) {
// Webhook error handled silently
}
+45 -40
View File
@@ -7,6 +7,8 @@ class Scheduler {
constructor() {
this.jobs = new Map();
this.isRunning = false;
this.lastCheckedAt = new Map();
this.slowAlertCooldown = new Map();
}
start() {
@@ -47,40 +49,21 @@ class Scheduler {
SELECT id, retention_days FROM resources
`).all();
const insertArchived = db.prepare(`
const bulkArchive = db.prepare(`
INSERT INTO archived_checks (resource_id, status, response_time, status_code, error_message, details, checked_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const deleteOld = db.prepare(`
DELETE FROM checks WHERE id = ?
SELECT resource_id, status, response_time, status_code, error_message, details, checked_at
FROM checks WHERE resource_id = ? AND checked_at < ?
`);
const bulkDelete = db.prepare(`DELETE FROM checks WHERE resource_id = ? AND checked_at < ?`);
const transaction = db.transaction(() => {
resources.forEach(resource => {
// Use per-resource retention if set, otherwise use global
const retentionDays = resource.retention_days || globalRetentionDays;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
const cutoffString = cutoffDate.toISOString().replace('T', ' ').split('.')[0];
// Get old checks for this resource
const oldChecks = db.prepare(`
SELECT * FROM checks WHERE resource_id = ? AND checked_at < ?
`).all(resource.id, cutoffString);
oldChecks.forEach(check => {
insertArchived.run(
check.resource_id,
check.status,
check.response_time,
check.status_code,
check.error_message,
check.details,
check.checked_at
);
deleteOld.run(check.id);
});
bulkArchive.run(resource.id, cutoffString);
bulkDelete.run(resource.id, cutoffString);
});
});
@@ -101,6 +84,13 @@ class Scheduler {
const baseSpacingMs = 250; // spread starts in 250ms increments
const tasks = resources.map((resource, index) => (async () => {
// Honour per-resource check_interval — skip if not yet due
const now = Date.now();
const lastChecked = this.lastCheckedAt.get(resource.id) || 0;
const interval = resource.check_interval || 60000;
if (now - lastChecked < interval) return;
this.lastCheckedAt.set(resource.id, now);
await sleep(index * baseSpacingMs + jitter());
try {
const result = await monitorService.checkResource(resource);
@@ -111,19 +101,15 @@ class Scheduler {
const capSetting = db.prepare(`SELECT value FROM settings WHERE key = 'checks_cap_per_resource'`).get();
const cap = capSetting ? parseInt(capSetting.value) : 10000;
if (Number.isFinite(cap) && cap > 0) {
// Delete oldest rows beyond cap, using id ordering per resource
db.prepare(`
DELETE FROM checks
WHERE id IN (
SELECT id FROM checks
WHERE resource_id = ?
ORDER BY id ASC
LIMIT (
SELECT MAX(COUNT(*) - ?, 0)
FROM checks WHERE resource_id = ?
const rowCount = db.prepare('SELECT COUNT(*) as cnt FROM checks WHERE resource_id = ?').get(resource.id).cnt;
const excess = rowCount - cap;
if (excess > 0) {
db.prepare(`
DELETE FROM checks WHERE id IN (
SELECT id FROM checks WHERE resource_id = ? ORDER BY id ASC LIMIT ?
)
)
`).run(resource.id, cap, resource.id);
`).run(resource.id, excess);
}
}
} catch (e) {
// Non-fatal
@@ -137,9 +123,9 @@ class Scheduler {
if (incident.type !== 'none') {
const stats = monitorService.getResourceStats(resource.id, 24);
const recentChecks = db.prepare(`
SELECT response_time, status FROM checks
WHERE resource_id = ?
ORDER BY checked_at DESC
SELECT response_time, status FROM checks
WHERE resource_id = ?
ORDER BY checked_at DESC
LIMIT 12
`).all(resource.id);
stats.recentChecks = recentChecks.reverse();
@@ -147,6 +133,25 @@ class Scheduler {
await notificationService.sendAlert(resource, incident, stats);
}
}
// Response time threshold alert (only when up, with 1-hour cooldown)
if (
result.status === 'up' &&
resource.response_time_threshold &&
result.response_time > resource.response_time_threshold &&
!resource.maintenance_mode
) {
const lastSlowAlert = this.slowAlertCooldown.get(resource.id) || 0;
if (now - lastSlowAlert > 60 * 60 * 1000) {
this.slowAlertCooldown.set(resource.id, now);
await notificationService.sendAlert(resource, {
type: 'slow',
responseTime: result.response_time,
threshold: resource.response_time_threshold,
id: null,
});
}
}
} catch (error) {
// Check error handled silently
}