chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 26620707a0
347 files changed
+56172

No files matched your search

+434
View File
@@ -0,0 +1,434 @@
import { Download, KeyRound, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { api } from '@shared/api/client'
import type { ApiTokenDto, CreatedApiTokenDto, EventDto, Page, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
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' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis'
function Settings() {
const settings = useQuery(() => api.get<Page<SettingDto>>('/api/admin/settings'), [])
const { busy, run } = useAction()
const [draft, setDraft] = useState<Record<string, string>>({})
useEffect(() => setDraft({}), [settings.data])
const items = settings.data?.items ?? []
const dirty = Object.keys(draft).length > 0
return (
<div className="card">
<ErrorNotice error={settings.error} />
<table className="table">
<thead>
<tr>
<th>设置</th>
<th>值</th>
<th>说明</th>
</tr>
</thead>
<tbody>
{items.map((s) => {
const value = draft[s.key] ?? s.value
return (
<tr key={s.key}>
<td className="mono">{s.key}</td>
<td style={{ width: 200 }}>
{s.type === 'bool' ? (
<label className="switch">
<input type="checkbox" checked={value === 'true'} onChange={(e) => setDraft({ ...draft, [s.key]: String(e.target.checked) })} />
{value === 'true' ? '开' : '关'}
</label>
) : (
<input className="input" type={s.type === 'int' ? 'number' : 'text'} value={value} onChange={(e) => setDraft({ ...draft, [s.key]: e.target.value })} />
)}
</td>
<td className="muted">
{translateSetting(s.key, s.description)}
{s.value !== s.default ? <span className="faint">(默认 {s.default})</span> : null}
</td>
</tr>
)
})}
</tbody>
</table>
<div className="row" style={{ marginTop: 16 }}>
<button
type="button"
className="btn primary"
disabled={!dirty || busy}
onClick={async () => {
const body: Record<string, unknown> = {}
for (const s of items) {
if (draft[s.key] === undefined) continue
body[s.key] = s.type === 'bool' ? draft[s.key] === 'true' : s.type === 'int' ? Number(draft[s.key]) : draft[s.key]
}
await run(() => api.patch('/api/admin/settings', body), '已保存')
settings.reload()
}}
>
保存
</button>
<span className="muted">「site.public」打开之后,展示页对匿名访客公开。</span>
</div>
</div>
)
}
function Tokens() {
const tokens = useQuery(() => api.get<Page<ApiTokenDto>>('/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<CreatedApiTokenDto>()
const [revoking, setRevoking] = useState<ApiTokenDto>()
return (
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<span className="muted">给脚本和其他程序用的令牌:read 只能读展示数据,admin 可以调用全部管理接口。</span>
<button type="button" className="btn primary right" onClick={() => setCreating(true)}>
<Plus size={18} />
创建令牌
</button>
</div>
<ErrorNotice error={tokens.error} />
{(tokens.data?.items ?? []).length === 0 ? (
<Empty>没有 API 令牌</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>名称</th>
<th>权限</th>
<th>创建</th>
<th>最后使用</th>
<th />
</tr>
</thead>
<tbody>
{tokens.data?.items.map((t) => (
<tr key={t.id}>
<td style={{ fontWeight: 600 }}>{t.name}</td>
<td>
<Chip tone={t.scope === 'admin' ? 'warn' : ''}>{t.scope}</Chip>
</td>
<td className="muted">{fmtTime(t.created_at)}</td>
<td className="muted">{t.revoked_at ? <Chip tone="bad">已吊销</Chip> : fmtTime(t.last_used_at)}</td>
<td className="r">
{!t.revoked_at ? (
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
<Trash2 size={14} />
吊销
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
)}
{creating ? (
<Dialog
title="创建 API 令牌"
onClose={() => setCreating(false)}
actions={
<>
<button type="button" className="btn text" onClick={() => setCreating(false)}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={!name.trim()}
onClick={async () => {
let result: CreatedApiTokenDto | undefined
const ok = await run(async () => {
result = await api.post<CreatedApiTokenDto>('/api/admin/api-tokens', { name: name.trim(), scope })
})
if (ok && result) {
setCreated(result)
setCreating(false)
setName('')
tokens.reload()
}
}}
>
创建
</button>
</>
}
>
<div className="stack">
<Field label="名称">
<input className="input" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</Field>
<Field label="权限">
<Segmented options={[{ value: 'read' as const, label: 'read(只读)' }, { value: 'admin' as const, label: 'admin(全部)' }]} value={scope} onChange={setScope} />
</Field>
</div>
</Dialog>
) : null}
{created ? (
<Dialog title="令牌已创建" onClose={() => setCreated(undefined)} actions={<button type="button" className="btn primary" onClick={() => setCreated(undefined)}>完成</button>}>
<div className="stack">
<div className="notice warn">令牌明文只显示这一次,请现在就复制。</div>
<pre className="code">{created.plaintext}</pre>
<div>
<CopyButton text={created.plaintext} />
</div>
</div>
</Dialog>
) : null}
{revoking ? (
<Confirm title="吊销令牌" danger confirmText="吊销" onClose={() => setRevoking(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/api-tokens/${revoking.id}`), '已吊销'); tokens.reload() }}>
使用「{revoking.name}」的程序会立刻失去访问权限。
</Confirm>
) : null}
</div>
)
}
function Users() {
const users = useQuery(() => api.get<Page<UserDto>>('/api/admin/users'), [])
const { run } = useAction()
const [dialog, setDialog] = useState<{ user?: UserDto }>()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
return (
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<span className="muted">能登录管理端的账号。</span>
<button type="button" className="btn primary right" onClick={() => { setUsername(''); setPassword(''); setDialog({}) }}>
<Plus size={18} />
添加用户
</button>
</div>
<ErrorNotice error={users.error} />
<table className="table">
<thead>
<tr>
<th>用户名</th>
<th>创建</th>
<th />
</tr>
</thead>
<tbody>
{users.data?.items.map((u) => (
<tr key={u.id}>
<td style={{ fontWeight: 600 }}>{u.username}</td>
<td className="muted">{fmtTime(u.created_at)}</td>
<td className="r">
<button type="button" className="btn small" onClick={() => { setPassword(''); setDialog({ user: u }) }}>
<KeyRound size={14} />
改密码
</button>
</td>
</tr>
))}
</tbody>
</table>
{dialog ? (
<Dialog
title={dialog.user ? `修改 ${dialog.user.username} 的密码` : '添加用户'}
onClose={() => setDialog(undefined)}
actions={
<>
<button type="button" className="btn text" onClick={() => setDialog(undefined)}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={!password || (!dialog.user && !username.trim())}
onClick={async () => {
const ok = await run(
() => (dialog.user ? api.patch(`/api/admin/users/${dialog.user.id}`, { password }) : api.post('/api/admin/users', { username: username.trim(), password })),
'已保存',
)
if (ok) {
setDialog(undefined)
users.reload()
}
}}
>
保存
</button>
</>
}
>
<div className="stack">
{!dialog.user ? (
<Field label="用户名">
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
</Field>
) : null}
<Field label="密码">
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
</Field>
</div>
</Dialog>
) : null}
</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)
return (
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<input className="input" style={{ width: 240 }} placeholder="按类型筛选,例如 probe_connected" value={kind} onChange={(e) => setKind(e.target.value)} />
<span className="right">{events.loading && !events.data ? <Spinner /> : null}</span>
</div>
<ErrorNotice error={events.error} />
{(events.data?.items ?? []).length === 0 ? (
<Empty>没有事件</Empty>
) : (
<table className="table">
<tbody>
{events.data?.items.map((e) => (
<tr key={e.id}>
<td className="muted num" style={{ width: 150 }}>
{fmtTime(e.at)}
</td>
<td style={{ width: 200 }}>{e.kind}</td>
<td className="mono faint" style={{ width: 280 }}>
{e.agent_id ?? ''}
</td>
<td className="mono muted">{e.detail ?? ''}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
function Raw() {
const [kind, setKind] = useState('')
const [agent, setAgent] = useState('')
const chunks = useQuery(() => api.get<Page<RawChunkMetaDto>>('/api/admin/raw/chunks', { kind: kind || undefined, agent_id: agent || undefined, limit: 50 }), [kind, agent])
return (
<div className="card">
<div className="row wrap" style={{ marginBottom: 12 }}>
<input className="input" style={{ width: 200 }} placeholder="Agent ID" value={agent} onChange={(e) => setAgent(e.target.value)} />
<input className="input" style={{ width: 200 }} placeholder="采集方法,例如 udp_echo" value={kind} onChange={(e) => setKind(e.target.value)} />
<span className="muted">原始块按保留期限保存,可以下载后离线分析。</span>
</div>
<ErrorNotice error={chunks.error} />
{(chunks.data?.items ?? []).length === 0 ? (
<Empty>没有符合条件的原始块</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>时间</th>
<th>采集方法</th>
<th>序列</th>
<th className="r">时长</th>
<th className="r">大小</th>
<th />
</tr>
</thead>
<tbody>
{chunks.data?.items.map((c) => (
<tr key={c.chunk_id}>
<td className="muted num">{fmtTime(c.start)}</td>
<td style={{ fontWeight: 600 }}>
{c.kind}
{c.partial ? <Chip tone="warn">不完整</Chip> : null}
</td>
<td className="mono faint" style={{ maxWidth: 260, overflowWrap: 'anywhere' }}>
{c.series}
</td>
<td className="r num">{fmtNum(c.duration_ms / 1000, 1)} s</td>
<td className="r num">{fmtNum(c.bytes / 1024, 1)} KiB</td>
<td className="r">
<a className="btn small" href={`/api/admin/raw/chunks/${c.chunk_id}`} download>
<Download size={14} />
下载
</a>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
function Analysis() {
const state = useQuery(() => api.get<RecomputeDto>('/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 (
<div className="stack">
<div className="card">
<div className="card-title">服务状态</div>
{health.data ? (
<div className="row wrap">
<Chip tone={health.data.status === 'ok' || health.data.status === 'healthy' ? 'ok' : 'bad'}>{health.data.status}</Chip>
<Chip tone={health.data.database ? 'ok' : 'bad'}>数据库 {health.data.database ? '正常' : '异常'}</Chip>
<Chip tone={health.data.raw_directory ? 'ok' : 'bad'}>原始数据目录 {health.data.raw_directory ? '正常' : '异常'}</Chip>
</div>
) : (
<Spinner />
)}
</div>
<div className="card">
<div className="card-title">指标重算</div>
<p className="muted" style={{ marginBottom: 12 }}>新增或修改指标之后,后台会在保留期内的原始数据上自动重算,这里是进度。</p>
<ErrorNotice error={state.error} />
{d ? (
<dl className="kv">
<dt>状态</dt>
<dd>
<Chip tone={d.state === 'idle' || d.state === 'done' ? 'ok' : 'accent'}>{d.state}</Chip>
</dd>
<dt>重算的指标</dt>
<dd>{d.metrics.length ? d.metrics.join(', ') : '—'}</dd>
<dt>进度</dt>
<dd className="num">
{fmtNum(d.windows_done)} / {fmtNum(d.windows_total)} 个窗口
</dd>
<dt>排队中的窗口</dt>
<dd className="num">{fmtNum(d.queue_length)}</dd>
</dl>
) : (
<Spinner />
)}
</div>
</div>
)
}
export function System() {
const [tab, setTab] = useState<Tab>('settings')
return (
<>
<header>
<h1>系统</h1>
</header>
<div className="stack">
<Segmented
options={[
{ value: 'settings' as const, label: '设置' },
{ value: 'tokens' as const, label: 'API 令牌' },
{ value: 'users' as const, label: '用户' },
{ value: 'events' as const, label: '事件' },
{ value: 'raw' as const, label: '原始数据' },
{ value: 'analysis' as const, label: '分析' },
]}
value={tab}
onChange={setTab}
/>
{tab === 'settings' ? <Settings /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
</div>
</>
)
}