Skip to content

Commit cc185e0

Browse files
authored
feat(web): Add project validation results (#889)
## 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)
1 parent 1f2d0b3 commit cc185e0

15 files changed

Lines changed: 415 additions & 22 deletions

File tree

‎web/service/stovepipe/src/server/instrumentation.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { instrumentStovepipeWeb } from "./instrumentation";
66
function createHostFixture() {
77
const list = vi.fn().mockResolvedValue({ requests: [], nextPageToken: "" });
88
const getRequestHistoryByID = vi.fn().mockRejectedValue(new Error("private failure"));
9-
const web = createStovepipeWeb({ queues: [{ name: "demo" }], service: { list, getRequestHistoryByID } });
9+
const getProjectStatusByID = vi.fn().mockRejectedValue(new Error("private failure"));
10+
const web = createStovepipeWeb({ queues: [{ name: "demo" }], service: { list, getRequestHistoryByID, getProjectStatusByID } });
1011
const record = vi.fn();
1112
const add = vi.fn();
1213
const span = { setAttribute: vi.fn(), setStatus: vi.fn(), recordException: vi.fn(), end: vi.fn() };

‎web/service/stovepipe/src/server/instrumentation.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ export function instrumentStovepipeWeb(
1515
const result = await web.handle(request);
1616
outcome = result.kind;
1717
if (result.kind === "render") {
18-
const section = result.model.kind === "queue" ? result.model.requests : result.model.history;
19-
if (!section.ok) {
18+
const sections = result.model.kind === "queue"
19+
? [result.model.requests] : [result.model.history, result.model.projectStatus];
20+
if (sections.some((section) => !section.ok)) {
2021
outcome = "error";
2122
observeTelemetry(() => span?.setStatus({ code: SpanStatusCode.ERROR }));
2223
}

‎web/stovepipe/README.md‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
11
# Stovepipe web UI
22

3-
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.
3+
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.
44

55
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.
66

77
## Integration
88

9-
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.
9+
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.
1010

1111
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`.
1212

1313
## Routes and data
1414

1515
Request pages use `/<queue>/request/<request-id>` 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.
1616

17+
Project results use `GetProjectStatusByID` for that exact request, with an empty
18+
project selection to read all recorded projects. Each page contains up to 50
19+
projects; `?projects=<opaque-token>` carries its continuation and can be shared.
20+
First-page navigation removes the cursor. Pages are live rather than a snapshot;
21+
return to the first page to see newly recorded projects.
22+
23+
Repository breakage, per-project breakage, and recording completion are separate.
24+
Zero breakage is green; absent results are never treated as green. Completion
25+
describes result production, not whether all pages have been displayed. Project
26+
and history reads fail independently, and backend diagnostics stay in host logs.
27+
All result mapping, rendering, pagination, refresh and stale-result handling live
28+
in this package; hosts only adapt their transport representation.
29+
1730
## Refresh and presentation
1831

1932
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.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { ProjectStatusModel } from "../entity/index.js";
2+
import type { StovepipeService } from "../extension/service/index.js";
3+
import type { StovepipePaths } from "../core/paths.js";
4+
5+
function breakageDegree(value: number): number {
6+
if (!Number.isFinite(value) || value < 0 || value > 1)
7+
throw new Error("Invalid breakage degree");
8+
return value;
9+
}
10+
11+
export async function loadProjectStatus(
12+
service: StovepipeService,
13+
paths: StovepipePaths,
14+
queue: string,
15+
requestId: string,
16+
pageToken: string,
17+
signal?: AbortSignal,
18+
): Promise<ProjectStatusModel> {
19+
const result = await service.getProjectStatusByID(
20+
{ queue, requestId, projects: [], pageSize: 50, pageToken },
21+
signal ? { signal } : {},
22+
);
23+
if (result.queue !== queue || result.requestId !== requestId)
24+
throw new Error("Project response does not identify the selected request");
25+
return {
26+
updatedAtMs: result.updatedAtMs.toString(),
27+
repositoryBreakageDegree: result.repositoryResult.case === "repositoryBreakageDegree"
28+
? breakageDegree(result.repositoryResult.value) : null,
29+
complete: result.projectResultsComplete,
30+
projects: result.projects.map((entry) => ({
31+
project: entry.project,
32+
breakageDegree: entry.result.case === "breakageDegree"
33+
? breakageDegree(entry.result.value) : null,
34+
})),
35+
firstHref: pageToken ? paths.request(queue, requestId) : null,
36+
nextHref: result.nextPageToken
37+
? paths.request(queue, requestId, result.nextPageToken)
38+
: null,
39+
};
40+
}

‎web/stovepipe/core/paths.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ export class StovepipePaths {
99
const path = `${this.basePath}/${encodePathSegment(queue)}`;
1010
return page ? `${path}?${new URLSearchParams({ page })}` : path;
1111
}
12-
request(queue: string, requestId: string): string {
13-
return `${this.queue(queue)}/request/${encodePathSegment(requestId)}`;
12+
request(queue: string, requestId: string, projects = ""): string {
13+
const path = `${this.queue(queue)}/request/${encodePathSegment(requestId)}`;
14+
return projects ? `${path}?${new URLSearchParams({ projects })}` : path;
1415
}
1516
}

‎web/stovepipe/entity/index.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@ export interface RequestHistoryModel {
4141
/** Retained events ordered by occurrence time and stable event identity. */
4242
events: HistoryEventModel[];
4343
}
44+
export interface ProjectStatusModel {
45+
/** Newest durable result or lifecycle record in Unix milliseconds. */
46+
updatedAtMs: string;
47+
/** Whole-repository breakage on [0, 1]; null means no recorded result. */
48+
repositoryBreakageDegree: number | null;
49+
/** Whether production of relevant project results has finished, independent of pagination. */
50+
complete: boolean;
51+
/** Recorded projects on this page; null breakage means no result. */
52+
projects: { project: string; breakageDegree: number | null }[];
53+
/** First project page for this request; null on the first page. */
54+
firstHref: string | null;
55+
/** Opaque project continuation URL; null on the final page. */
56+
nextHref: string | null;
57+
}
4458
interface PageBase {
4559
/** Route and cursor identity; independent of refresh time. */
4660
key: string;
@@ -70,5 +84,6 @@ export type StovepipePageModel = PageBase &
7084
kind: "request";
7185
requestId: string;
7286
history: ReadResult<RequestHistoryModel>;
87+
projectStatus: ReadResult<ProjectStatusModel>;
7388
}
7489
);

‎web/stovepipe/extension/service/index.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ export interface HistoryEvent {
1818
outcomeReason: string;
1919
}
2020

21+
export interface ProjectStatus {
22+
queue: string;
23+
requestId: string;
24+
updatedAtMs: bigint;
25+
repositoryResult:
26+
| { case: "repositoryBreakageDegree"; value: number }
27+
| { case: undefined; value?: undefined };
28+
projectResultsComplete: boolean;
29+
projects: {
30+
project: string;
31+
result:
32+
| { case: "breakageDegree"; value: number }
33+
| { case: undefined; value?: undefined };
34+
}[];
35+
nextPageToken: string;
36+
}
37+
2138
/** Hosts supply a transport client; the library has no generated-binding dependency. */
2239
export interface StovepipeService {
2340
list(
@@ -31,4 +48,14 @@ export interface StovepipeService {
3148
requestSummary?: RequestSummary | undefined;
3249
events: HistoryEvent[];
3350
}>;
51+
getProjectStatusByID(
52+
request: {
53+
queue: string;
54+
requestId: string;
55+
projects: string[];
56+
pageSize: number;
57+
pageToken: string;
58+
},
59+
options?: { signal?: AbortSignal },
60+
): Promise<ProjectStatus>;
3461
}

‎web/stovepipe/module.test.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { StovepipePaths } from "./core/paths.js";
44
import type {
55
StovepipeService,
66
RequestSummary,
7+
ProjectStatus,
78
} from "./extension/service/index.js";
89

910
const summary: RequestSummary = {
@@ -32,6 +33,19 @@ function createPageTestContext() {
3233
},
3334
],
3435
}),
36+
getProjectStatusByID: vi.fn().mockResolvedValue({
37+
queue: summary.queue,
38+
requestId: summary.requestId,
39+
updatedAtMs: 9007199254740993n,
40+
repositoryResult: { case: "repositoryBreakageDegree", value: 0 },
41+
projectResultsComplete: false,
42+
projects: [
43+
{ project: "green", result: { case: "breakageDegree", value: 0 } },
44+
{ project: "broken", result: { case: "breakageDegree", value: 0.25 } },
45+
{ project: "unknown", result: { case: undefined } },
46+
],
47+
nextPageToken: "next+/=",
48+
}),
3549
};
3650
const paths = new StovepipePaths("/stovepipe");
3751
const app = createStovepipeWeb({
@@ -142,6 +156,7 @@ describe("Stovepipe page loading", () => {
142156
paths.request("repo/main", "7"),
143157
]);
144158
expect(service.getRequestHistoryByID).not.toHaveBeenCalled();
159+
expect(service.getProjectStatusByID).not.toHaveBeenCalled();
145160
});
146161
it("loads the current summary and events by ID without reconstructing state from history", async () => {
147162
const { app, service, paths } = createPageTestContext();
@@ -155,6 +170,10 @@ describe("Stovepipe page loading", () => {
155170
{ queue: "repo/main", requestId: summary.requestId },
156171
{ signal },
157172
);
173+
expect(service.getProjectStatusByID).toHaveBeenCalledWith(
174+
{ queue: summary.queue, requestId: summary.requestId, projects: [], pageSize: 50, pageToken: "" },
175+
{ signal },
176+
);
158177
expect(result).toMatchObject({
159178
kind: "render",
160179
model: {
@@ -247,6 +266,7 @@ describe("Stovepipe page loading", () => {
247266
});
248267
expect(result).toEqual({ kind: "forbidden" });
249268
expect(service.getRequestHistoryByID).not.toHaveBeenCalled();
269+
expect(service.getProjectStatusByID).not.toHaveBeenCalled();
250270
});
251271
it.each([
252272
"/other",
@@ -263,5 +283,85 @@ describe("Stovepipe page loading", () => {
263283
).toEqual({ kind: "not-found" });
264284
expect(service.list).not.toHaveBeenCalled();
265285
expect(service.getRequestHistoryByID).not.toHaveBeenCalled();
286+
expect(service.getProjectStatusByID).not.toHaveBeenCalled();
287+
});
288+
289+
it("loads recorded projects by exact request ID and preserves opaque cursors and int64 precision", async () => {
290+
const { app, service, paths } = createPageTestContext();
291+
const result = await app.handle({
292+
path: paths.request(summary.queue, summary.requestId),
293+
search: new URLSearchParams({ projects: "opaque+/=" }),
294+
});
295+
expect(service.getProjectStatusByID).toHaveBeenCalledWith({
296+
queue: summary.queue, requestId: summary.requestId,
297+
projects: [], pageSize: 50, pageToken: "opaque+/=",
298+
}, {});
299+
expect(result).toMatchObject({ kind: "render", model: { projectStatus: {
300+
ok: true,
301+
data: {
302+
updatedAtMs: "9007199254740993",
303+
repositoryBreakageDegree: 0,
304+
complete: false,
305+
projects: [
306+
{ project: "green", breakageDegree: 0 },
307+
{ project: "broken", breakageDegree: 0.25 },
308+
{ project: "unknown", breakageDegree: null },
309+
],
310+
firstHref: paths.request(summary.queue, summary.requestId),
311+
nextHref: paths.request(summary.queue, summary.requestId, "next+/="),
312+
},
313+
} } });
314+
expect(() => JSON.stringify(result)).not.toThrow();
315+
});
316+
317+
it.each([
318+
{ queue: "other" },
319+
{ requestId: "other" },
320+
{ projects: [{ project: "invalid", result: { case: "breakageDegree", value: NaN } }] },
321+
{ repositoryResult: { case: "repositoryBreakageDegree", value: 1.1 } },
322+
] satisfies Partial<ProjectStatus>[])("rejects mismatched or malformed project status %j", async (overrides) => {
323+
const { app, service, paths } = createPageTestContext();
324+
const original = await service.getProjectStatusByID({
325+
queue: summary.queue, requestId: summary.requestId, projects: [], pageSize: 50, pageToken: "",
326+
});
327+
vi.mocked(service.getProjectStatusByID).mockResolvedValue({
328+
...original, ...overrides,
329+
});
330+
const result = await app.handle({
331+
path: paths.request(summary.queue, summary.requestId), search: new URLSearchParams(),
332+
});
333+
expect(result).toMatchObject({
334+
kind: "render", model: { history: { ok: true }, projectStatus: { ok: false } },
335+
});
336+
});
337+
338+
it("loads history independently when the project read fails without leaking diagnostics", async () => {
339+
const { app, service, paths } = createPageTestContext();
340+
vi.mocked(service.getProjectStatusByID).mockRejectedValue(new Error("private diagnostic"));
341+
const result = await app.handle({
342+
path: paths.request(summary.queue, summary.requestId), search: new URLSearchParams(),
343+
});
344+
expect(result).toMatchObject({
345+
kind: "render", model: { history: { ok: true }, projectStatus: { ok: false } },
346+
});
347+
expect(JSON.stringify(result)).not.toContain("private diagnostic");
348+
});
349+
350+
it("keeps unknown repository results and empty complete pages distinct from green", async () => {
351+
const { app, service, paths } = createPageTestContext();
352+
vi.mocked(service.getProjectStatusByID).mockResolvedValue({
353+
queue: summary.queue, requestId: summary.requestId, updatedAtMs: 0n,
354+
repositoryResult: { case: undefined }, projectResultsComplete: true,
355+
projects: [], nextPageToken: "",
356+
});
357+
const result = await app.handle({
358+
path: paths.request(summary.queue, summary.requestId), search: new URLSearchParams(),
359+
});
360+
expect(result).toMatchObject({ kind: "render", model: { projectStatus: {
361+
ok: true, data: {
362+
repositoryBreakageDegree: null, complete: true, projects: [],
363+
firstHref: null, nextHref: null,
364+
},
365+
} } });
266366
});
267367
});

‎web/stovepipe/module.ts‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { StovepipePaths } from "./core/paths.js";
66
import { readSection } from "./controller/page.js";
77
import { loadQueueRequests } from "./controller/queue.js";
88
import { loadRequestHistory } from "./controller/request.js";
9+
import { loadProjectStatus } from "./controller/projects.js";
910

1011
export interface StovepipeWebRequest {
1112
/** Percent-encoded URL pathname, including the mount path. */
@@ -78,14 +79,24 @@ export function createStovepipeWeb(options: StovepipeWebOptions) {
7879
);
7980
return { kind: "render", model: { ...common, kind: "queue", requests } };
8081
}
81-
const history = await readSection(
82-
() => loadRequestHistory(service, paths, queue.name, requestId, request.signal),
83-
onLog,
84-
"request history"
85-
);
82+
const [history, projectStatus] = await Promise.all([
83+
readSection(
84+
() => loadRequestHistory(service, paths, queue.name, requestId, request.signal),
85+
onLog,
86+
"request history",
87+
),
88+
readSection(
89+
() => loadProjectStatus(
90+
service, paths, queue.name, requestId,
91+
request.search.get("projects") ?? "", request.signal,
92+
),
93+
onLog,
94+
"project results",
95+
),
96+
]);
8697
return {
8798
kind: "render",
88-
model: { ...common, kind: "request", requestId, history },
99+
model: { ...common, kind: "request", requestId, history, projectStatus },
89100
};
90101
} catch (error) {
91102
onLog("error", "Web page load failed", { error });

0 commit comments

Comments
 (0)