feat: Real-time Outbound Traffic, UI Improvements & Fix (#3629)

* Refactor HTML and JavaScript for improved UI and functionality

- Cleaned up JavaScript methods in subscription.js for better readability.
- Updated inbounds.html to clarify traffic update handling and removed unnecessary comments.
- Enhanced xray.html by correcting casing in routingDomainStrategies.
- Added mobile touch scrolling styles in page.html for better tab navigation on small screens.
- Streamlined vless.html by removing redundant line breaks and improving form layout.
- Refined subscription subpage.html for better structure and user experience.
- Adjusted outbounds.html to improve button visibility and functionality.
- Updated xray_traffic_job.go to ensure accurate traffic updates and real-time UI refresh.

* Refactor client traffic handling in InboundService

- Updated addClientTraffic method to initialize onlineClients as an empty slice instead of nil.
- Improved clarity and consistency in handling empty onlineUsers scenario.

* Add WebSocket support for outbounds traffic updates

- Implemented WebSocket connection in xray.html to handle real-time updates for outbounds traffic.
- Enhanced xray_traffic_job.go to retrieve and broadcast outbounds traffic updates.
- Introduced MessageTypeOutbounds in hub.go for managing outbounds messages.
- Added BroadcastOutbounds function in notifier.go to facilitate broadcasting outbounds updates to connected clients.

---------

Co-authored-by: lolka1333 <test123@gmail.com>
This commit is contained in:
lolka1333 2026-01-05 05:50:40 +01:00 committed by GitHub
parent a9770e1da2
commit 4800f8fb70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 260 additions and 239 deletions

View file

@ -143,9 +143,9 @@
npvtunUrl() { npvtunUrl() {
return this.app.subUrl; return this.app.subUrl;
}, },
happUrl() { happUrl() {
return `happ://add/${encodeURIComponent(this.app.subUrl)}`; return `happ://add/${encodeURIComponent(this.app.subUrl)}`;
} }
}, },
methods: { methods: {
renderLink, renderLink,

View file

@ -24,6 +24,40 @@
body { body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Vazirmatn', 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Vazirmatn', 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
} }
/* mobile touch scrolling for tabs */
@media (max-width: 576px) {
.ant-tabs-nav-container {
overflow-x: auto !important;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
overscroll-behavior-x: contain;
white-space: nowrap;
max-width: 100%;
padding: 0 !important; /* Remove padding for arrows */
}
.ant-tabs-nav-wrap {
overflow: visible !important;
padding: 0 !important;
}
.ant-tabs-nav-scroll {
overflow: visible !important;
box-shadow: none !important;
}
.ant-tabs-nav {
display: flex !important;
transform: none !important; /* Disable JS transform */
width: auto !important;
margin: 0 !important;
}
.ant-tabs-tab-prev,
.ant-tabs-tab-next {
display: none !important; /* Hide arrows */
}
.ant-tabs-nav-container::-webkit-scrollbar {
display: none;
}
}
</style> </style>
<title>{{ .host }} {{ i18n .title}}</title> <title>{{ .host }} {{ i18n .title}}</title>
{{ end }} {{ end }}

View file

@ -1,6 +1,5 @@
{{define "form/vless"}} {{define "form/vless"}}
<a-collapse activeKey="0" <a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vlesses.slice(0,1)" v-if="!isEdit">
v-for="(client, index) in inbound.settings.vlesses.slice(0,1)" v-if="!isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'> <a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
{{template "form/client"}} {{template "form/client"}}
</a-collapse-panel> </a-collapse-panel>
@ -22,115 +21,103 @@
</a-collapse-panel> </a-collapse-panel>
</a-collapse> </a-collapse>
<template v-if=" !inbound.stream.isTLS || !inbound.stream.isReality"> <template v-if=" !inbound.stream.isTLS || !inbound.stream.isReality">
<a-form :colon="false" :label-col="{ md: {span:8} }" <a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
:wrapper-col="{ md: {span:14} }"> <a-form-item label="Authentication">
<a-form-item label="Authentication"> <a-select v-model="inbound.settings.selectedAuth" @change="getNewVlessEnc"
<a-select v-model="inbound.settings.selectedAuth" @change="getNewVlessEnc" :dropdown-class-name="themeSwitcher.currentTheme">
:dropdown-class-name="themeSwitcher.currentTheme"> <a-select-option value="X25519, not Post-Quantum">X25519 (not
<a-select-option value="X25519, not Post-Quantum">X25519 (not Post-Quantum)</a-select-option>
Post-Quantum)</a-select-option> <a-select-option value="ML-KEM-768, Post-Quantum">ML-KEM-768
<a-select-option value="ML-KEM-768, Post-Quantum">ML-KEM-768 (Post-Quantum)</a-select-option>
(Post-Quantum)</a-select-option> </a-select>
</a-select> </a-form-item>
</a-form-item> <a-form-item label="decryption">
<a-form-item label="decryption"> <a-input v-model.trim="inbound.settings.decryption"></a-input>
<a-input v-model.trim="inbound.settings.decryption"></a-input> </a-form-item>
</a-form-item> <a-form-item label="encryption">
<a-form-item label="encryption"> <a-input v-model="inbound.settings.encryption"></a-input>
<a-input v-model="inbound.settings.encryption"></a-input> </a-form-item>
</a-form-item> <a-form-item label=" ">
<a-form-item label=" "> <a-space>
<a-space> <a-button type="primary" icon="import" @click="getNewVlessEnc">Get New
<a-button type="primary" icon="import" @click="getNewVlessEnc">Get New keys</a-button>
keys</a-button> <a-button danger @click="clearVlessEnc">Clear</a-button>
<a-button danger @click="clearVlessEnc">Clear</a-button> </a-space>
</a-space> </a-form-item>
</a-form-item> </a-form>
</a-form> <a-divider :style="{ margin: '5px 0' }"></a-divider>
<a-divider v-if="inbound.settings.selectedAuth" </template>
:style="{ margin: '5px 0' }"></a-divider> <template v-if="inbound.isTcp && !inbound.settings.selectedAuth">
</template> <a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<template v-if="inbound.isTcp && !inbound.settings.selectedAuth"> <a-form-item label="Fallbacks">
<a-form :colon="false" :label-col="{ md: {span:8} }" <a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
:wrapper-col="{ md: {span:14} }"> </a-form-item>
<a-form-item label="Fallbacks"> </a-form>
<a-button icon="plus" type="primary" size="small"
@click="inbound.settings.addFallback()"></a-button>
</a-form-item>
</a-form>
<!-- vless fallbacks --> <!-- vless fallbacks -->
<a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" <a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" :label-col="{ md: {span:8} }"
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
:wrapper-col="{ md: {span:14} }"> <a-divider :style="{ margin: '0' }"> Fallback [[ index + 1 ]] <a-icon type="delete"
<a-divider :style="{ margin: '0' }"> Fallback [[ index + 1 ]] <a-icon @click="() => inbound.settings.delFallback(index)"
type="delete" :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
@click="() => inbound.settings.delFallback(index)" </a-divider>
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon> <a-form-item label='SNI'>
</a-divider> <a-input v-model="fallback.name"></a-input>
<a-form-item label='SNI'> </a-form-item>
<a-input v-model="fallback.name"></a-input> <a-form-item label='ALPN'>
</a-form-item> <a-input v-model="fallback.alpn"></a-input>
<a-form-item label='ALPN'> </a-form-item>
<a-input v-model="fallback.alpn"></a-input> <a-form-item label='Path'>
</a-form-item> <a-input v-model="fallback.path"></a-input>
<a-form-item label='Path'> </a-form-item>
<a-input v-model="fallback.path"></a-input> <a-form-item label='Dest'>
</a-form-item> <a-input v-model="fallback.dest"></a-input>
<a-form-item label='Dest'> </a-form-item>
<a-input v-model="fallback.dest"></a-input> <a-form-item label='xVer'>
</a-form-item> <a-input-number v-model.number="fallback.xver" :min="0" :max="2"></a-input-number>
<a-form-item label='xVer'> </a-form-item>
<a-input-number v-model.number="fallback.xver" :min="0" </a-form>
:max="2"></a-input-number> <a-divider :style="{ margin: '5px 0' }"></a-divider>
</a-form-item> </template>
</a-form> <template v-if="inbound.canEnableVisionSeed()">
<a-divider :style="{ margin: '5px 0' }"></a-divider> <a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
</template> <a-form-item label="Vision Seed">
<template v-if="inbound.canEnableVisionSeed()"> <a-row :gutter="8">
<a-form :colon="false" :label-col="{ md: {span:8} }" <a-col :span="6">
:wrapper-col="{ md: {span:14} }"> <a-input-number
<a-form-item label="Vision Seed"> :value="(inbound.settings.testseed && inbound.settings.testseed[0] !== undefined) ? inbound.settings.testseed[0] : 900"
<a-row :gutter="8"> @change="(val) => updateTestseed(0, val)" :min="0" :max="9999" :style="{ width: '100%' }"
<a-col :span="6"> placeholder="900" addon-before="[0]"></a-input-number>
<a-input-number </a-col>
:value="(inbound.settings.testseed && inbound.settings.testseed[0] !== undefined) ? inbound.settings.testseed[0] : 900" <a-col :span="6">
@change="(val) => updateTestseed(0, val)" :min="0" :max="9999" <a-input-number
:style="{ width: '100%' }" :value="(inbound.settings.testseed && inbound.settings.testseed[1] !== undefined) ? inbound.settings.testseed[1] : 500"
placeholder="900" addon-before="[0]"></a-input-number> @change="(val) => updateTestseed(1, val)" :min="0" :max="9999" :style="{ width: '100%' }"
</a-col> placeholder="500" addon-before="[1]"></a-input-number>
<a-col :span="6"> </a-col>
<a-input-number <a-col :span="6">
:value="(inbound.settings.testseed && inbound.settings.testseed[1] !== undefined) ? inbound.settings.testseed[1] : 500" <a-input-number
@change="(val) => updateTestseed(1, val)" :min="0" :max="9999" :value="(inbound.settings.testseed && inbound.settings.testseed[2] !== undefined) ? inbound.settings.testseed[2] : 900"
:style="{ width: '100%' }" @change="(val) => updateTestseed(2, val)" :min="0" :max="9999" :style="{ width: '100%' }"
placeholder="500" addon-before="[1]"></a-input-number> placeholder="900" addon-before="[2]"></a-input-number>
</a-col> </a-col>
<a-col :span="6"> <a-col :span="6">
<a-input-number <a-input-number
:value="(inbound.settings.testseed && inbound.settings.testseed[2] !== undefined) ? inbound.settings.testseed[2] : 900" :value="(inbound.settings.testseed && inbound.settings.testseed[3] !== undefined) ? inbound.settings.testseed[3] : 256"
@change="(val) => updateTestseed(2, val)" :min="0" :max="9999" @change="(val) => updateTestseed(3, val)" :min="0" :max="9999" :style="{ width: '100%' }"
:style="{ width: '100%' }" placeholder="256" addon-before="[3]"></a-input-number>
placeholder="900" addon-before="[2]"></a-input-number> </a-col>
</a-col> </a-row>
<a-col :span="6"> <a-space :size="8" :style="{ marginTop: '8px' }">
<a-input-number <a-button type="primary" @click="setRandomTestseed">
:value="(inbound.settings.testseed && inbound.settings.testseed[3] !== undefined) ? inbound.settings.testseed[3] : 256" Rand
@change="(val) => updateTestseed(3, val)" :min="0" :max="9999" </a-button>
:style="{ width: '100%' }" <a-button @click="resetTestseed">
placeholder="256" addon-before="[3]"></a-input-number> Reset
</a-col> </a-button>
</a-row> </a-space>
<a-space :size="8" :style="{ marginTop: '8px' }"> </a-form-item>
<a-button type="primary" @click="setRandomTestseed"> </a-form>
Rand <a-divider :style="{ margin: '5px 0' }"></a-divider>
</a-button> </template>
<a-button @click="resetTestseed">
Reset
</a-button>
</a-space>
</a-form-item>
</a-form>
<a-divider :style="{ margin: '5px 0' }"></a-divider>
</template>
{{end}} {{end}}

View file

@ -1608,24 +1608,9 @@
// Listen for traffic updates // Listen for traffic updates
window.wsClient.on('traffic', (payload) => { window.wsClient.on('traffic', (payload) => {
if (payload && payload.clientTraffics && Array.isArray(payload.clientTraffics)) { // Note: Do NOT update total consumed traffic (stats.up, stats.down) from this event
// Update client traffic statistics // because clientTraffics contains delta/incremental values, not total accumulated values.
payload.clientTraffics.forEach(clientTraffic => { // Total traffic is updated via the 'inbounds' event which contains accumulated values from database.
const dbInbound = this.dbInbounds.find(ib => {
if (!ib) return false;
const clients = this.getInboundClients(ib);
return clients && Array.isArray(clients) && clients.some(c => c && c.email === clientTraffic.email);
});
if (dbInbound && dbInbound.clientStats && Array.isArray(dbInbound.clientStats)) {
const stats = dbInbound.clientStats.find(s => s && s.email === clientTraffic.email);
if (stats) {
stats.up = clientTraffic.up || stats.up;
stats.down = clientTraffic.down || stats.down;
stats.total = clientTraffic.total || stats.total;
}
}
});
}
// Update online clients list in real-time // Update online clients list in real-time
if (payload && Array.isArray(payload.onlineClients)) { if (payload && Array.isArray(payload.onlineClients)) {
@ -1645,8 +1630,6 @@
} }
}); });
// Notifications disabled - white notifications are not needed
// Fallback to polling if WebSocket fails // Fallback to polling if WebSocket fails
window.wsClient.on('error', () => { window.wsClient.on('error', () => {
console.warn('WebSocket connection failed, falling back to polling'); console.warn('WebSocket connection failed, falling back to polling');

View file

@ -20,28 +20,20 @@
</a-space> </a-space>
</template> </template>
<template #extra> <template #extra>
<a-popover <a-popover :overlay-class-name="themeSwitcher.currentTheme" title='{{ i18n "menu.settings" }}'
:overlay-class-name="themeSwitcher.currentTheme"
title='{{ i18n "menu.settings" }}'
placement="bottomRight" trigger="click"> placement="bottomRight" trigger="click">
<template #content> <template #content>
<a-space direction="vertical" :size="10"> <a-space direction="vertical" :size="10">
<a-theme-switch-login></a-theme-switch-login> <a-theme-switch-login></a-theme-switch-login>
<span>{{ i18n "pages.settings.language" <span>{{ i18n "pages.settings.language"
}}</span> }}</span>
<a-select ref="selectLang" class="w-100" <a-select ref="selectLang" class="w-100" v-model="lang"
v-model="lang"
@change="LanguageManager.setLanguage(lang)" @change="LanguageManager.setLanguage(lang)"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="l.value" <a-select-option :value="l.value" label="English"
label="English" v-for="l in LanguageManager.supportedLanguages" :key="l.value">
v-for="l in LanguageManager.supportedLanguages" <span role="img" :aria-label="l.name" v-text="l.icon"></span>
:key="l.value"> &nbsp;&nbsp;<span v-text="l.name"></span>
<span role="img"
:aria-label="l.name"
v-text="l.icon"></span>
&nbsp;&nbsp;<span
v-text="l.name"></span>
</a-select-option> </a-select-option>
</a-select> </a-select>
</a-space> </a-space>
@ -53,42 +45,31 @@
<a-form layout="vertical"> <a-form layout="vertical">
<a-form-item> <a-form-item>
<a-space direction="vertical" align="center"> <a-space direction="vertical" align="center">
<a-row type="flex" :gutter="[8,8]" <a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
justify="center" style="width:100%"> <a-col :xs="24" :sm="app.subJsonUrl ? 12 : 24" style="text-align:center;">
<a-col :xs="24" :sm="app.subJsonUrl ? 12 : 24"
style="text-align:center;">
<tr-qr-box class="qr-box"> <tr-qr-box class="qr-box">
<a-tag color="purple" <a-tag color="purple" class="qr-tag">
class="qr-tag">
<span>{{ i18n <span>{{ i18n
"pages.settings.subSettings"}}</span> "pages.settings.subSettings"}}</span>
</a-tag> </a-tag>
<tr-qr-bg class="qr-bg-sub"> <tr-qr-bg class="qr-bg-sub">
<tr-qr-bg-inner <tr-qr-bg-inner class="qr-bg-sub-inner">
class="qr-bg-sub-inner"> <canvas id="qrcode" class="qr-cv" title='{{ i18n "copy" }}'
<canvas id="qrcode"
class="qr-cv"
title='{{ i18n "copy" }}'
@click="copy(app.subUrl)"></canvas> @click="copy(app.subUrl)"></canvas>
</tr-qr-bg-inner> </tr-qr-bg-inner>
</tr-qr-bg> </tr-qr-bg>
</tr-qr-box> </tr-qr-box>
</a-col> </a-col>
<a-col v-if="app.subJsonUrl" :xs="24" :sm="12" <a-col v-if="app.subJsonUrl" :xs="24" :sm="12" style="text-align:center;">
style="text-align:center;">
<tr-qr-box class="qr-box"> <tr-qr-box class="qr-box">
<a-tag color="purple" <a-tag color="purple" class="qr-tag">
class="qr-tag">
<span>{{ i18n <span>{{ i18n
"pages.settings.subSettings"}} "pages.settings.subSettings"}}
Json</span> Json</span>
</a-tag> </a-tag>
<tr-qr-bg class="qr-bg-sub"> <tr-qr-bg class="qr-bg-sub">
<tr-qr-bg-inner <tr-qr-bg-inner class="qr-bg-sub-inner">
class="qr-bg-sub-inner"> <canvas id="qrcode-subjson" class="qr-cv" title='{{ i18n "copy" }}'
<canvas id="qrcode-subjson"
class="qr-cv"
title='{{ i18n "copy" }}'
@click="copy(app.subJsonUrl)"></canvas> @click="copy(app.subJsonUrl)"></canvas>
</tr-qr-bg-inner> </tr-qr-bg-inner>
</tr-qr-bg> </tr-qr-bg>
@ -100,45 +81,36 @@
<a-form-item> <a-form-item>
<a-descriptions bordered :column="1" size="small"> <a-descriptions bordered :column="1" size="small">
<a-descriptions-item <a-descriptions-item label='{{ i18n "subscription.subId" }}'>[[
label='{{ i18n "subscription.subId" }}'>[[
app.sId app.sId
]]</a-descriptions-item> ]]</a-descriptions-item>
<a-descriptions-item <a-descriptions-item label='{{ i18n "subscription.status" }}'>
label='{{ i18n "subscription.status" }}'>
<template v-if="isUnlimited"> <template v-if="isUnlimited">
<a-tag color="purple">{{ i18n <a-tag color="purple">{{ i18n
"subscription.unlimited" }}</a-tag> "subscription.unlimited" }}</a-tag>
</template> </template>
<template v-else> <template v-else>
<a-tag <a-tag :color="isActive ? 'green' : 'red'">[[
:color="isActive ? 'green' : 'red'">[[
isActive ? '{{ i18n isActive ? '{{ i18n
"subscription.active" }}' : '{{ i18n "subscription.active" }}' : '{{ i18n
"subscription.inactive" }}' "subscription.inactive" }}'
]]</a-tag> ]]</a-tag>
</template> </template>
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item <a-descriptions-item label='{{ i18n "subscription.downloaded" }}'>[[
label='{{ i18n "subscription.downloaded" }}'>[[
app.download app.download
]]</a-descriptions-item> ]]</a-descriptions-item>
<a-descriptions-item <a-descriptions-item label='{{ i18n "subscription.uploaded" }}'>[[
label='{{ i18n "subscription.uploaded" }}'>[[
app.upload app.upload
]]</a-descriptions-item> ]]</a-descriptions-item>
<a-descriptions-item <a-descriptions-item label='{{ i18n "usage" }}'>[[ app.used
label='{{ i18n "usage" }}'>[[ app.used
]]</a-descriptions-item> ]]</a-descriptions-item>
<a-descriptions-item <a-descriptions-item label='{{ i18n "subscription.totalQuota" }}'>[[
label='{{ i18n "subscription.totalQuota" }}'>[[
app.total app.total
]]</a-descriptions-item> ]]</a-descriptions-item>
<a-descriptions-item v-if="app.totalByte > 0" <a-descriptions-item v-if="app.totalByte > 0" label='{{ i18n "remained" }}'>[[
label='{{ i18n "remained" }}'>[[
app.remained ]]</a-descriptions-item> app.remained ]]</a-descriptions-item>
<a-descriptions-item <a-descriptions-item label='{{ i18n "lastOnline" }}'>
label='{{ i18n "lastOnline" }}'>
<template v-if="app.lastOnlineMs > 0"> <template v-if="app.lastOnlineMs > 0">
[[ IntlUtil.formatDate(app.lastOnlineMs) ]] [[ IntlUtil.formatDate(app.lastOnlineMs) ]]
</template> </template>
@ -146,8 +118,7 @@
<span>-</span> <span>-</span>
</template> </template>
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item <a-descriptions-item label='{{ i18n "subscription.expiry" }}'>
label='{{ i18n "subscription.expiry" }}'>
<template v-if="app.expireMs === 0"> <template v-if="app.expireMs === 0">
{{ i18n "subscription.noExpiry" }} {{ i18n "subscription.noExpiry" }}
</template> </template>
@ -160,32 +131,48 @@
</a-form> </a-form>
<br /> <br />
<a-list bordered> <div v-for="(link, idx) in links" :key="link"
<a-list-item v-for="(link, idx) in links" :key="link"> style="position: relative; margin-bottom: 20px; text-align: center;">
<div style="width:100%; text-align:center;"> <div class="qr-box" style="display: inline-block; width: 100%; max-width: 100%;">
<a-button type="primary" :block="isMobile" <a-tag color="purple"
@click="copy(link)">[[ linkName(link, idx) style="margin-bottom: -10px; position: relative; z-index: 2; box-shadow: 0 2px 4px rgba(0,0,0,0.2);">
]]</a-button> <span>[[ linkName(link, idx) ]]</span>
</a-tag>
<div @click="copy(link)" style="
cursor: pointer;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 25px 20px 15px 20px;
margin-top: -12px;
word-break: break-all;
color: #fff;
font-size: 13px;
line-height: 1.5;
text-align: left;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
" onmouseover="this.style.background='rgba(0, 0, 0, 0.3)'; this.style.borderColor='rgba(255, 255, 255, 0.2)'"
onmouseout="this.style.background='rgba(0, 0, 0, 0.2)'; this.style.borderColor='rgba(255, 255, 255, 0.1)'">
[[ link ]]
</div> </div>
</a-list-item> </div>
</a-list> </div>
</div>
<br /> <br />
<a-form layout="vertical"> <a-form layout="vertical">
<a-form-item> <a-form-item>
<a-row type="flex" justify="center" :gutter="[8,8]" <a-row type="flex" justify="center" :gutter="[8,8]" style="width:100%">
style="width:100%"> <a-col :xs="24" :sm="12" style="text-align:center;">
<a-col :xs="24" :sm="12"
style="text-align:center;">
<!-- Android dropdown --> <!-- Android dropdown -->
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-button icon="android" :block="isMobile" <a-button icon="android" :block="isMobile"
:style="{ marginTop: isMobile ? '6px' : 0 }" :style="{ marginTop: isMobile ? '6px' : 0 }" size="large" type="primary">
size="large" type="primary">
Android <a-icon type="down" /> Android <a-icon type="down" />
</a-button> </a-button>
<a-menu slot="overlay" <a-menu slot="overlay" :class="themeSwitcher.currentTheme">
:class="themeSwitcher.currentTheme">
<a-menu-item key="android-v2box" <a-menu-item key="android-v2box"
@click="open('v2box://install-sub?url=' + encodeURIComponent(app.subUrl) + '&name=' + encodeURIComponent(app.sId))">V2Box</a-menu-item> @click="open('v2box://install-sub?url=' + encodeURIComponent(app.subUrl) + '&name=' + encodeURIComponent(app.sId))">V2Box</a-menu-item>
<a-menu-item key="android-v2rayng" <a-menu-item key="android-v2rayng"
@ -194,39 +181,32 @@
@click="copy(app.subUrl)">Sing-box</a-menu-item> @click="copy(app.subUrl)">Sing-box</a-menu-item>
<a-menu-item key="android-v2raytun" <a-menu-item key="android-v2raytun"
@click="copy(app.subUrl)">V2RayTun</a-menu-item> @click="copy(app.subUrl)">V2RayTun</a-menu-item>
<a-menu-item key="android-npvtunnel" <a-menu-item key="android-npvtunnel" @click="copy(app.subUrl)">NPV
@click="copy(app.subUrl)">NPV
Tunnel</a-menu-item> Tunnel</a-menu-item>
<a-menu-item key="android-happ" <a-menu-item key="android-happ"
@click="open('happ://add/' + encodeURIComponent(app.subUrl))">Happ</a-menu-item> @click="open('happ://add/' + encodeURIComponent(app.subUrl))">Happ</a-menu-item>
</a-menu> </a-menu>
</a-dropdown> </a-dropdown>
</a-col> </a-col>
<a-col :xs="24" :sm="12" <a-col :xs="24" :sm="12" style="text-align:center;">
style="text-align:center;">
<!-- iOS dropdown --> <!-- iOS dropdown -->
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-button icon="apple" :block="isMobile" <a-button icon="apple" :block="isMobile"
:style="{ marginTop: isMobile ? '6px' : 0 }" :style="{ marginTop: isMobile ? '6px' : 0 }" size="large" type="primary">
size="large" type="primary">
iOS <a-icon type="down" /> iOS <a-icon type="down" />
</a-button> </a-button>
<a-menu slot="overlay" <a-menu slot="overlay" :class="themeSwitcher.currentTheme">
:class="themeSwitcher.currentTheme">
<a-menu-item key="ios-shadowrocket" <a-menu-item key="ios-shadowrocket"
@click="open(shadowrocketUrl)">Shadowrocket</a-menu-item> @click="open(shadowrocketUrl)">Shadowrocket</a-menu-item>
<a-menu-item key="ios-v2box" <a-menu-item key="ios-v2box" @click="open(v2boxUrl)">V2Box</a-menu-item>
@click="open(v2boxUrl)">V2Box</a-menu-item>
<a-menu-item key="ios-streisand" <a-menu-item key="ios-streisand"
@click="open(streisandUrl)">Streisand</a-menu-item> @click="open(streisandUrl)">Streisand</a-menu-item>
<a-menu-item key="ios-v2raytun" <a-menu-item key="ios-v2raytun"
@click="copy(v2raytunUrl)">V2RayTun</a-menu-item> @click="copy(v2raytunUrl)">V2RayTun</a-menu-item>
<a-menu-item key="ios-npvtunnel" <a-menu-item key="ios-npvtunnel" @click="copy(npvtunUrl)">NPV
@click="copy(npvtunUrl)">NPV
Tunnel Tunnel
</a-menu-item> </a-menu-item>
<a-menu-item key="ios-happ" <a-menu-item key="ios-happ" @click="open(happUrl)">Happ</a-menu-item>
@click="open(happUrl)">Happ</a-menu-item>
</a-menu> </a-menu>
</a-dropdown> </a-dropdown>
</a-col> </a-col>
@ -240,17 +220,12 @@
</a-layout> </a-layout>
<!-- Bootstrap data for external JS --> <!-- Bootstrap data for external JS -->
<template id="subscription-data" data-sid="{{ .sId }}" <template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}"
data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}" data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}"
data-download="{{ .download }}" data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
data-upload="{{ .upload }}" data-used="{{ .used }}" data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
data-total="{{ .total }}" data-remained="{{ .remained }}"
data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
data-downloadbyte="{{ .downloadByte }}"
data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
data-datepicker="{{ .datepicker }}"></template> data-datepicker="{{ .datepicker }}"></template>
<textarea id="subscription-links" <textarea id="subscription-links" style="display:none">{{ range .result }}{{ . }}
style="display:none">{{ range .result }}{{ . }}
{{ end }}</textarea> {{ end }}</textarea>
{{template "component/aThemeSwitch" .}} {{template "component/aThemeSwitch" .}}

View file

@ -3,8 +3,8 @@
<a-row> <a-row>
<a-col :xs="12" :sm="12" :lg="12"> <a-col :xs="12" :sm="12" :lg="12">
<a-space direction="horizontal" size="small"> <a-space direction="horizontal" size="small">
<a-button type="primary" icon="plus" @click="addOutbound()"> <a-button type="primary" icon="plus" @click="addOutbound">
{{ i18n "pages.xray.outbound.addOutbound" }} <span v-if="!isMobile">{{ i18n "pages.xray.outbound.addOutbound" }}</span>
</a-button> </a-button>
<a-button type="primary" icon="cloud" @click="showWarp()">WARP</a-button> <a-button type="primary" icon="cloud" @click="showWarp()">WARP</a-button>
</a-space> </a-space>

View file

@ -269,7 +269,7 @@
tag: "direct", tag: "direct",
protocol: "freedom" protocol: "freedom"
}, },
routingDomainStrategies: ["AsIs", "IpIfNonMatch", "IpOnDemand"], routingDomainStrategies: ["AsIs", "IPIfNonMatch", "IPOnDemand"],
log: { log: {
loglevel: ["none", "debug", "info", "warning", "error"], loglevel: ["none", "debug", "info", "warning", "error"],
access: ["none", "./access.log"], access: ["none", "./access.log"],
@ -968,6 +968,17 @@
await this.getXraySetting(); await this.getXraySetting();
await this.getXrayResult(); await this.getXrayResult();
await this.getOutboundsTraffic(); await this.getOutboundsTraffic();
if (window.wsClient) {
window.wsClient.connect();
window.wsClient.on('outbounds', (payload) => {
if (payload) {
this.outboundsTraffic = payload;
this.$forceUpdate();
}
});
}
while (true) { while (true) {
await PromiseUtil.sleep(800); await PromiseUtil.sleep(800);
this.saveBtnDisable = this.oldXraySetting === this.xraySetting; this.saveBtnDisable = this.oldXraySetting === this.xraySetting;

View file

@ -58,7 +58,19 @@ func (j *XrayTrafficJob) Run() {
lastOnlineMap = make(map[string]int64) lastOnlineMap = make(map[string]int64)
} }
// Broadcast traffic update via WebSocket // Fetch updated inbounds from database with accumulated traffic values
// This ensures frontend receives the actual total traffic, not just delta values
updatedInbounds, err := j.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("get all inbounds for websocket failed:", err)
}
updatedOutbounds, err := j.outboundService.GetOutboundsTraffic()
if err != nil {
logger.Warning("get all outbounds for websocket failed:", err)
}
// Broadcast traffic update via WebSocket with accumulated values from database
trafficUpdate := map[string]interface{}{ trafficUpdate := map[string]interface{}{
"traffics": traffics, "traffics": traffics,
"clientTraffics": clientTraffics, "clientTraffics": clientTraffics,
@ -66,6 +78,16 @@ func (j *XrayTrafficJob) Run() {
"lastOnlineMap": lastOnlineMap, "lastOnlineMap": lastOnlineMap,
} }
websocket.BroadcastTraffic(trafficUpdate) websocket.BroadcastTraffic(trafficUpdate)
// Broadcast full inbounds update for real-time UI refresh
if updatedInbounds != nil {
websocket.BroadcastInbounds(updatedInbounds)
}
if updatedOutbounds != nil {
websocket.BroadcastOutbounds(updatedOutbounds)
}
} }
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) { func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {

View file

@ -1010,12 +1010,12 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
if len(traffics) == 0 { if len(traffics) == 0 {
// Empty onlineUsers // Empty onlineUsers
if p != nil { if p != nil {
p.SetOnlineClients(nil) p.SetOnlineClients(make([]string, 0))
} }
return nil return nil
} }
var onlineClients []string onlineClients := make([]string, 0)
emails := make([]string, 0, len(traffics)) emails := make([]string, 0, len(traffics))
for _, traffic := range traffics { for _, traffic := range traffics {

View file

@ -20,6 +20,7 @@ const (
MessageTypeInbounds MessageType = "inbounds" // Inbounds list update MessageTypeInbounds MessageType = "inbounds" // Inbounds list update
MessageTypeNotification MessageType = "notification" // System notification MessageTypeNotification MessageType = "notification" // System notification
MessageTypeXrayState MessageType = "xray_state" // Xray state change MessageTypeXrayState MessageType = "xray_state" // Xray state change
MessageTypeOutbounds MessageType = "outbounds" // Outbounds list update
) )
// Message represents a WebSocket message // Message represents a WebSocket message

View file

@ -48,6 +48,14 @@ func BroadcastInbounds(inbounds interface{}) {
} }
} }
// BroadcastOutbounds broadcasts outbounds list update to all connected clients
func BroadcastOutbounds(outbounds interface{}) {
hub := GetHub()
if hub != nil {
hub.Broadcast(MessageTypeOutbounds, outbounds)
}
}
// BroadcastNotification broadcasts a system notification to all connected clients // BroadcastNotification broadcasts a system notification to all connected clients
func BroadcastNotification(title, message, level string) { func BroadcastNotification(title, message, level string) {
hub := GetHub() hub := GetHub()