Skip to content

Commit e838002

Browse files
committed
Add Five Minute Buffer for Stats
1 parent 7e457e8 commit e838002

4 files changed

Lines changed: 164 additions & 3 deletions

File tree

internal/api/handlers.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,8 @@ func (h *Handler) handleStats(w http.ResponseWriter, r *http.Request) {
160160
w.Header().Set("Content-Type", "application/json")
161161
w.Header().Set("Access-Control-Allow-Origin", "*")
162162

163-
sysStats := h.stats.Get()
164-
if err := json.NewEncoder(w).Encode(sysStats); err != nil {
163+
history := h.stats.History()
164+
if err := json.NewEncoder(w).Encode(history); err != nil {
165165
log.Printf("Error encoding stats response: %v", err)
166166
}
167167
}
@@ -233,7 +233,10 @@ func (h *Handler) sendAppsToClient(w http.ResponseWriter, flusher http.Flusher)
233233
}
234234

235235
func (h *Handler) sendStatsToClient(w http.ResponseWriter, flusher http.Flusher) {
236-
sysStats := h.stats.Get()
236+
sysStats, ok := h.stats.Latest()
237+
if !ok {
238+
sysStats = h.stats.Get()
239+
}
237240
data, err := json.Marshal(sysStats)
238241
if err != nil {
239242
log.Printf("Error marshaling stats: %v", err)

internal/stats/stats.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package stats
22

33
import (
44
"runtime"
5+
"sync"
56
"time"
67

78
"github.com/shirou/gopsutil/v3/cpu"
@@ -18,11 +19,22 @@ type SystemStats struct {
1819
NetRX *float64 `json:"netRx"`
1920
}
2021

22+
type Sample struct {
23+
Timestamp time.Time
24+
Stats SystemStats
25+
}
26+
2127
type Collector struct {
2228
lastNetStats net.IOCountersStat
2329
lastNetTime time.Time
30+
history []Sample
31+
mu sync.Mutex
32+
stopCh chan struct{}
33+
running bool
2434
}
2535

36+
const historyWindow = 5 * time.Minute
37+
2638
func NewCollector() *Collector {
2739
c := &Collector{
2840
lastNetTime: time.Now(),
@@ -36,6 +48,7 @@ func NewCollector() *Collector {
3648
}
3749

3850
func (c *Collector) Get() SystemStats {
51+
now := time.Now()
3952
stats := SystemStats{
4053
CPU: nil,
4154
RAM: nil,
@@ -62,9 +75,76 @@ func (c *Collector) Get() SystemStats {
6275
stats.NetTX = netTx
6376
stats.NetRX = netRx
6477

78+
c.recordSample(now, stats)
79+
6580
return stats
6681
}
6782

83+
func (c *Collector) Start(interval time.Duration) {
84+
if interval <= 0 {
85+
interval = 2 * time.Second
86+
}
87+
88+
c.mu.Lock()
89+
if c.running {
90+
c.mu.Unlock()
91+
return
92+
}
93+
if c.stopCh == nil {
94+
c.stopCh = make(chan struct{})
95+
}
96+
c.running = true
97+
stopCh := c.stopCh
98+
c.mu.Unlock()
99+
100+
go func() {
101+
ticker := time.NewTicker(interval)
102+
defer ticker.Stop()
103+
for {
104+
select {
105+
case <-ticker.C:
106+
c.Get()
107+
case <-stopCh:
108+
return
109+
}
110+
}
111+
}()
112+
}
113+
114+
func (c *Collector) Stop() {
115+
c.mu.Lock()
116+
if !c.running {
117+
c.mu.Unlock()
118+
return
119+
}
120+
close(c.stopCh)
121+
c.stopCh = nil
122+
c.running = false
123+
c.mu.Unlock()
124+
}
125+
126+
func (c *Collector) History() []Sample {
127+
now := time.Now()
128+
cutoff := now.Add(-historyWindow)
129+
130+
c.mu.Lock()
131+
c.pruneLocked(cutoff)
132+
historyCopy := make([]Sample, len(c.history))
133+
copy(historyCopy, c.history)
134+
c.mu.Unlock()
135+
136+
return historyCopy
137+
}
138+
139+
func (c *Collector) Latest() (SystemStats, bool) {
140+
c.mu.Lock()
141+
defer c.mu.Unlock()
142+
if len(c.history) == 0 {
143+
return SystemStats{}, false
144+
}
145+
return c.history[len(c.history)-1].Stats, true
146+
}
147+
68148
func (c *Collector) getTemperature() *float64 {
69149
// Temperature sensors are platform-specific
70150
if runtime.GOOS == "darwin" {
@@ -105,10 +185,12 @@ func (c *Collector) getNetworkRates() (*float64, *float64) {
105185
}
106186

107187
now := time.Now()
188+
c.mu.Lock()
108189
elapsed := now.Sub(c.lastNetTime).Seconds()
109190

110191
// Avoid division by zero
111192
if elapsed < 0.1 {
193+
c.mu.Unlock()
112194
return nil, nil
113195
}
114196

@@ -121,6 +203,7 @@ func (c *Collector) getNetworkRates() (*float64, *float64) {
121203
// Update last stats
122204
c.lastNetStats = currentStats
123205
c.lastNetTime = now
206+
c.mu.Unlock()
124207

125208
// Convert to KB/s
126209
txKbps := txRate / 1024
@@ -137,4 +220,23 @@ func (c *Collector) getNetworkRates() (*float64, *float64) {
137220
return &txKbps, &rxKbps
138221
}
139222

223+
func (c *Collector) recordSample(timestamp time.Time, stats SystemStats) {
224+
cutoff := timestamp.Add(-historyWindow)
225+
226+
c.mu.Lock()
227+
c.history = append(c.history, Sample{Timestamp: timestamp, Stats: stats})
228+
c.pruneLocked(cutoff)
229+
c.mu.Unlock()
230+
}
231+
232+
func (c *Collector) pruneLocked(cutoff time.Time) {
233+
idx := 0
234+
for idx < len(c.history) && c.history[idx].Timestamp.Before(cutoff) {
235+
idx++
236+
}
237+
if idx > 0 {
238+
c.history = c.history[idx:]
239+
}
240+
}
241+
140242
// formatNetworkRate is no longer needed - removed

main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"os/signal"
1111
"syscall"
12+
"time"
1213

1314
"homedash/internal/api"
1415
"homedash/internal/config"
@@ -36,6 +37,7 @@ func main() {
3637
checker := health.NewChecker(manifestMgr, cfg.CheckInterval, cfg.CheckTimeout)
3738

3839
statsCollector := stats.NewCollector()
40+
statsCollector.Start(2 * time.Second)
3941

4042
uiConfig := api.UIConfig{
4143
TimeFormat24h: cfg.TimeFormat24h,
@@ -82,6 +84,9 @@ func main() {
8284
if checker != nil {
8385
checker.Stop()
8486
}
87+
if statsCollector != nil {
88+
statsCollector.Stop()
89+
}
8590
if manifestMgr != nil {
8691
manifestMgr.Close()
8792
}

web/app.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ document.addEventListener('DOMContentLoaded', () => {
7676
initSparklineCleanup();
7777

7878
fetchApps();
79+
fetchStatsHistory();
7980
connectSSE();
8081
});
8182

@@ -536,6 +537,19 @@ async function fetchApps() {
536537
}
537538
}
538539

540+
async function fetchStatsHistory() {
541+
try {
542+
const res = await fetch('/api/stats');
543+
if (!res.ok) {
544+
throw new Error('Failed to fetch stats history');
545+
}
546+
const historyPayload = await res.json();
547+
hydrateHistory(historyPayload);
548+
} catch (err) {
549+
console.error('Failed to fetch stats history:', err);
550+
}
551+
}
552+
539553
function connectSSE() {
540554
const es = new EventSource('/api/events');
541555

@@ -689,6 +703,43 @@ function renderStats(s) {
689703
updateHostTrendValues(s);
690704
}
691705

706+
function hydrateHistory(samples) {
707+
if (!Array.isArray(samples) || samples.length === 0) {
708+
return;
709+
}
710+
711+
history.host.cpu = [];
712+
history.host.ram = [];
713+
history.host.temp = [];
714+
history.host.netTx = [];
715+
history.host.netRx = [];
716+
717+
samples.forEach((sample) => {
718+
const timestamp = Date.parse(sample.Timestamp);
719+
if (!Number.isFinite(timestamp)) return;
720+
const s = sample.Stats || {};
721+
pushHistory(history.host.cpu, timestamp, s.cpu);
722+
pushHistory(history.host.ram, timestamp, s.ram);
723+
pushHistory(history.host.temp, timestamp, s.temp);
724+
pushHistory(history.host.netTx, timestamp, s.netTx);
725+
pushHistory(history.host.netRx, timestamp, s.netRx);
726+
});
727+
728+
const latest = samples[samples.length - 1];
729+
if (latest && latest.Stats) {
730+
stats = latest.Stats;
731+
}
732+
733+
const now = Date.now();
734+
pruneHistory(history.host.cpu, now);
735+
pruneHistory(history.host.ram, now);
736+
pruneHistory(history.host.temp, now);
737+
pruneHistory(history.host.netTx, now);
738+
pruneHistory(history.host.netRx, now);
739+
renderHostSparklines();
740+
updateHostTrendValues(stats);
741+
}
742+
692743
function applyStatsVisibility() {
693744
document.getElementById('stat-cpu').style.display = config.showCPU ? '' : 'none';
694745
document.getElementById('stat-ram').style.display = config.showRAM ? '' : 'none';

0 commit comments

Comments
 (0)