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