milestone 30: overview as a dashboard, explicit health contract, period aggregations
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user