diff --git a/web/display/src/AdaptiveAudit.tsx b/web/display/src/AdaptiveAudit.tsx new file mode 100644 index 0000000..4913dc3 --- /dev/null +++ b/web/display/src/AdaptiveAudit.tsx @@ -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 = { quic_ping: 'QUIC 往返', tcp_ping: 'TCP 往返', tcp_connect: 'TCP 建连', http_request: 'HTTP 请求' } +const STATE_LABEL: Record = { 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(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 ( +
+
{KIND_NAMES[kind] ?? kind}
+
setHover(null)}> +
+ {buckets.map((b, i) => { + const state = dominant(b) + const level = state === null ? 'none' : STATES.find((s) => s.key === state)!.level + return setHover(i)} /> + })} +
+ +
+ {at ? ( + <> + + {hhmm(at.start, true)}–{hhmm(new Date(Date.parse(at.start) + stepMs).toISOString(), false)} + +
+ {STATES.map((s) => ( + + {s.label} {Math.round(secondsOf(at, s.key) / 60)} 分钟 + + ))} + + 平均频率 {at.average_hz === null ? '—' : `${at.average_hz} Hz`} + + + 状态变化 {at.changes} 次 + + {at.max_loss_pct > 0 ? ( + + 变化时丢包 {at.max_loss_pct}% + + ) : null} +
+ + ) : null} +
+
+
+ ) +} + +/** + * 自适应探测频率的审计:每种方法每个时段(一格)里占时最长的状态,鼠标悬停看各状态的分钟数、平均频率和变化次数; + * 下面是最近的每一次状态变化和原因。数据来自节点上报的状态变化(存 30 天以上),所以几天后还能回看晚高峰时的情况。 + */ +export function AdaptiveAudit({ lineId }: { lineId: string }) { + const [window, setWindow] = useState('48h') + const info = WINDOWS.find((w) => w.value === window) ?? WINDOWS[1] + const query = useQuery(() => api.get(`/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 ( +
+
+ 自适应探测频率 + {query.loading ? '加载中…' : '这段时间没有记录:线路一直平稳(没有状态变化),或者拨号节点还没升级到会上报的版本'} +
+
+ ) + } + return ( +
+
+ 自适应探测频率 + 每格 {info.step},颜色是这一格里占时最长的状态 + + options={WINDOWS.map((w) => ({ value: w.value, label: w.label }))} value={window} onChange={setWindow} /> + +
+
+ {STATES.map((s) => ( + + {s.label} + + ))} +
+ {data.kinds.map((k) => ( + + ))} + {data.changes.length > 0 ? ( +
+ 最近 {data.changes.length} 次状态变化 + + + + + + + + + + + + + {data.changes.map((c) => ( + + + + + + + + + ))} + +
时间方法状态频率原因丢包
{hhmm(c.at, true)}{KIND_NAMES[c.kind] ?? c.kind} + {STATE_LABEL[c.from] ?? c.from} → {STATE_LABEL[c.to] ?? c.to} + {c.hz} Hz{c.reason} + {c.loss_pct}%({c.lost}/{c.sent}) +
+
+ ) : null} +
+ ) +} diff --git a/web/display/src/display.css b/web/display/src/display.css index 1916f5f..db409f2 100644 --- a/web/display/src/display.css +++ b/web/display/src/display.css @@ -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; +} diff --git a/web/display/src/pages/Line.tsx b/web/display/src/pages/Line.tsx index 05fa62d..1c1006b 100644 --- a/web/display/src/pages/Line.tsx +++ b/web/display/src/pages/Line.tsx @@ -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} + + {test.data && result ? (
最近一次测试 · {test.data.plan}
diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index 9a19d66..bb1c5fb 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -86,3 +86,35 @@ export interface TargetDto { values: Record summary: { metrics: Record; samples: Record; coverage: number } } + +export interface AdaptiveBucketDto { + start: string + quiet_seconds: number + watch_seconds: number + active_seconds: number + dead_seconds: number + average_hz: number | null + changes: number + max_loss_pct: number +} + +export interface AdaptiveChangeDto { + at: string + kind: string + from: string + to: string + hz: number + reason: string + loss_pct: number + sent: number + lost: number +} + +export interface AdaptiveDto { + line_id: string + from: string + to: string + step: string + kinds: { kind: string; buckets: AdaptiveBucketDto[] }[] + changes: AdaptiveChangeDto[] +}