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

+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>线路质量</title>
<title>MyMonitor</title>
</head>
<body>
<div id="root"></div>
+6 -4
View File
@@ -1,3 +1,4 @@
import { BuildFooter } from './BuildFooter'
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
import { SourceButton, ThemeToggle } from '@shared/ui'
import { Compare } from './pages/Compare'
@@ -9,9 +10,6 @@ export function App() {
<>
<header className="topbar">
<div className="inner">
<NavLink to="/" className="brand">
线路质量
</NavLink>
<nav className="topnav">
<NavLink to="/" end className={({ isActive }) => (isActive ? 'active' : '')}>
总览
@@ -21,7 +19,7 @@ export function App() {
</NavLink>
<a href="/admin/">管理</a>
</nav>
<SourceButton />
<SourceButton variant="status" />
<ThemeToggle />
</div>
</header>
@@ -31,6 +29,10 @@ export function App() {
<Route path="/compare" element={<Compare />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<footer className="site-foot">
国家和运营商由 <a href="https://ip.007995.xyz/" target="_blank" rel="noreferrer">ip.007995.xyz</a> 查询
<BuildFooter />
</footer>
</>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { api } from '@shared/api/client'
import type { AboutDto, BuildDto } from '@shared/api/types'
import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo'
import { useQuery } from '@shared/hooks'
/** 「0.1.1 · v0.1.1-5-gabc123 · 2026-10-06 20:30」:版本、tag(和版本一样就不重复写)、构建时间。 */
function buildText(b: { version: string; tag?: string | null; built_at?: string | null }): string {
const parts = [b.version]
if (b.tag && b.tag.replace(/^v/, '') !== b.version) parts.push(b.tag)
const at = fmtBuiltAt(b.built_at)
if (at) parts.push(at)
return parts.join(' · ')
}
/**
* 页面底部的构建信息:后端和前端的版本、tag、构建时间。要管理员在设置里打开「site.show_build」才显示;
* 关着的时候(或者后端太旧没有这个接口)什么也不画,前端自己的信息也不单独露出来。
*/
export function BuildFooter() {
const about = useQuery(() => api.get<AboutDto>('/api/public/about'), [], 5 * 60_000)
if (!about.data?.show_build || !about.data.backend) return null
const backend: BuildDto = about.data.backend
return (
<div className="build-info">
<span>后端 {buildText(backend)}</span>
<span>前端 {buildText({ version: frontendBuild.version, tag: frontendBuild.tag, built_at: frontendBuild.builtAt })}</span>
</div>
)
}
+143
View File
@@ -0,0 +1,143 @@
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'
import { useQuery } from '@shared/hooks'
/** 总览卡片可以看的时长,以及每一格的长度(格数约 36~48,太多就挤了)。 */
export const OVERVIEW_RANGES = [
{ value: '3h', label: '3 小时', hours: 3, step: '5m', stepMin: 5 },
{ value: '6h', label: '6 小时', hours: 6, step: '10m', stepMin: 10 },
{ value: '12h', label: '12 小时', hours: 12, step: '15m', stepMin: 15 },
] as const
export type OverviewRange = (typeof OVERVIEW_RANGES)[number]['value']
type Level = 'ok' | 'warn' | 'bad' | 'none'
const levelOf = (loss: number | null | undefined): Level => (loss === null || loss === undefined ? 'none' : loss <= 0.5 ? 'ok' : loss < 3 ? 'warn' : 'bad')
const clock = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
interface StripProps {
/** 左上:什么延迟(UDP 延迟 p50)。 */
rttLabel: string
rtt: number | null | undefined
/** 右上:丢包还是重传。 */
badLabel: string
bad: number | null | undefined
badHint?: string
/** 每 5 分钟一格:这一格的丢包(重传)率、延迟。 */
t: number[]
badSeries: (number | null)[]
ready: boolean
/** 提示里这一格的「丢包」叫什么。 */
tipBad: string
/** 没有数据时占位的格数,和每格长度的说明(「每 5 分钟」)。 */
cells: number
stepText: string
hoursText: string
/** 悬停的那一格(UDP 和 TCP 共用);`null` = 没有。 */
hover: number | null
}
/**
* 一排小格(每 5 分钟一格,按这 5 分钟的丢包率着色:绿 / 黄 / 红 / 灰 = 没有数据)
* 尽量轻。
*/
function Strip({ rttLabel, rtt, badLabel, bad, badHint, t, badSeries, ready, cells, stepText, hoursText, hover }: StripProps) {
return (
<div className="udp-strip">
<div className="udp-head">
<span className="udp-title">
{rttLabel} <b className="num">{fmtMs(rtt)}</b>
</span>
<span className="udp-title" title={badHint}>
{badLabel} <b className={`udp-rate num ${levelOf(bad)}`}>{fmtPct(bad)}</b>
</span>
</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' : ''}`} />
))}
</div>
</div>
)
}
interface Props {
lineId: string
range: OverviewRange
/** 近 3 小时的汇总(左上、右上的数字)。 */
m: Record<string, number | null | undefined>
}
/** 一条线路近一段时间的 UDP 和 TCP:各是一排小格,共用一次请求。 */
export function LineStrips({ lineId, range, m }: Props) {
const info = OVERVIEW_RANGES.find((r) => r.value === range) ?? OVERVIEW_RANGES[0]
const series = useQuery(
() => api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { range: info.value, anchor: 'latest', step: info.step, preset: 'udp,tcp', metrics: 'loss.total,rtt.p50,tcp.retrans,tcp.rtt.p50' }),
[lineId, info.value],
30_000,
)
const s = series.data
const t = s?.t ?? []
const col = (key: string): (number | null)[] => s?.values[key] ?? []
// 悬停的那一格:UDP 和 TCP 的时间轴一样,所以同一格同时高亮;提示弹在卡片右侧,一次展示这段时间的 UDP 和 TCP
const [hover, setHover] = useState<number | null>(null)
const last = useRef<number | null>(null) // 提示收回去的动画期间,内容还要留着
if (hover !== null) last.current = hover
const shown = hover ?? last.current
// 整块线路区域都能触发:按鼠标横向位置对应到小格(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" ref={box} onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
<Strip
{...common}
rttLabel="UDP 延迟 p50"
rtt={m['rtt.p50']}
badLabel="丢包"
bad={m['loss.total']}
badHint={`上行丢包 ${fmtPct(m['loss.up'])} 下行丢包 ${fmtPct(m['loss.down'])}`}
badSeries={col('loss.total')}
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 ? (
<>
<b className="pop-time">
{clock(t[at])}–{clock(t[at] + info.stepMin * 60_000)}
</b>
<div className="pop-group">
<span className="pop-name">UDP</span>
<span>
丢包 <em className={levelOf(col('loss.total')[at])}>{fmtPct(col('loss.total')[at])}</em>
</span>
<span>
延迟 p50 <em>{fmtMs(col('rtt.p50')[at])}</em>
</span>
</div>
<div className="pop-group">
<span className="pop-name">TCP</span>
<span>
重传 <em className={levelOf(col('tcp.retrans')[at])}>{fmtPct(col('tcp.retrans')[at])}</em>
</span>
<span>
RTT p50 <em>{fmtMs(col('tcp.rtt.p50')[at])}</em>
</span>
</div>
</>
) : null}
</div>
</div>
)
}
+258
View File
@@ -0,0 +1,258 @@
import { useEffect, useMemo, useRef, useState, type PointerEvent } from 'react'
import { LoadingBar } from '@shared/ui'
import { fmtSpan } from './ranges'
const MIN = 60_000
/** 选区最短 5 分钟。 */
const MIN_LEN = 5
const EDGE_PX = 9
interface Props {
/** 时间轴的起点(毫秒)。 */
domainStart: number
/** 时间轴的长度(分钟):1440 或 2880(连续 48 小时,选区可以跨零点)。 */
domainMin: number
t: number[]
/** 丢包(缩略图画它)。 */
values: (number | null)[]
fromMs: number
toMs: number
onSelect: (fromMs: number, toMs: number) => void
/** 缩略图的数据正在加载。 */
loading?: boolean
}
type Mode = 'l' | 'r' | 'move' | 'new'
interface Drag {
mode: Mode
anchor: number
from: number
to: number
moved: boolean
}
const hourOf = (ms: number) => new Date(ms).getHours()
const dateOf = (ms: number) => `${new Date(ms).getMonth() + 1}/${new Date(ms).getDate()}`
const clockOf = (ms: number) => `${String(new Date(ms).getHours()).padStart(2, '0')}:${String(new Date(ms).getMinutes()).padStart(2, '0')}`
/**
* 连续 48 小时的缩略图(丢包),上面有一块可拖动的选区:
* 拖空白处新建,拖两边调长短,拖中间整体平移;选区可以跨过零点(晚高峰常常是 20:00 到凌晨 02:00)。
* 点一下空白处或者刻度下面的钟点:离那里近的端点贴过去,另一个端点不动;鼠标移上去会标出这个位置的时间。
* 拖动过程中只在这里本地更新,松手才通知外面(外面再去取数),所以不会闪。
*/
export function Minimap({ domainStart, domainMin, t, values, fromMs, toMs, onSelect, loading = false }: Props) {
const box = useRef<HTMLDivElement>(null)
const drag = useRef<Drag | null>(null)
const latest = useRef<[number, number] | null>(null)
const [live, setLive] = useState<[number, number] | null>(null)
const [held, setHeld] = useState<[number, number] | null>(null)
const [hover, setHover] = useState<number | null>(null)
const propFrom = Math.round((fromMs - domainStart) / MIN)
const propTo = Math.round((toMs - domainStart) / MIN)
// 松手之后到外面的状态更新完成之前,选区停在松手的位置,不跳回去
useEffect(() => {
if (held && propFrom === held[0] && propTo === held[1]) setHeld(null)
}, [propFrom, propTo, held])
useEffect(() => {
if (!held) return
const id = window.setTimeout(() => setHeld(null), 4000)
return () => window.clearTimeout(id)
}, [held])
const shown = live ?? held ?? [propFrom, propTo]
const from = Math.max(0, shown[0])
const to = Math.min(domainMin, shown[1])
const path = useMemo(() => {
let max = 5
for (const v of values) if (v !== null && v !== undefined && v > max) max = v
const points: { x: number; v: number }[] = []
for (let i = 0; i < t.length; i++) {
const v = values[i]
const x = (t[i] - domainStart) / (domainMin * MIN)
if (v !== null && v !== undefined && x >= 0 && x <= 1) points.push({ x, v })
}
if (points.length < 2) return ''
const first = points[0].x * 1000
const last = points[points.length - 1].x * 1000
return `M${first.toFixed(1)},60 ` + points.map((p) => `L${(p.x * 1000).toFixed(1)},${(58 - (Math.min(p.v, max) / max) * 54).toFixed(1)}`).join(' ') + ` L${last.toFixed(1)},60 Z`
}, [t, values, domainStart, domainMin])
// 有数据的时间段:其余(还没有数据的更早时间、中间的缺口、还没到的未来)画成灰色斜纹
const covered = useMemo(() => {
const step = t.length > 1 ? t[1] - t[0] : 2 * MIN
const segments: { x: number; w: number }[] = []
let start = -1
let last = -1
const flush = () => {
if (start >= 0) segments.push({ x: ((start - domainStart) / (domainMin * MIN)) * 1000, w: Math.max(2, ((last + step - start) / (domainMin * MIN)) * 1000) })
start = -1
}
for (let i = 0; i < t.length; i++) {
if (values[i] === null || values[i] === undefined) continue
if (start >= 0 && t[i] - last > step * 2.5) flush()
if (start < 0) start = t[i]
last = t[i]
}
flush()
return segments
}, [t, values, domainStart, domainMin])
// 刻度:每小时一个小短线;24 小时刻度每 2 小时、48 小时刻度每 3 小时标一个钟点;零点标日期
const ticks = useMemo(() => {
const labelEvery = domainMin <= 1440 ? 2 : 3
const list: { hour: number; frac: number; label: string | null; midnight: boolean }[] = []
for (let h = 0; h <= domainMin / 60; h += 1) {
const ms = domainStart + h * 3_600_000
const hour = hourOf(ms)
const labelled = h % labelEvery === 0 || hour === 0
list.push({ hour: h, frac: h / (domainMin / 60), label: !labelled ? null : hour === 0 ? dateOf(ms) : `${hour}时`, midnight: hour === 0 })
}
return list
}, [domainStart, domainMin])
const minuteAt = (e: PointerEvent) => {
const rect = box.current!.getBoundingClientRect()
return Math.min(domainMin, Math.max(0, ((e.clientX - rect.left) / rect.width) * domainMin))
}
const clamp = (a: number, b: number): [number, number] => {
let lo = Math.round(Math.min(a, b))
let hi = Math.round(Math.max(a, b))
if (hi - lo < MIN_LEN) hi = Math.min(domainMin, lo + MIN_LEN)
if (hi - lo < MIN_LEN) lo = hi - MIN_LEN
return [Math.max(0, lo), Math.min(domainMin, hi)]
}
const down = (e: PointerEvent) => {
if (e.button !== 0) return
const rect = box.current!.getBoundingClientRect()
const pxPerMin = rect.width / domainMin
const at = minuteAt(e)
let mode: Mode = 'new'
if (Math.abs(at - from) * pxPerMin <= EDGE_PX) mode = 'l'
else if (Math.abs(at - to) * pxPerMin <= EDGE_PX) mode = 'r'
else if (at > from && at < to) mode = 'move'
drag.current = { mode, anchor: at, from, to, moved: false }
latest.current = null
e.currentTarget.setPointerCapture(e.pointerId)
e.preventDefault()
}
const move = (e: PointerEvent) => {
const d = drag.current
if (!d) return
const at = minuteAt(e)
setHover(null)
if (!d.moved && Math.abs(at - d.anchor) * (box.current!.getBoundingClientRect().width / domainMin) < 3) return
d.moved = true
let next: [number, number]
if (d.mode === 'new') next = clamp(d.anchor, at)
else if (d.mode === 'l') next = clamp(at, d.to)
else if (d.mode === 'r') next = clamp(d.from, at)
else {
const len = d.to - d.from
const start = Math.min(domainMin - len, Math.max(0, d.from + (at - d.anchor)))
next = [Math.round(start), Math.round(start + len)]
}
latest.current = next
setLive(next)
}
const up = () => {
const d = drag.current
const result = latest.current
drag.current = null
latest.current = null
setLive(null)
if (d?.moved && result && (result[0] !== d.from || result[1] !== d.to)) {
setHeld(result)
onSelect(domainStart + result[0] * MIN, domainStart + result[1] * MIN)
} else if (d && !d.moved && (d.mode === 'new' || d.mode === 'move')) {
// 只是点了一下:离点的位置近的那个端点贴过去,另一个端点不动
snapTo(d.anchor, d.from, d.to)
}
}
/** 哪个端点离 `minute` 近,就把哪个端点移到那里(点在选区外面也一样),另一端不动。 */
const predict = (minute: number, a = from, b = to): { next: [number, number]; side: 'l' | 'r' } => {
const at = Math.round(minute)
return Math.abs(at - a) <= Math.abs(at - b) ? { next: clamp(at, b), side: 'l' } : { next: clamp(a, at), side: 'r' }
}
const snapTo = (minute: number, a = from, b = to) => {
const { next } = predict(minute, a, b)
if (next[0] === a && next[1] === b) return
setHeld(next)
onSelect(domainStart + next[0] * MIN, domainStart + next[1] * MIN)
}
// 点下去会动的是哪个端点:从那个端点到目标位置之间染色(扩大是一种颜色,收缩是另一种),那个端点也变色
const target = hover !== null && !live && !drag.current ? predict(hover) : null
const sweep = target
? target.side === 'l'
? { lo: Math.min(from, target.next[0]), hi: Math.max(from, target.next[0]), grow: target.next[0] < from, changed: target.next[0] !== from }
: { lo: Math.min(to, target.next[1]), hi: Math.max(to, target.next[1]), grow: target.next[1] > to, changed: target.next[1] !== to }
: null
const jumpTo = (tickHour: number) => snapTo(tickHour * 60)
const hoverMove = (e: PointerEvent) => {
if (drag.current) return
setHover(minuteAt(e))
}
const length = to - from
return (
<div className="minimap">
<div ref={box} className="track" onPointerDown={down} onPointerMove={(e) => { move(e); hoverMove(e) }} onPointerLeave={() => setHover(null)} onPointerUp={up} onPointerCancel={up} role="slider" aria-label="拖动选择时间段" aria-valuemin={0} aria-valuemax={domainMin} aria-valuenow={from} tabIndex={0}>
<LoadingBar active={loading} />
<svg viewBox="0 0 1000 60" preserveAspectRatio="none" aria-hidden="true">
{covered.map((c) => (
<rect key={c.x} className="has" x={c.x} y="0" width={c.w} height="60" />
))}
<path d={path} />
</svg>
{ticks
.filter((x) => x.midnight)
.map((x) => (
<div key={x.frac} className="midnight" style={{ left: `${x.frac * 100}%` }} />
))}
{sweep?.changed ? <div className={`sweep ${sweep.grow ? 'grow' : 'cut'}`} style={{ left: `${(sweep.lo / domainMin) * 100}%`, width: `${((sweep.hi - sweep.lo) / domainMin) * 100}%` }} /> : null}
<div className="sel" style={{ left: `${(from / domainMin) * 100}%`, width: `${(length / domainMin) * 100}%` }}>
<i className={`h l ${sweep?.changed && target?.side === 'l' ? `will ${sweep.grow ? 'grow' : 'cut'}` : ''}`} />
<i className={`h r ${sweep?.changed && target?.side === 'r' ? `will ${sweep.grow ? 'grow' : 'cut'}` : ''}`} />
</div>
{hover !== null && !live ? (
<>
<div className="guide" style={{ left: `${(hover / domainMin) * 100}%` }} />
<div className="guide-label num" style={{ left: `${Math.min(88, Math.max(0, (hover / domainMin) * 100 - 6))}%` }}>
{dateOf(domainStart + hover * MIN)} {clockOf(domainStart + hover * MIN)}
</div>
</>
) : null}
<div className="ticks">
{ticks.map((x, i) => (
<button
key={x.hour}
type="button"
className={`tick ${x.label ? '' : 'minor'} ${x.midnight ? 'date' : ''}`}
style={{ left: `${x.frac * 100}%`, transform: i === 0 ? 'none' : i === ticks.length - 1 ? 'translateX(-100%)' : undefined }}
onPointerDown={(e) => e.stopPropagation()}
onPointerMove={(e) => {
// 停在刻度上:按这个钟点预览(不是鼠标所在的像素位置)
e.stopPropagation()
if (x.hour * 60 < domainMin) setHover(x.hour * 60)
}}
onClick={() => jumpTo(x.hour)}
title={`离 ${clockOf(domainStart + x.hour * 3_600_000)} 近的端点移到这里`}
tabIndex={-1}
disabled={x.hour * 60 >= domainMin}
>
{x.label}
</button>
))}
</div>
</div>
<div className="readout num">
{fmtSpan(domainStart + from * MIN, domainStart + to * MIN)}
<span className="faint"> · {length >= 60 ? `${Math.floor(length / 60)} 小时${length % 60 ? ` ${length % 60} 分` : ''}` : `${length} 分钟`}</span>
</div>
</div>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+773 -6
View File
@@ -239,6 +239,7 @@
}
.agent-card {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
@@ -276,14 +277,28 @@
.line-link {
display: flex;
align-items: center;
gap: 8px;
flex-direction: column;
align-items: stretch;
gap: 6px;
min-width: 0;
padding: 6px 10px;
border-radius: var(--r-control);
background: var(--app-card-inset);
color: var(--app-ink);
}
.line-link > .muted {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.line-link > .scores {
flex-wrap: wrap;
margin-left: auto;
}
.line-link:hover {
text-decoration: none;
background: var(--app-card-hover);
@@ -398,15 +413,42 @@
background: var(--app-ok);
}
.line-link > * {
white-space: nowrap;
}
.range-picker {
display: flex;
flex-direction: column;
}
.day-pick {
position: relative;
display: inline-flex;
align-items: center;
height: 36px;
padding: 0 16px;
border: 0;
border-radius: var(--r-control);
background: var(--app-card-inset);
color: var(--app-ink);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.day-pick:hover {
background: var(--app-card-hover);
}
.day-pick input {
position: absolute;
left: 0;
bottom: 0;
width: 1px;
height: 1px;
padding: 0;
border: 0;
opacity: 0;
pointer-events: none;
}
/* ── 对比:每个 Probe 的最佳线路 + 矩阵 ── */
.winners {
margin-top: 24px;
@@ -460,3 +502,728 @@
.cell svg {
color: var(--app-ok);
}
/* ── 线路详情:指标平铺成一格一格 ── */
.tiles {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
}
.tiles > .stat,
.tiles > .tile {
padding: 14px 16px;
border-radius: var(--r-card);
background: var(--app-card);
}
.tiles > .tile {
display: flex;
flex-direction: column;
gap: 8px;
grid-column: 1 / -1;
}
.tiles > .tile .k {
font-size: 12px;
color: var(--app-ink-faint);
}
.tiles .stat .v {
font-size: 22px;
}
.tiles > .tile .scores {
justify-content: flex-start;
}
/* ── 一天的缩略图和可拖动的选区 ── */
.minimap {
--pick: var(--chart-1);
margin-top: 12px;
}
.minimap .track {
position: relative;
height: 60px;
margin-bottom: 18px;
border-radius: var(--r-control);
/* 没有数据的地方:灰色斜纹 */
background: repeating-linear-gradient(135deg, var(--app-card-inset) 0 5px, var(--app-card-hover) 5px 10px);
cursor: crosshair;
touch-action: none;
user-select: none;
}
.minimap svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: var(--r-control);
overflow: hidden;
}
.minimap svg path {
fill: var(--app-bad);
opacity: 0.55;
}
.minimap svg rect.has {
fill: var(--app-card);
}
.minimap .sel {
position: absolute;
top: 0;
bottom: 0;
border: 2px solid var(--pick);
border-radius: var(--r-control);
background: color-mix(in srgb, var(--pick) 16%, transparent);
cursor: grab;
}
.minimap .sel .h {
position: absolute;
top: 12px;
bottom: 12px;
width: 6px;
border-radius: 3px;
background: var(--pick);
cursor: ew-resize;
}
.minimap .sel .h.l {
left: -4px;
}
.minimap .sel .h.r {
right: -4px;
}
.minimap .ticks {
position: absolute;
left: 0;
right: 0;
top: 100%;
height: 18px;
}
.minimap .tick {
position: absolute;
top: 0;
min-width: 16px;
height: 18px;
padding: 7px 2px 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--app-ink-faint);
font: inherit;
font-size: 11px;
line-height: 1;
cursor: pointer;
transform: translateX(-50%);
}
/* 刻度线:每小时一根,标了钟点的更长 */
.minimap .tick::before {
content: '';
position: absolute;
left: 50%;
top: 0;
width: 1px;
height: 4px;
background: var(--app-ink-faint);
opacity: 0.6;
}
.minimap .tick.minor::before {
height: 3px;
opacity: 0.35;
}
.minimap .tick:not(.minor)::before {
height: 6px;
}
.minimap .tick:hover:not(:disabled) {
background: var(--app-card-hover);
color: var(--app-ink);
}
.minimap .tick:disabled {
cursor: default;
}
.minimap .tick.date {
font-weight: 600;
color: var(--app-ink-muted);
}
.minimap .guide {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--app-ink-muted);
opacity: 0.7;
pointer-events: none;
}
.minimap .guide-label {
position: absolute;
top: 4px;
padding: 2px 6px;
border-radius: 6px;
background: var(--app-ink);
color: var(--app-card);
font-size: 11px;
white-space: nowrap;
pointer-events: none;
}
.minimap .readout {
font-size: 12px;
color: var(--app-ink-muted);
}
.day-pick {
user-select: none;
}
.minimap .midnight {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--app-ink-faint);
opacity: 0.6;
pointer-events: none;
}
/* ── 线路详情的图表:按 UDP、TCP、评分分组,宽图占满一行 ── */
.chart-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.chart-section h2 {
margin: 8px 4px 0;
font-size: 14px;
font-weight: 600;
color: var(--app-ink-muted);
}
.chart-section .chart-card.wide {
grid-column: 1 / -1;
}
.minimap .loading-bar {
z-index: 2;
border-radius: var(--r-control) var(--r-control) 0 0;
}
/* 点下去会动的端点:它到目标位置之间染色(扩大 = 强调色,收缩 = 灰色),端点本身也变色 */
.minimap .sweep {
position: absolute;
top: 0;
bottom: 0;
pointer-events: none;
z-index: 1;
}
.minimap .sweep.grow {
background: color-mix(in srgb, var(--chart-2) 32%, transparent);
}
.minimap .sweep.cut {
background: color-mix(in srgb, var(--app-ink) 18%, transparent);
}
.minimap .sel .h {
transition: background-color 0.12s ease;
}
.minimap .sel .h.will.grow {
background: var(--chart-2);
}
.minimap .sel .h.will.cut {
background: var(--app-ink-muted);
}
/* 顶栏里的连接状态:紧凑的一条,放在导航和主题切换之间 */
.topbar .src-status {
width: auto;
max-width: 220px;
border-radius: var(--r-pill, 999px);
padding: 6px 12px;
}
@media (max-width: 640px) {
.topbar .src-status .st {
display: none;
}
.topbar .src-status {
max-width: 120px;
}
}
.line-top {
display: flex;
align-items: center;
gap: 8px;
}
.lines-caption {
padding: 0 4px 2px;
font-size: 12px;
color: var(--app-ink-faint);
}
/* ── 总览卡片里的 UDP:一排小格(每 5 分钟一格,按丢包着色)+ 延迟小走势,尽量轻 ── */
.line-name {
font-weight: 600;
font-size: 13px;
}
.line-top .faint {
font-size: 11px;
}
.udp-strip {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
}
.udp-head {
display: flex;
align-items: baseline;
justify-content: space-between;
font-size: 11px;
}
.udp-title {
color: var(--app-ink-faint);
}
.udp-title b {
margin-left: 2px;
font-size: 13px;
font-weight: 700;
color: var(--app-ink);
}
.udp-title b.udp-rate.ok {
color: var(--app-ok);
}
.udp-title b.udp-rate.warn {
color: var(--app-warn);
}
.udp-title b.udp-rate.bad {
color: var(--app-bad);
}
.bars {
display: flex;
gap: 2px;
height: 10px;
}
.bar {
flex: 1;
min-width: 0;
border-radius: 2px;
background: var(--app-idle);
opacity: 0.35;
}
.bar.ok {
background: var(--app-ok);
opacity: 0.9;
}
.bar.warn {
background: var(--app-warn);
opacity: 1;
}
.bar.bad {
background: var(--app-bad);
opacity: 1;
}
/* UDP 和 TCP 两段之间留一点空 */
.strips {
display: flex;
flex-direction: column;
gap: 10px;
}
/* 悬停的那一格:小格描边、走势图上一条竖线和一个点,再弹一个小提示 */
.bar.on {
outline: 2px solid var(--app-ink);
outline-offset: 1px;
}
.strips {
position: relative;
}
/* 提示弹在卡片右侧:平时收在卡片边上(透明、偏左),悬停时滑出来,移开时缩回去 */
.pop {
position: absolute;
top: -4px;
left: calc(100% + 40px);
z-index: 20;
display: flex;
flex-direction: column;
gap: 8px;
width: 168px;
padding: 10px 12px;
border-radius: 12px;
background: var(--app-ink);
color: var(--app-card);
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transform: translateX(-14px) scale(0.96);
transform-origin: left center;
transition:
opacity 0.16s ease,
transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
box-shadow: 0 6px 20px color-mix(in srgb, var(--app-shadow), transparent 68%);
}
.pop.open {
opacity: 1;
transform: none;
}
/* 指向卡片的小三角 */
.pop::before {
content: '';
position: absolute;
top: 18px;
left: -5px;
width: 10px;
height: 10px;
background: var(--app-ink);
transform: rotate(45deg);
border-radius: 2px;
}
.pop-time {
font-size: 12px;
font-weight: 700;
opacity: 0.8;
}
.pop-group {
display: grid;
grid-template-columns: 1fr;
gap: 1px;
}
.pop-name {
font-size: 10px;
letter-spacing: 0.08em;
opacity: 0.6;
}
.pop em {
font-style: normal;
font-weight: 700;
margin-left: 4px;
}
.pop em.warn {
color: var(--app-warn-container);
}
.pop em.bad {
color: var(--app-bad-container);
}
/* 右边放不下(窄屏、卡片贴着右边)时,提示改成盖在卡片上方 */
@media (max-width: 900px) {
.pop {
left: auto;
right: 0;
top: -8px;
transform: translateY(8px) scale(0.96);
transform-origin: right bottom;
}
.pop::before {
display: none;
}
}
.site-foot {
max-width: 900px;
margin: 48px auto 24px;
padding: 0 16px;
font-size: 11px;
text-align: center;
color: var(--app-ink-faint);
}
.site-foot a {
color: inherit;
text-decoration: underline;
}
/* Agent 卡片:国旗开头,名字下面是「运营商 / 系统」两行(左边名字右边内容,和下面的流量、网速一个风格) */
/* 亚克力:整张卡片半透明 + 背景模糊,细亮边,大一点的投影;标题栏是稍深的一条,底下垫放大模糊的国旗 */
/* 背景图固定在最底下,自己模糊一点(边缘放大一点,免得模糊后露出白边);上面盖一层页面底色,亮色盖得浅一点,暗色盖得深 */
body::before {
content: '';
position: fixed;
inset: -24px;
z-index: -1;
background-image:
linear-gradient(color-mix(in srgb, var(--app-bg) var(--bg-veil), transparent), color-mix(in srgb, var(--app-bg) var(--bg-veil), transparent)),
url('./assets/bg.jpg');
background-size: cover;
background-position: center;
filter: blur(var(--bg-blur));
pointer-events: none;
}
:root {
--bg-veil: 50%;
--bg-blur: 10px;
}
:root[data-theme="dark"] {
--bg-veil: 82%;
}
.agent-card {
background: color-mix(in srgb, var(--app-card) 76%, transparent);
backdrop-filter: blur(28px) saturate(1.6);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 9%, transparent),
inset 0 1px 0 color-mix(in srgb, var(--app-card) 70%, transparent),
0 14px 32px -12px color-mix(in srgb, var(--app-shadow) 45%, transparent);
}
.agent-head {
position: relative;
isolation: isolate;
margin: -20px -20px 0;
padding: 16px 20px;
border-radius: var(--r-card) var(--r-card) 0 0;
background: color-mix(in srgb, var(--app-ink) 5%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--app-ink) 8%, transparent);
}
.agent-head .flag {
flex: none;
width: 24px;
height: 18px;
border-radius: 3px;
object-fit: cover;
box-shadow: 0 0 0 1px var(--app-outline-soft);
}
.agent-head .flag.ph {
display: inline-block;
background: var(--app-card-inset);
}
.facts {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.fact {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.fact .k {
color: var(--app-ink-faint);
}
.fact .v {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--app-ink);
}
.fact .v .isp {
flex: none;
width: 16px;
height: 16px;
object-fit: contain;
}
.fact .v .isp rect {
fill: var(--app-ink-faint);
}
.fact .v .isp text {
fill: var(--isp-ink);
font-size: 10px;
font-weight: 700;
}
.fact .v .isp-broadnet rect {
fill: var(--isp-broadnet);
}
/* 线路行:国旗在 Probe 名字前面,运营商图标在后面 */
.line-name {
display: inline-flex;
align-items: center;
gap: 6px;
}
.line-name .flag {
flex: none;
width: 18px;
height: 13.5px;
border-radius: 2px;
object-fit: cover;
box-shadow: 0 0 0 1px var(--app-outline-soft);
}
.line-top .isp {
flex: none;
width: 16px;
height: 16px;
object-fit: contain;
}
.line-top .isp rect {
fill: var(--app-ink-faint);
}
.line-top .isp text {
fill: var(--isp-ink);
font-size: 10px;
font-weight: 700;
}
.line-top .isp-broadnet rect {
fill: var(--isp-broadnet);
}
/* 亚克力卡片里的线路块:不用实色,只叠一层淡淡的墨色和细描边,透出卡片本身 */
.agent-card .line-link {
background: color-mix(in srgb, var(--app-ink) 4%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 7%, transparent);
}
.agent-card .line-link:hover {
background: color-mix(in srgb, var(--app-ink) 7%, transparent);
}
/* 线路详情、对比等页面的卡片和指标块:同样的亚克力,一层压一层 */
.card:not(.agent-card),
.tiles > .stat,
.tiles > .tile {
background: color-mix(in srgb, var(--app-card) 76%, transparent);
backdrop-filter: blur(28px) saturate(1.6);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 9%, transparent),
inset 0 1px 0 color-mix(in srgb, var(--app-card) 70%, transparent),
0 14px 32px -12px color-mix(in srgb, var(--app-shadow) 45%, transparent);
}
/* 小的指标块浮得低一点:阴影收小 */
.tiles > .stat {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 8%, transparent),
inset 0 1px 0 color-mix(in srgb, var(--app-card) 70%, transparent),
0 6px 14px -8px color-mix(in srgb, var(--app-shadow) 40%, transparent);
}
.topbar {
background: color-mix(in srgb, var(--app-card) 62%, transparent);
backdrop-filter: blur(20px) saturate(1.5);
border-bottom-color: color-mix(in srgb, var(--app-ink) 7%, transparent);
box-shadow: 0 4px 14px -8px color-mix(in srgb, var(--app-shadow) 28%, transparent);
}
/* 时间轴:同样的亚克力。有数据的段是半透明的亮底,没数据的段是淡淡的斜纹,整条有细描边和投影 */
.minimap .track {
background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--app-card) 55%, var(--app-idle)) 0 5px, color-mix(in srgb, var(--app-card) 70%, var(--app-idle)) 5px 10px);
backdrop-filter: blur(20px) saturate(1.6);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 9%, transparent),
0 10px 24px -12px color-mix(in srgb, var(--app-shadow) 45%, transparent);
}
.minimap svg rect.has {
fill: color-mix(in srgb, var(--app-card) 80%, transparent);
}
/* 直接落在背景图上的字(页面标题、分组名、说明):加一圈和页面底色一样的柔光,免得被图案吃掉 */
body {
text-shadow: 0 0 8px color-mix(in srgb, var(--app-bg) 90%, transparent), 0 0 2px color-mix(in srgb, var(--app-bg) 70%, transparent);
}
.card,
.pop,
.tiles > .stat,
.tiles > .tile,
.topbar,
button,
input {
text-shadow: none;
}
/* 时间轴整条做成圆角:轨道、里面的图、选区用同一个半径 */
.minimap .track,
.minimap svg,
.minimap .sel {
border-radius: 18px;
}
/* 版本、tag:等宽的小字 */
.fact .v.mono-tag,
.probe-tag {
font-family: var(--font-mono);
font-size: 11px;
}
.probe-tag {
padding: 0 6px;
border-radius: 6px;
background: color-mix(in srgb, var(--app-ink) 6%, transparent);
color: var(--app-ink-muted);
}
.build-info {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px 16px;
margin-top: 4px;
font-family: var(--font-mono);
font-size: 11px;
}
+6 -4
View File
@@ -4,7 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
import '@shared/theme/base.css'
import { configureSource } from '@shared/api/source'
import { applyStoredTheme } from '@shared/hooks'
import { ToastProvider } from '@shared/ui'
import { ErrorBoundary, ToastProvider } from '@shared/ui'
import { App } from './App'
import './display.css'
@@ -14,9 +14,11 @@ applyStoredTheme()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter basename={import.meta.env.BASE_URL}>
<ToastProvider>
<App />
</ToastProvider>
<ErrorBoundary>
<ToastProvider>
<App />
</ToastProvider>
</ErrorBoundary>
</BrowserRouter>
</StrictMode>,
)
+3 -3
View File
@@ -20,9 +20,9 @@ interface MetricInfo {
const METRICS: MetricInfo[] = [
{ id: 'rtt.p50', label: '延迟 p50', unit: 'ms', better: 'lower' },
{ id: 'rtt.p95', label: '延迟 p95', unit: 'ms', better: 'lower' },
{ id: 'loss.total', label: '丢包', unit: '%', better: 'lower' },
{ id: 'loss.up', label: '上行丢包', unit: '%', better: 'lower' },
{ id: 'loss.down', label: '下行丢包', unit: '%', better: 'lower' },
{ id: 'loss.total', label: 'UDP 丢包', unit: '%', better: 'lower' },
{ id: 'loss.up', label: 'UDP 上行丢包', unit: '%', better: 'lower' },
{ id: 'loss.down', label: 'UDP 下行丢包', unit: '%', better: 'lower' },
{ id: 'jitter.up.p95', label: '上行抖动', unit: 'ms', better: 'lower' },
{ id: 'jitter.down.p95', label: '下行抖动', unit: 'ms', better: 'lower' },
{ id: 'tcp.rtt.p50', label: 'TCP RTT p50', unit: 'ms', better: 'lower' },
+276 -134
View File
@@ -2,14 +2,19 @@ import { ChevronLeft } from 'lucide-react'
import { useMemo } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { LineDto, SeriesDto, TestDto } from '@shared/api/types'
import type { LineDto, TestDto } from '@shared/api/types'
import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart'
import { fmtAgo, fmtMbps, fmtMs, fmtNum, fmtPct, fmtTime } from '@shared/format'
import { NodeTag, useNodeInfos } from '@shared/nodes'
import { fmtBytes, useHosts } from '../live'
import { useQuery } from '@shared/hooks'
import { Chip, Dot, Spinner, Stat } from '@shared/ui'
import { Chip, Dot, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui'
import { PageError, ScoreChips } from '../lib'
import { availablePresets, defaultPreset, monthsWithData, PRESETS, RangePicker, resolveRange, SLICES, type PresetValue, type RangeState, type SliceValue } from '../ranges'
import { Minimap } from '../Minimap'
import { readSpec, usePeriod, usePeriodSeries, writeSpec } from '../periods'
import { dayBounds, dayKey, RangePicker, WINDOWS, type WindowValue } from '../ranges'
const MIN = 60_000
function tone(value: number | null | undefined, good: number, poor: number): 'ok' | 'warn' | 'bad' | '' {
if (value === null || value === undefined) return ''
@@ -23,70 +28,216 @@ interface HostSeries {
values: Record<string, (number | null)[]>
}
/** 指标卡片:好坏的分界(越低越好)和显示格式。 */
const TILES: { label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string }[] = [
{ label: 'UDP 延迟 p50', key: 'rtt.p50', fmt: fmtMs },
{ label: 'UDP 延迟 p95', key: 'rtt.p95', fmt: fmtMs },
{ label: 'UDP 丢包', key: 'loss.total', fmt: fmtPct, good: 0.5, poor: 3 },
{ label: 'UDP 上行丢包', key: 'loss.up', fmt: fmtPct, good: 0.5, poor: 3 },
{ label: 'UDP 下行丢包', key: 'loss.down', fmt: fmtPct, good: 0.5, poor: 3 },
{ label: 'UDP 最长连续丢包', key: 'loss.run_max', fmt: fmtNum, unit: '包' },
{ label: 'UDP 上行抖动 p95', key: 'jitter.up.p95', fmt: fmtMs },
{ label: 'UDP 下行抖动 p95', key: 'jitter.down.p95', fmt: fmtMs },
{ label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs },
{ label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs },
{ label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs },
{ label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 },
]
interface ChartDef {
id: string
title: string
unit: string
zero?: boolean
fill?: boolean
series: { key: string; label: string; color: string; dash?: number[] }[]
group: 'udp' | 'tcp' | 'score'
/** 占满一行。 */
wide?: boolean
}
const GROUPS = [
{ id: 'udp', title: 'UDP(往返探测)' },
{ id: 'tcp', title: 'TCP' },
{ id: 'score', title: '评分' },
] as const
const CHARTS: ChartDef[] = [
{ id: 'rtt', title: '延迟', group: 'udp', wide: true, unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] },
{ id: 'loss', title: '丢包', group: 'udp', unit: '%', series: [{ key: 'loss.up', label: '上行', color: '--chart-2' }, { key: 'loss.down', label: '下行', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] },
{ id: 'jitter', title: '抖动(p95)', group: 'udp', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] },
{ id: 'tcp', title: '往返时延(RTT)', group: 'tcp', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] },
{ id: 'retrans', title: '重传率', group: 'tcp', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] },
{ id: 'score', title: '综合评分', group: 'score', wide: true, unit: '', series: [{ key: 'score.reality', label: 'REALITY', color: '--chart-1' }, { key: 'score.hy2', label: 'Hy2', color: '--chart-2' }, { key: 'score.mc', label: 'MC', color: '--chart-4' }, { key: 'score.base', label: '综合', color: '--chart-5', dash: [4, 3] }] },
]
/** EWMA 平滑的力度:系数越小越平滑(s = α·x + (1−α)·s)。 */
const EWMA = [
{ value: 'off', label: '关', alpha: 0 },
{ value: 'low', label: '轻', alpha: 0.3 },
{ value: 'mid', label: '中', alpha: 0.1 },
{ value: 'high', label: '重', alpha: 0.03 },
] as const
type EwmaValue = (typeof EWMA)[number]['value']
/** 指数加权移动平均;缺口(没有数据)处断开,缺口后重新开始,不会把缺口两边连在一起。 */
function ewma(values: (number | null)[], alpha: number): (number | null)[] {
let state: number | null = null
return values.map((v) => {
if (v === null || v === undefined) {
state = null
return null
}
state = state === null ? v : alpha * v + (1 - alpha) * state
return state
})
}
/** 主机状态:实时数据(SSE)每隔几秒来一次,只重绘这一块,不影响上面的图表。 */
function HostCard({ agentId, range }: { agentId: string | undefined; range: string | null }) {
const history = useQuery(
() => (agentId && range ? api.get<HostSeries>(`/api/public/agents/${agentId}/host`, { range }) : Promise.resolve(undefined)),
[agentId, range],
30_000,
)
const { hosts } = useHosts()
const host = agentId ? hosts.get(agentId)?.host : undefined
const data = history.data
const charts = useMemo(
() =>
data
? {
usage: [
{ label: 'CPU', values: data.values['cpu'] ?? [], color: '--chart-1' },
{ label: '内存', values: data.values['mem'] ?? [], color: '--chart-2' },
{ label: '磁盘', values: data.values['disk'] ?? [], color: '--chart-4' },
],
speed: [
{ label: '上行', values: data.values['tx_bps'] ?? [], color: '--chart-2' },
{ label: '下行', values: data.values['rx_bps'] ?? [], color: '--chart-1' },
],
}
: undefined,
[data],
)
if (!host && !data) return null
return (
<div className="card">
<div className="card-title">主机状态</div>
{host ? (
<div className="grid stats">
<Stat label="CPU" value={host.cpu_percent === null ? '—' : host.cpu_percent.toFixed(0)} unit="%" />
<Stat label="内存" value={host.mem_total ? ((100 * (host.mem_used ?? 0)) / host.mem_total).toFixed(0) : '—'} unit="%" />
<Stat label="磁盘" value={host.disk_total ? ((100 * (host.disk_used ?? 0)) / host.disk_total).toFixed(0) : '—'} unit="%" />
<Stat label="累计流量 ↑ / ↓" value={`${fmtBytes(host.net_tx_total)} / ${fmtBytes(host.net_rx_total)}`} />
</div>
) : null}
{data && charts ? (
<div className="grid cols-2" style={{ marginTop: 16 }}>
<TimeChart t={data.t} unit="%" series={charts.usage} />
<TimeChart t={data.t} unit=" B/s" series={charts.speed} />
</div>
) : null}
</div>
)
}
export function Line() {
const { id = '' } = useParams()
const [params, setParams] = useSearchParams()
const line = useQuery(() => api.get<LineDto>(`/api/public/lines/${encodeURIComponent(id)}`), [id], 15_000)
const infos = useNodeInfos()
const ready = Boolean(line.data)
const sinceMs = line.data?.data_since ? Date.parse(line.data.data_since) : null
const nowMs = useMemo(() => Date.now(), [line.data])
const spanMs = sinceMs === null ? null : nowMs - sinceMs
const todayStart = dayBounds(nowMs).start
// 时间范围:只提供有数据的档位;URL 里的值如果超出了现有的数据就退回到默认
const wanted = params.get('range')
const available = availablePresets(spanMs)
const preset: PresetValue = PRESETS.some((x) => x.value === wanted) && available.some((x) => x.value === wanted) ? (wanted as PresetValue) : defaultPreset(spanMs)
const sliceParam = params.get('slice')
const state: RangeState = {
preset,
month: params.get('month'),
slice: SLICES.some((x) => x.value === sliceParam) ? (sliceParam as SliceValue) : 'all',
page: Math.max(0, Number(params.get('page') ?? 0) || 0),
}
const months = useMemo(() => monthsWithData(sinceMs, nowMs), [sinceMs, nowMs])
const resolved = resolveRange(state, months, nowMs)
const rangeKey = JSON.stringify(resolved.query)
const setRange = (next: RangeState) => {
const q = new URLSearchParams()
if (next.month) {
q.set('month', next.month)
if (next.slice !== 'all') q.set('slice', next.slice)
if (next.page > 0) q.set('page', String(next.page))
} else {
q.set('range', next.preset)
}
const spec = readSpec(params)
const period = usePeriod(id, ready, sinceMs, nowMs, spec)
const series = usePeriodSeries(id, ready, period)
const update = (fn: (q: URLSearchParams) => void) => {
const q = new URLSearchParams(params)
fn(q)
setParams(q, { replace: true })
}
const dayParam = (dayStart: number) => (dayStart === todayStart ? null : dayKey(dayStart))
const changeDay = (next: { dayStart: number; window: WindowValue | null }) =>
update((q) => writeSpec(q, { ...readSpec(q), day: dayParam(next.dayStart), range: next.window, at: null }))
const selectRange = (fromMs: number, toMs: number) =>
update((q) => {
const a = Math.round((fromMs - period.dayStart) / MIN)
const b = Math.round((toMs - period.dayStart) / MIN)
writeSpec(q, { ...readSpec(q), day: dayParam(period.dayStart), range: null, at: `${a}_${b}` })
})
/** 点了一档时长。看的是最新的(今天、选区到现在)就继续看「最近一段」;否则保持选区的起点,往后取这么长。 */
const pickLength = (w: WindowValue) =>
update((q) => {
const base = { ...readSpec(q), day: dayParam(period.dayStart) }
const info = WINDOWS.find((x) => x.value === w)!
if (w === 'day' || (period.isToday && (period.live || period.toMs >= nowMs - 2 * MIN))) {
writeSpec(q, { ...base, range: w, at: null })
return
}
const start = Math.min(period.fromMs, nowMs - info.ms)
const a = Math.round((start - period.dayStart) / MIN)
writeSpec(q, { ...base, range: null, at: `${a}_${a + info.ms / MIN}` })
})
const setScale = (scale: 24 | 48) =>
update((q) => {
const current = readSpec(q)
// 换回 24 小时:跨零点的选区放不下了,回到默认
writeSpec(q, { ...current, span: scale === 24 ? '24' : null, at: scale === 24 && period.domainMin > 1440 ? null : current.at })
})
const series = useQuery(
() => (line.data ? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(id)}/series`, { ...resolved.query, preset: 'udp,tcp,scores' }) : Promise.resolve(undefined)),
[id, rangeKey, Boolean(line.data)],
state.month ? undefined : 10_000,
)
const agentId = line.data?.agent_id
const hostHistory = useQuery(
() => (agentId && !state.month ? api.get<HostSeries>(`/api/public/agents/${agentId}/host`, { range: resolved.hostRange }) : Promise.resolve(undefined)),
[agentId, resolved.hostRange, Boolean(state.month)],
30_000,
)
const { hosts } = useHosts()
const host = agentId ? hosts.get(agentId)?.host : undefined
const lastTestId = line.data?.last_test?.id
const test = useQuery(() => (lastTestId ? api.get<TestDto>(`/api/public/tests/${lastTestId}`) : Promise.resolve(undefined)), [lastTestId])
const smooth = EWMA.find((x) => x.value === params.get('ewma')) ?? EWMA[0]
const s = series.data
const col = (key: string): (number | null)[] => s?.values[key] ?? []
const m = s?.summary.metrics ?? {}
const gaps = useMemo(() => (s?.gaps ?? []).map((g) => ({ from: Date.parse(g.from), to: Date.parse(g.to) })), [s])
const t = s?.t ?? []
const chart = (title: string, unit: string, list: ChartSeries[], zero = true) =>
s && list.some((x) => x.values.some((v) => v !== null)) ? (
<div className="card chart-card">
<h3>{title}</h3>
<TimeChart t={t} series={list} unit={unit} gaps={gaps} zero={zero} />
// 图表只在数据变化时更新;别的状态(加载中、实时推送)变化不会让它们闪
const charts = useMemo(
() =>
s
? GROUPS.map((group) => {
const cards = CHARTS.filter((def) => def.group === group.id).map((def) => {
const raw = def.series.map((x) => ({ x, values: s.values[x.key] ?? [] }))
// 开了 EWMA:原始数据画成淡线垫在后面,平滑后的线画在上面
const list: ChartSeries[] =
smooth.alpha > 0
? [
...raw.map(({ x, values }) => ({ label: `${x.label}(原始)`, values, color: x.color, faint: true })),
...raw.map(({ x, values }) => ({ label: x.label, values: ewma(values, smooth.alpha), color: x.color, dash: x.dash, fill: def.fill })),
]
: raw.map(({ x, values }) => ({ label: x.label, values, color: x.color, dash: x.dash, fill: def.fill }))
if (!list.some((x) => x.values.some((v) => v !== null))) return null
return { def, node: <TimeChart t={s.t} series={list} unit={def.unit} gaps={gaps} zero={def.zero !== false} height={def.wide ? 260 : 220} /> }
})
return { group, cards: cards.filter((c): c is NonNullable<typeof c> => c !== null) }
}).filter((g) => g.cards.length > 0)
: [],
[s, gaps, smooth.alpha],
)
const loadingNow = !ready || series.busy
const chartSections = charts.map(({ group, cards }) => (
<section className="chart-section" key={group.id}>
<h2>{group.title}</h2>
<div className="grid cols-2">
{cards.map(({ def, node }) => (
<div className={`card chart-card has-loading ${def.wide ? 'wide' : ''}`} key={def.id}>
<LoadingBar active={loadingNow} />
<h3>{def.title}</h3>
{node}
</div>
))}
</div>
) : null
</section>
))
const result = test.data?.result
const peak = result?.metrics?.['tcp.bulk.peak.max']
@@ -109,111 +260,102 @@ export function Line() {
<div className="row wrap" style={{ marginTop: 12 }}>
<Dot tone={line.data.online && line.data.probe_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
<span className="muted">{line.data.online ? (line.data.probe_connected ? '在线' : 'Agent 在线,Probe 未连接') : 'Agent 离线'}</span>
<span className="row" style={{ gap: 6 }}>
<span className="faint">Agent</span>
<NodeTag info={infos.get(line.data.agent_id)} />
</span>
<span className="row" style={{ gap: 6 }}>
<span className="faint">Probe</span>
<NodeTag info={infos.get(line.data.probe_id)} />
</span>
{line.data.labels.map((x) => (
<Chip key={x}>{x}</Chip>
))}
{series.loading && !s ? <Spinner /> : null}
</div>
) : null}
<div className="toolbar">
<RangePicker state={state} onChange={setRange} sinceMs={sinceMs} nowMs={nowMs} />
<span className="right row">
<span className="muted">{resolved.label}</span>
{series.loading && !s ? <Spinner /> : null}
</span>
<div style={{ marginTop: 16 }}>
<RangePicker
dayStart={period.dayStart}
fromMs={period.fromMs}
toMs={period.toMs}
onChange={changeDay}
onWindow={pickLength}
scale={period.domainMin > 1440 ? 48 : 24}
onScale={setScale}
sinceMs={sinceMs}
nowMs={nowMs}
spanMs={period.spanMs}
>
<Minimap
domainStart={period.domainStart}
domainMin={period.domainMin}
t={period.overview.t}
values={period.overview.loss}
fromMs={period.fromMs}
toMs={period.toMs}
loading={!ready || period.busy}
onSelect={selectRange}
/>
</RangePicker>
</div>
</section>
<PageError error={line.error ?? series.error} />
{!s && (loadingNow || !line.error) && !series.error ? (
// 还没有数据:先摆出占位,每块上面有进度条
<div className="stack" style={{ marginTop: 24 }}>
<div className="tiles">
{Array.from({ length: 12 }, (_, i) => (
<div className="skeleton has-loading" key={i}>
<LoadingBar active />
</div>
))}
</div>
<div className="skeleton chart has-loading">
<LoadingBar active />
</div>
<div className="grid cols-2">
{[0, 1].map((i) => (
<div className="skeleton chart has-loading" key={i}>
<LoadingBar active />
</div>
))}
</div>
</div>
) : null}
{s ? (
<div className="stack" style={{ marginTop: 24 }}>
<div className="card">
<div className="row wrap" style={{ justifyContent: 'space-between', gap: 24 }}>
<div className="grid stats grow">
<Stat label="延迟 p50" value={fmtMs(m['rtt.p50'])} />
<Stat label="延迟 p95" value={fmtMs(m['rtt.p95'])} />
<Stat label="丢包" value={fmtPct(m['loss.total'])} tone={tone(m['loss.total'], 0.5, 3)} />
<Stat label="上行丢包" value={fmtPct(m['loss.up'])} tone={tone(m['loss.up'], 0.5, 3)} />
<Stat label="下行丢包" value={fmtPct(m['loss.down'])} tone={tone(m['loss.down'], 0.5, 3)} />
<Stat label="抖动(上 / 下)" value={`${fmtMs(m['jitter.up.p95'])} / ${fmtMs(m['jitter.down.p95'])}`} />
</div>
<div className="tiles">
<div className="tile scores-tile has-loading">
<LoadingBar active={loadingNow} />
<span className="k">评分</span>
<ScoreChips metrics={m} />
</div>
<hr className="divider" />
<div className="grid stats">
<Stat label="TCP RTT p50" value={fmtMs(m['tcp.rtt.p50'])} />
<Stat label="TCP RTT p99" value={fmtMs(m['tcp.rtt.p99'])} />
<Stat label="TCP RTT 最大" value={fmtMs(m['tcp.rtt.max'])} />
<Stat label="TCP 重传" value={fmtPct(m['tcp.retrans'])} tone={tone(m['tcp.retrans'], 0.5, 3)} />
<Stat label="最长连续丢包" value={fmtNum(m['loss.run_max'])} unit="包" />
<Stat label="样本数(UDP / TCP)" value={`${fmtNum(s.summary.samples['udp_echo'])} / ${fmtNum(s.summary.samples['tcp_echo'])}`} />
</div>
{s.summary.coverage < 0.95 ? <p className="faint" style={{ marginTop: 12 }}>这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。</p> : null}
{TILES.map((t) => (
<Stat key={t.key} label={t.label} value={t.fmt(m[t.key])} unit={t.unit} tone={t.good !== undefined ? tone(m[t.key], t.good, t.poor ?? Infinity) : ''} />
))}
</div>
<p className="faint">
样本数:UDP {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])}
</p>
{s.summary.coverage < 0.95 ? <p className="faint">这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。</p> : null}
<div className="grid cols-2">
{chart('延迟(UDP 往返)', 'ms', [
{ label: 'p50', values: col('rtt.p50'), color: '--chart-1' },
{ label: 'p95', values: col('rtt.p95'), color: '--chart-2' },
{ label: 'p99', values: col('rtt.p99'), color: '--chart-3' },
])}
{chart('丢包', '%', [
{ label: '上行', values: col('loss.up'), color: '--chart-2' },
{ label: '下行', values: col('loss.down'), color: '--chart-3' },
{ label: '合计', values: col('loss.total'), color: '--chart-1', dash: [4, 3] },
])}
{chart('抖动(p95)', 'ms', [
{ label: '上行', values: col('jitter.up.p95'), color: '--chart-2' },
{ label: '下行', values: col('jitter.down.p95'), color: '--chart-1' },
])}
{chart('TCP 往返', 'ms', [
{ label: 'p50', values: col('tcp.rtt.p50'), color: '--chart-1' },
{ label: 'p95', values: col('tcp.rtt.p95'), color: '--chart-2' },
{ label: '最大', values: col('tcp.rtt.max'), color: '--chart-3' },
])}
{chart('TCP 重传', '%', [{ label: '重传率', values: col('tcp.retrans'), color: '--chart-3', fill: true }])}
{chart('综合评分', '', [
{ label: 'REALITY', values: col('score.reality'), color: '--chart-1' },
{ label: 'Hy2', values: col('score.hy2'), color: '--chart-2' },
{ label: 'MC', values: col('score.mc'), color: '--chart-4' },
{ label: '综合', values: col('score.base'), color: '--chart-5', dash: [4, 3] },
])}
<div className="row" style={{ justifyContent: 'flex-end', gap: 8 }}>
<span className="faint" title="指数加权移动平均:把线条磨平,看清趋势;原始数据以淡线垫在后面">
EWMA 平滑
</span>
<Segmented<EwmaValue>
options={EWMA.map((x) => ({ value: x.value, label: x.label }))}
value={smooth.value}
onChange={(v) => update((q) => (v === 'off' ? q.delete('ewma') : q.set('ewma', v)))}
/>
</div>
{chartSections}
{host || hostHistory.data ? (
<div className="card">
<div className="card-title">主机状态</div>
{host ? (
<div className="grid stats">
<Stat label="CPU" value={host.cpu_percent === null ? '—' : host.cpu_percent.toFixed(0)} unit="%" />
<Stat label="内存" value={host.mem_total ? ((100 * (host.mem_used ?? 0)) / host.mem_total).toFixed(0) : '—'} unit="%" />
<Stat label="磁盘" value={host.disk_total ? ((100 * (host.disk_used ?? 0)) / host.disk_total).toFixed(0) : '—'} unit="%" />
<Stat label="累计流量 ↑ / ↓" value={`${fmtBytes(host.net_tx_total)} / ${fmtBytes(host.net_rx_total)}`} />
</div>
) : null}
{hostHistory.data ? (
<div className="grid cols-2" style={{ marginTop: 16 }}>
<TimeChart
t={hostHistory.data.t}
unit="%"
series={[
{ label: 'CPU', values: hostHistory.data.values['cpu'] ?? [], color: '--chart-1' },
{ label: '内存', values: hostHistory.data.values['mem'] ?? [], color: '--chart-2' },
{ label: '磁盘', values: hostHistory.data.values['disk'] ?? [], color: '--chart-4' },
]}
/>
<TimeChart
t={hostHistory.data.t}
unit=" B/s"
series={[
{ label: '上行', values: hostHistory.data.values['tx_bps'] ?? [], color: '--chart-2' },
{ label: '下行', values: hostHistory.data.values['rx_bps'] ?? [], color: '--chart-1' },
]}
/>
</div>
) : null}
</div>
) : null}
<HostCard agentId={agentId} range={period.hostRange} />
{test.data && result ? (
<div className="card">
+81 -28
View File
@@ -1,16 +1,19 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { Link, useSearchParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { OverviewDto, OverviewLineDto } from '@shared/api/types'
import { fmtAgo, fmtMs, fmtPct } from '@shared/format'
import type { NodeInfoDto, OverviewDto, OverviewLineDto } from '@shared/api/types'
import { fmtAgo } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Dot, Empty, Segmented, Spinner } from '@shared/ui'
import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts, type HostItem } from '../live'
import { PageError, ScoreChips } from '../lib'
import { Flag, IspIcon, useNodeInfos } from '@shared/nodes'
import { OsIcon } from '@shared/osIcon'
import { PageError } from '../lib'
import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips'
const METRICS = ['rtt.p50', 'loss.total', 'jitter.up.p95', 'jitter.down.p95', 'score.base', 'score.reality', 'score.hy2', 'score.mc'].join(',')
const METRICS = ['rtt.p50', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',')
type SortKey = 'name' | 'rtt' | 'score'
type SortKey = 'name' | 'rtt' | 'loss'
interface AgentCard {
id: string
@@ -20,12 +23,6 @@ interface AgentCard {
lines: OverviewLineDto[]
}
function tone(value: number | null | undefined, good: number, poor: number): 'ok' | 'warn' | 'bad' | '' {
if (value === null || value === undefined) return ''
if (value <= good) return 'ok'
if (value >= poor) return 'bad'
return 'warn'
}
function Meter({ label, value, detail }: { label: string; value: number | null; detail?: string }) {
const level = value === null ? '' : value >= 90 ? 'bad' : value >= 75 ? 'warn' : 'ok'
@@ -41,17 +38,49 @@ function Meter({ label, value, detail }: { label: string; value: number | null;
)
}
function Card({ agent, host }: { agent: AgentCard; host: HostItem | undefined }) {
function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem | undefined; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
const h = host?.host ?? null
const stale = !agent.online || !host || host.stale
const info = infos.get(agent.id)
return (
<div className={`card agent-card ${agent.online ? '' : 'offline'}`}>
<div className="agent-head">
<Dot tone={agent.online ? 'ok' : 'bad'} />
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
{info?.country ? <Flag code={info.country} /> : <Dot tone={agent.online ? 'ok' : 'bad'} />}
<span className="agent-name">{agent.name}</span>
<Chip tone={agent.online ? 'ok' : 'bad'}>{agent.online ? '在线' : '离线'}</Chip>
{!agent.online && host?.last_seen_at ? <span className="faint">{fmtAgo(host.last_seen_at)}</span> : null}
</div>
{info?.isp_name || info?.os_name || info?.tag || info?.version ? (
<div className="facts">
{info.isp_name ? (
<div className="fact">
<span className="k">运营商</span>
<span className="v">
{info.isp_key ? <IspIcon ispKey={info.isp_key} name={info.isp_name} /> : null}
{info.isp_name}
</span>
</div>
) : null}
{info.os_name ? (
<div className="fact">
<span className="k">系统</span>
<span className="v">
<OsIcon os={info.os} name={info.os_name} />
{[info.os_name, info.arch].filter(Boolean).join(' / ')}
</span>
</div>
) : null}
{info.tag || info.version ? (
<div className="fact">
<span className="k">版本</span>
<span className="v mono-tag" title={info.version ? `版本 ${info.version}` : undefined}>
{info.tag || info.version}
</span>
</div>
) : null}
</div>
) : null}
{agent.labels.length > 0 ? (
<div className="line-meta">
{agent.labels.map((x) => (
@@ -65,12 +94,21 @@ function Card({ agent, host }: { agent: AgentCard; host: HostItem | undefined })
const m = l.summary.metrics
return (
<Link key={l.id} to={`/line/${encodeURIComponent(l.id)}`} className="line-link">
<span className="muted">→ {l.probe_name}</span>
<span className="grow" />
{!l.probe_connected && l.online ? <Chip tone="warn">Probe 未连接</Chip> : null}
<span className="num">{fmtMs(m['rtt.p50'])}</span>
<Chip tone={tone(m['loss.total'], 0.5, 3)}>{fmtPct(m['loss.total'])}</Chip>
<ScoreChips metrics={m} compact />
<div className="line-top">
<span className="line-name">
→ {infos.get(l.probe_id)?.country ? <Flag code={infos.get(l.probe_id)!.country!} /> : null}
{l.probe_name}
</span>
{infos.get(l.probe_id)?.isp_key ? <IspIcon ispKey={infos.get(l.probe_id)!.isp_key!} name={infos.get(l.probe_id)!.isp_name} /> : null}
{infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version ? (
<span className="probe-tag" title="Probe 版本">
{infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version}
</span>
) : null}
<span className="faint">近 {OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range}</span>
{!l.probe_connected && l.online ? <Chip tone="warn">Probe 未连接</Chip> : null}
</div>
<LineStrips lineId={l.id} range={range} m={m} />
</Link>
)
})}
@@ -113,7 +151,21 @@ export function Overview() {
const [probe, setProbe] = useState<string>('')
const [sort, setSort] = useState<SortKey>('name')
const { hosts, live } = useHosts()
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range: '15m', metrics: METRICS }), [], 10_000)
const infos = useNodeInfos()
// 近多久的汇总和走势:3 / 6 / 12 小时,记在链接里(?range=6h)
const [params, setParams] = useSearchParams()
const range: OverviewRange = OVERVIEW_RANGES.find((r) => r.value === params.get('range'))?.value ?? '3h'
const setRange = (v: OverviewRange) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev)
if (v === '3h') next.delete('range')
else next.set('range', v)
return next
},
{ replace: true },
)
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: METRICS }), [range], 10_000)
const probes = useMemo(() => [...new Set((data?.lines ?? []).map((l) => l.probe_name))].sort(), [data])
const cards = useMemo(() => {
@@ -131,13 +183,13 @@ export function Overview() {
return [...byAgent.values()].sort((a, b) => {
if (a.online !== b.online) return a.online ? -1 : 1
if (sort === 'name') return a.name.localeCompare(b.name)
const key = sort === 'rtt' ? 'rtt.p50' : 'score.base'
const x = best(a, key, sort === 'rtt' ? Math.min : Math.max)
const y = best(b, key, sort === 'rtt' ? Math.min : Math.max)
const key = sort === 'rtt' ? 'rtt.p50' : 'loss.total'
const x = best(a, key, Math.min)
const y = best(b, key, Math.min)
if (x === null && y === null) return a.name.localeCompare(b.name)
if (x === null) return 1
if (y === null) return -1
return sort === 'rtt' ? x - y : y - x
return x - y
})
}, [data, probe, sort])
@@ -146,7 +198,7 @@ export function Overview() {
return (
<main className="page">
<section className="hero">
<h1>线路质量</h1>
<h1>总览</h1>
<p className="sub">
{data ? `${online} / ${cards.length} 台在线` : '加载中…'}
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
@@ -154,11 +206,12 @@ export function Overview() {
<div className="toolbar">
{probes.length > 1 ? <Segmented options={[{ value: '', label: '全部 Probe' }, ...probes.map((p) => ({ value: p, label: p }))]} value={probe} onChange={setProbe} /> : null}
<span className="right row">
<Segmented<OverviewRange> options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: `近 ${r.label}` }))} value={range} onChange={setRange} />
<Segmented
options={[
{ value: 'name' as const, label: '名称' },
{ value: 'rtt' as const, label: '延迟' },
{ value: 'score' as const, label: '评分' },
{ value: 'loss' as const, label: '丢包' },
]}
value={sort}
onChange={setSort}
@@ -178,7 +231,7 @@ export function Overview() {
) : (
<div className="cards">
{cards.map((c) => (
<Card key={c.id} agent={c} host={hosts.get(c.id)} />
<Card key={c.id} agent={c} host={hosts.get(c.id)} range={range} infos={infos} />
))}
</div>
)
+182
View File
@@ -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 }
+130 -140
View File
@@ -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>
)
}
+6 -1
View File
@@ -1,20 +1,25 @@
import react from '@vitejs/plugin-react'
import { fileURLToPath } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import { buildDefines } from '../build-info.mjs'
// 开发时把 /api 转发给后端,地址取自环境变量 BACKEND_URL,或 web/.env.local 里的 BACKEND_URL=(见 web/.env.example):
// BACKEND_URL=https://mo.oeo.one npm run dev:display
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, fileURLToPath(new URL('..', import.meta.url)), '')
const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:5000').replace(/\/+$/, '')
const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:29181').replace(/\/+$/, '')
return {
base: '/',
define: buildDefines(mode === 'development'),
plugins: [react()],
resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } },
server: {
port: 5173,
strictPort: true,
fs: { allow: ['..'] },
proxy: {
// 开发时管理端是另一个 Vite 服务(5175);和线上一样用同源的 /admin/ 访问它
'/admin': { target: 'http://localhost:5175', changeOrigin: true, ws: true },
'/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
'/healthz': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
},