A status page that can say “gave up” A static export in Bun and TypeScript
Read the supervisor's log, decide what the page is allowed to claim, and build plain HTML ahead of time. Green has to be earned.
F-SWE · A · arc: self-healing-edge · langs: ts, gleam · objectives: none mapped
By Evan Eugene Pincham.
Concept
A service that restarts itself leaves a trail. In the companion lesson "Liveness, readiness, and a bounded supervisor in Rust", a small HTTP service called edge-svc runs under a supervisor. The supervisor restarts it when it dies, stops after 3 restarts, and writes one JSON line for every action it takes. In a second companion lesson, "Restart policy as an operator decision", that policy moved into a Nickel config file and the log was queried with Nushell.
This lesson turns the same log into a status page. Someone who did not watch the terminal can open the page and learn what happened. The page is an operator surface, and what the log tells the operator becomes what the page tells them.
A status page is a claim. Anyone who reads it will act on what it says, so each state it can show is a statement you must be able to defend. Four ideas follow.
- The most common lie is green by default. A page that starts from "healthy" and only turns red when it finds a reason is wrong whenever it fails to find the reason. Reasons go missing when a file is truncated, a line is garbled, or a log is empty. The page should start from "I do not know" and earn each stronger claim from evidence.
- Gave up wins over everything else. When the supervisor stops restarting, it is saying that automatic recovery has failed and a person is needed. That is the most important thing the log can say. The page must show it first, and it must name how many restarts happened and when the last event was, so the reader can judge how stale the failure is.
- A log the page cannot read is Unknown, never Healthy. "I could not read it" and "nothing is wrong" are different statements. Only one of them is a claim you can back up.
- For this page, a static export is the right shape. The page has no accounts and no data entry, and its content is a function of one file. A static export means the HTML is built ahead of time, with no server running and no JavaScript in the browser. There is no backend because a backend would add a process that can itself fail, and the status page is the thing you want to keep working when other things are failing. Plain files can be served by any web server or copied anywhere. The cost is that the page is only as fresh as its last build. You rebuild when the log changes, and the page prints the time of the last event so a stale page looks stale.
The Site Reliability Engineering book (chapter 6) says monitoring should answer what is broken and, where it can, why, and it advises keeping the system simple. Its distinction between symptoms and causes applies here. "Gave up" is the symptom the page shows. The nearest cause, exit status: 70, stays in the log's detail field, where the person who opens the log after reading the page will find it. Chapter 22 explains why the supervisor stops at all. Unbounded retries can turn a local failure into a wider one, so a limit is a safety feature. Joe Armstrong's thesis describes the same escalation pattern: a supervisor that exceeds its restart limit stops and reports upward. Here the level above the supervisor is a human, and the page is how the report reaches them. Newman's Building Microservices (chapter 10) separates fixed-question monitoring from observability, where you keep raw events and can ask new questions later. The page asks one fixed question, and the log keeps the raw events behind it.
A note for later: the JSON log has a closed vocabulary of events and a timestamp on every line, so it is already structured data. If a future lesson ever labels runs as "recovered" or "gave up", this log is the material to label. Nothing in this lesson uses machine learning.
Worked example
The project has four source files and a folder of fixture logs. Start with package.json:
{
"name": "edge-svc-status",
"private": true,
"type": "module",
"scripts": { "build": "bun build.ts" }
}
You will type bun run build. That command runs the script named build in package.json, and the script is bun build.ts. Here build.ts is the name of a file, so Bun simply runs it as a program. This is different from Bun's bundler, which you call as bun build <entry> --outdir ... to combine client code into files for a browser. The two look alike because Bun uses the word "build" for both. This lab does not use the bundler, because there is no client script to bundle.
Bun runs build.ts as an ES module, which is what allows its import lines and the top-level await inside the loop. Bun runs TypeScript directly by removing the types, and it does not type-check. If you want type checking, run the TypeScript compiler (tsc --noEmit) yourself.
Deciding what the page may claim
// Reads the supervisor's JSON event log and decides what the page may claim.
export type Event = { ts: number; event: string; detail: string };
export type State = "healthy" | "restarted" | "gave_up" | "unknown";
export type Summary = { state: State; restarts: number; lastTs: number | null; note: string };
const KNOWN = new Set(["started", "exited", "restarting", "unhealthy", "gave_up"]);
export function parse(text: string): { events: Event[]; bad: number } {
const events: Event[] = [];
let bad = 0;
for (const line of text.split("\n")) {
if (line.trim() === "") continue;
try {
const e = JSON.parse(line);
if (typeof e.ts === "number" && typeof e.event === "string" && KNOWN.has(e.event)) events.push(e);
else bad++;
} catch {
bad++;
}
}
return { events, bad };
}
export function summarize(text: string): Summary {
const { events, bad } = parse(text);
const restarts = events.filter((e) => e.event === "restarting").length;
const last = events.at(-1) ?? null;
const lastTs = last ? last.ts : null;
if (events.some((e) => e.event === "gave_up")) {
return { state: "gave_up", restarts, lastTs, note: `Gave up after ${restarts} restarts. A human needs to look.` };
}
if (bad > 0 || last === null || last.event !== "started") {
return { state: "unknown", restarts, lastTs, note: `Cannot tell: ${bad} unreadable line(s), last event ${last?.event ?? "none"}.` };
}
if (restarts > 0) {
return { state: "restarted", restarts, lastTs, note: `Running again after ${restarts} restart(s).` };
}
return { state: "healthy", restarts, lastTs, note: "Running. No restarts in this log." };
}
Read parse first. It never trusts a line. JSON.parse throws a SyntaxError on text that is not valid JSON, so the plain-text line the shell appends ("supervisor exit status: 1") lands in the catch and is counted as bad. A line can also be valid JSON and still wrong. The JSON value null parses fine, but reading e.ts on it throws a TypeError, and that also happens inside the try, so it is counted as bad too. A line with an unknown event name is counted as bad as well. A newer supervisor might add events this page does not understand, and the page should not pretend it does.
Now summarize. Restarts are the count of restarting events. events.at(-1) takes the last parsed event, since Array.prototype.at accepts a negative index, and ?? null covers an empty log. The decision is a chain of if statements in a deliberate order:
- Gave up first. If any
gave_upevent exists, that is the answer, whatever else the file contains. The gave-up fixture ends with the shell's non-JSON line, so itsbadcount is 1. If the Unknown check came first, that line would turn the most serious state into the least informative one. Unreadable lines can hide facts, but they cannot un-saygave_up. The supervisor also exits after giving up, so no later event can contradict it. - Unknown next. Any unreadable line, an empty log, or a last event that is not
startedmeans the page cannot say the service is running. A log that ends inexitedorrestartingmeans a restart is in progress or has stalled, and the log does not say which. - Restarted. The last event is
startedand there was at least one restart. The service recovered, and the page says so rather than hiding the history. - Healthy last. The page reaches this claim only after every other case is excluded.
Building the pages
// Static export: one HTML page per fixture log, written to dist/. No server, no client JS.
import { readdirSync } from "node:fs";
import { summarize, type State } from "./status.ts";
const LABEL: Record<State, string> = {
healthy: "Healthy",
restarted: "Restarted",
gave_up: "Gave up",
unknown: "Unknown",
};
const esc = (s: string) =>
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
for (const file of readdirSync("fixtures").filter((f) => f.endsWith(".log")).sort()) {
const s = summarize(await Bun.file(`fixtures/${file}`).text());
const when = s.lastTs === null ? "no events" : new Date(s.lastTs * 1000).toISOString().replace(".000Z", "Z");
const html = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>edge-svc: ${LABEL[s.state]}</title>
<link rel="stylesheet" href="status.css"></head>
<body><main class="status state-${s.state}" data-state="${s.state}">
<h1>edge-svc: ${LABEL[s.state]}</h1>
<p>${esc(s.note)}</p>
<p>Restarts: <span data-restarts>${s.restarts}</span>. Last event: <time>${when}</time>.</p>
</main></body></html>
`;
await Bun.write(`dist/${file.replace(/\.log$/, ".html")}`, html);
console.log(`${file} -> dist/${file.replace(/\.log$/, ".html")} state=${s.state} restarts=${s.restarts}`);
}
await Bun.write("dist/status.css", await Bun.file("status.css").text());
Bun's file API covers the reading and writing. Bun.file(path).text() reads a file as a string, and Bun.write(path, data) writes one. Bun.write creates dist/ if it is missing: the checker deletes dist/ before every build, and the build still writes all four pages. The only import is readdirSync from Node's file module, which Bun supports, to list the fixtures. There is no framework, no template library and no dependency to install.
The page is a template string. The timestamp is a Unix time in seconds, so it is multiplied by 1000 for Date, and toISOString() gives text like 2026-11-01T15:03:18.000Z. The .replace(".000Z", "Z") trims the milliseconds the log never had. The esc function escapes the note text. The note is built from a fixed set of event names and numbers, so it is safe today, but escaping at the point where text enters HTML is a habit that prevents injection if someone later puts log text into the page.
The attributes data-state, data-restarts and <time> give the page stable hooks that do not depend on wording. The lab checker reads them. The state also appears in the heading text and not only in colour, so a reader who cannot see the colour still gets the claim.
.status { font-family: system-ui, sans-serif; padding: 1.5rem; border-left: 0.5rem solid; }
.state-healthy { border-color: #1a7f37; }
.state-restarted { border-color: #9a6700; }
.state-gave_up { border-color: #cf222e; }
.state-unknown { border-color: #6e7781; }
Running it
The fixtures are four small logs in the same format the supervisor writes: healthy.log is one started line. restarted.log is started, exited with code 70, restarting 1/3, then started. gave-up.log has three restarts, then gave_up, then the shell's plain-text exit-status line. garbled.log is one started line followed by that plain-text line.
This is a reference run with Bun 1.3.14 in the lab container with no network. The first line is the Bun version.
1.3.14
$ bun build.ts
garbled.log -> dist/garbled.html state=unknown restarts=0
gave-up.log -> dist/gave-up.html state=gave_up restarts=3
healthy.log -> dist/healthy.html state=healthy restarts=0
restarted.log -> dist/restarted.html state=restarted restarts=1
Three of the generated pages, as written to dist/:
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>edge-svc: Gave up</title>
<link rel="stylesheet" href="status.css"></head>
<body><main class="status state-gave_up" data-state="gave_up">
<h1>edge-svc: Gave up</h1>
<p>Gave up after 3 restarts. A human needs to look.</p>
<p>Restarts: <span data-restarts>3</span>. Last event: <time>2026-11-01T15:03:18Z</time>.</p>
</main></body></html>
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>edge-svc: Restarted</title>
<link rel="stylesheet" href="status.css"></head>
<body><main class="status state-restarted" data-state="restarted">
<h1>edge-svc: Restarted</h1>
<p>Running again after 1 restart(s).</p>
<p>Restarts: <span data-restarts>1</span>. Last event: <time>2026-11-01T15:03:17Z</time>.</p>
</main></body></html>
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>edge-svc: Unknown</title>
<link rel="stylesheet" href="status.css"></head>
<body><main class="status state-unknown" data-state="unknown">
<h1>edge-svc: Unknown</h1>
<p>Cannot tell: 1 unreadable line(s), last event started.</p>
<p>Restarts: <span data-restarts>0</span>. Last event: <time>2026-11-01T15:03:16Z</time>.</p>
</main></body></html>
Compare the gave-up page and the unknown page. Both logs contain the same plain-text line, and one is Gave up and the other is Unknown. The difference is a fact the first log contains (gave_up) and the second lacks. Note also that the Unknown page still shows the time of the last event it could read, because that is true and useful.
Lab
Starter. The lab directory holds the fixtures, build.ts, status.css, package.json, check.sh, and this status.ts:
// Reads the supervisor's JSON event log and decides what the page may claim.
export type Event = { ts: number; event: string; detail: string };
export type State = "healthy" | "restarted" | "gave_up" | "unknown";
export type Summary = { state: State; restarts: number; lastTs: number | null; note: string };
export function summarize(text: string): Summary {
// TODO: parse the JSON lines, count restarts, and report gave_up and unreadable logs honestly.
const lines = text.split("\n").filter((l) => l.trim() !== "");
return { state: "healthy", restarts: 0, lastTs: null, note: `Read ${lines.length} lines. All good.` };
}
This starter builds without error and every page it produces says Healthy. That is the lie from the Concept section.
Task. Make summarize() honest. Edit only status.ts. The rules are:
- Read each non-blank line as JSON. A line that is not valid JSON, or is JSON of the wrong shape, or names an event outside
started,exited,restarting,unhealthy,gave_up, is unreadable. restartsis the number ofrestartingevents.lastTsis thetsof the last readable event, ornullif there is none. It is not the time of any unreadable line.- If any
gave_upevent exists, the state isgave_up, regardless of unreadable lines. - Otherwise, if any line was unreadable, the log has no events, or the last event is not
started, the state isunknown. - Otherwise, if there was at least one restart, the state is
restarted. - Otherwise the state is
healthy.
You can compare against the reference in the Worked example once you have tried it. Typing the code out yourself teaches the ordering better than copying it.
Run it. Install Bun, enter the lab directory, and run:
sh check.sh
To run it in the same clean container the lab was checked in (Bun 1.3.14, no network), use docker run --rm --network=none -v "$PWD":/lab -w /lab oven/bun:1.3-debian sh check.sh from the lab directory. To look at the result, run bun run build and open dist/gave-up.html in a browser.
Expected output when it passes. The checker builds the pages, then prints one RESULT line for each of four checks. Bun also prints the script line $ bun build.ts, but on standard error, not standard output. In a terminal it appears first. A log that stores standard output and then standard error, as the lab's CI log does, shows it last.
garbled.log -> dist/garbled.html state=unknown restarts=0
gave-up.log -> dist/gave-up.html state=gave_up restarts=3
healthy.log -> dist/healthy.html state=healthy restarts=0
restarted.log -> dist/restarted.html state=restarted restarts=1
built 4 pages
RESULT static_build PASS
states: healthy=healthy restarted=restarted gave-up=gave_up garbled=unknown
RESULT three_distinct_states PASS
RESULT gave_up_never_green PASS
RESULT unreadable_is_unknown PASS
With the starter unchanged, the static_build check passes, because the build itself works. The other three fail, and the states: line shows every page as healthy:
built 4 pages
RESULT static_build PASS
states: healthy=healthy restarted=healthy gave-up=healthy garbled=healthy
RESULT three_distinct_states FAIL
RESULT gave_up_never_green FAIL
RESULT unreadable_is_unknown FAIL
The gave_up_never_green check passes only when the gave-up page avoids the healthy style, shows 3 restarts, and shows the time 2026-11-01T15:03:18Z. If you get the state right but the time wrong, check that you took lastTs from the last readable event.
Stretch. Add a fifth fixture, unhealthy.log, in which the supervisor's probe fails and the log ends on an unhealthy event with no restarting or started after it. Decide what the page should show and defend your choice. Two defensible answers are keeping it Unknown, because the log does not say whether a restart will follow, and adding a new state such as "Degraded". Think about what a reader on call would do after seeing each one, and what you can prove from the log alone.
Two practical notes. The checker expects exactly 4 pages, so a fifth fixture makes static_build fail until you change the number in check.sh, or you can work in a copy. And because Bun does not type-check, adding a state to the State type without adding it to LABEL will not stop the build. The page would print edge-svc: undefined instead. Run the TypeScript compiler if you want that mistake caught before it ships.
Other paths
Gleam with Lustre (hypothetical). This path was not built or run for this lesson. It rests on two documented capabilities. In Gleam, State would be a custom type with four variants, and summarize would return one of them. The Gleam Language Tour states that the compiler performs exhaustiveness checking on case expressions, so adding a fifth state would be a compile error in every case that still handles only four. That is stronger than the TypeScript situation above, where a missing LABEL entry fails silently under Bun. For the page, the Lustre documentation (v5.7.1, module lustre/element) says Lustre can be used as an HTML templating library without its runtime, through functions such as to_document_string, which renders an element as a full document with a doctype. A build script would call it on the page element and write the string to a file. The result is the same static export with no client code. What changes is where mistakes are caught: at compile time in Gleam, at check time in this lab.
Server-rendered Rust. Switch to a server framework such as axum when the page needs something a build step cannot give it: sign-in, per-user views, forms that change data, or live numbers that must be current on every request. At that point the page has inputs the author does not control, and a running process is the right tool. Until then, a server adds a failure mode to the thing you use to report failures, so this lesson does not write that code.
Citations
- Beyer, Jones, Petoff, Murphy (eds.), Site Reliability Engineering, O'Reilly, 2016. Chapter 6, "Monitoring Distributed Systems" (symptoms versus causes; a page should say what is broken; keep it simple). Chapter 22, "Addressing Cascading Failures" (why retries must be bounded).
- Sam Newman, Building Microservices, 2nd edition, O'Reilly, 2021. Chapter 10, "From Monitoring to Observability".
- Joe Armstrong, Making reliable distributed systems in the presence of software errors, PhD thesis, KTH, 2003 (a supervisor escalates when restarts exceed its limit).
- Bun documentation, bun.sh: "File I/O" (
Bun.file,.text(),Bun.write); "bun run" (package.json scripts and running TypeScript files directly); "Bundler" (thebun buildsubcommand). - MDN Web Docs,
JSON.parse()(throwsSyntaxErroron invalid JSON);Array.prototype.at();Date.prototype.toISOString(). - Lustre documentation, v5.7.1, hexdocs.pm/lustre: module
lustre/element(module overview;to_string,to_document_string). - The Gleam Language Tour, tour.gleam.run, "Case expressions" (exhaustiveness checking).