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:
1 parent
c8a8cc581a
commit
1844bccbd4
14 files changed
+586
-124
No files matched your search
@@ -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
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user