diff --git a/server/database.js b/server/database.js index 271a79e..e8b7cd3 100644 --- a/server/database.js +++ b/server/database.js @@ -127,4 +127,10 @@ try { db.prepare("ALTER TABLE checks ADD COLUMN cert_expiry_date TEXT").run(); } 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; diff --git a/server/index.js b/server/index.js index 14ff562..54aeb7b 100644 --- a/server/index.js +++ b/server/index.js @@ -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) app.get('/api/history/overview', (req, res) => { const { days = 7, page = 1, page: pageParam, averaged = 'false' } = req.query; diff --git a/server/monitorService.js b/server/monitorService.js index a4c0849..3654c92 100644 --- a/server/monitorService.js +++ b/server/monitorService.js @@ -356,6 +356,174 @@ class MonitorService { 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();