P8(1): restore homepage overview curves on a memory-only sampling ring
Backend: new overviewMetrics store — a single-tier (2s x 360 = 12min) in-memory ring fed by ServerService.RefreshStatus for exactly the eight overview series (cpu/mem/swap/diskUsage/netUp/netDown/tcpCount/udpCount). No persistence, no gob snapshot, no system-history page: the only surface is GET /panel/api/server/history/:metric/:bucket restricted to those keys, which seeds the frontend curves. ~46KB bounded by construction; the ring starts empty on every panel start. Frontend: restore useOverviewHistory + the VitalTile/ThroughputCard/ ConnectionsCard sparklines and the IndexPage wiring exactly as they were before P7 removed them along with the history module.
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
@@ -39,6 +40,7 @@ func NewServerController(g *gin.RouterGroup) *ServerController {
|
||||
// initRouter sets up the routes for server status, Xray management, and utility endpoints.
|
||||
func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/status", a.status)
|
||||
g.GET("/history/:metric/:bucket", a.getOverviewHistoryBucket)
|
||||
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
||||
g.GET("/getUpdateStatus", a.getUpdateStatus)
|
||||
g.GET("/getConfigJson", a.getConfigJson)
|
||||
@@ -86,6 +88,25 @@ func (a *ServerController) startTask() {
|
||||
// status returns the current server status information.
|
||||
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.serverService.LastStatus(), nil) }
|
||||
|
||||
// getOverviewHistoryBucket returns up to 60 buckets of one homepage
|
||||
// overview metric (see service.OverviewMetricKeys). It backs only the
|
||||
// index-page sparkline seed; the store behind it is the memory-only
|
||||
// overview ring — not the removed system-history API (no other metrics,
|
||||
// no persistence, nothing to browse beyond the overview window).
|
||||
func (a *ServerController) getOverviewHistoryBucket(c *gin.Context) {
|
||||
metric := c.Param("metric")
|
||||
if !slices.Contains(service.OverviewMetricKeys, metric) {
|
||||
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
|
||||
return
|
||||
}
|
||||
bucket, err := strconv.Atoi(c.Param("bucket"))
|
||||
if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
|
||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
||||
return
|
||||
}
|
||||
jsonObj(c, a.serverService.AggregateOverviewMetric(metric, bucket, 60), nil)
|
||||
}
|
||||
|
||||
func (a *ServerController) getFail2banStatus(c *gin.Context) {
|
||||
jsonObj(c, a.serverService.GetFail2banStatus(), nil)
|
||||
}
|
||||
|
||||
@@ -87,8 +87,12 @@ type series struct {
|
||||
}
|
||||
|
||||
func newSeries() *series {
|
||||
s := &series{tiers: make([]*tierBuf, len(metricTiers))}
|
||||
for i, spec := range metricTiers {
|
||||
return newSeriesWithTiers(metricTiers)
|
||||
}
|
||||
|
||||
func newSeriesWithTiers(specs []tierSpec) *series {
|
||||
s := &series{tiers: make([]*tierBuf, len(specs))}
|
||||
for i, spec := range specs {
|
||||
s.tiers[i] = &tierBuf{resolution: spec.resolution, capacity: spec.capacity}
|
||||
}
|
||||
return s
|
||||
@@ -112,14 +116,20 @@ func (s *series) pickTier(spanSeconds int64) *tierBuf {
|
||||
}
|
||||
|
||||
// metricHistory is a thread-safe, in-memory store of tiered series keyed by
|
||||
// arbitrary strings. One singleton lives below: per-node metrics.
|
||||
// arbitrary strings. Two singletons live below: per-node metrics (full
|
||||
// rollup ladder) and the homepage overview series (one small fixed ring).
|
||||
type metricHistory struct {
|
||||
mu sync.Mutex
|
||||
tiers []tierSpec
|
||||
series map[string]*series
|
||||
}
|
||||
|
||||
func newMetricHistory() *metricHistory {
|
||||
return &metricHistory{series: map[string]*series{}}
|
||||
return newMetricHistoryWithTiers(metricTiers)
|
||||
}
|
||||
|
||||
func newMetricHistoryWithTiers(tiers []tierSpec) *metricHistory {
|
||||
return &metricHistory{tiers: tiers, series: map[string]*series{}}
|
||||
}
|
||||
|
||||
// append stores a single sample for the given metric across all tiers.
|
||||
@@ -128,7 +138,7 @@ func (h *metricHistory) append(metric string, t time.Time, v float64) {
|
||||
defer h.mu.Unlock()
|
||||
s := h.series[metric]
|
||||
if s == nil {
|
||||
s = newSeries()
|
||||
s = newSeriesWithTiers(h.tiers)
|
||||
h.series[metric] = s
|
||||
}
|
||||
s.add(t.Unix(), v)
|
||||
@@ -208,12 +218,27 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
|
||||
return out
|
||||
}
|
||||
|
||||
// nodeMetrics is the remaining singleton: per-node CPU/Mem/net series fed by
|
||||
// NodeHeartbeatJob (via UpdateHeartbeat) and read by the node history panel.
|
||||
// The whole-host system series and the xray expvar series that used to live
|
||||
// here were removed with the system-history and xray-metrics features,
|
||||
// together with the gob persistence of the host series.
|
||||
// nodeMetrics holds per-node CPU/Mem/net series fed by NodeHeartbeatJob
|
||||
// (via UpdateHeartbeat) and read by the node history panel. The whole-host
|
||||
// system series and the xray expvar series that used to live here were
|
||||
// removed with the system-history and xray-metrics features, together with
|
||||
// the gob persistence of the host series.
|
||||
var nodeMetrics = newMetricHistory()
|
||||
|
||||
// NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes.
|
||||
var NodeMetricKeys = []string{"cpu", "mem", "netUp", "netDown"}
|
||||
|
||||
// overviewMetrics is the homepage overview store (P8): a deliberately small,
|
||||
// memory-only ring feeding the index-page sparkline seed — NOT a revival of
|
||||
// the removed system-history feature. One tier at the status cadence (2s)
|
||||
// holding 360 samples = a 12-minute window; eight series of 360 samples are
|
||||
// ~46 KB of payload, bounded by construction. Nothing is persisted: the
|
||||
// ring starts empty on every panel start and the overview curves simply
|
||||
// accumulate from there.
|
||||
var overviewMetrics = newMetricHistoryWithTiers([]tierSpec{{resolution: 2, capacity: 360}})
|
||||
|
||||
// OverviewMetricKeys lists the host metric names ServerService writes on
|
||||
// every status sample for the homepage overview. It matches the frontend
|
||||
// useOverviewHistory SERIES_KEYS exactly; the controller validates incoming
|
||||
// history requests against this allow-list.
|
||||
var OverviewMetricKeys = []string{"cpu", "mem", "swap", "diskUsage", "netUp", "netDown", "tcpCount", "udpCount"}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The overview ring is a fresh store per test (the package singleton is fed
|
||||
// by RefreshStatus in production); these tests pin its contract: only the
|
||||
// overview keys are written by AppendOverviewSample, samples come back
|
||||
// through AggregateOverviewMetric, and the window is hard-bounded by the
|
||||
// single tier's capacity no matter how long the panel runs.
|
||||
|
||||
func overviewTestStatus(cpu float64) *Status {
|
||||
st := &Status{Cpu: cpu, TcpCount: 3, UdpCount: 2}
|
||||
st.Mem.Current = 1
|
||||
st.Mem.Total = 4
|
||||
st.Swap.Current = 0
|
||||
st.Swap.Total = 0
|
||||
st.Disk.Current = 1
|
||||
st.Disk.Total = 2
|
||||
st.NetIO.Up = 100
|
||||
st.NetIO.Down = 200
|
||||
return st
|
||||
}
|
||||
|
||||
func TestOverviewRingAppendAndAggregate(t *testing.T) {
|
||||
s := &ServerService{}
|
||||
base := time.Now().Add(-10 * time.Second).Truncate(2 * time.Second)
|
||||
for i := range 5 {
|
||||
s.AppendOverviewSample(base.Add(time.Duration(i*2)*time.Second), overviewTestStatus(float64(i)))
|
||||
}
|
||||
// overviewMetrics is the package singleton; the samples just appended
|
||||
// must be visible through the aggregate the controller serves.
|
||||
out := s.AggregateOverviewMetric("cpu", 2, 60)
|
||||
if len(out) == 0 {
|
||||
t.Fatal("AggregateOverviewMetric(cpu) returned no points after appends")
|
||||
}
|
||||
last := out[len(out)-1]
|
||||
if got, _ := last["v"].(float64); got != 4 {
|
||||
t.Fatalf("last cpu point = %v, want 4", last["v"])
|
||||
}
|
||||
for _, key := range OverviewMetricKeys {
|
||||
if pts := s.AggregateOverviewMetric(key, 2, 60); len(pts) == 0 {
|
||||
t.Fatalf("overview key %q has no points after AppendOverviewSample", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverviewRingIsBounded(t *testing.T) {
|
||||
h := newMetricHistoryWithTiers([]tierSpec{{resolution: 2, capacity: 360}})
|
||||
base := time.Unix(1_700_000_000, 0)
|
||||
// Feed ten windows' worth of samples; the ring must not grow past its
|
||||
// single tier capacity.
|
||||
for i := range 3600 {
|
||||
h.append("cpu", base.Add(time.Duration(i*2)*time.Second), float64(i))
|
||||
}
|
||||
// readSamples returns the closed buckets plus the still-open one, so the
|
||||
// hard bound is capacity+1.
|
||||
raw := h.series["cpu"].tiers[0].readSamples()
|
||||
if len(raw) > 361 {
|
||||
t.Fatalf("overview tier retained %d samples, want <= 361 (capacity + open bucket)", len(raw))
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
t.Fatal("overview tier retained no samples")
|
||||
}
|
||||
}
|
||||
@@ -177,8 +177,8 @@ var allowedHistoryBuckets = map[int]bool{
|
||||
}
|
||||
|
||||
// IsAllowedHistoryBucket reports whether a bucket-seconds value is in the
|
||||
// whitelist used by /server/history, /server/cpuHistory, /server/xrayMetricsHistory,
|
||||
// /server/xrayObservatoryHistory, and /nodes/history.
|
||||
// whitelist used by the time-series aggregation endpoints
|
||||
// (/server/history for the homepage overview, /nodes/history).
|
||||
func IsAllowedHistoryBucket(bucketSeconds int) bool {
|
||||
return allowedHistoryBuckets[bucketSeconds]
|
||||
}
|
||||
@@ -242,7 +242,7 @@ func (s *ServerService) isFail2banInstalled() bool {
|
||||
// Returns the new snapshot (may be nil if collection failed). Called by the
|
||||
// background ticker; the caller is responsible for any side effects
|
||||
// (websocket broadcast). The realtime status served to the UI comes from
|
||||
// this snapshot — the removed system-history series used to be fed here too.
|
||||
// this snapshot; the homepage overview ring is fed here too.
|
||||
func (s *ServerService) RefreshStatus() *Status {
|
||||
next := s.GetStatus(s.LastStatus())
|
||||
if next == nil {
|
||||
@@ -251,9 +251,47 @@ func (s *ServerService) RefreshStatus() *Status {
|
||||
s.lastStatusMu.Lock()
|
||||
s.lastStatus = next
|
||||
s.lastStatusMu.Unlock()
|
||||
s.AppendOverviewSample(time.Now(), next)
|
||||
return next
|
||||
}
|
||||
|
||||
// AppendOverviewSample writes one tick of the homepage overview metrics —
|
||||
// CPU, memory/swap/disk percents, network throughput (bytes/s) and TCP/UDP
|
||||
// connection counts — into the memory-only overview ring. Called by
|
||||
// RefreshStatus on the @2s status cadence, so all series stay aligned.
|
||||
// Only the overview keys are kept; this is not the removed system-history
|
||||
// sampler (no extra series, no persistence).
|
||||
func (s *ServerService) AppendOverviewSample(t time.Time, status *Status) {
|
||||
if status == nil {
|
||||
return
|
||||
}
|
||||
overviewMetrics.append("cpu", t, status.Cpu)
|
||||
if status.Mem.Total > 0 {
|
||||
overviewMetrics.append("mem", t, float64(status.Mem.Current)*100.0/float64(status.Mem.Total))
|
||||
}
|
||||
if status.Swap.Total > 0 {
|
||||
overviewMetrics.append("swap", t, float64(status.Swap.Current)*100.0/float64(status.Swap.Total))
|
||||
} else {
|
||||
overviewMetrics.append("swap", t, 0)
|
||||
}
|
||||
overviewMetrics.append("netUp", t, float64(status.NetIO.Up))
|
||||
overviewMetrics.append("netDown", t, float64(status.NetIO.Down))
|
||||
if status.Disk.Total > 0 {
|
||||
overviewMetrics.append("diskUsage", t, float64(status.Disk.Current)*100.0/float64(status.Disk.Total))
|
||||
}
|
||||
overviewMetrics.append("tcpCount", t, float64(status.TcpCount))
|
||||
overviewMetrics.append("udpCount", t, float64(status.UdpCount))
|
||||
}
|
||||
|
||||
// AggregateOverviewMetric returns up to maxPoints averaged buckets of size
|
||||
// bucketSeconds for one overview metric (see OverviewMetricKeys). Output
|
||||
// points have keys {"t": unixSec, "v": value}. It backs the seed fetch of
|
||||
// the homepage overview curves; the ring behind it is memory-only, so
|
||||
// shortly after a panel restart it legitimately returns few or no points.
|
||||
func (s *ServerService) AggregateOverviewMetric(metric string, bucketSeconds int, maxPoints int) []map[string]any {
|
||||
return overviewMetrics.aggregate(metric, bucketSeconds, maxPoints)
|
||||
}
|
||||
|
||||
func getPublicIP(url string) string {
|
||||
client := &http.Client{
|
||||
Timeout: 3 * time.Second,
|
||||
|
||||
Reference in New Issue
Block a user