1use crate::app::App;
2use crate::common::{
3 format_bytes, format_iso_timestamp, render_pagination_text, render_partial_pagination,
4 CyclicEnum, InputFocus, SortDirection, UTC_TIMESTAMP_WIDTH,
5};
6use crate::dynamodb::items::{self, ItemRequestContext};
7use crate::dynamodb::table;
8use crate::table::{TableState, TableWindow};
9use crate::tags::{self, TagSort, TagsConfig};
10use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
11use crate::ui::format_title;
12use crate::ui::render_tabs;
13use crate::ui::table::{expanded_from_columns, render_table, Column, TableConfig};
14use crate::ui::Summary;
15use ratatui::prelude::*;
16use ratatui::widgets::Paragraph;
17use rusticity_core::dynamodb::{
18 DdbBackup, DdbIndex, DdbItem, DdbItemCursor, DdbMonitoringData, DdbReplica, DdbTable,
19 DdbTableSettings,
20};
21
22pub const FILTER_CONTROLS: [InputFocus; 2] = [InputFocus::Filter, InputFocus::Pagination];
23
24pub const ITEM_OPERATION: InputFocus = InputFocus::Dropdown("DdbItemOperation");
26pub const ITEM_INDEX: InputFocus = InputFocus::Dropdown("DdbItemIndex");
27pub const ITEM_PROJECTION: InputFocus = InputFocus::Dropdown("DdbItemProjection");
28
29pub const ITEMS_FILTER_CONTROLS: [InputFocus; 5] = [
32 ITEM_OPERATION,
33 ITEM_INDEX,
34 ITEM_PROJECTION,
35 InputFocus::Filter,
36 InputFocus::Pagination,
37];
38
39pub const ITEM_LIMIT: i32 = 100;
41
42#[derive(Debug, Clone, Copy, PartialEq, Default)]
44pub enum ItemOperation {
45 #[default]
46 Scan,
47 Query,
48}
49
50impl CyclicEnum for ItemOperation {
51 const ALL: &'static [Self] = &[Self::Scan, Self::Query];
52}
53
54impl ItemOperation {
55 pub fn name(&self) -> &'static str {
56 match self {
57 ItemOperation::Scan => "Scan",
58 ItemOperation::Query => "Query",
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Default)]
65pub enum ItemProjection {
66 #[default]
67 AllAttributes,
68 KeysOnly,
69}
70
71impl CyclicEnum for ItemProjection {
72 const ALL: &'static [Self] = &[Self::AllAttributes, Self::KeysOnly];
73}
74
75impl ItemProjection {
76 pub fn name(&self) -> &'static str {
77 match self {
78 ItemProjection::AllAttributes => "All attributes",
79 ItemProjection::KeysOnly => "Keys only",
80 }
81 }
82}
83
84pub const INDEXES_COLUMNS: [&str; 10] = [
86 "Name",
87 "Type",
88 "Status",
89 "Partition key",
90 "Sort key",
91 "Attributes",
92 "Read capacity mode",
93 "Write capacity mode",
94 "Size",
95 "Item count",
96];
97pub const REPLICAS_COLUMNS: [&str; 5] = ["Region", "Status", "Table class", "KMS key", "Indexes"];
99pub const BACKUPS_COLUMNS: [&str; 6] = [
101 "Backup name",
102 "Status",
103 "Type",
104 "Size",
105 "Creation time",
106 "Expiry time",
107];
108pub const TAGS_COLUMNS: [&str; 2] = ["Key", "Value"];
110
111pub fn detail_fixed_columns(detail_tab: DetailTab) -> Option<&'static [&'static str]> {
113 match detail_tab {
114 DetailTab::Indexes => Some(&INDEXES_COLUMNS),
115 DetailTab::GlobalTables => Some(&REPLICAS_COLUMNS),
116 DetailTab::Backups => Some(&BACKUPS_COLUMNS),
117 DetailTab::Tags => Some(&TAGS_COLUMNS),
118 _ => None,
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq)]
124pub enum DetailTab {
125 Items,
126 Settings,
127 Indexes,
128 Monitor,
129 GlobalTables,
130 Backups,
131 ExportsStreams,
132 AdditionalSettings,
133 Tags,
134}
135
136impl CyclicEnum for DetailTab {
137 const ALL: &'static [Self] = &[
138 Self::Items,
139 Self::Settings,
140 Self::Indexes,
141 Self::Monitor,
142 Self::GlobalTables,
143 Self::Backups,
144 Self::ExportsStreams,
145 Self::AdditionalSettings,
146 Self::Tags,
147 ];
148}
149
150impl DetailTab {
151 pub fn name(&self) -> &'static str {
152 match self {
153 DetailTab::Items => "Items",
154 DetailTab::Settings => "Settings",
155 DetailTab::Indexes => "Indexes",
156 DetailTab::Monitor => "Monitor",
157 DetailTab::GlobalTables => "Global tables",
158 DetailTab::Backups => "Backups",
159 DetailTab::ExportsStreams => "Exports and streams",
160 DetailTab::AdditionalSettings => "Additional settings",
161 DetailTab::Tags => "Tags",
162 }
163 }
164
165 pub fn console_tab(&self) -> &'static str {
167 match self {
168 DetailTab::Items => "item-explorer",
169 DetailTab::Settings => "overview",
170 DetailTab::Indexes => "indexes",
171 DetailTab::Monitor => "monitoring",
172 DetailTab::GlobalTables => "globaltables",
173 DetailTab::Backups => "backups",
174 DetailTab::ExportsStreams => "exportsandstreams",
175 DetailTab::AdditionalSettings => "additionalsettings",
176 DetailTab::Tags => "tags",
177 }
178 }
179}
180
181pub struct State {
182 pub tables: TableState<DdbTable>,
183 pub input_focus: InputFocus,
184 pub current_table: Option<String>,
185 pub detail_tab: DetailTab,
186 pub items: TableState<DdbItem>,
187 pub items_loading: bool,
188 pub items_cursor: Option<DdbItemCursor>,
189 pub items_context: Option<ItemRequestContext>,
190 pub items_append: bool,
192 pub items_failed: bool,
193 pub item_filter: String,
196 pub item_operation: ItemOperation,
197 pub item_index: usize,
199 pub item_projection: ItemProjection,
200 pub item_hidden_columns: Vec<String>,
203 pub executed_query: String,
205 pub indexes_table: TableState<DdbIndex>,
206 pub replicas_table: TableState<DdbReplica>,
207 pub backups: TableState<DdbBackup>,
208 pub backups_loading: bool,
209 pub settings: DdbTableSettings,
210 pub settings_loading: bool,
211 pub tags_table: TableState<(String, String)>,
212 pub tags_loading: bool,
213 pub table_policy: Option<String>,
215 pub stream_policy: Option<String>,
216 pub permissions_loading: bool,
217 pub metrics: DdbMonitoringData,
219 pub metrics_loading: bool,
220 pub monitoring_scroll: usize,
221}
222
223pub const MONITORING_CHARTS: usize = 4;
225
226impl crate::ui::monitoring::MonitoringState for State {
227 fn is_metrics_loading(&self) -> bool {
228 self.metrics_loading
229 }
230
231 fn set_metrics_loading(&mut self, loading: bool) {
232 self.metrics_loading = loading;
233 }
234
235 fn monitoring_scroll(&self) -> usize {
236 self.monitoring_scroll
237 }
238
239 fn set_monitoring_scroll(&mut self, scroll: usize) {
240 self.monitoring_scroll = scroll;
241 }
242
243 fn clear_metrics(&mut self) {
244 self.metrics = DdbMonitoringData::default();
245 }
246}
247
248impl Default for State {
249 fn default() -> Self {
250 Self::new()
251 }
252}
253
254impl State {
255 pub fn new() -> Self {
256 Self {
257 tables: TableState::new(),
258 input_focus: InputFocus::Filter,
259 current_table: None,
260 detail_tab: DetailTab::Items,
261 items: TableState::new(),
262 items_loading: false,
263 items_cursor: None,
264 items_context: None,
265 items_append: false,
266 items_failed: false,
267 item_filter: String::new(),
268 item_operation: ItemOperation::default(),
269 item_index: 0,
270 item_projection: ItemProjection::default(),
271 item_hidden_columns: Vec::new(),
272 executed_query: String::new(),
273 indexes_table: TableState::new(),
274 replicas_table: TableState::new(),
275 backups: TableState::new(),
276 backups_loading: false,
277 settings: DdbTableSettings::default(),
278 settings_loading: false,
279 tags_table: TableState::new(),
280 tags_loading: false,
281 table_policy: None,
282 stream_policy: None,
283 permissions_loading: false,
284 metrics: DdbMonitoringData::default(),
285 metrics_loading: false,
286 monitoring_scroll: 0,
287 }
288 }
289}
290
291pub fn filtered_dynamodb_tables(app: &App) -> Vec<&DdbTable> {
292 let filter = app.dynamodb_state.tables.filter.to_lowercase();
293 app.dynamodb_state
294 .tables
295 .items
296 .iter()
297 .filter(|t| {
298 filter.is_empty()
299 || t.name.to_lowercase().contains(&filter)
300 || t.status.to_lowercase().contains(&filter)
301 || t.partition_key.to_lowercase().contains(&filter)
302 })
303 .collect()
304}
305
306pub fn current_table(app: &App) -> Option<&DdbTable> {
308 app.dynamodb_state
309 .current_table
310 .as_deref()
311 .and_then(|name| {
312 app.dynamodb_state
313 .tables
314 .items
315 .iter()
316 .find(|t| t.name == name)
317 })
318}
319
320pub fn needs_items_load(app: &App) -> bool {
323 items::needs_load(app)
324}
325
326pub fn render_tables(frame: &mut Frame, app: &App, area: Rect) {
327 if app.dynamodb_state.current_table.is_some() {
328 render_table_detail(frame, app, area);
329 } else {
330 render_tables_list(frame, app, area);
331 }
332}
333
334pub fn enabled_disabled_color(value: &str) -> Color {
338 match value.to_uppercase().as_str() {
339 "ENABLED" => Color::Green,
340 "DISABLED" => Color::Yellow,
341 _ => Color::White,
342 }
343}
344
345pub fn format_capacity(mode: &str, units: i64, unit_label: &str) -> String {
347 if mode == "Provisioned" && units > 0 {
348 format!("{} ({} {})", mode, units, unit_label)
349 } else {
350 mode.to_string()
351 }
352}
353
354fn or_dash(value: &str) -> String {
356 if value.is_empty() {
357 "-".to_string()
358 } else {
359 value.to_string()
360 }
361}
362
363fn build_summary_lines(t: &DdbTable) -> Vec<Line<'static>> {
364 use crate::ui::{labeled_field, labeled_field_colored};
365
366 let (status_text, status_color) = table::format_status(&t.status);
367
368 let mut lines: Vec<Line> = vec![
369 labeled_field("Table name", t.name.clone()),
370 labeled_field_colored("Status", status_text.to_string(), status_color),
371 labeled_field("Partition key", or_dash(&t.partition_key)),
372 labeled_field("Sort key", or_dash(&t.sort_key)),
373 labeled_field(
374 "Read capacity mode",
375 format_capacity(&t.read_capacity_mode, t.provisioned_read_units, "RCU"),
376 ),
377 labeled_field(
378 "Write capacity mode",
379 format_capacity(&t.write_capacity_mode, t.provisioned_write_units, "WCU"),
380 ),
381 labeled_field("Table class", t.table_class.clone()),
382 labeled_field("Total size", format_bytes(t.total_size_bytes)),
383 labeled_field("Item count", t.item_count.to_string()),
384 labeled_field("Indexes", t.index_count().to_string()),
385 labeled_field_colored(
386 "Deletion protection",
387 t.deletion_protection.clone(),
388 enabled_disabled_color(&t.deletion_protection),
389 ),
390 ];
391 if !t.creation_time.is_empty() {
392 lines.push(labeled_field(
393 "Creation time",
394 format_iso_timestamp(&t.creation_time),
395 ));
396 }
397 lines
398}
399
400pub fn render_table_detail(frame: &mut Frame, app: &App, area: Rect) {
401 let tabs: Vec<(&str, DetailTab)> = DetailTab::ALL.iter().map(|t| (t.name(), *t)).collect();
402
403 let Some(t) = current_table(app) else {
404 let chunks = Layout::default()
405 .direction(Direction::Vertical)
406 .constraints([Constraint::Min(0), Constraint::Length(1)])
407 .split(area);
408 render_tabs(frame, chunks[1], &tabs, &app.dynamodb_state.detail_tab);
409 return;
410 };
411
412 if app.dynamodb_state.detail_tab == DetailTab::Items {
414 let chunks = Layout::default()
415 .direction(Direction::Vertical)
416 .constraints([Constraint::Length(1), Constraint::Min(0)])
417 .split(area);
418 render_tabs(frame, chunks[0], &tabs, &app.dynamodb_state.detail_tab);
419 render_items_tab(frame, app, t, chunks[1]);
420 return;
421 }
422
423 let summary = Summary::new("Summary", build_summary_lines(t));
424 let summary_height = summary
425 .height(area.width)
426 .min(area.height.saturating_sub(2));
427
428 let chunks = Layout::default()
429 .direction(Direction::Vertical)
430 .constraints([
431 Constraint::Length(summary_height),
432 Constraint::Length(1),
433 Constraint::Min(0),
434 ])
435 .split(area);
436
437 summary.render(frame, chunks[0]);
438
439 render_tabs(frame, chunks[1], &tabs, &app.dynamodb_state.detail_tab);
440
441 match app.dynamodb_state.detail_tab {
442 DetailTab::Items => {}
444 DetailTab::Settings => render_settings(frame, app, t, chunks[2]),
445 DetailTab::Indexes => render_indexes_tab(frame, app, t, chunks[2]),
446 DetailTab::Monitor => render_monitor_tab(frame, app, t, chunks[2]),
447 DetailTab::GlobalTables => render_global_tables_tab(frame, app, t, chunks[2]),
448 DetailTab::Backups => render_backups_tab(frame, app, chunks[2]),
449 DetailTab::ExportsStreams => render_exports_streams(frame, t, chunks[2]),
450 DetailTab::AdditionalSettings => render_additional_settings(frame, app, chunks[2]),
451 DetailTab::Tags => render_tags_tab(frame, app, chunks[2]),
452 }
453}
454
455struct FieldCol<T: 'static> {
457 label: &'static str,
458 width: u16,
459 get: fn(&T) -> String,
460}
461
462impl<T> Column<T> for FieldCol<T> {
463 fn name(&self) -> &str {
464 self.label
465 }
466 fn width(&self) -> u16 {
467 self.width
468 }
469 fn render(&self, item: &T) -> (String, Style) {
470 ((self.get)(item), Style::default())
471 }
472}
473
474fn render_settings(frame: &mut Frame, app: &App, t: &DdbTable, area: Rect) {
475 use crate::ui::{labeled_field, labeled_field_colored, render_fields_with_dynamic_columns};
476
477 let s = &app.dynamodb_state.settings;
478 let ttl_status = if app.dynamodb_state.settings_loading {
479 "Loading...".to_string()
480 } else {
481 or_dash(&s.ttl_status)
482 };
483
484 let general: Vec<Line> = vec![
485 labeled_field("ARN", or_dash(&t.table_arn)),
486 labeled_field("Table ID", or_dash(&t.table_id)),
487 labeled_field("Encryption type", or_dash(&t.encryption_type)),
488 labeled_field("KMS key ARN", or_dash(&t.kms_key_arn)),
489 labeled_field_colored(
490 "Time to Live (TTL)",
491 ttl_status.clone(),
492 enabled_disabled_color(&ttl_status),
493 ),
494 labeled_field("TTL attribute", or_dash(&s.ttl_attribute)),
495 labeled_field_colored(
496 "Deletion protection",
497 t.deletion_protection.clone(),
498 enabled_disabled_color(&t.deletion_protection),
499 ),
500 ];
501 let capacity: Vec<Line> = vec![
502 labeled_field(
503 "Read capacity",
504 format_capacity(&t.read_capacity_mode, t.provisioned_read_units, "RCU"),
505 ),
506 labeled_field(
507 "Write capacity",
508 format_capacity(&t.write_capacity_mode, t.provisioned_write_units, "WCU"),
509 ),
510 labeled_field("Table class", t.table_class.clone()),
511 labeled_field("Replicas", t.replicas.len().to_string()),
512 ];
513
514 let summary = Summary::new("General information", general);
515 let h1 = summary.height(area.width).min(area.height);
516 let chunks = Layout::default()
517 .direction(Direction::Vertical)
518 .constraints([Constraint::Length(h1), Constraint::Min(0)])
519 .split(area);
520
521 summary.render(frame, chunks[0]);
522
523 let block2 = crate::ui::titled_block(" Capacity ");
524 let inner2 = block2.inner(chunks[1]);
525 frame.render_widget(block2, chunks[1]);
526 render_fields_with_dynamic_columns(frame, inner2, capacity);
527}
528
529fn render_exports_streams(frame: &mut Frame, t: &DdbTable, area: Rect) {
530 use crate::ui::{labeled_field, labeled_field_colored, render_fields_with_dynamic_columns};
531
532 let lines: Vec<Line> = vec![
533 labeled_field_colored(
534 "DynamoDB stream",
535 t.stream_enabled.clone(),
536 enabled_disabled_color(&t.stream_enabled),
537 ),
538 labeled_field("View type", or_dash(&t.stream_view_type)),
539 labeled_field("Latest stream ARN", or_dash(&t.latest_stream_arn)),
540 ];
541
542 let block = crate::ui::titled_block(" DynamoDB stream details ");
543 let inner = block.inner(area);
544 frame.render_widget(block, area);
545 render_fields_with_dynamic_columns(frame, inner, lines);
546}
547
548fn render_additional_settings(frame: &mut Frame, app: &App, area: Rect) {
549 let chunks = Layout::default()
550 .direction(Direction::Vertical)
551 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
552 .split(area);
553
554 render_policy_pane(
555 frame,
556 chunks[0],
557 " Resource-based policy for table ",
558 app.dynamodb_state.table_policy.as_deref(),
559 "No table resource-based policy to display",
560 app.dynamodb_state.permissions_loading,
561 );
562 render_policy_pane(
563 frame,
564 chunks[1],
565 " Resource-based policy for active stream ",
566 app.dynamodb_state.stream_policy.as_deref(),
567 "No stream resource-based policy to display",
568 app.dynamodb_state.permissions_loading,
569 );
570}
571
572fn render_policy_pane(
574 frame: &mut Frame,
575 area: Rect,
576 title: &str,
577 policy: Option<&str>,
578 empty_message: &str,
579 loading: bool,
580) {
581 let block = crate::ui::titled_block(title.to_string());
582 let inner = block.inner(area);
583 frame.render_widget(block, area);
584
585 if loading {
586 let p = Paragraph::new("Loading policy...")
587 .style(Style::default().fg(Color::DarkGray))
588 .alignment(Alignment::Center);
589 frame.render_widget(p, inner);
590 return;
591 }
592
593 match policy {
594 Some(json) if !json.is_empty() => {
595 frame.render_widget(Paragraph::new(json.to_string()), inner);
596 }
597 _ => {
598 let p = Paragraph::new(empty_message.to_string())
599 .style(Style::default().fg(Color::DarkGray))
600 .alignment(Alignment::Center);
601 frame.render_widget(p, inner);
602 }
603 }
604}
605
606fn render_indexes_tab(frame: &mut Frame, app: &App, t: &DdbTable, area: Rect) {
607 let rows: Vec<&DdbIndex> = t
608 .global_secondary_indexes
609 .iter()
610 .chain(t.local_secondary_indexes.iter())
611 .collect();
612
613 let columns: Vec<Box<dyn Column<DdbIndex>>> = vec![
614 Box::new(FieldCol {
615 label: "Name",
616 width: 26,
617 get: |i: &DdbIndex| i.name.clone(),
618 }),
619 Box::new(FieldCol {
620 label: "Type",
621 width: 8,
622 get: |i: &DdbIndex| i.index_type.clone(),
623 }),
624 Box::new(FieldCol {
625 label: "Status",
626 width: 12,
627 get: |i: &DdbIndex| i.status.clone(),
628 }),
629 Box::new(FieldCol {
630 label: "Partition key",
631 width: 20,
632 get: |i: &DdbIndex| or_dash(&i.partition_key),
633 }),
634 Box::new(FieldCol {
635 label: "Sort key",
636 width: 20,
637 get: |i: &DdbIndex| or_dash(&i.sort_key),
638 }),
639 Box::new(FieldCol {
640 label: "Attributes",
641 width: 20,
642 get: |i: &DdbIndex| i.projection.clone(),
643 }),
644 Box::new(FieldCol {
645 label: "Read capacity mode",
646 width: 18,
647 get: |i: &DdbIndex| i.read_capacity_mode.clone(),
648 }),
649 Box::new(FieldCol {
650 label: "Write capacity mode",
651 width: 19,
652 get: |i: &DdbIndex| i.write_capacity_mode.clone(),
653 }),
654 Box::new(FieldCol {
655 label: "Size",
656 width: 12,
657 get: |i: &DdbIndex| format_bytes(i.size_bytes),
658 }),
659 Box::new(FieldCol {
660 label: "Item count",
661 width: 11,
662 get: |i: &DdbIndex| i.item_count.to_string(),
663 }),
664 ];
665
666 let selected = app
667 .dynamodb_state
668 .indexes_table
669 .selected
670 .min(rows.len().saturating_sub(1));
671
672 let config = TableConfig {
673 items: rows.clone(),
674 selected_index: selected,
675 expanded_index: app.dynamodb_state.indexes_table.expanded_item,
676 columns: &columns,
677 sort_column: "Name",
678 sort_direction: SortDirection::Asc,
679 title: format_title(&format!("Indexes ({})", rows.len())),
680 area,
681 get_expanded_content: Some(Box::new(|i: &DdbIndex| expanded_from_columns(&columns, i))),
682 is_active: app.mode != crate::keymap::Mode::FilterInput,
683 };
684 render_table(frame, config);
685}
686
687fn render_global_tables_tab(frame: &mut Frame, app: &App, t: &DdbTable, area: Rect) {
688 use crate::ui::labeled_field_colored;
689
690 let status = if t.replicas.is_empty() {
693 "Disabled"
694 } else {
695 "Enabled"
696 };
697 let repl_lines: Vec<Line> = vec![labeled_field_colored(
698 "Settings replication status",
699 status.to_string(),
700 enabled_disabled_color(status),
701 )];
702 let summary = Summary::new("Settings replication", repl_lines);
703 let repl_height = summary.height(area.width).min(area.height);
704 let chunks = Layout::default()
705 .direction(Direction::Vertical)
706 .constraints([Constraint::Length(repl_height), Constraint::Min(0)])
707 .split(area);
708
709 summary.render(frame, chunks[0]);
710
711 let area = chunks[1];
712 let rows: Vec<&DdbReplica> = t.replicas.iter().collect();
713
714 let columns: Vec<Box<dyn Column<DdbReplica>>> = vec![
715 Box::new(FieldCol {
716 label: "Region",
717 width: 16,
718 get: |r: &DdbReplica| r.region.clone(),
719 }),
720 Box::new(FieldCol {
721 label: "Status",
722 width: 14,
723 get: |r: &DdbReplica| r.status.clone(),
724 }),
725 Box::new(FieldCol {
726 label: "Table class",
727 width: 14,
728 get: |r: &DdbReplica| or_dash(&r.table_class),
729 }),
730 Box::new(FieldCol {
731 label: "KMS key",
732 width: 30,
733 get: |r: &DdbReplica| or_dash(&r.kms_key_id),
734 }),
735 Box::new(FieldCol {
736 label: "Indexes",
737 width: 8,
738 get: |r: &DdbReplica| r.index_count.to_string(),
739 }),
740 ];
741
742 let selected = app
743 .dynamodb_state
744 .replicas_table
745 .selected
746 .min(rows.len().saturating_sub(1));
747
748 let config = TableConfig {
749 items: rows.clone(),
750 selected_index: selected,
751 expanded_index: app.dynamodb_state.replicas_table.expanded_item,
752 columns: &columns,
753 sort_column: "Region",
754 sort_direction: SortDirection::Asc,
755 title: format_title(&format!("Replicas ({})", rows.len())),
756 area,
757 get_expanded_content: Some(Box::new(|r: &DdbReplica| {
758 expanded_from_columns(&columns, r)
759 })),
760 is_active: app.mode != crate::keymap::Mode::FilterInput,
761 };
762 render_table(frame, config);
763}
764
765fn render_backups_tab(frame: &mut Frame, app: &App, area: Rect) {
766 use crate::ui::{labeled_field, labeled_field_colored};
767
768 let s = &app.dynamodb_state.settings;
770 let pitr_status = if app.dynamodb_state.settings_loading {
771 "Loading...".to_string()
772 } else if s.pitr_status.is_empty() {
773 "Off".to_string()
774 } else if s.pitr_status == "Enabled" {
775 "On".to_string()
776 } else {
777 "Off".to_string()
778 };
779 let mut pitr_lines: Vec<Line> = vec![labeled_field_colored(
780 "Status",
781 pitr_status.clone(),
782 if pitr_status == "On" {
783 Color::Green
784 } else {
785 Color::Yellow
786 },
787 )];
788 if s.recovery_period_days > 0 {
789 pitr_lines.push(labeled_field(
790 "Backup recovery period",
791 format!("{} days", s.recovery_period_days),
792 ));
793 }
794 if !s.earliest_restorable_time.is_empty() {
795 pitr_lines.push(labeled_field(
796 "Earliest restore point",
797 format_iso_timestamp(&s.earliest_restorable_time),
798 ));
799 }
800 if !s.latest_restorable_time.is_empty() {
801 pitr_lines.push(labeled_field(
802 "Latest restore point",
803 format_iso_timestamp(&s.latest_restorable_time),
804 ));
805 }
806
807 let summary = Summary::new("Point-in-time recovery (PITR)", pitr_lines);
808 let pitr_height = summary.height(area.width).min(area.height);
809 let chunks = Layout::default()
810 .direction(Direction::Vertical)
811 .constraints([Constraint::Length(pitr_height), Constraint::Min(0)])
812 .split(area);
813
814 summary.render(frame, chunks[0]);
815
816 let area = chunks[1];
817
818 if app.dynamodb_state.backups_loading {
819 let block = crate::ui::titled_block(" Backups ");
820 let inner = block.inner(area);
821 frame.render_widget(block, area);
822 let p = Paragraph::new("Loading backups...")
823 .style(Style::default().fg(Color::DarkGray))
824 .alignment(Alignment::Center);
825 frame.render_widget(p, inner);
826 return;
827 }
828
829 let rows: Vec<&DdbBackup> = app.dynamodb_state.backups.items.iter().collect();
830
831 let columns: Vec<Box<dyn Column<DdbBackup>>> = vec![
832 Box::new(FieldCol {
833 label: "Backup name",
834 width: 30,
835 get: |b: &DdbBackup| b.name.clone(),
836 }),
837 Box::new(FieldCol {
838 label: "Status",
839 width: 12,
840 get: |b: &DdbBackup| b.status.clone(),
841 }),
842 Box::new(FieldCol {
843 label: "Type",
844 width: 12,
845 get: |b: &DdbBackup| b.backup_type.clone(),
846 }),
847 Box::new(FieldCol {
848 label: "Size",
849 width: 12,
850 get: |b: &DdbBackup| format_bytes(b.size_bytes),
851 }),
852 Box::new(FieldCol {
853 label: "Creation time",
854 width: UTC_TIMESTAMP_WIDTH,
855 get: |b: &DdbBackup| format_iso_timestamp(&b.creation_time),
856 }),
857 Box::new(FieldCol {
858 label: "Expiry time",
859 width: UTC_TIMESTAMP_WIDTH,
860 get: |b: &DdbBackup| format_iso_timestamp(&b.expiry_time),
861 }),
862 ];
863
864 let selected = app
865 .dynamodb_state
866 .backups
867 .selected
868 .min(rows.len().saturating_sub(1));
869
870 let config = TableConfig {
871 items: rows,
872 selected_index: selected,
873 expanded_index: app.dynamodb_state.backups.expanded_item,
874 columns: &columns,
875 sort_column: "Creation time",
876 sort_direction: SortDirection::Desc,
877 title: format_title(&format!(
878 "Backups ({})",
879 app.dynamodb_state.backups.items.len()
880 )),
881 area,
882 get_expanded_content: Some(Box::new(|b: &DdbBackup| expanded_from_columns(&columns, b))),
883 is_active: app.mode != crate::keymap::Mode::FilterInput,
884 };
885 render_table(frame, config);
886}
887
888fn render_monitor_tab(frame: &mut Frame, app: &App, t: &DdbTable, area: Rect) {
890 use crate::ui::monitoring::{render_monitoring_tab, MetricChart, MultiDatasetChart};
891
892 if app.dynamodb_state.metrics_loading {
893 let block = crate::ui::titled_block(" Monitor ");
894 let inner = block.inner(area);
895 frame.render_widget(block, area);
896 let p = Paragraph::new("Loading metrics...")
897 .style(Style::default().fg(Color::DarkGray))
898 .alignment(Alignment::Center);
899 frame.render_widget(p, inner);
900 return;
901 }
902
903 let m = &app.dynamodb_state.metrics;
904
905 let read_threshold = (t.provisioned_read_units > 0).then_some(t.provisioned_read_units as f64);
907 let write_threshold =
908 (t.provisioned_write_units > 0).then_some(t.provisioned_write_units as f64);
909
910 let single = vec![
911 MetricChart {
912 title: "Read capacity (units/second)",
913 data: &m.consumed_read_capacity,
914 y_axis_label: "Units/s",
915 x_axis_label: None,
916 threshold: read_threshold,
917 },
918 MetricChart {
919 title: "Write capacity (units/second)",
920 data: &m.consumed_write_capacity,
921 y_axis_label: "Units/s",
922 x_axis_label: None,
923 threshold: write_threshold,
924 },
925 ];
926
927 let multi = vec![
928 MultiDatasetChart {
929 title: "Throttled requests and events",
930 datasets: vec![
931 ("Read throttle events", m.read_throttle_events.as_slice()),
932 ("Write throttle events", m.write_throttle_events.as_slice()),
933 ("Throttled requests", m.throttled_requests.as_slice()),
934 ],
935 y_axis_label: "Count",
936 y_axis_step: 1,
937 x_axis_label: None,
938 },
939 MultiDatasetChart {
940 title: "Successful request latency (ms)",
941 datasets: vec![
942 ("GetItem", m.latency_get_item.as_slice()),
943 ("PutItem", m.latency_put_item.as_slice()),
944 ("Query", m.latency_query.as_slice()),
945 ("Scan", m.latency_scan.as_slice()),
946 ],
947 y_axis_label: "ms",
948 y_axis_step: 1,
949 x_axis_label: None,
950 },
951 ];
952
953 render_monitoring_tab(
954 frame,
955 area,
956 &single,
957 &multi,
958 &[],
959 &[],
960 app.dynamodb_state.monitoring_scroll,
961 );
962}
963
964pub fn item_index_options(t: &DdbTable) -> Vec<String> {
967 let mut options = vec![format!("[Table] {}", t.name)];
968 options.extend(
969 t.global_secondary_indexes
970 .iter()
971 .chain(t.local_secondary_indexes.iter())
972 .map(|i| format!("[Index] {}", i.name)),
973 );
974 options
975}
976
977pub fn selected_item_index(t: &DdbTable, idx: usize) -> Option<String> {
979 if idx == 0 {
980 return None;
981 }
982 t.global_secondary_indexes
983 .iter()
984 .chain(t.local_secondary_indexes.iter())
985 .nth(idx - 1)
986 .map(|i| i.name.clone())
987}
988
989pub fn key_attribute_name(label: &str) -> &str {
991 label.split(" (").next().unwrap_or(label).trim()
992}
993
994pub fn key_attribute_type(label: &str) -> String {
996 label
997 .rsplit_once('(')
998 .and_then(|(_, rest)| rest.split(')').next())
999 .unwrap_or("S")
1000 .to_string()
1001}
1002
1003pub fn selected_partition_key_label(t: &DdbTable, idx: usize) -> String {
1005 if idx == 0 {
1006 return t.partition_key.clone();
1007 }
1008 t.global_secondary_indexes
1009 .iter()
1010 .chain(t.local_secondary_indexes.iter())
1011 .nth(idx - 1)
1012 .map(|i| i.partition_key.clone())
1013 .unwrap_or_else(|| t.partition_key.clone())
1014}
1015
1016pub fn selected_key_attributes(t: &DdbTable, idx: usize) -> Vec<String> {
1019 let (pk, sk) = if idx == 0 {
1020 (t.partition_key.clone(), t.sort_key.clone())
1021 } else {
1022 match t
1023 .global_secondary_indexes
1024 .iter()
1025 .chain(t.local_secondary_indexes.iter())
1026 .nth(idx - 1)
1027 {
1028 Some(i) => (i.partition_key.clone(), i.sort_key.clone()),
1029 None => (t.partition_key.clone(), t.sort_key.clone()),
1030 }
1031 };
1032 let mut keys = Vec::new();
1033 for label in [pk, sk] {
1034 if !label.is_empty() {
1035 keys.push(key_attribute_name(&label).to_string());
1036 }
1037 }
1038 keys
1039}
1040
1041pub fn filtered_items(app: &App) -> Vec<&DdbItem> {
1044 let filter = app.dynamodb_state.item_filter.to_lowercase();
1045 if app.dynamodb_state.item_operation == ItemOperation::Query || filter.is_empty() {
1046 return app.dynamodb_state.items.items.iter().collect();
1047 }
1048 app.dynamodb_state
1049 .items
1050 .items
1051 .iter()
1052 .filter(|i| {
1053 i.attributes.iter().any(|(k, v)| {
1054 k.to_lowercase().contains(&filter) || v.to_lowercase().contains(&filter)
1055 })
1056 })
1057 .collect()
1058}
1059
1060struct ItemCol {
1062 label: String,
1063}
1064
1065impl Column<DdbItem> for ItemCol {
1066 fn name(&self) -> &str {
1067 &self.label
1068 }
1069 fn width(&self) -> u16 {
1070 (self.label.len().max(18) as u16).min(40)
1071 }
1072 fn render(&self, item: &DdbItem) -> (String, Style) {
1073 let value = item.get(&self.label);
1074 if value.is_empty() {
1075 ("-".to_string(), Style::default().fg(Color::DarkGray))
1076 } else {
1077 (value.to_string(), Style::default())
1078 }
1079 }
1080}
1081
1082fn item_columns(t: &DdbTable, app: &App) -> Vec<Box<dyn Column<DdbItem>>> {
1083 item_visible_column_names(t, app)
1084 .into_iter()
1085 .map(|label| Box::new(ItemCol { label }) as Box<dyn Column<DdbItem>>)
1086 .collect()
1087}
1088
1089pub fn item_column_names(t: &DdbTable, app: &App) -> Vec<String> {
1098 let mut names = selected_key_attributes(t, app.dynamodb_state.item_index);
1099 let mut rest: Vec<String> = Vec::new();
1100 for item in &app.dynamodb_state.items.items {
1101 for attr in item.attributes.keys() {
1102 if !names.contains(attr) && !rest.contains(attr) {
1103 rest.push(attr.clone());
1104 }
1105 }
1106 }
1107 rest.sort();
1108 names.extend(rest);
1109 names
1110}
1111
1112pub fn item_visible_column_names(t: &DdbTable, app: &App) -> Vec<String> {
1115 let all = item_column_names(t, app);
1116 let visible: Vec<String> = all
1117 .iter()
1118 .filter(|n| !app.dynamodb_state.item_hidden_columns.contains(n))
1119 .cloned()
1120 .collect();
1121 if visible.is_empty() {
1122 all
1123 } else {
1124 visible
1125 }
1126}
1127
1128fn render_items_tab(frame: &mut Frame, app: &App, t: &DdbTable, area: Rect) {
1129 use crate::common::render_dropdown;
1130 use crate::ui::filter::{render_filter_bar, FilterConfig, FilterControl};
1131
1132 let chunks = Layout::default()
1133 .direction(Direction::Vertical)
1134 .constraints([Constraint::Length(3), Constraint::Min(0)])
1135 .split(area);
1136
1137 let state = &app.dynamodb_state;
1138 let index_options = item_index_options(t);
1139 let index_label = index_options
1140 .get(state.item_index)
1141 .cloned()
1142 .unwrap_or_else(|| index_options[0].clone());
1143
1144 let view = state
1145 .items
1146 .prepare_view(filtered_items(app), TableWindow::SelectedPage);
1147 let pagination = if items::results_complete(app) {
1148 render_pagination_text(view.current_page, view.total_pages)
1149 } else {
1150 render_partial_pagination(view.current_page, view.total_pages)
1151 };
1152
1153 let placeholder = if state.item_operation == ItemOperation::Query {
1154 "Enter a partition key value to query"
1155 } else {
1156 "Filter the scanned items"
1157 };
1158
1159 render_filter_bar(
1160 frame,
1161 FilterConfig {
1162 filter_text: &state.item_filter,
1163 placeholder,
1164 mode: app.mode,
1165 is_input_focused: state.input_focus == InputFocus::Filter,
1166 controls: vec![
1167 FilterControl {
1168 text: state.item_operation.name().to_string(),
1169 is_focused: state.input_focus == ITEM_OPERATION,
1170 },
1171 FilterControl {
1172 text: index_label,
1173 is_focused: state.input_focus == ITEM_INDEX,
1174 },
1175 FilterControl {
1176 text: state.item_projection.name().to_string(),
1177 is_focused: state.input_focus == ITEM_PROJECTION,
1178 },
1179 FilterControl {
1180 text: pagination.clone(),
1181 is_focused: state.input_focus == InputFocus::Pagination,
1182 },
1183 ],
1184 area: chunks[0],
1185 },
1186 );
1187
1188 if state.items_loading && !state.items_append {
1189 let block = crate::ui::titled_block(t.name.clone());
1190 let inner = block.inner(chunks[1]);
1191 frame.render_widget(block, chunks[1]);
1192 let p = Paragraph::new("Loading items...")
1193 .style(Style::default().fg(Color::DarkGray))
1194 .alignment(Alignment::Center);
1195 frame.render_widget(p, inner);
1196 return;
1197 }
1198
1199 if state.item_operation == ItemOperation::Query && state.item_filter.is_empty() {
1201 let block = crate::ui::titled_block(t.name.clone());
1202 let inner = block.inner(chunks[1]);
1203 frame.render_widget(block, chunks[1]);
1204 let key = selected_key_attributes(t, state.item_index)
1205 .first()
1206 .cloned()
1207 .unwrap_or_default();
1208 let p = Paragraph::new(format!("Enter a value for {} to run the query", key))
1209 .style(Style::default().fg(Color::DarkGray))
1210 .alignment(Alignment::Center);
1211 frame.render_widget(p, inner);
1212 return;
1213 }
1214
1215 let columns = item_columns(t, app);
1216
1217 let config = TableConfig {
1218 items: view.items,
1219 selected_index: view.selected_index,
1220 expanded_index: view.expanded_index,
1221 columns: &columns,
1222 sort_column: "",
1223 sort_direction: SortDirection::Asc,
1224 title: format_title(&t.name),
1225 area: chunks[1],
1226 get_expanded_content: Some(Box::new(|i: &DdbItem| expanded_from_columns(&columns, i))),
1227 is_active: app.mode != crate::keymap::Mode::FilterInput,
1228 };
1229 render_table(frame, config);
1230
1231 {
1234 let after_operation = (index_label_width(&index_options, state.item_index)
1235 + state.item_projection.name().len()) as u16
1236 + pagination.len() as u16
1237 + 9;
1238 if state.input_focus == ITEM_OPERATION {
1239 let names: Vec<&str> = ItemOperation::ALL.iter().map(|o| o.name()).collect();
1240 let selected = ItemOperation::ALL
1241 .iter()
1242 .position(|o| *o == state.item_operation)
1243 .unwrap_or(0);
1244 render_dropdown(frame, &names, selected, chunks[0], after_operation);
1245 } else if state.input_focus == ITEM_INDEX {
1246 let after = (state.item_projection.name().len() + pagination.len()) as u16 + 6;
1247 render_dropdown(
1248 frame,
1249 &index_options,
1250 state.item_index.min(index_options.len().saturating_sub(1)),
1251 chunks[0],
1252 after,
1253 );
1254 } else if state.input_focus == ITEM_PROJECTION {
1255 let names: Vec<&str> = ItemProjection::ALL.iter().map(|p| p.name()).collect();
1256 let selected = ItemProjection::ALL
1257 .iter()
1258 .position(|p| *p == state.item_projection)
1259 .unwrap_or(0);
1260 let after = pagination.len() as u16 + 3;
1261 render_dropdown(frame, &names, selected, chunks[0], after);
1262 }
1263 }
1264}
1265
1266fn index_label_width(options: &[String], selected: usize) -> usize {
1268 options
1269 .get(selected)
1270 .or_else(|| options.first())
1271 .map(|s| s.len())
1272 .unwrap_or(0)
1273}
1274
1275fn render_tags_tab(frame: &mut Frame, app: &App, area: Rect) {
1276 tags::render(
1277 frame,
1278 area,
1279 TagsConfig {
1280 table: &app.dynamodb_state.tags_table,
1281 items: &app.dynamodb_state.tags_table.items,
1282 columns: tags::columns(),
1283 sort: TagSort::Key,
1284 window: TableWindow::SelectedPage,
1285 focus: app.dynamodb_state.input_focus,
1286 mode: app.mode,
1287 loading: app.dynamodb_state.tags_loading,
1288 },
1289 );
1290}
1291
1292pub fn render_tables_list(frame: &mut Frame, app: &App, area: Rect) {
1293 let chunks = Layout::default()
1294 .direction(Direction::Vertical)
1295 .constraints([Constraint::Length(3), Constraint::Min(0)])
1296 .split(area);
1297
1298 let view = app
1299 .dynamodb_state
1300 .tables
1301 .prepare_view(filtered_dynamodb_tables(app), TableWindow::SelectedPage);
1302 let pagination = render_pagination_text(view.current_page, view.total_pages);
1303
1304 render_simple_filter(
1305 frame,
1306 chunks[0],
1307 SimpleFilterConfig {
1308 filter_text: &app.dynamodb_state.tables.filter,
1309 placeholder: "Search by table name, status, or partition key",
1310 pagination: &pagination,
1311 mode: app.mode,
1312 is_input_focused: app.dynamodb_state.input_focus == InputFocus::Filter,
1313 is_pagination_focused: app.dynamodb_state.input_focus == InputFocus::Pagination,
1314 },
1315 );
1316
1317 let title = format_title(&format!("Tables ({})", view.total_items));
1318
1319 let columns: Vec<Box<dyn Column<DdbTable>>> = app
1320 .dynamodb_visible_column_ids
1321 .iter()
1322 .filter_map(|col_id| {
1323 table::Column::from_id(col_id).map(|col| Box::new(col) as Box<dyn Column<DdbTable>>)
1324 })
1325 .collect();
1326
1327 let config = TableConfig {
1328 items: view.items,
1329 selected_index: view.selected_index,
1330 expanded_index: view.expanded_index,
1331 columns: &columns,
1332 sort_column: "Name",
1333 sort_direction: SortDirection::Asc,
1334 title,
1335 area: chunks[1],
1336 get_expanded_content: Some(Box::new(|t: &DdbTable| expanded_from_columns(&columns, t))),
1337 is_active: app.mode != crate::keymap::Mode::FilterInput,
1338 };
1339
1340 render_table(frame, config);
1341}
1342
1343#[cfg(test)]
1344mod tests {
1345 use super::*;
1346 use rusticity_core::dynamodb::DdbIndex;
1347
1348 fn make_table(name: &str, pk: &str) -> DdbTable {
1349 DdbTable {
1350 name: name.to_string(),
1351 status: "ACTIVE".to_string(),
1352 partition_key: pk.to_string(),
1353 read_capacity_mode: "On-demand".to_string(),
1354 write_capacity_mode: "On-demand".to_string(),
1355 table_class: "Standard".to_string(),
1356 deletion_protection: "Disabled".to_string(),
1357 ..Default::default()
1358 }
1359 }
1360
1361 #[test]
1362 fn test_prepared_items_second_page_and_stale_selection() {
1363 use crate::common::PageSize;
1364 let mut app = app_with_table();
1365 app.dynamodb_state.current_table = Some("orders".into());
1366 app.dynamodb_state.detail_tab = DetailTab::Items;
1367 app.dynamodb_state.items.items = (0..25)
1368 .map(|i| item_with(&[("order_id", &format!("record{i:02}")), ("status", "MATCH")]))
1369 .collect();
1370 app.dynamodb_state
1371 .items
1372 .items
1373 .push(item_with(&[("order_id", "excluded")]));
1374 app.dynamodb_state.item_filter = "match".into();
1375 app.dynamodb_state.items.page_size = PageSize::Ten;
1376 app.dynamodb_state.items.selected = 12;
1377 app.dynamodb_state.items.expanded_item = Some(11);
1378 app.dynamodb_state.items.next_token = Some("unchanged-server-cursor".into());
1379
1380 let content = render_to_string(&app, 200, 45);
1381 assert!(content.contains(&render_pagination_text(1, 3)));
1382 assert!(content.contains("record10"));
1383 assert!(content.contains("record19"));
1384 assert!(content.contains("▼ record11"));
1385 assert!(content.contains("► record12"));
1386 assert!(!content.contains("record09"));
1387 assert!(!content.contains("record20"));
1388 assert!(!content.contains("excluded"));
1389
1390 app.dynamodb_state.item_filter = "missing".into();
1392 let content = render_to_string(&app, 200, 45);
1393 assert!(!content.contains("record12"));
1394 assert_eq!(app.dynamodb_state.items.selected, 12);
1395 assert_eq!(app.dynamodb_state.items.expanded_item, Some(11));
1396 assert_eq!(
1397 app.dynamodb_state.items.next_token.as_deref(),
1398 Some("unchanged-server-cursor")
1399 );
1400 assert!(!app.dynamodb_state.items_loading);
1401 }
1402
1403 #[test]
1404 fn test_summary_and_general_borders_follow_content_without_blank_rows() {
1405 use ratatui::{backend::TestBackend, Terminal};
1406
1407 let mut app = app_with_table();
1408 app.dynamodb_state.current_table = Some("orders".into());
1409 app.dynamodb_state.detail_tab = DetailTab::Settings;
1410 app.dynamodb_state.tables.items[0].creation_time = "2026-07-27T15:11:44Z".into();
1411 app.dynamodb_state.tables.items[0].encryption_type = "Owned by Amazon DynamoDB".into();
1412 app.dynamodb_state.settings.ttl_status = "Enabled".into();
1413 app.dynamodb_state.settings.ttl_attribute = "expires_at".into();
1414
1415 for width in [90, 160, 300] {
1416 let mut terminal = Terminal::new(TestBackend::new(width, 60)).unwrap();
1417 let area = Rect::new(2, 1, width - 4, 58);
1419 terminal
1420 .draw(|f| render_table_detail(f, &app, area))
1421 .unwrap();
1422 let buffer = terminal.backend().buffer();
1423 let rows: Vec<String> = (area.y..area.bottom())
1424 .map(|y| {
1425 (area.x..area.right())
1426 .map(|x| buffer[(x, y)].symbol())
1427 .collect()
1428 })
1429 .collect();
1430 for title in ["Summary", "General information"] {
1431 let top = rows.iter().position(|row| row.contains(title)).unwrap();
1432 let bottom = (top + 1..rows.len())
1433 .find(|&y| rows[y].starts_with('╰'))
1434 .unwrap();
1435 assert!(bottom > top + 1);
1436 assert!(
1437 rows[bottom - 1]
1438 .chars()
1439 .any(|c| !c.is_whitespace() && c != '│'),
1440 "{title} has a blank row above its bottom border at width {width}"
1441 );
1442 for row in &rows[top + 1..bottom] {
1443 assert!(!row.contains('╰'), "Content overlaps a border: {row}");
1444 }
1445 }
1446 let content = rows.join("\n");
1447 for label in [
1448 "Table name:",
1449 "Status:",
1450 "Partition key:",
1451 "Sort key:",
1452 "Read capacity mode:",
1453 "Write capacity mode:",
1454 "Table class:",
1455 "Total size:",
1456 "Item count:",
1457 "Indexes:",
1458 "Creation time:",
1459 "ARN:",
1460 "Table ID:",
1461 "Encryption type:",
1462 "KMS key ARN:",
1463 "Time to Live (TTL):",
1464 "TTL attribute:",
1465 ] {
1466 assert!(content.contains(label), "Missing {label} at width {width}");
1467 }
1468 assert_eq!(content.matches("Deletion protection:").count(), 2);
1469 let summary_bottom = rows.iter().position(|row| row.starts_with('╰')).unwrap();
1470 assert!(rows[summary_bottom + 1].starts_with("Items"));
1471 assert!(rows[summary_bottom + 2].contains("General information"));
1472 let general_top = rows
1473 .iter()
1474 .position(|row| row.contains("General information"))
1475 .unwrap();
1476 let general_bottom = (general_top + 1..rows.len())
1477 .find(|&y| rows[y].starts_with('╰'))
1478 .unwrap();
1479 assert!(rows[general_bottom + 1].contains("Capacity"));
1480 if width == 300 {
1481 assert_eq!(summary_bottom, 5, "Four content rows plus two borders");
1482 assert_eq!(
1483 general_bottom - general_top,
1484 4,
1485 "Three content rows plus two borders"
1486 );
1487 }
1488 }
1489 }
1490
1491 #[test]
1492 fn test_summary_and_general_render_in_tiny_viewports() {
1493 use ratatui::{backend::TestBackend, Terminal};
1494 let mut app = app_with_table();
1495 app.dynamodb_state.current_table = Some("orders".into());
1496 app.dynamodb_state.detail_tab = DetailTab::Settings;
1497 for width in [1, 2, 12, 80] {
1498 for height in [1, 2, 3, 8] {
1499 let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
1500 terminal
1501 .draw(|f| render_table_detail(f, &app, f.area()))
1502 .unwrap();
1503 }
1504 }
1505 }
1506
1507 fn items_rows(app: &App) -> Vec<String> {
1508 use ratatui::{backend::TestBackend, Terminal};
1509 let mut terminal = Terminal::new(TestBackend::new(220, 20)).unwrap();
1510 let table = current_table(app).unwrap();
1511 terminal
1512 .draw(|f| render_items_tab(f, app, table, f.area()))
1513 .unwrap();
1514 let buffer = terminal.backend().buffer();
1515 (0..20)
1516 .map(|y| (0..220).map(|x| buffer[(x, y)].symbol()).collect())
1517 .collect()
1518 }
1519
1520 #[test]
1521 fn test_items_plain_title_has_no_result_summary_or_status_row() {
1522 let mut app = app_with_table();
1523 app.dynamodb_state.current_table = Some("orders".into());
1524 app.dynamodb_state.items.items = (0..152)
1525 .map(|i| item_with(&[("order_id", &format!("record{i:03}"))]))
1526 .collect();
1527 app.dynamodb_state.items_context = items::context(&app);
1528 let rows = items_rows(&app);
1529 assert!(rows[3].starts_with("╭─ orders ─"));
1531 let border = rows[3].strip_prefix("╭─ orders ─").unwrap();
1532 assert!(border.strip_suffix('╮').unwrap().chars().all(|c| c == '─'));
1533 assert!(rows[4].contains("order_id"));
1534 assert!(rows[5].contains("record000"));
1535 let screen = rows.join("\n");
1536 assert!(!screen.contains("loaded"));
1537 assert!(!screen.contains("matching"));
1538 assert!(!screen.contains("No more pages"));
1539 }
1540
1541 #[test]
1542 fn test_items_placeholders_keep_messages_in_body_not_title() {
1543 let mut app = app_with_table();
1544 app.dynamodb_state.current_table = Some("orders".into());
1545 app.dynamodb_state.items_loading = true;
1546 let rows = items_rows(&app);
1547 assert!(rows[3].starts_with("╭─ orders ─"));
1548 assert!(!rows[3].contains("Loading"));
1549 assert!(rows[4].contains("Loading items..."));
1550
1551 app.dynamodb_state.items_loading = false;
1552 app.dynamodb_state.item_operation = ItemOperation::Query;
1553 let rows = items_rows(&app);
1554 assert!(rows[3].starts_with("╭─ orders ─"));
1555 assert!(!rows[3].contains("query"));
1556 assert!(rows[4].contains("to run the query"));
1557 }
1558
1559 #[test]
1560 fn test_state_default() {
1561 let state = State::new();
1562 assert_eq!(state.input_focus, InputFocus::Filter);
1563 assert!(state.tables.items.is_empty());
1564 assert_eq!(state.current_table, None);
1565 assert_eq!(state.detail_tab, DetailTab::Items);
1566 assert!(state.item_hidden_columns.is_empty());
1567 }
1568
1569 #[test]
1570 fn test_filtered_by_name_status_partition_key() {
1571 let mut app = App::new_without_client("default".to_string(), None);
1572 app.dynamodb_state.tables.items = vec![
1573 make_table("orders", "order_id (S)"),
1574 make_table("users", "user_id (S)"),
1575 ];
1576
1577 app.dynamodb_state.tables.filter = "ord".to_string();
1578 assert_eq!(filtered_dynamodb_tables(&app).len(), 1);
1579
1580 app.dynamodb_state.tables.filter = "user_id".to_string();
1581 assert_eq!(filtered_dynamodb_tables(&app).len(), 1);
1582
1583 app.dynamodb_state.tables.filter = "active".to_string();
1584 assert_eq!(filtered_dynamodb_tables(&app).len(), 2);
1585
1586 app.dynamodb_state.tables.filter = String::new();
1587 assert_eq!(filtered_dynamodb_tables(&app).len(), 2);
1588 }
1589
1590 #[test]
1591 fn test_detail_tab_cycles_through_all_tabs() {
1592 let mut tab = DetailTab::Items;
1593 let mut seen = vec![tab];
1594 for _ in 0..DetailTab::ALL.len() - 1 {
1595 tab = tab.next();
1596 seen.push(tab);
1597 }
1598 assert_eq!(seen.len(), 9);
1599 assert_eq!(tab.next(), DetailTab::Items);
1600 assert_eq!(DetailTab::ALL.len(), 9);
1601 assert_eq!(DetailTab::ALL[0], DetailTab::Items);
1603 assert_eq!(DetailTab::ALL[1], DetailTab::Settings);
1604 assert_eq!(DetailTab::ALL[2], DetailTab::Indexes);
1605 assert_eq!(DetailTab::ALL[3], DetailTab::Monitor);
1606 assert_eq!(DetailTab::ALL[DetailTab::ALL.len() - 1], DetailTab::Tags);
1607 }
1608
1609 #[test]
1610 fn test_console_tab_ids_are_unique() {
1611 let mut ids: Vec<&str> = DetailTab::ALL.iter().map(|t| t.console_tab()).collect();
1612 ids.sort_unstable();
1613 let count = ids.len();
1614 ids.dedup();
1615 assert_eq!(ids.len(), count);
1616 }
1617
1618 #[test]
1619 fn test_format_capacity() {
1620 assert_eq!(format_capacity("On-demand", 0, "RCU"), "On-demand");
1621 assert_eq!(
1622 format_capacity("Provisioned", 5, "RCU"),
1623 "Provisioned (5 RCU)"
1624 );
1625 assert_eq!(format_capacity("Provisioned", 0, "WCU"), "Provisioned");
1627 }
1628
1629 #[test]
1630 fn test_enabled_disabled_color() {
1631 assert_eq!(enabled_disabled_color("Enabled"), Color::Green);
1632 assert_eq!(enabled_disabled_color("Disabled"), Color::Yellow);
1633 assert_eq!(enabled_disabled_color(""), Color::White);
1634 }
1635
1636 #[test]
1637 fn test_summary_includes_keys_capacity_and_indexes() {
1638 let mut t = make_table("orders", "order_id (S)");
1639 t.sort_key = "created_at (N)".to_string();
1640 t.read_capacity_mode = "Provisioned".to_string();
1641 t.provisioned_read_units = 5;
1642 t.write_capacity_mode = "Provisioned".to_string();
1643 t.provisioned_write_units = 7;
1644 t.total_size_bytes = 2048;
1645 t.item_count = 11;
1646 t.global_secondary_indexes = vec![DdbIndex::default()];
1647
1648 let text: String = build_summary_lines(&t)
1649 .iter()
1650 .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
1651 .collect::<Vec<_>>()
1652 .join("");
1653 assert!(text.contains("order_id (S)"));
1654 assert!(text.contains("created_at (N)"));
1655 assert!(text.contains("Provisioned (5 RCU)"));
1656 assert!(text.contains("Provisioned (7 WCU)"));
1657 assert!(text.contains("Standard"));
1658 }
1659
1660 #[test]
1661 fn test_summary_shows_dash_when_no_sort_key() {
1662 let t = make_table("orders", "order_id (S)");
1663 let text: String = build_summary_lines(&t)
1664 .iter()
1665 .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
1666 .collect::<Vec<_>>()
1667 .join("");
1668 assert!(text.contains("Sort key"));
1669 assert!(text.contains('-'));
1670 }
1671
1672 #[test]
1673 fn test_detail_fixed_columns() {
1674 assert_eq!(
1675 detail_fixed_columns(DetailTab::Indexes),
1676 Some(&INDEXES_COLUMNS[..])
1677 );
1678 assert_eq!(
1679 detail_fixed_columns(DetailTab::GlobalTables),
1680 Some(&REPLICAS_COLUMNS[..])
1681 );
1682 assert_eq!(
1683 detail_fixed_columns(DetailTab::Backups),
1684 Some(&BACKUPS_COLUMNS[..])
1685 );
1686 assert_eq!(
1687 detail_fixed_columns(DetailTab::Tags),
1688 Some(&TAGS_COLUMNS[..])
1689 );
1690 assert_eq!(detail_fixed_columns(DetailTab::Settings), None);
1691 assert_eq!(detail_fixed_columns(DetailTab::Monitor), None);
1692 assert_eq!(detail_fixed_columns(DetailTab::AdditionalSettings), None);
1693 }
1694
1695 #[test]
1696 fn test_current_table_lookup() {
1697 let mut app = App::new_without_client("default".to_string(), None);
1698 app.dynamodb_state.tables.items = vec![make_table("orders", "pk (S)")];
1699 assert!(current_table(&app).is_none());
1700 app.dynamodb_state.current_table = Some("orders".to_string());
1701 assert_eq!(current_table(&app).map(|t| t.name.as_str()), Some("orders"));
1702 }
1703
1704 fn render_to_string(app: &App, width: u16, height: u16) -> String {
1706 use ratatui::backend::TestBackend;
1707 use ratatui::Terminal;
1708 let backend = TestBackend::new(width, height);
1709 let mut terminal = Terminal::new(backend).unwrap();
1710 terminal.draw(|f| crate::ui::render(f, app)).unwrap();
1711 terminal
1712 .backend()
1713 .buffer()
1714 .content()
1715 .iter()
1716 .map(|c| c.symbol())
1717 .collect::<String>()
1718 }
1719
1720 fn app_with_table() -> App {
1721 let mut app = App::new_without_client("default".to_string(), None);
1722 app.current_service = crate::app::Service::DynamoDbTables;
1723 app.service_selected = true;
1724 app.mode = crate::keymap::Mode::Normal;
1725 let mut t = make_table("orders", "order_id (S)");
1726 t.sort_key = "created_at (N)".to_string();
1727 t.table_arn = "arn:aws:dynamodb:us-east-2:123456789012:table/orders".to_string();
1728 t.global_secondary_indexes = vec![DdbIndex {
1729 name: "customer-index".to_string(),
1730 index_type: "Global".to_string(),
1731 status: "ACTIVE".to_string(),
1732 partition_key: "customer_id (S)".to_string(),
1733 projection: "KEYS_ONLY".to_string(),
1734 ..Default::default()
1735 }];
1736 app.dynamodb_state.tables.items = vec![t];
1737 app
1738 }
1739
1740 fn item_with(attrs: &[(&str, &str)]) -> DdbItem {
1742 let mut item = DdbItem::default();
1743 for (k, v) in attrs {
1744 item.attributes.insert(k.to_string(), v.to_string());
1745 }
1746 item
1747 }
1748
1749 #[test]
1750 fn test_render_tables_list() {
1751 let app = app_with_table();
1752 let content = render_to_string(&app, 240, 30);
1754 assert!(content.contains("Tables (1)"));
1755 assert!(content.contains("orders"));
1756 assert!(content.contains("Partition key"));
1757 assert!(content.contains("Replication Regions"));
1758 assert!(content.contains("Table class"));
1759 }
1760
1761 #[test]
1762 fn test_render_detail_every_tab() {
1763 let mut app = app_with_table();
1764 app.dynamodb_state.current_table = Some("orders".to_string());
1765 for tab in DetailTab::ALL {
1766 app.dynamodb_state.detail_tab = *tab;
1767 let content = render_to_string(&app, 160, 40);
1768 assert!(content.contains(tab.name()), "tab {:?}", tab);
1771 if *tab != DetailTab::Items {
1772 assert!(content.contains("Summary"), "tab {:?}", tab);
1773 } else {
1774 assert!(!content.contains("Summary"), "tab {:?}", tab);
1775 }
1776 }
1777 }
1778
1779 #[test]
1780 fn test_render_indexes_tab_shows_index_rows() {
1781 let mut app = app_with_table();
1782 app.dynamodb_state.current_table = Some("orders".to_string());
1783 app.dynamodb_state.detail_tab = DetailTab::Indexes;
1784 let content = render_to_string(&app, 200, 40);
1785 assert!(content.contains("Indexes (1)"));
1786 assert!(content.contains("customer-index"));
1787 }
1788
1789 #[test]
1790 fn test_preferences_uses_tab_columns_not_list_columns() {
1791 let mut app = app_with_table();
1794 app.mode = crate::keymap::Mode::ColumnSelector;
1795
1796 let content = render_to_string(&app, 160, 40);
1798 assert!(content.contains("Partition key"));
1799 assert!(content.contains("Read capacity mode"));
1800
1801 app.dynamodb_state.current_table = Some("orders".to_string());
1804 app.dynamodb_state.detail_tab = DetailTab::Indexes;
1805 assert_eq!(crate::dynamodb::actions::column_count(&app), 10);
1806 assert_eq!(crate::dynamodb::actions::column_selector_max(&app), 10 + 6);
1807
1808 app.dynamodb_state.detail_tab = DetailTab::GlobalTables;
1810 assert_eq!(crate::dynamodb::actions::column_count(&app), 5);
1811 let content = render_to_string(&app, 160, 40);
1812 assert!(content.contains("Region"));
1813
1814 app.dynamodb_state.detail_tab = DetailTab::Settings;
1816 assert_eq!(
1817 crate::dynamodb::actions::column_count(&app),
1818 app.dynamodb_column_ids.len()
1819 );
1820 }
1821
1822 #[test]
1823 fn test_detail_tab_change_marks_lazy_data_for_reload() {
1824 let mut app = app_with_table();
1825 app.dynamodb_state.current_table = Some("orders".to_string());
1826 app.dynamodb_state.detail_tab = DetailTab::GlobalTables;
1827
1828 crate::dynamodb::actions::next_detail_tab(&mut app);
1830 assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Backups);
1831 assert!(app.dynamodb_state.backups_loading);
1832 assert!(app.dynamodb_state.settings_loading);
1833 app.dynamodb_state.settings_loading = false;
1834
1835 crate::dynamodb::actions::next_detail_tab(&mut app);
1836 assert_eq!(app.dynamodb_state.detail_tab, DetailTab::ExportsStreams);
1837
1838 crate::dynamodb::actions::next_detail_tab(&mut app);
1840 assert_eq!(app.dynamodb_state.detail_tab, DetailTab::AdditionalSettings);
1841 assert!(app.dynamodb_state.permissions_loading);
1842
1843 crate::dynamodb::actions::next_detail_tab(&mut app);
1845 assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Tags);
1846 assert!(app.dynamodb_state.tags_loading);
1847
1848 crate::dynamodb::actions::next_detail_tab(&mut app);
1850 assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Items);
1851 assert!(app.dynamodb_state.items_loading);
1852
1853 crate::dynamodb::actions::next_detail_tab(&mut app);
1854 assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Settings);
1855 assert!(app.dynamodb_state.settings_loading);
1856 }
1857
1858 #[test]
1859 fn test_tags_tab_renders_tag_rows() {
1860 let mut app = app_with_table();
1861 app.dynamodb_state.current_table = Some("orders".to_string());
1862 app.dynamodb_state.detail_tab = DetailTab::Tags;
1863 app.dynamodb_state.tags_table.items = vec![
1864 ("Name".to_string(), "rusticity-test".to_string()),
1865 ("Owner".to_string(), "azzhipa".to_string()),
1866 ];
1867 let content = render_to_string(&app, 200, 40);
1868 assert!(content.contains("Tags (2)"));
1869 assert!(content.contains("rusticity-test"));
1870 assert_eq!(crate::dynamodb::actions::column_count(&app), 2);
1871
1872 app.dynamodb_state.tags_loading = true;
1873 let content = render_to_string(&app, 200, 40);
1874 assert!(content.contains("Loading tags"));
1875 }
1876
1877 #[test]
1878 fn test_timestamps_use_the_common_utc_formatter() {
1879 let mut t = make_table("orders", "order_id (S)");
1881 t.creation_time = "2026-07-27T15:11:44.813999Z".to_string();
1882 let text: String = build_summary_lines(&t)
1883 .iter()
1884 .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
1885 .collect::<Vec<_>>()
1886 .join("");
1887 assert!(text.contains("2026-07-27 15:11:44 (UTC)"), "{}", text);
1888 assert!(!text.contains("2026-07-27T15:11:44.813999Z"));
1889
1890 let mut app = app_with_table();
1892 app.dynamodb_state.tables.items[0].creation_time = t.creation_time.clone();
1893 app.dynamodb_state.current_table = Some("orders".to_string());
1894 app.dynamodb_state.detail_tab = DetailTab::Backups;
1895 app.dynamodb_state.settings = DdbTableSettings {
1896 pitr_status: "Enabled".to_string(),
1897 recovery_period_days: 35,
1898 earliest_restorable_time: "2026-07-27T15:12:15Z".to_string(),
1899 latest_restorable_time: "2026-07-27T15:44:10.181999Z".to_string(),
1900 ..Default::default()
1901 };
1902 let content = render_to_string(&app, 200, 40);
1903 assert!(content.contains("2026-07-27 15:12:15 (UTC)"));
1904 assert!(content.contains("2026-07-27 15:44:10 (UTC)"));
1905 assert!(!content.contains(".181999Z"));
1906 }
1907
1908 #[test]
1909 fn test_selecting_a_table_opens_items_and_schedules_a_scan() {
1910 let mut app = app_with_table();
1911 crate::dynamodb::actions::select_item(&mut app);
1912 assert_eq!(app.dynamodb_state.current_table.as_deref(), Some("orders"));
1913 assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Items);
1915 assert!(app.dynamodb_state.items_loading);
1916 assert!(needs_items_load(&app));
1917 assert!(!app.dynamodb_state.settings_loading);
1919
1920 crate::dynamodb::actions::next_detail_tab(&mut app);
1921 assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Settings);
1922 assert!(app.dynamodb_state.settings_loading);
1923 }
1924
1925 #[test]
1926 fn test_backups_tab_shows_pitr_pane() {
1927 let mut app = app_with_table();
1928 app.dynamodb_state.current_table = Some("orders".to_string());
1929 app.dynamodb_state.detail_tab = DetailTab::Backups;
1930 app.dynamodb_state.settings = DdbTableSettings {
1931 pitr_status: "Enabled".to_string(),
1932 recovery_period_days: 35,
1933 earliest_restorable_time: "2026-07-27T15:12:15Z".to_string(),
1934 latest_restorable_time: "2026-07-27T15:33:18Z".to_string(),
1935 ..Default::default()
1936 };
1937 let content = render_to_string(&app, 200, 40);
1938 assert!(content.contains("Point-in-time recovery (PITR)"));
1939 assert!(content.contains("35 days"));
1940 assert!(content.contains("Earliest restore point"));
1941 assert!(content.contains("Latest restore point"));
1942 }
1943
1944 #[test]
1945 fn test_global_tables_tab_shows_settings_replication() {
1946 let mut app = app_with_table();
1947 app.dynamodb_state.current_table = Some("orders".to_string());
1948 app.dynamodb_state.detail_tab = DetailTab::GlobalTables;
1949 let content = render_to_string(&app, 200, 40);
1950 assert!(content.contains("Settings replication"));
1951 assert!(content.contains("Disabled"));
1952 assert!(content.contains("Replicas (0)"));
1953 }
1954
1955 #[test]
1956 fn test_additional_settings_shows_policy_panes() {
1957 let mut app = app_with_table();
1958 app.dynamodb_state.current_table = Some("orders".to_string());
1959 app.dynamodb_state.detail_tab = DetailTab::AdditionalSettings;
1960 let content = render_to_string(&app, 200, 40);
1961 assert!(content.contains("Resource-based policy for table"));
1962 assert!(content.contains("No table resource-based policy to display"));
1963 assert!(content.contains("No stream resource-based policy to display"));
1964
1965 app.dynamodb_state.table_policy = Some("{\n \"Version\": \"2012-10-17\"\n}".to_string());
1966 let content = render_to_string(&app, 200, 40);
1967 assert!(content.contains("2012-10-17"));
1968 }
1969
1970 #[test]
1971 fn test_settings_tab_shows_ttl() {
1972 let mut app = app_with_table();
1973 app.dynamodb_state.current_table = Some("orders".to_string());
1974 app.dynamodb_state.detail_tab = DetailTab::Settings;
1975 app.dynamodb_state.settings = DdbTableSettings {
1976 ttl_status: "Enabled".to_string(),
1977 ttl_attribute: "expires_at".to_string(),
1978 ..Default::default()
1979 };
1980 let content = render_to_string(&app, 200, 40);
1981 assert!(content.contains("Time to Live (TTL)"));
1982 assert!(content.contains("expires_at"));
1983 }
1984
1985 #[test]
1986 fn test_console_url_follows_active_tab() {
1987 let mut app = app_with_table();
1988 app.dynamodb_state.current_table = Some("orders".to_string());
1989 app.dynamodb_state.detail_tab = DetailTab::Indexes;
1990 let url = crate::dynamodb::actions::console_url(&app);
1991 assert!(url.contains("#table?name=orders&tab=indexes"), "{}", url);
1992
1993 app.dynamodb_state.detail_tab = DetailTab::Monitor;
1994 let url = crate::dynamodb::actions::console_url(&app);
1995 assert!(url.contains("tab=monitoring"), "{}", url);
1996
1997 app.dynamodb_state.current_table = None;
1998 let url = crate::dynamodb::actions::console_url(&app);
1999 assert!(url.contains("tab=overview"), "{}", url);
2000 }
2001
2002 #[test]
2003 fn test_monitor_tab_loads_metrics_and_scrolls() {
2004 use crate::ui::monitoring::MonitoringState;
2005 let mut app = app_with_table();
2006 app.dynamodb_state.current_table = Some("orders".to_string());
2007 app.dynamodb_state.detail_tab = DetailTab::Indexes;
2008
2009 app.dynamodb_state.monitoring_scroll = 3;
2011 crate::dynamodb::actions::next_detail_tab(&mut app);
2012 assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Monitor);
2013 assert!(app.dynamodb_state.metrics_loading);
2014 assert_eq!(app.dynamodb_state.monitoring_scroll, 0);
2015
2016 app.dynamodb_state.set_metrics_loading(false);
2018 for _ in 0..MONITORING_CHARTS + 2 {
2019 crate::dynamodb::actions::scroll_down_detail(&mut app);
2020 }
2021 assert_eq!(app.dynamodb_state.monitoring_scroll, MONITORING_CHARTS - 1);
2022 for _ in 0..MONITORING_CHARTS + 2 {
2023 crate::dynamodb::actions::scroll_up_detail(&mut app);
2024 }
2025 assert_eq!(app.dynamodb_state.monitoring_scroll, 0);
2026 }
2027
2028 #[test]
2029 fn test_monitor_tab_renders_charts() {
2030 let mut app = app_with_table();
2031 app.dynamodb_state.current_table = Some("orders".to_string());
2032 app.dynamodb_state.detail_tab = DetailTab::Monitor;
2033 app.dynamodb_state.metrics = rusticity_core::dynamodb::DdbMonitoringData {
2034 consumed_read_capacity: vec![(0, 1.0), (60, 2.0)],
2035 consumed_write_capacity: vec![(0, 0.5)],
2036 ..Default::default()
2037 };
2038 let content = render_to_string(&app, 200, 60);
2039 assert!(content.contains("Read capacity"));
2040 assert!(content.contains("Write capacity"));
2041
2042 app.dynamodb_state.metrics_loading = true;
2044 let content = render_to_string(&app, 200, 60);
2045 assert!(content.contains("Loading metrics"));
2046 }
2047
2048 #[test]
2049 fn test_items_loading_pane_names_the_table() {
2050 let mut app = app_with_table();
2051 app.dynamodb_state.current_table = Some("orders".to_string());
2052 app.dynamodb_state.detail_tab = DetailTab::Items;
2053 app.dynamodb_state.items_loading = true;
2054 let content = render_to_string(&app, 200, 40);
2055 assert!(content.contains("─ orders ─"));
2058 assert!(!content.contains("Items returned"));
2059 assert!(content.contains("Loading items"));
2060 }
2061
2062 #[test]
2063 fn test_items_tab_is_first_and_hides_summary() {
2064 assert_eq!(DetailTab::ALL[0], DetailTab::Items);
2065
2066 let mut app = app_with_table();
2067 app.dynamodb_state.current_table = Some("orders".to_string());
2068 app.dynamodb_state.detail_tab = DetailTab::Items;
2069 let content = render_to_string(&app, 200, 40);
2070 assert!(content.contains("Items"));
2072 assert!(content.contains("Settings"));
2073 assert!(!content.contains("Summary"));
2074
2075 app.dynamodb_state.detail_tab = DetailTab::Settings;
2077 let content = render_to_string(&app, 200, 40);
2078 assert!(content.contains("Summary"));
2079 }
2080
2081 #[test]
2082 fn test_items_filter_row_controls_and_defaults() {
2083 let mut app = app_with_table();
2084 app.dynamodb_state.current_table = Some("orders".to_string());
2085 app.dynamodb_state.detail_tab = DetailTab::Items;
2086 let content = render_to_string(&app, 200, 40);
2087 assert!(content.contains("Scan"));
2089 assert!(content.contains("[Table] orders"));
2090 assert!(content.contains("All attributes"));
2091
2092 assert_eq!(
2094 ITEMS_FILTER_CONTROLS,
2095 [
2096 ITEM_OPERATION,
2097 ITEM_INDEX,
2098 ITEM_PROJECTION,
2099 InputFocus::Filter,
2100 InputFocus::Pagination,
2101 ]
2102 );
2103 assert_eq!(app.dynamodb_state.item_operation, ItemOperation::Scan);
2104 assert_eq!(
2105 app.dynamodb_state.item_projection,
2106 ItemProjection::AllAttributes
2107 );
2108 }
2109
2110 #[test]
2111 fn test_open_dropdown_lists_every_option() {
2112 let mut app = app_with_table();
2113 app.dynamodb_state.current_table = Some("orders".to_string());
2114 app.dynamodb_state.detail_tab = DetailTab::Items;
2115
2116 app.dynamodb_state.input_focus = ITEM_OPERATION;
2118 let content = render_to_string(&app, 200, 40);
2119 assert!(content.contains("Scan"));
2120 assert!(content.contains("Query"));
2121
2122 app.dynamodb_state.input_focus = ITEM_INDEX;
2124 let content = render_to_string(&app, 200, 40);
2125 assert!(content.contains("[Table] orders"));
2126 assert!(content.contains("customer-index"));
2127
2128 app.dynamodb_state.input_focus = ITEM_PROJECTION;
2130 let content = render_to_string(&app, 200, 40);
2131 assert!(content.contains("All attributes"));
2132 assert!(content.contains("Keys only"));
2133 }
2134
2135 #[test]
2136 fn test_dropdowns_cycle_and_schedule_a_reload() {
2137 let mut app = app_with_table();
2138 app.dynamodb_state.current_table = Some("orders".to_string());
2139 app.dynamodb_state.detail_tab = DetailTab::Items;
2140
2141 app.dynamodb_state.input_focus = ITEM_OPERATION;
2142 crate::dynamodb::actions::next_dropdown_value(&mut app);
2143 assert_eq!(app.dynamodb_state.item_operation, ItemOperation::Query);
2144 assert!(!app.dynamodb_state.items_loading);
2146 crate::dynamodb::actions::prev_dropdown_value(&mut app);
2147 assert_eq!(app.dynamodb_state.item_operation, ItemOperation::Scan);
2148 assert!(app.dynamodb_state.items_loading);
2149
2150 app.dynamodb_state.input_focus = ITEM_INDEX;
2152 crate::dynamodb::actions::next_dropdown_value(&mut app);
2153 assert_eq!(app.dynamodb_state.item_index, 1);
2154 crate::dynamodb::actions::next_dropdown_value(&mut app);
2155 assert_eq!(app.dynamodb_state.item_index, 0);
2156
2157 app.dynamodb_state.input_focus = ITEM_PROJECTION;
2158 crate::dynamodb::actions::next_dropdown_value(&mut app);
2159 assert_eq!(app.dynamodb_state.item_projection, ItemProjection::KeysOnly);
2160 }
2161
2162 #[test]
2163 fn test_items_render_dynamic_attribute_columns() {
2164 let mut app = app_with_table();
2165 app.dynamodb_state.current_table = Some("orders".to_string());
2166 app.dynamodb_state.detail_tab = DetailTab::Items;
2167 let mut item = DdbItem::default();
2168 item.attributes
2169 .insert("order_id".to_string(), "o1".to_string());
2170 item.attributes
2171 .insert("created_at".to_string(), "1".to_string());
2172 item.attributes
2173 .insert("status".to_string(), "SHIPPED".to_string());
2174 app.dynamodb_state.items.items = vec![item];
2175
2176 let content = render_to_string(&app, 200, 40);
2177 assert!(content.contains("─ orders ─"));
2179 assert!(!content.contains("Items returned"));
2180 assert!(content.contains("order_id"));
2181 assert!(content.contains("SHIPPED"));
2182
2183 app.dynamodb_state.tables.filter = "nomatch".to_string();
2186 assert_eq!(filtered_items(&app).len(), 1);
2187 app.dynamodb_state.item_filter = "shipped".to_string();
2188 assert_eq!(filtered_items(&app).len(), 1);
2189 app.dynamodb_state.item_filter = "nomatch".to_string();
2190 assert_eq!(filtered_items(&app).len(), 0);
2191 }
2192
2193 #[test]
2194 fn test_item_column_names_are_keys_first_then_sorted() {
2195 let mut app = app_with_table();
2196 app.dynamodb_state.current_table = Some("orders".to_string());
2197 app.dynamodb_state.detail_tab = DetailTab::Items;
2198 app.dynamodb_state.items.items = vec![
2199 item_with(&[("status", "SHIPPED"), ("order_id", "o1")]),
2200 item_with(&[("amount", "12"), ("created_at", "1"), ("order_id", "o2")]),
2201 ];
2202
2203 let t = current_table(&app).unwrap();
2204 assert_eq!(
2207 item_column_names(t, &app),
2208 vec!["order_id", "created_at", "amount", "status"]
2209 );
2210 }
2211
2212 #[test]
2213 fn test_items_preferences_lists_the_tables_own_columns() {
2214 let mut app = app_with_table();
2215 app.dynamodb_state.current_table = Some("orders".to_string());
2216 app.dynamodb_state.detail_tab = DetailTab::Items;
2217 app.dynamodb_state.items.items = vec![item_with(&[
2218 ("order_id", "o1"),
2219 ("created_at", "1"),
2220 ("status", "SHIPPED"),
2221 ])];
2222
2223 assert_eq!(crate::dynamodb::actions::column_count(&app), 3);
2225 assert_eq!(crate::dynamodb::actions::column_selector_max(&app), 9);
2226
2227 app.mode = crate::keymap::Mode::ColumnSelector;
2228 let content = render_to_string(&app, 200, 40);
2229 assert!(content.contains("Preferences"));
2230 assert!(content.contains("status"));
2231 assert!(content.contains("Page size"));
2232 assert!(!content.contains("Replication Regions"));
2234 assert!(!content.contains("Table class"));
2235 }
2236
2237 #[test]
2238 fn test_toggling_an_item_column_hides_it_but_keeps_one_visible() {
2239 use crate::dynamodb::actions::toggle_column;
2240 let mut app = app_with_table();
2241 app.dynamodb_state.current_table = Some("orders".to_string());
2242 app.dynamodb_state.detail_tab = DetailTab::Items;
2243 app.dynamodb_state.items.items = vec![item_with(&[
2244 ("order_id", "o1"),
2245 ("created_at", "1"),
2246 ("status", "SHIPPED"),
2247 ])];
2248 let names = |app: &App| item_visible_column_names(current_table(app).unwrap(), app);
2249 assert_eq!(names(&app), vec!["order_id", "created_at", "status"]);
2250
2251 app.column_selector_index = 3;
2253 toggle_column(&mut app);
2254 assert_eq!(app.dynamodb_state.item_hidden_columns, vec!["status"]);
2255 assert_eq!(names(&app), vec!["order_id", "created_at"]);
2256 let content = render_to_string(&app, 200, 40);
2257 assert!(!content.contains("SHIPPED"));
2258
2259 toggle_column(&mut app);
2261 assert!(app.dynamodb_state.item_hidden_columns.is_empty());
2262 assert_eq!(names(&app), vec!["order_id", "created_at", "status"]);
2263
2264 for idx in [1, 2, 3] {
2266 app.column_selector_index = idx;
2267 toggle_column(&mut app);
2268 }
2269 assert_eq!(app.dynamodb_state.item_hidden_columns.len(), 2);
2270 assert_eq!(names(&app).len(), 1);
2271 }
2272
2273 #[test]
2274 fn test_items_preferences_page_size_is_actionable() {
2275 use crate::common::PageSize;
2276 use crate::dynamodb::actions::toggle_column;
2277 let mut app = app_with_table();
2278 app.dynamodb_state.current_table = Some("orders".to_string());
2279 app.dynamodb_state.detail_tab = DetailTab::Items;
2280 app.dynamodb_state.items.items = vec![item_with(&[("order_id", "o1")])];
2281
2282 assert_eq!(crate::dynamodb::actions::column_count(&app), 2);
2284 app.column_selector_index = 7;
2285 toggle_column(&mut app);
2286 assert_eq!(app.dynamodb_state.items.page_size, PageSize::Fifty);
2287
2288 app.column_selector_index = 5;
2289 toggle_column(&mut app);
2290 assert_eq!(app.dynamodb_state.items.page_size, PageSize::Ten);
2291 }
2292
2293 #[test]
2294 fn test_query_needs_a_partition_key_value() {
2295 let mut app = app_with_table();
2296 app.dynamodb_state.current_table = Some("orders".to_string());
2297 app.dynamodb_state.detail_tab = DetailTab::Items;
2298 app.dynamodb_state.item_operation = ItemOperation::Query;
2299
2300 let content = render_to_string(&app, 200, 40);
2301 assert!(content.contains("─ orders ─"));
2302 assert!(content.contains("to run the query"));
2303 assert!(!needs_items_load(&app));
2304
2305 app.dynamodb_state.item_filter = "o1".to_string();
2307 assert!(needs_items_load(&app));
2308 app.dynamodb_state.executed_query = "o1".to_string();
2309 assert!(!needs_items_load(&app));
2310
2311 app.dynamodb_state.item_filter = "o2".to_string();
2313 app.mode = crate::keymap::Mode::FilterInput;
2314 assert!(!needs_items_load(&app));
2315 }
2316
2317 #[test]
2318 fn test_key_attribute_helpers() {
2319 assert_eq!(key_attribute_name("order_id (S)"), "order_id");
2320 assert_eq!(key_attribute_name("order_id"), "order_id");
2321 assert_eq!(key_attribute_type("created_at (N)"), "N");
2322 assert_eq!(key_attribute_type("order_id"), "S");
2323
2324 let t = &app_with_table().dynamodb_state.tables.items[0].clone();
2325 assert_eq!(
2326 selected_key_attributes(t, 0),
2327 vec!["order_id".to_string(), "created_at".to_string()]
2328 );
2329 assert_eq!(
2330 selected_key_attributes(t, 1),
2331 vec!["customer_id".to_string()]
2332 );
2333 assert_eq!(selected_item_index(t, 0), None);
2334 assert_eq!(selected_item_index(t, 1).as_deref(), Some("customer-index"));
2335
2336 assert_eq!(key_attribute_type(&selected_partition_key_label(t, 0)), "S");
2338 assert_eq!(key_attribute_type(&selected_partition_key_label(t, 1)), "S");
2339 let mut numeric = t.clone();
2340 numeric.partition_key = "seq (N)".to_string();
2341 assert_eq!(
2342 key_attribute_type(&selected_partition_key_label(&numeric, 0)),
2343 "N"
2344 );
2345 }
2346
2347 #[test]
2348 fn test_tab_keys_reach_items_and_flag_a_load() {
2349 use crate::keymap::Action;
2350 let mut app = app_with_table();
2351 crate::dynamodb::actions::select_item(&mut app);
2352 assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Items);
2353
2354 app.handle_action(Action::NextDetailTab);
2356 assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Settings);
2357
2358 app.dynamodb_state.items_loading = false;
2360 app.handle_action(Action::PrevDetailTab);
2361 assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Items);
2362 assert!(app.dynamodb_state.items_loading);
2363 assert!(needs_items_load(&app));
2364 }
2365
2366 #[test]
2367 fn test_breadcrumb() {
2368 let mut app = app_with_table();
2369 assert_eq!(
2370 crate::dynamodb::actions::breadcrumb(&app),
2371 vec!["DynamoDB".to_string(), "Tables".to_string()]
2372 );
2373 app.dynamodb_state.current_table = Some("orders".to_string());
2374 assert_eq!(
2375 crate::dynamodb::actions::breadcrumb(&app),
2376 vec!["DynamoDB".to_string(), "orders".to_string()]
2377 );
2378 }
2379}