buildmymcpserver/apps/web/app/(dashboard)/audit/page.tsx

121 lines
4.3 KiB
TypeScript
Raw Normal View History

'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>
);
}