Public: anyone can read this; the crew can change it.
Settings › Capacity speaks the home page's language #9721
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9709-settings-capacity-speaks-the-home-page-s → main 2 commits fork
3 files
changed +114
−70
docs/design/areas/settings.md +5
| @@ -77,3 +77,8 @@ full width, when it has rows. Styled in `src/web/style/components.ts`. | ||
| 77 | 77 | - `.segmented` — a few mutually exclusive states side by side, the one on pressed; each other state is a one-button form. |
| 78 | 78 | - `.segmented-on` — the state that is on, in `.segmented`. |
| 79 | 79 | - `.setting-table` — a table of settings rows (Limits): name and reason, value and bounds, the form to change it; each row stacks on a phone. |
| 80 | ||
| 81 | Settings › Capacity (`capacityPage()` in `src/web/pages/capacity.ts`, #9709) has no card: one line of what a change does, | |
| 82 | the egress note when cells only report, then `.settings-block` sections — Capacity (the `.setting-list` of settings | |
| 83 | rows), Changes, Errors and refused calls, Egress connections, Self-swap. No new class. Pinned by | |
| 84 | `test/capacity-rows.test.ts`. |
src/web/pages/capacity.ts +38 −70
| @@ -14,9 +14,9 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 14 | 14 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 15 | 15 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 16 | 16 | import { field, readForm } from "../form.ts"; |
| 17 | import { html, when } from "../html.ts"; | |
| 17 | import { html, when, type Html } from "../html.ts"; | |
| 18 | 18 | import { flashTo } from "../layout.ts"; |
| 19 | import { badge, btn, card, icon, row, time } from "../ui.ts"; | |
| 19 | import { badge, btn, row, time } from "../ui.ts"; | |
| 20 | 20 | import { usageShell } from "./usage-tabs.ts"; |
| 21 | 21 | |
| 22 | 22 | function requireAdmin(ctx: Ctx): Response | undefined { |
| @@ -67,6 +67,11 @@ function input(key: CapacityKey, value: string) { | ||
| 67 | 67 | />`; |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | /** A titled part of the page: a small heading over its rows, never a card box (docs/DESIGN.md §7). */ | |
| 71 | function block(title: string, content: Html) { | |
| 72 | return html`<section class="settings-block"><h2>${title}</h2>${content}</section>`; | |
| 73 | } | |
| 74 | ||
| 70 | 75 | // GET /settings/capacity |
| 71 | 76 | export function capacityPage(ctx: Ctx): Response { |
| 72 | 77 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -88,7 +93,6 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 88 | 93 | const rows = settings.map((s) => { |
| 89 | 94 | const low = belowCeiling(ctx.app.db, s.key); |
| 90 | 95 | return row({ |
| 91 | lead: icon("activity"), | |
| 92 | 96 | title: html`${s.label} <span class="muted small mono">${s.key}</span>${when( |
| 93 | 97 | low !== null, |
| 94 | 98 | () => html` ${badge(`below ceiling ${low?.ceiling}`, { tone: "attn" })}`, |
| @@ -130,7 +134,6 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 130 | 134 | }); |
| 131 | 135 | const history = changes.map((c) => |
| 132 | 136 | row({ |
| 133 | lead: icon("history"), | |
| 134 | 137 | title: html`<span class="mono">${c.key}</span> |
| 135 | 138 | <span class="mono">${c.from === "" ? "auto" : c.from}</span> → |
| 136 | 139 | <span class="mono">${c.to === "" ? "auto" : c.to}</span>`, |
| @@ -140,9 +143,9 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 140 | 143 | const reported = settings.some( |
| 141 | 144 | (s) => s.key === "cells.egress" && s.value === "report", |
| 142 | 145 | ); |
| 143 | const happened = card( | |
| 144 | html`<div class="card-body"> | |
| 145 | <p class="muted small" id="errors"> | |
| 146 | const happened = block( | |
| 147 | "Errors and refused calls", | |
| 148 | html`<p class="muted small" id="errors"> | |
| 146 | 149 | ${errs.lastHour} error${errs.lastHour === 1 ? "" : "s"} in the last |
| 147 | 150 | hour; the latest ${errs.latest.length}, newest first. |
| 148 | 151 | </p> |
| @@ -157,13 +160,7 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 157 | 160 | ? html`; the latest was |
| 158 | 161 | <span class="mono">${refused.latest.method} ${refused.latest.path}</span>` |
| 159 | 162 | : ""}. |
| 160 | </p> | |
| 161 | </div>`, | |
| 162 | { | |
| 163 | head: html`${icon("activity")} | |
| 164 | <h2>Errors and refused calls</h2>`, | |
| 165 | pad: false, | |
| 166 | }, | |
| 163 | </p>`, | |
| 167 | 164 | ); |
| 168 | 165 | const open = egressSnapshot(); |
| 169 | 166 | // The bun cache at work (#7546): a warm cache keeps this near zero. |
| @@ -175,19 +172,17 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 175 | 172 | <span class="mono">${egress.npmTopRepoLastHour.repo}</span> |
| 176 | 173 | (<span class="mono">${bytes(egress.npmTopRepoLastHour.bytes)}</span>).` |
| 177 | 174 | }`; |
| 178 | const connections = card( | |
| 175 | const connections = block( | |
| 176 | "Egress connections", | |
| 179 | 177 | open.length === 0 |
| 180 | ? html`<div class="card-body"> | |
| 181 | <p class="muted small" id="egress-npm">${npm}</p> | |
| 178 | ? html`<p class="muted small" id="egress-npm">${npm}</p> | |
| 182 | 179 | <p class="muted small" id="egress-open"> |
| 183 | 180 | No app holds an egress connection open. |
| 184 | </p> | |
| 185 | </div>` | |
| 186 | : html`<ul class="list" id="egress-open"> | |
| 187 | ${row({ id: "egress-npm", lead: icon("activity"), title: npm })} | |
| 181 | </p>` | |
| 182 | : html`<ul class="list setting-list" id="egress-open"> | |
| 183 | ${row({ id: "egress-npm", title: npm })} | |
| 188 | 184 | ${open.map((r) => |
| 189 | 185 | row({ |
| 190 | lead: icon("activity"), | |
| 191 | 186 | title: r.label === "" ? "(unlabelled)" : r.label, |
| 192 | 187 | meta: html`<span class="mono">${r.destination}</span> · |
| 193 | 188 | ${bytes(r.bytesIn)} in · ${bytes(r.bytesOut)} out`, |
| @@ -195,20 +190,15 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 195 | 190 | }), |
| 196 | 191 | )} |
| 197 | 192 | </ul>`, |
| 198 | { | |
| 199 | head: html`${icon("activity")} | |
| 200 | <h2>Egress connections</h2>`, | |
| 201 | pad: false, | |
| 202 | }, | |
| 203 | 193 | ); |
| 204 | 194 | // Whether a landing here can swap the forge in place (#8383): the reason, before one is refused. |
| 205 | const swap = card( | |
| 206 | html`<ul class="list"> | |
| 195 | const swap = block( | |
| 196 | "Self-swap", | |
| 197 | html`<ul class="list setting-list"> | |
| 207 | 198 | ${row({ |
| 208 | 199 | id: "self-swap", |
| 209 | title: "Self-swap", | |
| 210 | meta: html`${selfSwap.blocked ?? "A landed release of the forge takes over the port in place."} | |
| 211 | · <span class="mono">FORGE_SELF_SWAP</span> ${selfSwap.enabled ? "is set" : "is off"} | |
| 200 | title: selfSwap.blocked ?? "A landed release of the forge takes over the port in place.", | |
| 201 | meta: html`<span class="mono">FORGE_SELF_SWAP</span> ${selfSwap.enabled ? "is set" : "is off"} | |
| 212 | 202 | · <span class="mono">net.ipv4.tcp_migrate_req</span> reads |
| 213 | 203 | <span class="mono">${selfSwap.migrateReq}</span>`, |
| 214 | 204 | trail: badge(selfSwap.blocked === null ? "ready" : "blocked", { |
| @@ -216,53 +206,31 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 216 | 206 | }), |
| 217 | 207 | })} |
| 218 | 208 | </ul>`, |
| 219 | { | |
| 220 | head: html`${icon("activity")} | |
| 221 | <h2>Self-swap</h2>`, | |
| 222 | pad: false, | |
| 223 | }, | |
| 224 | 209 | ); |
| 225 | const body = html`${happened}${connections}${swap}${when( | |
| 210 | // The settings first: they are what this page is for; what happened follows. | |
| 211 | const body = html`<p class="muted small mb"> | |
| 212 | How much the forge does at once. A change takes effect on the next crew | |
| 213 | run, check or app start — running work is left to finish — and never | |
| 214 | needs a restart. | |
| 215 | </p> | |
| 216 | ${when( | |
| 226 | 217 | reported, |
| 227 | 218 | () => html`<p class="muted small mb" id="egress-reported"> |
| 228 | ${icon("activity")} Egress is reported, not enforced: every new app, | |
| 219 | Egress is reported, not enforced: every new app, | |
| 229 | 220 | preview, terminal and check cell may reach any public host on ports |
| 230 | 221 | 80 and 443, declared or not. Private, loopback and cluster addresses, |
| 231 | 222 | and every other port, stay refused. Set it back to enforce once each |
| 232 | 223 | app's reported hosts are in its forge.json. |
| 233 | 224 | </p>`, |
| 234 | 225 | )} |
| 235 | ${card( | |
| 236 | html`<div class="card-body"> | |
| 237 | <p class="muted small"> | |
| 238 | How much the forge does at once. A change here takes effect on the | |
| 239 | next crew run, check or app start — running work is left to finish — | |
| 240 | and never needs a restart. | |
| 241 | </p> | |
| 242 | </div> | |
| 243 | <ul class="list setting-list"> | |
| 244 | ${rows} | |
| 245 | </ul>`, | |
| 246 | { | |
| 247 | head: html`${icon("activity")} | |
| 248 | <h2>Capacity</h2>`, | |
| 249 | pad: false, | |
| 250 | }, | |
| 251 | )} | |
| 252 | ${card( | |
| 253 | history.length === 0 | |
| 254 | ? html`<div class="card-body"> | |
| 255 | <p class="muted small">No change yet.</p> | |
| 256 | </div>` | |
| 257 | : html`<ul class="list"> | |
| 258 | ${history} | |
| 259 | </ul>`, | |
| 260 | { | |
| 261 | head: html`${icon("history")} | |
| 262 | <h2>Changes</h2>`, | |
| 263 | pad: false, | |
| 264 | }, | |
| 265 | )}`; | |
| 226 | ${block("Capacity", html`<ul class="list setting-list">${rows}</ul>`)} | |
| 227 | ${block( | |
| 228 | "Changes", | |
| 229 | history.length === 0 | |
| 230 | ? html`<p class="muted small">No change yet.</p>` | |
| 231 | : html`<ul class="list">${history}</ul>`, | |
| 232 | )} | |
| 233 | ${happened}${connections}${swap}`; | |
| 266 | 234 | const response = usageShell(ctx, "capacity", body); |
| 267 | 235 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 268 | 236 | return response; |
test/capacity-rows.test.ts added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | /** Settings › Capacity is rows under headings, not cards (#9709): one background, few borders. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | import { changeCapacity } from "../src/actions/capacity.ts"; | |
| 9 | import type { User } from "../src/core/users.ts"; | |
| 10 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 11 | ||
| 12 | let dataDir = ""; | |
| 13 | let app: App, servers: Servers, bearer = "", alice: User; | |
| 14 | ||
| 15 | beforeAll(async () => { | |
| 16 | dataDir = await mkdtemp(join(tmpdir(), "forge-caprows-")); | |
| 17 | app = createApp({ dataDir, port: 0 }); | |
| 18 | servers = await serve(app); | |
| 19 | const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 20 | if (!admin.ok) throw new Error(admin.error); | |
| 21 | alice = admin.value; | |
| 22 | const minted = await fetch(`${servers.url}/api/v1/tokens`, { | |
| 23 | method: "POST", | |
| 24 | headers: { "content-type": "application/json" }, | |
| 25 | body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }), | |
| 26 | }); | |
| 27 | bearer = ((await minted.json()) as { token: string }).token; | |
| 28 | }); | |
| 29 | ||
| 30 | afterAll(async () => { | |
| 31 | servers.stop(); | |
| 32 | app.close(); | |
| 33 | await rm(dataDir, { recursive: true, force: true }); | |
| 34 | }); | |
| 35 | ||
| 36 | const page = async (): Promise<string> => { | |
| 37 | const res = await fetch(`${servers.url}/settings/capacity`, { headers: { authorization: `Bearer ${bearer}` } }); | |
| 38 | expect(res.status).toBe(200); | |
| 39 | const body = await res.text(); | |
| 40 | const at = body.indexOf('class="settings-main"'); | |
| 41 | expect(at).toBeGreaterThan(-1); | |
| 42 | return body.slice(at); | |
| 43 | }; | |
| 44 | ||
| 45 | test("the settings are rows under a Capacity heading, and no part of the page is a card", async () => { | |
| 46 | const main = await page(); | |
| 47 | expect(main).not.toMatch(/class="card[\s"]/); | |
| 48 | expect(main).not.toContain("card-head"); | |
| 49 | for (const title of ["Capacity", "Changes", "Errors and refused calls", "Egress connections", "Self-swap"]) | |
| 50 | expect(main).toContain(`<section class="settings-block"><h2>${title}</h2>`); | |
| 51 | const settings = main.slice(main.indexOf("<h2>Capacity</h2>")); | |
| 52 | expect(settings).toMatch(/^<h2>Capacity<\/h2><ul class="list setting-list">/); | |
| 53 | expect(settings.slice(0, settings.indexOf("</ul>"))).toContain('name="key" value="cells.egress"'); | |
| 54 | // The settings come before what happened. | |
| 55 | expect(main.indexOf("<h2>Capacity</h2>")).toBeLessThan(main.indexOf("<h2>Errors and refused calls</h2>")); | |
| 56 | }); | |
| 57 | ||
| 58 | test("the egress row reads its true current state, and the reported note says so in plain words", async () => { | |
| 59 | const egressRow = (s: string) => { | |
| 60 | const at = s.indexOf('value="cells.egress"'); | |
| 61 | return s.slice(s.lastIndexOf("<li", at), at); | |
| 62 | }; | |
| 63 | const before = await page(); | |
| 64 | expect(before).not.toContain('id="egress-reported"'); | |
| 65 | const now = /Now <span class="mono">([^<]+)<\/span>/.exec(egressRow(before))?.[1]; | |
| 66 | expect(changeCapacity(app, alice, "cells.egress", "report").ok).toBe(true); | |
| 67 | const after = await page(); | |
| 68 | expect(egressRow(after)).toContain('Now <span class="mono">report</span>'); | |
| 69 | expect(now).not.toBe("report"); | |
| 70 | expect(after).toContain('id="egress-reported"'); | |
| 71 | }); |