From 8cd97654f2ce158320bee5168f52667d38e28495 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 9 May 2026 16:24:57 +0200 Subject: [PATCH] feat(stats): system history modal + per-node CPU/Mem trends across all locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend - web/service/metric_history.go: generic in-memory ring buffer with two singletons — system-wide (cpu/mem/netUp/netDown/online/load1/5/15) and per-node (cpu/mem) keyed by node id - ServerService.AppendStatusSample writes all 8 metrics every 2s on the same tick; AppendCpuSample/AggregateCpuHistory kept for back-compat - NodeService.UpdateHeartbeat appends cpu/mem only on online ticks so offline gaps render as missing data, not phantom dips - New routes: GET /panel/api/server/history/:metric/:bucket and GET /panel/api/nodes/history/:id/:metric/:bucket, both whitelisted Frontend - Sparkline component generalized: arbitrary value range (auto-scale when valueMax=null), pluggable yFormatter/tooltipFormatter for B/s, client counts, load averages - SystemHistoryModal replaces CpuHistoryModal with tabs for every metric; opened from a tag on the 3X-UI card next to Documentation - NodeHistoryPanel: expandable row on the Nodes table showing per-node CPU and Mem trends, refreshed every 15s Localization - Backfill systemHistoryTitle / trendLast2Min / pages.inbounds.{node, deployTo, localPanel} and the entire pages.nodes block (51 keys including statusValues + toasts) into all 11 non-en/fa locales: ar-EG, es-ES, id-ID, ja-JP, pt-BR, ru-RU, tr-TR, uk-UA, vi-VN, zh-CN, zh-TW Co-Authored-By: Claude Opus 4.7 --- frontend/src/components/Sparkline.vue | 68 ++++++-- frontend/src/pages/index/CpuHistoryModal.vue | 91 ---------- frontend/src/pages/index/IndexPage.vue | 22 ++- frontend/src/pages/index/StatusCard.vue | 16 +- .../src/pages/index/SystemHistoryModal.vue | 160 ++++++++++++++++++ frontend/src/pages/nodes/NodeHistoryPanel.vue | 134 +++++++++++++++ frontend/src/pages/nodes/NodeList.vue | 4 + web/controller/node.go | 27 +++ web/controller/server.go | 51 ++++-- web/service/metric_history.go | 143 ++++++++++++++++ web/service/node.go | 32 +++- web/service/server.go | 109 +++++------- web/translation/ar-EG.json | 61 +++++++ web/translation/en-US.json | 2 + web/translation/es-ES.json | 61 +++++++ web/translation/fa-IR.json | 2 + web/translation/id-ID.json | 61 +++++++ web/translation/ja-JP.json | 61 +++++++ web/translation/pt-BR.json | 61 +++++++ web/translation/ru-RU.json | 61 +++++++ web/translation/tr-TR.json | 61 +++++++ web/translation/uk-UA.json | 61 +++++++ web/translation/vi-VN.json | 61 +++++++ web/translation/zh-CN.json | 61 +++++++ web/translation/zh-TW.json | 61 +++++++ 25 files changed, 1328 insertions(+), 204 deletions(-) delete mode 100644 frontend/src/pages/index/CpuHistoryModal.vue create mode 100644 frontend/src/pages/index/SystemHistoryModal.vue create mode 100644 frontend/src/pages/nodes/NodeHistoryPanel.vue create mode 100644 web/service/metric_history.go diff --git a/frontend/src/components/Sparkline.vue b/frontend/src/components/Sparkline.vue index 050064e2..bb626d62 100644 --- a/frontend/src/components/Sparkline.vue +++ b/frontend/src/components/Sparkline.vue @@ -22,6 +22,16 @@ const props = defineProps({ paddingTop: { type: Number, default: 6 }, paddingBottom: { type: Number, default: 20 }, showTooltip: { type: Boolean, default: false }, + // Value-range customization. When valueMax is null the chart auto-scales + // to the running max of the data (useful for unbounded series like + // network throughput or online clients). Defaults preserve the legacy + // 0..100 percent behavior so existing callers don't need to change. + valueMin: { type: Number, default: 0 }, + valueMax: { type: [Number, null], default: 100 }, + // Y-axis tick formatter. Receives the raw value, returns the label. + // tooltipFormatter formats the hover-readout; falls back to yFormatter. + yFormatter: { type: Function, default: (v) => `${Math.round(v)}%` }, + tooltipFormatter: { type: Function, default: null }, }); const hoverIdx = ref(-1); @@ -44,17 +54,40 @@ const labelsSlice = computed(() => { return props.labels.slice(start); }); +// Resolved domain. When valueMax is null we auto-scale; pad the upper +// bound by 10% so the line never touches the top edge — looks more +// natural and gives the axis a sane ceiling. Floor the dynamic range +// at 1 to avoid divide-by-zero on flat-line data (e.g. all zeros). +const yDomain = computed(() => { + const min = props.valueMin; + if (props.valueMax != null) return { min, max: props.valueMax }; + let max = min; + for (const v of dataSlice.value) { + const n = Number(v); + if (Number.isFinite(n) && n > max) max = n; + } + if (max <= min) max = min + 1; + return { min, max: max * 1.1 }; +}); + +function project(v) { + const { min, max } = yDomain.value; + const span = max - min; + if (span <= 0) return props.paddingTop + drawHeight.value; + const clipped = Math.max(min, Math.min(max, Number(v) || 0)); + const ratio = (clipped - min) / span; + return Math.round(props.paddingTop + (drawHeight.value - ratio * drawHeight.value)); +} + const pointsArr = computed(() => { const n = nPoints.value; if (n === 0) return []; const slice = dataSlice.value; const w = drawWidth.value; - const h = drawHeight.value; const dx = n > 1 ? w / (n - 1) : 0; return slice.map((v, i) => { const x = Math.round(props.paddingLeft + i * dx); - const y = Math.round(props.paddingTop + (h - (Math.max(0, Math.min(100, v)) / 100) * h)); - return [x, y]; + return [x, project(v)]; }); }); @@ -84,13 +117,27 @@ const lastPoint = computed(() => { return pointsArr.value[pointsArr.value.length - 1]; }); +// Y-axis tick rendering. We pick a small number of evenly spaced values +// inside the resolved domain and run them through yFormatter — that's +// what makes "MB/s" / "clients" / "%" all render correctly without the +// caller having to subclass the component. const yTicks = computed(() => { if (!props.showAxes) return []; - const step = Math.max(1, props.yTickStep); + const { min, max } = yDomain.value; const out = []; - for (let p = 0; p <= 100; p += step) { - const y = Math.round(props.paddingTop + (drawHeight.value - (p / 100) * drawHeight.value)); - out.push({ y, label: `${p}%` }); + // For percent-style domains keep the legacy fixed step; otherwise + // default to 4 evenly spaced ticks (5 lines including the bottom). + if (props.valueMax === 100 && props.valueMin === 0 && props.yTickStep > 0) { + for (let p = min; p <= max; p += props.yTickStep) { + const y = project(p); + out.push({ y, label: props.yFormatter(p) }); + } + return out; + } + const ticks = 5; + for (let i = 0; i < ticks; i++) { + const v = min + ((max - min) * i) / (ticks - 1); + out.push({ y: project(v), label: props.yFormatter(v) }); } return out; }); @@ -131,10 +178,11 @@ function onMouseLeave() { function fmtHoverText() { const idx = hoverIdx.value; if (idx < 0 || idx >= dataSlice.value.length) return ''; - const raw = Math.max(0, Math.min(100, Number(dataSlice.value[idx] || 0))); - const val = Number.isFinite(raw) ? raw.toFixed(2) : raw; + const raw = Number(dataSlice.value[idx] || 0); + const fmt = props.tooltipFormatter || props.yFormatter; + const val = fmt(Number.isFinite(raw) ? raw : 0); const lab = labelsSlice.value[idx] != null ? labelsSlice.value[idx] : ''; - return `${val}%${lab ? ' • ' + lab : ''}`; + return `${val}${lab ? ' • ' + lab : ''}`; } // Stable per-instance gradient id so multiple sparklines on a page diff --git a/frontend/src/pages/index/CpuHistoryModal.vue b/frontend/src/pages/index/CpuHistoryModal.vue deleted file mode 100644 index 2bf34be4..00000000 --- a/frontend/src/pages/index/CpuHistoryModal.vue +++ /dev/null @@ -1,91 +0,0 @@ - - - - - diff --git a/frontend/src/pages/index/IndexPage.vue b/frontend/src/pages/index/IndexPage.vue index a6c84779..ad6c4df0 100644 --- a/frontend/src/pages/index/IndexPage.vue +++ b/frontend/src/pages/index/IndexPage.vue @@ -9,6 +9,7 @@ import { CloudUploadOutlined, ArrowUpOutlined, ArrowDownOutlined, + AreaChartOutlined, GlobalOutlined, SwapOutlined, EyeOutlined, @@ -29,7 +30,7 @@ import XrayStatusCard from './XrayStatusCard.vue'; import PanelUpdateModal from './PanelUpdateModal.vue'; import LogModal from './LogModal.vue'; import BackupModal from './BackupModal.vue'; -import CpuHistoryModal from './CpuHistoryModal.vue'; +import SystemHistoryModal from './SystemHistoryModal.vue'; import XrayLogModal from './XrayLogModal.vue'; import VersionModal from './VersionModal.vue'; @@ -69,7 +70,7 @@ const showIp = ref(false); const logsOpen = ref(false); const backupOpen = ref(false); const panelUpdateOpen = ref(false); -const cpuHistoryOpen = ref(false); +const sysHistoryOpen = ref(false); const xrayLogsOpen = ref(false); const versionOpen = ref(false); const configTextOpen = ref(false); @@ -93,7 +94,7 @@ async function restartXray() { await refresh(); } -function openCpuHistory() { cpuHistoryOpen.value = true; } +function openSystemHistory() { sysHistoryOpen.value = true; } function openXrayLogs() { xrayLogsOpen.value = true; } function openVersionSwitch() { versionOpen.value = true; } @@ -124,7 +125,7 @@ async function openConfig() { - + @@ -172,6 +173,10 @@ async function openConfig() { {{ t('pages.index.documentation') }} + + + {{ t('pages.index.systemHistoryTitle') }} + @@ -308,7 +313,7 @@ async function openConfig() { - + import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; -import { AreaChartOutlined, HistoryOutlined } from '@ant-design/icons-vue'; +import { AreaChartOutlined } from '@ant-design/icons-vue'; import { CPUFormatter, SizeFormatter } from '@/utils'; @@ -12,8 +12,6 @@ const props = defineProps({ isMobile: { type: Boolean, default: false }, }); -defineEmits(['open-cpu-history']); - // AD-Vue's default 120px dashboard renders the percent text at ~36px // which dwarfs the rest of the card. 70 (60 on mobile) plus the // :deep(.ant-progress-text) override below keep the gauges compact. @@ -44,14 +42,6 @@ const trailColor = 'rgba(128, 128, 128, 0.25)'; - - - - - - @@ -97,10 +87,6 @@ const trailColor = 'rgba(128, 128, 128, 0.25)'; text-align: center; } -.ml-8 { - margin-left: 8px; -} - /* Pin the percent number to a label-sized 14px — AD-Vue scales it * from the SVG's intrinsic size, so :width alone leaves it too big. */ :deep(.ant-progress-text) { diff --git a/frontend/src/pages/index/SystemHistoryModal.vue b/frontend/src/pages/index/SystemHistoryModal.vue new file mode 100644 index 00000000..aeb86d1c --- /dev/null +++ b/frontend/src/pages/index/SystemHistoryModal.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/frontend/src/pages/nodes/NodeHistoryPanel.vue b/frontend/src/pages/nodes/NodeHistoryPanel.vue new file mode 100644 index 00000000..5a8357f4 --- /dev/null +++ b/frontend/src/pages/nodes/NodeHistoryPanel.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/frontend/src/pages/nodes/NodeList.vue b/frontend/src/pages/nodes/NodeList.vue index e4346206..d785fe20 100644 --- a/frontend/src/pages/nodes/NodeList.vue +++ b/frontend/src/pages/nodes/NodeList.vue @@ -9,6 +9,7 @@ import { ThunderboltOutlined, ExclamationCircleOutlined, } from '@ant-design/icons-vue'; +import NodeHistoryPanel from './NodeHistoryPanel.vue'; const props = defineProps({ nodes: { type: Array, default: () => [] }, @@ -96,6 +97,9 @@ function formatPct(p) { size="middle" row-key="id" > +