新增仪表盘页:流量曲线、流量与内存数字、配置概览、内核状态

流量曲线手画 SVG polyline 而不引图表库:60 个点两条线,一个图表库几百 KB,
决策记录 0006 留的例外用在这里不划算。/traffic 和 /memory 是永不结束的流,
api.request() 会把整个响应读完等于挂死,所以新增 api.openStream() 返回响应体,
交给 web/stream.ts 的 readLines 逐行读,离开页面中止 fetch 断流。
导航入口要在 App.vue 里挂,按约定留给集成者统一改。
This commit is contained in:
2026-08-11 18:36:00 +08:00
parent 6c8a1c4e0e
commit 97c3943403
4 changed files with 594 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
/**
* 仪表盘页的数据整理。
*
* 流量窗口、折线坐标、端口列表这些是「拿到内核数据之后怎么摆」的纯逻辑,
* 放在这里而不是 DashboardPage.vue 的 computed 里,才能被 bun test 直接测到。
*/
/** 流量曲线保留的秒数。接口每秒吐一条,所以窗口长度就是样本数。 */
export const WINDOW = 60
export interface TrafficSample {
/** 当前每秒上行字节数 */
up: number
/** 当前每秒下行字节数 */
down: number
/** 累计上传字节数 */
upTotal: number
/** 累计下载字节数 */
downTotal: number
}
/** 追加一条样本,窗口满了就把最旧的挤掉。 */
export function pushSample(samples: TrafficSample[], sample: TrafficSample): TrafficSample[] {
return [...samples, sample].slice(-WINDOW)
}
/**
* 两条曲线共用的纵轴上限。
*
* 共用一个刻度,上行下行的差距才看得出来;各画各的刻度会让一条几乎不动的线
* 看起来像在剧烈波动。下限取 1 只为避免全 0 时除以 0。
*/
export function chartPeak(samples: TrafficSample[]): number {
let peak = 1
for (const s of samples) {
if (s.up > peak) peak = s.up
if (s.down > peak) peak = s.down
}
return peak
}
/**
* 一条曲线的 SVG polyline 点串。0 映射到 height,peak 映射到 0。
*
* 最新样本钉在右缘,窗口不满时曲线从左边空白处长出来,而不是把已有数据拉伸到
* 整个宽度——拉伸会让「刚开始采集」和「一直很闲」长得一样。
*/
export function linePoints(values: number[], peak: number, width: number, height: number): string {
const scale = Math.max(peak, 1)
const step = width / (WINDOW - 1)
return values
.map((value, i) => {
const x = width - (values.length - 1 - i) * step
const y = height - (value / scale) * height
return `${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
}
/** 内核运行模式在界面上怎么念。不在表里的原样显示,不猜它的意思。 */
export function modeLabel(mode: unknown): string {
switch (mode) {
case 'rule':
return '规则'
case 'global':
return '全局'
case 'direct':
return '直连'
default:
return typeof mode === 'string' && mode ? mode : '—'
}
}
/** 一个监听中的代理端口。name 用配置键原词,和路由页默认入站的命名一致。 */
export interface KernelPort {
name: string
port: number
}
const PORT_KEYS = ['port', 'socks-port', 'mixed-port', 'redir-port', 'tproxy-port'] as const
/** 从内核 /configs 的运行时设置里挑出正在监听的代理端口。0 和缺省都表示没启用。 */
export function kernelPorts(configs: Record<string, unknown>): KernelPort[] {
const result: KernelPort[] = []
for (const key of PORT_KEYS) {
const raw = configs[key]
const port = typeof raw === 'number' ? raw : Number(raw)
if (Number.isFinite(port) && port > 0) result.push({ name: key, port })
}
return result
}