Public: anyone can read this; the crew can change it.
Settings › Traffic › Scrapes speaks the home page's language #9823
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9807-settings-traffic-scrapes-speaks-the-home → main 2 commits fork
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 | ||
| 96 | 96 | |
| 97 | 97 | 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`. |
| 98 | 98 | |
| 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 | ||
| 99 | 107 | Settings › Trusted (`trustedPage()` in `src/web/pages/trusted.ts`, #9792) adds no component class. As on the home page it is |
| 100 | 108 | headings over rows: Fold-in, Trusted sources and Trust a source are each a `<section class="settings-block">` with an |
| 101 | 109 | `<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"; | ||
| 19 | 19 | import { field, readForm } from "../form.ts"; |
| 20 | 20 | import { html, type Html } from "../html.ts"; |
| 21 | 21 | import { flashTo, page } from "../layout.ts"; |
| 22 | import { badge, btn, card, empty, seg } from "../ui.ts"; | |
| 22 | import { badge, btn, seg } from "../ui.ts"; | |
| 23 | 23 | import { accountSections } from "./origins.ts"; |
| 24 | 24 | |
| 25 | 25 | const DAYS = [1, 3, 7] as const; |
| @@ -51,9 +51,9 @@ function whose(n: Network | null): Html { | ||
| 51 | 51 | |
| 52 | 52 | function readsCard(r: RepoReads, from: number): Html { |
| 53 | 53 | 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, | |
| 57 | 57 | ${r.paths} distinct paths. |
| 58 | 58 | ${r.coverage === undefined || r.coverage === null |
| 59 | 59 | ? html`<span class="muted">Coverage unknown.</span>` |
| @@ -76,29 +76,24 @@ function readsCard(r: RepoReads, from: number): Html { | ||
| 76 | 76 | ${commits.length === 0 |
| 77 | 77 | ? null |
| 78 | 78 | : 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>`}`; | |
| 81 | 80 | } |
| 82 | 81 | |
| 83 | 82 | function captureCard(now: number): Html { |
| 84 | 83 | const c = captureStore.current(now); |
| 85 | 84 | if (c === null) |
| 86 | return card( | |
| 87 | html`<p class="small"> | |
| 85 | return html`<p class="small"> | |
| 88 | 86 | A capture keeps full addresses and the shape of requests so one actor can be told across addresses. |
| 89 | 87 | It lasts ${CAPTURE_MS / 60_000} minutes or ${CAPTURE_MAX} requests, keeps no cookies, tokens or |
| 90 | 88 | query strings, skips signed-in people, and is deleted when it ends. |
| 91 | 89 | </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"> | |
| 96 | 92 | Capture <code>${c.id}</code> is running: ${c.entries.length} requests kept${c.full ? " (full: later requests are not kept)" : ""}, |
| 97 | 93 | ends in ${Math.max(0, Math.ceil((c.expiresAt - now) / 60_000))} min and is then deleted. |
| 98 | 94 | <a href="${BASE}">Refresh</a> |
| 99 | 95 | </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>`; | |
| 102 | 97 | } |
| 103 | 98 | |
| 104 | 99 | function groupRow(g: Group, net: NetworkTable): Html { |
| @@ -114,8 +109,7 @@ function groupRow(g: Group, net: NetworkTable): Html { | ||
| 114 | 109 | } |
| 115 | 110 | |
| 116 | 111 | 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> | |
| 119 | 113 | <p class="small">${g.size} requests from ${g.addresses.length} addresses, confidence ${g.confidence}.</p> |
| 120 | 114 | <ul class="small">${g.reasons.map((r) => html`<li>${r}</li>`)}</ul> |
| 121 | 115 | <p class="small muted">${LIMITS}</p> |
| @@ -136,8 +130,7 @@ function groupDetail(g: Group, net: NetworkTable): Html { | ||
| 136 | 130 | </tbody> |
| 137 | 131 | </table> |
| 138 | 132 | </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}`; | |
| 141 | 134 | } |
| 142 | 135 | |
| 143 | 136 | export async function scrapesPage(ctx: Ctx): Promise<Response> { |
| @@ -168,41 +161,47 @@ export async function scrapesPage(ctx: Ctx): Promise<Response> { | ||
| 168 | 161 | const body = html`<div class="mb"> |
| 169 | 162 | ${seg(DAYS.map((n) => ({ label: n === 1 ? "1 day" : `${n} days`, href: `${BASE}?days=${n}`, current: n === d })))} |
| 170 | 163 | </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>` | |
| 179 | 174 | : html`<div class="table-scroll"><table class="table" data-dense> |
| 180 | 175 | <caption class="sr-only">Address prefixes and their networks</caption> |
| 181 | 176 | <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> |
| 182 | 177 | <tbody>${prefixes.map((p) => { |
| 183 | 178 | const n = net.find(p.key); |
| 184 | 179 | 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 | |
| 201 | 199 | <code>range, name, operator, country, abuse contact</code> from a registry you looked up yourself.</p> |
| 202 | 200 | <form method="post" action="${BASE}/networks"> |
| 203 | 201 | <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> |
| 204 | 202 | ${btn("Save networks", { size: "sm" })} |
| 205 | </form>`)}`; | |
| 203 | </form> | |
| 204 | </section>`; | |
| 206 | 205 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 207 | 206 | return page({ |
| 208 | 207 | 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("<i>ScrapeBot</i>"); | |
| 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("<b>Evil</b>"); | |
| 93 | expect(detail).not.toContain("<b>Evil</b>"); | |
| 94 | expect(detail).not.toContain('class="card'); | |
| 95 | }); |