| 1 | <!doctype html> |
| 2 | <html lang="en"> |
| 3 | <head> |
| 4 | <meta charset="utf-8"> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | <title>Fleet status</title> |
| 7 | <link rel="stylesheet" href="status.css"> |
| 8 | </head> |
| 9 | <body> |
| 10 | <header class="masthead"> |
| 11 | <h1>Fleet status</h1> |
| 12 | <p class="generated" id="generated">Loading…</p> |
| 13 | </header> |
| 14 | <main> |
| 15 | <section class="tiles" aria-label="Fleet summary"> |
| 16 | <div class="tile"> |
| 17 | <h2>Instances</h2> |
| 18 | <p class="tile-value" id="tile-instances"></p> |
| 19 | <p class="tile-detail" id="tile-instances-detail"></p> |
| 20 | </div> |
| 21 | <div class="tile"> |
| 22 | <h2>Capacity</h2> |
| 23 | <p class="tile-value" id="tile-capacity"></p> |
| 24 | </div> |
| 25 | <div class="tile"> |
| 26 | <h2>Replication</h2> |
| 27 | <p class="tile-value" id="tile-replication"></p> |
| 28 | </div> |
| 29 | <div class="tile"> |
| 30 | <h2>Adoption</h2> |
| 31 | <p class="tile-value" id="tile-adoption"></p> |
| 32 | </div> |
| 33 | <div class="tile" id="tile-expired-canaries-wrap" hidden> |
| 34 | <h2>Expired canaries</h2> |
| 35 | <p class="tile-value" id="tile-expired-canaries"></p> |
| 36 | </div> |
| 37 | </section> |
| 38 | |
| 39 | <section> |
| 40 | <h2>Boxes</h2> |
| 41 | <div class="tablewrap"> |
| 42 | <table> |
| 43 | <thead> |
| 44 | <tr> |
| 45 | <th>Label</th><th>Region</th><th>Load</th> |
| 46 | <th>Static</th><th>Resident</th><th>Hibernating</th><th>Awake</th><th>Asleep</th> |
| 47 | <th>Adoption</th> |
| 48 | <th>Config sync</th> |
| 49 | <th></th> |
| 50 | </tr> |
| 51 | </thead> |
| 52 | <tbody id="boxes-body"></tbody> |
| 53 | </table> |
| 54 | </div> |
| 55 | </section> |
| 56 | |
| 57 | <section> |
| 58 | <h2>Apps</h2> |
| 59 | <div class="tablewrap"> |
| 60 | <table> |
| 61 | <thead> |
| 62 | <tr><th>Name</th><th>Instances</th><th>Healthy</th><th>Channel</th><th>Version</th></tr> |
| 63 | </thead> |
| 64 | <tbody id="apps-body"></tbody> |
| 65 | </table> |
| 66 | </div> |
| 67 | <p class="small" id="apps-unlisted"></p> |
| 68 | </section> |
| 69 | |
| 70 | <section> |
| 71 | <h2>Services</h2> |
| 72 | <div class="chips" id="services-chips"></div> |
| 73 | </section> |
| 74 | </main> |
| 75 | <footer> |
| 76 | <p> |
| 77 | This page publishes aggregate counts and status words only — never |
| 78 | hostnames, machine identifiers, or raw utilization figures; anything |
| 79 | this page doesn't know is shown as an em-dash, never guessed. |
| 80 | <span id="fallback"></span> |
| 81 | </p> |
| 82 | </footer> |
| 83 | <script> |
| 84 | (function () { |
| 85 | "use strict"; |
| 86 | |
| 87 | // emDash renders an unknown value loudly rather than silently: a titled |
| 88 | // "—" a reader can hover, instead of a blank cell that could be |
| 89 | // misread as zero or a JS "null"/"undefined" string leaking onto the page. |
| 90 | function emDash() { |
| 91 | var el = document.createElement("span"); |
| 92 | el.textContent = "—"; |
| 93 | el.title = "unknown"; |
| 94 | return el; |
| 95 | } |
| 96 | |
| 97 | // asText turns a JSON value into a DOM node: known values become plain |
| 98 | // text (never innerHTML — box labels, app names, and regions are |
| 99 | // operator-supplied strings, and this keeps them inert no matter what |
| 100 | // they contain); null/undefined become emDash(). This is the one place |
| 101 | // that decides "did we receive this number" so no other renderer below |
| 102 | // ever has to guess or format a value it didn't get. |
| 103 | function asText(value) { |
| 104 | if (value === null || value === undefined) return emDash(); |
| 105 | return document.createTextNode(String(value)); |
| 106 | } |
| 107 | |
| 108 | // asBool renders a tri-state boolean (true/false/unknown) as a word, never |
| 109 | // as JavaScript's own "true"/"false" — those read as code, not status. |
| 110 | function asBool(value, whenTrue, whenFalse) { |
| 111 | if (value === null || value === undefined) return emDash(); |
| 112 | return document.createTextNode(value ? whenTrue : whenFalse); |
| 113 | } |
| 114 | |
| 115 | function td(content) { |
| 116 | var el = document.createElement("td"); |
| 117 | el.appendChild(content instanceof Node ? content : asText(content)); |
| 118 | return el; |
| 119 | } |
| 120 | |
| 121 | // routeCols is the fixed key order both the boxes table and the |
| 122 | // instances tile detail line walk — snake_case, matching fleet.json's |
| 123 | // routes/instances shape exactly (see the design spec §2). |
| 124 | var routeCols = ["static", "resident", "hibernating", "awake", "asleep"]; |
| 125 | |
| 126 | function renderTiles(f) { |
| 127 | var instances = f.instances || {}; |
| 128 | document.getElementById("tile-instances").replaceChildren(asText(instances.total)); |
| 129 | |
| 130 | // Built as DOM nodes (not a joined string) so a null component renders |
| 131 | // through asText/emDash — a titled "unknown" span, same as every other |
| 132 | // null path on this page — rather than a bare "—" character that |
| 133 | // carries no title for a reader to hover. |
| 134 | var detailEl = document.getElementById("tile-instances-detail"); |
| 135 | var detailNodes = []; |
| 136 | routeCols.forEach(function (k, i) { |
| 137 | if (i > 0) detailNodes.push(document.createTextNode(" · ")); |
| 138 | detailNodes.push(document.createTextNode(k + " ")); |
| 139 | detailNodes.push(asText(instances[k])); |
| 140 | }); |
| 141 | detailEl.replaceChildren.apply(detailEl, detailNodes); |
| 142 | |
| 143 | var capacity = f.capacity || {}; |
| 144 | document.getElementById("tile-capacity").replaceChildren(asText(capacity.level)); |
| 145 | |
| 146 | var repl = f.replication || {}; |
| 147 | document.getElementById("tile-replication").replaceChildren(asBool(repl.healthy, "healthy", "unhealthy")); |
| 148 | |
| 149 | // "not running" is the loud reading this tile exists for: a fleet whose |
| 150 | // adoption timers aren't armed is one where nothing picks up a release on |
| 151 | // its own, which used to be invisible from anywhere. |
| 152 | document.getElementById("tile-adoption").replaceChildren(asBool(f.adoption_running, "running", "not running")); |
| 153 | |
| 154 | // The expired-canaries tile is deliberately absent, not zeroed, when |
| 155 | // there is nothing to see (pipelines spec §5): a fleet with no expired |
| 156 | // canaries — the ordinary, common case — should not carry a permanent |
| 157 | // "0" tile competing for attention with the three that always matter. |
| 158 | // Unknown (null) also stays hidden: "we couldn't check" is not the same |
| 159 | // claim as "there are some", and this tile only ever asserts the latter. |
| 160 | var wrap = document.getElementById("tile-expired-canaries-wrap"); |
| 161 | if (typeof f.expired_canaries === "number" && f.expired_canaries > 0) { |
| 162 | wrap.hidden = false; |
| 163 | document.getElementById("tile-expired-canaries").replaceChildren(asText(f.expired_canaries)); |
| 164 | } else { |
| 165 | wrap.hidden = true; |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | function renderBoxes(f) { |
| 170 | var body = document.getElementById("boxes-body"); |
| 171 | (f.boxes || []).forEach(function (b) { |
| 172 | var tr = document.createElement("tr"); |
| 173 | tr.appendChild(td(asText(b.label))); |
| 174 | tr.appendChild(td(asText(b.region))); |
| 175 | tr.appendChild(td(asText(b.load))); |
| 176 | var routes = b.routes || {}; |
| 177 | routeCols.forEach(function (k) { |
| 178 | tr.appendChild(td(asText(routes[k]))); |
| 179 | }); |
| 180 | tr.appendChild(td(asBool(b.adoption_running, "running", "not running"))); |
| 181 | // Config sync is a SEPARATE unit from adoption (carlos-adopt.path vs |
| 182 | // carlos-adopt.timer) and fails separately: a box can adopt releases |
| 183 | // fine while its unit-backed config quietly waits out the hour. Its |
| 184 | // own column, never folded into Adoption's word. |
| 185 | tr.appendChild(td(asBool(b.config_path_running, "armed", "not armed"))); |
| 186 | var staleTd = document.createElement("td"); |
| 187 | if (b.stale) { |
| 188 | // Bordered, not filled — a "trust this less" flag, not an outage |
| 189 | // marker; see status.css's .chip-stale comment. |
| 190 | var chip = document.createElement("span"); |
| 191 | chip.className = "chip chip-stale"; |
| 192 | chip.textContent = "stale"; |
| 193 | staleTd.appendChild(chip); |
| 194 | } |
| 195 | tr.appendChild(staleTd); |
| 196 | body.appendChild(tr); |
| 197 | }); |
| 198 | } |
| 199 | |
| 200 | function renderApps(f) { |
| 201 | var body = document.getElementById("apps-body"); |
| 202 | // Only what fleet.json actually carries: an app that opted in ships |
| 203 | // exactly these five fields (see the design spec's apps opt-in rule) — |
| 204 | // this loop never reaches for a field the JSON doesn't have. |
| 205 | (f.apps || []).forEach(function (a) { |
| 206 | var tr = document.createElement("tr"); |
| 207 | tr.appendChild(td(asText(a.name))); |
| 208 | tr.appendChild(td(asText(a.instances))); |
| 209 | tr.appendChild(td(asBool(a.healthy, "healthy", "unhealthy"))); |
| 210 | tr.appendChild(td(asText(a.channel))); |
| 211 | tr.appendChild(td(asText(a.version))); |
| 212 | body.appendChild(tr); |
| 213 | }); |
| 214 | |
| 215 | var unlisted = document.getElementById("apps-unlisted"); |
| 216 | if (typeof f.apps_unlisted === "number") { |
| 217 | unlisted.textContent = |
| 218 | f.apps_unlisted + " more app" + (f.apps_unlisted === 1 ? "" : "s") + " running, not publicly named."; |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | function renderServices(f) { |
| 223 | var chips = document.getElementById("services-chips"); |
| 224 | (f.services || []).forEach(function (s) { |
| 225 | var chip = document.createElement("span"); |
| 226 | chip.className = "chip " + (s.up ? "chip-up" : "chip-down"); |
| 227 | chip.textContent = s.name + " " + (s.up ? "up" : "down"); |
| 228 | chips.appendChild(chip); |
| 229 | }); |
| 230 | } |
| 231 | |
| 232 | // renderFallback builds the incident-fallback sentence — and its clickable |
| 233 | // link — from `location` at view time rather than a hardcoded absolute |
| 234 | // URL baked into this file: the whole point of the same-origin rule |
| 235 | // (design spec §3) is that this page must contain no http(s):// literal |
| 236 | // of its own, yet it still needs to tell a reader, with a real <a>, where |
| 237 | // the raw bucket copy lives once the serving box is gone. Built from |
| 238 | // location.origin + location.pathname (not location.href) specifically so |
| 239 | // a query string or hash on the viewed URL can never leak into the join. |
| 240 | function renderFallback() { |
| 241 | var dir = location.pathname.replace(/index\.html$/, ""); |
| 242 | if (dir.charAt(dir.length - 1) !== "/") dir += "/"; // guard a root path with no trailing slash |
| 243 | var url = location.origin + dir + "fleet.json"; |
| 244 | |
| 245 | var fallback = document.getElementById("fallback"); |
| 246 | var link = document.createElement("a"); |
| 247 | link.href = url; |
| 248 | link.textContent = url; |
| 249 | |
| 250 | fallback.replaceChildren( |
| 251 | document.createTextNode("If this page is unreachable, the same data is published at "), |
| 252 | link, |
| 253 | document.createTextNode(".") |
| 254 | ); |
| 255 | } |
| 256 | |
| 257 | fetch("fleet.json") |
| 258 | .then(function (res) { return res.json(); }) |
| 259 | .then(function (f) { |
| 260 | document.getElementById("generated").textContent = f.generated_at ? "Generated " + f.generated_at : ""; |
| 261 | renderTiles(f); |
| 262 | renderBoxes(f); |
| 263 | renderApps(f); |
| 264 | renderServices(f); |
| 265 | }) |
| 266 | .catch(function () { |
| 267 | document.getElementById("generated").textContent = "Unable to load fleet.json."; |
| 268 | }); |
| 269 | |
| 270 | renderFallback(); |
| 271 | })(); |
| 272 | </script> |
| 273 | </body> |
| 274 | </html> |
| 275 | |