chore: initial project import
This commit is contained in:
commit
26620707a0
347 files changed
+56172
No files matched your search
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user