395 lines
20 KiB
Plaintext
395 lines
20 KiB
Plaintext
<%
|
|
const page = it.page;
|
|
const monitors = it.monitors;
|
|
const groups = it.groups;
|
|
const incidents = it.incidents;
|
|
const themeClass = page.theme === 'dark' ? 'dark' : page.theme === 'light' ? 'light' : '';
|
|
|
|
// Group monitors. group_id null = "ungrouped".
|
|
const grouped = {};
|
|
for (const m of monitors) {
|
|
const key = m.group_id || '';
|
|
if (!grouped[key]) grouped[key] = [];
|
|
grouped[key].push(m);
|
|
}
|
|
const groupOrder = [...groups.map(g => g.id), ''];
|
|
|
|
function fmtPct(p) {
|
|
if (p == null) return '—';
|
|
return p === 100 ? '100%' : p.toFixed(2) + '%';
|
|
}
|
|
function statusLabel(s) {
|
|
if (s === 'up') return 'Operational';
|
|
if (s === 'down') return 'Down';
|
|
return 'Unknown';
|
|
}
|
|
function statusColor(s) {
|
|
if (s === 'up') return 'var(--bar-up)';
|
|
if (s === 'down') return 'var(--bar-down)';
|
|
return '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) + '%';
|
|
}
|
|
|
|
// Overall status: down if any monitor is down, degraded if any partial, else up.
|
|
let overall = 'up';
|
|
for (const m of monitors) {
|
|
const partial = m.region_states.some(r => r.state === 'down') && m.region_states.some(r => r.state === 'up');
|
|
if (m.current_state === 'down') { overall = 'down'; break; }
|
|
if (partial && overall !== 'down') overall = 'degraded';
|
|
}
|
|
const overallText = overall === 'up' ? 'All systems operational'
|
|
: overall === 'degraded' ? 'Some systems degraded'
|
|
: 'Major outage in progress';
|
|
const overallColor = overall === 'up' ? 'var(--bar-up)'
|
|
: overall === 'degraded' ? 'var(--bar-partial)'
|
|
: 'var(--bar-down)';
|
|
%><!DOCTYPE html>
|
|
<html lang="en" class="<%= themeClass %>">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title><%= page.title %></title>
|
|
<% if (page.description) { %><meta name="description" content="<%= page.description %>"><% } %>
|
|
<% if (!page.index_search) { %><meta name="robots" content="noindex,nofollow"><% } %>
|
|
<meta property="og:title" content="<%= page.title %>">
|
|
<% if (page.description) { %><meta property="og:description" content="<%= page.description %>"><% } %>
|
|
<% if (page.og_image_url) { %><meta property="og:image" content="<%= page.og_image_url %>"><% } %>
|
|
<link rel="alternate" type="application/rss+xml" title="<%= page.title %> incidents" href="/<%= page.slug %>.rss">
|
|
<link rel="manifest" href="/<%= page.slug %>/manifest.json">
|
|
<style>
|
|
:root {
|
|
--bg: #ffffff; --fg: #1e293b; --muted: #64748b; --card: #f8fafc;
|
|
--border: #e2e8f0; --accent: #0284c7;
|
|
--bar-up: #10b981; --bar-down: #ef4444; --bar-partial: #f59e0b; --bar-empty: #e5e7eb;
|
|
--overall-fg: #ffffff;
|
|
}
|
|
/* OLED-friendly dark mode: pure black background for power saving, but a
|
|
muted slate-300 foreground instead of white so it doesn't blow out on
|
|
self-emissive displays. */
|
|
html.dark, html:not(.light):not(.dark) { color-scheme: dark; }
|
|
@media (prefers-color-scheme: dark) {
|
|
html:not(.light) {
|
|
--bg: #0a0a0a; --fg: #c2c8d0; --muted: #6b7280; --card: #131418;
|
|
--border: #1f2026; --accent: #3b9bd1;
|
|
--bar-up: #1f8f6e; --bar-down: #c84a4a; --bar-partial: #c98a2c; --bar-empty: #1f2026;
|
|
--overall-fg: #f1f5f9;
|
|
}
|
|
}
|
|
html.dark {
|
|
--bg: #0a0a0a; --fg: #c2c8d0; --muted: #6b7280; --card: #131418;
|
|
--border: #1f2026; --accent: #3b9bd1;
|
|
--bar-up: #1f8f6e; --bar-down: #c84a4a; --bar-partial: #c98a2c; --bar-empty: #1f2026;
|
|
--overall-fg: #f1f5f9;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; line-height: 1.5; }
|
|
main { max-width: 880px; margin: 0 auto; padding: 3rem 1.5rem; }
|
|
h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; }
|
|
.muted { color: var(--muted); font-size: 0.875rem; }
|
|
.overall { padding: 1.25rem 1.5rem; border-radius: 12px; color: var(--overall-fg); font-weight: 600; font-size: 1.05rem; margin: 1.5rem 0 2rem; display: flex; align-items: center; gap: 0.75rem; }
|
|
.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; }
|
|
.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; }
|
|
.monitor-meta { display: flex; gap: 1rem; align-items: center; font-size: 0.85rem; color: var(--muted); }
|
|
.uptime-pct { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--fg); }
|
|
.bars { display: flex; gap: 2px; height: 32px; margin-top: 0.75rem; align-items: stretch; }
|
|
.bar { flex: 1; min-width: 0; border-radius: 2px; transition: opacity 0.15s; }
|
|
.bar:hover { opacity: 0.8; }
|
|
.bars-meta { display: flex; justify-content: space-between; font-size: 0.7rem; color: var(--muted); margin-top: 0.4rem; }
|
|
/* Multi-window uptime row: four labelled cells side by side. */
|
|
.uptime-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.5rem; margin-top: 0.75rem; }
|
|
.uptime-cell { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 0.65rem; }
|
|
.uptime-cell .label { font-size: 0.65rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.2rem; }
|
|
.uptime-cell .value { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.95rem; }
|
|
.uptime-cell.good .value { color: var(--bar-up); }
|
|
.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; }
|
|
.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); }
|
|
.region.down { color: var(--red); border-color: rgba(239,68,68,0.3); }
|
|
.incidents { margin-bottom: 2rem; }
|
|
.incident { background: var(--card); border-left: 4px solid var(--amber); border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
|
.incident.critical { border-left-color: var(--red); }
|
|
.incident.major { border-left-color: var(--amber); }
|
|
.incident-title { font-weight: 600; margin-bottom: 0.25rem; }
|
|
.incident-meta { color: var(--muted); font-size: 0.8rem; margin-bottom: 0.5rem; }
|
|
.incident-body p { margin: 0.5rem 0; }
|
|
.incident-body code { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
|
|
.past-incidents { margin-top: 3rem; }
|
|
.past-incidents h2 { font-size: 1.1rem; margin-bottom: 1rem; }
|
|
.past { padding: 0.75rem 0; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; gap: 1rem; }
|
|
.past-title { font-weight: 500; }
|
|
.past-meta { color: var(--muted); font-size: 0.8rem; }
|
|
footer { margin-top: 4rem; padding-top: 2rem; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.8rem; text-align: center; }
|
|
a { color: var(--accent); text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
<% if (page.custom_css) { %><%~ page.custom_css %><% } %>
|
|
</style>
|
|
<% if (page.analytics_html) { %><%~ page.analytics_html %><% } %>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1><%= page.title %></h1>
|
|
<% if (page.description) { %><div class="muted"><%= page.description %></div><% } %>
|
|
|
|
<div class="overall" style="background: <%= overallColor %>;">
|
|
<span class="dot"></span>
|
|
<span><%= overallText %></span>
|
|
</div>
|
|
|
|
<% if (incidents.active.length > 0) { %>
|
|
<div class="incidents">
|
|
<% incidents.active.forEach(function(i) { %>
|
|
<div id="incident-<%= i.id %>" class="incident <%= i.severity %>">
|
|
<div class="incident-title"><%= i.title %></div>
|
|
<div class="incident-meta"><%= i.status %> · started <%= new Date(i.started_at).toLocaleString() %></div>
|
|
<% if (i.latest_update_html) { %><div class="incident-body"><%~ i.latest_update_html %></div><% } %>
|
|
</div>
|
|
<% }); %>
|
|
</div>
|
|
<% } %>
|
|
|
|
<%
|
|
const isCompact = page.display_mode === 'compact';
|
|
const windowLabel = page.default_window === '24h' ? 'Last 24 hours'
|
|
: page.default_window === '7d' ? 'Last 7 days'
|
|
: page.default_window === '30d' ? 'Last 30 days'
|
|
: 'Last 90 days';
|
|
%>
|
|
<% groupOrder.forEach(function(gid) {
|
|
const list = grouped[gid];
|
|
if (!list || list.length === 0) return;
|
|
const groupName = gid ? (groups.find(g => g.id === gid)?.name || '') : '';
|
|
%>
|
|
<% if (groupName) { %><div class="group-title"><%= groupName %></div><% } %>
|
|
<div class="monitors">
|
|
<% list.forEach(function(m) {
|
|
const buckets = m.buckets || [];
|
|
const hasData = buckets.some(b => b.total > 0);
|
|
const u = m.uptime || { d24: null, d7: null, d30: null, d90: null };
|
|
%>
|
|
<% if (isCompact) { %>
|
|
<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">
|
|
<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>
|
|
</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>
|
|
<div class="bars" title="<%= statusLabel(m.current_state) %>">
|
|
<% buckets.forEach(function(b) { %>
|
|
<div class="bar" style="background: <%= bucketColor(b) %>;" title="<%= b.total > 0 ? (Math.round(100 * b.up / b.total) + '% up') : 'no data' %>"></div>
|
|
<% }); %>
|
|
</div>
|
|
<div class="bars-meta">
|
|
<span><%= windowLabel %></span>
|
|
<span><%= 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>
|
|
<% if (m.region_states && m.region_states.length > 1) { %>
|
|
<div class="regions">
|
|
<% m.region_states.forEach(function(r) { %>
|
|
<span class="region <%= r.state %>"><%= r.region %></span>
|
|
<% }); %>
|
|
</div>
|
|
<% } %>
|
|
</div>
|
|
<% } %>
|
|
<% }); %>
|
|
</div>
|
|
<% }); %>
|
|
|
|
<% if (incidents.recent.length > 0) { %>
|
|
<div class="past-incidents">
|
|
<h2>Past incidents</h2>
|
|
<% incidents.recent.forEach(function(i) { %>
|
|
<div class="past">
|
|
<div>
|
|
<div class="past-title"><%= i.title %></div>
|
|
<div class="past-meta"><%= i.status %> · <%= new Date(i.started_at).toLocaleDateString() %></div>
|
|
</div>
|
|
</div>
|
|
<% }); %>
|
|
</div>
|
|
<% } %>
|
|
|
|
<footer>
|
|
<% if (page.footer_text) { %><div><%= page.footer_text %></div><% } %>
|
|
<% if (page.show_powered_by) { %><div>Status powered by <a href="https://pingql.com">PingQL</a></div><% } %>
|
|
</footer>
|
|
</main>
|
|
|
|
<% if (isCompact) { %>
|
|
<script>
|
|
// Click-to-expand for compact display mode. First click on a monitor row
|
|
// 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>` : '';
|
|
const incidentsHtml = (payload.incidents && payload.incidents.length > 0)
|
|
? `<div class="bars-meta" style="margin-top:0.75rem"><span>Recent incidents</span><span>${payload.incidents.length}</span></div>`
|
|
: '';
|
|
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) { %>
|
|
<script>
|
|
// Auto-refresh data without a full page reload. Polls /<slug>.json and
|
|
// patches just the bar/uptime/dot DOM nodes.
|
|
(function() {
|
|
const slug = <%~ JSON.stringify(page.slug) %>;
|
|
const intervalMs = Math.max(10, <%= page.auto_refresh_s %>) * 1000;
|
|
async function refresh() {
|
|
try {
|
|
const r = await fetch('/' + slug + '.json', { cache: 'no-store' });
|
|
if (!r.ok) return;
|
|
// Reload on next idle for simplicity. The JSON payload is already cached
|
|
// server-side; the visible diff is small.
|
|
location.reload();
|
|
} catch {}
|
|
}
|
|
setTimeout(refresh, intervalMs);
|
|
})();
|
|
</script>
|
|
<% } %>
|
|
</body>
|
|
</html>
|