Procházet zdrojové kódy

feat(panel): xray metrics dashboard with observatory probe history

Polls xray's /debug/vars on the 2s status tick, stores memstats and per-outbound observatory delay in the metric history ring buffer, and exposes them through a new XrayMetricsModal opened from the Charts card. Restructures the dashboard to consolidate uptime, usage, version, and Telegram link into stat-style or action-style cards consistent with the existing AntD aesthetic.
MHSanaei před 1 dnem
rodič
revize
355bb4c9c0

+ 92 - 60
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() {
 
               <a-col :xs="24" :lg="12">
                 <a-card title="3X-UI" hoverable>
-                  <template v-if="panelUpdateInfo.updateAvailable" #extra>
-                    <a-tooltip :title="`${t('pages.index.updatePanel')}: ${panelUpdateInfo.latestVersion}`">
-                      <a-tag color="orange" class="update-tag" @click="panelUpdateOpen = true">
-                        <CloudDownloadOutlined />
-                        {{ panelUpdateInfo.latestVersion }}
-                        <span v-if="!isMobile">{{ t('pages.index.updatePanel') }}</span>
-                      </a-tag>
-                    </a-tooltip>
+                  <template #actions>
+                    <a-space class="action" @click="openTelegram">
+                      <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" class="tg-icon"
+                        aria-hidden="true">
+                        <path
+                          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" />
+                      </svg>
+                      <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>
-                  <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-col>
 
               <a-col :xs="24" :lg="12">
-                <a-card :title="t('pages.index.operationHours')" hoverable>
-                  <a-tag :color="status.xray.color">
-                    Xray: {{ TimeFormatter.formatSecond(status.appStats.uptime) }}
-                  </a-tag>
-                  <a-tag color="green">OS: {{ TimeFormatter.formatSecond(status.uptime) }}</a-tag>
+                <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>
                 </a-card>
               </a-col>
 
               <a-col :xs="24" :lg="12">
-                <a-card :title="t('pages.index.systemLoad')" hoverable>
-                  <template #extra>
-                    <a-tag color="blue" class="history-tag" @click="openSystemHistory">
-                      <AreaChartOutlined />
-                      {{ t('pages.index.systemHistoryTitle') }}
-                    </a-tag>
-                  </template>
-                  <a-tooltip :title="t('pages.index.systemLoadDesc')">
-                    <a-tag color="green">
-                      {{ status.loads[0] }} | {{ status.loads[1] }} | {{ status.loads[2] }}
-                    </a-tag>
-                  </a-tooltip>
+                <a-card :title="t('pages.index.operationHours')" hoverable>
+                  <a-row :gutter="isMobile ? [8, 8] : 0">
+                    <a-col :span="12">
+                      <CustomStatistic title="Xray" :value="TimeFormatter.formatSecond(status.appStats.uptime)">
+                        <template #prefix>
+                          <ThunderboltOutlined />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                    <a-col :span="12">
+                      <CustomStatistic title="OS" :value="TimeFormatter.formatSecond(status.uptime)">
+                        <template #prefix>
+                          <DesktopOutlined />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                  </a-row>
                 </a-card>
               </a-col>
 
               <a-col :xs="24" :lg="12">
                 <a-card :title="t('usage')" hoverable>
-                  <a-tag color="green">
-                    {{ t('pages.index.memory') }}: {{ SizeFormatter.sizeFormat(status.appStats.mem) }}
-                  </a-tag>
-                  <a-tag color="green">
-                    {{ t('pages.index.threads') }}: {{ status.appStats.threads }}
-                  </a-tag>
+                  <a-row :gutter="isMobile ? [8, 8] : 0">
+                    <a-col :span="12">
+                      <CustomStatistic :title="t('pages.index.memory')"
+                        :value="SizeFormatter.sizeFormat(status.appStats.mem)">
+                        <template #prefix>
+                          <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-col>
 
@@ -318,6 +357,7 @@ async function openConfig() {
       <LogModal v-model:open="logsOpen" />
       <BackupModal v-model:open="backupOpen" :base-path="basePath" @busy="setBusy" />
       <SystemHistoryModal v-model:open="sysHistoryOpen" :status="status" />
+      <XrayMetricsModal v-model:open="xrayMetricsOpen" />
       <XrayLogModal v-model:open="xrayLogsOpen" />
       <VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
       <TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
@@ -374,12 +414,13 @@ async function openConfig() {
   justify-content: center;
 }
 
-.update-tag {
-  cursor: pointer;
-  margin: 0;
-  display: inline-flex;
-  align-items: center;
-  gap: 4px;
+.action-update {
+  color: #fa8c16;
+  font-weight: 600;
+}
+
+.action-update :deep(.anticon) {
+  color: #fa8c16;
 }
 
 .history-tag {
@@ -390,18 +431,9 @@ async function openConfig() {
   margin-inline-end: 0;
 }
 
-.link-tags {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 6px;
-}
-
-.link-tags a {
-  display: inline-flex;
-}
-
-.link-tags :deep(.ant-tag) {
-  margin-inline-end: 0;
+.tg-icon {
+  display: inline-block;
+  vertical-align: -2px;
 }
 
 .ip-toggle-icon {

+ 347 - 0
frontend/src/pages/index/XrayMetricsModal.vue

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

+ 47 - 4
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
 

+ 9 - 0
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",
+}

+ 224 - 0
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)
+	}
+}

+ 11 - 0
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",

+ 11 - 0
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",

+ 11 - 0
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",

+ 11 - 0
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 دقیقه گذشته",

+ 11 - 0
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",

+ 11 - 0
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分間のシステム平均負荷",

+ 11 - 0
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",

+ 11 - 0
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 минут",

+ 11 - 0
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ı",

+ 11 - 0
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 хвилин",

+ 11 - 0
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",

+ 11 - 0
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 分钟的系统平均负载",

+ 11 - 0
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 分鐘的系統平均負載",