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
2 changes: 1 addition & 1 deletion doc/rfc/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Summary is the default request view, and `?view=history` makes History shareable

**Time zones.** Timestamps default to the browser time zone. Clicking a dotted-underlined timestamp opens a shared popover with local and UTC values, ISO copying, and a Local/UTC display choice that 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.
**Refresh and polling.** The header has an opt-in circular-arrow auto-refresh toggle, off by default. Its selected state stays visible while enabled, BaseUI tooltips identify Enable auto-refresh and Disable auto-refresh, and it animates only during a refresh. The interval remains ten seconds. 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
61 changes: 51 additions & 10 deletions web/platform/auto-refresh.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,49 @@ describe("AutoRefresh", () => {

afterEach(() => vi.useRealTimers());

it("exposes a compact toggle with an action label and a persistent selected state", () => {
render(<AutoRefresh refresh={async () => undefined} />);
const toggle = screen.getByRole("button", { name: "Enable auto-refresh" });
expect(toggle).not.toHaveAttribute("title");
expect(toggle).toHaveAttribute("aria-pressed", "false");
fireEvent.click(toggle);
expect(toggle).toHaveAccessibleName("Disable auto-refresh");
expect(toggle).toHaveAttribute("aria-pressed", "true");
fireEvent.click(toggle);
expect(toggle).toHaveAccessibleName("Enable auto-refresh");
expect(toggle).toHaveAttribute("aria-pressed", "false");
});

it("shows the action tooltip after a short hover and updates it when toggled", async () => {
render(<AutoRefresh refresh={async () => undefined} />);
const toggle = screen.getByRole("button", { name: "Enable auto-refresh" });
fireEvent.mouseEnter(toggle);
await act(async () => vi.advanceTimersByTime(149));
expect(screen.queryByRole("tooltip")).toBeNull();
await act(async () => vi.advanceTimersByTime(1));
expect(screen.getByRole("tooltip")).toHaveTextContent("Enable auto-refresh");
fireEvent.click(toggle);
expect(screen.getByRole("tooltip")).toHaveTextContent("Disable auto-refresh");
fireEvent.mouseLeave(toggle);
await act(async () => vi.advanceTimersByTime(100));
expect(screen.queryByRole("tooltip")).toBeNull();
});

it("shows the tooltip on keyboard focus", async () => {
render(<AutoRefresh refresh={async () => undefined} />);
const toggle = screen.getByRole("button", { name: "Enable auto-refresh" });
fireEvent.keyDown(document, { key: "Tab", keyCode: 9 });
act(() => toggle.focus());
expect(screen.getByRole("tooltip")).toHaveTextContent("Enable auto-refresh");
act(() => toggle.blur());
await act(async () => vi.advanceTimersByTime(100));
expect(screen.queryByRole("tooltip")).toBeNull();
});

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"));
fireEvent.click(screen.getByRole("button"));

await act(async () => vi.advanceTimersByTime(2_249));
expect(refresh).not.toHaveBeenCalled();
Expand All @@ -29,12 +68,14 @@ describe("AutoRefresh", () => {
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"));
fireEvent.click(screen.getByRole("button"));

await act(async () => vi.advanceTimersByTime(100));
expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "true");
await act(async () => vi.advanceTimersByTime(500));
expect(refresh).toHaveBeenCalledTimes(1);
await act(async () => finish?.());
expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "false");
});

