diff --git a/README.md b/README.md index 25b49b9..2886f0b 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ MONITOR_HUB=https://hub.example.com npm run dev 本主题用 `/node/{id}` 作为详情页。hub 的回落对它够用,但**hub 前面若有按路径做正向白名单的反代 或 WAF,得把这个前缀放行**:从列表点进去只是 pushState,边缘看不见,刷新详情页才会真的请求 `/node/{id}`,症状是「点进去正常,一刷新就被拦」。 +图标 `/favicon.svg` 和 `/apple-touch-icon.png` 同样要放行,后者被拦时 iOS 收藏和添加到主屏幕都没有图标。 ## 许可 diff --git a/index.html b/index.html index 3259a41..c7bfeaa 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ + 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() } }, [])