Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5245f03a78 | ||
|
|
08f261293c |
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, type MouseEvent } from 'react'
|
||||
import { useRef, useState, type CSSProperties, type MouseEvent } from 'react'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { SeriesDto } from '@shared/api/types'
|
||||
import { fmtMs, fmtPct } from '@shared/format'
|
||||
@@ -22,6 +22,24 @@ const levelOf = (loss: number | null | undefined): Level => (loss === null || lo
|
||||
|
||||
const clock = (ms: number, showDate: boolean) => new Date(ms).toLocaleString('zh-CN', { ...(showDate ? { month: '2-digit', day: '2-digit' } : {}), hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
|
||||
const hhmm = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
|
||||
/** 时间轴刻度:在整点(或整半点、整几小时)的格子边界上画一条细线并标时间,最多约 6 个;零点那条标日期。 */
|
||||
function axisTicks(t: number[], stepMin: number, hours: number): { pos: number; label: string }[] {
|
||||
if (t.length === 0) return []
|
||||
const interval = [30, 60, 120, 180, 360, 720, 1440].find((m) => (hours * 60) / m <= 6) ?? 1440
|
||||
const offset = new Date(t[0]).getTimezoneOffset() * 60_000
|
||||
const ticks: { pos: number; label: string }[] = []
|
||||
for (let i = 0; i <= t.length; i++) {
|
||||
const at = i < t.length ? t[i] : t[t.length - 1] + stepMin * 60_000
|
||||
if (Math.round((at - offset) / 60_000) % interval !== 0) continue
|
||||
const day = new Date(at)
|
||||
const label = day.getHours() === 0 && day.getMinutes() === 0 ? `${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}` : hhmm(at)
|
||||
ticks.push({ pos: (i / t.length) * 100, label })
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lineId: string
|
||||
/** 拨号方和监听方的名字:去程是拨号方到监听方,回程反过来。 */
|
||||
@@ -62,21 +80,37 @@ export function LineStrips({ lineId, dialer, listener, range, m }: Props) {
|
||||
const lossDown = col('loss.down')
|
||||
const retransUp = col('tcp.retrans.up')
|
||||
const retransDown = col('tcp.retrans.down')
|
||||
// 最后一格是还没走完的时间段:统计要等这一格结束才有值,不能当成「没有数据」
|
||||
const lastIndex = t.length - 1
|
||||
const pendingLast = Boolean(s?.edge_partial.last) && lastIndex >= 0 && [lossUp, lossDown, retransUp, retransDown].every((c) => c[lastIndex] === null || c[lastIndex] === undefined)
|
||||
const ticks = axisTicks(t, info.stepMin, info.hours)
|
||||
const lossText = (v: number | null | undefined) => (v === null || v === undefined ? '丢包 —' : `${fmtPct(v)} 丢包`)
|
||||
const retransText = (v: number | null | undefined) => (v === null || v === undefined ? '重传 —' : `${fmtPct(v)} 重传`)
|
||||
const cells = s ? Math.max(t.length, 1) : common.cells
|
||||
const cellsOf = (values: (number | null)[]) => (s ? values : Array.from({ length: common.cells }, () => null))
|
||||
// 一个方向下的一行:协议名、数值徽标和质量点阵。
|
||||
const row = (id: string, proto: string, label: string, value: number | null | undefined, text: string, values: (number | null)[]) => (
|
||||
// 一个方向下的一行:协议名、质量点阵和数值徽标,三列和同一方向的另一行对齐。
|
||||
const row = (id: string, gridRow: number, proto: string, label: string, value: number | null | undefined, text: string, values: (number | null)[]) => (
|
||||
<div className="line-mini-row" key={id}>
|
||||
<div className="line-mini-direction-head" title={`${proto} ${label}`}>
|
||||
<span>{proto}</span>
|
||||
<span className={`line-mini-badge ${levelOf(value)}`}>{text}</span>
|
||||
</div>
|
||||
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的 ${proto} ${label}`}>
|
||||
<span className="line-mini-proto" style={{ gridRow }} title={`${proto} ${label}`}>{proto}</span>
|
||||
<div className="bars" style={{ gridRow, '--cells': cells } as CSSProperties} role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的 ${proto} ${label}`}>
|
||||
{cellsOf(values).map((v, i) => (
|
||||
<span key={`${id}-${t[i] ?? i}`} className={`bar ${s ? levelOf(v) : 'none'}${hover === i ? ' on' : ''}`} />
|
||||
<span key={`${id}-${t[i] ?? i}`} className={`bar ${s ? levelOf(v) : 'none'}${pendingLast && i === lastIndex ? ' pending' : ''}${hover === i ? ' on' : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
<span className={`line-mini-badge ${levelOf(value)}`} style={{ gridRow }}>{text}</span>
|
||||
</div>
|
||||
)
|
||||
const direction = (id: string, route: string, title: string, rows: ReturnType<typeof row>[]) => (
|
||||
<div className="line-mini-direction" key={id}>
|
||||
<div className="line-mini-route" title={title}>{route}</div>
|
||||
{rows}
|
||||
{ticks.length > 0 ? (
|
||||
<div className="line-mini-axis" aria-hidden="true">
|
||||
{ticks.map((tick) => (
|
||||
<span key={tick.pos} style={{ left: `${tick.pos}%` }}>{tick.label}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
@@ -89,16 +123,14 @@ export function LineStrips({ lineId, dialer, listener, range, m }: Props) {
|
||||
<small>悬停看 p95 / p99</small>
|
||||
{series.error ? <span className="line-mini-badge bad" title={`上一次取数失败:${series.error.message}`}>数据未更新</span> : null}
|
||||
</div>
|
||||
<div className="line-mini-direction">
|
||||
<div className="line-mini-route" title={`去程:${dialer} → ${listener}`}>{dialer} → {listener}</div>
|
||||
{row('up-quic', 'QUIC', '去程丢包', m['loss.up'], lossText(m['loss.up']), lossUp)}
|
||||
{row('up-tcp', 'TCP', '去程重传', m['tcp.retrans.up'], retransText(m['tcp.retrans.up']), retransUp)}
|
||||
</div>
|
||||
<div className="line-mini-direction">
|
||||
<div className="line-mini-route" title={`回程:${listener} → ${dialer}`}>{listener} → {dialer}</div>
|
||||
{row('down-quic', 'QUIC', '回程丢包', m['loss.down'], lossText(m['loss.down']), lossDown)}
|
||||
{row('down-tcp', 'TCP', '回程重传', m['tcp.retrans.down'], retransText(m['tcp.retrans.down']), retransDown)}
|
||||
</div>
|
||||
{direction('up', `${dialer} → ${listener}`, `去程:${dialer} → ${listener}`, [
|
||||
row('up-quic', 2, 'QUIC', '去程丢包', m['loss.up'], lossText(m['loss.up']), lossUp),
|
||||
row('up-tcp', 3, 'TCP', '去程重传', m['tcp.retrans.up'], retransText(m['tcp.retrans.up']), retransUp),
|
||||
])}
|
||||
{direction('down', `${listener} → ${dialer}`, `回程:${listener} → ${dialer}`, [
|
||||
row('down-quic', 2, 'QUIC', '回程丢包', m['loss.down'], lossText(m['loss.down']), lossDown),
|
||||
row('down-tcp', 3, 'TCP', '回程重传', m['tcp.retrans.down'], retransText(m['tcp.retrans.down']), retransDown),
|
||||
])}
|
||||
</div>
|
||||
<div className={`pop num${hover !== null && at !== null ? ' open' : ''}`} role="tooltip" aria-hidden={hover === null}>
|
||||
{at !== null ? (
|
||||
@@ -106,6 +138,7 @@ export function LineStrips({ lineId, dialer, listener, range, m }: Props) {
|
||||
<b className="pop-time">
|
||||
{clock(t[at], info.hours >= 24)}–{clock(t[at] + info.stepMin * 60_000, info.hours >= 24)}
|
||||
</b>
|
||||
{pendingLast && at === lastIndex ? <span className="pop-note">这一格还没走完,结束后才有统计</span> : <>
|
||||
<div className="pop-group">
|
||||
<span className="pop-name">QUIC Datagram</span>
|
||||
<span>RTT p50 <em>{fmtMs(col('rtt.p50')[at])}</em></span>
|
||||
@@ -130,6 +163,7 @@ export function LineStrips({ lineId, dialer, listener, range, m }: Props) {
|
||||
RTT p50 <em>{fmtMs(col('tcp.rtt.p50')[at])}</em>
|
||||
</span>
|
||||
</div>
|
||||
</>}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
+212
-30
@@ -1374,6 +1374,11 @@
|
||||
}
|
||||
|
||||
.line-mini-stack {
|
||||
--lm-label: 32px;
|
||||
--lm-badge: 78px;
|
||||
--lm-gap: 8px;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
@@ -1387,8 +1392,7 @@
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.line-mini-quic-head > span,
|
||||
.line-mini-direction-head > span:first-child {
|
||||
.line-mini-quic-head > span {
|
||||
color: var(--app-ink-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
@@ -1410,28 +1414,14 @@
|
||||
|
||||
.line-mini-direction {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
grid-template-columns: var(--lm-label) minmax(0, 1fr) var(--lm-badge);
|
||||
gap: 4px var(--lm-gap);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.line-mini-direction-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.line-mini-direction-head > span:first-child {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-mini-direction-head .line-mini-badge {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.line-mini-route {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--app-ink);
|
||||
@@ -1442,9 +1432,59 @@
|
||||
}
|
||||
|
||||
.line-mini-row {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.line-mini-proto {
|
||||
grid-column: 1;
|
||||
color: var(--app-ink-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.line-mini-row .bars {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.line-mini-row .line-mini-badge {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.line-mini-axis {
|
||||
position: relative;
|
||||
grid-column: 2;
|
||||
grid-row: 4;
|
||||
height: 14px;
|
||||
border-top: 1px solid var(--app-ink-faint);
|
||||
color: var(--app-ink-muted);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.line-mini-axis > span {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
padding-top: 5px;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-mini-axis > span::before {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 50%;
|
||||
height: 5px;
|
||||
border-left: 1px solid var(--app-ink-muted);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.pop-note {
|
||||
color: var(--app-ink-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.line-mini-rtt {
|
||||
@@ -1488,23 +1528,28 @@
|
||||
}
|
||||
|
||||
.bars {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cells, 36), minmax(0, 1fr));
|
||||
min-width: 0;
|
||||
gap: 1.5px;
|
||||
height: 8px;
|
||||
gap: 2px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
/* 每格等宽等高;没有数据和还没走完的格子是灰色 */
|
||||
.bar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--app-idle);
|
||||
opacity: 0.35;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.bar.pending {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.bar.ok {
|
||||
background: var(--app-ok);
|
||||
opacity: 0.9;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bar.warn {
|
||||
@@ -1996,11 +2041,148 @@ input {
|
||||
.line-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 440px), 1fr));
|
||||
}
|
||||
|
||||
.agent-filter {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-filter-trigger,
|
||||
.agent-filter-tag {
|
||||
padding: 5px 12px;
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--app-card-inset);
|
||||
color: var(--app-ink-muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.agent-filter-trigger.on,
|
||||
.agent-filter-tag {
|
||||
background: var(--app-accent-container);
|
||||
color: var(--app-accent);
|
||||
}
|
||||
|
||||
.agent-filter-tag {
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-filter-panel {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
width: min(320px, 86vw);
|
||||
padding: 10px;
|
||||
border-radius: var(--r-control);
|
||||
background: var(--app-card);
|
||||
box-shadow: var(--glass-bar-drop);
|
||||
}
|
||||
|
||||
.agent-filter-search {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--app-outline-soft);
|
||||
border-radius: var(--r-control);
|
||||
background: var(--app-card-inset);
|
||||
color: var(--app-ink);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.agent-filter-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent-filter-actions > button {
|
||||
padding: 3px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--app-card-inset);
|
||||
color: var(--app-ink-muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.agent-filter-actions > button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.agent-filter-list {
|
||||
display: grid;
|
||||
max-height: 280px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.agent-filter-list label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.agent-filter-list label > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-card {
|
||||
padding: 10px 12px;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* 卡片窄了就收紧:数值徽标和协议名的列变窄,去掉提示文字;再窄把 QUIC/TCP 并排的 RTT 换行 */
|
||||
@container (width < 400px) {
|
||||
.line-mini-stack {
|
||||
--lm-label: 28px;
|
||||
--lm-badge: 66px;
|
||||
--lm-gap: 6px;
|
||||
}
|
||||
|
||||
.line-mini-quic-head > small {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width < 320px) {
|
||||
.line-mini-stack {
|
||||
--lm-label: 0px;
|
||||
--lm-badge: 58px;
|
||||
}
|
||||
|
||||
.line-mini-proto {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.line-mini-quic-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.line-card .line-name {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { AgentIpv6Dto, NodeInfoDto, OverviewDto, OverviewLineDto, Page } from '@shared/api/types'
|
||||
@@ -136,6 +136,58 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 只看选中的 Agent:多选,一条线路的任意一端在选中里就显示;一个都不选就是全部。
|
||||
* Agent 很多时用带搜索的下拉:输入名字过滤,「全选结果」一次勾上搜到的全部,已选的 Agent 在旁边显示成可以去掉的小标签。
|
||||
*/
|
||||
function AgentFilter({ options, selected, onChange }: { options: { id: string; name: string }[]; selected: string[]; onChange: (ids: string[]) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const outside = (event: MouseEvent) => { if (!box.current?.contains(event.target as Node)) setOpen(false) }
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('mousedown', outside)
|
||||
document.addEventListener('keydown', escape)
|
||||
return () => { document.removeEventListener('mousedown', outside); document.removeEventListener('keydown', escape) }
|
||||
}, [open])
|
||||
const names = new Map(options.map((o) => [o.id, o.name]))
|
||||
const chosen = selected.filter((id) => names.has(id))
|
||||
const needle = query.trim().toLowerCase()
|
||||
const visible = needle ? options.filter((o) => o.name.toLowerCase().includes(needle)) : options
|
||||
const toggle = (id: string) => onChange(chosen.includes(id) ? chosen.filter((x) => x !== id) : [...chosen, id])
|
||||
const addVisible = () => onChange([...new Set([...chosen, ...visible.map((o) => o.id)])])
|
||||
return (
|
||||
<div className="agent-filter" ref={box}>
|
||||
<button type="button" className={`agent-filter-trigger${chosen.length > 0 ? ' on' : ''}`} aria-expanded={open} onClick={() => setOpen(!open)}>
|
||||
Agent · {chosen.length === 0 ? '全部' : `已选 ${chosen.length}`} ▾
|
||||
</button>
|
||||
{chosen.slice(0, 3).map((id) => (
|
||||
<button key={id} type="button" className="agent-filter-tag" title={`取消只看 ${names.get(id)}`} onClick={() => toggle(id)}>{names.get(id)} ×</button>
|
||||
))}
|
||||
{chosen.length > 3 ? <span className="faint">+{chosen.length - 3}</span> : null}
|
||||
{open ? (
|
||||
<div className="agent-filter-panel" role="dialog" aria-label="选择要看的 Agent">
|
||||
<input className="agent-filter-search" type="search" placeholder={`搜索 ${options.length} 个 Agent`} value={query} onChange={(event) => setQuery(event.target.value)} autoFocus />
|
||||
<div className="agent-filter-actions">
|
||||
<button type="button" onClick={addVisible} disabled={visible.length === 0}>全选{needle ? '搜索结果' : ''}</button>
|
||||
<button type="button" onClick={() => onChange([])} disabled={chosen.length === 0}>清空(看全部)</button>
|
||||
</div>
|
||||
<ul className="agent-filter-list">
|
||||
{visible.map((o) => (
|
||||
<li key={o.id}>
|
||||
<label><input type="checkbox" checked={chosen.includes(o.id)} onChange={() => toggle(o.id)} /> <span title={o.name}>{o.name}</span></label>
|
||||
</li>
|
||||
))}
|
||||
{visible.length === 0 ? <li className="faint">没有匹配的 Agent</li> : null}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 一条线路(拨号方 ⇄ 监听方):RTT 是往返;下面每个方向一行,写明是 A → B 还是 B → A(A 是拨号方)。QUIC 看两个方向的丢包,TCP 看两个方向的重传。 */
|
||||
function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
|
||||
const dialerInfo = infos.get(l.dialer_id)
|
||||
@@ -184,6 +236,18 @@ export function Overview() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const ipVersion = ipVersionOf(params.get('ip_version'))
|
||||
const ipv6 = useQuery(() => ipVersion === 'ipv6' ? api.get<Page<AgentIpv6Dto>>('/api/public/ipv6') : Promise.resolve(undefined), [ipVersion], 15_000)
|
||||
// 只看选中的 Agent:记在链接里(?agents=agt_a,agt_b),刷新和分享都保留
|
||||
const selectedAgents = useMemo(() => (params.get('agents') ?? '').split(',').filter(Boolean), [params])
|
||||
const setSelectedAgents = (ids: string[]) =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
if (ids.length === 0) next.delete('agents')
|
||||
else next.set('agents', ids.join(','))
|
||||
return next
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
const range: OverviewRange = OVERVIEW_RANGES.find((r) => r.value === params.get('range'))?.value ?? '3h'
|
||||
const setRange = (v: OverviewRange) =>
|
||||
setParams(
|
||||
@@ -207,7 +271,7 @@ export function Overview() {
|
||||
}, [hosts, data])
|
||||
// 线路面板:按 IP 版本过滤,再排序
|
||||
const lines = useMemo(() => {
|
||||
const picked = (data?.lines ?? []).filter((l) => ipVersionOf(l.ip_version) === ipVersion)
|
||||
const picked = (data?.lines ?? []).filter((l) => ipVersionOf(l.ip_version) === ipVersion && (selectedAgents.length === 0 || selectedAgents.includes(l.dialer_id) || selectedAgents.includes(l.agent_id)))
|
||||
const key = sort === 'rtt' ? 'rtt.p50' : 'loss.total'
|
||||
return picked.sort((a, b) => {
|
||||
if (a.online !== b.online) return a.online ? -1 : 1
|
||||
@@ -220,7 +284,16 @@ export function Overview() {
|
||||
if (y === null || y === undefined) return -1
|
||||
return x - y
|
||||
})
|
||||
}, [data, sort, ipVersion])
|
||||
}, [data, sort, ipVersion, selectedAgents])
|
||||
// 筛选用的 Agent 列表:所有出现在线路两端的 Agent,按名字排
|
||||
const agentOptions = useMemo(() => {
|
||||
const byId = new Map<string, string>()
|
||||
for (const l of data?.lines ?? []) {
|
||||
byId.set(l.dialer_id, l.dialer_name)
|
||||
byId.set(l.agent_id, l.name)
|
||||
}
|
||||
return [...byId].map(([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
|
||||
}, [data])
|
||||
// 按 来源 Agent 的运营商分组(固定顺序,没有线路的组不显示)
|
||||
const groups = useMemo(
|
||||
() => CARRIERS.map((c) => ({ ...c, lines: lines.filter((l) => carrierOf(infos.get(l.dialer_id)) === c.key) })).filter((g) => g.lines.length > 0),
|
||||
@@ -262,6 +335,7 @@ export function Overview() {
|
||||
<div className="toolbar lines-toolbar">
|
||||
<IpVersionPicker value={ipVersion} onChange={(v) => { const next = new URLSearchParams(params); next.set('ip_version', v); setCarrier(''); setParams(next, { replace: true }) }} />
|
||||
{groups.length > 1 ? <Segmented<'' | CarrierKey> options={[{ value: '', label: '全部' }, ...groups.map((g) => ({ value: g.key, label: g.name }))]} value={carrier} onChange={setCarrier} /> : null}
|
||||
{agentOptions.length > 1 ? <AgentFilter options={agentOptions} selected={selectedAgents} onChange={setSelectedAgents} /> : null}
|
||||
<span className="right row">
|
||||
<Segmented<OverviewRange> options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: r.label }))} value={range} onChange={setRange} />
|
||||
<Segmented
|
||||
@@ -278,7 +352,7 @@ export function Overview() {
|
||||
{data ? (
|
||||
lines.length === 0 ? (
|
||||
<div>
|
||||
<Empty>{ipVersion === 'ipv6' ? '还没有 IPv6 线路' : '还没有线路'}</Empty>
|
||||
<Empty>{selectedAgents.length > 0 ? '没有和所选 Agent 有关的线路' : ipVersion === 'ipv6' ? '还没有 IPv6 线路' : '还没有线路'}</Empty>
|
||||
{ipVersion === 'ipv6' ? (ipv6.data?.items ?? []).map((a) => <p key={a.agent_id} className="muted">{a.name}:{IPV6_STATES[a.ipv6.state] ?? '状态未知'}{!a.ipv6.address_configured && a.ipv6.state !== 'address_not_configured' ? ';面板也尚未配置 IPv6 地址' : ''}{!a.online ? '(Agent 离线,显示上次诊断)' : ''}</p>) : null}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user