diff --git a/CONTEXT.md b/CONTEXT.md index 95d174d..440a4b8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -68,6 +68,10 @@ _Avoid_: 前置组、跳板组 开链式代理时,Patchbay 按「入站乘以来源订阅」的组合生成的订阅。`type: file`,读原订阅的缓存文件,带上指向入口组的 `dialer-proxy`。它不联网,原订阅刷新后内核的文件监视让它自动跟上。和哪个用户订阅共用同一个缓存文件,来源就是哪个。 _Avoid_: 派生订阅、克隆订阅、镜像订阅 +**公网 IP**: +一路入站的流量离开本机后,在外部服务眼里来自哪个地址。由 Patchbay 服务端把本机对应端口设为代理、访问 ip.sb 得到。它是观测结果,不是配置里的任何字段。 +_Avoid_: 出口 IP、落地 IP、出口 + ## 名字与引用 **名字表**: diff --git a/bun.lock b/bun.lock index ddb05f2..31af232 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "patchbay", "dependencies": { "@tanstack/vue-virtual": "^3.13.35", + "echarts": "^6.1.0", "vue": "^3.5.13", "yaml": "^2.9.0", }, @@ -202,6 +203,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "echarts": ["echarts@6.1.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.1.0" } }, "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], @@ -252,6 +255,8 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], @@ -274,6 +279,8 @@ "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "zrender": ["zrender@6.1.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ=="], + "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], diff --git a/package.json b/package.json index ba769a5..02444ed 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@tanstack/vue-virtual": "^3.13.35", + "echarts": "^6.1.0", "vue": "^3.5.13", "yaml": "^2.9.0" }, diff --git a/shared/inbounds.ts b/shared/inbounds.ts index 9e1daac..45b98e7 100644 --- a/shared/inbounds.ts +++ b/shared/inbounds.ts @@ -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()}` +} + /** * 走规则表的流量最后交给谁。 * diff --git a/shared/telemetry.test.ts b/shared/telemetry.test.ts new file mode 100644 index 0000000..26e156d --- /dev/null +++ b/shared/telemetry.test.ts @@ -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() + }) +}) diff --git a/shared/telemetry.ts b/shared/telemetry.ts new file mode 100644 index 0000000..5181525 --- /dev/null +++ b/shared/telemetry.ts @@ -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(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 + +/** + * 两次连接快照之间,每一路入站走了多少字节。 + * + * 按连接逐条求差再按入站聚合,而不是先按入站求和再求差:连接关闭后会从快照里 + * 消失,直接对总和求差会算出负数。代价是连接关闭前最后一段流量丢失——对走势图 + * 来说可以接受。新出现的连接把它的累计全部记入本次间隔(它就是这段时间里产生的)。 + * 同一条 id 的累计变小说明内核重启过计数,按全新连接处理。 + */ +export function routeDeltas( + prev: ConnTotals, + conns: ConnectionItem[], +): { deltas: Record; next: ConnTotals } { + const deltas: Record = {} + 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 } +} diff --git a/web/App.vue b/web/App.vue index 235dbbd..4adf420 100644 --- a/web/App.vue +++ b/web/App.vue @@ -5,6 +5,7 @@ import type { ConfigSnapshot } from './api' import { NAV } from './nav' import { density, toggleDensity } from './prefs' import { kernel, refreshKernel, reloadRuntime } from './runtime' +import { startTelemetry } from './telemetry' import ConfigPage from './views/ConfigPage.vue' import ConnectionsPage from './views/ConnectionsPage.vue' import DashboardPage from './views/DashboardPage.vue' @@ -23,6 +24,17 @@ const booted = ref(false) const refreshing = ref(false) const active = ref('dashboard') +/** + * 仪表盘的路由行点过去时要选中的那一路。放外壳是因为跳转跨页面: + * 仪表盘发起、路由页消费,消费完清掉,下次再点同一行还能再跳。 + */ +const routerFocus = ref(null) + +function openInbound(id: string) { + routerFocus.value = id + active.value = 'router' +} + /** * 重新读一遍状态。 * @@ -59,6 +71,9 @@ function submitToken(value: string) { onMounted(() => { if (!needsToken.value) void load() + // 流量和每路吞吐的历史跨页面留存,所以采集挂在外壳上而不是仪表盘里。 + // 单例自己跟着 kernel.online 起落,这里只负责点火 + startTelemetry() }) /** @@ -144,12 +159,19 @@ const current = computed(() => NAV.find((n) => n.key === active.value)) - + diff --git a/web/api.ts b/web/api.ts index 8ecc5e2..34fafc0 100644 --- a/web/api.ts +++ b/web/api.ts @@ -6,6 +6,7 @@ */ import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts' import type { ConnectionSnapshot } from '@shared/connections.ts' +import type { ExitProbe, HostInfo } from '@shared/host-info.ts' const TOKEN_KEY = 'patchbay.token' @@ -148,6 +149,18 @@ export const api = { kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'), + /** + * 服务端所在主机的指标:CPU、内存、负载、内网地址、网关。 + * 内核的管理接口只报自己的内存,这些只有跑在同一台机器上的 Patchbay 服务端答得出。 + */ + host: () => request('GET', '/api/host'), + + /** + * 经由某个入站端口探测公网 IP(服务端拿本机对应端口当代理去访问 ip.sb)。 + * 会发起真实的外网请求,只在用户点了检测时调。 + */ + probeExit: (port: number) => request('GET', `/api/host/exit?port=${String(port)}`), + /** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */ kernelConfigs: () => request>('GET', '/api/kernel/configs'), diff --git a/web/components/EChart.vue b/web/components/EChart.vue new file mode 100644 index 0000000..940300b --- /dev/null +++ b/web/components/EChart.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/web/dashboard.test.ts b/web/dashboard.test.ts index 78e9416..f0d6ba4 100644 --- a/web/dashboard.test.ts +++ b/web/dashboard.test.ts @@ -1,59 +1,5 @@ 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) - } - }) -}) +import { firstPort, kernelPorts, modeLabel } from './dashboard.ts' describe('运行模式', () => { it('三个已知模式有中文名', () => { @@ -85,3 +31,20 @@ describe('代理端口', () => { expect(kernelPorts({ port: '7890' })).toEqual([{ name: 'port', port: 7890 }]) }) }) + +describe('探测端口', () => { + it('单个端口直接用', () => { + expect(firstPort('7890')).toBe(7890) + }) + + it('多端口和区间取第一个数', () => { + expect(firstPort('200,302')).toBe(200) + expect(firstPort('401-429')).toBe(401) + }) + + it('解析不出或越界返回 null', () => { + expect(firstPort('')).toBeNull() + expect(firstPort('abc')).toBeNull() + expect(firstPort('70000')).toBeNull() + }) +}) diff --git a/web/dashboard.ts b/web/dashboard.ts index 3489e89..b678012 100644 --- a/web/dashboard.ts +++ b/web/dashboard.ts @@ -1,62 +1,11 @@ /** * 仪表盘页的数据整理。 * - * 流量窗口、折线坐标、端口列表这些是「拿到内核数据之后怎么摆」的纯逻辑, - * 放在这里而不是 DashboardPage.vue 的 computed 里,才能被 bun test 直接测到。 + * 「拿到内核数据之后怎么摆」的纯逻辑,放在这里而不是 DashboardPage.vue 的 + * computed 里,才能被 bun test 直接测到。流量窗口和每路吞吐的聚合在 + * shared/telemetry.ts——那些是采集层的事,跨页面共用。 */ -/** 流量曲线保留的秒数。接口每秒吐一条,所以窗口长度就是样本数。 */ -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) { @@ -89,3 +38,14 @@ export function kernelPorts(configs: Record): KernelPort[] { } return result } + +/** + * 入站声明的端口在探测公网 IP 时用哪个数。 + * 新式入站的端口可以写 "200,302" 或 "401-429",探测取第一个数字;解析不出返回 null。 + */ +export function firstPort(port: string): number | null { + const match = /\d+/.exec(port) + if (!match) return null + const value = Number(match[0]) + return value >= 1 && value <= 65535 ? value : null +} diff --git a/web/telemetry.ts b/web/telemetry.ts new file mode 100644 index 0000000..ce2789c --- /dev/null +++ b/web/telemetry.ts @@ -0,0 +1,191 @@ +/** + * 常驻的遥测采集:流量曲线、内核内存、每路入站的吞吐。 + * + * 住在模块级单例里而不是仪表盘组件里,是因为历史要跨页面留存——采集跟着组件 + * 走的话,切走一次曲线就清零一次。应用启动后由外壳调一次 startTelemetry(), + * 之后跟着 kernel.online 自己起落:上线接上,掉线断开但**不清历史**,断档在 + * 时间轴上自然呈现为一段空白。 + * + * 数据来源两路: + * - /traffic、/memory 流,每秒一行(内核源码 hub/route/server.go:386-414) + * - /connections 快照 3 秒一轮询,按连接逐条求差聚合出每路的吞吐 + * (快照本身没有流式 HTTP 版本,见 hub/route/connections.go:24-28) + */ +import { ref, watch } from 'vue' +import { + ROUTE_POLL_MS, + ROUTE_WINDOW_MS, + TRAFFIC_WINDOW_MS, + pushWindow, + routeDeltas, +} from '@shared/telemetry.ts' +import type { ConnTotals, RouteSample, TrafficPoint } from '@shared/telemetry.ts' +import type { ExitProbe } from '@shared/host-info.ts' +import { api } from './api' +import { kernel } from './runtime' +import { readLines } from './stream' + +/** 流量曲线,最近 30 分钟。up/down 是内核报的每秒字节数。 */ +export const traffic = ref([]) + +/** 内核报的累计上下行字节数。没收到过是 null。 */ +export const totals = ref<{ up: number; down: number } | null>(null) + +/** 内核自己的内存占用。 */ +export const kernelMemory = ref(null) + +/** 每路入站的吞吐样本,键是内核连接快照里的 inboundName。 */ +export const routeSamples = ref>({}) + +/** 当前连接数。 */ +export const connCount = ref(null) + +/** 流当前接着没有。断流期间仪表盘的「当前值」要显示成没数据,而不是冻在最后一秒。 */ +export const live = ref(false) + +/** 每个端口的公网 IP 检测结果,会话内缓存。键是端口号。 */ +export const exitProbes = ref>({}) + +/** 采集的时钟,每次轮询走一格。活跃判定这类「相对现在」的计算依赖它保持新鲜。 */ +export const tick = ref(Date.now()) + +const num = (value: unknown): number => + typeof value === 'number' && Number.isFinite(value) ? value : 0 + +let abort: AbortController | null = null +let pollTimer = 0 +let retryTimer = 0 +let prevTotals: ConnTotals = new Map() +let prevAt = 0 + +async function consume( + path: string, + ctrl: AbortController, + onLine: (payload: Record) => void, +) { + const body = await api.openStream(path, ctrl.signal) + for await (const line of readLines(body)) { + onLine(JSON.parse(line) as Record) + } +} + +function startStreams() { + const ctrl = new AbortController() + abort = ctrl + live.value = true + const run = (path: string, onLine: (payload: Record) => void) => + void consume(path, ctrl, onLine) + .catch(() => {}) + // 这两个流本身永不结束,正常返回也意味着断了 + .finally(() => { + if (ctrl.signal.aborted) return + // 一条断了把另一条也收掉,然后走统一的重连节奏 + ctrl.abort() + live.value = false + scheduleRetry() + }) + + run('/traffic', (p) => { + traffic.value = pushWindow( + traffic.value, + { t: Date.now(), up: num(p.up), down: num(p.down) }, + TRAFFIC_WINDOW_MS, + ) + totals.value = { up: num(p.upTotal), down: num(p.downTotal) } + }) + run('/memory', (p) => { + kernelMemory.value = num(p.inuse) + }) +} + +/** + * 断流不等于内核没了:保存配置触发的重载会掐断流,内核几秒内就回来、online 从未 + * 翻转。5 秒后重试,失败会再次走到这里形成节奏;真掉线交给下面 online 的 watch。 + */ +function scheduleRetry() { + window.clearTimeout(retryTimer) + retryTimer = window.setTimeout(() => { + if (kernel.value.online && !live.value) startStreams() + }, 5_000) +} + +async function pollConnections() { + const now = Date.now() + try { + const snapshot = await api.connections() + const conns = snapshot.connections ?? [] + connCount.value = conns.length + const { deltas, next } = routeDeltas(prevTotals, conns) + const ms = prevAt ? now - prevAt : ROUTE_POLL_MS + prevTotals = next + prevAt = now + + const merged = { ...routeSamples.value } + for (const [key, delta] of Object.entries(deltas)) { + merged[key] = pushWindow( + merged[key] ?? [], + { t: now, ms, up: delta.up, down: delta.down }, + ROUTE_WINDOW_MS, + ) + } + // 有历史但这一轮没有连接的路,补零样本让走势落回去而不是冻住; + // 整个窗口都归零之后把这路忘掉,改名、删除的入站才不会在内存里越攒越多 + for (const key of Object.keys(merged)) { + if (key in deltas) continue + const samples = pushWindow(merged[key]!, { t: now, ms, up: 0, down: 0 }, ROUTE_WINDOW_MS) + if (samples.every((s) => s.up === 0 && s.down === 0)) delete merged[key] + else merged[key] = samples + } + routeSamples.value = merged + } catch { + // 单轮失败就跳过。持续失败说明内核掉了,online 的 watch 会把轮询停掉 + } finally { + tick.value = now + } +} + +function attach() { + if (!abort) startStreams() + if (!pollTimer) { + void pollConnections() + pollTimer = window.setInterval(() => void pollConnections(), ROUTE_POLL_MS) + } +} + +function detach() { + abort?.abort() + abort = null + live.value = false + window.clearInterval(pollTimer) + window.clearTimeout(retryTimer) + pollTimer = 0 + prevTotals = new Map() + prevAt = 0 +} + +let started = false + +/** 外壳登录成功后调一次。之后采集跟着内核在线状态自己起落。 */ +export function startTelemetry(): void { + if (started) return + started = true + watch( + () => kernel.value.online, + (online) => { + if (online) attach() + else detach() + }, + { immediate: true }, + ) +} + +/** 检测某个端口的公网 IP。结果缓存到会话结束,force 重测。 */ +export async function probeExit(port: number, force = false): Promise { + const cached = exitProbes.value[port] + if (!force && cached && (cached.state === 'loading' || cached.result?.ok)) return + exitProbes.value = { ...exitProbes.value, [port]: { state: 'loading' } } + const result = await api + .probeExit(port) + .catch((error: unknown) => ({ ok: false, message: String(error) }) as ExitProbe) + exitProbes.value = { ...exitProbes.value, [port]: { state: 'done', result } } +} diff --git a/web/theme.ts b/web/theme.ts new file mode 100644 index 0000000..b02409c --- /dev/null +++ b/web/theme.ts @@ -0,0 +1,25 @@ +/** + * 图表取色。 + * + * Canvas 里画的东西不认 CSS 变量,颜色要在 JS 里解析出来传给 ECharts。 + * themeTick 在主题变化(设置页显式切换写 data-theme,或系统偏好翻转)时走一格, + * 图表的 option 把它算进依赖,主题一变整个重新取色。 + */ +import { ref } from 'vue' + +export const themeTick = ref(0) + +const bump = () => { + themeTick.value += 1 +} + +new MutationObserver(bump).observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], +}) +window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', bump) + +/** 当前生效的某个 CSS 变量的值,如 cssColor('--c-accent')。 */ +export function cssColor(name: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() +} diff --git a/web/views/DashboardPage.vue b/web/views/DashboardPage.vue index 2ab2167..68efb43 100644 --- a/web/views/DashboardPage.vue +++ b/web/views/DashboardPage.vue @@ -2,149 +2,269 @@ /** * 仪表盘:打开 Patchbay 第一眼看到的页面,现在什么状况。 * - * 全部只读。流量和内存是内核的流式接口(每秒一行 JSON),离开页面时中止; - * 概览的数字数自配置文件。两边来源不同:配置读得到不代表内核在跑,反过来也 - * 一样,所以内核未连接时概览照常显示,只有流量、内存和内核那栏换说法。 + * 页面本身只负责摆数据。采集在 web/telemetry.ts 的常驻单例里——流量曲线和每路 + * 吞吐的历史跨页面留存,切走再切回来不清零。主机指标(CPU、内存、网关)来自 + * Patchbay 自己的服务端,只在本页打开期间轮询:它没有历史,不需要常驻。 + * + * 除了「检测公网 IP」,全部只读。 */ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' -import { collectInbounds } from '@shared/inbounds.ts' -import { formatBytes } from '@shared/format.ts' +import { collectInbounds, inboundStatKey } from '@shared/inbounds.ts' +import { formatBytes, formatUptime } from '@shared/format.ts' +import { ACTIVE_BYTES, ROUTE_WINDOW_MS, lastRate, windowBytes } from '@shared/telemetry.ts' +import type { RouteSample } from '@shared/telemetry.ts' +import type { HostInfo } from '@shared/host-info.ts' import type { ConfigObject } from '../../vendor/config-editor/index.ts' +import type { EChartsCoreOption } from 'echarts/core' import { api } from '../api' -import { readLines } from '../stream' import { kernel, providerLines } from '../runtime' -import { chartPeak, kernelPorts, linePoints, modeLabel, pushSample } from '../dashboard' -import type { TrafficSample } from '../dashboard' +import { + connCount, + exitProbes, + kernelMemory, + live, + probeExit, + routeSamples, + tick, + totals, + traffic, +} from '../telemetry' +import { firstPort, kernelPorts, modeLabel } from '../dashboard' +import { cssColor, themeTick } from '../theme' +import EChart from '../components/EChart.vue' +import IconNet from '~icons/ri/line-chart-line' +import IconServer from '~icons/ri/server-line' +import IconRoute from '~icons/ri/route-line' +import IconDonut from '~icons/ri/pie-chart-2-line' +import IconLan from '~icons/ri/router-line' +import IconKernel from '~icons/ri/terminal-box-line' +import IconOverview from '~icons/ri/layout-grid-line' +import IconGo from '~icons/ri/arrow-right-s-line' const props = defineProps<{ data: ConfigObject }>() -const samples = ref([]) -const memory = ref(null) -const configs = ref | null>(null) -/** 流读失败或中途断掉(比如页面开着时内核停了)。流量和内存整块换说法。 */ -const streamFailed = ref(false) +const emit = defineEmits<{ 'open-inbound': [id: string]; 'open-router': [] }>() -const unreachable = computed(() => !kernel.value.online || streamFailed.value) +/* ── 网络 ─────────────────────────────────────── */ -let abort: AbortController | null = null +const RANGES = [ + { label: '实时', ms: 60_000 }, + { label: '5 分钟', ms: 5 * 60_000 }, + { label: '30 分钟', ms: 30 * 60_000 }, +] as const -/** 流里的数字字段。缺了、不是数字都按 0 算,第一行经常全是 0,那不是故障。 */ -const num = (value: unknown): number => - typeof value === 'number' && Number.isFinite(value) ? value : 0 +const rangeMs = ref(RANGES[0].ms) -async function consume( - path: string, - ctrl: AbortController, - onLine: (payload: Record) => void, -) { - const body = await api.openStream(path, ctrl.signal) - for await (const line of readLines(body)) { - onLine(JSON.parse(line) as Record) +/** 当前速率。断流期间是 null——显示破折号,而不是把最后一秒冻在屏幕上。 */ +const current = computed(() => (live.value ? (traffic.value.at(-1) ?? null) : null)) + +const rate = (n: number): string => `${formatBytes(n)}/s` + +const trafficOption = computed(() => { + void themeTick.value + const end = traffic.value.at(-1)?.t ?? Date.now() + const start = end - rangeMs.value + // 只把窗口内的点交给图表,多留两秒让曲线画到左缘之外不断头 + const points = traffic.value.filter((p) => p.t >= start - 2_000) + const faint = cssColor('--c-text-faint') + const border = cssColor('--c-border') + const accent = cssColor('--c-accent') + const line = (color: string, data: Array<[number, number]>) => ({ + type: 'line', + smooth: 0.4, + symbol: 'none', + sampling: 'lttb', + lineStyle: { width: 1.5, color }, + itemStyle: { color }, + data, + }) + return { + animation: false, + grid: { left: 4, right: 4, top: 8, bottom: 0, containLabel: true }, + tooltip: { + trigger: 'axis', + backgroundColor: cssColor('--c-surface'), + borderColor: border, + textStyle: { color: cssColor('--c-text'), fontSize: 12 }, + valueFormatter: (value: unknown) => (typeof value === 'number' ? rate(value) : '—'), + }, + xAxis: { + type: 'time', + min: start, + max: end, + axisLine: { lineStyle: { color: border } }, + axisTick: { show: false }, + axisLabel: { color: faint, fontSize: 11, hideOverlap: true }, + splitLine: { show: false }, + }, + yAxis: { + type: 'value', + min: 0, + axisLabel: { + color: faint, + fontSize: 11, + formatter: (value: number) => (value === 0 ? '0' : rate(value)), + }, + splitLine: { lineStyle: { color: border, opacity: 0.6 } }, + }, + series: [ + { name: '上行', ...line(faint, traffic.value.length ? points.map((p) => [p.t, p.up]) : []) }, + { name: '下行', ...line(accent, traffic.value.length ? points.map((p) => [p.t, p.down]) : []) }, + ], + } +}) + +/* ── 路由 ─────────────────────────────────────── */ + +/** 公网 IP 那一格的状态机。probeable 之外的入站不给按钮:服务端只能经 HTTP 代理发请求。 */ +type ExitCell = + | { kind: 'unsupported' } + | { kind: 'idle'; port: number } + | { kind: 'loading' } + | { kind: 'ok'; port: number; text: string; title: string } + | { kind: 'fail'; port: number; title: string } + +function exitCell(probeable: boolean, port: number | null): ExitCell { + if (!probeable || port === null) return { kind: 'unsupported' } + const probe = exitProbes.value[port] + if (!probe) return { kind: 'idle', port } + if (probe.state === 'loading') return { kind: 'loading' } + const result = probe.result + if (result?.ok) { + return { + kind: 'ok', + port, + text: [result.countryCode, result.ip].filter(Boolean).join(' ') || '未知地址', + title: `${result.country ?? ''} 往返 ${String(result.ms ?? '?')} ms,点击重测`, + } + } + return { kind: 'fail', port, title: result?.message ?? '探测失败' } +} + +const SHOWN_ROUTES = 6 + +const routeRows = computed(() => { + void themeTick.value + const now = tick.value + const accent = cssColor('--c-accent') + const rows = collectInbounds(props.data).map((inbound, index) => { + const samples = routeSamples.value[inboundStatKey(inbound)] ?? [] + const bytes = windowBytes(samples, now - ROUTE_WINDOW_MS) + const port = firstPort(inbound.port) + const probeable = port !== null && (inbound.type === 'http' || inbound.type === 'mixed') + return { + inbound, + index, + bytes, + active: bytes > ACTIVE_BYTES, + rate: lastRate(samples), + spark: sparkOption(samples, now, accent), + exit: exitCell(probeable, port), + } + }) + // 忙的排前面好找,同样闲的保持配置里的顺序 + return rows.sort((a, b) => b.bytes - a.bytes || a.index - b.index) +}) + +const shownRoutes = computed(() => routeRows.value.slice(0, SHOWN_ROUTES)) +const hiddenCount = computed(() => Math.max(0, routeRows.value.length - SHOWN_ROUTES)) +const activeCount = computed(() => routeRows.value.filter((r) => r.active).length) + +function sparkOption(samples: RouteSample[], end: number, color: string): EChartsCoreOption { + return { + animation: false, + grid: { left: 0, right: 0, top: 3, bottom: 3 }, + xAxis: { type: 'time', min: end - ROUTE_WINDOW_MS, max: end, show: false }, + yAxis: { type: 'value', min: 0, show: false }, + series: [ + { + type: 'line', + smooth: 0.4, + symbol: 'none', + silent: true, + lineStyle: { width: 1, color }, + areaStyle: { color, opacity: 0.08 }, + data: samples.map((s) => [s.t, ((s.up + s.down) / Math.max(s.ms, 1)) * 1000]), + }, + ], } } -function startStreams() { - const ctrl = new AbortController() - abort = ctrl - const run = (path: string, onLine: (payload: Record) => void) => - void consume(path, ctrl, onLine) - .catch(() => {}) - // 正常结束也是断了:这两个流本身永不结束 - .finally(() => { - if (ctrl.signal.aborted) return - // 一条断了把另一条也收掉:界面已经显示离线,不留一条看不见的活连接 - ctrl.abort() - streamFailed.value = true - }) +/* ── 服务器 ───────────────────────────────────── */ - run('/traffic', (p) => { - samples.value = pushSample(samples.value, { - up: num(p.up), - down: num(p.down), - upTotal: num(p.upTotal), - downTotal: num(p.downTotal), - }) - }) - run('/memory', (p) => { - memory.value = num(p.inuse) - }) +const host = ref(null) +const hostFailed = ref(false) +let hostTimer = 0 + +async function loadHost() { + try { + host.value = await api.host() + hostFailed.value = false + } catch { + hostFailed.value = true + } } -function stopStreams() { - abort?.abort() - abort = null -} +onMounted(() => { + void loadHost() + hostTimer = window.setInterval(() => void loadHost(), 5_000) +}) +onBeforeUnmount(() => window.clearInterval(hostTimer)) + +const memUsedRatio = computed(() => { + const h = host.value + if (!h || h.memTotal <= 0) return null + return Math.min(100, Math.max(0, (1 - h.memAvailable / h.memTotal) * 100)) +}) + +const pct = (n: number): string => `${String(Math.round(n))}%` + +/* ── 流量统计 ─────────────────────────────────── */ + +const donutOption = computed(() => { + void themeTick.value + const t = totals.value + const empty = !t || t.up + t.down === 0 + return { + animation: false, + series: [ + { + type: 'pie', + radius: ['64%', '86%'], + label: { show: false }, + labelLine: { show: false }, + emphasis: { scale: false }, + silent: true, + data: empty + ? [{ value: 1, itemStyle: { color: cssColor('--c-surface-sunken') } }] + : [ + { value: t.up, name: '上传', itemStyle: { color: cssColor('--c-text-faint') } }, + { value: t.down, name: '下载', itemStyle: { color: cssColor('--c-accent') } }, + ], + }, + ], + } +}) + +/* ── 概览与内核 ───────────────────────────────── */ + +const configs = ref | null>(null) async function loadConfigs() { configs.value = await api.kernelConfigs().catch(() => null) } onMounted(() => { - if (kernel.value.online) { - streamFailed.value = false - startStreams() - void loadConfigs() - } + if (kernel.value.online) void loadConfigs() }) - -// 内核从离线恢复(外壳的心跳每 10 秒探测一次)时重新接上;掉线时收掉 watch( () => kernel.value.online, (online) => { - stopStreams() - samples.value = [] - memory.value = null configs.value = null - if (online) { - streamFailed.value = false - startStreams() - void loadConfigs() - } + if (online) void loadConfigs() }, ) -// 断流不等于内核没了:配置重载会掐断流,内核几秒内就回来、online 从未翻转。 -// 稍后重试一次;再失败会重新走到这里,形成 5 秒一次的重试节奏,直到接上或离开页面 -let retryTimer = 0 -watch(streamFailed, (failed) => { - window.clearTimeout(retryTimer) - if (!failed) return - retryTimer = window.setTimeout(() => { - if (!kernel.value.online) return // 真离线交给上面那个 watch - streamFailed.value = false - startStreams() - void loadConfigs() - }, 5000) -}) - -onBeforeUnmount(() => { - stopStreams() - window.clearTimeout(retryTimer) -}) - -const latest = computed(() => samples.value.at(-1) ?? null) - -/** 当前值还没到时显示破折号,不是 0——0 是个真值,破折号是没数据。 */ -const current = computed(() => ({ - up: latest.value ? `${formatBytes(latest.value.up)}/s` : '—', - down: latest.value ? `${formatBytes(latest.value.down)}/s` : '—', - upTotal: latest.value ? formatBytes(latest.value.upTotal) : '—', - downTotal: latest.value ? formatBytes(latest.value.downTotal) : '—', - memory: memory.value !== null ? formatBytes(memory.value) : '—', -})) - -// 曲线的作图区域。viewBox 上下各留 6px,峰值的描边不顶边、底线描边不被裁掉 -const CHART_W = 600 -const CHART_H = 112 - -const peak = computed(() => chartPeak(samples.value)) -const upPoints = computed(() => - linePoints(samples.value.map((s) => s.up), peak.value, CHART_W, CHART_H), -) -const downPoints = computed(() => - linePoints(samples.value.map((s) => s.down), peak.value, CHART_W, CHART_H), -) - const inboundCount = computed(() => collectInbounds(props.data).length) const groupCount = computed(() => Array.isArray(props.data['proxy-groups']) ? (props.data['proxy-groups'] as unknown[]).length : 0, @@ -159,135 +279,398 @@ const ports = computed(() => kernelPorts(configs.value ?? {})) diff --git a/web/views/RouterPage.vue b/web/views/RouterPage.vue index 6beeaa2..05065dc 100644 --- a/web/views/RouterPage.vue +++ b/web/views/RouterPage.vue @@ -34,10 +34,15 @@ import InboundDetail from './InboundDetail.vue' import InboundForm from './InboundForm.vue' import IconAdd from '~icons/ri/add-line' -const props = defineProps<{ data: ConfigObject; revision: string }>() +const props = defineProps<{ + data: ConfigObject + revision: string + /** 外部(仪表盘的路由行)指名要选中的一路。消费后通过 focus-done 请外壳清掉 */ + focus?: string | null +}>() /** 配置文件变了,需要重读整份配置。运行时的变化不走这里。 */ -const emit = defineEmits<{ saved: [] }>() +const emit = defineEmits<{ saved: []; 'focus-done': [] }>() const inbounds = computed(() => collectInbounds(props.data)) const subscriptions = computed(() => userProviders(props.data)) @@ -83,6 +88,18 @@ watch( { immediate: true }, ) +// 带着指名跳进来时选中那一路。immediate 是因为组件随页签切换重建, +// watch 挂上时 focus 早就是最终值了,不 immediate 就永远等不到变化 +watch( + () => props.focus, + (id) => { + if (!id) return + if (inbounds.value.some((i) => i.id === id)) selectedId.value = id + emit('focus-done') + }, + { immediate: true }, +) + const selected = computed(() => inbounds.value.find((i) => i.id === selectedId.value) ?? null) const takenNames = computed(() => inbounds.value.map((i) => i.name))