diff --git a/apps/status/src/static/expand.js b/apps/status/src/static/expand.js index 31e5f5d..3c5c43f 100644 --- a/apps/status/src/static/expand.js +++ b/apps/status/src/static/expand.js @@ -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 = '
' + fmtBucketRange(start) + "
"; - if (total > 0) { - html += '
Checks' + total + "
"; - html += '
Successful' + up + "
"; - html += '
Uptime' + pctText + "
"; - if (lat != null) { - html += '
Avg ping' + lat + "ms
"; - } - } else { - html += '
No data
'; - } - 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, "'"); - } - 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 += '
'; - } - return html; - } - - function renderIncident(i) { - var klass = "incident " + i.severity + (i.resolved_at ? " resolved" : ""); - var html = '
'; - html += '
' + escapeHtml(i.title) + "
"; - html += '
' + i.status + ""; - html += "Started " + fmtTs(i.started_at); - if (i.resolved_at) html += " · Resolved " + fmtTs(i.resolved_at); - html += "
"; - if (i.updates && i.updates.length > 0) { - html += '
'; - for (var k = 0; k < i.updates.length; k++) { - var u = i.updates[k]; - html += '
'; - html += '
' + u.status + "" + fmtTs(u.created_at) + "
"; - html += '
' + u.body_html + "
"; - html += "
"; + 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 = '
' + fmtBucketRange(start) + "
"; + if (total > 0) { + html += '
Checks' + total + "
"; + html += '
Successful' + up + "
"; + html += '
Uptime' + pctText + "
"; + if (lat != null) { + html += '
Avg ping' + lat + "ms
"; } - html += "
"; + } else { + html += '
No data
'; } - html += "
"; - 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 = "" + m.avg_latency + "ms · "; + 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 += '
' + barsHtml + "
"; - html += '
' + windowLabel(defaultWindow) + "" + latencyHtml + (hasData ? "uptime over window" : "awaiting data") + "
"; - html += '
'; - html += '
24h
' + fmtUptime(u.d24) + "
"; - html += '
7d
' + fmtUptime(u.d7) + "
"; - html += '
30d
' + fmtUptime(u.d30) + "
"; - html += '
90d
' + fmtUptime(u.d90) + "
"; - html += "
"; - 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 = - '
Failed to load detail.
'; - }); - } - }); - })(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 }); })(); diff --git a/apps/status/src/views/page.ejs b/apps/status/src/views/page.ejs index 766078a..118ed55 100644 --- a/apps/status/src/views/page.ejs +++ b/apps/status/src/views/page.ejs @@ -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) { %> -
- -
-
+
+
- <% } else { %> -
-
-
- - <%= m.display_name %> -
-
- <% if (page.show_response_time && m.avg_latency != null) { %><%= m.avg_latency %>ms<% } %> - <%= fmtUptime(u[page.default_window === '24h' ? 'd24' : page.default_window === '7d' ? 'd7' : page.default_window === '30d' ? 'd30' : 'd90']) %> -
+
+ <% if (page.show_response_time && m.avg_latency != null) { %><%= m.avg_latency %>ms<% } %> + <%= fmtUptime(u[page.default_window === '24h' ? 'd24' : page.default_window === '7d' ? 'd7' : page.default_window === '30d' ? 'd30' : 'd90']) %> +
+ +
<% buckets.forEach(function(b) { %>
data-latency="<%= b.avg_latency %>"<% } %>>
@@ -325,7 +310,7 @@
90d
<%= fmtUptime(u.d90) %>
- <% } %> +
<% }); %>
<% }); %>