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 (
<>
「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}
| 名称 |
类型 |
版本 |
tag |
提交 |
构建时间 |
系统 |
更新 |
{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) => (
| {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 ? (
) : null}
{created ? (
) : 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 (
)
}
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 (
)
}
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' ?
:
}
>
)
}