mirror of
https://github.com/ClementTsang/bottom.git
synced 2026-07-09 06:01:04 +00:00
feature: add option to track unmounted disks on Linux (#2088)
This PR adds the option to additionally scan unmounted disk partitions. This currently only works on Linux.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -82,6 +82,8 @@ pub struct AppConfigFields {
|
||||
pub default_disk_sort_column: Option<DiskWidgetColumn>,
|
||||
pub temperature_legend_position: Option<LegendPosition>,
|
||||
pub disk_io_legend_position: Option<LegendPosition>,
|
||||
pub disk_show_unmounted: bool,
|
||||
pub disk_io_graph_show_unmounted: bool,
|
||||
}
|
||||
|
||||
/// For filtering out information
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Filter>) {
|
||||
pub fn update_disk_io(
|
||||
&mut self, disks: &[DiskWidgetData], filter: &Option<Filter>, show_unmounted: bool,
|
||||
) {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = show_unmounted;
|
||||
|
||||
let mut not_visited: HashSet<String> = 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;
|
||||
}
|
||||
|
||||
@@ -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<GraphData<'_, f64>> =
|
||||
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:<name_width$} R:{rate}").into())
|
||||
.style(read_style)
|
||||
.time(times)
|
||||
.values(values),
|
||||
@@ -151,7 +189,7 @@ impl Painter {
|
||||
};
|
||||
graph_data.push(
|
||||
GraphData::default()
|
||||
.name(format!("{display_name} W:{rate}").into())
|
||||
.name(format!("{display_name:<name_width$} W:{rate}").into())
|
||||
.style(write_style)
|
||||
.time(times)
|
||||
.values(values),
|
||||
|
||||
@@ -156,6 +156,7 @@ pub struct DataCollector {
|
||||
last_collection_time: Instant,
|
||||
widgets_to_harvest: UsedWidgets,
|
||||
filters: DataFilters,
|
||||
include_unmounted_disks: bool,
|
||||
|
||||
total_rx: u64,
|
||||
total_tx: u64,
|
||||
@@ -245,6 +246,7 @@ impl DataCollector {
|
||||
cgroup_memory_data: CgroupMemCollector::default(),
|
||||
#[cfg(target_os = "linux")]
|
||||
cgroup_cpu_data: CgroupCpuCollector::default(),
|
||||
include_unmounted_disks: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +292,10 @@ impl DataCollector {
|
||||
self.get_process_threads = get_process_threads;
|
||||
}
|
||||
|
||||
pub fn set_include_unmounted_disks(&mut self, include_unmounted_disks: bool) {
|
||||
self.include_unmounted_disks = include_unmounted_disks;
|
||||
}
|
||||
|
||||
#[cfg(feature = "zfs")]
|
||||
pub fn set_free_arc_mem(&mut self, free_mem: bool) {
|
||||
self.free_arc_mem = free_mem;
|
||||
|
||||
@@ -35,6 +35,8 @@ use crate::app::filter::Filter;
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DiskHarvest {
|
||||
pub name: String,
|
||||
|
||||
/// Current disk mount point. Empty string if not currently mounted.
|
||||
pub mount_point: String,
|
||||
|
||||
/// Windows also contains an additional volume name field.
|
||||
|
||||
@@ -33,10 +33,20 @@ pub fn get_disk_usage(collector: &DataCollector) -> anyhow::Result<Vec<DiskHarve
|
||||
let mount_filter = &collector.filters.mount_filter;
|
||||
let mut vec_disks: Vec<DiskHarvest> = 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<DiskHarve
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// If we're including unmounted disks, then we'll add any disks that we previously
|
||||
// did not mark as mounted.
|
||||
if collector.include_unmounted_disks {
|
||||
for disk in unmounted_disks(&mounted_names) {
|
||||
if keep_disk_entry(&disk.name, &disk.mount_point, disk_filter, &None) {
|
||||
vec_disks.push(disk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(vec_disks)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
mod counters;
|
||||
mod partition;
|
||||
mod unmounted;
|
||||
|
||||
pub use counters::*;
|
||||
pub(crate) use partition::*;
|
||||
pub(crate) use unmounted::*;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Return block devices that aren't currently mounted on Linux.
|
||||
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader},
|
||||
};
|
||||
|
||||
use crate::collection::disks::DiskHarvest;
|
||||
|
||||
/// `/proc/partitions` reports sizes in 1024-byte blocks.
|
||||
const PARTITION_BLOCK_SIZE: u64 = 1024;
|
||||
|
||||
/// Returns [`DiskHarvest`] entries for block devices that aren't in `mounted`.
|
||||
///
|
||||
/// These come from `/proc/partitions`, which lists every block device (so even
|
||||
/// devices with no I/O activity are covered) along with its size in terms of blocks.
|
||||
/// Note this also filters out some devices, like `loop*`, `ram*`, `zram*`, etc., as
|
||||
/// these are not "disks".
|
||||
pub(crate) fn unmounted_disks(mounted: &HashSet<String>) -> Vec<DiskHarvest> {
|
||||
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<R: BufRead>(mut reader: R, mounted: &HashSet<String>) -> Vec<DiskHarvest> {
|
||||
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::<u64>().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));
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ pub(crate) struct DiskConfig {
|
||||
/// A filter over the mount names.
|
||||
pub(crate) mount_filter: Option<IgnoreList>,
|
||||
|
||||
/// Whether to include block devices that aren't currently mounted (currently Linux only). Defaults to false.
|
||||
pub(crate) include_unmounted: Option<bool>,
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -36,4 +36,7 @@ pub(crate) struct DiskIoGraphConfig {
|
||||
|
||||
/// An optional list of device names to include or exclude.
|
||||
pub(crate) name_filter: Option<IgnoreList>,
|
||||
|
||||
/// Whether to include block devices that aren't currently mounted (currently Linux only). Defaults to false.
|
||||
pub(crate) include_unmounted: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -217,8 +217,9 @@ impl DataToCell<DiskWidgetColumn> for DiskWidgetData {
|
||||
}
|
||||
|
||||
pub struct DiskTableWidget {
|
||||
pub table: SortDataTable<DiskWidgetData, DiskWidgetColumn>,
|
||||
pub force_update_data: bool,
|
||||
pub(crate) table: SortDataTable<DiskWidgetData, DiskWidgetColumn>,
|
||||
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<DiskWidgetColumn>; 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<DiskWidgetData> = 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());
|
||||
|
||||
Reference in New Issue
Block a user