ipfrs_cli/
tui.rs

1//! Terminal User Interface (TUI) for IPFRS
2//!
3//! Provides an interactive dashboard for monitoring IPFRS node status,
4//! network activity, storage statistics, and more.
5
6use anyhow::Result;
7use crossterm::{
8    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
9    execute,
10    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
11};
12use ratatui::{
13    backend::{Backend, CrosstermBackend},
14    layout::{Alignment, Constraint, Direction, Layout, Rect},
15    style::{Color, Modifier, Style},
16    text::{Line, Span},
17    widgets::{Block, Borders, Gauge, List, ListItem, Paragraph, Sparkline, Tabs, Wrap},
18    Frame, Terminal,
19};
20use std::io;
21use std::time::{Duration, Instant};
22
23/// TUI application state
24pub struct App {
25    /// Current tab index
26    current_tab: usize,
27    /// Whether the app should quit
28    should_quit: bool,
29    /// Node statistics
30    stats: NodeStats,
31    /// Network activity history (for sparkline)
32    network_history: Vec<u64>,
33    /// Last update time
34    last_update: Instant,
35}
36
37/// Node statistics
38#[derive(Debug, Clone, Default)]
39struct NodeStats {
40    /// Peer count
41    peer_count: usize,
42    /// Total blocks stored
43    block_count: u64,
44    /// Total storage size in bytes
45    storage_size: u64,
46    /// Bandwidth in/out (bytes per second)
47    bandwidth_in: u64,
48    bandwidth_out: u64,
49    /// Uptime in seconds
50    uptime: u64,
51    /// Number of pinned items
52    pinned_count: usize,
53    /// DHT routing table size
54    dht_size: usize,
55}
56
57impl Default for App {
58    fn default() -> Self {
59        Self {
60            current_tab: 0,
61            should_quit: false,
62            stats: NodeStats::default(),
63            network_history: vec![0; 60],
64            last_update: Instant::now(),
65        }
66    }
67}
68
69impl App {
70    /// Create a new TUI app
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    /// Handle key events
76    fn handle_key_event(&mut self, key: event::KeyEvent) {
77        match (key.code, key.modifiers) {
78            (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
79                self.should_quit = true;
80            }
81            (KeyCode::Right | KeyCode::Tab, _) => {
82                self.current_tab = (self.current_tab + 1) % 4;
83            }
84            (KeyCode::Left, _) => {
85                self.current_tab = if self.current_tab > 0 {
86                    self.current_tab - 1
87                } else {
88                    3
89                };
90            }
91            (KeyCode::Char('1'), _) => self.current_tab = 0,
92            (KeyCode::Char('2'), _) => self.current_tab = 1,
93            (KeyCode::Char('3'), _) => self.current_tab = 2,
94            (KeyCode::Char('4'), _) => self.current_tab = 3,
95            _ => {}
96        }
97    }
98
99    /// Update statistics (mock data for now)
100    fn update_stats(&mut self) {
101        // In a real implementation, this would fetch data from the IPFRS node
102        // For now, we'll use mock data that changes over time
103
104        let elapsed = self.last_update.elapsed();
105        if elapsed >= Duration::from_secs(1) {
106            self.stats.uptime += elapsed.as_secs();
107            self.last_update = Instant::now();
108
109            // Simulate changing stats
110            use std::time::SystemTime;
111            let seed = SystemTime::now()
112                .duration_since(SystemTime::UNIX_EPOCH)
113                .unwrap()
114                .as_secs();
115
116            self.stats.peer_count = ((seed % 10) + 5) as usize;
117            self.stats.bandwidth_in = (seed % 1000) * 1024;
118            self.stats.bandwidth_out = (seed % 500) * 1024;
119
120            // Update network history
121            self.network_history.remove(0);
122            self.network_history.push(self.stats.bandwidth_in / 1024);
123        }
124    }
125}
126
127/// Run the TUI application
128pub async fn run_tui() -> Result<()> {
129    // Setup terminal
130    enable_raw_mode()?;
131    let mut stdout = io::stdout();
132    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
133    let backend = CrosstermBackend::new(stdout);
134    let mut terminal = Terminal::new(backend)?;
135
136    // Create app state
137    let mut app = App::new();
138
139    // Run the main loop
140    let res = run_app(&mut terminal, &mut app).await;
141
142    // Restore terminal
143    disable_raw_mode()?;
144    execute!(
145        terminal.backend_mut(),
146        LeaveAlternateScreen,
147        DisableMouseCapture
148    )?;
149    terminal.show_cursor()?;
150
151    res
152}
153
154/// Main application loop
155async fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()>
156where
157    <B as Backend>::Error: Send + Sync + 'static,
158{
159    loop {
160        // Update stats
161        app.update_stats();
162
163        // Draw UI
164        terminal.draw(|f| ui(f, app))?;
165
166        // Handle events with timeout
167        if event::poll(Duration::from_millis(100))? {
168            if let Event::Key(key) = event::read()? {
169                app.handle_key_event(key);
170            }
171        }
172
173        if app.should_quit {
174            break;
175        }
176    }
177
178    Ok(())
179}
180
181/// Draw the UI
182fn ui(f: &mut Frame, app: &App) {
183    let size = f.area();
184
185    // Create main layout
186    let chunks = Layout::default()
187        .direction(Direction::Vertical)
188        .constraints([
189            Constraint::Length(3),
190            Constraint::Min(0),
191            Constraint::Length(3),
192        ])
193        .split(size);
194
195    // Draw tabs
196    draw_tabs(f, chunks[0], app);
197
198    // Draw content based on current tab
199    match app.current_tab {
200        0 => draw_overview(f, chunks[1], app),
201        1 => draw_network(f, chunks[1], app),
202        2 => draw_storage(f, chunks[1], app),
203        3 => draw_help(f, chunks[1]),
204        _ => {}
205    }
206
207    // Draw footer
208    draw_footer(f, chunks[2], app);
209}
210
211/// Draw the tab bar
212fn draw_tabs(f: &mut Frame, area: Rect, app: &App) {
213    let titles = vec!["Overview", "Network", "Storage", "Help"];
214    let tabs = Tabs::new(titles)
215        .block(
216            Block::default()
217                .borders(Borders::ALL)
218                .title(" IPFRS Dashboard "),
219        )
220        .select(app.current_tab)
221        .style(Style::default().fg(Color::White))
222        .highlight_style(
223            Style::default()
224                .fg(Color::Yellow)
225                .add_modifier(Modifier::BOLD),
226        );
227    f.render_widget(tabs, area);
228}
229
230/// Draw the overview tab
231fn draw_overview(f: &mut Frame, area: Rect, app: &App) {
232    let chunks = Layout::default()
233        .direction(Direction::Vertical)
234        .constraints([
235            Constraint::Length(3),
236            Constraint::Length(3),
237            Constraint::Length(3),
238            Constraint::Length(3),
239            Constraint::Min(0),
240        ])
241        .split(area);
242
243    // Peer gauge
244    let peer_ratio = app.stats.peer_count as f64 / 50.0;
245    let peer_gauge = Gauge::default()
246        .block(Block::default().borders(Borders::ALL).title(" Peers "))
247        .gauge_style(Style::default().fg(Color::Green))
248        .percent((peer_ratio * 100.0).min(100.0) as u16)
249        .label(format!("{} / 50", app.stats.peer_count));
250    f.render_widget(peer_gauge, chunks[0]);
251
252    // Storage gauge
253    let storage_ratio = app.stats.storage_size as f64 / (10_u64 * 1024 * 1024 * 1024) as f64;
254    let storage_gauge = Gauge::default()
255        .block(Block::default().borders(Borders::ALL).title(" Storage "))
256        .gauge_style(Style::default().fg(Color::Blue))
257        .percent((storage_ratio * 100.0).min(100.0) as u16)
258        .label(format_bytes(app.stats.storage_size));
259    f.render_widget(storage_gauge, chunks[1]);
260
261    // Bandwidth in
262    let bw_in = Paragraph::new(format!(
263        "Incoming: {} /s",
264        format_bytes(app.stats.bandwidth_in)
265    ))
266    .block(Block::default().borders(Borders::ALL).title(" Bandwidth "));
267    f.render_widget(bw_in, chunks[2]);
268
269    // Bandwidth out
270    let bw_out = Paragraph::new(format!(
271        "Outgoing: {} /s",
272        format_bytes(app.stats.bandwidth_out)
273    ));
274    f.render_widget(bw_out, chunks[3]);
275
276    // Summary
277    let uptime_hours = app.stats.uptime / 3600;
278    let uptime_mins = (app.stats.uptime % 3600) / 60;
279    let summary = [
280        format!("Uptime: {}h {}m", uptime_hours, uptime_mins),
281        format!("Blocks: {}", app.stats.block_count),
282        format!("Pinned: {}", app.stats.pinned_count),
283        format!("DHT Size: {}", app.stats.dht_size),
284    ];
285    let summary_widget = Paragraph::new(summary.join("\n"))
286        .block(Block::default().borders(Borders::ALL).title(" Node Info "))
287        .wrap(Wrap { trim: true });
288    f.render_widget(summary_widget, chunks[4]);
289}
290
291/// Draw the network tab
292fn draw_network(f: &mut Frame, area: Rect, app: &App) {
293    let chunks = Layout::default()
294        .direction(Direction::Vertical)
295        .constraints([Constraint::Length(10), Constraint::Min(0)])
296        .split(area);
297
298    // Network activity sparkline
299    let sparkline = Sparkline::default()
300        .block(
301            Block::default()
302                .borders(Borders::ALL)
303                .title(" Network Activity (KB/s) "),
304        )
305        .data(&app.network_history)
306        .style(Style::default().fg(Color::Cyan));
307    f.render_widget(sparkline, chunks[0]);
308
309    // Connected peers list (placeholder)
310    let peers: Vec<ListItem> = vec![
311        ListItem::new("QmPeer1... - /ip4/192.168.1.100/tcp/4001"),
312        ListItem::new("QmPeer2... - /ip4/10.0.0.50/udp/4001/quic-v1"),
313        ListItem::new("QmPeer3... - /ip6/::1/tcp/4001"),
314    ];
315    let peer_list = List::new(peers).block(
316        Block::default()
317            .borders(Borders::ALL)
318            .title(format!(" Connected Peers ({}) ", app.stats.peer_count)),
319    );
320    f.render_widget(peer_list, chunks[1]);
321}
322
323/// Draw the storage tab
324fn draw_storage(f: &mut Frame, area: Rect, app: &App) {
325    let chunks = Layout::default()
326        .direction(Direction::Vertical)
327        .constraints([
328            Constraint::Length(5),
329            Constraint::Length(5),
330            Constraint::Min(0),
331        ])
332        .split(area);
333
334    // Storage breakdown
335    let storage_info = [
336        format!("Total Size: {}", format_bytes(app.stats.storage_size)),
337        format!("Block Count: {}", app.stats.block_count),
338        format!("Pinned Items: {}", app.stats.pinned_count),
339    ];
340    let storage_widget = Paragraph::new(storage_info.join("\n"))
341        .block(
342            Block::default()
343                .borders(Borders::ALL)
344                .title(" Storage Info "),
345        )
346        .wrap(Wrap { trim: true });
347    f.render_widget(storage_widget, chunks[0]);
348
349    // Recent blocks (placeholder)
350    let blocks: Vec<ListItem> = vec![
351        ListItem::new("QmHash1... - 1.2 MB - 2 mins ago"),
352        ListItem::new("QmHash2... - 534 KB - 5 mins ago"),
353        ListItem::new("QmHash3... - 2.1 GB - 10 mins ago"),
354    ];
355    let block_list = List::new(blocks).block(
356        Block::default()
357            .borders(Borders::ALL)
358            .title(" Recent Blocks "),
359    );
360    f.render_widget(block_list, chunks[1]);
361
362    // Cache stats
363    let cache_info = [
364        "Cache Hit Rate: 87.3%",
365        "Cache Size: 100 MB / 256 MB",
366        "Evictions: 1,234",
367    ];
368    let cache_widget = Paragraph::new(cache_info.join("\n"))
369        .block(
370            Block::default()
371                .borders(Borders::ALL)
372                .title(" Cache Stats "),
373        )
374        .wrap(Wrap { trim: true });
375    f.render_widget(cache_widget, chunks[2]);
376}
377
378/// Draw the help tab
379fn draw_help(f: &mut Frame, area: Rect) {
380    let help_text = vec![
381        Line::from(vec![
382            Span::styled(
383                "q",
384                Style::default()
385                    .fg(Color::Yellow)
386                    .add_modifier(Modifier::BOLD),
387            ),
388            Span::raw(" - Quit"),
389        ]),
390        Line::from(vec![
391            Span::styled(
392                "Tab / ←/→",
393                Style::default()
394                    .fg(Color::Yellow)
395                    .add_modifier(Modifier::BOLD),
396            ),
397            Span::raw(" - Switch tabs"),
398        ]),
399        Line::from(vec![
400            Span::styled(
401                "1-4",
402                Style::default()
403                    .fg(Color::Yellow)
404                    .add_modifier(Modifier::BOLD),
405            ),
406            Span::raw(" - Jump to tab"),
407        ]),
408        Line::from(""),
409        Line::from(vec![
410            Span::styled("Overview", Style::default().add_modifier(Modifier::BOLD)),
411            Span::raw(" - Node statistics and gauges"),
412        ]),
413        Line::from(vec![
414            Span::styled("Network", Style::default().add_modifier(Modifier::BOLD)),
415            Span::raw(" - Peer connections and activity"),
416        ]),
417        Line::from(vec![
418            Span::styled("Storage", Style::default().add_modifier(Modifier::BOLD)),
419            Span::raw(" - Block storage and cache stats"),
420        ]),
421        Line::from(""),
422        Line::from("Press Ctrl+C or q to exit the dashboard."),
423    ];
424
425    let help = Paragraph::new(help_text)
426        .block(
427            Block::default()
428                .borders(Borders::ALL)
429                .title(" Help & Keyboard Shortcuts "),
430        )
431        .alignment(Alignment::Left)
432        .wrap(Wrap { trim: true });
433    f.render_widget(help, area);
434}
435
436/// Draw the footer
437fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
438    let footer_text = format!(
439        " IPFRS v0.1.0 | Peers: {} | Blocks: {} | Press 'q' to quit ",
440        app.stats.peer_count, app.stats.block_count
441    );
442    let footer = Paragraph::new(footer_text)
443        .style(Style::default().fg(Color::White).bg(Color::DarkGray))
444        .alignment(Alignment::Center);
445    f.render_widget(footer, area);
446}
447
448/// Format bytes to human-readable string
449fn format_bytes(bytes: u64) -> String {
450    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
451
452    if bytes == 0 {
453        return "0 B".to_string();
454    }
455
456    let mut size = bytes as f64;
457    let mut unit_index = 0;
458
459    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
460        size /= 1024.0;
461        unit_index += 1;
462    }
463
464    if unit_index == 0 {
465        format!("{} {}", bytes, UNITS[unit_index])
466    } else {
467        format!("{:.2} {}", size, UNITS[unit_index])
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn test_format_bytes() {
477        assert_eq!(format_bytes(0), "0 B");
478        assert_eq!(format_bytes(500), "500 B");
479        assert_eq!(format_bytes(1024), "1.00 KB");
480        assert_eq!(format_bytes(1536), "1.50 KB");
481        assert_eq!(format_bytes(1048576), "1.00 MB");
482        assert_eq!(format_bytes(1073741824), "1.00 GB");
483    }
484
485    #[test]
486    fn test_app_creation() {
487        let app = App::new();
488        assert_eq!(app.current_tab, 0);
489        assert!(!app.should_quit);
490        assert_eq!(app.stats.peer_count, 0);
491    }
492
493    #[test]
494    fn test_tab_navigation() {
495        let mut app = App::new();
496
497        // Test right navigation
498        app.handle_key_event(event::KeyEvent::from(KeyCode::Right));
499        assert_eq!(app.current_tab, 1);
500
501        app.handle_key_event(event::KeyEvent::from(KeyCode::Right));
502        assert_eq!(app.current_tab, 2);
503
504        // Test wraparound
505        app.current_tab = 3;
506        app.handle_key_event(event::KeyEvent::from(KeyCode::Right));
507        assert_eq!(app.current_tab, 0);
508
509        // Test left navigation
510        app.handle_key_event(event::KeyEvent::from(KeyCode::Left));
511        assert_eq!(app.current_tab, 3);
512    }
513
514    #[test]
515    fn test_quit_key() {
516        let mut app = App::new();
517
518        app.handle_key_event(event::KeyEvent::from(KeyCode::Char('q')));
519        assert!(app.should_quit);
520    }
521
522    #[test]
523    fn test_direct_tab_selection() {
524        let mut app = App::new();
525
526        app.handle_key_event(event::KeyEvent::from(KeyCode::Char('3')));
527        assert_eq!(app.current_tab, 2);
528
529        app.handle_key_event(event::KeyEvent::from(KeyCode::Char('1')));
530        assert_eq!(app.current_tab, 0);
531    }
532}