仪表盘重做:ECharts 曲线、常驻遥测、路由行直达详情
采集从仪表盘组件挪进模块级单例(web/telemetry.ts),流量曲线和每路 吞吐的历史跨页面留存——跟着组件走的话切一次页面就清零一次。每路吞吐 从 /connections 快照按连接逐条求差再按 inboundName 聚合:先求和再求差 会在连接关闭时算出负数。 图表换 ECharts(按需注册):手写 SVG 折线做不出平滑曲线和时间轴, 颜色在运行时从 CSS 变量取,跟随主题切换重取。 路由列表每行带走势、速率和可选的公网 IP 检测,点击跳路由页并选中 那一路(外壳传 focus 给 RouterPage 消费)。
This commit is contained in:
@@ -68,6 +68,10 @@ _Avoid_: 前置组、跳板组
|
|||||||
开链式代理时,Patchbay 按「入站乘以来源订阅」的组合生成的订阅。`type: file`,读原订阅的缓存文件,带上指向入口组的 `dialer-proxy`。它不联网,原订阅刷新后内核的文件监视让它自动跟上。和哪个用户订阅共用同一个缓存文件,来源就是哪个。
|
开链式代理时,Patchbay 按「入站乘以来源订阅」的组合生成的订阅。`type: file`,读原订阅的缓存文件,带上指向入口组的 `dialer-proxy`。它不联网,原订阅刷新后内核的文件监视让它自动跟上。和哪个用户订阅共用同一个缓存文件,来源就是哪个。
|
||||||
_Avoid_: 派生订阅、克隆订阅、镜像订阅
|
_Avoid_: 派生订阅、克隆订阅、镜像订阅
|
||||||
|
|
||||||
|
**公网 IP**:
|
||||||
|
一路入站的流量离开本机后,在外部服务眼里来自哪个地址。由 Patchbay 服务端把本机对应端口设为代理、访问 ip.sb 得到。它是观测结果,不是配置里的任何字段。
|
||||||
|
_Avoid_: 出口 IP、落地 IP、出口
|
||||||
|
|
||||||
## 名字与引用
|
## 名字与引用
|
||||||
|
|
||||||
**名字表**:
|
**名字表**:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"name": "patchbay",
|
"name": "patchbay",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/vue-virtual": "^3.13.35",
|
"@tanstack/vue-virtual": "^3.13.35",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"yaml": "^2.9.0",
|
"yaml": "^2.9.0",
|
||||||
},
|
},
|
||||||
@@ -202,6 +203,8 @@
|
|||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/vue-virtual": "^3.13.35",
|
"@tanstack/vue-virtual": "^3.13.35",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -165,6 +165,20 @@ export function collectInbounds(data: ConfigObject): Inbound[] {
|
|||||||
return result
|
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()}`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 走规则表的流量最后交给谁。
|
* 走规则表的流量最后交给谁。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
+23
-1
@@ -5,6 +5,7 @@ import type { ConfigSnapshot } from './api'
|
|||||||
import { NAV } from './nav'
|
import { NAV } from './nav'
|
||||||
import { density, toggleDensity } from './prefs'
|
import { density, toggleDensity } from './prefs'
|
||||||
import { kernel, refreshKernel, reloadRuntime } from './runtime'
|
import { kernel, refreshKernel, reloadRuntime } from './runtime'
|
||||||
|
import { startTelemetry } from './telemetry'
|
||||||
import ConfigPage from './views/ConfigPage.vue'
|
import ConfigPage from './views/ConfigPage.vue'
|
||||||
import ConnectionsPage from './views/ConnectionsPage.vue'
|
import ConnectionsPage from './views/ConnectionsPage.vue'
|
||||||
import DashboardPage from './views/DashboardPage.vue'
|
import DashboardPage from './views/DashboardPage.vue'
|
||||||
@@ -23,6 +24,17 @@ const booted = ref(false)
|
|||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
const active = ref('dashboard')
|
const active = ref('dashboard')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘的路由行点过去时要选中的那一路。放外壳是因为跳转跨页面:
|
||||||
|
* 仪表盘发起、路由页消费,消费完清掉,下次再点同一行还能再跳。
|
||||||
|
*/
|
||||||
|
const routerFocus = ref<string | null>(null)
|
||||||
|
|
||||||
|
function openInbound(id: string) {
|
||||||
|
routerFocus.value = id
|
||||||
|
active.value = 'router'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重新读一遍状态。
|
* 重新读一遍状态。
|
||||||
*
|
*
|
||||||
@@ -59,6 +71,9 @@ function submitToken(value: string) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!needsToken.value) void load()
|
if (!needsToken.value) void load()
|
||||||
|
// 流量和每路吞吐的历史跨页面留存,所以采集挂在外壳上而不是仪表盘里。
|
||||||
|
// 单例自己跟着 kernel.online 起落,这里只负责点火
|
||||||
|
startTelemetry()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,12 +159,19 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DashboardPage v-if="active === 'dashboard'" :data="snapshot.data" />
|
<DashboardPage
|
||||||
|
v-if="active === 'dashboard'"
|
||||||
|
:data="snapshot.data"
|
||||||
|
@open-inbound="openInbound"
|
||||||
|
@open-router="active = 'router'"
|
||||||
|
/>
|
||||||
|
|
||||||
<RouterPage
|
<RouterPage
|
||||||
v-else-if="active === 'router'"
|
v-else-if="active === 'router'"
|
||||||
:data="snapshot.data"
|
:data="snapshot.data"
|
||||||
:revision="snapshot.revision"
|
:revision="snapshot.revision"
|
||||||
|
:focus="routerFocus"
|
||||||
|
@focus-done="routerFocus = null"
|
||||||
@saved="load"
|
@saved="load"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
+13
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
import type { ConnectionSnapshot } from '@shared/connections.ts'
|
import type { ConnectionSnapshot } from '@shared/connections.ts'
|
||||||
|
import type { ExitProbe, HostInfo } from '@shared/host-info.ts'
|
||||||
|
|
||||||
const TOKEN_KEY = 'patchbay.token'
|
const TOKEN_KEY = 'patchbay.token'
|
||||||
|
|
||||||
@@ -148,6 +149,18 @@ export const api = {
|
|||||||
|
|
||||||
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
|
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端所在主机的指标:CPU、内存、负载、内网地址、网关。
|
||||||
|
* 内核的管理接口只报自己的内存,这些只有跑在同一台机器上的 Patchbay 服务端答得出。
|
||||||
|
*/
|
||||||
|
host: () => request<HostInfo>('GET', '/api/host'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 经由某个入站端口探测公网 IP(服务端拿本机对应端口当代理去访问 ip.sb)。
|
||||||
|
* 会发起真实的外网请求,只在用户点了检测时调。
|
||||||
|
*/
|
||||||
|
probeExit: (port: number) => request<ExitProbe>('GET', `/api/host/exit?port=${String(port)}`),
|
||||||
|
|
||||||
/** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */
|
/** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */
|
||||||
kernelConfigs: () => request<Record<string, unknown>>('GET', '/api/kernel/configs'),
|
kernelConfigs: () => request<Record<string, unknown>>('GET', '/api/kernel/configs'),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* ECharts 的最小封装:初始化、跟随 option 更新、跟随容器尺寸缩放、卸载时销毁。
|
||||||
|
*
|
||||||
|
* 按需注册而不是整包引入:仪表盘只用折线和环形图。调用方传新的 option 对象
|
||||||
|
* 触发更新(computed 每次重算都是新引用),这里不做深比较。
|
||||||
|
*/
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import * as echarts from 'echarts/core'
|
||||||
|
import { LineChart, PieChart } from 'echarts/charts'
|
||||||
|
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
|
import type { EChartsCoreOption } from 'echarts/core'
|
||||||
|
|
||||||
|
echarts.use([LineChart, PieChart, GridComponent, TooltipComponent, CanvasRenderer])
|
||||||
|
|
||||||
|
const props = defineProps<{ option: EChartsCoreOption }>()
|
||||||
|
|
||||||
|
const el = ref<HTMLElement | null>(null)
|
||||||
|
let chart: echarts.EChartsType | null = null
|
||||||
|
let observer: ResizeObserver | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!el.value) return
|
||||||
|
chart = echarts.init(el.value)
|
||||||
|
chart.setOption(props.option)
|
||||||
|
observer = new ResizeObserver(() => chart?.resize())
|
||||||
|
observer.observe(el.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.option,
|
||||||
|
(option) => chart?.setOption(option),
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
chart?.dispose()
|
||||||
|
chart = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="el" class="echart"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.echart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+18
-55
@@ -1,59 +1,5 @@
|
|||||||
import { describe, expect, it } from 'bun:test'
|
import { describe, expect, it } from 'bun:test'
|
||||||
import { WINDOW, chartPeak, kernelPorts, linePoints, modeLabel, pushSample } from './dashboard.ts'
|
import { firstPort, kernelPorts, modeLabel } 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('运行模式', () => {
|
describe('运行模式', () => {
|
||||||
it('三个已知模式有中文名', () => {
|
it('三个已知模式有中文名', () => {
|
||||||
@@ -85,3 +31,20 @@ describe('代理端口', () => {
|
|||||||
expect(kernelPorts({ port: '7890' })).toEqual([{ name: 'port', port: 7890 }])
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+14
-54
@@ -1,62 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* 仪表盘页的数据整理。
|
* 仪表盘页的数据整理。
|
||||||
*
|
*
|
||||||
* 流量窗口、折线坐标、端口列表这些是「拿到内核数据之后怎么摆」的纯逻辑,
|
* 「拿到内核数据之后怎么摆」的纯逻辑,放在这里而不是 DashboardPage.vue 的
|
||||||
* 放在这里而不是 DashboardPage.vue 的 computed 里,才能被 bun test 直接测到。
|
* 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 {
|
export function modeLabel(mode: unknown): string {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
@@ -89,3 +38,14 @@ export function kernelPorts(configs: Record<string, unknown>): KernelPort[] {
|
|||||||
}
|
}
|
||||||
return result
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<TrafficPoint[]>([])
|
||||||
|
|
||||||
|
/** 内核报的累计上下行字节数。没收到过是 null。 */
|
||||||
|
export const totals = ref<{ up: number; down: number } | null>(null)
|
||||||
|
|
||||||
|
/** 内核自己的内存占用。 */
|
||||||
|
export const kernelMemory = ref<number | null>(null)
|
||||||
|
|
||||||
|
/** 每路入站的吞吐样本,键是内核连接快照里的 inboundName。 */
|
||||||
|
export const routeSamples = ref<Record<string, RouteSample[]>>({})
|
||||||
|
|
||||||
|
/** 当前连接数。 */
|
||||||
|
export const connCount = ref<number | null>(null)
|
||||||
|
|
||||||
|
/** 流当前接着没有。断流期间仪表盘的「当前值」要显示成没数据,而不是冻在最后一秒。 */
|
||||||
|
export const live = ref(false)
|
||||||
|
|
||||||
|
/** 每个端口的公网 IP 检测结果,会话内缓存。键是端口号。 */
|
||||||
|
export const exitProbes = ref<Record<number, { state: 'loading' | 'done'; result?: ExitProbe }>>({})
|
||||||
|
|
||||||
|
/** 采集的时钟,每次轮询走一格。活跃判定这类「相对现在」的计算依赖它保持新鲜。 */
|
||||||
|
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<string, unknown>) => void,
|
||||||
|
) {
|
||||||
|
const body = await api.openStream(path, ctrl.signal)
|
||||||
|
for await (const line of readLines(body)) {
|
||||||
|
onLine(JSON.parse(line) as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStreams() {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
abort = ctrl
|
||||||
|
live.value = true
|
||||||
|
const run = (path: string, onLine: (payload: Record<string, unknown>) => 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<void> {
|
||||||
|
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 } }
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
+783
-235
File diff suppressed because it is too large
Load Diff
@@ -34,10 +34,15 @@ import InboundDetail from './InboundDetail.vue'
|
|||||||
import InboundForm from './InboundForm.vue'
|
import InboundForm from './InboundForm.vue'
|
||||||
import IconAdd from '~icons/ri/add-line'
|
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 inbounds = computed(() => collectInbounds(props.data))
|
||||||
const subscriptions = computed(() => userProviders(props.data))
|
const subscriptions = computed(() => userProviders(props.data))
|
||||||
@@ -83,6 +88,18 @@ watch(
|
|||||||
{ immediate: true },
|
{ 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 selected = computed(() => inbounds.value.find((i) => i.id === selectedId.value) ?? null)
|
||||||
const takenNames = computed(() => inbounds.value.map((i) => i.name))
|
const takenNames = computed(() => inbounds.value.map((i) => i.name))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user