Skip to main content

rusticity_term/ui/cw/
alarms.rs

1use crate::app::App;
2use crate::common::{ColumnId, CyclicEnum, InputFocus};
3use crate::cw::{Alarm, AlarmColumn};
4use crate::keymap::Mode;
5use crate::ui::table::{render_table, Column, TableConfig};
6use crate::ui::{format_title, vertical};
7use ratatui::{prelude::*, widgets::*};
8
9pub const FILTER_CONTROLS: [InputFocus; 2] = [InputFocus::Filter, InputFocus::Pagination];
10
11pub struct State {
12    pub alarms: Vec<Alarm>,
13    pub selected: usize,
14    pub loading: bool,
15    pub alarm_filter: String,
16    pub alarm_tab: AlarmTab,
17    pub sort_column: String,
18    pub sort_direction: crate::common::SortDirection,
19    pub visible_columns: Vec<ColumnId>,
20    pub all_columns: Vec<ColumnId>,
21    pub expanded_alarm: Option<usize>,
22    pub input_focus: InputFocus,
23}
24
25impl Default for State {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl State {
32    pub fn new() -> Self {
33        Self {
34            alarms: Vec::new(),
35            selected: 0,
36            loading: false,
37            alarm_filter: String::new(),
38            alarm_tab: AlarmTab::AllAlarms,
39            sort_column: "Name".to_string(),
40            sort_direction: crate::common::SortDirection::Asc,
41            visible_columns: [
42                AlarmColumn::Name,
43                AlarmColumn::State,
44                AlarmColumn::LastStateUpdate,
45                AlarmColumn::Conditions,
46            ]
47            .iter()
48            .map(|c| c.id())
49            .collect(),
50            all_columns: AlarmColumn::ids(),
51            expanded_alarm: None,
52            input_focus: InputFocus::Filter,
53        }
54    }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub enum AlarmTab {
59    AllAlarms,
60    InAlarm,
61}
62
63impl CyclicEnum for AlarmTab {
64    const ALL: &'static [Self] = &[Self::AllAlarms, Self::InAlarm];
65}
66
67impl AlarmTab {
68    pub fn name(&self) -> &'static str {
69        match self {
70            AlarmTab::AllAlarms => "All alarms",
71            AlarmTab::InAlarm => "In alarm",
72        }
73    }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq)]
77pub enum AlarmViewMode {
78    Table,
79    Detail,
80    Cards,
81}
82
83const COLOR_ORANGE: Color = Color::Rgb(255, 165, 0);
84
85struct AlarmTableColumn {
86    column_type: AlarmColumn,
87}
88
89impl Column<Alarm> for AlarmTableColumn {
90    fn name(&self) -> &str {
91        Box::leak(self.column_type.name().into_boxed_str())
92    }
93
94    fn width(&self) -> u16 {
95        self.column_type.width()
96    }
97
98    fn render(&self, alarm: &Alarm) -> (String, Style) {
99        match self.column_type {
100            AlarmColumn::Name => (alarm.name.clone(), Style::default()),
101            AlarmColumn::State => {
102                let state_upper = alarm.state.to_uppercase();
103                let (icon, color, text) = if state_upper == "OK" {
104                    ("✅", Color::Green, "OK")
105                } else if state_upper == "ALARM" {
106                    ("⚠️", COLOR_ORANGE, "In alarm")
107                } else if state_upper == "INSUFFICIENT_DATA" {
108                    ("❔", Color::Gray, "Insufficient data")
109                } else {
110                    ("", Color::White, alarm.state.as_str())
111                };
112                (format!("{} {}", icon, text), Style::default().fg(color))
113            }
114            AlarmColumn::LastStateUpdate => (
115                format!("{} (UTC)", alarm.state_updated_timestamp),
116                Style::default(),
117            ),
118            AlarmColumn::Description => (alarm.description.clone(), Style::default()),
119            AlarmColumn::Conditions => (
120                format!(
121                    "{} {} {}",
122                    alarm.metric_name, alarm.comparison_operator, alarm.threshold
123                ),
124                Style::default(),
125            ),
126            AlarmColumn::Actions => {
127                if alarm.actions_enabled {
128                    (
129                        "✅ Actions enabled".to_string(),
130                        Style::default().fg(Color::Green),
131                    )
132                } else {
133                    ("No actions".to_string(), Style::default())
134                }
135            }
136            AlarmColumn::StateDetails => (alarm.state_reason.clone(), Style::default()),
137            AlarmColumn::MetricName => (alarm.metric_name.clone(), Style::default()),
138            AlarmColumn::Namespace => (alarm.namespace.clone(), Style::default()),
139            AlarmColumn::Statistic => (alarm.statistic.clone(), Style::default()),
140            AlarmColumn::Period => (alarm.period.to_string(), Style::default()),
141            AlarmColumn::Resource => (alarm.resource.clone(), Style::default()),
142            AlarmColumn::Dimensions => (alarm.dimensions.clone(), Style::default()),
143            AlarmColumn::Expression => (alarm.expression.clone(), Style::default()),
144            AlarmColumn::Type => (alarm.alarm_type.clone(), Style::default()),
145            AlarmColumn::CrossAccount => (alarm.cross_account.clone(), Style::default()),
146        }
147    }
148}
149
150fn render_alarm_detail(frame: &mut Frame, app: &App, area: Rect) {
151    let alarm_name = app.alarms_state.current_alarm.as_ref().unwrap();
152    let alarm = app
153        .alarms_state
154        .table
155        .items
156        .iter()
157        .find(|a| &a.name == alarm_name);
158
159    if let Some(alarm) = alarm {
160        let chunks = vertical([Constraint::Length(8), Constraint::Min(0)], area);
161
162        // Details section
163        let title = format_title(&format!("Alarm: {}", alarm.name));
164        let block = Block::default()
165            .borders(Borders::ALL)
166            .title(title)
167            .border_style(Style::default().fg(Color::Cyan));
168
169        let inner = block.inner(chunks[0]);
170        frame.render_widget(block, chunks[0]);
171
172        let text = vec![
173            Line::from(vec![
174                Span::styled("State: ", Style::default().add_modifier(Modifier::BOLD)),
175                Span::raw(&alarm.state),
176            ]),
177            Line::from(vec![
178                Span::styled(
179                    "Description: ",
180                    Style::default().add_modifier(Modifier::BOLD),
181                ),
182                Span::raw(&alarm.description),
183            ]),
184            Line::from(vec![
185                Span::styled("Metric: ", Style::default().add_modifier(Modifier::BOLD)),
186                Span::raw(&alarm.metric_name),
187            ]),
188            Line::from(vec![
189                Span::styled("Namespace: ", Style::default().add_modifier(Modifier::BOLD)),
190                Span::raw(&alarm.namespace),
191            ]),
192            Line::from(vec![
193                Span::styled(
194                    "Conditions: ",
195                    Style::default().add_modifier(Modifier::BOLD),
196                ),
197                Span::raw(format!(
198                    "{} {} {}",
199                    alarm.statistic, alarm.comparison_operator, alarm.threshold
200                )),
201            ]),
202            Line::from(vec![
203                Span::styled(
204                    "State Reason: ",
205                    Style::default().add_modifier(Modifier::BOLD),
206                ),
207                Span::raw(&alarm.state_reason),
208            ]),
209        ];
210
211        let paragraph = Paragraph::new(text);
212        frame.render_widget(paragraph, inner);
213
214        // Graph section - only render when data is available
215        if !app.alarms_state.metric_data.is_empty() {
216            // Render chart using common monitoring component
217            let chart = crate::ui::monitoring::MetricChart {
218                title: &alarm.metric_name,
219                data: &app.alarms_state.metric_data,
220                y_axis_label: "",
221                x_axis_label: None,
222            };
223            crate::ui::monitoring::render_monitoring_tab(
224                frame,
225                chunks[1],
226                &[chart],
227                &[],
228                &[],
229                &[],
230                0,
231            );
232        }
233    }
234}
235
236pub fn render(frame: &mut Frame, app: &App, area: Rect) {
237    frame.render_widget(Clear, area);
238
239    // If an alarm is selected, render detail view
240    if app.alarms_state.current_alarm.is_some() {
241        render_alarm_detail(frame, app, area);
242        return;
243    }
244
245    let chunks = vertical(
246        [
247            Constraint::Length(3),
248            Constraint::Length(1),
249            Constraint::Min(0),
250        ],
251        area,
252    );
253
254    // Filter pane with pagination on right
255    let page_size = app.alarms_state.table.page_size.value();
256    let filtered_count = match app.alarms_state.alarm_tab {
257        crate::app::AlarmTab::AllAlarms => app.alarms_state.table.items.len(),
258        crate::app::AlarmTab::InAlarm => app
259            .alarms_state
260            .table
261            .items
262            .iter()
263            .filter(|a| a.state.to_uppercase() == "ALARM")
264            .count(),
265    };
266    let total_pages = filtered_count.div_ceil(page_size);
267    let current_page = app.alarms_state.table.selected / page_size;
268
269    let pagination = crate::ui::render_pagination_text(current_page, total_pages);
270
271    crate::ui::filter::render_simple_filter(
272        frame,
273        chunks[0],
274        crate::ui::filter::SimpleFilterConfig {
275            filter_text: &app.alarms_state.table.filter,
276            placeholder: "Search",
277            pagination: &pagination,
278            mode: app.mode,
279            is_input_focused: app.alarms_state.input_focus == InputFocus::Filter,
280            is_pagination_focused: app.alarms_state.input_focus == InputFocus::Pagination,
281        },
282    );
283
284    // Tabs between panes (left-justified)
285    let tabs: Vec<(&str, AlarmTab)> = AlarmTab::ALL.iter().map(|tab| (tab.name(), *tab)).collect();
286    crate::ui::render_tabs(frame, chunks[1], &tabs, &app.alarms_state.alarm_tab);
287
288    // Filter alarms
289    let mut filtered_alarms: Vec<&Alarm> = match app.alarms_state.alarm_tab {
290        crate::app::AlarmTab::AllAlarms => app.alarms_state.table.items.iter().collect(),
291        crate::app::AlarmTab::InAlarm => app
292            .alarms_state
293            .table
294            .items
295            .iter()
296            .filter(|a| a.state.to_uppercase() == "ALARM")
297            .collect(),
298    };
299
300    if !app.alarms_state.table.filter.is_empty() {
301        let filter_lower = app.alarms_state.table.filter.to_lowercase();
302        filtered_alarms.retain(|a| a.name.to_lowercase().contains(&filter_lower));
303    }
304
305    let count = filtered_alarms.len();
306    let title = format_title(&format!("Alarms ({})", count));
307
308    let columns: Vec<Box<dyn Column<Alarm>>> = app
309        .cw_alarm_visible_column_ids
310        .iter()
311        .filter_map(|col_id| {
312            AlarmColumn::from_id(col_id).map(|col| {
313                Box::new(AlarmTableColumn { column_type: col }) as Box<dyn Column<Alarm>>
314            })
315        })
316        .collect();
317
318    let config = TableConfig {
319        items: filtered_alarms,
320        selected_index: app.alarms_state.table.selected,
321        expanded_index: app.alarms_state.table.expanded_item,
322        columns: &columns,
323        sort_column: &app.alarms_state.sort_column,
324        sort_direction: app.alarms_state.sort_direction,
325        title,
326        area: chunks[2],
327        is_active: !matches!(
328            app.mode,
329            Mode::SpaceMenu
330                | Mode::ServicePicker
331                | Mode::ColumnSelector
332                | Mode::ErrorModal
333                | Mode::HelpModal
334                | Mode::RegionPicker
335                | Mode::CalendarPicker
336                | Mode::TabPicker
337                | Mode::FilterInput
338        ),
339        get_expanded_content: Some(Box::new(|alarm: &Alarm| {
340            crate::ui::table::expanded_from_columns(&columns, alarm)
341        })),
342    };
343
344    render_table(frame, config);
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    const WIDTH: usize = 100;
352    const PAGINATION: &str = "[1] 2 3";
353    const PAGINATION_LEN: usize = PAGINATION.len();
354
355    fn test_spacing(content_len: usize) -> usize {
356        WIDTH - content_len - PAGINATION_LEN
357    }
358
359    #[test]
360    fn test_pagination_spacing() {
361        assert_eq!(test_spacing(6), 87);
362        assert_eq!(test_spacing(1), 92);
363        assert_eq!(test_spacing(4), 89);
364        assert_eq!(test_spacing(5), 88);
365        assert_eq!(6 + 87 + PAGINATION_LEN, WIDTH);
366        assert_eq!(1 + 92 + PAGINATION_LEN, WIDTH);
367    }
368
369    fn test_alarm() -> Alarm {
370        Alarm {
371            name: "TestAlarm".to_string(),
372            state: "OK".to_string(),
373            state_updated_timestamp: "2025-07-22 17:13:07".to_string(),
374            description: "Test description".to_string(),
375            metric_name: "CPUUtilization".to_string(),
376            namespace: "AWS/EC2".to_string(),
377            statistic: "Average".to_string(),
378            period: 300,
379            comparison_operator: "GreaterThanThreshold".to_string(),
380            threshold: 80.0,
381            actions_enabled: true,
382            state_reason: "Threshold Crossed".to_string(),
383            resource: "i-1234567890abcdef0".to_string(),
384            dimensions: "InstanceId=i-1234567890abcdef0".to_string(),
385            expression: String::new(),
386            alarm_type: "MetricAlarm".to_string(),
387            cross_account: String::new(),
388        }
389    }
390
391    fn format_alarm_line(col: &AlarmColumn, alarm: &Alarm) -> String {
392        let value = match col {
393            AlarmColumn::Name => alarm.name.clone(),
394            AlarmColumn::State => "✅ OK".to_string(),
395            AlarmColumn::LastStateUpdate => format!("{} (UTC)", alarm.state_updated_timestamp),
396            AlarmColumn::Conditions => format!(
397                "{} {} {}",
398                alarm.metric_name, alarm.comparison_operator, alarm.threshold
399            ),
400            AlarmColumn::StateDetails => alarm.state_reason.clone(),
401            _ => String::new(),
402        };
403        format!("{}: {}", col.name(), value)
404    }
405
406    #[test]
407    fn test_expanded_content_full_values() {
408        let alarm = test_alarm();
409        let cols = [
410            AlarmColumn::Name,
411            AlarmColumn::State,
412            AlarmColumn::LastStateUpdate,
413            AlarmColumn::Conditions,
414        ];
415        let lines: Vec<_> = cols.iter().map(|c| format_alarm_line(c, &alarm)).collect();
416        let content = lines.join("\n");
417
418        let ts = lines
419            .iter()
420            .find(|l| l.starts_with("Last state update:"))
421            .unwrap();
422        assert_eq!(ts, "Last state update: 2025-07-22 17:13:07 (UTC)");
423        assert!(ts.contains("(UTC)") && !ts.contains("( UTC"));
424        assert!(content.contains("Name: TestAlarm"));
425        assert!(content.contains("State: ✅ OK"));
426        assert!(content.contains("Conditions: CPUUtilization GreaterThanThreshold 80"));
427    }
428
429    #[test]
430    fn test_expanded_content_not_affected_by_divisors() {
431        let mut alarm = test_alarm();
432        alarm.state = "ALARM".to_string();
433        alarm.state_updated_timestamp = "2025-10-20 10:59:06".to_string();
434        alarm.state_reason = "Threshold Crossed: 1 datapoint [500.0 (20/10/25 10:59:00)] was less than the threshold (1000.0).".to_string();
435
436        let cols = [
437            AlarmColumn::Name,
438            AlarmColumn::LastStateUpdate,
439            AlarmColumn::StateDetails,
440        ];
441        let lines: Vec<_> = cols.iter().map(|c| format_alarm_line(c, &alarm)).collect();
442
443        let ts = lines
444            .iter()
445            .find(|l| l.starts_with("Last state update:"))
446            .unwrap();
447        assert!(ts.ends_with("(UTC)") && !ts.contains("( UTC") && !ts.contains("( TC)"));
448
449        let state = lines
450            .iter()
451            .find(|l| l.starts_with("State details:"))
452            .unwrap();
453        assert!(state.contains("Threshold Crossed") && state.contains("datapoint"));
454        assert!(state.contains("(20/10/25") && state.contains("(1000.0)"));
455    }
456
457    #[test]
458    fn test_last_state_update_column_width_fits_timestamp() {
459        let ts = "2025-07-22 17:13:07 (UTC)";
460        let width = AlarmColumn::LastStateUpdate.width() as usize;
461        assert!(width >= ts.len());
462        assert!(width >= "Last state update ↑".to_string().len());
463    }
464}