diff --git a/src/App.tsx b/src/App.tsx index a77d841..c78da8a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,7 @@ import { Link, useNodeRoute } from "@/lib/route" // `history_days` is absent from hubs predating the hourly tier, which kept a // week for anonymous callers. -type Me = { authed: boolean; github: boolean; site_name: string; public_page: boolean; history_days?: number } +type Me = { authed: boolean; site_name: string; public_page: boolean; history_days?: number } // Split out because recharts is most of the bundle and the list draws no chart. // Warmed as soon as the app starts, so the first chart opened does not wait on it. diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index 7fef1a1..cccd7da 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react" import { Area, AreaChart, Brush, CartesianGrid, ComposedChart, Line, ResponsiveContainer, - Tooltip, XAxis, YAxis, + Tooltip, XAxis, YAxis, type DotItemDotProps, } from "recharts" import { Skeleton } from "@/components/ui/skeleton" @@ -9,7 +9,7 @@ import { deployed, Dot, Flag } from "@/components/ServerTable" import { api, type Node } from "@/lib/api" import { axisBytes, axisTop, bytes, clockFor, despike, quarters, rate, RATE_FLOOR, rateAxis, tickClock, timeTicks, uptime, - windows, + windows, withGaps, } from "@/lib/format" import { cn } from "@/lib/utils" @@ -58,6 +58,17 @@ const AXIS = { stroke: "currentColor", fontSize: 11, tickLine: false, axisLine: // chart across seven hundred points per probe. const SERIES = { dot: false as const, strokeWidth: 1.5, isAnimationActive: false } +// A resource chart's series. A row with a gap on either side has no neighbour to +// draw a line to, so it is marked with a dot; without one, a node back for a +// single bucket after a stretch offline would not show at all. +const RESOURCE_SERIES = { + ...SERIES, + dot: ({ cx, cy, index, points, stroke }: DotItemDotProps) => + cy == null || points[index - 1]?.y != null || points[index + 1]?.y != null ? null : ( + + ), +} + // 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 // 28px, placing a CPU spike and the network spike that caused it at different x. @@ -73,14 +84,17 @@ const VALUE_AXIS = { ...AXIS, width: 68, interval: 0 } // lines both read as texture. const PALETTE = [1, 2, 3, 4, 5].map((i) => `var(--color-chart-${i})`) -// recharts paints its tooltip white unless told otherwise, which is a white box -// on the dark theme. -const TIP = { - fontSize: 12, - background: "var(--color-popover)", - color: "var(--color-popover-foreground)", - border: "1px solid var(--color-border)", - borderRadius: "var(--radius)", +// Shared by every chart's tooltip. recharts paints it white unless told +// otherwise, which is a white box on the dark theme. +const TOOLTIP = { + labelFormatter: (ts: unknown) => new Date(Number(ts)).toLocaleString("zh-CN"), + contentStyle: { + fontSize: 12, + background: "var(--color-popover)", + color: "var(--color-popover-foreground)", + border: "1px solid var(--color-border)", + borderRadius: "var(--radius)", + }, } function Panel({ title, children }: { title: React.ReactNode; children: React.ReactNode }) { @@ -135,8 +149,8 @@ function useHistory(id: number, hours: number, series: "metrics" | "ping") { // approximate figure suffices, and the viewport is known before layout. A // rotation keeps whatever it fetched with. // - // Only the half on screen is requested; the other accounted for a third to two - // thirds of every response and was never drawn. + // Only the series on screen is requested; the other would make up a third to + // two thirds of every response and never be drawn. const points = Math.round(globalThis.innerWidth * (globalThis.devicePixelRatio || 1)) api(`/nodes/${id}/metrics?hours=${hours}&points=${points}&series=${series}`) .then((next) => { if (active) setData(next) }) @@ -270,9 +284,10 @@ export function Latency({ id, className }: { id: number; className?: string }) { 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. + // 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 @@ -349,10 +364,10 @@ export function Latency({ id, className }: { id: number; className?: string }) { it, and zero flattens every wobble. */} new Date(Number(ts)).toLocaleString("zh-CN")} + {...TOOLTIP} // 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`. + // `t7`, or `s7` with spikes clipped; 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 @@ -361,7 +376,6 @@ export function Latency({ id, className }: { id: number; className?: string }) { const loss = Number(item?.payload?.[`l${String(item.dataKey).slice(1)}`] ?? 0) return [`${Math.round(Number(v))} ms${loss > 0 ? ` · 丢 ${loss}%` : ""}`, name] }} - contentStyle={TIP} /> {/* Behind the line, the range that bucket's answers spanned -- Smokeping's "smoke". At the day window a bucket moves 63 ms at @@ -440,6 +454,9 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num tx_band: [lift(m.net_tx), lift(m.net_tx_max ?? m.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(() => { @@ -469,6 +486,9 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num ), } }, [metricRows]) + // Held across the live pushes that re-render this page, or each would hand + // the four axes a new formatter and lay them out again. + const xAxis = useMemo(() => timeAxis(chartRows, hours), [chartRows, hours]) return (
@@ -476,7 +496,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num

