183 lines
8.4 KiB
TypeScript
183 lines
8.4 KiB
TypeScript
import { useMemo } from 'react'
|
|
import { api } from '@shared/api/client'
|
|
import type { SeriesDto } from '@shared/api/types'
|
|
import { useQuery } from '@shared/hooks'
|
|
import { availableWindows, dayBounds, dayKey, defaultWindow, fmtDayLabel, fmtSpan, isWindow, parseDay, WINDOWS, type WindowValue } from './ranges'
|
|
|
|
const MIN = 60_000
|
|
const HOUR = 60 * MIN
|
|
|
|
/** URL 里的一段时间:哪一天、快捷档位、或者拖出来的起止分钟(从当天 0 点算)。 */
|
|
export interface PeriodSpec {
|
|
day: string | null
|
|
range: string | null
|
|
at: string | null
|
|
/** 缩略图的刻度:缺省连续 48 小时(可以跨零点);`24` = 只看一天。 */
|
|
span: string | null
|
|
}
|
|
|
|
export function readSpec(params: URLSearchParams): PeriodSpec {
|
|
return { day: params.get('day'), range: params.get('range'), at: params.get('at'), span: params.get('span') }
|
|
}
|
|
|
|
export function writeSpec(params: URLSearchParams, spec: PeriodSpec) {
|
|
for (const [key, value] of [['day', spec.day], ['range', spec.range], ['at', spec.at], ['span', spec.span]] as const) {
|
|
if (value) params.set(key, value)
|
|
else params.delete(key)
|
|
}
|
|
}
|
|
|
|
/** `at=起_止`:从选中那天 0 点算起的分钟数,可以是负的(前一天晚上)或超过 1440(跨到后一天);晚高峰常常跨零点。 */
|
|
export function parseAt(at: string | null): [number, number] | null {
|
|
const m = /^(-?\d{1,5})_(-?\d{1,5})$/.exec(at ?? '')
|
|
if (!m) return null
|
|
const a = Number(m[1])
|
|
const b = Number(m[2])
|
|
return b > a && a >= -DOMAIN_MIN && b <= 2 * DOMAIN_MIN && b - a <= 2 * DOMAIN_MIN ? [a, b] : null
|
|
}
|
|
|
|
/** 缩略图的时间轴:连续的 48 小时,选区可以跨零点。 */
|
|
export const DOMAIN_MIN = 1440
|
|
export const DOMAIN_SPAN = 2 * DOMAIN_MIN
|
|
|
|
export const fmtMinute = (minutes: number) => `${String(Math.floor(minutes / 60) % 24).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`
|
|
|
|
const fmtClock = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
|
|
|
export interface Period {
|
|
dayStart: number
|
|
isToday: boolean
|
|
/** 这一天里的数据概览(缩略图用)。 */
|
|
overview: { t: number[]; loss: (number | null)[]; rtt: (number | null)[] }
|
|
/** 缩略图时间轴的起点(毫秒):往日是这一天 0 点起的 48 小时;今天是昨天 0 点起的 48 小时。 */
|
|
domainStart: number
|
|
/** 缩略图的刻度长度(分钟):1440 或 2880。 */
|
|
domainMin: number
|
|
spanMs: number | null
|
|
dataFirst: number | null
|
|
available: WindowValue[]
|
|
/** 当前选中的快捷档位;拖出来的区间是 `null`。 */
|
|
win: WindowValue | null
|
|
/** 选中的区间(毫秒):缩略图上的选区、也是对比时对齐的起点。 */
|
|
fromMs: number
|
|
toMs: number
|
|
query: Record<string, string>
|
|
hostRange: string | null
|
|
label: string
|
|
shortLabel: string
|
|
/** 缩略图的数据正在加载(不含定时刷新)。 */
|
|
busy: boolean
|
|
/** 今天、且是「最近一段」:会跟着最新数据刷新。 */
|
|
live: boolean
|
|
}
|
|
|
|
/**
|
|
* 把「哪一天 + 看多长或拖出的区间」变成这一段的取数参数和概览。
|
|
* 今天的快捷档位看最近一段(实时);往日的快捷档位从这一天第一条记录所在的那一分钟起算。
|
|
*/
|
|
export function usePeriod(lineId: string, ready: boolean, sinceMs: number | null, nowMs: number, spec: PeriodSpec): Period {
|
|
const todayStart = dayBounds(nowMs).start
|
|
const firstDay = sinceMs === null ? todayStart : dayBounds(sinceMs).start
|
|
const dayStart = Math.min(Math.max(parseDay(spec.day) ?? todayStart, firstDay), todayStart)
|
|
const isToday = dayStart === todayStart
|
|
const bounds = dayBounds(dayStart)
|
|
const dayEnd = isToday ? nowMs : bounds.end
|
|
|
|
// 缩略图的数据:连续 48 小时(选区可以跨零点);这一天自己的数据范围和档位从中截取
|
|
// 刻度:缺省连续 48 小时(选区可以跨零点);用户选了 24 小时就只看这一天,除非选区本身跨了零点
|
|
const wantedAt = parseAt(spec.at)
|
|
const wide = spec.span !== '24' || (wantedAt !== null && (wantedAt[1] > DOMAIN_MIN || wantedAt[0] < 0))
|
|
const domainMin = wide ? DOMAIN_SPAN : DOMAIN_MIN
|
|
const before = (isToday && wide) || (wantedAt !== null && wantedAt[0] < 0) ? DOMAIN_MIN : 0
|
|
const domainStart = dayStart - before * MIN
|
|
const domainEnd = Math.min(domainStart + domainMin * MIN, nowMs)
|
|
const probe = useQuery(
|
|
() =>
|
|
ready
|
|
? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, {
|
|
from: new Date(Math.max(domainStart, sinceMs === null ? domainStart : dayBounds(sinceMs).start)).toISOString(),
|
|
to: new Date(domainEnd).toISOString(),
|
|
step: wide ? '2m' : '1m',
|
|
preset: 'quic',
|
|
metrics: 'rtt.p50,loss.total',
|
|
})
|
|
: Promise.resolve(undefined),
|
|
[lineId, domainStart, domainMin, ready, isToday ? Math.floor(nowMs / MIN) : 0],
|
|
)
|
|
const overview = useMemo(() => {
|
|
const t = probe.data?.t ?? []
|
|
return { t, loss: probe.data?.values['loss.total'] ?? [], rtt: probe.data?.values['rtt.p50'] ?? [] }
|
|
}, [probe.data])
|
|
|
|
// 这一天自己的数据:第一条和最后一条记录
|
|
let firstIdx = -1
|
|
let lastIdx = -1
|
|
for (let i = 0; i < overview.t.length; i++) {
|
|
if (overview.t[i] < dayStart || overview.t[i] >= dayEnd) continue
|
|
if (overview.rtt[i] === null || overview.rtt[i] === undefined) continue
|
|
if (firstIdx < 0) firstIdx = i
|
|
lastIdx = i
|
|
}
|
|
const stepMs = overview.t.length > 1 ? overview.t[1] - overview.t[0] : MIN
|
|
const dataFirst = firstIdx >= 0 ? overview.t[firstIdx] : null
|
|
// 哪些长度可选:选区可以跨到前一天/后一天,所以看的是「从这里往前(今天)或往后(往日)一共有多少数据」,不是这一天自己有多少
|
|
const availSpan = isToday ? (sinceMs === null ? null : nowMs - sinceMs) : dataFirst === null ? null : nowMs - dataFirst
|
|
const available = availableWindows(availSpan)
|
|
|
|
const dayLabel = fmtDayLabel(dayStart, nowMs)
|
|
const at = wantedAt
|
|
const wanted = isWindow(spec.range) && available.includes(spec.range) ? spec.range : null
|
|
const win: WindowValue | null = at ? null : (wanted ?? defaultWindow(isToday, available))
|
|
|
|
let fromMs: number
|
|
let toMs: number
|
|
let query: Record<string, string>
|
|
let hostRange: string | null = null
|
|
let label: string
|
|
let live = false
|
|
if (at) {
|
|
fromMs = bounds.start + at[0] * MIN
|
|
toMs = Math.min(bounds.start + at[1] * MIN, nowMs)
|
|
if (toMs <= fromMs) toMs = fromMs + MIN
|
|
query = { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString() }
|
|
label = fmtSpan(fromMs, toMs)
|
|
} else if (win === 'day') {
|
|
fromMs = bounds.start
|
|
toMs = dayEnd
|
|
query = { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString() }
|
|
hostRange = isToday ? '24h' : null
|
|
label = `${dayLabel} 全天`
|
|
} else {
|
|
const info = WINDOWS.find((w) => w.value === win)!
|
|
if (isToday) {
|
|
// 今天:最近一段,以最新的数据为终点
|
|
const lastMs = lastIdx >= 0 ? overview.t[lastIdx] + stepMs : nowMs
|
|
toMs = Math.min(nowMs, lastMs)
|
|
fromMs = toMs - info.ms
|
|
query = { range: info.value, anchor: 'latest' }
|
|
hostRange = info.ms <= HOUR ? '1h' : info.value
|
|
label = `${dayLabel} 最近 ${info.label}`
|
|
live = true
|
|
} else {
|
|
// 往日:从这一天第一条记录所在的那一分钟起
|
|
fromMs = Math.floor((dataFirst ?? bounds.start) / MIN) * MIN
|
|
toMs = Math.min(fromMs + info.ms, bounds.end)
|
|
query = { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString() }
|
|
label = `${dayLabel} ${fmtClock(fromMs)} 起 ${info.label}`
|
|
}
|
|
}
|
|
const shortLabel = fmtSpan(fromMs, toMs, true)
|
|
return { dayStart, isToday, domainStart, domainMin, overview, spanMs: availSpan, dataFirst, available, win, fromMs, toMs, query, hostRange, label, shortLabel, busy: probe.busy, live }
|
|
}
|
|
|
|
/** 这一段的序列;今天的「最近一段」会定时刷新。 */
|
|
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, ...(metrics ? { metrics } : { preset }) }) : Promise.resolve(undefined)),
|
|
[lineId, JSON.stringify(period.query), ready, preset, metrics],
|
|
period.isToday ? (period.win === 'day' ? 60_000 : 10_000) : undefined,
|
|
)
|
|
}
|
|
|
|
export { dayKey }
|