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

Settings › Traffic › Scrapes speaks the home page's language #9823

Merged opened this

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

Merged

mason/forge:mason/9807-settings-traffic-scrapes-speaks-the-home → main 2 commits fork

  • Merged. mason/9807-settings-traffic-scrapes-speaks-the-home was merged into main as 77313cb.

Review

Sign in to review this change.
3 files changed +145 −43
docs/design/areas/settings.md +8
@@ -96,6 +96,14 @@ confirm/refusal notices are plain `<section class="settings-block">` blocks with
9696
9797 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`.
9898
99 Settings › Traffic › Scrapes (`scrapesPage()` in `src/web/pages/scrapes.ts`, #9807) adds no class and has no card: under
100 the day switch, What was read, Who it is, One actor across addresses and Networks on file are each a
101 `<section class="settings-block">` with an `<h2>`. Each repository read is an `<h3>` over its counts and path lists; a
102 picked capture group is an `<h3 id="group">` over its reasons, export button and requests table, inside the One actor
103 block. The prefix, group and request tables keep their `.table-scroll` box, `sr-only` caption and `data-dense`; the
104 capture and networks forms sit straight in their blocks. An empty part (no reads in the window, no prefixes, no groups
105 yet) is one plain sentence. Pinned by `test/scrapes-blocks.test.ts`.
106
99107 Settings › Trusted (`trustedPage()` in `src/web/pages/trusted.ts`, #9792) adds no component class. As on the home page it is
100108 headings over rows: Fold-in, Trusted sources and Trust a source are each a `<section class="settings-block">` with an
101109 `<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/scrapes.ts +42 −43
@@ -19,7 +19,7 @@ import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
1919 import { field, readForm } from "../form.ts";
2020 import { html, type Html } from "../html.ts";
2121 import { flashTo, page } from "../layout.ts";
22 import { badge, btn, card, empty, seg } from "../ui.ts";
22 import { badge, btn, seg } from "../ui.ts";
2323 import { accountSections } from "./origins.ts";
2424
2525 const DAYS = [1, 3, 7] as const;
@@ -51,9 +51,9 @@ function whose(n: Network | null): Html {
5151
5252 function readsCard(r: RepoReads, from: number): Html {
5353 const commits = r.commits.filter((c) => isCommit(c.ref));
54 return card(
55 html`<p class="small">
56 <strong class="break">${r.repo}</strong>: ${r.requests} reads, ${r.anonymous} anonymous,
54 return html`<h3 class="break">${r.repo}</h3>
55 <p class="small">
56 ${r.requests} reads, ${r.anonymous} anonymous,
5757 ${r.paths} distinct paths.
5858 ${r.coverage === undefined || r.coverage === null
5959 ? html`<span class="muted">Coverage unknown.</span>`
@@ -76,29 +76,24 @@ function readsCard(r: RepoReads, from: number): Html {
7676 ${commits.length === 0
7777 ? null
7878 : html`<p class="small muted">Commits read:
79 ${commits.map((c) => html`<code>${c.ref.slice(0, 12)}</code> ${c.requests} `)}</p>`}`,
80 );
79 ${commits.map((c) => html`<code>${c.ref.slice(0, 12)}</code> ${c.requests} `)}</p>`}`;
8180 }
8281
8382 function captureCard(now: number): Html {
8483 const c = captureStore.current(now);
8584 if (c === null)
86 return card(
87 html`<p class="small">
85 return html`<p class="small">
8886 A capture keeps full addresses and the shape of requests so one actor can be told across addresses.
8987 It lasts ${CAPTURE_MS / 60_000} minutes or ${CAPTURE_MAX} requests, keeps no cookies, tokens or
9088 query strings, skips signed-in people, and is deleted when it ends.
9189 </p>
92 <form method="post" action="${BASE}/capture" class="inline">${btn("Start a capture", { variant: "primary" })}</form>`,
93 );
94 return card(
95 html`<p class="small">
90 <form method="post" action="${BASE}/capture" class="inline">${btn("Start a capture", { variant: "primary" })}</form>`;
91 return html`<p class="small">
9692 Capture <code>${c.id}</code> is running: ${c.entries.length} requests kept${c.full ? " (full: later requests are not kept)" : ""},
9793 ends in ${Math.max(0, Math.ceil((c.expiresAt - now) / 60_000))} min and is then deleted.
9894 <a href="${BASE}">Refresh</a>
9995 </p>
100 <form method="post" action="${BASE}/stop" class="inline">${btn("Stop and delete it", { size: "sm" })}</form>`,
101 );
96 <form method="post" action="${BASE}/stop" class="inline">${btn("Stop and delete it", { size: "sm" })}</form>`;
10297 }
10398
10499 function groupRow(g: Group, net: NetworkTable): Html {
@@ -114,8 +109,7 @@ function groupRow(g: Group, net: NetworkTable): Html {
114109 }
115110
116111 function groupDetail(g: Group, net: NetworkTable): Html {
117 return card(
118 html`<h2 id="group">Group ${g.id}</h2>
112 return html`<h3 id="group">Group ${g.id}</h3>
119113 <p class="small">${g.size} requests from ${g.addresses.length} addresses, confidence ${g.confidence}.</p>
120114 <ul class="small">${g.reasons.map((r) => html`<li>${r}</li>`)}</ul>
121115 <p class="small muted">${LIMITS}</p>
@@ -136,8 +130,7 @@ function groupDetail(g: Group, net: NetworkTable): Html {
136130 </tbody>
137131 </table>
138132 </div>
139 ${g.size > SHOWN_REQUESTS ? html`<p class="small muted">First ${SHOWN_REQUESTS} of ${g.size} shown.</p>` : null}`,
140 );
133 ${g.size > SHOWN_REQUESTS ? html`<p class="small muted">First ${SHOWN_REQUESTS} of ${g.size} shown.</p>` : null}`;
141134 }
142135
143136 export async function scrapesPage(ctx: Ctx): Promise<Response> {
@@ -168,41 +161,47 @@ export async function scrapesPage(ctx: Ctx): Promise<Response> {
168161 const body = html`<div class="mb">
169162 ${seg(DAYS.map((n) => ({ label: n === 1 ? "1 day" : `${n} days`, href: `${BASE}?days=${n}`, current: n === d })))}
170163 </div>
171 <h2>What was read</h2>
172 ${reports.length === 0
173 ? empty({ icon: "users", title: "No file reads recorded in this window", note: `Reads are kept up to ${READ_DAYS_MAX} days.` })
174 : reports.map((r) => readsCard(r, d))}
175 <h2>Who it is</h2>
176 ${card(
177 prefixes.length === 0
178 ? html`<p class="small muted">No address prefixes in the last 24 hours.</p>`
164 <section class="settings-block">
165 <h2>What was read</h2>
166 ${reports.length === 0
167 ? html`<p class="small">No file reads recorded in this window; reads are kept up to ${READ_DAYS_MAX} days.</p>`
168 : reports.map((r) => readsCard(r, d))}
169 </section>
170 <section class="settings-block">
171 <h2>Who it is</h2>
172 ${prefixes.length === 0
173 ? html`<p class="small">No address prefixes in the last 24 hours.</p>`
179174 : html`<div class="table-scroll"><table class="table" data-dense>
180175 <caption class="sr-only">Address prefixes and their networks</caption>
181176 <thead><tr><th scope="col">Prefix</th><th scope="col" class="num">Requests</th><th scope="col">Network (from the forge's own table)</th><th scope="col">Abuse contact</th></tr></thead>
182177 <tbody>${prefixes.map((p) => {
183178 const n = net.find(p.key);
184179 return html`<tr><td class="break">${p.key}</td><td class="num">${p.requests}</td><td class="small">${whose(n)}</td><td class="break small">${n?.abuse || "—"}</td></tr>`;
185 })}</tbody></table></div>`,
186 )}
187 <h2>One actor across addresses</h2>
188 ${captureCard(now)}
189 ${cap === null
190 ? null
191 : groups.length === 0
192 ? html`<p class="small muted">No group of three or more similar requests yet.</p>`
193 : card(html`<div class="table-scroll"><table class="table" data-dense>
194 <caption class="sr-only">Groups of requests that look alike</caption>
195 <thead><tr><th scope="col">User agent</th><th scope="col" class="num">Requests</th><th scope="col" class="num">Rate</th><th scope="col" class="num">Addresses</th><th scope="col">Confidence</th><th scope="col">Networks</th><th scope="col"></th></tr></thead>
196 <tbody>${groups.slice(0, 20).map((g) => groupRow(g, net))}</tbody></table></div>
197 <p class="small muted">Groups share a user agent and the names and order of their headers. ${LIMITS}</p>`)}
198 ${picked === undefined ? null : groupDetail(picked, net)}
199 <h2>Networks on file</h2>
200 ${card(html`<p class="small">${net.size} ranges. Lookups are local: no visitor address is sent to anyone. Paste lines of
180 })}</tbody></table></div>`}
181 </section>
182 <section class="settings-block">
183 <h2>One actor across addresses</h2>
184 ${captureCard(now)}
185 ${cap === null
186 ? null
187 : groups.length === 0
188 ? html`<p class="small">No group of three or more similar requests yet.</p>`
189 : html`<div class="table-scroll"><table class="table" data-dense>
190 <caption class="sr-only">Groups of requests that look alike</caption>
191 <thead><tr><th scope="col">User agent</th><th scope="col" class="num">Requests</th><th scope="col" class="num">Rate</th><th scope="col" class="num">Addresses</th><th scope="col">Confidence</th><th scope="col">Networks</th><th scope="col"></th></tr></thead>
192 <tbody>${groups.slice(0, 20).map((g) => groupRow(g, net))}</tbody></table></div>
193 <p class="small muted">Groups share a user agent and the names and order of their headers. ${LIMITS}</p>`}
194 ${picked === undefined ? null : groupDetail(picked, net)}
195 </section>
196 <section class="settings-block">
197 <h2>Networks on file</h2>
198 <p class="small">${net.size} ranges. Lookups are local: no visitor address is sent to anyone. Paste lines of
201199 <code>range, name, operator, country, abuse contact</code> from a registry you looked up yourself.</p>
202200 <form method="post" action="${BASE}/networks">
203201 <textarea name="lines" rows="4" maxlength="200000" aria-label="Network ranges" placeholder="203.0.0.0/16, Example Hosting, Example Ltd, NL, [email protected]"></textarea>
204202 ${btn("Save networks", { size: "sm" })}
205 </form>`)}`;
203 </form>
204 </section>`;
206205 notePhase(ctx.request, "render", performance.now() - rendering);
207206 return page({
208207 ctx,
test/scrapes-blocks.test.ts added +95
@@ -0,0 +1,95 @@
1 /** Settings › Traffic › Scrapes is headings over rows, not cards (#9807). */
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 { createSession } from "../src/core/sessions.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { captureStore } from "../src/perf/live.ts";
11 import { parseNetworks, saveNetworks } from "../src/perf/network.ts";
12
13 let dataDir = "";
14 let app: App;
15 let servers: Servers;
16 let cookie = "";
17 const url = () => `${servers.url}/settings/traffic/scrapes`;
18 const main = async (query = ""): Promise<string> => {
19 const text = await (await fetch(`${url()}${query}`, { headers: { cookie } })).text();
20 return text.slice(text.indexOf("<main"), text.indexOf("</main>"));
21 };
22
23 beforeAll(async () => {
24 dataDir = await mkdtemp(join(tmpdir(), "forge-scrapes-blocks-"));
25 app = createApp({ dataDir, port: 0 });
26 servers = await serve(app);
27 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "keeper", email: "[email protected]", password: "correct-horse-battery", isAdmin: true });
28 if (!made.ok) throw new Error(made.error);
29 cookie = `forge_session=${createSession(app.db, made.value.id).token}`;
30 });
31 afterAll(async () => {
32 captureStore.stop();
33 servers.stop();
34 app.close();
35 await rm(dataDir, { recursive: true, force: true });
36 });
37
38 const HEADINGS = ["What was read", "Who it is", "One actor across addresses", "Networks on file"];
39
40 /** The `<section>` that holds `<h2>name</h2>`, from its opening tag to its close. */
41 function block(html: string, name: string): string {
42 const at = html.indexOf(`<h2>${name}</h2>`);
43 expect(at).toBeGreaterThan(-1);
44 const open = html.lastIndexOf("<section", at);
45 expect(html.slice(open, at)).toMatch(/^<section class="settings-block">\s*$/);
46 return html.slice(open, html.indexOf("</section>", at));
47 }
48
49 test("with nothing recorded, each part is a headed block and each empty state one plain sentence", async () => {
50 const html = await main();
51 for (const h of HEADINGS) block(html, h);
52 expect(block(html, "What was read")).toContain("No file reads recorded in this window");
53 expect(block(html, "Who it is")).toContain("No address prefixes in the last 24 hours.");
54 for (const boxed of ['class="card', "card-head", "card-body", 'class="empty']) expect(html).not.toContain(boxed);
55 });
56
57 test("an admin with one capture group and one network on file sees four headed blocks and no card", async () => {
58 const got = parseNetworks("203.0.0.0/16, <b>Evil</b> Hosting, Op, DE, [email protected]");
59 saveNetworks(app.db, got.rows, "keeper", Date.now());
60 captureStore.start(Date.now(), "keeper");
61 for (let i = 0; i < 6; i++)
62 captureStore.note(Date.now(), {
63 request: new Request(`http://forge.test/alice/open/blob/main/f${i}.ts`, { headers: { "user-agent": "<i>ScrapeBot</i>" } }),
64 address: `203.0.113.${i}`,
65 viaEdge: true,
66 status: 200,
67 ms: 5,
68 cls: "deep",
69 pattern: "/:owner/:name/blob/:ref/*",
70 });
71 const html = await main();
72 for (const h of HEADINGS) block(html, h);
73 const actors = block(html, "One actor across addresses");
74 expect(actors).toContain('<div class="table-scroll"><table class="table" data-dense>');
75 expect(actors).toContain("Groups of requests that look alike");
76 expect(actors).toContain("Stop and delete it");
77 expect(actors).toContain(`action="/settings/traffic/scrapes/stop"`);
78 expect(actors).toContain("&lt;i&gt;ScrapeBot&lt;/i&gt;");
79 expect(actors).not.toContain("<i>ScrapeBot</i>");
80 const networks = block(html, "Networks on file");
81 expect(networks).toContain("1 ranges.");
82 expect(networks).toContain(`action="/settings/traffic/scrapes/networks"`);
83 expect(networks).toContain('maxlength="200000"');
84 expect(html).not.toContain('class="card');
85
86 const id = /group=([0-9a-f]{8})/.exec(html)?.[1];
87 expect(id).toBeDefined();
88 const detail = await main(`?group=${id}`);
89 const picked = block(detail, "One actor across addresses");
90 expect(picked).toContain(`<h3 id="group">Group ${id}</h3>`);
91 expect(picked).toContain(`Requests behind group ${id}`);
92 expect(picked).toContain("&lt;b&gt;Evil&lt;/b&gt;");
93 expect(detail).not.toContain("<b>Evil</b>");
94 expect(detail).not.toContain('class="card');
95 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs