Files
im0o 158dc9fca7 消化一批 P3:GEOIP 附加参数、坏 JSON 跳行、兜底丢弃;请求连接两页保活
ini 里 []GEOIP,CN,no-resolve 的附加参数原来被并进类别名,生成字段错位
的规则;现在拆出来放在目标之后。表里已有 MATCH 时配方的 []FINAL 不再
追加成死行,改为丢弃并在成功提示里注明——已有兜底是用户自己安排的
分流终点。

遥测流里偶发的坏 JSON 行改为跳过,原来一行坏数据会杀掉整个流进 5 秒
重试循环。请求页:跟随底部的阈值从 24px 降到 8px(原值比行高还大,
向上滚不足一行就被新日志拽回);silent 级别下的空状态不再说「没有流量
经过」。logs.test 的 localStorage 桩用完删掉,bun test 单进程跑,全局
残留会影响之后的测试文件。api.ts 的 format 注释照 provider.go:37 纠正。

请求页和连接页包进 KeepAlive:切走不销毁,筛选词、排序、滚动位置留住。
KeepAlive 常驻、子组件自己 v-if 进出;连接页的轮询改挂 activated——
挂载时它也触发,继续用 onMounted 会叠两个定时器,切走时停掉轮询。
2026-08-12 03:07:56 +08:00

