diff --git a/web/stovepipe/README.md b/web/stovepipe/README.md index 16b313a9d..fbfda7870 100644 --- a/web/stovepipe/README.md +++ b/web/stovepipe/README.md @@ -20,6 +20,8 @@ Mount `OpenIssuePagesProvider` with an authenticated `loadPage(href, signal)` ca Request pages use `//request/` and load both the current summary and events with `GetRequestHistoryByID`. The summary is displayed above history and is not reconstructed from retained events. Queue pagination uses the backend's opaque continuations, and the latest URL carries no time window. +History's Details column shows retained build IDs, breakage degree, and project-fact counts, including zero values. Multiple fields appear on separate lines; missing metadata displays a dash. Hosts may supply `resolveBuildUrl({ queue, buildId })` to `createStovepipeWeb` to link build IDs to their build provider. The callback runs while loading history and returns an absolute HTTP(S) URL or `undefined` to leave the ID as text. Invalid or unsupported URLs also leave it as text. The library preserves each ID verbatim and places only the resolved URL in the serializable page model; provider-specific URL conventions belong in the host. + The queue's `?view=issues` tab calls `ListOpenIssues` and groups rows by task in backend task/unit order. An empty unit is labeled Task-wide failure and appears first within its task; loading another page extends the existing group. Each row links to its opening request and shows the opening timestamp. The displayed count is loaded issues, not a queue-wide total. Empty batches can carry a continuation, so only an exhausted empty listing says No tracked open issues. This is not a claim of build success or complete coverage. Project attribution, issue history, and repair controls are not part of this view. Project results use `GetProjectStatusByID` for that exact request, with an empty diff --git a/web/stovepipe/controller/request.ts b/web/stovepipe/controller/request.ts index dede07cdf..fbe1233c4 100644 --- a/web/stovepipe/controller/request.ts +++ b/web/stovepipe/controller/request.ts @@ -1,13 +1,45 @@ -import type { RequestHistoryModel } from "../entity/index.js"; -import type { StovepipeService } from "../extension/service/index.js"; +import type { HistoryEventModel, RequestHistoryModel } from "../entity/index.js"; +import type { HistoryEvent, StovepipeService } from "../extension/service/index.js"; import type { StovepipePaths } from "../core/paths.js"; import { mapRequestSummary } from "./mapping.js"; +export type BuildUrlResolver = (input: { queue: string; buildId: string }) => string | undefined; + +function mapHistoryDetails( + event: HistoryEvent, + queue: string, + resolveBuildUrl?: BuildUrlResolver, +): HistoryEventModel["details"] { + const payload = event.data?.payload; + switch (payload?.case) { + case "requestState": + case "buildTriggered": + case "buildFinished": + case "buildAnalysisAbandoned": { + const buildId = payload.value.buildId; + if (!buildId) return []; + const href = resolveBuildUrl?.({ queue, buildId }); + const protocol = href && URL.canParse(href) ? new URL(href).protocol : ""; + return [{ + label: "Build ID", value: buildId, + ...(href && (protocol === "https:" || protocol === "http:") ? { href } : {}), + }]; + } + case "validationFactRecorded": + return [{ label: "Breakage degree", value: String(payload.value.factDegree) }]; + case "projectFactsRecorded": + return [{ label: "Project facts", value: payload.value.projectFactCount.toString() }]; + default: + return []; + } +} + export async function loadRequestHistory( service: StovepipeService, paths: StovepipePaths, queue: string, requestId: string, + resolveBuildUrl?: BuildUrlResolver, signal?: AbortSignal, ): Promise { const result = await service.getRequestHistoryByID( @@ -25,6 +57,7 @@ export async function loadRequestHistory( kind: event.occurrence.case ?? "unknown", label: event.occurrence.value ?? "Unknown event", outcomeReason: event.outcomeReason, + details: mapHistoryDetails(event, queue, resolveBuildUrl), })), }; } diff --git a/web/stovepipe/entity/index.ts b/web/stovepipe/entity/index.ts index a30110a71..9eeadc0a1 100644 --- a/web/stovepipe/entity/index.ts +++ b/web/stovepipe/entity/index.ts @@ -62,6 +62,8 @@ export interface HistoryEventModel { label: string; /** Backend reason associated with this occurrence. */ outcomeReason: string; + /** Labeled occurrence metadata with optional absolute HTTP(S) links; empty when unavailable. */ + details: { label: string; value: string; href?: string }[]; } export interface RequestHistoryModel { /** Current materialized lifecycle summary returned with the history. */ diff --git a/web/stovepipe/extension/service/index.ts b/web/stovepipe/extension/service/index.ts index d22faa3d0..0afd5fe2c 100644 --- a/web/stovepipe/extension/service/index.ts +++ b/web/stovepipe/extension/service/index.ts @@ -18,6 +18,13 @@ export interface HistoryEvent { | { case: "requestState" | "event"; value: string } | { case: undefined; value?: undefined }; outcomeReason: string; + data?: { + payload: + | { case: "requestState" | "buildTriggered" | "buildFinished" | "buildAnalysisAbandoned"; value: { buildId: string } } + | { case: "validationFactRecorded"; value: { factDegree: number } } + | { case: "projectFactsRecorded"; value: { projectFactCount: bigint } } + | { case: undefined; value?: undefined }; + } | undefined; } export interface ProjectStatus { diff --git a/web/stovepipe/module.test.ts b/web/stovepipe/module.test.ts index 00f5030e9..bf6a816f0 100644 --- a/web/stovepipe/module.test.ts +++ b/web/stovepipe/module.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import { createStovepipeWeb } from "./module.js"; +import { createStovepipeWeb, type StovepipeWebOptions } from "./module.js"; import { StovepipePaths } from "./core/paths.js"; import type { StovepipeService, RequestSummary, ProjectStatus, + HistoryEvent, } from "./extension/service/index.js"; const summary: RequestSummary = { @@ -19,7 +20,7 @@ const summary: RequestSummary = { projectCount: 51, brokenProjectCount: 2, }; -function createPageTestContext() { +function createPageTestContext(options: Pick = {}) { const service: StovepipeService = { listOpenIssues: vi.fn().mockResolvedValue({ issues: [], nextPageToken: "" }), list: vi @@ -55,6 +56,7 @@ function createPageTestContext() { service, paths, queues: [{ name: "repo/main" }], + ...options, }); return { service, paths, app }; } @@ -275,6 +277,81 @@ describe("Stovepipe page loading", () => { }, }); }); + it.each([ + ...(["requestState", "buildTriggered", "buildFinished", "buildAnalysisAbandoned"] as const).map((eventCase) => ({ + name: eventCase, + data: { payload: { case: eventCase, value: { buildId: "runner/build/7?attempt=2" } } }, + details: [{ label: "Build ID", value: "runner/build/7?attempt=2" }], + })), + { name: "green validation", data: { payload: { case: "validationFactRecorded", value: { factDegree: 0 } } }, details: [{ label: "Breakage degree", value: "0" }] }, + { name: "partial breakage", data: { payload: { case: "validationFactRecorded", value: { factDegree: 0.25 } } }, details: [{ label: "Breakage degree", value: "0.25" }] }, + { name: "no project facts", data: { payload: { case: "projectFactsRecorded", value: { projectFactCount: 0n } } }, details: [{ label: "Project facts", value: "0" }] }, + { name: "large project count", data: { payload: { case: "projectFactsRecorded", value: { projectFactCount: 9007199254740993n } } }, details: [{ label: "Project facts", value: "9007199254740993" }] }, + { name: "missing metadata", data: undefined, details: [] }, + { name: "unknown metadata", data: { payload: { case: undefined } }, details: [] }, + { name: "empty build ID", data: { payload: { case: "buildTriggered", value: { buildId: "" } } }, details: [] }, + ] satisfies { name: string; data: HistoryEvent["data"]; details: { label: string; value: string }[] }[])("exposes serializable history details for $name", async ({ data, details }) => { + const { app, service, paths } = createPageTestContext(); + vi.mocked(service.getRequestHistoryByID).mockResolvedValue({ + requestSummary: summary, + events: [{ eventId: "e1", timestampMs: 100n, occurrence: { case: "event", value: "retained_event" }, outcomeReason: "", data }], + }); + const result = await app.handle({ + path: paths.request(summary.queue, summary.requestId), search: new URLSearchParams(), + }); + expect(result).toMatchObject({ kind: "render", model: { history: { ok: true, data: { events: [{ details }] } } } }); + expect(() => JSON.stringify(result)).not.toThrow(); + }); + it.each(["requestState", "buildTriggered", "buildFinished", "buildAnalysisAbandoned"] as const)("resolves %s build links with the opaque ID and queue", async (payloadCase) => { + const href = "https://builds.example.test/pipeline/builds/7?attempt=2"; + const resolveBuildUrl = vi.fn(() => href); + const { app, service, paths } = createPageTestContext({ resolveBuildUrl }); + vi.mocked(service.getRequestHistoryByID).mockResolvedValue({ + requestSummary: summary, + events: [{ + eventId: "e1", timestampMs: 100n, occurrence: { case: "event", value: "retained_event" }, outcomeReason: "", + data: { payload: { case: payloadCase, value: { buildId: "runner/build/7?attempt=2" } } }, + }], + }); + const result = await app.handle({ path: paths.request(summary.queue, summary.requestId), search: new URLSearchParams() }); + expect(resolveBuildUrl).toHaveBeenCalledExactlyOnceWith({ queue: summary.queue, buildId: "runner/build/7?attempt=2" }); + expect(result).toMatchObject({ kind: "render", model: { history: { ok: true, data: { events: [{ + details: [{ label: "Build ID", value: "runner/build/7?attempt=2", href }], + }] } } } }); + expect(() => JSON.stringify(result)).not.toThrow(); + }); + it.each([undefined, "", "not a URL", "/build/7", "//builds.example.test/7", "javascript:alert(1)", "data:text/html,build"])("leaves build IDs as text when the resolver returns %j", async (href) => { + const { app, service, paths } = createPageTestContext({ resolveBuildUrl: () => href }); + vi.mocked(service.getRequestHistoryByID).mockResolvedValue({ + requestSummary: summary, + events: [{ + eventId: "e1", timestampMs: 100n, occurrence: { case: "event", value: "build_triggered" }, outcomeReason: "", + data: { payload: { case: "buildTriggered", value: { buildId: "runner/build/7" } } }, + }], + }); + const result = await app.handle({ path: paths.request(summary.queue, summary.requestId), search: new URLSearchParams() }); + if (result.kind !== "render" || result.model.kind !== "request" || !result.model.history.ok) + throw new Error("No history page"); + expect(result.model.history.data.events[0]?.details).toEqual([{ label: "Build ID", value: "runner/build/7" }]); + }); + it("does not resolve links for metadata without a build ID", async () => { + const resolveBuildUrl = vi.fn(() => "https://builds.example.test/7"); + const { app, service, paths } = createPageTestContext({ resolveBuildUrl }); + const metadata: HistoryEvent["data"][] = [ + undefined, + { payload: { case: "validationFactRecorded", value: { factDegree: 0 } } }, + { payload: { case: "buildTriggered", value: { buildId: "" } } }, + ]; + vi.mocked(service.getRequestHistoryByID).mockResolvedValue({ + requestSummary: summary, + events: metadata.map((data, index): HistoryEvent => ({ + eventId: String(index), timestampMs: 100n, occurrence: { case: "event", value: "retained_event" }, outcomeReason: "", data, + })), + }); + const result = await app.handle({ path: paths.request(summary.queue, summary.requestId), search: new URLSearchParams() }); + expect(result).toMatchObject({ kind: "render", model: { history: { ok: true } } }); + expect(resolveBuildUrl).not.toHaveBeenCalled(); + }); it.each([ { name: "missing summary", requestSummary: undefined }, { name: "another request", requestSummary: { ...summary, requestId: "7" } }, diff --git a/web/stovepipe/module.ts b/web/stovepipe/module.ts index 2cbf70b90..12a96032b 100644 --- a/web/stovepipe/module.ts +++ b/web/stovepipe/module.ts @@ -6,7 +6,7 @@ import { parseProjectPageSize, StovepipePaths } from "./core/paths.js"; import { readSection } from "./controller/page.js"; import { loadQueueRequests } from "./controller/queue.js"; import { loadOpenIssues } from "./controller/issues.js"; -import { loadRequestHistory } from "./controller/request.js"; +import { loadRequestHistory, type BuildUrlResolver } from "./controller/request.js"; import { loadProjectStatus } from "./controller/projects.js"; export interface StovepipeWebRequest { @@ -30,6 +30,8 @@ export interface StovepipeWebOptions { queues: readonly Queue[]; paths?: StovepipePaths; authorize?(principal: unknown, queue: string): boolean | Promise; + /** Maps opaque build IDs to absolute HTTP(S) URLs; undefined leaves the ID as text. */ + resolveBuildUrl?: BuildUrlResolver; } export function createStovepipeWeb(options: StovepipeWebOptions) { const paths = options.paths ?? new StovepipePaths(); @@ -91,7 +93,7 @@ export function createStovepipeWeb(options: StovepipeWebOptions) { } const [history, projectStatus] = await Promise.all([ readSection( - () => loadRequestHistory(service, paths, queue.name, requestId, request.signal), + () => loadRequestHistory(service, paths, queue.name, requestId, options.resolveBuildUrl, request.signal), onLog, "request history", ), diff --git a/web/stovepipe/view/app.test.tsx b/web/stovepipe/view/app.test.tsx index 286e64233..b1632ef99 100644 --- a/web/stovepipe/view/app.test.tsx +++ b/web/stovepipe/view/app.test.tsx @@ -56,6 +56,7 @@ const model: StovepipePageModel = { kind: "requestState", label: "accepted", outcomeReason: "", + details: [], }, ], }, diff --git a/web/stovepipe/view/pages.test.tsx b/web/stovepipe/view/pages.test.tsx index a818bcb8d..768beb5de 100644 --- a/web/stovepipe/view/pages.test.tsx +++ b/web/stovepipe/view/pages.test.tsx @@ -1,11 +1,50 @@ import { cleanup, render, screen, within } from "../../platform/test/rtl.js"; import { afterEach, expect, it } from "vitest"; -import type { RequestSummaryModel } from "../entity/index.js"; -import { RequestDetails, RequestList } from "./pages.js"; +import type { HistoryEventModel, RequestSummaryModel } from "../entity/index.js"; +import { History, RequestDetails, RequestList } from "./pages.js"; import { QueueRequests } from "./queue.js"; afterEach(cleanup); +it("shows labeled history metadata and preserves zeros, opaque IDs and missing values", () => { + const events: HistoryEventModel[] = [ + { id: "triggered", timestampMs: "1000", kind: "event", label: "build_triggered", outcomeReason: "", + details: [{ label: "Build ID", value: "runner/build/7?attempt=2" }] }, + { id: "validation", timestampMs: "2000", kind: "event", label: "validation_fact_recorded", outcomeReason: "", + details: [{ label: "Breakage degree", value: "0" }] }, + { id: "projects", timestampMs: "3000", kind: "event", label: "project_facts_recorded", outcomeReason: "", + details: [{ label: "Project facts", value: "0" }] }, + { id: "legacy-finished", timestampMs: "4000", kind: "event", label: "build_finished", outcomeReason: "", details: [] }, + ]; + render(); + const table = within(screen.getByRole("table", { name: "Request history" })); + expect(table.getByRole("columnheader", { name: "Details" })).toBeVisible(); + for (const text of ["Build ID: runner/build/7?attempt=2", "Breakage degree: 0", "Project facts: 0"]) + expect(table.getByRole("cell", { name: text })).toBeVisible(); + const lastRow = within(table.getAllByRole("row").at(-1)!); + expect(lastRow.getAllByRole("cell").at(-1)).toHaveTextContent("—"); +}); + +it("links resolved build IDs and leaves other values as text", () => { + render(); + expect(screen.getByRole("link", { name: "runner/build/7?attempt=2" })).toHaveAttribute("href", "https://builds.example.test/pipeline/builds/7?attempt=2"); + expect(screen.queryByRole("link", { name: "runner/build/8" })).toBeNull(); + expect(screen.getByText("runner/build/8")).toBeVisible(); + expect(screen.getByRole("cell", { name: "Breakage degree: 0.5" })).toBeVisible(); + expect(screen.getAllByRole("link")).toHaveLength(1); +}); + const request: RequestSummaryModel = { id: "7", href: "/queue/request/7", diff --git a/web/stovepipe/view/pages.tsx b/web/stovepipe/view/pages.tsx index a420b6104..7e778b256 100644 --- a/web/stovepipe/view/pages.tsx +++ b/web/stovepipe/view/pages.tsx @@ -2,6 +2,7 @@ import { Fact, Timestamp } from "@submitqueue/web-platform"; import { Block } from "baseui/block"; +import { StyledLink } from "baseui/link"; import { StyledTableBodyCell, StyledTableBodyRow } from "baseui/table-semantic"; import { Tag } from "baseui/tag"; import { ParagraphSmall } from "baseui/typography"; @@ -105,7 +106,7 @@ export function History({ events }: { events: HistoryEventModel[] }) { return events.length ? ( {events.map((event) => ( @@ -122,6 +123,14 @@ export function History({ events }: { events: HistoryEventModel[] }) { {event.label}{event.outcomeReason || "—"} + + {event.details.length ? event.details.map((detail) => ( + + {detail.label}: + {detail.href ? {detail.value} : detail.value} + + )) : "—"} + ))}
diff --git a/web/test/e2e/stovepipe/run_e2e.py b/web/test/e2e/stovepipe/run_e2e.py index 2cc2540c4..5e04cfabb 100644 --- a/web/test/e2e/stovepipe/run_e2e.py +++ b/web/test/e2e/stovepipe/run_e2e.py @@ -44,6 +44,21 @@ def _pagination_fixture_sql() -> str: statements.append( "INSERT INTO request_log VALUES ('web/pagination','51','accepted',1000,'accepted','',1,'','{}');" ) + history_details = [ + ("triggered", "", "build_triggered", {"build_id": "runner/build/51?attempt=2"}), + ("finished", "", "build_finished", {"build_id": "runner/build/51?attempt=2"}), + ("validation", "", "validation_fact_recorded", {"fact_degree": "0.25"}), + ("green", "", "validation_fact_recorded", {"fact_degree": "0"}), + ("projects", "", "project_facts_recorded", {"project_fact_count": "51"}), + ("empty-projects", "", "project_facts_recorded", {"project_fact_count": "0"}), + ("abandoned", "", "build_analysis_abandoned", {"build_id": "runner/build/51?attempt=2"}), + ("failed", "failed", "", {"build_id": "runner/build/51?attempt=2"}), + ] + for index, (event_id, state, event, metadata) in enumerate(history_details): + statements.append( + "INSERT INTO request_log VALUES " + f"('web/pagination','51','{event_id}',{1100+index},'{state}','{event}',1,'','{json.dumps(metadata)}');" + ) uri = "git://web/pagination/51?file=src%2Fmain#fragment" statements.append( f"INSERT INTO validation_fact (queue,uri,project,degree,request_id,created_at) VALUES ('web/pagination','{uri}','',0.25,'51',2000);" diff --git a/web/test/e2e/stovepipe/stovepipe.spec.ts b/web/test/e2e/stovepipe/stovepipe.spec.ts index d427b77f2..3e0731c3e 100644 --- a/web/test/e2e/stovepipe/stovepipe.spec.ts +++ b/web/test/e2e/stovepipe/stovepipe.spec.ts @@ -102,6 +102,22 @@ test("loads and retries issue pages, merges task groups, and refreshes from the expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); }); +test("shows retained history metadata, including zero values, in the details column", async ({ page }) => { + await page.goto("/web%2Fpagination/request/51?view=history"); + const history = page.getByRole("table", { name: "Request history", exact: true }); + await expect(history.getByRole("columnheader", { name: "Details", exact: true })).toBeVisible(); + for (const label of ["build_triggered", "build_finished", "build_analysis_abandoned", "failed"]) { + const row = history.getByRole("row").filter({ has: page.getByRole("cell", { name: label, exact: true }) }); + await expect(row.getByRole("cell", { name: "Build ID: runner/build/51?attempt=2", exact: true })).toBeVisible(); + } + for (const text of ["Breakage degree: 0", "Breakage degree: 0.25", "Project facts: 0", "Project facts: 51"]) + await expect(history.getByRole("cell", { name: text, exact: true })).toBeVisible(); + const accepted = history.getByRole("row").filter({ has: page.getByRole("cell", { name: "accepted", exact: true }) }); + await expect(accepted.getByRole("cell").last()).toHaveText("—"); + await page.screenshot({ path: `${process.env.TEST_UNDECLARED_OUTPUTS_DIR}/stovepipe-history-details.png` }); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); +}); + test("continues past an empty issue batch instead of claiming the queue has no issues", async ({ page }) => { await page.goto("/web%2Fempty?view=issues"); await expect(page.getByText("No matching issues in this batch. More results are available.")).toBeVisible();