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 (
<>
「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}
| 名称 |
类型 |
版本 |
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」之后,展示页底部和节点卡片上才会给访客显示(提交号只在这里显示)。
「更新」一列来自节点: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 令牌
) : (
)}
{creating ? (
) : null}
{created ? (
) : 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 (
<>
>
)
}
/** 审计:谁在什么时候做了什么。写操作、登录成败都在里面,保留一年。 */
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 (
)
}
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 === 'trust' ? (
<>
>
) : tab === 'audit' ?
: tab === 'events' ?
: tab === 'raw' ?
:
}
>
)
}