1use 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
23pub struct App {
25 current_tab: usize,
27 should_quit: bool,
29 stats: NodeStats,
31 network_history: Vec<u64>,
33 last_update: Instant,
35}
36
37#[derive(Debug, Clone, Default)]
39struct NodeStats {
40 peer_count: usize,
42 block_count: u64,
44 storage_size: u64,
46 bandwidth_in: u64,
48 bandwidth_out: u64,
49 uptime: u64,
51 pinned_count: usize,
53 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 pub fn new() -> Self {
72 Self::default()
73 }
74
75 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 fn update_stats(&mut self) {
101 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 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 self.network_history.remove(0);
122 self.network_history.push(self.stats.bandwidth_in / 1024);
123 }
124 }
125}
126
127pub async fn run_tui() -> Result<()> {
129 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 let mut app = App::new();
138
139 let res = run_app(&mut terminal, &mut app).await;
141
142 disable_raw_mode()?;
144 execute!(
145 terminal.backend_mut(),
146 LeaveAlternateScreen,
147 DisableMouseCapture
148 )?;
149 terminal.show_cursor()?;
150
151 res
152}
153
154async 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 app.update_stats();
162
163 terminal.draw(|f| ui(f, app))?;
165
166 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
181fn ui(f: &mut Frame, app: &App) {
183 let size = f.area();
184
185 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(f, chunks[0], app);
197
198 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(f, chunks[2], app);
209}
210
211fn 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
230fn 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 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 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 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 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 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
291fn 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 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 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
323fn 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 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 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 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
378fn 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
436fn 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
448fn 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 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 app.current_tab = 3;
506 app.handle_key_event(event::KeyEvent::from(KeyCode::Right));
507 assert_eq!(app.current_tab, 0);
508
509 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}