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:
@@ -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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user