Public: anyone can read this; the crew can change it.
Settings › Traffic speaks the home page's language #9844
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9775-settings-traffic-speaks-the-home-page-s → main 8 commits fork 3 threads · 3 unresolved
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 | ||
| 126 | 126 | `.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 |
| 127 | 127 | before. `test/trusted-sources.test.ts` holds the shape. |
| 128 | 128 | |
| 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 | ||
| 129 | 134 | Settings › Crew (`crewPage()` and `crewReadiness()` in `src/web/pages/crew.ts`, #9826) adds no class and has no card. The |
| 130 | 135 | readiness words come first: a danger notice (no runner, runner cannot run, unsandboxed) is an existing |
| 131 | 136 | `.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). |
src/tui/screens/entries.ts +2
| @@ -52,6 +52,7 @@ import { entry as quotas } from "./quotas.ts"; | ||
| 52 | 52 | import { entry as resilience } from "./resilience.ts"; |
| 53 | 53 | import { entry as scorecard } from "./scorecard.ts"; |
| 54 | 54 | import { entry as scrapes } from "./scrapes.ts"; |
| 55 | import { entry as trafficScreenEntry } from "./traffic.ts"; | |
| 55 | 56 | import { entry as secrets } from "./secrets.ts"; |
| 56 | 57 | import { entry as sentinel } from "./sentinel.ts"; |
| 57 | 58 | import { entry as subscriptions } from "./subscriptions.ts"; |
| @@ -103,6 +104,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 103 | 104 | limits, |
| 104 | 105 | flowsAll, |
| 105 | 106 | scrapes, |
| 107 | trafficScreenEntry, | |
| 106 | 108 | teams, |
| 107 | 109 | orgTeams, |
| 108 | 110 | 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"; | ||
| 30 | 30 | import { html, type Html } from "../html.ts"; |
| 31 | 31 | import { page } from "../layout.ts"; |
| 32 | 32 | import { TRAFFIC_TONES, trafficColumns } from "../studio/kpi_row.ts"; |
| 33 | import { card, empty, seg } from "../ui.ts"; | |
| 33 | import { row, seg } from "../ui.ts"; | |
| 34 | 34 | import { accountSections } from "./origins.ts"; |
| 35 | 35 | |
| 36 | 36 | const CHART_W = 600; |
| @@ -72,36 +72,24 @@ function classes(t: TrafficHistory): Html { | ||
| 72 | 72 | </div>`; |
| 73 | 73 | } |
| 74 | 74 | |
| 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 | ||
| 75 | 79 | function top(title: string, rows: readonly TopRow[], t: TrafficTotals): Html { |
| 76 | return card( | |
| 80 | return block( | |
| 81 | title, | |
| 77 | 82 | rows.length === 0 |
| 78 | 83 | ? 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>`, | |
| 105 | 93 | ); |
| 106 | 94 | } |
| 107 | 95 | |
| @@ -135,28 +123,28 @@ export function trafficPage(ctx: Ctx): Response { | ||
| 135 | 123 | const span = TRAFFIC_WINDOWS[chosen].words; |
| 136 | 124 | const body = |
| 137 | 125 | 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>` | |
| 143 | 130 | : html`${wrong === null |
| 144 | 131 | ? null |
| 145 | : html`<p class="mb"> | |
| 132 | : html`<p> | |
| 146 | 133 | <strong |
| 147 | 134 | >Something is wrong: ${TRAFFIC_WORDS[wrong]} took |
| 148 | 135 | ${trafficShare(t, t.classes[wrong].ms)} of the answering |
| 149 | 136 | time.</strong |
| 150 | 137 | > |
| 151 | 138 | </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"> | |
| 160 | 148 | ${trafficColumns( |
| 161 | 149 | t.minutes, |
| 162 | 150 | CHART_W, |
| @@ -171,13 +159,13 @@ export function trafficPage(ctx: Ctx): Response { | ||
| 171 | 159 | ${top("Address prefix", tops.prefixes, t)} |
| 172 | 160 | ${top("Country", tops.countries, t)} |
| 173 | 161 | ${top("Request signature", tops.signatures, t)} |
| 174 | <p class="mt"> | |
| 162 | <p> | |
| 175 | 163 | <a href="/settings/traffic/sources">Where people come from and where they go: sources, campaigns, a path to a goal</a> |
| 176 | 164 | </p> |
| 177 | <p class="mt"> | |
| 165 | <p> | |
| 178 | 166 | <a href="/settings/traffic/scrapes">What was scraped, and who: reads, networks, captures</a> |
| 179 | 167 | </p> |
| 180 | <p class="small muted mt"> | |
| 168 | <p class="small muted"> | |
| 181 | 169 | ${chosen === "15m" |
| 182 | 170 | ? KPI_COUNTS.traffic |
| 183 | 171 | : "Share of summed time to answer. Refused means answered 429."} |
| @@ -192,9 +180,10 @@ export function trafficPage(ctx: Ctx): Response { | ||
| 192 | 180 | ctx, |
| 193 | 181 | title: "Traffic", |
| 194 | 182 | 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> | |
| 195 | 184 | ${settingsLayout( |
| 196 | 185 | accountSections("traffic", { admin: true }), |
| 197 | html`<div class="mb"> | |
| 186 | html`<div> | |
| 198 | 187 | ${seg( |
| 199 | 188 | Object.entries(TRAFFIC_WINDOWS).map(([key, w]) => ({ |
| 200 | 189 | label: w.words, |
test/palette-roster.test.ts +1
| @@ -102,6 +102,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 102 | 102 | ["maintenance", "Maintenance", "prepare the forge for a power-off, check the drain, or end maintenance"], |
| 103 | 103 | ["crawlers", "Crawlers", "their share of answering time, and who was refused"], |
| 104 | 104 | ["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"], | |
| 105 | 106 | ["api", "API", "the forge's endpoints; run a GET"], |
| 106 | 107 | ["changelog", "Changelog", "the last 30 releases and their pulls"], |
| 107 | 108 | ["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 | ||
| 178 | 178 | const text = await page.text(); |
| 179 | 179 | for (const w of ["15m", "24h", "7d"]) expect(text).toContain(`/settings/traffic?window=${w}`); |
| 180 | 180 | 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"); | |
| 181 | 188 | expect(windowOf("nonsense")).toBe("15m"); |
| 182 | 189 | const notAdmin = `forge_session=${createSession(app.db, bob.id).token}`; |
| 183 | 190 | expect((await fetch(`${servers.url}/settings/traffic?window=7d`, { headers: { cookie: notAdmin } })).status).toBe(404); |
| 184 | 191 | }); |
| 185 | 192 | |
| 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 | ||
| 186 | 203 | test("a deleted repository is named, with how; a private one only to who answers for it", async () => { |
| 187 | 204 | const deletedBy = async (visibility: "public" | "private", name: string): Promise<ForgeEvent> => { |
| 188 | 205 | const made = await actingVia({ userId: alice.id, door: "token:laptop" }, () => |
The terminal screen and docs/features/traffic.md are missing (both doors).