121 lines
4.3 KiB
TypeScript
121 lines
4.3 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useEffect, useState } from 'react';
|
||
|
|
import { apiFetch } from '@/lib/api';
|
||
|
|
import { Input } from '@/components/input';
|
||
|
|
|
||
|
|
interface AuditEntry {
|
||
|
|
id: string;
|
||
|
|
action: string;
|
||
|
|
resourceType: string | null;
|
||
|
|
resourceId: string | null;
|
||
|
|
metadata: Record<string, unknown> | null;
|
||
|
|
ipAddress: string | null;
|
||
|
|
userId: string | null;
|
||
|
|
createdAt: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
const ACTION_FILTERS = [
|
||
|
|
{ value: '', label: 'All actions' },
|
||
|
|
{ value: 'auth.login', label: 'Logins' },
|
||
|
|
{ value: 'auth.logout', label: 'Logouts' },
|
||
|
|
{ value: 'server.create', label: 'Server created' },
|
||
|
|
{ value: 'server.iterate', label: 'Server iterated' },
|
||
|
|
{ value: 'server.delete', label: 'Server deleted' },
|
||
|
|
];
|
||
|
|
|
||
|
|
export default function AuditPage() {
|
||
|
|
const [entries, setEntries] = useState<AuditEntry[] | null>(null);
|
||
|
|
const [action, setAction] = useState('');
|
||
|
|
const [search, setSearch] = useState('');
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const q = action ? `?action=${encodeURIComponent(action)}` : '';
|
||
|
|
apiFetch<{ entries: AuditEntry[] }>(`/v1/audit${q}`).then((r) => setEntries(r.entries));
|
||
|
|
}, [action]);
|
||
|
|
|
||
|
|
const visible = entries?.filter((e) =>
|
||
|
|
search
|
||
|
|
? e.action.includes(search) ||
|
||
|
|
e.resourceId?.includes(search) ||
|
||
|
|
e.ipAddress?.includes(search)
|
||
|
|
: true,
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="mx-auto max-w-7xl px-6 py-8">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-[22px] font-semibold tracking-tight">Audit log</h1>
|
||
|
|
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||
|
|
Every privileged action in your workspace, with IP and metadata.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="mt-6 flex flex-wrap gap-2">
|
||
|
|
<select
|
||
|
|
value={action}
|
||
|
|
onChange={(e) => setAction(e.target.value)}
|
||
|
|
className="h-8 rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2 text-[13px] focus:border-[--color-accent] focus:outline-none"
|
||
|
|
>
|
||
|
|
{ACTION_FILTERS.map((f) => (
|
||
|
|
<option key={f.value} value={f.value}>
|
||
|
|
{f.label}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
<Input
|
||
|
|
value={search}
|
||
|
|
onChange={(e) => setSearch(e.target.value)}
|
||
|
|
placeholder="Filter by resource id or ip…"
|
||
|
|
className="w-72"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="panel mt-4">
|
||
|
|
{!visible && (
|
||
|
|
<div className="px-4 py-4 text-[12.5px] text-[--color-fg-muted]">Loading…</div>
|
||
|
|
)}
|
||
|
|
{visible && visible.length === 0 && (
|
||
|
|
<div className="px-4 py-12 text-center text-[13px] text-[--color-fg-muted]">
|
||
|
|
No matching entries.
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{visible && visible.length > 0 && (
|
||
|
|
<table className="w-full text-[12px]">
|
||
|
|
<thead className="border-b border-[--color-border] text-[--color-fg-subtle]">
|
||
|
|
<tr>
|
||
|
|
<th className="px-4 py-2 text-left font-medium">When</th>
|
||
|
|
<th className="px-4 py-2 text-left font-medium">Action</th>
|
||
|
|
<th className="px-4 py-2 text-left font-medium">Resource</th>
|
||
|
|
<th className="px-4 py-2 text-left font-medium">IP</th>
|
||
|
|
<th className="px-4 py-2 text-left font-medium">Metadata</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{visible.map((e) => (
|
||
|
|
<tr key={e.id} className="border-b border-[--color-border] last:border-0">
|
||
|
|
<td className="px-4 py-2 mono text-[--color-fg-muted]">
|
||
|
|
{new Date(e.createdAt).toLocaleString()}
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-2">
|
||
|
|
<span className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-2 py-0.5 text-[11px]">
|
||
|
|
{e.action}
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-2 mono text-[--color-fg-muted]">
|
||
|
|
{e.resourceType ? `${e.resourceType}/${e.resourceId?.slice(0, 8) ?? '—'}` : '—'}
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-2 mono text-[--color-fg-muted]">{e.ipAddress ?? '—'}</td>
|
||
|
|
<td className="px-4 py-2 mono text-[10.5px] text-[--color-fg-subtle]">
|
||
|
|
{e.metadata ? JSON.stringify(e.metadata) : '—'}
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|