From e30631a060072140cd4710227b14fa42f8a21607 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:26:46 +0100 Subject: [PATCH] feat(admin): one Activity surface with real filters and export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Audit tab with a merged Activity view over all three journals. The old table rendered four columns -- event, user, IP, when -- and dropped everything else the row carried. auth_events captures a metadata object and a user agent on every insert, and neither was ever shown, so "Admin revoked · user-42" could not tell you which admin. Rows now expand into a detail panel carrying the actor, the affected user, the outcome, the user agent and every metadata field. Filters were an event box whose placeholder was the only hint that denied logins are spelled "oidc_login_denied", plus a user-id box. The backend supported a time window that the UI never sent. Now: a category facet, an event picker fed by the instance's own catalogue, quick time ranges and a search that reaches into the detail payload. Export buttons pull the filtered feed down as CSV or NDJSON. The deep link from Overview (?event=oidc_login_denied) still lands pre- filtered, and the route stays /admin/audit so existing bookmarks work. Drops the two single-journal service readers the merged feed supersedes -- getDeviceAudit had shipped months ago and never had a caller. The /api/admin/audit endpoint stays for API consumers. TableCell/TableHeader gain colSpan so the detail row can span the table instead of a second table being invented for it. --- frontend/src/admin/Activity.tsx | 434 ++++++++++++++++++++++ frontend/src/admin/AdminUI.test.ts | 72 ++++ frontend/src/admin/AdminUI.tsx | 94 ++++- frontend/src/admin/Audit.tsx | 202 ---------- frontend/src/admin/index.tsx | 4 +- frontend/src/api/endpoints.ts | 5 +- frontend/src/api/services/adminService.ts | 49 ++- frontend/src/components/ui/table.tsx | 6 + frontend/src/locale/en.json | 2 +- 9 files changed, 633 insertions(+), 235 deletions(-) create mode 100644 frontend/src/admin/Activity.tsx create mode 100644 frontend/src/admin/AdminUI.test.ts delete mode 100644 frontend/src/admin/Audit.tsx diff --git a/frontend/src/admin/Activity.tsx b/frontend/src/admin/Activity.tsx new file mode 100644 index 00000000..0cb4ce64 --- /dev/null +++ b/frontend/src/admin/Activity.tsx @@ -0,0 +1,434 @@ +import { ChevronDown, ChevronRight, Download } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useSearchParams } from 'react-router-dom'; + +import adminService, { + type ActivityFilters, +} from '../api/services/adminService'; +import { Button } from '../components/ui/button'; +import { Input } from '../components/ui/input'; +import { MultiSelect } from '../components/ui/multi-select'; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from '../components/ui/table'; +import { selectToken } from '../preferences/preferenceSlice'; +import { + Loading, + Pill, + categoryTone, + eventLabel, + eventTone, + fmtDate, + fmtNumber, + fmtRelative, + isLoopback, + outcomeTone, +} from './AdminUI'; + +type ActivityRow = { + feed: string; + id: string; + event: string; + category: string; + actor_id: string | null; + target_id: string | null; + ip: string | null; + user_agent: string | null; + outcome: string | null; + detail: Record | null; + created_at: string | null; +}; + +type Catalogue = { + events: { event: string; category: string }[]; + categories: string[]; +}; + +const PAGE_SIZE = 50; + +/** Quick ranges, in days. `0` means "no lower bound". */ +const RANGES: { label: string; days: number }[] = [ + { label: '24h', days: 1 }, + { label: '7d', days: 7 }, + { label: '30d', days: 30 }, + { label: '90d', days: 90 }, + { label: 'All', days: 0 }, +]; + +function sinceFor(days: number): string | undefined { + if (days <= 0) return undefined; + return new Date(Date.now() - days * 86400000).toISOString(); +} + +/** One detail value, rendered as a readable line rather than raw JSON. */ +function DetailRow({ label, value }: { label: string; value: unknown }) { + const text = + typeof value === 'object' && value !== null + ? JSON.stringify(value) + : String(value); + return ( +
+ {label} + {text} +
+ ); +} + +/** + * The expanded row. auth_events captures `metadata` and `user_agent` on every + * insert and the table showed neither, so "Admin revoked · user-42" never said + * who revoked it. Everything the feed carries lands here. + */ +function RowDetail({ row }: { row: ActivityRow }) { + const detail = row.detail ?? {}; + const entries = Object.entries(detail); + return ( +
+ + + {row.actor_id ? : null} + {row.target_id ? ( + + ) : null} + {row.outcome ? : null} + {row.ip ? : null} + {row.user_agent ? ( + + ) : null} + + {entries.length > 0 ? ( +
+ {entries.map(([key, value]) => ( + + ))} +
+ ) : null} +
+ ); +} + +export default function Activity() { + const token = useSelector(selectToken); + const [searchParams] = useSearchParams(); + + // Deep links from Overview land here pre-filtered (?event=oidc_login_denied). + const initialEvent = searchParams.get('event'); + + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(null); + const [exporting, setExporting] = useState(false); + const [catalogue, setCatalogue] = useState(null); + + const [categories, setCategories] = useState([]); + const [events, setEvents] = useState( + initialEvent ? [initialEvent] : [], + ); + const [rangeDays, setRangeDays] = useState(30); + const [searchDraft, setSearchDraft] = useState(''); + const [search, setSearch] = useState(''); + + const filters: ActivityFilters = useMemo( + () => ({ + category: categories.length ? categories : undefined, + event: events.length ? events : undefined, + since: sinceFor(rangeDays), + search: search || undefined, + }), + [categories, events, rangeDays, search], + ); + + useEffect(() => { + let cancelled = false; + adminService + .getActivityEvents(token) + .then((res) => res.json()) + .then((json) => { + if (!cancelled && json?.success) setCatalogue(json); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [token]); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await adminService.getActivity( + { ...filters, page, page_size: PAGE_SIZE }, + token, + ); + const json = await res.json().catch(() => ({})); + setRows(json.activity ?? []); + setTotal(json.total ?? 0); + } finally { + setLoading(false); + } + }, [token, page, filters]); + + useEffect(() => { + load(); + }, [load]); + + // Any filter change invalidates the current page number. + useEffect(() => { + setPage(1); + }, [filters]); + + const exportAs = async (format: 'csv' | 'ndjson') => { + setExporting(true); + try { + const res = await adminService.exportActivity(filters, format, token); + if (!res.ok) return; + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `docsgpt-activity.${format}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } finally { + setExporting(false); + } + }; + + const eventOptions = useMemo( + () => + (catalogue?.events ?? []).map(({ event }) => ({ + value: event, + label: eventLabel(event), + })), + [catalogue], + ); + const categoryOptions = useMemo( + () => + (catalogue?.categories ?? []).map((category) => ({ + value: category, + label: category.charAt(0).toUpperCase() + category.slice(1), + })), + [catalogue], + ); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const hasFilters = + categories.length > 0 || events.length > 0 || search !== ''; + + const applySearch = () => setSearch(searchDraft.trim()); + + return ( +
+
+ setSearchDraft(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && applySearch()} + onBlur={applySearch} + className="max-w-xs" + /> + + +
+ {RANGES.map((range) => ( + + ))} +
+ {hasFilters ? ( + + ) : null} +
+ + +
+
+ + {loading ? ( + + ) : rows.length === 0 ? ( +

No activity.

+ ) : ( + <> + + + + + + Event + Category + Actor + Affected + IP + When + + + + {rows.map((row, idx) => { + const key = `${row.feed}:${row.id}`; + const isOpen = expanded === key; + // De-emphasize an actor repeated from the row above (ditto), + // so distinct actors stand out in a long single-user stream. + const repeat = + idx > 0 && rows[idx - 1].actor_id === row.actor_id; + return [ + setExpanded(isOpen ? null : key)} + > + + {isOpen ? ( + + ) : ( + + )} + + +
+ + {eventLabel(row.event)} + + {row.outcome ? ( + + {row.outcome} + + ) : null} +
+
+ + + {row.category} + + + + {repeat ? '〃' : (row.actor_id ?? '—')} + + + {row.target_id && row.target_id !== row.actor_id + ? row.target_id + : '—'} + + + {row.ip === null ? ( + + — + + ) : isLoopback(row.ip) ? ( + + local + + ) : ( + + {row.ip} + + )} + + + + {fmtRelative(row.created_at)} + + +
, + isOpen ? ( + + + + + + ) : null, + ]; + })} +
+
+
+
+

+ {fmtNumber(total)} events · page {page} of {totalPages} +

+
+ + +
+
+ + )} +
+ ); +} diff --git a/frontend/src/admin/AdminUI.test.ts b/frontend/src/admin/AdminUI.test.ts new file mode 100644 index 00000000..03a14186 --- /dev/null +++ b/frontend/src/admin/AdminUI.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { categoryTone, eventLabel, eventTone, outcomeTone } from './AdminUI'; + +describe('eventLabel', () => { + it('humanizes the events it knows', () => { + expect(eventLabel('oidc_login_denied')).toBe('Login denied'); + expect(eventLabel('source.deleted')).toBe('Source deleted'); + expect(eventLabel('device.run_command')).toBe('Device command'); + }); + + it('stays legible for an event a newer release added', () => { + expect(eventLabel('source.archived')).toBe('Source archived'); + expect(eventLabel('brand_new_event')).toBe('Brand new event'); + }); +}); + +describe('eventTone', () => { + it('flags removals and refusals', () => { + expect(eventTone('oidc_login_denied')).toBe('danger'); + expect(eventTone('agent.deleted')).toBe('danger'); + }); + + it('flags revocations as a warning, not a failure', () => { + expect(eventTone('role_revoked')).toBe('warning'); + expect(eventTone('agent.key_regenerated')).toBe('warning'); + }); + + it('marks an admin grant with the brand tone', () => { + expect(eventTone('role_granted')).toBe('brand'); + }); + + it('leaves routine events muted', () => { + expect(eventTone('oidc_login')).toBe('muted'); + expect(eventTone('anything_else')).toBe('muted'); + }); +}); + +describe('categoryTone', () => { + it('has a tone for every category the API declares', () => { + for (const category of [ + 'identity', + 'access', + 'config', + 'data', + 'device', + 'safety', + 'other', + ]) { + expect(categoryTone(category)).toBeTruthy(); + } + }); + + it('falls back for an unknown category', () => { + expect(categoryTone('invented')).toBe('muted'); + }); +}); + +describe('outcomeTone', () => { + it('reads a block or a denial as dangerous', () => { + expect(outcomeTone('blocked')).toBe('danger'); + expect(outcomeTone('denied')).toBe('danger'); + }); + + it('reads an allowed command as a success', () => { + expect(outcomeTone('allowed')).toBe('success'); + }); + + it('leaves an unfamiliar outcome muted', () => { + expect(outcomeTone('pending')).toBe('muted'); + }); +}); diff --git a/frontend/src/admin/AdminUI.tsx b/frontend/src/admin/AdminUI.tsx index 6bc5caeb..df8709de 100644 --- a/frontend/src/admin/AdminUI.tsx +++ b/frontend/src/admin/AdminUI.tsx @@ -104,8 +104,10 @@ export function fmtCompact(n?: number | null): string { }).format(n ?? 0); } -// Humanized auth-event labels + semantic color tier, shared by the Audit feed +// Humanized event labels + semantic color tier, shared by the activity feed // and the user-detail modal so the same event reads identically everywhere. +// Unknown names fall back to a title-cased form of the name itself, so an +// event added by a newer release is still legible here. const EVENT_LABELS: Record = { oidc_login: 'Login', oidc_login_denied: 'Login denied', @@ -121,25 +123,91 @@ const EVENT_LABELS: Record = { scim_reactivated: 'Activated (SCIM)', quota_policy_set: 'Quota set', quota_policy_deleted: 'Quota removed', + pat_created: 'Token created', + pat_revoked: 'Token revoked', + pat_regenerated: 'Token regenerated', + 'team.create': 'Team created', + 'team.delete': 'Team deleted', + 'team.member_add': 'Member added', + 'team.member_role': 'Member role changed', + 'team.member_remove': 'Member removed', + 'team.share': 'Resource shared', + 'team.unshare': 'Resource unshared', + 'team.transfer_owner': 'Ownership transferred', + 'source.created': 'Source created', + 'source.deleted': 'Source deleted', + 'source.reingested': 'Source reingested', + 'agent.created': 'Agent created', + 'agent.updated': 'Agent updated', + 'agent.deleted': 'Agent deleted', + 'agent.key_regenerated': 'Agent key rotated', + 'conversation.deleted': 'Conversation deleted', + 'conversation.deleted_all': 'All conversations deleted', + 'device.run_command': 'Device command', + 'guardrail.input': 'Guardrail (input)', + 'guardrail.retrieval': 'Guardrail (retrieval)', + 'guardrail.tool_result': 'Guardrail (tool result)', + 'guardrail.output': 'Guardrail (output)', }; export function eventLabel(event: string): string { - return ( - EVENT_LABELS[event] ?? - event.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase()) - ); + const known = EVENT_LABELS[event]; + if (known) return known; + // "source.reingested" -> "Source reingested" + return event.replace(/[._]/g, ' ').replace(/^\w/, (c) => c.toUpperCase()); } +// Events that describe something being taken away or refused. +const DANGER_EVENTS = new Set([ + 'oidc_login_denied', + 'admin_user_deactivated', + 'scim_deactivated', + 'source.deleted', + 'agent.deleted', + 'conversation.deleted', + 'conversation.deleted_all', + 'team.delete', +]); + +const WARNING_EVENTS = new Set([ + 'role_revoked', + 'admin_sessions_revoked', + 'pat_revoked', + 'agent.key_regenerated', + 'quota_policy_deleted', + 'team.member_remove', + 'team.unshare', +]); + export function eventTone(event: string): Tone { - if ( - event === 'oidc_login_denied' || - event === 'admin_user_deactivated' || - event === 'scim_deactivated' - ) - return 'danger'; + if (DANGER_EVENTS.has(event)) return 'danger'; if (event === 'role_granted') return 'brand'; - if (event === 'role_revoked' || event === 'admin_sessions_revoked') - return 'warning'; + if (WARNING_EVENTS.has(event)) return 'warning'; + return 'muted'; +} + +// Category facet colors. Mirrors docsgpt/audit_events.py. +const CATEGORY_TONES: Record = { + identity: 'default', + access: 'brand', + config: 'warning', + data: 'success', + device: 'warning', + safety: 'danger', + other: 'muted', +}; + +export function categoryTone(category: string): Tone { + return CATEGORY_TONES[category] ?? 'muted'; +} + +// A guardrail "blocked" or a device "denied" is the row's headline, so it gets +// the same treatment as a dangerous event name. +const DANGER_OUTCOMES = new Set(['blocked', 'denied', 'error', 'failed']); + +export function outcomeTone(outcome: string): Tone { + if (DANGER_OUTCOMES.has(outcome)) return 'danger'; + if (outcome === 'allowed' || outcome === 'passed') return 'success'; return 'muted'; } diff --git a/frontend/src/admin/Audit.tsx b/frontend/src/admin/Audit.tsx deleted file mode 100644 index 02c5e492..00000000 --- a/frontend/src/admin/Audit.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; -import { useSearchParams } from 'react-router-dom'; - -import adminService from '../api/services/adminService'; -import { Button } from '../components/ui/button'; -import { Input } from '../components/ui/input'; -import { - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableHeader, - TableRow, -} from '../components/ui/table'; -import { selectToken } from '../preferences/preferenceSlice'; -import { - Loading, - Pill, - eventLabel, - eventTone, - fmtDate, - fmtNumber, - fmtRelative, - isLoopback, -} from './AdminUI'; - -type Event = { - id: string; - user_id: string; - event: string; - ip?: string; - created_at?: string; -}; - -const PAGE_SIZE = 50; - -export default function Audit() { - const token = useSelector(selectToken); - const [searchParams] = useSearchParams(); - const initialEvent = searchParams.get('event') ?? ''; - const [events, setEvents] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [eventFilter, setEventFilter] = useState(initialEvent); - const [userFilter, setUserFilter] = useState(''); - const [search, setSearch] = useState({ event: initialEvent, user_id: '' }); - const [loading, setLoading] = useState(true); - - const load = useCallback(async () => { - setLoading(true); - try { - const res = await adminService.getAudit( - { - page, - page_size: PAGE_SIZE, - event: search.event || undefined, - user_id: search.user_id || undefined, - }, - token, - ); - const json = await res.json().catch(() => ({})); - setEvents(json.events ?? []); - setTotal(json.total ?? 0); - } finally { - setLoading(false); - } - }, [token, page, search]); - - useEffect(() => { - load(); - }, [load]); - - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - const applyFilters = () => { - setPage(1); - setSearch({ event: eventFilter.trim(), user_id: userFilter.trim() }); - }; - - return ( -
-
- setEventFilter(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && applyFilters()} - className="max-w-xs" - /> - setUserFilter(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && applyFilters()} - className="max-w-xs" - /> - - {(search.event || search.user_id) && ( - - )} -
- - {loading ? ( - - ) : events.length === 0 ? ( -

No events.

- ) : ( - <> - - - - - Event - User - IP - When - - - - {events.map((ev, idx) => { - // De-emphasize a user id repeated from the row above (ditto), - // so distinct users stand out in a long single-user stream. - const repeat = - idx > 0 && events[idx - 1].user_id === ev.user_id; - return ( - - - - {eventLabel(ev.event)} - - - - {repeat ? '〃' : ev.user_id} - - - {isLoopback(ev.ip) ? ( - - local - - ) : ( - - {ev.ip ?? '—'} - - )} - - - - {fmtRelative(ev.created_at)} - - - - ); - })} - -
-
-
-

- {fmtNumber(total)} events · page {page} of {totalPages} -

-
- - -
-
- - )} -
- ); -} diff --git a/frontend/src/admin/index.tsx b/frontend/src/admin/index.tsx index f8ad6157..65ec1c7c 100644 --- a/frontend/src/admin/index.tsx +++ b/frontend/src/admin/index.tsx @@ -5,7 +5,7 @@ import SectionIndexPage from '../navigation/SectionIndexPage'; import { CurrentSectionHeader } from '../navigation/SectionPageHeader'; import { ADMIN_SECTION } from '../navigation/sections'; import Admins from './Admins'; -import Audit from './Audit'; +import Activity from './Activity'; import Overview from './Overview'; import Quotas from './Quotas'; import Usage from './Usage'; @@ -38,7 +38,7 @@ export default function Admin() { } /> } /> } /> - } /> + } /> } /> diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 89187528..ddfc4345 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -172,8 +172,9 @@ const endpoints = { `/api/admin/users/${encodeURIComponent(id)}/revoke-sessions`, ADMINS: '/api/admin/admins', USAGE: '/api/admin/usage', - AUDIT: '/api/admin/audit', - DEVICE_AUDIT: '/api/admin/devices/audit', + ACTIVITY: '/api/admin/activity', + ACTIVITY_EVENTS: '/api/admin/activity/events', + ACTIVITY_EXPORT: '/api/admin/activity/export', QUOTAS: '/api/admin/quotas', QUOTA_INSTANCE: '/api/admin/quotas/instance', QUOTA_TEAM: (id: string) => diff --git a/frontend/src/api/services/adminService.ts b/frontend/src/api/services/adminService.ts index adf67e71..ad44e97e 100644 --- a/frontend/src/api/services/adminService.ts +++ b/frontend/src/api/services/adminService.ts @@ -1,15 +1,36 @@ import apiClient from '../client'; import endpoints from '../endpoints'; -const qs = (params: Record): string => { +type QueryValue = string | number | string[] | undefined; + +const qs = (params: Record): string => { const search = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== '') search.append(key, String(value)); + if (value === undefined || value === '') return; + // Array facets repeat the key; the API accumulates repeats. + if (Array.isArray(value)) { + value.forEach((item) => item && search.append(key, item)); + return; + } + search.append(key, String(value)); }); const str = search.toString(); return str ? `?${str}` : ''; }; +export type ActivityFilters = { + page?: number; + page_size?: number; + category?: string[]; + event?: string[]; + feed?: string[]; + actor_id?: string; + user_id?: string; + since?: string; + until?: string; + search?: string; +}; + export type QuotaScope = 'instance' | 'team' | 'user'; const quotaUrl = (scope: QuotaScope, subjectId?: string | null): string => { @@ -47,21 +68,19 @@ const adminService = { token: string | null, ): Promise => apiClient.get(`${endpoints.ADMIN.USAGE}${qs(params)}`, token), - getAudit: ( - params: { - page?: number; - page_size?: number; - event?: string; - user_id?: string; - }, + getActivity: (filters: ActivityFilters, token: string | null): Promise => + apiClient.get(`${endpoints.ADMIN.ACTIVITY}${qs(filters)}`, token), + getActivityEvents: (token: string | null): Promise => + apiClient.get(endpoints.ADMIN.ACTIVITY_EVENTS, token), + exportActivity: ( + filters: ActivityFilters, + format: 'csv' | 'ndjson', token: string | null, ): Promise => - apiClient.get(`${endpoints.ADMIN.AUDIT}${qs(params)}`, token), - getDeviceAudit: ( - params: { page?: number; page_size?: number; decision?: string }, - token: string | null, - ): Promise => - apiClient.get(`${endpoints.ADMIN.DEVICE_AUDIT}${qs(params)}`, token), + apiClient.get( + `${endpoints.ADMIN.ACTIVITY_EXPORT}${qs({ ...filters, format })}`, + token, + ), getQuotas: (token: string | null): Promise => apiClient.get(endpoints.ADMIN.QUOTAS, token), getUserQuota: (userId: string, token: string | null): Promise => diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx index 4f3b7c7f..b15cfb6e 100644 --- a/frontend/src/components/ui/table.tsx +++ b/frontend/src/components/ui/table.tsx @@ -32,6 +32,8 @@ interface TableCellProps { minWidth?: string; width?: string; align?: 'left' | 'right' | 'center'; + /** Span several columns, e.g. a detail row under the row it expands. */ + colSpan?: number; } const TableContainer = React.forwardRef( @@ -134,6 +136,7 @@ const TableHeader: React.FC = ({ minWidth, width, align = 'left', + colSpan, }) => { const alignmentClass = align === 'right' @@ -145,6 +148,7 @@ const TableHeader: React.FC = ({ return ( = ({ minWidth, width, align = 'left', + colSpan, }) => { const alignmentClass = align === 'right' @@ -175,6 +180,7 @@ const TableCell: React.FC = ({ return (