仪表盘重做:ECharts 曲线、常驻遥测、路由行直达详情

采集从仪表盘组件挪进模块级单例(web/telemetry.ts),流量曲线和每路
吞吐的历史跨页面留存——跟着组件走的话切一次页面就清零一次。每路吞吐
从 /connections 快照按连接逐条求差再按 inboundName 聚合:先求和再求差
会在连接关闭时算出负数。

图表换 ECharts(按需注册):手写 SVG 折线做不出平滑曲线和时间轴,
颜色在运行时从 CSS 变量取,跟随主题切换重取。

路由列表每行带走势、速率和可选的公网 IP 检测,点击跳路由页并选中
那一路(外壳传 focus 给 RouterPage 消费)。
This commit is contained in:
2026-08-11 22:26:36 +08:00
parent ce181e9ae5
commit eec5b791c8
15 changed files with 1385 additions and 376 deletions
+14
View File
@@ -165,6 +165,20 @@ export function collectInbounds(data: ConfigObject): Inbound[] {
return result
}
/**
* 这一路入站在内核连接快照里的 `inboundName`。
*
* 命名入站带的是配置里的 name 原词(内核源码 `adapter/inbound/addition.go:19`,
* 名字来自 listener 配置的 name 字段)。老式顶层端口没有名字,内核给它们造了
* `DEFAULT-协议` 这样的内部名(如 `listener/mixed/mixed.go:57` 的 DEFAULT-MIXED、
* `listener/http/server.go:61` 的 DEFAULT-HTTP)。按这个名字才能把连接、吞吐这些
* 运行时数据对回界面上的某一路。
*/
export function inboundStatKey(inbound: Inbound): string {
if (!inbound.isDefault) return inbound.name
return `DEFAULT-${inbound.type.toUpperCase()}`
}
/**
* 走规则表的流量最后交给谁。
*
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, test } from 'bun:test'
import {
lastRate,
pushWindow,
routeDeltas,
windowBytes,
type ConnTotals,
type RouteSample,
} from './telemetry.ts'
import type { ConnectionItem } from './connections.ts'
function conn(id: string, inbound: string, upload: number, download: number): ConnectionItem {
return {
id,
upload,
download,
start: '2026-08-11T00:00:00+08:00',
chains: ['DIRECT'],
rule: 'Match',
rulePayload: '',
metadata: {
host: 'example.com',
destinationIP: '1.2.3.4',
destinationPort: '443',
inboundName: inbound,
inboundPort: '7890',
},
}
}
describe('pushWindow', () => {
test('把窗口外的老点挤掉,保留窗口内的', () => {
const list = [{ t: 0 }, { t: 5_000 }, { t: 9_000 }]
const next = pushWindow(list, { t: 12_000 }, 5_000)
expect(next.map((p) => p.t)).toEqual([9_000, 12_000])
})
test('全部过期时只剩新点', () => {
const next = pushWindow([{ t: 0 }], { t: 60_000 }, 5_000)
expect(next.map((p) => p.t)).toEqual([60_000])
})
})
describe('routeDeltas', () => {
test('已有连接算差值,并按入站聚合', () => {
const prev: ConnTotals = new Map([
['a', { up: 100, down: 200 }],
['b', { up: 10, down: 20 }],
])
const { deltas } = routeDeltas(prev, [
conn('a', 'lan-a', 150, 260),
conn('b', 'lan-a', 30, 21),
])
expect(deltas['lan-a']).toEqual({ up: 70, down: 61 })
})
test('新连接把累计全部记入本次间隔', () => {
const { deltas } = routeDeltas(new Map(), [conn('a', 'lan-a', 500, 700)])
expect(deltas['lan-a']).toEqual({ up: 500, down: 700 })
})
test('累计变小(内核重启)按全新连接处理,不出负数', () => {
const prev: ConnTotals = new Map([['a', { up: 900, down: 900 }]])
const { deltas } = routeDeltas(prev, [conn('a', 'lan-a', 50, 60)])
expect(deltas['lan-a']).toEqual({ up: 50, down: 60 })
})
test('消失的连接不再出现在 next 里', () => {
const prev: ConnTotals = new Map([['gone', { up: 1, down: 1 }]])
const { next } = routeDeltas(prev, [conn('a', 'lan-a', 5, 5)])
expect(next.has('gone')).toBe(false)
expect(next.get('a')).toEqual({ up: 5, down: 5 })
})
test('没有名字的入站退回端口做键', () => {
const item = conn('a', '', 5, 5)
const { deltas } = routeDeltas(new Map(), [item])
expect(Object.keys(deltas)).toEqual(['7890'])
})
})
describe('windowBytes / lastRate', () => {
const samples: RouteSample[] = [
{ t: 0, ms: 3_000, up: 100, down: 100 },
{ t: 3_000, ms: 3_000, up: 300, down: 300 },
{ t: 6_000, ms: 2_000, up: 400, down: 600 },
]
test('windowBytes 只算窗口内的样本', () => {
expect(windowBytes(samples, 3_000)).toBe(300 + 300 + 400 + 600)
})
test('lastRate 按实际间隔换算成每秒', () => {
expect(lastRate(samples)).toEqual({ up: 200, down: 300 })
})
test('lastRate 没有样本时是 null', () => {
expect(lastRate([])).toBeNull()
})
})
+92
View File
@@ -0,0 +1,92 @@
/**
* 仪表盘遥测的纯逻辑:滑动窗口和按入站聚合的吞吐差值。
*
* 采集本身(开流、轮询)在 web/telemetry.ts 的单例里做;这里只回答
* 「两次连接快照之间每一路走了多少字节」这类纯问题,让 bun test 够得着。
*/
import type { ConnectionItem } from './connections.ts'
/** 流量大图保留多久的历史。切走页面数据还在,是这个窗口在兜底。 */
export const TRAFFIC_WINDOW_MS = 30 * 60_000
/** 每路吞吐走势保留多久。活跃判定也用同一个窗口。 */
export const ROUTE_WINDOW_MS = 5 * 60_000
/** 连接快照的轮询间隔。 */
export const ROUTE_POLL_MS = 3_000
/**
* 「活跃」的门槛:最近 5 分钟走过的字节数。
* 取 256 KB 是为了滤掉心跳、DNS 这类背景流量——它们在 5 分钟里通常只有几十 KB,
* 而任何真实使用(网页、更新检查)轻松超过这个数。
*/
export const ACTIVE_BYTES = 256 * 1024
/** 一个采样点。up/down 是这个采样间隔里走过的字节数(不是速率),ms 是实际间隔。 */
export interface RouteSample {
t: number
ms: number
up: number
down: number
}
/** 流量曲线的一个点。up/down 是内核报的每秒字节数。 */
export interface TrafficPoint {
t: number
up: number
down: number
}
/** 追加一个点并把窗口外的老点挤掉。窗口按时间算,不按条数——采样间隔不保证均匀。 */
export function pushWindow<T extends { t: number }>(list: T[], point: T, windowMs: number): T[] {
const cutoff = point.t - windowMs
const start = list.findIndex((p) => p.t >= cutoff)
return [...(start === -1 ? [] : list.slice(start)), point]
}
/** 上次快照里每条连接的累计字节,按连接 id 记。 */
export type ConnTotals = Map<string, { up: number; down: number }>
/**
* 两次连接快照之间,每一路入站走了多少字节。
*
* 按连接逐条求差再按入站聚合,而不是先按入站求和再求差:连接关闭后会从快照里
* 消失,直接对总和求差会算出负数。代价是连接关闭前最后一段流量丢失——对走势图
* 来说可以接受。新出现的连接把它的累计全部记入本次间隔(它就是这段时间里产生的)。
* 同一条 id 的累计变小说明内核重启过计数,按全新连接处理。
*/
export function routeDeltas(
prev: ConnTotals,
conns: ConnectionItem[],
): { deltas: Record<string, { up: number; down: number }>; next: ConnTotals } {
const deltas: Record<string, { up: number; down: number }> = {}
const next: ConnTotals = new Map()
for (const conn of conns) {
const key = conn.metadata.inboundName || conn.metadata.inboundPort
const last = prev.get(conn.id)
const fresh = !last || last.up > conn.upload || last.down > conn.download
const up = fresh ? conn.upload : conn.upload - last.up
const down = fresh ? conn.download : conn.download - last.down
const entry = (deltas[key] ??= { up: 0, down: 0 })
entry.up += up
entry.down += down
next.set(conn.id, { up: conn.upload, down: conn.download })
}
return { deltas, next }
}
/** 窗口内一共走了多少字节(上下行合计)。活跃判定用。 */
export function windowBytes(samples: RouteSample[], since: number): number {
let total = 0
for (const s of samples) {
if (s.t >= since) total += s.up + s.down
}
return total
}
/** 最近一个采样的速率(字节每秒)。没有采样时是 null——「没数据」和 0 不是一回事。 */
export function lastRate(samples: RouteSample[]): { up: number; down: number } | null {
const last = samples.at(-1)
if (!last || last.ms <= 0) return null
return { up: (last.up / last.ms) * 1000, down: (last.down / last.ms) * 1000 }
}