gardesk/gartop / 32386d7

Browse files

Add network and disk I/O monitoring with dedicated tabs

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
32386d77dc21b9ab6242841cc25ed0f903eb7b99
Parents
c8b48f3
Tree
82ff60b

9 changed files

StatusFile+-
M gartop-ipc/src/lib.rs 60 0
A gartop/src/collector/disk.rs 147 0
M gartop/src/collector/mod.rs 5 1
A gartop/src/collector/network.rs 113 0
M gartop/src/daemon/mod.rs 29 1
M gartop/src/daemon/state.rs 24 2
M gartop/src/gui/app.rs 204 2
M gartop/src/gui/tabs.rs 10 4
M gartop/src/gui/theme.rs 4 0
gartop-ipc/src/lib.rsmodified
@@ -26,6 +26,20 @@ pub enum Command {
2626
         #[serde(default)]
2727
         count: Option<usize>,
2828
     },
29
+    /// Get current network stats.
30
+    GetNetwork,
31
+    /// Get network history.
32
+    GetNetworkHistory {
33
+        #[serde(default)]
34
+        count: Option<usize>,
35
+    },
36
+    /// Get current disk I/O stats.
37
+    GetDisk,
38
+    /// Get disk I/O history.
39
+    GetDiskHistory {
40
+        #[serde(default)]
41
+        count: Option<usize>,
42
+    },
2943
     /// Get running processes.
