diff --git a/README.md b/README.md
index aa98d31..25b49b9 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ MONITOR_HUB=https://hub.example.com npm run dev
构建产物位于 `dist/`。提交前运行 `npm run build && npm run lint && npm test`。
-`npm test` 校验数字格式化和实时指标的输入边界。没有测试框架,Node 自己剥掉
+`npm test` 校验数字格式化、坐标轴与时间刻度,以及实时指标的输入边界。没有测试框架,Node 自己剥掉
类型,失败时退出码非零。
## 主题包
@@ -56,7 +56,7 @@ MONITOR_HUB=https://hub.example.com npm run dev
| 接口 | 用途 |
|---|---|
-| `GET /api/me` | 站点名、登录状态、公开页开关 |
+| `GET /api/me` | 站点名、登录状态、公开页开关、历史保留天数 |
| `GET /api/nodes` | 节点列表、实时指标和累计流量 |
| `GET /api/nodes/{id}/metrics` | 历史指标和延迟记录 |
| `GET /api/ws` | 每 2 秒推送一次节点快照的 WebSocket |
@@ -66,8 +66,8 @@ MONITOR_HUB=https://hub.example.com npm run dev
`metrics` 的三个查询参数都可省:
-- `hours=N` 窗口宽度。**匿名上限 168,登录后 2160**,超出静默 clamp——降采样限的是响应行数,这个
- 上限限的是 hub 扫描多少行
+- `hours=N` 窗口宽度。**上限是 hub 的保留天数**(`/api/me` 的 `history_days`),登录与匿名相同,超出
+ 静默 clamp,所以档位要按它生成。7 天以内取分钟明细,更宽取每小时的汇总
- `points=W` 调用方画得下的点数,只会让 hub 抽得更稀,不会更密
- `series=metrics|ping` 只取要画的那一半,省掉的那半原本占响应的三分之一到三分之二
diff --git a/package-lock.json b/package-lock.json
index 90852bf..f35eb6d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,8 +19,7 @@
"recharts": "^3.10.1",
"simple-icons": "^16.32.0",
"tailwind-merge": "^3.6.0",
- "tailwindcss": "^4.3.3",
- "tw-animate-css": "^1.4.0"
+ "tailwindcss": "^4.3.3"
},
"devDependencies": {
"@types/node": "^24.13.3",
@@ -3907,15 +3906,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
- "node_modules/tw-animate-css": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
- "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/Wombosvideo"
- }
- },
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
diff --git a/package.json b/package.json
index e814a5b..c3a00b2 100644
--- a/package.json
+++ b/package.json
@@ -22,8 +22,7 @@
"recharts": "^3.10.1",
"simple-icons": "^16.32.0",
"tailwind-merge": "^3.6.0",
- "tailwindcss": "^4.3.3",
- "tw-animate-css": "^1.4.0"
+ "tailwindcss": "^4.3.3"
},
"devDependencies": {
"@types/node": "^24.13.3",
diff --git a/src/App.tsx b/src/App.tsx
index c83ba68..07fc832 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -13,8 +13,8 @@ import { loadConfig } from "@/lib/config"
type Me = { authed: boolean; github: boolean; site_name: string; public_page: boolean; history_days?: number }
// Split out because recharts is most of this bundle and the list page draws no
-// chart. The landing page is 242 kB rather than 629 kB (77 kB gzipped against
-// 188 kB), with the rest fetched immediately after it paints.
+// chart. The landing page is 288 kB rather than 678 kB (95 kB gzipped against
+// 207 kB), with the rest fetched immediately after it paints.
const loadDetail = () => import("@/components/NodeDetail").then((m) => ({ default: m.NodeDetail }))
const NodeDetail = lazy(loadDetail)
@@ -35,7 +35,10 @@ function useNodeRoute() {
return [
id,
(next: number | null) => {
- history.pushState({}, "", next === null ? "/" : `/node/${next}`)
+ const path = next === null ? "/" : `/node/${next}`
+ // The site name leads to the list from the list as well, and each repeat
+ // would otherwise cost one more press of back.
+ if (location.pathname !== path) history.pushState({}, "", path)
setId(next)
scrollTo(0, 0)
},
diff --git a/src/components/Meter.tsx b/src/components/Meter.tsx
index 0760d77..eb4a2ed 100644
--- a/src/components/Meter.tsx
+++ b/src/components/Meter.tsx
@@ -9,14 +9,15 @@ type Props = { label: ReactNode; pct: number | null; foot: ReactNode; empty?: Re
export function Meter({ label, pct, foot, empty = "—" }: Props) {
// null means the metric has no ceiling to fill, so the bar stays empty rather
// than reporting 0%. What replaces the percentage depends on the reason:
- // unknown for a node with no metrics, ∞ for a plan with no limit.
+ // unknown for a node with no metrics, ∞ for a plan with no limit. The bar stops
+ // at full while the figure does not, so a plan past its quota reads 209%.
const filled = pct === null ? 0 : Math.min(100, Math.max(0, pct))
return (
{label}
- {pct === null ? empty : `${filled < 10 ? filled.toFixed(1) : filled.toFixed(0)}%`}
+ {pct === null ? empty : `${pct < 10 ? pct.toFixed(1) : pct.toFixed(0)}%`}
diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx
index 2389943..211fd9e 100644
--- a/src/components/NodeCard.tsx
+++ b/src/components/NodeCard.tsx
@@ -79,7 +79,6 @@ export function Status({ node }: { node: Node }) {
// Muted once it stops reporting: the figures on the page are genuine, merely
// no longer current.
+
{node.country}
)
diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx
index d09ff1d..61463bd 100644
--- a/src/components/NodeDetail.tsx
+++ b/src/components/NodeDetail.tsx
@@ -10,7 +10,7 @@ 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, RATE_FLOOR,
- rateAxis, tickClock, timeTicks, windows,
+ rateAxis, tickClock, timeTicks, windows, withGaps,
} from "@/lib/format"
type Point = {
@@ -57,7 +57,11 @@ 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 }
+const TOOLTIP = {
+ isAnimationActive: false,
+ labelFormatter: (ts: unknown) => new Date(Number(ts)).toLocaleString("zh-CN"),
+ contentStyle: { fontSize: 12 },
+}
// 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
@@ -108,6 +112,7 @@ function Tab({ active, onClick, narrow = false, children }: {
}) {
return (
{
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)],
+ return (data?.metrics ?? []).map((p) => ({
+ ...p,
+ ts: p.ts * 1_000,
+ rx: lift(p.net_rx),
+ tx: lift(p.net_tx),
+ rx_band: [lift(p.net_rx), lift(p.net_rx_max ?? p.net_rx)],
+ tx_band: [lift(p.net_tx), lift(p.net_tx_max ?? p.net_tx)],
}))
}, [data])
+ // What the four panels draw: the rows above, broken where the node was silent.
+ // The axes are fitted to the rows alone.
+ const chartRows = useMemo(() => withGaps(metricRows), [metricRows])
// 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(() => {
@@ -353,21 +362,25 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
return (
-
-
-
{node.name}
+ {/* Wraps below the name on a phone, where the badges beside it would
+ leave a 320px screen two characters of it. */}
+
+
+
+
{node.name}
+
{node.agent_version && (
-
+
agent {node.agent_version}
)}
- {/* One flat row of facts: what is left after the traffic figures moved
- out is one machine's spec sheet, and a box around a single topic is
- just a box. Three across at lg, two at md, one on a phone -- a kernel
- version or a CPU model needs about 270px to stay whole. */}
+ {/* One flat grid of facts: one machine's spec sheet, where a box around
+ a single topic would be just a box. Three across at lg, two at md, one
+ on a phone -- a kernel version or a CPU model needs about 270px to
+ stay whole. */}
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.
// `dataKey` is `t7`/`s7`; the loss sits at `l7`.
@@ -495,7 +507,6 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
const loss = Number(item?.payload?.[`l${String(item.dataKey).slice(1)}`] ?? 0)
return [`${Math.round(Number(v))} ms${loss > 0 ? ` · 丢 ${loss}%` : ""}`, name]
}}
- contentStyle={{ fontSize: 12 }}
/>
{/* Behind the line, the range that bucket's answers
spanned -- Smokeping's "smoke". At the day window a
@@ -551,43 +562,44 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
same SVG as the axis, so this is as close beneath as HTML
sits. */}
{(pingSeries.length > 1 || pingSeries.some((s) => s.loss > 0)) && (
-
- {pingSeries.map((s) => {
- const shown = !hiddenProbes.includes(s.id)
- return (
-
- setHiddenProbes((h) => (shown ? [...h, s.id] : h.filter((id) => id !== s.id)))
- }
- className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-opacity ${
- shown ? "" : "opacity-40"
- }`}
- >
- {/* The swatch carries the same shade and dash as the line. */}
-
-
-
- {s.name}
- {/* The line is only what answered, so a probe dropping
- half its packets draws like a healthy one. */}
- {s.loss > 0 && (
-
- 丢 {s.loss < 1 ? "<1" : Math.round(s.loss)}%
-
- )}
-
- )
- })}
-
+
+ {pingSeries.map((s) => {
+ const shown = !hiddenProbes.includes(s.id)
+ return (
+
+ setHiddenProbes((h) => (shown ? [...h, s.id] : h.filter((id) => id !== s.id)))
+ }
+ className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-opacity ${
+ shown ? "" : "opacity-40"
+ }`}
+ >
+ {/* The swatch carries the same shade and dash as the line. */}
+
+
+
+ {s.name}
+ {/* The line is only what answered, so a probe dropping
+ half its packets draws like a healthy one. */}
+ {s.loss > 0 && (
+
+ 丢 {s.loss < 1 ? "<1" : Math.round(s.loss)}%
+
+ )}
+
+ )
+ })}
+
)}
)
@@ -597,16 +609,11 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
-
+
-
+
- new Date(Number(ts)).toLocaleString("zh-CN")}
- formatter={(v) => [`${Number(v).toFixed(1)}%`, "CPU"]}
- contentStyle={{ fontSize: 12 }}
- />
+ [`${Number(v).toFixed(1)}%`, "CPU"]} />
@@ -619,16 +626,11 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
title because the axis top is claiming it. */}
-
+
-
+
- new Date(Number(ts)).toLocaleString("zh-CN")}
- formatter={(v) => bytes(Number(v))}
- contentStyle={{ fontSize: 12 }}
- />
+ bytes(Number(v))} />
@@ -652,13 +654,12 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
line. */}
-
+
-
+
new Date(Number(ts)).toLocaleString("zh-CN")}
// The hub's figures rather than `v`, which is lifted to the
// axis floor.
formatter={(_, name, item) => {
@@ -666,7 +667,6 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
const top = item?.payload?.[`net_${item.dataKey}_max`]
return [top === undefined ? rate(mean) : `均值 ${rate(mean)} · 峰值 ${rate(top)}`, name]
}}
- contentStyle={{ fontSize: 12 }}
/>
{[
{ key: "rx", stroke: "var(--color-ok)" },
@@ -696,16 +696,11 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
axis tracks the window's own maximum. */}
-
+
-
+
- new Date(Number(ts)).toLocaleString("zh-CN")}
- formatter={(v) => bytes(Number(v))}
- contentStyle={{ fontSize: 12 }}
- />
+ bytes(Number(v))} />
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
index 6eb2a05..55c9ace 100644
--- a/src/components/ui/badge.tsx
+++ b/src/components/ui/badge.tsx
@@ -1,48 +1,21 @@
import * as React from "react"
-import { cva, type VariantProps } from "class-variance-authority"
-import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
-const badgeVariants = cva(
- "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
- {
- variants: {
- variant: {
- default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
- secondary:
- "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
- destructive:
- "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
- outline:
- "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
- ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
- link: "text-primary underline-offset-4 [a&]:hover:underline",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- }
-)
-
-function Badge({
- className,
- variant = "default",
- asChild = false,
- ...props
-}: React.ComponentProps<"span"> &
- VariantProps & { asChild?: boolean }) {
- const Comp = asChild ? Slot.Root : "span"
-
+// The outline badge only, the one this theme draws. shadcn's ships five more
+// variants and `asChild`, all unused, and every variant's classes would land in
+// the stylesheet.
+function Badge({ className, ...props }: React.ComponentProps<"span">) {
return (
- svg]:pointer-events-none [&>svg]:size-3",
+ className
+ )}
{...props}
/>
)
}
-export { Badge, badgeVariants }
+export { Badge }
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index 4d38506..8279757 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -4,31 +4,24 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
+// The variants and sizes this theme uses. shadcn's ships three more of each and
+// the aria-invalid styling of a form control, all unused, and every class would
+// land in the stylesheet.
const buttonVariants = cva(
- "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
- destructive:
- "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
- outline:
- "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
- link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
- xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
- lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
- "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
- "icon-sm": "size-8",
- "icon-lg": "size-10",
},
},
defaultVariants: {
@@ -61,4 +54,4 @@ function Button({
)
}
-export { Button, buttonVariants }
+export { Button }
diff --git a/src/index.css b/src/index.css
index 6db4991..1f2bad3 100644
--- a/src/index.css
+++ b/src/index.css
@@ -8,7 +8,6 @@
@import "tailwindcss" source(none);
@source "../index.html";
@source "../src";
-@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@@ -18,8 +17,6 @@
--foreground: oklch(0.18 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.18 0 0);
- --popover: oklch(1 0 0);
- --popover-foreground: oklch(0.18 0 0);
--primary: oklch(0.18 0 0);
--primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.96 0 0);
@@ -30,15 +27,15 @@
--accent-foreground: oklch(0.18 0 0);
--destructive: oklch(0.3 0 0);
--border: oklch(0.9 0 0);
- --input: oklch(0.9 0 0);
--ring: oklch(0.62 0 0);
--chart-1: oklch(0.3 0 0);
--chart-2: oklch(0.45 0 0);
--chart-3: oklch(0.58 0 0);
--chart-4: oklch(0.7 0 0);
--chart-5: oklch(0.82 0 0);
- /* Greyscale has no red or amber, so severity is carried by weight: the bar
- darkens as it fills. */
+ /* Greyscale has no green or amber, so lightness carries both: --ok draws the
+ download line, lighter than the upload's --chart-1, and --warn an expiry
+ within a week, darker than the muted text beside it. */
--ok: oklch(0.62 0 0);
--warn: oklch(0.44 0 0);
/* The status dot. Each clears 3:1 against the card (3.7 and 5.5), and 1.5:1
@@ -56,8 +53,6 @@
--foreground: oklch(0.98 0 0);
--card: oklch(0.2 0 0);
--card-foreground: oklch(0.98 0 0);
- --popover: oklch(0.2 0 0);
- --popover-foreground: oklch(0.98 0 0);
--primary: oklch(0.92 0 0);
--primary-foreground: oklch(0.2 0 0);
--secondary: oklch(0.27 0 0);
@@ -68,14 +63,13 @@
--accent-foreground: oklch(0.98 0 0);
--destructive: oklch(0.95 0 0);
--border: oklch(1 0 0 / 12%);
- --input: oklch(1 0 0 / 15%);
--ring: oklch(0.55 0 0);
--chart-1: oklch(0.95 0 0);
--chart-2: oklch(0.82 0 0);
--chart-3: oklch(0.68 0 0);
--chart-4: oklch(0.54 0 0);
--chart-5: oklch(0.42 0 0);
- /* Inverted against the dark ground: the fuller the bar, the brighter. */
+ /* Inverted against the dark ground: --warn is brighter than the muted text. */
--ok: oklch(0.62 0 0);
--warn: oklch(0.79 0 0);
/* 7.8 and 3.7 against the card, 2.1 apart. The muted grey would sit 1.15 from
@@ -93,8 +87,6 @@
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
- --color-popover: var(--popover);
- --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
@@ -105,7 +97,6 @@
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
- --color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 271d911..eb422eb 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -177,7 +177,7 @@ export function useNodes() {
let socket: WebSocket | null = null
let poll: ReturnType | null = null
let retry: ReturnType | null = null
- let closed = false
+ let stopped = false
const receive = (list: Node[]) => {
const safe = safeNodes(list)
@@ -200,7 +200,7 @@ export function useNodes() {
const url = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/api/ws`
// A hub restart closes every stream. Without reconnecting, a page that
// outlives a deploy would remain on the fallback poll for the rest of its
- // life, refreshing at a fifth of the live rate with no indication.
+ // life, refreshing every 5 seconds rather than 2 with no indication.
const connect = () => {
try {
socket = new WebSocket(url)
@@ -218,7 +218,7 @@ export function useNodes() {
}
socket.onerror = () => socket?.close()
socket.onclose = () => {
- if (closed) return
+ if (stopped) return
poll ??= setInterval(fetchOnce, 5000)
retry = setTimeout(connect, 5000)
}
@@ -226,7 +226,7 @@ export function useNodes() {
connect()
return () => {
- closed = true
+ stopped = true
socket?.close()
if (poll) clearInterval(poll)
if (retry) clearTimeout(retry)
diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts
index ea9f934..0392f0c 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, cpuName, cycle, daysUntil, despike, osName, pair, quarters, RATE_FLOOR, rateAxis,
- tickClock, timeTicks, uptime, windows,
+ axisBytes, axisTop, bytes, cpuName, cycle, daysUntil, despike, osName, pair, percent, quarters, RATE_FLOOR,
+ rateAxis, tickClock, timeTicks, uptime, windows, withGaps,
} from "./format.ts"
let failed = 0
@@ -45,6 +45,21 @@ 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"], "四条网格线都是整值")
+// percent: a plan used past its quota keeps its figure.
+eq(percent(3, 2), 150, "超额不封顶")
+eq(percent(1, 0), 0, "没有总量时为 0")
+
+// withGaps: an empty row in a gap over twice the usual spacing, none for one
+// missed bucket or a slower agent's regular spacing. Empty rows show negated.
+const gapped = (list: number[]) => withGaps(list.map((ts) => ({ ts, v: 1 }))).map((r) => ("v" in r ? r.ts : -r.ts))
+eq(gapped([0, 60, 120, 180, 600, 660]), [0, 60, 120, 180, -390, 600, 660], "离线的一段断开")
+eq(gapped([0, 60, 180, 240]), [0, 60, 180, 240], "缺一个桶不断开")
+eq(gapped([0, 300, 600, 900, 1200]), [0, 300, 600, 900, 1200], "五分钟上报一次的 agent 照常连线")
+eq(gapped([0, 60, 300, 600, 900, 1200]), [0, 60, 300, 600, 900, 1200], "间隔不齐时按中位数而非最小值")
+eq(gapped([0, 60, 660]), [0, 60, -360, 660], "两段间隔时较短的一段是常规间隔")
+eq(gapped([0]), [0], "单点")
+eq(gapped([]), [], "空")
+
// 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) => {
diff --git a/src/lib/format.ts b/src/lib/format.ts
index f453b2a..9c2a4a0 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -43,16 +43,16 @@ export function pair(used: number, total: number): string {
*/
export function axisBytes(v: number): string {
if (!v || v < 0) return "0 B"
- const unit = Math.min(Math.floor(Math.log(v) / Math.log(1024)), 5)
- return bytes(v, v / 1024 ** unit >= 100 ? 0 : 1).replace(".0 ", " ")
+ return bytes(v, v / 1024 ** unitOf(v) >= 100 ? 0 : 1).replace(".0 ", " ")
}
export function rate(n: number): string {
return `${bytes(n, 1)}/s`
}
+/** Not capped: a plan used past its quota reads as 209%, not 100%. */
export function percent(used: number, total: number): number {
- return total > 0 ? Math.min(100, (used / total) * 100) : 0
+ return total > 0 ? (used / total) * 100 : 0
}
export function uptime(seconds: number): string {
@@ -97,15 +97,14 @@ export function cycle(billing: string): string {
return CYCLE_WORDS[months] ?? (months % 12 ? `${months} 个月付` : `${months / 12} 年付`)
}
-// Hoisted out of `clock`: recharts calls a tickFormatter for every sample when
-// laying out an axis rather than once per tick drawn, and constructing an Intl
-// formatter per call was the largest single cost on the detail page -- 348 ms of
-// a 1531 ms click-to-chart. The zone now resolves once, which only an OS timezone
-// change under an open tab would notice.
+// Built once: recharts calls a tickFormatter for every sample when laying out an
+// axis rather than once per tick drawn, and an Intl formatter constructed per call
+// would be the largest single cost on the detail page -- 348 ms of a 1531 ms
+// click-to-chart. The zone resolves once, which only an OS timezone change under
+// an open tab would notice.
//
-// Both take epoch milliseconds, which is what the charts feed their time axis:
-// recharts passes `scale="time"` to a d3 time scale, and a scale given seconds
-// reads 1.79e9 as three weeks past the epoch. The hub answers in seconds.
+// They take epoch milliseconds, as Date does. The hub answers in seconds, which
+// the charts convert as the rows arrive.
const HHMM = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit" })
const MDHHMM = new Intl.DateTimeFormat("zh-CN", {
@@ -117,16 +116,12 @@ const MDHHMM = new Intl.DateTimeFormat("zh-CN", {
const MMDD = new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit" })
-export function clock(ms: number): string {
- return HHMM.format(ms)
-}
-
/**
* Axis ticks for a window `hours` wide. Beyond a day a bare "14:00" recurs each
* midnight and the axis no longer indicates which day it refers to.
*/
export function clockFor(hours: number): (ms: number) => string {
- return hours <= 24 ? clock : (ms: number) => MDHHMM.format(ms)
+ return hours <= 24 ? HHMM.format : MDHHMM.format
}
/**
@@ -138,7 +133,7 @@ export function clockFor(hours: number): (ms: number) => string {
*/
export function tickClock(ticks: number[], hours: number): (ms: number) => string {
const midnight = (t: number) => new Date(t).getHours() === 0 && new Date(t).getMinutes() === 0
- return ticks.length > 0 && ticks.every(midnight) ? (ms: number) => MMDD.format(ms) : clockFor(hours)
+ return ticks.length > 0 && ticks.every(midnight) ? MMDD.format : clockFor(hours)
}
// Round chart windows, in hours: up to a week they are drawn from minute rows,
@@ -233,14 +228,13 @@ function monthTicks(from: number, to: number, count: number): number[] {
* exceeds 0.4% CPU would get an axis of 0-0.4 and render every scheduler blip as
* a peak.
*/
-export function axisTop(max: number, floor: number, cap = Infinity): number {
+export function axisTop(max: number, floor: number, cap: number): number {
const target = Math.min(cap, Math.max(max, floor)) / 4
const scale = 10 ** Math.floor(Math.log10(target))
- const step = LADDER.map((m) => m * scale).find((n) => n >= target)
- return Math.min(cap, (step ?? target) * 4)
+ return Math.min(cap, LADDER.find((m) => m * scale >= target)! * scale * 4)
}
-/** Round multipliers within a decade. */
+/** Round multipliers spanning a decade, so `axisTop` always finds one. */
const LADDER = [1, 1.5, 2, 2.5, 3, 4, 5, 7.5, 10]
/**
@@ -255,6 +249,23 @@ export function quarters(top: number): number[] {
return [0, 0.25, 0.5, 0.75, 1].map((f) => top * f)
}
+/**
+ * `rows` with an empty row in each gap over twice their usual spacing, where a
+ * chart breaks its line rather than drawing a straight one across a stretch with
+ * no samples -- a day offline would read as a day of steady load. The usual
+ * spacing is the median gap rather than the hub's bucket: an agent reporting
+ * every few minutes leaves a row only every few buckets.
+ */
+export function withGaps(rows: T[]): (T | { ts: number })[] {
+ const gaps = rows.slice(1).map((r, i) => r.ts - rows[i].ts).sort((a, b) => a - b)
+ // The lower median: of two gaps, the shorter is the spacing and the longer
+ // the one in question.
+ const usual = gaps[(gaps.length - 1) >> 1]
+ return rows.flatMap((r, i) =>
+ i > 0 && r.ts - rows[i - 1].ts > 2 * usual ? [{ ts: (r.ts + rows[i - 1].ts) / 2 }, r] : [r],
+ )
+}
+
/**
* The rungs of a logarithmic byte axis: 1, 10 and 100 of each binary unit, so
* every gridline prints as a round label -- 100 B, 1 KB, 10 KB, 100 KB, 1 MB.