1use crate::common::{
2 filter_by_fields, render_pagination_text, CyclicEnum, InputFocus, SortDirection,
3};
4use crate::ec2::tag::{Column as TagColumn, InstanceTag};
5use crate::ec2::{Column, Instance};
6use crate::keymap::Mode;
7use crate::table::TableState;
8use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
9use crate::ui::table::{expanded_from_columns, render_table, Column as TableColumn, TableConfig};
10use crate::ui::{
11 calculate_dynamic_height, format_title, render_fields_with_dynamic_columns, rounded_block,
12 titled_block,
13};
14use ratatui::prelude::*;
15
16pub const FILTER_CONTROLS: [InputFocus; 3] = [
17 InputFocus::Filter,
18 InputFocus::Checkbox("state"),
19 InputFocus::Pagination,
20];
21
22pub const STATE_FILTER: InputFocus = InputFocus::Checkbox("state");
23
24#[derive(Debug, Clone, Copy, PartialEq, Default)]
25pub enum StateFilter {
26 #[default]
27 AllStates,
28 Running,
29 Stopped,
30 Terminated,
31 Pending,
32 ShuttingDown,
33 Stopping,
34}
35
36impl StateFilter {
37 pub fn name(&self) -> &'static str {
38 match self {
39 StateFilter::AllStates => "All states",
40 StateFilter::Running => "Running",
41 StateFilter::Stopped => "Stopped",
42 StateFilter::Terminated => "Terminated",
43 StateFilter::Pending => "Pending",
44 StateFilter::ShuttingDown => "Shutting down",
45 StateFilter::Stopping => "Stopping",
46 }
47 }
48
49 pub fn matches(&self, state: &str) -> bool {
50 match self {
51 StateFilter::AllStates => true,
52 StateFilter::Running => state == "running",
53 StateFilter::Stopped => state == "stopped",
54 StateFilter::Terminated => state == "terminated",
55 StateFilter::Pending => state == "pending",
56 StateFilter::ShuttingDown => state == "shutting-down",
57 StateFilter::Stopping => state == "stopping",
58 }
59 }
60}
61
62impl CyclicEnum for StateFilter {
63 const ALL: &'static [Self] = &[
64 StateFilter::AllStates,
65 StateFilter::Running,
66 StateFilter::Stopped,
67 StateFilter::Terminated,
68 StateFilter::Pending,
69 StateFilter::ShuttingDown,
70 StateFilter::Stopping,
71 ];
72
73 fn next(&self) -> Self {
74 match self {
75 StateFilter::AllStates => StateFilter::Running,
76 StateFilter::Running => StateFilter::Stopped,
77 StateFilter::Stopped => StateFilter::Terminated,
78 StateFilter::Terminated => StateFilter::Pending,
79 StateFilter::Pending => StateFilter::ShuttingDown,
80 StateFilter::ShuttingDown => StateFilter::Stopping,
81 StateFilter::Stopping => StateFilter::AllStates,
82 }
83 }
84
85 fn prev(&self) -> Self {
86 match self {
87 StateFilter::AllStates => StateFilter::Stopping,
88 StateFilter::Running => StateFilter::AllStates,
89 StateFilter::Stopped => StateFilter::Running,
90 StateFilter::Terminated => StateFilter::Stopped,
91 StateFilter::Pending => StateFilter::Terminated,
92 StateFilter::ShuttingDown => StateFilter::Pending,
93 StateFilter::Stopping => StateFilter::ShuttingDown,
94 }
95 }
96}
97
98#[derive(Debug, Clone, Copy, PartialEq)]
99pub enum DetailTab {
100 Details,
101 StatusAndAlarms,
102 Monitoring,
103 Security,
104 Networking,
105 Storage,
106 Tags,
107}
108
109impl CyclicEnum for DetailTab {
110 const ALL: &'static [Self] = &[
111 Self::Details,
112 Self::StatusAndAlarms,
113 Self::Monitoring,
114 Self::Security,
115 Self::Networking,
116 Self::Storage,
117 Self::Tags,
118 ];
119}
120
121impl DetailTab {
122 pub fn name(&self) -> &'static str {
123 match self {
124 DetailTab::Details => "Details",
125 DetailTab::StatusAndAlarms => "Status and alarms",
126 DetailTab::Monitoring => "Monitoring",
127 DetailTab::Security => "Security",
128 DetailTab::Networking => "Networking",
129 DetailTab::Storage => "Storage",
130 DetailTab::Tags => "Tags",
131 }
132 }
133}
134
135pub struct State {
136 pub table: TableState<Instance>,
137 pub state_filter: StateFilter,
138 pub sort_column: Column,
139 pub sort_direction: SortDirection,
140 pub input_focus: InputFocus,
141 pub current_instance: Option<String>,
142 pub detail_tab: DetailTab,
143 pub tags: TableState<InstanceTag>,
144 pub tag_visible_column_ids: Vec<String>,
145 pub tag_column_ids: Vec<String>,
146 pub monitoring_scroll: usize,
147 pub metrics_loading: bool,
148 pub metric_data_cpu: Vec<(i64, f64)>,
149 pub metric_data_network_in: Vec<(i64, f64)>,
150 pub metric_data_network_out: Vec<(i64, f64)>,
151 pub metric_data_network_packets_in: Vec<(i64, f64)>,
152 pub metric_data_network_packets_out: Vec<(i64, f64)>,
153 pub metric_data_metadata_no_token: Vec<(i64, f64)>,
154}
155
156impl Default for State {
157 fn default() -> Self {
158 let tag_column_ids: Vec<String> = TagColumn::ids().iter().map(|s| s.to_string()).collect();
159 Self {
160 table: TableState::default(),
161 state_filter: StateFilter::default(),
162 sort_column: Column::LaunchTime,
163 sort_direction: SortDirection::Desc,
164 input_focus: InputFocus::Filter,
165 current_instance: None,
166 detail_tab: DetailTab::Details,
167 tags: TableState::new(),
168 tag_visible_column_ids: tag_column_ids.clone(),
169 tag_column_ids,
170 monitoring_scroll: 0,
171 metrics_loading: false,
172 metric_data_cpu: Vec::new(),
173 metric_data_network_in: Vec::new(),
174 metric_data_network_out: Vec::new(),
175 metric_data_network_packets_in: Vec::new(),
176 metric_data_network_packets_out: Vec::new(),
177 metric_data_metadata_no_token: Vec::new(),
178 }
179 }
180}
181
182impl crate::ui::monitoring::MonitoringState for State {
183 fn is_metrics_loading(&self) -> bool {
184 self.metrics_loading
185 }
186
187 fn set_metrics_loading(&mut self, loading: bool) {
188 self.metrics_loading = loading;
189 }
190
191 fn monitoring_scroll(&self) -> usize {
192 self.monitoring_scroll
193 }
194
195 fn set_monitoring_scroll(&mut self, scroll: usize) {
196 self.monitoring_scroll = scroll;
197 }
198
199 fn clear_metrics(&mut self) {
200 self.metric_data_cpu.clear();
201 self.metric_data_network_in.clear();
202 self.metric_data_network_out.clear();
203 self.metric_data_network_packets_in.clear();
204 self.metric_data_network_packets_out.clear();
205 self.metric_data_metadata_no_token.clear();
206 }
207}
208
209pub const FILTER_HINT: &str = "Find Instance by attribute or tag (case-sensitive)";
210
211pub fn render_instances(
212 frame: &mut Frame,
213 area: Rect,
214 state: &State,
215 visible_columns: &[&str],
216 mode: Mode,
217) {
218 use crate::common::render_dropdown;
219 use crate::ui::filter::{render_filter_bar, FilterConfig, FilterControl};
220 use crate::ui::table::render_table;
221
222 let chunks = Layout::default()
223 .direction(Direction::Vertical)
224 .constraints([Constraint::Length(3), Constraint::Min(0)])
225 .split(area);
226
227 let filtered_items: Vec<&Instance> = state
228 .table
229 .items
230 .iter()
231 .filter(|i| state.state_filter.matches(&i.state))
232 .filter(|i| {
233 if state.table.filter.is_empty() {
234 return true;
235 }
236 i.name.contains(&state.table.filter)
237 || i.instance_id.contains(&state.table.filter)
238 || i.state.contains(&state.table.filter)
239 || i.instance_type.contains(&state.table.filter)
240 || i.availability_zone.contains(&state.table.filter)
241 || i.security_groups.contains(&state.table.filter)
242 || i.key_name.contains(&state.table.filter)
243 })
244 .collect();
245
246 let page_size = state.table.page_size.value();
247 let filtered_count = filtered_items.len();
248 let total_pages = filtered_count.div_ceil(page_size);
249 let current_page = state.table.selected / page_size;
250 let pagination = render_pagination_text(current_page, total_pages);
251
252 render_filter_bar(
253 frame,
254 FilterConfig {
255 filter_text: &state.table.filter,
256 placeholder: FILTER_HINT,
257 mode,
258 is_input_focused: state.input_focus == InputFocus::Filter,
259 controls: vec![
260 FilterControl {
261 text: state.state_filter.name().to_string(),
262 is_focused: state.input_focus == STATE_FILTER,
263 },
264 FilterControl {
265 text: pagination.clone(),
266 is_focused: state.input_focus == InputFocus::Pagination,
267 },
268 ],
269 area: chunks[0],
270 },
271 );
272
273 let columns: Vec<_> = visible_columns
274 .iter()
275 .filter_map(|id| Column::from_id(id).map(|c| c.to_column()))
276 .collect();
277
278 let title = format!("Instances ({})", filtered_count);
279
280 use crate::ui::table::TableConfig;
281 render_table(
282 frame,
283 TableConfig {
284 items: filtered_items,
285 selected_index: state.table.selected,
286 expanded_index: state.table.expanded_item,
287 columns: &columns,
288 sort_column: "",
289 sort_direction: state.sort_direction,
290 title,
291 area: chunks[1],
292 get_expanded_content: Some(Box::new(|instance: &Instance| {
293 expanded_from_columns(&columns, instance)
294 })),
295 is_active: mode == Mode::Normal,
296 },
297 );
298
299 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 },
801 crate::ui::monitoring::MetricChart {
802 title: &network_in_label,
803 data: &app.ec2_state.metric_data_network_in,
804 y_axis_label: "bytes",
805 x_axis_label: None,
806 },
807 crate::ui::monitoring::MetricChart {
808 title: &network_out_label,
809 data: &app.ec2_state.metric_data_network_out,
810 y_axis_label: "bytes",
811 x_axis_label: None,
812 },
813 crate::ui::monitoring::MetricChart {
814 title: &network_packets_in_label,
815 data: &app.ec2_state.metric_data_network_packets_in,
816 y_axis_label: "count",
817 x_axis_label: None,
818 },
819 crate::ui::monitoring::MetricChart {
820 title: &network_packets_out_label,
821 data: &app.ec2_state.metric_data_network_packets_out,
822 y_axis_label: "count",
823 x_axis_label: None,
824 },
825 crate::ui::monitoring::MetricChart {
826 title: &metadata_no_token_label,
827 data: &app.ec2_state.metric_data_metadata_no_token,
828 y_axis_label: "count",
829 x_axis_label: None,
830 },
831 ],
832 &[],
833 &[],
834 &[],
835 app.ec2_state.monitoring_scroll,
836 );
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842
843 #[test]
844 fn test_state_filter_names() {
845 assert_eq!(StateFilter::AllStates.name(), "All states");
846 assert_eq!(StateFilter::Running.name(), "Running");
847 assert_eq!(StateFilter::Stopped.name(), "Stopped");
848 assert_eq!(StateFilter::Terminated.name(), "Terminated");
849 assert_eq!(StateFilter::Pending.name(), "Pending");
850 assert_eq!(StateFilter::ShuttingDown.name(), "Shutting down");
851 assert_eq!(StateFilter::Stopping.name(), "Stopping");
852 }
853
854 #[test]
855 fn test_state_filter_matches() {
856 assert!(StateFilter::AllStates.matches("running"));
857 assert!(StateFilter::AllStates.matches("stopped"));
858 assert!(StateFilter::Running.matches("running"));
859 assert!(!StateFilter::Running.matches("stopped"));
860 assert!(StateFilter::Stopped.matches("stopped"));
861 assert!(!StateFilter::Stopped.matches("running"));
862 }
863
864 #[test]
865 fn test_state_filter_next() {
866 assert_eq!(StateFilter::AllStates.next(), StateFilter::Running);
867 assert_eq!(StateFilter::Running.next(), StateFilter::Stopped);
868 assert_eq!(StateFilter::Stopping.next(), StateFilter::AllStates);
869 }
870
871 #[test]
872 fn test_state_filter_prev() {
873 assert_eq!(StateFilter::AllStates.prev(), StateFilter::Stopping);
874 assert_eq!(StateFilter::Running.prev(), StateFilter::AllStates);
875 assert_eq!(StateFilter::Stopped.prev(), StateFilter::Running);
876 }
877
878 #[test]
879 fn test_state_filter_all_constant() {
880 assert_eq!(StateFilter::ALL.len(), 7);
881 assert_eq!(StateFilter::ALL[0], StateFilter::AllStates);
882 assert_eq!(StateFilter::ALL[6], StateFilter::Stopping);
883 }
884
885 #[test]
886 fn test_state_default() {
887 let state = State::default();
888 assert_eq!(state.table.items.len(), 0);
889 assert_eq!(state.table.selected, 0);
890 assert!(!state.table.loading);
891 assert_eq!(state.table.filter, "");
892 assert_eq!(state.state_filter, StateFilter::AllStates);
893 assert_eq!(state.sort_column, Column::LaunchTime);
894 assert_eq!(state.sort_direction, SortDirection::Desc);
895 assert!(!state.metrics_loading);
896 assert!(state.metric_data_cpu.is_empty());
897 assert!(state.metric_data_network_in.is_empty());
898 assert!(state.metric_data_network_out.is_empty());
899 assert!(state.metric_data_network_packets_in.is_empty());
900 assert!(state.metric_data_network_packets_out.is_empty());
901 assert!(state.metric_data_metadata_no_token.is_empty());
902 }
903
904 #[test]
905 fn test_state_filter_matches_all_states() {
906 let filter = StateFilter::AllStates;
907 assert!(filter.matches("running"));
908 assert!(filter.matches("stopped"));
909 assert!(filter.matches("terminated"));
910 assert!(filter.matches("pending"));
911 assert!(filter.matches("shutting-down"));
912 assert!(filter.matches("stopping"));
913 }
914
915 #[test]
916 fn test_state_filter_matches_specific_states() {
917 assert!(StateFilter::Running.matches("running"));
918 assert!(!StateFilter::Running.matches("stopped"));
919
920 assert!(StateFilter::Stopped.matches("stopped"));
921 assert!(!StateFilter::Stopped.matches("running"));
922
923 assert!(StateFilter::Terminated.matches("terminated"));
924 assert!(!StateFilter::Terminated.matches("running"));
925
926 assert!(StateFilter::Pending.matches("pending"));
927 assert!(!StateFilter::Pending.matches("running"));
928
929 assert!(StateFilter::ShuttingDown.matches("shutting-down"));
930 assert!(!StateFilter::ShuttingDown.matches("running"));
931
932 assert!(StateFilter::Stopping.matches("stopping"));
933 assert!(!StateFilter::Stopping.matches("running"));
934 }
935
936 #[test]
937 fn test_state_filter_cycle() {
938 let mut filter = StateFilter::AllStates;
939 filter = filter.next();
940 assert_eq!(filter, StateFilter::Running);
941 filter = filter.next();
942 assert_eq!(filter, StateFilter::Stopped);
943 filter = filter.next();
944 assert_eq!(filter, StateFilter::Terminated);
945 filter = filter.next();
946 assert_eq!(filter, StateFilter::Pending);
947 filter = filter.next();
948 assert_eq!(filter, StateFilter::ShuttingDown);
949 filter = filter.next();
950 assert_eq!(filter, StateFilter::Stopping);
951 filter = filter.next();
952 assert_eq!(filter, StateFilter::AllStates);
953 }
954
955 #[test]
956 fn test_filter_controls_constant() {
957 assert_eq!(FILTER_CONTROLS.len(), 3);
958 assert_eq!(FILTER_CONTROLS[0], InputFocus::Filter);
959 assert_eq!(FILTER_CONTROLS[1], STATE_FILTER);
960 assert_eq!(FILTER_CONTROLS[2], InputFocus::Pagination);
961 }
962
963 #[test]
964 fn test_input_focus_cycling() {
965 let mut focus = InputFocus::Filter;
966 focus = focus.next(&FILTER_CONTROLS);
967 assert_eq!(focus, STATE_FILTER);
968 focus = focus.next(&FILTER_CONTROLS);
969 assert_eq!(focus, InputFocus::Pagination);
970 focus = focus.next(&FILTER_CONTROLS);
971 assert_eq!(focus, InputFocus::Filter);
972 }
973
974 #[test]
975 fn test_input_focus_reverse_cycling() {
976 let mut focus = InputFocus::Filter;
977 focus = focus.prev(&FILTER_CONTROLS);
978 assert_eq!(focus, InputFocus::Pagination);
979 focus = focus.prev(&FILTER_CONTROLS);
980 assert_eq!(focus, STATE_FILTER);
981 focus = focus.prev(&FILTER_CONTROLS);
982 assert_eq!(focus, InputFocus::Filter);
983 }
984
985 #[test]
986 fn test_state_default_input_focus() {
987 let state = State::default();
988 assert_eq!(state.input_focus, InputFocus::Filter);
989 }
990
991 #[test]
992 fn test_filter_controls_includes_state_filter() {
993 assert_eq!(FILTER_CONTROLS.len(), 3);
994 assert_eq!(FILTER_CONTROLS[0], InputFocus::Filter);
995 assert_eq!(FILTER_CONTROLS[1], STATE_FILTER);
996 assert_eq!(FILTER_CONTROLS[2], InputFocus::Pagination);
997 }
998
999 #[test]
1000 fn test_state_filter_constant() {
1001 assert_eq!(STATE_FILTER, InputFocus::Checkbox("state"));
1002 }
1003
1004 #[test]
1005 fn test_state_filter_all_has_7_items() {
1006 assert_eq!(StateFilter::ALL.len(), 7);
1007 }
1008
1009 #[test]
1010 fn test_dropdown_shows_when_state_filter_focused() {
1011 let focus = STATE_FILTER;
1014 assert_eq!(focus, InputFocus::Checkbox("state"));
1015 }
1016
1017 #[test]
1018 fn test_detail_tab_cycling() {
1019 let mut tab = DetailTab::Details;
1020 tab = tab.next();
1021 assert_eq!(tab, DetailTab::StatusAndAlarms);
1022 tab = tab.next();
1023 assert_eq!(tab, DetailTab::Monitoring);
1024 tab = tab.next();
1025 assert_eq!(tab, DetailTab::Security);
1026 tab = tab.next();
1027 assert_eq!(tab, DetailTab::Networking);
1028 tab = tab.next();
1029 assert_eq!(tab, DetailTab::Storage);
1030 tab = tab.next();
1031 assert_eq!(tab, DetailTab::Tags);
1032 tab = tab.next();
1033 assert_eq!(tab, DetailTab::Details);
1034 }
1035
1036 #[test]
1037 fn test_detail_tab_reverse_cycling() {
1038 let mut tab = DetailTab::Details;
1039 tab = tab.prev();
1040 assert_eq!(tab, DetailTab::Tags);
1041 tab = tab.prev();
1042 assert_eq!(tab, DetailTab::Storage);
1043 tab = tab.prev();
1044 assert_eq!(tab, DetailTab::Networking);
1045 tab = tab.prev();
1046 assert_eq!(tab, DetailTab::Security);
1047 tab = tab.prev();
1048 assert_eq!(tab, DetailTab::Monitoring);
1049 tab = tab.prev();
1050 assert_eq!(tab, DetailTab::StatusAndAlarms);
1051 tab = tab.prev();
1052 assert_eq!(tab, DetailTab::Details);
1053 }
1054
1055 #[test]
1056 fn test_detail_tab_names() {
1057 assert_eq!(DetailTab::Details.name(), "Details");
1058 assert_eq!(DetailTab::StatusAndAlarms.name(), "Status and alarms");
1059 assert_eq!(DetailTab::Monitoring.name(), "Monitoring");
1060 assert_eq!(DetailTab::Security.name(), "Security");
1061 assert_eq!(DetailTab::Networking.name(), "Networking");
1062 assert_eq!(DetailTab::Storage.name(), "Storage");
1063 assert_eq!(DetailTab::Tags.name(), "Tags");
1064 }
1065
1066 #[test]
1067 fn test_detail_tab_all_has_7_items() {
1068 assert_eq!(DetailTab::ALL.len(), 7);
1069 }
1070
1071 #[test]
1072 fn test_state_default_has_no_current_instance() {
1073 let state = State::default();
1074 assert_eq!(state.current_instance, None);
1075 assert_eq!(state.detail_tab, DetailTab::Details);
1076 }
1077
1078 #[test]
1079 fn test_column_distribution_10_fields_3_columns() {
1080 let total = 10;
1082 let cols = 3;
1083 let base = total / cols; let extra = total % cols; let mut distribution = Vec::new();
1087 for col in 0..cols {
1088 let count = if col < extra { base + 1 } else { base };
1089 distribution.push(count);
1090 }
1091
1092 assert_eq!(distribution, vec![4, 3, 3]);
1093 }
1094
1095 #[test]
1096 fn test_column_distribution_10_fields_2_columns() {
1097 let total = 10;
1099 let cols = 2;
1100 let base = total / cols;
1101 let extra = total % cols;
1102
1103 let mut distribution = Vec::new();
1104 for col in 0..cols {
1105 let count = if col < extra { base + 1 } else { base };
1106 distribution.push(count);
1107 }
1108
1109 assert_eq!(distribution, vec![5, 5]);
1110 }
1111
1112 #[test]
1113 fn test_column_distribution_10_fields_4_columns() {
1114 let total = 10;
1116 let cols = 4;
1117 let base = total / cols; let extra = total % cols; let mut distribution = Vec::new();
1121 for col in 0..cols {
1122 let count = if col < extra { base + 1 } else { base };
1123 distribution.push(count);
1124 }
1125
1126 assert_eq!(distribution, vec![3, 3, 2, 2]);
1127 }
1128
1129 #[test]
1130 fn test_column_distribution_7_fields_3_columns() {
1131 let total = 7;
1133 let cols = 3;
1134 let base = total / cols; let extra = total % cols; let mut distribution = Vec::new();
1138 for col in 0..cols {
1139 let count = if col < extra { base + 1 } else { base };
1140 distribution.push(count);
1141 }
1142
1143 assert_eq!(distribution, vec![3, 2, 2]);
1144 }
1145
1146 #[test]
1147 fn test_column_distribution_ensures_first_has_most() {
1148 for total in 1..20 {
1150 for cols in 1..=total {
1151 let base = total / cols;
1152 let extra = total % cols;
1153
1154 let first_col = if 0 < extra { base + 1 } else { base };
1155 let last_col = if (cols - 1) < extra { base + 1 } else { base };
1156
1157 assert!(
1158 first_col >= last_col,
1159 "total={}, cols={}, first={}, last={}",
1160 total,
1161 cols,
1162 first_col,
1163 last_col
1164 );
1165 }
1166 }
1167 }
1168
1169 #[test]
1170 fn test_render_fields_with_dynamic_columns_empty() {
1171 use ratatui::backend::TestBackend;
1172 use ratatui::Terminal;
1173
1174 let backend = TestBackend::new(80, 24);
1175 let mut terminal = Terminal::new(backend).unwrap();
1176
1177 terminal
1178 .draw(|f| {
1179 let area = f.area();
1180 let height = render_fields_with_dynamic_columns(f, area, vec![]);
1181 assert_eq!(height, 0);
1182 })
1183 .unwrap();
1184 }
1185
1186 #[test]
1187 fn test_render_fields_with_dynamic_columns_single_field() {
1188 use ratatui::backend::TestBackend;
1189 use ratatui::Terminal;
1190
1191 let backend = TestBackend::new(80, 24);
1192 let mut terminal = Terminal::new(backend).unwrap();
1193
1194 terminal
1195 .draw(|f| {
1196 let area = f.area();
1197 let fields = vec![Line::from("Test field")];
1198 let height = render_fields_with_dynamic_columns(f, area, fields);
1199 assert_eq!(height, 1);
1200 })
1201 .unwrap();
1202 }
1203
1204 #[test]
1205 fn test_render_fields_with_dynamic_columns_calculates_height() {
1206 use ratatui::backend::TestBackend;
1207 use ratatui::Terminal;
1208
1209 let backend = TestBackend::new(40, 24); let mut terminal = Terminal::new(backend).unwrap();
1211
1212 terminal
1213 .draw(|f| {
1214 let area = f.area();
1215 let fields = vec![
1216 Line::from("Field 1"),
1217 Line::from("Field 2"),
1218 Line::from("Field 3"),
1219 Line::from("Field 4"),
1220 ];
1221 let height = render_fields_with_dynamic_columns(f, area, fields);
1222 assert!((1..=4).contains(&height));
1224 })
1225 .unwrap();
1226 }
1227
1228 #[test]
1229 fn test_instance_summary_has_18_fields() {
1230 let field_count = 18;
1232 assert_eq!(field_count, 18, "Instance summary should have 18 fields");
1233 }
1234
1235 #[test]
1236 fn test_instance_summary_includes_key_fields() {
1237 let required_fields = vec![
1239 "Instance ID",
1240 "Public IPv4 address",
1241 "Private IPv4 addresses",
1242 "IPv6 address",
1243 "Instance state",
1244 "Public DNS",
1245 "Hostname type",
1246 "Private IP DNS name (IPv4 only)",
1247 "Instance type",
1248 "Elastic IP addresses",
1249 "Auto-assigned IP address",
1250 "VPC ID",
1251 "IAM Role",
1252 "Subnet ID",
1253 "IMDSv2",
1254 "Availability Zone",
1255 "Managed",
1256 "Operator",
1257 ];
1258 assert_eq!(required_fields.len(), 18);
1259 }
1260
1261 #[test]
1262 fn test_rounded_block_helper_creates_block_with_title() {
1263 use crate::ui::titled_block;
1264 use ratatui::prelude::Rect;
1265 let block = titled_block("Instance summary");
1267 let area = Rect::new(0, 0, 50, 10);
1268 let inner = block.inner(area);
1269 assert_eq!(inner.width, 48);
1271 assert_eq!(inner.height, 8);
1272 }
1273
1274 #[test]
1275 fn test_summary_height_uses_dynamic_calculation() {
1276 use crate::ui::{calculate_dynamic_height, labeled_field};
1277 let fields = vec![
1279 labeled_field("Instance ID", "i-1234567890abcdef0"),
1280 labeled_field("Instance type", "t2.micro"),
1281 labeled_field("State", "running"),
1282 labeled_field("VPC ID", "vpc-12345678"),
1283 ];
1284 let width = 180;
1285 let height = calculate_dynamic_height(&fields, width);
1286 assert!(height <= 2, "Expected 2 rows or less, got {}", height);
1288 }
1289
1290 #[test]
1291 fn test_tag_column_ids() {
1292 let ids = TagColumn::ids();
1293 assert_eq!(ids.len(), 2);
1294 assert_eq!(ids[0], "column.ec2.tag.key");
1295 assert_eq!(ids[1], "column.ec2.tag.value");
1296 }
1297
1298 #[test]
1299 fn test_tag_column_from_id() {
1300 assert_eq!(
1301 TagColumn::from_id("column.ec2.tag.key"),
1302 Some(TagColumn::Key)
1303 );
1304 assert_eq!(
1305 TagColumn::from_id("column.ec2.tag.value"),
1306 Some(TagColumn::Value)
1307 );
1308 assert_eq!(TagColumn::from_id("invalid"), None);
1309 }
1310
1311 #[test]
1312 fn test_state_includes_tags() {
1313 let state = State::default();
1314 assert_eq!(state.tags.items.len(), 0);
1315 assert_eq!(state.tag_visible_column_ids.len(), 2);
1316 assert_eq!(state.tag_column_ids.len(), 2);
1317 }
1318
1319 #[test]
1320 fn test_filtered_tags_empty() {
1321 use crate::app::App;
1322 let app = App::new_without_client("default".to_string(), None);
1323 let filtered = filtered_tags(&app);
1324 assert_eq!(filtered.len(), 0);
1325 }
1326
1327 #[test]
1328 fn test_filtered_tags_with_filter() {
1329 use crate::app::App;
1330 let mut app = App::new_without_client("default".to_string(), None);
1331 app.ec2_state.tags.items = vec![
1332 InstanceTag {
1333 key: "Name".to_string(),
1334 value: "test-instance".to_string(),
1335 },
1336 InstanceTag {
1337 key: "Environment".to_string(),
1338 value: "production".to_string(),
1339 },
1340 InstanceTag {
1341 key: "Team".to_string(),
1342 value: "backend".to_string(),
1343 },
1344 ];
1345 app.ec2_state.tags.filter = "prod".to_string();
1346 let filtered = filtered_tags(&app);
1347 assert_eq!(filtered.len(), 1);
1348 assert_eq!(filtered[0].key, "Environment");
1349 }
1350
1351 #[test]
1352 fn test_filtered_tags_sorts_by_value() {
1353 use crate::app::App;
1354 let mut app = App::new_without_client("default".to_string(), None);
1355 app.ec2_state.tags.items = vec![
1356 InstanceTag {
1357 key: "Name".to_string(),
1358 value: "zebra".to_string(),
1359 },
1360 InstanceTag {
1361 key: "Environment".to_string(),
1362 value: "alpha".to_string(),
1363 },
1364 InstanceTag {
1365 key: "Team".to_string(),
1366 value: "beta".to_string(),
1367 },
1368 ];
1369 let filtered = filtered_tags(&app);
1370 assert_eq!(filtered.len(), 3);
1371 assert_eq!(filtered[0].value, "alpha");
1372 assert_eq!(filtered[1].value, "beta");
1373 assert_eq!(filtered[2].value, "zebra");
1374 }
1375
1376 #[test]
1377 fn test_load_tags_integration() {
1378 use crate::app::App;
1380 let mut app = App::new_without_client("default".to_string(), None);
1381 app.ec2_state.tags.items = vec![InstanceTag {
1382 key: "test".to_string(),
1383 value: "value".to_string(),
1384 }];
1385 assert_eq!(app.ec2_state.tags.items.len(), 1);
1386 }
1387
1388 #[test]
1389 fn test_tags_columns_fallback_when_empty() {
1390 use crate::app::App;
1391 let app = App::new_without_client("default".to_string(), None);
1392 assert_eq!(app.ec2_state.tag_visible_column_ids.len(), 2);
1394 }
1395
1396 #[test]
1397 fn test_tags_collapse_on_left_arrow() {
1398 use crate::app::App;
1399 let mut app = App::new_without_client("default".to_string(), None);
1400 app.ec2_state.tags.expanded_item = Some(0);
1401 assert!(app.ec2_state.tags.has_expanded_item());
1402 app.ec2_state.tags.collapse();
1403 assert!(!app.ec2_state.tags.has_expanded_item());
1404 }
1405
1406 #[test]
1407 fn test_max_detail_columns_constant() {
1408 use crate::ui::MAX_DETAIL_COLUMNS;
1409 assert_eq!(MAX_DETAIL_COLUMNS, 3);
1410 }
1411
1412 #[test]
1413 fn test_page_size_options_constant() {
1414 use crate::ui::PAGE_SIZE_OPTIONS;
1415 assert_eq!(PAGE_SIZE_OPTIONS.len(), 4);
1416 assert_eq!(PAGE_SIZE_OPTIONS[0].1, "10");
1417 assert_eq!(PAGE_SIZE_OPTIONS[3].1, "100");
1418 }
1419
1420 #[test]
1421 fn test_page_size_options_small_constant() {
1422 use crate::ui::PAGE_SIZE_OPTIONS_SMALL;
1423 assert_eq!(PAGE_SIZE_OPTIONS_SMALL.len(), 3);
1424 assert_eq!(PAGE_SIZE_OPTIONS_SMALL[0].1, "10");
1425 assert_eq!(PAGE_SIZE_OPTIONS_SMALL[2].1, "50");
1426 }
1427}