diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-18 14:14:52 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-18 14:14:52 +0200 |
| commit | f7887117c5269ed0336cc058c78c4b222e0e6b34 (patch) | |
| tree | 53e9c4b5163b29b0987beda78572ecdb13c31cf0 | |
| parent | 69f5017434298f1ffd4cdc30c30b95d0f4bd344f (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.md | 15 | ||||
| -rw-r--r-- | cmd/loadbars/main.go | 5 | ||||
| -rw-r--r-- | internal/app/store.go | 26 | ||||
| -rw-r--r-- | internal/app/store_test.go | 71 | ||||
| -rw-r--r-- | internal/collector/collector.go | 5 | ||||
| -rw-r--r-- | internal/collector/parse.go | 30 | ||||
| -rw-r--r-- | internal/collector/parse_test.go | 43 | ||||
| -rw-r--r-- | internal/collector/protocol.go | 3 | ||||
| -rw-r--r-- | internal/collector/scriptdata/loadbars-remote.sh | 10 | ||||
| -rw-r--r-- | internal/collector/types.go | 10 | ||||
| -rw-r--r-- | internal/config/config.go | 24 | ||||
| -rw-r--r-- | internal/constants/constants.go | 11 | ||||
| -rw-r--r-- | internal/display/disk.go | 220 | ||||
| -rw-r--r-- | internal/display/display.go | 60 | ||||
| -rw-r--r-- | internal/display/display_test.go | 226 | ||||
| -rw-r--r-- | internal/display/hittest.go | 23 | ||||
| -rw-r--r-- | internal/display/tooltip.go | 36 | ||||
| -rw-r--r-- | internal/stats/stats.go | 9 | ||||
| -rw-r--r-- | scripts/loadbars-remote.sh | 10 |
19 files changed, 818 insertions, 19 deletions
@@ -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 |
