Skip to main content

rusticity_term/ui/
ec2.rs

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