web: merge the per-agent lines into one table on the agent page
This commit is contained in:
1 parent
f62eefd365
commit
758b6b1566
2 files changed
+141
-83
No files matched your search
@@ -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<AgentDto>(`/api/admin/agents/${id}`), [id], 5000)
|
||||
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 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 ? (
|
||||
<div className="stack">
|
||||
<Links dialerId={a.id} />
|
||||
<div className="card">
|
||||
<div className="card-title">接入 Agent({a.dialers.length})</div>
|
||||
{a.dialers.length === 0 ? (
|
||||
<Empty>现在没有 Agent 接入此节点</Empty>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>来源 Agent</th>
|
||||
<th>版本</th>
|
||||
<th>来源地址</th>
|
||||
<th>连接时间</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{a.dialers.map((p) => (
|
||||
<tr key={`${p.dialer_id}~${p.ip_version}`}>
|
||||
<td>
|
||||
<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>
|
||||
<td className="muted">
|
||||
{fmtTime(p.connected_since)}({fmtAgo(p.connected_since)})
|
||||
</td>
|
||||
<td className="r">
|
||||
<span className="row" style={{ justifyContent: 'flex-end' }}>
|
||||
<Link className="btn small" to={`/tests?agent=${a.id}&dialer=${p.dialer_id}&ip_version=${p.ip_version}`}>
|
||||
<FlaskConical size={14} />
|
||||
测试
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn small danger"
|
||||
onClick={async () => {
|
||||
await run(() => api.post(`/api/admin/agents/${a.id}/dialers/${p.dialer_id}/disconnect`), '已断开;线路开启时对端会自动重连')
|
||||
agent.reload()
|
||||
}}
|
||||
>
|
||||
<Unplug size={14} />
|
||||
断开
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="agent-summary" aria-label="概览">
|
||||
<div><span>状态</span><b><AgentStatus agent={a} /></b></div>
|
||||
<div><span>版本</span><b>{a.agent_version ?? '—'}</b></div>
|
||||
<div><span>最后心跳</span><b>{a.heartbeat ? fmtAgo(a.heartbeat.at) : '—'}</b></div>
|
||||
<div><span>任务同步</span><b>{a.task_version !== a.applied_task_version ? <Chip tone="warn">未同步</Chip> : <Chip tone="ok">已同步</Chip>}</b></div>
|
||||
<div><span>监听</span><b>{a.listens ? `${a.tcp_port}/tcp · ${a.quic_ports.map((p) => `${p}/udp`).join(', ') || '—'}` : '只拨出'}</b></div>
|
||||
<div><span>地址</span><b className="mono">{a.public_host || '—'}</b></div>
|
||||
</div>
|
||||
<AgentLines agent={a} onChanged={agent.reload} />
|
||||
|
||||
<div className="grid cols-2">
|
||||
<div className="card">
|
||||
@@ -245,36 +199,10 @@ export function AgentDetail() {
|
||||
<dd className="mono">
|
||||
{a.ipv6_host || (a.ipv6_host_auto ? '未识别到公网 IPv6 地址' : '已手动关闭')} <Chip>{a.ipv6_host_auto ? '自动' : '手动'}</Chip>
|
||||
</dd>
|
||||
<dt>IPv6 监听</dt>
|
||||
<dd>{a.ipv6_supported ? '已启用' : '未启用,请升级 Agent 并配置双栈监听'}</dd>
|
||||
<dt>IPv6 诊断</dt>
|
||||
<dd>{IPV6_STATES[a.ipv6?.state ?? 'unknown'] ?? '状态未知'}</dd>
|
||||
<dt>检测网络</dt>
|
||||
<dd>{a.ipv6?.network_scope === 'host' ? '宿主机' : a.ipv6?.network_scope === 'container' ? '容器网络' : '未知'}</dd>
|
||||
<dt>宿主全局 IPv6</dt>
|
||||
<dd>{a.ipv6?.host_has_global_address == null ? '无法读取' : a.ipv6.host_has_global_address ? '已获得' : '未获得'}</dd>
|
||||
<dt>全局 IPv6 地址</dt>
|
||||
<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.tcp_port}/tcp</dd>
|
||||
<dt>数据端口</dt>
|
||||
<dd className="mono">{a.quic_ports.map((p) => `${p}/udp (QUIC)`).join(', ') || '—'}</dd>
|
||||
<dt>版本</dt>
|
||||
<dd>{a.agent_version ?? '—'}</dd>
|
||||
<dt>展示页可见</dt>
|
||||
<dd>{a.public_visible ? '是' : '否'}</dd>
|
||||
<dt>注册时间</dt>
|
||||
<dd>{fmtTime(a.created_at)}</dd>
|
||||
{a.added_by ? (
|
||||
<>
|
||||
<dt>添加者</dt>
|
||||
<dd>{a.added_by}</dd>
|
||||
</>
|
||||
) : null}
|
||||
<dd>{fmtTime(a.created_at)}{a.added_by ? <span className="faint"> · {a.added_by}</span> : null}</dd>
|
||||
<dt>最后在线</dt>
|
||||
<dd>{fmtTime(a.last_seen_at)}</dd>
|
||||
<dt>标签</dt>
|
||||
@@ -292,6 +220,21 @@ export function AgentDetail() {
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
<details className="agent-ipv6">
|
||||
<summary>IPv6 诊断:{IPV6_STATES[a.ipv6?.state ?? 'unknown'] ?? '状态未知'}</summary>
|
||||
<dl className="kv">
|
||||
<dt>IPv6 监听</dt>
|
||||
<dd>{a.ipv6_supported ? '已启用' : '未启用,请升级 Agent 并配置双栈监听'}</dd>
|
||||
<dt>检测网络</dt>
|
||||
<dd>{a.ipv6?.network_scope === 'host' ? '宿主机' : a.ipv6?.network_scope === 'container' ? '容器网络' : '未知'}</dd>
|
||||
<dt>宿主全局 IPv6</dt>
|
||||
<dd>{a.ipv6?.host_has_global_address == null ? '无法读取' : a.ipv6.host_has_global_address ? '已获得' : '未获得'}</dd>
|
||||
<dt>全局 IPv6 地址</dt>
|
||||
<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>
|
||||
</dl>
|
||||
</details>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">运行状态</div>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<LinkMatrixDto>('/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 (
|
||||
<div className="card">
|
||||
<div className="card-title row">
|
||||
线路
|
||||
<span className="muted">{items.length} 条 · {items.filter((link) => link.connected).length} 条已连接</span>
|
||||
{matrix.loading && !matrix.data ? <Spinner /> : null}
|
||||
<Link className="right muted" to="/links">在矩阵里看全部线路</Link>
|
||||
</div>
|
||||
<ErrorNotice error={matrix.error} />
|
||||
{matrix.data && items.length === 0 ? <Empty>还没有线路:添加并启用其他 Agent 后会自动连线。</Empty> : null}
|
||||
{items.length > 0 ? (
|
||||
<table className="table agent-lines">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>对端</th>
|
||||
<th>方向</th>
|
||||
<th>状态</th>
|
||||
<th className="r">RTT</th>
|
||||
<th className="r">丢包</th>
|
||||
<th>连接</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={`${link.dialer_id}~${link.listener_id}`}>
|
||||
<td>
|
||||
<span className={`link-dot ${nodes.get(peerId)?.online ? 'good' : 'offline'}`} /> <Link to={`/agents/${peerId}`} style={{ fontWeight: 600 }}>{peerName}</Link>
|
||||
</td>
|
||||
<td className="muted">{outgoing ? '本机拨出 →' : '← 对端拨入'}</td>
|
||||
<td>
|
||||
<Chip tone={tone}>{!link.linked ? '已关闭' : link.connected ? '已连接' : STATE_LABELS[link.connection_state] ?? '连接中'}</Chip>
|
||||
{link.override != null ? <span className="faint"> 手动</span> : null}
|
||||
</td>
|
||||
<td className="r num">{q?.rtt != null ? `${q.rtt < 10 ? q.rtt.toFixed(1) : Math.round(q.rtt)} ms` : '—'}</td>
|
||||
<td className={`r num${lossy ? ' warn-text' : ''}`}>{q?.loss != null ? `${q.loss.toFixed(2)}%` : '—'}</td>
|
||||
<td className="muted">
|
||||
{session && 'remote' in session && session.remote ? <span className="mono">{session.remote}</span> : null}
|
||||
{since ? <div className="faint">{fmtAgo(since)}</div> : null}
|
||||
{!link.connected && link.linked && link.connection_reason ? <div className="faint" title={link.last_error ?? undefined}>{link.connection_reason}</div> : null}
|
||||
</td>
|
||||
<td className="r">
|
||||
<span className="row" style={{ justifyContent: 'flex-end' }}>
|
||||
<Link className="btn small" to={outgoing ? `/tests?agent=${peerId}&dialer=${agent.id}` : `/tests?agent=${agent.id}&dialer=${peerId}`}>
|
||||
<FlaskConical size={14} />
|
||||
测试
|
||||
</Link>
|
||||
{!outgoing && link.connected ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn small danger"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
await run(() => api.post(`/api/admin/agents/${agent.id}/dialers/${peerId}/disconnect`), '已断开;线路开启时对端会自动重连')
|
||||
reload()
|
||||
}}
|
||||
>
|
||||
<Unplug size={14} />
|
||||
断开
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="btn small" disabled={busy} onClick={() => toggle(link)}>
|
||||
{link.linked ? '关闭' : '开启'}
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user