Add checks/incidents APIs with filters and SLA summary

This commit is contained in:
nmemmert committed 2025-12-15 16:22:15 -05:00
1 parent 49cd477991
commit c8de1257ba
3 files changed
+241

No files matched your search

+6
View File
@@ -127,4 +127,10 @@ try {
db.prepare("ALTER TABLE checks ADD COLUMN cert_expiry_date TEXT").run(); db.prepare("ALTER TABLE checks ADD COLUMN cert_expiry_date TEXT").run();
} catch (err) {} } catch (err) {}
// 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);
`);
module.exports = db; module.exports = db;
+67
View File
@@ -481,6 +481,73 @@ app.get('/api/resources/:id/history', (req, res) => {
}); });
}); });
// Paginated checks with filters
app.get('/api/resources/:id/checks', (req, res) => {
const resourceId = Number(req.params.id);
const {
limit = 50,
offset = 0,
status,
from,
to,
sort = 'desc',
} = req.query;
try {
const checks = monitorService.getChecks(resourceId, {
limit: Math.min(Number(limit), 200),
offset: Number(offset),
status,
from,
to,
sort,
});
res.json({ checks });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Incidents timeline
app.get('/api/resources/:id/incidents', (req, res) => {
const resourceId = Number(req.params.id);
const {
limit = 50,
offset = 0,
status = 'all',
from,
to,
sort = 'desc',
} = req.query;
try {
const incidents = monitorService.getIncidents(resourceId, {
limit: Math.min(Number(limit), 200),
offset: Number(offset),
status,
from,
to,
sort,
});
res.json({ incidents });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// SLA/SLO summary for a resource
app.get('/api/resources/:id/sla', (req, res) => {
const resourceId = Number(req.params.id);
const hours = req.query.hours ? Number(req.query.hours) : 24;
try {
const summary = monitorService.getSlaSummary(resourceId, hours);
res.json(summary);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get all resources' check history for dashboard (optimized with aggregation) // Get all resources' check history for dashboard (optimized with aggregation)
app.get('/api/history/overview', (req, res) => { app.get('/api/history/overview', (req, res) => {
const { days = 7, page = 1, page: pageParam, averaged = 'false' } = req.query; const { days = 7, page = 1, page: pageParam, averaged = 'false' } = req.query;
+168
View File
@@ -356,6 +356,174 @@ class MonitorService {
checks: checks.slice(-50), // Last 50 checks checks: checks.slice(-50), // Last 50 checks
}; };
} }
/**
* Paginated checks with basic filters and ISO timestamps
*/
getChecks(resourceId, {
limit = 50,
offset = 0,
status = null,
from = null,
to = null,
sort = 'desc',
} = {}) {
const clauses = ['resource_id = ?'];
const params = [resourceId];
if (status) {
clauses.push('status = ?');
params.push(status);
}
if (from) {
clauses.push('checked_at >= ?');
params.push(from);
}
if (to) {
clauses.push('checked_at <= ?');
params.push(to);
}
const order = sort === 'asc' ? 'ASC' : 'DESC';
const sql = `
SELECT
id,
resource_id,
status,
response_time,
status_code,
error_message,
details,
REPLACE(checked_at, ' ', 'T') || 'Z' AS checked_at
FROM checks
WHERE ${clauses.join(' AND ')}
ORDER BY checked_at ${order}
LIMIT ? OFFSET ?
`;
return db.prepare(sql).all(...params, limit, offset);
}
/**
* Incidents timeline with filters
*/
getIncidents(resourceId, {
limit = 50,
offset = 0,
status = 'all', // all | open | closed
from = null,
to = null,
sort = 'desc',
} = {}) {
const clauses = ['resource_id = ?'];
const params = [resourceId];
if (status === 'open') {
clauses.push('resolved_at IS NULL');
} else if (status === 'closed') {
clauses.push('resolved_at IS NOT NULL');
}
if (from) {
clauses.push('started_at >= ?');
params.push(from);
}
if (to) {
clauses.push('started_at <= ?');
params.push(to);
}
const order = sort === 'asc' ? 'ASC' : 'DESC';
const sql = `
SELECT
id,
resource_id,
REPLACE(started_at, ' ', 'T') || 'Z' AS started_at,
CASE
WHEN resolved_at IS NULL THEN NULL
ELSE REPLACE(resolved_at, ' ', 'T') || 'Z'
END AS resolved_at
FROM incidents
WHERE ${clauses.join(' AND ')}
ORDER BY started_at ${order}
LIMIT ? OFFSET ?
`;
return db.prepare(sql).all(...params, limit, offset);
}
/**
* SLA/SLO summary over a time window
*/
getSlaSummary(resourceId, hours = 24) {
const checks = db.prepare(`
SELECT
status,
response_time,
REPLACE(checked_at, ' ', 'T') || 'Z' AS checked_at
FROM checks
WHERE resource_id = ?
AND checked_at >= datetime('now', '-' || ? || ' hours')
ORDER BY checked_at ASC
`).all(resourceId, hours);
if (checks.length < 2) {
return {
windowHours: hours,
uptimePct: 0,
downtimeMinutes: 0,
mttrMinutes: null,
mtbfMinutes: null,
p95LatencyMs: null,
totalChecks: checks.length,
};
}
let upMs = 0;
let downMs = 0;
const upRuns = [];
const downRuns = [];
for (let i = 0; i < checks.length - 1; i++) {
const cur = checks[i];
const next = checks[i + 1];
const curTime = new Date(cur.checked_at).getTime();
const nextTime = new Date(next.checked_at).getTime();
const span = Math.max(0, nextTime - curTime);
if (cur.status === 'up') {
upMs += span;
upRuns.push(span);
} else {
downMs += span;
downRuns.push(span);
}
}
const totalMs = upMs + downMs || 1;
const uptimePct = 100 * (upMs / totalMs);
const avg = arr => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : null;
const mttrMs = avg(downRuns);
const mtbfMs = avg(upRuns);
const latencies = checks
.map(c => c.response_time)
.filter(v => typeof v === 'number')
.sort((a, b) => a - b);
const p95LatencyMs = latencies.length
? latencies[Math.floor(0.95 * (latencies.length - 1))]
: null;
return {
windowHours: hours,
uptimePct: Number(uptimePct.toFixed(2)),
downtimeMinutes: Number((downMs / 60000).toFixed(2)),
mttrMinutes: mttrMs != null ? Number((mttrMs / 60000).toFixed(2)) : null,
mtbfMinutes: mtbfMs != null ? Number((mtbfMs / 60000).toFixed(2)) : null,
p95LatencyMs,
totalChecks: checks.length,
};
}
} }
module.exports = new MonitorService(); module.exports = new MonitorService();