Public: anyone can read this; the crew can change it.
Settings › Telemetry speaks the home page's language #9810
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9806-settings-telemetry-speaks-the-home-page → main 3 commits fork
3 files
changed +101
−17
docs/design/areas/settings.md +2
| @@ -95,6 +95,8 @@ accounts are `row()`s in `<ul class="list setting-list" id="accounts-list">` und | ||
| 95 | 95 | (Sign-ins, Verify, Suspend, Remove) in the row's `after` slot; the mail, ownerless-organization, sign-in reach and |
| 96 | 96 | confirm/refusal notices are plain `<section class="settings-block">` blocks with an `<h2>`. `test/accounts-rows.test.ts` holds the shape. |
| 97 | 97 | |
| 98 | Settings › Telemetry (`telemetryPage()` in `src/web/pages/telemetry.ts`, #9806) adds no class: two `<section class="settings-block">` blocks, Telemetry grants (one plain paragraph, then each agent a `row()` with its Grant/Revoke form in a `.setting-list`) and Reads; an empty list is one plain sentence. Pinned by `test/telemetry-blocks.test.ts`. | |
| 99 | ||
| 98 | 100 | Settings › Trusted (`trustedPage()` in `src/web/pages/trusted.ts`, #9792) adds no component class. As on the home page it is |
| 99 | 101 | headings over rows: Fold-in, Trusted sources and Trust a source are each a `<section class="settings-block">` with an |
| 100 | 102 | `<h2>`. Each trusted entry is a `row()` in `<ul class="list setting-list" id="trusted-list">`; its state is a badge in the |
src/web/pages/telemetry.ts +17 −17
| @@ -10,7 +10,7 @@ import { notFoundPage, signInFirst } from "../chrome.ts"; | ||
| 10 | 10 | import { field, readForm } from "../form.ts"; |
| 11 | 11 | import { html } from "../html.ts"; |
| 12 | 12 | import { flashTo } from "../layout.ts"; |
| 13 | import { btn, card, icon, row, time } from "../ui.ts"; | |
| 13 | import { btn, icon, row, time } from "../ui.ts"; | |
| 14 | 14 | import { usageShell } from "./usage-tabs.ts"; |
| 15 | 15 | |
| 16 | 16 | function owner(ctx: Ctx): Response | undefined { |
| @@ -26,17 +26,18 @@ export function telemetryPage(ctx: Ctx): Response { | ||
| 26 | 26 | const agents = timePhase(ctx.request, "agents", () => grantable(ctx.app.db)); |
| 27 | 27 | const reads = timePhase(ctx.request, "reads", () => recentReads(ctx.app.db)); |
| 28 | 28 | const rendering = performance.now(); |
| 29 | const body = html`${card( | |
| 30 | html`<div class="card-body"><p class="muted small"> | |
| 29 | const body = html`<section class="settings-block"> | |
| 30 | <h2>Telemetry grants</h2> | |
| 31 | <p class="muted small"> | |
| 31 | 32 | A granted agent reads <a href="/boards">Boards</a>, <a href="/system">System</a> and the |
| 32 | 33 | boards' sources through the API and MCP — counts and rates, with |
| 33 | 34 | private repositories and sealed values cut out. Revoke at any time; |
| 34 | 35 | every read is listed below. |
| 35 | </p></div> | |
| 36 | <ul class="list setting-list"> | |
| 37 | ${agents.length === 0 | |
| 38 | ? html`<li class="muted small">No agent accounts yet.</li>` | |
| 39 | : agents.map((a) => | |
| 36 | </p> | |
| 37 | ${agents.length === 0 | |
| 38 | ? html`<p class="small">No agent accounts yet.</p>` | |
| 39 | : html`<ul class="list setting-list"> | |
| 40 | ${agents.map((a) => | |
| 40 | 41 | row({ |
| 41 | 42 | lead: icon("bot"), |
| 42 | 43 | title: html`${a.username} <span class="muted small mono">telemetry:read</span>`, |
| @@ -52,17 +53,16 @@ export function telemetryPage(ctx: Ctx): Response { | ||
| 52 | 53 | </form>`, |
| 53 | 54 | }), |
| 54 | 55 | )} |
| 55 | </ul>`, | |
| 56 | { head: html`${icon("activity")}<h2>Telemetry grants</h2>`, pad: false }, | |
| 57 | )} | |
| 58 | ${card( | |
| 59 | reads.length === 0 | |
| 60 | ? html`<div class="card-body"><p class="muted small">No agent has read anything yet.</p></div>` | |
| 56 | </ul>`} | |
| 57 | </section> | |
| 58 | <section class="settings-block"> | |
| 59 | <h2>Reads</h2> | |
| 60 | ${reads.length === 0 | |
| 61 | ? html`<p class="small">No agent has read anything yet.</p>` | |
| 61 | 62 | : html`<ul class="list">${reads.map((r) => |
| 62 | 63 | row({ lead: icon("history"), title: html`${r.username} <span class="mono">${r.what}</span>`, meta: time(r.at) }), |
| 63 | )}</ul>`, | |
| 64 | { head: html`${icon("history")}<h2>Reads</h2>`, pad: false }, | |
| 65 | )}`; | |
| 64 | )}</ul>`} | |
| 65 | </section>`; | |
| 66 | 66 | const response = usageShell(ctx, "telemetry", body); |
| 67 | 67 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 68 | 68 | return response; |
test/telemetry-blocks.test.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | /** Settings › Telemetry is headings over rows, not cards (#9806). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createAgent } from "../src/actions/crew.ts"; | |
| 7 | import { setTelemetryGrant } from "../src/actions/telemetry.ts"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { logRead } from "../src/core/telemetry.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | ||
| 16 | let root = ""; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | let alice: User; | |
| 20 | let cookie = ""; | |
| 21 | ||
| 22 | async function page(): Promise<string> { | |
| 23 | const html = await (await fetch(`${servers.url}/settings/telemetry`, { headers: { cookie } })).text(); | |
| 24 | return html.slice(html.indexOf("<main"), html.indexOf("</main>")); | |
| 25 | } | |
| 26 | ||
| 27 | beforeAll(async () => { | |
| 28 | root = mkdtempSync(join(tmpdir(), "forge-telemetry-blocks-")); | |
| 29 | app = createApp({ dataDir: root, port: 0 }); | |
| 30 | const u = await createUser(app.db, { | |
| 31 | origin: "seed", | |
| 32 | trusted: true, | |
| 33 | username: "alice", | |
| 34 | email: "[email protected]", | |
| 35 | password: "a-good-passphrase", | |
| 36 | adminIfFirst: true, | |
| 37 | }); | |
| 38 | if (!u.ok) throw new Error(u.error); | |
| 39 | alice = u.value; | |
| 40 | servers = await serve(app); | |
| 41 | const signin = await fetch(`${servers.url}/signin`, { | |
| 42 | method: "POST", | |
| 43 | headers: { origin: servers.url }, | |
| 44 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 45 | redirect: "manual", | |
| 46 | }); | |
| 47 | cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 48 | }); | |
| 49 | afterAll(() => { | |
| 50 | servers.stop(); | |
| 51 | app.close(); | |
| 52 | rmSync(root, { recursive: true, force: true }); | |
| 53 | }); | |
| 54 | ||
| 55 | test("with no agents and no reads, each heading has one plain sentence and no box", async () => { | |
| 56 | const main = await page(); | |
| 57 | for (const h of ["Telemetry grants", "Reads"]) expect(main).toContain(`<h2>${h}</h2>`); | |
| 58 | expect(main).toContain("No agent accounts yet."); | |
| 59 | expect(main).toContain("No agent has read anything yet."); | |
| 60 | for (const boxed of ['class="card', "card-head", "card-body", 'class="empty']) expect(main).not.toContain(boxed); | |
| 61 | }); | |
| 62 | ||
| 63 | test("one granted agent and one read: two headed sections, a Revoke row, no card", async () => { | |
| 64 | const made = await createAgent(app, alice, { username: "muse" }); | |
| 65 | if (!made.ok) throw new Error(String(made.error)); | |
| 66 | expect(setTelemetryGrant(app, alice, "muse", true).ok).toBe(true); | |
| 67 | logRead(app.db, made.value.user.id, "/api/v1/telemetry/<flow>"); | |
| 68 | const main = await page(); | |
| 69 | const grants = main.slice(main.indexOf("<h2>Telemetry grants</h2>")); | |
| 70 | const grantsBlock = grants.slice(0, grants.indexOf("</section>")); | |
| 71 | expect(main.lastIndexOf('<section class="settings-block">', main.indexOf("<h2>Telemetry grants</h2>"))).toBeGreaterThan(-1); | |
| 72 | expect(grantsBlock).toContain('<ul class="list setting-list">'); | |
| 73 | expect(grantsBlock).toContain("Revoke"); | |
| 74 | expect(grantsBlock).toContain('action="/settings/telemetry"'); | |
| 75 | const reads = main.slice(main.indexOf("<h2>Reads</h2>")); | |
| 76 | const readsBlock = reads.slice(0, reads.indexOf("</section>")); | |
| 77 | expect(main.indexOf("<h2>Reads</h2>")).toBeGreaterThan(-1); | |
| 78 | expect(readsBlock).toContain("muse"); | |
| 79 | expect(readsBlock).toContain("/api/v1/telemetry/<flow>"); | |
| 80 | expect(readsBlock).not.toContain("<flow>"); | |
| 81 | for (const boxed of ['class="card', "card-head", "card-body"]) expect(main).not.toContain(boxed); | |
| 82 | }); |