150 lines
7.0 KiB
Plaintext
150 lines
7.0 KiB
Plaintext
<%~ include('./partials/head', { title: 'Monitors' }) %>
|
|
<%~ include('./partials/nav', { nav: 'monitors' }) %>
|
|
|
|
<!-- Content -->
|
|
<main class="max-w-5xl mx-auto px-6 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"></div>
|
|
<a href="/dashboard/monitors/new" class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">+ New</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="monitors-list" class="space-y-3">
|
|
<div class="text-center py-16 text-gray-600">Loading...</div>
|
|
</div>
|
|
|
|
<div id="empty-state" class="hidden text-center py-16">
|
|
<p class="text-gray-500 mb-4">No monitors yet</p>
|
|
<a href="/dashboard/monitors/new" class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-6 py-3 rounded-lg transition-colors inline-block">Create your first monitor</a>
|
|
</div>
|
|
</main>
|
|
|
|
<script>
|
|
if (!requireAuth()) throw 'auth';
|
|
|
|
async function load() {
|
|
try {
|
|
const monitors = await api('/monitors/');
|
|
const list = document.getElementById('monitors-list');
|
|
const emptyState = document.getElementById('empty-state');
|
|
const summary = document.getElementById('summary');
|
|
|
|
if (monitors.length === 0) {
|
|
list.classList.add('hidden');
|
|
emptyState.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
// Fetch last ping for each monitor
|
|
const monitorsWithPings = await Promise.all(
|
|
monitors.map(async (m) => {
|
|
try {
|
|
const pings = await api(`/monitors/${m.id}/pings?limit=20`);
|
|
return { ...m, pings };
|
|
} catch {
|
|
return { ...m, pings: [] };
|
|
}
|
|
})
|
|
);
|
|
|
|
const upCount = monitorsWithPings.filter(m => m.pings[0]?.up === true).length;
|
|
const downCount = monitorsWithPings.filter(m => m.pings[0]?.up === false).length;
|
|
summary.innerHTML = `<span class="text-green-400">${upCount} up</span> · <span class="${downCount > 0 ? 'text-red-400' : 'text-gray-500'}">${downCount} down</span> · ${monitors.length} total`;
|
|
|
|
// Store latencies per monitor for live sparkline updates
|
|
window._monitorLatencies = window._monitorLatencies || {};
|
|
monitorsWithPings.forEach(m => {
|
|
window._monitorLatencies[m.id] = m.pings.filter(c => c.latency_ms != null).map(c => c.latency_ms).reverse();
|
|
});
|
|
|
|
list.innerHTML = monitorsWithPings.map(m => {
|
|
const lastPing = m.pings[0];
|
|
const latencies = window._monitorLatencies[m.id];
|
|
const avgLatency = latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : null;
|
|
|
|
return `
|
|
<a href="/dashboard/monitors/${m.id}" data-monitor-id="${m.id}" class="block bg-gray-900 hover:bg-gray-800/80 border border-gray-800 rounded-xl p-4 transition-colors 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 ${lastPing == null ? 'bg-gray-600' : lastPing.up ? 'bg-green-500' : 'bg-red-500'}"></span>
|
|
<div class="min-w-0">
|
|
<div class="font-medium text-gray-100 group-hover:text-white truncate">${escapeHtml(m.name)}</div>
|
|
<div class="text-xs text-gray-500 truncate">${escapeHtml(m.url)}</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-6 shrink-0 ml-4">
|
|
<div class="hidden sm:block stat-sparkline">${sparkline(latencies)}</div>
|
|
<div class="text-right">
|
|
<div class="text-sm text-gray-300 stat-latency">${avgLatency != null ? avgLatency + 'ms' : '—'}</div>
|
|
<div class="text-xs text-gray-500 stat-last">${lastPing ? timeAgo(lastPing.checked_at) : 'no pings'}</div>
|
|
</div>
|
|
<div class="text-xs px-2 py-1 rounded ${m.enabled ? 'bg-gray-800 text-gray-400' : 'bg-yellow-900/30 text-yellow-500'}">${m.enabled ? m.interval_s + 's' : 'paused'}</div>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
`;
|
|
}).join('');
|
|
} catch (e) {
|
|
document.getElementById('monitors-list').innerHTML = `<div class="text-center py-8 text-red-400">${escapeHtml(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
load();
|
|
// No interval poll — SSE handles live updates. Only reload on tab focus if stale.
|
|
let lastLoad = Date.now();
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden && Date.now() - lastLoad > 60000) { load(); lastLoad = Date.now(); }
|
|
});
|
|
|
|
// SSE: subscribe to all monitors after load so cards update in real time
|
|
const sseConnections = [];
|
|
async function subscribeAll() {
|
|
sseConnections.forEach(es => es.abort());
|
|
sseConnections.length = 0;
|
|
const monitors = await api('/monitors/');
|
|
monitors.forEach(m => {
|
|
const es = watchMonitor(m.id, (ping) => {
|
|
const card = document.querySelector(`[data-monitor-id="${m.id}"]`);
|
|
if (!card) return;
|
|
|
|
// Status dot
|
|
const statusDot = card.querySelector('.status-dot');
|
|
if (statusDot) {
|
|
const wasUp = statusDot.classList.contains('bg-green-500');
|
|
statusDot.className = `status-dot w-2.5 h-2.5 rounded-full ${ping.up ? 'bg-green-500' : 'bg-red-500'}`;
|
|
// If status changed, recount and update summary
|
|
if (wasUp !== ping.up) {
|
|
const dots = document.querySelectorAll('.status-dot');
|
|
const upNow = [...dots].filter(d => d.classList.contains('bg-green-500')).length;
|
|
const downNow = [...dots].filter(d => d.classList.contains('bg-red-500')).length;
|
|
const summary = document.getElementById('summary');
|
|
if (summary) summary.innerHTML = `<span class="text-green-400">${upNow} up</span> · <span class="${downNow > 0 ? 'text-red-400' : 'text-gray-500'}">${downNow} down</span> · ${dots.length} total`;
|
|
}
|
|
}
|
|
|
|
// Latency text
|
|
if (ping.latency_ms) card.querySelector('.stat-latency').textContent = `${ping.latency_ms}ms`;
|
|
|
|
// Timestamp
|
|
card.querySelector('.stat-last').innerHTML = timeAgo(ping.checked_at);
|
|
|
|
// Sparkline — push new value, keep last 20, redraw
|
|
if (ping.latency_ms != null) {
|
|
const lats = window._monitorLatencies[m.id] || [];
|
|
lats.push(ping.latency_ms);
|
|
if (lats.length > 20) lats.shift();
|
|
window._monitorLatencies[m.id] = lats;
|
|
const sparkEl = card.querySelector('.stat-sparkline');
|
|
if (sparkEl) sparkEl.innerHTML = sparkline(lats);
|
|
}
|
|
});
|
|
if (es) sseConnections.push(es);
|
|
});
|
|
}
|
|
subscribeAll();
|
|
</script>
|
|
|
|
<%~ include('./partials/foot') %>
|