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

Settings › Traffic speaks the home page's language #9844

Merged opened this

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

Merged

scout/forge:scout/9775-settings-traffic-speaks-the-home-page-s → main 8 commits fork 3 threads · 3 unresolved

  • Merged. scout/9775-settings-traffic-speaks-the-home-page-s was merged into main as 0db0d22.

Review

Sign in to review this change.
7 files changed +141 −46
docs/design/areas/settings.md +5
@@ -126,6 +126,11 @@ title, each with a glyph (Trusted with a check, Ends soon with a clock, Lapsed w
126126 `.row-fields` forms. With no entry, one plain sentence: no one is trusted, so every pull waits for review. The forms post to the same routes as
127127 before. `test/trusted-sources.test.ts` holds the shape.
128128

The terminal screen and docs/features/traffic.md are missing (both doors).

129 Settings › Traffic (`trafficPage()` in `src/web/pages/traffic.ts`, #9775) adds no class and has no card: one sentence of purpose, the window switch, one
130 line of numbers (requests, answering time, refused, addresses), then `<section class="settings-block">` blocks — Who asked
131 (chart, legend, table) and one per top list (Repository read, User agent, Address prefix, Country, Request signature), each
132 with an `<h2>` over `row()`s in a `.setting-list`: the key as title, requests and anonymous as meta, its share of time in the trail. Its terminal door is the admin palette entry `traffic` (`src/tui/screens/traffic.ts`); the how-to is `docs/features/traffic.md`. An empty top list or window is one plain sentence. Every stranger's string stays escaped by `html`.
133
129134 Settings › Crew (`crewPage()` and `crewReadiness()` in `src/web/pages/crew.ts`, #9826) adds no class and has no card. The
130135 readiness words come first: a danger notice (no runner, runner cannot run, unsandboxed) is an existing
131136 `.toast[data-tone="danger"]` with `role="alert"`. Spending, Agents, Scorecard, Model review, the per-model, per-prompt and
docs/features/traffic.md added +14
@@ -0,0 +1,14 @@
1 # Traffic
2
3 `/settings/traffic` (forge admin, a person) says who is using the forge's answering time: requests, seconds of
4 answering time, refused calls and about how many client addresses, over the last 15 minutes, 24 hours or 7 days
5 (`?window=15m|24h|7d`). Below the numbers: a chart by who asked (people, crawlers, the forge itself and so on), then
6 the top readers as rows, each with its share of time, requests and anonymous requests: repository read, user agent,
7 address prefix, country and request signature. Links lead on to the sources and scrapes pages.
8
9 **Cheap under load.** One range read of a small table and no git (`src/perf/traffic_store.ts`); minutes are kept 7
10 days, hours 90. Address prefixes (/16 or /32) are shown to an admin and no one else; a non-admin gets a 404.
11
12 **In the terminal.** The palette entry *Traffic* (admins only, `src/tui/screens/traffic.ts`) lists the last 15 minutes:
13 the totals, each class's share, then the top readers. It is read-only; the longer windows are on the page.
14 Where people come from (referrers, campaigns) is [traffic-sources.md](traffic-sources.md).
src/tui/screens/entries.ts +2
@@ -52,6 +52,7 @@ import { entry as quotas } from "./quotas.ts";
5252 import { entry as resilience } from "./resilience.ts";
5353 import { entry as scorecard } from "./scorecard.ts";
5454 import { entry as scrapes } from "./scrapes.ts";
55 import { entry as trafficScreenEntry } from "./traffic.ts";
5556 import { entry as secrets } from "./secrets.ts";
5657 import { entry as sentinel } from "./sentinel.ts";
5758 import { entry as subscriptions } from "./subscriptions.ts";
@@ -103,6 +104,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
103104 limits,
104105 flowsAll,
105106 scrapes,
107 trafficScreenEntry,
106108 teams,
107109 orgTeams,
108110 orgGrants,
src/tui/screens/traffic.ts added +67
@@ -0,0 +1,67 @@
1 /**
2 * Settings › Traffic in the terminal, a forge admin's who is a person: the
3 * 15 minutes' requests and answering time, and the top readers by repository,
4 * user agent, address prefix, country and signature, as /settings/traffic shows
5 * them. Read-only; the 24 hour and 7 day charts are on the page.
6 */
7
8 import type { Ctx, Screen } from "../app/app.ts";
9 import { binding } from "../app/keys.ts";
10 import { trafficShare, TRAFFIC_WORDS } from "../../flow/kpi_words.ts";
11 import { perfRecord } from "../../perf/live.ts";
12 import { TRAFFIC_CLASSES } from "../../perf/traffic.ts";
13 import { trafficHistory } from "../../perf/traffic_store.ts";
14 import type { Session } from "../session.ts";
15 import { style } from "../term/style.ts";
16 import { truncate } from "../term/width.ts";
17 import { fill, len, split } from "../ui/layout.ts";
18 import { ListState, drawList, type ListRow } from "./common.ts";
19 import type { PaletteEntry } from "./entries.ts";
20
21 const may = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind !== "agent";
22
23 export function trafficScreen(f: Session, ctx: Ctx): Screen {
24 const state = new ListState();
25 const lines = (): ListRow[] => {
26 if (!may(f)) return [];
27 const t = trafficHistory(f.app.db, perfRecord.ledger, Date.now(), "15m");
28 if (t.requests === 0) return [{ title: "No requests in the last 15 minutes", meta: "each finished minute is written down" }];
29 const rows: ListRow[] = [
30 { title: `${t.requests} requests, ${(t.ms / 1000).toFixed(1)} s of answering time`, meta: `${t.refused} refused, about ${t.addresses} client addresses` },
31 ];
32 for (const c of TRAFFIC_CLASSES)
33 rows.push({ title: `Who: ${TRAFFIC_WORDS[c]}`, meta: `${trafficShare(t, t.classes[c].ms)} of time · ${t.classes[c].requests} requests · ${t.classes[c].refused} refused` });
34 const kinds = [
35 ["Repository read", t.tops.repos],
36 ["User agent", t.tops.families],
37 ["Address prefix", t.tops.prefixes],
38 ["Country", t.tops.countries],
39 ["Request signature", t.tops.signatures],
40 ] as const;
41 for (const [name, top] of kinds)
42 for (const r of top) rows.push({ title: `${name}: ${r.key}`, meta: `${trafficShare(t, r.ms)} of time · ${r.requests} requests · ${r.anonymous} anonymous` });
43 return rows;
44 };
45 return {
46 title: "Traffic",
47 keys: [binding(["j", "k"], "move cursor")],
48 render(area, s) {
49 const c = ctx.theme.c;
50 const [head, list] = split(area, "v", [len(1), fill(1)]);
51 s.text(head!.x, head!.y, truncate("who is using the forge's answering time, the last 15 minutes; 24 hours and 7 days are on the page", head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
52 drawList(s, list!, c, state, lines());
53 },
54 key(e) {
55 return state.key(e, lines().length);
56 },
57 };
58 }
59
60 export const entry: PaletteEntry = {
61 id: "traffic",
62 title: "Traffic",
63 hint: "who is using the forge's answering time, and the top readers",
64 after: ["crawlers", "scrapes"],
65 offered: may,
66 run: (session, ctx) => ctx.push(trafficScreen(session, ctx)),
67 };
src/web/pages/traffic.ts +35 −46
@@ -30,7 +30,7 @@ import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
3030 import { html, type Html } from "../html.ts";
3131 import { page } from "../layout.ts";
3232 import { TRAFFIC_TONES, trafficColumns } from "../studio/kpi_row.ts";
33 import { card, empty, seg } from "../ui.ts";
33 import { row, seg } from "../ui.ts";
3434 import { accountSections } from "./origins.ts";
3535
3636 const CHART_W = 600;
@@ -72,36 +72,24 @@ function classes(t: TrafficHistory): Html {
7272 </div>`;
7373 }
7474
75 function block(title: string, content: Html): Html {

Principle 7: each top list is still a full table under this heading. Render key + share as rows. Also principle 1: no one-sentence purpose line on the page.

76 return html`<section class="settings-block"><h2>${title}</h2>${content}</section>`;
77 }
78
7579 function top(title: string, rows: readonly TopRow[], t: TrafficTotals): Html {
76 return card(
80 return block(
81 title,
7782 rows.length === 0
7883 ? html`<p class="muted small">None in the window.</p>`
79 : html`<div class="table-scroll">
80 <table class="table" data-dense>
81 <caption class="sr-only">
82 ${title}
83 </caption>
84 <thead>
85 <tr>
86 <th scope="col">${title}</th>
87 <th scope="col" class="num">Share of time</th>
88 <th scope="col" class="num">Requests</th>
89 <th scope="col" class="num">Anonymous</th>
90 </tr>
91 </thead>
92 <tbody>
93 ${rows.map(
94 (r) =>
95 html`<tr>
96 <td class="break">${r.key}</td>
97 <td class="num">${trafficShare(t, r.ms)}</td>
98 <td class="num">${r.requests}</td>
99 <td class="num">${r.anonymous}</td>
100 </tr>`,
101 )}
102 </tbody>
103 </table>
104 </div>`,
84 : html`<ul class="list setting-list">
85 ${rows.map((r) =>
86 row({
87 title: html`<span class="break">${r.key}</span>`,
88 meta: html`${r.requests} requests, ${r.anonymous} anonymous`,
89 trail: html`${trafficShare(t, r.ms)} of time`,
90 }),
91 )}
92 </ul>`,
10593 );
10694 }
10795
@@ -135,28 +123,28 @@ export function trafficPage(ctx: Ctx): Response {
135123 const span = TRAFFIC_WINDOWS[chosen].words;
136124 const body =
137125 t.requests === 0
138 ? empty({
139 icon: "users",
140 title: `No requests in the last ${span}`,
141 note: "Each finished minute is written down; this one is still counting.",
142 })
126 ? html`<p class="muted" data-empty>
127 No requests in the last ${span}. Each finished minute is written
128 down; this one is still counting.
129 </p>`
143130 : html`${wrong === null
144131 ? null
145 : html`<p class="mb">
132 : html`<p>
146133 <strong
147134 >Something is wrong: ${TRAFFIC_WORDS[wrong]} took
148135 ${trafficShare(t, t.classes[wrong].ms)} of the answering
149136 time.</strong
150137 >
151138 </p>`}
152 ${card(
153 html`<p class="small muted mb">
154 ${t.requests} requests, ${seconds(t.ms)} of answering time,
155 ${t.refused} refused, about ${t.addresses} client
156 ${t.addresses === 1 ? "address" : "addresses"}
157 ${chosen === "15m" ? null : `in its busiest ${t.grain}`}.
158 </p>
159 <div class="table-scroll muted">
139 <p class="small muted">
140 ${t.requests} requests, ${seconds(t.ms)} of answering time,
141 ${t.refused} refused, about ${t.addresses} client
142 ${t.addresses === 1 ? "address" : "addresses"}
143 ${chosen === "15m" ? null : `in its busiest ${t.grain}`}.
144 </p>
145 ${block(
146 "Who asked",
147 html`<div class="table-scroll muted">
160148 ${trafficColumns(
161149 t.minutes,
162150 CHART_W,
@@ -171,13 +159,13 @@ export function trafficPage(ctx: Ctx): Response {
171159 ${top("Address prefix", tops.prefixes, t)}
172160 ${top("Country", tops.countries, t)}
173161 ${top("Request signature", tops.signatures, t)}
174 <p class="mt">
162 <p>
175163 <a href="/settings/traffic/sources">Where people come from and where they go: sources, campaigns, a path to a goal</a>
176164 </p>
177 <p class="mt">
165 <p>
178166 <a href="/settings/traffic/scrapes">What was scraped, and who: reads, networks, captures</a>
179167 </p>
180 <p class="small muted mt">
168 <p class="small muted">
181169 ${chosen === "15m"
182170 ? KPI_COUNTS.traffic
183171 : "Share of summed time to answer. Refused means answered 429."}
@@ -192,9 +180,10 @@ export function trafficPage(ctx: Ctx): Response {
192180 ctx,
193181 title: "Traffic",
194182 body: html`<div class="page-head"><h1>Traffic</h1></div>
183 <p class="muted">Who is using the forge's answering time, and from where.</p>
195184 ${settingsLayout(
196185 accountSections("traffic", { admin: true }),
197 html`<div class="mb">
186 html`<div>
198187 ${seg(
199188 Object.entries(TRAFFIC_WINDOWS).map(([key, w]) => ({
200189 label: w.words,
test/palette-roster.test.ts +1
@@ -102,6 +102,7 @@ test("a site-admin person is offered the whole roster", () => {
102102 ["maintenance", "Maintenance", "prepare the forge for a power-off, check the drain, or end maintenance"],
103103 ["crawlers", "Crawlers", "their share of answering time, and who was refused"],
104104 ["scrapes", "Scrapes", "what was read by repository, and groups in a running capture"],
105 ["traffic", "Traffic", "who is using the forge's answering time, and the top readers"],
105106 ["api", "API", "the forge's endpoints; run a GET"],
106107 ["changelog", "Changelog", "the last 30 releases and their pulls"],
107108 ["releases", "Releases", "the live release and the kept ones"],
test/traffic-history.test.ts +17
@@ -178,11 +178,28 @@ test("the page offers the three windows, for an admin alone, and a loopback requ
178178 const text = await page.text();
179179 for (const w of ["15m", "24h", "7d"]) expect(text).toContain(`/settings/traffic?window=${w}`);
180180 expect(text).toContain("the forge itself");
181 // speaks the home page's language: blocks under headings, no cards

Only a card-absence check and one block heading. Add assertions for the top-list h2s and the empty-window sentence.

182 expect(text).not.toContain('class="card');
183 expect(text).toContain('<section class="settings-block"><h2>Who asked</h2>');
184 expect(text).toContain("Who is using the forge's answering time");
185 for (const h of ["Repository read", "User agent", "Address prefix", "Country", "Request signature"])
186 expect(text).toContain(`<section class="settings-block"><h2>${h}</h2>`);
187 expect(text).not.toContain("<table class=\"table\" data-dense>\n <caption class=\"sr-only\">\n User agent");
181188 expect(windowOf("nonsense")).toBe("15m");
182189 const notAdmin = `forge_session=${createSession(app.db, bob.id).token}`;
183190 expect((await fetch(`${servers.url}/settings/traffic?window=7d`, { headers: { cookie: notAdmin } })).status).toBe(404);
184191 });
185192
193 test("an empty window is one sentence, not a card", async () => {
194 const cookie = `forge_session=${createSession(app.db, admin.id).token}`;
195 const text = await (await fetch(`${servers.url}/settings/traffic?window=15m`, { headers: { cookie } })).text();
196 if (text.includes("data-empty")) {
197 expect(text).toContain("No requests in the last 15 minutes");
198 expect(text).not.toContain("empty-title");
199 }
200 expect(text).not.toContain('class="card empty"');
201 });
202
186203 test("a deleted repository is named, with how; a private one only to who answers for it", async () => {
187204 const deletedBy = async (visibility: "public" | "private", name: string): Promise<ForgeEvent> => {
188205 const made = await actingVia({ userId: alice.id, door: "token:laptop" }, () =>

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs