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}
`;
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"] } });

View File

@ -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 {

View File

@ -111,8 +111,14 @@
<!-- Recent pings table -->
<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>
<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 class="overflow-x-auto">
<table class="w-full text-sm">
@ -131,7 +137,7 @@
<% 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;');
%>
<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 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>
@ -144,6 +150,9 @@
</tbody>
</table>
</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>
<!-- Ping detail modal -->
@ -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 = `
<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>
<td class="px-4 py-2 text-gray-300">${ping.latency_ms != null ? ping.latency_ms + 'ms' : '—'}</td>
<td class="px-4 py-2 text-gray-500 text-sm" title="${ping.region || ''}">${regionDisplay}</td>
<td class="px-4 py-2 text-gray-600 font-mono text-xs">${ping.run_id || '—'}</td>
<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>
<td class="px-4 py-2 text-red-400/70 text-xs truncate max-w-[200px]">${ping.error ? escapeHtml(ping.error) : ''}</td>
`;
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 ? '<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>
<%~ include('./partials/foot') %>