Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -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 (
<main className="shell">
<RequestDetailView
key={`${queue}:${sqid}`}
result={result} view={view}
backHref={paths.requests(queue)}
summaryHref={paths.request(queue, sqid)}
historyHref={paths.request(queue, sqid, { view: "history" })}
controls={<NextRefresh {...refreshState} />}
/>
</main>
);
}
51 changes: 50 additions & 1 deletion web/submitqueue/src/components.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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(<RequestDetail model={model} view="history" />);

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(<QueueDirectory queues={[{ name: "demo-queue", description: "Demo gateway" }]} />);
expect(screen.getByRole("link", { name: "demo-queue" }).getAttribute("href")).toBe("/demo-queue");
Expand Down Expand Up @@ -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(<RequestDetail model={{
request, history: [], historyError: {
kind: "transient", title: "Unavailable", message: "Try again", retryable: true,
},
}} />);
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(<RequestStatus status="new_pipeline_step" />);
expect(screen.getByText("New Pipeline Step").getAttribute("data-tone")).toBe("neutral");
Expand Down
99 changes: 99 additions & 0 deletions web/submitqueue/src/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,35 @@ export function RequestList({ model, basePath, empty, changeLinks, changeLabels
</section>;
}

function HistoryValue({ item }: { item: HistoryEventModel }) {
if (item.type === "status" && item.status !== null) {
return <RequestStatus status={item.status} />;
}
return <span className="sq-event">{item.type === "event" && item.event !== null ? statusDisplay(item.event).label : "Unknown event"}</span>;
}

export function RequestHistory({ events }: { events: HistoryEventModel[] }) {
const [filter, setFilter] = useState("all");
const shown = events.filter(event => filter === "all" || event.type === filter);
return <section>
<div className="sq-toolbar"><h2>Request history</h2><label>Show<select value={filter} onChange={event => setFilter(event.target.value)}>
<option value="all">All events</option><option value="status">Lifecycle</option><option value="event">Occurrence events</option>
</select></label></div>
{events.length === 0 ? <p className="sq-empty">No history has been retained for this request.</p> :
<ol className="sq-history" aria-label="Request history">{shown.map((item, index) =>
<li className="sq-history__item" key={`${item.timestampMs}-${index}`}>
<Timestamp value={item.timestampMs} />
<details><summary><HistoryValue item={item} /></summary>
{Object.keys(item.metadata).length ? <Metadata values={item.metadata} /> : <p className="sq-secondary">No additional metadata.</p>}
</details>
<span className="sq-secondary">{item.type === "status" ? "Lifecycle" : item.type === "event" ? "Occurrence" : "Unknown"}</span>
{item.lastError ? <p className="sq-row-error" role="alert">{item.lastError}</p> : null}
</li>
)}</ol>}
<p className="sq-secondary" aria-live="polite">{shown.length} displayed events · oldest first · UTC</p>
</section>;
}

export function ErrorState({ error, compact = false }: { error: WebError; compact?: boolean }) {
return <section className="sq-error" data-error-kind={error.kind} role={compact ? "status" : "alert"}>
<h2>{error.title}</h2><p>{error.message}</p>
Expand Down Expand Up @@ -113,3 +142,73 @@ export function RequestListView({ result, ...props }: Omit<RequestListProps, "mo
{data ? <RequestList model={data} {...props} /> : 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 <div className="sq-copy">
<button type="button" onClick={() => void copyValue(id, "ID")}>Copy ID</button>
<button type="button" onClick={() => void copyValue(window.location.href, "Link")}>Copy link</button>
<span role="status">{message}</span>
</div>;
}

export interface RequestDetailProps {
model: RequestDetailModel;
view?: "summary" | "history";
basePath?: string;
backHref?: string;
summaryHref?: string;
historyHref?: string;
changeLinks?: Record<string, string>;
changeLabels?: Record<string, string>;
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 <article className="sq-request-detail">
<nav className="sq-breadcrumb" aria-label="Breadcrumb"><a href={paths.directory()}>Queues</a><span>›</span><a href={backHref ?? paths.requests(request.queue)}>{request.queue}</a><span>› Request</span></nav>
<header className="sq-detail-heading"><div><h1>{request.sqid}</h1><RequestStatus status={request.status} /></div><div className="sq-detail-actions"><CopyControls id={request.sqid} />{controls}</div></header>
{request.lastError ? <p className="sq-failure-banner" role="alert">{request.lastError}</p> : null}
<nav className="sq-tabs" aria-label="Request views">
<a aria-current={view === "summary" ? "page" : undefined} href={summaryHref ?? paths.request(request.queue, request.sqid)}>Summary</a>
<a aria-current={view === "history" ? "page" : undefined} href={historyHref ?? paths.request(request.queue, request.sqid, { view: "history" })}>History{model.historyError ? "" : ` (${model.history.length})`}</a>
</nav>
{view === "history" ? model.historyError ? <ErrorState error={model.historyError} /> : <RequestHistory events={model.history} /> :
<div className="sq-detail-columns"><dl className="sq-facts">
<div><dt>Queue</dt><dd>{request.queue}</dd></div><div><dt>Request ID</dt><dd>{request.sqid}</dd></div>
<div><dt>Received · UTC</dt><dd><Timestamp value={request.receivedAtMs} /></dd></div>
<div><dt>Last recorded event</dt><dd>{model.historyError ? "History unavailable" : latest ? <><HistoryValue item={latest} /> <Timestamp value={latest.timestampMs} /></> : "No retained events"}</dd></div>
<div><dt>Last error</dt><dd>{request.lastError ?? "None reported"}</dd></div>
</dl><div><section className="sq-soft-panel"><h2>Changes</h2><RequestChanges request={request} {...(changeLinks ? { links: changeLinks } : {})} {...(changeLabels ? { labels: changeLabels } : {})} /></section>
<details className="sq-soft-panel"><summary>Request metadata</summary>{Object.keys(request.metadata).length ? <Metadata values={request.metadata} /> : <p className="sq-secondary">No additional metadata.</p>}</details>
{model.historyError ? <ErrorState compact error={model.historyError} /> : null}
</div></div>}
{view === "summary" && model.historyError === null ?
<ol className="sq-stage" aria-label="Observed lifecycle transitions">
{model.history.filter(event => event.type === "status" && event.status !== null).map((event, index) =>
<li key={`${event.timestampMs}-${index}`}>{statusDisplay(event.status!).label}</li>
)}
</ol> : null}
</article>;
}

export function RequestDetailView({ result, ...props }: Omit<RequestDetailProps, "model"> & {
result: LoadResult<RequestDetailModel>;
}) {
const data = useLastSuccessfulData(result);
return <>
{!result.ok ? <><ErrorState compact={data !== undefined} error={result.error} />{data ? <p className="sq-secondary">Showing the last successful snapshot; updates are unavailable.</p> : null}</> : null}
{data ? <RequestDetail model={data} {...props} /> : props.controls}
</>;
}
4 changes: 4 additions & 0 deletions web/submitqueue/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export {
RequestList,
RequestListView,
type RequestListProps,
RequestHistory,
RequestDetail,
RequestDetailView,
type RequestDetailProps,
} from "./components.js";
export type {
QueueModel,
Expand Down