Browse Source

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 <[email protected]>
lolka1333 3 days ago
parent
commit
4800f8fb70

+ 5 - 5
web/assets/js/subscription.js

@@ -138,14 +138,14 @@
         return `streisand://import/${encodeURIComponent(this.app.subUrl)}`;
       },
       v2raytunUrl() {
-        return this.app.subUrl; 
+        return this.app.subUrl;
       },
       npvtunUrl() {
-        return this.app.subUrl; 
+        return this.app.subUrl;
       },
-	  happUrl() {
-		return `happ://add/${encodeURIComponent(this.app.subUrl)}`;
-	  }
+      happUrl() {
+        return `happ://add/${encodeURIComponent(this.app.subUrl)}`;
+      }
     },
     methods: {
       renderLink,

+ 34 - 0
web/html/common/page.html

@@ -24,6 +24,40 @@
     body {
       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>
   <title>{{ .host }} – {{ i18n .title}}</title>
 {{ end }}

+ 99 - 112
web/html/form/protocol/vless.html

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

+ 3 - 20
web/html/inbounds.html

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

+ 71 - 96
web/html/settings/panel/subscription/subpage.html

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

+ 2 - 2
web/html/settings/xray/outbounds.html

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

+ 12 - 1
web/html/xray.html

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

+ 23 - 1
web/job/xray_traffic_job.go

@@ -58,7 +58,19 @@ func (j *XrayTrafficJob) Run() {
 		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{}{
 		"traffics":       traffics,
 		"clientTraffics": clientTraffics,
@@ -66,6 +78,16 @@ func (j *XrayTrafficJob) Run() {
 		"lastOnlineMap":  lastOnlineMap,
 	}
 	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) {

+ 2 - 2
web/service/inbound.go

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

+ 1 - 0
web/websocket/hub.go

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

+ 8 - 0
web/websocket/notifier.go

@@ -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
 func BroadcastNotification(title, message, level string) {
 	hub := GetHub()