chore: initial project import
This commit is contained in:
commit
3fc4e5ca53
347 files changed
+56172
No files matched your search
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>线路质量</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "monitor-display",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint:css": "stylelint \"src/**/*.css\" \"../shared/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"uplot": "^1.6.31"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"stylelint": "^16.12.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// 颜色只能出现在 shared/theme/tokens.css:其他样式一律引用令牌
|
||||
export default {
|
||||
rules: {
|
||||
'color-no-hex': true,
|
||||
'color-named': 'never',
|
||||
'function-disallowed-list': ['rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch'],
|
||||
},
|
||||
overrides: [{ files: ['../shared/theme/tokens.css'], rules: { 'color-no-hex': null, 'function-disallowed-list': null } }],
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": { "@shared/*": ["../shared/*"] }
|
||||
},
|
||||
"include": ["src", "../shared"],
|
||||
"exclude": ["../shared/api/schema.ts"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"erasableSyntaxOnly": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
|
||||
// 开发时把 /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(/\/+$/, '')
|
||||
return {
|
||||
base: '/',
|
||||
plugins: [react()],
|
||||
resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } },
|
||||
server: {
|
||||
port: 5173,
|
||||
fs: { allow: ['..'] },
|
||||
proxy: {
|
||||
'/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
|
||||
'/healthz': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user