the five-field form with its apply button is gone. one row holds a search-shaped domain field and a client ip field that debounce into the url with enter flushing at once, result segments and a time menu that commit instantly, and a clear that appears only when a filter is active without reflowing the row. presets freeze both absolute bounds at click time so a bookmark describes the same investigation later, custom ranges apply atomically through set range with inline validation, and echo queues keep in-flight commits from clobbering newer typing or newer picks. live mode renders no toolbar, the availability banner became a footer note, previous rows stay visible during refetch, and every control carries a 44px hit target.
195 lines
6.2 KiB
TypeScript
195 lines
6.2 KiB
TypeScript
/**
|
|
* Activity: one surface over the queries nxdns answered, in two modes.
|
|
*
|
|
* History reads the persisted log and Live reads the stream, but they are the
|
|
* same seven columns over the same filters, and the reader moves between them
|
|
* without losing the question they were asking. The mode lives in the URL with
|
|
* the filters, so an investigation is one link — including which half of it the
|
|
* recipient should be looking at.
|
|
*/
|
|
|
|
import { useCallback } from "react";
|
|
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
|
import * as stylex from "@stylexjs/stylex";
|
|
import { Tab, TabList, TabPanel, Tabs } from "react-aria-components";
|
|
import { styles as shared } from "@/ui/styles";
|
|
import { colors } from "@/ui/tokens.stylex";
|
|
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
|
|
import HistoryActivity from "./HistoryActivity";
|
|
import LiveActivity from "./LiveActivity";
|
|
import type { ActivityMode } from "./search";
|
|
|
|
const MODES: ReadonlyArray<{ mode: ActivityMode; label: string }> = [
|
|
{ mode: "history", label: "History" },
|
|
{ mode: "live", label: "Live" },
|
|
];
|
|
|
|
const styles = stylex.create({
|
|
header: {
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
alignItems: "center",
|
|
gap: "0.75rem",
|
|
},
|
|
heading: {
|
|
fontSize: "1.5rem",
|
|
lineHeight: "2rem",
|
|
fontWeight: 600,
|
|
},
|
|
switch: {
|
|
display: "flex",
|
|
gap: "0.25rem",
|
|
borderRadius: "0.25rem",
|
|
borderWidth: 1,
|
|
borderStyle: "solid",
|
|
borderColor: colors.border,
|
|
padding: "0.125rem",
|
|
},
|
|
modeButton: {
|
|
borderStyle: "none",
|
|
borderRadius: "0.1875rem",
|
|
paddingInline: "0.75rem",
|
|
paddingBlock: "0.25rem",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
},
|
|
/** A Tab is a `div` with a roving tabindex, so RAC drives the ring, not `:focus-visible`. */
|
|
modeFocusVisible: {
|
|
outlineWidth: 2,
|
|
outlineStyle: "solid",
|
|
outlineColor: colors.focus,
|
|
outlineOffset: 2,
|
|
},
|
|
modeIdle: {
|
|
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
|
color: { default: colors.textSecondary, ":hover": colors.text },
|
|
},
|
|
/** The selected mode reads as a filled chip, the same weight the nav uses. */
|
|
modeSelected: {
|
|
backgroundColor: colors.primary,
|
|
color: colors.primaryText,
|
|
},
|
|
/** The one way into the simulation from here, so it cannot sit behind a row. */
|
|
simulationLink: {
|
|
marginInlineStart: "auto",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.primaryOnSurface,
|
|
textDecorationLine: "none",
|
|
},
|
|
liveNote: {
|
|
marginTop: "0.5rem",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.textMuted,
|
|
},
|
|
panel: {
|
|
outlineStyle: "none",
|
|
},
|
|
/**
|
|
* A panel with nothing tabbable in it — an empty or loading history — is given
|
|
* a tabindex by RAC so the reader can still reach its content, so it has to be
|
|
* able to show that it holds the focus.
|
|
*/
|
|
panelFocusVisible: {
|
|
outlineWidth: 2,
|
|
outlineStyle: "solid",
|
|
outlineColor: colors.focus,
|
|
outlineOffset: 2,
|
|
},
|
|
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
|
|
tabsRoot: {
|
|
display: "block",
|
|
},
|
|
});
|
|
|
|
function panelClass({ isFocusVisible }: { isFocusVisible: boolean }): string {
|
|
return stylex.props(styles.panel, isFocusVisible && styles.panelFocusVisible).className ?? "";
|
|
}
|
|
|
|
export default function ActivityPage() {
|
|
const search = useSearch({ from: "/shell/activity" });
|
|
const navigate = useNavigate({ from: "/activity" });
|
|
// The functional form, not a replacement object: the filters are retained
|
|
// across a mode switch on purpose, and spelling out a new search here would
|
|
// drop every one of them on the way to Live and back.
|
|
function selectMode(mode: ActivityMode) {
|
|
if (mode === search.mode) return;
|
|
void navigate({ search: (prev) => ({ ...prev, mode }) });
|
|
}
|
|
|
|
// A patch, merged into whatever the URL already says: a segment click must not
|
|
// spell out the four filters it is not about. `replace` is the debounced text
|
|
// commits, so the back button steps between investigations, not keystrokes.
|
|
const apply = useCallback(
|
|
(patch: Partial<AppliedFilters>, replace = false) => {
|
|
void navigate({ search: (prev) => ({ ...prev, ...patch }), replace });
|
|
},
|
|
[navigate],
|
|
);
|
|
|
|
const clear = useCallback(() => {
|
|
void navigate({ search: (prev) => ({ mode: prev.mode, ...NO_FILTERS }) });
|
|
}, [navigate]);
|
|
|
|
return (
|
|
<section>
|
|
{/*
|
|
* The selected tab is the URL's `mode` and nothing else. RAC would hold
|
|
* the selection itself, but a second copy of it would fight the back
|
|
* button, so the search parameter stays the only state there is.
|
|
*/}
|
|
<Tabs
|
|
selectedKey={search.mode}
|
|
onSelectionChange={(key) => selectMode(key as ActivityMode)}
|
|
className={() => stylex.props(styles.tabsRoot).className ?? ""}
|
|
>
|
|
<div {...stylex.props(styles.header)}>
|
|
<h1 {...stylex.props(styles.heading)}>Activity</h1>
|
|
<TabList
|
|
aria-label="Activity mode"
|
|
className={() => stylex.props(styles.switch).className ?? ""}
|
|
>
|
|
{MODES.map((option) => (
|
|
<Tab
|
|
key={option.mode}
|
|
id={option.mode}
|
|
className={({ isSelected, isFocusVisible }) =>
|
|
stylex.props(
|
|
styles.modeButton,
|
|
isSelected ? styles.modeSelected : styles.modeIdle,
|
|
isFocusVisible && styles.modeFocusVisible,
|
|
).className ?? ""
|
|
}
|
|
>
|
|
{option.label}
|
|
</Tab>
|
|
))}
|
|
</TabList>
|
|
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
|
|
Current policy simulation
|
|
</Link>
|
|
</div>
|
|
|
|
{/*
|
|
* The toolbar belongs to History alone. It is not remounted on a search
|
|
* change: it resyncs its draft from the URL instead, because a remount
|
|
* mid-debounce would take the focus out of the input being typed in.
|
|
*/}
|
|
<TabPanel id="history" className={panelClass}>
|
|
<ActivityFilters applied={search} onApply={apply} onClear={clear} />
|
|
<HistoryActivity search={search} />
|
|
</TabPanel>
|
|
<TabPanel id="live" className={panelClass}>
|
|
<p {...stylex.props(styles.liveNote)}>
|
|
The stream carries every query the server answers; the History filters apply to history only.
|
|
</p>
|
|
<LiveActivity origin={search} />
|
|
</TabPanel>
|
|
</Tabs>
|
|
</section>
|
|
);
|
|
}
|