From 261a1f67876380e351f6ac82eeb72760652e6363 Mon Sep 17 00:00:00 2001 From: Muse Date: Wed, 21 Oct 2026 13:49:43 +0000 Subject: [PATCH] P8(1): restore homepage overview curves on a memory-only sampling ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/pages/index/ConnectionsCard.tsx | 42 ++++- frontend/src/pages/index/IndexPage.tsx | 30 +++- frontend/src/pages/index/ThroughputCard.tsx | 55 ++++++- frontend/src/pages/index/VitalTile.tsx | 55 ++++++- .../src/pages/index/useOverviewHistory.ts | 150 ++++++++++++++++++ internal/web/controller/server.go | 21 +++ internal/web/service/metric_history.go | 45 ++++-- internal/web/service/overview_history_test.go | 67 ++++++++ internal/web/service/server.go | 44 ++++- 9 files changed, 483 insertions(+), 26 deletions(-) create mode 100644 frontend/src/pages/index/useOverviewHistory.ts create mode 100644 internal/web/service/overview_history_test.go diff --git a/frontend/src/pages/index/ConnectionsCard.tsx b/frontend/src/pages/index/ConnectionsCard.tsx index f32222f..a127fb2 100644 --- a/frontend/src/pages/index/ConnectionsCard.tsx +++ b/frontend/src/pages/index/ConnectionsCard.tsx @@ -1,18 +1,38 @@ +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, theme } from 'antd'; +import { Sparkline } from '@/components/viz'; import type { Status } from '@/models/status'; interface ConnectionsCardProps { status: Status; + tcp: number[]; + udp: number[]; + labels: string[]; + isMobile: boolean; } -export default function ConnectionsCard({ status }: ConnectionsCardProps) { +export default function ConnectionsCard({ + status, + tcp, + udp, + labels, + isMobile, +}: ConnectionsCardProps) { const { t } = useTranslation(); const { token } = theme.useToken(); const accent = token.colorPrimary; const udpColor = token.colorTextTertiary; + const referenceLines = useMemo( + () => [ + { y: status.udpCount, color: udpColor, dash: '2 4' }, + { y: status.tcpCount, color: accent, dash: '2 4' }, + ], + [status.tcpCount, status.udpCount, accent, udpColor], + ); + return (
@@ -35,6 +55,26 @@ export default function ConnectionsCard({ status }: ConnectionsCardProps) { {status.udpCount.toLocaleString()}
+ +
+ Math.round(v).toLocaleString()} + referenceLines={referenceLines} + /> +
); } diff --git a/frontend/src/pages/index/IndexPage.tsx b/frontend/src/pages/index/IndexPage.tsx index 70771cf..4f7c58d 100644 --- a/frontend/src/pages/index/IndexPage.tsx +++ b/frontend/src/pages/index/IndexPage.tsx @@ -28,6 +28,7 @@ import VitalTile from './VitalTile'; import ThroughputCard from './ThroughputCard'; import ConnectionsCard from './ConnectionsCard'; import SystemStrip from './SystemStrip'; +import { mean, peak, useOverviewHistory } from './useOverviewHistory'; import type { PanelUpdateInfo } from './PanelUpdateModal'; const JsonEditor = lazy(() => import('@/components/form/JsonEditor')); const PanelUpdateModal = lazy(() => import('./PanelUpdateModal')); @@ -44,6 +45,8 @@ export default function IndexPage() { setMessageInstance(messageApi); }, [messageApi]); + const history = useOverviewHistory(status, fetched && !fetchError); + const [devChannelEnable, setDevChannelEnable] = useState(false); const [panelUpdateInfo, setPanelUpdateInfo] = useState({ currentVersion: '', @@ -192,6 +195,9 @@ export default function IndexPage() { percent={status.cpu.percent} statusColor={status.cpu.color} detail={`${CPUFormatter.cpuCoreFormat(status.cpuCores)} / ${status.logicalPro}T · ${CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}`} + footLeft={`${t('pages.index.avg')} ${mean(history.series.cpu).toFixed(0)}%`} + footRight={`${t('pages.index.peak')} ${peak(history.series.cpu).toFixed(0)}%`} + data={history.series.cpu} isMobile={isMobile} />
- - + +
[ + { y: status.netIO.down, color: downColor, dash: '2 4' }, + { y: status.netIO.up, color: accent, dash: '2 4' }, + ], + [status.netIO.up, status.netIO.down, accent, downColor], + ); + return (
{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,