diff --git a/web/service/submitqueue/src/app/(protected)/[queue]/request/[...sqid]/page.tsx b/web/service/submitqueue/src/app/(protected)/[queue]/request/[...sqid]/page.tsx new file mode 100644 index 000000000..d3ac76ee5 --- /dev/null +++ b/web/service/submitqueue/src/app/(protected)/[queue]/request/[...sqid]/page.tsx @@ -0,0 +1,81 @@ +import { + RequestDetailView, +} from "@submitqueue/web-submitqueue"; +import { + loadRequestDetail, + requestDetailRefreshState, + WebPaths, + decodePathSegment, +} from "@submitqueue/web-submitqueue/server"; +import type { Metadata } from "next"; +import { notFound, redirect } from "next/navigation"; +import { connection } from "next/server"; + +import { DEMO_QUEUE } from "../../../../../server/config"; +import { gatewayDiagnostics } from "../../../../../server/diagnostics"; +import { resolveDemoGateway } from "../../../../../server/gateway"; +import { requireAuthorization } from "../../../../../server/request-auth"; +import { NextRefresh } from "../../../../../components/next-refresh"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export const metadata: Metadata = { + title: "Request status", +}; + +const paths = new WebPaths(); + +export default async function RequestPage({ + params, + searchParams, +}: Readonly<{ + params: Promise<{ queue: string; sqid: string[] }>; + searchParams: Promise<{ view?: string | string[]; from?: string | string[]; to?: string | string[]; page?: string | string[] }>; +}>) { + await connection(); + await requireAuthorization(); + + const { queue: queuePath, sqid: sqidPath } = await params; + const queue = decodePathSegment(decodeURIComponent(queuePath)); + if (queue !== DEMO_QUEUE || sqidPath.length === 0) { + notFound(); + } + const sqid = sqidPath.map(value => decodePathSegment(decodeURIComponent(value))).join("/"); + const search = await searchParams; + const view = search.view === "history" ? "history" : "summary"; + if (search.from !== undefined || search.to !== undefined || search.page !== undefined) { + redirect(paths.request(queue, sqid, { view })); + } + + const result = await loadRequestDetail( + resolveDemoGateway, + { + queue, + sqid, + }, + { diagnostics: gatewayDiagnostics }, + ); + + if (!result.ok && result.error.kind === "not-found") { + notFound(); + } + + const refreshState = result.ok ? requestDetailRefreshState(result.data) : { + terminal: !result.error.retryable, + transientFailureCount: result.error.retryable ? 1 : 0, + }; + + return ( +
+ } + /> +
+ ); +} diff --git a/web/submitqueue/src/components.test.tsx b/web/submitqueue/src/components.test.tsx index 8930d94a6..7c7a87648 100644 --- a/web/submitqueue/src/components.test.tsx +++ b/web/submitqueue/src/components.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { ErrorState, RequestStatus, QueueDirectory, RequestList, RequestListView } from "./components"; +import { ErrorState, RequestStatus, QueueDirectory, RequestList, RequestListView, RequestDetail } from "./components"; import type { RequestDetailModel, RequestListModel } from "./models"; const request = { @@ -51,6 +51,41 @@ describe("request components", () => { expect(screen.getByText("No requests were received in this window.")).toBeTruthy(); }); + it("renders ordered status/build history with expandable metadata", () => { + const model: RequestDetailModel = { + request: { ...request, status: "landed" }, + history: [ + { + timestampMs: 1_700_000_000_000, + type: "status", + status: "started", + event: null, + lastError: null, + metadata: {}, + }, + { + timestampMs: 1_700_000_001_000, + type: "event", + status: null, + event: "building", + lastError: null, + metadata: { build_url: "https://build.example/1" }, + }, + ], + historyError: null, + }; + render(); + + expect(screen.getByRole("heading", { level: 1 }).textContent).toContain("demo-queue/1"); + const history = screen.getByRole("list", { name: "Request history" }); + expect(within(history).getAllByRole("listitem")).toHaveLength(2); + expect(within(history).getByText("Building")).toBeTruthy(); + fireEvent.click(within(history).getByText("Building")); + expect(within(history).getByText("https://build.example/1")).toBeTruthy(); + fireEvent.change(screen.getByRole("combobox"), { target: { value: "event" } }); + expect(within(history).getAllByRole("listitem")).toHaveLength(1); + }); + it("lists configured queues even when there is only one", () => { render(); expect(screen.getByRole("link", { name: "demo-queue" }).getAttribute("href")).toBe("/demo-queue"); @@ -94,6 +129,20 @@ describe("request components", () => { expect(screen.queryByRole("link", { name: request.sqid })).toBeNull(); }); + it("does not describe unavailable history as empty and copies the opaque ID", async () => { + const writeText = vi.fn(async () => undefined); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + render(); + expect(screen.getByText("History unavailable")).toBeTruthy(); + expect(screen.queryByText("No history has been retained for this request.")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Copy ID" })); + await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith(request.sqid)); + }); + it("shows unknown statuses safely", () => { render(); expect(screen.getByText("New Pipeline Step").getAttribute("data-tone")).toBe("neutral"); diff --git a/web/submitqueue/src/components.tsx b/web/submitqueue/src/components.tsx index a099b0597..52c90c138 100644 --- a/web/submitqueue/src/components.tsx +++ b/web/submitqueue/src/components.tsx @@ -85,6 +85,35 @@ export function RequestList({ model, basePath, empty, changeLinks, changeLabels ; } +function HistoryValue({ item }: { item: HistoryEventModel }) { + if (item.type === "status" && item.status !== null) { + return ; + } + return {item.type === "event" && item.event !== null ? statusDisplay(item.event).label : "Unknown event"}; +} + +export function RequestHistory({ events }: { events: HistoryEventModel[] }) { + const [filter, setFilter] = useState("all"); + const shown = events.filter(event => filter === "all" || event.type === filter); + return
+

Request history

+ {events.length === 0 ?

No history has been retained for this request.

: +
    {shown.map((item, index) => +
  1. + +
    + {Object.keys(item.metadata).length ? :

    No additional metadata.

    } +
    + {item.type === "status" ? "Lifecycle" : item.type === "event" ? "Occurrence" : "Unknown"} + {item.lastError ?

    {item.lastError}

    : null} +
  2. + )}
} +

{shown.length} displayed events · oldest first · UTC

+
; +} + export function ErrorState({ error, compact = false }: { error: WebError; compact?: boolean }) { return

{error.title}

{error.message}

@@ -113,3 +142,73 @@ export function RequestListView({ result, ...props }: Omit : null} ; } + +function CopyControls({ id }: { id: string }) { + const [message, setMessage] = useState(""); + const copyValue = async (value: string, label: string) => { + try { + await navigator.clipboard.writeText(value); + setMessage(`${label} copied`); + } catch { + setMessage("Copy unavailable. Select the ID or copy the browser address."); + } + }; + return
+ + + {message} +
; +} + +export interface RequestDetailProps { + model: RequestDetailModel; + view?: "summary" | "history"; + basePath?: string; + backHref?: string; + summaryHref?: string; + historyHref?: string; + changeLinks?: Record; + changeLabels?: Record; + controls?: ReactNode; +} + +export function RequestDetail({ model, view = "summary", basePath, backHref, summaryHref, historyHref, changeLinks, changeLabels, controls }: RequestDetailProps) { + const { request } = model; + const paths = new WebPaths(basePath === undefined ? {} : { basePath }); + const latest = model.history.at(-1); + return
+ +

{request.sqid}

{controls}
+ {request.lastError ?

{request.lastError}

: null} + + {view === "history" ? model.historyError ? : : +
+
Queue
{request.queue}
Request ID
{request.sqid}
+
Received · UTC
+
Last recorded event
{model.historyError ? "History unavailable" : latest ? <> : "No retained events"}
+
Last error
{request.lastError ?? "None reported"}
+

Changes

+
Request metadata{Object.keys(request.metadata).length ? :

No additional metadata.

}
+ {model.historyError ? : null} +
} + {view === "summary" && model.historyError === null ? +
    + {model.history.filter(event => event.type === "status" && event.status !== null).map((event, index) => +
  1. {statusDisplay(event.status!).label}
  2. + )} +
: null} +
; +} + +export function RequestDetailView({ result, ...props }: Omit & { + result: LoadResult; +}) { + const data = useLastSuccessfulData(result); + return <> + {!result.ok ? <>{data ?

Showing the last successful snapshot; updates are unavailable.

: null} : null} + {data ? : props.controls} + ; +} diff --git a/web/submitqueue/src/index.ts b/web/submitqueue/src/index.ts index 8556d766d..bed92f00d 100644 --- a/web/submitqueue/src/index.ts +++ b/web/submitqueue/src/index.ts @@ -9,6 +9,10 @@ export { RequestList, RequestListView, type RequestListProps, + RequestHistory, + RequestDetail, + RequestDetailView, + type RequestDetailProps, } from "./components.js"; export type { QueueModel,