nodo_inspector 0.18.5

Telemetry terminal UI for NODO
// Copyright 2023 David Weikersdorfer

use clap::Parser;
use core::time::Duration;
use eyre::{Result, WrapErr};
use nodo_runtime::{
    proto::nodo as nodo_pb, ConfigureClient, ConfigureReply, ConfigureRequest, InspectorClient,
};
use ratatui::{
    crossterm::event::{self, KeyCode},
    layout::{Constraint, Layout},
    prelude::Direction,
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, TableState, Tabs},
};
use std::{cell::RefCell, sync::Arc, time::Instant};

mod configure;
mod connection_indicator;
mod help_view;
mod monitors_view;
mod nodo_app_tree;
mod schedule_view;
mod signals_view;
mod statistics;
mod style;

use configure::*;
use connection_indicator::*;
use help_view::*;
use monitors_view::*;
use nodo_app_tree::*;
use schedule_view::*;
use signals_view::*;
use statistics::*;

pub const PAGE_SCROLL_STEP: usize = 16;

#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Cli {
    #[arg(long, default_value = "localhost")]
    host: String,

    #[arg(long)]
    disable_tui: bool,
}

fn main() -> Result<()> {
    env_logger::init();

    let cli = Cli::parse();

    let mut terminal = (!cli.disable_tui).then(|| ratatui::init());

    let model = Arc::new(RefCell::new(InspectorModel::new(&cli.host)?));

    let mut stats_ctrl = StatisticsController {
        model: model.clone(),
    };

    let stats_view = StatisticsView {
        model: model.clone(),
    };

    let mut config_ctrl = ConfigureController {
        model: model.clone(),
    };

    let config_view = ConfigureView {
        model: model.clone(),
    };

    let mut signals_ctrl = SignalsController {
        model: model.clone(),
    };

    let signals_view = SignalsView {
        model: model.clone(),
    };

    let mut monitors_ctrl = MonitorsController {
        model: model.clone(),
    };

    let monitors_view = MonitorsView {
        model: model.clone(),
    };

    let mut schedules_ctrl = ScheduleController {
        model: model.clone(),
    };

    let schedule_view = ScheduleView {
        model: model.clone(),
    };

    let help_view = HelpView {
        model: model.clone(),
    };

    let mut selected_tab: usize = 0;

    const TAB_COUNT: usize = 6;

    const TAB_TITLES: [&str; TAB_COUNT] = [
        "Statistics",
        "Config",
        "Signals",
        "Monitors",
        "Schedules",
        "Help",
    ];

    loop {
        let maybe_error = {
            let mut model = model.borrow_mut();
            if let Err(err) = model.on_step() {
                model.error.set(format!("{err:?}"));
            }
            model.error.get().map(|s| s.clone())
        };

        if let Some(terminal) = terminal.as_mut() {
            terminal.draw(|f| {
                let chunks = Layout::default()
                    .direction(Direction::Vertical)
                    .constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
                    .split(f.area());

                {
                    let titles: Vec<_> = TAB_TITLES.iter().cloned().map(Line::from).collect();

                    let model = model.borrow();

                    let tabs = Tabs::new(titles)
                        .block(
                            Block::default()
                                .borders(Borders::ALL)
                                .title(Line::from(vec![
                                    Span::styled(
                                        " NODO INSPECTOR",
                                        Style::default()
                                            .fg(Color::White)
                                            .add_modifier(Modifier::BOLD),
                                    ),
                                    Span::from(" ── "),
                                    model.connection_indicator.span(),
                                    Span::from(" ("),
                                    if model.connection_indicator.is_connected() {
                                        Span::styled(&model.host, Color::Green)
                                    } else {
                                        Span::styled(&model.host, Color::DarkGray)
                                    },
                                    Span::from(")"),
                                    if model.connection_indicator.is_connected() {
                                        Span::styled(
                                            format!(" [{:.0} kB/s]", model.datarate / 1024.0),
                                            Style::default().fg(Color::White),
                                        )
                                    } else {
                                        Span::default()
                                    },
                                    Span::from(" ── Press 'h' for help  ──"),
                                    {
                                        match maybe_error {
                                            Some(err) => Span::styled(
                                                format!("Error: {err}"),
                                                Style::default()
                                                    .fg(Color::Red)
                                                    .add_modifier(Modifier::REVERSED),
                                            ),
                                            None => Span::from(""),
                                        }
                                    },
                                ])),
                        )
                        .style(Color::Yellow)
                        .highlight_style(
                            Style::default()
                                .fg(Color::Yellow)
                                .add_modifier(Modifier::REVERSED),
                        )
                        .select(selected_tab);

                    f.render_widget(tabs, chunks[0]);
                }

                match selected_tab {
                    0 => f.render_widget(&stats_view, chunks[1]),
                    1 => f.render_widget(&config_view, chunks[1]),
                    2 => f.render_widget(&signals_view, chunks[1]),
                    3 => f.render_widget(&monitors_view, chunks[1]),
                    4 => f.render_widget(&schedule_view, chunks[1]),
                    5 => f.render_widget(&help_view, chunks[1]),
                    TAB_COUNT.. => unreachable!("invalid tab: {selected_tab}"),
                }
            })?;

            if event::poll(Duration::from_millis(50))? {
                match event::read()? {
                    event::Event::Key(key) => match key.code {
                        KeyCode::Char('q') => break,
                        KeyCode::Char('h') => selected_tab = TAB_COUNT - 1,
                        KeyCode::Left => {
                            selected_tab = selected_tab.wrapping_sub(1).min(TAB_COUNT - 1)
                        }
                        KeyCode::Right => {
                            selected_tab = {
                                let n = selected_tab + 1;
                                if n == TAB_COUNT {
                                    0
                                } else {
                                    n
                                }
                            }
                        }
                        key => match selected_tab {
                            0 => stats_ctrl.on_key(key),
                            1 => config_ctrl.on_key(key),
                            2 => signals_ctrl.on_key(key),
                            3 => monitors_ctrl.on_key(key),
                            4 => schedules_ctrl.on_key(key),
                            5 => {}
                            TAB_COUNT.. => unreachable!("invalid tab: {selected_tab}"),
                        },
                    },
                    _ => {}
                }
            }
        }
    }

    ratatui::restore();

    Ok(())
}

