web: manage links in a master-detail explorer instead of the board
This commit is contained in:
1 parent
70fc033789
commit
f62eefd365
5 files changed
+456
-645
No files matched your search
+234
-287
@@ -304,291 +304,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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-edge.good,
|
|
||||||
.link-board-legend .good {
|
|
||||||
color: var(--app-ok);
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-board-edge.fair,
|
|
||||||
.link-board-legend .fair {
|
|
||||||
color: var(--app-warn);
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-board-edge.poor,
|
|
||||||
.link-board-legend .poor {
|
|
||||||
color: var(--app-bad);
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-board-edge.pending,
|
|
||||||
.link-board-legend .pending {
|
|
||||||
color: var(--app-warn);
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-board-edge.dim {
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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.pending .link-board-edge-line {
|
|
||||||
stroke-dasharray: 2 6;
|
|
||||||
stroke-linecap: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-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-legend .pending::before {
|
|
||||||
border-top-style: dotted;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
.link-board-header {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
@@ -597,7 +312,239 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-board-tools > .row {
|
/* 线路管理:左边 Agent 列表(健康摘要,有问题的在前),右边是选中 Agent 的全部线路 */
|
||||||
flex-wrap: wrap;
|
.link-dot {
|
||||||
|
display: inline-block;
|
||||||
|
flex: none;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--app-ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dot.good {
|
||||||
|
background: var(--app-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dot.fair {
|
||||||
|
background: var(--app-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dot.poor {
|
||||||
|
background: var(--app-bad);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: var(--r-control);
|
||||||
|
background: var(--app-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-filter {
|
||||||
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-explorer-list ul {
|
||||||
|
display: grid;
|
||||||
|
max-height: 560px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-agent {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--app-ink);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-agent:hover {
|
||||||
|
background: var(--app-card-inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-agent.selected {
|
||||||
|
background: var(--app-accent-container);
|
||||||
|
color: var(--app-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-agent.offline .name {
|
||||||
|
color: var(--app-ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-agent .name {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-agent small {
|
||||||
|
color: var(--app-ink-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-agent .count {
|
||||||
|
color: var(--app-ink-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-agent .badge {
|
||||||
|
min-width: 18px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-agent .badge.poor {
|
||||||
|
background: color-mix(in srgb, var(--app-bad) 30%, transparent);
|
||||||
|
color: var(--app-bad);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-agent .badge.fair {
|
||||||
|
background: color-mix(in srgb, var(--app-warn) 26%, transparent);
|
||||||
|
color: var(--app-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-list .empty {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-hint {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: var(--app-ink-faint);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-detail {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-head .input {
|
||||||
|
width: auto;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-peer {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--app-ink);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-peer:hover {
|
||||||
|
color: var(--app-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-lines td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-lines tr.poor {
|
||||||
|
background: color-mix(in srgb, var(--app-bad) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-lines tr.fair,
|
||||||
|
.link-explorer-lines tr.pending {
|
||||||
|
background: color-mix(in srgb, var(--app-warn) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-explorer-lines tr.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn-text {
|
||||||
|
color: var(--app-warn);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 900px) {
|
||||||
|
.link-explorer {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent 详情:顶部概览条、线路表、折叠的 IPv6 诊断 */
|
||||||
|
.agent-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: var(--r-control);
|
||||||
|
background: var(--app-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-summary > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-summary span {
|
||||||
|
color: var(--app-ink-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-summary b {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-lines td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-ipv6 {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--app-outline-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-ipv6 > summary {
|
||||||
|
color: var(--app-ink-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-ipv6 > .kv {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
import { Maximize, Minus, Plus, RotateCcw, Server } from 'lucide-react'
|
|
||||||
import { useEffect, 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}`
|
|
||||||
/** 线路质量:摘要里的 RTT 中位数(毫秒)和总丢包率(0–1);线路 id 是 <监听方>~<拨号方>。 */
|
|
||||||
export type LineQuality = { rtt: number | null; loss: number | null }
|
|
||||||
export const lineIdOf = (link: LinkDto) => `${link.listener_id}~${link.dialer_id}`
|
|
||||||
type Tone = 'good' | 'fair' | 'poor' | 'connected' | 'pending' | 'disabled'
|
|
||||||
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 toneOf(link: LinkDto, quality?: LineQuality): Tone {
|
|
||||||
if (!link.linked) return 'disabled'
|
|
||||||
if (!link.connected) return 'pending'
|
|
||||||
const rtt = quality?.rtt
|
|
||||||
if (rtt == null) return 'connected'
|
|
||||||
const loss = quality?.loss ?? 0
|
|
||||||
if (rtt >= 250 || loss >= 0.05) return 'poor'
|
|
||||||
if (rtt >= 100 || loss >= 0.01) return 'fair'
|
|
||||||
return 'good'
|
|
||||||
}
|
|
||||||
|
|
||||||
function edgeText(link: LinkDto, quality?: LineQuality) {
|
|
||||||
if (!link.linked || !link.connected) return labelOf(link)
|
|
||||||
if (quality?.rtt == null) return '测量中'
|
|
||||||
const loss = quality.loss ?? 0
|
|
||||||
return `${Math.round(quality.rtt)} ms${loss >= 0.005 ? ` · ${(loss * 100).toFixed(1)}%` : ''}`
|
|
||||||
}
|
|
||||||
|
|
||||||
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, quality, busy, onSet, onRetry }: {
|
|
||||||
data: LinkMatrixDto
|
|
||||||
quality: Record<string, LineQuality>
|
|
||||||
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 [focusNode, setFocusNode] = 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 nodes = data.nodes
|
|
||||||
const focused = data.items.find((link) => keyOf(link) === selected)
|
|
||||||
const focusedQuality = focused ? quality[lineIdOf(focused)] : undefined
|
|
||||||
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()
|
|
||||||
setFocusNode(node ?? null)
|
|
||||||
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">一对节点一条线,线上是近 3 小时 RTT 中位数 · 点节点只看它的线路 · 拖动排布 · 滚轮缩放</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="节点之间的线路">
|
|
||||||
{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 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 path = `M ${startX} ${startY} L ${endX} ${endY}`
|
|
||||||
const labelX = (startX + endX) / 2
|
|
||||||
const labelY = (startY + endY) / 2
|
|
||||||
const active = selected === keyOf(link)
|
|
||||||
const q = quality[lineIdOf(link)]
|
|
||||||
const tone = toneOf(link, q)
|
|
||||||
const text = edgeText(link, q)
|
|
||||||
const dim = focusNode !== null && link.dialer_id !== focusNode && link.listener_id !== focusNode && !active
|
|
||||||
return (
|
|
||||||
<g key={keyOf(link)} data-edge className={`link-board-edge ${tone}${active ? ' selected' : ''}${dim ? ' dim' : ''}`} role="button" tabIndex={0} aria-label={`${link.dialer_name} 与 ${link.listener_name}:${text}`} 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)}{q?.rtt != null ? `,RTT ${Math.round(q.rtt)} ms` : ''}</title>
|
|
||||||
<path className="link-board-edge-hit" d={path} />
|
|
||||||
<path className="link-board-edge-line" d={path} />
|
|
||||||
<g className="link-board-edge-label" transform={`translate(${labelX}, ${labelY})`}>
|
|
||||||
<rect x="-40" y="-12" width="80" height="24" rx="12" />
|
|
||||||
<text textAnchor="middle" dominantBaseline="central">{text}</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="good">RTT < 100 ms</span><span className="fair">100–250 ms 或丢包 ≥ 1%</span><span className="poor">> 250 ms 或丢包 ≥ 5%</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">{focusedQuality?.rtt != null ? <><dt>RTT 中位数</dt><dd>{Math.round(focusedQuality.rtt)} ms</dd></> : null}{focusedQuality?.loss != null ? <><dt>丢包率</dt><dd>{(focusedQuality.loss * 100).toFixed(2)}%</dd></> : null}<dt>拨号方</dt><dd>{focused.dialer_name}({runtimeStatusLabel(focused.dialer_runtime_status)})</dd><dt>监听方</dt><dd>{focused.listener_name}({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">一对节点只有一条线路,上下行都在这条连接上测,RTT 只测一次。</span>
|
|
||||||
</div>
|
|
||||||
) : <Empty>点击画板中的连线,查看线路质量或开关这条线路。</Empty>}
|
|
||||||
<p className="link-board-storage faint">节点位置仅保存在当前浏览器。</p>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import type { LinkDto, LinkMatrixDto } from '@shared/api/types'
|
||||||
|
import { Chip, Empty } from '@shared/ui'
|
||||||
|
import { lineIdOf, type LineQuality } from './lineQuality'
|
||||||
|
|
||||||
|
type Tone = 'good' | 'fair' | 'poor' | 'connected' | 'pending' | 'disabled'
|
||||||
|
type SortKey = 'rtt' | 'loss' | 'state' | 'name'
|
||||||
|
|
||||||
|
const STATE_LABELS: Record<string, string> = {
|
||||||
|
waiting_report: '等待上报', control_connecting: '控制连接中', control_down: '失联',
|
||||||
|
task_connecting: 'QUIC 握手中', task_retrying: 'QUIC 重试中', dialer_unavailable: '拨号方不可用', listener_unavailable: '监听方不可用',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 严重程度:左边的列表排序、徽标和「只看有问题的」用。已关闭是你自己的选择,不算问题。 */
|
||||||
|
const SEVERITY: Record<Tone, number> = { poor: 3, pending: 2, fair: 2, disabled: 0, connected: 0, good: 0 }
|
||||||
|
|
||||||
|
function toneOf(link: LinkDto, quality?: LineQuality): Tone {
|
||||||
|
if (!link.linked) return 'disabled'
|
||||||
|
if (!link.connected) return 'pending'
|
||||||
|
const rtt = quality?.rtt
|
||||||
|
if (rtt == null) return 'connected'
|
||||||
|
const loss = quality?.loss ?? 0
|
||||||
|
if (rtt >= 250 || loss >= 5) return 'poor'
|
||||||
|
if (rtt >= 100 || loss >= 1) return 'fair'
|
||||||
|
return 'good'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtRtt = (ms: number) => `${ms < 10 ? ms.toFixed(1) : Math.round(ms)} ms`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 线路管理:左边是 Agent 列表(只显示健康摘要,有问题的排在前面),右边是选中的 Agent 和所有对端的线路。
|
||||||
|
* 一对节点只有一条线路,所以右边每一行就是一条线路:谁拨谁、状态、RTT、丢包,行内直接开关。节点再多也不会铺出 N×N 的格子。
|
||||||
|
*/
|
||||||
|
export function LinkExplorer({ data, quality, busy, onSet, onRetry }: {
|
||||||
|
data: LinkMatrixDto
|
||||||
|
quality: Record<string, LineQuality>
|
||||||
|
busy: boolean
|
||||||
|
onSet: (link: LinkDto, enabled: boolean | null) => Promise<void>
|
||||||
|
onRetry: (link: LinkDto) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [problemsOnly, setProblemsOnly] = useState(false)
|
||||||
|
const [sort, setSort] = useState<SortKey>('state')
|
||||||
|
|
||||||
|
// 每个 Agent 的健康摘要
|
||||||
|
const summaries = useMemo(() => {
|
||||||
|
return data.nodes.map((node) => {
|
||||||
|
const links = data.items.filter((link) => link.dialer_id === node.id || link.listener_id === node.id)
|
||||||
|
const tones = links.map((link) => toneOf(link, quality[lineIdOf(link)]))
|
||||||
|
const worst = tones.reduce((max, tone) => Math.max(max, SEVERITY[tone]), 0)
|
||||||
|
const problems = tones.filter((tone) => SEVERITY[tone] > 0).length
|
||||||
|
const worstRtt = links.reduce((max, link) => Math.max(max, quality[lineIdOf(link)]?.rtt ?? 0), 0)
|
||||||
|
return { node, links, worst, problems, worstRtt, connected: links.filter((link) => link.connected).length }
|
||||||
|
})
|
||||||
|
}, [data.nodes, data.items, quality])
|
||||||
|
|
||||||
|
const list = useMemo(() => {
|
||||||
|
const needle = query.trim().toLowerCase()
|
||||||
|
return summaries
|
||||||
|
.filter((s) => (needle ? s.node.name.toLowerCase().includes(needle) : true))
|
||||||
|
.filter((s) => !problemsOnly || s.problems > 0)
|
||||||
|
.sort((a, b) => b.worst - a.worst || b.problems - a.problems || a.node.name.localeCompare(b.node.name, undefined, { numeric: true }))
|
||||||
|
}, [summaries, query, problemsOnly])
|
||||||
|
|
||||||
|
// 没选或选的不在列表里:选最需要关注的那个
|
||||||
|
useEffect(() => {
|
||||||
|
if ((selectedId === null || !data.nodes.some((n) => n.id === selectedId)) && summaries.length > 0) {
|
||||||
|
const first = [...summaries].sort((a, b) => b.worst - a.worst || b.problems - a.problems || a.node.name.localeCompare(b.node.name, undefined, { numeric: true }))[0]
|
||||||
|
setSelectedId(first.node.id)
|
||||||
|
}
|
||||||
|
}, [selectedId, data.nodes, summaries])
|
||||||
|
|
||||||
|
const current = summaries.find((s) => s.node.id === selectedId)
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
if (!current) return []
|
||||||
|
const keyed = current.links.map((link) => {
|
||||||
|
const outgoing = link.dialer_id === current.node.id
|
||||||
|
const q = quality[lineIdOf(link)]
|
||||||
|
return { link, outgoing, peerId: outgoing ? link.listener_id : link.dialer_id, peerName: outgoing ? link.listener_name : link.dialer_name, q, tone: toneOf(link, q) }
|
||||||
|
})
|
||||||
|
return keyed.sort((a, b) => {
|
||||||
|
switch (sort) {
|
||||||
|
case 'rtt': return (b.q?.rtt ?? -1) - (a.q?.rtt ?? -1)
|
||||||
|
case 'loss': return (b.q?.loss ?? -1) - (a.q?.loss ?? -1)
|
||||||
|
case 'name': return a.peerName.localeCompare(b.peerName, undefined, { numeric: true })
|
||||||
|
default: return SEVERITY[b.tone] - SEVERITY[a.tone] || (b.q?.rtt ?? -1) - (a.q?.rtt ?? -1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [current, quality, sort])
|
||||||
|
|
||||||
|
async function each(links: LinkDto[], enabled: boolean | null) {
|
||||||
|
for (const link of links) await onSet(link, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.nodes.length === 0) return <Empty>还没有已启用的 Agent,请先添加并启用节点。</Empty>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="link-explorer">
|
||||||
|
<aside className="link-explorer-list" aria-label="Agent 列表">
|
||||||
|
<div className="link-explorer-filter">
|
||||||
|
<input className="input" type="search" placeholder={`搜索 ${data.nodes.length} 个 Agent`} value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||||
|
<label className="switch">
|
||||||
|
<input type="checkbox" checked={problemsOnly} onChange={(event) => setProblemsOnly(event.target.checked)} /> 只看有问题的
|
||||||
|
<span className="link-explorer-hint" tabIndex={0} title={'圆点:它最差的一条线路\n红:RTT > 250 ms 或丢包 ≥ 5%\n黄:RTT 100–250 ms、丢包 ≥ 1% 或连接中\n数字:需要关注的线路数;没有问题时是 已连接/总数'}>ⓘ</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{list.map((s) => (
|
||||||
|
<li key={s.node.id}>
|
||||||
|
<button type="button" className={`link-explorer-agent${s.node.id === selectedId ? ' selected' : ''}${s.node.online ? '' : ' offline'}`} onClick={() => setSelectedId(s.node.id)}>
|
||||||
|
<span className={`link-dot ${!s.node.online ? 'offline' : s.worst >= 3 ? 'poor' : s.worst >= 2 ? 'fair' : 'good'}`} aria-hidden="true" />
|
||||||
|
<span className="name">{s.node.name}</span>
|
||||||
|
{s.node.listens ? null : <small>只拨出</small>}
|
||||||
|
{s.problems > 0 ? <span className={`badge ${s.worst >= 3 ? 'poor' : 'fair'}`} title={`${s.problems} 条线路需要关注`}>{s.problems}</span> : <span className="count">{s.connected}/{s.links.length}</span>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{list.length === 0 ? <li className="faint empty">没有匹配的 Agent</li> : null}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="link-explorer-detail" aria-label="选中 Agent 的线路">
|
||||||
|
{current ? (
|
||||||
|
<>
|
||||||
|
<div className="link-explorer-head">
|
||||||
|
<div className="stack">
|
||||||
|
<b><Link to={`/agents/${current.node.id}`}>{current.node.name}</Link></b>
|
||||||
|
<span className="row wrap muted">
|
||||||
|
<Chip tone={current.node.online ? 'ok' : ''}>{current.node.online ? '在线' : '离线'}</Chip>
|
||||||
|
{current.node.listens ? '监听' : '只拨出'} · {current.links.length} 条线路,{current.connected} 条已连接{current.problems > 0 ? `,${current.problems} 条需要关注` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="right row wrap">
|
||||||
|
<label className="row">排序
|
||||||
|
<select className="input" value={sort} onChange={(event) => setSort(event.target.value as SortKey)}>
|
||||||
|
<option value="state">问题优先</option>
|
||||||
|
<option value="rtt">RTT 高到低</option>
|
||||||
|
<option value="loss">丢包高到低</option>
|
||||||
|
<option value="name">名称</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" className="btn small" disabled={busy || current.links.every((l) => !l.linked)} onClick={() => each(current.links.filter((l) => l.linked), false)}>全部关闭</button>
|
||||||
|
<button type="button" className="btn small" disabled={busy || current.links.every((l) => l.linked)} onClick={() => each(current.links.filter((l) => !l.linked), true)}>全部打开</button>
|
||||||
|
<button type="button" className="btn small" disabled={busy || current.links.every((l) => l.override == null)} onClick={() => each(current.links.filter((l) => l.override != null), null)}>恢复默认</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 ? <Empty>这个 Agent 还没有线路:添加并启用其他 Agent 后会自动连线。</Empty> : (
|
||||||
|
<table className="table link-explorer-lines">
|
||||||
|
<thead>
|
||||||
|
<tr><th>对端</th><th>方向</th><th>状态</th><th className="r">RTT(近 3 小时)</th><th className="r">丢包</th><th /></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map(({ link, outgoing, peerId, peerName, q, tone }) => {
|
||||||
|
const peerNode = data.nodes.find((n) => n.id === peerId)
|
||||||
|
const canSwap = data.nodes.find((n) => n.id === link.dialer_id)?.listens === true
|
||||||
|
return (
|
||||||
|
<tr key={`${link.dialer_id}~${link.listener_id}`} className={tone}>
|
||||||
|
<td>
|
||||||
|
<span className={`link-dot ${peerNode?.online ? tone === 'poor' ? 'poor' : tone === 'fair' || tone === 'pending' ? 'fair' : 'good' : 'offline'}`} aria-hidden="true" />{' '}
|
||||||
|
<button type="button" className="link-explorer-peer" onClick={() => setSelectedId(peerId)} title={`看 ${peerName} 的全部线路`}>{peerName}</button>
|
||||||
|
</td>
|
||||||
|
<td className="muted">{outgoing ? '本机拨出 →' : '← 对端拨入'}</td>
|
||||||
|
<td>
|
||||||
|
<Chip tone={link.connected ? 'ok' : link.linked ? 'warn' : ''}>{!link.linked ? '已关闭' : link.connected ? '已连接' : STATE_LABELS[link.connection_state] ?? '连接中'}</Chip>
|
||||||
|
{link.override != null ? <span className="faint"> 手动</span> : null}
|
||||||
|
{link.linked && !link.connected && link.connection_reason ? <div className="faint" title={link.last_error ?? undefined}>{link.connection_reason}</div> : null}
|
||||||
|
</td>
|
||||||
|
<td className="r num">{q?.rtt != null ? fmtRtt(q.rtt) : '—'}</td>
|
||||||
|
<td className={`r num${(q?.loss ?? 0) >= 1 ? ' warn-text' : ''}`}>{q?.loss != null ? `${q.loss.toFixed(2)}%` : '—'}</td>
|
||||||
|
<td className="r">
|
||||||
|
<span className="row" style={{ justifyContent: 'flex-end' }}>
|
||||||
|
{link.linked && !link.connected ? <button type="button" className="btn small" disabled={busy} onClick={() => onRetry(link)}>重试</button> : null}
|
||||||
|
{link.override != null ? <button type="button" className="btn small text" disabled={busy} title="回到自动" onClick={() => onSet(link, null)}>↺</button> : null}
|
||||||
|
{canSwap && peerNode?.listens !== false ? (
|
||||||
|
<button type="button" className="btn small text" disabled={busy} title={`换成 ${link.listener_name} 拨 ${link.dialer_name}`} onClick={() => onSet({ ...link, dialer_id: link.listener_id, dialer_name: link.listener_name, listener_id: link.dialer_id, listener_name: link.dialer_name }, true)}>换方向</button>
|
||||||
|
) : null}
|
||||||
|
<button type="button" className={`btn small${link.linked ? '' : ' primary'}`} disabled={busy} onClick={() => onSet(link, !link.linked)}>{link.linked ? '关闭' : '开启'}</button>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,33 +1,18 @@
|
|||||||
import { LinkBoard, type LineQuality } from './LinkBoard'
|
import { LinkExplorer } from './LinkExplorer'
|
||||||
import { api } from '@shared/api/client'
|
import { api } from '@shared/api/client'
|
||||||
import type { LinkDto, LinkMatrixDto, OverviewDto } from '@shared/api/types'
|
import type { LinkDto, LinkMatrixDto } from '@shared/api/types'
|
||||||
import { useQuery } from '@shared/hooks'
|
import { useQuery } from '@shared/hooks'
|
||||||
import { runtimeStatusLabel } from '@shared/nodes'
|
import { ErrorNotice, Spinner, useAction } from '@shared/ui'
|
||||||
import { Chip, Empty, ErrorNotice, Spinner, useAction } from '@shared/ui'
|
import { useLineQuality } from './lineQuality'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 线路:Agent 之间的线路,一对 Agent 一条。自动连接开着时所有可达的 Agent 两两相连,关着时只连手动选中的;
|
* 线路:Agent 之间的线路,一对 Agent 一条。自动连接开着时所有可达的 Agent 两两相连,关着时只连手动选中的;
|
||||||
* 每一对都可以手动强制开或关(点一下切换,「↺」回到自动)。拨号的一方由面板决定(不监听的拨监听的;国内的节点默认只拨出;其余 id 较小的拨),「恢复默认连接模式」清掉所有手动设置。
|
* 每一对都可以手动强制开或关。拨号的一方由面板决定(不监听的拨监听的;国内的节点默认只拨出;其余 id 较小的拨),「恢复默认连接模式」清掉所有手动设置。
|
||||||
*/
|
*/
|
||||||
/** 线路连接状态:control_* 是与 Agent 的控制连接(断开即失联),task_* 是测量用的 QUIC 数据通道。 */
|
export function Links() {
|
||||||
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 matrix = useQuery(() => api.get<LinkMatrixDto>('/api/admin/links'), [], 8000)
|
||||||
// 线路质量:画板上每条线写近 3 小时的 RTT 中位数和丢包,没有数据就只显示连接状态
|
const quality = useLineQuality()
|
||||||
const overview = useQuery(() => (dialerId ? Promise.resolve(null) : api.get<OverviewDto>('/api/public/overview', { range: '3h', metrics: 'rtt.p50,loss.total', ip_version: 'ipv4' })), [dialerId], 30_000)
|
|
||||||
const quality: Record<string, LineQuality> = Object.fromEntries((overview.data?.lines ?? []).map((line) => [line.id, { rtt: line.summary.metrics['rtt.p50'] ?? null, loss: line.summary.metrics['loss.total'] ?? null }]))
|
|
||||||
const { busy, run } = useAction()
|
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) => {
|
const set = async (link: LinkDto, enabled: boolean | null) => {
|
||||||
await run(() => api.put(`/api/admin/links/${link.dialer_id}/${link.listener_id}`, { enabled }))
|
await run(() => api.put(`/api/admin/links/${link.dialer_id}/${link.listener_id}`, { enabled }))
|
||||||
matrix.reload()
|
matrix.reload()
|
||||||
@@ -48,72 +33,17 @@ export function Links({ dialerId }: { dialerId?: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className={`row${dialerId ? '' : ' link-board-header'}`} style={{ marginBottom: 12 }}>
|
<div className="row link-board-header" style={{ marginBottom: 12 }}>
|
||||||
<b>{dialerId ? '拨出连接' : 'Agent 节点画板'}</b>
|
<b title="每对 Agent 一条线路,只测一次 RTT">Agent 线路</b>
|
||||||
{matrix.loading && !matrix.data ? <Spinner /> : null}
|
{matrix.loading && !matrix.data ? <Spinner /> : null}
|
||||||
<span className="muted">{dialerId ? '点格子调整单方向的连接策略。' : '每对 Agent 一条线路,只测一次 RTT;点击连线查看质量或开关。'}</span>
|
<button type="button" className="btn small right" disabled={busy} title="默认规则:不监听的节点拨监听的;国内(CN)的节点默认只拨出、不当被连接方;其余按 id 小的拨。手动设置会被清掉。" onClick={resetLinks}>恢复默认连接模式</button>
|
||||||
{!dialerId ? <button type="button" className="btn small right" disabled={busy} title="默认规则:不监听的节点拨监听的;国内(CN)的节点默认只拨出、不当被连接方;其余按 id 小的拨。手动设置会被清掉。" onClick={resetLinks}>恢复默认连接模式</button> : null}
|
<label className="switch">
|
||||||
{!dialerId ? <label className="switch">
|
|
||||||
<input type="checkbox" checked={matrix.data?.auto_link ?? true} disabled={busy} onChange={(e) => setAuto(e.target.checked)} />
|
<input type="checkbox" checked={matrix.data?.auto_link ?? true} disabled={busy} onChange={(e) => setAuto(e.target.checked)} />
|
||||||
自动连接 {matrix.data?.auto_link === false ? '关' : '开'}
|
自动连接 {matrix.data?.auto_link === false ? '关' : '开'}
|
||||||
</label> : null}
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<ErrorNotice error={matrix.error} />
|
<ErrorNotice error={matrix.error} />
|
||||||
{!dialerId && matrix.data ? <LinkBoard data={matrix.data} quality={quality} busy={busy} onSet={set} onRetry={retry} /> : null}
|
{matrix.data ? <LinkExplorer data={matrix.data} quality={quality} 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { api } from '@shared/api/client'
|
||||||
|
import type { OverviewDto } from '@shared/api/types'
|
||||||
|
import { useQuery } from '@shared/hooks'
|
||||||
|
import type { LinkDto } from '@shared/api/types'
|
||||||
|
|
||||||
|
/** 线路质量:摘要里的 RTT 中位数(毫秒)和总丢包率(百分比,0.5 就是 0.5%)。 */
|
||||||
|
export type LineQuality = { rtt: number | null; loss: number | null }
|
||||||
|
|
||||||
|
/** 线路 id 是 <监听方>~<拨号方>。 */
|
||||||
|
export const lineIdOf = (link: LinkDto) => `${link.listener_id}~${link.dialer_id}`
|
||||||
|
|
||||||
|
/** 每条线路近 3 小时的 RTT 中位数和丢包(百分比),键是线路 id(<监听方>~<拨号方>);没有数据的线路不在里面。 */
|
||||||
|
export function useLineQuality(): Record<string, LineQuality> {
|
||||||
|
const overview = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range: '3h', metrics: 'rtt.p50,loss.total', ip_version: 'ipv4' }), [], 30_000)
|
||||||
|
return Object.fromEntries((overview.data?.lines ?? []).map((line) => [line.id, { rtt: line.summary.metrics['rtt.p50'] ?? null, loss: line.summary.metrics['loss.total'] ?? null }]))
|
||||||
|
}
|
||||||
Reference in new issue
Block a user