chore: initial project import
This commit is contained in:
commit
3fc4e5ca53
347 files changed
+56172
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# 复制成 .env.local:本机开发时前端把 /api 转发到哪个后端(可以是线上的公网地址)
|
||||
BACKEND_URL=https://mo.oeo.one
|
||||
@@ -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
|
||||
@@ -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 才能工作)。
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 } }],
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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: '' },
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 } }],
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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: '' },
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
Generated
+2489
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user