feat: filter pings
This commit is contained in:
parent
091096aa11
commit
7b5411ab64
|
|
@ -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"] } });
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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('&').split('"').join('"').split('<').join('<').split(String.fromCharCode(62)).join('>');
|
const pingJson = JSON.stringify(c).split('&').join('&').split('"').join('"').split('<').join('<').split(String.fromCharCode(62)).join('>');
|
||||||
%>
|
%>
|
||||||
<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') %>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue