From 094501ed41b420a56838a940087c43f53913b6b7 Mon Sep 17 00:00:00 2001 From: "prath.shenoy" Date: Mon, 19 Oct 2026 19:15:20 +0000 Subject: [PATCH] feat(web): Add project validation results ## Summary **What**: - Add project and repository results in a single table on request pages, with recording progress, shareable pagination, and the updated timestamp beneath the results. - Keep result mapping, presentation, refresh, and stale-result handling in the shared UI while hosts supply transport, authentication, and telemetry. **Why**: - Let operators inspect validation results for the exact request without leaving its status page. - Avoid duplicating project-results behavior across host applications. ## Test Plan - [x] Add unit tests. ## Revert Plan - Revert this PR. Roll back dependent UI package updates with it; no stored data changes or migrations are involved. ## Issues - [CODEM-603](https://linear.app/uber/issue/CODEM-603/request-page-ui) --- .../src/server/instrumentation.test.ts | 3 +- .../stovepipe/src/server/instrumentation.ts | 5 +- web/stovepipe/README.md | 17 ++- web/stovepipe/controller/projects.ts | 40 +++++++ web/stovepipe/core/paths.ts | 5 +- web/stovepipe/entity/index.ts | 15 +++ web/stovepipe/extension/service/index.ts | 27 +++++ web/stovepipe/module.test.ts | 100 ++++++++++++++++++ web/stovepipe/module.ts | 23 ++-- web/stovepipe/view/app.test.tsx | 67 +++++++++++- web/stovepipe/view/app.tsx | 17 ++- web/stovepipe/view/projects.tsx | 53 ++++++++++ web/stovepipe/view/styles.css | 2 + web/test/e2e/stovepipe/run_e2e.py | 12 ++- web/test/e2e/stovepipe/stovepipe.spec.ts | 51 ++++++++- 15 files changed, 415 insertions(+), 22 deletions(-) create mode 100644 web/stovepipe/controller/projects.ts create mode 100644 web/stovepipe/view/projects.tsx diff --git a/web/service/stovepipe/src/server/instrumentation.test.ts b/web/service/stovepipe/src/server/instrumentation.test.ts index c19e42598..4bfa66ae8 100644 --- a/web/service/stovepipe/src/server/instrumentation.test.ts +++ b/web/service/stovepipe/src/server/instrumentation.test.ts @@ -6,7 +6,8 @@ import { instrumentStovepipeWeb } from "./instrumentation"; function createHostFixture() { const list = vi.fn().mockResolvedValue({ requests: [], nextPageToken: "" }); const getRequestHistoryByID = vi.fn().mockRejectedValue(new Error("private failure")); - const web = createStovepipeWeb({ queues: [{ name: "demo" }], service: { list, getRequestHistoryByID } }); + const getProjectStatusByID = vi.fn().mockRejectedValue(new Error("private failure")); + const web = createStovepipeWeb({ queues: [{ name: "demo" }], service: { list, getRequestHistoryByID, getProjectStatusByID } }); const record = vi.fn(); const add = vi.fn(); const span = { setAttribute: vi.fn(), setStatus: vi.fn(), recordException: vi.fn(), end: vi.fn() }; diff --git a/web/service/stovepipe/src/server/instrumentation.ts b/web/service/stovepipe/src/server/instrumentation.ts index f9c6b96cb..910aa653d 100644 --- a/web/service/stovepipe/src/server/instrumentation.ts +++ b/web/service/stovepipe/src/server/instrumentation.ts @@ -15,8 +15,9 @@ export function instrumentStovepipeWeb( const result = await web.handle(request); outcome = result.kind; if (result.kind === "render") { - const section = result.model.kind === "queue" ? result.model.requests : result.model.history; - if (!section.ok) { + const sections = result.model.kind === "queue" + ? [result.model.requests] : [result.model.history, result.model.projectStatus]; + if (sections.some((section) => !section.ok)) { outcome = "error"; observeTelemetry(() => span?.setStatus({ code: SpanStatusCode.ERROR })); } diff --git a/web/stovepipe/README.md b/web/stovepipe/README.md index c8f868e8e..cbe74e110 100644 --- a/web/stovepipe/README.md +++ b/web/stovepipe/README.md @@ -1,12 +1,12 @@ # Stovepipe web UI -A framework-neutral React UI with a queue request list and a request page containing the current lifecycle summary and retained history. The host supplies queue configuration, a structural `StovepipeService` client, authentication, transport and optional logging. +A framework-neutral React UI with a queue request list and a request page containing the current lifecycle summary, retained history, and recorded project results. The host supplies queue configuration, a structural `StovepipeService` client, authentication, transport and optional logging. Logging uses a synchronous `onLog` callback passed on each `handle` request. Bind it to the host's request-scoped logger; it receives severity, message, and structured metadata containing the original `error`, correlation `requestId`, and page `path`. Omitting it is silent, and callback exceptions do not change page results. The library has no logging-library dependency. ## Integration -Instrumentation belongs in the host: wrap `handle` to observe page results and instrument the concrete service client for RPC traces. A `render` result can contain a failed section, so check `requests.ok` or `history.ok` before reporting success. The reference host uses native OpenTelemetry spans, a page-load counter and a duration metric; other hosts can use their own telemetry without an adapter. The reusable module has no telemetry dependency or instrumentation API. +Instrumentation belongs in the host: wrap `handle` to observe page results and instrument the concrete service client for RPC traces. A `render` result can contain a failed section, so check `requests.ok`, or both `history.ok` and `projectStatus.ok`, before reporting success. The reference host uses native OpenTelemetry spans, a page-load counter and a duration metric; other hosts can use their own telemetry without an adapter. The reusable module has no telemetry dependency or instrumentation API. Use the server entry's `createStovepipeWeb` to load a page and render its model with `StovepipeApp`. Mount beneath `WebNavigationProvider` when the host supplies client navigation and refresh. Without it, links and refresh use ordinary document navigation. Import `@submitqueue/web-stovepipe/styles.css`, which includes the common native styles from `@submitqueue/web-platform`. @@ -14,6 +14,19 @@ Use the server entry's `createStovepipeWeb` to load a page and render its model 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. +Project results use `GetProjectStatusByID` for that exact request, with an empty +project selection to read all recorded projects. Each page contains up to 50 +projects; `?projects=` carries its continuation and can be shared. +First-page navigation removes the cursor. Pages are live rather than a snapshot; +return to the first page to see newly recorded projects. + +Repository breakage, per-project breakage, and recording completion are separate. +Zero breakage is green; absent results are never treated as green. Completion +describes result production, not whether all pages have been displayed. Project +and history reads fail independently, and backend diagnostics stay in host logs. +All result mapping, rendering, pagination, refresh and stale-result handling live +in this package; hosts only adapt their transport representation. + ## Refresh and presentation Refresh is manual by default, with optional ten-second polling. Failed refreshes retain successful results on the same route; changing routes clears them. Timestamps use UTC. Lifecycle labels and outcome reasons come from the backend and are displayed verbatim. diff --git a/web/stovepipe/controller/projects.ts b/web/stovepipe/controller/projects.ts new file mode 100644 index 000000000..ba0eafcda --- /dev/null +++ b/web/stovepipe/controller/projects.ts @@ -0,0 +1,40 @@ +import type { ProjectStatusModel } from "../entity/index.js"; +import type { StovepipeService } from "../extension/service/index.js"; +import type { StovepipePaths } from "../core/paths.js"; + +function breakageDegree(value: number): number { + if (!Number.isFinite(value) || value < 0 || value > 1) + throw new Error("Invalid breakage degree"); + return value; +} + +export async function loadProjectStatus( + service: StovepipeService, + paths: StovepipePaths, + queue: string, + requestId: string, + pageToken: string, + signal?: AbortSignal, +): Promise { + const result = await service.getProjectStatusByID( + { queue, requestId, projects: [], pageSize: 50, pageToken }, + signal ? { signal } : {}, + ); + if (result.queue !== queue || result.requestId !== requestId) + throw new Error("Project response does not identify the selected request"); + return { + updatedAtMs: result.updatedAtMs.toString(), + repositoryBreakageDegree: result.repositoryResult.case === "repositoryBreakageDegree" + ? breakageDegree(result.repositoryResult.value) : null, + complete: result.projectResultsComplete, + projects: result.projects.map((entry) => ({ + project: entry.project, + breakageDegree: entry.result.case === "breakageDegree" + ? breakageDegree(entry.result.value) : null, + })), + firstHref: pageToken ? paths.request(queue, requestId) : null, + nextHref: result.nextPageToken + ? paths.request(queue, requestId, result.nextPageToken) + : null, + }; +} diff --git a/web/stovepipe/core/paths.ts b/web/stovepipe/core/paths.ts index 4f9371508..397248b00 100644 --- a/web/stovepipe/core/paths.ts +++ b/web/stovepipe/core/paths.ts @@ -9,7 +9,8 @@ export class StovepipePaths { const path = `${this.basePath}/${encodePathSegment(queue)}`; return page ? `${path}?${new URLSearchParams({ page })}` : path; } - request(queue: string, requestId: string): string { - return `${this.queue(queue)}/request/${encodePathSegment(requestId)}`; + request(queue: string, requestId: string, projects = ""): string { + const path = `${this.queue(queue)}/request/${encodePathSegment(requestId)}`; + return projects ? `${path}?${new URLSearchParams({ projects })}` : path; } } diff --git a/web/stovepipe/entity/index.ts b/web/stovepipe/entity/index.ts index 5e6a2a951..30760a4ba 100644 --- a/web/stovepipe/entity/index.ts +++ b/web/stovepipe/entity/index.ts @@ -41,6 +41,20 @@ export interface RequestHistoryModel { /** Retained events ordered by occurrence time and stable event identity. */ events: HistoryEventModel[]; } +export interface ProjectStatusModel { + /** Newest durable result or lifecycle record in Unix milliseconds. */ + updatedAtMs: string; + /** Whole-repository breakage on [0, 1]; null means no recorded result. */ + repositoryBreakageDegree: number | null; + /** Whether production of relevant project results has finished, independent of pagination. */ + complete: boolean; + /** Recorded projects on this page; null breakage means no result. */ + projects: { project: string; breakageDegree: number | null }[]; + /** First project page for this request; null on the first page. */ + firstHref: string | null; + /** Opaque project continuation URL; null on the final page. */ + nextHref: string | null; +} interface PageBase { /** Route and cursor identity; independent of refresh time. */ key: string; @@ -70,5 +84,6 @@ export type StovepipePageModel = PageBase & kind: "request"; requestId: string; history: ReadResult; + projectStatus: ReadResult; } ); diff --git a/web/stovepipe/extension/service/index.ts b/web/stovepipe/extension/service/index.ts index e8f5af902..17da4ea2d 100644 --- a/web/stovepipe/extension/service/index.ts +++ b/web/stovepipe/extension/service/index.ts @@ -18,6 +18,23 @@ export interface HistoryEvent { outcomeReason: string; } +export interface ProjectStatus { + queue: string; + requestId: string; + updatedAtMs: bigint; + repositoryResult: + | { case: "repositoryBreakageDegree"; value: number } + | { case: undefined; value?: undefined }; + projectResultsComplete: boolean; + projects: { + project: string; + result: + | { case: "breakageDegree"; value: number } + | { case: undefined; value?: undefined }; + }[]; + nextPageToken: string; +} + /** Hosts supply a transport client; the library has no generated-binding dependency. */ export interface StovepipeService { list( @@ -31,4 +48,14 @@ export interface StovepipeService { requestSummary?: RequestSummary | undefined; events: HistoryEvent[]; }>; + getProjectStatusByID( + request: { + queue: string; + requestId: string; + projects: string[]; + pageSize: number; + pageToken: string; + }, + options?: { signal?: AbortSignal }, + ): Promise; } diff --git a/web/stovepipe/module.test.ts b/web/stovepipe/module.test.ts index 0f888a636..f16c306f1 100644 --- a/web/stovepipe/module.test.ts +++ b/web/stovepipe/module.test.ts @@ -4,6 +4,7 @@ import { StovepipePaths } from "./core/paths.js"; import type { StovepipeService, RequestSummary, + ProjectStatus, } from "./extension/service/index.js"; const summary: RequestSummary = { @@ -32,6 +33,19 @@ function createPageTestContext() { }, ], }), + getProjectStatusByID: vi.fn().mockResolvedValue({ + queue: summary.queue, + requestId: summary.requestId, + updatedAtMs: 9007199254740993n, + repositoryResult: { case: "repositoryBreakageDegree", value: 0 }, + projectResultsComplete: false, + projects: [ + { project: "green", result: { case: "breakageDegree", value: 0 } }, + { project: "broken", result: { case: "breakageDegree", value: 0.25 } }, + { project: "unknown", result: { case: undefined } }, + ], + nextPageToken: "next+/=", + }), }; const paths = new StovepipePaths("/stovepipe"); const app = createStovepipeWeb({ @@ -142,6 +156,7 @@ describe("Stovepipe page loading", () => { paths.request("repo/main", "7"), ]); expect(service.getRequestHistoryByID).not.toHaveBeenCalled(); + expect(service.getProjectStatusByID).not.toHaveBeenCalled(); }); it("loads the current summary and events by ID without reconstructing state from history", async () => { const { app, service, paths } = createPageTestContext(); @@ -155,6 +170,10 @@ describe("Stovepipe page loading", () => { { queue: "repo/main", requestId: summary.requestId }, { signal }, ); + expect(service.getProjectStatusByID).toHaveBeenCalledWith( + { queue: summary.queue, requestId: summary.requestId, projects: [], pageSize: 50, pageToken: "" }, + { signal }, + ); expect(result).toMatchObject({ kind: "render", model: { @@ -247,6 +266,7 @@ describe("Stovepipe page loading", () => { }); expect(result).toEqual({ kind: "forbidden" }); expect(service.getRequestHistoryByID).not.toHaveBeenCalled(); + expect(service.getProjectStatusByID).not.toHaveBeenCalled(); }); it.each([ "/other", @@ -263,5 +283,85 @@ describe("Stovepipe page loading", () => { ).toEqual({ kind: "not-found" }); expect(service.list).not.toHaveBeenCalled(); expect(service.getRequestHistoryByID).not.toHaveBeenCalled(); + expect(service.getProjectStatusByID).not.toHaveBeenCalled(); + }); + + it("loads recorded projects by exact request ID and preserves opaque cursors and int64 precision", async () => { + const { app, service, paths } = createPageTestContext(); + const result = await app.handle({ + path: paths.request(summary.queue, summary.requestId), + search: new URLSearchParams({ projects: "opaque+/=" }), + }); + expect(service.getProjectStatusByID).toHaveBeenCalledWith({ + queue: summary.queue, requestId: summary.requestId, + projects: [], pageSize: 50, pageToken: "opaque+/=", + }, {}); + expect(result).toMatchObject({ kind: "render", model: { projectStatus: { + ok: true, + data: { + updatedAtMs: "9007199254740993", + repositoryBreakageDegree: 0, + complete: false, + projects: [ + { project: "green", breakageDegree: 0 }, + { project: "broken", breakageDegree: 0.25 }, + { project: "unknown", breakageDegree: null }, + ], + firstHref: paths.request(summary.queue, summary.requestId), + nextHref: paths.request(summary.queue, summary.requestId, "next+/="), + }, + } } }); + expect(() => JSON.stringify(result)).not.toThrow(); + }); + + it.each([ + { queue: "other" }, + { requestId: "other" }, + { projects: [{ project: "invalid", result: { case: "breakageDegree", value: NaN } }] }, + { repositoryResult: { case: "repositoryBreakageDegree", value: 1.1 } }, + ] satisfies Partial[])("rejects mismatched or malformed project status %j", async (overrides) => { + const { app, service, paths } = createPageTestContext(); + const original = await service.getProjectStatusByID({ + queue: summary.queue, requestId: summary.requestId, projects: [], pageSize: 50, pageToken: "", + }); + vi.mocked(service.getProjectStatusByID).mockResolvedValue({ + ...original, ...overrides, + }); + const result = await app.handle({ + path: paths.request(summary.queue, summary.requestId), search: new URLSearchParams(), + }); + expect(result).toMatchObject({ + kind: "render", model: { history: { ok: true }, projectStatus: { ok: false } }, + }); + }); + + it("loads history independently when the project read fails without leaking diagnostics", async () => { + const { app, service, paths } = createPageTestContext(); + vi.mocked(service.getProjectStatusByID).mockRejectedValue(new Error("private diagnostic")); + const result = await app.handle({ + path: paths.request(summary.queue, summary.requestId), search: new URLSearchParams(), + }); + expect(result).toMatchObject({ + kind: "render", model: { history: { ok: true }, projectStatus: { ok: false } }, + }); + expect(JSON.stringify(result)).not.toContain("private diagnostic"); + }); + + it("keeps unknown repository results and empty complete pages distinct from green", async () => { + const { app, service, paths } = createPageTestContext(); + vi.mocked(service.getProjectStatusByID).mockResolvedValue({ + queue: summary.queue, requestId: summary.requestId, updatedAtMs: 0n, + repositoryResult: { case: undefined }, projectResultsComplete: true, + projects: [], nextPageToken: "", + }); + const result = await app.handle({ + path: paths.request(summary.queue, summary.requestId), search: new URLSearchParams(), + }); + expect(result).toMatchObject({ kind: "render", model: { projectStatus: { + ok: true, data: { + repositoryBreakageDegree: null, complete: true, projects: [], + firstHref: null, nextHref: null, + }, + } } }); }); }); diff --git a/web/stovepipe/module.ts b/web/stovepipe/module.ts index 4027ac70a..7cae4b8c7 100644 --- a/web/stovepipe/module.ts +++ b/web/stovepipe/module.ts @@ -6,6 +6,7 @@ import { StovepipePaths } from "./core/paths.js"; import { readSection } from "./controller/page.js"; import { loadQueueRequests } from "./controller/queue.js"; import { loadRequestHistory } from "./controller/request.js"; +import { loadProjectStatus } from "./controller/projects.js"; export interface StovepipeWebRequest { /** Percent-encoded URL pathname, including the mount path. */ @@ -78,14 +79,24 @@ export function createStovepipeWeb(options: StovepipeWebOptions) { ); return { kind: "render", model: { ...common, kind: "queue", requests } }; } - const history = await readSection( - () => loadRequestHistory(service, paths, queue.name, requestId, request.signal), - onLog, - "request history" - ); + const [history, projectStatus] = await Promise.all([ + readSection( + () => loadRequestHistory(service, paths, queue.name, requestId, request.signal), + onLog, + "request history", + ), + readSection( + () => loadProjectStatus( + service, paths, queue.name, requestId, + request.search.get("projects") ?? "", request.signal, + ), + onLog, + "project results", + ), + ]); return { kind: "render", - model: { ...common, kind: "request", requestId, history }, + model: { ...common, kind: "request", requestId, history, projectStatus }, }; } catch (error) { onLog("error", "Web page load failed", { error }); diff --git a/web/stovepipe/view/app.test.tsx b/web/stovepipe/view/app.test.tsx index 76e001957..d13f616ac 100644 --- a/web/stovepipe/view/app.test.tsx +++ b/web/stovepipe/view/app.test.tsx @@ -13,6 +13,21 @@ const model: StovepipePageModel = { latestHref: "/repo%2Fmain", showLatestLink: false, loadedAtMs: 1000, + projectStatus: { + ok: true, + data: { + updatedAtMs: "1000", + repositoryBreakageDegree: null, + complete: false, + projects: [ + { project: "green-project", breakageDegree: 0 }, + { project: "broken-project", breakageDegree: 0.25 }, + { project: "unknown-project", breakageDegree: null }, + ], + firstHref: null, + nextHref: "/repo%2Fmain/request/7?projects=opaque%2B%2F%3D", + }, + }, history: { ok: true, data: { @@ -51,7 +66,7 @@ it("displays the backend summary above the retained history", () => { expect(within(history).getByText("accepted")).toBeVisible(); expect( screen.getAllByRole("heading", { level: 2 }).map((heading) => heading.textContent), - ).toEqual(["Request status", "History"]); + ).toEqual(["Request status", "History", "Project results"]); }); it("links queue entries by request ID even when the change URI is missing", () => { if (!model.history.ok) throw new Error("No summary"); @@ -80,12 +95,14 @@ it("retains the summary and history after a failed refresh", () => { , ); expect(screen.getByText("git://repo/sha")).toBeVisible(); expect(screen.getByText("all_targets_passed")).toBeVisible(); expect(screen.getByText("accepted")).toBeVisible(); - expect(screen.getByText("Showing the last successful result.")).toBeVisible(); + expect(screen.getAllByText("Showing the last successful result.")).toHaveLength(2); + expect(screen.getByText("green-project")).toBeVisible(); }); it("discards previous results when the request changes", () => { const { rerender } = render(); @@ -95,9 +112,55 @@ it("discards previous results when the request changes", () => { key: "/repo%2Fmain/request/8?", requestId: "8", history: { ok: false, error: "History unavailable" }, + projectStatus: { ok: false, error: "Projects unavailable" }, }} />, ); expect(screen.getByRole("heading", { name: "Request 8" })).toBeVisible(); expect(screen.queryByText("git://repo/sha")).toBeNull(); expect(screen.queryByText("all_targets_passed")).toBeNull(); + expect(screen.queryByText("green-project")).toBeNull(); +}); + +it("renders recorded project results, completion and shareable pagination", () => { + render(); + const projects = screen.getByRole("region", { name: "Project results" }); + const table = within(projects).getByRole("table", { name: "Project results" }); + expect(within(table).getByRole("rowheader", { name: "Repository" })).toBeVisible(); + expect(within(projects).getByText("Green (0%)")).toBeVisible(); + expect(within(projects).getByText("Breakage (25%)")).toBeVisible(); + expect(within(projects).getAllByText("No result recorded")).toHaveLength(2); + expect(within(projects).getByText(/still in progress/)).toBeVisible(); + expect(within(projects).getByRole("link", { name: /Next projects/ })).toHaveAttribute( + "href", "/repo%2Fmain/request/7?projects=opaque%2B%2F%3D", + ); + const timestamp = within(projects).getByText(/Results updated:/); + const pagination = within(projects).getByRole("navigation", { name: "Project pages" }); + expect(table.compareDocumentPosition(timestamp) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(pagination.compareDocumentPosition(timestamp) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); +}); + +it("does not show a previous project's page after a failed pagination load", () => { + const { rerender } = render(); + rerender(); + expect(screen.queryByText("green-project")).toBeNull(); + expect(screen.getByText("git://repo/sha")).toBeVisible(); +}); + +it("shows complete empty results without inventing green or a continuation", () => { + if (!model.projectStatus.ok) throw new Error("No projects"); + render(); + expect(screen.getByText("Project result recording is complete.")).toBeVisible(); + expect(screen.getByText("No project results recorded on this page.")).toBeVisible(); + expect(screen.queryByRole("link", { name: /Next projects/ })).toBeNull(); + const table = screen.getByRole("table", { name: "Project results" }); + expect(within(table).getByRole("rowheader", { name: "Repository" })).toBeVisible(); + expect(within(table).getByText("No result recorded")).toBeVisible(); }); diff --git a/web/stovepipe/view/app.tsx b/web/stovepipe/view/app.tsx index 196e61628..738c29c03 100644 --- a/web/stovepipe/view/app.tsx +++ b/web/stovepipe/view/app.tsx @@ -5,6 +5,7 @@ import { RefreshControls } from "./refresh.js"; import type { StovepipePageModel } from "../entity/index.js"; import { Link, Section } from "./components.js"; import { RequestList, RequestDetails, History } from "./pages.js"; +import { ProjectResults } from "./projects.js"; function Page({ model }: { model: StovepipePageModel }) { return ( @@ -56,9 +57,10 @@ function Page({ model }: { model: StovepipePageModel }) { {(data) => } ) : ( -
+
+
{({ request, events }) => ( -
+ <>

Request status

@@ -67,9 +69,16 @@ function Page({ model }: { model: StovepipePageModel }) {

History

-
+ )} -
+
+
+

Project results

+
+ {(data) => } +
+
+ )}