{node.name}

- + {node.online ? `在线 ${m ? uptime(m.uptime) : ""}` : deployed(node) ? `离线 ${away >= 60 ? uptime(away) : ""}` : "未接入"} {node.agent_version && agent {node.agent_version}} @@ -505,16 +525,15 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
- + - + new Date(Number(ts)).toLocaleString("zh-CN")} + {...TOOLTIP} formatter={(v) => [`${Number(v).toFixed(1)}%`, "CPU"]} - contentStyle={TIP} /> - + @@ -526,16 +545,15 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num title because the axis top is claiming it. */} - + - + new Date(Number(ts)).toLocaleString("zh-CN")} + {...TOOLTIP} formatter={(v) => bytes(Number(v))} - contentStyle={TIP} /> - + @@ -560,21 +578,21 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num <> 网络速率 - ● 下行{peak && 峰值 {rate(peak.rx)}} + ● 下行{peak && 峰值 {rate(peak.rx)}} - ● 上行{peak && 峰值 {rate(peak.tx)}} + ● 上行{peak && 峰值 {rate(peak.tx)}} } > - + - + new Date(Number(ts)).toLocaleString("zh-CN")} + {...TOOLTIP} // The hub's figures rather than `v`, which is lifted to the // axis floor. formatter={(_, name, item) => { @@ -582,7 +600,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={TIP} /> {[ { key: "rx", stroke: "var(--color-chart-2)" }, @@ -601,8 +618,8 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num legendType="none" /> ))} - - + + @@ -612,16 +629,15 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num axis tracks the window's own maximum. */} - + - + new Date(Number(ts)).toLocaleString("zh-CN")} + {...TOOLTIP} formatter={(v) => bytes(Number(v))} - contentStyle={TIP} /> - + diff --git a/src/components/ServerTable.tsx b/src/components/ServerTable.tsx index 7ae5078..5db18e5 100644 --- a/src/components/ServerTable.tsx +++ b/src/components/ServerTable.tsx @@ -72,7 +72,7 @@ const DISTROS: [string, SimpleIcon][] = [ * The distribution's logo in its brand colour. Mixed toward white on the dark * theme, where AlmaLinux's black and CentOS's navy would otherwise vanish. */ -export function OsIcon({ os, className }: { os: string; className?: string }) { +function OsIcon({ os, className }: { os: string; className?: string }) { if (!os) return null const name = os.toLowerCase() const icon = DISTROS.find(([key]) => name.includes(key))?.[1] ?? siLinux @@ -98,7 +98,7 @@ function Bar({ pct, label }: { pct: number | null; label?: string }) { return (
- + {label ?? (pct === null ? "—" : `${v.toFixed(1)}%`)}
@@ -136,7 +136,7 @@ function Line({ label, children }: { label: string; children: ReactNode }) { return (
{label} - {children} + {children}
) } @@ -235,7 +235,7 @@ function Row({ node, index }: { node: Node; index: number }) { tabIndex={0} onClick={toggle} onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && (e.preventDefault(), toggle())} - className={cn("cursor-pointer border-0 hover:bg-accent", shade)} + className={cn("cursor-pointer hover:bg-accent", shade)} > {node.name} @@ -255,7 +255,7 @@ function Row({ node, index }: { node: Node; index: number }) { 0 ? `${pair(traffic, node.traffic_limit)}(${((traffic / node.traffic_limit) * 100).toFixed(1)}%)` : `${bytes(traffic)} · 无流量配额`}`} + title={`本月已用 ${node.traffic_limit > 0 ? `${pair(traffic, node.traffic_limit)}(${percent(traffic, node.traffic_limit).toFixed(1)}%)` : `${bytes(traffic)} · 无流量配额`}`} > 0 ? percent(traffic, node.traffic_limit) : null} @@ -264,8 +264,8 @@ function Row({ node, index }: { node: Node; index: number }) { {open && ( - - + +
@@ -308,7 +308,7 @@ function ServerTable({ title, nodes }: { title: string; nodes: Node[] }) {

{title}

-
+
在线 {nodes.filter((n) => n.online).length} / {nodes.length} · ↓ {compact(sum((n) => n.metrics!.net_rx))}/s · ↑{" "} {compact(sum((n) => n.metrics!.net_tx))}/s @@ -318,7 +318,7 @@ function ServerTable({ title, nodes }: { title: string; nodes: Node[] }) {
- + {heads.map(([col, label], i) => ( {label} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index def3eb1..2d59775 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -11,7 +11,7 @@ const VARIANTS = { ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", } -const SIZES = { default: "h-9 px-4 py-2 has-[>svg]:px-3", icon: "size-9" } +const SIZES = { default: "h-9 px-4 py-2", icon: "size-9" } function Button({ className, @@ -23,7 +23,7 @@ function Button({
@@ -26,24 +26,15 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { ) } -function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { - return ( - - ) +function TableBody(props: React.ComponentProps<"tbody">) { + return } function TableRow({ className, ...props }: React.ComponentProps<"tr">) { return ( ) @@ -54,7 +45,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
[role=checkbox]]:translate-y-[2px]", + "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground", className )} {...props} @@ -67,7 +58,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) { [role=checkbox]]:translate-y-[2px]", + "p-2 align-middle whitespace-nowrap", className )} {...props} diff --git a/src/index.css b/src/index.css index 75c4fdd..b810c4e 100644 --- a/src/index.css +++ b/src/index.css @@ -21,8 +21,6 @@ --popover-foreground: #212121; --primary: #337ab7; --primary-foreground: #ffffff; - --secondary: #f5f5f5; - --secondary-foreground: #212121; --muted: #f9f9f9; --muted-foreground: #777777; --accent: #e6e6e6; @@ -62,8 +60,6 @@ --popover-foreground: #f1f1f1; --primary: #4992ff; --primary-foreground: #ffffff; - --secondary: #22232e; - --secondary-foreground: #f1f1f1; --muted: #22232e; --muted-foreground: #aaaaaa; --accent: #303241; @@ -92,10 +88,7 @@ } @theme inline { - --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -104,8 +97,6 @@ --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); @@ -132,6 +123,3 @@ html { scrollbar-gutter: stable; } body { @apply bg-background text-foreground; } } - -/* Tabular figures keep live numbers from shifting as they update. */ -.tnum { font-variant-numeric: tabular-nums; } diff --git a/src/lib/api.ts b/src/lib/api.ts index bfcd232..6e9e2e0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react" -export type Metrics = { +type Metrics = { uptime: number cpu: number load: [number, number, number] @@ -25,7 +25,6 @@ export type Node = { id: number name: string sort: number - public: boolean online: boolean /** ISO 3166-1 alpha-2, or empty when the hub could not locate the address. */ country: string @@ -61,12 +60,9 @@ export type Node = { month_tx: number /** This period's usage as the plan meters it (`traffic_mode`). Absent on older hubs. */ month_used?: number - month_start: string day_rx: number day_tx: number - /** Panel only. */ - hostname?: string - ip?: string + /** Sent only to a signed-in visitor, who receives the panel's view. */ remark?: string } @@ -96,18 +92,14 @@ async function failure(res: Response): Promise { ) } -export async function api(path: string, init?: RequestInit): Promise { +export async function api(path: string): Promise { let res: Response try { - res = await fetch(`/api${path}`, { - ...init, - headers: init?.body ? { "content-type": "application/json", ...init?.headers } : init?.headers, - }) + res = await fetch(`/api${path}`) } catch { throw new ApiError(0, "网络连接失败,稍后再试") } if (!res.ok) throw await failure(res) - if (res.status === 204) return undefined as T // A 200 carrying HTML is a proxy's page, not the hub's JSON. return res.json().catch(() => { throw new ApiError(res.status, "收到的不是状态数据,稍后再试") @@ -143,7 +135,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[]) => { setNodes(safeNodes(list)) @@ -164,7 +156,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) @@ -182,7 +174,7 @@ export function useNodes() { } socket.onerror = () => socket?.close() socket.onclose = () => { - if (closed) return + if (stopped) return poll ??= setInterval(fetchOnce, 5000) retry = setTimeout(connect, 5000) } @@ -190,7 +182,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 dd039c6..0397a5a 100644 --- a/src/lib/format.test.ts +++ b/src/lib/format.test.ts @@ -5,7 +5,7 @@ // Nothing imports it, so the bundle never includes it. import { axisBytes, axisTop, bytes, compact, cpuName, cycle, daysUntil, despike, distro, duration, expiresIn, money, monthUsage, - osName, pair, quarters, RATE_FLOOR, rateAxis, tickClock, timeTicks, uptime, windows, + osName, pair, quarters, RATE_FLOOR, rateAxis, tickClock, timeTicks, uptime, windows, withGaps, } from "./format.ts" let failed = 0 @@ -45,6 +45,17 @@ 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"], "四条网格线都是整值") +// withGaps: an empty row in a gap over twice the usual spacing, none for one +// missing bucket or an agent reporting every few minutes. Gaps print negative. +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 09afb37..3ec569f 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -7,9 +7,8 @@ const unitOf = (n: number) => Math.min(Math.floor(Math.log(n) / Math.log(1024)), * `df -h` and hosting plans write them: no plan is sold as "1000 GiB", and the two * extra letters push the memory and traffic lines past their column. * - * Three significant digits by default. Two decimals throughout would end the - * card's lines in an ellipsis on a four-column grid; a pair sharing a unit - * recovers them through pair() below. + * Three significant digits by default; a pair sharing a unit gets two decimals + * through pair() below. */ export function bytes(n: number, digits?: number): string { // `< 1` rather than `< 0`: a fraction of a byte puts `unitOf` at -1 and prints @@ -21,10 +20,10 @@ export function bytes(n: number, digits?: number): string { } /** - * A "used / total" pair. Sharing a unit means writing it once, and those four - * characters are what allow the two decimals: 111px against the 122px a card in - * the four-column grid provides, where separate units require 132px. A pair - * spanning two units has nothing to save and falls back to bytes(). + * A "used / total" pair. Sharing a unit means writing it once, and the + * characters saved go to two decimals: "1.23 / 3.82 GB" rather than + * "1.23 GB / 3.82 GB". A pair spanning two units has nothing to save and falls + * back to bytes(). */ export function pair(used: number, total: number): string { if (used > 0 && total > 0 && unitOf(used) === unitOf(total)) { @@ -43,8 +42,7 @@ 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 { @@ -64,7 +62,7 @@ export function compact(n: number): string { /** Distribution and major version only: "Debian GNU/Linux 13 (trixie)" is "Debian 13". */ export function distro(os: string): string { - const name = os.trim().split(/\s+/)[0] ?? "" + const name = os.trim().split(/\s+/)[0] const version = os.match(/\d+(?:\.\d+)?/)?.[0] return version ? `${name} ${version}` : name } @@ -96,8 +94,9 @@ export function monthUsage(node: { month_rx: number; month_tx: number; month_use } } +/** Not capped: traffic past its quota reads as 212%, while the bar stops full. */ 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 { @@ -171,15 +170,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", { @@ -191,16 +189,12 @@ const MDHHMM = new Intl.DateTimeFormat("zh-CN", { const MMDD = new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit" }) -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 } /** @@ -212,7 +206,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, @@ -307,14 +301,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] /** @@ -329,6 +322,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. diff --git a/src/lib/route.tsx b/src/lib/route.tsx index 41a6640..4cade3a 100644 --- a/src/lib/route.tsx +++ b/src/lib/route.tsx @@ -20,9 +20,13 @@ export function useNodeRoute() { return id } -/** Announced as a popstate, so every `useNodeRoute` hears it the way it hears back. */ +/** + * Announced as a popstate, so every `useNodeRoute` hears it the way it hears back. + * A link to the page already shown adds no entry, or each click on it would cost + * one more press of back. + */ function navigate(href: string) { - history.pushState({}, "", href) + if (location.pathname + location.search + location.hash !== href) history.pushState({}, "", href) dispatchEvent(new PopStateEvent("popstate")) scrollTo(0, 0) }