admin: live query detail becomes a modal, live/history and period pickers become tabs and radios, client delete confirms in a dialog

the streamed-query detail panel is now a rac modal dialog with focus containment and restore. the live/history switch is rac tabs driven by the url, the overview period picker is a rac radio group, and the clients delete flow uses the shared confirm dialog; an authority turn keeps the dialog open and withdraws only the destructive action.
This commit is contained in:
2026-08-29 11:55:24 +02:00
parent 79578e1d2a
commit 317d5dd4f8
11 changed files with 709 additions and 375 deletions
+109 -134
View File
@@ -1,6 +1,6 @@
/**
* Activity in live mode: the SSE stream, its bounded ring buffer, and the
* in-place detail a streamed row opens.
* Activity in live mode: the SSE stream, its bounded ring buffer, and the modal
* detail a streamed row opens.
*
* This subtree is mounted only while the URL says `mode=live`, which is what
* closes the EventSource on the way back to history: the connection is a
@@ -17,6 +17,7 @@ import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { useClientNames } from "@/features/clients/clientNames";
import { summarizeEvent } from "@/features/provenance/querySummary";
import Dialog from "@/ui/Dialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
@@ -162,29 +163,6 @@ const styles = stylex.create({
textDecorationLine: "underline",
textDecorationStyle: "dotted",
},
detailPanel: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surface,
padding: "1rem",
},
detailBar: {
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: "0.75rem",
},
detailLabel: {
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 600,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: colors.textMuted,
},
footnote: {
marginTop: "0.75rem",
fontSize: "0.875rem",
@@ -193,10 +171,6 @@ const styles = stylex.create({
},
});
/** One panel at a time, so the trigger that opened it can name it in `aria-controls`. */
const DETAIL_PANEL_ID = "live-query-detail";
const DETAIL_LABEL_ID = "live-query-detail-label";
const PILL_LABELS: Record<StreamStatus, string> = {
connecting: "Connecting…",
open: "Live",
@@ -226,40 +200,15 @@ function StatusPill({ status }: { status: StreamStatus }) {
*
* The buffer is a 500-row ring that a gap merge also rewrites: a reference by
* key would go stale under the reader while they were still reading it, and the
* panel would blank out for no reason they could see. The snapshot is the whole
* fact — a streamed frame carries its own provenance — so it survives eviction,
* a merge and a Freeze/Resume, and closes only when the reader closes it or
* leaves live mode.
* dialog would blank out or swap under their eyes for no reason they could see.
* The snapshot is the whole fact — a streamed frame carries its own provenance
* — so it survives eviction, a merge and a Freeze/Resume, and closes only when
* the reader closes it or leaves live mode. The stream behind it never stops.
*/
function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: ActivitySearch; onClose: () => void }) {
const summary = summarizeEvent(row.event);
const panel = useRef<HTMLDivElement>(null);
// The panel opens above the table, behind the trigger in tab order, so a
// forward tab from the row would walk past it. Focus moves in on open —
// keyed on the row, so choosing a second row moves it again — and the
// closer puts it back on the trigger.
useEffect(() => {
panel.current?.focus();
}, [row.key]);
return (
<div
ref={panel}
id={DETAIL_PANEL_ID}
tabIndex={-1}
role="group"
aria-labelledby={DETAIL_LABEL_ID}
{...stylex.props(styles.detailPanel)}
>
<div {...stylex.props(styles.detailBar)}>
<span id={DETAIL_LABEL_ID} {...stylex.props(styles.detailLabel)}>
Streamed query
</span>
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
Close
</button>
</div>
<Dialog title="Streamed query" size="detail" isOpen onClose={onClose}>
<ProvenanceDetail
provenance={row.event}
persistedId={null}
@@ -268,10 +217,11 @@ function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: Activi
domain={summary.domain}
client={summary.client_ip}
ts={summary.ts}
origin={origin} />
origin={origin}
/>
}
/>
</div>
</Dialog>
);
}
@@ -291,21 +241,36 @@ export default function LiveActivity({
const clientNames = useClientNames();
const [selected, setSelected] = useState<StreamedRow | null>(null);
const trigger = useRef<HTMLButtonElement | null>(null);
const results = useRef<HTMLDivElement>(null);
const restoring = useRef(false);
/**
* Where focus lands when the dialog closes.
*
* React Aria restores focus itself, but in a `requestAnimationFrame` and
* only while focus is still on the body — and its target is the row button,
* which the ring may have evicted while the reader was reading. So this runs
* in the effect that follows the focus scope's teardown and puts focus on a
* connected element first: the row if it is still there, the results region
* if it is not. React Aria's deferred pass then finds focus already placed
* and does nothing, so the two never fight over it. If a navigation unmounts
* this component the effect never runs, which is the right answer — there is
* no longer a table to return to.
*/
useEffect(() => {
if (selected !== null || !restoring.current) return;
restoring.current = false;
const from = trigger.current;
trigger.current = null;
(from?.isConnected === true ? from : results.current)?.focus();
}, [selected]);
function open(row: StreamedRow, from: HTMLButtonElement) {
trigger.current = from;
restoring.current = true;
setSelected(row);
}
// The row that opened the panel takes focus back, unless the ring has
// already evicted it: a detached button cannot be focused, and the browser
// falls back to the document, which is the best available answer.
function close() {
setSelected(null);
trigger.current?.focus();
trigger.current = null;
}
return (
<>
<div {...stylex.props(styles.toolbar)}>
@@ -361,71 +326,81 @@ export default function LiveActivity({
</div>
)}
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={close} />}
{/*
* The region is the anchor focus falls back to when the row that
* opened the dialog is gone, so it is rendered unconditionally: an
* anchor that disappears with the last row is no anchor at all.
*/}
<div
ref={results}
tabIndex={-1}
role="region"
aria-label="Live queries"
{...stylex.props(shared.focusRing)}
>
{live.rows.length === 0 ? (
live.status !== "capped" && (
<p {...stylex.props(styles.empty)}>
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
</p>
)
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<ActivityTableHead />
<tbody>
{live.rows.map((row) => {
const summary = summaryOf(row);
return (
<tr
key={row.key}
{...stylex.props(styles.row, summary.blocked && styles.rowBlocked)}
>
<ActivityCells
row={summary}
clientNames={clientNames}
renderDomain={(_id, children) =>
row.kind === "streamed" ? (
<button
type="button"
aria-haspopup="dialog"
onClick={(event) => open(row, event.currentTarget)}
{...stylex.props(
styles.domainButton,
activityDomainLink,
shared.focusRing,
)}
>
{children}
</button>
) : (
<Link
to="/activity/queries/$id"
params={{ id: String(row.row.id) }}
search={origin}
{...stylex.props(activityDomainLink, shared.focusRing)}
>
{children}
</Link>
)
}
/>
</tr>
);
})}
</tbody>
</table>
</div>
<p {...stylex.props(styles.footnote)}>
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first,
last {capacity} kept).
</p>
</>
)}
</div>
{live.rows.length === 0 ? (
live.status !== "capped" && (
<p {...stylex.props(styles.empty)}>
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
</p>
)
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<ActivityTableHead />
<tbody>
{live.rows.map((row) => {
const summary = summaryOf(row);
return (
<tr
key={row.key}
{...stylex.props(styles.row, summary.blocked && styles.rowBlocked)}
>
<ActivityCells
row={summary}
clientNames={clientNames}
renderDomain={(_id, children) =>
row.kind === "streamed" ? (
<button
type="button"
aria-expanded={selected?.key === row.key}
aria-controls={
selected?.key === row.key ? DETAIL_PANEL_ID : undefined
}
onClick={(event) => open(row, event.currentTarget)}
{...stylex.props(
styles.domainButton,
activityDomainLink,
shared.focusRing,
)}
>
{children}
</button>
) : (
<Link
to="/activity/queries/$id"
params={{ id: String(row.row.id) }}
search={origin}
{...stylex.props(activityDomainLink, shared.focusRing)}
>
{children}
</Link>
)
}
/>
</tr>
);
})}
</tbody>
</table>
</div>
<p {...stylex.props(styles.footnote)}>
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
{capacity} kept).
</p>
</>
)}
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={() => setSelected(null)} />}
</>
);
}