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