10 Commits adc262a238 ... 21058eb63c

Author SHA1 Message Date
  MHSanaei 21058eb63c fix(routing): make rule drag-and-drop work on mobile cards 1 day ago
  Black 194de8869e feat(panel): add 'Edit' button to tables and enhance layout (#4355) 1 day ago
  MHSanaei 26accfd8f7 fix(qr): lock QR code modules to black-on-white across all themes 1 day ago
  MHSanaei 2551a673c3 fix(inbounds): refresh client rows live over websocket 1 day ago
  MHSanaei ce4c42e09c feat(json): swap raw textareas for a CodeMirror 6 JsonEditor 1 day ago
  MHSanaei 18614bd6ea feat(tabs): collapse settings and xray tab bars to evenly-spread icons 1 day ago
  MHSanaei e564c9283d feat(nodes): mobile card list, info modal, and tighter summary layout 1 day ago
  MHSanaei 933567d423 feat(inbounds): collapse mobile cards to id/email + info button 1 day ago
  MHSanaei 771bc7c8ef feat(inbounds): align tunnel, tun, and hysteria UI with Xray docs 1 day ago
  MHSanaei 61ab602887 fix(iplog): parse xray access-log timestamps in local time 1 day ago

File diff suppressed because it is too large
+ 321 - 28
frontend/package-lock.json


+ 3 - 0
frontend/package.json

@@ -16,8 +16,11 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@ant-design/icons-vue": "^7.0.1",
     "@ant-design/icons-vue": "^7.0.1",
+    "@codemirror/lang-json": "^6.0.2",
+    "@codemirror/theme-one-dark": "^6.1.3",
     "ant-design-vue": "^4.2.6",
     "ant-design-vue": "^4.2.6",
     "axios": "^1.7.9",
     "axios": "^1.7.9",
+    "codemirror": "^6.0.2",
     "dayjs": "^1.11.20",
     "dayjs": "^1.11.20",
     "otpauth": "^9.5.1",
     "otpauth": "^9.5.1",
     "qs": "^6.13.1",
     "qs": "^6.13.1",

+ 185 - 0
frontend/src/components/JsonEditor.vue

@@ -0,0 +1,185 @@
+<script setup>
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import { EditorView, basicSetup } from 'codemirror';
+import { EditorState, Compartment } from '@codemirror/state';
+import { json, jsonParseLinter } from '@codemirror/lang-json';
+import { lintGutter, linter } from '@codemirror/lint';
+import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
+import { syntaxHighlighting } from '@codemirror/language';
+import { keymap } from '@codemirror/view';
+import { indentWithTab } from '@codemirror/commands';
+
+import { theme as themeState } from '@/composables/useTheme.js';
+
+const props = defineProps({
+  value: { type: String, default: '' },
+  minHeight: { type: String, default: '320px' },
+  maxHeight: { type: String, default: '600px' },
+  readonly: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(['update:value', 'change']);
+
+const host = ref(null);
+let view = null;
+const themeCompartment = new Compartment();
+const readonlyCompartment = new Compartment();
+
+function buildDarkTheme({ bg, panelBg, activeBg, border, selection }) {
+  return EditorView.theme(
+    {
+      '&': { color: '#dcdcdc', backgroundColor: bg },
+      '.cm-content': { caretColor: '#dcdcdc' },
+      '.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
+      '.cm-gutters': {
+        backgroundColor: bg,
+        borderRight: `1px solid ${border}`,
+        color: '#6a6a6a',
+      },
+      '.cm-activeLine': { backgroundColor: activeBg },
+      '.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
+      '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
+        { backgroundColor: selection },
+      '.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
+      '.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
+      '.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
+      '.cm-tooltip': {
+        backgroundColor: panelBg,
+        border: `1px solid ${border}`,
+        color: '#dcdcdc',
+      },
+    },
+    { dark: true },
+  );
+}
+
+const darkTheme = buildDarkTheme({
+  bg: '#1e1e1e',
+  panelBg: '#2d2d30',
+  activeBg: '#252526',
+  border: '#3a3a3c',
+  selection: '#3a3a3c',
+});
+
+const ultraDarkTheme = buildDarkTheme({
+  bg: '#0a0a0a',
+  panelBg: '#141414',
+  activeBg: '#141414',
+  border: '#1f1f1f',
+  selection: '#2a2a2a',
+});
+
+function themeExtension() {
+  if (!themeState.isDark) return [];
+  const chrome = themeState.isUltra ? ultraDarkTheme : darkTheme;
+  return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
+}
+
+function readonlyExtension() {
+  return EditorState.readOnly.of(props.readonly);
+}
+
+onMounted(() => {
+  const updateListener = EditorView.updateListener.of((u) => {
+    if (!u.docChanged) return;
+    const next = u.state.doc.toString();
+    if (next === props.value) return;
+    emit('update:value', next);
+    emit('change', next);
+  });
+
+  view = new EditorView({
+    parent: host.value,
+    state: EditorState.create({
+      doc: props.value || '',
+      extensions: [
+        basicSetup,
+        keymap.of([indentWithTab]),
+        json(),
+        linter(jsonParseLinter()),
+        lintGutter(),
+        EditorView.lineWrapping,
+        updateListener,
+        themeCompartment.of(themeExtension()),
+        readonlyCompartment.of(readonlyExtension()),
+        EditorView.theme({
+          '&': { height: '100%' },
+          '.cm-scroller': {
+            fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
+            fontSize: '12px',
+            minHeight: props.minHeight,
+            maxHeight: props.maxHeight,
+          },
+        }),
+      ],
+    }),
+  });
+});
+
+watch(() => props.value, (next) => {
+  if (!view) return;
+  const current = view.state.doc.toString();
+  if (next === current) return;
+  view.dispatch({
+    changes: { from: 0, to: current.length, insert: next || '' },
+  });
+});
+
+watch(
+  [() => themeState.isDark, () => themeState.isUltra],
+  () => {
+    if (!view) return;
+    view.dispatch({ effects: themeCompartment.reconfigure(themeExtension()) });
+  },
+);
+
+watch(
+  () => props.readonly,
+  () => {
+    if (!view) return;
+    view.dispatch({ effects: readonlyCompartment.reconfigure(readonlyExtension()) });
+  },
+);
+
+onBeforeUnmount(() => {
+  view?.destroy();
+  view = null;
+});
+
+defineExpose({
+  focus: () => view?.focus(),
+});
+</script>
+
+<template>
+  <div ref="host" class="json-editor-host" />
+</template>
+
+<style scoped>
+.json-editor-host {
+  border: 1px solid var(--ant-color-border, #d9d9d9);
+  border-radius: 6px;
+  overflow: hidden;
+  background: var(--ant-color-bg-container, #fff);
+}
+
+.json-editor-host :deep(.cm-editor),
+.json-editor-host :deep(.cm-editor.cm-focused) {
+  outline: none;
+}
+
+.json-editor-host:focus-within {
+  border-color: var(--ant-color-primary, #1677ff);
+  box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
+}
+
+:global(body.dark) .json-editor-host {
+  border-color: #3a3a3c;
+  background: #1e1e1e;
+}
+
+:global(html[data-theme="ultra-dark"]) .json-editor-host {
+  border-color: #1f1f1f;
+  background: #0a0a0a;
+}
+</style>

+ 20 - 12
frontend/src/models/inbound.js

@@ -2967,37 +2967,45 @@ Inbound.HysteriaSettings.Hysteria = class extends Inbound.ClientBase {
 Inbound.TunnelSettings = class extends Inbound.Settings {
 Inbound.TunnelSettings = class extends Inbound.Settings {
     constructor(
     constructor(
         protocol,
         protocol,
-        address,
-        port,
+        rewriteAddress,
+        rewritePort,
         portMap = [],
         portMap = [],
-        network = 'tcp,udp',
+        allowedNetwork = 'tcp,udp',
         followRedirect = false
         followRedirect = false
     ) {
     ) {
         super(protocol);
         super(protocol);
-        this.address = address;
-        this.port = port;
+        this.rewriteAddress = rewriteAddress;
+        this.rewritePort = rewritePort;
         this.portMap = portMap;
         this.portMap = portMap;
-        this.network = network;
+        this.allowedNetwork = allowedNetwork;
         this.followRedirect = followRedirect;
         this.followRedirect = followRedirect;
     }
     }
 
 
+    addPortMap(port = '', target = '') {
+        this.portMap.push({ name: port, value: target });
+    }
+
+    removePortMap(index) {
+        this.portMap.splice(index, 1);
+    }
+
     static fromJson(json = {}) {
     static fromJson(json = {}) {
         return new Inbound.TunnelSettings(
         return new Inbound.TunnelSettings(
             Protocols.TUNNEL,
             Protocols.TUNNEL,
-            json.address,
-            json.port,
+            json.rewriteAddress,
+            json.rewritePort,
             XrayCommonClass.toHeaders(json.portMap),
             XrayCommonClass.toHeaders(json.portMap),
-            json.network,
+            json.allowedNetwork,
             json.followRedirect,
             json.followRedirect,
         );
         );
     }
     }
 
 
     toJson() {
     toJson() {
         return {
         return {
-            address: this.address,
-            port: this.port,
+            rewriteAddress: this.rewriteAddress,
+            rewritePort: this.rewritePort,
             portMap: XrayCommonClass.toV2Headers(this.portMap, false),
             portMap: XrayCommonClass.toV2Headers(this.portMap, false),
-            network: this.network,
+            allowedNetwork: this.allowedNetwork,
             followRedirect: this.followRedirect,
             followRedirect: this.followRedirect,
         };
         };
     }
     }

+ 38 - 19
frontend/src/pages/inbounds/ClientRowTable.vue

@@ -33,6 +33,7 @@ const props = defineProps({
   isDarkTheme: { type: Boolean, default: false },
   isDarkTheme: { type: Boolean, default: false },
   pageSize: { type: Number, default: 0 },
   pageSize: { type: Number, default: 0 },
   totalClientCount: { type: Number, default: 0 },
   totalClientCount: { type: Number, default: 0 },
+  statsVersion: { type: Number, default: 0 },
 });
 });
 
 
 const emit = defineEmits([
 const emit = defineEmits([
@@ -63,7 +64,11 @@ watch([clients, () => props.pageSize], () => {
 });
 });
 
 
 // === Per-client stats lookup =======================================
 // === Per-client stats lookup =======================================
+// statsVersion bumps on every ws merge so this computed re-evaluates
+// (DBInbound isn't reactive — the in-place stat mutations alone don't
+// trigger Vue's tracking).
 const statsMap = computed(() => {
 const statsMap = computed(() => {
+  void props.statsVersion;
   const m = new Map();
   const m = new Map();
   for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
   for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
   return m;
   return m;
@@ -217,6 +222,14 @@ watch(clients, (list) => {
   if (next.size !== selected.value.size) selected.value = next;
   if (next.size !== selected.value.size) selected.value = next;
 });
 });
 
 
+const statsClient = ref(null);
+function openStats(client) {
+  statsClient.value = client;
+}
+function closeStats() {
+  statsClient.value = null;
+}
+
 function confirmBulkDelete() {
 function confirmBulkDelete() {
   const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
   const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
   if (picked.length === 0) return;
   if (picked.length === 0) return;
@@ -433,6 +446,9 @@ function confirmBulkDelete() {
             <span class="client-email">{{ client.email }}</span>
             <span class="client-email">{{ client.email }}</span>
           </a-tooltip>
           </a-tooltip>
           <div class="client-card-actions">
           <div class="client-card-actions">
+            <a-tooltip :title="t('info')">
+              <InfoCircleOutlined class="row-icon" @click="openStats(client)" />
+            </a-tooltip>
             <a-switch :checked="client.enable" size="small"
             <a-switch :checked="client.enable" size="small"
               @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
               @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
             <a-dropdown :trigger="['click']" placement="bottomRight">
             <a-dropdown :trigger="['click']" placement="bottomRight">
@@ -459,52 +475,55 @@ function confirmBulkDelete() {
             </a-dropdown>
             </a-dropdown>
           </div>
           </div>
         </div>
         </div>
+      </div>
 
 
-        <div v-if="client.comment && client.comment.trim()" class="client-comment-line">
-          {{ client.comment.length > 80 ? client.comment.substring(0, 77) + '…' : client.comment }}
-        </div>
-
-        <div class="client-card-foot">
+      <a-modal :open="!!statsClient" :footer="null" :width="360" centered
+        :title="statsClient ? statsClient.email || t('info') : ''" @cancel="closeStats">
+        <div v-if="statsClient" class="client-card-foot">
+          <div v-if="statsClient.comment && statsClient.comment.trim()" class="client-comment-line">
+            {{ statsClient.comment }}
+          </div>
           <div class="stat-row">
           <div class="stat-row">
             <span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
             <span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
-            <a-tag :color="clientStatsColor(client.email)">
-              {{ SizeFormatter.sizeFormat(getSum(client.email)) }} /
-              <InfinityIcon v-if="isUnlimitedTotal(client)" />
-              <template v-else>{{ totalGbDisplay(client) }}</template>
+            <a-tag :color="clientStatsColor(statsClient.email)">
+              {{ SizeFormatter.sizeFormat(getSum(statsClient.email)) }} /
+              <InfinityIcon v-if="isUnlimitedTotal(statsClient)" />
+              <template v-else>{{ totalGbDisplay(statsClient) }}</template>
             </a-tag>
             </a-tag>
           </div>
           </div>
           <div class="stat-row">
           <div class="stat-row">
             <span class="stat-label">{{ t('remained') }}</span>
             <span class="stat-label">{{ t('remained') }}</span>
-            <a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
+            <a-tag v-if="isUnlimitedTotal(statsClient)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
               <InfinityIcon />
               <InfinityIcon />
             </a-tag>
             </a-tag>
-            <a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
-              {{ SizeFormatter.sizeFormat(getRem(client.email)) }}
+            <a-tag v-else :color="isClientDepleted(statsClient.email) ? 'red' : ''">
+              {{ SizeFormatter.sizeFormat(getRem(statsClient.email)) }}
             </a-tag>
             </a-tag>
           </div>
           </div>
           <div class="stat-row">
           <div class="stat-row">
             <span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
             <span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
-            <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
+            <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(statsClient.email)) }}</a-tag>
           </div>
           </div>
           <div class="stat-row">
           <div class="stat-row">
             <span class="stat-label">{{ t('online') }}</span>
             <span class="stat-label">{{ t('online') }}</span>
-            <a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
+            <a-tag v-if="statsClient.enable && isClientOnline(statsClient.email)" color="green">{{ t('online') }}</a-tag>
             <a-tag v-else>{{ t('offline') }}</a-tag>
             <a-tag v-else>{{ t('offline') }}</a-tag>
           </div>
           </div>
           <div class="stat-row">
           <div class="stat-row">
             <span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
             <span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
-            <a-tag v-if="client.expiryTime > 0" :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
-              {{ IntlUtil.formatRelativeTime(client.expiryTime) }}
+            <a-tag v-if="statsClient.expiryTime > 0"
+              :color="ColorUtils.userExpiryColor(expireDiff, statsClient, isDarkTheme)">
+              {{ IntlUtil.formatRelativeTime(statsClient.expiryTime) }}
             </a-tag>
             </a-tag>
-            <a-tag v-else-if="client.expiryTime < 0" color="green">
-              {{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
+            <a-tag v-else-if="statsClient.expiryTime < 0" color="green">
+              {{ -statsClient.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
             </a-tag>
             </a-tag>
             <a-tag v-else color="purple">
             <a-tag v-else color="purple">
               <InfinityIcon />
               <InfinityIcon />
             </a-tag>
             </a-tag>
           </div>
           </div>
         </div>
         </div>
-      </div>
+      </a-modal>
     </template>
     </template>
 
 
     <a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"
     <a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"

+ 139 - 23
frontend/src/pages/inbounds/InboundFormModal.vue

@@ -32,6 +32,7 @@ import {
 import { DBInbound } from '@/models/dbinbound.js';
 import { DBInbound } from '@/models/dbinbound.js';
 import FinalMaskForm from '@/components/FinalMaskForm.vue';
 import FinalMaskForm from '@/components/FinalMaskForm.vue';
 import DateTimePicker from '@/components/DateTimePicker.vue';
 import DateTimePicker from '@/components/DateTimePicker.vue';
+import JsonEditor from '@/components/JsonEditor.vue';
 import { useNodeList } from '@/composables/useNodeList.js';
 import { useNodeList } from '@/composables/useNodeList.js';
 
 
 const { t } = useI18n();
 const { t } = useI18n();
@@ -679,10 +680,7 @@ watch(
       </a-tab-pane>
       </a-tab-pane>
 
 
       <!-- ============================== PROTOCOL ============================== -->
       <!-- ============================== PROTOCOL ============================== -->
-      <!-- TUN has no per-protocol form yet (interface/mtu/gateway live in
-           settings JSON), so the tab would render empty — hide it until
-           a TUN form is added. -->
-      <a-tab-pane v-if="protocol !== Protocols.TUN" key="protocol" :tab="t('pages.inbounds.protocol')">
+      <a-tab-pane key="protocol" :tab="t('pages.inbounds.protocol')">
         <!-- Multi-user inbounds: in add mode embed the first client form,
         <!-- Multi-user inbounds: in add mode embed the first client form,
              in edit mode show a count summary. -->
              in edit mode show a count summary. -->
         <template v-if="isMultiUser">
         <template v-if="isMultiUser">
@@ -895,24 +893,126 @@ watch(
         <!-- Tunnel -->
         <!-- Tunnel -->
         <a-form v-if="protocol === Protocols.TUNNEL" :colon="false" :label-col="{ sm: { span: 8 } }"
         <a-form v-if="protocol === Protocols.TUNNEL" :colon="false" :label-col="{ sm: { span: 8 } }"
           :wrapper-col="{ sm: { span: 14 } }" class="mt-12">
           :wrapper-col="{ sm: { span: 14 } }" class="mt-12">
-          <a-form-item label="Address">
-            <a-input v-model:value="inbound.settings.address" />
+          <a-form-item label="Rewrite address">
+            <a-input v-model:value="inbound.settings.rewriteAddress" />
           </a-form-item>
           </a-form-item>
-          <a-form-item label="Destination port">
-            <a-input-number v-model:value="inbound.settings.port" :min="1" :max="65535" />
+          <a-form-item label="Rewrite port">
+            <a-input-number v-model:value="inbound.settings.rewritePort" :min="0" :max="65535" />
           </a-form-item>
           </a-form-item>
-          <a-form-item label="Network">
-            <a-select v-model:value="inbound.settings.network">
+          <a-form-item label="Allowed network">
+            <a-select v-model:value="inbound.settings.allowedNetwork">
               <a-select-option value="tcp,udp">TCP, UDP</a-select-option>
               <a-select-option value="tcp,udp">TCP, UDP</a-select-option>
               <a-select-option value="tcp">TCP</a-select-option>
               <a-select-option value="tcp">TCP</a-select-option>
               <a-select-option value="udp">UDP</a-select-option>
               <a-select-option value="udp">UDP</a-select-option>
             </a-select>
             </a-select>
           </a-form-item>
           </a-form-item>
+          <a-form-item label="Port map">
+            <a-button size="small" @click="inbound.settings.addPortMap('', '')">
+              <template #icon>
+                <PlusOutlined />
+              </template>
+            </a-button>
+          </a-form-item>
+          <a-form-item v-if="inbound.settings.portMap.length > 0" :wrapper-col="{ span: 24 }">
+            <a-input-group v-for="(pm, idx) in inbound.settings.portMap" :key="`pm-${idx}`" compact class="mb-8">
+              <a-input :style="{ width: '30%' }" v-model:value="pm.name" placeholder="5555">
+                <template #addonBefore>{{ idx + 1 }}</template>
+              </a-input>
+              <a-input :style="{ width: '60%' }" v-model:value="pm.value" placeholder="1.1.1.1:7777" />
+              <a-button @click="inbound.settings.removePortMap(idx)">
+                <template #icon>
+                  <MinusOutlined />
+                </template>
+              </a-button>
+            </a-input-group>
+          </a-form-item>
           <a-form-item label="Follow redirect">
           <a-form-item label="Follow redirect">
             <a-switch v-model:checked="inbound.settings.followRedirect" />
             <a-switch v-model:checked="inbound.settings.followRedirect" />
           </a-form-item>
           </a-form-item>
         </a-form>
         </a-form>
 
 
+        <!-- TUN -->
+        <a-form v-if="protocol === Protocols.TUN" :colon="false" :label-col="{ sm: { span: 8 } }"
+          :wrapper-col="{ sm: { span: 14 } }" class="mt-12">
+          <a-form-item label="Interface name">
+            <a-input v-model:value="inbound.settings.name" placeholder="xray0" />
+          </a-form-item>
+          <a-form-item label="MTU">
+            <a-input-number v-model:value="inbound.settings.mtu" :min="0" />
+          </a-form-item>
+          <a-form-item label="Gateway">
+            <a-button size="small" @click="inbound.settings.gateway.push('')">
+              <template #icon>
+                <PlusOutlined />
+              </template>
+            </a-button>
+            <a-input v-for="(_ip, j) in inbound.settings.gateway" :key="`tun-gw-${j}`"
+              v-model:value="inbound.settings.gateway[j]" class="mt-4"
+              :placeholder="j === 0 ? '10.0.0.1/16' : 'fc00::1/64'">
+              <template #addonAfter>
+                <a-button size="small" @click="inbound.settings.gateway.splice(j, 1)">
+                  <template #icon>
+                    <MinusOutlined />
+                  </template>
+                </a-button>
+              </template>
+            </a-input>
+          </a-form-item>
+          <a-form-item label="DNS">
+            <a-button size="small" @click="inbound.settings.dns.push('')">
+              <template #icon>
+                <PlusOutlined />
+              </template>
+            </a-button>
+            <a-input v-for="(_ip, j) in inbound.settings.dns" :key="`tun-dns-${j}`"
+              v-model:value="inbound.settings.dns[j]" class="mt-4" :placeholder="j === 0 ? '1.1.1.1' : '8.8.8.8'">
+              <template #addonAfter>
+                <a-button size="small" @click="inbound.settings.dns.splice(j, 1)">
+                  <template #icon>
+                    <MinusOutlined />
+                  </template>
+                </a-button>
+              </template>
+            </a-input>
+          </a-form-item>
+          <a-form-item label="User level">
+            <a-input-number v-model:value="inbound.settings.userLevel" :min="0" />
+          </a-form-item>
+          <a-form-item>
+            <template #label>
+              <a-tooltip
+                title="Windows-only. CIDRs added to the system routing table automatically so matching traffic goes through TUN.">
+                Auto system routes
+              </a-tooltip>
+            </template>
+            <a-button size="small" @click="inbound.settings.autoSystemRoutingTable.push('')">
+              <template #icon>
+                <PlusOutlined />
+              </template>
+            </a-button>
+            <a-input v-for="(_ip, j) in inbound.settings.autoSystemRoutingTable" :key="`tun-rt-${j}`"
+              v-model:value="inbound.settings.autoSystemRoutingTable[j]" class="mt-4"
+              :placeholder="j === 0 ? '0.0.0.0/0' : '::/0'">
+              <template #addonAfter>
+                <a-button size="small" @click="inbound.settings.autoSystemRoutingTable.splice(j, 1)">
+                  <template #icon>
+                    <MinusOutlined />
+                  </template>
+                </a-button>
+              </template>
+            </a-input>
+          </a-form-item>
+          <a-form-item>
+            <template #label>
+              <a-tooltip
+                title='Physical interface for outbound traffic. Use "auto" to detect; auto-enabled when Auto system routes is set.'>
+                Auto outbounds interface
+              </a-tooltip>
+            </template>
+            <a-input v-model:value="inbound.settings.autoOutboundsInterface" placeholder="auto" />
+          </a-form-item>
+        </a-form>
+
         <!-- WireGuard -->
         <!-- WireGuard -->
         <a-form v-if="protocol === Protocols.WIREGUARD" :colon="false" :label-col="{ sm: { span: 8 } }"
         <a-form v-if="protocol === Protocols.WIREGUARD" :colon="false" :label-col="{ sm: { span: 8 } }"
           :wrapper-col="{ sm: { span: 14 } }" class="mt-12">
           :wrapper-col="{ sm: { span: 14 } }" class="mt-12">
@@ -1723,9 +1823,33 @@ watch(
             </a-form-item>
             </a-form-item>
           </template>
           </template>
 
 
-          <!-- ====== Hysteria Masquerade ====== -->
-          <!-- Per https://xtls.github.io/config/transports/hysteria.html#masqobject -->
+          <!-- ====== Hysteria stream settings ====== -->
+          <!-- Per https://xtls.github.io/config/transports/hysteria.html -->
           <template v-if="protocol === Protocols.HYSTERIA">
           <template v-if="protocol === Protocols.HYSTERIA">
+            <a-form-item>
+              <template #label>
+                <a-tooltip title="Hysteria protocol version. Currently must be 2.">
+                  Version
+                </a-tooltip>
+              </template>
+              <a-input-number v-model:value="inbound.stream.hysteria.version" :min="2" :max="2" />
+            </a-form-item>
+            <a-form-item>
+              <template #label>
+                <a-tooltip title="Obfuscation password. Must match between server and client.">
+                  Obfs password
+                </a-tooltip>
+              </template>
+              <a-input v-model:value="inbound.stream.hysteria.auth" />
+            </a-form-item>
+            <a-form-item>
+              <template #label>
+                <a-tooltip title="Idle timeout (seconds) for a single QUIC native UDP connection.">
+                  UDP idle timeout
+                </a-tooltip>
+              </template>
+              <a-input-number v-model:value="inbound.stream.hysteria.udpIdleTimeout" :min="0" />
+            </a-form-item>
             <a-form-item label="Masquerade">
             <a-form-item label="Masquerade">
               <a-switch v-model:checked="inbound.stream.hysteria.masqueradeSwitch" />
               <a-switch v-model:checked="inbound.stream.hysteria.masqueradeSwitch" />
             </a-form-item>
             </a-form-item>
@@ -1833,16 +1957,13 @@ watch(
           class="mb-12" />
           class="mb-12" />
         <a-form layout="vertical">
         <a-form layout="vertical">
           <a-form-item label="settings (clients, encryption, fallbacks, …)">
           <a-form-item label="settings (clients, encryption, fallbacks, …)">
-            <a-textarea v-model:value="advancedJson.settings" :auto-size="{ minRows: 10, maxRows: 24 }"
-              spellcheck="false" class="json-editor" />
+            <JsonEditor v-model:value="advancedJson.settings" min-height="280px" max-height="520px" />
           </a-form-item>
           </a-form-item>
           <a-form-item label="streamSettings">
           <a-form-item label="streamSettings">
-            <a-textarea v-model:value="advancedJson.stream" :auto-size="{ minRows: 10, maxRows: 24 }" spellcheck="false"
-              class="json-editor" />
+            <JsonEditor v-model:value="advancedJson.stream" min-height="280px" max-height="520px" />
           </a-form-item>
           </a-form-item>
           <a-form-item label="sniffing (overrides the Sniffing tab when set)">
           <a-form-item label="sniffing (overrides the Sniffing tab when set)">
-            <a-textarea v-model:value="advancedJson.sniffing" :auto-size="{ minRows: 6, maxRows: 16 }"
-              spellcheck="false" class="json-editor" />
+            <JsonEditor v-model:value="advancedJson.sniffing" min-height="180px" max-height="360px" />
           </a-form-item>
           </a-form-item>
         </a-form>
         </a-form>
       </a-tab-pane>
       </a-tab-pane>
@@ -1892,11 +2013,6 @@ watch(
   margin-top: 6px;
   margin-top: 6px;
 }
 }
 
 
-.json-editor {
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-  font-size: 12px;
-}
-
 .client-summary {
 .client-summary {
   width: 100%;
   width: 100%;
   border-collapse: collapse;
   border-collapse: collapse;

+ 34 - 3
frontend/src/pages/inbounds/InboundInfoModal.vue

@@ -585,19 +585,50 @@ const showSubscriptionTab = computed(
             </tbody>
             </tbody>
           </table>
           </table>
 
 
+          <!-- TUN -->
+          <dl v-if="inbound.protocol === Protocols.TUN" class="info-list info-list-block">
+            <div class="info-row">
+              <dt>Interface name</dt>
+              <dd><a-tag color="green" class="value-tag">{{ inbound.settings.name }}</a-tag></dd>
+            </div>
+            <div class="info-row">
+              <dt>MTU</dt>
+              <dd><a-tag color="green">{{ inbound.settings.mtu }}</a-tag></dd>
+            </div>
+            <div v-if="inbound.settings.gateway?.length" class="info-row">
+              <dt>Gateway</dt>
+              <dd><a-tag v-for="(ip, j) in inbound.settings.gateway" :key="`tun-i-gw-${j}`" color="green"
+                  class="value-tag">{{ ip }}</a-tag></dd>
+            </div>
+            <div v-if="inbound.settings.dns?.length" class="info-row">
+              <dt>DNS</dt>
+              <dd><a-tag v-for="(ip, j) in inbound.settings.dns" :key="`tun-i-dns-${j}`" color="green">{{ ip }}</a-tag>
+              </dd>
+            </div>
+            <div class="info-row">
+              <dt>Outbounds interface</dt>
+              <dd><a-tag color="green">{{ inbound.settings.autoOutboundsInterface || 'auto' }}</a-tag></dd>
+            </div>
+            <div v-if="inbound.settings.autoSystemRoutingTable?.length" class="info-row">
+              <dt>Auto system routes</dt>
+              <dd><a-tag v-for="(cidr, j) in inbound.settings.autoSystemRoutingTable" :key="`tun-i-rt-${j}`"
+                  color="green">{{ cidr }}</a-tag></dd>
+            </div>
+          </dl>
+
           <!-- Tunnel -->
           <!-- Tunnel -->
           <dl v-if="inbound.protocol === Protocols.TUNNEL" class="info-list info-list-block">
           <dl v-if="inbound.protocol === Protocols.TUNNEL" class="info-list info-list-block">
             <div class="info-row">
             <div class="info-row">
               <dt>{{ t('pages.inbounds.targetAddress') }}</dt>
               <dt>{{ t('pages.inbounds.targetAddress') }}</dt>
-              <dd><a-tag color="green" class="value-tag">{{ inbound.settings.address }}</a-tag></dd>
+              <dd><a-tag color="green" class="value-tag">{{ inbound.settings.rewriteAddress }}</a-tag></dd>
             </div>
             </div>
             <div class="info-row">
             <div class="info-row">
               <dt>{{ t('pages.inbounds.destinationPort') }}</dt>
               <dt>{{ t('pages.inbounds.destinationPort') }}</dt>
-              <dd><a-tag color="green">{{ inbound.settings.port }}</a-tag></dd>
+              <dd><a-tag color="green">{{ inbound.settings.rewritePort }}</a-tag></dd>
             </div>
             </div>
             <div class="info-row">
             <div class="info-row">
               <dt>{{ t('pages.inbounds.network') }}</dt>
               <dt>{{ t('pages.inbounds.network') }}</dt>
-              <dd><a-tag color="green">{{ inbound.settings.network }}</a-tag></dd>
+              <dd><a-tag color="green">{{ inbound.settings.allowedNetwork }}</a-tag></dd>
             </div>
             </div>
             <div class="info-row">
             <div class="info-row">
               <dt>FollowRedirect</dt>
               <dt>FollowRedirect</dt>

+ 147 - 113
frontend/src/pages/inbounds/InboundList.vue

@@ -50,6 +50,7 @@ const props = defineProps({
   // inbound row can render its node name without an extra fetch.
   // inbound row can render its node name without an extra fetch.
   nodesById: { type: Map, default: () => new Map() },
   nodesById: { type: Map, default: () => new Map() },
   hasActiveNode: { type: Boolean, default: false },
   hasActiveNode: { type: Boolean, default: false },
+  statsVersion: { type: Number, default: 0 },
 });
 });
 
 
 const emit = defineEmits([
 const emit = defineEmits([
@@ -229,7 +230,7 @@ const hasAnyRemark = computed(() =>
 const desktopColumns = computed(() => {
 const desktopColumns = computed(() => {
   const cols = [
   const cols = [
     sortableCol({ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 }, 'id'),
     sortableCol({ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 }, 'id'),
-    { title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 },
+    { title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 60 },
     sortableCol({ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 }, 'enable'),
     sortableCol({ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 }, 'enable'),
   ];
   ];
   if (hasAnyRemark.value) {
   if (hasAnyRemark.value) {
@@ -263,6 +264,14 @@ function isExpanded(id) {
   return expandedIds.value.has(id);
   return expandedIds.value.has(id);
 }
 }
 
 
+const statsRecord = ref(null);
+function openStats(record) {
+  statsRecord.value = record;
+}
+function closeStats() {
+  statsRecord.value = null;
+}
+
 // ============ Pagination ============================================
 // ============ Pagination ============================================
 function paginationFor(rows) {
 function paginationFor(rows) {
   const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
   const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
@@ -388,13 +397,16 @@ function showQrCodeMenu(dbInbound) {
         <div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
         <div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
 
 
         <div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
         <div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
-          <!-- Header: chevron (multi-user only) + remark + enable + actions -->
+          <!-- Header: chevron (multi-user only) + id + remark + info + enable + actions -->
           <div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
           <div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
             <RightOutlined v-if="record.isMultiUser()" class="card-expand"
             <RightOutlined v-if="record.isMultiUser()" class="card-expand"
               :class="{ 'is-expanded': isExpanded(record.id) }" />
               :class="{ 'is-expanded': isExpanded(record.id) }" />
             <span class="card-id">#{{ record.id }}</span>
             <span class="card-id">#{{ record.id }}</span>
             <span class="tag-name">{{ record.remark }}</span>
             <span class="tag-name">{{ record.remark }}</span>
             <div class="card-actions" @click.stop>
             <div class="card-actions" @click.stop>
+              <a-tooltip :title="t('info')">
+                <InfoCircleOutlined class="row-action-trigger" @click="openStats(record)" />
+              </a-tooltip>
               <a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" />
               <a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" />
               <a-dropdown :trigger="['click']" placement="bottomRight">
               <a-dropdown :trigger="['click']" placement="bottomRight">
                 <MoreOutlined class="row-action-trigger" @click.prevent />
                 <MoreOutlined class="row-action-trigger" @click.prevent />
@@ -452,74 +464,12 @@ function showQrCodeMenu(dbInbound) {
             </div>
             </div>
           </div>
           </div>
 
 
-          <!-- 2-column labelled stat grid: protocol/port/node + traffic/clients/expiry -->
-          <div class="card-stats">
-            <div class="stat-row">
-              <span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
-              <a-tag color="purple">{{ record.protocol }}</a-tag>
-              <template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria">
-                <a-tag color="green">{{ record.isHysteria ? 'UDP' : record.toInbound().stream.network }}</a-tag>
-                <a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
-                <a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
-              </template>
-            </div>
-            <div class="stat-row">
-              <span class="stat-label">{{ t('pages.inbounds.port') }}</span>
-              <a-tag>{{ record.port }}</a-tag>
-            </div>
-            <div v-if="hasActiveNode" class="stat-row">
-              <span class="stat-label">{{ t('pages.inbounds.node') }}</span>
-              <a-tag v-if="record.nodeId == null" color="default">
-                {{ t('pages.inbounds.localPanel') }}
-              </a-tag>
-              <a-tag v-else-if="nodesById.get(record.nodeId)"
-                :color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
-                {{ nodesById.get(record.nodeId).name }}
-              </a-tag>
-              <a-tag v-else color="orange">#{{ record.nodeId }}</a-tag>
-            </div>
-            <div class="stat-row">
-              <span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
-              <a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
-                {{ SizeFormatter.sizeFormat(record.up + record.down) }} /
-                <template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
-                <InfinityIcon v-else />
-              </a-tag>
-            </div>
-            <div class="stat-row">
-              <span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
-              <a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
-            </div>
-            <div v-if="clientCount[record.id]" class="stat-row">
-              <span class="stat-label">{{ t('clients') }}</span>
-              <a-tag color="green" class="client-count-tag">{{ clientCount[record.id].clients }}</a-tag>
-              <a-tag v-if="clientCount[record.id].online.length" color="blue">
-                {{ clientCount[record.id].online.length }} {{ t('online') }}
-              </a-tag>
-              <a-tag v-if="clientCount[record.id].depleted.length" color="red">
-                {{ clientCount[record.id].depleted.length }} {{ t('depleted') }}
-              </a-tag>
-              <a-tag v-if="clientCount[record.id].expiring.length" color="orange">
-                {{ clientCount[record.id].expiring.length }} {{ t('depletingSoon') }}
-              </a-tag>
-            </div>
-            <div class="stat-row">
-              <span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
-              <a-tag v-if="record.expiryTime > 0"
-                :color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)">
-                {{ IntlUtil.formatRelativeTime(record.expiryTime) }}
-              </a-tag>
-              <a-tag v-else color="purple">
-                <InfinityIcon />
-              </a-tag>
-            </div>
-          </div>
-
           <!-- Expanded client list (multi-user only) -->
           <!-- Expanded client list (multi-user only) -->
           <div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
           <div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
             <ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
             <ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
               :online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
               :online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
               :page-size="pageSize" :total-client-count="clientCount[record.id]?.clients || 0"
               :page-size="pageSize" :total-client-count="clientCount[record.id]?.clients || 0"
+              :stats-version="statsVersion"
               @edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
               @edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
               @info-client="(p) => emit('info-client', p)"
               @info-client="(p) => emit('info-client', p)"
               @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
               @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@@ -530,6 +480,73 @@ function showQrCodeMenu(dbInbound) {
         </div>
         </div>
       </div>
       </div>
 
 
+      <!-- ====================== Mobile: info modal ====================== -->
+      <a-modal v-if="isMobile" :open="!!statsRecord" :footer="null" :width="360" centered
+        :title="statsRecord ? `#${statsRecord.id} ${statsRecord.remark || ''}`.trim() : ''" @cancel="closeStats">
+        <div v-if="statsRecord" class="card-stats">
+          <div class="stat-row">
+            <span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
+            <a-tag color="purple">{{ statsRecord.protocol }}</a-tag>
+            <template
+              v-if="statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria">
+              <a-tag color="green">{{ statsRecord.isHysteria ? 'UDP' : statsRecord.toInbound().stream.network }}</a-tag>
+              <a-tag v-if="statsRecord.toInbound().stream.isTls" color="blue">TLS</a-tag>
+              <a-tag v-if="statsRecord.toInbound().stream.isReality" color="blue">Reality</a-tag>
+            </template>
+          </div>
+          <div class="stat-row">
+            <span class="stat-label">{{ t('pages.inbounds.port') }}</span>
+            <a-tag>{{ statsRecord.port }}</a-tag>
+          </div>
+          <div v-if="hasActiveNode" class="stat-row">
+            <span class="stat-label">{{ t('pages.inbounds.node') }}</span>
+            <a-tag v-if="statsRecord.nodeId == null" color="default">
+              {{ t('pages.inbounds.localPanel') }}
+            </a-tag>
+            <a-tag v-else-if="nodesById.get(statsRecord.nodeId)"
+              :color="nodesById.get(statsRecord.nodeId).status === 'online' ? 'blue' : 'red'">
+              {{ nodesById.get(statsRecord.nodeId).name }}
+            </a-tag>
+            <a-tag v-else color="orange">#{{ statsRecord.nodeId }}</a-tag>
+          </div>
+          <div class="stat-row">
+            <span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
+            <a-tag :color="ColorUtils.usageColor(statsRecord.up + statsRecord.down, trafficDiff, statsRecord.total)">
+              {{ SizeFormatter.sizeFormat(statsRecord.up + statsRecord.down) }} /
+              <template v-if="statsRecord.total > 0">{{ SizeFormatter.sizeFormat(statsRecord.total) }}</template>
+              <InfinityIcon v-else />
+            </a-tag>
+          </div>
+          <div class="stat-row">
+            <span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
+            <a-tag>{{ SizeFormatter.sizeFormat(statsRecord.allTime || 0) }}</a-tag>
+          </div>
+          <div v-if="clientCount[statsRecord.id]" class="stat-row">
+            <span class="stat-label">{{ t('clients') }}</span>
+            <a-tag color="green" class="client-count-tag">{{ clientCount[statsRecord.id].clients }}</a-tag>
+            <a-tag v-if="clientCount[statsRecord.id].online.length" color="blue">
+              {{ clientCount[statsRecord.id].online.length }} {{ t('online') }}
+            </a-tag>
+            <a-tag v-if="clientCount[statsRecord.id].depleted.length" color="red">
+              {{ clientCount[statsRecord.id].depleted.length }} {{ t('depleted') }}
+            </a-tag>
+            <a-tag v-if="clientCount[statsRecord.id].expiring.length" color="orange">
+              {{ clientCount[statsRecord.id].expiring.length }} {{ t('depletingSoon') }}
+            </a-tag>
+          </div>
+          <div class="stat-row">
+            <span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
+            <a-tag v-if="statsRecord.expiryTime > 0"
+              :color="ColorUtils.usageColor(Date.now(), expireDiff, statsRecord._expiryTime)">
+              {{ IntlUtil.formatRelativeTime(statsRecord.expiryTime) }}
+            </a-tag>
+            <a-tag v-else color="purple">
+              <InfinityIcon />
+            </a-tag>
+          </div>
+        </div>
+      </a-modal>
+
       <!-- ====================== Desktop: a-table ======================== -->
       <!-- ====================== Desktop: a-table ======================== -->
       <a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
       <a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
         :pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
         :pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
@@ -542,6 +559,7 @@ function showQrCodeMenu(dbInbound) {
             :traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
             :traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
             :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
             :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
             :total-client-count="clientCount[record.id]?.clients || 0"
             :total-client-count="clientCount[record.id]?.clients || 0"
+            :stats-version="statsVersion"
             @edit-client="(p) => emit('edit-client', p)"
             @edit-client="(p) => emit('edit-client', p)"
             @qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
             @qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
             @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
             @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@@ -553,59 +571,68 @@ function showQrCodeMenu(dbInbound) {
         <template #bodyCell="{ column, record }">
         <template #bodyCell="{ column, record }">
           <!-- ============== Action dropdown ============== -->
           <!-- ============== Action dropdown ============== -->
           <template v-if="column.key === 'action'">
           <template v-if="column.key === 'action'">
-            <a-dropdown :trigger="['click']">
-              <MoreOutlined class="row-action-trigger" @click.prevent />
-              <template #overlay>
-                <a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
-                  <a-menu-item key="edit">
-                    <EditOutlined /> {{ t('edit') }}
-                  </a-menu-item>
-                  <a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
-                    <QrcodeOutlined /> {{ t('qrCode') }}
-                  </a-menu-item>
-                  <template v-if="record.isMultiUser()">
-                    <a-menu-item key="addClient">
-                      <UserAddOutlined /> {{ t('pages.client.add') }}
-                    </a-menu-item>
-                    <a-menu-item key="addBulkClient">
-                      <UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
-                    </a-menu-item>
-                    <a-menu-item key="copyClients">
-                      <CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
-                    </a-menu-item>
-                    <a-menu-item key="resetClients">
-                      <FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
+            <div class="action-buttons">
+              <a-button type="text" size="small" @click.prevent="emit('row-action', {key: 'edit', dbInbound: record})">
+                <template #icon>
+                  <EditOutlined />
+                </template>
+              </a-button>
+
+              <a-dropdown :trigger="['click']">
+                <a-button type="text" size="small" @click.prevent>
+                  <template #icon>
+                    <MoreOutlined />
+                  </template>
+                </a-button>
+                <template #overlay>
+                  <a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
+                    <a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
+                      <QrcodeOutlined /> {{ t('qrCode') }}
                     </a-menu-item>
                     </a-menu-item>
-                    <a-menu-item key="export">
-                      <ExportOutlined /> {{ t('pages.inbounds.export') }}
+                    <template v-if="record.isMultiUser()">
+                      <a-menu-item key="addClient">
+                        <UserAddOutlined /> {{ t('pages.client.add') }}
+                      </a-menu-item>
+                      <a-menu-item key="addBulkClient">
+                        <UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
+                      </a-menu-item>
+                      <a-menu-item key="copyClients">
+                        <CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
+                      </a-menu-item>
+                      <a-menu-item key="resetClients">
+                        <FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
+                      </a-menu-item>
+                      <a-menu-item key="export">
+                        <ExportOutlined /> {{ t('pages.inbounds.export') }}
+                      </a-menu-item>
+                      <a-menu-item v-if="subEnable" key="subs">
+                        <ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
+                      </a-menu-item>
+                      <a-menu-item key="delDepletedClients" class="danger-item">
+                        <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
+                      </a-menu-item>
+                    </template>
+                    <template v-else>
+                      <a-menu-item key="showInfo">
+                        <InfoCircleOutlined /> {{ t('info') }}
+                      </a-menu-item>
+                    </template>
+                    <a-menu-item key="clipboard">
+                      <CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
                     </a-menu-item>
                     </a-menu-item>
-                    <a-menu-item v-if="subEnable" key="subs">
-                      <ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
+                    <a-menu-item key="resetTraffic">
+                      <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
                     </a-menu-item>
                     </a-menu-item>
-                    <a-menu-item key="delDepletedClients" class="danger-item">
-                      <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
+                    <a-menu-item key="clone">
+                      <BlockOutlined /> {{ t('pages.inbounds.clone') }}
                     </a-menu-item>
                     </a-menu-item>
-                  </template>
-                  <template v-else>
-                    <a-menu-item key="showInfo">
-                      <InfoCircleOutlined /> {{ t('info') }}
+                    <a-menu-item key="delete" class="danger-item">
+                      <DeleteOutlined /> {{ t('delete') }}
                     </a-menu-item>
                     </a-menu-item>
-                  </template>
-                  <a-menu-item key="clipboard">
-                    <CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
-                  </a-menu-item>
-                  <a-menu-item key="resetTraffic">
-                    <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
-                  </a-menu-item>
-                  <a-menu-item key="clone">
-                    <BlockOutlined /> {{ t('pages.inbounds.clone') }}
-                  </a-menu-item>
-                  <a-menu-item key="delete" class="danger-item">
-                    <DeleteOutlined /> {{ t('delete') }}
-                  </a-menu-item>
-                </a-menu>
-              </template>
-            </a-dropdown>
+                  </a-menu>
+                </template>
+              </a-dropdown>
+            </div>
           </template>
           </template>
 
 
           <!-- ============== Enable switch (desktop) ============== -->
           <!-- ============== Enable switch (desktop) ============== -->
@@ -746,6 +773,13 @@ function showQrCodeMenu(dbInbound) {
     margin-bottom: 4px;
     margin-bottom: 4px;
 }
 }
 
 
+.action-buttons {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 4px;
+}
+
 .protocol-tags {
 .protocol-tags {
   display: inline-flex;
   display: inline-flex;
   flex-wrap: wrap;
   flex-wrap: wrap;

+ 2 - 0
frontend/src/pages/inbounds/InboundsPage.vue

@@ -45,6 +45,7 @@ const {
   ipLimitEnable,
   ipLimitEnable,
   remarkModel,
   remarkModel,
   lastOnlineMap,
   lastOnlineMap,
+  statsVersion,
   refresh,
   refresh,
   fetchDefaultSettings,
   fetchDefaultSettings,
   applyTrafficEvent,
   applyTrafficEvent,
@@ -648,6 +649,7 @@ function onRowAction({ key, dbInbound }) {
                   :last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
                   :last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
                   :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
                   :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
                   :sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="hasActiveNode"
                   :sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="hasActiveNode"
+                  :stats-version="statsVersion"
                   @refresh="refresh"
                   @refresh="refresh"
                   @add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
                   @add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
                   @edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
                   @edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"

+ 1 - 1
frontend/src/pages/inbounds/QrPanel.vue

@@ -47,7 +47,7 @@ function download() {
     </div>
     </div>
     <div v-if="showQr" class="qr-panel-canvas">
     <div v-if="showQr" class="qr-panel-canvas">
       <a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
       <a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
-        :title="t('copy')" @click="copy" />
+        color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy" />
     </div>
     </div>
   </div>
   </div>
 </template>
 </template>

+ 10 - 3
frontend/src/pages/inbounds/useInbounds.js

@@ -23,6 +23,11 @@ export function useInbounds() {
   const clientCount = ref({});
   const clientCount = ref({});
   const onlineClients = ref([]);
   const onlineClients = ref([]);
   const lastOnlineMap = ref({});
   const lastOnlineMap = ref({});
+  // Bumps on every client_stats merge so the per-inbound ClientRowTable
+  // child can re-render. DBInbound is a plain class instance, not reactive,
+  // so the in-place mutations on its clientStats array are invisible to
+  // Vue's tracking unless something else (this tick) signals the change.
+  const statsVersion = ref(0);
 
 
   // Default-settings sidecar fields the table needs for color/expiry math.
   // Default-settings sidecar fields the table needs for color/expiry math.
   const expireDiff = ref(0);
   const expireDiff = ref(0);
@@ -173,9 +178,9 @@ export function useInbounds() {
     rebuildClientCount();
     rebuildClientCount();
   }
   }
 
 
-  // The client_stats payload carries absolute traffic counters for the
-  // clients that had activity in the latest window plus per-inbound
-  // totals. Both are absolute (not deltas), so we overwrite in place.
+  // The client_stats payload carries absolute traffic counters for every
+  // client + per-inbound totals (full snapshot, not deltas). Both are
+  // overwritten in place.
   function applyClientStatsEvent(payload) {
   function applyClientStatsEvent(payload) {
     if (!payload || typeof payload !== 'object') return;
     if (!payload || typeof payload !== 'object') return;
     let touched = false;
     let touched = false;
@@ -220,6 +225,7 @@ export function useInbounds() {
     }
     }
 
 
     if (touched) {
     if (touched) {
+      statsVersion.value++;
       dbInbounds.value = [...dbInbounds.value];
       dbInbounds.value = [...dbInbounds.value];
       rebuildClientCount();
       rebuildClientCount();
     }
     }
@@ -315,6 +321,7 @@ export function useInbounds() {
     clientCount,
     clientCount,
     onlineClients,
     onlineClients,
     lastOnlineMap,
     lastOnlineMap,
+    statsVersion,
     totals,
     totals,
     expireDiff,
     expireDiff,
     trafficDiff,
     trafficDiff,

+ 223 - 1
frontend/src/pages/nodes/NodeList.vue

@@ -9,6 +9,9 @@ import {
   ExclamationCircleOutlined,
   ExclamationCircleOutlined,
   EyeOutlined,
   EyeOutlined,
   EyeInvisibleOutlined,
   EyeInvisibleOutlined,
+  InfoCircleOutlined,
+  MoreOutlined,
+  RightOutlined,
 } from '@ant-design/icons-vue';
 } from '@ant-design/icons-vue';
 import NodeHistoryPanel from './NodeHistoryPanel.vue';
 import NodeHistoryPanel from './NodeHistoryPanel.vue';
 
 
@@ -72,6 +75,25 @@ function formatPct(p) {
   if (typeof p !== 'number' || isNaN(p)) return '-';
   if (typeof p !== 'number' || isNaN(p)) return '-';
   return `${p.toFixed(1)}%`;
   return `${p.toFixed(1)}%`;
 }
 }
+
+const statsNode = ref(null);
+function openStats(node) {
+  statsNode.value = node;
+}
+function closeStats() {
+  statsNode.value = null;
+}
+
+const expandedIds = ref(new Set());
+function toggleExpanded(id) {
+  const next = new Set(expandedIds.value);
+  if (next.has(id)) next.delete(id);
+  else next.add(id);
+  expandedIds.value = next;
+}
+function isExpanded(id) {
+  return expandedIds.value.has(id);
+}
 </script>
 </script>
 
 
 <template>
 <template>
@@ -85,7 +107,103 @@ function formatPct(p) {
       </a-button>
       </a-button>
     </div>
     </div>
 
 
-    <a-table :data-source="dataSource" :pagination="false" :loading="loading" :scroll="{ x: 'max-content' }"
+    <!-- ====================== Mobile: card list ======================= -->
+    <div v-if="isMobile" class="node-cards">
+      <div v-if="dataSource.length === 0" class="card-empty">—</div>
+
+      <div v-for="record in dataSource" :key="record.id" class="node-card">
+        <div class="card-head" @click="toggleExpanded(record.id)">
+          <RightOutlined class="card-expand" :class="{ 'is-expanded': isExpanded(record.id) }" />
+          <a-badge
+            :status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
+          <span class="node-name">{{ record.name }}</span>
+          <div class="card-actions" @click.stop>
+            <a-tooltip :title="t('info')">
+              <InfoCircleOutlined class="row-action-trigger" @click="openStats(record)" />
+            </a-tooltip>
+            <a-switch :checked="record.enable" size="small" @change="(v) => emit('toggle-enable', record, v)" />
+            <a-dropdown :trigger="['click']" placement="bottomRight">
+              <MoreOutlined class="row-action-trigger" @click.prevent />
+              <template #overlay>
+                <a-menu>
+                  <a-menu-item key="probe" @click="emit('probe', record)">
+                    <ThunderboltOutlined /> {{ t('pages.nodes.probe') }}
+                  </a-menu-item>
+                  <a-menu-item key="edit" @click="emit('edit', record)">
+                    <EditOutlined /> {{ t('edit') }}
+                  </a-menu-item>
+                  <a-menu-item key="delete" class="danger-item" @click="emit('delete', record)">
+                    <DeleteOutlined /> {{ t('delete') }}
+                  </a-menu-item>
+                </a-menu>
+              </template>
+            </a-dropdown>
+          </div>
+        </div>
+
+        <div v-if="isExpanded(record.id)" class="card-history">
+          <NodeHistoryPanel :node="record" />
+        </div>
+      </div>
+    </div>
+
+    <a-modal v-if="isMobile" :open="!!statsNode" :footer="null" :width="360" centered
+      :title="statsNode ? statsNode.name : ''" @cancel="closeStats">
+      <div v-if="statsNode" class="card-stats">
+        <div v-if="statsNode.remark" class="stat-row">
+          <span class="stat-label">{{ t('pages.nodes.name') }}</span>
+          <span>{{ statsNode.remark }}</span>
+        </div>
+        <div class="stat-row">
+          <span class="stat-label">{{ t('pages.nodes.address') }}</span>
+          <a :href="statsNode.url" target="_blank" rel="noopener noreferrer"
+            :class="showAddress ? 'address-visible' : 'address-hidden'">{{ statsNode.url }}</a>
+          <a-tooltip :title="t('pages.index.toggleIpVisibility')">
+            <component :is="showAddress ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
+              @click="showAddress = !showAddress" />
+          </a-tooltip>
+        </div>
+        <div class="stat-row">
+          <span class="stat-label">{{ t('pages.nodes.status') }}</span>
+          <a-badge
+            :status="statusColor(statsNode.status) === 'green' ? 'success' : (statusColor(statsNode.status) === 'red' ? 'error' : 'default')" />
+          <span>{{ t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`) }}</span>
+          <a-tooltip v-if="statsNode.lastError" :title="statsNode.lastError">
+            <ExclamationCircleOutlined style="color: #faad14" />
+          </a-tooltip>
+        </div>
+        <div class="stat-row">
+          <span class="stat-label">{{ t('pages.nodes.cpu') }}</span>
+          <a-tag>{{ formatPct(statsNode.cpuPct) }}</a-tag>
+        </div>
+        <div class="stat-row">
+          <span class="stat-label">{{ t('pages.nodes.mem') }}</span>
+          <a-tag>{{ formatPct(statsNode.memPct) }}</a-tag>
+        </div>
+        <div class="stat-row">
+          <span class="stat-label">{{ t('pages.nodes.xrayVersion') }}</span>
+          <a-tag>{{ statsNode.xrayVersion || '-' }}</a-tag>
+        </div>
+        <div class="stat-row">
+          <span class="stat-label">{{ t('pages.nodes.uptime') }}</span>
+          <a-tag>{{ formatUptime(statsNode.uptimeSecs) }}</a-tag>
+        </div>
+        <div class="stat-row">
+          <span class="stat-label">{{ t('pages.nodes.latency') }}</span>
+          <a-tag>
+            <template v-if="statsNode.latencyMs > 0">{{ statsNode.latencyMs }} ms</template>
+            <template v-else>-</template>
+          </a-tag>
+        </div>
+        <div class="stat-row">
+          <span class="stat-label">{{ t('pages.nodes.lastHeartbeat') }}</span>
+          <a-tag>{{ relativeTime(statsNode.lastHeartbeat) }}</a-tag>
+        </div>
+      </div>
+    </a-modal>
+
+    <!-- ====================== Desktop: a-table ======================== -->
+    <a-table v-else :data-source="dataSource" :pagination="false" :loading="loading" :scroll="{ x: 'max-content' }"
       size="middle" row-key="id">
       size="middle" row-key="id">
       <template #expandedRowRender="{ record }">
       <template #expandedRowRender="{ record }">
         <NodeHistoryPanel :node="record" />
         <NodeHistoryPanel :node="record" />
@@ -240,4 +358,108 @@ function formatPct(p) {
 .address-visible {
 .address-visible {
   filter: none;
   filter: none;
 }
 }
+
+.node-cards {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  margin-top: 4px;
+}
+
+.node-card {
+  border: 1px solid rgba(128, 128, 128, 0.2);
+  border-radius: 10px;
+  padding: 12px;
+  background: rgba(255, 255, 255, 0.02);
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+:global(body.dark) .node-card {
+  background: rgba(255, 255, 255, 0.03);
+  border-color: rgba(255, 255, 255, 0.1);
+}
+
+.card-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  cursor: pointer;
+  user-select: none;
+}
+
+.card-expand {
+  font-size: 12px;
+  opacity: 0.6;
+  transition: transform 150ms ease;
+  flex-shrink: 0;
+}
+
+.card-expand.is-expanded {
+  transform: rotate(90deg);
+}
+
+.node-name {
+  font-weight: 600;
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.card-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-shrink: 0;
+}
+
+.row-action-trigger {
+  font-size: 20px;
+  cursor: pointer;
+}
+
+.card-stats {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.stat-row {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+
+.stat-label {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+  opacity: 0.6;
+  min-width: 96px;
+  flex-shrink: 0;
+}
+
+.card-stats :deep(.ant-tag) {
+  margin: 0;
+}
+
+.card-history {
+  margin-top: 4px;
+  padding-top: 8px;
+  border-top: 1px solid rgba(128, 128, 128, 0.15);
+}
+
+.card-empty {
+  text-align: center;
+  opacity: 0.4;
+  padding: 20px 0;
+}
+
+.danger-item {
+  color: #ff4d4f;
+}
 </style>
 </style>

+ 6 - 6
frontend/src/pages/nodes/NodesPage.vue

@@ -108,33 +108,33 @@ async function onToggleEnable(node, next) {
           <a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
           <a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
             <div v-if="!fetched" class="loading-spacer" />
             <div v-if="!fetched" class="loading-spacer" />
 
 
-            <a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
+            <a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
               <!-- Summary statistics card -->
               <!-- Summary statistics card -->
               <a-col :span="24">
               <a-col :span="24">
                 <a-card size="small" hoverable class="summary-card">
                 <a-card size="small" hoverable class="summary-card">
-                  <a-row :gutter="[16, 12]">
-                    <a-col :sm="12" :md="6">
+                  <a-row :gutter="[16, isMobile ? 16 : 12]">
+                    <a-col :xs="12" :sm="12" :md="6">
                       <CustomStatistic :title="t('pages.nodes.totalNodes')" :value="String(totals.total)">
                       <CustomStatistic :title="t('pages.nodes.totalNodes')" :value="String(totals.total)">
                         <template #prefix>
                         <template #prefix>
                           <CloudServerOutlined />
                           <CloudServerOutlined />
                         </template>
                         </template>
                       </CustomStatistic>
                       </CustomStatistic>
                     </a-col>
                     </a-col>
-                    <a-col :sm="12" :md="6">
+                    <a-col :xs="12" :sm="12" :md="6">
                       <CustomStatistic :title="t('pages.nodes.onlineNodes')" :value="String(totals.online)">
                       <CustomStatistic :title="t('pages.nodes.onlineNodes')" :value="String(totals.online)">
                         <template #prefix>
                         <template #prefix>
                           <CheckCircleOutlined style="color: #52c41a" />
                           <CheckCircleOutlined style="color: #52c41a" />
                         </template>
                         </template>
                       </CustomStatistic>
                       </CustomStatistic>
                     </a-col>
                     </a-col>
-                    <a-col :sm="12" :md="6">
+                    <a-col :xs="12" :sm="12" :md="6">
                       <CustomStatistic :title="t('pages.nodes.offlineNodes')" :value="String(totals.offline)">
                       <CustomStatistic :title="t('pages.nodes.offlineNodes')" :value="String(totals.offline)">
                         <template #prefix>
                         <template #prefix>
                           <CloseCircleOutlined style="color: #ff4d4f" />
                           <CloseCircleOutlined style="color: #ff4d4f" />
                         </template>
                         </template>
                       </CustomStatistic>
                       </CustomStatistic>
                     </a-col>
                     </a-col>
-                    <a-col :sm="12" :md="6">
+                    <a-col :xs="12" :sm="12" :md="6">
                       <CustomStatistic :title="t('pages.nodes.avgLatency')"
                       <CustomStatistic :title="t('pages.nodes.avgLatency')"
                         :value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'">
                         :value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'">
                         <template #prefix>
                         <template #prefix>

+ 50 - 11
frontend/src/pages/settings/SettingsPage.vue

@@ -228,39 +228,49 @@ onBeforeUnmount(() => {
                 </a-col>
                 </a-col>
 
 
                 <a-col :span="24">
                 <a-col :span="24">
-                  <a-tabs :active-key="activeTabKey" @change="onTabChange">
+                  <a-tabs :active-key="activeTabKey" :class="{ 'icons-only': isMobile }" @change="onTabChange">
                     <a-tab-pane key="1" class="tab-pane">
                     <a-tab-pane key="1" class="tab-pane">
                       <template #tab>
                       <template #tab>
-                        <SettingOutlined />
-                        <span>{{ t('pages.settings.panelSettings') }}</span>
+                        <a-tooltip :title="isMobile ? t('pages.settings.panelSettings') : null">
+                          <SettingOutlined />
+                        </a-tooltip>
+                        <span v-if="!isMobile">{{ t('pages.settings.panelSettings') }}</span>
                       </template>
                       </template>
                       <GeneralTab :all-setting="allSetting" />
                       <GeneralTab :all-setting="allSetting" />
                     </a-tab-pane>
                     </a-tab-pane>
                     <a-tab-pane key="2" class="tab-pane">
                     <a-tab-pane key="2" class="tab-pane">
                       <template #tab>
                       <template #tab>
-                        <SafetyOutlined />
-                        <span>{{ t('pages.settings.securitySettings') }}</span>
+                        <a-tooltip :title="isMobile ? t('pages.settings.securitySettings') : null">
+                          <SafetyOutlined />
+                        </a-tooltip>
+                        <span v-if="!isMobile">{{ t('pages.settings.securitySettings') }}</span>
                       </template>
                       </template>
                       <SecurityTab :all-setting="allSetting" />
                       <SecurityTab :all-setting="allSetting" />
                     </a-tab-pane>
                     </a-tab-pane>
                     <a-tab-pane key="3" class="tab-pane">
                     <a-tab-pane key="3" class="tab-pane">
                       <template #tab>
                       <template #tab>
-                        <MessageOutlined />
-                        <span>{{ t('pages.settings.TGBotSettings') }}</span>
+                        <a-tooltip :title="isMobile ? t('pages.settings.TGBotSettings') : null">
+                          <MessageOutlined />
+                        </a-tooltip>
+                        <span v-if="!isMobile">{{ t('pages.settings.TGBotSettings') }}</span>
                       </template>
                       </template>
                       <TelegramTab :all-setting="allSetting" />
                       <TelegramTab :all-setting="allSetting" />
                     </a-tab-pane>
                     </a-tab-pane>
                     <a-tab-pane key="4" class="tab-pane">
                     <a-tab-pane key="4" class="tab-pane">
                       <template #tab>
                       <template #tab>
-                        <CloudServerOutlined />
-                        <span>{{ t('pages.settings.subSettings') }}</span>
+                        <a-tooltip :title="isMobile ? t('pages.settings.subSettings') : null">
+                          <CloudServerOutlined />
+                        </a-tooltip>
+                        <span v-if="!isMobile">{{ t('pages.settings.subSettings') }}</span>
                       </template>
                       </template>
                       <SubscriptionGeneralTab :all-setting="allSetting" />
                       <SubscriptionGeneralTab :all-setting="allSetting" />
                     </a-tab-pane>
                     </a-tab-pane>
                     <a-tab-pane v-if="allSetting.subJsonEnable || allSetting.subClashEnable" key="5" class="tab-pane">
                     <a-tab-pane v-if="allSetting.subJsonEnable || allSetting.subClashEnable" key="5" class="tab-pane">
                       <template #tab>
                       <template #tab>
-                        <CodeOutlined />
-                        <span>{{ t('pages.settings.subSettings') }} (Formats)</span>
+                        <a-tooltip :title="isMobile ? `${t('pages.settings.subSettings')} (Formats)` : null">
+                          <CodeOutlined />
+                        </a-tooltip>
+                        <span v-if="!isMobile">{{ t('pages.settings.subSettings') }} (Formats)</span>
                       </template>
                       </template>
                       <SubscriptionFormatsTab :all-setting="allSetting" />
                       <SubscriptionFormatsTab :all-setting="allSetting" />
                     </a-tab-pane>
                     </a-tab-pane>
@@ -333,4 +343,33 @@ onBeforeUnmount(() => {
 .tab-pane {
 .tab-pane {
   padding-top: 20px;
   padding-top: 20px;
 }
 }
+
+.icons-only :deep(.ant-tabs-nav) {
+  margin-bottom: 8px;
+}
+
+.icons-only :deep(.ant-tabs-nav-wrap) {
+  width: 100%;
+}
+
+.icons-only :deep(.ant-tabs-nav-list) {
+  display: flex;
+  width: 100%;
+}
+
+.icons-only :deep(.ant-tabs-tab) {
+  flex: 1 1 0;
+  justify-content: center;
+  margin: 0;
+  padding: 10px 0;
+}
+
+.icons-only :deep(.ant-tabs-tab .anticon) {
+  margin: 0;
+  font-size: 18px;
+}
+
+.icons-only :deep(.ant-tabs-nav-operations) {
+  display: none;
+}
 </style>
 </style>

+ 1 - 1
frontend/src/pages/settings/TwoFactorModal.vue

@@ -82,7 +82,7 @@ async function copyToken() {
       <p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
       <p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
       <div class="qr-wrap">
       <div class="qr-wrap">
         <a-qrcode class="qr-code" :value="qrValue" :size="180" type="svg" :bordered="false"
         <a-qrcode class="qr-code" :value="qrValue" :size="180" type="svg" :bordered="false"
-          error-level="L" :title="t('copy')" @click="copyToken" />
+          color="#000000" bg-color="#ffffff" error-level="L" :title="t('copy')" @click="copyToken" />
         <span class="qr-token">{{ token }}</span>
         <span class="qr-token">{{ token }}</span>
       </div>
       </div>
       <a-divider />
       <a-divider />

+ 3 - 3
frontend/src/pages/sub/SubPage.vue

@@ -204,7 +204,7 @@ const themeClass = computed(() => ({
                   <div class="qr-box">
                   <div class="qr-box">
                     <a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
                     <a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
                     <a-qrcode class="qr-code" :value="subUrl" :size="QR_SIZE" type="svg" :bordered="false"
                     <a-qrcode class="qr-code" :value="subUrl" :size="QR_SIZE" type="svg" :bordered="false"
-                      :title="t('copy')" @click="copy(subUrl)" />
+                      color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subUrl)" />
                   </div>
                   </div>
                 </a-col>
                 </a-col>
                 <a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
                 <a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
@@ -213,14 +213,14 @@ const themeClass = computed(() => ({
                       {{ t('pages.settings.subSettings') }} JSON
                       {{ t('pages.settings.subSettings') }} JSON
                     </a-tag>
                     </a-tag>
                     <a-qrcode class="qr-code" :value="subJsonUrl" :size="QR_SIZE" type="svg" :bordered="false"
                     <a-qrcode class="qr-code" :value="subJsonUrl" :size="QR_SIZE" type="svg" :bordered="false"
-                      :title="t('copy')" @click="copy(subJsonUrl)" />
+                      color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subJsonUrl)" />
                   </div>
                   </div>
                 </a-col>
                 </a-col>
                 <a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
                 <a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
                   <div class="qr-box">
                   <div class="qr-box">
                     <a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
                     <a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
                     <a-qrcode class="qr-code" :value="subClashUrl" :size="QR_SIZE" type="svg" :bordered="false"
                     <a-qrcode class="qr-code" :value="subClashUrl" :size="QR_SIZE" type="svg" :bordered="false"
-                      :title="t('copy')" @click="copy(subClashUrl)" />
+                      color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subClashUrl)" />
                   </div>
                   </div>
                 </a-col>
                 </a-col>
               </a-row>
               </a-row>

+ 49 - 28
frontend/src/pages/xray/BalancersTab.vue

@@ -10,6 +10,7 @@ import {
 import { Modal } from 'ant-design-vue';
 import { Modal } from 'ant-design-vue';
 
 
 import BalancerFormModal from './BalancerFormModal.vue';
 import BalancerFormModal from './BalancerFormModal.vue';
+import JsonEditor from '@/components/JsonEditor.vue';
 
 
 const { t } = useI18n();
 const { t } = useI18n();
 
 
@@ -21,6 +22,7 @@ const { t } = useI18n();
 const props = defineProps({
 const props = defineProps({
   templateSettings: { type: Object, default: null },
   templateSettings: { type: Object, default: null },
   clientReverseTags: { type: Array, default: () => [] },
   clientReverseTags: { type: Array, default: () => [] },
+  isMobile: { type: Boolean, default: false },
 });
 });
 
 
 const STRATEGY_LABELS = {
 const STRATEGY_LABELS = {
@@ -196,7 +198,7 @@ function confirmDelete(idx) {
 }
 }
 
 
 const columns = computed(() => [
 const columns = computed(() => [
-  { title: '#', key: 'action', align: 'center', width: 80 },
+  { title: '#', key: 'action', align: 'center', width: 100 },
   { title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
   { title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
   { title: 'Strategy', key: 'strategy', align: 'center', width: 140 },
   { title: 'Strategy', key: 'strategy', align: 'center', width: 140 },
   { title: 'Selector', key: 'selector', align: 'center' },
   { title: 'Selector', key: 'selector', align: 'center' },
@@ -266,25 +268,39 @@ const obsText = computed({
         {{ t('pages.xray.Balancers') }}
         {{ t('pages.xray.Balancers') }}
       </a-button>
       </a-button>
 
 
-      <a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false" size="small" bordered>
+      <a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
+        size="small" :scroll="{ x: 400 }">
         <template #bodyCell="{ column, record, index }">
         <template #bodyCell="{ column, record, index }">
           <template v-if="column.key === 'action'">
           <template v-if="column.key === 'action'">
-            <span class="row-index">{{ index + 1 }}</span>
-            <a-dropdown :trigger="['click']">
-              <a-button shape="circle" size="small" class="action-btn">
-                <MoreOutlined />
-              </a-button>
-              <template #overlay>
-                <a-menu>
-                  <a-menu-item @click="openEdit(index)">
-                    <EditOutlined /> {{ t('edit') }}
-                  </a-menu-item>
-                  <a-menu-item class="danger" @click="confirmDelete(index)">
-                    <DeleteOutlined /> {{ t('delete') }}
-                  </a-menu-item>
-                </a-menu>
-              </template>
-            </a-dropdown>
+            <div class="action-cell">
+              <span class="row-index">{{ index + 1 }}</span>
+
+              <div :class="!isMobile ? 'action-buttons' : ''">
+                <a-button v-if="!isMobile" shape="circle" size="small" @click="openEdit(index)">
+                  <template #icon>
+                    <EditOutlined />
+                  </template>
+                </a-button>
+
+                <a-dropdown :trigger="['click']">
+                  <a-button shape="circle" size="small">
+                    <template #icon>
+                      <MoreOutlined />
+                    </template>
+                  </a-button>
+                  <template #overlay>
+                    <a-menu>
+                      <a-menu-item v-if="isMobile" @click="openEdit(index)">
+                        <EditOutlined /> {{ t('edit') }}
+                      </a-menu-item>
+                      <a-menu-item class="danger" @click="confirmDelete(index)">
+                        <DeleteOutlined /> {{ t('delete') }}
+                      </a-menu-item>
+                    </a-menu>
+                  </template>
+                </a-dropdown>
+              </div>
+            </div>
           </template>
           </template>
 
 
           <template v-else-if="column.key === 'strategy'">
           <template v-else-if="column.key === 'strategy'">
@@ -305,8 +321,7 @@ const obsText = computed({
           <a-radio-button v-if="hasObservatory" value="observatory">Observatory</a-radio-button>
           <a-radio-button v-if="hasObservatory" value="observatory">Observatory</a-radio-button>
           <a-radio-button v-if="hasBurstObservatory" value="burstObservatory">Burst Observatory</a-radio-button>
           <a-radio-button v-if="hasBurstObservatory" value="burstObservatory">Burst Observatory</a-radio-button>
         </a-radio-group>
         </a-radio-group>
-        <a-textarea v-model:value="obsText" :auto-size="{ minRows: 8, maxRows: 24 }" spellcheck="false"
-          class="json-editor" />
+        <JsonEditor v-model:value="obsText" min-height="220px" max-height="480px" />
       </template>
       </template>
     </template>
     </template>
 
 
@@ -316,23 +331,29 @@ const obsText = computed({
 </template>
 </template>
 
 
 <style scoped>
 <style scoped>
+.action-cell {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
 .row-index {
 .row-index {
   font-weight: 500;
   font-weight: 500;
   opacity: 0.7;
   opacity: 0.7;
-  margin-right: 6px;
+  min-width: 18px;
+  text-align: right;
 }
 }
 
 
-.action-btn {
-  vertical-align: middle;
+.action-buttons {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 4px;
+  margin-left: auto;
 }
 }
 
 
 .danger {
 .danger {
   color: #ff4d4f;
   color: #ff4d4f;
 }
 }
 
 
-.json-editor {
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-  font-size: 12px;
-  margin-top: 8px;
-}
 </style>
 </style>

+ 2 - 7
frontend/src/pages/xray/OutboundFormModal.vue

@@ -21,6 +21,7 @@ import {
   DNSRuleActions,
   DNSRuleActions,
 } from '@/models/outbound.js';
 } from '@/models/outbound.js';
 import FinalMaskForm from '@/components/FinalMaskForm.vue';
 import FinalMaskForm from '@/components/FinalMaskForm.vue';
+import JsonEditor from '@/components/JsonEditor.vue';
 
 
 const { t } = useI18n();
 const { t } = useI18n();
 
 
@@ -988,8 +989,7 @@ function regenerateWgKeys() {
               <a-button>Convert</a-button>
               <a-button>Convert</a-button>
             </template>
             </template>
           </a-input-search>
           </a-input-search>
-          <a-textarea v-model:value="advancedJson" :auto-size="{ minRows: 14, maxRows: 30 }" spellcheck="false"
-            class="json-editor" />
+          <JsonEditor v-model:value="advancedJson" min-height="360px" max-height="600px" />
         </a-space>
         </a-space>
       </a-tab-pane>
       </a-tab-pane>
     </a-tabs>
     </a-tabs>
@@ -1032,11 +1032,6 @@ function regenerateWgKeys() {
   opacity: 0.85;
   opacity: 0.85;
 }
 }
 
 
-.json-editor {
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-  font-size: 12px;
-}
-
 /* AD-Vue 4 renders a-checkbox children inside a-checkbox-group as
 /* AD-Vue 4 renders a-checkbox children inside a-checkbox-group as
  * inline-block, but inside a narrow form wrapper they can wrap
  * inline-block, but inside a narrow form wrapper they can wrap
  * inconsistently. Force a clean horizontal row with even gaps. */
  * inconsistently. Force a clean horizontal row with even gaps. */

+ 51 - 30
frontend/src/pages/xray/OutboundsTab.vue

@@ -157,9 +157,9 @@ function hasBreakdown(r) {
 // === Columns ========================================================
 // === Columns ========================================================
 // Computed so titles re-render after a locale swap.
 // Computed so titles re-render after a locale swap.
 const columns = computed(() => [
 const columns = computed(() => [
-  { title: '#', key: 'action', align: 'center', width: 70 },
-  { title: 'Tag', key: 'identity', align: 'left', width: 220 },
-  { title: t('pages.inbounds.address'), key: 'address', align: 'left', width: 230 },
+  { title: '#', key: 'action', align: 'center', width: 100 },
+  { title: 'Tag', key: 'identity', align: 'left' },
+  { title: t('pages.inbounds.address'), key: 'address', align: 'left' },
   { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 },
   { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 },
   { title: t('pages.xray.latency') !== 'pages.xray.latency' ? t('pages.xray.latency') : 'Latency', key: 'testResult', align: 'left', width: 140 },
   { title: t('pages.xray.latency') !== 'pages.xray.latency' ? t('pages.xray.latency') : 'Latency', key: 'testResult', align: 'left', width: 140 },
   { title: t('check'), key: 'test', align: 'center', width: 80 },
   { title: t('check'), key: 'test', align: 'center', width: 80 },
@@ -322,33 +322,41 @@ const rows = computed(() => {
         <template v-if="column.key === 'action'">
         <template v-if="column.key === 'action'">
           <div class="action-cell">
           <div class="action-cell">
             <span class="row-index">{{ index + 1 }}</span>
             <span class="row-index">{{ index + 1 }}</span>
-            <a-dropdown :trigger="['click']">
-              <a-button shape="circle" size="small">
-                <MoreOutlined />
-              </a-button>
-              <template #overlay>
-                <a-menu>
-                  <a-menu-item v-if="index > 0" @click="setFirst(index)">
-                    <VerticalAlignTopOutlined /> Move to top
-                  </a-menu-item>
-                  <a-menu-item @click="openEdit(index)">
-                    <EditOutlined /> Edit
-                  </a-menu-item>
-                  <a-menu-item :disabled="index === 0" @click="moveUp(index)">
-                    <ArrowUpOutlined />
-                  </a-menu-item>
-                  <a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
-                    <ArrowDownOutlined />
-                  </a-menu-item>
-                  <a-menu-item @click="emit('reset-traffic', record.tag || '')">
-                    <RetweetOutlined /> Reset traffic
-                  </a-menu-item>
-                  <a-menu-item class="danger" @click="confirmDelete(index)">
-                    <DeleteOutlined /> Delete
-                  </a-menu-item>
-                </a-menu>
-              </template>
-            </a-dropdown>
+
+            <div class="action-buttons">
+              <a-button shape="circle" size="small" @click="openEdit(index)">
+                <template #icon>
+                    <EditOutlined />
+                  </template>
+                </a-button>
+
+              <a-dropdown :trigger="['click']">
+                <a-button shape="circle" size="small">
+                  <template #icon>
+                    <MoreOutlined />
+                  </template>
+                </a-button>
+                <template #overlay>
+                  <a-menu>
+                    <a-menu-item v-if="index > 0" @click="setFirst(index)">
+                      <VerticalAlignTopOutlined /> Move to top
+                    </a-menu-item>
+                    <a-menu-item :disabled="index === 0" @click="moveUp(index)">
+                      <ArrowUpOutlined />
+                    </a-menu-item>
+                    <a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
+                      <ArrowDownOutlined />
+                    </a-menu-item>
+                    <a-menu-item @click="emit('reset-traffic', record.tag || '')">
+                      <RetweetOutlined /> Reset traffic
+                    </a-menu-item>
+                    <a-menu-item class="danger" @click="confirmDelete(index)">
+                      <DeleteOutlined /> Delete
+                    </a-menu-item>
+                  </a-menu>
+                </template>
+              </a-dropdown>
+            </div>
           </div>
           </div>
         </template>
         </template>
 
 
@@ -444,6 +452,11 @@ const rows = computed(() => {
   justify-content: flex-end;
   justify-content: flex-end;
 }
 }
 
 
+.toolbar-right :global(.ant-space),
+.header-actions :global(.ant-space) {
+  margin-bottom: 0 !important;
+}
+
 .card-empty {
 .card-empty {
   text-align: center;
   text-align: center;
   opacity: 0.4;
   opacity: 0.4;
@@ -526,6 +539,14 @@ const rows = computed(() => {
   text-align: right;
   text-align: right;
 }
 }
 
 
+.action-buttons {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 4px;
+  margin-left: auto;
+}
+
 .identity-cell {
 .identity-cell {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;

+ 43 - 24
frontend/src/pages/xray/RoutingTab.vue

@@ -188,9 +188,9 @@ function onDragPointerMove(ev) {
   dragMoved = true;
   dragMoved = true;
   const el = document.elementFromPoint(ev.clientX, ev.clientY);
   const el = document.elementFromPoint(ev.clientX, ev.clientY);
   if (!el) return;
   if (!el) return;
-  const tr = el.closest('tr[data-row-key]');
-  if (!tr) return;
-  const idx = Number(tr.getAttribute('data-row-key'));
+  const target = el.closest('[data-row-key]');
+  if (!target) return;
+  const idx = Number(target.getAttribute('data-row-key'));
   if (Number.isFinite(idx)) dropTargetIndex.value = idx;
   if (Number.isFinite(idx)) dropTargetIndex.value = idx;
 }
 }
 
 
@@ -220,7 +220,7 @@ function rowProps(_record, index) {
 // === Columns =========================================================
 // === Columns =========================================================
 // Computed so titles re-render after a locale swap.
 // Computed so titles re-render after a locale swap.
 const desktopColumns = computed(() => [
 const desktopColumns = computed(() => [
-  { title: '#', align: 'center', width: 70, key: 'action' },
+  { title: '#', align: 'center', width: 100, key: 'action' },
   { title: 'Source', align: 'left', width: 180, key: 'source' },
   { title: 'Source', align: 'left', width: 180, key: 'source' },
   { title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
   { title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
   { title: 'Destination', align: 'left', key: 'destination' },
   { title: 'Destination', align: 'left', key: 'destination' },
@@ -340,27 +340,38 @@ function chipPreview(value) {
             <HolderOutlined class="drag-handle" :title="t('drag') || 'Drag to reorder'"
             <HolderOutlined class="drag-handle" :title="t('drag') || 'Drag to reorder'"
               @pointerdown="onHandlePointerDown(index, $event)" />
               @pointerdown="onHandlePointerDown(index, $event)" />
             <span class="row-index">{{ index + 1 }}</span>
             <span class="row-index">{{ index + 1 }}</span>
-            <a-dropdown :trigger="['click']">
-              <a-button shape="circle" size="small">
-                <MoreOutlined />
+
+            <div :class="!isMobile ? 'action-buttons' : ''">
+              <a-button v-if="!isMobile" shape="circle" size="small" @click="openEdit(index)">
+                <template #icon>
+                  <EditOutlined />
+                </template>
               </a-button>
               </a-button>
-              <template #overlay>
-                <a-menu>
-                  <a-menu-item @click="openEdit(index)">
-                    <EditOutlined /> {{ t('edit') }}
-                  </a-menu-item>
-                  <a-menu-item :disabled="index === 0" @click="moveUp(index)">
-                    <ArrowUpOutlined />
-                  </a-menu-item>
-                  <a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
-                    <ArrowDownOutlined />
-                  </a-menu-item>
-                  <a-menu-item class="danger" @click="confirmDelete(index)">
-                    <DeleteOutlined /> {{ t('delete') }}
-                  </a-menu-item>
-                </a-menu>
-              </template>
-            </a-dropdown>
+
+              <a-dropdown :trigger="['click']">
+                <a-button shape="circle" size="small">
+                  <template #icon>
+                    <MoreOutlined />
+                  </template>
+                </a-button>
+                <template #overlay>
+                  <a-menu>
+                    <a-menu-item v-if="isMobile" @click="openEdit(index)">
+                      <EditOutlined /> {{ t('edit') }}
+                    </a-menu-item>
+                    <a-menu-item :disabled="index === 0" @click="moveUp(index)">
+                      <ArrowUpOutlined />
+                    </a-menu-item>
+                    <a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
+                      <ArrowDownOutlined />
+                    </a-menu-item>
+                    <a-menu-item class="danger" @click="confirmDelete(index)">
+                      <DeleteOutlined /> {{ t('delete') }}
+                    </a-menu-item>
+                  </a-menu>
+                </template>
+              </a-dropdown>
+            </div>
           </div>
           </div>
         </template>
         </template>
 
 
@@ -550,6 +561,14 @@ function chipPreview(value) {
   text-align: right;
   text-align: right;
 }
 }
 
 
+.action-buttons {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 4px;
+  margin-left: auto;
+}
+
 .criterion-flow {
 .criterion-flow {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;

+ 56 - 13
frontend/src/pages/xray/XrayPage.vue

@@ -22,6 +22,7 @@ import BalancersTab from './BalancersTab.vue';
 import DnsTab from './DnsTab.vue';
 import DnsTab from './DnsTab.vue';
 import WarpModal from './WarpModal.vue';
 import WarpModal from './WarpModal.vue';
 import NordModal from './NordModal.vue';
 import NordModal from './NordModal.vue';
+import JsonEditor from '@/components/JsonEditor.vue';
 import { useXraySetting } from './useXraySetting.js';
 import { useXraySetting } from './useXraySetting.js';
 import { useWebSocket } from '@/composables/useWebSocket.js';
 import { useWebSocket } from '@/composables/useWebSocket.js';
 
 
@@ -301,10 +302,13 @@ onBeforeUnmount(() => {
 
 
                 <!-- Tabs -->
                 <!-- Tabs -->
                 <a-col :span="24">
                 <a-col :span="24">
-                  <a-tabs :active-key="activeTabKey" @change="onTabChange">
+                  <a-tabs :active-key="activeTabKey" :class="{ 'icons-only': isMobile }" @change="onTabChange">
                     <a-tab-pane key="tpl-basic" class="tab-pane">
                     <a-tab-pane key="tpl-basic" class="tab-pane">
                       <template #tab>
                       <template #tab>
-                        <SettingOutlined /> <span>{{ t('pages.xray.basicTemplate') }}</span>
+                        <a-tooltip :title="isMobile ? t('pages.xray.basicTemplate') : null">
+                          <SettingOutlined />
+                        </a-tooltip>
+                        <span v-if="!isMobile">{{ t('pages.xray.basicTemplate') }}</span>
                       </template>
                       </template>
                       <BasicsTab :template-settings="templateSettings" :outbound-test-url="outboundTestUrl"
                       <BasicsTab :template-settings="templateSettings" :outbound-test-url="outboundTestUrl"
                         :warp-exist="warpExist" :nord-exist="nordExist"
                         :warp-exist="warpExist" :nord-exist="nordExist"
@@ -314,7 +318,10 @@ onBeforeUnmount(() => {
 
 
                     <a-tab-pane key="tpl-routing" class="tab-pane">
                     <a-tab-pane key="tpl-routing" class="tab-pane">
                       <template #tab>
                       <template #tab>
-                        <SwapOutlined /> <span>{{ t('pages.xray.Routings') }}</span>
+                        <a-tooltip :title="isMobile ? t('pages.xray.Routings') : null">
+                          <SwapOutlined />
+                        </a-tooltip>
+                        <span v-if="!isMobile">{{ t('pages.xray.Routings') }}</span>
                       </template>
                       </template>
                       <RoutingTab :template-settings="templateSettings" :inbound-tags="inboundTags"
                       <RoutingTab :template-settings="templateSettings" :inbound-tags="inboundTags"
                         :client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
                         :client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
@@ -322,7 +329,10 @@ onBeforeUnmount(() => {
 
 
                     <a-tab-pane key="tpl-outbound" class="tab-pane">
                     <a-tab-pane key="tpl-outbound" class="tab-pane">
                       <template #tab>
                       <template #tab>
-                        <UploadOutlined /> <span>{{ t('pages.xray.Outbounds') }}</span>
+                        <a-tooltip :title="isMobile ? t('pages.xray.Outbounds') : null">
+                          <UploadOutlined />
+                        </a-tooltip>
+                        <span v-if="!isMobile">{{ t('pages.xray.Outbounds') }}</span>
                       </template>
                       </template>
                       <OutboundsTab :template-settings="templateSettings" :outbounds-traffic="outboundsTraffic"
                       <OutboundsTab :template-settings="templateSettings" :outbounds-traffic="outboundsTraffic"
                         :outbound-test-states="outboundTestStates" :testing-all="testingAll"
                         :outbound-test-states="outboundTestStates" :testing-all="testingAll"
@@ -334,21 +344,31 @@ onBeforeUnmount(() => {
 
 
                     <a-tab-pane key="tpl-balancer" class="tab-pane">
                     <a-tab-pane key="tpl-balancer" class="tab-pane">
                       <template #tab>
                       <template #tab>
-                        <ClusterOutlined /> <span>{{ t('pages.xray.Balancers') }}</span>
+                        <a-tooltip :title="isMobile ? t('pages.xray.Balancers') : null">
+                          <ClusterOutlined />
+                        </a-tooltip>
+                        <span v-if="!isMobile">{{ t('pages.xray.Balancers') }}</span>
                       </template>
                       </template>
-                      <BalancersTab :template-settings="templateSettings" :client-reverse-tags="clientReverseTags" />
+                      <BalancersTab :template-settings="templateSettings" 
+                      :client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
                     </a-tab-pane>
                     </a-tab-pane>
 
 
                     <a-tab-pane key="tpl-dns" class="tab-pane">
                     <a-tab-pane key="tpl-dns" class="tab-pane">
                       <template #tab>
                       <template #tab>
-                        <DatabaseOutlined /> <span>DNS</span>
+                        <a-tooltip :title="isMobile ? 'DNS' : null">
+                          <DatabaseOutlined />
+                        </a-tooltip>
+                        <span v-if="!isMobile">DNS</span>
                       </template>
                       </template>
                       <DnsTab :template-settings="templateSettings" />
                       <DnsTab :template-settings="templateSettings" />
                     </a-tab-pane>
                     </a-tab-pane>
 
 
                     <a-tab-pane key="tpl-advanced" class="tab-pane">
                     <a-tab-pane key="tpl-advanced" class="tab-pane">
                       <template #tab>
                       <template #tab>
-                        <CodeOutlined /> <span>{{ t('pages.xray.advancedTemplate') }}</span>
+                        <a-tooltip :title="isMobile ? t('pages.xray.advancedTemplate') : null">
+                          <CodeOutlined />
+                        </a-tooltip>
+                        <span v-if="!isMobile">{{ t('pages.xray.advancedTemplate') }}</span>
                       </template>
                       </template>
                       <a-list-item-meta :title="t('pages.xray.Template')" :description="t('pages.xray.TemplateDesc')" />
                       <a-list-item-meta :title="t('pages.xray.Template')" :description="t('pages.xray.TemplateDesc')" />
                       <a-radio-group v-model:value="advSettings" button-style="solid"
                       <a-radio-group v-model:value="advSettings" button-style="solid"
@@ -358,8 +378,7 @@ onBeforeUnmount(() => {
                         <a-radio-button value="outboundSettings">{{ t('pages.xray.Outbounds') }}</a-radio-button>
                         <a-radio-button value="outboundSettings">{{ t('pages.xray.Outbounds') }}</a-radio-button>
                         <a-radio-button value="routingRuleSettings">{{ t('pages.xray.Routings') }}</a-radio-button>
                         <a-radio-button value="routingRuleSettings">{{ t('pages.xray.Routings') }}</a-radio-button>
                       </a-radio-group>
                       </a-radio-group>
-                      <a-textarea v-model:value="advancedText" :auto-size="{ minRows: 18, maxRows: 40 }"
-                        spellcheck="false" class="json-editor" />
+                      <JsonEditor v-model:value="advancedText" min-height="420px" max-height="720px" />
                     </a-tab-pane>
                     </a-tab-pane>
                   </a-tabs>
                   </a-tabs>
                 </a-col>
                 </a-col>
@@ -446,8 +465,32 @@ onBeforeUnmount(() => {
   margin: 0;
   margin: 0;
 }
 }
 
 
-.json-editor {
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-  font-size: 12px;
+.icons-only :deep(.ant-tabs-nav) {
+  margin-bottom: 8px;
+}
+
+.icons-only :deep(.ant-tabs-nav-wrap) {
+  width: 100%;
+}
+
+.icons-only :deep(.ant-tabs-nav-list) {
+  display: flex;
+  width: 100%;
+}
+
+.icons-only :deep(.ant-tabs-tab) {
+  flex: 1 1 0;
+  justify-content: center;
+  margin: 0;
+  padding: 10px 0;
+}
+
+.icons-only :deep(.ant-tabs-tab .anticon) {
+  margin: 0;
+  font-size: 18px;
+}
+
+.icons-only :deep(.ant-tabs-nav-operations) {
+  display: none;
 }
 }
 </style>
 </style>

+ 1 - 1
web/job/check_client_ip_job.go

@@ -181,7 +181,7 @@ func (j *CheckClientIpJob) processLogFile() bool {
 		var timestamp int64
 		var timestamp int64
 		timestampMatches := timestampRegex.FindStringSubmatch(line)
 		timestampMatches := timestampRegex.FindStringSubmatch(line)
 		if len(timestampMatches) >= 2 {
 		if len(timestampMatches) >= 2 {
-			t, err := time.Parse("2006/01/02 15:04:05", timestampMatches[1])
+			t, err := time.ParseInLocation("2006/01/02 15:04:05", timestampMatches[1], time.Local)
 			if err == nil {
 			if err == nil {
 				timestamp = t.Unix()
 				timestamp = t.Unix()
 			} else {
 			} else {

+ 6 - 51
web/job/node_traffic_sync_job.go

@@ -43,36 +43,6 @@ func (a *atomicBool) takeAndReset() bool {
 	return v
 	return v
 }
 }
 
 
-type emailSet struct {
-	mu sync.Mutex
-	m  map[string]struct{}
-}
-
-func newEmailSet() *emailSet { return &emailSet{m: make(map[string]struct{})} }
-
-func (s *emailSet) addAll(emails []string) {
-	if len(emails) == 0 {
-		return
-	}
-	s.mu.Lock()
-	for _, e := range emails {
-		if e != "" {
-			s.m[e] = struct{}{}
-		}
-	}
-	s.mu.Unlock()
-}
-
-func (s *emailSet) slice() []string {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-	out := make([]string, 0, len(s.m))
-	for e := range s.m {
-		out = append(out, e)
-	}
-	return out
-}
-
 func NewNodeTrafficSyncJob() *NodeTrafficSyncJob {
 func NewNodeTrafficSyncJob() *NodeTrafficSyncJob {
 	return &NodeTrafficSyncJob{}
 	return &NodeTrafficSyncJob{}
 }
 }
@@ -97,7 +67,6 @@ func (j *NodeTrafficSyncJob) Run() {
 		return
 		return
 	}
 	}
 
 
-	touched := newEmailSet()
 	sem := make(chan struct{}, nodeTrafficSyncConcurrency)
 	sem := make(chan struct{}, nodeTrafficSyncConcurrency)
 	var wg sync.WaitGroup
 	var wg sync.WaitGroup
 	for _, n := range nodes {
 	for _, n := range nodes {
@@ -109,7 +78,7 @@ func (j *NodeTrafficSyncJob) Run() {
 		go func(n *model.Node) {
 		go func(n *model.Node) {
 			defer wg.Done()
 			defer wg.Done()
 			defer func() { <-sem }()
 			defer func() { <-sem }()
-			j.syncOne(mgr, n, touched)
+			j.syncOne(mgr, n)
 		}(n)
 		}(n)
 	}
 	}
 	wg.Wait()
 	wg.Wait()
@@ -135,12 +104,10 @@ func (j *NodeTrafficSyncJob) Run() {
 	})
 	})
 
 
 	clientStats := map[string]any{}
 	clientStats := map[string]any{}
-	if emails := touched.slice(); len(emails) > 0 {
-		if stats, err := j.inboundService.GetActiveClientTraffics(emails); err != nil {
-			logger.Warning("node traffic sync: get client traffics for websocket failed:", err)
-		} else if len(stats) > 0 {
-			clientStats["clients"] = stats
-		}
+	if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
+		logger.Warning("node traffic sync: get all client traffics for websocket failed:", err)
+	} else if len(stats) > 0 {
+		clientStats["clients"] = stats
 	}
 	}
 	if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
 	if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
 		logger.Warning("node traffic sync: get inbounds summary for websocket failed:", err)
 		logger.Warning("node traffic sync: get inbounds summary for websocket failed:", err)
@@ -156,7 +123,7 @@ func (j *NodeTrafficSyncJob) Run() {
 	}
 	}
 }
 }
 
 
-func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, touched *emailSet) {
+func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
 	ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
 	ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
 	defer cancel()
 	defer cancel()
 
 
@@ -179,16 +146,4 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, touche
 	if changed {
 	if changed {
 		j.structural.set()
 		j.structural.set()
 	}
 	}
-	for _, ib := range snap.Inbounds {
-		if ib == nil {
-			continue
-		}
-		emails := make([]string, 0, len(ib.ClientStats))
-		for _, cs := range ib.ClientStats {
-			if cs.Email != "" {
-				emails = append(emails, cs.Email)
-			}
-		}
-		touched.addAll(emails)
-	}
 }
 }

+ 9 - 31
web/job/xray_traffic_job.go

@@ -95,18 +95,16 @@ func (j *XrayTrafficJob) Run() {
 		"lastOnlineMap":  lastOnlineMap,
 		"lastOnlineMap":  lastOnlineMap,
 	})
 	})
 
 
-	// Compact delta payload: per-client absolute counters for clients active
-	// this cycle, plus inbound-level absolute totals. Frontend applies both
-	// in-place — typical payload ~10–50KB even for 10k+ client deployments.
-	// Replaces the old full-inbound-list broadcast that hit WS size limits
-	// (5–10MB) and forced the frontend into a REST refetch.
+	// Full snapshot every cycle: absolute per-client counters and inbound
+	// totals. Frontend overwrites both in place. The previous delta path
+	// (activeEmails -> GetActiveClientTraffics) silently omitted the
+	// clients array whenever nobody moved bytes in the cycle, leaving the
+	// client rows in the UI stuck at stale traffic/remained/all-time.
 	clientStatsPayload := map[string]any{}
 	clientStatsPayload := map[string]any{}
-	if activeEmails := activeEmails(clientTraffics); len(activeEmails) > 0 {
-		if stats, err := j.inboundService.GetActiveClientTraffics(activeEmails); err != nil {
-			logger.Warning("get active client traffics for websocket failed:", err)
-		} else if len(stats) > 0 {
-			clientStatsPayload["clients"] = stats
-		}
+	if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
+		logger.Warning("get all client traffics for websocket failed:", err)
+	} else if len(stats) > 0 {
+		clientStatsPayload["clients"] = stats
 	}
 	}
 	if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
 	if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
 		logger.Warning("get inbounds traffic summary for websocket failed:", err)
 		logger.Warning("get inbounds traffic summary for websocket failed:", err)
@@ -126,26 +124,6 @@ func (j *XrayTrafficJob) Run() {
 	}
 	}
 }
 }
 
 
-// activeEmails returns the set of client emails that had non-zero traffic in
-// the current collection window. Idle clients are skipped — no need to push
-// their (unchanged) counters to the frontend.
-func activeEmails(clientTraffics []*xray.ClientTraffic) []string {
-	if len(clientTraffics) == 0 {
-		return nil
-	}
-	emails := make([]string, 0, len(clientTraffics))
-	for _, ct := range clientTraffics {
-		if ct == nil || ct.Email == "" {
-			continue
-		}
-		if ct.Up == 0 && ct.Down == 0 {
-			continue
-		}
-		emails = append(emails, ct.Email)
-	}
-	return emails
-}
-
 func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
 func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
 	informURL, err := j.settingService.GetExternalTrafficInformURI()
 	informURL, err := j.settingService.GetExternalTrafficInformURI()
 	if err != nil {
 	if err != nil {

+ 1 - 1
web/service/config.json

@@ -12,7 +12,7 @@
     "port": 62789,
     "port": 62789,
     "protocol": "tunnel",
     "protocol": "tunnel",
     "settings": {
     "settings": {
-      "address": "127.0.0.1"
+      "rewriteAddress": "127.0.0.1"
     },
     },
     "tag": "api"
     "tag": "api"
   }],
   }],

+ 14 - 0
web/service/inbound.go

@@ -3322,6 +3322,20 @@ func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.Clien
 	return traffics, nil
 	return traffics, nil
 }
 }
 
 
+// GetAllClientTraffics returns the full set of client_traffics rows so the
+// websocket broadcasters can ship a complete snapshot every cycle. The old
+// delta-only path (GetActiveClientTraffics on activeEmails) silently dropped
+// the per-client section whenever no client moved bytes in the cycle or a
+// node sync failed, leaving client rows in the UI stuck at stale numbers.
+func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) {
+	db := database.GetDB()
+	var traffics []*xray.ClientTraffic
+	if err := db.Model(xray.ClientTraffic{}).Find(&traffics).Error; err != nil {
+		return nil, err
+	}
+	return traffics, nil
+}
+
 type InboundTrafficSummary struct {
 type InboundTrafficSummary struct {
 	Id      int   `json:"id"`
 	Id      int   `json:"id"`
 	Up      int64 `json:"up"`
 	Up      int64 `json:"up"`

Some files were not shown because too many files changed in this diff