Skip to content
Open
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
6 changes: 4 additions & 2 deletions doc/rfc/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,11 @@ Summary is the default request view, and `?view=history` makes History shareable

## Behavior

**Windows and pagination.** `List` takes a queue and a half-open receipt window. The module recalculates the trailing 24-hour window on refresh and keeps timestamps out of the URL. Older pages carry the gateway's continuation token verbatim as `?page=`; the token holds the original bounds, and the page shows the window the gateway echoes back. The module neither wraps nor signs the token, so hosts need no secret, and a token the gateway rejects redirects to the live first page. Refreshing an older page returns to the live first page.
**Windows and pagination.** `List` takes a queue and a half-open receipt window. The module recalculates the trailing 24-hour window on refresh and keeps timestamps out of the URL. Older pages carry the gateway's continuation token verbatim as `?page=`; the token holds the original bounds, and the page shows the window the gateway echoes back. The module neither wraps nor signs the token, so hosts need no secret, and a token the gateway rejects redirects to the live first page. Browser reload retains an older page; its latest link or auto-refresh returns to the live first page.

**Refresh and polling.** A refresh does not schedule the next until it has finished rendering. The wait is the poll interval plus jitter, grows across consecutive transport failures, and pauses while the document is hidden or the browser is offline. A request view stops once the summary is terminal and loaded history contains the same terminal status, or when a terminal summary pairs with a non-retryable history error that polling cannot fix. A queue list keeps polling for the life of its window. A change page that scans the receipt window refreshes only on request, because each render reads up to ten queue pages; a pinned version, found by one exact lookup, keeps polling.
**Time zones.** Timestamps default to the browser time zone. The header's Browser/UTC segmented control applies to all timestamps and persists in local storage. Server rendering uses UTC until hydration resolves the browser zone and preference.

**Refresh and polling.** The header has an opt-in ten-second auto-refresh checkbox, off by default. Users reload the browser for manual updates. A refresh does not schedule the next until it has finished rendering. The wait is the poll interval plus jitter, grows across consecutive transport failures, and pauses while the document is hidden or the browser is offline. A request view stops once the summary is terminal and loaded history contains the same terminal status, or when a terminal summary pairs with a non-retryable history error that polling cannot fix. When enabled, a queue list keeps polling for the life of its window. A change page that scans the receipt window refreshes only on request, because each render reads up to ten queue pages; a pinned version, found by one exact lookup, keeps polling.

**Errors and values.** Gateway failures map from gRPC status: invalid argument and resource exhausted are user errors, not found is a missing resource, unavailable and deadline exceeded are transient, and everything else is internal. A transport with another error shape supplies its own classifier. Raw gateway messages are logged, never rendered, and views keep the last good data on transient failures. Status values stay strings with a display table and an unknown fallback. `int64` values arrive as bigint, number, string, or `Long` and become milliseconds after a safe-range check.

Expand Down
1 change: 1 addition & 0 deletions web/platform/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ web_library(
":node_modules/@types/react",
":node_modules/baseui",
":node_modules/react",
"//web:node_modules/@types/react",
],
)

