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 ];
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), Constraint::Min(0), ])
222 .split(area);
223
224 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 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 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 if app.mode == Mode::FilterInput && app.cfn_state.input_focus == STATUS_FILTER {
314 let max_filter_width = StatusFilter::all()
316 .iter()
317 .map(|f| f.name().len())
318 .max()
319 .unwrap_or(10) as u16
320 + 4; 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 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 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), Constraint::Min(0), ])
392 .split(area);
393
394 frame.render_widget(Paragraph::new(stack.name.clone()), chunks[0]);
396
397 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 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; 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; 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; 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; 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; 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), ])
620 .split(area);
621
622 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 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 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 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 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}