diff --git a/web/controller/node.go b/web/controller/node.go
index fdba9333..715f1a27 100644
--- a/web/controller/node.go
+++ b/web/controller/node.go
@@ -2,6 +2,8 @@ package controller
import (
"context"
+ "fmt"
+ "slices"
"strconv"
"time"
@@ -41,6 +43,9 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
// /probe/:id triggers a synchronous probe of an already-saved node
// without waiting for the next 10s heartbeat tick.
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) {
@@ -182,3 +187,25 @@ func (a *NodeController) probe(c *gin.Context) {
_ = a.nodeService.UpdateHeartbeat(id, patch)
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)
+}
diff --git a/web/controller/server.go b/web/controller/server.go
index fc3dbec5..031c5318 100644
--- a/web/controller/server.go
+++ b/web/controller/server.go
@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"regexp"
+ "slices"
"strconv"
"time"
@@ -45,6 +46,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/status", a.status)
g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
+ g.GET("/history/:metric/:bucket", a.getMetricHistoryBucket)
g.GET("/getXrayVersion", a.getXrayVersion)
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
g.GET("/getConfigJson", a.getConfigJson)
@@ -67,12 +69,13 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
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() {
a.lastStatus = a.serverService.GetStatus(a.lastStatus)
- // collect cpu history when status is fresh
if a.lastStatus != nil {
- a.serverService.AppendCpuSample(time.Now(), a.lastStatus.Cpu)
+ a.serverService.AppendStatusSample(time.Now(), a.lastStatus)
// Broadcast status update via WebSocket
websocket.BroadcastStatus(a.lastStatus)
}
@@ -92,7 +95,22 @@ func (a *ServerController) startTask() {
// status returns the current server status information.
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.
+// 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) {
bucketStr := c.Param("bucket")
bucket, err := strconv.Atoi(bucketStr)
@@ -100,15 +118,7 @@ func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
jsonMsg(c, "invalid bucket", fmt.Errorf("bad bucket"))
return
}
- allowed := 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
- }
- if !allowed[bucket] {
+ if !allowedHistoryBuckets[bucket] {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return
}
@@ -116,6 +126,23 @@ func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
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.
func (a *ServerController) getXrayVersion(c *gin.Context) {
now := time.Now().Unix()
diff --git a/web/service/metric_history.go b/web/service/metric_history.go
new file mode 100644
index 00000000..f180d3b9
--- /dev/null
+++ b/web/service/metric_history.go
@@ -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"}
diff --git a/web/service/node.go b/web/service/node.go
index bfe77832..122e15fe 100644
--- a/web/service/node.go
+++ b/web/service/node.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/http"
+ "strconv"
"strings"
"time"
@@ -140,6 +141,10 @@ func (s *NodeService) Delete(id int) error {
if mgr := runtime.GetManager(); mgr != nil {
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
}
@@ -163,7 +168,32 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
"uptime_secs": p.UptimeSecs,
"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
diff --git a/web/service/server.go b/web/service/server.go
index 3bb93028..d3caf3aa 100644
--- a/web/service/server.go
+++ b/web/service/server.go
@@ -112,75 +112,27 @@ type ServerService struct {
hasLastCPUSample bool
hasNativeCPUSample bool
emaCPU float64
- cpuHistory []CPUSample
cachedCpuSpeedMhz float64
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 {
- if bucketSeconds <= 0 || maxPoints <= 0 {
- return nil
- }
- cutoff := time.Now().Add(-time.Duration(bucketSeconds*maxPoints) * time.Second).Unix()
- 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:]
+ out := systemMetrics.aggregate("cpu", bucketSeconds, maxPoints)
+ for _, p := range out {
+ p["cpu"] = p["v"]
+ delete(p, "v")
}
return out
}
-// CPUSample single CPU utilization sample
-type CPUSample struct {
- T int64 `json:"t"` // unix seconds
- Cpu float64 `json:"cpu"` // percent 0..100
+// AggregateSystemMetric returns up to maxPoints averaged buckets for any
+// known system metric (see SystemMetricKeys). Output points have keys
+// {"t": unixSec, "v": value}; the caller decides how to format the value.
+func (s *ServerService) AggregateSystemMetric(metric string, bucketSeconds int, maxPoints int) []map[string]any {
+ return systemMetrics.aggregate(metric, bucketSeconds, maxPoints)
}
type LogEntry struct {
@@ -423,18 +375,35 @@ func (s *ServerService) GetStatus(lastStatus *Status) *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) {
- const capacity = 9000 // ~5 hours @ 2s interval
- s.mu.Lock()
- defer s.mu.Unlock()
- p := CPUSample{T: t.Unix(), Cpu: v}
- if n := len(s.cpuHistory); n > 0 && s.cpuHistory[n-1].T == p.T {
- s.cpuHistory[n-1] = p
- } else {
- s.cpuHistory = append(s.cpuHistory, p)
+ systemMetrics.append("cpu", t, v)
+}
+
+// AppendStatusSample writes one tick of every metric we keep — CPU, memory
+// percent, network throughput (bytes/s), online client count, and the three
+// load averages. Called by ServerController.refreshStatus on the same @2s
+// cadence as AppendCpuSample, so all series stay aligned.
+func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
+ if status == nil {
+ return
}
- if len(s.cpuHistory) > capacity {
- s.cpuHistory = s.cpuHistory[len(s.cpuHistory)-capacity:]
+ systemMetrics.append("cpu", t, status.Cpu)
+ 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])
}
}
diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json
index 4550623c..9b5f856c 100644
--- a/web/translation/ar-EG.json
+++ b/web/translation/ar-EG.json
@@ -140,6 +140,8 @@
"xrayStatusError": "فيها غلطة",
"xrayErrorPopoverTitle": "حصل خطأ أثناء تشغيل Xray",
"operationHours": "مدة التشغيل",
+ "systemHistoryTitle": "تاريخ النظام",
+ "trendLast2Min": "آخر دقيقتين",
"systemLoad": "تحميل النظام",
"systemLoadDesc": "متوسط تحميل النظام في الدقائق 1, 5, و15",
"connectionCount": "إحصائيات الاتصال",
@@ -231,6 +233,9 @@
"operate": "القائمة",
"enable": "مفعل",
"remark": "ملاحظة",
+ "node": "نود",
+ "deployTo": "نشر على",
+ "localPanel": "بانل محلي",
"protocol": "بروتوكول",
"port": "بورت",
"portMap": "خريطة البورت",
@@ -380,6 +385,62 @@
"renew": "تجديد تلقائي",
"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": {
"title": "إعدادات البانل",
"save": "حفظ",
diff --git a/web/translation/en-US.json b/web/translation/en-US.json
index a719b8c9..53b287ef 100644
--- a/web/translation/en-US.json
+++ b/web/translation/en-US.json
@@ -141,6 +141,8 @@
"xrayStatusError": "Error",
"xrayErrorPopoverTitle": "An error occurred while running Xray",
"operationHours": "Uptime",
+ "systemHistoryTitle": "System History",
+ "trendLast2Min": "Last 2 minutes",
"systemLoad": "System Load",
"systemLoadDesc": "System load average for the past 1, 5, and 15 minutes",
"connectionCount": "Connection Stats",
diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json
index 7d5a9382..386acf83 100644
--- a/web/translation/es-ES.json
+++ b/web/translation/es-ES.json
@@ -140,6 +140,8 @@
"xrayStatusError": "Error",
"xrayErrorPopoverTitle": "Se produjo un error al ejecutar Xray",
"operationHours": "Tiempo de Funcionamiento",
+ "systemHistoryTitle": "Historial del Sistema",
+ "trendLast2Min": "Últimos 2 minutos",
"systemLoad": "Carga del Sistema",
"systemLoadDesc": "promedio de carga del sistema en los últimos 1, 5 y 15 minutos",
"connectionCount": "Número de Conexiones",
@@ -231,6 +233,9 @@
"operate": "Menú",
"enable": "Habilitar",
"remark": "Notas",
+ "node": "Nodo",
+ "deployTo": "Desplegar en",
+ "localPanel": "Panel local",
"protocol": "Protocolo",
"port": "Puerto",
"portMap": "Puertos de Destino",
@@ -380,6 +385,62 @@
"renew": "Renovación automática",
"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": {
"title": "Configuraciones",
"save": "Guardar",
diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json
index 6d386583..ea510c35 100644
--- a/web/translation/fa-IR.json
+++ b/web/translation/fa-IR.json
@@ -141,6 +141,8 @@
"xrayStatusError": "خطا",
"xrayErrorPopoverTitle": "خطا در هنگام اجرای Xray رخ داد",
"operationHours": "مدتکارکرد",
+ "systemHistoryTitle": "تاریخچه سیستم",
+ "trendLast2Min": "۲ دقیقه اخیر",
"systemLoad": "بارسیستم",
"systemLoadDesc": "میانگین بار سیستم برای 1، 5 و 15 دقیقه گذشته",
"connectionCount": "تعداد کانکشن ها",
diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json
index 2e3fb057..5e341637 100644
--- a/web/translation/id-ID.json
+++ b/web/translation/id-ID.json
@@ -140,6 +140,8 @@
"xrayStatusError": "Kesalahan",
"xrayErrorPopoverTitle": "Terjadi kesalahan saat menjalankan Xray",
"operationHours": "Waktu Aktif",
+ "systemHistoryTitle": "Riwayat Sistem",
+ "trendLast2Min": "2 menit terakhir",
"systemLoad": "Beban Sistem",
"systemLoadDesc": "Rata-rata beban sistem selama 1, 5, dan 15 menit terakhir",
"connectionCount": "Statistik Koneksi",
@@ -231,6 +233,9 @@
"operate": "Menu",
"enable": "Aktifkan",
"remark": "Catatan",
+ "node": "Node",
+ "deployTo": "Terapkan ke",
+ "localPanel": "Panel lokal",
"protocol": "Protokol",
"port": "Port",
"portMap": "Port Mapping",
@@ -380,6 +385,62 @@
"renew": "Perpanjang Otomatis",
"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": {
"title": "Pengaturan Panel",
"save": "Simpan",
diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json
index 59fabe95..c2c85f0c 100644
--- a/web/translation/ja-JP.json
+++ b/web/translation/ja-JP.json
@@ -140,6 +140,8 @@
"xrayStatusError": "エラー",
"xrayErrorPopoverTitle": "Xrayの実行中にエラーが発生しました",
"operationHours": "システム稼働時間",
+ "systemHistoryTitle": "システム履歴",
+ "trendLast2Min": "直近2分",
"systemLoad": "システム負荷",
"systemLoadDesc": "過去1、5、15分間のシステム平均負荷",
"connectionCount": "接続数",
@@ -231,6 +233,9 @@
"operate": "メニュー",
"enable": "有効化",
"remark": "備考",
+ "node": "ノード",
+ "deployTo": "デプロイ先",
+ "localPanel": "ローカルパネル",
"protocol": "プロトコル",
"port": "ポート",
"portMap": "ポートマッピング",
@@ -380,6 +385,62 @@
"renew": "自動更新",
"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": {
"title": "パネル設定",
"save": "保存",
diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json
index e366bc57..5a953d2a 100644
--- a/web/translation/pt-BR.json
+++ b/web/translation/pt-BR.json
@@ -140,6 +140,8 @@
"xrayStatusError": "Erro",
"xrayErrorPopoverTitle": "Ocorreu um erro ao executar o Xray",
"operationHours": "Tempo de Atividade",
+ "systemHistoryTitle": "Histórico do Sistema",
+ "trendLast2Min": "Últimos 2 minutos",
"systemLoad": "Carga do Sistema",
"systemLoadDesc": "Média de carga do sistema nos últimos 1, 5 e 15 minutos",
"connectionCount": "Estatísticas de Conexão",
@@ -231,6 +233,9 @@
"operate": "Menu",
"enable": "Ativado",
"remark": "Observação",
+ "node": "Nó",
+ "deployTo": "Implantar em",
+ "localPanel": "Painel local",
"protocol": "Protocolo",
"port": "Porta",
"portMap": "Porta Mapeada",
@@ -380,6 +385,62 @@
"renew": "Renovação Automática",
"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": {
"title": "Configurações do Painel",
"save": "Salvar",
diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json
index f31a5c02..8335e566 100644
--- a/web/translation/ru-RU.json
+++ b/web/translation/ru-RU.json
@@ -140,6 +140,8 @@
"xrayStatusError": "Ошибка",
"xrayErrorPopoverTitle": "Ошибка при запуске Xray",
"operationHours": "Время работы системы",
+ "systemHistoryTitle": "История системы",
+ "trendLast2Min": "Последние 2 минуты",
"systemLoad": "Нагрузка на систему",
"systemLoadDesc": "Средняя загрузка системы за последние 1, 5 и 15 минут",
"connectionCount": "Количество соединений",
@@ -231,6 +233,9 @@
"operate": "Меню",
"enable": "Включить",
"remark": "Примечание",
+ "node": "Узел",
+ "deployTo": "Развернуть на",
+ "localPanel": "Локальная панель",
"protocol": "Протокол",
"port": "Порт",
"portMap": "Порт-маппинг",
@@ -380,6 +385,62 @@
"renew": "Автопродление",
"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": {
"title": "Настройки",
"save": "Сохранить",
diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json
index 47fda61c..cd267d67 100644
--- a/web/translation/tr-TR.json
+++ b/web/translation/tr-TR.json
@@ -140,6 +140,8 @@
"xrayStatusError": "Hata",
"xrayErrorPopoverTitle": "Xray çalıştırılırken bir hata oluştu",
"operationHours": "Çalışma Süresi",
+ "systemHistoryTitle": "Sistem Geçmişi",
+ "trendLast2Min": "Son 2 dakika",
"systemLoad": "Sistem Yükü",
"systemLoadDesc": "Geçmiş 1, 5 ve 15 dakika için sistem yük ortalaması",
"connectionCount": "Bağlantı İstatistikleri",
@@ -231,6 +233,9 @@
"operate": "Menü",
"enable": "Etkin",
"remark": "Açıklama",
+ "node": "Düğüm",
+ "deployTo": "Şuraya dağıt",
+ "localPanel": "Yerel panel",
"protocol": "Protokol",
"port": "Port",
"portMap": "Port Atama",
@@ -380,6 +385,62 @@
"renew": "Otomatik Yenile",
"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": {
"title": "Panel Ayarları",
"save": "Kaydet",
diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json
index 8e34b90e..2af003d0 100644
--- a/web/translation/uk-UA.json
+++ b/web/translation/uk-UA.json
@@ -140,6 +140,8 @@
"xrayStatusError": "Помилка",
"xrayErrorPopoverTitle": "Під час роботи Xray сталася помилка",
"operationHours": "Час роботи",
+ "systemHistoryTitle": "Історія системи",
+ "trendLast2Min": "Останні 2 хвилини",
"systemLoad": "Завантаження системи",
"systemLoadDesc": "Середнє завантаження системи за останні 1, 5 і 15 хвилин",
"connectionCount": "Статистика з'єднання",
@@ -231,6 +233,9 @@
"operate": "Меню",
"enable": "Увімкнено",
"remark": "Примітка",
+ "node": "Вузол",
+ "deployTo": "Розгорнути на",
+ "localPanel": "Локальна панель",
"protocol": "Протокол",
"port": "Порт",
"portMap": "Порт-перехід",
@@ -380,6 +385,62 @@
"renew": "Автоматичне оновлення",
"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": {
"title": "Параметри панелі",
"save": "Зберегти",
diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json
index c2fd83c2..ef9331d9 100644
--- a/web/translation/vi-VN.json
+++ b/web/translation/vi-VN.json
@@ -140,6 +140,8 @@
"xrayStatusError": "Lỗi",
"xrayErrorPopoverTitle": "Đã xảy ra lỗi khi chạy Xray",
"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",
"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",
@@ -231,6 +233,9 @@
"operate": "Thao tác",
"enable": "Kích hoạt",
"remark": "Chú thích",
+ "node": "Nút",
+ "deployTo": "Triển khai tới",
+ "localPanel": "Panel cục bộ",
"protocol": "Giao thức",
"port": "Cổng",
"portMap": "Cổng tạo",
@@ -380,6 +385,62 @@
"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)"
},
+ "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": {
"title": "Cài đặt",
"save": "Lưu",
diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json
index e0b92164..da2ad92b 100644
--- a/web/translation/zh-CN.json
+++ b/web/translation/zh-CN.json
@@ -140,6 +140,8 @@
"xrayStatusError": "错误",
"xrayErrorPopoverTitle": "运行Xray时发生错误",
"operationHours": "系统正常运行时间",
+ "systemHistoryTitle": "系统历史",
+ "trendLast2Min": "最近 2 分钟",
"systemLoad": "系统负载",
"systemLoadDesc": "过去 1、5 和 15 分钟的系统平均负载",
"connectionCount": "连接数",
@@ -231,6 +233,9 @@
"operate": "菜单",
"enable": "启用",
"remark": "备注",
+ "node": "节点",
+ "deployTo": "部署到",
+ "localPanel": "本地面板",
"protocol": "协议",
"port": "端口",
"portMap": "端口映射",
@@ -380,6 +385,62 @@
"renew": "自动续订",
"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": {
"title": "面板设置",
"save": "保存",
diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json
index 6e415bc7..8ca96193 100644
--- a/web/translation/zh-TW.json
+++ b/web/translation/zh-TW.json
@@ -140,6 +140,8 @@
"xrayStatusError": "錯誤",
"xrayErrorPopoverTitle": "執行Xray時發生錯誤",
"operationHours": "系統正常執行時間",
+ "systemHistoryTitle": "系統歷史",
+ "trendLast2Min": "最近 2 分鐘",
"systemLoad": "系統負載",
"systemLoadDesc": "過去 1、5 和 15 分鐘的系統平均負載",
"connectionCount": "連線數",
@@ -231,6 +233,9 @@
"operate": "選單",
"enable": "啟用",
"remark": "備註",
+ "node": "節點",
+ "deployTo": "部署到",
+ "localPanel": "本機面板",
"protocol": "協議",
"port": "埠",
"portMap": "埠映射",
@@ -380,6 +385,62 @@
"renew": "自動續訂",
"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": {
"title": "面板設定",
"save": "儲存",