chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 3fc4e5ca53
347 files changed
+56172

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
# 复制成 .env.local:本机开发时前端把 /api 转发到哪个后端(可以是线上的公网地址)
BACKEND_URL=https://mo.oeo.one
+26
View File
@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env.local
+27
View File
@@ -0,0 +1,27 @@
# 前端
两个独立的 Vite + React + TypeScript 工程,共享 `shared/`(主题令牌、API 客户端与类型、图表、通用组件)。
| 目录 | 内容 | 访问 |
| --- | --- | --- |
| `display/` | 展示端:Agent 卡片总览(线路质量 + 主机资源占用,实时刷新)、线路详情(延迟、丢包、抖动、TCP、UDP、评分、历史)、线路对比 | `/`,匿名可看;站点没有公开时提示登录 |
| `admin/` | 管理端:Agent(添加、审批、启停、接管、删除、加入令牌)、Probe(创建、轮换密钥、吊销、删除、连接)、任务、按需测试、系统(设置、API 令牌、用户、事件、原始数据、分析) | `/admin/`,要登录 |
| `shared/` | `theme/`(「纸墨」主题令牌和基础样式)、`api/`(客户端、从 OpenAPI 生成的类型)、`charts/`(uPlot 封装)、`ui/`(通用组件) | |
```sh
npm install # 在 web/ 下一次装好两个工程的依赖(npm workspaces)
npm run dev # 两个一起启动
BACKEND_URL=https://mo.oeo.one npm run dev:display # http://127.0.0.1:5173/
BACKEND_URL=https://mo.oeo.one npm run dev:admin # http://127.0.0.1:5174/admin/
# 或者把 .env.example 复制成 .env.local,写好 BACKEND_URL,之后直接 npm run dev:display
npm run build # 各自输出到 display/dist 和 admin/dist
npm run gen:api # 后端接口变了之后,重新生成 shared/api/schema.ts
npm run lint:css # 颜色只能写在 shared/theme/tokens.css,其他样式一律引用令牌
```
连别的后端(本机跑前端、连线上的):点页面右上角(管理端登录页右下角)的插头图标,填后端地址和 API 令牌(展示端:站点私有时需要只读令牌;管理端:admin 范围的令牌),保存后刷新。设置存在浏览器里;跨域时不带 Cookie,所以后端要设 `Monitor__CorsOrigins=<这个前端的来源>`(如 `http://localhost:5173`)。也可以用构建时的 `VITE_API_BASE` 给默认地址。
约定:
- 展示端能拿到的主机数据只有汇总的数字(CPU、内存、磁盘占用,网卡累计流量与瞬时网速,运行时长),**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——后端的公开接口不返回它们,Agent 也根本不采集。展示端不显示 Probe 的版本和来源地址(这些只在管理端)。
- 时间范围(15 分钟 … 7 天)只在线路详情页;总览是卡片,Agent 主机状态通过 SSE(`/api/public/live?topics=hosts`)实时更新,其余每 10 秒刷新。
- 生产部署时,把 `display/dist` 放在站点根、`admin/dist` 放在 `/admin/` 下(同源,Cookie 和 CSRF 才能工作)。
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>线路质量 · 管理</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{
"name": "monitor-admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint:css": "stylelint \"src/**/*.css\" \"../shared/**/*.css\""
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.1.0",
"uplot": "^1.6.31"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"stylelint": "^16.12.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
+134
View File
@@ -0,0 +1,134 @@
import { FlaskConical, ListChecks, LogOut, 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 { describeError, Field, SourceButton, Spinner, ThemeToggle } from '@shared/ui'
import { useAuth } from './auth'
import { AgentDetail } from './pages/AgentDetail'
import { Agents } from './pages/Agents'
import { Probes } from './pages/Probes'
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 [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const submit = async (e: FormEvent) => {
e.preventDefault()
setBusy(true)
setError('')
try {
if (remote) await loginWithToken(token)
else await login(username, password)
} catch (err) {
setError(describeError(err))
} finally {
setBusy(false)
}
}
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 />
</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>
</form>
<div style={{ position: 'fixed', right: 16, bottom: 16 }}>
<SourceButton tokenLabel="管理令牌" />
</div>
</div>
)
}
const NAV = [
{ section: '运维', items: [{ to: '/agents', label: 'Agent', icon: Server }, { to: '/probes', label: 'Probe', icon: Radar }] },
{ section: '测试', items: [{ to: '/tasks', label: '任务', icon: ListChecks }, { to: '/tests', label: '按需测试', icon: FlaskConical }] },
{ section: '系统', items: [{ to: '/system', label: '系统', icon: Settings }] },
]
export function App() {
const { me, ready, logout } = useAuth()
if (!ready) {
return (
<div className="login">
<Spinner />
</div>
)
}
if (!me) return <Login />
return (
<div className="shell">
<aside className="side">
<a className="brand" href="/">
线路质量
</a>
{NAV.map((group) => (
<div key={group.section} style={{ display: 'contents' }}>
<div className="section">{group.section}</div>
{group.items.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => `nav ${isActive ? 'active' : ''}`}>
<Icon size={20} />
{label}
</NavLink>
))}
</div>
))}
<div className="spacer" />
<div className="me">
<span className="avatar">{(me.username ?? 'A').slice(0, 1).toUpperCase()}</span>
<span className="grow">
<div style={{ fontWeight: 600 }}>{me.username ?? 'API 令牌'}</div>
<div className="faint" style={{ fontSize: 12 }}>
{me.scope}
</div>
</span>
<SourceButton tokenLabel="管理令牌" />
<ThemeToggle />
<button type="button" className="btn icon" onClick={() => void logout()} aria-label="退出登录" title="退出登录">
<LogOut size={18} />
</button>
</div>
</aside>
<div className="main">
<Routes>
<Route path="/" element={<Navigate to="/agents" replace />} />
<Route path="/agents" element={<Agents />} />
<Route path="/agents/:id" element={<AgentDetail />} />
<Route path="/probes" element={<Probes />} />
<Route path="/tasks" element={<Tasks />} />
<Route path="/tests" element={<Tests />} />
<Route path="/system" element={<System />} />
<Route path="*" element={<Navigate to="/agents" replace />} />
</Routes>
</div>
</div>
)
}
+194
View File
@@ -0,0 +1,194 @@
/* 管理端:左侧导航 + 内容区(M3 风格:选中项是胶囊,内容区是圆角卡片) */
.shell {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
min-height: 100%;
}
.side {
position: sticky;
top: 0;
height: 100vh;
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 12px;
background: var(--app-card-inset);
overflow: auto;
}
.side .brand {
padding: 8px 14px 16px;
font-size: 18px;
font-weight: 700;
color: var(--app-ink);
display: block;
}
.side .brand:hover {
text-decoration: none;
}
.side .section {
padding: 16px 14px 4px;
font-size: 12px;
color: var(--app-ink-faint);
}
.side a.nav {
display: flex;
align-items: center;
gap: 12px;
height: 44px;
padding: 0 14px;
border-radius: var(--r-pill);
color: var(--app-ink);
font-weight: 500;
}
.side a.nav:hover {
text-decoration: none;
background: color-mix(in srgb, var(--app-ink), transparent calc((1 - var(--state-hover)) * 100%));
}
.side a.nav.active {
background: var(--app-accent-container);
color: var(--app-on-accent-container);
}
.side .spacer {
flex: 1;
}
.me {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--r-card);
background: var(--app-card);
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--app-accent);
color: var(--app-card);
display: grid;
place-items: center;
font-weight: 600;
}
.main {
padding: 24px 32px 64px;
min-width: 0;
}
.main > header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.main > header h1 {
font-size: 24px;
font-weight: 700;
}
.login {
min-height: 100%;
display: grid;
place-items: center;
padding: 24px;
}
.login .card {
width: min(400px, 100%);
padding: 32px;
}
.login h1 {
font-size: 28px;
font-weight: 800;
margin-bottom: 4px;
}
.kv {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
gap: 8px 16px;
}
.kv dt {
color: var(--app-ink-faint);
}
.kv dd {
margin: 0;
min-width: 0;
overflow-wrap: anywhere;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.form-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-grid .wide {
grid-column: 1 / -1;
}
.switch {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.switch input {
accent-color: var(--app-accent);
width: 18px;
height: 18px;
}
.test-status {
text-transform: none;
}
@media (max-width: 900px) {
.shell {
grid-template-columns: minmax(0, 1fr);
}
.side {
position: static;
height: auto;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
.side .section,
.side .spacer,
.me .grow {
display: none;
}
.main {
padding: 16px;
}
.form-grid {
grid-template-columns: minmax(0, 1fr);
}
}
+69
View File
@@ -0,0 +1,69 @@
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 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>
logout: () => Promise<void>
}
const AuthContext = createContext<Auth | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [me, setMe] = useState<MeDto | null>(null)
const [ready, setReady] = useState(false)
useEffect(() => {
api
.get<MeDto>('/api/admin/auth/me')
.then((m) => {
setCsrf(m.csrf_token ?? null)
setMe(m)
})
.catch((e: unknown) => {
if (!(e instanceof ApiError) || (e.status !== 401 && e.status !== 403)) console.error(e)
})
.finally(() => setReady(true))
}, [])
const login = useCallback(async (username: string, password: string) => {
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')
} finally {
setCsrf(null)
setMe(null)
}
}, [])
return <AuthContext.Provider value={{ me, ready, login, loginWithToken, logout }}>{children}</AuthContext.Provider>
}
export function useAuth(): Auth {
const value = useContext(AuthContext)
if (!value) throw new Error('AuthProvider is missing')
return value
}
+25
View File
@@ -0,0 +1,25 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
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 { App } from './App'
import { AuthProvider } from './auth'
import './admin.css'
configureSource('admin')
applyStoredTheme()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter basename={import.meta.env.BASE_URL}>
<ToastProvider>
<AuthProvider>
<App />
</AuthProvider>
</ToastProvider>
</BrowserRouter>
</StrictMode>,
)
+323
View File
@@ -0,0 +1,323 @@
import { ChevronLeft, FlaskConical, Pencil, Unplug } from 'lucide-react'
import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { AgentDto, EventDto, Page, ProbeDto } from '@shared/api/types'
import { fmtAgo, fmtNum, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
import { AgentStatus } from './Agents'
function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: () => void; onSaved: () => void }) {
const { busy, run } = useAction()
const [name, setName] = useState(agent.name)
const [labels, setLabels] = useState(agent.labels.join(', '))
const [host, setHost] = useState(agent.public_host)
const [visible, setVisible] = useState(agent.public_visible)
return (
<Dialog
title="修改 Agent"
onClose={onClose}
actions={
<>
<button type="button" className="btn text" onClick={onClose}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={busy}
onClick={async () => {
const ok = await run(
() => api.patch(`/api/admin/agents/${agent.id}`, { name, labels: labels.split(',').map((x) => x.trim()).filter(Boolean), public_host: host, public_visible: visible }),
'已保存',
)
if (ok) {
onSaved()
onClose()
}
}}
>
保存
</button>
</>
}
>
<div className="form-grid">
<div className="wide">
<Field label="名称" hint="字母、数字、点、下划线和连字符">
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
</Field>
</div>
<div className="wide">
<Field label="公开地址" hint="Probe 用它连接这个 Agent:域名或 IP">
<input className="input" value={host} onChange={(e) => setHost(e.target.value)} />
</Field>
</div>
<div className="wide">
<Field label="标签" hint="逗号分隔">
<input className="input" value={labels} onChange={(e) => setLabels(e.target.value)} />
</Field>
</div>
<div className="wide">
<label className="switch">
<input type="checkbox" checked={visible} onChange={(e) => setVisible(e.target.checked)} />
在展示页对匿名访客可见
</label>
</div>
</div>
</Dialog>
)
}
type Action = 'approve' | 'disable' | 'enable' | 'takeover' | 'delete'
const ACTIONS: Record<Action, { label: string; text: string; danger?: boolean }> = {
approve: { label: '审批通过', text: '审批之后,这个 Agent 会被启用并加入所有 Probe 的列表。' },
enable: { label: '启用', text: '启用之后,这个 Agent 会重新加入 Probe 的列表。' },
disable: { label: '停用', text: '停用之后,Agent 的连接会被断开,它也会从 Probe 的列表里消失。数据保留。', danger: true },
takeover: { label: '接管同名节点', text: '用这个待审批的节点替换同名的旧节点,并保留旧节点的历史数据。', danger: true },
delete: { label: '删除', text: '删除 Agent。已经入库的数据按保留策略自然过期。', danger: true },
}
export function AgentDetail() {
const { id = '' } = useParams()
const navigate = useNavigate()
const agent = useQuery(() => api.get<AgentDto>(`/api/admin/agents/${id}`), [id], 5000)
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [])
const events = useQuery(() => api.get<Page<EventDto>>('/api/admin/events', { agent_id: id, limit: 20 }), [id], 15_000)
const [editing, setEditing] = useState(false)
const [pending, setPending] = useState<Action>()
const { run } = useAction()
const a = agent.data
const probeName = (probeId: string) => probes.data?.items.find((p) => p.id === probeId)?.name ?? probeId
const act = async (action: Action) => {
if (action === 'delete') {
const ok = await run(() => api.del(`/api/admin/agents/${id}`), '已删除')
if (ok) navigate('/agents')
} else {
await run(() => api.post(`/api/admin/agents/${id}/${action}`), '已完成')
agent.reload()
}
}
return (
<>
<Link to="/agents" className="row muted" style={{ marginBottom: 8 }}>
<ChevronLeft size={16} />
Agent
</Link>
<header>
<h1>{a?.name ?? id}</h1>
{a ? <AgentStatus agent={a} /> : null}
<span className="right row wrap">
{agent.loading && !a ? <Spinner /> : null}
{a ? (
<>
<button type="button" className="btn" onClick={() => setEditing(true)}>
<Pencil size={16} />
修改
</button>
{a.status === 'pending' ? (
<>
<button type="button" className="btn primary" onClick={() => setPending('approve')}>
{ACTIONS.approve.label}
</button>
<button type="button" className="btn" onClick={() => setPending('takeover')}>
{ACTIONS.takeover.label}
</button>
</>
) : a.status === 'disabled' ? (
<button type="button" className="btn" onClick={() => setPending('enable')}>
{ACTIONS.enable.label}
</button>
) : (
<button type="button" className="btn" onClick={() => setPending('disable')}>
{ACTIONS.disable.label}
</button>
)}
<button type="button" className="btn danger" onClick={() => setPending('delete')}>
{ACTIONS.delete.label}
</button>
</>
) : null}
</span>
</header>
<ErrorNotice error={agent.error} />
{a ? (
<div className="stack">
<div className="card">
<div className="card-title">连着的 Probe({a.probes.length})</div>
{a.probes.length === 0 ? (
<Empty>现在没有 Probe 连着这个 Agent</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>Probe</th>
<th>版本</th>
<th>来源地址</th>
<th>连接时间</th>
<th />
</tr>
</thead>
<tbody>
{a.probes.map((p) => (
<tr key={p.probe_id}>
<td>
<div style={{ fontWeight: 600 }}>{probeName(p.probe_id)}</div>
<div className="mono faint">{p.probe_id}</div>
</td>
<td>{p.version ?? '—'}</td>
<td className="mono">{p.remote ?? '—'}</td>
<td className="muted">
{fmtTime(p.connected_since)}({fmtAgo(p.connected_since)})
</td>
<td className="r">
<span className="row" style={{ justifyContent: 'flex-end' }}>
<Link className="btn small" to={`/tests?agent=${a.id}&probe=${p.probe_id}`}>
<FlaskConical size={14} />
测试
</Link>
<button
type="button"
className="btn small danger"
onClick={async () => {
await run(() => api.post(`/api/admin/agents/${a.id}/probes/${p.probe_id}/disconnect`), '已断开(Probe 会自动重连,除非已被吊销)')
agent.reload()
}}
>
<Unplug size={14} />
断开
</button>
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="grid cols-2">
<div className="card">
<div className="card-title">基本信息</div>
<dl className="kv">
<dt>ID</dt>
<dd className="mono">{a.id}</dd>
<dt>公开地址</dt>
<dd className="mono">{a.public_host || '—'}</dd>
<dt>控制端口</dt>
<dd className="mono">{a.probe_tcp_port}/tcp</dd>
<dt>数据端口</dt>
<dd className="mono">{a.probe_udp_ports.map((p) => `${p}/udp`).join(', ') || '—'}</dd>
<dt>版本</dt>
<dd>{a.agent_version ?? '—'}</dd>
<dt>展示页可见</dt>
<dd>{a.public_visible ? '是' : '否'}</dd>
<dt>注册时间</dt>
<dd>{fmtTime(a.created_at)}</dd>
<dt>最后在线</dt>
<dd>{fmtTime(a.last_seen_at)}</dd>
<dt>标签</dt>
<dd>
<div className="tags">
{a.labels.length ? a.labels.map((x) => <Chip key={x}>{x}</Chip>) : <span className="faint">—</span>}
</div>
</dd>
<dt>支持的采集</dt>
<dd>
<div className="tags">
{a.kinds.map((x) => (
<Chip key={x}>{x}</Chip>
))}
</div>
</dd>
</dl>
</div>
<div className="card">
<div className="card-title">运行状态</div>
<dl className="kv">
<dt>任务同步</dt>
<dd>
期望版本 {a.task_version},已应用 {a.applied_task_version}
{a.task_version !== a.applied_task_version ? <Chip tone="warn">未同步</Chip> : <Chip tone="ok">已同步</Chip>}
</dd>
{a.rejected.length ? (
<>
<dt>被拒绝的任务</dt>
<dd>
{a.rejected.map((r) => (
<div key={r.id}>
<span className="mono">{r.id}</span> <Chip tone="bad">{r.reason}</Chip>
</div>
))}
</dd>
</>
) : null}
<dt>NAT 端点变化</dt>
<dd>{a.endpoint_changes} 次</dd>
{a.heartbeat ? (
<>
<dt>心跳</dt>
<dd>{fmtAgo(a.heartbeat.at)}</dd>
<dt>收发包速率</dt>
<dd className="num">
入 {fmtNum(a.heartbeat.pps_in)} pps / 出 {fmtNum(a.heartbeat.pps_out)} pps
</dd>
<dt>丢弃(无效 / 限速)</dt>
<dd className="num">
{fmtNum(a.heartbeat.dropped_invalid)} / {fmtNum(a.heartbeat.dropped_rate_limited)}
</dd>
<dt>待上报队列</dt>
<dd className="num">{fmtNum(a.heartbeat.queue_bytes / 1024)} KiB</dd>
</>
) : null}
{a.local_limits ? (
<>
<dt>本地上限</dt>
<dd className="num">
{fmtNum(a.local_limits.max_total_mbps)} Mbit/s,{fmtNum(a.local_limits.max_total_pps)} pps,{a.local_limits.max_concurrent_tests} 个并发测试
</dd>
</>
) : null}
</dl>
</div>
</div>
<div className="card">
<div className="card-title">最近事件</div>
{(events.data?.items ?? []).length === 0 ? (
<Empty>没有事件</Empty>
) : (
<table className="table">
<tbody>
{events.data?.items.map((e) => (
<tr key={e.id}>
<td className="muted num" style={{ width: 160 }}>
{fmtTime(e.at)}
</td>
<td style={{ width: 200 }}>{e.kind}</td>
<td className="mono muted">{e.detail ?? ''}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
) : null}
{editing && a ? <EditDialog agent={a} onClose={() => setEditing(false)} onSaved={agent.reload} /> : null}
{pending ? (
<Confirm title={ACTIONS[pending].label} danger={ACTIONS[pending].danger} confirmText={ACTIONS[pending].label} onClose={() => setPending(undefined)} onConfirm={() => act(pending)}>
{ACTIONS[pending].text}
</Confirm>
) : null}
</>
)
}
+276
View File
@@ -0,0 +1,276 @@
import { 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'
export const STATUS: Record<string, { label: string; tone: 'ok' | 'warn' | 'bad' }> = {
active: { label: '已启用', tone: 'ok' },
pending: { label: '待审批', tone: 'warn' },
disabled: { label: '已停用', tone: 'bad' },
}
export function AgentStatus({ agent }: { agent: Pick<AgentDto, 'status' | 'online'> }) {
const s = STATUS[agent.status] ?? { label: agent.status, tone: 'warn' as const }
return (
<span className="row" style={{ gap: 8 }}>
<Dot tone={agent.online ? 'ok' : 'bad'} />
<Chip tone={s.tone}>{s.label}</Chip>
</span>
)
}
function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const { busy, run } = useAction()
const [note, setNote] = useState('')
const [labels, setLabels] = useState('')
const [autoApprove, setAutoApprove] = useState(true)
const [maxUses, setMaxUses] = useState(1)
const [hours, setHours] = useState(24)
const [created, setCreated] = useState<CreatedJoinTokenDto>()
if (created) {
return (
<Dialog
title="加入令牌已创建"
onClose={onClose}
actions={
<button type="button" className="btn primary" onClick={onClose}>
完成
</button>
}
>
<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>
<Field label="令牌">
<pre className="code">{created.plaintext}</pre>
<div>
<CopyButton text={created.plaintext} label="复制令牌" />
</div>
</Field>
<p className="muted">
{created.token.auto_approve ? '使用这个令牌注册的 Agent 会自动启用。' : '使用这个令牌注册的 Agent 需要你在列表里审批之后才会启用。'} 可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。
</p>
</div>
</Dialog>
)
}
return (
<Dialog
title="添加 Agent"
onClose={onClose}
actions={
<>
<button type="button" className="btn text" onClick={onClose}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={busy}
onClick={async () => {
await run(async () => {
const token = await api.post<CreatedJoinTokenDto>('/api/admin/join-tokens', {
note: note || null,
labels: labels.split(',').map((x) => x.trim()).filter(Boolean),
auto_approve: autoApprove,
max_uses: maxUses,
expires_in_hours: hours,
})
setCreated(token)
onCreated()
})
}}
>
创建加入令牌
</button>
</>
}
>
<p className="muted" style={{ marginBottom: 16 }}>
添加 Agent 就是创建一个一次性的加入令牌:在目标 VPS 上运行安装命令,Agent 会用它注册并出现在列表里。
</p>
<div className="form-grid">
<div className="wide">
<Field label="备注" hint="例如这台 VPS 的位置或用途">
<input className="input" value={note} onChange={(e) => setNote(e.target.value)} placeholder="HK-1" />
</Field>
</div>
<div className="wide">
<Field label="标签" hint="逗号分隔,注册后会带在这个 Agent 上">
<input className="input" value={labels} onChange={(e) => setLabels(e.target.value)} placeholder="hk, cn2" />
</Field>
</div>
<Field label="可用次数">
<input className="input" type="number" min={1} max={100} value={maxUses} onChange={(e) => setMaxUses(Number(e.target.value))} />
</Field>
<Field label="有效期(小时)">
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
</Field>
<div className="wide">
<label className="switch">
<input type="checkbox" checked={autoApprove} onChange={(e) => setAutoApprove(e.target.checked)} />
注册后自动启用(不勾选则需要手动审批)
</label>
</div>
</div>
</Dialog>
)
}
export function Agents() {
const navigate = useNavigate()
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [], 10_000)
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 { run } = useAction()
const list = (agents.data?.items ?? []).filter((a) => filter === 'all' || a.status === filter)
const pending = (agents.data?.items ?? []).filter((a) => a.status === 'pending').length
return (
<>
<header>
<h1>Agent</h1>
<span className="right row">
{agents.loading && !agents.data ? <Spinner /> : null}
<button type="button" className="btn primary" onClick={() => setAdding(true)}>
<Plus size={18} />
添加 Agent
</button>
</span>
</header>
<ErrorNotice error={agents.error} />
<div className="stack">
<Segmented
options={[
{ value: 'all' as const, label: '全部' },
{ value: 'pending' as const, label: `待审批${pending ? ` (${pending})` : ''}` },
{ value: 'disabled' as const, label: '已停用' },
]}
value={filter}
onChange={setFilter}
/>
<div className="card" style={{ padding: '8px 12px' }}>
{list.length === 0 ? (
<Empty>{agents.data ? '没有符合条件的 Agent。点击右上角「添加 Agent」创建加入令牌。' : '加载中…'}</Empty>
) : (
<table className="table hover">
<thead>
<tr>
<th>状态</th>
<th>名称</th>
<th>地址</th>
<th>版本</th>
<th>连着的 Probe</th>
<th>最后在线</th>
<th>标签</th>
</tr>
</thead>
<tbody>
{list.map((a) => (
<tr key={a.id} onClick={() => navigate(`/agents/${a.id}`)}>
<td>
<AgentStatus agent={a} />
</td>
<td style={{ fontWeight: 600 }}>{a.name}</td>
<td className="mono muted">{a.public_host || '—'}</td>
<td className="muted">{a.agent_version ?? '—'}</td>
<td>
<Chip tone={a.probes.length > 0 ? 'ok' : ''}>{a.probes.length} 个</Chip>
</td>
<td className="muted">{fmtAgo(a.last_seen_at)}</td>
<td>
<div className="tags">
{a.labels.map((x) => (
<Chip key={x}>{x}</Chip>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="card">
<div className="card-title">加入令牌</div>
{(tokens.data?.items ?? []).length === 0 ? (
<Empty>没有加入令牌</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>备注</th>
<th>使用</th>
<th>注册后</th>
<th>过期</th>
<th>状态</th>
<th />
</tr>
</thead>
<tbody>
{tokens.data?.items.map((t) => {
const expired = Date.parse(t.expires_at) < Date.now()
const state = t.revoked_at ? '已吊销' : expired ? '已过期' : t.uses >= t.max_uses ? '已用完' : '可用'
return (
<tr key={t.id}>
<td>{t.note || <span className="faint">—</span>}</td>
<td className="num">
{t.uses} / {t.max_uses}
</td>
<td>{t.auto_approve ? '自动启用' : '需要审批'}</td>
<td className="muted">{fmtTime(t.expires_at)}</td>
<td>
<Chip tone={state === '可用' ? 'ok' : ''}>{state}</Chip>
</td>
<td className="r">
{state === '可用' ? (
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
<Trash2 size={14} />
吊销
</button>
) : null}
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
{adding ? <AddAgentDialog onClose={() => setAdding(false)} onCreated={tokens.reload} /> : null}
{revoking ? (
<Confirm
title="吊销加入令牌"
danger
confirmText="吊销"
onClose={() => setRevoking(undefined)}
onConfirm={async () => {
await run(() => api.del(`/api/admin/join-tokens/${revoking.id}`), '已吊销')
tokens.reload()
}}
>
吊销之后,这个令牌不能再用来注册 Agent。已经注册的 Agent 不受影响。
</Confirm>
) : null}
</>
)
}
+245
View File
@@ -0,0 +1,245 @@
import { Download, KeyRound, Pencil, Plus, ShieldOff, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { Link } from 'react-router-dom'
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 { Chip, Confirm, CopyButton, Dialog, downloadText, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
/** probe.json 只在创建和轮换密钥时返回一次:这里让管理员马上下载或复制。 */
function IdentityDialog({ identity, title, onClose }: { identity: ProbeIdentityDto; title: string; onClose: () => void }) {
const json = JSON.stringify(identity, null, 2)
return (
<Dialog
title={title}
onClose={onClose}
actions={
<button type="button" className="btn primary" onClick={onClose}>
我已保存
</button>
}
>
<div className="stack">
<div className="notice warn">私钥只显示这一次,关闭之后无法再找回。请现在保存 probe.json。</div>
<div className="row wrap">
<button type="button" className="btn primary" onClick={() => downloadText('probe.json', json)}>
<Download size={16} />
下载 probe.json
</button>
<CopyButton text={json} label="复制内容" />
</div>
<Field label="在家宽机器上" hint="保存为 /etc/monitor/probe.json,然后启动 Probe;它会根据 bootstrap 列表连接 Agent,之后的 Agent 列表由面板同步。">
<pre className="code">{`monitor-probe --config /etc/monitor/probe.json`}</pre>
</Field>
<details>
<summary className="muted" style={{ cursor: 'pointer' }}>
查看内容
</summary>
<pre className="code" style={{ marginTop: 8 }}>
{json}
</pre>
</details>
</div>
</Dialog>
)
}
function NameDialog({ title, initial, onClose, onSubmit }: { title: string; initial: string; onClose: () => void; onSubmit: (name: string) => Promise<boolean> }) {
const [name, setName] = useState(initial)
const [busy, setBusy] = useState(false)
return (
<Dialog
title={title}
onClose={onClose}
actions={
<>
<button type="button" className="btn text" onClick={onClose}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={busy || !name.trim()}
onClick={async () => {
setBusy(true)
const ok = await onSubmit(name.trim())
setBusy(false)
if (ok) onClose()
}}
>
确定
</button>
</>
}
>
<Field label="名称" hint="字母、数字、点、下划线和连字符,例如 home 或 office">
<input className="input" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</Field>
</Dialog>
)
}
type Pending = { kind: 'rotate' | 'revoke' | 'delete'; probe: ProbeDto }
export function Probes() {
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 8000)
const { run } = useAction()
const [creating, setCreating] = useState(false)
const [renaming, setRenaming] = useState<ProbeDto>()
const [pending, setPending] = useState<Pending>()
const [identity, setIdentity] = useState<{ title: string; value: ProbeIdentityDto }>()
const list = probes.data?.items ?? []
const confirmText = {
rotate: { title: '轮换密钥', danger: true, text: '旧密钥立即失效,所有 Agent 会断开这个 Probe 的会话。你需要把新的 probe.json 放到这台 Probe 上并重启它。' },
revoke: { title: '吊销 Probe', danger: true, text: '所有 Agent 会立刻断开并拒绝这个 Probe。记录和历史数据保留,但它不能再连接,也不能恢复。' },
delete: { title: '删除 Probe', danger: true, text: '删除这个 Probe 和它的线路。已经入库的数据按保留策略自然过期。' },
}
return (
<>
<header>
<h1>Probe</h1>
<span className="right row">
{probes.loading && !probes.data ? <Spinner /> : null}
<button type="button" className="btn primary" onClick={() => setCreating(true)}>
<Plus size={18} />
创建 Probe
</button>
</span>
</header>
<ErrorNotice error={probes.error} />
<div className="stack">
{probes.data && list.length === 0 ? (
<div className="card">
<Empty>还没有 Probe。点击右上角「创建 Probe」,由后端生成身份,再把 probe.json 放到家宽机器上。</Empty>
</div>
) : null}
{list.map((p) => (
<div key={p.id} className="card">
<div className="row wrap">
<span style={{ fontSize: 18, fontWeight: 700 }}>{p.name}</span>
{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">
<button type="button" className="btn small" onClick={() => setRenaming(p)}>
<Pencil size={14} />
改名
</button>
{!p.revoked ? (
<>
<button type="button" className="btn small" onClick={() => setPending({ kind: 'rotate', probe: p })}>
<KeyRound size={14} />
轮换密钥
</button>
<button type="button" className="btn small danger" onClick={() => setPending({ kind: 'revoke', probe: p })}>
<ShieldOff size={14} />
吊销
</button>
</>
) : null}
<button type="button" className="btn small danger" onClick={() => setPending({ kind: 'delete', probe: p })}>
<Trash2 size={14} />
删除
</button>
</span>
</div>
<p className="faint" style={{ marginTop: 4 }}>
创建于 {fmtTime(p.created_at)}
{p.revoked_at ? `,吊销于 ${fmtTime(p.revoked_at)}` : ''}
</p>
{p.connections.length > 0 ? (
<table className="table" style={{ marginTop: 8 }}>
<thead>
<tr>
<th>Agent</th>
<th>版本</th>
<th>来源地址</th>
<th>连接时间</th>
</tr>
</thead>
<tbody>
{p.connections.map((c) => (
<tr key={c.agent_id}>
<td>
<Link to={`/agents/${c.agent_id}`} style={{ fontWeight: 600 }}>
{c.agent_name}
</Link>
</td>
<td>{c.version ?? '—'}</td>
<td className="mono">{c.remote ?? '—'}</td>
<td className="muted">
{fmtTime(c.connected_since)}({fmtAgo(c.connected_since)})
</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
))}
</div>
{creating ? (
<NameDialog
title="创建 Probe"
initial=""
onClose={() => setCreating(false)}
onSubmit={async (name) => {
let created: ProbeIdentityDto | undefined
const ok = await run(async () => {
created = await api.post<ProbeIdentityDto>('/api/admin/probes', { name })
})
if (ok && created) {
setIdentity({ title: `Probe ${name} 已创建`, value: created })
probes.reload()
}
return ok
}}
/>
) : null}
{renaming ? (
<NameDialog
title="修改名称"
initial={renaming.name}
onClose={() => setRenaming(undefined)}
onSubmit={async (name) => {
const ok = await run(() => api.patch(`/api/admin/probes/${renaming.id}`, { name }), '已保存')
probes.reload()
return ok
}}
/>
) : null}
{pending ? (
<Confirm
title={confirmText[pending.kind].title}
danger={confirmText[pending.kind].danger}
confirmText={confirmText[pending.kind].title}
onClose={() => setPending(undefined)}
onConfirm={async () => {
const { kind, probe } = pending
if (kind === 'rotate') {
let rotated: ProbeIdentityDto | undefined
const ok = await run(async () => {
rotated = await api.post<ProbeIdentityDto>(`/api/admin/probes/${probe.id}/rotate`)
})
if (ok && rotated) setIdentity({ title: `${probe.name} 的新密钥`, value: rotated })
} else if (kind === 'revoke') {
await run(() => api.post(`/api/admin/probes/${probe.id}/revoke`), '已吊销')
} else {
await run(() => api.del(`/api/admin/probes/${probe.id}`), '已删除')
}
probes.reload()
}}
>
{confirmText[pending.kind].text}
</Confirm>
) : null}
{identity ? <IdentityDialog title={identity.title} identity={identity.value} onClose={() => setIdentity(undefined)} /> : null}
</>
)
}
+434
View File
@@ -0,0 +1,434 @@
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 { 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'
function Settings() {
const settings = useQuery(() => api.get<Page<SettingDto>>('/api/admin/settings'), [])
const { busy, run } = useAction()
const [draft, setDraft] = useState<Record<string, string>>({})
useEffect(() => setDraft({}), [settings.data])
const items = settings.data?.items ?? []
const dirty = Object.keys(draft).length > 0
return (
<div className="card">
<ErrorNotice error={settings.error} />
<table className="table">
<thead>
<tr>
<th>设置</th>
<th>值</th>
<th>说明</th>
</tr>
</thead>
<tbody>
{items.map((s) => {
const value = draft[s.key] ?? s.value
return (
<tr key={s.key}>
<td className="mono">{s.key}</td>
<td style={{ width: 200 }}>
{s.type === 'bool' ? (
<label className="switch">
<input type="checkbox" checked={value === 'true'} onChange={(e) => setDraft({ ...draft, [s.key]: String(e.target.checked) })} />
{value === 'true' ? '开' : '关'}
</label>
) : (
<input className="input" type={s.type === 'int' ? 'number' : 'text'} value={value} onChange={(e) => setDraft({ ...draft, [s.key]: e.target.value })} />
)}
</td>
<td className="muted">
{translateSetting(s.key, s.description)}
{s.value !== s.default ? <span className="faint">(默认 {s.default})</span> : null}
</td>
</tr>
)
})}
</tbody>
</table>
<div className="row" style={{ marginTop: 16 }}>
<button
type="button"
className="btn primary"
disabled={!dirty || busy}
onClick={async () => {
const body: Record<string, unknown> = {}
for (const s of items) {
if (draft[s.key] === undefined) continue
body[s.key] = s.type === 'bool' ? draft[s.key] === 'true' : s.type === 'int' ? Number(draft[s.key]) : draft[s.key]
}
await run(() => api.patch('/api/admin/settings', body), '已保存')
settings.reload()
}}
>
保存
</button>
<span className="muted">「site.public」打开之后,展示页对匿名访客公开。</span>
</div>
</div>
)
}
function Tokens() {
const tokens = useQuery(() => api.get<Page<ApiTokenDto>>('/api/admin/api-tokens'), [])
const { run } = useAction()
const [creating, setCreating] = useState(false)
const [name, setName] = useState('')
const [scope, setScope] = useState<'read' | 'admin'>('read')
const [created, setCreated] = useState<CreatedApiTokenDto>()
const [revoking, setRevoking] = useState<ApiTokenDto>()
return (
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<span className="muted">给脚本和其他程序用的令牌:read 只能读展示数据,admin 可以调用全部管理接口。</span>
<button type="button" className="btn primary right" onClick={() => setCreating(true)}>
<Plus size={18} />
创建令牌
</button>
</div>
<ErrorNotice error={tokens.error} />
{(tokens.data?.items ?? []).length === 0 ? (
<Empty>没有 API 令牌</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>名称</th>
<th>权限</th>
<th>创建</th>
<th>最后使用</th>
<th />
</tr>
</thead>
<tbody>
{tokens.data?.items.map((t) => (
<tr key={t.id}>
<td style={{ fontWeight: 600 }}>{t.name}</td>
<td>
<Chip tone={t.scope === 'admin' ? 'warn' : ''}>{t.scope}</Chip>
</td>
<td className="muted">{fmtTime(t.created_at)}</td>
<td className="muted">{t.revoked_at ? <Chip tone="bad">已吊销</Chip> : fmtTime(t.last_used_at)}</td>
<td className="r">
{!t.revoked_at ? (
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
<Trash2 size={14} />
吊销
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
)}
{creating ? (
<Dialog
title="创建 API 令牌"
onClose={() => setCreating(false)}
actions={
<>
<button type="button" className="btn text" onClick={() => setCreating(false)}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={!name.trim()}
onClick={async () => {
let result: CreatedApiTokenDto | undefined
const ok = await run(async () => {
result = await api.post<CreatedApiTokenDto>('/api/admin/api-tokens', { name: name.trim(), scope })
})
if (ok && result) {
setCreated(result)
setCreating(false)
setName('')
tokens.reload()
}
}}
>
创建
</button>
</>
}
>
<div className="stack">
<Field label="名称">
<input className="input" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</Field>
<Field label="权限">
<Segmented options={[{ value: 'read' as const, label: 'read(只读)' }, { value: 'admin' as const, label: 'admin(全部)' }]} value={scope} onChange={setScope} />
</Field>
</div>
</Dialog>
) : null}
{created ? (
<Dialog title="令牌已创建" onClose={() => setCreated(undefined)} actions={<button type="button" className="btn primary" onClick={() => setCreated(undefined)}>完成</button>}>
<div className="stack">
<div className="notice warn">令牌明文只显示这一次,请现在就复制。</div>
<pre className="code">{created.plaintext}</pre>
<div>
<CopyButton text={created.plaintext} />
</div>
</div>
</Dialog>
) : null}
{revoking ? (
<Confirm title="吊销令牌" danger confirmText="吊销" onClose={() => setRevoking(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/api-tokens/${revoking.id}`), '已吊销'); tokens.reload() }}>
使用「{revoking.name}」的程序会立刻失去访问权限。
</Confirm>
) : null}
</div>
)
}
function Users() {
const users = useQuery(() => api.get<Page<UserDto>>('/api/admin/users'), [])
const { run } = useAction()
const [dialog, setDialog] = useState<{ user?: UserDto }>()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
return (
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<span className="muted">能登录管理端的账号。</span>
<button type="button" className="btn primary right" onClick={() => { setUsername(''); setPassword(''); setDialog({}) }}>
<Plus size={18} />
添加用户
</button>
</div>
<ErrorNotice error={users.error} />
<table className="table">
<thead>
<tr>
<th>用户名</th>
<th>创建</th>
<th />
</tr>
</thead>
<tbody>
{users.data?.items.map((u) => (
<tr key={u.id}>
<td style={{ fontWeight: 600 }}>{u.username}</td>
<td className="muted">{fmtTime(u.created_at)}</td>
<td className="r">
<button type="button" className="btn small" onClick={() => { setPassword(''); setDialog({ user: u }) }}>
<KeyRound size={14} />
改密码
</button>
</td>
</tr>
))}
</tbody>
</table>
{dialog ? (
<Dialog
title={dialog.user ? `修改 ${dialog.user.username} 的密码` : '添加用户'}
onClose={() => setDialog(undefined)}
actions={
<>
<button type="button" className="btn text" onClick={() => setDialog(undefined)}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={!password || (!dialog.user && !username.trim())}
onClick={async () => {
const ok = await run(
() => (dialog.user ? api.patch(`/api/admin/users/${dialog.user.id}`, { password }) : api.post('/api/admin/users', { username: username.trim(), password })),
'已保存',
)
if (ok) {
setDialog(undefined)
users.reload()
}
}}
>
保存
</button>
</>
}
>
<div className="stack">
{!dialog.user ? (
<Field label="用户名">
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
</Field>
) : null}
<Field label="密码">
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
</Field>
</div>
</Dialog>
) : null}
</div>
)
}
function Events() {
const [kind, setKind] = useState('')
const events = useQuery(() => api.get<Page<EventDto>>('/api/admin/events', { kind: kind || undefined, limit: 100 }), [kind], 10_000)
return (
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<input className="input" style={{ width: 240 }} placeholder="按类型筛选,例如 probe_connected" value={kind} onChange={(e) => setKind(e.target.value)} />
<span className="right">{events.loading && !events.data ? <Spinner /> : null}</span>
</div>
<ErrorNotice error={events.error} />
{(events.data?.items ?? []).length === 0 ? (
<Empty>没有事件</Empty>
) : (
<table className="table">
<tbody>
{events.data?.items.map((e) => (
<tr key={e.id}>
<td className="muted num" style={{ width: 150 }}>
{fmtTime(e.at)}
</td>
<td style={{ width: 200 }}>{e.kind}</td>
<td className="mono faint" style={{ width: 280 }}>
{e.agent_id ?? ''}
</td>
<td className="mono muted">{e.detail ?? ''}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
function Raw() {
const [kind, setKind] = useState('')
const [agent, setAgent] = useState('')
const chunks = useQuery(() => api.get<Page<RawChunkMetaDto>>('/api/admin/raw/chunks', { kind: kind || undefined, agent_id: agent || undefined, limit: 50 }), [kind, agent])
return (
<div className="card">
<div className="row wrap" style={{ marginBottom: 12 }}>
<input className="input" style={{ width: 200 }} placeholder="Agent ID" value={agent} onChange={(e) => setAgent(e.target.value)} />
<input className="input" style={{ width: 200 }} placeholder="采集方法,例如 udp_echo" value={kind} onChange={(e) => setKind(e.target.value)} />
<span className="muted">原始块按保留期限保存,可以下载后离线分析。</span>
</div>
<ErrorNotice error={chunks.error} />
{(chunks.data?.items ?? []).length === 0 ? (
<Empty>没有符合条件的原始块</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>时间</th>
<th>采集方法</th>
<th>序列</th>
<th className="r">时长</th>
<th className="r">大小</th>
<th />
</tr>
</thead>
<tbody>
{chunks.data?.items.map((c) => (
<tr key={c.chunk_id}>
<td className="muted num">{fmtTime(c.start)}</td>
<td style={{ fontWeight: 600 }}>
{c.kind}
{c.partial ? <Chip tone="warn">不完整</Chip> : null}
</td>
<td className="mono faint" style={{ maxWidth: 260, overflowWrap: 'anywhere' }}>
{c.series}
</td>
<td className="r num">{fmtNum(c.duration_ms / 1000, 1)} s</td>
<td className="r num">{fmtNum(c.bytes / 1024, 1)} KiB</td>
<td className="r">
<a className="btn small" href={`/api/admin/raw/chunks/${c.chunk_id}`} download>
<Download size={14} />
下载
</a>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
function Analysis() {
const state = useQuery(() => api.get<RecomputeDto>('/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 (
<div className="stack">
<div className="card">
<div className="card-title">服务状态</div>
{health.data ? (
<div className="row wrap">
<Chip tone={health.data.status === 'ok' || health.data.status === 'healthy' ? 'ok' : 'bad'}>{health.data.status}</Chip>
<Chip tone={health.data.database ? 'ok' : 'bad'}>数据库 {health.data.database ? '正常' : '异常'}</Chip>
<Chip tone={health.data.raw_directory ? 'ok' : 'bad'}>原始数据目录 {health.data.raw_directory ? '正常' : '异常'}</Chip>
</div>
) : (
<Spinner />
)}
</div>
<div className="card">
<div className="card-title">指标重算</div>
<p className="muted" style={{ marginBottom: 12 }}>新增或修改指标之后,后台会在保留期内的原始数据上自动重算,这里是进度。</p>
<ErrorNotice error={state.error} />
{d ? (
<dl className="kv">
<dt>状态</dt>
<dd>
<Chip tone={d.state === 'idle' || d.state === 'done' ? 'ok' : 'accent'}>{d.state}</Chip>
</dd>
<dt>重算的指标</dt>
<dd>{d.metrics.length ? d.metrics.join(', ') : '—'}</dd>
<dt>进度</dt>
<dd className="num">
{fmtNum(d.windows_done)} / {fmtNum(d.windows_total)} 个窗口
</dd>
<dt>排队中的窗口</dt>
<dd className="num">{fmtNum(d.queue_length)}</dd>
</dl>
) : (
<Spinner />
)}
</div>
</div>
)
}
export function System() {
const [tab, setTab] = useState<Tab>('settings')
return (
<>
<header>
<h1>系统</h1>
</header>
<div className="stack">
<Segmented
options={[
{ value: 'settings' as const, label: '设置' },
{ value: 'tokens' as const, label: 'API 令牌' },
{ value: 'users' as const, label: '用户' },
{ value: 'events' as const, label: '事件' },
{ value: 'raw' as const, label: '原始数据' },
{ value: 'analysis' as const, label: '分析' },
]}
value={tab}
onChange={setTab}
/>
{tab === 'settings' ? <Settings /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
</div>
</>
)
}
+317
View File
@@ -0,0 +1,317 @@
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { api } from '@shared/api/client'
import type { AgentDto, KindDto, Page, ParamDto, TaskDto } from '@shared/api/types'
import { fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
type ScopeFilter = 'all' | 'template' | 'override' | 'target'
const kindOf = (t: TaskDto): ScopeFilter => (t.scope === 'target' ? 'target' : t.agent_id ? 'override' : 'template')
function summary(params: Record<string, unknown>): string {
const entries = Object.entries(params)
return entries.length === 0 ? '默认参数' : entries.map(([k, v]) => `${k}=${String(v)}`).join(' ')
}
/** 按 Kind 声明的参数规格生成表单:只提交和默认值不同的那些。 */
function ParamForm({ params, values, onChange }: { params: ParamDto[]; values: Record<string, unknown>; onChange: (v: Record<string, unknown>) => void }) {
const set = (name: string, value: unknown, fallback: unknown) => {
const next = { ...values }
if (value === '' || value === undefined || value === fallback) delete next[name]
else next[name] = value
onChange(next)
}
return (
<div className="form-grid">
{params.map((p) => {
const current = values[p.name]
const hint = [p.description, p.min !== null && p.min !== undefined ? `最小 ${p.min}` : '', p.max !== null && p.max !== undefined ? `最大 ${p.max}` : '', p.unit ?? ''].filter(Boolean).join(' · ')
return (
<Field key={p.name} label={`${p.name}(默认 ${JSON.stringify(p.default)})`} hint={hint}>
{p.type === 'bool' ? (
<select className="select" value={current === undefined ? '' : String(current)} onChange={(e) => set(p.name, e.target.value === '' ? undefined : e.target.value === 'true', p.default)}>
<option value="">默认</option>
<option value="true">是</option>
<option value="false">否</option>
</select>
) : p.values && p.values.length > 0 ? (
<select className="select" value={current === undefined ? '' : String(current)} onChange={(e) => set(p.name, e.target.value, p.default)}>
<option value="">默认</option>
{p.values.map((v) => (
<option key={String(v)} value={String(v)}>
{String(v)}
</option>
))}
</select>
) : (
<input
className="input"
type={p.type === 'string' ? 'text' : 'number'}
step={p.type === 'int' ? 1 : 'any'}
value={current === undefined ? '' : String(current)}
placeholder={String(p.default)}
onChange={(e) => set(p.name, p.type === 'string' ? e.target.value : e.target.value === '' ? '' : Number(e.target.value), p.default)}
/>
)}
</Field>
)
})}
</div>
)
}
function TaskDialog({ task, kinds, agents, templates, onClose, onSaved }: { task?: TaskDto; kinds: KindDto[]; agents: AgentDto[]; templates: TaskDto[]; onClose: () => void; onSaved: () => void }) {
const { busy, run } = useAction()
const editing = Boolean(task)
const [mode, setMode] = useState<'template' | 'override' | 'target'>(task ? (kindOf(task) as 'template' | 'override' | 'target') : 'override')
const [kindId, setKindId] = useState(task?.kind ?? '')
const [agentId, setAgentId] = useState(task?.agent_id ?? agents[0]?.id ?? '')
const [baseId, setBaseId] = useState(task?.base_task_id ?? '')
const [target, setTarget] = useState(task?.target ?? '')
const [values, setValues] = useState<Record<string, unknown>>(task ? { ...(task.params as Record<string, unknown>) } : {})
const [enabled, setEnabled] = useState(task?.enabled ?? true)
const base = templates.find((t) => t.id === baseId)
const effectiveKind = mode === 'override' ? (base?.kind ?? '') : kindId
const kind = kinds.find((k) => k.id === effectiveKind)
const kindChoices = kinds.filter((k) => k.scopes.includes(mode === 'target' ? 'target' : 'line'))
const submit = async () => {
const ok = await run(async () => {
if (task) {
await api.patch(`/api/admin/tasks/${task.id}`, { params: values, enabled, target: mode === 'target' ? target : undefined })
} else {
await api.post('/api/admin/tasks', {
scope: mode === 'target' ? 'target' : 'line',
kind: effectiveKind,
target: mode === 'target' ? target : null,
params: values,
enabled,
agent_id: mode === 'template' ? null : agentId,
base_task_id: mode === 'override' ? baseId : null,
})
}
}, '已保存')
if (ok) {
onSaved()
onClose()
}
}
return (
<Dialog
title={editing ? '修改任务' : '添加任务'}
onClose={onClose}
actions={
<>
<button type="button" className="btn text" onClick={onClose}>
取消
</button>
<button type="button" className="btn primary" disabled={busy || !effectiveKind || (mode !== 'template' && !agentId) || (mode === 'target' && !target) || (mode === 'override' && !baseId)} onClick={submit}>
保存
</button>
</>
}
>
<div className="stack">
{!editing ? (
<Field label="类型" hint={mode === 'template' ? '线路模板:对所有 Agent 生效' : mode === 'override' ? '覆盖:只改某个 Agent 上某个模板的参数' : '目标任务:Agent 自己去测一个地址(ICMP、TCP、TLS 建连),需要 Agent 本地允许这个网段'}>
<Segmented
options={[
{ value: 'override' as const, label: 'Agent 覆盖' },
{ value: 'template' as const, label: '线路模板' },
{ value: 'target' as const, label: '目标任务' },
]}
value={mode}
onChange={(v) => {
setMode(v)
setValues({})
setKindId('')
}}
/>
</Field>
) : null}
{mode !== 'template' ? (
<Field label="Agent">
<select className="select" value={agentId} disabled={editing} onChange={(e) => setAgentId(e.target.value)}>
{agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</Field>
) : null}
{mode === 'override' && !editing ? (
<Field label="要覆盖的模板">
<select className="select" value={baseId} onChange={(e) => { setBaseId(e.target.value); setValues({}) }}>
<option value="">选择模板</option>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.kind}({summary(t.params as Record<string, unknown>)})
</option>
))}
</select>
</Field>
) : null}
{mode !== 'override' && !editing ? (
<Field label="采集方法">
<select className="select" value={kindId} onChange={(e) => { setKindId(e.target.value); setValues({}) }}>
<option value="">选择</option>
{kindChoices.map((k) => (
<option key={k.id} value={k.id}>
{k.title}({k.id})
</option>
))}
</select>
</Field>
) : null}
{mode === 'target' ? (
<Field label="目标" hint="例如 1.1.1.1 或 example.com:443">
<input className="input" value={target} onChange={(e) => setTarget(e.target.value)} />
</Field>
) : null}
{kind ? (
<>
{kind.description ? <p className="muted">{kind.description}</p> : null}
<ParamForm params={kind.params} values={values} onChange={setValues} />
</>
) : null}
<label className="switch">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
启用
</label>
</div>
</Dialog>
)
}
export function Tasks() {
const tasks = useQuery(() => api.get<Page<TaskDto>>('/api/admin/tasks'), [])
const kinds = useQuery(() => api.get<Page<KindDto>>('/api/public/catalog/kinds'), [])
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [])
const [filter, setFilter] = useState<ScopeFilter>('all')
const [editing, setEditing] = useState<TaskDto | 'new'>()
const [deleting, setDeleting] = useState<TaskDto>()
const { run } = useAction()
const all = tasks.data?.items ?? []
const list = all.filter((t) => filter === 'all' || kindOf(t) === filter)
const agentName = (id: string | null) => (id ? (agents.data?.items.find((a) => a.id === id)?.name ?? id) : '全部 Agent')
return (
<>
<header>
<h1>任务</h1>
<span className="right row">
{tasks.loading && !tasks.data ? <Spinner /> : null}
<button type="button" className="btn primary" onClick={() => setEditing('new')} disabled={!kinds.data || !agents.data}>
<Plus size={18} />
添加任务
</button>
</span>
</header>
<ErrorNotice error={tasks.error ?? kinds.error} />
<div className="stack">
<Segmented
options={[
{ value: 'all' as const, label: '全部' },
{ value: 'template' as const, label: '线路模板' },
{ value: 'override' as const, label: 'Agent 覆盖' },
{ value: 'target' as const, label: '目标任务' },
]}
value={filter}
onChange={setFilter}
/>
<div className="card" style={{ padding: '8px 12px' }}>
{list.length === 0 ? (
<Empty>{tasks.data ? '没有任务' : '加载中…'}</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>采集方法</th>
<th>类型</th>
<th>作用于</th>
<th>参数</th>
<th>状态</th>
<th>更新</th>
<th />
</tr>
</thead>
<tbody>
{list.map((t) => {
const k = kindOf(t)
return (
<tr key={t.id}>
<td style={{ fontWeight: 600 }}>{t.kind}</td>
<td>
<Chip tone={k === 'target' ? 'accent' : ''}>{k === 'template' ? '线路模板' : k === 'override' ? 'Agent 覆盖' : '目标任务'}</Chip>
</td>
<td>
{agentName(t.agent_id)}
{t.target ? <div className="mono faint">{t.target}</div> : null}
</td>
<td className="mono muted" style={{ maxWidth: 320, overflowWrap: 'anywhere' }}>
{summary(t.params as Record<string, unknown>)}
</td>
<td>
<button
type="button"
className="btn small"
onClick={async () => {
await run(() => api.patch(`/api/admin/tasks/${t.id}`, { enabled: !t.enabled }))
tasks.reload()
}}
>
{t.enabled ? <Chip tone="ok">已启用</Chip> : <Chip>已停用</Chip>}
</button>
</td>
<td className="muted">{fmtTime(t.updated_at)}</td>
<td className="r">
<span className="row" style={{ justifyContent: 'flex-end' }}>
<button type="button" className="btn small" onClick={() => setEditing(t)}>
<Pencil size={14} />
</button>
<button type="button" className="btn small danger" onClick={() => setDeleting(t)}>
<Trash2 size={14} />
</button>
</span>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
{editing && kinds.data && agents.data ? (
<TaskDialog
task={editing === 'new' ? undefined : editing}
kinds={kinds.data.items}
agents={agents.data.items}
templates={all.filter((t) => kindOf(t) === 'template')}
onClose={() => setEditing(undefined)}
onSaved={tasks.reload}
/>
) : null}
{deleting ? (
<Confirm
title="删除任务"
danger
confirmText="删除"
onClose={() => setDeleting(undefined)}
onConfirm={async () => {
await run(() => api.del(`/api/admin/tasks/${deleting.id}`), '已删除')
tasks.reload()
}}
>
删除之后,Agent 和 Probe 会在下一次同步时停止这个任务。
</Confirm>
) : null}
</>
)
}
+240
View File
@@ -0,0 +1,240 @@
import { FlaskConical, Square } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { AgentDto, Page, PlanDto, ProbeDto, TestDto } from '@shared/api/types'
import { fmtMbps, fmtMs, fmtPct, fmtTime, scoreTone } from '@shared/format'
import { translatePlan } from '@shared/i18n'
import { useQuery } from '@shared/hooks'
import { Chip, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
const STATUS: Record<string, { label: string; tone: 'ok' | 'warn' | 'bad' | 'accent' | '' }> = {
queued: { label: '排队中', tone: '' },
running: { label: '运行中', tone: 'accent' },
succeeded: { label: '成功', tone: 'ok' },
failed: { label: '失败', tone: 'bad' },
aborted: { label: '已取消', tone: 'warn' },
}
const ACTIVE = new Set(['queued', 'running'])
function Result({ test }: { test: TestDto }) {
const r = test.result
if (!r) return <Empty>{ACTIVE.has(test.status) ? '测试进行中…' : test.error_code ? `失败:${test.error_code}` : '没有结果'}</Empty>
const m = r.metrics
const pick = (id: string, fmt: (v: number) => string, label: string) => (m[id] === undefined ? null : (
<div className="stat" key={id}>
<span className="k">{label}</span>
<span className="v" style={{ fontSize: 20 }}>{fmt(m[id])}</span>
</div>
))
return (
<div className="stack">
<div className="grid stats">
{pick('tcp.bulk.peak.max', fmtMbps, '吞吐峰值')}
{pick('tcp.bulk.mbps.p50', fmtMbps, '吞吐中位数')}
{pick('connect.success', (v) => fmtPct(v, 1), '建连成功率')}
{pick('connect.tcp_ms.p95', fmtMs, 'TCP 建连 p95')}
{pick('connect.tls_ms.p95', fmtMs, 'TLS 握手 p95')}
{pick('hold.lifetime_max', (v) => `${(v / 1000).toFixed(0)} s`, '最长保持')}
{pick('hy2.sustainable_mbps', fmtMbps, 'Hy2 可持续码率')}
{pick('hy2.qos_onset_mbps', fmtMbps, '限速起点')}
{pick('tcp.retrans', fmtPct, 'TCP 重传')}
</div>
<div className="row wrap">
{Object.entries(r.scores).map(([model, score]) => (
<Chip key={model} tone={scoreTone(score.score) || ''}>
{model} {score.score === null ? '—' : Math.round(score.score)}
{score.estimated ? '(估算)' : ''}
</Chip>
))}
{r.caveats.map((c) => (
<Chip key={c} tone="warn">
{c}
</Chip>
))}
</div>
{r.steps.length > 0 ? (
<table className="table">
<thead>
<tr>
<th>阶梯</th>
<th className="r">目标码率</th>
<th className="r">丢包</th>
<th className="r">抖动 p95</th>
<th className="r">实际吞吐</th>
</tr>
</thead>
<tbody>
{r.steps.map((s) => (
<tr key={s.index}>
<td>#{s.index + 1}</td>
<td className="r num">{fmtMbps(s.target_mbps)}</td>
<td className="r num">{fmtPct(s.loss_pct)}</td>
<td className="r num">{fmtMs(s.jitter_p95_ms)}</td>
<td className="r num">{fmtMbps(s.delivered_mbps)}</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
)
}
export function Tests() {
const [params, setParams] = useSearchParams()
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [], 10_000)
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [])
const plans = useQuery(() => api.get<Page<PlanDto>>('/api/public/catalog/plans'), [])
const { busy, run } = useAction()
const active = (agents.data?.items ?? []).filter((a) => a.status === 'active')
const agentId = params.get('agent') ?? active[0]?.id ?? ''
const agent = active.find((a) => a.id === agentId)
const connected = agent?.probes ?? []
const probeId = params.get('probe') ?? connected[0]?.probe_id ?? ''
const [planId, setPlanId] = useState('')
const [selected, setSelected] = useState<string>()
const line = agentId && probeId ? `${agentId}~${probeId}` : ''
useEffect(() => {
if (!planId && plans.data?.items[0]) setPlanId(plans.data.items[0].id)
}, [plans.data, planId])
const tests = useQuery(() => (line ? api.get<Page<TestDto>>(`/api/admin/lines/${encodeURIComponent(line)}/tests`, { limit: 20 }) : Promise.resolve(undefined)), [line], 3000)
const detail = useQuery(() => (selected ? api.get<TestDto>(`/api/public/tests/${selected}`) : Promise.resolve(undefined)), [selected, tests.data?.items[0]?.status], 3000)
const probeName = (id: string) => probes.data?.items.find((p) => p.id === id)?.name ?? id
const rawPlan = plans.data?.items.find((p) => p.id === planId)
const plan = rawPlan ? translatePlan(rawPlan.id, rawPlan.title, rawPlan.description) : undefined
const set = (patch: Record<string, string>) => {
const next = new URLSearchParams(params)
for (const [k, v] of Object.entries(patch)) next.set(k, v)
setParams(next, { replace: true })
}
return (
<>
<header>
<h1>按需测试</h1>
<span className="right">{agents.loading && !agents.data ? <Spinner /> : null}</span>
</header>
<ErrorNotice error={agents.error ?? plans.error ?? tests.error} />
<div className="stack">
<div className="card">
<div className="card-title">发起测试</div>
<p className="muted" style={{ marginBottom: 16 }}>
测试会在选中的线路(一个 Agent 和一个连着它的 Probe)上跑,会占用链路带宽,请不要太频繁。
</p>
<div className="form-grid">
<Field label="Agent">
<select className="select" value={agentId} onChange={(e) => set({ agent: e.target.value, probe: '' })}>
{active.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
{a.online ? '' : '(离线)'}
</option>
))}
</select>
</Field>
<Field label="Probe" hint={connected.length === 0 ? '这个 Agent 现在没有 Probe 连着,不能发起测试' : undefined}>
<select className="select" value={probeId} onChange={(e) => set({ probe: e.target.value })} disabled={connected.length === 0}>
{connected.map((p) => (
<option key={p.probe_id} value={p.probe_id}>
{probeName(p.probe_id)}
</option>
))}
</select>
</Field>
<div className="wide">
<Field label="方案" hint={plan?.description}>
<select className="select" value={planId} onChange={(e) => setPlanId(e.target.value)}>
{(plans.data?.items ?? []).map((p) => (
<option key={p.id} value={p.id}>
{translatePlan(p.id, p.title, p.description).title}({p.id})
</option>
))}
</select>
</Field>
</div>
</div>
<div style={{ marginTop: 16 }}>
<button
type="button"
className="btn primary"
disabled={busy || !line || !planId || connected.length === 0}
onClick={async () => {
let started: TestDto | undefined
const ok = await run(async () => {
started = await api.post<TestDto>(`/api/admin/lines/${encodeURIComponent(line)}/tests`, { plan: planId })
}, '测试已开始')
if (ok && started) setSelected(started.id)
tests.reload()
}}
>
<FlaskConical size={18} />
开始测试
</button>
</div>
</div>
<div className="grid cols-2" style={{ alignItems: 'start' }}>
<div className="card" style={{ padding: '16px 12px' }}>
<div className="card-title" style={{ padding: '0 12px' }}>
这条线路的测试
</div>
{(tests.data?.items ?? []).length === 0 ? (
<Empty>{line ? '还没有测试' : '选择一条线路'}</Empty>
) : (
<table className="table hover">
<tbody>
{tests.data?.items.map((t) => (
<tr key={t.id} onClick={() => setSelected(t.id)} style={selected === t.id ? { background: 'var(--app-card-hover)' } : undefined}>
<td>
<Chip tone={STATUS[t.status]?.tone ?? ''}>{STATUS[t.status]?.label ?? t.status}</Chip>
</td>
<td style={{ fontWeight: 600 }}>{t.plan}</td>
<td className="muted">{fmtTime(t.requested_at)}</td>
<td className="r">
{ACTIVE.has(t.status) ? (
<button
type="button"
className="btn small danger"
onClick={async (e) => {
e.stopPropagation()
await run(() => api.post(`/api/admin/tests/${t.id}/cancel`), '已取消')
tests.reload()
}}
>
<Square size={14} />
取消
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="card">
<div className="card-title">结果</div>
{detail.data ? (
<>
<p className="faint" style={{ marginBottom: 12 }}>
{detail.data.plan} · {STATUS[detail.data.status]?.label ?? detail.data.status}
{detail.data.phase !== null ? ` · 阶段 ${detail.data.phase + 1} / ${detail.data.phases.length}` : ''}
</p>
<Result test={detail.data} />
</>
) : (
<Empty>选择左边的一条测试查看结果</Empty>
)}
</div>
</div>
</div>
</>
)
}
+9
View File
@@ -0,0 +1,9 @@
// 颜色只能出现在 shared/theme/tokens.css:其他样式一律引用令牌
export default {
rules: {
'color-no-hex': true,
'color-named': 'never',
'function-disallowed-list': ['rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch'],
},
overrides: [{ files: ['../shared/theme/tokens.css'], rules: { 'color-no-hex': null, 'function-disallowed-list': null } }],
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"paths": { "@shared/*": ["../shared/*"] }
},
"include": ["src", "../shared"],
"exclude": ["../shared/api/schema.ts"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"erasableSyntaxOnly": true
},
"include": ["vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import react from '@vitejs/plugin-react'
import { fileURLToPath } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
// 开发时把 /api 转发给后端,地址取自环境变量 BACKEND_URL,或 web/.env.local 里的 BACKEND_URL=(见 web/.env.example):
// BACKEND_URL=https://mo.oeo.one npm run dev:display
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, fileURLToPath(new URL('..', import.meta.url)), '')
const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:5000').replace(/\/+$/, '')
return {
base: '/admin/',
plugins: [react()],
resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } },
server: {
port: 5174,
fs: { allow: ['..'] },
proxy: {
'/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
'/healthz': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
},
},
}
})
+7
View File
@@ -0,0 +1,7 @@
// 同时启动展示端(5173)和管理端(5174/admin/);Ctrl+C 一起退出。
import { spawn } from 'node:child_process'
const children = ['display', 'admin'].map((name) => spawn('npm', ['run', 'dev', '-w', name], { stdio: 'inherit', shell: true }))
const stop = () => children.forEach((c) => c.kill())
process.on('SIGINT', stop)
process.on('exit', stop)
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>线路质量</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{
"name": "monitor-display",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint:css": "stylelint \"src/**/*.css\" \"../shared/**/*.css\""
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.1.0",
"uplot": "^1.6.31"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"stylelint": "^16.12.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
import { SourceButton, ThemeToggle } from '@shared/ui'
import { Compare } from './pages/Compare'
import { Line } from './pages/Line'
import { Overview } from './pages/Overview'
export function App() {
return (
<>
<header className="topbar">
<div className="inner">
<NavLink to="/" className="brand">
线路质量
</NavLink>
<nav className="topnav">
<NavLink to="/" end className={({ isActive }) => (isActive ? 'active' : '')}>
总览
</NavLink>
<NavLink to="/compare" className={({ isActive }) => (isActive ? 'active' : '')}>
对比
</NavLink>
<a href="/admin/">管理</a>
</nav>
<SourceButton />
<ThemeToggle />
</div>
</header>
<Routes>
<Route path="/" element={<Overview />} />
<Route path="/line/:id" element={<Line />} />
<Route path="/compare" element={<Compare />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</>
)
}
+462
View File
@@ -0,0 +1,462 @@
/* 展示端:极简博客式的页面——顶栏、居中的内容列、大号标题、细分隔线 */
.topbar {
position: sticky;
top: 0;
z-index: 10;
background: color-mix(in srgb, var(--app-bg), transparent 8%);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--app-outline-soft);
}
.topbar .inner {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
height: 60px;
display: flex;
align-items: center;
gap: 24px;
}
.brand {
font-size: 18px;
font-weight: 700;
color: var(--app-ink);
}
.brand:hover {
text-decoration: none;
}
.topnav {
display: flex;
gap: 4px;
margin-left: auto;
}
.topnav a {
padding: 6px 14px;
border-radius: var(--r-pill);
color: var(--app-ink-muted);
font-weight: 500;
}
.topnav a:hover {
text-decoration: none;
background: var(--app-card-inset);
}
.topnav a.active {
background: var(--app-accent-container);
color: var(--app-on-accent-container);
}
.page {
max-width: 1200px;
margin: 0 auto;
padding: 40px 24px 80px;
}
.hero h1 {
font-size: 40px;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.2;
}
.hero .sub {
margin-top: 8px;
color: var(--app-ink-muted);
}
.section-label {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--app-accent);
font-weight: 700;
}
.section-head {
display: flex;
align-items: flex-end;
gap: 16px;
margin: 40px 0 16px;
flex-wrap: wrap;
}
.section-head h2 {
font-size: 20px;
font-weight: 700;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 24px;
}
.line-row td:first-child {
min-width: 200px;
}
.line-name {
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.line-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.scores {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.score-chip {
display: inline-flex;
flex-direction: column;
align-items: center;
min-width: 52px;
padding: 4px 10px;
border-radius: var(--r-control);
background: var(--app-card-inset);
line-height: 1.25;
}
.score-chip b {
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.score-chip small {
font-size: 10px;
color: var(--app-ink-muted);
}
.score-chip.ok {
background: var(--app-ok-container);
color: var(--app-ok);
}
.score-chip.warn {
background: var(--app-warn-container);
color: var(--app-warn);
}
.score-chip.bad {
background: var(--app-bad-container);
color: var(--app-bad);
}
.chart-card {
display: flex;
flex-direction: column;
gap: 4px;
}
.chart-card h3 {
font-size: 14px;
font-weight: 600;
}
.crumbs {
display: flex;
align-items: center;
gap: 8px;
color: var(--app-ink-muted);
margin-bottom: 12px;
}
.event-list {
display: flex;
flex-direction: column;
}
.event-list > div {
display: flex;
gap: 16px;
padding: 8px 0;
border-bottom: 1px solid var(--app-outline-soft);
}
.event-list > div:last-child {
border-bottom: 0;
}
.pick-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pick {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: var(--r-pill);
background: var(--app-card-inset);
cursor: pointer;
user-select: none;
}
.pick.on {
background: var(--app-accent-container);
color: var(--app-on-accent-container);
}
.pick input {
display: none;
}
@media (max-width: 700px) {
.hero h1 {
font-size: 30px;
}
.table .hide-sm {
display: none;
}
}
/* ── Agent 卡片 ── */
.cards {
margin-top: 24px;
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
}
.agent-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
}
.agent-card.offline {
opacity: 0.62;
}
.agent-head {
display: flex;
align-items: center;
gap: 8px;
}
.agent-name {
font-size: 17px;
font-weight: 700;
}
.agent-head .chip {
margin-left: auto;
}
.agent-head .chip + .faint {
font-size: 12px;
}
.lines {
display: flex;
flex-direction: column;
gap: 4px;
}
.line-link {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--r-control);
background: var(--app-card-inset);
color: var(--app-ink);
}
.line-link:hover {
text-decoration: none;
background: var(--app-card-hover);
}
.scores.compact {
gap: 3px;
}
.scores.compact .score-chip {
min-width: 0;
padding: 1px 6px;
flex-direction: row;
gap: 4px;
align-items: baseline;
}
.scores.compact .score-chip b {
font-size: 12px;
}
.scores.compact .score-chip small {
display: none;
}
.host {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--app-outline-soft);
}
.host.stale .meter i {
background: var(--app-idle);
}
.meter-row {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 44px;
align-items: center;
column-gap: 10px;
}
.meter-row .meter-detail {
grid-column: 2 / 4;
font-size: 11px;
margin-top: -2px;
}
.meter-label {
color: var(--app-ink-muted);
font-size: 12px;
}
.meter {
height: 8px;
border-radius: var(--r-pill);
background: var(--app-card-inset);
overflow: hidden;
}
.meter i {
display: block;
height: 100%;
border-radius: var(--r-pill);
background: var(--app-accent);
transition: width 0.4s;
}
.meter i.warn {
background: var(--app-warn);
}
.meter i.bad {
background: var(--app-bad);
}
.meter-value {
text-align: right;
font-size: 12px;
}
.host-grid {
display: grid;
grid-template-columns: 1fr;
gap: 4px;
margin-top: 4px;
font-size: 12px;
}
.host-grid .stat {
flex-direction: row;
gap: 12px;
align-items: baseline;
}
.host-grid .stat .k {
min-width: 56px;
}
.live-dot {
display: inline-block;
width: 8px;
height: 8px;
margin-left: 10px;
border-radius: 50%;
background: var(--app-idle);
}
.live-dot.on {
background: var(--app-ok);
}
.line-link > * {
white-space: nowrap;
}
.range-picker {
display: flex;
flex-direction: column;
}
/* ── 对比:每个 Probe 的最佳线路 + 矩阵 ── */
.winners {
margin-top: 24px;
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.winner {
display: flex;
flex-direction: column;
gap: 2px;
padding: 14px 18px;
}
.winner-name {
display: flex;
align-items: center;
gap: 6px;
font-size: 17px;
font-weight: 700;
color: var(--app-ok);
}
.matrix td.r {
padding-top: 6px;
padding-bottom: 6px;
}
.cell {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border: 0;
border-radius: var(--r-control);
background: transparent;
color: var(--app-ink);
cursor: pointer;
font-variant-numeric: tabular-nums;
}
.cell:hover {
background: var(--app-card-hover);
}
.cell.on {
background: var(--app-accent-container);
}
.cell svg {
color: var(--app-ok);
}
+56
View File
@@ -0,0 +1,56 @@
import { ApiError } from '@shared/api/client'
import { describeError } from '@shared/ui'
import { scoreTone } from '@shared/format'
export const RANGES = [
{ value: '15m', label: '15 分钟' },
{ value: '1h', label: '1 小时' },
{ value: '6h', label: '6 小时' },
{ value: '24h', label: '24 小时' },
{ value: '7d', label: '7 天' },
] as const
export type RangeValue = (typeof RANGES)[number]['value']
export const isRange = (v: string | null): v is RangeValue => RANGES.some((r) => r.value === v)
export const SCORE_MODELS = [
{ id: 'score.reality', label: 'REALITY' },
{ id: 'score.hy2', label: 'Hy2' },
{ id: 'score.mc', label: 'MC' },
{ id: 'score.base', label: '综合' },
] as const
export function ScoreChips({ metrics, compact }: { metrics: Record<string, number | null | undefined>; compact?: boolean }) {
return (
<div className={`scores ${compact ? 'compact' : ''}`}>
{SCORE_MODELS.map((m) => {
const v = metrics[m.id]
return (
<span key={m.id} className={`score-chip ${scoreTone(v)}`} title={`${m.label} 评分`}>
<b>{v === null || v === undefined ? '—' : Math.round(v)}</b>
<small>{m.label}</small>
</span>
)
})}
</div>
)
}
/** 站点没有公开时匿名访问会被拒绝:给出一个说得清楚的提示,而不是一条错误。 */
export function PageError({ error }: { error: unknown }) {
if (!error) return null
if (error instanceof ApiError && error.code === 'site_private') {
return (
<div className="card" style={{ marginTop: 24 }}>
<div className="card-title">站点未公开</div>
<p className="muted">管理员还没有把站点设为公开。管理员请先 <a href="/admin/">登录管理端</a>,在「系统 → 设置」里打开「站点公开」,或者带着登录状态访问这里。</p>
</div>
)
}
return <div className="notice bad" style={{ marginTop: 24 }}>{describeError(error)}</div>
}
export const lineLabel = (name: string, probe: string) => `${name} · ${probe}`
export const num = (v: number | null | undefined) => (v === null || v === undefined ? null : v)
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useState } from 'react'
import type { Page } from '@shared/api/types'
import { api } from '@shared/api/client'
import { openLive } from '@shared/api/source'
/** 展示页能拿到的主机状态:只有汇总的数字,没有型号和 IP。 */
export interface Host {
cpu_percent: number | null
mem_used: number | null
mem_total: number | null
disk_used: number | null
disk_total: number | null
net_rx_total: number | null
net_tx_total: number | null
net_rx_bps: number | null
net_tx_bps: number | null
uptime_seconds: number | null
at: string
}
export interface HostItem {
agent_id: string
name: string
online: boolean
stale: boolean
last_seen_at: string | null
host: Host | null
}
/**
* Agent 主机状态的实时订阅:先用 REST 取一次全量,再用 SSE(`hosts` 主题)接收每一次更新。
* 连接断开时浏览器会自动重连;重连之后服务端会先推一份当前快照。
*/
export function useHosts(): { hosts: Map<string, HostItem>; live: boolean } {
const [hosts, setHosts] = useState<Map<string, HostItem>>(new Map())
const [live, setLive] = useState(false)
useEffect(() => {
let alive = true
api
.get<Page<HostItem>>('/api/public/hosts')
.then((page) => alive && setHosts(new Map(page.items.map((i) => [i.agent_id, i]))))
.catch(() => undefined)
const source = openLive('/api/public/live?topics=hosts,nodes')
source.onopen = () => setLive(true)
source.onerror = () => setLive(false)
source.addEventListener('host', (event) => {
const data = JSON.parse((event as MessageEvent<string>).data) as { agent_id: string; online: boolean; host: Host }
setHosts((previous) => {
const next = new Map(previous)
const old = previous.get(data.agent_id)
next.set(data.agent_id, { agent_id: data.agent_id, name: old?.name ?? data.agent_id, online: data.online, stale: false, last_seen_at: data.host.at, host: data.host })
return next
})
})
source.addEventListener('node', (event) => {
const data = JSON.parse((event as MessageEvent<string>).data) as { id: string; online: boolean }
setHosts((previous) => {
const old = previous.get(data.id)
if (!old) return previous
const next = new Map(previous)
next.set(data.id, { ...old, online: data.online, stale: data.online ? old.stale : true })
return next
})
})
return () => {
alive = false
source.close()
}
}, [])
return { hosts, live }
}
export function fmtBytes(v: number | null | undefined): string {
if (v === null || v === undefined) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = v
let i = 0
while (value >= 1024 && i < units.length - 1) {
value /= 1024
i++
}
return `${value.toFixed(value >= 100 || i === 0 ? 0 : 2)} ${units[i]}`
}
export function fmtSpeed(v: number | null | undefined): string {
return v === null || v === undefined ? '—' : `${fmtBytes(v)}/s`
}
export function fmtUptime(seconds: number | null | undefined): string {
if (seconds === null || seconds === undefined) return '—'
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
return d > 0 ? `${d} 天 ${h} 小时 ${m} 分` : h > 0 ? `${h} 小时 ${m} 分` : `${m} 分 ${Math.floor(seconds % 60)} 秒`
}
export const pct = (used: number | null, total: number | null): number | null => (used !== null && total !== null && total > 0 ? (100 * used) / total : null)
+22
View File
@@ -0,0 +1,22 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
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 { App } from './App'
import './display.css'
configureSource('display')
applyStoredTheme()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter basename={import.meta.env.BASE_URL}>
<ToastProvider>
<App />
</ToastProvider>
</BrowserRouter>
</StrictMode>,
)
+213
View File
@@ -0,0 +1,213 @@
import { Star } from 'lucide-react'
import { useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { CompareDto, OverviewDto, OverviewLineDto } from '@shared/api/types'
import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart'
import { fmtMs, fmtPct } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Empty, Segmented, Spinner } from '@shared/ui'
import { lineLabel, PageError } from '../lib'
import { PRESETS, type PresetValue } from '../ranges'
interface MetricInfo {
id: string
label: string
unit: 'ms' | '%' | ''
better: 'lower' | 'higher'
}
const METRICS: MetricInfo[] = [
{ id: 'rtt.p50', label: '延迟 p50', unit: 'ms', better: 'lower' },
{ id: 'rtt.p95', label: '延迟 p95', unit: 'ms', better: 'lower' },
{ id: 'loss.total', label: '丢包', unit: '%', better: 'lower' },
{ id: 'loss.up', label: '上行丢包', unit: '%', better: 'lower' },
{ id: 'loss.down', label: '下行丢包', unit: '%', better: 'lower' },
{ id: 'jitter.up.p95', label: '上行抖动', unit: 'ms', better: 'lower' },
{ id: 'jitter.down.p95', label: '下行抖动', unit: 'ms', better: 'lower' },
{ id: 'tcp.rtt.p50', label: 'TCP RTT p50', unit: 'ms', better: 'lower' },
{ id: 'tcp.retrans', label: 'TCP 重传', unit: '%', better: 'lower' },
{ id: 'score.reality', label: 'REALITY 评分', unit: '', better: 'higher' },
{ id: 'score.hy2', label: 'Hy2 评分', unit: '', better: 'higher' },
{ id: 'score.mc', label: 'MC 评分', unit: '', better: 'higher' },
{ id: 'score.base', label: '综合评分', unit: '', better: 'higher' },
]
const COLORS = ['--chart-1', '--chart-2', '--chart-3', '--chart-4', '--chart-5', '--chart-6']
const MAX_LINES = 6
const isPreset = (v: string | null): v is PresetValue => PRESETS.some((p) => p.value === v)
function fmt(m: MetricInfo, v: number | null | undefined): string {
if (v === null || v === undefined) return '—'
return m.unit === 'ms' ? fmtMs(v) : m.unit === '%' ? fmtPct(v) : v.toFixed(0)
}
interface Cell {
line: OverviewLineDto
value: number | null
/** 在这一列(同一个 Probe)里的名次,从 1 开始;没有数据是 null。 */
rank: number | null
/** 比这一列最好的差多少(百分比);最好的是 0。 */
gap: number | null
}
export function Compare() {
const [params, setParams] = useSearchParams()
const range: PresetValue = isPreset(params.get('range')) ? (params.get('range') as PresetValue) : '1d'
const metric = METRICS.find((m) => m.id === params.get('metric')) ?? METRICS[0]
const picked = useMemo(() => (params.get('lines') ?? '').split(',').filter(Boolean), [params])
const overview = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: metric.id }), [range, metric.id], 15_000)
// 矩阵:行是 Agent,列是 Probe;每一列内部排名(同一个 Probe 看出去,哪个 Agent 的线路好)
const matrix = useMemo(() => {
const lines = overview.data?.lines ?? []
const probes = [...new Map(lines.map((l) => [l.probe_id, l.probe_name])).entries()].sort((a, b) => a[1].localeCompare(b[1]))
const agents = [...new Map(lines.map((l) => [l.agent_id, l.name])).entries()].sort((a, b) => a[1].localeCompare(b[1]))
const byProbe = new Map<string, Cell[]>()
for (const [probeId] of probes) {
const cells: Cell[] = lines
.filter((l) => l.probe_id === probeId)
.map((line) => ({ line, value: line.summary.metrics[metric.id] ?? null, rank: null, gap: null }))
const ranked = cells.filter((c) => c.value !== null).sort((a, b) => (metric.better === 'lower' ? a.value! - b.value! : b.value! - a.value!))
const best = ranked[0]?.value ?? null
ranked.forEach((c, i) => {
c.rank = i + 1
c.gap = best === null || best === 0 ? (c.value === best ? 0 : null) : (Math.abs(c.value! - best) / Math.abs(best)) * 100
})
byProbe.set(probeId, cells)
}
return { probes, agents, byProbe }
}, [overview.data, metric])
// 默认选每个 Probe 里最好的那条线路(最多 6 条)
const defaults = useMemo(() => {
const ids: string[] = []
for (const [probeId] of matrix.probes) {
const best = matrix.byProbe.get(probeId)?.find((c) => c.rank === 1)
if (best) ids.push(best.line.id)
}
return ids.slice(0, MAX_LINES)
}, [matrix])
const selected = picked.length > 0 ? picked : defaults
const compare = useQuery(
() => (selected.length ? api.get<CompareDto>('/api/public/compare', { lines: selected.join(','), metric: metric.id, range }) : Promise.resolve(undefined)),
[selected.join(','), metric.id, range],
15_000,
)
const update = (patch: Record<string, string | null>) => {
const next = new URLSearchParams(params)
for (const [k, v] of Object.entries(patch)) {
if (v) next.set(k, v)
else next.delete(k)
}
setParams(next, { replace: true })
}
const toggle = (id: string) => {
const next = selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id].slice(-MAX_LINES)
update({ lines: next.join(',') })
}
const names = new Map((overview.data?.lines ?? []).map((l) => [l.id, lineLabel(l.name, l.probe_name)]))
const series: ChartSeries[] = selected.map((id, i) => ({ label: names.get(id) ?? id, values: compare.data?.values[id] ?? [], color: COLORS[i % COLORS.length] }))
// 每个 Probe 的最佳线路
const winners = matrix.probes.map(([probeId, probeName]) => ({ probeName, cell: matrix.byProbe.get(probeId)?.find((c) => c.rank === 1) }))
return (
<main className="page">
<section className="hero">
<h1>线路对比</h1>
<p className="sub">同一个指标,在每个 Probe 看出去,哪个 Agent 的线路更好。</p>
<div className="toolbar">
<Segmented options={PRESETS.map((p) => ({ value: p.value, label: p.label }))} value={range} onChange={(v) => update({ range: v })} />
<select className="select" style={{ width: 'auto' }} value={metric.id} onChange={(e) => update({ metric: e.target.value })}>
{METRICS.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
<span className="muted">{metric.better === 'lower' ? '越低越好' : '越高越好'}</span>
<span className="right">{overview.loading && !overview.data ? <Spinner /> : null}</span>
</div>
</section>
<PageError error={overview.error ?? compare.error} />
{overview.data ? (
matrix.agents.length === 0 ? (
<div className="card" style={{ marginTop: 24 }}>
<Empty>还没有线路</Empty>
</div>
) : (
<>
{winners.some((w) => w.cell) ? (
<div className="winners">
{winners.map((w) =>
w.cell ? (
<div key={w.probeName} className="card winner">
<span className="faint">从 {w.probeName} 看出去最好</span>
<span className="winner-name">
<Star size={16} /> {w.cell.line.name}
</span>
<span className="num muted">{fmt(metric, w.cell.value)}</span>
</div>
) : null,
)}
</div>
) : null}
<div className="card" style={{ marginTop: 16, padding: '8px 12px' }}>
<table className="table matrix">
<thead>
<tr>
<th>Agent / Probe</th>
{matrix.probes.map(([id, name]) => (
<th key={id} className="r">
{name}
</th>
))}
</tr>
</thead>
<tbody>
{matrix.agents.map(([agentId, agentName]) => (
<tr key={agentId}>
<td style={{ fontWeight: 600 }}>{agentName}</td>
{matrix.probes.map(([probeId]) => {
const cell = matrix.byProbe.get(probeId)?.find((c) => c.line.agent_id === agentId)
if (!cell) return <td key={probeId} className="r faint">—</td>
const on = selected.includes(cell.line.id)
const tone = cell.rank === 1 ? 'ok' : cell.gap !== null && cell.gap > 50 ? 'bad' : cell.gap !== null && cell.gap > 15 ? 'warn' : ''
return (
<td key={probeId} className="r">
<button type="button" className={`cell ${on ? 'on' : ''}`} onClick={() => toggle(cell.line.id)} title={on ? '从图里移除' : '加到下面的图里'}>
{cell.rank === 1 ? <Star size={13} /> : null}
<Chip tone={tone}>{fmt(metric, cell.value)}</Chip>
{cell.rank !== null && cell.rank > 1 && cell.gap !== null ? <small className="faint">+{cell.gap.toFixed(0)}%</small> : null}
</button>
</td>
)
})}
</tr>
))}
</tbody>
</table>
<p className="faint" style={{ padding: '4px 12px 8px' }}>
★ 是这一列(同一个 Probe)里最好的;点格子把线路加到下面的图里,最多 {MAX_LINES} 条。
</p>
</div>
<div className="card" style={{ marginTop: 16 }}>
<div className="card-title">{metric.label} 的走势</div>
{compare.data ? <TimeChart t={compare.data.t} series={series} unit={metric.unit === '' ? '' : metric.unit} /> : <Empty>{selected.length ? '加载中…' : '点上面的格子选择线路'}</Empty>}
</div>
</>
)
) : null}
</main>
)
}
+254
View File
@@ -0,0 +1,254 @@
import { ChevronLeft } from 'lucide-react'
import { useMemo } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { LineDto, SeriesDto, TestDto } from '@shared/api/types'
import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart'
import { fmtAgo, fmtMbps, fmtMs, fmtNum, fmtPct, fmtTime } from '@shared/format'
import { fmtBytes, useHosts } from '../live'
import { useQuery } from '@shared/hooks'
import { Chip, Dot, Spinner, Stat } from '@shared/ui'
import { PageError, ScoreChips } from '../lib'
import { availablePresets, defaultPreset, monthsWithData, PRESETS, RangePicker, resolveRange, SLICES, type PresetValue, type RangeState, type SliceValue } from '../ranges'
function tone(value: number | null | undefined, good: number, poor: number): 'ok' | 'warn' | 'bad' | '' {
if (value === null || value === undefined) return ''
if (value <= good) return 'ok'
if (value >= poor) return 'bad'
return 'warn'
}
interface HostSeries {
t: number[]
values: Record<string, (number | null)[]>
}
export function Line() {
const { id = '' } = useParams()
const [params, setParams] = useSearchParams()
const line = useQuery(() => api.get<LineDto>(`/api/public/lines/${encodeURIComponent(id)}`), [id], 15_000)
const sinceMs = line.data?.data_since ? Date.parse(line.data.data_since) : null
const nowMs = useMemo(() => Date.now(), [line.data])
const spanMs = sinceMs === null ? null : nowMs - sinceMs
// 时间范围:只提供有数据的档位;URL 里的值如果超出了现有的数据就退回到默认
const wanted = params.get('range')
const available = availablePresets(spanMs)
const preset: PresetValue = PRESETS.some((x) => x.value === wanted) && available.some((x) => x.value === wanted) ? (wanted as PresetValue) : defaultPreset(spanMs)
const sliceParam = params.get('slice')
const state: RangeState = {
preset,
month: params.get('month'),
slice: SLICES.some((x) => x.value === sliceParam) ? (sliceParam as SliceValue) : 'all',
page: Math.max(0, Number(params.get('page') ?? 0) || 0),
}
const months = useMemo(() => monthsWithData(sinceMs, nowMs), [sinceMs, nowMs])
const resolved = resolveRange(state, months, nowMs)
const rangeKey = JSON.stringify(resolved.query)
const setRange = (next: RangeState) => {
const q = new URLSearchParams()
if (next.month) {
q.set('month', next.month)
if (next.slice !== 'all') q.set('slice', next.slice)
if (next.page > 0) q.set('page', String(next.page))
} else {
q.set('range', next.preset)
}
setParams(q, { replace: true })
}
const series = useQuery(
() => (line.data ? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(id)}/series`, { ...resolved.query, preset: 'udp,tcp,scores' }) : Promise.resolve(undefined)),
[id, rangeKey, Boolean(line.data)],
state.month ? undefined : 10_000,
)
const agentId = line.data?.agent_id
const hostHistory = useQuery(
() => (agentId && !state.month ? api.get<HostSeries>(`/api/public/agents/${agentId}/host`, { range: resolved.hostRange }) : Promise.resolve(undefined)),
[agentId, resolved.hostRange, Boolean(state.month)],
30_000,
)
const { hosts } = useHosts()
const host = agentId ? hosts.get(agentId)?.host : undefined
const lastTestId = line.data?.last_test?.id
const test = useQuery(() => (lastTestId ? api.get<TestDto>(`/api/public/tests/${lastTestId}`) : Promise.resolve(undefined)), [lastTestId])
const s = series.data
const col = (key: string): (number | null)[] => s?.values[key] ?? []
const m = s?.summary.metrics ?? {}
const gaps = useMemo(() => (s?.gaps ?? []).map((g) => ({ from: Date.parse(g.from), to: Date.parse(g.to) })), [s])
const t = s?.t ?? []
const chart = (title: string, unit: string, list: ChartSeries[], zero = true) =>
s && list.some((x) => x.values.some((v) => v !== null)) ? (
<div className="card chart-card">
<h3>{title}</h3>
<TimeChart t={t} series={list} unit={unit} gaps={gaps} zero={zero} />
</div>
) : null
const result = test.data?.result
const peak = result?.metrics?.['tcp.bulk.peak.max']
return (
<main className="page">
<div className="crumbs">
<Link to="/" className="row">
<ChevronLeft size={16} />
总览
</Link>
</div>
<section className="hero">
<h1>
{line.data?.name ?? '线路'}
{line.data ? <span className="muted" style={{ fontWeight: 400 }}> → {line.data.probe_name}</span> : null}
</h1>
{line.data ? (
<div className="row wrap" style={{ marginTop: 12 }}>
<Dot tone={line.data.online && line.data.probe_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
<span className="muted">{line.data.online ? (line.data.probe_connected ? '在线' : 'Agent 在线,Probe 未连接') : 'Agent 离线'}</span>
{line.data.labels.map((x) => (
<Chip key={x}>{x}</Chip>
))}
</div>
) : null}
<div className="toolbar">
<RangePicker state={state} onChange={setRange} sinceMs={sinceMs} nowMs={nowMs} />
<span className="right row">
<span className="muted">{resolved.label}</span>
{series.loading && !s ? <Spinner /> : null}
</span>
</div>
</section>
<PageError error={line.error ?? series.error} />
{s ? (
<div className="stack" style={{ marginTop: 24 }}>
<div className="card">
<div className="row wrap" style={{ justifyContent: 'space-between', gap: 24 }}>
<div className="grid stats grow">
<Stat label="延迟 p50" value={fmtMs(m['rtt.p50'])} />
<Stat label="延迟 p95" value={fmtMs(m['rtt.p95'])} />
<Stat label="丢包" value={fmtPct(m['loss.total'])} tone={tone(m['loss.total'], 0.5, 3)} />
<Stat label="上行丢包" value={fmtPct(m['loss.up'])} tone={tone(m['loss.up'], 0.5, 3)} />
<Stat label="下行丢包" value={fmtPct(m['loss.down'])} tone={tone(m['loss.down'], 0.5, 3)} />
<Stat label="抖动(上 / 下)" value={`${fmtMs(m['jitter.up.p95'])} / ${fmtMs(m['jitter.down.p95'])}`} />
</div>
<ScoreChips metrics={m} />
</div>
<hr className="divider" />
<div className="grid stats">
<Stat label="TCP RTT p50" value={fmtMs(m['tcp.rtt.p50'])} />
<Stat label="TCP RTT p99" value={fmtMs(m['tcp.rtt.p99'])} />
<Stat label="TCP RTT 最大" value={fmtMs(m['tcp.rtt.max'])} />
<Stat label="TCP 重传" value={fmtPct(m['tcp.retrans'])} tone={tone(m['tcp.retrans'], 0.5, 3)} />
<Stat label="最长连续丢包" value={fmtNum(m['loss.run_max'])} unit="包" />
<Stat label="样本数(UDP / TCP)" value={`${fmtNum(s.summary.samples['udp_echo'])} / ${fmtNum(s.summary.samples['tcp_echo'])}`} />
</div>
{s.summary.coverage < 0.95 ? <p className="faint" style={{ marginTop: 12 }}>这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。</p> : null}
</div>
<div className="grid cols-2">
{chart('延迟(UDP 往返)', 'ms', [
{ label: 'p50', values: col('rtt.p50'), color: '--chart-1' },
{ label: 'p95', values: col('rtt.p95'), color: '--chart-2' },
{ label: 'p99', values: col('rtt.p99'), color: '--chart-3' },
])}
{chart('丢包', '%', [
{ label: '上行', values: col('loss.up'), color: '--chart-2' },
{ label: '下行', values: col('loss.down'), color: '--chart-3' },
{ label: '合计', values: col('loss.total'), color: '--chart-1', dash: [4, 3] },
])}
{chart('抖动(p95)', 'ms', [
{ label: '上行', values: col('jitter.up.p95'), color: '--chart-2' },
{ label: '下行', values: col('jitter.down.p95'), color: '--chart-1' },
])}
{chart('TCP 往返', 'ms', [
{ label: 'p50', values: col('tcp.rtt.p50'), color: '--chart-1' },
{ label: 'p95', values: col('tcp.rtt.p95'), color: '--chart-2' },
{ label: '最大', values: col('tcp.rtt.max'), color: '--chart-3' },
])}
{chart('TCP 重传', '%', [{ label: '重传率', values: col('tcp.retrans'), color: '--chart-3', fill: true }])}
{chart('综合评分', '', [
{ label: 'REALITY', values: col('score.reality'), color: '--chart-1' },
{ label: 'Hy2', values: col('score.hy2'), color: '--chart-2' },
{ label: 'MC', values: col('score.mc'), color: '--chart-4' },
{ label: '综合', values: col('score.base'), color: '--chart-5', dash: [4, 3] },
])}
</div>
{host || hostHistory.data ? (
<div className="card">
<div className="card-title">主机状态</div>
{host ? (
<div className="grid stats">
<Stat label="CPU" value={host.cpu_percent === null ? '—' : host.cpu_percent.toFixed(0)} unit="%" />
<Stat label="内存" value={host.mem_total ? ((100 * (host.mem_used ?? 0)) / host.mem_total).toFixed(0) : '—'} unit="%" />
<Stat label="磁盘" value={host.disk_total ? ((100 * (host.disk_used ?? 0)) / host.disk_total).toFixed(0) : '—'} unit="%" />
<Stat label="累计流量 ↑ / ↓" value={`${fmtBytes(host.net_tx_total)} / ${fmtBytes(host.net_rx_total)}`} />
</div>
) : null}
{hostHistory.data ? (
<div className="grid cols-2" style={{ marginTop: 16 }}>
<TimeChart
t={hostHistory.data.t}
unit="%"
series={[
{ label: 'CPU', values: hostHistory.data.values['cpu'] ?? [], color: '--chart-1' },
{ label: '内存', values: hostHistory.data.values['mem'] ?? [], color: '--chart-2' },
{ label: '磁盘', values: hostHistory.data.values['disk'] ?? [], color: '--chart-4' },
]}
/>
<TimeChart
t={hostHistory.data.t}
unit=" B/s"
series={[
{ label: '上行', values: hostHistory.data.values['tx_bps'] ?? [], color: '--chart-2' },
{ label: '下行', values: hostHistory.data.values['rx_bps'] ?? [], color: '--chart-1' },
]}
/>
</div>
) : null}
</div>
) : null}
{test.data && result ? (
<div className="card">
<div className="card-title">最近一次测试 · {test.data.plan}</div>
<div className="grid stats">
{peak !== undefined ? <Stat label="吞吐峰值" value={fmtMbps(peak)} /> : null}
{result.metrics['connect.success'] !== undefined ? <Stat label="建连成功率" value={fmtPct(result.metrics['connect.success'], 1)} /> : null}
{result.metrics['connect.tls_ms.p95'] !== undefined ? <Stat label="TLS 握手 p95" value={fmtMs(result.metrics['connect.tls_ms.p95'])} /> : null}
{result.metrics['hy2.sustainable_mbps'] !== undefined ? <Stat label="Hy2 可持续码率" value={fmtMbps(result.metrics['hy2.sustainable_mbps'])} /> : null}
{result.metrics['hy2.qos_onset_mbps'] !== undefined ? <Stat label="限速起点" value={fmtMbps(result.metrics['hy2.qos_onset_mbps'])} /> : null}
</div>
<p className="faint" style={{ marginTop: 12 }}>
{fmtTime(test.data.finished_at)}({fmtAgo(test.data.finished_at)})
</p>
</div>
) : null}
{s.events.length > 0 ? (
<div className="card">
<div className="card-title">事件</div>
<div className="event-list">
{s.events.slice(0, 20).map((e) => (
<div key={e.id}>
<span className="faint num" style={{ minWidth: 120 }}>
{fmtTime(e.at)}
</span>
<span>{e.kind}</span>
{e.detail ? <span className="muted mono">{e.detail}</span> : null}
</div>
))}
</div>
</div>
) : null}
</div>
) : null}
</main>
)
}
+188
View File
@@ -0,0 +1,188 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { OverviewDto, OverviewLineDto } from '@shared/api/types'
import { fmtAgo, fmtMs, fmtPct } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Dot, Empty, Segmented, Spinner } from '@shared/ui'
import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts, type HostItem } from '../live'
import { PageError, ScoreChips } from '../lib'
const METRICS = ['rtt.p50', 'loss.total', 'jitter.up.p95', 'jitter.down.p95', 'score.base', 'score.reality', 'score.hy2', 'score.mc'].join(',')
type SortKey = 'name' | 'rtt' | 'score'
interface AgentCard {
id: string
name: string
labels: string[]
online: boolean
lines: OverviewLineDto[]
}
function tone(value: number | null | undefined, good: number, poor: number): 'ok' | 'warn' | 'bad' | '' {
if (value === null || value === undefined) return ''
if (value <= good) return 'ok'
if (value >= poor) return 'bad'
return 'warn'
}
function Meter({ label, value, detail }: { label: string; value: number | null; detail?: string }) {
const level = value === null ? '' : value >= 90 ? 'bad' : value >= 75 ? 'warn' : 'ok'
return (
<div className="meter-row">
<span className="meter-label">{label}</span>
<span className="meter">
<i className={level} style={{ width: `${Math.min(100, Math.max(0, value ?? 0))}%` }} />
</span>
<span className="meter-value num">{value === null ? '—' : `${value.toFixed(value >= 10 ? 0 : 1)}%`}</span>
{detail ? <span className="meter-detail faint num">{detail}</span> : null}
</div>
)
}
function Card({ agent, host }: { agent: AgentCard; host: HostItem | undefined }) {
const h = host?.host ?? null
const stale = !agent.online || !host || host.stale
return (
<div className={`card agent-card ${agent.online ? '' : 'offline'}`}>
<div className="agent-head">
<Dot tone={agent.online ? 'ok' : 'bad'} />
<span className="agent-name">{agent.name}</span>
<Chip tone={agent.online ? 'ok' : 'bad'}>{agent.online ? '在线' : '离线'}</Chip>
{!agent.online && host?.last_seen_at ? <span className="faint">{fmtAgo(host.last_seen_at)}</span> : null}
</div>
{agent.labels.length > 0 ? (
<div className="line-meta">
{agent.labels.map((x) => (
<Chip key={x}>{x}</Chip>
))}
</div>
) : null}
<div className="lines">
{agent.lines.map((l) => {
const m = l.summary.metrics
return (
<Link key={l.id} to={`/line/${encodeURIComponent(l.id)}`} className="line-link">
<span className="muted">→ {l.probe_name}</span>
<span className="grow" />
{!l.probe_connected && l.online ? <Chip tone="warn">Probe 未连接</Chip> : null}
<span className="num">{fmtMs(m['rtt.p50'])}</span>
<Chip tone={tone(m['loss.total'], 0.5, 3)}>{fmtPct(m['loss.total'])}</Chip>
<ScoreChips metrics={m} compact />
</Link>
)
})}
</div>
<div className={`host ${stale ? 'stale' : ''}`}>
{h ? (
<>
<Meter label="CPU" value={h.cpu_percent} />
<Meter label="内存" value={pct(h.mem_used, h.mem_total)} detail={h.mem_total ? `${fmtBytes(h.mem_used)} / ${fmtBytes(h.mem_total)}` : undefined} />
<Meter label="磁盘" value={pct(h.disk_used, h.disk_total)} detail={h.disk_total ? `${fmtBytes(h.disk_used)} / ${fmtBytes(h.disk_total)}` : undefined} />
<div className="host-grid">
<div className="stat">
<span className="k">流量</span>
<span className="num">
↑ {fmtBytes(h.net_tx_total)} ↓ {fmtBytes(h.net_rx_total)}
</span>
</div>
<div className="stat">
<span className="k">网速</span>
<span className="num">
↑ {fmtSpeed(agent.online ? h.net_tx_bps : null)} ↓ {fmtSpeed(agent.online ? h.net_rx_bps : null)}
</span>
</div>
<div className="stat">
<span className="k">运行时间</span>
<span className="num">{fmtUptime(h.uptime_seconds)}</span>
</div>
</div>
</>
) : (
<div className="faint">这台 Agent 没有上报主机状态</div>
)}
</div>
</div>
)
}
export function Overview() {
const [probe, setProbe] = useState<string>('')
const [sort, setSort] = useState<SortKey>('name')
const { hosts, live } = useHosts()
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range: '15m', metrics: METRICS }), [], 10_000)
const probes = useMemo(() => [...new Set((data?.lines ?? []).map((l) => l.probe_name))].sort(), [data])
const cards = useMemo(() => {
const byAgent = new Map<string, AgentCard>()
for (const l of data?.lines ?? []) {
if (probe && l.probe_name !== probe) continue
const card = byAgent.get(l.agent_id) ?? { id: l.agent_id, name: l.name, labels: l.labels, online: l.online, lines: [] }
card.lines.push(l)
byAgent.set(l.agent_id, card)
}
const best = (c: AgentCard, key: string, pick: (a: number, b: number) => number) => {
const values = c.lines.map((l) => l.summary.metrics[key]).filter((v): v is number => v !== null && v !== undefined)
return values.length ? values.reduce(pick) : null
}
return [...byAgent.values()].sort((a, b) => {
if (a.online !== b.online) return a.online ? -1 : 1
if (sort === 'name') return a.name.localeCompare(b.name)
const key = sort === 'rtt' ? 'rtt.p50' : 'score.base'
const x = best(a, key, sort === 'rtt' ? Math.min : Math.max)
const y = best(b, key, sort === 'rtt' ? Math.min : Math.max)
if (x === null && y === null) return a.name.localeCompare(b.name)
if (x === null) return 1
if (y === null) return -1
return sort === 'rtt' ? x - y : y - x
})
}, [data, probe, sort])
const online = cards.filter((c) => c.online).length
return (
<main className="page">
<section className="hero">
<h1>线路质量</h1>
<p className="sub">
{data ? `${online} / ${cards.length} 台在线` : '加载中…'}
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
</p>
<div className="toolbar">
{probes.length > 1 ? <Segmented options={[{ value: '', label: '全部 Probe' }, ...probes.map((p) => ({ value: p, label: p }))]} value={probe} onChange={setProbe} /> : null}
<span className="right row">
<Segmented
options={[
{ value: 'name' as const, label: '名称' },
{ value: 'rtt' as const, label: '延迟' },
{ value: 'score' as const, label: '评分' },
]}
value={sort}
onChange={setSort}
/>
{loading && !data ? <Spinner /> : null}
</span>
</div>
</section>
<PageError error={error} />
{data ? (
cards.length === 0 ? (
<div className="card" style={{ marginTop: 24 }}>
<Empty>还没有线路</Empty>
</div>
) : (
<div className="cards">
{cards.map((c) => (
<Card key={c.id} agent={c} host={hosts.get(c.id)} />
))}
</div>
)
) : null}
</main>
)
}
+190
View File
@@ -0,0 +1,190 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useMemo } from 'react'
import { Segmented } from '@shared/ui'
const MIN = 60_000
const DAY = 24 * 60 * MIN
/** 线路详情页的时间范围:只提供这条线路“有数据”的档位。 */
export const PRESETS = [
{ value: '5m', label: '5 分钟', ms: 5 * MIN },
{ value: '15m', label: '15 分钟', ms: 15 * MIN },
{ value: '30m', label: '30 分钟', ms: 30 * MIN },
{ value: '1d', label: '1 天', ms: DAY },
{ value: '3d', label: '3 天', ms: 3 * DAY },
{ value: '7d', label: '7 天', ms: 7 * DAY },
{ value: '14d', label: '14 天', ms: 14 * DAY },
{ value: '30d', label: '一个月', ms: 30 * DAY },
] as const
export type PresetValue = (typeof PRESETS)[number]['value']
/** 进入某个月之后的切片:整月,或者从月初起按固定长度翻页。 */
export const SLICES = [
{ value: 'all', label: '整月', ms: 0 },
{ value: '14d', label: '14 天', ms: 14 * DAY },
{ value: '7d', label: '7 天', ms: 7 * DAY },
{ value: '3d', label: '3 天', ms: 3 * DAY },
{ value: '1d', label: '1 天', ms: DAY },
] as const
export type SliceValue = (typeof SLICES)[number]['value']
/** 数据不足整档时允许 10% 的误差:29 分钟的数据算得上“30 分钟”。至少保留最小的一档。 */
const TOLERANCE = 0.9
export function availablePresets(spanMs: number | null) {
if (spanMs === null) return [PRESETS[0]]
const list = PRESETS.filter((p) => spanMs >= p.ms * TOLERANCE)
return list.length > 0 ? list : [PRESETS[0]]
}
/** 默认选“有数据的最大档”,但最长到 1 天(再长的范围图会很挤,用户可以自己点)。 */
export function defaultPreset(spanMs: number | null): PresetValue {
const list = availablePresets(spanMs).filter((p) => p.ms <= DAY)
return list[list.length - 1]?.value ?? '5m'
}
export interface MonthInfo {
key: string // YYYY-MM
label: string
start: number
end: number // 不含
}
const pad = (n: number) => String(n).padStart(2, '0')
export function monthOf(ms: number): MonthInfo {
const d = new Date(ms)
const start = new Date(d.getFullYear(), d.getMonth(), 1).getTime()
const end = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime()
return { key: `${d.getFullYear()}-${pad(d.getMonth() + 1)}`, label: `${d.getFullYear()} 年 ${d.getMonth() + 1} 月`, start, end }
}
/** 从有数据的第一个月到当前月,新的在前。 */
export function monthsWithData(sinceMs: number | null, nowMs: number): MonthInfo[] {
if (sinceMs === null) return []
const months: MonthInfo[] = []
let cursor = monthOf(sinceMs)
const last = monthOf(nowMs)
while (cursor.start <= last.start) {
months.push(cursor)
cursor = monthOf(cursor.end)
}
return months.reverse()
}
export interface RangeState {
/** 没有 month 就是“最近一段”。 */
preset: PresetValue
month: string | null
slice: SliceValue
page: number
}
export interface ResolvedRange {
query: { range?: string; from?: string; to?: string; anchor?: string }
/** 主机历史只支持 ≥ 1 小时的 range,这里给一个对应的值。 */
hostRange: string
label: string
}
const fmtDay = (ms: number) => {
const d = new Date(ms)
return `${d.getMonth() + 1}/${d.getDate()}`
}
export function resolveRange(state: RangeState, months: MonthInfo[], nowMs: number): ResolvedRange {
if (!state.month) {
const hostRange = state.preset === '5m' || state.preset === '15m' || state.preset === '30m' ? '1h' : state.preset
return { query: { range: state.preset, anchor: 'latest' }, hostRange, label: PRESETS.find((p) => p.value === state.preset)?.label ?? state.preset }
}
const month = months.find((m) => m.key === state.month) ?? monthOf(nowMs)
const slice = SLICES.find((s) => s.value === state.slice) ?? SLICES[0]
const end = Math.min(month.end, nowMs)
let from = month.start
let to = end
if (slice.ms > 0) {
from = month.start + state.page * slice.ms
to = Math.min(from + slice.ms, end)
}
const spanDays = Math.max(1, Math.round((to - from) / DAY))
return {
query: { from: new Date(from).toISOString(), to: new Date(to).toISOString() },
hostRange: spanDays >= 30 ? '30d' : `${Math.max(1, spanDays)}d`,
label: slice.ms > 0 ? `${month.label} ${fmtDay(from)} – ${fmtDay(to - 1)}` : month.label,
}
}
/** 一个月里按固定长度能翻几页。 */
function pagesIn(month: MonthInfo, sliceMs: number, nowMs: number, sinceMs: number | null): { first: number; last: number } {
const start = Math.max(month.start, sinceMs === null ? month.start : sinceMs - (sinceMs % DAY))
const end = Math.min(month.end, nowMs)
return { first: Math.max(0, Math.floor((start - month.start) / sliceMs)), last: Math.max(0, Math.ceil((end - month.start) / sliceMs) - 1) }
}
interface Props {
state: RangeState
onChange: (next: RangeState) => void
sinceMs: number | null
nowMs: number
}
export function RangePicker({ state, onChange, sinceMs, nowMs }: Props) {
const spanMs = sinceMs === null ? null : nowMs - sinceMs
const presets = useMemo(() => availablePresets(spanMs), [spanMs])
const months = useMemo(() => monthsWithData(sinceMs, nowMs), [sinceMs, nowMs])
const month = state.month ? months.find((m) => m.key === state.month) : undefined
const monthLength = month ? Math.min(month.end, nowMs) - Math.max(month.start, sinceMs ?? month.start) : 0
const slices = SLICES.filter((s) => s.ms === 0 || monthLength >= s.ms * TOLERANCE)
const slice = SLICES.find((s) => s.value === state.slice) ?? SLICES[0]
const pages = month && slice.ms > 0 ? pagesIn(month, slice.ms, nowMs, sinceMs) : undefined
return (
<div className="range-picker">
<div className="row wrap">
<Segmented
options={PRESETS.map((p) => {
const ok = presets.some((x) => x.value === p.value)
return { value: p.value, label: p.label, disabled: !ok, title: ok ? undefined : '这条线路的数据还没有这么长' }
})}
value={state.month ? ('' as PresetValue) : state.preset}
onChange={(v) => onChange({ ...state, preset: v, month: null, page: 0 })}
/>
{months.length > 0 ? (
<select
className="select"
style={{ width: 'auto' }}
disabled={spanMs === null || spanMs < DAY}
title={spanMs !== null && spanMs >= DAY ? undefined : '数据超过 1 天之后可以按月查看'}
value={state.month ?? ''}
onChange={(e) => onChange({ ...state, month: e.target.value || null, slice: 'all', page: 0 })}
aria-label="按月查看"
>
<option value="">按月查看</option>
{months.map((m) => (
<option key={m.key} value={m.key}>
{m.label}
</option>
))}
</select>
) : null}
</div>
{month ? (
<div className="row wrap" style={{ marginTop: 12 }}>
<Segmented options={slices.map((s) => ({ value: s.value, label: s.label }))} value={state.slice} onChange={(v) => onChange({ ...state, slice: v, page: 0 })} />
{pages ? (
<span className="row" style={{ gap: 4 }}>
<button type="button" className="btn icon small" disabled={state.page <= pages.first} onClick={() => onChange({ ...state, page: state.page - 1 })} aria-label="上一段">
<ChevronLeft size={16} />
</button>
<button type="button" className="btn icon small" disabled={state.page >= pages.last} onClick={() => onChange({ ...state, page: state.page + 1 })} aria-label="下一段">
<ChevronRight size={16} />
</button>
</span>
) : null}
</div>
) : null}
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
// 颜色只能出现在 shared/theme/tokens.css:其他样式一律引用令牌
export default {
rules: {
'color-no-hex': true,
'color-named': 'never',
'function-disallowed-list': ['rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch'],
},
overrides: [{ files: ['../shared/theme/tokens.css'], rules: { 'color-no-hex': null, 'function-disallowed-list': null } }],
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"paths": { "@shared/*": ["../shared/*"] }
},
"include": ["src", "../shared"],
"exclude": ["../shared/api/schema.ts"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"erasableSyntaxOnly": true
},
"include": ["vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import react from '@vitejs/plugin-react'
import { fileURLToPath } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
// 开发时把 /api 转发给后端,地址取自环境变量 BACKEND_URL,或 web/.env.local 里的 BACKEND_URL=(见 web/.env.example):
// BACKEND_URL=https://mo.oeo.one npm run dev:display
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, fileURLToPath(new URL('..', import.meta.url)), '')
const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:5000').replace(/\/+$/, '')
return {
base: '/',
plugins: [react()],
resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } },
server: {
port: 5173,
fs: { allow: ['..'] },
proxy: {
'/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
'/healthz': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
},
},
}
})
+2489
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "monitor-web",
"private": true,
"version": "0.1.0",
"workspaces": ["display", "admin"],
"scripts": {
"dev": "node dev.mjs",
"dev:display": "npm run dev -w display",
"dev:admin": "npm run dev -w admin",
"build": "npm run build -w display && npm run build -w admin",
"gen:api": "npx -y openapi-typescript ../backend/openapi/v1.json -o shared/api/schema.ts",
"lint:css": "npm run lint:css -w display && npm run lint:css -w admin"
}
}
+65
View File
@@ -0,0 +1,65 @@
import { authHeaders, isRemote, resolveUrl } from './source'
/** 后端 API 客户端:默认同源请求(开发时由 Vite 代理到后端),Cookie 会话,修改类请求带 CSRF 令牌;指到别的后端时用 Bearer 令牌(见 source.ts)。 */
export class ApiError extends Error {
readonly status: number
readonly code: string
readonly detail: string
/** 字段级的校验错误:字段名 → 信息。 */
readonly errors: Record<string, string[]>
constructor(status: number, code: string, detail: string, errors: Record<string, string[]> = {}) {
super(detail || code)
this.status = status
this.code = code
this.detail = detail
this.errors = errors
}
}
let csrfToken: string | null = null
export function setCsrf(token: string | null) {
csrfToken = token
}
export type Query = Record<string, string | number | boolean | null | undefined>
export function withQuery(path: string, query?: Query): string {
if (!query) return path
const params = new URLSearchParams()
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== null && v !== '') params.set(k, String(v))
}
const text = params.toString()
return text ? `${path}?${text}` : path
}
async function request<T>(method: string, path: string, body?: unknown, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() }
if (body !== undefined) headers['Content-Type'] = method === 'PATCH' ? 'application/merge-patch+json' : 'application/json'
if (method !== 'GET' && csrfToken) headers['X-CSRF-Token'] = csrfToken
const response = await fetch(resolveUrl(path), {
method,
headers,
credentials: isRemote() ? 'omit' : 'include',
body: body === undefined ? undefined : JSON.stringify(body),
...init,
})
if (response.status === 204) return undefined as T
const text = await response.text()
const data = text ? (JSON.parse(text) as unknown) : undefined
if (!response.ok) {
const problem = (data ?? {}) as { code?: string; detail?: string; title?: string; errors?: Record<string, string[]> | null }
throw new ApiError(response.status, problem.code ?? 'error', problem.detail ?? problem.title ?? response.statusText, problem.errors ?? {})
}
return data as T
}
export const api = {
get: <T>(path: string, query?: Query, init?: RequestInit) => request<T>('GET', withQuery(path, query), undefined, init),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body ?? {}),
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
del: <T>(path: string) => request<T>('DELETE', path),
}
File diff suppressed because it is too large. Load diff
+132
View File
@@ -0,0 +1,132 @@
/**
* 数据源:前端默认和后端同源(Cookie 会话)。也可以指到别的后端(如本机跑前端、连线上的 `https://mo.oeo.one`):
* 地址和令牌存在浏览器里(每个前端各存一份令牌),也可以用构建时的 `VITE_API_BASE` 给个默认地址。
* 跨域时不带 Cookie,只用 `Authorization: Bearer <API 令牌>`;后端要在 `Monitor__CorsOrigins` 里放行这个前端的来源。
*/
const BASE_KEY = 'monitor.api_base'
let app = 'app'
/** 每个前端启动时调用一次,令牌按前端分开存(展示端用只读令牌,管理端用管理令牌)。 */
export function configureSource(name: string) {
app = name
}
function read(key: string): string {
try {
return localStorage.getItem(key) ?? ''
} catch {
return ''
}
}
function write(key: string, value: string) {
try {
if (value) localStorage.setItem(key, value)
else localStorage.removeItem(key)
} catch {
// 浏览器禁用存储时只是不记住
}
}
export function normalizeBase(input: string): string {
const text = input.trim().replace(/\/+$/, '')
if (!text) return ''
return /^https?:\/\//i.test(text) ? text : `https://${text}`
}
/** 后端地址(不带末尾斜杠);空串 = 同源。 */
export function getBase(): string {
return normalizeBase(read(BASE_KEY) || (import.meta.env.VITE_API_BASE as string | undefined) || '')
}
export function getToken(): string {
return read(`monitor.api_token.${app}`)
}
export function setSource(base: string, token: string) {
write(BASE_KEY, normalizeBase(base))
write(`monitor.api_token.${app}`, token.trim())
}
export function setToken(token: string) {
write(`monitor.api_token.${app}`, token.trim())
}
export const isRemote = () => getBase() !== ''
/** 拼出请求地址:同源时原样返回。 */
export function resolveUrl(path: string): string {
return getBase() + path
}
export function authHeaders(): Record<string, string> {
const token = getToken()
return isRemote() && token ? { Authorization: `Bearer ${token}` } : {}
}
export interface LiveSource {
onopen: (() => void) | null
onerror: (() => void) | null
addEventListener: (type: string, listener: (event: MessageEvent<string>) => void) => void
close: () => void
}
/**
* SSE 订阅。同源用浏览器的 EventSource;跨域时 EventSource 带不了 Authorization 头,
* 所以用 fetch 读流并自己解析,断开后 3 秒重连。
*/
export function openLive(path: string): LiveSource {
if (!isRemote()) return new EventSource(path) as unknown as LiveSource
const listeners = new Map<string, ((event: MessageEvent<string>) => void)[]>()
const source: LiveSource = {
onopen: null,
onerror: null,
addEventListener: (type, listener) => listeners.set(type, [...(listeners.get(type) ?? []), listener]),
close: () => {
closed = true
controller?.abort()
},
}
let closed = false
let controller: AbortController | undefined
const run = async () => {
while (!closed) {
controller = new AbortController()
try {
const response = await fetch(resolveUrl(path), { headers: { Accept: 'text/event-stream', ...authHeaders() }, signal: controller.signal })
if (!response.ok || !response.body) throw new Error(String(response.status))
source.onopen?.()
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buffer += value
let at: number
while ((at = buffer.search(/\r?\n\r?\n/)) >= 0) {
const block = buffer.slice(0, at)
buffer = buffer.slice(at).replace(/^\r?\n\r?\n/, '')
let type = 'message'
const data: string[] = []
for (const line of block.split(/\r?\n/)) {
if (line.startsWith('event:')) type = line.slice(6).trim()
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''))
}
if (data.length > 0) for (const l of listeners.get(type) ?? []) l({ data: data.join('\n') } as MessageEvent<string>)
}
}
} catch {
// 下面统一报错并重连
}
if (closed) return
source.onerror?.()
await new Promise((resolve) => setTimeout(resolve, 3000))
}
}
void run()
return source
}
+47
View File
@@ -0,0 +1,47 @@
import type { components } from './schema'
type S = components['schemas']
/** 生成的类型把整数写成 number | string(后端的 JSON 序列化允许字符串):这里统一成 number。 */
type NarrowValue<V> = unknown extends V ? string : number extends V ? Exclude<V, string> : V extends readonly (infer U)[] ? NarrowValue<U>[] : V extends object ? Narrow<V> : V
type Narrow<T> = { [K in keyof T]: NarrowValue<T[K]> }
export type AgentDto = Narrow<S['AgentDto']>
export type ConnectedProbeDto = Narrow<S['ConnectedProbeDto']>
export type ProbeDto = Narrow<S['ProbeDto']>
export type ProbeConnectionDto = Narrow<S['ProbeConnectionDto']>
export type ProbeIdentityDto = Narrow<S['ProbeIdentityDto']>
export type LineDto = Narrow<S['LineDto']>
export type OverviewDto = Narrow<S['OverviewDto']>
export type OverviewLineDto = Narrow<S['OverviewLineDto']>
export type SeriesDto = Narrow<S['SeriesDto']>
export type CompareDto = Narrow<S['CompareDto']>
export type SummaryDto = Narrow<S['SummaryDto']>
export type TaskDto = Narrow<S['TaskDto']>
export type TestDto = Narrow<S['TestDto']>
export type TestResultDto = Narrow<S['TestResultDto']>
export type ScoreDto = Narrow<S['ScoreDto']>
export type PlanDto = Narrow<S['PlanDto']>
export type KindDto = Narrow<S['KindDto']>
export type ParamDto = Narrow<S['ParamDto']>
export type MetricDto = Narrow<S['MetricDto']>
export type JoinTokenDto = Narrow<S['JoinTokenDto']>
export type CreatedJoinTokenDto = Narrow<S['CreatedJoinTokenDto']>
export type ApiTokenDto = Narrow<S['ApiTokenDto']>
export type CreatedApiTokenDto = Narrow<S['CreatedApiTokenDto']>
export type SettingDto = Narrow<S['SettingDto']>
export type UserDto = Narrow<S['UserDto']>
export type EventDto = Narrow<S['EventDto']>
export type GapDto = Narrow<S['GapDto']>
export type RecomputeDto = Narrow<S['RecomputeDto']>
export type RawChunkMetaDto = Narrow<S['RawChunkMetaDto']>
export type LoginResponse = Narrow<S['LoginResponse']>
export type MeDto = Narrow<S['MeDto']>
export interface Page<T> {
items: T[]
next_cursor?: string | null
}
/** 后端数字有时按 integer|string 声明:统一成 number。 */
export const n = (v: number | string | null | undefined): number => (v === null || v === undefined ? 0 : Number(v))
+189
View File
@@ -0,0 +1,189 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import uPlot, { type AlignedData, type Options } from 'uplot'
import 'uplot/dist/uPlot.min.css'
export interface ChartSeries {
label: string
values: (number | null)[]
/** CSS 变量名,例如 --chart-1。 */
color: string
dash?: number[]
fill?: boolean
}
interface Props {
/** 时间轴,Unix 毫秒。 */
t: number[]
series: ChartSeries[]
height?: number
/** Y 轴的单位后缀(ms、%)。 */
unit?: string
/** 为真时 Y 轴从 0 开始。 */
zero?: boolean
/** 缺口:这些时间段用底色标出。 */
gaps?: { from: number; to: number }[]
}
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
}
function formatValue(v: number | null | undefined, unit: string): string {
if (v === null || v === undefined) return '—'
const text = Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2)
return `${text}${unit}`
}
/**
* uPlot 的封装:
* - 跟随主题(明暗切换时重绘)和容器宽度,空值断线;
* - 在图上拖动选一段放大预览,双击或点「还原」恢复;数据刷新时保持放大的区间;
* - 鼠标悬停显示这个时刻每条线的值。
*/
export function TimeChart({ t, series, height = 220, unit = '', zero = true, gaps = [] }: Props) {
const host = useRef<HTMLDivElement>(null)
const tip = useRef<HTMLDivElement>(null)
const plot = useRef<uPlot | null>(null)
const zoom = useRef<{ min: number; max: number } | null>(null)
const [zoomed, setZoomed] = useState(false)
const reset = useCallback(() => {
zoom.current = null
setZoomed(false)
const u = plot.current
if (u && u.data[0].length > 0) u.setScale('x', { min: u.data[0][0], max: u.data[0][u.data[0].length - 1] })
}, [])
useEffect(() => {
const el = host.current
if (!el) return
const build = () => {
plot.current?.destroy()
const axis = cssVar('--chart-axis')
const grid = cssVar('--chart-grid')
const font = '12px ' + (cssVar('--font-sans') || 'sans-serif')
const shade = cssVar('--app-warn-container')
const options: Options = {
width: el.clientWidth || 600,
height,
legend: { show: false },
cursor: { drag: { x: true, y: false, setScale: false }, points: { size: 6 } },
select: { show: true, left: 0, top: 0, width: 0, height: 0 },
scales: {
x: { time: true, range: (_u, min, max) => (zoom.current ? [zoom.current.min, zoom.current.max] : [min, max]) },
y: { range: (_u, min, max) => [zero ? Math.min(0, min) : min, max <= min ? min + 1 : max + (max - min) * 0.08] },
},
axes: [
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { stroke: grid }, font },
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { show: false }, font, size: 56, values: (_u, ticks) => ticks.map((v) => `${v}${unit}`) },
],
series: [
{},
...series.map((s) => {
const color = cssVar(s.color)
return {
label: s.label,
stroke: color,
width: 1.8,
dash: s.dash,
fill: s.fill ? color + '22' : undefined,
spanGaps: false,
points: { show: false },
}
}),
],
hooks: {
ready: [
(u) => {
u.over.addEventListener('dblclick', reset)
},
],
setSelect: [
(u) => {
if (u.select.width < 6) return
const min = u.posToVal(u.select.left, 'x')
const max = u.posToVal(u.select.left + u.select.width, 'x')
if (max - min < 5) return
zoom.current = { min, max }
setZoomed(true)
u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false)
u.setScale('x', { min, max })
},
],
setCursor: [
(u) => {
const box = tip.current
if (!box) return
const idx = u.cursor.idx
if (idx === null || idx === undefined || (u.cursor.left ?? -1) < 0) {
box.style.display = 'none'
return
}
const when = new Date(u.data[0][idx] * 1000)
const lines = [when.toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })]
for (let i = 0; i < series.length; i++) lines.push(`${series[i].label} ${formatValue(u.data[i + 1][idx], unit)}`)
box.textContent = lines.join('\n')
box.style.display = 'block'
const width = box.offsetWidth
const left = (u.cursor.left ?? 0) + 14
box.style.left = `${left + width > u.over.clientWidth ? Math.max(0, (u.cursor.left ?? 0) - width - 14) : left}px`
box.style.top = '8px'
},
],
drawClear: [
(u) => {
const { ctx } = u
ctx.save()
ctx.fillStyle = shade
for (const g of gaps) {
const x0 = Math.max(u.bbox.left, u.valToPos(g.from / 1000, 'x', true))
const x1 = Math.min(u.bbox.left + u.bbox.width, u.valToPos(g.to / 1000, 'x', true))
if (x1 > x0) ctx.fillRect(x0, u.bbox.top, x1 - x0, u.bbox.height)
}
ctx.restore()
},
],
},
}
const data: AlignedData = [t.map((v) => v / 1000), ...series.map((s) => s.values.map((v) => (v === null ? null : v)))]
plot.current = new uPlot(options, data, el)
// 把提示框放进绘图区,这样它的位置和光标是同一个坐标系
if (tip.current) plot.current.over.appendChild(tip.current)
}
build()
const resize = new ResizeObserver(() => plot.current?.setSize({ width: el.clientWidth, height }))
resize.observe(el)
const theme = new MutationObserver(build)
theme.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
return () => {
resize.disconnect()
theme.disconnect()
plot.current?.destroy()
plot.current = null
}
}, [t, series, height, unit, zero, gaps, reset])
return (
<div className="chart-wrap">
<div className="chart-legend">
{series.map((s) => (
<span key={s.label}>
<i style={{ background: `var(${s.color})` }} />
{s.label}
</span>
))}
<span className="chart-hint">
{zoomed ? (
<button type="button" className="btn small text" onClick={reset}>
还原缩放
</button>
) : (
'拖动选择一段放大,双击还原'
)}
</span>
</div>
<div ref={host} className="chart" />
<div ref={tip} className="chart-tip" />
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
export function fmtMs(v: number | null | undefined, digits = 1): string {
if (v === null || v === undefined || Number.isNaN(v)) return '—'
if (v >= 1000) return `${(v / 1000).toFixed(2)} s`
return `${v.toFixed(v >= 100 ? 0 : digits)} ms`
}
export function fmtPct(v: number | null | undefined, digits = 2): string {
if (v === null || v === undefined || Number.isNaN(v)) return '—'
return `${v.toFixed(v >= 10 ? 1 : digits)}%`
}
export function fmtMbps(v: number | null | undefined): string {
if (v === null || v === undefined || Number.isNaN(v)) return '—'
return v >= 100 ? `${v.toFixed(0)} Mbit/s` : `${v.toFixed(1)} Mbit/s`
}
export function fmtNum(v: number | null | undefined, digits = 0): string {
if (v === null || v === undefined || Number.isNaN(v)) return '—'
return v.toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
export function fmtTime(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
export function fmtAgo(iso: string | null | undefined): string {
if (!iso) return '—'
const seconds = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000)
if (seconds < 60) return `${Math.floor(seconds)} 秒前`
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟前`
if (seconds < 86400) return `${Math.floor(seconds / 3600)} 小时前`
return `${Math.floor(seconds / 86400)} 天前`
}
export function fmtDuration(ms: number): string {
const s = Math.floor(ms / 1000)
if (s < 60) return `${s} 秒`
if (s < 3600) return `${Math.floor(s / 60)} 分钟`
if (s < 86400) return `${Math.floor(s / 3600)} 小时 ${Math.floor((s % 3600) / 60)} 分钟`
return `${Math.floor(s / 86400)} 天 ${Math.floor((s % 86400) / 3600)} 小时`
}
/** 评分 → 状态色(和后端模型的分档一致:≥85 好,≥60 一般,其余差)。 */
export function scoreTone(score: number | null | undefined): 'ok' | 'warn' | 'bad' | '' {
if (score === null || score === undefined) return ''
if (score >= 85) return 'ok'
if (score >= 60) return 'warn'
return 'bad'
}
+79
View File
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ApiError } from './api/client'
export interface QueryState<T> {
data: T | undefined
error: ApiError | Error | undefined
loading: boolean
reload: () => void
}
/** 取数:依赖变化时重新取;可选轮询。重新取数时保留上一次的数据,避免界面闪烁。 */
export function useQuery<T>(fetcher: () => Promise<T>, deps: unknown[], intervalMs?: number): QueryState<T> {
const [data, setData] = useState<T>()
const [error, setError] = useState<ApiError | Error>()
const [loading, setLoading] = useState(true)
const [tick, setTick] = useState(0)
const fetcherRef = useRef(fetcher)
fetcherRef.current = fetcher
useEffect(() => {
let alive = true
setLoading(true)
fetcherRef
.current()
.then((value) => {
if (!alive) return
setData(value)
setError(undefined)
})
.catch((e: unknown) => {
if (alive) setError(e instanceof Error ? e : new Error(String(e)))
})
.finally(() => {
if (alive) setLoading(false)
})
return () => {
alive = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, tick])
useEffect(() => {
if (!intervalMs) return
const id = window.setInterval(() => setTick((t) => t + 1), intervalMs)
return () => window.clearInterval(id)
}, [intervalMs])
const reload = useCallback(() => setTick((t) => t + 1), [])
return { data, error, loading, reload }
}
export type Theme = 'light' | 'dark'
export function useTheme(): [Theme, () => void] {
const [theme, setTheme] = useState<Theme>(() => (document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'))
const toggle = useCallback(() => {
const next: Theme = theme === 'dark' ? 'light' : 'dark'
document.documentElement.dataset.theme = next
try {
localStorage.setItem('monitor-theme', next)
} catch {
// 存储不可用时只在本次会话生效
}
setTheme(next)
}, [theme])
return [theme, toggle]
}
/** 首屏之前调用:按保存的选择或系统偏好设置主题。 */
export function applyStoredTheme() {
let theme: string | null = null
try {
theme = localStorage.getItem('monitor-theme')
} catch {
// 忽略
}
const dark = theme ? theme === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
}
+162
View File
@@ -0,0 +1,162 @@
/**
* 后端的错误信息是英文(API 契约就是英文):前端在这里把常见的错误码、字段名和校验信息翻成中文。
* 没有收录的信息原样显示,不会丢。
*/
const CODES: Record<string, string> = {
validation_failed: '填写的内容有问题',
budget_exceeded: '超出了这台节点本地设定的带宽或包速率上限',
unauthorized: '用户名或密码不对,或者登录已经失效',
csrf_failed: '登录状态已经过期,请刷新页面重新登录',
forbidden: '没有权限做这件事',
site_private: '站点未公开:请管理员登录,或在管理端把站点设为公开',
not_found: '没有找到',
agent_not_found: '没有找到这个 Agent',
probe_not_found: '没有找到这个 Probe',
probe_revoked: '这个 Probe 已经被吊销,不能再换密钥,请新建一个 Probe',
probe_required: '有多个 Probe,请指定具体的线路',
task_not_found: '没有找到这个任务',
test_not_found: '没有找到这次测试',
chunk_not_found: '没有找到这个原始数据块',
token_not_found: '没有找到这个令牌',
unknown_kind: '不认识这种采集方法',
unknown_plan: '不认识这个测试方案',
name_conflict: '名称已经被使用,换一个',
probe_offline: '这个 Probe 现在没有连着这个 Agent',
test_busy: '这条线路上已经有测试在运行',
agent_not_active: '这个 Agent 还没有启用',
agent_offline: 'Agent 不在线',
raw_expired: '这份原始数据已经超过保留期限',
join_token_expired: '加入令牌已经过期',
precondition_failed: '这条记录刚被别人改过,请刷新后再改',
payload_too_large: '提交的内容太大',
range_too_large: '选的时间范围太大,请缩短或增大步长',
invalid_step: '步长不合法',
invalid_range: '时间范围不合法',
unknown_metric: '不认识这个指标',
unknown_preset: '不认识这个指标分组',
too_many_metrics: '一次选的指标太多',
too_many_requests: '请求太频繁,请稍后再试',
too_many_streams: '实时连接太多',
storage_unavailable: '存储暂时不可用,请稍后再试',
fleet_unsupported: '这个 Agent 不支持这项功能',
fleet_join_token_invalid: '加入令牌无效',
fleet_node_disabled: '这个节点已被停用',
internal_error: '服务器出错了,请稍后再试',
method_not_allowed: '不支持这个操作',
unsupported_media_type: '提交的格式不对',
}
const FIELDS: Record<string, string> = {
name: '名称',
username: '用户名',
password: '密码',
note: '备注',
labels: '标签',
public_host: '公开地址',
public_visible: '展示页可见',
max_uses: '可用次数',
expires_in_hours: '有效期',
scope: '类型',
kind: '采集方法',
target: '目标',
schedule: '调度',
agent_id: 'Agent',
base_task_id: '要覆盖的模板',
plan: '方案',
params: '参数',
range: '时间范围',
from: '开始时间',
to: '结束时间',
step: '步长',
lines: '线路',
metric: '指标',
metrics: '指标',
cursor: '分页游标',
anchor: '时间锚点',
tz: '时区',
}
const MESSAGES: [RegExp, (m: RegExpMatchArray) => string][] = [
[/^is required$/, () => '不能为空'],
[/^must be 1-64 characters( of letters, digits|: letters \(any language\), digits), '\.', '_' or '-'$/, () => '只能是 1–64 个字、字母、数字、点、下划线或连字符(可以用中文)'],
[/^must be 3-32 characters of letters, digits, '\.', '_' or '-'$/, () => '只能是 3–32 个字母、数字、点、下划线或连字符'],
[/^must be 1-64 characters$/, () => '长度需要 1–64 个字符'],
[/^must be (\d+)-(\d+) characters$/, (m) => `长度需要 ${m[1]}–${m[2]} 个字符`],
[/^must be at most (\d+) characters$/, (m) => `最多 ${m[1]} 个字符`],
[/^at most (\d+) labels of (\d+)-(\d+) characters$/, (m) => `最多 ${m[1]} 个标签,每个 ${m[2]}–${m[3]} 个字符`],
[/^must be a host name or IP address$/, () => '必须是域名或 IP 地址'],
[/^must be an IP address with an optional port$/, () => '必须是 IP 地址,端口可选'],
[/^must be between (-?[\d.]+) and (-?[\d.]+)$/, (m) => `必须在 ${m[1]} 到 ${m[2]} 之间`],
[/^must be an integer$/, () => '必须是整数'],
[/^must be a number$/, () => '必须是数字'],
[/^must be a boolean$/, () => '必须是 是 / 否'],
[/^must be one of: (.*)$/, (m) => `只能是:${m[1]}`],
[/^is not a parameter of this kind$/, () => '这种采集方法没有这个参数'],
[/^must be line or target$/, () => '只能是线路任务或目标任务'],
[/^must be read or admin$/, () => '只能是 read(只读)或 admin(全部权限)'],
[/^must be now or latest$/, () => '只能是 now 或 latest'],
[/^is not a valid cursor$/, () => '不是有效的分页游标'],
[/^is mutually exclusive with from and to$/, () => '不能和开始、结束时间同时使用'],
[/^only UTC is supported at the moment$/, () => '目前只支持 UTC'],
[/^line tasks have no target$/, () => '线路任务没有目标'],
[/^agent_id is required for target tasks$/, () => '目标任务必须指定 Agent'],
[/^per-agent line tasks must be overrides \(base_task_id\)$/, () => '针对某个 Agent 的线路任务必须是覆盖(要选一个模板)'],
[/^must reference a line template and requires agent_id$/, () => '必须引用一个线路模板,并且要指定 Agent'],
[/^an override must use the same kind as its template$/, () => '覆盖必须和它的模板是同一种采集方法'],
[/^once tasks are created through the tests API$/, () => '一次性测试请在「按需测试」里发起'],
[/^unknown kind (.+)$/, (m) => `不认识采集方法 ${m[1]}`],
[/^unknown plan (.+)$/, (m) => `不认识测试方案 ${m[1]}`],
[/^a probe named (.+) already exists$/, (m) => `已经有一个叫 ${m[1]} 的 Probe`],
[/^probe (\S+) has no session with this agent$/, () => '这个 Probe 现在没有连着这个 Agent'],
[/^probe (\S+) is not connected to this agent$/, () => '这个 Probe 现在没有连着这个 Agent'],
[/^this probe has no session with this agent$/, () => '这个 Probe 现在没有连着这个 Agent'],
[/^the probe has no session with this agent$/, () => '这个 Probe 现在没有连着这个 Agent'],
[/^another test is already running on this line$/, () => '这条线路上已经有测试在运行'],
[/^agent is offline$/, () => 'Agent 不在线'],
[/^agent is not active$/, () => 'Agent 还没有启用'],
[/^agent not found$/, () => '没有找到这个 Agent'],
[/^probe not found$/, () => '没有找到这个 Probe'],
[/^line not found$/, () => '没有找到这条线路'],
[/^there is no active probe$/, () => '现在没有可用的 Probe'],
[/^several probes exist.*$/, () => '有多个 Probe,请指定具体的线路'],
[/^a revoked probe cannot get a new key.*$/, () => '已吊销的 Probe 不能换密钥,请新建一个 Probe'],
[/^this agent does not support (\S+), which the (\S+) plan needs$/, (m) => `这个 Agent 不支持 ${m[1]},而 ${m[2]} 方案需要它`],
[/^the agent did not accept the test$/, () => 'Agent 没有接受这次测试'],
[/^a valid X-CSRF-Token header is required$/, () => '登录状态已经过期,请刷新页面重新登录'],
]
export function translateMessage(message: string): string {
const text = message.trim()
for (const [pattern, render] of MESSAGES) {
const match = text.match(pattern)
if (match) return render(match)
}
return text
}
export const translateField = (field: string): string => FIELDS[field] ?? field
export function translateCode(code: string): string | undefined {
return CODES[code]
}
const SETTINGS: Record<string, string> = {
'site.public': '匿名访客能不能读展示页的数据(关闭时只有登录的管理员能看)',
'retention.raw_days': '持续任务的原始数据块保留多少天',
'retention.test_raw_days': '按需测试的原始数据块保留多少天',
'retention.metric_10s_hours': '10 秒精度的指标保留多少小时',
'retention.metric_1m_days': '1 分钟精度的指标保留多少天',
'retention.metric_5m_days': '5 分钟精度的指标保留多少天',
'retention.events_days': '事件保留多少天',
'retention.host_days': 'Agent 主机资源占用(每分钟一个样本)保留多少天',
}
const PLANS: Record<string, { title: string; description: string }> = {
hy2_ladder: { title: 'Hysteria2 阶梯测试', description: 'UDP 灌包,依次以 10、25、50、100 Mbit/s 各跑 10 秒,用来判断 Brutal 固定码率拥塞控制能不能跑得起来。' },
reality_session: { title: 'REALITY 会话测试', description: '建连测试、4 秒 TLS 批量传输(量吞吐峰值)和一个长连接保持。' },
}
export const translateSetting = (key: string, fallback: string): string => SETTINGS[key] ?? fallback
export const translatePlan = (id: string, title: string, description: string): { title: string; description: string } => PLANS[id] ?? { title, description }
+637
View File
@@ -0,0 +1,637 @@
@import "./tokens.css";
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: var(--app-bg);
color: var(--app-ink);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3,
h4,
p {
margin: 0;
}
a {
color: var(--app-accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
.mono {
font-family: var(--font-mono);
font-size: 0.92em;
}
.muted {
color: var(--app-ink-muted);
}
.faint {
color: var(--app-ink-faint);
}
.num {
font-variant-numeric: tabular-nums;
}
.row {
display: flex;
align-items: center;
gap: 12px;
}
.row.wrap {
flex-wrap: wrap;
}
.grow {
flex: 1;
min-width: 0;
}
.stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.right {
margin-left: auto;
}
/* ── 卡片:靠色调分层,没有描边 ── */
.card {
background: var(--app-card);
border-radius: var(--r-card);
padding: 20px 24px;
}
.card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
}
.inset {
background: var(--app-card-inset);
border-radius: var(--r-control);
padding: 12px 16px;
}
.divider {
height: 1px;
background: var(--app-outline-soft);
border: 0;
margin: 16px 0;
}
/* ── 按钮 ── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 36px;
padding: 0 18px;
border: 0;
border-radius: var(--r-pill);
background: var(--app-card-inset);
color: var(--app-ink);
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--app-card-inset), var(--app-ink) calc(var(--state-hover) * 100%));
}
.btn:disabled {
opacity: 0.45;
cursor: default;
}
.btn.primary {
background: var(--app-primary);
color: var(--app-on-primary);
}
.btn.primary:hover:not(:disabled) {
background: color-mix(in srgb, var(--app-primary), var(--app-on-primary) calc(var(--state-hover) * 100%));
}
.btn.accent {
background: var(--app-accent);
color: var(--app-card);
}
.btn.text {
background: transparent;
color: var(--app-accent);
padding: 0 12px;
}
.btn.text:hover:not(:disabled) {
background: color-mix(in srgb, var(--app-accent), transparent calc((1 - var(--state-hover)) * 100%));
}
.btn.danger {
background: var(--app-bad-container);
color: var(--app-bad);
}
.btn.icon {
width: 36px;
padding: 0;
}
.btn.small {
height: 30px;
padding: 0 12px;
font-size: 13px;
}
/* ── 连接式分组(M3 Expressive connected button group):组内小圆角、外角大圆角、选中段变胶囊 ── */
.group {
display: inline-flex;
gap: 2px;
}
.group > button {
height: 36px;
padding: 0 16px;
border: 0;
background: var(--app-card-inset);
color: var(--app-ink-muted);
font-weight: 500;
cursor: pointer;
border-radius: var(--r-group-inner);
transition:
border-radius 0.18s,
background 0.15s;
}
.group > button:first-child {
border-radius: var(--r-group-outer) var(--r-group-inner) var(--r-group-inner) var(--r-group-outer);
}
.group > button:last-child {
border-radius: var(--r-group-inner) var(--r-group-outer) var(--r-group-outer) var(--r-group-inner);
}
.group > button:only-child {
border-radius: var(--r-group-outer);
}
.group > button:hover {
background: color-mix(in srgb, var(--app-card-inset), var(--app-ink) calc(var(--state-hover) * 100%));
}
.group > button.on {
background: var(--app-accent-container);
color: var(--app-on-accent-container);
border-radius: var(--r-group-outer);
}
/* ── 表单 ── */
.field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.field > label,
.field > .label {
font-size: 12px;
color: var(--app-ink-muted);
}
.field .hint {
font-size: 12px;
color: var(--app-ink-faint);
}
.input,
.select,
.textarea {
width: 100%;
min-height: 40px;
padding: 8px 14px;
border: 0;
border-radius: var(--r-control);
background: var(--app-card-inset);
outline: 2px solid transparent;
outline-offset: -2px;
transition: outline-color 0.15s;
}
.textarea {
resize: vertical;
font-family: var(--font-mono);
font-size: 13px;
}
.input:focus,
.select:focus,
.textarea:focus {
outline-color: var(--app-accent);
}
.input::placeholder {
color: var(--app-ink-faint);
}
.check {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.check input {
accent-color: var(--app-accent);
width: 16px;
height: 16px;
}
/* ── 小件 ── */
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 24px;
padding: 0 10px;
border-radius: var(--r-pill);
background: var(--app-card-inset);
color: var(--app-ink-muted);
font-size: 12px;
white-space: nowrap;
}
.chip.ok {
background: var(--app-ok-container);
color: var(--app-ok);
}
.chip.warn {
background: var(--app-warn-container);
color: var(--app-warn);
}
.chip.bad {
background: var(--app-bad-container);
color: var(--app-bad);
}
.chip.accent {
background: var(--app-accent-container);
color: var(--app-on-accent-container);
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--app-idle);
}
.dot.ok {
background: var(--app-ok);
}
.dot.warn {
background: var(--app-warn);
}
.dot.bad {
background: var(--app-bad);
}
/* ── 表格:细分隔线,没有边框 ── */
.table {
width: 100%;
border-collapse: collapse;
}
.table th {
text-align: left;
font-weight: 500;
font-size: 12px;
color: var(--app-ink-faint);
padding: 8px 12px;
border-bottom: 1px solid var(--app-outline);
white-space: nowrap;
}
.table td {
padding: 12px;
border-bottom: 1px solid var(--app-outline-soft);
vertical-align: middle;
}
.table tr:last-child td {
border-bottom: 0;
}
.table.hover tbody tr {
cursor: pointer;
}
.table.hover tbody tr:hover td {
background: var(--app-card-hover);
}
.table th.r,
.table td.r {
text-align: right;
}
/* ── 指标块 ── */
.stat {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.stat .k {
font-size: 12px;
color: var(--app-ink-faint);
}
.stat .v {
font-size: 24px;
font-weight: 600;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.stat .v small {
font-size: 12px;
font-weight: 400;
color: var(--app-ink-muted);
margin-left: 2px;
}
.stat .v.ok {
color: var(--app-ok);
}
.stat .v.warn {
color: var(--app-warn);
}
.stat .v.bad {
color: var(--app-bad);
}
.grid {
display: grid;
gap: 16px;
}
.grid.cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid.cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid.cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.grid.stats {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
@media (max-width: 900px) {
.grid.cols-2,
.grid.cols-3,
.grid.cols-4 {
grid-template-columns: minmax(0, 1fr);
}
}
/* ── 对话框与提示 ── */
.scrim {
position: fixed;
inset: 0;
background: color-mix(in srgb, var(--app-scrim), transparent 60%);
display: grid;
place-items: center;
z-index: 50;
padding: 16px;
}
.dialog {
background: var(--app-card);
border-radius: 28px;
padding: 24px;
width: min(560px, 100%);
max-height: calc(100vh - 32px);
overflow: auto;
box-shadow: 0 8px 32px color-mix(in srgb, var(--app-shadow), transparent 80%);
}
.dialog h2 {
font-size: 20px;
margin-bottom: 16px;
}
.dialog .actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
.toasts {
position: fixed;
right: 24px;
bottom: 24px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 60;
}
.toast {
background: var(--app-primary);
color: var(--app-on-primary);
border-radius: var(--r-control);
padding: 10px 16px;
max-width: 420px;
box-shadow: 0 4px 16px color-mix(in srgb, var(--app-shadow), transparent 75%);
}
.toast.bad {
background: var(--app-bad);
color: var(--app-card);
}
.empty {
padding: 40px 16px;
text-align: center;
color: var(--app-ink-faint);
}
.notice {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px 18px;
border-radius: var(--r-control);
background: var(--app-accent-container);
color: var(--app-on-accent-container);
}
.notice.warn {
background: var(--app-warn-container);
color: var(--app-warn);
}
.notice.bad {
background: var(--app-bad-container);
color: var(--app-bad);
}
pre.code {
margin: 0;
padding: 12px 16px;
border-radius: var(--r-control);
background: var(--app-card-inset);
font-family: var(--font-mono);
font-size: 12.5px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid var(--app-outline);
border-top-color: var(--app-accent);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ── 图表 ── */
/* 拖动选区和十字光标:uPlot 自带的颜色在暗色下几乎看不见,换成主题令牌 */
.chart .u-select {
background: color-mix(in srgb, var(--app-accent), transparent 72%);
border-left: 1px solid var(--app-accent);
border-right: 1px solid var(--app-accent);
}
.chart .u-cursor-x,
.chart .u-cursor-y {
border-color: var(--app-ink-faint);
}
.chart-wrap {
position: relative;
}
.chart-hint {
margin-left: auto;
color: var(--app-ink-faint);
}
.chart-hint .btn {
height: 22px;
padding: 0 8px;
}
.chart-tip {
display: none;
position: absolute;
z-index: 5;
pointer-events: none;
padding: 6px 10px;
border-radius: 8px;
background: var(--app-primary);
color: var(--app-on-primary);
font-size: 12px;
line-height: 1.5;
white-space: pre;
font-variant-numeric: tabular-nums;
}
.group > button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.group > button:disabled:hover {
background: var(--app-card-inset);
}
.chart {
width: 100%;
}
.chart .u-legend {
font-family: var(--font-sans);
font-size: 12px;
color: var(--app-ink-muted);
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
font-size: 12px;
color: var(--app-ink-muted);
margin-bottom: 4px;
}
.chart-legend i {
display: inline-block;
width: 10px;
height: 3px;
border-radius: 2px;
margin-right: 6px;
vertical-align: middle;
}
+96
View File
@@ -0,0 +1,96 @@
/*
* 「纸墨」主题令牌:暖中性纸白底、深墨主色、唯一的强调色钢笔墨水蓝、复古低饱和的状态色。
* 颜色只在这个文件里出现;其他样式一律引用令牌(stylelint 会拦住 hex / rgb / 具名色)。
*/
:root {
color-scheme: light;
--app-bg: #f4f2ed;
--app-card: #fbfaf7;
--app-card-inset: #ece9e2;
--app-card-hover: #f0ede6;
--app-outline: #d8d4c9;
--app-outline-soft: #e5e1d7;
--app-ink: #1f1d18;
--app-ink-muted: #5c5950;
--app-ink-faint: #8b877b;
--app-primary: #2a2720;
--app-on-primary: #f7f6f3;
--app-accent: #2a5da8;
--app-accent-container: #dce6f4;
--app-on-accent-container: #12305c;
--app-ok: #566f2c;
--app-ok-container: #e3ead2;
--app-warn: #9c6b0e;
--app-warn-container: #f1e4c4;
--app-bad: #a23e2c;
--app-bad-container: #f2d9d2;
--app-idle: #8b877b;
--app-scrim: #1f1d18;
--app-shadow: #1f1d18;
--chart-1: #2a5da8;
--chart-2: #b9770e;
--chart-3: #a23e2c;
--chart-4: #566f2c;
--chart-5: #7a4f7a;
--chart-6: #2f7a76;
--chart-grid: #e1ddd2;
--chart-axis: #7a766b;
--r-card: 24px;
--r-group-outer: 28px;
--r-group-inner: 8px;
--r-control: 12px;
--r-pill: 999px;
--state-hover: 0.08;
--state-press: 0.12;
--font-sans: "Source Han Sans SC", "Noto Sans SC", "Source Han Sans CN", "Microsoft YaHei UI", "PingFang SC", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, monospace;
}
:root[data-theme="dark"] {
color-scheme: dark;
--app-bg: #181714;
--app-card: #211f1b;
--app-card-inset: #2b2924;
--app-card-hover: #28261f;
--app-outline: #3b3830;
--app-outline-soft: #2f2d27;
--app-ink: #ece9e1;
--app-ink-muted: #b0ab9e;
--app-ink-faint: #7f7b70;
--app-primary: #ece9e1;
--app-on-primary: #1f1d18;
--app-accent: #8db0e6;
--app-accent-container: #26364f;
--app-on-accent-container: #cfe0f8;
--app-ok: #a6c06c;
--app-ok-container: #2c3520;
--app-warn: #dcae52;
--app-warn-container: #3a2f16;
--app-bad: #e3917f;
--app-bad-container: #432720;
--app-idle: #7f7b70;
--chart-1: #8db0e6;
--chart-2: #e0a74d;
--chart-3: #e3917f;
--chart-4: #a6c06c;
--chart-5: #c79ac7;
--chart-6: #72c0bb;
--chart-grid: #32302a;
--chart-axis: #8f8b7f;
}
+279
View File
@@ -0,0 +1,279 @@
import { Check, Copy, Moon, Plug, Sun } from 'lucide-react'
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
import { ApiError } from '../api/client'
import { getBase, getToken, normalizeBase, setSource } from '../api/source'
import { useTheme } from '../hooks'
import { translateCode, translateField, translateMessage } from '../i18n'
/** 连接式分组(单选):组内小圆角、外角大圆角,选中段变胶囊。 */
export function Segmented<T extends string>({ options, value, onChange }: { options: { value: T; label: ReactNode; disabled?: boolean; title?: string }[]; value: T; onChange: (v: T) => void }) {
return (
<div className="group" role="tablist">
{options.map((o) => (
<button key={o.value} type="button" role="tab" aria-selected={o.value === value} className={o.value === value ? 'on' : ''} disabled={o.disabled} title={o.title} onClick={() => onChange(o.value)}>
{o.label}
</button>
))}
</div>
)
}
export function Chip({ tone = '', children }: { tone?: 'ok' | 'warn' | 'bad' | 'accent' | ''; children: ReactNode }) {
return <span className={`chip ${tone}`}>{children}</span>
}
export function Dot({ tone = '' }: { tone?: 'ok' | 'warn' | 'bad' | '' }) {
return <span className={`dot ${tone}`} />
}
export function Spinner() {
return <span className="spinner" role="status" aria-label="加载中" />
}
export function Empty({ children }: { children: ReactNode }) {
return <div className="empty">{children}</div>
}
export function Stat({ label, value, unit, tone = '' }: { label: string; value: ReactNode; unit?: string; tone?: 'ok' | 'warn' | 'bad' | '' }) {
return (
<div className="stat">
<span className="k">{label}</span>
<span className={`v ${tone}`}>
{value}
{unit ? <small>{unit}</small> : null}
</span>
</div>
)
}
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<div className="field">
<span className="label">{label}</span>
{children}
{hint ? <span className="hint">{hint}</span> : null}
</div>
)
}
export function Dialog({ title, onClose, children, actions }: { title: string; onClose: () => void; children: ReactNode; actions?: ReactNode }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div className="scrim" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div className="dialog" role="dialog" aria-modal="true" aria-label={title}>
<h2>{title}</h2>
{children}
{actions ? <div className="actions">{actions}</div> : null}
</div>
</div>
)
}
/** 错误提示:把常见的错误码、字段名和校验信息翻成中文;没有收录的信息原样显示。 */
export function describeError(e: unknown): string {
if (e instanceof ApiError) {
const fields = Object.entries(e.errors).flatMap(([field, messages]) => messages.map((m) => `${translateField(field)}:${translateMessage(m)}`))
if (fields.length > 0) return fields.join(';')
const byCode = translateCode(e.code)
// 有具体说明的(例如“要覆盖的模板不存在”)优先给说明,翻不出来就用错误码对应的话
const detail = e.detail ? translateMessage(e.detail) : ''
if (detail && detail !== e.detail) return detail
return byCode ?? (detail || e.code)
}
return e instanceof Error ? e.message : String(e)
}
export function ErrorNotice({ error }: { error: unknown }) {
if (!error) return null
const privateSite = error instanceof ApiError && error.code === 'site_private'
return <div className={`notice ${privateSite ? '' : 'bad'}`}>{describeError(error)}</div>
}
/* ── 提示 ── */
interface ToastItem {
id: number
text: string
bad: boolean
}
const ToastContext = createContext<(text: string, bad?: boolean) => void>(() => undefined)
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([])
const nextId = useRef(1)
const push = useCallback((text: string, bad = false) => {
const id = nextId.current++
setItems((list) => [...list, { id, text, bad }])
window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), bad ? 6000 : 3000)
}, [])
return (
<ToastContext.Provider value={push}>
{children}
<div className="toasts" aria-live="polite">
{items.map((t) => (
<div key={t.id} className={`toast ${t.bad ? 'bad' : ''}`}>
{t.text}
</div>
))}
</div>
</ToastContext.Provider>
)
}
export const useToast = () => useContext(ToastContext)
/** 执行一个操作,成功提示、失败显示错误。 */
export function useAction() {
const toast = useToast()
const [busy, setBusy] = useState(false)
const run = useCallback(
async (action: () => Promise<unknown>, done?: string) => {
setBusy(true)
try {
await action()
if (done) toast(done)
return true
} catch (e) {
toast(describeError(e), true)
return false
} finally {
setBusy(false)
}
},
[toast],
)
return { busy, run }
}
/** 确认对话框。 */
export function Confirm({ title, children, confirmText = '确定', danger, onConfirm, onClose }: { title: string; children: ReactNode; confirmText?: string; danger?: boolean; onConfirm: () => void | Promise<void>; onClose: () => void }) {
const [busy, setBusy] = useState(false)
return (
<Dialog
title={title}
onClose={onClose}
actions={
<>
<button type="button" className="btn text" onClick={onClose}>
取消
</button>
<button
type="button"
className={`btn ${danger ? 'danger' : 'primary'}`}
disabled={busy}
onClick={async () => {
setBusy(true)
try {
await onConfirm()
} finally {
setBusy(false)
onClose()
}
}}
>
{confirmText}
</button>
</>
}
>
<div className="muted">{children}</div>
</Dialog>
)
}
export function CopyButton({ text, label = '复制' }: { text: string; label?: string }) {
const [done, setDone] = useState(false)
return (
<button
type="button"
className="btn small"
onClick={async () => {
try {
await navigator.clipboard.writeText(text)
setDone(true)
window.setTimeout(() => setDone(false), 1500)
} catch {
// 没有剪贴板权限时用户可以手动选中
}
}}
>
{done ? <Check size={14} /> : <Copy size={14} />}
{done ? '已复制' : label}
</button>
)
}
export function ThemeToggle() {
const [theme, toggle] = useTheme()
return (
<button type="button" className="btn icon" onClick={toggle} aria-label="切换明暗主题" title="切换明暗主题">
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button>
)
}
/** 数据源设置:默认连同源的后端;填了地址就连别的后端(跨域用 API 令牌,后端要放行这个前端的来源)。 */
export function SourceButton({ tokenLabel = '只读令牌(站点私有时才需要)' }: { tokenLabel?: string }) {
const [open, setOpen] = useState(false)
const [base, setBase] = useState('')
const [token, setToken] = useState('')
const remote = getBase() !== ''
const show = () => {
setBase(getBase())
setToken(getToken())
setOpen(true)
}
const apply = (nextBase: string, nextToken: string) => {
setSource(nextBase, nextToken)
location.reload()
}
return (
<>
<button type="button" className={`btn icon${remote ? ' primary' : ''}`} onClick={show} aria-label="数据源" title={remote ? `数据源:${getBase()}` : '数据源:同源'}>
<Plug size={18} />
</button>
{open ? (
<Dialog
title="数据源"
onClose={() => setOpen(false)}
actions={
<>
<button type="button" className="btn" onClick={() => apply('', '')}>
恢复同源
</button>
<button type="button" className="btn primary" onClick={() => apply(base, token)}>
保存并刷新
</button>
</>
}
>
<div className="stack">
<p className="muted">默认连这个页面所在的后端。填了地址就连另一个后端(比如本机跑前端、连线上的);此时不用 Cookie,改用 API 令牌,后端需要放行这个页面的来源(<code>Monitor__CorsOrigins</code>)。</p>
<Field label="后端地址" hint={base && normalizeBase(base) !== base.trim() ? `将使用 ${normalizeBase(base)}` : '例如 https://mo.oeo.one;留空 = 同源'}>
<input className="input" value={base} onChange={(e) => setBase(e.target.value)} placeholder="https://mo.oeo.one" autoFocus />
</Field>
<Field label={tokenLabel}>
<input className="input" type="password" value={token} onChange={(e) => setToken(e.target.value)} autoComplete="off" />
</Field>
</div>
</Dialog>
) : null}
</>
)
}
/** 下载文本文件(probe.json 只给一次)。 */
export function downloadText(filename: string, text: string) {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }))
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}