diff --git a/CHANGELOG.md b/CHANGELOG.md index 849c7f60..1c301a0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ That said, these are more guidelines rather than hard rules, though the project - [#2053](https://github.com/ClementTsang/bottom/pull/2053): Add a configurable default sort column for the process widget (`processes.default_sort` or `--process-default-sort`). - [#2066](https://github.com/ClementTsang/bottom/pull/2066): Add search support in the help dialog. - [#1791](https://github.com/ClementTsang/bottom/pull/1791), [#2072](https://github.com/ClementTsang/bottom/pull/2072): Add support for using a short name for the GPU in memory usage. +- [#2073](https://github.com/ClementTsang/bottom/pull/2073): Add disk I/O time series graph. ### Changes @@ -53,6 +54,7 @@ That said, these are more guidelines rather than hard rules, though the project - [#2064](https://github.com/ClementTsang/bottom/pull/2064): Move CPU config file options from `[flags]` to be under `[cpu]`. - [#2064](https://github.com/ClementTsang/bottom/pull/2064): Move memory config file options from `[flags]` to be under `[memory_graph]`. - [#2066](https://github.com/ClementTsang/bottom/pull/2066): Take cgroup into account for CPU usage% calculations. +- [#2074](https://github.com/ClementTsang/bottom/pull/2074): Treat uppercase 'Q' as a quit shortcut. ### Other diff --git a/docs/content/assets/screenshots/disk_graph.webp b/docs/content/assets/screenshots/disk_graph.webp new file mode 100644 index 00000000..081d317c Binary files /dev/null and b/docs/content/assets/screenshots/disk_graph.webp differ diff --git a/docs/content/assets/screenshots/temperature_graph.webp b/docs/content/assets/screenshots/temperature_graph.webp new file mode 100644 index 00000000..bea62885 Binary files /dev/null and b/docs/content/assets/screenshots/temperature_graph.webp differ diff --git a/docs/content/configuration/config-file/cpu-graph.md b/docs/content/configuration/config-file/cpu-graph.md index 30915fe0..012645cd 100644 --- a/docs/content/configuration/config-file/cpu-graph.md +++ b/docs/content/configuration/config-file/cpu-graph.md @@ -1,4 +1,4 @@ -# CPU +# CPU Configuration If you want to change some of the default behaviour of the CPU graph widget, you can configure things under the `[cpu]` section. diff --git a/docs/content/configuration/config-file/disk-io-graph.md b/docs/content/configuration/config-file/disk-io-graph.md new file mode 100644 index 00000000..548f12a7 --- /dev/null +++ b/docs/content/configuration/config-file/disk-io-graph.md @@ -0,0 +1,73 @@ +# Disk I/O Graph Configuration + +The disk I/O graph widget is configured under `[disk_io_graph]`. + +## Showing Read/Write Lines + +By default, both the read and write rate lines are shown. They can be toggled off independently. + +```toml +[disk_io_graph] +# Whether to show the read rate line. Defaults to true. +show_read = true + +# Whether to show the write rate line. Defaults to true. +show_write = true +``` + +## Legend Labels + +By default, legend entries are labelled by disk name (e.g. `sda`, `nvme0n1`). To use mount points +instead (e.g. `/`, `/home`): + +```toml +[disk_io_graph] +legend = "mount" +``` + +Valid values are `"disk"` (default) and `"mount"`. + +## Logarithmic Scale + +The y-axis can be switched to a logarithmic scale: + +```toml +[disk_io_graph] +use_log = true +``` + +## Legend Position + +The location of the legend can be set with `legend_position`. Valid values are `none`, `top-left`, `top`, `top-right`, +`left`, `right`, `bottom-left`, `bottom`, and `bottom-right`. Defaults to `top-right`. + +```toml +[disk_io_graph] +legend_position = "top-right" +``` + +## Filtering Entries + +You can filter out what entries to show by configuring `[disk_io_graph.name_filter]` and `[disk_io_graph.mount_filter]` to filter by +name and mount point respectively. In particular, you can set a list of things to filter with by setting `list`, +and configure how that list is processed with the other options. + +If we wanted to ignoring any entry with a name that matches `/dev/sda`: + +```toml +[disk_io_graph.name_filter] +# Whether to ignore any matches. Defaults to true. +is_list_ignored = true + +# A list of filters to try and match. +list = ["/dev/sda"] + +# Whether to use regex. Defaults to false. +regex = true + +# Whether to be case-sensitive. Defaults to false. +case_sensitive = false + +# Whether to require matching the whole word. Defaults to false. +whole_word = false +``` diff --git a/docs/content/configuration/config-file/disk-table.md b/docs/content/configuration/config-file/disk-table.md index 6904c7d8..c15dcc73 100644 --- a/docs/content/configuration/config-file/disk-table.md +++ b/docs/content/configuration/config-file/disk-table.md @@ -1,4 +1,4 @@ -# Disk Table +# Disk Table Configuration ## Columns diff --git a/docs/content/configuration/config-file/memory-graph.md b/docs/content/configuration/config-file/memory-graph.md index 9cb19322..72516c56 100644 --- a/docs/content/configuration/config-file/memory-graph.md +++ b/docs/content/configuration/config-file/memory-graph.md @@ -1,4 +1,4 @@ -# Memory Graph +# Memory Graph Configuration If you want to change some of the default behaviour of the memory graph widget, you can configure things under the `[memory_graph]` (or `[memory]`) section. diff --git a/docs/content/configuration/config-file/network-graph.md b/docs/content/configuration/config-file/network-graph.md index 9eec9669..bc85a0a1 100644 --- a/docs/content/configuration/config-file/network-graph.md +++ b/docs/content/configuration/config-file/network-graph.md @@ -1,4 +1,4 @@ -# Network Graph +# Network Graph Configuration ## Settings diff --git a/docs/content/configuration/config-file/processes.md b/docs/content/configuration/config-file/processes.md index 46a4c829..9050c426 100644 --- a/docs/content/configuration/config-file/processes.md +++ b/docs/content/configuration/config-file/processes.md @@ -1,4 +1,4 @@ -# Processes +# Processes Configuration ## Settings diff --git a/docs/content/configuration/config-file/temperature-graph.md b/docs/content/configuration/config-file/temperature-graph.md index c033f625..3c819c92 100644 --- a/docs/content/configuration/config-file/temperature-graph.md +++ b/docs/content/configuration/config-file/temperature-graph.md @@ -1,4 +1,4 @@ -# Temperature Graph +# Temperature Graph Configuration The temperature graph widget is configured under `[temperature_graph]`. diff --git a/docs/content/configuration/config-file/temperature-table.md b/docs/content/configuration/config-file/temperature-table.md index e44f8be6..797af1ad 100644 --- a/docs/content/configuration/config-file/temperature-table.md +++ b/docs/content/configuration/config-file/temperature-table.md @@ -1,4 +1,4 @@ -# Temperature Table +# Temperature Table Configuration The temperature table widget is configured under `[temperature]`. diff --git a/docs/content/usage/widgets/disk-io-graph.md b/docs/content/usage/widgets/disk-io-graph.md new file mode 100644 index 00000000..8dd5f2dc --- /dev/null +++ b/docs/content/usage/widgets/disk-io-graph.md @@ -0,0 +1,31 @@ +# Disk I/O Graph Widget + +The disk I/O graph widget shows read and write rates over time. + +
+ A picture of an expanded disk I/O graph widget. +
+ +## Features + +The legend displays the current reads and writes per second in bytes for each mount/disk item. The y-axis +automatically scales based on shown read/write values. By default this is in a linear scale, but it can be +switched to a logarithmic scale through the [config file](../../configuration/config-file/network-graph.md). + +The displayed time range can be adjusted through either the keyboard or mouse. + +## Key bindings + +Note that key bindings are generally case-sensitive. + +| Binding | Action | +| --------- | --------------------------------------- | +| ++plus++ | Zoom in on chart (decrease time range) | +| ++minus++ | Zoom out on chart (increase time range) | +| ++equal++ | Reset zoom | + +## Mouse bindings + +| Binding | Action | +| ------------ | -------------------------------------------------------------- | +| ++"Scroll"++ | Scrolling up or down zooms in or out of the graph respectively | diff --git a/docs/content/usage/widgets/network-graph.md b/docs/content/usage/widgets/network-graph.md index 9cf1e9e2..fd7f5668 100644 --- a/docs/content/usage/widgets/network-graph.md +++ b/docs/content/usage/widgets/network-graph.md @@ -12,7 +12,7 @@ received and transmitted. The legend displays the current reads and writes per second in bits, as well as the total amount read/written. The y-axis automatically scales based on shown read/write values, and by default, is a linear scale based on base-10 units (e.x. kilobit, gigabit, etc.). -Through [configuration](../../configuration/command-line-options.md), the read/write per second unit can be changed to bytes, while the y-axis can be changed to a +Through [configuration](../../configuration/config-file/network-graph.md), the read/write per second unit can be changed to bytes, while the y-axis can be changed to a log scale and/or use base-2 units (e.x. kibibit, gibibit, etc.). One can also adjust the displayed time range through either the keyboard or mouse, with a range of 30s to 600s. diff --git a/docs/content/usage/widgets/temperature-graph.md b/docs/content/usage/widgets/temperature-graph.md index 1074dc54..6dd5a03c 100644 --- a/docs/content/usage/widgets/temperature-graph.md +++ b/docs/content/usage/widgets/temperature-graph.md @@ -2,12 +2,17 @@ The temperature graph widget provides temperature readings over time. +
+ A picture of an expanded temperature graph widget. +
+ ## Features Each detected sensor is drawn as its own line. The y-axis is in the configured temperature unit (Celsius by default; see the `--celsius`/`--fahrenheit`/`--kelvin` arguments or `flags.temperature_type` config option). -By default the y-axis is bounded at 100°C (or the equivalent in the configured unit) and grows automatically. An upper bound can also be set explicitly via the [config file](../../configuration/config-file/temperature-graph.md). +By default the y-axis is bounded at 100°C (or the equivalent in the configured unit) and grows automatically. +An upper bound can also be set explicitly via the [config file](../../configuration/config-file/temperature-graph.md). The displayed time range can be adjusted through either the keyboard or mouse. @@ -26,5 +31,3 @@ Note that key bindings are generally case-sensitive. | Binding | Action | | ------------ | -------------------------------------------------------------- | | ++"Scroll"++ | Scrolling up or down zooms in or out of the graph respectively | - - diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 8738e4ff..1fdf1e78 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -168,6 +168,7 @@ nav: - "Disk Widget": usage/widgets/disk.md - "Temperature Widget": usage/widgets/temperature-table.md - "Temperature Graph Widget": usage/widgets/temperature-graph.md + - "Disk I/O Graph Widget": usage/widgets/disk-io-graph.md - "Battery Widget": usage/widgets/battery.md - "Auto-Complete": usage/autocomplete.md - "Configuration": @@ -181,6 +182,7 @@ nav: - "Processes Widget": configuration/config-file/processes.md - "Temperature Table Widget": configuration/config-file/temperature-table.md - "Temperature Graph Widget": configuration/config-file/temperature-graph.md + - "Disk I/O Graph Widget": configuration/config-file/disk-io-graph.md - "Flags": configuration/config-file/flags.md - "Layout": configuration/config-file/layout.md - "Styling": configuration/config-file/styling.md diff --git a/src/app.rs b/src/app.rs index e2e934cd..c9cc61c3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -81,6 +81,7 @@ pub struct AppConfigFields { pub default_temp_sort_column: Option, pub default_disk_sort_column: Option, pub temperature_legend_position: Option, + pub disk_io_legend_position: Option, } /// For filtering out information @@ -90,6 +91,7 @@ pub struct DataFilters { pub mount_filter: Option, pub temp_filter: Option, pub temp_graph_filter: Option, + pub disk_io_graph_filter: Option, pub net_filter: Option, } @@ -2073,6 +2075,14 @@ impl App { { Some(widget_state.graph.state_mut()) } + BottomWidgetType::DiskIoGraph + if let Some(widget_state) = self + .states + .disk_io_graph_state + .get_mut_widget_state(self.current_widget.widget_id) => + { + Some(widget_state.graph.state_mut()) + } _ => None, } } diff --git a/src/app/data/store.rs b/src/app/data/store.rs index 0aadc5ee..4fa3adf8 100644 --- a/src/app/data/store.rs +++ b/src/app/data/store.rs @@ -11,7 +11,7 @@ use crate::{ collection::{ Data, cpu::{CpuHarvest, LoadAvgHarvest}, - disks, + disks::{DiskHarvest, IoHarvest}, memory::MemData, network::NetworkHarvest, }, @@ -153,6 +153,11 @@ impl StoredData { if let Some(disks) = data.disks { if let Some(io) = data.io { self.eat_disks(disks, io, harvested_time); + + if used_widgets.use_disk_io_graph { + self.time_series_data + .update_disk_io(&self.disk_harvest, &filters.disk_io_graph_filter); + } } } @@ -171,9 +176,8 @@ impl StoredData { self.last_update_time = harvested_time; } - fn eat_disks( - &mut self, disks: Vec, io: disks::IoHarvest, harvested_time: Instant, - ) { + // TODO: There's a spike on the first hit. We should probably fix this and the index issue. + fn eat_disks(&mut self, disks: Vec, io: IoHarvest, harvested_time: Instant) { let time_since_last_harvest = harvested_time .duration_since(self.last_update_time) .as_secs_f64(); @@ -184,6 +188,8 @@ impl StoredData { self.prev_io.reserve(prev_io_diff); self.prev_io.extend((0..prev_io_diff).map(|_| (0, 0))); + // FIXME: prev_io is indexed by position (itx), not by device name, which might cause problems + // if the order changes or something. for (itx, device) in disks.into_iter().enumerate() { let Some(checked_name) = ({ #[cfg(target_os = "windows")] diff --git a/src/app/data/time_series.rs b/src/app/data/time_series.rs index d1a5c27d..9b3fe66d 100644 --- a/src/app/data/time_series.rs +++ b/src/app/data/time_series.rs @@ -12,6 +12,7 @@ use timeless::data::ChunkedData; use crate::{ app::{AppConfigFields, DataFilters, filter::Filter, layout_manager::UsedWidgets}, collection::Data, + widgets::DiskWidgetData, }; /// Values corresponding to a time slice. @@ -65,6 +66,12 @@ pub struct TimeSeriesData { /// /// TODO: Maybe make this use TypedTemperature? pub temperature: HashMap>, + + /// Disk I/O read rate data, keyed by device name. + pub disk_io_read: HashMap>, + + /// Disk I/O write rate data, keyed by device name. + pub disk_io_write: HashMap>, } impl TimeSeriesData { @@ -226,6 +233,42 @@ impl TimeSeriesData { } } + /// Update disk I/O time series using already-calculated rates from + /// [`DiskWidgetData`]. Devices not present in `disks` get a break inserted + /// so the legend keeps them around for the window's remaining lifetime. + pub fn update_disk_io(&mut self, disks: &[DiskWidgetData], filter: &Option) { + let mut not_visited: HashSet = self.disk_io_read.keys().cloned().collect(); + + for disk in disks { + if !Filter::optional_should_keep(filter, &disk.name) { + continue; + } + + not_visited.remove(&disk.name); + + let read_entry = self.disk_io_read.entry(disk.name.clone()).or_default(); + match disk.io_read_rate_bytes { + Some(v) => read_entry.push(v as f64), + None => read_entry.insert_break(), + } + + let write_entry = self.disk_io_write.entry(disk.name.clone()).or_default(); + match disk.io_write_rate_bytes { + Some(v) => write_entry.push(v as f64), + None => write_entry.insert_break(), + } + } + + for name in not_visited { + if let Some(entry) = self.disk_io_read.get_mut(&name) { + entry.insert_break(); + } + if let Some(entry) = self.disk_io_write.get_mut(&name) { + entry.insert_break(); + } + } + } + /// Prune any data older than the given duration. pub fn prune(&mut self, max_age: Duration) { if self.time.is_empty() { @@ -297,5 +340,27 @@ impl TimeSeriesData { true } }); + + self.disk_io_read.retain(|_, data| { + let _ = data.prune(end); + + if data.no_elements() { + false + } else { + data.shrink_to_fit(); + true + } + }); + + self.disk_io_write.retain(|_, data| { + let _ = data.prune(end); + + if data.no_elements() { + false + } else { + data.shrink_to_fit(); + true + } + }); } } diff --git a/src/app/layout_manager.rs b/src/app/layout_manager.rs index 73efce5b..9f7cbafe 100644 --- a/src/app/layout_manager.rs +++ b/src/app/layout_manager.rs @@ -943,6 +943,7 @@ pub enum BottomWidgetType { Temp, TempGraph, Disk, + DiskIoGraph, BasicCpu, BasicMem, BasicNet, @@ -958,7 +959,7 @@ impl BottomWidgetType { pub fn is_widget_graph(&self) -> bool { use BottomWidgetType::*; - matches!(self, Cpu | Net | Mem | TempGraph) + matches!(self, Cpu | Net | Mem | TempGraph | DiskIoGraph) } pub fn get_pretty_name(&self) -> &str { @@ -990,6 +991,7 @@ impl std::str::FromStr for BottomWidgetType { "temp" | "temperature" => Ok(BottomWidgetType::Temp), "temp_graph" | "temperature_graph" => Ok(BottomWidgetType::TempGraph), "disk" => Ok(BottomWidgetType::Disk), + "disk_io_graph" => Ok(BottomWidgetType::DiskIoGraph), "empty" => Ok(BottomWidgetType::Empty), #[cfg(feature = "battery")] "battery" | "batt" => Ok(BottomWidgetType::Battery), @@ -1015,6 +1017,8 @@ Supported widget names: +--------------------------------+ | disk | +--------------------------------+ +| disk_io_graph | ++--------------------------------+ | batt, battery | +--------------------------------+ | empty | @@ -1043,6 +1047,8 @@ Supported widget names: +--------------------------------+ | disk | +--------------------------------+ +| disk_io_graph | ++--------------------------------+ | empty | +--------------------------------+ ", @@ -1064,5 +1070,6 @@ pub struct UsedWidgets { pub use_disk: bool, pub use_temp: bool, pub use_temp_graph: bool, + pub use_disk_io_graph: bool, pub use_battery: bool, } diff --git a/src/app/states.rs b/src/app/states.rs index 32881d65..49555979 100644 --- a/src/app/states.rs +++ b/src/app/states.rs @@ -5,8 +5,9 @@ use crate::{ constants, utils::input::InputFieldState, widgets::{ - BatteryWidgetState, CpuWidgetState, DiskTableWidget, MemWidgetState, NetWidgetState, - ProcWidgetState, TempGraphWidgetState, TempWidgetState, query::ProcessQuery, + BatteryWidgetState, CpuWidgetState, DiskIoGraphWidgetState, DiskTableWidget, + MemWidgetState, NetWidgetState, ProcWidgetState, TempGraphWidgetState, TempWidgetState, + query::ProcessQuery, }, }; @@ -18,6 +19,7 @@ pub struct AppWidgetStates { pub temp_state: TempState, pub temp_graph_state: TempGraphStates, pub disk_state: DiskState, + pub disk_io_graph_state: DiskIoGraphStates, pub battery_state: AppBatteryState, pub basic_table_widget_state: Option, } @@ -192,6 +194,21 @@ impl TempGraphStates { } } +/// Holds per-widget state for all disk I/O graph instances in the layout. +pub struct DiskIoGraphStates { + pub widget_states: HashMap, +} + +impl DiskIoGraphStates { + pub fn init(widget_states: HashMap) -> Self { + DiskIoGraphStates { widget_states } + } + + pub fn get_mut_widget_state(&mut self, widget_id: u64) -> Option<&mut DiskIoGraphWidgetState> { + self.widget_states.get_mut(&widget_id) + } +} + pub struct DiskState { pub widget_states: HashMap, } diff --git a/src/canvas.rs b/src/canvas.rs index 21a8e681..d382866d 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -281,6 +281,12 @@ impl Painter { rect[0], app_state.current_widget.widget_id, ), + DiskIoGraph => self.draw_disk_io_graph( + f, + app_state, + rect[0], + app_state.current_widget.widget_id, + ), _ => {} } } else if app_state.app_config_fields.use_basic_mode { @@ -401,6 +407,9 @@ impl Painter { vertical_chunks[3], widget_id, ), + DiskIoGraph => { + self.draw_disk_io_graph(f, app_state, vertical_chunks[3], widget_id) + } _ => {} } } @@ -482,6 +491,9 @@ impl Painter { TempGraph => { self.draw_temperature_graph(f, app_state, *draw_loc, widget.widget_id) } + DiskIoGraph => { + self.draw_disk_io_graph(f, app_state, *draw_loc, widget.widget_id) + } _ => {} } } diff --git a/src/canvas/widgets/disk_io_graph.rs b/src/canvas/widgets/disk_io_graph.rs new file mode 100644 index 00000000..d27105be --- /dev/null +++ b/src/canvas/widgets/disk_io_graph.rs @@ -0,0 +1,314 @@ +use std::{ + borrow::Cow, + time::{Duration, Instant}, +}; + +use rustc_hash::FxHashMap; +use timeless::data::ChunkedData; +use tui::{ + Frame, + layout::{Constraint, Rect}, +}; + +use crate::{ + app::App, + canvas::{ + Painter, + components::time_series::{AxisBound, ChartScaling, GraphData, LegendConstraints}, + drawing_utils::should_hide_x_label, + }, + components::time_series::GraphDrawCtx, + options::config::disk_io_graph::DiskGraphLegend, + utils::data_units::*, +}; + +impl Painter { + pub fn draw_disk_io_graph( + &self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect, widget_id: u64, + ) { + if let Some(widget_state) = app_state + .states + .disk_io_graph_state + .get_mut_widget_state(widget_id) + { + let shared_data = app_state.data_store.get_data(); + let read_data = &shared_data.time_series_data.disk_io_read; + let write_data = &shared_data.time_series_data.disk_io_write; + let times = &shared_data.time_series_data.time; + + let mount_map: FxHashMap<&str, &str> = shared_data + .disk_harvest + .iter() + .map(|d| (d.name.as_str(), d.mount_point.as_str())) + .collect(); + + let border_style = self.get_border_style(widget_id, app_state.current_widget.widget_id); + let graph_state = widget_state.graph.state_mut(); + let hide_x_labels = should_hide_x_label( + app_state.app_config_fields.hide_time, + app_state.app_config_fields.autohide_time, + graph_state.autohide_timer_mut(), + draw_loc, + ); + let current_display_time = graph_state.current_display_time(); + + let show_read = widget_state.show_read; + let show_write = widget_state.show_write; + let use_log = widget_state.use_log; + let legend_type = &widget_state.legend; + + let mut device_names: Vec<&String> = read_data + .keys() + .filter(|name| { + mount_map.contains_key(name.as_str()) + || read_data + .get(*name) + .is_some_and(|d| has_data_in_window(d, times, current_display_time)) + }) + .collect(); + device_names.sort_unstable(); + + // Compute y_max across all visible series. + let mut visible_sources: Vec<&_> = Vec::new(); + for name in &device_names { + if show_read { + if let Some(data) = read_data.get(*name) { + visible_sources.push(data); + } + } + if show_write { + if let Some(data) = write_data.get(*name) { + visible_sources.push(data); + } + } + } + let y_max = widget_state.graph.y_max(visible_sources.into_iter(), times); + + let (adjusted_y_max, y_labels) = if use_log { + adjust_disk_io_log(y_max) + } else { + adjust_disk_io_linear(y_max) + }; + + let legend_constraints = LegendConstraints { + width: Constraint::Ratio(9, 10), + height: Constraint::Ratio(1, 2), + }; + + // Removed devices still visible in the window show "N/A". + // TODO: Maybe should make it so the colour is based on entry name? As then it may shift. + let read_colours = &self.styles.disk_io_read_colour_styles; + let write_colours = &self.styles.disk_io_write_colour_styles; + + let mut graph_data: Vec> = + Vec::with_capacity(device_names.len() * 2); + for (idx, name) in device_names.iter().enumerate() { + let display_name = match legend_type { + DiskGraphLegend::Disk => name.as_str(), + DiskGraphLegend::Mount => mount_map + .get(name.as_str()) + .copied() + .unwrap_or(name.as_str()), + }; + + let is_active = mount_map.contains_key(name.as_str()); + + let read_values = show_read.then(|| read_data.get(*name)).flatten(); + let write_values = show_write.then(|| write_data.get(*name)).flatten(); + + let read_style = if read_colours.is_empty() { + tui::style::Style::default() + } else { + read_colours[idx % read_colours.len()] + }; + let write_style = if write_colours.is_empty() { + tui::style::Style::default() + } else { + write_colours[idx % write_colours.len()] + }; + + // TODO: Combine into one line; probably need to add some kind of multi-styled GraphData. + if let Some(values) = read_values { + let rate = if is_active { + format_rate_fixed(values.last().copied().unwrap_or(0.0)) + } else { + " N/A".to_string() + }; + graph_data.push( + GraphData::default() + .name(format!("{display_name} R:{rate}").into()) + .style(read_style) + .time(times) + .values(values), + ); + } + + if let Some(values) = write_values { + let rate = if is_active { + format_rate_fixed(values.last().copied().unwrap_or(0.0)) + } else { + " N/A".to_string() + }; + graph_data.push( + GraphData::default() + .name(format!("{display_name} W:{rate}").into()) + .style(write_style) + .time(times) + .values(values), + ); + } + } + + let marker = self.get_marker(app_state.app_config_fields.use_dot); + let y_bounds = AxisBound::Max(adjusted_y_max); + let scaling = if use_log { + ChartScaling::Log2 + } else { + ChartScaling::Linear + }; + let y_labels: Vec> = y_labels.into_iter().map(Into::into).collect(); + + widget_state.graph.draw( + f, + draw_loc, + GraphDrawCtx { + title: " Disk I/O ".into(), + border_style, + title_style: self.styles.widget_title_style, + graph_style: self.styles.graph_style, + general_widget_style: self.styles.general_widget_style, + border_type: self.styles.border_type, + marker, + hide_x_labels, + is_selected: app_state.current_widget.widget_id == widget_id, + is_expanded: app_state.is_expanded, + legend_position: app_state.app_config_fields.disk_io_legend_position, + legend_constraints: Some(legend_constraints), + }, + y_bounds, + &y_labels, + scaling, + graph_data, + ); + } + + if app_state.should_get_widget_bounds() { + if let Some(widget) = app_state.widget_map.get_mut(&widget_id) { + widget.top_left_corner = Some((draw_loc.x, draw_loc.y)); + widget.bottom_right_corner = + Some((draw_loc.x + draw_loc.width, draw_loc.y + draw_loc.height)); + } + } + } +} + +/// Returns true if `data` has at least one real (non-gap) data point within the +/// visible time window defined by `current_display_time` milliseconds from the end +/// of `times`. +fn has_data_in_window>( + data: &ChunkedData, times: &[Instant], current_display_time: u64, +) -> bool { + let Some(&last_time) = times.last() else { + return false; + }; + let display_duration = Duration::from_millis(current_display_time); + let oldest = last_time.checked_sub(display_duration).unwrap_or(last_time); + data.iter_along_base(times) + .next_back() + .is_some_and(|(t, _)| *t >= oldest) +} + +/// Format a byte/s rate as a fixed-width string (always 11 chars, right-aligned) +/// to keep legend labels a stable width and prevent legend box shifting between frames. +fn format_rate_fixed(bytes_per_sec: f64) -> String { + let (val, unit) = if bytes_per_sec < KIBI_LIMIT_F64 { + (bytes_per_sec, "B/s") + } else if bytes_per_sec < MEBI_LIMIT_F64 { + (bytes_per_sec / KIBI_LIMIT_F64, "KiB/s") + } else if bytes_per_sec < GIBI_LIMIT_F64 { + (bytes_per_sec / MEBI_LIMIT_F64, "MiB/s") + } else if bytes_per_sec < TEBI_LIMIT_F64 { + (bytes_per_sec / GIBI_LIMIT_F64, "GiB/s") + } else { + (bytes_per_sec / TEBI_LIMIT_F64, "TiB/s") + }; + format!("{:>11}", format!("{val:.1}{unit}")) +} + +fn adjust_disk_io_linear(max_entry: f64) -> (f64, Vec) { + let max_entry_upper = if max_entry == 0.0 { + 1.0 + } else { + max_entry * 1.5 + }; + + let (max_value_scaled, unit) = if max_entry_upper < KIBI_LIMIT_F64 { + (max_entry, "B/s") + } else if max_entry_upper < MEBI_LIMIT_F64 { + (max_entry / KIBI_LIMIT_F64, "KiB/s") + } else if max_entry_upper < GIBI_LIMIT_F64 { + (max_entry / MEBI_LIMIT_F64, "MiB/s") + } else if max_entry_upper < TEBI_LIMIT_F64 { + (max_entry / GIBI_LIMIT_F64, "GiB/s") + } else { + (max_entry / TEBI_LIMIT_F64, "TiB/s") + }; + + let labels = vec![ + format!("0{unit}"), + format!("{:.1}", max_value_scaled * 0.5), + format!("{:.1}", max_value_scaled), + format!("{:.1}", max_value_scaled * 1.5), + ] + .into_iter() + .map(|s| format!("{s:>8}")) + .collect(); + + (max_entry_upper, labels) +} + +fn adjust_disk_io_log(max_entry: f64) -> (f64, Vec) { + use crate::utils::general::saturating_log2; + + let log_max = saturating_log2(max_entry); + + if log_max < LOG_MEBI_LIMIT { + ( + LOG_MEBI_LIMIT, + vec![" 0B/s".into(), "1KiB/s".into(), "1MiB/s".into()], + ) + } else if log_max < LOG_GIBI_LIMIT { + ( + LOG_GIBI_LIMIT, + vec![ + " 0B/s".into(), + "1KiB/s".into(), + "1MiB/s".into(), + "1GiB/s".into(), + ], + ) + } else if log_max < LOG_TEBI_LIMIT { + ( + LOG_TEBI_LIMIT, + vec![ + " 0B/s".into(), + "1KiB/s".into(), + "1MiB/s".into(), + "1GiB/s".into(), + "1TiB/s".into(), + ], + ) + } else { + ( + LOG_PEBI_LIMIT, + vec![ + " 0B/s".into(), + "1KiB/s".into(), + "1MiB/s".into(), + "1GiB/s".into(), + "1TiB/s".into(), + "1PiB/s".into(), + ], + ) + } +} diff --git a/src/canvas/widgets/mod.rs b/src/canvas/widgets/mod.rs index ef9a6156..37dfcd6e 100644 --- a/src/canvas/widgets/mod.rs +++ b/src/canvas/widgets/mod.rs @@ -2,6 +2,7 @@ use crate::{collection::network::NetworkHarvest, utils::data_units::convert_byte pub mod cpu_basic; pub mod cpu_graph; +pub mod disk_io_graph; pub mod disk_table; pub mod mem_basic; pub mod mem_graph; diff --git a/src/collection.rs b/src/collection.rs index 87119847..175a6789 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -627,7 +627,7 @@ impl DataCollector { #[inline] fn update_disks(&mut self) { - if self.widgets_to_harvest.use_disk { + if self.widgets_to_harvest.use_disk || self.widgets_to_harvest.use_disk_io_graph { self.data.disks = disks::get_disk_usage(self).ok(); self.data.io = disks::get_io_usage().ok(); } @@ -669,6 +669,7 @@ mod tests { mount_filter: None, temp_filter: None, temp_graph_filter: None, + disk_io_graph_filter: None, net_filter: None, }); diff --git a/src/components/time_series.rs b/src/components/time_series.rs index 340f7196..c1f4d0e9 100644 --- a/src/components/time_series.rs +++ b/src/components/time_series.rs @@ -49,7 +49,7 @@ impl TimeseriesState { } /// Get the current display time. - fn current_display_time(&self) -> u64 { + pub(crate) fn current_display_time(&self) -> u64 { self.current_display_time } diff --git a/src/options.rs b/src/options.rs index 2ddd76c2..dd33cb8d 100644 --- a/src/options.rs +++ b/src/options.rs @@ -360,6 +360,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL let mut temp_state_map: FxHashMap = FxHashMap::default(); let mut temp_graph_state_map: FxHashMap = FxHashMap::default(); let mut disk_state_map: FxHashMap = FxHashMap::default(); + let mut disk_io_graph_state_map: FxHashMap = FxHashMap::default(); let mut battery_state_map: FxHashMap = FxHashMap::default(); let autohide_timer = if autohide_time { @@ -402,6 +403,12 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL let network_legend_position = get_network_legend_position(args, config)?; let memory_legend_position = get_memory_legend_position(args, config)?; let temperature_legend_position = get_temperature_legend_position(config)?; + let disk_io_legend_position = get_disk_io_legend_position(config)?; + let disk_io_name_filter = match &config.disk_io_graph { + Some(cfg) => get_ignore_list(&cfg.name_filter) + .context("Update 'disk_io_graph.name_filter' in your config file")?, + None => None, + }; // TODO: Can probably just reuse the options struct. let app_config_fields = AppConfigFields { @@ -478,6 +485,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL .as_ref() .and_then(|cfg| cfg.default_sort.to_owned()), temperature_legend_position, + disk_io_legend_position, }; let process_default_sort = match &args.process.process_default_sort { @@ -614,6 +622,43 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL TempGraphWidgetState::new(ts_config, autohide_timer, upper_limit), ); } + DiskIoGraph => { + let show_read = config + .disk_io_graph + .as_ref() + .and_then(|c| c.show_read) + .unwrap_or(true); + + let show_write = config + .disk_io_graph + .as_ref() + .and_then(|c| c.show_write) + .unwrap_or(true); + + let legend = config + .disk_io_graph + .as_ref() + .and_then(|c| c.legend.clone()) + .unwrap_or_default(); + + let use_log = config + .disk_io_graph + .as_ref() + .and_then(|c| c.use_log) + .unwrap_or(false); + + disk_io_graph_state_map.insert( + widget.widget_id, + DiskIoGraphWidgetState::new( + ts_config, + autohide_timer, + show_read, + show_write, + legend, + use_log, + ), + ); + } Battery => { battery_state_map .insert(widget.widget_id, BatteryWidgetState::default()); @@ -661,6 +706,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL use_disk: used_widget_set.contains(&Disk), use_temp: used_widget_set.contains(&Temp), use_temp_graph: used_widget_set.contains(&TempGraph), + use_disk_io_graph: used_widget_set.contains(&DiskIoGraph), use_battery: used_widget_set.contains(&Battery), }; @@ -701,6 +747,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL temp_state: TempState::init(temp_state_map), temp_graph_state: TempGraphStates::init(temp_graph_state_map), disk_state: DiskState::init(disk_state_map), + disk_io_graph_state: DiskIoGraphStates::init(disk_io_graph_state_map), battery_state: AppBatteryState::init(battery_state_map), basic_table_widget_state, }; @@ -714,6 +761,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL mount_filter: disk_mount_filter, temp_filter: temp_sensor_filter, temp_graph_filter: temp_graph_sensor_filter, + disk_io_graph_filter: disk_io_name_filter, net_filter: net_interface_filter, }; let is_expanded = expanded && !use_basic_mode; @@ -1284,6 +1332,18 @@ fn get_temperature_legend_position(config: &Config) -> OptionResult OptionResult> { + parse_legend_position( + None, + config + .disk_io_graph + .as_ref() + .and_then(|settings| settings.legend_position.as_ref()), + None, + "disk_io_graph.legend_position", + ) +} + #[cfg(test)] mod test { use clap::Parser; diff --git a/src/options/config.rs b/src/options/config.rs index 44773876..9479ec14 100644 --- a/src/options/config.rs +++ b/src/options/config.rs @@ -1,5 +1,6 @@ pub mod cpu; pub mod disk; +pub mod disk_io_graph; pub mod flags; mod ignore_list; pub mod layout; @@ -11,6 +12,7 @@ pub mod temperature; pub mod temperature_graph; use disk::DiskConfig; +use disk_io_graph::DiskIoGraphConfig; use flags::GeneralConfig; use network_graph::NetworkGraphConfig; use serde::{Deserialize, Serialize}; @@ -32,6 +34,7 @@ pub struct Config { pub(crate) row: Option>, pub(crate) processes: Option, pub(crate) disk: Option, + pub(crate) disk_io_graph: Option, pub(crate) temperature: Option, pub(crate) temperature_graph: Option, #[serde(alias = "network")] diff --git a/src/options/config/disk_io_graph.rs b/src/options/config/disk_io_graph.rs new file mode 100644 index 00000000..631720f8 --- /dev/null +++ b/src/options/config/disk_io_graph.rs @@ -0,0 +1,39 @@ +use serde::Deserialize; + +use super::IgnoreList; + +/// Whether the disk graph legend labels use device names or mount points. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum DiskGraphLegend { + /// Label entries by kernel device name (e.g. `sda`, `nvme0n1`). + #[default] + Disk, + /// Label entries by mount point (e.g. `/`, `/home`). + Mount, +} + +/// Disk I/O graph configuration. +#[derive(Clone, Debug, Default, Deserialize)] +#[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))] +#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))] +pub(crate) struct DiskIoGraphConfig { + /// Whether to show the read rate line. Defaults to true. + pub(crate) show_read: Option, + + /// Whether to show the write rate line. Defaults to true. + pub(crate) show_write: Option, + + /// Whether to label legend entries by device name or mount point. Defaults to disk name. + pub(crate) legend: Option, + + /// Whether to use a logarithmic scale on the y-axis. Defaults to false. + pub(crate) use_log: Option, + + /// Where to position the legend within the widget. + pub(crate) legend_position: Option, + + /// An optional list of device names to include or exclude. + pub(crate) name_filter: Option, +} diff --git a/src/options/config/style.rs b/src/options/config/style.rs index 7e21bfa8..84f4d1f9 100644 --- a/src/options/config/style.rs +++ b/src/options/config/style.rs @@ -3,6 +3,7 @@ mod battery; mod borders; mod cpu; +mod disk_io_graph; mod graphs; mod memory; mod network; @@ -16,6 +17,7 @@ use std::borrow::Cow; use battery::BatteryStyle; use cpu::CpuStyle; +use disk_io_graph::DiskIoGraphStyle; use graphs::GraphStyle; use memory::MemoryStyle; use network::NetworkStyle; @@ -84,6 +86,9 @@ pub(crate) struct StyleConfig { /// Styling for the network widget. pub(crate) network: Option, + /// Styling for the disk I/O graph widget. + pub(crate) disk_io_graph: Option, + /// Styling for the temperature graph widget. pub(crate) temp_graph: Option, @@ -119,6 +124,8 @@ pub struct Styles { pub(crate) avg_cpu_colour: Style, pub(crate) cpu_colour_styles: Vec