3044
     GetProcesses {
3145
         #[serde(default)]
@@ -156,6 +170,48 @@ pub struct ProcessInfo {
156170
     pub user: String,
157171
 }
158172
 
173
+/// Network interface statistics.
174
+#[derive(Debug, Clone, Serialize, Deserialize)]
175
+pub struct NetworkStats {
176
+    /// Interface name (e.g., "eth0", "wlan0").
177
+    pub interface: String,
178
+    /// Bytes received.
179
+    pub rx_bytes: u64,
180
+    /// Bytes transmitted.
181
+    pub tx_bytes: u64,
182
+    /// Packets received.
183
+    pub rx_packets: u64,
184
+    /// Packets transmitted.
185
+    pub tx_packets: u64,
186
+    /// Receive rate in bytes/sec (calculated from delta).
187
+    pub rx_rate: f64,
188
+    /// Transmit rate in bytes/sec (calculated from delta).
189
+    pub tx_rate: f64,
190
+    /// Timestamp (milliseconds since epoch).
191
+    pub timestamp: u64,
192
+}
193
+
194
+/// Disk I/O statistics.
195
+#[derive(Debug, Clone, Serialize, Deserialize)]
196
+pub struct DiskStats {
197
+    /// Device name (e.g., "sda", "nvme0n1").
198
+    pub device: String,
199
+    /// Bytes read.
200
+    pub read_bytes: u64,
201
+    /// Bytes written.
202
+    pub write_bytes: u64,
203
+    /// Read operations completed.
204
+    pub reads: u64,
205
+    /// Write operations completed.
206
+    pub writes: u64,
207
+    /// Read rate in bytes/sec (calculated from delta).
208
+    pub read_rate: f64,
209
+    /// Write rate in bytes/sec (calculated from delta).
210
+    pub write_rate: f64,
211
+    /// Timestamp (milliseconds since epoch).
212
+    pub timestamp: u64,
213
+}
214
+
159215
 /// Events broadcast to subscribers.
160216
 #[derive(Debug, Clone, Serialize, Deserialize)]
161217
 #[serde(tag = "event", rename_all = "snake_case")]
@@ -166,6 +222,10 @@ pub enum Event {
166222
     MemoryUpdate(MemoryStats),
167223
     /// Process list updated.
168224
     ProcessUpdate { processes: Vec<ProcessInfo> },
225
+    /// Network stats updated.
226
+    NetworkUpdate { interfaces: Vec<NetworkStats> },
227
+    /// Disk stats updated.
228
+    DiskUpdate { disks: Vec<DiskStats> },
169229
 }
170230
 
171231
 /// Daemon status information.
gartop/src/collector/disk.rsadded
@@ -0,0 +1,147 @@
1
+//! Disk I/O statistics collection from /proc/diskstats
2
+
3
+use crate::error::Result;
4
+use gartop_ipc::DiskStats;
5
+use std::collections::HashMap;
6
+use std::fs;
7
+use std::time::{Instant, SystemTime, UNIX_EPOCH};
8
+
9
+/// Disk device data for rate calculation.
10
+struct DiskData {
11
+    read_bytes: u64,
12
+    write_bytes: u64,
13
+    reads: u64,
14
+    writes: u64,
15
+    timestamp: Instant,
16
+}
17
+
18
+/// Disk I/O statistics collector.
19
+pub struct DiskCollector {
20
+    prev_data: HashMap<String, DiskData>,
21
+}
22
+
23
+impl DiskCollector {
24
+    /// Create a new disk collector.
25
+    pub fn new() -> Self {
26
+        Self {
27
+            prev_data: HashMap::new(),
28
+        }
29
+    }
30
+
31
+    /// Collect current disk I/O statistics for all block devices.
32
+    pub fn collect(&mut self) -> Result<Vec<DiskStats>> {
33
+        let contents = fs::read_to_string("/proc/diskstats")?;
34
+        let now = Instant::now();
35
+        let timestamp = SystemTime::now()
36
+            .duration_since(UNIX_EPOCH)
37
+            .map(|d| d.as_millis() as u64)
38
+            .unwrap_or(0);
39
+
40
+        let mut stats = Vec::new();
41
+
42
+        for line in contents.lines() {
43
+            let parts: Vec<&str> = line.split_whitespace().collect();
44
+            if parts.len() < 14 {
45
+                continue;
46
+            }
47
+
48
+            let device = parts[2].to_string();
49
+
50
+            // Only include physical disks, not partitions or device-mapper
51
+            // Include: sda, nvme0n1, vda, etc.
52
+            // Exclude: sda1, nvme0n1p1, loop*, dm-*, ram*
53
+            if !Self::is_physical_disk(&device) {
54
+                continue;
55
+            }
56
+
57
+            // Fields from /proc/diskstats (sector size is typically 512 bytes)
58
+            let reads: u64 = parts[3].parse().unwrap_or(0);
59
+            let read_sectors: u64 = parts[5].parse().unwrap_or(0);
60
+            let writes: u64 = parts[7].parse().unwrap_or(0);
61
+            let write_sectors: u64 = parts[9].parse().unwrap_or(0);
62
+
63
+            // Convert sectors to bytes (512 bytes per sector)
64
+            let read_bytes = read_sectors * 512;
65
+            let write_bytes = write_sectors * 512;
66
+
67
+            // Calculate rates from previous data
68
+            let (read_rate, write_rate) = if let Some(prev) = self.prev_data.get(&device) {
69
+                let elapsed = now.duration_since(prev.timestamp).as_secs_f64();
70
+                if elapsed > 0.0 {
71
+                    let read_delta = read_bytes.saturating_sub(prev.read_bytes) as f64;
72
+                    let write_delta = write_bytes.saturating_sub(prev.write_bytes) as f64;
73
+                    (read_delta / elapsed, write_delta / elapsed)
74
+                } else {
75
+                    (0.0, 0.0)
76
+                }
77
+            } else {
78
+                (0.0, 0.0)
79
+            };
80
+
81
+            // Store current data for next calculation
82
+            self.prev_data.insert(
83
+                device.clone(),
84
+                DiskData {
85
+                    read_bytes,
86
+                    write_bytes,
87
+                    reads,
88
+                    writes,
89
+                    timestamp: now,
90
+                },
91
+            );
92
+
93
+            stats.push(DiskStats {
94
+                device,
95
+                read_bytes,
96
+                write_bytes,
97
+                reads,
98
+                writes,
99
+                read_rate,
100
+                write_rate,
101
+                timestamp,
102
+            });
103
+        }
104
+
105
+        Ok(stats)
106
+    }
107
+
108
+    /// Check if a device name is a physical disk (not a partition or virtual device).
109
+    fn is_physical_disk(device: &str) -> bool {
110
+        // Exclude loop devices
111
+        if device.starts_with("loop") {
112
+            return false;
113
+        }
114
+        // Exclude device-mapper
115
+        if device.starts_with("dm-") {
116
+            return false;
117
+        }
118
+        // Exclude ram disks
119
+        if device.starts_with("ram") {
120
+            return false;
121
+        }
122
+        // Exclude partitions (sda1, nvme0n1p1, etc.)
123
+        if device.chars().last().map(|c| c.is_ascii_digit()).unwrap_or(false) {
124
+            // Check if it's a partition
125
+            if device.starts_with("sd") || device.starts_with("hd") || device.starts_with("vd") {
126
+                // sda, sdb are disks; sda1, sda2 are partitions
127
+                let num_digits = device.chars().rev().take_while(|c| c.is_ascii_digit()).count();
128
+                let alpha_part: String = device.chars().take(device.len() - num_digits).collect();
129
+                // If alpha part ends with letter, it's a partition
130
+                if alpha_part.len() > 2 {
131
+                    return false;
132
+                }
133
+            }
134
+            // NVMe: nvme0n1 is disk, nvme0n1p1 is partition
135
+            if device.contains("nvme") && device.contains('p') {
136
+                return false;
137
+            }
138
+        }
139
+        true
140
+    }
141
+}
142
+
143
+impl Default for DiskCollector {
144
+    fn default() -> Self {
145
+        Self::new()
146
+    }
147
+}
gartop/src/collector/mod.rsmodified
@@ -1,13 +1,17 @@
11
 //! System data collectors
22
 //!
3
-//! Collects CPU, memory, and process data from procfs.
3
+//! Collects CPU, memory, process, network, and disk data from procfs.
44
 
55
 mod cpu;
6
+mod disk;
67
 mod history;
78
 mod memory;
9
+mod network;
810
 mod process;
911
 
1012
 pub use cpu::CpuCollector;
13
+pub use disk::DiskCollector;
1114
 pub use history::History;
1215
 pub use memory::MemoryCollector;
16
+pub use network::NetworkCollector;
1317
 pub use process::ProcessCollector;
gartop/src/collector/network.rsadded
@@ -0,0 +1,113 @@
1
+//! Network statistics collection from /proc/net/dev
2
+
3
+use crate::error::Result;
4
+use gartop_ipc::NetworkStats;
5
+use std::collections::HashMap;
6
+use std::fs;
7
+use std::time::{Instant, SystemTime, UNIX_EPOCH};
8
+
9
+/// Network interface data for rate calculation.
10
+struct InterfaceData {
11
+    rx_bytes: u64,
12
+    tx_bytes: u64,
13
+    rx_packets: u64,
14
+    tx_packets: u64,
15
+    timestamp: Instant,
16
+}
17
+
18
+/// Network statistics collector.
19
+pub struct NetworkCollector {
20
+    prev_data: HashMap<String, InterfaceData>,
21
+}
22
+
23
+impl NetworkCollector {
24
+    /// Create a new network collector.
25
+    pub fn new() -> Self {
26
+        Self {
27
+            prev_data: HashMap::new(),
28
+        }
29
+    }
30
+
31
+    /// Collect current network statistics for all interfaces.
32
+    pub fn collect(&mut self) -> Result<Vec<NetworkStats>> {
33
+        let contents = fs::read_to_string("/proc/net/dev")?;
34
+        let now = Instant::now();
35
+        let timestamp = SystemTime::now()
36
+            .duration_since(UNIX_EPOCH)
37
+            .map(|d| d.as_millis() as u64)
38
+            .unwrap_or(0);
39
+
40
+        let mut stats = Vec::new();
41
+
42
+        for line in contents.lines().skip(2) {
43
+            // Skip header lines
44
+            let line = line.trim();
45
+            if line.is_empty() {
46
+                continue;
47
+            }
48
+
49
+            // Parse: interface: rx_bytes rx_packets ... tx_bytes tx_packets ...
50
+            let parts: Vec<&str> = line.split_whitespace().collect();
51
+            if parts.len() < 10 {
52
+                continue;
53
+            }
54
+
55
+            let interface = parts[0].trim_end_matches(':').to_string();
56
+
57
+            // Skip loopback and virtual interfaces
58
+            if interface == "lo" || interface.starts_with("veth") || interface.starts_with("docker") {
59
+                continue;
60
+            }
61
+
62
+            let rx_bytes: u64 = parts[1].parse().unwrap_or(0);
63
+            let rx_packets: u64 = parts[2].parse().unwrap_or(0);
64
+            let tx_bytes: u64 = parts[9].parse().unwrap_or(0);
65
+            let tx_packets: u64 = parts[10].parse().unwrap_or(0);
66
+
67
+            // Calculate rates from previous data
68
+            let (rx_rate, tx_rate) = if let Some(prev) = self.prev_data.get(&interface) {
69
+                let elapsed = now.duration_since(prev.timestamp).as_secs_f64();
70
+                if elapsed > 0.0 {
71
+                    let rx_delta = rx_bytes.saturating_sub(prev.rx_bytes) as f64;
72
+                    let tx_delta = tx_bytes.saturating_sub(prev.tx_bytes) as f64;
73
+                    (rx_delta / elapsed, tx_delta / elapsed)
74
+                } else {
75
+                    (0.0, 0.0)
76
+                }
77
+            } else {
78
+                (0.0, 0.0)
79
+            };
80
+
81
+            // Store current data for next calculation
82
+            self.prev_data.insert(
83
+                interface.clone(),
84
+                InterfaceData {
85
+                    rx_bytes,
86
+                    tx_bytes,
87
+                    rx_packets,
88
+                    tx_packets,
89
+                    timestamp: now,
90
+                },
91
+            );
92
+
93
+            stats.push(NetworkStats {
94
+                interface,
95
+                rx_bytes,
96
+                tx_bytes,
97
+                rx_packets,
98
+                tx_packets,
99
+                rx_rate,
100
+                tx_rate,
101
+                timestamp,
102
+            });
103
+        }
104
+
105
+        Ok(stats)
106
+    }
107
+}
108
+
109
+impl Default for NetworkCollector {
110
+    fn default() -> Self {
111
+        Self::new()
112
+    }
113
+}
gartop/src/daemon/mod.rsmodified
@@ -22,7 +22,7 @@ pub async fn run(config_path: Option<String>, _foreground: bool) -> Result<()> {
2222
     )?));
