Public: anyone can read this; the crew can change it.
The API's board panel read names its Server-Timing phases: session, board, query, render #3928
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3920-the-api-s-board-panel-read-names-its-ser → main 2 commits fork 1 thread · 1 unresolved
2 files
changed +99
−13
src/http/api.ts +20 −13
| @@ -1558,9 +1558,12 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1558 | 1558 | // board from 0; `from`, `to` and `step` (300, 5m, 1h) the window; |
| 1559 | 1559 | // `format=csv` the same rows as a table file. |
| 1560 | 1560 | router.add("GET", "/api/v1/boards/:slug/panels/:id", async (ctx) => { |
| 1561 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 1561 | 1562 | const denied = boardsAdmin(ctx); |
| 1562 | 1563 | if (denied) return denied; |
| 1563 | const shown = await showBoard(ctx.app, ctx.auth.user, ctx.params.slug!); | |
| 1564 | const shown = await timePhaseAsync(ctx.request, "board", () => | |
| 1565 | showBoard(ctx.app, ctx.auth.user, ctx.params.slug!), | |
| 1566 | ); | |
| 1564 | 1567 | const id = ctx.params.id!; |
| 1565 | 1568 | const panel = shown.ok && /^\d{1,2}$/.test(id) ? shown.value.panels[Number(id)] : undefined; |
| 1566 | 1569 | if (!shown.ok || !panel) return notFound(); |
| @@ -1568,20 +1571,24 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1568 | 1571 | if (format !== "json" && format !== "csv") return apiError("invalid_format", 422, "format must be json or csv"); |
| 1569 | 1572 | const window = parseWindow(ctx.url.searchParams); |
| 1570 | 1573 | if (!window.ok) return apiError("invalid_window", 422, window.error); |
| 1571 | const data = panelData(ctx.app.db, shown.value, panel, window.window); | |
| 1574 | const data = timePhase(ctx.request, "query", () => | |
| 1575 | panelData(ctx.app.db, shown.value, panel, window.window), | |
| 1576 | ); | |
| 1572 | 1577 | if (!data.ok) return apiError("bad_query", 400, data.error); |
| 1573 | 1578 | const headers = { "cache-control": "private, no-store" }; |
| 1574 | if (format === "csv") { | |
| 1575 | return new Response(rowsCsv(data.rows), { | |
| 1576 | headers: { | |
| 1577 | ...headers, | |
| 1578 | "content-type": "text/csv; charset=utf-8", | |
| 1579 | "content-disposition": `attachment; filename="${ctx.params.slug}-${id}.csv"`, | |
| 1580 | }, | |
| 1581 | }); | |
| 1582 | } | |
| 1583 | const { ok: _, ...out } = data; | |
| 1584 | return json({ board: ctx.params.slug, panel: Number(id), title: panel.title, chart: panel.chart, ...out }, 200, headers); | |
| 1579 | return timePhase(ctx.request, "render", () => { | |
| 1580 | if (format === "csv") { | |
| 1581 | return new Response(rowsCsv(data.rows), { | |
| 1582 | headers: { | |
| 1583 | ...headers, | |
| 1584 | "content-type": "text/csv; charset=utf-8", | |
| 1585 | "content-disposition": `attachment; filename="${ctx.params.slug}-${id}.csv"`, | |
| 1586 | }, | |
| 1587 | }); | |
| 1588 | } | |
| 1589 | const { ok: _, ...out } = data; | |
| 1590 | return json({ board: ctx.params.slug, panel: Number(id), title: panel.title, chart: panel.chart, ...out }, 200, headers); | |
| 1591 | }); | |
| 1585 | 1592 | }); |
| 1586 | 1593 | |
| 1587 | 1594 | router.add("PUT", "/api/v1/boards/:slug", async (ctx) => { |
test/api-panel-timing.test.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** The API's board panel read names its Server-Timing phases (#3920). */ | |
| 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 { writeBoard } from "../src/actions/boards.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { quietLogs } from "./helpers/globals.ts"; | |
| 12 | ||
| 13 | quietLogs(); | |
| 14 | ||
| 15 | let dataDir: string; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | let token = ""; | |
| 19 | ||
| 20 | beforeAll(async () => { | |
| 21 | dataDir = await mkdtemp(join(tmpdir(), "forge-api-panel-timing-")); | |
| 22 | app = createApp({ dataDir, port: 0 }); | |
| 23 | servers = await serve(app); | |
| 24 | const made = await createUser(app.db, { | |
| 25 | origin: "seed", | |
| 26 | trusted: true, | |
| 27 | username: "alice", | |
| 28 | email: "[email protected]", | |
| 29 | password: "a-good-passphrase", | |
| 30 | adminIfFirst: true, | |
| 31 | }); | |
| 32 | if (!made.ok) throw new Error(String(made.error)); | |
| 33 | token = createToken(app.db, made.value.id, "t", "admin").token; | |
| 34 | const done = await writeBoard(app, made.value, "usage", { | |
| 35 | title: "Usage", | |
| 36 | range: "24h", | |
| 37 | refresh: "off", | |
| 38 | panels: [ | |
| 39 | { title: "By target", chart: "bar", query: { source: "machine", aggregate: "count", groupBy: "target" }, w: 1, h: 1 }, | |
| 40 | ], | |
| 41 | }); | |
| 42 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 43 | }); | |
| 44 | ||
| 45 | afterAll(async () => { | |
| 46 | await servers.stop(); | |
| 47 | app.close(); | |
| 48 | await rm(dataDir, { recursive: true, force: true }); | |
| 49 | }); | |
| 50 | ||
| 51 | async function phases(path: string): Promise<{ status: number; names: string[]; res: Response }> { | |
| 52 | const res = await fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${token}` } }); | |
| 53 | await res.text(); | |
| 54 | const names = (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!); | |
| 55 | return { status: res.status, names: names.slice(names.indexOf("app") + 1), res }; | |
| 56 | } | |
| 57 | ||
| 58 | test("a panel read names session, board, query and render", async () => { | |
| 59 | const got = await phases("/api/v1/boards/usage/panels/0"); | |
| 60 | expect(got.status).toBe(200); | |
| 61 | expect(got.names).toEqual(["session", "board", "query", "render"]); | |
| 62 | expect(got.res.headers.get("cache-control")).toBe("private, no-store"); | |
| 63 | }); | |
| 64 | ||
| 65 | test("the CSV answer names the same phases", async () => { | |
| 66 | const got = await phases("/api/v1/boards/usage/panels/0?format=csv"); | |
| 67 | expect(got.status).toBe(200); | |
| 68 | expect(got.names).toEqual(["session", "board", "query", "render"]); | |
| 69 | expect(got.res.headers.get("cache-control")).toBe("private, no-store"); | |
| 70 | }); | |
| 71 | ||
| 72 | test("refusals keep their status and name the phases they reached", async () => { | |
| 73 | const missing = await phases("/api/v1/boards/usage/panels/9"); | |
| 74 | expect(missing.status).toBe(404); | |
| 75 | expect(missing.names).toEqual(["session", "board"]); | |
| 76 | const bad = await phases("/api/v1/boards/usage/panels/0?format=xml"); | |
| 77 | expect(bad.status).toBe(422); | |
| 78 | expect(bad.names).toEqual(["session", "board"]); | |
| 79 | }); |
new above
offline
Noting
sessionbeforeboardsAdminmeans a 401 or 403 also carries the session phase. I checked this on the preview, and it matches what the issue asked.