Skip to content
Draft
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
4 changes: 3 additions & 1 deletion web/package_test/consumer.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { AutoRefresh, RequestStatus } from "@submitqueue/web-submitqueue";
import { AutoRefresh, RequestStatus, RequestList, QueueDirectory } from "@submitqueue/web-submitqueue";
import { loadRequestList } from "@submitqueue/web-submitqueue/server";
import { createFakeGatewayReader } from "@submitqueue/web-submitqueue/testing";

void AutoRefresh;
void RequestStatus;
void RequestList;
void QueueDirectory;
void loadRequestList;
void createFakeGatewayReader;
107 changes: 107 additions & 0 deletions web/service/submitqueue/src/app/(protected)/[queue]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
RequestListView,
Timestamp,
} from "@submitqueue/web-submitqueue";
import {
loadRequestList,
WebPaths,
decodePathSegment,
} from "@submitqueue/web-submitqueue/server";
import type { Metadata } from "next";
import Link from "next/link";
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";
import { loadAuthConfiguration } from "../../../server/auth";
import {
defaultRequestWindow,
decodeRequestPage,
encodeRequestPage,
REQUEST_PAGE_SIZE,
type RequestSearchParams,
} from "../../../server/window";

export const dynamic = "force-dynamic";
export const revalidate = 0;

export const metadata: Metadata = {
title: "Requests",
};

const paths = new WebPaths();

export default async function QueueRequestsPage({
params,
searchParams,
}: Readonly<{
params: Promise<{ queue: string }>;
searchParams: Promise<RequestSearchParams>;
}>) {
await connection();
await requireAuthorization();

const queue = decodePathSegment(decodeURIComponent((await params).queue));
if (queue !== DEMO_QUEUE) {
notFound();
}

const search = await searchParams;
if (search.from !== undefined || search.to !== undefined ||
(search.page !== undefined && typeof search.page !== "string")) {
redirect(paths.requests(queue));
}
const secret = loadAuthConfiguration().token;
const pageWindow = typeof search.page === "string" ? decodeRequestPage(queue, search.page, secret) : undefined;
if (search.page !== undefined && pageWindow === undefined) {
redirect(paths.requests(queue));
}
const requestWindow = pageWindow ?? defaultRequestWindow();

const loaded = await loadRequestList(
resolveDemoGateway,
{
queue,
receivedAtOrAfterMs: requestWindow.fromMs,
receivedBeforeMs: requestWindow.toMs,
pageSize: REQUEST_PAGE_SIZE,
pageToken: requestWindow.pageToken,
},
{ diagnostics: gatewayDiagnostics },
);
const result = loaded.ok && loaded.data.nextPageToken ? {
...loaded,
data: { ...loaded.data, nextPageToken: encodeRequestPage(queue, requestWindow, loaded.data.nextPageToken, secret) },
} : loaded;

return (
<main className="shell">
<div className="page-heading">
<div>
<p className="eyebrow">Queue activity</p>
<h1>{queue}</h1>
<p>{pageWindow ? "Older requests · snapshot" : "Last 24 hours · updates on refresh"}: <Timestamp value={requestWindow.fromMs} /> — <Timestamp value={requestWindow.toMs} /></p>
</div>
<div className="page-actions">
<Link href={paths.requests(queue)}>Latest 24 hours</Link>
<NextRefresh
terminal={pageWindow !== undefined || (!result.ok && !result.error.retryable)}
transientFailureCount={!result.ok && result.error.retryable ? 1 : 0}
{...(pageWindow ? { refreshHref: paths.requests(queue) } : {})}
/>
</div>
</div>

<section className="panel" aria-label="Queue requests">
<RequestListView
key={`${queue}:${search.page ?? "live"}`}
result={result}
/>
</section>
</main>
);
}
5 changes: 4 additions & 1 deletion web/service/submitqueue/src/app/(protected)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { connection } from "next/server";
import { QueueDirectory } from "@submitqueue/web-submitqueue";

import { HOST_QUEUES } from "../../server/config";
import { requireAuthorization } from "../../server/request-auth";

