Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/netscli-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ async fn main() -> Result<()> {
.unwrap_or_else(|| tui_settings::load_settings().max_concurrent_probes),
..Default::default()
});
let local_addr = netscli_core::detect_default_ipv4_addr().map(|ip| ip.to_string());
// Route enumeration is a blocking syscall; this runs inside the async
// main, so it delayed every startup path behind it (B-10).
let local_addr = tokio::task::spawn_blocking(netscli_core::detect_default_ipv4_addr)
.await
.ok()
.flatten()
.map(|ip| ip.to_string());

if let Some(command) = &cli.command {
cli_dispatch::run_command(
Expand Down
7 changes: 5 additions & 2 deletions apps/netscli-cli/src/tui/events/pcap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@ pub(super) async fn handle(
}

if check || interface.is_none() {
match ops.pcap_check_support() {
// Device enumeration is a blocking syscall on every platform, and
// this runs on a tokio worker (B-10). `block_in_place` rather than
// `spawn_blocking` because `ops` is a borrow, not `'static`.
match tokio::task::block_in_place(|| ops.pcap_check_support()) {
Ok(devs) => {
out.extend(Formatter::format_pcap_interfaces(&devs));
if !check && interface.is_none() {
Expand Down Expand Up @@ -152,7 +155,7 @@ pub(super) async fn handle(
}

let iface = interface.unwrap_or_else(|| "unknown".to_string());
match ops.pcap_check_support() {
match tokio::task::block_in_place(|| ops.pcap_check_support()) {
// Deliberately no name check here (B-12).
//
// This used to require exact string equality against a device name,
Expand Down
9 changes: 8 additions & 1 deletion apps/netscli-cli/src/tui/events/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,14 @@ pub(super) async fn handle_scan(
commands::db_add_scan_history_safe(db, "scan", 0, &res).await;
}

let arp = netscli_core::NetworkManager::find_mac(&ip);
// `find_mac` shells out to `arp` on Windows and macOS, so
// calling it inline blocked a tokio worker for the lifetime
// of a subprocess (B-10).
let arp = tokio::task::spawn_blocking(move || {
netscli_core::NetworkManager::find_mac(&ip)
})
.await
.unwrap_or(None);
let mac = arp.as_ref().map(|e| e.mac.to_string());
let vendor = arp.and_then(|e| e.vendor);
let hostname = netscli_core::dns::reverse_lookup_best_effort_timeout(
Expand Down
24 changes: 20 additions & 4 deletions apps/netscli-cli/src/tui/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ pub async fn run_tui(concurrency: Option<usize>) -> Result<()> {
input.refresh_exit_confirmation(&mut app);
tasks.refresh_running_detail(&mut app);

// Sample traffic here rather than from the draw path: `get_stats`
// holds a mutex across a syscall, and drawing is synchronous so it
// cannot yield (B-10). `block_in_place` keeps the borrow.
tokio::task::block_in_place(|| app.refresh_traffic_stats());

app.draw(&mut terminal)?;
if app.running {
app.suggestions.clear();
Expand All @@ -37,11 +42,22 @@ pub async fn run_tui(concurrency: Option<usize>) -> Result<()> {

tasks.finish_ready_task(&mut app).await;

if !event::poll(tick_rate)? {
continue;
}
// `event::poll` parks the calling thread for up to `tick_rate`, and
// this loop runs on a tokio worker — so every iteration blocked a
// worker for 100ms whether or not a key arrived (B-10). Both the poll
// and the read move to a blocking thread; they stay together so the
// read cannot race another poller.
let polled = tokio::task::spawn_blocking(move || -> std::io::Result<Option<Event>> {
if event::poll(tick_rate)? {
Ok(Some(event::read()?))
} else {
Ok(None)
}
})
.await
.map_err(|e| std::io::Error::other(e.to_string()))??;

let Event::Key(key) = event::read()? else {
let Some(Event::Key(key)) = polled else {
continue;
};

Expand Down
25 changes: 22 additions & 3 deletions apps/netscli-cli/src/tui/state/config_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,25 @@ impl<'a> TuiApp<'a> {
}

pub fn exit_config(&mut self) {
// Every close path funnels through here (Esc, Done, /config toggle),
// so this is where the single write belongs. Cycling a value used to
// do a synchronous fs::write + fs::rename per key event, on the event
// loop thread, which under key-repeat meant one full file rewrite per
// repeat tick (B-11).
self.flush_settings_if_dirty();
self.ui_mode = UiMode::Normal;
self.scroll_to_bottom();
}

/// Persist settings only if something actually changed.
fn flush_settings_if_dirty(&mut self) -> Option<String> {
if !self.settings_dirty {
return None;
}
self.settings_dirty = false;
self.config_save_settings()
}

pub fn config_next(&mut self) {
let Some(state) = self.config_state_mut() else {
return;
Expand Down Expand Up @@ -111,9 +126,10 @@ impl<'a> TuiApp<'a> {
ConfigItemKind::Reset | ConfigItemKind::Done => {}
}

let msg = self.config_save_settings();
// Deferred to exit_config; see B-11 there.
self.settings_dirty = true;
if let Some(state) = self.config_state_mut() {
state.message = msg;
state.message = None;
}
}

Expand All @@ -140,7 +156,10 @@ impl<'a> TuiApp<'a> {
_ => false,
};

let msg = self.config_save_settings();
// Reset is a discrete, destructive action, so it is written through
// immediately rather than waiting for the panel to close.
self.settings_dirty = true;
let msg = self.flush_settings_if_dirty();
if let Some(state) = self.config_state_mut() {
state.message = msg;
}
Expand Down
5 changes: 3 additions & 2 deletions apps/netscli-cli/src/tui/state/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ pub struct TuiApp<'a> {
pub confirm_exit: bool,
pub running: bool,
pub running_detail: Option<String>,
pub spinner_idx: usize,
pub settings: TuiSettings,
pub concurrency_override: Option<usize>,
pub hostname: String,
Expand All @@ -56,6 +55,8 @@ pub struct TuiApp<'a> {
stats_download_active: bool,
input_scroll_x: usize,
ui_mode: UiMode,
/// Set when /config changes a value; cleared when it is written to disk.
settings_dirty: bool,
}

impl<'a> TuiApp<'a> {
Expand Down Expand Up @@ -87,7 +88,6 @@ impl<'a> TuiApp<'a> {
confirm_exit: false,
running: false,
running_detail: None,
spinner_idx: 0,
settings: TuiSettings::default(),
concurrency_override: None,
hostname,
Expand All @@ -101,6 +101,7 @@ impl<'a> TuiApp<'a> {
stats_download_active: false,
input_scroll_x: 0,
ui_mode: UiMode::Normal,
settings_dirty: false,
}
}

Expand Down
1 change: 1 addition & 0 deletions apps/netscli-cli/src/tui/state/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod content;
mod input;
mod message;
mod scrollbar;
mod stats;

impl<'a> TuiApp<'a> {
pub fn draw<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<(), B::Error> {
Expand Down
77 changes: 1 addition & 76 deletions apps/netscli-cli/src/tui/state/render/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ use super::super::super::widgets::{
value_style,
};
use super::super::{TuiApp, INPUT_PLACEHOLDER};
use crate::tui_settings::StatsUnit;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
style::Style,
text::{Line, Span},
widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap},
Frame,
Expand Down Expand Up @@ -258,78 +257,4 @@ impl<'a> TuiApp<'a> {
inner,
);
}

fn render_stats_lines(&mut self) -> (Line<'static>, Line<'static>) {
let stats = self.monitor.get_stats();
if stats.available {
self.stats_upload_mbps = stats.upload_mbps;
self.stats_download_mbps = stats.download_mbps;
self.stats_upload_active = stats.upload_active;
self.stats_download_active = stats.download_active;
} else {
self.stats_upload_mbps = 0.0;
self.stats_download_mbps = 0.0;
self.stats_upload_active = false;
self.stats_download_active = false;
}

let dot = " · ";
let unit: StatsUnit = self.settings.stats_unit;
let up = format!(
"{:.2}",
unit.scale_from_mbps(self.stats_upload_mbps).min(999.99)
);
let down = format!(
"{:.2}",
unit.scale_from_mbps(self.stats_download_mbps).min(999.99)
);
let unit_suffix = format!(" {}", unit.suffix());

let left = Line::from(vec![
Span::styled("host ", label_style()),
Span::styled(self.hostname.clone(), value_style()),
Span::styled(dot, label_style()),
Span::styled("ip ", label_style()),
Span::styled(
self.context_address
.clone()
.unwrap_or_else(|| "n/a".to_string()),
value_style(),
),
]);

let up_arrow_style = if self.stats_upload_active {
Style::default().fg(Color::Cyan)
} else {
label_style()
};
let down_arrow_style = if self.stats_download_active {
Style::default().fg(Color::Cyan)
} else {
label_style()
};
let number_style = value_style();

let right_spans = vec![
Span::styled("↑ ", up_arrow_style),
Span::styled(up, number_style),
Span::styled(unit_suffix.clone(), label_style()),
Span::styled(dot, label_style()),
Span::styled("↓ ", down_arrow_style),
Span::styled(down, number_style),
Span::styled(unit_suffix, label_style()),
];

(left, Line::from(right_spans))
}

pub(super) fn spinner_frame(&mut self) -> &'static str {
if !self.running {
return " ";
}
let frames = ["-", "\\", "|", "/"];
let ch = frames[self.spinner_idx % frames.len()];
self.spinner_idx = (self.spinner_idx + 1) % frames.len();
ch
}
}
109 changes: 109 additions & 0 deletions apps/netscli-cli/src/tui/state/render/stats.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//! Traffic-stats sampling and the stats line the input box renders under it.
//!
//! Split from `render/input.rs` to keep it under the maintainability cap; the
//! gate's own note named splitting the mode renderers as the next step and
//! this is the self-contained half.

use super::super::super::widgets::{label_style, value_style};
use super::super::TuiApp;
use crate::tui_settings::StatsUnit;
use ratatui::{
style::{Color, Style},
text::{Line, Span},
};

impl TuiApp<'_> {
/// Sample the traffic monitor and cache the result.
///
/// This used to happen inside `render_stats_lines`, i.e. from the draw
/// path — and `get_stats` holds a mutex across a `Networks::refresh`
/// syscall (B-10). Drawing is synchronous, so the fix is not
/// `spawn_blocking` but moving the sample to the async event loop, which
/// can yield around it. The draw path now only reads cached numbers.
pub(in crate::tui) fn refresh_traffic_stats(&mut self) {
let stats = self.monitor.get_stats();
if stats.available {
self.stats_upload_mbps = stats.upload_mbps;
self.stats_download_mbps = stats.download_mbps;
self.stats_upload_active = stats.upload_active;
self.stats_download_active = stats.download_active;
} else {
self.stats_upload_mbps = 0.0;
self.stats_download_mbps = 0.0;
self.stats_upload_active = false;
self.stats_download_active = false;
}
}

pub(super) fn render_stats_lines(&mut self) -> (Line<'static>, Line<'static>) {
let dot = " · ";
let unit: StatsUnit = self.settings.stats_unit;
let up = format!(
"{:.2}",
unit.scale_from_mbps(self.stats_upload_mbps).min(999.99)
);
let down = format!(
"{:.2}",
unit.scale_from_mbps(self.stats_download_mbps).min(999.99)
);
let unit_suffix = format!(" {}", unit.suffix());

let left = Line::from(vec![
Span::styled("host ", label_style()),
Span::styled(self.hostname.clone(), value_style()),
Span::styled(dot, label_style()),
Span::styled("ip ", label_style()),
Span::styled(
self.context_address
.clone()
.unwrap_or_else(|| "n/a".to_string()),
value_style(),
),
]);

let up_arrow_style = if self.stats_upload_active {
Style::default().fg(Color::Cyan)
} else {
label_style()
};
let down_arrow_style = if self.stats_download_active {
Style::default().fg(Color::Cyan)
} else {
label_style()
};
let number_style = value_style();

let right_spans = vec![
Span::styled("↑ ", up_arrow_style),
Span::styled(up, number_style),
Span::styled(unit_suffix.clone(), label_style()),
Span::styled(dot, label_style()),
Span::styled("↓ ", down_arrow_style),
Span::styled(down, number_style),
Span::styled(unit_suffix, label_style()),
];

(left, Line::from(right_spans))
}

/// Current spinner glyph, derived from wall time.
///
/// This used to advance an index every time it was called — from the draw
/// path (B-36). That coupled the spinner's speed to the frame rate, so it
/// span faster on a busy redraw and stalled when nothing else forced a
/// frame, and it made rendering mutate state. Wall time gives a constant
/// rate regardless of how often the screen is drawn, and lets this take
/// `&self`.
pub(super) fn spinner_frame(&self) -> &'static str {
if !self.running {
return " ";
}
const FRAMES: [&str; 4] = ["-", "\\", "|", "/"];
const FRAME_MS: u128 = 120;
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
FRAMES[((ms / FRAME_MS) as usize) % FRAMES.len()]
}
}
Loading
Loading