mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
feat(stats): system history modal + per-node CPU/Mem trends across all locales
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 <noreply@anthropic.com>
This commit is contained in:
parent
36114a2fcc
commit
8cd97654f2
25 changed files with 1328 additions and 204 deletions
|
|
@ -22,6 +22,16 @@ const props = defineProps({
|
||||||
paddingTop: { type: Number, default: 6 },
|
paddingTop: { type: Number, default: 6 },
|
||||||
paddingBottom: { type: Number, default: 20 },
|
paddingBottom: { type: Number, default: 20 },
|
||||||
showTooltip: { type: Boolean, default: false },
|
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);
|
const hoverIdx = ref(-1);
|
||||||
|
|
@ -44,17 +54,40 @@ const labelsSlice = computed(() => {
|
||||||
return props.labels.slice(start);
|
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 pointsArr = computed(() => {
|
||||||
const n = nPoints.value;
|
const n = nPoints.value;
|
||||||
if (n === 0) return [];
|
if (n === 0) return [];
|
||||||
const slice = dataSlice.value;
|
const slice = dataSlice.value;
|
||||||
const w = drawWidth.value;
|
const w = drawWidth.value;
|
||||||
const h = drawHeight.value;
|
|
||||||
const dx = n > 1 ? w / (n - 1) : 0;
|
const dx = n > 1 ? w / (n - 1) : 0;
|
||||||
return slice.map((v, i) => {
|
return slice.map((v, i) => {
|
||||||
const x = Math.round(props.paddingLeft + i * dx);
|
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, project(v)];
|
||||||
return [x, y];
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -84,13 +117,27 @@ const lastPoint = computed(() => {
|
||||||
return pointsArr.value[pointsArr.value.length - 1];
|
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(() => {
|
const yTicks = computed(() => {
|
||||||
if (!props.showAxes) return [];
|
if (!props.showAxes) return [];
|
||||||
const step = Math.max(1, props.yTickStep);
|
const { min, max } = yDomain.value;
|
||||||
const out = [];
|
const out = [];
|
||||||
for (let p = 0; p <= 100; p += step) {
|
// For percent-style domains keep the legacy fixed step; otherwise
|
||||||
const y = Math.round(props.paddingTop + (drawHeight.value - (p / 100) * drawHeight.value));
|
// default to 4 evenly spaced ticks (5 lines including the bottom).
|
||||||
out.push({ y, label: `${p}%` });
|
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;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
@ -131,10 +178,11 @@ function onMouseLeave() {
|
||||||
function fmtHoverText() {
|
function fmtHoverText() {
|
||||||
const idx = hoverIdx.value;
|
const idx = hoverIdx.value;
|
||||||
if (idx < 0 || idx >= dataSlice.value.length) return '';
|
if (idx < 0 || idx >= dataSlice.value.length) return '';
|
||||||
const raw = Math.max(0, Math.min(100, Number(dataSlice.value[idx] || 0)));
|
const raw = Number(dataSlice.value[idx] || 0);
|
||||||
const val = Number.isFinite(raw) ? raw.toFixed(2) : raw;
|
const fmt = props.tooltipFormatter || props.yFormatter;
|
||||||
|
const val = fmt(Number.isFinite(raw) ? raw : 0);
|
||||||
const lab = labelsSlice.value[idx] != null ? labelsSlice.value[idx] : '';
|
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
|
// Stable per-instance gradient id so multiple sparklines on a page
|
||||||
|
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { HttpUtil } from '@/utils';
|
|
||||||
import Sparkline from '@/components/Sparkline.vue';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
open: { type: Boolean, default: false },
|
|
||||||
status: { type: Object, required: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:open']);
|
|
||||||
|
|
||||||
// Bucket size in seconds per data point — matches legacy options.
|
|
||||||
const bucket = ref(2);
|
|
||||||
const points = ref([]);
|
|
||||||
const labels = ref([]);
|
|
||||||
|
|
||||||
async function fetchBucket() {
|
|
||||||
try {
|
|
||||||
const msg = await HttpUtil.get(`/panel/api/server/cpuHistory/${bucket.value}`);
|
|
||||||
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(Math.max(0, Math.min(100, p.cpu)));
|
|
||||||
}
|
|
||||||
labels.value = labs;
|
|
||||||
points.value = vals;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch bucketed cpu history', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:open', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.open, (next) => { if (next) fetchBucket(); });
|
|
||||||
watch(bucket, () => { if (props.open) fetchBucket(); });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<a-modal :open="open" :closable="true" :footer="null" width="900px" @cancel="close">
|
|
||||||
<template #title>
|
|
||||||
{{ t('pages.index.cpu') }}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="cpu-chart-wrap">
|
|
||||||
<div class="cpu-chart-meta">
|
|
||||||
Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
|
|
||||||
</div>
|
|
||||||
<Sparkline :data="points" :labels="labels" :vb-width="840" :height="220" :stroke="status?.cpu?.color || '#008771'"
|
|
||||||
: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" />
|
|
||||||
</div>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.bucket-select {
|
|
||||||
width: 80px;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cpu-chart-wrap {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cpu-chart-meta {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
CloudUploadOutlined,
|
CloudUploadOutlined,
|
||||||
ArrowUpOutlined,
|
ArrowUpOutlined,
|
||||||
ArrowDownOutlined,
|
ArrowDownOutlined,
|
||||||
|
AreaChartOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
SwapOutlined,
|
SwapOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
|
|
@ -29,7 +30,7 @@ import XrayStatusCard from './XrayStatusCard.vue';
|
||||||
import PanelUpdateModal from './PanelUpdateModal.vue';
|
import PanelUpdateModal from './PanelUpdateModal.vue';
|
||||||
import LogModal from './LogModal.vue';
|
import LogModal from './LogModal.vue';
|
||||||
import BackupModal from './BackupModal.vue';
|
import BackupModal from './BackupModal.vue';
|
||||||
import CpuHistoryModal from './CpuHistoryModal.vue';
|
import SystemHistoryModal from './SystemHistoryModal.vue';
|
||||||
import XrayLogModal from './XrayLogModal.vue';
|
import XrayLogModal from './XrayLogModal.vue';
|
||||||
import VersionModal from './VersionModal.vue';
|
import VersionModal from './VersionModal.vue';
|
||||||
|
|
||||||
|
|
@ -69,7 +70,7 @@ const showIp = ref(false);
|
||||||
const logsOpen = ref(false);
|
const logsOpen = ref(false);
|
||||||
const backupOpen = ref(false);
|
const backupOpen = ref(false);
|
||||||
const panelUpdateOpen = ref(false);
|
const panelUpdateOpen = ref(false);
|
||||||
const cpuHistoryOpen = ref(false);
|
const sysHistoryOpen = ref(false);
|
||||||
const xrayLogsOpen = ref(false);
|
const xrayLogsOpen = ref(false);
|
||||||
const versionOpen = ref(false);
|
const versionOpen = ref(false);
|
||||||
const configTextOpen = ref(false);
|
const configTextOpen = ref(false);
|
||||||
|
|
@ -93,7 +94,7 @@ async function restartXray() {
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCpuHistory() { cpuHistoryOpen.value = true; }
|
function openSystemHistory() { sysHistoryOpen.value = true; }
|
||||||
function openXrayLogs() { xrayLogsOpen.value = true; }
|
function openXrayLogs() { xrayLogsOpen.value = true; }
|
||||||
function openVersionSwitch() { versionOpen.value = true; }
|
function openVersionSwitch() { versionOpen.value = true; }
|
||||||
|
|
||||||
|
|
@ -124,7 +125,7 @@ async function openConfig() {
|
||||||
|
|
||||||
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
|
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
|
||||||
<a-col :span="24">
|
<a-col :span="24">
|
||||||
<StatusCard :status="status" :is-mobile="isMobile" @open-cpu-history="openCpuHistory" />
|
<StatusCard :status="status" :is-mobile="isMobile" />
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :sm="24" :lg="12">
|
<a-col :sm="24" :lg="12">
|
||||||
|
|
@ -172,6 +173,10 @@ async function openConfig() {
|
||||||
<a href="https://github.com/MHSanaei/3x-ui/wiki" target="_blank" rel="noopener noreferrer">
|
<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-tag color="purple">{{ t('pages.index.documentation') }}</a-tag>
|
||||||
</a>
|
</a>
|
||||||
|
<a-tag color="blue" class="history-tag" @click="openSystemHistory">
|
||||||
|
<AreaChartOutlined />
|
||||||
|
{{ t('pages.index.systemHistoryTitle') }}
|
||||||
|
</a-tag>
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
|
|
@ -308,7 +313,7 @@ async function openConfig() {
|
||||||
<PanelUpdateModal v-model:open="panelUpdateOpen" :info="panelUpdateInfo" @busy="setBusy" />
|
<PanelUpdateModal v-model:open="panelUpdateOpen" :info="panelUpdateInfo" @busy="setBusy" />
|
||||||
<LogModal v-model:open="logsOpen" />
|
<LogModal v-model:open="logsOpen" />
|
||||||
<BackupModal v-model:open="backupOpen" :base-path="basePath" @busy="setBusy" />
|
<BackupModal v-model:open="backupOpen" :base-path="basePath" @busy="setBusy" />
|
||||||
<CpuHistoryModal v-model:open="cpuHistoryOpen" :status="status" />
|
<SystemHistoryModal v-model:open="sysHistoryOpen" :status="status" />
|
||||||
<XrayLogModal v-model:open="xrayLogsOpen" />
|
<XrayLogModal v-model:open="xrayLogsOpen" />
|
||||||
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
|
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
|
||||||
<TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
|
<TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
|
||||||
|
|
@ -366,6 +371,13 @@ async function openConfig() {
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-tag {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.ip-toggle-icon {
|
.ip-toggle-icon {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
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';
|
import { CPUFormatter, SizeFormatter } from '@/utils';
|
||||||
|
|
||||||
|
|
@ -12,8 +12,6 @@ const props = defineProps({
|
||||||
isMobile: { type: Boolean, default: false },
|
isMobile: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits(['open-cpu-history']);
|
|
||||||
|
|
||||||
// AD-Vue's default 120px dashboard renders the percent text at ~36px
|
// 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
|
// which dwarfs the rest of the card. 70 (60 on mobile) plus the
|
||||||
// :deep(.ant-progress-text) override below keep the gauges compact.
|
// :deep(.ant-progress-text) override below keep the gauges compact.
|
||||||
|
|
@ -44,14 +42,6 @@ const trailColor = 'rgba(128, 128, 128, 0.25)';
|
||||||
</template>
|
</template>
|
||||||
<AreaChartOutlined />
|
<AreaChartOutlined />
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-tooltip>
|
|
||||||
<template #title>{{ t('pages.index.cpu') }}</template>
|
|
||||||
<a-button size="small" shape="circle" class="ml-8" @click="$emit('open-cpu-history')">
|
|
||||||
<template #icon>
|
|
||||||
<HistoryOutlined />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</a-tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
|
|
@ -97,10 +87,6 @@ const trailColor = 'rgba(128, 128, 128, 0.25)';
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ml-8 {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pin the percent number to a label-sized 14px — AD-Vue scales it
|
/* 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. */
|
* from the SVG's intrinsic size, so :width alone leaves it too big. */
|
||||||
:deep(.ant-progress-text) {
|
:deep(.ant-progress-text) {
|
||||||
|
|
|
||||||
160
frontend/src/pages/index/SystemHistoryModal.vue
Normal file
160
frontend/src/pages/index/SystemHistoryModal.vue
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { HttpUtil, SizeFormatter } from '@/utils';
|
||||||
|
import Sparkline from '@/components/Sparkline.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
status: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open']);
|
||||||
|
|
||||||
|
// One tab per system metric. The order here drives the tab order in
|
||||||
|
// the UI; everything else (axis label, tooltip unit, fetch URL) is
|
||||||
|
// looked up from the active key. Adding another metric is one row.
|
||||||
|
const metrics = [
|
||||||
|
{ key: 'cpu', tab: 'CPU', valueMax: 100, unit: '%', stroke: '' },
|
||||||
|
{ key: 'mem', tab: 'RAM', valueMax: 100, unit: '%', stroke: '#7c4dff' },
|
||||||
|
{ key: 'netUp', tab: 'Net Up', valueMax: null, unit: 'B/s', stroke: '#1890ff' },
|
||||||
|
{ key: 'netDown', tab: 'Net Down', valueMax: null, unit: 'B/s', stroke: '#13c2c2' },
|
||||||
|
{ key: 'online', tab: 'Online', valueMax: null, unit: '', stroke: '#52c41a' },
|
||||||
|
{ key: 'load1', tab: 'Load 1m', valueMax: null, unit: '', stroke: '#fa8c16' },
|
||||||
|
{ key: 'load5', tab: 'Load 5m', valueMax: null, unit: '', stroke: '#f5222d' },
|
||||||
|
{ key: 'load15', tab: 'Load 15m', valueMax: null, unit: '', stroke: '#a0d911' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeKey = ref('cpu');
|
||||||
|
const bucket = ref(2);
|
||||||
|
const points = ref([]);
|
||||||
|
const labels = ref([]);
|
||||||
|
|
||||||
|
const activeMetric = computed(() => metrics.find((m) => m.key === activeKey.value));
|
||||||
|
|
||||||
|
// CPU keeps using the status-card color so the modal visually echoes
|
||||||
|
// the dot in StatusCard. Non-CPU tabs each get their own constant color.
|
||||||
|
const strokeColor = computed(() => {
|
||||||
|
const m = activeMetric.value;
|
||||||
|
if (m?.stroke) return m.stroke;
|
||||||
|
return props.status?.cpu?.color || '#008771';
|
||||||
|
});
|
||||||
|
|
||||||
|
function unitFormatter(unit) {
|
||||||
|
if (unit === 'B/s') {
|
||||||
|
return (v) => `${SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0))}/s`;
|
||||||
|
}
|
||||||
|
if (unit === '%') {
|
||||||
|
return (v) => `${Number(v).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
// Plain numbers: load averages get two decimals, online client count
|
||||||
|
// is integer. Heuristic on the unit-less metric key is good enough.
|
||||||
|
return (v) => {
|
||||||
|
const n = Number(v) || 0;
|
||||||
|
if (activeKey.value === 'online') return String(Math.round(n));
|
||||||
|
return n.toFixed(2);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const yFormatter = computed(() => unitFormatter(activeMetric.value?.unit ?? ''));
|
||||||
|
|
||||||
|
async function fetchBucket() {
|
||||||
|
const m = activeMetric.value;
|
||||||
|
if (!m) return;
|
||||||
|
try {
|
||||||
|
const url = `/panel/api/server/history/${m.key}/${bucket.value}`;
|
||||||
|
const msg = await HttpUtil.get(url);
|
||||||
|
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 = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch history bucket', e);
|
||||||
|
labels.value = [];
|
||||||
|
points.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (next) {
|
||||||
|
activeKey.value = 'cpu';
|
||||||
|
fetchBucket();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
watch([activeKey, bucket], () => {
|
||||||
|
if (props.open) fetchBucket();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :closable="true" :footer="null" width="900px" @cancel="close">
|
||||||
|
<template #title>
|
||||||
|
{{ t('pages.index.systemHistoryTitle') }}
|
||||||
|
<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-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 class="cpu-chart-wrap">
|
||||||
|
<div class="cpu-chart-meta">
|
||||||
|
Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
|
||||||
|
</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="activeMetric?.valueMax ?? null"
|
||||||
|
:y-formatter="yFormatter" />
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bucket-select {
|
||||||
|
width: 80px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tabs {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpu-chart-wrap {
|
||||||
|
padding: 8px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpu-chart-meta {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
134
frontend/src/pages/nodes/NodeHistoryPanel.vue
Normal file
134
frontend/src/pages/nodes/NodeHistoryPanel.vue
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
<script setup>
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import Sparkline from '@/components/Sparkline.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
node: { type: Object, required: true },
|
||||||
|
// Bucket size in seconds — matches the SystemHistoryModal selector.
|
||||||
|
bucket: { type: Number, default: 30 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two parallel series so the panel renders CPU and Mem side-by-side
|
||||||
|
// in a single fetch round-trip per refresh.
|
||||||
|
const cpuPoints = ref([]);
|
||||||
|
const cpuLabels = ref([]);
|
||||||
|
const memPoints = ref([]);
|
||||||
|
const memLabels = ref([]);
|
||||||
|
|
||||||
|
const REFRESH_MS = 15000;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function bucketLabel(unixSec) {
|
||||||
|
const d = new Date(unixSec * 1000);
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
if (props.bucket >= 60) return `${hh}:${mm}`;
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSeries(metric) {
|
||||||
|
try {
|
||||||
|
const url = `/panel/api/nodes/history/${props.node.id}/${metric}/${props.bucket}`;
|
||||||
|
const msg = await HttpUtil.get(url);
|
||||||
|
if (msg?.success && Array.isArray(msg.obj)) {
|
||||||
|
const vals = [];
|
||||||
|
const labs = [];
|
||||||
|
for (const p of msg.obj) {
|
||||||
|
labs.push(bucketLabel(p.t));
|
||||||
|
vals.push(Math.max(0, Math.min(100, Number(p.v) || 0)));
|
||||||
|
}
|
||||||
|
return { vals, labs };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('node history fetch failed', metric, e);
|
||||||
|
}
|
||||||
|
return { vals: [], labs: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const [cpu, mem] = await Promise.all([fetchSeries('cpu'), fetchSeries('mem')]);
|
||||||
|
cpuPoints.value = cpu.vals;
|
||||||
|
cpuLabels.value = cpu.labs;
|
||||||
|
memPoints.value = mem.vals;
|
||||||
|
memLabels.value = mem.labs;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refresh();
|
||||||
|
timer = window.setInterval(refresh, REFRESH_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer != null) window.clearInterval(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the parent table re-emits a node row with a different id (rare —
|
||||||
|
// happens when the list is sorted or filtered while the panel is open),
|
||||||
|
// reset and re-fetch.
|
||||||
|
watch(() => props.node?.id, (a, b) => {
|
||||||
|
if (a !== b) refresh();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="node-history-panel">
|
||||||
|
<div class="series">
|
||||||
|
<div class="series-title">{{ t('pages.nodes.cpu') }}</div>
|
||||||
|
<Sparkline
|
||||||
|
:data="cpuPoints"
|
||||||
|
:labels="cpuLabels"
|
||||||
|
:vb-width="640" :height="120"
|
||||||
|
stroke="#008771"
|
||||||
|
:show-grid="true" :show-axes="true"
|
||||||
|
:tick-count-x="4"
|
||||||
|
:max-points="cpuPoints.length || 1"
|
||||||
|
:fill-opacity="0.18"
|
||||||
|
:marker-radius="2.6"
|
||||||
|
:show-tooltip="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="series">
|
||||||
|
<div class="series-title">{{ t('pages.nodes.mem') }}</div>
|
||||||
|
<Sparkline
|
||||||
|
:data="memPoints"
|
||||||
|
:labels="memLabels"
|
||||||
|
:vb-width="640" :height="120"
|
||||||
|
stroke="#7c4dff"
|
||||||
|
:show-grid="true" :show-axes="true"
|
||||||
|
:tick-count-x="4"
|
||||||
|
:max-points="memPoints.length || 1"
|
||||||
|
:fill-opacity="0.18"
|
||||||
|
:marker-radius="2.6"
|
||||||
|
:show-tooltip="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.node-history-panel {
|
||||||
|
padding: 8px 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.node-history-panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
|
import NodeHistoryPanel from './NodeHistoryPanel.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
nodes: { type: Array, default: () => [] },
|
nodes: { type: Array, default: () => [] },
|
||||||
|
|
@ -96,6 +97,9 @@ function formatPct(p) {
|
||||||
size="middle"
|
size="middle"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
>
|
>
|
||||||
|
<template #expandedRowRender="{ record }">
|
||||||
|
<NodeHistoryPanel :node="record" />
|
||||||
|
</template>
|
||||||
<a-table-column :title="t('pages.nodes.name')" data-index="name" :ellipsis="true">
|
<a-table-column :title="t('pages.nodes.name')" data-index="name" :ellipsis="true">
|
||||||
<template #default="{ record }">
|
<template #default="{ record }">
|
||||||
<div class="name-cell">
|
<div class="name-cell">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -41,6 +43,9 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
|
||||||
// /probe/:id triggers a synchronous probe of an already-saved node
|
// /probe/:id triggers a synchronous probe of an already-saved node
|
||||||
// without waiting for the next 10s heartbeat tick.
|
// without waiting for the next 10s heartbeat tick.
|
||||||
g.POST("/probe/:id", a.probe)
|
g.POST("/probe/:id", a.probe)
|
||||||
|
// /history/:id/:metric/:bucket returns up to 60 averaged buckets of
|
||||||
|
// the per-node CPU or Mem time series collected by the heartbeat job.
|
||||||
|
g.GET("/history/:id/:metric/:bucket", a.history)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *NodeController) list(c *gin.Context) {
|
func (a *NodeController) list(c *gin.Context) {
|
||||||
|
|
@ -182,3 +187,25 @@ func (a *NodeController) probe(c *gin.Context) {
|
||||||
_ = a.nodeService.UpdateHeartbeat(id, patch)
|
_ = a.nodeService.UpdateHeartbeat(id, patch)
|
||||||
jsonObj(c, patch.ToUI(probeErr == nil), nil)
|
jsonObj(c, patch.ToUI(probeErr == nil), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// history returns averaged buckets of the per-node CPU/Mem time-series.
|
||||||
|
// Mirrors the system-level /panel/api/server/history/:metric/:bucket
|
||||||
|
// endpoint so the frontend can reuse the same fetch logic.
|
||||||
|
func (a *NodeController) history(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metric := c.Param("metric")
|
||||||
|
if !slices.Contains(service.NodeMetricKeys, 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.nodeService.AggregateNodeMetric(id, metric, bucket, 60), nil)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -45,6 +46,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||||
|
|
||||||
g.GET("/status", a.status)
|
g.GET("/status", a.status)
|
||||||
g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
|
g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
|
||||||
|
g.GET("/history/:metric/:bucket", a.getMetricHistoryBucket)
|
||||||
g.GET("/getXrayVersion", a.getXrayVersion)
|
g.GET("/getXrayVersion", a.getXrayVersion)
|
||||||
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
||||||
g.GET("/getConfigJson", a.getConfigJson)
|
g.GET("/getConfigJson", a.getConfigJson)
|
||||||
|
|
@ -67,12 +69,13 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||||
g.POST("/getNewEchCert", a.getNewEchCert)
|
g.POST("/getNewEchCert", a.getNewEchCert)
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshStatus updates the cached server status and collects CPU history.
|
// refreshStatus updates the cached server status and collects time-series
|
||||||
|
// metrics. CPU/Mem/Net/Online/Load are all written in one call so the
|
||||||
|
// SystemHistoryModal's tabs share an identical x-axis.
|
||||||
func (a *ServerController) refreshStatus() {
|
func (a *ServerController) refreshStatus() {
|
||||||
a.lastStatus = a.serverService.GetStatus(a.lastStatus)
|
a.lastStatus = a.serverService.GetStatus(a.lastStatus)
|
||||||
// collect cpu history when status is fresh
|
|
||||||
if a.lastStatus != nil {
|
if a.lastStatus != nil {
|
||||||
a.serverService.AppendCpuSample(time.Now(), a.lastStatus.Cpu)
|
a.serverService.AppendStatusSample(time.Now(), a.lastStatus)
|
||||||
// Broadcast status update via WebSocket
|
// Broadcast status update via WebSocket
|
||||||
websocket.BroadcastStatus(a.lastStatus)
|
websocket.BroadcastStatus(a.lastStatus)
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +95,22 @@ func (a *ServerController) startTask() {
|
||||||
// status returns the current server status information.
|
// status returns the current server status information.
|
||||||
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) }
|
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) }
|
||||||
|
|
||||||
|
// allowedHistoryBuckets is the bucket-second whitelist shared by both
|
||||||
|
// /cpuHistory/:bucket and /history/:metric/:bucket. Restricting it
|
||||||
|
// prevents callers from triggering arbitrary aggregation work and keeps
|
||||||
|
// the front-end's bucket selector self-documenting.
|
||||||
|
var allowedHistoryBuckets = map[int]bool{
|
||||||
|
2: true, // Real-time view
|
||||||
|
30: true, // 30s intervals
|
||||||
|
60: true, // 1m intervals
|
||||||
|
120: true, // 2m intervals
|
||||||
|
180: true, // 3m intervals
|
||||||
|
300: true, // 5m intervals
|
||||||
|
}
|
||||||
|
|
||||||
// getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
|
// getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
|
||||||
|
// Kept for back-compat; new callers should use /history/cpu/:bucket which
|
||||||
|
// returns {"t","v"} (uniform across all metrics) instead of {"t","cpu"}.
|
||||||
func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
|
func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
|
||||||
bucketStr := c.Param("bucket")
|
bucketStr := c.Param("bucket")
|
||||||
bucket, err := strconv.Atoi(bucketStr)
|
bucket, err := strconv.Atoi(bucketStr)
|
||||||
|
|
@ -100,15 +118,7 @@ func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
|
||||||
jsonMsg(c, "invalid bucket", fmt.Errorf("bad bucket"))
|
jsonMsg(c, "invalid bucket", fmt.Errorf("bad bucket"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
allowed := map[int]bool{
|
if !allowedHistoryBuckets[bucket] {
|
||||||
2: true, // Real-time view
|
|
||||||
30: true, // 30s intervals
|
|
||||||
60: true, // 1m intervals
|
|
||||||
120: true, // 2m intervals
|
|
||||||
180: true, // 3m intervals
|
|
||||||
300: true, // 5m intervals
|
|
||||||
}
|
|
||||||
if !allowed[bucket] {
|
|
||||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -116,6 +126,23 @@ func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
|
||||||
jsonObj(c, points, nil)
|
jsonObj(c, points, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getMetricHistoryBucket returns up to 60 buckets of history for a single
|
||||||
|
// system metric (cpu, mem, netUp, netDown, online, load1/5/15). The
|
||||||
|
// SystemHistoryModal calls one endpoint per active tab.
|
||||||
|
func (a *ServerController) getMetricHistoryBucket(c *gin.Context) {
|
||||||
|
metric := c.Param("metric")
|
||||||
|
if !slices.Contains(service.SystemMetricKeys, 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.serverService.AggregateSystemMetric(metric, bucket, 60), nil)
|
||||||
|
}
|
||||||
|
|
||||||
// getXrayVersion retrieves available Xray versions, with caching for 1 minute.
|
// getXrayVersion retrieves available Xray versions, with caching for 1 minute.
|
||||||
func (a *ServerController) getXrayVersion(c *gin.Context) {
|
func (a *ServerController) getXrayVersion(c *gin.Context) {
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
|
|
|
||||||
143
web/service/metric_history.go
Normal file
143
web/service/metric_history.go
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MetricSample is one point of any time-series we keep in memory.
|
||||||
|
// The frontend deserializes both keys, so they must stay short.
|
||||||
|
type MetricSample struct {
|
||||||
|
T int64 `json:"t"`
|
||||||
|
V float64 `json:"v"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// metricCapacityDefault caps each ring buffer at ~5h worth of @2s samples
|
||||||
|
// or ~25h worth of @10s samples. Plenty for the bucketed aggregation
|
||||||
|
// view and small enough that the working set per metric stays under
|
||||||
|
// ~150 KiB.
|
||||||
|
const metricCapacityDefault = 9000
|
||||||
|
|
||||||
|
// metricHistory is a thread-safe, in-memory ring buffer keyed by
|
||||||
|
// arbitrary strings. Two singletons live below: one for system-wide
|
||||||
|
// host metrics, one for per-node metrics. Keeping them in this file
|
||||||
|
// (rather than scattered across services) makes the storage model
|
||||||
|
// easy to reason about and avoids double-locking.
|
||||||
|
type metricHistory struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
metrics map[string][]MetricSample
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMetricHistory() *metricHistory {
|
||||||
|
return &metricHistory{metrics: map[string][]MetricSample{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// append stores a single sample for the given metric, deduping when
|
||||||
|
// two appends happen within the same wall-clock second (which can
|
||||||
|
// happen if the cron tick is faster than the metric's natural rate).
|
||||||
|
func (h *metricHistory) append(metric string, t time.Time, v float64) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
buf := h.metrics[metric]
|
||||||
|
p := MetricSample{T: t.Unix(), V: v}
|
||||||
|
if n := len(buf); n > 0 && buf[n-1].T == p.T {
|
||||||
|
buf[n-1] = p
|
||||||
|
} else {
|
||||||
|
buf = append(buf, p)
|
||||||
|
}
|
||||||
|
if len(buf) > metricCapacityDefault {
|
||||||
|
buf = buf[len(buf)-metricCapacityDefault:]
|
||||||
|
}
|
||||||
|
h.metrics[metric] = buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// drop removes the entire history for one metric. Used when a node is
|
||||||
|
// deleted so its old samples don't linger forever in the singleton.
|
||||||
|
func (h *metricHistory) drop(metric string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.metrics, metric)
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregate returns up to maxPoints buckets of size bucketSeconds,
|
||||||
|
// each bucket carrying the arithmetic mean of the underlying samples.
|
||||||
|
// Bucket alignment is to absolute Unix-second boundaries so two
|
||||||
|
// concurrent calls (e.g. two browser tabs) see identical x-axes.
|
||||||
|
func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints int) []map[string]any {
|
||||||
|
if bucketSeconds <= 0 || maxPoints <= 0 {
|
||||||
|
return []map[string]any{}
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-time.Duration(bucketSeconds*maxPoints) * time.Second).Unix()
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
hist := h.metrics[metric]
|
||||||
|
startIdx := 0
|
||||||
|
for i := len(hist) - 1; i >= 0; i-- {
|
||||||
|
if hist[i].T < cutoff {
|
||||||
|
startIdx = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if startIdx >= len(hist) {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return []map[string]any{}
|
||||||
|
}
|
||||||
|
tmp := make([]MetricSample, len(hist)-startIdx)
|
||||||
|
copy(tmp, hist[startIdx:])
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
if len(tmp) == 0 {
|
||||||
|
return []map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
bSize := int64(bucketSeconds)
|
||||||
|
curBucket := (tmp[0].T / bSize) * bSize
|
||||||
|
var out []map[string]any
|
||||||
|
var acc []float64
|
||||||
|
flush := func(ts int64) {
|
||||||
|
if len(acc) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sum := 0.0
|
||||||
|
for _, v := range acc {
|
||||||
|
sum += v
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{"t": ts, "v": sum / float64(len(acc))})
|
||||||
|
acc = acc[:0]
|
||||||
|
}
|
||||||
|
for _, p := range tmp {
|
||||||
|
b := (p.T / bSize) * bSize
|
||||||
|
if b != curBucket {
|
||||||
|
flush(curBucket)
|
||||||
|
curBucket = b
|
||||||
|
}
|
||||||
|
acc = append(acc, p.V)
|
||||||
|
}
|
||||||
|
flush(curBucket)
|
||||||
|
if len(out) > maxPoints {
|
||||||
|
out = out[len(out)-maxPoints:]
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
return []map[string]any{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemMetrics holds whole-host time series (cpu, mem, netUp, etc.)
|
||||||
|
// fed by ServerController.refreshStatus every 2s. nodeMetrics holds
|
||||||
|
// per-node CPU/Mem fed by NodeHeartbeatJob every 10s. Both are
|
||||||
|
// process-local — survival across panel restart is not required.
|
||||||
|
var (
|
||||||
|
systemMetrics = newMetricHistory()
|
||||||
|
nodeMetrics = newMetricHistory()
|
||||||
|
)
|
||||||
|
|
||||||
|
// SystemMetricKeys lists the metric names ServerService writes on every
|
||||||
|
// status sample. Exposed for documentation/test purposes; the
|
||||||
|
// controller validates incoming names against an allow-list.
|
||||||
|
var SystemMetricKeys = []string{
|
||||||
|
"cpu", "mem", "netUp", "netDown", "online", "load1", "load5", "load15",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes.
|
||||||
|
var NodeMetricKeys = []string{"cpu", "mem"}
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -140,6 +141,10 @@ func (s *NodeService) Delete(id int) error {
|
||||||
if mgr := runtime.GetManager(); mgr != nil {
|
if mgr := runtime.GetManager(); mgr != nil {
|
||||||
mgr.InvalidateNode(id)
|
mgr.InvalidateNode(id)
|
||||||
}
|
}
|
||||||
|
// Drop in-memory series so a freshly created node with the same id
|
||||||
|
// doesn't inherit stale points (sqlite reuses ids freely).
|
||||||
|
nodeMetrics.drop(nodeMetricKey(id, "cpu"))
|
||||||
|
nodeMetrics.drop(nodeMetricKey(id, "mem"))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,7 +168,32 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
|
||||||
"uptime_secs": p.UptimeSecs,
|
"uptime_secs": p.UptimeSecs,
|
||||||
"last_error": p.LastError,
|
"last_error": p.LastError,
|
||||||
}
|
}
|
||||||
return db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error
|
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Only record online ticks. Offline probes carry zeroed cpu/mem and
|
||||||
|
// would draw a misleading dip on the chart; the gap on the x-axis is
|
||||||
|
// the truthful representation of "we couldn't reach the node".
|
||||||
|
if p.Status == "online" {
|
||||||
|
now := time.Unix(p.LastHeartbeat, 0)
|
||||||
|
nodeMetrics.append(nodeMetricKey(id, "cpu"), now, p.CpuPct)
|
||||||
|
nodeMetrics.append(nodeMetricKey(id, "mem"), now, p.MemPct)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodeMetricKey is the namespacing used inside the singleton ring buffer
|
||||||
|
// so per-node metrics don't collide with each other or with the system
|
||||||
|
// metrics in the sibling singleton.
|
||||||
|
func nodeMetricKey(id int, metric string) string {
|
||||||
|
return "node:" + strconv.Itoa(id) + ":" + metric
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateNodeMetric returns up to maxPoints averaged buckets for one
|
||||||
|
// node's metric (currently "cpu" or "mem"). Output shape matches
|
||||||
|
// AggregateSystemMetric: {"t": unixSec, "v": value}.
|
||||||
|
func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds int, maxPoints int) []map[string]any {
|
||||||
|
return nodeMetrics.aggregate(nodeMetricKey(id, metric), bucketSeconds, maxPoints)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Probe issues a single GET to the node's /panel/api/server/status and
|
// Probe issues a single GET to the node's /panel/api/server/status and
|
||||||
|
|
|
||||||
|
|
@ -112,75 +112,27 @@ type ServerService struct {
|
||||||
hasLastCPUSample bool
|
hasLastCPUSample bool
|
||||||
hasNativeCPUSample bool
|
hasNativeCPUSample bool
|
||||||
emaCPU float64
|
emaCPU float64
|
||||||
cpuHistory []CPUSample
|
|
||||||
cachedCpuSpeedMhz float64
|
cachedCpuSpeedMhz float64
|
||||||
lastCpuInfoAttempt time.Time
|
lastCpuInfoAttempt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// AggregateCpuHistory returns up to maxPoints averaged buckets of size bucketSeconds over recent data.
|
// AggregateCpuHistory returns up to maxPoints averaged buckets of size bucketSeconds.
|
||||||
|
// Kept for back-compat with the original /panel/api/server/cpuHistory/:bucket route;
|
||||||
|
// the response key is "cpu" (not "v") so legacy consumers parse unchanged.
|
||||||
func (s *ServerService) AggregateCpuHistory(bucketSeconds int, maxPoints int) []map[string]any {
|
func (s *ServerService) AggregateCpuHistory(bucketSeconds int, maxPoints int) []map[string]any {
|
||||||
if bucketSeconds <= 0 || maxPoints <= 0 {
|
out := systemMetrics.aggregate("cpu", bucketSeconds, maxPoints)
|
||||||
return nil
|
for _, p := range out {
|
||||||
}
|
p["cpu"] = p["v"]
|
||||||
cutoff := time.Now().Add(-time.Duration(bucketSeconds*maxPoints) * time.Second).Unix()
|
delete(p, "v")
|
||||||
s.mu.Lock()
|
|
||||||
// find start index (history sorted ascending)
|
|
||||||
hist := s.cpuHistory
|
|
||||||
// binary-ish scan (simple linear from end since size capped ~10800 is fine)
|
|
||||||
startIdx := 0
|
|
||||||
for i := len(hist) - 1; i >= 0; i-- {
|
|
||||||
if hist[i].T < cutoff {
|
|
||||||
startIdx = i + 1
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if startIdx >= len(hist) {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return []map[string]any{}
|
|
||||||
}
|
|
||||||
slice := hist[startIdx:]
|
|
||||||
// copy for unlock
|
|
||||||
tmp := make([]CPUSample, len(slice))
|
|
||||||
copy(tmp, slice)
|
|
||||||
s.mu.Unlock()
|
|
||||||
if len(tmp) == 0 {
|
|
||||||
return []map[string]any{}
|
|
||||||
}
|
|
||||||
var out []map[string]any
|
|
||||||
var acc []float64
|
|
||||||
bSize := int64(bucketSeconds)
|
|
||||||
curBucket := (tmp[0].T / bSize) * bSize
|
|
||||||
flush := func(ts int64) {
|
|
||||||
if len(acc) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sum := 0.0
|
|
||||||
for _, v := range acc {
|
|
||||||
sum += v
|
|
||||||
}
|
|
||||||
avg := sum / float64(len(acc))
|
|
||||||
out = append(out, map[string]any{"t": ts, "cpu": avg})
|
|
||||||
acc = acc[:0]
|
|
||||||
}
|
|
||||||
for _, p := range tmp {
|
|
||||||
b := (p.T / bSize) * bSize
|
|
||||||
if b != curBucket {
|
|
||||||
flush(curBucket)
|
|
||||||
curBucket = b
|
|
||||||
}
|
|
||||||
acc = append(acc, p.Cpu)
|
|
||||||
}
|
|
||||||
flush(curBucket)
|
|
||||||
if len(out) > maxPoints {
|
|
||||||
out = out[len(out)-maxPoints:]
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// CPUSample single CPU utilization sample
|
// AggregateSystemMetric returns up to maxPoints averaged buckets for any
|
||||||
type CPUSample struct {
|
// known system metric (see SystemMetricKeys). Output points have keys
|
||||||
T int64 `json:"t"` // unix seconds
|
// {"t": unixSec, "v": value}; the caller decides how to format the value.
|
||||||
Cpu float64 `json:"cpu"` // percent 0..100
|
func (s *ServerService) AggregateSystemMetric(metric string, bucketSeconds int, maxPoints int) []map[string]any {
|
||||||
|
return systemMetrics.aggregate(metric, bucketSeconds, maxPoints)
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogEntry struct {
|
type LogEntry struct {
|
||||||
|
|
@ -423,18 +375,35 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AppendCpuSample is preserved for callers that only have the CPU number.
|
||||||
|
// New callers should prefer AppendStatusSample which writes the full set.
|
||||||
func (s *ServerService) AppendCpuSample(t time.Time, v float64) {
|
func (s *ServerService) AppendCpuSample(t time.Time, v float64) {
|
||||||
const capacity = 9000 // ~5 hours @ 2s interval
|
systemMetrics.append("cpu", t, v)
|
||||||
s.mu.Lock()
|
}
|
||||||
defer s.mu.Unlock()
|
|
||||||
p := CPUSample{T: t.Unix(), Cpu: v}
|
// AppendStatusSample writes one tick of every metric we keep — CPU, memory
|
||||||
if n := len(s.cpuHistory); n > 0 && s.cpuHistory[n-1].T == p.T {
|
// percent, network throughput (bytes/s), online client count, and the three
|
||||||
s.cpuHistory[n-1] = p
|
// load averages. Called by ServerController.refreshStatus on the same @2s
|
||||||
} else {
|
// cadence as AppendCpuSample, so all series stay aligned.
|
||||||
s.cpuHistory = append(s.cpuHistory, p)
|
func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
|
||||||
|
if status == nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if len(s.cpuHistory) > capacity {
|
systemMetrics.append("cpu", t, status.Cpu)
|
||||||
s.cpuHistory = s.cpuHistory[len(s.cpuHistory)-capacity:]
|
if status.Mem.Total > 0 {
|
||||||
|
systemMetrics.append("mem", t, float64(status.Mem.Current)*100.0/float64(status.Mem.Total))
|
||||||
|
}
|
||||||
|
systemMetrics.append("netUp", t, float64(status.NetIO.Up))
|
||||||
|
systemMetrics.append("netDown", t, float64(status.NetIO.Down))
|
||||||
|
online := 0
|
||||||
|
if p != nil && p.IsRunning() {
|
||||||
|
online = len(p.GetOnlineClients())
|
||||||
|
}
|
||||||
|
systemMetrics.append("online", t, float64(online))
|
||||||
|
if len(status.Loads) >= 3 {
|
||||||
|
systemMetrics.append("load1", t, status.Loads[0])
|
||||||
|
systemMetrics.append("load5", t, status.Loads[1])
|
||||||
|
systemMetrics.append("load15", t, status.Loads[2])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@
|
||||||
"xrayStatusError": "فيها غلطة",
|
"xrayStatusError": "فيها غلطة",
|
||||||
"xrayErrorPopoverTitle": "حصل خطأ أثناء تشغيل Xray",
|
"xrayErrorPopoverTitle": "حصل خطأ أثناء تشغيل Xray",
|
||||||
"operationHours": "مدة التشغيل",
|
"operationHours": "مدة التشغيل",
|
||||||
|
"systemHistoryTitle": "تاريخ النظام",
|
||||||
|
"trendLast2Min": "آخر دقيقتين",
|
||||||
"systemLoad": "تحميل النظام",
|
"systemLoad": "تحميل النظام",
|
||||||
"systemLoadDesc": "متوسط تحميل النظام في الدقائق 1, 5, و15",
|
"systemLoadDesc": "متوسط تحميل النظام في الدقائق 1, 5, و15",
|
||||||
"connectionCount": "إحصائيات الاتصال",
|
"connectionCount": "إحصائيات الاتصال",
|
||||||
|
|
@ -231,6 +233,9 @@
|
||||||
"operate": "القائمة",
|
"operate": "القائمة",
|
||||||
"enable": "مفعل",
|
"enable": "مفعل",
|
||||||
"remark": "ملاحظة",
|
"remark": "ملاحظة",
|
||||||
|
"node": "نود",
|
||||||
|
"deployTo": "نشر على",
|
||||||
|
"localPanel": "بانل محلي",
|
||||||
"protocol": "بروتوكول",
|
"protocol": "بروتوكول",
|
||||||
"port": "بورت",
|
"port": "بورت",
|
||||||
"portMap": "خريطة البورت",
|
"portMap": "خريطة البورت",
|
||||||
|
|
@ -380,6 +385,62 @@
|
||||||
"renew": "تجديد تلقائي",
|
"renew": "تجديد تلقائي",
|
||||||
"renewDesc": "تجديد تلقائي بعد انتهاء الصلاحية. (0 = تعطيل)(الوحدة: يوم)"
|
"renewDesc": "تجديد تلقائي بعد انتهاء الصلاحية. (0 = تعطيل)(الوحدة: يوم)"
|
||||||
},
|
},
|
||||||
|
"nodes": {
|
||||||
|
"title": "النودز",
|
||||||
|
"addNode": "إضافة نود",
|
||||||
|
"editNode": "تعديل نود",
|
||||||
|
"totalNodes": "إجمالي النودز",
|
||||||
|
"onlineNodes": "أونلاين",
|
||||||
|
"offlineNodes": "أوفلاين",
|
||||||
|
"avgLatency": "متوسط الكمون",
|
||||||
|
"name": "الاسم",
|
||||||
|
"namePlaceholder": "مثال: de-frankfurt-1",
|
||||||
|
"addressPlaceholder": "panel.example.com أو 1.2.3.4",
|
||||||
|
"remark": "ملاحظة",
|
||||||
|
"scheme": "البروتوكول",
|
||||||
|
"address": "العنوان",
|
||||||
|
"port": "البورت",
|
||||||
|
"basePath": "المسار الأساسي",
|
||||||
|
"apiToken": "توكن API",
|
||||||
|
"apiTokenPlaceholder": "التوكن من صفحة إعدادات البانل البعيد",
|
||||||
|
"apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في الإعدادات → توكن API.",
|
||||||
|
"regenerate": "تجديد التوكن",
|
||||||
|
"regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
|
||||||
|
"enable": "مفعل",
|
||||||
|
"status": "الحالة",
|
||||||
|
"cpu": "المعالج",
|
||||||
|
"mem": "الذاكرة",
|
||||||
|
"uptime": "مدة التشغيل",
|
||||||
|
"latency": "الكمون",
|
||||||
|
"lastHeartbeat": "آخر نبضة",
|
||||||
|
"xrayVersion": "إصدار Xray",
|
||||||
|
"actions": "العمليات",
|
||||||
|
"refresh": "تحديث",
|
||||||
|
"probe": "فحص فوري",
|
||||||
|
"testConnection": "اختبار الاتصال",
|
||||||
|
"connectionOk": "الاتصال شغال ({ms} ms)",
|
||||||
|
"connectionFailed": "فشل الاتصال",
|
||||||
|
"never": "أبدًا",
|
||||||
|
"justNow": "دلوقتي",
|
||||||
|
"deleteConfirmTitle": "تحذف النود \"{name}\"؟",
|
||||||
|
"deleteConfirmContent": "ده هيوقّف مراقبة النود. البانل البعيد نفسه مش هيتأثر.",
|
||||||
|
"statusValues": {
|
||||||
|
"online": "أونلاين",
|
||||||
|
"offline": "أوفلاين",
|
||||||
|
"unknown": "غير معروف"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"list": "فشل تحميل النودز",
|
||||||
|
"obtain": "فشل تحميل النود",
|
||||||
|
"add": "إضافة نود",
|
||||||
|
"update": "تحديث النود",
|
||||||
|
"delete": "حذف النود",
|
||||||
|
"deleted": "اتمسح النود",
|
||||||
|
"test": "اختبار الاتصال",
|
||||||
|
"fillRequired": "الاسم والعنوان والبورت وتوكن API كلهم مطلوبين",
|
||||||
|
"probeFailed": "فشل الفحص"
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "إعدادات البانل",
|
"title": "إعدادات البانل",
|
||||||
"save": "حفظ",
|
"save": "حفظ",
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,8 @@
|
||||||
"xrayStatusError": "Error",
|
"xrayStatusError": "Error",
|
||||||
"xrayErrorPopoverTitle": "An error occurred while running Xray",
|
"xrayErrorPopoverTitle": "An error occurred while running Xray",
|
||||||
"operationHours": "Uptime",
|
"operationHours": "Uptime",
|
||||||
|
"systemHistoryTitle": "System History",
|
||||||
|
"trendLast2Min": "Last 2 minutes",
|
||||||
"systemLoad": "System Load",
|
"systemLoad": "System Load",
|
||||||
"systemLoadDesc": "System load average for the past 1, 5, and 15 minutes",
|
"systemLoadDesc": "System load average for the past 1, 5, and 15 minutes",
|
||||||
"connectionCount": "Connection Stats",
|
"connectionCount": "Connection Stats",
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@
|
||||||
"xrayStatusError": "Error",
|
"xrayStatusError": "Error",
|
||||||
"xrayErrorPopoverTitle": "Se produjo un error al ejecutar Xray",
|
"xrayErrorPopoverTitle": "Se produjo un error al ejecutar Xray",
|
||||||
"operationHours": "Tiempo de Funcionamiento",
|
"operationHours": "Tiempo de Funcionamiento",
|
||||||
|
"systemHistoryTitle": "Historial del Sistema",
|
||||||
|
"trendLast2Min": "Últimos 2 minutos",
|
||||||
"systemLoad": "Carga del Sistema",
|
"systemLoad": "Carga del Sistema",
|
||||||
"systemLoadDesc": "promedio de carga del sistema en los últimos 1, 5 y 15 minutos",
|
"systemLoadDesc": "promedio de carga del sistema en los últimos 1, 5 y 15 minutos",
|
||||||
"connectionCount": "Número de Conexiones",
|
"connectionCount": "Número de Conexiones",
|
||||||
|
|
@ -231,6 +233,9 @@
|
||||||
"operate": "Menú",
|
"operate": "Menú",
|
||||||
"enable": "Habilitar",
|
"enable": "Habilitar",
|
||||||
"remark": "Notas",
|
"remark": "Notas",
|
||||||
|
"node": "Nodo",
|
||||||
|
"deployTo": "Desplegar en",
|
||||||
|
"localPanel": "Panel local",
|
||||||
"protocol": "Protocolo",
|
"protocol": "Protocolo",
|
||||||
"port": "Puerto",
|
"port": "Puerto",
|
||||||
"portMap": "Puertos de Destino",
|
"portMap": "Puertos de Destino",
|
||||||
|
|
@ -380,6 +385,62 @@
|
||||||
"renew": "Renovación automática",
|
"renew": "Renovación automática",
|
||||||
"renewDesc": "Renovación automática después de la expiración. (0 = desactivar) (unidad: día)"
|
"renewDesc": "Renovación automática después de la expiración. (0 = desactivar) (unidad: día)"
|
||||||
},
|
},
|
||||||
|
"nodes": {
|
||||||
|
"title": "Nodos",
|
||||||
|
"addNode": "Agregar nodo",
|
||||||
|
"editNode": "Editar nodo",
|
||||||
|
"totalNodes": "Total de nodos",
|
||||||
|
"onlineNodes": "En línea",
|
||||||
|
"offlineNodes": "Desconectado",
|
||||||
|
"avgLatency": "Latencia media",
|
||||||
|
"name": "Nombre",
|
||||||
|
"namePlaceholder": "p. ej. de-frankfurt-1",
|
||||||
|
"addressPlaceholder": "panel.example.com o 1.2.3.4",
|
||||||
|
"remark": "Notas",
|
||||||
|
"scheme": "Esquema",
|
||||||
|
"address": "Dirección",
|
||||||
|
"port": "Puerto",
|
||||||
|
"basePath": "Ruta base",
|
||||||
|
"apiToken": "Token de API",
|
||||||
|
"apiTokenPlaceholder": "Token desde la página de Configuración del panel remoto",
|
||||||
|
"apiTokenHint": "El panel remoto expone su token de API en Configuración → Token de API.",
|
||||||
|
"regenerate": "Regenerar token",
|
||||||
|
"regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?",
|
||||||
|
"enable": "Habilitado",
|
||||||
|
"status": "Estado",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"mem": "Memoria",
|
||||||
|
"uptime": "Tiempo activo",
|
||||||
|
"latency": "Latencia",
|
||||||
|
"lastHeartbeat": "Último latido",
|
||||||
|
"xrayVersion": "Versión de Xray",
|
||||||
|
"actions": "Acciones",
|
||||||
|
"refresh": "Actualizar",
|
||||||
|
"probe": "Sondear ahora",
|
||||||
|
"testConnection": "Probar conexión",
|
||||||
|
"connectionOk": "Conexión correcta ({ms} ms)",
|
||||||
|
"connectionFailed": "Conexión fallida",
|
||||||
|
"never": "nunca",
|
||||||
|
"justNow": "ahora mismo",
|
||||||
|
"deleteConfirmTitle": "¿Eliminar el nodo \"{name}\"?",
|
||||||
|
"deleteConfirmContent": "Esto detiene la monitorización del nodo. El panel remoto en sí no se ve afectado.",
|
||||||
|
"statusValues": {
|
||||||
|
"online": "En línea",
|
||||||
|
"offline": "Desconectado",
|
||||||
|
"unknown": "Desconocido"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"list": "Error al cargar los nodos",
|
||||||
|
"obtain": "Error al cargar el nodo",
|
||||||
|
"add": "Agregar nodo",
|
||||||
|
"update": "Actualizar nodo",
|
||||||
|
"delete": "Eliminar nodo",
|
||||||
|
"deleted": "Nodo eliminado",
|
||||||
|
"test": "Probar conexión",
|
||||||
|
"fillRequired": "El nombre, la dirección, el puerto y el token de API son obligatorios",
|
||||||
|
"probeFailed": "Sondeo fallido"
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Configuraciones",
|
"title": "Configuraciones",
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,8 @@
|
||||||
"xrayStatusError": "خطا",
|
"xrayStatusError": "خطا",
|
||||||
"xrayErrorPopoverTitle": "خطا در هنگام اجرای Xray رخ داد",
|
"xrayErrorPopoverTitle": "خطا در هنگام اجرای Xray رخ داد",
|
||||||
"operationHours": "مدتکارکرد",
|
"operationHours": "مدتکارکرد",
|
||||||
|
"systemHistoryTitle": "تاریخچه سیستم",
|
||||||
|
"trendLast2Min": "۲ دقیقه اخیر",
|
||||||
"systemLoad": "بارسیستم",
|
"systemLoad": "بارسیستم",
|
||||||
"systemLoadDesc": "میانگین بار سیستم برای 1، 5 و 15 دقیقه گذشته",
|
"systemLoadDesc": "میانگین بار سیستم برای 1، 5 و 15 دقیقه گذشته",
|
||||||
"connectionCount": "تعداد کانکشن ها",
|
"connectionCount": "تعداد کانکشن ها",
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@
|
||||||
"xrayStatusError": "Kesalahan",
|
"xrayStatusError": "Kesalahan",
|
||||||
"xrayErrorPopoverTitle": "Terjadi kesalahan saat menjalankan Xray",
|
"xrayErrorPopoverTitle": "Terjadi kesalahan saat menjalankan Xray",
|
||||||
"operationHours": "Waktu Aktif",
|
"operationHours": "Waktu Aktif",
|
||||||
|
"systemHistoryTitle": "Riwayat Sistem",
|
||||||
|
"trendLast2Min": "2 menit terakhir",
|
||||||
"systemLoad": "Beban Sistem",
|
"systemLoad": "Beban Sistem",
|
||||||
"systemLoadDesc": "Rata-rata beban sistem selama 1, 5, dan 15 menit terakhir",
|
"systemLoadDesc": "Rata-rata beban sistem selama 1, 5, dan 15 menit terakhir",
|
||||||
"connectionCount": "Statistik Koneksi",
|
"connectionCount": "Statistik Koneksi",
|
||||||
|
|
@ -231,6 +233,9 @@
|
||||||
"operate": "Menu",
|
"operate": "Menu",
|
||||||
"enable": "Aktifkan",
|
"enable": "Aktifkan",
|
||||||
"remark": "Catatan",
|
"remark": "Catatan",
|
||||||
|
"node": "Node",
|
||||||
|
"deployTo": "Terapkan ke",
|
||||||
|
"localPanel": "Panel lokal",
|
||||||
"protocol": "Protokol",
|
"protocol": "Protokol",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"portMap": "Port Mapping",
|
"portMap": "Port Mapping",
|
||||||
|
|
@ -380,6 +385,62 @@
|
||||||
"renew": "Perpanjang Otomatis",
|
"renew": "Perpanjang Otomatis",
|
||||||
"renewDesc": "Perpanjangan otomatis setelah kedaluwarsa. (0 = nonaktif)(unit: hari)"
|
"renewDesc": "Perpanjangan otomatis setelah kedaluwarsa. (0 = nonaktif)(unit: hari)"
|
||||||
},
|
},
|
||||||
|
"nodes": {
|
||||||
|
"title": "Node",
|
||||||
|
"addNode": "Tambah Node",
|
||||||
|
"editNode": "Edit Node",
|
||||||
|
"totalNodes": "Total Node",
|
||||||
|
"onlineNodes": "Online",
|
||||||
|
"offlineNodes": "Offline",
|
||||||
|
"avgLatency": "Latensi Rata-rata",
|
||||||
|
"name": "Nama",
|
||||||
|
"namePlaceholder": "mis. de-frankfurt-1",
|
||||||
|
"addressPlaceholder": "panel.example.com atau 1.2.3.4",
|
||||||
|
"remark": "Catatan",
|
||||||
|
"scheme": "Skema",
|
||||||
|
"address": "Alamat",
|
||||||
|
"port": "Port",
|
||||||
|
"basePath": "Base Path",
|
||||||
|
"apiToken": "Token API",
|
||||||
|
"apiTokenPlaceholder": "Token dari halaman Pengaturan panel jarak jauh",
|
||||||
|
"apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Pengaturan → Token API.",
|
||||||
|
"regenerate": "Buat Ulang Token",
|
||||||
|
"regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?",
|
||||||
|
"enable": "Aktif",
|
||||||
|
"status": "Status",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"mem": "Memori",
|
||||||
|
"uptime": "Uptime",
|
||||||
|
"latency": "Latensi",
|
||||||
|
"lastHeartbeat": "Heartbeat Terakhir",
|
||||||
|
"xrayVersion": "Versi Xray",
|
||||||
|
"actions": "Aksi",
|
||||||
|
"refresh": "Segarkan",
|
||||||
|
"probe": "Probe Sekarang",
|
||||||
|
"testConnection": "Tes Koneksi",
|
||||||
|
"connectionOk": "Koneksi OK ({ms} ms)",
|
||||||
|
"connectionFailed": "Koneksi gagal",
|
||||||
|
"never": "tidak pernah",
|
||||||
|
"justNow": "baru saja",
|
||||||
|
"deleteConfirmTitle": "Hapus node \"{name}\"?",
|
||||||
|
"deleteConfirmContent": "Ini menghentikan pemantauan node. Panel jarak jauh itu sendiri tidak terpengaruh.",
|
||||||
|
"statusValues": {
|
||||||
|
"online": "Online",
|
||||||
|
"offline": "Offline",
|
||||||
|
"unknown": "Tidak diketahui"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"list": "Gagal memuat node",
|
||||||
|
"obtain": "Gagal memuat node",
|
||||||
|
"add": "Tambah node",
|
||||||
|
"update": "Perbarui node",
|
||||||
|
"delete": "Hapus node",
|
||||||
|
"deleted": "Node dihapus",
|
||||||
|
"test": "Tes koneksi",
|
||||||
|
"fillRequired": "Nama, alamat, port, dan token API wajib diisi",
|
||||||
|
"probeFailed": "Probe gagal"
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Pengaturan Panel",
|
"title": "Pengaturan Panel",
|
||||||
"save": "Simpan",
|
"save": "Simpan",
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@
|
||||||
"xrayStatusError": "エラー",
|
"xrayStatusError": "エラー",
|
||||||
"xrayErrorPopoverTitle": "Xrayの実行中にエラーが発生しました",
|
"xrayErrorPopoverTitle": "Xrayの実行中にエラーが発生しました",
|
||||||
"operationHours": "システム稼働時間",
|
"operationHours": "システム稼働時間",
|
||||||
|
"systemHistoryTitle": "システム履歴",
|
||||||
|
"trendLast2Min": "直近2分",
|
||||||
"systemLoad": "システム負荷",
|
"systemLoad": "システム負荷",
|
||||||
"systemLoadDesc": "過去1、5、15分間のシステム平均負荷",
|
"systemLoadDesc": "過去1、5、15分間のシステム平均負荷",
|
||||||
"connectionCount": "接続数",
|
"connectionCount": "接続数",
|
||||||
|
|
@ -231,6 +233,9 @@
|
||||||
"operate": "メニュー",
|
"operate": "メニュー",
|
||||||
"enable": "有効化",
|
"enable": "有効化",
|
||||||
"remark": "備考",
|
"remark": "備考",
|
||||||
|
"node": "ノード",
|
||||||
|
"deployTo": "デプロイ先",
|
||||||
|
"localPanel": "ローカルパネル",
|
||||||
"protocol": "プロトコル",
|
"protocol": "プロトコル",
|
||||||
"port": "ポート",
|
"port": "ポート",
|
||||||
"portMap": "ポートマッピング",
|
"portMap": "ポートマッピング",
|
||||||
|
|
@ -380,6 +385,62 @@
|
||||||
"renew": "自動更新",
|
"renew": "自動更新",
|
||||||
"renewDesc": "期限が切れた後に自動更新。(0 = 無効)(単位:日)"
|
"renewDesc": "期限が切れた後に自動更新。(0 = 無効)(単位:日)"
|
||||||
},
|
},
|
||||||
|
"nodes": {
|
||||||
|
"title": "ノード",
|
||||||
|
"addNode": "ノードを追加",
|
||||||
|
"editNode": "ノードを編集",
|
||||||
|
"totalNodes": "ノード総数",
|
||||||
|
"onlineNodes": "オンライン",
|
||||||
|
"offlineNodes": "オフライン",
|
||||||
|
"avgLatency": "平均レイテンシ",
|
||||||
|
"name": "名前",
|
||||||
|
"namePlaceholder": "例: de-frankfurt-1",
|
||||||
|
"addressPlaceholder": "panel.example.com または 1.2.3.4",
|
||||||
|
"remark": "備考",
|
||||||
|
"scheme": "スキーム",
|
||||||
|
"address": "アドレス",
|
||||||
|
"port": "ポート",
|
||||||
|
"basePath": "ベースパス",
|
||||||
|
"apiToken": "APIトークン",
|
||||||
|
"apiTokenPlaceholder": "リモートパネルの設定ページから取得したトークン",
|
||||||
|
"apiTokenHint": "リモートパネルでは、設定 → APIトークン でAPIトークンを確認できます。",
|
||||||
|
"regenerate": "トークンを再生成",
|
||||||
|
"regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?",
|
||||||
|
"enable": "有効",
|
||||||
|
"status": "ステータス",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"mem": "メモリ",
|
||||||
|
"uptime": "稼働時間",
|
||||||
|
"latency": "レイテンシ",
|
||||||
|
"lastHeartbeat": "最後のハートビート",
|
||||||
|
"xrayVersion": "Xrayバージョン",
|
||||||
|
"actions": "操作",
|
||||||
|
"refresh": "更新",
|
||||||
|
"probe": "今すぐプローブ",
|
||||||
|
"testConnection": "接続テスト",
|
||||||
|
"connectionOk": "接続OK ({ms} ms)",
|
||||||
|
"connectionFailed": "接続に失敗しました",
|
||||||
|
"never": "なし",
|
||||||
|
"justNow": "たった今",
|
||||||
|
"deleteConfirmTitle": "ノード「{name}」を削除しますか?",
|
||||||
|
"deleteConfirmContent": "ノードの監視を停止します。リモートパネル自体には影響しません。",
|
||||||
|
"statusValues": {
|
||||||
|
"online": "オンライン",
|
||||||
|
"offline": "オフライン",
|
||||||
|
"unknown": "不明"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"list": "ノードの読み込みに失敗しました",
|
||||||
|
"obtain": "ノードの読み込みに失敗しました",
|
||||||
|
"add": "ノードを追加",
|
||||||
|
"update": "ノードを更新",
|
||||||
|
"delete": "ノードを削除",
|
||||||
|
"deleted": "ノードを削除しました",
|
||||||
|
"test": "接続テスト",
|
||||||
|
"fillRequired": "名前、アドレス、ポート、APIトークンは必須です",
|
||||||
|
"probeFailed": "プローブに失敗しました"
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "パネル設定",
|
"title": "パネル設定",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@
|
||||||
"xrayStatusError": "Erro",
|
"xrayStatusError": "Erro",
|
||||||
"xrayErrorPopoverTitle": "Ocorreu um erro ao executar o Xray",
|
"xrayErrorPopoverTitle": "Ocorreu um erro ao executar o Xray",
|
||||||
"operationHours": "Tempo de Atividade",
|
"operationHours": "Tempo de Atividade",
|
||||||
|
"systemHistoryTitle": "Histórico do Sistema",
|
||||||
|
"trendLast2Min": "Últimos 2 minutos",
|
||||||
"systemLoad": "Carga do Sistema",
|
"systemLoad": "Carga do Sistema",
|
||||||
"systemLoadDesc": "Média de carga do sistema nos últimos 1, 5 e 15 minutos",
|
"systemLoadDesc": "Média de carga do sistema nos últimos 1, 5 e 15 minutos",
|
||||||
"connectionCount": "Estatísticas de Conexão",
|
"connectionCount": "Estatísticas de Conexão",
|
||||||
|
|
@ -231,6 +233,9 @@
|
||||||
"operate": "Menu",
|
"operate": "Menu",
|
||||||
"enable": "Ativado",
|
"enable": "Ativado",
|
||||||
"remark": "Observação",
|
"remark": "Observação",
|
||||||
|
"node": "Nó",
|
||||||
|
"deployTo": "Implantar em",
|
||||||
|
"localPanel": "Painel local",
|
||||||
"protocol": "Protocolo",
|
"protocol": "Protocolo",
|
||||||
"port": "Porta",
|
"port": "Porta",
|
||||||
"portMap": "Porta Mapeada",
|
"portMap": "Porta Mapeada",
|
||||||
|
|
@ -380,6 +385,62 @@
|
||||||
"renew": "Renovação Automática",
|
"renew": "Renovação Automática",
|
||||||
"renewDesc": "Renovação automática após expiração. (0 = desativado)(unidade: dia)"
|
"renewDesc": "Renovação automática após expiração. (0 = desativado)(unidade: dia)"
|
||||||
},
|
},
|
||||||
|
"nodes": {
|
||||||
|
"title": "Nós",
|
||||||
|
"addNode": "Adicionar nó",
|
||||||
|
"editNode": "Editar nó",
|
||||||
|
"totalNodes": "Total de nós",
|
||||||
|
"onlineNodes": "Online",
|
||||||
|
"offlineNodes": "Offline",
|
||||||
|
"avgLatency": "Latência média",
|
||||||
|
"name": "Nome",
|
||||||
|
"namePlaceholder": "ex.: de-frankfurt-1",
|
||||||
|
"addressPlaceholder": "panel.example.com ou 1.2.3.4",
|
||||||
|
"remark": "Observação",
|
||||||
|
"scheme": "Esquema",
|
||||||
|
"address": "Endereço",
|
||||||
|
"port": "Porta",
|
||||||
|
"basePath": "Caminho base",
|
||||||
|
"apiToken": "Token da API",
|
||||||
|
"apiTokenPlaceholder": "Token da página de Configurações do painel remoto",
|
||||||
|
"apiTokenHint": "O painel remoto exibe o token da API em Configurações → Token da API.",
|
||||||
|
"regenerate": "Regenerar token",
|
||||||
|
"regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?",
|
||||||
|
"enable": "Ativado",
|
||||||
|
"status": "Status",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"mem": "Memória",
|
||||||
|
"uptime": "Tempo ativo",
|
||||||
|
"latency": "Latência",
|
||||||
|
"lastHeartbeat": "Último heartbeat",
|
||||||
|
"xrayVersion": "Versão do Xray",
|
||||||
|
"actions": "Ações",
|
||||||
|
"refresh": "Atualizar",
|
||||||
|
"probe": "Sondar agora",
|
||||||
|
"testConnection": "Testar conexão",
|
||||||
|
"connectionOk": "Conexão OK ({ms} ms)",
|
||||||
|
"connectionFailed": "Falha na conexão",
|
||||||
|
"never": "nunca",
|
||||||
|
"justNow": "agora mesmo",
|
||||||
|
"deleteConfirmTitle": "Excluir o nó \"{name}\"?",
|
||||||
|
"deleteConfirmContent": "Isso interrompe o monitoramento do nó. O painel remoto em si não é afetado.",
|
||||||
|
"statusValues": {
|
||||||
|
"online": "Online",
|
||||||
|
"offline": "Offline",
|
||||||
|
"unknown": "Desconhecido"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"list": "Falha ao carregar os nós",
|
||||||
|
"obtain": "Falha ao carregar o nó",
|
||||||
|
"add": "Adicionar nó",
|
||||||
|
"update": "Atualizar nó",
|
||||||
|
"delete": "Excluir nó",
|
||||||
|
"deleted": "Nó excluído",
|
||||||
|
"test": "Testar conexão",
|
||||||
|
"fillRequired": "Nome, endereço, porta e token da API são obrigatórios",
|
||||||
|
"probeFailed": "Falha na sondagem"
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Configurações do Painel",
|
"title": "Configurações do Painel",
|
||||||
"save": "Salvar",
|
"save": "Salvar",
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@
|
||||||
"xrayStatusError": "Ошибка",
|
"xrayStatusError": "Ошибка",
|
||||||
"xrayErrorPopoverTitle": "Ошибка при запуске Xray",
|
"xrayErrorPopoverTitle": "Ошибка при запуске Xray",
|
||||||
"operationHours": "Время работы системы",
|
"operationHours": "Время работы системы",
|
||||||
|
"systemHistoryTitle": "История системы",
|
||||||
|
"trendLast2Min": "Последние 2 минуты",
|
||||||
"systemLoad": "Нагрузка на систему",
|
"systemLoad": "Нагрузка на систему",
|
||||||
"systemLoadDesc": "Средняя загрузка системы за последние 1, 5 и 15 минут",
|
"systemLoadDesc": "Средняя загрузка системы за последние 1, 5 и 15 минут",
|
||||||
"connectionCount": "Количество соединений",
|
"connectionCount": "Количество соединений",
|
||||||
|
|
@ -231,6 +233,9 @@
|
||||||
"operate": "Меню",
|
"operate": "Меню",
|
||||||
"enable": "Включить",
|
"enable": "Включить",
|
||||||
"remark": "Примечание",
|
"remark": "Примечание",
|
||||||
|
"node": "Узел",
|
||||||
|
"deployTo": "Развернуть на",
|
||||||
|
"localPanel": "Локальная панель",
|
||||||
"protocol": "Протокол",
|
"protocol": "Протокол",
|
||||||
"port": "Порт",
|
"port": "Порт",
|
||||||
"portMap": "Порт-маппинг",
|
"portMap": "Порт-маппинг",
|
||||||
|
|
@ -380,6 +385,62 @@
|
||||||
"renew": "Автопродление",
|
"renew": "Автопродление",
|
||||||
"renewDesc": "Автопродление после истечения срока действия. (0 = отключить)(единица: день)"
|
"renewDesc": "Автопродление после истечения срока действия. (0 = отключить)(единица: день)"
|
||||||
},
|
},
|
||||||
|
"nodes": {
|
||||||
|
"title": "Узлы",
|
||||||
|
"addNode": "Добавить узел",
|
||||||
|
"editNode": "Редактировать узел",
|
||||||
|
"totalNodes": "Всего узлов",
|
||||||
|
"onlineNodes": "Онлайн",
|
||||||
|
"offlineNodes": "Офлайн",
|
||||||
|
"avgLatency": "Средняя задержка",
|
||||||
|
"name": "Имя",
|
||||||
|
"namePlaceholder": "напр. de-frankfurt-1",
|
||||||
|
"addressPlaceholder": "panel.example.com или 1.2.3.4",
|
||||||
|
"remark": "Примечание",
|
||||||
|
"scheme": "Схема",
|
||||||
|
"address": "Адрес",
|
||||||
|
"port": "Порт",
|
||||||
|
"basePath": "Базовый путь",
|
||||||
|
"apiToken": "Токен API",
|
||||||
|
"apiTokenPlaceholder": "Токен со страницы Настроек удалённой панели",
|
||||||
|
"apiTokenHint": "Удалённая панель показывает свой токен API в разделе Настройки → Токен API.",
|
||||||
|
"regenerate": "Сгенерировать токен заново",
|
||||||
|
"regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?",
|
||||||
|
"enable": "Включён",
|
||||||
|
"status": "Статус",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"mem": "Память",
|
||||||
|
"uptime": "Время работы",
|
||||||
|
"latency": "Задержка",
|
||||||
|
"lastHeartbeat": "Последний пинг",
|
||||||
|
"xrayVersion": "Версия Xray",
|
||||||
|
"actions": "Действия",
|
||||||
|
"refresh": "Обновить",
|
||||||
|
"probe": "Проверить сейчас",
|
||||||
|
"testConnection": "Проверить соединение",
|
||||||
|
"connectionOk": "Соединение в порядке ({ms} мс)",
|
||||||
|
"connectionFailed": "Не удалось подключиться",
|
||||||
|
"never": "никогда",
|
||||||
|
"justNow": "только что",
|
||||||
|
"deleteConfirmTitle": "Удалить узел \"{name}\"?",
|
||||||
|
"deleteConfirmContent": "Это остановит мониторинг узла. Сама удалённая панель не будет затронута.",
|
||||||
|
"statusValues": {
|
||||||
|
"online": "Онлайн",
|
||||||
|
"offline": "Офлайн",
|
||||||
|
"unknown": "Неизвестно"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"list": "Не удалось загрузить узлы",
|
||||||
|
"obtain": "Не удалось загрузить узел",
|
||||||
|
"add": "Добавить узел",
|
||||||
|
"update": "Обновить узел",
|
||||||
|
"delete": "Удалить узел",
|
||||||
|
"deleted": "Узел удалён",
|
||||||
|
"test": "Проверить соединение",
|
||||||
|
"fillRequired": "Имя, адрес, порт и токен API обязательны",
|
||||||
|
"probeFailed": "Проверка не удалась"
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Настройки",
|
"title": "Настройки",
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@
|
||||||
"xrayStatusError": "Hata",
|
"xrayStatusError": "Hata",
|
||||||
"xrayErrorPopoverTitle": "Xray çalıştırılırken bir hata oluştu",
|
"xrayErrorPopoverTitle": "Xray çalıştırılırken bir hata oluştu",
|
||||||
"operationHours": "Çalışma Süresi",
|
"operationHours": "Çalışma Süresi",
|
||||||
|
"systemHistoryTitle": "Sistem Geçmişi",
|
||||||
|
"trendLast2Min": "Son 2 dakika",
|
||||||
"systemLoad": "Sistem Yükü",
|
"systemLoad": "Sistem Yükü",
|
||||||
"systemLoadDesc": "Geçmiş 1, 5 ve 15 dakika için sistem yük ortalaması",
|
"systemLoadDesc": "Geçmiş 1, 5 ve 15 dakika için sistem yük ortalaması",
|
||||||
"connectionCount": "Bağlantı İstatistikleri",
|
"connectionCount": "Bağlantı İstatistikleri",
|
||||||
|
|
@ -231,6 +233,9 @@
|
||||||
"operate": "Menü",
|
"operate": "Menü",
|
||||||
"enable": "Etkin",
|
"enable": "Etkin",
|
||||||
"remark": "Açıklama",
|
"remark": "Açıklama",
|
||||||
|
"node": "Düğüm",
|
||||||
|
"deployTo": "Şuraya dağıt",
|
||||||
|
"localPanel": "Yerel panel",
|
||||||
"protocol": "Protokol",
|
"protocol": "Protokol",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"portMap": "Port Atama",
|
"portMap": "Port Atama",
|
||||||
|
|
@ -380,6 +385,62 @@
|
||||||
"renew": "Otomatik Yenile",
|
"renew": "Otomatik Yenile",
|
||||||
"renewDesc": "Süresi dolduktan sonra otomatik yenileme. (0 = devre dışı)(birim: gün)"
|
"renewDesc": "Süresi dolduktan sonra otomatik yenileme. (0 = devre dışı)(birim: gün)"
|
||||||
},
|
},
|
||||||
|
"nodes": {
|
||||||
|
"title": "Düğümler",
|
||||||
|
"addNode": "Düğüm Ekle",
|
||||||
|
"editNode": "Düğümü Düzenle",
|
||||||
|
"totalNodes": "Toplam Düğüm",
|
||||||
|
"onlineNodes": "Çevrimiçi",
|
||||||
|
"offlineNodes": "Çevrimdışı",
|
||||||
|
"avgLatency": "Ortalama Gecikme",
|
||||||
|
"name": "Ad",
|
||||||
|
"namePlaceholder": "ör. de-frankfurt-1",
|
||||||
|
"addressPlaceholder": "panel.example.com veya 1.2.3.4",
|
||||||
|
"remark": "Açıklama",
|
||||||
|
"scheme": "Şema",
|
||||||
|
"address": "Adres",
|
||||||
|
"port": "Port",
|
||||||
|
"basePath": "Temel Yol",
|
||||||
|
"apiToken": "API Token",
|
||||||
|
"apiTokenPlaceholder": "Uzak panelin Ayarlar sayfasındaki token",
|
||||||
|
"apiTokenHint": "Uzak panel API token'ını Ayarlar → API Token altında gösterir.",
|
||||||
|
"regenerate": "Token'ı Yeniden Oluştur",
|
||||||
|
"regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?",
|
||||||
|
"enable": "Etkin",
|
||||||
|
"status": "Durum",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"mem": "Bellek",
|
||||||
|
"uptime": "Çalışma Süresi",
|
||||||
|
"latency": "Gecikme",
|
||||||
|
"lastHeartbeat": "Son Sinyal",
|
||||||
|
"xrayVersion": "Xray Sürümü",
|
||||||
|
"actions": "İşlemler",
|
||||||
|
"refresh": "Yenile",
|
||||||
|
"probe": "Şimdi Test Et",
|
||||||
|
"testConnection": "Bağlantıyı Test Et",
|
||||||
|
"connectionOk": "Bağlantı tamam ({ms} ms)",
|
||||||
|
"connectionFailed": "Bağlantı başarısız",
|
||||||
|
"never": "asla",
|
||||||
|
"justNow": "şimdi",
|
||||||
|
"deleteConfirmTitle": "\"{name}\" düğümü silinsin mi?",
|
||||||
|
"deleteConfirmContent": "Bu, düğüm izlemeyi durdurur. Uzak panelin kendisi etkilenmez.",
|
||||||
|
"statusValues": {
|
||||||
|
"online": "Çevrimiçi",
|
||||||
|
"offline": "Çevrimdışı",
|
||||||
|
"unknown": "Bilinmiyor"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"list": "Düğümler yüklenemedi",
|
||||||
|
"obtain": "Düğüm yüklenemedi",
|
||||||
|
"add": "Düğüm ekle",
|
||||||
|
"update": "Düğümü güncelle",
|
||||||
|
"delete": "Düğümü sil",
|
||||||
|
"deleted": "Düğüm silindi",
|
||||||
|
"test": "Bağlantıyı test et",
|
||||||
|
"fillRequired": "Ad, adres, port ve API token gereklidir",
|
||||||
|
"probeFailed": "Test başarısız"
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Panel Ayarları",
|
"title": "Panel Ayarları",
|
||||||
"save": "Kaydet",
|
"save": "Kaydet",
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@
|
||||||
"xrayStatusError": "Помилка",
|
"xrayStatusError": "Помилка",
|
||||||
"xrayErrorPopoverTitle": "Під час роботи Xray сталася помилка",
|
"xrayErrorPopoverTitle": "Під час роботи Xray сталася помилка",
|
||||||
"operationHours": "Час роботи",
|
"operationHours": "Час роботи",
|
||||||
|
"systemHistoryTitle": "Історія системи",
|
||||||
|
"trendLast2Min": "Останні 2 хвилини",
|
||||||
"systemLoad": "Завантаження системи",
|
"systemLoad": "Завантаження системи",
|
||||||
"systemLoadDesc": "Середнє завантаження системи за останні 1, 5 і 15 хвилин",
|
"systemLoadDesc": "Середнє завантаження системи за останні 1, 5 і 15 хвилин",
|
||||||
"connectionCount": "Статистика з'єднання",
|
"connectionCount": "Статистика з'єднання",
|
||||||
|
|
@ -231,6 +233,9 @@
|
||||||
"operate": "Меню",
|
"operate": "Меню",
|
||||||
"enable": "Увімкнено",
|
"enable": "Увімкнено",
|
||||||
"remark": "Примітка",
|
"remark": "Примітка",
|
||||||
|
"node": "Вузол",
|
||||||
|
"deployTo": "Розгорнути на",
|
||||||
|
"localPanel": "Локальна панель",
|
||||||
"protocol": "Протокол",
|
"protocol": "Протокол",
|
||||||
"port": "Порт",
|
"port": "Порт",
|
||||||
"portMap": "Порт-перехід",
|
"portMap": "Порт-перехід",
|
||||||
|
|
@ -380,6 +385,62 @@
|
||||||
"renew": "Автоматичне оновлення",
|
"renew": "Автоматичне оновлення",
|
||||||
"renewDesc": "Автоматичне поновлення після закінчення терміну дії. (0 = вимкнено)(одиниця: день)"
|
"renewDesc": "Автоматичне поновлення після закінчення терміну дії. (0 = вимкнено)(одиниця: день)"
|
||||||
},
|
},
|
||||||
|
"nodes": {
|
||||||
|
"title": "Вузли",
|
||||||
|
"addNode": "Додати вузол",
|
||||||
|
"editNode": "Редагувати вузол",
|
||||||
|
"totalNodes": "Усього вузлів",
|
||||||
|
"onlineNodes": "Онлайн",
|
||||||
|
"offlineNodes": "Офлайн",
|
||||||
|
"avgLatency": "Середня затримка",
|
||||||
|
"name": "Назва",
|
||||||
|
"namePlaceholder": "напр. de-frankfurt-1",
|
||||||
|
"addressPlaceholder": "panel.example.com або 1.2.3.4",
|
||||||
|
"remark": "Примітка",
|
||||||
|
"scheme": "Схема",
|
||||||
|
"address": "Адреса",
|
||||||
|
"port": "Порт",
|
||||||
|
"basePath": "Базовий шлях",
|
||||||
|
"apiToken": "Токен API",
|
||||||
|
"apiTokenPlaceholder": "Токен зі сторінки Налаштувань віддаленої панелі",
|
||||||
|
"apiTokenHint": "Віддалена панель показує свій токен API в Налаштуваннях → Токен API.",
|
||||||
|
"regenerate": "Перегенерувати токен",
|
||||||
|
"regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?",
|
||||||
|
"enable": "Увімкнено",
|
||||||
|
"status": "Статус",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"mem": "Пам'ять",
|
||||||
|
"uptime": "Час роботи",
|
||||||
|
"latency": "Затримка",
|
||||||
|
"lastHeartbeat": "Останній пінг",
|
||||||
|
"xrayVersion": "Версія Xray",
|
||||||
|
"actions": "Дії",
|
||||||
|
"refresh": "Оновити",
|
||||||
|
"probe": "Перевірити зараз",
|
||||||
|
"testConnection": "Перевірити з'єднання",
|
||||||
|
"connectionOk": "З'єднання в порядку ({ms} мс)",
|
||||||
|
"connectionFailed": "Помилка з'єднання",
|
||||||
|
"never": "ніколи",
|
||||||
|
"justNow": "щойно",
|
||||||
|
"deleteConfirmTitle": "Видалити вузол \"{name}\"?",
|
||||||
|
"deleteConfirmContent": "Це зупинить моніторинг вузла. Сама віддалена панель не зазнає змін.",
|
||||||
|
"statusValues": {
|
||||||
|
"online": "Онлайн",
|
||||||
|
"offline": "Офлайн",
|
||||||
|
"unknown": "Невідомо"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"list": "Не вдалося завантажити вузли",
|
||||||
|
"obtain": "Не вдалося завантажити вузол",
|
||||||
|
"add": "Додати вузол",
|
||||||
|
"update": "Оновити вузол",
|
||||||
|
"delete": "Видалити вузол",
|
||||||
|
"deleted": "Вузол видалено",
|
||||||
|
"test": "Перевірити з'єднання",
|
||||||
|
"fillRequired": "Назва, адреса, порт та токен API є обов'язковими",
|
||||||
|
"probeFailed": "Помилка перевірки"
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Параметри панелі",
|
"title": "Параметри панелі",
|
||||||
"save": "Зберегти",
|
"save": "Зберегти",
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@
|
||||||
"xrayStatusError": "Lỗi",
|
"xrayStatusError": "Lỗi",
|
||||||
"xrayErrorPopoverTitle": "Đã xảy ra lỗi khi chạy Xray",
|
"xrayErrorPopoverTitle": "Đã xảy ra lỗi khi chạy Xray",
|
||||||
"operationHours": "Thời gian hoạt động",
|
"operationHours": "Thời gian hoạt động",
|
||||||
|
"systemHistoryTitle": "Lịch sử hệ thống",
|
||||||
|
"trendLast2Min": "2 phút gần nhất",
|
||||||
"systemLoad": "Tải hệ thống",
|
"systemLoad": "Tải hệ thống",
|
||||||
"systemLoadDesc": "trung bình tải hệ thống trong 1, 5 và 15 phút qua",
|
"systemLoadDesc": "trung bình tải hệ thống trong 1, 5 và 15 phút qua",
|
||||||
"connectionCount": "Số lượng kết nối",
|
"connectionCount": "Số lượng kết nối",
|
||||||
|
|
@ -231,6 +233,9 @@
|
||||||
"operate": "Thao tác",
|
"operate": "Thao tác",
|
||||||
"enable": "Kích hoạt",
|
"enable": "Kích hoạt",
|
||||||
"remark": "Chú thích",
|
"remark": "Chú thích",
|
||||||
|
"node": "Nút",
|
||||||
|
"deployTo": "Triển khai tới",
|
||||||
|
"localPanel": "Panel cục bộ",
|
||||||
"protocol": "Giao thức",
|
"protocol": "Giao thức",
|
||||||
"port": "Cổng",
|
"port": "Cổng",
|
||||||
"portMap": "Cổng tạo",
|
"portMap": "Cổng tạo",
|
||||||
|
|
@ -380,6 +385,62 @@
|
||||||
"renew": "Tự động gia hạn",
|
"renew": "Tự động gia hạn",
|
||||||
"renewDesc": "Tự động gia hạn sau khi hết hạn. (0 = tắt)(đơn vị: ngày)"
|
"renewDesc": "Tự động gia hạn sau khi hết hạn. (0 = tắt)(đơn vị: ngày)"
|
||||||
},
|
},
|
||||||
|
"nodes": {
|
||||||
|
"title": "Nút",
|
||||||
|
"addNode": "Thêm nút",
|
||||||
|
"editNode": "Chỉnh sửa nút",
|
||||||
|
"totalNodes": "Tổng số nút",
|
||||||
|
"onlineNodes": "Trực tuyến",
|
||||||
|
"offlineNodes": "Ngoại tuyến",
|
||||||
|
"avgLatency": "Độ trễ trung bình",
|
||||||
|
"name": "Tên",
|
||||||
|
"namePlaceholder": "vd: de-frankfurt-1",
|
||||||
|
"addressPlaceholder": "panel.example.com hoặc 1.2.3.4",
|
||||||
|
"remark": "Chú thích",
|
||||||
|
"scheme": "Giao thức",
|
||||||
|
"address": "Địa chỉ",
|
||||||
|
"port": "Cổng",
|
||||||
|
"basePath": "Đường dẫn cơ sở",
|
||||||
|
"apiToken": "Token API",
|
||||||
|
"apiTokenPlaceholder": "Token từ trang Cài đặt của panel từ xa",
|
||||||
|
"apiTokenHint": "Panel từ xa hiển thị token API tại Cài đặt → Token API.",
|
||||||
|
"regenerate": "Tạo lại token",
|
||||||
|
"regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?",
|
||||||
|
"enable": "Kích hoạt",
|
||||||
|
"status": "Trạng thái",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"mem": "Bộ nhớ",
|
||||||
|
"uptime": "Thời gian hoạt động",
|
||||||
|
"latency": "Độ trễ",
|
||||||
|
"lastHeartbeat": "Heartbeat gần nhất",
|
||||||
|
"xrayVersion": "Phiên bản Xray",
|
||||||
|
"actions": "Hành động",
|
||||||
|
"refresh": "Làm mới",
|
||||||
|
"probe": "Kiểm tra ngay",
|
||||||
|
"testConnection": "Kiểm tra kết nối",
|
||||||
|
"connectionOk": "Kết nối OK ({ms} ms)",
|
||||||
|
"connectionFailed": "Kết nối thất bại",
|
||||||
|
"never": "chưa bao giờ",
|
||||||
|
"justNow": "vừa xong",
|
||||||
|
"deleteConfirmTitle": "Xóa nút \"{name}\"?",
|
||||||
|
"deleteConfirmContent": "Việc này dừng giám sát nút. Panel từ xa không bị ảnh hưởng.",
|
||||||
|
"statusValues": {
|
||||||
|
"online": "Trực tuyến",
|
||||||
|
"offline": "Ngoại tuyến",
|
||||||
|
"unknown": "Không xác định"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"list": "Không tải được danh sách nút",
|
||||||
|
"obtain": "Không tải được nút",
|
||||||
|
"add": "Thêm nút",
|
||||||
|
"update": "Cập nhật nút",
|
||||||
|
"delete": "Xóa nút",
|
||||||
|
"deleted": "Đã xóa nút",
|
||||||
|
"test": "Kiểm tra kết nối",
|
||||||
|
"fillRequired": "Tên, địa chỉ, cổng và token API là bắt buộc",
|
||||||
|
"probeFailed": "Kiểm tra thất bại"
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Cài đặt",
|
"title": "Cài đặt",
|
||||||
"save": "Lưu",
|
"save": "Lưu",
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@
|
||||||
"xrayStatusError": "错误",
|
"xrayStatusError": "错误",
|
||||||
"xrayErrorPopoverTitle": "运行Xray时发生错误",
|
"xrayErrorPopoverTitle": "运行Xray时发生错误",
|
||||||
"operationHours": "系统正常运行时间",
|
"operationHours": "系统正常运行时间",
|
||||||
|
"systemHistoryTitle": "系统历史",
|
||||||
|
"trendLast2Min": "最近 2 分钟",
|
||||||
"systemLoad": "系统负载",
|
"systemLoad": "系统负载",
|
||||||
"systemLoadDesc": "过去 1、5 和 15 分钟的系统平均负载",
|
"systemLoadDesc": "过去 1、5 和 15 分钟的系统平均负载",
|
||||||
"connectionCount": "连接数",
|
"connectionCount": "连接数",
|
||||||
|
|
@ -231,6 +233,9 @@
|
||||||
"operate": "菜单",
|
"operate": "菜单",
|
||||||
"enable": "启用",
|
"enable": "启用",
|
||||||
"remark": "备注",
|
"remark": "备注",
|
||||||
|
"node": "节点",
|
||||||
|
"deployTo": "部署到",
|
||||||
|
"localPanel": "本地面板",
|
||||||
"protocol": "协议",
|
"protocol": "协议",
|
||||||
"port": "端口",
|
"port": "端口",
|
||||||
"portMap": "端口映射",
|
"portMap": "端口映射",
|
||||||
|
|
@ -380,6 +385,62 @@
|
||||||
"renew": "自动续订",
|
"renew": "自动续订",
|
||||||
"renewDesc": "到期后自动续订。(0 = 禁用)(单位: 天)"
|
"renewDesc": "到期后自动续订。(0 = 禁用)(单位: 天)"
|
||||||
},
|
},
|
||||||
|
"nodes": {
|
||||||
|
"title": "节点",
|
||||||
|
"addNode": "添加节点",
|
||||||
|
"editNode": "编辑节点",
|
||||||
|
"totalNodes": "节点总数",
|
||||||
|
"onlineNodes": "在线",
|
||||||
|
"offlineNodes": "离线",
|
||||||
|
"avgLatency": "平均延迟",
|
||||||
|
"name": "名称",
|
||||||
|
"namePlaceholder": "例如:de-frankfurt-1",
|
||||||
|
"addressPlaceholder": "panel.example.com 或 1.2.3.4",
|
||||||
|
"remark": "备注",
|
||||||
|
"scheme": "协议",
|
||||||
|
"address": "地址",
|
||||||
|
"port": "端口",
|
||||||
|
"basePath": "基础路径",
|
||||||
|
"apiToken": "API 令牌",
|
||||||
|
"apiTokenPlaceholder": "远程面板设置页中的令牌",
|
||||||
|
"apiTokenHint": "远程面板在 设置 → API 令牌 中显示其 API 令牌。",
|
||||||
|
"regenerate": "重新生成令牌",
|
||||||
|
"regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
|
||||||
|
"enable": "已启用",
|
||||||
|
"status": "状态",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"mem": "内存",
|
||||||
|
"uptime": "运行时间",
|
||||||
|
"latency": "延迟",
|
||||||
|
"lastHeartbeat": "上次心跳",
|
||||||
|
"xrayVersion": "Xray 版本",
|
||||||
|
"actions": "操作",
|
||||||
|
"refresh": "刷新",
|
||||||
|
"probe": "立即探测",
|
||||||
|
"testConnection": "测试连接",
|
||||||
|
"connectionOk": "连接正常 ({ms} ms)",
|
||||||
|
"connectionFailed": "连接失败",
|
||||||
|
"never": "从未",
|
||||||
|
"justNow": "刚刚",
|
||||||
|
"deleteConfirmTitle": "删除节点 \"{name}\"?",
|
||||||
|
"deleteConfirmContent": "这将停止监控该节点。远程面板本身不受影响。",
|
||||||
|
"statusValues": {
|
||||||
|
"online": "在线",
|
||||||
|
"offline": "离线",
|
||||||
|
"unknown": "未知"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"list": "加载节点失败",
|
||||||
|
"obtain": "加载节点失败",
|
||||||
|
"add": "添加节点",
|
||||||
|
"update": "更新节点",
|
||||||
|
"delete": "删除节点",
|
||||||
|
"deleted": "节点已删除",
|
||||||
|
"test": "测试连接",
|
||||||
|
"fillRequired": "名称、地址、端口和 API 令牌为必填项",
|
||||||
|
"probeFailed": "探测失败"
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "面板设置",
|
"title": "面板设置",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@
|
||||||
"xrayStatusError": "錯誤",
|
"xrayStatusError": "錯誤",
|
||||||
"xrayErrorPopoverTitle": "執行Xray時發生錯誤",
|
"xrayErrorPopoverTitle": "執行Xray時發生錯誤",
|
||||||
"operationHours": "系統正常執行時間",
|
"operationHours": "系統正常執行時間",
|
||||||
|
"systemHistoryTitle": "系統歷史",
|
||||||
|
"trendLast2Min": "最近 2 分鐘",
|
||||||
"systemLoad": "系統負載",
|
"systemLoad": "系統負載",
|
||||||
"systemLoadDesc": "過去 1、5 和 15 分鐘的系統平均負載",
|
"systemLoadDesc": "過去 1、5 和 15 分鐘的系統平均負載",
|
||||||
"connectionCount": "連線數",
|
"connectionCount": "連線數",
|
||||||
|
|
@ -231,6 +233,9 @@
|
||||||
"operate": "選單",
|
"operate": "選單",
|
||||||
"enable": "啟用",
|
"enable": "啟用",
|
||||||
"remark": "備註",
|
"remark": "備註",
|
||||||
|
"node": "節點",
|
||||||
|
"deployTo": "部署到",
|
||||||
|
"localPanel": "本機面板",
|
||||||
"protocol": "協議",
|
"protocol": "協議",
|
||||||
"port": "埠",
|
"port": "埠",
|
||||||
"portMap": "埠映射",
|
"portMap": "埠映射",
|
||||||
|
|
@ -380,6 +385,62 @@
|
||||||
"renew": "自動續訂",
|
"renew": "自動續訂",
|
||||||
"renewDesc": "到期後自動續訂。(0 = 禁用)(單位: 天)"
|
"renewDesc": "到期後自動續訂。(0 = 禁用)(單位: 天)"
|
||||||
},
|
},
|
||||||
|
"nodes": {
|
||||||
|
"title": "節點",
|
||||||
|
"addNode": "新增節點",
|
||||||
|
"editNode": "編輯節點",
|
||||||
|
"totalNodes": "節點總數",
|
||||||
|
"onlineNodes": "線上",
|
||||||
|
"offlineNodes": "離線",
|
||||||
|
"avgLatency": "平均延遲",
|
||||||
|
"name": "名稱",
|
||||||
|
"namePlaceholder": "例如:de-frankfurt-1",
|
||||||
|
"addressPlaceholder": "panel.example.com 或 1.2.3.4",
|
||||||
|
"remark": "備註",
|
||||||
|
"scheme": "協議",
|
||||||
|
"address": "位址",
|
||||||
|
"port": "埠",
|
||||||
|
"basePath": "基礎路徑",
|
||||||
|
"apiToken": "API 權杖",
|
||||||
|
"apiTokenPlaceholder": "遠端面板設定頁中的權杖",
|
||||||
|
"apiTokenHint": "遠端面板在 設定 → API 權杖 中顯示其 API 權杖。",
|
||||||
|
"regenerate": "重新產生權杖",
|
||||||
|
"regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
|
||||||
|
"enable": "已啟用",
|
||||||
|
"status": "狀態",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"mem": "記憶體",
|
||||||
|
"uptime": "執行時間",
|
||||||
|
"latency": "延遲",
|
||||||
|
"lastHeartbeat": "上次心跳",
|
||||||
|
"xrayVersion": "Xray 版本",
|
||||||
|
"actions": "操作",
|
||||||
|
"refresh": "重新整理",
|
||||||
|
"probe": "立即探測",
|
||||||
|
"testConnection": "測試連線",
|
||||||
|
"connectionOk": "連線正常 ({ms} ms)",
|
||||||
|
"connectionFailed": "連線失敗",
|
||||||
|
"never": "從未",
|
||||||
|
"justNow": "剛剛",
|
||||||
|
"deleteConfirmTitle": "刪除節點「{name}」?",
|
||||||
|
"deleteConfirmContent": "這將停止監控該節點。遠端面板本身不受影響。",
|
||||||
|
"statusValues": {
|
||||||
|
"online": "線上",
|
||||||
|
"offline": "離線",
|
||||||
|
"unknown": "未知"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"list": "載入節點失敗",
|
||||||
|
"obtain": "載入節點失敗",
|
||||||
|
"add": "新增節點",
|
||||||
|
"update": "更新節點",
|
||||||
|
"delete": "刪除節點",
|
||||||
|
"deleted": "節點已刪除",
|
||||||
|
"test": "測試連線",
|
||||||
|
"fillRequired": "名稱、位址、埠與 API 權杖為必填",
|
||||||
|
"probeFailed": "探測失敗"
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "面板設定",
|
"title": "面板設定",
|
||||||
"save": "儲存",
|
"save": "儲存",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue