feat(admin): add /admin/logs page with logs + rate-limit tabs

This commit is contained in:
root
2026-05-19 08:54:08 +00:00
parent 4ea91f9f53
commit 428c998537
2 changed files with 198 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
import { AdminChrome } from '@/components/admin/AdminChrome'
import { isAdminAuthenticated } from '@/lib/admin/session'
import { redirect } from 'next/navigation'
import { LogsViewer } from '@/components/admin/LogsViewer'
export default async function Page() {
if (!(await isAdminAuthenticated())) redirect('/admin/login')
return (
<AdminChrome>
<LogsViewer />
</AdminChrome>
)
}
+185
View File
@@ -0,0 +1,185 @@
'use client'
import { Fragment, useEffect, useState } from 'react'
type View = 'logs' | 'rate_limit'
interface LogEvent {
ts: string
level: string
msg: string
route?: string
error?: { name: string; message: string; stack?: string }
[k: string]: unknown
}
interface RateLimitRow {
id: number
ts: number
route: string
ip: string
key: string
}
export function LogsViewer() {
const [view, setView] = useState<View>('logs')
const [level, setLevel] = useState<string>('all')
const [autoRefresh, setAutoRefresh] = useState(false)
const [logs, setLogs] = useState<LogEvent[]>([])
const [rl, setRl] = useState<RateLimitRow[]>([])
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
async function fetchData() {
try {
const params = new URLSearchParams({ view, limit: '200' })
if (view === 'logs' && level !== 'all') params.set('level', level)
const res = await fetch(`/api/admin/logs?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (view === 'logs') setLogs(data.rows ?? [])
else setRl(data.rows ?? [])
setError(null)
} catch (e) {
setError((e as Error).message)
}
}
useEffect(() => {
fetchData()
if (!autoRefresh) return
const t = setInterval(fetchData, 10_000)
return () => clearInterval(t)
}, [view, level, autoRefresh])
function toggle(id: string) {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div className="p-6 space-y-4">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold">日志</h1>
<div className="ml-auto flex gap-2">
<button
onClick={() => setView('logs')}
className={`px-3 py-1 rounded text-sm ${view === 'logs' ? 'bg-amber-500 text-white' : 'bg-zinc-700 text-zinc-200'}`}
>日志</button>
<button
onClick={() => setView('rate_limit')}
className={`px-3 py-1 rounded text-sm ${view === 'rate_limit' ? 'bg-amber-500 text-white' : 'bg-zinc-700 text-zinc-200'}`}
>被限流</button>
</div>
</div>
{view === 'logs' && (
<div className="flex gap-2 items-center text-sm">
<span className="text-zinc-400">级别:</span>
{['all', 'warn', 'error'].map((lv) => (
<button
key={lv}
onClick={() => setLevel(lv)}
className={`px-2 py-1 rounded ${level === lv ? 'bg-amber-500 text-white' : 'bg-zinc-800 text-zinc-300'}`}
>{lv}</button>
))}
<label className="ml-auto flex items-center gap-2">
<input type="checkbox" checked={autoRefresh} onChange={(e) => setAutoRefresh(e.target.checked)} />
<span className="text-zinc-400">10s 自动刷新</span>
</label>
<button onClick={fetchData} className="px-3 py-1 rounded bg-zinc-700 text-zinc-200">刷新</button>
</div>
)}
{error && (
<div className="p-3 rounded bg-red-900 text-red-100 text-sm">加载失败:{error}</div>
)}
{view === 'logs' && (
<div className="bg-zinc-900 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-zinc-800 text-zinc-400">
<tr>
<th className="text-left p-2 w-44">时间</th>
<th className="text-left p-2 w-16">级别</th>
<th className="text-left p-2 w-56">route</th>
<th className="text-left p-2">消息</th>
</tr>
</thead>
<tbody>
{logs.length === 0 && (
<tr><td colSpan={4} className="p-6 text-center text-zinc-500">暂无日志</td></tr>
)}
{logs.map((ev, idx) => {
const id = `${ev.ts}-${idx}`
const isOpen = expanded.has(id)
return (
<Fragment key={id}>
<tr
className="border-t border-zinc-800 cursor-pointer hover:bg-zinc-800"
onClick={() => toggle(id)}
>
<td className="p-2 text-zinc-400 font-mono text-xs">{ev.ts.replace('T', ' ').slice(0, 19)}</td>
<td className="p-2">
<span className={`px-2 py-0.5 rounded text-xs ${
ev.level === 'error' ? 'bg-red-900 text-red-100' :
ev.level === 'warn' ? 'bg-amber-900 text-amber-100' :
'bg-zinc-700 text-zinc-200'
}`}>{ev.level}</span>
</td>
<td className="p-2 text-zinc-300 font-mono text-xs">{ev.route ?? '—'}</td>
<td className="p-2 text-zinc-200">{ev.msg}</td>
</tr>
{isOpen && (
<tr className="bg-zinc-950">
<td colSpan={4} className="p-3">
<pre className="text-xs text-zinc-300 whitespace-pre-wrap overflow-x-auto">
{JSON.stringify(ev, null, 2)}
</pre>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
)}
{view === 'rate_limit' && (
<div className="bg-zinc-900 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-zinc-800 text-zinc-400">
<tr>
<th className="text-left p-2 w-44">时间</th>
<th className="text-left p-2 w-56">route</th>
<th className="text-left p-2 w-40">IP</th>
<th className="text-left p-2">key</th>
</tr>
</thead>
<tbody>
{rl.length === 0 && (
<tr><td colSpan={4} className="p-6 text-center text-zinc-500">最近没有被限流的请求</td></tr>
)}
{rl.map((row) => (
<tr key={row.id} className="border-t border-zinc-800">
<td className="p-2 text-zinc-400 font-mono text-xs">
{new Date(row.ts).toISOString().replace('T', ' ').slice(0, 19)}
</td>
<td className="p-2 text-zinc-300 font-mono text-xs">{row.route}</td>
<td className="p-2 text-zinc-300 font-mono text-xs">{row.ip}</td>
<td className="p-2 text-zinc-300 font-mono text-xs">{row.key}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}