diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js index 48d8b9fe..800d4e92 100644 --- a/frontend/src/pages/api-docs/endpoints.js +++ b/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', diff --git a/frontend/src/pages/inbounds/InboundFormModal.vue b/frontend/src/pages/inbounds/InboundFormModal.vue index cd01691d..fd045bbb 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.vue +++ b/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( - - X25519 + + X25519 auth - - ML-KEM-768 + + ML-KEM-768 auth Clear + + Selected: {{ selectedVlessAuth }} + @@ -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; diff --git a/frontend/src/pages/inbounds/InboundList.vue b/frontend/src/pages/inbounds/InboundList.vue index 2e8a7a06..9f0ffdbe 100644 --- a/frontend/src/pages/inbounds/InboundList.vue +++ b/frontend/src/pages/inbounds/InboundList.vue @@ -167,6 +167,56 @@ const visibleInbounds = computed(() => { return applySecondaryFilters(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 @@ -177,23 +227,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; }); @@ -336,7 +386,7 @@ function showQrCodeMenu(dbInbound) {
-
+
- + diff --git a/frontend/src/pages/index/IndexPage.vue b/frontend/src/pages/index/IndexPage.vue index 292e4156..0551c2b6 100644 --- a/frontend/src/pages/index/IndexPage.vue +++ b/frontend/src/pages/index/IndexPage.vue @@ -14,6 +14,10 @@ import { SwapOutlined, EyeOutlined, EyeInvisibleOutlined, + ThunderboltOutlined, + DesktopOutlined, + DatabaseOutlined, + ForkOutlined, } from '@ant-design/icons-vue'; const { t } = useI18n(); @@ -31,6 +35,7 @@ import PanelUpdateModal from './PanelUpdateModal.vue'; import LogModal from './LogModal.vue'; import BackupModal from './BackupModal.vue'; import SystemHistoryModal from './SystemHistoryModal.vue'; +import XrayMetricsModal from './XrayMetricsModal.vue'; import XrayLogModal from './XrayLogModal.vue'; import VersionModal from './VersionModal.vue'; @@ -71,6 +76,7 @@ const logsOpen = ref(false); const backupOpen = ref(false); const panelUpdateOpen = ref(false); const sysHistoryOpen = ref(false); +const xrayMetricsOpen = ref(false); const xrayLogsOpen = ref(false); const versionOpen = ref(false); const configTextOpen = ref(false); @@ -98,6 +104,18 @@ function openSystemHistory() { sysHistoryOpen.value = true; } function openXrayLogs() { xrayLogsOpen.value = true; } function openVersionSwitch() { versionOpen.value = true; } +function openPanelVersion() { + if (panelUpdateInfo.value.updateAvailable) { + panelUpdateOpen.value = true; + } else { + window.open('https://github.com/MHSanaei/3x-ui/releases', '_blank', 'noopener,noreferrer'); + } +} + +function openTelegram() { + window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer'); +} + // Legacy "Config" action — fetch the rendered xray config and show // it as JSON in the shared TextModal (same UX as main). async function openConfig() { @@ -155,62 +173,83 @@ async function openConfig() { -