2323
     let server = IpcServer::new().await?;
2424
 
25
-    // CPU/Memory collection loop
25
+    // CPU/Memory/Network/Disk collection loop
2626
     let sample_interval = std::time::Duration::from_millis(daemon_config.sample_interval_ms);
2727
     let state_clone = state.clone();
2828
     tokio::spawn(async move {
@@ -36,6 +36,12 @@ pub async fn run(config_path: Option<String>, _foreground: bool) -> Result<()> {
3636
             if let Err(e) = s.collect_memory() {
3737
                 debug!("Memory collect error: {}", e);
3838
             }
39
+            if let Err(e) = s.collect_network() {
40
+                debug!("Network collect error: {}", e);
41
+            }
42
+            if let Err(e) = s.collect_disk() {
43
+                debug!("Disk collect error: {}", e);
44
+            }
3945
         }
4046
     });
4147
 
@@ -109,6 +115,28 @@ async fn handle_client(
109115
                     };
110116
                     Response::ok_with_data(data)
111117
                 }
118
+                Command::GetNetwork => match s.network_history.latest() {
119
+                    Some(stats) => Response::ok_with_data(stats),
120
+                    None => Response::err("No data yet"),
121
+                },
122
+                Command::GetNetworkHistory { count } => {
123
+                    let data = match count {
124
+                        Some(n) => s.network_history.last_n(n),
125
+                        None => s.network_history.to_vec(),
126
+                    };
127
+                    Response::ok_with_data(data)
128
+                }
129
+                Command::GetDisk => match s.disk_history.latest() {
130
+                    Some(stats) => Response::ok_with_data(stats),
131
+                    None => Response::err("No data yet"),
132
+                },
133
+                Command::GetDiskHistory { count } => {
134
+                    let data = match count {
135
+                        Some(n) => s.disk_history.last_n(n),
136
+                        None => s.disk_history.to_vec(),
137
+                    };
138
+                    Response::ok_with_data(data)
139
+                }
112140
                 Command::GetProcesses { sort_by, limit } => {
113141
                     match s.collect_processes(sort_by.unwrap_or_default(), limit) {
114142
                         Ok(procs) => Response::ok_with_data(procs),
gartop/src/daemon/state.rsmodified
@@ -1,17 +1,21 @@
11
 //! Daemon state management
22
 
3
-use crate::collector::{CpuCollector, History, MemoryCollector, ProcessCollector};
3
+use crate::collector::{CpuCollector, DiskCollector, History, MemoryCollector, NetworkCollector, ProcessCollector};
44
 use crate::error::Result;
5
-use gartop_ipc::{CpuStats, MemoryStats, ProcessInfo, SortField};
5
+use gartop_ipc::{CpuStats, DiskStats, MemoryStats, NetworkStats, ProcessInfo, SortField};
66
 use std::time::Instant;
77
 
88
 /// Shared daemon state.
99
 pub struct DaemonState {
1010
     pub cpu_collector: CpuCollector,
1111
     pub memory_collector: MemoryCollector,
12
+    pub network_collector: NetworkCollector,
13
+    pub disk_collector: DiskCollector,
1214
     pub process_collector: ProcessCollector,
1315
     pub cpu_history: History<CpuStats>,
1416
     pub memory_history: History<MemoryStats>,
17
+    pub network_history: History<Vec<NetworkStats>>,
18
+    pub disk_history: History<Vec<DiskStats>>,
1519
     pub processes: Vec<ProcessInfo>,
1620
     pub started: Instant,
1721
     pub sample_interval_ms: u64,
@@ -23,9 +27,13 @@ impl DaemonState {
2327
         Ok(Self {
2428
             cpu_collector: CpuCollector::new()?,
2529
             memory_collector: MemoryCollector::new(),
30
+            network_collector: NetworkCollector::new(),
31
+            disk_collector: DiskCollector::new(),
2632
             process_collector: ProcessCollector::new()?,
2733
             cpu_history: History::new(history_size),
2834
             memory_history: History::new(history_size),
35
+            network_history: History::new(history_size),
36
+            disk_history: History::new(history_size),
2937
             processes: Vec::new(),
3038
             started: Instant::now(),
3139
             sample_interval_ms,
@@ -46,6 +54,20 @@ impl DaemonState {
4654
         Ok(stats)
4755
     }
4856
 
57
+    /// Collect network stats and add to history.
58
+    pub fn collect_network(&mut self) -> Result<Vec<NetworkStats>> {
59
+        let stats = self.network_collector.collect()?;
60
+        self.network_history.push(stats.clone());
61
+        Ok(stats)
62
+    }
63
+
64
+    /// Collect disk stats and add to history.
65
+    pub fn collect_disk(&mut self) -> Result<Vec<DiskStats>> {
66
+        let stats = self.disk_collector.collect()?;
67
+        self.disk_history.push(stats.clone());
68
+        Ok(stats)
69
+    }
70
+
4971
     /// Collect process list.
5072
     pub fn collect_processes(
5173
         &mut self,
gartop/src/gui/app.rsmodified
@@ -12,7 +12,7 @@ use anyhow::Result;
1212
 use gartk_core::{InputEvent, Key, Point, Rect};
1313
 use gartk_render::{Renderer, TextStyle};
1414
 use gartk_x11::{Connection, EventLoop, EventLoopConfig, Window, WindowConfig};
15
-use gartop_ipc::{Command, CpuStats, MemoryStats, ProcessInfo, Response, SortField, StatusInfo};
15
+use gartop_ipc::{Command, CpuStats, DiskStats, MemoryStats, NetworkStats, ProcessInfo, Response, SortField, StatusInfo};
1616
 use std::io::{BufRead, BufReader, Write};
1717
 use std::os::unix::net::UnixStream;
1818
 use std::time::Instant;
@@ -49,8 +49,12 @@ pub struct App {
4949
     status: Option<StatusInfo>,
5050
     cpu_stats: Option<CpuStats>,
5151
     memory_stats: Option<MemoryStats>,
52
+    network_stats: Vec<NetworkStats>,
53
+    disk_stats: Vec<DiskStats>,
5254
     cpu_history: Vec<CpuStats>,
5355
     memory_history: Vec<MemoryStats>,
56
+    network_history: Vec<Vec<NetworkStats>>,
57
+    disk_history: Vec<Vec<DiskStats>>,
5458
     processes: Vec<ProcessInfo>,
5559
 }
5660
 
@@ -117,8 +121,12 @@ impl App {
117121
             status: None,
118122
             cpu_stats: None,
119123
             memory_stats: None,
124
+            network_stats: Vec::new(),
125
+            disk_stats: Vec::new(),
120126
             cpu_history: Vec::new(),
121127
             memory_history: Vec::new(),
128
+            network_history: Vec::new(),
129
+            disk_history: Vec::new(),
122130
             processes: Vec::new(),
123131
         })
124132
     }
@@ -205,10 +213,39 @@ impl App {
205213
             }
206214
         }
207215
 
216
+        // Get network stats
217
+        if let Some(resp) = self.send_command(&Command::GetNetwork) {
218
+            if resp.success {
219
+                self.network_stats = resp.data.and_then(|d| serde_json::from_value(d).ok()).unwrap_or_default();
220
+            }
221
+        }
222
+
223
+        // Get network history
224
+        if let Some(resp) = self.send_command(&Command::GetNetworkHistory { count: Some(60) }) {
225
+            if resp.success {
226
+                self.network_history = resp.data.and_then(|d| serde_json::from_value(d).ok()).unwrap_or_default();
227
+            }
228
+        }
229
+
230
+        // Get disk stats
231
+        if let Some(resp) = self.send_command(&Command::GetDisk) {
232
+            if resp.success {
233
+                self.disk_stats = resp.data.and_then(|d| serde_json::from_value(d).ok()).unwrap_or_default();
234
+            }
235
+        }
236
+
237
+        // Get disk history
238
+        if let Some(resp) = self.send_command(&Command::GetDiskHistory { count: Some(60) }) {
239
+            if resp.success {
240
+                self.disk_history = resp.data.and_then(|d| serde_json::from_value(d).ok()).unwrap_or_default();
241
+            }
242
+        }
243
+
208244
         // Get processes sorted by current tab's resource
209245
         let sort_field = match self.tab_bar.active() {
210246
             Tab::Cpu => SortField::Cpu,
211247
             Tab::Memory => SortField::Memory,
248
+            Tab::Network | Tab::Disk => SortField::Cpu, // Default for I/O tabs
212249
         };
213250
         if let Some(resp) = self.send_command(&Command::GetProcesses {
214251
             sort_by: Some(sort_field),
@@ -398,6 +435,140 @@ impl App {
398435
                 }).collect());
399436
                 graph.render(&ctx, graph_rect, &[mem_series, swap_series], &self.theme);
400437
             }
438
+
439
+            Tab::Network => {
440
+                // Network label - show total rx/tx rates across all interfaces
441
+                let (total_rx, total_tx) = self.network_stats.iter().fold((0.0, 0.0), |acc, s| {
442
+                    (acc.0 + s.rx_rate, acc.1 + s.tx_rate)
443
+                });
444
+                let net_label = format!(
445
+                    "Network: {} \u{2193} / {} \u{2191}",
446
+                    format_rate(total_rx),
447
+                    format_rate(total_tx)
448
+                );
449
+                self.renderer.text(
450
+                    &net_label,
451
+                    CONTENT_PADDING as f64,
452
+                    y as f64 + 14.0,
453
+                    &TextStyle {
454
+                        font_family: "monospace".to_string(),
455
+                        font_size: 12.0,
456
+                        color: self.theme.network_color,
457
+                        ..Default::default()
458
+                    },
459
+                )?;
460
+
461
+                // Interface count on right
462
+                let iface_info = format!("{} interfaces", self.network_stats.len());
463
+                self.renderer.text(
464
+                    &iface_info,
465
+                    (self.width - 100) as f64,
466
+                    y as f64 + 14.0,
467
+                    &TextStyle {
468
+                        font_family: "monospace".to_string(),
469
+                        font_size: 10.0,
470
+                        color: self.theme.text_secondary,
471
+                        ..Default::default()
472
+                    },
473
+                )?;
474
+                y += 20;
475
+
476
+                // Network Graph - show rx/tx rates over time
477
+                let graph_rect = Rect::new(CONTENT_PADDING as i32, y, graph_width, GRAPH_HEIGHT);
478
+                let mut rx_series = DataSeries::new("Download", self.theme.network_color);
479
+                let mut tx_series = DataSeries::new("Upload", self.theme.swap_color);
480
+
481
+                // Calculate max rate for scaling
482
+                let max_rate = self.network_history.iter()
483
+                    .flat_map(|stats| stats.iter().map(|s| s.rx_rate.max(s.tx_rate)))
484
+                    .fold(1.0, f64::max);
485
+
486
+                // Convert rates to percentage of max for graph
487
+                rx_series.set_values(
488
+                    self.network_history.iter()
489
+                        .map(|stats| {
490
+                            let total: f64 = stats.iter().map(|s| s.rx_rate).sum();
491
+                            (total / max_rate) * 100.0
492
+                        })
493
+                        .collect()
494
+                );
495
+                tx_series.set_values(
496
+                    self.network_history.iter()
497
+                        .map(|stats| {
498
+                            let total: f64 = stats.iter().map(|s| s.tx_rate).sum();
499
+                            (total / max_rate) * 100.0
500
+                        })
501
+                        .collect()
502
+                );
503
+                graph.render(&ctx, graph_rect, &[rx_series, tx_series], &self.theme);
504
+            }
505
+
506
+            Tab::Disk => {
507
+                // Disk label - show total read/write rates
508
+                let (total_read, total_write) = self.disk_stats.iter().fold((0.0, 0.0), |acc, s| {
509
+                    (acc.0 + s.read_rate, acc.1 + s.write_rate)
510
+                });
511
+                let disk_label = format!(
512
+                    "Disk: {} read / {} write",
513
+                    format_rate(total_read),
514
+                    format_rate(total_write)
515
+                );
516
+                self.renderer.text(
517
+                    &disk_label,
518
+                    CONTENT_PADDING as f64,
519
+                    y as f64 + 14.0,
520
+                    &TextStyle {
521
+                        font_family: "monospace".to_string(),
522
+                        font_size: 12.0,
523
+                        color: self.theme.disk_color,
524
+                        ..Default::default()
525
+                    },
526
+                )?;
527
+
528
+                // Device count on right
529
+                let dev_info = format!("{} devices", self.disk_stats.len());
530
+                self.renderer.text(
531
+                    &dev_info,
532
+                    (self.width - 100) as f64,
533
+                    y as f64 + 14.0,
534
+                    &TextStyle {
535
+                        font_family: "monospace".to_string(),
536
+                        font_size: 10.0,
537
+                        color: self.theme.text_secondary,
538
+                        ..Default::default()
539
+                    },
540
+                )?;
541
+                y += 20;
542
+
543
+                // Disk Graph - show read/write rates over time
544
+                let graph_rect = Rect::new(CONTENT_PADDING as i32, y, graph_width, GRAPH_HEIGHT);
545
+                let mut read_series = DataSeries::new("Read", self.theme.disk_color);
546
+                let mut write_series = DataSeries::new("Write", self.theme.swap_color);
547
+
548
+                // Calculate max rate for scaling
549
+                let max_rate = self.disk_history.iter()
550
+                    .flat_map(|stats| stats.iter().map(|s| s.read_rate.max(s.write_rate)))
551
+                    .fold(1.0, f64::max);
552
+
553
+                // Convert rates to percentage of max for graph
554
+                read_series.set_values(
555
+                    self.disk_history.iter()
556
+                        .map(|stats| {
557
+                            let total: f64 = stats.iter().map(|s| s.read_rate).sum();
558
+                            (total / max_rate) * 100.0
559
+                        })
560
+                        .collect()
561
+                );
562
+                write_series.set_values(
563
+                    self.disk_history.iter()
564
+                        .map(|stats| {
565
+                            let total: f64 = stats.iter().map(|s| s.write_rate).sum();
566
+                            (total / max_rate) * 100.0
567
+                        })
568
+                        .collect()
569
+                );
570
+                graph.render(&ctx, graph_rect, &[read_series, write_series], &self.theme);
571
+            }
401572
         }
402573
 
403574
         // Render process list
@@ -463,6 +634,7 @@ impl App {
463634
                 let sort_field = match tab {
464635
                     Tab::Cpu => SortField::Cpu,
465636
                     Tab::Memory => SortField::Memory,
637
+                    Tab::Network | Tab::Disk => SortField::Cpu,
466638
                 };
467639
                 self.process_list.set_sort(sort_field);
468640
                 // Force refresh to get re-sorted processes
@@ -547,15 +719,28 @@ impl App {
547719
                             self.last_refresh = Instant::now() - std::time::Duration::from_secs(10);
548720
                             ev_loop.request_redraw();
549721
                         }
722
+                        Key::Char('3') => {
723
+                            self.tab_bar.set_active(Tab::Network);
724
+                            self.last_refresh = Instant::now() - std::time::Duration::from_secs(10);
725
+                            ev_loop.request_redraw();
726
+                        }
727
+                        Key::Char('4') => {
728
+                            self.tab_bar.set_active(Tab::Disk);
729
+                            self.last_refresh = Instant::now() - std::time::Duration::from_secs(10);
730
+                            ev_loop.request_redraw();
731
+                        }
550732
                         Key::Tab => {
551733
                             let new_tab = match self.tab_bar.active() {
552734
                                 Tab::Cpu => Tab::Memory,
553
-                                Tab::Memory => Tab::Cpu,
735
+                                Tab::Memory => Tab::Network,
736
+                                Tab::Network => Tab::Disk,
737
+                                Tab::Disk => Tab::Cpu,
554738
                             };
555739
                             self.tab_bar.set_active(new_tab);
556740
                             let sort_field = match new_tab {
557741
                                 Tab::Cpu => SortField::Cpu,
558742
                                 Tab::Memory => SortField::Memory,
743
+                                Tab::Network | Tab::Disk => SortField::Cpu,
559744
                             };
560745
                             self.process_list.set_sort(sort_field);
561746
                             self.last_refresh = Instant::now() - std::time::Duration::from_secs(10);
@@ -627,3 +812,20 @@ fn format_bytes(bytes: u64) -> String {
627812
         format!("{} B", bytes)
628813
     }
629814
 }
815
+
816
+/// Format rate (bytes/second) to human-readable string.
817
+fn format_rate(bytes_per_sec: f64) -> String {
818
+    const KIB: f64 = 1024.0;
819
+    const MIB: f64 = KIB * 1024.0;
820
+    const GIB: f64 = MIB * 1024.0;
821
+
822
+    if bytes_per_sec >= GIB {
823
+        format!("{:.1} GiB/s", bytes_per_sec / GIB)
824
+    } else if bytes_per_sec >= MIB {
825
+        format!("{:.1} MiB/s", bytes_per_sec / MIB)
826
+    } else if bytes_per_sec >= KIB {
827
+        format!("{:.1} KiB/s", bytes_per_sec / KIB)
828
+    } else {
829
+        format!("{:.0} B/s", bytes_per_sec)
830
+    }
831
+}
gartop/src/gui/tabs.rsmodified
@@ -1,4 +1,4 @@
1
-//! Tab bar component for switching between CPU and Memory views
1
+//! Tab bar component for switching between CPU, Memory, Network, and Disk views
22
 
33
 use gartk_core::{Point, Rect};
44
 use gartk_render::{Renderer, TextStyle};
@@ -10,12 +10,14 @@ pub enum Tab {
1010
     #[default]
1111
     Cpu,
1212
     Memory,
13
+    Network,
14
+    Disk,
1315
 }
1416
 
1517
 impl Tab {
1618
     /// Get all tabs in order.
1719
     pub fn all() -> &'static [Tab] {
18
-        &[Tab::Cpu, Tab::Memory]
20
+        &[Tab::Cpu, Tab::Memory, Tab::Network, Tab::Disk]
1921
     }
2022
 
2123
     /// Get tab label.
@@ -23,6 +25,8 @@ impl Tab {
2325
         match self {
2426
             Tab::Cpu => "CPU",
2527
             Tab::Memory => "Memory",
28
+            Tab::Network => "Network",
29
+            Tab::Disk => "Disk",
2630
         }
2731
     }
2832
 }
@@ -53,12 +57,12 @@ impl TabBar {
5357
     /// Calculate bounds for each tab.
5458
     fn calculate_tab_bounds(bounds: Rect) -> Vec<Rect> {
5559
         let tabs = Tab::all();
56
-        let tab_width = 100u32;
60
+        let tab_width = 80u32;
5761
         let tab_height = bounds.height - 6; // Small bottom margin
5862
         let mut result = Vec::with_capacity(tabs.len());
5963
 
6064
         for (i, _) in tabs.iter().enumerate() {
61
-            let x = bounds.x + 12 + (i as i32 * (tab_width as i32 + 8));
65
+            let x = bounds.x + 12 + (i as i32 * (tab_width as i32 + 6));
6266
             let y = bounds.y + 2; // Small top margin
6367
             result.push(Rect::new(x, y, tab_width, tab_height));
6468
         }
@@ -142,6 +146,8 @@ impl TabBar {
142146
                 match tab {
143147
                     Tab::Cpu => theme.cpu_color,
144148
                     Tab::Memory => theme.memory_color,
149
+                    Tab::Network => theme.network_color,
150
+                    Tab::Disk => theme.disk_color,
145151
                 }
146152
             } else {
147153
                 theme.text_secondary
gartop/src/gui/theme.rsmodified
@@ -13,6 +13,8 @@ pub struct Theme {
1313
     pub cpu_color: Color,
1414
     pub memory_color: Color,
1515
     pub swap_color: Color,
16
+    pub network_color: Color,
17
+    pub disk_color: Color,
1618
     pub border: Color,
1719
     pub graph_bg: Color,
1820
     pub graph_grid: Color,
@@ -32,6 +34,8 @@ impl Default for Theme {
3234
             cpu_color: Color::from_hex("#f38ba8").unwrap_or(Color::WHITE),
3335
             memory_color: Color::from_hex("#a6e3a1").unwrap_or(Color::WHITE),
3436
             swap_color: Color::from_hex("#f9e2af").unwrap_or(Color::WHITE),
37
+            network_color: Color::from_hex("#94e2d5").unwrap_or(Color::WHITE),
38
+            disk_color: Color::from_hex("#cba6f7").unwrap_or(Color::WHITE),
3539
             border: Color::from_hex("#585b70").unwrap_or(Color::WHITE),
3640
             graph_bg: Color::from_hex("#11111b").unwrap_or(Color::BLACK),
3741
             graph_grid: Color::from_hex("#313244").unwrap_or(Color::BLACK),