Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 54 additions & 35 deletions src/components/NodeDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { Skeleton } from "@/components/ui/skeleton"
import { deployed, Dot, Flag } from "@/components/ServerTable"
import { api, type Node } from "@/lib/api"
import {
axisBytes, axisTop, bytes, clockFor, despike, quarters, rate, tickClock, timeTicks, uptime, windows,
axisBytes, axisTop, bytes, clockFor, despike, quarters, rate, RATE_FLOOR, rateAxis, tickClock, timeTicks, uptime,
windows,
} from "@/lib/format"
import { cn } from "@/lib/utils"

Expand Down Expand Up @@ -60,7 +61,12 @@ const SERIES = { dot: false as const, strokeWidth: 1.5, isAnimationActive: false
// One width for every stacked panel's value axis. Sized to their own labels --
// 40px under "100%", 68px under "172 MB" -- the four plot areas would be offset by
// 28px, placing a CPU spike and the network spike that caused it at different x.
const Y_WIDTH = 68
//
// Every listed tick is drawn: the lists are spaced to fit, while recharts' own
// collision pass would nudge the top label inward, drop the tick beneath it, and
// thin the gridlines separately -- the CPU axis would lose its 30% gridline, and
// a rate axis of 1 KB/s to 100 MB/s its 10 MB/s label and two gridlines.
const VALUE_AXIS = { ...AXIS, width: 68, interval: 0 }

// Hue alone separates the probes. A dash pattern would not: once every ping in a
// day is on the chart its period is shorter than the jitter, and dotted and dashed
Expand Down Expand Up @@ -107,9 +113,8 @@ type History = { metrics: Point[]; ping: PingPoint[]; probes: Probes; loss?: Los
* One window of history.
*
* A refused request is kept apart from an empty window. The hub builds at most
* four windows at once, since each holds the connection the agents report
* through, and answers a fifth with a 503; drawn as an empty chart, that answer
* would misdirect the reader, so callers show it with a retry.
* four windows at once and answers a fifth with a 503; drawn as an empty chart,
* that answer would misdirect the reader, so callers show it with a retry.
*/
function useHistory(id: number, hours: number, series: "metrics" | "ping") {
const [data, setData] = useState<History | null>(null)
Expand Down Expand Up @@ -421,17 +426,20 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num

// The hub answers in seconds; the time axis requires milliseconds. Each rate
// also spans from its mean to its peak, which is the band drawn behind the
// line; without a peak the band has no height.
const metricRows = useMemo(
() =>
(data?.metrics ?? []).map((m) => ({
...m,
ts: m.ts * 1_000,
rx_band: [m.net_rx, m.net_rx_max ?? m.net_rx],
tx_band: [m.net_tx, m.net_tx_max ?? m.net_tx],
})),
[data],
)
// line; without a peak the band has no height. The rates drawn are lifted to
// the floor of their log axis, which has no zero, while `net_rx` and `net_tx`
// keep the hub's figures for the tooltip.
const metricRows = useMemo(() => {
const lift = (v: number) => Math.max(v, RATE_FLOOR)
return (data?.metrics ?? []).map((m) => ({
...m,
ts: m.ts * 1_000,
rx: lift(m.net_rx),
tx: lift(m.net_tx),
rx_band: [lift(m.net_rx), lift(m.net_rx_max ?? m.net_rx)],
tx_band: [lift(m.net_tx), lift(m.net_tx_max ?? m.net_tx)],
}))
}, [data])
// The window's highest rate each way, or null from a hub that sends no peak:
// a maximum of the means would be labelled a peak it is not.
const peak = useMemo(() => {
Expand All @@ -441,20 +449,24 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
return { rx: max((m) => m.net_rx_max), tx: max((m) => m.net_tx_max) }
}, [data])

// Axis tops for the two panels with no capacity to measure against. CPU and a
// Axes for the two panels with no capacity to measure against. CPU and a
// transfer rate do not express fullness: against a fixed 0-100, a machine
// sitting at 0.4% draws as a line along the panel's floor. Memory and disk keep
// their totals as tops, where fullness is the entire question.
const tops = useMemo(() => {
const axes = useMemo(() => {
const max = (pick: (m: (typeof metricRows)[number]) => number) =>
metricRows.reduce((hi, m) => Math.max(hi, pick(m)), 0)
// A floor of 4%, or a machine that never exceeds 0.4% would get an axis of
// 0-0.4 and render every scheduler blip as a peak. Capped at 100.
const cpu = axisTop(max((m) => m.cpu), 4, 100)
return {
// A floor of 4%, or a machine that never exceeds 0.4% would get an axis of
// 0-0.4 and render every scheduler blip as a peak. Capped at 100.
cpu: axisTop(max((m) => m.cpu), 4, 10, 100),
// Base 1024, so the steps are round in the unit `axisBytes` prints. Fitted
// to the band rather than the line, or the peaks would run off the top.
rate: axisTop(max((m) => Math.max(m.rx_band[1], m.tx_band[1])), 1024, 1024),
cpu: { domain: [0, cpu], ticks: quarters(cpu) },
// From the slowest rate drawn to the highest peak, so the band stays
// within the panel.
rate: rateAxis(
metricRows.reduce((lo, m) => Math.min(lo, m.rx, m.tx), Infinity),
max((m) => Math.max(m.rx_band[1], m.tx_band[1])),
),
}
}, [metricRows])

Expand Down Expand Up @@ -496,7 +508,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
<AreaChart data={metricRows}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" vertical={false} />
<XAxis {...timeAxis(metricRows, hours)} />
<YAxis domain={[0, tops.cpu]} ticks={quarters(tops.cpu)} unit="%" width={Y_WIDTH} {...AXIS} />
<YAxis {...axes.cpu} unit="%" {...VALUE_AXIS} />
<Tooltip
labelFormatter={(ts) => new Date(Number(ts)).toLocaleString("zh-CN")}
formatter={(v) => [`${Number(v).toFixed(1)}%`, "CPU"]}
Expand All @@ -517,7 +529,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
<AreaChart data={metricRows}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" vertical={false} />
<XAxis {...timeAxis(metricRows, hours)} />
<YAxis domain={[0, node.mem_total]} ticks={quarters(node.mem_total)} tickFormatter={axisBytes} width={Y_WIDTH} {...AXIS} />
<YAxis domain={[0, node.mem_total]} ticks={quarters(node.mem_total)} tickFormatter={axisBytes} {...VALUE_AXIS} />
<Tooltip
labelFormatter={(ts) => new Date(Number(ts)).toLocaleString("zh-CN")}
formatter={(v) => bytes(Number(v))}
Expand All @@ -528,8 +540,12 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
</ResponsiveContainer>
</Panel>

{/* A rate has no total to be a fraction of, so this one climbs the
ladder like CPU rather than pinning to a capacity.
{/* A rate has no total to be a fraction of, and its range spans orders
of magnitude: a week of one node runs from 0.2 KB/s idle to bursts
of 70 MB/s. Fitted to the bursts, a linear axis would draw the
median minute less than a pixel above the floor on seven of nine
nodes at the day window, so this one is logarithmic, each tenfold
step the same height.

The line is the bucket's mean, so it integrates to the traffic
totals, and a 15-second speed test averaged over its minute draws
Expand All @@ -556,12 +572,15 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
<ComposedChart data={metricRows}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" vertical={false} />
<XAxis {...timeAxis(metricRows, hours)} />
<YAxis domain={[0, tops.rate]} ticks={quarters(tops.rate)} tickFormatter={axisBytes} unit="/s" width={Y_WIDTH} {...AXIS} />
<YAxis scale="log" {...axes.rate} tickFormatter={axisBytes} unit="/s" {...VALUE_AXIS} />
<Tooltip
labelFormatter={(ts) => new Date(Number(ts)).toLocaleString("zh-CN")}
formatter={(v, name, item) => {
const top = item?.payload?.[item.dataKey === "net_rx" ? "net_rx_max" : "net_tx_max"]
return [top === undefined ? rate(Number(v)) : `均值 ${rate(Number(v))} · 峰值 ${rate(top)}`, name]
// The hub's figures rather than `v`, which is lifted to the
// axis floor.
formatter={(_, name, item) => {
const mean = item?.payload?.[`net_${item.dataKey}`]
const top = item?.payload?.[`net_${item.dataKey}_max`]
return [top === undefined ? rate(mean) : `均值 ${rate(mean)} · 峰值 ${rate(top)}`, name]
}}
contentStyle={TIP}
/>
Expand All @@ -582,8 +601,8 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
legendType="none"
/>
))}
<Line dataKey="net_rx" name="下行" stroke="var(--color-chart-2)" {...SERIES} />
<Line dataKey="net_tx" name="上行" stroke="var(--color-chart-3)" {...SERIES} />
<Line dataKey="rx" name="下行" stroke="var(--color-chart-2)" {...SERIES} />
<Line dataKey="tx" name="上行" stroke="var(--color-chart-3)" {...SERIES} />
</ComposedChart>
</ResponsiveContainer>
</Panel>
Expand All @@ -596,7 +615,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
<AreaChart data={metricRows}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" vertical={false} />
<XAxis {...timeAxis(metricRows, hours)} />
<YAxis domain={[0, node.disk_total]} ticks={quarters(node.disk_total)} tickFormatter={axisBytes} width={Y_WIDTH} {...AXIS} />
<YAxis domain={[0, node.disk_total]} ticks={quarters(node.disk_total)} tickFormatter={axisBytes} {...VALUE_AXIS} />
<Tooltip
labelFormatter={(ts) => new Date(Number(ts)).toLocaleString("zh-CN")}
formatter={(v) => bytes(Number(v))}
Expand Down
44 changes: 21 additions & 23 deletions src/lib/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// Nothing imports it, so the bundle never includes it.
import {
axisBytes, axisTop, bytes, compact, cpuName, cycle, daysUntil, despike, distro, duration, expiresIn, money, monthUsage,
osName, pair, quarters, tickClock, timeTicks, uptime, windows,
osName, pair, quarters, RATE_FLOOR, rateAxis, tickClock, timeTicks, uptime, windows,
} from "./format.ts"

let failed = 0
Expand Down Expand Up @@ -39,30 +39,28 @@ eq(axisBytes(2 * 1024 ** 3), "2 GB", "整数刻度不写 .0")
eq(axisBytes(0), "0 B", "零刻度")

// axisTop: the top is derived from a round gridline, so quarters() lands on round
// values in the unit the axis is printed in.
eq(axisTop(0.4, 4, 10, 100), 4, "闲置机器拿到地板值")
eq(axisTop(63, 4, 10, 100), 80, "63% -> 0/20/40/60/80")
eq(axisTop(200, 4, 10, 100), 100, "百分比封顶")
eq(axisTop(25_000_000, 1024, 1024), 32 * 1024 ** 2, "字节轴按 1024 取整")
// values.
eq(axisTop(0.4, 4, 100), 4, "闲置机器拿到地板值")
eq(axisTop(63, 4, 100), 80, "63% -> 0/20/40/60/80")
eq(axisTop(200, 4, 100), 100, "百分比封顶")
eq(quarters(32 * 1024 ** 2).map(axisBytes), ["0 B", "8 MB", "16 MB", "24 MB", "32 MB"], "四条网格线都是整值")
// 四条刻度为 step·[1,2,3,4],约束落在第三条:3m 必须能被 axisBytes 精确打印,
// 而它只保留一位小数。2 的幂均满足;半档 384 与 768 不满足,其第三条刻度为
// 1.125 Ki 与 2.25 Ki,将被打印为 "1.1" 与 "2.3"。这两档恰好覆盖 1–1.5 MB/s
// 与 2–3 MB/s。断言针对标签本身,仅比较轴顶比例无法发现该问题。
eq(quarters(axisTop(2_621_440, 1024, 1024)).map(axisBytes),
["0 B", "1 MB", "2 MB", "3 MB", "4 MB"], "峰值 2.5 MB/s 的四条刻度")
eq(quarters(axisTop(1_258_291, 1024, 1024)).map(axisBytes),
["0 B", "512 KB", "1 MB", "1.5 MB", "2 MB"], "峰值 1.2 MB/s 的四条刻度")
// 轴顶必须取自梯子而非数据本身。十进制梯子配合 1024 进制的 scale 最大仅到
// 10·1024^k,越过该档后 find 落空,`?? target` 将轴顶回退为 max,网格线随之变为
// max/4 这类非整值,正是本函数要避免的情形。该回退仅在 [4,40)·1024^k 的窄带内
// 不出问题,而 40 KB/s–4 MB/s 恰是 VPS 最常见的区间。
for (const max of [3_000, 300_000, 3_000_000, 300_000_000]) {
const top = axisTop(max, 1024, 1024)
eq(top > max, true, `${max} B/s 的轴顶不能等于数据本身`)
// 上界为 2 而非 1.5:梯子移除半档后,最坏情况是下一档 2 的幂。
eq(top / max < 2, true, `${max} B/s 的轴顶不能浪费整块面板`)

// rateAxis: from the rung at or below the slowest rate to the rung at or above
// the highest peak, every label round, at most six of them.
const axis = (low: number, high: number) => {
const { domain, ticks } = rateAxis(low, high)
return [domain.map(axisBytes), ticks.map(axisBytes)]
}
eq(axis(209, 70 * 1024 ** 2), [["1 KB", "100 MB"], ["1 KB", "10 KB", "100 KB", "1 MB", "10 MB", "100 MB"]],
"空闲 209 B/s 落在标了的底上,突发 70 MB/s")
eq(axis(744, 229 * 1024 ** 2), [["1 KB", "1 GB"], ["1 KB", "100 KB", "10 MB", "1 GB"]], "七档隔一档标")
eq(axis(300, 1.5 * 1024 ** 3), [["1 KB", "10 GB"], ["10 KB", "1 MB", "100 MB", "10 GB"]], "八档从顶往下数")
eq(axis(5 * 1024 ** 2, 80 * 1024 ** 2), [["1 MB", "100 MB"], ["1 MB", "10 MB", "100 MB"]], "底随最慢的速率上移")
eq(axis(1100, 5100), [["1 KB", "10 KB"], ["1 KB", "10 KB"]], "不到一档时也有一档")
eq(axis(1024, 1024), [["1 KB", "10 KB"], ["1 KB", "10 KB"]], "正好落在档上")
eq(axis(0, 0), [["1 KB", "10 KB"], ["1 KB", "10 KB"]], "全是零")
eq(axis(Infinity, 0), [["1 KB", "10 KB"], ["1 KB", "10 KB"]], "空窗口:最小值的初值是 Infinity")
eq(RATE_FLOOR, 1024, "底是 1 KB/s")

// timeTicks: round clock values, phased on local midnight rather than the epoch,
// and never more than requested.
Expand Down
77 changes: 47 additions & 30 deletions src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,49 +295,27 @@ function monthTicks(from: number, to: number, count: number): number[] {
}

/**
* A zero-anchored axis top for a quantity with no capacity to measure against: a
* utilisation percentage, a load average, a transfer rate.
* A zero-anchored axis top for a utilisation percentage, which has no capacity
* to measure against.
*
* Derived from the gridline rather than the reverse -- the smallest round step
* whose fourth multiple clears the data. Chosen the other way, the top is round
* while the four gridlines beneath it are not: a ceiling of 75% draws lines at
* 18.75 and 56.25. `base` is 1024 for a quantity written in binary units, so the
* steps are round in the unit it is printed in.
* 18.75 and 56.25.
*
* `floor` keeps an idle machine looking idle: tracked exactly, a host that never
* exceeds 0.4% CPU would get an axis of 0-0.4 and render every scheduler blip as
* a peak.
*/
export function axisTop(max: number, floor: number, base = 10, cap = Infinity): number {
export function axisTop(max: number, floor: number, cap = Infinity): number {
const target = Math.min(cap, Math.max(max, floor)) / 4
const scale = base ** Math.floor(Math.log(target) / Math.log(base))
const step = LADDER[base].map((m) => m * scale).find((n) => n >= target)
const scale = 10 ** Math.floor(Math.log10(target))
const step = LADDER.map((m) => m * scale).find((n) => n >= target)
return Math.min(cap, (step ?? target) * 4)
}

/**
* Round multipliers, per base. A decade of base 1024 spans 1024, so a decimal
* ladder cannot reach across one: with `scale` at 1024^k the largest step it
* offers is 10 · 1024^k, leaving any target above that unmatched and falling
* through to `top = max` -- no round step and no round gridlines, which is what
* this function exists to provide. That held only for a peak within
* [4, 40) · 1024^k, so a byte axis was more often wrong than right: 40 KB/s to
* 4 MB/s, the ordinary range for a VPS, drew 2.9 MB with quarters at 732.4 KB.
*
* Powers of two for base 1024, with no half-step between them. The four ticks are
* `step · [1, 2, 3, 4]`, so the third constrains the ladder: `3m` must land on a
* label `axisBytes` can print, and it prints one decimal. Every power of two does
* (3 · 512 Ki = 1.5 Mi); the half-steps 384 and 768 do not, their third gridline
* being 1.125 Ki and 2.25 Ki, printed as "1.1" and "2.3" -- one incorrect label
* beneath a top and two gridlines that are correct. Those two rungs would be
* selected for peaks in (1, 1.5] and (2, 3] · 1024^k, that is 1-1.5 MB/s and
* 2-3 MB/s, within the range named above. The cost of dropping them is a top that
* may overshoot the data by 2x rather than 1.5x.
*/
const LADDER: Record<number, number[]> = {
10: [1, 1.5, 2, 2.5, 3, 4, 5, 7.5, 10],
1024: [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024],
}
/** Round multipliers within a decade. */
const LADDER = [1, 1.5, 2, 2.5, 3, 4, 5, 7.5, 10]

/**
* The gridlines for a zero-anchored axis: the top and the three quarters beneath
Expand All @@ -351,6 +329,45 @@ export function quarters(top: number): number[] {
return [0, 0.25, 0.5, 0.75, 1].map((f) => top * f)
}

/**
* The rungs of a logarithmic byte axis: 1, 10 and 100 of each binary unit, so
* every gridline prints as a round label -- 100 B, 1 KB, 10 KB, 100 KB, 1 MB.
* Adjacent rungs are 10 apart, or 10.24 across a unit, which draws as even.
*/
const rung = (i: number) => 1024 ** Math.floor(i / 3) * 10 ** (i % 3)

/** The rung of `RATE_FLOOR`. */
const FLOOR = 3

/**
* The lowest rate drawn, 1 KB/s, since a log axis has no zero. An agent's own
* reports run below it, 0.2 to 0.7 KB/s each way across nine nodes over a week,
* so a node doing nothing else lies along the floor rather than drawing that
* traffic, magnified, as activity.
*/
export const RATE_FLOOR = rung(FLOOR)

/**
* A logarithmic axis for transfer rates, from the rung at or below `low` to the
* rung at or above `high`, and no lower than `RATE_FLOOR`.
*
* At most six labels, counted down from the top, which is the one that says how
* far the axis reaches. Six label every rung from the floor to 100 MB/s, the
* bursts of a gigabit port, so an idle line lies on a labelled gridline; on a
* panel 120 px tall they sit 24 px apart. Wider spans take every other rung:
* 1 KB/s to 1 GB/s is seven, and a label on each would sit 20 px from the next.
*/
export function rateAxis(low: number, high: number): { domain: [number, number]; ticks: number[] } {
let bottom = FLOOR
while (rung(bottom + 1) <= Math.min(low, high)) bottom++
let top = bottom + 1
while (rung(top) < high) top++
const step = Math.ceil((top - bottom) / 5)
const ticks: number[] = []
for (let i = top; i >= bottom; i -= step) ticks.unshift(rung(i))
return { domain: [rung(bottom), rung(top)], ticks }
}

/**
* Hampel filter (Hampel 1974; MATLAB ships it as `hampel`). A sample more than
* `sigmas` robust deviations from the median of its window is replaced by that
Expand Down
Loading