Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions client/status-icon-fix.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
(function () {
"use strict";

var ICON_MARKUP =
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="vertical-align:-3px;margin-right:6px;flex-shrink:0">' +
'<path d="M15 2.5H12C7.52166 2.5 5.28249 2.5 3.89124 3.89124C2.5 5.28249 2.5 7.52166 2.5 12C2.5 16.4783 2.5 18.7175 3.89124 20.1088C5.28249 21.5 7.52166 21.5 12 21.5C16.4783 21.5 18.7175 21.5 20.1088 20.1088C21.5 18.7175 21.5 16.4783 21.5 12V10" stroke="#22C55E" stroke-width="2" stroke-linecap="round"/>' +
'<path d="M8.5 10L12 13.5L21.0002 3.5" stroke="#22C55E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' +
"</svg>";

function createIcon() {
var span = document.createElement("span");
span.innerHTML = ICON_MARKUP;
return span.firstChild;
}

function fix() {
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
var matches = [];
var node;
while ((node = walker.nextNode())) {
if (node.nodeValue.trim() === "✅") matches.push(node);
}
matches.forEach(function (textNode) {
textNode.parentNode.replaceChild(createIcon(), textNode);
});
}

fix();
document.addEventListener("DOMContentLoaded", fix);
window.addEventListener("load", fix);

// The Sapper bundle re-renders this section on hydration and on each
// data poll, reinstating the emoji text node — watch for that and
// swap it back to the SVG. This script runs from a <script> tag in
// <nav>, before <main> exists, so observe document.body (already
// present when the tag executes) rather than a more specific
// container that isn't in the DOM yet.
if (window.MutationObserver) {
new MutationObserver(fix).observe(document.body, {
childList: true,
subtree: true,
characterData: true,
});
}
})();
228 changes: 156 additions & 72 deletions client/system-status.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,75 @@
var DAY_MS = 24 * 60 * 60 * 1000;
var MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

var root = document.getElementById("system-status-rows");
var rangeLabel = document.getElementById("system-status-range");
var prevBtn = document.querySelector('.status-nav-btn[data-dir="prev"]');
var nextBtn = document.querySelector('.status-nav-btn[data-dir="next"]');
if (!root) return;
var SPINNER =
'<div class="loading"><svg height="38" stroke="#aaa" width="38" xmlns="http://www.w3.org/2000/svg">' +
'<g fill="none" fill-rule="evenodd"><g stroke-width="2" transform="translate(1 1)">' +
'<circle cx="18" cy="18" r="18" stroke-opacity=".5"></circle>' +
'<path d="M36 18c0-9.94-8.06-18-18-18"><animateTransform attributeName="transform" dur="1s" ' +
'from="0 18 18" repeatCount="indefinite" to="360 18 18" type="rotate"></animateTransform></path>' +
"</g></g></svg></div>";

var SECTION_HTML =
'<section id="system-status" class="system-status" aria-labelledby="system-status-heading">' +
'<div class="f changed"><h2 id="system-status-heading">System status</h2>' +
'<div class="status-nav">' +
'<button type="button" class="status-nav-btn" data-dir="prev" aria-label="Previous period" disabled>‹</button>' +
'<span class="status-range" id="system-status-range">Loading…</span>' +
'<button type="button" class="status-nav-btn" data-dir="next" aria-label="Next period" disabled>›</button>' +
"</div></div>" +
'<div id="system-status-rows" class="status-rows">' +
SPINNER +
"</div></section>";

// Sapper hydrates over the SSR'd DOM and discards any element it
// doesn't recognize as part of its own component tree — so this
// section can't just be static markup in index.html, it has to be
// built and (re-)inserted from script, and watched in case
// hydration tears it out from under us.
function findAnchor() {
return document.querySelector(".f.changed");
}

function buildSection() {
var wrapper = document.createElement("div");
wrapper.innerHTML = SECTION_HTML;
return wrapper.firstElementChild;
}

var refs = null; // { root, rangeLabel, prevBtn, nextBtn }

