- 曲线空态和图共用一个定高容器,数据到来不再挤下面的内容 - 路由行去掉行间分割线:它会吃掉 hover 高亮的圆角 - 每路走势线先合并成 28 桶再画。3 秒一个采样直接全画,在一条 百来像素宽的线上只剩噪声;没采集的桶画断口,不画假零 - 环形图两段加圆角,交界用底色描边让出间隙 - 内核卡片从右列搬到服务器和路由之间(左列本来就矮一截),加 「重启内核」:POST /restart 后内核自我 exec、PID 不变,恢复没有 任何通知,只能每 2 秒探一次,半分钟探不到交还给外壳心跳
126 lines
5.0 KiB
TypeScript
126 lines
5.0 KiB
TypeScript
/**
|
|
* 仪表盘遥测的纯逻辑:滑动窗口和按入站聚合的吞吐差值。
|
|
*
|
|
* 采集本身(开流、轮询)在 web/telemetry.ts 的单例里做;这里只回答
|
|
* 「两次连接快照之间每一路走了多少字节」这类纯问题,让 bun test 够得着。
|
|
*/
|
|
import type { ConnectionItem } from './connections.ts'
|
|
|
|
/** 流量大图保留多久的历史。切走页面数据还在,是这个窗口在兜底。 */
|
|
export const TRAFFIC_WINDOW_MS = 30 * 60_000
|
|
|
|
/** 每路吞吐走势保留多久。活跃判定也用同一个窗口。 */
|
|
export const ROUTE_WINDOW_MS = 5 * 60_000
|
|
|
|
/** 连接快照的轮询间隔。 */
|
|
export const ROUTE_POLL_MS = 3_000
|
|
|
|
/**
|
|
* 「活跃」的门槛:最近 5 分钟走过的字节数。
|
|
* 取 256 KB 是为了滤掉心跳、DNS 这类背景流量——它们在 5 分钟里通常只有几十 KB,
|
|
* 而任何真实使用(网页、更新检查)轻松超过这个数。
|
|
*/
|
|
export const ACTIVE_BYTES = 256 * 1024
|
|
|
|
/** 一个采样点。up/down 是这个采样间隔里走过的字节数(不是速率),ms 是实际间隔。 */
|
|
export interface RouteSample {
|
|
t: number
|
|
ms: number
|
|
up: number
|
|
down: number
|
|
}
|
|
|
|
/** 流量曲线的一个点。up/down 是内核报的每秒字节数。 */
|
|
export interface TrafficPoint {
|
|
t: number
|
|
up: number
|
|
down: number
|
|
}
|
|
|
|
/** 追加一个点并把窗口外的老点挤掉。窗口按时间算,不按条数——采样间隔不保证均匀。 */
|
|
export function pushWindow<T extends { t: number }>(list: T[], point: T, windowMs: number): T[] {
|
|
const cutoff = point.t - windowMs
|
|
const start = list.findIndex((p) => p.t >= cutoff)
|
|
return [...(start === -1 ? [] : list.slice(start)), point]
|
|
}
|
|
|
|
/** 上次快照里每条连接的累计字节,按连接 id 记。 */
|
|
export type ConnTotals = Map<string, { up: number; down: number }>
|
|
|
|
/**
|
|
* 两次连接快照之间,每一路入站走了多少字节。
|
|
*
|
|
* 按连接逐条求差再按入站聚合,而不是先按入站求和再求差:连接关闭后会从快照里
|
|
* 消失,直接对总和求差会算出负数。代价是连接关闭前最后一段流量丢失——对走势图
|
|
* 来说可以接受。新出现的连接把它的累计全部记入本次间隔(它就是这段时间里产生的)。
|
|
* 同一条 id 的累计变小说明内核重启过计数,按全新连接处理。
|
|
*/
|
|
export function routeDeltas(
|
|
prev: ConnTotals,
|
|
conns: ConnectionItem[],
|
|
): { deltas: Record<string, { up: number; down: number }>; next: ConnTotals } {
|
|
const deltas: Record<string, { up: number; down: number }> = {}
|
|
const next: ConnTotals = new Map()
|
|
for (const conn of conns) {
|
|
const key = conn.metadata.inboundName || conn.metadata.inboundPort
|
|
const last = prev.get(conn.id)
|
|
const fresh = !last || last.up > conn.upload || last.down > conn.download
|
|
const up = fresh ? conn.upload : conn.upload - last.up
|
|
const down = fresh ? conn.download : conn.download - last.down
|
|
const entry = (deltas[key] ??= { up: 0, down: 0 })
|
|
entry.up += up
|
|
entry.down += down
|
|
next.set(conn.id, { up: conn.upload, down: conn.download })
|
|
}
|
|
return { deltas, next }
|
|
}
|
|
|
|
/** 窗口内一共走了多少字节(上下行合计)。活跃判定用。 */
|
|
export function windowBytes(samples: RouteSample[], since: number): number {
|
|
let total = 0
|
|
for (const s of samples) {
|
|
if (s.t >= since) total += s.up + s.down
|
|
}
|
|
return total
|
|
}
|
|
|
|
/** 最近一个采样的速率(字节每秒)。没有采样时是 null——「没数据」和 0 不是一回事。 */
|
|
export function lastRate(samples: RouteSample[]): { up: number; down: number } | null {
|
|
const last = samples.at(-1)
|
|
if (!last || last.ms <= 0) return null
|
|
return { up: (last.up / last.ms) * 1000, down: (last.down / last.ms) * 1000 }
|
|
}
|
|
|
|
/**
|
|
* 把窗口内的样本合并成固定数量的桶,每桶是平均速率(字节每秒)。
|
|
*
|
|
* 走势线要的是形状,不是每一次采样:3 秒一个点、5 分钟窗口是一百个点,
|
|
* 全画在一条几十像素宽的线上只剩噪声。没有采样的桶是 null(「没在采集」,
|
|
* 不是「速率为 0」——零样本是采集层补出来的真零),开头的 null 直接裁掉,
|
|
* 采集是从中途开始的,不该画出一段假的静止期。
|
|
*/
|
|
export function bucketRates(
|
|
samples: RouteSample[],
|
|
start: number,
|
|
end: number,
|
|
buckets: number,
|
|
): Array<[number, number | null]> {
|
|
if (end <= start || buckets <= 0) return []
|
|
const width = (end - start) / buckets
|
|
const sums = Array.from({ length: buckets }, () => ({ bytes: 0, ms: 0 }))
|
|
for (const s of samples) {
|
|
if (s.t < start || s.t > end) continue
|
|
const i = Math.min(buckets - 1, Math.floor((s.t - start) / width))
|
|
sums[i]!.bytes += s.up + s.down
|
|
sums[i]!.ms += s.ms
|
|
}
|
|
const points = sums.map(
|
|
(b, i): [number, number | null] => [
|
|
Math.round(start + (i + 0.5) * width),
|
|
b.ms > 0 ? (b.bytes / b.ms) * 1000 : null,
|
|
],
|
|
)
|
|
const first = points.findIndex(([, v]) => v !== null)
|
|
return first === -1 ? [] : points.slice(first)
|
|
}
|