feat: node info, build info and acrylic web theme

Squash of recent dev work: return_session login, backend switcher, line
detail timeline, overview strips, node platform/country/ISP info with flags
and icons, build info (version, tag, time) behind site.show_build, and the
acrylic display theme.
This commit is contained in:
justme committed 2026-10-06 21:24:25 +08:00
1 parent 10634bdd80
commit 14b0de3544
95 files changed
+5524 -528

No files matched your search

+45 -12
View File
@@ -1,11 +1,12 @@
import { Plus, Trash2 } from 'lucide-react'
import { Ban, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { AgentDto, CreatedJoinTokenDto, JoinTokenDto, Page } from '@shared/api/types'
import { fmtAgo, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Confirm, CopyButton, Dialog, Dot, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
import { NodeTag, useNodeInfos } from '@shared/nodes'
import { Chip, CommandTabs, Confirm, CopyButton, Dialog, Dot, Empty, ErrorNotice, Field, guessPlatform, Segmented, Spinner, useAction } from '@shared/ui'
export const STATUS: Record<string, { label: string; tone: 'ok' | 'warn' | 'bad' }> = {
active: { label: '已启用', tone: 'ok' },
@@ -45,11 +46,16 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
>
<div className="stack">
<div className="notice warn">令牌明文只显示这一次,请现在就复制。</div>
<Field label="在要加入的 VPS 上运行">
<pre className="code">{created.install_command}</pre>
<div>
<CopyButton text={created.install_command} label="复制安装命令" />
</div>
<Field label="在要加入的机器上运行">
<CommandTabs
storageKey="monitor.install_tab"
initial={guessPlatform()}
tabs={[
{ id: 'linux', label: 'Linux', command: created.install_commands.linux },
{ id: 'macos', label: 'macOS', command: created.install_commands.macos },
{ id: 'windows', label: 'Windows', command: created.install_commands.windows },
]}
/>
</Field>
<Field label="令牌">
<pre className="code">{created.plaintext}</pre>
@@ -131,10 +137,12 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
export function Agents() {
const navigate = useNavigate()
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [], 10_000)
const infos = useNodeInfos()
const tokens = useQuery(() => api.get<Page<JoinTokenDto>>('/api/admin/join-tokens'), [])
const [filter, setFilter] = useState<'all' | 'pending' | 'disabled'>('all')
const [adding, setAdding] = useState(false)
const [revoking, setRevoking] = useState<JoinTokenDto>()
const [deleting, setDeleting] = useState<JoinTokenDto>()
const { run } = useAction()
const list = (agents.data?.items ?? []).filter((a) => filter === 'all' || a.status === filter)
@@ -187,7 +195,12 @@ export function Agents() {
<td>
<AgentStatus agent={a} />
</td>
<td style={{ fontWeight: 600 }}>{a.name}</td>
<td style={{ fontWeight: 600 }}>
<span className="row" style={{ gap: 8 }}>
{a.name}
<NodeTag info={infos.get(a.id)} />
</span>
</td>
<td className="mono muted">{a.public_host || '—'}</td>
<td className="muted">{a.agent_version ?? '—'}</td>
<td>
@@ -240,12 +253,18 @@ export function Agents() {
<Chip tone={state === '可用' ? 'ok' : ''}>{state}</Chip>
</td>
<td className="r">
{state === '可用' ? (
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
<span className="row" style={{ gap: 8, justifyContent: 'flex-end' }}>
{state === '可用' ? (
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
<Ban size={14} />
吊销
</button>
) : null}
<button type="button" className="btn small danger" onClick={() => setDeleting(t)}>
<Trash2 size={14} />
吊销
删除
</button>
) : null}
</span>
</td>
</tr>
)
@@ -271,6 +290,20 @@ export function Agents() {
吊销之后,这个令牌不能再用来注册 Agent。已经注册的 Agent 不受影响。
</Confirm>
) : null}
{deleting ? (
<Confirm
title="删除加入令牌"
danger
confirmText="删除"
onClose={() => setDeleting(undefined)}
onConfirm={async () => {
await run(() => api.del(`/api/admin/join-tokens/${deleting.id}?purge=true`), '已删除')
tokens.reload()
}}
>
从列表里彻底删除这条记录。{deleting.revoked_at || deleting.uses >= deleting.max_uses || Date.parse(deleting.expires_at) < Date.now() ? '它已经不能再用了。' : '它现在还能用,删除之后会立刻失效。'}已经注册的 Agent 不受影响。
</Confirm>
) : null}
</>
)
}
+3
View File
@@ -5,6 +5,7 @@ import { api } from '@shared/api/client'
import type { Page, ProbeDto, ProbeIdentityDto } from '@shared/api/types'
import { fmtAgo, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { NodeTag, useNodeInfos } from '@shared/nodes'
import { Chip, Confirm, CopyButton, Dialog, downloadText, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
/** probe.json 只在创建和轮换密钥时返回一次:这里让管理员马上下载或复制。 */
@@ -84,6 +85,7 @@ type Pending = { kind: 'rotate' | 'revoke' | 'delete'; probe: ProbeDto }
export function Probes() {
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 8000)
const infos = useNodeInfos()
const { run } = useAction()
const [creating, setCreating] = useState(false)
const [renaming, setRenaming] = useState<ProbeDto>()
@@ -123,6 +125,7 @@ export function Probes() {
<div key={p.id} className="card">
<div className="row wrap">
<span style={{ fontSize: 18, fontWeight: 700 }}>{p.name}</span>
<NodeTag info={infos.get(p.id)} />
{p.revoked ? <Chip tone="bad">已吊销</Chip> : <Chip tone={p.connections.length > 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${p.connections.length} 个 Agent` : '没有连接'}</Chip>}
<span className="mono faint">{p.id}</span>
<span className="right row wrap">
+76 -3
View File
@@ -1,13 +1,14 @@
import { Download, KeyRound, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { api } from '@shared/api/client'
import type { ApiTokenDto, CreatedApiTokenDto, EventDto, Page, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import type { AgentDto, ApiTokenDto, BuildInfoDto, 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' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis'
type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis'
function Settings() {
const settings = useQuery(() => api.get<Page<SettingDto>>('/api/admin/settings'), [])
@@ -76,6 +77,77 @@ function Settings() {
)
}
/** 一行:名字、类型、版本、tag、提交、构建时间、系统。没有的写「—」。 */
function BuildRow({ name, kind, version, tag, commit, builtAt, platform }: { name: string; kind: string; version?: string | null; tag?: string | null; commit?: string | null; builtAt?: string | null; platform?: string }) {
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>
</tr>
)
}
/** 每个程序的版本:后端、前端、每个 Agent 和 Probe 自己上报的(它们没升级就能看出来)。 */
function Versions() {
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)
return (
<div className="card">
<ErrorNotice error={info.error} />
<table className="table">
<thead>
<tr>
<th>名称</th>
<th>类型</th>
<th>版本</th>
<th>tag</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} /> : 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>
</tr>
))}
</tbody>
</table>
{nodes.length === 0 && !info.loading ? <Empty>还没有节点上报过版本</Empty> : null}
<p className="muted" style={{ marginTop: 12 }}>
版本、tag 和构建时间是各个程序自己上报的。设置里打开「site.show_build」之后,展示页底部和节点卡片上才会给访客显示(提交号只在这里显示)。
</p>
</div>
)
}
function Tokens() {
const tokens = useQuery(() => api.get<Page<ApiTokenDto>>('/api/admin/api-tokens'), [])
const { run } = useAction()
@@ -418,6 +490,7 @@ export function System() {
<Segmented
options={[
{ value: 'settings' as const, label: '设置' },
{ value: 'versions' as const, label: '版本' },
{ value: 'tokens' as const, label: 'API 令牌' },
{ value: 'users' as const, label: '用户' },
{ value: 'events' as const, label: '事件' },
@@ -427,7 +500,7 @@ export function System() {
value={tab}
onChange={setTab}
/>
{tab === 'settings' ? <Settings /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
{tab === 'settings' ? <Settings /> : tab === 'versions' ? <Versions /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
</div>
</>
)