summaryrefslogtreecommitdiff
path: root/internal/display
diff options
context:
space:
mode:
Diffstat (limited to 'internal/display')
-rw-r--r--internal/display/disk.go220
-rw-r--r--internal/display/display.go60
-rw-r--r--internal/display/display_test.go226
-rw-r--r--internal/display/hittest.go23
-rw-r--r--internal/display/tooltip.go36
5 files changed, 550 insertions, 15 deletions
diff --git a/internal/display/disk.go b/internal/display/disk.go
new file mode 100644
index 0000000..0143b68
--- /dev/null
+++ b/internal/display/disk.go
@@ -0,0 +1,220 @@
+package display
+
+import (
+ "regexp"
+ "sort"
+ "strings"
+
+ "codeberg.org/snonux/loadbars/internal/constants"
+ "codeberg.org/snonux/loadbars/internal/stats"
+ "github.com/veandco/go-sdl2/sdl"
+)
+
+// partitionSuffix matches trailing partition numbers on SCSI-style names (sda1, vda2, xvda3)
+// and NVMe partition suffixes (nvme0n1p1). Loop, ram, and dm- devices are handled separately.
+var partitionSuffix = regexp.MustCompile(`^(sd|vd|xvd|hd)[a-z]+\d+$`)
+var nvmePartition = regexp.MustCompile(`^nvme\d+n\d+p\d+$`)
+
+// isWholeDisk returns true if the device name represents a whole disk (not a partition,
+// loop, ram, or device-mapper device). Used to filter /proc/diskstats entries.
+func isWholeDisk(name string) bool {
+ if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "dm-") {
+ return false
+ }
+ if partitionSuffix.MatchString(name) {
+ return false
+ }
+ if nvmePartition.MatchString(name) {
+ return false
+ }
+ return true
+}
+
+// sortedDiskNames returns the list of disk device names to display based on the disk mode.
+// In aggregate mode, returns ["all"]; in device mode, returns sorted whole-disk names;
+// in off mode, returns nil.
+func sortedDiskNames(disk map[string]stats.DiskStamp, diskMode int) []string {
+ switch diskMode {
+ case constants.DiskModeAggregate:
+ return []string{"all"}
+ case constants.DiskModeDevices:
+ var names []string
+ for dev := range disk {
+ if isWholeDisk(dev) {
+ names = append(names, dev)
+ }
+ }
+ sort.Strings(names)
+ return names
+ default:
+ return nil
+ }
+}
+
+// sumAllDisks sums sectors and picks the latest timestamp across all whole-disk devices.
+func sumAllDisks(disk map[string]stats.DiskStamp) stats.DiskStamp {
+ var sum stats.DiskStamp
+ for dev, ds := range disk {
+ if !isWholeDisk(dev) {
+ continue
+ }
+ sum.SectorsRead += ds.SectorsRead
+ sum.SectorsWrite += ds.SectorsWrite
+ sum.IoTicks += ds.IoTicks
+ if ds.Stamp > sum.Stamp {
+ sum.Stamp = ds.Stamp
+ }
+ }
+ return sum
+}
+
+// updateDiskPeak updates the auto-scale disk peak (bytes/sec) with slow decay.
+// When diskMax > 0, the fixed value is used instead.
+func updateDiskPeak(snap map[string]*stats.HostStats, state *runState, diskMax float64) {
+ if diskMax > 0 {
+ state.diskPeak = diskMax
+ return
+ }
+ // Slow per-frame decay toward idle baseline
+ state.diskPeak *= 0.9999
+ const floorBps = 1048576.0 // 1 MB/s floor
+ if state.diskPeak < floorBps {
+ state.diskPeak = floorBps
+ }
+ // Scan current disk data to find if any host exceeds the peak
+ for host, h := range snap {
+ if h == nil || h.Disk == nil {
+ continue
+ }
+ diskNames := sortedDiskNames(h.Disk, state.diskMode)
+ for _, name := range diskNames {
+ key := host + ";disk;" + name
+ var cur stats.DiskStamp
+ if name == "all" {
+ cur = sumAllDisks(h.Disk)
+ } else {
+ cur = h.Disk[name]
+ }
+ prev, ok := state.prevDisk[key]
+ if !ok || cur.Stamp <= prev.Stamp || prev.Stamp == 0 {
+ continue
+ }
+ dt := cur.Stamp - prev.Stamp
+ if dt <= 0 {
+ continue
+ }
+ readBps := float64(cur.SectorsRead-prev.SectorsRead) * 512 / dt
+ writeBps := float64(cur.SectorsWrite-prev.SectorsWrite) * 512 / dt
+ totalBps := readBps + writeBps
+ if totalBps > state.diskPeak {
+ state.diskPeak = totalBps
+ }
+ }
+ }
+}
+
+// drawDiskBarSmoothed draws a single disk bar with read (top, purple) and write (bottom,
+// darker purple). Returns the current DiskStamp to be stored as previous for the next frame.
+func drawDiskBarSmoothed(renderer *sdl.Renderer, cur stats.DiskStamp, cfg *runState, smoothed *struct{ readPct, writePct float64 }, prev stats.DiskStamp, factor float64, barW, x, y, barH int32, extended bool) stats.DiskStamp {
+ // Clear this slot to black
+ renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255)
+ renderer.FillRect(&sdl.Rect{X: x, Y: y, W: barW, H: barH})
+
+ // Only recompute when the collector has provided new data (same guard as net bars).
+ if cur.Stamp > prev.Stamp && prev.Stamp > 0 {
+ prev = smoothDiskUtilization(cur, prev, cfg, smoothed, factor)
+ } else if prev.Stamp == 0 {
+ // First sample: record but don't draw yet (no delta available)
+ return cur
+ }
+
+ drawDiskHalves(renderer, smoothed, x, y, barW, barH)
+
+ // In extended mode, overlay a utilization % line
+ if extended {
+ drawDiskUtilLine(renderer, cur, prev, cfg, x, y, barW, barH)
+ }
+ return prev
+}
+
+// smoothDiskUtilization computes read/write throughput as % of diskPeak and smooths.
+func smoothDiskUtilization(cur, prev stats.DiskStamp, state *runState, smoothed *struct{ readPct, writePct float64 }, factor float64) stats.DiskStamp {
+ peak := state.diskPeak
+ if peak <= 0 {
+ peak = 1048576 // 1 MB/s fallback
+ }
+ dt := cur.Stamp - prev.Stamp
+ if dt > 0 {
+ deltaRead := cur.SectorsRead - prev.SectorsRead
+ deltaWrite := cur.SectorsWrite - prev.SectorsWrite
+ if deltaRead < 0 {
+ deltaRead = 0
+ }
+ if deltaWrite < 0 {
+ deltaWrite = 0
+ }
+ readBps := float64(deltaRead) * 512 / dt
+ writeBps := float64(deltaWrite) * 512 / dt
+ targetRead := 100 * readBps / peak
+ targetWrite := 100 * writeBps / peak
+ smoothed.readPct += (targetRead - smoothed.readPct) * factor
+ smoothed.writePct += (targetWrite - smoothed.writePct) * factor
+ }
+ return cur // advance the baseline
+}
+
+// drawDiskHalves draws read from top (purple) and write from bottom (darker purple).
+func drawDiskHalves(renderer *sdl.Renderer, smoothed *struct{ readPct, writePct float64 }, x, y, barW, barH int32) {
+ halfH := barH / 2
+ pxPerPct := float64(barH) / 100.0
+
+ // Read from top (purple)
+ readH := int32(smoothed.readPct * pxPerPct)
+ if readH > halfH {
+ readH = halfH
+ }
+ if readH > 0 {
+ renderer.SetDrawColor(constants.DiskRead.R, constants.DiskRead.G, constants.DiskRead.B, 255)
+ renderer.FillRect(&sdl.Rect{X: x, Y: y, W: barW, H: readH})
+ }
+
+ // Write from bottom (darker purple)
+ writeH := int32(smoothed.writePct * pxPerPct)
+ if writeH > halfH {
+ writeH = halfH
+ }
+ if writeH > 0 {
+ renderer.SetDrawColor(constants.DiskWrite.R, constants.DiskWrite.G, constants.DiskWrite.B, 255)
+ renderer.FillRect(&sdl.Rect{X: x, Y: y + barH - writeH, W: barW, H: writeH})
+ }
+}
+
+// drawDiskUtilLine draws a 3px-thick horizontal line showing disk utilization %
+// (fraction of time the device had I/O in progress) in extended mode.
+func drawDiskUtilLine(renderer *sdl.Renderer, cur, prev stats.DiskStamp, state *runState, x, y, barW, barH int32) {
+ dt := cur.Stamp - prev.Stamp
+ if dt <= 0 {
+ return
+ }
+ // IoTicks is cumulative ms; utilization = delta_io_ticks / (dt * 1000)
+ deltaIo := cur.IoTicks - prev.IoTicks
+ if deltaIo < 0 {
+ deltaIo = 0
+ }
+ utilPct := float64(deltaIo) / (dt * 1000) * 100
+ if utilPct > 100 {
+ utilPct = 100
+ }
+ lineY := y + int32(utilPct/100*float64(barH))
+ if lineY >= y+barH {
+ lineY = y + barH - 1
+ }
+ renderer.SetDrawColor(constants.DiskUtil.R, constants.DiskUtil.G, constants.DiskUtil.B, 255)
+ // Draw 3px band for visibility
+ for dy := int32(-1); dy <= 1; dy++ {
+ ly := lineY + dy
+ if ly >= y && ly < y+barH {
+ renderer.DrawLine(x, ly, x+barW-1, ly)
+ }
+ }
+}
diff --git a/internal/display/display.go b/internal/display/display.go
index 874abe3..94b4c76 100644
--- a/internal/display/display.go
+++ b/internal/display/display.go
@@ -44,6 +44,10 @@ type runState struct {
smoothedNet map[string]*struct{ rxPct, txPct float64 }
prevNet map[string]stats.NetStamp // aggregated (summed) previous net stamp per host
peakHistory map[string][]float64
+ diskMode int // constants.DiskModeAggregate / DiskModeDevices / DiskModeOff
+ diskPeak float64 // auto-scale peak (bytes/sec) for disk bars
+ prevDisk map[string]stats.DiskStamp // previous disk stamp per host+device key
+ smoothedDisk map[string]*struct{ readPct, writePct float64 }
mouseX int32 // last known mouse X position (for tooltip hit testing)
mouseY int32 // last known mouse Y position (for tooltip hit testing)
mouseLastMove time.Time // timestamp of last mouse movement; tooltip hidden after 3s idle
@@ -57,6 +61,10 @@ func newRunState(cfg *config.Config, winW, winH int32) *runState {
if cfg.LoadMax > 0 {
initLoadPeak = cfg.LoadMax
}
+ initDiskPeak := 1048576.0 // 1 MB/s floor for auto-scale
+ if cfg.DiskMax > 0 {
+ initDiskPeak = cfg.DiskMax
+ }
return &runState{
showAvgLine: cfg.ShowAvgLine,
showIOAvgLine: cfg.ShowIOAvgLine,
@@ -75,6 +83,10 @@ func newRunState(cfg *config.Config, winW, winH int32) *runState {
smoothedNet: make(map[string]*struct{ rxPct, txPct float64 }),
prevNet: make(map[string]stats.NetStamp),
peakHistory: make(map[string][]float64),
+ diskMode: cfg.DiskMode,
+ diskPeak: initDiskPeak,
+ prevDisk: make(map[string]stats.DiskStamp),
+ smoothedDisk: make(map[string]*struct{ readPct, writePct float64 }),
mouseX: -1, // off-screen until first mouse move
mouseY: -1,
}
@@ -199,6 +211,17 @@ func handleToggleKeys(sym sdl.Keycode, cfg *config.Config, state *runState) {
case sdl.K_4, sdl.K_l:
state.showLoad = !state.showLoad
fmt.Println("==> Toggled show load:", state.showLoad)
+ case sdl.K_5:
+ // Cycle through three disk display modes: aggregate → devices → off → aggregate
+ state.diskMode = (state.diskMode + 1) % constants.DiskModeCount
+ switch state.diskMode {
+ case constants.DiskModeAggregate:
+ fmt.Println("==> Disk: aggregate (all devices)")
+ case constants.DiskModeDevices:
+ fmt.Println("==> Disk: per-device")
+ case constants.DiskModeOff:
+ fmt.Println("==> Disk: off")
+ }
case sdl.K_r:
// Reset load auto-scale peak to the floor so the bar rescales immediately.
// Has no effect when loadmax is fixed (cfg.LoadMax > 0).
@@ -242,6 +265,14 @@ func handleAdjustAndSave(sym sdl.Keycode, cfg *config.Config, state *runState) {
cfg.NetAverage--
}
fmt.Println("==> Net average samples:", cfg.NetAverage)
+ case sdl.K_b:
+ cfg.DiskAverage++
+ fmt.Println("==> Disk average samples:", cfg.DiskAverage)
+ case sdl.K_x:
+ if cfg.DiskAverage > 1 {
+ cfg.DiskAverage--
+ }
+ fmt.Println("==> Disk average samples:", cfg.DiskAverage)
case sdl.K_f:
scaleLinkUp(cfg)
case sdl.K_v:
@@ -257,6 +288,7 @@ func handleAdjustAndSave(sym sdl.Keycode, cfg *config.Config, state *runState) {
cfg.ShowNet = state.showNet
cfg.ShowLoad = state.showLoad
cfg.ShowSeparators = state.showSeparators
+ cfg.DiskMode = state.diskMode
cfg.Extended = state.extended
if err := cfg.Write(); err != nil {
fmt.Fprintf(os.Stderr, "!!! Write config: %v\n", err)
@@ -338,7 +370,7 @@ func barRect(winW, winH int32, numBars, maxPerRow, barIndex int) (x, y, w, h int
// When showAvgLine/showIOAvgLine are enabled, global average lines are drawn on top.
func drawFrame(renderer *sdl.Renderer, src stats.Source, cfg *config.Config, state *runState) {
snap := src.Snapshot()
- numBars := countBars(snap, state.cpuMode, state.showMem, state.showNet, state.showLoad)
+ numBars := countBars(snap, state.cpuMode, state.showMem, state.showNet, state.showLoad, state.diskMode)
// Always clear the entire window before drawing. SDL2 uses double-buffering,
// so skipping clear leaves stale content in the back buffer.
renderer.SetDrawColor(0, 0, 0, 255)
@@ -347,6 +379,10 @@ func drawFrame(renderer *sdl.Renderer, src stats.Source, cfg *config.Config, sta
// Update the global load peak before drawing so bar scale is current.
updateLoadPeak(snap, state, cfg.LoadMax)
}
+ if state.diskMode != constants.DiskModeOff {
+ // Update the global disk peak before drawing so bar scale is current.
+ updateDiskPeak(snap, state, cfg.DiskMax)
+ }
drawBars(renderer, snap, cfg, state, numBars)
if state.showAvgLine {
drawGlobalAvgLine(renderer, snap, state, numBars, cfg.MaxBarsPerRow)
@@ -358,7 +394,7 @@ func drawFrame(renderer *sdl.Renderer, src stats.Source, cfg *config.Config, sta
drawOverlay(renderer, snap, cfg, state)
}
-func countBars(snap map[string]*stats.HostStats, cpuMode int, showMem, showNet, showLoad bool) int {
+func countBars(snap map[string]*stats.HostStats, cpuMode int, showMem, showNet, showLoad bool, diskMode int) int {
n := 0
for _, host := range sortedHosts(snap) {
if h := snap[host]; h != nil {
@@ -372,6 +408,7 @@ func countBars(snap map[string]*stats.HostStats, cpuMode int, showMem, showNet,
if showLoad {
n++
}
+ n += len(sortedDiskNames(h.Disk, diskMode))
}
}
if n == 0 {
@@ -553,6 +590,23 @@ func drawHostBars(renderer *sdl.Renderer, h *stats.HostStats, host string, cfg *
*barIndex++
drawLoadAvgBar(renderer, h, state.loadPeak, barW, x, y, barH)
}
+ // Disk I/O bars: aggregate (one bar) or per-device based on diskMode
+ diskNames := sortedDiskNames(h.Disk, state.diskMode)
+ for _, dname := range diskNames {
+ key := host + ";disk;" + dname
+ var cur stats.DiskStamp
+ if dname == "all" {
+ cur = sumAllDisks(h.Disk)
+ } else {
+ cur = h.Disk[dname]
+ }
+ if state.smoothedDisk[key] == nil {
+ state.smoothedDisk[key] = &struct{ readPct, writePct float64 }{}
+ }
+ x, y, barW, barH := barRect(state.winW, state.winH, numBars, maxPerRow, *barIndex)
+ *barIndex++
+ state.prevDisk[key] = drawDiskBarSmoothed(renderer, cur, state, state.smoothedDisk[key], state.prevDisk[key], smoothFactor, barW, x, y, barH, state.extended)
+ }
}
func peakPctForBar(state *runState, key string, cpuAvg int, s *[10]float64) float64 {
@@ -772,7 +826,7 @@ func drawMemBarSmoothed(renderer *sdl.Renderer, h *stats.HostStats, smoothed *st
}
func printHotkeys() {
- fmt.Println("=> Hotkeys: 1=cores 2/m=mem 3/n=net 4/l=load r=reset load peak e=extended g=avg line i=io avg s=separators h=help q=quit w=write config a/y=cpu avg d/c=net avg f/v=link scale arrows=resize")
+ fmt.Println("=> Hotkeys: 1=cores 2/m=mem 3/n=net 4/l=load 5=disk r=reset load peak e=extended g=avg line i=io avg s=separators h=help q=quit w=write config a/y=cpu avg d/c=net avg b/x=disk avg f/v=link scale arrows=resize")
}
// scaleLinkUp moves cfg.NetLink to the next higher link speed in linkScales.
diff --git a/internal/display/display_test.go b/internal/display/display_test.go
index 16a7700..a587818 100644
--- a/internal/display/display_test.go
+++ b/internal/display/display_test.go
@@ -397,7 +397,7 @@ func TestMultiHost_BarCount(t *testing.T) {
}
snap := src.Snapshot()
- numBars := countBars(snap, constants.CPUModeAverage, true, true, false)
+ numBars := countBars(snap, constants.CPUModeAverage, true, true, false, constants.DiskModeOff)
if numBars != 6 {
t.Fatalf("expected 6 bars (2 hosts × 3), got %d", numBars)
}
@@ -430,19 +430,19 @@ func TestCores_Toggle(t *testing.T) {
snap := map[string]*stats.HostStats{"host1": hostStats}
// CPUModeAverage: aggregate bar only (1 bar)
- nAverage := countBars(snap, constants.CPUModeAverage, false, false, false)
+ nAverage := countBars(snap, constants.CPUModeAverage, false, false, false, constants.DiskModeOff)
if nAverage != 1 {
t.Errorf("CPUModeAverage: expected 1 bar, got %d", nAverage)
}
// CPUModeCores: aggregate + individual cores = cpu + cpu0 + cpu1 (3 bars)
- nCores := countBars(snap, constants.CPUModeCores, false, false, false)
+ nCores := countBars(snap, constants.CPUModeCores, false, false, false, constants.DiskModeOff)
if nCores != 3 {
t.Errorf("CPUModeCores: expected 3 bars, got %d", nCores)
}
// CPUModeOff: no CPU bars → countBars floors to 1 (window always shows something)
- nOff := countBars(snap, constants.CPUModeOff, false, false, false)
+ nOff := countBars(snap, constants.CPUModeOff, false, false, false, constants.DiskModeOff)
if nOff != 1 {
t.Errorf("CPUModeOff: expected 1 (floor), got %d", nOff)
}
@@ -664,8 +664,8 @@ func TestHandleKey_Quit(t *testing.T) {
func TestHandleKey_UnknownKey(t *testing.T) {
cfg := defaultTestConfig()
state := newRunState(cfg, 200, 100)
- if handleKey(sdl.K_x, nil, cfg, state) {
- t.Error("expected handleKey(x) to return false")
+ if handleKey(sdl.K_z, nil, cfg, state) {
+ t.Error("expected handleKey(z) to return false")
}
// State should be unchanged
if state.cpuMode != cfg.CPUMode || state.showMem != cfg.ShowMem || state.showNet != cfg.ShowNet {
@@ -700,7 +700,7 @@ func TestHandleKey_ToggleCores(t *testing.T) {
}
// State 2 (CPUModeOff): no CPU bars; countBars returns 1 (floor) so window is still drawn
- nOff := countBars(src.Snapshot(), constants.CPUModeOff, false, false, false)
+ nOff := countBars(src.Snapshot(), constants.CPUModeOff, false, false, false, constants.DiskModeOff)
if nOff != 1 {
t.Errorf("CPUModeOff: expected countBars=1 (floor), got %d", nOff)
}
@@ -1607,3 +1607,215 @@ func TestHandleKey_WriteConfig_Separators(t *testing.T) {
t.Error("expected ShowSeparators=true in config after 'w'")
}
}
+
+// --- Disk display tests ---
+
+func TestIsWholeDisk(t *testing.T) {
+ tests := []struct {
+ name string
+ want bool
+ }{
+ {"sda", true},
+ {"sda1", false},
+ {"sda12", false},
+ {"nvme0n1", true},
+ {"nvme0n1p1", false},
+ {"nvme0n1p12", false},
+ {"vda", true},
+ {"vda1", false},
+ {"xvda", true},
+ {"xvda1", false},
+ {"hda", true},
+ {"hda1", false},
+ {"loop0", false},
+ {"loop1", false},
+ {"ram0", false},
+ {"dm-0", false},
+ {"dm-1", false},
+ {"sr0", true}, // CD-ROM, not a partition
+ {"mmcblk0", true}, // SD card, whole disk
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isWholeDisk(tt.name); got != tt.want {
+ t.Errorf("isWholeDisk(%q) = %v, want %v", tt.name, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSortedDiskNames(t *testing.T) {
+ disk := map[string]stats.DiskStamp{
+ "sda": {SectorsRead: 100},
+ "sda1": {SectorsRead: 50},
+ "nvme0n1": {SectorsRead: 200},
+ "nvme0n1p1": {SectorsRead: 100},
+ "loop0": {SectorsRead: 0},
+ }
+
+ // Aggregate mode returns ["all"]
+ agg := sortedDiskNames(disk, constants.DiskModeAggregate)
+ if len(agg) != 1 || agg[0] != "all" {
+ t.Errorf("aggregate: got %v, want [all]", agg)
+ }
+
+ // Device mode returns sorted whole-disk names only
+ devs := sortedDiskNames(disk, constants.DiskModeDevices)
+ if len(devs) != 2 || devs[0] != "nvme0n1" || devs[1] != "sda" {
+ t.Errorf("devices: got %v, want [nvme0n1, sda]", devs)
+ }
+
+ // Off mode returns nil
+ off := sortedDiskNames(disk, constants.DiskModeOff)
+ if off != nil {
+ t.Errorf("off: got %v, want nil", off)
+ }
+}
+
+func TestSumAllDisks(t *testing.T) {
+ disk := map[string]stats.DiskStamp{
+ "sda": {SectorsRead: 100, SectorsWrite: 200, IoTicks: 10, Stamp: 2.0},
+ "sda1": {SectorsRead: 50, SectorsWrite: 100, IoTicks: 5, Stamp: 2.0}, // partition, skipped
+ "nvme0n1": {SectorsRead: 300, SectorsWrite: 400, IoTicks: 20, Stamp: 3.0},
+ "loop0": {SectorsRead: 10, SectorsWrite: 0, IoTicks: 1, Stamp: 1.0}, // loop, skipped
+ }
+ sum := sumAllDisks(disk)
+ if sum.SectorsRead != 400 || sum.SectorsWrite != 600 || sum.IoTicks != 30 {
+ t.Errorf("sumAllDisks: got sr=%d sw=%d io=%d, want sr=400 sw=600 io=30",
+ sum.SectorsRead, sum.SectorsWrite, sum.IoTicks)
+ }
+ if sum.Stamp != 3.0 {
+ t.Errorf("sumAllDisks: got stamp=%.1f, want 3.0", sum.Stamp)
+ }
+}
+
+func TestUpdateDiskPeak(t *testing.T) {
+ // Fixed override: diskPeak always equals DiskMax
+ state := &runState{
+ diskMode: constants.DiskModeAggregate,
+ diskPeak: 1048576,
+ prevDisk: make(map[string]stats.DiskStamp),
+ }
+ snap := map[string]*stats.HostStats{}
+ updateDiskPeak(snap, state, 5000000) // fixed 5 MB/s
+ if state.diskPeak != 5000000 {
+ t.Errorf("fixed: got diskPeak=%f, want 5000000", state.diskPeak)
+ }
+
+ // Auto-scale: decay toward floor
+ state.diskPeak = 2000000
+ updateDiskPeak(snap, state, 0)
+ if state.diskPeak >= 2000000 {
+ t.Errorf("auto-scale: diskPeak should have decayed below 2000000, got %f", state.diskPeak)
+ }
+ if state.diskPeak < 1048576 {
+ t.Errorf("auto-scale: diskPeak should not go below floor 1048576, got %f", state.diskPeak)
+ }
+
+ // Auto-scale at floor: stays at floor
+ state.diskPeak = 1048576
+ updateDiskPeak(snap, state, 0)
+ if state.diskPeak != 1048576 {
+ t.Errorf("auto-scale floor: got diskPeak=%f, want 1048576", state.diskPeak)
+ }
+}
+
+func TestHandleKey_ToggleDisk(t *testing.T) {
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 100)
+ // Default is DiskModeOff
+ if state.diskMode != constants.DiskModeOff {
+ t.Fatalf("expected diskMode=DiskModeOff initially, got %d", state.diskMode)
+ }
+
+ // Press '5': DiskModeOff → DiskModeAggregate
+ handleKey(sdl.K_5, nil, cfg, state)
+ if state.diskMode != constants.DiskModeAggregate {
+ t.Fatalf("expected diskMode=DiskModeAggregate after first press, got %d", state.diskMode)
+ }
+
+ // Press '5': DiskModeAggregate → DiskModeDevices
+ handleKey(sdl.K_5, nil, cfg, state)
+ if state.diskMode != constants.DiskModeDevices {
+ t.Fatalf("expected diskMode=DiskModeDevices after second press, got %d", state.diskMode)
+ }
+
+ // Press '5': DiskModeDevices → DiskModeOff
+ handleKey(sdl.K_5, nil, cfg, state)
+ if state.diskMode != constants.DiskModeOff {
+ t.Fatalf("expected diskMode=DiskModeOff after third press, got %d", state.diskMode)
+ }
+}
+
+func TestHandleKey_DiskAverage(t *testing.T) {
+ cfg := defaultTestConfig()
+ cfg.DiskAverage = 5
+ state := newRunState(cfg, 200, 100)
+
+ // 'b' increases disk average
+ handleKey(sdl.K_b, nil, cfg, state)
+ if cfg.DiskAverage != 6 {
+ t.Errorf("expected DiskAverage=6 after 'b', got %d", cfg.DiskAverage)
+ }
+
+ // 'x' decreases disk average
+ handleKey(sdl.K_x, nil, cfg, state)
+ if cfg.DiskAverage != 5 {
+ t.Errorf("expected DiskAverage=5 after 'x', got %d", cfg.DiskAverage)
+ }
+
+ // 'x' should clamp at 1
+ cfg.DiskAverage = 1
+ handleKey(sdl.K_x, nil, cfg, state)
+ if cfg.DiskAverage != 1 {
+ t.Errorf("expected DiskAverage=1 (clamped), got %d", cfg.DiskAverage)
+ }
+}
+
+func TestHandleKey_WriteConfig_Disk(t *testing.T) {
+ tmpDir := t.TempDir()
+ origHome := os.Getenv("HOME")
+ os.Setenv("HOME", tmpDir)
+ defer os.Setenv("HOME", origHome)
+
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 100)
+ state.diskMode = constants.DiskModeAggregate
+
+ handleKey(sdl.K_w, nil, cfg, state)
+
+ if cfg.DiskMode != constants.DiskModeAggregate {
+ t.Errorf("expected DiskMode=DiskModeAggregate in config after 'w', got %d", cfg.DiskMode)
+ }
+}
+
+func TestCountBars_WithDisk(t *testing.T) {
+ snap := map[string]*stats.HostStats{
+ "host1": {
+ CPU: map[string]collector.CPULine{"cpu": {}},
+ Disk: map[string]stats.DiskStamp{
+ "sda": {SectorsRead: 100},
+ "sda1": {SectorsRead: 50},
+ "nvme0n1": {SectorsRead: 200},
+ },
+ },
+ }
+
+ // DiskModeOff: 1 CPU bar only
+ n := countBars(snap, constants.CPUModeAverage, false, false, false, constants.DiskModeOff)
+ if n != 1 {
+ t.Errorf("DiskModeOff: expected 1, got %d", n)
+ }
+
+ // DiskModeAggregate: 1 CPU + 1 disk = 2
+ n = countBars(snap, constants.CPUModeAverage, false, false, false, constants.DiskModeAggregate)
+ if n != 2 {
+ t.Errorf("DiskModeAggregate: expected 2, got %d", n)
+ }
+
+ // DiskModeDevices: 1 CPU + 2 whole-disk devices (sda, nvme0n1) = 3
+ n = countBars(snap, constants.CPUModeAverage, false, false, false, constants.DiskModeDevices)
+ if n != 3 {
+ t.Errorf("DiskModeDevices: expected 3, got %d", n)
+ }
+}
diff --git a/internal/display/hittest.go b/internal/display/hittest.go
index fd2a218..e8c1909 100644
--- a/internal/display/hittest.go
+++ b/internal/display/hittest.go
@@ -14,20 +14,22 @@ const (
barMem
barNet
barLoad
+ barDisk
)
// barDescriptor describes a single bar's position, host, and type.
type barDescriptor struct {
- host string // hostname this bar belongs to
- kind barKind // CPU, mem, or net
- cpuName string // CPU name (e.g. "cpu", "cpu0"); only set for barCPU
- rect sdl.Rect
+ host string // hostname this bar belongs to
+ kind barKind // CPU, mem, net, load, or disk
+ cpuName string // CPU name (e.g. "cpu", "cpu0"); only set for barCPU
+ diskName string // disk device name (e.g. "sda", "all"); only set for barDisk
+ rect sdl.Rect
}
// buildBarMap replays the same host/bar iteration as drawBars to produce
// a slice of bar descriptors with their screen rectangles.
func buildBarMap(snap map[string]*stats.HostStats, cfg *config.Config, state *runState) []barDescriptor {
- numBars := countBars(snap, state.cpuMode, state.showMem, state.showNet, state.showLoad)
+ numBars := countBars(snap, state.cpuMode, state.showMem, state.showNet, state.showLoad, state.diskMode)
maxPerRow := cfg.MaxBarsPerRow
hosts := sortedHosts(snap)
@@ -76,6 +78,17 @@ func buildBarMap(snap map[string]*stats.HostStats, cfg *config.Config, state *ru
})
barIndex++
}
+ diskNames := sortedDiskNames(h.Disk, state.diskMode)
+ for _, dname := range diskNames {
+ x, y, w, bh := barRect(state.winW, state.winH, numBars, maxPerRow, barIndex)
+ bars = append(bars, barDescriptor{
+ host: host,
+ kind: barDisk,
+ diskName: dname,
+ rect: sdl.Rect{X: x, Y: y, W: w, H: bh},
+ })
+ barIndex++
+ }
}
return bars
}
diff --git a/internal/display/tooltip.go b/internal/display/tooltip.go
index e612b2c..9dec4f2 100644
--- a/internal/display/tooltip.go
+++ b/internal/display/tooltip.go
@@ -38,6 +38,8 @@ func tooltipLines(bar *barDescriptor, snap map[string]*stats.HostStats, cfg *con
return netTooltipLines(bar, cfg, state)
case barLoad:
return loadTooltipLines(bar, h, cfg, state)
+ case barDisk:
+ return diskTooltipLines(bar, h, cfg, state)
}
return nil
}
@@ -126,6 +128,40 @@ func loadTooltipLines(bar *barDescriptor, h *stats.HostStats, cfg *config.Config
return lines
}
+// diskTooltipLines returns tooltip text for a disk bar showing read/write throughput
+// and utilization %.
+func diskTooltipLines(bar *barDescriptor, h *stats.HostStats, cfg *config.Config, state *runState) []string {
+ label := bar.diskName
+ if label == "" {
+ label = "all"
+ }
+ lines := []string{fmt.Sprintf("%s [disk:%s]", bar.host, label)}
+ key := bar.host + ";disk;" + label
+ sm := state.smoothedDisk[key]
+ if sm == nil {
+ lines = append(lines, "No data yet")
+ return lines
+ }
+ // Compute MB/s from smoothed percentages and current peak
+ peak := state.diskPeak
+ if peak <= 0 {
+ peak = 1048576
+ }
+ readMBs := sm.readPct / 100 * peak / 1048576
+ writeMBs := sm.writePct / 100 * peak / 1048576
+
+ scaleLabel := "Peak: "
+ if cfg.DiskMax > 0 {
+ scaleLabel = "Max: "
+ }
+ lines = append(lines,
+ fmt.Sprintf("Read: %6.2f MB/s", readMBs),
+ fmt.Sprintf("Write: %6.2f MB/s", writeMBs),
+ fmt.Sprintf(scaleLabel+"%6.2f MB/s", peak/1048576),
+ )
+ return lines
+}
+
// formatKB formats a value in KB as a human-readable string (KB, MB, or GB).
func formatKB(kb int64) string {
switch {