From d2cf088c77bd8099b067cb0a28a577fb588b456b Mon Sep 17 00:00:00 2001 From: Azat Khuzhin Date: Sun, 6 Sep 2026 22:25:39 +0200 Subject: [PATCH 1/2] Per-host summary rows in --cluster mode (system.asynchronous_metrics GROUP BY host), toggled with 1 Co-Authored-By: Claude Fable 5.1 --- Documentation/Actions.md | 1 + Documentation/SystemViews.md | 4 + src/interpreter/clickhouse.rs | 181 ++++++++++++++---- src/interpreter/options.rs | 11 ++ src/interpreter/worker.rs | 31 +++- src/tui/navigation.rs | 15 ++ src/tui/style.rs | 9 + src/tui/views/log_view.rs | 31 +--- src/tui/views/settings_view.rs | 13 ++ src/tui/views/summary_view.rs | 324 ++++++++++++++++++++++++++++++++- src/utils.rs | 20 ++ tests/configs/chdig_basic.yaml | 1 + tests/integration.rs | 28 +++ 13 files changed, 597 insertions(+), 72 deletions(-) diff --git a/Documentation/Actions.md b/Documentation/Actions.md index d29618bf..6a30a1bd 100644 --- a/Documentation/Actions.md +++ b/Documentation/Actions.md @@ -88,3 +88,4 @@ Here is a list of available shortcuts | | **t** | Seek 10 mins forward | | | **Alt+t** | Set time interval | | | **~** | chdig debug console | +| | **1** | Toggle per-host rows in the summary (cluster) | diff --git a/Documentation/SystemViews.md b/Documentation/SystemViews.md index fb65d9db..8bf081f2 100644 --- a/Documentation/SystemViews.md +++ b/Documentation/SystemViews.md @@ -22,6 +22,10 @@ Part-level details: **Table Parts** (`system.parts`) and **Part Log** ## Replication +In `--cluster` mode the summary header also lists every host (uptime, cpu, +memory, threads, network and disk rates), busiest first and capped to a third +of the screen; **1** toggles it (`view.summary_per_host` in the config). + **Replication queue** (`system.replication_queue`), **Replicated fetches** (`system.replicated_fetches`) and **Replicas** (`system.replicas`) - note the replication lag and queue size in the summary header: diff --git a/src/interpreter/clickhouse.rs b/src/interpreter/clickhouse.rs index 1bfeffea..d3972cad 100644 --- a/src/interpreter/clickhouse.rs +++ b/src/interpreter/clickhouse.rs @@ -271,6 +271,35 @@ pub struct ClickHouseServerRows { pub selected: u64, pub inserted: u64, } +/// Per-host summary (system.asynchronous_metrics only, so one cheap GROUP BY +/// hostName() query): network/blkdev are per-interval deltas like in +/// ClickHouseServerSummary. +#[derive(Default)] +pub struct ClickHouseHostSummary { + pub host: String, + pub uptime: u64, + pub cpu: ClickHouseServerCPU, + pub memory_total: u64, + pub memory_resident: u64, + pub threads_total: u64, + pub threads_runnable: u64, + pub network: ClickHouseServerNetwork, + pub blkdev: ClickHouseServerBlockDevices, + pub update_interval: f64, +} + +/// system.asynchronous_metrics expressions of the per-CPU/interface/block-device +/// metrics, which differ by server version (see asynchronous_metrics_exprs()). +struct AsyncMetricsExprs { + cpu_count: &'static str, + cpu_user: &'static str, + cpu_system: &'static str, + net_send: &'static str, + net_receive: &'static str, + block_read: &'static str, + block_write: &'static str, +} + #[derive(Default)] pub struct ClickHouseServerSummary { pub queries: u64, @@ -1051,6 +1080,113 @@ impl ClickHouse { .await; } + fn asynchronous_metrics_exprs(&self) -> AsyncMetricsExprs { + // Per-CPU/interface/block-device metrics are single rows with value=NaN and the breakdown + // in the key_values Map since 26.8 (suffixed per-key rows are gone) + if self + .quirks + .has(ClickHouseAvailableQuirks::AsynchronousMetricsKeyValues) + { + AsyncMetricsExprs { + cpu_count: "sumIf(length(key_values), metric = 'CPUFrequencyMHz')", + cpu_user: "sumIf(arraySum(mapValues(key_values)), metric = 'OSUserTimeCPU')", + cpu_system: "sumIf(arraySum(mapValues(key_values)), metric = 'OSSystemTimeCPU')", + net_send: "sumIf(arraySum(mapValues(mapFilter((k, v) -> k NOT LIKE '%vlan%', key_values))), metric = 'NetworkSendBytes')", + net_receive: "sumIf(arraySum(mapValues(mapFilter((k, v) -> k NOT LIKE '%vlan%', key_values))), metric = 'NetworkReceiveBytes')", + // exclude MD/LVM + block_read: "sumIf(arraySum(mapValues(mapFilter((k, v) -> k LIKE 'sd%' OR k LIKE 'nvme%' OR k LIKE 'vd%', key_values))), metric = 'BlockReadBytes')", + block_write: "sumIf(arraySum(mapValues(mapFilter((k, v) -> k LIKE 'sd%' OR k LIKE 'nvme%' OR k LIKE 'vd%', key_values))), metric = 'BlockWriteBytes')", + } + } else { + AsyncMetricsExprs { + cpu_count: "countIf(metric LIKE 'CPUFrequencyMHz%')", + cpu_user: "sumIf(value, metric LIKE 'OSUserTimeCPU%')", + cpu_system: "sumIf(value, metric LIKE 'OSSystemTimeCPU%')", + net_send: "sumIf(value, metric LIKE 'NetworkSendBytes%' AND metric NOT LIKE '%vlan%')", + net_receive: "sumIf(value, metric LIKE 'NetworkReceiveBytes%' AND metric NOT LIKE '%vlan%')", + // exclude MD/LVM + block_read: "sumIf(value, metric LIKE 'BlockReadBytes%' AND (metric LIKE '%_sd%' OR metric LIKE '%_nvme%' OR metric LIKE '%_vd%'))", + block_write: "sumIf(value, metric LIKE 'BlockWriteBytes%' AND (metric LIKE '%_sd%' OR metric LIKE '%_nvme%' OR metric LIKE '%_vd%'))", + } + } + } + + /// One row per host of the cluster (empty without --cluster), see + /// ClickHouseHostSummary. + pub async fn get_hosts_summary(&self) -> Result> { + if self.opts().cluster.is_none() { + return Ok(Vec::new()); + } + let exprs = self.asynchronous_metrics_exprs(); + let block = self + .execute(&format!( + r#" + SELECT + hostName() AS host, + CAST(min(uptime()) AS UInt64) AS uptime, + CAST(coalesce(sumIfOrNull(value, metric == 'CGroupMemoryTotal' AND value > 0), sumIf(value, metric == 'OSMemoryTotal')) AS UInt64) AS memory_total, + CAST(sumIf(value, metric == 'MemoryResident') AS UInt64) AS memory_resident, + CAST(max2({cpu_count}, sumIf(value, metric = 'CGroupMaxCPU')) AS UInt64) AS cpu_count, + CAST(max2({cpu_user}, sumIf(value, metric = 'OSUserTime')) AS UInt64) AS cpu_user, + CAST(max2({cpu_system}, sumIf(value, metric = 'OSSystemTime')) AS UInt64) AS cpu_system, + CAST(sumIf(value, metric = 'OSThreadsTotal') AS UInt64) AS threads_total, + CAST(sumIf(value, metric = 'OSThreadsRunnable') AS UInt64) AS threads_runnable, + CAST({net_send} AS UInt64) AS net_send_bytes, + CAST({net_receive} AS UInt64) AS net_receive_bytes, + CAST({block_read} AS UInt64) AS block_read_bytes, + CAST({block_write} AS UInt64) AS block_write_bytes, + anyLastIf(value, metric == 'AsynchronousMetricsUpdateInterval') AS update_interval + FROM {asynchronous_metrics} + GROUP BY host + ORDER BY host + "#, + cpu_count = exprs.cpu_count, + cpu_user = exprs.cpu_user, + cpu_system = exprs.cpu_system, + net_send = exprs.net_send, + net_receive = exprs.net_receive, + block_read = exprs.block_read, + block_write = exprs.block_write, + asynchronous_metrics = self.get_live_table_name("asynchronous_metrics"), + )) + .await?; + + // Missing/mistyped columns degrade to 0 with a warning (worker thread, a panic would + // silently stop all UI updates) + let get = |row: usize, column: &str| -> u64 { + block.get::(row, column).unwrap_or_else(|err| { + log::warn!("Cannot get hosts summary column {}: {}", column, err); + 0 + }) + }; + let mut hosts = Vec::with_capacity(block.row_count()); + for i in 0..block.row_count() { + hosts.push(ClickHouseHostSummary { + host: block.get::(i, "host")?, + uptime: get(i, "uptime"), + cpu: ClickHouseServerCPU { + count: get(i, "cpu_count"), + user: get(i, "cpu_user"), + system: get(i, "cpu_system"), + }, + memory_total: get(i, "memory_total"), + memory_resident: get(i, "memory_resident"), + threads_total: get(i, "threads_total"), + threads_runnable: get(i, "threads_runnable"), + network: ClickHouseServerNetwork { + send_bytes: get(i, "net_send_bytes"), + receive_bytes: get(i, "net_receive_bytes"), + }, + blkdev: ClickHouseServerBlockDevices { + read_bytes: get(i, "block_read_bytes"), + write_bytes: get(i, "block_write_bytes"), + }, + update_interval: block.get::(i, "update_interval").unwrap_or(0.), + }); + } + Ok(hosts) + } + pub async fn get_summary( &self, selected_host: Option<&String>, @@ -1062,42 +1198,15 @@ impl ClickHouse { format!(" WHERE {}", &host_filter[4..]) // Remove leading "AND " }; - // Per-CPU/interface/block-device metrics are single rows with value=NaN and the breakdown - // in the key_values Map since 26.8 (suffixed per-key rows are gone) - let ( - cpu_count_expr, - cpu_user_expr, - cpu_system_expr, - net_send_expr, - net_receive_expr, - block_read_expr, - block_write_expr, - ) = if self - .quirks - .has(ClickHouseAvailableQuirks::AsynchronousMetricsKeyValues) - { - ( - "sumIf(length(key_values), metric = 'CPUFrequencyMHz')", - "sumIf(arraySum(mapValues(key_values)), metric = 'OSUserTimeCPU')", - "sumIf(arraySum(mapValues(key_values)), metric = 'OSSystemTimeCPU')", - "sumIf(arraySum(mapValues(mapFilter((k, v) -> k NOT LIKE '%vlan%', key_values))), metric = 'NetworkSendBytes')", - "sumIf(arraySum(mapValues(mapFilter((k, v) -> k NOT LIKE '%vlan%', key_values))), metric = 'NetworkReceiveBytes')", - // exclude MD/LVM - "sumIf(arraySum(mapValues(mapFilter((k, v) -> k LIKE 'sd%' OR k LIKE 'nvme%' OR k LIKE 'vd%', key_values))), metric = 'BlockReadBytes')", - "sumIf(arraySum(mapValues(mapFilter((k, v) -> k LIKE 'sd%' OR k LIKE 'nvme%' OR k LIKE 'vd%', key_values))), metric = 'BlockWriteBytes')", - ) - } else { - ( - "countIf(metric LIKE 'CPUFrequencyMHz%')", - "sumIf(value, metric LIKE 'OSUserTimeCPU%')", - "sumIf(value, metric LIKE 'OSSystemTimeCPU%')", - "sumIf(value, metric LIKE 'NetworkSendBytes%' AND metric NOT LIKE '%vlan%')", - "sumIf(value, metric LIKE 'NetworkReceiveBytes%' AND metric NOT LIKE '%vlan%')", - // exclude MD/LVM - "sumIf(value, metric LIKE 'BlockReadBytes%' AND (metric LIKE '%_sd%' OR metric LIKE '%_nvme%' OR metric LIKE '%_vd%'))", - "sumIf(value, metric LIKE 'BlockWriteBytes%' AND (metric LIKE '%_sd%' OR metric LIKE '%_nvme%' OR metric LIKE '%_vd%'))", - ) - }; + let AsyncMetricsExprs { + cpu_count: cpu_count_expr, + cpu_user: cpu_user_expr, + cpu_system: cpu_system_expr, + net_send: net_send_expr, + net_receive: net_receive_expr, + block_read: block_read_expr, + block_write: block_write_expr, + } = self.asynchronous_metrics_exprs(); let memory_index_granularity_trait = if self.quirks.has(ClickHouseAvailableQuirks::AsynchronousMetricsTotalIndexGranularityBytesInMemoryAllocated) { format!("(SELECT sum(index_granularity_bytes_in_memory_allocated) FROM {}{}) AS memory_index_granularity_", self.get_live_table_name("parts"), host_where) diff --git a/src/interpreter/options.rs b/src/interpreter/options.rs index 15b56d00..e532216c 100644 --- a/src/interpreter/options.rs +++ b/src/interpreter/options.rs @@ -654,6 +654,11 @@ pub struct ViewOptions { #[clap(skip)] pub flamelens_pane: FlamelensPane, + /// Per-host rows in the summary header in --cluster mode (toggled with `1`). + /// Not exposed on CLI; populated from YAML config and the settings dialog. + #[clap(skip = true)] + pub summary_per_host: bool, + /// Disable stripping common hostname prefix and suffix in queries and logs views #[arg(long, action = ArgAction::SetTrue)] pub no_strip_hostname_suffix: bool, @@ -1273,6 +1278,7 @@ struct ChDigViewConfig { wrap: Option, align_log_columns: Option, flamelens_pane: Option, + summary_per_host: Option, no_strip_hostname_suffix: Option, no_color: Option, queries_limit: Option, @@ -1567,6 +1573,9 @@ fn apply_chdig_config( { options.view.align_log_columns = align; } + if let Some(summary_per_host) = view.summary_per_host { + options.view.summary_per_host = summary_per_host; + } if let Some(flamelens_pane) = view.flamelens_pane { options.view.flamelens_pane = flamelens_pane; } @@ -2367,6 +2376,7 @@ mod tests { assert_eq!(config.view.no_strip_hostname_suffix, Some(true)); assert_eq!(config.view.queries_limit, Some(500)); assert_eq!(config.view.flamelens_pane, Some(FlamelensPane::Below)); + assert_eq!(config.view.summary_per_host, Some(false)); assert_eq!(config.service.log.as_deref(), Some("/tmp/chdig.log")); assert_eq!( @@ -2751,6 +2761,7 @@ views: assert_eq!(options.view.no_strip_hostname_suffix, true); assert_eq!(options.view.queries_limit, 500); assert_eq!(options.view.flamelens_pane, FlamelensPane::Below); + assert!(!options.view.summary_per_host); assert_eq!(options.service.log.as_deref(), Some("/tmp/chdig.log")); assert_eq!( options.service.pastila_clickhouse_host, diff --git a/src/interpreter/worker.rs b/src/interpreter/worker.rs index 6af12cf5..ddb9059a 100644 --- a/src/interpreter/worker.rs +++ b/src/interpreter/worker.rs @@ -1651,8 +1651,24 @@ async fn process_event(context: ContextArc, event: Event, need_clear: &mut bool) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Event::Summary => { - let block = clickhouse.get_summary(selected_host.as_ref()).await; - match block { + let per_host = { + let ctx = context.lock().unwrap(); + ctx.options.clickhouse.cluster.is_some() + && selected_host.is_none() + && ctx.options.view.summary_per_host + }; + let hosts_summary = async { + if per_host { + Some(clickhouse.get_hosts_summary().await) + } else { + None + } + }; + let (summary, hosts) = tokio::join!( + clickhouse.get_summary(selected_host.as_ref()), + hosts_summary + ); + match summary { Err(err) => { let message = err.to_string(); cb_sink @@ -1662,10 +1678,19 @@ async fn process_event(context: ContextArc, event: Event, need_clear: &mut bool) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Ok(summary) => { + // The per-host part must not blank the header on every tick + let hosts = match hosts { + Some(Ok(hosts)) => Some(hosts), + Some(Err(err)) => { + log::warn!("Cannot get per-host summary: {}", err); + None + } + None => None, + }; cb_sink .send(Box::new(move |app: &mut App| { app.call_on_name("summary", move |view: &mut SummaryView| { - view.update(summary); + view.update(summary, hosts); }); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; diff --git a/src/tui/navigation.rs b/src/tui/navigation.rs index fcdab541..8fa2b2a7 100644 --- a/src/tui/navigation.rs +++ b/src/tui/navigation.rs @@ -73,6 +73,20 @@ fn focused_action_owners(app: &mut App) -> HashSet> { .collect() } +/// `1` in cluster mode, like top(1): the per-host rows of the summary header +fn toggle_summary_per_host(app: &mut App) { + let context = app.user_data::().unwrap().clone(); + let enabled = { + let mut ctx = context.lock().unwrap(); + ctx.options.view.summary_per_host = !ctx.options.view.summary_per_host; + ctx.options.view.summary_per_host + }; + app.call_on_name( + "summary", + |view: &mut crate::tui::views::summary_view::SummaryView| view.set_per_host(enabled), + ); +} + fn toggle_debug_metrics(app: &mut App) { let ctx = app.user_data::().unwrap().clone(); let metrics = ctx.lock().unwrap().debug_metrics.clone(); @@ -500,6 +514,7 @@ impl Navigation for App { if context.options.clickhouse.cluster.is_some() { context.add_global_action(self, "Filter by host", Event::CtrlChar('h'), |app| app.show_connection_dialog()); + context.add_global_action(self, "Toggle per-host summary", '1', toggle_summary_per_host); } self.add_global_callback('F', |app| { diff --git a/src/tui/style.rs b/src/tui/style.rs index 9602c009..921c0c7b 100644 --- a/src/tui/style.rs +++ b/src/tui/style.rs @@ -169,3 +169,12 @@ pub fn print_str(buf: &mut Buffer, x: u16, y: u16, area: Rect, s: &str, style: S pub fn str_width(s: &str) -> usize { UnicodeWidthStr::width(s) } + +/// Pads the field that started at display offset `start` of `line` to `width` +/// columns (left aligned). +pub fn pad_column(line: &mut StyledString, start: usize, width: usize) { + let written = line.width() - start; + if written < width { + line.append_plain(" ".repeat(width - written)); + } +} diff --git a/src/tui/views/log_view.rs b/src/tui/views/log_view.rs index 7bea5a60..5a3deac9 100644 --- a/src/tui/views/log_view.rs +++ b/src/tui/views/log_view.rs @@ -18,10 +18,10 @@ use crate::tui::edit::EditView; use crate::tui::event::{Event, EventResult, Key, MouseEvent}; use crate::tui::prompt::show_bottom_prompt; use crate::tui::resize::Resizable; -use crate::tui::style::{Color, Modifier, Style, StyledString, print_str, str_width}; +use crate::tui::style::{Color, Modifier, Style, StyledString, pad_column, print_str, str_width}; use crate::tui::views::log_store::{LogEntry, LogStore}; use crate::tui::views::text_log_view::TextLogView; -use crate::utils::find_common_hostname_prefix_and_suffix; +use crate::utils::{find_common_hostname_prefix_and_suffix, strip_hostname as strip_host}; // Hash-based color function matching ClickHouse's setColor from terminalColors.cpp // Uses YCbCr color space with constant brightness (y=128) for better readability @@ -135,25 +135,6 @@ fn ansi_sgr_params(style: &Style) -> String { params.join(";") } -// Strip the common prefix/suffix from a hostname for display -fn strip_host<'a>(hostname: &'a str, strip: Option<&(String, String)>) -> &'a str { - let Some((prefix, suffix)) = strip else { - return hostname; - }; - let mut hostname = hostname; - if !prefix.is_empty() - && let Some(stripped) = hostname.strip_prefix(prefix.as_str()) - { - hostname = stripped; - } - if !suffix.is_empty() - && let Some(stripped) = hostname.strip_suffix(suffix.as_str()) - { - hostname = stripped; - } - hostname -} - struct IdentifierMaps { query_id_map: HashMap, logger_name_map: HashMap, @@ -172,14 +153,6 @@ struct ColumnWidths { logger: usize, } -// Pad the field that started at display offset `start` to `width` columns -fn pad_column(line: &mut StyledString, start: usize, width: usize) { - let written = line.width() - start; - if written < width { - line.append_plain(" ".repeat(width - written)); - } -} - // Renders the line and also returns the display offsets where each seekable // column starts, always 7 entries: date, time, thread_id, query_id, level, // logger_name, message (used for horizontal seeking by columns, which relies diff --git a/src/tui/views/settings_view.rs b/src/tui/views/settings_view.rs index d3cdde0d..b4f255c8 100644 --- a/src/tui/views/settings_view.rs +++ b/src/tui/views/settings_view.rs @@ -15,6 +15,7 @@ use crate::tui::scroll::ScrollView; use crate::tui::style::{Modifier, Style, StyledString}; use crate::tui::text::TextView; use crate::tui::views::queries_view::{ordered_query_columns, query_column_id}; +use crate::tui::views::summary_view::SummaryView; use crate::tui::{Mux, Navigation, show_bottom_prompt, submit_on_enter}; fn apply_settings(app: &mut App, context: &ContextArc) { @@ -45,6 +46,9 @@ fn apply_settings(app: &mut App, context: &ContextArc) { let no_subqueries = app .call_on_name("set_no_subqueries", |v: &mut Checkbox| v.is_checked()) .unwrap(); + let summary_per_host = app + .call_on_name("set_summary_per_host", |v: &mut Checkbox| v.is_checked()) + .unwrap(); let wrap = app .call_on_name("set_wrap", |v: &mut Checkbox| v.is_checked()) .unwrap(); @@ -237,6 +241,7 @@ fn apply_settings(app: &mut App, context: &ContextArc) { ctx.options.view.flamelens_pane = flamelens_pane; ctx.options.view.no_strip_hostname_suffix = no_strip; ctx.options.view.no_color = no_color; + ctx.options.view.summary_per_host = summary_per_host; *ctx.settings_queries_filter().lock().unwrap() = queries_filter; ctx.options.view.queries_limit = queries_limit; *ctx.queries_limit.lock().unwrap() = queries_limit; @@ -271,6 +276,9 @@ fn apply_settings(app: &mut App, context: &ContextArc) { ctx.trigger_view_refresh(); } + app.call_on_name("summary", |view: &mut SummaryView| { + view.set_per_host(summary_per_host) + }); // Re-create the focused pane's view so option changes that only take // effect at view construction time (e.g. query_columns) are picked up @@ -538,6 +546,11 @@ pub fn show_settings_dialog(app: &mut App) { opts.view.no_strip_hostname_suffix, ); layout.checkbox("no_color", "set_no_color", opts.view.no_color); + layout.checkbox( + "summary_per_host (cluster)", + "set_summary_per_host", + opts.view.summary_per_host, + ); layout.edit("queries_filter", "set_queries_filter", &queries_filter, 30); layout.edit( "queries_limit", diff --git a/src/tui/views/summary_view.rs b/src/tui/views/summary_view.rs index 025b4fca..f712d051 100644 --- a/src/tui/views/summary_view.rs +++ b/src/tui/views/summary_view.rs @@ -2,18 +2,21 @@ use chrono::{DateTime, Local}; use humantime::format_duration; use ratatui::layout::{Rect, Size}; use size::{Base, SizeFormatter, Style}; +use std::collections::HashMap; use std::time::Duration; use crate::common::sparkline::SparklineBuffer; use crate::interpreter::{ - BackgroundRunner, ContextArc, WorkerEvent, clickhouse::ClickHouseServerSummary, + BackgroundRunner, ContextArc, WorkerEvent, + clickhouse::{ClickHouseHostSummary, ClickHouseServerSummary}, }; use crate::tui::component::{Canvas, Component, DummyView, Nameable, call_on_name}; use crate::tui::event::{Event, EventResult}; use crate::tui::linear::LinearLayout; use crate::tui::resize::Resizable; -use crate::tui::style::{Color, StyledString}; +use crate::tui::style::{Color, StyledString, pad_column}; use crate::tui::text::TextView; +use crate::utils::{find_common_hostname_prefix_and_suffix, strip_hostname}; const SPARKLINE_CAPACITY: usize = 60; const SPARKLINE_WIDTH: usize = 8; @@ -36,17 +39,119 @@ impl SparklineSet { } } +/// Index of the per-host table in `layout` (after the 4 aggregated rows); it +/// is added only while shown, an empty TextView would still take a row. +const PER_HOST_CHILD: usize = 4; +const PER_HOST_MIN_ROWS: usize = 4; +/// (header, right aligned) +const PER_HOST_COLUMNS: &[(&str, bool)] = &[ + ("host", false), + ("up", false), + ("cpu", true), + ("mem", true), + ("thr", true), + ("net recv/sent", true), + ("disk r/w", true), +]; + +struct HostSparklines { + cpu: SparklineBuffer, + memory: SparklineBuffer, +} + +/// One host of the per-host table (cells follow PER_HOST_COLUMNS) +struct HostRow { + host: String, + /// Sort key: used cpus / cpu count + cpu_ratio: f64, + cells: Vec, +} + pub struct SummaryView { + context: ContextArc, + prev_summary: Option, prev_update_time: Option>, layout: LinearLayout, sparklines: SparklineSet, - #[allow(unused)] + // Per-host table (cluster mode, toggled with '1') + per_host_enabled: bool, + host_sparklines: HashMap, + host_rows: Vec, + /// Rows shown for the last seen height (see required_size) + last_row_cap: usize, + bg_runner: BackgroundRunner, } +/// How many host rows fit: a third of the height, at least PER_HOST_MIN_ROWS. +fn row_cap(height: u16) -> usize { + (height as usize / 3).max(PER_HOST_MIN_ROWS) +} + +/// Sorts by cpu utilization (busiest first), then by host for a stable order. +fn sort_host_rows(rows: &mut [HostRow]) { + rows.sort_by(|a, b| { + b.cpu_ratio + .total_cmp(&a.cpu_ratio) + .then_with(|| a.host.cmp(&b.host)) + }); +} + +/// The table text: header, at most `cap` rows, "... and K more hosts". +fn render_host_rows(rows: &[HostRow], cap: usize) -> StyledString { + let widths: Vec = PER_HOST_COLUMNS + .iter() + .enumerate() + .map(|(i, (header, _))| { + rows.iter() + .map(|r| r.cells[i].width()) + .max() + .unwrap_or(0) + .max(header.len()) + }) + .collect(); + + // One StyledString per line: pad_column() measures the widest line + let mut header = StyledString::new(); + for (i, (title, _)) in PER_HOST_COLUMNS.iter().enumerate() { + if i > 0 { + header.append_plain(" "); + } + let start = header.width(); + header.append_styled(*title, Color::Cyan); + pad_column(&mut header, start, widths[i]); + } + let mut text = header; + for row in rows.iter().take(cap) { + let mut line = StyledString::new(); + for (i, (_, right)) in PER_HOST_COLUMNS.iter().enumerate() { + if i > 0 { + line.append_plain(" "); + } + let start = line.width(); + let cell = &row.cells[i]; + if *right { + line.append_plain(" ".repeat(widths[i].saturating_sub(cell.width()))); + } + line.append(cell.clone()); + pad_column(&mut line, start, widths[i]); + } + text.append_plain("\n"); + text.append(line); + } + if rows.len() > cap { + text.append_plain("\n"); + text.append_styled( + format!("... and {} more hosts", rows.len() - cap), + Color::Gray, + ); + } + text +} + fn get_color_for_ratio(used: u64, total: u64) -> Color { let q = used as f64 / total as f64; if q > 0.90 { @@ -171,15 +276,155 @@ impl SummaryView { let mut bg_runner = BackgroundRunner::new(delay, bg_runner_cv, bg_runner_generation); bg_runner.start(update_callback); + let per_host_enabled = { + let ctx = context.lock().unwrap(); + ctx.options.clickhouse.cluster.is_some() && ctx.options.view.summary_per_host + }; + Self { + context, prev_summary: None, prev_update_time: None, layout, sparklines: SparklineSet::new(), + per_host_enabled, + host_sparklines: HashMap::new(), + host_rows: Vec::new(), + last_row_cap: PER_HOST_MIN_ROWS, bg_runner, } } + /// Shows/hides the per-host table (the worker fetches it only while enabled). + pub fn set_per_host(&mut self, enabled: bool) { + if self.per_host_enabled == enabled { + return; + } + self.per_host_enabled = enabled; + if enabled { + self.bg_runner.schedule(); + } else { + self.host_sparklines.clear(); + self.host_rows.clear(); + self.show_host_table(false); + } + } + + fn show_host_table(&mut self, shown: bool) { + let present = self.layout.len() > PER_HOST_CHILD; + if shown && !present { + self.layout.add_child( + TextView::new(render_host_rows(&self.host_rows, self.last_row_cap)) + .no_wrap() + .with_name("per_host"), + ); + } else if shown { + self.set_view_content( + "per_host", + render_host_rows(&self.host_rows, self.last_row_cap), + ); + } else if present { + self.layout.remove_child(PER_HOST_CHILD); + } + } + + fn update_hosts(&mut self, hosts: Vec) { + let fmt = SizeFormatter::new() + .with_base(Base::Base2) + .with_style(Style::Abbreviated); + + self.host_sparklines + .retain(|host, _| hosts.iter().any(|h| h.host == *host)); + + let strip = { + let no_strip = self + .context + .lock() + .unwrap() + .options + .view + .no_strip_hostname_suffix; + (!no_strip && hosts.len() > 1).then(|| { + find_common_hostname_prefix_and_suffix(hosts.iter().map(|h| h.host.as_str())) + }) + }; + + let mut rows = Vec::with_capacity(hosts.len()); + for host in &hosts { + let sparklines = self + .host_sparklines + .entry(host.host.clone()) + .or_insert_with(|| HostSparklines { + cpu: SparklineBuffer::new(SPARKLINE_CAPACITY), + memory: SparklineBuffer::new(SPARKLINE_CAPACITY), + }); + // update_interval is available only since 23.3 + let update_interval = if host.update_interval > 0. { + host.update_interval + } else { + 1. + }; + let used_cpus = host.cpu.user + host.cpu.system; + sparklines.cpu.push(used_cpus as f64); + sparklines.memory.push(host.memory_resident as f64); + + let spark = |s: String, content: &mut StyledString| { + if !s.is_empty() { + content.append_plain(" "); + content.append_styled(s, Color::Gray); + } + }; + + let mut cpu = StyledString::new(); + cpu.append_styled( + used_cpus.to_string(), + get_color_for_ratio(used_cpus, host.cpu.count), + ); + cpu.append_plain(format!("/{}", host.cpu.count)); + spark(sparklines.cpu.render(SPARKLINE_WIDTH), &mut cpu); + + let mut mem = StyledString::new(); + mem.append_styled( + fmt.format(host.memory_resident as i64), + get_color_for_ratio(host.memory_resident, host.memory_total), + ); + mem.append_plain(format!("/{}", fmt.format(host.memory_total as i64))); + spark(sparklines.memory.render(SPARKLINE_WIDTH), &mut mem); + + let rate = |bytes: u64| fmt.format((bytes as f64 / update_interval) as i64); + rows.push(HostRow { + host: host.host.clone(), + cpu_ratio: used_cpus as f64 / host.cpu.count.max(1) as f64, + cells: vec![ + StyledString::plain(strip_hostname(&host.host, strip.as_ref())), + StyledString::plain( + format_duration(Duration::from_secs(host.uptime - host.uptime % 60)) + .to_string(), + ), + cpu, + mem, + StyledString::plain(format!( + "{}/{}", + host.threads_runnable, host.threads_total + )), + StyledString::plain(format!( + "{}/{}", + rate(host.network.receive_bytes), + rate(host.network.send_bytes) + )), + StyledString::plain(format!( + "{}/{}", + rate(host.blkdev.read_bytes), + rate(host.blkdev.write_bytes) + )), + ], + }); + } + sort_host_rows(&mut rows); + self.host_rows = rows; + self.show_host_table(self.per_host_enabled && !self.host_rows.is_empty()); + } + pub fn set_view_content(&mut self, view_name: &str, content: S) where S: Into, @@ -190,7 +435,19 @@ impl SummaryView { }); } - pub fn update(&mut self, summary: ClickHouseServerSummary) { + /// `hosts` is None when the per-host table is off (or its query failed: + /// the previous table stays). + pub fn update( + &mut self, + summary: ClickHouseServerSummary, + hosts: Option>, + ) { + if let Some(hosts) = hosts { + self.update_hosts(hosts); + } else if !self.per_host_enabled { + self.show_host_table(false); + } + let fmt = SizeFormatter::new() .with_base(Base::Base2) .with_style(Style::Abbreviated); @@ -557,6 +814,15 @@ impl Component for SummaryView { } fn required_size(&mut self, max: Size) -> Size { + // The layout hands the summary the whole remaining screen height, so + // the host rows cap follows terminal resizes + let cap = row_cap(max.height); + if cap != self.last_row_cap { + self.last_row_cap = cap; + if self.layout.len() > PER_HOST_CHILD { + self.set_view_content("per_host", render_host_rows(&self.host_rows, cap)); + } + } self.layout.required_size(max) } @@ -568,3 +834,53 @@ impl Component for SummaryView { f(&mut self.layout); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn row(host: &str, cpu_ratio: f64) -> HostRow { + HostRow { + host: host.to_string(), + cpu_ratio, + cells: PER_HOST_COLUMNS + .iter() + .enumerate() + .map(|(i, _)| StyledString::plain(format!("{}{}", host, "x".repeat(i)))) + .collect(), + } + } + + #[test] + fn test_row_cap() { + assert_eq!(row_cap(10), 4); + assert_eq!(row_cap(24), 8); + assert_eq!(row_cap(60), 20); + } + + #[test] + fn test_sort_and_cap() { + let mut rows = vec![ + row("c", 0.3), + row("a", 0.1), + row("e", 0.5), + row("d", 0.5), + row("f", 0.0), + row("b", 0.2), + ]; + sort_host_rows(&mut rows); + let order: Vec<&str> = rows.iter().map(|r| r.host.as_str()).collect(); + assert_eq!(order, ["d", "e", "c", "b", "a", "f"]); + + let text = render_host_rows(&rows, 4).source(); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 1 + 4 + 1); + assert!(lines[1].starts_with("d ")); + assert_eq!(lines[5], "... and 2 more hosts"); + // Columns are aligned: every line has the same width + let width = lines[0].chars().count(); + for line in &lines[..5] { + assert_eq!(line.chars().count(), width, "{line:?}"); + } + } +} diff --git a/src/utils.rs b/src/utils.rs index a23f0323..8ca537d8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -184,6 +184,26 @@ pub async fn share_graph( Ok(()) } +/// The hostname without the common `(prefix, suffix)` of the cluster (see +/// find_common_hostname_prefix_and_suffix()) for display. +pub fn strip_hostname<'a>(hostname: &'a str, strip: Option<&(String, String)>) -> &'a str { + let Some((prefix, suffix)) = strip else { + return hostname; + }; + let mut hostname = hostname; + if !prefix.is_empty() + && let Some(stripped) = hostname.strip_prefix(prefix.as_str()) + { + hostname = stripped; + } + if !suffix.is_empty() + && let Some(stripped) = hostname.strip_suffix(suffix.as_str()) + { + hostname = stripped; + } + hostname +} + pub fn find_common_hostname_prefix_and_suffix<'a, I>(hostnames: I) -> (String, String) where I: Iterator, diff --git a/tests/configs/chdig_basic.yaml b/tests/configs/chdig_basic.yaml index bb55b404..4144abbc 100644 --- a/tests/configs/chdig_basic.yaml +++ b/tests/configs/chdig_basic.yaml @@ -23,6 +23,7 @@ view: no_strip_hostname_suffix: true queries_limit: 500 flamelens_pane: below + summary_per_host: false service: log: "/tmp/chdig.log" diff --git a/tests/integration.rs b/tests/integration.rs index b2a0a300..6de5551f 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1438,6 +1438,33 @@ async fn test_cluster() { assert_eq!(hosts.len(), 1); } +async fn test_hosts_summary() { + let Some(server) = common::server() else { + return; + }; + + // Not a cluster: nothing per host + let chdig = server.chdig().await; + assert!(chdig.get_hosts_summary().await.unwrap().is_empty()); + + let chdig = ClickHouse::new(ClickHouseOptions { + cluster: Some(common::CLUSTER.to_string()), + ..server.chdig_options() + }) + .await + .unwrap(); + // Both "replicas" are the same host, so one (doubled) row + let hosts = chdig.get_hosts_summary().await.unwrap(); + assert_eq!(hosts.len(), 1); + let host = &hosts[0]; + assert!(!host.host.is_empty()); + assert!(host.uptime > 0); + assert!(host.memory_total > 0); + assert!(host.memory_resident > 0); + assert!(host.cpu.count > 0); + assert!(host.threads_total > 0); +} + async fn test_history_with_cluster() { let Some(server) = common::server() else { return; @@ -1595,6 +1622,7 @@ common::integration_tests!( test_warnings_and_cluster_hosts, test_history, test_cluster, + test_hosts_summary, test_history_with_cluster, test_custom_database, test_database_from_url, From bc5d2ff8cbc2c9c1f2132d5f31d729ca2cd3a24a Mon Sep 17 00:00:00 2001 From: Azat Khuzhin Date: Sun, 6 Sep 2026 22:43:29 +0200 Subject: [PATCH 2/2] Resize the per-host summary table with [ ] or by dragging its separator Co-Authored-By: Claude Fable 5.1 --- Documentation/Actions.md | 1 + Documentation/SystemViews.md | 5 +- src/tui/linear.rs | 29 ++++++- src/tui/navigation.rs | 9 +++ src/tui/views/summary_view.rs | 143 +++++++++++++++++++++++++++++----- 5 files changed, 163 insertions(+), 24 deletions(-) diff --git a/Documentation/Actions.md b/Documentation/Actions.md index 6a30a1bd..7c70553f 100644 --- a/Documentation/Actions.md +++ b/Documentation/Actions.md @@ -89,3 +89,4 @@ Here is a list of available shortcuts | | **Alt+t** | Set time interval | | | **~** | chdig debug console | | | **1** | Toggle per-host rows in the summary (cluster) | +| | **[**/**]** | Fewer/more hosts in the summary (or drag its separator) | diff --git a/Documentation/SystemViews.md b/Documentation/SystemViews.md index 8bf081f2..d6480c31 100644 --- a/Documentation/SystemViews.md +++ b/Documentation/SystemViews.md @@ -23,8 +23,9 @@ Part-level details: **Table Parts** (`system.parts`) and **Part Log** ## Replication In `--cluster` mode the summary header also lists every host (uptime, cpu, -memory, threads, network and disk rates), busiest first and capped to a third -of the screen; **1** toggles it (`view.summary_per_host` in the config). +memory, threads, network and disk rates), busiest first; it takes a third of +the screen by default, **[**/**]** or dragging its bottom separator resize it, +**1** toggles it (`view.summary_per_host` in the config). **Replication queue** (`system.replication_queue`), **Replicated fetches** (`system.replicated_fetches`) and **Replicas** (`system.replicas`) - note the diff --git a/src/tui/linear.rs b/src/tui/linear.rs index 54a65c0f..8f1bfa69 100644 --- a/src/tui/linear.rs +++ b/src/tui/linear.rs @@ -1,7 +1,7 @@ use ratatui::layout::{Position, Rect, Size}; use super::component::{Boxed, Canvas, Component}; -use super::event::{Event, EventResult, Key}; +use super::event::{Event, EventResult, Key, MouseButton, MouseEvent}; #[derive(Clone, Copy, PartialEq, Eq)] pub enum Orientation { @@ -19,6 +19,9 @@ struct Child { /// everything available (`full_width`/`full_screen` wrappers, tables, panes). pub struct LinearLayout { orientation: Orientation, + /// Child that consumed the last left press: Hold/Release go to it until the + /// release, even when the pointer leaves it (drags across children). + mouse_capture: Option, children: Vec, focus: usize, } @@ -27,6 +30,7 @@ impl LinearLayout { pub fn new(orientation: Orientation) -> Self { Self { orientation, + mouse_capture: None, children: Vec::new(), focus: 0, } @@ -208,7 +212,24 @@ impl Component for LinearLayout { } // Mouse events are routed positionally, not by focus. - if let Event::Mouse { position, .. } = event { + if let Event::Mouse { + position, + event: mouse, + } = event + { + if let Some(i) = self.mouse_capture + && matches!( + mouse, + MouseEvent::Hold(MouseButton::Left) | MouseEvent::Release(MouseButton::Left) + ) + { + if matches!(mouse, MouseEvent::Release(_)) { + self.mouse_capture = None; + } + if i < self.children.len() { + return self.children[i].view.on_event(event); + } + } let pos = Position::new(position.x, position.y); for i in 0..self.children.len() { if self.children[i].last_rect.contains(pos) { @@ -216,6 +237,10 @@ impl Component for LinearLayout { if result.is_consumed() && self.children[i].view.take_focus() { self.focus = i; } + if result.is_consumed() && matches!(mouse, MouseEvent::Press(MouseButton::Left)) + { + self.mouse_capture = Some(i); + } return result; } } diff --git a/src/tui/navigation.rs b/src/tui/navigation.rs index 8fa2b2a7..c41d6b17 100644 --- a/src/tui/navigation.rs +++ b/src/tui/navigation.rs @@ -87,6 +87,13 @@ fn toggle_summary_per_host(app: &mut App) { ); } +fn adjust_summary_hosts(app: &mut App, delta: i32) { + app.call_on_name( + "summary", + |view: &mut crate::tui::views::summary_view::SummaryView| view.adjust_host_rows(delta), + ); +} + fn toggle_debug_metrics(app: &mut App) { let ctx = app.user_data::().unwrap().clone(); let metrics = ctx.lock().unwrap().debug_metrics.clone(); @@ -515,6 +522,8 @@ impl Navigation for App { if context.options.clickhouse.cluster.is_some() { context.add_global_action(self, "Filter by host", Event::CtrlChar('h'), |app| app.show_connection_dialog()); context.add_global_action(self, "Toggle per-host summary", '1', toggle_summary_per_host); + context.add_global_action(self, "Fewer hosts in the summary", '[', |app| adjust_summary_hosts(app, -1)); + context.add_global_action(self, "More hosts in the summary", ']', |app| adjust_summary_hosts(app, 1)); } self.add_global_callback('F', |app| { diff --git a/src/tui/views/summary_view.rs b/src/tui/views/summary_view.rs index f712d051..8801d0bd 100644 --- a/src/tui/views/summary_view.rs +++ b/src/tui/views/summary_view.rs @@ -11,10 +11,10 @@ use crate::interpreter::{ clickhouse::{ClickHouseHostSummary, ClickHouseServerSummary}, }; use crate::tui::component::{Canvas, Component, DummyView, Nameable, call_on_name}; -use crate::tui::event::{Event, EventResult}; +use crate::tui::event::{Event, EventResult, MouseButton, MouseEvent}; use crate::tui::linear::LinearLayout; use crate::tui::resize::Resizable; -use crate::tui::style::{Color, StyledString, pad_column}; +use crate::tui::style::{Color, Style as TextStyle, StyledString, pad_column, print_str}; use crate::tui::text::TextView; use crate::utils::{find_common_hostname_prefix_and_suffix, strip_hostname}; @@ -43,6 +43,10 @@ impl SparklineSet { /// is added only while shown, an empty TextView would still take a row. const PER_HOST_CHILD: usize = 4; const PER_HOST_MIN_ROWS: usize = 4; +/// Rows the panes below keep when the host table is resized +const PANES_MIN_HEIGHT: usize = 6; +/// Aggregated rows + table header + separator row +const PER_HOST_FIXED_ROWS: usize = 4 + 1 + 1; /// (header, right aligned) const PER_HOST_COLUMNS: &[(&str, bool)] = &[ ("host", false), @@ -80,17 +84,32 @@ pub struct SummaryView { per_host_enabled: bool, host_sparklines: HashMap, host_rows: Vec, - /// Rows shown for the last seen height (see required_size) + /// Table body lines (host rows, incl. the "... more" line) for the last + /// seen height (see required_size) last_row_cap: usize, + /// Body lines chosen by the user ([ ] or dragging the separator); None = + /// a third of the screen + host_rows_limit: Option, + last_area: Rect, + resizing: bool, bg_runner: BackgroundRunner, } -/// How many host rows fit: a third of the height, at least PER_HOST_MIN_ROWS. +/// How many table body lines fit by default: a third of the height, at least +/// PER_HOST_MIN_ROWS. fn row_cap(height: u16) -> usize { (height as usize / 3).max(PER_HOST_MIN_ROWS) } +/// The most body lines the table may take at `height` while the panes keep +/// PANES_MIN_HEIGHT rows. +fn max_row_cap(height: u16) -> usize { + (height as usize) + .saturating_sub(PER_HOST_FIXED_ROWS + PANES_MIN_HEIGHT) + .max(1) +} + /// Sorts by cpu utilization (busiest first), then by host for a stable order. fn sort_host_rows(rows: &mut [HostRow]) { rows.sort_by(|a, b| { @@ -100,8 +119,14 @@ fn sort_host_rows(rows: &mut [HostRow]) { }); } -/// The table text: header, at most `cap` rows, "... and K more hosts". +/// The table text: header and at most `cap` body lines, the last one being +/// "... and K more hosts" when the rows do not fit. fn render_host_rows(rows: &[HostRow], cap: usize) -> StyledString { + let shown = if rows.len() > cap { + cap.saturating_sub(1) + } else { + rows.len() + }; let widths: Vec = PER_HOST_COLUMNS .iter() .enumerate() @@ -125,7 +150,7 @@ fn render_host_rows(rows: &[HostRow], cap: usize) -> StyledString { pad_column(&mut header, start, widths[i]); } let mut text = header; - for row in rows.iter().take(cap) { + for row in rows.iter().take(shown) { let mut line = StyledString::new(); for (i, (_, right)) in PER_HOST_COLUMNS.iter().enumerate() { if i > 0 { @@ -142,10 +167,10 @@ fn render_host_rows(rows: &[HostRow], cap: usize) -> StyledString { text.append_plain("\n"); text.append(line); } - if rows.len() > cap { + if rows.len() > shown { text.append_plain("\n"); text.append_styled( - format!("... and {} more hosts", rows.len() - cap), + format!("... and {} more hosts", rows.len() - shown), Color::Gray, ); } @@ -291,10 +316,40 @@ impl SummaryView { host_sparklines: HashMap::new(), host_rows: Vec::new(), last_row_cap: PER_HOST_MIN_ROWS, + host_rows_limit: None, + last_area: Rect::default(), + resizing: false, bg_runner, } } + fn table_shown(&self) -> bool { + self.layout.len() > PER_HOST_CHILD + } + + /// Body lines of the table at `height`: the user's choice (clamped so the + /// panes keep their minimum), else a third of the screen. + fn row_cap_at(&self, height: u16) -> usize { + self.host_rows_limit + .unwrap_or_else(|| row_cap(height)) + .clamp(1, max_row_cap(height)) + } + + /// `[`/`]`: one body line less/more. + pub fn adjust_host_rows(&mut self, delta: i32) { + let rows = (self.last_row_cap as i32 + delta).max(1) as usize; + self.host_rows_limit = Some(rows); + } + + fn set_row_cap(&mut self, cap: usize) { + if cap != self.last_row_cap { + self.last_row_cap = cap; + if self.table_shown() { + self.set_view_content("per_host", render_host_rows(&self.host_rows, cap)); + } + } + } + /// Shows/hides the per-host table (the worker fetches it only while enabled). pub fn set_per_host(&mut self, enabled: bool) { if self.per_host_enabled == enabled { @@ -810,23 +865,59 @@ impl SummaryView { impl Component for SummaryView { fn draw(&mut self, canvas: &mut Canvas<'_>, area: Rect, focused: bool) { - self.layout.draw(canvas, area, focused); + self.last_area = area; + if !self.table_shown() || area.height < 2 { + self.layout.draw(canvas, area, focused); + return; + } + // The last row is the separator (the drag handle), like the pane ones + let inner = Rect::new(area.x, area.y, area.width, area.height - 1); + self.layout.draw(canvas, inner, focused); + let y = area.bottom() - 1; + for x in area.left()..area.right() { + print_str(canvas.buf, x, y, area, "\u{2500}", TextStyle::default()); + } } fn required_size(&mut self, max: Size) -> Size { // The layout hands the summary the whole remaining screen height, so - // the host rows cap follows terminal resizes - let cap = row_cap(max.height); - if cap != self.last_row_cap { - self.last_row_cap = cap; - if self.layout.len() > PER_HOST_CHILD { - self.set_view_content("per_host", render_host_rows(&self.host_rows, cap)); - } + // the cap follows terminal resizes (and keeps the panes' minimum) + self.set_row_cap(self.row_cap_at(max.height)); + let mut size = self.layout.required_size(max); + if self.table_shown() { + size.height = (size.height + 1).min(max.height); } - self.layout.required_size(max) + size } fn on_event(&mut self, event: &Event) -> EventResult { + if let Event::Mouse { + position, + event: mouse, + } = event + && self.table_shown() + { + let separator_y = self.last_area.bottom().saturating_sub(1); + match mouse { + MouseEvent::Press(MouseButton::Left) if position.y == separator_y => { + self.resizing = true; + return EventResult::consumed(); + } + MouseEvent::Hold(MouseButton::Left) if self.resizing => { + // Body lines between the header and the pointer (the + // separator lands where the pointer is) + let body_top = self.last_area.y as i32 + 4 + 1; + let rows = (position.y as i32 - body_top).max(1) as usize; + self.host_rows_limit = Some(rows); + return EventResult::consumed(); + } + MouseEvent::Release(MouseButton::Left) if self.resizing => { + self.resizing = false; + return EventResult::consumed(); + } + _ => {} + } + } self.layout.on_event(event) } @@ -872,15 +963,27 @@ mod tests { let order: Vec<&str> = rows.iter().map(|r| r.host.as_str()).collect(); assert_eq!(order, ["d", "e", "c", "b", "a", "f"]); + // 4 body lines: 3 rows and the "more" line let text = render_host_rows(&rows, 4).source(); let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines.len(), 1 + 4 + 1); + assert_eq!(lines.len(), 1 + 4); assert!(lines[1].starts_with("d ")); - assert_eq!(lines[5], "... and 2 more hosts"); + assert_eq!(lines[4], "... and 3 more hosts"); // Columns are aligned: every line has the same width let width = lines[0].chars().count(); - for line in &lines[..5] { + for line in &lines[..4] { assert_eq!(line.chars().count(), width, "{line:?}"); } + // Everything fits: no "more" line + let text = render_host_rows(&rows, 6).source(); + assert_eq!(text.lines().count(), 1 + 6); + } + + #[test] + fn test_max_row_cap() { + // 4 rows + header + separator + 6 for the panes = 12 fixed + assert_eq!(max_row_cap(30), 18); + assert_eq!(max_row_cap(12), 1); + assert_eq!(max_row_cap(5), 1); } }