rusticity_term/ui/
cfn.rs

1use crate::app::App;
2use crate::cfn::{Column as CfnColumn, Stack as CfnStack};
3use crate::common::CyclicEnum;
4use crate::common::{render_pagination_text, InputFocus, SortDirection};
5use crate::keymap::Mode;
6use crate::table::TableState;
7use crate::ui::labeled_field;
8use ratatui::{prelude::*, widgets::*};
9
10pub const STATUS_FILTER: InputFocus = InputFocus::Dropdown("StatusFilter");
11pub const VIEW_NESTED: InputFocus = InputFocus::Checkbox("ViewNested");
12
13impl State {
14    pub const FILTER_CONTROLS: [InputFocus; 4] = [
15        InputFocus::Filter,
16        STATUS_FILTER,
17        VIEW_NESTED,
18        InputFocus::Pagination,
19    ];
20}
21
22pub struct State {
23    pub table: TableState<CfnStack>,
24    pub input_focus: InputFocus,
25    pub status_filter: StatusFilter,
26    pub view_nested: bool,
27    pub current_stack: Option<String>,
28    pub detail_tab: DetailTab,
29    pub overview_scroll: u16,
30    pub sort_column: CfnColumn,
31    pub sort_direction: SortDirection,
32}
33
34impl Default for State {
35    fn default() -> Self {
36        Self::new()
37    }
38}
39
40impl State {
41    pub fn new() -> Self {
42        Self {
43            table: TableState::new(),
44            input_focus: InputFocus::Filter,
45            status_filter: StatusFilter::All,
46            view_nested: false,
47            current_stack: None,
48            detail_tab: DetailTab::StackInfo,
49            overview_scroll: 0,
50            sort_column: CfnColumn::CreatedTime,
51            sort_direction: SortDirection::Desc,
52        }
53    }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub enum StatusFilter {
58    All,
59    Active,
60    Complete,
61    Failed,
62    Deleted,
63    InProgress,
64}
65
66impl StatusFilter {
67    pub fn name(&self) -> &'static str {
68        match self {
69            StatusFilter::All => "All",
70            StatusFilter::Active => "Active",
71            StatusFilter::Complete => "Complete",
72            StatusFilter::Failed => "Failed",
73            StatusFilter::Deleted => "Deleted",
74            StatusFilter::InProgress => "In progress",
75        }
76    }
77
78    pub fn all() -> Vec<StatusFilter> {
79        vec![
80            StatusFilter::All,
81            StatusFilter::Active,
82            StatusFilter::Complete,
83            StatusFilter::Failed,
84            StatusFilter::Deleted,
85            StatusFilter::InProgress,
86        ]
87    }
88
89    pub fn next(&self) -> Self {
90        match self {
91            StatusFilter::All => StatusFilter::Active,
92            StatusFilter::Active => StatusFilter::Complete,
93            StatusFilter::Complete => StatusFilter::Failed,
94            StatusFilter::Failed => StatusFilter::Deleted,
95            StatusFilter::Deleted => StatusFilter::InProgress,
96            StatusFilter::InProgress => StatusFilter::All,
97        }
98    }
99
100    pub fn prev(&self) -> Self {
101        match self {
102            StatusFilter::All => StatusFilter::InProgress,
103            StatusFilter::Active => StatusFilter::All,
104            StatusFilter::Complete => StatusFilter::Active,
105            StatusFilter::Failed => StatusFilter::Complete,
106            StatusFilter::Deleted => StatusFilter::Failed,
107            StatusFilter::InProgress => StatusFilter::Deleted,
108        }
109    }
110
111    pub fn matches(&self, status: &str) -> bool {
112        match self {
113            StatusFilter::All => true,
114            StatusFilter::Active => {
115                !status.contains("DELETE")
116                    && !status.contains("COMPLETE")
117                    && !status.contains("FAILED")
118            }
119            StatusFilter::Complete => status.contains("COMPLETE") && !status.contains("DELETE"),
120            StatusFilter::Failed => status.contains("FAILED"),
121            StatusFilter::Deleted => status.contains("DELETE"),
122            StatusFilter::InProgress => status.contains("IN_PROGRESS"),
123        }
124    }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq)]
128pub enum DetailTab {
129    StackInfo,
130    Events,
131    Resources,
132    Outputs,
133    Parameters,
134    Template,
135    ChangeSets,
136    GitSync,
137}
138
139impl CyclicEnum for DetailTab {
140    const ALL: &'static [Self] = &[
141        Self::StackInfo,
142        // Self::Events,
143        // Self::Resources,
144        // Self::Outputs,
145        // Self::Parameters,
146        // Self::Template,
147        // Self::ChangeSets,
148        // Self::GitSync,
149    ];
150}
151
152impl DetailTab {
153    pub fn name(&self) -> &'static str {
154        match self {
155            DetailTab::StackInfo => "Stack info",
156            DetailTab::Events => "Events",
157            DetailTab::Resources => "Resources",
158            DetailTab::Outputs => "Outputs",
159            DetailTab::Parameters => "Parameters",
160            DetailTab::Template => "Template",
161            DetailTab::ChangeSets => "Change sets",
162            DetailTab::GitSync => "Git sync",
163        }
164    }
165
166    pub fn all() -> Vec<DetailTab> {
167        vec![
168            DetailTab::StackInfo,
169            DetailTab::Events,
170            DetailTab::Resources,
171            DetailTab::Outputs,
172            DetailTab::Parameters,
173            DetailTab::Template,
174            DetailTab::ChangeSets,
175            DetailTab::GitSync,
176        ]
177    }
178}
179
180pub fn filtered_cloudformation_stacks(app: &App) -> Vec<&crate::cfn::Stack> {
181    let filtered: Vec<&crate::cfn::Stack> = if app.cfn_state.table.filter.is_empty() {
182        app.cfn_state.table.items.iter().collect()
183    } else {
184        app.cfn_state
185            .table
186            .items
187            .iter()
188            .filter(|s| {
189                s.name
190                    .to_lowercase()
191                    .contains(&app.cfn_state.table.filter.to_lowercase())
192                    || s.description
193                        .to_lowercase()
194                        .contains(&app.cfn_state.table.filter.to_lowercase())
195            })
196            .collect()
197    };
198
199    filtered
200        .into_iter()
201        .filter(|s| app.cfn_state.status_filter.matches(&s.status))
202        .collect()
203}
204
205pub fn render_stacks(frame: &mut Frame, app: &App, area: Rect) {
206    frame.render_widget(Clear, area);
207
208    if app.cfn_state.current_stack.is_some() {
209        render_cloudformation_stack_detail(frame, app, area);
210    } else {
211        render_cloudformation_stack_list(frame, app, area);
212    }
213}
214
215pub fn render_cloudformation_stack_list(frame: &mut Frame, app: &App, area: Rect) {
216    let chunks = Layout::default()
217        .direction(Direction::Vertical)
218        .constraints([
219            Constraint::Length(3), // Filter + controls
220            Constraint::Min(0),    // Table
221        ])
222        .split(area);
223
224    // Filter line - search on left, controls on right
225    let filtered_stacks = filtered_cloudformation_stacks(app);
226    let filtered_count = filtered_stacks.len();
227
228    let placeholder = "Search by stack name";
229
230    let status_filter_text = format!("Filter status: {}", app.cfn_state.status_filter.name());
231    let view_nested_text = if app.cfn_state.view_nested {
232        "☑ View nested"
233    } else {
234        "☐ View nested"
235    };
236    let page_size = app.cfn_state.table.page_size.value();
237    let total_pages = filtered_count.div_ceil(page_size);
238    let current_page =
239        if filtered_count > 0 && app.cfn_state.table.scroll_offset + page_size >= filtered_count {
240            total_pages.saturating_sub(1)
241        } else {
242            app.cfn_state.table.scroll_offset / page_size
243        };
244    let pagination = render_pagination_text(current_page, total_pages);
245
246    crate::ui::filter::render_filter_bar(
247        frame,
248        crate::ui::filter::FilterConfig {
249            filter_text: &app.cfn_state.table.filter,
250            placeholder,
251            mode: app.mode,
252            is_input_focused: app.cfn_state.input_focus == InputFocus::Filter,
253            controls: vec![
254                crate::ui::filter::FilterControl {
255                    text: status_filter_text.to_string(),
256                    is_focused: app.cfn_state.input_focus == STATUS_FILTER,
257                },
258                crate::ui::filter::FilterControl {
259                    text: view_nested_text.to_string(),
260                    is_focused: app.cfn_state.input_focus == VIEW_NESTED,
261                },
262                crate::ui::filter::FilterControl {
263                    text: pagination.clone(),
264                    is_focused: app.cfn_state.input_focus == InputFocus::Pagination,
265                },
266            ],
267            area: chunks[0],
268        },
269    );
270
271    // Table - use scroll_offset for pagination
272    let scroll_offset = app.cfn_state.table.scroll_offset;
273    let page_stacks: Vec<_> = filtered_stacks
274        .iter()
275        .skip(scroll_offset)
276        .take(page_size)
277        .collect();
278
279    // Define columns
280    let columns: Vec<Box<dyn crate::ui::table::Column<&crate::cfn::Stack>>> = app
281        .visible_cfn_columns
282        .iter()
283        .map(|col| col.to_column())
284        .collect();
285
286    let expanded_index = app.cfn_state.table.expanded_item.and_then(|idx| {
287        let scroll_offset = app.cfn_state.table.scroll_offset;
288        if idx >= scroll_offset && idx < scroll_offset + page_size {
289            Some(idx - scroll_offset)
290        } else {
291            None
292        }
293    });
294
295    let config = crate::ui::table::TableConfig {
296        items: page_stacks,
297        selected_index: app.cfn_state.table.selected % app.cfn_state.table.page_size.value(),
298        expanded_index,
299        columns: &columns,
300        sort_column: app.cfn_state.sort_column.name(),
301        sort_direction: app.cfn_state.sort_direction,
302        title: format!(" Stacks ({}) ", filtered_count),
303        area: chunks[1],
304        get_expanded_content: Some(Box::new(|stack: &&crate::cfn::Stack| {
305            crate::ui::table::expanded_from_columns(&columns, stack)
306        })),
307        is_active: app.mode != Mode::FilterInput,
308    };
309
310    crate::ui::table::render_table(frame, config);
311
312    // Render dropdown for StatusFilter when focused (after table so it appears on top)
313    if app.mode == Mode::FilterInput && app.cfn_state.input_focus == STATUS_FILTER {
314        // Find the longest filter name for consistent width
315        let max_filter_width = StatusFilter::all()
316            .iter()
317            .map(|f| f.name().len())
318            .max()
319            .unwrap_or(10) as u16
320            + 4; // +4 for padding and borders
321
322        let dropdown_items: Vec<ListItem> = StatusFilter::all()
323            .iter()
324            .map(|filter| {
325                let style = if *filter == app.cfn_state.status_filter {
326                    Style::default().fg(Color::Yellow).bold()
327                } else {
328                    Style::default()
329                };
330                ListItem::new(format!(" {} ", filter.name())).style(style)
331            })
332            .collect();
333
334        let dropdown_height = dropdown_items.len() as u16 + 2;
335
336        // Calculate position based on actual control positions
337        let view_nested_width = " ☑ View nested ".len() as u16;
338        let pagination_width = pagination.len() as u16;
339
340        let dropdown_width = max_filter_width;
341        let dropdown_x = chunks[0]
342            .x
343            .saturating_add(chunks[0].width)
344            .saturating_sub(view_nested_width + 3 + pagination_width + 3 + dropdown_width);
345
346        let dropdown_area = Rect {
347            x: dropdown_x,
348            y: chunks[0].y + chunks[0].height,
349            width: dropdown_width,
350            height: dropdown_height.min(10),
351        };
352
353        frame.render_widget(
354            List::new(dropdown_items)
355                .block(
356                    Block::default()
357                        .borders(Borders::ALL)
358                        .border_style(Style::default().fg(Color::Yellow)),
359                )
360                .style(Style::default().bg(Color::Black)),
361            dropdown_area,
362        );
363    }
364}
365
366pub fn render_cloudformation_stack_detail(frame: &mut Frame, app: &App, area: Rect) {
367    let stack_name = app.cfn_state.current_stack.as_ref().unwrap();
368
369    // Find the stack
370    let stack = app
371        .cfn_state
372        .table
373        .items
374        .iter()
375        .find(|s| &s.name == stack_name);
376
377    if stack.is_none() {
378        let paragraph = Paragraph::new("Stack not found")
379            .block(Block::default().borders(Borders::ALL).title(" Error "));
380        frame.render_widget(paragraph, area);
381        return;
382    }
383
384    let stack = stack.unwrap();
385
386    let chunks = Layout::default()
387        .direction(Direction::Vertical)
388        .constraints([
389            Constraint::Length(1), // Stack name
390            Constraint::Min(0),    // Content
391        ])
392        .split(area);
393
394    // Render stack name
395    frame.render_widget(Paragraph::new(stack.name.clone()), chunks[0]);
396
397    // Render content based on selected tab
398    match app.cfn_state.detail_tab {
399        DetailTab::StackInfo => {
400            render_stack_info(frame, app, stack, chunks[1]);
401        }
402        _ => unimplemented!(),
403    }
404}
405
406pub fn render_stack_info(frame: &mut Frame, _app: &App, stack: &crate::cfn::Stack, area: Rect) {
407    let (formatted_status, _status_color) = crate::cfn::format_status(&stack.status);
408
409    // Overview section
410    let fields = vec![
411        (
412            "Stack ID",
413            if stack.stack_id.is_empty() {
414                "-"
415            } else {
416                &stack.stack_id
417            },
418        ),
419        (
420            "Description",
421            if stack.description.is_empty() {
422                "-"
423            } else {
424                &stack.description
425            },
426        ),
427        ("Status", &formatted_status),
428        (
429            "Detailed status",
430            if stack.detailed_status.is_empty() {
431                "-"
432            } else {
433                &stack.detailed_status
434            },
435        ),
436        (
437            "Status reason",
438            if stack.status_reason.is_empty() {
439                "-"
440            } else {
441                &stack.status_reason
442            },
443        ),
444        (
445            "Root stack",
446            if stack.root_stack.is_empty() {
447                "-"
448            } else {
449                &stack.root_stack
450            },
451        ),
452        (
453            "Parent stack",
454            if stack.parent_stack.is_empty() {
455                "-"
456            } else {
457                &stack.parent_stack
458            },
459        ),
460        (
461            "Created time",
462            if stack.created_time.is_empty() {
463                "-"
464            } else {
465                &stack.created_time
466            },
467        ),
468        (
469            "Updated time",
470            if stack.updated_time.is_empty() {
471                "-"
472            } else {
473                &stack.updated_time
474            },
475        ),
476        (
477            "Deleted time",
478            if stack.deleted_time.is_empty() {
479                "-"
480            } else {
481                &stack.deleted_time
482            },
483        ),
484        (
485            "Drift status",
486            if stack.drift_status.is_empty() {
487                "-"
488            } else {
489                &stack.drift_status
490            },
491        ),
492        (
493            "Last drift check time",
494            if stack.last_drift_check_time.is_empty() {
495                "-"
496            } else {
497                &stack.last_drift_check_time
498            },
499        ),
500        (
501            "Termination protection",
502            if stack.termination_protection {
503                "Activated"
504            } else {
505                "Disabled"
506            },
507        ),
508        (
509            "IAM role",
510            if stack.iam_role.is_empty() {
511                "-"
512            } else {
513                &stack.iam_role
514            },
515        ),
516    ];
517    let overview_height = fields.len() as u16 + 2; // +2 for borders
518
519    // Tags section
520    let tags_lines = if stack.tags.is_empty() {
521        vec![
522            "Stack-level tags will apply to all supported resources in your stack.".to_string(),
523            "You can add up to 50 unique tags for each stack.".to_string(),
524            String::new(),
525            "No tags defined".to_string(),
526        ]
527    } else {
528        let mut lines = vec!["Key                          Value".to_string()];
529        for (key, value) in &stack.tags {
530            lines.push(format!("{}  {}", key, value));
531        }
532        lines
533    };
534    let tags_height = tags_lines.len() as u16 + 2; // +2 for borders
535
536    // Stack policy section
537    let policy_lines = if stack.stack_policy.is_empty() {
538        vec![
539            "Defines the resources that you want to protect from unintentional".to_string(),
540            "updates during a stack update.".to_string(),
541            String::new(),
542            "No stack policy".to_string(),
543            "  There is no stack policy defined".to_string(),
544        ]
545    } else {
546        vec![stack.stack_policy.clone()]
547    };
548    let policy_height = policy_lines.len() as u16 + 2; // +2 for borders
549
550    // Rollback configuration section
551    let rollback_lines = if stack.rollback_alarms.is_empty() {
552        vec![
553            "Specifies alarms for CloudFormation to monitor when creating and".to_string(),
554            "updating the stack. If the operation breaches an alarm threshold,".to_string(),
555            "CloudFormation rolls it back.".to_string(),
556            String::new(),
557            "Monitoring time".to_string(),
558            format!(
559                "  {}",
560                if stack.rollback_monitoring_time.is_empty() {
561                    "-"
562                } else {
563                    &stack.rollback_monitoring_time
564                }
565            ),
566        ]
567    } else {
568        let mut lines = vec![
569            "Monitoring time".to_string(),
570            format!(
571                "  {}",
572                if stack.rollback_monitoring_time.is_empty() {
573                    "-"
574                } else {
575                    &stack.rollback_monitoring_time
576                }
577            ),
578            String::new(),
579            "CloudWatch alarm ARN".to_string(),
580        ];
581        for alarm in &stack.rollback_alarms {
582            lines.push(format!("  {}", alarm));
583        }
584        lines
585    };
586    let rollback_height = rollback_lines.len() as u16 + 2; // +2 for borders
587
588    // Notification options section
589    let notification_lines = if stack.notification_arns.is_empty() {
590        vec![
591            "Specifies where notifications about stack actions will be sent.".to_string(),
592            String::new(),
593            "SNS topic ARN".to_string(),
594            "  No notifications configured".to_string(),
595        ]
596    } else {
597        let mut lines = vec![
598            "Specifies where notifications about stack actions will be sent.".to_string(),
599            String::new(),
600            "SNS topic ARN".to_string(),
601        ];
602        for arn in &stack.notification_arns {
603            lines.push(format!("  {}", arn));
604        }
605        lines
606    };
607    let notification_height = notification_lines.len() as u16 + 2; // +2 for borders
608
609    // Split into sections with calculated heights
610    let sections = Layout::default()
611        .direction(Direction::Vertical)
612        .constraints([
613            Constraint::Length(overview_height),
614            Constraint::Length(tags_height),
615            Constraint::Length(policy_height),
616            Constraint::Length(rollback_height),
617            Constraint::Length(notification_height),
618            Constraint::Min(0), // Remaining space
619        ])
620        .split(area);
621
622    // Render overview
623    let overview_lines: Vec<_> = fields
624        .iter()
625        .map(|(label, value)| labeled_field(label, *value))
626        .collect();
627    let overview = Paragraph::new(overview_lines)
628        .block(Block::default().borders(Borders::ALL).title(" Overview "))
629        .wrap(Wrap { trim: true });
630    frame.render_widget(overview, sections[0]);
631
632    // Render tags
633    let tags = Paragraph::new(tags_lines.join("\n"))
634        .block(Block::default().borders(Borders::ALL).title(" Tags "))
635        .wrap(Wrap { trim: true });
636    frame.render_widget(tags, sections[1]);
637
638    // Render stack policy
639    let policy = Paragraph::new(policy_lines.join("\n"))
640        .block(
641            Block::default()
642                .borders(Borders::ALL)
643                .title(" Stack policy "),
644        )
645        .wrap(Wrap { trim: true });
646    frame.render_widget(policy, sections[2]);
647
648    // Render rollback configuration
649    let rollback = Paragraph::new(rollback_lines.join("\n"))
650        .block(
651            Block::default()
652                .borders(Borders::ALL)
653                .title(" Rollback configuration "),
654        )
655        .wrap(Wrap { trim: true });
656    frame.render_widget(rollback, sections[3]);
657
658    // Render notification options
659    let notifications = Paragraph::new(notification_lines.join("\n"))
660        .block(
661            Block::default()
662                .borders(Borders::ALL)
663                .title(" Notification options "),
664        )
665        .wrap(Wrap { trim: true });
666    frame.render_widget(notifications, sections[4]);
667}