采集从仪表盘组件挪进模块级单例(web/telemetry.ts),流量曲线和每路 吞吐的历史跨页面留存——跟着组件走的话切一次页面就清零一次。每路吞吐 从 /connections 快照按连接逐条求差再按 inboundName 聚合:先求和再求差 会在连接关闭时算出负数。 图表换 ECharts(按需注册):手写 SVG 折线做不出平滑曲线和时间轴, 颜色在运行时从 CSS 变量取,跟随主题切换重取。 路由列表每行带走势、速率和可选的公网 IP 检测,点击跳路由页并选中 那一路(外壳传 focus 给 RouterPage 消费)。
235 lines
9.2 KiB
TypeScript
235 lines
9.2 KiB
TypeScript
/**
|
||
* 把配置文件里两套写法的入站合并成界面上的一张表。
|
||
*
|
||
* mihomo 有新旧两套声明入站的方式:
|
||
* 旧:顶层的 mixed-port / port / socks-port / redir-port / tproxy-port,
|
||
* 每种只能有一个,而且没法给它单独指定出站
|
||
* 新:listeners 数组,数量不限,每个都能有自己的出站和附加规则
|
||
*
|
||
* 界面上必须两边都画。只画 listeners 的话,一台只用 mixed-port 的机器打开是空板——
|
||
* 而实际上那个端口正在跑,这等于对用户撒谎。
|
||
*/
|
||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||
|
||
/** 内核自带、不需要配置就存在的出站。 */
|
||
export const BUILTIN_OUTBOUNDS = [
|
||
'DIRECT',
|
||
'REJECT',
|
||
'REJECT-DROP',
|
||
'PASS',
|
||
'PASS-RULE',
|
||
'COMPATIBLE',
|
||
'GLOBAL',
|
||
] as const
|
||
|
||
/** 旧写法的顶层端口字段,以及它们各自等价于哪种入站类型。 */
|
||
const LEGACY_PORTS: Array<{ key: string; type: string }> = [
|
||
{ key: 'mixed-port', type: 'mixed' },
|
||
{ key: 'port', type: 'http' },
|
||
{ key: 'socks-port', type: 'socks' },
|
||
{ key: 'redir-port', type: 'redir' },
|
||
{ key: 'tproxy-port', type: 'tproxy' },
|
||
]
|
||
|
||
/** 一路入站的出站去向。四选一,对应界面上那个下拉框。 */
|
||
export type OutboundMode =
|
||
| { kind: 'rules' } // 没写 proxy,流量进分流规则
|
||
| { kind: 'direct' } // proxy: DIRECT
|
||
| { kind: 'global' } // proxy: GLOBAL
|
||
| { kind: 'named'; name: string } // proxy: 某个策略组或自有线路
|
||
|
||
export interface Inbound {
|
||
/** 界面上用来定位这一行 */
|
||
id: string
|
||
name: string
|
||
type: string
|
||
/** 端口。旧写法一定是单个数字,新写法可以是 "200,302" 或 "401-429" 这类写法 */
|
||
port: string
|
||
listen: string | null
|
||
outbound: OutboundMode
|
||
/** 挂在这路入站上的附加规则组名,没有就是 null */
|
||
ruleSet: string | null
|
||
/** 旧写法声明的入站。界面上不可删除,也不能单独指定出站 */
|
||
isDefault: boolean
|
||
/** 旧写法对应的顶层字段名,用于写回。新写法为 null */
|
||
legacyKey: string | null
|
||
/** 在 listeners 数组里的下标,用于写回。旧写法为 null */
|
||
listenerIndex: number | null
|
||
}
|
||
|
||
/**
|
||
* 不用指名道姓的那几种出站,以及它们各自对应配置里的什么值。
|
||
*
|
||
* 这张表是「读配置 → 界面显示」的唯一约定来源。Patchbay 自己不会写出这些值
|
||
* (出站模式的写方向已随设计废弃),但手写配置里它们都可能出现,读和显示必须认得。
|
||
*
|
||
* `field` 为 undefined 表示配置里**没写** proxy 这个字段——「没有这个键」和
|
||
* 「这个键是空字符串」在 YAML 里是两回事。
|
||
*/
|
||
export const OUTBOUND_PRESETS: Array<{
|
||
mode: OutboundMode
|
||
field: string | undefined
|
||
label: string
|
||
}> = [
|
||
{ mode: { kind: 'rules' }, field: undefined, label: 'RULE' },
|
||
{ mode: { kind: 'direct' }, field: 'DIRECT', label: 'DIRECT' },
|
||
{ mode: { kind: 'global' }, field: 'GLOBAL', label: 'GLOBAL' },
|
||
]
|
||
|
||
function outboundOf(proxy: unknown): OutboundMode {
|
||
if (typeof proxy !== 'string' || proxy === '') return { kind: 'rules' }
|
||
const preset = OUTBOUND_PRESETS.find((p) => p.field === proxy)
|
||
return preset ? preset.mode : { kind: 'named', name: proxy }
|
||
}
|
||
|
||
/** 出站在界面上的名字。指名道姓的就是它自己的名字,其余用配置里的原词。 */
|
||
export function outboundLabel(mode: OutboundMode): string {
|
||
if (mode.kind === 'named') return mode.name
|
||
return OUTBOUND_PRESETS.find((p) => p.mode.kind === mode.kind)?.label ?? mode.kind
|
||
}
|
||
|
||
/**
|
||
* 是不是「没有明确指定」那种状态。
|
||
*
|
||
* 界面上用来决定要不要弱化显示:RULE 是不写 proxy 字段时的默认结果,不是用户做出的
|
||
* 选择,跟他明确选了 DIRECT 是两回事,不该长得一样。
|
||
*/
|
||
export function isUnsetOutbound(mode: OutboundMode): boolean {
|
||
return mode.kind === 'rules'
|
||
}
|
||
|
||
/** 入站类型在界面上怎么念。以配置里的原词为准,不另造描述。 */
|
||
export function inboundTypeLabel(type: string): string {
|
||
return type === '' ? '—' : type
|
||
}
|
||
|
||
/**
|
||
* 老式顶层端口实际绑在哪个地址上。
|
||
*
|
||
* 它没有自己的 listen 字段,地址是内核从两个全局开关推出来的
|
||
* (内核源码 `listener/listener.go:709-718`):
|
||
* allow-lan 关 → 127.0.0.1,只有本机连得上
|
||
* allow-lan 开 → bind-address 指定的地址,写 * 或没写就是所有网卡
|
||
* 界面上不能一律显示 0.0.0.0,那会让人以为局域网连得上而实际连不上。
|
||
*/
|
||
export function legacyListenAddress(data: ConfigObject): string {
|
||
if (data['allow-lan'] !== true) return '127.0.0.1'
|
||
const bind = data['bind-address']
|
||
if (typeof bind !== 'string' || bind === '' || bind === '*') return '0.0.0.0'
|
||
return bind
|
||
}
|
||
|
||
export function collectInbounds(data: ConfigObject): Inbound[] {
|
||
const result: Inbound[] = []
|
||
const legacyListen = legacyListenAddress(data)
|
||
|
||
for (const { key, type } of LEGACY_PORTS) {
|
||
const raw = data[key]
|
||
const port = typeof raw === 'number' ? raw : Number(raw)
|
||
// 0 和缺省都表示没启用这个端口
|
||
if (!Number.isFinite(port) || port <= 0) continue
|
||
result.push({
|
||
id: `legacy:${key}`,
|
||
// 名字直接用它来自的那个配置键。比「HTTP 和 SOCKS5 共用」这种描述好:
|
||
// 类型那一列已经写着协议了,名字再说一遍是重复,而配置键能直接对上文件里的行。
|
||
name: key,
|
||
type,
|
||
port: String(port),
|
||
listen: legacyListen,
|
||
outbound: { kind: 'rules' },
|
||
ruleSet: null,
|
||
isDefault: true,
|
||
legacyKey: key,
|
||
listenerIndex: null,
|
||
})
|
||
}
|
||
|
||
const listeners = Array.isArray(data.listeners) ? data.listeners : []
|
||
listeners.forEach((entry, index) => {
|
||
if (typeof entry !== 'object' || entry === null) return
|
||
const item = entry as Record<string, unknown>
|
||
result.push({
|
||
id: `listener:${String(index)}`,
|
||
name: typeof item.name === 'string' ? item.name : `第 ${String(index + 1)} 个入站`,
|
||
type: typeof item.type === 'string' ? item.type : '',
|
||
port: item.port === undefined || item.port === null ? '' : String(item.port),
|
||
listen: typeof item.listen === 'string' ? item.listen : null,
|
||
outbound: outboundOf(item.proxy),
|
||
ruleSet: typeof item.rule === 'string' && item.rule ? item.rule : null,
|
||
isDefault: false,
|
||
legacyKey: null,
|
||
listenerIndex: index,
|
||
})
|
||
})
|
||
|
||
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()}`
|
||
}
|
||
|
||
/**
|
||
* 走规则表的流量最后交给谁。
|
||
*
|
||
* `MATCH` 匹配一切,所以第一条 MATCH 之后的规则都到不了,它就是兜底出站。
|
||
* 没有 MATCH 时内核会直连,返回 null。
|
||
*/
|
||
export function fallbackTarget(data: ConfigObject): string | null {
|
||
const rules = Array.isArray(data.rules) ? data.rules : []
|
||
for (const rule of rules) {
|
||
if (typeof rule !== 'string') continue
|
||
const parts = rule.split(',').map((p) => p.trim())
|
||
if (parts[0]?.toUpperCase() === 'MATCH' && parts[1]) return parts[1]
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 这一路入站的流量最终交给哪个出站。
|
||
*
|
||
* 和 `inbound.outbound` 的区别在于「跟着规则走」会被继续解析下去:那种情况下真正决定
|
||
* 去向的是规则表末尾的 MATCH。界面上要显示的是这个结果——用户想知道的是流量去哪,
|
||
* 不是配置里那个字段写没写。
|
||
*/
|
||
export function resolveDestination(data: ConfigObject, inbound: Inbound): string | null {
|
||
const mode = inbound.outbound
|
||
if (mode.kind === 'direct') return 'DIRECT'
|
||
if (mode.kind === 'global') return 'GLOBAL'
|
||
if (mode.kind === 'named') return mode.name
|
||
return fallbackTarget(data)
|
||
}
|
||
|
||
/**
|
||
* 所有可以被入站的出站字段引用的名字。
|
||
*
|
||
* 订阅带来的线路**不在这里**——内核按名字找出站时查的是一张由内置出站、顶层 proxies
|
||
* 和 proxy-groups 构成的表,订阅线路住在订阅对象里,不在表上。想让入站固定走订阅里的
|
||
* 某条线路,得先建一个只装它的策略组。
|
||
*/
|
||
export function collectOutboundNames(data: ConfigObject): {
|
||
builtins: string[]
|
||
groups: string[]
|
||
ownProxies: string[]
|
||
} {
|
||
const groups = (Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : [])
|
||
.map((g) => (typeof g === 'object' && g !== null ? (g as Record<string, unknown>).name : null))
|
||
.filter((n): n is string => typeof n === 'string')
|
||
|
||
const ownProxies = (Array.isArray(data.proxies) ? data.proxies : [])
|
||
.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).name : null))
|
||
.filter((n): n is string => typeof n === 'string')
|
||
|
||
return { builtins: [...BUILTIN_OUTBOUNDS], groups, ownProxies }
|
||
}
|