g1t/apps/status/src/model.test.ts
| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import { components } from "./components.ts"; |
| 5 | import { |
| 6 | type DayRow, |
| 7 | barFor, |
| 8 | buildPage, |
| 9 | classify, |
| 10 | dayState, |
| 11 | effectiveState, |
| 12 | lastDays, |
| 13 | maintenanceState, |
| 14 | openOn, |
| 15 | percent, |
| 16 | recentIncidents, |
| 17 | summarize, |
| 18 | uptimeOf, |
| 19 | uptimeOver, |
| 20 | } from "./model.ts"; |
| 21 | |
| 22 | const VARS = { |
| 23 | SITE_URL: "https://g1t.sh", |
| 24 | API_URL: "https://api.g1t.sh", |
| 25 | MCP_URL: "https://mcp.g1t.sh", |
| 26 | DOCS_URL: "https://docs.g1t.sh", |
| 27 | PAGES_URL: "https://g1t.page", |
| 28 | MODELS_URL: "https://models.g1t.sh", |
| 29 | PROBE_REPO: "flagon-io/g1t", |
| 30 | }; |
| 31 | |
| 32 | const row = (component: string, day: string, up: number, degraded = 0, down = 0): DayRow => ({ |
| 33 | component, |
| 34 | day, |
| 35 | checks: up + degraded + down, |
| 36 | up, |
| 37 | degraded, |
| 38 | down, |
| 39 | latency_total: (up + degraded) * 100, |
| 40 | latency_count: up + degraded, |
| 41 | }); |
| 42 | |
| 43 | test("classify: fast is up, slow is degraded, failed is down, none is unmonitored", () => { |
| 44 | assert.equal(classify({ ok: true, ms: 84.4 }).state, "up"); |
| 45 | assert.equal(classify({ ok: true, ms: 84.4 }).detail, "Answered in 84 ms"); |
| 46 | assert.equal(classify({ ok: true, ms: 2000 }).state, "degraded"); |
| 47 | assert.deepEqual(classify({ ok: false, ms: 5000, error: "timed out" }), { state: "down", detail: "Failed: timed out" }); |
| 48 | assert.equal(classify(null).state, "unmonitored"); |
| 49 | }); |
| 50 | |
| 51 | test("uptime rounds down, so one failure never reads as 100%", () => { |
| 52 | assert.equal(uptimeOf(1439, 1440), 99.93); |
| 53 | assert.equal(uptimeOf(99999, 100000), 99.99); |
| 54 | assert.equal(uptimeOf(10, 10), 100); |
| 55 | assert.equal(uptimeOf(0, 0), null); |
| 56 | assert.equal(percent(100), "100%"); |
| 57 | assert.equal(percent(99.5), "99.50%"); |
| 58 | assert.equal(percent(null), "No data"); |
| 59 | }); |
| 60 | |
| 61 | test("a day's colour: a blip stays green, a bad hour is degraded, worse is down", () => { |
| 62 | assert.equal(dayState(null), "none"); |
| 63 | assert.equal(dayState({ checks: 1440, up: 1439, degraded: 0, down: 1 }), "up"); |
| 64 | assert.equal(dayState({ checks: 1440, up: 1380, degraded: 0, down: 60 }), "degraded"); |
| 65 | assert.equal(dayState({ checks: 1440, up: 1000, degraded: 0, down: 440 }), "down"); |
| 66 | // Answering, but slow much of the day. |
| 67 | assert.equal(dayState({ checks: 100, up: 60, degraded: 40, down: 0 }), "degraded"); |
| 68 | // An incident colours the day at least as badly as its impact. |
| 69 | assert.equal(dayState({ checks: 1440, up: 1440, degraded: 0, down: 0 }, "down"), "down"); |
| 70 | assert.equal(dayState(null, "degraded"), "degraded"); |
| 71 | }); |
| 72 | |
| 73 | test("lastDays ends today, oldest first", () => { |
| 74 | const days = lastDays(new Date("2026-10-05T12:00:00Z")); |
| 75 | assert.equal(days.length, 90); |
| 76 | assert.equal(days.at(-1), "2026-10-05"); |
| 77 | assert.equal(days[0], "2026-07-08"); |
| 78 | }); |
| 79 | |
| 80 | test("an incident is on every day it was open", () => { |
| 81 | const incident = { started_at: "2026-10-03T23:30:00Z", resolved_at: "2026-10-04T00:10:00Z" }; |
| 82 | assert.equal(openOn(incident, "2026-10-02"), false); |
| 83 | assert.equal(openOn(incident, "2026-10-03"), true); |
| 84 | assert.equal(openOn(incident, "2026-10-04"), true); |
| 85 | assert.equal(openOn(incident, "2026-10-05"), false); |
| 86 | assert.equal(openOn({ started_at: "2026-10-01T00:00:00Z", resolved_at: null }, "2026-10-05"), true); |
| 87 | }); |
| 88 | |
| 89 | test("bars and 90-day uptime come from the daily tally", () => { |
| 90 | const days = ["2026-10-03", "2026-10-04", "2026-10-05"]; |
| 91 | const rows = [row("api", "2026-10-04", 1439, 0, 1), row("api", "2026-10-05", 600), row("git", "2026-10-05", 10)]; |
| 92 | const bar = barFor("api", rows, [], days); |
| 93 | assert.deepEqual( |
| 94 | bar.map((d) => d.state), |
| 95 | ["none", "up", "up"], |
| 96 | ); |
| 97 | assert.equal(bar[1]!.failed, 1); |
| 98 | assert.equal(bar[1]!.avg_ms, 100); |
| 99 | assert.equal(uptimeOver("api", rows), uptimeOf(2039, 2040)); |
| 100 | assert.equal(uptimeOver("docs", rows), null); |
| 101 | }); |
| 102 | |
| 103 | test("a bar marks the days an incident on its part was open", () => { |
| 104 | const incident = { |
| 105 | id: "1", |
| 106 | title: "Clones failing", |
| 107 | impact: "down" as const, |
| 108 | status: "resolved" as const, |
| 109 | components: ["git"], |
| 110 | component_impacts: [{ key: "git", impact: "major_outage" as const }], |
| 111 | started_at: "2026-10-04T10:00:00Z", |
| 112 | resolved_at: "2026-10-04T11:00:00Z", |
| 113 | url: "https://status.g1t.sh/incidents/1", |
| 114 | postmortem_published_at: null, |
| 115 | updates: [], |
| 116 | }; |
| 117 | const days = ["2026-10-04", "2026-10-05"]; |
| 118 | const git = barFor("git", [row("git", "2026-10-04", 1440)], [incident], days); |
| 119 | assert.equal(git[0]!.state, "down"); |
| 120 | assert.deepEqual(git[0]!.incidents, ["Clones failing"]); |
| 121 | assert.equal(barFor("api", [row("api", "2026-10-04", 1440)], [incident], days)[0]!.state, "up"); |
| 122 | }); |
| 123 | |
| 124 | test("summarize: core down is a major outage, others partial, slow is degraded performance", () => { |
| 125 | const core = new Set(["site", "api"]); |
| 126 | const up = { key: "api", name: "API", state: "up" as const }; |
| 127 | assert.equal(summarize([up], core).title, "All systems normal"); |
| 128 | assert.equal(summarize([{ ...up, state: "down" }], core).title, "Major outage"); |
| 129 | assert.equal(summarize([up, { key: "docs", name: "Documentation", state: "down" }], core).title, "Partial outage"); |
| 130 | const slow = summarize([up, { key: "docs", name: "Documentation", state: "degraded" }], core); |
| 131 | assert.equal(slow.title, "Degraded performance"); |
| 132 | assert.match(slow.line, /Documentation/); |
| 133 | assert.equal(summarize([{ key: "sandboxes", name: "Sandboxes", state: "unmonitored" }], core).state, "unknown"); |
| 134 | const incident = summarize([up], core, [{ title: "Pushes failing", impact: "degraded", status: "identified" }]); |
| 135 | assert.equal(incident.title, "Partial outage"); |
| 136 | assert.equal(incident.line, "Identified: Pushes failing."); |
| 137 | }); |
| 138 | |
| 139 | test("recent incidents: open ones and those resolved in 90 days, newest first", () => { |
| 140 | const now = new Date("2026-10-05T00:00:00Z"); |
| 141 | const list = recentIncidents( |
| 142 | [ |
| 143 | { id: "old", started_at: "2026-01-01T00:00:00Z", resolved_at: "2026-01-01T01:00:00Z" }, |
| 144 | { id: "open", started_at: "2026-01-02T00:00:00Z", resolved_at: null }, |
| 145 | { id: "new", started_at: "2026-10-01T00:00:00Z", resolved_at: "2026-10-01T01:00:00Z" }, |
| 146 | ], |
| 147 | now, |
| 148 | ); |
| 149 | assert.deepEqual( |
| 150 | list.map((i) => i.id), |
| 151 | ["new", "open"], |
| 152 | ); |
| 153 | }); |
| 154 | |
| 155 | test("buildPage: parts with no check are unmonitored and have no uptime; staleness is noticed", () => { |
| 156 | const now = new Date("2026-10-05T12:00:00Z"); |
| 157 | const parts = components(VARS, true); |
| 158 | const current = new Map(parts.map((p) => [p.key, { state: "up" as const, detail: "Answered in 80 ms", latency_ms: 80 }])); |
| 159 | const page = buildPage({ |
| 160 | parts, |
| 161 | current, |
| 162 | checkedAt: "2026-10-05T11:59:30Z", |
| 163 | days: [row("api", "2026-10-05", 700)], |
| 164 | incidents: [], |
| 165 | now, |
| 166 | }); |
| 167 | const sandboxes = page.report.components.find((c) => c.key === "sandboxes")!; |
| 168 | assert.equal(sandboxes.state, "unmonitored"); |
| 169 | assert.equal(sandboxes.uptime_90d, null); |
| 170 | assert.equal(page.report.components.find((c) => c.key === "api")!.uptime_90d, 100); |
| 171 | assert.equal(page.report.overall.title, "All systems normal"); |
| 172 | assert.equal(page.stale, false); |
| 173 | assert.equal(page.bars.api!.length, 90); |
| 174 | assert.equal(buildPage({ parts, current, checkedAt: "2026-10-05T11:00:00Z", days: [], incidents: [], now }).stale, true); |
| 175 | }); |
| 176 | |
| 177 | test("components: an installation without a host leaves that part off", () => { |
| 178 | const keys = components({ SITE_URL: "http://localhost:8787", API_URL: "http://localhost:8787/api", PROBE_REPO: "me/demo" }, false).map( |
| 179 | (c) => c.key, |
| 180 | ); |
| 181 | assert.deepEqual(keys, ["site", "api", "git", "speed"]); |
| 182 | const bare = components({ SITE_URL: "http://g1t:8787" }, false); |
| 183 | assert.deepEqual( |
| 184 | bare.map((c) => c.key), |
| 185 | ["site"], |
| 186 | ); |
| 187 | assert.equal(bare[0]!.check.kind === "http" && bare[0]!.check.steps.length, 1); |
| 188 | const all = components(VARS, true); |
| 189 | assert.deepEqual( |
| 190 | all.map((c) => c.key), |
| 191 | ["site", "api", "git", "speed", "mcp", "docs", "deployments", "agents", "sandboxes", "billing"], |
| 192 | ); |
| 193 | const speed = all.find((c) => c.key === "speed")!; |
| 194 | assert.deepEqual(speed.check, { kind: "http", steps: [{ url: "https://g1t.sh/flagon-io/g1t" }, { url: "https://g1t.sh/explore" }] }); |
| 195 | assert.equal(classify({ ok: true, ms: 900 }, speed.slowMs).state, "degraded"); |
| 196 | assert.equal(classify({ ok: true, ms: 300 }, speed.slowMs).state, "up"); |
| 197 | const git = all.find((c) => c.key === "git")!; |
| 198 | assert.deepEqual(git.check, { kind: "http", steps: [{ url: "https://g1t.sh/flagon-io/g1t.git/info/refs?service=git-upload-pack" }] }); |
| 199 | }); |
| 200 | |
| 201 | test("a part's state: the worse of its check and open incidents; maintenance unless an incident says worse", () => { |
| 202 | assert.equal(effectiveState("up", [], false), "up"); |
| 203 | assert.equal(effectiveState("up", ["partial_outage"], false), "partial"); |
| 204 | assert.equal(effectiveState("down", ["degraded"], false), "down", "a failing check beats a milder report"); |
| 205 | assert.equal(effectiveState("degraded", ["major_outage", "degraded"], false), "down", "the worst impact wins"); |
| 206 | assert.equal(effectiveState("up", ["operational"], false), "up"); |
| 207 | assert.equal(effectiveState("unmonitored", ["degraded"], false), "degraded", "an incident speaks for a part with no check"); |
| 208 | assert.equal(effectiveState("down", [], true), "maintenance", "failing checks in a window are expected"); |
| 209 | assert.equal(effectiveState("up", ["major_outage"], true), "down", "an incident during maintenance still shows"); |
| 210 | }); |
| 211 | |
| 212 | test("maintenance moves with its window, and cancelled or completed stays put", () => { |
| 213 | const m = { state: "scheduled" as const, starts_at: "2026-10-05T12:00:00Z", ends_at: "2026-10-05T13:00:00Z" }; |
| 214 | assert.equal(maintenanceState(m, new Date("2026-10-05T11:59:00Z")), "scheduled"); |
| 215 | assert.equal(maintenanceState(m, new Date("2026-10-05T12:00:00Z")), "in_progress"); |
| 216 | assert.equal(maintenanceState(m, new Date("2026-10-05T13:00:00Z")), "completed"); |
| 217 | assert.equal(maintenanceState({ ...m, state: "in_progress" }, new Date("2026-10-05T11:00:00Z")), "in_progress", "started early by hand"); |
| 218 | assert.equal(maintenanceState({ ...m, state: "cancelled" }, new Date("2026-10-05T12:30:00Z")), "cancelled"); |
| 219 | assert.equal(maintenanceState({ ...m, state: "completed" }, new Date("2026-10-05T12:30:00Z")), "completed", "finished early by hand"); |
| 220 | }); |
| 221 | |
| 222 | test("buildPage: incident impacts and maintenance reach the parts and the banner", () => { |
| 223 | const now = new Date("2026-10-05T12:30:00Z"); |
| 224 | const parts = components(VARS, false); |
| 225 | const current = new Map(parts.map((p) => [p.key, { state: "up" as const, detail: "Answered in 80 ms", latency_ms: 80 }])); |
| 226 | current.set("docs", { state: "down", detail: "Failed: HTTP 502", latency_ms: null }); |
| 227 | const maintenance = [ |
| 228 | { id: "m1", title: "Docs move", message: "Moving docs.", components: ["docs"], starts_at: "2026-10-05T12:00:00Z", ends_at: "2026-10-05T13:00:00Z", state: "scheduled" as const, url: "", updates: [] }, |
| 229 | { id: "m2", title: "Later", message: "Later.", components: ["api"], starts_at: "2026-10-06T12:00:00Z", ends_at: "2026-10-06T13:00:00Z", state: "scheduled" as const, url: "", updates: [] }, |
| 230 | { id: "m3", title: "Called off", message: "No.", components: ["api"], starts_at: "2026-10-05T12:00:00Z", ends_at: "2026-10-05T13:00:00Z", state: "cancelled" as const, url: "", updates: [] }, |
| 231 | ]; |
| 232 | const quiet = buildPage({ parts, current, checkedAt: "2026-10-05T12:29:30Z", days: [], incidents: [], maintenance, now }); |
| 233 | const docs = quiet.report.components.find((c) => c.key === "docs")!; |
| 234 | assert.equal(docs.state, "maintenance"); |
| 235 | assert.equal(docs.check_state, "down"); |
| 236 | assert.equal(quiet.report.overall.state, "maintenance"); |
| 237 | assert.equal(quiet.report.overall.title, "Under maintenance"); |
| 238 | assert.deepEqual(quiet.report.maintenance.map((m) => [m.id, m.state]), [["m1", "in_progress"], ["m2", "scheduled"]]); |
| 239 | |
| 240 | const incident = { |
| 241 | id: "i1", |
| 242 | title: "API errors", |
| 243 | impact: "degraded" as const, |
| 244 | status: "identified" as const, |
| 245 | components: ["api"], |
| 246 | component_impacts: [{ key: "api", impact: "partial_outage" as const }], |
| 247 | started_at: "2026-10-05T12:10:00Z", |
| 248 | resolved_at: null, |
| 249 | url: "", |
| 250 | postmortem_published_at: null, |
| 251 | updates: [], |
| 252 | }; |
| 253 | const busy = buildPage({ parts, current, checkedAt: "2026-10-05T12:29:30Z", days: [], incidents: [incident], maintenance, now }); |
| 254 | assert.equal(busy.report.components.find((c) => c.key === "api")!.state, "partial"); |
| 255 | assert.equal(busy.report.overall.title, "Partial outage"); |
| 256 | assert.equal(busy.report.overall.line, "Identified: API errors."); |
| 257 | const resolved = buildPage({ parts, current, checkedAt: "2026-10-05T12:29:30Z", days: [], incidents: [{ ...incident, resolved_at: "2026-10-05T12:20:00Z" }], now }); |
| 258 | assert.equal(resolved.report.components.find((c) => c.key === "api")!.state, "up", "a resolved incident no longer colours its part"); |
| 259 | }); |