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
3 changes: 2 additions & 1 deletion web/service/stovepipe/src/server/instrumentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() };
Expand Down
5 changes: 3 additions & 2 deletions web/service/stovepipe/src/server/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
}
Expand Down
17 changes: 15 additions & 2 deletions web/stovepipe/README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
# 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`.

## Routes and data

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.

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=<opaque-token>` 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.
40 changes: 40 additions & 0 deletions web/stovepipe/controller/projects.ts
Original file line number Diff line number Diff line change
@@ -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<ProjectStatusModel> {
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,
};
}
5 changes: 3 additions & 2 deletions web/stovepipe/core/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
15 changes: 15 additions & 0 deletions web/stovepipe/entity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -70,5 +84,6 @@ export type StovepipePageModel = PageBase &
kind: "request";
requestId: string;
history: ReadResult<RequestHistoryModel>;
projectStatus: ReadResult<ProjectStatusModel>;
}
);
27 changes: 27 additions & 0 deletions web/stovepipe/extension/service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<ProjectStatus>;
}
100 changes: 100 additions & 0 deletions web/stovepipe/module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { StovepipePaths } from "./core/paths.js";
import type {
StovepipeService,
RequestSummary,
ProjectStatus,
} from "./extension/service/index.js";

const summary: RequestSummary = {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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();
Expand All @@ -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: {
Expand Down Expand Up @@ -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",
Expand All @@ -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<ProjectStatus>[])("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,
},
} } });
});
});
23 changes: 17 additions & 6 deletions web/stovepipe/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 });
Expand Down
Loading
Loading