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() {
-
-
-
-
- {{ panelUpdateInfo.latestVersion }}
- {{ t('pages.index.updatePanel') }}
-
-
+
+
+
+ @XrayUI
+
+
+
+
+ {{ panelUpdateInfo.updateAvailable
+ ? `${t('update')} ${panelUpdateInfo.latestVersion}`
+ : `v${displayVersion}` }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('pages.index.systemHistoryTitle') }}
+
+
+
+ {{ t('pages.index.xrayMetricsTitle') }}
+
-
-
- Xray: {{ TimeFormatter.formatSecond(status.appStats.uptime) }}
-
- OS: {{ TimeFormatter.formatSecond(status.uptime) }}
-
-
-
-
-
-
-
-
- {{ t('pages.index.systemHistoryTitle') }}
-
-
-
-
- {{ status.loads[0] }} | {{ status.loads[1] }} | {{ status.loads[2] }}
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
- {{ t('pages.index.memory') }}: {{ SizeFormatter.sizeFormat(status.appStats.mem) }}
-
-
- {{ t('pages.index.threads') }}: {{ status.appStats.threads }}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -318,6 +357,7 @@ async function openConfig() {
+
+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();
+});
+
+
+
+
+
+ {{ t('pages.index.xrayMetricsTitle') }}
+
+ 2m
+ 30m
+ 1h
+ 2h
+ 3h
+ 5h
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tg.tag }}
+
+
+
+
+
+ {{ activeObsTag.alive ? t('pages.index.xrayObservatoryAlive') : t('pages.index.xrayObservatoryDead') }}
+
+
{{ activeObsTag.delay }} ms
+
+ {{ t('pages.index.xrayObservatoryLastSeen') }}: {{ fmtTimestamp(activeObsTag.lastSeenTime) }}
+
+
+ {{ t('pages.index.xrayObservatoryLastTry') }}: {{ fmtTimestamp(activeObsTag.lastTryTime) }}
+
+
+
+
+
+
+
+ Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
+ · {{ state.listen }}
+
+
+
+
+
+
+
diff --git a/main.go b/main.go
index 9bb1d0b9..90db08da 100644
--- a/main.go
+++ b/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
diff --git a/web/controller/server.go b/web/controller/server.go
index 441a0d88..4d5aa356 100644
--- a/web/controller/server.go
+++ b/web/controller/server.go
@@ -23,9 +23,10 @@ var filenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`)
type ServerController struct {
BaseController
- serverService service.ServerService
- settingService service.SettingService
- panelService service.PanelService
+ serverService service.ServerService
+ settingService service.SettingService
+ panelService service.PanelService
+ xrayMetricsService service.XrayMetricsService
lastStatus *service.Status
@@ -47,6 +48,10 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/status", a.status)
g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
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("/getPanelUpdateInfo", a.getPanelUpdateInfo)
g.GET("/getConfigJson", a.getConfigJson)
@@ -75,7 +80,9 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
func (a *ServerController) refreshStatus() {
a.lastStatus = a.serverService.GetStatus(a.lastStatus)
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
websocket.BroadcastStatus(a.lastStatus)
}
@@ -143,6 +150,42 @@ func (a *ServerController) getMetricHistoryBucket(c *gin.Context) {
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) {
const cacheTTLSeconds = 15 * 60
diff --git a/web/service/metric_history.go b/web/service/metric_history.go
index f180d3b9..5905b678 100644
--- a/web/service/metric_history.go
+++ b/web/service/metric_history.go
@@ -130,6 +130,7 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
var (
systemMetrics = newMetricHistory()
nodeMetrics = newMetricHistory()
+ xrayMetrics = newMetricHistory()
)
// 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.
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",
+}
diff --git a/web/service/node.go b/web/service/node.go
index 551e6dd1..a2b6d818 100644
--- a/web/service/node.go
+++ b/web/service/node.go
@@ -54,6 +54,20 @@ func (s *NodeService) GetById(id int) (*model.Node, error) {
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 {
n.Name = strings.TrimSpace(n.Name)
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" {
n.Scheme = "https"
}
- if n.BasePath == "" {
- n.BasePath = "/"
- }
- if !strings.HasPrefix(n.BasePath, "/") {
- n.BasePath = "/" + n.BasePath
- }
- if !strings.HasSuffix(n.BasePath, "/") {
- n.BasePath = n.BasePath + "/"
- }
+ n.BasePath = normalizeBasePath(n.BasePath)
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) {
patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()}
- hostPort := net.JoinHostPort(n.Address, strconv.Itoa(n.Port))
- url := fmt.Sprintf("%s://%s%spanel/api/server/status", n.Scheme, hostPort, n.BasePath)
- url, err := SanitizePublicHTTPURL(url, n.AllowPrivateAddress)
- if err != nil {
- patch.LastError = err.Error()
- return patch, err
- }
+ url := fmt.Sprintf("%s://%s:%d%spanel/api/server/status",
+ n.Scheme, n.Address, n.Port, normalizeBasePath(n.BasePath))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
diff --git a/web/service/server.go b/web/service/server.go
index b7615a98..54aef910 100644
--- a/web/service/server.go
+++ b/web/service/server.go
@@ -1333,7 +1333,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
@@ -1343,14 +1349,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
}
}
@@ -1360,9 +1370,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) {
diff --git a/web/service/server_vlessenc_test.go b/web/service/server_vlessenc_test.go
new file mode 100644
index 00000000..2e8b8572
--- /dev/null
+++ b/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"])
+ }
+}
diff --git a/web/service/traffic_writer.go b/web/service/traffic_writer.go
index b15c459a..f7b3fef6 100644
--- a/web/service/traffic_writer.go
+++ b/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()
-
- if queue == nil {
+ ctx := twCtx
+ done := twDone
+ if queue == nil || ctx == nil || done == nil {
+ twMu.Unlock()
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 {
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")
+ }
+ }
}
diff --git a/web/service/traffic_writer_test.go b/web/service/traffic_writer_test.go
new file mode 100644
index 00000000..6ecb5eb9
--- /dev/null
+++ b/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
+ }
+}
diff --git a/web/service/xray_metrics.go b/web/service/xray_metrics.go
new file mode 100644
index 00000000..9eb08039
--- /dev/null
+++ b/web/service/xray_metrics.go
@@ -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)
+ }
+}
diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json
index 47152b57..7ab23c2c 100644
--- a/web/translation/ar-EG.json
+++ b/web/translation/ar-EG.json
@@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "حصل خطأ أثناء تشغيل Xray",
"operationHours": "مدة التشغيل",
"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": "آخر دقيقتين",
"systemLoad": "تحميل النظام",
"systemLoadDesc": "متوسط تحميل النظام في الدقائق 1, 5, و15",
diff --git a/web/translation/en-US.json b/web/translation/en-US.json
index bd1f882f..c8e08798 100644
--- a/web/translation/en-US.json
+++ b/web/translation/en-US.json
@@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "An error occurred while running Xray",
"operationHours": "Uptime",
"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",
"systemLoad": "System Load",
"systemLoadDesc": "System load average for the past 1, 5, and 15 minutes",
diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json
index 4fad3fdc..2e610b2b 100644
--- a/web/translation/es-ES.json
+++ b/web/translation/es-ES.json
@@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "Se produjo un error al ejecutar Xray",
"operationHours": "Tiempo de Funcionamiento",
"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",
"systemLoad": "Carga del Sistema",
"systemLoadDesc": "promedio de carga del sistema en los últimos 1, 5 y 15 minutos",
diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json
index 6b75c54b..fc5ba7cd 100644
--- a/web/translation/fa-IR.json
+++ b/web/translation/fa-IR.json
@@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "خطا در هنگام اجرای Xray رخ داد",
"operationHours": "مدتکارکرد",
"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": "۲ دقیقه اخیر",
"systemLoad": "بارسیستم",
"systemLoadDesc": "میانگین بار سیستم برای 1، 5 و 15 دقیقه گذشته",
diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json
index 4a189655..c651d64f 100644
--- a/web/translation/id-ID.json
+++ b/web/translation/id-ID.json
@@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "Terjadi kesalahan saat menjalankan Xray",
"operationHours": "Waktu Aktif",
"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",
"systemLoad": "Beban Sistem",
"systemLoadDesc": "Rata-rata beban sistem selama 1, 5, dan 15 menit terakhir",
diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json
index 5c2f3013..1daca2e5 100644
--- a/web/translation/ja-JP.json
+++ b/web/translation/ja-JP.json
@@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "Xrayの実行中にエラーが発生しました",
"operationHours": "システム稼働時間",
"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分",
"systemLoad": "システム負荷",
"systemLoadDesc": "過去1、5、15分間のシステム平均負荷",
diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json
index cfec3c58..f639f230 100644
--- a/web/translation/pt-BR.json
+++ b/web/translation/pt-BR.json
@@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "Ocorreu um erro ao executar o Xray",
"operationHours": "Tempo de Atividade",
"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",
"systemLoad": "Carga do Sistema",
"systemLoadDesc": "Média de carga do sistema nos últimos 1, 5 e 15 minutos",
diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json
index efb4ac9f..b2b88cfe 100644
--- a/web/translation/ru-RU.json
+++ b/web/translation/ru-RU.json
@@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "Ошибка при запуске Xray",
"operationHours": "Время работы системы",
"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 минуты",
"systemLoad": "Нагрузка на систему",
"systemLoadDesc": "Средняя загрузка системы за последние 1, 5 и 15 минут",
diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json
index 3c8932c8..05136a79 100644
--- a/web/translation/tr-TR.json
+++ b/web/translation/tr-TR.json
@@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "Xray çalıştırılırken bir hata oluştu",
"operationHours": "Çalışma Süresi",
"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",
"systemLoad": "Sistem Yükü",
"systemLoadDesc": "Geçmiş 1, 5 ve 15 dakika için sistem yük ortalaması",
diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json
index 66839b55..eb451b14 100644
--- a/web/translation/uk-UA.json
+++ b/web/translation/uk-UA.json
@@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "Під час роботи Xray сталася помилка",
"operationHours": "Час роботи",
"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 хвилини",
"systemLoad": "Завантаження системи",
"systemLoadDesc": "Середнє завантаження системи за останні 1, 5 і 15 хвилин",
diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json
index 5353d6cc..8e0ff6e6 100644
--- a/web/translation/vi-VN.json
+++ b/web/translation/vi-VN.json
@@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "Đã xảy ra lỗi khi chạy Xray",
"operationHours": "Thời gian hoạt độ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",
"systemLoad": "Tải hệ thống",
"systemLoadDesc": "trung bình tải hệ thống trong 1, 5 và 15 phút qua",
diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json
index 2c501498..e27c3652 100644
--- a/web/translation/zh-CN.json
+++ b/web/translation/zh-CN.json
@@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "运行Xray时发生错误",
"operationHours": "系统正常运行时间",
"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 分钟",
"systemLoad": "系统负载",
"systemLoadDesc": "过去 1、5 和 15 分钟的系统平均负载",
diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json
index 2e1fe327..78f26dee 100644
--- a/web/translation/zh-TW.json
+++ b/web/translation/zh-TW.json
@@ -143,6 +143,17 @@
"xrayErrorPopoverTitle": "執行Xray時發生錯誤",
"operationHours": "系統正常執行時間",
"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 分鐘",
"systemLoad": "系統負載",
"systemLoadDesc": "過去 1、5 和 15 分鐘的系統平均負載",
diff --git a/web/web.go b/web/web.go
index ecde3a7e..35516e4d 100644
--- a/web/web.go
+++ b/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 {
@@ -431,12 +442,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
@@ -444,13 +457,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