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:
1 parent
016dbc2bbf
commit
15f0023614
32 files changed
+937
-1093
No files matched your search
+4
-2
@@ -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 详情中的拨出连接继续使用紧凑表格。
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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': ['吊销或删除节点', '破坏性操作'],
|
||||
|
||||
@@ -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 && 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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -309,7 +309,7 @@ export function Tasks() {
|
||||
tasks.reload()
|
||||
}}
|
||||
>
|
||||
删除之后,Agent 和 Probe 会在下一次同步时停止这个任务。
|
||||
删除之后,Agent 会在下一次同步时停止这个任务。
|
||||
</Confirm>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 秒精度的指标保留多少小时',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user