update: status
This commit is contained in:
parent
264a51384c
commit
db9c63a096
|
|
@ -1,218 +1,87 @@
|
|||
// Public status page client JS:
|
||||
// 1. Bar tooltips (always active) — hover any heartbeat bar to see the
|
||||
// bucket time range and uptime breakdown.
|
||||
// 2. Click-to-expand for compact display mode (only fires when there are
|
||||
// compact monitors on the page).
|
||||
// Reads its config from window.PINGQL_PAGE.
|
||||
// 1. Click any monitor row to collapse/expand its detail panel.
|
||||
// 2. Hover any heartbeat bar to see the bucket time range and uptime breakdown.
|
||||
// All monitor detail HTML is server-rendered; this script only toggles a CSS
|
||||
// class on click. Reads its config from window.PINGQL_PAGE.
|
||||
(function () {
|
||||
var cfg = window.PINGQL_PAGE || {};
|
||||
var slug = cfg.slug;
|
||||
var defaultWindow = cfg.default_window || "24h";
|
||||
var showResponseTime = !!cfg.show_response_time;
|
||||
if (!slug) return;
|
||||
|
||||
// ── Click to expand/collapse ──────────────────────────────────────────
|
||||
var rows = document.querySelectorAll(".monitor .monitor-row");
|
||||
for (var r = 0; r < rows.length; r++) {
|
||||
(function (row) {
|
||||
var card = row.parentElement;
|
||||
row.addEventListener("click", function () {
|
||||
var isOpen = card.classList.toggle("expanded-state");
|
||||
row.setAttribute("aria-expanded", isOpen ? "true" : "false");
|
||||
});
|
||||
})(rows[r]);
|
||||
}
|
||||
|
||||
// ── Bar tooltips ──────────────────────────────────────────────────────
|
||||
var tooltip = document.getElementById("bar-tooltip");
|
||||
if (tooltip) {
|
||||
var bucketSpanMs = (defaultWindow === "24h") ? 3600 * 1000 : 86400 * 1000;
|
||||
function fmtBucketRange(startIso) {
|
||||
var start = new Date(startIso);
|
||||
var end = new Date(start.getTime() + bucketSpanMs);
|
||||
var dateOpts = { month: "short", day: "numeric" };
|
||||
var timeOpts = { hour: "2-digit", minute: "2-digit" };
|
||||
if (defaultWindow === "24h") {
|
||||
return start.toLocaleDateString(undefined, dateOpts) + ", " +
|
||||
start.toLocaleTimeString(undefined, timeOpts) + " — " +
|
||||
end.toLocaleTimeString(undefined, timeOpts);
|
||||
}
|
||||
// 7d / 30d / 90d → daily buckets
|
||||
return start.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||
}
|
||||
function uptimeBand(p) {
|
||||
if (p == null) return "";
|
||||
if (p >= 99.9) return "good";
|
||||
if (p >= 99.0) return "warn";
|
||||
return "bad";
|
||||
}
|
||||
if (!tooltip) return;
|
||||
|
||||
function showTooltipForBar(bar, evt) {
|
||||
var total = parseInt(bar.getAttribute("data-total") || "0", 10);
|
||||
var up = parseInt(bar.getAttribute("data-up") || "0", 10);
|
||||
var start = bar.getAttribute("data-start");
|
||||
var latRaw = bar.getAttribute("data-latency");
|
||||
var lat = latRaw == null ? null : parseInt(latRaw, 10);
|
||||
if (!start) return;
|
||||
var pct = total > 0 ? Math.round(1000 * up / total) / 10 : null;
|
||||
var pctText = pct == null ? "—" : (pct === 100 ? "100%" : pct.toFixed(1) + "%");
|
||||
var html = '<div class="head">' + fmtBucketRange(start) + "</div>";
|
||||
if (total > 0) {
|
||||
html += '<div class="row"><span>Checks</span><span>' + total + "</span></div>";
|
||||
html += '<div class="row"><span>Successful</span><span>' + up + "</span></div>";
|
||||
html += '<div class="row"><span>Uptime</span><span class="pct ' + uptimeBand(pct) + '">' + pctText + "</span></div>";
|
||||
if (lat != null) {
|
||||
html += '<div class="row"><span>Avg ping</span><span>' + lat + "ms</span></div>";
|
||||
}
|
||||
} else {
|
||||
html += '<div class="row"><span>No data</span><span>—</span></div>';
|
||||
}
|
||||
tooltip.innerHTML = html;
|
||||
tooltip.style.display = "block";
|
||||
var rect = bar.getBoundingClientRect();
|
||||
// Position at top-center of the bar.
|
||||
tooltip.style.left = (rect.left + rect.width / 2) + "px";
|
||||
tooltip.style.top = rect.top + "px";
|
||||
var bucketSpanMs = (defaultWindow === "24h") ? 3600 * 1000 : 86400 * 1000;
|
||||
function fmtBucketRange(startIso) {
|
||||
var start = new Date(startIso);
|
||||
var end = new Date(start.getTime() + bucketSpanMs);
|
||||
var dateOpts = { month: "short", day: "numeric" };
|
||||
var timeOpts = { hour: "2-digit", minute: "2-digit" };
|
||||
if (defaultWindow === "24h") {
|
||||
return start.toLocaleDateString(undefined, dateOpts) + ", " +
|
||||
start.toLocaleTimeString(undefined, timeOpts) + " — " +
|
||||
end.toLocaleTimeString(undefined, timeOpts);
|
||||
}
|
||||
function hideTooltip() {
|
||||
tooltip.style.display = "none";
|
||||
}
|
||||
|
||||
// Delegate from each .bars container so dynamically-rendered bars
|
||||
// (compact mode click-to-expand) work too.
|
||||
document.addEventListener("mouseover", function (e) {
|
||||
var bar = e.target && e.target.closest && e.target.closest(".bar");
|
||||
if (bar && bar.parentElement && bar.parentElement.classList.contains("bars")) {
|
||||
showTooltipForBar(bar, e);
|
||||
}
|
||||
});
|
||||
document.addEventListener("mouseout", function (e) {
|
||||
var bar = e.target && e.target.closest && e.target.closest(".bar");
|
||||
if (bar) hideTooltip();
|
||||
});
|
||||
// Hide on scroll so the tooltip doesn't lag behind the bar.
|
||||
window.addEventListener("scroll", hideTooltip, { passive: true });
|
||||
}
|
||||
// ── End bar tooltips ──────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
function bucketColor(b) {
|
||||
if (!b || b.total === 0) return "var(--bar-empty)";
|
||||
if (b.up === b.total) return "var(--bar-up)";
|
||||
if (b.up === 0) return "var(--bar-down)";
|
||||
return "var(--bar-partial)";
|
||||
return start.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||
}
|
||||
function uptimeBand(p) {
|
||||
if (p == null) return "empty";
|
||||
if (p == null) return "";
|
||||
if (p >= 99.9) return "good";
|
||||
if (p >= 99.0) return "warn";
|
||||
return "bad";
|
||||
}
|
||||
function fmtUptime(p) {
|
||||
if (p == null) return "—";
|
||||
if (p === 100) return "100" + "%";
|
||||
return p.toFixed(2) + "%";
|
||||
}
|
||||
function windowLabel(w) {
|
||||
if (w === "24h") return "Last 24 hours";
|
||||
if (w === "7d") return "Last 7 days";
|
||||
if (w === "30d") return "Last 30 days";
|
||||
return "Last 90 days";
|
||||
}
|
||||
function fmtTs(iso) {
|
||||
var d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function renderBars(buckets) {
|
||||
var html = "";
|
||||
for (var i = 0; i < buckets.length; i++) {
|
||||
var b = buckets[i];
|
||||
var pct = b.total > 0 ? Math.round(100 * b.up / b.total) + "% up" : "no data";
|
||||
html += '<div class="bar" style="background: ' + bucketColor(b) + ';" title="' + pct + '"></div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderIncident(i) {
|
||||
var klass = "incident " + i.severity + (i.resolved_at ? " resolved" : "");
|
||||
var html = '<div class="' + klass + '" style="margin-top:1rem">';
|
||||
html += '<div class="incident-title">' + escapeHtml(i.title) + "</div>";
|
||||
html += '<div class="incident-meta"><span class="pill ' + i.status + '">' + i.status + "</span>";
|
||||
html += "Started " + fmtTs(i.started_at);
|
||||
if (i.resolved_at) html += " · Resolved " + fmtTs(i.resolved_at);
|
||||
html += "</div>";
|
||||
if (i.updates && i.updates.length > 0) {
|
||||
html += '<div class="incident-timeline">';
|
||||
for (var k = 0; k < i.updates.length; k++) {
|
||||
var u = i.updates[k];
|
||||
html += '<div class="incident-update">';
|
||||
html += '<div class="head"><span class="status ' + u.status + '">' + u.status + "</span><span>" + fmtTs(u.created_at) + "</span></div>";
|
||||
html += '<div class="body">' + u.body_html + "</div>";
|
||||
html += "</div>";
|
||||
function showTooltipForBar(bar) {
|
||||
var total = parseInt(bar.getAttribute("data-total") || "0", 10);
|
||||
var up = parseInt(bar.getAttribute("data-up") || "0", 10);
|
||||
var start = bar.getAttribute("data-start");
|
||||
var latRaw = bar.getAttribute("data-latency");
|
||||
var lat = latRaw == null ? null : parseInt(latRaw, 10);
|
||||
if (!start) return;
|
||||
var pct = total > 0 ? Math.round(1000 * up / total) / 10 : null;
|
||||
var pctText = pct == null ? "—" : (pct === 100 ? "100%" : pct.toFixed(1) + "%");
|
||||
var html = '<div class="head">' + fmtBucketRange(start) + "</div>";
|
||||
if (total > 0) {
|
||||
html += '<div class="row"><span>Checks</span><span>' + total + "</span></div>";
|
||||
html += '<div class="row"><span>Successful</span><span>' + up + "</span></div>";
|
||||
html += '<div class="row"><span>Uptime</span><span class="pct ' + uptimeBand(pct) + '">' + pctText + "</span></div>";
|
||||
if (lat != null) {
|
||||
html += '<div class="row"><span>Avg ping</span><span>' + lat + "ms</span></div>";
|
||||
}
|
||||
html += "</div>";
|
||||
} else {
|
||||
html += '<div class="row"><span>No data</span><span>—</span></div>';
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
tooltip.innerHTML = html;
|
||||
tooltip.style.display = "block";
|
||||
var rect = bar.getBoundingClientRect();
|
||||
tooltip.style.left = (rect.left + rect.width / 2) + "px";
|
||||
tooltip.style.top = rect.top + "px";
|
||||
}
|
||||
function hideTooltip() {
|
||||
tooltip.style.display = "none";
|
||||
}
|
||||
|
||||
function renderDetail(payload) {
|
||||
var m = payload.monitor;
|
||||
var u = m.uptime || { d24: null, d7: null, d30: null, d90: null };
|
||||
var buckets = m.buckets || [];
|
||||
var hasData = false;
|
||||
for (var i = 0; i < buckets.length; i++) if (buckets[i].total > 0) { hasData = true; break; }
|
||||
|
||||
var barsHtml = renderBars(buckets);
|
||||
|
||||
var latencyHtml = "";
|
||||
if (showResponseTime && m.avg_latency != null) {
|
||||
latencyHtml = "<span>" + m.avg_latency + "ms · </span>";
|
||||
document.addEventListener("mouseover", function (e) {
|
||||
var bar = e.target && e.target.closest && e.target.closest(".bar");
|
||||
if (bar && bar.parentElement && bar.parentElement.classList.contains("bars")) {
|
||||
showTooltipForBar(bar);
|
||||
}
|
||||
|
||||
var incidentsHtml = "";
|
||||
if (payload.incidents && payload.incidents.length > 0) {
|
||||
for (var x = 0; x < payload.incidents.length; x++) {
|
||||
incidentsHtml += renderIncident(payload.incidents[x]);
|
||||
}
|
||||
}
|
||||
|
||||
var html = "";
|
||||
html += '<div class="bars" title="' + m.current_state + '">' + barsHtml + "</div>";
|
||||
html += '<div class="bars-meta"><span>' + windowLabel(defaultWindow) + "</span><span>" + latencyHtml + (hasData ? "uptime over window" : "awaiting data") + "</span></div>";
|
||||
html += '<div class="uptime-row">';
|
||||
html += '<div class="uptime-cell ' + uptimeBand(u.d24) + '"><div class="label">24h</div><div class="value">' + fmtUptime(u.d24) + "</div></div>";
|
||||
html += '<div class="uptime-cell ' + uptimeBand(u.d7) + '"><div class="label">7d</div><div class="value">' + fmtUptime(u.d7) + "</div></div>";
|
||||
html += '<div class="uptime-cell ' + uptimeBand(u.d30) + '"><div class="label">30d</div><div class="value">' + fmtUptime(u.d30) + "</div></div>";
|
||||
html += '<div class="uptime-cell ' + uptimeBand(u.d90) + '"><div class="label">90d</div><div class="value">' + fmtUptime(u.d90) + "</div></div>";
|
||||
html += "</div>";
|
||||
html += incidentsHtml;
|
||||
return html;
|
||||
}
|
||||
|
||||
var cards = document.querySelectorAll(".monitor.compact");
|
||||
for (var c = 0; c < cards.length; c++) {
|
||||
(function (card) {
|
||||
var id = card.dataset.monitorId;
|
||||
var button = card.querySelector(".monitor-row");
|
||||
var detail = card.querySelector(".monitor-detail");
|
||||
button.addEventListener("click", function () {
|
||||
var isOpen = card.classList.toggle("expanded-state");
|
||||
button.setAttribute("aria-expanded", isOpen ? "true" : "false");
|
||||
if (isOpen && detail.dataset.loaded === "false") {
|
||||
fetch("/" + slug + "/monitor/" + encodeURIComponent(id) + ".json", { cache: "no-store" })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("status " + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function (payload) {
|
||||
detail.innerHTML = renderDetail(payload);
|
||||
detail.dataset.loaded = "true";
|
||||
})
|
||||
.catch(function () {
|
||||
detail.innerHTML =
|
||||
'<div style="color:var(--bar-down);font-size:0.85rem;padding:0.5rem 0">Failed to load detail.</div>';
|
||||
});
|
||||
}
|
||||
});
|
||||
})(cards[c]);
|
||||
}
|
||||
});
|
||||
document.addEventListener("mouseout", function (e) {
|
||||
var bar = e.target && e.target.closest && e.target.closest(".bar");
|
||||
if (bar) hideTooltip();
|
||||
});
|
||||
window.addEventListener("scroll", hideTooltip, { passive: true });
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -110,8 +110,10 @@
|
|||
.overall .dot { width: 12px; height: 12px; border-radius: 50%; background: var(--overall-fg); opacity: 0.9; }
|
||||
.group-title { font-size: 0.85rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin: 2rem 0 0.75rem; }
|
||||
.monitors { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.monitor { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 1rem 1.25rem; }
|
||||
.monitor-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 0.5rem; }
|
||||
/* All monitors share one structure: a clickable header row + a collapsible
|
||||
detail panel. display_mode just controls whether `expanded-state` is set
|
||||
on initial render. */
|
||||
.monitor { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 0; overflow: hidden; }
|
||||
.monitor-name { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
|
||||
.monitor-name .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.monitor-name .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
|
@ -138,16 +140,13 @@
|
|||
.uptime-cell.warn .value { color: var(--bar-partial); }
|
||||
.uptime-cell.bad .value { color: var(--bar-down); }
|
||||
.uptime-cell.empty .value { color: var(--muted); }
|
||||
/* Compact mode: each monitor is a one-line button. Detail expands beneath. */
|
||||
.monitor.compact { padding: 0; overflow: hidden; }
|
||||
.monitor-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.85rem 1.25rem; cursor: pointer; background: transparent; border: none; color: inherit; width: 100%; text-align: left; font-family: inherit; font-size: inherit; }
|
||||
/* Header row is always clickable; detail panel collapses/expands. */
|
||||
.monitor-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem; cursor: pointer; background: transparent; border: none; color: inherit; width: 100%; text-align: left; font-family: inherit; font-size: inherit; }
|
||||
.monitor-row:hover { background: rgba(255,255,255,0.02); }
|
||||
.monitor-row .chev { color: var(--muted); transition: transform 0.15s; flex-shrink: 0; }
|
||||
.monitor.expanded-state .monitor-row .chev { transform: rotate(90deg); }
|
||||
.monitor-detail { padding: 0 1.25rem 1rem; border-top: 1px solid var(--border); display: none; }
|
||||
.monitor.expanded-state .monitor-detail { display: block; padding-top: 1rem; }
|
||||
.monitor-detail .skeleton { height: 80px; background: linear-gradient(90deg, var(--card), var(--bg), var(--card)); background-size: 200% 100%; animation: shimmer 1.2s infinite; border-radius: 6px; }
|
||||
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||
.regions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; font-size: 0.75rem; }
|
||||
.region { padding: 0.15rem 0.5rem; border-radius: 999px; border: 1px solid var(--border); }
|
||||
.region.up { color: var(--green); border-color: rgba(16,185,129,0.3); }
|
||||
|
|
@ -278,37 +277,23 @@
|
|||
const buckets = m.buckets || [];
|
||||
const hasData = buckets.some(b => b.total > 0);
|
||||
const u = m.uptime || { d24: null, d7: null, d30: null, d90: null };
|
||||
const monitorIsCompact = m.display_mode === 'compact';
|
||||
// Every monitor renders with the same DOM. display_mode just sets
|
||||
// whether the detail panel starts expanded or collapsed.
|
||||
const startsOpen = m.display_mode !== 'compact';
|
||||
%>
|
||||
<% if (monitorIsCompact) { %>
|
||||
<div class="monitor compact" data-monitor-id="<%= m.id %>">
|
||||
<button type="button" class="monitor-row" aria-expanded="false">
|
||||
<div class="monitor-name">
|
||||
<span class="dot" style="background: <%= statusColor(m.current_state) %>;"></span>
|
||||
<span class="name"><%= m.display_name %></span>
|
||||
</div>
|
||||
<div class="monitor-meta">
|
||||
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %>
|
||||
<span class="uptime-pct"><%= fmtUptime(u[page.default_window === '24h' ? 'd24' : page.default_window === '7d' ? 'd7' : page.default_window === '30d' ? 'd30' : 'd90']) %></span>
|
||||
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
</button>
|
||||
<div class="monitor-detail" data-loaded="false">
|
||||
<div class="skeleton"></div>
|
||||
<div class="monitor<%= startsOpen ? ' expanded-state' : '' %>" data-monitor-id="<%= m.id %>">
|
||||
<button type="button" class="monitor-row" aria-expanded="<%= startsOpen ? 'true' : 'false' %>">
|
||||
<div class="monitor-name">
|
||||
<span class="dot" style="background: <%= statusColor(m.current_state) %>;"></span>
|
||||
<span class="name"><%= m.display_name %></span>
|
||||
</div>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="monitor" data-monitor-id="<%= m.id %>">
|
||||
<div class="monitor-head">
|
||||
<div class="monitor-name">
|
||||
<span class="dot" style="background: <%= statusColor(m.current_state) %>;"></span>
|
||||
<span class="name"><%= m.display_name %></span>
|
||||
</div>
|
||||
<div class="monitor-meta">
|
||||
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %>
|
||||
<span class="uptime-pct"><%= fmtUptime(u[page.default_window === '24h' ? 'd24' : page.default_window === '7d' ? 'd7' : page.default_window === '30d' ? 'd30' : 'd90']) %></span>
|
||||
</div>
|
||||
<div class="monitor-meta">
|
||||
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %>
|
||||
<span class="uptime-pct"><%= fmtUptime(u[page.default_window === '24h' ? 'd24' : page.default_window === '7d' ? 'd7' : page.default_window === '30d' ? 'd30' : 'd90']) %></span>
|
||||
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
</button>
|
||||
<div class="monitor-detail">
|
||||
<div class="bars" aria-label="<%= statusLabel(m.current_state) %>">
|
||||
<% buckets.forEach(function(b) { %>
|
||||
<div class="bar" style="background: <%= bucketColor(b) %>;" data-start="<%= b.start %>" data-total="<%= b.total %>" data-up="<%= b.up %>"<% if (b.avg_latency != null) { %> data-latency="<%= b.avg_latency %>"<% } %>></div>
|
||||
|
|
@ -325,7 +310,7 @@
|
|||
<div class="uptime-cell <%= uptimeBand(u.d90) %>"><div class="label">90d</div><div class="value"><%= fmtUptime(u.d90) %></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% }); %>
|
||||
|
|
|
|||
Loading…
Reference in New Issue