summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-18 14:14:52 +0200
committerPaul Buetow <paul@buetow.org>2026-02-18 14:14:52 +0200
commitf7887117c5269ed0336cc058c78c4b222e0e6b34 (patch)
tree53e9c4b5163b29b0987beda78572ecdb13c31cf0
parent69f5017434298f1ffd4cdc30c30b95d0f4bd344f (diff)
feat: add disk I/O stats (read/write throughput bars, hotkey 5, auto-scale)
Add a new disk stats category that reads /proc/diskstats, shows read/write throughput as colored bars (purple/darker purple like network RX/TX), with optional utilization % overlay in extended mode. Hotkey 5 cycles: aggregate (sum all whole-disk devices) → per-device → off. Auto-scale with decay and optional fixed override via --diskmax / diskmax config key. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--README.md15
-rw-r--r--cmd/loadbars/main.go5
-rw-r--r--internal/app/store.go26
-rw-r--r--internal/app/store_test.go71
-rw-r--r--internal/collector/collector.go5
-rw-r--r--internal/collector/parse.go30
-rw-r--r--internal/collector/parse_test.go43
-rw-r--r--internal/collector/protocol.go3
-rw-r--r--internal/collector/scriptdata/loadbars-remote.sh10
-rw-r--r--internal/collector/types.go10
-rw-r--r--internal/config/config.go24
-rw-r--r--internal/constants/constants.go11
-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
-rw-r--r--internal/stats/stats.go9
-rw-r--r--scripts/loadbars-remote.sh10
19 files changed, 818 insertions, 19 deletions
diff --git a/README.md b/README.md
index 9610e62..9d30b64 100644
--- a/README.md
+++ b/README.md
@@ -81,6 +81,8 @@ All options can also be set in `~/.loadbarsrc` (key=value, no leading `--`). CLI
| `--hasagent` | SSH key is already loaded in agent (skip extra agent checks) | off |
| `--showload` | Show load average bars (1-min teal fill, 5-min yellow line, 15-min white line per host) | off |
| `--loadmax <n>` | Fix the load bar full-height reference to `n` (e.g. `8` = core count); 0 = auto-scale | 0 |
+| `--diskmode <n>` | Disk I/O display mode: 0=aggregate, 1=per-device, 2=off | 2 (off) |
+| `--diskmax <n>` | Fix the disk bar full-height reference to `n` bytes/sec; 0 = auto-scale | 0 |
| `--maxbarsperrow <n>` | Max bars per row; 0 = unlimited (single row) | 0 |
| `--help` | Show usage and exit | — |
| `--version` | Print version and exit | — |
@@ -140,6 +142,7 @@ Press these keys while loadbars is running (see also `h` for a short list on std
| **2** / **m** | Toggle memory bars (RAM left, Swap right per host) |
| **3** / **n** | Toggle network bars (RX/TX summed across all non-lo interfaces per host) |
| **4** / **l** | Toggle load average bars (1-min teal fill, 5-min yellow line, 15-min white line) |
+| **5** | Toggle disk I/O bars: aggregate (all devices) → per-device → off → aggregate |
| **r** | Reset load auto-scale peak to floor (2.0) — has no effect when `loadmax` is fixed |
| **e** | Toggle extended display (1px peak line on CPU bars: max system+user over last samples) |
| **g** | Toggle global average CPU line (1px red line showing mean CPU usage across all hosts) |
@@ -152,6 +155,8 @@ Press these keys while loadbars is running (see also `h` for a short list on std
| **y** | Decrease CPU average samples (min 1) |
| **d** | Increase net average samples |
| **c** | Decrease net average samples (min 1) |
+| **b** | Increase disk average samples |
+| **x** | Decrease disk average samples (min 1) |
| **f** | Link scale up (net utilization reference) |
| **v** | Link scale down (net utilization reference) |
| **Arrow keys** | Resize window (left/right: width, up/down: height) |
@@ -192,6 +197,16 @@ When network bar is red: No non-loopback interface exists on the specific remote
- *Auto-scale* (`loadmax=0`, default): the reference tracks the global maximum 1-min load across all hosts, decaying slowly over time (floor 2.0). Press **r** to reset it back to 2.0 immediately.
- *Fixed scale* (`loadmax=N`): the bar's full height always equals `N`. Useful when you know the core count and want a stable reference across sessions. Tooltip shows `Max:` instead of `Peak:`.
+### Disk I/O stuff
+
+- **Purple fill** from top downward: read throughput as % of the peak/max reference.
+- **Darker purple fill** from bottom upward: write throughput as % of the peak/max reference.
+- In **extended mode** (`e` key), a 3px light-red line shows disk utilization % (fraction of time the device had I/O in progress).
+- **Aggregate mode** (`5` key, first press): one bar per host summing all whole-disk devices. Partitions (`sda1`, `nvme0n1p1`), loop, ram, and device-mapper devices are excluded.
+- **Per-device mode** (`5` key, second press): one bar per whole-disk device per host.
+- **Scale reference**: auto-scales based on observed peak throughput (floor 1 MB/s), or fixed via `diskmax=N` (bytes/sec).
+- **Config keys**: `diskmode` (0/1/2), `diskmax` (bytes/sec), `diskaverage` (smoothing samples).
+
**Aggregated interfaces:** Loadbars sums RX/TX across all non-loopback interfaces (e.g. `eth0`, `wlan0`, `enp0s3`) and shows the combined total. Loopback (`lo`) is always excluded.
**Link speed** (`netlink`): Used to compute utilization %. Default is `gbit`. Set e.g. `netlink=100mbit` or `netlink=10gbit` in ~/.loadbarsrc or `--netlink 100mbit`.
diff --git a/cmd/loadbars/main.go b/cmd/loadbars/main.go
index dd86723..bb33ef6 100644
--- a/cmd/loadbars/main.go
+++ b/cmd/loadbars/main.go
@@ -35,6 +35,8 @@ func main() {
flag.StringVar(&cfg.SSHOpts, "sshopts", cfg.SSHOpts, "Set SSH options")
flag.BoolVar(&cfg.HasAgent, "hasagent", cfg.HasAgent, "SSH key already known by agent")
flag.IntVar(&cfg.MaxBarsPerRow, "maxbarsperrow", cfg.MaxBarsPerRow, "Max bars per row (0=unlimited)")
+ flag.IntVar(&cfg.DiskMode, "diskmode", cfg.DiskMode, "Disk display mode (0=aggregate, 1=devices, 2=off)")
+ diskmax := flag.Float64("diskmax", 0, "Fixed disk bar full-height reference in bytes/sec (0 = auto-scale)")
loadmax := flag.Float64("loadmax", 0, "Fixed load bar full-height reference value (0 = auto-scale)")
flag.Parse()
@@ -54,6 +56,9 @@ func main() {
if *loadmax > 0 {
cfg.LoadMax = *loadmax
}
+ if *diskmax > 0 {
+ cfg.DiskMax = *diskmax
+ }
if *cluster != "" {
cfg.Cluster = *cluster
}
diff --git a/internal/app/store.go b/internal/app/store.go
index ffe00c8..5864d8d 100644
--- a/internal/app/store.go
+++ b/internal/app/store.go
@@ -19,6 +19,7 @@ type hostData struct {
mem map[string]int64
net map[string]stats.NetStamp
cpu map[string]collector.CPULine
+ disk map[string]stats.DiskStamp
}
// Compile-time interface satisfaction checks.
@@ -62,6 +63,19 @@ func (s *Store) SetNet(host, iface string, net collector.NetLine, stamp float64)
d.net[iface] = stats.NetStamp{B: net.B, Tb: net.Tb, Stamp: stamp}
}
+// SetDisk sets the disk stamp for the given host and device.
+func (s *Store) SetDisk(host, device string, disk collector.DiskLine, stamp float64) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ d := s.getOrCreate(host)
+ d.disk[device] = stats.DiskStamp{
+ SectorsRead: disk.SectorsRead,
+ SectorsWrite: disk.SectorsWrite,
+ IoTicks: disk.IoTicks,
+ Stamp: stamp,
+ }
+}
+
// Snapshot returns a copy of current stats for all hosts for the display.
func (s *Store) Snapshot() map[string]*stats.HostStats {
s.mu.RLock()
@@ -80,6 +94,10 @@ func (s *Store) Snapshot() map[string]*stats.HostStats {
for k, v := range d.cpu {
cpu[k] = v
}
+ disk := make(map[string]stats.DiskStamp, len(d.disk))
+ for k, v := range d.disk {
+ disk[k] = v
+ }
out[h] = &stats.HostStats{
LoadAvg1: d.load1,
LoadAvg5: d.load5,
@@ -87,6 +105,7 @@ func (s *Store) Snapshot() map[string]*stats.HostStats {
Mem: mem,
Net: net,
CPU: cpu,
+ Disk: disk,
}
}
return out
@@ -95,9 +114,10 @@ func (s *Store) Snapshot() map[string]*stats.HostStats {
func (s *Store) getOrCreate(host string) *hostData {
if s.hosts[host] == nil {
s.hosts[host] = &hostData{
- mem: make(map[string]int64),
- net: make(map[string]stats.NetStamp),
- cpu: make(map[string]collector.CPULine),
+ mem: make(map[string]int64),
+ net: make(map[string]stats.NetStamp),
+ cpu: make(map[string]collector.CPULine),
+ disk: make(map[string]stats.DiskStamp),
}
}
return s.hosts[host]
diff --git a/internal/app/store_test.go b/internal/app/store_test.go
new file mode 100644
index 0000000..037d7d4
--- /dev/null
+++ b/internal/app/store_test.go
@@ -0,0 +1,71 @@
+package app
+
+import (
+ "testing"
+
+ "codeberg.org/snonux/loadbars/internal/collector"
+ "codeberg.org/snonux/loadbars/internal/stats"
+)
+
+func TestSetDisk(t *testing.T) {
+ s := NewStore()
+ disk := collector.DiskLine{
+ Device: "sda",
+ SectorsRead: 1000,
+ SectorsWrite: 2000,
+ IoTicks: 50,
+ }
+ s.SetDisk("host1", "sda", disk, 1.0)
+
+ snap := s.Snapshot()
+ h := snap["host1"]
+ if h == nil {
+ t.Fatal("expected host1 in snapshot")
+ }
+ ds, ok := h.Disk["sda"]
+ if !ok {
+ t.Fatal("expected sda in Disk map")
+ }
+ if ds.SectorsRead != 1000 || ds.SectorsWrite != 2000 || ds.IoTicks != 50 || ds.Stamp != 1.0 {
+ t.Errorf("disk stamp mismatch: got %+v", ds)
+ }
+}
+
+func TestSnapshotDiskDeepCopy(t *testing.T) {
+ s := NewStore()
+ disk := collector.DiskLine{
+ Device: "sda",
+ SectorsRead: 100,
+ SectorsWrite: 200,
+ }
+ s.SetDisk("host1", "sda", disk, 1.0)
+
+ snap1 := s.Snapshot()
+
+ // Mutate the store after snapshot
+ disk2 := collector.DiskLine{
+ Device: "sda",
+ SectorsRead: 999,
+ SectorsWrite: 888,
+ }
+ s.SetDisk("host1", "sda", disk2, 2.0)
+
+ // snap1 should be unchanged (deep copy)
+ ds := snap1["host1"].Disk["sda"]
+ if ds.SectorsRead != 100 || ds.SectorsWrite != 200 {
+ t.Errorf("snapshot was mutated: got sr=%d sw=%d, want sr=100 sw=200",
+ ds.SectorsRead, ds.SectorsWrite)
+ }
+
+ // Verify new snapshot has updated values
+ snap2 := s.Snapshot()
+ ds2 := snap2["host1"].Disk["sda"]
+ if ds2.SectorsRead != 999 || ds2.SectorsWrite != 888 {
+ t.Errorf("snap2: got sr=%d sw=%d, want sr=999 sw=888",
+ ds2.SectorsRead, ds2.SectorsWrite)
+ }
+}
+
+// Ensure Store still satisfies the collector.StatsStore interface (which now includes SetDisk).
+var _ collector.StatsStore = (*Store)(nil)
+var _ stats.Source = (*Store)(nil)
diff --git a/internal/collector/collector.go b/internal/collector/collector.go
index 0107d61..5c99347 100644
--- a/internal/collector/collector.go
+++ b/internal/collector/collector.go
@@ -18,6 +18,7 @@ type StatsStore interface {
SetCPU(host, name string, line CPULine)
SetMem(host, key string, value int64)
SetNet(host, iface string, net NetLine, stamp float64)
+ SetDisk(host, device string, disk DiskLine, stamp float64)
}
// Run starts a collector for one host: runs the embedded remote script (local or over SSH)
@@ -134,6 +135,10 @@ func dispatchCollectorLine(mode, line, hostKey string, store StatsStore) {
store.SetNet(hostKey, net.Iface, net, float64(time.Now().UnixNano())/1e9)
}
}
+ case ModeDiskStats:
+ if d, err := ParseDiskLine(line); err == nil {
+ store.SetDisk(hostKey, d.Device, d, float64(time.Now().UnixNano())/1e9)
+ }
case ModeCPUStats:
if strings.HasPrefix(line, "cpu") {
if cu, err := ParseCPULine(line); err == nil {
diff --git a/internal/collector/parse.go b/internal/collector/parse.go
index 4f49456..034a50b 100644
--- a/internal/collector/parse.go
+++ b/internal/collector/parse.go
@@ -100,3 +100,33 @@ func ParseLoadAvg(line string) LoadAvg {
}
return l
}
+
+// ParseDiskLine parses "device:rs=N;ws=N;rt=N;wt=N;io=N" from the M DISKSTATS section.
+func ParseDiskLine(line string) (DiskLine, error) {
+ parts := strings.SplitN(line, ":", 2)
+ if len(parts) != 2 {
+ return DiskLine{}, fmt.Errorf("disk line missing colon: %q", line)
+ }
+ d := DiskLine{Device: strings.TrimSpace(parts[0])}
+ for _, pair := range strings.Split(parts[1], ";") {
+ kv := strings.SplitN(pair, "=", 2)
+ if len(kv) != 2 {
+ continue
+ }
+ k := strings.TrimSpace(kv[0])
+ v, _ := strconv.ParseInt(strings.TrimSpace(kv[1]), 10, 64)
+ switch k {
+ case "rs":
+ d.SectorsRead = v
+ case "ws":
+ d.SectorsWrite = v
+ case "rt":
+ d.ReadTicks = v
+ case "wt":
+ d.WriteTicks = v
+ case "io":
+ d.IoTicks = v
+ }
+ }
+ return d, nil
+}
diff --git a/internal/collector/parse_test.go b/internal/collector/parse_test.go
index fe7a73c..8d569cd 100644
--- a/internal/collector/parse_test.go
+++ b/internal/collector/parse_test.go
@@ -115,3 +115,46 @@ func TestParseLoadAvg(t *testing.T) {
t.Errorf("ParseLoadAvg(1.0) = %+v", got2)
}
}
+
+func TestParseDiskLine(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ wantDevice string
+ wantSR int64
+ wantSW int64
+ wantRT int64
+ wantWT int64
+ wantIO int64
+ wantErr bool
+ }{
+ {"full", "sda:rs=1000;ws=2000;rt=50;wt=100;io=120", "sda", 1000, 2000, 50, 100, 120, false},
+ {"nvme", "nvme0n1:rs=500;ws=300;rt=10;wt=20;io=30", "nvme0n1", 500, 300, 10, 20, 30, false},
+ {"zeros", "vda:rs=0;ws=0;rt=0;wt=0;io=0", "vda", 0, 0, 0, 0, 0, false},
+ {"large_counters", "sda:rs=9999999999;ws=8888888888;rt=100;wt=200;io=300", "sda", 9999999999, 8888888888, 100, 200, 300, false},
+ {"partial_fields", "sda:rs=100;ws=200", "sda", 100, 200, 0, 0, 0, false},
+ {"no_colon", "sda rs=100", "", 0, 0, 0, 0, 0, true},
+ {"empty", "", "", 0, 0, 0, 0, 0, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := ParseDiskLine(tt.line)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ParseDiskLine() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if tt.wantErr {
+ return
+ }
+ if got.Device != tt.wantDevice {
+ t.Errorf("Device = %q, want %q", got.Device, tt.wantDevice)
+ }
+ if got.SectorsRead != tt.wantSR || got.SectorsWrite != tt.wantSW {
+ t.Errorf("Sectors = read:%d write:%d, want read:%d write:%d", got.SectorsRead, got.SectorsWrite, tt.wantSR, tt.wantSW)
+ }
+ if got.ReadTicks != tt.wantRT || got.WriteTicks != tt.wantWT || got.IoTicks != tt.wantIO {
+ t.Errorf("Ticks = rt:%d wt:%d io:%d, want rt:%d wt:%d io:%d", got.ReadTicks, got.WriteTicks, got.IoTicks, tt.wantRT, tt.wantWT, tt.wantIO)
+ }
+ })
+ }
+}
diff --git a/internal/collector/protocol.go b/internal/collector/protocol.go
index 26e8a8d..92557e1 100644
--- a/internal/collector/protocol.go
+++ b/internal/collector/protocol.go
@@ -5,5 +5,6 @@ const (
ModeLoadAvg = "M LOADAVG"
ModeMemStats = "M MEMSTATS"
ModeNetStats = "M NETSTATS"
- ModeCPUStats = "M CPUSTATS"
+ ModeDiskStats = "M DISKSTATS"
+ ModeCPUStats = "M CPUSTATS"
)
diff --git a/internal/collector/scriptdata/loadbars-remote.sh b/internal/collector/scriptdata/loadbars-remote.sh
index 9037ad8..1bdb661 100644
--- a/internal/collector/scriptdata/loadbars-remote.sh
+++ b/internal/collector/scriptdata/loadbars-remote.sh
@@ -26,6 +26,16 @@ while true; do
fi
done < <(tail -n +3 /proc/net/dev 2>/dev/null)
+ # Disk: /proc/diskstats, one line per block device with cumulative counters
+ echo "M DISKSTATS"
+ while IFS= read -r line; do
+ set -- $line
+ # $1=major $2=minor $3=device $4=reads_completed $5=reads_merged
+ # $6=sectors_read $7=ms_reading $8=writes_completed $9=writes_merged
+ # $10=sectors_written $11=ms_writing $12=ios_in_progress $13=ms_io
+ [ -n "$3" ] && echo "$3:rs=${6:-0};ws=${10:-0};rt=${7:-0};wt=${11:-0};io=${13:-0}"
+ done < /proc/diskstats 2>/dev/null
+
# CPU: /proc/stat, 20 times with INTERVAL sleep
echo "M CPUSTATS"
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
diff --git a/internal/collector/types.go b/internal/collector/types.go
index 13bcb1f..6d1ecf0 100644
--- a/internal/collector/types.go
+++ b/internal/collector/types.go
@@ -34,3 +34,13 @@ type NetLine struct {
type LoadAvg struct {
Load1, Load5, Load15 string
}
+
+// DiskLine is one device from /proc/diskstats with cumulative counters.
+type DiskLine struct {
+ Device string
+ SectorsRead int64 // cumulative sectors read (each sector = 512 bytes)
+ SectorsWrite int64 // cumulative sectors written
+ ReadTicks int64 // cumulative ms spent reading
+ WriteTicks int64 // cumulative ms spent writing
+ IoTicks int64 // cumulative ms the device had I/O in progress
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index d9b367c..1df8edd 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -32,6 +32,9 @@ type Config struct {
ShowLoad bool
LoadMax float64 // 0 = auto-scale; >0 = fixed full-height reference value
ShowSeparators bool
+ DiskMode int // constants.DiskModeAggregate / DiskModeDevices / DiskModeOff
+ DiskMax float64 // 0 = auto-scale; >0 = fixed bytes/sec reference
+ DiskAverage int // smoothing sample count (like CPUAverage/NetAverage)
MaxBarsPerRow int
SSHOpts string
Cluster string
@@ -51,6 +54,9 @@ func Default() Config {
CPUMode: constants.CPUModeAverage, // start with aggregate bar only
ShowMem: false,
ShowNet: false,
+ DiskMode: constants.DiskModeOff,
+ DiskMax: 0,
+ DiskAverage: 10,
MaxBarsPerRow: 0,
}
}
@@ -112,6 +118,7 @@ func (c *Config) parseReader(f *os.File) error {
"hasagent": true, "height": true, "maxwidth": true, "netaverage": true,
"netlink": true, "cpumode": true, "showcores": true, "showmem": true,
"showavgline": true, "showioavgline": true, "shownet": true, "showload": true, "loadmax": true, "showseparators": true,
+ "diskmode": true, "diskmax": true, "diskaverage": true,
"maxbarsperrow": true, "sshopts": true, "cluster": true,
}
scanner := bufio.NewScanner(f)
@@ -217,6 +224,20 @@ func (c *Config) setDisplayFlags(key, val string) {
}
case "showseparators":
c.ShowSeparators = parseBool(val)
+ case "diskmode":
+ // 0=aggregate, 1=devices, 2=off — clamp to valid range
+ if n, err := strconv.Atoi(val); err == nil && n >= 0 && n < constants.DiskModeCount {
+ c.DiskMode = n
+ }
+ case "diskmax":
+ // Accept any non-negative float; 0 means auto-scale.
+ if f, err := strconv.ParseFloat(val, 64); err == nil && f >= 0 {
+ c.DiskMax = f
+ }
+ case "diskaverage":
+ if n, err := strconv.Atoi(val); err == nil && n > 0 {
+ c.DiskAverage = n
+ }
}
}
@@ -249,6 +270,9 @@ func (c *Config) writeTo(f *os.File) error {
writeBool("showload", c.ShowLoad)
writeFloat("loadmax", c.LoadMax)
writeBool("showseparators", c.ShowSeparators)
+ writeInt("diskmode", c.DiskMode)
+ writeFloat("diskmax", c.DiskMax)
+ writeInt("diskaverage", c.DiskAverage)
writeInt("maxbarsperrow", c.MaxBarsPerRow)
writeStr("sshopts", c.SSHOpts)
writeStr("cluster", c.Cluster)
diff --git a/internal/constants/constants.go b/internal/constants/constants.go
index 48d6e12..3f53cd0 100644
--- a/internal/constants/constants.go
+++ b/internal/constants/constants.go
@@ -24,6 +24,14 @@ const (
CPUModeCount = 3 // Total number of CPU modes for cycling
)
+// DiskMode controls which disk I/O bars are displayed (cycles with the 5 key).
+const (
+ DiskModeAggregate = 0 // Show one bar per host summing all whole-disk devices
+ DiskModeDevices = 1 // Show one bar per whole-disk device per host
+ DiskModeOff = 2 // Hide disk bars entirely (default)
+ DiskModeCount = 3 // Total number of disk modes for cycling
+)
+
// Exit codes
const (
Success = 0
@@ -58,6 +66,9 @@ var (
Yellow0 = RGB{0xff, 0xa0, 0x00}
Yellow = RGB{0xff, 0xc0, 0x00}
Teal = RGB{0x00, 0xcc, 0xcc} // Load average bar fill
+ DiskRead = RGB{0x90, 0x00, 0xd0} // Purple for disk read throughput
+ DiskWrite = RGB{0x60, 0x00, 0x90} // Darker purple for disk write throughput
+ DiskUtil = RGB{0xff, 0x60, 0x60} // Light red for disk utilization % overlay
)
// BytesPerSec for link speed reference (bytes per second at given mbit)
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 {
diff --git a/internal/stats/stats.go b/internal/stats/stats.go
index ebb81b3..463268f 100644
--- a/internal/stats/stats.go
+++ b/internal/stats/stats.go
@@ -11,12 +11,21 @@ type NetStamp struct {
Stamp float64
}
+// DiskStamp holds disk I/O stats and timestamp for delta calculation.
+type DiskStamp struct {
+ SectorsRead int64
+ SectorsWrite int64
+ IoTicks int64
+ Stamp float64
+}
+
// HostStats holds the latest stats for one host (read-only snapshot).
type HostStats struct {
LoadAvg1, LoadAvg5, LoadAvg15 string
Mem map[string]int64
Net map[string]NetStamp
CPU map[string]collector.CPULine
+ Disk map[string]DiskStamp
}
// Source is the interface the display uses to read current stats.
diff --git a/scripts/loadbars-remote.sh b/scripts/loadbars-remote.sh
index 9037ad8..1bdb661 100644
--- a/scripts/loadbars-remote.sh
+++ b/scripts/loadbars-remote.sh
@@ -26,6 +26,16 @@ while true; do
fi
done < <(tail -n +3 /proc/net/dev 2>/dev/null)
+ # Disk: /proc/diskstats, one line per block device with cumulative counters
+ echo "M DISKSTATS"
+ while IFS= read -r line; do
+ set -- $line
+ # $1=major $2=minor $3=device $4=reads_completed $5=reads_merged
+ # $6=sectors_read $7=ms_reading $8=writes_completed $9=writes_merged
+ # $10=sectors_written $11=ms_writing $12=ios_in_progress $13=ms_io
+ [ -n "$3" ] && echo "$3:rs=${6:-0};ws=${10:-0};rt=${7:-0};wt=${11:-0};io=${13:-0}"
+ done < /proc/diskstats 2>/dev/null
+
# CPU: /proc/stat, 20 times with INTERVAL sleep
echo "M CPUSTATS"
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do