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 (
定时重启(Agent 和 Probe)
:

{draft.enabled ? `每天 ${pad2(draft.hour)}:${pad2(draft.minute)}(${zoneName})` : '已关闭'} :节点自己有序退出一次,由系统服务(systemd、容器、procd)重新拉起,测量会中断几十秒。节点本地写了 restart_at 或环境变量 MOSHIRO_RESTART_AT 时以本地为准。

) } function Settings() { const settings = useQuery(() => api.get>('/api/admin/settings'), []) const { busy, run } = useAction() const [draft, setDraft] = useState>({}) useEffect(() => setDraft({}), [settings.data]) const items = (settings.data?.items ?? []).filter((s) => !RESTART_KEYS.includes(s.key)) const dirty = Object.keys(draft).length > 0 return ( <>
{items.map((s) => { const value = draft[s.key] ?? s.value return ( ) })}
设置 值 说明
{s.key} {s.type === 'bool' ? ( ) : ( setDraft({ ...draft, [s.key]: e.target.value })} /> )} {translateSetting(s.key, s.description)} {s.value !== s.default ? (默认 {s.default}) : null}
「site.public」打开之后,展示页对匿名访客公开。
) } /** 一行:名字、类型、版本、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 = — return ( {name} {kind} {version || dash} {tag || dash} {commit || dash} {fmtBuiltAt(builtAt) || dash} {platform || dash} {update ?? dash} {dash} {dash} ) } /** 节点宿主机上的更新器报告的状态;没装更新器(二进制安装、旧版本)的节点没有。 */ const UPDATE_STATE: Record = { 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 未报告 return ( {owners.map((o) => ( {o.passkey ? `${o.passkey}${o.account ? ` · ${o.account}` : ''}` : '未知凭证'} ))} ) } function UpdateCell({ update, busy, onUpdate }: { update?: NodeUpdateDto | null; busy: boolean; onUpdate: () => void }) { if (!update) return — const s = UPDATE_STATE[update.state] ?? { text: update.state, tone: '' as const } return ( {s.text} {update.state === 'available' ? {update.available_tag} : null} {update.state === 'available' ? ( ) : null} ) } /** 每个程序的版本:后端、前端、每个 Agent 和 Probe 自己上报的,以及 CI 发布的最新签名清单和各节点的更新状态。 */ function Versions() { const { busy, run } = useAction() const releases = useQuery(() => api.get>('/api/admin/releases'), [], 30_000) const info = useQuery(() => api.get('/api/admin/build-info'), [], 15_000) const agents = useQuery(() => api.get>('/api/admin/agents'), [], 30_000) const probes = useQuery(() => api.get>('/api/admin/probes'), [], 30_000) const names = new Map([...(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 (
最新发布: {(releases.data?.items ?? []).length === 0 ? '还没有(CI 构建后会自动登记)' : (releases.data?.items ?? []).map((r) => ( {r.component} {r.tag} ({r.channel}) ))} {updatable.length > 0 ? ( ) : null}
{back ? {info.data.backend_update.channel} · {info.data.backend_update.state === 'unavailable' ? '未安装更新器' : UPDATE_STATE[info.data.backend_update.state]?.text ?? info.data.backend_update.state} : undefined} /> : null} {nodes.map((n) => ( ))}
名称 类型 版本 tag 提交 构建时间 系统 更新 属主
{names.get(n.id) ?? n.id} {n.kind} {n.version ? mismatch(n.version) ? {n.version} : n.version : —} {n.tag || —} {n.commit || —} {fmtBuiltAt(n.built_at) || —} {[n.os_name, n.arch].filter(Boolean).join(' / ') || —} requestUpdate([n.id])} />
{nodes.length === 0 && !info.loading ? 还没有节点上报过版本 : null}

版本、tag 和构建时间是各个程序自己上报的。设置里打开「site.show_build」之后,展示页底部和节点卡片上才会给访客显示(提交号只在这里显示)。 「更新」一列来自节点:Podman Quadlet 节点(MOSHIRO_UPDATE=restart)点更新就是有序退出、由 systemd 重新拉取最新镜像;宿主机更新器节点由更新器验签和拉取:清单是 CI 签名的,面板只负责通知和请求,验签和拉取都在节点上完成;没装更新器的节点这一列是「—」。

) } function Tokens() { const tokens = useQuery(() => api.get>('/api/admin/api-tokens'), []) const catalog = useQuery(() => api.get('/api/admin/capabilities'), []) const me = useQuery(() => api.get('/api/admin/auth/me'), []) const { run } = useAction() const [creating, setCreating] = useState(false) const [name, setName] = useState('') const [days, setDays] = useState(null) const [caps, setCaps] = useState>(new Set()) const [created, setCreated] = useState() const [revoking, setRevoking] = useState() const [picked, setPicked] = useState>(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 (
给脚本和其他程序用的令牌:按需勾选能力,只能授予自己拥有的能力。 {picked.size > 0 ? ( ) : null}
{items.length === 0 ? ( 没有 API 令牌 ) : ( {items.map((t) => ( ))}
setPicked(e.target.checked ? new Set(active.map((t) => t.id)) : new Set())} /> 名称 能力 过期 创建 最后使用
{ const next = new Set(picked) if (e.target.checked) next.add(t.id) else next.delete(t.id) setPicked(next) }} /> {t.name} trust.has(c)) ? 'warn' : ''}>{t.capabilities.length} 项{' '} {t.capabilities.slice(0, 2).map(capabilityName).join('、')} {t.capabilities.length > 2 ? '…' : ''} {t.expires_at ? fmtTime(t.expires_at) : '不过期'} {fmtTime(t.created_at)} {t.revoked_at ? 已吊销 : fmtTime(t.last_used_at)} {!t.revoked_at ? ( ) : null}
)} {creating ? ( setCreating(false)} actions={ <> } >
setName(e.target.value)} autoFocus />
{catalog.data ? : }
) : null} {created ? ( setCreated(undefined)} actions={ } >
令牌明文只显示这一次,请现在就复制。
{created.plaintext}

{created.token.capabilities.length} 项能力:{created.token.capabilities.map(capabilityName).join('、')}。{created.token.expires_at ? `${fmtTime(created.token.expires_at)} 过期。` : '不会过期。'}

) : null} {revoking ? ( 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('、')} 的程序会立刻失去访问权限。 ) : null}
) } function Passkeys() { const list = useQuery(() => api.get>('/api/admin/auth/passkeys'), []) const { run } = useAction() const [removing, setRemoving] = useState() const items = list.data?.items ?? [] return (
我的 Passkey 用指纹、人脸或安全密钥登录,不用输密码。建议至少注册两个(比如手机和安全密钥)。
{items.length === 0 ? ( 还没有 Passkey ) : ( {items.map((p) => ( ))}
名称 创建 最后使用
{p.name} {p.transports.includes('internal') ? 本机 : p.transports.length > 0 ? {p.transports.join('、')} : null} {fmtTime(p.created_at)} {p.last_used_at ? fmtTime(p.last_used_at) : '从未'}
)} {removing ? ( setRemoving(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/auth/passkeys/${removing.id}`), '已删除'); list.reload() }}> 「{removing.name}」删除之后不能再用来登录。如果这是你唯一的 Passkey,仍然可以用密码登录。 ) : null}
) } function Users() { const users = useQuery(() => api.get>('/api/admin/users'), []) const { run } = useAction() const [dialog, setDialog] = useState<{ user?: UserDto }>() const [username, setUsername] = useState('') const [password, setPassword] = useState('') return ( <>
能登录管理端的账号。
{users.data?.items.map((u) => ( ))}
用户名 创建
{u.username} {fmtTime(u.created_at)}
{dialog ? ( setDialog(undefined)} actions={ <> } >
{!dialog.user ? ( setUsername(e.target.value)} autoFocus /> ) : null} setPassword(e.target.value)} autoComplete="new-password" />
) : null}
) } /** 审计:谁在什么时候做了什么。写操作、登录成败都在里面,保留一年。 */ function Audit() { const [actor, setActor] = useState('') const [action, setAction] = useState('') const audit = useQuery(() => api.get>('/api/admin/audit', { actor: actor || undefined, action: action || undefined, limit: 100 }), [actor, action], 10_000) return (
setActor(e.target.value)} /> setAction(e.target.value)} /> {audit.loading && !audit.data ? : null}
{(audit.data?.items ?? []).length === 0 ? ( 没有记录 ) : ( {audit.data?.items.map((a) => ( ))}
时间 操作者 方式 操作 对象 / 细节 来源 结果
{fmtTime(a.at)} {a.actor} {a.method === 'key' ? 'API 令牌' : a.method === 'passkey' ? 'Passkey' : '密码'} {a.action} {[a.target, a.detail].filter(Boolean).join(' · ')} {a.remote ?? ''} {a.success ? '成功' : '失败'}
)}
) } function Events() { const [kind, setKind] = useState('') const events = useQuery(() => api.get>('/api/admin/events', { kind: kind || undefined, limit: 100 }), [kind], 10_000) return (
setKind(e.target.value)} /> {events.loading && !events.data ? : null}
{(events.data?.items ?? []).length === 0 ? ( 没有事件 ) : ( {events.data?.items.map((e) => ( ))}
{fmtTime(e.at)} {e.kind} {e.agent_id ?? ''} {e.detail ?? ''}
)}
) } function Raw() { const [kind, setKind] = useState('') const [agent, setAgent] = useState('') const chunks = useQuery(() => api.get>('/api/admin/raw/chunks', { kind: kind || undefined, agent_id: agent || undefined, limit: 50 }), [kind, agent]) return (
setAgent(e.target.value)} /> setKind(e.target.value)} /> 原始块按保留期限保存,可以下载后离线分析。
{(chunks.data?.items ?? []).length === 0 ? ( 没有符合条件的原始块 ) : ( {chunks.data?.items.map((c) => ( ))}
时间 采集方法 序列 时长 大小
{fmtTime(c.start)} {c.kind} {c.partial ? 不完整 : null} {c.series} {fmtNum(c.duration_ms / 1000, 1)} s {fmtNum(c.bytes / 1024, 1)} KiB 下载
)}
) } function Analysis() { const state = useQuery(() => api.get('/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 (
服务状态
{health.data ? (
{health.data.status} 数据库 {health.data.database ? '正常' : '异常'} 原始数据目录 {health.data.raw_directory ? '正常' : '异常'}
) : ( )}
指标重算

新增或修改指标之后,后台会在保留期内的原始数据上自动重算,这里是进度。

{d ? (
状态
{d.state}
重算的指标
{d.metrics.length ? d.metrics.join(', ') : '—'}
进度
{fmtNum(d.windows_done)} / {fmtNum(d.windows_total)} 个窗口
排队中的窗口
{fmtNum(d.queue_length)}
) : ( )}
) } export function System() { const [tab, setTab] = useState('settings') return ( <>

系统

{tab === 'settings' ? : tab === 'versions' ? : tab === 'tokens' ? : tab === 'users' ? : tab === 'trust' ? ( <> ) : tab === 'audit' ? : tab === 'events' ? : tab === 'raw' ? : }
) }