diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx index 211fd9e..d0b4312 100644 --- a/src/components/NodeCard.tsx +++ b/src/components/NodeCard.tsx @@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge" import { Card } from "@/components/ui/card" import { Meter } from "@/components/Meter" import type { Node } from "@/lib/api" -import { bytes, daysUntil, FOREVER, osName, pair, percent, rate, uptime } from "@/lib/format" +import { bytes, daysUntil, FOREVER, osName, pair, percent, rate, sinceSeen, uptime } from "@/lib/format" import { cn } from "@/lib/utils" // Emitted as files and fetched on first use, so a page carries only the flags its @@ -69,7 +69,7 @@ function deployed(node: Node) { * durations, so the badge keeps its shape either way. */ export function Status({ node }: { node: Node }) { - const down = node.last_seen ? Date.now() / 1000 - node.last_seen : 0 + const down = sinceSeen(node) const label = node.online ? `在线 ${node.metrics ? uptime(node.metrics.uptime) : ""}` : deployed(node) diff --git a/src/lib/api.ts b/src/lib/api.ts index c305b85..43ded08 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -32,6 +32,8 @@ export type Node = { /** Set by the operator; empty is ungrouped. Absent from a hub predating groups. */ group?: string last_seen: number + /** Seconds since `last_seen` on the hub's clock, null for a node never seen. Absent on older hubs. */ + last_seen_ago?: number | null metrics: Metrics | null os: string kernel: string diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts index 10f1b89..7cd2696 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, cpuName, cycle, daysUntil, despike, money, osName, pair, percent, quarters, - RATE_FLOOR, rateAxis, tickClock, timeTicks, uptime, windows, withGaps, + RATE_FLOOR, rateAxis, sinceSeen, tickClock, timeTicks, uptime, windows, withGaps, } from "./format.ts" let failed = 0 @@ -136,6 +136,11 @@ eq(RATE_FLOOR, 1024, "底是 1 KB/s") eq(windows(1).map((w) => w.label), ["1 小时", "6 小时", "1 天"], "一天") } +// sinceSeen: the hub's count wins, null included; only its absence falls back to the browser's. +eq(sinceSeen({ last_seen: 1, last_seen_ago: 120 }), 120, "按 hub 的时钟") +eq(sinceSeen({ last_seen: Date.now() / 1000 - 300, last_seen_ago: null }), 0, "hub 说从未上报,不改用浏览器计算") +eq(Math.round(sinceSeen({ last_seen: Date.now() / 1000 - 300 })), 300, "旧 hub 由浏览器计算") + // daysUntil: whole days, negative once past, null when there is no date. { const at = (days: number) => { diff --git a/src/lib/format.ts b/src/lib/format.ts index dc40680..91a5a66 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -365,3 +365,14 @@ function median(values: number[]): number { const half = sorted.length >> 1 return sorted.length % 2 ? sorted[half] : (sorted[half - 1] + sorted[half]) / 2 } + +/** + * Seconds since the node last reported, as the hub counts them: subtracted on + * the visitor's clock, a browser eight hours fast would show a node that + * dropped a minute ago as offline for eight hours. The browser's clock serves + * only a hub from before `last_seen_ago`. + */ +export function sinceSeen(node: { last_seen: number; last_seen_ago?: number | null }): number { + if (node.last_seen_ago !== undefined) return node.last_seen_ago ?? 0 + return node.last_seen ? Date.now() / 1000 - node.last_seen : 0 +}