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
70 changes: 61 additions & 9 deletions src/components/NodeDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<number[]>([])
// 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.
Expand Down Expand Up @@ -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<string, number | [number, number] | null>
>()
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)
})
}
Expand Down Expand Up @@ -285,6 +325,14 @@ export function Latency({ id, className }: { id: number; className?: string }) {
</button>
)
})}
<button
onClick={() => setSmooth((on) => !on)}
aria-pressed={smooth}
title="把孤立的异常值换成邻近若干桶的中位数,持续的变化保持原样"
className={`rounded-md border px-2 py-1 text-xs transition-opacity ${smooth ? "" : "opacity-40"}`}
>
削峰
</button>
</div>

<div className={cn("w-full text-muted-foreground", className)}>
Expand All @@ -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}
/>
Expand All @@ -321,7 +373,7 @@ export function Latency({ id, className }: { id: number; className?: string }) {
shownProbes.map((s) => (
<Area
key={`band${s.id}`}
dataKey={`b${s.id}`}
dataKey={`${smooth ? "c" : "b"}${s.id}`}
stroke="none"
fill={color(s.id)}
fillOpacity={0.16}
Expand All @@ -334,7 +386,7 @@ export function Latency({ id, className }: { id: number; className?: string }) {
{shownProbes.map((s) => (
<Line
key={s.id}
dataKey={`t${s.id}`}
dataKey={`${smooth ? "s" : "t"}${s.id}`}
name={s.name}
stroke={color(s.id)}
{...SERIES}
Expand Down
66 changes: 61 additions & 5 deletions src/components/ServerTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,37 @@ function Bar({ pct, label }: { pct: number | null; label?: string }) {
)
}

// The widest form each formatter writes, in `ch`, measured with tabular figures.
// M is the widest unit letter, so each is measured in megabytes rather than the
// gigabytes a reading is more often in: `compact` spans "0B" to "1023M", that is
// 2.2 to 5.49; `bytes` reaches 7.19 at "1023 MB"; `rate` 10.09 at "1023.0 MB/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.3, rate: 10.2 }

/**
* 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. The width travels as a custom property rather than `min-width` itself,
* which is what allows a caller to drop the reservation where the column is too
* narrow to hold it: an inline style would outrank the class that does so.
*/
function Num({ ch, className, children }: { ch: number; className?: string; children: ReactNode }) {
return (
<span
className={cn("tnum inline-block min-w-(--slot) text-right", className)}
style={{ "--slot": `${ch}ch` } as CSSProperties}
>
{children}
</span>
)
}

function Expiry({ node }: { node: Node }) {
const days = daysUntil(node.expires_at)
if (days === null) return <span className="text-muted-foreground" title="永不到期">{FOREVER}</span>
Expand Down Expand Up @@ -181,7 +212,15 @@ function Details({ node }: { node: Node }) {

<Line label="负载">{m ? m.load.map((n) => n.toFixed(2)).join(" / ") : "—"}</Line>
<Line label="进程 / 连接">{m ? `${m.procs} · TCP ${m.tcp} · UDP ${m.udp}` : "—"}</Line>
<Line label="网速">{m ? `↓ ${rate(m.net_rx)} · ↑ ${rate(m.net_tx)}` : "—"}</Line>
<Line label="网速">
{m ? (
<>
↓ <Num ch={SLOT.rate}>{rate(m.net_rx)}</Num> · ↑ <Num ch={SLOT.rate}>{rate(m.net_tx)}</Num>
</>
) : (
"—"
)}
</Line>

<Line label="今日流量">{flow(node.day_rx, node.day_tx)}</Line>
<Line label="本月流量">{flow(node.month_rx, node.month_tx)}</Line>
Expand Down Expand Up @@ -249,7 +288,19 @@ function Row({ node, index }: { node: Node; index: number }) {
<TableCell className={COL.uptime}>{m ? duration(m.uptime) : "—"}</TableCell>
<TableCell className={COL.expiry}><Expiry node={node} /></TableCell>
<TableCell className={COL.load}>{m ? m.load[0].toFixed(2) : "—"}</TableCell>
<TableCell className={COL.speed}>{m ? `${compact(m.net_rx)} | ${compact(m.net_tx)}` : "— | —"}</TableCell>
{/* 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. */}
<TableCell className={COL.speed}>
{m ? (
<>
<Num ch={SLOT.compact} className="@max-3xl:min-w-0">{compact(m.net_rx)}</Num> |{" "}
<Num ch={SLOT.compact} className="@max-3xl:min-w-0">{compact(m.net_tx)}</Num>
</>
) : (
"— | —"
)}
</TableCell>
<TableCell className={COL.bar}><Bar pct={m ? m.cpu : null} /></TableCell>
<TableCell className={COL.bar}><Bar pct={m ? percent(m.mem_used, m.mem_total) : null} /></TableCell>
<TableCell className={COL.bar}><Bar pct={m ? percent(m.disk_used, m.disk_total) : null} /></TableCell>
Expand Down Expand Up @@ -291,10 +342,15 @@ export function ServerTable({ nodes }: { nodes: Node[] }) {
<h2 className="text-lg font-semibold max-md:text-sm">服务器</h2>
<div className="tnum flex flex-wrap justify-center gap-x-3 gap-y-1 text-xs text-muted-foreground max-md:text-[10px]">
<span className="whitespace-nowrap">
在线 {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. */}
在线 <Num ch={String(nodes.length).length}>{nodes.filter((n) => n.online).length}</Num> / {nodes.length} · ↓{" "}
<Num ch={SLOT.compact}>{compact(sum((n) => n.metrics!.net_rx))}</Num>/s · ↑{" "}
<Num ch={SLOT.compact}>{compact(sum((n) => n.metrics!.net_tx))}</Num>/s
</span>
<span className="whitespace-nowrap" title="所有节点累计下载与上传流量">
总流量 ↓ <Num ch={SLOT.bytes}>{bytes(totalRx)}</Num> · ↑ <Num ch={SLOT.bytes}>{bytes(totalTx)}</Num>
</span>
<span className="whitespace-nowrap" title="所有节点累计下载与上传流量">总流量 ↓ {bytes(totalRx)} · ↑ {bytes(totalTx)}</span>
</div>
</div>
<Table className="text-center text-sm @max-3xl:table-fixed @max-3xl:text-[10px]">
Expand Down
24 changes: 22 additions & 2 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, 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
Expand Down Expand Up @@ -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 名去掉商标和核数")

Expand Down
47 changes: 47 additions & 0 deletions src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,50 @@ const LADDER: Record<number, number[]> = {
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
}
Loading