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 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 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 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 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 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 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 let calc_height = |fields: &[Line], width: u16| -> u16 {
474 if fields.is_empty() {
475 return 2; }
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 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 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 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 let total = 10;
1084 let cols = 3;
1085 let base = total / cols; let extra = total % cols; 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 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 let total = 10;
1118 let cols = 4;
1119 let base = total / cols; let extra = total % cols; 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 let total = 7;
1135 let cols = 3;
1136 let base = total / cols; let extra = total % cols; 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 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); 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 assert!((1..=4).contains(&height));
1226 })
1227 .unwrap();
1228 }
1229
1230 #[test]
1231 fn test_instance_summary_has_18_fields() {
1232 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 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 let block = titled_block("Instance summary");
1269 let area = Rect::new(0, 0, 50, 10);
1270 let inner = block.inner(area);
1271 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 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 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 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 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 let source = include_str!("ec2.rs");
1435
1436 assert!(
1438 source.contains("render_filter_bar"),
1439 "render_instances must use render_filter_bar to show the 🔍 label"
1440 );
1441
1442 assert!(
1444 source.contains("placeholder: FILTER_HINT"),
1445 "FilterConfig must use FILTER_HINT as placeholder"
1446 );
1447
1448 assert!(!FILTER_HINT.is_empty(), "FILTER_HINT must not be empty");
1450 }
1451}