Skip to main content

rusticity_term/ui/
efs.rs

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    // ── Access points ──
59    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    // ── Network (mount targets) ──
64    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    // ── Replication ──
69    pub replication_document: String,
70    pub replication_loading: bool,
71    // ── File system policy ──
72    pub policy_document: String,
73    pub policy_scroll: usize,
74    pub policy_loading: bool,
75    // ── Monitoring ──
76    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
170/// Load the file system's access points into app state.
171pub 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
182/// Load the file system's mount targets (Network tab) into app state.
183pub 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
194/// Load the file system's replication configuration text into app state.
195pub 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
201/// Load the file system's resource policy JSON into app state.
202pub 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
212/// Load the EFS monitoring metrics for the given file system into app state.
213pub 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        // General summary always visible at top — compute its height
276        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        // Layout: [Summary] [Ribbon] [Tab content]
282        let chunks = Layout::default()
283            .direction(Direction::Vertical)
284            .constraints([
285                Constraint::Length(summary_height), // General summary
286                Constraint::Length(1),              // Ribbon
287                Constraint::Min(0),                 // Tab content
288            ])
289            .split(area);
290
291        // Always render General summary
292        summary.render(frame, chunks[0]);
293
294        // Always render ribbon
295        render_tabs(frame, chunks[1], &tabs, &app.efs_state.detail_tab);
296
297        // Render tab-specific content below
298        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    // Encrypted: show KMS key ARN if encrypted, colored green/yellow
340    // Encrypted: green=Yes (no KMS), white=KMS ARN, yellow=No
341    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    // File system state color — SDK Debug gives lowercase ("available", "creating" etc.)
354    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    // Replication overwrite protection color
373    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
397/// Format a raw byte count AWS-console style: two decimals for KiB/MiB/GiB,
398/// and "N Bytes" below 1 KiB (so 0 renders as "0 Bytes").
399fn 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
415/// Percentage of `part` relative to `total` (0.0 when total is 0).
416fn 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
424/// The value shown for a storage-class field: size plus percentage of total.
425fn 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
456/// Access points filtered by the current filter (name, access point ID, or path).
457pub 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    // No filter for the Network tab; the list is already sorted by AZ ascending.
530    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
566/// Text shown on the Replication tab: either the replication configuration
567/// output, or a placeholder when the file system is not being replicated.
568/// Returns `(text, is_placeholder)`.
569fn 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
596/// Render the File system policy tab as a scrollable, syntax-highlighted JSON
597/// control. Shows a placeholder when the file system has no resource policy.
598fn 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
620/// Compute a `>= 1` y-axis step for a multi-dataset chart from its max value,
621/// aiming for ~10 gridlines. Guards the widget's label loop against a 0 step.
622fn 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    // Graph 6 — convert storage bytes to GiB for a readable y-axis.
636    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    // Steps for the percentage charts (0–100) and the storage chart.
645    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    // Single-value charts (grouped first by the shared widget).
655    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    // Multi-dataset charts.
680    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), // Filter
749            Constraint::Min(0),    // Table
750        ])
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        // List view: yank should select file_system_id (no clipboard in tests,
892        // but we verify it doesn't panic and state is unchanged)
893        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        // Detail view: yank uses ARN. Verify it doesn't panic.
908        yank(&app);
909        // State unchanged — yank is a side-effect (clipboard)
910        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        // The General pane must be sized to content height, not the full available area.
919        // With 10 fields and a wide terminal, all fields fit in 1 row (dynamic columns).
920        // With a narrow terminal, fields stack into more rows.
921        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        // Wide terminal: fields should fit in fewer rows
941        let wide_height = calculate_dynamic_height(&fields, 300);
942        // Narrow terminal: fields stack into more rows
943        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        // Block height = content + 2 borders; must be <= available height
951        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        // 36 KiB total, all Standard.
983        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        // Zero total => 0%.
987        assert_eq!(metered_field_value(0, 0), "0 Bytes (0%)");
988    }
989
990    #[test]
991    fn test_multi_y_step_never_zero() {
992        // The MultiDatasetChart y-axis label loop increments by this step; a 0
993        // step would spin forever. It must always be >= 1.
994        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        // Tiny fractional maxima (e.g. a near-empty file system) still yield >= 1.
1000        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        // Whitespace-only is also treated as empty.
1036        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        // General tab was removed; default detail tab is Metered size.
1050        let state = State::new();
1051        assert_eq!(state.detail_tab, DetailTab::MeteredSize);
1052        // The ribbon no longer contains a General tab.
1053        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}