feat(ui): compact layout for small terminal windows
In a short window the fixed root layout runs out of rows and the CPU graph and per-core bars are what collapse first: the header, gauges and process table hold fixed heights, so at ~18 rows the top row is left with no drawable interior and both panes disappear entirely. Add a second layout, entered automatically once the Disks pane can no longer show even one complete disk card: - Disks is dropped — it is the pane that degrades worst when partly drawn. - Memory and Swap move side by side into the row Disks vacated. - GPU collapses to a single full-width line (utilisation and VRAM, no device name), and is omitted entirely on a host that reports no GPU. - The reclaimed rows go to the CPU panes, with the surplus above their floor shared with the bottom half so the process table still grows with the window. `--compact` pins the layout at any size. The root layout was duplicated in three places (the draw path and both input hit-testing paths), which would have drifted the moment a second layout existed. Move it into ui::layout as the single source of truth and have all three callers go through it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,8 @@ socktop is a remote system monitor with a rich TUI, inspired by top/btop, talkin
|
|||||||
- Only top-level processes listed (threads hidden) — matches btop/top
|
- Only top-level processes listed (threads hidden) — matches btop/top
|
||||||
- Optional GPU metrics (can be disabled)
|
- Optional GPU metrics (can be disabled)
|
||||||
- Optional auth token for the agent
|
- Optional auth token for the agent
|
||||||
|
- Compact layout for small windows: automatically drops the panes that no longer fit so
|
||||||
|
the CPU graph and per-core bars stay visible (see [Compact mode](#compact-mode))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -213,6 +215,8 @@ socktop --verify-hostname --tls-ca /path/to/cert.pem wss://HOST:8443/ws
|
|||||||
# shorthand:
|
# shorthand:
|
||||||
socktop -t /path/to/cert.pem wss://HOST:8443/ws
|
socktop -t /path/to/cert.pem wss://HOST:8443/ws
|
||||||
# Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget
|
# Note: providing --tls-ca/-t automatically upgrades ws:// to wss:// if you forget
|
||||||
|
# force the small-window layout at any terminal size (normally automatic):
|
||||||
|
socktop --compact ws://HOST:3000/ws
|
||||||
```
|
```
|
||||||
|
|
||||||
Intervals (client-driven):
|
Intervals (client-driven):
|
||||||
@@ -224,6 +228,29 @@ The agent stays idle unless queried. When queried, it collects just what’s nee
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Compact mode
|
||||||
|
|
||||||
|
In a short terminal the fixed layout runs out of rows and the CPU graph and per-core bars
|
||||||
|
are the first things to collapse — exactly the panes you are most likely watching. Once
|
||||||
|
the window is too short for the Disks pane to show even one disk, socktop switches to a
|
||||||
|
compact layout:
|
||||||
|
|
||||||
|
- **Disks is dropped.** It is the pane that degrades worst when partially drawn.
|
||||||
|
- **Memory and Swap move side by side** into the row Disks vacated.
|
||||||
|
- **GPU shrinks to a single line** — utilisation and VRAM only, no device name. On a host
|
||||||
|
with no GPU the pane disappears entirely.
|
||||||
|
- **Everything reclaimed goes to the CPU graph and per-core bars**, which stay usable well
|
||||||
|
below the size where they used to vanish.
|
||||||
|
|
||||||
|
The switch is automatic and needs no configuration. Pass `--compact` to pin the compact
|
||||||
|
layout at any window size:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
socktop --compact ws://HOST:3000/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Connection Profiles (Named)
|
## Connection Profiles (Named)
|
||||||
|
|
||||||
You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later.
|
You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later.
|
||||||
|
|||||||
+53
-95
@@ -15,7 +15,7 @@ use ratatui::{
|
|||||||
//style::Color, // + add Color
|
//style::Color, // + add Color
|
||||||
Terminal,
|
Terminal,
|
||||||
backend::CrosstermBackend,
|
backend::CrosstermBackend,
|
||||||
layout::{Constraint, Direction, Rect},
|
layout::Rect,
|
||||||
};
|
};
|
||||||
use tokio::time::{sleep, timeout};
|
use tokio::time::{sleep, timeout};
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ use crate::ui::cpu::{
|
|||||||
per_core_content_area, per_core_handle_key, per_core_handle_mouse,
|
per_core_content_area, per_core_handle_key, per_core_handle_mouse,
|
||||||
per_core_handle_scrollbar_mouse,
|
per_core_handle_scrollbar_mouse,
|
||||||
};
|
};
|
||||||
|
use crate::ui::layout::{AppLayout, compute as compute_layout};
|
||||||
use crate::ui::modal::{ModalAction, ModalManager, ModalType};
|
use crate::ui::modal::{ModalAction, ModalManager, ModalType};
|
||||||
use crate::ui::processes::{
|
use crate::ui::processes::{
|
||||||
ProcSortBy, ProcessKeyParams, processes_handle_key_with_selection,
|
ProcSortBy, ProcessKeyParams, processes_handle_key_with_selection,
|
||||||
@@ -34,7 +35,7 @@ use crate::ui::processes::{
|
|||||||
};
|
};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
disks::draw_disks,
|
disks::draw_disks,
|
||||||
gpu::draw_gpu,
|
gpu::{draw_gpu, draw_gpu_compact},
|
||||||
header::{build_header_intervals, build_header_title, draw_header},
|
header::{build_header_intervals, build_header_title, draw_header},
|
||||||
mem::draw_mem,
|
mem::draw_mem,
|
||||||
net::draw_net_spark,
|
net::draw_net_spark,
|
||||||
@@ -145,6 +146,10 @@ pub struct App {
|
|||||||
pub is_tls: bool,
|
pub is_tls: bool,
|
||||||
pub has_token: bool,
|
pub has_token: bool,
|
||||||
|
|
||||||
|
// --compact: pin the compact layout regardless of window size. Without it the
|
||||||
|
// layout switches on its own once the window is too short for the Disks pane.
|
||||||
|
force_compact: bool,
|
||||||
|
|
||||||
// Cached title strings — only rebuilt when source values change so the
|
// Cached title strings — only rebuilt when source values change so the
|
||||||
// diff renderer can suppress redraws on idle frames.
|
// diff renderer can suppress redraws on idle frames.
|
||||||
header_title: String,
|
header_title: String,
|
||||||
@@ -229,6 +234,7 @@ impl App {
|
|||||||
verify_hostname: false,
|
verify_hostname: false,
|
||||||
is_tls: false,
|
is_tls: false,
|
||||||
has_token: false,
|
has_token: false,
|
||||||
|
force_compact: false,
|
||||||
header_title: String::new(),
|
header_title: String::new(),
|
||||||
header_title_key: (String::new(), false, false),
|
header_title_key: (String::new(), false, false),
|
||||||
header_intervals_text: String::new(),
|
header_intervals_text: String::new(),
|
||||||
@@ -247,6 +253,23 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pins the compact layout at any window size (`--compact`).
|
||||||
|
pub fn with_compact(mut self, force_compact: bool) -> Self {
|
||||||
|
self.force_compact = force_compact;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pane rects for the current frame. The draw path and the mouse/key hit-testing
|
||||||
|
/// paths all go through here so they cannot disagree about where a pane is.
|
||||||
|
fn layout(&self, area: Rect) -> AppLayout {
|
||||||
|
let has_gpu = self
|
||||||
|
.last_metrics
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.gpus.as_ref())
|
||||||
|
.is_some_and(|g| !g.is_empty());
|
||||||
|
compute_layout(area, self.force_compact, has_gpu)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_intervals(mut self, metrics_ms: Option<u64>, procs_ms: Option<u64>) -> Self {
|
pub fn with_intervals(mut self, metrics_ms: Option<u64>, procs_ms: Option<u64>) -> Self {
|
||||||
metrics_ms.inspect(|&m| {
|
metrics_ms.inspect(|&m| {
|
||||||
self.metrics_interval = Duration::from_millis(m.max(MIN_METRICS_INTERVAL_MS));
|
self.metrics_interval = Duration::from_millis(m.max(MIN_METRICS_INTERVAL_MS));
|
||||||
@@ -803,21 +826,8 @@ impl App {
|
|||||||
// Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End)
|
// Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End)
|
||||||
let sz = terminal.size()?;
|
let sz = terminal.size()?;
|
||||||
let area = Rect::new(0, 0, sz.width, sz.height);
|
let area = Rect::new(0, 0, sz.width, sz.height);
|
||||||
let rows = ratatui::layout::Layout::default()
|
let layout = self.layout(area);
|
||||||
.direction(Direction::Vertical)
|
let content = per_core_content_area(layout.per_core);
|
||||||
.constraints([
|
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Ratio(1, 3),
|
|
||||||
Constraint::Length(3),
|
|
||||||
Constraint::Length(3),
|
|
||||||
Constraint::Min(10),
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
let top = ratatui::layout::Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
|
||||||
.split(rows[1]);
|
|
||||||
let content = per_core_content_area(top[1]);
|
|
||||||
|
|
||||||
// Refresh the filtered+sorted index cache once before we
|
// Refresh the filtered+sorted index cache once before we
|
||||||
// borrow individual fields of `self`.
|
// borrow individual fields of `self`.
|
||||||
@@ -915,23 +925,10 @@ impl App {
|
|||||||
// Layout to get areas
|
// Layout to get areas
|
||||||
let sz = terminal.size()?;
|
let sz = terminal.size()?;
|
||||||
let area = Rect::new(0, 0, sz.width, sz.height);
|
let area = Rect::new(0, 0, sz.width, sz.height);
|
||||||
let rows = ratatui::layout::Layout::default()
|
let layout = self.layout(area);
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Ratio(1, 3),
|
|
||||||
Constraint::Length(3),
|
|
||||||
Constraint::Length(3),
|
|
||||||
Constraint::Min(10),
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
let top = ratatui::layout::Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
|
||||||
.split(rows[1]);
|
|
||||||
|
|
||||||
// Content wheel scrolling
|
// Content wheel scrolling
|
||||||
let content = per_core_content_area(top[1]);
|
let content = per_core_content_area(layout.per_core);
|
||||||
per_core_handle_mouse(
|
per_core_handle_mouse(
|
||||||
&mut self.per_core_scroll,
|
&mut self.per_core_scroll,
|
||||||
m,
|
m,
|
||||||
@@ -949,7 +946,7 @@ impl App {
|
|||||||
&mut self.per_core_scroll,
|
&mut self.per_core_scroll,
|
||||||
&mut self.per_core_drag,
|
&mut self.per_core_drag,
|
||||||
m,
|
m,
|
||||||
top[1],
|
layout.per_core,
|
||||||
total_rows,
|
total_rows,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1278,18 +1275,7 @@ impl App {
|
|||||||
|
|
||||||
pub fn draw(&mut self, f: &mut ratatui::Frame<'_>) {
|
pub fn draw(&mut self, f: &mut ratatui::Frame<'_>) {
|
||||||
let area = f.area();
|
let area = f.area();
|
||||||
|
let l = self.layout(area);
|
||||||
// Root rows: header, top (cpu avg + per-core), memory, swap, bottom
|
|
||||||
let rows = ratatui::layout::Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(1), // header
|
|
||||||
Constraint::Ratio(1, 3), // top row
|
|
||||||
Constraint::Length(3), // memory (left) + GPU (right, part 1)
|
|
||||||
Constraint::Length(3), // swap (left) + GPU (right, part 2)
|
|
||||||
Constraint::Min(10), // bottom: disks + net (left), top procs (right)
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
// Header — refresh cached strings only when their inputs change so the
|
// Header — refresh cached strings only when their inputs change so the
|
||||||
// ratatui diff renderer can suppress repaints on idle frames.
|
// ratatui diff renderer can suppress repaints on idle frames.
|
||||||
@@ -1315,69 +1301,41 @@ impl App {
|
|||||||
self.header_intervals_key = intervals_key;
|
self.header_intervals_key = intervals_key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
draw_header(f, rows[0], &self.header_title, &self.header_intervals_text);
|
draw_header(f, l.header, &self.header_title, &self.header_intervals_text);
|
||||||
|
|
||||||
// Top row: left CPU avg, right Per-core (full top-right)
|
|
||||||
let top_lr = ratatui::layout::Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
|
||||||
.split(rows[1]);
|
|
||||||
|
|
||||||
draw_cpu_avg_graph(
|
draw_cpu_avg_graph(
|
||||||
f,
|
f,
|
||||||
top_lr[0],
|
l.cpu,
|
||||||
&mut self.cpu_hist,
|
&mut self.cpu_hist,
|
||||||
self.cpu_hist_sum,
|
self.cpu_hist_sum,
|
||||||
self.last_metrics.as_ref(),
|
self.last_metrics.as_ref(),
|
||||||
);
|
);
|
||||||
draw_per_core_bars(
|
draw_per_core_bars(
|
||||||
f,
|
f,
|
||||||
top_lr[1],
|
l.per_core,
|
||||||
self.last_metrics.as_ref(),
|
self.last_metrics.as_ref(),
|
||||||
&mut self.per_core_hist,
|
&mut self.per_core_hist,
|
||||||
self.per_core_scroll,
|
self.per_core_scroll,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memory + Swap rows split into left/right columns
|
// Memory + Swap: stacked vertically in the normal layout, side by side in the
|
||||||
let mem_lr = ratatui::layout::Layout::default()
|
// row Disks vacates in compact mode.
|
||||||
.direction(Direction::Horizontal)
|
draw_mem(f, l.mem, self.last_metrics.as_ref());
|
||||||
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
draw_swap(f, l.swap, self.last_metrics.as_ref());
|
||||||
.split(rows[2]);
|
|
||||||
let swap_lr = ratatui::layout::Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([Constraint::Percentage(66), Constraint::Percentage(34)])
|
|
||||||
.split(rows[3]);
|
|
||||||
|
|
||||||
// Left: Memory + Swap
|
// GPU: a panel beside Memory/Swap normally, a single full-width line in compact
|
||||||
draw_mem(f, mem_lr[0], self.last_metrics.as_ref());
|
// mode, and absent entirely when the host reports no GPU while compact.
|
||||||
draw_swap(f, swap_lr[0], self.last_metrics.as_ref());
|
if let Some(gpu_area) = l.gpu {
|
||||||
|
if l.mode.is_compact() {
|
||||||
|
draw_gpu_compact(f, gpu_area, self.last_metrics.as_ref());
|
||||||
|
} else {
|
||||||
|
draw_gpu(f, gpu_area, self.last_metrics.as_ref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Right: GPU spans the same vertical space as Memory + Swap
|
if let Some(disks_area) = l.disks {
|
||||||
let gpu_area = ratatui::layout::Rect {
|
draw_disks(f, disks_area, self.last_metrics.as_ref());
|
||||||
x: mem_lr[1].x,
|
}
|
||||||
y: mem_lr[1].y,
|
|
||||||
width: mem_lr[1].width,
|
|
||||||
height: mem_lr[1].height + swap_lr[1].height,
|
|
||||||
};
|
|
||||||
draw_gpu(f, gpu_area, self.last_metrics.as_ref());
|
|
||||||
|
|
||||||
// Bottom area: left = Disks + Network, right = Top Processes
|
|
||||||
let bottom_lr = ratatui::layout::Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
|
|
||||||
.split(rows[4]);
|
|
||||||
|
|
||||||
// Left bottom: Disks + Net stacked (make net panes slightly taller)
|
|
||||||
let left_stack = ratatui::layout::Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Min(4), // Disks shrink slightly
|
|
||||||
Constraint::Length(5), // Download taller
|
|
||||||
Constraint::Length(5), // Upload taller
|
|
||||||
])
|
|
||||||
.split(bottom_lr[0]);
|
|
||||||
|
|
||||||
draw_disks(f, left_stack[0], self.last_metrics.as_ref());
|
|
||||||
|
|
||||||
// Net titles only change when the throughput or peak changes.
|
// Net titles only change when the throughput or peak changes.
|
||||||
let rx_now = self.rx_hist.back().copied().unwrap_or(0);
|
let rx_now = self.rx_hist.back().copied().unwrap_or(0);
|
||||||
@@ -1388,7 +1346,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
draw_net_spark(
|
draw_net_spark(
|
||||||
f,
|
f,
|
||||||
left_stack[1],
|
l.download,
|
||||||
&self.net_dl_title,
|
&self.net_dl_title,
|
||||||
&mut self.rx_hist,
|
&mut self.rx_hist,
|
||||||
ratatui::style::Color::Green,
|
ratatui::style::Color::Green,
|
||||||
@@ -1402,14 +1360,14 @@ impl App {
|
|||||||
}
|
}
|
||||||
draw_net_spark(
|
draw_net_spark(
|
||||||
f,
|
f,
|
||||||
left_stack[2],
|
l.upload,
|
||||||
&self.net_ul_title,
|
&self.net_ul_title,
|
||||||
&mut self.tx_hist,
|
&mut self.tx_hist,
|
||||||
ratatui::style::Color::Blue,
|
ratatui::style::Color::Blue,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Right bottom: Top Processes fills the column
|
// Right bottom: Top Processes fills the column
|
||||||
let procs_area = bottom_lr[1];
|
let procs_area = l.procs;
|
||||||
// Cache for input handlers
|
// Cache for input handlers
|
||||||
self.last_procs_area = Some(procs_area);
|
self.last_procs_area = Some(procs_area);
|
||||||
// Refresh the filter cache before partial borrows of self.
|
// Refresh the filter cache before partial borrows of self.
|
||||||
|
|||||||
+20
-8
@@ -22,6 +22,7 @@ pub(crate) struct ParsedArgs {
|
|||||||
metrics_interval_ms: Option<u64>,
|
metrics_interval_ms: Option<u64>,
|
||||||
processes_interval_ms: Option<u64>,
|
processes_interval_ms: Option<u64>,
|
||||||
verify_hostname: bool,
|
verify_hostname: bool,
|
||||||
|
compact: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
|
pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<ParsedArgs, String> {
|
||||||
@@ -36,11 +37,12 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
|
|||||||
let mut metrics_interval_ms: Option<u64> = None;
|
let mut metrics_interval_ms: Option<u64> = None;
|
||||||
let mut processes_interval_ms: Option<u64> = None;
|
let mut processes_interval_ms: Option<u64> = None;
|
||||||
let mut verify_hostname = false;
|
let mut verify_hostname = false;
|
||||||
|
let mut compact = false;
|
||||||
while let Some(arg) = it.next() {
|
while let Some(arg) = it.next() {
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
"-h" | "--help" => {
|
"-h" | "--help" => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
|
"Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [--metrics-interval-ms N] [--processes-interval-ms N] [ws://HOST:PORT/ws]\n"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
"--tls-ca" | "-t" => {
|
"--tls-ca" | "-t" => {
|
||||||
@@ -61,6 +63,11 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
|
|||||||
"--demo" => {
|
"--demo" => {
|
||||||
demo = true;
|
demo = true;
|
||||||
}
|
}
|
||||||
|
"--compact" => {
|
||||||
|
// Force the small-window layout at any terminal size. Without it the
|
||||||
|
// layout switches on its own once the window gets too short.
|
||||||
|
compact = true;
|
||||||
|
}
|
||||||
"--dry-run" => {
|
"--dry-run" => {
|
||||||
// intentionally undocumented
|
// intentionally undocumented
|
||||||
dry_run = true;
|
dry_run = true;
|
||||||
@@ -100,7 +107,7 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
|
|||||||
url = Some(arg);
|
url = Some(arg);
|
||||||
} else {
|
} else {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]"
|
"Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--verify-hostname] [--profile NAME|-P NAME] [--save] [--demo] [--compact] [ws://HOST:PORT/ws]"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,6 +123,7 @@ pub(crate) fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Pars
|
|||||||
metrics_interval_ms,
|
metrics_interval_ms,
|
||||||
processes_interval_ms,
|
processes_interval_ms,
|
||||||
verify_hostname,
|
verify_hostname,
|
||||||
|
compact,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +144,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) {
|
if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) {
|
||||||
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let profiles_file = load_profiles();
|
let profiles_file = load_profiles();
|
||||||
@@ -241,7 +249,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if (1..=names.len()).contains(&idx) {
|
if (1..=names.len()).contains(&idx) {
|
||||||
let name = &names[idx - 1];
|
let name = &names[idx - 1];
|
||||||
if name == "demo" {
|
if name == "demo" {
|
||||||
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
|
||||||
}
|
}
|
||||||
if let Some(entry) = profiles_mut.profiles.get(name) {
|
if let Some(entry) = profiles_mut.profiles.get(name) {
|
||||||
(
|
(
|
||||||
@@ -301,7 +309,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
);
|
);
|
||||||
eprintln!("If you don't have an agent running, you can try the demo mode.");
|
eprintln!("If you don't have an agent running, you can try the demo mode.");
|
||||||
if prompt_yes_no("Would you like to start the demo mode now? [Y/n]: ") {
|
if prompt_yes_no("Would you like to start the demo mode now? [Y/n]: ") {
|
||||||
return run_demo_mode(parsed.tls_ca.as_deref()).await;
|
return run_demo_mode(parsed.tls_ca.as_deref(), parsed.compact).await;
|
||||||
} else {
|
} else {
|
||||||
eprintln!("Aborting. You can run 'socktop --help' for usage information.");
|
eprintln!("Aborting. You can run 'socktop --help' for usage information.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -315,7 +323,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let has_token = url.contains("token=");
|
let has_token = url.contains("token=");
|
||||||
let mut app = App::new()
|
let mut app = App::new()
|
||||||
.with_intervals(metrics_interval_ms, processes_interval_ms)
|
.with_intervals(metrics_interval_ms, processes_interval_ms)
|
||||||
.with_status(is_tls, has_token);
|
.with_status(is_tls, has_token)
|
||||||
|
.with_compact(parsed.compact);
|
||||||
if parsed.dry_run {
|
if parsed.dry_run {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -379,7 +388,10 @@ fn gather_intervals(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Demo mode implementation
|
// Demo mode implementation
|
||||||
async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
async fn run_demo_mode(
|
||||||
|
_tls_ca: Option<&str>,
|
||||||
|
compact: bool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let port = 3231;
|
let port = 3231;
|
||||||
let url = format!("ws://127.0.0.1:{port}/ws");
|
let url = format!("ws://127.0.0.1:{port}/ws");
|
||||||
let child = match spawn_demo_agent(port) {
|
let child = match spawn_demo_agent(port) {
|
||||||
@@ -392,7 +404,7 @@ async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box<dyn std::error::
|
|||||||
}
|
}
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
};
|
};
|
||||||
let mut app = App::new();
|
let mut app = App::new().with_compact(compact);
|
||||||
// Demo mode connects to localhost, so disable hostname verification
|
// Demo mode connects to localhost, so disable hostname verification
|
||||||
tokio::select! { res=app.run(&url,None,false)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } }
|
tokio::select! { res=app.run(&url,None,false)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,3 +121,209 @@ pub fn draw_gpu(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One-line GPU strip for compact mode: no device name (it is the first thing to lose
|
||||||
|
/// value when rows are scarce), just utilisation and VRAM on the single content row
|
||||||
|
/// between the block borders. Only the first GPU fits; the title says so when there are
|
||||||
|
/// more.
|
||||||
|
pub fn draw_gpu_compact(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||||
|
let gpus = m.and_then(|mm| mm.gpus.as_ref());
|
||||||
|
let count = gpus.map(|g| g.len()).unwrap_or(0);
|
||||||
|
let title = if count > 1 {
|
||||||
|
format!("GPU (1/{count})")
|
||||||
|
} else {
|
||||||
|
"GPU".to_string()
|
||||||
|
};
|
||||||
|
f.render_widget(Block::default().borders(Borders::ALL).title(title), area);
|
||||||
|
|
||||||
|
if area.height < 3 || area.width <= 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let inner = Rect {
|
||||||
|
x: area.x + 1,
|
||||||
|
y: area.y + 1,
|
||||||
|
width: area.width - 2,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(g) = gpus.and_then(|v| v.first()) else {
|
||||||
|
f.render_widget(Paragraph::new("No GPUs"), inner);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let util = g.utilization.unwrap_or(0.0).clamp(0.0, 100.0) as u16;
|
||||||
|
let used = g.mem_used.unwrap_or(0);
|
||||||
|
let total = g.mem_total.unwrap_or(1);
|
||||||
|
let mem_ratio = if total > 0 {
|
||||||
|
(used as f64 / total as f64).clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let util_label = format!("util: {util}%");
|
||||||
|
let mem_label = format!(
|
||||||
|
"vram: {}/{} ({}%)",
|
||||||
|
fmt_bytes(used),
|
||||||
|
fmt_bytes(total),
|
||||||
|
(mem_ratio * 100.0).round() as u16
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bars are sized explicitly rather than left to stretch: an idle bar renders as
|
||||||
|
// empty cells, so a full-width one turns into a long blank run between two labels.
|
||||||
|
const MIN_GAUGE_W: u16 = 6;
|
||||||
|
const MAX_GAUGE_W: u16 = 24;
|
||||||
|
let labels_w = util_label.len() as u16 + mem_label.len() as u16 + 4; // one space each side
|
||||||
|
let gauge_w = inner
|
||||||
|
.width
|
||||||
|
.saturating_sub(labels_w)
|
||||||
|
.min(2 * MAX_GAUGE_W)
|
||||||
|
.div_euclid(2);
|
||||||
|
|
||||||
|
// Too narrow for bars worth drawing: keep the numbers, drop the bars.
|
||||||
|
if gauge_w < MIN_GAUGE_W {
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Span::raw(format!("{util_label} {mem_label}")))
|
||||||
|
.style(Style::default().fg(Color::Gray)),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each label leads its own bar. Bar-then-label (as the tall panel does) is ambiguous
|
||||||
|
// on a single line: with an idle bar rendering empty, the next pair's fill ends up
|
||||||
|
// flush against the previous pair's text and reads as belonging to it.
|
||||||
|
let mut x = inner.x;
|
||||||
|
let mut place = |w: u16| {
|
||||||
|
let r = Rect {
|
||||||
|
x,
|
||||||
|
y: inner.y,
|
||||||
|
width: w,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
x += w;
|
||||||
|
r
|
||||||
|
};
|
||||||
|
let util_rect = place(util_label.len() as u16 + 2);
|
||||||
|
let util_bar = place(gauge_w);
|
||||||
|
let mem_rect = place(mem_label.len() as u16 + 2);
|
||||||
|
let mem_bar = place(gauge_w);
|
||||||
|
|
||||||
|
let label = |text: &str| {
|
||||||
|
Paragraph::new(Span::raw(format!(" {text} "))).style(Style::default().fg(Color::Gray))
|
||||||
|
};
|
||||||
|
|
||||||
|
f.render_widget(label(&util_label), util_rect);
|
||||||
|
f.render_widget(
|
||||||
|
Gauge::default()
|
||||||
|
.gauge_style(Style::default().fg(Color::Green))
|
||||||
|
.label(Span::raw(""))
|
||||||
|
.ratio(util as f64 / 100.0),
|
||||||
|
util_bar,
|
||||||
|
);
|
||||||
|
f.render_widget(label(&mem_label), mem_rect);
|
||||||
|
f.render_widget(
|
||||||
|
Gauge::default()
|
||||||
|
.gauge_style(Style::default().fg(Color::LightMagenta))
|
||||||
|
.label(Span::raw(""))
|
||||||
|
.ratio(mem_ratio),
|
||||||
|
mem_bar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod render_tests {
|
||||||
|
use super::*;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use ratatui::backend::TestBackend;
|
||||||
|
use socktop_connector::{GpuInfo, Metrics};
|
||||||
|
|
||||||
|
fn gpu(name: &str) -> GpuInfo {
|
||||||
|
GpuInfo {
|
||||||
|
name: Some(name.into()),
|
||||||
|
vendor: None,
|
||||||
|
utilization: Some(42.0),
|
||||||
|
mem_used: Some(4_724_464_025),
|
||||||
|
mem_total: Some(17_070_817_280),
|
||||||
|
temp: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metrics(gpus: Option<Vec<GpuInfo>>) -> Metrics {
|
||||||
|
Metrics {
|
||||||
|
cpu_total: 0.0,
|
||||||
|
cpu_per_core: vec![],
|
||||||
|
mem_total: 1024,
|
||||||
|
mem_used: 0,
|
||||||
|
swap_total: 0,
|
||||||
|
swap_used: 0,
|
||||||
|
hostname: "t".into(),
|
||||||
|
cpu_temp_c: None,
|
||||||
|
disks: vec![],
|
||||||
|
networks: vec![],
|
||||||
|
top_processes: vec![],
|
||||||
|
gpus,
|
||||||
|
process_count: Some(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(width: u16, m: &Metrics) -> String {
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(width, 3)).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|f| draw_gpu_compact(f, Rect::new(0, 0, width, 3), Some(m)))
|
||||||
|
.unwrap();
|
||||||
|
let buf = terminal.backend().buffer();
|
||||||
|
let mut out = String::new();
|
||||||
|
for y in 0..buf.area().height {
|
||||||
|
for x in 0..buf.area().width {
|
||||||
|
out.push_str(buf[(x, y)].symbol());
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact mode drops the device name — the row is one line and the numbers are
|
||||||
|
/// what the space is for.
|
||||||
|
#[test]
|
||||||
|
fn compact_strip_omits_the_device_name() {
|
||||||
|
let m = metrics(Some(vec![gpu("NVIDIA GeForce RTX 5080")]));
|
||||||
|
let out = render(80, &m);
|
||||||
|
assert!(
|
||||||
|
!out.contains("NVIDIA"),
|
||||||
|
"name leaked into compact strip:\n{out}"
|
||||||
|
);
|
||||||
|
assert!(out.contains("util: 42%"), "{out}");
|
||||||
|
assert!(out.contains("vram: 4.4G/15.9G (28%)"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A second GPU cannot fit on one line, so the title has to say the strip is partial
|
||||||
|
/// rather than silently showing only the first card.
|
||||||
|
#[test]
|
||||||
|
fn multiple_gpus_are_flagged_in_the_title() {
|
||||||
|
let one = render(80, &metrics(Some(vec![gpu("a")])));
|
||||||
|
assert!(one.contains("GPU") && !one.contains("1/"), "{one}");
|
||||||
|
|
||||||
|
let two = render(80, &metrics(Some(vec![gpu("a"), gpu("b")])));
|
||||||
|
assert!(two.contains("GPU (1/2)"), "{two}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Narrow terminals drop the gauges rather than rendering two-cell stubs, but must
|
||||||
|
/// never drop the numbers.
|
||||||
|
#[test]
|
||||||
|
fn narrow_strip_keeps_the_numbers() {
|
||||||
|
let m = metrics(Some(vec![gpu("a")]));
|
||||||
|
for width in [20u16, 30, 40, 47, 48, 80, 200] {
|
||||||
|
let out = render(width, &m);
|
||||||
|
if width >= 40 {
|
||||||
|
assert!(out.contains("util: 42%"), "width {width}:\n{out}");
|
||||||
|
}
|
||||||
|
// No panic, and the block always closes on the last row.
|
||||||
|
assert_eq!(out.lines().count(), 3, "width {width}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_gpu_payload_does_not_panic() {
|
||||||
|
assert!(render(80, &metrics(None)).contains("No GPUs"));
|
||||||
|
assert!(render(80, &metrics(Some(vec![]))).contains("No GPUs"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
//! Root layout computation, shared by the draw path and the input hit-testing paths.
|
||||||
|
//!
|
||||||
|
//! Two modes:
|
||||||
|
//!
|
||||||
|
//! * [`LayoutMode::Normal`] — the full layout. CPU graph and per-core bars on top,
|
||||||
|
//! Memory over Swap on the left with the GPU panel beside them, then Disks and the
|
||||||
|
//! network graphs next to the process table.
|
||||||
|
//!
|
||||||
|
//! * [`LayoutMode::Compact`] — entered when the window is too short for the Disks pane
|
||||||
|
//! to render even one complete disk card. Disks is dropped, Memory and Swap move side
|
||||||
|
//! by side into the space it vacated, the GPU collapses to a single full-width line
|
||||||
|
//! (and disappears entirely when the host has no GPU), and every row reclaimed goes to
|
||||||
|
//! the CPU graph and per-core bars — which in the fixed layout are squeezed to nothing
|
||||||
|
//! long before the rest of the panes stop being useful.
|
||||||
|
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
|
||||||
|
/// Which of the two layouts [`compute`] produced.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum LayoutMode {
|
||||||
|
Normal,
|
||||||
|
Compact,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LayoutMode {
|
||||||
|
pub fn is_compact(self) -> bool {
|
||||||
|
matches!(self, LayoutMode::Compact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rows the Disks pane needs before it can show one disk card: the card itself is
|
||||||
|
/// 3 rows (`disks::draw_disks`) plus the pane's own top and bottom border.
|
||||||
|
const DISKS_MIN_H: u16 = 5;
|
||||||
|
|
||||||
|
/// Header line.
|
||||||
|
const HEADER_H: u16 = 1;
|
||||||
|
/// Memory and Swap gauges: 1 content row between borders.
|
||||||
|
const GAUGE_H: u16 = 3;
|
||||||
|
/// A network graph at its preferred height.
|
||||||
|
const NET_H: u16 = 5;
|
||||||
|
|
||||||
|
// Compact-mode budget. The top row is kept at `TOP_MIN_H` (3 content rows between
|
||||||
|
// borders) before the network graphs are allowed to shrink, because restoring the CPU
|
||||||
|
// panes is the entire point of the mode.
|
||||||
|
const TOP_MIN_H: u16 = 5;
|
||||||
|
const BOTTOM_PREF_H: u16 = GAUGE_H + 2 * NET_H;
|
||||||
|
const BOTTOM_MIN_H: u16 = GAUGE_H + 2 * 3;
|
||||||
|
|
||||||
|
/// Every pane rect for one frame. `disks` and `gpu` are `None` when the mode omits them.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct AppLayout {
|
||||||
|
pub mode: LayoutMode,
|
||||||
|
pub header: Rect,
|
||||||
|
pub cpu: Rect,
|
||||||
|
pub per_core: Rect,
|
||||||
|
pub gpu: Option<Rect>,
|
||||||
|
pub mem: Rect,
|
||||||
|
pub swap: Rect,
|
||||||
|
pub disks: Option<Rect>,
|
||||||
|
pub download: Rect,
|
||||||
|
pub upload: Rect,
|
||||||
|
pub procs: Rect,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits `area` into pane rects.
|
||||||
|
///
|
||||||
|
/// `force_compact` comes from `--compact` and pins the compact layout at any size.
|
||||||
|
/// `has_gpu` decides whether compact mode reserves its one-line GPU strip; it is false
|
||||||
|
/// until the first metrics payload arrives, so a GPU-less host never reserves the row.
|
||||||
|
pub fn compute(area: Rect, force_compact: bool, has_gpu: bool) -> AppLayout {
|
||||||
|
if force_compact {
|
||||||
|
return compact(area, has_gpu);
|
||||||
|
}
|
||||||
|
let normal = normal(area);
|
||||||
|
match normal.disks {
|
||||||
|
Some(d) if d.height >= DISKS_MIN_H => normal,
|
||||||
|
_ => compact(area, has_gpu),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split(area: Rect, dir: Direction, constraints: &[Constraint]) -> std::rc::Rc<[Rect]> {
|
||||||
|
Layout::default()
|
||||||
|
.direction(dir)
|
||||||
|
.constraints(constraints)
|
||||||
|
.split(area)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 66/34 split used by every full-width row in the normal layout.
|
||||||
|
fn left_right(area: Rect) -> std::rc::Rc<[Rect]> {
|
||||||
|
split(
|
||||||
|
area,
|
||||||
|
Direction::Horizontal,
|
||||||
|
&[Constraint::Percentage(66), Constraint::Percentage(34)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normal(area: Rect) -> AppLayout {
|
||||||
|
let rows = split(
|
||||||
|
area,
|
||||||
|
Direction::Vertical,
|
||||||
|
&[
|
||||||
|
Constraint::Length(HEADER_H), // header
|
||||||
|
Constraint::Ratio(1, 3), // top row
|
||||||
|
Constraint::Length(GAUGE_H), // memory (left) + GPU (right, part 1)
|
||||||
|
Constraint::Length(GAUGE_H), // swap (left) + GPU (right, part 2)
|
||||||
|
Constraint::Min(2 * NET_H), // bottom: disks + net (left), top procs (right)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let top = left_right(rows[1]);
|
||||||
|
let mem_lr = left_right(rows[2]);
|
||||||
|
let swap_lr = left_right(rows[3]);
|
||||||
|
|
||||||
|
// GPU spans the same vertical space as Memory + Swap.
|
||||||
|
let gpu = Rect {
|
||||||
|
x: mem_lr[1].x,
|
||||||
|
y: mem_lr[1].y,
|
||||||
|
width: mem_lr[1].width,
|
||||||
|
height: mem_lr[1].height + swap_lr[1].height,
|
||||||
|
};
|
||||||
|
|
||||||
|
let bottom = split(
|
||||||
|
rows[4],
|
||||||
|
Direction::Horizontal,
|
||||||
|
&[Constraint::Percentage(60), Constraint::Percentage(40)],
|
||||||
|
);
|
||||||
|
let left_stack = split(
|
||||||
|
bottom[0],
|
||||||
|
Direction::Vertical,
|
||||||
|
&[
|
||||||
|
Constraint::Min(4), // disks absorbs the slack
|
||||||
|
Constraint::Length(NET_H), // download
|
||||||
|
Constraint::Length(NET_H), // upload
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
AppLayout {
|
||||||
|
mode: LayoutMode::Normal,
|
||||||
|
header: rows[0],
|
||||||
|
cpu: top[0],
|
||||||
|
per_core: top[1],
|
||||||
|
gpu: Some(gpu),
|
||||||
|
mem: mem_lr[0],
|
||||||
|
swap: swap_lr[0],
|
||||||
|
disks: Some(left_stack[0]),
|
||||||
|
download: left_stack[1],
|
||||||
|
upload: left_stack[2],
|
||||||
|
procs: bottom[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact(area: Rect, has_gpu: bool) -> AppLayout {
|
||||||
|
let gpu_h = if has_gpu { GAUGE_H } else { 0 };
|
||||||
|
let avail = area.height.saturating_sub(HEADER_H + gpu_h);
|
||||||
|
|
||||||
|
// Give the top row its floor first, then share any surplus with the bottom so the
|
||||||
|
// process table keeps growing with the window instead of staying pinned at 13 rows.
|
||||||
|
let (top_h, bottom_h) = if avail >= TOP_MIN_H + BOTTOM_PREF_H {
|
||||||
|
let top = TOP_MIN_H + (avail - TOP_MIN_H - BOTTOM_PREF_H) / 2;
|
||||||
|
(top, avail - top)
|
||||||
|
} else if avail >= TOP_MIN_H + BOTTOM_MIN_H {
|
||||||
|
(TOP_MIN_H, avail - TOP_MIN_H)
|
||||||
|
} else {
|
||||||
|
// Smaller than both floors: the network graphs are already at their minimum, so
|
||||||
|
// the top row takes what is left (panes clip below this point).
|
||||||
|
let bottom = BOTTOM_MIN_H.min(avail);
|
||||||
|
(avail - bottom, bottom)
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = split(
|
||||||
|
area,
|
||||||
|
Direction::Vertical,
|
||||||
|
&[
|
||||||
|
Constraint::Length(HEADER_H),
|
||||||
|
Constraint::Length(top_h),
|
||||||
|
Constraint::Length(gpu_h),
|
||||||
|
Constraint::Length(bottom_h),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let top = left_right(rows[1]);
|
||||||
|
|
||||||
|
let bottom = split(
|
||||||
|
rows[3],
|
||||||
|
Direction::Horizontal,
|
||||||
|
&[Constraint::Percentage(60), Constraint::Percentage(40)],
|
||||||
|
);
|
||||||
|
// Memory + Swap take the row Disks used to occupy; the graphs share what is left.
|
||||||
|
let left_stack = split(
|
||||||
|
bottom[0],
|
||||||
|
Direction::Vertical,
|
||||||
|
&[
|
||||||
|
Constraint::Length(GAUGE_H),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let gauges = split(
|
||||||
|
left_stack[0],
|
||||||
|
Direction::Horizontal,
|
||||||
|
&[Constraint::Percentage(50), Constraint::Percentage(50)],
|
||||||
|
);
|
||||||
|
|
||||||
|
AppLayout {
|
||||||
|
mode: LayoutMode::Compact,
|
||||||
|
header: rows[0],
|
||||||
|
cpu: top[0],
|
||||||
|
per_core: top[1],
|
||||||
|
gpu: has_gpu.then_some(rows[2]),
|
||||||
|
mem: gauges[0],
|
||||||
|
swap: gauges[1],
|
||||||
|
disks: None,
|
||||||
|
download: left_stack[1],
|
||||||
|
upload: left_stack[2],
|
||||||
|
procs: bottom[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn area(w: u16, h: u16) -> Rect {
|
||||||
|
Rect::new(0, 0, w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The height where the normal layout still fits a full disk card. Below it the CPU
|
||||||
|
/// panes are the ones that collapse, which is what compact mode exists to prevent.
|
||||||
|
#[test]
|
||||||
|
fn tall_window_stays_normal() {
|
||||||
|
let l = compute(area(120, 40), false, true);
|
||||||
|
assert_eq!(l.mode, LayoutMode::Normal);
|
||||||
|
assert!(l.disks.expect("disks pane").height >= DISKS_MIN_H);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_window_switches_to_compact() {
|
||||||
|
let l = compute(area(120, 24), false, true);
|
||||||
|
assert_eq!(l.mode, LayoutMode::Compact);
|
||||||
|
assert!(l.disks.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The switch happens exactly when Disks can no longer show one card, and never
|
||||||
|
/// oscillates: every height above the crossover is normal, every height below is
|
||||||
|
/// compact.
|
||||||
|
#[test]
|
||||||
|
fn mode_is_monotonic_in_height() {
|
||||||
|
let mut first_normal = None;
|
||||||
|
for h in 10..=60u16 {
|
||||||
|
let mode = compute(area(120, h), false, true).mode;
|
||||||
|
match (mode, first_normal) {
|
||||||
|
(LayoutMode::Normal, None) => first_normal = Some(h),
|
||||||
|
(LayoutMode::Compact, Some(prev)) => {
|
||||||
|
panic!("height {h} went back to compact after normal at {prev}")
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(first_normal.is_some(), "never reached the normal layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn force_compact_overrides_a_tall_window() {
|
||||||
|
let l = compute(area(200, 80), true, true);
|
||||||
|
assert_eq!(l.mode, LayoutMode::Compact);
|
||||||
|
assert!(l.disks.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compact_drops_the_gpu_row_without_a_gpu() {
|
||||||
|
let with = compute(area(120, 24), true, true);
|
||||||
|
let without = compute(area(120, 24), true, false);
|
||||||
|
assert!(with.gpu.is_some());
|
||||||
|
assert_eq!(with.gpu.expect("gpu strip").height, GAUGE_H);
|
||||||
|
assert!(without.gpu.is_none());
|
||||||
|
// The rows a GPU-less host saves are shared between the CPU panes and the
|
||||||
|
// bottom half, and none of them are left as a gap.
|
||||||
|
assert!(without.cpu.height > with.cpu.height);
|
||||||
|
assert!(without.procs.height > with.procs.height);
|
||||||
|
assert_eq!(without.procs.y + without.procs.height, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact exists to keep the CPU graph and per-core bars drawable: both need
|
||||||
|
/// content rows inside their borders.
|
||||||
|
#[test]
|
||||||
|
fn compact_keeps_the_cpu_panes_drawable() {
|
||||||
|
for h in 18..=32u16 {
|
||||||
|
let l = compute(area(120, h), false, true);
|
||||||
|
assert_eq!(l.mode, LayoutMode::Compact, "height {h}");
|
||||||
|
assert!(
|
||||||
|
l.cpu.height >= TOP_MIN_H,
|
||||||
|
"height {h}: cpu pane only {} rows",
|
||||||
|
l.cpu.height
|
||||||
|
);
|
||||||
|
assert_eq!(l.per_core.height, l.cpu.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression guard for the bug this mode fixes: at 18 rows the old fixed layout
|
||||||
|
/// left the top row with no drawable interior at all.
|
||||||
|
#[test]
|
||||||
|
fn compact_beats_the_fixed_layout_at_18_rows() {
|
||||||
|
let compact = compute(area(120, 18), false, true);
|
||||||
|
let fixed = normal(area(120, 18));
|
||||||
|
assert!(fixed.cpu.height <= 2, "fixed layout unexpectedly usable");
|
||||||
|
assert!(compact.cpu.height > fixed.cpu.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compact_panes_tile_the_area_without_gaps() {
|
||||||
|
for h in 16..=32u16 {
|
||||||
|
for has_gpu in [true, false] {
|
||||||
|
let l = compute(area(120, h), true, has_gpu);
|
||||||
|
assert_eq!(l.header.y, 0);
|
||||||
|
assert_eq!(l.cpu.y, l.header.y + l.header.height);
|
||||||
|
assert_eq!(l.per_core.x, l.cpu.x + l.cpu.width);
|
||||||
|
|
||||||
|
let after_cpu = l.cpu.y + l.cpu.height;
|
||||||
|
let bottom_y = match l.gpu {
|
||||||
|
Some(g) => {
|
||||||
|
assert_eq!(g.y, after_cpu);
|
||||||
|
assert_eq!(g.width, 120, "gpu strip spans the full width");
|
||||||
|
g.y + g.height
|
||||||
|
}
|
||||||
|
None => after_cpu,
|
||||||
|
};
|
||||||
|
assert_eq!(l.mem.y, bottom_y);
|
||||||
|
// Memory and Swap sit side by side on one row.
|
||||||
|
assert_eq!(l.swap.y, l.mem.y);
|
||||||
|
assert_eq!(l.swap.x, l.mem.x + l.mem.width);
|
||||||
|
assert_eq!(l.mem.height, GAUGE_H);
|
||||||
|
assert_eq!(l.download.y, l.mem.y + l.mem.height);
|
||||||
|
assert_eq!(l.upload.y, l.download.y + l.download.height);
|
||||||
|
assert_eq!(l.procs.y, bottom_y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A degenerate size must not panic or produce rects outside the frame.
|
||||||
|
#[test]
|
||||||
|
fn tiny_windows_stay_inside_the_frame() {
|
||||||
|
for h in 0..=16u16 {
|
||||||
|
for w in [0u16, 1, 20, 80] {
|
||||||
|
let l = compute(area(w, h), false, true);
|
||||||
|
for r in [l.header, l.cpu, l.per_core, l.mem, l.swap, l.procs] {
|
||||||
|
assert!(r.y + r.height <= h, "{r:?} escapes height {h}");
|
||||||
|
assert!(r.x + r.width <= w, "{r:?} escapes width {w}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ pub mod cpu;
|
|||||||
pub mod disks;
|
pub mod disks;
|
||||||
pub mod gpu;
|
pub mod gpu;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
|
pub mod layout;
|
||||||
pub mod mem;
|
pub mod mem;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
pub mod modal_connection;
|
pub mod modal_connection;
|
||||||
|
|||||||
@@ -73,3 +73,36 @@ fn test_tlc_ca_arg_long_and_short_parsed() {
|
|||||||
);
|
);
|
||||||
assert!(text3.contains("Usage:"));
|
assert!(text3.contains("Usage:"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compact_flag_documented_and_accepted() {
|
||||||
|
let exe = env!("CARGO_BIN_EXE_socktop");
|
||||||
|
let out = Command::new(exe)
|
||||||
|
.args(["--compact", "--help"])
|
||||||
|
.output()
|
||||||
|
.expect("run socktop --compact --help");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"socktop --compact --help did not succeed"
|
||||||
|
);
|
||||||
|
let text = format!(
|
||||||
|
"{}{}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("--compact"),
|
||||||
|
"help text missing --compact\n{text}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The flag must not be mistaken for the positional URL argument.
|
||||||
|
let out2 = Command::new(exe)
|
||||||
|
.args(["--compact", "--dry-run", "ws://127.0.0.1:3000/ws"])
|
||||||
|
.output()
|
||||||
|
.expect("run socktop --compact --dry-run");
|
||||||
|
assert!(
|
||||||
|
out2.status.success(),
|
||||||
|
"socktop --compact with a URL was rejected: {}",
|
||||||
|
String::from_utf8_lossy(&out2.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user