pingql/apps/web/src/views/detail.ejs

466 lines
21 KiB
Plaintext

<%~ include('./partials/head', { title: 'Monitor', scripts: ['/dashboard/query-builder.js'] }) %>
<%~ 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;
const barPings = pings.slice(0, 60).reverse();
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">&larr; 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 bg-green-400 mr-2" title="Up"></span>' : '<span class="inline-block w-2.5 h-2.5 rounded-full bg-red-400 mr-2" title="Down"></span>') : '<span class="inline-block w-2.5 h-2.5 rounded-full bg-gray-600 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">
<button id="toggle-btn" class="text-sm px-4 py-2 rounded-lg border transition-colors <%= m.enabled ? 'border-gray-700 hover:border-gray-600 text-gray-300' : 'border-green-800 hover:border-green-700 text-green-400' %>"><%= m.enabled ? 'Pause' : 'Resume' %></button>
<button id="delete-btn" class="text-sm px-4 py-2 rounded-lg border border-red-900/50 text-red-400 hover:bg-red-900/20 transition-colors">Delete</button>
</div>
</div>
<!-- Stats row -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
<div class="bg-gray-900 border border-gray-800 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="bg-gray-900 border border-gray-800 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="bg-gray-900 border border-gray-800 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="bg-gray-900 border border-gray-800 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="bg-gray-900 border border-gray-800 rounded-xl 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">
<canvas id="chart-canvas" class="w-full h-full"></canvas>
<div id="chart-tooltip" class="absolute hidden bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-xs pointer-events-none z-10 shadow-lg" 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="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-8">
<h3 class="text-sm text-gray-400 mb-3">Status History</h3>
<div id="status-bar" class="flex gap-0.5 h-8 rounded overflow-hidden">
<% if (barPings.length > 0) { %>
<% barPings.forEach(function(c) { %>
<div class="flex-1 <%= c.up ? 'bg-green-500/70' : 'bg-red-500/70' %>" title="<%= new Date(c.checked_at).toLocaleString() %> — <%= c.up ? 'Up' : 'Down' %> <%= c.latency_ms ? c.latency_ms + 'ms' : '' %>"></div>
<% }) %>
<% } else { %>
<div class="flex-1 bg-gray-800 text-center text-xs text-gray-600 leading-8">No data</div>
<% } %>
</div>
</div>
<!-- Recent pings table -->
<div class="bg-gray-900 border border-gray-800 rounded-xl mb-8 overflow-hidden">
<div class="px-4 py-3 border-b border-gray-800">
<h3 class="text-sm text-gray-400">Recent Pings</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<%
const regionFlag = {
'eu-central': '🇩🇪',
'us-east': '🇺🇸',
'us-west': '🇺🇸',
'ap-southeast': '🇸🇬',
};
%>
<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-gray-800/50">
<% pings.slice(0, 30).forEach(function(c) { %>
<tr class="hover:bg-gray-800/50">
<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" title="<%= c.region || '' %>"><%= c.region ? (regionFlag[c.region] || '🌐') + ' ' + c.region : '—' %></td>
<td class="px-4 py-2 text-gray-600 font-mono text-xs" title="<%= c.run_id || '' %>"><%= c.run_id ? c.run_id.slice(0, 8) + '…' : '—' %></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>
<!-- Edit form -->
<div class="bg-gray-900 border border-gray-800 rounded-xl 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', border: 'border-gray-700' } }) %>
</div>
</div>
</main>
<script>
const monitorId = '<%= m.id %>';
const _prefix = 'edit-';
const _initialQuery = <%~ JSON.stringify(m.query || null) %>;
<%~ include('./partials/monitor-form-js') %>
// Toggle button
document.getElementById('toggle-btn').onclick = async () => {
await api(`/monitors/${monitorId}/toggle`, { method: 'POST' });
location.reload();
};
// Delete button
document.getElementById('delete-btn').onclick = async () => {
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 = {
'eu-central': '#3b82f6', 'us-east': '#10b981',
'us-west': '#f59e0b', 'ap-southeast': '#a78bfa', '__none__': '#6b7280'
};
const REGION_FLAGS = {
'eu-central': '🇩🇪 EU Central', 'us-east': '🇺🇸 US East',
'us-west': '🇺🇸 US West', 'ap-southeast': '🇸🇬 AP Southeast'
};
let 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
}))) %>;
function renderChart() {
const canvas = document.getElementById('chart-canvas');
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;
}
// 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;
const allT = data.map(p => new Date(p.checked_at).getTime());
const tMin = Math.min(...allT), tMax = Math.max(...allT);
const tRange = tMax - tMin || 1;
function toX(t) { return pad.left + ((t - tMin) / tRange) * cW; }
function toY(v) { return pad.top + cH - ((v - yMin) / yRange) * cH; }
// 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) =>
new Date(a.checked_at).getTime() - new Date(b.checked_at).getTime()
);
const pts = rPings.map(p => ({
x: toX(new Date(p.checked_at).getTime()),
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';
// Catmull-Rom spline for smooth curves
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 tension = 0.3;
const cp1x = p1.x + (p2.x - p0.x) * tension;
const cp1y = p1.y + (p2.y - p0.y) * tension;
const cp2x = p2.x - (p3.x - p1.x) * tension;
const cp2y = p2.y - (p3.y - p1.y) * tension;
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_FLAGS[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;
canvas._toX = toX; canvas._toY = toY;
canvas._tMin = tMin; canvas._tRange = tRange;
canvas._W = W; canvas._H = H; canvas._pad = pad; canvas._cW = cW;
}
// 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');
chartContainer.addEventListener('mousemove', (e) => {
const canvas = document.getElementById('chart-canvas');
if (!canvas._regionPoints) return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const result = getRunGroup(mx, canvas._regionPoints, canvas._cW, canvas._pad);
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_FLAGS[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.slice(0, 8)}…</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('mouseleave', () => {
tooltip.classList.add('hidden');
crosshair.classList.add('hidden');
});
renderChart();
window.addEventListener('resize', renderChart);
// 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 bg-green-400 mr-2"></span>'
: '<span class="inline-block w-2.5 h-2.5 rounded-full bg-red-400 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 — prepend segment, cap at 60
const bar = document.getElementById('status-bar');
if (bar) {
const seg = document.createElement('div');
seg.className = `flex-1 ${ping.up ? 'bg-green-500/70' : 'bg-red-500/70'}`;
seg.title = `${new Date(ping.checked_at).toLocaleString()} — ${ping.up ? 'Up' : 'Down'}${ping.latency_ms ? ' ' + ping.latency_ms + 'ms' : ''}`;
bar.prepend(seg);
while (bar.children.length > 60) bar.removeChild(bar.lastChild);
}
// Pings table — prepend row, cap at 100
const tbody = document.getElementById('pings-table');
if (tbody) {
const tr = document.createElement('tr');
tr.className = 'hover:bg-gray-800/50';
const regionFlags = {'eu-central':'🇩🇪','us-east':'🇺🇸','us-west':'🇺🇸','ap-southeast':'🇸🇬'};
const regionDisplay = ping.region ? `${regionFlags[ping.region] || '🌐'} ${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" title="${ping.run_id || ''}">${ping.run_id ? ping.run_id.slice(0, 8) + '…' : '—'}</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);
}
// Chart — push new ping and re-render locally
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
});
if (chartPings.length > 200) chartPings.shift();
renderChart();
});
</script>
<%~ include('./partials/foot') %>