Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions web/stovepipe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Mount `OpenIssuePagesProvider` with an authenticated `loadPage(href, signal)` ca

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.

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
Expand Down
37 changes: 35 additions & 2 deletions web/stovepipe/controller/request.ts
Original file line number Diff line number Diff line change
@@ -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<RequestHistoryModel> {
const result = await service.getRequestHistoryByID(
Expand All @@ -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),
})),
};
}
2 changes: 2 additions & 0 deletions web/stovepipe/entity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
7 changes: 7 additions & 0 deletions web/stovepipe/extension/service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
81 changes: 79 additions & 2 deletions web/stovepipe/module.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -19,7 +20,7 @@ const summary: RequestSummary = {
projectCount: 51,
brokenProjectCount: 2,
};
function createPageTestContext() {
function createPageTestContext(options: Pick<StovepipeWebOptions, "resolveBuildUrl"> = {}) {
const service: StovepipeService = {
listOpenIssues: vi.fn().mockResolvedValue({ issues: [], nextPageToken: "" }),
list: vi
Expand Down Expand Up @@ -55,6 +56,7 @@ function createPageTestContext() {
service,
paths,
queues: [{ name: "repo/main" }],
...options,
});
return { service, paths, app };
}
Expand Down Expand Up @@ -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" } },
Expand Down
6 changes: 4 additions & 2 deletions web/stovepipe/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -30,6 +30,8 @@ export interface StovepipeWebOptions {
queues: readonly Queue[];
paths?: StovepipePaths;
authorize?(principal: unknown, queue: string): boolean | Promise<boolean>;
/** 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();
Expand Down Expand Up @@ -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",
),
Expand Down
1 change: 1 addition & 0 deletions web/stovepipe/view/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const model: StovepipePageModel = {
kind: "requestState",
label: "accepted",
outcomeReason: "",
details: [],
},
],
},
Expand Down
43 changes: 41 additions & 2 deletions web/stovepipe/view/pages.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<History events={events} />);
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(<History events={[{
id: "build", timestampMs: "1000", kind: "event", label: "build_finished", outcomeReason: "",
details: [
{ label: "Build ID", value: "runner/build/7?attempt=2", href: "https://builds.example.test/pipeline/builds/7?attempt=2" },
],
}, {
id: "unresolved", timestampMs: "2000", kind: "event", label: "build_triggered", outcomeReason: "",
details: [{ label: "Build ID", value: "runner/build/8" }],
}, {
id: "validation", timestampMs: "3000", kind: "event", label: "validation_fact_recorded", outcomeReason: "",
details: [{ label: "Breakage degree", value: "0.5" }],
}]} />);
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",
Expand Down
11 changes: 10 additions & 1 deletion web/stovepipe/view/pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -105,7 +106,7 @@ export function History({ events }: { events: HistoryEventModel[] }) {
return events.length ? (
<Table
label="Request history"
headers={["Time", "Type", "State or event", "Outcome"]}
headers={["Time", "Type", "State or event", "Outcome", "Details"]}
fill
>
{events.map((event) => (
Expand All @@ -122,6 +123,14 @@ export function History({ events }: { events: HistoryEventModel[] }) {
</StyledTableBodyCell>
<StyledTableBodyCell>{event.label}</StyledTableBodyCell>
<StyledTableBodyCell>{event.outcomeReason || "—"}</StyledTableBodyCell>
<StyledTableBodyCell>
{event.details.length ? event.details.map((detail) => (
<Block key={detail.label} $style={{ overflowWrap: "anywhere", minWidth: "10rem", maxWidth: "28rem" }}>
<Block as="span" color="contentSecondary">{detail.label}: </Block>
{detail.href ? <StyledLink href={detail.href}>{detail.value}</StyledLink> : detail.value}
</Block>
)) : "—"}
</StyledTableBodyCell>
</StyledTableBodyRow>
))}
</Table>
Expand Down
15 changes: 15 additions & 0 deletions web/test/e2e/stovepipe/run_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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);"
Expand Down
Loading
Loading