mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 13:14:11 +00:00
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>
143 lines
4.1 KiB
Go
143 lines
4.1 KiB
Go
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"}
|