feat(site): static status page (closes #2 — page renderer)

Single-page status dashboard for Molecules AI services. Pure static
HTML+CSS+JS — zero build step, zero dependencies. Reads probe results
directly from public Gitea raw URLs at runtime.

Files:
- site/index.html: structure + embedded CSS (light/dark via prefers
  -color-scheme; ~110 lines styling)
- site/app.js: fetches .upptimerc.yml + per-site history JSONL,
  renders rows + summary + 24h-history sparkline, auto-refreshes
  every 5 min (matches probe cadence)
- site/vercel.json: static-site config + security headers

Why no framework
- Page must load fast and never lie. React/Vue would be cargo-cult
  at this scale (3 visible elements, 1 data source).
- Plain DOM + fetch removes the supply-chain surface a JS framework
  drags in. Zero npm deps, zero lockfile, zero CI build.

Slugify rule mirrors the probe binary's slugify() in
cmd/probe/main.go — both must agree on the file naming for
history/<slug>.jsonl to round-trip cleanly.

Out of scope (separate PRs / follow-ups)
- Vercel project configuration + deploy (next commit)
- Custom domain status.moleculesai.app
- Historical data migration from old upptime JSON format
- Alerting / RSS / status-as-API endpoints
This commit is contained in:
claude-ceo-assistant 2026-05-08 01:20:55 +00:00
parent 08066d3d67
commit 2371a319bb
3 changed files with 403 additions and 0 deletions

245
site/app.js Normal file
View File

@ -0,0 +1,245 @@
// status.moleculesai.app — read-only status page for Molecules AI services.
//
// Pulls the probe-list config + per-site history JSONL from the
// molecule-ai-status repo on Gitea, renders a one-row-per-service
// dashboard with current state + a 24h-history sparkline.
//
// Why no framework: this page is plain DOM + fetch. Zero build step,
// zero dependencies, zero supply-chain surface. The thing it MUST do
// well is "load fast, show correct status, never lie." React/Vue
// would be cargo-culting at this scale.
//
// Data source: public Gitea raw URLs. No auth. Repo is public.
const REPO_BASE =
"https://git.moleculesai.app/molecule-ai/molecule-ai-status/raw/branch/main";
const HISTORY_URL = (slug) => `${REPO_BASE}/history/${slug}.jsonl`;
const CONFIG_URL = `${REPO_BASE}/.upptimerc.yml`;
// Window of history we render in the sparkline (24h of probes at one
// per 5 minutes ≈ 288). Cap to keep the DOM bounded if a site has
// been probing for years.
const SPARKLINE_LIMIT = 288;
// Slugify must match the probe binary's slugify() in cmd/probe/main.go
// — the page reads files the probe writes, so the slugging rule is
// load-bearing. Mirror in tests if/when this gets a follow-up.
function slugify(s) {
let out = "";
let last = "-";
for (const c of s.toLowerCase()) {
const isAlnum = (c >= "a" && c <= "z") || (c >= "0" && c <= "9");
if (isAlnum) {
out += c;
last = c;
} else if (last !== "-") {
out += "-";
last = "-";
}
}
return out.replace(/^-+|-+$/g, "");
}
// Minimal YAML parser for the subset of .upptimerc.yml we read:
// only the `sites:` list of `{name, url}`. Anything more elaborate
// (anchors, multiline strings, etc.) is overkill — the upstream
// upptime config schema is intentionally simple.
function parseSites(yamlText) {
const sites = [];
let inSites = false;
let current = null;
for (const rawLine of yamlText.split("\n")) {
const line = rawLine.replace(/\r$/, "");
if (line.startsWith("#")) continue;
if (/^\s*$/.test(line)) continue;
if (/^sites:\s*$/.test(line)) {
inSites = true;
continue;
}
if (inSites && /^[a-zA-Z]/.test(line)) {
// hit a top-level key after sites: — bail
inSites = false;
}
if (!inSites) continue;
const itemStart = line.match(/^\s*-\s+name:\s*(.+)$/);
if (itemStart) {
if (current) sites.push(current);
current = { name: itemStart[1].trim().replace(/^["']|["']$/g, "") };
continue;
}
const urlMatch = line.match(/^\s+url:\s*(.+)$/);
if (urlMatch && current) {
current.url = urlMatch[1].trim().replace(/^["']|["']$/g, "");
}
}
if (current) sites.push(current);
return sites.filter((s) => s.name && s.url);
}
// Parse a JSONL response into an array of Result objects. Tolerant of
// trailing newlines + (rarely) blank lines from a partial-write race.
function parseJSONL(text) {
const out = [];
for (const line of text.split("\n")) {
if (!line.trim()) continue;
try {
out.push(JSON.parse(line));
} catch {
// skip malformed line — better than the whole page erroring
}
}
return out;
}
// Best-effort fetch — returns null on failure (no exceptions).
async function fetchText(url) {
try {
const resp = await fetch(url, { cache: "no-cache" });
if (!resp.ok) return null;
return await resp.text();
} catch {
return null;
}
}
// Render a row for one site given its latest results.
function renderRow(site, results) {
const last = results[results.length - 1];
const status = !last
? "unknown"
: last.success
? "up"
: "down";
const latency = last && last.success ? `${last.latency_ms} ms` : "—";
// Sparkline: last SPARKLINE_LIMIT entries, one bar per. Bar height
// proportional to latency (clamped). Failing checks render red and
// taller (so eye is drawn to outages).
const recent = results.slice(-SPARKLINE_LIMIT);
const maxLat = Math.max(50, ...recent.filter((r) => r.success).map((r) => r.latency_ms));
const spark = recent
.map((r) => {
const cls = r.success ? "" : "fail";
const h = !r.success ? 20 : Math.max(2, Math.round((r.latency_ms / maxLat) * 18));
return `<span class="${cls}" style="height:${h}px" title="${r.timestamp} · ${r.success ? r.latency_ms + "ms" : "FAIL: " + (r.error || "")}"></span>`;
})
.join("");
return `
<div class="row" data-status="${status}">
<div class="dot ${status}" title="${status}"></div>
<div class="row-name">
<a href="${site.url}" target="_blank" rel="noopener noreferrer">${escape(site.name)}</a>
<span class="url">${escape(site.url)}</span>
</div>
<div class="row-spark" title="last ${recent.length} checks (newest right)">${spark}</div>
<div class="row-latency">${latency}</div>
</div>
`;
}
function escape(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[c]);
}
function renderSummary(rows) {
const total = rows.length;
const up = rows.filter((r) => r.status === "up").length;
const down = rows.filter((r) => r.status === "down").length;
const unknown = rows.filter((r) => r.status === "unknown").length;
let dot, text, sub;
if (total === 0) {
dot = "var(--ink-soft)";
text = "No services configured";
sub = "Add `.upptimerc.yml` entries.";
} else if (down === 0 && unknown === 0) {
dot = "var(--green)";
text = "All systems operational";
sub = `${up} of ${total} services responding normally.`;
} else if (down === 0) {
dot = "var(--amber)";
text = "Status partially unknown";
sub = `${up} up · ${unknown} no recent data.`;
} else if (up === 0) {
dot = "var(--red)";
text = "Major outage";
sub = `${down} services failing.`;
} else {
dot = "var(--amber)";
text = "Partial outage";
sub = `${up} up · ${down} down · ${unknown} unknown.`;
}
return `
<div class="summary-dot" style="background:${dot}"></div>
<div class="summary-text">
<strong>${text}</strong>
<small>${sub}</small>
</div>
`;
}
async function load() {
// 1. Fetch + parse the probe-list config.
const yaml = await fetchText(CONFIG_URL);
if (!yaml) {
document.getElementById("grid").innerHTML =
`<div class="empty">Failed to load probe-list config from Gitea. Check that <code>${CONFIG_URL}</code> is reachable.</div>`;
document.getElementById("updated").textContent = "load failed";
return;
}
const sites = parseSites(yaml);
if (sites.length === 0) {
document.getElementById("grid").innerHTML =
`<div class="empty">No sites declared in <code>.upptimerc.yml</code>.</div>`;
return;
}
// 2. For each site, fetch its history JSONL in parallel.
const enriched = await Promise.all(
sites.map(async (site) => {
const slug = slugify(site.name);
const text = await fetchText(HISTORY_URL(slug));
const results = text ? parseJSONL(text) : [];
return { site, slug, results };
})
);
// 3. Render rows + summary.
const rowSummaries = enriched.map(({ site, results }) => {
const last = results[results.length - 1];
return {
status: !last ? "unknown" : last.success ? "up" : "down",
};
});
document.getElementById("summary").innerHTML = renderSummary(rowSummaries);
document.getElementById("grid").innerHTML = enriched
.map(({ site, results }) => renderRow(site, results))
.join("");
// Updated-at timestamp: latest probe across all sites.
const allTimestamps = enriched
.flatMap(({ results }) => results)
.map((r) => r.timestamp)
.filter(Boolean);
if (allTimestamps.length > 0) {
const latest = allTimestamps.sort().pop();
const ago = Math.round((Date.now() - new Date(latest).getTime()) / 60000);
document.getElementById("updated").innerHTML =
`last probe ${ago} min ago · <a href="${REPO_BASE}/history">history</a>`;
} else {
document.getElementById("updated").textContent = "no probe data yet";
}
}
load();
// Auto-refresh every 5 min — matches the probe cadence so the page
// catches up with new history without a hard reload.
setInterval(load, 5 * 60 * 1000);

137
site/index.html Normal file
View File

@ -0,0 +1,137 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Live status for Molecules AI services. Probes refresh every 5 minutes.">
<title>Molecules AI · Status</title>
<style>
:root {
color-scheme: light dark;
--bg: #0a0a0a;
--card: #141414;
--line: #2a2a2a;
--ink: #e5e5e5;
--ink-soft: #999;
--green: #34d399;
--amber: #fbbf24;
--red: #f87171;
--blue: #60a5fa;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
line-height: 1.5;
}
.wrap { max-width: 980px; margin: 0 auto; padding: 32px 24px; }
header {
display: flex; align-items: baseline; justify-content: space-between;
margin-bottom: 32px; padding-bottom: 20px; border-bottom: 1px solid var(--line);
}
h1 { font-size: 22px; margin: 0; font-weight: 600; }
.meta { font-size: 13px; color: var(--ink-soft); }
.meta a { color: var(--blue); text-decoration: none; }
.meta a:hover { text-decoration: underline; }
.summary {
background: var(--card); border: 1px solid var(--line);
border-radius: 12px; padding: 24px; margin-bottom: 24px;
display: flex; align-items: center; gap: 20px;
}
.summary-dot {
width: 18px; height: 18px; border-radius: 50%;
flex-shrink: 0;
}
.summary-text strong { font-size: 18px; display: block; margin-bottom: 2px; }
.summary-text small { color: var(--ink-soft); font-size: 13px; }
.grid {
display: grid; gap: 12px;
}
.row {
background: var(--card); border: 1px solid var(--line);
border-radius: 10px; padding: 16px 20px;
display: grid;
grid-template-columns: 28px 1fr auto auto;
align-items: center; gap: 16px;
}
.dot {
width: 12px; height: 12px; border-radius: 50%;
box-shadow: 0 0 8px currentColor;
}
.dot.up { background: var(--green); color: var(--green); }
.dot.down { background: var(--red); color: var(--red); }
.dot.unknown { background: var(--ink-soft); color: var(--ink-soft); box-shadow: none; }
.row-name { font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row-name a { color: var(--ink); text-decoration: none; }
.row-name a:hover { color: var(--blue); }
.row-name .url { display: block; font-size: 11px; color: var(--ink-soft); font-family: ui-monospace, 'SF Mono', monospace; }
.row-latency {
font-size: 13px; color: var(--ink-soft); font-variant-numeric: tabular-nums;
text-align: right; min-width: 70px;
}
.row-spark {
display: flex; gap: 2px; align-items: flex-end; height: 20px;
}
.row-spark span {
width: 3px; background: var(--green); display: block;
border-radius: 1px; opacity: 0.85;
}
.row-spark span.fail { background: var(--red); }
footer {
margin-top: 40px; padding-top: 20px; border-top: 1px solid var(--line);
font-size: 12px; color: var(--ink-soft); text-align: center;
}
footer a { color: var(--ink-soft); text-decoration: underline; }
.empty {
text-align: center; padding: 48px 24px; color: var(--ink-soft);
}
.skel {
height: 60px; background: var(--card); border: 1px solid var(--line);
border-radius: 10px; margin-bottom: 12px;
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 0.9; } }
@media (prefers-color-scheme: light) {
:root {
--bg: #fafafa; --card: #fff; --line: #e5e5e5; --ink: #1a1a1a; --ink-soft: #666;
}
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Molecules AI · Status</h1>
<div class="meta" id="updated">checking…</div>
</header>
<div class="summary" id="summary">
<div class="summary-dot" style="background:var(--ink-soft)"></div>
<div class="summary-text">
<strong>Loading current status…</strong>
<small>Fetching latest probe results.</small>
</div>
</div>
<div class="grid" id="grid">
<div class="skel"></div><div class="skel"></div><div class="skel"></div>
</div>
<footer>
Probes run every 5 minutes via Gitea Actions cron.
Source: <a href="https://git.moleculesai.app/molecule-ai/molecule-ai-status">molecule-ai/molecule-ai-status</a> ·
Probe binary: <a href="https://git.moleculesai.app/molecule-ai/molecule-ai-uptime-probe">molecule-ai-uptime-probe</a>
</footer>
</div>
<script src="./app.js"></script>
</body>
</html>

21
site/vercel.json Normal file
View File

@ -0,0 +1,21 @@
{
"version": 2,
"name": "molecule-ai-status",
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }
]
},
{
"source": "/(index.html|app.js)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=60, s-maxage=60" }
]
}
]
}