ci/woodpecker/push/main-amd64-build Pipeline was successful
Agents and Probes exit in order at a configured time (default 04:30 UTC+8, switch and time zone in the admin settings) and the service manager starts them again; a local restart_at or MOSHIRO_RESTART_AT wins. The connect kind (TCP connect and TLS handshake timings) is now a default continuous line template, with a section on the line page.
662 lines
28 KiB
TypeScript
662 lines
28 KiB
TypeScript
import { Download, KeyRound, Plus, Trash2 } from 'lucide-react'
|
||
import { useEffect, useState, type ReactNode } from 'react'
|
||
import { api } from '@shared/api/client'
|
||
import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeUpdateDto, ReleaseDto, 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'
|
||
|
||
const RESTART_KEYS = ['fleet.restart_enabled', 'fleet.restart_hour', 'fleet.restart_minute', 'fleet.restart_utc_offset_minutes']
|
||
|
||
/** 常用时区(固定偏移,不含夏令时):偏移是东经分钟数。 */
|
||
const TIME_ZONES: { offset: number; label: string }[] = [
|
||
{ offset: 480, label: '北京时间(UTC+8)' },
|
||
{ offset: 0, label: 'UTC' },
|
||
{ offset: 540, label: '东京、首尔(UTC+9)' },
|
||
{ offset: 60, label: '中欧(UTC+1)' },
|
||
{ offset: 180, label: '莫斯科(UTC+3)' },
|
||
{ offset: -300, label: '美东(UTC-5)' },
|
||
{ offset: -480, label: '美西(UTC-8)' },
|
||
]
|
||
|
||
const pad2 = (n: number) => String(n).padStart(2, '0')
|
||
const offsetLabel = (offset: number) => `UTC${offset < 0 ? '-' : '+'}${Math.floor(Math.abs(offset) / 60)}${Math.abs(offset) % 60 ? `:${pad2(Math.abs(offset) % 60)}` : ''}`
|
||
|
||
/** 定时重启:节点每天这个时刻有序退出一次,由系统服务拉起。设置保存后马上通知所有在线节点。 */
|
||
function RestartCard({ items, onSaved }: { items: SettingDto[]; onSaved: () => void }) {
|
||
const { busy, run } = useAction()
|
||
const value = (key: string, fallback: string) => items.find((s) => s.key === key)?.value ?? fallback
|
||
const saved = {
|
||
enabled: value('fleet.restart_enabled', 'true') === 'true',
|
||
hour: Number(value('fleet.restart_hour', '4')),
|
||
minute: Number(value('fleet.restart_minute', '30')),
|
||
offset: Number(value('fleet.restart_utc_offset_minutes', '480')),
|
||
}
|
||
const [draft, setDraft] = useState(saved)
|
||
useEffect(() => setDraft(saved), [saved.enabled, saved.hour, saved.minute, saved.offset]) // eslint-disable-line react-hooks/exhaustive-deps
|
||
const dirty = draft.enabled !== saved.enabled || draft.hour !== saved.hour || draft.minute !== saved.minute || draft.offset !== saved.offset
|
||
const zones = TIME_ZONES.some((z) => z.offset === draft.offset) ? TIME_ZONES : [...TIME_ZONES, { offset: draft.offset, label: offsetLabel(draft.offset) }]
|
||
const zoneName = zones.find((z) => z.offset === draft.offset)?.label ?? offsetLabel(draft.offset)
|
||
|
||
return (
|
||
<div className="card" style={{ marginBottom: 16 }}>
|
||
<div className="card-title">定时重启(Agent 和 Probe)</div>
|
||
<div className="row wrap" style={{ gap: 16 }}>
|
||
<label className="switch">
|
||
<input type="checkbox" checked={draft.enabled} onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })} />
|
||
{draft.enabled ? '开' : '关'}
|
||
</label>
|
||
<span className="row" style={{ gap: 6 }}>
|
||
<select className="select" aria-label="小时" value={draft.hour} disabled={!draft.enabled} onChange={(e) => setDraft({ ...draft, hour: Number(e.target.value) })}>
|
||
{Array.from({ length: 24 }, (_, h) => (
|
||
<option key={h} value={h}>
|
||
{pad2(h)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
:
|
||
<select className="select" aria-label="分钟" value={draft.minute} disabled={!draft.enabled} onChange={(e) => setDraft({ ...draft, minute: Number(e.target.value) })}>
|
||
{Array.from({ length: 60 }, (_, m) => (
|
||
<option key={m} value={m}>
|
||
{pad2(m)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</span>
|
||
<select className="select" aria-label="时区" value={draft.offset} disabled={!draft.enabled} onChange={(e) => setDraft({ ...draft, offset: Number(e.target.value) })}>
|
||
{zones.map((z) => (
|
||
<option key={z.offset} value={z.offset}>
|
||
{z.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
type="button"
|
||
className="btn primary"
|
||
disabled={!dirty || busy}
|
||
onClick={async () => {
|
||
await run(
|
||
() =>
|
||
api.patch('/api/admin/settings', {
|
||
'fleet.restart_enabled': draft.enabled,
|
||
'fleet.restart_hour': draft.hour,
|
||
'fleet.restart_minute': draft.minute,
|
||
'fleet.restart_utc_offset_minutes': draft.offset,
|
||
}),
|
||
'已保存,已通知在线节点',
|
||
)
|
||
onSaved()
|
||
}}
|
||
>
|
||
保存
|
||
</button>
|
||
</div>
|
||
<p className="muted" style={{ marginTop: 12 }}>
|
||
{draft.enabled ? `每天 ${pad2(draft.hour)}:${pad2(draft.minute)}(${zoneName})` : '已关闭'}
|
||
:节点自己有序退出一次,由系统服务(systemd、容器、procd)重新拉起,测量会中断几十秒。节点本地写了 restart_at 或环境变量 MOSHIRO_RESTART_AT 时以本地为准。
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 ?? []).filter((s) => !RESTART_KEYS.includes(s.key))
|
||
const dirty = Object.keys(draft).length > 0
|
||
return (
|
||
<>
|
||
<RestartCard items={settings.data?.items ?? []} onSaved={settings.reload} />
|
||
<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>
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** 一行:名字、类型、版本、tag、提交、构建时间、系统。没有的写「—」。 */
|
||
function BuildRow({ name, kind, version, tag, commit, builtAt, platform, update }: { name: string; kind: string; version?: string | null; tag?: string | null; commit?: string | null; builtAt?: string | null; platform?: string; update?: ReactNode }) {
|
||
const dash = <span className="faint">—</span>
|
||
return (
|
||
<tr>
|
||
<td style={{ fontWeight: 600 }}>{name}</td>
|
||
<td>
|
||
<Chip>{kind}</Chip>
|
||
</td>
|
||
<td className="mono">{version || dash}</td>
|
||
<td className="mono">{tag || dash}</td>
|
||
<td className="mono">{commit || dash}</td>
|
||
<td className="muted">{fmtBuiltAt(builtAt) || dash}</td>
|
||
<td className="muted">{platform || dash}</td>
|
||
<td>{update ?? dash}</td>
|
||
</tr>
|
||
)
|
||
}
|
||
|
||
/** 节点宿主机上的更新器报告的状态;没装更新器(二进制安装、旧版本)的节点没有。 */
|
||
const UPDATE_STATE: Record<string, { text: string; tone: '' | 'ok' | 'warn' | 'bad' }> = {
|
||
up_to_date: { text: '已是最新', tone: 'ok' },
|
||
available: { text: '有新版本', tone: 'warn' },
|
||
applying: { text: '更新中…', tone: 'warn' },
|
||
failed: { text: '更新失败', tone: 'bad' },
|
||
rolled_back: { text: '已回滚', tone: 'bad' },
|
||
invalid: { text: '清单无效', tone: 'bad' },
|
||
}
|
||
|
||
function UpdateCell({ update, busy, onUpdate }: { update?: NodeUpdateDto | null; busy: boolean; onUpdate: () => void }) {
|
||
if (!update) return <span className="faint">—</span>
|
||
const s = UPDATE_STATE[update.state] ?? { text: update.state, tone: '' as const }
|
||
return (
|
||
<span className="row" style={{ gap: 8 }} title={update.error ?? undefined}>
|
||
<Chip tone={s.tone}>{s.text}</Chip>
|
||
{update.state === 'available' ? <span className="mono muted">{update.available_tag}</span> : null}
|
||
{update.state === 'available' ? (
|
||
<button type="button" className="btn small primary" disabled={busy} onClick={onUpdate}>
|
||
更新
|
||
</button>
|
||
) : null}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/** 每个程序的版本:后端、前端、每个 Agent 和 Probe 自己上报的,以及 CI 发布的最新签名清单和各节点的更新状态。 */
|
||
function Versions() {
|
||
const { busy, run } = useAction()
|
||
const releases = useQuery(() => api.get<Page<ReleaseDto>>('/api/admin/releases'), [], 30_000)
|
||
const info = useQuery(() => api.get<BuildInfoDto>('/api/admin/build-info'), [], 15_000)
|
||
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [], 30_000)
|
||
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 30_000)
|
||
const names = new Map<string, string>([...(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)
|
||
const updatable = nodes.filter((n) => n.update?.state === 'available')
|
||
const requestUpdate = async (ids: string[]) => {
|
||
for (const id of ids) {
|
||
await run(() => api.post(`/api/admin/nodes/${id}/update`), '已请求更新')
|
||
}
|
||
info.reload()
|
||
}
|
||
return (
|
||
<div className="card">
|
||
<ErrorNotice error={info.error ?? releases.error} />
|
||
<div className="row" style={{ marginBottom: 12 }}>
|
||
<span className="muted">
|
||
最新发布:
|
||
{(releases.data?.items ?? []).length === 0
|
||
? '还没有(CI 构建后会自动登记)'
|
||
: (releases.data?.items ?? []).map((r) => (
|
||
<span key={`${r.component}:${r.channel}`} style={{ marginRight: 12 }}>
|
||
{r.component} <span className="mono">{r.tag}</span> <span className="faint">({r.channel})</span>
|
||
</span>
|
||
))}
|
||
</span>
|
||
{updatable.length > 0 ? (
|
||
<button type="button" className="btn primary right" disabled={busy} onClick={() => requestUpdate(updatable.map((n) => n.id))}>
|
||
全部更新({updatable.length})
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>名称</th>
|
||
<th>类型</th>
|
||
<th>版本</th>
|
||
<th>tag</th>
|
||
<th>提交</th>
|
||
<th>构建时间</th>
|
||
<th>系统</th>
|
||
<th>更新</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{back ? <BuildRow name="后端" kind="backend" version={back.version} tag={back.tag} commit={back.commit} builtAt={back.built_at} update={info.data?.backend_update ? <Chip tone={info.data.backend_update.state === 'available' ? 'warn' : ''}>{info.data.backend_update.channel} · {info.data.backend_update.state === 'unavailable' ? '未安装更新器' : UPDATE_STATE[info.data.backend_update.state]?.text ?? info.data.backend_update.state}</Chip> : undefined} /> : null}
|
||
<BuildRow name="前端" kind="web" version={frontendBuild.version} tag={frontendBuild.tag} commit={frontendBuild.commit} builtAt={frontendBuild.builtAt} />
|
||
{nodes.map((n) => (
|
||
<tr key={n.id}>
|
||
<td style={{ fontWeight: 600 }}>{names.get(n.id) ?? n.id}</td>
|
||
<td>
|
||
<Chip>{n.kind}</Chip>
|
||
</td>
|
||
<td className="mono">
|
||
{n.version ? mismatch(n.version) ? <Chip tone="warn">{n.version}</Chip> : n.version : <span className="faint">—</span>}
|
||
</td>
|
||
<td className="mono">{n.tag || <span className="faint">—</span>}</td>
|
||
<td className="mono">{n.commit || <span className="faint">—</span>}</td>
|
||
<td className="muted">{fmtBuiltAt(n.built_at) || <span className="faint">—</span>}</td>
|
||
<td className="muted">{[n.os_name, n.arch].filter(Boolean).join(' / ') || <span className="faint">—</span>}</td>
|
||
<td>
|
||
<UpdateCell update={n.update} busy={busy} onUpdate={() => requestUpdate([n.id])} />
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{nodes.length === 0 && !info.loading ? <Empty>还没有节点上报过版本</Empty> : null}
|
||
<p className="muted" style={{ marginTop: 12 }}>
|
||
版本、tag 和构建时间是各个程序自己上报的。设置里打开「site.show_build」之后,展示页底部和节点卡片上才会给访客显示(提交号只在这里显示)。
|
||
「更新」一列来自节点宿主机上的更新器:清单是 CI 签名的,面板只负责通知和请求,验签和拉取都在节点上完成;没装更新器的节点这一列是「—」。
|
||
</p>
|
||
</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' | 'release'>('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: 'release' as const, label: 'release(只登记发布,给 CI)' }]} 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: 'versions' 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 === 'versions' ? <Versions /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
|
||
</div>
|
||
</>
|
||
)
|
||
}
|