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:
arc53-machine committed 2026-09-22 10:26:46 +01:00
1 parent 92c05089c7
commit e30631a060
9 files changed
+633 -235

No files matched your search

+434
View File
@@ -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>
);
}
+72
View File
@@ -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');
});
});
+81 -13
View File
@@ -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';
}
-202
View File
@@ -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>
);
}
+2 -2
View File
@@ -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>
</>
+3 -2
View File
@@ -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) =>
+34 -15
View File
@@ -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> =>
+6
View File
@@ -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,
+1 -1
View File
@@ -1691,7 +1691,7 @@
"admins": "Admins",
"usage": "Usage",
"quotas": "Quotas",
"audit": "Audit"
"audit": "Activity"
}
}
}