feat(web): acrylic theme, node flags and icons, build info
Acrylic cards and topbar over a blurred background, country flags, ISP and OS icons, per-node version tags, a build info footer and an admin versions tab. Overview cards drop the sparkline and widen the hover area.
This commit is contained in:
1 parent
1e5c660d05
commit
83f54978d2
29 files changed
+1310
-143
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState, type MouseEvent } from 'react'
|
||||
import { useRef, useState, type MouseEvent } from 'react'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { SeriesDto } from '@shared/api/types'
|
||||
import { fmtMs, fmtPct } from '@shared/format'
|
||||
@@ -30,9 +30,7 @@ interface StripProps {
|
||||
/** 每 5 分钟一格:这一格的丢包(重传)率、延迟。 */
|
||||
t: number[]
|
||||
badSeries: (number | null)[]
|
||||
rttSeries: (number | null)[]
|
||||
ready: boolean
|
||||
loading: boolean
|
||||
/** 提示里这一格的「丢包」叫什么。 */
|
||||
tipBad: string
|
||||
/** 没有数据时占位的格数,和每格长度的说明(「每 5 分钟」)。 */
|
||||
@@ -41,42 +39,13 @@ interface StripProps {
|
||||
hoursText: string
|
||||
/** 悬停的那一格(UDP 和 TCP 共用);`null` = 没有。 */
|
||||
hover: number | null
|
||||
onHover: (i: number | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 一排小格(每 5 分钟一格,按这 5 分钟的丢包率着色:绿 / 黄 / 红 / 灰 = 没有数据)
|
||||
* 加一条延迟的小走势(只标纵轴最大值),尽量轻。
|
||||
* 尽量轻。
|
||||
*/
|
||||
function Strip({ rttLabel, rtt, badLabel, bad, badHint, t, badSeries, rttSeries, ready, loading, cells, stepText, hoursText, hover, onHover }: StripProps) {
|
||||
const count = ready ? badSeries.length : 0
|
||||
const onSparkMove = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (count < 2) return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
onHover(Math.min(count - 1, Math.max(0, Math.round(((e.clientX - rect.left) / rect.width) * (count - 1)))))
|
||||
}
|
||||
// 纵轴贴着数据走:从最小值到最大值(上下各留一点),而不是从 0 开始——延迟本来就在一个窄范围里波动,从 0 画会成一条平线
|
||||
const spark = useMemo(() => {
|
||||
const points = rttSeries.map((v, i) => ({ v, i })).filter((p): p is { v: number; i: number } => p.v !== null && p.v !== undefined)
|
||||
if (points.length < 2) return null
|
||||
const lo = Math.min(...points.map((p) => p.v))
|
||||
const hi = Math.max(...points.map((p) => p.v))
|
||||
const mean = points.reduce((sum, p) => sum + p.v, 0) / points.length
|
||||
const pad = Math.max((hi - lo) * 0.15, mean * 0.03, 1)
|
||||
const top = hi + pad
|
||||
const bottom = Math.max(0, lo - pad)
|
||||
const n = rttSeries.length - 1
|
||||
const W = 300
|
||||
const H = 30
|
||||
const x = (i: number) => (i / n) * W
|
||||
const y = (v: number) => H - ((v - bottom) / (top - bottom)) * H
|
||||
const line = points.map((p, k) => `${k === 0 ? 'M' : 'L'}${x(p.i).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ')
|
||||
const area = `${line} L${x(points[points.length - 1].i).toFixed(1)},${H} L${x(points[0].i).toFixed(1)},${H} Z`
|
||||
const fmt = (v: number) => (v >= 100 ? String(Math.round(v)) : v.toFixed(1))
|
||||
const dots = rttSeries.map((v) => (v === null || v === undefined ? null : y(v) / H))
|
||||
return { line, area, hi: fmt(hi), lo: fmt(lo), W, H, dots }
|
||||
}, [rttSeries])
|
||||
|
||||
function Strip({ rttLabel, rtt, badLabel, bad, badHint, t, badSeries, ready, cells, stepText, hoursText, hover }: StripProps) {
|
||||
return (
|
||||
<div className="udp-strip">
|
||||
<div className="udp-head">
|
||||
@@ -89,29 +58,9 @@ function Strip({ rttLabel, rtt, badLabel, bad, badHint, t, badSeries, rttSeries,
|
||||
</div>
|
||||
<div className="bars" role="img" aria-label={`近 ${hoursText}每 ${stepText}的${badLabel}情况`}>
|
||||
{(ready ? badSeries : Array.from({ length: cells }, () => null)).map((v, i) => (
|
||||
<span key={t[i] ?? i} className={`bar ${ready ? levelOf(v) : 'none'}${hover === i ? ' on' : ''}`} onMouseEnter={() => ready && onHover(i)} />
|
||||
<span key={t[i] ?? i} className={`bar ${ready ? levelOf(v) : 'none'}${hover === i ? ' on' : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="spark" onMouseMove={onSparkMove}>
|
||||
{spark ? (
|
||||
<>
|
||||
<svg viewBox={`0 0 ${spark.W} ${spark.H}`} preserveAspectRatio="none" aria-hidden="true">
|
||||
<path className="area" d={spark.area} />
|
||||
<path className="line" d={spark.line} vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
{hover !== null && spark.dots[hover] !== null && spark.dots[hover] !== undefined ? (
|
||||
<>
|
||||
<i className="guide" style={{ left: `${(hover / (count - 1)) * 100}%` }} />
|
||||
<i className="dot" style={{ left: `${(hover / (count - 1)) * 100}%`, top: `${(spark.dots[hover] as number) * 100}%` }} />
|
||||
</>
|
||||
) : null}
|
||||
<span className="ymax num">{spark.hi}</span>
|
||||
<span className="ymin num">{spark.lo}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="faint empty">{loading ? '加载中…' : '没有数据'}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -123,7 +72,7 @@ interface Props {
|
||||
m: Record<string, number | null | undefined>
|
||||
}
|
||||
|
||||
/** 一条线路近一段时间的 UDP 和 TCP:各是一排小格加一条延迟走势,共用一次请求。 */
|
||||
/** 一条线路近一段时间的 UDP 和 TCP:各是一排小格,共用一次请求。 */
|
||||
export function LineStrips({ lineId, range, m }: Props) {
|
||||
const info = OVERVIEW_RANGES.find((r) => r.value === range) ?? OVERVIEW_RANGES[0]
|
||||
const series = useQuery(
|
||||
@@ -139,10 +88,18 @@ export function LineStrips({ lineId, range, m }: Props) {
|
||||
const last = useRef<number | null>(null) // 提示收回去的动画期间,内容还要留着
|
||||
if (hover !== null) last.current = hover
|
||||
const shown = hover ?? last.current
|
||||
const common = { t, ready: Boolean(s), loading: series.loading, cells: Math.round((info.hours * 60) / info.stepMin), stepText: `${info.stepMin} 分钟`, hoursText: `${info.hours} 小时`, hover, onHover: setHover }
|
||||
// 整块线路区域都能触发:按鼠标横向位置对应到小格(UDP 和 TCP 的小格一样宽,量第一排就行),不用精确指到小格上
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const onMove = (e: MouseEvent<HTMLDivElement>) => {
|
||||
const bars = box.current?.querySelector('.bars')
|
||||
if (!s || t.length < 1 || !bars) return
|
||||
const r = bars.getBoundingClientRect()
|
||||
setHover(Math.min(t.length - 1, Math.max(0, Math.floor(((e.clientX - r.left) / r.width) * t.length))))
|
||||
}
|
||||
const common = { t, ready: Boolean(s), cells: Math.round((info.hours * 60) / info.stepMin), stepText: `${info.stepMin} 分钟`, hoursText: `${info.hours} 小时`, hover }
|
||||
const at = shown !== null && t[shown] !== undefined ? shown : null
|
||||
return (
|
||||
<div className="strips" onMouseLeave={() => setHover(null)}>
|
||||
<div className="strips" ref={box} onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
|
||||
<Strip
|
||||
{...common}
|
||||
rttLabel="UDP 延迟 p50"
|
||||
@@ -151,10 +108,9 @@ export function LineStrips({ lineId, range, m }: Props) {
|
||||
bad={m['loss.total']}
|
||||
badHint={`上行丢包 ${fmtPct(m['loss.up'])} 下行丢包 ${fmtPct(m['loss.down'])}`}
|
||||
badSeries={col('loss.total')}
|
||||
rttSeries={col('rtt.p50')}
|
||||
tipBad="丢包"
|
||||
/>
|
||||
<Strip {...common} rttLabel="TCP RTT p50" rtt={m['tcp.rtt.p50']} badLabel="重传" bad={m['tcp.retrans']} badSeries={col('tcp.retrans')} rttSeries={col('tcp.rtt.p50')} tipBad="重传" />
|
||||
<Strip {...common} rttLabel="TCP RTT p50" rtt={m['tcp.rtt.p50']} badLabel="重传" bad={m['tcp.retrans']} badSeries={col('tcp.retrans')} tipBad="重传" />
|
||||
<div className={`pop num${hover !== null && at !== null ? ' open' : ''}`} role="tooltip" aria-hidden={hover === null}>
|
||||
{at !== null ? (
|
||||
<>
|
||||
|
||||
Reference in new issue
Block a user