From c9a0b87af4ec22b0ba09f815defbe78f2d761ff7 Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Tue, 11 Aug 2026 18:48:55 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E9=A1=B5=EF=BC=9A=E6=AF=8F?= =?UTF-8?q?=E7=A7=92=E8=BD=AE=E8=AF=A2=E7=9A=84=E8=BF=9E=E6=8E=A5=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC=EF=BC=8C=E8=83=BD=E6=8C=89=E5=85=A5=E7=AB=99=E7=9C=8B?= =?UTF-8?q?=E3=80=81=E8=83=BD=E6=96=AD=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /connections 没有流式版本,只能全量轮询;断开是运行时动作,不走 落盘那条保护链路。两个容易做反的地方都收在 shared/connections.ts: chains 数组第一个是最终出口,显示要倒过来;没有连接时 connections 字段是 null 而不是空数组。虚拟滚动用 @tanstack/vue-virtual,ADR 0006 留的两个例外之一,它只有 virtual-core 一个运行时依赖。 --- bun.lock | 5 + package.json | 5 +- shared/connections.test.ts | 171 ++++++++++++++ shared/connections.ts | 131 +++++++++++ web/api.ts | 363 ++++++++++++++-------------- web/views/ConnectionsPage.vue | 431 ++++++++++++++++++++++++++++++++++ 6 files changed, 932 insertions(+), 174 deletions(-) create mode 100644 shared/connections.test.ts create mode 100644 shared/connections.ts create mode 100644 web/views/ConnectionsPage.vue diff --git a/bun.lock b/bun.lock index 11a9f8d..ddb05f2 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "patchbay", "dependencies": { + "@tanstack/vue-virtual": "^3.13.35", "vue": "^3.5.13", "yaml": "^2.9.0", }, @@ -153,6 +154,10 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.7", "", {}, "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA=="], + + "@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.35", "", { "dependencies": { "@tanstack/virtual-core": "3.17.7" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-lOfSPvgPdlaH6Qy+CyIc3XpycitaSQ9GECndGpTuDiu+uDA1am+90yWXwzDSd/20ZM196ggWJLS+Qb6WjVd/OA=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], diff --git a/package.json b/package.json index 10e4beb..ba769a5 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,9 @@ "typecheck": "vue-tsc --noEmit" }, "dependencies": { - "yaml": "^2.9.0", - "vue": "^3.5.13" + "@tanstack/vue-virtual": "^3.13.35", + "vue": "^3.5.13", + "yaml": "^2.9.0" }, "devDependencies": { "@iconify-json/ri": "^1.2.10", diff --git a/shared/connections.test.ts b/shared/connections.test.ts new file mode 100644 index 0000000..7acb1fd --- /dev/null +++ b/shared/connections.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'bun:test' +import { + chainLabel, + filterConnections, + formatDuration, + groupByInbound, + hostLabel, + ruleLabel, + sortConnections, +} from './connections.ts' +import type { ConnectionItem, ConnectionMeta } from './connections.ts' + +const NOW = Date.parse('2026-08-11T18:40:00+08:00') + +function meta(over: Partial = {}): ConnectionMeta { + return { + host: 'example.com', + destinationIP: '93.184.216.34', + destinationPort: '443', + inboundName: 'lxc-web', + inboundPort: '17891', + ...over, + } +} + +function conn(over: Partial = {}): ConnectionItem { + return { + id: 'a', + upload: 0, + download: 0, + start: '2026-08-11T18:39:00+08:00', + chains: ['[vless]某线路', 'AUTO', 'PROXY'], + rule: 'DomainSuffix', + rulePayload: 'example.com', + metadata: meta(), + ...over, + } +} + +describe('主机标签', () => { + it('有域名用域名,带端口', () => { + expect(hostLabel(meta())).toBe('example.com:443') + }) + + it('没域名退回 IP', () => { + expect(hostLabel(meta({ host: '' }))).toBe('93.184.216.34:443') + }) + + it('没端口就只给主机', () => { + expect(hostLabel(meta({ destinationPort: '' }))).toBe('example.com') + }) +}) + +describe('出站链', () => { + it('倒过来显示:最先接触的在左,最终出口在右', () => { + expect(chainLabel(['[vless]某线路', 'AUTO', 'PROXY'])).toBe('PROXY → AUTO → [vless]某线路') + }) + + it('单跳就是直连', () => { + expect(chainLabel(['DIRECT'])).toBe('DIRECT') + }) +}) + +describe('规则标签', () => { + it('类型和载荷用空格连起来', () => { + expect(ruleLabel('DomainSuffix', 'example.com')).toBe('DomainSuffix example.com') + }) + + it('兜底规则没有载荷', () => { + expect(ruleLabel('Match', '')).toBe('Match') + }) +}) + +describe('持续时间', () => { + it('不满一分钟按秒', () => { + expect(formatDuration('2026-08-11T18:39:18+08:00', NOW)).toBe('42 秒') + }) + + it('满一分钟带分', () => { + expect(formatDuration('2026-08-11T18:36:36+08:00', NOW)).toBe('3 分 24 秒') + }) + + it('满一小时不再给秒', () => { + expect(formatDuration('2026-08-11T16:35:00+08:00', NOW)).toBe('2 小时 5 分') + }) + + it('start 带时区偏移,跨时区解析不能算错', () => { + // 同一时刻写成 UTC 和 +08:00,算出来的持续时间必须一样 + const local = formatDuration('2026-08-11T18:39:00+08:00', NOW) + const utc = formatDuration('2026-08-11T10:39:00Z', NOW) + expect(local).toBe(utc) + }) + + it('坏数据返回空串,未来的时间按 0 算', () => { + expect(formatDuration('not-a-date', NOW)).toBe('') + expect(formatDuration('2026-08-11T19:00:00+08:00', NOW)).toBe('0 秒') + }) +}) + +describe('排序', () => { + const list = [ + conn({ id: 'small-new', download: 100, start: '2026-08-11T18:39:50+08:00' }), + conn({ id: 'big-new', download: 9000, start: '2026-08-11T18:39:40+08:00' }), + conn({ id: 'small-old', download: 50, start: '2026-08-11T18:00:00+08:00' }), + ] + + it('按下载量降序', () => { + expect(sortConnections(list, 'download', NOW).map((c) => c.id)).toEqual([ + 'big-new', + 'small-new', + 'small-old', + ]) + }) + + it('按持续时间降序:活得久的排前面', () => { + expect(sortConnections(list, 'duration', NOW).map((c) => c.id)).toEqual([ + 'small-old', + 'big-new', + 'small-new', + ]) + }) + + it('不动原数组', () => { + const before = list.map((c) => c.id) + sortConnections(list, 'download', NOW) + expect(list.map((c) => c.id)).toEqual(before) + }) +}) + +describe('筛选', () => { + const list = [ + conn({ id: 'a', metadata: meta({ host: 'speed.cloudflare.com' }) }), + conn({ id: 'b', metadata: meta({ host: '', destinationIP: '1.2.3.4' }) }), + ] + + it('匹配主机名,大小写不敏感', () => { + expect(filterConnections(list, 'CLOUDFLARE').map((c) => c.id)).toEqual(['a']) + }) + + it('没域名时也能按 IP 搜', () => { + expect(filterConnections(list, '1.2').map((c) => c.id)).toEqual(['b']) + }) + + it('空串不筛,空白也不算条件', () => { + expect(filterConnections(list, ' ')).toHaveLength(2) + }) +}) + +describe('按入站聚合', () => { + it('同一路入站计数、流量都累加,连接多的排前面', () => { + const list = [ + conn({ id: 'a', download: 10, upload: 1 }), + conn({ id: 'b', download: 20, upload: 2 }), + conn({ id: 'c', metadata: meta({ inboundName: 'lxc-db', inboundPort: '17892' }) }), + ] + expect(groupByInbound(list)).toEqual([ + { label: 'lxc-web(17891)', count: 2, upload: 3, download: 30 }, + { label: 'lxc-db(17892)', count: 1, upload: 0, download: 0 }, + ]) + }) + + it('默认入站用内核给的内部名,附上端口', () => { + const list = [conn({ metadata: meta({ inboundName: 'DEFAULT-MIXED', inboundPort: '17890' }) })] + expect(groupByInbound(list)[0]?.label).toBe('DEFAULT-MIXED(17890)') + }) + + it('入站名字缺失时用端口凑', () => { + const list = [conn({ metadata: meta({ inboundName: '' }) })] + expect(groupByInbound(list)[0]?.label).toBe('17891') + }) +}) diff --git a/shared/connections.ts b/shared/connections.ts new file mode 100644 index 0000000..accdc77 --- /dev/null +++ b/shared/connections.ts @@ -0,0 +1,131 @@ +/** + * 连接页的领域逻辑:内核当前连接的显示、筛选、排序和按入站聚合。 + * + * 类型按 v1.19.29 管理接口 GET /connections 的实际返回写(开发内核实测), + * 只保留界面用到的字段。`connections` 在没有连接时是 null 而不是空数组。 + */ + +/** 一条连接里界面关心的元数据。内核返回的字段远不止这些,用不到的不收。 */ +export interface ConnectionMeta { + host: string + destinationIP: string + destinationPort: string + /** 从哪一路入站进来:命名入站是它的名字,默认入站是内核造的 DEFAULT-* */ + inboundName: string + inboundPort: string +} + +export interface ConnectionItem { + id: string + upload: number + download: number + /** ISO 时间,带时区偏移(内核按本机时区输出) */ + start: string + /** 经过的出站链。第一个是最终出口,最后一个是最先接触的——和直觉相反 */ + chains: string[] + /** 命中的分流规则类型,如 DomainSuffix;兜底是 Match */ + rule: string + rulePayload: string + metadata: ConnectionMeta +} + +export interface ConnectionSnapshot { + downloadTotal: number + uploadTotal: number + connections: ConnectionItem[] | null +} + +/** 这条连接连往哪。有域名显示域名,没有退回 IP,都带端口。 */ +export function hostLabel(meta: ConnectionMeta): string { + const target = meta.host || meta.destinationIP + return meta.destinationPort ? `${target}:${meta.destinationPort}` : target +} + +/** + * 出站链按流量经过的顺序显示:最先接触的在左,最终出口在右。 + * 内核给的数组恰好相反,所以要倒过来。 + */ +export function chainLabel(chains: string[]): string { + return [...chains].reverse().join(' → ') +} + +/** + * 规则显示成「类型 载荷」,兜底规则没有载荷。 + * 类型和载荷都是配置文件 rules: 里的原词,不翻译。 + */ +export function ruleLabel(rule: string, payload: string): string { + return payload ? `${rule} ${payload}` : rule +} + +/** 持续时间。start 解析不出来(坏数据)时返回空串,由界面决定摆什么。 */ +export function formatDuration(startIso: string, now: number): string { + const started = Date.parse(startIso) + if (Number.isNaN(started)) return '' + const seconds = Math.max(0, Math.floor((now - started) / 1000)) + if (seconds < 60) return `${String(seconds)} 秒` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${String(minutes)} 分 ${String(seconds % 60)} 秒` + return `${String(Math.floor(minutes / 60))} 小时 ${String(minutes % 60)} 分` +} + +export type SortKey = 'download' | 'duration' + +/** + * 排序。都是降序:下载多的、活得久的排前面。 + * 持续时间没有现成字段,从 start 现算;排坏的 start 沉底。 + */ +export function sortConnections( + list: ConnectionItem[], + key: SortKey, + now: number, +): ConnectionItem[] { + const age = (c: ConnectionItem) => { + const started = Date.parse(c.start) + return Number.isNaN(started) ? -1 : now - started + } + return [...list].sort((a, b) => (key === 'download' ? b.download - a.download : age(b) - age(a))) +} + +/** 按主机名筛选。大小写不敏感,空串不筛。 */ +export function filterConnections(list: ConnectionItem[], query: string): ConnectionItem[] { + const q = query.trim().toLowerCase() + if (!q) return list + return list.filter((c) => hostLabel(c.metadata).toLowerCase().includes(q)) +} + +/** 一路入站当前有多少条连接。 */ +export interface InboundUse { + /** 命名入站显示名字;默认入站内核只给 DEFAULT-* 这类内部名,附上端口好认 */ + label: string + count: number + upload: number + download: number +} + +/** + * 按入站聚合。这是连接页对本项目的独特价值:每一路入站(每个容器)现在有多少条 + * 连接、走了多少流量。 + */ +export function groupByInbound(list: ConnectionItem[]): InboundUse[] { + const byName = new Map() + for (const c of list) { + const name = c.metadata.inboundName || c.metadata.inboundPort + let entry = byName.get(name) + if (!entry) { + entry = { + label: c.metadata.inboundName + ? `${c.metadata.inboundName}(${c.metadata.inboundPort})` + : c.metadata.inboundPort, + count: 0, + upload: 0, + download: 0, + } + byName.set(name, entry) + } + entry.count += 1 + entry.upload += c.upload + entry.download += c.download + } + // 连接多的排前面,和表格的默认排序一个取向 + return [...byName.values()].sort((a, b) => b.count - a.count) +} diff --git a/web/api.ts b/web/api.ts index 7714233..03f7506 100644 --- a/web/api.ts +++ b/web/api.ts @@ -1,172 +1,191 @@ -/** - * 后端接口的客户端。 - * - * 令牌存在 localStorage 里。它等同于密码,但这个页面本来就只有能访问 127.0.0.1 - * (或者持有 SSH 隧道)的人才打得开,存在浏览器里不额外扩大暴露面。 - */ -import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts' - -const TOKEN_KEY = 'patchbay.token' - -/** 测延迟用的目标。204 空响应,体积最小,全球都有部署。 */ -const TEST_URL = 'https://www.gstatic.com/generate_204' - -export function getToken(): string { - return localStorage.getItem(TOKEN_KEY) ?? '' -} - -export function setToken(value: string): void { - localStorage.setItem(TOKEN_KEY, value.trim()) -} - -export function clearToken(): void { - localStorage.removeItem(TOKEN_KEY) -} - -/** - * 接口报错时抛这个。 - * - * 服务端的错误响应统一是 { message, hint? }:message 说发生了什么,hint 说下一步该干嘛。 - * 界面直接用这两个字段,不需要认得每个端点各自的错误形状。 - */ -export class ApiError extends Error { - constructor( - readonly status: number, - readonly payload: Record, - ) { - super( - typeof payload.message === 'string' - ? payload.message - : `请求失败(HTTP ${String(status)})`, - ) - this.name = 'ApiError' - } - - /** 服务端给的下一步建议,没有就是空串。 */ - get hint(): string { - return typeof this.payload.hint === 'string' ? this.payload.hint : '' - } - - get unauthorized(): boolean { - return this.status === 401 - } -} - -async function request(method: string, path: string, body?: unknown): Promise { - const res = await fetch(path, { - method, - headers: { - Authorization: `Bearer ${getToken()}`, - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), - }, - body: body === undefined ? undefined : JSON.stringify(body), - }) - const text = await res.text() - const payload = text ? (JSON.parse(text) as Record) : {} - if (!res.ok) throw new ApiError(res.status, payload) - return payload as T -} - -export interface ConfigSnapshot { - data: ConfigObject - revision: string - diagnostics: ConfigDiagnostic[] -} - -export interface ApplySuccess { - ok: true - snapshot: ConfigSnapshot - reloaded: boolean - reloadMessage: string -} - -export interface ProxyInfo { - /** 出站的名字。/proxies 用键名标识,provider 里的线路则带在对象上 */ - name: string - /** 协议类型(Vmess、Vless…)或策略组类型(Selector、URLTest…) */ - type: string - /** 策略组当前选中的成员。不是策略组就没有这个字段 */ - now?: string - /** 策略组的成员列表 */ - all?: string[] - /** 延迟测试记录,最后一条是最近一次。delay 为 0 表示测失败 */ - history?: { delay: number }[] -} - -/** 最近一次延迟。没测过或测失败都返回 null。 */ -export function latestDelay(info: ProxyInfo | undefined): number | null { - const last = info?.history?.at(-1)?.delay - return last === undefined || last === 0 ? null : last -} - -export interface FieldSpec { - key: string - type: 'string' | 'number' | 'boolean' | 'string[]' | 'number[]' | 'object' | 'object[]' - required: boolean - default: unknown - enum: (string | number)[] | null - note: string -} - -export interface ListenerFields { - common: FieldSpec[] - types: Record -} - -export const api = { - readConfig: () => request('GET', '/api/config'), - - applyConfig: (data: ConfigObject, baseRevision: string) => - request('PUT', '/api/config', { data, baseRevision }), - - listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'), - - restoreBackup: (name: string) => - request('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`), - - kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'), - - /** 入站类型的字段表,从内核源码提取,用来驱动表单。 */ - listenerFields: () => request('GET', '/api/meta/listener-fields'), - - /** - * 所有出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。 - * 这些配置文件里都没有,只能问运行中的内核。 - */ - proxies: () => request<{ proxies: Record }>('GET', '/api/kernel/proxies'), - - /** - * 切换某个策略组当前选中哪个成员。 - * 这是运行时状态,不写配置文件,立刻生效——metacubexd 里点一条线路做的就是这件事。 - */ - selectProxy: (group: string, member: string) => - request<{ ok: true }>('PUT', `/api/kernel/proxies/${encodeURIComponent(group)}`, { - name: member, - }), - - /** - * 测一条出站的延迟。 - * - * 走哪个接口取决于它在不在内核的全局名字表里:策略组和自有线路在表里,用 /proxies; - * 订阅线路不在表里(那正是它们无法被入站直接引用的原因),只能从所属订阅进去测。 - */ - testLine: (name: string, provider?: string, timeout = 5000) => { - const q = `url=${encodeURIComponent(TEST_URL)}&timeout=${String(timeout)}` - const path = provider - ? `/api/kernel/providers/proxies/${encodeURIComponent(provider)}/${encodeURIComponent(name)}/healthcheck?${q}` - : `/api/kernel/proxies/${encodeURIComponent(name)}/delay?${q}` - return request<{ delay: number }>('GET', path) - }, - - /** - * 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。 - * - * 返回的线路带完整的运行时信息(协议、延迟历史),因为这些线路不在 /proxies 那张 - * 全局名字表里,这是拿到它们类型和延迟的唯一途径。 - */ - providers: () => - request<{ providers: Record }>( - 'GET', - '/api/kernel/providers/proxies', - ), -} +/** + * 后端接口的客户端。 + * + * 令牌存在 localStorage 里。它等同于密码,但这个页面本来就只有能访问 127.0.0.1 + * (或者持有 SSH 隧道)的人才打得开,存在浏览器里不额外扩大暴露面。 + */ +import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts' +import type { ConnectionSnapshot } from '@shared/connections.ts' + +const TOKEN_KEY = 'patchbay.token' + +/** 测延迟用的目标。204 空响应,体积最小,全球都有部署。 */ +const TEST_URL = 'https://www.gstatic.com/generate_204' + +export function getToken(): string { + return localStorage.getItem(TOKEN_KEY) ?? '' +} + +export function setToken(value: string): void { + localStorage.setItem(TOKEN_KEY, value.trim()) +} + +export function clearToken(): void { + localStorage.removeItem(TOKEN_KEY) +} + +/** + * 接口报错时抛这个。 + * + * 服务端的错误响应统一是 { message, hint? }:message 说发生了什么,hint 说下一步该干嘛。 + * 界面直接用这两个字段,不需要认得每个端点各自的错误形状。 + */ +export class ApiError extends Error { + constructor( + readonly status: number, + readonly payload: Record, + ) { + super( + typeof payload.message === 'string' + ? payload.message + : `请求失败(HTTP ${String(status)})`, + ) + this.name = 'ApiError' + } + + /** 服务端给的下一步建议,没有就是空串。 */ + get hint(): string { + return typeof this.payload.hint === 'string' ? this.payload.hint : '' + } + + get unauthorized(): boolean { + return this.status === 401 + } +} + +async function request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(path, { + method, + headers: { + Authorization: `Bearer ${getToken()}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const text = await res.text() + const payload = text ? (JSON.parse(text) as Record) : {} + if (!res.ok) throw new ApiError(res.status, payload) + return payload as T +} + +export interface ConfigSnapshot { + data: ConfigObject + revision: string + diagnostics: ConfigDiagnostic[] +} + +export interface ApplySuccess { + ok: true + snapshot: ConfigSnapshot + reloaded: boolean + reloadMessage: string +} + +export interface ProxyInfo { + /** 出站的名字。/proxies 用键名标识,provider 里的线路则带在对象上 */ + name: string + /** 协议类型(Vmess、Vless…)或策略组类型(Selector、URLTest…) */ + type: string + /** 策略组当前选中的成员。不是策略组就没有这个字段 */ + now?: string + /** 策略组的成员列表 */ + all?: string[] + /** 延迟测试记录,最后一条是最近一次。delay 为 0 表示测失败 */ + history?: { delay: number }[] +} + +/** 最近一次延迟。没测过或测失败都返回 null。 */ +export function latestDelay(info: ProxyInfo | undefined): number | null { + const last = info?.history?.at(-1)?.delay + return last === undefined || last === 0 ? null : last +} + +export interface FieldSpec { + key: string + type: 'string' | 'number' | 'boolean' | 'string[]' | 'number[]' | 'object' | 'object[]' + required: boolean + default: unknown + enum: (string | number)[] | null + note: string +} + +export interface ListenerFields { + common: FieldSpec[] + types: Record +} + +export const api = { + readConfig: () => request('GET', '/api/config'), + + applyConfig: (data: ConfigObject, baseRevision: string) => + request('PUT', '/api/config', { data, baseRevision }), + + listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'), + + restoreBackup: (name: string) => + request('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`), + + kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'), + + /** 入站类型的字段表,从内核源码提取,用来驱动表单。 */ + listenerFields: () => request('GET', '/api/meta/listener-fields'), + + /** + * 所有出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。 + * 这些配置文件里都没有,只能问运行中的内核。 + */ + proxies: () => request<{ proxies: Record }>('GET', '/api/kernel/proxies'), + + /** + * 切换某个策略组当前选中哪个成员。 + * 这是运行时状态,不写配置文件,立刻生效——metacubexd 里点一条线路做的就是这件事。 + */ + selectProxy: (group: string, member: string) => + request<{ ok: true }>('PUT', `/api/kernel/proxies/${encodeURIComponent(group)}`, { + name: member, + }), + + /** + * 测一条出站的延迟。 + * + * 走哪个接口取决于它在不在内核的全局名字表里:策略组和自有线路在表里,用 /proxies; + * 订阅线路不在表里(那正是它们无法被入站直接引用的原因),只能从所属订阅进去测。 + */ + testLine: (name: string, provider?: string, timeout = 5000) => { + const q = `url=${encodeURIComponent(TEST_URL)}&timeout=${String(timeout)}` + const path = provider + ? `/api/kernel/providers/proxies/${encodeURIComponent(provider)}/${encodeURIComponent(name)}/healthcheck?${q}` + : `/api/kernel/proxies/${encodeURIComponent(name)}/delay?${q}` + return request<{ delay: number }>('GET', path) + }, + + /** + * 当前所有连接的快照。 + * + * 这个接口没有流式版本(流式的是 /traffic、/logs 那几个),只能全量轮询。 + * 没有连接时 connections 字段是 null 而不是空数组。 + */ + connections: () => request('GET', '/api/kernel/connections'), + + /** 断开某一条连接。运行时动作,不写配置文件。 */ + closeConnection: (id: string) => + request<{ message?: string }>( + 'DELETE', + `/api/kernel/connections/${encodeURIComponent(id)}`, + ), + + /** 断开所有连接。 */ + closeAllConnections: () => request<{ message?: string }>('DELETE', '/api/kernel/connections'), + + /** + * 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。 + * + * 返回的线路带完整的运行时信息(协议、延迟历史),因为这些线路不在 /proxies 那张 + * 全局名字表里,这是拿到它们类型和延迟的唯一途径。 + */ + providers: () => + request<{ providers: Record }>( + 'GET', + '/api/kernel/providers/proxies', + ), +} diff --git a/web/views/ConnectionsPage.vue b/web/views/ConnectionsPage.vue new file mode 100644 index 0000000..f51041b --- /dev/null +++ b/web/views/ConnectionsPage.vue @@ -0,0 +1,431 @@ + + + + +