Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -181,7 +183,7 @@ export default function App() {
<Skeleton className="h-96" />
) : selected ? (
<Suspense fallback={<Skeleton className="h-96" />}>
<NodeDetail node={selected} />
<NodeDetail node={selected} historyDays={me.history_days ?? 7} />
</Suspense>
) : (
<p className="py-16 text-center text-sm text-muted-foreground">
Expand Down
67 changes: 37 additions & 30 deletions src/components/NodeDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -47,18 +47,6 @@ type Probes = Record<string, string>
*/
type Loss = Record<string, number>

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
Expand Down Expand Up @@ -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 (
<button
onClick={onClick}
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
className={`rounded-md py-1 text-xs transition-colors ${narrow ? "px-1.5 sm:px-2.5" : "px-2.5"} ${
active ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"
}`}
>
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
<div className="space-y-4">
Expand Down Expand Up @@ -385,12 +390,14 @@ export function NodeDetail({ node }: { node: Node }) {
))}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex gap-1">
{RANGES_FOR[tab].map((r) => (
{/* Wraps on a screen narrower than the seven windows of a year. */}
<div className="flex flex-wrap gap-1">
{ranges.map((r) => (
<Tab
key={r.hours}
narrow
active={hours === r.hours}
onClick={() => setRanges((all) => ({ ...all, [tab]: r.hours }))}
onClick={() => setPicked((all) => ({ ...all, [tab]: r.hours }))}
>
{r.label}
</Tab>
Expand Down
42 changes: 41 additions & 1 deletion src/lib/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
65 changes: 61 additions & 4 deletions src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -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.
Expand Down
Loading