Monitor
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..98be41e
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/src/lib/api.ts b/src/lib/api.ts
index eb422eb..c305b85 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -177,38 +177,72 @@ export function useNodes() {
let socket: WebSocket | null = null
let poll: ReturnType | null = null
let retry: ReturnType | null = null
- let stopped = false
+ let silent: ReturnType | null = null
+ // Set by `resume`. The throughput line is drawn by position, one point per
+ // push, so the samples from before the page was hidden, or the stream went
+ // silent, would join the new ones as if no time had passed in between. They
+ // are dropped on the first arrival rather than at once, which would read as
+ // 0 B/s beside figures that are merely stale.
+ let gap = false
const receive = (list: Node[]) => {
const safe = safeNodes(list)
+ if (gap) speedHistory.clear()
+ gap = false
sample(safe)
setNodes(safe)
setError(null)
setClosed(false)
}
- const fetchOnce = () =>
- api<{ nodes: Node[] }>("/nodes")
- .then((d) => receive(d.nodes))
+ // Bumped by `pause`: a request started before the page was hidden may fail
+ // or land after a fresh one, and neither result describes the page now.
+ let epoch = 0
+ const fetchOnce = () => {
+ const started = epoch
+ return api<{ nodes: Node[] }>("/nodes")
+ .then((d) => {
+ if (started === epoch) receive(d.nodes)
+ })
.catch((e: Error) => {
+ if (started !== epoch) return
setError(e.message)
if (e instanceof ApiError && e.status === 401) setClosed(true)
})
-
- fetchOnce()
+ }
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 every 5 seconds rather than 2 with no indication.
const connect = () => {
+ let opened: WebSocket
try {
- socket = new WebSocket(url)
+ opened = new WebSocket(url)
} catch {
poll ??= setInterval(fetchOnce, 5000)
return
}
- socket.onmessage = (event) => {
+ socket = opened
+ // Re-armed by every frame. Five of the hub's two-second pushes without one
+ // mean the connection died without closing, as when a NAT on the path
+ // forgets it or the hub's machine drops off the network; the browser sends
+ // nothing on it and would notice only when TCP keepalive gives up, 450 s
+ // later in Chrome. The stream is replaced rather
+ // than closed and awaited: on a dead connection the close event arrives
+ // only after the 60 s closing handshake times out. The notice stays until
+ // data arrives, since with no network the fetch started alongside may hang
+ // rather than fail.
+ const watch = () => {
+ if (silent) clearTimeout(silent)
+ silent = setTimeout(() => {
+ setError("实时数据中断,正在重新连接")
+ resume()
+ }, 10_000)
+ }
+ watch()
+ opened.onmessage = (event) => {
+ watch()
receive(JSON.parse(event.data).nodes)
// The stream has returned; the poll was only covering for it.
if (poll) {
@@ -216,20 +250,45 @@ export function useNodes() {
poll = null
}
}
- socket.onerror = () => socket?.close()
- socket.onclose = () => {
- if (stopped) return
+ opened.onerror = () => opened.close()
+ opened.onclose = () => {
+ if (silent) clearTimeout(silent)
poll ??= setInterval(fetchOnce, 5000)
retry = setTimeout(connect, 5000)
}
}
- connect()
- return () => {
- stopped = true
- socket?.close()
+ // A phone suspends a page it sends to the background and drops its
+ // connections without telling it. Back in front, the socket may still read
+ // as open while nothing arrives, or close and wait out the retry, either
+ // way leaving the figures from before; a request caught in flight fails.
+ // So a hidden page lets go of the stream and starts nothing, and a visible
+ // one fetches at once and opens a fresh stream.
+ const pause = () => {
+ epoch++
+ if (socket) {
+ socket.onclose = null
+ socket.close()
+ socket = null
+ }
if (poll) clearInterval(poll)
if (retry) clearTimeout(retry)
+ if (silent) clearTimeout(silent)
+ poll = retry = silent = null
+ }
+ const resume = () => {
+ pause()
+ gap = true
+ fetchOnce()
+ connect()
+ }
+ const visibility = () => (document.hidden ? pause() : resume())
+ document.addEventListener("visibilitychange", visibility)
+ resume()
+
+ return () => {
+ document.removeEventListener("visibilitychange", visibility)
+ pause()
}
}, [])