macnetmon 0.1.4

Network interface bandwidth monitor for macOS
use std::collections::{HashMap, HashSet};
use std::time::Instant;

use ratatui::style::{Color, Style};

use crate::args::Args;
use crate::config::Config;
use crate::constants::{HISTORY_LEN, INTERVAL_STEP_MS, MAX_INTERVAL_MS, MIN_INTERVAL_MS};
use crate::model::{DisplaySettings, Group, SortMode};
use crate::net::{
    get_hostname, is_physical_interface, is_up, load_interface_aliases, sample_interfaces,
    InterfaceSample,
};
use crate::theme::{build_themes, solid_tx_color, BorderColors, Theme, SOLID_THEMES};

#[derive(Clone, Debug)]
pub struct IfaceState {
    pub name: String,
    pub rx_bytes: u64,
    pub tx_bytes: u64,
    pub rx_rate: f64,
    pub tx_rate: f64,
    pub total_rate: f64,
    pub flags: u32,
    pub is_loopback: bool,
    pub rx_history: Vec<u64>,
    pub tx_history: Vec<u64>,
    pub last_active_sample: u64,
    pub initialized: bool,
}

impl IfaceState {
    fn new(sample: &InterfaceSample) -> Self {
        Self {
            name: sample.name.clone(),
            rx_bytes: sample.rx_bytes,
            tx_bytes: sample.tx_bytes,
            rx_rate: 0.0,
            tx_rate: 0.0,
            total_rate: 0.0,
            flags: sample.flags,
            is_loopback: sample.is_loopback,
            rx_history: Vec::with_capacity(HISTORY_LEN),
            tx_history: Vec::with_capacity(HISTORY_LEN),
            last_active_sample: 0,
            initialized: false,
        }
    }
}

pub struct App {
    pub states: HashMap<String, IfaceState>,
    pub last_sample: Instant,
    pub sample_index: u64,
    pub hostname: String,
    pub display: DisplaySettings,
    pub theme_index: usize,
    pub themes: Vec<Theme>,
    pub sort_mode: SortMode,
    pub interval_ms: u64,
    pub last_error: Option<String>,
    pub name_error: Option<String>,
    pub aliases: HashMap<String, String>,
    pub visible_physical: HashSet<String>,
    pub visible_virtual: HashSet<String>,
    pub total_rx: f64,
    pub total_tx: f64,
    pub total_rx_history: Vec<u64>,
    pub total_tx_history: Vec<u64>,
}

impl App {
    pub fn new(args: &Args) -> Self {
        let cfg = Config::load();
        let mut app = Self {
            states: HashMap::new(),
            last_sample: Instant::now(),
            sample_index: 0,
            hostname: get_hostname().unwrap_or_else(|| "unknown".to_string()),
            display: cfg.display,
            theme_index: 0,
            themes: build_themes(),
            sort_mode: cfg.sort_mode,
            interval_ms: cfg.interval_ms,
            last_error: None,
            name_error: None,
            aliases: HashMap::new(),
            visible_physical: HashSet::new(),
            visible_virtual: HashSet::new(),
            total_rx: 0.0,
            total_tx: 0.0,
            total_rx_history: Vec::with_capacity(HISTORY_LEN),
            total_tx_history: Vec::with_capacity(HISTORY_LEN),
        };
        if let Some(idx) = app.themes.iter().position(|t| t.name == cfg.theme) {
            app.theme_index = idx;
        }
        if args.hide_loopback {
            app.display.show_loopback = false;
        }
        if args.hide_virtual {
            app.display.show_virtual = false;
        }
        if args.show_inactive {
            app.display.show_inactive = true;
        }
        if args.bits {
            app.display.show_bits = true;
        }
        if let Some(interval) = args.interval {
            app.interval_ms = interval;
        }
        app.interval_ms = app.interval_ms.clamp(MIN_INTERVAL_MS, MAX_INTERVAL_MS);
        app.save_config();
        app.refresh_aliases();
        app
    }

