Skip to content

Commit 28b0557

Browse files
authored
feat: add GPU process filter and improve sort stability (#117)
* feat: add GPU process filter and improve sort stability (#114) Add the ability to filter the process list to show only GPU processes (those with used_memory > 0) and improve sort stability to prevent processes from constantly shifting positions during monitoring. Changes: - Add gpu_filter_enabled flag to AppState for tracking filter state - Add 'f' keyboard shortcut to toggle GPU-only process filter - Apply GPU filter in the render pipeline before displaying processes - Update status bar to show [Filter:GPU] when filter is active - Use PID as secondary sort key in process sorting to ensure stable ordering when primary values are equal - Update help text to show new filter shortcut and filter status Closes #114 * fix: resolve GPU filter selection and performance issues This commit addresses two MEDIUM priority issues in PR #117: 1. Selection Index Inconsistency: Reset selected_process_index and start_index to 0 when enabling GPU filter to prevent out-of-bounds errors when the filtered list is shorter than the unfiltered list. This provides a clean UX by jumping to the top of the filtered list. 2. Memory Allocation Overhead: Use Cow<'_, [ProcessInfo]> pattern to eliminate unnecessary cloning. When filter is disabled, we now borrow the process list instead of cloning it, significantly reducing allocations on every render cycle. Changes: - event_handler.rs: Add index reset logic when toggling filter on - ui_loop.rs: Replace Vec clone with Cow for conditional ownership - app_state.rs: Auto-formatting changes only * chore: finalize PR with tests, docs, and lint fixes - Add comprehensive unit tests for GPU filter functionality - Add tests for sort stability with PID as secondary sort key - Update README with new 'f' key for GPU process filter - Update man page with complete keyboard shortcuts documentation - Fix clippy warnings in storage module by marking public API items as allowed unused (used by library consumers, not binary)
1 parent 2011071 commit 28b0557

File tree

10 files changed

+301
-16
lines changed

10 files changed

+301
-16
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ http://gpu-node3:9090
299299
- Keyboard: Arrow keys, Page Up/Down, Tab switching
300300
- Mouse: Click column headers to sort (process view)
301301
- Sorting: 'd' (default), 'u' (utilization), 'g' (GPU memory), 'p' (PID), 'm' (memory), 'c' (CPU)
302+
- Filtering: 'f' (toggle GPU process filter - show only processes with GPU memory usage)
302303
- Interface: '1'/'h' (help), 'q' (quit), ESC (close help)
303304
- **Visual Design:**
304305
- Color-coded status: Green (≤60%), Yellow (60-80%), Red (>80%)

docs/man/all-smi.1

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,28 @@ Jump to the first/last item
127127
Refresh the display immediately
128128
.TP
129129
.B d
130-
Toggle debug log visibility
130+
Sort by default (hostname+index)
131+
.TP
132+
.B f
133+
Toggle GPU process filter (show only processes with GPU memory usage)
134+
.TP
135+
.B g
136+
Sort by GPU memory usage
137+
.TP
138+
.B m
139+
Sort by memory percentage
140+
.TP
141+
.B p
142+
Sort by PID
143+
.TP
144+
.B u
145+
Sort by utilization
146+
.TP
147+
.B c
148+
Toggle per-core CPU display
149+
.TP
150+
.B h / 1
151+
Toggle help screen
131152
.TP
132153
.B q, Ctrl+C
133154
Quit the application

src/app_state.rs

Lines changed: 211 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ pub struct AppState {
122122
pub runtime_environment: RuntimeEnvironment,
123123
/// Version counter that increments when data changes, used to detect if re-render is needed
124124
pub data_version: u64,
125+
/// Filter to show only GPU processes (processes with used_memory > 0)
126+
pub gpu_filter_enabled: bool,
125127
}
126128

127129
#[derive(Clone, Copy, PartialEq, Debug)]
@@ -213,6 +215,7 @@ impl AppState {
213215
is_local_mode: true, // Default to local mode
214216
runtime_environment: RuntimeEnvironment::detect(),
215217
data_version: 0,
218+
gpu_filter_enabled: false, // GPU filter disabled by default
216219
}
217220
}
218221

@@ -346,27 +349,42 @@ impl SortCriteria {
346349
) -> Ordering {
347350
let base_ordering = match self {
348351
SortCriteria::Pid => a.pid.cmp(&b.pid),
349-
SortCriteria::User => a.user.cmp(&b.user),
350-
SortCriteria::Priority => a.priority.cmp(&b.priority),
351-
SortCriteria::Nice => a.nice_value.cmp(&b.nice_value),
352-
SortCriteria::VirtualMemory => a.memory_vms.cmp(&b.memory_vms),
353-
SortCriteria::ResidentMemory => a.memory_rss.cmp(&b.memory_rss),
354-
SortCriteria::State => a.state.cmp(&b.state),
352+
SortCriteria::User => a.user.cmp(&b.user).then_with(|| a.pid.cmp(&b.pid)),
353+
SortCriteria::Priority => a.priority.cmp(&b.priority).then_with(|| a.pid.cmp(&b.pid)),
354+
SortCriteria::Nice => a
355+
.nice_value
356+
.cmp(&b.nice_value)
357+
.then_with(|| a.pid.cmp(&b.pid)),
358+
SortCriteria::VirtualMemory => a
359+
.memory_vms
360+
.cmp(&b.memory_vms)
361+
.then_with(|| a.pid.cmp(&b.pid)),
362+
SortCriteria::ResidentMemory => a
363+
.memory_rss
364+
.cmp(&b.memory_rss)
365+
.then_with(|| a.pid.cmp(&b.pid)),
366+
SortCriteria::State => a.state.cmp(&b.state).then_with(|| a.pid.cmp(&b.pid)),
355367
SortCriteria::CpuPercent => a
356368
.cpu_percent
357369
.partial_cmp(&b.cpu_percent)
358-
.unwrap_or(Ordering::Equal),
370+
.unwrap_or(Ordering::Equal)
371+
.then_with(|| a.pid.cmp(&b.pid)),
359372
SortCriteria::MemoryPercent => a
360373
.memory_percent
361374
.partial_cmp(&b.memory_percent)
362-
.unwrap_or(Ordering::Equal),
375+
.unwrap_or(Ordering::Equal)
376+
.then_with(|| a.pid.cmp(&b.pid)),
363377
SortCriteria::GpuPercent => a
364378
.gpu_utilization
365379
.partial_cmp(&b.gpu_utilization)
366-
.unwrap_or(Ordering::Equal),
367-
SortCriteria::GpuMemoryUsage => a.used_memory.cmp(&b.used_memory),
368-
SortCriteria::CpuTime => a.cpu_time.cmp(&b.cpu_time),
369-
SortCriteria::Command => a.command.cmp(&b.command),
380+
.unwrap_or(Ordering::Equal)
381+
.then_with(|| a.pid.cmp(&b.pid)),
382+
SortCriteria::GpuMemoryUsage => a
383+
.used_memory
384+
.cmp(&b.used_memory)
385+
.then_with(|| a.pid.cmp(&b.pid)),
386+
SortCriteria::CpuTime => a.cpu_time.cmp(&b.cpu_time).then_with(|| a.pid.cmp(&b.pid)),
387+
SortCriteria::Command => a.command.cmp(&b.command).then_with(|| a.pid.cmp(&b.pid)),
370388
// For GPU-related sorting or default, sort by PID
371389
_ => a.pid.cmp(&b.pid),
372390
};
@@ -397,4 +415,185 @@ mod tests {
397415
let default_state = AppState::new();
398416
assert!(default_state.is_local_mode);
399417
}
418+
419+
#[test]
420+
fn test_gpu_filter_default() {
421+
let state = AppState::new();
422+
// GPU filter should be disabled by default
423+
assert!(!state.gpu_filter_enabled);
424+
}
425+
426+
#[test]
427+
fn test_gpu_filter_toggle() {
428+
let mut state = AppState::new();
429+
assert!(!state.gpu_filter_enabled);
430+
431+
// Enable filter
432+
state.gpu_filter_enabled = true;
433+
assert!(state.gpu_filter_enabled);
434+
435+
// Disable filter
436+
state.gpu_filter_enabled = false;
437+
assert!(!state.gpu_filter_enabled);
438+
}
439+
440+
#[test]
441+
fn test_data_version_increment() {
442+
let mut state = AppState::new();
443+
let initial_version = state.data_version;
444+
445+
state.mark_data_changed();
446+
assert_eq!(state.data_version, initial_version + 1);
447+
448+
state.mark_data_changed();
449+
assert_eq!(state.data_version, initial_version + 2);
450+
}
451+
452+
fn create_test_process(pid: u32, used_memory: u64) -> ProcessInfo {
453+
ProcessInfo {
454+
device_id: 0,
455+
device_uuid: "test-uuid".to_string(),
456+
pid,
457+
used_memory,
458+
process_name: format!("process_{pid}"),
459+
user: "testuser".to_string(),
460+
state: "S".to_string(),
461+
command: format!("/usr/bin/process_{pid}"),
462+
cpu_percent: 10.0,
463+
memory_percent: 5.0,
464+
gpu_utilization: 0.0,
465+
priority: 20,
466+
nice_value: 0,
467+
memory_vms: 1024 * 1024,
468+
memory_rss: 512 * 1024,
469+
cpu_time: 100,
470+
start_time: "00:00:00".to_string(),
471+
ppid: 1,
472+
threads: 1,
473+
uses_gpu: used_memory > 0,
474+
}
475+
}
476+
477+
#[test]
478+
fn test_sort_processes_by_pid_with_stability() {
479+
// Test that sorting is stable - equal primary keys should be sorted by PID
480+
let p1 = create_test_process(100, 1024);
481+
let p2 = create_test_process(200, 1024);
482+
let p3 = create_test_process(50, 1024);
483+
484+
let criteria = SortCriteria::GpuMemoryUsage;
485+
486+
// All have same GPU memory, so they should be sorted by PID as secondary key
487+
// In descending order, higher PID comes first (reversed from ascending)
488+
let ordering = criteria.sort_processes(&p1, &p2, SortDirection::Descending);
489+
assert_eq!(
490+
ordering,
491+
Ordering::Greater,
492+
"p1 (pid 100) should come after p2 (pid 200) in descending order"
493+
);
494+
495+
// In ascending order, lower PID comes first
496+
let ordering = criteria.sort_processes(&p3, &p1, SortDirection::Ascending);
497+
assert_eq!(
498+
ordering,
499+
Ordering::Less,
500+
"p3 (pid 50) should come before p1 (pid 100) in ascending order"
501+
);
502+
}
503+
504+
#[test]
505+
fn test_sort_processes_by_gpu_memory() {
506+
let p1 = create_test_process(100, 1024);
507+
let p2 = create_test_process(200, 2048);
508+
509+
let criteria = SortCriteria::GpuMemoryUsage;
510+
511+
// In descending order, higher memory should come first
512+
let ordering = criteria.sort_processes(&p1, &p2, SortDirection::Descending);
513+
assert_eq!(
514+
ordering,
515+
Ordering::Greater,
516+
"p1 (1024 MB) should come after p2 (2048 MB) in descending order"
517+
);
518+
519+
// In ascending order, lower memory should come first
520+
let ordering = criteria.sort_processes(&p1, &p2, SortDirection::Ascending);
521+
assert_eq!(
522+
ordering,
523+
Ordering::Less,
524+
"p1 (1024 MB) should come before p2 (2048 MB) in ascending order"
525+
);
526+
}
527+
528+
#[test]
529+
fn test_sort_processes_by_cpu_percent_with_stability() {
530+
let mut p1 = create_test_process(100, 0);
531+
let mut p2 = create_test_process(200, 0);
532+
let mut p3 = create_test_process(50, 0);
533+
534+
p1.cpu_percent = 50.0;
535+
p2.cpu_percent = 50.0;
536+
p3.cpu_percent = 50.0;
537+
538+
let criteria = SortCriteria::CpuPercent;
539+
540+
// All have same CPU%, so they should be sorted by PID as secondary key
541+
// In ascending order, lower PID comes first
542+
let ordering = criteria.sort_processes(&p1, &p2, SortDirection::Ascending);
543+
assert_eq!(
544+
ordering,
545+
Ordering::Less,
546+
"p1 (pid 100) should come before p2 (pid 200) when CPU% is equal (ascending)"
547+
);
548+
549+
// In descending order, higher PID comes first (reversed)
550+
let ordering = criteria.sort_processes(&p3, &p1, SortDirection::Descending);
551+
assert_eq!(
552+
ordering,
553+
Ordering::Greater,
554+
"p3 (pid 50) should come after p1 (pid 100) in descending order"
555+
);
556+
}
557+
558+
#[test]
559+
fn test_sort_processes_multiple_criteria() {
560+
let mut p1 = create_test_process(100, 1024);
561+
let mut p2 = create_test_process(200, 2048);
562+
let mut p3 = create_test_process(50, 1024);
563+
564+
p1.memory_percent = 10.0;
565+
p2.memory_percent = 20.0;
566+
p3.memory_percent = 10.0;
567+
568+
// Test MemoryPercent criteria
569+
let criteria = SortCriteria::MemoryPercent;
570+
let ordering = criteria.sort_processes(&p1, &p2, SortDirection::Descending);
571+
assert_eq!(
572+
ordering,
573+
Ordering::Greater,
574+
"p1 (10%) should come after p2 (20%) in descending order"
575+
);
576+
577+
// p1 and p3 have same memory%, should be sorted by PID
578+
// In descending order, the order is reversed: lower PID (p3=50) > higher PID (p1=100)
579+
// So p1 (100) compared to p3 (50): base ordering = Less (100 > 50 in PID cmp)
580+
// After reverse for descending: Greater
581+
// Wait, let me think again:
582+
// base_ordering: a.pid.cmp(&b.pid) where a=p1(100), b=p3(50) -> 100.cmp(&50) = Greater
583+
// After reverse for descending: Less
584+
let ordering = criteria.sort_processes(&p1, &p3, SortDirection::Descending);
585+
assert_eq!(
586+
ordering,
587+
Ordering::Less,
588+
"p1 (pid 100) should come before p3 (pid 50) in descending sort (reversed from ascending)"
589+
);
590+
591+
// In ascending order, lower PID comes first
592+
let ordering = criteria.sort_processes(&p1, &p3, SortDirection::Ascending);
593+
assert_eq!(
594+
ordering,
595+
Ordering::Greater,
596+
"p1 (pid 100) should come after p3 (pid 50) in ascending order"
597+
);
598+
}
400599
}

src/storage/mod.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,18 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
//! Storage monitoring module.
16+
//!
17+
//! This module provides storage/disk information reading capabilities
18+
//! for local system monitoring.
19+
1520
pub mod info;
1621
pub mod reader;
1722

18-
// Re-export commonly used items
23+
// Re-export commonly used items for the public library API.
24+
// These exports are used by the prelude module and external library users,
25+
// even though internal code may import from submodules directly.
26+
#[allow(unused_imports)]
1927
pub use info::StorageInfo;
28+
#[allow(unused_imports)]
2029
pub use reader::{create_storage_reader, LocalStorageReader, StorageReader};

src/storage/reader.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ use crate::utils::{filter_docker_aware_disks, get_hostname};
3838
/// }
3939
/// }
4040
/// ```
41+
#[allow(dead_code)] // Public API trait - used by library consumers
4142
pub trait StorageReader: Send + Sync {
4243
/// Get information about all detected storage devices.
4344
///
@@ -69,6 +70,7 @@ pub trait StorageReader: Send + Sync {
6970
/// println!("{}: {:.1}% used", storage.mount_point, usage_percent);
7071
/// }
7172
/// ```
73+
#[allow(dead_code)] // Public API struct - used by library consumers
7274
pub struct LocalStorageReader {
7375
hostname: String,
7476
}
@@ -77,6 +79,7 @@ impl LocalStorageReader {
7779
/// Create a new local storage reader.
7880
///
7981
/// The hostname is cached at creation time to avoid repeated lookups.
82+
#[allow(dead_code)] // Public API constructor - used by library consumers
8083
pub fn new() -> Self {
8184
Self {
8285
hostname: get_hostname(),
@@ -130,6 +133,7 @@ impl StorageReader for LocalStorageReader {
130133
/// let storage_info = reader.get_storage_info();
131134
/// println!("Found {} storage device(s)", storage_info.len());
132135
/// ```
136+
#[allow(dead_code)] // Public API factory function - used by library consumers
133137
pub fn create_storage_reader() -> Box<dyn StorageReader> {
134138
Box::new(LocalStorageReader::new())
135139
}

src/ui/chrome.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,25 @@ pub fn print_function_keys<W: Write>(
134134
crate::app_state::SortCriteria::Temperature => "Sort:Temp",
135135
};
136136

137+
// Get GPU filter indicator
138+
let filter_indicator = if state.gpu_filter_enabled {
139+
"Filter:GPU"
140+
} else {
141+
""
142+
};
143+
137144
let function_keys = if is_remote {
138145
// Remote mode: only GPU sorting
139146
format!(
140147
"h:Help q:Exit c:CPU Cores ←→:Tabs ↑↓:Scroll PgUp/PgDn:Page d:Default u:Util g:GPU-Mem [{sort_indicator}]"
141148
)
142149
} else {
143150
// Local mode: both process and GPU sorting
144-
format!("h:Help q:Exit c:CPU Cores ←→:Tabs ↑↓:Scroll PgUp/PgDn:Page p:PID m:Memory d:Default u:Util g:GPU-Mem [{sort_indicator}]")
151+
if state.gpu_filter_enabled {
152+
format!("h:Help q:Exit c:CPU Cores f:Filter ←→:Scroll ↑↓:Scroll p:PID m:Memory g:GPU-Mem [{sort_indicator}] [{filter_indicator}]")
153+
} else {
154+
format!("h:Help q:Exit c:CPU Cores f:Filter ←→:Scroll ↑↓:Scroll p:PID m:Memory g:GPU-Mem [{sort_indicator}]")
155+
}
145156
};
146157

147158
let truncated_keys = if display_width(&function_keys) > cols as usize {

src/ui/help.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ fn render_shortcuts_section(
175175
("Display Control:", "", "header"),
176176
(" H", "Toggle this help screen", "shortcut"),
177177
(" C", "Toggle per-core CPU display", "shortcut"),
178+
(" F", "Toggle GPU process filter", "shortcut"),
178179
(" Q", "Exit application", "shortcut"),
179180
(" ESC", "Close help or exit", "shortcut"),
180181
("", "", ""),
@@ -236,6 +237,14 @@ fn render_shortcuts_section(
236237
let sort_status = get_current_sort_status(&state.sort_criteria);
237238
right_column.push((" Sort mode:", &sort_status, "status"));
238239

240+
// Add filter status
241+
let filter_status = if state.gpu_filter_enabled {
242+
"GPU Only"
243+
} else {
244+
"All Processes"
245+
};
246+
right_column.push((" Filter:", filter_status, "status"));
247+
239248
// Handle special rows
240249
match line_idx {
241250
0 => center_text_colored("KEYBOARD SHORTCUTS & NAVIGATION", width, Color::Yellow),

0 commit comments

Comments
 (0)