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 }) {