Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 收藏和添加到主屏幕都没有图标。

## 许可

Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Monitor</title>
</head>
<body>
Expand Down
Binary file added public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
89 changes: 74 additions & 15 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,59 +177,118 @@ export function useNodes() {
let socket: WebSocket | null = null
let poll: ReturnType<typeof setInterval> | null = null
let retry: ReturnType<typeof setTimeout> | null = null
let stopped = false
let silent: ReturnType<typeof setTimeout> | 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) {
clearInterval(poll)
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()
}
}, [])

Expand Down
Loading