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