chore: initial project import
This commit is contained in:
commit
26620707a0
347 files changed
+56172
No files matched your search
@@ -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: '' },
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user