generated from haoqimao/MatchU--
feat(admin): add /admin/logs page with logs + rate-limit tabs
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user