export const dynamic = "force-dynamic";
Expand All @@ -7,5 +10,5 @@ export const revalidate = 0;
export default async function HomePage() {
await connection();
await requireAuthorization();
return <main className="shell"><h1>SubmitQueue</h1><p>Read-only demo host</p></main>;
return <main className="shell"><QueueDirectory queues={HOST_QUEUES} /></main>;
}
34 changes: 34 additions & 0 deletions web/service/submitqueue/src/server/window.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { defaultRequestWindow, decodeRequestPage, encodeRequestPage, REQUEST_WINDOW_MS } from "./window";

const secret = "test-pagination-secret";
const queue = "demo-queue";
const window = defaultRequestWindow(REQUEST_WINDOW_MS + 100);

describe("live request window and snapshot pagination", () => {
it("advances the default window each time it is refreshed", () => {
expect(defaultRequestWindow(REQUEST_WINDOW_MS + 100)).toEqual({ fromMs: 100, toMs: REQUEST_WINDOW_MS + 100 });
expect(defaultRequestWindow(REQUEST_WINDOW_MS + 200)).toEqual({ fromMs: 200, toMs: REQUEST_WINDOW_MS + 200 });
});

it("round-trips stable gateway paging bounds inside one opaque cursor", () => {
const cursor = encodeRequestPage(queue, window, "opaque/token+=\nvalue", secret);
expect(decodeRequestPage(queue, cursor, secret)).toEqual({ ...window, pageToken: "opaque/token+=\nvalue" });
});

it("rejects altered, cross-queue, or differently signed cursors", () => {
const cursor = encodeRequestPage(queue, window, "next", secret);
expect(decodeRequestPage("other-queue", cursor, secret)).toBeUndefined();
expect(decodeRequestPage(queue, `${cursor}x`, secret)).toBeUndefined();
expect(decodeRequestPage(queue, cursor, "other-secret")).toBeUndefined();
});

it.each(["", "not-a-cursor", ".invalid", "a".repeat(16_385)])("rejects malformed cursor", (cursor) => {
expect(decodeRequestPage(queue, cursor, secret)).toBeUndefined();
});

it("rejects signed cursors with missing tokens or invalid snapshot bounds", () => {
expect(decodeRequestPage(queue, encodeRequestPage(queue, window, "", secret), secret)).toBeUndefined();
expect(decodeRequestPage(queue, encodeRequestPage(queue, { fromMs: 200, toMs: 100 }, "next", secret), secret)).toBeUndefined();
});
});
57 changes: 57 additions & 0 deletions web/service/submitqueue/src/server/window.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { createHmac, timingSafeEqual } from "node:crypto";

export const REQUEST_WINDOW_MS = 24 * 60 * 60 * 1_000;
export const REQUEST_PAGE_SIZE = 50;

export type RequestWindow = Readonly<{
fromMs: number;
toMs: number;
pageToken?: string;
}>;

export type RequestSearchParams = Readonly<{
page?: string | string[];
from?: string | string[];
to?: string | string[];
}>;

export function defaultRequestWindow(nowMs = Date.now()): RequestWindow {
return { fromMs: nowMs - REQUEST_WINDOW_MS, toMs: nowMs };
}

function parseMillisecond(value: string | undefined): number | undefined {
if (!value || !/^\d+$/u.test(value)) {
return undefined;
}
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
}

export function encodeRequestPage(queue: string, window: RequestWindow, token: string, secret: string): string {
const body = Buffer.from(
["v1", encodeURIComponent(queue), window.fromMs, window.toMs, token].join("\n"),
).toString("base64url");
const signature = createHmac("sha256", secret).update(body).digest("base64url");
return `${body}.${signature}`;
}

