仪表盘细节修整,内核卡片搬左列并加重启
- 曲线空态和图共用一个定高容器,数据到来不再挤下面的内容 - 路由行去掉行间分割线:它会吃掉 hover 高亮的圆角 - 每路走势线先合并成 28 桶再画。3 秒一个采样直接全画,在一条 百来像素宽的线上只剩噪声;没采集的桶画断口,不画假零 - 环形图两段加圆角,交界用底色描边让出间隙 - 内核卡片从右列搬到服务器和路由之间(左列本来就矮一截),加 「重启内核」:POST /restart 后内核自我 exec、PID 不变,恢复没有 任何通知,只能每 2 秒探一次,半分钟探不到交还给外壳心跳
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
bucketRates,
|
||||
lastRate,
|
||||
pushWindow,
|
||||
routeDeltas,
|
||||
@@ -79,6 +80,36 @@ describe('routeDeltas', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('bucketRates', () => {
|
||||
test('样本按桶平均,桶的时间戳在桶中央', () => {
|
||||
const samples: RouteSample[] = [
|
||||
{ t: 1_000, ms: 1_000, up: 100, down: 0 },
|
||||
{ t: 2_000, ms: 1_000, up: 300, down: 0 },
|
||||
{ t: 6_000, ms: 1_000, up: 50, down: 50 },
|
||||
]
|
||||
// 0-10s 分两桶:前桶 400 字节 / 2 秒 = 200 B/s,后桶 100 字节 / 1 秒 = 100 B/s
|
||||
expect(bucketRates(samples, 0, 10_000, 2)).toEqual([
|
||||
[2_500, 200],
|
||||
[7_500, 100],
|
||||
])
|
||||
})
|
||||
|
||||
test('开头没数据的桶被裁掉,中间的留成 null', () => {
|
||||
const samples: RouteSample[] = [
|
||||
{ t: 3_000, ms: 1_000, up: 100, down: 0 },
|
||||
{ t: 9_000, ms: 1_000, up: 100, down: 0 },
|
||||
]
|
||||
const points = bucketRates(samples, 0, 10_000, 5)
|
||||
expect(points).toHaveLength(4)
|
||||
expect(points[0]).toEqual([3_000, 100])
|
||||
expect(points[1]![1]).toBeNull()
|
||||
})
|
||||
|
||||
test('没有任何样本返回空数组', () => {
|
||||
expect(bucketRates([], 0, 10_000, 5)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('windowBytes / lastRate', () => {
|
||||
const samples: RouteSample[] = [
|
||||
{ t: 0, ms: 3_000, up: 100, down: 100 },
|
||||
|
||||
@@ -90,3 +90,36 @@ export function lastRate(samples: RouteSample[]): { up: number; down: number } |
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user