Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit7f0c2bd

fix(status): public page showed 0.0% uptime after every deploy

fix(status): public page showed 0.0% uptime after every deploy

The 24h uptime figure was `(uptimeMs / WINDOW_MS) * 100` — the age of the
current Bun worker as a fraction of 24 hours. A deploy is not an outage, so
every deploy reset the public status page to a near-zero number.

Observed live, minutes after a routine deploy:

  <div class="status-hero-stat-num">0.0%</div>
  <div class="status-hero-stat-label">24h uptime</div>

sitting directly beside "All systems operational" and a service table where
every row read 100.0%. With the 60s auto-deploy timer this was the page's
normal state rather than an edge case — anyone who checked status during a
deploy saw 0% uptime on a completely healthy system.

Derive it from the incidents table instead, which carries real
startedAt/resolvedAt timestamps. Spans are clamped to the window and merged
before summing, so overlapping incidents cannot double-count and drive the
result below zero; an unresolved incident counts as ongoing up to now.

Extracted as uptimePctFromIncidents() with an injectable `now` so the
behaviour is unit-testable without wall-clock dependence — covering window
clamping, span merging, unresolved incidents, and malformed rows (an
unparseable date or a resolvedAt before its startedAt must not yield NaN).

The separate "process uptime" stat elsewhere on the page still uses uptimeMs,
which is honest for what it claims to be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ccantynz-alt committed on July 27, 2026Parent: 6088927
2 files changed+19897f0c2bdd49748bca81f895f4b285b726612615ad
2 changed files+198−9
Addedsrc/__tests__/status-uptime.test.ts+127−0View fileUnifiedSplit
1/**
2 * Public /status 24h uptime.
3 *
4 * The figure was `(uptimeMs / WINDOW_MS) * 100` — the age of the current Bun
5 * worker as a fraction of 24 hours. A deploy is not an outage, so every deploy
6 * reset the public status page to a near-zero number. Observed live on
7 * 2026-07-28, minutes after a routine deploy:
8 *
9 * <div class="status-hero-stat-num">0.0%</div>
10 * <div class="status-hero-stat-label">24h uptime</div>
11 *
12 * directly beside "All systems operational" and a service table where every
13 * row read 100.0%. With the 60s auto-deploy timer this was the page's normal
14 * state, not an edge case — anyone checking the status page during a deploy
15 * saw 0% uptime on a healthy system.
16 *
17 * It now derives from recorded incidents.
18 */
19
20import { describe, expect, it } from "bun:test";
21import { uptimePctFromIncidents, UPTIME_WINDOW_MS } from "../routes/status";
22
23const NOW = new Date("2026-07-28T12:00:00.000Z").getTime();
24const HOUR = 3_600_000;
25
26describe("no incidents means full uptime", () => {
27 it("reports 100 with an empty incident list", () => {
28 // The core regression: a freshly-restarted process with a clean incident
29 // log must read 100%, not "how long have I been alive".
30 expect(uptimePctFromIncidents([], NOW)).toBe(100);
31 });
32
33 it("ignores incidents that ended before the window", () => {
34 const old = [
35 {
36 startedAt: new Date(NOW - 40 * HOUR),
37 resolvedAt: new Date(NOW - 30 * HOUR),
38 },
39 ];
40 expect(uptimePctFromIncidents(old, NOW)).toBe(100);
41 });
42});
43
44describe("downtime is subtracted accurately", () => {
45 it("a 1h resolved incident costs 1/24th", () => {
46 const rows = [
47 { startedAt: new Date(NOW - 5 * HOUR), resolvedAt: new Date(NOW - 4 * HOUR) },
48 ];
49 const pct = uptimePctFromIncidents(rows, NOW);
50 expect(pct).toBeCloseTo((23 / 24) * 100, 5);
51 });
52
53 it("an unresolved incident counts as ongoing up to now", () => {
54 const rows = [{ startedAt: new Date(NOW - 2 * HOUR), resolvedAt: null }];
55 expect(uptimePctFromIncidents(rows, NOW)).toBeCloseTo((22 / 24) * 100, 5);
56 });
57
58 it("clamps an incident that began before the window to the window edge", () => {
59 // Started 30h ago, resolved 20h ago → only 4h falls inside the window.
60 const rows = [
61 { startedAt: new Date(NOW - 30 * HOUR), resolvedAt: new Date(NOW - 20 * HOUR) },
62 ];
63 expect(uptimePctFromIncidents(rows, NOW)).toBeCloseTo((20 / 24) * 100, 5);
64 });
65});
66
67describe("overlapping incidents are merged, not double-counted", () => {
68 it("two fully overlapping incidents count once", () => {
69 const rows = [
70 { startedAt: new Date(NOW - 6 * HOUR), resolvedAt: new Date(NOW - 4 * HOUR) },
71 { startedAt: new Date(NOW - 5 * HOUR), resolvedAt: new Date(NOW - 4 * HOUR) },
72 ];
73 // 2h of real downtime, not 3h.
74 expect(uptimePctFromIncidents(rows, NOW)).toBeCloseTo((22 / 24) * 100, 5);
75 });
76
77 it("partially overlapping incidents merge into one span", () => {
78 const rows = [
79 { startedAt: new Date(NOW - 6 * HOUR), resolvedAt: new Date(NOW - 4 * HOUR) },
80 { startedAt: new Date(NOW - 5 * HOUR), resolvedAt: new Date(NOW - 3 * HOUR) },
81 ];
82 // Union is 6h ago → 3h ago = 3h.
83 expect(uptimePctFromIncidents(rows, NOW)).toBeCloseTo((21 / 24) * 100, 5);
84 });
85
86 it("never goes below 0 even if incidents blanket the window many times over", () => {
87 const rows = Array.from({ length: 10 }, () => ({
88 startedAt: new Date(NOW - 48 * HOUR),
89 resolvedAt: null,
90 }));
91 expect(uptimePctFromIncidents(rows, NOW)).toBe(0);
92 });
93
94 it("is order-independent", () => {
95 const a = { startedAt: new Date(NOW - 3 * HOUR), resolvedAt: new Date(NOW - 2 * HOUR) };
96 const b = { startedAt: new Date(NOW - 9 * HOUR), resolvedAt: new Date(NOW - 8 * HOUR) };
97 expect(uptimePctFromIncidents([a, b], NOW)).toBeCloseTo(
98 uptimePctFromIncidents([b, a], NOW),
99 10
100 );
101 });
102});
103
104describe("malformed rows do not corrupt the figure", () => {
105 it("skips unparseable timestamps rather than producing NaN", () => {
106 const rows = [
107 { startedAt: "not-a-date", resolvedAt: null },
108 { startedAt: new Date(NOW - 2 * HOUR), resolvedAt: new Date(NOW - 1 * HOUR) },
109 ];
110 const pct = uptimePctFromIncidents(rows, NOW);
111 expect(Number.isFinite(pct)).toBe(true);
112 expect(pct).toBeCloseTo((23 / 24) * 100, 5);
113 });
114
115 it("skips a resolvedAt earlier than its startedAt", () => {
116 const rows = [
117 { startedAt: new Date(NOW - 1 * HOUR), resolvedAt: new Date(NOW - 5 * HOUR) },
118 ];
119 expect(uptimePctFromIncidents(rows, NOW)).toBe(100);
120 });
121});
122
123describe("window constant", () => {
124 it("is 24 hours", () => {
125 expect(UPTIME_WINDOW_MS).toBe(24 * 60 * 60 * 1000);
126 });
127});
Modifiedsrc/routes/status.tsx+71−9View fileUnifiedSplit
2929import { config } from "../lib/config";
3030
3131const status = new Hono<AuthEnv>();
32
33/** Rolling window the public status page reports uptime over. */
34export const UPTIME_WINDOW_MS = 24 * 60 * 60 * 1000;
35
36/**
37 * 24h uptime percentage, derived from recorded incidents.
38 *
39 * Replaces `(uptimeMs / WINDOW_MS) * 100` — the age of the current Bun worker
40 * as a fraction of 24h. A deploy is not an outage, so every deploy reset the
41 * public page to a near-zero figure: right after a restart the hero read
42 * "0.0% — 24h uptime" beside "All systems operational" and a service table of
43 * 100.0%s. With a 60s auto-deploy timer that was the page's normal state.
44 *
45 * Incident spans are clamped to the window and merged before summing, so
46 * overlapping incidents cannot double-count and push the result below zero.
47 * An unresolved incident counts as ongoing up to `now`.
48 *
49 * Exported for tests; `now` is injectable so they need no wall-clock.
50 */
51export function uptimePctFromIncidents(
52 rows: Array<{ startedAt: Date | string; resolvedAt: Date | string | null }>,
53 now: number = Date.now()
54): number {
55 const windowStart = now - UPTIME_WINDOW_MS;
56
57 const spans = rows
58 .map((i) => {
59 const rawStart = new Date(i.startedAt).getTime();
60 const rawEnd = i.resolvedAt ? new Date(i.resolvedAt).getTime() : now;
61 return {
62 start: Math.max(rawStart, windowStart),
63 end: Math.min(rawEnd, now),
64 };
65 })
66 .filter(
67 (s) =>
68 Number.isFinite(s.start) && Number.isFinite(s.end) && s.end > s.start
69 )
70 .sort((a, b) => a.start - b.start);
71
72 let downtimeMs = 0;
73 let cursor = -1;
74 for (const s of spans) {
75 const from = Math.max(s.start, cursor);
76 if (s.end > from) {
77 downtimeMs += s.end - from;
78 cursor = s.end;
79 }
80 }
81
82 return Math.max(
83 0,
84 Math.min(100, ((UPTIME_WINDOW_MS - downtimeMs) / UPTIME_WINDOW_MS) * 100)
85 );
86}
3287status.use("*", softAuth);
3388
3489const started = Date.now();
261316 recentIncidentRows.length === 0 &&
262317 incidentHistory.filter((i) => i.status !== "resolved").length === 0;
263318
264 // Aggregate uptime — process uptime over the last 24h window, capped
265 // at 100%. We don't track historical downtime in-process, so this is a
266 // best-effort number based on how long this Bun worker has been alive
267 // versus the rolling 24h window.
268 const WINDOW_MS = 24 * 60 * 60 * 1000;
269 const aggregatePct =
270 overallOk
271 ? Math.min(100, (uptimeMs / WINDOW_MS) * 100)
272 : Math.max(0, 100 - (recentIncidentRows.length * 100) / 24);
319 // Aggregate 24h uptime, derived from RECORDED INCIDENTS.
320 //
321 // This used to be `(uptimeMs / WINDOW_MS) * 100` — the age of the current
322 // Bun worker as a fraction of 24h. A deploy is not an outage, so every
323 // deploy reset the public status page to a near-zero number: immediately
324 // after a restart the hero read "0.0% — 24h uptime" directly beside "All
325 // systems operational" and a table of services each showing 100.0%. With
326 // the 60s auto-deploy timer this was the normal state of the page, not an
327 // edge case.
328 //
329 // Downtime now comes from the incidents table, which carries real
330 // startedAt/resolvedAt timestamps. Intervals are clamped to the window and
331 // merged before summing, so two overlapping incidents cannot double-count
332 // and drive the figure below zero. An unresolved incident counts as ongoing
333 // up to now.
334 const aggregatePct = uptimePctFromIncidents(incidentHistory);
273335 const aggregateStr = aggregatePct >= 100 ? "100.0" : aggregatePct.toFixed(1);
274336
275337 // Per-service status descriptors — kept here so the JSX is just markup.
276338