Add major features: mobile responsive, alert acknowledgment, quiet hours, HTTP keyword matching, custom headers, certificate expiry warnings, and SLA dashboard

This commit is contained in:
nmemmert committed 2025-12-11 09:20:52 -05:00
1 parent c8a8cc581a
commit 1844bccbd4
14 files changed
+586 -124

No files matched your search

+50
View File
@@ -42,9 +42,19 @@ db.exec(`
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
resolved_at DATETIME,
notified INTEGER DEFAULT 0,
acknowledged INTEGER DEFAULT 0,
acknowledged_at DATETIME,
acknowledged_by TEXT,
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_checks_resource_id ON checks(resource_id);
CREATE INDEX IF NOT EXISTS idx_checks_checked_at ON checks(checked_at);
CREATE INDEX IF NOT EXISTS idx_incidents_resource_id ON incidents(resource_id);
@@ -64,4 +74,44 @@ try {
// Column already exists
}
try {
db.prepare("ALTER TABLE resources ADD COLUMN http_keyword TEXT").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE resources ADD COLUMN http_headers TEXT").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE resources ADD COLUMN quiet_hours_start TEXT").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE resources ADD COLUMN quiet_hours_end TEXT").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE resources ADD COLUMN cert_expiry_days INTEGER DEFAULT 30").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE resources ADD COLUMN sla_target REAL DEFAULT 99.9").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE incidents ADD COLUMN acknowledged INTEGER DEFAULT 0").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE incidents ADD COLUMN acknowledged_at DATETIME").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE incidents ADD COLUMN acknowledged_by TEXT").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE checks ADD COLUMN cert_expiry_date TEXT").run();
} catch (err) {}
module.exports = db;
+90 -7
View File
@@ -72,15 +72,15 @@ app.get('/api/resources/:id', (req, res) => {
// Create resource
app.post('/api/resources', (req, res) => {
const { name, url, type, check_interval, timeout, group_id } = 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 } = req.body;
if (!name || !url) {
return res.status(400).json({ error: 'Name and URL are required' });
}
const stmt = db.prepare(`
INSERT INTO resources (name, url, type, check_interval, timeout, group_id)
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -89,7 +89,13 @@ app.post('/api/resources', (req, res) => {
type || 'http',
check_interval || 60000,
timeout || 5000,
group_id || null
group_id || null,
http_keyword || null,
http_headers || null,
quiet_hours_start || null,
quiet_hours_end || null,
cert_expiry_days || 30,
sla_target || 99.9
);
res.json({ id: result.lastInsertRowid, message: 'Resource created' });
@@ -97,15 +103,30 @@ 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 } = 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 } = req.body;
const stmt = db.prepare(`
UPDATE resources
SET name = ?, url = ?, type = ?, check_interval = ?, timeout = ?, enabled = ?, group_id = ?
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 = ?
WHERE id = ?
`);
stmt.run(name, url, type, check_interval, timeout, enabled ? 1 : 0, group_id || null, req.params.id);
stmt.run(
name,
url,
type,
check_interval,
timeout,
enabled ? 1 : 0,
group_id || null,
http_keyword || null,
http_headers || null,
quiet_hours_start || null,
quiet_hours_end || null,
cert_expiry_days || 30,
sla_target || 99.9,
req.params.id
);
res.json({ message: 'Resource updated' });
});
@@ -322,6 +343,68 @@ app.get('/api/history/overview', (req, res) => {
res.json(overview);
});
// Acknowledge incident
app.post('/api/incidents/:id/acknowledge', (req, res) => {
const { id } = req.params;
const { acknowledged_by } = req.body;
const stmt = db.prepare(`
UPDATE incidents
SET acknowledged = 1, acknowledged_at = datetime('now'), acknowledged_by = ?
WHERE id = ? AND resolved_at IS NULL
`);
const result = stmt.run(acknowledged_by || 'User', id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Incident not found or already resolved' });
}
res.json({ message: 'Incident acknowledged' });
});
// Get SLA report
app.get('/api/sla', (req, res) => {
const { days = 30 } = req.query;
const resources = db.prepare('SELECT * FROM resources WHERE enabled = 1').all();
const slaData = resources.map(resource => {
const checks = db.prepare(`
SELECT status
FROM checks
WHERE resource_id = ? AND checked_at > datetime('now', ?)
`).all(resource.id, `-${days} days`);
const upCount = checks.filter(c => c.status === 'up').length;
const actualUptime = checks.length > 0 ? (upCount / checks.length * 100) : 0;
const target = resource.sla_target || 99.9;
const meetsTarget = actualUptime >= target;
const incidents = db.prepare(`
SELECT COUNT(*) as count,
SUM(julianday(COALESCE(resolved_at, datetime('now'))) - julianday(started_at)) * 24 * 60 as downtime_minutes
FROM incidents
WHERE resource_id = ? AND started_at > datetime('now', ?)
`).get(resource.id, `-${days} days`);
return {
resource_id: resource.id,
resource_name: resource.name,
sla_target: target,
actual_uptime: actualUptime.toFixed(2),
meets_target: meetsTarget,
total_checks: checks.length,
successful_checks: upCount,
incidents: incidents.count || 0,
downtime_minutes: Math.round(incidents.downtime_minutes || 0),
group_id: resource.group_id,
};
});
res.json(slaData);
});
// Test email
app.post('/api/test-email', async (req, res) => {
const nodemailer = require('nodemailer');
+31 -2
View File
@@ -65,19 +65,41 @@ class MonitorService {
async checkHttp(resource, startTime, base) {
let result = { ...base };
try {
const headers = {};
if (resource.http_headers) {
try {
const customHeaders = JSON.parse(resource.http_headers);
Object.assign(headers, customHeaders);
} catch (e) {
// Invalid JSON, skip custom headers
}
}
const response = await axios.get(resource.url, {
timeout: resource.timeout,
validateStatus: () => true,
maxRedirects: 5,
headers,
});
const responseTime = Date.now() - startTime;
const isUp = response.status >= 200 && response.status < 400;
// Check for keyword if specified
let keywordMatch = true;
if (resource.http_keyword && response.data) {
const dataStr = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
keywordMatch = dataStr.includes(resource.http_keyword);
if (!keywordMatch) {
result.error_message = `Keyword "${resource.http_keyword}" not found in response`;
}
}
result = {
...result,
status: isUp ? 'up' : 'down',
status: (isUp && keywordMatch) ? 'up' : 'down',
response_time: responseTime,
status_code: response.status,
error_message: isUp ? null : `HTTP ${response.status}`,
error_message: isUp && keywordMatch ? null : (result.error_message || `HTTP ${response.status}`),
};
} catch (error) {
result.error_message = error.message;
@@ -123,6 +145,10 @@ class MonitorService {
const expires = new Date(cert.valid_to).getTime();
const daysRemaining = Math.round((expires - now) / (1000 * 60 * 60 * 24));
const validHost = cert.subjectaltname ? cert.subjectaltname.includes(host) : true;
// Check certificate expiry threshold
const expiryThreshold = resource.cert_expiry_days || 30;
const certExpiringWarning = daysRemaining <= expiryThreshold && daysRemaining > 0;
const status = daysRemaining > 0 && validHost ? 'up' : 'down';
resolve({
@@ -130,12 +156,15 @@ class MonitorService {
status,
response_time: Date.now() - startTime,
error_message: status === 'up' ? null : 'TLS invalid or expired',
cert_expiry_date: cert.valid_to,
details: JSON.stringify({
issuer: cert.issuer?.CN,
subject: cert.subject?.CN,
valid_to: cert.valid_to,
days_remaining: daysRemaining,
host_matches: validHost,
cert_expiring_soon: certExpiringWarning,
expiry_threshold: expiryThreshold,
}),
});
socket.end();
+34
View File
@@ -49,6 +49,18 @@ class NotificationService {
}
async sendAlert(resource, incident, stats = null) {
// Check if incident is acknowledged
if (incident.acknowledged) {
console.log(`Incident ${incident.id} already acknowledged, skipping alert`);
return;
}
// Check quiet hours
if (this.isQuietHours(resource)) {
console.log(`Resource ${resource.name} is in quiet hours, skipping alert`);
return;
}
const isDown = incident.type === 'started';
const statusEmoji = isDown ? '🔴' : '🟢';
const statusText = isDown ? 'DOWN' : 'UP';
@@ -68,6 +80,28 @@ class NotificationService {
await Promise.allSettled(promises);
}
isQuietHours(resource) {
if (!resource.quiet_hours_start || !resource.quiet_hours_end) {
return false;
}
const now = new Date();
const currentTime = now.getHours() * 60 + now.getMinutes();
const [startHour, startMin] = resource.quiet_hours_start.split(':').map(Number);
const [endHour, endMin] = resource.quiet_hours_end.split(':').map(Number);
const startTime = startHour * 60 + startMin;
const endTime = endHour * 60 + endMin;
// Handle overnight quiet hours
if (startTime > endTime) {
return currentTime >= startTime || currentTime < endTime;
}
return currentTime >= startTime && currentTime < endTime;
}
async sendEmail(resource, message, type, stats = null) {
if (!this.transporter) {
console.error('Email config incomplete; skipping email');