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
100 changes: 64 additions & 36 deletions src/components/NodeDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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])

Expand Down Expand Up @@ -466,6 +482,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
far from it, and zero flattens every wobble. */}
<YAxis unit="ms" width={52} domain={["auto", "auto"]} {...AXIS} />
<Tooltip
{...TOOLTIP}
labelFormatter={(ts) => 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.
Expand Down Expand Up @@ -583,8 +600,9 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
<AreaChart data={metricRows}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" vertical={false} />
<XAxis {...timeAxis(metricRows)} />
<YAxis domain={[0, tops.cpu]} ticks={quarters(tops.cpu)} unit="%" width={Y_WIDTH} {...AXIS} />
<YAxis {...axes.cpu} unit="%" {...VALUE_AXIS} />
<Tooltip
{...TOOLTIP}
labelFormatter={(ts) => new Date(Number(ts)).toLocaleString("zh-CN")}
formatter={(v) => [`${Number(v).toFixed(1)}%`, "CPU"]}
contentStyle={{ fontSize: 12 }}
Expand All @@ -604,8 +622,9 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
<AreaChart data={metricRows}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" vertical={false} />
<XAxis {...timeAxis(metricRows)} />
<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
{...TOOLTIP}
labelFormatter={(ts) => new Date(Number(ts)).toLocaleString("zh-CN")}
formatter={(v) => bytes(Number(v))}
contentStyle={{ fontSize: 12 }}
Expand All @@ -615,8 +634,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 @@ -632,12 +655,16 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
<ComposedChart data={metricRows}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" vertical={false} />
<XAxis {...timeAxis(metricRows)} />
<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
{...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={{ fontSize: 12 }}
/>
Expand All @@ -658,8 +685,8 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
legendType="none"
/>
))}
<Line dataKey="net_rx" name="下行" stroke="var(--color-ok)" {...SERIES} />
<Line dataKey="net_tx" name="上行" stroke="var(--color-chart-1)" {...SERIES} />
<Line dataKey="rx" name="下行" stroke="var(--color-ok)" {...SERIES} />
<Line dataKey="tx" name="上行" stroke="var(--color-chart-1)" {...SERIES} />
</ComposedChart>
</ResponsiveContainer>
</Panel>
Expand All @@ -672,8 +699,9 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
<AreaChart data={metricRows}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" vertical={false} />
<XAxis {...timeAxis(metricRows)} />
<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
{...TOOLTIP}
labelFormatter={(ts) => new Date(Number(ts)).toLocaleString("zh-CN")}
formatter={(v) => bytes(Number(v))}
contentStyle={{ fontSize: 12 }}
Expand Down
46 changes: 22 additions & 24 deletions src/lib/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
Loading
Loading