From 758b6b15664c682f96a454b8c15f2069a797b9ff Mon Sep 17 00:00:00 2001 From: justme Date: Thu, 22 Oct 2026 05:59:56 +0800 Subject: [PATCH] web: merge the per-agent lines into one table on the agent page --- web/admin/src/pages/AgentDetail.tsx | 109 +++++++------------------- web/admin/src/pages/AgentLines.tsx | 115 ++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 83 deletions(-) create mode 100644 web/admin/src/pages/AgentLines.tsx diff --git a/web/admin/src/pages/AgentDetail.tsx b/web/admin/src/pages/AgentDetail.tsx index 1ba7902..2fa3679 100644 --- a/web/admin/src/pages/AgentDetail.tsx +++ b/web/admin/src/pages/AgentDetail.tsx @@ -1,4 +1,4 @@ -import { ChevronLeft, FlaskConical, Pencil, Unplug } from 'lucide-react' +import { ChevronLeft, Pencil } from 'lucide-react' import { useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { api } from '@shared/api/client' @@ -8,7 +8,7 @@ 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 { AgentLines } from './AgentLines' import { RuntimeLogs } from '../RuntimeLogs' function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: () => void; onSaved: () => void }) { @@ -111,14 +111,12 @@ export function AgentDetail() { const { id = '' } = useParams() const navigate = useNavigate() const agent = useQuery(() => api.get(`/api/admin/agents/${id}`), [id], 5000) - const dialers = useQuery(() => api.get>('/api/admin/agents'), []) const events = useQuery(() => api.get>('/api/admin/events', { agent_id: id, limit: 20 }), [id], 15_000) const [editing, setEditing] = useState(false) const [pending, setPending] = useState() const { run } = useAction() const a = agent.data - const dialerName = (dialerId: string) => dialers.data?.items.find((p) => p.id === dialerId)?.name ?? dialerId const act = async (action: Action) => { if (action === 'delete') { @@ -177,59 +175,15 @@ export function AgentDetail() { {a ? (
- -
-
接入 Agent({a.dialers.length})
- {a.dialers.length === 0 ? ( - 现在没有 Agent 接入此节点 - ) : ( - - - - - - - - - - - {a.dialers.map((p) => ( - - - - - - - - ))} - -
来源 Agent版本来源地址连接时间 -
-
{dialerName(p.dialer_id)} {p.ip_version === 'ipv6' ? 'IPv6' : 'IPv4'}
-
{p.dialer_id}
-
{p.version ?? '—'}{p.remote ?? '—'} - {fmtTime(p.connected_since)}({fmtAgo(p.connected_since)}) - - - - - 测试 - - - -
- )} +
+
状态
+
版本{a.agent_version ?? '—'}
+
最后心跳{a.heartbeat ? fmtAgo(a.heartbeat.at) : '—'}
+
任务同步{a.task_version !== a.applied_task_version ? 未同步 : 已同步}
+
监听{a.listens ? `${a.tcp_port}/tcp · ${a.quic_ports.map((p) => `${p}/udp`).join(', ') || '—'}` : '只拨出'}
+
地址{a.public_host || '—'}
+
@@ -245,36 +199,10 @@ export function AgentDetail() {
{a.ipv6_host || (a.ipv6_host_auto ? '未识别到公网 IPv6 地址' : '已手动关闭')} {a.ipv6_host_auto ? '自动' : '手动'}
-
IPv6 监听
-
{a.ipv6_supported ? '已启用' : '未启用,请升级 Agent 并配置双栈监听'}
-
IPv6 诊断
-
{IPV6_STATES[a.ipv6?.state ?? 'unknown'] ?? '状态未知'}
-
检测网络
-
{a.ipv6?.network_scope === 'host' ? '宿主机' : a.ipv6?.network_scope === 'container' ? '容器网络' : '未知'}
-
宿主全局 IPv6
-
{a.ipv6?.host_has_global_address == null ? '无法读取' : a.ipv6.host_has_global_address ? '已获得' : '未获得'}
-
全局 IPv6 地址
-
{a.ipv6?.has_global_address == null ? '未知' : a.ipv6.has_global_address ? '已获得' : '未获得'}
-
IPv6 默认路由
-
{a.ipv6?.has_default_route == null ? '未知' : a.ipv6.has_default_route ? '存在' : '缺失'}
-
监听
-
{a.listens ? '已开启' : '已关闭'}
-
控制端口
-
{a.tcp_port}/tcp
-
数据端口
-
{a.quic_ports.map((p) => `${p}/udp (QUIC)`).join(', ') || '—'}
-
版本
-
{a.agent_version ?? '—'}
展示页可见
{a.public_visible ? '是' : '否'}
注册时间
-
{fmtTime(a.created_at)}
- {a.added_by ? ( - <> -
添加者
-
{a.added_by}
- - ) : null} +
{fmtTime(a.created_at)}{a.added_by ? · {a.added_by} : null}
最后在线
{fmtTime(a.last_seen_at)}
标签
@@ -292,6 +220,21 @@ export function AgentDetail() {
+
+ IPv6 诊断:{IPV6_STATES[a.ipv6?.state ?? 'unknown'] ?? '状态未知'} +
+
IPv6 监听
+
{a.ipv6_supported ? '已启用' : '未启用,请升级 Agent 并配置双栈监听'}
+
检测网络
+
{a.ipv6?.network_scope === 'host' ? '宿主机' : a.ipv6?.network_scope === 'container' ? '容器网络' : '未知'}
+
宿主全局 IPv6
+
{a.ipv6?.host_has_global_address == null ? '无法读取' : a.ipv6.host_has_global_address ? '已获得' : '未获得'}
+
全局 IPv6 地址
+
{a.ipv6?.has_global_address == null ? '未知' : a.ipv6.has_global_address ? '已获得' : '未获得'}
+
IPv6 默认路由
+
{a.ipv6?.has_default_route == null ? '未知' : a.ipv6.has_default_route ? '存在' : '缺失'}
+
+
运行状态
diff --git a/web/admin/src/pages/AgentLines.tsx b/web/admin/src/pages/AgentLines.tsx new file mode 100644 index 0000000..559f4e6 --- /dev/null +++ b/web/admin/src/pages/AgentLines.tsx @@ -0,0 +1,115 @@ +import { FlaskConical, Unplug } from 'lucide-react' +import { Link } from 'react-router-dom' +import { api } from '@shared/api/client' +import type { AgentDto, LinkDto, LinkMatrixDto } from '@shared/api/types' +import { fmtAgo } from '@shared/format' +import { useQuery } from '@shared/hooks' +import { Chip, Empty, ErrorNotice, Spinner, useAction } from '@shared/ui' +import { lineIdOf, useLineQuality } from './lineQuality' + +const STATE_LABELS: Record = { + waiting_report: '等待上报', control_connecting: '控制连接中', control_down: '失联', + task_connecting: 'QUIC 握手中', task_retrying: 'QUIC 重试中', dialer_unavailable: '拨号方不可用', listener_unavailable: '监听方不可用', +} + +/** 这个 Agent 的所有线路:一对节点一条,写明谁拨谁、状态、RTT、丢包,以及这个节点上的连接和操作。 */ +export function AgentLines({ agent, onChanged }: { agent: AgentDto; onChanged: () => void }) { + const matrix = useQuery(() => api.get('/api/admin/links'), [agent.id], 8000) + const quality = useLineQuality() + const { busy, run } = useAction() + const items = (matrix.data?.items ?? []).filter((link) => link.dialer_id === agent.id || link.listener_id === agent.id) + const nodes = new Map((matrix.data?.nodes ?? []).map((node) => [node.id, node])) + + const reload = () => { + matrix.reload() + onChanged() + } + const toggle = async (link: LinkDto) => { + await run(() => api.put(`/api/admin/links/${link.dialer_id}/${link.listener_id}`, { enabled: !link.linked })) + reload() + } + + return ( +
+
+ 线路 + {items.length} 条 · {items.filter((link) => link.connected).length} 条已连接 + {matrix.loading && !matrix.data ? : null} + 在矩阵里看全部线路 +
+ + {matrix.data && items.length === 0 ? 还没有线路:添加并启用其他 Agent 后会自动连线。 : null} + {items.length > 0 ? ( + + + + + + + + + + + + + {items.map((link) => { + const outgoing = link.dialer_id === agent.id + const peerId = outgoing ? link.listener_id : link.dialer_id + const peerName = outgoing ? link.listener_name : link.dialer_name + const q = quality[lineIdOf(link)] + const session = outgoing ? agent.outgoing.find((o) => o.listener_id === peerId) : agent.dialers.find((d) => d.dialer_id === peerId) + const since = session ? ('connected_since' in session ? session.connected_since : undefined) : undefined + const tone = link.connected ? 'ok' : link.linked ? 'warn' : '' + const lossy = (q?.loss ?? 0) >= 1 + return ( + + + + + + + + + + ) + })} + +
对端方向状态RTT丢包连接 +
+ {peerName} + {outgoing ? '本机拨出 →' : '← 对端拨入'} + {!link.linked ? '已关闭' : link.connected ? '已连接' : STATE_LABELS[link.connection_state] ?? '连接中'} + {link.override != null ? 手动 : null} + {q?.rtt != null ? `${q.rtt < 10 ? q.rtt.toFixed(1) : Math.round(q.rtt)} ms` : '—'}{q?.loss != null ? `${q.loss.toFixed(2)}%` : '—'} + {session && 'remote' in session && session.remote ? {session.remote} : null} + {since ?
{fmtAgo(since)}
: null} + {!link.connected && link.linked && link.connection_reason ?
{link.connection_reason}
: null} +
+ + + + 测试 + + {!outgoing && link.connected ? ( + + ) : null} + + +
+ ) : null} +
+ ) +}