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
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
100 changes: 58 additions & 42 deletions src/components/NodeDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
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"
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"

Expand Down Expand Up @@ -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 : (
<circle cx={cx} cy={cy} r={2} fill={stroke} />
),
}

// 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.
Expand All @@ -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 }) {
Expand Down Expand Up @@ -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<History>(`/nodes/${id}/metrics?hours=${hours}&points=${points}&series=${series}`)
.then((next) => { if (active) setData(next) })
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -349,10 +364,10 @@ export function Latency({ id, className }: { id: number; className?: string }) {
it, and zero flattens every wobble. */}
<YAxis unit="ms" width={52} domain={["auto", "auto"]} {...AXIS} />
<Tooltip
labelFormatter={(ts) => 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
Expand All @@ -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
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -469,14 +486,17 @@ 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 (
<div className="space-y-4">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
<Dot node={node} />
<h2 className="truncate text-lg font-semibold">{node.name}</h2>
<Flag code={node.country} className="text-sm" />
<span className="tnum text-xs text-muted-foreground">
<span className="tabular-nums text-xs text-muted-foreground">
{node.online ? `在线 ${m ? uptime(m.uptime) : ""}` : deployed(node) ? `离线 ${away >= 60 ? uptime(away) : ""}` : "未接入"}
</span>
{node.agent_version && <span className="text-xs text-muted-foreground">agent {node.agent_version}</span>}
Expand Down Expand Up @@ -505,16 +525,15 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
<div className="space-y-5">
<Panel title="CPU">
<ResponsiveContainer>
<AreaChart data={metricRows}>
<AreaChart data={chartRows}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" vertical={false} />
<XAxis {...timeAxis(metricRows, hours)} />
<XAxis {...xAxis} />
<YAxis {...axes.cpu} unit="%" {...VALUE_AXIS} />
<Tooltip
labelFormatter={(ts) => new Date(Number(ts)).toLocaleString("zh-CN")}
{...TOOLTIP}
formatter={(v) => [`${Number(v).toFixed(1)}%`, "CPU"]}
contentStyle={TIP}
/>
<Area dataKey="cpu" stroke="var(--color-chart-1)" fill="var(--color-chart-1)" fillOpacity={0.15} {...SERIES} />
<Area dataKey="cpu" stroke="var(--color-chart-1)" fill="var(--color-chart-1)" fillOpacity={0.15} {...RESOURCE_SERIES} />
</AreaChart>
</ResponsiveContainer>
</Panel>
Expand All @@ -526,16 +545,15 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
title because the axis top is claiming it. */}
<Panel title={`内存 · ${bytes(node.mem_total)}`}>
<ResponsiveContainer>
<AreaChart data={metricRows}>
<AreaChart data={chartRows}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" vertical={false} />
<XAxis {...timeAxis(metricRows, hours)} />
<XAxis {...xAxis} />
<YAxis domain={[0, node.mem_total]} ticks={quarters(node.mem_total)} tickFormatter={axisBytes} {...VALUE_AXIS} />
<Tooltip
labelFormatter={(ts) => new Date(Number(ts)).toLocaleString("zh-CN")}
{...TOOLTIP}
formatter={(v) => bytes(Number(v))}
contentStyle={TIP}
/>
<Area dataKey="mem_used" name="内存" stroke="var(--color-chart-4)" fill="var(--color-chart-4)" fillOpacity={0.15} {...SERIES} />
<Area dataKey="mem_used" name="内存" stroke="var(--color-chart-4)" fill="var(--color-chart-4)" fillOpacity={0.15} {...RESOURCE_SERIES} />
</AreaChart>
</ResponsiveContainer>
</Panel>
Expand All @@ -560,29 +578,28 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
<>
网络速率
<span className="ml-3 whitespace-nowrap text-chart-2">
● 下行{peak && <span className="tnum text-muted-foreground"> 峰值 {rate(peak.rx)}</span>}
● 下行{peak && <span className="tabular-nums text-muted-foreground"> 峰值 {rate(peak.rx)}</span>}
</span>
<span className="ml-2 whitespace-nowrap text-chart-3">
● 上行{peak && <span className="tnum text-muted-foreground"> 峰值 {rate(peak.tx)}</span>}
● 上行{peak && <span className="tabular-nums text-muted-foreground"> 峰值 {rate(peak.tx)}</span>}
</span>
</>
}
>
<ResponsiveContainer>
<ComposedChart data={metricRows}>
<ComposedChart data={chartRows}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" vertical={false} />
<XAxis {...timeAxis(metricRows, hours)} />
<XAxis {...xAxis} />
<YAxis scale="log" {...axes.rate} tickFormatter={axisBytes} unit="/s" {...VALUE_AXIS} />
<Tooltip
labelFormatter={(ts) => 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) => {
const mean = item?.payload?.[`net_${item.dataKey}`]
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)" },
Expand All @@ -601,8 +618,8 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
legendType="none"
/>
))}
<Line dataKey="rx" name="下行" stroke="var(--color-chart-2)" {...SERIES} />
<Line dataKey="tx" name="上行" stroke="var(--color-chart-3)" {...SERIES} />
<Line dataKey="rx" name="下行" stroke="var(--color-chart-2)" {...RESOURCE_SERIES} />
<Line dataKey="tx" name="上行" stroke="var(--color-chart-3)" {...RESOURCE_SERIES} />
</ComposedChart>
</ResponsiveContainer>
</Panel>
Expand All @@ -612,16 +629,15 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
axis tracks the window's own maximum. */}
<Panel title={`硬盘 · ${bytes(node.disk_total)}`}>
<ResponsiveContainer>
<AreaChart data={metricRows}>
<AreaChart data={chartRows}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" vertical={false} />
<XAxis {...timeAxis(metricRows, hours)} />
<XAxis {...xAxis} />
<YAxis domain={[0, node.disk_total]} ticks={quarters(node.disk_total)} tickFormatter={axisBytes} {...VALUE_AXIS} />
<Tooltip
labelFormatter={(ts) => new Date(Number(ts)).toLocaleString("zh-CN")}
{...TOOLTIP}
formatter={(v) => bytes(Number(v))}
contentStyle={TIP}
/>
<Area dataKey="disk_used" name="硬盘" stroke="var(--color-chart-5)" fill="var(--color-chart-5)" fillOpacity={0.15} {...SERIES} />
<Area dataKey="disk_used" name="硬盘" stroke="var(--color-chart-5)" fill="var(--color-chart-5)" fillOpacity={0.15} {...RESOURCE_SERIES} />
</AreaChart>
</ResponsiveContainer>
</Panel>
Expand Down
18 changes: 9 additions & 9 deletions src/components/ServerTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -98,7 +98,7 @@ function Bar({ pct, label }: { pct: number | null; label?: string }) {
return (
<div className="relative h-5 overflow-hidden rounded bg-bar-track shadow-[inset_0_1px_2px_rgb(0_0_0/0.1)] @max-3xl:h-4">
<div className={cn("h-full rounded-l-[3px] transition-[width] duration-500", tone)} style={{ width: `${v}%` }} />
<span className="tnum absolute inset-y-0 left-1.5 flex items-center text-[10px] leading-none text-bar-text @max-3xl:left-0.5 @max-3xl:text-[8px]">
<span className="tabular-nums absolute inset-y-0 left-1.5 flex items-center text-[10px] leading-none text-bar-text @max-3xl:left-0.5 @max-3xl:text-[8px]">
{label ?? (pct === null ? "—" : `${v.toFixed(1)}%`)}
</span>
</div>
Expand Down Expand Up @@ -136,7 +136,7 @@ function Line({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="grid min-w-0 grid-cols-[5.5em_minmax(0,1fr)] gap-x-2">
<span className="text-muted-foreground">{label}</span>
<span className="tnum break-words">{children}</span>
<span className="tabular-nums break-words">{children}</span>
</div>
)
}
Expand Down Expand Up @@ -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)}
>
<TableCell className={COL.status}><Dot node={node} className="mx-auto block @max-3xl:size-2.5" /></TableCell>
<TableCell className={COL.name} title={node.name}>{node.name}</TableCell>
Expand All @@ -255,7 +255,7 @@ function Row({ node, index }: { node: Node; index: number }) {
<TableCell className={COL.bar}><Bar pct={m ? percent(m.disk_used, m.disk_total) : null} /></TableCell>
<TableCell
className={COL.traffic}
title={`本月已用 ${node.traffic_limit > 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)} · 无流量配额`}`}
>
<Bar
pct={node.traffic_limit > 0 ? percent(traffic, node.traffic_limit) : null}
Expand All @@ -264,8 +264,8 @@ function Row({ node, index }: { node: Node; index: number }) {
</TableCell>
</TableRow>
{open && (
<TableRow className={cn("border-0 hover:bg-transparent", shade)}>
<TableCell colSpan={12} className="border-t-0! p-0! text-left whitespace-normal">
<TableRow className={shade}>
<TableCell colSpan={12} className="p-0! text-left whitespace-normal">
<Details node={node} />
</TableCell>
</TableRow>
Expand Down Expand Up @@ -308,7 +308,7 @@ function ServerTable({ title, nodes }: { title: string; nodes: Node[] }) {
<section className="@container rounded-md border bg-card p-5 text-card-foreground shadow-sm max-md:p-2">
<div className="grid grid-cols-[1fr_auto_1fr] items-baseline gap-x-3 gap-y-1 px-1 pb-3 max-md:pb-2 @max-3xl:grid-cols-1">
<h2 className="min-w-0 truncate text-lg font-semibold max-md:text-sm" title={title}>{title}</h2>
<div className="tnum flex flex-wrap justify-center gap-x-3 gap-y-1 text-xs text-muted-foreground max-md:text-[10px]">
<div className="tabular-nums flex flex-wrap justify-center gap-x-3 gap-y-1 text-xs text-muted-foreground max-md:text-[10px]">
<span className="whitespace-nowrap">
在线 {nodes.filter((n) => n.online).length} / {nodes.length} · ↓ {compact(sum((n) => n.metrics!.net_rx))}/s · ↑{" "}
{compact(sum((n) => n.metrics!.net_tx))}/s
Expand All @@ -318,7 +318,7 @@ function ServerTable({ title, nodes }: { title: string; nodes: Node[] }) {
</div>
<Table className="text-center text-sm @max-3xl:table-fixed @max-3xl:text-[10px]">
<TableHeader>
<TableRow className="border-0 hover:bg-transparent">
<TableRow>
{heads.map(([col, label], i) => (
<TableHead key={i} className={cn("h-8 border-t px-1.5 text-center font-semibold @max-3xl:px-0.5", COL[col])}>
{label}
Expand Down
Loading
Loading