diff --git a/src/App.tsx b/src/App.tsx
index 5c5e734..c83ba68 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -8,7 +8,9 @@ import { Skeleton } from "@/components/ui/skeleton"
import { api, groupsOf, useNodes, type Node } from "@/lib/api"
import { loadConfig } from "@/lib/config"
-type Me = { authed: boolean; github: boolean; site_name: string; public_page: boolean }
+// `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 }
// 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
@@ -181,7 +183,7 @@ export default function App() {
) : selected ? (
}>
-
+
) : (
diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx
index 21d3d02..35c88ad 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,
- timeTicks,
+ tickClock, timeTicks, windows,
} from "@/lib/format"
type Point = {
@@ -47,18 +47,6 @@ type Probes = Record
*/
type Loss = Record
-const RANGES = [
- { hours: 1, label: "1 小时" },
- { hours: 6, label: "6 小时" },
- { hours: 24, label: "24 小时" },
- { hours: 168, label: "7 天" },
-]
-
-// Latency stops at a day. A week-wide bucket would still carry the spread and the
-// loss figure, but a week of probe history is outside this page's purpose, and
-// these are the windows in which every ping remains on the chart.
-const RANGES_FOR = { resources: RANGES, latency: RANGES.filter((r) => r.hours <= 24) }
-
const AXIS = { stroke: "currentColor", fontSize: 11, tickLine: false, axisLine: false }
// No grow-in animation: it would spend 1.5 s drawing a line across the panel on
@@ -100,11 +88,18 @@ function Panel({ title, children }: { title: string; children: React.ReactNode }
)
}
-function Tab({ active, onClick, children }: { active: boolean; onClick: () => void; children: string }) {
+// `narrow` tightens the padding on a phone, so the seven windows of a year of
+// history fit one row of a 360px screen.
+function Tab({ active, onClick, narrow = false, children }: {
+ active: boolean
+ onClick: () => void
+ narrow?: boolean
+ children: string
+}) {
return (
@@ -140,12 +135,19 @@ function Fact({ label, value }: { label: string; value?: string | number | null
)
}
-export function NodeDetail({ node }: { node: Node }) {
+/**
+ * `historyDays` is how far back the hub keeps history. Both tabs offer every
+ * window within it: up to a day each ping is on the latency chart, and past it a
+ * point is a bucket -- its median, the range its answers spanned and the share
+ * lost -- which over a month shows a route's evening pattern a day cannot.
+ */
+export function NodeDetail({ node, historyDays }: { node: Node; historyDays: number }) {
+ const ranges = useMemo(() => windows(historyDays), [historyDays])
const [tab, setTab] = useState<(typeof TABS)[number]["key"]>("resources")
// Each tab keeps its own range: a 7-day trend and a 1-hour trace answer
// different questions.
- const [ranges, setRanges] = useState({ resources: 6, latency: 6 })
- const hours = ranges[tab]
+ const [picked, setPicked] = useState({ resources: 6, latency: 6 })
+ const hours = picked[tab]
const [smooth, setSmooth] = useState(false)
// Probes switched off. Hiding a slow one is what makes the fast ones readable,
// as the axis rescales to what remains.
@@ -318,17 +320,20 @@ export function NodeDetail({ node }: { node: Node }) {
// 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.
- const timeAxis = (rows: { ts: number }[], from = 0, to = rows.length - 1) => ({
- dataKey: "ts",
- type: "number" as const,
- domain: ["dataMin", "dataMax"] as const,
+ const timeAxis = (rows: { ts: number }[], from = 0, to = rows.length - 1) => {
// Explicit, or recharts places them at 05:14 and 10:22. Any that still collide
// are dropped by `minTickGap`.
- ticks: rows.length ? timeTicks(rows[from].ts, rows[to].ts) : undefined,
- tickFormatter: clockFor(hours),
- minTickGap: hours > 24 ? 72 : 40,
- ...AXIS,
- })
+ const ticks = rows.length ? timeTicks(rows[from].ts, rows[to].ts) : []
+ return {
+ dataKey: "ts",
+ type: "number" as const,
+ domain: ["dataMin", "dataMax"] as const,
+ ticks,
+ tickFormatter: tickClock(ticks, hours),
+ minTickGap: hours > 24 ? 72 : 40,
+ ...AXIS,
+ }
+ }
return (
@@ -385,12 +390,14 @@ export function NodeDetail({ node }: { node: Node }) {
))}
-
- {RANGES_FOR[tab].map((r) => (
+ {/* Wraps on a screen narrower than the seven windows of a year. */}
+
+ {ranges.map((r) => (
setRanges((all) => ({ ...all, [tab]: r.hours }))}
+ onClick={() => setPicked((all) => ({ ...all, [tab]: r.hours }))}
>
{r.label}
diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts
index 2a39a0f..6f2f333 100644
--- a/src/lib/format.test.ts
+++ b/src/lib/format.test.ts
@@ -4,7 +4,8 @@
//
// Nothing imports it, so the bundle never includes it.
import {
- axisBytes, axisTop, bytes, cpuName, cycle, daysUntil, despike, osName, pair, quarters, timeTicks, uptime,
+ axisBytes, axisTop, bytes, cpuName, cycle, daysUntil, despike, osName, pair, quarters, tickClock, timeTicks,
+ uptime, windows,
} from "./format.ts"
let failed = 0
@@ -81,6 +82,45 @@ for (const max of [3_000, 300_000, 3_000_000, 300_000_000]) {
"刻度间距均匀",
)
eq(timeTicks(to, to - day), [], "反向区间不产出刻度")
+
+ // Past what two weeks can cover, the first of a month; either way a window of
+ // days is labelled by date alone.
+ const midnight = (t: number) => new Date(t).getHours() === 0 && new Date(t).getMinutes() === 0
+ for (const days of [30, 90, 180, 365]) {
+ const wide = timeTicks(to - days * day, to)
+ eq(wide.length >= 3 && wide.length <= 8, true, `${days} 天窗 3 到 8 个刻度(得到 ${wide.length})`)
+ eq(wide.every(midnight), true, `${days} 天窗的刻度落在零点`)
+ }
+ // The zone above may have no daylight saving; New York changes on 8 March and
+ // 1 November 2026.
+ const zone = process.env.TZ
+ process.env.TZ = "America/New_York"
+ for (const end of [new Date(2026, 2, 25).getTime(), new Date(2026, 10, 20).getTime()]) {
+ for (const days of [30, 90]) {
+ const label = `纽约 ${new Date(end).getMonth() + 1} 月止的 ${days} 天窗跨夏令时仍落在零点`
+ eq(timeTicks(end - days * day, end).every(midnight), true, label)
+ }
+ }
+ if (zone === undefined) delete process.env.TZ
+ else process.env.TZ = zone
+ eq(timeTicks(to - 365 * day, to).every((t) => new Date(t).getDate() === 1), true, "一年窗的刻度落在每月 1 日")
+ const october = new Date(2026, 9, 1).getTime()
+ eq(tickClock(timeTicks(to - 365 * day, to), 8760)(october), "10/01", "全在零点的刻度只写日期")
+ eq(tickClock(timeTicks(to - 2 * day, to), 8760)(october + 6 * 3_600_000), "10/01 06:00", "缩放到两天写回时刻")
+ eq(tickClock(timeTicks(to - day, to), 24)(october + 6 * 3_600_000), "06:00", "一天之内只写时刻")
+}
+
+// windows: the round windows shorter than the history kept, then all of it.
+{
+ const hours = (days: number) => windows(days).map((w) => w.hours)
+ eq(hours(7), [1, 6, 24, 168], "保留一周与原来的四档相同")
+ eq(hours(30), [1, 6, 24, 168, 720], "默认 30 天")
+ eq(hours(90), [1, 6, 24, 168, 720, 2160], "保留 90 天")
+ eq(windows(365).at(-1), { hours: 8760, label: "1 年" }, "一年")
+ eq(windows(45).at(-1), { hours: 1080, label: "45 天" }, "不在取整档位上的保留期作为最后一档")
+ eq(hours(92), [1, 6, 24, 168, 720, 2208], "紧挨保留期的整档位不单列")
+ eq(hours(8), [1, 6, 24, 192], "8 天不再并列 7 天")
+ eq(windows(1).map((w) => w.label), ["1 小时", "6 小时", "1 天"], "一天")
}
// daysUntil: whole days, negative once past, null when there is no date.
diff --git a/src/lib/format.ts b/src/lib/format.ts
index 1cb013c..5326bde 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -115,6 +115,8 @@ const MDHHMM = new Intl.DateTimeFormat("zh-CN", {
minute: "2-digit",
})
+const MMDD = new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit" })
+
export function clock(ms: number): string {
return HHMM.format(ms)
}
@@ -127,6 +129,38 @@ export function clockFor(hours: number): (ms: number) => string {
return hours <= 24 ? clock : (ms: number) => MDHHMM.format(ms)
}
+/**
+ * The label format for an axis carrying `ticks` over a window `hours` wide: the
+ * date alone once every tick is a local midnight, where "00:00" beside each
+ * would say nothing, and `clockFor` otherwise. Decided by the ticks rather than
+ * the window, so a month dragged down to one day by the brush gets its times
+ * back.
+ */
+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)
+}
+
+// Round chart windows, in hours: up to a week they are drawn from minute rows,
+// past it from the hub's hourly tier.
+const WINDOWS = [1, 6, 24, 168, 720, 2160]
+
+/**
+ * The chart windows offered for a hub keeping `days` of history, which `/api/me`
+ * reports as `history_days`: the round windows shorter than it, then the whole
+ * of it. A window past it would be narrowed by the hub without saying so, and
+ * drawn under a label claiming more than it holds. A round window the whole
+ * exceeds by less than a quarter is left out, as it would sit beside a tab of
+ * nearly the same width: 30 and 31 days, 90 and 92.
+ */
+export function windows(days: number): { hours: number; label: string }[] {
+ const whole = Math.max(1, Math.floor(days)) * 24
+ return [...WINDOWS.filter((h) => h * 1.25 <= whole), whole].map((hours) => ({
+ hours,
+ label: hours < 24 ? `${hours} 小时` : hours === 8760 ? "1 年" : `${hours / 24} 天`,
+ }))
+}
+
/**
* Distro and CPU names as vendors write them carry mostly redundant text: a
* codename in brackets, "GNU/Linux", "(R)", a core count already printed
@@ -152,17 +186,40 @@ export function cpuName(name: string): string {
// time scale's own ticks, whatever `scale` specifies. The axis is therefore given
// the list explicitly: the smallest step from the ladder keeping the count under
// `count`, phased on local midnight so a daily tick lands on the day even in a
-// zone offset by 30 or 45 minutes.
-const TICK_STEPS = [1, 2, 5, 10, 15, 30, 60, 120, 180, 360, 720, 1440, 2880, 10080].map((m) => m * 60_000)
+// zone offset by 30 or 45 minutes. Past what two weeks can cover, on the first of
+// a month: months differ in length, so no fixed step lands on one.
+const TICK_STEPS = [1, 2, 5, 10, 15, 30, 60, 120, 180, 360, 720, 1440, 2880, 10080, 20160].map((m) => m * 60_000)
+const TICK_MONTHS = [1, 2, 3, 6, 12]
export function timeTicks(from: number, to: number, count = 8): number[] {
- const step = TICK_STEPS.find((s) => (to - from) / s <= count) ?? TICK_STEPS[TICK_STEPS.length - 1]
+ const step = TICK_STEPS.find((s) => (to - from) / s <= count)
+ if (step === undefined) return monthTicks(from, to, count)
const zone = new Date(from).getTimezoneOffset() * 60_000
const ticks: number[] = []
- for (let t = Math.ceil((from - zone) / step) * step + zone; t <= to; t += step) ticks.push(t)
+ for (let t = Math.ceil((from - zone) / step) * step + zone; t <= to; t += step) {
+ // Past a daylight-saving change a step of days lands an hour off midnight,
+ // so each such tick is set back on the nearest one.
+ const tick = step < 86_400_000 ? t : new Date(t + 43_200_000).setHours(0, 0, 0, 0)
+ if (tick <= to) ticks.push(tick)
+ }
return ticks
}
+// The first of every `every`th month within `[from, to]`, on months divisible
+// by `every` so a quarterly axis reads January, April, July.
+function monthTicks(from: number, to: number, count: number): number[] {
+ const start = new Date(from)
+ const every = TICK_MONTHS.find((k) => (to - from) / (k * 30 * 86_400_000) <= count) ?? 12
+ let month = start.getFullYear() * 12 + start.getMonth()
+ if (new Date(start.getFullYear(), start.getMonth(), 1).getTime() < from) month++
+ const ticks: number[] = []
+ for (month = Math.ceil(month / every) * every; ; month += every) {
+ const t = new Date(Math.floor(month / 12), month % 12, 1).getTime()
+ if (t > to) return ticks
+ ticks.push(t)
+ }
+}
+
/**
* A zero-anchored axis top for a quantity with no capacity to measure against: a
* utilisation percentage, a load average, a transfer rate.