milestone 29: activity — history, live and policy simulation on one surface
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
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.
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Activity in history mode: the persisted queries the URL's filters select,
|
||||
* paged by keyset cursor.
|
||||
*
|
||||
* The filters arrive already applied — the URL is the applied state — so this
|
||||
* only reads them. Everything about how the reader got here lives one level up.
|
||||
*/
|
||||
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import * as api from "@/lib/api";
|
||||
import CoverageNotice from "@/lib/CoverageNotice";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { queriesInfiniteQuery } from "@/lib/queries";
|
||||
import type { QueryRow } from "@/lib/types";
|
||||
import { useClientNames } from "@/features/clients/clientNames";
|
||||
import { summarizeRow } from "@/features/queries/querySummary";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||
import { queriesFilterOf, type ActivitySearch } from "./search";
|
||||
|
||||
const styles = stylex.create({
|
||||
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,
|
||||
},
|
||||
footer: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
refetching: {
|
||||
marginTop: "0.75rem",
|
||||
},
|
||||
moreButton: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
moreError: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.dangerText,
|
||||
},
|
||||
});
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export default function HistoryActivity({ search }: { search: ActivitySearch }) {
|
||||
const filter = queriesFilterOf(search);
|
||||
const base = useInfiniteQuery(queriesInfiniteQuery(filter));
|
||||
const clientNames = useClientNames();
|
||||
|
||||
const pages = base.data?.pages ?? [];
|
||||
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
||||
const coverage = pages[0]?.coverage;
|
||||
const filterActive = Object.keys(filter).length > 0;
|
||||
// `base.hasNextPage` reads the query state, which is empty while placeholder
|
||||
// data stands in for a filter change; derive the cursor from what is on
|
||||
// screen so the button keeps its place instead of flashing "end of log".
|
||||
const lastPage = pages[pages.length - 1];
|
||||
const hasMore = lastPage !== undefined && lastPage.next_before !== null;
|
||||
// A 401 is already redirecting via the cache-level handleUnauthorized.
|
||||
const isUnauthorized = base.error instanceof api.ApiError && base.error.status === 401;
|
||||
const moreError = base.isFetchNextPageError && !isUnauthorized ? errorMessage(base.error) : null;
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore || base.isFetchingNextPage || base.isPlaceholderData) return;
|
||||
void base.fetchNextPage();
|
||||
}
|
||||
|
||||
// The loader starts this fetch but does not wait for it, so both the first
|
||||
// paint and a failed first page are this component's to render.
|
||||
if (base.status === "error" && base.data === undefined) {
|
||||
return <InlineError error={base.error} onRetry={() => void base.refetch()} />;
|
||||
}
|
||||
if (base.data === undefined) {
|
||||
return (
|
||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||
Loading activity…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{base.isFetching && (
|
||||
<p {...stylex.props(styles.note, styles.refetching)} role="status">
|
||||
Loading…
|
||||
</p>
|
||||
)}
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
|
||||
{rows.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} {...stylex.props(styles.row)}>
|
||||
<ActivityCells
|
||||
row={summarizeRow(row)}
|
||||
clientNames={clientNames}
|
||||
renderDomain={(id, children) =>
|
||||
id === null ? (
|
||||
children
|
||||
) : (
|
||||
<Link
|
||||
to="/activity/queries/$id"
|
||||
params={{ id: String(id) }}
|
||||
search={search}
|
||||
{...stylex.props(activityDomainLink, shared.focusRing)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div {...stylex.props(styles.footer)}>
|
||||
<p {...stylex.props(styles.note)}>
|
||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||
{hasMore ? "" : " — end of log"}
|
||||
</p>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={base.isFetchingNextPage || base.isPlaceholderData}
|
||||
{...stylex.props(shared.button, styles.moreButton, shared.focusRing)}
|
||||
>
|
||||
{base.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{moreError !== null && (
|
||||
<p role="alert" {...stylex.props(styles.moreError)}>
|
||||
Failed to load more: {moreError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user