From f4f0af576acef47d2182efd7a20e194e1a34ebcf Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 9 May 2026 17:30:31 +0200 Subject: [PATCH] feat(ws): live updates on inbounds/xray/nodes pages, drop polling + manual refresh Replaces the legacy polling + manual-refresh model with WebSocket pushes across the three live-data pages. The hub already broadcast traffic / client_stats / outbounds; this wires the frontend to consume them and adds a new `nodes` channel for the heartbeat job's snapshot. Frontend - new useWebSocket composable: page-scoped singleton WebSocketClient, lifecycle-managed on/off, leaves disconnect to page-unload - inbounds: useInbounds gains applyTrafficEvent / applyClientStatsEvent / applyInvalidate that merge counters and online/lastOnline in place; InboundsPage subscribes; InboundList drops the auto-refresh popover, the refresh button, and the now-unused refreshing prop - xray outbounds: useXraySetting gains applyOutboundsEvent; XrayPage subscribes; OutboundsTab drops the refresh button + emit - nodes: useNodes gains applyNodesEvent and stops the 5s setInterval/visibilitychange polling; NodesPage subscribes; NodeList drops the refresh button and ReloadOutlined import Backend - web/websocket: new MessageTypeNodes + BroadcastNodes notifier - node_heartbeat_job: after wg.Wait(), reload the table once and BroadcastNodes(updated). Gated on websocket.HasClients() so a panel with no open browser doesn't spend the DB read Bug fixes spotted in this pass - websocket.js #buildUrl defaulted basePath to '' when the global was missing (dev mode), producing `ws://host:portws` and a SyntaxError on the WebSocket constructor. Fall back to '/' and ensure leading slash. - vite.config.js: forward /ws to ws://localhost:2053 with ws:true so dev (5173) reaches the Go backend's WebSocket - NodeFormModal: a-input-password's visibilityToggle is Boolean in AntD Vue 4; the v3-era object form (`{ visible, 'onUpdate:visible' }`) triggered a Vue prop-type warning. Drop the override (default true shows the eye icon and toggles internally) and remove the orphaned tokenVisible ref Translations - pages.inbounds.autoRefresh / autoRefreshInterval: removed from all 13 locales (UI gone) - pages.nodes.refresh: removed from all 13 locales (UI gone) Co-Authored-By: Claude Opus 4.7 --- frontend/src/api/websocket.js | 10 +- frontend/src/composables/useWebSocket.js | 48 ++++++++ frontend/src/pages/inbounds/InboundList.vue | 73 +----------- frontend/src/pages/inbounds/InboundsPage.vue | 17 ++- frontend/src/pages/inbounds/useInbounds.js | 110 ++++++++++++++++++- frontend/src/pages/nodes/NodeFormModal.vue | 3 - frontend/src/pages/nodes/NodeList.vue | 16 +-- frontend/src/pages/nodes/NodesPage.vue | 7 +- frontend/src/pages/nodes/useNodes.js | 58 ++++------ frontend/src/pages/xray/OutboundsTab.vue | 36 ++---- frontend/src/pages/xray/XrayPage.vue | 7 +- frontend/src/pages/xray/useXraySetting.js | 8 ++ frontend/vite.config.js | 25 +++-- web/job/node_heartbeat_job.go | 15 +++ web/translation/ar-EG.json | 3 - web/translation/en-US.json | 3 - web/translation/es-ES.json | 3 - web/translation/fa-IR.json | 3 - web/translation/id-ID.json | 3 - web/translation/ja-JP.json | 3 - web/translation/pt-BR.json | 3 - web/translation/ru-RU.json | 3 - web/translation/tr-TR.json | 3 - web/translation/uk-UA.json | 3 - web/translation/vi-VN.json | 3 - web/translation/zh-CN.json | 3 - web/translation/zh-TW.json | 3 - web/websocket/hub.go | 1 + web/websocket/notifier.go | 9 ++ 29 files changed, 275 insertions(+), 207 deletions(-) create mode 100644 frontend/src/composables/useWebSocket.js diff --git a/frontend/src/api/websocket.js b/frontend/src/api/websocket.js index 92c32e77..5076e53e 100644 --- a/frontend/src/api/websocket.js +++ b/frontend/src/api/websocket.js @@ -140,8 +140,14 @@ export class WebSocketClient { #buildUrl() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - let basePath = this.basePath || ''; - if (basePath && !basePath.endsWith('/')) basePath += '/'; + // basePath comes from window.__X_UI_BASE_PATH__ which is only injected + // by the Go binary in production. In dev (Vite serves directly) the + // global is missing and basePath would be '' — without the fallback to + // '/' we'd build `ws://host:portws` (no separator) and the WebSocket + // constructor throws a SyntaxError. + let basePath = this.basePath || '/'; + if (!basePath.startsWith('/')) basePath = '/' + basePath; + if (!basePath.endsWith('/')) basePath += '/'; return `${protocol}//${window.location.host}${basePath}ws`; } diff --git a/frontend/src/composables/useWebSocket.js b/frontend/src/composables/useWebSocket.js new file mode 100644 index 00000000..a4810d21 --- /dev/null +++ b/frontend/src/composables/useWebSocket.js @@ -0,0 +1,48 @@ +import { onBeforeUnmount, onMounted } from 'vue'; +import { WebSocketClient } from '@/api/websocket.js'; + +// One client per browser tab (= per multi-page entry). WebSocketClient is +// idempotent: repeated connect() calls while the socket is already open +// are no-ops, so multiple components on the same page can share a single +// underlying connection without each spawning their own. +let sharedClient = null; + +function getSharedClient() { + if (sharedClient) return sharedClient; + const basePath = (typeof window !== 'undefined' && window.__X_UI_BASE_PATH__) || ''; + sharedClient = new WebSocketClient(basePath); + return sharedClient; +} + +// useWebSocket lets a Vue component subscribe to live server-pushed +// events. Pass a map of { eventName: handler } and the composable wires +// connect()/disconnect() into the component lifecycle and unsubscribes +// every handler on unmount so a stale closure can't fire after the +// page has moved on. +// +// Example: +// useWebSocket({ +// traffic: (payload) => applyTrafficEvent(payload), +// client_stats: (payload) => applyClientStatsEvent(payload), +// invalidate: ({ dataType }) => { if (dataType === 'inbounds') refresh(); }, +// }); +// +// Built-in lifecycle events ('connected' / 'disconnected' / 'error') +// can be subscribed to alongside server-emitted types. +export function useWebSocket(handlers) { + const client = getSharedClient(); + const entries = Object.entries(handlers || {}); + + onMounted(() => { + for (const [event, fn] of entries) client.on(event, fn); + client.connect(); + }); + + onBeforeUnmount(() => { + for (const [event, fn] of entries) client.off(event, fn); + // Don't disconnect — another mounted component on the same page may + // still be subscribed. The client closes naturally on page unload. + }); + + return { client }; +} diff --git a/frontend/src/pages/inbounds/InboundList.vue b/frontend/src/pages/inbounds/InboundList.vue index f473dd33..09b583d6 100644 --- a/frontend/src/pages/inbounds/InboundList.vue +++ b/frontend/src/pages/inbounds/InboundList.vue @@ -1,11 +1,9 @@