Skip to main content

rusticity_term/ui/
ec2.rs

1use crate::common::{
2    filter_by_fields, render_pagination_text, CyclicEnum, InputFocus, SortDirection,
3};
4use crate::ec2::tag::{Column as TagColumn, InstanceTag};
5use crate::ec2::{Column, Instance};
6use crate::keymap::Mode;
7use crate::table::TableState;
8use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
9use crate::ui::table::{expanded_from_columns, render_table, Column as TableColumn, TableConfig};
10use crate::ui::{
11    calculate_dynamic_height, format_title, render_fields_with_dynamic_columns, rounded_block,
12    titled_block,
13};
14use ratatui::prelude::*;
15
16pub const FILTER_CONTROLS: [InputFocus; 3] = [
17    InputFocus::Filter,
18    InputFocus::Checkbox("state"),
19    InputFocus::Pagination,
20];
21
22pub const STATE_FILTER: InputFocus = InputFocus::Checkbox("state");
23
24#[derive(Debug, Clone, Copy, PartialEq, Default)]
25pub enum StateFilter {
26    #[default]
27    AllStates,
28    Running,
29    Stopped,
30    Terminated,
31    Pending,
32    ShuttingDown,
33    Stopping,
34}
35
36impl StateFilter {
37    pub fn name(&self) -> &'static str {
38        match self {
39            StateFilter::AllStates => "All states",
40            StateFilter::Running => "Running",
41            StateFilter::Stopped => "Stopped",
42            StateFilter::Terminated => "Terminated",
43            StateFilter::Pending => "Pending",
44            StateFilter::ShuttingDown => "Shutting down",
45            StateFilter::Stopping => "Stopping",
46        }
47    }
48
49    pub fn matches(&self, state: &str) -> bool {
50        match self {
51            StateFilter::AllStates => true,
52            StateFilter::Running => state == "running",
53            StateFilter::Stopped => state == "stopped",
54            StateFilter::Terminated => state == "terminated",
55            StateFilter::Pending => state == "pending",
56            StateFilter::ShuttingDown => state == "shutting-down",
57            StateFilter::Stopping => state == "stopping",
58        }
59    }
60}
61
62impl CyclicEnum for StateFilter {
63    const ALL: &'static [Self] = &[
64        StateFilter::AllStates,
65        StateFilter::Running,
66        StateFilter::Stopped,
67        StateFilter::Terminated,
68        StateFilter::Pending,
69        StateFilter::ShuttingDown,
70        StateFilter::Stopping,
71    ];
72
73    fn next(&self) -> Self {
74        match self {
75            StateFilter::AllStates => StateFilter::Running,
76            StateFilter::Running => StateFilter::Stopped,
77            StateFilter::Stopped => StateFilter::Terminated,
78            StateFilter::Terminated => StateFilter::Pending,
79            StateFilter::Pending => StateFilter::ShuttingDown,
80            StateFilter::ShuttingDown => StateFilter::Stopping,
81            StateFilter::Stopping => StateFilter::AllStates,
82        }
83    }
84
85    fn prev(&self) -> Self {
86        match self {
87            StateFilter::AllStates => StateFilter::Stopping,
88            StateFilter::Running => StateFilter::AllStates,
89            StateFilter::Stopped => StateFilter::Running,
90            StateFilter::Terminated => StateFilter::Stopped,
91            StateFilter::Pending => StateFilter::Terminated,
92            StateFilter::ShuttingDown => StateFilter::Pending,
93            StateFilter::Stopping => StateFilter::ShuttingDown,
94        }
95    }
96}
97
98#[derive(Debug, Clone, Copy, PartialEq)]
99pub enum DetailTab {
100    Details,
101    StatusAndAlarms,
102    Monitoring,
103    Security,
104    Networking,
105    Storage,
106    Tags,
107}
108
109impl CyclicEnum for DetailTab {
110    const ALL: &'static [Self] = &[
111        Self::Details,
112        Self::StatusAndAlarms,
113        Self::Monitoring,
114        Self::Security,
115        Self::Networking,
116        Self::Storage,
117        Self::Tags,
118    ];
119}
120
121impl DetailTab {
122    pub fn name(&self) -> &'static str {
123        match self {
124            DetailTab::Details => "Details",
125            DetailTab::StatusAndAlarms => "Status and alarms",
126            DetailTab::Monitoring => "Monitoring",
127            DetailTab::Security => "Security",
128            DetailTab::Networking => "Networking",
129            DetailTab::Storage => "Storage",
130            DetailTab::Tags => "Tags",
131        }
132    }
133}
134
135pub struct State {
136    pub table: TableState<Instance>,
137    pub state_filter: StateFilter,
138    pub sort_column: Column,
139    pub sort_direction: SortDirection,
140    pub input_focus: InputFocus,
141    pub current_instance: Option<String>,
142    pub detail_tab: DetailTab,
143    pub tags: TableState<InstanceTag>,
144    pub tag_visible_column_ids: Vec<String>,
145    pub tag_column_ids: Vec<String>,
146    pub monitoring_scroll: usize,
147    pub metrics_loading: bool,
148    pub metric_data_cpu: Vec<(i64, f64)>,
149    pub metric_data_network_in: Vec<(i64, f64)>,
150    pub metric_data_network_out: Vec<(i64, f64)>,
151    pub metric_data_network_packets_in: Vec<(i64, f64)>,
152    pub metric_data_network_packets_out: Vec<(i64, f64)>,
153    pub metric_data_metadata_no_token: Vec<(i64, f64)>,
154}
155
156impl Default for State {
157    fn default() -> Self {
158        let tag_column_ids: Vec<String> = TagColumn::ids().iter().map(|s| s.to_string()).collect();
159        Self {
160            table: TableState::default(),
161            state_filter: StateFilter::default(),
162            sort_column: Column::LaunchTime,
163            sort_direction: SortDirection::Desc,
164            input_focus: InputFocus::Filter,
165            current_instance: None,
166            detail_tab: DetailTab::Details,
167            tags: TableState::new(),
168            tag_visible_column_ids: tag_column_ids.clone(),
169            tag_column_ids,
170            monitoring_scroll: 0,
171            metrics_loading: false,
172            metric_data_cpu: Vec::new(),
173            metric_data_network_in: Vec::new(),
174            metric_data_network_out: Vec::new(),
175            metric_data_network_packets_in: Vec::new(),
176            metric_data_network_packets_out: Vec::new(),
177            metric_data_metadata_no_token: Vec::new(),
178        }
179    }
180}
181
182impl crate::ui::monitoring::MonitoringState for State {
183    fn is_metrics_loading(&self) -> bool {
184        self.metrics_loading
185    }
186
187    fn set_metrics_loading(&mut self, loading: bool) {
188        self.metrics_loading = loading;
189    }
190
191    fn monitoring_scroll(&self) -> usize {
192        self.monitoring_scroll
193    }
194
195    fn set_monitoring_scroll(&mut self, scroll: usize) {
196        self.monitoring_scroll = scroll;
197    }
198
199    fn clear_metrics(&mut self) {
200        self.metric_data_cpu.clear();
201        self.metric_data_network_in.clear();
202        self.metric_data_network_out.clear();
203        self.metric_data_network_packets_in.clear();
204        self.metric_data_network_packets_out.clear();
205        self.metric_data_metadata_no_token.clear();
206    }
207}
208
209pub const FILTER_HINT: &str = "Find Instance by attribute or tag (case-sensitive)";
210
211pub fn render_instances(
212    frame: &mut Frame,
213    area: Rect,
214    state: &State,
215    visible_columns: &[&str],
216    mode: Mode,
217) {
218    use crate::common::render_dropdown;
219    use crate::ui::filter::{render_filter_bar, FilterConfig, FilterControl};
220    use crate::ui::table::render_table;
221
222    let chunks = Layout::default()
223        .direction(Direction::Vertical)
224        .constraints([Constraint::Length(3), Constraint::Min(0)])
225        .split(area);
226
227    let filtered_items: Vec<&Instance> = state
228        .table
229        .items
230        .iter()
231        .filter(|i| state.state_filter.matches(&i.state))
232        .filter(|i| {
233            if state.table.filter.is_empty() {
234                return true;
235            }
236            i.name.contains(&state.table.filter)
237                || i.instance_id.contains(&state.table.filter)
238                || i.state.contains(&state.table.filter)
239                || i.instance_type.contains(&state.table.filter)
240                || i.availability_zone.contains(&state.table.filter)
241                || i.security_groups.contains(&state.table.filter)
242                || i.key_name.contains(&state.table.filter)
243        })
244        .collect();
245
246    let page_size = state.table.page_size.value();
247    let filtered_count = filtered_items.len();
248    let total_pages = filtered_count.div_ceil(page_size);
249    let current_page = state.table.selected / page_size;
250    let pagination = render_pagination_text(current_page, total_pages);
251
252    render_filter_bar(
253        frame,
254        FilterConfig {
255            filter_text: &state.table.filter,
256            placeholder: FILTER_HINT,
257            mode,
258            is_input_focused: state.input_focus == InputFocus::Filter,
259            controls: vec![
260                FilterControl {
261                    text: state.state_filter.name().to_string(),
262                    is_focused: state.input_focus == STATE_FILTER,
263                },
264                FilterControl {
265                    text: pagination.clone(),
266                    is_focused: state.input_focus == InputFocus::Pagination,
267                },
268            ],
269            area: chunks[0],
270        },
271    );
272
273    let columns: Vec<_> = visible_columns
274        .iter()
275        .filter_map(|id| Column::from_id(id).map(|c| c.to_column()))
276        .collect();
277
278    let title = format!("Instances ({})", filtered_count);
279
280    use crate::ui::table::TableConfig;
281    render_table(
282        frame,
283        TableConfig {
284            items: filtered_items,
285            selected_index: state.table.selected,
286            expanded_index: state.table.expanded_item,
287            columns: &columns,
288            sort_column: "",
289            sort_direction: state.sort_direction,
290            title,
291            area: chunks[1],
292            get_expanded_content: Some(Box::new(|instance: &Instance| {
293                expanded_from_columns(&columns, instance)
294            })),
295            is_active: mode == Mode::Normal,
296        },
297    );
298
299    // Render dropdown for StateFilter when focused (after table so it appears on top)
300    if mode == Mode::FilterInput && state.input_focus == STATE_FILTER {
301        let filter_names: Vec<&str> = StateFilter::ALL.iter().map(|f| f.name()).collect();
302        let selected_idx = StateFilter::ALL
303            .iter()
304            .position(|f| *f == state.state_filter)
305            .unwrap_or(0);
306        let controls_after = pagination.len() as u16 + 3;
307        render_dropdown(
308            frame,
309            &filter_names,
310            selected_idx,
311            chunks[0],
312            controls_after,
313        );
314    }
315}
316
317pub fn filtered_ec2_instances(app: &crate::app::App) -> Vec<&Instance> {
318    let filtered: Vec<&Instance> = if app.ec2_state.table.filter.is_empty() {
319        app.ec2_state.table.items.iter().collect()
320    } else {
321        app.ec2_state
322            .table
323            .items
324            .iter()
325            .filter(|i| {
326                i.instance_id.contains(&app.ec2_state.table.filter)
327                    || i.name.contains(&app.ec2_state.table.filter)
328                    || i.state.contains(&app.ec2_state.table.filter)
329                    || i.instance_type.contains(&app.ec2_state.table.filter)
330                    || i.public_ipv4_address.contains(&app.ec2_state.table.filter)
331                    || i.private_ip_address.contains(&app.ec2_state.table.filter)
332            })
333            .collect()
334    };
335
336    filtered
337        .into_iter()
338        .filter(|i| app.ec2_state.state_filter.matches(&i.state))
339        .collect()
340}
341
342pub fn render_instance_detail(frame: &mut Frame, area: Rect, app: &crate::app::App) {
343    use crate::ui::{labeled_field, render_tabs};
344
345    let instance = app
346        .ec2_state
347        .table
348        .items
349        .iter()
350        .find(|i| Some(&i.instance_id) == app.ec2_state.current_instance.as_ref());
351
352    let Some(instance) = instance else {
353        return;
354    };
355
356    // All fields to display (matching AWS console)
357    let all_fields = vec![
358        labeled_field("Instance ID", &instance.instance_id),
359        labeled_field("Public IPv4 address", &instance.public_ipv4_address),
360        labeled_field("Private IPv4 addresses", &instance.private_ip_address),
361        labeled_field("IPv6 address", &instance.ipv6_ips),
362        labeled_field("Instance state", &instance.state),
363        labeled_field("Public DNS", &instance.public_ipv4_dns),
364        labeled_field(
365            "Hostname type",
366            format!("IP name: {}", &instance.private_dns_name),
367        ),
368        labeled_field(
369            "Private IP DNS name (IPv4 only)",
370            &instance.private_dns_name,
371        ),
372        labeled_field("Instance type", &instance.instance_type),
373        labeled_field("Elastic IP addresses", &instance.elastic_ip),
374        labeled_field(
375            "Auto-assigned IP address",
376            format!("{} [Public IP]", &instance.public_ipv4_address),
377        ),
378        labeled_field("VPC ID", &instance.vpc_id),
379        labeled_field("IAM Role", &instance.iam_instance_profile_arn),
380        labeled_field("Subnet ID", &instance.subnet_ids),
381        labeled_field("IMDSv2", &instance.imdsv2),
382        labeled_field("Availability Zone", &instance.availability_zone),
383        labeled_field("Managed", &instance.managed),
384        labeled_field("Operator", &instance.operator),
385    ];
386
387    // Calculate height needed
388    let summary_height = calculate_dynamic_height(&all_fields, area.width.saturating_sub(4)) + 2;
389
390    let chunks = Layout::default()
391        .direction(Direction::Vertical)
392        .constraints([Constraint::Length(summary_height), Constraint::Min(0)])
393        .split(area);
394
395    // Instance summary
396    let summary_block = titled_block("Instance summary");
397    let summary_inner = summary_block.inner(chunks[0]);
398    frame.render_widget(summary_block, chunks[0]);
399
400    render_fields_with_dynamic_columns(frame, summary_inner, all_fields);
401
402    // Tab content
403    let tab_names: Vec<(&str, DetailTab)> = DetailTab::ALL.iter().map(|t| (t.name(), *t)).collect();
404    render_tabs(frame, chunks[1], &tab_names, &app.ec2_state.detail_tab);
405
406    // Content area starts right after tabs (1 line for tab bar)
407    let content_area = Rect {
408        x: chunks[1].x,
409        y: chunks[1].y + 1,
410        width: chunks[1].width,
411        height: chunks[1].height.saturating_sub(1),
412    };
413
414    match app.ec2_state.detail_tab {
415        DetailTab::Details => {
416            let instance_details = vec![
417                labeled_field("AMI ID", &instance.image_id),
418                labeled_field("Monitoring", &instance.monitoring),
419                labeled_field("Platform details", &instance.platform_details),
420                labeled_field("AMI name", "–"),
421                labeled_field("Allowed image", "–"),
422                labeled_field("Termination protection", "–"),
423                labeled_field("Stop protection", "–"),
424                labeled_field("Launch time", &instance.launch_time),
425                labeled_field("AMI location", "–"),
426                labeled_field("Instance reboot migration", "–"),
427                labeled_field("Instance auto-recovery", "–"),
428                labeled_field("Lifecycle", &instance.instance_lifecycle),
429                labeled_field(
430                    "Stop-hibernate behavior",
431                    &instance.stop_hibernation_behavior,
432                ),
433                labeled_field("AMI Launch index", &instance.ami_launch_index),
434                labeled_field("Key pair assigned at launch", &instance.key_name),
435                labeled_field(
436                    "State transition reason",
437                    &instance.state_transition_reason_code,
438                ),
439                labeled_field("Credit specification", "–"),
440                labeled_field("Kernel ID", &instance.kernel_id),
441                labeled_field(
442                    "State transition message",
443                    &instance.state_transition_reason_message,
444                ),
445                labeled_field("Usage operation", &instance.usage_operation),
446                labeled_field("RAM disk ID", &instance.ramdisk_id),
447                labeled_field("Owner", &instance.owner_id),
448                labeled_field("Enclaves Support", "–"),
449                labeled_field("Boot mode", "–"),
450                labeled_field("Current instance boot mode", "–"),
451                labeled_field("Allow tags in instance metadata", "–"),
452                labeled_field("Use RBN as guest OS hostname", "–"),
453                labeled_field("Answer RBN DNS hostname IPv4", "–"),
454            ];
455
456            let placement = vec![
457                labeled_field("Host ID", &instance.host_id),
458                labeled_field("Affinity", &instance.affinity),
459                labeled_field("Placement group", &instance.placement_group),
460                labeled_field("Host resource group name", "–"),
461                labeled_field("Tenancy", &instance.tenancy),
462                labeled_field("Placement group ID", "–"),
463                labeled_field("Virtualization type", &instance.virtualization_type),
464                labeled_field("Reservation", &instance.reservation_id),
465                labeled_field("Partition number", &instance.partition_number),
466                labeled_field("Number of vCPUs", "–"),
467            ];
468
469            let capacity = vec![
470                labeled_field("Capacity Reservation ID", &instance.capacity_reservation_id),
471                labeled_field("Capacity Reservation setting", "open"),
472            ];
473
474            // Calculate heights for each section based on field count and width
475            let calc_height = |fields: &[Line], width: u16| -> u16 {
476                if fields.is_empty() {
477                    return 2; // Just borders
478                }
479                let field_widths: Vec<u16> = fields
480                    .iter()
481                    .map(|line| {
482                        line.spans
483                            .iter()
484                            .map(|span| span.content.len() as u16)
485                            .sum::<u16>()
486                            + 2
487                    })
488                    .collect();
489                let max_field_width = *field_widths.iter().max().unwrap_or(&20);
490                let num_columns =
491                    (width / max_field_width).max(1).min(fields.len() as u16) as usize;
492                let base = fields.len() / num_columns;
493                let extra = fields.len() % num_columns;
494                let max_rows = if extra > 0 { base + 1 } else { base };
495                (max_rows as u16) + 2
496            };
497
498            let details_height =
499                calc_height(&instance_details, content_area.width.saturating_sub(2));
500            let placement_height = calc_height(&placement, content_area.width.saturating_sub(2));
501            let capacity_height = calc_height(&capacity, content_area.width.saturating_sub(2));
502
503            // Ensure total height doesn't exceed available space
504            let total_height = details_height + placement_height + capacity_height;
505            let available_height = content_area.height;
506
507            let sections = if total_height <= available_height {
508                Layout::default()
509                    .direction(Direction::Vertical)
510                    .constraints([
511                        Constraint::Length(details_height),
512                        Constraint::Length(placement_height),
513                        Constraint::Length(capacity_height),
514                    ])
515                    .split(content_area)
516            } else {
517                // If doesn't fit, use Min for last section
518                Layout::default()
519                    .direction(Direction::Vertical)
520                    .constraints([
521                        Constraint::Length(details_height),
522                        Constraint::Length(placement_height),
523                        Constraint::Min(0),
524                    ])
525                    .split(content_area)
526            };
527
528            let details_block = titled_block("Instance details");
529            let details_inner = details_block.inner(sections[0]);
530            frame.render_widget(details_block, sections[0]);
531            render_fields_with_dynamic_columns(frame, details_inner, instance_details);
532
533            let placement_block = titled_block("Host and placement group");
534            let placement_inner = placement_block.inner(sections[1]);
535            frame.render_widget(placement_block, sections[1]);
536            render_fields_with_dynamic_columns(frame, placement_inner, placement);
537
538            let capacity_block = titled_block("Capacity reservation");
539            let capacity_inner = capacity_block.inner(sections[2]);
540            frame.render_widget(capacity_block, sections[2]);
541            render_fields_with_dynamic_columns(frame, capacity_inner, capacity);
542        }
543        DetailTab::StatusAndAlarms => {
544            let block = rounded_block();
545            let inner = block.inner(content_area);
546            frame.render_widget(block, content_area);
547
548            let lines = vec![
549                labeled_field("Status checks", &instance.status_checks),
550                labeled_field("Alarm status", &instance.alarm_status),
551                labeled_field("Monitoring", &instance.monitoring),
552                labeled_field(
553                    "State transition reason",
554                    &instance.state_transition_reason_message,
555                ),
556            ];
557            render_fields_with_dynamic_columns(frame, inner, lines);
558        }
559        DetailTab::Monitoring => {
560            render_ec2_monitoring_charts(frame, app, content_area);
561        }
562        DetailTab::Security => {
563            let block = rounded_block();
564            let inner = block.inner(content_area);
565            frame.render_widget(block, content_area);
566
567            let lines = vec![
568                labeled_field("Security groups", &instance.security_groups),
569                labeled_field("Security group IDs", &instance.security_group_ids),
570                labeled_field("Key name", &instance.key_name),
571                labeled_field("IAM role", &instance.iam_instance_profile_arn),
572                labeled_field("IMDSv2", &instance.imdsv2),
573            ];
574            render_fields_with_dynamic_columns(frame, inner, lines);
575        }
576        DetailTab::Networking => {
577            let block = rounded_block();
578            let inner = block.inner(content_area);
579            frame.render_widget(block, content_area);
580
581            let lines = vec![
582                labeled_field("VPC ID", &instance.vpc_id),
583                labeled_field("Subnet IDs", &instance.subnet_ids),
584                labeled_field("Private DNS", &instance.private_dns_name),
585                labeled_field("Private IP", &instance.private_ip_address),
586                labeled_field("Public IPv4 DNS", &instance.public_ipv4_dns),
587                labeled_field("Public IPv4", &instance.public_ipv4_address),
588                labeled_field("Elastic IP", &instance.elastic_ip),
589                labeled_field("IPv6 IPs", &instance.ipv6_ips),
590            ];
591            render_fields_with_dynamic_columns(frame, inner, lines);
592        }
593        DetailTab::Storage => {
594            let block = rounded_block();
595            let inner = block.inner(content_area);
596            frame.render_widget(block, content_area);
597
598            let lines = vec![
599                labeled_field("Volume ID", &instance.volume_id),
600                labeled_field("Root device", &instance.root_device_name),
601                labeled_field("Root device type", &instance.root_device_type),
602                labeled_field("EBS optimized", &instance.ebs_optimized),
603            ];
604            render_fields_with_dynamic_columns(frame, inner, lines);
605        }
606        DetailTab::Tags => {
607            render_tags_tab(frame, app, content_area);
608        }
609    }
610}
611
612fn render_tags_tab(frame: &mut Frame, app: &crate::app::App, area: Rect) {
613    let chunks = Layout::default()
614        .direction(Direction::Vertical)
615        .constraints([Constraint::Length(3), Constraint::Min(0)])
616        .split(area);
617
618    let filtered = filtered_tags(app);
619
620    let columns: Vec<Box<dyn TableColumn<InstanceTag>>> = app
621        .ec2_state
622        .tag_visible_column_ids
623        .iter()
624        .filter_map(|id| TagColumn::from_id(id))
625        .map(|col| Box::new(col) as Box<dyn TableColumn<InstanceTag>>)
626        .collect();
627
628    let page_size = app.ec2_state.tags.page_size.value();
629    let total_pages = filtered.len().div_ceil(page_size.max(1));
630    let current_page = app.ec2_state.tags.selected / page_size.max(1);
631    let pagination = render_pagination_text(current_page, total_pages);
632
633    render_simple_filter(
634        frame,
635        chunks[0],
636        SimpleFilterConfig {
637            filter_text: &app.ec2_state.tags.filter,
638            placeholder: "Search tags",
639            pagination: &pagination,
640            mode: app.mode,
641            is_input_focused: app.ec2_state.input_focus == InputFocus::Filter,
642            is_pagination_focused: app.ec2_state.input_focus == InputFocus::Pagination,
643        },
644    );
645
646    let start_idx = current_page * page_size;
647    let end_idx = (start_idx + page_size).min(filtered.len());
648    let paginated: Vec<_> = filtered[start_idx..end_idx].to_vec();
649
650    let expanded_index = app.ec2_state.tags.expanded_item.and_then(|idx| {
651        if idx >= start_idx && idx < end_idx {
652            Some(idx - start_idx)
653        } else {
654            None
655        }
656    });
657
658    render_table(
659        frame,
660        TableConfig {
661            area: chunks[1],
662            columns: &columns,
663            items: paginated,
664            selected_index: app.ec2_state.tags.selected % page_size.max(1),
665            is_active: app.mode != Mode::FilterInput,
666            title: format_title(&format!("Tags ({})", filtered.len())),
667            sort_column: "value",
668            sort_direction: SortDirection::Asc,
669            expanded_index,
670            get_expanded_content: Some(Box::new(|tag: &InstanceTag| {
671                expanded_from_columns(&columns, tag)
672            })),
673        },
674    );
675}
676
677pub fn filtered_tags(app: &crate::app::App) -> Vec<&InstanceTag> {
678    let mut filtered =
679        filter_by_fields(&app.ec2_state.tags.items, &app.ec2_state.tags.filter, |t| {
680            vec![&t.key, &t.value]
681        });
682
683    filtered.sort_by(|a, b| a.value.cmp(&b.value));
684    filtered
685}
686
687pub async fn load_tags(app: &mut crate::app::App, instance_id: &str) -> anyhow::Result<()> {
688    let tags = app.ec2_client.list_tags(instance_id).await?;
689
690    app.ec2_state.tags.items = tags
691        .into_iter()
692        .map(|t| InstanceTag {
693            key: t.key,
694            value: t.value,
695        })
696        .collect();
697
698    app.ec2_state
699        .tags
700        .items
701        .sort_by(|a, b| a.value.cmp(&b.value));
702
703    Ok(())
704}
705
706pub async fn load_ec2_metrics(app: &mut crate::app::App, instance_id: &str) -> anyhow::Result<()> {
707    app.ec2_state.metric_data_cpu = app.ec2_client.get_cpu_metrics(instance_id).await?;
708    app.ec2_state.metric_data_network_in =
709        app.ec2_client.get_network_in_metrics(instance_id).await?;
710    app.ec2_state.metric_data_network_out =
711        app.ec2_client.get_network_out_metrics(instance_id).await?;
712    app.ec2_state.metric_data_network_packets_in = app
713        .ec2_client
714        .get_network_packets_in_metrics(instance_id)
715        .await?;
716    app.ec2_state.metric_data_network_packets_out = app
717        .ec2_client
718        .get_network_packets_out_metrics(instance_id)
719        .await?;
720    app.ec2_state.metric_data_metadata_no_token = app
721        .ec2_client
722        .get_metadata_no_token_metrics(instance_id)
723        .await?;
724    Ok(())
725}
726
727fn render_ec2_monitoring_charts(frame: &mut Frame, app: &crate::app::App, area: Rect) {
728    use crate::ui::monitoring::render_monitoring_tab;
729
730    let cpu_avg: f64 = if !app.ec2_state.metric_data_cpu.is_empty() {
731        app.ec2_state
732            .metric_data_cpu
733            .iter()
734            .map(|(_, v)| v)
735            .sum::<f64>()
736            / app.ec2_state.metric_data_cpu.len() as f64
737    } else {
738        0.0
739    };
740    let cpu_label = format!("CPU utilization (%) [avg: {:.2}]", cpu_avg);
741
742    let network_in_sum: f64 = app
743        .ec2_state
744        .metric_data_network_in
745        .iter()
746        .map(|(_, v)| v)
747        .sum();
748    let network_in_label = format!("Network in (bytes) [sum: {:.0}]", network_in_sum);
749
750    let network_out_sum: f64 = app
751        .ec2_state
752        .metric_data_network_out
753        .iter()
754        .map(|(_, v)| v)
755        .sum();
756    let network_out_label = format!("Network out (bytes) [sum: {:.0}]", network_out_sum);
757
758    let network_packets_in_sum: f64 = app
759        .ec2_state
760        .metric_data_network_packets_in
761        .iter()
762        .map(|(_, v)| v)
763        .sum();
764    let network_packets_in_label = format!(
765        "Network packets in (count) [sum: {:.0}]",
766        network_packets_in_sum
767    );
768
769    let network_packets_out_sum: f64 = app
770        .ec2_state
771        .metric_data_network_packets_out
772        .iter()
773        .map(|(_, v)| v)
774        .sum();
775    let network_packets_out_label = format!(
776        "Network packets out (count) [sum: {:.0}]",
777        network_packets_out_sum
778    );
779
780    let metadata_no_token_sum: f64 = app
781        .ec2_state
782        .metric_data_metadata_no_token
783        .iter()
784        .map(|(_, v)| v)
785        .sum();
786    let metadata_no_token_label = format!(
787        "Metadata no token (count) [sum: {:.0}]",
788        metadata_no_token_sum
789    );
790
791    render_monitoring_tab(
792        frame,
793        area,
794        &[
795            crate::ui::monitoring::MetricChart {
796                title: &cpu_label,
797                data: &app.ec2_state.metric_data_cpu,
798                y_axis_label: "%",
799                x_axis_label: None,
800            },
801            crate::ui::monitoring::MetricChart {
802                title: &network_in_label,
803                data: &app.ec2_state.metric_data_network_in,
804                y_axis_label: "bytes",
805                x_axis_label: None,
806            },
807            crate::ui::monitoring::MetricChart {
808                title: &network_out_label,
809                data: &app.ec2_state.metric_data_network_out,
810                y_axis_label: "bytes",
811                x_axis_label: None,
812            },
813            crate::ui::monitoring::MetricChart {
814                title: &network_packets_in_label,
815                data: &app.ec2_state.metric_data_network_packets_in,
816                y_axis_label: "count",
817                x_axis_label: None,
818            },
819            crate::ui::monitoring::MetricChart {
820                title: &network_packets_out_label,
821                data: &app.ec2_state.metric_data_network_packets_out,
822                y_axis_label: "count",
823                x_axis_label: None,
824            },
825            crate::ui::monitoring::MetricChart {
826                title: &metadata_no_token_label,
827                data: &app.ec2_state.metric_data_metadata_no_token,
828                y_axis_label: "count",
829                x_axis_label: None,
830            },
831        ],
832        &[],
833        &[],
834        &[],
835        app.ec2_state.monitoring_scroll,
836    );
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842
843    #[test]
844    fn test_state_filter_names() {
845        assert_eq!(StateFilter::AllStates.name(), "All states");
846        assert_eq!(StateFilter::Running.name(), "Running");
847        assert_eq!(StateFilter::Stopped.name(), "Stopped");
848        assert_eq!(StateFilter::Terminated.name(), "Terminated");
849        assert_eq!(StateFilter::Pending.name(), "Pending");
850        assert_eq!(StateFilter::ShuttingDown.name(), "Shutting down");
851        assert_eq!(StateFilter::Stopping.name(), "Stopping");
852    }
853
854    #[test]
855    fn test_state_filter_matches() {
856        assert!(StateFilter::AllStates.matches("running"));
857        assert!(StateFilter::AllStates.matches("stopped"));
858        assert!(StateFilter::Running.matches("running"));
859        assert!(!StateFilter::Running.matches("stopped"));
860        assert!(StateFilter::Stopped.matches("stopped"));
861        assert!(!StateFilter::Stopped.matches("running"));
862    }
863
864    #[test]
865    fn test_state_filter_next() {
866        assert_eq!(StateFilter::AllStates.next(), StateFilter::Running);
867        assert_eq!(StateFilter::Running.next(), StateFilter::Stopped);
868        assert_eq!(StateFilter::Stopping.next(), StateFilter::AllStates);
869    }
870
871    #[test]
872    fn test_state_filter_prev() {
873        assert_eq!(StateFilter::AllStates.prev(), StateFilter::Stopping);
874        assert_eq!(StateFilter::Running.prev(), StateFilter::AllStates);
875        assert_eq!(StateFilter::Stopped.prev(), StateFilter::Running);
876    }
877
878    #[test]
879    fn test_state_filter_all_constant() {
880        assert_eq!(StateFilter::ALL.len(), 7);
881        assert_eq!(StateFilter::ALL[0], StateFilter::AllStates);
882        assert_eq!(StateFilter::ALL[6], StateFilter::Stopping);
883    }
884
885    #[test]
886    fn test_state_default() {
887        let state = State::default();
888        assert_eq!(state.table.items.len(), 0);
889        assert_eq!(state.table.selected, 0);
890        assert!(!state.table.loading);
891        assert_eq!(state.table.filter, "");
892        assert_eq!(state.state_filter, StateFilter::AllStates);
893        assert_eq!(state.sort_column, Column::LaunchTime);
894        assert_eq!(state.sort_direction, SortDirection::Desc);
895        assert!(!state.metrics_loading);
896        assert!(state.metric_data_cpu.is_empty());
897        assert!(state.metric_data_network_in.is_empty());
898        assert!(state.metric_data_network_out.is_empty());
899        assert!(state.metric_data_network_packets_in.is_empty());
900        assert!(state.metric_data_network_packets_out.is_empty());
901        assert!(state.metric_data_metadata_no_token.is_empty());
902    }
903
904    #[test]
905    fn test_state_filter_matches_all_states() {
906        let filter = StateFilter::AllStates;
907        assert!(filter.matches("running"));
908        assert!(filter.matches("stopped"));
909        assert!(filter.matches("terminated"));
910        assert!(filter.matches("pending"));
911        assert!(filter.matches("shutting-down"));
912        assert!(filter.matches("stopping"));
913    }
914
915    #[test]
916    fn test_state_filter_matches_specific_states() {
917        assert!(StateFilter::Running.matches("running"));
918        assert!(!StateFilter::Running.matches("stopped"));
919
920        assert!(StateFilter::Stopped.matches("stopped"));
921        assert!(!StateFilter::Stopped.matches("running"));
922
923        assert!(StateFilter::Terminated.matches("terminated"));
924        assert!(!StateFilter::Terminated.matches("running"));
925
926        assert!(StateFilter::Pending.matches("pending"));
927        assert!(!StateFilter::Pending.matches("running"));
928
929        assert!(StateFilter::ShuttingDown.matches("shutting-down"));
930        assert!(!StateFilter::ShuttingDown.matches("running"));
931
932        assert!(StateFilter::Stopping.matches("stopping"));
933        assert!(!StateFilter::Stopping.matches("running"));
934    }
935
936    #[test]
937    fn test_state_filter_cycle() {
938        let mut filter = StateFilter::AllStates;
939        filter = filter.next();
940        assert_eq!(filter, StateFilter::Running);
941        filter = filter.next();
942        assert_eq!(filter, StateFilter::Stopped);
943        filter = filter.next();
944        assert_eq!(filter, StateFilter::Terminated);
945        filter = filter.next();
946        assert_eq!(filter, StateFilter::Pending);
947        filter = filter.next();
948        assert_eq!(filter, StateFilter::ShuttingDown);
949        filter = filter.next();
950        assert_eq!(filter, StateFilter::Stopping);
951        filter = filter.next();
952        assert_eq!(filter, StateFilter::AllStates);
953    }
954
955    #[test]
956    fn test_filter_controls_constant() {
957        assert_eq!(FILTER_CONTROLS.len(), 3);
958        assert_eq!(FILTER_CONTROLS[0], InputFocus::Filter);
959        assert_eq!(FILTER_CONTROLS[1], STATE_FILTER);
960        assert_eq!(FILTER_CONTROLS[2], InputFocus::Pagination);
961    }
962
963    #[test]
964    fn test_input_focus_cycling() {
965        let mut focus = InputFocus::Filter;
966        focus = focus.next(&FILTER_CONTROLS);
967        assert_eq!(focus, STATE_FILTER);
968        focus = focus.next(&FILTER_CONTROLS);
969        assert_eq!(focus, InputFocus::Pagination);
970        focus = focus.next(&FILTER_CONTROLS);
971        assert_eq!(focus, InputFocus::Filter);
972    }
973
974    #[test]
975    fn test_input_focus_reverse_cycling() {
976        let mut focus = InputFocus::Filter;
977        focus = focus.prev(&FILTER_CONTROLS);
978        assert_eq!(focus, InputFocus::Pagination);
979        focus = focus.prev(&FILTER_CONTROLS);
980        assert_eq!(focus, STATE_FILTER);
981        focus = focus.prev(&FILTER_CONTROLS);
982        assert_eq!(focus, InputFocus::Filter);
983    }
984
985    #[test]
986    fn test_state_default_input_focus() {
987        let state = State::default();
988        assert_eq!(state.input_focus, InputFocus::Filter);
989    }
990
991    #[test]
992    fn test_filter_controls_includes_state_filter() {
993        assert_eq!(FILTER_CONTROLS.len(), 3);
994        assert_eq!(FILTER_CONTROLS[0], InputFocus::Filter);
995        assert_eq!(FILTER_CONTROLS[1], STATE_FILTER);
996        assert_eq!(FILTER_CONTROLS[2], InputFocus::Pagination);
997    }
998
999    #[test]
1000    fn test_state_filter_constant() {
1001        assert_eq!(STATE_FILTER, InputFocus::Checkbox("state"));
1002    }
1003
1004    #[test]
1005    fn test_state_filter_all_has_7_items() {
1006        assert_eq!(StateFilter::ALL.len(), 7);
1007    }
1008
1009    #[test]
1010    fn test_dropdown_shows_when_state_filter_focused() {
1011        // This is tested via integration - dropdown renders when input_focus == STATE_FILTER
1012        // Verify the constant is accessible
1013        let focus = STATE_FILTER;
1014        assert_eq!(focus, InputFocus::Checkbox("state"));
1015    }
1016
1017    #[test]
1018    fn test_detail_tab_cycling() {
1019        let mut tab = DetailTab::Details;
1020        tab = tab.next();
1021        assert_eq!(tab, DetailTab::StatusAndAlarms);
1022        tab = tab.next();
1023        assert_eq!(tab, DetailTab::Monitoring);
1024        tab = tab.next();
1025        assert_eq!(tab, DetailTab::Security);
1026        tab = tab.next();
1027        assert_eq!(tab, DetailTab::Networking);
1028        tab = tab.next();
1029        assert_eq!(tab, DetailTab::Storage);
1030        tab = tab.next();
1031        assert_eq!(tab, DetailTab::Tags);
1032        tab = tab.next();
1033        assert_eq!(tab, DetailTab::Details);
1034    }
1035
1036    #[test]
1037    fn test_detail_tab_reverse_cycling() {
1038        let mut tab = DetailTab::Details;
1039        tab = tab.prev();
1040        assert_eq!(tab, DetailTab::Tags);
1041        tab = tab.prev();
1042        assert_eq!(tab, DetailTab::Storage);
1043        tab = tab.prev();
1044        assert_eq!(tab, DetailTab::Networking);
1045        tab = tab.prev();
1046        assert_eq!(tab, DetailTab::Security);
1047        tab = tab.prev();
1048        assert_eq!(tab, DetailTab::Monitoring);
1049        tab = tab.prev();
1050        assert_eq!(tab, DetailTab::StatusAndAlarms);
1051        tab = tab.prev();
1052        assert_eq!(tab, DetailTab::Details);
1053    }
1054
1055    #[test]
1056    fn test_detail_tab_names() {
1057        assert_eq!(DetailTab::Details.name(), "Details");
1058        assert_eq!(DetailTab::StatusAndAlarms.name(), "Status and alarms");
1059        assert_eq!(DetailTab::Monitoring.name(), "Monitoring");
1060        assert_eq!(DetailTab::Security.name(), "Security");
1061        assert_eq!(DetailTab::Networking.name(), "Networking");
1062        assert_eq!(DetailTab::Storage.name(), "Storage");
1063        assert_eq!(DetailTab::Tags.name(), "Tags");
1064    }
1065
1066    #[test]
1067    fn test_detail_tab_all_has_7_items() {
1068        assert_eq!(DetailTab::ALL.len(), 7);
1069    }
1070
1071    #[test]
1072    fn test_state_default_has_no_current_instance() {
1073        let state = State::default();
1074        assert_eq!(state.current_instance, None);
1075        assert_eq!(state.detail_tab, DetailTab::Details);
1076    }
1077
1078    #[test]
1079    fn test_column_distribution_10_fields_3_columns() {
1080        // 10 fields, 3 columns: should be 4, 3, 3
1081        let total = 10;
1082        let cols = 3;
1083        let base = total / cols; // 3
1084        let extra = total % cols; // 1
1085
1086        let mut distribution = Vec::new();
1087        for col in 0..cols {
1088            let count = if col < extra { base + 1 } else { base };
1089            distribution.push(count);
1090        }
1091
1092        assert_eq!(distribution, vec![4, 3, 3]);
1093    }
1094
1095    #[test]
1096    fn test_column_distribution_10_fields_2_columns() {
1097        // 10 fields, 2 columns: should be 5, 5
1098        let total = 10;
1099        let cols = 2;
1100        let base = total / cols;
1101        let extra = total % cols;
1102
1103        let mut distribution = Vec::new();
1104        for col in 0..cols {
1105            let count = if col < extra { base + 1 } else { base };
1106            distribution.push(count);
1107        }
1108
1109        assert_eq!(distribution, vec![5, 5]);
1110    }
1111
1112    #[test]
1113    fn test_column_distribution_10_fields_4_columns() {
1114        // 10 fields, 4 columns: should be 3, 3, 2, 2
1115        let total = 10;
1116        let cols = 4;
1117        let base = total / cols; // 2
1118        let extra = total % cols; // 2
1119
1120        let mut distribution = Vec::new();
1121        for col in 0..cols {
1122            let count = if col < extra { base + 1 } else { base };
1123            distribution.push(count);
1124        }
1125
1126        assert_eq!(distribution, vec![3, 3, 2, 2]);
1127    }
1128
1129    #[test]
1130    fn test_column_distribution_7_fields_3_columns() {
1131        // 7 fields, 3 columns: should be 3, 2, 2
1132        let total = 7;
1133        let cols = 3;
1134        let base = total / cols; // 2
1135        let extra = total % cols; // 1
1136
1137        let mut distribution = Vec::new();
1138        for col in 0..cols {
1139            let count = if col < extra { base + 1 } else { base };
1140            distribution.push(count);
1141        }
1142
1143        assert_eq!(distribution, vec![3, 2, 2]);
1144    }
1145
1146    #[test]
1147    fn test_column_distribution_ensures_first_has_most() {
1148        // Test that first column always has >= other columns
1149        for total in 1..20 {
1150            for cols in 1..=total {
1151                let base = total / cols;
1152                let extra = total % cols;
1153
1154                let first_col = if 0 < extra { base + 1 } else { base };
1155                let last_col = if (cols - 1) < extra { base + 1 } else { base };
1156
1157                assert!(
1158                    first_col >= last_col,
1159                    "total={}, cols={}, first={}, last={}",
1160                    total,
1161                    cols,
1162                    first_col,
1163                    last_col
1164                );
1165            }
1166        }
1167    }
1168
1169    #[test]
1170    fn test_render_fields_with_dynamic_columns_empty() {
1171        use ratatui::backend::TestBackend;
1172        use ratatui::Terminal;
1173
1174        let backend = TestBackend::new(80, 24);
1175        let mut terminal = Terminal::new(backend).unwrap();
1176
1177        terminal
1178            .draw(|f| {
1179                let area = f.area();
1180                let height = render_fields_with_dynamic_columns(f, area, vec![]);
1181                assert_eq!(height, 0);
1182            })
1183            .unwrap();
1184    }
1185
1186    #[test]
1187    fn test_render_fields_with_dynamic_columns_single_field() {
1188        use ratatui::backend::TestBackend;
1189        use ratatui::Terminal;
1190
1191        let backend = TestBackend::new(80, 24);
1192        let mut terminal = Terminal::new(backend).unwrap();
1193
1194        terminal
1195            .draw(|f| {
1196                let area = f.area();
1197                let fields = vec![Line::from("Test field")];
1198                let height = render_fields_with_dynamic_columns(f, area, fields);
1199                assert_eq!(height, 1);
1200            })
1201            .unwrap();
1202    }
1203
1204    #[test]
1205    fn test_render_fields_with_dynamic_columns_calculates_height() {
1206        use ratatui::backend::TestBackend;
1207        use ratatui::Terminal;
1208
1209        let backend = TestBackend::new(40, 24); // Narrow width
1210        let mut terminal = Terminal::new(backend).unwrap();
1211
1212        terminal
1213            .draw(|f| {
1214                let area = f.area();
1215                let fields = vec![
1216                    Line::from("Field 1"),
1217                    Line::from("Field 2"),
1218                    Line::from("Field 3"),
1219                    Line::from("Field 4"),
1220                ];
1221                let height = render_fields_with_dynamic_columns(f, area, fields);
1222                // Should return a reasonable height
1223                assert!((1..=4).contains(&height));
1224            })
1225            .unwrap();
1226    }
1227
1228    #[test]
1229    fn test_instance_summary_has_18_fields() {
1230        // Verify summary has all required fields matching AWS console
1231        let field_count = 18;
1232        assert_eq!(field_count, 18, "Instance summary should have 18 fields");
1233    }
1234
1235    #[test]
1236    fn test_instance_summary_includes_key_fields() {
1237        // Test that key field labels are present
1238        let required_fields = vec![
1239            "Instance ID",
1240            "Public IPv4 address",
1241            "Private IPv4 addresses",
1242            "IPv6 address",
1243            "Instance state",
1244            "Public DNS",
1245            "Hostname type",
1246            "Private IP DNS name (IPv4 only)",
1247            "Instance type",
1248            "Elastic IP addresses",
1249            "Auto-assigned IP address",
1250            "VPC ID",
1251            "IAM Role",
1252            "Subnet ID",
1253            "IMDSv2",
1254            "Availability Zone",
1255            "Managed",
1256            "Operator",
1257        ];
1258        assert_eq!(required_fields.len(), 18);
1259    }
1260
1261    #[test]
1262    fn test_rounded_block_helper_creates_block_with_title() {
1263        use crate::ui::titled_block;
1264        use ratatui::prelude::Rect;
1265        // Verify rounded_block helper can be used with title
1266        let block = titled_block("Instance summary");
1267        let area = Rect::new(0, 0, 50, 10);
1268        let inner = block.inner(area);
1269        // Inner area: 50 - 2 (borders) - 2 (padding) = 46 width
1270        assert_eq!(inner.width, 48);
1271        assert_eq!(inner.height, 8);
1272    }
1273
1274    #[test]
1275    fn test_summary_height_uses_dynamic_calculation() {
1276        use crate::ui::{calculate_dynamic_height, labeled_field};
1277        // Verify summary height accounts for column packing
1278        let fields = vec![
1279            labeled_field("Instance ID", "i-1234567890abcdef0"),
1280            labeled_field("Instance type", "t2.micro"),
1281            labeled_field("State", "running"),
1282            labeled_field("VPC ID", "vpc-12345678"),
1283        ];
1284        let width = 180;
1285        let height = calculate_dynamic_height(&fields, width);
1286        // With 4 fields and wide width, should pack into 2 rows
1287        assert!(height <= 2, "Expected 2 rows or less, got {}", height);
1288    }
1289
1290    #[test]
1291    fn test_tag_column_ids() {
1292        let ids = TagColumn::ids();
1293        assert_eq!(ids.len(), 2);
1294        assert_eq!(ids[0], "column.ec2.tag.key");
1295        assert_eq!(ids[1], "column.ec2.tag.value");
1296    }
1297
1298    #[test]
1299    fn test_tag_column_from_id() {
1300        assert_eq!(
1301            TagColumn::from_id("column.ec2.tag.key"),
1302            Some(TagColumn::Key)
1303        );
1304        assert_eq!(
1305            TagColumn::from_id("column.ec2.tag.value"),
1306            Some(TagColumn::Value)
1307        );
1308        assert_eq!(TagColumn::from_id("invalid"), None);
1309    }
1310
1311    #[test]
1312    fn test_state_includes_tags() {
1313        let state = State::default();
1314        assert_eq!(state.tags.items.len(), 0);
1315        assert_eq!(state.tag_visible_column_ids.len(), 2);
1316        assert_eq!(state.tag_column_ids.len(), 2);
1317    }
1318
1319    #[test]
1320    fn test_filtered_tags_empty() {
1321        use crate::app::App;
1322        let app = App::new_without_client("default".to_string(), None);
1323        let filtered = filtered_tags(&app);
1324        assert_eq!(filtered.len(), 0);
1325    }
1326
1327    #[test]
1328    fn test_filtered_tags_with_filter() {
1329        use crate::app::App;
1330        let mut app = App::new_without_client("default".to_string(), None);
1331        app.ec2_state.tags.items = vec![
1332            InstanceTag {
1333                key: "Name".to_string(),
1334                value: "test-instance".to_string(),
1335            },
1336            InstanceTag {
1337                key: "Environment".to_string(),
1338                value: "production".to_string(),
1339            },
1340            InstanceTag {
1341                key: "Team".to_string(),
1342                value: "backend".to_string(),
1343            },
1344        ];
1345        app.ec2_state.tags.filter = "prod".to_string();
1346        let filtered = filtered_tags(&app);
1347        assert_eq!(filtered.len(), 1);
1348        assert_eq!(filtered[0].key, "Environment");
1349    }
1350
1351    #[test]
1352    fn test_filtered_tags_sorts_by_value() {
1353        use crate::app::App;
1354        let mut app = App::new_without_client("default".to_string(), None);
1355        app.ec2_state.tags.items = vec![
1356            InstanceTag {
1357                key: "Name".to_string(),
1358                value: "zebra".to_string(),
1359            },
1360            InstanceTag {
1361                key: "Environment".to_string(),
1362                value: "alpha".to_string(),
1363            },
1364            InstanceTag {
1365                key: "Team".to_string(),
1366                value: "beta".to_string(),
1367            },
1368        ];
1369        let filtered = filtered_tags(&app);
1370        assert_eq!(filtered.len(), 3);
1371        assert_eq!(filtered[0].value, "alpha");
1372        assert_eq!(filtered[1].value, "beta");
1373        assert_eq!(filtered[2].value, "zebra");
1374    }
1375
1376    #[test]
1377    fn test_load_tags_integration() {
1378        // Test that load_tags function exists and has correct signature
1379        use crate::app::App;
1380        let mut app = App::new_without_client("default".to_string(), None);
1381        app.ec2_state.tags.items = vec![InstanceTag {
1382            key: "test".to_string(),
1383            value: "value".to_string(),
1384        }];
1385        assert_eq!(app.ec2_state.tags.items.len(), 1);
1386    }
1387
1388    #[test]
1389    fn test_tags_columns_fallback_when_empty() {
1390        use crate::app::App;
1391        let app = App::new_without_client("default".to_string(), None);
1392        // tag_visible_column_ids should be initialized with all columns
1393        assert_eq!(app.ec2_state.tag_visible_column_ids.len(), 2);
1394    }
1395
1396    #[test]
1397    fn test_tags_collapse_on_left_arrow() {
1398        use crate::app::App;
1399        let mut app = App::new_without_client("default".to_string(), None);
1400        app.ec2_state.tags.expanded_item = Some(0);
1401        assert!(app.ec2_state.tags.has_expanded_item());
1402        app.ec2_state.tags.collapse();
1403        assert!(!app.ec2_state.tags.has_expanded_item());
1404    }
1405
1406    #[test]
1407    fn test_max_detail_columns_constant() {
1408        use crate::ui::MAX_DETAIL_COLUMNS;
1409        assert_eq!(MAX_DETAIL_COLUMNS, 3);
1410    }
1411
1412    #[test]
1413    fn test_page_size_options_constant() {
1414        use crate::ui::PAGE_SIZE_OPTIONS;
1415        assert_eq!(PAGE_SIZE_OPTIONS.len(), 4);
1416        assert_eq!(PAGE_SIZE_OPTIONS[0].1, "10");
1417        assert_eq!(PAGE_SIZE_OPTIONS[3].1, "100");
1418    }
1419
1420    #[test]
1421    fn test_page_size_options_small_constant() {
1422        use crate::ui::PAGE_SIZE_OPTIONS_SMALL;
1423        assert_eq!(PAGE_SIZE_OPTIONS_SMALL.len(), 3);
1424        assert_eq!(PAGE_SIZE_OPTIONS_SMALL[0].1, "10");
1425        assert_eq!(PAGE_SIZE_OPTIONS_SMALL[2].1, "50");
1426    }
1427}