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 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 if !app.alarms_state.metric_data.is_empty() {
216 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 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 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 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 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}