diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx
index 35c88ad..d09ff1d 100644
--- a/src/components/NodeDetail.tsx
+++ b/src/components/NodeDetail.tsx
@@ -9,8 +9,8 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Country, Status } from "@/components/NodeCard"
import { api, type Node } from "@/lib/api"
import {
- axisBytes, axisTop, bytes, clockFor, cycle, despike, quarters, cpuName, FOREVER, money, osName, rate,
- tickClock, timeTicks, windows,
+ axisBytes, axisTop, bytes, clockFor, cycle, despike, quarters, cpuName, FOREVER, money, osName, rate, RATE_FLOOR,
+ rateAxis, tickClock, timeTicks, windows,
} from "@/lib/format"
type Point = {
@@ -54,10 +54,20 @@ const AXIS = { stroke: "currentColor", fontSize: 11, tickLine: false, axisLine:
// chart across seven hundred points per probe.
const SERIES = { dot: false as const, strokeWidth: 1.5, isAnimationActive: false }
+// Nor does the tooltip slide. Entering a chart at its right edge, it appears
+// beside the cursor first and would glide 400 ms back within the chart,
+// overhanging the page by up to 140 px and flashing a horizontal scrollbar.
+const TOOLTIP = { 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 5 px 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 }
// The palette is greyscale, so lightness alone is exhausted after two or three
// series and the dash pattern carries the rest.
@@ -155,9 +165,8 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
const [data, setData] = useState<{ metrics: Point[]; ping: PingPoint[]; probes: Probes; loss?: Loss } | null>(null)
// Retained rather than folded into an empty result: a refused request and an
// empty window are different answers, and the hub has reason to refuse this one
- // -- it caps how many history windows it builds concurrently, since each holds
- // the connection the agents report through. Rendered as an empty window, a 503
- // would misdirect the reader.
+ // -- it caps how many history windows it builds concurrently. Rendered as an
+ // empty window, a 503 would misdirect the reader.
const [failed, setFailed] = useState("")
// Where the brush has been dragged, so the axis reticks for the visible span
// rather than retaining the whole window's ticks.
@@ -222,17 +231,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(() => {
@@ -242,20 +254,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])
@@ -466,6 +482,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
far from it, and zero flattens every wobble. */}
new Date(Number(ts)).toLocaleString("zh-CN")}
// The line is drawn from what answered, so without this a
// bucket that lost most of its packets reads as normal.
@@ -583,8 +600,9 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
-
+
new Date(Number(ts)).toLocaleString("zh-CN")}
formatter={(v) => [`${Number(v).toFixed(1)}%`, "CPU"]}
contentStyle={{ fontSize: 12 }}
@@ -604,8 +622,9 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
-
+
new Date(Number(ts)).toLocaleString("zh-CN")}
formatter={(v) => bytes(Number(v))}
contentStyle={{ fontSize: 12 }}
@@ -615,8 +634,12 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
- {/* 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
@@ -632,12 +655,16 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
-
+
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={{ fontSize: 12 }}
/>
@@ -658,8 +685,8 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
legendType="none"
/>
))}
-
-
+
+
@@ -672,8 +699,9 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
-
+
new Date(Number(ts)).toLocaleString("zh-CN")}
formatter={(v) => bytes(Number(v))}
contentStyle={{ fontSize: 12 }}
diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts
index 6f2f333..ea9f934 100644
--- a/src/lib/format.test.ts
+++ b/src/lib/format.test.ts
@@ -4,8 +4,8 @@
//
// Nothing imports it, so the bundle never includes it.
import {
- axisBytes, axisTop, bytes, cpuName, cycle, daysUntil, despike, osName, pair, quarters, tickClock, timeTicks,
- uptime, windows,
+ axisBytes, axisTop, bytes, cpuName, cycle, daysUntil, despike, osName, pair, quarters, RATE_FLOOR, rateAxis,
+ tickClock, timeTicks, uptime, windows,
} from "./format.ts"
let failed = 0
@@ -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.
diff --git a/src/lib/format.ts b/src/lib/format.ts
index 5326bde..f453b2a 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -221,49 +221,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 = {
- 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
@@ -277,6 +255,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