From 834b8dc608bbbfbac5fd3506464d14bfb4a01d3d Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:19:23 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=E5=88=87=E5=9B=9E=E5=89=8D=E5=8F=B0?= =?UTF-8?q?=E7=AB=8B=E5=8D=B3=E5=88=B7=E6=96=B0=E5=B9=B6=E9=87=8D=E8=BF=9E?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E6=8E=A8=E9=80=81=EF=BC=9B=E5=8A=A0=20iOS=20?= =?UTF-8?q?=E4=B8=BB=E5=B1=8F=E5=B9=95=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + index.html | 1 + public/apple-touch-icon.png | Bin 0 -> 3003 bytes src/lib/api.ts | 58 ++++++++++++++++++++++++++++-------- 4 files changed, 47 insertions(+), 13 deletions(-) create mode 100644 public/apple-touch-icon.png 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 0000000000000000000000000000000000000000..98be41edc218eed5637324271160a65a242c9584 GIT binary patch literal 3003 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 - 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 + opened.onmessage = (event) => { receive(JSON.parse(event.data).nodes) // The stream has returned; the poll was only covering for it. if (poll) { @@ -216,20 +224,44 @@ export function useNodes() { poll = null } } - socket.onerror = () => socket?.close() - socket.onclose = () => { + opened.onerror = () => opened.close() + opened.onclose = () => { if (stopped) return 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) + poll = retry = null + } + const resume = () => { + pause() + fetchOnce() + connect() + } + const visibility = () => (document.hidden ? pause() : resume()) + document.addEventListener("visibilitychange", visibility) + resume() + + return () => { + stopped = true + document.removeEventListener("visibilitychange", visibility) + pause() } }, []) From fd128a881f859ae2a6bf9ff1167bf9b5a1117fe0 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:46:18 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=E5=9B=9E=E5=88=B0=E5=89=8D=E5=8F=B0?= =?UTF-8?q?=E6=97=B6=E5=AE=9E=E6=97=B6=E7=BD=91=E9=80=9F=E6=9B=B2=E7=BA=BF?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E5=BC=80=E5=A7=8B=EF=BC=8C=E4=B8=8D=E6=8A=8A?= =?UTF-8?q?=E9=9A=90=E8=97=8F=E5=89=8D=E5=90=8E=E7=9A=84=E6=A0=B7=E6=9C=AC?= =?UTF-8?q?=E8=BF=9E=E6=88=90=E4=B8=80=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/api.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/api.ts b/src/lib/api.ts index dd30045..8e72c05 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -251,6 +251,10 @@ export function useNodes() { } const resume = () => { pause() + // The throughput line is drawn by position, one point per push, so the + // samples from before the page was hidden would join the new ones as if no + // time had passed in between. + speedHistory.clear() fetchOnce() connect() } From 5b0aaae4d4cf967940cf3b5e6cf8ea143025c4b2 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:06:59 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=E6=8E=A8=E9=80=81=2010=20=E7=A7=92?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E6=95=B0=E6=8D=AE=E5=B0=B1=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E4=B8=AD=E6=96=AD=E5=B9=B6=E9=87=8D=E8=BF=9E=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E7=AD=89=20TCP=20=E4=BF=9D=E6=B4=BB=E7=9A=84=207=20?= =?UTF-8?q?=E5=88=86=E5=8D=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/api.ts | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 8e72c05..ec712f9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -177,10 +177,19 @@ export function useNodes() { let socket: WebSocket | null = null let poll: ReturnType | null = null let retry: ReturnType | null = null + let silent: ReturnType | null = null let stopped = false + // 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) @@ -216,7 +225,24 @@ export function useNodes() { return } 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 phone moves between + // networks; 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) { @@ -226,6 +252,7 @@ export function useNodes() { } opened.onerror = () => opened.close() opened.onclose = () => { + if (silent) clearTimeout(silent) if (stopped) return poll ??= setInterval(fetchOnce, 5000) retry = setTimeout(connect, 5000) @@ -247,14 +274,12 @@ export function useNodes() { } if (poll) clearInterval(poll) if (retry) clearTimeout(retry) - poll = retry = null + if (silent) clearTimeout(silent) + poll = retry = silent = null } const resume = () => { pause() - // The throughput line is drawn by position, one point per push, so the - // samples from before the page was hidden would join the new ones as if no - // time had passed in between. - speedHistory.clear() + gap = true fetchOnce() connect() } From 018a89429acc1ea38703ec30902768f058eaa361 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:28:21 +0800 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20=E5=8E=BB=E6=8E=89=E5=B7=B2?= =?UTF-8?q?=E7=94=B1=20pause=20=E4=BF=9D=E8=AF=81=E7=9A=84=E5=8D=B8?= =?UTF-8?q?=E8=BD=BD=E6=A0=87=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/api.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index ec712f9..b258d43 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -178,7 +178,6 @@ export function useNodes() { let poll: ReturnType | null = null let retry: ReturnType | null = null let silent: ReturnType | null = null - let stopped = false // 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 @@ -253,7 +252,6 @@ export function useNodes() { opened.onerror = () => opened.close() opened.onclose = () => { if (silent) clearTimeout(silent) - if (stopped) return poll ??= setInterval(fetchOnce, 5000) retry = setTimeout(connect, 5000) } @@ -288,7 +286,6 @@ export function useNodes() { resume() return () => { - stopped = true document.removeEventListener("visibilitychange", visibility) pause() } From 91f6e9353de1eaffa25424a32e7c467d5b9a90d7 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:55:42 +0800 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=E6=9B=B4=E6=AD=A3=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E7=9C=8B=E9=97=A8=E7=8B=97=E6=B3=A8=E9=87=8A=E9=87=8C?= =?UTF-8?q?=E5=8D=8A=E5=BC=80=E8=BF=9E=E6=8E=A5=E7=9A=84=E6=88=90=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/api.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index b258d43..c305b85 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -225,9 +225,10 @@ export function useNodes() { } 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 phone moves between - // networks; 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 + // 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