feat: audit log of admin writes, sign-ins and key creation, with an audit tab [CI]

This commit is contained in:
justme committed 2026-10-07 22:17:52 +08:00
1 parent 359313a57b
commit 1379b8c7cb
21 files changed
+579 -7

No files matched your search

+53 -3
View File
@@ -1,7 +1,7 @@
import { Download, KeyRound, Plus, Trash2 } from 'lucide-react'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { api } from '@shared/api/client'
import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, AuditDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo'
import { fmtNum, fmtTime } from '@shared/format'
import { translateSetting } from '@shared/i18n'
@@ -10,7 +10,7 @@ import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented
import { CapabilityPicker, capabilityName } from './CapabilityPicker'
import { PasskeyAddForm } from './PasskeyAdd'
type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis'
type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'audit' | 'events' | 'raw' | 'analysis'
const RESTART_KEYS = ['fleet.restart_enabled', 'fleet.restart_hour', 'fleet.restart_minute', 'fleet.restart_utc_offset_minutes']
@@ -636,6 +636,55 @@ function Users() {
)
}
/** 审计:谁在什么时候做了什么。写操作、登录成败都在里面,保留一年。 */
function Audit() {
const [actor, setActor] = useState('')
const [action, setAction] = useState('')
const audit = useQuery(() => api.get<Page<AuditDto>>('/api/admin/audit', { actor: actor || undefined, action: action || undefined, limit: 100 }), [actor, action], 10_000)
return (
<div className="card">
<div className="row wrap" style={{ marginBottom: 12 }}>
<input className="input" style={{ width: 200 }} placeholder="按操作者筛选,例如 admin" value={actor} onChange={(e) => setActor(e.target.value)} />
<input className="input" style={{ width: 240 }} placeholder="按操作筛选,例如 auth.login" value={action} onChange={(e) => setAction(e.target.value)} />
<span className="right">{audit.loading && !audit.data ? <Spinner /> : null}</span>
</div>
<ErrorNotice error={audit.error} />
{(audit.data?.items ?? []).length === 0 ? (
<Empty>没有记录</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>时间</th>
<th>操作者</th>
<th>方式</th>
<th>操作</th>
<th>对象 / 细节</th>
<th>来源</th>
<th>结果</th>
</tr>
</thead>
<tbody>
{audit.data?.items.map((a) => (
<tr key={a.id}>
<td className="muted num">{fmtTime(a.at)}</td>
<td>{a.actor}</td>
<td className="muted">{a.method === 'key' ? 'API 令牌' : a.method === 'passkey' ? 'Passkey' : '密码'}</td>
<td className="mono">{a.action}</td>
<td className="mono muted">{[a.target, a.detail].filter(Boolean).join(' · ')}</td>
<td className="mono faint">{a.remote ?? ''}</td>
<td>
<Chip tone={a.success ? 'ok' : 'bad'}>{a.success ? '成功' : '失败'}</Chip>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
function Events() {
const [kind, setKind] = useState('')
const events = useQuery(() => api.get<Page<EventDto>>('/api/admin/events', { kind: kind || undefined, limit: 100 }), [kind], 10_000)
@@ -783,6 +832,7 @@ export function System() {
{ value: 'versions' as const, label: '版本' },
{ value: 'tokens' as const, label: 'API 令牌' },
{ value: 'users' as const, label: '用户' },
{ value: 'audit' as const, label: '审计' },
{ value: 'events' as const, label: '事件' },
{ value: 'raw' as const, label: '原始数据' },
{ value: 'analysis' as const, label: '分析' },
@@ -790,7 +840,7 @@ export function System() {
value={tab}
onChange={setTab}
/>
{tab === 'settings' ? <Settings /> : tab === 'versions' ? <Versions /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
{tab === 'settings' ? <Settings /> : tab === 'versions' ? <Versions /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'audit' ? <Audit /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
</div>
</>
)