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:
1 parent
10634bdd80
commit
14b0de3544
95 files changed
+5524
-528
No files matched your search
+78
-34
@@ -1,7 +1,7 @@
|
||||
import { FlaskConical, ListChecks, LogOut, Radar, Server, Settings } from 'lucide-react'
|
||||
import { FlaskConical, ListChecks, LogOut, Plus, Radar, Server, Settings } from 'lucide-react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { isRemote } from '@shared/api/source'
|
||||
import { activateSource, getBase, listSources } from '@shared/api/source'
|
||||
import { describeError, Field, SourceButton, Spinner, ThemeToggle } from '@shared/ui'
|
||||
import { useAuth } from './auth'
|
||||
import { AgentDetail } from './pages/AgentDetail'
|
||||
@@ -11,22 +11,34 @@ import { System } from './pages/System'
|
||||
import { Tasks } from './pages/Tasks'
|
||||
import { Tests } from './pages/Tests'
|
||||
|
||||
/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边都是用户名 + 密码。 */
|
||||
function Login() {
|
||||
const { login, loginWithToken } = useAuth()
|
||||
const remote = isRemote()
|
||||
const [token, setTokenText] = useState('')
|
||||
const { login } = useAuth()
|
||||
const active = getBase()
|
||||
const saved = listSources()
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const choose = (base: string) => {
|
||||
setAdding(false)
|
||||
setError('')
|
||||
setPassword('')
|
||||
if (base !== active) {
|
||||
activateSource(base)
|
||||
location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
if (remote) await loginWithToken(token)
|
||||
else await login(username, password)
|
||||
await login(username, password, adding ? url : undefined)
|
||||
} catch (err) {
|
||||
setError(describeError(err))
|
||||
} finally {
|
||||
@@ -34,35 +46,67 @@ function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
const rows = [{ base: '', label: location.host }, ...saved.map((x) => ({ base: x.base, label: x.base.replace(/^https?:\/\//i, '') }))]
|
||||
const title = adding ? '添加后端' : active ? active.replace(/^https?:\/\//i, '') : location.origin
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<form className="card stack" onSubmit={submit}>
|
||||
<div>
|
||||
<h1>线路质量</h1>
|
||||
<p className="muted">管理端</p>
|
||||
</div>
|
||||
{remote ? (
|
||||
<Field label="管理令牌" hint="在管理端「系统 → API 令牌」里创建,范围选 admin">
|
||||
<input className="input" type="password" value={token} onChange={(e) => setTokenText(e.target.value)} autoComplete="off" autoFocus required />
|
||||
<form className="card login-split" onSubmit={submit}>
|
||||
<aside className="login-side">
|
||||
<div>
|
||||
<h1>MyMonitor</h1>
|
||||
<p className="muted">管理端</p>
|
||||
</div>
|
||||
<div className="src-list" role="listbox" aria-label="后端">
|
||||
{rows.map((row) => {
|
||||
const on = !adding && row.base === active
|
||||
return (
|
||||
<button key={row.base || 'page'} type="button" role="option" aria-selected={on} className={`src-pick${on ? ' on' : ''}`} onClick={() => choose(row.base)}>
|
||||
<span className="src-dot" />
|
||||
<span className="src-text">
|
||||
<b>{row.label}</b>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={adding}
|
||||
className={`src-pick${adding ? ' on' : ''}`}
|
||||
onClick={() => {
|
||||
setAdding(true)
|
||||
setUrl('')
|
||||
setPassword('')
|
||||
setError('')
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span className="src-text">
|
||||
<b>添加后端</b>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<section className="login-main stack">
|
||||
<h2>{title}</h2>
|
||||
{adding ? (
|
||||
<Field label="后端地址">
|
||||
<input className="input" name="backend-url" type="text" inputMode="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required />
|
||||
</Field>
|
||||
) : null}
|
||||
<Field label="用户名">
|
||||
<input className="input" name="username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoFocus={!adding} required />
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
<Field label="用户名">
|
||||
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoFocus required />
|
||||
</Field>
|
||||
<Field label="密码">
|
||||
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{error ? <div className="notice bad">{error}</div> : null}
|
||||
<button className="btn primary" type="submit" disabled={busy}>
|
||||
登录
|
||||
</button>
|
||||
<Field label="密码">
|
||||
<input className="input" name="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
|
||||
</Field>
|
||||
{error ? <div className="notice bad">{error}</div> : null}
|
||||
<button className="btn primary" type="submit" disabled={busy}>
|
||||
{adding ? '保存并登录' : '登录'}
|
||||
</button>
|
||||
</section>
|
||||
</form>
|
||||
<div style={{ position: 'fixed', right: 16, bottom: 16 }}>
|
||||
<SourceButton tokenLabel="管理令牌" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -88,7 +132,7 @@ export function App() {
|
||||
<div className="shell">
|
||||
<aside className="side">
|
||||
<a className="brand" href="/">
|
||||
线路质量
|
||||
MyMonitor
|
||||
</a>
|
||||
{NAV.map((group) => (
|
||||
<div key={group.section} style={{ display: 'contents' }}>
|
||||
@@ -102,6 +146,7 @@ export function App() {
|
||||
</div>
|
||||
))}
|
||||
<div className="spacer" />
|
||||
<SourceButton variant="status" />
|
||||
<div className="me">
|
||||
<span className="avatar">{(me.username ?? 'A').slice(0, 1).toUpperCase()}</span>
|
||||
<span className="grow">
|
||||
@@ -110,7 +155,6 @@ export function App() {
|
||||
{me.scope}
|
||||
</div>
|
||||
</span>
|
||||
<SourceButton tokenLabel="管理令牌" />
|
||||
<ThemeToggle />
|
||||
<button type="button" className="btn icon" onClick={() => void logout()} aria-label="退出登录" title="退出登录">
|
||||
<LogOut size={18} />
|
||||
|
||||
@@ -69,6 +69,27 @@
|
||||
background: var(--app-card);
|
||||
}
|
||||
|
||||
/* 用户名太长时省略,不要和右边的按钮叠在一起 */
|
||||
.me {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.me .grow {
|
||||
flex: 1 1 100px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.me .grow > div {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.me .btn,
|
||||
.me .avatar {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -192,3 +213,45 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 登录页:左边选后端,右边输入凭据 */
|
||||
.login .card.login-split {
|
||||
width: min(760px, 100%);
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 32px 24px;
|
||||
background: var(--app-card-inset);
|
||||
}
|
||||
|
||||
.login-main {
|
||||
padding: 32px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.login-main h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.login .card.login-split {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.login-side {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-main {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
+25
-21
@@ -1,14 +1,13 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { api, ApiError, setCsrf } from '@shared/api/client'
|
||||
import { isRemote, setToken } from '@shared/api/source'
|
||||
import { api, ApiError, loginTo, setCsrf } from '@shared/api/client'
|
||||
import { getBase, normalizeBase, setSource, setToken } from '@shared/api/source'
|
||||
import type { LoginResponse, MeDto } from '@shared/api/types'
|
||||
|
||||
interface Auth {
|
||||
me: MeDto | null
|
||||
ready: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
/** 连别的后端时用 API 令牌登录(不用 Cookie)。 */
|
||||
loginWithToken: (token: string) => Promise<void>
|
||||
/** 用户名密码登录。`base` 是新添加的后端地址(登录成功才保存);不给就登录当前连着的后端。 */
|
||||
login: (username: string, password: string, base?: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -31,35 +30,40 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
.finally(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const login = useCallback(async (username: string, password: string, newBase?: string) => {
|
||||
const base = newBase ? normalizeBase(newBase) : getBase()
|
||||
if (base) {
|
||||
// 别的后端:跨域用不了 Cookie,登录后拿回会话令牌存进浏览器(对用户不可见)
|
||||
const session = await loginTo(base, username, password)
|
||||
setSource(base, session)
|
||||
try {
|
||||
const m = await api.get<MeDto>('/api/admin/auth/me')
|
||||
setCsrf(m.csrf_token ?? null)
|
||||
setMe(m)
|
||||
} catch (e) {
|
||||
setToken('')
|
||||
throw e
|
||||
}
|
||||
return
|
||||
}
|
||||
const r = await api.post<LoginResponse>('/api/admin/auth/login', { username, password })
|
||||
setCsrf(r.csrf_token)
|
||||
setMe({ kind: 'user', scope: 'admin', user_id: r.user_id, username: r.username, csrf_token: r.csrf_token })
|
||||
}, [])
|
||||
|
||||
const loginWithToken = useCallback(async (token: string) => {
|
||||
setToken(token)
|
||||
try {
|
||||
const m = await api.get<MeDto>('/api/admin/auth/me')
|
||||
setCsrf(m.csrf_token ?? null)
|
||||
setMe(m)
|
||||
} catch (e) {
|
||||
setToken('')
|
||||
throw e
|
||||
}
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
if (isRemote()) setToken('')
|
||||
else await api.post('/api/admin/auth/logout')
|
||||
await api.post('/api/admin/auth/logout')
|
||||
} catch {
|
||||
// 会话已经失效也没关系,下面照样清掉本地的
|
||||
} finally {
|
||||
setToken('')
|
||||
setCsrf(null)
|
||||
setMe(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <AuthContext.Provider value={{ me, ready, login, loginWithToken, logout }}>{children}</AuthContext.Provider>
|
||||
return <AuthContext.Provider value={{ me, ready, login, logout }}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth(): Auth {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import '@shared/theme/base.css'
|
||||
import { configureSource } from '@shared/api/source'
|
||||
import { applyStoredTheme } from '@shared/hooks'
|
||||
import { ToastProvider } from '@shared/ui'
|
||||
import { ErrorBoundary, ToastProvider } from '@shared/ui'
|
||||
import { App } from './App'
|
||||
import { AuthProvider } from './auth'
|
||||
import './admin.css'
|
||||
@@ -15,11 +15,13 @@ applyStoredTheme()
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user