the header row survives only on narrow screens; on desktop its lone occupant, log out, joins pause in the sidebar footer, both full width. pause leaves the query detail page's related actions, where a global control had no business, and its hand-rolled duration dropdown becomes a react-aria menu with real keyboard navigation, dismissal and positioning.
175 lines
5.8 KiB
TypeScript
175 lines
5.8 KiB
TypeScript
/**
|
|
* The Pause/Resume control, at the foot of the sidebar. A pause stops filtering
|
|
* for the whole resolver, so it belongs to the shell rather than to any page,
|
|
* and it has no second placement.
|
|
*
|
|
* 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 } from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import * as stylex from "@stylexjs/stylex";
|
|
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
|
|
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)" },
|
|
},
|
|
/** The sidebar foot is the only placement, and there Log out sets the width. */
|
|
width: "100%",
|
|
},
|
|
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,
|
|
},
|
|
/** `--trigger-width` is RAC's: the menu is as wide as the button that opened it. */
|
|
popover: {
|
|
width: "var(--trigger-width)",
|
|
borderRadius: "0.25rem",
|
|
borderWidth: 1,
|
|
borderStyle: "solid",
|
|
borderColor: colors.border,
|
|
backgroundColor: colors.surfaceRaised,
|
|
color: colors.text,
|
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
|
},
|
|
menu: {
|
|
outlineStyle: "none",
|
|
paddingBlock: "0.25rem",
|
|
},
|
|
menuItem: {
|
|
cursor: "pointer",
|
|
paddingInline: "0.75rem",
|
|
paddingBlock: "0.375rem",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
},
|
|
/**
|
|
* RAC focuses the item's own node, so the shared ring applies; it is inset
|
|
* because an item flush against the popover edge clips an outset one, and
|
|
* recoloured because the focus token is the blue this row just painted.
|
|
*/
|
|
menuItemFocused: {
|
|
backgroundColor: colors.primary,
|
|
color: colors.primaryText,
|
|
outlineColor: { default: null, ":focus-visible": colors.primaryText },
|
|
},
|
|
});
|
|
|
|
export default function PauseControl() {
|
|
const queryClient = useQueryClient();
|
|
const protection = useProtection();
|
|
const mutation = useMutation(pauseMutation(queryClient));
|
|
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";
|
|
|
|
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.row)}>
|
|
<MenuTrigger>
|
|
<Button
|
|
isDisabled={mutation.isPending}
|
|
className={() => stylex.props(shared.button, styles.trigger, shared.focusRing).className ?? ""}
|
|
>
|
|
Pause
|
|
</Button>
|
|
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
|
|
<Menu {...stylex.props(styles.menu)}>
|
|
{DURATIONS.map(({ label, seconds }) => (
|
|
<MenuItem
|
|
key={label}
|
|
onAction={() =>
|
|
mutation.mutate(
|
|
seconds === null
|
|
? { paused: true }
|
|
: { paused: true, duration_seconds: seconds },
|
|
)
|
|
}
|
|
className={({ isFocused }) =>
|
|
stylex.props(
|
|
styles.menuItem,
|
|
shared.insetFocusRing,
|
|
isFocused && styles.menuItemFocused,
|
|
).className ?? ""
|
|
}
|
|
>
|
|
{label}
|
|
</MenuItem>
|
|
))}
|
|
</Menu>
|
|
</Popover>
|
|
</MenuTrigger>
|
|
<InlineError error={mutation.error} />
|
|
</div>
|
|
);
|
|
}
|