From 9bc53299643699e7fed44babae96dbd6a0b424b7 Mon Sep 17 00:00:00 2001 From: "prath.shenoy" Date: Thu, 29 Oct 2026 22:36:07 +0000 Subject: [PATCH] feat(stovepipe-web): Show open issues by task ## Summary **What**: - Show current queue issues grouped by task, with task-wide failures first within each task. - Add continuation loading, opening-request links, and refresh behavior that preserves the last successful display when reads fail. **Why**: - Make recorded failures visible to users. The backend already maintains and lists open issues, but the web UI currently exposes only requests and project results. - Keep the display useful while issues open and close. Continue past empty batches, merge tasks across pages, and restart the listing after a successful refresh rather than indefinitely retaining closed issues from older pages. ## Test Plan - [x] Add unit tests. - [x] Verify grouped listing, pagination, refresh, and accessibility in a browser against the running service and MySQL. ## Revert Plan - Revert this PR. The view is read-only, and the backend APIs and stored issue data are unchanged. ## Issue Links - [CODEM-607](https://linear.app/uber/issue/CODEM-607/build-analysis) --- web/platform/index.ts | 1 + web/platform/paged-requests.test.tsx | 40 +++- web/platform/paged-requests.tsx | 59 ++---- web/platform/use-page-loader.ts | 74 +++++++ .../src/app/api/open-issues/route.test.ts | 44 ++++ .../src/app/api/open-issues/route.ts | 21 ++ .../stovepipe/src/next/queue-provider.tsx | 14 +- .../src/server/instrumentation.test.ts | 13 +- .../stovepipe/src/server/instrumentation.ts | 3 +- web/stovepipe/README.md | 10 +- web/stovepipe/controller/issues.ts | 26 +++ web/stovepipe/core/paths.ts | 5 + web/stovepipe/entity/index.ts | 22 ++ web/stovepipe/extension/service/index.ts | 11 + web/stovepipe/index.ts | 3 +- web/stovepipe/module.test.ts | 48 +++++ web/stovepipe/module.ts | 14 +- web/stovepipe/view/app.test.tsx | 52 ++++- web/stovepipe/view/app.tsx | 33 ++- web/stovepipe/view/issues.test.tsx | 188 ++++++++++++++++++ web/stovepipe/view/issues.tsx | 91 +++++++++ web/stovepipe/view/refresh.test.tsx | 9 + web/stovepipe/view/refresh.tsx | 6 +- web/stovepipe/view/styles.css | 14 ++ web/test/e2e/stovepipe/run_e2e.py | 25 +++ web/test/e2e/stovepipe/stovepipe.spec.ts | 107 ++++++++++ 26 files changed, 864 insertions(+), 69 deletions(-) create mode 100644 web/platform/use-page-loader.ts create mode 100644 web/service/stovepipe/src/app/api/open-issues/route.test.ts create mode 100644 web/service/stovepipe/src/app/api/open-issues/route.ts create mode 100644 web/stovepipe/controller/issues.ts create mode 100644 web/stovepipe/view/issues.test.tsx create mode 100644 web/stovepipe/view/issues.tsx diff --git a/web/platform/index.ts b/web/platform/index.ts index 4f49b9eb7..94ebb0a35 100644 --- a/web/platform/index.ts +++ b/web/platform/index.ts @@ -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"; diff --git a/web/platform/paged-requests.test.tsx b/web/platform/paged-requests.test.tsx index a0e724a79..d6ebc538b 100644 --- a/web/platform/paged-requests.test.tsx +++ b/web/platform/paged-requests.test.tsx @@ -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; @@ -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) => void; + const loadPage = vi.fn(() => new Promise>(resolve => { finish = resolve; })); + const { rerender } = render( + + ); + await act(async () => intersect()); + const signal = loadPage.mock.calls[0]![1]; + const latest = page(["4", "3"]); + latest.requests[1]!.state = "succeeded"; + rerender( + + ); + 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( + + ); + 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"]); +}); diff --git a/web/platform/paged-requests.tsx b/web/platform/paged-requests.tsx index 0c304950c..1931a7c6b 100644 --- a/web/platform/paged-requests.tsx +++ b/web/platform/paged-requests.tsx @@ -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; @@ -63,20 +64,28 @@ export function PagedRequests({ initial, routeKey, requestKey, children }: { const [snapshot, setSnapshot] = useState(() => (context?.pages.get(routeKey)?.snapshot as RequestSnapshot | undefined) ?? createSnapshot(initial, requestKey)); const [hydrated, setHydrated] = useState(false); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(false); const sentinel = useRef(null); - const inFlight = useRef(null); const saved = useRef({ 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>) | 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); @@ -89,43 +98,10 @@ export function PagedRequests({ 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; - 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 => { @@ -154,10 +130,7 @@ export function PagedRequests({ initial, routeKey, requestKey, children }: { return <> {newRequests && diff --git a/web/platform/use-page-loader.ts b/web/platform/use-page-loader.ts new file mode 100644 index 000000000..4d2e70c94 --- /dev/null +++ b/web/platform/use-page-loader.ts @@ -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({ + loadPage, nextHref, loadedHrefs, resetKey, onPage, +}: { + loadPage: ((href: string, signal: AbortSignal) => Promise) | 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(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(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; + })]; +} diff --git a/web/service/stovepipe/src/app/api/open-issues/route.test.ts b/web/service/stovepipe/src/app/api/open-issues/route.test.ts new file mode 100644 index 000000000..33ac8ae13 --- /dev/null +++ b/web/service/stovepipe/src/app/api/open-issues/route.test.ts @@ -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); +}); diff --git a/web/service/stovepipe/src/app/api/open-issues/route.ts b/web/service/stovepipe/src/app/api/open-issues/route.ts new file mode 100644 index 000000000..d314c3854 --- /dev/null +++ b/web/service/stovepipe/src/app/api/open-issues/route.ts @@ -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 { + 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" } }); +} diff --git a/web/service/stovepipe/src/next/queue-provider.tsx b/web/service/stovepipe/src/next/queue-provider.tsx index f73f07e0a..e4e1288f3 100644 --- a/web/service/stovepipe/src/next/queue-provider.tsx +++ b/web/service/stovepipe/src/next/queue-provider.tsx @@ -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); @@ -12,5 +12,15 @@ const loadPage: RequestPageLoader = async (href, signal) => { }; export function QueueProvider({ children }: { children: ReactNode }) { - return {children}; + return + {children} + ; } + +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(); +}; diff --git a/web/service/stovepipe/src/server/instrumentation.test.ts b/web/service/stovepipe/src/server/instrumentation.test.ts index 4bfa66ae8..83debb67f 100644 --- a/web/service/stovepipe/src/server/instrumentation.test.ts +++ b/web/service/stovepipe/src/server/instrumentation.test.ts @@ -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() }; @@ -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() }); diff --git a/web/service/stovepipe/src/server/instrumentation.ts b/web/service/stovepipe/src/server/instrumentation.ts index 910aa653d..83c35bfe5 100644 --- a/web/service/stovepipe/src/server/instrumentation.ts +++ b/web/service/stovepipe/src/server/instrumentation.ts @@ -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 })); diff --git a/web/stovepipe/README.md b/web/stovepipe/README.md index 5ef6efa1a..16b313a9d 100644 --- a/web/stovepipe/README.md +++ b/web/stovepipe/README.md @@ -1,6 +1,6 @@ # Stovepipe web UI -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. +A framework-neutral React UI with queue-level Requests and Open issues views 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. @@ -8,16 +8,20 @@ Logging uses a synchronous `onLog` callback passed on each `handle` request. Bin Process the UI entry with the host's bundler for both SSR and the browser. The `/server` entry runs directly in Node and does not load BaseUI. See the shared [package contract](../README.md#layers). -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. +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`, `issues.ok`, or both `history.ok` and `projectStatus.ok`, according to the page kind, 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` for long-identifier wrapping, and supply BaseUI's `BaseProvider` and Styletron's `Provider`. The host owns the theme and styling engine, including SSR stylesheet extraction and hydration; the library creates neither. BaseUI, React, React DOM, and Styletron React are peer dependencies. Mount `RequestPagesProvider` above route changes with a `loadPage(href, signal)` callback that returns a `QueueRequestsModel` through the host's authenticated transport. Queue lists then load older rows automatically near the bottom, with a keyboard-accessible Load more button and explicit retry after failure. The provider retains loaded rows and scroll positions across detail visits. Without JavaScript or a loader, continuation links navigate normally. The reference host supplies an authenticated, uncached JSON route. +Mount `OpenIssuePagesProvider` with an authenticated `loadPage(href, signal)` callback returning an `OpenIssuesModel` to append issue pages with Load more. The reference host supplies its own uncached, authenticated continuation route. Without JavaScript or a loader, continuation links navigate normally. + ## Routes and data Request pages use `//request/` 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. +The queue's `?view=issues` tab calls `ListOpenIssues` and groups rows by task in backend task/unit order. An empty unit is labeled Task-wide failure and appears first within its task; loading another page extends the existing group. Each row links to its opening request and shows the opening timestamp. The displayed count is loaded issues, not a queue-wide total. Empty batches can carry a continuation, so only an exhausted empty listing says No tracked open issues. This is not a claim of build success or complete coverage. Project attribution, issue history, and repair controls are not part of this view. + Project results use `GetProjectStatusByID` for that exact request, with an empty project selection to read all recorded projects. Pages default to 50 projects; the Projects per page dropdown offers 25, 50, 100, or 200. @@ -43,3 +47,5 @@ in this package; hosts only adapt their transport representation. The header offers optional ten-second auto-refresh, off by default. Use browser reload for manual updates. Polling pauses while the page is hidden or offline. Failed refreshes retain successful results on the same route; request-detail results are scoped to their route. Timestamps default to the browser's time zone, with a Browser/UTC segmented control in the header. The browser remembers the selection across navigation and reloads when local storage is available. Server-rendered timestamps use UTC until the browser resolves its time zone and preference. Lifecycle labels and outcome reasons come from the backend and are displayed verbatim. Queue refreshes update the status of matching displayed requests while preserving their positions and loaded older pages. New arrivals appear behind a Show latest requests action that starts a fresh list. A full browser reload also starts a fresh list; older-page URLs retain their explicit cursor. + +Open-issue refreshes read the first page. Before any continuation is loaded, a successful refresh replaces the displayed issues. After loading more, polling preserves the displayed traversal and offers Show latest issues to replace it with the latest successful first-page result. The issues page and retained data are scoped to the queue and view, not the cursor, so refreshing a continuation URL preserves both the display on failure and the auto-refresh setting. Failed continuations preserve loaded issues and allow retrying the same cursor. Loading more is not a snapshot; issues can change between reads. diff --git a/web/stovepipe/controller/issues.ts b/web/stovepipe/controller/issues.ts new file mode 100644 index 000000000..b23ad1ac6 --- /dev/null +++ b/web/stovepipe/controller/issues.ts @@ -0,0 +1,26 @@ +import type { StovepipeService } from "../extension/service/index.js"; +import type { StovepipePaths } from "../core/paths.js"; +import type { OpenIssuesModel } from "../entity/index.js"; + +export async function loadOpenIssues( + service: StovepipeService, + paths: StovepipePaths, + queue: string, + pageToken: string, + signal?: AbortSignal, +): Promise { + const result = await service.listOpenIssues( + { queue, pageSize: 50, pageToken }, + signal ? { signal } : {}, + ); + return { + issues: result.issues.map(issue => ({ + taskId: issue.taskId, + unitId: issue.unitId, + firstSeenAtMs: issue.changedAtMs.toString(), + requestId: issue.requestId, + requestHref: paths.request(queue, issue.requestId), + })), + nextHref: result.nextPageToken ? paths.openIssues(queue, result.nextPageToken) : null, + }; +} diff --git a/web/stovepipe/core/paths.ts b/web/stovepipe/core/paths.ts index fb78b1e5f..754a87c27 100644 --- a/web/stovepipe/core/paths.ts +++ b/web/stovepipe/core/paths.ts @@ -15,6 +15,11 @@ export class StovepipePaths { const path = `${this.basePath}/${encodePathSegment(queue)}`; return page ? `${path}?${new URLSearchParams({ page })}` : path; } + openIssues(queue: string, page = ""): string { + const search = new URLSearchParams({ view: "issues" }); + if (page) search.set("page", page); + return `${this.queue(queue)}?${search}`; + } request(queue: string, requestId: string, projects = "", projectPageSize = 50): string { const path = `${this.queue(queue)}/request/${encodePathSegment(requestId)}`; const search = new URLSearchParams(); diff --git a/web/stovepipe/entity/index.ts b/web/stovepipe/entity/index.ts index e2fb71535..a0185391e 100644 --- a/web/stovepipe/entity/index.ts +++ b/web/stovepipe/entity/index.ts @@ -33,6 +33,24 @@ export interface QueueRequestsModel { /** Opaque continuation URL; null on the final page. */ nextHref: string | null; } +export interface OpenIssueModel { + /** Logical validation task, preserved verbatim. */ + taskId: string; + /** Validation unit; empty identifies a task-wide failure. */ + unitId: string; + /** Opening time in Unix milliseconds as a decimal string. */ + firstSeenAtMs: string; + /** Request that established this opening. */ + requestId: string; + /** In-app URL of the opening request. */ + requestHref: string; +} +export interface OpenIssuesModel { + /** Open issues on this page, in backend task/unit order. */ + issues: OpenIssueModel[]; + /** Opaque continuation URL; null when no further row was observed. */ + nextHref: string | null; +} export interface HistoryEventModel { /** Stable event identifier within the request. */ id: string; @@ -91,6 +109,10 @@ export type StovepipePageModel = PageBase & kind: "queue"; requests: ReadResult; } + | { + kind: "issues"; + issues: ReadResult; + } | { kind: "request"; requestId: string; diff --git a/web/stovepipe/extension/service/index.ts b/web/stovepipe/extension/service/index.ts index 207b709f0..c3e069fea 100644 --- a/web/stovepipe/extension/service/index.ts +++ b/web/stovepipe/extension/service/index.ts @@ -37,8 +37,19 @@ export interface ProjectStatus { nextPageToken: string; } +export interface OpenIssueSummary { + taskId: string; + unitId: string; + requestId: string; + changedAtMs: bigint; +} + /** Hosts supply a transport client; the library has no generated-binding dependency. */ export interface StovepipeService { + listOpenIssues( + request: { queue: string; pageSize: number; pageToken: string }, + options?: { signal?: AbortSignal }, + ): Promise<{ issues: OpenIssueSummary[]; nextPageToken: string }>; list( request: { queue: string; pageSize: number; pageToken: string }, options?: { signal?: AbortSignal }, diff --git a/web/stovepipe/index.ts b/web/stovepipe/index.ts index f667eaf09..d244dcd37 100644 --- a/web/stovepipe/index.ts +++ b/web/stovepipe/index.ts @@ -1,6 +1,7 @@ "use client"; export { StovepipeApp } from "./view/app.js"; -export type { QueueRequestsModel, StovepipePageModel } from "./entity/index.js"; +export type { OpenIssuesModel, QueueRequestsModel, StovepipePageModel } from "./entity/index.js"; +export { OpenIssuePagesProvider, type OpenIssuePageLoader } from "./view/issues.js"; export { RequestPagesProvider, type RequestPageLoader, diff --git a/web/stovepipe/module.test.ts b/web/stovepipe/module.test.ts index e739daaa6..0812fd1a8 100644 --- a/web/stovepipe/module.test.ts +++ b/web/stovepipe/module.test.ts @@ -21,6 +21,7 @@ const summary: RequestSummary = { }; function createPageTestContext() { const service: StovepipeService = { + listOpenIssues: vi.fn().mockResolvedValue({ issues: [], nextPageToken: "" }), list: vi .fn() .mockResolvedValue({ requests: [], nextPageToken: "older+/page" }), @@ -58,6 +59,53 @@ function createPageTestContext() { return { service, paths, app }; } describe("Stovepipe page loading", () => { + it("loads only open issues for the queue tab and preserves identities and opaque cursors", async () => { + const { app, service, paths } = createPageTestContext(); + vi.mocked(service.listOpenIssues).mockResolvedValue({ + issues: [{ taskId: "build/test", unitId: "", requestId: summary.requestId, changedAtMs: 9007199254740993n }], + nextPageToken: "next+/=", + }); + const controller = new AbortController(); + const result = await app.handle({ + path: paths.queue("repo/main"), search: new URLSearchParams({ view: "issues", page: "opaque+/=" }), + signal: controller.signal, + }); + expect(service.listOpenIssues).toHaveBeenCalledWith( + { queue: "repo/main", pageSize: 50, pageToken: "opaque+/=" }, { signal: controller.signal }, + ); + expect(service.list).not.toHaveBeenCalled(); + expect(service.getRequestHistoryByID).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + kind: "render", + model: { + kind: "issues", latestHref: paths.openIssues("repo/main"), showLatestLink: true, + issues: { ok: true, data: { + issues: [{ + taskId: "build/test", unitId: "", firstSeenAtMs: "9007199254740993", + requestId: summary.requestId, requestHref: paths.request("repo/main", summary.requestId), + }], + nextHref: paths.openIssues("repo/main", "next+/="), + } }, + }, + }); + }); + + it("does not call issue storage for an unknown queue or denied access", async () => { + const { app, service, paths } = createPageTestContext(); + expect(await app.handle({ path: paths.queue("unknown"), search: new URLSearchParams({ view: "issues" }) })).toEqual({ kind: "not-found" }); + const denied = createStovepipeWeb({ service, paths, queues: [{ name: "repo/main" }], authorize: () => false }); + expect(await denied.handle({ path: paths.queue("repo/main"), search: new URLSearchParams({ view: "issues" }) })).toEqual({ kind: "forbidden" }); + expect(service.listOpenIssues).not.toHaveBeenCalled(); + }); + + it("contains issue-service failures in the page section without exposing backend diagnostics", async () => { + const { app, service, paths } = createPageTestContext(); + vi.mocked(service.listOpenIssues).mockRejectedValue(new Error("private issue-service detail")); + const result = await app.handle({ path: paths.queue("repo/main"), search: new URLSearchParams({ view: "issues" }) }); + expect(result).toMatchObject({ kind: "render", model: { kind: "issues", issues: { ok: false } } }); + expect(JSON.stringify(result)).not.toContain("private issue-service detail"); + }); + it("keeps overlapping loads bound to their own host logging callbacks", async () => { const { app, service, paths } = createPageTestContext(); let release!: () => void; diff --git a/web/stovepipe/module.ts b/web/stovepipe/module.ts index 2c6556f1a..2cbf70b90 100644 --- a/web/stovepipe/module.ts +++ b/web/stovepipe/module.ts @@ -5,6 +5,7 @@ import type { Queue, StovepipePageModel } from "./entity/index.js"; import { parseProjectPageSize, StovepipePaths } from "./core/paths.js"; import { readSection } from "./controller/page.js"; import { loadQueueRequests } from "./controller/queue.js"; +import { loadOpenIssues } from "./controller/issues.js"; import { loadRequestHistory } from "./controller/request.js"; import { loadProjectStatus } from "./controller/projects.js"; @@ -48,6 +49,7 @@ export function createStovepipeWeb(options: StovepipeWebOptions) { const route = parseStovepipeRoute(request.path, paths, options.queues); if (!route) return { kind: "not-found" }; const { queue, requestId } = route; + const issuesView = !requestId && request.search.get("view") === "issues"; if (options.authorize && !(await options.authorize(request.principal, queue.name))) return { kind: "forbidden" }; const service = @@ -57,13 +59,21 @@ export function createStovepipeWeb(options: StovepipeWebOptions) { queue: queue.name, queues: options.queues.map((queue) => ({ name: queue.name, - href: paths.queue(queue.name), + href: issuesView ? paths.openIssues(queue.name) : paths.queue(queue.name), })), queueHref: paths.queue(queue.name), - latestHref: paths.queue(queue.name), + latestHref: issuesView ? paths.openIssues(queue.name) : paths.queue(queue.name), showLatestLink: Boolean(!requestId && request.search.get("page")), loadedAtMs: Date.now(), }; + if (issuesView) { + const issues = await readSection( + () => loadOpenIssues(service, paths, queue.name, request.search.get("page") ?? "", request.signal), + onLog, + "open issues", + ); + return { kind: "render", model: { ...common, kind: "issues", issues } }; + } if (!requestId) { const requests = await readSection( () => diff --git a/web/stovepipe/view/app.test.tsx b/web/stovepipe/view/app.test.tsx index 74cb59ba2..4c2df04f4 100644 --- a/web/stovepipe/view/app.test.tsx +++ b/web/stovepipe/view/app.test.tsx @@ -1,5 +1,6 @@ -import { cleanup, render, screen, waitFor, within } from "../../platform/test/rtl.js"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "../../platform/test/rtl.js"; import { afterEach, expect, it } from "vitest"; +import { WebNavigationProvider } from "@submitqueue/web-platform"; import { StovepipeApp } from "./app.js"; import type { StovepipePageModel } from "../entity/index.js"; afterEach(cleanup); @@ -75,6 +76,55 @@ it("displays request summary data", () => { const history = screen.getByText("accepted").closest("section")!; expect(history).not.toBeVisible(); }); +it("shows a separate queue issues tab and retains successful issue data on refresh failure", () => { + const issuesModel: StovepipePageModel = { + ...model, kind: "issues", key: "/repo%2Fmain?view=issues", latestHref: "/repo%2Fmain?view=issues", + issues: { ok: true, data: { + issues: [{ taskId: "buildtest", unitId: "", firstSeenAtMs: "1000", requestId: "7", requestHref: "/repo%2Fmain/request/7" }], + nextHref: null, + } }, + }; + const { rerender } = render(); + const navigation = screen.getByRole("navigation", { name: "Queue views" }); + expect(within(navigation).getByRole("link", { name: "Requests" })).toHaveAttribute("href", "/repo%2Fmain"); + expect(within(navigation).getByRole("link", { name: "Open issues" })).toHaveAttribute("aria-current", "page"); + expect(screen.getByText("Task-wide failure")).toBeVisible(); + rerender(); + expect(screen.getByRole("alert")).toHaveTextContent("Unable to load open issues"); + expect(screen.getByText("Task-wide failure")).toBeVisible(); +}); +it("retains issue data and auto-refresh when a cursor refresh fails on the first-page URL", () => { + const issuesModel: StovepipePageModel = { + ...model, kind: "issues", key: "/repo%2Fmain?view=issues&page=cursor", + latestHref: "/repo%2Fmain?view=issues", showLatestLink: true, + issues: { ok: true, data: { + issues: [{ taskId: "buildtest", unitId: "", firstSeenAtMs: "1000", requestId: "7", requestHref: "/repo%2Fmain/request/7" }], + nextHref: null, + } }, + }; + const navigation = { refresh: async () => undefined }; + const { rerender } = render( + + ); + fireEvent.click(screen.getByRole("checkbox", { name: "Auto refresh every 10s" })); + rerender( + + ); + expect(screen.getByText("Task-wide failure")).toBeVisible(); + expect(screen.getByRole("alert")).toHaveTextContent("Unable to load open issues"); + expect(screen.getByRole("checkbox", { name: "Auto refresh every 10s" })).toBeChecked(); + rerender( + + ); + expect(screen.queryByText("Task-wide failure")).toBeNull(); + expect(screen.getByRole("checkbox", { name: "Auto refresh every 10s" })).not.toBeChecked(); +}); it("links queue entries by request ID even when the change URI is missing", () => { if (!model.history.ok) throw new Error("No summary"); render( diff --git a/web/stovepipe/view/app.tsx b/web/stovepipe/view/app.tsx index 2788a17f6..51e6f18e5 100644 --- a/web/stovepipe/view/app.tsx +++ b/web/stovepipe/view/app.tsx @@ -10,9 +10,10 @@ import type { StovepipePageModel } from "../entity/index.js"; import { Link, Section } from "./components.js"; import { RequestDetails, History } from "./pages.js"; import { QueueRequests } from "./queue.js"; +import { OpenIssues } from "./issues.js"; import { ProjectResults, RecordedResult } from "./projects.js"; -const RequestViewLink = styled(Link, ({ $theme, $active }) => ({ +const ViewLink = styled(Link, ({ $theme, $active }) => ({ display: "inline-block", paddingTop: $theme.sizing.scale400, paddingBottom: $theme.sizing.scale400, @@ -20,7 +21,7 @@ const RequestViewLink = styled(Link, ({ $them borderBottom: `2px solid ${$active ? $theme.colors.contentPrimary : "transparent"}`, })); -const RequestViewNavigation = styled("nav", ({ $theme }) => ({ +const ViewNavigation = styled("nav", ({ $theme }) => ({ display: "flex", flexWrap: "wrap", columnGap: $theme.sizing.scale700, @@ -60,7 +61,7 @@ function Page({ model }: { model: StovepipePageModel }) { SPStovepipe - + @@ -82,21 +83,31 @@ function Page({ model }: { model: StovepipePageModel }) { ← Queue requests / {model.queue} )} - {model.kind === "queue" ? <> + {model.kind !== "request" ? <> {model.queue} - Requests ordered by acceptance time, newest first. + {model.kind === "issues" ? "Open issues grouped by task." : "Requests ordered by acceptance time, newest first."} : Request {model.requestId}} - {model.kind === "queue" && model.showLatestLink && ( + {model.kind !== "request" && + Requests + Open issues + } + {model.kind !== "request" && model.showLatestLink && ( - Latest page + {model.kind === "issues" ? "First page" : "Latest page"} )} {model.kind === "queue" ? (
{(data) => data && }
+ ) : model.kind === "issues" ? ( + +
+ {data => data && } +
+
) : (
{(history) => ( @@ -106,11 +117,11 @@ function Page({ model }: { model: StovepipePageModel }) {
} />
- + {views.tabs.map(tab => ( - {tab.label} + {tab.label} ))} - + {views.tabs.map(tab => (