Files
nxdns/admin/src/features/activity/LiveActivity.tsx
T
mokhtar 317d5dd4f8 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.
2026-08-29 11:55:24 +02:00

407 lines
12 KiB
TypeScript

/**
* 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
* server-side resource capped per address, so a page that kept it open while
* showing something else would spend a viewer slot on nothing.
*
* Freeze and Follow are display state and stay out of the URL. They describe
* what the screen is doing right now, not what it is showing, so a shared link
* would carry a frozen moment the recipient never saw fill.
*/
import { useEffect, useRef, useState } from "react";
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";
import ProvenanceDetail from "./ProvenanceDetail";
import RelatedActions from "./RelatedActions";
import { RING_CAPACITY, summaryOf, type StreamedRow } from "./ringBuffer";
import type { ActivitySearch } from "./search";
import { useLiveQueries, type StreamStatus } from "./useLiveQueries";
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
toolbar: {
marginTop: "1rem",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
},
toolbarButton: {
fontWeight: 500,
},
pill: {
borderRadius: "9999px",
paddingInline: "0.625rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
},
/** Four stream states need four tints; only two of them map onto a token role. */
pillConnecting: {
backgroundColor: { default: "oklch(96.7% 0.001 286.375)", [DARK]: "oklch(27.4% 0.006 286.033)" },
color: { default: "oklch(37% 0.013 285.805)", [DARK]: "oklch(87.1% 0.006 286.286)" },
},
pillOpen: {
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(39.3% 0.095 152.535)" },
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(92.5% 0.084 155.995)" },
},
pillRetrying: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(41.4% 0.112 45.904)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(92.4% 0.12 95.746)" },
},
pillCapped: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
},
note: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
/** Informational, neither a warning nor a failure, so the blue ramp stands alone. */
resumed: {
marginTop: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.75rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: { default: "oklch(80.9% 0.105 251.813)", [DARK]: "oklch(37.9% 0.146 265.522)" },
backgroundColor: { default: "oklch(97% 0.014 254.604)", [DARK]: "oklch(28.2% 0.091 267.935)" },
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(88.2% 0.059 254.128)" },
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
dismiss: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
color: "inherit",
fontSize: "inherit",
fontWeight: 500,
textDecorationLine: "underline",
},
failureNote: {
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
cappedBox: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.dangerBorder,
backgroundColor: colors.dangerSurface,
padding: "1rem",
},
cappedHeading: {
fontWeight: 600,
color: colors.dangerText,
},
cappedDetail: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
empty: {
marginTop: "1.5rem",
color: colors.textMuted,
},
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
},
table: {
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** `divide-y`: a hairline between rows, so the first row carries none. */
row: {
borderTopWidth: { default: 1, ":first-child": 0 },
borderTopStyle: "solid",
borderTopColor: colors.border,
},
rowBlocked: {
backgroundColor: {
default: "oklch(97.1% 0.013 17.38)",
[DARK]: "oklch(25.8% 0.092 26.042 / 0.4)",
},
},
/** A streamed row opens its detail here rather than at a route, so it is a button that looks like the link beside it. */
domainButton: {
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
font: "inherit",
textAlign: "left",
cursor: "pointer",
textDecorationLine: "underline",
textDecorationStyle: "dotted",
},
footnote: {
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
const PILL_LABELS: Record<StreamStatus, string> = {
connecting: "Connecting…",
open: "Live",
retrying: "Reconnecting…",
capped: "Disconnected",
};
function pillStyle(status: StreamStatus) {
if (status === "open") return styles.pillOpen;
if (status === "retrying") return styles.pillRetrying;
if (status === "capped") return styles.pillCapped;
return styles.pillConnecting;
}
function StatusPill({ status }: { status: StreamStatus }) {
const label = PILL_LABELS[status];
return (
<span role="status" aria-label={label} {...stylex.props(styles.pill, pillStyle(status))}>
{label}
</span>
);
}
/**
* The open detail, held as the selected row itself rather than as a key into
* the buffer.
*
* 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
* 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);
return (
<Dialog title="Streamed query" size="detail" isOpen onClose={onClose}>
<ProvenanceDetail
provenance={row.event}
persistedId={null}
relatedActions={
<RelatedActions
domain={summary.domain}
client={summary.client_ip}
ts={summary.ts}
origin={origin}
/>
}
/>
</Dialog>
);
}
/**
* `capacity` is the ring size, a parameter only so a test can provoke an
* eviction with a handful of rows rather than 500 frames through React state.
* The route renders this without it, so the app is always the 500-row ring.
*/
export default function LiveActivity({
origin,
capacity = RING_CAPACITY,
}: {
origin: ActivitySearch;
capacity?: number;
}) {
const live = useLiveQueries({ capacity });
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);
}
return (
<>
<div {...stylex.props(styles.toolbar)}>
<StatusPill status={live.status} />
<button
type="button"
onClick={live.toggleFreeze}
aria-pressed={live.frozen}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
>
{live.frozen ? "Resume" : "Freeze"}
</button>
</div>
{live.frozen && (
<p {...stylex.props(styles.note)} role="status">
Display frozen new queries keep buffering ({live.liveCount} in buffer, newest {capacity} kept).
</p>
)}
{live.missed !== null && (
<div role="status" {...stylex.props(styles.resumed)}>
<span>
Stream resumed {" "}
{live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}.
</span>
<button
type="button"
onClick={live.dismissMissed}
{...stylex.props(styles.dismiss, shared.focusRing)}
>
Dismiss
</button>
</div>
)}
{live.resyncFailed && (
<p role="alert" {...stylex.props(styles.failureNote)}>
Stream resumed, but re-syncing the gap failed some queries may be missing here.
</p>
)}
{live.status === "capped" && (
<div role="alert" {...stylex.props(styles.cappedBox)}>
<h2 {...stylex.props(styles.cappedHeading)}>Live stream unavailable</h2>
<p {...stylex.props(styles.cappedDetail)}>
The connection failed repeatedly possibly too many live viewers (the server caps streams per
address), or the server is unreachable.
</p>
<button type="button" onClick={live.retry} {...stylex.props(shared.retryButton, shared.focusRing)}>
Retry
</button>
</div>
)}
{/*
* 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>
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={() => setSelected(null)} />}
</>
);
}