From e8ce4f6b757c4ef8857f1af2d8630f1ae3802100 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:15:47 +0800 Subject: [PATCH 1/3] =?UTF-8?q?chore:=20=E4=BF=AE=E6=AD=A3=E4=B8=8E?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=B8=8D=E7=AC=A6=E7=9A=84=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=EF=BC=8C=E5=88=A0=E6=8E=89=E7=94=A8=E4=B8=8D=E5=88=B0=E7=9A=84?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=B8=8E=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 注释对齐现状:断线后轮询间隔、bytes 与 pair 的说明(本主题没有卡片布局)、时间格式化器、请求只取所需序列、延迟提示框的数据键 - 删掉 clock 包装、axisTop 不会走到的分支与默认参数、api() 没有调用者的请求体与 204 分支,axisBytes 复用 unitOf - 五个提示框共用一份设置,四个资源图共用一次算好的时间轴 - percent 不再封顶,进度条自己止于满格,流量提示改用它 - 删掉类型里没人读的字段,以及表格、输入框、按钮里从未命中的模板样式和 --secondary 等变量,CSS 从 33.6 kB 降到 30.8 kB - .tnum 换成 Tailwind 自带的 tabular-nums --- src/App.tsx | 2 +- src/components/NodeDetail.tsx | 62 +++++++++++++++++----------------- src/components/ServerTable.tsx | 16 ++++----- src/components/ui/button.tsx | 4 +-- src/components/ui/input.tsx | 3 +- src/components/ui/table.tsx | 21 ++++-------- src/index.css | 12 ------- src/lib/api.ts | 24 +++++-------- src/lib/format.ts | 51 ++++++++++++---------------- 9 files changed, 79 insertions(+), 116 deletions(-) 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..7ba8794 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -73,14 +73,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 +138,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 +273,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 +353,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 +365,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 @@ -469,6 +472,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num ), } }, [metricRows]) + const xAxis = timeAxis(metricRows, hours) return (
@@ -476,7 +480,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}} @@ -507,12 +511,11 @@ 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} /> @@ -528,12 +531,11 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num - + new Date(Number(ts)).toLocaleString("zh-CN")} + {...TOOLTIP} formatter={(v) => bytes(Number(v))} - contentStyle={TIP} /> @@ -560,10 +562,10 @@ 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)}} } @@ -571,10 +573,10 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num - + 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 +584,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)" }, @@ -614,12 +615,11 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num - + 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..573a0fe 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,7 +264,7 @@ 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.ts b/src/lib/format.ts index 09afb37..26f055b 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] /** From 6f636c8ec2e594c18871efd7b7269b1193402987 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:25:02 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E8=B5=84=E6=BA=90=E5=9B=BE=E5=9C=A8?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E7=A6=BB=E7=BA=BF=E7=9A=84=E6=97=B6=E6=AE=B5?= =?UTF-8?q?=E6=96=AD=E5=BC=80=EF=BC=9B=E7=82=B9=E5=BD=93=E5=89=8D=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E7=9A=84=E9=93=BE=E6=8E=A5=E4=B8=8D=E5=86=8D=E5=86=99?= =?UTF-8?q?=E5=85=A5=E6=B5=8F=E8=A7=88=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 资源图在节点离线的时段断开,不再画成一条直线;间隔超过常规间隔(取较小的中位数)两倍才断开,上报间隔较长的 agent 不受影响,坐标轴仍按真实数据计算 - 站名、首页、监控与节点列表里指向当前页面的链接不再重复写入浏览历史,返回键一次回到上一页 --- src/components/NodeDetail.tsx | 15 +++++++++------ src/lib/format.test.ts | 13 ++++++++++++- src/lib/format.ts | 17 +++++++++++++++++ src/lib/route.tsx | 8 ++++++-- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index 7ba8794..d0d2e3b 100644 --- a/src/components/NodeDetail.tsx +++ b/src/components/NodeDetail.tsx @@ -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" @@ -443,6 +443,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(() => { @@ -472,7 +475,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num ), } }, [metricRows]) - const xAxis = timeAxis(metricRows, hours) + const xAxis = timeAxis(chartRows, hours) return (
@@ -509,7 +512,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num
- + @@ -529,7 +532,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num title because the axis top is claiming it. */} - + @@ -571,7 +574,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num } > - + @@ -613,7 +616,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num axis tracks the window's own maximum. */} - + 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 26f055b..3ec569f 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -322,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..5fcc3cb 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 !== href) history.pushState({}, "", href) dispatchEvent(new PopStateEvent("popstate")) scrollTo(0, 0) } From b07b5761d877d6a923824efa9e3ebb7ff6a9928d Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:02:07 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E8=B5=84=E6=BA=90=E5=9B=BE=E4=B8=8A?= =?UTF-8?q?=E5=89=8D=E5=90=8E=E9=83=BD=E6=98=AF=E7=A9=BA=E6=A1=A3=E7=9A=84?= =?UTF-8?q?=E5=8D=95=E4=B8=AA=E7=82=B9=E7=94=BB=E6=88=90=E5=9C=86=E7=82=B9?= =?UTF-8?q?=EF=BC=9B=E5=AF=BC=E8=88=AA=E6=8C=89=E5=AE=8C=E6=95=B4=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E5=88=A4=E6=96=AD=E6=98=AF=E5=90=A6=E5=90=8C=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 节点离线后刚恢复、只有一个桶的数据时,这个点原来在四个资源图上都看不见,现在画成与线同色的小圆点 - 判断是否为当前页时连同查询串与锚点一起比较,从带参数的链接点站名会回到干净的地址 - 时间轴在实时推送触发的重渲染之间保持不变,四根横轴不再每次重新布局 - 删掉展开行单元格上已不起作用的 border-t-0 --- src/components/NodeDetail.tsx | 27 ++++++++++++++++++++------- src/components/ServerTable.tsx | 2 +- src/lib/route.tsx | 2 +- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/components/NodeDetail.tsx b/src/components/NodeDetail.tsx index d0d2e3b..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" @@ -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. @@ -475,7 +486,9 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num ), } }, [metricRows]) - const xAxis = timeAxis(chartRows, hours) + // 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 (
@@ -520,7 +533,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num {...TOOLTIP} formatter={(v) => [`${Number(v).toFixed(1)}%`, "CPU"]} /> - + @@ -540,7 +553,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num {...TOOLTIP} formatter={(v) => bytes(Number(v))} /> - + @@ -605,8 +618,8 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num legendType="none" /> ))} - - + + @@ -624,7 +637,7 @@ export function NodeDetail({ node, historyDays }: { node: Node; historyDays: num {...TOOLTIP} formatter={(v) => bytes(Number(v))} /> - + diff --git a/src/components/ServerTable.tsx b/src/components/ServerTable.tsx index 573a0fe..5db18e5 100644 --- a/src/components/ServerTable.tsx +++ b/src/components/ServerTable.tsx @@ -265,7 +265,7 @@ function Row({ node, index }: { node: Node; index: number }) { {open && ( - +
diff --git a/src/lib/route.tsx b/src/lib/route.tsx index 5fcc3cb..4cade3a 100644 --- a/src/lib/route.tsx +++ b/src/lib/route.tsx @@ -26,7 +26,7 @@ export function useNodeRoute() { * one more press of back. */ function navigate(href: string) { - if (location.pathname !== href) history.pushState({}, "", href) + if (location.pathname + location.search + location.hash !== href) history.pushState({}, "", href) dispatchEvent(new PopStateEvent("popstate")) scrollTo(0, 0) }