344 lines
16 KiB
Plaintext
344 lines
16 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 '-';
|
|
// Only show "100%" when the value is *exactly* 100. Anything below - even
|
|
// 99.9999% - must show 2 decimals so visitors can see there was downtime.
|
|
// Truncate (floor) rather than round, otherwise 99.9999 would render as
|
|
// "100.00" and silently swallow the downtime.
|
|
if (p >= 100) return '100%';
|
|
return (Math.floor(p * 100) / 100).toFixed(2) + '%';
|
|
}
|
|
function statusLabel(s) {
|
|
if (s === 'up') return 'Operational';
|
|
if (s === 'down') return 'Down';
|
|
if (s === 'paused') return 'Under maintenance';
|
|
return 'Unknown';
|
|
}
|
|
function statusColor(s) {
|
|
if (s === 'up') return 'var(--bar-up)';
|
|
if (s === 'down') return 'var(--bar-down)';
|
|
if (s === 'paused') return 'var(--bar-paused)';
|
|
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 >= 97) return 'good';
|
|
if (p >= 87) return 'warn';
|
|
return 'bad';
|
|
}
|
|
function fmtUptime(p) {
|
|
if (p == null) return '-';
|
|
// Only "100%" if the monitor was up for every single check in the window.
|
|
// Truncate (not round) below that so 99.9999% never displays as "100.00".
|
|
if (p >= 100) return '100%';
|
|
return (Math.floor(p * 100) / 100).toFixed(2) + '%';
|
|
}
|
|
|
|
// Overall status: down if any monitor is down, degraded if any partial, else up.
|
|
// Paused monitors are operator-declared maintenance - they don't count
|
|
// toward "down" or "degraded", but we surface a small note in the banner
|
|
// when at least one is in maintenance so visitors aren't confused.
|
|
let paused_count = 0;
|
|
let down_count = 0;
|
|
let active_count = 0;
|
|
let has_degraded = false;
|
|
for (const m of monitors) {
|
|
if (m.current_state === 'paused') { paused_count++; continue; }
|
|
active_count++;
|
|
const partial = m.region_states.some(r => r.state === 'down') && m.region_states.some(r => r.state === 'up');
|
|
if (m.current_state === 'down') down_count++;
|
|
else if (partial) has_degraded = true;
|
|
}
|
|
const overall = down_count === 0 && !has_degraded ? 'up'
|
|
: active_count > 0 && down_count === active_count ? 'down'
|
|
: 'degraded';
|
|
let overallText = overall === 'up' ? 'All systems operational'
|
|
: overall === 'down' ? 'Major outage in progress'
|
|
: 'Partial outage';
|
|
if (paused_count > 0) {
|
|
overallText += ' - ' + paused_count + (paused_count === 1 ? ' service' : ' services') + ' under maintenance';
|
|
}
|
|
const overallBg = overall === 'up' ? 'rgba(16,185,129,0.1)'
|
|
: overall === 'degraded' ? 'rgba(245,158,11,0.1)'
|
|
: 'rgba(239,68,68,0.1)';
|
|
const overallBorder = overall === 'up' ? 'rgba(16,185,129,0.25)'
|
|
: overall === 'degraded' ? 'rgba(245,158,11,0.25)'
|
|
: 'rgba(239,68,68,0.25)';
|
|
const overallFg = overall === 'up' ? 'var(--bar-up)'
|
|
: overall === 'degraded' ? 'var(--bar-partial)'
|
|
: 'var(--bar-down)';
|
|
const dotColor = overall === 'up' ? '#10b981'
|
|
: overall === 'degraded' ? '#f59e0b'
|
|
: '#ef4444';
|
|
const dotGlow = overall === 'up' ? 'rgba(16,185,129,0.4)'
|
|
: overall === 'degraded' ? 'rgba(245,158,11,0.4)'
|
|
: 'rgba(239,68,68,0.4)';
|
|
|
|
%><!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 %>"><% } %>
|
|
<link rel="icon" href="/_static/favicon.svg" type="image/svg+xml">
|
|
<link rel="alternate" type="application/rss+xml" title="<%= page.title %> incidents" href="/<%= page.slug %>.rss">
|
|
<link rel="manifest" href="/<%= page.slug %>/manifest.json">
|
|
<link rel="stylesheet" href="/_static/app.css?v=<%= it.appCssHash %>">
|
|
<% if (page.custom_css) { %><style><%~ page.custom_css %></style><% } %>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<div class="title-row">
|
|
<h1><%= page.title %></h1>
|
|
<a class="json-link" href="/<%= page.slug %>.json" title="JSON API" aria-label="JSON API">
|
|
<svg width="18" height="18" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M10 4a3 3 0 0 0-3 3v6a3 3 0 0 1-3 3 3 3 0 0 1 3 3v6a3 3 0 0 0 3 3"/>
|
|
<path d="M22 28a3 3 0 0 0 3-3v-6a3 3 0 0 1 3-3 3 3 0 0 1-3-3V7a3 3 0 0 0-3-3"/>
|
|
<circle cx="16" cy="12.5" r="1.4" fill="currentColor" stroke="none"/>
|
|
<circle cx="16" cy="18" r="1.4" fill="currentColor" stroke="none"/>
|
|
<line x1="16" y1="18" x2="14.8" y2="21" stroke-width="2"/>
|
|
</svg>
|
|
</a>
|
|
<a class="rss-link" href="/<%= page.slug %>.rss" title="Subscribe via RSS" aria-label="Subscribe via RSS">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
|
<path d="M4 11a9 9 0 0 1 9 9"/>
|
|
<path d="M4 4a16 16 0 0 1 16 16"/>
|
|
<circle cx="5" cy="19" r="1.5" fill="currentColor" stroke="none"/>
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
<% if (page.description) { %><div class="muted"><%= page.description %></div><% } %>
|
|
|
|
<div class="overall" style="background: <%= overallBg %>; border-color: <%= overallBorder %>; color: <%= overallFg %>;">
|
|
<span class="dot" style="background: <%= dotColor %>; box-shadow: 0 0 8px <%= dotGlow %>;"></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 %><%= i.resolved_at ? ' resolved' : '' %>">
|
|
<div class="incident-title"><%= i.title %></div>
|
|
<div class="incident-meta">
|
|
<span class="pill <%= i.status %>"><%= i.status %></span>
|
|
Started <%= fmtTimestamp(i.started_at) %>
|
|
<% if (i.resolved_at) { %> · Resolved <%= fmtTimestamp(i.resolved_at) %><% } %>
|
|
</div>
|
|
<% if (i.updates && i.updates.length > 0) { %>
|
|
<div class="incident-timeline">
|
|
<% i.updates.forEach(function(u) { %>
|
|
<div class="incident-update">
|
|
<div class="head">
|
|
<span class="status <%= u.status %>"><%= u.status %></span>
|
|
<span><%= fmtTimestamp(u.created_at) %></span>
|
|
</div>
|
|
<div class="body"><%~ u.body_html %></div>
|
|
</div>
|
|
<% }); %>
|
|
</div>
|
|
<% } %>
|
|
</div>
|
|
<% }); %>
|
|
</div>
|
|
<% } %>
|
|
|
|
<%
|
|
const windowLabel = page.bar_frequency === 'hourly'
|
|
? ('Last ' + page.bar_count + ' hour' + (page.bar_count === 1 ? '' : 's'))
|
|
: ('Last ' + page.bar_count + ' day' + (page.bar_count === 1 ? '' : 's'));
|
|
function fmtTimestamp(iso) {
|
|
const d = new Date(iso);
|
|
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
%>
|
|
<div class="page-components">
|
|
<% groupOrder.forEach(function(gid, gi) {
|
|
const list = grouped[gid];
|
|
if (!list || list.length === 0) return;
|
|
const groupName = gid ? (groups.find(g => g.id === gid)?.name || '') : '';
|
|
// Aggregate status for the group header
|
|
const activeInGroup = list.filter(m => m.current_state !== 'paused');
|
|
const downInGroup = activeInGroup.filter(m => m.current_state === 'down').length;
|
|
const groupStatus = activeInGroup.length === 0 ? 'unknown' : downInGroup === activeInGroup.length ? 'down' : downInGroup > 0 ? 'degraded' : 'up';
|
|
const groupStatusLabel = groupStatus === 'up' ? 'Operational' : groupStatus === 'degraded' ? 'Degraded' : groupStatus === 'down' ? 'Down' : '';
|
|
const groupStatusColor = groupStatus === 'up' ? 'var(--bar-up)' : groupStatus === 'degraded' ? 'var(--bar-partial)' : groupStatus === 'down' ? 'var(--bar-down)' : 'var(--muted)';
|
|
%>
|
|
<% if (groupName) {
|
|
// Aggregate buckets across all monitors in the group (weighted average)
|
|
const aggBuckets = [];
|
|
const firstWithBuckets = list.find(m => m.buckets && m.buckets.length > 0);
|
|
if (firstWithBuckets) {
|
|
for (let bi = 0; bi < firstWithBuckets.buckets.length; bi++) {
|
|
let t = 0, u = 0, latSum = 0, latN = 0;
|
|
for (const m of list) {
|
|
if (!m.buckets || !m.buckets[bi]) continue;
|
|
t += m.buckets[bi].total;
|
|
u += m.buckets[bi].up;
|
|
if (m.buckets[bi].avg_latency != null) { latSum += m.buckets[bi].avg_latency * m.buckets[bi].total; latN += m.buckets[bi].total; }
|
|
}
|
|
aggBuckets.push({ start: firstWithBuckets.buckets[bi].start, total: t, up: u, avg_latency: latN > 0 ? Math.round(latSum / latN) : null });
|
|
}
|
|
}
|
|
const aggTotal = aggBuckets.reduce((a, b) => a + b.total, 0);
|
|
const aggUp = aggBuckets.reduce((a, b) => a + b.up, 0);
|
|
const aggPct = aggTotal > 0 ? (100 * aggUp / aggTotal) : null;
|
|
%>
|
|
<div class="group-section">
|
|
<input type="checkbox" class="group-toggle" id="g-<%= gi %>">
|
|
<label class="group-header" for="g-<%= gi %>">
|
|
<div class="monitor-name">
|
|
<span class="dot" style="background: <%= groupStatusColor %>;"></span>
|
|
<span class="name"><%= groupName %></span>
|
|
</div>
|
|
<div class="monitor-meta">
|
|
<span><%= list.length %> monitor<%= list.length === 1 ? '' : 's' %></span>
|
|
<span class="uptime-pct <%= uptimeBand(aggPct) %>"><%= fmtUptime(aggPct) %></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>
|
|
</label>
|
|
<div class="group-aggregate">
|
|
<div class="bars" aria-label="Group uptime">
|
|
<% aggBuckets.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>
|
|
<% }); %>
|
|
</div>
|
|
</div>
|
|
<div class="group-body">
|
|
<% } %>
|
|
<% const inGroup = !!groupName; %>
|
|
<div class="monitors">
|
|
<% list.forEach(function(m) {
|
|
const buckets = m.buckets || [];
|
|
%>
|
|
<div class="monitor" data-monitor-id="<%= m.id %>">
|
|
<div class="monitor-header">
|
|
<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 (m.current_state === 'paused') { %>
|
|
<span class="maintenance-pill" title="This service is paused for maintenance - checks are not running.">Maintenance</span>
|
|
<% } else { %>
|
|
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %>
|
|
<span class="uptime-pct <%= uptimeBand(m.uptime_pct) %>"><%= fmtUptime(m.uptime_pct) %></span>
|
|
<% } %>
|
|
</div>
|
|
</div>
|
|
<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>
|
|
<% }); %>
|
|
</div>
|
|
</div>
|
|
<% }); %>
|
|
</div>
|
|
<% if (groupName) { %>
|
|
</div>
|
|
</div>
|
|
<% } %>
|
|
<% }); %>
|
|
</div>
|
|
|
|
<% if (incidents.recent.length > 0) { %>
|
|
<%
|
|
// Group past incidents by their started_at date (Y-M-D in local time).
|
|
// Atlassian-style: each date is its own section, incidents listed below.
|
|
var pastByDate = {};
|
|
var pastOrder = [];
|
|
for (var pi = 0; pi < incidents.recent.length; pi++) {
|
|
var inc = incidents.recent[pi];
|
|
var d = new Date(inc.started_at);
|
|
var key = d.getFullYear() + '-' + (d.getMonth()+1) + '-' + d.getDate();
|
|
if (!pastByDate[key]) {
|
|
pastByDate[key] = { date: d, items: [] };
|
|
pastOrder.push(key);
|
|
}
|
|
pastByDate[key].items.push(inc);
|
|
}
|
|
%>
|
|
<div class="past-incidents">
|
|
<h2>Past incidents</h2>
|
|
<% pastOrder.forEach(function(k) {
|
|
var day = pastByDate[k];
|
|
var dateLabel = day.date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
|
|
%>
|
|
<div class="past-day">
|
|
<h3 class="past-day-header"><%= dateLabel %></h3>
|
|
<% day.items.forEach(function(i) { %>
|
|
<div id="incident-<%= i.id %>" class="past-item">
|
|
<div class="past-item-title"><%= i.title %></div>
|
|
<% if (i.updates && i.updates.length > 0) { %>
|
|
<% i.updates.forEach(function(u) { %>
|
|
<div class="past-update">
|
|
<span class="past-update-status <%= u.status %>"><%= u.status %></span>
|
|
<span class="past-update-body"> - <%~ u.body_html %></span>
|
|
<div class="past-update-time"><%= fmtTimestamp(u.created_at) %></div>
|
|
</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>
|
|
|
|
<div id="bar-tooltip" role="tooltip"></div>
|
|
<script>window.PINGQL_PAGE = <%~ JSON.stringify({ slug: page.slug, bar_frequency: page.bar_frequency, show_response_time: !!page.show_response_time }) %>;</script>
|
|
<script src="/_static/expand.js?v=<%= it.expandJsHash %>" defer></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>
|