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 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(`/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 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(`/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 }