fix
This commit is contained in:
parent
8f7ac6bb4b
commit
84e609c48f
|
|
@ -1,6 +1,7 @@
|
||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
import { Eta } from "eta";
|
import { Eta } from "eta";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
|
import { createHash } from "crypto";
|
||||||
import sql from "./db";
|
import sql from "./db";
|
||||||
import { loadStatusPage, loadPagePayload, loadMonitorDetail, type Window } from "./data";
|
import { loadStatusPage, loadPagePayload, loadMonitorDetail, type Window } from "./data";
|
||||||
import { renderRss } from "./render/rss";
|
import { renderRss } from "./render/rss";
|
||||||
|
|
@ -18,6 +19,12 @@ const eta = new Eta({ views: resolve(import.meta.dir, "./views"), cache: true, d
|
||||||
|
|
||||||
const PUBLIC_BASE = process.env.STATUS_BASE_URL ?? "https://status.pingql.com";
|
const PUBLIC_BASE = process.env.STATUS_BASE_URL ?? "https://status.pingql.com";
|
||||||
|
|
||||||
|
// Hash the static expand.js so the URL changes whenever its bytes change.
|
||||||
|
// Used as a cache-busting query string on the <script src=...> tag.
|
||||||
|
const expandJsPath = resolve(import.meta.dir, "./static/expand.js");
|
||||||
|
const expandJsBytes = await Bun.file(expandJsPath).bytes();
|
||||||
|
const expandJsHash = createHash("md5").update(expandJsBytes).digest("hex").slice(0, 8);
|
||||||
|
|
||||||
function clientIp(req: Request): string {
|
function clientIp(req: Request): string {
|
||||||
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||||
|| req.headers.get("cf-connecting-ip")
|
|| req.headers.get("cf-connecting-ip")
|
||||||
|
|
@ -60,7 +67,7 @@ async function renderHtml(slug: string, request: Request): Promise<Response> {
|
||||||
}
|
}
|
||||||
const payload = await cached(`payload:${slug}`, 15, () => loadPagePayload(slug));
|
const payload = await cached(`payload:${slug}`, 15, () => loadPagePayload(slug));
|
||||||
if (!payload) return notFound();
|
if (!payload) return notFound();
|
||||||
const html = eta.render("page", payload);
|
const html = eta.render("page", { ...payload, expandJsHash });
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"content-type": "text/html; charset=utf-8",
|
"content-type": "text/html; charset=utf-8",
|
||||||
"cache-control": "public, max-age=15, s-maxage=15",
|
"cache-control": "public, max-age=15, s-maxage=15",
|
||||||
|
|
@ -105,6 +112,14 @@ const app = new Elysia()
|
||||||
headers: { "content-type": "text/plain" },
|
headers: { "content-type": "text/plain" },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Static expand.js — cached aggressively, hash-busted via query string.
|
||||||
|
.get("/_static/expand.js", () => new Response(Bun.file(expandJsPath), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/javascript; charset=utf-8",
|
||||||
|
"cache-control": "public, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
// Single public route — dispatches HTML / JSON / RSS by extension on the slug.
|
// Single public route — dispatches HTML / JSON / RSS by extension on the slug.
|
||||||
.get("/:slug", async ({ params, request, query }) => {
|
.get("/:slug", async ({ params, request, query }) => {
|
||||||
const { slug, format } = splitSlugAndFormat(params.slug);
|
const { slug, format } = splitSlugAndFormat(params.slug);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
// Click-to-expand for compact display mode on the public status page.
|
||||||
|
// Loaded by status pages that have at least one monitor in compact mode.
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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)";
|
||||||
|
}
|
||||||
|
function uptimeBand(p) {
|
||||||
|
if (p == null) return "empty";
|
||||||
|
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>";
|
||||||
|
}
|
||||||
|
html += "</div>";
|
||||||
|
}
|
||||||
|
html += "</div>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 regionsHtml = "";
|
||||||
|
if (m.region_states && m.region_states.length > 1) {
|
||||||
|
regionsHtml = '<div class="regions">';
|
||||||
|
for (var r = 0; r < m.region_states.length; r++) {
|
||||||
|
var rs = m.region_states[r];
|
||||||
|
regionsHtml += '<span class="region ' + rs.state + '">' + escapeHtml(rs.region) + "</span>";
|
||||||
|
}
|
||||||
|
regionsHtml += "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
var latencyHtml = "";
|
||||||
|
if (showResponseTime && m.avg_latency != null) {
|
||||||
|
latencyHtml = "<span>" + m.avg_latency + "ms · </span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 += regionsHtml;
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -322,117 +322,8 @@
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<% if (anyCompact) { %>
|
<% if (anyCompact) { %>
|
||||||
<script>
|
<script>window.PINGQL_PAGE = <%~ JSON.stringify({ slug: page.slug, default_window: page.default_window, show_response_time: !!page.show_response_time }) %>;</script>
|
||||||
// Click-to-expand for compact display mode. First click on a monitor row
|
<script src="/_static/expand.js?v=<%= it.expandJsHash %>" defer></script>
|
||||||
// fetches /<slug>/monitor/<id>.json once and renders the detail inline;
|
|
||||||
// subsequent clicks just toggle visibility.
|
|
||||||
(function() {
|
|
||||||
const slug = <%~ JSON.stringify(page.slug) %>;
|
|
||||||
const defaultWindow = <%~ JSON.stringify(page.default_window) %>;
|
|
||||||
const showResponseTime = <%~ JSON.stringify(!!page.show_response_time) %>;
|
|
||||||
|
|
||||||
function escapeHtml(s) {
|
|
||||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
function statusColor(s) {
|
|
||||||
return s === 'up' ? 'var(--bar-up)' : s === 'down' ? 'var(--bar-down)' : 'var(--muted)';
|
|
||||||
}
|
|
||||||
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)';
|
|
||||||
}
|
|
||||||
function uptimeBand(p) {
|
|
||||||
if (p == null) return 'empty';
|
|
||||||
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) {
|
|
||||||
return w === '24h' ? 'Last 24 hours' : w === '7d' ? 'Last 7 days' : w === '30d' ? 'Last 30 days' : 'Last 90 days';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDetail(payload) {
|
|
||||||
const m = payload.monitor;
|
|
||||||
const u = m.uptime || { d24: null, d7: null, d30: null, d90: null };
|
|
||||||
const buckets = m.buckets || [];
|
|
||||||
const hasData = buckets.some(b => b.total > 0);
|
|
||||||
const barsHtml = buckets.map(b =>
|
|
||||||
`<div class="bar" style="background: ${bucketColor(b)};" title="${b.total > 0 ? Math.round(100 * b.up / b.total) + '% up' : 'no data'}"></div>`
|
|
||||||
).join('');
|
|
||||||
const regionsHtml = (m.region_states && m.region_states.length > 1)
|
|
||||||
? `<div class="regions">${m.region_states.map(r => `<span class="region ${r.state}">${escapeHtml(r.region)}</span>`).join('')}</div>`
|
|
||||||
: '';
|
|
||||||
const latencyHtml = showResponseTime && m.avg_latency != null ? `<span>${m.avg_latency}ms · </span>` : '';
|
|
||||||
// Render the full incident timeline for any incidents touching this monitor.
|
|
||||||
function fmtTs(iso) {
|
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
function renderIncidentClient(i) {
|
|
||||||
const klass = i.resolved_at ? `incident ${i.severity} resolved` : `incident ${i.severity}`;
|
|
||||||
let h = `<div class="${klass}" style="margin-top:1rem"><div class="incident-title">${escapeHtml(i.title)}</div>`;
|
|
||||||
h += `<div class="incident-meta"><span class="pill ${i.status}">${i.status}</span>Started ${fmtTs(i.started_at)}`;
|
|
||||||
if (i.resolved_at) h += ` · Resolved ${fmtTs(i.resolved_at)}`;
|
|
||||||
h += `</div>`;
|
|
||||||
if (i.updates && i.updates.length > 0) {
|
|
||||||
h += `<div class="incident-timeline">`;
|
|
||||||
for (const u of i.updates) {
|
|
||||||
h += `<div class="incident-update"><div class="head"><span class="status ${u.status}">${u.status}</span><span>${fmtTs(u.created_at)}</span></div><div class="body">${u.body_html}</div></div>`;
|
|
||||||
}
|
|
||||||
h += `</div>`;
|
|
||||||
}
|
|
||||||
h += `</div>`;
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
const incidentsHtml = (payload.incidents && payload.incidents.length > 0)
|
|
||||||
? payload.incidents.map(renderIncidentClient).join('')
|
|
||||||
: '';
|
|
||||||
return `
|
|
||||||
<div class="bars" title="${m.current_state}">${barsHtml}</div>
|
|
||||||
<div class="bars-meta"><span>${windowLabel(defaultWindow)}</span><span>${latencyHtml}${hasData ? 'uptime over window' : 'awaiting data'}</span></div>
|
|
||||||
<div class="uptime-row">
|
|
||||||
<div class="uptime-cell ${uptimeBand(u.d24)}"><div class="label">24h</div><div class="value">${fmtUptime(u.d24)}</div></div>
|
|
||||||
<div class="uptime-cell ${uptimeBand(u.d7)}"><div class="label">7d</div><div class="value">${fmtUptime(u.d7)}</div></div>
|
|
||||||
<div class="uptime-cell ${uptimeBand(u.d30)}"><div class="label">30d</div><div class="value">${fmtUptime(u.d30)}</div></div>
|
|
||||||
<div class="uptime-cell ${uptimeBand(u.d90)}"><div class="label">90d</div><div class="value">${fmtUptime(u.d90)}</div></div>
|
|
||||||
</div>
|
|
||||||
${regionsHtml}
|
|
||||||
${incidentsHtml}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll('.monitor.compact').forEach(card => {
|
|
||||||
const id = card.dataset.monitorId;
|
|
||||||
const button = card.querySelector('.monitor-row');
|
|
||||||
const detail = card.querySelector('.monitor-detail');
|
|
||||||
button.addEventListener('click', async () => {
|
|
||||||
const isOpen = card.classList.toggle('expanded-state');
|
|
||||||
button.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
|
||||||
if (isOpen && detail.dataset.loaded === 'false') {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/' + slug + '/monitor/' + encodeURIComponent(id) + '.json', { cache: 'no-store' });
|
|
||||||
if (!r.ok) {
|
|
||||||
detail.innerHTML = '<div style="color:var(--bar-down);font-size:0.85rem;padding:0.5rem 0">Failed to load detail.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = await r.json();
|
|
||||||
detail.innerHTML = renderDetail(payload);
|
|
||||||
detail.dataset.loaded = 'true';
|
|
||||||
} catch (e) {
|
|
||||||
detail.innerHTML = '<div style="color:var(--bar-down);font-size:0.85rem;padding:0.5rem 0">Failed to load detail.</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<% if (page.auto_refresh_s > 0) { %>
|
<% if (page.auto_refresh_s > 0) { %>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue