diff --git a/apps/api/src/routes/monitors.ts b/apps/api/src/routes/monitors.ts index 169307d..3e02bb3 100644 --- a/apps/api/src/routes/monitors.ts +++ b/apps/api/src/routes/monitors.ts @@ -149,10 +149,40 @@ export const monitors = new Elysia({ prefix: "/monitors" }) SELECT id FROM monitors WHERE id = ${params.id} AND account_id = ${accountId} `; if (!monitor) { set.status = 404; return { error: "Not found" }; } + const limit = Math.min(Number(query.limit) || 100, 1000); + const filter = query.filter; // "up", "down", "events", or undefined/all + const before = query.before; // cursor: ISO timestamp for pagination + + const cursorClause = before ? sql`AND checked_at < ${new Date(before)}` : sql``; + + if (filter === "up") { + return sql` + SELECT * FROM pings WHERE monitor_id = ${params.id} AND up = true ${cursorClause} + ORDER BY checked_at DESC LIMIT ${limit} + `; + } + if (filter === "down") { + return sql` + SELECT * FROM pings WHERE monitor_id = ${params.id} AND up = false ${cursorClause} + ORDER BY checked_at DESC LIMIT ${limit} + `; + } + if (filter === "events") { + // State changes: pings where `up` differs from the previous ping's `up` for this monitor + return sql` + SELECT * FROM ( + SELECT *, LAG(up) OVER (ORDER BY checked_at) AS prev_up + FROM pings WHERE monitor_id = ${params.id} + ) t + WHERE prev_up IS NULL OR up != prev_up + ${before ? sql`AND checked_at < ${new Date(before)}` : sql``} + ORDER BY checked_at DESC LIMIT ${limit} + `; + } + return sql` - SELECT * FROM pings - WHERE monitor_id = ${params.id} + SELECT * FROM pings WHERE monitor_id = ${params.id} ${cursorClause} ORDER BY checked_at DESC LIMIT ${limit} `; }, { detail: { summary: "Get ping history", tags: ["monitors"] } }); diff --git a/apps/monitor/src/runner.rs b/apps/monitor/src/runner.rs index db4dbfb..c915561 100644 --- a/apps/monitor/src/runner.rs +++ b/apps/monitor/src/runner.rs @@ -343,7 +343,19 @@ fn run_check_blocking( } const MAX_BODY: usize = 10 * 1024 * 1024; - let body_str = resp.body_mut().read_to_string().unwrap_or_default(); + let body_str = match resp.body_mut().read_to_string() { + Ok(s) => s, + Err(e) => { + // Try reading as raw bytes (e.g. compressed or non-UTF-8 responses) + let mut buf = Vec::new(); + let _ = std::io::Read::read_to_end(resp.body_mut().as_reader(), &mut buf); + if buf.is_empty() { + format!("[failed to read body: {}]", e) + } else { + String::from_utf8_lossy(&buf).into_owned() + } + } + }; let body_out = if body_str.len() > MAX_BODY { format!("[body truncated: {} bytes]", body_str.len()) } else { diff --git a/apps/web/src/views/detail.ejs b/apps/web/src/views/detail.ejs index 9740cdd..2f2f5db 100644 --- a/apps/web/src/views/detail.ejs +++ b/apps/web/src/views/detail.ejs @@ -111,8 +111,14 @@
-
+

Recent Pings

+
+ + + + +
@@ -131,7 +137,7 @@ <% pings.slice(0, 30).forEach(function(c) { const pingJson = JSON.stringify(c).split('&').join('&').split('"').join('"').split('<').join('<').split(String.fromCharCode(62)).join('>'); %> - + @@ -144,6 +150,9 @@
<%~ c.up ? 'Up' : 'Down' %> <%= c.status_code != null ? c.status_code : '—' %> <%= c.latency_ms != null ? c.latency_ms + 'ms' : '—' %>
+
+ +
@@ -881,24 +890,32 @@ updateStatusTooltip(); } - // Pings table — prepend row, cap at 100 + // Pings table — prepend row if filter matches const tbody = document.getElementById('pings-table'); if (tbody) { - const tr = document.createElement('tr'); - tr.className = 'table-row-alt cursor-pointer hover:bg-white/[0.02]'; - tr.dataset.ping = JSON.stringify(ping); - const regionDisplay = ping.region || '—'; - tr.innerHTML = ` - ${ping.up ? 'Up' : 'Down'} - ${ping.status_code ?? '—'} - ${ping.latency_ms != null ? ping.latency_ms + 'ms' : '—'} - ${regionDisplay} - ${ping.run_id || '—'} - ${timeAgo(ping.checked_at)}${ping.jitter_ms != null ? ` (+${ping.jitter_ms}ms)` : ''} - ${ping.error ? escapeHtml(ping.error) : ''} - `; - tbody.prepend(tr); - while (tbody.children.length > 100) tbody.removeChild(tbody.lastChild); + const shouldShow = _pingFilter === 'all' + || (_pingFilter === 'up' && ping.up) + || (_pingFilter === 'down' && !ping.up); + // For events filter, always prepend — it's a state change if it differs from current status + const isEvent = _pingFilter === 'events'; + + if (shouldShow || isEvent) { + const tr = document.createElement('tr'); + tr.className = 'table-row-alt cursor-pointer hover:bg-white/[0.02]'; + tr.dataset.ping = JSON.stringify(ping); + tr.dataset.up = ping.up ? '1' : '0'; + tr.innerHTML = pingRowHtml(ping); + if (shouldShow) { + tbody.prepend(tr); + } else if (isEvent) { + // Check if this is a state change from the most recent row + const firstRow = tbody.querySelector('tr[data-up]'); + if (!firstRow || firstRow.dataset.up !== (ping.up ? '1' : '0')) { + tbody.prepend(tr); + } + } + while (tbody.children.length > 100) tbody.removeChild(tbody.lastChild); + } } // Chart — push new ping, trim oldest runs, re-render @@ -911,6 +928,75 @@ renderChart(); updateTooltip(_hoverX); }); + + // ── Ping table filters (server-side) ────────────────────────── + let _pingFilter = 'all'; + let _loadingPings = false; + + function pingRowHtml(p) { + const up = p.up; + const statusHtml = up ? 'Up' : 'Down'; + const code = p.status_code != null ? p.status_code : '—'; + const latency = p.latency_ms != null ? p.latency_ms + 'ms' : '—'; + const region = p.region || '—'; + const runId = p.run_id || '—'; + const time = timeAgo(p.checked_at); + const jitter = p.jitter_ms != null ? ` (+${p.jitter_ms}ms)` : ''; + const error = p.error ? escapeHtml(p.error) : ''; + return ` + ${statusHtml} + ${code} + ${latency} + ${escapeHtml(region)} + ${escapeHtml(runId)} + ${time}${jitter} + ${error} + `; + } + + async function loadPings(filter, before) { + if (_loadingPings) return; + _loadingPings = true; + try { + let url = `/monitors/${monitorId}/pings?limit=50`; + if (filter && filter !== 'all') url += `&filter=${filter}`; + if (before) url += `&before=${encodeURIComponent(before)}`; + const pings = await api(url); + const tbody = document.getElementById('pings-table'); + if (!before) tbody.innerHTML = ''; + for (const p of pings) { + const tr = document.createElement('tr'); + tr.className = 'table-row-alt cursor-pointer hover:bg-white/[0.02]'; + tr.dataset.ping = JSON.stringify(p); + tr.dataset.up = p.up ? '1' : '0'; + tr.innerHTML = pingRowHtml(p); + tbody.appendChild(tr); + } + // Show/hide load more + const btn = document.getElementById('load-more'); + if (pings.length >= 50) { + btn.classList.remove('hidden'); + btn.dataset.before = pings[pings.length - 1].checked_at; + } else { + btn.classList.add('hidden'); + } + } catch (e) { + console.error('Failed to load pings:', e); + } + _loadingPings = false; + } + + document.getElementById('ping-filters').addEventListener('click', (e) => { + const btn = e.target.closest('[data-filter]'); + if (!btn) return; + _pingFilter = btn.dataset.filter; + document.querySelectorAll('#ping-filters button').forEach(b => { + b.className = b === btn + ? 'px-2.5 py-1 text-xs rounded-md bg-white/[0.06] text-gray-200 transition-colors' + : 'px-2.5 py-1 text-xs rounded-md text-gray-500 hover:text-gray-300 transition-colors'; + }); + loadPings(_pingFilter); + }); <%~ include('./partials/foot') %>