From 2e124398bbbd498a7d1e2c25bb5ca05c2e8d14d4 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:45:27 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=E5=AE=9E=E6=97=B6=E9=80=9F=E7=8E=87?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=9B=A0=E4=BD=8D=E6=95=B0=E5=8F=98=E5=8C=96?= =?UTF-8?q?=E8=80=8C=E5=B7=A6=E5=8F=B3=E8=B7=B3=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 网速由 compact / bytes / rate 格式化,宽度随量级变化(compact 最窄 "0B" 2.2ch、 最宽 "1023M" 5.49ch),每次推送整行都会重新排版:摘要行的左边界实测在 526 与 573 之间来回,表格网速列在 845 与 854 之间。 改为把每个实时数字放进按最宽形态预留的槽位,右对齐,单位因此不动而数字向箭头 方向生长。槽宽用 ch,即等宽数字下一个数字的宽度,跟随字号,手机上 10px 同样成立。 四种量级的速度下,摘要行、总流量、网速列三处的坐标完全不变。 --- src/components/ServerTable.tsx | 55 ++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/src/components/ServerTable.tsx b/src/components/ServerTable.tsx index ffbc3bc..0893665 100644 --- a/src/components/ServerTable.tsx +++ b/src/components/ServerTable.tsx @@ -105,6 +105,30 @@ function Bar({ pct, label }: { pct: number | null; label?: string }) { ) } +// The widest form each formatter writes, in `ch`: `compact` spans "0B" to +// "1023M", that is 2.2 to 5.49; `bytes` reaches 7.09 at "1023 GB"; `rate` 9.79 at +// "1023.0 KB/s". Rounded up, since other UI fonts are a few percent wider than +// the one these were measured in. +const SLOT = { compact: 5.6, bytes: 7.2, rate: 9.9 } + +/** + * A figure that changes on every push, held in a slot wide enough for the widest + * form it can take, so a node moving from 19K/s to 8.19K/s leaves the rest of the + * line where it was. Right-aligned, so the unit keeps its place and the digits + * grow towards the arrow instead. + * + * `ch` is the width of a digit under the tabular figures this selects, so a slot + * follows whatever size it is drawn at, down to the 10px the table uses on a + * phone. + */ +function Num({ ch, children }: { ch: number; children: ReactNode }) { + return ( + + {children} + + ) +} + function Expiry({ node }: { node: Node }) { const days = daysUntil(node.expires_at) if (days === null) return {FOREVER} @@ -181,7 +205,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 +281,15 @@ 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)}` : "— | —"} + + {m ? ( + <> + {compact(m.net_rx)} | {compact(m.net_tx)} + + ) : ( + "— | —" + )} + @@ -291,10 +331,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)}
From 4a5b8522063f6cb05a1e3156c94f4818c162ff93 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:45:27 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E5=BB=B6=E8=BF=9F=E5=9B=BE?= =?UTF-8?q?=E8=A1=A8=E5=8A=A0=E5=89=8A=E5=B3=B0=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一个 2 秒的桶会把 24 小时的轴撑到 2200ms,其余部分压成一条平线。加 Hampel 滤波(format.ts 的 despike):偏离窗口中位数超过 3 倍稳健差的样本换成该中位数, 其余原样通过,所以持续一段时间的高延迟保持原高度,只有孤立的尖峰被拉回。 绝对中位差下限取 1 ms,即延迟存储的分辨率。不设下限时,稳定线路的窗口内多数 样本是同一个整数,中位差正好为 0,判据要么什么都不削、要么把非中位数的样本全削 掉——两种都恰好在削峰唯一要处理的形状上失效。 单探测时画出的 band 与线共享同一批离群点,且轴是按它拟合的,所以一并削:实测 24 小时窗,削峰前轴顶 2400ms,只削线不削 band 仍是 2400ms,两者都削后为 480ms。 --- src/components/NodeDetail.tsx | 38 +++++++++++++++++++++++++------- src/lib/format.test.ts | 20 +++++++++++++++-- src/lib/format.ts | 41 +++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index a69791b..fb35ec2 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" @@ -184,6 +184,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 +228,34 @@ 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 line = despike(s.points.map((p) => p.latency)) + // 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)) + const hi = despike(s.points.map((p) => p.band?.[1] ?? p.latency)) + 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]) + const [low, high] = [lo[i], hi[i]] + row[`c${s.id}`] = low === null || high === null ? null : [low, high] rows.set(p.ts, row) }) } @@ -285,6 +299,14 @@ export function Latency({ id, className }: { id: number; className?: string }) { ) })} +
@@ -321,7 +343,7 @@ export function Latency({ id, className }: { id: number; className?: string }) { shownProbes.map((s) => ( ( = { 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. + * + * 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 +} From 4f85222a2ff31ad0cc42a056e733257d93f73d23 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:17:31 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=E5=89=8A=E5=B3=B0=E5=90=8E=E7=9A=84?= =?UTF-8?q?=20tooltip=20=E5=8F=96=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 被替换的样本带的是偶数窗口的中位数,落在两个整毫秒之间,tooltip 会显示 180.5 ms 这种值。 --- src/components/NodeDetail.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index fb35ec2..713dba1 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -325,9 +325,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} /> From b289bd68ced867f626e4658163165e57ceb36f7e Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:32:12 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=E5=89=8A=E5=B3=B0=E7=9A=84=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E6=8C=89=E6=97=B6=E9=95=BF=E6=8A=98=E7=AE=97=EF=BC=8C?= =?UTF-8?q?=E7=BD=91=E9=80=9F=E5=88=97=E5=9C=A8=E6=89=8B=E6=9C=BA=E4=B8=8A?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=A2=84=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处 review 发现: 窗口此前固定 7 个样本,而桶的间隔由视口决定(请求的 points 是 innerWidth × devicePixelRatio),同一个 24 小时窗在桌面是一分钟一桶、在手机是两分钟一桶,于是 同一次五分钟的卡顿在桌面被当成尖峰削掉、在手机被当成持续保留。改为按探测自己的最小 桶间隔折算成七分钟,取奇数并夹在 3 到 15 之间。 削峰对两侧都生效,而开关的说明只写了「高延迟」。孤立的异常快同样会把自适应的轴拉开 ——实测一个这样的桶把轴从 150-480 ms 拉到 0-600 ms——所以改的是说明而不是算法。 网速列在手机版是面板宽度的 21%,320px 下只有 60px,而两个槽位加分隔符要 70px,超出 的部分被旁边的进度条盖住,常见值也会被截掉半截。该列在 @max-3xl 下不再预留;槽宽因此 改由自定义属性传递,行内样式盖不住类。 --- src/components/NodeDetail.tsx | 26 ++++++++++++++++++++++---- src/components/ServerTable.tsx | 17 +++++++++++++---- src/lib/format.test.ts | 4 ++++ src/lib/format.ts | 6 ++++++ 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index 713dba1..80bdcd0 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -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. @@ -239,12 +256,13 @@ export function Latency({ id, className }: { id: number; className?: string }) { { ts: number } & Record >() for (const s of pingSeries) { - const line = despike(s.points.map((p) => p.latency)) + const window = despikeWindow(s.points) + const line = despike(s.points.map((p) => p.latency), window) // 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)) - const hi = despike(s.points.map((p) => p.band?.[1] ?? p.latency)) + const lo = despike(s.points.map((p) => p.band?.[0] ?? p.latency), window) + const hi = despike(s.points.map((p) => p.band?.[1] ?? p.latency), window) s.points.forEach((p, i) => { const row = rows.get(p.ts) ?? { ts: p.ts * 1_000 } row[`t${s.id}`] = p.latency @@ -302,7 +320,7 @@ export function Latency({ id, className }: { id: number; className?: string }) {