feat: filter pings

This commit is contained in:
nate 2026-03-26 12:20:09 +04:00
parent 091096aa11
commit 7b5411ab64
3 changed files with 149 additions and 21 deletions

View File

@ -149,10 +149,40 @@ export const monitors = new Elysia({ prefix: "/monitors" })
SELECT id FROM monitors WHERE id = ${params.id} AND account_id = ${accountId} SELECT id FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
`; `;
if (!monitor) { set.status = 404; return { error: "Not found" }; } if (!monitor) { set.status = 404; return { error: "Not found" }; }
const limit = Math.min(Number(query.limit) || 100, 1000); 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` return sql`
SELECT * FROM pings SELECT * FROM pings WHERE monitor_id = ${params.id} ${cursorClause}
WHERE monitor_id = ${params.id}
ORDER BY checked_at DESC LIMIT ${limit} ORDER BY checked_at DESC LIMIT ${limit}
`; `;
}, { detail: { summary: "Get ping history", tags: ["monitors"] } }); }, { detail: { summary: "Get ping history", tags: ["monitors"] } });

View File

@ -343,7 +343,19 @@ fn run_check_blocking(
} }
const MAX_BODY: usize = 10 * 1024 * 1024; 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 { let body_out = if body_str.len() > MAX_BODY {
format!("[body truncated: {} bytes]", body_str.len()) format!("[body truncated: {} bytes]", body_str.len())
} else { } else {

View File

@ -111,8 +111,14 @@
<!-- Recent pings table --> <!-- Recent pings table -->
<div class="card-static mb-8 overflow-hidden"> <div class="card-static mb-8 overflow-hidden">
<div class="px-4 py-3 border-b divider"> <div class="px-4 py-3 border-b divider flex items-center justify-between">
<h3 class="text-sm text-gray-400">Recent Pings</h3> <h3 class="text-sm text-gray-400">Recent Pings</h3>
<div id="ping-filters" class="flex gap-1">
<button data-filter="all" class="px-2.5 py-1 text-xs rounded-md bg-white/[0.06] text-gray-200 transition-colors">All</button>
<button data-filter="up" class="px-2.5 py-1 text-xs rounded-md text-gray-500 hover:text-gray-300 transition-colors">Up</button>
<button data-filter="down" class="px-2.5 py-1 text-xs rounded-md text-gray-500 hover:text-gray-300 transition-colors">Down</button>
<button data-filter="events" class="px-2.5 py-1 text-xs rounded-md text-gray-500 hover:text-gray-300 transition-colors">Events</button>
</div>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
@ -131,7 +137,7 @@
<% pings.slice(0, 30).forEach(function(c) { <% pings.slice(0, 30).forEach(function(c) {
const pingJson = JSON.stringify(c).split('&').join('&amp;').split('"').join('&quot;').split('<').join('&lt;').split(String.fromCharCode(62)).join('&gt;'); const pingJson = JSON.stringify(c).split('&').join('&amp;').split('"').join('&quot;').split('<').join('&lt;').split(String.fromCharCode(62)).join('&gt;');
%> %>
<tr class="table-row-alt cursor-pointer hover:bg-white/[0.02]" data-ping="<%~ pingJson %>"> <tr class="table-row-alt cursor-pointer hover:bg-white/[0.02]" data-ping="<%~ pingJson %>" data-up="<%= c.up ? '1' : '0' %>">
<td class="px-4 py-2"><%~ c.up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>' %></td> <td class="px-4 py-2"><%~ c.up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>' %></td>
<td class="px-4 py-2 text-gray-300"><%= c.status_code != null ? c.status_code : '—' %></td> <td class="px-4 py-2 text-gray-300"><%= c.status_code != null ? c.status_code : '—' %></td>
<td class="px-4 py-2 text-gray-300"><%= c.latency_ms != null ? c.latency_ms + 'ms' : '—' %></td> <td class="px-4 py-2 text-gray-300"><%= c.latency_ms != null ? c.latency_ms + 'ms' : '—' %></td>
@ -144,6 +150,9 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="px-4 py-3 border-t divider">
<button id="load-more" class="hidden w-full text-sm text-gray-500 hover:text-gray-300 transition-colors py-1" onclick="loadPings(_pingFilter, this.dataset.before)">Load more</button>
</div>
</div> </div>
<!-- Ping detail modal --> <!-- Ping detail modal -->
@ -881,24 +890,32 @@
updateStatusTooltip(); updateStatusTooltip();
} }
// Pings table — prepend row, cap at 100 // Pings table — prepend row if filter matches
const tbody = document.getElementById('pings-table'); const tbody = document.getElementById('pings-table');
if (tbody) { if (tbody) {
const tr = document.createElement('tr'); const shouldShow = _pingFilter === 'all'
tr.className = 'table-row-alt cursor-pointer hover:bg-white/[0.02]'; || (_pingFilter === 'up' && ping.up)
tr.dataset.ping = JSON.stringify(ping); || (_pingFilter === 'down' && !ping.up);
const regionDisplay = ping.region || '—'; // For events filter, always prepend — it's a state change if it differs from current status
tr.innerHTML = ` const isEvent = _pingFilter === 'events';
<td class="px-4 py-2">${ping.up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>'}</td>
<td class="px-4 py-2 text-gray-300">${ping.status_code ?? '—'}</td> if (shouldShow || isEvent) {
<td class="px-4 py-2 text-gray-300">${ping.latency_ms != null ? ping.latency_ms + 'ms' : '—'}</td> const tr = document.createElement('tr');
<td class="px-4 py-2 text-gray-500 text-sm" title="${ping.region || ''}">${regionDisplay}</td> tr.className = 'table-row-alt cursor-pointer hover:bg-white/[0.02]';
<td class="px-4 py-2 text-gray-600 font-mono text-xs">${ping.run_id || '—'}</td> tr.dataset.ping = JSON.stringify(ping);
<td class="px-4 py-2 text-gray-500">${timeAgo(ping.checked_at)}${ping.jitter_ms != null ? ` <span class="text-gray-600 text-xs">(+${ping.jitter_ms}ms)</span>` : ''}</td> tr.dataset.up = ping.up ? '1' : '0';
<td class="px-4 py-2 text-red-400/70 text-xs truncate max-w-[200px]">${ping.error ? escapeHtml(ping.error) : ''}</td> tr.innerHTML = pingRowHtml(ping);
`; if (shouldShow) {
tbody.prepend(tr); tbody.prepend(tr);
while (tbody.children.length > 100) tbody.removeChild(tbody.lastChild); } 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 // Chart — push new ping, trim oldest runs, re-render
@ -911,6 +928,75 @@
renderChart(); renderChart();
updateTooltip(_hoverX); updateTooltip(_hoverX);
}); });
// ── Ping table filters (server-side) ──────────────────────────
let _pingFilter = 'all';
let _loadingPings = false;
function pingRowHtml(p) {
const up = p.up;
const statusHtml = up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>';
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 ? ` <span class="text-gray-600 text-xs">(+${p.jitter_ms}ms)</span>` : '';
const error = p.error ? escapeHtml(p.error) : '';
return `
<td class="px-4 py-2">${statusHtml}</td>
<td class="px-4 py-2 text-gray-300">${code}</td>
<td class="px-4 py-2 text-gray-300">${latency}</td>
<td class="px-4 py-2 text-gray-500 text-sm">${escapeHtml(region)}</td>
<td class="px-4 py-2 text-gray-600 font-mono text-xs">${escapeHtml(runId)}</td>
<td class="px-4 py-2 text-gray-500">${time}${jitter}</td>
<td class="px-4 py-2 text-red-400/70 text-xs truncate max-w-[200px]">${error}</td>
`;
}
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);
});
</script> </script>
<%~ include('./partials/foot') %> <%~ include('./partials/foot') %>