Skip to content

Commit 3ff32f8

Browse files
feat(console): persist audit-log filters in the query string (#1395)
Follow-up to #1394. ## What Every audit-log filter is now mirrored into the URL query string, so a filtered `/admin/audit-log` view is **shareable and survives reload**: - event type (`type`), severity (`severity`), origin (`origin`) — comma-separated - date range (`from` / `to`, ISO) - admin workspace filter (`ws` = id, `wsName` = label) ## How Shared `AuditLog` component (used by both `/admin/audit-log` and the workspace-scoped view), `components/AuditLog/AuditLog.tsx`: - **Hydrate once** `router.query` is ready → seed filter state from the URL. - **Mirror back** on every filter change via a shallow `router.replace` (no history spam). - The `hydrated` gate is **state, not a ref**, so the write-back effect can't run with the still-empty initial state and wipe the incoming params before hydration commits. - Non-filter params (e.g. the `[workspaceId]` route segment) are preserved — only the managed keys are rewritten. - The workspace **label** is carried in the URL next to its id so the chip renders a name without an extra lookup (the workspace search is keyed by name, not id). - Pagination (`cursor`) is intentionally **not** persisted. ## Testing - `tsc --noEmit` clean (pre-existing errors are unrelated missing dev-deps: `msw`, `testcontainers`, `@storybook/*`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01SeUP6H1fsHsJjo2FLKKqUS
2 parents f7e7bb1 + 43342c8 commit 3ff32f8

1 file changed

Lines changed: 62 additions & 1 deletion

File tree

webapps/console/components/AuditLog/AuditLog.tsx

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useMemo, useState } from "react";
1+
import React, { useEffect, useMemo, useState } from "react";
22
import { Alert, Button, DatePicker, Select, Table, Tag, Tooltip } from "antd";
33
import dayjs, { Dayjs } from "dayjs";
44
import utc from "dayjs/plugin/utc";
@@ -7,6 +7,7 @@ import { rpc } from "juava";
77
import { useQuery } from "@tanstack/react-query";
88
import { useDebounce } from "use-debounce";
99
import Link from "next/link";
10+
import { useRouter } from "next/router";
1011
import { AuditLogDiff } from "../AuditLogDiff/AuditLogDiff";
1112
import { originFromAuth } from "../../lib/schema";
1213
import { FaTerminal } from "react-icons/fa";
@@ -251,6 +252,66 @@ export const AuditLog: React.FC<AuditLogProps> = ({ workspaceId, workspaceSlug,
251252
const [wsFilter, setWsFilter] = useState<{ value: string; label: string } | undefined>(undefined);
252253
const [wsSearch, setWsSearch] = useState("");
253254
const [debouncedWsSearch] = useDebounce(wsSearch, 300);
255+
256+
// ── URL ⇄ filter sync ──────────────────────────────────────────────────
257+
// Persist every filter in the query string so a filtered view is shareable
258+
// and survives reload. We hydrate state once `router.query` is ready, then
259+
// mirror state back to the URL on every change via a shallow `replace` (no
260+
// history spam). The `hydrated` gate is state, not a ref: the write-back
261+
// effect must not fire until the restored values are committed, otherwise its
262+
// first run would serialize the still-empty state and wipe the incoming
263+
// params. Non-filter params (e.g. the `[workspaceId]` route segment) are left
264+
// untouched. Pagination (`cursor`) is intentionally not persisted.
265+
const router = useRouter();
266+
const [hydrated, setHydrated] = useState(false);
267+
268+
useEffect(() => {
269+
if (!router.isReady || hydrated) return;
270+
const q = router.query;
271+
const csv = (v: string | string[] | undefined) => (typeof v === "string" && v ? v.split(",").filter(Boolean) : []);
272+
const t = csv(q.type);
273+
const s = csv(q.severity);
274+
const o = csv(q.origin);
275+
if (t.length) setTypes(t);
276+
if (s.length) setSeverities(s);
277+
if (o.length) setOrigins(o);
278+
// Guard against malformed URL params: dayjs() happily builds an invalid
279+
// object from garbage, which would later serialize back as "Invalid Date"
280+
// and poison both state and the RPC request. Drop anything that isn't valid.
281+
const parseDate = (v: string | string[] | undefined) => {
282+
if (typeof v !== "string" || !v) return null;
283+
const d = dayjs(v);
284+
return d.isValid() ? d : null;
285+
};
286+
const from = parseDate(q.from);
287+
const to = parseDate(q.to);
288+
if (from || to) setRange([from, to]);
289+
if (adminView && typeof q.ws === "string" && q.ws) {
290+
// Carry the label in the URL too so the chip renders a name without an
291+
// extra lookup (the workspace search is keyed by name, not id).
292+
setWsFilter({ value: q.ws, label: typeof q.wsName === "string" && q.wsName ? q.wsName : q.ws });
293+
}
294+
setHydrated(true);
295+
// eslint-disable-next-line react-hooks/exhaustive-deps
296+
}, [router.isReady, hydrated]);
297+
298+
useEffect(() => {
299+
if (!hydrated) return;
300+
const managed = ["type", "severity", "origin", "from", "to", "ws", "wsName"];
301+
const query: Record<string, any> = { ...router.query };
302+
for (const k of managed) delete query[k];
303+
if (types.length) query.type = types.join(",");
304+
if (severities.length) query.severity = severities.join(",");
305+
if (origins.length) query.origin = origins.join(",");
306+
if (range?.[0]) query.from = range[0].toISOString();
307+
if (range?.[1]) query.to = range[1].toISOString();
308+
if (adminView && wsFilter) {
309+
query.ws = wsFilter.value;
310+
query.wsName = wsFilter.label;
311+
}
312+
router.replace({ pathname: router.pathname, query }, undefined, { shallow: true });
313+
// eslint-disable-next-line react-hooks/exhaustive-deps
314+
}, [hydrated, types, severities, origins, range, wsFilter]);
254315
const wsQuery = useQuery(
255316
["audit-log-workspace-options", debouncedWsSearch],
256317
async () => {

0 commit comments

Comments
 (0)