Expand Down
21 changes: 15 additions & 6 deletions web/platform/auto-refresh.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe("AutoRefresh", () => {
it("uses the interval plus jitter and continues polling", async () => {
const refresh = vi.fn(async () => undefined);
render(<AutoRefresh intervalMs={2_000} jitterMs={500} random={() => 0.5} refresh={refresh} />);
fireEvent.click(screen.getByRole("checkbox"));

await act(async () => vi.advanceTimersByTime(2_249));
expect(refresh).not.toHaveBeenCalled();
Expand All @@ -24,13 +25,13 @@ describe("AutoRefresh", () => {
expect(refresh).toHaveBeenCalledTimes(2);
});

it("keeps manual and timer refreshes single-flight beyond multiple intervals", async () => {
it("keeps polling single-flight beyond multiple intervals", async () => {
let finish: (() => void) | undefined;
const refresh = vi.fn(() => new Promise<void>((resolve) => (finish = resolve)));
render(<AutoRefresh intervalMs={100} jitterMs={0} refresh={refresh} />);
fireEvent.click(screen.getByRole("checkbox"));

await act(async () => vi.advanceTimersByTime(100));
fireEvent.click(screen.getByRole("button", { name: "Refreshing…" }));
await act(async () => vi.advanceTimersByTime(500));
expect(refresh).toHaveBeenCalledTimes(1);
await act(async () => finish?.());
Expand All @@ -48,6 +49,7 @@ describe("AutoRefresh", () => {
transientFailureCount={4}
/>,
);
fireEvent.click(screen.getByRole("checkbox"));

await act(async () => vi.advanceTimersByTime(29_999));
expect(refresh).not.toHaveBeenCalled();
Expand All @@ -67,6 +69,7 @@ describe("AutoRefresh", () => {
transientFailureCount={1}
/>,
);
fireEvent.click(screen.getByRole("checkbox"));

await act(async () => vi.advanceTimersByTime(200));
expect(refresh).toHaveBeenCalledTimes(1);
Expand All @@ -83,6 +86,7 @@ describe("AutoRefresh", () => {
it("backs off after a rejected refresh and recovers once a refresh succeeds", async () => {
const refresh = vi.fn<() => Promise<void>>().mockRejectedValueOnce(new Error("network")).mockResolvedValue(undefined);
render(<AutoRefresh intervalMs={100} jitterMs={0} random={() => 0} refresh={refresh} />);
fireEvent.click(screen.getByRole("checkbox"));

await act(async () => vi.advanceTimersByTime(100));
expect(refresh).toHaveBeenCalledTimes(1);
Expand All @@ -100,6 +104,7 @@ describe("AutoRefresh", () => {
Object.defineProperty(navigator, "onLine", { configurable: true, value: false });
const refresh = vi.fn(async () => undefined);
render(<AutoRefresh intervalMs={100} jitterMs={0} refresh={refresh} />);
fireEvent.click(screen.getByRole("checkbox"));

expect(screen.getByText("Automatic refresh paused")).toBeTruthy();
await act(async () => vi.advanceTimersByTime(1_000));
Expand All @@ -114,6 +119,7 @@ describe("AutoRefresh", () => {
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
const refresh = vi.fn(async () => undefined);
render(<AutoRefresh intervalMs={100} jitterMs={0} refresh={refresh} />);
fireEvent.click(screen.getByRole("checkbox"));

await act(async () => vi.advanceTimersByTime(1_000));
expect(refresh).not.toHaveBeenCalled();
Expand All @@ -125,13 +131,15 @@ describe("AutoRefresh", () => {

it("stops automatic polling for terminal detail views", async () => {
const refresh = vi.fn(async () => undefined);
render(<AutoRefresh intervalMs={100} jitterMs={0} refresh={refresh} terminal />);
const { rerender } = render(<AutoRefresh intervalMs={100} jitterMs={0} refresh={refresh} />);
fireEvent.click(screen.getByRole("checkbox"));
await act(async () => vi.advanceTimersByTime(100));
expect(refresh).toHaveBeenCalledTimes(1);
rerender(<AutoRefresh intervalMs={100} jitterMs={0} refresh={refresh} terminal />);

await act(async () => vi.advanceTimersByTime(1_000));
expect(refresh).not.toHaveBeenCalled();
expect(screen.getByText("Automatic refresh stopped")).toBeTruthy();
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Refresh" })));
expect(refresh).toHaveBeenCalledTimes(1);
expect(screen.getByRole("checkbox")).toBeDisabled();
});

it("does not poll a terminal list failure", async () => {
Expand All @@ -145,6 +153,7 @@ describe("AutoRefresh", () => {
transientFailureCount={1}
/>,
);
fireEvent.click(screen.getByRole("checkbox"));

await act(async () => vi.advanceTimersByTime(1_000));
expect(refresh).not.toHaveBeenCalled();
Expand Down
36 changes: 22 additions & 14 deletions web/platform/auto-refresh.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import type { Theme } from "baseui";
import { Block } from "baseui/block";
import { Button, SIZE } from "baseui/button";
import { Checkbox } from "baseui/checkbox";
import { LabelXSmall } from "baseui/typography";

export interface AutoRefreshProps {
terminal?: boolean;
/** Whether to poll; when false only the manual button refreshes. */
automatic?: boolean;
/** Whether the host supports refreshing in place. */
available?: boolean;
intervalMs?: number;
jitterMs?: number;
maxBackoffMs?: number;
Expand All @@ -23,14 +24,15 @@ function pageCanRefresh(): boolean {

export function AutoRefresh({
terminal = false,
automatic = true,
intervalMs = 2_000,
jitterMs = 500,
available = true,
intervalMs = 10_000,
jitterMs = 0,
maxBackoffMs = 30_000,
transientFailureCount = 0,
refresh,
random = Math.random,
}: AutoRefreshProps) {
const [enabled, setEnabled] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [paused, setPaused] = useState(false);
const [cycle, setCycle] = useState(0);
Expand Down Expand Up @@ -80,7 +82,7 @@ export function AutoRefresh({
}, []);

useEffect(() => {
if (terminal || paused || !automatic) {
if (terminal || paused || !available || !enabled) {
return;
}
const failures = Math.max(transientFailureCount, localFailures.current);
Expand All @@ -96,17 +98,23 @@ export function AutoRefresh({
timer.current = null;
}
};
}, [automatic, cycle, intervalMs, jitterMs, maxBackoffMs, paused, random, runRefresh, terminal, transientFailureCount]);
}, [available, enabled, cycle, intervalMs, jitterMs, maxBackoffMs, paused, random, runRefresh, terminal, transientFailureCount]);

if (!available) return null;

return (
<Block display="flex" alignItems="center" flexWrap gridGap="scale500" data-paused={paused || terminal}>
<Button size={SIZE.compact} disabled={refreshing || paused} onClick={() => void runRefresh()} type="button">
{refreshing ? "Refreshing…" : "Refresh"}
</Button>
<LabelXSmall as="span" color="contentSecondary" aria-live="polite">
{terminal ? "Automatic refresh stopped" : !automatic ? "Manual refresh" : paused ? "Automatic refresh paused" :
<Checkbox checked={enabled} disabled={terminal} onChange={event => setEnabled(event.currentTarget.checked)}
overrides={{
Root: { style: { alignItems: "center" } },
Label: { style: ({ $theme }: { $theme: Theme }) => $theme.typography.LabelSmall },
}}>
Auto refresh every {intervalMs / 1_000}s
</Checkbox>
{enabled && <LabelXSmall as="span" color="contentSecondary" aria-live="polite">
{terminal ? "Automatic refresh stopped" : paused ? "Automatic refresh paused" : refreshing ? "Refreshing…" :
transientFailureCount > 0 || localFailures.current > 0 ? "Updates unavailable · retrying" : "Live"}
</LabelXSmall>
</LabelXSmall>}
</Block>
);
}
54 changes: 54 additions & 0 deletions web/platform/timestamp.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { cleanup, fireEvent, render, screen } from "./test/rtl.js";
import { afterEach, expect, it, vi } from "vitest";
import { Timestamp, TimeZoneProvider, TimeZoneToggle } from "./timestamp.js";

afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.localStorage.clear();
});

it("defaults to the browser zone, respects daylight saving, and switches every timestamp without changing its instant", () => {
const options = new Intl.DateTimeFormat().resolvedOptions();
vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({ ...options, timeZone: "America/New_York" });
const instants = ["2026-11-01T05:30:00.000Z", "2026-11-01T06:30:00.000Z"];
const { container } = render(<TimeZoneProvider>
<TimeZoneToggle />
{instants.map(instant => <Timestamp key={instant} value={Date.parse(instant)} />)}
</TimeZoneProvider>);
const browser = screen.getByRole("option", { name: "Browser" });
expect(browser).toHaveAttribute("aria-selected", "true");
expect(browser).toHaveAttribute("title", "America/New_York");
expect(screen.getByText("Nov 1, 2026, 1:30:00 AM EDT")).toBeVisible();
expect(screen.getByText("Nov 1, 2026, 1:30:00 AM EST")).toBeVisible();
fireEvent.click(screen.getByRole("option", { name: "UTC" }));
expect(screen.getByRole("option", { name: "UTC" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Nov 1, 2026, 5:30:00 AM UTC")).toBeVisible();
expect(screen.getByText("Nov 1, 2026, 6:30:00 AM UTC")).toBeVisible();
expect([...container.querySelectorAll("time")].map(time => time.dateTime)).toEqual(instants);
fireEvent.click(browser);
expect(screen.getByText("Nov 1, 2026, 1:30:00 AM EST")).toBeVisible();
});

it("preserves invalid timestamp values without inventing a date", () => {
render(<TimeZoneProvider><Timestamp value="unknown" /></TimeZoneProvider>);
expect(screen.getByText("unknown")).not.toHaveAttribute("datetime");
});

it("remembers UTC when a new page mounts", () => {
const page = <TimeZoneProvider><TimeZoneToggle /><Timestamp value={0} /></TimeZoneProvider>;
const first = render(page);
fireEvent.click(screen.getByRole("option", { name: "UTC" }));
first.unmount();
render(page);
expect(screen.getByRole("option", { name: "UTC" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Jan 1, 1970, 12:00:00 AM UTC")).toBeVisible();
});

it("allows switching when browser storage is unavailable", () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("Storage unavailable"); });
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("Storage unavailable"); });
render(<TimeZoneProvider><TimeZoneToggle /><Timestamp value={0} /></TimeZoneProvider>);
fireEvent.click(screen.getByRole("option", { name: "UTC" }));
expect(screen.getByRole("option", { name: "UTC" })).toHaveAttribute("aria-selected", "true");
});
59 changes: 57 additions & 2 deletions web/platform/timestamp.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,66 @@
"use client";

const timestampFormatter = new Intl.DateTimeFormat("en-US", {
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { Block } from "baseui/block";
import { Segment, SegmentedControl } from "baseui/segmented-control";
import { LabelSmall } from "baseui/typography";

const utcFormatter = new Intl.DateTimeFormat("en-US", {
dateStyle: "medium", timeStyle: "long", timeZone: "UTC",
});
const preferenceKey = "submitqueue.time-zone";

const TimeZoneContext = createContext<{
selection: "browser" | "utc";
selectTimeZone(selection: "browser" | "utc"): void;
browserTimeZone: string;
formatter: Intl.DateTimeFormat;
} | null>(null);

/** Remembers the browser's selection across page loads; UTC is the server-rendering fallback. */
export function TimeZoneProvider({ children }: { children: ReactNode }) {
const [selection, setSelection] = useState<"browser" | "utc">("browser");
const [browserTimeZone, setBrowserTimeZone] = useState("UTC");
useEffect(() => {
setBrowserTimeZone(new Intl.DateTimeFormat().resolvedOptions().timeZone);
try {
if (window.localStorage.getItem(preferenceKey) === "utc") setSelection("utc");
} catch {
// Storage may be unavailable; the toggle still works for this page.
}
}, []);
const selectTimeZone = useCallback((next: "browser" | "utc") => {
setSelection(next);
try {
window.localStorage.setItem(preferenceKey, next);
} catch {
// Storage may be unavailable; keep the in-memory selection.
}
}, []);
const formatter = useMemo(() => new Intl.DateTimeFormat("en-US", {
dateStyle: "medium", timeStyle: "long", timeZone: selection === "utc" ? "UTC" : browserTimeZone,
}), [selection, browserTimeZone]);
const value = useMemo(() => ({ selection, selectTimeZone, browserTimeZone, formatter }), [selection, selectTimeZone, browserTimeZone, formatter]);
return <TimeZoneContext.Provider value={value}>{children}</TimeZoneContext.Provider>;
}

export function TimeZoneToggle() {
const context = useContext(TimeZoneContext);
if (!context) return null;
return <Block display="flex" alignItems="center" flexWrap gridGap="scale300">
<LabelSmall as="span">Time zone</LabelSmall>
<SegmentedControl uid="time-zone" activeKey={context.selection}
onChange={({ activeKey }) => context.selectTimeZone(activeKey === "utc" ? "utc" : "browser")}
overrides={{ SegmentList: { props: { "aria-label": "Time zone" } } }}>
<Segment key="browser" label="Browser" overrides={{ Segment: { props: { title: context.browserTimeZone } } }} />
<Segment key="utc" label="UTC" />
</SegmentedControl>
</Block>;
}

export function Timestamp({ value }: { value: string | number }) {
const formatter = useContext(TimeZoneContext)?.formatter ?? utcFormatter;
const date = new Date(Number(value));
return Number.isNaN(date.getTime()) ? <time>{value}</time> :
<time dateTime={date.toISOString()}>{timestampFormatter.format(date)}</time>;
<time dateTime={date.toISOString()}>{formatter.format(date)}</time>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const dynamic = "force-dynamic";
export const revalidate = 0;

const page = createNextPage({
shell: { environmentLabel: "Local demo · read-only", footer: "Read-only demo" },
web: resolveReferenceWeb,
authenticate: requireAuthorization,
onLog: resolveReferenceLogCallback(),
Expand Down
6 changes: 1 addition & 5 deletions web/service/submitqueue/src/app/(protected)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { ReactNode } from "react";
import { SubmitQueueShell } from "@submitqueue/web-submitqueue";

import { requireAuthorization } from "../../next/authorize";

Expand All @@ -11,8 +10,5 @@ export default async function ProtectedLayout({
}: Readonly<{ children: ReactNode }>) {
await requireAuthorization();

return (
<SubmitQueueShell environmentLabel="Local demo · read-only"
footer="Read-only demo · updates automatically">{children}</SubmitQueueShell>
);
return children;
}
5 changes: 3 additions & 2 deletions web/service/submitqueue/src/next/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { forbidden, notFound, redirect } from "next/navigation";
import { connection } from "next/server";
import { cache } from "react";
import { SubmitQueueApp } from "@submitqueue/web-submitqueue";
import { SubmitQueueApp, type SubmitQueueShellProps } from "@submitqueue/web-submitqueue";
import type { SubmitQueueWeb, SubmitQueueWebRequest, WebSearchParams } from "@submitqueue/web-submitqueue/server";
import { NextNavigationProvider } from "@submitqueue/web-service-shared/navigation";

Expand All @@ -12,6 +12,7 @@ export interface NextPageProps {
}

export interface NextPageOptions {
shell?: Omit<SubmitQueueShellProps, "children" | "controls">;
/** The web UI, or a resolver called per request so configuration is read at runtime rather than build time. */
web: SubmitQueueWeb | (() => SubmitQueueWeb);
/**
Expand Down Expand Up @@ -61,7 +62,7 @@ export function createNextPage(options: NextPageOptions) {
case "redirect":
redirect(result.href);
case "render":
return <NextNavigationProvider><SubmitQueueApp model={result.model} /></NextNavigationProvider>;
return <NextNavigationProvider><SubmitQueueApp model={result.model} shell={options.shell} /></NextNavigationProvider>;
}
}

Expand Down
Loading
Loading