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
15pub const TAGS_COLUMNS: [&str; 2] = ["Key", "Value"];
17pub const UPDATES_COLUMNS: [&str; 5] = [
19 "Update type",
20 "Target value",
21 "Status",
22 "Progress %",
23 "Request time",
24];
25pub 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
44pub 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#[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
147pub 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
160pub fn format_storage_class(raw: &str) -> String {
162 raw.to_uppercase()
163}
164
165pub fn format_compression(raw: &str) -> String {
167 raw.to_uppercase()
168}
169
170pub 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
178pub 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
197pub 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 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
346struct 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 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 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); 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 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 assert_eq!(crate::fsx::actions::column_count(&app), 13);
874
875 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 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 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 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 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 app.fsx_state.detail_tab = DetailTab::NetworkSecurity;
956 assert_eq!(crate::fsx::actions::column_count(&app), 13);
957 }
958}