    pub fn update(&mut self) {
        let now = Instant::now();
        let dt = now
            .duration_since(self.last_sample)
            .as_secs_f64()
            .max(0.001);
        self.last_sample = now;
        self.sample_index = self.sample_index.saturating_add(1);

        match sample_interfaces() {
            Ok(samples) => {
                self.last_error = None;
                let mut seen = HashSet::new();

                for sample in samples {
                    if !is_up(sample.flags) {
                        continue;
                    }

                    seen.insert(sample.name.clone());

                    let entry = self
                        .states
                        .entry(sample.name.clone())
                        .or_insert_with(|| IfaceState::new(&sample));

                    if entry.initialized {
                        let rx_delta = sample.rx_bytes.saturating_sub(entry.rx_bytes);
                        let tx_delta = sample.tx_bytes.saturating_sub(entry.tx_bytes);
                        entry.rx_rate = rx_delta as f64 / dt;
                        entry.tx_rate = tx_delta as f64 / dt;
                        entry.total_rate = entry.rx_rate + entry.tx_rate;
                    } else {
                        entry.rx_rate = 0.0;
                        entry.tx_rate = 0.0;
                        entry.total_rate = 0.0;
                    }

                    entry.rx_bytes = sample.rx_bytes;
                    entry.tx_bytes = sample.tx_bytes;
                    entry.flags = sample.flags;
                    entry.is_loopback = sample.is_loopback;
                    entry.initialized = true;

                    if entry.total_rate >= 1.0 {
                        entry.last_active_sample = self.sample_index;
                        if is_physical_interface(&entry.name) {
                            self.visible_physical.insert(entry.name.clone());
                        } else {
                            self.visible_virtual.insert(entry.name.clone());
                        }
                    }

                    entry
                        .rx_history
                        .insert(0, entry.rx_rate.round().max(0.0) as u64);
                    entry.rx_history.truncate(HISTORY_LEN);
                    entry
                        .tx_history
                        .insert(0, entry.tx_rate.round().max(0.0) as u64);
                    entry.tx_history.truncate(HISTORY_LEN);
                }

                self.states.retain(|name, _| seen.contains(name));
                self.visible_physical
                    .retain(|name| self.states.contains_key(name));
                self.visible_virtual
                    .retain(|name| self.states.contains_key(name));

                let mut total_rx = 0.0;
                let mut total_tx = 0.0;
                for iface in self.states.values() {
                    if !self.display.show_loopback && iface.is_loopback {
                        continue;
                    }
                    total_rx += iface.rx_rate;
                    total_tx += iface.tx_rate;
                }

                self.total_rx = total_rx;
                self.total_tx = total_tx;
                self.total_rx_history
                    .insert(0, total_rx.round().max(0.0) as u64);
                self.total_rx_history.truncate(HISTORY_LEN);
                self.total_tx_history
                    .insert(0, total_tx.round().max(0.0) as u64);
                self.total_tx_history.truncate(HISTORY_LEN);
            }
            Err(err) => {
                self.last_error = Some(err.to_string());
            }
        }
    }

    pub fn theme(&self) -> &Theme {
        &self.themes[self.theme_index % self.themes.len()]
    }

    pub fn theme_slot(&self) -> usize {
        self.theme_index % self.themes.len()
    }

    pub fn rx_color(&self) -> Color {
        self.theme().graph
    }

    pub fn tx_color(&self) -> Color {
        if self.is_advanced_theme() {
            self.theme().outer
        } else {
            solid_tx_color(self.theme().graph)
        }
    }

    pub fn save_config(&self) {
        let cfg = Config {
            theme: self.theme().name.to_string(),
            display: self.display,
            sort_mode: self.sort_mode,
            interval_ms: self.interval_ms,
        };
        cfg.save();
    }

    pub fn is_advanced_theme(&self) -> bool {
        self.theme_slot() >= SOLID_THEMES.len()
    }

    pub fn next_theme(&mut self) {
        self.theme_index = (self.theme_index + 1) % self.themes.len();
        self.save_config();
    }

    pub fn colors(&self) -> BorderColors {
        let t = self.theme();
        BorderColors {
            outer: t.outer,
            pane: t.pane,
            tile: t.graph,
        }
    }

    pub fn block_style(&self) -> Style {
        if self.is_advanced_theme() {
            Style::default().bg(self.theme().background)
        } else {
            Style::default()
        }
    }

    pub fn rx_style(&self) -> Style {
        let mut style = Style::default().fg(self.rx_color());
        if self.is_advanced_theme() {
            style = style.bg(self.theme().background);
        }
        style
    }

    pub fn tx_style(&self) -> Style {
        let mut style = Style::default().fg(self.tx_color());
        if self.is_advanced_theme() {
            style = style.bg(self.theme().background);
        }
        style
    }

    pub fn toggle_sort(&mut self) {
        self.sort_mode = match self.sort_mode {
            SortMode::Bandwidth => SortMode::Name,
            SortMode::Name => SortMode::Bandwidth,
        };
        self.save_config();
    }

    pub fn inc_interval(&mut self) {
        let next = self.interval_ms.saturating_add(INTERVAL_STEP_MS);
        self.interval_ms = next.min(MAX_INTERVAL_MS);
        self.save_config();
    }

    pub fn dec_interval(&mut self) {
        if self.interval_ms <= INTERVAL_STEP_MS {
            self.interval_ms = MIN_INTERVAL_MS;
            self.save_config();
            return;
        }
        let next = self.interval_ms.saturating_sub(INTERVAL_STEP_MS);
        self.interval_ms = next.max(MIN_INTERVAL_MS);
        self.save_config();
    }

    pub fn in_group(&self, iface: &IfaceState, group: Group) -> bool {
        match group {
            Group::Physical if !is_physical_interface(&iface.name) => return false,
            Group::Virtual if is_physical_interface(&iface.name) => return false,
            _ => {}
        }

        true
    }

    pub fn refresh_aliases(&mut self) {
        match load_interface_aliases() {
            Ok(map) => {
                self.aliases = map;
                self.name_error = None;
            }
            Err(err) => {
                self.name_error = Some(err.to_string());
            }
        }
    }
}