use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::Render;
use super::filter_bar;
use super::sanitize::scrub_ctrl;
use super::widgets::columns::{Align, Column, PRIO_ESSENTIAL, PRIO_HIGH, PRIO_LOW, PRIO_MEDIUM};
use super::widgets::empty::{self, EmptyState};
use super::widgets::table::{self, Cell, Row, Spec};
use super::widgets::{badge, meter, spark};
use crate::app::NetworkSortField;
use crate::ui::theme::Level;
use muxtop_core::network::NetworkInterfaceSnapshot;
use muxtop_core::process::SortOrder;
const COL_NAME: usize = 0;
const COL_RX: usize = 2;
const COL_TX: usize = 3;
const COL_TOTAL_RX: usize = 4;
const COL_TOTAL_TX: usize = 5;
const COL_ERRORS: usize = 6;
const COLUMNS: &[Column] = &[
Column::flex("INTERFACE", 12, PRIO_ESSENTIAL),
Column::fixed("S", 2, Align::Left, PRIO_LOW),
Column::fixed("RX/s", 12, Align::Right, PRIO_ESSENTIAL),
Column::fixed("TX/s", 12, Align::Right, PRIO_ESSENTIAL),
Column::fixed("TOTAL RX", 10, Align::Right, PRIO_MEDIUM),
Column::fixed("TOTAL TX", 10, Align::Right, PRIO_MEDIUM),
Column::fixed("ERR", 7, Align::Right, PRIO_HIGH),
];
const GRAPH_HEIGHT: u16 = 4;
pub fn draw_network_tab(frame: &mut Frame, area: Rect, r: &Render<'_>) {
let app = r.app;
if app.last_snapshot.is_none() {
let waiting = r.ellipsis("Waiting for data");
empty::render(frame, area, &EmptyState::waiting(&waiting), r.theme);
return;
}
let interfaces = sorted_interfaces(r);
let filter_h = u16::from(app.filter_editing());
let graph_h = if !interfaces.is_empty() && area.height >= 12 {
GRAPH_HEIGHT
} else {
0
};
let [summary_area, table_area, graph_area, filter_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(graph_h),
Constraint::Length(filter_h),
])
.areas(area);
draw_summary(frame, summary_area, r, &interfaces);
let filtered = !app.net_filter_input.is_empty();
let spec = Spec {
columns: COLUMNS,
sort_col: sort_column(app.net_sort_field),
descending: matches!(app.net_sort_order, SortOrder::Desc),
total: interfaces.len(),
selected: app.net_selected,
scroll: app.net_scroll_offset,
col_scroll: app.col_scroll,
empty: if filtered {
EmptyState::no_match("No matching interfaces")
} else {
EmptyState::empty(
"No network interfaces",
Some("Nothing is reporting traffic."),
)
},
};
table::draw(frame, table_area, r, &spec, |idx| {
match interfaces.get(idx) {
Some(iface) => interface_row(iface, r),
None => Row::new(Vec::new()),
}
});
if graph_h > 0 {
draw_graph(frame, graph_area, r, &interfaces);
}
if filter_h > 0 {
filter_bar::draw(frame, filter_area, r, "Filter interfaces");
}
}
fn sort_column(field: NetworkSortField) -> Option<usize> {
Some(match field {
NetworkSortField::Name => COL_NAME,
NetworkSortField::RxRate => COL_RX,
NetworkSortField::TxRate => COL_TX,
NetworkSortField::TotalRx => COL_TOTAL_RX,
NetworkSortField::TotalTx => COL_TOTAL_TX,
NetworkSortField::Errors => COL_ERRORS,
})
}
fn sorted_interfaces(r: &Render<'_>) -> Vec<NetworkInterfaceSnapshot> {
let app = r.app;
let mut rows = app.visible_interfaces();
let history = &app.network_history;
match app.net_sort_field {
NetworkSortField::Name => rows.sort_by(|a, b| a.name.cmp(&b.name)),
NetworkSortField::RxRate => rows.sort_by(|a, b| {
history
.bandwidth_rx(&b.name)
.partial_cmp(&history.bandwidth_rx(&a.name))
.unwrap_or(std::cmp::Ordering::Equal)
}),
NetworkSortField::TxRate => rows.sort_by(|a, b| {
history
.bandwidth_tx(&b.name)
.partial_cmp(&history.bandwidth_tx(&a.name))
.unwrap_or(std::cmp::Ordering::Equal)
}),
NetworkSortField::TotalRx => rows.sort_by_key(|i| std::cmp::Reverse(i.bytes_rx)),
NetworkSortField::TotalTx => rows.sort_by_key(|i| std::cmp::Reverse(i.bytes_tx)),
NetworkSortField::Errors => {
rows.sort_by_key(|i| std::cmp::Reverse(i.errors_rx + i.errors_tx))
}
}
let ascending = matches!(app.net_sort_order, SortOrder::Asc);
let natural_ascending = matches!(app.net_sort_field, NetworkSortField::Name);
if ascending != natural_ascending {
rows.reverse();
}
rows
}
fn interface_row(iface: &NetworkInterfaceSnapshot, r: &Render<'_>) -> Row {
let history = &r.app.network_history;
let rx = history.bandwidth_rx(&iface.name) as u64;
let tx = history.bandwidth_tx(&iface.name) as u64;
let errors = iface.errors_rx + iface.errors_tx;
let level = if iface.is_up {
Level::Success
} else {
Level::Neutral
};
Row::new(vec![
Cell::new(scrub_ctrl(&iface.name).into_owned()),
Cell::colored(badge::marker(level, r.glyphs), r.theme.level_color(level)),
Cell::new(format!("{} {}", r.glyphs.arrow_down, meter::human_rate(rx))),
Cell::new(format!("{} {}", r.glyphs.arrow_up, meter::human_rate(tx))),
Cell::new(meter::human_bytes(iface.bytes_rx)),
Cell::new(meter::human_bytes(iface.bytes_tx)),
if errors > 0 {
Cell::colored(errors.to_string(), r.theme.danger)
} else {
Cell::new("0")
},
])
}
fn draw_summary(
frame: &mut Frame,
area: Rect,
r: &Render<'_>,
interfaces: &[NetworkInterfaceSnapshot],
) {
let history = &r.app.network_history;
let rx: f64 = interfaces
.iter()
.map(|i| history.bandwidth_rx(&i.name))
.sum();
let tx: f64 = interfaces
.iter()
.map(|i| history.bandwidth_tx(&i.name))
.sum();
let up = interfaces.iter().filter(|i| i.is_up).count();
let line = Line::from(vec![
Span::styled(" Network ", r.theme.accent_fill()),
Span::styled(
format!(" Interfaces {up}/{}", interfaces.len()),
r.theme.dim(),
),
Span::styled(
format!(
" Total {} {} {} {}",
r.glyphs.arrow_down,
meter::human_rate(rx as u64),
r.glyphs.arrow_up,
meter::human_rate(tx as u64)
),
r.theme.body(),
),
]);
frame.render_widget(Paragraph::new(line), area);
}
fn draw_graph(
frame: &mut Frame,
area: Rect,
r: &Render<'_>,
interfaces: &[NetworkInterfaceSnapshot],
) {
let Some(iface) = interfaces.get(r.app.net_selected) else {
return;
};
let width = area.width.saturating_sub(14);
if width == 0 {
return;
}
let history = &r.app.network_history;
let rx = history.sparkline_rx(&iface.name, width as usize);
let tx = history.sparkline_tx(&iface.name, width as usize);
let scale = rx
.iter()
.chain(tx.iter())
.copied()
.max()
.unwrap_or(1)
.max(1);
let mut lines = vec![Line::from(Span::styled(
format!(" {} ", scrub_ctrl(&iface.name)),
r.theme.accent(),
))];
for (arrow, data, color) in [
(r.glyphs.arrow_down, rx, r.theme.success),
(r.glyphs.arrow_up, tx, r.theme.info),
] {
let mut spans = vec![Span::styled(format!(" {arrow} "), r.theme.dim())];
spans.extend(
spark::line(
&data,
width,
Some(scale),
spark::Tint::Flat,
color,
r.theme,
r.glyphs,
)
.spans,
);
lines.push(Line::from(spans));
}
lines.push(Line::from(Span::styled(
format!(" peak {}", meter::human_rate(scale)),
r.theme.subtle(),
)));
frame.render_widget(Paragraph::new(lines), area);
}
#[cfg(test)]
mod tests {
use crate::app::{AppState, Tab};
use crate::ui::test_support::*;
fn app() -> AppState {
let mut app = app_with_data();
app.tab = Tab::Network;
app
}
#[test]
fn table_lists_interfaces_with_rates() {
let text = all_text(&render_with(&app(), 120, 24));
assert!(text.contains("INTERFACE"));
assert!(text.contains("RX/s"));
assert!(text.contains("TX/s"));
assert!(text.contains("eth0"));
assert!(text.contains("lo"));
}
#[test]
fn summary_shows_totals_and_interface_count() {
let text = all_text(&render_with(&app(), 120, 24));
assert!(text.contains("Interfaces"));
assert!(text.contains("Total"));
}
#[test]
fn graph_shows_the_selected_interface() {
let text = all_text(&render_with(&app(), 120, 30));
assert!(text.contains("peak"), "traffic graph missing:\n{text}");
}
#[test]
fn graph_is_dropped_on_short_terminals_rather_than_squashed() {
let text = all_text(&render_with(&app(), 120, 11));
assert!(!text.contains("peak"));
assert!(text.contains("eth0"));
}
#[test]
fn errors_are_shown() {
let mut snap = snapshot();
snap.networks.interfaces[0].errors_rx = 12;
let mut app = AppState::new();
app.tab = Tab::Network;
app.apply_snapshot(snap);
let text = all_text(&render_with(&app, 120, 24));
assert!(text.contains("12"));
}
#[test]
fn empty_filter_result_explains_itself() {
let mut app = app();
app.set_filter("zzzz");
let text = all_text(&render_with(&app, 100, 24));
assert!(text.contains("No matching interfaces"));
}
#[test]
fn renders_under_every_profile_and_size() {
let mut app = app();
for (w, h) in [(1u16, 1u16), (40, 8), (80, 24), (200, 50)] {
let _ = render_with(&app, w, h);
}
for (color, unicode) in all_profiles() {
let _ = render_caps(&mut app, 100, 30, color, unicode);
}
}
}