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

172 lines
8.4 KiB
Plaintext

<%~ include('./partials/head', { title: 'Monitors' }) %>
<%~ include('./partials/nav', { nav: 'monitors' }) %>
<%
const upCount = it.monitors.filter(m => m.last_ping && m.last_ping.up === true).length;
const downCount = it.monitors.filter(m => m.last_ping && m.last_ping.up === false).length;
%>
<!-- Content -->
<main class="max-w-7xl mx-auto px-8 py-8">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold text-gray-200">Monitors</h2>
<div class="flex items-center gap-4">
<div id="summary" class="text-sm text-gray-500"><% if (it.monitors.length > 0) { %><span class="text-green-400"><%= upCount %> up</span> · <span class="<%= downCount > 0 ? 'text-red-400' : 'text-gray-500' %>"><%= downCount %> down</span> · <%= it.monitors.length %> total<% } %></div>
<a href="/dashboard/monitors/new" class="btn-primary text-sm px-4 py-2">+ New</a>
</div>
</div>
<div id="monitors-list" class="space-y-3">
<% if (it.monitors.length === 0) { %>
<div id="empty-state" class="text-center py-20">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gray-800/50 border border-border-subtle flex items-center justify-center">
<svg class="w-8 h-8 text-gray-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<p class="text-gray-500 mb-1">No monitors yet</p>
<p class="text-gray-600 text-sm mb-5">Create your first monitor to start tracking uptime</p>
<a href="/dashboard/monitors/new" class="btn-primary text-sm px-6 py-3 inline-block">Create your first monitor</a>
</div>
<% } else { %>
<% it.monitors.forEach(function(m) {
const latencies = (m.pings || []).filter(p => p.latency_ms != null);
// Pick the region with the lowest avg latency (same logic as sparklineFromPings)
const byRegion = {};
for (const p of latencies) {
const key = p.region || '__none__';
if (!byRegion[key]) byRegion[key] = [];
byRegion[key].push(p.latency_ms);
}
let bestRegion = '__none__', bestAvg = Infinity;
for (const [region, vals] of Object.entries(byRegion)) {
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
if (avg < bestAvg) { bestAvg = avg; bestRegion = region; }
}
const bestVals = byRegion[bestRegion] || [];
const avgLatency = bestVals.length ? bestVals[bestVals.length - 1] : null;
%>
<a href="/dashboard/monitors/<%= m.id %>" data-monitor-id="<%= m.id %>" class="block monitor-card rounded-xl p-4 group">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 min-w-0">
<span class="status-dot w-2.5 h-2.5 rounded-full shrink-0 <%= m.last_ping == null ? 'dot-unknown' : (m.last_ping.up ? 'dot-up' : 'dot-down') %>"></span>
<div class="min-w-0">
<div class="font-medium text-gray-100 group-hover:text-white truncate"><%= m.name %></div>
<div class="text-xs text-gray-500 truncate"><%= m.url %></div>
</div>
</div>
<div class="flex items-center gap-6 shrink-0 ml-4">
<div class="hidden sm:block stat-sparkline" style="width:120px;height:32px"><%~ it.sparklineSSR(latencies) %></div>
<div class="text-right" style="width:72px">
<div class="text-sm text-gray-300 stat-latency"><%= avgLatency != null ? avgLatency + 'ms' : '—' %></div>
<div class="text-xs text-gray-500 stat-last"><%~ m.last_ping ? it.timeAgoSSR(m.last_ping.checked_at) : 'no pings' %></div>
</div>
<div class="text-xs px-2 py-1 rounded <%= m.enabled ? 'bg-gray-800/50 text-gray-400 border border-border-subtle' : 'bg-yellow-900/20 text-yellow-500 border border-yellow-800/30' %>"><%= m.enabled ? m.interval_s + 's' : 'paused' %></div>
</div>
</div>
</a>
<% }) %>
<% } %>
</div>
</main>
<script>
// Reload on tab focus if stale
let lastLoad = Date.now();
document.addEventListener('visibilitychange', () => {
if (!document.hidden && Date.now() - lastLoad > 60000) { location.reload(); }
});
// Known monitor IDs for change detection
const _knownIds = [<% it.monitors.forEach(function(m, i) { %>'<%= m.id %>'<% if (i < it.monitors.length - 1) { %>,<% } %><% }) %>];
// Poll every 30s to detect monitor list changes
setInterval(async () => {
try {
const res = await fetch('/dashboard/home/data', { credentials: 'same-origin' });
if (!res.ok) return;
const data = await res.json();
const ids = data.monitorIds || [];
if (ids.length !== _knownIds.length || ids.some(id => !_knownIds.includes(id))) {
location.reload();
}
} catch {}
}, 30000);
const REGION_COLORS = {
'eu-central': '#3b82f6',
'us-west': '#f59e0b',
};
// Per-monitor, per-region latency tracking for sparkline (mirrors SSR sparklineFromPings)
const sparkData = {};
// Seed from SSR data-vals and data-region
document.querySelectorAll('[data-monitor-id]').forEach(card => {
const mid = card.dataset.monitorId;
const svg = card.querySelector('.stat-sparkline svg');
if (!svg) return;
const region = svg.dataset.region || '__none__';
const vals = svg.dataset.vals ? svg.dataset.vals.split(',').map(Number) : [];
sparkData[mid] = {};
sparkData[mid][region] = vals;
});
function getBestRegion(monitorId) {
const regions = sparkData[monitorId] || {};
let bestRegion = '__none__', bestAvg = Infinity;
for (const [region, vals] of Object.entries(regions)) {
if (!vals.length) continue;
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
if (avg < bestAvg) { bestAvg = avg; bestRegion = region; }
}
const vals = (regions[bestRegion] || []);
const latest = vals.length ? vals[vals.length - 1] : null;
return { region: bestRegion, latest };
}
function redrawSparkline(card, monitorId) {
const { region: bestRegion } = getBestRegion(monitorId);
const vals = (sparkData[monitorId] || {})[bestRegion];
if (!vals || !vals.length) return;
const color = REGION_COLORS[bestRegion] || '#60a5fa';
const W = 120, H = 32;
const max = Math.max(...vals, 1);
const min = Math.min(...vals, 0);
const range = max - min || 1;
const step = W / Math.max(vals.length - 1, 1);
const points = vals.map((v, i) => `${i * step},${H - ((v - min) / range) * (H - 4) - 2}`).join(' ');
const sparkEl = card.querySelector('.stat-sparkline');
if (sparkEl) {
sparkEl.innerHTML = `<svg width="${W}" height="${H}" class="inline-block" data-vals="${vals.join(',')}" data-region="${bestRegion}"><polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
}
// SSE: on each ping, update text fields and redraw sparkline in place
watchAccount((ping) => {
const card = document.querySelector(`[data-monitor-id="${ping.monitor_id}"]`);
if (!card) return;
// Status dot
const dot = card.querySelector('.status-dot');
if (dot) dot.className = `status-dot w-2.5 h-2.5 rounded-full shrink-0 ${ping.up ? 'dot-up' : 'dot-down'}`;
// Last ping time
card.querySelector('.stat-last').innerHTML = timeAgo(ping.checked_at);
// Update sparkline data per region, then redraw picking best region
if (ping.latency_ms != null) {
const mid = ping.monitor_id;
const region = ping.region || '__none__';
if (!sparkData[mid]) sparkData[mid] = {};
if (!sparkData[mid][region]) sparkData[mid][region] = [];
sparkData[mid][region].push(ping.latency_ms);
if (sparkData[mid][region].length > 20) sparkData[mid][region].shift();
redrawSparkline(card, mid);
// Show best region's latest latency
const { latest } = getBestRegion(mid);
if (latest != null) card.querySelector('.stat-latency').textContent = latest + 'ms';
}
});
</script>
<%~ include('./partials/foot') %>