Skip to main content

rusticity_term/ui/
fsx.rs

1use crate::app::App;
2use crate::common::{render_pagination_text, CyclicEnum, InputFocus, SortDirection};
3use crate::fsx::fs;
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::*;
10use ratatui::widgets::Paragraph;
11use rusticity_core::fsx::FsxFileSystem;
12
13pub const FILTER_CONTROLS: [InputFocus; 2] = [InputFocus::Filter, InputFocus::Pagination];
14
15/// Fixed column names for the Tags detail table (single source for table + prefs).
16pub const TAGS_COLUMNS: [&str; 2] = ["Key", "Value"];
17/// Fixed column names for the Updates detail table.
18pub const UPDATES_COLUMNS: [&str; 5] = [
19    "Update type",
20    "Target value",
21    "Status",
22    "Progress %",
23    "Request time",
24];
25/// Fixed column names for the Backups detail table.
26pub const BACKUPS_COLUMNS: [&str; 15] = [
27    "Backup name",
28    "Backup ID",
29    "File system type",
30    "Lifecycle state",
31    "Progress %",
32    "Type",
33    "Deployment Type",
34    "Storage class",
35    "Storage",
36    "Resource ID",
37    "Resource Name",
38    "Backup time",
39    "Active directory",
40    "File system Lustre version",
41    "KMS key ID",
42];
43
44/// Fixed column names for the active detail tab, if it is a fixed-column table.
45pub fn detail_fixed_columns(detail_tab: DetailTab) -> Option<&'static [&'static str]> {
46    match detail_tab {
47        DetailTab::Tags => Some(&TAGS_COLUMNS),
48        DetailTab::Updates => Some(&UPDATES_COLUMNS),
49        DetailTab::Backups => Some(&BACKUPS_COLUMNS),
50        _ => None,
51    }
52}
53
54/// Detail-view ribbon tabs (mirrors the AWS FSx console).
55#[derive(Debug, Clone, Copy, PartialEq)]
56pub enum DetailTab {
57    NetworkSecurity,
58    MonitoringPerformance,
59    Administration,
60    DataRepository,
61    Backups,
62    Updates,
63    Tags,
64}
65
66impl CyclicEnum for DetailTab {
67    const ALL: &'static [Self] = &[
68        Self::NetworkSecurity,
69        Self::MonitoringPerformance,
70        Self::Administration,
71        Self::DataRepository,
72        Self::Backups,
73        Self::Updates,
74        Self::Tags,
75    ];
76}
77
78impl DetailTab {
79    pub fn name(&self) -> &'static str {
80        match self {
81            DetailTab::NetworkSecurity => "Network & security",
82            DetailTab::MonitoringPerformance => "Monitoring & performance",
83            DetailTab::Administration => "Administration",
84            DetailTab::DataRepository => "Data repository",
85            DetailTab::Backups => "Backups",
86            DetailTab::Updates => "Updates",
87            DetailTab::Tags => "Tags",
88        }
89    }
90}
91
92pub struct State {
93    pub file_systems: TableState<FsxFileSystem>,
94    pub input_focus: InputFocus,
95    pub current_file_system: Option<String>,
96    pub detail_tab: DetailTab,
97    pub tags_table: TableState<(String, String)>,
98    pub updates_table: TableState<rusticity_core::fsx::FsxUpdate>,
99    pub backups: TableState<rusticity_core::fsx::FsxBackup>,
100    pub backups_loading: bool,
101}
102
103impl Default for State {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl State {
110    pub fn new() -> Self {
111        Self {
112            file_systems: TableState::new(),
113            input_focus: InputFocus::Filter,
114            current_file_system: None,
115            detail_tab: DetailTab::NetworkSecurity,
116            tags_table: TableState::new(),
117            updates_table: TableState::new(),
118            backups: TableState::new(),
119            backups_loading: false,
120        }
121    }
122}
123
124pub fn filtered_fsx_file_systems(app: &App) -> Vec<&FsxFileSystem> {
125    let filter = app.fsx_state.file_systems.filter.to_lowercase();
126    app.fsx_state
127        .file_systems
128        .items
129        .iter()
130        .filter(|fs| {
131            filter.is_empty()
132                || fs.name.to_lowercase().contains(&filter)
133                || fs.file_system_id.to_lowercase().contains(&filter)
134                || fs.file_system_type.to_lowercase().contains(&filter)
135        })
136        .collect()
137}
138
139pub fn render_file_systems(frame: &mut Frame, app: &App, area: Rect) {
140    if app.fsx_state.current_file_system.is_some() {
141        render_file_system_detail(frame, app, area);
142    } else {
143        render_file_systems_list(frame, app, area);
144    }
145}
146
147// ── Formatting helpers ────────────────────────────────────────────────────────
148
149/// Insert a space before a trailing digit: "Persistent2" -> "Persistent 2".
150pub fn format_deployment_type(raw: &str) -> String {
151    if let Some(last) = raw.chars().last() {
152        if last.is_ascii_digit() && raw.len() > 1 {
153            let (head, tail) = raw.split_at(raw.len() - 1);
154            return format!("{} {}", head, tail);
155        }
156    }
157    raw.to_string()
158}
159
160/// Storage class in upper case: "Ssd" -> "SSD".
161pub fn format_storage_class(raw: &str) -> String {
162    raw.to_uppercase()
163}
164
165/// Data compression type in upper case: "Lz4" -> "LZ4", "None" -> "NONE".
166pub fn format_compression(raw: &str) -> String {
167    raw.to_uppercase()
168}
169
170/// Storage capacity in decimal TB from raw GiB: 309600 -> "309.6 TB".
171pub fn format_capacity_tb(gib: i64) -> String {
172    if gib <= 0 {
173        return "-".to_string();
174    }
175    format!("{:.1} TB", gib as f64 / 1000.0)
176}
177
178/// Format a weekly maintenance start ("d:HH:MM") as "Thursday 07:00 UTC".
179pub fn format_weekly_maintenance(raw: &str) -> String {
180    let parts: Vec<&str> = raw.split(':').collect();
181    if parts.len() == 3 {
182        let day = match parts[0] {
183            "1" => "Monday",
184            "2" => "Tuesday",
185            "3" => "Wednesday",
186            "4" => "Thursday",
187            "5" => "Friday",
188            "6" => "Saturday",
189            "7" => "Sunday",
190            _ => parts[0],
191        };
192        return format!("{} {}:{} UTC", day, parts[1], parts[2]);
193    }
194    raw.to_string()
195}
196
197/// Green for "Enabled", yellow for "Disabled" (used for EFA / Root Squash).
198pub fn enabled_disabled_color(value: &str) -> Color {
199    match value {
200        "Enabled" | "ENABLED" | "enabled" => Color::Green,
201        "Disabled" | "DISABLED" | "disabled" => Color::Yellow,
202        _ => Color::White,
203    }
204}
205
206fn lifecycle_color(status: &str) -> Color {
207    match status {
208        "AVAILABLE" | "Available" | "available" => Color::Green,
209        "CREATING" | "UPDATING" | "Creating" | "Updating" => Color::Yellow,
210        "FAILED" | "DELETING" | "MISCONFIGURED" | "MISCONFIGURED_UNAVAILABLE" => Color::Red,
211        _ => Color::White,
212    }
213}
214
215fn build_summary_lines(fs: &FsxFileSystem) -> Vec<Line<'static>> {
216    use crate::ui::{labeled_field, labeled_field_colored};
217
218    let mut lines: Vec<Line> = vec![
219        labeled_field("File system ID", fs.file_system_id.clone()),
220        labeled_field_colored(
221            "Lifecycle state",
222            fs.status.clone(),
223            lifecycle_color(&fs.status),
224        ),
225        labeled_field("File system type", fs.file_system_type.clone()),
226    ];
227    if !fs.deployment_type.is_empty() {
228        lines.push(labeled_field(
229            "Deployment type",
230            format_deployment_type(&fs.deployment_type),
231        ));
232    }
233    if !fs.data_compression_type.is_empty() {
234        lines.push(labeled_field(
235            "Data compression type",
236            format_compression(&fs.data_compression_type),
237        ));
238    }
239    if !fs.storage_class.is_empty() {
240        lines.push(labeled_field(
241            "Storage class",
242            format_storage_class(&fs.storage_class),
243        ));
244    }
245    lines.push(labeled_field(
246        "Storage capacity",
247        format_capacity_tb(fs.storage_capacity_gib),
248    ));
249    if fs.per_unit_throughput > 0 {
250        lines.push(labeled_field(
251            "Throughput per unit of storage",
252            format!("{} MB/s/TiB", fs.per_unit_throughput),
253        ));
254    }
255    if fs.total_throughput_mbps > 0 {
256        lines.push(labeled_field(
257            "Total throughput",
258            format!("{} MB/s", fs.total_throughput_mbps),
259        ));
260    }
261    if !fs.efa.is_empty() {
262        lines.push(labeled_field_colored(
263            "EFA",
264            fs.efa.clone(),
265            enabled_disabled_color(&fs.efa),
266        ));
267    }
268    if !fs.root_squash.is_empty() {
269        lines.push(labeled_field_colored(
270            "Root Squash",
271            fs.root_squash.clone(),
272            enabled_disabled_color(&fs.root_squash),
273        ));
274    }
275    if !fs.lustre_version.is_empty() {
276        lines.push(labeled_field("Lustre version", fs.lustre_version.clone()));
277    }
278    if !fs.subnet_ids.is_empty() {
279        lines.push(labeled_field("Subnet IDs", fs.subnet_ids.clone()));
280    }
281    lines.push(labeled_field("Creation time", fs.creation_time.clone()));
282    if !fs.mount_name.is_empty() {
283        lines.push(labeled_field("Mount name", fs.mount_name.clone()));
284    }
285    lines
286}
287
288pub fn render_file_system_detail(frame: &mut Frame, app: &App, area: Rect) {
289    let fs = app.fsx_state.current_file_system.as_deref().and_then(|id| {
290        app.fsx_state
291            .file_systems
292            .items
293            .iter()
294            .find(|f| f.file_system_id == id)
295    });
296
297    let tabs: Vec<(&str, DetailTab)> = DetailTab::ALL.iter().map(|t| (t.name(), *t)).collect();
298
299    let Some(fs) = fs else {
300        let chunks = Layout::default()
301            .direction(Direction::Vertical)
302            .constraints([Constraint::Min(0), Constraint::Length(1)])
303            .split(area);
304        render_tabs(frame, chunks[1], &tabs, &app.fsx_state.detail_tab);
305        return;
306    };
307
308    // Summary pane sized to its content height (dynamic columns).
309    let field_lines = build_summary_lines(fs);
310    let content_rows =
311        crate::ui::calculate_dynamic_height(&field_lines, area.width.saturating_sub(2));
312    let summary_height = (content_rows + 3).min(area.height.saturating_sub(2));
313
314    let chunks = Layout::default()
315        .direction(Direction::Vertical)
316        .constraints([
317            Constraint::Length(summary_height),
318            Constraint::Length(1),
319            Constraint::Min(0),
320        ])
321        .split(area);
322
323    let block = crate::ui::titled_block(" Summary ");
324    let inner = block.inner(chunks[0]);
325    frame.render_widget(block, chunks[0]);
326    crate::ui::render_fields_with_dynamic_columns(frame, inner, field_lines);
327
328    render_tabs(frame, chunks[1], &tabs, &app.fsx_state.detail_tab);
329
330    match app.fsx_state.detail_tab {
331        DetailTab::NetworkSecurity => render_network_security(frame, fs, chunks[2]),
332        DetailTab::Administration => render_administration(frame, fs, chunks[2]),
333        DetailTab::Updates => render_updates_tab(frame, app, fs, chunks[2]),
334        DetailTab::Backups => render_backups_tab(frame, app, chunks[2]),
335        DetailTab::Tags => render_tags_tab(frame, app, fs, chunks[2]),
336        other => {
337            let msg = format!("{} — not yet implemented", other.name());
338            let p = Paragraph::new(msg)
339                .style(Style::default().fg(Color::DarkGray))
340                .alignment(Alignment::Center);
341            frame.render_widget(p, chunks[2]);
342        }
343    }
344}
345
346/// A simple label/width/extractor column adapter for fixed detail tables.
347struct FieldCol<T: 'static> {
348    label: &'static str,
349    width: u16,
350    get: fn(&T) -> String,
351}
352
353impl<T> Column<T> for FieldCol<T> {
354    fn name(&self) -> &str {
355        self.label
356    }
357    fn width(&self) -> u16 {
358        self.width
359    }
360    fn render(&self, item: &T) -> (String, Style) {
361        ((self.get)(item), Style::default())
362    }
363}
364
365fn render_network_security(frame: &mut Frame, fs: &FsxFileSystem, area: Rect) {
366    use crate::ui::{calculate_dynamic_height, labeled_field, render_fields_with_dynamic_columns};
367
368    let net_lines: Vec<Line> = vec![
369        labeled_field("VPC", fs.vpc_id.clone()),
370        labeled_field("DNS name", fs.dns_name.clone()),
371        labeled_field("Network interface IDs", fs.network_interface_ids.clone()),
372        labeled_field("KMS key ID", fs.kms_key_id.clone()),
373    ];
374    let subnet_lines: Vec<Line> = vec![
375        labeled_field("Subnet", fs.subnet_ids.clone()),
376        // FSx returns subnet IDs, not AZ names — resolving AZ needs an EC2 call.
377        labeled_field("Availability Zone", "-"),
378        labeled_field("Network interface(s)", "See the Amazon EC2 Console"),
379    ];
380
381    let w = area.width.saturating_sub(2);
382    let h1 = (calculate_dynamic_height(&net_lines, w) + 3).min(area.height);
383    let chunks = Layout::default()
384        .direction(Direction::Vertical)
385        .constraints([Constraint::Length(h1), Constraint::Min(0)])
386        .split(area);
387
388    let block1 = crate::ui::titled_block(" Network & security ");
389    let inner1 = block1.inner(chunks[0]);
390    frame.render_widget(block1, chunks[0]);
391    render_fields_with_dynamic_columns(frame, inner1, net_lines);
392
393    let first_subnet = fs.subnet_ids.split(',').next().unwrap_or("").trim();
394    let subnet_title = if first_subnet.is_empty() {
395        " Subnet ".to_string()
396    } else {
397        format!(" Subnet ({}) ", first_subnet)
398    };
399    let block2 = crate::ui::titled_block(subnet_title);
400    let inner2 = block2.inner(chunks[1]);
401    frame.render_widget(block2, chunks[1]);
402    render_fields_with_dynamic_columns(frame, inner2, subnet_lines);
403}
404
405fn render_administration(frame: &mut Frame, fs: &FsxFileSystem, area: Rect) {
406    use crate::ui::{labeled_field, render_fields_with_dynamic_columns};
407
408    let mut lines: Vec<Line> = Vec::new();
409    if !fs.weekly_maintenance_start.is_empty() {
410        lines.push(labeled_field(
411            "Weekly maintenance window",
412            format_weekly_maintenance(&fs.weekly_maintenance_start),
413        ));
414    }
415    if fs.automatic_backup_retention_days > 0 {
416        lines.push(labeled_field(
417            "Automatic backup retention",
418            format!("{} days", fs.automatic_backup_retention_days),
419        ));
420    } else {
421        lines.push(labeled_field("Automatic backups", "Disabled"));
422    }
423    if !fs.daily_automatic_backup_start.is_empty() {
424        lines.push(labeled_field(
425            "Daily automatic backup window",
426            format!("{} UTC", fs.daily_automatic_backup_start),
427        ));
428    }
429
430    let block = crate::ui::titled_block(" Settings ");
431    let inner = block.inner(area);
432    frame.render_widget(block, area);
433    render_fields_with_dynamic_columns(frame, inner, lines);
434}
435
436fn render_updates_tab(frame: &mut Frame, app: &App, fs: &FsxFileSystem, area: Rect) {
437    use rusticity_core::fsx::FsxUpdate;
438
439    let rows: Vec<&FsxUpdate> = fs.updates.iter().collect();
440    let columns: Vec<Box<dyn Column<FsxUpdate>>> = vec![
441        Box::new(FieldCol {
442            label: "Update type",
443            width: 26,
444            get: |u: &FsxUpdate| u.update_type.clone(),
445        }),
446        Box::new(FieldCol {
447            label: "Target value",
448            width: 18,
449            get: |u: &FsxUpdate| u.target_value.clone(),
450        }),
451        Box::new(FieldCol {
452            label: "Status",
453            width: 18,
454            get: |u: &FsxUpdate| u.status.clone(),
455        }),
456        Box::new(FieldCol {
457            label: "Progress %",
458            width: 12,
459            get: |u: &FsxUpdate| u.progress_percent.clone(),
460        }),
461        Box::new(FieldCol {
462            label: "Request time",
463            width: 26,
464            get: |u: &FsxUpdate| u.request_time.clone(),
465        }),
466    ];
467
468    let selected = app
469        .fsx_state
470        .updates_table
471        .selected
472        .min(rows.len().saturating_sub(1));
473
474    let config = TableConfig {
475        items: rows,
476        selected_index: selected,
477        expanded_index: app.fsx_state.updates_table.expanded_item,
478        columns: &columns,
479        sort_column: "Request time",
480        sort_direction: SortDirection::Asc,
481        title: format_title(&format!("Updates ({})", fs.updates.len())),
482        area,
483        get_expanded_content: Some(Box::new(|u: &FsxUpdate| expanded_from_columns(&columns, u))),
484        is_active: app.mode != crate::keymap::Mode::FilterInput,
485    };
486    render_table(frame, config);
487}
488
489fn render_backups_tab(frame: &mut Frame, app: &App, area: Rect) {
490    use rusticity_core::fsx::FsxBackup;
491
492    if app.fsx_state.backups_loading {
493        let block = crate::ui::titled_block(" Backups ");
494        let inner = block.inner(area);
495        frame.render_widget(block, area);
496        let p = Paragraph::new("Loading backups...")
497            .style(Style::default().fg(Color::DarkGray))
498            .alignment(Alignment::Center);
499        frame.render_widget(p, inner);
500        return;
501    }
502
503    let rows: Vec<&FsxBackup> = app.fsx_state.backups.items.iter().collect();
504    let columns: Vec<Box<dyn Column<FsxBackup>>> = vec![
505        Box::new(FieldCol {
506            label: "Backup name",
507            width: 22,
508            get: |b: &FsxBackup| b.name.clone(),
509        }),
510        Box::new(FieldCol {
511            label: "Backup ID",
512            width: 24,
513            get: |b: &FsxBackup| b.backup_id.clone(),
514        }),
515        Box::new(FieldCol {
516            label: "File system type",
517            width: 16,
518            get: |b: &FsxBackup| b.file_system_type.clone(),
519        }),
520        Box::new(FieldCol {
521            label: "Lifecycle state",
522            width: 16,
523            get: |b: &FsxBackup| b.lifecycle_state.clone(),
524        }),
525        Box::new(FieldCol {
526            label: "Progress %",
527            width: 12,
528            get: |b: &FsxBackup| b.progress_percent.clone(),
529        }),
530        Box::new(FieldCol {
531            label: "Type",
532            width: 16,
533            get: |b: &FsxBackup| b.backup_type.clone(),
534        }),
535        Box::new(FieldCol {
536            label: "Deployment Type",
537            width: 18,
538            get: |b: &FsxBackup| b.deployment_type.clone(),
539        }),
540        Box::new(FieldCol {
541            label: "Storage class",
542            width: 13,
543            get: |b: &FsxBackup| b.storage_class.clone(),
544        }),
545        Box::new(FieldCol {
546            label: "Storage",
547            width: 14,
548            get: |b: &FsxBackup| b.storage.clone(),
549        }),
550        Box::new(FieldCol {
551            label: "Resource ID",
552            width: 22,
553            get: |b: &FsxBackup| b.resource_id.clone(),
554        }),
555        Box::new(FieldCol {
556            label: "Resource Name",
557            width: 22,
558            get: |b: &FsxBackup| b.resource_name.clone(),
559        }),
560        Box::new(FieldCol {
561            label: "Backup time",
562            width: 26,
563            get: |b: &FsxBackup| b.backup_time.clone(),
564        }),
565        Box::new(FieldCol {
566            label: "Active directory",
567            width: 18,
568            get: |b: &FsxBackup| b.active_directory.clone(),
569        }),
570        Box::new(FieldCol {
571            label: "File system Lustre version",
572            width: 26,
573            get: |b: &FsxBackup| b.lustre_version.clone(),
574        }),
575        Box::new(FieldCol {
576            label: "KMS key ID",
577            width: 30,
578            get: |b: &FsxBackup| b.kms_key_id.clone(),
579        }),
580    ];
581
582    let selected = app
583        .fsx_state
584        .backups
585        .selected
586        .min(rows.len().saturating_sub(1));
587
588    let config = TableConfig {
589        items: rows,
590        selected_index: selected,
591        expanded_index: app.fsx_state.backups.expanded_item,
592        columns: &columns,
593        sort_column: "Backup time",
594        sort_direction: SortDirection::Desc,
595        title: format_title(&format!("Backups ({})", app.fsx_state.backups.items.len())),
596        area,
597        get_expanded_content: Some(Box::new(|b: &FsxBackup| expanded_from_columns(&columns, b))),
598        is_active: app.mode != crate::keymap::Mode::FilterInput,
599    };
600    render_table(frame, config);
601}
602
603fn render_tags_tab(frame: &mut Frame, app: &App, fs: &FsxFileSystem, area: Rect) {
604    struct KeyCol;
605    struct ValueCol;
606
607    #[derive(Clone)]
608    struct TagRow {
609        key: String,
610        value: String,
611    }
612
613    impl Column<TagRow> for KeyCol {
614        fn name(&self) -> &str {
615            "Key"
616        }
617        fn width(&self) -> u16 {
618            30
619        }
620        fn render(&self, item: &TagRow) -> (String, Style) {
621            (item.key.clone(), Style::default())
622        }
623    }
624    impl Column<TagRow> for ValueCol {
625        fn name(&self) -> &str {
626            "Value"
627        }
628        fn width(&self) -> u16 {
629            50
630        }
631        fn render(&self, item: &TagRow) -> (String, Style) {
632            (item.value.clone(), Style::default())
633        }
634    }
635
636    let rows: Vec<TagRow> = fs
637        .tags
638        .iter()
639        .map(|(k, v)| TagRow {
640            key: k.clone(),
641            value: v.clone(),
642        })
643        .collect();
644    let row_refs: Vec<&TagRow> = rows.iter().collect();
645    let columns: Vec<Box<dyn Column<TagRow>>> = vec![Box::new(KeyCol), Box::new(ValueCol)];
646
647    let selected = app
648        .fsx_state
649        .tags_table
650        .selected
651        .min(rows.len().saturating_sub(1));
652
653    let config = TableConfig {
654        items: row_refs,
655        selected_index: selected,
656        expanded_index: app.fsx_state.tags_table.expanded_item,
657        columns: &columns,
658        sort_column: "Key",
659        sort_direction: SortDirection::Asc,
660        title: format_title(&format!("Tags ({})", rows.len())),
661        area,
662        get_expanded_content: Some(Box::new(|row: &TagRow| {
663            expanded_from_columns(&columns, row)
664        })),
665        is_active: app.mode != crate::keymap::Mode::FilterInput,
666    };
667    render_table(frame, config);
668}
669
670pub fn render_file_systems_list(frame: &mut Frame, app: &App, area: Rect) {
671    let chunks = Layout::default()
672        .direction(Direction::Vertical)
673        .constraints([Constraint::Length(3), Constraint::Min(0)])
674        .split(area);
675
676    let filtered = filtered_fsx_file_systems(app);
677    let filtered_count = filtered.len();
678
679    let page_size = app.fsx_state.file_systems.page_size.value();
680    let total_pages = filtered_count.div_ceil(page_size.max(1));
681    let current_page = app
682        .fsx_state
683        .file_systems
684        .selected
685        .checked_div(page_size)
686        .unwrap_or(0);
687    let pagination = render_pagination_text(current_page, total_pages);
688
689    render_simple_filter(
690        frame,
691        chunks[0],
692        SimpleFilterConfig {
693            filter_text: &app.fsx_state.file_systems.filter,
694            placeholder: "Search by name, file system ID, or type",
695            pagination: &pagination,
696            mode: app.mode,
697            is_input_focused: app.fsx_state.input_focus == InputFocus::Filter,
698            is_pagination_focused: app.fsx_state.input_focus == InputFocus::Pagination,
699        },
700    );
701
702    let start_idx = current_page * page_size;
703    let end_idx = (start_idx + page_size).min(filtered_count);
704    let paginated: Vec<_> = if start_idx < filtered_count {
705        filtered[start_idx..end_idx].to_vec()
706    } else {
707        vec![]
708    };
709
710    let title = format_title(&format!("File Systems ({})", filtered_count));
711
712    let columns: Vec<Box<dyn Column<FsxFileSystem>>> = app
713        .fsx_visible_column_ids
714        .iter()
715        .filter_map(|col_id| {
716            fs::Column::from_id(col_id).map(|col| Box::new(col) as Box<dyn Column<FsxFileSystem>>)
717        })
718        .collect();
719
720    let expanded_index = app.fsx_state.file_systems.expanded_item.and_then(|idx| {
721        if idx >= start_idx && idx < end_idx {
722            Some(idx - start_idx)
723        } else {
724            None
725        }
726    });
727
728    let selected_index = app
729        .fsx_state
730        .file_systems
731        .selected
732        .saturating_sub(start_idx);
733
734    let config = TableConfig {
735        items: paginated,
736        selected_index,
737        expanded_index,
738        columns: &columns,
739        sort_column: "Creation time",
740        sort_direction: SortDirection::Desc,
741        title,
742        area: chunks[1],
743        get_expanded_content: Some(Box::new(|fs: &FsxFileSystem| {
744            expanded_from_columns(&columns, fs)
745        })),
746        is_active: app.mode != crate::keymap::Mode::FilterInput,
747    };
748
749    render_table(frame, config);
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755    use crate::app::App;
756    use rusticity_core::fsx::FsxFileSystem;
757
758    fn make_fs(id: &str, name: &str, fstype: &str) -> FsxFileSystem {
759        FsxFileSystem {
760            name: name.to_string(),
761            file_system_id: id.to_string(),
762            file_system_type: fstype.to_string(),
763            status: "AVAILABLE".to_string(),
764            ..Default::default()
765        }
766    }
767
768    #[test]
769    fn test_state_default() {
770        let state = State::new();
771        assert_eq!(state.input_focus, InputFocus::Filter);
772        assert!(state.file_systems.items.is_empty());
773        assert_eq!(state.current_file_system, None);
774        assert_eq!(state.detail_tab, DetailTab::NetworkSecurity);
775    }
776
777    #[test]
778    fn test_filtered_by_name_id_type() {
779        let mut app = App::new_without_client("default".to_string(), None);
780        app.fsx_state.file_systems.items = vec![
781            make_fs("fs-001", "prod-lustre", "LUSTRE"),
782            make_fs("fs-002", "dev-windows", "WINDOWS"),
783        ];
784
785        app.fsx_state.file_systems.filter = "prod".to_string();
786        assert_eq!(filtered_fsx_file_systems(&app).len(), 1);
787
788        app.fsx_state.file_systems.filter = "fs-002".to_string();
789        assert_eq!(filtered_fsx_file_systems(&app).len(), 1);
790
791        app.fsx_state.file_systems.filter = "windows".to_string();
792        assert_eq!(filtered_fsx_file_systems(&app).len(), 1);
793
794        app.fsx_state.file_systems.filter = String::new();
795        assert_eq!(filtered_fsx_file_systems(&app).len(), 2);
796    }
797
798    #[test]
799    fn test_format_deployment_type() {
800        assert_eq!(format_deployment_type("Persistent2"), "Persistent 2");
801        assert_eq!(format_deployment_type("Persistent1"), "Persistent 1");
802        assert_eq!(format_deployment_type("Scratch2"), "Scratch 2");
803        // No trailing digit — unchanged.
804        assert_eq!(format_deployment_type("MultiAz"), "MultiAz");
805    }
806
807    #[test]
808    fn test_format_storage_class_and_compression() {
809        assert_eq!(format_storage_class("Ssd"), "SSD");
810        assert_eq!(format_storage_class("Hdd"), "HDD");
811        assert_eq!(format_compression("Lz4"), "LZ4");
812        assert_eq!(format_compression("None"), "NONE");
813    }
814
815    #[test]
816    fn test_format_capacity_tb() {
817        assert_eq!(format_capacity_tb(309600), "309.6 TB");
818        assert_eq!(format_capacity_tb(1200), "1.2 TB");
819        assert_eq!(format_capacity_tb(0), "-");
820    }
821
822    #[test]
823    fn test_detail_tab_cycles_through_seven() {
824        let mut tab = DetailTab::NetworkSecurity;
825        let mut seen = vec![tab];
826        for _ in 0..6 {
827            tab = tab.next();
828            seen.push(tab);
829        }
830        assert_eq!(seen.len(), 7);
831        assert_eq!(tab.next(), DetailTab::NetworkSecurity); // wraps around
832        assert_eq!(DetailTab::ALL.len(), 7);
833    }
834
835    #[test]
836    fn test_summary_includes_lustre_fields_when_present() {
837        let mut fs = make_fs("fs-abc", "kirin", "Lustre");
838        fs.deployment_type = "Persistent2".to_string();
839        fs.data_compression_type = "Lz4".to_string();
840        fs.storage_class = "Ssd".to_string();
841        fs.storage_capacity_gib = 309600;
842        fs.per_unit_throughput = 1000;
843        fs.total_throughput_mbps = 309600;
844        fs.efa = "Disabled".to_string();
845        fs.root_squash = "Disabled".to_string();
846        fs.lustre_version = "2.12".to_string();
847        fs.mount_name = "v4kavb4v".to_string();
848
849        let lines = build_summary_lines(&fs);
850        let text: String = lines
851            .iter()
852            .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
853            .collect::<Vec<_>>()
854            .join("");
855        assert!(text.contains("Persistent 2"));
856        assert!(text.contains("LZ4"));
857        assert!(text.contains("SSD"));
858        assert!(text.contains("309.6 TB"));
859        assert!(text.contains("1000 MB/s/TiB"));
860        assert!(text.contains("309600 MB/s"));
861        assert!(text.contains("2.12"));
862        assert!(text.contains("v4kavb4v"));
863    }
864
865    #[test]
866    fn test_column_count_uses_tags_columns_on_tags_tab() {
867        // Regression: the Tags detail tab must NOT expose the File systems
868        // columns in the preferences selector — only the 2 fixed Key/Value cols.
869        let mut app = App::new_without_client("default".to_string(), None);
870        app.fsx_state.file_systems.items = vec![make_fs("fs-1", "n", "LUSTRE")];
871
872        // List view → all 13 file-system columns.
873        assert_eq!(crate::fsx::actions::column_count(&app), 13);
874
875        // Detail + Tags tab → 2 columns (Key/Value).
876        app.fsx_state.current_file_system = Some("fs-1".to_string());
877        app.fsx_state.detail_tab = DetailTab::Tags;
878        assert_eq!(crate::fsx::actions::column_count(&app), 2);
879        assert_eq!(crate::fsx::actions::column_selector_max(&app), 2 + 6);
880
881        // A non-Tags detail tab falls back to the list columns.
882        app.fsx_state.detail_tab = DetailTab::NetworkSecurity;
883        assert_eq!(crate::fsx::actions::column_count(&app), 13);
884    }
885
886    #[test]
887    fn test_enabled_disabled_color() {
888        assert_eq!(enabled_disabled_color("Enabled"), Color::Green);
889        assert_eq!(enabled_disabled_color("Disabled"), Color::Yellow);
890        assert_eq!(enabled_disabled_color("other"), Color::White);
891    }
892
893    #[test]
894    fn test_summary_omits_lustre_fields_for_windows() {
895        let mut fs = make_fs("fs-win", "winfs", "Windows");
896        fs.deployment_type = "MultiAz".to_string();
897        fs.storage_class = "Ssd".to_string();
898        fs.storage_capacity_gib = 1024;
899        let lines = build_summary_lines(&fs);
900        let text: String = lines
901            .iter()
902            .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
903            .collect::<Vec<_>>()
904            .join("");
905        assert!(!text.contains("Lustre version"));
906        assert!(!text.contains("Mount name"));
907        assert!(!text.contains("MB/s/TiB"));
908    }
909
910    #[test]
911    fn test_format_weekly_maintenance() {
912        assert_eq!(format_weekly_maintenance("4:07:00"), "Thursday 07:00 UTC");
913        assert_eq!(format_weekly_maintenance("1:00:00"), "Monday 00:00 UTC");
914        assert_eq!(format_weekly_maintenance("7:23:30"), "Sunday 23:30 UTC");
915        // Malformed input is passed through unchanged.
916        assert_eq!(format_weekly_maintenance("weird"), "weird");
917    }
918
919    #[test]
920    fn test_detail_fixed_columns() {
921        assert_eq!(
922            detail_fixed_columns(DetailTab::Tags),
923            Some(&TAGS_COLUMNS[..])
924        );
925        assert_eq!(
926            detail_fixed_columns(DetailTab::Updates),
927            Some(&UPDATES_COLUMNS[..])
928        );
929        assert_eq!(
930            detail_fixed_columns(DetailTab::Backups),
931            Some(&BACKUPS_COLUMNS[..])
932        );
933        assert_eq!(detail_fixed_columns(DetailTab::NetworkSecurity), None);
934        assert_eq!(detail_fixed_columns(DetailTab::Administration), None);
935        // Column counts match the requested tables.
936        assert_eq!(UPDATES_COLUMNS.len(), 5);
937        assert_eq!(BACKUPS_COLUMNS.len(), 15);
938    }
939
940    #[test]
941    fn test_column_count_per_detail_tab() {
942        let mut app = App::new_without_client("default".to_string(), None);
943        app.fsx_state.file_systems.items = vec![make_fs("fs-1", "n", "LUSTRE")];
944        // List view → 13 file-system columns.
945        assert_eq!(crate::fsx::actions::column_count(&app), 13);
946
947        app.fsx_state.current_file_system = Some("fs-1".to_string());
948        app.fsx_state.detail_tab = DetailTab::Tags;
949        assert_eq!(crate::fsx::actions::column_count(&app), 2);
950        app.fsx_state.detail_tab = DetailTab::Updates;
951        assert_eq!(crate::fsx::actions::column_count(&app), 5);
952        app.fsx_state.detail_tab = DetailTab::Backups;
953        assert_eq!(crate::fsx::actions::column_count(&app), 15);
954        // Non-table detail tabs fall back to the list columns.
955        app.fsx_state.detail_tab = DetailTab::NetworkSecurity;
956        assert_eq!(crate::fsx::actions::column_count(&app), 13);
957    }
958}