web: show the adaptive probe-rate audit on the line page [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
ci/woodpecker/push/dev-backend-image Pipeline was successful

This commit is contained in:
justme committed 2026-10-22 10:44:46 +08:00
1 parent 9ad34acbac
commit af385cf688
4 files changed
+292

No files matched your search

+183
View File
@@ -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>
)
}
+74
View File
@@ -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;
}
+3
View File
@@ -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>