Merge branch 'main' into fix/farhadh/security-hardening-2

This commit is contained in:
Sanaei 2026-05-12 11:51:54 +02:00 committed by GitHub
commit 11db4f0d29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1391 additions and 150 deletions

View file

@ -325,7 +325,7 @@ export const sections = [
{ {
method: 'GET', method: 'GET',
path: '/panel/api/server/getNewVlessEnc', 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', method: 'POST',

View file

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

View file

@ -167,6 +167,56 @@ const visibleInbounds = computed(() => {
return applySecondaryFilters(out); 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 ================================================= // ============ Columns =================================================
// `key`-driven so we can render via the body-cell slot below. AD-Vue 4's // `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 // `responsive` array still works on column defs. Computed so column
@ -177,23 +227,23 @@ const hasAnyRemark = computed(() =>
const desktopColumns = computed(() => { const desktopColumns = computed(() => {
const cols = [ 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.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) { 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) { 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( cols.push(
{ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 }, sortableCol({ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 }, 'port'),
{ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, sortableCol({ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, 'protocol'),
{ title: t('clients'), key: 'clients', align: 'left', width: 50 }, sortableCol({ title: t('clients'), key: 'clients', align: 'left', width: 50 }, 'clients'),
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, sortableCol({ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, 'traffic'),
{ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 }, sortableCol({ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 }, 'allTimeInbound'),
{ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, sortableCol({ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, 'expiryTime'),
); );
return cols; return cols;
}); });
@ -336,7 +386,7 @@ function showQrCodeMenu(dbInbound) {
<div v-if="isMobile" class="inbound-cards"> <div v-if="isMobile" class="inbound-cards">
<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 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 --> <!-- Header: chevron (multi-user only) + remark + 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"
@ -480,9 +530,9 @@ function showQrCodeMenu(dbInbound) {
</div> </div>
<!-- ====================== Desktop: a-table ======================== --> <!-- ====================== Desktop: a-table ======================== -->
<a-table v-else :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id" <a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
:pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small" :pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')"> :row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')" @change="onTableChange">
<!-- Per-inbound client list, expanded by clicking the row's <!-- Per-inbound client list, expanded by clicking the row's
default expand chevron. Hidden via row-class-name for default expand chevron. Hidden via row-class-name for
non-multi-user inbounds (matches legacy behavior). --> non-multi-user inbounds (matches legacy behavior). -->

View file

@ -14,6 +14,10 @@ import {
SwapOutlined, SwapOutlined,
EyeOutlined, EyeOutlined,
EyeInvisibleOutlined, EyeInvisibleOutlined,
ThunderboltOutlined,
DesktopOutlined,
DatabaseOutlined,
ForkOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
const { t } = useI18n(); const { t } = useI18n();
@ -31,6 +35,7 @@ import PanelUpdateModal from './PanelUpdateModal.vue';
import LogModal from './LogModal.vue'; import LogModal from './LogModal.vue';
import BackupModal from './BackupModal.vue'; import BackupModal from './BackupModal.vue';
import SystemHistoryModal from './SystemHistoryModal.vue'; import SystemHistoryModal from './SystemHistoryModal.vue';
import XrayMetricsModal from './XrayMetricsModal.vue';
import XrayLogModal from './XrayLogModal.vue'; import XrayLogModal from './XrayLogModal.vue';
import VersionModal from './VersionModal.vue'; import VersionModal from './VersionModal.vue';
@ -71,6 +76,7 @@ const logsOpen = ref(false);
const backupOpen = ref(false); const backupOpen = ref(false);
const panelUpdateOpen = ref(false); const panelUpdateOpen = ref(false);
const sysHistoryOpen = ref(false); const sysHistoryOpen = ref(false);
const xrayMetricsOpen = ref(false);
const xrayLogsOpen = ref(false); const xrayLogsOpen = ref(false);
const versionOpen = ref(false); const versionOpen = ref(false);
const configTextOpen = ref(false); const configTextOpen = ref(false);
@ -98,6 +104,18 @@ function openSystemHistory() { sysHistoryOpen.value = true; }
function openXrayLogs() { xrayLogsOpen.value = true; } function openXrayLogs() { xrayLogsOpen.value = true; }
function openVersionSwitch() { versionOpen.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 // Legacy "Config" action fetch the rendered xray config and show
// it as JSON in the shared TextModal (same UX as main). // it as JSON in the shared TextModal (same UX as main).
async function openConfig() { async function openConfig() {
@ -155,62 +173,83 @@ async function openConfig() {
<a-col :xs="24" :lg="12"> <a-col :xs="24" :lg="12">
<a-card title="3X-UI" hoverable> <a-card title="3X-UI" hoverable>
<template v-if="panelUpdateInfo.updateAvailable" #extra> <template #actions>
<a-tooltip :title="`${t('pages.index.updatePanel')}: ${panelUpdateInfo.latestVersion}`"> <a-space class="action" @click="openTelegram">
<a-tag color="orange" class="update-tag" @click="panelUpdateOpen = true"> <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" class="tg-icon"
<CloudDownloadOutlined /> aria-hidden="true">
{{ panelUpdateInfo.latestVersion }} <path
<span v-if="!isMobile">{{ t('pages.index.updatePanel') }}</span> d="M21.93 4.34a1.5 1.5 0 0 0-2.05-1.6L2.97 9.6c-.92.36-.91 1.66.02 1.99l4.32 1.53 1.7 5.23a1 1 0 0 0 1.68.36l2.43-2.43 4.36 3.21a1.5 1.5 0 0 0 2.36-.91l3.09-13.86a1.5 1.5 0 0 0 0-.38ZM9.97 14.66l-.55 3.36-1.36-4.2 9.8-7.05-7.89 7.89Z" />
</a-tag> </svg>
</a-tooltip> <span v-if="!isMobile">@XrayUI</span>
</a-space>
<a-space class="action" :class="{ 'action-update': panelUpdateInfo.updateAvailable }"
@click="openPanelVersion">
<CloudDownloadOutlined />
<span v-if="!isMobile">
{{ panelUpdateInfo.updateAvailable
? `${t('update')} ${panelUpdateInfo.latestVersion}`
: `v${displayVersion}` }}
</span>
</a-space>
</template>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :title="t('pages.index.charts')" hoverable>
<template #actions>
<a-space class="action" @click="openSystemHistory">
<AreaChartOutlined />
<span v-if="!isMobile">{{ t('pages.index.systemHistoryTitle') }}</span>
</a-space>
<a-space class="action" @click="xrayMetricsOpen = true">
<AreaChartOutlined />
<span v-if="!isMobile">{{ t('pages.index.xrayMetricsTitle') }}</span>
</a-space>
</template> </template>
<div class="link-tags">
<a href="https://github.com/MHSanaei/3x-ui/releases" target="_blank" rel="noopener noreferrer">
<a-tag color="green">v{{ displayVersion }}</a-tag>
</a>
<a href="https://t.me/XrayUI" target="_blank" rel="noopener noreferrer">
<a-tag color="green">@XrayUI</a-tag>
</a>
<a href="https://github.com/MHSanaei/3x-ui/wiki" target="_blank" rel="noopener noreferrer">
<a-tag color="purple">{{ t('pages.index.documentation') }}</a-tag>
</a>
</div>
</a-card> </a-card>
</a-col> </a-col>
<a-col :xs="24" :lg="12"> <a-col :xs="24" :lg="12">
<a-card :title="t('pages.index.operationHours')" hoverable> <a-card :title="t('pages.index.operationHours')" hoverable>
<a-tag :color="status.xray.color"> <a-row :gutter="isMobile ? [8, 8] : 0">
Xray: {{ TimeFormatter.formatSecond(status.appStats.uptime) }} <a-col :span="12">
</a-tag> <CustomStatistic title="Xray" :value="TimeFormatter.formatSecond(status.appStats.uptime)">
<a-tag color="green">OS: {{ TimeFormatter.formatSecond(status.uptime) }}</a-tag> <template #prefix>
</a-card> <ThunderboltOutlined />
</a-col> </template>
</CustomStatistic>
<a-col :xs="24" :lg="12"> </a-col>
<a-card :title="t('pages.index.systemLoad')" hoverable> <a-col :span="12">
<template #extra> <CustomStatistic title="OS" :value="TimeFormatter.formatSecond(status.uptime)">
<a-tag color="blue" class="history-tag" @click="openSystemHistory"> <template #prefix>
<AreaChartOutlined /> <DesktopOutlined />
{{ t('pages.index.systemHistoryTitle') }} </template>
</a-tag> </CustomStatistic>
</template> </a-col>
<a-tooltip :title="t('pages.index.systemLoadDesc')"> </a-row>
<a-tag color="green">
{{ status.loads[0] }} | {{ status.loads[1] }} | {{ status.loads[2] }}
</a-tag>
</a-tooltip>
</a-card> </a-card>
</a-col> </a-col>
<a-col :xs="24" :lg="12"> <a-col :xs="24" :lg="12">
<a-card :title="t('usage')" hoverable> <a-card :title="t('usage')" hoverable>
<a-tag color="green"> <a-row :gutter="isMobile ? [8, 8] : 0">
{{ t('pages.index.memory') }}: {{ SizeFormatter.sizeFormat(status.appStats.mem) }} <a-col :span="12">
</a-tag> <CustomStatistic :title="t('pages.index.memory')"
<a-tag color="green"> :value="SizeFormatter.sizeFormat(status.appStats.mem)">
{{ t('pages.index.threads') }}: {{ status.appStats.threads }} <template #prefix>
</a-tag> <DatabaseOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :span="12">
<CustomStatistic :title="t('pages.index.threads')" :value="status.appStats.threads">
<template #prefix>
<ForkOutlined />
</template>
</CustomStatistic>
</a-col>
</a-row>
</a-card> </a-card>
</a-col> </a-col>
@ -318,6 +357,7 @@ async function openConfig() {
<LogModal v-model:open="logsOpen" /> <LogModal v-model:open="logsOpen" />
<BackupModal v-model:open="backupOpen" :base-path="basePath" @busy="setBusy" /> <BackupModal v-model:open="backupOpen" :base-path="basePath" @busy="setBusy" />
<SystemHistoryModal v-model:open="sysHistoryOpen" :status="status" /> <SystemHistoryModal v-model:open="sysHistoryOpen" :status="status" />
<XrayMetricsModal v-model:open="xrayMetricsOpen" />
<XrayLogModal v-model:open="xrayLogsOpen" /> <XrayLogModal v-model:open="xrayLogsOpen" />
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" /> <VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
<TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText" <TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
@ -374,12 +414,13 @@ async function openConfig() {
justify-content: center; justify-content: center;
} }
.update-tag { .action-update {
cursor: pointer; color: #fa8c16;
margin: 0; font-weight: 600;
display: inline-flex; }
align-items: center;
gap: 4px; .action-update :deep(.anticon) {
color: #fa8c16;
} }
.history-tag { .history-tag {
@ -390,18 +431,9 @@ async function openConfig() {
margin-inline-end: 0; margin-inline-end: 0;
} }
.link-tags { .tg-icon {
display: flex; display: inline-block;
flex-wrap: wrap; vertical-align: -2px;
gap: 6px;
}
.link-tags a {
display: inline-flex;
}
.link-tags :deep(.ant-tag) {
margin-inline-end: 0;
} }
.ip-toggle-icon { .ip-toggle-icon {

View file

@ -0,0 +1,347 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { HttpUtil, SizeFormatter } from '@/utils';
import Sparkline from '@/components/Sparkline.vue';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
const { t } = useI18n();
const { isMobile } = useMediaQuery();
const modalWidth = computed(() => (isMobile.value ? '95vw' : '900px'));
const props = defineProps({
open: { type: Boolean, default: false },
});
const emit = defineEmits(['update:open']);
const OBS_KEY = 'xrObs';
const metrics = [
{ key: 'xrAlloc', tab: 'Heap', unit: 'B', stroke: '#7c4dff' },
{ key: 'xrSys', tab: 'Sys', unit: 'B', stroke: '#1890ff' },
{ key: 'xrHeapObjects', tab: 'Objects', unit: '', stroke: '#13c2c2' },
{ key: 'xrNumGC', tab: 'GC Count', unit: '', stroke: '#fa8c16' },
{ key: 'xrPauseNs', tab: 'GC Pause', unit: 'ns', stroke: '#f5222d' },
{ key: OBS_KEY, tab: 'Observatory', unit: 'ms', stroke: '#52c41a' },
];
const activeKey = ref('xrAlloc');
const bucket = ref(2);
const points = ref([]);
const labels = ref([]);
const state = ref({ enabled: false, listen: '', reason: '' });
const obsTags = ref([]);
const obsActiveTag = ref('');
let obsTimer = null;
const activeMetric = computed(() => metrics.find((m) => m.key === activeKey.value));
const isObservatory = computed(() => activeKey.value === OBS_KEY);
const strokeColor = computed(() => activeMetric.value?.stroke || '#008771');
const activeObsTag = computed(() => obsTags.value.find((tg) => tg.tag === obsActiveTag.value) || null);
function unitFormatter(unit) {
if (unit === 'B') {
return (v) => SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0));
}
if (unit === 'ns') {
return (v) => {
const n = Math.max(0, Number(v) || 0);
if (n >= 1e6) return `${(n / 1e6).toFixed(2)} ms`;
if (n >= 1e3) return `${(n / 1e3).toFixed(1)} µs`;
return `${n.toFixed(0)} ns`;
};
}
if (unit === 'ms') {
return (v) => `${Math.round(Number(v) || 0)} ms`;
}
return (v) => {
const n = Number(v) || 0;
return Math.round(n).toLocaleString();
};
}
const yFormatter = computed(() => unitFormatter(activeMetric.value?.unit ?? ''));
function fmtTimestamp(unixSec) {
if (!unixSec) return '—';
const d = new Date(unixSec * 1000);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${d.toLocaleDateString()} ${hh}:${mm}:${ss}`;
}
async function fetchState() {
try {
const msg = await HttpUtil.get('/panel/api/server/xrayMetricsState');
if (msg?.success && msg.obj) state.value = msg.obj;
} catch (e) {
console.error('Failed to fetch xray metrics state', e);
}
}
async function fetchObservatory() {
try {
const msg = await HttpUtil.get('/panel/api/server/xrayObservatory');
if (msg?.success && Array.isArray(msg.obj)) {
obsTags.value = msg.obj;
if (!obsTags.value.find((tg) => tg.tag === obsActiveTag.value)) {
obsActiveTag.value = obsTags.value[0]?.tag || '';
}
} else {
obsTags.value = [];
}
} catch (e) {
console.error('Failed to fetch observatory snapshot', e);
obsTags.value = [];
}
}
async function fetchMetricBucket() {
const m = activeMetric.value;
if (!m) return;
try {
const url = `/panel/api/server/xrayMetricsHistory/${m.key}/${bucket.value}`;
const msg = await HttpUtil.get(url);
applyHistory(msg);
} catch (e) {
console.error('Failed to fetch xray metrics bucket', e);
labels.value = [];
points.value = [];
}
}
async function fetchObsBucket() {
const tag = obsActiveTag.value;
if (!tag) {
labels.value = [];
points.value = [];
return;
}
try {
const url = `/panel/api/server/xrayObservatoryHistory/${encodeURIComponent(tag)}/${bucket.value}`;
const msg = await HttpUtil.get(url);
applyHistory(msg);
} catch (e) {
console.error('Failed to fetch observatory bucket', e);
labels.value = [];
points.value = [];
}
}
function applyHistory(msg) {
if (msg?.success && Array.isArray(msg.obj)) {
const vals = [];
const labs = [];
for (const p of msg.obj) {
const d = new Date(p.t * 1000);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
labs.push(bucket.value >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
vals.push(Number(p.v) || 0);
}
labels.value = labs;
points.value = vals;
} else {
labels.value = [];
points.value = [];
}
}
function refreshActive() {
if (isObservatory.value) {
fetchObsBucket();
} else {
fetchMetricBucket();
}
}
function startObsPolling() {
stopObsPolling();
obsTimer = window.setInterval(async () => {
if (!props.open || !isObservatory.value) return;
await fetchObservatory();
fetchObsBucket();
}, 2000);
}
function stopObsPolling() {
if (obsTimer != null) {
window.clearInterval(obsTimer);
obsTimer = null;
}
}
function close() {
emit('update:open', false);
}
watch(() => props.open, (next) => {
if (next) {
activeKey.value = 'xrAlloc';
fetchState();
fetchMetricBucket();
} else {
stopObsPolling();
}
});
watch(activeKey, async (key) => {
if (!props.open) return;
if (key === OBS_KEY) {
await fetchObservatory();
fetchObsBucket();
startObsPolling();
} else {
stopObsPolling();
fetchMetricBucket();
}
});
watch(bucket, () => {
if (props.open) refreshActive();
});
watch(obsActiveTag, () => {
if (props.open && isObservatory.value) fetchObsBucket();
});
</script>
<template>
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" @cancel="close">
<template #title>
{{ t('pages.index.xrayMetricsTitle') }}
<a-select v-model:value="bucket" size="small" class="bucket-select">
<a-select-option :value="2">2m</a-select-option>
<a-select-option :value="30">30m</a-select-option>
<a-select-option :value="60">1h</a-select-option>
<a-select-option :value="120">2h</a-select-option>
<a-select-option :value="180">3h</a-select-option>
<a-select-option :value="300">5h</a-select-option>
</a-select>
</template>
<a-alert v-if="!state.enabled" type="warning" show-icon class="metrics-alert"
:message="t('pages.index.xrayMetricsDisabled')"
:description="state.reason || t('pages.index.xrayMetricsHint')" />
<a-tabs v-model:active-key="activeKey" size="small" class="history-tabs">
<a-tab-pane v-for="m in metrics" :key="m.key" :tab="m.tab" />
</a-tabs>
<div v-if="isObservatory" class="obs-pane">
<a-alert v-if="state.enabled && obsTags.length === 0" type="info" show-icon class="metrics-alert"
:message="t('pages.index.xrayObservatoryEmpty')"
:description="t('pages.index.xrayObservatoryHint')" />
<div v-else class="obs-controls">
<a-select v-model:value="obsActiveTag" size="small" class="obs-select"
:placeholder="t('pages.index.xrayObservatoryTagPlaceholder')">
<a-select-option v-for="tg in obsTags" :key="tg.tag" :value="tg.tag">
<span class="obs-dot" :class="tg.alive ? 'is-alive' : 'is-dead'" />
{{ tg.tag }}
</a-select-option>
</a-select>
<div v-if="activeObsTag" class="obs-stats">
<a-tag :color="activeObsTag.alive ? 'green' : 'red'">
{{ activeObsTag.alive ? t('pages.index.xrayObservatoryAlive') : t('pages.index.xrayObservatoryDead') }}
</a-tag>
<a-tag color="blue">{{ activeObsTag.delay }} ms</a-tag>
<span class="obs-stamp">
{{ t('pages.index.xrayObservatoryLastSeen') }}: {{ fmtTimestamp(activeObsTag.lastSeenTime) }}
</span>
<span class="obs-stamp">
{{ t('pages.index.xrayObservatoryLastTry') }}: {{ fmtTimestamp(activeObsTag.lastTryTime) }}
</span>
</div>
</div>
</div>
<div class="cpu-chart-wrap">
<div class="cpu-chart-meta">
Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
<span v-if="state.enabled && state.listen" class="listen-tag"> · {{ state.listen }}</span>
</div>
<Sparkline :data="points" :labels="labels" :vb-width="840" :height="220" :stroke="strokeColor" :stroke-width="2.2"
:show-grid="true" :show-axes="true" :tick-count-x="5" :max-points="points.length || 1" :fill-opacity="0.18"
:marker-radius="3.2" :show-tooltip="true" :value-min="0" :value-max="null" :y-formatter="yFormatter" />
</div>
</a-modal>
</template>
<style scoped>
.bucket-select {
width: 80px;
margin-left: 10px;
}
.metrics-alert {
margin-bottom: 10px;
}
.history-tabs {
margin-bottom: 4px;
}
.obs-pane {
padding: 4px 16px 0;
}
.obs-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.obs-select {
min-width: 240px;
}
.obs-stats {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 12px;
opacity: 0.85;
}
.obs-stamp {
opacity: 0.7;
}
.obs-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.obs-dot.is-alive {
background: #52c41a;
}
.obs-dot.is-dead {
background: #f5222d;
}
.cpu-chart-wrap {
padding: 8px 16px 16px;
}
.cpu-chart-meta {
margin-bottom: 10px;
font-size: 11px;
opacity: 0.65;
}
.listen-tag {
opacity: 0.7;
}
</style>

View file

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

View file

@ -23,9 +23,10 @@ var filenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`)
type ServerController struct { type ServerController struct {
BaseController BaseController
serverService service.ServerService serverService service.ServerService
settingService service.SettingService settingService service.SettingService
panelService service.PanelService panelService service.PanelService
xrayMetricsService service.XrayMetricsService
lastStatus *service.Status lastStatus *service.Status
@ -47,6 +48,10 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/status", a.status) g.GET("/status", a.status)
g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket) g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
g.GET("/history/:metric/:bucket", a.getMetricHistoryBucket) g.GET("/history/:metric/:bucket", a.getMetricHistoryBucket)
g.GET("/xrayMetricsState", a.getXrayMetricsState)
g.GET("/xrayMetricsHistory/:metric/:bucket", a.getXrayMetricsHistoryBucket)
g.GET("/xrayObservatory", a.getXrayObservatory)
g.GET("/xrayObservatoryHistory/:tag/:bucket", a.getXrayObservatoryHistoryBucket)
g.GET("/getXrayVersion", a.getXrayVersion) g.GET("/getXrayVersion", a.getXrayVersion)
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo) g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
g.GET("/getConfigJson", a.getConfigJson) g.GET("/getConfigJson", a.getConfigJson)
@ -75,7 +80,9 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
func (a *ServerController) refreshStatus() { func (a *ServerController) refreshStatus() {
a.lastStatus = a.serverService.GetStatus(a.lastStatus) a.lastStatus = a.serverService.GetStatus(a.lastStatus)
if a.lastStatus != nil { if a.lastStatus != nil {
a.serverService.AppendStatusSample(time.Now(), a.lastStatus) now := time.Now()
a.serverService.AppendStatusSample(now, a.lastStatus)
a.xrayMetricsService.Sample(now)
// Broadcast status update via WebSocket // Broadcast status update via WebSocket
websocket.BroadcastStatus(a.lastStatus) websocket.BroadcastStatus(a.lastStatus)
} }
@ -143,6 +150,42 @@ func (a *ServerController) getMetricHistoryBucket(c *gin.Context) {
jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil) jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil)
} }
func (a *ServerController) getXrayMetricsState(c *gin.Context) {
jsonObj(c, a.xrayMetricsService.State(), nil)
}
func (a *ServerController) getXrayMetricsHistoryBucket(c *gin.Context) {
metric := c.Param("metric")
if !slices.Contains(service.XrayMetricKeys, metric) {
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
return
}
bucket, err := strconv.Atoi(c.Param("bucket"))
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return
}
jsonObj(c, a.xrayMetricsService.AggregateMetric(metric, bucket, 60), nil)
}
func (a *ServerController) getXrayObservatory(c *gin.Context) {
jsonObj(c, a.xrayMetricsService.ObservatorySnapshot(), nil)
}
func (a *ServerController) getXrayObservatoryHistoryBucket(c *gin.Context) {
tag := c.Param("tag")
if !a.xrayMetricsService.HasObservatoryTag(tag) {
jsonMsg(c, "invalid tag", fmt.Errorf("unknown observatory tag"))
return
}
bucket, err := strconv.Atoi(c.Param("bucket"))
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return
}
jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
}
func (a *ServerController) getXrayVersion(c *gin.Context) { func (a *ServerController) getXrayVersion(c *gin.Context) {
const cacheTTLSeconds = 15 * 60 const cacheTTLSeconds = 15 * 60

View file

@ -130,6 +130,7 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
var ( var (
systemMetrics = newMetricHistory() systemMetrics = newMetricHistory()
nodeMetrics = newMetricHistory() nodeMetrics = newMetricHistory()
xrayMetrics = newMetricHistory()
) )
// SystemMetricKeys lists the metric names ServerService writes on every // SystemMetricKeys lists the metric names ServerService writes on every
@ -141,3 +142,11 @@ var SystemMetricKeys = []string{
// NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes. // NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes.
var NodeMetricKeys = []string{"cpu", "mem"} var NodeMetricKeys = []string{"cpu", "mem"}
// XrayMetricKeys lists series sourced from xray's /debug/vars expvar
// endpoint. Populated by XrayMetricsService.Sample on the same 2s cadence
// as the system metrics, but only when the xray config has a `metrics`
// block configured.
var XrayMetricKeys = []string{
"xrAlloc", "xrSys", "xrHeapObjects", "xrNumGC", "xrPauseNs",
}

View file

@ -54,6 +54,20 @@ func (s *NodeService) GetById(id int) (*model.Node, error) {
return n, nil return n, nil
} }
func normalizeBasePath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if !strings.HasSuffix(p, "/") {
p = p + "/"
}
return p
}
func (s *NodeService) normalize(n *model.Node) error { func (s *NodeService) normalize(n *model.Node) error {
n.Name = strings.TrimSpace(n.Name) n.Name = strings.TrimSpace(n.Name)
n.Address = strings.TrimSpace(n.Address) n.Address = strings.TrimSpace(n.Address)
@ -70,15 +84,7 @@ func (s *NodeService) normalize(n *model.Node) error {
if n.Scheme != "http" && n.Scheme != "https" { if n.Scheme != "http" && n.Scheme != "https" {
n.Scheme = "https" n.Scheme = "https"
} }
if n.BasePath == "" { n.BasePath = normalizeBasePath(n.BasePath)
n.BasePath = "/"
}
if !strings.HasPrefix(n.BasePath, "/") {
n.BasePath = "/" + n.BasePath
}
if !strings.HasSuffix(n.BasePath, "/") {
n.BasePath = n.BasePath + "/"
}
return nil return nil
} }
@ -170,13 +176,8 @@ func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds i
func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) { func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) {
patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()} patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()}
hostPort := net.JoinHostPort(n.Address, strconv.Itoa(n.Port)) url := fmt.Sprintf("%s://%s:%d%spanel/api/server/status",
url := fmt.Sprintf("%s://%s%spanel/api/server/status", n.Scheme, hostPort, n.BasePath) n.Scheme, n.Address, n.Port, normalizeBasePath(n.BasePath))
url, err := SanitizePublicHTTPURL(url, n.AllowPrivateAddress)
if err != nil {
patch.LastError = err.Error()
return patch, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {

View file

@ -1333,7 +1333,13 @@ func (s *ServerService) GetNewVlessEnc() (any, error) {
return nil, err 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 auths []map[string]string
var current map[string]string var current map[string]string
@ -1343,14 +1349,18 @@ func (s *ServerService) GetNewVlessEnc() (any, error) {
if current != nil { if current != nil {
auths = append(auths, current) auths = append(auths, current)
} }
label := strings.TrimSpace(strings.TrimPrefix(line, "Authentication:"))
current = map[string]string{ 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"`) { } else if strings.HasPrefix(line, `"decryption"`) || strings.HasPrefix(line, `"encryption"`) {
parts := strings.SplitN(line, ":", 2) parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 && current != nil { if len(parts) == 2 && current != nil {
key := strings.Trim(parts[0], `" `) 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 current[key] = val
} }
} }
@ -1360,9 +1370,19 @@ func (s *ServerService) GetNewVlessEnc() (any, error) {
auths = append(auths, current) auths = append(auths, current)
} }
return map[string]any{ return auths
"auths": auths, }
}, nil
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) { func (s *ServerService) GetNewUUID() (map[string]string, error) {

View file

@ -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"])
}
}

View file

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

View file

@ -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
}
}

224
web/service/xray_metrics.go Normal file
View file

@ -0,0 +1,224 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/logger"
)
type xrayMetricsState struct {
Enabled bool `json:"enabled"`
Listen string `json:"listen"`
Reason string `json:"reason,omitempty"`
}
type ObsTagSnapshot struct {
Tag string `json:"tag"`
Alive bool `json:"alive"`
Delay int64 `json:"delay"`
LastSeenTime int64 `json:"lastSeenTime"`
LastTryTime int64 `json:"lastTryTime"`
UpdatedAt int64 `json:"updatedAt"`
}
type XrayMetricsService struct {
settingService SettingService
mu sync.RWMutex
state xrayMetricsState
client *http.Client
obsByTag map[string]ObsTagSnapshot
}
var validObsTag = regexp.MustCompile(`^[a-zA-Z0-9._\-]+$`)
func obsHistoryKey(tag string) string {
return "xrObs." + tag + ".delay"
}
func newXrayMetricsClient() *http.Client {
return &http.Client{Timeout: 1500 * time.Millisecond}
}
func (s *XrayMetricsService) getClient() *http.Client {
s.mu.Lock()
defer s.mu.Unlock()
if s.client == nil {
s.client = newXrayMetricsClient()
}
return s.client
}
func (s *XrayMetricsService) State() xrayMetricsState {
s.mu.RLock()
defer s.mu.RUnlock()
return s.state
}
func (s *XrayMetricsService) AggregateMetric(metric string, bucketSeconds, maxPoints int) []map[string]any {
return xrayMetrics.aggregate(metric, bucketSeconds, maxPoints)
}
func (s *XrayMetricsService) ObservatorySnapshot() []ObsTagSnapshot {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]ObsTagSnapshot, 0, len(s.obsByTag))
for _, v := range s.obsByTag {
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return out[i].Tag < out[j].Tag })
return out
}
func (s *XrayMetricsService) HasObservatoryTag(tag string) bool {
if !validObsTag.MatchString(tag) {
return false
}
s.mu.RLock()
defer s.mu.RUnlock()
_, ok := s.obsByTag[tag]
return ok
}
func (s *XrayMetricsService) AggregateObservatory(tag string, bucketSeconds, maxPoints int) []map[string]any {
if !validObsTag.MatchString(tag) {
return []map[string]any{}
}
return xrayMetrics.aggregate(obsHistoryKey(tag), bucketSeconds, maxPoints)
}
func (s *XrayMetricsService) discoverListen() (string, error) {
tmpl, err := s.settingService.GetXrayConfigTemplate()
if err != nil {
return "", err
}
var parsed struct {
Metrics *struct {
Listen string `json:"listen"`
} `json:"metrics"`
}
if err := json.Unmarshal([]byte(tmpl), &parsed); err != nil {
return "", err
}
if parsed.Metrics == nil || strings.TrimSpace(parsed.Metrics.Listen) == "" {
return "", nil
}
return strings.TrimSpace(parsed.Metrics.Listen), nil
}
type rawObsEntry struct {
Alive bool `json:"alive"`
Delay int64 `json:"delay"`
LastSeenTime int64 `json:"last_seen_time"`
LastTryTime int64 `json:"last_try_time"`
OutboundTag string `json:"outbound_tag"`
}
func (s *XrayMetricsService) Sample(t time.Time) {
listen, err := s.discoverListen()
if err != nil {
s.setState(xrayMetricsState{Reason: err.Error()})
return
}
if listen == "" {
s.setState(xrayMetricsState{Reason: "metrics block not configured in xray template"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
defer cancel()
url := fmt.Sprintf("http://%s/debug/vars", listen)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()})
return
}
resp, err := s.getClient().Do(req)
if err != nil {
s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
s.setState(xrayMetricsState{Listen: listen, Reason: fmt.Sprintf("HTTP %d", resp.StatusCode)})
return
}
var payload struct {
MemStats struct {
HeapAlloc uint64 `json:"HeapAlloc"`
Sys uint64 `json:"Sys"`
HeapObjects uint64 `json:"HeapObjects"`
NumGC uint32 `json:"NumGC"`
PauseNs [256]uint64 `json:"PauseNs"`
} `json:"memstats"`
Observatory map[string]rawObsEntry `json:"observatory"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()})
return
}
xrayMetrics.append("xrAlloc", t, float64(payload.MemStats.HeapAlloc))
xrayMetrics.append("xrSys", t, float64(payload.MemStats.Sys))
xrayMetrics.append("xrHeapObjects", t, float64(payload.MemStats.HeapObjects))
xrayMetrics.append("xrNumGC", t, float64(payload.MemStats.NumGC))
var lastPause uint64
if payload.MemStats.NumGC > 0 {
idx := (payload.MemStats.NumGC + 255) % 256
lastPause = payload.MemStats.PauseNs[idx]
}
xrayMetrics.append("xrPauseNs", t, float64(lastPause))
s.applyObservatory(t, payload.Observatory)
s.setState(xrayMetricsState{Enabled: true, Listen: listen})
}
func (s *XrayMetricsService) applyObservatory(t time.Time, entries map[string]rawObsEntry) {
next := make(map[string]ObsTagSnapshot, len(entries))
for key, e := range entries {
tag := e.OutboundTag
if tag == "" {
tag = key
}
if !validObsTag.MatchString(tag) {
continue
}
snap := ObsTagSnapshot{
Tag: tag,
Alive: e.Alive,
Delay: e.Delay,
LastSeenTime: e.LastSeenTime,
LastTryTime: e.LastTryTime,
UpdatedAt: t.Unix(),
}
next[tag] = snap
xrayMetrics.append(obsHistoryKey(tag), t, float64(e.Delay))
}
s.mu.Lock()
for tag := range s.obsByTag {
if _, kept := next[tag]; !kept {
xrayMetrics.drop(obsHistoryKey(tag))
}
}
s.obsByTag = next
s.mu.Unlock()
}
func (s *XrayMetricsService) setState(st xrayMetricsState) {
s.mu.Lock()
s.state = st
s.mu.Unlock()
if !st.Enabled && st.Reason != "" {
logger.Debugf("xray metrics unavailable: %s", st.Reason)
}
}

View file

@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "حصل خطأ أثناء تشغيل Xray", "xrayErrorPopoverTitle": "حصل خطأ أثناء تشغيل Xray",
"operationHours": "مدة التشغيل", "operationHours": "مدة التشغيل",
"systemHistoryTitle": "تاريخ النظام", "systemHistoryTitle": "تاريخ النظام",
"charts": "الرسوم البيانية",
"xrayMetricsTitle": "مقاييس Xray",
"xrayMetricsDisabled": "نقطة نهاية مقاييس Xray غير مهيأة",
"xrayMetricsHint": "أضف كتلة metrics على المستوى الأعلى في إعدادات xray مع tag باسم metrics_out و listen على 127.0.0.1:11111، ثم أعد تشغيل xray.",
"xrayObservatoryEmpty": "لا توجد بيانات Observatory بعد",
"xrayObservatoryHint": "أضف كتلة observatory إلى إعدادات xray مع قائمة وسوم outbound للفحص، ثم أعد تشغيل xray.",
"xrayObservatoryTagPlaceholder": "اختر outbound",
"xrayObservatoryAlive": "نشط",
"xrayObservatoryDead": "غير متصل",
"xrayObservatoryLastSeen": "آخر مشاهدة",
"xrayObservatoryLastTry": "آخر محاولة",
"trendLast2Min": "آخر دقيقتين", "trendLast2Min": "آخر دقيقتين",
"systemLoad": "تحميل النظام", "systemLoad": "تحميل النظام",
"systemLoadDesc": "متوسط تحميل النظام في الدقائق 1, 5, و15", "systemLoadDesc": "متوسط تحميل النظام في الدقائق 1, 5, و15",

View file

@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "An error occurred while running Xray", "xrayErrorPopoverTitle": "An error occurred while running Xray",
"operationHours": "Uptime", "operationHours": "Uptime",
"systemHistoryTitle": "System History", "systemHistoryTitle": "System History",
"charts": "Charts",
"xrayMetricsTitle": "Xray Metrics",
"xrayMetricsDisabled": "Xray metrics endpoint not configured",
"xrayMetricsHint": "Add a top-level metrics block to the xray config with tag metrics_out and listen 127.0.0.1:11111, then restart xray.",
"xrayObservatoryEmpty": "No observatory data yet",
"xrayObservatoryHint": "Add an observatory block to the xray config listing the outbound tags to probe, then restart xray.",
"xrayObservatoryTagPlaceholder": "Select outbound",
"xrayObservatoryAlive": "Alive",
"xrayObservatoryDead": "Down",
"xrayObservatoryLastSeen": "Last seen",
"xrayObservatoryLastTry": "Last try",
"trendLast2Min": "Last 2 minutes", "trendLast2Min": "Last 2 minutes",
"systemLoad": "System Load", "systemLoad": "System Load",
"systemLoadDesc": "System load average for the past 1, 5, and 15 minutes", "systemLoadDesc": "System load average for the past 1, 5, and 15 minutes",

View file

@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "Se produjo un error al ejecutar Xray", "xrayErrorPopoverTitle": "Se produjo un error al ejecutar Xray",
"operationHours": "Tiempo de Funcionamiento", "operationHours": "Tiempo de Funcionamiento",
"systemHistoryTitle": "Historial del Sistema", "systemHistoryTitle": "Historial del Sistema",
"charts": "Gráficos",
"xrayMetricsTitle": "Métricas de Xray",
"xrayMetricsDisabled": "Endpoint de métricas de Xray no configurado",
"xrayMetricsHint": "Añade un bloque metrics de nivel superior a la configuración de xray con tag metrics_out y listen 127.0.0.1:11111, luego reinicia xray.",
"xrayObservatoryEmpty": "Aún no hay datos de Observatory",
"xrayObservatoryHint": "Añade un bloque observatory a la configuración de xray listando los tags de outbound a sondear, luego reinicia xray.",
"xrayObservatoryTagPlaceholder": "Seleccionar outbound",
"xrayObservatoryAlive": "Activo",
"xrayObservatoryDead": "Caído",
"xrayObservatoryLastSeen": "Visto por última vez",
"xrayObservatoryLastTry": "Último intento",
"trendLast2Min": "Últimos 2 minutos", "trendLast2Min": "Últimos 2 minutos",
"systemLoad": "Carga del Sistema", "systemLoad": "Carga del Sistema",
"systemLoadDesc": "promedio de carga del sistema en los últimos 1, 5 y 15 minutos", "systemLoadDesc": "promedio de carga del sistema en los últimos 1, 5 y 15 minutos",

View file

@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "خطا در هنگام اجرای Xray رخ داد", "xrayErrorPopoverTitle": "خطا در هنگام اجرای Xray رخ داد",
"operationHours": "مدت‌کارکرد", "operationHours": "مدت‌کارکرد",
"systemHistoryTitle": "تاریخچه سیستم", "systemHistoryTitle": "تاریخچه سیستم",
"charts": "نمودارها",
"xrayMetricsTitle": "متریک‌های Xray",
"xrayMetricsDisabled": "نقطه پایانی متریک‌های Xray پیکربندی نشده",
"xrayMetricsHint": "یک بلاک metrics در سطح بالای پیکربندی xray با tag برابر metrics_out و listen برابر 127.0.0.1:11111 اضافه کنید، سپس xray را راه‌اندازی مجدد کنید.",
"xrayObservatoryEmpty": "هنوز داده‌ای از Observatory دریافت نشده",
"xrayObservatoryHint": "یک بلاک observatory در پیکربندی xray اضافه کنید و outbound tagهایی که می‌خواهید بررسی شوند را لیست کنید، سپس xray را راه‌اندازی مجدد کنید.",
"xrayObservatoryTagPlaceholder": "انتخاب outbound",
"xrayObservatoryAlive": "فعال",
"xrayObservatoryDead": "غیرفعال",
"xrayObservatoryLastSeen": "آخرین مشاهده",
"xrayObservatoryLastTry": "آخرین تلاش",
"trendLast2Min": "۲ دقیقه اخیر", "trendLast2Min": "۲ دقیقه اخیر",
"systemLoad": "بارسیستم", "systemLoad": "بارسیستم",
"systemLoadDesc": "میانگین بار سیستم برای 1، 5 و 15 دقیقه گذشته", "systemLoadDesc": "میانگین بار سیستم برای 1، 5 و 15 دقیقه گذشته",

View file

@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "Terjadi kesalahan saat menjalankan Xray", "xrayErrorPopoverTitle": "Terjadi kesalahan saat menjalankan Xray",
"operationHours": "Waktu Aktif", "operationHours": "Waktu Aktif",
"systemHistoryTitle": "Riwayat Sistem", "systemHistoryTitle": "Riwayat Sistem",
"charts": "Grafik",
"xrayMetricsTitle": "Metrik Xray",
"xrayMetricsDisabled": "Endpoint metrik Xray belum dikonfigurasi",
"xrayMetricsHint": "Tambahkan blok metrics tingkat atas ke konfigurasi xray dengan tag metrics_out dan listen 127.0.0.1:11111, lalu mulai ulang xray.",
"xrayObservatoryEmpty": "Belum ada data Observatory",
"xrayObservatoryHint": "Tambahkan blok observatory ke konfigurasi xray yang mencantumkan tag outbound untuk diuji, lalu mulai ulang xray.",
"xrayObservatoryTagPlaceholder": "Pilih outbound",
"xrayObservatoryAlive": "Aktif",
"xrayObservatoryDead": "Mati",
"xrayObservatoryLastSeen": "Terakhir terlihat",
"xrayObservatoryLastTry": "Percobaan terakhir",
"trendLast2Min": "2 menit terakhir", "trendLast2Min": "2 menit terakhir",
"systemLoad": "Beban Sistem", "systemLoad": "Beban Sistem",
"systemLoadDesc": "Rata-rata beban sistem selama 1, 5, dan 15 menit terakhir", "systemLoadDesc": "Rata-rata beban sistem selama 1, 5, dan 15 menit terakhir",

View file

@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "Xrayの実行中にエラーが発生しました", "xrayErrorPopoverTitle": "Xrayの実行中にエラーが発生しました",
"operationHours": "システム稼働時間", "operationHours": "システム稼働時間",
"systemHistoryTitle": "システム履歴", "systemHistoryTitle": "システム履歴",
"charts": "チャート",
"xrayMetricsTitle": "Xray メトリクス",
"xrayMetricsDisabled": "Xray メトリクスエンドポイントが設定されていません",
"xrayMetricsHint": "xray 設定にトップレベルの metrics ブロック(tag: metrics_out、listen: 127.0.0.1:11111)を追加し、xray を再起動してください。",
"xrayObservatoryEmpty": "Observatory データはまだありません",
"xrayObservatoryHint": "xray 設定に observatory ブロックを追加し、プローブする outbound タグを列挙してから xray を再起動してください。",
"xrayObservatoryTagPlaceholder": "Outbound を選択",
"xrayObservatoryAlive": "稼働中",
"xrayObservatoryDead": "停止",
"xrayObservatoryLastSeen": "最終確認",
"xrayObservatoryLastTry": "最終試行",
"trendLast2Min": "直近2分", "trendLast2Min": "直近2分",
"systemLoad": "システム負荷", "systemLoad": "システム負荷",
"systemLoadDesc": "過去1、5、15分間のシステム平均負荷", "systemLoadDesc": "過去1、5、15分間のシステム平均負荷",

View file

@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "Ocorreu um erro ao executar o Xray", "xrayErrorPopoverTitle": "Ocorreu um erro ao executar o Xray",
"operationHours": "Tempo de Atividade", "operationHours": "Tempo de Atividade",
"systemHistoryTitle": "Histórico do Sistema", "systemHistoryTitle": "Histórico do Sistema",
"charts": "Gráficos",
"xrayMetricsTitle": "Métricas do Xray",
"xrayMetricsDisabled": "Endpoint de métricas do Xray não configurado",
"xrayMetricsHint": "Adicione um bloco metrics de nível superior à configuração do xray com tag metrics_out e listen 127.0.0.1:11111, depois reinicie o xray.",
"xrayObservatoryEmpty": "Ainda não há dados do Observatory",
"xrayObservatoryHint": "Adicione um bloco observatory à configuração do xray listando as tags de outbound a sondar, depois reinicie o xray.",
"xrayObservatoryTagPlaceholder": "Selecionar outbound",
"xrayObservatoryAlive": "Ativo",
"xrayObservatoryDead": "Inativo",
"xrayObservatoryLastSeen": "Visto pela última vez",
"xrayObservatoryLastTry": "Última tentativa",
"trendLast2Min": "Últimos 2 minutos", "trendLast2Min": "Últimos 2 minutos",
"systemLoad": "Carga do Sistema", "systemLoad": "Carga do Sistema",
"systemLoadDesc": "Média de carga do sistema nos últimos 1, 5 e 15 minutos", "systemLoadDesc": "Média de carga do sistema nos últimos 1, 5 e 15 minutos",

View file

@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "Ошибка при запуске Xray", "xrayErrorPopoverTitle": "Ошибка при запуске Xray",
"operationHours": "Время работы системы", "operationHours": "Время работы системы",
"systemHistoryTitle": "История системы", "systemHistoryTitle": "История системы",
"charts": "Графики",
"xrayMetricsTitle": "Метрики Xray",
"xrayMetricsDisabled": "Конечная точка метрик Xray не настроена",
"xrayMetricsHint": "Добавьте блок metrics верхнего уровня в конфигурацию xray с tag metrics_out и listen 127.0.0.1:11111, затем перезапустите xray.",
"xrayObservatoryEmpty": "Данных Observatory пока нет",
"xrayObservatoryHint": "Добавьте блок observatory в конфигурацию xray с указанием тегов outbound для проверки, затем перезапустите xray.",
"xrayObservatoryTagPlaceholder": "Выберите outbound",
"xrayObservatoryAlive": "Активен",
"xrayObservatoryDead": "Недоступен",
"xrayObservatoryLastSeen": "Последняя активность",
"xrayObservatoryLastTry": "Последняя попытка",
"trendLast2Min": "Последние 2 минуты", "trendLast2Min": "Последние 2 минуты",
"systemLoad": "Нагрузка на систему", "systemLoad": "Нагрузка на систему",
"systemLoadDesc": "Средняя загрузка системы за последние 1, 5 и 15 минут", "systemLoadDesc": "Средняя загрузка системы за последние 1, 5 и 15 минут",

View file

@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "Xray çalıştırılırken bir hata oluştu", "xrayErrorPopoverTitle": "Xray çalıştırılırken bir hata oluştu",
"operationHours": "Çalışma Süresi", "operationHours": "Çalışma Süresi",
"systemHistoryTitle": "Sistem Geçmişi", "systemHistoryTitle": "Sistem Geçmişi",
"charts": "Grafikler",
"xrayMetricsTitle": "Xray Metrikleri",
"xrayMetricsDisabled": "Xray metrik uç noktası yapılandırılmadı",
"xrayMetricsHint": "xray yapılandırmasına tag metrics_out ve listen 127.0.0.1:11111 olan üst düzey bir metrics bloğu ekleyin, sonra xray'i yeniden başlatın.",
"xrayObservatoryEmpty": "Henüz Observatory verisi yok",
"xrayObservatoryHint": "xray yapılandırmasına test edilecek outbound etiketlerini listeleyen bir observatory bloğu ekleyin, sonra xray'i yeniden başlatın.",
"xrayObservatoryTagPlaceholder": "Outbound seç",
"xrayObservatoryAlive": "Aktif",
"xrayObservatoryDead": "Kapalı",
"xrayObservatoryLastSeen": "Son görülme",
"xrayObservatoryLastTry": "Son deneme",
"trendLast2Min": "Son 2 dakika", "trendLast2Min": "Son 2 dakika",
"systemLoad": "Sistem Yükü", "systemLoad": "Sistem Yükü",
"systemLoadDesc": "Geçmiş 1, 5 ve 15 dakika için sistem yük ortalaması", "systemLoadDesc": "Geçmiş 1, 5 ve 15 dakika için sistem yük ortalaması",

View file

@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "Під час роботи Xray сталася помилка", "xrayErrorPopoverTitle": "Під час роботи Xray сталася помилка",
"operationHours": "Час роботи", "operationHours": "Час роботи",
"systemHistoryTitle": "Історія системи", "systemHistoryTitle": "Історія системи",
"charts": "Графіки",
"xrayMetricsTitle": "Метрики Xray",
"xrayMetricsDisabled": "Кінцева точка метрик Xray не налаштована",
"xrayMetricsHint": "Додайте блок metrics верхнього рівня до конфігурації xray з tag metrics_out і listen 127.0.0.1:11111, потім перезапустіть xray.",
"xrayObservatoryEmpty": "Даних Observatory ще немає",
"xrayObservatoryHint": "Додайте блок observatory до конфігурації xray зі списком outbound тегів для перевірки, потім перезапустіть xray.",
"xrayObservatoryTagPlaceholder": "Виберіть outbound",
"xrayObservatoryAlive": "Активний",
"xrayObservatoryDead": "Недоступний",
"xrayObservatoryLastSeen": "Остання активність",
"xrayObservatoryLastTry": "Остання спроба",
"trendLast2Min": "Останні 2 хвилини", "trendLast2Min": "Останні 2 хвилини",
"systemLoad": "Завантаження системи", "systemLoad": "Завантаження системи",
"systemLoadDesc": "Середнє завантаження системи за останні 1, 5 і 15 хвилин", "systemLoadDesc": "Середнє завантаження системи за останні 1, 5 і 15 хвилин",

View file

@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "Đã xảy ra lỗi khi chạy Xray", "xrayErrorPopoverTitle": "Đã xảy ra lỗi khi chạy Xray",
"operationHours": "Thời gian hoạt động", "operationHours": "Thời gian hoạt động",
"systemHistoryTitle": "Lịch sử hệ thống", "systemHistoryTitle": "Lịch sử hệ thống",
"charts": "Biểu đồ",
"xrayMetricsTitle": "Chỉ số Xray",
"xrayMetricsDisabled": "Điểm cuối chỉ số Xray chưa được cấu hình",
"xrayMetricsHint": "Thêm khối metrics cấp cao nhất vào cấu hình xray với tag là metrics_out và listen là 127.0.0.1:11111, sau đó khởi động lại xray.",
"xrayObservatoryEmpty": "Chưa có dữ liệu Observatory",
"xrayObservatoryHint": "Thêm khối observatory vào cấu hình xray liệt kê các tag outbound cần kiểm tra, sau đó khởi động lại xray.",
"xrayObservatoryTagPlaceholder": "Chọn outbound",
"xrayObservatoryAlive": "Hoạt động",
"xrayObservatoryDead": "Ngừng",
"xrayObservatoryLastSeen": "Lần cuối thấy",
"xrayObservatoryLastTry": "Lần thử cuối",
"trendLast2Min": "2 phút gần nhất", "trendLast2Min": "2 phút gần nhất",
"systemLoad": "Tải hệ thống", "systemLoad": "Tải hệ thống",
"systemLoadDesc": "trung bình tải hệ thống trong 1, 5 và 15 phút qua", "systemLoadDesc": "trung bình tải hệ thống trong 1, 5 và 15 phút qua",

View file

@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "运行Xray时发生错误", "xrayErrorPopoverTitle": "运行Xray时发生错误",
"operationHours": "系统正常运行时间", "operationHours": "系统正常运行时间",
"systemHistoryTitle": "系统历史", "systemHistoryTitle": "系统历史",
"charts": "图表",
"xrayMetricsTitle": "Xray 指标",
"xrayMetricsDisabled": "未配置 Xray 指标端点",
"xrayMetricsHint": "在 xray 配置中添加顶级 metrics 块,tag 为 metrics_out,listen 为 127.0.0.1:11111,然后重启 xray。",
"xrayObservatoryEmpty": "暂无 Observatory 数据",
"xrayObservatoryHint": "在 xray 配置中添加 observatory 块,列出要探测的出站 tag,然后重启 xray。",
"xrayObservatoryTagPlaceholder": "选择出站",
"xrayObservatoryAlive": "在线",
"xrayObservatoryDead": "离线",
"xrayObservatoryLastSeen": "最后在线",
"xrayObservatoryLastTry": "最后尝试",
"trendLast2Min": "最近 2 分钟", "trendLast2Min": "最近 2 分钟",
"systemLoad": "系统负载", "systemLoad": "系统负载",
"systemLoadDesc": "过去 1、5 和 15 分钟的系统平均负载", "systemLoadDesc": "过去 1、5 和 15 分钟的系统平均负载",

View file

@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "執行Xray時發生錯誤", "xrayErrorPopoverTitle": "執行Xray時發生錯誤",
"operationHours": "系統正常執行時間", "operationHours": "系統正常執行時間",
"systemHistoryTitle": "系統歷史", "systemHistoryTitle": "系統歷史",
"charts": "圖表",
"xrayMetricsTitle": "Xray 指標",
"xrayMetricsDisabled": "未設定 Xray 指標端點",
"xrayMetricsHint": "在 xray 設定中加入頂層 metrics 區塊,tag 為 metrics_out,listen 為 127.0.0.1:11111,然後重啟 xray。",
"xrayObservatoryEmpty": "尚無 Observatory 資料",
"xrayObservatoryHint": "在 xray 設定中加入 observatory 區塊,列出要探測的出站 tag,然後重啟 xray。",
"xrayObservatoryTagPlaceholder": "選擇出站",
"xrayObservatoryAlive": "在線",
"xrayObservatoryDead": "離線",
"xrayObservatoryLastSeen": "最後在線",
"xrayObservatoryLastTry": "最後嘗試",
"trendLast2Min": "最近 2 分鐘", "trendLast2Min": "最近 2 分鐘",
"systemLoad": "系統負載", "systemLoad": "系統負載",
"systemLoadDesc": "過去 1、5 和 15 分鐘的系統平均負載", "systemLoadDesc": "過去 1、5 和 15 分鐘的系統平均負載",

View file

@ -259,11 +259,13 @@ func (s *Server) initRouter() (*gin.Engine, error) {
// startTask schedules background jobs (Xray checks, traffic jobs, cron // startTask schedules background jobs (Xray checks, traffic jobs, cron
// jobs) which the panel relies on for periodic maintenance and monitoring. // jobs) which the panel relies on for periodic maintenance and monitoring.
func (s *Server) startTask() { func (s *Server) startTask(restartXray bool) {
s.customGeoService.EnsureOnStartup() s.customGeoService.EnsureOnStartup()
err := s.xrayService.RestartXray(true) if restartXray {
if err != nil { err := s.xrayService.RestartXray(true)
logger.Warning("start xray failed:", err) if err != nil {
logger.Warning("start xray failed:", err)
}
} }
// Check whether xray is running every second // Check whether xray is running every second
s.cron.AddJob("@every 1s", job.NewCheckXrayRunningJob()) 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. // Start initializes and starts the web server with configured settings, routes, and background jobs.
func (s *Server) Start() (err error) { 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 // This is an anonymous function, no function name
defer func() { defer func() {
if err != nil { if err != nil {
@ -431,12 +442,14 @@ func (s *Server) Start() (err error) {
s.httpServer.Serve(listener) s.httpServer.Serve(listener)
}() }()
s.startTask() s.startTask(restartXray)
isTgbotenabled, err := s.settingService.GetTgbotEnabled() if startTgBot {
if (err == nil) && (isTgbotenabled) { isTgbotenabled, err := s.settingService.GetTgbotEnabled()
tgBot := s.tgbotService.NewTgbot() if (err == nil) && (isTgbotenabled) {
tgBot.Start(i18nFS) tgBot := s.tgbotService.NewTgbot()
tgBot.Start(i18nFS)
}
} }
return nil return nil
@ -444,13 +457,26 @@ func (s *Server) Start() (err error) {
// Stop gracefully shuts down the web server, stops Xray, cron jobs, and Telegram bot. // Stop gracefully shuts down the web server, stops Xray, cron jobs, and Telegram bot.
func (s *Server) Stop() error { 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.cancel()
s.xrayService.StopXray() if stopXray {
s.xrayService.StopXray()
}
if s.cron != nil { if s.cron != nil {
s.cron.Stop() s.cron.Stop()
} }
service.StopTrafficWriter() if stopXray {
if s.tgbotService.IsRunning() { service.StopTrafficWriter()
}
if stopTgBot && s.tgbotService.IsRunning() {
s.tgbotService.Stop() s.tgbotService.Stop()
} }
// Gracefully stop WebSocket hub // Gracefully stop WebSocket hub