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 ( - ) - })} -
+
+ {pingSeries.map((s) => { + const shown = !hiddenProbes.includes(s.id) + return ( + + ) + })} +
)}
) @@ -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.