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
1 change: 1 addition & 0 deletions web/platform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from "./table.js";
export * from "./facts.js";
export * from "./select-overrides.js";
export * from "./paged-requests.js";
export * from "./use-page-loader.js";
40 changes: 39 additions & 1 deletion web/platform/paged-requests.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "./test/rtl.js";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { PagedRequests, RequestPagesProvider, type RequestPage } from "./paged-requests.js";
import { PagedRequests, RequestPagesProvider, type RequestPage, type RequestPageLoader } from "./paged-requests.js";

interface Row {
id: string;
Expand Down Expand Up @@ -107,3 +107,41 @@ it("aborts a departing queue, restores its loaded rows, and keeps new arrivals b
expect(displayedIds()).toEqual(["4", "3"]);
expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
});

it("keeps loading older requests while a refresh updates the latest rows", async () => {
let finish!: (result: RequestPage<Row>) => void;
const loadPage = vi.fn<RequestPageLoader>(() => new Promise<RequestPage<Row>>(resolve => { finish = resolve; }));
const { rerender } = render(<RequestPagesProvider loadPage={loadPage}>
<RowList routeKey="queue" initial={page(["3"])} />
</RequestPagesProvider>);
await act(async () => intersect());
const signal = loadPage.mock.calls[0]![1];
const latest = page(["4", "3"]);
latest.requests[1]!.state = "succeeded";
rerender(<RequestPagesProvider loadPage={loadPage}>
<RowList routeKey="queue" initial={latest} />
</RequestPagesProvider>);
expect(signal.aborted).toBe(false);
expect(screen.getByText("succeeded")).toBeVisible();
await act(async () => finish(page(["2"], null)));
expect(displayedIds()).toEqual(["3", "2"]);
fireEvent.click(screen.getByRole("button", { name: /Show latest requests/ }));
expect(displayedIds()).toEqual(["4", "3"]);
});

it("rejects an earlier continuation and retries without losing displayed requests", async () => {
const loadPage = vi.fn()
.mockResolvedValueOnce(page(["2"], "/last"))
.mockResolvedValueOnce(page(["1"], nextHref))
.mockResolvedValueOnce(page(["1"], null));
render(<RequestPagesProvider loadPage={loadPage}>
<RowList routeKey="queue" initial={page(["3"])} />
</RequestPagesProvider>);
await act(async () => intersect());
fireEvent.click(screen.getByRole("button", { name: "Load more requests" }));
await waitFor(() => expect(screen.getByRole("alert")).toBeVisible());
expect(displayedIds()).toEqual(["3", "2"]);
fireEvent.click(screen.getByRole("button", { name: "Retry loading older requests" }));
await waitFor(() => expect(displayedIds()).toEqual(["3", "2", "1"]));
expect(loadPage.mock.calls.map(call => call[0])).toEqual([nextHref, "/last", "/last"]);
});
59 changes: 16 additions & 43 deletions web/platform/paged-requests.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"use client";

import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Block } from "baseui/block";
import { Button, KIND, SIZE } from "baseui/button";
import { StyledLink } from "baseui/link";
import { Spinner } from "baseui/spinner";
import { ParagraphSmall, ParagraphXSmall } from "baseui/typography";
import { useWebNavigation } from "./navigation.js";
import { appendUniqueItems, usePageLoader } from "./use-page-loader.js";

const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;

