chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 3fc4e5ca53
347 files changed
+56172

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
import { SourceButton, ThemeToggle } from '@shared/ui'
import { Compare } from './pages/Compare'
import { Line } from './pages/Line'
import { Overview } from './pages/Overview'
export function App() {
return (
<>
<header className="topbar">
<div className="inner">
<NavLink to="/" className="brand">
线路质量
</NavLink>
<nav className="topnav">
<NavLink to="/" end className={({ isActive }) => (isActive ? 'active' : '')}>
总览
</NavLink>
<NavLink to="/compare" className={({ isActive }) => (isActive ? 'active' : '')}>
对比
</NavLink>
<a href="/admin/">管理</a>
</nav>
<SourceButton />
<ThemeToggle />
</div>
</header>
<Routes>
<Route path="/" element={<Overview />} />
<Route path="/line/:id" element={<Line />} />
<Route path="/compare" element={<Compare />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</>
)
}
+462
View File
@@ -0,0 +1,462 @@
/* 展示端:极简博客式的页面——顶栏、居中的内容列、大号标题、细分隔线 */
.topbar {
position: sticky;
top: 0;
z-index: 10;
background: color-mix(in srgb, var(--app-bg), transparent 8%);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--app-outline-soft);
}
.topbar .inner {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
height: 60px;
display: flex;
align-items: center;
gap: 24px;
}
.brand {
font-size: 18px;
font-weight: 700;
color: var(--app-ink);
}
.brand:hover {
text-decoration: none;
}
.topnav {
display: flex;
gap: 4px;
margin-left: auto;
}
.topnav a {
padding: 6px 14px;
border-radius: var(--r-pill);
color: var(--app-ink-muted);
font-weight: 500;
}
.topnav a:hover {
text-decoration: none;
background: var(--app-card-inset);
}
.topnav a.active {
background: var(--app-accent-container);
color: var(--app-on-accent-container);
}
.page {
max-width: 1200px;
margin: 0 auto;
padding: 40px 24px 80px;
}
.hero h1 {
font-size: 40px;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.2;
}
.hero .sub {
margin-top: 8px;
color: var(--app-ink-muted);
}
.section-label {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--app-accent);
font-weight: 700;
}
.section-head {
display: flex;
align-items: flex-end;
gap: 16px;
margin: 40px 0 16px;
flex-wrap: wrap;
}
.section-head h2 {
font-size: 20px;
font-weight: 700;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 24px;
}
.line-row td:first-child {
min-width: 200px;
}
.line-name {
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.line-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.scores {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.score-chip {
display: inline-flex;
flex-direction: column;
align-items: center;
min-width: 52px;
padding: 4px 10px;
border-radius: var(--r-control);
background: var(--app-card-inset);
line-height: 1.25;
}
.score-chip b {
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.score-chip small {
font-size: 10px;
color: var(--app-ink-muted);
}
.score-chip.ok {
background: var(--app-ok-container);
color: var(--app-ok);
}
.score-chip.warn {
background: var(--app-warn-container);
color: var(--app-warn);
}
.score-chip.bad {
background: var(--app-bad-container);
color: var(--app-bad);
}
.chart-card {
display: flex;
flex-direction: column;
gap: 4px;
}
.chart-card h3 {
font-size: 14px;
font-weight: 600;
}
.crumbs {
display: flex;
align-items: center;
gap: 8px;
color: var(--app-ink-muted);
margin-bottom: 12px;
}
.event-list {
display: flex;
flex-direction: column;
}
.event-list > div {
display: flex;
gap: 16px;
padding: 8px 0;
border-bottom: 1px solid var(--app-outline-soft);
}
.event-list > div:last-child {
border-bottom: 0;
}
.pick-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pick {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: var(--r-pill);
background: var(--app-card-inset);
cursor: pointer;
user-select: none;
}
.pick.on {
background: var(--app-accent-container);
color: var(--app-on-accent-container);
}
.pick input {
display: none;
}
@media (max-width: 700px) {
.hero h1 {
font-size: 30px;
}
.table .hide-sm {
display: none;
}
}
/* ── Agent 卡片 ── */
.cards {
margin-top: 24px;
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
}
.agent-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
}
.agent-card.offline {
opacity: 0.62;
}
.agent-head {
display: flex;
align-items: center;
gap: 8px;
}
.agent-name {
font-size: 17px;
font-weight: 700;
}
.agent-head .chip {
margin-left: auto;
}
.agent-head .chip + .faint {
font-size: 12px;
}
.lines {
display: flex;
flex-direction: column;
gap: 4px;
}
.line-link {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--r-control);
background: var(--app-card-inset);
color: var(--app-ink);
}
.line-link:hover {
text-decoration: none;
background: var(--app-card-hover);
}
.scores.compact {
gap: 3px;
}
.scores.compact .score-chip {
min-width: 0;
padding: 1px 6px;
flex-direction: row;
gap: 4px;
align-items: baseline;
}
.scores.compact .score-chip b {
font-size: 12px;
}
.scores.compact .score-chip small {
display: none;
}
.host {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--app-outline-soft);
}
.host.stale .meter i {
background: var(--app-idle);
}
.meter-row {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 44px;
align-items: center;
column-gap: 10px;
}
.meter-row .meter-detail {
grid-column: 2 / 4;
font-size: 11px;
margin-top: -2px;
}
.meter-label {
color: var(--app-ink-muted);
font-size: 12px;
}
.meter {
height: 8px;
border-radius: var(--r-pill);
background: var(--app-card-inset);
overflow: hidden;
}
.meter i {
display: block;
height: 100%;
border-radius: var(--r-pill);
background: var(--app-accent);
transition: width 0.4s;
}
.meter i.warn {
background: var(--app-warn);
}
.meter i.bad {
background: var(--app-bad);
}
.meter-value {
text-align: right;
font-size: 12px;
}
.host-grid {
display: grid;
grid-template-columns: 1fr;
gap: 4px;
margin-top: 4px;
font-size: 12px;
}
.host-grid .stat {
flex-direction: row;
gap: 12px;
align-items: baseline;
}
.host-grid .stat .k {
min-width: 56px;
}
.live-dot {
display: inline-block;
width: 8px;
height: 8px;
margin-left: 10px;
border-radius: 50%;
background: var(--app-idle);
}
.live-dot.on {
background: var(--app-ok);
}
.line-link > * {
white-space: nowrap;
}
.range-picker {
display: flex;
flex-direction: column;
}
/* ── 对比:每个 Probe 的最佳线路 + 矩阵 ── */
.winners {
margin-top: 24px;
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.winner {
display: flex;
flex-direction: column;
gap: 2px;
padding: 14px 18px;
}
.winner-name {
display: flex;
align-items: center;
gap: 6px;
font-size: 17px;
font-weight: 700;
color: var(--app-ok);
}
.matrix td.r {
padding-top: 6px;
padding-bottom: 6px;
}
.cell {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border: 0;
border-radius: var(--r-control);
background: transparent;
color: var(--app-ink);
cursor: pointer;
font-variant-numeric: tabular-nums;
}
.cell:hover {
background: var(--app-card-hover);
}
.cell.on {
background: var(--app-accent-container);
}
.cell svg {
color: var(--app-ok);
}
+56
View File
@@ -0,0 +1,56 @@
import { ApiError } from '@shared/api/client'
import { describeError } from '@shared/ui'
import { scoreTone } from '@shared/format'
export const RANGES = [
{ value: '15m', label: '15 分钟' },
{ value: '1h', label: '1 小时' },
{ value: '6h', label: '6 小时' },
{ value: '24h', label: '24 小时' },
{ value: '7d', label: '7 天' },
] as const
export type RangeValue = (typeof RANGES)[number]['value']
export const isRange = (v: string | null): v is RangeValue => RANGES.some((r) => r.value === v)
export const SCORE_MODELS = [
{ id: 'score.reality', label: 'REALITY' },
{ id: 'score.hy2', label: 'Hy2' },
{ id: 'score.mc', label: 'MC' },
{ id: 'score.base', label: '综合' },
] as const
export function ScoreChips({ metrics, compact }: { metrics: Record<string, number | null | undefined>; compact?: boolean }) {
return (
<div className={`scores ${compact ? 'compact' : ''}`}>
{SCORE_MODELS.map((m) => {
const v = metrics[m.id]
return (
<span key={m.id} className={`score-chip ${scoreTone(v)}`} title={`${m.label} 评分`}>
<b>{v === null || v === undefined ? '—' : Math.round(v)}</b>
<small>{m.label}</small>
</span>
)
})}
</div>
)
}
/** 站点没有公开时匿名访问会被拒绝:给出一个说得清楚的提示,而不是一条错误。 */
export function PageError({ error }: { error: unknown }) {
if (!error) return null
if (error instanceof ApiError && error.code === 'site_private') {
return (
<div className="card" style={{ marginTop: 24 }}>
<div className="card-title">站点未公开</div>
<p className="muted">管理员还没有把站点设为公开。管理员请先 <a href="/admin/">登录管理端</a>,在「系统 → 设置」里打开「站点公开」,或者带着登录状态访问这里。</p>
</div>
)
}
return <div className="notice bad" style={{ marginTop: 24 }}>{describeError(error)}</div>
}
export const lineLabel = (name: string, probe: string) => `${name} · ${probe}`
export const num = (v: number | null | undefined) => (v === null || v === undefined ? null : v)
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useState } from 'react'
import type { Page } from '@shared/api/types'
import { api } from '@shared/api/client'
import { openLive } from '@shared/api/source'
/** 展示页能拿到的主机状态:只有汇总的数字,没有型号和 IP。 */
export interface Host {
cpu_percent: number | null
mem_used: number | null
mem_total: number | null
disk_used: number | null
disk_total: number | null
net_rx_total: number | null
net_tx_total: number | null
net_rx_bps: number | null
net_tx_bps: number | null
uptime_seconds: number | null
at: string
}
export interface HostItem {
agent_id: string
name: string
online: boolean
stale: boolean
last_seen_at: string | null
host: Host | null
}
/**
* Agent 主机状态的实时订阅:先用 REST 取一次全量,再用 SSE(`hosts` 主题)接收每一次更新。
* 连接断开时浏览器会自动重连;重连之后服务端会先推一份当前快照。
*/
export function useHosts(): { hosts: Map<string, HostItem>; live: boolean } {
const [hosts, setHosts] = useState<Map<string, HostItem>>(new Map())
const [live, setLive] = useState(false)
useEffect(() => {
let alive = true
api
.get<Page<HostItem>>('/api/public/hosts')
.then((page) => alive && setHosts(new Map(page.items.map((i) => [i.agent_id, i]))))
.catch(() => undefined)
const source = openLive('/api/public/live?topics=hosts,nodes')
source.onopen = () => setLive(true)
source.onerror = () => setLive(false)
source.addEventListener('host', (event) => {
const data = JSON.parse((event as MessageEvent<string>).data) as { agent_id: string; online: boolean; host: Host }
setHosts((previous) => {
const next = new Map(previous)
const old = previous.get(data.agent_id)
next.set(data.agent_id, { agent_id: data.agent_id, name: old?.name ?? data.agent_id, online: data.online, stale: false, last_seen_at: data.host.at, host: data.host })
return next
})
})
source.addEventListener('node', (event) => {
const data = JSON.parse((event as MessageEvent<string>).data) as { id: string; online: boolean }
setHosts((previous) => {
const old = previous.get(data.id)
if (!old) return previous
const next = new Map(previous)
next.set(data.id, { ...old, online: data.online, stale: data.online ? old.stale : true })
return next
})
})
return () => {
alive = false
source.close()
}
}, [])
return { hosts, live }
}
export function fmtBytes(v: number | null | undefined): string {
if (v === null || v === undefined) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = v
let i = 0
while (value >= 1024 && i < units.length - 1) {
value /= 1024
i++
}
return `${value.toFixed(value >= 100 || i === 0 ? 0 : 2)} ${units[i]}`
}
export function fmtSpeed(v: number | null | undefined): string {
return v === null || v === undefined ? '—' : `${fmtBytes(v)}/s`
}
export function fmtUptime(seconds: number | null | undefined): string {
if (seconds === null || seconds === undefined) return '—'
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
return d > 0 ? `${d} 天 ${h} 小时 ${m} 分` : h > 0 ? `${h} 小时 ${m} 分` : `${m} 分 ${Math.floor(seconds % 60)} 秒`
}
export const pct = (used: number | null, total: number | null): number | null => (used !== null && total !== null && total > 0 ? (100 * used) / total : null)
+22
View File
@@ -0,0 +1,22 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
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 { App } from './App'
import './display.css'
configureSource('display')
applyStoredTheme()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter basename={import.meta.env.BASE_URL}>
<ToastProvider>
<App />
</ToastProvider>
</BrowserRouter>
</StrictMode>,
)
+213
View File
@@ -0,0 +1,213 @@
import { Star } from 'lucide-react'
import { useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { CompareDto, OverviewDto, OverviewLineDto } from '@shared/api/types'
import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart'
import { fmtMs, fmtPct } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Empty, Segmented, Spinner } from '@shared/ui'
import { lineLabel, PageError } from '../lib'
import { PRESETS, type PresetValue } from '../ranges'
interface MetricInfo {
id: string
label: string
unit: 'ms' | '%' | ''
better: 'lower' | 'higher'
}
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: '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' },
{ id: 'tcp.retrans', label: 'TCP 重传', unit: '%', better: 'lower' },
{ id: 'score.reality', label: 'REALITY 评分', unit: '', better: 'higher' },
{ id: 'score.hy2', label: 'Hy2 评分', unit: '', better: 'higher' },
{ id: 'score.mc', label: 'MC 评分', unit: '', better: 'higher' },
{ id: 'score.base', label: '综合评分', unit: '', better: 'higher' },
]
const COLORS = ['--chart-1', '--chart-2', '--chart-3', '--chart-4', '--chart-5', '--chart-6']
const MAX_LINES = 6
const isPreset = (v: string | null): v is PresetValue => PRESETS.some((p) => p.value === v)
function fmt(m: MetricInfo, v: number | null | undefined): string {
if (v === null || v === undefined) return '—'
return m.unit === 'ms' ? fmtMs(v) : m.unit === '%' ? fmtPct(v) : v.toFixed(0)
}
interface Cell {
line: OverviewLineDto
value: number | null
/** 在这一列(同一个 Probe)里的名次,从 1 开始;没有数据是 null。 */
rank: number | null
/** 比这一列最好的差多少(百分比);最好的是 0。 */
gap: number | null
}
export function Compare() {
const [params, setParams] = useSearchParams()
const range: PresetValue = isPreset(params.get('range')) ? (params.get('range') as PresetValue) : '1d'
const metric = METRICS.find((m) => m.id === params.get('metric')) ?? METRICS[0]
const picked = useMemo(() => (params.get('lines') ?? '').split(',').filter(Boolean), [params])
const overview = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: metric.id }), [range, metric.id], 15_000)
// 矩阵:行是 Agent,列是 Probe;每一列内部排名(同一个 Probe 看出去,哪个 Agent 的线路好)
const matrix = useMemo(() => {
const lines = overview.data?.lines ?? []
const probes = [...new Map(lines.map((l) => [l.probe_id, l.probe_name])).entries()].sort((a, b) => a[1].localeCompare(b[1]))
const agents = [...new Map(lines.map((l) => [l.agent_id, l.name])).entries()].sort((a, b) => a[1].localeCompare(b[1]))
const byProbe = new Map<string, Cell[]>()
for (const [probeId] of probes) {
const cells: Cell[] = lines
.filter((l) => l.probe_id === probeId)
.map((line) => ({ line, value: line.summary.metrics[metric.id] ?? null, rank: null, gap: null }))
const ranked = cells.filter((c) => c.value !== null).sort((a, b) => (metric.better === 'lower' ? a.value! - b.value! : b.value! - a.value!))
const best = ranked[0]?.value ?? null
ranked.forEach((c, i) => {
c.rank = i + 1
c.gap = best === null || best === 0 ? (c.value === best ? 0 : null) : (Math.abs(c.value! - best) / Math.abs(best)) * 100
})
byProbe.set(probeId, cells)
}
return { probes, agents, byProbe }
}, [overview.data, metric])
// 默认选每个 Probe 里最好的那条线路(最多 6 条)
const defaults = useMemo(() => {
const ids: string[] = []
for (const [probeId] of matrix.probes) {
const best = matrix.byProbe.get(probeId)?.find((c) => c.rank === 1)
if (best) ids.push(best.line.id)
}
return ids.slice(0, MAX_LINES)
}, [matrix])
const selected = picked.length > 0 ? picked : defaults
const compare = useQuery(
() => (selected.length ? api.get<CompareDto>('/api/public/compare', { lines: selected.join(','), metric: metric.id, range }) : Promise.resolve(undefined)),
[selected.join(','), metric.id, range],
15_000,
)
const update = (patch: Record<string, string | null>) => {
const next = new URLSearchParams(params)
for (const [k, v] of Object.entries(patch)) {
if (v) next.set(k, v)
else next.delete(k)
}
setParams(next, { replace: true })
}
const toggle = (id: string) => {
const next = selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id].slice(-MAX_LINES)
update({ lines: next.join(',') })
}
const names = new Map((overview.data?.lines ?? []).map((l) => [l.id, lineLabel(l.name, l.probe_name)]))
const series: ChartSeries[] = selected.map((id, i) => ({ label: names.get(id) ?? id, values: compare.data?.values[id] ?? [], color: COLORS[i % COLORS.length] }))
// 每个 Probe 的最佳线路
const winners = matrix.probes.map(([probeId, probeName]) => ({ probeName, cell: matrix.byProbe.get(probeId)?.find((c) => c.rank === 1) }))
return (
<main className="page">
<section className="hero">
<h1>线路对比</h1>
<p className="sub">同一个指标,在每个 Probe 看出去,哪个 Agent 的线路更好。</p>
<div className="toolbar">
<Segmented options={PRESETS.map((p) => ({ value: p.value, label: p.label }))} value={range} onChange={(v) => update({ range: v })} />
<select className="select" style={{ width: 'auto' }} value={metric.id} onChange={(e) => update({ metric: e.target.value })}>
{METRICS.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
<span className="muted">{metric.better === 'lower' ? '越低越好' : '越高越好'}</span>
<span className="right">{overview.loading && !overview.data ? <Spinner /> : null}</span>
</div>
</section>
<PageError error={overview.error ?? compare.error} />
{overview.data ? (
matrix.agents.length === 0 ? (
<div className="card" style={{ marginTop: 24 }}>
<Empty>还没有线路</Empty>
</div>
) : (
<>
{winners.some((w) => w.cell) ? (
<div className="winners">
{winners.map((w) =>
w.cell ? (
<div key={w.probeName} className="card winner">
<span className="faint">从 {w.probeName} 看出去最好</span>
<span className="winner-name">
<Star size={16} /> {w.cell.line.name}
</span>
<span className="num muted">{fmt(metric, w.cell.value)}</span>
</div>
) : null,
)}
</div>
) : null}
<div className="card" style={{ marginTop: 16, padding: '8px 12px' }}>
<table className="table matrix">
<thead>
<tr>
<th>Agent / Probe</th>
{matrix.probes.map(([id, name]) => (
<th key={id} className="r">
{name}
</th>
))}
</tr>
</thead>
<tbody>
{matrix.agents.map(([agentId, agentName]) => (
<tr key={agentId}>
<td style={{ fontWeight: 600 }}>{agentName}</td>
{matrix.probes.map(([probeId]) => {
const cell = matrix.byProbe.get(probeId)?.find((c) => c.line.agent_id === agentId)
if (!cell) return <td key={probeId} className="r faint">—</td>
const on = selected.includes(cell.line.id)
const tone = cell.rank === 1 ? 'ok' : cell.gap !== null && cell.gap > 50 ? 'bad' : cell.gap !== null && cell.gap > 15 ? 'warn' : ''
return (
<td key={probeId} className="r">
<button type="button" className={`cell ${on ? 'on' : ''}`} onClick={() => toggle(cell.line.id)} title={on ? '从图里移除' : '加到下面的图里'}>
{cell.rank === 1 ? <Star size={13} /> : null}
<Chip tone={tone}>{fmt(metric, cell.value)}</Chip>
{cell.rank !== null && cell.rank > 1 && cell.gap !== null ? <small className="faint">+{cell.gap.toFixed(0)}%</small> : null}
</button>
</td>
)
})}
</tr>
))}
</tbody>
</table>
<p className="faint" style={{ padding: '4px 12px 8px' }}>
★ 是这一列(同一个 Probe)里最好的;点格子把线路加到下面的图里,最多 {MAX_LINES} 条。
</p>
</div>
<div className="card" style={{ marginTop: 16 }}>
<div className="card-title">{metric.label} 的走势</div>
{compare.data ? <TimeChart t={compare.data.t} series={series} unit={metric.unit === '' ? '' : metric.unit} /> : <Empty>{selected.length ? '加载中…' : '点上面的格子选择线路'}</Empty>}
</div>
</>
)
) : null}
</main>
)
}
+254
View File
@@ -0,0 +1,254 @@
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 { TimeChart, type ChartSeries } from '@shared/charts/TimeChart'
import { fmtAgo, fmtMbps, fmtMs, fmtNum, fmtPct, fmtTime } from '@shared/format'
import { fmtBytes, useHosts } from '../live'
import { useQuery } from '@shared/hooks'
import { Chip, Dot, 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'
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'
}
interface HostSeries {
t: number[]
values: Record<string, (number | null)[]>
}
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 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
// 时间范围:只提供有数据的档位;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)
}
setParams(q, { replace: true })
}
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 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} />
</div>
) : null
const result = test.data?.result
const peak = result?.metrics?.['tcp.bulk.peak.max']
return (
<main className="page">
<div className="crumbs">
<Link to="/" className="row">
<ChevronLeft size={16} />
总览
</Link>
</div>
<section className="hero">
<h1>
{line.data?.name ?? '线路'}
{line.data ? <span className="muted" style={{ fontWeight: 400 }}> → {line.data.probe_name}</span> : null}
</h1>
{line.data ? (
<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>
{line.data.labels.map((x) => (
<Chip key={x}>{x}</Chip>
))}
</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>
</section>
<PageError error={line.error ?? series.error} />
{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>
<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}
</div>
<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>
{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}
{test.data && result ? (
<div className="card">
<div className="card-title">最近一次测试 · {test.data.plan}</div>
<div className="grid stats">
{peak !== undefined ? <Stat label="吞吐峰值" value={fmtMbps(peak)} /> : null}
{result.metrics['connect.success'] !== undefined ? <Stat label="建连成功率" value={fmtPct(result.metrics['connect.success'], 1)} /> : null}
{result.metrics['connect.tls_ms.p95'] !== undefined ? <Stat label="TLS 握手 p95" value={fmtMs(result.metrics['connect.tls_ms.p95'])} /> : null}
{result.metrics['hy2.sustainable_mbps'] !== undefined ? <Stat label="Hy2 可持续码率" value={fmtMbps(result.metrics['hy2.sustainable_mbps'])} /> : null}
{result.metrics['hy2.qos_onset_mbps'] !== undefined ? <Stat label="限速起点" value={fmtMbps(result.metrics['hy2.qos_onset_mbps'])} /> : null}
</div>
<p className="faint" style={{ marginTop: 12 }}>
{fmtTime(test.data.finished_at)}({fmtAgo(test.data.finished_at)})
</p>
</div>
) : null}
{s.events.length > 0 ? (
<div className="card">
<div className="card-title">事件</div>
<div className="event-list">
{s.events.slice(0, 20).map((e) => (
<div key={e.id}>
<span className="faint num" style={{ minWidth: 120 }}>
{fmtTime(e.at)}
</span>
<span>{e.kind}</span>
{e.detail ? <span className="muted mono">{e.detail}</span> : null}
</div>
))}
</div>
</div>
) : null}
</div>
) : null}
</main>
)
}
+188
View File
@@ -0,0 +1,188 @@
import { useMemo, useState } from 'react'
import { Link } 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 { 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'
const METRICS = ['rtt.p50', 'loss.total', 'jitter.up.p95', 'jitter.down.p95', 'score.base', 'score.reality', 'score.hy2', 'score.mc'].join(',')
type SortKey = 'name' | 'rtt' | 'score'
interface AgentCard {
id: string
name: string
labels: string[]
online: boolean
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'
return (
<div className="meter-row">
<span className="meter-label">{label}</span>
<span className="meter">
<i className={level} style={{ width: `${Math.min(100, Math.max(0, value ?? 0))}%` }} />
</span>
<span className="meter-value num">{value === null ? '—' : `${value.toFixed(value >= 10 ? 0 : 1)}%`}</span>
{detail ? <span className="meter-detail faint num">{detail}</span> : null}
</div>
)
}
function Card({ agent, host }: { agent: AgentCard; host: HostItem | undefined }) {
const h = host?.host ?? null
const stale = !agent.online || !host || host.stale
return (
<div className={`card agent-card ${agent.online ? '' : 'offline'}`}>
<div className="agent-head">
<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>
{agent.labels.length > 0 ? (
<div className="line-meta">
{agent.labels.map((x) => (
<Chip key={x}>{x}</Chip>
))}
</div>
) : null}
<div className="lines">
{agent.lines.map((l) => {
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 />
</Link>
)
})}
</div>
<div className={`host ${stale ? 'stale' : ''}`}>
{h ? (
<>
<Meter label="CPU" value={h.cpu_percent} />
<Meter label="内存" value={pct(h.mem_used, h.mem_total)} detail={h.mem_total ? `${fmtBytes(h.mem_used)} / ${fmtBytes(h.mem_total)}` : undefined} />
<Meter label="磁盘" value={pct(h.disk_used, h.disk_total)} detail={h.disk_total ? `${fmtBytes(h.disk_used)} / ${fmtBytes(h.disk_total)}` : undefined} />
<div className="host-grid">
<div className="stat">
<span className="k">流量</span>
<span className="num">
↑ {fmtBytes(h.net_tx_total)} ↓ {fmtBytes(h.net_rx_total)}
</span>
</div>
<div className="stat">
<span className="k">网速</span>
<span className="num">
↑ {fmtSpeed(agent.online ? h.net_tx_bps : null)} ↓ {fmtSpeed(agent.online ? h.net_rx_bps : null)}
</span>
</div>
<div className="stat">
<span className="k">运行时间</span>
<span className="num">{fmtUptime(h.uptime_seconds)}</span>
</div>
</div>
</>
) : (
<div className="faint">这台 Agent 没有上报主机状态</div>
)}
</div>
</div>
)
}
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 probes = useMemo(() => [...new Set((data?.lines ?? []).map((l) => l.probe_name))].sort(), [data])
const cards = useMemo(() => {
const byAgent = new Map<string, AgentCard>()
for (const l of data?.lines ?? []) {
if (probe && l.probe_name !== probe) continue
const card = byAgent.get(l.agent_id) ?? { id: l.agent_id, name: l.name, labels: l.labels, online: l.online, lines: [] }
card.lines.push(l)
byAgent.set(l.agent_id, card)
}
const best = (c: AgentCard, key: string, pick: (a: number, b: number) => number) => {
const values = c.lines.map((l) => l.summary.metrics[key]).filter((v): v is number => v !== null && v !== undefined)
return values.length ? values.reduce(pick) : null
}
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)
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
})
}, [data, probe, sort])
const online = cards.filter((c) => c.online).length
return (
<main className="page">
<section className="hero">
<h1>线路质量</h1>
<p className="sub">
{data ? `${online} / ${cards.length} 台在线` : '加载中…'}
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
</p>
<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
options={[
{ value: 'name' as const, label: '名称' },
{ value: 'rtt' as const, label: '延迟' },
{ value: 'score' as const, label: '评分' },
]}
value={sort}
onChange={setSort}
/>
{loading && !data ? <Spinner /> : null}
</span>
</div>
</section>
<PageError error={error} />
{data ? (
cards.length === 0 ? (
<div className="card" style={{ marginTop: 24 }}>
<Empty>还没有线路</Empty>
</div>
) : (
<div className="cards">
{cards.map((c) => (
<Card key={c.id} agent={c} host={hosts.get(c.id)} />
))}
</div>
)
) : null}
</main>
)
}
+190
View File
@@ -0,0 +1,190 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useMemo } from 'react'
import { Segmented } from '@shared/ui'
const MIN = 60_000
const DAY = 24 * 60 * MIN
/** 线路详情页的时间范围:只提供这条线路“有数据”的档位。 */
export const PRESETS = [
{ value: '5m', label: '5 分钟', ms: 5 * MIN },
{ value: '15m', label: '15 分钟', ms: 15 * MIN },
{ value: '30m', label: '30 分钟', ms: 30 * MIN },
{ value: '1d', label: '1 天', ms: DAY },
{ value: '3d', label: '3 天', ms: 3 * DAY },
{ value: '7d', label: '7 天', ms: 7 * DAY },
{ value: '14d', label: '14 天', ms: 14 * DAY },
{ value: '30d', label: '一个月', ms: 30 * DAY },
] as const
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 },
] as const
export type SliceValue = (typeof SLICES)[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 // 不含
}
const pad = (n: number) => String(n).padStart(2, '0')
export function monthOf(ms: number): MonthInfo {
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 }
}
/** 从有数据的第一个月到当前月,新的在前。 */
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 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) => {
const d = new Date(ms)
return `${d.getMonth() + 1}/${d.getDate()}`
}
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,
}
}
/** 一个月里按固定长度能翻几页。 */
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) }
}
interface Props {
state: RangeState
onChange: (next: RangeState) => void
sinceMs: number | null
nowMs: number
}
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
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>
) : 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}
</div>
)
}