From 8cc39585ea0c04d6e1508e2aaae6b8f25a3cd603 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 16 Jan 2026 14:50:10 +0800 Subject: [PATCH] feat: improve network status graph with sparklines and responsive width --- cmd/status/metrics.go | 73 +++++++++++++++++++++++++++++------ cmd/status/metrics_network.go | 48 +++++++++++++++++++---- cmd/status/view.go | 51 +++++++++++++++--------- 3 files changed, 134 insertions(+), 38 deletions(-) diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go index 86f958b..c9bdc5c 100644 --- a/cmd/status/metrics.go +++ b/cmd/status/metrics.go @@ -12,6 +12,50 @@ import ( "github.com/shirou/gopsutil/v3/net" ) +// RingBuffer is a fixed-size circular buffer for float64 values. +type RingBuffer struct { + data []float64 + index int // Current insert position (oldest value) + size int // Number of valid elements + cap int // Total capacity +} + +func NewRingBuffer(capacity int) *RingBuffer { + return &RingBuffer{ + data: make([]float64, capacity), + cap: capacity, + } +} + +func (rb *RingBuffer) Add(val float64) { + rb.data[rb.index] = val + rb.index = (rb.index + 1) % rb.cap + if rb.size < rb.cap { + rb.size++ + } +} + +// Slice returns the data in chronological order (oldest to newest). +func (rb *RingBuffer) Slice() []float64 { + if rb.size == 0 { + return nil + } + res := make([]float64, rb.size) + if rb.size < rb.cap { + // Not full yet: data is at [0 : size] + copy(res, rb.data[:rb.size]) + } else { + // Full: oldest is at index, then wrapped + // data: [4, 5, 1, 2, 3] (cap=5, index=2, oldest=1) + // want: [1, 2, 3, 4, 5] + // part1: [index:] -> [1, 2, 3] + // part2: [:index] -> [4, 5] + copy(res, rb.data[rb.index:]) + copy(res[rb.cap-rb.index:], rb.data[:rb.index]) + } + return res +} + type MetricsSnapshot struct { CollectedAt time.Time Host string @@ -105,12 +149,13 @@ type NetworkStatus struct { TxRateMBs float64 IP string } +// NetworkHistory holds the global network usage history. type NetworkHistory struct { RxHistory []float64 TxHistory []float64 } -const NetworkHistorySize = 20 // number of checks to keep +const NetworkHistorySize = 120 // Increased history size for wider graph type ProxyStatus struct { Enabled bool @@ -161,18 +206,21 @@ type Collector struct { lastBT []BluetoothDevice // Fast metrics (1s). - prevNet map[string]net.IOCountersStat - lastNetAt time.Time - netHistory NetworkHistory - lastGPUAt time.Time - cachedGPU []GPUStatus - prevDiskIO disk.IOCountersStat - lastDiskAt time.Time + prevNet map[string]net.IOCountersStat + lastNetAt time.Time + rxHistoryBuf *RingBuffer + txHistoryBuf *RingBuffer + lastGPUAt time.Time + cachedGPU []GPUStatus + prevDiskIO disk.IOCountersStat + lastDiskAt time.Time } func NewCollector() *Collector { return &Collector{ - prevNet: make(map[string]net.IOCountersStat), + prevNet: make(map[string]net.IOCountersStat), + rxHistoryBuf: NewRingBuffer(NetworkHistorySize), + txHistoryBuf: NewRingBuffer(NetworkHistorySize), } } @@ -271,8 +319,11 @@ func (c *Collector) Collect() (MetricsSnapshot, error) { Disks: diskStats, DiskIO: diskIO, Network: netStats, - NetworkHistory: c.netHistory, - Proxy: proxyStats, + NetworkHistory: NetworkHistory{ + RxHistory: c.rxHistoryBuf.Slice(), + TxHistory: c.txHistoryBuf.Slice(), + }, + Proxy: proxyStats, Batteries: batteryStats, Thermal: thermalStats, Sensors: sensorStats, diff --git a/cmd/status/metrics_network.go b/cmd/status/metrics_network.go index f38f69d..d714135 100644 --- a/cmd/status/metrics_network.go +++ b/cmd/status/metrics_network.go @@ -75,18 +75,50 @@ func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) { totalRx += r.RxRateMBs totalTx += r.TxRateMBs } - c.netHistory.RxHistory = append(c.netHistory.RxHistory, totalRx) - c.netHistory.TxHistory = append(c.netHistory.TxHistory, totalTx) - if len(c.netHistory.RxHistory) > NetworkHistorySize { - c.netHistory.RxHistory = c.netHistory.RxHistory[len(c.netHistory.RxHistory)-NetworkHistorySize:] - } - if len(c.netHistory.TxHistory) > NetworkHistorySize { - c.netHistory.TxHistory = c.netHistory.TxHistory[len(c.netHistory.TxHistory)-NetworkHistorySize:] - } + + // Update history using the global/aggregated stats + c.rxHistoryBuf.Add(totalRx) + c.txHistoryBuf.Add(totalTx) return result, nil } +// Rewriting slightly more of the file to inject history update logic correctly inside the loop. +// The previous "tail" logic for totalRx history was actually not what I wrote in the *previous* step +// (Wait, did the `pull` bring in my changes? No, I implemented them, then did `git reset` then `git pull`. +// The `git pull` brought in the changes from `dev`. +// In `dev` (which I pulled), the code at the bottom of `collectNetwork` (lines 73-86 in View) +// seems to be appending to `c.netHistory.RxHistory`. +// So the merged code uses a GLOBAL history in `MetricsSnapshot` (or `Collector`?) +// Let's check `metrics.go` again. +// In the pulled `metrics.go` (before my generic change): +// type NetworkHistory struct { RxHistory []float64 ... } +// type Collector struct { ... netHistory NetworkHistory ... } +// So the user's merged code uses a SINGLE global history struct, not a map per interface. +// This simplifies things! It aggregates ALL traffic history? +// Or does it just append the totals? +// Line 73-77 calculates `totalRx`. +// Line 78 appends `totalRx` to `c.netHistory.RxHistory`. +// Yes, it tracks GLOBAL network usage. +// So I should adapt my RingBuffer to replace `NetworkHistory` struct usage. + +// I will replace `collectNetwork` to use the new `map[string][2]*RingBuffer`? +// User asked to "optimize". Global history is easier for the UI ("Total Down/Up"). +// Per-interface history is more detailed but if UI only shows one sparkline, Global is better. +// The user said "responsive width... reference Proxy System". +// And "generic history structure". +// If I use RingBuffer, I should probably stick to the GLOBAL history design if that's what `dev` has, +// OR change `Collector` to use `RingBuffer` for that global history. +// +// Let's look at `metrics.go` again (my previous edit). +// I changed `netHistory` to `map[string][2]*RingBuffer`. +// This contradicts the `dev` branch's `NetworkHistory` (global). +// I should probably revert to a SINGLE `RingBuffer` pair for global history if the UI expects global. +// Usage in `view.go` (which I haven't read fully yet after pull) will tell me. +// If `view.go` uses `m.NetworkHistory.RxHistory`, then it expects global. +// Let's check `view.go` first before editing `metrics_network.go`. + + func getInterfaceIPs() map[string]string { result := make(map[string]string) ifaces, err := net.Interfaces() diff --git a/cmd/status/view.go b/cmd/status/view.go index 47991da..828ecf5 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -201,20 +201,7 @@ func getScoreStyle(score int) lipgloss.Style { } } -func buildCards(m MetricsSnapshot, _ int) []cardData { - cards := []cardData{ - renderCPUCard(m.CPU, m.Thermal), - renderMemoryCard(m.Memory), - renderDiskCard(m.Disks, m.DiskIO), - renderBatteryCard(m.Batteries, m.Thermal), - renderProcessCard(m.TopProcesses), - renderNetworkCard(m.Network, m.NetworkHistory, m.Proxy), - } - if hasSensorData(m.Sensors) { - cards = append(cards, renderSensorsCard(m.Sensors)) - } - return cards -} + func hasSensorData(sensors []SensorReading) bool { for _, s := range sensors { @@ -417,6 +404,21 @@ func renderProcessCard(procs []ProcessInfo) cardData { return cardData{icon: iconProcs, title: "Processes", lines: lines} } +func buildCards(m MetricsSnapshot, width int) []cardData { + cards := []cardData{ + renderCPUCard(m.CPU, m.Thermal), + renderMemoryCard(m.Memory), + renderDiskCard(m.Disks, m.DiskIO), + renderBatteryCard(m.Batteries, m.Thermal), + renderProcessCard(m.TopProcesses), + renderNetworkCard(m.Network, m.NetworkHistory, m.Proxy, width), + } + if hasSensorData(m.Sensors) { + cards = append(cards, renderSensorsCard(m.Sensors)) + } + return cards +} + func miniBar(percent float64) string { filled := min(int(percent/20), 5) if filled < 0 { @@ -425,7 +427,7 @@ func miniBar(percent float64) string { return colorizePercent(percent, strings.Repeat("▮", filled)+strings.Repeat("▯", 5-filled)) } -func renderNetworkCard(netStats []NetworkStatus, history NetworkHistory, proxy ProxyStatus) cardData { +func renderNetworkCard(netStats []NetworkStatus, history NetworkHistory, proxy ProxyStatus, cardWidth int) cardData { var lines []string var totalRx, totalTx float64 var primaryIP string @@ -441,9 +443,21 @@ func renderNetworkCard(netStats []NetworkStatus, history NetworkHistory, proxy P if len(netStats) == 0 { lines = []string{subtleStyle.Render("Collecting...")} } else { + // Calculate dynamic width + // Layout: "Down " (7) + graph + " " (2) + rate (approx 10-12) + // Safe margin: 22 chars. + // We target 16 chars to match progressBar implementation for visual consistency. + graphWidth := cardWidth - 22 + if graphWidth < 5 { + graphWidth = 5 + } + if graphWidth > 16 { + graphWidth = 16 // Match progressBar fixed width + } + // sparkline graphs - rxSparkline := sparkline(history.RxHistory, totalRx) - txSparkline := sparkline(history.TxHistory, totalTx) + rxSparkline := sparkline(history.RxHistory, totalRx, graphWidth) + txSparkline := sparkline(history.TxHistory, totalTx, graphWidth) lines = append(lines, fmt.Sprintf("Down %s %s", rxSparkline, formatRate(totalRx))) lines = append(lines, fmt.Sprintf("Up %s %s", txSparkline, formatRate(totalTx))) // Show proxy and IP on one line. @@ -477,8 +491,7 @@ func netBar(rate float64) string { } // 8 levels: ▁▂▃▄▅▆▇█ -func sparkline(history []float64, current float64) string { - const width = 16 +func sparkline(history []float64, current float64, width int) string { blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} data := make([]float64, 0, width)