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_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                ..Default::default()
801            },
802            crate::ui::monitoring::MetricChart {
803                title: &network_in_label,
804                data: &app.ec2_state.metric_data_network_in,
805                y_axis_label: "bytes",
806                x_axis_label: None,
807                ..Default::default()
808            },
809            crate::ui::monitoring::MetricChart {
810                title: &network_out_label,
811                data: &app.ec2_state.metric_data_network_out,
812                y_axis_label: "bytes",
813                x_axis_label: None,
814                ..Default::default()
815            },
816            crate::ui::monitoring::MetricChart {
817                title: &network_packets_in_label,
818                data: &app.ec2_state.metric_data_network_packets_in,
819                y_axis_label: "count",
820                x_axis_label: None,
821                ..Default::default()
822            },
823            crate::ui::monitoring::MetricChart {
824                title: &network_packets_out_label,
825                data: &app.ec2_state.metric_data_network_packets_out,
826                y_axis_label: "count",
827                x_axis_label: None,
828                ..Default::default()
829            },
830            crate::ui::monitoring::MetricChart {
831                title: &metadata_no_token_label,
832                data: &app.ec2_state.metric_data_metadata_no_token,
833                y_axis_label: "count",
834                x_axis_label: None,
835                ..Default::default()
836            },
837        ],
838        &[],
839        &[],
840        &[],
841        app.ec2_state.monitoring_scroll,
842    );
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848
849    #[test]
850    fn test_state_filter_names() {
851        assert_eq!(StateFilter::AllStates.name(), "All states");
852        assert_eq!(StateFilter::Running.name(), "Running");
853        assert_eq!(StateFilter::Stopped.name(), "Stopped");
854        assert_eq!(StateFilter::Terminated.name(), "Terminated");
855        assert_eq!(StateFilter::Pending.name(), "Pending");
856        assert_eq!(StateFilter::ShuttingDown.name(), "Shutting down");
857        assert_eq!(StateFilter::Stopping.name(), "Stopping");
858    }
859
860    #[test]
861    fn test_state_filter_matches() {
862        assert!(StateFilter::AllStates.matches("running"));
863        assert!(StateFilter::AllStates.matches("stopped"));
864        assert!(StateFilter::Running.matches("running"));
865        assert!(!StateFilter::Running.matches("stopped"));
866        assert!(StateFilter::Stopped.matches("stopped"));
867        assert!(!StateFilter::Stopped.matches("running"));
868    }
869
870    #[test]
871    fn test_state_filter_next() {
872        assert_eq!(StateFilter::AllStates.next(), StateFilter::Running);
873        assert_eq!(StateFilter::Running.next(), StateFilter::Stopped);
874        assert_eq!(StateFilter::Stopping.next(), StateFilter::AllStates);
875    }
876
877    #[test]
878    fn test_state_filter_prev() {
879        assert_eq!(StateFilter::AllStates.prev(), StateFilter::Stopping);
880        assert_eq!(StateFilter::Running.prev(), StateFilter::AllStates);
881        assert_eq!(StateFilter::Stopped.prev(), StateFilter::Running);
882    }
883
884    #[test]
885    fn test_state_filter_all_constant() {
886        assert_eq!(StateFilter::ALL.len(), 7);
887        assert_eq!(StateFilter::ALL[0], StateFilter::AllStates);
888        assert_eq!(StateFilter::ALL[6], StateFilter::Stopping);
889    }
890
891    #[test]
892    fn test_state_default() {
893        let state = State::default();
894        assert_eq!(state.table.items.len(), 0);
895        assert_eq!(state.table.selected, 0);
896        assert!(!state.table.loading);
897        assert_eq!(state.table.filter, "");
898        assert_eq!(state.state_filter, StateFilter::AllStates);
899        assert_eq!(state.sort_column, Column::LaunchTime);
900        assert_eq!(state.sort_direction, SortDirection::Desc);
901        assert!(!state.metrics_loading);
902        assert!(state.metric_data_cpu.is_empty());
903        assert!(state.metric_data_network_in.is_empty());
904        assert!(state.metric_data_network_out.is_empty());
905        assert!(state.metric_data_network_packets_in.is_empty());
906        assert!(state.metric_data_network_packets_out.is_empty());
907        assert!(state.metric_data_metadata_no_token.is_empty());
908    }
909
910    #[test]
911    fn test_state_filter_matches_all_states() {
912        let filter = StateFilter::AllStates;
913        assert!(filter.matches("running"));
914        assert!(filter.matches("stopped"));
915        assert!(filter.matches("terminated"));
916        assert!(filter.matches("pending"));
917        assert!(filter.matches("shutting-down"));
918        assert!(filter.matches("stopping"));
919    }
920
921    #[test]
922    fn test_state_filter_matches_specific_states() {
923        assert!(StateFilter::Running.matches("running"));
924        assert!(!StateFilter::Running.matches("stopped"));
925
926        assert!(StateFilter::Stopped.matches("stopped"));
927        assert!(!StateFilter::Stopped.matches("running"));
928
929        assert!(StateFilter::Terminated.matches("terminated"));
930        assert!(!StateFilter::Terminated.matches("running"));
931
932        assert!(StateFilter::Pending.matches("pending"));
933        assert!(!StateFilter::Pending.matches("running"));
934
935        assert!(StateFilter::ShuttingDown.matches("shutting-down"));
936        assert!(!StateFilter::ShuttingDown.matches("running"));
937
938        assert!(StateFilter::Stopping.matches("stopping"));
939        assert!(!StateFilter::Stopping.matches("running"));
940    }
941
942    #[test]
943    fn test_state_filter_cycle() {
944        let mut filter = StateFilter::AllStates;
945        filter = filter.next();
946        assert_eq!(filter, StateFilter::Running);
947        filter = filter.next();
948        assert_eq!(filter, StateFilter::Stopped);
949        filter = filter.next();
950        assert_eq!(filter, StateFilter::Terminated);
951        filter = filter.next();
952        assert_eq!(filter, StateFilter::Pending);
953        filter = filter.next();
954        assert_eq!(filter, StateFilter::ShuttingDown);
955        filter = filter.next();
956        assert_eq!(filter, StateFilter::Stopping);
957        filter = filter.next();
958        assert_eq!(filter, StateFilter::AllStates);
959    }
960
961    #[test]
962    fn test_filter_controls_constant() {
963        assert_eq!(FILTER_CONTROLS.len(), 3);
964        assert_eq!(FILTER_CONTROLS[0], InputFocus::Filter);
965        assert_eq!(FILTER_CONTROLS[1], STATE_FILTER);
966        assert_eq!(FILTER_CONTROLS[2], InputFocus::Pagination);
967    }
968
969    #[test]
970    fn test_input_focus_cycling() {
971        let mut focus = InputFocus::Filter;
972        focus = focus.next(&FILTER_CONTROLS);
973        assert_eq!(focus, STATE_FILTER);
974        focus = focus.next(&FILTER_CONTROLS);
975        assert_eq!(focus, InputFocus::Pagination);
976        focus = focus.next(&FILTER_CONTROLS);
977        assert_eq!(focus, InputFocus::Filter);
978    }
979
980    #[test]
981    fn test_input_focus_reverse_cycling() {
982        let mut focus = InputFocus::Filter;
983        focus = focus.prev(&FILTER_CONTROLS);
984        assert_eq!(focus, InputFocus::Pagination);
985        focus = focus.prev(&FILTER_CONTROLS);
986        assert_eq!(focus, STATE_FILTER);
987        focus = focus.prev(&FILTER_CONTROLS);
988        assert_eq!(focus, InputFocus::Filter);
989    }
990
991    #[test]
992    fn test_state_default_input_focus() {
993        let state = State::default();
994        assert_eq!(state.input_focus, InputFocus::Filter);
995    }
996
997    #[test]
998    fn test_filter_controls_includes_state_filter() {
999        assert_eq!(FILTER_CONTROLS.len(), 3);
1000        assert_eq!(FILTER_CONTROLS[0], InputFocus::Filter);
1001        assert_eq!(FILTER_CONTROLS[1], STATE_FILTER);
1002        assert_eq!(FILTER_CONTROLS[2], InputFocus::Pagination);
1003    }
1004
1005    #[test]
1006    fn test_state_filter_constant() {
1007        assert_eq!(STATE_FILTER, InputFocus::Checkbox("state"));
1008    }
1009
1010    #[test]
1011    fn test_state_filter_all_has_7_items() {
1012        assert_eq!(StateFilter::ALL.len(), 7);
1013    }
1014
1015    #[test]
1016    fn test_dropdown_shows_when_state_filter_focused() {
1017        // This is tested via integration - dropdown renders when input_focus == STATE_FILTER
1018        // Verify the constant is accessible
1019        let focus = STATE_FILTER;
1020        assert_eq!(focus, InputFocus::Checkbox("state"));
1021    }
1022
1023    #[test]
1024    fn test_detail_tab_cycling() {
1025        let mut tab = DetailTab::Details;
1026        tab = tab.next();
1027        assert_eq!(tab, DetailTab::StatusAndAlarms);
1028        tab = tab.next();
1029        assert_eq!(tab, DetailTab::Monitoring);
1030        tab = tab.next();
1031        assert_eq!(tab, DetailTab::Security);
1032        tab = tab.next();
1033        assert_eq!(tab, DetailTab::Networking);
1034        tab = tab.next();
1035        assert_eq!(tab, DetailTab::Storage);
1036        tab = tab.next();
1037        assert_eq!(tab, DetailTab::Tags);
1038        tab = tab.next();
1039        assert_eq!(tab, DetailTab::Details);
1040    }
1041
1042    #[test]
1043    fn test_detail_tab_reverse_cycling() {
1044        let mut tab = DetailTab::Details;
1045        tab = tab.prev();
1046        assert_eq!(tab, DetailTab::Tags);
1047        tab = tab.prev();
1048        assert_eq!(tab, DetailTab::Storage);
1049        tab = tab.prev();
1050        assert_eq!(tab, DetailTab::Networking);
1051        tab = tab.prev();
1052        assert_eq!(tab, DetailTab::Security);
1053        tab = tab.prev();
1054        assert_eq!(tab, DetailTab::Monitoring);
1055        tab = tab.prev();
1056        assert_eq!(tab, DetailTab::StatusAndAlarms);
1057        tab = tab.prev();
1058        assert_eq!(tab, DetailTab::Details);
1059    }
1060
1061    #[test]
1062    fn test_detail_tab_names() {
1063        assert_eq!(DetailTab::Details.name(), "Details");
1064        assert_eq!(DetailTab::StatusAndAlarms.name(), "Status and alarms");
1065        assert_eq!(DetailTab::Monitoring.name(), "Monitoring");
1066        assert_eq!(DetailTab::Security.name(), "Security");
1067        assert_eq!(DetailTab::Networking.name(), "Networking");
1068        assert_eq!(DetailTab::Storage.name(), "Storage");
1069        assert_eq!(DetailTab::Tags.name(), "Tags");
1070    }
1071
1072    #[test]
1073    fn test_detail_tab_all_has_7_items() {
1074        assert_eq!(DetailTab::ALL.len(), 7);
1075    }
1076
1077    #[test]
1078    fn test_state_default_has_no_current_instance() {
1079        let state = State::default();
1080        assert_eq!(state.current_instance, None);
1081        assert_eq!(state.detail_tab, DetailTab::Details);
1082    }
1083
1084    #[test]
1085    fn test_column_distribution_10_fields_3_columns() {
1086        // 10 fields, 3 columns: should be 4, 3, 3
1087        let total = 10;
1088        let cols = 3;
1089        let base = total / cols; // 3
1090        let extra = total % cols; // 1
1091
1092        let mut distribution = Vec::new();
1093        for col in 0..cols {
1094            let count = if col < extra { base + 1 } else { base };
1095            distribution.push(count);
1096        }
1097
1098        assert_eq!(distribution, vec![4, 3, 3]);
1099    }
1100
1101    #[test]
1102    fn test_column_distribution_10_fields_2_columns() {
1103        // 10 fields, 2 columns: should be 5, 5
1104        let total = 10;
1105        let cols = 2;
1106        let base = total / cols;
1107        let extra = total % cols;
1108
1109        let mut distribution = Vec::new();
1110        for col in 0..cols {
1111            let count = if col < extra { base + 1 } else { base };
1112            distribution.push(count);
1113        }
1114
1115        assert_eq!(distribution, vec![5, 5]);
1116    }
1117
1118    #[test]
1119    fn test_column_distribution_10_fields_4_columns() {
1120        // 10 fields, 4 columns: should be 3, 3, 2, 2
1121        let total = 10;
1122        let cols = 4;
1123        let base = total / cols; // 2
1124        let extra = total % cols; // 2
1125
1126        let mut distribution = Vec::new();
1127        for col in 0..cols {
1128            let count = if col < extra { base + 1 } else { base };
1129            distribution.push(count);
1130        }
1131
1132        assert_eq!(distribution, vec![3, 3, 2, 2]);
1133    }
1134
1135    #[test]
1136    fn test_column_distribution_7_fields_3_columns() {
1137        // 7 fields, 3 columns: should be 3, 2, 2
1138        let total = 7;
1139        let cols = 3;
1140        let base = total / cols; // 2
1141        let extra = total % cols; // 1
1142
1143        let mut distribution = Vec::new();
1144        for col in 0..cols {
1145            let count = if col < extra { base + 1 } else { base };
1146            distribution.push(count);
1147        }
1148
1149        assert_eq!(distribution, vec![3, 2, 2]);
1150    }
1151
1152    #[test]
1153    fn test_column_distribution_ensures_first_has_most() {
1154        // Test that first column always has >= other columns
1155        for total in 1..20 {
1156            for cols in 1..=total {
1157                let base = total / cols;
1158                let extra = total % cols;
1159
1160                let first_col = if 0 < extra { base + 1 } else { base };
1161                let last_col = if (cols - 1) < extra { base + 1 } else { base };
1162
1163                assert!(
1164                    first_col >= last_col,
1165                    "total={}, cols={}, first={}, last={}",
1166                    total,
1167                    cols,
1168                    first_col,
1169                    last_col
1170                );
1171            }
1172        }
1173    }
1174
1175    #[test]
1176    fn test_render_fields_with_dynamic_columns_empty() {
1177        use ratatui::backend::TestBackend;
1178        use ratatui::Terminal;
1179
1180        let backend = TestBackend::new(80, 24);
1181        let mut terminal = Terminal::new(backend).unwrap();
1182
1183        terminal
1184            .draw(|f| {
1185                let area = f.area();
1186                let height = render_fields_with_dynamic_columns(f, area, vec![]);
1187                assert_eq!(height, 0);
1188            })
1189            .unwrap();
1190    }
1191
1192    #[test]
1193    fn test_render_fields_with_dynamic_columns_single_field() {
1194        use ratatui::backend::TestBackend;
1195        use ratatui::Terminal;
1196
1197        let backend = TestBackend::new(80, 24);
1198        let mut terminal = Terminal::new(backend).unwrap();
1199
1200        terminal
1201            .draw(|f| {
1202                let area = f.area();
1203                let fields = vec![Line::from("Test field")];
1204                let height = render_fields_with_dynamic_columns(f, area, fields);
1205                assert_eq!(height, 1);
1206            })
1207            .unwrap();
1208    }
1209
1210    #[test]
1211    fn test_render_fields_with_dynamic_columns_calculates_height() {
1212        use ratatui::backend::TestBackend;
1213        use ratatui::Terminal;
1214
1215        let backend = TestBackend::new(40, 24); // Narrow width
1216        let mut terminal = Terminal::new(backend).unwrap();
1217
1218        terminal
1219            .draw(|f| {
1220                let area = f.area();
1221                let fields = vec![
1222                    Line::from("Field 1"),
1223                    Line::from("Field 2"),
1224                    Line::from("Field 3"),
1225                    Line::from("Field 4"),
1226                ];
1227                let height = render_fields_with_dynamic_columns(f, area, fields);
1228                // Should return a reasonable height
1229                assert!((1..=4).contains(&height));
1230            })
1231            .unwrap();
1232    }
1233
1234    #[test]
1235    fn test_instance_summary_has_18_fields() {
1236        // Verify summary has all required fields matching AWS console
1237        let field_count = 18;
1238        assert_eq!(field_count, 18, "Instance summary should have 18 fields");
1239    }
1240
1241    #[test]
1242    fn test_instance_summary_includes_key_fields() {
1243        // Test that key field labels are present
1244        let required_fields = vec![
1245            "Instance ID",
1246            "Public IPv4 address",
1247            "Private IPv4 addresses",
1248            "IPv6 address",
1249            "Instance state",
1250            "Public DNS",
1251            "Hostname type",
1252            "Private IP DNS name (IPv4 only)",
1253            "Instance type",
1254            "Elastic IP addresses",
1255            "Auto-assigned IP address",
1256            "VPC ID",
1257            "IAM Role",
1258            "Subnet ID",
1259            "IMDSv2",
1260            "Availability Zone",
1261            "Managed",
1262            "Operator",
1263        ];
1264        assert_eq!(required_fields.len(), 18);
1265    }
1266
1267    #[test]
1268    fn test_rounded_block_helper_creates_block_with_title() {
1269        use crate::ui::titled_block;
1270        use ratatui::prelude::Rect;
1271        // Verify rounded_block helper can be used with title
1272        let block = titled_block("Instance summary");
1273        let area = Rect::new(0, 0, 50, 10);
1274        let inner = block.inner(area);
1275        // Inner area: 50 - 2 (borders) - 2 (padding) = 46 width
1276        assert_eq!(inner.width, 48);
1277        assert_eq!(inner.height, 8);
1278    }
1279
1280    #[test]
1281    fn test_summary_height_uses_dynamic_calculation() {
1282        use crate::ui::{calculate_dynamic_height, labeled_field};
1283        // Verify summary height accounts for column packing
1284        let fields = vec![
1285            labeled_field("Instance ID", "i-1234567890abcdef0"),
1286            labeled_field("Instance type", "t2.micro"),
1287            labeled_field("State", "running"),
1288            labeled_field("VPC ID", "vpc-12345678"),
1289        ];
1290        let width = 180;
1291        let height = calculate_dynamic_height(&fields, width);
1292        // With 4 fields and wide width, should pack into 2 rows
1293        assert!(height <= 2, "Expected 2 rows or less, got {}", height);
1294    }
1295
1296    #[test]
1297    fn test_tag_column_ids() {
1298        let ids = TagColumn::ids();
1299        assert_eq!(ids.len(), 2);
1300        assert_eq!(ids[0], "column.ec2.tag.key");
1301        assert_eq!(ids[1], "column.ec2.tag.value");
1302    }
1303
1304    #[test]
1305    fn test_tag_column_from_id() {
1306        assert_eq!(
1307            TagColumn::from_id("column.ec2.tag.key"),
1308            Some(TagColumn::Key)
1309        );
1310        assert_eq!(
1311            TagColumn::from_id("column.ec2.tag.value"),
1312            Some(TagColumn::Value)
1313        );
1314        assert_eq!(TagColumn::from_id("invalid"), None);
1315    }
1316
1317    #[test]
1318    fn test_state_includes_tags() {
1319        let state = State::default();
1320        assert_eq!(state.tags.items.len(), 0);
1321        assert_eq!(state.tag_visible_column_ids.len(), 2);
1322        assert_eq!(state.tag_column_ids.len(), 2);
1323    }
1324
1325    #[test]
1326    fn test_filtered_tags_empty() {
1327        use crate::app::App;
1328        let app = App::new_without_client("default".to_string(), None);
1329        let filtered = filtered_tags(&app);
1330        assert_eq!(filtered.len(), 0);
1331    }
1332
1333    #[test]
1334    fn test_filtered_tags_with_filter() {
1335        use crate::app::App;
1336        let mut app = App::new_without_client("default".to_string(), None);
1337        app.ec2_state.tags.items = vec![
1338            InstanceTag {
1339                key: "Name".to_string(),
1340                value: "test-instance".to_string(),
1341            },
1342            InstanceTag {
1343                key: "Environment".to_string(),
1344                value: "production".to_string(),
1345            },
1346            InstanceTag {
1347                key: "Team".to_string(),
1348                value: "backend".to_string(),
1349            },
1350        ];
1351        app.ec2_state.tags.filter = "prod".to_string();
1352        let filtered = filtered_tags(&app);
1353        assert_eq!(filtered.len(), 1);
1354        assert_eq!(filtered[0].key, "Environment");
1355    }
1356
1357    #[test]
1358    fn test_filtered_tags_sorts_by_value() {
1359        use crate::app::App;
1360        let mut app = App::new_without_client("default".to_string(), None);
1361        app.ec2_state.tags.items = vec![
1362            InstanceTag {
1363                key: "Name".to_string(),
1364                value: "zebra".to_string(),
1365            },
1366            InstanceTag {
1367                key: "Environment".to_string(),
1368                value: "alpha".to_string(),
1369            },
1370            InstanceTag {
1371                key: "Team".to_string(),
1372                value: "beta".to_string(),
1373            },
1374        ];
1375        let filtered = filtered_tags(&app);
1376        assert_eq!(filtered.len(), 3);
1377        assert_eq!(filtered[0].value, "alpha");
1378        assert_eq!(filtered[1].value, "beta");
1379        assert_eq!(filtered[2].value, "zebra");
1380    }
1381
1382    #[test]
1383    fn test_load_tags_integration() {
1384        // Test that load_tags function exists and has correct signature
1385        use crate::app::App;
1386        let mut app = App::new_without_client("default".to_string(), None);
1387        app.ec2_state.tags.items = vec![InstanceTag {
1388            key: "test".to_string(),
1389            value: "value".to_string(),
1390        }];
1391        assert_eq!(app.ec2_state.tags.items.len(), 1);
1392    }
1393
1394    #[test]
1395    fn test_tags_columns_fallback_when_empty() {
1396        use crate::app::App;
1397        let app = App::new_without_client("default".to_string(), None);
1398        // tag_visible_column_ids should be initialized with all columns
1399        assert_eq!(app.ec2_state.tag_visible_column_ids.len(), 2);
1400    }
1401
1402    #[test]
1403    fn test_tags_collapse_on_left_arrow() {
1404        use crate::app::App;
1405        let mut app = App::new_without_client("default".to_string(), None);
1406        app.ec2_state.tags.expanded_item = Some(0);
1407        assert!(app.ec2_state.tags.has_expanded_item());
1408        app.ec2_state.tags.collapse();
1409        assert!(!app.ec2_state.tags.has_expanded_item());
1410    }
1411
1412    #[test]
1413    fn test_max_detail_columns_constant() {
1414        use crate::ui::MAX_DETAIL_COLUMNS;
1415        assert_eq!(MAX_DETAIL_COLUMNS, 3);
1416    }
1417
1418    #[test]
1419    fn test_page_size_options_constant() {
1420        use crate::ui::PAGE_SIZE_OPTIONS;
1421        assert_eq!(PAGE_SIZE_OPTIONS.len(), 4);
1422        assert_eq!(PAGE_SIZE_OPTIONS[0].1, "10");
1423        assert_eq!(PAGE_SIZE_OPTIONS[3].1, "100");
1424    }
1425
1426    #[test]
1427    fn test_page_size_options_small_constant() {
1428        use crate::ui::PAGE_SIZE_OPTIONS_SMALL;
1429        assert_eq!(PAGE_SIZE_OPTIONS_SMALL.len(), 3);
1430        assert_eq!(PAGE_SIZE_OPTIONS_SMALL[0].1, "10");
1431        assert_eq!(PAGE_SIZE_OPTIONS_SMALL[2].1, "50");
1432    }
1433
1434    #[test]
1435    fn test_ec2_filter_has_search_icon_label() {
1436        // The filter panel must use render_filter_bar (which always shows SEARCH_ICON = "─ 🔍 ─"
1437        // as the block title), so there must never be an unlabelled/empty filter panel.
1438        let source = include_str!("ec2.rs");
1439
1440        // render_instances must call render_filter_bar (not skip it)
1441        assert!(
1442            source.contains("render_filter_bar"),
1443            "render_instances must use render_filter_bar to show the 🔍 label"
1444        );
1445
1446        // FILTER_HINT must be set as the placeholder so empty filter shows context
1447        assert!(
1448            source.contains("placeholder: FILTER_HINT"),
1449            "FilterConfig must use FILTER_HINT as placeholder"
1450        );
1451
1452        // Verify FILTER_HINT is not empty
1453        assert!(!FILTER_HINT.is_empty(), "FILTER_HINT must not be empty");
1454    }
1455}