function ensureSection() {
var existing = document.getElementById("system-status");
if (existing) return existing;

var anchor = findAnchor();
if (!anchor || !anchor.parentNode) return null;

var section = buildSection();
anchor.parentNode.insertBefore(section, anchor);

refs = {
root: section.querySelector("#system-status-rows"),
rangeLabel: section.querySelector("#system-status-range"),
prevBtn: section.querySelector('.status-nav-btn[data-dir="prev"]'),
nextBtn: section.querySelector('.status-nav-btn[data-dir="next"]'),
};
refs.prevBtn.addEventListener("click", onPrev);
refs.nextBtn.addEventListener("click", onNext);

if (state.ready) render();
else if (state.error) showError();

return section;
}

function watchForRemoval() {
var host = document.querySelector("main.container") || document.body;
if (!host || !window.MutationObserver) return;
new MutationObserver(function () {
if (!document.getElementById("system-status")) ensureSection();
}).observe(host, { childList: true });
}

function toDateOnly(d) {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
Expand Down Expand Up @@ -130,82 +194,102 @@
return row;
}

function init(sites, issuesBySlug, startBySlug) {
var state = {
ready: false,
error: false,
sites: null,
issuesBySlug: {},
startBySlug: {},
windowEnd: toDateOnly(new Date()),
};

function render() {
if (!refs || !refs.root) return;
var today = toDateOnly(new Date());
var windowEnd = today;

function render() {
var windowStart = addDays(windowEnd, -(WINDOW_DAYS - 1));
root.innerHTML = "";
sites.forEach(function (site) {
var events = issuesBySlug[site.slug] || [];
var siteStart = startBySlug[site.slug] || null;
root.appendChild(renderRow(site, events, siteStart, windowStart, windowEnd));
});
rangeLabel.textContent = formatRange(windowStart, windowEnd);

var earliestStart = sites.reduce(function (min, site) {
var s = startBySlug[site.slug];
if (!s) return min;
return !min || s < min ? s : min;
}, null);
prevBtn.disabled = !!earliestStart && windowStart <= earliestStart;
nextBtn.disabled = windowEnd >= today;
}
var windowStart = addDays(state.windowEnd, -(WINDOW_DAYS - 1));

prevBtn.addEventListener("click", function () {
windowEnd = addDays(windowEnd, -WINDOW_DAYS);
render();
});
nextBtn.addEventListener("click", function () {
windowEnd = addDays(windowEnd, WINDOW_DAYS);
if (windowEnd > today) windowEnd = today;
render();
refs.root.innerHTML = "";
state.sites.forEach(function (site) {
var events = state.issuesBySlug[site.slug] || [];
var siteStart = state.startBySlug[site.slug] || null;
refs.root.appendChild(renderRow(site, events, siteStart, windowStart, state.windowEnd));
});
refs.rangeLabel.textContent = formatRange(windowStart, state.windowEnd);

var earliestStart = state.sites.reduce(function (min, site) {
var s = state.startBySlug[site.slug];
if (!s) return min;
return !min || s < min ? s : min;
}, null);
refs.prevBtn.disabled = !!earliestStart && windowStart <= earliestStart;
refs.nextBtn.disabled = state.windowEnd >= today;
}

function showError() {
if (!refs || !refs.root) return;
refs.root.innerHTML = "";
refs.root.appendChild(el("p", "status-error", "Status data is unavailable right now."));
refs.rangeLabel.textContent = "";
refs.prevBtn.disabled = true;
refs.nextBtn.disabled = true;
}

function onPrev() {
state.windowEnd = addDays(state.windowEnd, -WINDOW_DAYS);
render();
}

fetchJson(RAW_BASE + "summary.json")
.then(function (sites) {
var startBySlug = {};
var startFetches = sites.map(function (site) {
return fetch(RAW_BASE + site.slug + ".yml")
.then(function (res) {
return res.ok ? res.text() : "";
})
.then(function (text) {
var m = text.match(/startTime:\s*([^\n]+)/);
if (m) startBySlug[site.slug] = toDateOnly(new Date(m[1].trim()));
})
.catch(function () {});
});
function onNext() {
var today = toDateOnly(new Date());
state.windowEnd = addDays(state.windowEnd, WINDOW_DAYS);
if (state.windowEnd > today) state.windowEnd = today;
render();
}

var issuesBySlug = {};
var issuesFetch = fetchJson(ISSUES_URL)
.then(function (issues) {
var events = issues.map(classifyIssue).filter(Boolean);
// Single-monitor setup: attribute all events to every site.
sites.forEach(function (site) {
issuesBySlug[site.slug] = events;
});
})
.catch(function () {
// Rate-limited or offline — fall back to an all-green bar.
sites.forEach(function (site) {
issuesBySlug[site.slug] = [];
});
function loadData() {
fetchJson(RAW_BASE + "summary.json")
.then(function (sites) {
state.sites = sites;

var startFetches = sites.map(function (site) {
return fetch(RAW_BASE + site.slug + ".yml")
.then(function (res) {
return res.ok ? res.text() : "";
})
.then(function (text) {
var m = text.match(/startTime:\s*([^\n]+)/);
if (m) state.startBySlug[site.slug] = toDateOnly(new Date(m[1].trim()));
})
.catch(function () {});
});

return Promise.all(startFetches.concat([issuesFetch])).then(function () {
init(sites, issuesBySlug, startBySlug);
var issuesFetch = fetchJson(ISSUES_URL)
.then(function (issues) {
var events = issues.map(classifyIssue).filter(Boolean);
sites.forEach(function (site) {
state.issuesBySlug[site.slug] = events;
});
})
.catch(function () {
// Rate-limited or offline — fall back to an all-green bar.
sites.forEach(function (site) {
state.issuesBySlug[site.slug] = [];
});
});

return Promise.all(startFetches.concat([issuesFetch]));
})
.then(function () {
state.ready = true;
render();
})
.catch(function () {
state.error = true;
showError();
});
})
.catch(function () {
root.innerHTML = "";
root.appendChild(el("p", "status-error", "Status data is unavailable right now."));
rangeLabel.textContent = "";
prevBtn.disabled = true;
nextBtn.disabled = true;
});
}

ensureSection();
watchForRemoval();
loadData();
})();
49 changes: 43 additions & 6 deletions global.css
Original file line number Diff line number Diff line change
Expand Up @@ -354,14 +354,38 @@ article.up::after {
background: var(--up-border-left-color);
}

/* The top summary banner already carries its own status emoji —
the corner dot is redundant there, so drop it. */
/* The top summary banner carries its own status icon (see
status-icon-fix.js) — the corner dot is redundant there. */
article.up:not(.link)::after,
article.degraded:not(.link)::after,
article.down:not(.link)::after {
display: none;
}

/* Real SVG icon replacing the bundle's hardcoded emoji. The icon
itself is a self-contained outline glyph, colored via
currentColor to match its status. */
.status-banner-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 0.5rem;
vertical-align: -4px;
}

.status-banner-icon.up {
color: var(--up-border-left-color);
}

.status-banner-icon.degraded {
color: var(--degraded-border-left-color);
}

.status-banner-icon.down {
color: var(--down-border-left-color);
}

/* Pulse ring on live/healthy status dots — signals "actively
monitored", not just a static state. Square to match the dot. */
article.up.link::before {
Expand Down Expand Up @@ -515,12 +539,26 @@ article.degraded-active {
.live-status article.svelte-fqsq6s.svelte-fqsq6s.svelte-fqsq6s,
.live-status article.graph.svelte-fqsq6s.svelte-fqsq6s.svelte-fqsq6s {
position: relative;
padding-right: 1.25rem;
padding-right: 200px;
min-height: 96px;
}

/* Sparkline background-image removed — card shows status text only */
.live-status article.graph.svelte-fqsq6s.svelte-fqsq6s.svelte-fqsq6s {
background-image: none !important;
background-image: var(--background);
background-size: auto 48px;
background-repeat: no-repeat;
background-position: right 1.25rem bottom 1rem;
opacity: var(--graph-opacity);
filter: var(--graph-filter);
}

/* The response-time chart on a monitor's detail page is drawn on a
<canvas> with its teal line color baked into the compiled bundle
(not a CSS variable) — hue-shift it to the theme's green instead
of hand-patching the minified JS. Same rotation as the sparkline
above, so both read as the same base green. */
main canvas {
filter: var(--graph-filter);
}

.live-status .icon,
Expand Down Expand Up @@ -653,7 +691,6 @@ article.degraded-active {
min-width: 4px;
height: 28px;
background: var(--up-border-left-color);
opacity: 0.85;
}

.status-cell.degraded {
Expand Down
Loading