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('>');
%>
-
+
| <%~ c.up ? 'Up' : 'Down' %> |
<%= c.status_code != null ? c.status_code : '—' %> |
<%= c.latency_ms != null ? c.latency_ms + 'ms' : '—' %> |
@@ -144,6 +150,9 @@
+
+
+
@@ -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') %>