rusticity_term/ui/
cfn.rs

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