1009 lines
46 KiB
Plaintext
1009 lines
46 KiB
Plaintext
<%~ include('./partials/head', { title: 'Monitor', scripts: ['/dashboard/query-builder.js?v=' + it.h.qb] }) %>
|
|
<%~ include('./partials/nav', { nav: 'monitors' }) %>
|
|
|
|
<%
|
|
const m = it.monitor;
|
|
const pings = it.pings || [];
|
|
const lastPing = pings[0];
|
|
const upPings = pings.filter(p => p.up);
|
|
const latencies = pings.filter(p => p.latency_ms != null).map(p => p.latency_ms);
|
|
const avgLatency = latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : null;
|
|
const uptime = pings.length ? Math.round((upPings.length / pings.length) * 100) : null;
|
|
// Group pings by run_id for status bar
|
|
const barRuns = [];
|
|
const runMap = {};
|
|
for (const p of pings.slice(0, 120).reverse()) {
|
|
const rid = p.run_id || p.checked_at;
|
|
if (!runMap[rid]) {
|
|
runMap[rid] = { run_id: rid, up: 0, down: 0, total: 0, checked_at: p.checked_at, regions: [] };
|
|
barRuns.push(runMap[rid]);
|
|
}
|
|
runMap[rid].total++;
|
|
if (p.up) runMap[rid].up++; else runMap[rid].down++;
|
|
runMap[rid].regions.push({ region: p.region || '', up: p.up, latency_ms: p.latency_ms });
|
|
}
|
|
// Keep last 60 runs
|
|
const barPings = barRuns.slice(-60);
|
|
const chartPings = pings.slice().reverse();
|
|
%>
|
|
|
|
<main class="max-w-7xl mx-auto px-8 py-8">
|
|
<div class="mb-6">
|
|
<a href="/dashboard/home" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">← Back</a>
|
|
</div>
|
|
|
|
<div id="content">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-8">
|
|
<div>
|
|
<div class="flex items-center gap-3">
|
|
<span id="status-dot"><%~ lastPing ? (lastPing.up ? '<span class="inline-block w-2.5 h-2.5 rounded-full dot-up mr-2" title="Up"></span>' : '<span class="inline-block w-2.5 h-2.5 rounded-full dot-down mr-2" title="Down"></span>') : '<span class="inline-block w-2.5 h-2.5 rounded-full dot-unknown mr-2" title="Unknown"></span>' %></span>
|
|
<h2 id="monitor-name" class="text-xl font-semibold text-gray-100"><%= m.name %></h2>
|
|
</div>
|
|
<p id="monitor-url" class="text-sm text-gray-500 mt-1"><%= m.url %></p>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<form action="/dashboard/monitors/<%= m.id %>/toggle" method="POST" class="inline">
|
|
<button type="submit" id="toggle-btn" class="btn-secondary text-sm px-4 py-2 <%= m.enabled ? '' : '!border-green-800/50 !text-green-400 hover:!border-green-700/60' %>"><%= m.enabled ? 'Pause' : 'Resume' %></button>
|
|
</form>
|
|
<form action="/dashboard/monitors/<%= m.id %>/delete" method="POST" class="inline" onsubmit="return confirm('Delete this monitor? This cannot be undone.')">
|
|
<button type="submit" id="delete-btn" class="text-sm px-4 py-2 rounded-lg border border-red-900/30 text-red-400 hover:bg-red-900/20 hover:border-red-800/40 transition-colors">Delete</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats row -->
|
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
|
|
<div class="stat-card rounded-xl p-4">
|
|
<div class="text-xs text-gray-500 mb-1">Status</div>
|
|
<div id="stat-status" class="text-lg font-semibold"><%~ lastPing ? (lastPing.up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>') : '<span class="text-gray-500">-</span>' %></div>
|
|
</div>
|
|
<div class="stat-card rounded-xl p-4">
|
|
<div class="text-xs text-gray-500 mb-1">Avg Latency</div>
|
|
<div id="stat-latency" class="text-lg font-semibold text-gray-200"><%= avgLatency != null ? avgLatency + 'ms' : '-' %></div>
|
|
</div>
|
|
<div class="stat-card rounded-xl p-4">
|
|
<div class="text-xs text-gray-500 mb-1">Uptime</div>
|
|
<div id="stat-uptime" class="text-lg font-semibold text-gray-200"><%= uptime != null ? uptime + '%' : '-' %></div>
|
|
</div>
|
|
<div class="stat-card rounded-xl p-4">
|
|
<div class="text-xs text-gray-500 mb-1">Last Ping</div>
|
|
<div id="stat-last" class="text-lg font-semibold text-gray-200"><%~ lastPing ? it.timeAgoSSR(lastPing.checked_at) : '-' %></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Latency chart -->
|
|
<div class="card-static p-4 mb-8">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h3 class="text-sm text-gray-400">Response Time</h3>
|
|
<div id="chart-legend" class="flex gap-3 text-xs text-gray-500"></div>
|
|
</div>
|
|
<div id="latency-chart" class="relative h-48 w-full" style="cursor:crosshair">
|
|
<!-- SSR SVG chart (works without JS, replaced by canvas when JS loads) -->
|
|
<div id="chart-ssr" class="w-full h-full"><%~ it.latencyChartSSR(chartPings) %></div>
|
|
<canvas id="chart-canvas" class="w-full h-full hidden"></canvas>
|
|
<div id="chart-tooltip" class="absolute hidden card-static px-3 py-2 text-xs pointer-events-none z-10" style="min-width:140px"></div>
|
|
<div id="chart-crosshair" class="absolute top-0 bottom-0 w-px bg-gray-600/50 pointer-events-none hidden"></div>
|
|
<span id="chart-ymax" class="absolute top-1 left-2 text-gray-600 text-xs pointer-events-none"></span>
|
|
<span id="chart-ymin" class="absolute bottom-1 left-2 text-gray-600 text-xs pointer-events-none"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status bar -->
|
|
<div class="card-static p-4 mb-8">
|
|
<h3 class="text-sm text-gray-400 mb-3">Status History</h3>
|
|
<div class="relative">
|
|
<div id="status-bar" class="flex gap-[0.1rem] h-8 rounded-lg overflow-hidden">
|
|
<% if (barPings.length > 0) { %>
|
|
<% barPings.forEach(function(c) {
|
|
const color = c.down === 0 ? 'bg-green-500/70' : (c.up === 0 ? 'bg-red-500/70' : 'bg-orange-400/70');
|
|
const regionsJson = JSON.stringify(c.regions).split('&').join('&').split('"').join('"').split('<').join('<').split(String.fromCharCode(62)).join('>');
|
|
%>
|
|
<div class="flex-1 rounded-sm <%= color %> cursor-pointer" data-run="<%= c.run_id %>" data-up="<%= c.up %>" data-down="<%= c.down %>" data-total="<%= c.total %>" data-time="<%= c.checked_at %>" data-regions="<%~ regionsJson %>"></div>
|
|
<% }) %>
|
|
<% } else { %>
|
|
<div class="flex-1 bg-gray-800/50 text-center text-xs text-gray-600 leading-8 rounded-lg">No data</div>
|
|
<% } %>
|
|
</div>
|
|
<div id="status-tooltip" class="absolute hidden card-static px-3 py-2 text-xs pointer-events-none z-10 -top-2 -translate-y-full" style="min-width:160px"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent pings table -->
|
|
<div class="card-static mb-8 overflow-hidden">
|
|
<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">
|
|
<thead>
|
|
<tr class="text-gray-500 text-xs">
|
|
<th class="text-left px-4 py-2 font-medium">Status</th>
|
|
<th class="text-left px-4 py-2 font-medium">Code</th>
|
|
<th class="text-left px-4 py-2 font-medium">Latency</th>
|
|
<th class="text-left px-4 py-2 font-medium">Region</th>
|
|
<th class="text-left px-4 py-2 font-medium">Run ID</th>
|
|
<th class="text-left px-4 py-2 font-medium">Time / Jitter</th>
|
|
<th class="text-left px-4 py-2 font-medium">Error</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="pings-table" class="divide-y divide-border-subtle">
|
|
<% pings.slice(0, 30).forEach(function(c) {
|
|
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 %>" 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>
|
|
<td class="px-4 py-2 text-gray-500 text-sm"><%= c.region || '-' %></td>
|
|
<td class="px-4 py-2 text-gray-600 font-mono text-xs"><%= c.run_id || '-' %></td>
|
|
<td class="px-4 py-2 text-gray-500"><%~ it.timeAgoSSR(c.checked_at) %><% if (c.jitter_ms != null) { %> <span class="text-gray-600 text-xs">(+<%= c.jitter_ms %>ms)</span><% } %></td>
|
|
<td class="px-4 py-2 text-red-400/70 text-xs truncate max-w-[200px]"><%= c.error ? c.error : '' %></td>
|
|
</tr>
|
|
<% }) %>
|
|
</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>
|
|
|
|
<!-- Edit form -->
|
|
<div class="card-static p-6">
|
|
<h3 class="text-sm text-gray-400 mb-4">Edit Monitor</h3>
|
|
<%~ include('./partials/monitor-form', { _form: { monitor: m, isEdit: true, prefix: 'edit-', bg: 'bg-gray-800/50', border: 'border-border-subtle' }, plan: it.plan, regions: it.regions, channels: it.channels }) %>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<div id="ping-modal" class="hidden" style="position:fixed;inset:0;z-index:9999">
|
|
<div style="position:absolute;inset:0;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" onclick="closePingModal()"></div>
|
|
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:90%;max-width:800px;max-height:85vh;overflow-y:auto;background:#141418;border:1px solid #232329;border-radius:12px">
|
|
<div style="position:sticky;top:0;background:#141418;display:flex;align-items:center;justify-content:space-between;padding:12px 20px;border-bottom:1px solid #1e1e24">
|
|
<h3 style="font-size:14px;font-weight:500;color:#e5e7eb">Ping Details</h3>
|
|
<button onclick="closePingModal()" style="color:#6b7280;font-size:18px;cursor:pointer;background:none;border:none">×</button>
|
|
</div>
|
|
<div id="ping-modal-body" class="p-6 text-sm"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
|
|
const monitorId = '<%= m.id %>';
|
|
const _prefix = 'edit-';
|
|
const _initialQuery = <%~ JSON.stringify(m.query || null) %>;
|
|
<%~ include('./partials/monitor-form-js') %>
|
|
|
|
// ── Syntax highlighting ────────────────────────────────────────
|
|
function highlightJson(str) {
|
|
try { str = JSON.stringify(JSON.parse(str), null, 2); } catch {}
|
|
return escapeHtml(str).replace(
|
|
/("(?:\\.|[^"\\])*")\s*:/g,
|
|
'<span class="text-blue-400">$1</span>:'
|
|
).replace(
|
|
/:\s*("(?:\\.|[^"\\])*")/g,
|
|
': <span class="text-green-400">$1</span>'
|
|
).replace(
|
|
/:\s*(true|false)/g,
|
|
': <span class="text-yellow-400">$1</span>'
|
|
).replace(
|
|
/:\s*(null)/g,
|
|
': <span class="text-gray-500">$1</span>'
|
|
).replace(
|
|
/:\s*(-?\d+\.?\d*(?:[eE][+-]?\d+)?)/g,
|
|
': <span class="text-purple-400">$1</span>'
|
|
);
|
|
}
|
|
|
|
function highlightHtml(raw) {
|
|
let out = '';
|
|
let i = 0;
|
|
const s = raw;
|
|
const n = s.length;
|
|
|
|
while (i < n) {
|
|
// Comment
|
|
if (s.startsWith('<!--', i)) {
|
|
const end = s.indexOf('-->', i + 4);
|
|
const chunk = end === -1 ? s.slice(i) : s.slice(i, end + 3);
|
|
out += '<span class="text-gray-600">' + escapeHtml(chunk) + '</span>';
|
|
i += chunk.length;
|
|
continue;
|
|
}
|
|
// CDATA
|
|
if (s.startsWith('<![CDATA[', i)) {
|
|
const end = s.indexOf(']]>', i + 9);
|
|
const chunk = end === -1 ? s.slice(i) : s.slice(i, end + 3);
|
|
out += '<span class="text-gray-600">' + escapeHtml(chunk) + '</span>';
|
|
i += chunk.length;
|
|
continue;
|
|
}
|
|
// DOCTYPE / processing instruction
|
|
if (s.startsWith('<!', i) || s.startsWith('<?', i)) {
|
|
const end = s.indexOf('>', i);
|
|
const chunk = end === -1 ? s.slice(i) : s.slice(i, end + 1);
|
|
out += '<span class="text-gray-500">' + escapeHtml(chunk) + '</span>';
|
|
i += chunk.length;
|
|
continue;
|
|
}
|
|
// Script/style: highlight tag but treat content as plain text
|
|
const scriptMatch = s.slice(i).match(/^<(script|style)([\s>])/i);
|
|
if (scriptMatch) {
|
|
const tagName = scriptMatch[1];
|
|
const closeTag = '</' + tagName;
|
|
const closeIdx = s.toLowerCase().indexOf(closeTag.toLowerCase(), i + 1);
|
|
const tagEnd = s.indexOf('>', i);
|
|
if (tagEnd !== -1) {
|
|
// Opening tag
|
|
out += highlightTag(s.slice(i, tagEnd + 1));
|
|
const contentStart = tagEnd + 1;
|
|
if (closeIdx !== -1) {
|
|
// Content as plain
|
|
const content = s.slice(contentStart, closeIdx);
|
|
if (content) out += '<span class="text-gray-400">' + escapeHtml(content) + '</span>';
|
|
// Closing tag
|
|
const closeEnd = s.indexOf('>', closeIdx);
|
|
const closeChunk = closeEnd === -1 ? s.slice(closeIdx) : s.slice(closeIdx, closeEnd + 1);
|
|
out += highlightTag(closeChunk);
|
|
i = closeIdx + closeChunk.length;
|
|
} else {
|
|
i = tagEnd + 1;
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
// Regular tag
|
|
if (s[i] === '<' && (s[i+1] === '/' || /[a-zA-Z]/.test(s[i+1] || ''))) {
|
|
const end = s.indexOf('>', i);
|
|
if (end !== -1) {
|
|
out += highlightTag(s.slice(i, end + 1));
|
|
i = end + 1;
|
|
continue;
|
|
}
|
|
}
|
|
// Entity reference
|
|
if (s[i] === '&') {
|
|
const semi = s.indexOf(';', i);
|
|
if (semi !== -1 && semi - i < 10) {
|
|
const ent = s.slice(i, semi + 1);
|
|
out += '<span class="text-purple-400">' + escapeHtml(ent) + '</span>';
|
|
i = semi + 1;
|
|
continue;
|
|
}
|
|
}
|
|
// Plain text
|
|
const next = s.indexOf('<', i + 1);
|
|
const ampNext = s.indexOf('&', i + 1);
|
|
let textEnd = n;
|
|
if (next !== -1) textEnd = next;
|
|
if (ampNext !== -1 && ampNext < textEnd) textEnd = ampNext;
|
|
out += escapeHtml(s.slice(i, textEnd));
|
|
i = textEnd;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function highlightTag(tag) {
|
|
// Parse: </?tagname attrs... /?>
|
|
const m = tag.match(/^(<\/?)([^\s/>]+)([\s\S]*?)(\/?>\s*)$/);
|
|
if (!m) return '<span class="text-gray-500">' + escapeHtml(tag) + '</span>';
|
|
const [, open, name, attrs, close] = m;
|
|
let result = '<span class="text-gray-500">' + escapeHtml(open) + '</span>';
|
|
result += '<span class="text-red-400">' + escapeHtml(name) + '</span>';
|
|
if (attrs.trim()) result += highlightAttrs(attrs);
|
|
result += '<span class="text-gray-500">' + escapeHtml(close) + '</span>';
|
|
return result;
|
|
}
|
|
|
|
function highlightAttrs(str) {
|
|
let out = '';
|
|
let i = 0;
|
|
const n = str.length;
|
|
while (i < n) {
|
|
// Whitespace
|
|
if (/\s/.test(str[i])) { out += str[i]; i++; continue; }
|
|
// attr=value or attr="value" or attr='value' or boolean attr
|
|
const am = str.slice(i).match(/^([\w:.@-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/);
|
|
if (am) {
|
|
out += '<span class="text-yellow-400">' + escapeHtml(am[1]) + '</span>';
|
|
if (am[0].includes('=')) {
|
|
out += '<span class="text-gray-500">=</span>';
|
|
const val = am[2] ?? am[3] ?? am[4] ?? '';
|
|
const q = am[2] != null ? '"' : am[3] != null ? "'" : '';
|
|
out += '<span class="text-green-400">' + escapeHtml(q + val + q) + '</span>';
|
|
}
|
|
i += am[0].length;
|
|
} else {
|
|
out += escapeHtml(str[i]);
|
|
i++;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function highlightBody(body, contentType) {
|
|
const ct = (contentType || '').toLowerCase();
|
|
if (ct.includes('json')) return highlightJson(body);
|
|
if (ct.includes('xml') || ct.includes('html')) return highlightHtml(body);
|
|
return escapeHtml(body);
|
|
}
|
|
|
|
function getContentType(headers) {
|
|
if (!headers) return '';
|
|
for (const [k, v] of Object.entries(headers)) {
|
|
if (k.toLowerCase() === 'content-type') return String(v);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// ── Ping detail modal ──────────────────────────────────────────
|
|
function openPingModal(ping) {
|
|
const modalBody = document.getElementById('ping-modal-body');
|
|
const meta = ping.meta || {};
|
|
const headers = meta.headers || {};
|
|
const time = new Date(ping.checked_at);
|
|
const scheduled = ping.scheduled_at ? new Date(ping.scheduled_at) : null;
|
|
|
|
let html = '<div class="space-y-4">';
|
|
|
|
// Status row
|
|
html += '<div class="flex items-center gap-3">';
|
|
html += ping.up
|
|
? '<span class="inline-block w-2.5 h-2.5 rounded-full dot-up"></span><span class="text-green-400 font-medium">Up</span>'
|
|
: '<span class="inline-block w-2.5 h-2.5 rounded-full dot-down"></span><span class="text-red-400 font-medium">Down</span>';
|
|
if (ping.status_code != null) html += `<span class="text-gray-400 font-mono">${ping.status_code}</span>`;
|
|
if (ping.latency_ms != null) html += `<span class="text-gray-500">${ping.latency_ms}ms</span>`;
|
|
html += '</div>';
|
|
|
|
// Info grid
|
|
html += '<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">';
|
|
html += `<div class="text-gray-500">Checked at</div><div class="text-gray-300">${time.toLocaleString()}</div>`;
|
|
if (scheduled) html += `<div class="text-gray-500">Scheduled at</div><div class="text-gray-300">${scheduled.toLocaleString()}</div>`;
|
|
if (ping.jitter_ms != null) html += `<div class="text-gray-500">Jitter</div><div class="text-gray-300">${ping.jitter_ms}ms</div>`;
|
|
if (ping.region) html += `<div class="text-gray-500">Region</div><div class="text-gray-300">${escapeHtml(ping.region)}</div>`;
|
|
if (ping.run_id) html += `<div class="text-gray-500">Run ID</div><div class="text-gray-300 font-mono break-all">${escapeHtml(ping.run_id)}</div>`;
|
|
if (ping.cert_expiry_days != null) html += `<div class="text-gray-500">Cert expiry</div><div class="text-gray-300">${ping.cert_expiry_days} days</div>`;
|
|
if (ping.cert_issuer) html += `<div class="text-gray-500">Cert issuer</div><div class="text-gray-300">${escapeHtml(ping.cert_issuer)}</div>`;
|
|
if (ping.response_size != null) html += `<div class="text-gray-500">Response size</div><div class="text-gray-300">${ping.response_size >= 1024 ? (ping.response_size / 1024).toFixed(1) + ' KB' : ping.response_size + ' B'}</div>`;
|
|
if (ping.dns_ms != null || ping.tcp_ms != null || ping.tls_ms != null) {
|
|
var timingParts = [];
|
|
if (ping.dns_ms != null) timingParts.push('DNS ' + ping.dns_ms + 'ms');
|
|
if (ping.tcp_ms != null) timingParts.push('TCP ' + ping.tcp_ms + 'ms');
|
|
if (ping.tls_ms != null) timingParts.push('TLS ' + ping.tls_ms + 'ms');
|
|
timingParts.push('HTTP ' + (ping.latency_ms || 0) + 'ms');
|
|
html += '<div class="text-gray-500">Timing</div><div class="text-gray-300">' + timingParts.join(' / ') + '</div>';
|
|
}
|
|
if (ping.important) html += `<div class="text-gray-500">Important</div><div class="text-yellow-400">Yes</div>`;
|
|
html += '</div>';
|
|
|
|
// Error
|
|
if (ping.error) {
|
|
html += '<div>';
|
|
html += '<div class="text-xs text-gray-500 mb-1">Error</div>';
|
|
html += `<div class="text-red-400 text-xs bg-red-500/5 border border-red-500/10 rounded-lg px-3 py-2 font-mono break-all">${escapeHtml(ping.error)}</div>`;
|
|
html += '</div>';
|
|
}
|
|
|
|
// Response headers
|
|
const headerKeys = Object.keys(headers);
|
|
if (headerKeys.length > 0) {
|
|
html += '<div>';
|
|
html += '<div class="text-xs text-gray-500 mb-1">Response Headers</div>';
|
|
html += '<div class="bg-gray-800/50 border border-border-subtle rounded-lg px-3 py-2 text-xs font-mono max-h-64 overflow-y-auto">';
|
|
for (const [k, v] of Object.entries(headers)) {
|
|
html += `<div class="flex gap-2"><span class="text-blue-400 shrink-0">${escapeHtml(k)}:</span><span class="text-gray-300 break-all">${escapeHtml(String(v))}</span></div>`;
|
|
}
|
|
html += '</div></div>';
|
|
}
|
|
|
|
// Response body - loaded on demand
|
|
const contentType = getContentType(headers);
|
|
const ctLabel = contentType ? ` <span class="text-gray-600">(${escapeHtml(contentType.split(';')[0].trim())})</span>` : '';
|
|
html += '<div id="ping-body-section">';
|
|
if (ping.id) {
|
|
html += `<div class="text-xs text-gray-500 mb-1">Response Body${ctLabel}</div>`;
|
|
html += '<div id="ping-body-content" class="bg-gray-800/50 border border-border-subtle rounded-lg px-3 py-2 text-xs font-mono text-gray-500 min-h-[2rem] flex items-center">Loading...</div>';
|
|
}
|
|
html += '</div>';
|
|
|
|
html += '</div>';
|
|
modalBody.innerHTML = html;
|
|
document.getElementById('ping-modal').classList.remove('hidden');
|
|
|
|
// Fetch body asynchronously
|
|
if (ping.id) {
|
|
const ct = getContentType(headers);
|
|
api(`/pings/${ping.id}/body`).then(data => {
|
|
const el = document.getElementById('ping-body-content');
|
|
if (!el) return;
|
|
if (data.body) {
|
|
el.className = 'bg-gray-800/50 border border-border-subtle rounded-lg px-3 py-2 text-xs font-mono text-gray-300 whitespace-pre-wrap break-all max-h-80 overflow-y-auto';
|
|
el.innerHTML = highlightBody(data.body, ct);
|
|
} else {
|
|
el.textContent = 'No body stored';
|
|
}
|
|
}).catch(() => {
|
|
const el = document.getElementById('ping-body-content');
|
|
if (el) el.textContent = 'Failed to load';
|
|
});
|
|
}
|
|
}
|
|
|
|
function closePingModal() {
|
|
document.getElementById('ping-modal').classList.add('hidden');
|
|
}
|
|
|
|
// Close on Escape
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') closePingModal();
|
|
});
|
|
|
|
// Click handler for ping rows (event delegation)
|
|
document.getElementById('pings-table').addEventListener('click', (e) => {
|
|
const row = e.target.closest('tr[data-ping]');
|
|
if (!row) return;
|
|
try {
|
|
const ping = JSON.parse(row.dataset.ping);
|
|
console.log('opening modal for ping', ping.id);
|
|
openPingModal(ping);
|
|
console.log('modal hidden?', document.getElementById('ping-modal').classList.contains('hidden'));
|
|
} catch (err) { console.error('modal error', err); }
|
|
});
|
|
|
|
// Toggle button
|
|
document.getElementById('toggle-btn').onclick = async (e) => {
|
|
e.preventDefault();
|
|
await api(`/monitors/${monitorId}/toggle`, { method: 'POST' });
|
|
location.reload();
|
|
};
|
|
|
|
// Delete button
|
|
document.getElementById('delete-btn').onclick = async (e) => {
|
|
e.preventDefault();
|
|
if (!confirm('Delete this monitor and all its ping history?')) return;
|
|
await api(`/monitors/${monitorId}`, { method: 'DELETE' });
|
|
window.location.href = '/dashboard/home';
|
|
};
|
|
|
|
// Edit form submission
|
|
document.getElementById('edit-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const errEl = document.getElementById('edit-error');
|
|
errEl.classList.add('hidden');
|
|
try {
|
|
const body = collectFormData(_prefix);
|
|
await api(`/monitors/${monitorId}`, { method: 'PATCH', body });
|
|
location.reload();
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.classList.remove('hidden');
|
|
}
|
|
});
|
|
|
|
// ── Interactive latency chart ──────────────────────────────────────
|
|
const REGION_COLORS = <%~ JSON.stringify(it.regionColors) %>;
|
|
const REGION_LABELS = <%~ JSON.stringify(it.regionLabels) %>;
|
|
|
|
const MAX_RUNS = 100;
|
|
const chartPings = <%~ JSON.stringify(chartPings.map(p => ({
|
|
latency_ms: p.latency_ms, region: p.region || '__none__',
|
|
checked_at: p.checked_at, up: p.up, run_id: p.run_id || null,
|
|
status_code: p.status_code
|
|
}))) %>;
|
|
|
|
// Trim chartPings in place to keep only the newest MAX_RUNS runs
|
|
function trimChartRuns() {
|
|
// Collect runs sorted by avg time
|
|
const runAvg = {};
|
|
for (const p of chartPings) {
|
|
const rid = p.run_id || p.checked_at;
|
|
if (!runAvg[rid]) runAvg[rid] = { sum: 0, n: 0 };
|
|
runAvg[rid].sum += new Date(p.checked_at).getTime();
|
|
runAvg[rid].n++;
|
|
}
|
|
const sorted = Object.keys(runAvg).sort((a, b) =>
|
|
(runAvg[a].sum / runAvg[a].n) - (runAvg[b].sum / runAvg[b].n)
|
|
);
|
|
if (sorted.length <= MAX_RUNS) return;
|
|
const stale = new Set(sorted.slice(0, sorted.length - MAX_RUNS));
|
|
// Splice in place so all references see the change
|
|
for (let i = chartPings.length - 1; i >= 0; i--) {
|
|
if (stale.has(chartPings[i].run_id || chartPings[i].checked_at)) {
|
|
chartPings.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
trimChartRuns();
|
|
|
|
function renderChart() {
|
|
const canvas = document.getElementById('chart-canvas');
|
|
// Replace SSR chart with interactive canvas
|
|
const ssrChart = document.getElementById('chart-ssr');
|
|
if (ssrChart) ssrChart.classList.add('hidden');
|
|
canvas.classList.remove('hidden');
|
|
const container = canvas.parentElement;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const rect = container.getBoundingClientRect();
|
|
const W = rect.width, H = rect.height;
|
|
canvas.width = W * dpr; canvas.height = H * dpr;
|
|
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.scale(dpr, dpr);
|
|
ctx.clearRect(0, 0, W, H);
|
|
|
|
const pad = { top: 8, bottom: 8, left: 0, right: 0 };
|
|
const cW = W - pad.left - pad.right;
|
|
const cH = H - pad.top - pad.bottom;
|
|
|
|
const data = chartPings.filter(p => p.latency_ms != null);
|
|
if (data.length < 2) {
|
|
ctx.fillStyle = '#4b5563'; ctx.font = '13px sans-serif'; ctx.textAlign = 'center';
|
|
ctx.fillText('Not enough data', W / 2, H / 2);
|
|
return;
|
|
}
|
|
|
|
// Build ordered list of unique runs, sorted by avg checked_at
|
|
const runTimes = {};
|
|
for (const p of data) {
|
|
const rid = p.run_id || p.checked_at;
|
|
if (!runTimes[rid]) runTimes[rid] = [];
|
|
runTimes[rid].push(new Date(p.checked_at).getTime());
|
|
}
|
|
const runs = Object.keys(runTimes).sort((a, b) => {
|
|
const avgA = runTimes[a].reduce((x, y) => x + y, 0) / runTimes[a].length;
|
|
const avgB = runTimes[b].reduce((x, y) => x + y, 0) / runTimes[b].length;
|
|
return avgA - avgB;
|
|
});
|
|
|
|
// Evenly space runs across the chart width (fixed spacing like SSR)
|
|
const runIndex = {};
|
|
runs.forEach((rid, i) => { runIndex[rid] = i; });
|
|
const maxIdx = Math.max(runs.length - 1, 1);
|
|
|
|
// Group by region
|
|
const byRegion = {};
|
|
for (const p of data) {
|
|
const r = p.region || '__none__';
|
|
if (!byRegion[r]) byRegion[r] = [];
|
|
byRegion[r].push(p);
|
|
}
|
|
|
|
const allLat = data.map(p => p.latency_ms);
|
|
const yMin = Math.min(...allLat), yMax = Math.max(...allLat);
|
|
const yRange = yMax - yMin || 1;
|
|
|
|
function toY(v) { return pad.top + cH - ((v - yMin) / yRange) * cH; }
|
|
function pingX(p) {
|
|
const idx = runIndex[p.run_id || p.checked_at] || 0;
|
|
return pad.left + (idx / maxIdx) * cW;
|
|
}
|
|
|
|
// Grid lines
|
|
ctx.strokeStyle = 'rgba(75,85,99,0.3)'; ctx.lineWidth = 0.5;
|
|
for (let i = 0; i <= 4; i++) {
|
|
const y = pad.top + (cH / 4) * i;
|
|
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
|
}
|
|
|
|
// Draw smooth lines per region using cardinal spline
|
|
const regions = Object.keys(byRegion);
|
|
const regionPoints = {};
|
|
for (const region of regions) {
|
|
const color = REGION_COLORS[region] || '#6b7280';
|
|
const rPings = byRegion[region].slice().sort((a, b) =>
|
|
pingX(a) - pingX(b)
|
|
);
|
|
const pts = rPings.map(p => ({
|
|
x: pingX(p),
|
|
y: toY(p.latency_ms),
|
|
ping: p
|
|
}));
|
|
regionPoints[region] = pts;
|
|
|
|
if (pts.length < 2) continue;
|
|
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
|
|
|
// Gentle Catmull-Rom spline - low tension to avoid overshoot on noisy data
|
|
ctx.moveTo(pts[0].x, pts[0].y);
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
const p0 = pts[Math.max(i - 1, 0)];
|
|
const p1 = pts[i];
|
|
const p2 = pts[i + 1];
|
|
const p3 = pts[Math.min(i + 2, pts.length - 1)];
|
|
const t = 0.15;
|
|
const cp1x = p1.x + (p2.x - p0.x) * t;
|
|
const cp1y = p1.y + (p2.y - p0.y) * t;
|
|
const cp2x = p2.x - (p3.x - p1.x) * t;
|
|
const cp2y = p2.y - (p3.y - p1.y) * t;
|
|
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y);
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Draw dots for failed pings
|
|
for (const pt of pts) {
|
|
if (!pt.ping.up) {
|
|
ctx.beginPath(); ctx.arc(pt.x, pt.y, 3, 0, Math.PI * 2);
|
|
ctx.fillStyle = '#f87171'; ctx.fill();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Y-axis labels
|
|
document.getElementById('chart-ymax').textContent = yMax + 'ms';
|
|
document.getElementById('chart-ymin').textContent = yMin + 'ms';
|
|
|
|
// Legend
|
|
const legendEl = document.getElementById('chart-legend');
|
|
if (regions.length > 1) {
|
|
legendEl.innerHTML = regions.map(r => {
|
|
const c = REGION_COLORS[r] || '#6b7280';
|
|
const l = REGION_LABELS[r] || r;
|
|
return `<span class="flex items-center gap-1"><span style="background:${c}" class="inline-block w-2 h-2 rounded-full"></span>${l}</span>`;
|
|
}).join('');
|
|
} else {
|
|
legendEl.innerHTML = '';
|
|
}
|
|
|
|
// Store for hover
|
|
canvas._regionPoints = regionPoints;
|
|
}
|
|
|
|
// Group pings by run_id for tooltip
|
|
function getRunGroup(t, regionPoints, cW, pad) {
|
|
// Find the closest time point across all regions
|
|
let closestDist = Infinity, closestT = null;
|
|
for (const pts of Object.values(regionPoints)) {
|
|
for (const pt of pts) {
|
|
const d = Math.abs(pt.x - t);
|
|
if (d < closestDist) { closestDist = d; closestT = pt.ping; }
|
|
}
|
|
}
|
|
if (!closestT || closestDist > 30) return null;
|
|
|
|
// If we have a run_id, group all pings with same run_id
|
|
const runId = closestT.run_id;
|
|
const group = [];
|
|
if (runId) {
|
|
for (const [region, pts] of Object.entries(regionPoints)) {
|
|
const match = pts.find(pt => pt.ping.run_id === runId);
|
|
if (match) group.push({ region, ...match });
|
|
}
|
|
}
|
|
if (!group.length) {
|
|
// Fallback: just show the closest point
|
|
const region = closestT.region || '__none__';
|
|
const pts = regionPoints[region] || [];
|
|
const pt = pts.find(p => p.ping === closestT);
|
|
if (pt) group.push({ region, ...pt });
|
|
}
|
|
return group.length ? { group, x: group[0].x, time: closestT.checked_at, runId } : null;
|
|
}
|
|
|
|
// Hover handler
|
|
const chartContainer = document.getElementById('latency-chart');
|
|
const tooltip = document.getElementById('chart-tooltip');
|
|
const crosshair = document.getElementById('chart-crosshair');
|
|
let _hoverX = null; // track last mouse x for re-rendering tooltip on chart update
|
|
|
|
function updateTooltip(mx) {
|
|
const canvas = document.getElementById('chart-canvas');
|
|
if (!canvas._regionPoints || mx == null) {
|
|
tooltip.classList.add('hidden');
|
|
crosshair.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
const result = getRunGroup(mx, canvas._regionPoints);
|
|
if (!result) {
|
|
tooltip.classList.add('hidden');
|
|
crosshair.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
crosshair.classList.remove('hidden');
|
|
crosshair.style.left = result.x + 'px';
|
|
|
|
// Build tooltip
|
|
const time = new Date(result.time);
|
|
const timeStr = time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
let html = `<div class="text-gray-400 mb-1">${timeStr}</div>`;
|
|
result.group.sort((a, b) => (a.ping.latency_ms || 0) - (b.ping.latency_ms || 0));
|
|
for (const pt of result.group) {
|
|
const c = REGION_COLORS[pt.region] || '#6b7280';
|
|
const label = REGION_LABELS[pt.region] || pt.region;
|
|
const status = pt.ping.up ? '' : ' <span class="text-red-400">DOWN</span>';
|
|
html += `<div class="flex items-center justify-between gap-3">
|
|
<span class="flex items-center gap-1.5"><span style="background:${c}" class="inline-block w-1.5 h-1.5 rounded-full"></span>${label}</span>
|
|
<span class="text-gray-200 font-mono">${pt.ping.latency_ms}ms${status}</span>
|
|
</div>`;
|
|
}
|
|
if (result.runId) {
|
|
html += `<div class="text-gray-600 mt-1 text-[10px] font-mono">${result.runId}</div>`;
|
|
}
|
|
tooltip.innerHTML = html;
|
|
tooltip.classList.remove('hidden');
|
|
|
|
// Position tooltip
|
|
const tw = tooltip.offsetWidth;
|
|
const containerW = chartContainer.offsetWidth;
|
|
const left = result.x + 12;
|
|
tooltip.style.left = (left + tw > containerW ? result.x - tw - 12 : left) + 'px';
|
|
tooltip.style.top = '4px';
|
|
}
|
|
|
|
chartContainer.addEventListener('mousemove', (e) => {
|
|
const rect = document.getElementById('chart-canvas').getBoundingClientRect();
|
|
_hoverX = e.clientX - rect.left;
|
|
updateTooltip(_hoverX);
|
|
});
|
|
|
|
chartContainer.addEventListener('mouseleave', () => {
|
|
_hoverX = null;
|
|
tooltip.classList.add('hidden');
|
|
crosshair.classList.add('hidden');
|
|
});
|
|
|
|
renderChart();
|
|
window.addEventListener('resize', renderChart);
|
|
|
|
// ── Status bar hover tooltip ──────────────────────────────────────
|
|
const statusBar = document.getElementById('status-bar');
|
|
const statusTooltip = document.getElementById('status-tooltip');
|
|
let _statusMouseX = null;
|
|
|
|
function segAtX(x) {
|
|
if (x == null) return null;
|
|
const barRect = statusBar.getBoundingClientRect();
|
|
const absX = barRect.left + x;
|
|
for (const seg of statusBar.querySelectorAll('[data-run]')) {
|
|
const r = seg.getBoundingClientRect();
|
|
if (absX >= r.left && absX <= r.right) return seg;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function updateStatusTooltip() {
|
|
const seg = segAtX(_statusMouseX);
|
|
if (!seg) { statusTooltip.classList.add('hidden'); return; }
|
|
|
|
const up = parseInt(seg.dataset.up || '0');
|
|
const down = parseInt(seg.dataset.down || '0');
|
|
const total = up + down;
|
|
const uptimePct = total > 0 ? Math.round((up / total) * 100) : 0;
|
|
const time = seg.dataset.time ? new Date(seg.dataset.time) : null;
|
|
const timeStr = time ? time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '';
|
|
const dateStr = time ? time.toLocaleDateString([], { month: 'short', day: 'numeric' }) : '';
|
|
|
|
const statusLabel = down === 0 ? '<span class="text-green-400">All Up</span>'
|
|
: up === 0 ? '<span class="text-red-400">All Down</span>'
|
|
: '<span class="text-orange-400">Partial</span>';
|
|
|
|
let html = `<div class="text-gray-400 mb-1">${dateStr} ${timeStr}</div>`;
|
|
html += `<div class="flex items-center justify-between gap-4 mb-1"><span>Status</span>${statusLabel}</div>`;
|
|
html += `<div class="flex items-center justify-between gap-4"><span class="text-gray-500">Uptime</span><span class="text-gray-200 font-mono">${uptimePct}%</span></div>`;
|
|
|
|
let regions = [];
|
|
try { regions = JSON.parse(seg.dataset.regions || '[]'); } catch {}
|
|
if (regions.length > 0) {
|
|
html += '<div class="mt-1.5 pt-1.5 border-t border-gray-700/50">';
|
|
for (const r of regions) {
|
|
const rLabel = REGION_LABELS[r.region] || r.region || 'unknown';
|
|
const status = r.up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>';
|
|
const lat = r.latency_ms != null ? `<span class="text-gray-400 font-mono">${r.latency_ms}ms</span>` : '';
|
|
html += `<div class="flex items-center justify-between gap-3"><span>${rLabel}</span><span>${lat} ${status}</span></div>`;
|
|
}
|
|
html += '</div>';
|
|
}
|
|
|
|
const rid = seg.dataset.run;
|
|
if (rid) html += `<div class="text-gray-600 mt-1 text-[10px] font-mono">${rid}</div>`;
|
|
|
|
statusTooltip.innerHTML = html;
|
|
statusTooltip.classList.remove('hidden');
|
|
|
|
const barRect = statusBar.getBoundingClientRect();
|
|
const segRect = seg.getBoundingClientRect();
|
|
const segCenter = segRect.left + segRect.width / 2 - barRect.left;
|
|
const tw = statusTooltip.offsetWidth;
|
|
let left = segCenter - tw / 2;
|
|
if (left < 0) left = 0;
|
|
if (left + tw > barRect.width) left = barRect.width - tw;
|
|
statusTooltip.style.left = left + 'px';
|
|
}
|
|
|
|
statusBar.addEventListener('mousemove', (e) => {
|
|
const barRect = statusBar.getBoundingClientRect();
|
|
_statusMouseX = e.clientX - barRect.left;
|
|
updateStatusTooltip();
|
|
});
|
|
|
|
statusBar.addEventListener('mouseleave', () => {
|
|
_statusMouseX = null;
|
|
statusTooltip.classList.add('hidden');
|
|
});
|
|
|
|
// Running totals for incremental stat updates
|
|
let _total = <%= pings.length %>, _up = <%= upPings.length %>;
|
|
let _latSum = <%= latencies.reduce((a,b)=>a+b,0) %>, _latCount = <%= latencies.length %>;
|
|
|
|
// SSE: update everything on ping
|
|
watchAccount((ping) => {
|
|
if (ping.monitor_id !== monitorId) return;
|
|
|
|
// Accumulate
|
|
_total++;
|
|
if (ping.up) _up++;
|
|
if (ping.latency_ms != null) { _latSum += ping.latency_ms; _latCount++; }
|
|
|
|
// Status
|
|
document.getElementById('stat-status').innerHTML = ping.up
|
|
? '<span class="text-green-400">Up</span>'
|
|
: '<span class="text-red-400">Down</span>';
|
|
document.getElementById('status-dot').innerHTML = ping.up
|
|
? '<span class="inline-block w-2.5 h-2.5 rounded-full dot-up mr-2"></span>'
|
|
: '<span class="inline-block w-2.5 h-2.5 rounded-full dot-down mr-2"></span>';
|
|
|
|
// Avg latency
|
|
if (_latCount > 0)
|
|
document.getElementById('stat-latency').textContent = Math.round(_latSum / _latCount) + 'ms';
|
|
|
|
// Uptime
|
|
if (_total > 0)
|
|
document.getElementById('stat-uptime').textContent = Math.round((_up / _total) * 100) + '%';
|
|
|
|
// Last ping
|
|
document.getElementById('stat-last').innerHTML = timeAgo(ping.checked_at);
|
|
|
|
// Status bar - group by run_id, cap at 60
|
|
const bar = document.getElementById('status-bar');
|
|
if (bar) {
|
|
const rid = ping.run_id || ping.checked_at;
|
|
let existing = bar.querySelector(`[data-run="${rid}"]`);
|
|
if (existing) {
|
|
const up = parseInt(existing.dataset.up || '0') + (ping.up ? 1 : 0);
|
|
const down = parseInt(existing.dataset.down || '0') + (ping.up ? 0 : 1);
|
|
existing.dataset.up = up;
|
|
existing.dataset.down = down;
|
|
existing.dataset.total = up + down;
|
|
existing.dataset.time = ping.checked_at;
|
|
const regions = JSON.parse(existing.dataset.regions || '[]');
|
|
regions.push({ region: ping.region || '', up: ping.up, latency_ms: ping.latency_ms });
|
|
existing.dataset.regions = JSON.stringify(regions);
|
|
const color = down === 0 ? 'bg-green-500/70' : (up === 0 ? 'bg-red-500/70' : 'bg-orange-400/70');
|
|
existing.className = `flex-1 rounded-sm ${color} cursor-pointer`;
|
|
} else {
|
|
const seg = document.createElement('div');
|
|
const color = ping.up ? 'bg-green-500/70' : 'bg-red-500/70';
|
|
seg.className = `flex-1 rounded-sm ${color} cursor-pointer`;
|
|
seg.dataset.run = rid;
|
|
seg.dataset.up = ping.up ? '1' : '0';
|
|
seg.dataset.down = ping.up ? '0' : '1';
|
|
seg.dataset.total = '1';
|
|
seg.dataset.time = ping.checked_at;
|
|
seg.dataset.regions = JSON.stringify([{ region: ping.region || '', up: ping.up, latency_ms: ping.latency_ms }]);
|
|
bar.appendChild(seg);
|
|
while (bar.children.length > 60) bar.removeChild(bar.firstChild);
|
|
}
|
|
updateStatusTooltip();
|
|
}
|
|
|
|
// Pings table - prepend row if filter matches
|
|
const tbody = document.getElementById('pings-table');
|
|
if (tbody) {
|
|
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
|
|
chartPings.push({
|
|
latency_ms: ping.latency_ms, region: ping.region || '__none__',
|
|
checked_at: ping.checked_at, up: ping.up, run_id: ping.run_id || null,
|
|
status_code: ping.status_code
|
|
});
|
|
trimChartRuns();
|
|
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') %>
|