Skip to content

Commit 5599362

Browse files
committed
feat: add CloudFormation stack events and outputs sub-resources
- Add Events sub-resource (shortcut 'e') showing stack event timeline - Add Outputs sub-resource (shortcut 'o') showing stack output key/values - Add CFN-specific state colors (CREATE_COMPLETE, UPDATE_FAILED, etc.) - Add preserve_order field to skip alphabetical sorting for time-ordered data - Save/restore selection and filters when navigating into sub-resources
1 parent ee59e94 commit 5599362

5 files changed

Lines changed: 185 additions & 9 deletions

File tree

src/app.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ pub struct ParentContext {
5454
pub item: Value,
5555
/// Display name for breadcrumb
5656
pub display_name: String,
57+
/// Saved selection index to restore when navigating back
58+
pub saved_selected: usize,
5759
}
5860

5961
/// AWS API Filters for server-side filtering
@@ -1303,6 +1305,7 @@ impl App {
13031305
resource_key: self.current_resource_key.clone(),
13041306
item: selected_item,
13051307
display_name: display,
1308+
saved_selected: self.selected,
13061309
});
13071310

13081311
// Navigate
@@ -1326,7 +1329,7 @@ impl App {
13261329

13271330
// Navigate to parent resource
13281331
self.current_resource_key = parent.resource_key;
1329-
self.selected = 0;
1332+
self.selected = parent.saved_selected;
13301333
self.filter_text.clear();
13311334
self.filter_active = false;
13321335

src/resource/fetcher.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,15 @@ pub async fn fetch_resources_paginated(
131131
let mut items = extract_items(&response, &resource_def.response_path)?;
132132

133133
// 5. Sort items by name_field (or id_field) for consistent ordering
134-
let sort_field = &resource_def.name_field;
135-
items.sort_by(|a, b| {
136-
let a_val = a.get(sort_field).and_then(|v| v.as_str()).unwrap_or("");
137-
let b_val = b.get(sort_field).and_then(|v| v.as_str()).unwrap_or("");
138-
a_val.cmp(b_val)
139-
});
134+
// Skip sorting if the resource wants to preserve API order (e.g., events sorted by time)
135+
if !resource_def.preserve_order {
136+
let sort_field = &resource_def.name_field;
137+
items.sort_by(|a, b| {
138+
let a_val = a.get(sort_field).and_then(|v| v.as_str()).unwrap_or("");
139+
let b_val = b.get(sort_field).and_then(|v| v.as_str()).unwrap_or("");
140+
a_val.cmp(b_val)
141+
});
142+
}
140143

141144
// 6. Extract next_token from response (if present)
142145
let next_token = response

src/resource/registry.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,10 @@ pub struct ResourceDef {
211211
/// Used for sub-resources like Log Streams that need a Log Group
212212
#[serde(default)]
213213
pub requires_parent: bool,
214+
215+
/// If true, preserve the order returned by the API instead of sorting alphabetically
216+
#[serde(default)]
217+
pub preserve_order: bool,
214218
}
215219

216220
impl ResourceDef {
@@ -642,4 +646,63 @@ mod tests {
642646
"get_parameter should use 'x' shortcut"
643647
);
644648
}
649+
650+
#[test]
651+
fn test_cloudformation_stacks_has_sub_resources() {
652+
let resource = get_resource("cloudformation-stacks").unwrap();
653+
assert_eq!(resource.display_name, "CloudFormation Stacks");
654+
655+
let events_sub = resource
656+
.sub_resources
657+
.iter()
658+
.find(|s| s.resource_key == "cloudformation-events");
659+
assert!(events_sub.is_some(), "Stacks should have events sub-resource");
660+
assert_eq!(events_sub.unwrap().shortcut, "e");
661+
662+
let outputs_sub = resource
663+
.sub_resources
664+
.iter()
665+
.find(|s| s.resource_key == "cloudformation-outputs");
666+
assert!(outputs_sub.is_some(), "Stacks should have outputs sub-resource");
667+
assert_eq!(outputs_sub.unwrap().shortcut, "o");
668+
}
669+
670+
#[test]
671+
fn test_cloudformation_events_resource() {
672+
let resource = get_resource("cloudformation-events").unwrap();
673+
assert_eq!(resource.display_name, "Stack Events");
674+
assert!(resource.requires_parent, "Events should require a parent stack");
675+
assert!(resource.preserve_order, "Events should preserve API order (chronological)");
676+
677+
let col_headers: Vec<&str> = resource.columns.iter().map(|c| c.header.as_str()).collect();
678+
assert!(col_headers.contains(&"TIMESTAMP"));
679+
assert!(col_headers.contains(&"STATUS"));
680+
assert!(col_headers.contains(&"LOGICAL ID"));
681+
}
682+
683+
#[test]
684+
fn test_cloudformation_outputs_resource() {
685+
let resource = get_resource("cloudformation-outputs").unwrap();
686+
assert_eq!(resource.display_name, "Stack Outputs");
687+
assert!(resource.requires_parent, "Outputs should require a parent stack");
688+
689+
let col_headers: Vec<&str> = resource.columns.iter().map(|c| c.header.as_str()).collect();
690+
assert!(col_headers.contains(&"KEY"));
691+
assert!(col_headers.contains(&"VALUE"));
692+
}
693+
694+
#[test]
695+
fn test_cfn_state_colors_exist() {
696+
let create_complete = get_color_for_value("state", "CREATE_COMPLETE");
697+
assert_eq!(create_complete, Some([0, 255, 0]));
698+
699+
let create_failed = get_color_for_value("state", "CREATE_FAILED");
700+
assert_eq!(create_failed, Some([255, 0, 0]));
701+
702+
let create_in_progress = get_color_for_value("state", "CREATE_IN_PROGRESS");
703+
assert_eq!(create_in_progress, Some([255, 255, 0]));
704+
705+
let delete_complete = get_color_for_value("state", "DELETE_COMPLETE");
706+
assert_eq!(delete_complete, Some([128, 128, 128]));
707+
}
645708
}

src/resources/cloudformation.json

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,22 @@
1515
{ "header": "CREATED", "json_path": "CreationTime", "width": 25 },
1616
{ "header": "UPDATED", "json_path": "LastUpdatedTime", "width": 25 }
1717
],
18-
"sub_resources": [],
18+
"sub_resources": [
19+
{
20+
"shortcut": "e",
21+
"display_name": "Events",
22+
"resource_key": "cloudformation-events",
23+
"parent_id_field": "StackName",
24+
"filter_param": "stack_name"
25+
},
26+
{
27+
"shortcut": "o",
28+
"display_name": "Outputs",
29+
"resource_key": "cloudformation-outputs",
30+
"parent_id_field": "StackName",
31+
"filter_param": "stack_name"
32+
}
33+
],
1934
"actions": [
2035
{ "key": "ctrl+d", "display_name": "Delete Stack", "shortcut": "ctrl+d", "sdk_method": "delete_stack", "confirm": { "message": "Delete CloudFormation stack", "default_yes": false, "destructive": true } }
2136
],
@@ -40,6 +55,76 @@
4055
"id_param": "StackName"
4156
}
4257
}
58+
},
59+
"cloudformation-events": {
60+
"display_name": "Stack Events",
61+
"service": "cloudformation",
62+
"sdk_method": "describe_stack_events",
63+
"sdk_method_params": {},
64+
"response_path": "events",
65+
"id_field": "EventId",
66+
"name_field": "LogicalResourceId",
67+
"is_global": false,
68+
"requires_parent": true,
69+
"preserve_order": true,
70+
"columns": [
71+
{ "header": "TIMESTAMP", "json_path": "Timestamp", "width": 22 },
72+
{ "header": "LOGICAL ID", "json_path": "LogicalResourceId", "width": 28 },
73+
{ "header": "STATUS", "json_path": "ResourceStatus", "width": 28, "color_map": "state" },
74+
{ "header": "TYPE", "json_path": "ResourceType", "width": 30 },
75+
{ "header": "REASON", "json_path": "ResourceStatusReason", "width": 40 }
76+
],
77+
"sub_resources": [],
78+
"actions": [],
79+
"api_config": {
80+
"protocol": "query",
81+
"action": "DescribeStackEvents",
82+
"response_root": "/DescribeStackEventsResponse/DescribeStackEventsResult/StackEvents/member",
83+
"param_mapping": {
84+
"stack_name": "StackName"
85+
}
86+
},
87+
"field_mappings": {
88+
"EventId": { "source": "/EventId", "default": "-" },
89+
"StackName": { "source": "/StackName", "default": "-" },
90+
"LogicalResourceId": { "source": "/LogicalResourceId", "default": "-" },
91+
"PhysicalResourceId": { "source": "/PhysicalResourceId", "default": "-" },
92+
"ResourceType": { "source": "/ResourceType", "default": "-" },
93+
"ResourceStatus": { "source": "/ResourceStatus", "default": "-" },
94+
"ResourceStatusReason": { "source": "/ResourceStatusReason", "default": "-" },
95+
"Timestamp": { "source": "/Timestamp", "default": "-" }
96+
}
97+
},
98+
"cloudformation-outputs": {
99+
"display_name": "Stack Outputs",
100+
"service": "cloudformation",
101+
"sdk_method": "describe_stacks",
102+
"sdk_method_params": {},
103+
"response_path": "outputs",
104+
"id_field": "OutputKey",
105+
"name_field": "OutputKey",
106+
"is_global": false,
107+
"requires_parent": true,
108+
"columns": [
109+
{ "header": "KEY", "json_path": "OutputKey", "width": 50 },
110+
{ "header": "VALUE", "json_path": "OutputValue", "width": 50 }
111+
],
112+
"sub_resources": [],
113+
"actions": [],
114+
"api_config": {
115+
"protocol": "query",
116+
"action": "DescribeStacks",
117+
"response_root": "/DescribeStacksResponse/DescribeStacksResult/Stacks/member/Outputs/member",
118+
"param_mapping": {
119+
"stack_name": "StackName"
120+
}
121+
},
122+
"field_mappings": {
123+
"OutputKey": { "source": "/OutputKey", "default": "-" },
124+
"OutputValue": { "source": "/OutputValue", "default": "-" },
125+
"Description": { "source": "/Description", "default": "-" },
126+
"ExportName": { "source": "/ExportName", "default": "-" }
127+
}
43128
}
44129
}
45130
}

