diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index a69791b..fe602bc 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -8,7 +8,7 @@ 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, quarters, rate, timeTicks, uptime, + axisBytes, axisTop, bytes, clockFor, despike, quarters, rate, timeTicks, uptime, } from "@/lib/format" import { cn } from "@/lib/utils" @@ -157,6 +157,23 @@ function Failed({ message, retry }: { message: string; retry: () => void }) { ) } +/** + * How many samples of a probe's own series make up seven minutes of neighbours. + * + * The window the filter judges against has to be a duration, not a count: the + * hub buckets a window to the `points` asked for above, so the same day arrives + * as one-minute buckets on a desktop and two-minute ones on a phone, and a fixed + * count would clip a five-minute stall on the first while keeping it on the + * second. The smallest gap is the bucket interval; a longer one is the node + * being offline. Odd, so the window has a middle, and bounded so a sparse probe + * still has neighbours and a dense one does not pay for a wide sort. + */ +function despikeWindow(points: { ts: number }[]): number { + let step = Infinity + for (let i = 1; i < points.length; i++) step = Math.min(step, points[i].ts - points[i - 1].ts) + return Math.min(15, Math.max(3, Math.round(420 / step) | 1)) +} + // A real time axis rather than the category axis recharts defaults to: on a // category axis ticks are selected by index, so a period the agent was offline for // collapses to nothing. @@ -184,6 +201,10 @@ export function Latency({ id, className }: { id: number; className?: string }) { // Probes switched off. Hiding a slow one is what makes the fast ones readable, // as the axis rescales to what remains. const [hiddenProbes, setHiddenProbes] = useState([]) + // Spikes pulled back to their neighbourhood. Off by default, since it is the + // raw round trips that answer whether the node stalled at 03:00; on, a single + // 2 s bucket stops setting the axis for the day around it. + const [smooth, setSmooth] = useState(false) // Where the brush has been dragged, so the axis reticks for the visible span. // Tagged with the window it was dragged on, so a new window starts unzoomed // without an effect to clear it. @@ -224,24 +245,43 @@ export function Latency({ id, className }: { id: number; className?: string }) { // slower probe leaves gaps in its own column, which is what `connectNulls` // addresses. // - // Every probe is held here whether or not it is on screen: recharts resets the - // brush when the data array changes identity, and re-reads a controlled - // selection only when the index props change, which they do not. Hiding a probe - // therefore selects a `dataKey` rather than rebuilding the array. + // Every probe, and both versions of every sample, are held here whether or not + // they are on screen: recharts resets the brush when the data array changes + // identity, and re-reads a controlled selection only when the index props + // change, which they do not. Hiding a probe or clipping the spikes therefore + // selects a `dataKey` rather than rebuilding the array. const pingRows = useMemo(() => { const rows = new Map< number, { ts: number } & Record >() for (const s of pingSeries) { - s.points.forEach((p) => { + const windowSize = despikeWindow(s.points) + const line = despike(s.points.map((p) => p.latency), windowSize) + // The band spans the same outliers as the line, and with one probe on + // screen it is what the axis is fitted to, so it is clipped alongside it + // rather than left to pull the axis back open. + const lo = despike(s.points.map((p) => p.band?.[0] ?? p.latency), windowSize) + const hi = despike(s.points.map((p) => p.band?.[1] ?? p.latency), windowSize) + s.points.forEach((p, i) => { const row = rows.get(p.ts) ?? { ts: p.ts * 1_000 } row[`t${s.id}`] = p.latency + row[`s${s.id}`] = line[i] row[`l${s.id}`] = p.loss ?? 0 // A bucket with a single answer carries no band and spans only that answer. Left null, `connectNulls` // would bridge the hours between the few buckets that have one: 9 of // 1,438 in a day, the widest gap 268 minutes, drawn as one large wedge. row[`b${s.id}`] = p.band ?? (p.latency === null ? null : [p.latency, p.latency]) + // Taken as the span of three filtered series rather than a pair: the two + // edges are filtered independently, so a bucket that answered slightly + // faster than usual can trip the low edge alone and come back above the + // high one -- [180, 178] against a line of 176, drawn backwards with the + // line outside it. + const [low, high] = [lo[i], hi[i]] + row[`c${s.id}`] = + low === null || high === null + ? null + : [Math.min(low, high, line[i] ?? low), Math.max(low, high, line[i] ?? high)] rows.set(p.ts, row) }) } @@ -285,6 +325,14 @@ export function Latency({ id, className }: { id: number; className?: string }) { ) })} +
@@ -303,9 +351,13 @@ export function Latency({ id, className }: { id: number; className?: string }) { // The line is drawn from what answered, so without this a bucket // that lost most of its packets reads as normal. `dataKey` is // `t7`; the loss sits at `l7`. + // + // Rounded because a clipped sample carries the median of an even + // window, which falls between two of the whole milliseconds the + // hub stores. formatter={(v, name, item) => { const loss = Number(item?.payload?.[`l${String(item.dataKey).slice(1)}`] ?? 0) - return [`${Number(v)} ms${loss > 0 ? ` · 丢 ${loss}%` : ""}`, name] + return [`${Math.round(Number(v))} ms${loss > 0 ? ` · 丢 ${loss}%` : ""}`, name] }} contentStyle={TIP} /> @@ -321,7 +373,7 @@ export function Latency({ id, className }: { id: number; className?: string }) { shownProbes.map((s) => ( ( + {children} + + ) +} + function Expiry({ node }: { node: Node }) { const days = daysUntil(node.expires_at) if (days === null) return {FOREVER} @@ -181,7 +212,15 @@ function Details({ node }: { node: Node }) { {m ? m.load.map((n) => n.toFixed(2)).join(" / ") : "—"} {m ? `${m.procs} · TCP ${m.tcp} · UDP ${m.udp}` : "—"} - {m ? `↓ ${rate(m.net_rx)} · ↑ ${rate(m.net_tx)}` : "—"} + + {m ? ( + <> + ↓ {rate(m.net_rx)} · ↑ {rate(m.net_tx)} + + ) : ( + "—" + )} + {flow(node.day_rx, node.day_tx)} {flow(node.month_rx, node.month_tx)} @@ -249,7 +288,19 @@ function Row({ node, index }: { node: Node; index: number }) { {m ? duration(m.uptime) : "—"} {m ? m.load[0].toFixed(2) : "—"} - {m ? `${compact(m.net_rx)} | ${compact(m.net_tx)}` : "— | —"} + {/* No reservation on a phone: the column is 21% of the panel, 60px at + 320px, against the 70px two slots and their separator need, and the + overflow disappears under the bar beside it. */} + + {m ? ( + <> + {compact(m.net_rx)} |{" "} + {compact(m.net_tx)} + + ) : ( + "— | —" + )} + @@ -291,10 +342,15 @@ export function ServerTable({ nodes }: { nodes: Node[] }) {

服务器

- 在线 {nodes.filter((n) => n.online).length} / {nodes.length} · ↓ {compact(sum((n) => n.metrics!.net_rx))}/s · ↑{" "} - {compact(sum((n) => n.metrics!.net_tx))}/s + {/* The online count is reserved for as many digits as the total has, + since it cannot exceed it. */} + 在线 {nodes.filter((n) => n.online).length} / {nodes.length} · ↓{" "} + {compact(sum((n) => n.metrics!.net_rx))}/s · ↑{" "} + {compact(sum((n) => n.metrics!.net_tx))}/s + + + 总流量 ↓ {bytes(totalRx)} · ↑ {bytes(totalTx)} - 总流量 ↓ {bytes(totalRx)} · ↑ {bytes(totalTx)}
diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts index e0858f5..c7599b8 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, compact, cpuName, daysUntil, distro, duration, monthUsage, osName, pair, quarters, - timeTicks, uptime, + axisBytes, axisTop, bytes, compact, cpuName, daysUntil, despike, distro, duration, monthUsage, osName, pair, + quarters, timeTicks, uptime, } from "./format.ts" let failed = 0 @@ -127,6 +127,26 @@ eq(duration(76 * 86400 + 5), "76 天", "超过一天只写天数") eq(monthUsage({ ...node, traffic_mode: "max" }), 5, "取较大值") } +// despike:孤立的尖峰被拉回邻域,持续的高延迟保留,超时仍是缺口。 +{ + const flat = [20, 21, 20, 22, 21, 20, 21] + eq(despike(flat), flat, "没有离群点就原样返回") + eq(despike([20, 21, 20, 900, 21, 20, 21])[3], 21, "孤立尖峰替换为窗口中位数") + // 一段持续的高延迟是真实状况而非尖峰:窗口内多数样本同样高,中位数随之抬高。 + // 只断言尖峰那一点会漏掉这条——滑动中位数同样能通过前一条断言。 + eq(despike([20, 21, 300, 310, 305, 300, 21, 20]).slice(2, 6), [300, 310, 305, 300], "持续升高不被削掉") + eq(despike([20, null, 900, null, 21]), [20, null, 21, null, 21], "超时保持为缺口,不参与比较") + // 延迟以整毫秒存储,稳定线路的窗口内多数样本完全相同,绝对中位差为 0。不设下限 + // 时这条断言会失败,而它正是削峰要处理的形状:平直的线加一个 2 秒的桶。 + eq(despike([180, 180, 181, 180, 2000, 180, 180, 181, 180])[4], 180, "平直线路上的尖峰同样被削掉") + // 下限不能低到把正常抖动也当成尖峰:整毫秒的数据里 1 ms 的起伏是常态。 + eq(despike([180, 181, 180, 180, 181, 180, 180]), [180, 181, 180, 180, 181, 180, 180], "1 ms 抖动原样保留") + // 两侧都削:孤立的「异常快」同样会把自适应的轴拉开,和尖峰是同一个问题的镜像。 + eq(despike([200, 200, 20, 200, 200, 200, 200])[2], 200, "异常快的桶同样被拉回邻域") + // 窗口按样本数计,桶的时长由调用方按桶间隔折算。 + eq(despike([20, 900, 20, 20, 20], 3)[1], 20, "窗口可以调小") +} + eq(osName("Debian GNU/Linux 12 (bookworm)"), "Debian 12", "发行版名去掉代号") eq(cpuName("Intel(R) Xeon(R) CPU E5-2680 8-Core Processor"), "Intel Xeon E5-2680", "CPU 名去掉商标和核数") diff --git a/src/lib/format.ts b/src/lib/format.ts index 24fbe76..ac8acb9 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -259,3 +259,50 @@ const LADDER: Record = { export function quarters(top: number): number[] { return [0, 0.25, 0.5, 0.75, 1].map((f) => top * f) } + +/** + * 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 + * median, while everything else passes through unchanged, which is what + * separates it from a rolling median or a moving average: a period that is + * genuinely slow keeps its height, since its own neighbours are slow too. + * + * Both tails are rejected, not only the slow one. A single bucket that answered + * far faster than its neighbours stretches an auto-fitted axis exactly as a slow + * one does -- measured on a day of two probes, one such bucket took the axis from + * 150-480 ms out to 0-600 ms -- and this filter is what a reader turns on to see + * the trend rather than the extremes. The unfiltered view is one click away. + * + * 1.4826 rescales the median absolute deviation to a standard deviation for + * normally distributed data; 3 sigma is the conventional cut. + * + * The deviation is floored at one millisecond, the resolution round trips are + * stored in. Unfloored, a window whose samples mostly repeat one value -- which + * is what a steady route looks like at that resolution -- has a deviation of + * exactly 0, and the test then either rejects nothing or rejects every sample + * that is not the median, depending on which way the comparison is written. Both + * fail on the very chart this exists for: a flat line with one 2 s bucket in it. + * The floor puts the cut at 4.5 ms for a line that does not move. + * + * A null is a timeout rather than a high reading, so it is neither replaced nor + * counted towards what its neighbours are compared against. + */ +export function despike(values: (number | null)[], window = 7, sigmas = 3): (number | null)[] { + const half = window >> 1 + // ponytail: the window is re-collected per sample. A day of one probe is 1,438 + // of them; a rolling structure would only pay off on a far longer window. + return values.map((v, i) => { + if (v === null) return v + const near = values.slice(Math.max(0, i - half), i + half + 1).filter((n) => n !== null) + const mid = median(near) + const mad = Math.max(median(near.map((n) => Math.abs(n - mid))), 1) + return Math.abs(v - mid) > sigmas * 1.4826 * mad ? mid : v + }) +} + +/** The middle of a sorted copy, the mean of the middle pair for an even count. */ +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b) + const half = sorted.length >> 1 + return sorted.length % 2 ? sorted[half] : (sorted[half - 1] + sorted[half]) / 2 +}