Files
MoShiro/web/admin/src/pages/System.tsx
T
2026-10-09 01:33:22 +08:00

880 lines
38 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, useMemo, useState, type ReactNode } from 'react'
import { api } from '@shared/api/client'
import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeOwnerDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, AuditDto, 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'
import { CapabilityPicker, capabilityName } from './CapabilityPicker'
import { PasskeyAddForm } from './PasskeyAdd'
import { Trust } from './Trust'
import { ReleaseKeys } from './ReleaseKeys'
type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'trust' | 'audit' | '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>
<td>{dash}</td>
<td>{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' },
}
/**
* 节点的属主:节点自己要求有属主(passkey),安装时固定在本地;面板顺着它报上来的凭证 id 反查出绑定在哪个账户的哪把 passkey 上。
* 以后给节点更多接口和操作(web ssh、加密内容)时,只认这些 passkey。
*/
function OwnerCell({ owners }: { owners?: NodeOwnerDto[] | null }) {
if (!owners || owners.length === 0) return <span className="faint">未报告</span>
return (
<span className="row" style={{ gap: 6, flexWrap: 'wrap' }}>
{owners.map((o) => (
<Chip key={o.credential_id} tone={o.passkey ? '' : 'warn'}>
<span title={o.credential_id}>{o.passkey ? `${o.passkey}${o.account ? ` · ${o.account}` : ''}` : '未知凭证'}</span>
</Chip>
))}
</span>
)
}
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>
<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>
<td>
<OwnerCell owners={n.owners} />
</td>
</tr>
))}
</tbody>
</table>
{nodes.length === 0 && !info.loading ? <Empty>还没有节点上报过版本</Empty> : null}
<p className="muted" style={{ marginTop: 12 }}>
版本、tag 和构建时间是各个程序自己上报的。设置里打开「site.show_build」之后,展示页底部和节点卡片上才会给访客显示(提交号只在这里显示)。
「更新」一列来自节点:Podman Quadlet 节点(MOSHIRO_UPDATE=restart)点更新就是有序退出、由 systemd 重新拉取最新镜像;宿主机更新器节点由更新器验签和拉取:清单是 CI 签名的,面板只负责通知和请求,验签和拉取都在节点上完成;没装更新器的节点这一列是「—」。
</p>
</div>
)
}
function Tokens() {
const tokens = useQuery(() => api.get<Page<ApiTokenDto>>('/api/admin/api-tokens'), [])
const catalog = useQuery(() => api.get<CapabilityCatalogDto>('/api/admin/capabilities'), [])
const me = useQuery(() => api.get<MeDto>('/api/admin/auth/me'), [])
const { run } = useAction()
const [creating, setCreating] = useState(false)
const [name, setName] = useState('')
const [days, setDays] = useState<number | null>(null)
const [caps, setCaps] = useState<Set<string>>(new Set())
const [created, setCreated] = useState<CreatedApiTokenDto>()
const [revoking, setRevoking] = useState<ApiTokenDto[]>()
const [picked, setPicked] = useState<Set<string>>(new Set())
const items = tokens.data?.items ?? []
const active = items.filter((t) => !t.revoked_at)
const allPicked = active.length > 0 && active.every((t) => picked.has(t.id))
const mine = useMemo(() => new Set(me.data?.capabilities ?? []), [me.data])
const trust = useMemo(() => new Set((catalog.data?.groups ?? []).flatMap((g) => g.capabilities.filter((c) => c.trust).map((c) => c.id))), [catalog.data])
const open = () => {
setName('')
setDays(null)
setCaps(new Set())
setCreating(true)
}
return (
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<span className="muted">给脚本和其他程序用的令牌:按需勾选能力,只能授予自己拥有的能力。</span>
<span className="right row">
{picked.size > 0 ? (
<button type="button" className="btn small danger" onClick={() => setRevoking(active.filter((t) => picked.has(t.id)))}>
<Trash2 size={14} />
吊销所选({picked.size})
</button>
) : null}
<button type="button" className="btn primary" onClick={open}>
<Plus size={18} />
创建令牌
</button>
</span>
</div>
<ErrorNotice error={tokens.error} />
{items.length === 0 ? (
<Empty>没有 API 令牌</Empty>
) : (
<table className="table">
<thead>
<tr>
<th style={{ width: 28 }}>
<input type="checkbox" aria-label="全选" checked={allPicked} onChange={(e) => setPicked(e.target.checked ? new Set(active.map((t) => t.id)) : new Set())} />
</th>
<th>名称</th>
<th>能力</th>
<th>过期</th>
<th>创建</th>
<th>最后使用</th>
<th />
</tr>
</thead>
<tbody>
{items.map((t) => (
<tr key={t.id}>
<td>
<input
type="checkbox"
aria-label={`选择 ${t.name}`}
disabled={!!t.revoked_at}
checked={picked.has(t.id)}
onChange={(e) => {
const next = new Set(picked)
if (e.target.checked) next.add(t.id)
else next.delete(t.id)
setPicked(next)
}}
/>
</td>
<td style={{ fontWeight: 600 }}>{t.name}</td>
<td title={t.capabilities.map(capabilityName).join('、')}>
<Chip tone={t.capabilities.some((c) => trust.has(c)) ? 'warn' : ''}>{t.capabilities.length} 项</Chip>{' '}
<span className="faint">
{t.capabilities.slice(0, 2).map(capabilityName).join('、')}
{t.capabilities.length > 2 ? '…' : ''}
</span>
</td>
<td className="muted">{t.expires_at ? fmtTime(t.expires_at) : '不过期'}</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 令牌"
wide
onClose={() => setCreating(false)}
actions={
<>
<button type="button" className="btn text" onClick={() => setCreating(false)}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={!name.trim() || caps.size === 0}
onClick={async () => {
let result: CreatedApiTokenDto | undefined
const ok = await run(async () => {
result = await api.post<CreatedApiTokenDto>('/api/admin/api-tokens', { name: name.trim(), capabilities: [...caps], expires_in_days: days })
})
if (ok && result) {
setCreated(result)
setCreating(false)
tokens.reload()
}
}}
>
创建
</button>
</>
}
>
<div className="stack">
<div className="form-grid">
<Field label="名称">
<input className="input" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</Field>
<Field label="有效期">
<select className="input" value={days ?? ''} onChange={(e) => setDays(e.target.value ? Number(e.target.value) : null)}>
<option value="">不过期</option>
<option value="1">1 天</option>
<option value="7">7 天</option>
<option value="30">30 天</option>
<option value="90">90 天</option>
<option value="365">365 天</option>
</select>
</Field>
</div>
<Field label="能力" hint="写蕴含读:勾选“修改”会自动带上对应的“查看”。灰色的是你自己没有的能力,不能授予。">
{catalog.data ? <CapabilityPicker catalog={catalog.data} mine={mine} value={caps} onChange={setCaps} /> : <Spinner />}
</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>
<p className="muted">
{created.token.capabilities.length} 项能力:{created.token.capabilities.map(capabilityName).join('、')}。{created.token.expires_at ? `${fmtTime(created.token.expires_at)} 过期。` : '不会过期。'}
</p>
</div>
</Dialog>
) : null}
{revoking ? (
<Confirm
title="吊销令牌"
danger
confirmText={`吊销 ${revoking.length} 个`}
onClose={() => setRevoking(undefined)}
onConfirm={async () => {
for (const t of revoking) await run(() => api.del(`/api/admin/api-tokens/${t.id}`), `已吊销 ${t.name}`)
setPicked(new Set())
tokens.reload()
}}
>
使用 {revoking.map((t) => `「${t.name}」`).join('、')} 的程序会立刻失去访问权限。
</Confirm>
) : null}
</div>
)
}
function Passkeys() {
const list = useQuery(() => api.get<Page<PasskeyDto>>('/api/admin/auth/passkeys'), [])
const { run } = useAction()
const [removing, setRemoving] = useState<PasskeyDto>()
const items = list.data?.items ?? []
return (
<div className="card" style={{ marginBottom: 16 }}>
<div className="row" style={{ marginBottom: 12 }}>
<b>我的 Passkey</b>
<span className="muted">用指纹、人脸或安全密钥登录,不用输密码。建议至少注册两个(比如手机和安全密钥)。</span>
</div>
<ErrorNotice error={list.error} />
{items.length === 0 ? (
<Empty>还没有 Passkey</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>名称</th>
<th>创建</th>
<th>最后使用</th>
<th />
</tr>
</thead>
<tbody>
{items.map((p) => (
<tr key={p.id}>
<td style={{ fontWeight: 600 }}>
{p.name} {p.transports.includes('internal') ? <Chip>本机</Chip> : p.transports.length > 0 ? <Chip>{p.transports.join('、')}</Chip> : null}
</td>
<td className="muted">{fmtTime(p.created_at)}</td>
<td className="muted">{p.last_used_at ? fmtTime(p.last_used_at) : '从未'}</td>
<td className="r">
<button type="button" className="btn small danger" onClick={() => setRemoving(p)}>
<Trash2 size={14} />
删除
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<PasskeyAddForm onAdded={list.reload} />
{removing ? (
<Confirm title="删除 Passkey" danger confirmText="删除" onClose={() => setRemoving(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/auth/passkeys/${removing.id}`), '已删除'); list.reload() }}>
「{removing.name}」删除之后不能再用来登录。如果这是你唯一的 Passkey,仍然可以用密码登录。
</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 (
<>
<Passkeys />
<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 Audit() {
const [actor, setActor] = useState('')
const [action, setAction] = useState('')
const audit = useQuery(() => api.get<Page<AuditDto>>('/api/admin/audit', { actor: actor || undefined, action: action || undefined, limit: 100 }), [actor, action], 10_000)
return (
<div className="card">
<div className="row wrap" style={{ marginBottom: 12 }}>
<input className="input" style={{ width: 200 }} placeholder="按操作者筛选,例如 admin" value={actor} onChange={(e) => setActor(e.target.value)} />
<input className="input" style={{ width: 240 }} placeholder="按操作筛选,例如 auth.login" value={action} onChange={(e) => setAction(e.target.value)} />
<span className="right">{audit.loading && !audit.data ? <Spinner /> : null}</span>
</div>
<ErrorNotice error={audit.error} />
{(audit.data?.items ?? []).length === 0 ? (
<Empty>没有记录</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>时间</th>
<th>操作者</th>
<th>方式</th>
<th>操作</th>
<th>对象 / 细节</th>
<th>来源</th>
<th>结果</th>
</tr>
</thead>
<tbody>
{audit.data?.items.map((a) => (
<tr key={a.id}>
<td className="muted num">{fmtTime(a.at)}</td>
<td>{a.actor}</td>
<td className="muted">{a.method === 'key' ? 'API 令牌' : a.method === 'passkey' ? 'Passkey' : '密码'}</td>
<td className="mono">{a.action}</td>
<td className="mono muted">{[a.target, a.detail].filter(Boolean).join(' · ')}</td>
<td className="mono faint">{a.remote ?? ''}</td>
<td>
<Chip tone={a.success ? 'ok' : 'bad'}>{a.success ? '成功' : '失败'}</Chip>
</td>
</tr>
))}
</tbody>
</table>
)}
</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: 'trust' as const, label: '信任' },
{ value: 'audit' 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 === 'trust' ? (
<>
<Trust />
<ReleaseKeys />
</>
) : tab === 'audit' ? <Audit /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
</div>
</>
)
}