import { Download, KeyRound, Plus, Trash2 } from 'lucide-react' import { useEffect, useState } from 'react' import { api } from '@shared/api/client' import type { AgentDto, ApiTokenDto, BuildInfoDto, CreatedApiTokenDto, 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' import { useQuery } from '@shared/hooks' import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui' type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis' function Settings() { const settings = useQuery(() => api.get>('/api/admin/settings'), []) const { busy, run } = useAction() const [draft, setDraft] = useState>({}) useEffect(() => setDraft({}), [settings.data]) const items = settings.data?.items ?? [] const dirty = Object.keys(draft).length > 0 return (
{items.map((s) => { const value = draft[s.key] ?? s.value return ( ) })}
设置 值 说明
{s.key} {s.type === 'bool' ? ( ) : ( setDraft({ ...draft, [s.key]: e.target.value })} /> )} {translateSetting(s.key, s.description)} {s.value !== s.default ? (默认 {s.default}) : null}
「site.public」打开之后,展示页对匿名访客公开。
) } /** 一行:名字、类型、版本、tag、提交、构建时间、系统。没有的写「—」。 */ function BuildRow({ name, kind, version, tag, commit, builtAt, platform }: { name: string; kind: string; version?: string | null; tag?: string | null; commit?: string | null; builtAt?: string | null; platform?: string }) { const dash = — return ( {name} {kind} {version || dash} {tag || dash} {commit || dash} {fmtBuiltAt(builtAt) || dash} {platform || dash} ) } /** 每个程序的版本:后端、前端、每个 Agent 和 Probe 自己上报的(它们没升级就能看出来)。 */ function Versions() { const info = useQuery(() => api.get('/api/admin/build-info'), [], 15_000) const agents = useQuery(() => api.get>('/api/admin/agents'), [], 30_000) const probes = useQuery(() => api.get>('/api/admin/probes'), [], 30_000) const names = new Map([...(agents.data?.items ?? []).map((a) => [a.id, a.name] as const), ...(probes.data?.items ?? []).map((p) => [p.id, p.name] as const)]) const back = info.data?.backend const nodes = info.data?.nodes ?? [] // 以后端的版本为准,节点的版本不一样就标出来(升级了一半的时候一眼能看到) const mismatch = (v?: string | null) => Boolean(v && back && v !== back.version) return (
{back ? : null} {nodes.map((n) => ( ))}
名称 类型 版本 tag 提交 构建时间 系统
{names.get(n.id) ?? n.id} {n.kind} {n.version ? mismatch(n.version) ? {n.version} : n.version : —} {n.tag || —} {n.commit || —} {fmtBuiltAt(n.built_at) || —} {[n.os_name, n.arch].filter(Boolean).join(' / ') || —}
{nodes.length === 0 && !info.loading ? 还没有节点上报过版本 : null}

版本、tag 和构建时间是各个程序自己上报的。设置里打开「site.show_build」之后,展示页底部和节点卡片上才会给访客显示(提交号只在这里显示)。

) } function Tokens() { const tokens = useQuery(() => api.get>('/api/admin/api-tokens'), []) const { run } = useAction() const [creating, setCreating] = useState(false) const [name, setName] = useState('') const [scope, setScope] = useState<'read' | 'admin'>('read') const [created, setCreated] = useState() const [revoking, setRevoking] = useState() return (
给脚本和其他程序用的令牌:read 只能读展示数据,admin 可以调用全部管理接口。
{(tokens.data?.items ?? []).length === 0 ? ( 没有 API 令牌 ) : ( {tokens.data?.items.map((t) => ( ))}
名称 权限 创建 最后使用
{t.name} {t.scope} {fmtTime(t.created_at)} {t.revoked_at ? 已吊销 : fmtTime(t.last_used_at)} {!t.revoked_at ? ( ) : null}
)} {creating ? ( setCreating(false)} actions={ <> } >
setName(e.target.value)} autoFocus />
) : null} {created ? ( setCreated(undefined)} actions={}>
令牌明文只显示这一次,请现在就复制。
{created.plaintext}
) : null} {revoking ? ( setRevoking(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/api-tokens/${revoking.id}`), '已吊销'); tokens.reload() }}> 使用「{revoking.name}」的程序会立刻失去访问权限。 ) : null}
) } function Users() { const users = useQuery(() => api.get>('/api/admin/users'), []) const { run } = useAction() const [dialog, setDialog] = useState<{ user?: UserDto }>() const [username, setUsername] = useState('') const [password, setPassword] = useState('') return (
能登录管理端的账号。
{users.data?.items.map((u) => ( ))}
用户名 创建
{u.username} {fmtTime(u.created_at)}
{dialog ? ( setDialog(undefined)} actions={ <> } >
{!dialog.user ? ( setUsername(e.target.value)} autoFocus /> ) : null} setPassword(e.target.value)} autoComplete="new-password" />
) : null}
) } function Events() { const [kind, setKind] = useState('') const events = useQuery(() => api.get>('/api/admin/events', { kind: kind || undefined, limit: 100 }), [kind], 10_000) return (
setKind(e.target.value)} /> {events.loading && !events.data ? : null}
{(events.data?.items ?? []).length === 0 ? ( 没有事件 ) : ( {events.data?.items.map((e) => ( ))}
{fmtTime(e.at)} {e.kind} {e.agent_id ?? ''} {e.detail ?? ''}
)}
) } function Raw() { const [kind, setKind] = useState('') const [agent, setAgent] = useState('') const chunks = useQuery(() => api.get>('/api/admin/raw/chunks', { kind: kind || undefined, agent_id: agent || undefined, limit: 50 }), [kind, agent]) return (
setAgent(e.target.value)} /> setKind(e.target.value)} /> 原始块按保留期限保存,可以下载后离线分析。
{(chunks.data?.items ?? []).length === 0 ? ( 没有符合条件的原始块 ) : ( {chunks.data?.items.map((c) => ( ))}
时间 采集方法 序列 时长 大小
{fmtTime(c.start)} {c.kind} {c.partial ? 不完整 : null} {c.series} {fmtNum(c.duration_ms / 1000, 1)} s {fmtNum(c.bytes / 1024, 1)} KiB 下载
)}
) } function Analysis() { const state = useQuery(() => api.get('/api/admin/analysis/recompute'), [], 5000) const health = useQuery(() => api.get<{ status: string; database: boolean; raw_directory: boolean }>('/healthz'), [], 10_000) const d = state.data return (
服务状态
{health.data ? (
{health.data.status} 数据库 {health.data.database ? '正常' : '异常'} 原始数据目录 {health.data.raw_directory ? '正常' : '异常'}
) : ( )}
指标重算

新增或修改指标之后,后台会在保留期内的原始数据上自动重算,这里是进度。

{d ? (
状态
{d.state}
重算的指标
{d.metrics.length ? d.metrics.join(', ') : '—'}
进度
{fmtNum(d.windows_done)} / {fmtNum(d.windows_total)} 个窗口
排队中的窗口
{fmtNum(d.queue_length)}
) : ( )}
) } export function System() { const [tab, setTab] = useState('settings') return ( <>

系统

{tab === 'settings' ? : tab === 'versions' ? : tab === 'tokens' ? : tab === 'users' ? : tab === 'events' ? : tab === 'raw' ? : }
) }