nodo_inspector 0.18.5

Telemetry terminal UI for NODO
// Copyright 2025 David Weikersdorfer

use crate::{
    style::*, AppTreeItem, InspectorModel, KeyCode, NodoAppTreeNode, TreeLocation,
    TreeLocationLevel, TreeLocationPos, PAGE_SCROLL_STEP,
};
use nodo::codelet::Transition;
use nodo_runtime::proto::nodo as nodo_pb;
use ratatui::{
    layout::{Constraint, Layout},
    prelude::{Alignment, Buffer, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span, Text},
    widgets::{Cell, Row, StatefulWidget, Table, Widget},
};
use regex::Regex;
use std::{cell::RefCell, cmp::Ordering, sync::Arc};

pub struct StatisticsController {
    pub model: Arc<RefCell<InspectorModel>>,
}

impl StatisticsController {
    pub fn on_key(&mut self, key: KeyCode) {
        match key {
            KeyCode::Down => self.select_next(),
            KeyCode::Up => self.select_previous(),
            KeyCode::PageDown => self.on_page_down(),
            KeyCode::PageUp => self.on_page_up(),
            KeyCode::Enter => self.toggle_expand(),
            _ => {}
        }
    }

    fn select_next(&mut self) {
        self.model.borrow_mut().report_table_state.select_next();
    }

    fn select_previous(&mut self) {
        self.model.borrow_mut().report_table_state.select_previous();
    }

    fn on_page_down(&mut self) {
        for _ in 0..PAGE_SCROLL_STEP {
            self.select_next()
        }
    }

    fn on_page_up(&mut self) {
        for _ in 0..PAGE_SCROLL_STEP {
            self.select_previous()
        }
    }

    fn toggle_expand(&mut self) {
        self.model.borrow_mut().tree.on_toggle_expand_selected();
    }
}

pub struct StatisticsView {
    pub model: Arc<RefCell<InspectorModel>>,
}

impl Widget for &StatisticsView {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let chunks = Layout::default()
            .constraints([Constraint::Percentage(100)].as_ref())
            .split(area);

        const BASE_LEN: usize = 70;

        let mut model = self.model.borrow_mut();

        fn tree_view_sort<'a>(a: &AppTreeItem<'a>, b: &AppTreeItem<'a>) -> Ordering {
            let card = |x| (x * 1000.) as i64;

            match (a, b) {
                (AppTreeItem::Worker(a), AppTreeItem::Worker(b)) => card(a.step_duration)
                    .cmp(&card(b.step_duration))
                    .then_with(|| a.name.cmp(&b.name)),
                (AppTreeItem::Sequence(a), AppTreeItem::Sequence(b)) => card(a.step_duration)
                    .cmp(&card(b.step_duration))
                    .then_with(|| a.name.cmp(&b.name)),
                (AppTreeItem::Node(a), AppTreeItem::Node(b)) => card(a.step_duration())
                    .cmp(&card(b.step_duration()))
                    .then_with(|| a.info.node_name.cmp(&b.info.node_name)),
                _ => unreachable!(),
            }
            // largest duration first
            .reverse()
        }

        fn tree_view_recurse<'a>(a: &AppTreeItem<'a>) -> bool {
            match a {
                AppTreeItem::Worker(a) => a.is_expanded,
                AppTreeItem::Sequence(a) => a.is_expanded,
                AppTreeItem::Node(_) => true,
            }
        }

        let tree_structure_f = |loc: &TreeLocation| -> &'static str {
            match loc.level {
                TreeLocationLevel::Node(0) => {
                    if loc.is_expanded {
                        "+ "
                    } else {
                        "- "
                    }
                }
                TreeLocationLevel::Node(1) => {
                    if loc.is_expanded {
                        "├─+ "
                    } else {
                        "├─- "
                    }
                }
                TreeLocationLevel::Leaf => match loc.pos {
                    TreeLocationPos::First | TreeLocationPos::Mid => "| ├─",
                    TreeLocationPos::Last => "| └─",
                },
                _ => unreachable!(),
            }
        };

        // Formats an node (schedule/sequence) entry for the tree
        let tree_node_f = |loc: &TreeLocation, name, step_duration| {
            Row::new(vec![
                Cell::from(Line::from(vec![
                    Span::from(tree_structure_f(loc)),
                    Span::styled(name, STYLE_COLOR_SCHEDULE_NAME),
                    Span::from(format!(" {}", "─".repeat(2 * BASE_LEN))),
                ])),
                Cell::from("─".repeat(4)),
                Cell::from("─".repeat(2 * BASE_LEN)),
                Cell::from("─".repeat(10)),
                Cell::from(align_right(
                    format_total_duration(step_duration, model.tree.step_duration, 0.35)
                        .patch_style(Style::default().add_modifier(Modifier::REVERSED)),
                )),
                Cell::from("─".repeat(10)),
                Cell::from("─".repeat(10)),
                Cell::from("─".repeat(10)),
                Cell::from("─".repeat(4 * BASE_LEN)),
            ])
        };

        // Formats an leaf entry (nodelet) for the tree
        let tree_leaf_f = |loc: &TreeLocation, node: &NodoAppTreeNode| {
            let transition = &node.statistics.transitions[Transition::Step.index()];

            Row::new(vec![
                Cell::from(Line::from(vec![
                    Span::from(tree_structure_f(loc)),
                    Span::styled(format!(" {}", node.info.node_name), STYLE_COLOR_NODE_NAME),
                ])),
                Cell::from(format_node_id(node.info.id)),
                Cell::from(format_status(&node.status)),
                Cell::from(align_right(format_skip_percent(transition))),
                Cell::from(align_right(format_total_duration(
                    transition.duration.unwrap().total,
                    model.tree.step_duration,
                    0.10,
                ))),
                Cell::from(align_right(format_step_duration(transition))),
                Cell::from(align_right(format_step_count(transition))),
                Cell::from(align_right(format_period(transition))),
                Cell::from(Text::from(format_typename(&node.info.typename))),
            ])
        };

        // iterate over tree in custom order
        let mut combined_rows: Vec<_> = Vec::new();
        for (loc, item) in model.tree.tree_iter(tree_view_sort, tree_view_recurse) {
            match item {
                AppTreeItem::Worker(sched) => {
                    combined_rows.push(tree_node_f(&loc, sched.name.clone(), sched.step_duration));
                }
                AppTreeItem::Sequence(seq) => {
                    combined_rows.push(tree_node_f(&loc, seq.name.clone(), seq.step_duration));
                }
                AppTreeItem::Node(node) => {
                    combined_rows.push(tree_leaf_f(&loc, node));
                }
            }
        }

        let selection = model.report_table_state.selected();
        model.tree.tree_entry_select(selection);

        // Create the combined table.
        let combined_table = Table::new(
            combined_rows,
            &[
                Constraint::Fill(2),    // Node name
                Constraint::Length(4),  // Node ID
                Constraint::Fill(2),    // Status label
                Constraint::Length(8),  // Skipped percent
                Constraint::Length(10), // Total duration
                Constraint::Length(10), // Step duration
                Constraint::Length(10), // Step count
                Constraint::Length(10), // Step period
                Constraint::Fill(4),    // Codelet typename
            ],
        )
        .header(
            Row::new(vec![
                "Codelet".into(),
                align_right("ID".into()),
                "Status".into(),
                align_right("Skip%".into()),
                align_right("Time".into()),
                align_right("Step".into()),
                align_right("Count".into()),
                align_right("Period".into()),
                "Type".into(),
            ])
            .style(
                Style::default()
                    .add_modifier(Modifier::BOLD)
                    .add_modifier(Modifier::REVERSED),
            ),
        )
        // .block(Block::default().borders(Borders::ALL))
        .row_highlight_style(Style::new().add_modifier(Modifier::REVERSED))
        .style(Color::Yellow);

        // Render the combined table.
        StatefulWidget::render(
            combined_table,
            chunks[0],
            buf,
            &mut model.report_table_state,
        );
    }
}

fn align_right(span: Span<'_>) -> Text<'_> {
    Text::from(span).alignment(Alignment::Right)
}

fn align_left(span: Span<'_>) -> Text<'_> {
    Text::from(span).alignment(Alignment::Left)
}

fn format_status(status: &nodo_pb::NodeStatus) -> Line<'static> {
    let lcs: nodo_pb::LifecycleStatus = status
        .lifecycle_status
        .try_into()
        .unwrap_or(nodo_pb::LifecycleStatus::Unspecified);

    let lcs_color = lifecycle_status_color(lcs);

    let mut spans = vec![Span::styled(format!("{:?}", lcs), lcs_color)];

    spans.push(Span::styled("[", lcs_color));

    let status_style = if status.is_skipped {
        Style::default().fg(STYLE_COLOR_WARN)
    } else {
        Style::default().fg(STYLE_COLOR_OK)
    };

    spans.push(Span::styled(status.label.clone(), status_style));

    spans.push(Span::styled("]", lcs_color));

    Line::from(spans)
}

pub(crate) fn lifecycle_status_color(lcs: nodo_pb::LifecycleStatus) -> Color {
    match lcs {
        nodo_pb::LifecycleStatus::Unspecified => STYLE_COLOR_ERR,
        nodo_pb::LifecycleStatus::Inactive => STYLE_COLOR_UNAVAILABLE,
        nodo_pb::LifecycleStatus::Starting => STYLE_COLOR_WARN,
        nodo_pb::LifecycleStatus::Running => STYLE_COLOR_OK,
        nodo_pb::LifecycleStatus::Pausing => STYLE_COLOR_WARN,
        nodo_pb::LifecycleStatus::Paused => STYLE_COLOR_WARN,
        nodo_pb::LifecycleStatus::Resuming => STYLE_COLOR_WARN,
        nodo_pb::LifecycleStatus::Stopping => STYLE_COLOR_WARN,
        nodo_pb::LifecycleStatus::Error => STYLE_COLOR_ERR,
    }
}

fn format_skip_percent(u: &nodo_pb::TransitionStatistics) -> Span<'static> {
    if u.skipped_count == 0 {
        Span::styled(format!("{:>6}", "None"), STYLE_COLOR_UNAVAILABLE)
    } else {
        let p = {
            let total = u.skipped_count + u.duration.unwrap().count;
            if total == 0 {
                0.
            } else {
                u.skipped_count as f32 / total as f32
            }
        };
        let color = if p < 0.8 {
            STYLE_COLOR_VALUE_DEFAULT
        } else if p < 0.9 {
            STYLE_COLOR_WARN
        } else {
            STYLE_COLOR_ERR
        };
        Span::styled(format!("{:>5.1}%", p * 100.), color)
    }
}

fn format_total_duration(x: f32, overall_total: f32, threshold: f32) -> Span<'static> {
    let p = x / overall_total;
    let color = if p > threshold {
        STYLE_COLOR_ERR
    } else if p > 0.1 * threshold {
        STYLE_COLOR_WARN
    } else {
        STYLE_COLOR_VALUE_DEFAULT
    };
    Span::styled(format!("{:>7.1}s", x), color)
}

fn average_ms(sample: &nodo_pb::StatisticsSamples) -> Option<f32> {
    (sample.count > 0).then(|| 1000.0 * sample.total / sample.count as f32)
}

fn format_step_duration(u: &nodo_pb::TransitionStatistics) -> Span<'static> {
    if let (Some(x), Some(period)) = (
        u.duration.as_ref().and_then(|d| average_ms(d)),
        u.period.as_ref().and_then(|d| average_ms(d)),
    ) {
        let p = x / period;
        let color = if p > 0.5 {
            STYLE_COLOR_ERR
        } else if p > 0.20 {
            STYLE_COLOR_WARN
        } else {
            STYLE_COLOR_VALUE_DEFAULT
        };
        Span::styled(format!("{:>5.1} ms", x), color)
    } else {
        Span::styled(format!("{:>8}", "None"), STYLE_COLOR_UNAVAILABLE)
    }
}

fn format_step_count(u: &nodo_pb::TransitionStatistics) -> Span<'static> {
    let x = u.duration.unwrap().count;
    Span::styled(format!("{:>8}", x), STYLE_COLOR_VALUE_DEFAULT)
}

fn format_period(u: &nodo_pb::TransitionStatistics) -> Span<'static> {
    if let Some(x) = average_ms(u.period.as_ref().unwrap()) {
        Span::styled(format!("{:>6.1} ms", x), STYLE_COLOR_VALUE_DEFAULT)
    } else {
        Span::styled(format!("{:>8}", "Never"), STYLE_COLOR_UNAVAILABLE)
    }
}

fn format_node_id(id: u32) -> Span<'static> {
    Span::styled(format!("{:>4}", id), STYLE_COLOR_TYPENAME)
}

/// Function to format a string as a `Span` with color formatting.
fn format_typename<'a>(input: &str) -> Line<'a> {
    // Define a regex to match the format [namespace::]typename[<generics>]
    let regex = Regex::new(r"(?P<namespace>(?:[a-zA-Z_][a-zA-Z0-9_]*::)+)?(?P<typename>[a-zA-Z_][a-zA-Z0-9_]*)?(?P<generics><.+>)?")
        .unwrap();

    // This will hold the formatted spans.
    let mut spans = Vec::new();

    // If the regex matches the input, split it into namespace, typename, and generics.
    if let Some(captures) = regex.captures(input) {
        // Extract namespace if present
        if let Some(namespace) = captures.name("namespace") {
            spans.push(Span::styled(
                namespace.as_str().to_string(),
                Style::default().fg(STYLE_COLOR_DISABLED),
            ));
        }

        // Extract typename
        if let Some(typename) = captures.name("typename") {
            spans.push(Span::styled(
                typename.as_str().to_string(),
                Style::default().fg(STYLE_COLOR_TYPENAME),
            ));
        }

        // Extract generics if present
        if let Some(generics) = captures.name("generics") {
            spans.push(Span::styled(
                generics.as_str().to_string(),
                Style::default().fg(STYLE_COLOR_DISABLED),
            ));
        }
    }

    spans.into()
}