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