src/resources/common.json

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,29 @@
2424
{ "value": "terminating", "color": [255, 255, 0] },
2525
{ "value": "in-progress", "color": [255, 255, 0] },
2626
{ "value": "initializing", "color": [255, 255, 0] },
27-
{ "value": "rebooting", "color": [255, 255, 0] }
27+
{ "value": "rebooting", "color": [255, 255, 0] },
28+
{ "value": "CREATE_COMPLETE", "color": [0, 255, 0] },
29+
{ "value": "UPDATE_COMPLETE", "color": [0, 255, 0] },
30+
{ "value": "IMPORT_COMPLETE", "color": [0, 255, 0] },
31+
{ "value": "DELETE_COMPLETE", "color": [128, 128, 128] },
32+
{ "value": "ROLLBACK_COMPLETE", "color": [255, 0, 0] },
33+
{ "value": "CREATE_FAILED", "color": [255, 0, 0] },
34+
{ "value": "UPDATE_FAILED", "color": [255, 0, 0] },
35+
{ "value": "DELETE_FAILED", "color": [255, 0, 0] },
36+
{ "value": "IMPORT_ROLLBACK_COMPLETE", "color": [255, 0, 0] },
37+
{ "value": "UPDATE_ROLLBACK_COMPLETE", "color": [255, 0, 0] },
38+
{ "value": "UPDATE_ROLLBACK_FAILED", "color": [255, 0, 0] },
39+
{ "value": "ROLLBACK_FAILED", "color": [255, 0, 0] },
40+
{ "value": "CREATE_IN_PROGRESS", "color": [255, 255, 0] },
41+
{ "value": "UPDATE_IN_PROGRESS", "color": [255, 255, 0] },
42+
{ "value": "DELETE_IN_PROGRESS", "color": [255, 255, 0] },
43+
{ "value": "ROLLBACK_IN_PROGRESS", "color": [255, 255, 0] },
44+
{ "value": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", "color": [255, 255, 0] },
45+
{ "value": "UPDATE_ROLLBACK_IN_PROGRESS", "color": [255, 255, 0] },
46+
{ "value": "UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS", "color": [255, 255, 0] },
47+
{ "value": "IMPORT_IN_PROGRESS", "color": [255, 255, 0] },
48+
{ "value": "IMPORT_ROLLBACK_IN_PROGRESS", "color": [255, 255, 0] },
49+
{ "value": "REVIEW_IN_PROGRESS", "color": [255, 255, 0] }
2850
],
2951
"bool": [
3052
{ "value": "true", "color": [0, 255, 255] },

0 commit comments

Comments
 (0)