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

Settings › Telemetry speaks the home page's language #9810

Merged opened this

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

Merged

mason/forge:mason/9806-settings-telemetry-speaks-the-home-page → main 3 commits fork

  • Merged. mason/9806-settings-telemetry-speaks-the-home-page was merged into main as c72432f.

Review

Sign in to review this change.
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
9595 (Sign-ins, Verify, Suspend, Remove) in the row's `after` slot; the mail, ownerless-organization, sign-in reach and
9696 confirm/refusal notices are plain `<section class="settings-block">` blocks with an `<h2>`. `test/accounts-rows.test.ts` holds the shape.
9797
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
98100 Settings › Trusted (`trustedPage()` in `src/web/pages/trusted.ts`, #9792) adds no component class. As on the home page it is
99101 headings over rows: Fold-in, Trusted sources and Trust a source are each a `<section class="settings-block">` with an
100102 `<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";
1010 import { field, readForm } from "../form.ts";
1111 import { html } from "../html.ts";
1212 import { flashTo } from "../layout.ts";
13 import { btn, card, icon, row, time } from "../ui.ts";
13 import { btn, icon, row, time } from "../ui.ts";
1414 import { usageShell } from "./usage-tabs.ts";
1515
1616 function owner(ctx: Ctx): Response | undefined {
@@ -26,17 +26,18 @@ export function telemetryPage(ctx: Ctx): Response {
2626 const agents = timePhase(ctx.request, "agents", () => grantable(ctx.app.db));
2727 const reads = timePhase(ctx.request, "reads", () => recentReads(ctx.app.db));
2828 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">
3132 A granted agent reads <a href="/boards">Boards</a>, <a href="/system">System</a> and the
3233 boards' sources through the API and MCP — counts and rates, with
3334 private repositories and sealed values cut out. Revoke at any time;
3435 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) =>
4041 row({
4142 lead: icon("bot"),
4243 title: html`${a.username} <span class="muted small mono">telemetry:read</span>`,
@@ -52,17 +53,16 @@ export function telemetryPage(ctx: Ctx): Response {
5253 </form>`,
5354 }),
5455 )}
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>`
6162 : html`<ul class="list">${reads.map((r) =>
6263 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>`;
6666 const response = usageShell(ctx, "telemetry", body);
6767 notePhase(ctx.request, "render", performance.now() - rendering);
6868 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/&lt;flow&gt;");
80 expect(readsBlock).not.toContain("<flow>");
81 for (const boxed of ['class="card', "card-head", "card-body"]) expect(main).not.toContain(boxed);
82 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs