mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 22:14:05 +00:00
feat(admin): one Activity surface with real filters and export
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.
This commit is contained in:
1 parent
92c05089c7
commit
e30631a060
9 files changed
+633
-235
No files matched your search
@@ -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<string, unknown> | 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 (
|
||||
<div className="flex gap-2 text-[13px] break-all">
|
||||
<span className="text-muted-foreground min-w-32 shrink-0">{label}</span>
|
||||
<span className="font-mono">{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="bg-muted/40 space-y-1 rounded-lg px-4 py-3">
|
||||
<DetailRow label="Event" value={row.event} />
|
||||
<DetailRow label="Journal" value={row.feed} />
|
||||
{row.actor_id ? <DetailRow label="Actor" value={row.actor_id} /> : null}
|
||||
{row.target_id ? (
|
||||
<DetailRow label="Affected user" value={row.target_id} />
|
||||
) : null}
|
||||
{row.outcome ? <DetailRow label="Outcome" value={row.outcome} /> : null}
|
||||
{row.ip ? <DetailRow label="IP" value={row.ip} /> : null}
|
||||
{row.user_agent ? (
|
||||
<DetailRow label="User agent" value={row.user_agent} />
|
||||
) : null}
|
||||
<DetailRow label="Recorded" value={fmtDate(row.created_at)} />
|
||||
{entries.length > 0 ? (
|
||||
<div className="border-border mt-2 space-y-1 border-t pt-2">
|
||||
{entries.map(([key, value]) => (
|
||||
<DetailRow key={key} label={key} value={value} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<ActivityRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [catalogue, setCatalogue] = useState<Catalogue | null>(null);
|
||||
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [events, setEvents] = useState<string[]>(
|
||||
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 (
|
||||
<div className="mt-6">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search user, IP or detail"
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applySearch()}
|
||||
onBlur={applySearch}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<MultiSelect
|
||||
options={categoryOptions}
|
||||
selected={categories}
|
||||
onChange={setCategories}
|
||||
placeholder="All categories"
|
||||
className="w-48"
|
||||
/>
|
||||
<MultiSelect
|
||||
options={eventOptions}
|
||||
selected={events}
|
||||
onChange={setEvents}
|
||||
placeholder="All events"
|
||||
searchPlaceholder="Find an event"
|
||||
className="w-56"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{RANGES.map((range) => (
|
||||
<Button
|
||||
key={range.label}
|
||||
variant={range.days === rangeDays ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-3xl"
|
||||
onClick={() => setRangeDays(range.days)}
|
||||
>
|
||||
{range.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCategories([]);
|
||||
setEvents([]);
|
||||
setSearchDraft('');
|
||||
setSearch('');
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={exporting}
|
||||
onClick={() => exportAs('csv')}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={exporting}
|
||||
onClick={() => exportAs('ndjson')}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
NDJSON
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-muted-foreground mt-8 text-sm">No activity.</p>
|
||||
) : (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader className="w-8" />
|
||||
<TableHeader>Event</TableHeader>
|
||||
<TableHeader>Category</TableHeader>
|
||||
<TableHeader>Actor</TableHeader>
|
||||
<TableHeader>Affected</TableHeader>
|
||||
<TableHeader>IP</TableHeader>
|
||||
<TableHeader>When</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{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 [
|
||||
<TableRow
|
||||
key={key}
|
||||
className="hover:bg-muted/40 cursor-pointer"
|
||||
onClick={() => setExpanded(isOpen ? null : key)}
|
||||
>
|
||||
<TableCell>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="text-muted-foreground size-4" />
|
||||
) : (
|
||||
<ChevronRight className="text-muted-foreground size-4" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill tone={eventTone(row.event)}>
|
||||
{eventLabel(row.event)}
|
||||
</Pill>
|
||||
{row.outcome ? (
|
||||
<Pill tone={outcomeTone(row.outcome)}>
|
||||
{row.outcome}
|
||||
</Pill>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Pill tone={categoryTone(row.category)}>
|
||||
{row.category}
|
||||
</Pill>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`font-mono text-[13px] ${
|
||||
repeat ? 'text-muted-foreground/50' : ''
|
||||
}`}
|
||||
>
|
||||
{repeat ? '〃' : (row.actor_id ?? '—')}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[13px]">
|
||||
{row.target_id && row.target_id !== row.actor_id
|
||||
? row.target_id
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{row.ip === null ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
—
|
||||
</span>
|
||||
) : isLoopback(row.ip) ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
local
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-[13px]">
|
||||
{row.ip}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground whitespace-nowrap">
|
||||
<span title={fmtDate(row.created_at)}>
|
||||
{fmtRelative(row.created_at)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>,
|
||||
isOpen ? (
|
||||
<TableRow key={`${key}:detail`}>
|
||||
<TableCell colSpan={7} className="py-2">
|
||||
<RowDetail row={row} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null,
|
||||
];
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{fmtNumber(total)} events · page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
oidc_login: 'Login',
|
||||
oidc_login_denied: 'Login denied',
|
||||
@@ -121,25 +123,91 @@ const EVENT_LABELS: Record<string, string> = {
|
||||
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<string, Tone> = {
|
||||
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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Event[]>([]);
|
||||
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 (
|
||||
<div className="mt-6">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Event (e.g. oidc_login_denied)"
|
||||
value={eventFilter}
|
||||
onChange={(e) => setEventFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="User id"
|
||||
value={userFilter}
|
||||
onChange={(e) => setUserFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={applyFilters}>
|
||||
Filter
|
||||
</Button>
|
||||
{(search.event || search.user_id) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEventFilter('');
|
||||
setUserFilter('');
|
||||
setPage(1);
|
||||
setSearch({ event: '', user_id: '' });
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : events.length === 0 ? (
|
||||
<p className="text-muted-foreground mt-8 text-sm">No events.</p>
|
||||
) : (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Event</TableHeader>
|
||||
<TableHeader>User</TableHeader>
|
||||
<TableHeader>IP</TableHeader>
|
||||
<TableHeader>When</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{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 (
|
||||
<TableRow key={ev.id}>
|
||||
<TableCell>
|
||||
<Pill tone={eventTone(ev.event)}>
|
||||
{eventLabel(ev.event)}
|
||||
</Pill>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`font-mono text-[13px] ${
|
||||
repeat ? 'text-muted-foreground/50' : ''
|
||||
}`}
|
||||
>
|
||||
{repeat ? '〃' : ev.user_id}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{isLoopback(ev.ip) ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
local
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-[13px]">
|
||||
{ev.ip ?? '—'}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground whitespace-nowrap">
|
||||
<span title={fmtDate(ev.created_at)}>
|
||||
{fmtRelative(ev.created_at)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{fmtNumber(total)} events · page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<Route path="roles" element={<Admins />} />
|
||||
<Route path="usage" element={<Usage />} />
|
||||
<Route path="quotas" element={<Quotas />} />
|
||||
<Route path="audit" element={<Audit />} />
|
||||
<Route path="audit" element={<Activity />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Routes>
|
||||
</>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -1,15 +1,36 @@
|
||||
import apiClient from '../client';
|
||||
import endpoints from '../endpoints';
|
||||
|
||||
const qs = (params: Record<string, string | number | undefined>): string => {
|
||||
type QueryValue = string | number | string[] | undefined;
|
||||
|
||||
const qs = (params: Record<string, QueryValue>): 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<any> =>
|
||||
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<any> =>
|
||||
apiClient.get(`${endpoints.ADMIN.ACTIVITY}${qs(filters)}`, token),
|
||||
getActivityEvents: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.ADMIN.ACTIVITY_EVENTS, token),
|
||||
exportActivity: (
|
||||
filters: ActivityFilters,
|
||||
format: 'csv' | 'ndjson',
|
||||
token: string | null,
|
||||
): Promise<any> =>
|
||||
apiClient.get(`${endpoints.ADMIN.AUDIT}${qs(params)}`, token),
|
||||
getDeviceAudit: (
|
||||
params: { page?: number; page_size?: number; decision?: string },
|
||||
token: string | null,
|
||||
): Promise<any> =>
|
||||
apiClient.get(`${endpoints.ADMIN.DEVICE_AUDIT}${qs(params)}`, token),
|
||||
apiClient.get(
|
||||
`${endpoints.ADMIN.ACTIVITY_EXPORT}${qs({ ...filters, format })}`,
|
||||
token,
|
||||
),
|
||||
getQuotas: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.ADMIN.QUOTAS, token),
|
||||
getUserQuota: (userId: string, token: string | null): Promise<any> =>
|
||||
|
||||
@@ -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<HTMLDivElement, TableContainerProps>(
|
||||
@@ -134,6 +136,7 @@ const TableHeader: React.FC<TableCellProps> = ({
|
||||
minWidth,
|
||||
width,
|
||||
align = 'left',
|
||||
colSpan,
|
||||
}) => {
|
||||
const alignmentClass =
|
||||
align === 'right'
|
||||
@@ -145,6 +148,7 @@ const TableHeader: React.FC<TableCellProps> = ({
|
||||
return (
|
||||
<th
|
||||
data-slot="table-header"
|
||||
colSpan={colSpan}
|
||||
className={cn(
|
||||
'border-border text-muted-foreground relative box-border border-b px-2 py-3 text-sm font-medium lg:px-3',
|
||||
alignmentClass,
|
||||
@@ -164,6 +168,7 @@ const TableCell: React.FC<TableCellProps> = ({
|
||||
minWidth,
|
||||
width,
|
||||
align = 'left',
|
||||
colSpan,
|
||||
}) => {
|
||||
const alignmentClass =
|
||||
align === 'right'
|
||||
@@ -175,6 +180,7 @@ const TableCell: React.FC<TableCellProps> = ({
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
colSpan={colSpan}
|
||||
className={cn(
|
||||
'box-border px-2 py-2 text-sm lg:px-3',
|
||||
alignmentClass,
|
||||
|
||||
@@ -1691,7 +1691,7 @@
|
||||
"admins": "Admins",
|
||||
"usage": "Usage",
|
||||
"quotas": "Quotas",
|
||||
"audit": "Audit"
|
||||
"audit": "Activity"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user