From 97c3943403cf3b2f6b733dd63bcc6642e35577da Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Tue, 11 Aug 2026 18:34:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BB=AA=E8=A1=A8=E7=9B=98?= =?UTF-8?q?=E9=A1=B5=EF=BC=9A=E6=B5=81=E9=87=8F=E6=9B=B2=E7=BA=BF=E3=80=81?= =?UTF-8?q?=E6=B5=81=E9=87=8F=E4=B8=8E=E5=86=85=E5=AD=98=E6=95=B0=E5=AD=97?= =?UTF-8?q?=E3=80=81=E9=85=8D=E7=BD=AE=E6=A6=82=E8=A7=88=E3=80=81=E5=86=85?= =?UTF-8?q?=E6=A0=B8=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 流量曲线手画 SVG polyline 而不引图表库:60 个点两条线,一个图表库几百 KB, 决策记录 0006 留的例外用在这里不划算。/traffic 和 /memory 是永不结束的流, api.request() 会把整个响应读完等于挂死,所以新增 api.openStream() 返回响应体, 交给 web/stream.ts 的 readLines 逐行读,离开页面中止 fetch 断流。 导航入口要在 App.vue 里挂,按约定留给集成者统一改。 --- web/api.ts | 23 +++ web/dashboard.test.ts | 87 ++++++++ web/dashboard.ts | 91 +++++++++ web/views/DashboardPage.vue | 393 ++++++++++++++++++++++++++++++++++++ 4 files changed, 594 insertions(+) create mode 100644 web/dashboard.test.ts create mode 100644 web/dashboard.ts create mode 100644 web/views/DashboardPage.vue diff --git a/web/api.ts b/web/api.ts index 7714233..1df56a9 100644 --- a/web/api.ts +++ b/web/api.ts @@ -126,6 +126,29 @@ export const api = { kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'), + /** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */ + kernelConfigs: () => request>('GET', '/api/kernel/configs'), + + /** + * 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。 + * + * 不能用上面的 request():它会把整个响应读完,而对永不结束的流来说那等于挂死。 + * 调用方拿到响应体后用 web/stream.ts 的 readLines() 逐行读;中止 signal 即断流。 + */ + openStream: async (path: string, signal: AbortSignal): Promise> => { + const res = await fetch(`/api/kernel${path}`, { + headers: { Authorization: `Bearer ${getToken()}` }, + signal, + }) + if (!res.ok) { + const text = await res.text() + const payload = text ? (JSON.parse(text) as Record) : {} + throw new ApiError(res.status, payload) + } + if (!res.body) throw new Error('响应没有内容') + return res.body + }, + /** 入站类型的字段表,从内核源码提取,用来驱动表单。 */ listenerFields: () => request('GET', '/api/meta/listener-fields'), diff --git a/web/dashboard.test.ts b/web/dashboard.test.ts new file mode 100644 index 0000000..78e9416 --- /dev/null +++ b/web/dashboard.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'bun:test' +import { WINDOW, chartPeak, kernelPorts, linePoints, modeLabel, pushSample } from './dashboard.ts' +import type { TrafficSample } from './dashboard.ts' + +const sample = (up: number, down: number): TrafficSample => ({ + up, + down, + upTotal: 0, + downTotal: 0, +}) + +describe('流量样本窗口', () => { + it('追加样本', () => { + expect(pushSample([sample(1, 2)], sample(3, 4))).toHaveLength(2) + }) + + it('窗口满了挤掉最旧的', () => { + const full = Array.from({ length: WINDOW }, (_, i) => sample(i, i)) + const next = pushSample(full, sample(999, 999)) + expect(next).toHaveLength(WINDOW) + expect(next.at(-1)?.up).toBe(999) + expect(next[0]?.up).toBe(1) + }) +}) + +describe('纵轴上限', () => { + it('空窗口和全 0 都有下限 1,不会除出 NaN', () => { + expect(chartPeak([])).toBe(1) + expect(chartPeak([sample(0, 0)])).toBe(1) + }) + + it('取上行下行的共同峰值', () => { + expect(chartPeak([sample(10, 500), sample(800, 20)])).toBe(800) + }) +}) + +describe('折线坐标', () => { + it('最新样本钉在右缘,最旧的往左排', () => { + const points = linePoints([5, 10], 10, 590, 100).split(' ') + expect(points).toHaveLength(2) + // 窗口 60,步长 10:两条样本占最右边两格 + expect(points[0]).toBe('580.0,50.0') + expect(points[1]).toBe('590.0,0.0') + }) + + it('窗口不满不拉伸,左边留空', () => { + const points = linePoints([7], 10, 590, 100).split(' ') + expect(points).toEqual(['590.0,30.0']) + }) + + it('全是 0 时整条贴在底线上', () => { + for (const point of linePoints([0, 0, 0], 1, 590, 100).split(' ')) { + expect(point.endsWith(',100.0')).toBe(true) + } + }) +}) + +describe('运行模式', () => { + it('三个已知模式有中文名', () => { + expect(modeLabel('rule')).toBe('规则') + expect(modeLabel('global')).toBe('全局') + expect(modeLabel('direct')).toBe('直连') + }) + + it('没见过的值原样显示,缺省是破折号', () => { + expect(modeLabel('script')).toBe('script') + expect(modeLabel(undefined)).toBe('—') + expect(modeLabel('')).toBe('—') + }) +}) + +describe('代理端口', () => { + it('挑出大于 0 的端口,按固定顺序', () => { + expect(kernelPorts({ 'mixed-port': 7890, port: 7891, 'socks-port': 0 })).toEqual([ + { name: 'port', port: 7891 }, + { name: 'mixed-port', port: 7890 }, + ]) + }) + + it('0、缺省和非数字都不算启用', () => { + expect(kernelPorts({ port: 0, 'socks-port': 'abc' })).toEqual([]) + }) + + it('字符串端口号也认得', () => { + expect(kernelPorts({ port: '7890' })).toEqual([{ name: 'port', port: 7890 }]) + }) +}) diff --git a/web/dashboard.ts b/web/dashboard.ts new file mode 100644 index 0000000..3489e89 --- /dev/null +++ b/web/dashboard.ts @@ -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): 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 +} diff --git a/web/views/DashboardPage.vue b/web/views/DashboardPage.vue new file mode 100644 index 0000000..3395b28 --- /dev/null +++ b/web/views/DashboardPage.vue @@ -0,0 +1,393 @@ + + + + +