diff --git a/apps/web/src/views/detail.ejs b/apps/web/src/views/detail.ejs
index 481bd81..8595d8a 100644
--- a/apps/web/src/views/detail.ejs
+++ b/apps/web/src/views/detail.ejs
@@ -15,10 +15,12 @@
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, checked_at: p.checked_at, latency_ms: p.latency_ms };
+ 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);
@@ -90,17 +92,19 @@
Status History
-
- <% 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 label = c.down === 0 ? 'Up' : (c.up === 0 ? 'Down' : 'Partial');
- %>
-
- <% }) %>
- <% } else { %>
-
No data
- <% } %>
+
+
+ <% 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');
+ %>
+
">
+ <% }) %>
+ <% } else { %>
+
No data
+ <% } %>
+
+
@@ -462,6 +466,66 @@
renderChart();
window.addEventListener('resize', renderChart);
+ // ── Status bar hover tooltip ──────────────────────────────────────
+ const statusBar = document.getElementById('status-bar');
+ const statusTooltip = document.getElementById('status-tooltip');
+
+ statusBar.addEventListener('mousemove', (e) => {
+ const seg = e.target.closest('[data-run]');
+ 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 ? '
All Up'
+ : up === 0 ? '
All Down'
+ : '
Partial';
+
+ let html = `
${dateStr} ${timeStr}
`;
+ html += `
Status${statusLabel}
`;
+ html += `
Uptime${uptimePct}%
`;
+
+ // Region breakdown
+ let regions = [];
+ try { regions = JSON.parse(seg.dataset.regions || '[]'); } catch {}
+ if (regions.length > 0) {
+ html += '
';
+ for (const r of regions) {
+ const flag = {'eu-central':'🇩🇪','us-west':'🇺🇸'}[r.region] || '🌐';
+ const rLabel = r.region || 'unknown';
+ const status = r.up ? '
Up' : '
Down';
+ const lat = r.latency_ms != null ? `
${r.latency_ms}ms` : '';
+ html += `
${flag} ${rLabel}${lat} ${status}
`;
+ }
+ html += '
';
+ }
+
+ const rid = seg.dataset.run;
+ if (rid) html += `
${rid}
`;
+
+ statusTooltip.innerHTML = html;
+ statusTooltip.classList.remove('hidden');
+
+ // Position horizontally centered on the segment
+ 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('mouseleave', () => {
+ 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 %>;
@@ -500,22 +564,27 @@
const rid = ping.run_id || ping.checked_at;
let existing = bar.querySelector(`[data-run="${rid}"]`);
if (existing) {
- // Update existing run segment
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');
- const label = down === 0 ? 'Up' : (up === 0 ? 'Down' : 'Partial');
- existing.className = `flex-1 rounded-sm ${color}`;
- existing.title = `${new Date(ping.checked_at).toLocaleString()} — ${label}`;
+ existing.className = `flex-1 rounded-sm ${color} cursor-pointer`;
} else {
const seg = document.createElement('div');
- seg.className = `flex-1 rounded-sm ${ping.up ? 'bg-green-500/70' : 'bg-red-500/70'}`;
+ 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.title = `${new Date(ping.checked_at).toLocaleString()} — ${ping.up ? 'Up' : 'Down'}`;
+ 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);
}