milestone 30: overview as a dashboard, explicit health contract, period aggregations
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* The Pause control, migrated from the header PauseWidget it replaces. Every
|
||||
* behaviour that widget pinned is pinned here, now driven by `Health.protection`
|
||||
* rather than by a second poll of `/api/pause`.
|
||||
*/
|
||||
|
||||
import { act } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import PauseControl from "@/features/pause/PauseControl";
|
||||
import { formatClock } from "@/lib/format";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import { queryKeys } from "@/lib/queries";
|
||||
import type { Health, PausePost, PauseState } from "@/lib/types";
|
||||
|
||||
let protection: Health["protection"];
|
||||
let postBodies: PausePost[];
|
||||
let postFailure: (() => Response) | null;
|
||||
let healthFails: boolean;
|
||||
|
||||
function jsonResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
protection = { state: "active", until: null };
|
||||
postBodies = [];
|
||||
postFailure = null;
|
||||
healthFails = false;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/health") {
|
||||
if (healthFails) {
|
||||
return new Response(JSON.stringify({ error: "nope" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return jsonResponse(health({ protection }));
|
||||
}
|
||||
if (url === "/api/pause" && init?.method === "POST") {
|
||||
const body = JSON.parse(String(init.body)) as PausePost;
|
||||
postBodies.push(body);
|
||||
if (postFailure !== null) return postFailure();
|
||||
protection = body.paused
|
||||
? {
|
||||
state: "paused",
|
||||
until:
|
||||
body.duration_seconds == null
|
||||
? null
|
||||
: Math.floor(Date.now() / 1000) + body.duration_seconds,
|
||||
}
|
||||
: { state: "active", until: null };
|
||||
const echo: PauseState = { paused: body.paused, until: protection.until };
|
||||
return jsonResponse(echo);
|
||||
}
|
||||
return jsonResponse({ error: "not stubbed" });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function renderControl(client?: QueryClient) {
|
||||
render(
|
||||
<QueryClientProvider client={client ?? createQueryClient()}>
|
||||
<PauseControl />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function findPauseTrigger(): Promise<HTMLButtonElement> {
|
||||
await waitFor(() => {
|
||||
const button = screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
|
||||
expect(button.disabled).toBe(false);
|
||||
});
|
||||
return screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
test("unpaused: duration menu pauses with the picked duration_seconds", async () => {
|
||||
renderControl();
|
||||
|
||||
const trigger = await findPauseTrigger();
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) {
|
||||
expect(screen.getByRole("button", { name: label })).toBeTruthy();
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
});
|
||||
|
||||
test("indefinite pause sends no duration_seconds", async () => {
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
});
|
||||
|
||||
test("resume posts paused false and returns to the Pause button", async () => {
|
||||
protection = { state: "paused", until: null };
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Resume" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: false }]));
|
||||
await screen.findByRole("button", { name: "Pause" });
|
||||
});
|
||||
|
||||
test("a timed pause says until when, beside the control that would end it", () => {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const client = createQueryClient();
|
||||
client.setQueryData(queryKeys.health, health({ protection: { state: "paused", until: nowSec + 90 } }));
|
||||
renderControl(client);
|
||||
|
||||
// The same clock format the Diagnostics health strip writes, off the same
|
||||
// reading, so the two cannot say different things about one pause.
|
||||
expect(screen.getByText(`Paused until ${formatClock(nowSec + 90)}`)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Resume" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a pause with no end says so without inventing a time", () => {
|
||||
const client = createQueryClient();
|
||||
client.setQueryData(queryKeys.health, health({ protection: { state: "paused", until: null } }));
|
||||
renderControl(client);
|
||||
|
||||
expect(screen.getByText("Paused")).toBeTruthy();
|
||||
expect(screen.queryByText(/until/)).toBeNull();
|
||||
});
|
||||
|
||||
test("an active resolver states nothing: the button already says Pause", async () => {
|
||||
renderControl();
|
||||
|
||||
expect(await findPauseTrigger()).toBeTruthy();
|
||||
expect(screen.queryByText(/^Paused/)).toBeNull();
|
||||
});
|
||||
|
||||
test("escape closes the duration menu", async () => {
|
||||
renderControl();
|
||||
|
||||
const trigger = await findPauseTrigger();
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
fireEvent.keyDown(trigger, { key: "Escape" });
|
||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||
});
|
||||
|
||||
test("failed pause with 429 shows a ticking retry countdown", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
postFailure = () =>
|
||||
new Response(JSON.stringify({ error: "rate limited" }), {
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json", "Retry-After": "30" },
|
||||
});
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 29s.");
|
||||
});
|
||||
|
||||
test("failed pause with 503 shows the degraded message", async () => {
|
||||
postFailure = () =>
|
||||
new Response(JSON.stringify({ error: "unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "60 seconds" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
|
||||
});
|
||||
|
||||
test("a successful pause clears the previous mutation error", async () => {
|
||||
postFailure = () =>
|
||||
new Response(JSON.stringify({ error: "unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
await screen.findByRole("alert");
|
||||
|
||||
postFailure = null;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
test("no control at all while protection is unavailable", async () => {
|
||||
protection = { state: "unavailable", until: null };
|
||||
const client = createQueryClient();
|
||||
renderControl(client);
|
||||
|
||||
await waitFor(() => expect(client.getQueryData(queryKeys.health)).toBeDefined());
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
test("a failed poll after a good one withdraws the control rather than acting on a stale state", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderControl();
|
||||
await vi.waitFor(() =>
|
||||
expect((screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement).disabled).toBe(false),
|
||||
);
|
||||
|
||||
healthFails = true;
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
|
||||
await vi.waitFor(() => expect(screen.queryByRole("button")).toBeNull());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("a menu left open when the control withdraws does not come back open", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderControl();
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
|
||||
protection = { state: "unavailable", until: null };
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
await vi.waitFor(() => expect(screen.queryByRole("button")).toBeNull());
|
||||
|
||||
protection = { state: "active", until: null };
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
|
||||
// Protection is back, and so is the trigger — but the menu is a thing the
|
||||
// reader opened, and nobody opened this one.
|
||||
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("a menu open when someone else pauses does not reopen when that pause ends", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderControl();
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
|
||||
// Filtering is paused from somewhere else, and this browser learns it from
|
||||
// the poll. The Resume rendering has no menu.
|
||||
protection = { state: "paused", until: null };
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
await vi.waitFor(() => expect(screen.getByRole("button", { name: "Resume" })).toBeTruthy());
|
||||
|
||||
protection = { state: "active", until: null };
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
|
||||
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("protection unknown offers no control at all, by the same rule as unavailable", () => {
|
||||
// Health has not answered. Which of Pause and Resume applies is exactly what
|
||||
// it has not said, so the control names neither.
|
||||
renderControl();
|
||||
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* The Pause/Resume control, in the two places a pause is a valid answer to what
|
||||
* the reader is looking at: the foot of the sidebar, where it belongs to the
|
||||
* resolver rather than to any page, and beside the detail of a query that was
|
||||
* blocked.
|
||||
*
|
||||
* It reads `Health.protection` rather than `/api/pause` so it cannot contradict
|
||||
* the Diagnostics health strip, and it renders nothing at all while protection
|
||||
* is unavailable or unknown — pausing a resolver that has no filter snapshot
|
||||
* would change nothing an operator could observe, and a state health has not
|
||||
* confirmed does not name an action either.
|
||||
*
|
||||
* A pause says so, wherever the control is. "Resume" alone names an action
|
||||
* without stating the state it would end, and with the header indicator and the
|
||||
* Overview status row both gone the sidebar is the only place most pages can
|
||||
* carry that fact at all: a paused resolver would otherwise leave no trace
|
||||
* outside the Diagnostics page. The line and the health strip cannot disagree —
|
||||
* one `protection` reading, one clock format, and the expiry refetch in
|
||||
* `useProtection` retires both at the same moment. An active resolver gets no
|
||||
* line: the button says Pause, which is the whole message.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatClock } from "@/lib/format";
|
||||
import { pauseMutation } from "@/lib/queries";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { useProtection } from "./protection";
|
||||
|
||||
const DURATIONS = [
|
||||
{ label: "60 seconds", seconds: 60 },
|
||||
{ label: "5 minutes", seconds: 300 },
|
||||
{ label: "30 minutes", seconds: 1800 },
|
||||
{ label: "Indefinitely", seconds: null },
|
||||
] as const;
|
||||
|
||||
const styles = stylex.create({
|
||||
/**
|
||||
* Disabled text darkens in light scheme and lightens in dark, the opposite
|
||||
* direction from `textMuted`, so the token cannot express it.
|
||||
*/
|
||||
trigger: {
|
||||
color: {
|
||||
default: null,
|
||||
":disabled": "oklch(70.5% 0.015 286.067)",
|
||||
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
|
||||
},
|
||||
},
|
||||
row: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
/** Text, never a colour or an icon alone: this is the state, spelled out. */
|
||||
state: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
anchor: {
|
||||
position: "relative",
|
||||
},
|
||||
menu: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: "100%",
|
||||
zIndex: 10,
|
||||
marginTop: "0.25rem",
|
||||
display: "flex",
|
||||
width: "9rem",
|
||||
flexDirection: "column",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingBlock: "0.25rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
menuItem: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: "inherit",
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
});
|
||||
|
||||
export default function PauseControl() {
|
||||
const queryClient = useQueryClient();
|
||||
const protection = useProtection();
|
||||
const mutation = useMutation(pauseMutation(queryClient));
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const paused = protection.state === "paused";
|
||||
const { reset } = mutation;
|
||||
useEffect(() => reset(), [paused, reset]);
|
||||
|
||||
// Nothing to offer, by the same rule in both cases: unavailable has no action
|
||||
// worth taking, and unknown has no way to tell which of the two it would be.
|
||||
// A disabled "Pause" beside a reading that says "Paused until 14:05" names
|
||||
// the wrong action, which is the contradiction this control exists to end.
|
||||
const actionable = protection.state === "active" || protection.state === "paused";
|
||||
|
||||
// Leaving `active` unmounts the menu but not the state that opened it, and
|
||||
// the menu belongs to the active rendering alone — a pause someone else
|
||||
// started, seen through the poll, takes it away exactly as a withdrawal does.
|
||||
// Closing on the way out rather than on the way back means the trigger can
|
||||
// only ever come back shut, however long it was gone.
|
||||
useEffect(() => {
|
||||
if (protection.state !== "active") setMenuOpen(false);
|
||||
}, [protection.state]);
|
||||
|
||||
if (!actionable) return null;
|
||||
|
||||
if (paused) {
|
||||
const until = protection.state === "paused" ? protection.until : null;
|
||||
return (
|
||||
<div {...stylex.props(styles.row)}>
|
||||
<p {...stylex.props(styles.state)}>
|
||||
{until === null ? "Paused" : `Paused until ${formatClock(until)}`}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mutation.mutate({ paused: false })}
|
||||
disabled={mutation.isPending}
|
||||
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<InlineError error={mutation.error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...stylex.props(styles.anchor)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="pause-menu"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
disabled={mutation.isPending}
|
||||
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div id="pause-menu" {...stylex.props(styles.menu)}>
|
||||
{DURATIONS.map(({ label, seconds }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
mutation.mutate(
|
||||
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
|
||||
);
|
||||
}}
|
||||
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<InlineError error={mutation.error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import { act } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import PauseWidget, { formatRemaining } from "@/features/pause/PauseWidget";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { queryKeys } from "@/lib/queries";
|
||||
import type { PausePost, PauseState } from "@/lib/types";
|
||||
|
||||
let getState: PauseState;
|
||||
let postBodies: PausePost[];
|
||||
let postResponse: (body: PausePost) => PauseState;
|
||||
let postFailure: (() => Response) | null;
|
||||
|
||||
function jsonResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
postBodies = [];
|
||||
postFailure = null;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url !== "/api/pause") return jsonResponse({ error: "not stubbed" });
|
||||
if (init?.method === "POST") {
|
||||
const body = JSON.parse(String(init.body)) as PausePost;
|
||||
postBodies.push(body);
|
||||
if (postFailure !== null) return postFailure();
|
||||
return jsonResponse(postResponse(body));
|
||||
}
|
||||
return jsonResponse(getState);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function renderWidget(client?: QueryClient) {
|
||||
render(
|
||||
<QueryClientProvider client={client ?? createQueryClient()}>
|
||||
<PauseWidget />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function findPauseTrigger(): Promise<HTMLButtonElement> {
|
||||
await waitFor(() => {
|
||||
const button = screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
|
||||
expect(button.disabled).toBe(false);
|
||||
});
|
||||
return screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
test("unpaused: duration menu pauses with the picked duration_seconds", async () => {
|
||||
getState = { paused: false, until: null };
|
||||
postResponse = () => ({ paused: true, until: Math.floor(Date.now() / 1000) + 300 });
|
||||
renderWidget();
|
||||
|
||||
const trigger = await findPauseTrigger();
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) {
|
||||
expect(screen.getByRole("button", { name: label })).toBeTruthy();
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
expect(screen.getByText(/^Paused \d+:\d{2}$/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("indefinite pause sends no duration_seconds and renders without a countdown", async () => {
|
||||
getState = { paused: false, until: null };
|
||||
postResponse = () => ({ paused: true, until: null });
|
||||
renderWidget();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
expect(screen.getByText("Paused")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resume posts paused false and returns to the Pause button", async () => {
|
||||
getState = { paused: true, until: null };
|
||||
postResponse = () => ({ paused: false, until: null });
|
||||
renderWidget();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Resume" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: false }]));
|
||||
await screen.findByRole("button", { name: "Pause" });
|
||||
});
|
||||
|
||||
test("timed pause counts down live", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const client = createQueryClient();
|
||||
client.setQueryData(queryKeys.pause, { paused: true, until: nowSec + 90 });
|
||||
renderWidget(client);
|
||||
|
||||
expect(screen.getByText("Paused 1:30")).toBeTruthy();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(screen.getByText("Paused 1:28")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("escape closes the duration menu", async () => {
|
||||
getState = { paused: false, until: null };
|
||||
postResponse = () => getState;
|
||||
renderWidget();
|
||||
|
||||
const trigger = await findPauseTrigger();
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
fireEvent.keyDown(trigger, { key: "Escape" });
|
||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||
});
|
||||
|
||||
test("failed pause with 429 shows a ticking retry countdown", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
getState = { paused: false, until: null };
|
||||
postFailure = () =>
|
||||
new Response(JSON.stringify({ error: "rate limited" }), {
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json", "Retry-After": "30" },
|
||||
});
|
||||
renderWidget();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 29s.");
|
||||
});
|
||||
|
||||
test("failed pause with 503 shows the degraded message", async () => {
|
||||
getState = { paused: false, until: null };
|
||||
postFailure = () =>
|
||||
new Response(JSON.stringify({ error: "unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
renderWidget();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "60 seconds" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
|
||||
});
|
||||
|
||||
test("a successful pause clears the previous mutation error", async () => {
|
||||
getState = { paused: false, until: null };
|
||||
postFailure = () =>
|
||||
new Response(JSON.stringify({ error: "unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
renderWidget();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
await screen.findByRole("alert");
|
||||
|
||||
postFailure = null;
|
||||
postResponse = () => ({ paused: true, until: Math.floor(Date.now() / 1000) + 300 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
test("formatRemaining renders m:ss and h:mm:ss and clamps at zero", () => {
|
||||
expect(formatRemaining(0)).toBe("0:00");
|
||||
expect(formatRemaining(-5)).toBe("0:00");
|
||||
expect(formatRemaining(59)).toBe("0:59");
|
||||
expect(formatRemaining(90)).toBe("1:30");
|
||||
expect(formatRemaining(3661)).toBe("1:01:01");
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { pauseMutation, pauseQuery } from "@/lib/queries";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const DURATIONS = [
|
||||
{ label: "60 seconds", seconds: 60 },
|
||||
{ label: "5 minutes", seconds: 300 },
|
||||
{ label: "30 minutes", seconds: 1800 },
|
||||
{ label: "Indefinitely", seconds: null },
|
||||
] as const;
|
||||
|
||||
const styles = stylex.create({
|
||||
/**
|
||||
* Disabled text darkens in light scheme and lightens in dark, the opposite
|
||||
* direction from `textMuted`, so the token cannot express it.
|
||||
*/
|
||||
trigger: {
|
||||
color: {
|
||||
default: null,
|
||||
":disabled": "oklch(70.5% 0.015 286.067)",
|
||||
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
|
||||
},
|
||||
},
|
||||
pausedRow: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
pausedControls: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
/**
|
||||
* Amber as standalone text on the app ground, not inside a warning banner, so
|
||||
* the `warn*` tokens — tuned against `warnSurface` — do not apply here.
|
||||
*/
|
||||
pausedLabel: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: {
|
||||
default: "oklch(55.5% 0.163 48.998)",
|
||||
"@media (prefers-color-scheme: dark)": "oklch(82.8% 0.189 84.429)",
|
||||
},
|
||||
},
|
||||
anchor: {
|
||||
position: "relative",
|
||||
},
|
||||
menu: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: "100%",
|
||||
zIndex: 10,
|
||||
marginTop: "0.25rem",
|
||||
display: "flex",
|
||||
width: "9rem",
|
||||
flexDirection: "column",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingBlock: "0.25rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
menuItem: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: "inherit",
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
});
|
||||
|
||||
export function formatRemaining(totalSeconds: number): string {
|
||||
const clamped = Math.max(0, totalSeconds);
|
||||
const hours = Math.floor(clamped / 3600);
|
||||
const minutes = Math.floor((clamped % 3600) / 60);
|
||||
const seconds = clamped % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
function nowSeconds(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function useNowSeconds(active: boolean): number {
|
||||
const [now, setNow] = useState(nowSeconds);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setNow(nowSeconds());
|
||||
const id = setInterval(() => setNow(nowSeconds()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [active]);
|
||||
return now;
|
||||
}
|
||||
|
||||
export default function PauseWidget() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data } = useQuery({
|
||||
...pauseQuery(),
|
||||
refetchInterval: (query) => (query.state.data?.paused === true ? 5000 : false),
|
||||
});
|
||||
const mutation = useMutation(pauseMutation(queryClient));
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const now = useNowSeconds(data?.paused === true && data.until !== null);
|
||||
const paused = data?.paused === true;
|
||||
const { reset } = mutation;
|
||||
useEffect(() => reset(), [paused, reset]);
|
||||
|
||||
if (data === undefined) {
|
||||
return (
|
||||
<button type="button" disabled {...stylex.props(shared.button, styles.trigger, shared.focusRing)}>
|
||||
Pause
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.paused) {
|
||||
return (
|
||||
<div {...stylex.props(styles.pausedRow)}>
|
||||
<div {...stylex.props(styles.pausedControls)}>
|
||||
<span {...stylex.props(styles.pausedLabel)}>
|
||||
{data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mutation.mutate({ paused: false })}
|
||||
disabled={mutation.isPending}
|
||||
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={mutation.error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...stylex.props(styles.anchor)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="pause-menu"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
disabled={mutation.isPending}
|
||||
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div id="pause-menu" {...stylex.props(styles.menu)}>
|
||||
{DURATIONS.map(({ label, seconds }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
mutation.mutate(
|
||||
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
|
||||
);
|
||||
}}
|
||||
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<InlineError error={mutation.error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Protection, as the interface reads it: one view model derived from
|
||||
* `Health.protection`, shared by the sidebar Pause control, the related action
|
||||
* on a blocked query's detail, and the Diagnostics health strip.
|
||||
*
|
||||
* There is deliberately no second source. `GET /api/pause` answers the same
|
||||
* question, but two polled copies of one fact can disagree, and the row that
|
||||
* says "Paused" beside a button that says "Pause" is exactly the contradiction
|
||||
* this milestone set out to remove.
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { healthQuery, queryKeys } from "@/lib/queries";
|
||||
import type { Health } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* `unknown` is its own state, never folded into `active`: health has not
|
||||
* answered yet, or the last poll failed, and claiming filtering is in force on
|
||||
* no evidence is the one reading that could get a household bitten.
|
||||
*/
|
||||
export type ProtectionView =
|
||||
{ state: "unknown" } | { state: "active" } | { state: "paused"; until: number | null } | { state: "unavailable" };
|
||||
|
||||
export function protectionViewOf(protection: Health["protection"] | undefined): ProtectionView {
|
||||
if (protection === undefined) return { state: "unknown" };
|
||||
if (protection.state === "unavailable") return { state: "unavailable" };
|
||||
if (protection.state === "paused") return { state: "paused", until: protection.until };
|
||||
return { state: "active" };
|
||||
}
|
||||
|
||||
function nowSeconds(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Protection now, with the expiry of a timed pause scheduled.
|
||||
*
|
||||
* Health polls every ten seconds, so without the timer a pause that ended
|
||||
* three seconds ago still reads "Paused until 14:05" — a stale claim about the
|
||||
* one fact the indicator exists to state. The timeout fires a second past
|
||||
* `until` so the server's own expiry rule, not the browser's clock, decides.
|
||||
*/
|
||||
export function useProtection(): ProtectionView {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isError } = useQuery(healthQuery());
|
||||
// A failed poll leaves the last body in the cache, and that body is a
|
||||
// reading, not the current state: the query this view answers is "is
|
||||
// filtering in force right now", and a stale yes is the same lie as an
|
||||
// invented one. The Diagnostics health strip may still render the cached
|
||||
// conditions, because it marks them stale in the same breath; this view has
|
||||
// no such caption, so a failure is `unknown`.
|
||||
const view = isError ? ({ state: "unknown" } as const) : protectionViewOf(data?.protection);
|
||||
const until = view.state === "paused" ? view.until : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (until === null) return;
|
||||
const delay = Math.max(0, until - nowSeconds() + 1) * 1000;
|
||||
const timer = setTimeout(() => void queryClient.invalidateQueries({ queryKey: queryKeys.health }), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [until, queryClient]);
|
||||
|
||||
return view;
|
||||
}
|
||||
Reference in New Issue
Block a user