status: the viewer rides the binary — same-origin, unknowns loud
Task 4: embed index.html + status.css (assets/*) into the carlos binary and have PublishFleet Put css, then html, then fleet.json — assets before the JSON so a reader who polls fleet.json right after a publish never finds a page missing its own stylesheet or markup. The inline script fetches fleet.json relative, renders band words as text, and turns every null into a titled em-dash rather than formatting a number it never received. No http(s):// literal and no <script src> anywhere in the shipped HTML — the incident-fallback URL is built from `location` at view time instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+406
−4
internal/status/assets/index.html+221 −0internal/status/assets/status.css+108 −0internal/status/fleet.go+25 −4internal/status/page.go+15 −0internal/status/status_test.go+37 −0
diff --git a/internal/status/assets/index.html b/internal/status/assets/index.html| new file mode 100644 |
| index 000000000..acc47a06c |
| --- /dev/null |
| +++ b/internal/status/assets/index.html |
| @@ -0,0 +1,221 @@ |
| +<!doctype html> |
| +<html lang="en"> |
| +<head> |
| +<meta charset="utf-8"> |
| +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| +<title>Fleet status</title> |
| +<link rel="stylesheet" href="status.css"> |
| +</head> |
| +<body> |
| +<header class="masthead"> |
| + <h1>Fleet status</h1> |
| + <p class="generated" id="generated">Loading…</p> |
| +</header> |
| +<main> |
| + <section class="tiles" aria-label="Fleet summary"> |
| + <div class="tile"> |
| + <h2>Instances</h2> |
| + <p class="tile-value" id="tile-instances"></p> |
| + <p class="tile-detail" id="tile-instances-detail"></p> |
| + </div> |
| + <div class="tile"> |
| + <h2>Capacity</h2> |
| + <p class="tile-value" id="tile-capacity"></p> |
| + </div> |
| + <div class="tile"> |
| + <h2>Replication</h2> |
| + <p class="tile-value" id="tile-replication"></p> |
| + </div> |
| + </section> |
| + |
| + <section> |
| + <h2>Boxes</h2> |
| + <div class="tablewrap"> |
| + <table> |
| + <thead> |
| + <tr> |
| + <th>Label</th><th>Region</th><th>Load</th> |
| + <th>Static</th><th>Resident</th><th>Hibernating</th><th>Awake</th><th>Asleep</th> |
| + <th></th> |
| + </tr> |
| + </thead> |
| + <tbody id="boxes-body"></tbody> |
| + </table> |
| + </div> |
| + </section> |
| + |
| + <section> |
| + <h2>Apps</h2> |
| + <div class="tablewrap"> |
| + <table> |
| + <thead> |
| + <tr><th>Name</th><th>Instances</th><th>Healthy</th><th>Channel</th><th>Version</th></tr> |
| + </thead> |
| + <tbody id="apps-body"></tbody> |
| + </table> |
| + </div> |
| + <p class="small" id="apps-unlisted"></p> |
| + </section> |
| + |
| + <section> |
| + <h2>Services</h2> |
| + <div class="chips" id="services-chips"></div> |
| + </section> |
| +</main> |
| +<footer> |
| + <p> |
| + This page publishes aggregate counts and status words only — never |
| + hostnames, machine identifiers, or raw utilization figures; anything |
| + this page doesn't know is shown as an em-dash, never guessed. |
| + <span id="fallback"></span> |
| + </p> |
| +</footer> |
| +<script> |
| +(function () { |
| + "use strict"; |
| + |
| + // emDash renders an unknown value loudly rather than silently: a titled |
| + // "—" a reader can hover, instead of a blank cell that could be |
| + // misread as zero or a JS "null"/"undefined" string leaking onto the page. |
| + function emDash() { |
| + var el = document.createElement("span"); |
| + el.textContent = "—"; |
| + el.title = "unknown"; |
| + return el; |
| + } |
| + |
| + // asText turns a JSON value into a DOM node: known values become plain |
| + // text (never innerHTML — box labels, app names, and regions are |
| + // operator-supplied strings, and this keeps them inert no matter what |
| + // they contain); null/undefined become emDash(). This is the one place |
| + // that decides "did we receive this number" so no other renderer below |
| + // ever has to guess or format a value it didn't get. |
| + function asText(value) { |
| + if (value === null || value === undefined) return emDash(); |
| + return document.createTextNode(String(value)); |
| + } |
| + |
| + // asBool renders a tri-state boolean (true/false/unknown) as a word, never |
| + // as JavaScript's own "true"/"false" — those read as code, not status. |
| + function asBool(value, whenTrue, whenFalse) { |
| + if (value === null || value === undefined) return emDash(); |
| + return document.createTextNode(value ? whenTrue : whenFalse); |
| + } |
| + |
| + function td(content) { |
| + var el = document.createElement("td"); |
| + el.appendChild(content instanceof Node ? content : asText(content)); |
| + return el; |
| + } |
| + |
| + // routeCols is the fixed key order both the boxes table and the |
| + // instances tile detail line walk — snake_case, matching fleet.json's |
| + // routes/instances shape exactly (see the design spec §2). |
| + var routeCols = ["static", "resident", "hibernating", "awake", "asleep"]; |
| + |
| + function renderTiles(f) { |
| + var instances = f.instances || {}; |
| + document.getElementById("tile-instances").replaceChildren(asText(instances.total)); |
| + |
| + var detail = routeCols |
| + .map(function (k) { |
| + var v = instances[k]; |
| + return k + " " + (v === null || v === undefined ? "—" : v); |
| + }) |
| + .join(" · "); |
| + document.getElementById("tile-instances-detail").textContent = detail; |
| + |
| + var capacity = f.capacity || {}; |
| + document.getElementById("tile-capacity").replaceChildren(asText(capacity.level)); |
| + |
| + var repl = f.replication || {}; |
| + document.getElementById("tile-replication").replaceChildren(asBool(repl.healthy, "healthy", "unhealthy")); |
| + } |
| + |
| + function renderBoxes(f) { |
| + var body = document.getElementById("boxes-body"); |
| + (f.boxes || []).forEach(function (b) { |
| + var tr = document.createElement("tr"); |
| + tr.appendChild(td(asText(b.label))); |
| + tr.appendChild(td(asText(b.region))); |
| + tr.appendChild(td(asText(b.load))); |
| + var routes = b.routes || {}; |
| + routeCols.forEach(function (k) { |
| + tr.appendChild(td(asText(routes[k]))); |
| + }); |
| + var staleTd = document.createElement("td"); |
| + if (b.stale) { |
| + // Bordered, not filled — a "trust this less" flag, not an outage |
| + // marker; see status.css's .chip-stale comment. |
| + var chip = document.createElement("span"); |
| + chip.className = "chip chip-stale"; |
| + chip.textContent = "stale"; |
| + staleTd.appendChild(chip); |
| + } |
| + tr.appendChild(staleTd); |
| + body.appendChild(tr); |
| + }); |
| + } |
| + |
| + function renderApps(f) { |
| + var body = document.getElementById("apps-body"); |
| + // Only what fleet.json actually carries: an app that opted in ships |
| + // exactly these five fields (see the design spec's apps opt-in rule) — |
| + // this loop never reaches for a field the JSON doesn't have. |
| + (f.apps || []).forEach(function (a) { |
| + var tr = document.createElement("tr"); |
| + tr.appendChild(td(asText(a.name))); |
| + tr.appendChild(td(asText(a.instances))); |
| + tr.appendChild(td(asBool(a.healthy, "healthy", "unhealthy"))); |
| + tr.appendChild(td(asText(a.channel))); |
| + tr.appendChild(td(asText(a.version))); |
| + body.appendChild(tr); |
| + }); |
| + |
| + var unlisted = document.getElementById("apps-unlisted"); |
| + if (typeof f.apps_unlisted === "number") { |
| + unlisted.textContent = |
| + f.apps_unlisted + " more app" + (f.apps_unlisted === 1 ? "" : "s") + " running, not publicly named."; |
| + } |
| + } |
| + |
| + function renderServices(f) { |
| + var chips = document.getElementById("services-chips"); |
| + (f.services || []).forEach(function (s) { |
| + var chip = document.createElement("span"); |
| + chip.className = "chip " + (s.up ? "chip-up" : "chip-down"); |
| + chip.textContent = s.name + " " + (s.up ? "up" : "down"); |
| + chips.appendChild(chip); |
| + }); |
| + } |
| + |
| + // renderFallback builds the incident-fallback sentence from `location` at |
| + // view time rather than a hardcoded absolute URL baked into this file — |
| + // the whole point of the same-origin rule (design spec §3): this page |
| + // must contain no http(s):// literal of its own, yet still needs to tell |
| + // a reader where the raw bucket copy lives once the serving box is gone. |
| + function renderFallback() { |
| + var base = location.href.replace(/index\.html$/, ""); |
| + var url = base + "fleet.json"; |
| + document.getElementById("fallback").textContent = |
| + "If this page is unreachable, the same data is published at " + url + "."; |
| + } |
| + |
| + fetch("fleet.json") |
| + .then(function (res) { return res.json(); }) |
| + .then(function (f) { |
| + document.getElementById("generated").textContent = f.generated_at ? "Generated " + f.generated_at : ""; |
| + renderTiles(f); |
| + renderBoxes(f); |
| + renderApps(f); |
| + renderServices(f); |
| + }) |
| + .catch(function () { |
| + document.getElementById("generated").textContent = "Unable to load fleet.json."; |
| + }); |
| + |
| + renderFallback(); |
| +})(); |
| +</script> |
| +</body> |
| +</html> |
diff --git a/internal/status/assets/status.css b/internal/status/assets/status.css| new file mode 100644 |
| index 000000000..d3dde40a0 |
| --- /dev/null |
| +++ b/internal/status/assets/status.css |
| @@ -0,0 +1,108 @@ |
| +/* status.css — the fleet status page's one stylesheet. Same palette shape as |
| + the console's inline styles (CSS custom properties flipped by |
| + prefers-color-scheme — see internal/console/templates/layout.html), kept |
| + in its own small file here because this page ships from an embedded FS, |
| + not the console's template set, and the design spec calls for exactly one |
| + stylesheet, no frameworks. */ |
| + |
| +:root { |
| + --bg: #ffffff; |
| + --ink: #1d2029; |
| + --muted: #64707f; |
| + --accent: #0f5e85; |
| + --accent-soft: rgba(15, 94, 133, 0.09); |
| + --rule: #e7e9f0; |
| + --good: #2f7a4f; |
| + --bad: #b3403a; |
| + --warn: #a6790f; |
| +} |
| +@media (prefers-color-scheme: dark) { |
| + :root { |
| + --bg: #14151c; |
| + --ink: #eef0f6; |
| + --muted: #93a4ba; |
| + --accent: #5db8dd; |
| + --accent-soft: rgba(93, 184, 221, 0.12); |
| + --rule: #2a2e38; |
| + --good: #6fbf8d; |
| + --bad: #e07b76; |
| + --warn: #e0b95c; |
| + } |
| +} |
| + |
| +* { margin: 0; padding: 0; box-sizing: border-box; } |
| + |
| +body { |
| + background: var(--bg); |
| + color: var(--ink); |
| + font: 400 1rem/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; |
| + padding: 0 1.25rem 3rem; |
| +} |
| + |
| +header.masthead { |
| + max-width: 56rem; |
| + margin: 0 auto; |
| + padding: 1.75rem 0 1rem; |
| + border-bottom: 1px solid var(--rule); |
| +} |
| +header.masthead h1 { font-size: 1.6rem; } |
| +.generated { color: var(--muted); font-size: 0.85rem; margin-top: 0.35rem; } |
| + |
| +main { max-width: 56rem; margin: 0 auto; } |
| +h2 { font-size: 1.1rem; margin: 2rem 0 0.75rem; } |
| + |
| +.tiles { display: flex; flex-wrap: wrap; gap: 1rem; margin: 1.5rem 0; } |
| +.tile { |
| + flex: 1 1 10rem; |
| + background: var(--accent-soft); |
| + border-radius: 0.6rem; |
| + padding: 1rem 1.1rem; |
| +} |
| +.tile h2 { |
| + margin: 0 0 0.35rem; |
| + font-size: 0.78rem; |
| + text-transform: uppercase; |
| + letter-spacing: 0.05em; |
| + color: var(--muted); |
| + font-weight: 600; |
| +} |
| +.tile-value { font-size: 1.6rem; font-weight: 600; } |
| +.tile-detail { color: var(--muted); font-size: 0.8rem; margin-top: 0.25rem; } |
| + |
| +.tablewrap { overflow-x: auto; } |
| +table { border-collapse: collapse; width: 100%; font-size: 0.9rem; } |
| +th, td { padding: 0.5rem 0.9rem 0.5rem 0; border-bottom: 1px solid var(--rule); text-align: left; } |
| +th { |
| + font-size: 0.75rem; |
| + text-transform: uppercase; |
| + letter-spacing: 0.05em; |
| + color: var(--muted); |
| + font-weight: 600; |
| +} |
| + |
| +/* .chip-stale is deliberately bordered-only (no fill) per the design spec: |
| + staleness is a "trust this less" signal, not an outage — it should read |
| + as a flag on the row, not as loud as the up/down service chips. */ |
| +.chip { |
| + display: inline-block; |
| + font-size: 0.78rem; |
| + font-weight: 600; |
| + padding: 0.15rem 0.55rem; |
| + border-radius: 999px; |
| + margin: 0.15rem 0.3rem 0.15rem 0; |
| +} |
| +.chip-stale { border: 1px solid var(--warn); color: var(--warn); } |
| +.chip-up { background: var(--accent-soft); color: var(--good); } |
| +.chip-down { background: var(--accent-soft); color: var(--bad); } |
| +.chips { display: flex; flex-wrap: wrap; } |
| + |
| +.small { color: var(--muted); font-size: 0.85rem; margin-top: 0.5rem; } |
| + |
| +footer { |
| + max-width: 56rem; |
| + margin: 2.5rem auto 0; |
| + padding-top: 1.25rem; |
| + border-top: 1px solid var(--rule); |
| + color: var(--muted); |
| + font-size: 0.85rem; |
| +} |
diff --git a/internal/status/fleet.go b/internal/status/fleet.go| index 2eb750047..cf1af6998 100644 |
| --- a/internal/status/fleet.go |
| +++ b/internal/status/fleet.go |
| @@ -342,11 +342,32 @@ func buildServices(routes []registry.Route) []struct { |
| return services |
| } |
| -// PublishFleet writes fleet.json to status/. Page assets (index.html, the |
| -// stylesheet) are embedded and published alongside it starting in Task 4 — |
| -// this function's single Put is the seam that loop lands in; it isn't a |
| -// TODO so much as the honest scope of what this task builds. |
| +// pageAssets is the fixed publish order for assetsFS's contents: css before |
| +// html, both before fleet.json (see PublishFleet). It is NOT fs.ReadDir |
| +// order — "index.html" sorts alphabetically before "status.css", which |
| +// would flip the required css-before-html order — so the sequence is |
| +// spelled out explicitly rather than derived from a directory walk. |
| +var pageAssets = []string{"status.css", "index.html"} |
| + |
| +// PublishFleet writes the whole viewer: the embedded page assets first, |
| +// then fleet.json last. Order mirrors Eleven's upload-styles-first rule — |
| +// fleet.json is the object readers poll most often, so a reader who fetches |
| +// it the instant after a publish must never be able to load a page that |
| +// references a stylesheet or markup that hasn't landed yet. Every object |
| +// goes through putOverwrite: PublishFleet republishes all three keys on |
| +// every pass (every 5 minutes, per the plan), so a create-only Put would |
| +// fail from the second pass onward. |
| func PublishFleet(ctx context.Context, s release.Store, f *Fleet) error { |
| + for _, name := range pageAssets { |
| + body, err := assetsFS.ReadFile("assets/" + name) |
| + if err != nil { |
| + return err |
| + } |
| + if err := putOverwrite(ctx, s, "status/"+name, body); err != nil { |
| + return err |
| + } |
| + } |
| + |
| body, err := json.MarshalIndent(f, "", " ") |
| if err != nil { |
| return err |
diff --git a/internal/status/page.go b/internal/status/page.go| new file mode 100644 |
| index 000000000..318373f98 |
| --- /dev/null |
| +++ b/internal/status/page.go |
| @@ -0,0 +1,15 @@ |
| +package status |
| + |
| +import "embed" |
| + |
| +// assetsFS embeds the fleet status page's viewer — index.html and |
| +// status.css — straight into the carlos binary. This is the "the viewer |
| +// rides the binary" half of the design (see the design spec §3): page |
| +// changes deploy with a binary release, data changes deploy every 5 |
| +// minutes when the status-lease holder republishes fleet.json. Kept in its |
| +// own file (rather than fleet.go) because it's a pure asset declaration |
| +// with no logic of its own — PublishFleet in fleet.go is what actually |
| +// reads out of this FS and Puts each file to the bucket. |
| +// |
| +//go:embed assets/* |
| +var assetsFS embed.FS |
diff --git a/internal/status/status_test.go b/internal/status/status_test.go| index 163f2de8b..c36769a44 100644 |
| --- a/internal/status/status_test.go |
| +++ b/internal/status/status_test.go |
| @@ -383,6 +383,43 @@ func TestPublishFleetWritesAndOverwrites(t *testing.T) { |
| } |
| } |
| +// ---- Task 4: embedded viewer page ----------------------------------------- |
| + |
| +// TestPublishWritesPageAssets proves PublishFleet ships the whole viewer — |
| +// fleet.json alongside the embedded index.html and status.css — and that the |
| +// page is same-origin only: no absolute http(s):// URL and no <script src> |
| +// anywhere in the shipped markup. (Simplified from the plan's step-1 text, |
| +// per the operator's ambiguity resolution: the footer's incident-fallback |
| +// URL is injected as text by inline JS from `location` at view time, never |
| +// baked into the HTML as a hardcoded absolute href, so a flat "no |
| +// http(s):// substring, no <script src" check is the honest same-origin |
| +// assertion here — no carve-out needed for a rel="me" link that doesn't |
| +// exist.) |
| +func TestPublishWritesPageAssets(t *testing.T) { |
| + s := teststore(t) |
| + f, err := status.BuildFleet(context.Background(), s, nil, time.Now(), 5*time.Minute) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := status.PublishFleet(context.Background(), s, f); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, key := range []string{"status/fleet.json", "status/index.html", "status/status.css"} { |
| + if _, _, err := s.Get(context.Background(), key); err != nil { |
| + t.Errorf("%s not published: %v", key, err) |
| + } |
| + } |
| + html, _, err := s.Get(context.Background(), "status/index.html") |
| + if err != nil { |
| + t.Fatalf("Get status/index.html: %v", err) |
| + } |
| + for _, banned := range []string{"http://", "https://", "<script src"} { |
| + if strings.Contains(string(html), banned) { |
| + t.Errorf("page must be same-origin only; found %q in index.html", banned) |
| + } |
| + } |
| +} |
| + |
| // TestWriteBoxReturnsErrorOnDoubleCASConflict proves WriteBox does NOT retry |
| // a second time: if the retried Put also loses the race, that error must |
| // reach the caller rather than being swallowed or retried forever. |