feat: node info, build info and acrylic web theme
Squash of recent dev work: return_session login, backend switcher, line detail timeline, overview strips, node platform/country/ISP info with flags and icons, build info (version, tag, time) behind site.show_build, and the acrylic display theme.
This commit is contained in:
1 parent
c30c4e16ef
commit
86311c7452
95 files changed
+5524
-528
No files matched your search
@@ -0,0 +1,182 @@
|
||||
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: 'udp',
|
||||
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) {
|
||||
return useQuery(
|
||||
() => (ready ? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { ...period.query, preset: 'udp,tcp,scores' }) : Promise.resolve(undefined)),
|
||||
[lineId, JSON.stringify(period.query), ready],
|
||||
period.isToday ? (period.win === 'day' ? 60_000 : 10_000) : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
export { dayKey }
|
||||
Reference in new issue
Block a user