199 lines
6.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 常驻的遥测采集:流量曲线、内核内存、每路入站的吞吐。
*
* 住在模块级单例里而不是仪表盘组件里,是因为历史要跨页面留存——采集跟着组件
* 走的话,切走一次曲线就清零一次。应用启动后由外壳调一次 startTelemetry(),
* 之后跟着 kernel.online 自己起落:上线接上,掉线断开但**不清历史**,断档在
* 时间轴上自然呈现为一段空白。
*
* 数据来源两路:
* - /traffic、/memory 流,每秒一行(内核源码 hub/route/server.go:386-414)
* - /connections 快照 3 秒一轮询,按连接逐条求差聚合出每路的吞吐
* (快照本身没有流式 HTTP 版本,见 hub/route/connections.go:24-28)
*/
import { ref, watch } from 'vue'
import {
ROUTE_POLL_MS,
ROUTE_WINDOW_MS,
TRAFFIC_WINDOW_MS,
pushWindow,
routeDeltas,
} from '@shared/telemetry.ts'
import type { ConnTotals, RouteSample, TrafficPoint } from '@shared/telemetry.ts'
import type { ExitProbe } from '@shared/host-info.ts'
import { api } from './api'
import { kernel } from './runtime'
import { readLines } from './stream'
/** 流量曲线,最近 30 分钟。up/down 是内核报的每秒字节数。 */
export const traffic = ref<TrafficPoint[]>([])
/** 内核报的累计上下行字节数。没收到过是 null。 */
export const totals = ref<{ up: number; down: number } | null>(null)
/** 内核自己的内存占用。 */
export const kernelMemory = ref<number | null>(null)
/** 每路入站的吞吐样本,键是内核连接快照里的 inboundName。 */
export const routeSamples = ref<Record<string, RouteSample[]>>({})
/** 当前连接数。 */
export const connCount = ref<number | null>(null)
/** 流当前接着没有。断流期间仪表盘的「当前值」要显示成没数据,而不是冻在最后一秒。 */
export const live = ref(false)
/** 每个端口的公网 IP 检测结果,会话内缓存。键是端口号。 */
export const exitProbes = ref<Record<number, { state: 'loading' | 'done'; result?: ExitProbe }>>({})
/** 采集的时钟,每次轮询走一格。活跃判定这类「相对现在」的计算依赖它保持新鲜。 */
export const tick = ref(Date.now())
const num = (value: unknown): number =>
typeof value === 'number' && Number.isFinite(value) ? value : 0
let abort: AbortController | null = null
let pollTimer = 0
let retryTimer = 0
let prevTotals: ConnTotals = new Map()
let prevAt = 0
async function consume(
path: string,
ctrl: AbortController,
onLine: (payload: Record<string, unknown>) => void,
) {
const body = await api.openStream(path, ctrl.signal)
for await (const line of readLines(body)) {
// 偶发的坏行跳过就行,抛出去会杀掉整个流、进 5 秒重试循环
let payload: Record<string, unknown>
try {
payload = JSON.parse(line) as Record<string, unknown>
} catch {
continue
}
onLine(payload)
}
}
function startStreams() {
const ctrl = new AbortController()
abort = ctrl
live.value = true
const run = (path: string, onLine: (payload: Record<string, unknown>) => void) =>
void consume(path, ctrl, onLine)
.catch(() => {})
// 这两个流本身永不结束,正常返回也意味着断了
.finally(() => {
if (ctrl.signal.aborted) return
// 一条断了把另一条也收掉,然后走统一的重连节奏
ctrl.abort()
live.value = false
scheduleRetry()
})
run('/traffic', (p) => {
traffic.value = pushWindow(
traffic.value,
{ t: Date.now(), up: num(p.up), down: num(p.down) },
TRAFFIC_WINDOW_MS,
)
totals.value = { up: num(p.upTotal), down: num(p.downTotal) }
})
run('/memory', (p) => {
kernelMemory.value = num(p.inuse)
})
}
/**
* 断流不等于内核没了:保存配置触发的重载会掐断流,内核几秒内就回来、online 从未
* 翻转。5 秒后重试,失败会再次走到这里形成节奏;真掉线交给下面 online 的 watch。
*/
function scheduleRetry() {
window.clearTimeout(retryTimer)
retryTimer = window.setTimeout(() => {
if (kernel.value.online && !live.value) startStreams()
}, 5_000)
}
async function pollConnections() {
const now = Date.now()
try {
const snapshot = await api.connections()
const conns = snapshot.connections ?? []
connCount.value = conns.length
const { deltas, next } = routeDeltas(prevTotals, conns)
const ms = prevAt ? now - prevAt : ROUTE_POLL_MS
prevTotals = next
prevAt = now
const merged = { ...routeSamples.value }
for (const [key, delta] of Object.entries(deltas)) {
merged[key] = pushWindow(
merged[key] ?? [],
{ t: now, ms, up: delta.up, down: delta.down },
ROUTE_WINDOW_MS,
)
}
// 有历史但这一轮没有连接的路,补零样本让走势落回去而不是冻住;
// 整个窗口都归零之后把这路忘掉,改名、删除的入站才不会在内存里越攒越多
for (const key of Object.keys(merged)) {
if (key in deltas) continue
const samples = pushWindow(merged[key]!, { t: now, ms, up: 0, down: 0 }, ROUTE_WINDOW_MS)
if (samples.every((s) => s.up === 0 && s.down === 0)) delete merged[key]
else merged[key] = samples
}
routeSamples.value = merged
} catch {
// 单轮失败就跳过。持续失败说明内核掉了,online 的 watch 会把轮询停掉
} finally {
tick.value = now
}
}
function attach() {
if (!abort) startStreams()
if (!pollTimer) {
void pollConnections()
pollTimer = window.setInterval(() => void pollConnections(), ROUTE_POLL_MS)
}
}
function detach() {
abort?.abort()
abort = null
live.value = false
window.clearInterval(pollTimer)
window.clearTimeout(retryTimer)
pollTimer = 0
prevTotals = new Map()
prevAt = 0
}
let started = false
/** 外壳登录成功后调一次。之后采集跟着内核在线状态自己起落。 */
export function startTelemetry(): void {
if (started) return
started = true
watch(
() => kernel.value.online,
(online) => {
if (online) attach()
else detach()
},
{ immediate: true },
)
}
/** 检测某个端口的公网 IP。结果缓存到会话结束,force 重测。 */
export async function probeExit(port: number, force = false): Promise<void> {
const cached = exitProbes.value[port]
if (!force && cached && (cached.state === 'loading' || cached.result?.ok)) return
exitProbes.value = { ...exitProbes.value, [port]: { state: 'loading' } }
const result = await api
.probeExit(port)
.catch((error: unknown) => ({ ok: false, message: String(error) }) as ExitProbe)
exitProbes.value = { ...exitProbes.value, [port]: { state: 'done', result } }
}