Files
MoShiro/web/admin/src/pages/System.tsx
T
justme 9bcef56497
ci/woodpecker/push/main-amd64-build Pipeline was successful
feat: daily node restart controlled from the admin, connect timings as a default line task [CI]
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.
2026-10-07 14:56:50 +08:00

662 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
)
}