it("backs off transient failures up to the configured cap", async () => {
Expand All @@ -49,7 +90,7 @@ describe("AutoRefresh", () => {
transientFailureCount={4}
/>,
);
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(screen.getByRole("button"));

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

await act(async () => vi.advanceTimersByTime(200));
expect(refresh).toHaveBeenCalledTimes(1);
Expand All @@ -86,7 +127,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"));
fireEvent.click(screen.getByRole("button"));

await act(async () => vi.advanceTimersByTime(100));
expect(refresh).toHaveBeenCalledTimes(1);
Expand All @@ -104,7 +145,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"));
fireEvent.click(screen.getByRole("button"));

expect(screen.getByText("Automatic refresh paused")).toBeTruthy();
await act(async () => vi.advanceTimersByTime(1_000));
Expand All @@ -119,7 +160,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"));
fireEvent.click(screen.getByRole("button"));

await act(async () => vi.advanceTimersByTime(1_000));
expect(refresh).not.toHaveBeenCalled();
Expand All @@ -132,14 +173,14 @@ describe("AutoRefresh", () => {
it("stops automatic polling for terminal detail views", async () => {
const refresh = vi.fn(async () => undefined);
const { rerender } = render(<AutoRefresh intervalMs={100} jitterMs={0} refresh={refresh} />);
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(screen.getByRole("button"));
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).toHaveBeenCalledTimes(1);
expect(screen.getByRole("checkbox")).toBeDisabled();
expect(screen.getByRole("button")).toBeDisabled();
});

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

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

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

const RefreshIcon = styled<"svg", { $spinning: boolean }>("svg", ({ $spinning }) => ({
animationName: $spinning ? { from: { transform: "rotate(0deg)" }, to: { transform: "rotate(360deg)" } } : "none",
animationDuration: "1s", animationIterationCount: "infinite", animationTimingFunction: "linear",
"@media (prefers-reduced-motion: reduce)": { animationName: "none" },
}));

export interface AutoRefreshProps {
terminal?: boolean;
Expand Down Expand Up @@ -102,19 +108,43 @@ export function AutoRefresh({

if (!available) return null;

const action = terminal ? "Auto-refresh stopped" : enabled ? "Disable auto-refresh" : "Enable auto-refresh";
const failed = transientFailureCount > 0 || localFailures.current > 0;
const quiet = !terminal && !paused && !failed;
return (
<Block display="flex" alignItems="center" flexWrap gridGap="scale500" data-paused={paused || terminal}>
<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">
<Block display="flex" alignItems="center" flexWrap gridGap="scale300" data-paused={paused || terminal}>
<StatefulTooltip content={action} placement="bottomRight" onMouseEnterDelay={150} onMouseLeaveDelay={0}>
<Button type="button" kind={KIND.tertiary} shape={SHAPE.circle} size={SIZE.mini}
isSelected={enabled && !terminal} disabled={terminal}
aria-label={action} aria-pressed={enabled && !terminal} aria-busy={refreshing}
onClick={() => setEnabled(current => !current)}
overrides={{ BaseButton: { style: ({ $theme, $isSelected, $isFocusVisible }: {
$theme: Theme; $isSelected?: boolean; $isFocusVisible?: boolean;
}) => ({
backgroundColor: $isSelected ? $theme.colors.backgroundAccentLight : "transparent",
color: $isSelected ? $theme.colors.contentAccent : $theme.colors.contentSecondary,
boxShadow: $isFocusVisible ? `inset 0 0 0 2px ${$theme.colors.borderAccent}` : "none",
":hover": {
backgroundColor: $isSelected ? $theme.colors.backgroundAccentLight : $theme.colors.backgroundSecondary,
color: $isSelected ? $theme.colors.contentAccent : $theme.colors.contentPrimary,
},
"@media (pointer: coarse)": { width: "44px", height: "44px" },
}) } }}>
<RefreshIcon $spinning={refreshing} width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16M8 16H3v5" />
</RefreshIcon>
</Button>
</StatefulTooltip>
{enabled && <Block as="span" font="ParagraphXSmall" color="contentSecondary" role="status"
$style={quiet ? {
position: "absolute", width: "1px", height: "1px", padding: 0, margin: "-1px",
overflow: "hidden", clipPath: "inset(50%)", whiteSpace: "nowrap",
} : {}}>
{terminal ? "Automatic refresh stopped" : paused ? "Automatic refresh paused" : refreshing ? "Refreshing…" :
transientFailureCount > 0 || localFailures.current > 0 ? "Updates unavailable · retrying" : "Live"}
</LabelXSmall>}
failed ? "Updates unavailable · retrying" : "Live"}
</Block>}
</Block>
);
}
2 changes: 1 addition & 1 deletion web/stovepipe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Repository breakage, per-project breakage, and recording completion are separate

## Refresh and presentation

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. Click a dotted-underlined timestamp to inspect its local, UTC and ISO values, copy the ISO timestamp, or change the Local/UTC display preference for the entire page. 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.
The header offers optional ten-second auto-refresh, off by default. The circular-arrow button toggles polling, stays selected while enabled, and animates only during a refresh; BaseUI tooltips identify Enable auto-refresh and Disable auto-refresh. 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. Click a dotted-underlined timestamp to inspect its local, UTC and ISO values, copy the ISO timestamp, or change the Local/UTC display preference for the entire page. 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.

Expand Down
6 changes: 3 additions & 3 deletions web/stovepipe/view/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ it("retains issue data and auto-refresh when a cursor refresh fails on the first
const { rerender } = render(<WebNavigationProvider value={navigation}>
<StovepipeApp model={issuesModel} />
</WebNavigationProvider>);
fireEvent.click(screen.getByRole("checkbox", { name: "Auto refresh every 10s" }));
fireEvent.click(screen.getByRole("button", { name: /auto-refresh/i }));
rerender(<WebNavigationProvider value={navigation}>
<StovepipeApp model={{
...issuesModel, key: issuesModel.latestHref, showLatestLink: false,
Expand All @@ -117,15 +117,15 @@ it("retains issue data and auto-refresh when a cursor refresh fails on the first
</WebNavigationProvider>);
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();
expect(screen.getByRole("button", { name: /auto-refresh/i })).toHaveAttribute("aria-pressed", "true");
rerender(<WebNavigationProvider value={navigation}>
<StovepipeApp model={{
...issuesModel, queue: "other", key: "/other?view=issues", latestHref: "/other?view=issues",
issues: { ok: false, error: "Unable to load other issues." },
}} />
</WebNavigationProvider>);
expect(screen.queryByText("Task-wide failure")).toBeNull();
expect(screen.getByRole("checkbox", { name: "Auto refresh every 10s" })).not.toBeChecked();
expect(screen.getByRole("button", { name: /auto-refresh/i })).toHaveAttribute("aria-pressed", "false");
});
it("links queue entries by request ID even when the change URI is missing", () => {
if (!model.history.ok) throw new Error("No summary");
Expand Down
12 changes: 6 additions & 6 deletions web/stovepipe/view/refresh.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ it("refreshes only after polling is enabled, then stops when disabled", async ()
);
await act(async () => vi.advanceTimersByTime(30_000));
expect(refresh).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(screen.getByRole("button", { name: /auto-refresh/i }));
await act(async () => vi.advanceTimersByTime(9_999));
expect(refresh).not.toHaveBeenCalled();
await act(async () => vi.advanceTimersByTime(1));
expect(refresh).toHaveBeenCalledWith(null);
expect(refresh).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(screen.getByRole("button", { name: /auto-refresh/i }));
await act(async () => vi.advanceTimersByTime(30_000));
expect(refresh).toHaveBeenCalledTimes(1);
});
Expand All @@ -56,10 +56,10 @@ it("reports a failed refresh and recovers on the next ten-second poll", async ()
<RefreshControls />
</WebNavigationProvider>,
);
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(screen.getByRole("button", { name: /auto-refresh/i }));
await act(async () => vi.advanceTimersByTime(10_000));
expect(screen.getByRole("alert")).toHaveTextContent("Refresh failed");
expect(screen.getByRole("checkbox")).toBeChecked();
expect(screen.getByRole("button", { name: /auto-refresh/i })).toHaveAttribute("aria-pressed", "true");
await act(async () => vi.advanceTimersByTime(10_000));
expect(refresh).toHaveBeenCalledTimes(2);
expect(screen.queryByRole("alert")).toBeNull();
Expand All @@ -72,7 +72,7 @@ it("removes scheduled polls when the page unmounts", async () => {
<RefreshControls />
</WebNavigationProvider>,
);
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(screen.getByRole("button", { name: /auto-refresh/i }));
unmount();
await act(async () => vi.advanceTimersByTime(30_000));
expect(refresh).not.toHaveBeenCalled();
Expand All @@ -82,7 +82,7 @@ it("returns cursor-based issue pages to their first page on refresh", async () =
render(<WebNavigationProvider value={{ refresh }}>
<RefreshControls href="/queue?view=issues" />
</WebNavigationProvider>);
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(screen.getByRole("button", { name: /auto-refresh/i }));
await act(async () => vi.advanceTimersByTime(10_000));
expect(refresh).toHaveBeenCalledWith("/queue?view=issues");
});
Loading
Loading