From c2691e33f27b3e63ce37a9e5c0fd88fc3d245a39 Mon Sep 17 00:00:00 2001 From: chang-ning Date: Sun, 5 Jul 2026 17:52:09 -0700 Subject: [PATCH 1/3] feat(tui): RDMA/XGMI/NVLink tabs, drop cargo features for runtime detection --- Cargo.toml | 10 +- examples/pytorch/README.md | 6 +- src/main.rs | 6 +- src/nvlink.rs | 9 +- src/tui/app.rs | 274 ++++++++++++++++------ src/tui/events.rs | 2 + src/tui/ui.rs | 458 ++++++++++++++++++++++++++++++++++--- src/xgmi.rs | 8 +- 8 files changed, 645 insertions(+), 128 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 144b67a..edcc71d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,10 +8,6 @@ repository = "https://github.com/uccl-project/rdmatop" keywords = ["rdma", "efa", "networking", "tui", "infiniband"] categories = ["network-programming", "command-line-utilities"] -[features] -nvlink = ["dep:nvml-wrapper", "dep:nvml-wrapper-sys"] -xgmi = ["dep:libloading"] - [profile.release] strip = true @@ -27,6 +23,6 @@ alphanumeric-sort = "1.5" libc = "0.2" ratatui = "0.30" crossterm = "0.29" -nvml-wrapper = { version = "0.12.1", optional = true } -nvml-wrapper-sys = { version = "0.9.1", optional = true } -libloading = { version = "0.8", optional = true } +nvml-wrapper = "0.12.1" +nvml-wrapper-sys = "0.9.1" +libloading = "0.8" diff --git a/examples/pytorch/README.md b/examples/pytorch/README.md index 3786387..3311d07 100644 --- a/examples/pytorch/README.md +++ b/examples/pytorch/README.md @@ -25,10 +25,10 @@ detail pane shows traffic on every link. # NVIDIA (CUDA) pip install torch ``` -- `rdmatop` built with the matching feature to see the GPU rows: +- `rdmatop` to see the GPU rows (XGMI/NVLink hardware is detected at + runtime — no build flags needed): ```bash - cargo install --path . --features xgmi # AMD - cargo install --path . --features nvlink # NVIDIA + cargo install --path . ``` ## Usage diff --git a/src/main.rs b/src/main.rs index e09a456..8814718 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,10 @@ mod net; mod netlink; +mod nvlink; mod rdma; mod stat; mod trace; mod tui; - -#[cfg(feature = "nvlink")] -mod nvlink; - -#[cfg(feature = "xgmi")] mod xgmi; use std::io; diff --git a/src/nvlink.rs b/src/nvlink.rs index d57fcdf..fb41c52 100644 --- a/src/nvlink.rs +++ b/src/nvlink.rs @@ -1,10 +1,9 @@ //! NVLink data layer built on top of the NVIDIA Management Library (NVML). //! -//! This module is only compiled when the `nvlink` cargo feature is enabled. -//! It exposes a small snapshot type that the TUI can render without having to -//! link directly against NVML. Per-link TX/RX throughput is read via the raw -//! `nvmlDeviceGetFieldValues` entry point because `nvml-wrapper` does not -//! currently expose a safe helper for those field IDs. +//! NVML is loaded at runtime by `nvml-wrapper`; on hosts without the NVIDIA +//! driver `read_all_nvlink_stats` returns an empty vector. Per-link TX/RX +//! throughput is read via the raw `nvmlDeviceGetFieldValues` entry point +//! because `nvml-wrapper` does not expose a safe helper for those field IDs. use std::io; diff --git a/src/tui/app.rs b/src/tui/app.rs index 27f9cdf..b7ee631 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -7,6 +7,14 @@ use std::time::{Duration, Instant}; use std::collections::HashMap; +/// Which hardware class a `PortThroughput` row belongs to; one TUI tab each. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum DeviceClass { + Rdma, + Xgmi, + Nvlink, +} + /// Per-port computed throughput (delta / interval). #[derive(Clone, Debug)] pub struct PortThroughput { @@ -22,36 +30,29 @@ pub struct PortThroughput { pub counter_rates: Vec, /// Optional override for the Port column text. Used by NVLink rows. pub port_label: Option, - /// NVLink metadata attached to this row. Populated by Task 3 and rendered - /// by the TUI detail pane when the `nvlink` feature is enabled. - #[cfg(feature = "nvlink")] + /// NVLink metadata attached to this row. pub nvlink: Option, - /// XGMI metadata attached to this row when the `xgmi` feature is enabled. - #[cfg(feature = "xgmi")] + /// XGMI metadata attached to this row. pub xgmi: Option, + pub class: DeviceClass, } -/// Per-GPU XGMI metadata attached to a `PortThroughput` row when the -/// `xgmi` feature is enabled. Mirrors `NvLinkThroughputMeta`; `links[]` +/// Per-GPU XGMI metadata. Mirrors `NvLinkThroughputMeta`; `links[]` /// tx/rx are rewritten as per-second byte rates for the detail pane. -#[cfg(feature = "xgmi")] #[derive(Clone, Debug)] pub struct XgmiThroughputMeta { /// Not rendered yet (rows already show `amdgpu` via `dev_name`); /// kept so future panels (e.g. topology graph) can reference it. #[allow(dead_code)] pub gpu_index: u32, - /// Marketing name (e.g. "AMD Instinct MI325X"); kept for future panels. - #[allow(dead_code)] + /// Marketing name (e.g. "AMD Instinct MI325X"); Name column on the tab. pub gpu_name: String, pub active_links: u32, pub links: Vec, } -/// Per-GPU NVLink metadata attached to a `PortThroughput` row when the -/// `nvlink` feature is enabled. The TUI uses this to show per-link details +/// Per-GPU NVLink metadata. The TUI uses this to show per-link details /// and error counters in the detail pane. -#[cfg(feature = "nvlink")] #[derive(Clone, Debug)] pub struct NvLinkThroughputMeta { /// GPU index reported by NVML. Not directly rendered by the TUI today @@ -59,9 +60,7 @@ pub struct NvLinkThroughputMeta { /// but kept so future panels (e.g. topology graph) can reference it. #[allow(dead_code)] pub gpu_index: u32, - /// Marketing name of the GPU (e.g. "H100"). Currently unused by the - /// detail pane, which displays only the aggregate active/total counts. - #[allow(dead_code)] + /// Marketing name of the GPU (e.g. "H100"); Name column on the tab. pub gpu_name: String, pub active_links: u32, pub links: Vec, @@ -108,6 +107,17 @@ impl TableColumn { } } + /// Table-header text: the bar column names the direction ("TX"/"RX") + /// and the value column next to it is just "Gbps" — no duplication. + pub fn header_label(&self) -> String { + match self { + Self::TxBar => "TX".into(), + Self::RxBar => "RX".into(), + Self::TxGbps | Self::RxGbps => "Gbps".into(), + _ => self.label(), + } + } + pub fn width(&self) -> u16 { match self { Self::Device => 16, @@ -251,10 +261,9 @@ impl RollingAvgState { rx_drops_per_sec: window.iter().map(|s| s.rx_drops_per_sec).sum::() / n, counter_rates: Vec::new(), port_label: latest.port_label.clone(), - #[cfg(feature = "nvlink")] nvlink: latest.nvlink.clone(), - #[cfg(feature = "xgmi")] xgmi: latest.xgmi.clone(), + class: latest.class, }; if let Some(template) = window.last() { avg.counter_rates = template @@ -353,10 +362,11 @@ pub struct App { pub recorder: Option, /// Transient message shown after a recording is saved or fails. pub record_status: Option, - #[cfg(feature = "nvlink")] prev_nvlink: Vec, - #[cfg(feature = "xgmi")] prev_xgmi: Vec, + pub active_tab: DeviceClass, + pub seen_tabs: Vec, + tab_selection: HashMap, } const HISTORY_LEN: usize = 60; @@ -431,13 +441,13 @@ impl App { cached_display: Vec::new(), recorder: None, record_status: None, - // Pre-read like prev_stats above: NVML/amdsmi counters are - // cumulative since driver load, so an empty baseline would - // render an absurd first-frame rate spike. - #[cfg(feature = "nvlink")] + // Pre-read like prev_stats: NVML/amdsmi counters are cumulative + // since driver load; an empty baseline causes a first-frame spike. prev_nvlink: crate::nvlink::read_all_nvlink_stats().unwrap_or_default(), - #[cfg(feature = "xgmi")] prev_xgmi: crate::xgmi::read_all_xgmi_stats().unwrap_or_default(), + active_tab: DeviceClass::Rdma, + seen_tabs: vec![DeviceClass::Rdma], + tab_selection: HashMap::new(), } } @@ -453,7 +463,6 @@ impl App { self.throughputs = compute_throughputs(&self.prev_stats, &curr, elapsed); self.prev_stats = curr; - #[cfg(feature = "nvlink")] { let curr_nvlink = crate::nvlink::read_all_nvlink_stats().unwrap_or_default(); let mut nvlink_rows = @@ -462,7 +471,6 @@ impl App { self.prev_nvlink = curr_nvlink; } - #[cfg(feature = "xgmi")] { let curr_xgmi = crate::xgmi::read_all_xgmi_stats().unwrap_or_default(); let mut xgmi_rows = compute_xgmi_throughputs(&self.prev_xgmi, &curr_xgmi, elapsed); @@ -473,6 +481,7 @@ impl App { // Stable display order: sort once here so history, rolling averages, // recording, and the display all inherit the same order. sort_by_device_order(&mut self.throughputs); + detect_tabs(&mut self.seen_tabs, &self.throughputs); let curr_if = net::read_all_ifstats().unwrap_or_default(); let net_rate = net::compute_net_rate(&self.prev_ifstats, &curr_if, elapsed); @@ -495,15 +504,23 @@ impl App { } /// Recompute the cached display throughputs (call after any change to - /// `throughputs`, `show_rolling_avg`, or rolling avg state). + /// `throughputs`, `show_rolling_avg`, `active_tab`, or rolling avg state). fn recompute_display(&mut self) { - if self.show_rolling_avg { + let rows: Vec = if self.show_rolling_avg { let mut avgs = self.rolling_avg.averages(); sort_by_throughput_order(&mut avgs, &self.throughputs); - self.cached_display = avgs; + avgs } else { - self.cached_display = self.throughputs.clone(); - } + self.throughputs.clone() + }; + self.cached_display = rows + .into_iter() + .filter(|t| t.class == self.active_tab) + .collect(); + // Keep the cursor inside the (possibly shorter) filtered view. + self.selected_row = self + .selected_row + .min(self.cached_display.len().saturating_sub(1)); } fn update_history(&mut self) { @@ -526,7 +543,7 @@ impl App { } pub fn move_down(&mut self) { - if !self.throughputs.is_empty() && self.selected_row < self.throughputs.len() - 1 { + if !self.cached_display.is_empty() && self.selected_row < self.cached_display.len() - 1 { self.selected_row += 1; } } @@ -548,7 +565,9 @@ impl App { pub fn detail_scroll_down(&mut self, max: u16) { if self.detail_scroll < max { self.detail_scroll += 1; - } else if !self.throughputs.is_empty() && self.selected_row < self.throughputs.len() - 1 { + } else if !self.cached_display.is_empty() + && self.selected_row < self.cached_display.len() - 1 + { self.selected_row += 1; self.detail_scroll = 0; } @@ -640,6 +659,11 @@ impl App { } pub fn open_column_picker(&mut self) { + // Column layout is configurable only on the RDMA tab; GPU tabs + // render a fixed column set. + if self.active_tab != DeviceClass::Rdma { + return; + } self.show_column_picker = true; self.column_picker_cursor = 0; } @@ -696,7 +720,7 @@ impl App { } pub fn selected_throughput(&self) -> Option<&PortThroughput> { - self.throughputs.get(self.selected_row) + self.cached_display.get(self.selected_row) } pub fn selected_device_processes(&self) -> Vec<&stat::ProcessRdmaInfo> { @@ -710,8 +734,45 @@ impl App { } fn clamp_selection(&mut self) { - if !self.throughputs.is_empty() && self.selected_row >= self.throughputs.len() { - self.selected_row = self.throughputs.len() - 1; + if !self.cached_display.is_empty() && self.selected_row >= self.cached_display.len() { + self.selected_row = self.cached_display.len() - 1; + } + } + + /// Cycle the active tab (Tab forward, Shift-Tab back); remembers the + /// cursor per tab and resets scroll state. + pub fn cycle_tab(&mut self, forward: bool) { + if self.seen_tabs.len() < 2 { + return; + } + let idx = self + .seen_tabs + .iter() + .position(|&t| t == self.active_tab) + .unwrap_or(0); + let n = self.seen_tabs.len(); + let next = if forward { + (idx + 1) % n + } else { + (idx + n - 1) % n + }; + self.tab_selection + .insert(self.active_tab, self.selected_row); + self.active_tab = self.seen_tabs[next]; + self.selected_row = *self.tab_selection.get(&self.active_tab).unwrap_or(&0); + self.table_offset = 0; + self.h_scroll = 0; + self.recompute_display(); + } +} + +/// A class's tab appears the first time it produces rows and stays for the +/// session, so a transient sampling failure cannot flicker it away. +fn detect_tabs(seen: &mut Vec, rows: &[PortThroughput]) { + for class in [DeviceClass::Xgmi, DeviceClass::Nvlink] { + if !seen.contains(&class) && rows.iter().any(|t| t.class == class) { + seen.push(class); + seen.sort_by_key(|c| *c as usize); // Rdma < Xgmi < Nvlink } } } @@ -902,10 +963,9 @@ fn compute_port_throughput( rx_drops_per_sec: rate_by_name(&counter_rates, "rx_drops"), counter_rates, port_label: None, - #[cfg(feature = "nvlink")] nvlink: None, - #[cfg(feature = "xgmi")] xgmi: None, + class: DeviceClass::Rdma, } } @@ -920,12 +980,7 @@ fn compute_throughputs(prev: &[PortStat], curr: &[PortStat], elapsed: f64) -> Ve /// NVLink and XGMI rows key by `dev_name` alone because their `port` field /// encodes the active-link count, which can change between samples. fn throughput_key(t: &PortThroughput) -> String { - #[cfg(feature = "nvlink")] - if t.nvlink.is_some() { - return t.dev_name.clone(); - } - #[cfg(feature = "xgmi")] - if t.xgmi.is_some() { + if t.nvlink.is_some() || t.xgmi.is_some() { return t.dev_name.clone(); } format!("{}/{}", t.dev_name, t.port) @@ -963,10 +1018,6 @@ fn sort_by_throughput_order(avgs: &mut [PortThroughput], reference: &[PortThroug /// exactly once per GPU (from the first active link, or the first link if no /// link is active) instead of summing across links. Summing would multiply /// the aggregate by the number of active links. -/// -/// Per-link error `CounterRate`s (crc/replay/recovery) are still emitted for -/// every link so the detail pane can display per-lane error counts. -#[cfg(feature = "nvlink")] fn compute_nvlink_throughputs( prev: &[crate::nvlink::NvLinkSnapshot], curr: &[crate::nvlink::NvLinkSnapshot], @@ -1089,8 +1140,8 @@ fn compute_nvlink_throughputs( active_links: active, links, }), - #[cfg(feature = "xgmi")] xgmi: None, + class: DeviceClass::Nvlink, }); } out @@ -1099,7 +1150,6 @@ fn compute_nvlink_throughputs( /// Compute one `PortThroughput` row per GPU from a pair of XGMI snapshots. /// amdsmi reports true per-link accumulators, so GPU totals are the sum of /// per-link deltas; meta links carry per-second byte rates for the pane. -#[cfg(feature = "xgmi")] fn compute_xgmi_throughputs( prev: &[crate::xgmi::XgmiSnapshot], curr: &[crate::xgmi::XgmiSnapshot], @@ -1182,7 +1232,6 @@ fn compute_xgmi_throughputs( rx_drops_per_sec: 0.0, counter_rates, port_label: Some(format!("{}/{}", active, gpu.link_count)), - #[cfg(feature = "nvlink")] nvlink: None, xgmi: Some(XgmiThroughputMeta { gpu_index: gpu.gpu_index, @@ -1190,12 +1239,13 @@ fn compute_xgmi_throughputs( active_links: active, links, }), + class: DeviceClass::Xgmi, }); } out } -#[cfg(all(test, feature = "xgmi"))] +#[cfg(test)] mod xgmi_tests { use super::*; use crate::xgmi::{XgmiLinkSnapshot, XgmiSnapshot}; @@ -1348,7 +1398,7 @@ mod xgmi_tests { } } -#[cfg(all(test, feature = "nvlink"))] +#[cfg(test)] mod nvlink_tests { use super::*; use crate::nvlink::{LinkSnapshot, NvLinkSnapshot, RemoteDeviceType}; @@ -1893,8 +1943,8 @@ mod nvlink_tests { active_links: 2, links: Vec::new(), }), - #[cfg(feature = "xgmi")] xgmi: None, + class: DeviceClass::Nvlink, }; let rdma_a_ref = PortThroughput { dev_name: "mlx5_0".to_string(), @@ -1907,10 +1957,9 @@ mod nvlink_tests { rx_drops_per_sec: 0.0, counter_rates: Vec::new(), port_label: None, - #[cfg(feature = "nvlink")] nvlink: None, - #[cfg(feature = "xgmi")] xgmi: None, + class: DeviceClass::Rdma, }; let rdma_b_ref = PortThroughput { dev_name: "mlx5_1".to_string(), @@ -1923,10 +1972,9 @@ mod nvlink_tests { rx_drops_per_sec: 0.0, counter_rates: Vec::new(), port_label: None, - #[cfg(feature = "nvlink")] nvlink: None, - #[cfg(feature = "xgmi")] xgmi: None, + class: DeviceClass::Rdma, }; let reference = vec![rdma_a_ref.clone(), nvlink_ref.clone(), rdma_b_ref.clone()]; let nvlink_ref_pos = 1usize; @@ -1944,15 +1992,14 @@ mod nvlink_tests { rx_drops_per_sec: 0.0, counter_rates: Vec::new(), port_label: Some("3/3".to_string()), - #[cfg(feature = "nvlink")] nvlink: Some(NvLinkThroughputMeta { gpu_index: 0, gpu_name: "H100".to_string(), active_links: 3, links: Vec::new(), }), - #[cfg(feature = "xgmi")] xgmi: None, + class: DeviceClass::Nvlink, }; let rdma_a_avg = PortThroughput { dev_name: "mlx5_0".to_string(), @@ -1965,10 +2012,9 @@ mod nvlink_tests { rx_drops_per_sec: 0.0, counter_rates: Vec::new(), port_label: None, - #[cfg(feature = "nvlink")] nvlink: None, - #[cfg(feature = "xgmi")] xgmi: None, + class: DeviceClass::Rdma, }; let rdma_b_avg = PortThroughput { dev_name: "mlx5_1".to_string(), @@ -1981,10 +2027,9 @@ mod nvlink_tests { rx_drops_per_sec: 0.0, counter_rates: Vec::new(), port_label: None, - #[cfg(feature = "nvlink")] nvlink: None, - #[cfg(feature = "xgmi")] xgmi: None, + class: DeviceClass::Rdma, }; let mut avgs = vec![rdma_b_avg.clone(), nvlink_avg.clone(), rdma_a_avg.clone()]; @@ -2023,10 +2068,9 @@ mod sort_tests { rx_drops_per_sec: 0.0, counter_rates: Vec::new(), port_label: None, - #[cfg(feature = "nvlink")] nvlink: None, - #[cfg(feature = "xgmi")] xgmi: None, + class: DeviceClass::Rdma, } } @@ -2063,3 +2107,99 @@ mod sort_tests { ); } } + +#[cfg(test)] +mod tab_tests { + use super::*; + + fn row(class: DeviceClass, name: &str) -> PortThroughput { + PortThroughput { + dev_name: name.to_string(), + port: 1, + link_gbps: None, + tx_gbps: 0.0, + rx_gbps: 0.0, + tx_pkts_per_sec: 0.0, + rx_pkts_per_sec: 0.0, + rx_drops_per_sec: 0.0, + counter_rates: Vec::new(), + port_label: None, + nvlink: None, + xgmi: None, + class, + } + } + + #[test] + fn detect_tabs_is_sticky() { + let mut seen = vec![DeviceClass::Rdma]; + detect_tabs(&mut seen, &[row(DeviceClass::Xgmi, "amdgpu0")]); + assert_eq!(seen, vec![DeviceClass::Rdma, DeviceClass::Xgmi]); + // rows disappear -> tab stays + detect_tabs(&mut seen, &[]); + assert_eq!(seen, vec![DeviceClass::Rdma, DeviceClass::Xgmi]); + // no duplicates + detect_tabs(&mut seen, &[row(DeviceClass::Xgmi, "amdgpu0")]); + assert_eq!(seen.len(), 2); + } + + #[test] + fn detect_tabs_orders_rdma_xgmi_nvlink() { + let mut seen = vec![DeviceClass::Rdma]; + detect_tabs(&mut seen, &[row(DeviceClass::Nvlink, "nvidia0")]); + detect_tabs(&mut seen, &[row(DeviceClass::Xgmi, "amdgpu0")]); + assert_eq!( + seen, + vec![DeviceClass::Rdma, DeviceClass::Xgmi, DeviceClass::Nvlink] + ); + } + + #[test] + fn display_filters_by_active_tab() { + let mut app = App::new(); + app.throughputs = vec![ + row(DeviceClass::Rdma, "mlx5_0"), + row(DeviceClass::Rdma, "mlx5_1"), + row(DeviceClass::Xgmi, "amdgpu0"), + ]; + app.active_tab = DeviceClass::Rdma; + app.recompute_display(); + assert_eq!(app.display_throughputs().len(), 2); + app.active_tab = DeviceClass::Xgmi; + app.recompute_display(); + assert_eq!(app.display_throughputs().len(), 1); + assert_eq!(app.display_throughputs()[0].dev_name, "amdgpu0"); + } + + #[test] + fn cycle_wraps_and_remembers_selection() { + let mut app = App::new(); + app.throughputs = vec![ + row(DeviceClass::Rdma, "mlx5_0"), + row(DeviceClass::Rdma, "mlx5_1"), + row(DeviceClass::Rdma, "mlx5_2"), + row(DeviceClass::Xgmi, "amdgpu0"), + ]; + app.seen_tabs = vec![DeviceClass::Rdma, DeviceClass::Xgmi]; + app.active_tab = DeviceClass::Rdma; + app.recompute_display(); + app.selected_row = 2; + app.cycle_tab(true); + assert_eq!(app.active_tab, DeviceClass::Xgmi); + assert_eq!(app.selected_row, 0); + app.cycle_tab(true); // wraps back + assert_eq!(app.active_tab, DeviceClass::Rdma); + assert_eq!(app.selected_row, 2, "remembered per-tab cursor"); + app.cycle_tab(false); // backward wraps to Xgmi + assert_eq!(app.active_tab, DeviceClass::Xgmi); + } + + #[test] + fn single_tab_cycle_is_noop() { + let mut app = App::new(); + app.seen_tabs = vec![DeviceClass::Rdma]; + app.active_tab = DeviceClass::Rdma; + app.cycle_tab(true); + assert_eq!(app.active_tab, DeviceClass::Rdma); + } +} diff --git a/src/tui/events.rs b/src/tui/events.rs index 59301bb..57073b8 100644 --- a/src/tui/events.rs +++ b/src/tui/events.rs @@ -52,6 +52,8 @@ fn handle_normal_mode(app: &mut App, key: KeyCode) { KeyCode::Right => app.scroll_right(), KeyCode::Enter => app.toggle_detail(), KeyCode::Char('t') => app.cycle_theme(), + KeyCode::Tab => app.cycle_tab(true), + KeyCode::BackTab => app.cycle_tab(false), KeyCode::Char('a') => app.toggle_rolling_avg(), KeyCode::Char('+') | KeyCode::Char('=') => app.increase_avg_window(), KeyCode::Char('-') => app.decrease_avg_window(), diff --git a/src/tui/ui.rs b/src/tui/ui.rs index b10a240..8efe334 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -10,7 +10,8 @@ use ratatui::{ }; use super::app::{ - all_columns, App, CounterRate, PortThroughput, TableColumn, BAR_WIDTH, EXTRA_COUNTERS, + all_columns, App, CounterRate, DeviceClass, PortThroughput, TableColumn, BAR_WIDTH, + EXTRA_COUNTERS, }; use super::theme::ThemeColors; @@ -21,6 +22,7 @@ const HELP_KEYS: &[(&str, &str)] = &[ ("Enter", "Toggle detail panel"), ("Esc", "Close detail / quit"), ("t", "Cycle theme"), + ("Tab / S-Tab", "Switch device class (RDMA/XGMI/NVLink)"), ("a", "Toggle rolling average"), ("+ / =", "Increase avg window (+1s)"), ("-", "Decrease avg window (-1s)"), @@ -50,18 +52,33 @@ pub fn draw(frame: &mut Frame, app: &mut App) { ); } - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(5), - Constraint::Min(5), - Constraint::Length(1), - ]) - .split(frame.area()); - - draw_header(frame, app, chunks[0], &tc); - draw_body(frame, app, chunks[1], &tc); - draw_status_bar(frame, app, chunks[2], &tc); + if app.seen_tabs.len() >= 2 { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Length(1), + Constraint::Min(5), + Constraint::Length(1), + ]) + .split(frame.area()); + draw_header(frame, app, chunks[0], &tc); + draw_tab_bar(frame, app, chunks[1], &tc); + draw_body(frame, app, chunks[2], &tc); + draw_status_bar(frame, app, chunks[3], &tc); + } else { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Min(5), + Constraint::Length(1), + ]) + .split(frame.area()); + draw_header(frame, app, chunks[0], &tc); + draw_body(frame, app, chunks[1], &tc); + draw_status_bar(frame, app, chunks[2], &tc); + } if app.show_help { draw_help_popup(frame, &tc); @@ -130,9 +147,14 @@ fn header_line2(app: &App, tc: &ThemeColors) -> Line<'static> { app.theme.label() ); + let label = match app.active_tab { + DeviceClass::Rdma => "RDMA", + DeviceClass::Xgmi => "XGMI", + DeviceClass::Nvlink => "NVLink", + }; Line::from(vec![ styled( - &format!(" RDMA: {} device{}", n, if n == 1 { "" } else { "s" }), + &format!(" {}: {} device{}", label, n, if n == 1 { "" } else { "s" }), tc.fg, false, ), @@ -219,6 +241,37 @@ fn draw_header(frame: &mut Frame, app: &App, area: Rect, tc: &ThemeColors) { frame.render_widget(Paragraph::new(lines).block(block), area); } +/// One-line tab bar: ` RDMA │ XGMI │ NVLINK `, active tab highlighted. +fn tab_bar_line(app: &App, tc: &ThemeColors) -> Line<'static> { + let mut spans: Vec> = vec![styled(" ", tc.muted, false)]; + for (i, tab) in app.seen_tabs.iter().enumerate() { + if i > 0 { + spans.push(styled(" │ ", tc.muted, false)); + } + let label = match tab { + DeviceClass::Rdma => "RDMA", + DeviceClass::Xgmi => "XGMI", + DeviceClass::Nvlink => "NVLINK", + }; + if *tab == app.active_tab { + spans.push(Span::styled( + format!(" {} ", label), + Style::default() + .fg(Color::Black) + .bg(tc.accent) + .add_modifier(Modifier::BOLD), + )); + } else { + spans.push(styled(&format!(" {} ", label), tc.muted, false)); + } + } + Line::from(spans) +} + +fn draw_tab_bar(frame: &mut Frame, app: &App, area: Rect, tc: &ThemeColors) { + frame.render_widget(Paragraph::new(tab_bar_line(app, tc)), area); +} + fn gbps_bar(gbps: f64, link_gbps: Option) -> String { // Scale to the port's line rate; fall back to a default when unknown. let max = link_gbps.filter(|&r| r > 0.0).unwrap_or(RDMA_LINK_GBPS); @@ -280,7 +333,165 @@ fn column_cell(col: &TableColumn, t: &PortThroughput, tc: &ThemeColors) -> Cell< } } +/// Marketing name from whichever GPU meta the row carries. +fn gpu_meta_name(t: &PortThroughput) -> String { + if let Some(ref m) = t.xgmi { + return m.gpu_name.clone(); + } + if let Some(ref m) = t.nvlink { + return m.gpu_name.clone(); + } + String::new() +} + +/// XGMI: "ce/ue". NVLink: single summed nvlink_* error total. +fn gpu_errs(t: &PortThroughput) -> String { + let counter = |name: &str| { + t.counter_rates + .iter() + .find(|c| c.name == name) + .map(|c| c.value) + .unwrap_or(0) + }; + if t.xgmi.is_some() { + return format!("{}/{}", counter("xgmi_wafl_ce"), counter("xgmi_wafl_ue")); + } + let sum: u64 = t + .counter_rates + .iter() + .filter(|c| c.name.starts_with("nvlink_")) + .map(|c| c.value) + .sum(); + sum.to_string() +} + +fn gpu_row_cells(t: &PortThroughput, tc: &ThemeColors) -> Vec> { + let speed = t + .link_gbps + .map(|g| format!("{:.0}", g)) + .unwrap_or_else(|| "-".to_string()); + // Bar and Gbps live in separate cells styled with gbps_color, exactly + // like the RDMA table, so the two tabs stay visually aligned. + vec![ + Cell::from(t.dev_name.clone()).style(Style::default().fg(tc.fg)), + Cell::from(gpu_meta_name(t)).style(Style::default().fg(tc.muted)), + Cell::from(t.port_label.clone().unwrap_or_default()).style(Style::default().fg(tc.fg)), + Cell::from(speed).style(Style::default().fg(tc.fg)), + Cell::from(gbps_bar(t.tx_gbps, t.link_gbps)) + .style(Style::default().fg(gbps_color(t.tx_gbps, tc))), + Cell::from(format!("{:.2}", t.tx_gbps)) + .style(Style::default().fg(gbps_color(t.tx_gbps, tc))), + Cell::from(gbps_bar(t.rx_gbps, t.link_gbps)) + .style(Style::default().fg(gbps_color(t.rx_gbps, tc))), + Cell::from(format!("{:.2}", t.rx_gbps)) + .style(Style::default().fg(gbps_color(t.rx_gbps, tc))), + Cell::from(gpu_errs(t)).style(Style::default().fg(tc.warning)), + ] +} + +/// GPU-tab table: fixed columns for XGMI / NVLink rows. +/// h-scroll and the column picker are RDMA-only; this function ignores both. +fn draw_gpu_table(frame: &mut Frame, app: &mut App, area: Rect, tc: &ThemeColors) { + let display = app.display_throughputs().to_vec(); + let label = match app.active_tab { + DeviceClass::Xgmi => "XGMI", + DeviceClass::Nvlink => "NVLink", + DeviceClass::Rdma => "RDMA", + }; + let title = if app.show_rolling_avg { + format!( + " {} Throughput (avg {}s) ", + label, app.rolling_avg.window_secs + ) + } else { + format!(" {} Throughput ", label) + }; + + let header = Row::new(vec![ + "Device", "Name", "Links", "Speed", "TX", "Gbps", "RX", "Gbps", "Errs", + ]) + .style( + Style::default() + .fg(tc.header_fg) + .add_modifier(Modifier::BOLD), + ) + .height(1); + + let rows: Vec = display + .iter() + .map(|t| Row::new(gpu_row_cells(t, tc))) + .collect(); + + // Shared columns copy the RDMA table's widths (Device 16, bars + // BAR_WIDTH, Gbps 9) so switching tabs doesn't shift the layout. + let widths = [ + Constraint::Length(16), + Constraint::Length(22), + Constraint::Length(6), + Constraint::Length(7), + Constraint::Length(BAR_WIDTH as u16), + Constraint::Length(9), + Constraint::Length(BAR_WIDTH as u16), + Constraint::Length(9), + Constraint::Length(9), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(tc.border)) + .title(title) + .title_style(Style::default().fg(tc.accent)), + ) + .row_highlight_style( + Style::default() + .bg(tc.highlight_bg) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + + let mut state = TableState::default(); + if !display.is_empty() { + let viewport = area.height.saturating_sub(4) as usize; + if viewport > 0 { + if app.selected_row >= app.table_offset + viewport { + app.table_offset = app.selected_row + 1 - viewport; + } else if app.selected_row < app.table_offset { + app.table_offset = app.selected_row; + } + } + state.select(Some(app.selected_row)); + *state.offset_mut() = app.table_offset; + } + frame.render_stateful_widget(table, area, &mut state); + + if display.len() > area.height.saturating_sub(4) as usize { + let mut v_scroll = ScrollbarState::new(display.len()).position(app.selected_row); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .thumb_symbol("▐") + .track_symbol(Some("│")) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")) + .thumb_style(Style::default().fg(tc.accent)) + .track_style(Style::default().fg(tc.border)), + area.inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut v_scroll, + ); + } +} + fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, tc: &ThemeColors) { + if app.active_tab != DeviceClass::Rdma { + draw_gpu_table(frame, app, area, tc); + return; + } + let display = app.display_throughputs().to_vec(); let title = if app.show_rolling_avg { format!(" RDMA Throughput (avg {}s) ", app.rolling_avg.window_secs) @@ -333,13 +544,18 @@ fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, tc: &ThemeColors) { (visible, true) }; - let header = Row::new(cols_to_render.iter().map(|c| c.label()).collect::>()) - .style( - Style::default() - .fg(tc.header_fg) - .add_modifier(Modifier::BOLD), - ) - .height(1); + let header = Row::new( + cols_to_render + .iter() + .map(|c| c.header_label()) + .collect::>(), + ) + .style( + Style::default() + .fg(tc.header_fg) + .add_modifier(Modifier::BOLD), + ) + .height(1); let rows: Vec = display .iter() @@ -465,13 +681,8 @@ fn build_detail_lines( /// Build the per-lane detail panel for a NVLink GPU row. /// -/// Layout: -/// Device: nvidiaN [NVLink] active/total active -/// TX/RX aggregate Gbps with sparkline -/// Lane table header (Lane, State, Ver, TX MB/s, RX MB/s, Remote) -/// One row per link, including inactive lanes -/// Summed error counters (replay/recovery/crc) -#[cfg(feature = "nvlink")] +/// Layout: header, TX/RX sparklines, lane table (Lane, State, Ver, +/// TX MB/s, RX MB/s, Remote), summed error counters. fn build_nvlink_detail_lines( t: &PortThroughput, meta: &super::app::NvLinkThroughputMeta, @@ -602,7 +813,6 @@ fn build_nvlink_detail_lines( /// Build the per-link detail panel for an XGMI GPU row. /// Layout mirrors the NVLink pane: header, TX/RX sparklines, a link table /// (Link, State, Gbps, TX MB/s, RX MB/s, Remote), then XGMI_WAFL ECC counts. -#[cfg(feature = "xgmi")] fn build_xgmi_detail_lines( t: &PortThroughput, meta: &super::app::XgmiThroughputMeta, @@ -827,11 +1037,9 @@ fn build_selected_detail_lines( show_avg: bool, avg_window: usize, ) -> Vec> { - #[cfg(feature = "nvlink")] if let Some(ref meta) = t.nvlink { return build_nvlink_detail_lines(t, meta, history, tc, show_avg, avg_window); } - #[cfg(feature = "xgmi")] if let Some(ref meta) = t.xgmi { return build_xgmi_detail_lines(t, meta, history, tc, show_avg, avg_window); } @@ -1154,7 +1362,58 @@ fn truncate(s: &str, max: usize) -> String { } } -#[cfg(all(test, feature = "nvlink"))] +#[cfg(test)] +mod tab_bar_tests { + use super::*; + use crate::tui::app::{App, DeviceClass}; + use crate::tui::theme::Theme; + + fn line_text(line: &Line<'_>) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() + } + + #[test] + fn tab_bar_shows_all_seen_tabs() { + let mut app = App::new(); + app.seen_tabs = vec![DeviceClass::Rdma, DeviceClass::Xgmi]; + app.active_tab = DeviceClass::Rdma; + let tc = Theme::Default.colors(); + let bar = tab_bar_line(&app, &tc); + let text = line_text(&bar); + assert!(text.contains("RDMA"), "bar: {text:?}"); + assert!(text.contains("XGMI"), "bar: {text:?}"); + assert!(text.contains('│'), "bar: {text:?}"); + } + + #[test] + fn active_tab_is_highlighted() { + let mut app = App::new(); + app.seen_tabs = vec![DeviceClass::Rdma, DeviceClass::Xgmi]; + app.active_tab = DeviceClass::Xgmi; + let tc = Theme::Default.colors(); + let bar = tab_bar_line(&app, &tc); + let xgmi_span = bar + .spans + .iter() + .find(|s| s.content.contains("XGMI")) + .expect("XGMI span"); + let rdma_span = bar + .spans + .iter() + .find(|s| s.content.contains("RDMA")) + .expect("RDMA span"); + assert!( + xgmi_span.style.add_modifier.contains(Modifier::BOLD), + "active XGMI span should be bold" + ); + assert!( + !rdma_span.style.add_modifier.contains(Modifier::BOLD), + "inactive RDMA span should not be bold" + ); + } +} + +#[cfg(test)] mod nvlink_detail_tests { use super::*; use crate::nvlink::{LinkSnapshot, RemoteDeviceType}; @@ -1240,8 +1499,8 @@ mod nvlink_detail_tests { counter_rates, port_label: Some(format!("{}/{}", active, links.len())), nvlink: Some(meta.clone()), - #[cfg(feature = "xgmi")] xgmi: None, + class: DeviceClass::Nvlink, }; (port, meta) } @@ -1530,8 +1789,8 @@ mod nvlink_detail_tests { counter_rates: Vec::new(), port_label: Some("2/3".to_string()), nvlink: Some(meta.clone()), - #[cfg(feature = "xgmi")] xgmi: None, + class: DeviceClass::Nvlink, }; let tc = Theme::Default.colors(); @@ -1639,8 +1898,8 @@ mod nvlink_detail_tests { counter_rates: Vec::new(), port_label: Some("2/2".to_string()), nvlink: Some(meta.clone()), - #[cfg(feature = "xgmi")] xgmi: None, + class: DeviceClass::Nvlink, }; let tc = Theme::Default.colors(); @@ -1678,7 +1937,7 @@ mod nvlink_detail_tests { } } -#[cfg(all(test, feature = "xgmi"))] +#[cfg(test)] mod xgmi_detail_tests { use super::*; use crate::tui::app::{PortThroughput, XgmiThroughputMeta}; @@ -1735,9 +1994,9 @@ mod xgmi_detail_tests { }, ], port_label: Some(format!("{}/{}", active, meta.links.len())), - #[cfg(feature = "nvlink")] nvlink: None, xgmi: Some(meta.clone()), + class: DeviceClass::Xgmi, }; (port, meta) } @@ -1809,3 +2068,128 @@ mod xgmi_detail_tests { assert!(row.matches('-').count() >= 2, "row: {row:?}"); } } + +#[cfg(test)] +mod gpu_table_tests { + use super::*; + use crate::tui::app::{CounterRate, DeviceClass, NvLinkThroughputMeta, XgmiThroughputMeta}; + + fn make_counter(name: &str, value: u64) -> CounterRate { + CounterRate { + name: name.to_string(), + value, + delta: 0, + rate: 0.0, + is_bytes: false, + } + } + + fn xgmi_row(ce: u64, ue: u64) -> PortThroughput { + PortThroughput { + dev_name: "amdgpu0".to_string(), + port: 7, + link_gbps: Some(3584.0), + tx_gbps: 0.0, + rx_gbps: 0.0, + tx_pkts_per_sec: 0.0, + rx_pkts_per_sec: 0.0, + rx_drops_per_sec: 0.0, + counter_rates: vec![ + make_counter("xgmi_wafl_ce", ce), + make_counter("xgmi_wafl_ue", ue), + ], + port_label: Some("7/7".to_string()), + nvlink: None, + xgmi: Some(XgmiThroughputMeta { + gpu_index: 0, + gpu_name: "MI325X".to_string(), + active_links: 7, + links: vec![], + }), + class: DeviceClass::Xgmi, + } + } + + fn nvlink_row(crc: u64, replay: u64) -> PortThroughput { + PortThroughput { + dev_name: "nvidia0".to_string(), + port: 18, + link_gbps: Some(900.0), + tx_gbps: 0.0, + rx_gbps: 0.0, + tx_pkts_per_sec: 0.0, + rx_pkts_per_sec: 0.0, + rx_drops_per_sec: 0.0, + counter_rates: vec![ + make_counter("nvlink_crc_l0", crc), + make_counter("nvlink_replay_l1", replay), + ], + port_label: Some("18/18".to_string()), + nvlink: Some(NvLinkThroughputMeta { + gpu_index: 0, + gpu_name: "H100".to_string(), + active_links: 18, + links: vec![], + }), + xgmi: None, + class: DeviceClass::Nvlink, + } + } + + fn rdma_row() -> PortThroughput { + PortThroughput { + dev_name: "mlx5_0".to_string(), + port: 1, + link_gbps: Some(100.0), + tx_gbps: 0.0, + rx_gbps: 0.0, + tx_pkts_per_sec: 0.0, + rx_pkts_per_sec: 0.0, + rx_drops_per_sec: 0.0, + counter_rates: vec![], + port_label: None, + nvlink: None, + xgmi: None, + class: DeviceClass::Rdma, + } + } + + #[test] + fn gpu_meta_name_xgmi() { + assert_eq!(gpu_meta_name(&xgmi_row(7, 1)), "MI325X"); + } + + #[test] + fn gpu_meta_name_nvlink() { + assert_eq!(gpu_meta_name(&nvlink_row(2, 3)), "H100"); + } + + #[test] + fn gpu_meta_name_plain_rdma() { + assert_eq!(gpu_meta_name(&rdma_row()), ""); + } + + #[test] + fn gpu_errs_xgmi_ce_ue() { + assert_eq!(gpu_errs(&xgmi_row(7, 1)), "7/1"); + } + + #[test] + fn gpu_errs_xgmi_missing_counters() { + let mut row = xgmi_row(0, 0); + row.counter_rates.clear(); + assert_eq!(gpu_errs(&row), "0/0"); + } + + #[test] + fn gpu_errs_nvlink_sum() { + assert_eq!(gpu_errs(&nvlink_row(2, 3)), "5"); + } + + #[test] + fn gpu_errs_nvlink_missing_counters() { + let mut row = nvlink_row(0, 0); + row.counter_rates.clear(); + assert_eq!(gpu_errs(&row), "0"); + } +} diff --git a/src/xgmi.rs b/src/xgmi.rs index 9fd2e36..820cb7c 100644 --- a/src/xgmi.rs +++ b/src/xgmi.rs @@ -1,6 +1,6 @@ -//! XGMI data layer built on top of AMD SMI (`libamd_smi`), compiled only -//! with the `xgmi` cargo feature. The library is dlopen'd at runtime; when -//! absent or failing to init, `read_all_xgmi_stats` returns an empty vector. +//! XGMI data layer built on top of AMD SMI (`libamd_smi`). The library is +//! dlopen'd at runtime; when absent or failing to init, +//! `read_all_xgmi_stats` returns an empty vector. use std::ffi::CStr; use std::io; @@ -525,7 +525,7 @@ mod tests { assert_eq!(format_bdf((0x65 << 8) | (0x1f << 3) | 0x7), "0000:65:1f.7"); } - /// Hardware smoke test: run with `cargo test --features xgmi -- --ignored` + /// Hardware smoke test: run with `cargo test -- --ignored` /// on a machine with AMD GPUs. #[test] #[ignore] From 0e87dc910c903fa3e17258e966e0bea2669e65bd Mon Sep 17 00:00:00 2001 From: chang-ning Date: Sun, 5 Jul 2026 17:59:47 -0700 Subject: [PATCH 2/3] fix: help desc was truncated Signed-off-by: chang-ning --- src/tui/theme.rs | 6 ------ src/tui/ui.rs | 28 ++++++++++++---------------- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/src/tui/theme.rs b/src/tui/theme.rs index bc8d118..5c8cbd5 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -54,7 +54,6 @@ pub struct ThemeColors { pub status_bg: Color, pub status_fg: Color, pub header_fg: Color, - pub group_title: Color, } fn default_colors() -> ThemeColors { @@ -71,7 +70,6 @@ fn default_colors() -> ThemeColors { status_bg: Color::Green, status_fg: Color::Black, header_fg: Color::Cyan, - group_title: Color::Yellow, } } @@ -89,7 +87,6 @@ fn dracula_colors() -> ThemeColors { status_bg: Color::Rgb(170, 140, 220), status_fg: Color::Rgb(30, 30, 40), header_fg: Color::Rgb(150, 210, 240), - group_title: Color::Rgb(220, 220, 160), } } @@ -107,7 +104,6 @@ fn nord_colors() -> ThemeColors { status_bg: Color::Rgb(130, 165, 195), status_fg: Color::Rgb(40, 45, 55), header_fg: Color::Rgb(140, 190, 210), - group_title: Color::Rgb(220, 200, 150), } } @@ -125,7 +121,6 @@ fn monokai_colors() -> ThemeColors { status_bg: Color::Rgb(220, 160, 70), status_fg: Color::Rgb(35, 35, 30), header_fg: Color::Rgb(130, 200, 220), - group_title: Color::Rgb(210, 200, 130), } } @@ -143,6 +138,5 @@ fn gruvbox_colors() -> ThemeColors { status_bg: Color::Rgb(200, 115, 50), status_fg: Color::Rgb(35, 35, 35), header_fg: Color::Rgb(145, 175, 165), - group_title: Color::Rgb(230, 185, 75), } } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 8efe334..c2a8c91 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -32,12 +32,6 @@ const HELP_KEYS: &[(&str, &str)] = &[ ("r", "Record Perfetto trace (start/stop)"), ("h", "Toggle this help"), ("q", "Quit"), - ("", ""), - ("", "── Detail mode ──"), - ("↑ / k", "Scroll up"), - ("↓ / j", "Scroll down"), - ("", "Scroll past end → next device"), - ("", "Scroll past top → prev device"), ]; const RDMA_LINK_GBPS: f64 = 100.0; @@ -1168,8 +1162,14 @@ fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect, tc: &ThemeColors) { fn draw_help_popup(frame: &mut Frame, tc: &ThemeColors) { let area = frame.area(); - let w = 50.min(area.width.saturating_sub(4)); - let h = 18.min(area.height.saturating_sub(4)); + // Size the popup to the longest row (16-char key column + description) + // plus borders, so nothing gets clipped. + let mut content_w = 0; + for (_, desc) in HELP_KEYS { + content_w = content_w.max(16 + desc.chars().count()); + } + let w = (content_w as u16 + 2).min(area.width.saturating_sub(4)); + let h = (HELP_KEYS.len() as u16 + 2).min(area.height.saturating_sub(4)); let popup = centered_rect(area, w, h); frame.render_widget(Clear, popup); @@ -1259,14 +1259,10 @@ fn draw_column_picker(frame: &mut Frame, app: &App, tc: &ThemeColors) { } fn help_line(key: &str, desc: &str, tc: &ThemeColors) -> Line<'static> { - if key.is_empty() { - Line::from(styled(&format!(" {}", desc), tc.group_title, false)) - } else { - Line::from(vec![ - styled(&format!(" {:<14}", key), tc.accent, false), - styled(desc, tc.fg, false), - ]) - } + Line::from(vec![ + styled(&format!(" {:<14}", key), tc.accent, false), + styled(desc, tc.fg, false), + ]) } fn centered_rect(area: Rect, w: u16, h: u16) -> Rect { From 734d2138858811f3a56c773e702822944ced5144 Mon Sep 17 00:00:00 2001 From: chang-ning Date: Sun, 5 Jul 2026 18:12:44 -0700 Subject: [PATCH 3/3] address comments Signed-off-by: chang-ning --- src/tui/app.rs | 13 +++++++++++++ src/tui/events.rs | 2 ++ src/tui/ui.rs | 20 ++++---------------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index b7ee631..a137e15 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -15,6 +15,17 @@ pub enum DeviceClass { Nvlink, } +impl DeviceClass { + /// Display name used by the tab bar, header, and table titles. + pub fn label(&self) -> &'static str { + match self { + DeviceClass::Rdma => "RDMA", + DeviceClass::Xgmi => "XGMI", + DeviceClass::Nvlink => "NVLink", + } + } +} + /// Per-port computed throughput (delta / interval). #[derive(Clone, Debug)] pub struct PortThroughput { @@ -762,6 +773,8 @@ impl App { self.selected_row = *self.tab_selection.get(&self.active_tab).unwrap_or(&0); self.table_offset = 0; self.h_scroll = 0; + // Tab also works with the detail pane open; start it at the top. + self.detail_scroll = 0; self.recompute_display(); } } diff --git a/src/tui/events.rs b/src/tui/events.rs index 57073b8..62ff328 100644 --- a/src/tui/events.rs +++ b/src/tui/events.rs @@ -72,6 +72,8 @@ fn handle_detail_mode(app: &mut App, key: KeyCode) { KeyCode::Esc | KeyCode::Enter => app.toggle_detail(), KeyCode::Up | KeyCode::Char('k') => app.detail_scroll_up(), KeyCode::Down | KeyCode::Char('j') => app.detail_scroll_down(app.detail_max_scroll), + KeyCode::Tab => app.cycle_tab(true), + KeyCode::BackTab => app.cycle_tab(false), KeyCode::Char('t') => app.cycle_theme(), KeyCode::Char('a') => app.toggle_rolling_avg(), KeyCode::Char('+') | KeyCode::Char('=') => app.increase_avg_window(), diff --git a/src/tui/ui.rs b/src/tui/ui.rs index c2a8c91..716ac33 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -141,11 +141,7 @@ fn header_line2(app: &App, tc: &ThemeColors) -> Line<'static> { app.theme.label() ); - let label = match app.active_tab { - DeviceClass::Rdma => "RDMA", - DeviceClass::Xgmi => "XGMI", - DeviceClass::Nvlink => "NVLink", - }; + let label = app.active_tab.label(); Line::from(vec![ styled( &format!(" {}: {} device{}", label, n, if n == 1 { "" } else { "s" }), @@ -235,18 +231,14 @@ fn draw_header(frame: &mut Frame, app: &App, area: Rect, tc: &ThemeColors) { frame.render_widget(Paragraph::new(lines).block(block), area); } -/// One-line tab bar: ` RDMA │ XGMI │ NVLINK `, active tab highlighted. +/// One-line tab bar: ` RDMA │ XGMI │ NVLink `, active tab highlighted. fn tab_bar_line(app: &App, tc: &ThemeColors) -> Line<'static> { let mut spans: Vec> = vec![styled(" ", tc.muted, false)]; for (i, tab) in app.seen_tabs.iter().enumerate() { if i > 0 { spans.push(styled(" │ ", tc.muted, false)); } - let label = match tab { - DeviceClass::Rdma => "RDMA", - DeviceClass::Xgmi => "XGMI", - DeviceClass::Nvlink => "NVLINK", - }; + let label = tab.label(); if *tab == app.active_tab { spans.push(Span::styled( format!(" {} ", label), @@ -387,11 +379,7 @@ fn gpu_row_cells(t: &PortThroughput, tc: &ThemeColors) -> Vec> { /// h-scroll and the column picker are RDMA-only; this function ignores both. fn draw_gpu_table(frame: &mut Frame, app: &mut App, area: Rect, tc: &ThemeColors) { let display = app.display_throughputs().to_vec(); - let label = match app.active_tab { - DeviceClass::Xgmi => "XGMI", - DeviceClass::Nvlink => "NVLink", - DeviceClass::Rdma => "RDMA", - }; + let label = app.active_tab.label(); let title = if app.show_rolling_avg { format!( " {} Throughput (avg {}s) ",