1use crate::app::App;
2use crate::common::{render_pagination_text, CyclicEnum, InputFocus, SortDirection};
3use crate::efs::fs::{self, FileSystem as EfsFileSystem};
4use crate::table::TableState;
5use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
6use crate::ui::format_title;
7use crate::ui::render_tabs;
8use crate::ui::table::{expanded_from_columns, render_table, Column, TableConfig};
9use ratatui::{prelude::*, widgets::*};
10
11pub const FILTER_CONTROLS: [InputFocus; 2] = [InputFocus::Filter, InputFocus::Pagination];
12
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub enum DetailTab {
15 MeteredSize,
16 Monitoring,
17 Tags,
18 FileSystemPolicy,
19 AccessPoints,
20 Network,
21 Replication,
22}
23
24impl CyclicEnum for DetailTab {
25 const ALL: &'static [Self] = &[
26 Self::MeteredSize,
27 Self::Monitoring,
28 Self::Tags,
29 Self::FileSystemPolicy,
30 Self::AccessPoints,
31 Self::Network,
32 Self::Replication,
33 ];
34}
35
36impl DetailTab {
37 pub fn name(&self) -> &'static str {
38 match self {
39 DetailTab::MeteredSize => "Metered size",
40 DetailTab::Monitoring => "Monitoring",
41 DetailTab::Tags => "Tags",
42 DetailTab::FileSystemPolicy => "File system policy",
43 DetailTab::AccessPoints => "Access points",
44 DetailTab::Network => "Network",
45 DetailTab::Replication => "Replication",
46 }
47 }
48}
49
50pub struct State {
51 pub file_systems: TableState<EfsFileSystem>,
52 pub input_focus: InputFocus,
53 pub current_file_system: Option<String>,
54 pub detail_tab: DetailTab,
55 pub tags_table: TableState<(String, String)>,
56 pub access_points: TableState<rusticity_core::efs::EfsAccessPoint>,
58 pub ap_column_ids: Vec<crate::common::ColumnId>,
59 pub ap_visible_column_ids: Vec<crate::common::ColumnId>,
60 pub ap_loading: bool,
61 pub mount_targets: TableState<rusticity_core::efs::EfsMountTarget>,
63 pub mt_column_ids: Vec<crate::common::ColumnId>,
64 pub mt_visible_column_ids: Vec<crate::common::ColumnId>,
65 pub mt_loading: bool,
66 pub replication_document: String,
68 pub replication_loading: bool,
69 pub policy_document: String,
71 pub policy_scroll: usize,
72 pub policy_loading: bool,
73 pub metrics_loading: bool,
75 pub monitoring_scroll: usize,
76 pub metric_throughput_utilization: Vec<(i64, f64)>,
77 pub metric_iops_data_write: Vec<(i64, f64)>,
78 pub metric_iops_data_read: Vec<(i64, f64)>,
79 pub metric_iops_metadata: Vec<(i64, f64)>,
80 pub metric_throughput_data_write: Vec<(i64, f64)>,
81 pub metric_throughput_data_read: Vec<(i64, f64)>,
82 pub metric_throughput_metadata: Vec<(i64, f64)>,
83 pub metric_iops_utilization: Vec<(i64, f64)>,
84 pub metric_client_connections: Vec<(i64, f64)>,
85 pub metric_storage_standard: Vec<(i64, f64)>,
86 pub metric_storage_ia: Vec<(i64, f64)>,
87 pub metric_storage_archive: Vec<(i64, f64)>,
88}
89
90impl Default for State {
91 fn default() -> Self {
92 Self::new()
93 }
94}
95
96impl State {
97 pub fn new() -> Self {
98 Self {
99 file_systems: TableState::new(),
100 input_focus: InputFocus::Filter,
101 current_file_system: None,
102 detail_tab: DetailTab::MeteredSize,
103 tags_table: TableState::new(),
104 access_points: TableState::new(),
105 ap_column_ids: crate::efs::access_point::Column::ids(),
106 ap_visible_column_ids: crate::efs::access_point::Column::default_visible_ids(),
107 ap_loading: false,
108 mount_targets: TableState::new(),
109 mt_column_ids: crate::efs::mount_target::Column::ids(),
110 mt_visible_column_ids: crate::efs::mount_target::Column::default_visible_ids(),
111 mt_loading: false,
112 replication_document: String::new(),
113 replication_loading: false,
114 policy_document: String::new(),
115 policy_scroll: 0,
116 policy_loading: false,
117 metrics_loading: false,
118 monitoring_scroll: 0,
119 metric_throughput_utilization: Vec::new(),
120 metric_iops_data_write: Vec::new(),
121 metric_iops_data_read: Vec::new(),
122 metric_iops_metadata: Vec::new(),
123 metric_throughput_data_write: Vec::new(),
124 metric_throughput_data_read: Vec::new(),
125 metric_throughput_metadata: Vec::new(),
126 metric_iops_utilization: Vec::new(),
127 metric_client_connections: Vec::new(),
128 metric_storage_standard: Vec::new(),
129 metric_storage_ia: Vec::new(),
130 metric_storage_archive: Vec::new(),
131 }
132 }
133}
134
135impl crate::ui::monitoring::MonitoringState for State {
136 fn is_metrics_loading(&self) -> bool {
137 self.metrics_loading
138 }
139
140 fn set_metrics_loading(&mut self, loading: bool) {
141 self.metrics_loading = loading;
142 }
143
144 fn monitoring_scroll(&self) -> usize {
145 self.monitoring_scroll
146 }
147
148 fn set_monitoring_scroll(&mut self, scroll: usize) {
149 self.monitoring_scroll = scroll;
150 }
151
152 fn clear_metrics(&mut self) {
153 self.metric_throughput_utilization.clear();
154 self.metric_iops_data_write.clear();
155 self.metric_iops_data_read.clear();
156 self.metric_iops_metadata.clear();
157 self.metric_throughput_data_write.clear();
158 self.metric_throughput_data_read.clear();
159 self.metric_throughput_metadata.clear();
160 self.metric_iops_utilization.clear();
161 self.metric_client_connections.clear();
162 self.metric_storage_standard.clear();
163 self.metric_storage_ia.clear();
164 self.metric_storage_archive.clear();
165 }
166}
167
168pub async fn load_access_points(app: &mut App, file_system_id: &str) -> anyhow::Result<()> {
170 let items = app
171 .efs_client
172 .describe_access_points(file_system_id)
173 .await?;
174 app.efs_state.access_points.items = items;
175 app.efs_state.access_points.selected = 0;
176 app.efs_state.access_points.expanded_item = None;
177 Ok(())
178}
179
180pub async fn load_mount_targets(app: &mut App, file_system_id: &str) -> anyhow::Result<()> {
182 let items = app
183 .efs_client
184 .describe_mount_targets(file_system_id)
185 .await?;
186 app.efs_state.mount_targets.items = items;
187 app.efs_state.mount_targets.selected = 0;
188 app.efs_state.mount_targets.expanded_item = None;
189 Ok(())
190}
191
192pub async fn load_replication(app: &mut App, file_system_id: &str) -> anyhow::Result<()> {
194 let replication = app.efs_client.get_replication(file_system_id).await?;
195 app.efs_state.replication_document = replication.unwrap_or_default();
196 Ok(())
197}
198
199pub async fn load_policy(app: &mut App, file_system_id: &str) -> anyhow::Result<()> {
201 let policy = app
202 .efs_client
203 .get_file_system_policy(file_system_id)
204 .await?;
205 app.efs_state.policy_document = policy.unwrap_or_default();
206 app.efs_state.policy_scroll = 0;
207 Ok(())
208}
209
210pub async fn load_metrics(app: &mut App, file_system_id: &str) -> anyhow::Result<()> {
212 let data = app
213 .efs_client
214 .get_monitoring_metrics(file_system_id)
215 .await?;
216 let s = &mut app.efs_state;
217 s.metric_throughput_utilization = data.throughput_utilization;
218 s.metric_iops_data_write = data.iops_data_write;
219 s.metric_iops_data_read = data.iops_data_read;
220 s.metric_iops_metadata = data.iops_metadata;
221 s.metric_throughput_data_write = data.throughput_data_write;
222 s.metric_throughput_data_read = data.throughput_data_read;
223 s.metric_throughput_metadata = data.throughput_metadata;
224 s.metric_iops_utilization = data.iops_utilization;
225 s.metric_client_connections = data.client_connections;
226 s.metric_storage_standard = data.storage_standard;
227 s.metric_storage_ia = data.storage_ia;
228 s.metric_storage_archive = data.storage_archive;
229 Ok(())
230}
231
232pub fn filtered_efs_file_systems(app: &App) -> Vec<&EfsFileSystem> {
233 let filter = &app.efs_state.file_systems.filter;
234 app.efs_state
235 .file_systems
236 .items
237 .iter()
238 .filter(|fs| {
239 filter.is_empty()
240 || fs.name.to_lowercase().contains(&filter.to_lowercase())
241 || fs
242 .file_system_id
243 .to_lowercase()
244 .contains(&filter.to_lowercase())
245 || fs
246 .availability_zone
247 .to_lowercase()
248 .contains(&filter.to_lowercase())
249 })
250 .collect()
251}
252
253pub fn render_file_systems(frame: &mut Frame, app: &App, area: Rect) {
254 if app.efs_state.current_file_system.is_some() {
255 render_file_system_detail(frame, app, area);
256 } else {
257 render_file_systems_list(frame, app, area);
258 }
259}
260
261pub fn render_file_system_detail(frame: &mut Frame, app: &App, area: Rect) {
262 let fs = app.efs_state.current_file_system.as_deref().and_then(|id| {
263 app.efs_state
264 .file_systems
265 .items
266 .iter()
267 .find(|f| f.file_system_id == id)
268 });
269
270 let tabs: Vec<(&str, DetailTab)> = DetailTab::ALL.iter().map(|t| (t.name(), *t)).collect();
271
272 if let Some(fs) = fs {
273 let field_lines = build_general_lines(fs);
275 let content_rows =
276 crate::ui::calculate_dynamic_height(&field_lines, area.width.saturating_sub(2));
277 let summary_height = (content_rows + 3).min(area.height.saturating_sub(2));
279
280 let chunks = Layout::default()
282 .direction(Direction::Vertical)
283 .constraints([
284 Constraint::Length(summary_height), Constraint::Length(1), Constraint::Min(0), ])
288 .split(area);
289
290 let block = crate::ui::titled_block(" General ");
292 let inner = block.inner(chunks[0]);
293 frame.render_widget(block, chunks[0]);
294 crate::ui::render_fields_with_dynamic_columns(frame, inner, field_lines);
295
296 render_tabs(frame, chunks[1], &tabs, &app.efs_state.detail_tab);
298
299 match app.efs_state.detail_tab {
301 DetailTab::MeteredSize => {
302 render_metered_size_tab(frame, fs, chunks[2]);
303 }
304 DetailTab::Monitoring => {
305 render_efs_monitoring_charts(frame, app, chunks[2]);
306 }
307 DetailTab::Tags => {
308 render_tags_tab(frame, app, fs, chunks[2]);
309 }
310 DetailTab::FileSystemPolicy => {
311 render_file_system_policy_tab(frame, app, chunks[2]);
312 }
313 DetailTab::AccessPoints => {
314 render_access_points_tab(frame, app, chunks[2]);
315 }
316 DetailTab::Network => {
317 render_network_tab(frame, app, chunks[2]);
318 }
319 DetailTab::Replication => {
320 render_replication_tab(frame, app, chunks[2]);
321 }
322 }
323 } else {
324 let chunks = Layout::default()
325 .direction(Direction::Vertical)
326 .constraints([Constraint::Min(0), Constraint::Length(1)])
327 .split(area);
328 render_tabs(frame, chunks[1], &tabs, &app.efs_state.detail_tab);
329 }
330}
331
332fn build_general_lines(fs: &EfsFileSystem) -> Vec<ratatui::text::Line<'static>> {
333 use crate::ui::{labeled_field, labeled_field_colored};
334
335 let az_display = if fs.availability_zone.is_empty() {
336 "Regional".to_string()
337 } else {
338 fs.availability_zone.clone()
339 };
340
341 let (encrypted_text, encrypted_color) = if fs.encrypted == "Yes" || fs.encrypted == "YES" {
344 if fs.kms_key_id.is_empty() {
345 ("Yes".to_string(), ratatui::style::Color::Green)
346 } else {
347 (fs.kms_key_id.clone(), ratatui::style::Color::White)
348 }
349 } else if fs.encrypted == "No" || fs.encrypted == "NO" {
350 ("No".to_string(), ratatui::style::Color::Yellow)
351 } else {
352 (fs.encrypted.clone(), ratatui::style::Color::White)
353 };
354
355 let (state_text, state_color) = match fs.life_cycle_state.as_str() {
357 "available" | "Available" | "AVAILABLE" => {
358 ("✅ Available".to_string(), ratatui::style::Color::Green)
359 }
360 "creating" | "Creating" | "CREATING" => {
361 ("⏳ Creating".to_string(), ratatui::style::Color::Yellow)
362 }
363 "deleting" | "Deleting" | "DELETING" => {
364 ("🗑 Deleting".to_string(), ratatui::style::Color::Red)
365 }
366 "deleted" | "Deleted" | "DELETED" => ("🗑 Deleted".to_string(), ratatui::style::Color::Red),
367 "updating" | "Updating" | "UPDATING" => {
368 ("🔄 Updating".to_string(), ratatui::style::Color::Yellow)
369 }
370 "error" | "Error" | "ERROR" => ("⚠ Error".to_string(), ratatui::style::Color::Red),
371 s => (s.to_string(), ratatui::style::Color::White),
372 };
373
374 let (rep_text, rep_color) = match fs.replication_overwrite_protection.as_str() {
376 "Enabled" | "ENABLED" => ("Enabled".to_string(), ratatui::style::Color::Green),
377 "Disabled" | "DISABLED" => ("Disabled".to_string(), ratatui::style::Color::Yellow),
378 "Replicating" | "REPLICATING" => ("Replicating".to_string(), ratatui::style::Color::Cyan),
379 s => (s.to_string(), ratatui::style::Color::White),
380 };
381
382 vec![
383 labeled_field("ARN", fs.file_system_arn.clone()),
384 labeled_field(
385 "Performance mode",
386 format_performance_mode_display(&fs.performance_mode),
387 ),
388 labeled_field("Throughput mode", fs.throughput_mode.clone()),
389 labeled_field("Availability zone", az_display),
390 labeled_field_colored("Encrypted", encrypted_text, encrypted_color),
391 labeled_field_colored("File system state", state_text, state_color),
392 labeled_field("DNS name", fs.dns_name.clone()),
393 labeled_field_colored("Replication overwrite protection", rep_text, rep_color),
394 labeled_field("File system ID", fs.file_system_id.clone()),
395 labeled_field("Creation time", fs.creation_time.clone()),
396 ]
397}
398
399fn fmt_metered_bytes(bytes: i64) -> String {
402 const GIB: f64 = (1u64 << 30) as f64;
403 const MIB: f64 = (1u64 << 20) as f64;
404 const KIB: f64 = (1u64 << 10) as f64;
405 let b = bytes as f64;
406 if b >= GIB {
407 format!("{:.2} GiB", b / GIB)
408 } else if b >= MIB {
409 format!("{:.2} MiB", b / MIB)
410 } else if b >= KIB {
411 format!("{:.2} KiB", b / KIB)
412 } else {
413 format!("{} Bytes", bytes)
414 }
415}
416
417fn metered_pct(part: i64, total: i64) -> f64 {
419 if total > 0 {
420 (part as f64 / total as f64) * 100.0
421 } else {
422 0.0
423 }
424}
425
426fn metered_field_value(bytes: i64, total: i64) -> String {
428 format!(
429 "{} ({:.0}%)",
430 fmt_metered_bytes(bytes),
431 metered_pct(bytes, total)
432 )
433}
434
435fn render_metered_size_tab(frame: &mut Frame, fs: &EfsFileSystem, area: Rect) {
436 use crate::ui::labeled_field;
437
438 let total = fs.total_size_bytes.max(0);
439 let field_lines: Vec<Line> = vec![
440 labeled_field("Total size", fmt_metered_bytes(fs.total_size_bytes)),
441 labeled_field(
442 "Size in Standard",
443 metered_field_value(fs.size_in_standard_bytes, total),
444 ),
445 labeled_field(
446 "Size in IA",
447 metered_field_value(fs.size_in_ia_bytes, total),
448 ),
449 labeled_field(
450 "Size in Archive",
451 metered_field_value(fs.size_in_archive_bytes, total),
452 ),
453 ];
454
455 let content_rows =
457 crate::ui::calculate_dynamic_height(&field_lines, area.width.saturating_sub(2));
458 let pane_height = (content_rows + 3).min(area.height);
459 let pane = Rect {
460 height: pane_height,
461 ..area
462 };
463
464 let block = crate::ui::titled_block(" Metered size ");
465 let inner = block.inner(pane);
466 frame.render_widget(block, pane);
467 crate::ui::render_fields_with_dynamic_columns(frame, inner, field_lines);
468}
469
470pub fn filtered_access_points(app: &App) -> Vec<&rusticity_core::efs::EfsAccessPoint> {
472 let filter = app.efs_state.access_points.filter.to_lowercase();
473 app.efs_state
474 .access_points
475 .items
476 .iter()
477 .filter(|ap| {
478 filter.is_empty()
479 || ap.name.to_lowercase().contains(&filter)
480 || ap.access_point_id.to_lowercase().contains(&filter)
481 || ap.path.to_lowercase().contains(&filter)
482 })
483 .collect()
484}
485
486fn render_access_points_tab(frame: &mut Frame, app: &App, area: Rect) {
487 use rusticity_core::efs::EfsAccessPoint;
488
489 let chunks = Layout::default()
490 .direction(Direction::Vertical)
491 .constraints([Constraint::Length(3), Constraint::Min(0)])
492 .split(area);
493
494 let filtered = filtered_access_points(app);
495 let filtered_count = filtered.len();
496
497 let page_size = app.efs_state.access_points.page_size.value();
498 let total_pages = filtered_count.div_ceil(page_size.max(1));
499 let current_page = app
500 .efs_state
501 .access_points
502 .selected
503 .checked_div(page_size)
504 .unwrap_or(0);
505 let pagination = render_pagination_text(current_page, total_pages);
506
507 render_simple_filter(
508 frame,
509 chunks[0],
510 SimpleFilterConfig {
511 filter_text: &app.efs_state.access_points.filter,
512 placeholder: "Search by name, access point ID, or path",
513 pagination: &pagination,
514 mode: app.mode,
515 is_input_focused: app.efs_state.input_focus == InputFocus::Filter,
516 is_pagination_focused: app.efs_state.input_focus == InputFocus::Pagination,
517 },
518 );
519
520 let start_idx = current_page * page_size;
521 let end_idx = (start_idx + page_size).min(filtered_count);
522 let paginated: Vec<_> = if start_idx < filtered_count {
523 filtered[start_idx..end_idx].to_vec()
524 } else {
525 vec![]
526 };
527
528 let columns: Vec<Box<dyn Column<EfsAccessPoint>>> = app
529 .efs_state
530 .ap_visible_column_ids
531 .iter()
532 .filter_map(|id| {
533 crate::efs::access_point::Column::from_id(id)
534 .map(|c| Box::new(c) as Box<dyn Column<EfsAccessPoint>>)
535 })
536 .collect();
537
538 let expanded_index = app.efs_state.access_points.expanded_item.and_then(|idx| {
539 if idx >= start_idx && idx < end_idx {
540 Some(idx - start_idx)
541 } else {
542 None
543 }
544 });
545 let selected_index = app
546 .efs_state
547 .access_points
548 .selected
549 .saturating_sub(start_idx);
550
551 let config = TableConfig {
552 items: paginated,
553 selected_index,
554 expanded_index,
555 columns: &columns,
556 sort_column: "Name",
557 sort_direction: SortDirection::Asc,
558 title: format_title(&format!("Access points ({})", filtered_count)),
559 area: chunks[1],
560 get_expanded_content: Some(Box::new(|ap: &EfsAccessPoint| {
561 expanded_from_columns(&columns, ap)
562 })),
563 is_active: app.mode != crate::keymap::Mode::FilterInput,
564 };
565 render_table(frame, config);
566}
567
568fn render_network_tab(frame: &mut Frame, app: &App, area: Rect) {
569 use rusticity_core::efs::EfsMountTarget;
570
571 let items: Vec<&EfsMountTarget> = app.efs_state.mount_targets.items.iter().collect();
573 let count = items.len();
574
575 let columns: Vec<Box<dyn Column<EfsMountTarget>>> = app
576 .efs_state
577 .mt_visible_column_ids
578 .iter()
579 .filter_map(|id| {
580 crate::efs::mount_target::Column::from_id(id)
581 .map(|c| Box::new(c) as Box<dyn Column<EfsMountTarget>>)
582 })
583 .collect();
584
585 let selected = app
586 .efs_state
587 .mount_targets
588 .selected
589 .min(count.saturating_sub(1));
590
591 let config = TableConfig {
592 items,
593 selected_index: selected,
594 expanded_index: app.efs_state.mount_targets.expanded_item,
595 columns: &columns,
596 sort_column: "Availability zone (AZ-ID)",
597 sort_direction: SortDirection::Asc,
598 title: format_title(&format!("Mount targets ({})", count)),
599 area,
600 get_expanded_content: Some(Box::new(|mt: &EfsMountTarget| {
601 expanded_from_columns(&columns, mt)
602 })),
603 is_active: app.mode != crate::keymap::Mode::FilterInput,
604 };
605 render_table(frame, config);
606}
607
608fn replication_text(doc: &str) -> (String, bool) {
612 if doc.trim().is_empty() {
613 (
614 "No replication\nThis file system is not being replicated.".to_string(),
615 true,
616 )
617 } else {
618 (doc.to_string(), false)
619 }
620}
621
622fn render_replication_tab(frame: &mut Frame, app: &App, area: Rect) {
623 let block = crate::ui::titled_block(" Replication ");
624 let inner = block.inner(area);
625 frame.render_widget(block, area);
626
627 let (text, is_placeholder) = replication_text(&app.efs_state.replication_document);
628 if is_placeholder {
629 let msg = Paragraph::new(text)
630 .style(Style::default().fg(Color::DarkGray))
631 .alignment(Alignment::Center);
632 frame.render_widget(msg, inner);
633 } else {
634 frame.render_widget(Paragraph::new(text), inner);
635 }
636}
637
638fn render_file_system_policy_tab(frame: &mut Frame, app: &App, area: Rect) {
641 let policy = &app.efs_state.policy_document;
642 if policy.is_empty() {
643 let block = crate::ui::titled_block(" File system policy ");
644 let inner = block.inner(area);
645 frame.render_widget(block, area);
646 let msg = Paragraph::new("No file system policy attached")
647 .style(Style::default().fg(Color::DarkGray))
648 .alignment(Alignment::Center);
649 frame.render_widget(msg, inner);
650 return;
651 }
652 crate::ui::render_json_highlighted(
653 frame,
654 area,
655 policy,
656 app.efs_state.policy_scroll,
657 "File system policy",
658 app.mode != crate::keymap::Mode::FilterInput,
659 );
660}
661
662fn multi_y_step(max: f64) -> u32 {
665 if max <= 10.0 {
666 1
667 } else {
668 ((max / 10.0).ceil() as u32).max(1)
669 }
670}
671
672fn render_efs_monitoring_charts(frame: &mut Frame, app: &App, area: Rect) {
673 use crate::ui::monitoring::{render_monitoring_tab, MetricChart, MultiDatasetChart};
674
675 let s = &app.efs_state;
676
677 const GIB: f64 = (1u64 << 30) as f64;
679 let to_gib = |series: &[(i64, f64)]| -> Vec<(i64, f64)> {
680 series.iter().map(|(t, v)| (*t, v / GIB)).collect()
681 };
682 let storage_standard = to_gib(&s.metric_storage_standard);
683 let storage_ia = to_gib(&s.metric_storage_ia);
684 let storage_archive = to_gib(&s.metric_storage_archive);
685
686 let pct_step = 10;
688 let storage_max = storage_standard
689 .iter()
690 .chain(storage_ia.iter())
691 .chain(storage_archive.iter())
692 .map(|(_, v)| *v)
693 .fold(0.0_f64, f64::max);
694 let storage_step = multi_y_step(storage_max);
695
696 let single = [
698 MetricChart {
699 title: "Throughput utilization (%)",
700 data: &s.metric_throughput_utilization,
701 y_axis_label: "%",
702 x_axis_label: None,
703 threshold: Some(75.0),
704 },
705 MetricChart {
706 title: "IOPS utilization (%)",
707 data: &s.metric_iops_utilization,
708 y_axis_label: "%",
709 x_axis_label: None,
710 ..Default::default()
711 },
712 MetricChart {
713 title: "Client connections",
714 data: &s.metric_client_connections,
715 y_axis_label: "Count",
716 x_axis_label: None,
717 ..Default::default()
718 },
719 ];
720
721 let multi = [
723 MultiDatasetChart {
724 title: "IOPS by type (%)",
725 datasets: vec![
726 ("Data write", s.metric_iops_data_write.as_slice()),
727 ("Data read", s.metric_iops_data_read.as_slice()),
728 ("Metadata", s.metric_iops_metadata.as_slice()),
729 ],
730 y_axis_label: "%",
731 y_axis_step: pct_step,
732 x_axis_label: None,
733 },
734 MultiDatasetChart {
735 title: "Throughput by type (%)",
736 datasets: vec![
737 ("Data write", s.metric_throughput_data_write.as_slice()),
738 ("Data read", s.metric_throughput_data_read.as_slice()),
739 ("Metadata", s.metric_throughput_metadata.as_slice()),
740 ],
741 y_axis_label: "%",
742 y_axis_step: pct_step,
743 x_axis_label: None,
744 },
745 MultiDatasetChart {
746 title: "Storage bytes (GiB)",
747 datasets: vec![
748 ("Standard", storage_standard.as_slice()),
749 ("IA", storage_ia.as_slice()),
750 ("Archive", storage_archive.as_slice()),
751 ],
752 y_axis_label: "GiB",
753 y_axis_step: storage_step,
754 x_axis_label: None,
755 },
756 ];
757
758 render_monitoring_tab(frame, area, &single, &multi, &[], &[], s.monitoring_scroll);
759}
760
761fn render_tags_tab(frame: &mut Frame, app: &App, fs: &EfsFileSystem, area: Rect) {
762 use crate::common::SortDirection;
763 use crate::ui::format_title;
764 use crate::ui::table::{expanded_from_columns, render_table, Column, TableConfig};
765
766 struct KeyCol;
767 struct ValueCol;
768
769 #[derive(Clone)]
770 struct TagRow {
771 key: String,
772 value: String,
773 }
774
775 impl Column<TagRow> for KeyCol {
776 fn name(&self) -> &str {
777 "Key"
778 }
779 fn width(&self) -> u16 {
780 30
781 }
782 fn render(&self, item: &TagRow) -> (String, ratatui::style::Style) {
783 (item.key.clone(), ratatui::style::Style::default())
784 }
785 }
786 impl Column<TagRow> for ValueCol {
787 fn name(&self) -> &str {
788 "Value"
789 }
790 fn width(&self) -> u16 {
791 50
792 }
793 fn render(&self, item: &TagRow) -> (String, ratatui::style::Style) {
794 (item.value.clone(), ratatui::style::Style::default())
795 }
796 }
797
798 let rows: Vec<TagRow> = fs
799 .tags
800 .iter()
801 .map(|(k, v)| TagRow {
802 key: k.clone(),
803 value: v.clone(),
804 })
805 .collect();
806 let row_refs: Vec<&TagRow> = rows.iter().collect();
807
808 let columns: Vec<Box<dyn Column<TagRow>>> = vec![Box::new(KeyCol), Box::new(ValueCol)];
809
810 let selected = app
811 .efs_state
812 .tags_table
813 .selected
814 .min(rows.len().saturating_sub(1));
815 let expanded_index = app.efs_state.tags_table.expanded_item;
816
817 let config = TableConfig {
818 items: row_refs,
819 selected_index: selected,
820 expanded_index,
821 columns: &columns,
822 sort_column: "Key",
823 sort_direction: SortDirection::Asc,
824 title: format_title(&format!("Tags ({})", rows.len())),
825 area,
826 get_expanded_content: Some(Box::new(|row: &TagRow| {
827 expanded_from_columns(&columns, row)
828 })),
829 is_active: app.mode != crate::keymap::Mode::FilterInput,
830 };
831 render_table(frame, config);
832}
833
834fn format_performance_mode_display(mode: &str) -> String {
835 match mode {
836 "GeneralPurpose" => "General Purpose".to_string(),
837 "MaxIo" => "Max I/O".to_string(),
838 s => s.to_string(),
839 }
840}
841
842pub fn render_file_systems_list(frame: &mut Frame, app: &App, area: Rect) {
843 let chunks = Layout::default()
844 .direction(Direction::Vertical)
845 .constraints([
846 Constraint::Length(3), Constraint::Min(0), ])
849 .split(area);
850
851 let filtered = filtered_efs_file_systems(app);
852 let filtered_count = filtered.len();
853
854 let page_size = app.efs_state.file_systems.page_size.value();
855 let total_pages = filtered_count.div_ceil(page_size);
856 let current_page = app
857 .efs_state
858 .file_systems
859 .selected
860 .checked_div(page_size)
861 .unwrap_or(0);
862 let pagination = render_pagination_text(current_page, total_pages);
863
864 render_simple_filter(
865 frame,
866 chunks[0],
867 SimpleFilterConfig {
868 filter_text: &app.efs_state.file_systems.filter,
869 placeholder: "Search by name, file system ID, or availability zone",
870 pagination: &pagination,
871 mode: app.mode,
872 is_input_focused: app.efs_state.input_focus == InputFocus::Filter,
873 is_pagination_focused: app.efs_state.input_focus == InputFocus::Pagination,
874 },
875 );
876
877 let start_idx = current_page * page_size;
878 let end_idx = (start_idx + page_size).min(filtered_count);
879 let paginated: Vec<_> = if start_idx < filtered_count {
880 filtered[start_idx..end_idx].to_vec()
881 } else {
882 vec![]
883 };
884
885 let title = format_title(&format!("File Systems ({})", filtered_count));
886
887 let columns: Vec<Box<dyn Column<EfsFileSystem>>> = app
888 .efs_visible_column_ids
889 .iter()
890 .filter_map(|col_id| {
891 fs::Column::from_id(col_id).map(|col| Box::new(col) as Box<dyn Column<EfsFileSystem>>)
892 })
893 .collect();
894
895 let expanded_index = app.efs_state.file_systems.expanded_item.and_then(|idx| {
896 if idx >= start_idx && idx < end_idx {
897 Some(idx - start_idx)
898 } else {
899 None
900 }
901 });
902
903 let selected_index = app
904 .efs_state
905 .file_systems
906 .selected
907 .saturating_sub(start_idx);
908
909 let config = TableConfig {
910 items: paginated,
911 selected_index,
912 expanded_index,
913 columns: &columns,
914 sort_column: "Creation time",
915 sort_direction: SortDirection::Desc,
916 title,
917 area: chunks[1],
918 get_expanded_content: Some(Box::new(|fs: &EfsFileSystem| {
919 expanded_from_columns(&columns, fs)
920 })),
921 is_active: app.mode != crate::keymap::Mode::FilterInput,
922 };
923
924 render_table(frame, config);
925}
926
927#[cfg(test)]
928mod tests {
929 use super::*;
930
931 #[test]
932 fn test_state_default() {
933 let state = State::new();
934 assert_eq!(state.input_focus, InputFocus::Filter);
935 assert!(state.file_systems.items.is_empty());
936 }
937
938 #[test]
939 fn test_filtered_efs_by_name() {
940 use crate::app::App;
941 let mut app = App::new_without_client("default".to_string(), None);
942 app.efs_state.file_systems.items = vec![
943 make_fs("fs-001", "prod-efs", "us-east-1a"),
944 make_fs("fs-002", "dev-efs", "us-east-1b"),
945 ];
946
947 app.efs_state.file_systems.filter = "prod".to_string();
948 let filtered = filtered_efs_file_systems(&app);
949 assert_eq!(filtered.len(), 1);
950 assert_eq!(filtered[0].name, "prod-efs");
951 }
952
953 #[test]
954 fn test_filtered_efs_by_id() {
955 use crate::app::App;
956 let mut app = App::new_without_client("default".to_string(), None);
957 app.efs_state.file_systems.items = vec![
958 make_fs("fs-aabbcc", "alpha", "us-east-1a"),
959 make_fs("fs-ddeeff", "beta", "us-east-1b"),
960 ];
961
962 app.efs_state.file_systems.filter = "aabb".to_string();
963 let filtered = filtered_efs_file_systems(&app);
964 assert_eq!(filtered.len(), 1);
965 assert_eq!(filtered[0].file_system_id, "fs-aabbcc");
966 }
967
968 #[test]
969 fn test_filtered_efs_empty_filter_returns_all() {
970 use crate::app::App;
971 let mut app = App::new_without_client("default".to_string(), None);
972 app.efs_state.file_systems.items = vec![
973 make_fs("fs-001", "a", "us-east-1a"),
974 make_fs("fs-002", "b", "us-east-1b"),
975 ];
976 let filtered = filtered_efs_file_systems(&app);
977 assert_eq!(filtered.len(), 2);
978 }
979
980 fn make_fs(id: &str, name: &str, az: &str) -> EfsFileSystem {
981 EfsFileSystem {
982 file_system_id: id.to_string(),
983 name: name.to_string(),
984 creation_token: String::new(),
985 encrypted: "Yes".to_string(),
986 kms_key_id: String::new(),
987 total_size: String::new(),
988 size_in_standard: String::new(),
989 size_in_ia: String::new(),
990 size_in_archive: String::new(),
991 provisioned_throughput: String::new(),
992 throughput_mode: String::new(),
993 life_cycle_state: "Available".to_string(),
994 number_of_mount_targets: "0".to_string(),
995 owner_id: String::new(),
996 creation_time: String::new(),
997 performance_mode: String::new(),
998 availability_zone: az.to_string(),
999 replication_overwrite_protection: String::new(),
1000 file_system_arn: String::new(),
1001 dns_name: String::new(),
1002 tags: vec![],
1003 total_size_bytes: 0,
1004 size_in_standard_bytes: 0,
1005 size_in_ia_bytes: 0,
1006 size_in_archive_bytes: 0,
1007 }
1008 }
1009
1010 #[test]
1011 fn test_yank_in_list_view_copies_file_system_id() {
1012 use crate::app::App;
1013 use crate::efs::actions::yank;
1014 let mut app = App::new_without_client("default".to_string(), None);
1015 app.efs_state.file_systems.items = vec![make_fs("fs-001", "my-efs", "us-east-1a")];
1016 app.efs_state.file_systems.selected = 0;
1017 app.efs_state.current_file_system = None;
1018 yank(&app);
1021 assert_eq!(app.efs_state.current_file_system, None);
1022 }
1023
1024 #[test]
1025 fn test_yank_in_detail_view_uses_arn() {
1026 use crate::app::App;
1027 use crate::efs::actions::yank;
1028 let mut app = App::new_without_client("default".to_string(), None);
1029 let mut fs = make_fs("fs-001", "my-efs", "us-east-1a");
1030 fs.file_system_arn =
1031 "arn:aws:elasticfilesystem:us-east-1:123456789012:file-system/fs-001".to_string();
1032 app.efs_state.file_systems.items = vec![fs];
1033 app.efs_state.current_file_system = Some("fs-001".to_string());
1034 yank(&app);
1036 assert_eq!(
1038 app.efs_state.current_file_system,
1039 Some("fs-001".to_string())
1040 );
1041 }
1042
1043 #[test]
1044 fn test_general_pane_height_fits_content() {
1045 use crate::ui::calculate_dynamic_height;
1049 use crate::ui::labeled_field;
1050
1051 let fields: Vec<ratatui::text::Line> = vec![
1052 labeled_field(
1053 "ARN",
1054 "arn:aws:elasticfilesystem:us-east-1:123:file-system/fs-001",
1055 ),
1056 labeled_field("Performance mode", "General Purpose"),
1057 labeled_field("Throughput mode", "Bursting"),
1058 labeled_field("Availability zone", "Regional"),
1059 labeled_field("Encrypted", "Yes"),
1060 labeled_field("File system state", "Available"),
1061 labeled_field("DNS name", "fs-001.efs.us-east-1.amazonaws.com"),
1062 labeled_field("Replication overwrite protection", "Enabled"),
1063 labeled_field("File system ID", "fs-001"),
1064 labeled_field("Creation time", "2024-01-01T00:00:00Z"),
1065 ];
1066
1067 let wide_height = calculate_dynamic_height(&fields, 300);
1069 let narrow_height = calculate_dynamic_height(&fields, 60);
1071
1072 assert!(
1073 wide_height <= narrow_height,
1074 "wide terminal should need <= rows than narrow: wide={wide_height} narrow={narrow_height}"
1075 );
1076
1077 let block_height = (wide_height + 2).min(50);
1079 assert!(
1080 block_height <= 50,
1081 "block height must fit in available area"
1082 );
1083 assert!(
1084 block_height >= 3,
1085 "block height must be at least 3 (1 row + 2 borders)"
1086 );
1087 }
1088
1089 #[test]
1090 fn test_fmt_metered_bytes() {
1091 assert_eq!(fmt_metered_bytes(0), "0 Bytes");
1092 assert_eq!(fmt_metered_bytes(512), "512 Bytes");
1093 assert_eq!(fmt_metered_bytes(36 * 1024), "36.00 KiB");
1094 assert_eq!(fmt_metered_bytes(1024 * 1024), "1.00 MiB");
1095 assert_eq!(fmt_metered_bytes(3 * 1024 * 1024 * 1024), "3.00 GiB");
1096 }
1097
1098 #[test]
1099 fn test_metered_pct() {
1100 assert_eq!(metered_pct(0, 0), 0.0);
1101 assert_eq!(metered_pct(50, 0), 0.0);
1102 assert_eq!(metered_pct(36864, 36864), 100.0);
1103 assert_eq!(metered_pct(0, 36864), 0.0);
1104 assert_eq!(metered_pct(25, 100), 25.0);
1105 }
1106
1107 #[test]
1108 fn test_metered_field_value() {
1109 let total = 36 * 1024;
1111 assert_eq!(metered_field_value(36 * 1024, total), "36.00 KiB (100%)");
1112 assert_eq!(metered_field_value(0, total), "0 Bytes (0%)");
1113 assert_eq!(metered_field_value(0, 0), "0 Bytes (0%)");
1115 }
1116
1117 #[test]
1118 fn test_multi_y_step_never_zero() {
1119 assert_eq!(multi_y_step(0.0), 1);
1122 assert_eq!(multi_y_step(5.0), 1);
1123 assert_eq!(multi_y_step(10.0), 1);
1124 assert!(multi_y_step(100.0) >= 1);
1125 assert!(multi_y_step(1_000_000.0) >= 1);
1126 assert_eq!(multi_y_step(0.0001), 1);
1128 }
1129
1130 #[test]
1131 fn test_console_url_policy_tab_has_tab_id() {
1132 let url = crate::efs::console_url_file_system_with_tab(
1133 "us-east-2",
1134 "fs-06320e1e17dfe0398",
1135 DetailTab::FileSystemPolicy,
1136 );
1137 assert!(url.contains("fs-06320e1e17dfe0398"), "url has fs id: {url}");
1138 assert!(
1139 url.contains("tabId=fileSystemPolicy"),
1140 "url has tabId: {url}"
1141 );
1142 assert!(url.contains("region=us-east-2"), "url has region: {url}");
1143 }
1144
1145 #[test]
1146 fn test_console_url_metered_size_tab_has_tab_id() {
1147 let url = crate::efs::console_url_file_system_with_tab(
1148 "us-east-2",
1149 "fs-123",
1150 DetailTab::MeteredSize,
1151 );
1152 assert!(url.contains("tabId=meteredSize"), "url: {url}");
1153 assert!(url.contains("#/file-systems/fs-123"), "url: {url}");
1154 }
1155
1156 #[test]
1157 fn test_replication_placeholder_when_empty() {
1158 let (text, is_placeholder) = replication_text("");
1159 assert!(is_placeholder);
1160 assert!(text.contains("No replication"));
1161 assert!(text.contains("This file system is not being replicated."));
1162 assert!(replication_text(" \n ").1);
1164 }
1165
1166 #[test]
1167 fn test_replication_shows_output_when_present() {
1168 let doc = "Source file system: fs-123\nDestinations:\n • fs-456 (us-west-2) — Enabled";
1169 let (text, is_placeholder) = replication_text(doc);
1170 assert!(!is_placeholder);
1171 assert_eq!(text, doc);
1172 }
1173
1174 #[test]
1175 fn test_default_detail_tab_is_not_general() {
1176 let state = State::new();
1178 assert_eq!(state.detail_tab, DetailTab::MeteredSize);
1179 assert!(DetailTab::ALL.iter().all(|t| t.name() != "General"));
1181 }
1182
1183 #[test]
1184 fn test_filtered_access_points_by_name_id_path() {
1185 use crate::app::App;
1186 use rusticity_core::efs::EfsAccessPoint;
1187 let mut app = App::new_without_client("default".to_string(), None);
1188 let mk = |id: &str, name: &str, path: &str| EfsAccessPoint {
1189 access_point_id: id.to_string(),
1190 name: name.to_string(),
1191 path: path.to_string(),
1192 posix_user: String::new(),
1193 creation_info: String::new(),
1194 life_cycle_state: "available".to_string(),
1195 };
1196 app.efs_state.access_points.items = vec![
1197 mk("fsap-001", "prod-ap", "/prod"),
1198 mk("fsap-002", "dev-ap", "/dev"),
1199 ];
1200
1201 app.efs_state.access_points.filter = "prod".to_string();
1202 assert_eq!(filtered_access_points(&app).len(), 1);
1203
1204 app.efs_state.access_points.filter = "fsap-002".to_string();
1205 assert_eq!(filtered_access_points(&app).len(), 1);
1206
1207 app.efs_state.access_points.filter = "/dev".to_string();
1208 assert_eq!(filtered_access_points(&app).len(), 1);
1209
1210 app.efs_state.access_points.filter = String::new();
1211 assert_eq!(filtered_access_points(&app).len(), 2);
1212 }
1213}