{t('pages.index.overallSpeed')}
-
{t('pages.index.throughputSub')}
+
+ {`${t('pages.index.throughputSub')} · ${t('pages.index.peak')} ${SizeFormatter.speedFormat(peak(down))}`}
+
@@ -36,6 +59,26 @@ export default function ThroughputCard({ status }: ThroughputCardProps) {
+
+
+
+
{t('pages.index.sent')}
@@ -46,6 +89,14 @@ export default function ThroughputCard({ status }: ThroughputCardProps) {
{t('pages.index.received')}
{SizeFormatter.sizeFormat(status.netTraffic.recv)}
+
+
+
{t('pages.index.avgWindow')}
+
+ {`↑ ${SizeFormatter.speedFormat(mean(up))}`}{' '}
+ {`↓ ${SizeFormatter.speedFormat(mean(down))}`}
+
+
);
diff --git a/frontend/src/pages/index/VitalTile.tsx b/frontend/src/pages/index/VitalTile.tsx
index 2e3ef3b..24bf49b 100644
--- a/frontend/src/pages/index/VitalTile.tsx
+++ b/frontend/src/pages/index/VitalTile.tsx
@@ -1,5 +1,9 @@
+import { useMemo } from 'react';
import type { ReactNode } from 'react';
-import { Card } from 'antd';
+import { Card, theme } from 'antd';
+
+import { Sparkline } from '@/components/viz';
+import { mean, peak } from './useOverviewHistory';
interface VitalTileProps {
icon: ReactNode;
@@ -7,11 +11,31 @@ interface VitalTileProps {
percent: number;
statusColor: string;
detail: string;
- footLeft?: string;
+ footLeft: string;
+ footRight: string;
+ data: number[];
isMobile: boolean;
}
-export default function VitalTile({ icon, label, percent, detail, footLeft }: VitalTileProps) {
+export default function VitalTile({
+ icon,
+ label,
+ percent,
+ statusColor,
+ detail,
+ footLeft,
+ footRight,
+ data,
+ isMobile,
+}: VitalTileProps) {
+ const { token } = theme.useToken();
+ const meanColor = token.colorTextTertiary;
+
+ const referenceLines = useMemo(
+ () => (data.length > 1 ? [{ y: mean(data), dash: '3 4', color: meanColor }] : []),
+ [data, meanColor],
+ );
+
return (
@@ -26,11 +50,26 @@ export default function VitalTile({ icon, label, percent, detail, footLeft }: Vi
{detail}
- {footLeft ? (
-
- {footLeft}
-
- ) : null}
+
+ {footLeft}
+ {footRight}
+
+
+
+ 0 ? null : 100}
+ stroke={statusColor}
+ referenceLines={referenceLines}
+ yFormatter={(v) => `${v.toFixed(0)}%`}
+ name1={label}
+ />
+
);
}
diff --git a/frontend/src/pages/index/useOverviewHistory.ts b/frontend/src/pages/index/useOverviewHistory.ts
new file mode 100644
index 0000000..6266a24
--- /dev/null
+++ b/frontend/src/pages/index/useOverviewHistory.ts
@@ -0,0 +1,150 @@
+import { useEffect, useMemo, useState } from 'react';
+
+import { HttpUtil, TimeFormatter } from '@/utils';
+import type { Status } from '@/models/status';
+
+const OVERVIEW_WINDOW = 72;
+const SEED_BUCKET_SECONDS = 2;
+
+const SERIES_KEYS = [
+ 'cpu',
+ 'mem',
+ 'swap',
+ 'diskUsage',
+ 'netUp',
+ 'netDown',
+ 'tcpCount',
+ 'udpCount',
+] as const;
+
+export type OverviewSeriesKey = (typeof SERIES_KEYS)[number];
+
+export interface OverviewHistory {
+ series: Record
;
+ labels: string[];
+}
+
+interface HistoryPoint {
+ t: number;
+ v: number;
+}
+
+interface HistoryWindow {
+ series: Record;
+ times: number[];
+}
+
+function emptySeries(): Record {
+ return Object.fromEntries(SERIES_KEYS.map((key) => [key, [] as number[]])) as Record<
+ OverviewSeriesKey,
+ number[]
+ >;
+}
+
+function emptyWindow(): HistoryWindow {
+ return { series: emptySeries(), times: [] };
+}
+
+function sampleOf(status: Status): Record {
+ return {
+ cpu: status.cpu.percent,
+ mem: status.mem.percent,
+ swap: status.swap.percent,
+ diskUsage: status.disk.percent,
+ netUp: status.netIO.up,
+ netDown: status.netIO.down,
+ tcpCount: status.tcpCount,
+ udpCount: status.udpCount,
+ };
+}
+
+function tailWindow(values: T[]): T[] {
+ return values.slice(-OVERVIEW_WINDOW);
+}
+
+export function mean(values: number[]): number {
+ if (values.length === 0) return 0;
+ let total = 0;
+ for (const v of values) total += v;
+ return total / values.length;
+}
+
+export function peak(values: number[]): number {
+ let max = 0;
+ for (const v of values) if (v > max) max = v;
+ return max;
+}
+
+/* the seed bucket must be in the backend's allowedHistoryBuckets whitelist;
+ 2s is the smallest and matches the status poll cadence */
+export function useOverviewHistory(status: Status, hasData: boolean): OverviewHistory {
+ const [trend, setTrend] = useState(emptyWindow);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const seed = async () => {
+ const responses = new Map();
+ await Promise.all(
+ SERIES_KEYS.map(async (key) => {
+ const msg = await HttpUtil.get(
+ `/panel/api/server/history/${key}/${SEED_BUCKET_SECONDS}`,
+ undefined,
+ { silent: true },
+ );
+ if (msg?.success && Array.isArray(msg.obj)) responses.set(key, msg.obj);
+ }),
+ );
+ if (cancelled || responses.size === 0) return;
+
+ let axis: HistoryPoint[] = [];
+ for (const points of responses.values()) {
+ if (points.length > axis.length) axis = points;
+ }
+ axis = tailWindow(axis);
+ if (axis.length === 0) return;
+
+ const seedTimes = axis.map((p) => Number(p.t) || 0);
+ const seedSeries = emptySeries();
+ for (const key of SERIES_KEYS) {
+ const byTs = new Map();
+ for (const p of responses.get(key) ?? []) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
+ seedSeries[key] = seedTimes.map((ts) => byTs.get(ts) ?? 0);
+ }
+
+ setTrend((prev) => {
+ const merged = emptyWindow();
+ merged.times = tailWindow(seedTimes.concat(prev.times));
+ for (const key of SERIES_KEYS) {
+ merged.series[key] = tailWindow(seedSeries[key].concat(prev.series[key]));
+ }
+ return merged;
+ });
+ };
+
+ seed().catch(() => undefined);
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ // Each polled status is appended during render; an effect would show the
+ // chart one sample behind the numbers beside it.
+ const [sampledStatus, setSampledStatus] = useState(null);
+ if (hasData && status !== sampledStatus) {
+ setSampledStatus(status);
+ setTrend((prev) => {
+ const point = sampleOf(status);
+ const next = emptyWindow();
+ next.times = tailWindow(prev.times.concat(Math.floor(Date.now() / 1000)));
+ for (const key of SERIES_KEYS) {
+ next.series[key] = tailWindow(prev.series[key].concat(point[key]));
+ }
+ return next;
+ });
+ }
+
+ const labels = useMemo(() => trend.times.map(TimeFormatter.formatClock), [trend.times]);
+
+ return useMemo(() => ({ series: trend.series, labels }), [trend.series, labels]);
+}
diff --git a/internal/web/controller/server.go b/internal/web/controller/server.go
index 8584766..1a40422 100644
--- a/internal/web/controller/server.go
+++ b/internal/web/controller/server.go
@@ -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)
}
diff --git a/internal/web/service/metric_history.go b/internal/web/service/metric_history.go
index b711d66..3079c94 100644
--- a/internal/web/service/metric_history.go
+++ b/internal/web/service/metric_history.go
@@ -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"}
diff --git a/internal/web/service/overview_history_test.go b/internal/web/service/overview_history_test.go
new file mode 100644
index 0000000..86011a3
--- /dev/null
+++ b/internal/web/service/overview_history_test.go
@@ -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")
+ }
+}
diff --git a/internal/web/service/server.go b/internal/web/service/server.go
index 55c3f4b..0952df3 100644
--- a/internal/web/service/server.go
+++ b/internal/web/service/server.go
@@ -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,