Gates / test (push) Successful in 1m40s
Gates / package (push) Successful in 3m58s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m51s
Gates / frontend (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 6m57s
query log, live and lookup merge into /activity. history filters live in the url, so a pasted link or back/forward reproduces the exact view; the result column separates servfail and nxdomain from success in the list. live is follow-by-default with freeze, and a streamed row opens its in-memory provenance detail — no correlation invented for rows sqlite has not written. lookup survives as the current policy simulation under /activity/test. investigation links carry absolute bounds, and the diagnostics page now honors since/until instead of ignoring them. the old routes are gone without aliases.
153 lines
4.4 KiB
TypeScript
153 lines
4.4 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 { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
|
import * as stylex from "@stylexjs/stylex";
|
|
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",
|
|
},
|
|
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,
|
|
},
|
|
});
|
|
|
|
export default function ActivityPage() {
|
|
const search = useSearch({ from: "/shell/activity" });
|
|
const navigate = useNavigate({ from: "/activity" });
|
|
const live = search.mode === "live";
|
|
|
|
// 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 }) });
|
|
}
|
|
|
|
function apply(filters: AppliedFilters) {
|
|
void navigate({ search: { mode: search.mode, ...filters } });
|
|
}
|
|
|
|
return (
|
|
<section>
|
|
<div {...stylex.props(styles.header)}>
|
|
<h1 {...stylex.props(styles.heading)}>Activity</h1>
|
|
<div role="group" aria-label="Activity mode" {...stylex.props(styles.switch)}>
|
|
{MODES.map((option) => {
|
|
const selected = option.mode === search.mode;
|
|
return (
|
|
<button
|
|
key={option.mode}
|
|
type="button"
|
|
aria-pressed={selected}
|
|
onClick={() => selectMode(option.mode)}
|
|
{...stylex.props(
|
|
styles.modeButton,
|
|
selected ? styles.modeSelected : styles.modeIdle,
|
|
shared.focusRing,
|
|
)}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
|
|
Current policy simulation
|
|
</Link>
|
|
</div>
|
|
|
|
{/*
|
|
* Remounted whenever the applied search changes, which is what makes
|
|
* the back button work: the draft is derived state, and the browser
|
|
* moving the URL under it has to move the form with it.
|
|
*/}
|
|
<ActivityFilters
|
|
key={`${search.domain ?? ""}|${search.client ?? ""}|${String(search.blocked)}|${String(search.since)}|${String(search.until)}`}
|
|
applied={search}
|
|
isDisabled={live}
|
|
onApply={apply}
|
|
onClear={() => apply(NO_FILTERS)}
|
|
/>
|
|
|
|
{live ? (
|
|
<>
|
|
<p {...stylex.props(styles.liveNote)}>
|
|
The stream carries every query the server answers; these filters apply to history only.
|
|
</p>
|
|
<LiveActivity origin={search} />
|
|
</>
|
|
) : (
|
|
<HistoryActivity search={search} />
|
|
)}
|
|
</section>
|
|
);
|
|
}
|