1pub mod actions;
2
3use crate::common::{translate_column, ColumnId, UTC_TIMESTAMP_WIDTH};
4use crate::ui::cfn::DetailTab;
5use crate::ui::table::Column as TableColumn;
6use ratatui::prelude::*;
7use std::collections::HashMap;
8
9pub fn init(i18n: &mut HashMap<String, String>) {
10 for col in [
11 Column::Name,
12 Column::StackId,
13 Column::Status,
14 Column::CreatedTime,
15 Column::UpdatedTime,
16 Column::DeletedTime,
17 Column::DriftStatus,
18 Column::LastDriftCheckTime,
19 Column::StatusReason,
20 Column::Description,
21 ] {
22 i18n.entry(col.id().to_string())
23 .or_insert_with(|| col.default_name().to_string());
24 }
25 for col in crate::ui::cfn::ParameterColumn::all() {
26 i18n.entry(col.id().to_string())
27 .or_insert_with(|| col.default_name().to_string());
28 }
29 for col in crate::ui::cfn::OutputColumn::all() {
30 i18n.entry(col.id().to_string())
31 .or_insert_with(|| col.default_name().to_string());
32 }
33 for col in crate::ui::cfn::ResourceColumn::all() {
34 i18n.entry(col.id().to_string())
35 .or_insert_with(|| col.default_name().to_string());
36 }
37}
38
39pub fn console_url_stacks(region: &str) -> String {
40 format!(
41 "https://{}.console.aws.amazon.com/cloudformation/home?region={}#/stacks",
42 region, region
43 )
44}
45
46pub fn console_url_stack_detail(region: &str, stack_name: &str, stack_id: &str) -> String {
47 format!(
48 "https://{}.console.aws.amazon.com/cloudformation/home?region={}#/stacks/{}?filteringText=&filteringStatus=active&viewNested=true&stackId={}",
49 region, region, stack_name, stack_id
50 )
51}
52
53pub fn console_url_stack_detail_with_tab(region: &str, stack_id: &str, tab: &DetailTab) -> String {
54 let tab_path = match tab {
55 DetailTab::StackInfo => "stackinfo",
56 DetailTab::Events => "events",
57 DetailTab::Resources => "resources",
58 DetailTab::Outputs => "outputs",
59 DetailTab::Parameters => "parameters",
60 DetailTab::Template => "template",
61 DetailTab::ChangeSets => "changesets",
62 DetailTab::GitSync => "gitsync",
63 };
64 let encoded_arn = urlencoding::encode(stack_id);
65 let extra = if matches!(tab, DetailTab::ChangeSets) {
67 "&eventsView=graph"
68 } else {
69 ""
70 };
71 format!(
72 "https://{}.console.aws.amazon.com/cloudformation/home?region={}#/stacks/{}?filteringText=&filteringStatus=active&viewNested=true&stackId={}{}",
73 region, region, tab_path, encoded_arn, extra
74 )
75}
76
77#[derive(Debug, Clone, Default)]
78pub struct Stack {
79 pub name: String,
80 pub stack_id: String,
81 pub status: String,
82 pub created_time: String,
83 pub updated_time: String,
84 pub deleted_time: String,
85 pub drift_status: String,
86 pub last_drift_check_time: String,
87 pub status_reason: String,
88 pub description: String,
89 pub detailed_status: String,
90 pub root_stack: String,
91 pub parent_stack: String,
92 pub termination_protection: bool,
93 pub iam_role: String,
94 pub tags: Vec<(String, String)>,
95 pub stack_policy: String,
96 pub rollback_monitoring_time: String,
97 pub rollback_alarms: Vec<String>,
98 pub notification_arns: Vec<String>,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub enum Column {
103 Name,
104 StackId,
105 Status,
106 CreatedTime,
107 UpdatedTime,
108 DeletedTime,
109 DriftStatus,
110 LastDriftCheckTime,
111 StatusReason,
112 Description,
113}
114
115impl Column {
116 const ID_NAME: &'static str = "column.cfn.stack.name";
117 const ID_STACK_ID: &'static str = "column.cfn.stack.stack_id";
118 const ID_STATUS: &'static str = "column.cfn.stack.status";
119 const ID_CREATED_TIME: &'static str = "column.cfn.stack.created_time";
120 const ID_UPDATED_TIME: &'static str = "column.cfn.stack.updated_time";
121 const ID_DELETED_TIME: &'static str = "column.cfn.stack.deleted_time";
122 const ID_DRIFT_STATUS: &'static str = "column.cfn.stack.drift_status";
123 const ID_LAST_DRIFT_CHECK_TIME: &'static str = "column.cfn.stack.last_drift_check_time";
124 const ID_STATUS_REASON: &'static str = "column.cfn.stack.status_reason";
125 const ID_DESCRIPTION: &'static str = "column.cfn.stack.description";
126
127 pub const fn id(&self) -> &'static str {
128 match self {
129 Column::Name => Self::ID_NAME,
130 Column::StackId => Self::ID_STACK_ID,
131 Column::Status => Self::ID_STATUS,
132 Column::CreatedTime => Self::ID_CREATED_TIME,
133 Column::UpdatedTime => Self::ID_UPDATED_TIME,
134 Column::DeletedTime => Self::ID_DELETED_TIME,
135 Column::DriftStatus => Self::ID_DRIFT_STATUS,
136 Column::LastDriftCheckTime => Self::ID_LAST_DRIFT_CHECK_TIME,
137 Column::StatusReason => Self::ID_STATUS_REASON,
138 Column::Description => Self::ID_DESCRIPTION,
139 }
140 }
141
142 pub const fn default_name(&self) -> &'static str {
143 match self {
144 Column::Name => "Stack Name",
145 Column::StackId => "Stack ID",
146 Column::Status => "Status",
147 Column::CreatedTime => "Created Time",
148 Column::UpdatedTime => "Updated Time",
149 Column::DeletedTime => "Deleted Time",
150 Column::DriftStatus => "Drift Status",
151 Column::LastDriftCheckTime => "Last Drift Check Time",
152 Column::StatusReason => "Status Reason",
153 Column::Description => "Description",
154 }
155 }
156
157 pub fn name(&self) -> String {
158 translate_column(self.id(), self.default_name())
159 }
160
161 pub fn from_id(id: &str) -> Option<Self> {
162 match id {
163 Self::ID_NAME => Some(Column::Name),
164 Self::ID_STACK_ID => Some(Column::StackId),
165 Self::ID_STATUS => Some(Column::Status),
166 Self::ID_CREATED_TIME => Some(Column::CreatedTime),
167 Self::ID_UPDATED_TIME => Some(Column::UpdatedTime),
168 Self::ID_DELETED_TIME => Some(Column::DeletedTime),
169 Self::ID_DRIFT_STATUS => Some(Column::DriftStatus),
170 Self::ID_LAST_DRIFT_CHECK_TIME => Some(Column::LastDriftCheckTime),
171 Self::ID_STATUS_REASON => Some(Column::StatusReason),
172 Self::ID_DESCRIPTION => Some(Column::Description),
173 _ => None,
174 }
175 }
176
177 pub fn all() -> [Column; 10] {
178 [
179 Column::Name,
180 Column::StackId,
181 Column::Status,
182 Column::CreatedTime,
183 Column::UpdatedTime,
184 Column::DeletedTime,
185 Column::DriftStatus,
186 Column::LastDriftCheckTime,
187 Column::StatusReason,
188 Column::Description,
189 ]
190 }
191
192 pub fn ids() -> Vec<ColumnId> {
193 Self::all().iter().map(|c| c.id()).collect()
194 }
195
196 pub fn to_column(&self) -> Box<dyn TableColumn<&Stack>> {
197 struct StackColumn {
198 variant: Column,
199 }
200
201 impl TableColumn<&Stack> for StackColumn {
202 fn name(&self) -> &str {
203 Box::leak(self.variant.name().into_boxed_str())
204 }
205
206 fn width(&self) -> u16 {
207 let translated = translate_column(self.variant.id(), self.variant.default_name());
208 translated.len().max(match self.variant {
209 Column::Name => 30,
210 Column::StackId => 20,
211 Column::Status => 35,
212 Column::CreatedTime
213 | Column::UpdatedTime
214 | Column::DeletedTime
215 | Column::LastDriftCheckTime => UTC_TIMESTAMP_WIDTH as usize,
216 Column::DriftStatus => 20,
217 Column::StatusReason | Column::Description => 50,
218 }) as u16
219 }
220
221 fn render(&self, item: &&Stack) -> (String, Style) {
222 match self.variant {
223 Column::Name => {
224 let is_nested =
225 !item.parent_stack.is_empty() && item.parent_stack != item.stack_id;
226 let name = if is_nested {
227 format!(" {}", item.name)
229 } else {
230 item.name.clone()
232 };
233 (name, Style::default())
234 }
235 Column::StackId => (item.stack_id.clone(), Style::default()),
236 Column::Status => {
237 let (formatted, color) = format_status(&item.status);
238 (formatted, Style::default().fg(color))
239 }
240 Column::CreatedTime => (item.created_time.clone(), Style::default()),
241 Column::UpdatedTime => (item.updated_time.clone(), Style::default()),
242 Column::DeletedTime => (item.deleted_time.clone(), Style::default()),
243 Column::DriftStatus => (item.drift_status.clone(), Style::default()),
244 Column::LastDriftCheckTime => {
245 (item.last_drift_check_time.clone(), Style::default())
246 }
247 Column::StatusReason => (item.status_reason.clone(), Style::default()),
248 Column::Description => (item.description.clone(), Style::default()),
249 }
250 }
251 }
252
253 Box::new(StackColumn { variant: *self })
254 }
255}
256
257pub fn format_status(status: &str) -> (String, ratatui::style::Color) {
258 let (emoji, color) = match status {
259 "UPDATE_COMPLETE" | "CREATE_COMPLETE" | "DELETE_COMPLETE" | "IMPORT_COMPLETE" => {
260 ("✅ ", ratatui::style::Color::Green)
261 }
262 "ROLLBACK_COMPLETE"
263 | "UPDATE_ROLLBACK_COMPLETE"
264 | "UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS"
265 | "UPDATE_FAILED"
266 | "CREATE_FAILED"
267 | "DELETE_FAILED"
268 | "ROLLBACK_FAILED"
269 | "UPDATE_ROLLBACK_FAILED"
270 | "IMPORT_ROLLBACK_FAILED"
271 | "IMPORT_ROLLBACK_COMPLETE" => ("❌ ", ratatui::style::Color::Red),
272 "UPDATE_IN_PROGRESS"
273 | "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS"
274 | "DELETE_IN_PROGRESS"
275 | "CREATE_IN_PROGRESS"
276 | "ROLLBACK_IN_PROGRESS"
277 | "UPDATE_ROLLBACK_IN_PROGRESS"
278 | "REVIEW_IN_PROGRESS"
279 | "IMPORT_IN_PROGRESS"
280 | "IMPORT_ROLLBACK_IN_PROGRESS" => ("ℹ️ ", ratatui::style::Color::Blue),
281 _ => ("", ratatui::style::Color::White),
282 };
283
284 (format!("{}{}", emoji, status), color)
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use crate::common::{CyclicEnum, SortDirection};
291 use crate::ui::cfn::{DetailTab, State, StatusFilter};
292
293 #[test]
294 fn test_state_default() {
295 let state = State::default();
296 assert_eq!(state.table.items.len(), 0);
297 assert_eq!(state.table.selected, 0);
298 assert!(!state.table.loading);
299 assert_eq!(state.table.filter, "");
300 assert_eq!(state.status_filter, StatusFilter::All);
301 assert!(!state.view_nested);
302 assert_eq!(state.table.expanded_item, None);
303 assert_eq!(state.current_stack, None);
304 assert_eq!(state.detail_tab, DetailTab::StackInfo);
305 assert_eq!(state.overview_scroll, 0);
306 assert_eq!(state.sort_column, Column::CreatedTime);
307 assert_eq!(state.sort_direction, SortDirection::Desc);
308 }
309
310 #[test]
311 fn test_status_filter_names() {
312 assert_eq!(StatusFilter::Active.name(), "Active");
313 assert_eq!(StatusFilter::Complete.name(), "Complete");
314 assert_eq!(StatusFilter::Failed.name(), "Failed");
315 assert_eq!(StatusFilter::Deleted.name(), "Deleted");
316 assert_eq!(StatusFilter::InProgress.name(), "In progress");
317 }
318
319 #[test]
320 fn test_status_filter_next() {
321 assert_eq!(StatusFilter::All.next(), StatusFilter::Active);
322 assert_eq!(StatusFilter::Active.next(), StatusFilter::Complete);
323 assert_eq!(StatusFilter::Complete.next(), StatusFilter::Failed);
324 assert_eq!(StatusFilter::Failed.next(), StatusFilter::Deleted);
325 assert_eq!(StatusFilter::Deleted.next(), StatusFilter::InProgress);
326 assert_eq!(StatusFilter::InProgress.next(), StatusFilter::All);
327 }
328
329 #[test]
330 fn test_status_filter_matches_active() {
331 let filter = StatusFilter::Active;
332 assert!(filter.matches("CREATE_IN_PROGRESS"));
333 assert!(filter.matches("UPDATE_IN_PROGRESS"));
334 assert!(!filter.matches("CREATE_COMPLETE"));
335 assert!(!filter.matches("DELETE_COMPLETE"));
336 assert!(!filter.matches("CREATE_FAILED"));
337 }
338
339 #[test]
340 fn test_status_filter_matches_complete() {
341 let filter = StatusFilter::Complete;
342 assert!(filter.matches("CREATE_COMPLETE"));
343 assert!(filter.matches("UPDATE_COMPLETE"));
344 assert!(!filter.matches("DELETE_COMPLETE"));
345 assert!(!filter.matches("CREATE_FAILED"));
346 assert!(!filter.matches("CREATE_IN_PROGRESS"));
347 }
348
349 #[test]
350 fn test_status_filter_matches_failed() {
351 let filter = StatusFilter::Failed;
352 assert!(filter.matches("CREATE_FAILED"));
353 assert!(filter.matches("UPDATE_FAILED"));
354 assert!(filter.matches("ROLLBACK_FAILED"));
355 assert!(!filter.matches("CREATE_COMPLETE"));
356 assert!(!filter.matches("DELETE_COMPLETE"));
357 }
358
359 #[test]
360 fn test_status_filter_matches_deleted() {
361 let filter = StatusFilter::Deleted;
362 assert!(filter.matches("DELETE_COMPLETE"));
363 assert!(filter.matches("DELETE_IN_PROGRESS"));
364 assert!(filter.matches("DELETE_FAILED"));
365 assert!(!filter.matches("CREATE_COMPLETE"));
366 assert!(!filter.matches("UPDATE_FAILED"));
367 }
368
369 #[test]
370 fn test_status_filter_matches_in_progress() {
371 let filter = StatusFilter::InProgress;
372 assert!(filter.matches("CREATE_IN_PROGRESS"));
373 assert!(filter.matches("UPDATE_IN_PROGRESS"));
374 assert!(filter.matches("DELETE_IN_PROGRESS"));
375 assert!(!filter.matches("CREATE_COMPLETE"));
376 assert!(!filter.matches("CREATE_FAILED"));
377 }
378
379 #[test]
380 fn test_detail_tab_names() {
381 assert_eq!(DetailTab::StackInfo.name(), "Stack info");
382 assert_eq!(DetailTab::Events.name(), "Events");
383 assert_eq!(DetailTab::Resources.name(), "Resources");
384 assert_eq!(DetailTab::Outputs.name(), "Outputs");
385 assert_eq!(DetailTab::Parameters.name(), "Parameters");
386 assert_eq!(DetailTab::Template.name(), "Template");
387 assert_eq!(DetailTab::ChangeSets.name(), "Change sets");
388 assert_eq!(DetailTab::GitSync.name(), "Git sync");
389 }
390
391 #[test]
392 fn test_detail_tab_next() {
393 assert_eq!(DetailTab::StackInfo.next(), DetailTab::Events);
394 }
395
396 #[test]
397 fn test_column_names() {
398 assert_eq!(Column::Name.name(), "Stack Name");
399 assert_eq!(Column::StackId.name(), "Stack ID");
400 assert_eq!(Column::Status.name(), "Status");
401 assert_eq!(Column::CreatedTime.name(), "Created Time");
402 assert_eq!(Column::UpdatedTime.name(), "Updated Time");
403 assert_eq!(Column::DeletedTime.name(), "Deleted Time");
404 assert_eq!(Column::DriftStatus.name(), "Drift Status");
405 assert_eq!(Column::LastDriftCheckTime.name(), "Last Drift Check Time");
406 assert_eq!(Column::StatusReason.name(), "Status Reason");
407 assert_eq!(Column::Description.name(), "Description");
408 }
409
410 #[test]
411 fn test_column_all() {
412 let columns = Column::ids();
413 assert_eq!(columns.len(), 10);
414 assert_eq!(columns[0], Column::Name.id());
415 assert_eq!(columns[9], Column::Description.id());
416 }
417
418 #[test]
419 fn test_format_status_complete_green() {
420 let (formatted, color) = format_status("UPDATE_COMPLETE");
421 assert_eq!(formatted, "✅ UPDATE_COMPLETE");
422 assert_eq!(color, ratatui::style::Color::Green);
423
424 let (formatted, color) = format_status("CREATE_COMPLETE");
425 assert_eq!(formatted, "✅ CREATE_COMPLETE");
426 assert_eq!(color, ratatui::style::Color::Green);
427
428 let (formatted, color) = format_status("DELETE_COMPLETE");
429 assert_eq!(formatted, "✅ DELETE_COMPLETE");
430 assert_eq!(color, ratatui::style::Color::Green);
431 }
432
433 #[test]
434 fn test_format_status_failed_red() {
435 let (formatted, color) = format_status("UPDATE_FAILED");
436 assert_eq!(formatted, "❌ UPDATE_FAILED");
437 assert_eq!(color, ratatui::style::Color::Red);
438
439 let (formatted, color) = format_status("CREATE_FAILED");
440 assert_eq!(formatted, "❌ CREATE_FAILED");
441 assert_eq!(color, ratatui::style::Color::Red);
442
443 let (formatted, color) = format_status("DELETE_FAILED");
444 assert_eq!(formatted, "❌ DELETE_FAILED");
445 assert_eq!(color, ratatui::style::Color::Red);
446
447 let (formatted, color) = format_status("ROLLBACK_FAILED");
448 assert_eq!(formatted, "❌ ROLLBACK_FAILED");
449 assert_eq!(color, ratatui::style::Color::Red);
450 }
451
452 #[test]
453 fn test_format_status_rollback_red() {
454 let (formatted, color) = format_status("ROLLBACK_COMPLETE");
455 assert_eq!(formatted, "❌ ROLLBACK_COMPLETE");
456 assert_eq!(color, ratatui::style::Color::Red);
457
458 let (formatted, color) = format_status("UPDATE_ROLLBACK_COMPLETE");
459 assert_eq!(formatted, "❌ UPDATE_ROLLBACK_COMPLETE");
460 assert_eq!(color, ratatui::style::Color::Red);
461
462 let (formatted, color) = format_status("UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS");
463 assert_eq!(formatted, "❌ UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS");
464 assert_eq!(color, ratatui::style::Color::Red);
465 }
466
467 #[test]
468 fn test_format_status_in_progress_blue() {
469 let (formatted, color) = format_status("UPDATE_IN_PROGRESS");
470 assert_eq!(formatted, "ℹ️ UPDATE_IN_PROGRESS");
471 assert_eq!(color, ratatui::style::Color::Blue);
472
473 let (formatted, color) = format_status("CREATE_IN_PROGRESS");
474 assert_eq!(formatted, "ℹ️ CREATE_IN_PROGRESS");
475 assert_eq!(color, ratatui::style::Color::Blue);
476
477 let (formatted, color) = format_status("DELETE_IN_PROGRESS");
478 assert_eq!(formatted, "ℹ️ DELETE_IN_PROGRESS");
479 assert_eq!(color, ratatui::style::Color::Blue);
480
481 let (formatted, color) = format_status("UPDATE_COMPLETE_CLEANUP_IN_PROGRESS");
482 assert_eq!(formatted, "ℹ️ UPDATE_COMPLETE_CLEANUP_IN_PROGRESS");
483 assert_eq!(color, ratatui::style::Color::Blue);
484
485 let (formatted, color) = format_status("ROLLBACK_IN_PROGRESS");
486 assert_eq!(formatted, "ℹ️ ROLLBACK_IN_PROGRESS");
487 assert_eq!(color, ratatui::style::Color::Blue);
488
489 let (formatted, color) = format_status("UPDATE_ROLLBACK_IN_PROGRESS");
490 assert_eq!(formatted, "ℹ️ UPDATE_ROLLBACK_IN_PROGRESS");
491 assert_eq!(color, ratatui::style::Color::Blue);
492 }
493
494 #[test]
495 fn test_format_status_unknown() {
496 let (formatted, color) = format_status("UNKNOWN_STATUS");
497 assert_eq!(formatted, "UNKNOWN_STATUS");
498 assert_eq!(color, ratatui::style::Color::White);
499 }
500
501 #[test]
502 fn test_format_status_emoji_spacing() {
503 let (formatted, _) = format_status("CREATE_IN_PROGRESS");
505 assert!(formatted.starts_with("ℹ️ ")); let (formatted, _) = format_status("CREATE_COMPLETE");
508 assert!(formatted.starts_with("✅ ")); let (formatted, _) = format_status("CREATE_FAILED");
511 assert!(formatted.starts_with("❌ ")); }
513
514 #[test]
515 fn test_all_aws_statuses_covered() {
516 let statuses = vec![
518 "CREATE_IN_PROGRESS",
519 "CREATE_FAILED",
520 "CREATE_COMPLETE",
521 "ROLLBACK_IN_PROGRESS",
522 "ROLLBACK_FAILED",
523 "ROLLBACK_COMPLETE",
524 "DELETE_IN_PROGRESS",
525 "DELETE_FAILED",
526 "DELETE_COMPLETE",
527 "UPDATE_IN_PROGRESS",
528 "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS",
529 "UPDATE_COMPLETE",
530 "UPDATE_FAILED",
531 "UPDATE_ROLLBACK_IN_PROGRESS",
532 "UPDATE_ROLLBACK_FAILED",
533 "UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS",
534 "UPDATE_ROLLBACK_COMPLETE",
535 "REVIEW_IN_PROGRESS",
536 "IMPORT_IN_PROGRESS",
537 "IMPORT_COMPLETE",
538 "IMPORT_ROLLBACK_IN_PROGRESS",
539 "IMPORT_ROLLBACK_FAILED",
540 "IMPORT_ROLLBACK_COMPLETE",
541 ];
542
543 for status in statuses {
544 let (formatted, _) = format_status(status);
545 assert!(!formatted.is_empty());
547 assert!(formatted.len() > 2); }
549 }
550
551 #[test]
552 fn test_column_ids_have_correct_prefix() {
553 for col in Column::all() {
554 assert!(
555 col.id().starts_with("column.cfn.stack."),
556 "Column ID '{}' should start with 'column.cfn.stack.'",
557 col.id()
558 );
559 }
560 }
561}