export function decodeRequestPage(queue: string, cursor: string, secret: string): RequestWindow | undefined {
if (cursor.length > 16_384 || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u.test(cursor)) {
return undefined;
}
const [body, signature] = cursor.split(".");
const actual = Buffer.from(signature!, "base64url");
const expected = createHmac("sha256", secret).update(body!).digest();
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
return undefined;
}
const [version, scope, from, to, ...tokenParts] = Buffer.from(body!, "base64url").toString("utf8").split("\n");
const fromMs = parseMillisecond(from);
const toMs = parseMillisecond(to);
const pageToken = tokenParts.join("\n");
if (version !== "v1" || scope !== encodeURIComponent(queue) || fromMs === undefined ||
toMs === undefined || toMs - fromMs !== REQUEST_WINDOW_MS || !pageToken) {
return undefined;
}
return { fromMs, toMs, pageToken };
}
80 changes: 79 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 } from "./components";
import { ErrorState, RequestStatus, QueueDirectory, RequestList, RequestListView } from "./components";
import type { RequestDetailModel, RequestListModel } from "./models";

const request = {
Expand All @@ -15,6 +15,84 @@ const request = {

describe("request components", () => {

it("renders a linked queue request with its complete sqid", () => {
const model: RequestListModel = {
queue: "demo-queue",
receivedAtOrAfterMs: 1,
receivedBeforeMs: 2,
requests: [request],
nextPageToken: "opaque-token",
};
render(<RequestList model={model} />);

const link = screen.getByRole("link", { name: "demo-queue/1" });
expect(link.getAttribute("href")).toBe(
"/demo-queue/request/demo-queue/1",
);
expect(screen.getByText("Speculating").getAttribute("data-tone")).toBe("progress");
expect(screen.getByText("Nov 14, 2023, 10:13:20 PM UTC")).toBeTruthy();
expect(screen.getByRole("link", { name: "Next page" }).getAttribute("href")).toBe(
"/demo-queue?page=opaque-token",
);
});

it("renders an accessible empty state", () => {
render(
<RequestList
model={{
queue: "demo-queue",
receivedAtOrAfterMs: 1,
receivedBeforeMs: 2,
requests: [],
nextPageToken: null,
}}
/>,
);
expect(screen.getByText("No requests were received in this window.")).toBeTruthy();
});

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");
});

it("filters only the displayed page without discarding its pagination link", () => {
render(<RequestList model={{
queue: "demo-queue", receivedAtOrAfterMs: 1, receivedBeforeMs: 2,
requests: [request, { ...request, sqid: "42", status: "landed" }],
nextPageToken: "next",
}} />);
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "landed" } });
expect(screen.queryByRole("link", { name: request.sqid })).toBeNull();
expect(screen.getByRole("link", { name: "42" })).toBeTruthy();
expect(screen.getByRole("link", { name: "Next page" })).toBeTruthy();
});

it("uses host-supplied change labels and links without exposing fake file hints", () => {
const raw = "git://git.example.com/demo/refs%2Fheads%2Fmain/sha?sq-files=demo%2Ffile.txt";
const clean = raw.split("?")[0]!;
render(<RequestList model={{
queue: "demo-queue", receivedAtOrAfterMs: 1, receivedBeforeMs: 2,
requests: [{ ...request, changeUris: [raw] }], nextPageToken: null,
}} changeLabels={{ [raw]: clean }} changeLinks={{ [raw]: "/change" }} />);
expect(screen.getByRole("link", { name: clean }).getAttribute("href")).toBe("/change");
expect(screen.queryByText(/sq-files=/)).toBeNull();
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "file.txt" } });
expect(screen.getByText("No displayed requests match.")).toBeTruthy();
});

it("retains the last successful list on a transient failure, but not a permanent failure", () => {
const view = render(<RequestListView result={{ ok: true, data: {
queue: "demo-queue", receivedAtOrAfterMs: 1, receivedBeforeMs: 2,
requests: [request], nextPageToken: null,
} }} />);
const error = { kind: "transient" as const, title: "Unavailable", message: "Retry", retryable: true };
view.rerender(<RequestListView result={{ ok: false, error }} />);
expect(screen.getByRole("link", { name: request.sqid })).toBeTruthy();
expect(screen.getByText(/last successful snapshot/)).toBeTruthy();
view.rerender(<RequestListView result={{ ok: false, error: { ...error, retryable: false } }} />);
expect(screen.queryByRole("link", { name: request.sqid })).toBeNull();
});

it("shows unknown statuses safely", () => {
render(<RequestStatus status="new_pipeline_step" />);
Expand Down
Loading