feature: add disk I/O time series chart (#2073)

This PR adds a disk I/O (read and write rate) graph.
This commit is contained in:
Clement Tsang
2026-05-30 00:43:20 -04:00
committed by GitHub
parent f916a87f87
commit fca070ed08
37 changed files with 838 additions and 71 deletions
+2
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

@@ -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.
@@ -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
```
@@ -1,4 +1,4 @@
# Disk Table
# Disk Table Configuration
## Columns
@@ -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.
@@ -1,4 +1,4 @@
# Network Graph
# Network Graph Configuration
## Settings
@@ -1,4 +1,4 @@
# Processes
# Processes Configuration
## Settings
@@ -1,4 +1,4 @@
# Temperature Graph
# Temperature Graph Configuration
The temperature graph widget is configured under `[temperature_graph]`.
@@ -1,4 +1,4 @@
# Temperature Table
# Temperature Table Configuration
The temperature table widget is configured under `[temperature]`.
@@ -0,0 +1,31 @@
# Disk I/O Graph Widget
The disk I/O graph widget shows read and write rates over time.
<figure>
<img src="../../../assets/screenshots/disk_graph.webp" alt="A picture of an expanded disk I/O graph widget."/>
</figure>
## 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 |
+1 -1
View File
@@ -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.
@@ -2,12 +2,17 @@
The temperature graph widget provides temperature readings over time.
<figure>
<img src="../../../assets/screenshots/temperature_graph.webp" alt="A picture of an expanded temperature graph widget."/>
</figure>
## 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 |
<!--TODO: Add screenshots of widgets -->
+2
View File
@@ -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
+10
View File
@@ -81,6 +81,7 @@ pub struct AppConfigFields {
pub default_temp_sort_column: Option<TempWidgetColumn>,
pub default_disk_sort_column: Option<DiskWidgetColumn>,
pub temperature_legend_position: Option<LegendPosition>,
pub disk_io_legend_position: Option<LegendPosition>,
}
/// For filtering out information
@@ -90,6 +91,7 @@ pub struct DataFilters {
pub mount_filter: Option<Filter>,
pub temp_filter: Option<Filter>,
pub temp_graph_filter: Option<Filter>,
pub disk_io_graph_filter: Option<Filter>,
pub net_filter: Option<Filter>,
}
@@ -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,
}
}
+10 -4
View File
@@ -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<disks::DiskHarvest>, 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<DiskHarvest>, 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")]
+65
View File
@@ -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<String, ChunkedData<f32>>,
/// Disk I/O read rate data, keyed by device name.
pub disk_io_read: HashMap<String, ChunkedData<f64>>,
/// Disk I/O write rate data, keyed by device name.
pub disk_io_write: HashMap<String, ChunkedData<f64>>,
}
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<Filter>) {
let mut not_visited: HashSet<String> = 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
}
});
}
}
+8 -1
View File
@@ -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,
}
+19 -2
View File
@@ -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<BasicTableWidgetState>,
}
@@ -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<u64, DiskIoGraphWidgetState>,
}
impl DiskIoGraphStates {
pub fn init(widget_states: HashMap<u64, DiskIoGraphWidgetState>) -> 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<u64, DiskTableWidget>,
}
+12
View File
@@ -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)
}
_ => {}
}
}
+314
View File
@@ -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<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()),
};
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<Cow<'_, str>> = 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<F: Copy + Default + Into<f64>>(
data: &ChunkedData<F>, 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<String>) {
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<String>) {
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(),
],
)
}
}
+1
View File
@@ -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;
+2 -1
View File
@@ -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,
});
+1 -1
View File
@@ -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
}
+60
View File
@@ -360,6 +360,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
let mut temp_state_map: FxHashMap<u64, TempWidgetState> = FxHashMap::default();
let mut temp_graph_state_map: FxHashMap<u64, TempGraphWidgetState> = FxHashMap::default();
let mut disk_state_map: FxHashMap<u64, DiskTableWidget> = FxHashMap::default();
let mut disk_io_graph_state_map: FxHashMap<u64, DiskIoGraphWidgetState> = FxHashMap::default();
let mut battery_state_map: FxHashMap<u64, BatteryWidgetState> = 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<Option<Legen
)
}
fn get_disk_io_legend_position(config: &Config) -> OptionResult<Option<LegendPosition>> {
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;
+3
View File
@@ -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<Vec<Row>>,
pub(crate) processes: Option<ProcessesConfig>,
pub(crate) disk: Option<DiskConfig>,
pub(crate) disk_io_graph: Option<DiskIoGraphConfig>,
pub(crate) temperature: Option<TempConfig>,
pub(crate) temperature_graph: Option<TempGraphConfig>,
#[serde(alias = "network")]
+39
View File
@@ -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<bool>,
/// Whether to show the write rate line. Defaults to true.
pub(crate) show_write: Option<bool>,
/// Whether to label legend entries by device name or mount point. Defaults to disk name.
pub(crate) legend: Option<DiskGraphLegend>,
/// Whether to use a logarithmic scale on the y-axis. Defaults to false.
pub(crate) use_log: Option<bool>,
/// Where to position the legend within the widget.
pub(crate) legend_position: Option<String>,
/// An optional list of device names to include or exclude.
pub(crate) name_filter: Option<IgnoreList>,
}
+19
View File
@@ -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<NetworkStyle>,
/// Styling for the disk I/O graph widget.
pub(crate) disk_io_graph: Option<DiskIoGraphStyle>,
/// Styling for the temperature graph widget.
pub(crate) temp_graph: Option<TempGraphStyle>,
@@ -119,6 +124,8 @@ pub struct Styles {
pub(crate) avg_cpu_colour: Style,
pub(crate) cpu_colour_styles: Vec<Style>,
pub(crate) temp_graph_colour_styles: Vec<Style>,
pub(crate) disk_io_read_colour_styles: Vec<Style>,
pub(crate) disk_io_write_colour_styles: Vec<Style>,
pub(crate) border_style: Style,
pub(crate) highlighted_border_style: Style,
pub(crate) text_style: Style,
@@ -191,6 +198,18 @@ impl Styles {
temp_graph_colour_styles
);
// Disk I/O graph
set_colour_list!(
self.disk_io_read_colour_styles,
config.disk_io_graph,
read_colours
);
set_colour_list!(
self.disk_io_write_colour_styles,
config.disk_io_graph,
write_colours
);
// Memory
set_colour!(self.ram_style, config.memory, ram_colour);
set_colour!(self.swap_style, config.memory, swap_colour);
+17
View File
@@ -0,0 +1,17 @@
use serde::{Deserialize, Serialize};
use super::ColourStr;
/// Styling specific to the disk I/O graph widget.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))]
#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))]
pub(crate) struct DiskIoGraphStyle {
/// Colour of each disk's read rate graph line. Read in order.
#[serde(alias = "read_colours")]
pub(crate) read_colours: Option<Vec<ColourStr>>,
/// Colour of each disk's write rate graph line. Read in order.
#[serde(alias = "write_colours")]
pub(crate) write_colours: Option<Vec<ColourStr>>,
}
+28 -20
View File
@@ -31,6 +31,16 @@ impl Styles {
colour!(Color::Blue),
];
let second_colours_list = vec![
colour!(FOURTH_COLOUR),
colour!(Color::LightBlue),
colour!(Color::LightRed),
colour!(Color::Cyan),
colour!(Color::Green),
colour!(Color::Blue),
colour!(Color::Red),
];
Self {
ram_style: colour!(FIRST_COLOUR),
#[cfg(not(target_os = "windows"))]
@@ -39,15 +49,7 @@ impl Styles {
#[cfg(feature = "zfs")]
arc_style: colour!(THIRD_COLOUR),
#[cfg(feature = "gpu")]
gpu_colours: vec![
colour!(FOURTH_COLOUR),
colour!(Color::LightBlue),
colour!(Color::LightRed),
colour!(Color::Cyan),
colour!(Color::Green),
colour!(Color::Blue),
colour!(Color::Red),
],
gpu_colours: second_colours_list.clone(),
rx_style: colour!(FIRST_COLOUR),
tx_style: colour!(SECOND_COLOUR),
total_rx_style: colour!(THIRD_COLOUR),
@@ -55,7 +57,9 @@ impl Styles {
all_cpu_colour: colour!(ALL_COLOUR),
avg_cpu_colour: colour!(AVG_COLOUR),
cpu_colour_styles: list_colours.clone(),
temp_graph_colour_styles: list_colours,
temp_graph_colour_styles: list_colours.clone(),
disk_io_read_colour_styles: list_colours,
disk_io_write_colour_styles: second_colours_list,
border_style: colour!(TEXT_COLOUR),
highlighted_border_style: colour!(HIGHLIGHT_COLOUR),
text_style: colour!(TEXT_COLOUR),
@@ -87,6 +91,16 @@ impl Styles {
colour!(Color::Red),
];
let second_colours_list = vec![
colour!(Color::LightGreen),
colour!(Color::LightCyan),
colour!(Color::LightRed),
colour!(Color::Cyan),
colour!(Color::Green),
colour!(Color::Blue),
colour!(Color::Red),
];
Self {
ram_style: colour!(Color::Blue),
#[cfg(not(target_os = "windows"))]
@@ -95,21 +109,15 @@ impl Styles {
#[cfg(feature = "zfs")]
arc_style: colour!(Color::LightBlue),
#[cfg(feature = "gpu")]
gpu_colours: vec![
colour!(Color::LightGreen),
colour!(Color::LightCyan),
colour!(Color::LightRed),
colour!(Color::Cyan),
colour!(Color::Green),
colour!(Color::Blue),
colour!(Color::Red),
],
gpu_colours: second_colours_list.clone(),
rx_style: colour!(Color::Blue),
tx_style: colour!(Color::Red),
total_rx_style: colour!(Color::LightBlue),
total_tx_style: colour!(Color::LightRed),
cpu_colour_styles: list_colours.clone(),
temp_graph_colour_styles: list_colours,
temp_graph_colour_styles: list_colours.clone(),
disk_io_read_colour_styles: list_colours,
disk_io_write_colour_styles: second_colours_list,
border_style: colour!(Color::Black),
text_style: colour!(Color::Black),
selected_text_style: colour!(Color::White).bg(Color::LightBlue),
+25 -11
View File
@@ -31,6 +31,16 @@ impl Styles {
hex!("#af3a03"),
];
let second_colours_list = vec![
hex!("#d79921"),
hex!("#458588"),
hex!("#b16286"),
hex!("#fe8019"),
hex!("#b8bb26"),
hex!("#cc241d"),
hex!("#98971a"),
];
Self {
ram_style: hex!("#8ec07c"),
#[cfg(not(target_os = "windows"))]
@@ -39,15 +49,7 @@ impl Styles {
#[cfg(feature = "zfs")]
arc_style: hex!("#689d6a"),
#[cfg(feature = "gpu")]
gpu_colours: vec![
hex!("#d79921"),
hex!("#458588"),
hex!("#b16286"),
hex!("#fe8019"),
hex!("#b8bb26"),
hex!("#cc241d"),
hex!("#98971a"),
],
gpu_colours: second_colours_list.clone(),
rx_style: hex!("#8ec07c"),
tx_style: hex!("#fabd2f"),
total_rx_style: hex!("#689d6a"),
@@ -55,7 +57,9 @@ impl Styles {
all_cpu_colour: hex!("#8ec07c"),
avg_cpu_colour: hex!("#fb4934"),
cpu_colour_styles: list_colours.clone(),
temp_graph_colour_styles: list_colours,
temp_graph_colour_styles: list_colours.clone(),
disk_io_read_colour_styles: list_colours,
disk_io_write_colour_styles: second_colours_list,
border_style: hex!("#ebdbb2"),
highlighted_border_style: hex!("#fe8019"),
text_style: hex!("#ebdbb2"),
@@ -124,7 +128,17 @@ impl Styles {
all_cpu_colour: hex!("#8ec07c"),
avg_cpu_colour: hex!("#fb4934"),
cpu_colour_styles: list_colours.clone(),
temp_graph_colour_styles: list_colours,
temp_graph_colour_styles: list_colours.clone(),
disk_io_read_colour_styles: list_colours,
disk_io_write_colour_styles: vec![
hex!("#9d0006"),
hex!("#98971a"),
hex!("#d79921"),
hex!("#458588"),
hex!("#b16286"),
hex!("#fe8019"),
hex!("#b8bb26"),
],
border_style: hex!("#3c3836"),
highlighted_border_style: hex!("#af3a03"),
text_style: hex!("#3c3836"),
+28 -20
View File
@@ -19,6 +19,16 @@ impl Styles {
hex!("#bf616a"),
];
let second_colours_list = vec![
hex!("#8fbcbb"),
hex!("#81a1c1"),
hex!("#d8dee9"),
hex!("#b48ead"),
hex!("#a3be8c"),
hex!("#ebcb8b"),
hex!("#bf616a"),
];
Self {
ram_style: hex!("#88c0d0"),
#[cfg(not(target_os = "windows"))]
@@ -27,15 +37,7 @@ impl Styles {
#[cfg(feature = "zfs")]
arc_style: hex!("#5e81ac"),
#[cfg(feature = "gpu")]
gpu_colours: vec![
hex!("#8fbcbb"),
hex!("#81a1c1"),
hex!("#d8dee9"),
hex!("#b48ead"),
hex!("#a3be8c"),
hex!("#ebcb8b"),
hex!("#bf616a"),
],
gpu_colours: second_colours_list.clone(),
rx_style: hex!("#88c0d0"),
tx_style: hex!("#d08770"),
total_rx_style: hex!("#5e81ac"),
@@ -43,7 +45,9 @@ impl Styles {
all_cpu_colour: hex!("#88c0d0"),
avg_cpu_colour: hex!("#8fbcbb"),
cpu_colour_styles: list_colours.clone(),
temp_graph_colour_styles: list_colours,
temp_graph_colour_styles: list_colours.clone(),
disk_io_read_colour_styles: list_colours,
disk_io_write_colour_styles: second_colours_list,
border_style: hex!("#88c0d0"),
highlighted_border_style: hex!("#5e81ac"),
text_style: hex!("#e5e9f0"),
@@ -76,6 +80,16 @@ impl Styles {
hex!("#bf616a"),
];
let second_colours_list = vec![
hex!("#8fbcbb"),
hex!("#88c0d0"),
hex!("#4c566a"),
hex!("#b48ead"),
hex!("#a3be8c"),
hex!("#ebcb8b"),
hex!("#bf616a"),
];
Self {
ram_style: hex!("#81a1c1"),
#[cfg(not(target_os = "windows"))]
@@ -84,15 +98,7 @@ impl Styles {
#[cfg(feature = "zfs")]
arc_style: hex!("#5e81ac"),
#[cfg(feature = "gpu")]
gpu_colours: vec![
hex!("#8fbcbb"),
hex!("#88c0d0"),
hex!("#4c566a"),
hex!("#b48ead"),
hex!("#a3be8c"),
hex!("#ebcb8b"),
hex!("#bf616a"),
],
gpu_colours: second_colours_list.clone(),
rx_style: hex!("#81a1c1"),
tx_style: hex!("#d08770"),
total_rx_style: hex!("#5e81ac"),
@@ -100,7 +106,9 @@ impl Styles {
all_cpu_colour: hex!("#81a1c1"),
avg_cpu_colour: hex!("#8fbcbb"),
cpu_colour_styles: list_colours.clone(),
temp_graph_colour_styles: list_colours,
temp_graph_colour_styles: list_colours.clone(),
disk_io_read_colour_styles: list_colours,
disk_io_write_colour_styles: second_colours_list,
border_style: hex!("#2e3440"),
highlighted_border_style: hex!("#5e81ac"),
text_style: hex!("#2e3440"),
+35
View File
@@ -0,0 +1,35 @@
use std::time::Instant;
use crate::{
components::time_series::{AutoYAxisTimeGraph, TimeseriesConfig},
options::config::disk_io_graph::DiskGraphLegend,
};
/// Runtime state for a disk I/O graph widget.
pub struct DiskIoGraphWidgetState {
/// The underlying time-series graph with automatic y-axis scaling.
pub graph: AutoYAxisTimeGraph,
/// Whether the read rate line is currently shown.
pub show_read: bool,
/// Whether the write rate line is currently shown.
pub show_write: bool,
/// Whether legend entries use device names or mount points.
pub legend: DiskGraphLegend,
/// Whether the y-axis uses a logarithmic scale.
pub use_log: bool,
}
impl DiskIoGraphWidgetState {
pub fn new(
config: TimeseriesConfig, autohide_timer: Option<Instant>, show_read: bool,
show_write: bool, legend: DiskGraphLegend, use_log: bool,
) -> Self {
Self {
graph: AutoYAxisTimeGraph::new(config, autohide_timer),
show_read,
show_write,
legend,
use_log,
}
}
}
+2
View File
@@ -1,5 +1,6 @@
pub mod battery_info;
pub mod cpu_graph;
pub mod disk_io_graph;
pub mod disk_table;
pub mod mem_graph;
pub mod network_graph;
@@ -9,6 +10,7 @@ pub mod temperature_table;
pub use battery_info::*;
pub use cpu_graph::*;
pub use disk_io_graph::*;
pub use disk_table::*;
pub use mem_graph::*;
pub use network_graph::*;
+5
View File
@@ -271,6 +271,11 @@ fn test_newer_temperature() {
run_and_kill_cfg("./tests/valid_configs/widget/temperature.toml");
}
#[test]
fn test_disk_io_graph() {
run_and_kill_cfg("./tests/valid_configs/widget/disk_io_graph.toml");
}
/// This uses deprecated temperature settings - once they are removed, this test file should be moved to invalid configs.
#[test]
fn test_deprecated_temperature() {
@@ -0,0 +1,13 @@
[disk_io_graph]
show_read = true
show_write = false
legend = "mount"
use_log = true
legend_position = "bottom-left"
[disk_io_graph.name_filter]
is_list_ignored = true
list = ["sda"]
regex = false
case_sensitive = false
whole_word = false