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 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-09 17:30:31 +02:00
parent d3dcd1d8bd
commit f4f0af576a
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
29 changed files with 275 additions and 207 deletions

View file

@ -140,8 +140,14 @@ export class WebSocketClient {
#buildUrl() { #buildUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let basePath = this.basePath || ''; // basePath comes from window.__X_UI_BASE_PATH__ which is only injected
if (basePath && !basePath.endsWith('/')) basePath += '/'; // 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`; return `${protocol}//${window.location.host}${basePath}ws`;
} }

View file

@ -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 };
}

View file

@ -1,11 +1,9 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { import {
PlusOutlined, PlusOutlined,
MenuOutlined, MenuOutlined,
SyncOutlined,
DownOutlined,
SearchOutlined, SearchOutlined,
FilterOutlined, FilterOutlined,
MoreOutlined, MoreOutlined,
@ -41,7 +39,6 @@ const props = defineProps({
clientCount: { type: Object, required: true }, clientCount: { type: Object, required: true },
onlineClients: { type: Array, required: true }, onlineClients: { type: Array, required: true },
lastOnlineMap: { type: Object, default: () => ({}) }, lastOnlineMap: { type: Object, default: () => ({}) },
refreshing: { type: Boolean, default: false },
expireDiff: { type: Number, default: 0 }, expireDiff: { type: Number, default: 0 },
trafficDiff: { type: Number, default: 0 }, trafficDiff: { type: Number, default: 0 },
pageSize: { type: Number, default: 0 }, pageSize: { type: Number, default: 0 },
@ -72,35 +69,6 @@ const enableFilter = ref(false);
const searchKey = ref(''); const searchKey = ref('');
const filterBy = ref(''); const filterBy = ref('');
// Auto-refresh same defaults as legacy (5s, opt-in via switch).
const isRefreshEnabled = ref(localStorage.getItem('isRefreshEnabled') === 'true');
const refreshIntervalMs = ref(Number(localStorage.getItem('refreshInterval')) || 5000);
let timer = null;
function startAutoRefresh() {
stopAutoRefresh();
timer = setInterval(() => emit('refresh'), refreshIntervalMs.value);
}
function stopAutoRefresh() {
if (timer != null) {
clearInterval(timer);
timer = null;
}
}
watch(isRefreshEnabled, (next) => {
localStorage.setItem('isRefreshEnabled', String(next));
if (next) startAutoRefresh();
else stopAutoRefresh();
}, { immediate: true });
watch(refreshIntervalMs, (next) => {
localStorage.setItem('refreshInterval', String(next));
if (isRefreshEnabled.value) startAutoRefresh();
});
// Without this, a stale setInterval keeps firing emit('refresh') after
// the component unmounts, which Vue surfaces as "emitsOptions" /
// "__asyncLoader" exceptions on the next tick.
onBeforeUnmount(stopAutoRefresh);
// Toggle the filter mode flip cleans the other input. // Toggle the filter mode flip cleans the other input.
function onToggleFilter() { function onToggleFilter() {
if (enableFilter.value) searchKey.value = ''; if (enableFilter.value) searchKey.value = '';
@ -257,39 +225,6 @@ function showQrCodeMenu(dbInbound) {
</a-space> </a-space>
</template> </template>
<template #extra>
<a-button-group>
<a-button :loading="refreshing" @click="emit('refresh')">
<template #icon>
<SyncOutlined />
</template>
</a-button>
<a-popover placement="bottomRight" trigger="click">
<template #title>
<div class="auto-refresh-title">
<a-switch v-model:checked="isRefreshEnabled" size="small" />
<span>{{ t('pages.inbounds.autoRefresh') }}</span>
</div>
</template>
<template #content>
<a-space direction="vertical">
<span>{{ t('pages.inbounds.autoRefreshInterval') }}</span>
<a-select v-model:value="refreshIntervalMs" :disabled="!isRefreshEnabled" :style="{ width: '100%' }">
<a-select-option v-for="key in [5, 10, 30, 60]" :key="key" :value="key * 1000">
{{ key }}s
</a-select-option>
</a-select>
</a-space>
</template>
<a-button>
<template #icon>
<DownOutlined />
</template>
</a-button>
</a-popover>
</a-button-group>
</template>
<a-space direction="vertical" :style="{ width: '100%' }"> <a-space direction="vertical" :style="{ width: '100%' }">
<!-- Search / filter toolbar --> <!-- Search / filter toolbar -->
<div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'"> <div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
@ -548,12 +483,6 @@ function showQrCodeMenu(dbInbound) {
</template> </template>
<style scoped> <style scoped>
.auto-refresh-title {
display: flex;
align-items: center;
gap: 8px;
}
.filter-bar { .filter-bar {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -26,12 +26,12 @@ import QrCodeModal from './QrCodeModal.vue';
import TextModal from '@/components/TextModal.vue'; import TextModal from '@/components/TextModal.vue';
import PromptModal from '@/components/PromptModal.vue'; import PromptModal from '@/components/PromptModal.vue';
import { useInbounds } from './useInbounds.js'; import { useInbounds } from './useInbounds.js';
import { useWebSocket } from '@/composables/useWebSocket.js';
const { t } = useI18n(); const { t } = useI18n();
const { const {
fetched, fetched,
refreshing,
dbInbounds, dbInbounds,
clientCount, clientCount,
onlineClients, onlineClients,
@ -46,7 +46,20 @@ const {
lastOnlineMap, lastOnlineMap,
refresh, refresh,
fetchDefaultSettings, fetchDefaultSettings,
applyTrafficEvent,
applyClientStatsEvent,
applyInvalidate,
} = useInbounds(); } = useInbounds();
// Live updates over WebSocket replaces the old 5s polling loop.
// The backend pushes traffic + per-client deltas every ~10s; we merge
// them into the local refs in-place so counters and online badges
// update without re-fetching the whole list.
useWebSocket({
traffic: applyTrafficEvent,
client_stats: applyClientStatsEvent,
invalidate: applyInvalidate,
});
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
// Node list lives on the central panel; the Inbounds page consumes // Node list lives on the central panel; the Inbounds page consumes
// the idnode map for the new "Node" column. Fetched once on mount. // the idnode map for the new "Node" column. Fetched once on mount.
@ -592,7 +605,7 @@ function onRowAction({ key, dbInbound }) {
<!-- Inbound list toolbar, search/filter, columns, row actions --> <!-- Inbound list toolbar, search/filter, columns, row actions -->
<a-col :span="24"> <a-col :span="24">
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients" <InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :refreshing="refreshing" :last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark"
:expire-diff="expireDiff" :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile" :expire-diff="expireDiff" :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh" @add-inbound="onAddInbound" :sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh" @add-inbound="onAddInbound"
@general-action="onGeneralAction" @row-action="onRowAction" @edit-client="onEditClient" @general-action="onGeneralAction" @row-action="onRowAction" @edit-client="onEditClient"

View file

@ -2,8 +2,10 @@
// last-online-map, default settings) and computes the per-inbound client // last-online-map, default settings) and computes the per-inbound client
// roll-ups the legacy panel surfaces in the popovers. // roll-ups the legacy panel surfaces in the popovers.
// //
// 5f-i scope: plain GET on mount + a manual refresh; auto-refresh and the // Live-update model: initial GET on mount, then the WebSocket delta path
// WebSocket delta path are deferred to a later subphase. // keeps the table fresh — the page subscribes to the server's `traffic`,
// `client_stats`, and `invalidate` events and merges them into local
// refs in-place. The manual refresh button is kept as a fallback.
import { computed, ref, shallowRef } from 'vue'; import { computed, ref, shallowRef } from 'vue';
import { HttpUtil, ObjectUtil } from '@/utils'; import { HttpUtil, ObjectUtil } from '@/utils';
@ -151,6 +153,107 @@ export function useInbounds() {
ipLimitEnable.value = !!s.ipLimitEnable; ipLimitEnable.value = !!s.ipLimitEnable;
} }
// ============ WebSocket live-update merge ===========================
// The xray traffic job and the node traffic sync job each broadcast
// a `traffic` payload every ~10s. We merge it into onlineClients +
// lastOnlineMap; per-inbound counters arrive in the parallel
// client_stats event below.
function applyTrafficEvent(payload) {
if (!payload || typeof payload !== 'object') return;
if (Array.isArray(payload.onlineClients)) {
onlineClients.value = payload.onlineClients;
}
if (payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
// Merge so a subsequent payload that drops a quiet client doesn't
// wipe their last-seen timestamp.
lastOnlineMap.value = { ...lastOnlineMap.value, ...payload.lastOnlineMap };
}
// Recompute per-inbound rollups so the "online" badges in the
// expand-row table flip without waiting for a full refresh.
rebuildClientCount();
}
// The client_stats payload carries absolute traffic counters for the
// clients that had activity in the latest window plus per-inbound
// totals. Both are absolute (not deltas), so we overwrite in place.
function applyClientStatsEvent(payload) {
if (!payload || typeof payload !== 'object') return;
let touched = false;
if (Array.isArray(payload.inbounds) && payload.inbounds.length > 0) {
const byId = new Map();
for (const row of payload.inbounds) {
if (row && row.id != null) byId.set(row.id, row);
}
for (const ib of dbInbounds.value) {
const upd = byId.get(ib.id);
if (!upd) continue;
if (typeof upd.up === 'number') ib.up = upd.up;
if (typeof upd.down === 'number') ib.down = upd.down;
if (typeof upd.allTime === 'number') ib.allTime = upd.allTime;
touched = true;
}
}
if (Array.isArray(payload.clients) && payload.clients.length > 0) {
const byEmail = new Map();
for (const row of payload.clients) {
if (row && row.email) byEmail.set(row.email, row);
}
for (const ib of dbInbounds.value) {
if (!Array.isArray(ib.clientStats)) continue;
for (let i = 0; i < ib.clientStats.length; i++) {
const stat = ib.clientStats[i];
const upd = byEmail.get(stat.email);
if (!upd) continue;
if (typeof upd.up === 'number') stat.up = upd.up;
if (typeof upd.down === 'number') stat.down = upd.down;
if (typeof upd.total === 'number') stat.total = upd.total;
if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
touched = true;
}
}
}
if (touched) {
// shallowRef → trigger reactivity by reassigning the same array.
dbInbounds.value = [...dbInbounds.value];
rebuildClientCount();
}
}
// The hub may decide a payload is too large to push directly and emit
// an `invalidate` event with the affected dataType instead. For the
// inbounds page that means "the inbound list changed elsewhere — go
// re-fetch via REST".
function applyInvalidate(payload) {
if (!payload || typeof payload !== 'object') return;
if (payload.dataType === 'inbounds') {
refresh();
}
}
// Recompute the per-inbound roll-up after any in-place mutation.
// Cheap because rollupClients only iterates a single inbound's
// clients + clientStats arrays.
function rebuildClientCount() {
const counts = {};
const tracked = [
Protocols.VMESS,
Protocols.VLESS,
Protocols.TROJAN,
Protocols.SHADOWSOCKS,
Protocols.HYSTERIA,
];
for (const dbInbound of dbInbounds.value) {
const parsed = dbInbound.toInbound();
if (!tracked.includes(dbInbound.protocol)) continue;
if (dbInbound.isSS && !parsed.isSSMultiUser) continue;
counts[dbInbound.id] = rollupClients(dbInbound, parsed);
}
clientCount.value = counts;
}
async function refresh() { async function refresh() {
refreshing.value = true; refreshing.value = true;
try { try {
@ -213,5 +316,8 @@ export function useInbounds() {
pageSize, pageSize,
refresh, refresh,
fetchDefaultSettings, fetchDefaultSettings,
applyTrafficEvent,
applyClientStatsEvent,
applyInvalidate,
}; };
} }

View file

@ -35,8 +35,6 @@ const form = reactive(defaultForm());
const submitting = ref(false); const submitting = ref(false);
const testing = ref(false); const testing = ref(false);
const testResult = ref(null); // { status, latencyMs, xrayVersion, error } const testResult = ref(null); // { status, latencyMs, xrayVersion, error }
const tokenVisible = ref(false);
// Reset the form whenever the modal is opened. In edit mode we copy // Reset the form whenever the modal is opened. In edit mode we copy
// the existing node into the form fields; in add mode we wipe back // the existing node into the form fields; in add mode we wipe back
// to defaults so a previous edit doesn't leak through. // to defaults so a previous edit doesn't leak through.
@ -175,7 +173,6 @@ async function onSave() {
<a-form-item :label="t('pages.nodes.apiToken')" required> <a-form-item :label="t('pages.nodes.apiToken')" required>
<a-input-password <a-input-password
v-model:value="form.apiToken" v-model:value="form.apiToken"
:visibility-toggle="{ visible: tokenVisible, 'onUpdate:visible': (v) => (tokenVisible = v) }"
:placeholder="t('pages.nodes.apiTokenPlaceholder')" :placeholder="t('pages.nodes.apiTokenPlaceholder')"
/> />
<div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div> <div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div>

View file

@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
import { import {
EditOutlined, EditOutlined,
DeleteOutlined, DeleteOutlined,
ReloadOutlined,
PlusOutlined, PlusOutlined,
ThunderboltOutlined, ThunderboltOutlined,
ExclamationCircleOutlined, ExclamationCircleOutlined,
@ -23,7 +22,6 @@ const emit = defineEmits([
'delete', 'delete',
'probe', 'probe',
'toggle-enable', 'toggle-enable',
'refresh',
]); ]);
const { t } = useI18n(); const { t } = useI18n();
@ -77,16 +75,10 @@ function formatPct(p) {
<template> <template>
<a-card size="small" hoverable> <a-card size="small" hoverable>
<div class="toolbar"> <div class="toolbar">
<a-space> <a-button type="primary" @click="emit('add')">
<a-button type="primary" @click="emit('add')"> <template #icon><PlusOutlined /></template>
<template #icon><PlusOutlined /></template> {{ t('pages.nodes.addNode') }}
{{ t('pages.nodes.addNode') }} </a-button>
</a-button>
<a-button :loading="loading" @click="emit('refresh')">
<template #icon><ReloadOutlined /></template>
{{ t('pages.nodes.refresh') }}
</a-button>
</a-space>
</div> </div>
<a-table <a-table

View file

@ -16,6 +16,7 @@ import CustomStatistic from '@/components/CustomStatistic.vue';
import NodeList from './NodeList.vue'; import NodeList from './NodeList.vue';
import NodeFormModal from './NodeFormModal.vue'; import NodeFormModal from './NodeFormModal.vue';
import { useNodes } from './useNodes.js'; import { useNodes } from './useNodes.js';
import { useWebSocket } from '@/composables/useWebSocket.js';
const { t } = useI18n(); const { t } = useI18n();
@ -24,7 +25,7 @@ const {
loading, loading,
fetched, fetched,
totals, totals,
refresh, applyNodesEvent,
create, create,
update, update,
remove, remove,
@ -33,6 +34,9 @@ const {
probe, probe,
} = useNodes(); } = useNodes();
// Live updates NodeHeartbeatJob pushes the fresh list every 10s.
useWebSocket({ nodes: applyNodesEvent });
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const basePath = window.__X_UI_BASE_PATH__ || ''; const basePath = window.__X_UI_BASE_PATH__ || '';
@ -167,7 +171,6 @@ async function onToggleEnable(node, next) {
@delete="onDelete" @delete="onDelete"
@probe="onProbe" @probe="onProbe"
@toggle-enable="onToggleEnable" @toggle-enable="onToggleEnable"
@refresh="refresh"
/> />
</a-col> </a-col>
</a-row> </a-row>

View file

@ -1,20 +1,15 @@
// Loads the node list and runs CRUD/probe actions against the // Loads the node list and runs CRUD/probe actions against the
// /panel/api/nodes/* endpoints. Polls every 5s while the page is // /panel/api/nodes/* endpoints. Live updates arrive over WebSocket
// visible so heartbeat status stays fresh without a WebSocket. // (pushed by NodeHeartbeatJob every 10s) so we don't poll.
import { computed, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'; import { computed, onMounted, ref, shallowRef } from 'vue';
import { HttpUtil } from '@/utils'; import { HttpUtil } from '@/utils';
const POLL_INTERVAL_MS = 5000;
export function useNodes() { export function useNodes() {
const nodes = shallowRef([]); const nodes = shallowRef([]);
const loading = ref(false); const loading = ref(false);
const fetched = ref(false); const fetched = ref(false);
let pollTimer = null;
let pageVisible = true;
async function refresh() { async function refresh() {
loading.value = true; loading.value = true;
try { try {
@ -28,6 +23,16 @@ export function useNodes() {
} }
} }
// Replaces the local list with the snapshot pushed by the heartbeat job.
// shallowRef means a fresh assignment is enough to retrigger reactivity;
// we always assign a new array so Vue notices.
function applyNodesEvent(payload) {
if (Array.isArray(payload)) {
nodes.value = payload;
if (!fetched.value) fetched.value = true;
}
}
async function create(payload) { async function create(payload) {
const msg = await HttpUtil.post('/panel/api/nodes/add', payload); const msg = await HttpUtil.post('/panel/api/nodes/add', payload);
if (msg?.success) await refresh(); if (msg?.success) await refresh();
@ -66,8 +71,8 @@ export function useNodes() {
return msg; return msg;
} }
// Aggregate cards on the dashboard. Computed off the live list so // Aggregate cards on the dashboard. Computed off the live list so a
// a refresh picks up new totals automatically. // refresh (or a WS push) picks up new totals automatically.
const totals = computed(() => { const totals = computed(() => {
const list = nodes.value; const list = nodes.value;
let online = 0; let online = 0;
@ -94,35 +99,9 @@ export function useNodes() {
}; };
}); });
function startPolling() { // Initial fetch — WebSocket takes over after the first heartbeat tick
if (pollTimer) return; // (~10s) but the page should populate immediately on mount.
pollTimer = setInterval(() => { onMounted(refresh);
if (pageVisible) refresh();
}, POLL_INTERVAL_MS);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function onVisibilityChange() {
pageVisible = !document.hidden;
if (pageVisible) refresh();
}
onMounted(() => {
refresh();
startPolling();
document.addEventListener('visibilitychange', onVisibilityChange);
});
onBeforeUnmount(() => {
stopPolling();
document.removeEventListener('visibilitychange', onVisibilityChange);
});
return { return {
nodes, nodes,
@ -130,6 +109,7 @@ export function useNodes() {
fetched, fetched,
totals, totals,
refresh, refresh,
applyNodesEvent,
create, create,
update, update,
remove, remove,

View file

@ -5,7 +5,6 @@ import {
PlusOutlined, PlusOutlined,
CloudOutlined, CloudOutlined,
ApiOutlined, ApiOutlined,
SyncOutlined,
RetweetOutlined, RetweetOutlined,
MoreOutlined, MoreOutlined,
EditOutlined, EditOutlined,
@ -39,9 +38,7 @@ const props = defineProps({
isMobile: { type: Boolean, default: false }, isMobile: { type: Boolean, default: false },
}); });
const emit = defineEmits(['refresh-traffic', 'reset-traffic', 'test', 'show-warp', 'show-nord']); const emit = defineEmits(['reset-traffic', 'test', 'show-warp', 'show-nord']);
const refreshing = ref(false);
// === Modal state ==================================================== // === Modal state ====================================================
const modalOpen = ref(false); const modalOpen = ref(false);
@ -97,12 +94,6 @@ function moveDown(idx) {
[arr[idx + 1], arr[idx]] = [arr[idx], arr[idx + 1]]; [arr[idx + 1], arr[idx]] = [arr[idx], arr[idx + 1]];
} }
async function onRefresh() {
refreshing.value = true;
try { emit('refresh-traffic'); }
finally { setTimeout(() => { refreshing.value = false; }, 500); }
}
// === Per-row helpers ================================================ // === Per-row helpers ================================================
function trafficFor(o) { function trafficFor(o) {
const t = props.outboundsTraffic.find((x) => x.tag === o.tag); const t = props.outboundsTraffic.find((x) => x.tag === o.tag);
@ -188,22 +179,17 @@ const rows = computed(() => {
</a-space> </a-space>
</a-col> </a-col>
<a-col :xs="24" :sm="10" class="toolbar-right"> <a-col :xs="24" :sm="10" class="toolbar-right">
<a-button-group> <a-popconfirm
<a-button :loading="refreshing" @click="onRefresh"> placement="topRight"
<template #icon><SyncOutlined /></template> :ok-text="t('reset')"
:cancel-text="t('cancel')"
:title="t('pages.inbounds.resetAllTrafficContent')"
@confirm="emit('reset-traffic', '-alltags-')"
>
<a-button>
<template #icon><RetweetOutlined /></template>
</a-button> </a-button>
<a-popconfirm </a-popconfirm>
placement="topRight"
:ok-text="t('reset')"
:cancel-text="t('cancel')"
:title="t('pages.inbounds.resetAllTrafficContent')"
@confirm="emit('reset-traffic', '-alltags-')"
>
<a-button>
<template #icon><RetweetOutlined /></template>
</a-button>
</a-popconfirm>
</a-button-group>
</a-col> </a-col>
</a-row> </a-row>

View file

@ -23,6 +23,7 @@ import DnsTab from './DnsTab.vue';
import WarpModal from './WarpModal.vue'; import WarpModal from './WarpModal.vue';
import NordModal from './NordModal.vue'; import NordModal from './NordModal.vue';
import { useXraySetting } from './useXraySetting.js'; import { useXraySetting } from './useXraySetting.js';
import { useWebSocket } from '@/composables/useWebSocket.js';
const { t } = useI18n(); const { t } = useI18n();
@ -40,14 +41,17 @@ const {
outboundsTraffic, outboundsTraffic,
outboundTestStates, outboundTestStates,
fetchAll, fetchAll,
fetchOutboundsTraffic,
resetOutboundsTraffic, resetOutboundsTraffic,
testOutbound, testOutbound,
saveAll, saveAll,
resetToDefault, resetToDefault,
restartXray, restartXray,
applyOutboundsEvent,
} = useXraySetting(); } = useXraySetting();
// Live outbounds traffic pushed by xray_traffic_job every ~10s.
useWebSocket({ outbounds: applyOutboundsEvent });
async function onTestOutbound(idx) { async function onTestOutbound(idx) {
const outbound = templateSettings.value?.outbounds?.[idx]; const outbound = templateSettings.value?.outbounds?.[idx];
if (outbound) await testOutbound(idx, outbound); if (outbound) await testOutbound(idx, outbound);
@ -291,7 +295,6 @@ function confirmRestart() {
:outbounds-traffic="outboundsTraffic" :outbounds-traffic="outboundsTraffic"
:outbound-test-states="outboundTestStates" :outbound-test-states="outboundTestStates"
:is-mobile="isMobile" :is-mobile="isMobile"
@refresh-traffic="fetchOutboundsTraffic"
@reset-traffic="resetOutboundsTraffic" @reset-traffic="resetOutboundsTraffic"
@test="onTestOutbound" @test="onTestOutbound"
@show-warp="showWarp" @show-warp="showWarp"

View file

@ -133,6 +133,13 @@ export function useXraySetting() {
if (msg?.success) await fetchOutboundsTraffic(); if (msg?.success) await fetchOutboundsTraffic();
} }
// Merges a WebSocket `outbounds` event into outboundsTraffic in place.
// The xray traffic job pushes the full snapshot every ~10s so the user
// doesn't have to click the (now-removed) refresh button.
function applyOutboundsEvent(payload) {
if (Array.isArray(payload)) outboundsTraffic.value = payload;
}
async function testOutbound(index, outbound) { async function testOutbound(index, outbound) {
if (!outbound) return null; if (!outbound) return null;
if (!outboundTestStates.value[index]) outboundTestStates.value[index] = {}; if (!outboundTestStates.value[index]) outboundTestStates.value[index] = {};
@ -230,6 +237,7 @@ export function useXraySetting() {
fetchAll, fetchAll,
fetchOutboundsTraffic, fetchOutboundsTraffic,
resetOutboundsTraffic, resetOutboundsTraffic,
applyOutboundsEvent,
testOutbound, testOutbound,
saveAll, saveAll,
resetToDefault, resetToDefault,

View file

@ -145,12 +145,23 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
strictPort: true, strictPort: true,
proxy: makeBackendProxy('http://localhost:2053', [ proxy: {
// Patterns are anchored regex so /login.html and /index.html ...makeBackendProxy('http://localhost:2053', [
// (which Vite serves itself) are NOT forwarded — only the bare // Patterns are anchored regex so /login.html and /index.html
// backend paths and their sub-routes. // (which Vite serves itself) are NOT forwarded — only the bare
'^/(login|logout|getTwoFactorEnable|csrf-token)$', // backend paths and their sub-routes.
'^/(panel|server)(/|$)', '^/(login|logout|getTwoFactorEnable|csrf-token)$',
]), '^/(panel|server)(/|$)',
]),
// The panel mounts the live-update WebSocket at /ws (basePath +
// "/ws"). Vite needs `ws: true` to forward the HTTP Upgrade to the
// Go backend; without it the dev server would 404 the upgrade and
// the page falls back to the no-data state.
'/ws': {
target: 'ws://localhost:2053',
ws: true,
changeOrigin: true,
},
},
}, },
}); });

View file

@ -8,6 +8,7 @@ import (
"github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/websocket"
) )
// nodeHeartbeatConcurrency caps how many remote panels we probe at once. // nodeHeartbeatConcurrency caps how many remote panels we probe at once.
@ -69,6 +70,20 @@ func (j *NodeHeartbeatJob) Run() {
}(n) }(n)
} }
wg.Wait() wg.Wait()
// Push the fresh list to any open Nodes page over WebSocket so the
// status / latency / cpu / mem cells update without the user clicking
// refresh. Skip the DB read entirely when no browser is connected —
// matches the gating pattern in xray_traffic_job.
if !websocket.HasClients() {
return
}
updated, err := j.nodeService.GetAll()
if err != nil {
logger.Warning("node heartbeat: load nodes for broadcast failed:", err)
return
}
websocket.BroadcastNodes(updated)
} }
// probeOne runs a single probe and persists the result. We deliberately // probeOne runs a single probe and persists the result. We deliberately

View file

@ -248,8 +248,6 @@
"resetTraffic": "إعادة ضبط الترافيك", "resetTraffic": "إعادة ضبط الترافيك",
"addInbound": "أضف إدخال", "addInbound": "أضف إدخال",
"generalActions": "إجراءات عامة", "generalActions": "إجراءات عامة",
"autoRefresh": "تحديث تلقائي",
"autoRefreshInterval": "الفاصل",
"modifyInbound": "تعديل الإدخال", "modifyInbound": "تعديل الإدخال",
"deleteInbound": "حذف الإدخال", "deleteInbound": "حذف الإدخال",
"deleteInboundContent": "متأكد إنك عايز تحذف الإدخال؟", "deleteInboundContent": "متأكد إنك عايز تحذف الإدخال؟",
@ -415,7 +413,6 @@
"lastHeartbeat": "آخر نبضة", "lastHeartbeat": "آخر نبضة",
"xrayVersion": "إصدار Xray", "xrayVersion": "إصدار Xray",
"actions": "العمليات", "actions": "العمليات",
"refresh": "تحديث",
"probe": "فحص فوري", "probe": "فحص فوري",
"testConnection": "اختبار الاتصال", "testConnection": "اختبار الاتصال",
"connectionOk": "الاتصال شغال ({ms} ms)", "connectionOk": "الاتصال شغال ({ms} ms)",

View file

@ -249,8 +249,6 @@
"resetTraffic": "Reset Traffic", "resetTraffic": "Reset Traffic",
"addInbound": "Add Inbound", "addInbound": "Add Inbound",
"generalActions": "General Actions", "generalActions": "General Actions",
"autoRefresh": "Auto-refresh",
"autoRefreshInterval": "Interval",
"modifyInbound": "Modify Inbound", "modifyInbound": "Modify Inbound",
"deleteInbound": "Delete Inbound", "deleteInbound": "Delete Inbound",
"deleteInboundContent": "Are you sure you want to delete inbound?", "deleteInboundContent": "Are you sure you want to delete inbound?",
@ -416,7 +414,6 @@
"lastHeartbeat": "Last Heartbeat", "lastHeartbeat": "Last Heartbeat",
"xrayVersion": "Xray Version", "xrayVersion": "Xray Version",
"actions": "Actions", "actions": "Actions",
"refresh": "Refresh",
"probe": "Probe Now", "probe": "Probe Now",
"testConnection": "Test Connection", "testConnection": "Test Connection",
"connectionOk": "Connection OK ({ms} ms)", "connectionOk": "Connection OK ({ms} ms)",

View file

@ -248,8 +248,6 @@
"resetTraffic": "Restablecer Tráfico", "resetTraffic": "Restablecer Tráfico",
"addInbound": "Agregar Entrada", "addInbound": "Agregar Entrada",
"generalActions": "Acciones Generales", "generalActions": "Acciones Generales",
"autoRefresh": "Auto-actualizar",
"autoRefreshInterval": "Intervalo",
"modifyInbound": "Modificar Entrada", "modifyInbound": "Modificar Entrada",
"deleteInbound": "Eliminar Entrada", "deleteInbound": "Eliminar Entrada",
"deleteInboundContent": "¿Confirmar eliminación de entrada?", "deleteInboundContent": "¿Confirmar eliminación de entrada?",
@ -415,7 +413,6 @@
"lastHeartbeat": "Último latido", "lastHeartbeat": "Último latido",
"xrayVersion": "Versión de Xray", "xrayVersion": "Versión de Xray",
"actions": "Acciones", "actions": "Acciones",
"refresh": "Actualizar",
"probe": "Sondear ahora", "probe": "Sondear ahora",
"testConnection": "Probar conexión", "testConnection": "Probar conexión",
"connectionOk": "Conexión correcta ({ms} ms)", "connectionOk": "Conexión correcta ({ms} ms)",

View file

@ -249,8 +249,6 @@
"resetTraffic": "ریست ترافیک", "resetTraffic": "ریست ترافیک",
"addInbound": "افزودن ورودی", "addInbound": "افزودن ورودی",
"generalActions": "عملیات کلی", "generalActions": "عملیات کلی",
"autoRefresh": "تازه‌سازی خودکار",
"autoRefreshInterval": "فاصله",
"modifyInbound": "ویرایش ورودی", "modifyInbound": "ویرایش ورودی",
"deleteInbound": "حذف ورودی", "deleteInbound": "حذف ورودی",
"deleteInboundContent": "آیا مطمئن به حذف ورودی هستید؟", "deleteInboundContent": "آیا مطمئن به حذف ورودی هستید؟",
@ -416,7 +414,6 @@
"lastHeartbeat": "آخرین ضربان", "lastHeartbeat": "آخرین ضربان",
"xrayVersion": "نسخه Xray", "xrayVersion": "نسخه Xray",
"actions": "عملیات", "actions": "عملیات",
"refresh": "به‌روزرسانی",
"probe": "بررسی فوری", "probe": "بررسی فوری",
"testConnection": "تست اتصال", "testConnection": "تست اتصال",
"connectionOk": "اتصال موفق ({ms} میلی‌ثانیه)", "connectionOk": "اتصال موفق ({ms} میلی‌ثانیه)",

View file

@ -248,8 +248,6 @@
"resetTraffic": "Reset Traffic", "resetTraffic": "Reset Traffic",
"addInbound": "Tambahkan Masuk", "addInbound": "Tambahkan Masuk",
"generalActions": "Tindakan Umum", "generalActions": "Tindakan Umum",
"autoRefresh": "Pembaruan otomatis",
"autoRefreshInterval": "Interval",
"modifyInbound": "Ubah Masuk", "modifyInbound": "Ubah Masuk",
"deleteInbound": "Hapus Masuk", "deleteInbound": "Hapus Masuk",
"deleteInboundContent": "Apakah Anda yakin ingin menghapus masuk?", "deleteInboundContent": "Apakah Anda yakin ingin menghapus masuk?",
@ -415,7 +413,6 @@
"lastHeartbeat": "Heartbeat Terakhir", "lastHeartbeat": "Heartbeat Terakhir",
"xrayVersion": "Versi Xray", "xrayVersion": "Versi Xray",
"actions": "Aksi", "actions": "Aksi",
"refresh": "Segarkan",
"probe": "Probe Sekarang", "probe": "Probe Sekarang",
"testConnection": "Tes Koneksi", "testConnection": "Tes Koneksi",
"connectionOk": "Koneksi OK ({ms} ms)", "connectionOk": "Koneksi OK ({ms} ms)",

View file

@ -248,8 +248,6 @@
"resetTraffic": "トラフィックリセット", "resetTraffic": "トラフィックリセット",
"addInbound": "インバウンド追加", "addInbound": "インバウンド追加",
"generalActions": "一般操作", "generalActions": "一般操作",
"autoRefresh": "自動更新",
"autoRefreshInterval": "間隔",
"modifyInbound": "インバウンド修正", "modifyInbound": "インバウンド修正",
"deleteInbound": "インバウンド削除", "deleteInbound": "インバウンド削除",
"deleteInboundContent": "インバウンドを削除してもよろしいですか?", "deleteInboundContent": "インバウンドを削除してもよろしいですか?",
@ -415,7 +413,6 @@
"lastHeartbeat": "最後のハートビート", "lastHeartbeat": "最後のハートビート",
"xrayVersion": "Xrayバージョン", "xrayVersion": "Xrayバージョン",
"actions": "操作", "actions": "操作",
"refresh": "更新",
"probe": "今すぐプローブ", "probe": "今すぐプローブ",
"testConnection": "接続テスト", "testConnection": "接続テスト",
"connectionOk": "接続OK ({ms} ms)", "connectionOk": "接続OK ({ms} ms)",

View file

@ -248,8 +248,6 @@
"resetTraffic": "Redefinir Tráfego", "resetTraffic": "Redefinir Tráfego",
"addInbound": "Adicionar Inbound", "addInbound": "Adicionar Inbound",
"generalActions": "Ações Gerais", "generalActions": "Ações Gerais",
"autoRefresh": "Atualização automática",
"autoRefreshInterval": "Intervalo",
"modifyInbound": "Modificar Inbound", "modifyInbound": "Modificar Inbound",
"deleteInbound": "Excluir Inbound", "deleteInbound": "Excluir Inbound",
"deleteInboundContent": "Tem certeza de que deseja excluir o inbound?", "deleteInboundContent": "Tem certeza de que deseja excluir o inbound?",
@ -415,7 +413,6 @@
"lastHeartbeat": "Último heartbeat", "lastHeartbeat": "Último heartbeat",
"xrayVersion": "Versão do Xray", "xrayVersion": "Versão do Xray",
"actions": "Ações", "actions": "Ações",
"refresh": "Atualizar",
"probe": "Sondar agora", "probe": "Sondar agora",
"testConnection": "Testar conexão", "testConnection": "Testar conexão",
"connectionOk": "Conexão OK ({ms} ms)", "connectionOk": "Conexão OK ({ms} ms)",

View file

@ -248,8 +248,6 @@
"resetTraffic": "Сброс трафика", "resetTraffic": "Сброс трафика",
"addInbound": "Создать подключение", "addInbound": "Создать подключение",
"generalActions": "Общие действия", "generalActions": "Общие действия",
"autoRefresh": "Автообновление",
"autoRefreshInterval": "Интервал",
"modifyInbound": "Изменить подключение", "modifyInbound": "Изменить подключение",
"deleteInbound": "Удалить подключение", "deleteInbound": "Удалить подключение",
"deleteInboundContent": "Вы уверены, что хотите удалить подключение?", "deleteInboundContent": "Вы уверены, что хотите удалить подключение?",
@ -415,7 +413,6 @@
"lastHeartbeat": "Последний пинг", "lastHeartbeat": "Последний пинг",
"xrayVersion": "Версия Xray", "xrayVersion": "Версия Xray",
"actions": "Действия", "actions": "Действия",
"refresh": "Обновить",
"probe": "Проверить сейчас", "probe": "Проверить сейчас",
"testConnection": "Проверить соединение", "testConnection": "Проверить соединение",
"connectionOk": "Соединение в порядке ({ms} мс)", "connectionOk": "Соединение в порядке ({ms} мс)",

View file

@ -248,8 +248,6 @@
"resetTraffic": "Trafiği Sıfırla", "resetTraffic": "Trafiği Sıfırla",
"addInbound": "Gelen Ekle", "addInbound": "Gelen Ekle",
"generalActions": "Genel Eylemler", "generalActions": "Genel Eylemler",
"autoRefresh": "Otomatik yenileme",
"autoRefreshInterval": "Aralık",
"modifyInbound": "Geleni Düzenle", "modifyInbound": "Geleni Düzenle",
"deleteInbound": "Geleni Sil", "deleteInbound": "Geleni Sil",
"deleteInboundContent": "Geleni silmek istediğinizden emin misiniz?", "deleteInboundContent": "Geleni silmek istediğinizden emin misiniz?",
@ -415,7 +413,6 @@
"lastHeartbeat": "Son Sinyal", "lastHeartbeat": "Son Sinyal",
"xrayVersion": "Xray Sürümü", "xrayVersion": "Xray Sürümü",
"actions": "İşlemler", "actions": "İşlemler",
"refresh": "Yenile",
"probe": "Şimdi Test Et", "probe": "Şimdi Test Et",
"testConnection": "Bağlantıyı Test Et", "testConnection": "Bağlantıyı Test Et",
"connectionOk": "Bağlantı tamam ({ms} ms)", "connectionOk": "Bağlantı tamam ({ms} ms)",

View file

@ -248,8 +248,6 @@
"resetTraffic": "Скинути трафік", "resetTraffic": "Скинути трафік",
"addInbound": "Додати вхідний", "addInbound": "Додати вхідний",
"generalActions": "Загальні дії", "generalActions": "Загальні дії",
"autoRefresh": "Автооновлення",
"autoRefreshInterval": "Інтервал",
"modifyInbound": "Змінити вхідний", "modifyInbound": "Змінити вхідний",
"deleteInbound": "Видалити вхідні", "deleteInbound": "Видалити вхідні",
"deleteInboundContent": "Ви впевнені, що хочете видалити вхідні?", "deleteInboundContent": "Ви впевнені, що хочете видалити вхідні?",
@ -415,7 +413,6 @@
"lastHeartbeat": "Останній пінг", "lastHeartbeat": "Останній пінг",
"xrayVersion": "Версія Xray", "xrayVersion": "Версія Xray",
"actions": "Дії", "actions": "Дії",
"refresh": "Оновити",
"probe": "Перевірити зараз", "probe": "Перевірити зараз",
"testConnection": "Перевірити з'єднання", "testConnection": "Перевірити з'єднання",
"connectionOk": "З'єднання в порядку ({ms} мс)", "connectionOk": "З'єднання в порядку ({ms} мс)",

View file

@ -248,8 +248,6 @@
"resetTraffic": "Đặt lại lưu lượng", "resetTraffic": "Đặt lại lưu lượng",
"addInbound": "Thêm điểm vào", "addInbound": "Thêm điểm vào",
"generalActions": "Hành động chung", "generalActions": "Hành động chung",
"autoRefresh": "Tự động làm mới",
"autoRefreshInterval": "Khoảng thời gian",
"modifyInbound": "Chỉnh sửa điểm vào (Inbound)", "modifyInbound": "Chỉnh sửa điểm vào (Inbound)",
"deleteInbound": "Xóa điểm vào (Inbound)", "deleteInbound": "Xóa điểm vào (Inbound)",
"deleteInboundContent": "Xác nhận xóa điểm vào? (Inbound)", "deleteInboundContent": "Xác nhận xóa điểm vào? (Inbound)",
@ -415,7 +413,6 @@
"lastHeartbeat": "Heartbeat gần nhất", "lastHeartbeat": "Heartbeat gần nhất",
"xrayVersion": "Phiên bản Xray", "xrayVersion": "Phiên bản Xray",
"actions": "Hành động", "actions": "Hành động",
"refresh": "Làm mới",
"probe": "Kiểm tra ngay", "probe": "Kiểm tra ngay",
"testConnection": "Kiểm tra kết nối", "testConnection": "Kiểm tra kết nối",
"connectionOk": "Kết nối OK ({ms} ms)", "connectionOk": "Kết nối OK ({ms} ms)",

View file

@ -248,8 +248,6 @@
"resetTraffic": "重置流量", "resetTraffic": "重置流量",
"addInbound": "添加入站", "addInbound": "添加入站",
"generalActions": "通用操作", "generalActions": "通用操作",
"autoRefresh": "自动刷新",
"autoRefreshInterval": "间隔",
"modifyInbound": "修改入站", "modifyInbound": "修改入站",
"deleteInbound": "删除入站", "deleteInbound": "删除入站",
"deleteInboundContent": "确定要删除入站吗?", "deleteInboundContent": "确定要删除入站吗?",
@ -415,7 +413,6 @@
"lastHeartbeat": "上次心跳", "lastHeartbeat": "上次心跳",
"xrayVersion": "Xray 版本", "xrayVersion": "Xray 版本",
"actions": "操作", "actions": "操作",
"refresh": "刷新",
"probe": "立即探测", "probe": "立即探测",
"testConnection": "测试连接", "testConnection": "测试连接",
"connectionOk": "连接正常 ({ms} ms)", "connectionOk": "连接正常 ({ms} ms)",

View file

@ -248,8 +248,6 @@
"resetTraffic": "重置流量", "resetTraffic": "重置流量",
"addInbound": "新增入站", "addInbound": "新增入站",
"generalActions": "通用操作", "generalActions": "通用操作",
"autoRefresh": "自動刷新",
"autoRefreshInterval": "間隔",
"modifyInbound": "修改入站", "modifyInbound": "修改入站",
"deleteInbound": "刪除入站", "deleteInbound": "刪除入站",
"deleteInboundContent": "確定要刪除入站嗎?", "deleteInboundContent": "確定要刪除入站嗎?",
@ -415,7 +413,6 @@
"lastHeartbeat": "上次心跳", "lastHeartbeat": "上次心跳",
"xrayVersion": "Xray 版本", "xrayVersion": "Xray 版本",
"actions": "操作", "actions": "操作",
"refresh": "重新整理",
"probe": "立即探測", "probe": "立即探測",
"testConnection": "測試連線", "testConnection": "測試連線",
"connectionOk": "連線正常 ({ms} ms)", "connectionOk": "連線正常 ({ms} ms)",

View file

@ -18,6 +18,7 @@ const (
MessageTypeTraffic MessageType = "traffic" MessageTypeTraffic MessageType = "traffic"
MessageTypeInbounds MessageType = "inbounds" MessageTypeInbounds MessageType = "inbounds"
MessageTypeOutbounds MessageType = "outbounds" MessageTypeOutbounds MessageType = "outbounds"
MessageTypeNodes MessageType = "nodes"
MessageTypeNotification MessageType = "notification" MessageTypeNotification MessageType = "notification"
MessageTypeXrayState MessageType = "xray_state" MessageTypeXrayState MessageType = "xray_state"
// MessageTypeClientStats carries absolute traffic counters for the clients // MessageTypeClientStats carries absolute traffic counters for the clients

View file

@ -62,6 +62,15 @@ func BroadcastInbounds(inbounds any) {
} }
} }
// BroadcastNodes broadcasts the fresh node list to all connected clients.
// Pushed by NodeHeartbeatJob at the end of each 10s tick so the Nodes page
// reflects status / latency / cpu / mem updates without polling.
func BroadcastNodes(nodes any) {
if hub := GetHub(); hub != nil {
hub.Broadcast(MessageTypeNodes, nodes)
}
}
// BroadcastOutbounds broadcasts outbounds list update to all connected clients. // BroadcastOutbounds broadcasts outbounds list update to all connected clients.
func BroadcastOutbounds(outbounds any) { func BroadcastOutbounds(outbounds any) {
if hub := GetHub(); hub != nil { if hub := GetHub(); hub != nil {