Files
mihomo-patchbay/web/dashboard.ts
T
im0o 97c3943403 新增仪表盘页:流量曲线、流量与内存数字、配置概览、内核状态
流量曲线手画 SVG polyline 而不引图表库:60 个点两条线,一个图表库几百 KB,
决策记录 0006 留的例外用在这里不划算。/traffic 和 /memory 是永不结束的流,
api.request() 会把整个响应读完等于挂死,所以新增 api.openStream() 返回响应体,
交给 web/stream.ts 的 readLines 逐行读,离开页面中止 fetch 断流。
导航入口要在 App.vue 里挂,按约定留给集成者统一改。
2026-08-11 18:36:00 +08:00

92 lines
3.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 仪表盘页的数据整理。
*
* 流量窗口、折线坐标、端口列表这些是「拿到内核数据之后怎么摆」的纯逻辑,
* 放在这里而不是 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
}