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 (
定时重启(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} ) } /** 节点宿主机上的更新器报告的状态;没装更新器(二进制安装、旧版本)的节点没有。 */ 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' }, } 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」之后,展示页底部和节点卡片上才会给访客显示(提交号只在这里显示)。 「更新」一列来自节点宿主机上的更新器:清单是 CI 签名的,面板只负责通知和请求,验签和拉取都在节点上完成;没装更新器的节点这一列是「—」。

) } function Tokens() { const tokens = useQuery(() => api.get>('/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() const [revoking, setRevoking] = useState() return (
给脚本和其他程序用的令牌:read 只能读展示数据,admin 可以调用全部管理接口。
{(tokens.data?.items ?? []).length === 0 ? ( 没有 API 令牌 ) : ( {tokens.data?.items.map((t) => ( ))}
名称 权限 创建 最后使用
{t.name} {t.scope} {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 />
) : null} {created ? ( setCreated(undefined)} actions={}>
令牌明文只显示这一次,请现在就复制。
{created.plaintext}
) : null} {revoking ? ( setRevoking(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/api-tokens/${revoking.id}`), '已吊销'); tokens.reload() }}> 使用「{revoking.name}」的程序会立刻失去访问权限。 ) : 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 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 === 'events' ? : tab === 'raw' ? : }
) }