8 Commits 3313086071 ... ad30298700

Autor SHA1 Mensagem Data
  MHSanaei ad30298700 Exclude virtual interfaces from network stats há 22 horas atrás
  MHSanaei 9be11e109e fix design há 22 horas atrás
  MHSanaei 7117d19fd1 fix: filter view in mobile há 1 dia atrás
  MHSanaei c88627a839 outbound: mobile style há 1 dia atrás
  MHSanaei c718e7ca5b fix(inbounds): remove stale reverse outbound tags after client deletion há 1 dia atrás
  pwnnex 6a483fa987 inbound: check transport in port conflict, allow tcp and udp on same port (#4169) há 1 dia atrás
  MHSanaei 47163c1418 Skip 26.5.3 and bump Xray version cutoff há 1 dia atrás
  MHSanaei 09f4f09b84 fix design há 1 dia atrás

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
web/assets/css/custom.min.css


+ 15 - 13
web/html/component/aClientTable.html

@@ -55,7 +55,7 @@
   </a-popover>
 </template>
 <template slot="client" slot-scope="text, client">
-  <a-space direction="horizontal" :size="2">
+  <a-space direction="horizontal" :size="2" style="flex-wrap:nowrap;min-width:0">
     <a-tooltip>
       <template slot="title">
         <template v-if="isClientDepleted(record, client.email)">{{ i18n "depleted" }}</template>
@@ -65,8 +65,10 @@
       <a-badge :class="isClientOnline(client.email)? 'online-animation' : ''"
         :color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge>
     </a-tooltip>
-    <a-space direction="vertical" :size="2">
-      <span class="client-email">[[ client.email ]]</span>
+    <a-space direction="vertical" :size="2" style="min-width:0;overflow:hidden">
+      <a-tooltip :title="client.email" :overlay-class-name="themeSwitcher.currentTheme">
+        <span class="client-email">[[ client.email ]]</span>
+      </a-tooltip>
       <template v-if="client.comment && client.comment.trim()">
         <a-tooltip v-if="client.comment.length > 50" :overlay-class-name="themeSwitcher.currentTheme">
           <template slot="title">
@@ -94,7 +96,7 @@
       </table>
     </template>
     <div class="tr-table-box">
-      <div class="tr-table-lt">[[ SizeFormatter.sizeFormat(getSumStats(record, client.email)) ]]</div>
+      <div class="tr-table-rt">[[ SizeFormatter.sizeFormat(getSumStats(record, client.email)) ]]</div>
       <div class="tr-table-bar" v-if="!client.enable">
         <a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" />
       </div>
@@ -184,20 +186,20 @@
   </a-dropdown>
 </template>
 <template slot="info" slot-scope="text, client, index">
-  <a-popover placement="bottomRight" :overlay-class-name="themeSwitcher.currentTheme" trigger="click">
+  <a-popover :placement="isMobile ? 'bottomLeft' : 'bottomRight'" :overlay-class-name="themeSwitcher.currentTheme" trigger="click">
     <template slot="content">
       <table>
         <tr>
           <td colspan="3" :style="{ textAlign: 'center' }">{{ i18n "pages.inbounds.traffic" }}</td>
         </tr>
         <tr>
-          <td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[
+          <td width="65px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[
             SizeFormatter.sizeFormat(getUpStats(record, client.email) + getDownStats(record, client.email)) ]] </td>
-          <td width="120px" v-if="!client.enable">
+          <td width="90px" v-if="!client.enable">
             <a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false"
               :percent="statsProgress(record, client.email)" />
           </td>
-          <td width="120px" v-else-if="client.totalGB > 0">
+          <td width="90px" v-else-if="client.totalGB > 0">
             <a-popover :overlay-class-name="themeSwitcher.currentTheme">
               <template slot="content" v-if="client.email">
                 <table cellpadding="2" width="100%">
@@ -216,11 +218,11 @@
                 :percent="statsProgress(record, client.email)" />
             </a-popover>
           </td>
-          <td width="120px" v-else class="infinite-bar">
+          <td width="90px" v-else class="infinite-bar">
             <a-progress :stroke-color="themeSwitcher.isDarkTheme ? '#2c1e32':'#F2EAF1'" :show-info="false"
               :percent="100"></a-progress>
           </td>
-          <td width="80px">
+          <td width="60px">
             <template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
             <span v-else class="tr-infinity-ch">&infin;</span>
           </td>
@@ -233,9 +235,9 @@
         </tr>
         <tr>
           <template v-if="client.expiryTime !=0 && client.reset >0">
-            <td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[
+            <td width="65px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[
               IntlUtil.formatRelativeTime(client.expiryTime) ]] </td>
-            <td width="120px" class="infinite-bar">
+            <td width="90px" class="infinite-bar">
               <a-popover :overlay-class-name="themeSwitcher.currentTheme">
                 <template slot="content">
                   <span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span>
@@ -245,7 +247,7 @@
                   :percent="expireProgress(client.expiryTime, client.reset)" />
               </a-popover>
             </td>
-            <td width="60px">[[ client.reset + "d" ]]</td>
+            <td width="50px">[[ client.reset + "d" ]]</td>
           </template>
           <template v-else>
             <td colspan="3" :style="{ textAlign: 'center' }">

+ 27 - 1
web/html/inbounds.html

@@ -182,6 +182,7 @@
                     <a-input v-if="!enableFilter" v-model.lazy="searchKey" placeholder='{{ i18n "search" }}' autofocus
                       :style="{ maxWidth: '300px' }" :size="isMobile ? 'small' : ''"></a-input>
                     <a-radio-group v-if="enableFilter" v-model="filterBy" @change="filterInbounds" button-style="solid"
