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;
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    // ── Access points ──
57    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    // ── Network (mount targets) ──
62    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    // ── Replication ──
67    pub replication_document: String,
68    pub replication_loading: bool,
69    // ── File system policy ──
70    pub policy_document: String,
71    pub policy_scroll: usize,
72    pub policy_loading: bool,
73    // ── Monitoring ──
74    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
168/// Load the file system's access points into app state.
169pub 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
180/// Load the file system's mount targets (Network tab) into app state.
181pub 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
192/// Load the file system's replication configuration text into app state.
193pub 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
199/// Load the file system's resource policy JSON into app state.
200pub 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
210/// Load the EFS monitoring metrics for the given file system into app state.
211pub 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        // General summary always visible at top — compute its height
274        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        // +2 for borders, +1 so bottom border doesn't sit on last field row
278        let summary_height = (content_rows + 3).min(area.height.saturating_sub(2));
279
280        // Layout: [Summary] [Ribbon] [Tab content]
281        let chunks = Layout::default()
282            .direction(Direction::Vertical)
283            .constraints([
284                Constraint::Length(summary_height), // General summary
285                Constraint::Length(1),              // Ribbon
286                Constraint::Min(0),                 // Tab content
287            ])
288            .split(area);
289
290        // Always render General summary
291        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        // Always render ribbon
297        render_tabs(frame, chunks[1], &tabs, &app.efs_state.detail_tab);
298
299        // Render tab-specific content below
300        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    // Encrypted: show KMS key ARN if encrypted, colored green/yellow
342    // Encrypted: green=Yes (no KMS), white=KMS ARN, yellow=No
343    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    // File system state color — SDK Debug gives lowercase ("available", "creating" etc.)
356    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    // Replication overwrite protection color
375    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
399/// Format a raw byte count AWS-console style: two decimals for KiB/MiB/GiB,
400/// and "N Bytes" below 1 KiB (so 0 renders as "0 Bytes").
401fn 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
417/// Percentage of `part` relative to `total` (0.0 when total is 0).
418fn 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
426/// The value shown for a storage-class field: size plus percentage of total.
427fn 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    // Size the pane to its content height (dynamic columns), like the General summary.
456    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
470/// Access points filtered by the current filter (name, access point ID, or path).
471pub 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    // No filter for the Network tab; the list is already sorted by AZ ascending.
572    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
608/// Text shown on the Replication tab: either the replication configuration
609/// output, or a placeholder when the file system is not being replicated.
610/// Returns `(text, is_placeholder)`.
611fn 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
638/// Render the File system policy tab as a scrollable, syntax-highlighted JSON
639/// control. Shows a placeholder when the file system has no resource policy.
640fn 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
662/// Compute a `>= 1` y-axis step for a multi-dataset chart from its max value,
663/// aiming for ~10 gridlines. Guards the widget's label loop against a 0 step.
664fn 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    // Graph 6 — convert storage bytes to GiB for a readable y-axis.
678    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    // Steps for the percentage charts (0–100) and the storage chart.
687    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    // Single-value charts (grouped first by the shared widget).
697    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    // Multi-dataset charts.
722    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), // Filter
847            Constraint::Min(0),    // Table
848        ])
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        // List view: yank should select file_system_id (no clipboard in tests,
1019        // but we verify it doesn't panic and state is unchanged)
1020        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        // Detail view: yank uses ARN. Verify it doesn't panic.
1035        yank(&app);
1036        // State unchanged — yank is a side-effect (clipboard)
1037        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        // The General pane must be sized to content height, not the full available area.
1046        // With 10 fields and a wide terminal, all fields fit in 1 row (dynamic columns).
1047        // With a narrow terminal, fields stack into more rows.
1048        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        // Wide terminal: fields should fit in fewer rows
1068        let wide_height = calculate_dynamic_height(&fields, 300);
1069        // Narrow terminal: fields stack into more rows
1070        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        // Block height = content + 2 borders; must be <= available height
1078        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        // 36 KiB total, all Standard.
1110        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        // Zero total => 0%.
1114        assert_eq!(metered_field_value(0, 0), "0 Bytes (0%)");
1115    }
1116
1117    #[test]
1118    fn test_multi_y_step_never_zero() {
1119        // The MultiDatasetChart y-axis label loop increments by this step; a 0
1120        // step would spin forever. It must always be >= 1.
1121        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        // Tiny fractional maxima (e.g. a near-empty file system) still yield >= 1.
1127        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        // Whitespace-only is also treated as empty.
1163        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        // General tab was removed; default detail tab is Metered size.
1177        let state = State::new();
1178        assert_eq!(state.detail_tab, DetailTab::MeteredSize);
1179        // The ribbon no longer contains a General tab.
1180        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}