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 ];
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), Constraint::Min(0), ])
214 .split(area);
215
216 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 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 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 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 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), Constraint::Min(0), ])
352 .split(area);
353
354 frame.render_widget(Paragraph::new(stack.name.clone()), chunks[0]);
356
357 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 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; 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; 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; 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; 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; 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), ])
580 .split(area);
581
582 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 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 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 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 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}