1
0

6 Коммиты 355bb4c9c0 ... 07bc74a521

Автор SHA1 Сообщение Дата
  MHSanaei 07bc74a521 feat(nodes): blur address column with eye-toggle, mirroring IndexPage IP card 1 день назад
  MHSanaei f570b991e7 fix(api-docs): copy API token button 1 день назад
  MHSanaei 80031e67cc feat(inbounds): restore copy-clients-between-inbounds modal 1 день назад
  Farhad H. P. Shirvan fdaa65ad7e Feat: clarify VLESS encryption auth selection (#4271) 1 день назад
  Farhad H. P. Shirvan d86e87ed30 Fix: traffic writer restart freeze (#4265) 1 день назад
  Abdalrahman 89a8f549f2 feat: sortable inbounds table columns (#4300) 1 день назад

+ 1 - 1
frontend/src/pages/api-docs/ApiDocsPage.vue

@@ -64,7 +64,7 @@ function regenerateApiToken() {
 
 async function copyApiToken() {
   if (!apiToken.value) return;
-  const ok = await ClipboardManager.copy(apiToken.value);
+  const ok = await ClipboardManager.copyText(apiToken.value);
   if (ok) message.success(t('success'));
 }
 

+ 1 - 1
frontend/src/pages/api-docs/endpoints.js

@@ -325,7 +325,7 @@ export const sections = [
       {
         method: 'GET',
         path: '/panel/api/server/getNewVlessEnc',
-        summary: 'Generate a new VLESS encryption keypair.',
+        summary: 'Generate VLESS encryption auth options. Returns auths with id, label, decryption, and encryption.',
       },
       {
         method: 'POST',

+ 185 - 0
frontend/src/pages/inbounds/CopyClientsModal.vue

@@ -0,0 +1,185 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { message } from 'ant-design-vue';
+
+import { HttpUtil, SizeFormatter, IntlUtil } from '@/utils';
+import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  dbInbound: { type: Object, default: null },
+  dbInbounds: { type: Array, default: () => [] },
+});
+
+const emit = defineEmits(['update:open', 'saved']);
+
+const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
+
+const sourceInboundId = ref(null);
+const selectedEmails = ref([]);
+const flow = ref('');
+const saving = ref(false);
+
+const sources = computed(() => {
+  if (!props.dbInbound) return [];
+  return props.dbInbounds
+    .filter(
+      (row) =>
+        row.id !== props.dbInbound.id &&
+        typeof row.isMultiUser === 'function' &&
+        row.isMultiUser(),
+    )
+    .map((row) => {
+      let count = 0;
+      try { count = (row.toInbound().clients || []).length; } catch (_e) { /* ignore */ }
+      return { id: row.id, label: `${row.remark || `#${row.id}`} (${row.protocol}, ${count})` };
+    });
+});
+
+const sourceInbound = computed(() => {
+  if (!sourceInboundId.value) return null;
+  return props.dbInbounds.find((r) => r.id === sourceInboundId.value) || null;
+});
+
+const sourceClients = computed(() => {
+  const sb = sourceInbound.value;
+  if (!sb) return [];
+  let list = [];
+  try { list = sb.toInbound().clients || []; } catch (_e) { /* ignore */ }
+  const stats = new Map((sb.clientStats || []).map((s) => [s.email, s]));
+  return list
+    .filter((c) => c.email)
+    .map((c) => {
+      const s = stats.get(c.email);
+      const used = s ? (s.up || 0) + (s.down || 0) : 0;
+      let expiryLabel = t('unlimited');
+      if (c.expiryTime > 0) expiryLabel = IntlUtil.formatDate(c.expiryTime);
+      else if (c.expiryTime < 0) expiryLabel = `${-c.expiryTime / 86400000}d`;
+      return { email: c.email, trafficLabel: SizeFormatter.sizeFormat(used), expiryLabel };
+    });
+});
+
+const showFlow = computed(() => {
+  if (!props.dbInbound) return false;
+  try {
+    const inb = props.dbInbound.toInbound();
+    return !!(inb && typeof inb.canEnableTlsFlow === 'function' && inb.canEnableTlsFlow());
+  } catch (_e) { return false; }
+});
+
+const columns = computed(() => [
+  { title: t('pages.inbounds.email'), dataIndex: 'email', width: 280 },
+  { title: t('pages.inbounds.traffic'), dataIndex: 'trafficLabel', width: 140 },
+  { title: t('pages.inbounds.expireDate'), dataIndex: 'expiryLabel', width: 160 },
+]);
+
+const rowSelection = computed(() => ({
+  selectedRowKeys: selectedEmails.value,
+  onChange: (keys) => { selectedEmails.value = keys; },
+}));
+
+const title = computed(() => {
+  if (!props.dbInbound) return t('pages.client.copyFromInbound');
+  const target = props.dbInbound.remark || `#${props.dbInbound.id}`;
+  return `${t('pages.client.copyToInbound')} ${target}`;
+});
+
+watch(() => props.open, (next) => {
+  if (!next) return;
+  sourceInboundId.value = null;
+  selectedEmails.value = [];
+  flow.value = '';
+  saving.value = false;
+});
+
+watch(sourceInboundId, () => {
+  selectedEmails.value = [];
+});
+
+function selectAll() {
+  selectedEmails.value = sourceClients.value.map((c) => c.email);
+}
+function clearAll() {
+  selectedEmails.value = [];
+}
+
+async function ok() {
+  if (!sourceInboundId.value) {
+    message.error(t('pages.client.copySelectSourceFirst'));
+    return;
+  }
+  if (!props.dbInbound) return;
+  saving.value = true;
+  try {
+    const payload = {
+      sourceInboundId: sourceInboundId.value,
+      clientEmails: selectedEmails.value,
+    };
+    if (showFlow.value && flow.value) payload.flow = flow.value;
+    const msg = await HttpUtil.post(
+      `/panel/api/inbounds/${props.dbInbound.id}/copyClients`,
+      payload,
+    );
+    if (!msg?.success) return;
+    const obj = msg.obj || {};
+    const addedCount = (obj.added || []).length;
+    const errorList = obj.errors || [];
+    if (addedCount > 0) {
+      message.success(`${t('pages.client.copyResultSuccess')}: ${addedCount}`);
+    } else {
+      message.warning(t('pages.client.copyResultNone'));
+    }
+    if (errorList.length > 0) {
+      message.error(`${t('pages.client.copyResultErrors')}: ${errorList.join('; ')}`);
+    }
+    emit('saved');
+    emit('update:open', false);
+  } finally {
+    saving.value = false;
+  }
+}
+
+function close() {
+  if (saving.value) return;
+  emit('update:open', false);
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="title" :ok-text="t('pages.client.copySelected')" :cancel-text="t('close')"
+    :confirm-loading="saving" :mask-closable="false" width="720px" @ok="ok" @cancel="close">
+    <a-space direction="vertical" :style="{ width: '100%' }">
+      <div>
+        <div :style="{ marginBottom: '6px' }">{{ t('pages.client.copySource') }}</div>
+        <a-select v-model:value="sourceInboundId" :style="{ width: '100%' }" allow-clear>
+          <a-select-option v-for="item in sources" :key="item.id" :value="item.id">
+            {{ item.label }}
+          </a-select-option>
+        </a-select>
+      </div>
+
+      <div v-if="sourceInboundId">
+        <a-space :style="{ marginBottom: '8px' }">
+          <a-button size="small" @click="selectAll">{{ t('pages.client.selectAll') }}</a-button>
+          <a-button size="small" @click="clearAll">{{ t('pages.client.clearAll') }}</a-button>
+        </a-space>
+        <a-table :columns="columns" :data-source="sourceClients" :pagination="false" size="small"
+          :row-key="(r) => r.email" :row-selection="rowSelection" :scroll="{ y: 280 }" />
+      </div>
+
+      <div v-if="showFlow">
+        <div :style="{ marginBottom: '6px' }">{{ t('pages.client.copyFlowLabel') }}</div>
+        <a-select v-model:value="flow" :style="{ width: '100%' }" allow-clear>
+          <a-select-option value="">{{ t('none') }}</a-select-option>
+          <a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
+        </a-select>
+        <div :style="{ marginTop: '4px', fontSize: '12px', opacity: 0.7 }">
+          {{ t('pages.client.copyFlowHint') }}
+        </div>
+      </div>
+    </a-space>
+  </a-modal>
+</template>

+ 42 - 10
frontend/src/pages/inbounds/InboundFormModal.vue

@@ -393,16 +393,29 @@ async function fetchDefaultCertSettings() {
 }
 
 // === VLESS encryption helpers =======================================
-// `xray vlessenc` returns both X25519 and ML-KEM-768 variants every
-// call; the user clicks one of two buttons to pick which block goes
-// into decryption/encryption.
-async function getNewVlessEnc(authLabel) {
-  if (!authLabel || !inbound.value?.settings) return;
+// `xray vlessenc` returns both X25519 and ML-KEM-768 auth variants every
+// call; the user clicks one button to pick which block goes into
+// decryption/encryption. Both generated strings share the same hybrid
+// mlkem768x25519plus prefix; the auth choice is the final key block.
+function normalizeVlessAuthLabel(label = '') {
+  return label.toLowerCase().replace(/[-_\s]/g, '');
+}
+
+function matchesVlessAuth(block, authId) {
+  if (block?.id === authId) return true;
+  const label = normalizeVlessAuthLabel(block?.label);
+  if (authId === 'mlkem768') return label.includes('mlkem768');
+  if (authId === 'x25519') return label.includes('x25519');
+  return false;
+}
+
+async function getNewVlessEnc(authId) {
+  if (!authId || !inbound.value?.settings) return;
   saving.value = true;
   try {
     const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
     if (!msg?.success) return;
-    const block = (msg.obj?.auths || []).find((a) => a.label === authLabel);
+    const block = (msg.obj?.auths || []).find((a) => matchesVlessAuth(a, authId));
     if (!block) return;
     inbound.value.settings.decryption = block.decryption;
     inbound.value.settings.encryption = block.encryption;
@@ -417,6 +430,17 @@ function clearVlessEnc() {
   inbound.value.settings.encryption = 'none';
 }
 
+const selectedVlessAuth = computed(() => {
+  const encryption = inbound.value?.settings?.encryption;
+  if (!encryption || encryption === 'none') return 'None';
+
+  const parts = encryption.split('.').filter(Boolean);
+  const authKey = parts[parts.length - 1] || '';
+  if (!authKey) return 'Custom';
+
+  return authKey.length > 300 ? 'ML-KEM-768 auth' : 'X25519 auth';
+});
+
 // === SS method change tracks legacy semantics =========================
 function onSSMethodChange() {
   inbound.value.settings.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method);
@@ -731,14 +755,17 @@ watch(
           </a-form-item>
           <a-form-item label=" ">
             <a-space :size="8" wrap>
-              <a-button type="primary" :loading="saving" @click="getNewVlessEnc('X25519, not Post-Quantum')">
-                X25519
+              <a-button type="primary" :loading="saving" @click="getNewVlessEnc('x25519')">
+                X25519 auth
               </a-button>
-              <a-button type="primary" :loading="saving" @click="getNewVlessEnc('ML-KEM-768, Post-Quantum')">
-                ML-KEM-768
+              <a-button type="primary" :loading="saving" @click="getNewVlessEnc('mlkem768')">
+                ML-KEM-768 auth
               </a-button>
               <a-button danger @click="clearVlessEnc">Clear</a-button>
             </a-space>
+            <a-typography-text type="secondary" class="vless-auth-state">
+              Selected: {{ selectedVlessAuth }}
+            </a-typography-text>
           </a-form-item>
         </a-form>
 
@@ -1741,6 +1768,11 @@ watch(
   color: #ff4d4f;
 }
 
+.vless-auth-state {
+  display: block;
+  margin-top: 6px;
+}
+
 .json-editor {
   font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
   font-size: 12px;

+ 65 - 15
frontend/src/pages/inbounds/InboundList.vue

@@ -1,5 +1,5 @@
 <script setup>
-import { computed, ref } from 'vue';
+import { computed, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import {
   PlusOutlined,
@@ -118,6 +118,56 @@ const visibleInbounds = computed(() => {
   return out;
 });
 
+// ============ Sorting =================================================
+const sortState = ref({ column: null, order: null });
+
+function sortableCol(col, key) {
+  return {
+    ...col,
+    sorter: true,
+    showSorterTooltip: false,
+    sortOrder: sortState.value.column === key ? sortState.value.order : null,
+    sortDirections: ['ascend', 'descend'],
+  };
+}
+
+const sortFns = {
+  id: (a, b) => a.id - b.id,
+  enable: (a, b) => Number(a.enable) - Number(b.enable),
+  remark: (a, b) => (a.remark || '').localeCompare(b.remark || ''),
+  port: (a, b) => a.port - b.port,
+  protocol: (a, b) => a.protocol.localeCompare(b.protocol),
+  traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
+  allTimeInbound: (a, b) => (a.allTime || 0) - (b.allTime || 0),
+  expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
+  node: (a, b) => {
+    const nameA = props.nodesById.get(a.nodeId)?.name ?? (a.nodeId == null ? '\uffff' : `node #${a.nodeId}`);
+    const nameB = props.nodesById.get(b.nodeId)?.name ?? (b.nodeId == null ? '\uffff' : `node #${b.nodeId}`);
+    return nameA.localeCompare(nameB);
+  },
+  clients: (a, b) => (props.clientCount[a.id]?.clients || 0) - (props.clientCount[b.id]?.clients || 0),
+};
+
+const sortedInbounds = computed(() => {
+  const { column, order } = sortState.value;
+  if (!column || !order) return visibleInbounds.value;
+  const fn = sortFns[column];
+  if (!fn) return visibleInbounds.value;
+  const sorted = [...visibleInbounds.value].sort(fn);
+  return order === 'descend' ? sorted.reverse() : sorted;
+});
+
+function onTableChange(_pag, _filters, sorter) {
+  sortState.value = {
+    column: sorter?.columnKey || sorter?.field || null,
+    order: sorter?.order || null,
+  };
+}
+
+watch([searchKey, filterBy], () => {
+  sortState.value = { column: null, order: null };
+});
+
 // ============ Columns =================================================
 // `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
 // `responsive` array still works on column defs. Computed so column
@@ -128,23 +178,23 @@ const hasAnyRemark = computed(() =>
 
 const desktopColumns = computed(() => {
   const cols = [
-    { title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 },
+    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.enable'), key: 'enable', align: 'center', width: 35 },
+    sortableCol({ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 }, 'enable'),
   ];
   if (hasAnyRemark.value) {
-    cols.push({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 });
+    cols.push(sortableCol({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 }, 'remark'));
   }
   if (props.nodesById.size > 0) {
-    cols.push({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 });
+    cols.push(sortableCol({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 }, 'node'));
   }
   cols.push(
-    { title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 },
-    { title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 },
-    { title: t('clients'), key: 'clients', align: 'left', width: 50 },
-    { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 },
-    { title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 },
-    { title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 },
+    sortableCol({ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 }, 'port'),
+    sortableCol({ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, 'protocol'),
+    sortableCol({ title: t('clients'), key: 'clients', align: 'left', width: 50 }, 'clients'),
+    sortableCol({ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, 'traffic'),
+    sortableCol({ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 }, 'allTimeInbound'),
+    sortableCol({ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, 'expiryTime'),
   );
   return cols;
 });
@@ -275,7 +325,7 @@ function showQrCodeMenu(dbInbound) {
       <div v-if="isMobile" class="inbound-cards">
         <div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
 
-        <div v-for="record in visibleInbounds" :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 -->
           <div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
             <RightOutlined v-if="record.isMultiUser()" class="card-expand"
@@ -419,9 +469,9 @@ function showQrCodeMenu(dbInbound) {
       </div>
 
       <!-- ====================== Desktop: a-table ======================== -->
-      <a-table v-else :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
-        :pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
-        :row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')">
+      <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"
+        :row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')" @change="onTableChange">
         <!-- Per-inbound client list, expanded by clicking the row's
              default expand chevron. Hidden via row-class-name for
              non-multi-user inbounds (matches legacy behavior). -->

+ 7 - 4
frontend/src/pages/inbounds/InboundsPage.vue

@@ -21,6 +21,7 @@ import InboundList from './InboundList.vue';
 import InboundFormModal from './InboundFormModal.vue';
 import ClientFormModal from './ClientFormModal.vue';
 import ClientBulkModal from './ClientBulkModal.vue';
+import CopyClientsModal from './CopyClientsModal.vue';
 import InboundInfoModal from './InboundInfoModal.vue';
 import QrCodeModal from './QrCodeModal.vue';
 import TextModal from '@/components/TextModal.vue';
@@ -88,6 +89,8 @@ const clientIndex = ref(null);
 
 const bulkOpen = ref(false);
 const bulkDbInbound = ref(null);
+const copyOpen = ref(false);
+const copyDbInbound = ref(null);
 
 // === Info / QR-code modals ===========================================
 const infoOpen = ref(false);
@@ -515,10 +518,8 @@ function onRowAction({ key, dbInbound }) {
       exportInboundClipboard(dbInbound);
       break;
     case 'copyClients':
-      // Copy-clients-from-inbound is a tiny dedicated modal in legacy
-      // (lets you tick clients to copy across inbounds). Defer to a
-      // future commit — surface a friendly message for now.
-      message.info('Copy clients across inbounds — coming soon');
+      copyDbInbound.value = dbInbound;
+      copyOpen.value = true;
       break;
     case 'delete':
       confirmDelete(dbInbound);
@@ -663,6 +664,8 @@ function onRowAction({ key, dbInbound }) {
         :ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
       <ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
         :tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
+      <CopyClientsModal v-model:open="copyOpen" :db-inbound="copyDbInbound" :db-inbounds="dbInbounds"
+        @saved="refresh" />
       <InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
         :remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
         :ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"

+ 42 - 5
frontend/src/pages/nodes/NodeList.vue

@@ -1,5 +1,5 @@
 <script setup>
-import { computed } from 'vue';
+import { computed, ref } from 'vue';
 import { useI18n } from 'vue-i18n';
 import {
   EditOutlined,
@@ -7,6 +7,8 @@ import {
   PlusOutlined,
   ThunderboltOutlined,
   ExclamationCircleOutlined,
+  EyeOutlined,
+  EyeInvisibleOutlined,
 } from '@ant-design/icons-vue';
 import NodeHistoryPanel from './NodeHistoryPanel.vue';
 
@@ -26,8 +28,6 @@ const emit = defineEmits([
 
 const { t } = useI18n();
 
-// Render the address column as a clickable URL so admins can jump to
-// the remote panel directly from the list.
 const dataSource = computed(() =>
   props.nodes.map((n) => ({
     ...n,
@@ -36,6 +36,8 @@ const dataSource = computed(() =>
   })),
 );
 
+const showAddress = ref(false);
+
 function statusColor(status) {
   switch (status) {
     case 'online': return 'green';
@@ -97,9 +99,19 @@ function formatPct(p) {
         </template>
       </a-table-column>
 
-      <a-table-column :title="t('pages.nodes.address')" data-index="url" :ellipsis="true">
+      <a-table-column data-index="url" :ellipsis="true">
+        <template #title>
+          <span class="address-header">
+            {{ t('pages.nodes.address') }}
+            <a-tooltip :title="t('pages.index.toggleIpVisibility')">
+              <component :is="showAddress ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
+                @click="showAddress = !showAddress" />
+            </a-tooltip>
+          </span>
+        </template>
         <template #default="{ record }">
-          <a :href="record.url" target="_blank" rel="noopener noreferrer">{{ record.url }}</a>
+          <a :href="record.url" target="_blank" rel="noopener noreferrer"
+            :class="showAddress ? 'address-visible' : 'address-hidden'">{{ record.url }}</a>
         </template>
       </a-table-column>
 
@@ -203,4 +215,29 @@ function formatPct(p) {
   font-size: 12px;
   opacity: 0.65;
 }
+
+.address-header {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.ip-toggle-icon {
+  cursor: pointer;
+  font-size: 14px;
+  opacity: 0.7;
+}
+
+.ip-toggle-icon:hover {
+  opacity: 1;
+}
+
+.address-hidden {
+  filter: blur(5px);
+  transition: filter 0.2s ease;
+}
+
+.address-visible {
+  filter: none;
+}
 </style>

+ 2 - 6
main.go

@@ -81,11 +81,7 @@ func runWebServer() {
 		case syscall.SIGHUP:
 			logger.Info("Received SIGHUP signal. Restarting servers...")
 
-			// --- FIX FOR TELEGRAM BOT CONFLICT (409): Stop bot before restart ---
-			service.StopBot()
-			// --
-
-			err := server.Stop()
+			err := server.StopPanelOnly()
 			if err != nil {
 				logger.Debug("Error stopping web server:", err)
 			}
@@ -96,7 +92,7 @@ func runWebServer() {
 
 			server = web.NewServer()
 			global.SetWebServer(server)
-			err = server.Start()
+			err = server.StartPanelOnly()
 			if err != nil {
 				log.Fatalf("Error restarting web server: %v", err)
 				return

+ 26 - 6
web/service/server.go

@@ -1275,7 +1275,13 @@ func (s *ServerService) GetNewVlessEnc() (any, error) {
 		return nil, err
 	}
 
-	lines := strings.Split(out.String(), "\n")
+	return map[string]any{
+		"auths": parseVlessEncAuths(out.String()),
+	}, nil
+}
+
+func parseVlessEncAuths(output string) []map[string]string {
+	lines := strings.Split(output, "\n")
 	var auths []map[string]string
 	var current map[string]string
 
@@ -1285,14 +1291,18 @@ func (s *ServerService) GetNewVlessEnc() (any, error) {
 			if current != nil {
 				auths = append(auths, current)
 			}
+			label := strings.TrimSpace(strings.TrimPrefix(line, "Authentication:"))
 			current = map[string]string{
-				"label": strings.TrimSpace(strings.TrimPrefix(line, "Authentication:")),
+				"id":    vlessEncAuthID(label),
+				"label": label,
 			}
 		} else if strings.HasPrefix(line, `"decryption"`) || strings.HasPrefix(line, `"encryption"`) {
 			parts := strings.SplitN(line, ":", 2)
 			if len(parts) == 2 && current != nil {
 				key := strings.Trim(parts[0], `" `)
-				val := strings.Trim(parts[1], `" `)
+				val := strings.TrimSpace(parts[1])
+				val = strings.TrimSuffix(val, ",")
+				val = strings.Trim(val, `" `)
 				current[key] = val
 			}
 		}
@@ -1302,9 +1312,19 @@ func (s *ServerService) GetNewVlessEnc() (any, error) {
 		auths = append(auths, current)
 	}
 
-	return map[string]any{
-		"auths": auths,
-	}, nil
+	return auths
+}
+
+func vlessEncAuthID(label string) string {
+	normalized := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(label))
+	switch {
+	case strings.Contains(normalized, "mlkem768"):
+		return "mlkem768"
+	case strings.Contains(normalized, "x25519"):
+		return "x25519"
+	default:
+		return normalized
+	}
 }
 
 func (s *ServerService) GetNewUUID() (map[string]string, error) {

+ 82 - 0
web/service/server_vlessenc_test.go

@@ -0,0 +1,82 @@
+package service
+
+import "testing"
+
+func TestParseVlessEncAuthsAddsStableIDs(t *testing.T) {
+	output := `
+Authentication: X25519, not Post-Quantum
+{
+  "decryption": "mlkem768x25519plus.native.600s.server-x25519",
+  "encryption": "mlkem768x25519plus.native.0rtt.client-x25519"
+}
+
+Authentication: ML-KEM-768, Post-Quantum
+{
+  "decryption": "mlkem768x25519plus.native.600s.server-mlkem",
+  "encryption": "mlkem768x25519plus.native.0rtt.client-mlkem"
+}
+`
+
+	auths := parseVlessEncAuths(output)
+	if len(auths) != 2 {
+		t.Fatalf("expected 2 auth blocks, got %d", len(auths))
+	}
+
+	tests := []struct {
+		index      int
+		id         string
+		label      string
+		decryption string
+		encryption string
+	}{
+		{
+			index:      0,
+			id:         "x25519",
+			label:      "X25519, not Post-Quantum",
+			decryption: "mlkem768x25519plus.native.600s.server-x25519",
+			encryption: "mlkem768x25519plus.native.0rtt.client-x25519",
+		},
+		{
+			index:      1,
+			id:         "mlkem768",
+			label:      "ML-KEM-768, Post-Quantum",
+			decryption: "mlkem768x25519plus.native.600s.server-mlkem",
+			encryption: "mlkem768x25519plus.native.0rtt.client-mlkem",
+		},
+	}
+
+	for _, test := range tests {
+		auth := auths[test.index]
+		if auth["id"] != test.id {
+			t.Errorf("auth[%d] id = %q, want %q", test.index, auth["id"], test.id)
+		}
+		if auth["label"] != test.label {
+			t.Errorf("auth[%d] label = %q, want %q", test.index, auth["label"], test.label)
+		}
+		if auth["decryption"] != test.decryption {
+			t.Errorf("auth[%d] decryption = %q, want %q", test.index, auth["decryption"], test.decryption)
+		}
+		if auth["encryption"] != test.encryption {
+			t.Errorf("auth[%d] encryption = %q, want %q", test.index, auth["encryption"], test.encryption)
+		}
+	}
+}
+
+func TestParseVlessEncAuthsHandlesMissingTrailingComma(t *testing.T) {
+	output := `
+Authentication: X25519, not Post-Quantum
+"decryption": "server"
+"encryption": "client"
+`
+
+	auths := parseVlessEncAuths(output)
+	if len(auths) != 1 {
+		t.Fatalf("expected 1 auth block, got %d", len(auths))
+	}
+	if auths[0]["decryption"] != "server" {
+		t.Fatalf("decryption = %q, want server", auths[0]["decryption"])
+	}
+	if auths[0]["encryption"] != "client" {
+		t.Fatalf("encryption = %q, want client", auths[0]["encryption"])
+	}
+}

+ 63 - 17
web/service/traffic_writer.go

@@ -23,6 +23,7 @@ type trafficWriteRequest struct {
 var (
 	twMu     sync.Mutex
 	twQueue  chan *trafficWriteRequest
+	twCtx    context.Context
 	twCancel context.CancelFunc
 	twDone   chan struct{}
 )
@@ -37,16 +38,26 @@ var (
 func StartTrafficWriter() {
 	twMu.Lock()
 	defer twMu.Unlock()
-	if twQueue != nil {
-		return
+
+	if twCancel != nil && twDone != nil {
+		select {
+		case <-twDone:
+			clearTrafficWriterState()
+		default:
+			return
+		}
 	}
+
 	queue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
 	ctx, cancel := context.WithCancel(context.Background())
 	done := make(chan struct{})
+
 	twQueue = queue
+	twCtx = ctx
 	twCancel = cancel
 	twDone = done
-	go runTrafficWriter(queue, ctx, done)
+
+	go runTrafficWriter(ctx, queue, done)
 }
 
 // StopTrafficWriter cancels the writer context and waits for the goroutine to
@@ -56,20 +67,30 @@ func StopTrafficWriter() {
 	twMu.Lock()
 	cancel := twCancel
 	done := twDone
-	twQueue = nil
-	twCancel = nil
-	twDone = nil
+	if cancel == nil || done == nil {
+		twMu.Unlock()
+		return
+	}
+	cancel()
 	twMu.Unlock()
 
-	if cancel != nil {
-		cancel()
-	}
-	if done != nil {
-		<-done
+	<-done
+
+	twMu.Lock()
+	if twDone == done {
+		clearTrafficWriterState()
 	}
+	twMu.Unlock()
 }
 
-func runTrafficWriter(queue chan *trafficWriteRequest, ctx context.Context, done chan struct{}) {
+func clearTrafficWriterState() {
+	twQueue = nil
+	twCtx = nil
+	twCancel = nil
+	twDone = nil
+}
+
+func runTrafficWriter(ctx context.Context, queue chan *trafficWriteRequest, done chan struct{}) {
 	defer close(done)
 	for {
 		select {
@@ -99,18 +120,43 @@ func safeApply(fn func() error) (err error) {
 }
 
 func submitTrafficWrite(fn func() error) error {
+	req := &trafficWriteRequest{apply: fn, done: make(chan error, 1)}
+
 	twMu.Lock()
 	queue := twQueue
-	twMu.Unlock()
+	ctx := twCtx
+	done := twDone
+	if queue == nil || ctx == nil || done == nil {
+		twMu.Unlock()
+		return safeApply(fn)
+	}
 
-	if queue == nil {
+	select {
+	case <-ctx.Done():
+		twMu.Unlock()
 		return safeApply(fn)
+	default:
 	}
-	req := &trafficWriteRequest{apply: fn, done: make(chan error, 1)}
+
+	timer := time.NewTimer(trafficWriterSubmitTimeout)
+	defer timer.Stop()
 	select {
 	case queue <- req:
-	case <-time.After(trafficWriterSubmitTimeout):
+		twMu.Unlock()
+	case <-timer.C:
+		twMu.Unlock()
 		return errors.New("traffic writer queue full")
 	}
-	return <-req.done
+
+	select {
+	case err := <-req.done:
+		return err
+	case <-done:
+		select {
+		case err := <-req.done:
+			return err
+		default:
+			return errors.New("traffic writer stopped before write completed")
+		}
+	}
 }

+ 190 - 0
web/service/traffic_writer_test.go

@@ -0,0 +1,190 @@
+package service
+
+import (
+	"sync/atomic"
+	"testing"
+	"time"
+)
+
+func TestTrafficWriterStartStopStartAcceptsWrites(t *testing.T) {
+	resetTrafficWriterForTest(t)
+
+	StartTrafficWriter()
+	var writes atomic.Int32
+	if err := submitTrafficWrite(func() error {
+		writes.Add(1)
+		return nil
+	}); err != nil {
+		t.Fatalf("first submitTrafficWrite: %v", err)
+	}
+
+	StopTrafficWriter()
+	StartTrafficWriter()
+	if err := submitTrafficWrite(func() error {
+		writes.Add(1)
+		return nil
+	}); err != nil {
+		t.Fatalf("second submitTrafficWrite: %v", err)
+	}
+
+	if got := writes.Load(); got != 2 {
+		t.Fatalf("writes = %d, want 2", got)
+	}
+}
+
+func TestTrafficWriterSubmitAfterStopRunsInline(t *testing.T) {
+	resetTrafficWriterForTest(t)
+
+	StartTrafficWriter()
+	StopTrafficWriter()
+
+	ran := make(chan struct{})
+	errCh := make(chan error, 1)
+	go func() {
+		errCh <- submitTrafficWrite(func() error {
+			close(ran)
+			return nil
+		})
+	}()
+
+	select {
+	case <-ran:
+	case <-time.After(time.Second):
+		t.Fatal("submitTrafficWrite did not run after traffic writer stopped")
+	}
+	if err := waitTrafficWriterErr(t, errCh); err != nil {
+		t.Fatalf("submitTrafficWrite after stop: %v", err)
+	}
+}
+
+func TestTrafficWriterStopDrainsQueuedWrite(t *testing.T) {
+	resetTrafficWriterForTest(t)
+
+	StartTrafficWriter()
+	firstStarted := make(chan struct{})
+	releaseFirst := make(chan struct{})
+	firstErr := make(chan error, 1)
+	go func() {
+		firstErr <- submitTrafficWrite(func() error {
+			close(firstStarted)
+			<-releaseFirst
+			return nil
+		})
+	}()
+	waitTrafficWriterSignal(t, firstStarted, "first write did not start")
+
+	secondRan := make(chan struct{})
+	secondErr := make(chan error, 1)
+	go func() {
+		secondErr <- submitTrafficWrite(func() error {
+			close(secondRan)
+			return nil
+		})
+	}()
+	waitTrafficWriterQueued(t)
+
+	stopDone := make(chan struct{})
+	go func() {
+		StopTrafficWriter()
+		close(stopDone)
+	}()
+
+	select {
+	case <-stopDone:
+		t.Fatal("StopTrafficWriter returned before in-flight write was released")
+	case <-time.After(50 * time.Millisecond):
+	}
+
+	close(releaseFirst)
+	waitTrafficWriterSignal(t, stopDone, "StopTrafficWriter did not return")
+	waitTrafficWriterSignal(t, secondRan, "queued write was not drained")
+
+	if err := waitTrafficWriterErr(t, firstErr); err != nil {
+		t.Fatalf("first submitTrafficWrite: %v", err)
+	}
+	if err := waitTrafficWriterErr(t, secondErr); err != nil {
+		t.Fatalf("second submitTrafficWrite: %v", err)
+	}
+}
+
+func TestTrafficWriterConcurrentStopDuringSubmitDoesNotHang(t *testing.T) {
+	resetTrafficWriterForTest(t)
+
+	StartTrafficWriter()
+	started := make(chan struct{})
+	release := make(chan struct{})
+	errCh := make(chan error, 1)
+	go func() {
+		errCh <- submitTrafficWrite(func() error {
+			close(started)
+			<-release
+			return nil
+		})
+	}()
+	waitTrafficWriterSignal(t, started, "write did not start")
+
+	stopDone := make(chan struct{})
+	go func() {
+		StopTrafficWriter()
+		close(stopDone)
+	}()
+
+	close(release)
+	waitTrafficWriterSignal(t, stopDone, "StopTrafficWriter hung during submit")
+	if err := waitTrafficWriterErr(t, errCh); err != nil {
+		t.Fatalf("submitTrafficWrite during stop: %v", err)
+	}
+}
+
+func resetTrafficWriterForTest(t *testing.T) {
+	t.Helper()
+	StopTrafficWriter()
+	twMu.Lock()
+	clearTrafficWriterState()
+	twMu.Unlock()
+	t.Cleanup(func() {
+		StopTrafficWriter()
+		twMu.Lock()
+		clearTrafficWriterState()
+		twMu.Unlock()
+	})
+}
+
+func waitTrafficWriterQueued(t *testing.T) {
+	t.Helper()
+
+	deadline := time.Now().Add(time.Second)
+	for time.Now().Before(deadline) {
+		twMu.Lock()
+		queued := 0
+		if twQueue != nil {
+			queued = len(twQueue)
+		}
+		twMu.Unlock()
+		if queued > 0 {
+			return
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+	t.Fatal("write was not queued")
+}
+
+func waitTrafficWriterSignal(t *testing.T, ch <-chan struct{}, msg string) {
+	t.Helper()
+	select {
+	case <-ch:
+	case <-time.After(time.Second):
+		t.Fatal(msg)
+	}
+}
+
+func waitTrafficWriterErr(t *testing.T, ch <-chan error) error {
+	t.Helper()
+	select {
+	case err := <-ch:
+		return err
+	case <-time.After(time.Second):
+		t.Fatal("timed out waiting for traffic writer result")
+		return nil
+	}
+}

+ 38 - 12
web/web.go

@@ -259,11 +259,13 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 
 // startTask schedules background jobs (Xray checks, traffic jobs, cron
 // jobs) which the panel relies on for periodic maintenance and monitoring.
-func (s *Server) startTask() {
+func (s *Server) startTask(restartXray bool) {
 	s.customGeoService.EnsureOnStartup()
-	err := s.xrayService.RestartXray(true)
-	if err != nil {
-		logger.Warning("start xray failed:", err)
+	if restartXray {
+		err := s.xrayService.RestartXray(true)
+		if err != nil {
+			logger.Warning("start xray failed:", err)
+		}
 	}
 	// Check whether xray is running every second
 	s.cron.AddJob("@every 1s", job.NewCheckXrayRunningJob())
@@ -348,6 +350,15 @@ func (s *Server) startTask() {
 
 // Start initializes and starts the web server with configured settings, routes, and background jobs.
 func (s *Server) Start() (err error) {
+	return s.start(true, true)
+}
+
+// StartPanelOnly initializes the panel during an in-process panel restart without cycling Xray.
+func (s *Server) StartPanelOnly() (err error) {
+	return s.start(false, false)
+}
+
+func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
 	// This is an anonymous function, no function name
 	defer func() {
 		if err != nil {
@@ -427,12 +438,14 @@ func (s *Server) Start() (err error) {
 		s.httpServer.Serve(listener)
 	}()
 
-	s.startTask()
+	s.startTask(restartXray)
 
-	isTgbotenabled, err := s.settingService.GetTgbotEnabled()
-	if (err == nil) && (isTgbotenabled) {
-		tgBot := s.tgbotService.NewTgbot()
-		tgBot.Start(i18nFS)
+	if startTgBot {
+		isTgbotenabled, err := s.settingService.GetTgbotEnabled()
+		if (err == nil) && (isTgbotenabled) {
+			tgBot := s.tgbotService.NewTgbot()
+			tgBot.Start(i18nFS)
+		}
 	}
 
 	return nil
@@ -440,13 +453,26 @@ func (s *Server) Start() (err error) {
 
 // Stop gracefully shuts down the web server, stops Xray, cron jobs, and Telegram bot.
 func (s *Server) Stop() error {
+	return s.stop(true, true)
+}
+
+// StopPanelOnly stops only panel-owned HTTP/background resources for an in-process panel restart.
+func (s *Server) StopPanelOnly() error {
+	return s.stop(false, false)
+}
+
+func (s *Server) stop(stopXray bool, stopTgBot bool) error {
 	s.cancel()
-	s.xrayService.StopXray()
+	if stopXray {
+		s.xrayService.StopXray()
+	}
 	if s.cron != nil {
 		s.cron.Stop()
 	}
-	service.StopTrafficWriter()
-	if s.tgbotService.IsRunning() {
+	if stopXray {
+		service.StopTrafficWriter()
+	}
+	if stopTgBot && s.tgbotService.IsRunning() {
 		s.tgbotService.Stop()
 	}
 	// Gracefully stop WebSocket hub