服务端代答主机指标与公网 IP 检测
mihomo 的管理接口只报自己的内存,CPU、主机内存、网关、内网地址只有 跑在同一台机器上的 Patchbay 服务端答得出,所以加 /api/host 由它代答。 /api/host/exit 把本机某个入站端口设成代理去访问 ip.sb——浏览器发不出 「经由某个端口」的请求,这条只能走服务端。
This commit is contained in:
@@ -18,3 +18,13 @@ export function formatBytes(bytes: number): string {
|
||||
// Number() 去掉无意义的尾零:1.0 KB → 1 KB
|
||||
return `${String(Number(value.toFixed(digits)))} ${UNITS[unit]}`
|
||||
}
|
||||
|
||||
/** 运行时长。一小时内说分钟,一天内说时分,再长说天和小时;秒级抖动没有信息量。 */
|
||||
export function formatUptime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '—'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${String(minutes)} 分钟`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${String(hours)} 小时 ${String(minutes % 60)} 分`
|
||||
return `${String(Math.floor(hours / 24))} 天 ${String(hours % 24)} 小时`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
cpuUsageBetween,
|
||||
parseGateway,
|
||||
parseMemAvailable,
|
||||
parseNameservers,
|
||||
} from './host-info.ts'
|
||||
|
||||
describe('parseMemAvailable', () => {
|
||||
test('取 MemAvailable 并换算成字节', () => {
|
||||
const meminfo = 'MemTotal: 2048000 kB\nMemFree: 100000 kB\nMemAvailable: 512000 kB\n'
|
||||
expect(parseMemAvailable(meminfo)).toBe(512000 * 1024)
|
||||
})
|
||||
|
||||
test('没有这一行(老内核)返回 null', () => {
|
||||
expect(parseMemAvailable('MemTotal: 1 kB\nMemFree: 1 kB\n')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseGateway', () => {
|
||||
test('默认路由的网关按小端序倒回来', () => {
|
||||
// 0102A8C0 → c0.a8.02.01 → 192.168.2.1
|
||||
const table = [
|
||||
'Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT',
|
||||
'eth0\t00000000\t0102A8C0\t0003\t0\t0\t0\t00000000\t0\t0\t0',
|
||||
'eth0\t0002A8C0\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0',
|
||||
].join('\n')
|
||||
expect(parseGateway(table)).toBe('192.168.2.1')
|
||||
})
|
||||
|
||||
test('没有默认路由返回 null', () => {
|
||||
const table = 'Iface\tDestination\tGateway\neth0\t0002A8C0\t00000000'
|
||||
expect(parseGateway(table)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseNameservers', () => {
|
||||
test('按出现顺序取 nameserver', () => {
|
||||
const conf = '# comment\nnameserver 223.5.5.5\nsearch lan\nnameserver 114.114.114.114\n'
|
||||
expect(parseNameservers(conf)).toEqual(['223.5.5.5', '114.114.114.114'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('cpuUsageBetween', () => {
|
||||
test('按两次快照的差值算百分比', () => {
|
||||
const usage = cpuUsageBetween({ busy: 100, total: 1000 }, { busy: 350, total: 2000 })
|
||||
expect(usage).toBe(25)
|
||||
})
|
||||
|
||||
test('时间没有前进时返回 null', () => {
|
||||
expect(cpuUsageBetween({ busy: 1, total: 10 }, { busy: 1, total: 10 })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 主机信息的纯解析。
|
||||
*
|
||||
* mihomo 的管理接口只报自己的内存,CPU、主机内存、网关这些要靠 Patchbay 服务端
|
||||
* 自己读 /proc 和 os 模块——服务端和内核跑在同一台机器上,这正是它能代答的原因。
|
||||
* 读文件在 server/host.ts;这里只做字符串到数字的解析,让 bun test 够得着。
|
||||
*/
|
||||
|
||||
/** GET /api/host 的响应。数据由 server/host.ts 采集,类型放这里让前后端共用。 */
|
||||
export interface HostInfo {
|
||||
/** 0-100。第一次请求没有参照快照,是 null */
|
||||
cpuUsage: number | null
|
||||
cpuCount: number
|
||||
memTotal: number
|
||||
/** 还能用的内存。Linux 用 MemAvailable,其余平台退回 freemem */
|
||||
memAvailable: number
|
||||
/** 1 分钟负载。Windows 上拿不到,是 null */
|
||||
load1: number | null
|
||||
uptimeSeconds: number
|
||||
platform: string
|
||||
release: string
|
||||
arch: string
|
||||
/** 非回环网卡上的 IPv4,含网卡名 */
|
||||
lanAddresses: Array<{ iface: string; address: string }>
|
||||
gateway: string | null
|
||||
nameservers: string[]
|
||||
}
|
||||
|
||||
/** GET /api/host/exit 的响应:经由某个入站端口探测到的公网 IP。 */
|
||||
export interface ExitProbe {
|
||||
ok: boolean
|
||||
ip?: string
|
||||
country?: string
|
||||
countryCode?: string
|
||||
/** 整个探测来回的毫秒数 */
|
||||
ms?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** /proc/meminfo 里的 MemAvailable(内核估算的「还能用多少」),换算成字节。 */
|
||||
export function parseMemAvailable(meminfo: string): number | null {
|
||||
const match = /^MemAvailable:\s+(\d+)\s*kB/m.exec(meminfo)
|
||||
if (!match) return null
|
||||
return Number(match[1]) * 1024
|
||||
}
|
||||
|
||||
/**
|
||||
* /proc/net/route 里的默认网关。
|
||||
*
|
||||
* 格式是制表分隔的表:目的网络为 00000000 的那行是默认路由,Gateway 列是
|
||||
* 小端序的十六进制 IPv4——字节序要倒回来才是人读的顺序。
|
||||
*/
|
||||
export function parseGateway(procNetRoute: string): string | null {
|
||||
for (const line of procNetRoute.split('\n').slice(1)) {
|
||||
const cols = line.trim().split(/\s+/)
|
||||
if (cols.length < 3 || cols[1] !== '00000000') continue
|
||||
const hex = cols[2]!
|
||||
if (!/^[0-9a-fA-F]{8}$/.test(hex)) continue
|
||||
const bytes = [6, 4, 2, 0].map((i) => parseInt(hex.slice(i, i + 2), 16))
|
||||
return bytes.join('.')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** /etc/resolv.conf 里的 DNS 服务器,按出现顺序。 */
|
||||
export function parseNameservers(resolvConf: string): string[] {
|
||||
const result: string[] = []
|
||||
for (const line of resolvConf.split('\n')) {
|
||||
const match = /^\s*nameserver\s+(\S+)/.exec(line)
|
||||
if (match) result.push(match[1]!)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 一次 CPU 时间快照:忙碌和总量的累计毫秒(os.cpus() 各核求和)。 */
|
||||
export interface CpuTimes {
|
||||
busy: number
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 两次快照之间的 CPU 使用率,0 到 100。
|
||||
* 时间没有前进(同一毫秒内连问两次,或计数回绕)时返回 null,不硬造一个数。
|
||||
*/
|
||||
export function cpuUsageBetween(prev: CpuTimes, next: CpuTimes): number | null {
|
||||
const total = next.total - prev.total
|
||||
const busy = next.busy - prev.busy
|
||||
if (total <= 0 || busy < 0) return null
|
||||
return Math.min(100, (busy / total) * 100)
|
||||
}
|
||||
Reference in New Issue
Block a user