From fca070ed08f2f092d732e7d606a2a6497abc4036 Mon Sep 17 00:00:00 2001 From: Clement Tsang <34804052+ClementTsang@users.noreply.github.com> Date: Sat, 30 May 2026 00:43:20 -0400 Subject: [PATCH] feature: add disk I/O time series chart (#2073) This PR adds a disk I/O (read and write rate) graph. --- CHANGELOG.md | 2 + .../assets/screenshots/disk_graph.webp | Bin 0 -> 20266 bytes .../assets/screenshots/temperature_graph.webp | Bin 0 -> 29494 bytes .../configuration/config-file/cpu-graph.md | 2 +- .../config-file/disk-io-graph.md | 73 ++++ .../configuration/config-file/disk-table.md | 2 +- .../configuration/config-file/memory-graph.md | 2 +- .../config-file/network-graph.md | 2 +- .../configuration/config-file/processes.md | 2 +- .../config-file/temperature-graph.md | 2 +- .../config-file/temperature-table.md | 2 +- docs/content/usage/widgets/disk-io-graph.md | 31 ++ docs/content/usage/widgets/network-graph.md | 2 +- .../usage/widgets/temperature-graph.md | 9 +- docs/mkdocs.yml | 2 + src/app.rs | 10 + src/app/data/store.rs | 14 +- src/app/data/time_series.rs | 65 ++++ src/app/layout_manager.rs | 9 +- src/app/states.rs | 21 +- src/canvas.rs | 12 + src/canvas/widgets/disk_io_graph.rs | 314 ++++++++++++++++++ src/canvas/widgets/mod.rs | 1 + src/collection.rs | 3 +- src/components/time_series.rs | 2 +- src/options.rs | 60 ++++ src/options/config.rs | 3 + src/options/config/disk_io_graph.rs | 39 +++ src/options/config/style.rs | 19 ++ src/options/config/style/disk_io_graph.rs | 17 + src/options/config/style/themes/default.rs | 48 +-- src/options/config/style/themes/gruvbox.rs | 36 +- src/options/config/style/themes/nord.rs | 48 +-- src/widgets/disk_io_graph.rs | 35 ++ src/widgets/mod.rs | 2 + tests/integration/valid_config_tests.rs | 5 + tests/valid_configs/widget/disk_io_graph.toml | 13 + 37 files changed, 838 insertions(+), 71 deletions(-) create mode 100644 docs/content/assets/screenshots/disk_graph.webp create mode 100644 docs/content/assets/screenshots/temperature_graph.webp create mode 100644 docs/content/configuration/config-file/disk-io-graph.md create mode 100644 docs/content/usage/widgets/disk-io-graph.md create mode 100644 src/canvas/widgets/disk_io_graph.rs create mode 100644 src/options/config/disk_io_graph.rs create mode 100644 src/options/config/style/disk_io_graph.rs create mode 100644 src/widgets/disk_io_graph.rs create mode 100644 tests/valid_configs/widget/disk_io_graph.toml 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 0000000000000000000000000000000000000000..081d317c4450a63a6c368d28a011c86ea4672f43 GIT binary patch literal 20266 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 Lqu4QRF0MZT_&19| literal 0 HcmV?d00001 diff --git a/docs/content/assets/screenshots/temperature_graph.webp b/docs/content/assets/screenshots/temperature_graph.webp new file mode 100644 index 0000000000000000000000000000000000000000..bea62885c205c29c8c498631a8e90c95f3d2dd76 GIT binary patch literal 29494 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

WV-u3 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 Fe*x0sKaBtY literal 0 HcmV?d00001 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