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