pub struct InspectorModel {
    host: String,
    inspector_client: InspectorClient,
    configure_client: ConfigureClient,

    connection_indicator: ConnectionIndicator,

    tree: NodoAppTree,

    datarate: f64,
    report: Option<nodo_pb::Report>,
    report_time: Option<Instant>,

    report_table_state: TableState,
    config_table_state: TableState,
    signals_table_state: TableState,
    monitors_table_state: TableState,
    schedule_table_state: TableState,

    error: TimeoutValue<String>,
}

pub struct TimeoutValue<T> {
    value: Option<T>,
    timeout: Duration,
    invalidation: Instant,
}

impl<T> TimeoutValue<T> {
    pub fn new(timeout: Duration) -> Self {
        Self {
            value: None,
            timeout,
            invalidation: Instant::now(),
        }
    }

    pub fn set(&mut self, value: T) {
        self.value = Some(value);
        self.invalidation = Instant::now() + self.timeout;
    }

    pub fn get(&mut self) -> Option<&T> {
        let now = Instant::now();
        if now > self.invalidation {
            self.value = None;
        }
        self.value.as_ref()
    }
}

impl InspectorModel {
    pub fn new(host: &str) -> Result<Self> {
        let inspector_client = InspectorClient::dial(&format!("tcp://{}:54399", host))?;
        let configure_client = ConfigureClient::dial(&format!("tcp://{}:54398", host))?;

        Ok(Self {
            host: host.into(),
            inspector_client,
            configure_client,
            connection_indicator: Default::default(),
            tree: Default::default(),
            datarate: Default::default(),
            report: Default::default(),
            report_time: Default::default(),
            report_table_state: Default::default(),
            config_table_state: Default::default(),
            signals_table_state: Default::default(),
            monitors_table_state: Default::default(),
            schedule_table_state: Default::default(),
            error: TimeoutValue::new(Duration::from_secs(3)),
        })
    }

    pub fn on_step(&mut self) -> Result<()> {
        self.update_config().context("failed to update config")?;
        self.update_report().context("failed to update report")?;

        if self
            .report_time
            .map_or(false, |last| (Instant::now() - last).as_secs_f32() < 1.0)
        {
            self.connection_indicator.on_connect();
        } else {
            self.connection_indicator.on_connecting();
        };

        self.connection_indicator.on_step();

        Ok(())
    }

    fn update_config(&mut self) -> Result<()> {
        if let Ok(reply) = self.configure_client.send_request(&ConfigureRequest::List) {
            match reply {
                ConfigureReply::List(list) => self.tree.update_config_parameters(list),
                _ => {}
            }
        }

        Ok(())
    }

    fn update_report(&mut self) -> Result<()> {
        if let Some(report) = self.inspector_client.try_recv_report()? {
            self.tree.update(report.clone())?;
            self.report = Some(report);
            self.report_time = Some(Instant::now());
        }

        self.datarate = self.inspector_client.datarate();

        Ok(())
    }
}