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 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 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 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 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 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 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 let calc_height = |fields: &[Line], width: u16| -> u16 {
476 if fields.is_empty() {
477 return 2; }
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 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 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 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 let total = 10;
1088 let cols = 3;
1089 let base = total / cols; let extra = total % cols; 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 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 let total = 10;
1122 let cols = 4;
1123 let base = total / cols; let extra = total % cols; 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 let total = 7;
1139 let cols = 3;
1140 let base = total / cols; let extra = total % cols; 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 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); 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 assert!((1..=4).contains(&height));
1230 })
1231 .unwrap();
1232 }
1233
1234 #[test]
1235 fn test_instance_summary_has_18_fields() {
1236 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 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 let block = titled_block("Instance summary");
1273 let area = Rect::new(0, 0, 50, 10);
1274 let inner = block.inner(area);
1275 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 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 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 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 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 let source = include_str!("ec2.rs");
1439
1440 assert!(
1442 source.contains("render_filter_bar"),
1443 "render_instances must use render_filter_bar to show the 🔍 label"
1444 );
1445
1446 assert!(
1448 source.contains("placeholder: FILTER_HINT"),
1449 "FilterConfig must use FILTER_HINT as placeholder"
1450 );
1451
1452 assert!(!FILTER_HINT.is_empty(), "FILTER_HINT must not be empty");
1454 }
1455}