chore: initial project import

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

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>线路质量 · 管理</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{
"name": "monitor-admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint:css": "stylelint \"src/**/*.css\" \"../shared/**/*.css\""
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.1.0",
"uplot": "^1.6.31"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"stylelint": "^16.12.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
+134
View File
@@ -0,0 +1,134 @@
import { FlaskConical, ListChecks, LogOut, Radar, Server, Settings } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
import { isRemote } from '@shared/api/source'
import { describeError, Field, SourceButton, Spinner, ThemeToggle } from '@shared/ui'
import { useAuth } from './auth'
import { AgentDetail } from './pages/AgentDetail'
import { Agents } from './pages/Agents'
import { Probes } from './pages/Probes'
import { System } from './pages/System'
import { Tasks } from './pages/Tasks'
import { Tests } from './pages/Tests'
function Login() {
const { login, loginWithToken } = useAuth()
const remote = isRemote()
const [token, setTokenText] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const submit = async (e: FormEvent) => {
e.preventDefault()
setBusy(true)
setError('')
try {
if (remote) await loginWithToken(token)
else await login(username, password)
} catch (err) {
setError(describeError(err))
} finally {
setBusy(false)
}
}
return (
<div className="login">
<form className="card stack" onSubmit={submit}>
<div>
<h1>线路质量</h1>
<p className="muted">管理端</p>
</div>
{remote ? (
<Field label="管理令牌" hint="在管理端「系统 → API 令牌」里创建,范围选 admin">
<input className="input" type="password" value={token} onChange={(e) => setTokenText(e.target.value)} autoComplete="off" autoFocus required />
</Field>
) : (
<>
<Field label="用户名">
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoFocus required />
</Field>
<Field label="密码">
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
</Field>
</>
)}
{error ? <div className="notice bad">{error}</div> : null}
<button className="btn primary" type="submit" disabled={busy}>
登录
</button>
</form>
<div style={{ position: 'fixed', right: 16, bottom: 16 }}>
<SourceButton tokenLabel="管理令牌" />
</div>
</div>
)
}
const NAV = [
{ section: '运维', items: [{ to: '/agents', label: 'Agent', icon: Server }, { to: '/probes', label: 'Probe', icon: Radar }] },
{ section: '测试', items: [{ to: '/tasks', label: '任务', icon: ListChecks }, { to: '/tests', label: '按需测试', icon: FlaskConical }] },
{ section: '系统', items: [{ to: '/system', label: '系统', icon: Settings }] },
]
export function App() {
const { me, ready, logout } = useAuth()
if (!ready) {
return (
<div className="login">
<Spinner />
</div>
)
}
if (!me) return <Login />
return (
<div className="shell">
<aside className="side">
<a className="brand" href="/">
线路质量
</a>
{NAV.map((group) => (
<div key={group.section} style={{ display: 'contents' }}>
<div className="section">{group.section}</div>
{group.items.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => `nav ${isActive ? 'active' : ''}`}>
<Icon size={20} />
{label}
</NavLink>
))}
</div>
))}
<div className="spacer" />
<div className="me">
<span className="avatar">{(me.username ?? 'A').slice(0, 1).toUpperCase()}</span>
<span className="grow">
<div style={{ fontWeight: 600 }}>{me.username ?? 'API 令牌'}</div>
<div className="faint" style={{ fontSize: 12 }}>
{me.scope}
</div>
</span>
<SourceButton tokenLabel="管理令牌" />
<ThemeToggle />
<button type="button" className="btn icon" onClick={() => void logout()} aria-label="退出登录" title="退出登录">
<LogOut size={18} />
</button>
</div>
</aside>
<div className="main">
<Routes>
<Route path="/" element={<Navigate to="/agents" replace />} />
<Route path="/agents" element={<Agents />} />
<Route path="/agents/:id" element={<AgentDetail />} />
<Route path="/probes" element={<Probes />} />
<Route path="/tasks" element={<Tasks />} />
<Route path="/tests" element={<Tests />} />
<Route path="/system" element={<System />} />
<Route path="*" element={<Navigate to="/agents" replace />} />
</Routes>
</div>
</div>
)
}
+194
View File
@@ -0,0 +1,194 @@
/* 管理端:左侧导航 + 内容区(M3 风格:选中项是胶囊,内容区是圆角卡片) */
.shell {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
min-height: 100%;
}
.side {
position: sticky;
top: 0;
height: 100vh;
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 12px;
background: var(--app-card-inset);
overflow: auto;
}
.side .brand {
padding: 8px 14px 16px;
font-size: 18px;
font-weight: 700;
color: var(--app-ink);
display: block;
}
.side .brand:hover {
text-decoration: none;
}
.side .section {
padding: 16px 14px 4px;
font-size: 12px;
color: var(--app-ink-faint);
}
.side a.nav {
display: flex;
align-items: center;
gap: 12px;
height: 44px;
padding: 0 14px;
border-radius: var(--r-pill);
color: var(--app-ink);
font-weight: 500;
}
.side a.nav:hover {
text-decoration: none;
background: color-mix(in srgb, var(--app-ink), transparent calc((1 - var(--state-hover)) * 100%));
}
.side a.nav.active {
background: var(--app-accent-container);
color: var(--app-on-accent-container);
}
.side .spacer {
flex: 1;
}
.me {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--r-card);
background: var(--app-card);
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--app-accent);
color: var(--app-card);
display: grid;
place-items: center;
font-weight: 600;
}
.main {
padding: 24px 32px 64px;
min-width: 0;
}
.main > header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.main > header h1 {
font-size: 24px;
font-weight: 700;
}
.login {
min-height: 100%;
display: grid;
place-items: center;
padding: 24px;
}
.login .card {
width: min(400px, 100%);
padding: 32px;
}
.login h1 {
font-size: 28px;
font-weight: 800;
margin-bottom: 4px;
}
.kv {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
gap: 8px 16px;
}
.kv dt {
color: var(--app-ink-faint);
}
.kv dd {
margin: 0;
min-width: 0;
overflow-wrap: anywhere;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.form-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-grid .wide {
grid-column: 1 / -1;
}
.switch {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.switch input {
accent-color: var(--app-accent);
width: 18px;
height: 18px;
}
.test-status {
text-transform: none;
}
@media (max-width: 900px) {
.shell {
grid-template-columns: minmax(0, 1fr);
}
.side {
position: static;
height: auto;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
.side .section,
.side .spacer,
.me .grow {
display: none;
}
.main {
padding: 16px;
}
.form-grid {
grid-template-columns: minmax(0, 1fr);
}
}
+69
View File
@@ -0,0 +1,69 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
import { api, ApiError, setCsrf } from '@shared/api/client'
import { isRemote, setToken } from '@shared/api/source'
import type { LoginResponse, MeDto } from '@shared/api/types'
interface Auth {
me: MeDto | null
ready: boolean
login: (username: string, password: string) => Promise<void>
/** 连别的后端时用 API 令牌登录(不用 Cookie)。 */
loginWithToken: (token: string) => Promise<void>
logout: () => Promise<void>
}
const AuthContext = createContext<Auth | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [me, setMe] = useState<MeDto | null>(null)
const [ready, setReady] = useState(false)
useEffect(() => {
api
.get<MeDto>('/api/admin/auth/me')
.then((m) => {
setCsrf(m.csrf_token ?? null)
setMe(m)
})
.catch((e: unknown) => {
if (!(e instanceof ApiError) || (e.status !== 401 && e.status !== 403)) console.error(e)
})
.finally(() => setReady(true))
}, [])
const login = useCallback(async (username: string, password: string) => {
const r = await api.post<LoginResponse>('/api/admin/auth/login', { username, password })
setCsrf(r.csrf_token)
setMe({ kind: 'user', scope: 'admin', user_id: r.user_id, username: r.username, csrf_token: r.csrf_token })
}, [])
const loginWithToken = useCallback(async (token: string) => {
setToken(token)
try {
const m = await api.get<MeDto>('/api/admin/auth/me')
setCsrf(m.csrf_token ?? null)
setMe(m)
} catch (e) {
setToken('')
throw e
}
}, [])
const logout = useCallback(async () => {
try {
if (isRemote()) setToken('')
else await api.post('/api/admin/auth/logout')
} finally {
setCsrf(null)
setMe(null)
}
}, [])
return <AuthContext.Provider value={{ me, ready, login, loginWithToken, logout }}>{children}</AuthContext.Provider>
}
export function useAuth(): Auth {
const value = useContext(AuthContext)
if (!value) throw new Error('AuthProvider is missing')
return value
}
+25
View File
@@ -0,0 +1,25 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import '@shared/theme/base.css'
import { configureSource } from '@shared/api/source'
import { applyStoredTheme } from '@shared/hooks'
import { ToastProvider } from '@shared/ui'
import { App } from './App'
import { AuthProvider } from './auth'
import './admin.css'
configureSource('admin')
applyStoredTheme()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter basename={import.meta.env.BASE_URL}>
<ToastProvider>
<AuthProvider>
<App />
</AuthProvider>
</ToastProvider>
</BrowserRouter>
</StrictMode>,
)
+323
View File
@@ -0,0 +1,323 @@
import { ChevronLeft, FlaskConical, Pencil, Unplug } from 'lucide-react'
import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { AgentDto, EventDto, Page, ProbeDto } from '@shared/api/types'
import { fmtAgo, fmtNum, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
import { AgentStatus } from './Agents'
function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: () => void; onSaved: () => void }) {
const { busy, run } = useAction()
const [name, setName] = useState(agent.name)
const [labels, setLabels] = useState(agent.labels.join(', '))
const [host, setHost] = useState(agent.public_host)
const [visible, setVisible] = useState(agent.public_visible)
return (
<Dialog
title="修改 Agent"
onClose={onClose}
actions={
<>
<button type="button" className="btn text" onClick={onClose}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={busy}
onClick={async () => {
const ok = await run(
() => api.patch(`/api/admin/agents/${agent.id}`, { name, labels: labels.split(',').map((x) => x.trim()).filter(Boolean), public_host: host, public_visible: visible }),
'已保存',
)
if (ok) {
onSaved()
onClose()
}
}}
>
保存
</button>
</>
}
>
<div className="form-grid">
<div className="wide">
<Field label="名称" hint="字母、数字、点、下划线和连字符">
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
</Field>
</div>
<div className="wide">
<Field label="公开地址" hint="Probe 用它连接这个 Agent:域名或 IP">
<input className="input" value={host} onChange={(e) => setHost(e.target.value)} />
</Field>
</div>
<div className="wide">
<Field label="标签" hint="逗号分隔">
<input className="input" value={labels} onChange={(e) => setLabels(e.target.value)} />
</Field>
</div>
<div className="wide">
<label className="switch">
<input type="checkbox" checked={visible} onChange={(e) => setVisible(e.target.checked)} />
在展示页对匿名访客可见
</label>
</div>
</div>
</Dialog>
)
}
type Action = 'approve' | 'disable' | 'enable' | 'takeover' | 'delete'
const ACTIONS: Record<Action, { label: string; text: string; danger?: boolean }> = {
approve: { label: '审批通过', text: '审批之后,这个 Agent 会被启用并加入所有 Probe 的列表。' },
enable: { label: '启用', text: '启用之后,这个 Agent 会重新加入 Probe 的列表。' },
disable: { label: '停用', text: '停用之后,Agent 的连接会被断开,它也会从 Probe 的列表里消失。数据保留。', danger: true },
takeover: { label: '接管同名节点', text: '用这个待审批的节点替换同名的旧节点,并保留旧节点的历史数据。', danger: true },
delete: { label: '删除', text: '删除 Agent。已经入库的数据按保留策略自然过期。', danger: true },
}
export function AgentDetail() {
const { id = '' } = useParams()
const navigate = useNavigate()
const agent = useQuery(() => api.get<AgentDto>(`/api/admin/agents/${id}`), [id], 5000)
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [])
const events = useQuery(() => api.get<Page<EventDto>>('/api/admin/events', { agent_id: id, limit: 20 }), [id], 15_000)
const [editing, setEditing] = useState(false)
const [pending, setPending] = useState<Action>()
const { run } = useAction()
const a = agent.data
const probeName = (probeId: string) => probes.data?.items.find((p) => p.id === probeId)?.name ?? probeId
const act = async (action: Action) => {
if (action === 'delete') {
const ok = await run(() => api.del(`/api/admin/agents/${id}`), '已删除')
if (ok) navigate('/agents')
} else {
await run(() => api.post(`/api/admin/agents/${id}/${action}`), '已完成')
agent.reload()
}
}
return (
<>
<Link to="/agents" className="row muted" style={{ marginBottom: 8 }}>
<ChevronLeft size={16} />
Agent
</Link>
<header>
<h1>{a?.name ?? id}</h1>
{a ? <AgentStatus agent={a} /> : null}
<span className="right row wrap">
{agent.loading && !a ? <Spinner /> : null}
{a ? (
<>
<button type="button" className="btn" onClick={() => setEditing(true)}>
<Pencil size={16} />
修改
</button>
{a.status === 'pending' ? (
<>
<button type="button" className="btn primary" onClick={() => setPending('approve')}>
{ACTIONS.approve.label}
</button>
<button type="button" className="btn" onClick={() => setPending('takeover')}>
{ACTIONS.takeover.label}
</button>
</>
) : a.status === 'disabled' ? (
<button type="button" className="btn" onClick={() => setPending('enable')}>
{ACTIONS.enable.label}
</button>
) : (
<button type="button" className="btn" onClick={() => setPending('disable')}>
{ACTIONS.disable.label}
</button>
)}
<button type="button" className="btn danger" onClick={() => setPending('delete')}>
{ACTIONS.delete.label}
</button>
</>
) : null}
</span>
</header>
<ErrorNotice error={agent.error} />
{a ? (
<div className="stack">
<div className="card">
<div className="card-title">连着的 Probe({a.probes.length})</div>
{a.probes.length === 0 ? (
<Empty>现在没有 Probe 连着这个 Agent</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>Probe</th>
<th>版本</th>
<th>来源地址</th>
<th>连接时间</th>
<th />
</tr>
</thead>
<tbody>
{a.probes.map((p) => (
<tr key={p.probe_id}>
<td>
<div style={{ fontWeight: 600 }}>{probeName(p.probe_id)}</div>
<div className="mono faint">{p.probe_id}</div>
</td>
<td>{p.version ?? '—'}</td>
<td className="mono">{p.remote ?? '—'}</td>
<td className="muted">
{fmtTime(p.connected_since)}({fmtAgo(p.connected_since)})
</td>
<td className="r">
<span className="row" style={{ justifyContent: 'flex-end' }}>
<Link className="btn small" to={`/tests?agent=${a.id}&probe=${p.probe_id}`}>
<FlaskConical size={14} />
测试
</Link>
<button
type="button"
className="btn small danger"
onClick={async () => {
await run(() => api.post(`/api/admin/agents/${a.id}/probes/${p.probe_id}/disconnect`), '已断开(Probe 会自动重连,除非已被吊销)')
agent.reload()
}}
>
<Unplug size={14} />
断开
</button>
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="grid cols-2">
<div className="card">
<div className="card-title">基本信息</div>
<dl className="kv">
<dt>ID</dt>
<dd className="mono">{a.id}</dd>
<dt>公开地址</dt>
<dd className="mono">{a.public_host || '—'}</dd>
<dt>控制端口</dt>
<dd className="mono">{a.probe_tcp_port}/tcp</dd>
<dt>数据端口</dt>
<dd className="mono">{a.probe_udp_ports.map((p) => `${p}/udp`).join(', ') || '—'}</dd>
<dt>版本</dt>
<dd>{a.agent_version ?? '—'}</dd>
<dt>展示页可见</dt>
<dd>{a.public_visible ? '是' : '否'}</dd>
<dt>注册时间</dt>
<dd>{fmtTime(a.created_at)}</dd>
<dt>最后在线</dt>
<dd>{fmtTime(a.last_seen_at)}</dd>
<dt>标签</dt>
<dd>
<div className="tags">
{a.labels.length ? a.labels.map((x) => <Chip key={x}>{x}</Chip>) : <span className="faint">—</span>}
</div>
</dd>
<dt>支持的采集</dt>
<dd>
<div className="tags">
{a.kinds.map((x) => (
<Chip key={x}>{x}</Chip>
))}
</div>
</dd>
</dl>
</div>
<div className="card">
<div className="card-title">运行状态</div>
<dl className="kv">
<dt>任务同步</dt>
<dd>
期望版本 {a.task_version},已应用 {a.applied_task_version}
{a.task_version !== a.applied_task_version ? <Chip tone="warn">未同步</Chip> : <Chip tone="ok">已同步</Chip>}
</dd>
{a.rejected.length ? (
<>
<dt>被拒绝的任务</dt>
<dd>
{a.rejected.map((r) => (
<div key={r.id}>
<span className="mono">{r.id}</span> <Chip tone="bad">{r.reason}</Chip>
</div>
))}
</dd>
</>
) : null}
<dt>NAT 端点变化</dt>
<dd>{a.endpoint_changes} 次</dd>
{a.heartbeat ? (
<>
<dt>心跳</dt>
<dd>{fmtAgo(a.heartbeat.at)}</dd>
<dt>收发包速率</dt>
<dd className="num">
入 {fmtNum(a.heartbeat.pps_in)} pps / 出 {fmtNum(a.heartbeat.pps_out)} pps
</dd>
<dt>丢弃(无效 / 限速)</dt>
<dd className="num">
{fmtNum(a.heartbeat.dropped_invalid)} / {fmtNum(a.heartbeat.dropped_rate_limited)}
</dd>
<dt>待上报队列</dt>
<dd className="num">{fmtNum(a.heartbeat.queue_bytes / 1024)} KiB</dd>
</>
) : null}
{a.local_limits ? (
<>
<dt>本地上限</dt>
<dd className="num">
{fmtNum(a.local_limits.max_total_mbps)} Mbit/s,{fmtNum(a.local_limits.max_total_pps)} pps,{a.local_limits.max_concurrent_tests} 个并发测试
</dd>
</>
) : null}
</dl>
</div>
</div>
<div className="card">
<div className="card-title">最近事件</div>
{(events.data?.items ?? []).length === 0 ? (
<Empty>没有事件</Empty>
) : (
<table className="table">
<tbody>
{events.data?.items.map((e) => (
<tr key={e.id}>
<td className="muted num" style={{ width: 160 }}>
{fmtTime(e.at)}
</td>
<td style={{ width: 200 }}>{e.kind}</td>
<td className="mono muted">{e.detail ?? ''}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
) : null}
{editing && a ? <EditDialog agent={a} onClose={() => setEditing(false)} onSaved={agent.reload} /> : null}
{pending ? (
<Confirm title={ACTIONS[pending].label} danger={ACTIONS[pending].danger} confirmText={ACTIONS[pending].label} onClose={() => setPending(undefined)} onConfirm={() => act(pending)}>
{ACTIONS[pending].text}
</Confirm>
) : null}
</>
)
}
+276
View File
@@ -0,0 +1,276 @@
import { Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { AgentDto, CreatedJoinTokenDto, JoinTokenDto, Page } from '@shared/api/types'
import { fmtAgo, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Confirm, CopyButton, Dialog, Dot, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
export const STATUS: Record<string, { label: string; tone: 'ok' | 'warn' | 'bad' }> = {
active: { label: '已启用', tone: 'ok' },
pending: { label: '待审批', tone: 'warn' },
disabled: { label: '已停用', tone: 'bad' },
}
export function AgentStatus({ agent }: { agent: Pick<AgentDto, 'status' | 'online'> }) {
const s = STATUS[agent.status] ?? { label: agent.status, tone: 'warn' as const }
return (
<span className="row" style={{ gap: 8 }}>
<Dot tone={agent.online ? 'ok' : 'bad'} />
<Chip tone={s.tone}>{s.label}</Chip>
</span>
)
}
function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const { busy, run } = useAction()
const [note, setNote] = useState('')
const [labels, setLabels] = useState('')
const [autoApprove, setAutoApprove] = useState(true)
const [maxUses, setMaxUses] = useState(1)
const [hours, setHours] = useState(24)
const [created, setCreated] = useState<CreatedJoinTokenDto>()
if (created) {
return (
<Dialog
title="加入令牌已创建"
onClose={onClose}
actions={
<button type="button" className="btn primary" onClick={onClose}>
完成
</button>
}
>
<div className="stack">
<div className="notice warn">令牌明文只显示这一次,请现在就复制。</div>
<Field label="在要加入的 VPS 上运行">
<pre className="code">{created.install_command}</pre>
<div>
<CopyButton text={created.install_command} label="复制安装命令" />
</div>
</Field>
<Field label="令牌">
<pre className="code">{created.plaintext}</pre>
<div>
<CopyButton text={created.plaintext} label="复制令牌" />
</div>
</Field>
<p className="muted">
{created.token.auto_approve ? '使用这个令牌注册的 Agent 会自动启用。' : '使用这个令牌注册的 Agent 需要你在列表里审批之后才会启用。'} 可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。
</p>
</div>
</Dialog>
)
}
return (
<Dialog
title="添加 Agent"
onClose={onClose}
actions={
<>
<button type="button" className="btn text" onClick={onClose}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={busy}
onClick={async () => {
await run(async () => {
const token = await api.post<CreatedJoinTokenDto>('/api/admin/join-tokens', {
note: note || null,
labels: labels.split(',').map((x) => x.trim()).filter(Boolean),
auto_approve: autoApprove,
max_uses: maxUses,
expires_in_hours: hours,
})
setCreated(token)
onCreated()
})
}}
>
创建加入令牌
</button>
</>
}
>
<p className="muted" style={{ marginBottom: 16 }}>
添加 Agent 就是创建一个一次性的加入令牌:在目标 VPS 上运行安装命令,Agent 会用它注册并出现在列表里。
</p>
<div className="form-grid">
<div className="wide">
<Field label="备注" hint="例如这台 VPS 的位置或用途">
<input className="input" value={note} onChange={(e) => setNote(e.target.value)} placeholder="HK-1" />
</Field>
</div>
<div className="wide">
<Field label="标签" hint="逗号分隔,注册后会带在这个 Agent 上">
<input className="input" value={labels} onChange={(e) => setLabels(e.target.value)} placeholder="hk, cn2" />
</Field>
</div>
<Field label="可用次数">
<input className="input" type="number" min={1} max={100} value={maxUses} onChange={(e) => setMaxUses(Number(e.target.value))} />
</Field>
<Field label="有效期(小时)">
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
</Field>
<div className="wide">
<label className="switch">
<input type="checkbox" checked={autoApprove} onChange={(e) => setAutoApprove(e.target.checked)} />
注册后自动启用(不勾选则需要手动审批)
</label>
</div>
</div>
</Dialog>
)
}
export function Agents() {
const navigate = useNavigate()
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [], 10_000)
const tokens = useQuery(() => api.get<Page<JoinTokenDto>>('/api/admin/join-tokens'), [])
const [filter, setFilter] = useState<'all' | 'pending' | 'disabled'>('all')
const [adding, setAdding] = useState(false)
const [revoking, setRevoking] = useState<JoinTokenDto>()
const { run } = useAction()
const list = (agents.data?.items ?? []).filter((a) => filter === 'all' || a.status === filter)
const pending = (agents.data?.items ?? []).filter((a) => a.status === 'pending').length
return (
<>
<header>
<h1>Agent</h1>
<span className="right row">
{agents.loading && !agents.data ? <Spinner /> : null}
<button type="button" className="btn primary" onClick={() => setAdding(true)}>
<Plus size={18} />
添加 Agent
</button>
</span>
</header>
<ErrorNotice error={agents.error} />
<div className="stack">
<Segmented
options={[
{ value: 'all' as const, label: '全部' },
{ value: 'pending' as const, label: `待审批${pending ? ` (${pending})` : ''}` },
{ value: 'disabled' as const, label: '已停用' },
]}
value={filter}
onChange={setFilter}
/>
<div className="card" style={{ padding: '8px 12px' }}>
{list.length === 0 ? (
<Empty>{agents.data ? '没有符合条件的 Agent。点击右上角「添加 Agent」创建加入令牌。' : '加载中…'}</Empty>
) : (
<table className="table hover">
<thead>
<tr>
<th>状态</th>
<th>名称</th>
<th>地址</th>
<th>版本</th>
<th>连着的 Probe</th>
<th>最后在线</th>
<th>标签</th>
</tr>
</thead>
<tbody>
{list.map((a) => (
<tr key={a.id} onClick={() => navigate(`/agents/${a.id}`)}>
<td>
<AgentStatus agent={a} />
</td>
<td style={{ fontWeight: 600 }}>{a.name}</td>
<td className="mono muted">{a.public_host || '—'}</td>
<td className="muted">{a.agent_version ?? '—'}</td>
<td>
<Chip tone={a.probes.length > 0 ? 'ok' : ''}>{a.probes.length} 个</Chip>
</td>
<td className="muted">{fmtAgo(a.last_seen_at)}</td>
<td>
<div className="tags">
{a.labels.map((x) => (
<Chip key={x}>{x}</Chip>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="card">
<div className="card-title">加入令牌</div>
{(tokens.data?.items ?? []).length === 0 ? (
<Empty>没有加入令牌</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>备注</th>
<th>使用</th>
<th>注册后</th>
<th>过期</th>
<th>状态</th>
<th />
</tr>
</thead>
<tbody>
{tokens.data?.items.map((t) => {
const expired = Date.parse(t.expires_at) < Date.now()
const state = t.revoked_at ? '已吊销' : expired ? '已过期' : t.uses >= t.max_uses ? '已用完' : '可用'
return (
<tr key={t.id}>
<td>{t.note || <span className="faint">—</span>}</td>
<td className="num">
{t.uses} / {t.max_uses}
</td>
<td>{t.auto_approve ? '自动启用' : '需要审批'}</td>
<td className="muted">{fmtTime(t.expires_at)}</td>
<td>
<Chip tone={state === '可用' ? 'ok' : ''}>{state}</Chip>
</td>
<td className="r">
{state === '可用' ? (
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
<Trash2 size={14} />
吊销
</button>
) : null}
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
{adding ? <AddAgentDialog onClose={() => setAdding(false)} onCreated={tokens.reload} /> : null}
{revoking ? (
<Confirm
title="吊销加入令牌"
danger
confirmText="吊销"
onClose={() => setRevoking(undefined)}
onConfirm={async () => {
await run(() => api.del(`/api/admin/join-tokens/${revoking.id}`), '已吊销')
tokens.reload()
}}
>
吊销之后,这个令牌不能再用来注册 Agent。已经注册的 Agent 不受影响。
</Confirm>
) : null}
</>
)
}
+245
View File
@@ -0,0 +1,245 @@
import { Download, KeyRound, Pencil, Plus, ShieldOff, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { Page, ProbeDto, ProbeIdentityDto } from '@shared/api/types'
import { fmtAgo, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Confirm, CopyButton, Dialog, downloadText, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
/** probe.json 只在创建和轮换密钥时返回一次:这里让管理员马上下载或复制。 */
function IdentityDialog({ identity, title, onClose }: { identity: ProbeIdentityDto; title: string; onClose: () => void }) {
const json = JSON.stringify(identity, null, 2)
return (
<Dialog
title={title}
onClose={onClose}
actions={
<button type="button" className="btn primary" onClick={onClose}>
我已保存
</button>
}
>
<div className="stack">
<div className="notice warn">私钥只显示这一次,关闭之后无法再找回。请现在保存 probe.json。</div>
<div className="row wrap">
<button type="button" className="btn primary" onClick={() => downloadText('probe.json', json)}>
<Download size={16} />
下载 probe.json
</button>
<CopyButton text={json} label="复制内容" />
</div>
<Field label="在家宽机器上" hint="保存为 /etc/monitor/probe.json,然后启动 Probe;它会根据 bootstrap 列表连接 Agent,之后的 Agent 列表由面板同步。">
<pre className="code">{`monitor-probe --config /etc/monitor/probe.json`}</pre>
</Field>
<details>
<summary className="muted" style={{ cursor: 'pointer' }}>
查看内容
</summary>
<pre className="code" style={{ marginTop: 8 }}>
{json}
</pre>
</details>
</div>
</Dialog>
)
}
function NameDialog({ title, initial, onClose, onSubmit }: { title: string; initial: string; onClose: () => void; onSubmit: (name: string) => Promise<boolean> }) {
const [name, setName] = useState(initial)
const [busy, setBusy] = useState(false)
return (
<Dialog
title={title}
onClose={onClose}
actions={
<>
<button type="button" className="btn text" onClick={onClose}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={busy || !name.trim()}
onClick={async () => {
setBusy(true)
const ok = await onSubmit(name.trim())
setBusy(false)
if (ok) onClose()
}}
>
确定
</button>
</>
}
>
<Field label="名称" hint="字母、数字、点、下划线和连字符,例如 home 或 office">
<input className="input" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</Field>
</Dialog>
)
}
type Pending = { kind: 'rotate' | 'revoke' | 'delete'; probe: ProbeDto }
export function Probes() {
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 8000)
const { run } = useAction()
const [creating, setCreating] = useState(false)
const [renaming, setRenaming] = useState<ProbeDto>()
const [pending, setPending] = useState<Pending>()
const [identity, setIdentity] = useState<{ title: string; value: ProbeIdentityDto }>()
const list = probes.data?.items ?? []
const confirmText = {
rotate: { title: '轮换密钥', danger: true, text: '旧密钥立即失效,所有 Agent 会断开这个 Probe 的会话。你需要把新的 probe.json 放到这台 Probe 上并重启它。' },
revoke: { title: '吊销 Probe', danger: true, text: '所有 Agent 会立刻断开并拒绝这个 Probe。记录和历史数据保留,但它不能再连接,也不能恢复。' },
delete: { title: '删除 Probe', danger: true, text: '删除这个 Probe 和它的线路。已经入库的数据按保留策略自然过期。' },
}
return (
<>
<header>
<h1>Probe</h1>
<span className="right row">
{probes.loading && !probes.data ? <Spinner /> : null}
<button type="button" className="btn primary" onClick={() => setCreating(true)}>
<Plus size={18} />
创建 Probe
</button>
</span>
</header>
<ErrorNotice error={probes.error} />
<div className="stack">
{probes.data && list.length === 0 ? (
<div className="card">
<Empty>还没有 Probe。点击右上角「创建 Probe」,由后端生成身份,再把 probe.json 放到家宽机器上。</Empty>
</div>
) : null}
{list.map((p) => (
<div key={p.id} className="card">
<div className="row wrap">
<span style={{ fontSize: 18, fontWeight: 700 }}>{p.name}</span>
{p.revoked ? <Chip tone="bad">已吊销</Chip> : <Chip tone={p.connections.length > 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${p.connections.length} 个 Agent` : '没有连接'}</Chip>}
<span className="mono faint">{p.id}</span>
<span className="right row wrap">
<button type="button" className="btn small" onClick={() => setRenaming(p)}>
<Pencil size={14} />
改名
</button>
{!p.revoked ? (
<>
<button type="button" className="btn small" onClick={() => setPending({ kind: 'rotate', probe: p })}>
<KeyRound size={14} />
轮换密钥
</button>
<button type="button" className="btn small danger" onClick={() => setPending({ kind: 'revoke', probe: p })}>
<ShieldOff size={14} />
吊销
</button>
</>
) : null}
<button type="button" className="btn small danger" onClick={() => setPending({ kind: 'delete', probe: p })}>
<Trash2 size={14} />
删除
</button>
</span>
</div>
<p className="faint" style={{ marginTop: 4 }}>
创建于 {fmtTime(p.created_at)}
{p.revoked_at ? `,吊销于 ${fmtTime(p.revoked_at)}` : ''}
</p>
{p.connections.length > 0 ? (
<table className="table" style={{ marginTop: 8 }}>
<thead>
<tr>
<th>Agent</th>
<th>版本</th>
<th>来源地址</th>
<th>连接时间</th>
</tr>
</thead>
<tbody>
{p.connections.map((c) => (
<tr key={c.agent_id}>
<td>
<Link to={`/agents/${c.agent_id}`} style={{ fontWeight: 600 }}>
{c.agent_name}
</Link>
</td>
<td>{c.version ?? '—'}</td>
<td className="mono">{c.remote ?? '—'}</td>
<td className="muted">
{fmtTime(c.connected_since)}({fmtAgo(c.connected_since)})
</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
))}
</div>
{creating ? (
<NameDialog
title="创建 Probe"
initial=""
onClose={() => setCreating(false)}
onSubmit={async (name) => {
let created: ProbeIdentityDto | undefined
const ok = await run(async () => {
created = await api.post<ProbeIdentityDto>('/api/admin/probes', { name })
})
if (ok && created) {
setIdentity({ title: `Probe ${name} 已创建`, value: created })
probes.reload()
}
return ok
}}
/>
) : null}
{renaming ? (
<NameDialog
title="修改名称"
initial={renaming.name}
onClose={() => setRenaming(undefined)}
onSubmit={async (name) => {
const ok = await run(() => api.patch(`/api/admin/probes/${renaming.id}`, { name }), '已保存')
probes.reload()
return ok
}}
/>
) : null}
{pending ? (
<Confirm
title={confirmText[pending.kind].title}
danger={confirmText[pending.kind].danger}
confirmText={confirmText[pending.kind].title}
onClose={() => setPending(undefined)}
onConfirm={async () => {
const { kind, probe } = pending
if (kind === 'rotate') {
let rotated: ProbeIdentityDto | undefined
const ok = await run(async () => {
rotated = await api.post<ProbeIdentityDto>(`/api/admin/probes/${probe.id}/rotate`)
})
if (ok && rotated) setIdentity({ title: `${probe.name} 的新密钥`, value: rotated })
} else if (kind === 'revoke') {
await run(() => api.post(`/api/admin/probes/${probe.id}/revoke`), '已吊销')
} else {
await run(() => api.del(`/api/admin/probes/${probe.id}`), '已删除')
}
probes.reload()
}}
>
{confirmText[pending.kind].text}
</Confirm>
) : null}
{identity ? <IdentityDialog title={identity.title} identity={identity.value} onClose={() => setIdentity(undefined)} /> : null}
</>
)
}
+434
View File
@@ -0,0 +1,434 @@
import { Download, KeyRound, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { api } from '@shared/api/client'
import type { ApiTokenDto, CreatedApiTokenDto, EventDto, Page, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import { fmtNum, fmtTime } from '@shared/format'
import { translateSetting } from '@shared/i18n'
import { useQuery } from '@shared/hooks'
import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
type Tab = 'settings' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis'
function Settings() {
const settings = useQuery(() => api.get<Page<SettingDto>>('/api/admin/settings'), [])
const { busy, run } = useAction()
const [draft, setDraft] = useState<Record<string, string>>({})
useEffect(() => setDraft({}), [settings.data])
const items = settings.data?.items ?? []
const dirty = Object.keys(draft).length > 0
return (
<div className="card">
<ErrorNotice error={settings.error} />
<table className="table">
<thead>
<tr>
<th>设置</th>
<th>值</th>
<th>说明</th>
</tr>
</thead>
<tbody>
{items.map((s) => {
const value = draft[s.key] ?? s.value
return (
<tr key={s.key}>
<td className="mono">{s.key}</td>
<td style={{ width: 200 }}>
{s.type === 'bool' ? (
<label className="switch">
<input type="checkbox" checked={value === 'true'} onChange={(e) => setDraft({ ...draft, [s.key]: String(e.target.checked) })} />
{value === 'true' ? '开' : '关'}
</label>
) : (
<input className="input" type={s.type === 'int' ? 'number' : 'text'} value={value} onChange={(e) => setDraft({ ...draft, [s.key]: e.target.value })} />
)}
</td>
<td className="muted">
{translateSetting(s.key, s.description)}
{s.value !== s.default ? <span className="faint">(默认 {s.default})</span> : null}
</td>
</tr>
)
})}
</tbody>
</table>
<div className="row" style={{ marginTop: 16 }}>
<button
type="button"
className="btn primary"
disabled={!dirty || busy}
onClick={async () => {
const body: Record<string, unknown> = {}
for (const s of items) {
if (draft[s.key] === undefined) continue
body[s.key] = s.type === 'bool' ? draft[s.key] === 'true' : s.type === 'int' ? Number(draft[s.key]) : draft[s.key]
}
await run(() => api.patch('/api/admin/settings', body), '已保存')
settings.reload()
}}
>
保存
</button>
<span className="muted">「site.public」打开之后,展示页对匿名访客公开。</span>
</div>
</div>
)
}
function Tokens() {
const tokens = useQuery(() => api.get<Page<ApiTokenDto>>('/api/admin/api-tokens'), [])
const { run } = useAction()
const [creating, setCreating] = useState(false)
const [name, setName] = useState('')
const [scope, setScope] = useState<'read' | 'admin'>('read')
const [created, setCreated] = useState<CreatedApiTokenDto>()
const [revoking, setRevoking] = useState<ApiTokenDto>()
return (
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<span className="muted">给脚本和其他程序用的令牌:read 只能读展示数据,admin 可以调用全部管理接口。</span>
<button type="button" className="btn primary right" onClick={() => setCreating(true)}>
<Plus size={18} />
创建令牌
</button>
</div>
<ErrorNotice error={tokens.error} />
{(tokens.data?.items ?? []).length === 0 ? (
<Empty>没有 API 令牌</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>名称</th>
<th>权限</th>
<th>创建</th>
<th>最后使用</th>
<th />
</tr>
</thead>
<tbody>
{tokens.data?.items.map((t) => (
<tr key={t.id}>
<td style={{ fontWeight: 600 }}>{t.name}</td>
<td>
<Chip tone={t.scope === 'admin' ? 'warn' : ''}>{t.scope}</Chip>
</td>
<td className="muted">{fmtTime(t.created_at)}</td>
<td className="muted">{t.revoked_at ? <Chip tone="bad">已吊销</Chip> : fmtTime(t.last_used_at)}</td>
<td className="r">
{!t.revoked_at ? (
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
<Trash2 size={14} />
吊销
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
)}
{creating ? (
<Dialog
title="创建 API 令牌"
onClose={() => setCreating(false)}
actions={
<>
<button type="button" className="btn text" onClick={() => setCreating(false)}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={!name.trim()}
onClick={async () => {
let result: CreatedApiTokenDto | undefined
const ok = await run(async () => {
result = await api.post<CreatedApiTokenDto>('/api/admin/api-tokens', { name: name.trim(), scope })
})
if (ok && result) {
setCreated(result)
setCreating(false)
setName('')
tokens.reload()
}
}}
>
创建
</button>
</>
}
>
<div className="stack">
<Field label="名称">
<input className="input" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</Field>
<Field label="权限">
<Segmented options={[{ value: 'read' as const, label: 'read(只读)' }, { value: 'admin' as const, label: 'admin(全部)' }]} value={scope} onChange={setScope} />
</Field>
</div>
</Dialog>
) : null}
{created ? (
<Dialog title="令牌已创建" onClose={() => setCreated(undefined)} actions={<button type="button" className="btn primary" onClick={() => setCreated(undefined)}>完成</button>}>
<div className="stack">
<div className="notice warn">令牌明文只显示这一次,请现在就复制。</div>
<pre className="code">{created.plaintext}</pre>
<div>
<CopyButton text={created.plaintext} />
</div>
</div>
</Dialog>
) : null}
{revoking ? (
<Confirm title="吊销令牌" danger confirmText="吊销" onClose={() => setRevoking(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/api-tokens/${revoking.id}`), '已吊销'); tokens.reload() }}>
使用「{revoking.name}」的程序会立刻失去访问权限。
</Confirm>
) : null}
</div>
)
}
function Users() {
const users = useQuery(() => api.get<Page<UserDto>>('/api/admin/users'), [])
const { run } = useAction()
const [dialog, setDialog] = useState<{ user?: UserDto }>()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
return (
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<span className="muted">能登录管理端的账号。</span>
<button type="button" className="btn primary right" onClick={() => { setUsername(''); setPassword(''); setDialog({}) }}>
<Plus size={18} />
添加用户
</button>
</div>
<ErrorNotice error={users.error} />
<table className="table">
<thead>
<tr>
<th>用户名</th>
<th>创建</th>
<th />
</tr>
</thead>
<tbody>
{users.data?.items.map((u) => (
<tr key={u.id}>
<td style={{ fontWeight: 600 }}>{u.username}</td>
<td className="muted">{fmtTime(u.created_at)}</td>
<td className="r">
<button type="button" className="btn small" onClick={() => { setPassword(''); setDialog({ user: u }) }}>
<KeyRound size={14} />
改密码
</button>
</td>
</tr>
))}
</tbody>
</table>
{dialog ? (
<Dialog
title={dialog.user ? `修改 ${dialog.user.username} 的密码` : '添加用户'}
onClose={() => setDialog(undefined)}
actions={
<>
<button type="button" className="btn text" onClick={() => setDialog(undefined)}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={!password || (!dialog.user && !username.trim())}
onClick={async () => {
const ok = await run(
() => (dialog.user ? api.patch(`/api/admin/users/${dialog.user.id}`, { password }) : api.post('/api/admin/users', { username: username.trim(), password })),
'已保存',
)
if (ok) {
setDialog(undefined)
users.reload()
}
}}
>
保存
</button>
</>
}
>
<div className="stack">
{!dialog.user ? (
<Field label="用户名">
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
</Field>
) : null}
<Field label="密码">
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
</Field>
</div>
</Dialog>
) : null}
</div>
)
}
function Events() {
const [kind, setKind] = useState('')
const events = useQuery(() => api.get<Page<EventDto>>('/api/admin/events', { kind: kind || undefined, limit: 100 }), [kind], 10_000)
return (
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<input className="input" style={{ width: 240 }} placeholder="按类型筛选,例如 probe_connected" value={kind} onChange={(e) => setKind(e.target.value)} />
<span className="right">{events.loading && !events.data ? <Spinner /> : null}</span>
</div>
<ErrorNotice error={events.error} />
{(events.data?.items ?? []).length === 0 ? (
<Empty>没有事件</Empty>
) : (
<table className="table">
<tbody>
{events.data?.items.map((e) => (
<tr key={e.id}>
<td className="muted num" style={{ width: 150 }}>
{fmtTime(e.at)}
</td>
<td style={{ width: 200 }}>{e.kind}</td>
<td className="mono faint" style={{ width: 280 }}>
{e.agent_id ?? ''}
</td>
<td className="mono muted">{e.detail ?? ''}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
function Raw() {
const [kind, setKind] = useState('')
const [agent, setAgent] = useState('')
const chunks = useQuery(() => api.get<Page<RawChunkMetaDto>>('/api/admin/raw/chunks', { kind: kind || undefined, agent_id: agent || undefined, limit: 50 }), [kind, agent])
return (
<div className="card">
<div className="row wrap" style={{ marginBottom: 12 }}>
<input className="input" style={{ width: 200 }} placeholder="Agent ID" value={agent} onChange={(e) => setAgent(e.target.value)} />
<input className="input" style={{ width: 200 }} placeholder="采集方法,例如 udp_echo" value={kind} onChange={(e) => setKind(e.target.value)} />
<span className="muted">原始块按保留期限保存,可以下载后离线分析。</span>
</div>
<ErrorNotice error={chunks.error} />
{(chunks.data?.items ?? []).length === 0 ? (
<Empty>没有符合条件的原始块</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>时间</th>
<th>采集方法</th>
<th>序列</th>
<th className="r">时长</th>
<th className="r">大小</th>
<th />
</tr>
</thead>
<tbody>
{chunks.data?.items.map((c) => (
<tr key={c.chunk_id}>
<td className="muted num">{fmtTime(c.start)}</td>
<td style={{ fontWeight: 600 }}>
{c.kind}
{c.partial ? <Chip tone="warn">不完整</Chip> : null}
</td>
<td className="mono faint" style={{ maxWidth: 260, overflowWrap: 'anywhere' }}>
{c.series}
</td>
<td className="r num">{fmtNum(c.duration_ms / 1000, 1)} s</td>
<td className="r num">{fmtNum(c.bytes / 1024, 1)} KiB</td>
<td className="r">
<a className="btn small" href={`/api/admin/raw/chunks/${c.chunk_id}`} download>
<Download size={14} />
下载
</a>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
function Analysis() {
const state = useQuery(() => api.get<RecomputeDto>('/api/admin/analysis/recompute'), [], 5000)
const health = useQuery(() => api.get<{ status: string; database: boolean; raw_directory: boolean }>('/healthz'), [], 10_000)
const d = state.data
return (
<div className="stack">
<div className="card">
<div className="card-title">服务状态</div>
{health.data ? (
<div className="row wrap">
<Chip tone={health.data.status === 'ok' || health.data.status === 'healthy' ? 'ok' : 'bad'}>{health.data.status}</Chip>
<Chip tone={health.data.database ? 'ok' : 'bad'}>数据库 {health.data.database ? '正常' : '异常'}</Chip>
<Chip tone={health.data.raw_directory ? 'ok' : 'bad'}>原始数据目录 {health.data.raw_directory ? '正常' : '异常'}</Chip>
</div>
) : (
<Spinner />
)}
</div>
<div className="card">
<div className="card-title">指标重算</div>
<p className="muted" style={{ marginBottom: 12 }}>新增或修改指标之后,后台会在保留期内的原始数据上自动重算,这里是进度。</p>
<ErrorNotice error={state.error} />
{d ? (
<dl className="kv">
<dt>状态</dt>
<dd>
<Chip tone={d.state === 'idle' || d.state === 'done' ? 'ok' : 'accent'}>{d.state}</Chip>
</dd>
<dt>重算的指标</dt>
<dd>{d.metrics.length ? d.metrics.join(', ') : '—'}</dd>
<dt>进度</dt>
<dd className="num">
{fmtNum(d.windows_done)} / {fmtNum(d.windows_total)} 个窗口
</dd>
<dt>排队中的窗口</dt>
<dd className="num">{fmtNum(d.queue_length)}</dd>
</dl>
) : (
<Spinner />
)}
</div>
</div>
)
}
export function System() {
const [tab, setTab] = useState<Tab>('settings')
return (
<>
<header>
<h1>系统</h1>
</header>
<div className="stack">
<Segmented
options={[
{ value: 'settings' as const, label: '设置' },
{ value: 'tokens' as const, label: 'API 令牌' },
{ value: 'users' as const, label: '用户' },
{ value: 'events' as const, label: '事件' },
{ value: 'raw' as const, label: '原始数据' },
{ value: 'analysis' as const, label: '分析' },
]}
value={tab}
onChange={setTab}
/>
{tab === 'settings' ? <Settings /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
</div>
</>
)
}
+317
View File
@@ -0,0 +1,317 @@
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { api } from '@shared/api/client'
import type { AgentDto, KindDto, Page, ParamDto, TaskDto } from '@shared/api/types'
import { fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
type ScopeFilter = 'all' | 'template' | 'override' | 'target'
const kindOf = (t: TaskDto): ScopeFilter => (t.scope === 'target' ? 'target' : t.agent_id ? 'override' : 'template')
function summary(params: Record<string, unknown>): string {
const entries = Object.entries(params)
return entries.length === 0 ? '默认参数' : entries.map(([k, v]) => `${k}=${String(v)}`).join(' ')
}
/** 按 Kind 声明的参数规格生成表单:只提交和默认值不同的那些。 */
function ParamForm({ params, values, onChange }: { params: ParamDto[]; values: Record<string, unknown>; onChange: (v: Record<string, unknown>) => void }) {
const set = (name: string, value: unknown, fallback: unknown) => {
const next = { ...values }
if (value === '' || value === undefined || value === fallback) delete next[name]
else next[name] = value
onChange(next)
}
return (
<div className="form-grid">
{params.map((p) => {
const current = values[p.name]
const hint = [p.description, p.min !== null && p.min !== undefined ? `最小 ${p.min}` : '', p.max !== null && p.max !== undefined ? `最大 ${p.max}` : '', p.unit ?? ''].filter(Boolean).join(' · ')
return (
<Field key={p.name} label={`${p.name}(默认 ${JSON.stringify(p.default)})`} hint={hint}>
{p.type === 'bool' ? (
<select className="select" value={current === undefined ? '' : String(current)} onChange={(e) => set(p.name, e.target.value === '' ? undefined : e.target.value === 'true', p.default)}>
<option value="">默认</option>
<option value="true">是</option>
<option value="false">否</option>
</select>
) : p.values && p.values.length > 0 ? (
<select className="select" value={current === undefined ? '' : String(current)} onChange={(e) => set(p.name, e.target.value, p.default)}>
<option value="">默认</option>
{p.values.map((v) => (
<option key={String(v)} value={String(v)}>
{String(v)}
</option>
))}
</select>
) : (
<input
className="input"
type={p.type === 'string' ? 'text' : 'number'}
step={p.type === 'int' ? 1 : 'any'}
value={current === undefined ? '' : String(current)}
placeholder={String(p.default)}
onChange={(e) => set(p.name, p.type === 'string' ? e.target.value : e.target.value === '' ? '' : Number(e.target.value), p.default)}
/>
)}
</Field>
)
})}
</div>
)
}
function TaskDialog({ task, kinds, agents, templates, onClose, onSaved }: { task?: TaskDto; kinds: KindDto[]; agents: AgentDto[]; templates: TaskDto[]; onClose: () => void; onSaved: () => void }) {
const { busy, run } = useAction()
const editing = Boolean(task)
const [mode, setMode] = useState<'template' | 'override' | 'target'>(task ? (kindOf(task) as 'template' | 'override' | 'target') : 'override')
const [kindId, setKindId] = useState(task?.kind ?? '')
const [agentId, setAgentId] = useState(task?.agent_id ?? agents[0]?.id ?? '')
const [baseId, setBaseId] = useState(task?.base_task_id ?? '')
const [target, setTarget] = useState(task?.target ?? '')
const [values, setValues] = useState<Record<string, unknown>>(task ? { ...(task.params as Record<string, unknown>) } : {})
const [enabled, setEnabled] = useState(task?.enabled ?? true)
const base = templates.find((t) => t.id === baseId)
const effectiveKind = mode === 'override' ? (base?.kind ?? '') : kindId
const kind = kinds.find((k) => k.id === effectiveKind)
const kindChoices = kinds.filter((k) => k.scopes.includes(mode === 'target' ? 'target' : 'line'))
const submit = async () => {
const ok = await run(async () => {
if (task) {
await api.patch(`/api/admin/tasks/${task.id}`, { params: values, enabled, target: mode === 'target' ? target : undefined })
} else {
await api.post('/api/admin/tasks', {
scope: mode === 'target' ? 'target' : 'line',
kind: effectiveKind,
target: mode === 'target' ? target : null,
params: values,
enabled,
agent_id: mode === 'template' ? null : agentId,
base_task_id: mode === 'override' ? baseId : null,
})
}
}, '已保存')
if (ok) {
onSaved()
onClose()
}
}
return (
<Dialog
title={editing ? '修改任务' : '添加任务'}
onClose={onClose}
actions={
<>
<button type="button" className="btn text" onClick={onClose}>
取消
</button>
<button type="button" className="btn primary" disabled={busy || !effectiveKind || (mode !== 'template' && !agentId) || (mode === 'target' && !target) || (mode === 'override' && !baseId)} onClick={submit}>
保存
</button>
</>
}
>
<div className="stack">
{!editing ? (
<Field label="类型" hint={mode === 'template' ? '线路模板:对所有 Agent 生效' : mode === 'override' ? '覆盖:只改某个 Agent 上某个模板的参数' : '目标任务:Agent 自己去测一个地址(ICMP、TCP、TLS 建连),需要 Agent 本地允许这个网段'}>
<Segmented
options={[
{ value: 'override' as const, label: 'Agent 覆盖' },
{ value: 'template' as const, label: '线路模板' },
{ value: 'target' as const, label: '目标任务' },
]}
value={mode}
onChange={(v) => {
setMode(v)
setValues({})
setKindId('')
}}
/>
</Field>
) : null}
{mode !== 'template' ? (
<Field label="Agent">
<select className="select" value={agentId} disabled={editing} onChange={(e) => setAgentId(e.target.value)}>
{agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</Field>
) : null}
{mode === 'override' && !editing ? (
<Field label="要覆盖的模板">
<select className="select" value={baseId} onChange={(e) => { setBaseId(e.target.value); setValues({}) }}>
<option value="">选择模板</option>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.kind}({summary(t.params as Record<string, unknown>)})
</option>
))}
</select>
</Field>
) : null}
{mode !== 'override' && !editing ? (
<Field label="采集方法">
<select className="select" value={kindId} onChange={(e) => { setKindId(e.target.value); setValues({}) }}>
<option value="">选择</option>
{kindChoices.map((k) => (
<option key={k.id} value={k.id}>
{k.title}({k.id})
</option>
))}
</select>
</Field>
) : null}
{mode === 'target' ? (
<Field label="目标" hint="例如 1.1.1.1 或 example.com:443">
<input className="input" value={target} onChange={(e) => setTarget(e.target.value)} />
</Field>
) : null}
{kind ? (
<>
{kind.description ? <p className="muted">{kind.description}</p> : null}
<ParamForm params={kind.params} values={values} onChange={setValues} />
</>
) : null}
<label className="switch">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
启用
</label>
</div>
</Dialog>
)
}
export function Tasks() {
const tasks = useQuery(() => api.get<Page<TaskDto>>('/api/admin/tasks'), [])
const kinds = useQuery(() => api.get<Page<KindDto>>('/api/public/catalog/kinds'), [])
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [])
const [filter, setFilter] = useState<ScopeFilter>('all')
const [editing, setEditing] = useState<TaskDto | 'new'>()
const [deleting, setDeleting] = useState<TaskDto>()
const { run } = useAction()
const all = tasks.data?.items ?? []
const list = all.filter((t) => filter === 'all' || kindOf(t) === filter)
const agentName = (id: string | null) => (id ? (agents.data?.items.find((a) => a.id === id)?.name ?? id) : '全部 Agent')
return (
<>
<header>
<h1>任务</h1>
<span className="right row">
{tasks.loading && !tasks.data ? <Spinner /> : null}
<button type="button" className="btn primary" onClick={() => setEditing('new')} disabled={!kinds.data || !agents.data}>
<Plus size={18} />
添加任务
</button>
</span>
</header>
<ErrorNotice error={tasks.error ?? kinds.error} />
<div className="stack">
<Segmented
options={[
{ value: 'all' as const, label: '全部' },
{ value: 'template' as const, label: '线路模板' },
{ value: 'override' as const, label: 'Agent 覆盖' },
{ value: 'target' as const, label: '目标任务' },
]}
value={filter}
onChange={setFilter}
/>
<div className="card" style={{ padding: '8px 12px' }}>
{list.length === 0 ? (
<Empty>{tasks.data ? '没有任务' : '加载中…'}</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>采集方法</th>
<th>类型</th>
<th>作用于</th>
<th>参数</th>
<th>状态</th>
<th>更新</th>
<th />
</tr>
</thead>
<tbody>
{list.map((t) => {
const k = kindOf(t)
return (
<tr key={t.id}>
<td style={{ fontWeight: 600 }}>{t.kind}</td>
<td>
<Chip tone={k === 'target' ? 'accent' : ''}>{k === 'template' ? '线路模板' : k === 'override' ? 'Agent 覆盖' : '目标任务'}</Chip>
</td>
<td>
{agentName(t.agent_id)}
{t.target ? <div className="mono faint">{t.target}</div> : null}
</td>
<td className="mono muted" style={{ maxWidth: 320, overflowWrap: 'anywhere' }}>
{summary(t.params as Record<string, unknown>)}
</td>
<td>
<button
type="button"
className="btn small"
onClick={async () => {
await run(() => api.patch(`/api/admin/tasks/${t.id}`, { enabled: !t.enabled }))
tasks.reload()
}}
>
{t.enabled ? <Chip tone="ok">已启用</Chip> : <Chip>已停用</Chip>}
</button>
</td>
<td className="muted">{fmtTime(t.updated_at)}</td>
<td className="r">
<span className="row" style={{ justifyContent: 'flex-end' }}>
<button type="button" className="btn small" onClick={() => setEditing(t)}>
<Pencil size={14} />
</button>
<button type="button" className="btn small danger" onClick={() => setDeleting(t)}>
<Trash2 size={14} />
</button>
</span>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
{editing && kinds.data && agents.data ? (
<TaskDialog
task={editing === 'new' ? undefined : editing}
kinds={kinds.data.items}
agents={agents.data.items}
templates={all.filter((t) => kindOf(t) === 'template')}
onClose={() => setEditing(undefined)}
onSaved={tasks.reload}
/>
) : null}
{deleting ? (
<Confirm
title="删除任务"
danger
confirmText="删除"
onClose={() => setDeleting(undefined)}
onConfirm={async () => {
await run(() => api.del(`/api/admin/tasks/${deleting.id}`), '已删除')
tasks.reload()
}}
>
删除之后,Agent 和 Probe 会在下一次同步时停止这个任务。
</Confirm>
) : null}
</>
)
}
+240
View File
@@ -0,0 +1,240 @@
import { FlaskConical, Square } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { AgentDto, Page, PlanDto, ProbeDto, TestDto } from '@shared/api/types'
import { fmtMbps, fmtMs, fmtPct, fmtTime, scoreTone } from '@shared/format'
import { translatePlan } from '@shared/i18n'
import { useQuery } from '@shared/hooks'
import { Chip, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
const STATUS: Record<string, { label: string; tone: 'ok' | 'warn' | 'bad' | 'accent' | '' }> = {
queued: { label: '排队中', tone: '' },
running: { label: '运行中', tone: 'accent' },
succeeded: { label: '成功', tone: 'ok' },
failed: { label: '失败', tone: 'bad' },
aborted: { label: '已取消', tone: 'warn' },
}
const ACTIVE = new Set(['queued', 'running'])
function Result({ test }: { test: TestDto }) {
const r = test.result
if (!r) return <Empty>{ACTIVE.has(test.status) ? '测试进行中…' : test.error_code ? `失败:${test.error_code}` : '没有结果'}</Empty>
const m = r.metrics
const pick = (id: string, fmt: (v: number) => string, label: string) => (m[id] === undefined ? null : (
<div className="stat" key={id}>
<span className="k">{label}</span>
<span className="v" style={{ fontSize: 20 }}>{fmt(m[id])}</span>
</div>
))
return (
<div className="stack">
<div className="grid stats">
{pick('tcp.bulk.peak.max', fmtMbps, '吞吐峰值')}
{pick('tcp.bulk.mbps.p50', fmtMbps, '吞吐中位数')}
{pick('connect.success', (v) => fmtPct(v, 1), '建连成功率')}
{pick('connect.tcp_ms.p95', fmtMs, 'TCP 建连 p95')}
{pick('connect.tls_ms.p95', fmtMs, 'TLS 握手 p95')}
{pick('hold.lifetime_max', (v) => `${(v / 1000).toFixed(0)} s`, '最长保持')}
{pick('hy2.sustainable_mbps', fmtMbps, 'Hy2 可持续码率')}
{pick('hy2.qos_onset_mbps', fmtMbps, '限速起点')}
{pick('tcp.retrans', fmtPct, 'TCP 重传')}
</div>
<div className="row wrap">
{Object.entries(r.scores).map(([model, score]) => (
<Chip key={model} tone={scoreTone(score.score) || ''}>
{model} {score.score === null ? '—' : Math.round(score.score)}
{score.estimated ? '(估算)' : ''}
</Chip>
))}
{r.caveats.map((c) => (
<Chip key={c} tone="warn">
{c}
</Chip>
))}
</div>
{r.steps.length > 0 ? (
<table className="table">
<thead>
<tr>
<th>阶梯</th>
<th className="r">目标码率</th>
<th className="r">丢包</th>
<th className="r">抖动 p95</th>
<th className="r">实际吞吐</th>
</tr>
</thead>
<tbody>
{r.steps.map((s) => (
<tr key={s.index}>
<td>#{s.index + 1}</td>
<td className="r num">{fmtMbps(s.target_mbps)}</td>
<td className="r num">{fmtPct(s.loss_pct)}</td>
<td className="r num">{fmtMs(s.jitter_p95_ms)}</td>
<td className="r num">{fmtMbps(s.delivered_mbps)}</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
)
}
export function Tests() {
const [params, setParams] = useSearchParams()
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [], 10_000)
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [])
const plans = useQuery(() => api.get<Page<PlanDto>>('/api/public/catalog/plans'), [])
const { busy, run } = useAction()
const active = (agents.data?.items ?? []).filter((a) => a.status === 'active')
const agentId = params.get('agent') ?? active[0]?.id ?? ''
const agent = active.find((a) => a.id === agentId)
const connected = agent?.probes ?? []
const probeId = params.get('probe') ?? connected[0]?.probe_id ?? ''
const [planId, setPlanId] = useState('')
const [selected, setSelected] = useState<string>()
const line = agentId && probeId ? `${agentId}~${probeId}` : ''
useEffect(() => {
if (!planId && plans.data?.items[0]) setPlanId(plans.data.items[0].id)
}, [plans.data, planId])
const tests = useQuery(() => (line ? api.get<Page<TestDto>>(`/api/admin/lines/${encodeURIComponent(line)}/tests`, { limit: 20 }) : Promise.resolve(undefined)), [line], 3000)
const detail = useQuery(() => (selected ? api.get<TestDto>(`/api/public/tests/${selected}`) : Promise.resolve(undefined)), [selected, tests.data?.items[0]?.status], 3000)
const probeName = (id: string) => probes.data?.items.find((p) => p.id === id)?.name ?? id
const rawPlan = plans.data?.items.find((p) => p.id === planId)
const plan = rawPlan ? translatePlan(rawPlan.id, rawPlan.title, rawPlan.description) : undefined
const set = (patch: Record<string, string>) => {
const next = new URLSearchParams(params)
for (const [k, v] of Object.entries(patch)) next.set(k, v)
setParams(next, { replace: true })
}
return (
<>
<header>
<h1>按需测试</h1>
<span className="right">{agents.loading && !agents.data ? <Spinner /> : null}</span>
</header>
<ErrorNotice error={agents.error ?? plans.error ?? tests.error} />
<div className="stack">
<div className="card">
<div className="card-title">发起测试</div>
<p className="muted" style={{ marginBottom: 16 }}>
测试会在选中的线路(一个 Agent 和一个连着它的 Probe)上跑,会占用链路带宽,请不要太频繁。
</p>
<div className="form-grid">
<Field label="Agent">
<select className="select" value={agentId} onChange={(e) => set({ agent: e.target.value, probe: '' })}>
{active.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
{a.online ? '' : '(离线)'}
</option>
))}
</select>
</Field>
<Field label="Probe" hint={connected.length === 0 ? '这个 Agent 现在没有 Probe 连着,不能发起测试' : undefined}>
<select className="select" value={probeId} onChange={(e) => set({ probe: e.target.value })} disabled={connected.length === 0}>
{connected.map((p) => (
<option key={p.probe_id} value={p.probe_id}>
{probeName(p.probe_id)}
</option>
))}
</select>
</Field>
<div className="wide">
<Field label="方案" hint={plan?.description}>
<select className="select" value={planId} onChange={(e) => setPlanId(e.target.value)}>
{(plans.data?.items ?? []).map((p) => (
<option key={p.id} value={p.id}>
{translatePlan(p.id, p.title, p.description).title}({p.id})
</option>
))}
</select>
</Field>
</div>
</div>
<div style={{ marginTop: 16 }}>
<button
type="button"
className="btn primary"
disabled={busy || !line || !planId || connected.length === 0}
onClick={async () => {
let started: TestDto | undefined
const ok = await run(async () => {
started = await api.post<TestDto>(`/api/admin/lines/${encodeURIComponent(line)}/tests`, { plan: planId })
}, '测试已开始')
if (ok && started) setSelected(started.id)
tests.reload()
}}
>
<FlaskConical size={18} />
开始测试
</button>
</div>
</div>
<div className="grid cols-2" style={{ alignItems: 'start' }}>
<div className="card" style={{ padding: '16px 12px' }}>
<div className="card-title" style={{ padding: '0 12px' }}>
这条线路的测试
</div>
{(tests.data?.items ?? []).length === 0 ? (
<Empty>{line ? '还没有测试' : '选择一条线路'}</Empty>
) : (
<table className="table hover">
<tbody>
{tests.data?.items.map((t) => (
<tr key={t.id} onClick={() => setSelected(t.id)} style={selected === t.id ? { background: 'var(--app-card-hover)' } : undefined}>
<td>
<Chip tone={STATUS[t.status]?.tone ?? ''}>{STATUS[t.status]?.label ?? t.status}</Chip>
</td>
<td style={{ fontWeight: 600 }}>{t.plan}</td>
<td className="muted">{fmtTime(t.requested_at)}</td>
<td className="r">
{ACTIVE.has(t.status) ? (
<button
type="button"
className="btn small danger"
onClick={async (e) => {
e.stopPropagation()
await run(() => api.post(`/api/admin/tests/${t.id}/cancel`), '已取消')
tests.reload()
}}
>
<Square size={14} />
取消
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="card">
<div className="card-title">结果</div>
{detail.data ? (
<>
<p className="faint" style={{ marginBottom: 12 }}>
{detail.data.plan} · {STATUS[detail.data.status]?.label ?? detail.data.status}
{detail.data.phase !== null ? ` · 阶段 ${detail.data.phase + 1} / ${detail.data.phases.length}` : ''}
</p>
<Result test={detail.data} />
</>
) : (
<Empty>选择左边的一条测试查看结果</Empty>
)}
</div>
</div>
</div>
</>
)
}
+9
View File
@@ -0,0 +1,9 @@
// 颜色只能出现在 shared/theme/tokens.css:其他样式一律引用令牌
export default {
rules: {
'color-no-hex': true,
'color-named': 'never',
'function-disallowed-list': ['rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch'],
},
overrides: [{ files: ['../shared/theme/tokens.css'], rules: { 'color-no-hex': null, 'function-disallowed-list': null } }],
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"paths": { "@shared/*": ["../shared/*"] }
},
"include": ["src", "../shared"],
"exclude": ["../shared/api/schema.ts"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"erasableSyntaxOnly": true
},
"include": ["vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import react from '@vitejs/plugin-react'
import { fileURLToPath } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
// 开发时把 /api 转发给后端,地址取自环境变量 BACKEND_URL,或 web/.env.local 里的 BACKEND_URL=(见 web/.env.example):
// BACKEND_URL=https://mo.oeo.one npm run dev:display
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, fileURLToPath(new URL('..', import.meta.url)), '')
const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:5000').replace(/\/+$/, '')
return {
base: '/admin/',
plugins: [react()],
resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } },
server: {
port: 5174,
fs: { allow: ['..'] },
proxy: {
'/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
'/healthz': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
},
},
}
})