web: unify Agent management and add a draggable link board

Replace Probe pages with directed Agent links, shared deployment controls and unified display data. Add node dragging, canvas pan and zoom, saved layouts, and per-direction link controls.
This commit is contained in:
justme committed 2026-10-21 11:55:50 +08:00
1 parent 016dbc2bbf
commit 15f0023614
32 files changed
+937 -1093

No files matched your search

+4 -2
View File
@@ -5,7 +5,7 @@
| 目录 | 内容 | 访问 |
| --- | --- | --- |
| `display/` | 展示端:Agent 卡片总览(线路质量 + 主机资源占用,实时刷新)、线路详情(延迟、丢包、抖动、TCP、UDP、评分、历史)、线路对比 | `/`,匿名可看;站点没有公开时提示登录 |
| `admin/` | 管理端:Agent(添加、审批、启停、接管、删除、加入令牌)、Probe(创建、轮换密钥、吊销、删除、连接)、任务、按需测试、系统(设置、版本与更新、API 令牌、用户、事件、原始数据、分析) | `/admin/`,要登录 |
| `admin/` | 管理端:Agent(添加、审批、启停、接管、删除、加入令牌)、线路画板(节点拖动、画布平移缩放、有向连接状态、方向开关与重试)、任务、按需测试、系统(设置、版本与更新、API 令牌、用户、事件、原始数据、分析) | `/admin/`,要登录 |
| `shared/` | `theme/`(「纸墨」主题令牌和基础样式)、`api/`(客户端、从 OpenAPI 生成的类型)、`charts/`(uPlot 封装)、`ui/`(通用组件) | |
```sh
@@ -23,9 +23,11 @@ npm run lint:css # 颜色只能写在 shared/theme/tokens.css,
约定:
- 总览、线路详情、对比和管理端按需测试均可切换 IPv4 / IPv6;查询和测试结果独立。管理端 Agent 编辑页分别配置 IPv4、IPv6 地址,IPv6 留空只采 IPv4。展示端只显示地址族,不显示 IP。
- 展示端能拿到的主机数据只有汇总的数字(CPU、内存、磁盘占用,网卡累计流量与瞬时网速,运行时长),没有 CPU 型号、主机名、网卡名,也没有任何 IP——后端的公开接口不返回它们,Agent 也根本不采集。展示端不显示 Probe 的版本和来源地址(这些只在管理端)。
- 展示端能拿到的主机数据只有汇总的数字(CPU、内存、磁盘占用,网卡累计流量与瞬时网速,运行时长),没有 CPU 型号、主机名、网卡名,也没有任何 IP——后端的公开接口不返回它们,Agent 也根本不采集。展示端不显示 节点的来源地址(这些只在管理端)。
- 线路详情页:先选哪一天(前一天 / 后一天 / 日期选择,URL 的 `day=YYYY-MM-DD`),下面是这一天的丢包缩略图,上面有可拖动的选区(拖空白处新建、拖两边调长短、拖中间平移;URL 的 `at=起_止`,单位是从选中那天 0 点算起的分钟,可以是负数或超过 1440)。缩略图的刻度缺省是 48 小时(连续两天,选区可以跨零点,比如晚高峰 20:00 到凌晨 02:00),也可以切成 24 小时(只看一天;URL 的 `span=24`,选区跨零点时自动用 48 小时)。刻度下方是选区长度:30 分钟、1 小时、3 小时、6 小时、12 小时、整天,点一下把选区设成这么长——今天且选区到现在为止的,继续看「最近一段」(实时刷新),否则保持选区的起点往后取。刻度上每小时一根短线,48 小时刻度每 3 小时标一个钟点(24 小时刻度每 2 小时),零点标日期;鼠标移到缩略图上会标出那个位置的时间;点一下空白处或者刻度下面的钟点:离那里近的端点(左端或右端)贴过去,另一个端点不动。鼠标停在轴上或刻度上时,会动的那个端点变色,它到目标位置之间染色:扩大是橙色,收缩是灰色。往日默认是整天;「整天」是本地时区的自然日。时间的文字不写「次日」,跨零点两头都写日期(2026年10月5日 21:06 – 2026年10月6日 02:31)。
- 图表上方有 EWMA 平滑开关(关、轻 α=0.3、中 α=0.1、重 α=0.03;URL 的 `ewma=low|mid|high`):开了之后每条线画成指数加权移动平均,原始数据以淡线垫在后面,缺口处断开;只在浏览器里算,不影响汇总的指标数字。
- 图表在数据刷新(轮询、实时)时原地更新,不重建;拖选区时只在本地更新,松手才取数。
- 总览是卡片,Agent 主机状态通过 SSE(`/api/public/live?topics=hosts`)实时更新,其余每 10 秒刷新。
- 生产部署时,把 `display/dist` 放在站点根、`admin/dist` 放在 `/admin/` 下(同源,Cookie 和 CSRF 才能工作)。
管理端线路画板:拖动 Agent 卡片调整位置,拖动空白处平移,滚轮或工具栏缩放;「显示全部节点」恢复可见范围,「重置布局」恢复默认排布。点击带箭头的连线,在右侧面板调整当前方向的开关、恢复自动或立即重试。节点位置按后端地址保存在浏览器中;节点聚焦后可用方向键移动。Agent 详情中的拨出连接继续使用紧凑表格。
+3 -5
View File
@@ -11,8 +11,7 @@ import { PasskeyNotice } from './PasskeyNotice'
import { Setup } from './Setup'
import { AgentDetail } from './pages/AgentDetail'
import { Agents } from './pages/Agents'
import { Probes } from './pages/Probes'
import { ProbeDetail } from './pages/ProbeDetail'
import { Links } from './pages/Links'
import { System } from './pages/System'
import { Tasks } from './pages/Tasks'
import { Tests } from './pages/Tests'
@@ -228,7 +227,7 @@ function Login() {
}
const NAV = [
{ section: '运维', items: [{ to: '/agents', label: 'Agent', icon: Server }, { to: '/probes', label: 'Probe', icon: Radar }] },
{ section: '运维', items: [{ to: '/agents', label: 'Agent', icon: Server }, { to: '/links', label: '线路画板', icon: Radar }] },
{ section: '测试', items: [{ to: '/tasks', label: '任务', icon: ListChecks }, { to: '/tests', label: '按需测试', icon: FlaskConical }] },
{ section: '系统', items: [{ to: '/system', label: '系统', icon: Settings }] },
]
@@ -286,8 +285,7 @@ export function App() {
<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="/probes/:id" element={<ProbeDetail />} />
<Route path="/links" element={<Links />} />
<Route path="/tasks" element={<Tasks />} />
<Route path="/tests" element={<Tests />} />
<Route path="/system" element={<System />} />
+1 -1
View File
@@ -5,7 +5,7 @@ import type { BackendUpdateDto } from '@shared/api/types'
import { Confirm, ErrorNotice, useAction } from '@shared/ui'
import { useAuth } from './auth'
/** webhook 发布后显示当前通道的更新;用户确认同一个 seq 后,Backend 先备份数据库再退出,由 Quadlet 拉最新镜像重启(和 Agent、Probe 一样)。 */
/** webhook 发布后显示当前通道的更新;用户确认同一个 seq 后,Backend 先备份数据库再退出,由 Quadlet 拉最新镜像重启(和 Agent 一样)。 */
export function BackendUpdateNotice() {
const { me } = useAuth()
const [update, setUpdate] = useState<BackendUpdateDto>()
+1 -1
View File
@@ -54,7 +54,7 @@ export function PasskeyNotice() {
<div className="row wrap">
<KeyRound size={16} />
<span>
<b>还没有 Passkey</b>:不注册 Passkey、不建立信任根就无法添加 Agent 和 Probe,现在只能做基础管理(架构如此:节点只信任由 Passkey 签名的信任根)。Passkey 也可以用来登录,不用输密码。
<b>还没有 Passkey</b>:不注册 Passkey、不建立信任根就无法添加 Agent,现在只能做基础管理(架构如此:节点只信任由 Passkey 签名的信任根)。Passkey 也可以用来登录,不用输密码。
</span>
<span className="right row">
<button type="button" className="btn small text" onClick={later}>
+1 -1
View File
@@ -45,7 +45,7 @@ export function Setup() {
<div className="stack">
<p className="muted">
Passkey 是整个系统的根:它给「操作员集合」签名,节点只认从它出发的信任根,以后 web ssh 等敏感操作也只认它。
<b>不注册 Passkey、不建立信任根就无法使用这些功能:添加不了 Agent 和 Probe,只能进入面板做基础管理</b>,这是架构如此,面板自己没法替你代签。
<b>不注册 Passkey、不建立信任根就无法使用这些功能:添加不了 Agent,只能进入面板做基础管理</b>,这是架构如此,面板自己没法替你代签。
{passkeySupported() ? '' : ' 这个浏览器不支持 Passkey(需要 HTTPS 或 localhost)。'}
</p>
<PasskeyAddForm compact onAdded={() => setStep(2)} />
+290
View File
@@ -303,3 +303,293 @@
padding: 24px;
}
}
/* Agent 节点画板:节点使用世界坐标,缩放和平移只作用于画板内容。 */
.link-board-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 284px;
gap: 16px;
}
.link-board {
min-width: 0;
overflow: hidden;
border: 1px solid var(--app-outline-soft);
border-radius: 20px;
background: var(--app-bg);
}
.link-board-tools {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--app-outline-soft);
background: var(--app-card);
font-size: 12px;
}
.link-board-scale {
min-width: 42px;
text-align: center;
font-variant-numeric: tabular-nums;
color: var(--app-ink-muted);
}
.link-board-viewport {
position: relative;
height: clamp(440px, calc(100dvh - 230px), 1000px);
overflow: hidden;
cursor: grab;
touch-action: none;
user-select: none;
background-image: radial-gradient(var(--app-outline) 1px, transparent 1px);
background-size: 22px 22px;
}
.link-board-viewport.dragging,
.link-board-viewport.dragging .link-board-node {
cursor: grabbing;
}
.link-board-world {
position: absolute;
width: 1px;
height: 1px;
transform-origin: 0 0;
}
.link-board-edges {
position: absolute;
overflow: visible;
pointer-events: none;
}
.link-board-edge {
cursor: pointer;
outline: none;
pointer-events: visiblePainted;
color: var(--app-ink-faint);
}
.link-board-edge.connected,
.link-board-legend .connected {
color: var(--app-ok);
}
.link-board-edge.pending,
.link-board-legend .pending {
color: var(--app-warn);
}
.link-board-edge.selected,
.link-board-edge:focus-visible {
color: var(--app-accent);
}
.link-board-edge-line {
stroke: currentColor;
stroke-width: 2;
fill: none;
pointer-events: none;
}
.link-board-edge.disabled .link-board-edge-line {
stroke-dasharray: 6 6;
}
.link-board-edge.selected .link-board-edge-line,
.link-board-edge:hover .link-board-edge-line,
.link-board-edge:focus-visible .link-board-edge-line {
stroke-width: 3;
}
.link-board-edge-hit {
stroke: transparent;
stroke-width: 22;
fill: none;
pointer-events: stroke;
}
.link-board-arrow.connected {
fill: var(--app-ok);
}
.link-board-arrow.pending {
fill: var(--app-warn);
}
.link-board-arrow.disabled {
fill: var(--app-ink-faint);
}
.link-board-arrow.selected {
fill: var(--app-accent);
}
.link-board-edge-label rect {
fill: var(--app-card);
stroke: var(--app-outline-soft);
}
.link-board-edge.selected .link-board-edge-label rect,
.link-board-edge:focus-visible .link-board-edge-label rect {
fill: var(--app-accent-container);
stroke: var(--app-accent);
}
.link-board-edge-label text {
fill: currentColor;
font-size: 12px;
font-weight: 500;
}
.link-board-node {
position: absolute;
display: flex;
flex-direction: column;
gap: 10px;
width: 216px;
height: 108px;
padding: 14px 16px;
border: 1px solid var(--app-outline);
border-radius: 18px;
background: var(--app-card);
box-shadow: 0 5px 18px color-mix(in srgb, var(--app-shadow) 7%, transparent);
cursor: grab;
}
.link-board-node:focus-visible {
outline: 2px solid var(--app-accent);
outline-offset: 4px;
}
.link-board-node.offline {
border-style: dashed;
}
.link-board-node-head {
display: flex;
align-items: center;
gap: 8px;
}
.link-board-node-head > svg {
flex-shrink: 0;
color: var(--app-accent);
}
.link-board-node-head strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-board-detail {
margin-left: auto;
flex-shrink: 0;
font-size: 12px;
cursor: pointer;
}
.link-board-node-counts {
display: flex;
justify-content: space-between;
gap: 8px;
color: var(--app-ink-muted);
font-size: 11px;
}
.link-board-node-counts b {
color: var(--app-ink);
}
.link-board-inspector {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 18px;
min-width: 0;
padding: 16px;
border-radius: 20px;
background: var(--app-card-inset);
}
.link-board-inspector .input {
width: 100%;
}
.link-board-route {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
font-weight: 600;
overflow-wrap: anywhere;
}
.link-board-storage {
margin-top: auto;
padding-top: 16px;
font-size: 12px;
}
.link-board-legend {
position: absolute;
bottom: 14px;
left: 16px;
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 8px 12px;
border-radius: var(--r-pill);
background: var(--app-card);
pointer-events: none;
font-size: 11px;
}
.link-board-legend span::before {
display: inline-block;
width: 16px;
margin-right: 6px;
border-top: 2px solid currentColor;
vertical-align: middle;
content: '';
}
.link-board-legend .disabled::before {
border-top-style: dashed;
}
.link-board-empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
}
@media (max-width: 1000px) {
.link-board-layout {
grid-template-columns: minmax(0, 1fr);
}
.link-board-viewport {
height: 480px;
}
}
.link-board-header {
flex-wrap: wrap;
}
.link-board-header .switch {
white-space: nowrap;
}
.link-board-tools > .row {
flex-wrap: wrap;
gap: 8px;
}
+23 -19
View File
@@ -2,12 +2,13 @@ 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 type { AgentDto, EventDto, Page } 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'
import { IPV6_STATES } from '@shared/ipVersions'
import { Links } from './Links'
import { RuntimeLogs } from '../RuntimeLogs'
function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: () => void; onSaved: () => void }) {
@@ -63,7 +64,7 @@ function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: ()
</Field>
</div>
<div className="wide">
<Field label="IPv4 地址" hint="Probe 用它测 IPv4:IPv4 地址或域名(A 记录)。默认由 Agent 上报自动识别">
<Field label="IPv4 地址" hint="其他 Agent 用它测 IPv4:IPv4 地址或域名(A 记录)。默认由 Agent 上报自动识别">
<input className="input" value={host} disabled={hostAuto} onChange={(e) => setHost(e.target.value)} />
<label className="switch">
<input type="checkbox" checked={hostAuto} onChange={(e) => setHostAuto(e.target.checked)} />
@@ -99,9 +100,9 @@ function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: ()
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 },
approve: { label: '审批通过', text: '审批之后,这个 Agent 会被启用并加入其他 Agent 的列表。' },
enable: { label: '启用', text: '启用之后,这个 Agent 会重新加入 其他 Agent 的拨号目标。' },
disable: { label: '停用', text: '停用之后,Agent 的连接会被断开,它也会从 其他 Agent 的拨号目标里消失。数据保留。', danger: true },
takeover: { label: '接管同名节点', text: '用这个待审批的节点替换同名的旧节点,并保留旧节点的历史数据。', danger: true },
delete: { label: '删除', text: '删除 Agent。已经入库的数据按保留策略自然过期。', danger: true },
}
@@ -110,14 +111,14 @@ 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 dialers = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [])
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 dialerName = (dialerId: string) => dialers.data?.items.find((p) => p.id === dialerId)?.name ?? dialerId
const act = async (action: Action) => {
if (action === 'delete') {
@@ -176,15 +177,16 @@ export function AgentDetail() {
{a ? (
<div className="stack">
<Links dialerId={a.id} />
<div className="card">
<div className="card-title">连着的 Probe({a.probes.length})</div>
{a.probes.length === 0 ? (
<Empty>现在没有 Probe 连着这个 Agent</Empty>
<div className="card-title">接入 Agent({a.dialers.length})</div>
{a.dialers.length === 0 ? (
<Empty>现在没有 Agent 接入此节点</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>Probe</th>
<th>来源 Agent</th>
<th>版本</th>
<th>来源地址</th>
<th>连接时间</th>
@@ -192,11 +194,11 @@ export function AgentDetail() {
</tr>
</thead>
<tbody>
{a.probes.map((p) => (
<tr key={`${p.probe_id}~${p.ip_version}`}>
{a.dialers.map((p) => (
<tr key={`${p.dialer_id}~${p.ip_version}`}>
<td>
<div style={{ fontWeight: 600 }}>{probeName(p.probe_id)} <Chip>{p.ip_version === 'ipv6' ? 'IPv6' : 'IPv4'}</Chip></div>
<div className="mono faint">{p.probe_id}</div>
<div style={{ fontWeight: 600 }}>{dialerName(p.dialer_id)} <Chip>{p.ip_version === 'ipv6' ? 'IPv6' : 'IPv4'}</Chip></div>
<div className="mono faint">{p.dialer_id}</div>
</td>
<td>{p.version ?? '—'}</td>
<td className="mono">{p.remote ?? '—'}</td>
@@ -205,7 +207,7 @@ export function AgentDetail() {
</td>
<td className="r">
<span className="row" style={{ justifyContent: 'flex-end' }}>
<Link className="btn small" to={`/tests?agent=${a.id}&probe=${p.probe_id}&ip_version=${p.ip_version}`}>
<Link className="btn small" to={`/tests?agent=${a.id}&dialer=${p.dialer_id}&ip_version=${p.ip_version}`}>
<FlaskConical size={14} />
测试
</Link>
@@ -213,7 +215,7 @@ export function AgentDetail() {
type="button"
className="btn small danger"
onClick={async () => {
await run(() => api.post(`/api/admin/agents/${a.id}/probes/${p.probe_id}/disconnect`), '已断开(Probe 会自动重连,除非已被吊销)')
await run(() => api.post(`/api/admin/agents/${a.id}/dialers/${p.dialer_id}/disconnect`), '已断开;线路开启时对端会自动重连')
agent.reload()
}}
>
@@ -255,10 +257,12 @@ export function AgentDetail() {
<dd>{a.ipv6?.has_global_address == null ? '未知' : a.ipv6.has_global_address ? '已获得' : '未获得'}</dd>
<dt>IPv6 默认路由</dt>
<dd>{a.ipv6?.has_default_route == null ? '未知' : a.ipv6.has_default_route ? '存在' : '缺失'}</dd>
<dt>监听</dt>
<dd>{a.listens ? '已开启' : '已关闭'}</dd>
<dt>控制端口</dt>
<dd className="mono">{a.probe_tcp_port}/tcp</dd>
<dd className="mono">{a.tcp_port}/tcp</dd>
<dt>数据端口</dt>
<dd className="mono">{a.probe_quic_ports.map((p) => `${p}/udp (QUIC)`).join(', ') || '—'}</dd>
<dd className="mono">{a.quic_ports.map((p) => `${p}/udp (QUIC)`).join(', ') || '—'}</dd>
<dt>版本</dt>
<dd>{a.agent_version ?? '—'}</dd>
<dt>展示页可见</dt>
+3 -3
View File
@@ -30,7 +30,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
const [created, setCreated] = useState<CreatedJoinTokenDto>()
const [controllers, setControllers] = useState<string[] | null>(null)
if (created) return <JoinTokenCreated created={created} kind="Agent" onClose={onClose} />
if (created) return <JoinTokenCreated created={created} onClose={onClose} />
return (
<Dialog
@@ -151,7 +151,7 @@ export function Agents() {
<th>名称</th>
<th>地址</th>
<th>版本</th>
<th>连着的 Probe</th>
<th>连接(拨出 / 接入)</th>
<th>最后在线</th>
<th>标签</th>
</tr>
@@ -171,7 +171,7 @@ export function Agents() {
<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>
<Chip tone={a.outgoing.length + a.dialers.length > 0 ? 'ok' : ''}>{a.outgoing.length} / {a.dialers.length}</Chip>
</td>
<td className="muted">{fmtAgo(a.last_seen_at)}</td>
<td>
+1 -1
View File
@@ -6,7 +6,7 @@ import { Chip } from '@shared/ui'
/** 能力的中文名和说明。目录(分组、依赖、预设)来自后端,这里只管显示文字。 */
const NAMES: Record<string, [string, string]> = {
'public.read': ['查看展示数据', '看隐藏线路;站点私有时看展示页'],
'fleet.read': ['查看节点', 'Agent、Probe、线路、加入代码'],
'fleet.read': ['查看节点', 'Agent、线路、加入代码'],
'fleet.write': ['修改节点', '改名、标签、可见性、地址、线路开关、断开会话'],
'fleet.enroll': ['添加节点', '生成加入代码、批准新节点'],
'fleet.revoke': ['吊销或删除节点', '破坏性操作'],
+11 -10
View File
@@ -3,12 +3,12 @@ import { fmtTime } from '@shared/format'
import { CommandTabs, CopyButton, Dialog, Field, guessPlatform, type CommandTab } from '@shared/ui'
/**
* 加入令牌创建之后的对话框:Agent 和 Probe 共用同一份。
* 加入令牌创建之后的对话框:Agent 共用同一份。
* Podman 系(Linux)首选 Quadlet(Podman 4.6+):env、单元文件(核对哈希)、启动用户服务;单元 Pull=always,节点发现新版本后自己退出重启,所以默认自动更新。
* Docker(Linux、macOS、Windows)用 compose:把面板给的 .env 存下来,下载 compose.yaml,`compose up -d`;compose 不会自动更新,手动 `compose pull && compose up -d`。
* env/.env 里只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥;一行安装脚本留作备用。
*/
export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJoinTokenDto; kind: 'Agent' | 'Probe'; onClose: () => void }) {
export function JoinTokenCreated({ created, onClose }: { created: CreatedJoinTokenDto; onClose: () => void }) {
const quadlet = created.quadlet
const compose = created.compose
const script = created.install_commands
@@ -20,7 +20,7 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
const initial = composeTabs.some((t) => t.id === guessPlatform()) ? guessPlatform() : 'linux'
return (
<Dialog
title="加入令牌已创建"
title="部署 Agent"
onClose={onClose}
actions={
<button type="button" className="btn primary" onClick={onClose}>
@@ -29,17 +29,18 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
}
>
<div className="stack">
<p className="muted">所有平台使用同一种 Agent,默认同时监听和拨号。加入后自动与其他可达 Agent 相互探测,可在线路矩阵中按方向调整。</p>
<div className="notice warn">一次性码只显示这一次,请现在就复制。下面只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥。</div>
{kind === 'Probe' && script.openwrt && (
{script.openwrt && (
<Field
label="OpenWrt · 原生二进制(自动更新)"
hint="以 root 运行,支持 ARM64 和 x86_64,不需要 Docker/Podman。通过 curl 或 OpenWrt 自带的 uclient-fetch 下载,缺少依赖时自动用 opkg/apk 安装。动态 musl 正式包需 libopenssl3。自动注册属主并配置 procd;发布后自动验签、安装,失败回滚。默认限制 20 Mbps,可在 /etc/moshiro/probe.json 修改。"
hint="以 root 运行,支持 ARM64 和 x86_64,不需要 Docker/Podman。通过 curl 或 OpenWrt 自带的 uclient-fetch 下载,缺少依赖时自动用 opkg/apk 安装。动态 musl 正式包需 libopenssl3。自动注册属主并配置 procd;发布后自动验签、安装,失败回滚。默认限制 20 Mbps,可在 /etc/moshiro/agent.json 修改。"
>
<CommandTabs storageKey="moshiro.openwrt_tab" initial="openwrt" tabs={[{ id: 'openwrt', label: 'OpenWrt · procd', command: script.openwrt }]} />
</Field>
)}
<Field
label={`Podman(推荐,自动更新):在要加入的机器上运行(${kind})`}
label={`Linux · Podman(推荐,自动更新)`}
hint="Linux,Podman 4.6 及以上,用普通用户运行(rootless,不用 sudo)。命令把参数写进 ~/.config/moshiro/(权限 600),下载 Quadlet 单元并核对 SHA-256,一致才启动。退出登录后继续运行需要管理员执行一次 loginctl enable-linger 用户名。节点名字默认取主机名;想自己定,在 env 里加一行 MOSHIRO_NAME=名字(1–64 个字母、数字、点、下划线、横线)。"
>
<CommandTabs storageKey="moshiro.quadlet_tab" initial="linux" tabs={[{ id: 'linux', label: 'Linux · Podman Quadlet', command: quadlet.command }]} />
@@ -49,7 +50,7 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
想改成在管理端点「更新」才更新,把 env 里 <span className="mono"># MOSHIRO_UPDATE_POLICY=manual</span> 的 # 去掉。固定版本:把单元里的 <span className="mono">:latest</span> 换成版本号。
</p>
<Field
label="Docker(Linux、macOS、Windows):compose"
label="Linux · Docker Compose"
hint="需要 docker compose(或 podman compose)。命令建一个目录,把参数写进 .env(权限 600),下载 compose.yaml 并核对 SHA-256,一致才启动。compose 不会自动更新(容器自己没法重新拉取):手动 compose pull && compose up -d。"
>
<CommandTabs storageKey="moshiro.compose_tab" initial={initial} tabs={composeTabs} />
@@ -57,7 +58,7 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
<details>
<summary>分步做:Quadlet 的 env 和单元,compose 的 .env 和 compose.yaml</summary>
<div className="stack" style={{ marginTop: 8 }}>
<Field label="Quadlet env(存成 ~/.config/moshiro/ 下的 agent.env 或 probe.env)">
<Field label="Quadlet env(存成 ~/.config/moshiro/ 下的 agent.env)">
<div className="row" style={{ alignItems: 'flex-start', gap: 8 }}>
<pre className="code" style={{ flex: 1, whiteSpace: 'pre-wrap', wordBreak: 'break-all', margin: 0 }}>{quadlet.env}</pre>
<CopyButton text={quadlet.env} />
@@ -92,11 +93,11 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
<p className="muted">
compose 更新:<span className="mono">podman compose pull &amp;&amp; podman compose up -d</span>(Docker 把 podman 换成 docker);身份和配置在命名卷里,不会动。
注册成功之后,可以把 env 或 .env 里的 <span className="mono">MOSHIRO_JOIN</span> 一行删掉。
{kind === 'Agent' ? (created.token.auto_approve ? ' 使用它注册的 Agent 会自动启用。' : ' 使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''}
{created.token.auto_approve ? ' 使用它注册的 Agent 会自动启用。' : ' 使用它注册的 Agent 需要你在列表里审批之后才会启用。'}
可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。
</p>
<details>
<summary>备用:一行安装脚本(先核对脚本哈希再执行,需要 Docker 或 Podman)</summary>
<summary>一键安装脚本 · Linux / macOS / Windows</summary>
<div style={{ marginTop: 8 }}>
<CommandTabs
storageKey="moshiro.install_tab"
+250
View File
@@ -0,0 +1,250 @@
import { Maximize, Minus, Plus, RotateCcw, Server } from 'lucide-react'
import { useEffect, useId, useRef, useState, type PointerEvent } from 'react'
import { Link } from 'react-router-dom'
import type { LinkDto, LinkMatrixDto } from '@shared/api/types'
import { getBase } from '@shared/api/source'
import { runtimeStatusLabel, RuntimeStatusChip } from '@shared/nodes'
import { Chip, Empty } from '@shared/ui'
type Point = { x: number; y: number }
type View = Point & { scale: number }
type Positions = Record<string, Point>
type Drag = { pointer: number; origin: Point; start: Point; node?: string }
const WIDTH = 216
const HEIGHT = 108
const keyOf = (link: LinkDto) => `${link.dialer_id}/${link.listener_id}`
const clampScale = (scale: number) => Math.max(0.25, Math.min(2, scale))
function labelOf(link: LinkDto) {
if (!link.linked) return '已关闭'
if (link.connected) return '已连接'
const states: Record<string, string> = {
waiting_report: '等待上报', control_connecting: '控制连接中', control_down: '控制连接断开',
task_connecting: 'QUIC 连接中', task_retrying: 'QUIC 重试中',
dialer_unavailable: '来源不可用', listener_unavailable: '目标不可用',
}
return states[link.connection_state] ?? '连接中'
}
function readPositions(key: string): Positions {
try {
const saved: unknown = JSON.parse(localStorage.getItem(key) ?? '{}')
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return {}
return Object.fromEntries(Object.entries(saved).filter(([, p]) => p && typeof p === 'object' && Number.isFinite(p.x) && Number.isFinite(p.y)))
} catch {
return {}
}
}
export function LinkBoard({ data, busy, onSet, onRetry }: {
data: LinkMatrixDto
busy: boolean
onSet: (link: LinkDto, enabled: boolean | null) => Promise<void>
onRetry: (link: LinkDto) => Promise<void>
}) {
const storageKey = `moshiro.link-board:${getBase() || window.location.origin}`
const [positions, setPositions] = useState<Positions>(() => readPositions(storageKey))
const [view, setView] = useState<View>({ x: 0, y: 0, scale: 1 })
const [size, setSize] = useState({ width: 0, height: 0 })
const [selected, setSelected] = useState<string | null>(null)
const [dragging, setDragging] = useState(false)
const viewport = useRef<HTMLDivElement>(null)
const drag = useRef<Drag | null>(null)
const initialized = useRef(false)
const marker = useId().replaceAll(':', '')
const nodes = data.nodes
const focused = data.items.find((link) => keyOf(link) === selected)
const nodeIds = nodes.map((node) => node.id).join(',')
function pointOf(id: string, layout = positions): Point {
if (layout[id]) return layout[id]
const index = nodes.findIndex((node) => node.id === id)
const angle = Math.PI + index * Math.PI * 2 / Math.max(1, nodes.length)
const radius = nodes.length > 1 ? Math.max(250, nodes.length * 45) : 0
return { x: Math.cos(angle) * radius - WIDTH / 2, y: Math.sin(angle) * radius - HEIGHT / 2 }
}
function fit(layout = positions) {
if (!nodes.length || !size.width || !size.height) return
const points = nodes.map((node) => pointOf(node.id, layout))
const left = Math.min(...points.map((p) => p.x)) - 64
const top = Math.min(...points.map((p) => p.y)) - 88
const width = Math.max(...points.map((p) => p.x + WIDTH)) + 64 - left
const height = Math.max(...points.map((p) => p.y + HEIGHT)) + 88 - top
const scale = Math.min(1, clampScale(Math.min(size.width / width, size.height / height)))
setView({ x: (size.width - width * scale) / 2 - left * scale, y: (size.height - height * scale) / 2 - top * scale, scale })
}
useEffect(() => {
const element = viewport.current
if (!element) return
const observer = new ResizeObserver(([entry]) => setSize({ width: entry.contentRect.width, height: entry.contentRect.height }))
observer.observe(element)
return () => observer.disconnect()
}, [])
useEffect(() => {
if (!initialized.current && size.width && size.height && nodes.length) {
fit()
initialized.current = true
}
}, [size.width, size.height, nodeIds])
useEffect(() => {
const timer = window.setTimeout(() => {
try { localStorage.setItem(storageKey, JSON.stringify(positions)) } catch { /* 浏览器禁用存储时继续使用当前布局。 */ }
}, 250)
return () => window.clearTimeout(timer)
}, [positions, storageKey])
useEffect(() => {
const element = viewport.current
if (!element) return
const wheel = (event: WheelEvent) => {
event.preventDefault()
if (drag.current) return
const rect = element.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
setView((current) => {
const scale = clampScale(current.scale * Math.exp(-event.deltaY * 0.0015))
const ratio = scale / current.scale
return { x: x - (x - current.x) * ratio, y: y - (y - current.y) * ratio, scale }
})
}
element.addEventListener('wheel', wheel, { passive: false })
return () => element.removeEventListener('wheel', wheel)
}, [])
function start(event: PointerEvent, node?: string) {
if (event.button !== 0 || drag.current || (event.target as Element).closest('a, button, [data-edge]')) return
event.stopPropagation()
event.preventDefault()
viewport.current?.setPointerCapture(event.pointerId)
drag.current = { pointer: event.pointerId, origin: { x: event.clientX, y: event.clientY }, start: node ? pointOf(node) : view, node }
setDragging(true)
}
function move(event: PointerEvent) {
const current = drag.current
if (!current || current.pointer !== event.pointerId) return
const x = event.clientX - current.origin.x
const y = event.clientY - current.origin.y
if (current.node) {
setPositions((layout) => ({ ...layout, [current.node!]: { x: current.start.x + x / view.scale, y: current.start.y + y / view.scale } }))
} else {
setView((old) => ({ ...old, x: current.start.x + x, y: current.start.y + y }))
}
}
function end(event: PointerEvent) {
if (drag.current?.pointer !== event.pointerId) return
drag.current = null
setDragging(false)
if (viewport.current?.hasPointerCapture(event.pointerId)) viewport.current.releasePointerCapture(event.pointerId)
}
function zoom(factor: number) {
setView((current) => {
const scale = clampScale(current.scale * factor)
const ratio = scale / current.scale
return { x: size.width / 2 - (size.width / 2 - current.x) * ratio, y: size.height / 2 - (size.height / 2 - current.y) * ratio, scale }
})
}
return (
<div className="link-board-layout">
<section className="link-board" aria-label="Agent 节点画板">
<div className="link-board-tools">
<span className="muted">拖动节点排布 · 拖动空白平移 · 滚轮缩放</span>
<div className="row">
<button type="button" className="btn small" aria-label="缩小画板" onClick={() => zoom(1 / 1.2)}><Minus size={16} /></button>
<span className="link-board-scale">{Math.round(view.scale * 100)}%</span>
<button type="button" className="btn small" aria-label="放大画板" onClick={() => zoom(1.2)}><Plus size={16} /></button>
<button type="button" className="btn small" title="显示全部节点" aria-label="显示全部节点" onClick={() => fit()}><Maximize size={16} /></button>
<button type="button" className="btn small" onClick={() => { setPositions({}); fit({}) }}><RotateCcw size={14} />重置布局</button>
</div>
</div>
<div ref={viewport} className={`link-board-viewport${dragging ? ' dragging' : ''}`} onPointerDown={(event) => start(event)} onPointerMove={move} onPointerUp={end} onPointerCancel={end} onLostPointerCapture={end}>
{!nodes.length ? <div className="link-board-empty"><Empty>还没有已启用的 Agent,请先添加并启用节点。</Empty></div> : null}
<div className="link-board-world" style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }}>
<svg className="link-board-edges" width="1" height="1" aria-label="有向线路">
<defs>{['connected', 'pending', 'disabled', 'selected'].map((tone) => <marker key={tone} id={`${marker}-${tone}`} markerWidth="8" markerHeight="8" refX="9" refY="5" viewBox="0 0 10 10" orient="auto"><path d="M 0 0 L 10 5 L 0 10 Z" className={`link-board-arrow ${tone}`} /></marker>)}</defs>
{data.items.map((link) => {
const source = pointOf(link.dialer_id)
const target = pointOf(link.listener_id)
const dx = target.x - source.x
const dy = target.y - source.y
const length = Math.hypot(dx, dy) || 1
const ux = dx / length
const uy = dy / length
const margin = Math.min((WIDTH / 2 + 12) / Math.max(Math.abs(ux), 0.001), (HEIGHT / 2 + 12) / Math.max(Math.abs(uy), 0.001))
const bend = 76
const startX = source.x + WIDTH / 2 + ux * margin
const startY = source.y + HEIGHT / 2 + uy * margin
const endX = target.x + WIDTH / 2 - ux * margin
const endY = target.y + HEIGHT / 2 - uy * margin
const controlX = (startX + endX) / 2 - uy * bend
const controlY = (startY + endY) / 2 + ux * bend
const path = `M ${startX} ${startY} Q ${controlX} ${controlY} ${endX} ${endY}`
const labelX = (startX + 2 * controlX + endX) / 4
const labelY = (startY + 2 * controlY + endY) / 4
const active = selected === keyOf(link)
return (
<g key={keyOf(link)} data-edge className={`link-board-edge ${link.connected ? 'connected' : link.linked ? 'pending' : 'disabled'}${active ? ' selected' : ''}`} role="button" tabIndex={0} aria-label={`${link.dialer_name} → ${link.listener_name}:${labelOf(link)}`} aria-pressed={active} onClick={() => setSelected(keyOf(link))} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setSelected(keyOf(link)) } }}>
<title>{link.dialer_name} → {link.listener_name}:{labelOf(link)}</title>
<path className="link-board-edge-hit" d={path} />
<path className="link-board-edge-line" d={path} markerEnd={`url(#${marker}-${active ? 'selected' : link.connected ? 'connected' : link.linked ? 'pending' : 'disabled'})`} />
<g className="link-board-edge-label" transform={`translate(${labelX}, ${labelY})`}>
<rect x="-52" y="-13" width="104" height="26" rx="13" />
<text textAnchor="middle" dominantBaseline="central">{labelOf(link)}</text>
</g>
</g>
)
})}
</svg>
{nodes.map((node) => {
const point = pointOf(node.id)
const outgoing = data.items.filter((link) => link.dialer_id === node.id && link.connected).length
const incoming = data.items.filter((link) => link.listener_id === node.id && link.connected).length
return (
<div key={node.id} className={`link-board-node${node.online ? '' : ' offline'}`} style={{ left: point.x, top: point.y }} tabIndex={0} role="group" aria-label={`${node.name} 节点,方向键移动`} onPointerDown={(event) => start(event, node.id)} onKeyDown={(event) => {
if (event.target !== event.currentTarget || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return
event.preventDefault()
setPositions((layout) => ({ ...layout, [node.id]: { x: point.x + (event.key === 'ArrowRight' ? 20 : event.key === 'ArrowLeft' ? -20 : 0), y: point.y + (event.key === 'ArrowDown' ? 20 : event.key === 'ArrowUp' ? -20 : 0) } }))
}}>
<div className="link-board-node-head"><Server size={18} /><strong title={node.name}>{node.name}</strong><Link to={`/agents/${encodeURIComponent(node.id)}`} className="link-board-detail">详情</Link></div>
<div className="row"><RuntimeStatusChip status={node.runtime_status} /><span className="faint">{node.online ? '在线' : '离线'}</span></div>
<div className="link-board-node-counts"><span>拨出 <b>{outgoing}</b></span><span>接入 <b>{incoming}</b></span><span>{node.listens ? '监听已开启' : '监听已关闭'}</span></div>
</div>
)
})}
</div>
<div className="link-board-legend"><span className="connected">已连接</span><span className="pending">连接中</span><span className="disabled">已关闭</span></div>
</div>
</section>
<aside className="link-board-inspector" aria-label="线路操作">
<div className="card-title">线路详情</div>
<select className="input" aria-label="选择线路" value={selected ?? ''} onChange={(event) => setSelected(event.target.value || null)}>
<option value="">选择来源 → 目标</option>
{data.items.map((link) => <option key={keyOf(link)} value={keyOf(link)}>{link.dialer_name} → {link.listener_name}</option>)}
</select>
{focused ? (
<div className="stack">
<div className="link-board-route"><Link to={`/agents/${focused.dialer_id}`}>{focused.dialer_name}</Link><span>→</span><Link to={`/agents/${focused.listener_id}`}>{focused.listener_name}</Link></div>
<div className="row wrap"><Chip tone={focused.connected ? 'ok' : focused.linked ? 'warn' : ''}>{labelOf(focused)}</Chip><span className="muted">{focused.override == null ? '跟随自动' : '手动设置'}</span></div>
<dl className="kv"><dt>来源节点</dt><dd>{runtimeStatusLabel(focused.dialer_runtime_status)}</dd><dt>目标节点</dt><dd>{runtimeStatusLabel(focused.listener_runtime_status)}</dd><dt>重试次数</dt><dd>{focused.retry_attempt}</dd></dl>
{focused.connection_reason ? <p className="muted">{focused.connection_reason}</p> : null}
{focused.last_error ? <p className="notice warn">{focused.last_error}</p> : null}
{focused.retry_in_ms ? <p className="muted">约 {Math.ceil(focused.retry_in_ms / 1000)} 秒后重试</p> : null}
<button type="button" className={`btn${focused.linked ? '' : ' primary'}`} disabled={busy} onClick={() => onSet(focused, !focused.linked)}>{focused.linked ? '关闭此方向' : '开启此方向'}</button>
<button type="button" className="btn" disabled={busy || focused.override == null} onClick={() => onSet(focused, null)}>恢复自动</button>
<button type="button" className="btn" disabled={busy} onClick={() => onRetry(focused)}>立即重试</button>
<span className="faint">操作只影响当前方向。</span>
</div>
) : <Empty>点击画板中的连线,查看状态或调整测量方向。</Empty>}
<p className="link-board-storage faint">节点位置仅保存在当前浏览器。</p>
</aside>
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { LinkBoard } from './LinkBoard'
import { api } from '@shared/api/client'
import type { LinkDto, LinkMatrixDto } from '@shared/api/types'
import { useQuery } from '@shared/hooks'
import { runtimeStatusLabel } from '@shared/nodes'
import { Chip, Empty, ErrorNotice, Spinner, useAction } from '@shared/ui'
/**
* 线路:Agent 之间的有向线路。自动连接开着时所有可达 Agent 相互连接,关着时只连手动选中的;
* 每一对都可以手动强制开或关(点一下切换,「↺」回到自动)。来源 Agent 始终是主动拨出的一方。
*/
/** 线路连接状态:control_* 是与 Agent 的控制连接(断开即失联),task_* 是测量用的 QUIC 数据通道。 */
const CONNECTION_LABELS: Record<string, string> = {
waiting_report: '等待状态',
control_connecting: '控制连接中',
control_down: '失联',
task_connecting: 'QUIC 握手中',
task_retrying: 'QUIC 重试中',
}
export function Links({ dialerId }: { dialerId?: string }) {
const matrix = useQuery(() => api.get<LinkMatrixDto>('/api/admin/links'), [], 8000)
const { busy, run } = useAction()
const items = matrix.data?.items ?? []
const dialers = (matrix.data?.nodes ?? []).filter((n) => !dialerId || n.id === dialerId).map((n) => [n.id, n.name] as const)
const agents = (matrix.data?.nodes ?? []).map((n) => [n.id, n.name] as const)
const cell = (dialerId: string, agentId: string) => items.find((i) => i.dialer_id === dialerId && i.listener_id === agentId)
const set = async (link: LinkDto, enabled: boolean | null) => {
await run(() => api.put(`/api/admin/links/${link.dialer_id}/${link.listener_id}`, { enabled }))
matrix.reload()
}
const retry = async (link: LinkDto) => {
await run(() => api.post(`/api/admin/links/${link.dialer_id}/${link.listener_id}/retry`), '已要求 Agent 立即重试;若 Agent 离线,恢复后会自动重连')
matrix.reload()
}
const setAuto = async (on: boolean) => {
await run(() => api.patch('/api/admin/settings', { 'fleet.auto_link': on }), on ? '已开启自动连接' : '已关闭自动连接')
matrix.reload()
}
return (
<div className="card">
<div className={`row${dialerId ? '' : ' link-board-header'}`} style={{ marginBottom: 12 }}>
<b>{dialerId ? '拨出连接' : 'Agent 节点画板'}</b>
{matrix.loading && !matrix.data ? <Spinner /> : null}
<span className="muted">{dialerId ? '点格子调整单方向的连接策略。' : 'Agent 默认相互探测,点击连线管理方向。'}</span>
{!dialerId ? <label className="switch right">
<input type="checkbox" checked={matrix.data?.auto_link ?? true} disabled={busy} onChange={(e) => setAuto(e.target.checked)} />
自动连接 {matrix.data?.auto_link === false ? '关' : '开'}
</label> : null}
</div>
<ErrorNotice error={matrix.error} />
{!dialerId && matrix.data ? <LinkBoard data={matrix.data} busy={busy} onSet={set} onRetry={retry} /> : null}
{dialerId && matrix.data && items.length === 0 ? <Empty>尚无可连接的线路,请添加并启用其他 Agent。</Empty> : null}
{dialerId ? <table className="table">
<thead>
<tr>
<th>来源 \ 目标</th>
{agents.map(([id, name]) => (
<th key={id}>{name}</th>
))}
</tr>
</thead>
<tbody>
{dialers.map(([dialerId, dialerName]) => (
<tr key={dialerId}>
<td style={{ fontWeight: 600 }}>{dialerName}</td>
{agents.map(([agentId]) => {
const link = cell(dialerId, agentId)
if (!link) return <td key={agentId} className="faint">{dialerId === agentId ? '—' : '未监听'}</td>
const tone = link.connected ? 'ok' : link.linked ? 'warn' : ''
const text = link.connected ? '已连接' : !link.linked ? '未连接' : CONNECTION_LABELS[link.connection_state] ?? (link.dialer_runtime_status !== 'running' ? runtimeStatusLabel(link.dialer_runtime_status) : link.listener_runtime_status !== 'running' ? runtimeStatusLabel(link.listener_runtime_status) : '连接中')
const statusHint = link.connected
? '两个 Agent 已 建立测量会话'
: !link.linked
? '这条线路已关闭'
: [link.connection_reason, link.last_error, link.retry_in_ms ? `约 ${Math.ceil(link.retry_in_ms / 1000)} 秒后自动重试` : null].filter(Boolean).join(';') || `来源 Agent:${runtimeStatusLabel(link.dialer_runtime_status)};目标 Agent:${runtimeStatusLabel(link.listener_runtime_status)}`
return (
<td key={agentId}>
<span className="row" style={{ gap: 6 }}>
<button type="button" className="btn small" disabled={busy} title={`${statusHint}。点一下${link.linked ? '手动关闭' : '手动开启'}线路`} onClick={() => set(link, !link.linked)}>
<Chip tone={tone}>{text}</Chip>
</button>
{link.override !== null && link.override !== undefined ? (
<>
<span className="faint" title="手动设置">
手动
</span>
<button type="button" className="btn small text" disabled={busy} title="回到自动" onClick={() => set(link, null)}>
↺
</button>
</>
) : null}
{link.linked && !link.connected ? (
<button type="button" className="btn small" disabled={busy} title={statusHint} onClick={() => retry(link)}>
立即重试
</button>
) : null}
</span>
{!link.connected && link.linked && link.connection_reason ? <div className="faint" style={{ marginTop: 4, maxWidth: 320 }} title={link.last_error ?? undefined}>{link.connection_reason}</div> : null}
</td>
)
})}
</tr>
))}
</tbody>
</table> : null}
</div>
)
}
+1 -1
View File
@@ -30,7 +30,7 @@ export function OwnerPicker({ value, onChange }: { value: string[] | null; onCha
if (!trust.initialized) {
return (
<div className="notice warn">
还没有建立信任根,所以现在无法添加 Agent 和 Probe:每个节点都要绑定到属主的 Passkey 上,节点只信任由 Passkey 签名的信任根。没有信任根时只能进入面板做基础管理,这是架构如此。请先到「系统 → 信任」注册 Passkey 并建立信任根。
还没有建立信任根,所以现在无法添加 Agent:每个节点都要绑定到属主的 Passkey 上,节点只信任由 Passkey 签名的信任根。没有信任根时只能进入面板做基础管理,这是架构如此。请先到「系统 → 信任」注册 Passkey 并建立信任根。
</div>
)
}
-120
View File
@@ -1,120 +0,0 @@
import { ChevronLeft } from 'lucide-react'
import { Link, useParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { ProbeDto } from '@shared/api/types'
import { fmtAgo, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { NodeTag, RuntimeStatusChip, useNodeInfos } from '@shared/nodes'
import { IPV6_STATES } from '@shared/ipVersions'
import { Chip, Dot, Empty, ErrorNotice, Spinner } from '@shared/ui'
import { RuntimeLogs } from '../RuntimeLogs'
export function ProbeDetail() {
const { id = '' } = useParams()
const probe = useQuery(() => api.get<ProbeDto>(`/api/admin/probes/${encodeURIComponent(id)}`), [id], 5000)
const infos = useNodeInfos()
const p = probe.data
return (
<>
<Link to="/probes" className="row muted" style={{ marginBottom: 8 }}>
<ChevronLeft size={16} />
Probe
</Link>
<header>
<h1>{p?.name ?? id}</h1>
{p ? (
<span className="row">
<Dot tone={p.online ? 'ok' : 'bad'} />
<RuntimeStatusChip status={p.runtime_status} />
</span>
) : null}
<span className="right row">
{probe.loading && !p ? <Spinner /> : null}
{p ? <NodeTag info={infos.get(p.id)} /> : null}
</span>
</header>
<ErrorNotice error={probe.error} />
{p ? (
<div className="stack">
<div className="grid cols-2">
<section className="card">
<div className="card-title">Probe 状态</div>
<dl className="kv">
<dt>运行状态</dt>
<dd><RuntimeStatusChip status={p.runtime_status} /></dd>
<dt>面板连接</dt>
<dd><Chip tone={p.online ? 'ok' : 'bad'}>{p.online ? '已连接' : '未连接'}</Chip></dd>
<dt>测量连接</dt>
<dd>{p.connections.length ? `连接 ${new Set(p.connections.map((c) => c.agent_id)).size} 个 Agent` : '当前无活动 Agent 会话'}</dd>
<dt>IPv6 诊断</dt>
<dd>{p.ipv6 ? IPV6_STATES[p.ipv6.state] ?? '状态未知' : '等待 Probe 上报'}</dd>
{p.ipv6 ? <>
<dt>检测网络</dt>
<dd>{p.ipv6.network_scope === 'host' ? '宿主机' : p.ipv6.network_scope === 'container' ? '容器网络' : '未知'}</dd>
<dt>IPv6 默认路由</dt>
<dd>{p.ipv6.has_default_route == null ? '未知' : p.ipv6.has_default_route ? '存在' : '缺失'}</dd>
</> : null}
<dt>Probe ID</dt>
<dd className="mono">{p.id}</dd>
<dt>创建时间</dt>
<dd>{fmtTime(p.created_at)}</dd>
{p.added_by ? <><dt>添加者</dt><dd>{p.added_by}</dd></> : null}
{p.revoked_at ? <><dt>吊销时间</dt><dd>{fmtTime(p.revoked_at)}</dd></> : null}
</dl>
<p className="faint" style={{ marginTop: 12 }}>
{p.runtime_status === 'probe_paused'
? '没有已启用的 Agent 目标;在「Probe → 线路」里开启线路后会开始探测。'
: p.online && p.connections.length === 0
? 'Probe 已连接面板,但当前没有建立 Agent 测量会话。'
: !p.online
? `Probe 当前未连接面板${p.revoked ? ',且已吊销' : ''}。`
: 'Probe 正在连接已启用的 Agent,并通过 QUIC Datagram 进行逐包探测。'}
</p>
<a className="btn small" href={`/host/${encodeURIComponent(p.id)}`}>
查看主机状态
</a>
</section>
<section className="card">
<div className="card-title">Agent 会话({p.connections.length})</div>
{p.connections.length === 0 ? (
<Empty>{p.online ? 'Probe 在线,但暂时没有活动的 Agent 会话。' : 'Probe 离线时没有活动会话。'}</Empty>
) : (
<table className="table">
<thead>
<tr>
<th>Agent</th>
<th>状态</th>
<th>IP 版本</th>
<th>版本</th>
<th>来源地址</th>
<th>连接时间</th>
<th />
</tr>
</thead>
<tbody>
{p.connections.map((c) => (
<tr key={`${c.agent_id}~${c.ip_version}`}>
<td><Link to={`/agents/${c.agent_id}`} style={{ fontWeight: 600 }}>{c.agent_name}</Link></td>
<td><Chip tone={c.agent_online ? 'ok' : 'bad'}>{c.agent_online ? '运行中' : 'Agent 离线'}</Chip></td>
<td><Chip>{c.ip_version === 'ipv6' ? 'IPv6' : 'IPv4'}</Chip></td>
<td>{c.version ?? '—'}</td>
<td className="mono">{c.remote ?? '—'}</td>
<td className="muted">{fmtTime(c.connected_since)}({fmtAgo(c.connected_since)})</td>
<td className="r"><a className="btn small" href={`/line/${encodeURIComponent(`${c.agent_id}~${p.id}${c.ip_version === 'ipv6' ? '~ipv6' : ''}`)}`}>查看线路</a></td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
<RuntimeLogs nodeId={p.id} />
</div>
) : null}
</>
)
}
-380
View File
@@ -1,380 +0,0 @@
import { 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 { CreatedJoinTokenDto, LinkDto, LinkMatrixDto, Page, ProbeDto } from '@shared/api/types'
import { fmtAgo, fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { NodeTag, RuntimeStatusChip, runtimeStatusLabel, useNodeInfos } from '@shared/nodes'
import { IPV6_STATES } from '@shared/ipVersions'
import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
import { JoinTokenCreated } from './JoinTokenCreated'
import { OwnerPicker } from './OwnerPicker'
/**
* 线路:哪个 Probe 连哪个 Agent。自动连接开着时所有 Probe 和 Agent 两两连,关着时只连手动选中的;
* 每一对都可以手动强制开或关(点一下切换,「↺」回到自动)。Probe 始终是主动拨出的一方。
*/
function LinkMatrix() {
const matrix = useQuery(() => api.get<LinkMatrixDto>('/api/admin/links'), [], 8000)
const { busy, run } = useAction()
const items = matrix.data?.items ?? []
const probes = [...new Map(items.map((i) => [i.probe_id, i.probe_name])).entries()]
const agents = [...new Map(items.map((i) => [i.agent_id, i.agent_name])).entries()]
const cell = (probeId: string, agentId: string) => items.find((i) => i.probe_id === probeId && i.agent_id === agentId)
const set = async (link: LinkDto, enabled: boolean | null) => {
await run(() => api.put(`/api/admin/links/${link.probe_id}/${link.agent_id}`, { enabled }))
matrix.reload()
}
const retry = async (link: LinkDto) => {
await run(() => api.post(`/api/admin/links/${link.probe_id}/${link.agent_id}/retry`), '已要求 Probe 立即重试;若 Probe 离线,恢复后会自动重连')
matrix.reload()
}
const setAuto = async (on: boolean) => {
await run(() => api.patch('/api/admin/settings', { 'fleet.auto_link': on }), on ? '已开启自动连接' : '已关闭自动连接')
matrix.reload()
}
if (items.length === 0 && !matrix.loading) return null
return (
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<b>线路</b>
<span className="muted">Probe 主动连接 Agent。点格子手动开关;「↺」回到自动。</span>
<label className="switch right">
<input type="checkbox" checked={matrix.data?.auto_link ?? true} disabled={busy} onChange={(e) => setAuto(e.target.checked)} />
自动连接 {matrix.data?.auto_link === false ? '关' : '开'}
</label>
</div>
<ErrorNotice error={matrix.error} />
<table className="table">
<thead>
<tr>
<th>Probe \ Agent</th>
{agents.map(([id, name]) => (
<th key={id}>{name}</th>
))}
</tr>
</thead>
<tbody>
{probes.map(([probeId, probeName]) => (
<tr key={probeId}>
<td style={{ fontWeight: 600 }}>{probeName}</td>
{agents.map(([agentId]) => {
const link = cell(probeId, agentId)
if (!link) return <td key={agentId} />
const tone = link.connected ? 'ok' : link.linked ? 'warn' : ''
const text = link.connected ? '已连接' : !link.linked ? '未连接' : link.connection_state === 'retry_wait' ? '重试等待' : link.connection_state === 'waiting_report' ? '等待状态' : link.connection_state === 'control_disconnected' ? '控制连接失败' : link.connection_state === 'quic_connecting' ? 'QUIC 握手中' : link.probe_runtime_status !== 'running' ? runtimeStatusLabel(link.probe_runtime_status) : link.agent_runtime_status !== 'running' ? runtimeStatusLabel(link.agent_runtime_status) : '连接中'
const statusHint = link.connected
? 'Probe 已和 Agent 建立测量会话'
: !link.linked
? '这条线路已关闭'
: [link.connection_reason, link.last_error, link.retry_in_ms ? `约 ${Math.ceil(link.retry_in_ms / 1000)} 秒后自动重试` : null].filter(Boolean).join(';') || `Probe:${runtimeStatusLabel(link.probe_runtime_status)};Agent:${runtimeStatusLabel(link.agent_runtime_status)}`
return (
<td key={agentId}>
<span className="row" style={{ gap: 6 }}>
<button type="button" className="btn small" disabled={busy} title={`${statusHint}。点一下${link.linked ? '手动关闭' : '手动开启'}线路`} onClick={() => set(link, !link.linked)}>
<Chip tone={tone}>{text}</Chip>
</button>
{link.override !== null && link.override !== undefined ? (
<>
<span className="faint" title="手动设置">
手动
</span>
<button type="button" className="btn small text" disabled={busy} title="回到自动" onClick={() => set(link, null)}>
↺
</button>
</>
) : null}
{link.linked && !link.connected ? (
<button type="button" className="btn small" disabled={busy} title={statusHint} onClick={() => retry(link)}>
立即重试
</button>
) : null}
</span>
{!link.connected && link.linked && link.connection_reason ? <div className="faint" style={{ marginTop: 4, maxWidth: 320 }} title={link.last_error ?? undefined}>{link.connection_reason}</div> : null}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)
}
/**
* 创建 Probe 和创建 Agent 一样:生成一个一次性的加入令牌,在家宽机器上运行命令。
* 密钥由 Probe 自己生成,面板从头到尾不接触私钥;想换密钥就吊销之后重新注册。
*/
function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const { busy, run } = useAction()
const [note, setNote] = useState('')
const [hours, setHours] = useState(24)
const [created, setCreated] = useState<CreatedJoinTokenDto>()
const [controllers, setControllers] = useState<string[] | null>(null)
if (created) return <JoinTokenCreated created={created} kind="Probe" onClose={onClose} />
return (
<Dialog
title="添加 Probe"
onClose={onClose}
actions={
<>
<button type="button" className="btn text" onClick={onClose}>
取消
</button>
<button
type="button"
className="btn primary"
disabled={busy || !controllers?.length}
onClick={async () => {
await run(async () => {
const token = await api.post<CreatedJoinTokenDto>('/api/admin/join-tokens', {
role: 'probe',
note: note || null,
auto_approve: true,
max_uses: 1,
expires_in_hours: hours,
controllers: controllers ?? undefined,
})
setCreated(token)
onCreated()
})
}}
>
创建加入令牌
</button>
</>
}
>
<p className="muted" style={{ marginBottom: 16 }}>
添加 Probe 就是创建一个一次性的加入令牌:在家宽机器上运行命令,Probe 会用它注册并出现在列表里。
</p>
<div className="form-grid">
<div className="wide">
<Field label="备注" hint="例如这台机器的位置或运营商">
<input className="input" value={note} onChange={(e) => setNote(e.target.value)} placeholder="上海电信家宽" />
</Field>
</div>
<Field label="有效期(小时)">
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
</Field>
<div className="wide">
<Field label="属主(passkey)" hint="必选(至少一把):这个节点绑定到哪几个账户的哪几把 Passkey 上,它的敏感操作(web ssh 等)和加密内容只认它们。只是选,不用触碰;装上后固定在节点本地,面板改不了。">
<OwnerPicker value={controllers} onChange={setControllers} />
</Field>
</div>
</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>
)
}
const ISP_CHOICES = [
{ value: 'auto', label: '运营商:自动识别' },
{ value: 'telecom', label: '中国电信' },
{ value: 'unicom', label: '中国联通' },
{ value: 'mobile', label: '中国移动' },
{ value: 'broadnet', label: '中国广电' },
{ value: 'other', label: '其他' },
]
type Pending = { kind: 'revoke' | 'delete'; probe: ProbeDto }
export function Probes() {
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 8000)
const infos = useNodeInfos()
const { run } = useAction()
const [creating, setCreating] = useState(false)
const [renaming, setRenaming] = useState<ProbeDto>()
const [pending, setPending] = useState<Pending>()
// 刚改过的运营商(公开的节点信息每分钟才刷新一次,先按这里显示)
const [isp, setIsp] = useState<Record<string, string>>({})
const list = probes.data?.items ?? []
const confirmText = {
revoke: { title: '吊销 Probe', danger: true, text: '所有 Agent 会立刻断开并拒绝这个 Probe。记录和历史数据保留,但它不能再连接,也不能恢复;要换密钥就吊销之后重新添加一个 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">
<LinkMatrix />
{probes.data && list.length === 0 ? (
<div className="card">
<Empty>还没有 Probe。点击右上角「添加 Probe」,在家宽机器上运行命令即可注册。</Empty>
</div>
) : null}
{list.map((p) => (
<div key={p.id} className="card">
<div className="row wrap">
<Link to={`/probes/${encodeURIComponent(p.id)}`} style={{ fontSize: 18, fontWeight: 700, color: 'inherit', textDecoration: 'none' }}>
{p.name}
</Link>
<NodeTag info={infos.get(p.id)} />
<Chip tone={p.ipv6?.state === 'ready' ? 'ok' : 'warn'}>{p.ipv6 ? IPV6_STATES[p.ipv6.state] ?? 'IPv6 状态未知' : '等待 IPv6 诊断'}</Chip>
{p.revoked ? (
<Chip tone="bad">已吊销</Chip>
) : (
<>
<RuntimeStatusChip status={p.runtime_status} />
<Chip tone={p.connections.length > 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${new Set(p.connections.map((c) => c.agent_id)).size} 个 Agent` : '没有连接'}</Chip>
</>
)}
<span className="mono faint">{p.id}</span>
<span className="right row wrap">
<select
className="input small"
title="展示页按电信 / 联通 / 移动给线路分组,自动识别不准时在这里手动指定"
value={isp[p.id] ?? (infos.get(p.id)?.isp_manual ? infos.get(p.id)?.isp_key ?? 'auto' : 'auto')}
onChange={async (e) => {
const value = e.target.value
if (await run(() => api.patch(`/api/admin/probes/${p.id}`, { isp: value }), '已保存')) setIsp((m) => ({ ...m, [p.id]: value }))
}}
>
{ISP_CHOICES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
<button type="button" className="btn small" onClick={() => setRenaming(p)}>
<Pencil size={14} />
改名
</button>
{!p.revoked ? (
<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.added_by ? `,由 ${p.added_by} 添加` : ''}
{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}~${c.ip_version}`}>
<td>
<Link to={`/agents/${c.agent_id}`} style={{ fontWeight: 600 }}>
{c.agent_name}
<Chip>{c.ip_version === 'ipv6' ? 'IPv6' : 'IPv4'}</Chip>
</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 ? <AddProbeDialog onClose={() => setCreating(false)} onCreated={probes.reload} /> : 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 === '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}
</>
)
}
+6 -7
View File
@@ -1,7 +1,7 @@
import { Download, KeyRound, Plus, Trash2 } from 'lucide-react'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { api } from '@shared/api/client'
import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeOwnerDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, AuditDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeOwnerDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, AuditDto, EventDto, Page, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo'
import { fmtNum, fmtTime } from '@shared/format'
import { translateSetting } from '@shared/i18n'
@@ -48,7 +48,7 @@ function RestartCard({ items, onSaved }: { items: SettingDto[]; onSaved: () => v
return (
<div className="card" style={{ marginBottom: 16 }}>
<div className="card-title">定时重启(Agent 和 Probe)</div>
<div className="card-title">定时重启(Agent)</div>
<div className="row wrap" style={{ gap: 16 }}>
<label className="switch">
<input type="checkbox" checked={draft.enabled} onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })} />
@@ -241,14 +241,13 @@ function UpdateCell({ update, busy, onUpdate }: { update?: NodeUpdateDto | null;
)
}
/** 每个程序的版本:后端、前端、每个 Agent 和 Probe 自己上报的,以及 CI 发布的最新签名清单和各节点的更新状态。 */
/** 每个程序的版本:后端、前端、每个 Agent 自己上报的,以及 CI 发布的最新签名清单和各节点的更新状态。 */
function Versions() {
const { busy, run } = useAction()
const releases = useQuery(() => api.get<Page<ReleaseDto>>('/api/admin/releases'), [], 30_000)
const info = useQuery(() => api.get<BuildInfoDto>('/api/admin/build-info'), [], 15_000)
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [], 30_000)
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 30_000)
const names = new Map<string, string>([...(agents.data?.items ?? []).map((a) => [a.id, a.name] as const), ...(probes.data?.items ?? []).map((p) => [p.id, p.name] as const)])
const names = new Map<string, string>([...(agents.data?.items ?? []).map((a) => [a.id, a.name] as const)])
const back = info.data?.backend
const nodes = info.data?.nodes ?? []
// 以后端的版本为准,节点的版本不一样就标出来(升级了一半的时候一眼能看到)
@@ -301,7 +300,7 @@ function Versions() {
<tr key={n.id}>
<td style={{ fontWeight: 600 }}>{names.get(n.id) ?? n.id}</td>
<td>
<Chip>{n.kind}</Chip>
<Chip>Agent</Chip>
</td>
<td className="mono">
{n.version ? mismatch(n.version) ? <Chip tone="warn">{n.version}</Chip> : n.version : <span className="faint">—</span>}
@@ -717,7 +716,7 @@ function Events() {
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)} />
<input className="input" style={{ width: 240 }} placeholder="按类型筛选,例如 dialer_connected" value={kind} onChange={(e) => setKind(e.target.value)} />
<span className="right">{events.loading && !events.data ? <Spinner /> : null}</span>
</div>
<ErrorNotice error={events.error} />
+1 -1
View File
@@ -309,7 +309,7 @@ export function Tasks() {
tasks.reload()
}}
>
删除之后,Agent 和 Probe 会在下一次同步时停止这个任务。
删除之后,Agent 会在下一次同步时停止这个任务。
</Confirm>
) : null}
</>
+13 -14
View File
@@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'
import { IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
import { useSearchParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { AgentDto, Page, PlanDto, ProbeDto, TestDto } from '@shared/api/types'
import type { AgentDto, Page, PlanDto, TestDto } from '@shared/api/types'
import { fmtMbps, fmtMs, fmtPct, fmtTime, scoreTone } from '@shared/format'
import { translatePlan } from '@shared/i18n'
import { useQuery } from '@shared/hooks'
@@ -87,18 +87,17 @@ export function Tests() {
const [params, setParams] = useSearchParams()
const ipVersion = ipVersionOf(params.get('ip_version'))
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 ?? []).filter((p) => ipVersionOf(p.ip_version) === ipVersion)
const probeId = params.get('probe') ?? connected[0]?.probe_id ?? ''
const connected = (agent?.dialers ?? []).filter((p) => ipVersionOf(p.ip_version) === ipVersion)
const dialerId = params.get('dialer') ?? connected[0]?.dialer_id ?? ''
const [planId, setPlanId] = useState('')
const [selected, setSelected] = useState<string>()
const line = agentId && probeId ? `${agentId}~${probeId}${ipVersion === 'ipv6' ? '~ipv6' : ''}` : ''
const line = agentId && dialerId ? `${agentId}~${dialerId}${ipVersion === 'ipv6' ? '~ipv6' : ''}` : ''
useEffect(() => {
if (!planId && plans.data?.items[0]) setPlanId(plans.data.items[0].id)
@@ -108,7 +107,7 @@ export function Tests() {
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 dialerName = (id: string) => agents.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
@@ -131,11 +130,11 @@ export function Tests() {
<div className="card">
<div className="card-title">发起测试</div>
<p className="muted" style={{ marginBottom: 16 }}>
测试会在选中的线路(一个 Agent 和一个连着它的 Probe)上跑,会占用链路带宽,请不要太频繁。
选择来源和目标 Agent,测试沿该方向运行并占用线路带宽。
</p>
<div className="form-grid">
<Field label="Agent">
<select className="select" value={agentId} onChange={(e) => set({ agent: e.target.value, probe: '' })}>
<Field label="目标 Agent">
<select className="select" value={agentId} onChange={(e) => set({ agent: e.target.value, dialer: '' })}>
{active.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
@@ -144,11 +143,11 @@ export function Tests() {
))}
</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}>
<Field label="来源 Agent" hint={connected.length === 0 ? '目标 Agent 暂无接入会话,连接建立后可发起测试' : undefined}>
<select className="select" value={dialerId} onChange={(e) => set({ dialer: e.target.value })} disabled={connected.length === 0}>
{connected.map((p) => (
<option key={p.probe_id} value={p.probe_id}>
{probeName(p.probe_id)}
<option key={p.dialer_id} value={p.dialer_id}>
{dialerName(p.dialer_id)}
</option>
))}
</select>
@@ -195,7 +194,7 @@ export function Tests() {
) : (
<table className="table hover">
<tbody>
{tests.data?.items.filter((t) => t.agent_id === agentId && t.probe_id === probeId && ipVersionOf(t.ip_version) === ipVersion).map((t) => (
{tests.data?.items.filter((t) => t.agent_id === agentId && t.dialer_id === dialerId && ipVersionOf(t.ip_version) === ipVersion).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>
+2 -2
View File
@@ -68,7 +68,7 @@ export function Trust({ onInitialized }: { onInitialized?: () => void } = {}) {
await api.post('/api/admin/trust', signed)
})
if (ok) {
toast(state.initialized ? `操作员集合已更新到第 ${proposal.version} 版,会随名册下发给节点。` : '信任根已建立。现在可以添加 Agent 和 Probe 了。', false, { title: '已用 Passkey 签名', ms: 8000 })
toast(state.initialized ? `操作员集合已更新到第 ${proposal.version} 版,会随名册下发给节点。` : '信任根已建立。现在可以添加 Agent 了。', false, { title: '已用 Passkey 签名', ms: 8000 })
trust.reload()
}
}
@@ -85,7 +85,7 @@ export function Trust({ onInitialized }: { onInitialized?: () => void } = {}) {
<b>还没有建立信任根</b>
</div>
<p className="muted">
<b>不建立信任根就无法添加 Agent 和 Probe,只能进入面板做基础管理——这是架构如此:节点只信任由你的 Passkey 签名的信任根,面板自己没法代签。</b>建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。
<b>不建立信任根就无法添加 Agent,只能进入面板做基础管理——这是架构如此:节点只信任由你的 Passkey 签名的信任根,面板自己没法代签。</b>建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。
这一步会把下面列出的 Passkey 登记为操作员,需要你触碰一次 Passkey 给集合签名。
</p>
{state.proposal ? (
+2 -2
View File
@@ -73,7 +73,7 @@ export function LineStrips({ lineId, range, m }: Props) {
{series.error ? <span className="line-mini-badge bad" title={`上一次取数失败:${series.error.message}`}>数据未更新</span> : null}
</div>
<div className="line-mini-direction">
<div className="line-mini-direction-head" title={`去程 Probe → Agent 丢包 ${fmtPct(up)}`}>
<div className="line-mini-direction-head" title={`去程 拨号方 → 监听方 丢包 ${fmtPct(up)}`}>
<span>去程</span>
<span className={`line-mini-badge ${levelOf(up)}`}>{upText}</span>
</div>
@@ -84,7 +84,7 @@ export function LineStrips({ lineId, range, m }: Props) {
</div>
</div>
<div className="line-mini-direction">
<div className="line-mini-direction-head" title={`回程 Agent → Probe 丢包 ${fmtPct(down)}`}>
<div className="line-mini-direction-head" title={`回程 监听方 → 拨号方 丢包 ${fmtPct(down)}`}>
<span>回程</span>
<span className={`line-mini-badge ${levelOf(down)}`}>{downText}</span>
</div>
+2 -2
View File
@@ -1008,7 +1008,7 @@
pointer-events: none;
}
/* ── 对比:每个 Probe 的最佳线路 + 矩阵 ── */
/* ── 对比:每个 来源 Agent 的最佳线路 + 矩阵 ── */
.winners {
margin-top: 24px;
display: grid;
@@ -1792,7 +1792,7 @@
fill: var(--isp-broadnet);
}
/* 线路行:国旗在 Probe 名字前面,运营商图标在后面 */
/* 线路行:国旗在 来源 Agent 名字前面,运营商图标在后面 */
.line-name {
display: inline-flex;
align-items: center;
+1 -1
View File
@@ -51,6 +51,6 @@ export function PageError({ error }: { error: unknown }) {
return <div className="notice bad" style={{ marginTop: 24 }}>{describeError(error)}</div>
}
export const lineLabel = (name: string, probe: string) => `${name} · ${probe}`
export const lineLabel = (target: string, source: string) => `${source} → ${target}`
export const num = (v: number | null | undefined) => (v === null || v === undefined ? null : v)
+6 -29
View File
@@ -1,36 +1,13 @@
import { useEffect, useState } from 'react'
import type { Page } from '@shared/api/types'
import type { Page, HostDto, HostItemDto } from '@shared/api/types'
import { api } from '@shared/api/client'
import { openLive } from '@shared/api/source'
/** 展示页能拿到的主机状态:只有汇总的数字,没有型号和 IP。 */
export interface Host {
cpu_percent: number | null
mem_used: number | null
mem_total: number | null
disk_used: number | null
disk_total: number | null
net_rx_total: number | null
net_tx_total: number | null
net_rx_bps: number | null
net_tx_bps: number | null
net_link_mbps: number | null
uptime_seconds: number | null
at: string
}
export interface HostItem {
id: string
kind: 'agent' | 'probe'
name: string
online: boolean
stale: boolean
last_seen_at: string | null
host: Host | null
}
export type Host = HostDto
export type HostItem = HostItemDto
/**
* Agent 和 Probe 主机状态的实时订阅:先用 REST 取一次全量,再用 SSE(`hosts` 主题)接收每一次更新。
* Agent 主机状态的实时订阅:先用 REST 取一次全量,再用 SSE(`hosts` 主题)接收每一次更新。
* 连接断开时浏览器会自动重连;重连之后服务端会先推一份当前快照。
*/
export function useHosts(): { hosts: Map<string, HostItem>; live: boolean } {
@@ -48,11 +25,11 @@ export function useHosts(): { hosts: Map<string, HostItem>; live: boolean } {
source.onopen = () => setLive(true)
source.onerror = () => setLive(false)
source.addEventListener('host', (event) => {
const data = JSON.parse((event as MessageEvent<string>).data) as { id: string; kind: 'agent' | 'probe'; online: boolean; host: Host }
const data = JSON.parse((event as MessageEvent<string>).data) as { id: string; online: boolean; host: Host }
setHosts((previous) => {
const next = new Map(previous)
const old = previous.get(data.id)
next.set(data.id, { id: data.id, kind: data.kind, name: old?.name ?? data.id, online: data.online, stale: false, last_seen_at: data.host.at, host: data.host })
next.set(data.id, { id: data.id, name: old?.name ?? data.id, online: data.online, stale: false, last_seen_at: data.host.at, host: data.host })
return next
})
})
+27 -27
View File
@@ -49,7 +49,7 @@ function fmt(m: MetricInfo, v: number | null | undefined): string {
interface Cell {
line: OverviewLineDto
value: number | null
/** 在这一列(同一个 Probe)里的名次,从 1 开始;没有数据是 null。 */
/** 在这一行(同一个来源 Agent)里的名次,从 1 开始;没有数据是 null。 */
rank: number | null
/** 比这一列最好的差多少(百分比);最好的是 0。 */
gap: number | null
@@ -64,15 +64,15 @@ export function Compare() {
const overview = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: metric.id, ip_version: ipVersion }), [range, metric.id, ipVersion], 15_000)
// 矩阵:行是 Agent,列是 Probe;每一列内部排名(同一个 Probe 看出去,哪个 Agent 的线路好)
// 矩阵:行是来源 Agent,列是目标 Agent;每一行内部排名(同一个 来源 Agent 看出去,哪个 Agent 的线路好)
const matrix = useMemo(() => {
const lines = (overview.data?.lines ?? []).filter((l) => ipVersionOf(l.ip_version) === ipVersion)
const probes = [...new Map(lines.map((l) => [l.probe_id, l.probe_name])).entries()].sort((a, b) => a[1].localeCompare(b[1]))
const dialers = [...new Map(lines.map((l) => [l.dialer_id, l.dialer_name])).entries()].sort((a, b) => a[1].localeCompare(b[1]))
const agents = [...new Map(lines.map((l) => [l.agent_id, l.name])).entries()].sort((a, b) => a[1].localeCompare(b[1]))
const byProbe = new Map<string, Cell[]>()
for (const [probeId] of probes) {
const byDialer = new Map<string, Cell[]>()
for (const [dialerId] of dialers) {
const cells: Cell[] = lines
.filter((l) => l.probe_id === probeId)
.filter((l) => l.dialer_id === dialerId)
.map((line) => ({ line, value: line.summary.metrics[metric.id] ?? null, rank: null, gap: null }))
const ranked = cells.filter((c) => c.value !== null).sort((a, b) => (metric.better === 'lower' ? a.value! - b.value! : b.value! - a.value!))
const best = ranked[0]?.value ?? null
@@ -80,16 +80,16 @@ export function Compare() {
c.rank = i + 1
c.gap = best === null || best === 0 ? (c.value === best ? 0 : null) : (Math.abs(c.value! - best) / Math.abs(best)) * 100
})
byProbe.set(probeId, cells)
byDialer.set(dialerId, cells)
}
return { probes, agents, byProbe }
return { dialers, agents, byDialer }
}, [overview.data, metric, ipVersion])
// 默认选每个 Probe 里最好的那条线路(最多 6 条)
// 默认选每个 来源 Agent 里最好的那条线路(最多 6 条)
const defaults = useMemo(() => {
const ids: string[] = []
for (const [probeId] of matrix.probes) {
const best = matrix.byProbe.get(probeId)?.find((c) => c.rank === 1)
for (const [dialerId] of matrix.dialers) {
const best = matrix.byDialer.get(dialerId)?.find((c) => c.rank === 1)
if (best) ids.push(best.line.id)
}
return ids.slice(0, MAX_LINES)
@@ -115,17 +115,17 @@ export function Compare() {
update({ lines: next.join(',') })
}
const names = new Map((overview.data?.lines ?? []).map((l) => [l.id, lineLabel(l.name, l.probe_name)]))
const names = new Map((overview.data?.lines ?? []).map((l) => [l.id, lineLabel(l.name, l.dialer_name)]))
const series: ChartSeries[] = selected.map((id, i) => ({ label: names.get(id) ?? id, values: compare.data?.values[id] ?? [], color: COLORS[i % COLORS.length] }))
// 每个 Probe 的最佳线路
const winners = matrix.probes.map(([probeId, probeName]) => ({ probeName, cell: matrix.byProbe.get(probeId)?.find((c) => c.rank === 1) }))
// 每个 来源 Agent 的最佳线路
const winners = matrix.dialers.map(([dialerId, dialerName]) => ({ dialerName, cell: matrix.byDialer.get(dialerId)?.find((c) => c.rank === 1) }))
return (
<main className="page">
<section className="hero">
<h1>线路对比</h1>
<p className="sub">同一个指标,在每个 Probe 看出去,哪个 Agent 的线路更好。</p>
<p className="sub">从每个来源 Agent 出发,对比各目标 Agent 的线路质量。</p>
<div className="toolbar">
<IpVersionPicker value={ipVersion} onChange={(v) => update({ ip_version: v, lines: null })} />
<Segmented options={PRESETS.map((p) => ({ value: p.value, label: p.label }))} value={range} onChange={(v) => update({ range: v })} />
@@ -154,8 +154,8 @@ export function Compare() {
<div className="winners">
{winners.map((w) =>
w.cell ? (
<div key={w.probeName} className="card winner">
<span className="faint">从 {w.probeName} 看出去最好</span>
<div key={w.dialerName} className="card winner">
<span className="faint">从 {w.dialerName} 看出去最好</span>
<span className="winner-name">
<Star size={16} /> {w.cell.line.name}
</span>
@@ -170,8 +170,8 @@ export function Compare() {
<table className="table matrix">
<thead>
<tr>
<th>Agent / Probe</th>
{matrix.probes.map(([id, name]) => (
<th>来源 \ 目标</th>
{matrix.agents.map(([id, name]) => (
<th key={id} className="r">
{name}
</th>
@@ -179,16 +179,16 @@ export function Compare() {
</tr>
</thead>
<tbody>
{matrix.agents.map(([agentId, agentName]) => (
<tr key={agentId}>
<td style={{ fontWeight: 600 }}>{agentName}</td>
{matrix.probes.map(([probeId]) => {
const cell = matrix.byProbe.get(probeId)?.find((c) => c.line.agent_id === agentId)
if (!cell) return <td key={probeId} className="r faint">—</td>
{matrix.dialers.map(([dialerId, dialerName]) => (
<tr key={dialerId}>
<td style={{ fontWeight: 600 }}>{dialerName}</td>
{matrix.agents.map(([agentId]) => {
const cell = matrix.byDialer.get(dialerId)?.find((c) => c.line.agent_id === agentId)
if (!cell) return <td key={agentId} className="r faint">—</td>
const on = selected.includes(cell.line.id)
const tone = cell.rank === 1 ? 'ok' : cell.gap !== null && cell.gap > 50 ? 'bad' : cell.gap !== null && cell.gap > 15 ? 'warn' : ''
return (
<td key={probeId} className="r">
<td key={agentId} className="r">
<button type="button" className={`cell ${on ? 'on' : ''}`} onClick={() => toggle(cell.line.id)} title={on ? '从图里移除' : '加到下面的图里'}>
{cell.rank === 1 ? <Star size={13} /> : null}
<Chip tone={tone}>{fmt(metric, cell.value)}</Chip>
@@ -202,7 +202,7 @@ export function Compare() {
</tbody>
</table>
<p className="faint" style={{ padding: '4px 12px 8px' }}>
★ 是这一列(同一个 Probe)里最好的;点格子把线路加到下面的图里,最多 {MAX_LINES} 条。
★ 是这一行(同一个来源 Agent)里最好的;点格子把线路加到下面的图里,最多 {MAX_LINES} 条。
</p>
</div>
+1 -1
View File
@@ -57,7 +57,7 @@ export function Host() {
{info.os_name ? <span className="plat"><OsIcon os={info.os} name={info.os_name} />{[info.os_name, info.arch].filter(Boolean).join(' / ')}</span> : null}
</span>
) : null}
{node ? <><Chip>{node.kind === 'agent' ? 'Agent' : 'Probe'}</Chip><Dot tone={node.online ? 'ok' : 'bad'} /><span className="muted">{node.online ? (node.stale ? '状态数据暂未更新' : '在线') : '离线'}</span></> : <span className="muted">节点信息加载中…</span>}
{node ? <><Chip>Agent</Chip><Dot tone={node.online ? 'ok' : 'bad'} /><span className="muted">{node.online ? (node.stale ? '状态数据暂未更新' : '在线') : '离线'}</span></> : <span className="muted">节点信息加载中…</span>}
{node?.last_seen_at && !node.online ? <span className="faint">最后在线 {fmtAgo(node.last_seen_at)}</span> : null}
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
</div>
+9 -9
View File
@@ -213,8 +213,8 @@ export function Line() {
<section className="hero line-header-surface">
<div className="line-title-row">
<h1>
{line.data?.name ?? '线路'}
{line.data ? <span className="muted line-peer-name"> → {line.data.probe_name}</span> : null}
{line.data?.dialer_name ?? '线路'}
{line.data ? <span className="muted line-peer-name"> → {line.data.name}</span> : null}
</h1>
<div className="row line-version-switch">
{IP_VERSIONS.map((v) => {
@@ -231,15 +231,15 @@ export function Line() {
</div>
{line.data ? (
<div className="row wrap line-route-meta">
<Dot tone={line.data.online && line.data.probe_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
<span className="line-route-status">{line.data.online ? (line.data.probe_connected ? '在线' : '测量未连接') : '离线'}</span>
<Dot tone={line.data.online && line.data.dialer_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
<span className="line-route-status">{line.data.online ? (line.data.dialer_connected ? '在线' : '测量未连接') : '离线'}</span>
<span className="row" style={{ gap: 6 }}>
<span className="faint">Agent</span>
<span className="faint">目标 Agent</span>
<NodeTag info={infos.get(line.data.agent_id)} />
</span>
<span className="row" style={{ gap: 6 }}>
<span className="faint">Probe</span>
<NodeTag info={infos.get(line.data.probe_id)} />
<span className="faint">来源 Agent</span>
<NodeTag info={infos.get(line.data.dialer_id)} />
</span>
{line.data.labels.map((x) => (
<Chip key={x}>{x}</Chip>
@@ -324,7 +324,7 @@ export function Line() {
<div className="line-jitter-pair"><span>↑ <b>{fmtMs(m['jitter.up.p95'])}</b></span><span>↓ <b>{fmtMs(m['jitter.down.p95'])}</b></span></div>
</div>
</div>
<div className="line-session-bar" title="会话计数从 Probe 本次启动开始;传输疑似丢包率不等同于 Datagram 应用丢包率,连接迁移成功率暂不采集。">
<div className="line-session-bar" title="会话计数从 来源 Agent 本次启动开始;传输疑似丢包率不等同于 Datagram 应用丢包率,连接迁移成功率暂不采集。">
<strong className="line-session-title">QUIC 会话与传输</strong>
{line.data?.quic_health ? (() => {
const h = line.data.quic_health!
@@ -338,7 +338,7 @@ export function Line() {
<span>路径 MTU <b>{fmtNum(h.path_mtu)} B</b></span>
</div>
</>
})() : <span className="line-session-empty">Probe 尚未上报会话数据</span>}
})() : <span className="line-session-empty">来源 Agent 尚未上报会话数据</span>}
</div>
</> : null}
{metricGroup !== 'quic' ? <div className="tiles">
+28 -28
View File
@@ -16,7 +16,7 @@ const METRICS = ['rtt.p50', 'rtt.p95', 'rtt.p99', 'loss.total', 'loss.up', 'loss
type SortKey = 'name' | 'rtt' | 'loss'
/** 线路按 Probe 的运营商分三网:电信、联通、移动;别的(广电、教育网、海外……)归到「其他」。 */
/** 线路按 来源 Agent 的运营商分三网:电信、联通、移动;别的(广电、教育网、海外……)归到「其他」。 */
const CARRIERS = [
{ key: 'telecom', name: '电信' },
{ key: 'unicom', name: '联通' },
@@ -31,7 +31,6 @@ const average = (values: Array<number | null | undefined>) => {
return v.length === 0 ? null : v.reduce((a, b) => a + b, 0) / v.length
}
const KIND_NAME = { agent: 'Agent', probe: 'Probe' } as const
function Meter({ label, value, detail }: { label: string; value: number | null; detail?: string }) {
const level = value === null ? '' : value >= 90 ? 'bad' : value >= 75 ? 'warn' : 'ok'
@@ -47,7 +46,7 @@ function Meter({ label, value, detail }: { label: string; value: number | null;
)
}
/** 一个节点(Agent 或 Probe)的卡片:运营商、系统、版本,和主机资源占用的汇总。线路单独放在下面的面板里。 */
/** 一个节点(Agent)的卡片:运营商、系统、版本,和主机资源占用的汇总。线路单独放在下面的面板里。 */
function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; infos: Map<string, NodeInfoDto> }) {
const h = node.host
const stale = !node.online || node.stale
@@ -58,7 +57,7 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
{info?.country ? <Flag code={info.country} /> : <Dot tone={node.online ? 'ok' : 'bad'} />}
<span className="agent-name">{node.name}</span>
<span className="faint kind-tag">{KIND_NAME[node.kind]}</span>
<span className="faint kind-tag">Agent</span>
<Chip tone={node.online ? 'ok' : 'bad'}>{node.online ? '在线' : '离线'}</Chip>
{!node.online && node.last_seen_at ? <span className="faint">{fmtAgo(node.last_seen_at)}</span> : null}
</div>
@@ -130,41 +129,43 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
</div>
</>
) : (
<div className="faint">这台 {KIND_NAME[node.kind]} 没有上报主机状态</div>
<div className="faint">这台 Agent 没有上报主机状态</div>
)}
</div>
</Link>
)
}
/** 一条线路(Probe → Agent):近一段时间的 QUIC Datagram 上下行丢包、RTT 和 TCP 重传。 */
/** 一条线路(拨号方 → 监听方):近一段时间的 QUIC Datagram 上下行丢包、RTT 和 TCP 重传。 */
function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
const probeInfo = infos.get(l.probe_id)
const dialerInfo = infos.get(l.dialer_id)
const agentInfo = infos.get(l.agent_id)
const rangeLabel = OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range
const connectionIssue = !l.online
? 'Agent 离线'
: l.quic_health?.state === 'retry_wait'
? 'QUIC 拨号失败'
: !l.probe_connected
? '测量通道未建立'
: l.quic_health && !l.quic_health.connected
? 'QUIC 正在连接'
: null
: l.quic_health?.control_state === 'backoff'
? '与 Agent 失联'
: l.quic_health?.state === 'retry_wait'
? 'QUIC 拨号失败'
: !l.dialer_connected
? '测量通道未建立'
: l.quic_health && !l.quic_health.connected
? 'QUIC 正在连接'
: null
return (
<Link to={`/line/${encodeURIComponent(l.id)}`} className="line-link line-card">
<div className="line-top">
<span className="line-name" title={[l.probe_name, probeInfo?.tag || probeInfo?.version].filter(Boolean).join(' · ')}>
{probeInfo?.country ? <Flag code={probeInfo.country} /> : null}
<span className="name">{l.probe_name}</span>
{probeInfo?.isp_key ? <IspIcon ispKey={probeInfo.isp_key} name={probeInfo.isp_name} /> : null}
<span className="line-name" title={[l.dialer_name, dialerInfo?.tag || dialerInfo?.version].filter(Boolean).join(' · ')}>
{dialerInfo?.country ? <Flag code={dialerInfo.country} /> : null}
<span className="name">{l.dialer_name}</span>
{dialerInfo?.isp_key ? <IspIcon ispKey={dialerInfo.isp_key} name={dialerInfo.isp_name} /> : null}
<span className="arrow">→</span>
{agentInfo?.country ? <Flag code={agentInfo.country} /> : null}
<span className="name">{l.name}</span>
</span>
<span
className={connectionIssue ? 'line-range connection-issue' : 'faint line-range'}
title={l.quic_health?.state === 'retry_wait' ? `Probe 正在等待退避重试${l.quic_health.retry_in_ms ? `(约 ${Math.ceil(l.quic_health.retry_in_ms / 1000)} 秒)` : ''}。` : !l.probe_connected && l.online ? 'Agent 在线,但 Probe 尚未建立测量会话。' : undefined}
title={l.quic_health?.state === 'retry_wait' ? `来源 Agent 正在等待退避重试${l.quic_health.retry_in_ms ? `(约 ${Math.ceil(l.quic_health.retry_in_ms / 1000)} 秒)` : ''}。` : !l.dialer_connected && l.online ? 'Agent 在线,但 来源 Agent 尚未建立测量会话。' : undefined}
>
{connectionIssue ? `${connectionIssue} · ${rangeLabel}` : rangeLabel}
</span>
@@ -196,13 +197,12 @@ export function Overview() {
)
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: METRICS, ip_version: ipVersion }), [range, ipVersion], 10_000)
// 节点卡片:先 Agent 后 Probe,各自在线的在前、再按名字;Agent 的标签来自它的线路
// 节点卡片:在线节点在前、再按名字;Agent 的标签来自它的线路
const nodes = useMemo(() => {
const labels = new Map<string, string[]>()
for (const l of data?.lines ?? []) labels.set(l.agent_id, l.labels)
const rank = (n: HostItem) => (n.kind === 'agent' ? 0 : 1)
return [...hosts.values()]
.sort((a, b) => rank(a) - rank(b) || Number(b.online) - Number(a.online) || a.name.localeCompare(b.name))
.sort((a, b) => Number(b.online) - Number(a.online) || a.name.localeCompare(b.name))
.map((node) => ({ node, labels: labels.get(node.id) ?? [] }))
}, [hosts, data])
// 线路面板:按 IP 版本过滤,再排序
@@ -211,7 +211,7 @@ export function Overview() {
const key = sort === 'rtt' ? 'rtt.p50' : 'loss.total'
return picked.sort((a, b) => {
if (a.online !== b.online) return a.online ? -1 : 1
const byName = () => a.name.localeCompare(b.name) || a.probe_name.localeCompare(b.probe_name)
const byName = () => a.name.localeCompare(b.name) || a.dialer_name.localeCompare(b.dialer_name)
if (sort === 'name') return byName()
const x = a.summary.metrics[key]
const y = b.summary.metrics[key]
@@ -221,15 +221,15 @@ export function Overview() {
return x - y
})
}, [data, sort, ipVersion])
// 按 Probe 的运营商分组(固定顺序,没有线路的组不显示)
// 按 来源 Agent 的运营商分组(固定顺序,没有线路的组不显示)
const groups = useMemo(
() => CARRIERS.map((c) => ({ ...c, lines: lines.filter((l) => carrierOf(infos.get(l.probe_id)) === c.key) })).filter((g) => g.lines.length > 0),
() => CARRIERS.map((c) => ({ ...c, lines: lines.filter((l) => carrierOf(infos.get(l.dialer_id)) === c.key) })).filter((g) => g.lines.length > 0),
[lines, infos],
)
const shown = groups.filter((g) => !carrier || g.key === carrier)
const count = (kind: 'agent' | 'probe') => {
const all = nodes.filter((n) => n.node.kind === kind)
const count = () => {
const all = nodes
return `${all.filter((n) => n.node.online).length} / ${all.length}`
}
@@ -238,7 +238,7 @@ export function Overview() {
<section className="hero">
<h1>总览</h1>
<p className="sub">
{hosts.size > 0 || data ? `Agent ${count('agent')} 在线 · Probe ${count('probe')} 在线` : '加载中…'}
{hosts.size > 0 || data ? `Agent ${count()} 在线` : '加载中…'}
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
</p>
</section>
+128 -405
View File
@@ -164,7 +164,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Latest host resource usage of every visible agent and probe (no hardware model, no addresses) */
/** Latest host resource usage of every visible node (no hardware model, no addresses) */
get: operations["hosts_list"];
put?: never;
post?: never;
@@ -181,7 +181,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Host resource usage history of one agent or probe (columnar, averaged per step) */
/** Host resource usage history of one node (columnar, averaged per step) */
get: operations["hosts_history"];
put?: never;
post?: never;
@@ -198,7 +198,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Display info of the visible agents and probes: system, architecture, country and ISP (never an address) */
/** Display info of the visible nodes: system, architecture, country and ISP (never an address) */
get: operations["nodes_list"];
put?: never;
post?: never;
@@ -472,7 +472,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** All agents including pending and disabled ones */
/** All nodes (agents) including pending and disabled ones; each says whether it listens, which dialers are connected to it and which listeners it dials */
get: operations["agents_list"];
put?: never;
post?: never;
@@ -497,7 +497,7 @@ export interface paths {
delete: operations["agents_delete"];
options?: never;
head?: never;
/** Update name, labels, public host or visibility (merge-patch, If-Match supported) */
/** Update name, labels, public host, ISP or visibility (merge-patch, If-Match supported) */
patch: operations["agents_update"];
trace?: never;
};
@@ -508,7 +508,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Recent Agent or Probe runtime logs */
/** Recent runtime logs of a node */
get: operations["node_runtime_logs"];
put?: never;
post?: never;
@@ -596,7 +596,7 @@ export interface paths {
/** Join tokens */
get: operations["join_tokens_list"];
put?: never;
/** Create a join token for an agent or a probe (role); the plaintext is returned only once */
/** Create a join token for an agent; the plaintext is returned only once */
post: operations["join_tokens_create"];
delete?: never;
options?: never;
@@ -621,43 +621,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/probes": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** All probes, with the agents each one is connected to */
get: operations["probes_list"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/probes/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** One probe */
get: operations["probes_get"];
put?: never;
post?: never;
/** Delete a probe and its lines; stored data expires with the retention policy */
delete: operations["probes_delete"];
options?: never;
head?: never;
/** Rename a probe */
patch: operations["probes_update"];
trace?: never;
};
"/api/admin/probes/{id}/revoke": {
"/api/admin/agents/{id}/dialers/{dialer_id}/disconnect": {
parameters: {
query?: never;
header?: never;
@@ -666,25 +630,8 @@ export interface paths {
};
get?: never;
put?: never;
/** Revoke a probe: every agent drops its session and refuses it; history stays until it expires */
post: operations["probes_revoke"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/agents/{id}/probes/{probe_id}/disconnect": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Make an agent drop one probe's session (the probe reconnects unless it is revoked) */
post: operations["agents_probe_disconnect"];
/** Make a listening node drop one dialer's session (the dialer reconnects unless it is disabled) */
post: operations["agents_dialer_disconnect"];
delete?: never;
options?: never;
head?: never;
@@ -978,7 +925,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Version, tag, commit, build time and update state of the backend and every agent and probe */
/** Version, tag, commit, build time and update state of the backend and every node */
get: operations["build_info_get"];
put?: never;
post?: never;
@@ -1014,7 +961,7 @@ export interface paths {
};
get?: never;
put?: never;
/** Ask an online agent or probe to install the release it has been offered */
/** Ask an online node to install the release it has been offered */
post: operations["nodes_update"];
delete?: never;
options?: never;
@@ -1083,7 +1030,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Every probe/agent pair: whether it is linked, the manual override and whether it is connected */
/** Every possible dialer/listener pair: whether it is linked, the manual override and whether it is connected; plus the nodes and which of them listen */
get: operations["links_list"];
put?: never;
post?: never;
@@ -1093,7 +1040,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/links/{probe_id}/{agent_id}": {
"/api/admin/links/{dialer_id}/{listener_id}": {
parameters: {
query?: never;
header?: never;
@@ -1101,7 +1048,7 @@ export interface paths {
cookie?: never;
};
get?: never;
/** Force a link on or off, or give it back to the automatic setting (enabled: null) */
/** Connect a node to a listening node (enabled: true), force the link off (false), or give it back to the automatic setting (null) */
put: operations["links_set"];
post?: never;
delete?: never;
@@ -1110,7 +1057,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/links/{probe_id}/{agent_id}/retry": {
"/api/admin/links/{dialer_id}/{listener_id}/retry": {
parameters: {
query?: never;
header?: never;
@@ -1119,7 +1066,7 @@ export interface paths {
};
get?: never;
put?: never;
/** Enable and immediately retry the Probe-to-Agent connection */
/** Enable and immediately retry the dialer-to-listener connection */
post: operations["links_retry"];
delete?: never;
options?: never;
@@ -1168,24 +1115,8 @@ export interface paths {
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
/** The static native OpenWrt installer and updater (Agent) */
get: operations["install_openwrt"];
put?: never;
post?: never;
delete?: never;
@@ -1218,7 +1149,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** The signed release manifest of the component an install code is for (exact signed bytes) */
/** The signed release manifest an install code may fetch (exact signed bytes) */
get: operations["install_manifest"];
put?: never;
post?: never;
@@ -1252,7 +1183,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** The static compose file of agent, probe or backend (agent.yaml, probe.yaml, backend.yaml; same bytes for everyone) */
/** The static compose file of the agent or the backend (agent.yaml, backend.yaml; same bytes for everyone) */
get: operations["install_compose"];
put?: never;
post?: never;
@@ -1269,7 +1200,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** The static Podman Quadlet unit of agent or probe (agent.container, probe.container; same bytes for everyone) */
/** The static Podman Quadlet unit of the agent (agent.container; same bytes for everyone) */
get: operations["install_quadlet"];
put?: never;
post?: never;
@@ -1360,14 +1291,15 @@ export interface components {
name: string;
status: string;
online: boolean;
probe_connected: boolean;
probes: components["schemas"]["ConnectedProbeDto"][];
listens: boolean;
dialer_connected: boolean;
dialers: components["schemas"]["ConnectedDialerDto"][];
outgoing: components["schemas"]["OutgoingConnectionDto"][];
host: null | components["schemas"]["HostDto"];
public_host: string;
/** Format: int32 */
probe_tcp_port: number | string;
probe_udp_ports: (number | string)[];
probe_quic_ports: (number | string)[];
tcp_port: number | string;
quic_ports: (number | string)[];
labels: string[];
public_visible: boolean;
kinds: string[];
@@ -1411,6 +1343,7 @@ export interface components {
ipv6_host?: null | string;
public_host_auto?: null | boolean;
ipv6_host_auto?: null | boolean;
isp?: null | string;
};
ApiTokenDto: {
id: string;
@@ -1522,8 +1455,8 @@ export interface components {
/** Format: int64 */
seq: number | string;
};
ConnectedProbeDto: {
probe_id: string;
ConnectedDialerDto: {
dialer_id: string;
version: null | string;
remote: null | string;
/** Format: date-time */
@@ -1680,7 +1613,6 @@ export interface components {
};
HostItemDto: {
id: string;
kind: string;
name: string;
online: boolean;
stale: boolean;
@@ -1734,8 +1666,6 @@ export interface components {
revoked_at: unknown;
/** Format: date-time */
created_at: unknown;
/** @default agent */
role: string;
created_by?: null | string;
};
JsonElement: unknown;
@@ -1751,12 +1681,12 @@ export interface components {
LineDto: {
id: string;
agent_id: string;
probe_id: string;
probe_name: string;
dialer_id: string;
dialer_name: string;
name: string;
labels: string[];
online: boolean;
probe_connected: boolean;
dialer_connected: boolean;
/** Format: date-time */
data_since: unknown;
kinds: string[];
@@ -1768,16 +1698,16 @@ export interface components {
quic_health?: null | components["schemas"]["QuicLineHealthDto"];
};
LinkDto: {
probe_id: string;
probe_name: string;
agent_id: string;
agent_name: string;
dialer_id: string;
dialer_name: string;
listener_id: string;
listener_name: string;
linked: boolean;
override: null | boolean;
connected: boolean;
probe_runtime_status: components["schemas"]["NodeRuntimeStatus"];
agent_runtime_status: components["schemas"]["NodeRuntimeStatus"];
/** @default connecting */
dialer_runtime_status: components["schemas"]["NodeRuntimeStatus"];
listener_runtime_status: components["schemas"]["NodeRuntimeStatus"];
/** @default control_connecting */
connection_state: string;
connection_reason?: null | string;
last_error?: null | string;
@@ -1791,8 +1721,16 @@ export interface components {
};
LinkMatrixDto: {
auto_link: boolean;
nodes: components["schemas"]["LinkNodeDto"][];
items: components["schemas"]["LinkDto"][];
};
LinkNodeDto: {
id: string;
name: string;
listens: boolean;
online: boolean;
runtime_status: components["schemas"]["NodeRuntimeStatus"];
};
LinkRetryDto: {
queued: boolean;
link: components["schemas"]["LinkDto"];
@@ -1852,7 +1790,6 @@ export interface components {
};
NodeBuildDto: {
id: string;
kind: string;
os: null | string;
os_name: null | string;
arch: null | string;
@@ -1865,7 +1802,6 @@ export interface components {
};
NodeInfoDto: {
id: string;
kind: string;
os: null | string;
os_name: null | string;
arch: null | string;
@@ -1899,7 +1835,7 @@ export interface components {
dropped: number | string;
};
/** @enum {unknown} */
NodeRuntimeStatus: "initializing" | "running" | "probe_paused" | "maintenance" | "stopping" | "offline" | "updating";
NodeRuntimeStatus: "initializing" | "running" | "idle" | "maintenance" | "stopping" | "offline" | "updating";
NodeUpdateDto: {
state: string;
current_digest: null | string;
@@ -1909,6 +1845,17 @@ export interface components {
/** Format: date-time */
at: unknown;
};
OutgoingConnectionDto: {
listener_id: string;
listener_name: string;
listener_online: boolean;
version: null | string;
remote: null | string;
/** Format: date-time */
connected_since: unknown;
/** @default ipv4 */
ip_version: string;
};
OverviewDto: {
range: string;
/** Format: date-time */
@@ -1923,12 +1870,12 @@ export interface components {
OverviewLineDto: {
id: string;
agent_id: string;
probe_id: string;
probe_name: string;
dialer_id: string;
dialer_name: string;
name: string;
labels: string[];
online: boolean;
probe_connected: boolean;
dialer_connected: boolean;
/** Format: int32 */
endpoint_changes: number | string;
summary: components["schemas"]["SummaryDto"];
@@ -1993,10 +1940,6 @@ export interface components {
items: components["schemas"]["PresetDto"][];
next_cursor: null | string;
};
PageDtoOfProbeDto: {
items: components["schemas"]["ProbeDto"][];
next_cursor: null | string;
};
PageDtoOfRawChunkMetaDto: {
items: components["schemas"]["RawChunkMetaDto"][];
next_cursor: null | string;
@@ -2066,10 +2009,6 @@ export interface components {
timeout_ms: number | string;
allow_credentials?: null | string[];
};
PatchProbeRequest: {
name: null | string;
isp?: null | string;
};
PatchTaskRequest: {
params: null | Record<string, never>;
schedule: null | string;
@@ -2093,31 +2032,6 @@ export interface components {
label: string;
metrics: string[];
};
ProbeConnectionDto: {
agent_id: string;
agent_name: string;
agent_online: boolean;
version: null | string;
remote: null | string;
/** Format: date-time */
connected_since: unknown;
/** @default ipv4 */
ip_version: string;
};
ProbeDto: {
id: string;
name: string;
/** Format: date-time */
created_at: unknown;
/** Format: date-time */
revoked_at: unknown;
revoked: boolean;
online: boolean;
connections: components["schemas"]["ProbeConnectionDto"][];
runtime_status: components["schemas"]["NodeRuntimeStatus"];
ipv6?: null | components["schemas"]["Ipv6DiagnosticDto"];
added_by?: null | string;
};
ProblemDto: {
type: string;
title: string;
@@ -2187,6 +2101,8 @@ export interface components {
retry_attempt: number | string;
/** Format: int64 */
retry_in_ms?: null | number | string;
/** @default dialing */
control_state: string;
};
RawChunkMetaDto: {
chunk_id: string;
@@ -2260,7 +2176,7 @@ export interface components {
SeriesDto: {
line_id: string;
agent_id: string;
probe_id: string;
dialer_id: string;
/** Format: date-time */
from: unknown;
/** Format: date-time */
@@ -2346,7 +2262,7 @@ export interface components {
TestDto: {
id: string;
agent_id: string;
probe_id: string;
dialer_id: string;
plan: string;
phases: string[];
status: string;
@@ -4307,260 +4223,13 @@ export interface operations {
};
};
};
probes_list: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PageDtoOfProbeDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
probes_get: {
agents_dialer_disconnect: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProbeDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
probes_delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
probes_update: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PatchProbeRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProbeDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
probes_revoke: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProbeDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
agents_probe_disconnect: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
probe_id: string;
dialer_id: string;
};
cookie?: never;
};
@@ -6227,8 +5896,8 @@ export interface operations {
query?: never;
header?: never;
path: {
probe_id: string;
agent_id: string;
dialer_id: string;
listener_id: string;
};
cookie?: never;
};
@@ -6283,6 +5952,24 @@ export interface operations {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
links_retry: {
@@ -6290,8 +5977,8 @@ export interface operations {
query?: never;
header?: never;
path: {
probe_id: string;
agent_id: string;
dialer_id: string;
listener_id: string;
};
cookie?: never;
};
@@ -6333,6 +6020,24 @@ export interface operations {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
health_get: {
@@ -6400,6 +6105,24 @@ export interface operations {
};
};
};
install_openwrt: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
install_sh: {
parameters: {
query?: never;
+2 -3
View File
@@ -6,13 +6,12 @@ type S = components['schemas']
type NarrowValue<V> = unknown extends V ? string : number extends V ? Exclude<V, string> : V extends readonly (infer U)[] ? NarrowValue<U>[] : V extends object ? Narrow<V> : V
type Narrow<T> = { [K in keyof T]: NarrowValue<T[K]> }
export type HostDto = Narrow<S['HostDto']>
export type HostItemDto = Narrow<S['HostItemDto']>
export type AgentDto = Narrow<S['AgentDto']>
export type NodeRuntimeLogsDto = Narrow<S['NodeRuntimeLogsDto']>
export type NodeRuntimeStatus = S['NodeRuntimeStatus']
export type AgentIpv6Dto = Narrow<S['AgentIpv6Dto']>
export type ConnectedProbeDto = Narrow<S['ConnectedProbeDto']>
export type ProbeDto = Narrow<S['ProbeDto']>
export type ProbeConnectionDto = Narrow<S['ProbeConnectionDto']>
export type LineDto = Narrow<S['LineDto']>
export type NodeInfoDto = Narrow<S['NodeInfoDto']>
export type AboutDto = Narrow<S['AboutDto']>
+7 -15
View File
@@ -12,9 +12,7 @@ const CODES: Record<string, string> = {
site_private: '站点未公开:请管理员登录,或在管理端把站点设为公开',
not_found: '没有找到',
agent_not_found: '没有找到这个 Agent',
probe_not_found: '没有找到这个 Probe',
probe_revoked: '这个 Probe 已经被吊销,不能再换密钥,请新建一个 Probe',
probe_required: '有多个 Probe,请指定具体的线路',
dialer_required: '请指定拨号 Agent 或具体线路',
task_not_found: '没有找到这个任务',
test_not_found: '没有找到这次测试',
chunk_not_found: '没有找到这个原始数据块',
@@ -22,7 +20,10 @@ const CODES: Record<string, string> = {
unknown_kind: '不认识这种采集方法',
unknown_plan: '不认识这个测试方案',
name_conflict: '名称已经被使用,换一个',
probe_offline: '这个 Probe 现在没有连着这个 Agent',
dialer_offline: '拨号 Agent 当前没有接入这个 Agent',
node_revoked: '节点已被吊销',
not_listening: '目标 Agent 没有开启监听',
self_link: 'Agent 不能连接自身',
test_busy: '这条线路上已经有测试在运行',
agent_not_active: '这个 Agent 还没有启用',
agent_offline: 'Agent 不在线',
@@ -107,20 +108,11 @@ const MESSAGES: [RegExp, (m: RegExpMatchArray) => string][] = [
[/^once tasks are created through the tests API$/, () => '一次性测试请在「按需测试」里发起'],
[/^unknown kind (.+)$/, (m) => `不认识采集方法 ${m[1]}`],
[/^unknown plan (.+)$/, (m) => `不认识测试方案 ${m[1]}`],
[/^a probe named (.+) already exists$/, (m) => `已经有一个叫 ${m[1]} 的 Probe`],
[/^probe (\S+) has no session with this agent$/, () => '这个 Probe 现在没有连着这个 Agent'],
[/^probe (\S+) is not connected to this agent$/, () => '这个 Probe 现在没有连着这个 Agent'],
[/^this probe has no session with this agent$/, () => '这个 Probe 现在没有连着这个 Agent'],
[/^the probe has no session with this agent$/, () => '这个 Probe 现在没有连着这个 Agent'],
[/^another test is already running on this line$/, () => '这条线路上已经有测试在运行'],
[/^agent is offline$/, () => 'Agent 不在线'],
[/^agent is not active$/, () => 'Agent 还没有启用'],
[/^agent not found$/, () => '没有找到这个 Agent'],
[/^probe not found$/, () => '没有找到这个 Probe'],
[/^line not found$/, () => '没有找到这条线路'],
[/^there is no active probe$/, () => '现在没有可用的 Probe'],
[/^several probes exist.*$/, () => '有多个 Probe,请指定具体的线路'],
[/^a revoked probe cannot get a new key.*$/, () => '已吊销的 Probe 不能换密钥,请新建一个 Probe'],
[/^this agent does not support (\S+), which the (\S+) plan needs$/, (m) => `这个 Agent 不支持 ${m[1]},而 ${m[2]} 方案需要它`],
[/^the agent did not accept the test$/, () => 'Agent 没有接受这次测试'],
[/^a valid X-CSRF-Token header is required$/, () => '登录状态已经过期,请刷新页面重新登录'],
@@ -143,8 +135,8 @@ export function translateCode(code: string): string | undefined {
const SETTINGS: Record<string, string> = {
'site.public': '匿名访客能不能读展示页的数据(关闭时只有登录的管理员能看)',
'fleet.auto_link': '所有 Probe 自动连接所有 Agent(关闭后只连在「Probe → 线路」里手动选中的)',
'site.show_build': '展示页底部和节点卡片上显示后端、前端、Agent、Probe 的版本、tag 和构建时间',
'fleet.auto_link': 'Agent 默认相互探测;关闭后只连接在线路矩阵中手动开启的方向',
'site.show_build': '展示页底部和节点卡片上显示后端、前端、Agent 的版本、tag 和构建时间',
'retention.raw_days': '持续任务的原始数据块保留多少天',
'retention.test_raw_days': '按需测试的原始数据块保留多少天',
'retention.metric_10s_hours': '10 秒精度的指标保留多少小时',
+2 -2
View File
@@ -9,7 +9,7 @@ import telecomIcon from './assets/isp/telecom.svg?url'
import unicomIcon from './assets/isp/unicom.svg?url'
/**
* 节点(Agent、Probe)的展示信息:国旗、运营商、系统和架构。
* 节点(Agent)的展示信息:国旗、运营商、系统和架构。
* 后端只给国家代码、运营商名、系统名和架构——没有 IP,所以这里也画不出 IP。
*/
@@ -107,7 +107,7 @@ export function NodeTag({ info, geoOnly }: TagProps) {
const RUNTIME_STATUS: Record<NodeRuntimeStatus, { label: string; tone: 'ok' | 'warn' | 'bad' | 'accent' | '' }> = {
initializing: { label: '初始化中', tone: 'warn' },
running: { label: '运行中', tone: 'ok' },
probe_paused: { label: '暂停探测', tone: 'warn' },
idle: { label: '空闲', tone: 'warn' },
maintenance: { label: '维护中', tone: 'warn' },
stopping: { label: '停止中', tone: 'warn' },
offline: { label: '失联', tone: 'bad' },
+1 -1
View File
@@ -508,7 +508,7 @@ export function SourceButton({ variant = 'icon' }: { variant?: 'icon' | 'status'
)
}
/** 下载文本文件(probe.json 只给一次)。 */
/** 下载文本文件。 */
export function downloadText(filename: string, text: string) {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }))
const a = document.createElement('a')