diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c301a0b..5522f125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ That said, these are more guidelines rather than hard rules, though the project - [#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. +- [#2088](https://github.com/ClementTsang/bottom/pull/2088): Add option to show unmounted disks on Linux. ### Changes diff --git a/docs/content/configuration/config-file/disk-io-graph.md b/docs/content/configuration/config-file/disk-io-graph.md index 548f12a7..ea26b2dc 100644 --- a/docs/content/configuration/config-file/disk-io-graph.md +++ b/docs/content/configuration/config-file/disk-io-graph.md @@ -46,6 +46,15 @@ The location of the legend can be set with `legend_position`. Valid values are ` legend_position = "top-right" ``` +## Show Unmounted Devices (Linux only) + +By default, only mounted devices are shown. To also show unmounted devices on Linux, enable `include_unmounted`: + +```toml +[disk_io_graph] +include_unmounted = true +``` + ## 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 diff --git a/docs/content/configuration/config-file/disk-table.md b/docs/content/configuration/config-file/disk-table.md index c15dcc73..2058fc51 100644 --- a/docs/content/configuration/config-file/disk-table.md +++ b/docs/content/configuration/config-file/disk-table.md @@ -22,6 +22,19 @@ default_sort = "R/s" You can use any valid [column](#columns) name here (e.g. "Disk", "Mount", etc.). Note that if you put a column name that is not actually used, the default sort will just be the first column shown. +## Show Unmounted Devices (Linux only) + +By default, only mounted devices are shown. To also show unmounted devices on Linux, enable `include_unmounted`: + +```toml +[disk] +include_unmounted = true +``` + +Unmounted devices show I/O activity but have no used/free space (those require a live mount), so those +columns appear as `N/A`. Pseudo-devices (`loop*`, `ram*`, `zram*`) are skipped, and you can hide other +noisy devices with the [name filter](#filtering-entries). + ## Filtering Entries You can filter out what entries to show by configuring `[disk.name_filter]` and `[disk.mount_filter]` to filter by name and mount point respectively. In particular, diff --git a/sample_configs/default_config.toml b/sample_configs/default_config.toml index ebba087a..415ca003 100644 --- a/sample_configs/default_config.toml +++ b/sample_configs/default_config.toml @@ -228,6 +228,9 @@ # Defaults to "Disk". #default_sort = "Disk" +# Whether to include block devices that aren't currently mounted (currently Linux only). Defaults to false. +#include_unmounted = false + # By default, there are no disk name filters enabled. These can be turned on to filter out specific data entries if you # don't want to see them. An example use case is provided below. #[disk.name_filter] @@ -264,6 +267,45 @@ #whole_word = false +# Disk I/O graph widget configuration +#[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 + +# Whether to label legend entries by device name ("disk") or mount point ("mount"). Defaults to "disk". +#legend = "disk" + +# Whether to use a logarithmic scale on the y-axis. Defaults to false. +#use_log = false + +# Where to place the legend. One of "none", "top-left", "top", "top-right", "left", "right", "bottom-left", "bottom", "bottom-right". +#legend_position = "top-right" + +# Whether to include block devices that aren't currently mounted (currently Linux only). Defaults to false. +#include_unmounted = false + +# By default, there are no disk I/O name filters enabled. An example use case is provided below. +#[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\\d+", "/dev/nvme0n1p2"] + +# 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 + + # Temperature widget configuration #[temperature] diff --git a/schema/nightly/bottom.json b/schema/nightly/bottom.json index c76c9f46..5492e3e8 100644 --- a/schema/nightly/bottom.json +++ b/schema/nightly/bottom.json @@ -25,6 +25,16 @@ } ] }, + "disk_io_graph": { + "anyOf": [ + { + "$ref": "#/$defs/DiskIoGraphConfig" + }, + { + "type": "null" + } + ] + }, "flags": { "anyOf": [ { @@ -257,6 +267,13 @@ } ] }, + "include_unmounted": { + "description": "Whether to include block devices that aren't currently mounted (currently Linux only). Defaults to false.", + "type": [ + "boolean", + "null" + ] + }, "mount_filter": { "description": "A filter over the mount names.", "anyOf": [ @@ -281,6 +298,110 @@ } } }, + "DiskGraphLegend": { + "description": "Whether the disk graph legend labels use device names or mount points.", + "oneOf": [ + { + "description": "Label entries by kernel device name (e.g. `sda`, `nvme0n1`).", + "type": "string", + "const": "disk" + }, + { + "description": "Label entries by mount point (e.g. `/`, `/home`).", + "type": "string", + "const": "mount" + } + ] + }, + "DiskIoGraphConfig": { + "description": "Disk I/O graph configuration.", + "type": "object", + "properties": { + "include_unmounted": { + "description": "Whether to include block devices that aren't currently mounted (currently Linux only). Defaults to false.", + "type": [ + "boolean", + "null" + ] + }, + "legend": { + "description": "Whether to label legend entries by device name or mount point. Defaults to disk name.", + "anyOf": [ + { + "$ref": "#/$defs/DiskGraphLegend" + }, + { + "type": "null" + } + ] + }, + "legend_position": { + "description": "Where to position the legend within the widget.", + "type": [ + "string", + "null" + ] + }, + "name_filter": { + "description": "An optional list of device names to include or exclude.", + "anyOf": [ + { + "$ref": "#/$defs/IgnoreList" + }, + { + "type": "null" + } + ] + }, + "show_read": { + "description": "Whether to show the read rate line. Defaults to true.", + "type": [ + "boolean", + "null" + ] + }, + "show_write": { + "description": "Whether to show the write rate line. Defaults to true.", + "type": [ + "boolean", + "null" + ] + }, + "use_log": { + "description": "Whether to use a logarithmic scale on the y-axis. Defaults to false.", + "type": [ + "boolean", + "null" + ] + } + } + }, + "DiskIoGraphStyle": { + "description": "Styling specific to the disk I/O graph widget.", + "type": "object", + "properties": { + "read_colours": { + "description": "Colour of each disk's read rate graph line. Read in order.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/ColourStr" + } + }, + "write_colours": { + "description": "Colour of each disk's write rate graph line. Read in order.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/ColourStr" + } + } + } + }, "DiskWidgetColumn": { "type": "string", "enum": [ @@ -1134,6 +1255,17 @@ } ] }, + "disk_io_graph": { + "description": "Styling for the disk I/O graph widget.", + "anyOf": [ + { + "$ref": "#/$defs/DiskIoGraphStyle" + }, + { + "type": "null" + } + ] + }, "graphs": { "description": "Styling for graph widgets.", "anyOf": [ diff --git a/src/app.rs b/src/app.rs index c9cc61c3..34b369c0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -82,6 +82,8 @@ pub struct AppConfigFields { pub default_disk_sort_column: Option, pub temperature_legend_position: Option, pub disk_io_legend_position: Option, + pub disk_show_unmounted: bool, + pub disk_io_graph_show_unmounted: bool, } /// For filtering out information diff --git a/src/app/data/store.rs b/src/app/data/store.rs index 4fa3adf8..f8e1fe75 100644 --- a/src/app/data/store.rs +++ b/src/app/data/store.rs @@ -155,8 +155,11 @@ impl StoredData { 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); + self.time_series_data.update_disk_io( + &self.disk_harvest, + &filters.disk_io_graph_filter, + settings.disk_io_graph_show_unmounted, + ); } } } diff --git a/src/app/data/time_series.rs b/src/app/data/time_series.rs index 9b3fe66d..22a8c4a5 100644 --- a/src/app/data/time_series.rs +++ b/src/app/data/time_series.rs @@ -236,10 +236,20 @@ 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) { + pub fn update_disk_io( + &mut self, disks: &[DiskWidgetData], filter: &Option, show_unmounted: bool, + ) { + #[cfg(not(target_os = "linux"))] + let _ = show_unmounted; + let mut not_visited: HashSet = self.disk_io_read.keys().cloned().collect(); for disk in disks { + #[cfg(target_os = "linux")] + if !show_unmounted && disk.mount_point.is_empty() { + continue; + } + if !Filter::optional_should_keep(filter, &disk.name) { continue; } diff --git a/src/canvas/widgets/disk_io_graph.rs b/src/canvas/widgets/disk_io_graph.rs index d27105be..4bac8437 100644 --- a/src/canvas/widgets/disk_io_graph.rs +++ b/src/canvas/widgets/disk_io_graph.rs @@ -8,6 +8,7 @@ use timeless::data::ChunkedData; use tui::{ Frame, layout::{Constraint, Rect}, + style::Style, }; use crate::{ @@ -60,10 +61,31 @@ impl Painter { let mut device_names: Vec<&String> = read_data .keys() .filter(|name| { - mount_map.contains_key(name.as_str()) - || read_data + let has_read_data = || { + read_data .get(*name) .is_some_and(|d| has_data_in_window(d, times, current_display_time)) + }; + + // If there is a mount point and we're in mount legend mode, it must be non-empty + // (i.e. actually mounted), or we will short-circuit and ignore it. + if let Some(mount_point) = mount_map.get(name.as_str()) { + match legend_type { + DiskGraphLegend::Disk => true, + DiskGraphLegend::Mount => !mount_point.is_empty() && has_read_data(), + } + } else { + match legend_type { + DiskGraphLegend::Disk => { + // Otherwise, it may have _previously_ been a valid mount point, so keep showing it until it ages out. + has_read_data() + } + DiskGraphLegend::Mount => { + // Since it would be misleading in this case, just skip it in mount mode. + false + } + } + } }) .collect(); device_names.sort_unstable(); @@ -100,15 +122,31 @@ impl Painter { let read_colours = &self.styles.disk_io_read_colour_styles; let write_colours = &self.styles.disk_io_write_colour_styles; + // Pad the device/mount labels to the widest visible one so the rate columns + // line up in the legend (the rate itself is already fixed-width). + let name_width = device_names + .iter() + .map(|name| match legend_type { + DiskGraphLegend::Disk => name.len(), + DiskGraphLegend::Mount => mount_map + .get(name.as_str()) + .map(|mount| mount.len()) + .unwrap_or(0), + }) + .max() + .unwrap_or(0); + 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()), + DiskGraphLegend::Mount => match mount_map.get(name.as_str()).copied() { + Some(mount) => mount, + // This wouldn't trigger anyway, we filter out devices without mount points in mount legend mode. + None => continue, + }, }; let is_active = mount_map.contains_key(name.as_str()); @@ -117,12 +155,12 @@ impl Painter { let write_values = show_write.then(|| write_data.get(*name)).flatten(); let read_style = if read_colours.is_empty() { - tui::style::Style::default() + Style::default() } else { read_colours[idx % read_colours.len()] }; let write_style = if write_colours.is_empty() { - tui::style::Style::default() + Style::default() } else { write_colours[idx % write_colours.len()] }; @@ -136,7 +174,7 @@ impl Painter { }; graph_data.push( GraphData::default() - .name(format!("{display_name} R:{rate}").into()) + .name(format!("{display_name: anyhow::Result = Vec::new(); + #[cfg(target_os = "linux")] + let mut mounted_names = std::collections::HashSet::new(); + for partition in physical_partitions()? { let name = partition.get_device_name(); let mount_point = partition.mount_point().to_string_lossy().to_string(); + #[cfg(target_os = "linux")] + if collector.include_unmounted_disks { + if let Some(base) = name.rsplit('/').next() { + mounted_names.insert(base.to_string()); + } + } + // Precedence ordering in the case where name and mount filters disagree, // "allow" takes precedence over "deny". // @@ -74,5 +84,18 @@ pub fn get_disk_usage(collector: &DataCollector) -> anyhow::Result) -> Vec { + const PROC_PARTITIONS: &str = "/proc/partitions"; + + let Ok(file) = File::open(PROC_PARTITIONS) else { + return Vec::new(); + }; + + parse_unmounted_disks(BufReader::new(file), mounted) +} + +/// Parses `/proc/partitions` into [`DiskHarvest`] entries for block devices not present in `mounted`. +fn parse_unmounted_disks(mut reader: R, mounted: &HashSet) -> Vec { + let mut disks = Vec::new(); + let mut line = String::new(); + + while let Ok(bytes) = reader.read_line(&mut line) { + if bytes == 0 { + break; + } + + // Format: `major minor #blocks name`. The header line and the blank line + // after it simply fail to parse here and get skipped. + let mut parts = line.split_whitespace(); + let blocks = parts.nth(2).and_then(|b| b.parse::().ok()); + let name = parts.next(); + + if let (Some(blocks), Some(name)) = (blocks, name) { + if !(mounted.contains(name) + || name.starts_with("loop") + || name.starts_with("ram") + || name.starts_with("zram")) + { + disks.push(DiskHarvest { + name: format!("/dev/{name}"), + mount_point: String::new(), + free_space: None, + used_space: None, + total_space: Some(blocks * PARTITION_BLOCK_SIZE), + }); + } + } + + line.clear(); + } + + disks +} + +#[cfg(test)] +mod tests { + use super::*; + + // Note the header + blank line at the top, exactly like the real file. + const SAMPLE: &str = "\ +major minor #blocks name + + 8 0 500 sda + 8 1 1000 sda1 + 8 2 4000000 sda2 + 259 0 90000000 nvme0n1 + 259 1 900000 nvme0n1p1 + 7 0 100000 loop0 + 1 0 6000 ram0 + 252 0 1000000 zram0 + 8 16 0 sdb +"; + + fn names(disks: &[DiskHarvest]) -> Vec<&str> { + disks.iter().map(|d| d.name.as_str()).collect() + } + + #[test] + fn skip_non_disk_entries() { + let disks = parse_unmounted_disks(SAMPLE.as_bytes(), &HashSet::new()); + assert_eq!( + names(&disks), + vec![ + "/dev/sda", + "/dev/sda1", + "/dev/sda2", + "/dev/nvme0n1", + "/dev/nvme0n1p1", + "/dev/sdb", + ] + ); + } + + #[test] + fn excludes_mounted_devices() { + let mounted = HashSet::from(["sda2".to_string(), "nvme0n1p1".to_string()]); + let disks = parse_unmounted_disks(SAMPLE.as_bytes(), &mounted); + + assert!(!names(&disks).contains(&"/dev/sda2")); + assert!(!names(&disks).contains(&"/dev/nvme0n1p1")); + + // Whole disk + other partitions still appear (kept on purpose). + assert!(names(&disks).contains(&"/dev/sda")); + assert!(names(&disks).contains(&"/dev/nvme0n1")); + } + + #[test] + fn check_disk_harvest_entry() { + let disks = parse_unmounted_disks(SAMPLE.as_bytes(), &HashSet::new()); + let sda = disks.iter().find(|d| d.name == "/dev/sda").unwrap(); + + assert_eq!(sda.total_space, Some(500 * PARTITION_BLOCK_SIZE)); + assert_eq!(sda.mount_point, ""); + assert_eq!(sda.free_space, None); + assert_eq!(sda.used_space, None); + } + + #[test] + fn zero_block_entry() { + let disks = parse_unmounted_disks(SAMPLE.as_bytes(), &HashSet::new()); + let sdb = disks.iter().find(|d| d.name == "/dev/sdb").unwrap(); + assert_eq!(sdb.total_space, Some(0)); + } +} diff --git a/src/constants.rs b/src/constants.rs index 433a2187..c54d8d1d 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -477,6 +477,9 @@ pub(crate) const CONFIG_TEXT: &str = r#"# This is a default config file for bott # Defaults to "Disk". #default_sort = "Disk" +# Whether to include block devices that aren't currently mounted (currently Linux only). Defaults to false. +#include_unmounted = false + # By default, there are no disk name filters enabled. These can be turned on to filter out specific data entries if you # don't want to see them. An example use case is provided below. #[disk.name_filter] @@ -513,6 +516,45 @@ pub(crate) const CONFIG_TEXT: &str = r#"# This is a default config file for bott #whole_word = false +# Disk I/O graph widget configuration +#[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 + +# Whether to label legend entries by device name ("disk") or mount point ("mount"). Defaults to "disk". +#legend = "disk" + +# Whether to use a logarithmic scale on the y-axis. Defaults to false. +#use_log = false + +# Where to place the legend. One of "none", "top-left", "top", "top-right", "left", "right", "bottom-left", "bottom", "bottom-right". +#legend_position = "top-right" + +# Whether to include block devices that aren't currently mounted (currently Linux only). Defaults to false. +#include_unmounted = false + +# By default, there are no disk I/O name filters enabled. An example use case is provided below. +#[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\\d+", "/dev/nvme0n1p2"] + +# 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 + + # Temperature widget configuration #[temperature] diff --git a/src/lib.rs b/src/lib.rs index 1cdb584f..cd0e5931 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -234,6 +234,8 @@ fn create_collection_thread( let get_process_threads = app_config_fields.get_process_threads; #[cfg(feature = "zfs")] let get_arc_free = app_config_fields.free_arc; + let include_unmounted_disks = + app_config_fields.disk_show_unmounted || app_config_fields.disk_io_graph_show_unmounted; thread::spawn(move || { let mut data_collector = collection::DataCollector::new(filters); @@ -245,6 +247,7 @@ fn create_collection_thread( data_collector.set_get_process_threads(get_process_threads); #[cfg(feature = "zfs")] data_collector.set_free_arc_mem(get_arc_free); + data_collector.set_include_unmounted_disks(include_unmounted_disks); data_collector.update_data(); data_collector.data = Data::default(); diff --git a/src/options.rs b/src/options.rs index dd33cb8d..cf538b21 100644 --- a/src/options.rs +++ b/src/options.rs @@ -409,6 +409,16 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL .context("Update 'disk_io_graph.name_filter' in your config file")?, None => None, }; + let disk_show_unmounted = config + .disk + .as_ref() + .and_then(|cfg| cfg.include_unmounted) + .unwrap_or(false); + let disk_io_graph_show_unmounted = config + .disk_io_graph + .as_ref() + .and_then(|cfg| cfg.include_unmounted) + .unwrap_or(false); // TODO: Can probably just reuse the options struct. let app_config_fields = AppConfigFields { @@ -486,6 +496,8 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL .and_then(|cfg| cfg.default_sort.to_owned()), temperature_legend_position, disk_io_legend_position, + disk_show_unmounted, + disk_io_graph_show_unmounted, }; let process_default_sort = match &args.process.process_default_sort { @@ -602,6 +614,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL &app_config_fields, &styling, config.disk.as_ref().and_then(|cfg| cfg.columns.as_deref()), + disk_show_unmounted, ), ); } diff --git a/src/options/config/disk.rs b/src/options/config/disk.rs index 3c073faa..1965cd7d 100644 --- a/src/options/config/disk.rs +++ b/src/options/config/disk.rs @@ -14,6 +14,9 @@ pub(crate) struct DiskConfig { /// A filter over the mount names. pub(crate) mount_filter: Option, + /// Whether to include block devices that aren't currently mounted (currently Linux only). Defaults to false. + pub(crate) include_unmounted: Option, + /// A list of disk widget columns. // TODO: make this more composable(?) in the future, we might need to // rethink how it's done for custom widgets. diff --git a/src/options/config/disk_io_graph.rs b/src/options/config/disk_io_graph.rs index 631720f8..eecbe141 100644 --- a/src/options/config/disk_io_graph.rs +++ b/src/options/config/disk_io_graph.rs @@ -36,4 +36,7 @@ pub(crate) struct DiskIoGraphConfig { /// An optional list of device names to include or exclude. pub(crate) name_filter: Option, + + /// Whether to include block devices that aren't currently mounted (currently Linux only). Defaults to false. + pub(crate) include_unmounted: Option, } diff --git a/src/widgets/disk_table.rs b/src/widgets/disk_table.rs index e7624d7f..dfb96cf0 100644 --- a/src/widgets/disk_table.rs +++ b/src/widgets/disk_table.rs @@ -217,8 +217,9 @@ impl DataToCell for DiskWidgetData { } pub struct DiskTableWidget { - pub table: SortDataTable, - pub force_update_data: bool, + pub(crate) table: SortDataTable, + pub(crate) force_update_data: bool, + pub(crate) show_unmounted: bool, } impl SortsRow for DiskWidgetColumn { @@ -318,6 +319,7 @@ const fn default_disk_columns() -> [SortColumn; 8] { impl DiskTableWidget { pub fn new( config: &AppConfigFields, palette: &Styles, columns: Option<&[DiskWidgetColumn]>, + show_unmounted: bool, ) -> Self { let props = SortDataTableProps { inner: DataTableProps { @@ -356,11 +358,13 @@ impl DiskTableWidget { Self { table: SortDataTable::new_sortable(columns, props, styling), force_update_data: false, + show_unmounted, } } None => Self { table: SortDataTable::new_sortable(default_disk_columns(), props, styling), force_update_data: false, + show_unmounted, }, } } @@ -373,7 +377,24 @@ impl DiskTableWidget { /// Update the current table data. pub fn set_table_data(&mut self, data: &StoredData) { - let mut data = data.disk_harvest.clone(); + // Note that the data may contain unmounted disks (e.g. we enable it for another disk widget), + // so we have to potentially filter it out here too. + let mut data: Vec = if self.show_unmounted { + data.disk_harvest.clone() + } else { + #[cfg(target_os = "linux")] + { + data.disk_harvest + .iter() + .filter(|disk| !disk.mount_point.is_empty()) + .cloned() + .collect() + } + #[cfg(not(target_os = "linux"))] + { + data.disk_harvest.clone() + } + }; if let Some(column) = self.table.columns.get(self.table.sort_index()) { column.sort_by(&mut data, self.table.order());