Public: anyone can read this; the crew can change it.

Settings › Capacity speaks the home page's language #9721

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/9709-settings-capacity-speaks-the-home-page-s → main 2 commits fork

  • Merged. mason/9709-settings-capacity-speaks-the-home-page-s was merged into main as dd75e24.

Review

Sign in to review this change.
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`.
7777 - `.segmented` — a few mutually exclusive states side by side, the one on pressed; each other state is a one-button form.
7878 - `.segmented-on` — the state that is on, in `.segmented`.
7979 - `.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";
1414 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1515 import { notFoundPage, signInFirst } from "../chrome.ts";
1616 import { field, readForm } from "../form.ts";
17 import { html, when } from "../html.ts";
17 import { html, when, type Html } from "../html.ts";
1818 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";
2020 import { usageShell } from "./usage-tabs.ts";
2121
2222 function requireAdmin(ctx: Ctx): Response | undefined {
@@ -67,6 +67,11 @@ function input(key: CapacityKey, value: string) {
6767 />`;
6868 }
6969
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
7075 // GET /settings/capacity
7176 export function capacityPage(ctx: Ctx): Response {
7277 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -88,7 +93,6 @@ export function capacityPage(ctx: Ctx): Response {
8893 const rows = settings.map((s) => {
8994 const low = belowCeiling(ctx.app.db, s.key);
9095 return row({
91 lead: icon("activity"),
9296 title: html`${s.label} <span class="muted small mono">${s.key}</span>${when(
9397 low !== null,
9498 () => html` ${badge(`below ceiling ${low?.ceiling}`, { tone: "attn" })}`,
@@ -130,7 +134,6 @@ export function capacityPage(ctx: Ctx): Response {
130134 });
131135 const history = changes.map((c) =>
132136 row({
133 lead: icon("history"),
134137 title: html`<span class="mono">${c.key}</span>
135138 <span class="mono">${c.from === "" ? "auto" : c.from}</span> →
136139 <span class="mono">${c.to === "" ? "auto" : c.to}</span>`,
@@ -140,9 +143,9 @@ export function capacityPage(ctx: Ctx): Response {
140143 const reported = settings.some(
141144 (s) => s.key === "cells.egress" && s.value === "report",
142145 );
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">
146149 ${errs.lastHour} error${errs.lastHour === 1 ? "" : "s"} in the last
147150 hour; the latest ${errs.latest.length}, newest first.
148151 </p>
@@ -157,13 +160,7 @@ export function capacityPage(ctx: Ctx): Response {
157160 ? html`; the latest was
158161 <span class="mono">${refused.latest.method} ${refused.latest.path}</span>`
159162 : ""}.
160 </p>
161 </div>`,
162 {
163 head: html`${icon("activity")}
164 <h2>Errors and refused calls</h2>`,
165 pad: false,
166 },
163 </p>`,
167164 );
168165 const open = egressSnapshot();
169166 // The bun cache at work (#7546): a warm cache keeps this near zero.
@@ -175,19 +172,17 @@ export function capacityPage(ctx: Ctx): Response {
175172 <span class="mono">${egress.npmTopRepoLastHour.repo}</span>
176173 (<span class="mono">${bytes(egress.npmTopRepoLastHour.bytes)}</span>).`
177174 }`;
178 const connections = card(
175 const connections = block(
176 "Egress connections",
179177 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>
182179 <p class="muted small" id="egress-open">
183180 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 })}
188184 ${open.map((r) =>
189185 row({
190 lead: icon("activity"),
191186 title: r.label === "" ? "(unlabelled)" : r.label,
192187 meta: html`<span class="mono">${r.destination}</span> ·
193188 ${bytes(r.bytesIn)} in · ${bytes(r.bytesOut)} out`,
@@ -195,20 +190,15 @@ export function capacityPage(ctx: Ctx): Response {
195190 }),
196191 )}
197192 </ul>`,
198 {
199 head: html`${icon("activity")}
200 <h2>Egress connections</h2>`,
201 pad: false,
202 },
203193 );
204194 // 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">
207198 ${row({
208199 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"}
212202 · <span class="mono">net.ipv4.tcp_migrate_req</span> reads
213203 <span class="mono">${selfSwap.migrateReq}</span>`,
214204 trail: badge(selfSwap.blocked === null ? "ready" : "blocked", {
@@ -216,53 +206,31 @@ export function capacityPage(ctx: Ctx): Response {
216206 }),
217207 })}
218208 </ul>`,
219 {
220 head: html`${icon("activity")}
221 <h2>Self-swap</h2>`,
222 pad: false,
223 },
224209 );
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(
226217 reported,
227218 () => 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,
229220 preview, terminal and check cell may reach any public host on ports
230221 80 and 443, declared or not. Private, loopback and cluster addresses,
231222 and every other port, stay refused. Set it back to enforce once each
232223 app's reported hosts are in its forge.json.
233224 </p>`,
234225 )}
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}`;
266234 const response = usageShell(ctx, "capacity", body);
267235 notePhase(ctx.request, "render", performance.now() - rendering);
268236 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs