monitor-ai/src/app/admin/audit-logs/page.tsx

122 lines
6.5 KiB
TypeScript

'use client'
// src/app/admin/audit-logs/page.tsx — 审计日志
import { useState, useEffect, useCallback } from 'react'
import { RotateCw } from 'lucide-react'
interface AuditLog {
id: number; username: string | null; action: string; entity_type: string
details: string | null; ip_address: string | null; created_at: string
}
export default function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [action, setAction] = useState('')
const [entityType, setEntityType] = useState('')
const [loading, setLoading] = useState(true)
const pageSize = 20
const load = useCallback(async () => {
setLoading(true)
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
if (action) params.set('action', action)
if (entityType) params.set('entity_type', entityType)
const res = await fetch(`/api/admin/audit-logs?${params}`)
if (res.ok) {
const data = await res.json()
setLogs(data.data)
setTotal(data.total)
}
setLoading(false)
}, [page, action, entityType])
useEffect(() => { load() }, [load])
const totalPages = Math.ceil(total / pageSize)
const renderDetails = (details: string | null) => {
if (!details) return '—'
try { return JSON.stringify(JSON.parse(details)).slice(0, 80) }
catch { return details.slice(0, 80) }
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]"></h1>
<p className="text-sm text-slate-400 mt-1"></p>
</div>
<div className="flex gap-4 flex-wrap">
<select value={action} onChange={e => { setAction(e.target.value); setPage(1) }}
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm">
<option value=""></option>
<option value="login"></option><option value="login_failed"></option><option value="logout"></option>
<option value="create_service"></option><option value="update_service"></option><option value="delete_service"></option>
<option value="create_channel"></option><option value="update_channel"></option><option value="delete_channel"></option>
<option value="update_user"></option><option value="update_role"></option>
</select>
<select value={entityType} onChange={e => { setEntityType(e.target.value); setPage(1) }}
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm">
<option value=""></option>
<option value="auth"></option><option value="service"></option><option value="alert_channel"></option>
<option value="user"></option><option value="role"></option>
</select>
<button onClick={load} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-2">
<RotateCw size={14} />
</button>
</div>
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700">
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">IP</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-slate-400">...</td></tr>
) : logs.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-slate-400"></td></tr>
) : logs.map(log => (
<tr key={log.id} className="border-b border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-400 whitespace-nowrap">{log.created_at}</td>
<td className="px-4 py-3 text-sm">{log.username || '—'}</td>
<td className="px-4 py-3 text-sm">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
log.action.includes('login') ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' :
log.action.includes('failed') ? 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400' :
log.action.includes('create') ? 'bg-green-100 text-green-700 dark:bg-green-500/10 dark:text-green-400' :
log.action.includes('delete') ? 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400' :
'bg-slate-100 text-slate-700 dark:bg-slate-500/10 dark:text-slate-400'
}`}>{log.action}</span>
</td>
<td className="px-4 py-3 text-sm">{log.entity_type}</td>
<td className="px-4 py-3 text-sm text-slate-500 font-mono text-xs">{renderDetails(log.details)}</td>
<td className="px-4 py-3 text-sm text-slate-400">{log.ip_address || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
className="px-3 py-1.5 text-sm border rounded-lg disabled:opacity-40"></button>
<span className="text-sm text-slate-500"> {page}/{totalPages} ( {total} )</span>
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}
className="px-3 py-1.5 text-sm border rounded-lg disabled:opacity-40"></button>
</div>
)}
</div>
)
}