mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
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:
parent
d3dcd1d8bd
commit
f4f0af576a
29 changed files with 275 additions and 207 deletions
|
|
@ -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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
48
frontend/src/composables/useWebSocket.js
Normal file
48
frontend/src/composables/useWebSocket.js
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 id→node map for the new "Node" column. Fetched once on mount.
|
// the id→node 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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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} میلیثانیه)",
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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} мс)",
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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} мс)",
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue