feat(display): add compact QUIC and host health views

This commit is contained in:
justme committed 2026-10-10 03:00:23 +08:00
1 parent b964dc2bbc
commit b25b111134
14 files changed
+921 -251

No files matched your search

+1 -1
View File
@@ -257,7 +257,7 @@ export function AgentDetail() {
<dt>控制端口</dt>
<dd className="mono">{a.probe_tcp_port}/tcp</dd>
<dt>数据端口</dt>
<dd className="mono">{a.probe_udp_ports.map((p) => `${p}/udp`).join(', ') || '—'}</dd>
<dd className="mono">{a.probe_quic_ports.map((p) => `${p}/udp (QUIC)`).join(', ') || '—'}</dd>
<dt>版本</dt>
<dd>{a.agent_version ?? '—'}</dd>
<dt>展示页可见</dt>
+12 -2
View File
@@ -17,8 +17,10 @@ import { OwnerPicker } from './OwnerPicker'
*/
function LinkMatrix() {
const matrix = useQuery(() => api.get<LinkMatrixDto>('/api/admin/links'), [], 8000)
const probeState = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 8000)
const { busy, run } = useAction()
const items = matrix.data?.items ?? []
const onlineByProbe = new Map((probeState.data?.items ?? []).map((probe) => [probe.id, probe.online]))
const probes = [...new Map(items.map((i) => [i.probe_id, i.probe_name])).entries()]
const agents = [...new Map(items.map((i) => [i.agent_id, i.agent_name])).entries()]
const cell = (probeId: string, agentId: string) => items.find((i) => i.probe_id === probeId && i.agent_id === agentId)
@@ -58,12 +60,20 @@ function LinkMatrix() {
{agents.map(([agentId]) => {
const link = cell(probeId, agentId)
if (!link) return <td key={agentId} />
const probeOnline = onlineByProbe.get(probeId)
const tone = link.connected ? 'ok' : link.linked ? 'warn' : ''
const text = link.connected ? '已连接' : link.linked ? '待连接' : '不连'
const text = link.connected ? '已连接' : !link.linked ? '不连' : probeOnline === false ? 'Probe 离线' : '待连接'
const statusHint = link.connected
? 'Probe 已和 Agent 建立测量会话'
: !link.linked
? '这条线路已关闭'
: probeOnline === false
? 'Probe 当前没有连接到面板;Probe 上线后才会尝试连接 Agent'
: '线路已启用,等待 Probe 与 Agent 建立测量会话'
return (
<td key={agentId}>
<span className="row" style={{ gap: 6 }}>
<button type="button" className="btn small" disabled={busy} title={link.linked ? '点一下:手动关闭' : '点一下:手动开启'} onClick={() => set(link, !link.linked)}>
<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 ? (
+2
View File
@@ -5,6 +5,7 @@ import { NavLink, Navigate, Route, Routes, useLocation, useParams } from 'react-
import { SourceButton, ThemeToggle } from '@shared/ui'
import { Compare } from './pages/Compare'
import { Line } from './pages/Line'
import { Host } from './pages/Host'
import { Overview } from './pages/Overview'
import { useBackgroundTint } from './backgroundTint'
import { useBackgroundParallax } from './parallax'
@@ -51,6 +52,7 @@ export function App() {
<Routes>
<Route path="/" element={<Overview />} />
<Route path="/line/:id" element={<LineRoute />} />
<Route path="/host/:id" element={<Host />} />
<Route path="/compare" element={<Compare />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+52 -64
View File
@@ -19,52 +19,6 @@ const levelOf = (loss: number | null | undefined): Level => (loss === null || lo
const clock = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
interface StripProps {
/** 左上:什么延迟(UDP 延迟 p50)。 */
rttLabel: string
rtt: number | null | undefined
/** 右上:丢包还是重传。 */
badLabel: string
bad: number | null | undefined
badHint?: string
/** 每 5 分钟一格:这一格的丢包(重传)率、延迟。 */
t: number[]
badSeries: (number | null)[]
ready: boolean
/** 提示里这一格的「丢包」叫什么。 */
tipBad: string
/** 没有数据时占位的格数,和每格长度的说明(「每 5 分钟」)。 */
cells: number
stepText: string
hoursText: string
/** 悬停的那一格(UDP 和 TCP 共用);`null` = 没有。 */
hover: number | null
}
/**
* 一排小格(每 5 分钟一格,按这 5 分钟的丢包率着色:绿 / 黄 / 红 / 灰 = 没有数据)
* 尽量轻。
*/
function Strip({ rttLabel, rtt, badLabel, bad, badHint, t, badSeries, ready, cells, stepText, hoursText, hover }: StripProps) {
return (
<div className="udp-strip">
<div className="udp-head">
<span className="udp-title">
{rttLabel} <b className="num">{fmtMs(rtt)}</b>
</span>
<span className="udp-title" title={badHint}>
{badLabel} <b className={`udp-rate num ${levelOf(bad)}`}>{fmtPct(bad)}</b>
</span>
</div>
<div className="bars" role="img" aria-label={`近 ${hoursText}每 ${stepText}的${badLabel}情况`}>
{(ready ? badSeries : Array.from({ length: cells }, () => null)).map((v, i) => (
<span key={t[i] ?? i} className={`bar ${ready ? levelOf(v) : 'none'}${hover === i ? ' on' : ''}`} />
))}
</div>
</div>
)
}
interface Props {
lineId: string
range: OverviewRange
@@ -72,23 +26,23 @@ interface Props {
m: Record<string, number | null | undefined>
}
/** 一条线路近一段时间的 UDP 和 TCP:各是一排小格,共用一次请求。 */
/** 一条线路的 QUIC/TCP 双行摘要,各自显示时延、质量点阵和丢包/重传率。 */
export function LineStrips({ lineId, range, m }: Props) {
const info = OVERVIEW_RANGES.find((r) => r.value === range) ?? OVERVIEW_RANGES[0]
const series = useQuery(
() => api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { range: info.value, anchor: 'latest', step: info.step, preset: 'udp,tcp', metrics: 'loss.total,rtt.p50,tcp.retrans,tcp.rtt.p50' }),
() => api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { range: info.value, anchor: 'latest', step: info.step, preset: 'quic,tcp', metrics: 'loss.up,loss.down,loss.total,rtt.p50,rtt.p95,rtt.p99,tcp.retrans,tcp.rtt.p50' }),
[lineId, info.value],
30_000,
)
const s = series.data
const t = s?.t ?? []
const col = (key: string): (number | null)[] => s?.values[key] ?? []
// 悬停的那一格:UDP 和 TCP 的时间轴一样,所以同一格同时高亮;提示弹在卡片右侧,一次展示这段时间的 UDP 和 TCP
// 悬停的那一格:QUIC 上下行和 TCP 共用时间轴。
const [hover, setHover] = useState<number | null>(null)
const last = useRef<number | null>(null) // 提示收回去的动画期间,内容还要留着
if (hover !== null) last.current = hover
const shown = hover ?? last.current
// 整块线路区域都能触发:按鼠标横向位置对应到小格(UDP 和 TCP 的小格一样宽,量第一排就行),不用精确指到小格上
// 整块线路区域都能触发:按鼠标横向位置对应到采样格,不用精确指到小格上。
const box = useRef<HTMLDivElement>(null)
const onMove = (e: MouseEvent<HTMLDivElement>) => {
const bars = box.current?.querySelector('.bars')
@@ -98,19 +52,50 @@ export function LineStrips({ lineId, range, m }: Props) {
}
const common = { t, ready: Boolean(s), cells: Math.round((info.hours * 60) / info.stepMin), stepText: `${info.stepMin} 分钟`, hoursText: `${info.hours} 小时`, hover }
const at = shown !== null && t[shown] !== undefined ? shown : null
const lossUp = col('loss.up')
const lossDown = col('loss.down')
const retrans = col('tcp.retrans')
const up = m['loss.up']
const down = m['loss.down']
const upText = up === null || up === undefined ? '丢包 —' : `${fmtPct(up)} 丢包`
const downText = down === null || down === undefined ? '丢包 —' : `${fmtPct(down)} 丢包`
const retransValue = m['tcp.retrans']
const retransTone = levelOf(retransValue)
const retransText = retransValue === null || retransValue === undefined ? '重传 —' : `${fmtPct(retransValue)} 重传`
return (
<div className="strips" ref={box} onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
<Strip
{...common}
rttLabel="UDP 延迟 p50"
rtt={m['rtt.p50']}
badLabel="丢包"
bad={m['loss.total']}
badHint={`上行丢包 ${fmtPct(m['loss.up'])} 下行丢包 ${fmtPct(m['loss.down'])}`}
badSeries={col('loss.total')}
tipBad="丢包"
/>
<Strip {...common} rttLabel="TCP RTT p50" rtt={m['tcp.rtt.p50']} badLabel="重传" bad={m['tcp.retrans']} badSeries={col('tcp.retrans')} tipBad="重传" />
<div className="line-mini-stack">
<div className="line-mini-quic-head" title={`QUIC RTT p50 ${fmtMs(m['rtt.p50'])} · p95 ${fmtMs(m['rtt.p95'])} · p99 ${fmtMs(m['rtt.p99'])}`}>
<span>QUIC</span><strong className="num">{fmtMs(m['rtt.p50'])}</strong><small>悬停看 p95 / p99</small>
</div>
<div className="line-mini-direction-row" title={`去程 Probe → Agent 丢包 ${fmtPct(up)}`}>
<span>去程</span>
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的去程丢包`}>
{(s ? lossUp : Array.from({ length: common.cells }, () => null)).map((value, i) => (
<span key={`up-${t[i] ?? i}`} className={`bar ${s ? levelOf(value) : 'none'}${hover === i ? ' on' : ''}`} />
))}
</div>
<span className={`line-mini-badge ${levelOf(up)}`}>{upText}</span>
</div>
<div className="line-mini-direction-row" title={`回程 Agent → Probe 丢包 ${fmtPct(down)}`}>
<span>回程</span>
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的回程丢包`}>
{(s ? lossDown : Array.from({ length: common.cells }, () => null)).map((value, i) => (
<span key={`down-${t[i] ?? i}`} className={`bar ${s ? levelOf(value) : 'none'}${hover === i ? ' on' : ''}`} />
))}
</div>
<span className={`line-mini-badge ${levelOf(down)}`}>{downText}</span>
</div>
<div className="line-mini-tcp-row" title={`TCP RTT p50 ${fmtMs(m['tcp.rtt.p50'])}`}>
<span>TCP</span><strong className="num">{fmtMs(m['tcp.rtt.p50'])}</strong>
<div className="bars" role="img" aria-label={`近 ${common.hoursText}每 ${common.stepText}的 TCP 重传`}>
{(s ? retrans : Array.from({ length: common.cells }, () => null)).map((value, i) => (
<span key={`retrans-${t[i] ?? i}`} className={`bar ${s ? levelOf(value) : 'none'}${hover === i ? ' on' : ''}`} />
))}
</div>
<span className={`line-mini-badge ${retransTone}`}>{retransText}</span>
</div>
</div>
<div className={`pop num${hover !== null && at !== null ? ' open' : ''}`} role="tooltip" aria-hidden={hover === null}>
{at !== null ? (
<>
@@ -118,12 +103,15 @@ export function LineStrips({ lineId, range, m }: Props) {
{clock(t[at])}–{clock(t[at] + info.stepMin * 60_000)}
</b>
<div className="pop-group">
<span className="pop-name">UDP</span>
<span className="pop-name">QUIC Datagram</span>
<span>RTT p50 <em>{fmtMs(col('rtt.p50')[at])}</em></span>
<span>RTT p95 <em>{fmtMs(col('rtt.p95')[at])}</em></span>
<span>RTT p99 <em>{fmtMs(col('rtt.p99')[at])}</em></span>
<span>
丢包 <em className={levelOf(col('loss.total')[at])}>{fmtPct(col('loss.total')[at])}</em>
去程丢包 <em className={levelOf(col('loss.up')[at])}>{fmtPct(col('loss.up')[at])}</em>
</span>
<span>
延迟 p50 <em>{fmtMs(col('rtt.p50')[at])}</em>
回程丢包 <em className={levelOf(col('loss.down')[at])}>{fmtPct(col('loss.down')[at])}</em>
</span>
</div>
<div className="pop-group">
+2 -1
View File
@@ -209,7 +209,8 @@ export function Minimap({ domainStart, domainMin, t, values, fromMs, toMs, onSel
<path d={path} />
</svg>
{ticks
.filter((x) => x.midnight)
// Outer edges already have date labels; only draw midnight markers inside the selected domain.
.filter((x) => x.midnight && x.frac > 0 && x.frac < 1)
.map((x) => (
<div key={x.frac} className="midnight" style={{ left: `${x.frac * 100}%` }} />
))}
+613 -20
View File
@@ -70,6 +70,523 @@
padding: 40px 24px 80px;
}
/* 线路页是数据工作区:宽屏充分利用空间,图表和辅助文字保持清晰对比。 */
.line-page {
--line-secondary: color-mix(in srgb, var(--app-ink) 58%, var(--app-ink-muted));
width: min(100%, 1640px);
max-width: 1640px;
padding-top: 24px;
padding-bottom: 56px;
/* 正文背景上的全局柔光会让标题、节点状态和说明显得发虚;数据页文字保持锐利。 */
text-shadow: none;
}
.line-page .hero h1 {
font-size: 32px;
}
.line-page .line-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
}
.line-page .line-title-row h1 {
margin: 0;
font-size: clamp(24px, 2vw, 32px);
}
.line-page .line-version-switch {
gap: 6px;
}
.line-page .line-route-meta {
gap: 6px 12px;
margin-top: 6px;
font-size: 12px;
}
.line-page .line-route-status {
color: var(--app-ink);
font-weight: 650;
}
.line-page .line-range-picker {
margin-top: 8px;
}
.line-page .faint,
.line-page .chart-hint,
.line-page .chart-section h2,
.line-page .stat .k {
color: var(--line-secondary);
font-weight: 500;
}
.line-page .line-peer-name {
color: var(--line-secondary);
font-weight: 500;
}
.line-page .line-section-label {
color: var(--app-ink);
font-size: 13px;
font-weight: 700;
white-space: nowrap;
}
.line-page .line-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
padding: 8px 12px;
border-radius: var(--r-card);
background: color-mix(in srgb, var(--app-card) 78%, transparent);
}
.line-page .line-toolbar-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.line-page .line-smoothing {
margin-left: 0;
}
.line-page .line-header-surface,
.line-page .line-metric-surface {
background: var(--glass-bg);
backdrop-filter: var(--glass-filter);
box-shadow: inset 0 1px 0 var(--glass-top), var(--glass-drop);
}
.line-page .line-header-surface {
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px 20px 16px;
border-radius: var(--r-group-outer);
}
.line-page .line-metric-surface {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
border-radius: var(--r-group-outer);
}
.line-page .line-metric-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px 20px;
}
.line-page .line-metric-heading {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 12px;
}
.line-page .line-metric-heading h2 {
color: var(--app-ink);
font-size: 19px;
font-weight: 700;
}
.line-page .line-metric-heading > span {
color: var(--line-secondary);
font-size: 12px;
}
.line-page .range-toolbar {
align-items: center;
gap: 8px 14px;
}
.line-page .range-toolbar-right {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px 14px;
margin-left: auto;
}
.line-page .line-data-notes {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 14px;
color: var(--line-secondary);
font-size: 11px;
font-weight: 450;
line-height: 1.4;
}
.line-page .crumbs,
.line-page .minimap .tick,
.line-page .minimap .readout,
.line-page .chart-legend,
.line-page .stat .v small {
color: var(--line-secondary);
}
.line-page .minimap .tick {
font-size: 12px;
font-weight: 500;
}
.line-page .chart-section h2,
.line-page .chart-card h3 {
font-weight: 700;
}
.line-page .chart-card {
min-width: 0;
gap: 2px;
padding: 12px 14px;
background: color-mix(in srgb, var(--glass-line) 4%, transparent);
box-shadow: none;
backdrop-filter: none;
}
.line-page .chart-card h3 {
font-size: 14px;
color: var(--app-ink);
}
.line-page .quic-chart-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.line-page .line-quic-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.line-page .line-combo-card {
display: flex;
min-width: 0;
flex-direction: column;
justify-content: center;
gap: 5px;
min-height: 84px;
padding: 10px 14px;
border-radius: var(--r-control);
background: color-mix(in srgb, var(--glass-line) 4%, transparent);
}
.line-page .line-combo-card h3 {
color: var(--line-secondary);
font-size: 12px;
font-weight: 600;
}
.line-page .line-combo-primary {
display: flex;
align-items: baseline;
gap: 8px;
color: var(--line-secondary);
font-size: 13px;
}
.line-page .line-combo-primary strong {
color: var(--app-ink);
font-size: 24px;
font-weight: 700;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.line-page .combo-tone-ok {
color: var(--app-ok) !important;
}
.line-page .combo-tone-warn {
color: var(--app-warn) !important;
}
.line-page .combo-tone-bad {
color: var(--app-bad) !important;
}
.line-page .line-combo-secondary,
.line-page .line-jitter-pair {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 10px;
color: var(--line-secondary);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.line-page .line-combo-secondary b,
.line-page .line-jitter-pair b {
color: var(--app-ink);
font-weight: 650;
}
.line-page .combo-divider {
opacity: 0.55;
}
.line-page .line-combo-badge {
align-self: flex-start;
padding: 2px 7px;
border-radius: var(--r-pill);
background: var(--app-card-inset);
color: var(--line-secondary);
font-size: 10px;
line-height: 1.4;
font-variant-numeric: tabular-nums;
}
.line-page .line-jitter-pair {
gap: 8px 20px;
font-size: 16px;
}
.line-page .line-session-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 18px;
padding: 10px 4px 2px;
border-top: 1px solid var(--glass-head-line);
background: transparent;
color: var(--line-secondary);
font-size: 12px;
}
.line-page .line-session-title {
color: var(--app-ink);
font-weight: 700;
white-space: nowrap;
}
.line-page .line-session-items {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 5px 18px;
}
.line-page .line-session-items b {
color: var(--app-ink);
font-variant-numeric: tabular-nums;
}
.line-page .line-session-bar small {
flex-basis: 100%;
color: var(--line-secondary);
font-size: 11px;
}
.line-page .tiles {
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 14px;
}
.line-page .tiles > .stat {
box-shadow: 0 8px 24px -20px var(--app-shadow);
}
.line-page .line-metric-subhead {
grid-column: 1 / -1;
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid var(--app-outline-soft);
color: var(--app-ink);
font-size: 14px;
font-weight: 700;
}
.line-page .line-wide-note {
grid-column: 1 / -1;
}
.line-page .stack {
gap: 10px;
}
.line-page .chart-section {
gap: 6px;
}
.line-page .chart-section h2 {
margin-top: 4px;
}
.line-page .minimap {
margin-top: 6px;
}
.line-page .minimap .track {
height: 42px;
margin-bottom: 22px;
background: repeating-linear-gradient(
135deg,
color-mix(in srgb, var(--glass-bg) 94%, var(--glass-shade) 6%) 0 8px,
color-mix(in srgb, var(--glass-bg) 86%, var(--glass-shade) 14%) 8px 11px
);
}
.line-page .minimap .ticks {
height: 20px;
}
.line-page .minimap .tick {
height: 20px;
padding-top: 8px;
font-size: 11px;
letter-spacing: 0.01em;
}
.line-page .minimap .tick.date {
font-size: 11px;
font-weight: 650;
}
.line-page .minimap .readout {
display: flex;
align-items: baseline;
gap: 6px;
margin-top: 3px;
font-size: 11px;
line-height: 1.5;
letter-spacing: 0.01em;
}
.line-page .minimap .readout > span {
margin-left: 2px;
}
.line-page .minimap .sel {
border-block: 1px solid var(--pick);
border-inline: 0;
border-radius: 8px;
background: color-mix(in srgb, var(--pick) 12%, transparent);
}
.line-page .minimap .sel .h {
top: 8px;
bottom: 8px;
width: 4px;
border-radius: 2px;
}
.line-page .minimap .sel .h::before {
position: absolute;
inset: -8px -4px;
content: '';
}
.line-page .minimap .sel .h.l {
left: -2px;
}
.line-page .minimap .sel .h.r {
right: -2px;
}
.line-page .minimap svg rect.has {
fill: var(--app-card);
}
.line-page .range-picker > .row:last-child {
margin-top: 4px !important;
}
@media (max-width: 760px) {
.line-page {
padding: 20px 14px 48px;
}
.line-page .line-header-surface {
padding: 14px;
border-radius: var(--r-card);
}
.line-page .line-metric-surface {
gap: 12px;
padding: 12px;
border-radius: var(--r-card);
}
.line-page .line-metric-header {
align-items: flex-start;
}
.line-page .range-toolbar > .group {
max-width: 100%;
min-width: 0;
flex-wrap: nowrap;
overflow-x: auto;
}
.line-page .range-toolbar > .group > button,
.line-page .range-toolbar-right .group > button {
flex: none;
white-space: nowrap;
}
.line-page .range-toolbar-right {
width: 100%;
flex-wrap: nowrap;
justify-content: flex-start;
overflow-x: auto;
}
.line-page .minimap .tick {
width: max-content;
min-width: 0;
padding-right: 1px;
padding-left: 1px;
font-size: 9px;
white-space: nowrap;
}
.line-page .minimap .tick:not(.date):not(:nth-child(6n + 1)) {
display: none;
}
.line-page .hero h1 {
font-size: 28px;
}
.line-page .line-title-row {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
.line-page .line-smoothing {
margin-left: 0;
}
.line-page .line-quic-summary {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 900px) {
.line-page .quic-chart-grid {
grid-template-columns: minmax(0, 1fr);
}
}
.hero h1 {
font-size: 40px;
font-weight: 800;
@@ -716,7 +1233,7 @@
}
/* ── 线路详情的图表:按 UDP、TCP、评分分组,宽图占满一行 ── */
/* ── 线路详情的图表:按 QUIC、TCP、评分分组,宽图占满一行 ── */
.chart-section {
display: flex;
flex-direction: column;
@@ -799,7 +1316,7 @@
color: var(--app-ink-faint);
}
/* ── 总览卡片里的 UDP:一排小格(每 5 分钟一格,按丢包着色)+ 延迟小走势,尽量轻 ── */
/* ── 总览卡片里的 QUIC Datagram:按方向显示丢包小格 ── */
.line-name {
font-weight: 600;
font-size: 13px;
@@ -809,47 +1326,123 @@
font-size: 11px;
}
.udp-strip {
position: relative;
.host-link {
color: inherit;
text-decoration: none;
cursor: pointer;
transition: transform 160ms ease, box-shadow 160ms ease;
}
.host-link:hover,
.host-link:focus-visible {
color: inherit;
transform: translateY(-2px);
box-shadow: 0 12px 32px -14px color-mix(in srgb, var(--glass-shade) 75%, transparent);
}
.host-link:focus-visible {
outline: none;
}
.line-mini-stack {
display: flex;
flex-direction: column;
gap: 4px;
}
.udp-head {
.line-mini-quic-head {
display: flex;
align-items: baseline;
justify-content: space-between;
font-size: 11px;
min-width: 0;
align-items: center;
gap: 8px;
line-height: 1.2;
}
.udp-title {
color: var(--app-ink-faint);
.line-mini-quic-head > span,
.line-mini-direction-row > span:first-child,
.line-mini-tcp-row > span:first-child {
color: var(--app-ink-muted);
font-size: 10px;
font-weight: 600;
}
.udp-title b {
margin-left: 2px;
font-size: 13px;
font-weight: 700;
.line-mini-quic-head > strong,
.line-mini-tcp-row > strong {
color: var(--app-ink);
font-size: 14px;
font-weight: 700;
white-space: nowrap;
}
.udp-title b.udp-rate.ok {
.line-mini-quic-head > small {
margin-left: auto;
color: var(--app-ink-faint);
font-size: 9px;
white-space: nowrap;
}
.line-mini-direction-row {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 66px;
align-items: center;
gap: 5px;
min-width: 0;
}
.line-mini-direction-row > span:first-child,
.line-mini-tcp-row > span:first-child {
font-weight: 500;
white-space: nowrap;
}
.line-mini-tcp-row {
display: grid;
grid-template-columns: 34px 46px minmax(0, 1fr) max-content;
align-items: center;
gap: 5px;
min-width: 0;
margin-top: 2px;
padding-top: 5px;
border-top: 1px solid var(--app-outline-soft);
}
.line-mini-tcp-row > strong {
font-size: 12px;
}
.line-mini-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 1px 5px;
border-radius: var(--r-pill);
background: var(--app-card-inset);
color: var(--app-ink-muted);
font-size: 9px;
line-height: 1.4;
white-space: nowrap;
}
.line-mini-badge.ok {
background: var(--app-ok-container);
color: var(--app-ok);
}
.udp-title b.udp-rate.warn {
.line-mini-badge.warn {
background: var(--app-warn-container);
color: var(--app-warn);
}
.udp-title b.udp-rate.bad {
.line-mini-badge.bad {
background: var(--app-bad-container);
color: var(--app-bad);
}
.bars {
display: flex;
gap: 2px;
height: 10px;
min-width: 0;
gap: 1.5px;
height: 8px;
}
.bar {
@@ -875,7 +1468,7 @@
opacity: 1;
}
/* UDP 和 TCP 两段之间留一点空 */
/* QUIC 和 TCP 两段之间留一点空 */
.strips {
display: flex;
flex-direction: column;
+5 -3
View File
@@ -21,9 +21,11 @@ interface MetricInfo {
const METRICS: MetricInfo[] = [
{ id: 'rtt.p50', label: '延迟 p50', unit: 'ms', better: 'lower' },
{ id: 'rtt.p95', label: '延迟 p95', unit: 'ms', better: 'lower' },
{ id: 'loss.total', label: 'UDP 丢包', unit: '%', better: 'lower' },
{ id: 'loss.up', label: 'UDP 上行丢包', unit: '%', better: 'lower' },
{ id: 'loss.down', label: 'UDP 下行丢包', unit: '%', better: 'lower' },
{ id: 'loss.total', label: 'QUIC 丢包', unit: '%', better: 'lower' },
{ id: 'loss.up', label: 'QUIC 上行丢包', unit: '%', better: 'lower' },
{ id: 'loss.down', label: 'QUIC 下行丢包', unit: '%', better: 'lower' },
{ id: 'loss.run.up_max', label: 'QUIC 上行最长连续丢包', unit: '', better: 'lower' },
{ id: 'loss.run.down_max', label: 'QUIC 下行最长连续丢包', unit: '', better: 'lower' },
{ id: 'jitter.up.p95', label: '上行抖动', unit: 'ms', better: 'lower' },
{ id: 'jitter.down.p95', label: '下行抖动', unit: 'ms', better: 'lower' },
{ id: 'tcp.rtt.p50', label: 'TCP RTT p50', unit: 'ms', better: 'lower' },
+89
View File
@@ -0,0 +1,89 @@
import { ChevronLeft } from 'lucide-react'
import { useMemo } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import { TimeChart } from '@shared/charts/TimeChart'
import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts } from '../live'
import { useQuery } from '@shared/hooks'
import { Chip, Dot, Segmented, Spinner, Stat } from '@shared/ui'
import { fmtAgo } from '@shared/format'
import { OsIcon } from '@shared/osIcon'
import { Flag, IspIcon, useNodeInfos } from '@shared/nodes'
import { PageError } from '../lib'
interface HostHistory {
t: number[]
values: Record<string, (number | null)[]>
}
const RANGES = [
{ value: '1h', label: '近 1 小时' },
{ value: '6h', label: '近 6 小时' },
{ value: '24h', label: '近 24 小时' },
{ value: '7d', label: '近 7 天' },
] as const
export function Host() {
const { id = '' } = useParams()
const [params, setParams] = useSearchParams()
const range = RANGES.find((item) => item.value === params.get('range'))?.value ?? '24h'
const { hosts, live } = useHosts()
const infos = useNodeInfos()
const node = hosts.get(id)
const info = infos.get(id)
const history = useQuery(() => api.get<HostHistory>(`/api/public/hosts/${encodeURIComponent(id)}`, { range }), [id, range], 30_000)
const data = history.data
const usage = useMemo(() => data ? [
{ label: 'CPU', values: data.values.cpu ?? [], color: '--chart-1' },
{ label: '内存', values: data.values.mem ?? [], color: '--chart-2' },
{ label: '磁盘', values: data.values.disk ?? [], color: '--chart-4' },
] : [], [data])
const network = useMemo(() => data ? [
{ label: '上行', values: data.values.tx_bps ?? [], color: '--chart-2' },
{ label: '下行', values: data.values.rx_bps ?? [], color: '--chart-1' },
] : [], [data])
const host = node?.host
return (
<main className="page">
<div className="crumbs"><Link to="/" className="row"><ChevronLeft size={16} />总览</Link></div>
<section className="hero">
<h1>{node?.name ?? id}</h1>
<div className="row wrap" style={{ marginTop: 12 }}>
{info?.country ? <Flag code={info.country} /> : null}
{info?.isp_key ? <IspIcon ispKey={info.isp_key} name={info.isp_name} /> : null}
{info?.os_name ? <span className="row"><OsIcon os={info.os} name={info.os_name} />{[info.os_name, info.arch].filter(Boolean).join(' / ')}</span> : null}
{node ? <><Chip>{node.kind === 'agent' ? 'Agent' : 'Probe'}</Chip><Dot tone={node.online ? 'ok' : 'bad'} /><span className="muted">{node.online ? (node.stale ? '状态数据暂未更新' : '在线') : '离线'}</span></> : <span className="muted">节点信息加载中…</span>}
{node?.last_seen_at && !node.online ? <span className="faint">最后在线 {fmtAgo(node.last_seen_at)}</span> : null}
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
</div>
</section>
<PageError error={history.error} />
{host ? <section className="stack" style={{ marginTop: 24 }}>
<div className="tiles">
<Stat label="CPU" value={host.cpu_percent === null ? '—' : host.cpu_percent.toFixed(1)} unit="%" />
<Stat label="内存" value={pct(host.mem_used, host.mem_total)?.toFixed(1) ?? '—'} unit="%" />
<Stat label="磁盘" value={pct(host.disk_used, host.disk_total)?.toFixed(1) ?? '—'} unit="%" />
<Stat label="累计流量 ↑ / ↓" value={`${fmtBytes(host.net_tx_total)} / ${fmtBytes(host.net_rx_total)}`} />
<Stat label="当前网速 ↑ / ↓" value={`${fmtSpeed(node?.online ? host.net_tx_bps : null)} / ${fmtSpeed(node?.online ? host.net_rx_bps : null)}`} />
<Stat label="链路速率" value={host.net_link_mbps ? `${host.net_link_mbps} Mbit/s` : '—'} />
<Stat label="运行时间" value={fmtUptime(host.uptime_seconds)} />
<Stat label="状态更新时间" value={fmtAgo(host.at)} />
</div>
</section> : <p className="faint" style={{ marginTop: 24 }}>尚无主机资源快照;节点上线并上报状态后会显示实时数据。</p>}
<section className="card host-history" style={{ marginTop: 20 }}>
<div className="agent-head">
<span className="agent-name">资源历史</span>
<span className="right"><Segmented options={RANGES.map((item) => item)} value={range} onChange={(value) => setParams((prev) => { const next = new URLSearchParams(prev); next.set('range', value); return next }, { replace: true })} /></span>
{history.loading ? <Spinner /> : null}
</div>
{data ? <div className="grid cols-2 host-history-charts">
<div><h2>CPU、内存与磁盘</h2><TimeChart t={data.t} unit="%" series={usage} /></div>
<div><h2>网络吞吐</h2><TimeChart t={data.t} unit=" B/s" series={network} /></div>
</div> : !history.loading ? <p className="faint">所选时间段暂无历史数据。</p> : null}
</section>
</main>
)
}
+116 -141
View File
@@ -1,5 +1,5 @@
import { ChevronLeft } from 'lucide-react'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { IP_VERSIONS, lineIdFor } from '@shared/ipVersions'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { api } from '@shared/api/client'
@@ -7,7 +7,6 @@ import type { LineDto, TestDto } from '@shared/api/types'
import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart'
import { fmtAgo, fmtMbps, fmtMs, fmtNum, fmtPct, fmtTime } from '@shared/format'
import { NodeTag, useNodeInfos } from '@shared/nodes'
import { fmtBytes, useHosts } from '../live'
import { useQuery } from '@shared/hooks'
import { Chip, Dot, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui'
import { PageError, ScoreChips } from '../lib'
@@ -24,28 +23,25 @@ function tone(value: number | null | undefined, good: number, poor: number): 'ok
return 'warn'
}
interface HostSeries {
t: number[]
values: Record<string, (number | null)[]>
}
/** 指标卡片:好坏的分界(越低越好)和显示格式。 */
const TILES: { label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string; /** 这个线路没有数据时不显示(建连任务不是每条线路都开着)。 */ optional?: boolean }[] = [
{ label: 'UDP 延迟 p50', key: 'rtt.p50', fmt: fmtMs },
{ label: 'UDP 延迟 p95', key: 'rtt.p95', fmt: fmtMs },
{ label: 'UDP 丢包', key: 'loss.total', fmt: fmtPct, good: 0.5, poor: 3 },
{ label: 'UDP 上行丢包', key: 'loss.up', fmt: fmtPct, good: 0.5, poor: 3 },
{ label: 'UDP 下行丢包', key: 'loss.down', fmt: fmtPct, good: 0.5, poor: 3 },
{ label: 'UDP 最长连续丢包', key: 'loss.run_max', fmt: fmtNum, unit: '包' },
{ label: 'UDP 上行抖动 p95', key: 'jitter.up.p95', fmt: fmtMs },
{ label: 'UDP 下行抖动 p95', key: 'jitter.down.p95', fmt: fmtMs },
{ label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs },
{ label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs },
{ label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs },
{ label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 },
{ label: 'TCP 建连 p95', key: 'connect.tcp_ms.p95', fmt: fmtMs, optional: true },
{ label: 'TLS 握手 p95', key: 'connect.tls_ms.p95', fmt: fmtMs, optional: true },
{ label: '建连成功率', key: 'connect.success', fmt: fmtPct, optional: true },
type MetricGroup = 'quic' | 'tcp-connect' | 'score'
const METRIC_GROUPS: { value: MetricGroup; label: string }[] = [
{ value: 'quic', label: 'QUIC' },
{ value: 'tcp-connect', label: 'TCP' },
{ value: 'score', label: '评分' },
]
const QUIC_METRICS = 'rtt.p50,rtt.p95,rtt.p99,loss.total,loss.up,loss.down,loss.run.up_max,loss.run.down_max,jitter.up.p95,jitter.down.p95'
const TILES: { group: Exclude<MetricGroup, 'score'>; label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string; /** 这个线路没有数据时不显示(建连任务不是每条线路都开着)。 */ optional?: boolean }[] = [
{ group: 'tcp-connect', label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs },
{ group: 'tcp-connect', label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs },
{ group: 'tcp-connect', label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs },
{ group: 'tcp-connect', label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 },
{ group: 'tcp-connect', label: 'TCP 建连 p95', key: 'connect.tcp_ms.p95', fmt: fmtMs, optional: true },
{ group: 'tcp-connect', label: 'TLS 握手 p95', key: 'connect.tls_ms.p95', fmt: fmtMs, optional: true },
{ group: 'tcp-connect', label: '建连成功率', key: 'connect.success', fmt: fmtPct, optional: true },
]
interface ChartDef {
@@ -55,26 +51,26 @@ interface ChartDef {
zero?: boolean
fill?: boolean
series: { key: string; label: string; color: string; dash?: number[] }[]
group: 'udp' | 'tcp' | 'connect' | 'score'
group: MetricGroup
/** 占满一行。 */
wide?: boolean
}
const GROUPS = [
{ id: 'udp', title: 'UDP(往返探测)' },
{ id: 'tcp', title: 'TCP' },
{ id: 'connect', title: '建连(TCP 握手、TLS 握手)' },
{ id: 'quic', title: 'QUIC Datagram' },
{ id: 'tcp-connect', title: 'TCP' },
{ id: 'score', title: '评分' },
] as const
const CHARTS: ChartDef[] = [
{ id: 'rtt', title: '延迟', group: 'udp', wide: true, unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] },
{ id: 'loss', title: '丢包', group: 'udp', unit: '%', series: [{ key: 'loss.up', label: '上行', color: '--chart-2' }, { key: 'loss.down', label: '下行', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] },
{ id: 'jitter', title: '抖动(p95)', group: 'udp', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] },
{ id: 'tcp', title: '往返时延(RTT)', group: 'tcp', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] },
{ id: 'retrans', title: '重传率', group: 'tcp', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] },
{ id: 'connect-time', title: '建连耗时(p95)', group: 'connect', unit: 'ms', series: [{ key: 'connect.tcp_ms.p95', label: 'TCP 建连', color: '--chart-1' }, { key: 'connect.tls_ms.p95', label: 'TLS 握手', color: '--chart-2' }] },
{ id: 'connect-ok', title: '建连成功率', group: 'connect', unit: '%', zero: false, series: [{ key: 'connect.success', label: '成功率', color: '--chart-3' }] },
{ id: 'rtt', title: '延迟', group: 'quic', unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] },
{ id: 'jitter', title: '抖动(p95)', group: 'quic', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] },
{ id: 'loss', title: '丢包', group: 'quic', unit: '%', series: [{ key: 'loss.up', label: '上行', color: '--chart-2' }, { key: 'loss.down', label: '下行', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] },
{ id: 'loss-runs', title: '最长连续丢包', group: 'quic', unit: '包', series: [{ key: 'loss.run.up_max', label: '上行', color: '--chart-2' }, { key: 'loss.run.down_max', label: '下行', color: '--chart-3' }] },
{ id: 'tcp', title: '往返时延(RTT)', group: 'tcp-connect', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] },
{ id: 'retrans', title: '重传率', group: 'tcp-connect', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] },
{ id: 'connect-time', title: '建连耗时(p95)', group: 'tcp-connect', unit: 'ms', series: [{ key: 'connect.tcp_ms.p95', label: 'TCP 建连', color: '--chart-1' }, { key: 'connect.tls_ms.p95', label: 'TLS 握手', color: '--chart-2' }] },
{ id: 'connect-ok', title: '建连成功率', group: 'tcp-connect', unit: '%', zero: false, series: [{ key: 'connect.success', label: '成功率', color: '--chart-3' }] },
{ id: 'score', title: '综合评分', group: 'score', wide: true, unit: '', series: [{ key: 'score.reality', label: 'REALITY', color: '--chart-1' }, { key: 'score.hy2', label: 'Hy2', color: '--chart-2' }, { key: 'score.mc', label: 'MC', color: '--chart-4' }, { key: 'score.base', label: '综合', color: '--chart-5', dash: [4, 3] }] },
]
@@ -100,58 +96,10 @@ function ewma(values: (number | null)[], alpha: number): (number | null)[] {
})
}
/** 主机状态:实时数据(SSE)每隔几秒来一次,只重绘这一块,不影响上面的图表。 */
function HostCard({ agentId, range }: { agentId: string | undefined; range: string | null }) {
const history = useQuery(
() => (agentId && range ? api.get<HostSeries>(`/api/public/hosts/${agentId}`, { range }) : Promise.resolve(undefined)),
[agentId, range],
30_000,
)
const { hosts } = useHosts()
const host = agentId ? hosts.get(agentId)?.host : undefined
const data = history.data
const charts = useMemo(
() =>
data
? {
usage: [
{ label: 'CPU', values: data.values['cpu'] ?? [], color: '--chart-1' },
{ label: '内存', values: data.values['mem'] ?? [], color: '--chart-2' },
{ label: '磁盘', values: data.values['disk'] ?? [], color: '--chart-4' },
],
speed: [
{ label: '上行', values: data.values['tx_bps'] ?? [], color: '--chart-2' },
{ label: '下行', values: data.values['rx_bps'] ?? [], color: '--chart-1' },
],
}
: undefined,
[data],
)
if (!host && !data) return null
return (
<div className="card">
<div className="card-title">主机状态</div>
{host ? (
<div className="grid stats">
<Stat label="CPU" value={host.cpu_percent === null ? '—' : host.cpu_percent.toFixed(0)} unit="%" />
<Stat label="内存" value={host.mem_total ? ((100 * (host.mem_used ?? 0)) / host.mem_total).toFixed(0) : '—'} unit="%" />
<Stat label="磁盘" value={host.disk_total ? ((100 * (host.disk_used ?? 0)) / host.disk_total).toFixed(0) : '—'} unit="%" />
<Stat label="累计流量 ↑ / ↓" value={`${fmtBytes(host.net_tx_total)} / ${fmtBytes(host.net_rx_total)}`} />
</div>
) : null}
{data && charts ? (
<div className="grid cols-2" style={{ marginTop: 16 }}>
<TimeChart t={data.t} unit="%" series={charts.usage} />
<TimeChart t={data.t} unit=" B/s" series={charts.speed} />
</div>
) : null}
</div>
)
}
export function Line() {
const { id = '' } = useParams()
const [params, setParams] = useSearchParams()
const [metricGroup, setMetricGroup] = useState<MetricGroup>('quic')
const line = useQuery(() => api.get<LineDto>(`/api/public/lines/${encodeURIComponent(id)}`), [id], 15_000)
const infos = useNodeInfos()
@@ -162,7 +110,13 @@ export function Line() {
const spec = readSpec(params)
const period = usePeriod(id, ready, sinceMs, nowMs, spec)
const series = usePeriodSeries(id, ready, period)
const series = usePeriodSeries(
id,
ready,
period,
metricGroup === 'score' ? 'scores' : metricGroup === 'tcp-connect' ? 'tcp,connect' : metricGroup,
metricGroup === 'quic' ? QUIC_METRICS : undefined,
)
const update = (fn: (q: URLSearchParams) => void) => {
const q = new URLSearchParams(params)
@@ -198,7 +152,6 @@ export function Line() {
writeSpec(q, { ...current, span: scale === 24 ? '24' : null, at: scale === 24 && period.domainMin > 1440 ? null : current.at })
})
const agentId = line.data?.agent_id
const lastTestId = line.data?.last_test?.id
const test = useQuery(() => (lastTestId ? api.get<TestDto>(`/api/public/tests/${lastTestId}`) : Promise.resolve(undefined)), [lastTestId])
@@ -211,7 +164,7 @@ export function Line() {
const charts = useMemo(
() =>
s
? GROUPS.map((group) => {
? GROUPS.filter((group) => group.id === metricGroup).map((group) => {
const cards = CHARTS.filter((def) => def.group === group.id).map((def) => {
const raw = def.series.map((x) => ({ x, values: s.values[x.key] ?? [] }))
// 开了 EWMA:原始数据画成淡线垫在后面,平滑后的线画在上面
@@ -223,20 +176,19 @@ export function Line() {
]
: raw.map(({ x, values }) => ({ label: x.label, values, color: x.color, dash: x.dash, fill: def.fill }))
if (!list.some((x) => x.values.some((v) => v !== null))) return null
return { def, node: <TimeChart t={s.t} series={list} unit={def.unit} gaps={gaps} zero={def.zero !== false} height={def.wide ? 260 : 220} /> }
return { def, node: <TimeChart t={s.t} series={list} unit={def.unit} gaps={gaps} zero={def.zero !== false} height={metricGroup === 'quic' ? 220 : def.wide ? 230 : 190} /> }
})
return { group, cards: cards.filter((c): c is NonNullable<typeof c> => c !== null) }
}).filter((g) => g.cards.length > 0)
: [],
[s, gaps, smooth.alpha],
[s, gaps, smooth.alpha, metricGroup],
)
const loadingNow = !ready || series.busy
const chartSections = charts.map(({ group, cards }) => (
<section className="chart-section" key={group.id}>
<h2>{group.title}</h2>
<div className="grid cols-2">
<div className={`grid cols-2 ${group.id === 'quic' ? 'quic-chart-grid' : ''}`}>
{cards.map(({ def, node }) => (
<div className={`card chart-card has-loading ${def.wide ? 'wide' : ''}`} key={def.id}>
<div className={`card chart-card has-loading ${def.wide || cards.length === 1 ? 'wide' : ''}`} key={def.id}>
<LoadingBar active={loadingNow} />
<h3>{def.title}</h3>
{node}
@@ -250,7 +202,7 @@ export function Line() {
const peak = result?.metrics?.['tcp.bulk.peak.max']
return (
<main className="page">
<main className="page line-page">
<div className="crumbs">
<Link to={id.endsWith('~ipv6') ? '/?ip_version=ipv6' : '/'} className="row">
<ChevronLeft size={16} />
@@ -258,12 +210,13 @@ export function Line() {
</Link>
</div>
<section className="hero">
<h1>
{line.data?.name ?? '线路'}
{line.data ? <span className="muted" style={{ fontWeight: 400 }}> → {line.data.probe_name}</span> : null}
</h1>
<div className="row" style={{ marginTop: 12 }}>
<section className="hero line-header-surface">
<div className="line-title-row">
<h1>
{line.data?.name ?? '线路'}
{line.data ? <span className="muted line-peer-name"> → {line.data.probe_name}</span> : null}
</h1>
<div className="row line-version-switch">
{IP_VERSIONS.map((v) => {
const query = new URLSearchParams(params)
query.set('ip_version', v.value)
@@ -274,11 +227,12 @@ export function Line() {
)
})}
{line.data && !line.data.ipv6_available && id.endsWith('~ipv6') ? <span className="muted">此 Agent 未配置 IPv6 地址</span> : null}
</div>
</div>
{line.data ? (
<div className="row wrap" style={{ marginTop: 12 }}>
<div className="row wrap line-route-meta">
<Dot tone={line.data.online && line.data.probe_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
<span className="muted">{line.data.online ? (line.data.probe_connected ? '在线' : 'Agent 在线,测量通道未建立(Probe 没能和它建立测量会话;机器可能是通的,ping 通不代表测量用的 TCP 和 UDP 通道可用)') : 'Agent 离线'}</span>
<span className="line-route-status">{line.data.online ? (line.data.probe_connected ? '在线' : '测量未连接') : '离线'}</span>
<span className="row" style={{ gap: 6 }}>
<span className="faint">Agent</span>
<NodeTag info={infos.get(line.data.agent_id)} />
@@ -293,7 +247,7 @@ export function Line() {
{series.loading && !s ? <Spinner /> : null}
</div>
) : null}
<div style={{ marginTop: 16 }}>
<div className="line-range-picker">
<RangePicker
dayStart={period.dayStart}
fromMs={period.fromMs}
@@ -305,6 +259,16 @@ export function Line() {
sinceMs={sinceMs}
nowMs={nowMs}
spanMs={period.spanMs}
toolbar={
<div className="line-toolbar-group line-smoothing">
<span className="line-section-label" title="指数加权移动平均:把线条磨平,看清趋势;原始数据以淡线垫在后面">平滑</span>
<Segmented<EwmaValue>
options={EWMA.map((x) => ({ value: x.value, label: x.label }))}
value={smooth.value}
onChange={(v) => update((q) => (v === 'off' ? q.delete('ewma') : q.set('ewma', v)))}
/>
</div>
}
>
<Minimap
domainStart={period.domainStart}
@@ -346,35 +310,62 @@ export function Line() {
) : null}
{s ? (
<div className="stack" style={{ marginTop: 24 }}>
<div className="tiles">
<div className="tile scores-tile has-loading">
<LoadingBar active={loadingNow} />
<span className="k">评分</span>
<ScoreChips metrics={m} />
<div className="stack line-metrics" style={{ marginTop: 16 }}>
<section className="line-metric-surface">
<header className="line-metric-header">
<div className="line-metric-heading">
<h2>{GROUPS.find((group) => group.id === metricGroup)?.title ?? '线路指标'}</h2>
{metricGroup === 'quic' ? <span>往返时延 · 双向丢包 · 抖动</span> : null}
</div>
<Segmented<MetricGroup> options={METRIC_GROUPS} value={metricGroup} onChange={setMetricGroup} />
</header>
{metricGroup === 'quic' ? <>
<div className="line-quic-summary">
<div className="line-combo-card">
<h3>QUIC RTT</h3>
<div className="line-combo-primary"><span>p50</span><strong>{fmtMs(m['rtt.p50'])}</strong></div>
<div className="line-combo-secondary"><span>p95 <b>{fmtMs(m['rtt.p95'])}</b></span><span className="combo-divider">|</span><span>p99 <b>{fmtMs(m['rtt.p99'])}</b></span></div>
</div>
<div className="line-combo-card">
<h3>QUIC Datagram 丢包</h3>
<div className="line-combo-primary"><span>总丢包</span><strong className={`combo-tone-${tone(m['loss.total'], 0.5, 3)}`}>{fmtPct(m['loss.total'])}</strong></div>
<div className="line-combo-secondary"><span>↑ {fmtPct(m['loss.up'])}</span><span>/</span><span>↓ {fmtPct(m['loss.down'])}</span></div>
<span className="line-combo-badge">最长连续 ↑ {fmtNum(m['loss.run.up_max'])} / ↓ {fmtNum(m['loss.run.down_max'])} 包</span>
</div>
<div className="line-combo-card">
<h3>QUIC 抖动 p95</h3>
<div className="line-jitter-pair"><span>↑ <b>{fmtMs(m['jitter.up.p95'])}</b></span><span>↓ <b>{fmtMs(m['jitter.down.p95'])}</b></span></div>
</div>
</div>
{TILES.filter((t) => !t.optional || m[t.key] != null).map((t) => (
<div className="line-session-bar" title="会话计数从 Probe 本次启动开始;传输疑似丢包率不等同于 Datagram 应用丢包率,连接迁移成功率暂不采集。">
<strong className="line-session-title">QUIC 会话与传输</strong>
{line.data?.quic_health ? (() => {
const h = line.data.quic_health!
return <>
<div className="line-session-items">
<span>握手 p95 <b>{fmtMs(h.handshake_p95_ms)}</b></span>
<span>成功率 <b>{fmtPct(h.handshake_success_rate)}</b></span>
<span>超时 <b>{fmtPct(h.handshake_timeout_rate)}</b></span>
<span>失败 <b>{fmtPct(h.handshake_failure_rate)}</b></span>
<span>传输疑似丢包 <b>{fmtPct(h.transport_packet_loss_rate)}</b></span>
<span>路径 MTU <b>{fmtNum(h.path_mtu)} B</b></span>
</div>
</>
})() : <span className="line-session-empty">Probe 尚未上报会话数据</span>}
</div>
</> : null}
{metricGroup !== 'quic' ? <div className="tiles">
{metricGroup === 'score' ? <div className="tile scores-tile has-loading"><LoadingBar active={loadingNow} /><span className="k">评分</span><ScoreChips metrics={m} /></div> : null}
{TILES.filter((t) => t.group === metricGroup && (!t.optional || m[t.key] != null)).map((t) => (
<Stat key={t.key} label={t.label} value={t.fmt(m[t.key])} unit={t.unit} tone={t.good !== undefined ? tone(m[t.key], t.good, t.poor ?? Infinity) : ''} />
))}
</div>
<p className="faint">
样本数:UDP {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])}
</p>
{s.summary.coverage < 0.95 ? <p className="faint">这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。</p> : null}
<div className="row" style={{ justifyContent: 'flex-end', gap: 8 }}>
<span className="faint" title="指数加权移动平均:把线条磨平,看清趋势;原始数据以淡线垫在后面">
EWMA 平滑
</span>
<Segmented<EwmaValue>
options={EWMA.map((x) => ({ value: x.value, label: x.label }))}
value={smooth.value}
onChange={(v) => update((q) => (v === 'off' ? q.delete('ewma') : q.set('ewma', v)))}
/>
</div> : null}
<div className="line-data-notes">
<span>样本数:QUIC Datagram {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])}</span>
{s.summary.coverage < 0.95 ? <span>数据覆盖率 {(s.summary.coverage * 100).toFixed(0)}%;阴影部分为无数据时段</span> : null}
</div>
{chartSections}
<HostCard agentId={agentId} range={period.hostRange} />
</section>
{test.data && result ? (
<div className="card">
@@ -392,22 +383,6 @@ export function Line() {
</div>
) : null}
{s.events.length > 0 ? (
<div className="card">
<div className="card-title">事件</div>
<div className="event-list">
{s.events.slice(0, 20).map((e) => (
<div key={e.id}>
<span className="faint num" style={{ minWidth: 120 }}>
{fmtTime(e.at)}
</span>
<span>{e.kind}</span>
{e.detail ? <span className="muted mono">{e.detail}</span> : null}
</div>
))}
</div>
</div>
) : null}
</div>
) : null}
</main>
+5 -5
View File
@@ -12,7 +12,7 @@ import { PageError } from '../lib'
import { IPV6_STATES, IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips'
const METRICS = ['rtt.p50', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',')
const METRICS = ['rtt.p50', 'rtt.p95', 'rtt.p99', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',')
type SortKey = 'name' | 'rtt' | 'loss'
@@ -53,7 +53,7 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
const stale = !node.online || node.stale
const info = infos.get(node.id)
return (
<div className={`card agent-card ${node.online ? '' : 'offline'}`}>
<Link to={`/host/${encodeURIComponent(node.id)}`} className={`card agent-card host-link ${node.online ? '' : 'offline'}`} aria-label={`查看 ${node.name} 主机状态`}>
<div className="agent-head">
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
{info?.country ? <Flag code={info.country} /> : <Dot tone={node.online ? 'ok' : 'bad'} />}
@@ -133,11 +133,11 @@ function NodeCard({ node, labels, infos }: { node: HostItem; labels: string[]; i
<div className="faint">这台 {KIND_NAME[node.kind]} 没有上报主机状态</div>
)}
</div>
</div>
</Link>
)
}
/** 一条线路(Probe → Agent):近一段时间的 UDP 丢包和延迟走势、TCP 的 RTT 和重传。 */
/** 一条线路(Probe → Agent):近一段时间的 QUIC Datagram 上下行丢包、RTT 和 TCP 重传。 */
function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
const probeInfo = infos.get(l.probe_id)
const agentInfo = infos.get(l.agent_id)
@@ -153,7 +153,7 @@ function LineCard({ l, range, infos }: { l: OverviewLineDto; range: OverviewRang
<span className="name">{l.name}</span>
</span>
{!l.probe_connected && l.online ? (
<span title="Agent 在线,但这个 Probe 没能和它建立测量会话(控制通道连不上或握手超时)。机器本身可能是通的、ping 也能通,这里说的是测量用的 TCP 和 UDP 通道,线路或运营商对它们的处理可能和 ping 不一样。">
<span title="Agent 在线,但这个 Probe 没能和它建立测量会话(控制通道连不上或握手超时)。机器本身可能是通的、ping 也能通,这里说的是测量用的 TCP 控制通道和 QUIC Datagram 数据通道,线路或运营商对它们的处理可能和 ping 不一样。">
<Chip tone="warn">测量通道未建立</Chip>
</span>
) : (
+4 -4
View File
@@ -98,7 +98,7 @@ export function usePeriod(lineId: string, ready: boolean, sinceMs: number | null
from: new Date(Math.max(domainStart, sinceMs === null ? domainStart : dayBounds(sinceMs).start)).toISOString(),
to: new Date(domainEnd).toISOString(),
step: wide ? '2m' : '1m',
preset: 'udp',
preset: 'quic',
metrics: 'rtt.p50,loss.total',
})
: Promise.resolve(undefined),
@@ -171,10 +171,10 @@ export function usePeriod(lineId: string, ready: boolean, sinceMs: number | null
}
/** 这一段的序列;今天的「最近一段」会定时刷新。 */
export function usePeriodSeries(lineId: string, ready: boolean, period: Period) {
export function usePeriodSeries(lineId: string, ready: boolean, period: Period, preset = 'quic,tcp,connect,scores', metrics?: string) {
return useQuery(
() => (ready ? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { ...period.query, preset: 'udp,tcp,connect,scores' }) : Promise.resolve(undefined)),
[lineId, JSON.stringify(period.query), ready],
() => (ready ? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { ...period.query, ...(metrics ? { metrics } : { preset }) }) : Promise.resolve(undefined)),
[lineId, JSON.stringify(period.query), ready, preset, metrics],
period.isToday ? (period.win === 'day' ? 60_000 : 10_000) : undefined,
)
}
+15 -10
View File
@@ -107,11 +107,13 @@ interface Props {
nowMs: number
/** 这一天里数据的跨度(毫秒);未知或没有数据是 `null`。 */
spanMs: number | null
/** 跟时长快捷键和刻度选项放在同一行的辅助控件。 */
toolbar?: ReactNode
/** 放在日期和档位下面(缩略图)。 */
children?: ReactNode
}
export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale, onScale, sinceMs, nowMs, spanMs, children }: Props) {
export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale, onScale, sinceMs, nowMs, spanMs, toolbar, children }: Props) {
const picker = useRef<HTMLInputElement>(null)
const today = dayBounds(nowMs).start
const firstDay = sinceMs === null ? today : dayBounds(sinceMs).start
@@ -157,7 +159,7 @@ export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale,
) : null}
</div>
{children}
<div className="row wrap" style={{ marginTop: 8, justifyContent: 'space-between' }}>
<div className="row wrap range-toolbar" style={{ marginTop: 8, justifyContent: 'space-between' }}>
<Segmented
options={WINDOWS.map((w) => {
const ok = available.includes(w.value)
@@ -166,14 +168,17 @@ export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale,
value={active}
onChange={onWindow}
/>
<Segmented<'24' | '48'>
options={[
{ value: '24', label: '24 小时刻度', title: '缩略图显示一天' },
{ value: '48', label: '48 小时刻度', title: '缩略图显示连续两天,选区可以跨零点' },
]}
value={String(scale) as '24' | '48'}
onChange={(v) => onScale(v === '48' ? 48 : 24)}
/>
<div className="range-toolbar-right">
{toolbar}
<Segmented<'24' | '48'>
options={[
{ value: '24', label: '24 小时刻度', title: '缩略图显示一天' },
{ value: '48', label: '48 小时刻度', title: '缩略图显示连续两天,选区可以跨零点' },
]}
value={String(scale) as '24' | '48'}
onChange={(v) => onScale(v === '48' ? 48 : 24)}
/>
</div>
</div>
</div>
)
+3
View File
@@ -16,6 +16,9 @@ export default defineConfig(({ mode }) => {
server: {
port: 5173,
strictPort: true,
// WSL cannot reliably receive inotify events for source files on /mnt/c.
// Poll only in WSL so Windows edits trigger HMR without slowing native hosts.
watch: process.env.WSL_DISTRO_NAME ? { usePolling: true, interval: 500 } : undefined,
fs: { allow: ['..'] },
proxy: {
// 开发时管理端是另一个 Vite 服务(5175);和线上一样用同源的 /admin/ 访问它
+2
View File
@@ -160,6 +160,8 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
(u) => {
const { ctx } = u
ctx.save()
// Coverage gaps are context only; keep the warm mask subtle so spikes stay prominent.
ctx.globalAlpha = 0.16
ctx.fillStyle = shade
for (const g of latest.current.gaps) {
const x0 = Math.max(u.bbox.left, u.valToPos(g.from / 1000, 'x', true))