合并连接页(task/connections)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user