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
10634bdd80
commit
14b0de3544
95 files changed
+5524
-528
No files matched your search
+130
-140
@@ -1,11 +1,12 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useRef, type ReactNode } from 'react'
|
||||
import { Segmented } from '@shared/ui'
|
||||
|
||||
const MIN = 60_000
|
||||
const DAY = 24 * 60 * MIN
|
||||
const HOUR = 60 * MIN
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
/** 线路详情页的时间范围:只提供这条线路“有数据”的档位。 */
|
||||
/** 对比页的时间范围:最近一段。 */
|
||||
export const PRESETS = [
|
||||
{ value: '5m', label: '5 分钟', ms: 5 * MIN },
|
||||
{ value: '15m', label: '15 分钟', ms: 15 * MIN },
|
||||
@@ -19,172 +20,161 @@ export const PRESETS = [
|
||||
|
||||
export type PresetValue = (typeof PRESETS)[number]['value']
|
||||
|
||||
/** 进入某个月之后的切片:整月,或者从月初起按固定长度翻页。 */
|
||||
export const SLICES = [
|
||||
{ value: 'all', label: '整月', ms: 0 },
|
||||
{ value: '14d', label: '14 天', ms: 14 * DAY },
|
||||
{ value: '7d', label: '7 天', ms: 7 * DAY },
|
||||
{ value: '3d', label: '3 天', ms: 3 * DAY },
|
||||
{ value: '1d', label: '1 天', ms: DAY },
|
||||
/** 线路详情页:先选哪一天,再选这一天里看多长。`day` = 整个自然日。 */
|
||||
export const WINDOWS = [
|
||||
{ value: '30m', label: '30 分钟', ms: 30 * MIN },
|
||||
{ value: '1h', label: '1 小时', ms: HOUR },
|
||||
{ value: '3h', label: '3 小时', ms: 3 * HOUR },
|
||||
{ value: '6h', label: '6 小时', ms: 6 * HOUR },
|
||||
{ value: '12h', label: '12 小时', ms: 12 * HOUR },
|
||||
{ value: 'day', label: '整天', ms: DAY },
|
||||
] as const
|
||||
|
||||
export type SliceValue = (typeof SLICES)[number]['value']
|
||||
export type WindowValue = (typeof WINDOWS)[number]['value']
|
||||
|
||||
/** 数据不足整档时允许 10% 的误差:29 分钟的数据算得上“30 分钟”。至少保留最小的一档。 */
|
||||
const TOLERANCE = 0.9
|
||||
|
||||
export function availablePresets(spanMs: number | null) {
|
||||
if (spanMs === null) return [PRESETS[0]]
|
||||
const list = PRESETS.filter((p) => spanMs >= p.ms * TOLERANCE)
|
||||
return list.length > 0 ? list : [PRESETS[0]]
|
||||
}
|
||||
|
||||
/** 默认选“有数据的最大档”,但最长到 1 天(再长的范围图会很挤,用户可以自己点)。 */
|
||||
export function defaultPreset(spanMs: number | null): PresetValue {
|
||||
const list = availablePresets(spanMs).filter((p) => p.ms <= DAY)
|
||||
return list[list.length - 1]?.value ?? '5m'
|
||||
}
|
||||
|
||||
export interface MonthInfo {
|
||||
key: string // YYYY-MM
|
||||
label: string
|
||||
start: number
|
||||
end: number // 不含
|
||||
}
|
||||
export const isWindow = (v: string | null): v is WindowValue => WINDOWS.some((w) => w.value === v)
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
export function monthOf(ms: number): MonthInfo {
|
||||
/** 本地时区的 YYYY-MM-DD。 */
|
||||
export function dayKey(ms: number): string {
|
||||
const d = new Date(ms)
|
||||
const start = new Date(d.getFullYear(), d.getMonth(), 1).getTime()
|
||||
const end = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime()
|
||||
return { key: `${d.getFullYear()}-${pad(d.getMonth() + 1)}`, label: `${d.getFullYear()} 年 ${d.getMonth() + 1} 月`, start, end }
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
/** 从有数据的第一个月到当前月,新的在前。 */
|
||||
export function monthsWithData(sinceMs: number | null, nowMs: number): MonthInfo[] {
|
||||
if (sinceMs === null) return []
|
||||
const months: MonthInfo[] = []
|
||||
let cursor = monthOf(sinceMs)
|
||||
const last = monthOf(nowMs)
|
||||
while (cursor.start <= last.start) {
|
||||
months.push(cursor)
|
||||
cursor = monthOf(cursor.end)
|
||||
}
|
||||
return months.reverse()
|
||||
export function parseDay(key: string | null): number | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key ?? '')
|
||||
if (!m) return null
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
|
||||
return Number.isNaN(d.getTime()) ? null : d.getTime()
|
||||
}
|
||||
|
||||
export interface RangeState {
|
||||
/** 没有 month 就是“最近一段”。 */
|
||||
preset: PresetValue
|
||||
month: string | null
|
||||
slice: SliceValue
|
||||
page: number
|
||||
}
|
||||
|
||||
export interface ResolvedRange {
|
||||
query: { range?: string; from?: string; to?: string; anchor?: string }
|
||||
/** 主机历史只支持 ≥ 1 小时的 range,这里给一个对应的值。 */
|
||||
hostRange: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const fmtDay = (ms: number) => {
|
||||
/** 本地时区这一天的 [起点, 终点)。 */
|
||||
export function dayBounds(ms: number): { start: number; end: number } {
|
||||
const d = new Date(ms)
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||
return { start: new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(), end: new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime() }
|
||||
}
|
||||
|
||||
export function resolveRange(state: RangeState, months: MonthInfo[], nowMs: number): ResolvedRange {
|
||||
if (!state.month) {
|
||||
const hostRange = state.preset === '5m' || state.preset === '15m' || state.preset === '30m' ? '1h' : state.preset
|
||||
return { query: { range: state.preset, anchor: 'latest' }, hostRange, label: PRESETS.find((p) => p.value === state.preset)?.label ?? state.preset }
|
||||
}
|
||||
const month = months.find((m) => m.key === state.month) ?? monthOf(nowMs)
|
||||
const slice = SLICES.find((s) => s.value === state.slice) ?? SLICES[0]
|
||||
const end = Math.min(month.end, nowMs)
|
||||
let from = month.start
|
||||
let to = end
|
||||
if (slice.ms > 0) {
|
||||
from = month.start + state.page * slice.ms
|
||||
to = Math.min(from + slice.ms, end)
|
||||
}
|
||||
const spanDays = Math.max(1, Math.round((to - from) / DAY))
|
||||
return {
|
||||
query: { from: new Date(from).toISOString(), to: new Date(to).toISOString() },
|
||||
hostRange: spanDays >= 30 ? '30d' : `${Math.max(1, spanDays)}d`,
|
||||
label: slice.ms > 0 ? `${month.label} ${fmtDay(from)} – ${fmtDay(to - 1)}` : month.label,
|
||||
}
|
||||
const clock = (ms: number) => `${pad(new Date(ms).getHours())}:${pad(new Date(ms).getMinutes())}`
|
||||
const fullDay = (ms: number) => `${new Date(ms).getFullYear()}年${new Date(ms).getMonth() + 1}月${new Date(ms).getDate()}日`
|
||||
const shortDay = (ms: number) => `${new Date(ms).getMonth() + 1}月${new Date(ms).getDate()}日`
|
||||
|
||||
/** 一段时间的文字:同一天写一次日期(2026年10月5日 21:06–23:00),跨零点两头都写日期(2026年10月5日 21:06 – 2026年10月6日 02:31)。 */
|
||||
export function fmtSpan(fromMs: number, toMs: number, short = false): string {
|
||||
const day = short ? shortDay : fullDay
|
||||
const end = Math.max(fromMs, toMs - 1)
|
||||
// 正好到次日 0 点的写成 24:00
|
||||
return dayKey(fromMs) === dayKey(end) ? `${day(fromMs)} ${clock(fromMs)}–${clock(toMs) === '00:00' ? '24:00' : clock(toMs)}` : `${day(fromMs)} ${clock(fromMs)} – ${day(end)} ${clock(toMs)}`
|
||||
}
|
||||
|
||||
/** 一个月里按固定长度能翻几页。 */
|
||||
function pagesIn(month: MonthInfo, sliceMs: number, nowMs: number, sinceMs: number | null): { first: number; last: number } {
|
||||
const start = Math.max(month.start, sinceMs === null ? month.start : sinceMs - (sinceMs % DAY))
|
||||
const end = Math.min(month.end, nowMs)
|
||||
return { first: Math.max(0, Math.floor((start - month.start) / sliceMs)), last: Math.max(0, Math.ceil((end - month.start) / sliceMs) - 1) }
|
||||
export const fmtDayLabel = (startMs: number, nowMs: number) => {
|
||||
const d = new Date(startMs)
|
||||
const today = dayKey(startMs) === dayKey(nowMs)
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日${today ? '(今天)' : ''}`
|
||||
}
|
||||
|
||||
/** 数据不足整档时允许 10% 的误差:29 分钟的数据算得上“30 分钟”。 */
|
||||
const TOLERANCE = 0.9
|
||||
|
||||
/** 这一天里有多长的数据(毫秒),决定哪些档位可选;`null` = 这一天没有数据。 */
|
||||
export function availableWindows(spanMs: number | null): WindowValue[] {
|
||||
if (spanMs === null) return []
|
||||
const list = WINDOWS.filter((w) => w.value === 'day' || spanMs >= w.ms * TOLERANCE).map((w) => w.value)
|
||||
return list.length > 0 ? list : ['day']
|
||||
}
|
||||
|
||||
/** 默认:今天看最近 1 小时,往日看整天。 */
|
||||
export function defaultWindow(isToday: boolean, available: WindowValue[]): WindowValue {
|
||||
if (!isToday) return 'day'
|
||||
return available.includes('1h') ? '1h' : (available.filter((w) => w !== 'day').pop() ?? 'day')
|
||||
}
|
||||
|
||||
interface Props {
|
||||
state: RangeState
|
||||
onChange: (next: RangeState) => void
|
||||
/** 选中那天的 0 点(本地时区)。 */
|
||||
dayStart: number
|
||||
/** 选区(毫秒):长度正好等于某一档时,那一档高亮。 */
|
||||
fromMs: number
|
||||
toMs: number
|
||||
/** 换了一天(选区回到默认)。 */
|
||||
onChange: (next: { dayStart: number; window: WindowValue | null }) => void
|
||||
/** 点了一档时长:30 分钟、1 小时、3 小时、6 小时、12 小时、整天。 */
|
||||
onWindow: (window: WindowValue) => void
|
||||
/** 缩略图的刻度:24 小时(一天)或 48 小时(连续两天,可以跨零点)。 */
|
||||
scale: 24 | 48
|
||||
onScale: (scale: 24 | 48) => void
|
||||
/** 这条线路最早有数据的时间;`null` = 没有数据。 */
|
||||
sinceMs: number | null
|
||||
nowMs: number
|
||||
/** 这一天里数据的跨度(毫秒);未知或没有数据是 `null`。 */
|
||||
spanMs: number | null
|
||||
/** 放在日期和档位下面(缩略图)。 */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function RangePicker({ state, onChange, sinceMs, nowMs }: Props) {
|
||||
const spanMs = sinceMs === null ? null : nowMs - sinceMs
|
||||
const presets = useMemo(() => availablePresets(spanMs), [spanMs])
|
||||
const months = useMemo(() => monthsWithData(sinceMs, nowMs), [sinceMs, nowMs])
|
||||
const month = state.month ? months.find((m) => m.key === state.month) : undefined
|
||||
const monthLength = month ? Math.min(month.end, nowMs) - Math.max(month.start, sinceMs ?? month.start) : 0
|
||||
const slices = SLICES.filter((s) => s.ms === 0 || monthLength >= s.ms * TOLERANCE)
|
||||
const slice = SLICES.find((s) => s.value === state.slice) ?? SLICES[0]
|
||||
const pages = month && slice.ms > 0 ? pagesIn(month, slice.ms, nowMs, sinceMs) : undefined
|
||||
export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale, onScale, sinceMs, nowMs, spanMs, children }: Props) {
|
||||
const picker = useRef<HTMLInputElement>(null)
|
||||
const today = dayBounds(nowMs).start
|
||||
const firstDay = sinceMs === null ? today : dayBounds(sinceMs).start
|
||||
const available = availableWindows(spanMs)
|
||||
const length = toMs - fromMs
|
||||
const active: WindowValue = WINDOWS.find((w) => Math.abs(length - w.ms) < 1.5 * MIN)?.value ?? ('' as WindowValue)
|
||||
const step = (days: number) => {
|
||||
const d = new Date(dayStart)
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + days).getTime()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="range-picker">
|
||||
<div className="row wrap">
|
||||
<Segmented
|
||||
options={PRESETS.map((p) => {
|
||||
const ok = presets.some((x) => x.value === p.value)
|
||||
return { value: p.value, label: p.label, disabled: !ok, title: ok ? undefined : '这条线路的数据还没有这么长' }
|
||||
})}
|
||||
value={state.month ? ('' as PresetValue) : state.preset}
|
||||
onChange={(v) => onChange({ ...state, preset: v, month: null, page: 0 })}
|
||||
/>
|
||||
{months.length > 0 ? (
|
||||
<select
|
||||
className="select"
|
||||
style={{ width: 'auto' }}
|
||||
disabled={spanMs === null || spanMs < DAY}
|
||||
title={spanMs !== null && spanMs >= DAY ? undefined : '数据超过 1 天之后可以按月查看'}
|
||||
value={state.month ?? ''}
|
||||
onChange={(e) => onChange({ ...state, month: e.target.value || null, slice: 'all', page: 0 })}
|
||||
aria-label="按月查看"
|
||||
>
|
||||
<option value="">按月查看</option>
|
||||
{months.map((m) => (
|
||||
<option key={m.key} value={m.key}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
<button type="button" className="btn icon small" disabled={dayStart <= firstDay} onClick={() => onChange({ dayStart: step(-1), window: null })} aria-label="前一天">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button type="button" className="day-pick" onClick={() => picker.current?.showPicker?.()}>
|
||||
{fmtDayLabel(dayStart, nowMs)}
|
||||
<input
|
||||
ref={picker}
|
||||
type="date"
|
||||
value={dayKey(dayStart)}
|
||||
min={dayKey(firstDay)}
|
||||
max={dayKey(today)}
|
||||
onChange={(e) => {
|
||||
const picked = parseDay(e.target.value)
|
||||
if (picked !== null) onChange({ dayStart: Math.min(Math.max(picked, firstDay), today), window: null })
|
||||
}}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</button>
|
||||
<button type="button" className="btn icon small" disabled={dayStart >= today} onClick={() => onChange({ dayStart: step(1), window: null })} aria-label="后一天">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</span>
|
||||
{dayStart !== today ? (
|
||||
<button type="button" className="btn small" onClick={() => onChange({ dayStart: today, window: null })}>
|
||||
回到今天
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{month ? (
|
||||
<div className="row wrap" style={{ marginTop: 12 }}>
|
||||
<Segmented options={slices.map((s) => ({ value: s.value, label: s.label }))} value={state.slice} onChange={(v) => onChange({ ...state, slice: v, page: 0 })} />
|
||||
{pages ? (
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
<button type="button" className="btn icon small" disabled={state.page <= pages.first} onClick={() => onChange({ ...state, page: state.page - 1 })} aria-label="上一段">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button type="button" className="btn icon small" disabled={state.page >= pages.last} onClick={() => onChange({ ...state, page: state.page + 1 })} aria-label="下一段">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
<div className="row wrap" style={{ marginTop: 8, justifyContent: 'space-between' }}>
|
||||
<Segmented
|
||||
options={WINDOWS.map((w) => {
|
||||
const ok = available.includes(w.value)
|
||||
return { value: w.value, label: w.label, disabled: !ok, title: ok ? undefined : '这一天的数据没有这么长' }
|
||||
})}
|
||||
value={active}
|
||||
onChange={onWindow}
|
||||
/>
|
||||
<Segmented<'24' | '48'>
|
||||
options={[
|
||||
{ value: '24', label: '24 小时刻度', title: '缩略图显示一天' },
|
||||
{ value: '48', label: '48 小时刻度', title: '缩略图显示连续两天,选区可以跨零点' },
|
||||
]}
|
||||
value={String(scale) as '24' | '48'}
|
||||
onChange={(v) => onScale(v === '48' ? 48 : 24)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user