合并连接页(task/connections)
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "patchbay",
|
"name": "patchbay",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/vue-virtual": "^3.13.35",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"yaml": "^2.9.0",
|
"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=="],
|
"@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/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=="],
|
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||||
|
|||||||
+3
-2
@@ -17,8 +17,9 @@
|
|||||||
"typecheck": "vue-tsc --noEmit"
|
"typecheck": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"yaml": "^2.9.0",
|
"@tanstack/vue-virtual": "^3.13.35",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13",
|
||||||
|
"yaml": "^2.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/ri": "^1.2.10",
|
"@iconify-json/ri": "^1.2.10",
|
||||||
|
|||||||
@@ -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> = {}): ConnectionMeta {
|
||||||
|
return {
|
||||||
|
host: 'example.com',
|
||||||
|
destinationIP: '93.184.216.34',
|
||||||
|
destinationPort: '443',
|
||||||
|
inboundName: 'lxc-web',
|
||||||
|
inboundPort: '17891',
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function conn(over: Partial<ConnectionItem> = {}): 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, InboundUse>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
+19
@@ -5,6 +5,7 @@
|
|||||||
* (或者持有 SSH 隧道)的人才打得开,存在浏览器里不额外扩大暴露面。
|
* (或者持有 SSH 隧道)的人才打得开,存在浏览器里不额外扩大暴露面。
|
||||||
*/
|
*/
|
||||||
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'
|
||||||
|
|
||||||
const TOKEN_KEY = 'patchbay.token'
|
const TOKEN_KEY = 'patchbay.token'
|
||||||
|
|
||||||
@@ -158,6 +159,24 @@ export const api = {
|
|||||||
return request<{ delay: number }>('GET', path)
|
return request<{ delay: number }>('GET', path)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前所有连接的快照。
|
||||||
|
*
|
||||||
|
* 这个接口没有流式版本(流式的是 /traffic、/logs 那几个),只能全量轮询。
|
||||||
|
* 没有连接时 connections 字段是 null 而不是空数组。
|
||||||
|
*/
|
||||||
|
connections: () => request<ConnectionSnapshot>('GET', '/api/kernel/connections'),
|
||||||
|
|
||||||
|
/** 断开某一条连接。运行时动作,不写配置文件。 */
|
||||||
|
closeConnection: (id: string) =>
|
||||||
|
request<{ message?: string }>(
|
||||||
|
'DELETE',
|
||||||
|
`/api/kernel/connections/${encodeURIComponent(id)}`,
|
||||||
|
),
|
||||||
|
|
||||||
|
/** 断开所有连接。 */
|
||||||
|
closeAllConnections: () => request<{ message?: string }>('DELETE', '/api/kernel/connections'),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。
|
* 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 连接页:内核当前有哪些连接,从哪一路入站进来,能断开它们。
|
||||||
|
*
|
||||||
|
* /connections 没有流式版本,只能每秒全量轮询一次。表格数据来自内核快照,
|
||||||
|
* 显示层的问题(主机名怎么摆、出站链按什么方向、持续时间多长)都在 shared/connections.ts
|
||||||
|
* 里回答,这里只管取数、状态和断开动作。
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||||
|
import {
|
||||||
|
chainLabel,
|
||||||
|
filterConnections,
|
||||||
|
formatDuration,
|
||||||
|
groupByInbound,
|
||||||
|
hostLabel,
|
||||||
|
ruleLabel,
|
||||||
|
sortConnections,
|
||||||
|
} from '@shared/connections.ts'
|
||||||
|
import type { ConnectionSnapshot, SortKey } from '@shared/connections.ts'
|
||||||
|
import { formatBytes } from '@shared/format.ts'
|
||||||
|
import { ApiError, api } from '../api'
|
||||||
|
import { kernel } from '../runtime'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
|
import IconClose from '~icons/ri/close-line'
|
||||||
|
|
||||||
|
const snapshot = ref<ConnectionSnapshot | null>(null)
|
||||||
|
const failure = ref('')
|
||||||
|
/** 表格里的持续时间按这一刻算。跟着轮询节拍走,不单独开秒表 */
|
||||||
|
const now = ref(Date.now())
|
||||||
|
const query = ref('')
|
||||||
|
const sort = ref<SortKey>('download')
|
||||||
|
/** 正在断开中的连接 id,防止连点 */
|
||||||
|
const closing = ref<string[]>([])
|
||||||
|
const closingAll = ref(false)
|
||||||
|
const askAll = ref(false)
|
||||||
|
/** 有请求在飞就不再发,内核卡住时每秒叠一个请求只会让局面更糟 */
|
||||||
|
let fetching = false
|
||||||
|
let timer: number | undefined
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
// 内核不在线时不打这个接口:得不到数据,界面直接说原因
|
||||||
|
if (!kernel.value.online || fetching) return
|
||||||
|
fetching = true
|
||||||
|
try {
|
||||||
|
snapshot.value = await api.connections()
|
||||||
|
now.value = Date.now()
|
||||||
|
failure.value = ''
|
||||||
|
} catch (error) {
|
||||||
|
failure.value = error instanceof ApiError ? error.message : String(error)
|
||||||
|
} finally {
|
||||||
|
fetching = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void refresh()
|
||||||
|
timer = window.setInterval(() => void refresh(), 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
})
|
||||||
|
|
||||||
|
const all = computed(() => snapshot.value?.connections ?? [])
|
||||||
|
const rows = computed(() =>
|
||||||
|
sortConnections(filterConnections(all.value, query.value), sort.value, now.value),
|
||||||
|
)
|
||||||
|
const uses = computed(() => groupByInbound(all.value))
|
||||||
|
|
||||||
|
/* 行高写死,estimateSize 必须和它一致,否则滚动位置算不准 */
|
||||||
|
const ROW_HEIGHT = 48
|
||||||
|
|
||||||
|
const scrollEl = ref<HTMLElement | null>(null)
|
||||||
|
const virtualizer = useVirtualizer(
|
||||||
|
computed(() => ({
|
||||||
|
count: rows.value.length,
|
||||||
|
getScrollElement: () => scrollEl.value,
|
||||||
|
estimateSize: () => ROW_HEIGHT,
|
||||||
|
overscan: 8,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const virtualRows = computed(() => virtualizer.value.getVirtualItems())
|
||||||
|
const totalSize = computed(() => virtualizer.value.getTotalSize())
|
||||||
|
|
||||||
|
async function closeOne(id: string) {
|
||||||
|
if (closing.value.includes(id) || closingAll.value) return
|
||||||
|
closing.value = [...closing.value, id]
|
||||||
|
failure.value = ''
|
||||||
|
try {
|
||||||
|
await api.closeConnection(id)
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
failure.value = error instanceof ApiError ? error.message : String(error)
|
||||||
|
} finally {
|
||||||
|
closing.value = closing.value.filter((item) => item !== id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeAllConfirmed() {
|
||||||
|
askAll.value = false
|
||||||
|
if (closingAll.value) return
|
||||||
|
closingAll.value = true
|
||||||
|
failure.value = ''
|
||||||
|
try {
|
||||||
|
await api.closeAllConnections()
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
failure.value = error instanceof ApiError ? error.message : String(error)
|
||||||
|
} finally {
|
||||||
|
closingAll.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<header class="head">
|
||||||
|
<div class="sums">
|
||||||
|
<div class="sum">
|
||||||
|
<span class="sum-n">{{ all.length }}</span>
|
||||||
|
<span class="sum-k">连接</span>
|
||||||
|
</div>
|
||||||
|
<div class="sum">
|
||||||
|
<span class="sum-n">{{ formatBytes(snapshot?.uploadTotal ?? 0) }}</span>
|
||||||
|
<span class="sum-k">上传总量</span>
|
||||||
|
</div>
|
||||||
|
<div class="sum">
|
||||||
|
<span class="sum-n">{{ formatBytes(snapshot?.downloadTotal ?? 0) }}</span>
|
||||||
|
<span class="sum-k">下载总量</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tools">
|
||||||
|
<input v-model="query" type="search" class="search" placeholder="按主机筛选" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="close-all"
|
||||||
|
:disabled="all.length === 0 || closingAll"
|
||||||
|
@click="askAll = true"
|
||||||
|
>
|
||||||
|
全部断开
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="failure" class="failure">
|
||||||
|
<strong>{{ failure }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!kernel.online" class="note">内核未连接,看不到当前有哪些连接。</p>
|
||||||
|
<p v-else-if="!snapshot" class="note">读取中</p>
|
||||||
|
<p v-else-if="all.length === 0" class="note">当前没有连接。</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 这个项目特有的视角:每一路入站(每个容器)现在有多少条连接 -->
|
||||||
|
<div class="uses">
|
||||||
|
<span v-for="use in uses" :key="use.label" class="use">
|
||||||
|
{{ use.label }}
|
||||||
|
<strong>{{ use.count }}</strong> 条
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="rows.length === 0" class="note">没有主机匹配「{{ query.trim() }}」。</p>
|
||||||
|
|
||||||
|
<div v-else class="table">
|
||||||
|
<div class="rowgrid thead">
|
||||||
|
<span>主机</span>
|
||||||
|
<span>规则</span>
|
||||||
|
<span>出站链</span>
|
||||||
|
<span class="num">上传</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['th num', sort === 'download' && 'is-on']"
|
||||||
|
@click="sort = 'download'"
|
||||||
|
>
|
||||||
|
下载{{ sort === 'download' ? ' ↓' : '' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['th num', sort === 'duration' && 'is-on']"
|
||||||
|
@click="sort = 'duration'"
|
||||||
|
>
|
||||||
|
持续时间{{ sort === 'duration' ? ' ↓' : '' }}
|
||||||
|
</button>
|
||||||
|
<span>入站</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="scrollEl" class="scroller">
|
||||||
|
<div class="spacer" :style="{ height: `${totalSize}px` }">
|
||||||
|
<div
|
||||||
|
v-for="vRow in virtualRows"
|
||||||
|
:key="rows[vRow.index]?.id ?? vRow.index"
|
||||||
|
class="rowgrid row"
|
||||||
|
:style="{ transform: `translateY(${vRow.start}px)` }"
|
||||||
|
>
|
||||||
|
<template v-if="rows[vRow.index]">
|
||||||
|
<span class="cell mono" :title="hostLabel(rows[vRow.index]!.metadata)">
|
||||||
|
{{ hostLabel(rows[vRow.index]!.metadata) }}
|
||||||
|
</span>
|
||||||
|
<span class="cell mono">{{ ruleLabel(rows[vRow.index]!.rule, rows[vRow.index]!.rulePayload) }}</span>
|
||||||
|
<span class="cell mono" :title="chainLabel(rows[vRow.index]!.chains)">
|
||||||
|
{{ chainLabel(rows[vRow.index]!.chains) }}
|
||||||
|
</span>
|
||||||
|
<span class="cell num mono">{{ formatBytes(rows[vRow.index]!.upload) }}</span>
|
||||||
|
<span class="cell num mono">{{ formatBytes(rows[vRow.index]!.download) }}</span>
|
||||||
|
<span class="cell num mono">{{ formatDuration(rows[vRow.index]!.start, now) }}</span>
|
||||||
|
<span class="cell mono">{{ rows[vRow.index]!.metadata.inboundName }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="drop"
|
||||||
|
title="断开这条连接"
|
||||||
|
:disabled="closing.includes(rows[vRow.index]!.id) || closingAll"
|
||||||
|
@click="closeOne(rows[vRow.index]!.id)"
|
||||||
|
>
|
||||||
|
<IconClose />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="askAll"
|
||||||
|
:message="`断开全部 ${String(all.length)} 条连接?正在进行的传输会中断。`"
|
||||||
|
action="全部断开"
|
||||||
|
@close="askAll = false"
|
||||||
|
@confirm="closeAllConfirmed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s-4);
|
||||||
|
margin-bottom: var(--s-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sums {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sum {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sum-n {
|
||||||
|
font-size: var(--t-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sum-k {
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
width: 200px;
|
||||||
|
padding: var(--s-2) var(--s-3);
|
||||||
|
border: 1px solid var(--c-border-strong);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--c-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-all {
|
||||||
|
padding: var(--s-2) var(--s-3);
|
||||||
|
border: 1px solid var(--c-border-strong);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--c-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.close-all:hover:not(:disabled) {
|
||||||
|
border-color: var(--c-danger);
|
||||||
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
.close-all:disabled {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--c-danger-soft);
|
||||||
|
padding: var(--s-3) var(--s-4);
|
||||||
|
margin-bottom: var(--s-4);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uses {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s-2);
|
||||||
|
margin-bottom: var(--s-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.use {
|
||||||
|
padding: var(--s-1) var(--s-3);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--c-surface);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.use strong {
|
||||||
|
color: var(--c-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 窄屏不挤变形,横向滚动 */
|
||||||
|
.table {
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--c-surface);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowgrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns:
|
||||||
|
minmax(160px, 1.5fr) minmax(120px, 1fr) minmax(160px, 1.5fr)
|
||||||
|
5rem 5rem 6.5rem minmax(90px, 0.8fr) 2rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-3);
|
||||||
|
min-width: 880px;
|
||||||
|
padding: 0 var(--s-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thead {
|
||||||
|
height: 40px;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 可排序的列表头。用按钮但视觉上仍是表头文字,排序中的那列才显色 */
|
||||||
|
.th {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.th:hover {
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.th.is-on {
|
||||||
|
color: var(--c-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller {
|
||||||
|
overflow-y: auto;
|
||||||
|
/* 表格最长不顶满一屏:留出头部和汇总条的位置,连接少时随内容缩短 */
|
||||||
|
max-height: calc(100vh - 20rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.drop:hover:not(:disabled) {
|
||||||
|
background: var(--c-danger-soft);
|
||||||
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
.drop:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user