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:
justme committed 2026-10-06 21:24:25 +08:00
1 parent 10634bdd80
commit 14b0de3544
95 files changed
+5524 -528

No files matched your search

+47 -13
View File
@@ -9,6 +9,8 @@ export interface ChartSeries {
color: string
dash?: number[]
fill?: boolean
/** 原始数据的淡线(开了 EWMA 平滑时垫在平滑线后面):画细、画淡,图例和提示框里不出现。 */
faint?: boolean
}
interface Props {
@@ -22,14 +24,33 @@ interface Props {
zero?: boolean
/** 缺口:这些时间段用底色标出。 */
gaps?: { from: number; to: number }[]
/** 为真时 `t` 是「从起点算起的毫秒数」而不是 Unix 毫秒:横轴显示 0:00、0:15…,用来把两段时间叠在一起比。 */
offset?: boolean
}
/** 偏移量(毫秒)→ h:mm:ss / m:ss。 */
export function fmtOffset(ms: number): string {
const total = Math.round(ms / 1000)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const sec = total % 60
const two = (n: number) => String(n).padStart(2, '0')
return h > 0 ? `${h}:${two(m)}${sec ? ':' + two(sec) : ''}` : `${m}:${two(sec)}`
}
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
}
/** 毫秒到 1 秒以上换成秒(30000ms → 30s),坐标轴和提示框都不会出现一长串数字。 */
function withUnit(v: number, unit: string, digits?: number): string {
if (unit === 'ms' && Math.abs(v) >= 1000) return `${digits === undefined ? +(v / 1000).toFixed(1) : (v / 1000).toFixed(digits)}s`
return `${v}${unit}`
}
function formatValue(v: number | null | undefined, unit: string): string {
if (v === null || v === undefined) return '—'
if (unit === 'ms' && Math.abs(v) >= 1000) return withUnit(v, unit, 2)
const text = Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2)
return `${text}${unit}`
}
@@ -40,12 +61,17 @@ function formatValue(v: number | null | undefined, unit: string): string {
* - 在图上拖动选一段放大预览,双击或点「还原」恢复;数据刷新时保持放大的区间;
* - 鼠标悬停显示这个时刻每条线的值。
*/
export function TimeChart({ t, series, height = 220, unit = '', zero = true, gaps = [] }: Props) {
export function TimeChart({ t, series, height = 220, unit = '', zero = true, gaps = [], offset = false }: Props) {
const host = useRef<HTMLDivElement>(null)
const tip = useRef<HTMLDivElement>(null)
const plot = useRef<uPlot | null>(null)
const zoom = useRef<{ min: number; max: number } | null>(null)
const [zoomed, setZoomed] = useState(false)
// 数据变了(轮询、实时)只更新数据,不重建图:重建会闪一下,还会丢掉鼠标位置
const latest = useRef({ t, series, gaps })
latest.current = { t, series, gaps }
const structure = JSON.stringify([series.map((x) => [x.label, x.color, x.dash, x.fill, x.faint]), unit, zero, offset, height])
const dataOf = (): AlignedData => [latest.current.t.map((v) => v / 1000), ...latest.current.series.map((x) => x.values.map((v) => (v === null ? null : v)))]
const reset = useCallback(() => {
zoom.current = null
@@ -70,12 +96,12 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
cursor: { drag: { x: true, y: false, setScale: false }, points: { size: 6 } },
select: { show: true, left: 0, top: 0, width: 0, height: 0 },
scales: {
x: { time: true, range: (_u, min, max) => (zoom.current ? [zoom.current.min, zoom.current.max] : [min, max]) },
x: { time: !offset, range: (_u, min, max) => (zoom.current ? [zoom.current.min, zoom.current.max] : [min, max]) },
y: { range: (_u, min, max) => [zero ? Math.min(0, min) : min, max <= min ? min + 1 : max + (max - min) * 0.08] },
},
axes: [
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { stroke: grid }, font },
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { show: false }, font, size: 56, values: (_u, ticks) => ticks.map((v) => `${v}${unit}`) },
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { stroke: grid }, font, ...(offset ? { values: (_u: uPlot, ticks: number[]) => ticks.map((v) => fmtOffset(v * 1000)) } : {}) },
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { show: false }, font, size: 56, values: (_u, ticks) => ticks.map((v) => withUnit(v, unit)) },
],
series: [
{},
@@ -83,8 +109,8 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
const color = cssVar(s.color)
return {
label: s.label,
stroke: color,
width: 1.8,
stroke: s.faint ? color + '55' : color,
width: s.faint ? 1 : 1.8,
dash: s.dash,
fill: s.fill ? color + '22' : undefined,
spanGaps: false,
@@ -120,8 +146,8 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
return
}
const when = new Date(u.data[0][idx] * 1000)
const lines = [when.toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })]
for (let i = 0; i < series.length; i++) lines.push(`${series[i].label} ${formatValue(u.data[i + 1][idx], unit)}`)
const lines = [offset ? `起点后 ${fmtOffset(u.data[0][idx] * 1000)}` : when.toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })]
for (let i = 0; i < latest.current.series.length; i++) if (!latest.current.series[i].faint) lines.push(`${latest.current.series[i].label} ${formatValue(u.data[i + 1][idx], unit)}`)
box.textContent = lines.join('\n')
box.style.display = 'block'
const width = box.offsetWidth
@@ -135,7 +161,7 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
const { ctx } = u
ctx.save()
ctx.fillStyle = shade
for (const g of gaps) {
for (const g of latest.current.gaps) {
const x0 = Math.max(u.bbox.left, u.valToPos(g.from / 1000, 'x', true))
const x1 = Math.min(u.bbox.left + u.bbox.width, u.valToPos(g.to / 1000, 'x', true))
if (x1 > x0) ctx.fillRect(x0, u.bbox.top, x1 - x0, u.bbox.height)
@@ -145,8 +171,7 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
],
},
}
const data: AlignedData = [t.map((v) => v / 1000), ...series.map((s) => s.values.map((v) => (v === null ? null : v)))]
plot.current = new uPlot(options, data, el)
plot.current = new uPlot(options, dataOf(), el)
// 把提示框放进绘图区,这样它的位置和光标是同一个坐标系
if (tip.current) plot.current.over.appendChild(tip.current)
}
@@ -161,12 +186,21 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
plot.current?.destroy()
plot.current = null
}
}, [t, series, height, unit, zero, gaps, reset])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [structure, reset])
useEffect(() => {
const u = plot.current
if (!u) return
u.setData(dataOf(), !zoom.current)
u.redraw(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, series, gaps])
return (
<div className="chart-wrap">
<div className="chart-legend">
{series.map((s) => (
{series.filter((s) => !s.faint).map((s) => (
<span key={s.label}>
<i style={{ background: `var(${s.color})` }} />
{s.label}