+                      class="mobile-filter-group"
                       :size="isMobile ? 'small' : ''">
                       <a-radio-button value>{{ i18n "none" }}</a-radio-button>
                       <a-radio-button value="active">{{ i18n "subscription.active"
@@ -648,7 +649,7 @@
                         :data-source="getInboundClients(record)"
                         :pagination=pagination(getInboundClients(record))
                         :scroll="isMobile ? {} : { x: 'max-content' }"
-                        :style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }">
+                        :style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -21px` }">
                         {{template "component/aClientTable" .}}
                       </a-table>
                     </template>
@@ -2241,6 +2242,31 @@
     #content-layout>.ant-layout-content>.ant-spin-nested-loading>div>.ant-spin {
       left: 50vw !important;
     }
+
+    /* Keep filter choices in a single horizontal line on phones. */
+    .inbounds-page .mobile-filter-group {
+      display: flex;
+      flex-wrap: nowrap;
+      max-width: 100%;
+      overflow-x: auto;
+      overflow-y: hidden;
+      padding-bottom: 2px;
+      -webkit-overflow-scrolling: touch;
+      scrollbar-width: thin;
+    }
+    .inbounds-page .mobile-filter-group .ant-radio-button-wrapper {
+      flex: 0 0 auto;
+      white-space: nowrap;
+    }
+
+    /* Prevent mobile row content from splitting across multiple lines. */
+    .inbounds-page .ant-table-tbody > tr > td {
+      white-space: nowrap;
+    }
+    .inbounds-page .ant-table-tbody > tr > td:nth-child(3) {
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
   }
 
   /* Protocol cell — wrap tags into a flex grid with consistent gap so

+ 108 - 4
web/html/settings/xray/outbounds.html

@@ -4,8 +4,7 @@
         <a-col :xs="24" :sm="14" :lg="14">
             <a-space direction="horizontal" size="small" class="outbounds-toolbar">
                 <a-button type="primary" icon="plus" @click="addOutbound">
-                    <span v-if="!isMobile">{{ i18n
-                        "pages.xray.outbound.addOutbound" }}</span>
+                    <span v-if="!isMobile">{{ i18n "pages.xray.outbound.addOutbound" }}</span>
                 </a-button>
                 <a-button type="primary" icon="cloud" @click="showWarp()">WARP</a-button>
                 <a-button type="primary" icon="api" @click="showNord()">NordVPN</a-button>
@@ -25,9 +24,114 @@
             </a-button-group>
         </a-col>
     </a-row>
-    <a-table :columns="outboundColumns" :row-key="r => r.key"
+
+    <!-- Mobile: card list -->
+    <template v-if="isMobile">
+        <div v-if="outboundData.length === 0" class="outbound-card-empty">—</div>
+        <div v-for="(outbound, index) in outboundData" :key="outbound.key" class="outbound-card">
+            <!-- card header: number + tag + protocol pills + action menu -->
+            <div class="outbound-card-header">
+                <div class="outbound-card-identity">
+                    <div class="outbound-card-title">
+                        <span class="outbound-card-num">[[ index + 1 ]]</span>
+                        <a-tooltip :title="outbound.tag" :overlay-class-name="themeSwitcher.currentTheme">
+                            <span class="outbound-tag">[[ outbound.tag ]]</span>
+                        </a-tooltip>
+                    </div>
+                    <div class="outbound-protocol-cell">
+                        <span class="outbound-pill" :class="outboundProtocolTone(outbound.protocol)">
+                            [[ outbound.protocol ]]
+                        </span>
+                        <template v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)">
+                            <span class="outbound-pill" :class="outboundNetworkTone(outbound.streamSettings.network)">
+                                [[ outbound.streamSettings.network ]]
+                            </span>
+                            <span class="outbound-pill" :class="outboundSecurityTone(outbound.streamSettings.security)"
+                                v-if="isOutboundSecurityVisible(outbound.streamSettings.security)">
+                                [[ outbound.streamSettings.security ]]
+                            </span>
+                        </template>
+                    </div>
+                </div>
+                <a-dropdown :trigger="['click']">
+                    <a-button shape="circle" size="small" class="outbound-action-btn"
+                        @click="e => e.preventDefault()">
+                        <a-icon type="more"></a-icon>
+                    </a-button>
+                    <a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
+                        <a-menu-item v-if="index > 0" @click="setFirstOutbound(index)">
+                            <a-icon type="vertical-align-top"></a-icon>
+                            <span>{{ i18n "pages.xray.rules.first" }}</span>
+                        </a-menu-item>
+                        <a-menu-item @click="editOutbound(index)">
+                            <a-icon type="edit"></a-icon>
+                            <span>{{ i18n "edit" }}</span>
+                        </a-menu-item>
+                        <a-menu-item @click="resetOutboundTraffic(index)">
+                            <a-icon type="retweet"></a-icon>
+                            <span>{{ i18n "pages.inbounds.resetTraffic" }}</span>
+                        </a-menu-item>
+                        <a-menu-item @click="deleteOutbound(index)">
+                            <span :style="{ color: '#FF4D4F' }">
+                                <a-icon type="delete"></a-icon>
+                                <span>{{ i18n "delete" }}</span>
+                            </span>
+                        </a-menu-item>
+                    </a-menu>
+                </a-dropdown>
+            </div>
+            <!-- address pills -->
+            <div class="outbound-address-list" v-if="outboundAddresses(outbound).length > 0">
+                <a-tooltip v-for="addr in outboundAddresses(outbound)" :key="addr"
+                    :title="addr" :overlay-class-name="themeSwitcher.currentTheme">
+                    <span class="outbound-address-pill">[[ addr ]]</span>
+                </a-tooltip>
+            </div>
+            <!-- card footer: traffic + test -->
+            <div class="outbound-card-footer">
+                <div class="outbound-traffic-cell">
+                    <span class="outbound-traffic-up">
+                        <a-icon type="arrow-up"></a-icon>
+                        [[ SizeFormatter.sizeFormat(findOutboundUp(outbound)) ]]
+                    </span>
+                    <span class="outbound-traffic-sep" aria-hidden="true"></span>
+                    <span class="outbound-traffic-down">
+                        <a-icon type="arrow-down"></a-icon>
+                        [[ SizeFormatter.sizeFormat(findOutboundDown(outbound)) ]]
+                    </span>
+                </div>
+                <div class="outbound-card-test">
+                    <div v-if="outboundTestResult(index)">
+                        <span v-if="outboundTestResult(index).success"
+                            class="outbound-result-pill outbound-result-ok">
+                            <a-icon type="check-circle" theme="filled"></a-icon>
+                            [[ outboundTestResult(index).delay ]]&nbsp;ms
+                        </span>
+                        <a-tooltip v-else :title="outboundTestResult(index).error"
+                            :overlay-class-name="themeSwitcher.currentTheme">
+                            <span class="outbound-result-pill outbound-result-fail">
+                                <a-icon type="close-circle" theme="filled"></a-icon>
+                                {{ i18n "pages.xray.outbound.testFailed" }}
+                            </span>
+                        </a-tooltip>
+                    </div>
+                    <a-icon type="loading" class="outbound-result-loading"
+                        v-else-if="isOutboundTesting(index)"></a-icon>
+                    <a-button type="primary" shape="circle" size="small" icon="thunderbolt"
+                        class="outbound-test-btn"
+                        :loading="isOutboundTesting(index)"
+                        @click="testOutbound(index)"
+                        :disabled="isOutboundUntestable(outbound) || isOutboundTesting(index)">
+                    </a-button>
+                </div>
+            </div>
+        </div>
+    </template>
+
+    <!-- Desktop: table -->
+    <a-table v-if="!isMobile" :columns="outboundColumns" :row-key="r => r.key"
         :data-source="outboundData"
-        :scroll="isMobile ? { x: 720 } : {}"
+        :scroll="{}"
         :pagination="false"
         :indent-size="0"
         class="outbounds-table"

+ 87 - 3
web/html/xray.html

@@ -169,8 +169,8 @@
     // network + security pills sit underneath it. Width chosen so the three
     // longest tonal pills (e.g. vless + httpupgrade + reality) fit on a
     // single line without wrapping.
-    { title: '{{ i18n "pages.xray.outbound.tag"}}', align: 'left', width: 280, scopedSlots: { customRender: 'identity' } },
-    { title: '{{ i18n "pages.xray.outbound.address"}}', align: 'left', scopedSlots: { customRender: 'address' } },
+    { title: '{{ i18n "pages.xray.outbound.tag"}}', align: 'left', width: 190, scopedSlots: { customRender: 'identity' } },
+    { title: '{{ i18n "pages.xray.outbound.address"}}', align: 'left', width: 230, scopedSlots: { customRender: 'address' } },
     { title: '{{ i18n "pages.inbounds.traffic" }}', align: 'left', width: 190, scopedSlots: { customRender: 'traffic' } },
     { title: '{{ i18n "pages.xray.outbound.testResult" }}', align: 'left', width: 130, scopedSlots: { customRender: 'testResult' } },
     { title: '{{ i18n "pages.xray.outbound.test" }}', align: 'center', width: 70, scopedSlots: { customRender: 'test' } },
@@ -1992,7 +1992,7 @@
     line-height: 1.5;
     border: 1px solid rgba(255, 255, 255, 0.06);
     display: inline-block;
-    max-width: 240px;
+    max-width: 190px;
     overflow: hidden;
     text-overflow: ellipsis;
     white-space: nowrap;
@@ -2273,5 +2273,89 @@
   }
   .light .xray-page .routing-target-icon { color: rgba(0, 0, 0, 0.45); }
 
+  /* ───────── Mobile outbound cards ───────── */
+  @media (max-width: 768px) {
+    .xray-page .outbounds-toolbar-right { text-align: left; }
+
+    .xray-page .outbound-card-empty {
+      text-align: center;
+      padding: 24px;
+      color: rgba(255, 255, 255, 0.35);
+      font-style: italic;
+    }
+    .light .xray-page .outbound-card-empty { color: rgba(0, 0, 0, 0.35); }
+
+    .xray-page .outbound-card {
+      background: rgba(255, 255, 255, 0.04);
+      border: 1px solid rgba(255, 255, 255, 0.07);
+      border-radius: 14px;
+      padding: 14px 16px;
+      display: flex;
+      flex-direction: column;
+      gap: 10px;
+      transition: background 0.15s ease;
+    }
+    .light .xray-page .outbound-card {
+      background: rgba(0, 0, 0, 0.025);
+      border-color: rgba(0, 0, 0, 0.06);
+    }
+
+    .xray-page .outbound-card-header {
+      display: flex;
+      align-items: flex-start;
+      justify-content: space-between;
+      gap: 8px;
+    }
+
+    .xray-page .outbound-card-identity {
+      display: flex;
+      flex-direction: column;
+      gap: 6px;
+      min-width: 0;
+      flex: 1;
+    }
+
+    .xray-page .outbound-card-title {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      min-width: 0;
+    }
+
+    .xray-page .outbound-card-num {
+      font-size: 11px;
+      font-weight: 600;
+      color: rgba(255, 255, 255, 0.35);
+      font-variant-numeric: tabular-nums;
+      flex: 0 0 auto;
+    }
+    .light .xray-page .outbound-card-num { color: rgba(0, 0, 0, 0.35); }
+
+    .xray-page .outbound-card .outbound-tag { font-size: 14px; }
+
+    .xray-page .outbound-card .outbound-protocol-cell { flex-wrap: wrap; }
+
+    .xray-page .outbound-card-footer {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 8px;
+      flex-wrap: wrap;
+    }
+
+    .xray-page .outbound-card .outbound-traffic-cell {
+      font-size: 12px;
+      padding: 4px 10px;
+      gap: 8px;
+    }
+
+    .xray-page .outbound-card-test {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      flex-wrap: wrap;
+    }
+  }
+
 </style>
 {{ template "page/body_end" .}}

+ 53 - 45
web/service/inbound.go

@@ -104,36 +104,6 @@ func (s *InboundService) GetInboundsByTrafficReset(period string) ([]*model.Inbo
 	return inbounds, nil
 }
 
-func (s *InboundService) checkPortExist(listen string, port int, ignoreId int) (bool, error) {
-	db := database.GetDB()
-	if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
-		db = db.Model(model.Inbound{}).Where("port = ?", port)
-	} else {
-		db = db.Model(model.Inbound{}).
-			Where("port = ?", port).
-			Where(
-				db.Model(model.Inbound{}).Where(
-					"listen = ?", listen,
-				).Or(
-					"listen = \"\"",
-				).Or(
-					"listen = \"0.0.0.0\"",
-				).Or(
-					"listen = \"::\"",
-				).Or(
-					"listen = \"::0\""))
-	}
-	if ignoreId > 0 {
-		db = db.Where("id != ?", ignoreId)
-	}
-	var count int64
-	err := db.Count(&count).Error
-	if err != nil {
-		return false, err
-	}
-	return count > 0, nil
-}
-
 func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, error) {
 	settings := map[string][]model.Client{}
 	json.Unmarshal([]byte(inbound.Settings), &settings)
@@ -221,7 +191,7 @@ func (s *InboundService) checkEmailExistForInbound(inbound *model.Inbound) (stri
 // then saves the inbound to the database and optionally adds it to the running Xray instance.
 // Returns the created inbound, whether Xray needs restart, and any error.
 func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
-	exist, err := s.checkPortExist(inbound.Listen, inbound.Port, 0)
+	exist, err := s.checkPortConflict(inbound, 0)
 	if err != nil {
 		return inbound, false, err
 	}
@@ -229,6 +199,16 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		return inbound, false, common.NewError("Port already exists:", inbound.Port)
 	}
 
+	// pick a tag that won't collide with an existing row. for the common
+	// case this is the same "inbound-<port>" string the controller already
+	// set; only when this port already has another inbound on a different
+	// transport (now possible after the transport-aware port check) does
+	// this disambiguate with a -tcp/-udp suffix. see #4103.
+	inbound.Tag, err = s.generateInboundTag(inbound, 0)
+	if err != nil {
+		return inbound, false, err
+	}
+
 	existEmail, err := s.checkEmailExistForInbound(inbound)
 	if err != nil {
 		return inbound, false, err
@@ -462,7 +442,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
 // It validates changes, updates the database, and syncs with the running Xray instance.
 // Returns the updated inbound, whether Xray needs restart, and any error.
 func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
-	exist, err := s.checkPortExist(inbound.Listen, inbound.Port, inbound.Id)
+	exist, err := s.checkPortConflict(inbound, inbound.Id)
 	if err != nil {
 		return inbound, false, err
 	}
@@ -565,10 +545,11 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	oldInbound.Settings = inbound.Settings
 	oldInbound.StreamSettings = inbound.StreamSettings
 	oldInbound.Sniffing = inbound.Sniffing
-	if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
-		oldInbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
-	} else {
-		oldInbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port)
+	// regenerate tag with collision-aware logic. for this row we pass
+	// inbound.Id as ignoreId so it doesn't see its own old tag in the db.
+	oldInbound.Tag, err = s.generateInboundTag(inbound, inbound.Id)
+	if err != nil {
+		return inbound, false, err
 	}
 
 	needRestart := false
@@ -1766,18 +1747,45 @@ func (s *InboundService) GetInboundTags() (string, error) {
 
 func (s *InboundService) GetClientReverseTags() (string, error) {
 	db := database.GetDB()
-	var rawTags []string
-	err := db.Raw(`
-		SELECT DISTINCT JSON_EXTRACT(client.value, '$.reverse.tag')
-		FROM inbounds,
-			JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
-		WHERE inbounds.protocol = 'vless'
-		  AND JSON_EXTRACT(client.value, '$.reverse.tag') IS NOT NULL
-		  AND JSON_EXTRACT(client.value, '$.reverse.tag') != ''
-	`).Scan(&rawTags).Error
+	var inbounds []model.Inbound
+	err := db.Model(model.Inbound{}).Select("settings").Where("protocol = ?", "vless").Find(&inbounds).Error
 	if err != nil && err != gorm.ErrRecordNotFound {
 		return "[]", err
 	}
+
+	tagSet := make(map[string]struct{})
+	for _, inbound := range inbounds {
+		var settings map[string]any
+		if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
+			continue
+		}
+		clients, ok := settings["clients"].([]any)
+		if !ok {
+			continue
+		}
+		for _, client := range clients {
+			clientMap, ok := client.(map[string]any)
+			if !ok {
+				continue
+			}
+			reverse, ok := clientMap["reverse"].(map[string]any)
+			if !ok {
+				continue
+			}
+			tag, _ := reverse["tag"].(string)
+			tag = strings.TrimSpace(tag)
+			if tag != "" {
+				tagSet[tag] = struct{}{}
+			}
+		}
+	}
+
+	rawTags := make([]string, 0, len(tagSet))
+	for tag := range tagSet {
+		rawTags = append(rawTags, tag)
+	}
+	sort.Strings(rawTags)
+
 	result, _ := json.Marshal(rawTags)
 	return string(result), nil
 }

+ 234 - 0
web/service/port_conflict.go

@@ -0,0 +1,234 @@
+package service
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v2/database"
+	"github.com/mhsanaei/3x-ui/v2/database/model"
+	"github.com/mhsanaei/3x-ui/v2/util/common"
+)
+
+// transportBits is a bitmask of L4 transports an inbound listens on.
+// 0.0.0.0:443/tcp and 0.0.0.0:443/udp are independent sockets in linux,
+// so the conflict check needs more than just the port number.
+type transportBits uint8
+
+const (
+	transportTCP transportBits = 1 << iota
+	transportUDP
+)
+
+// conflicts is true when the two masks share any L4 transport.
+func (b transportBits) conflicts(o transportBits) bool { return b&o != 0 }
+
+// inboundTransports returns the L4 transports the given inbound listens on.
+// always returns at least one bit (falls back to tcp on parse errors), so
+// the validator never gets looser than the old port-only check.
+//
+// the rules:
+//   - hysteria, hysteria2, wireguard: udp regardless of streamSettings
+//   - streamSettings.network=kcp: udp
+//   - shadowsocks: whatever settings.network says ("tcp" / "udp" / "tcp,udp")
+//   - mixed (socks/http combo): tcp + udp when settings.udp is true
+//   - everything else: tcp
+func inboundTransports(protocol model.Protocol, streamSettings, settings string) transportBits {
+	// protocols that ignore streamSettings entirely.
+	switch protocol {
+	case model.Hysteria, model.Hysteria2, model.WireGuard:
+		return transportUDP
+	}
+
+	var bits transportBits
+
+	// peek at streamSettings.network to spot udp transports like kcp.
+	// parse errors are non-fatal: missing or weird streamSettings just
+	// keeps the default tcp bit below.
+	network := ""
+	if streamSettings != "" {
+		var ss map[string]any
+		if json.Unmarshal([]byte(streamSettings), &ss) == nil {
+			if n, _ := ss["network"].(string); n != "" {
+				network = n
+			}
+		}
+	}
+	if network == "kcp" {
+		bits |= transportUDP
+	} else {
+		bits |= transportTCP
+	}
+
+	// some protocols also listen on udp on the same port via their own
+	// settings json. parse and merge.
+	if settings != "" {
+		var st map[string]any
+		if json.Unmarshal([]byte(settings), &st) == nil {
+			switch protocol {
+			case model.Shadowsocks:
+				// shadowsocks settings.network controls both tcp and udp,
+				// independently of streamSettings. the field takes "tcp",
+				// "udp", or "tcp,udp". if it's set, it wins outright.
+				if n, ok := st["network"].(string); ok && n != "" {
+					bits = 0
+					for _, part := range strings.Split(n, ",") {
+						switch strings.TrimSpace(part) {
+						case "tcp":
+							bits |= transportTCP
+						case "udp":
+							bits |= transportUDP
+						}
+					}
+				}
+			case model.Mixed:
+				// socks/http "mixed" inbound: settings.udp=true means it
+				// also relays udp on the same port (socks5 udp associate).
+				if udpOn, _ := st["udp"].(bool); udpOn {
+					bits |= transportUDP
+				}
+			}
+		}
+	}
+
+	// safety net: never return zero, even if every parse failed.
+	if bits == 0 {
+		bits = transportTCP
+	}
+	return bits
+}
+
+// listenOverlaps reports whether two listen addresses can collide on the
+// same port. preserves the rule from the original checkPortExist:
+// any-address (empty / 0.0.0.0 / :: / ::0) overlaps with everything,
+// otherwise only identical specific addresses overlap.
+func listenOverlaps(a, b string) bool {
+	if isAnyListen(a) || isAnyListen(b) {
+		return true
+	}
+	return a == b
+}
+
+func isAnyListen(s string) bool {
+	return s == "" || s == "0.0.0.0" || s == "::" || s == "::0"
+}
+
+// checkPortConflict reports whether adding/updating an inbound on
+// (listen, port) would clash with an existing inbound. unlike the old
+// port-only check, this one understands that tcp/443 and udp/443 are
+// independent sockets in linux and may coexist on the same address.
+//
+// the listen-overlap rule (specific addr conflicts with any-addr on the
+// same port, both directions) is preserved from the previous check.
+func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (bool, error) {
+	db := database.GetDB()
+
+	// pull every candidate on this port; we filter by listen-overlap and
+	// transport in go to keep the sql plain. the port column is indexed
+	// in practice by the existing port check, and the candidate set is
+	// tiny (one per coexisting socket family at most).
+	var candidates []*model.Inbound
+	q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port)
+	if ignoreId > 0 {
+		q = q.Where("id != ?", ignoreId)
+	}
+	if err := q.Find(&candidates).Error; err != nil {
+		return false, err
+	}
+
+	newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
+	for _, c := range candidates {
+		if !listenOverlaps(c.Listen, inbound.Listen) {
+			continue
+		}
+		if inboundTransports(c.Protocol, c.StreamSettings, c.Settings).conflicts(newBits) {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
+// baseInboundTag is the historical "inbound-<port>" / "inbound-<listen>:<port>"
+// shape. kept exactly so existing routing rules that reference these tags
+// keep working after the upgrade.
+func baseInboundTag(listen string, port int) string {
+	if isAnyListen(listen) {
+		return fmt.Sprintf("inbound-%v", port)
+	}
+	return fmt.Sprintf("inbound-%v:%v", listen, port)
+}
+
+// transportTagSuffix turns a transport mask into a short, stable string
+// for tag disambiguation. only used when the base "inbound-<port>" is
+// already taken on a coexisting transport (e.g. tcp inbound already lives
+// on 443 and we're now adding a udp one).
+func transportTagSuffix(b transportBits) string {
+	switch b {
+	case transportTCP:
+		return "tcp"
+	case transportUDP:
+		return "udp"
+	case transportTCP | transportUDP:
+		return "mixed"
+	}
+	return "any"
+}
+
+// generateInboundTag picks a tag for the inbound that doesn't collide with
+// any existing row. for the common single-inbound-per-port case the tag
+// stays exactly as before ("inbound-443"), so user routing rules don't
+// silently change shape on upgrade. only when a same-port neighbour
+// already owns the base tag (now possible because tcp/443 and udp/443 can
+// coexist after the transport-aware port check) does this append a
+// transport suffix like "inbound-443-udp".
+//
+// ignoreId is the inbound's own id during update so it doesn't see itself
+// as a collision; pass 0 on add.
+func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
+	base := baseInboundTag(inbound.Listen, inbound.Port)
+	exists, err := s.tagExists(base, ignoreId)
+	if err != nil {
+		return "", err
+	}
+	if !exists {
+		return base, nil
+	}
+
+	suffix := transportTagSuffix(inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings))
+	candidate := base + "-" + suffix
+	exists, err = s.tagExists(candidate, ignoreId)
+	if err != nil {
+		return "", err
+	}
+	if !exists {
+		return candidate, nil
+	}
+
+	// the transport-aware port check should have already blocked this
+	// path, but guard anyway so a unique-constraint failure doesn't reach
+	// the user as an opaque sqlite error.
+	for i := 2; i < 100; i++ {
+		c := fmt.Sprintf("%s-%d", candidate, i)
+		exists, err = s.tagExists(c, ignoreId)
+		if err != nil {
+			return "", err
+		}
+		if !exists {
+			return c, nil
+		}
+	}
+	return "", common.NewError("could not pick a unique inbound tag for port:", inbound.Port)
+}
+
+func (s *InboundService) tagExists(tag string, ignoreId int) (bool, error) {
+	db := database.GetDB()
+	q := db.Model(model.Inbound{}).Where("tag = ?", tag)
+	if ignoreId > 0 {
+		q = q.Where("id != ?", ignoreId)
+	}
+	var count int64
+	if err := q.Count(&count).Error; err != nil {
+		return false, err
+	}
+	return count > 0, nil
+}

+ 363 - 0
web/service/port_conflict_test.go

@@ -0,0 +1,363 @@
+package service
+
+import (
+	"path/filepath"
+	"sync"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v2/database"
+	"github.com/mhsanaei/3x-ui/v2/database/model"
+	xuilogger "github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/op/go-logging"
+)
+
+// the panel logger is a process-wide singleton. init it once per test
+// binary so a stray warning from gorm doesn't blow up on a nil logger.
+var portConflictLoggerOnce sync.Once
+
+// setupConflictDB wires a temp sqlite db so checkPortConflict can read
+// real candidates. closes the db before t.TempDir cleans up so windows
+// doesn't refuse to remove the file.
+func setupConflictDB(t *testing.T) {
+	t.Helper()
+	portConflictLoggerOnce.Do(func() { xuilogger.InitLogger(logging.ERROR) })
+
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() {
+		if err := database.CloseDB(); err != nil {
+			t.Logf("CloseDB warning: %v", err)
+		}
+	})
+}
+
+func seedInboundConflict(t *testing.T, tag, listen string, port int, protocol model.Protocol, streamSettings, settings string) {
+	t.Helper()
+	in := &model.Inbound{
+		Tag:            tag,
+		Enable:         true,
+		Listen:         listen,
+		Port:           port,
+		Protocol:       protocol,
+		StreamSettings: streamSettings,
+		Settings:       settings,
+	}
+	if err := database.GetDB().Create(in).Error; err != nil {
+		t.Fatalf("seed inbound %s: %v", tag, err)
+	}
+}
+
+func TestInboundTransports(t *testing.T) {
+	cases := []struct {
+		name           string
+		protocol       model.Protocol
+		streamSettings string
+		settings       string
+		want           transportBits
+	}{
+		{"vless default tcp", model.VLESS, `{"network":"tcp"}`, ``, transportTCP},
+		{"vless ws (still tcp)", model.VLESS, `{"network":"ws"}`, ``, transportTCP},
+		{"vless kcp is udp", model.VLESS, `{"network":"kcp"}`, ``, transportUDP},
+		{"vless empty stream defaults to tcp", model.VLESS, ``, ``, transportTCP},
+		{"vless garbage stream stays tcp", model.VLESS, `not json`, ``, transportTCP},
+
+		{"vmess default tcp", model.VMESS, `{"network":"tcp"}`, ``, transportTCP},
+		{"trojan grpc is tcp", model.Trojan, `{"network":"grpc"}`, ``, transportTCP},
+
+		{"hysteria forced udp", model.Hysteria, `{"network":"tcp"}`, ``, transportUDP},
+		{"hysteria2 forced udp", model.Hysteria2, ``, ``, transportUDP},
+		{"wireguard forced udp", model.WireGuard, ``, ``, transportUDP},
+
+		{"shadowsocks tcp,udp", model.Shadowsocks, ``, `{"network":"tcp,udp"}`, transportTCP | transportUDP},
+		{"shadowsocks udp only", model.Shadowsocks, ``, `{"network":"udp"}`, transportUDP},
+		{"shadowsocks tcp only", model.Shadowsocks, ``, `{"network":"tcp"}`, transportTCP},
+		{"shadowsocks empty network falls back to streamSettings", model.Shadowsocks, `{"network":"tcp"}`, `{}`, transportTCP},
+
+		{"mixed udp on", model.Mixed, `{"network":"tcp"}`, `{"udp":true}`, transportTCP | transportUDP},
+		{"mixed udp off", model.Mixed, `{"network":"tcp"}`, `{"udp":false}`, transportTCP},
+		{"mixed udp missing", model.Mixed, `{"network":"tcp"}`, `{}`, transportTCP},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got := inboundTransports(c.protocol, c.streamSettings, c.settings)
+			if got != c.want {
+				t.Fatalf("got bits %#b, want %#b", got, c.want)
+			}
+		})
+	}
+}
+
+func TestListenOverlaps(t *testing.T) {
+	cases := []struct {
+		a, b string
+		want bool
+	}{
+		{"", "", true},
+		{"0.0.0.0", "", true},
+		{"0.0.0.0", "1.2.3.4", true},
+		{"::", "1.2.3.4", true},
+		{"::0", "fe80::1", true},
+		{"1.2.3.4", "1.2.3.4", true},
+		{"1.2.3.4", "5.6.7.8", false},
+		{"1.2.3.4", "::1", false},
+	}
+	for _, c := range cases {
+		if got := listenOverlaps(c.a, c.b); got != c.want {
+			t.Errorf("listenOverlaps(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
+		}
+	}
+}
+
+// the actual case from #4103: tcp/443 vless reality and udp/443
+// hysteria2 must be allowed to coexist on the same port.
+func TestCheckPortConflict_TCPandUDPCoexistOnSamePort(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "vless-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
+
+	svc := &InboundService{}
+	hyst2 := &model.Inbound{
+		Tag:      "hyst2-443-udp",
+		Listen:   "0.0.0.0",
+		Port:     443,
+		Protocol: model.Hysteria2,
+	}
+	exist, err := svc.checkPortConflict(hyst2, 0)
+	if err != nil {
+		t.Fatalf("checkPortConflict: %v", err)
+	}
+	if exist {
+		t.Fatalf("vless/tcp and hysteria2/udp on the same port must be allowed to coexist")
+	}
+}
+
+// two tcp inbounds on the same port still conflict.
+func TestCheckPortConflict_TCPCollidesWithTCP(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "vless-443-a", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
+
+	svc := &InboundService{}
+	other := &model.Inbound{
+		Tag:            "vless-443-b",
+		Listen:         "0.0.0.0",
+		Port:           443,
+		Protocol:       model.Trojan,
+		StreamSettings: `{"network":"ws"}`,
+	}
+	exist, err := svc.checkPortConflict(other, 0)
+	if err != nil {
+		t.Fatalf("checkPortConflict: %v", err)
+	}
+	if !exist {
+		t.Fatalf("two tcp inbounds on the same port must still conflict")
+	}
+}
+
+// two udp inbounds (e.g. hysteria2 vs wireguard) on the same port also
+// conflict, since they fight for the same socket.
+func TestCheckPortConflict_UDPCollidesWithUDP(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "hyst2-443", "0.0.0.0", 443, model.Hysteria2, ``, ``)
+
+	svc := &InboundService{}
+	wg := &model.Inbound{
+		Tag:      "wg-443",
+		Listen:   "0.0.0.0",
+		Port:     443,
+		Protocol: model.WireGuard,
+	}
+	exist, err := svc.checkPortConflict(wg, 0)
+	if err != nil {
+		t.Fatalf("checkPortConflict: %v", err)
+	}
+	if !exist {
+		t.Fatalf("two udp inbounds on the same port must conflict")
+	}
+}
+
+// shadowsocks listening on tcp+udp eats the whole port for both
+// transports, so neither a tcp nor a udp neighbour is allowed.
+func TestCheckPortConflict_ShadowsocksDualListenBlocksBoth(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "ss-443-dual", "0.0.0.0", 443, model.Shadowsocks, ``, `{"network":"tcp,udp"}`)
+
+	svc := &InboundService{}
+
+	tcpClash := &model.Inbound{
+		Tag:            "vless-443",
+		Listen:         "0.0.0.0",
+		Port:           443,
+		Protocol:       model.VLESS,
+		StreamSettings: `{"network":"tcp"}`,
+	}
+	if exist, err := svc.checkPortConflict(tcpClash, 0); err != nil || !exist {
+		t.Fatalf("tcp inbound should clash with shadowsocks tcp,udp; exist=%v err=%v", exist, err)
+	}
+
+	udpClash := &model.Inbound{
+		Tag:      "hyst2-443",
+		Listen:   "0.0.0.0",
+		Port:     443,
+		Protocol: model.Hysteria2,
+	}
+	if exist, err := svc.checkPortConflict(udpClash, 0); err != nil || !exist {
+		t.Fatalf("udp inbound should clash with shadowsocks tcp,udp; exist=%v err=%v", exist, err)
+	}
+}
+
+// different ports never conflict regardless of transport.
+func TestCheckPortConflict_DifferentPortNeverConflicts(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "vless-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
+
+	svc := &InboundService{}
+	other := &model.Inbound{
+		Tag:            "vless-444",
+		Listen:         "0.0.0.0",
+		Port:           444,
+		Protocol:       model.VLESS,
+		StreamSettings: `{"network":"tcp"}`,
+	}
+	if exist, err := svc.checkPortConflict(other, 0); err != nil || exist {
+		t.Fatalf("different port must not conflict; exist=%v err=%v", exist, err)
+	}
+}
+
+// specific listen addresses on the same port don't clash with each other,
+// but do clash with any-address on the same port (preserved from the old
+// check).
+func TestCheckPortConflict_ListenOverlapPreserved(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "vless-1.2.3.4", "1.2.3.4", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
+
+	svc := &InboundService{}
+
+	// different specific address, same port + transport: no conflict.
+	other := &model.Inbound{
+		Tag:            "vless-5.6.7.8",
+		Listen:         "5.6.7.8",
+		Port:           443,
+		Protocol:       model.VLESS,
+		StreamSettings: `{"network":"tcp"}`,
+	}
+	if exist, err := svc.checkPortConflict(other, 0); err != nil || exist {
+		t.Fatalf("different specific listen must not conflict; exist=%v err=%v", exist, err)
+	}
+
+	// any-address vs specific on same transport: conflict (any-addr wins).
+	anyAddr := &model.Inbound{
+		Tag:            "vless-any",
+		Listen:         "0.0.0.0",
+		Port:           443,
+		Protocol:       model.VLESS,
+		StreamSettings: `{"network":"tcp"}`,
+	}
+	if exist, err := svc.checkPortConflict(anyAddr, 0); err != nil || !exist {
+		t.Fatalf("any-addr on same port+transport must conflict with specific; exist=%v err=%v", exist, err)
+	}
+}
+
+// when the base "inbound-<port>" tag is already taken on a coexisting
+// transport, generateInboundTag must disambiguate with a transport
+// suffix so the unique-tag DB constraint stays satisfied.
+func TestGenerateInboundTag_DisambiguatesByTransportOnSamePort(t *testing.T) {
+	setupConflictDB(t)
+	// existing tcp inbound owns "inbound-443".
+	seedInboundConflict(t, "inbound-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
+
+	svc := &InboundService{}
+	udp := &model.Inbound{
+		Listen:   "0.0.0.0",
+		Port:     443,
+		Protocol: model.Hysteria2,
+	}
+	got, err := svc.generateInboundTag(udp, 0)
+	if err != nil {
+		t.Fatalf("generateInboundTag: %v", err)
+	}
+	if got != "inbound-443-udp" {
+		t.Fatalf("expected disambiguated tag inbound-443-udp, got %q", got)
+	}
+}
+
+// when the port is free, the historical "inbound-<port>" shape is kept
+// so existing routing rules don't change shape on upgrade.
+func TestGenerateInboundTag_KeepsBaseTagWhenFree(t *testing.T) {
+	setupConflictDB(t)
+
+	svc := &InboundService{}
+	in := &model.Inbound{
+		Listen:   "0.0.0.0",
+		Port:     8443,
+		Protocol: model.VLESS,
+	}
+	got, err := svc.generateInboundTag(in, 0)
+	if err != nil {
+		t.Fatalf("generateInboundTag: %v", err)
+	}
+	if got != "inbound-8443" {
+		t.Fatalf("expected inbound-8443, got %q", got)
+	}
+}
+
+// updating an inbound on its own port must not flag its own tag as
+// taken, that's what ignoreId is for.
+func TestGenerateInboundTag_IgnoresSelfOnUpdate(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "inbound-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "inbound-443").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	svc := &InboundService{}
+	got, err := svc.generateInboundTag(&existing, existing.Id)
+	if err != nil {
+		t.Fatalf("generateInboundTag: %v", err)
+	}
+	if got != "inbound-443" {
+		t.Fatalf("self-update must keep base tag, got %q", got)
+	}
+}
+
+// specific listen address gets the listen-prefixed shape and same
+// disambiguation rules.
+func TestGenerateInboundTag_SpecificListenSameDisambiguation(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "inbound-1.2.3.4:443", "1.2.3.4", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
+
+	svc := &InboundService{}
+	udp := &model.Inbound{
+		Listen:   "1.2.3.4",
+		Port:     443,
+		Protocol: model.Hysteria2,
+	}
+	got, err := svc.generateInboundTag(udp, 0)
+	if err != nil {
+		t.Fatalf("generateInboundTag: %v", err)
+	}
+	if got != "inbound-1.2.3.4:443-udp" {
+		t.Fatalf("expected inbound-1.2.3.4:443-udp, got %q", got)
+	}
+}
+
+// updating an inbound must not see itself as a conflict, that's what
+// ignoreId is for.
+func TestCheckPortConflict_IgnoreSelfOnUpdate(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "vless-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "vless-443").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	svc := &InboundService{}
+	if exist, err := svc.checkPortConflict(&existing, existing.Id); err != nil || exist {
+		t.Fatalf("self-update must not be flagged as conflict; exist=%v err=%v", exist, err)
+	}
+}

+ 45 - 8
web/service/server.go

@@ -315,13 +315,21 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 	}
 
 	// Network stats
-	ioStats, err := net.IOCounters(false)
+	ioStats, err := net.IOCounters(true)
 	if err != nil {
 		logger.Warning("get io counters failed:", err)
-	} else if len(ioStats) > 0 {
-		ioStat := ioStats[0]
-		status.NetTraffic.Sent = ioStat.BytesSent
-		status.NetTraffic.Recv = ioStat.BytesRecv
+	} else {
+		var totalSent, totalRecv uint64
+		for _, iface := range ioStats {
+			name := strings.ToLower(iface.Name)
+			if isVirtualInterface(name) {
+				continue
+			}
+			totalSent += iface.BytesSent
+			totalRecv += iface.BytesRecv
+		}
+		status.NetTraffic.Sent = totalSent
+		status.NetTraffic.Recv = totalRecv
 
 		if lastStatus != nil {
 			duration := now.Sub(lastStatus.T)
@@ -331,8 +339,6 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 			status.NetIO.Up = up
 			status.NetIO.Down = down
 		}
-	} else {
-		logger.Warning("can not find io counters")
 	}
 
 	// TCP/UDP connections
@@ -555,6 +561,9 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
 	var versions []string
 	for _, release := range releases {
 		tagVersion := strings.TrimPrefix(release.TagName, "v")
+		if tagVersion == "26.5.3" {
+			continue
+		}
 		tagParts := strings.Split(tagVersion, ".")
 		if len(tagParts) != 3 {
 			continue
@@ -567,7 +576,7 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
 			continue
 		}
 
-		if major > 26 || (major == 26 && minor > 3) || (major == 26 && minor == 3 && patch >= 10) {
+		if major > 26 || (major == 26 && minor > 4) || (major == 26 && minor == 4 && patch >= 25) {
 			versions = append(versions, release.TagName)
 		}
 	}
@@ -857,6 +866,34 @@ func (s *ServerService) GetXrayLogs(
 	return entries
 }
 
+// isVirtualInterface returns true for loopback and virtual/tunnel interfaces
+// that should be excluded from network traffic statistics.
+func isVirtualInterface(name string) bool {
+	// Exact matches
+	if name == "lo" || name == "lo0" {
+		return true
+	}
+	// Prefix matches for virtual/tunnel interfaces
+	virtualPrefixes := []string{
+		"loopback",
+		"docker",
+		"br-",
+		"veth",
+		"virbr",
+		"tun",
+		"tap",
+		"wg",
+		"tailscale",
+		"zt",
+	}
+	for _, prefix := range virtualPrefixes {
+		if strings.HasPrefix(name, prefix) {
+			return true
+		}
+	}
+	return false
+}
+
 func logEntryContains(line string, suffixes []string) bool {
 	for _, sfx := range suffixes {
 		if strings.Contains(line, sfx+"]") {

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff