grafatui 0.1.12

A Grafana-like TUI for Prometheus
use super::layout::centered_rect;
use crate::annotations::{
    AnnotationModal, ClusterModalState, TagFilterModalState, format_event_time, visible_range,
};
use crate::app::AppState;
use ratatui::{
    Frame,
    layout::{Constraint, Layout, Margin, Rect, Size},
    style::{Modifier, Style},
    text::Line,
    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
};

const MODAL_WIDTH_PERCENT: u16 = 80;
const MODAL_HEIGHT_PERCENT: u16 = 70;

fn modal_area(size: Size) -> Rect {
    centered_rect(
        MODAL_WIDTH_PERCENT,
        MODAL_HEIGHT_PERCENT,
        Rect::new(0, 0, size.width, size.height),
    )
}

fn cluster_chunks(area: Rect) -> [Rect; 4] {
    let chunks = Layout::vertical([
        Constraint::Length(1),
        Constraint::Percentage(45),
        Constraint::Min(2),
        Constraint::Length(1),
    ])
    .split(area);
    [chunks[0], chunks[1], chunks[2], chunks[3]]
}

pub(crate) fn annotation_cluster_page_size(size: Size) -> usize {
    let outer = modal_area(size);
    let inner = outer.inner(Margin {
        vertical: 1,
        horizontal: 1,
    });
    usize::from(cluster_chunks(inner)[1].height).max(1)
}

pub(crate) fn render_annotation_modal(frame: &mut Frame, app: &AppState) {
    let Some(modal) = app.annotation_modal.as_ref() else {
        return;
    };
    let frame_area = frame.area();
    let area = modal_area(Size::new(frame_area.width, frame_area.height));
    frame.render_widget(Clear, area);
    match modal {
        AnnotationModal::Cluster(state) => render_cluster_modal(frame, area, state, app),
        AnnotationModal::TagFilter(state) => render_tag_filter_modal(frame, area, state, app),
    }
}

fn modal_block<'a>(title: &'a str, app: &AppState) -> Block<'a> {
    Block::default()
        .title(title)
        .borders(Borders::ALL)
        .border_style(Style::default().fg(app.theme.border_selected))
}

fn render_cluster_modal(frame: &mut Frame, area: Rect, state: &ClusterModalState, app: &AppState) {
    frame.render_widget(modal_block(" Annotation cluster ", app), area);
    let inner = area.inner(Margin {
        vertical: 1,
        horizontal: 1,
    });
    let [header_area, list_area, detail_area, hints_area] = cluster_chunks(inner);
    let total = state.events().len();
    frame.render_widget(
        Paragraph::new(format!(
            "{total} annotations    {} / {total}",
            state.selected().saturating_add(1)
        )),
        header_area,
    );

    let rows = usize::from(list_area.height);
    let visible = visible_range(total, state.selected(), rows);
    let items = state.events()[visible.clone()]
        .iter()
        .map(|event| {
            let time = if event.time.timestamp_subsec_millis() == 0 {
                event.time.format("%H:%M:%S").to_string()
            } else {
                event.time.format("%H:%M:%S%.3f").to_string()
            };
            ListItem::new(format!("{time}  {}", event.text))
        })
        .collect::<Vec<_>>();
    let mut list_state = ListState::default();
    if !items.is_empty() {
        list_state.select(Some(state.selected().saturating_sub(visible.start)));
    }
    let list = List::new(items).highlight_symbol("").highlight_style(
        Style::default()
            .fg(app.theme.title)
            .add_modifier(Modifier::BOLD),
    );
    frame.render_stateful_widget(list, list_area, &mut list_state);

    if let Some(event) = state.selected_event() {
        let tags = if event.tags.is_empty() {
            "Tags: —".to_string()
        } else {
            format!("Tags: {}", event.tags.join(", "))
        };
        let detail = Paragraph::new(vec![
            Line::from(format_event_time(event)),
            Line::from(event.text.as_str()),
            Line::from(tags),
        ])
        .wrap(Wrap { trim: false });
        frame.render_widget(detail, detail_area);
    }

    frame.render_widget(
        Paragraph::new("↑/↓ move  PgUp/PgDn page  Enter/Esc close"),
        hints_area,
    );
}

fn render_tag_filter_modal(
    frame: &mut Frame,
    area: Rect,
    state: &TagFilterModalState,
    app: &AppState,
) {
    frame.render_widget(modal_block(" Filter annotations by tag ", app), area);
    let inner = area.inner(Margin {
        vertical: 1,
        horizontal: 1,
    });
    let chunks = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(inner);
    let list_area = chunks[0];
    let hints_area = chunks[1];
    let rows = usize::from(list_area.height);
    let visible = visible_range(state.entries().len(), state.selected(), rows);
    let row_width = usize::from(list_area.width);
    let items = state.entries()[visible.clone()]
        .iter()
        .map(|entry| {
            let checked = if state.draft().selected().contains(&entry.tag) {
                'x'
            } else {
                ' '
            };
            let label_width = row_width.saturating_sub(4 + entry.count.to_string().len());
            ListItem::new(format!(
                "[{checked}] {:<label_width$}{}",
                entry.tag, entry.count
            ))
        })
        .collect::<Vec<_>>();
    let mut list_state = ListState::default();
    if !items.is_empty() {
        list_state.select(Some(state.selected().saturating_sub(visible.start)));
    }
    let list = List::new(items).highlight_style(
        Style::default()
            .fg(app.theme.title)
            .add_modifier(Modifier::BOLD),
    );
    frame.render_stateful_widget(list, list_area, &mut list_state);
    frame.render_widget(
        Paragraph::new("Space toggle  c clear  Enter apply  Esc cancel"),
        hints_area,
    );
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::annotations::{AnnotationEvent, AnnotationModal, AnnotationTarget, TagFilter};
    use crate::app::AppState;
    use crate::export::ExportOptions;
    use crate::prom::PromClient;
    use crate::theme::Theme;
    use ratatui::{Terminal, backend::TestBackend};
    use std::time::Duration;

    fn test_app() -> AppState {
        AppState::new(
            PromClient::new("http://localhost:9090".to_string()),
            Duration::from_secs(300),
            Duration::from_secs(5),
            Duration::from_secs(1),
            "Test".to_string(),
            vec![],
            0,
            Theme::default(),
            "dashed-line".to_string(),
            ExportOptions::default(),
        )
    }

    fn event(time: &str, text: &str, tags: &[&str]) -> AnnotationEvent {
        AnnotationEvent {
            time: chrono::DateTime::parse_from_rfc3339(time)
                .unwrap()
                .with_timezone(&chrono::Utc),
            text: text.to_string(),
            tags: tags.iter().map(|tag| (*tag).to_string()).collect(),
            target: AnnotationTarget::All,
        }
    }

    fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
        let buffer = terminal.backend().buffer();
        let mut output = String::new();
        for y in buffer.area.y..buffer.area.bottom() {
            for x in buffer.area.x..buffer.area.right() {
                output.push_str(buffer.cell((x, y)).unwrap().symbol());
            }
            output.push('\n');
        }
        output
    }

    #[test]
    fn cluster_modal_renders_summary_truncated_list_full_detail_and_hints() {
        let long_text = format!(
            "deploy {} wrapped detail reaches the final sentinel",
            "abcdefghijklmnopqrstuvwxyz".repeat(3)
        );
        let events = vec![
            event("2026-07-23T14:30:00.125Z", &long_text, &["prod", "api"]),
            event("2026-07-23T14:30:01Z", "rollback", &[]),
            event("2026-07-23T14:30:02Z", "resolved", &["incident"]),
        ];
        let mut app = test_app();
        app.annotation_modal = Some(AnnotationModal::Cluster(
            crate::annotations::ClusterModalState::new(events).unwrap(),
        ));
        let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap();

        terminal
            .draw(|frame| render_annotation_modal(frame, &app))
            .unwrap();
        let text = buffer_text(&terminal);

        assert!(text.contains("3 annotations"));
        assert!(text.contains("1 / 3"));
        assert!(text.contains("2026-07-23 14:30:00.125 UTC"));
        assert!(text.contains("final sentinel"));
        assert!(text.contains("Tags: prod, api"));
        assert!(text.contains("↑/↓ move  PgUp/PgDn page  Enter/Esc close"));
        assert_eq!(text.matches("final sentinel").count(), 1);
    }

    #[test]
    fn tag_modal_renders_alphabetical_counts_selection_and_hints() {
        let mut app = test_app();
        app.annotations = crate::annotations::AnnotationState::from_events_for_test(vec![
            event("2026-07-23T14:30:00Z", "alert", &["incident"]),
            event("2026-07-23T14:31:00Z", "release", &["deploy"]),
            event("2026-07-23T14:32:00Z", "release two", &["deploy"]),
        ]);
        app.annotations
            .set_filter(TagFilter::from_selected(["deploy".to_string()]));
        app.open_tag_filter_modal();
        let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap();

        terminal
            .draw(|frame| render_annotation_modal(frame, &app))
            .unwrap();
        let text = buffer_text(&terminal);
        let deploy = text.find("[x] deploy").unwrap();
        let incident = text.find("[ ] incident").unwrap();

        assert!(deploy < incident);
        assert!(
            text.lines()
                .any(|line| line.contains("│[x] deploy") && line.trim_end().ends_with("2│"))
        );
        assert!(
            text.lines()
                .any(|line| line.contains("│[ ] incident") && line.trim_end().ends_with("1│"))
        );
        assert!(text.contains("Space toggle  c clear  Enter apply  Esc cancel"));
    }

    #[test]
    fn cluster_page_size_matches_rendered_list_rows() {
        assert_eq!(annotation_cluster_page_size(Size::new(100, 40)), 12);
        assert!(annotation_cluster_page_size(Size::new(1, 1)) > 0);
    }

    #[test]
    fn annotation_modals_render_safely_on_a_small_terminal() {
        let mut app = test_app();
        app.annotation_modal = Some(AnnotationModal::Cluster(
            crate::annotations::ClusterModalState::new(vec![event(
                "2026-07-23T14:30:00Z",
                "deploy",
                &["prod"],
            )])
            .unwrap(),
        ));
        let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap();

        terminal
            .draw(|frame| render_annotation_modal(frame, &app))
            .unwrap();

        let buffer = terminal.backend().buffer();
        assert_eq!(buffer.area, Rect::new(0, 0, 40, 12));
        assert_eq!(buffer.content().len(), 40 * 12);
    }
}