Files
MoShiro/web/display/src/periods.ts
T

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 }