web: show the adaptive probe-rate audit on the line page [CI]
This commit is contained in:
1 parent
9ad34acbac
commit
af385cf688
4 files changed
+292
No files matched your search
@@ -0,0 +1,183 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { AdaptiveBucketDto, AdaptiveDto } from '@shared/api/types'
|
||||
import { fmtAgo } from '@shared/format'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Segmented } from '@shared/ui'
|
||||
|
||||
const WINDOWS = [
|
||||
{ value: '24h', label: '24 小时', step: '30m' },
|
||||
{ value: '48h', label: '48 小时', step: '1h' },
|
||||
{ value: '7d', label: '7 天', step: '3h' },
|
||||
{ value: '30d', label: '30 天', step: '12h' },
|
||||
] as const
|
||||
type Window = (typeof WINDOWS)[number]['value']
|
||||
|
||||
const STATES = [
|
||||
{ key: 'quiet', label: '平稳', level: 'ok' },
|
||||
{ key: 'watch', label: '观察', level: 'warn' },
|
||||
{ key: 'active', label: '活跃', level: 'bad' },
|
||||
{ key: 'dead', label: '不通', level: 'none' },
|
||||
] as const
|
||||
type StateKey = (typeof STATES)[number]['key']
|
||||
|
||||
const KIND_NAMES: Record<string, string> = { quic_ping: 'QUIC 往返', tcp_ping: 'TCP 往返', tcp_connect: 'TCP 建连', http_request: 'HTTP 请求' }
|
||||
const STATE_LABEL: Record<string, string> = { quiet: '平稳', watch: '观察', active: '活跃', dead: '不通' }
|
||||
|
||||
const secondsOf = (b: AdaptiveBucketDto, state: StateKey) => b[`${state}_seconds` as const]
|
||||
|
||||
/** 一个桶里占时最长的状态;没有任何记录(节点那段时间没有变化可报,也不知道状态)是 null。 */
|
||||
function dominant(b: AdaptiveBucketDto): StateKey | null {
|
||||
let best: StateKey | null = null
|
||||
let seconds = 0
|
||||
for (const s of STATES) {
|
||||
const v = secondsOf(b, s.key)
|
||||
if (v > seconds) {
|
||||
seconds = v
|
||||
best = s.key
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
const hhmm = (iso: string, withDate: boolean) => {
|
||||
const d = new Date(iso)
|
||||
const time = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
return withDate ? `${d.getMonth() + 1}/${d.getDate()} ${time}` : time
|
||||
}
|
||||
|
||||
function Row({ kind, buckets, step }: { kind: string; buckets: AdaptiveBucketDto[]; step: string }) {
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const stepMs = buckets.length > 1 ? Date.parse(buckets[1].start) - Date.parse(buckets[0].start) : 3_600_000
|
||||
const at = hover === null ? null : buckets[hover]
|
||||
// 刻度:每个本地零点、以及(短窗口时)每 6 小时一个
|
||||
const ticks = buckets.flatMap((b, i) => {
|
||||
const d = new Date(b.start)
|
||||
const midnight = d.getHours() === 0 && d.getMinutes() === 0
|
||||
const sixHour = stepMs <= 3_600_000 * 3 && d.getMinutes() === 0 && d.getHours() % 6 === 0
|
||||
return midnight || sixHour ? [{ pos: (i / buckets.length) * 100, label: midnight ? `${d.getMonth() + 1}/${d.getDate()}` : `${String(d.getHours()).padStart(2, '0')}:00` }] : []
|
||||
})
|
||||
return (
|
||||
<div className="adaptive-row">
|
||||
<div className="adaptive-name">{KIND_NAMES[kind] ?? kind}</div>
|
||||
<div className="adaptive-body" onMouseLeave={() => setHover(null)}>
|
||||
<div className="bars" style={{ ['--cells' as string]: buckets.length }} role="img" aria-label={`${KIND_NAMES[kind] ?? kind} 自适应状态,每格 ${step}`}>
|
||||
{buckets.map((b, i) => {
|
||||
const state = dominant(b)
|
||||
const level = state === null ? 'none' : STATES.find((s) => s.key === state)!.level
|
||||
return <span key={b.start} className={`bar ${level}${hover === i ? ' on' : ''}`} onMouseEnter={() => setHover(i)} />
|
||||
})}
|
||||
</div>
|
||||
<div className="line-mini-axis" aria-hidden="true">
|
||||
{ticks.map((t) => (
|
||||
<span key={t.pos} style={{ left: `${t.pos}%` }}>
|
||||
{t.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={`pop num adaptive-pop${at ? ' open' : ''}`} role="tooltip" aria-hidden={!at}>
|
||||
{at ? (
|
||||
<>
|
||||
<b className="pop-time">
|
||||
{hhmm(at.start, true)}–{hhmm(new Date(Date.parse(at.start) + stepMs).toISOString(), false)}
|
||||
</b>
|
||||
<div className="pop-group">
|
||||
{STATES.map((s) => (
|
||||
<span key={s.key}>
|
||||
{s.label} <em>{Math.round(secondsOf(at, s.key) / 60)} 分钟</em>
|
||||
</span>
|
||||
))}
|
||||
<span>
|
||||
平均频率 <em>{at.average_hz === null ? '—' : `${at.average_hz} Hz`}</em>
|
||||
</span>
|
||||
<span>
|
||||
状态变化 <em>{at.changes} 次</em>
|
||||
</span>
|
||||
{at.max_loss_pct > 0 ? (
|
||||
<span>
|
||||
变化时丢包 <em className="warn">{at.max_loss_pct}%</em>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 自适应探测频率的审计:每种方法每个时段(一格)里占时最长的状态,鼠标悬停看各状态的分钟数、平均频率和变化次数;
|
||||
* 下面是最近的每一次状态变化和原因。数据来自节点上报的状态变化(存 30 天以上),所以几天后还能回看晚高峰时的情况。
|
||||
*/
|
||||
export function AdaptiveAudit({ lineId }: { lineId: string }) {
|
||||
const [window, setWindow] = useState<Window>('48h')
|
||||
const info = WINDOWS.find((w) => w.value === window) ?? WINDOWS[1]
|
||||
const query = useQuery(() => api.get<AdaptiveDto>(`/api/public/lines/${encodeURIComponent(lineId)}/adaptive`, { range: info.value, step: info.step }), [lineId, info.value], 60_000)
|
||||
const data = query.data
|
||||
if (!data || data.kinds.length === 0) {
|
||||
return (
|
||||
<section className="card adaptive-card">
|
||||
<div className="adaptive-head">
|
||||
<b>自适应探测频率</b>
|
||||
<span className="faint">{query.loading ? '加载中…' : '这段时间没有记录:线路一直平稳(没有状态变化),或者拨号节点还没升级到会上报的版本'}</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<section className="card adaptive-card">
|
||||
<div className="adaptive-head">
|
||||
<b>自适应探测频率</b>
|
||||
<span className="faint">每格 {info.step},颜色是这一格里占时最长的状态</span>
|
||||
<span className="right">
|
||||
<Segmented<Window> options={WINDOWS.map((w) => ({ value: w.value, label: w.label }))} value={window} onChange={setWindow} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="adaptive-legend">
|
||||
{STATES.map((s) => (
|
||||
<span key={s.key}>
|
||||
<i className={`bar ${s.level}`} /> {s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{data.kinds.map((k) => (
|
||||
<Row key={k.kind} kind={k.kind} buckets={k.buckets} step={info.step} />
|
||||
))}
|
||||
{data.changes.length > 0 ? (
|
||||
<details className="adaptive-changes">
|
||||
<summary>最近 {data.changes.length} 次状态变化</summary>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>方法</th>
|
||||
<th>状态</th>
|
||||
<th>频率</th>
|
||||
<th>原因</th>
|
||||
<th>丢包</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.changes.map((c) => (
|
||||
<tr key={`${c.at}-${c.kind}-${c.to}`}>
|
||||
<td title={fmtAgo(c.at)}>{hhmm(c.at, true)}</td>
|
||||
<td>{KIND_NAMES[c.kind] ?? c.kind}</td>
|
||||
<td>
|
||||
{STATE_LABEL[c.from] ?? c.from} → {STATE_LABEL[c.to] ?? c.to}
|
||||
</td>
|
||||
<td className="num">{c.hz} Hz</td>
|
||||
<td className="muted">{c.reason}</td>
|
||||
<td className="num">
|
||||
{c.loss_pct}%({c.lost}/{c.sent})
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -2678,3 +2678,77 @@ input {
|
||||
.pop .pop-note {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* ── 线路详情:自适应探测频率审计 ── */
|
||||
.adaptive-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.adaptive-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.adaptive-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
color: var(--app-ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.adaptive-legend .bar {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 10px;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.adaptive-row {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.adaptive-name {
|
||||
padding-top: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.adaptive-body {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.adaptive-body .bars {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.adaptive-body .line-mini-axis {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
}
|
||||
|
||||
/* 提示框弹在这一行的上方,不盖住别的行 */
|
||||
.adaptive-pop {
|
||||
top: auto;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 0;
|
||||
width: 190px;
|
||||
transform-origin: left bottom;
|
||||
}
|
||||
|
||||
.adaptive-changes summary {
|
||||
cursor: pointer;
|
||||
color: var(--app-ink-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { NodeTag, useNodeInfos } from '@shared/nodes'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Chip, Dot, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui'
|
||||
import { PageError, ScoreChips } from '../lib'
|
||||
import { AdaptiveAudit } from '../AdaptiveAudit'
|
||||
import { Minimap } from '../Minimap'
|
||||
import { readSpec, usePeriod, usePeriodSeries, writeSpec } from '../periods'
|
||||
import { dayBounds, dayKey, RangePicker, WINDOWS, type WindowValue } from '../ranges'
|
||||
@@ -357,6 +358,8 @@ export function Line() {
|
||||
{chartSections}
|
||||
</section>
|
||||
|
||||
<AdaptiveAudit lineId={id} />
|
||||
|
||||
{test.data && result ? (
|
||||
<div className="card">
|
||||
<div className="card-title">最近一次测试 · {test.data.plan}</div>
|
||||
|
||||
Reference in new issue
Block a user