/** * 连接页的领域逻辑:内核当前连接的显示、筛选、排序和按入站聚合。 * * 类型按 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 } // 平局按 id 决胜。输入顺序来自内核快照的 map 遍历,本身无序且每秒在变; // 空闲连接的下载量普遍相同,没有决胜键的话行会在指针(和断开按钮)下面跳 return [...list].sort((a, b) => { const main = key === 'download' ? b.download - a.download : age(b) - age(a) return main !== 0 ? main : a.id.localeCompare(b.id) }) } /** 按主机名筛选。大小写不敏感,空串不筛。 */ 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) }