Expand Down Expand Up @@ -63,20 +64,28 @@ export function PagedRequests<T>({ initial, routeKey, requestKey, children }: {
const [snapshot, setSnapshot] = useState(() =>
(context?.pages.get(routeKey)?.snapshot as RequestSnapshot<T> | undefined) ?? createSnapshot(initial, requestKey));
const [hydrated, setHydrated] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const sentinel = useRef<HTMLElement | null>(null);
const inFlight = useRef<AbortController | null>(null);
const saved = useRef<SavedRequests>({ snapshot, scrollY: context?.pages.get(routeKey)?.scrollY ?? 0 });
saved.current.snapshot = snapshot;
const keyOf = useRef(requestKey);
keyOf.current = requestKey;

if (snapshot.latest !== initial) {
const current = new Map(initial.requests.map(request => [requestKey(request), request]));
setSnapshot({ ...snapshot, latest: initial, requests: snapshot.requests.map(request => current.get(requestKey(request)) ?? request) });
}

const { loading, error, loadNextPage: loadOlderRequests, cancelPageLoad } = usePageLoader({
loadPage: context?.loadPage as ((href: string, signal: AbortSignal) => Promise<RequestPage<T>>) | undefined,
nextHref: snapshot.nextHref,
loadedHrefs: snapshot.loadedHrefs,
resetKey: routeKey,
onPage: (next, href) => setSnapshot(current => current.nextHref !== href ? current : {
...current,
requests: appendUniqueItems(current.requests, next.requests, requestKey),
nextHref: next.nextHref,
loadedHrefs: [...current.loadedHrefs, href],
}),
});

useBrowserLayoutEffect(() => {
setHydrated(true);
const previous = context?.pages.get(routeKey);
Expand All @@ -89,43 +98,10 @@ export function PagedRequests<T>({ initial, routeKey, requestKey, children }: {
if (frame !== null) cancelAnimationFrame(frame);
window.removeEventListener("scroll", recordScroll);
// Save before navigation can shrink the document and reset its scroll offset.
inFlight.current?.abort();
context?.pages.set(routeKey, saved.current);
};
}, [context, routeKey]);

const loadOlderRequests = useCallback(async () => {
const href = snapshot.nextHref;
if (!context || !href || inFlight.current) return;
const controller = new AbortController();
inFlight.current = controller;
setLoading(true);
setError(false);
try {
const next = await context.loadPage(href, controller.signal) as RequestPage<T>;
if (controller.signal.aborted) return;
if (next.nextHref && [href, ...snapshot.loadedHrefs].includes(next.nextHref)) throw new Error("Repeated continuation");
setSnapshot(current => {
if (current.nextHref !== href) return current;
const existing = new Set(current.requests.map(keyOf.current));
const additions = next.requests.filter(request => {
const key = keyOf.current(request);
if (existing.has(key)) return false;
existing.add(key);
return true;
});
return { ...current, requests: [...current.requests, ...additions], nextHref: next.nextHref, loadedHrefs: [...current.loadedHrefs, href] };
});
} catch {
if (!controller.signal.aborted) setError(true);
} finally {
if (inFlight.current === controller) {
inFlight.current = null;
setLoading(false);
}
}
}, [context, snapshot.nextHref, snapshot.loadedHrefs]);

useEffect(() => {
if (!context || !snapshot.nextHref || error || loading || !sentinel.current || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(entries => {
Expand Down Expand Up @@ -154,10 +130,7 @@ export function PagedRequests<T>({ initial, routeKey, requestKey, children }: {
return <>
{newRequests && <Block marginBottom="scale600">
<Button size={SIZE.compact} onClick={() => {
inFlight.current?.abort();
inFlight.current = null;
setLoading(false);
setError(false);
cancelPageLoad();
setSnapshot(createSnapshot(initial, requestKey));
window.scrollTo(0, 0);
}}>New requests available · Show latest requests</Button>
Expand Down
74 changes: 74 additions & 0 deletions web/platform/use-page-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

/** Ignores late responses after a traversal is reset or unmounted. Each view owns its merge and refresh policy. */
export function usePageLoader<Page extends { nextHref: string | null }>({
loadPage, nextHref, loadedHrefs, resetKey, onPage,
}: {
loadPage: ((href: string, signal: AbortSignal) => Promise<Page>) | null | undefined;
nextHref: string | null;
loadedHrefs: readonly string[];
/** Identity of the traversal; change it to discard an in-flight continuation. */
resetKey: unknown;
onPage(page: Page, href: string): void;
}) {
const [status, setStatus] = useState({ key: resetKey, loading: false, error: false });
const inFlight = useRef<AbortController | null>(null);
const currentKey = useRef(resetKey);
const onPageRef = useRef(onPage);
currentKey.current = resetKey;
onPageRef.current = onPage;

useEffect(() => () => {
inFlight.current?.abort();
inFlight.current = null;
}, [resetKey]);

const cancelPageLoad = useCallback(() => {
inFlight.current?.abort();
inFlight.current = null;
setStatus({ key: currentKey.current, loading: false, error: false });
}, []);

const loadNextPage = useCallback(async () => {
const href = nextHref;
if (!loadPage || !href || inFlight.current) return;
const controller = new AbortController();
inFlight.current = controller;
setStatus({ key: resetKey, loading: true, error: false });
try {
const page = await loadPage(href, controller.signal);
if (controller.signal.aborted || currentKey.current !== resetKey) return;
if (page.nextHref && [href, ...loadedHrefs].includes(page.nextHref)) throw new Error("Repeated continuation");
onPageRef.current(page, href);
} catch {
if (!controller.signal.aborted && currentKey.current === resetKey) {
setStatus({ key: resetKey, loading: false, error: true });
}
} finally {
if (inFlight.current === controller) {
inFlight.current = null;
setStatus(current => current.key === resetKey ? { ...current, loading: false } : current);
}
}
}, [loadPage, nextHref, loadedHrefs, resetKey]);

return {
loading: status.key === resetKey && status.loading,
error: status.key === resetKey && status.error,
loadNextPage,
cancelPageLoad,
};
}

/** Preserves existing items and input arrays; duplicate incoming identities are appended only once. */
export function appendUniqueItems<T>(current: readonly T[], incoming: readonly T[], itemKey: (item: T) => string): T[] {
const seen = new Set(current.map(itemKey));
return [...current, ...incoming.filter(item => {
const key = itemKey(item);
if (seen.has(key)) return false;
seen.add(key);
return true;
})];
}
44 changes: 44 additions & 0 deletions web/service/stovepipe/src/app/api/open-issues/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { GET } from "./route";

const { handle } = vi.hoisted(() => ({ handle: vi.fn() }));
vi.mock("../../../server/host", () => ({ resolveHost: () => ({ handle }), onLog: vi.fn() }));
const authorization = `Basic ${Buffer.from("test:test").toString("base64")}`;
function request(path: string, authorized = true) {
return new Request(`http://localhost/api/open-issues?${new URLSearchParams({ path, page: "opaque+/=" })}`, {
headers: authorized ? { authorization } : {},
});
}
beforeEach(() => {
vi.stubEnv("STOVEPIPE_WEB_TOKEN", "test");
handle.mockReset().mockResolvedValue({ kind: "render", model: { kind: "issues", issues: { ok: true, data: { issues: [], nextHref: null } } } });
});
afterEach(() => vi.unstubAllEnvs());

it("authenticates before resolving the issue page", async () => {
const response = await GET(request("/repo%2Fmain", false));
expect(response.status).toBe(401);
expect(handle).not.toHaveBeenCalled();
});
it("loads only the issues view, preserves the cursor, and disables caching", async () => {
const response = await GET(request("/repo%2Fmain"));
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({ issues: [], nextHref: null });
expect(handle).toHaveBeenCalledWith(expect.objectContaining({
path: "/repo%2Fmain", search: new URLSearchParams({ view: "issues", page: "opaque+/=" }), signal: expect.any(AbortSignal),
}));
});
it("rejects request-detail paths", async () => {
expect((await GET(request("/repo%2Fmain/request/7"))).status).toBe(400);
expect(handle).not.toHaveBeenCalled();
});
it.each([
[{ kind: "not-found" }, 404],
[{ kind: "forbidden" }, 403],
[{ kind: "render", model: { kind: "issues", issues: { ok: false, error: "unavailable" } } }, 503],
[{ kind: "render", model: { kind: "queue" } }, 400],
])("preserves page failure status (%j)", async (result, status) => {
handle.mockResolvedValueOnce(result);
expect((await GET(request("/repo%2Fmain"))).status).toBe(status);
});
21 changes: 21 additions & 0 deletions web/service/stovepipe/src/app/api/open-issues/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { authenticationChallenge, isAuthorized, loadAuthConfiguration } from "../../../server/auth";
import { onLog, resolveHost } from "../../../server/host";

export const dynamic = "force-dynamic";

export async function GET(request: Request): Promise<Response> {
if (!isAuthorized(request.headers.get("authorization"), loadAuthConfiguration())) return authenticationChallenge();
const url = new URL(request.url);
const path = url.searchParams.get("path");
if (!path || !/^\/[^/]+$/u.test(path)) return new Response(null, { status: 400 });
const result = await resolveHost().handle({
path,
search: new URLSearchParams({ view: "issues", page: url.searchParams.get("page") ?? "" }),
signal: request.signal,
onLog,
});
if (result.kind !== "render") return new Response(null, { status: result.kind === "forbidden" ? 403 : 404 });
if (result.model.kind !== "issues") return new Response(null, { status: 400 });
if (!result.model.issues.ok) return new Response(null, { status: 503 });
return Response.json(result.model.issues.data, { headers: { "cache-control": "no-store" } });
}
14 changes: 12 additions & 2 deletions web/service/stovepipe/src/next/queue-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import type { ReactNode } from "react";
import { RequestPagesProvider, type RequestPageLoader } from "@submitqueue/web-stovepipe";
import { OpenIssuePagesProvider, RequestPagesProvider, type OpenIssuePageLoader, type RequestPageLoader } from "@submitqueue/web-stovepipe";

const loadPage: RequestPageLoader = async (href, signal) => {
const continuation = new URL(href, window.location.origin);
Expand All @@ -12,5 +12,15 @@ const loadPage: RequestPageLoader = async (href, signal) => {
};

export function QueueProvider({ children }: { children: ReactNode }) {
return <RequestPagesProvider loadPage={loadPage}>{children}</RequestPagesProvider>;
return <RequestPagesProvider loadPage={loadPage}>
<OpenIssuePagesProvider loadPage={loadOpenIssuePage}>{children}</OpenIssuePagesProvider>
</RequestPagesProvider>;
}

const loadOpenIssuePage: OpenIssuePageLoader = async (href, signal) => {
const continuation = new URL(href, window.location.origin);
const search = new URLSearchParams({ path: continuation.pathname, page: continuation.searchParams.get("page") ?? "" });
const response = await fetch(`/api/open-issues?${search}`, { signal, cache: "no-store" });
if (!response.ok) throw new Error("Open issue page unavailable");
return response.json();
};
13 changes: 11 additions & 2 deletions web/service/stovepipe/src/server/instrumentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { instrumentStovepipeWeb } from "./instrumentation";

function createHostFixture() {
const list = vi.fn().mockResolvedValue({ requests: [], nextPageToken: "" });
const listOpenIssues = vi.fn().mockResolvedValue({ issues: [], nextPageToken: "" });
const getRequestHistoryByID = vi.fn().mockRejectedValue(new Error("private failure"));
const getProjectStatusByID = vi.fn().mockRejectedValue(new Error("private failure"));
const web = createStovepipeWeb({ queues: [{ name: "demo" }], service: { list, getRequestHistoryByID, getProjectStatusByID } });
const web = createStovepipeWeb({ queues: [{ name: "demo" }], service: { list, listOpenIssues, 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 All @@ -16,10 +17,18 @@ function createHostFixture() {
const host = instrumentStovepipeWeb(web,
{ createHistogram, createCounter } as unknown as Meter,
{ startSpan: () => span } as unknown as Tracer);
return { host, list, web, record, add, span, createHistogram, createCounter };
return { host, list, listOpenIssues, web, record, add, span, createHistogram, createCounter };
}

describe("Stovepipe host instrumentation", () => {
it("records a failed open-issues section as a failed page load", async () => {
const fixture = createHostFixture();
fixture.listOpenIssues.mockRejectedValue(new Error("private issue failure"));
const result = await fixture.host.handle({ path: "/demo", search: new URLSearchParams({ view: "issues" }) });
expect(result).toMatchObject({ kind: "render", model: { kind: "issues", issues: { ok: false } } });
expect(fixture.add).toHaveBeenCalledWith(1, { outcome: "error" });
expect(fixture.span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR });
});
it("records successful page loads under the existing metric names", async () => {
const fixture = createHostFixture();
const result = await fixture.host.handle({ path: "/demo", search: new URLSearchParams() });
Expand Down
3 changes: 2 additions & 1 deletion web/service/stovepipe/src/server/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export function instrumentStovepipeWeb(
outcome = result.kind;
if (result.kind === "render") {
const sections = result.model.kind === "queue"
? [result.model.requests] : [result.model.history, result.model.projectStatus];
? [result.model.requests] : result.model.kind === "issues"
? [result.model.issues] : [result.model.history, result.model.projectStatus];
if (sections.some((section) => !section.ok)) {
outcome = "error";
observeTelemetry(() => span?.setStatus({ code: SpanStatusCode.ERROR }));
Expand Down
Loading
Loading