Introduction: Building Your First TUI Monitoring Dashboard

Welcome to Chapter 15! So far, we’ve explored the foundational elements of Ratatui, from basic widgets and layouts to event handling. Now, it’s time to put all that knowledge into action by building a practical, real-world application: a system monitoring dashboard.

In this chapter, you’ll learn how to create an interactive terminal user interface that displays real-time system metrics like CPU and memory usage. This project will solidify your understanding of Ratatui’s layout system, state management, and event loops, while also introducing you to integrating external Rust crates for system information. By the end, you’ll have a functional TUI dashboard and a deeper appreciation for how all the pieces fit together to create a dynamic terminal application.

Before we dive in, make sure you’re comfortable with:

  • Ratatui’s Layout system (covered in Chapter 7).
  • Basic widgets like Block, Paragraph, and Gauge (Chapters 5 & 6).
  • The crossterm backend and event handling (Chapter 8).
  • Managing application state (Chapter 9).

Let’s get cooking!

Core Concepts: Bringing Data to Your TUI

Building a monitoring dashboard involves three main conceptual pillars:

  1. Gathering System Metrics: How do we get information about the CPU, memory, and other system resources?
  2. Structuring the UI: How do we arrange multiple pieces of information on the terminal screen?
  3. Real-time Updates: How do we ensure the data displayed is fresh and responsive?

1. Gathering System Metrics with sysinfo

For obtaining system-level information in Rust, the sysinfo crate is an excellent, cross-platform choice. It provides a straightforward API to query CPU usage, memory consumption, disk I/O, network activity, and more.

Why sysinfo? It abstracts away the complexities of interacting with different operating system APIs (like /proc on Linux or Windows Management Instrumentation) and provides a unified, Rust-friendly interface. This means your monitoring dashboard will work on Linux, macOS, and Windows without needing platform-specific code.

We’ll use sysinfo to get:

  • Overall CPU usage percentage.
  • Total and used memory in bytes.

2. Structuring the UI: Layouts and Widgets

A monitoring dashboard often presents several pieces of information side-by-side or stacked. This is where Ratatui’s Layout system truly shines. We’ll divide the screen into logical areas using Layout::default().direction(...) and then render specific widgets within each area.

For our dashboard, we’ll aim for a simple layout: a main title, a section for CPU usage, and a section for memory usage. The Block widget will provide borders and titles for each section, while Gauge widgets will visually represent CPU and memory percentages.

3. Real-time Updates: The Event Loop and Timers

A static dashboard isn’t very useful! We need to refresh the displayed data periodically. Our main application loop, which you’re already familiar with, will be modified to:

  • Poll for User Input: Check for keyboard events (e.g., ‘q’ to quit).
  • Refresh System Data: Call sysinfo’s refresh methods at regular intervals.
  • Redraw the UI: Update the Ratatui terminal with the latest data.

We’ll use crossterm::event::poll with a timeout to allow for non-blocking event handling and periodic data refreshes.

Here’s a conceptual flow of our application:

flowchart TD A[Start App] --> B{Initialize TUI and Crossterm} B --> C[Create App State and Sysinfo] C --> D{Main Loop} D --> E{Poll for Events (e.g., 250ms timeout)} E --> F{Event Received?} F -->|Yes| G[Handle Event] F -->|No| H[Update App State] G --> I{Should Quit?} I -->|Yes| J[Restore Terminal, Exit] I -->|No| H H --> K[Draw UI Ratatui] K --> D

This diagram illustrates the continuous cycle of polling for events, updating our application’s data, and then rendering the UI based on that updated data.

Step-by-Step Implementation: Building the Dashboard

Let’s start coding our monitoring dashboard!

Step 1: Project Setup and Dependencies

First, create a new Rust project:

cargo new ratatui-dashboard
cd ratatui-dashboard

Now, open your Cargo.toml file and add the necessary dependencies. We’ll need ratatui for the UI, crossterm for terminal interaction, and sysinfo for system information.

# In your Cargo.toml
[package]
name = "ratatui-dashboard"
version = "0.1.0"
edition = "2021"

[dependencies]
ratatui = "0.26.0" # Check crates.io for the absolute latest stable version
crossterm = "0.27.0" # Check crates.io for the absolute latest stable version
sysinfo = "0.30.0" # Check crates.io for the absolute latest stable version

Why these versions? As of March 2026, ratatui is a rapidly evolving library, and 0.26.0 (or similar) represents a stable, feature-rich release. crossterm 0.27.0 is its common and robust backend. sysinfo 0.30.0 provides comprehensive system metrics. Always check crates.io for the absolute latest stable versions if you encounter compilation issues with these specific numbers.

Step 2: Basic Application Structure

Let’s start with the boilerplate for a Ratatui application. Open src/main.rs and add the following:

// src/main.rs

use std::{error::Error, io};
use crossterm::{
    event::{self, Event, KeyCode},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::CrosstermBackend,
    Terminal,
};
use sysinfo::{System, SystemExt, CpuExt, RefreshKind}; // New import for sysinfo

/// Represents the application's state.
/// This struct will hold all the data our TUI needs to display.
struct App {
    sys: System,
    should_quit: bool,
}

impl App {
    /// Constructs a new `App`.
    fn new() -> App {
        App {
            sys: System::new_with_specifics(RefreshKind::new().with_cpu().with_memory()),
            should_quit: false,
        }
    }

    /// Handles an incoming event.
    fn handle_event(&mut self, event: &Event) {
        if let Event::Key(key) = event {
            if key.code == KeyCode::Char('q') {
                self.should_quit = true;
            }
        }
    }

    /// Updates the application's state (e.g., refreshes system info).
    fn update(&mut self) {
        // We only refresh CPU and memory, as specified in `new()`
        self.sys.refresh_cpu();
        self.sys.refresh_memory();
    }
}

fn main() -> Result<(), Box<dyn Error>> {
    // 1. Setup terminal
    enable_raw_mode()?;
    execute!(io::stdout(), EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(io::stdout());
    let mut terminal = Terminal::new(backend)?;

    // 2. Create app and run it
    let mut app = App::new();
    let res = run_app(&mut terminal, &mut app);

    // 3. Restore terminal
    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;

    // 4. Handle result
    if let Err(err) = res {
        println!("{:?}", err)
    }

    Ok(())
}

fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> Result<(), Box<dyn Error>> {
    loop {
        // Draw the UI
        terminal.draw(|f| {
            // We'll add drawing logic here later!
            // For now, it's empty.
        })?;

        // Handle events
        if event::poll(std::time::Duration::from_millis(250))? {
            if let Event::Key(key) = event::read()? {
                if key.code == KeyCode::Char('q') {
                    break; // Exit the loop on 'q'
                }
            }
        }

        // Update app state
        app.update();

        // Check if app wants to quit
        if app.should_quit {
            break;
        }
    }
    Ok(())
}

Explanation of changes:

  • use sysinfo::...: We’ve added imports for the sysinfo crate.
  • struct App: Now includes a sys: System field to hold our system information.
  • App::new(): Initializes sysinfo::System. We use System::new_with_specifics and RefreshKind::new().with_cpu().with_memory() to tell sysinfo to only refresh CPU and memory information, which is more efficient than refreshing everything if we don’t need it.
  • App::update(): This method is now responsible for calling self.sys.refresh_cpu() and self.sys.refresh_memory() to get the latest data.
  • run_app loop: The event::poll now has a timeout of 250 milliseconds. This means if no events occur within 250ms, the poll function returns false, and the loop continues to app.update() and terminal.draw(). This ensures our dashboard updates even without user input.

You can try running this now (cargo run), but it will just show an empty screen until you press ‘q’.

Step 3: Drawing the Dashboard Layout

Now, let’s create our UI. We’ll divide the screen into three horizontal sections: a header, a CPU section, and a Memory section.

Modify the terminal.draw closure inside run_app:

// Inside run_app, replace the empty terminal.draw closure:

        terminal.draw(|f| {
            use ratatui::{
                layout::{Constraint, Direction, Layout},
                style::{Color, Style},
                widgets::{Block, Borders, Paragraph, Gauge},
            }; // Added necessary imports inside the closure for clarity

            let size = f.size();

            // Divide the screen into a header and two main sections
            let chunks = Layout::default()
                .direction(Direction::Vertical)
                .constraints([
                    Constraint::Length(3), // Header (3 lines tall)
                    Constraint::Min(0),    // Main content area
                ])
                .split(size);

            // Further divide the main content area for CPU and Memory
            let main_chunks = Layout::default()
                .direction(Direction::Horizontal)
                .constraints([
                    Constraint::Percentage(50), // CPU section
                    Constraint::Percentage(50), // Memory section
                ])
                .split(chunks[1]); // Split the second chunk from the first layout

            // 1. Header Block
            let header_block = Block::default()
                .borders(Borders::ALL)
                .style(Style::default().fg(Color::LightCyan))
                .title(" Ratatui System Monitor ");
            f.render_widget(header_block, chunks[0]);

            // 2. CPU Block
            let cpu_block = Block::default()
                .borders(Borders::ALL)
                .style(Style::default().fg(Color::Green))
                .title(" CPU Usage ");
            f.render_widget(cpu_block, main_chunks[0]);

            // 3. Memory Block
            let mem_block = Block::default()
                .borders(Borders::ALL)
                .style(Style::default().fg(Color::Yellow))
                .title(" Memory Usage ");
            f.render_widget(mem_block, main_chunks[1]);
        })?; // End of terminal.draw closure

Explanation:

  • We bring in Layout, Constraint, Direction, Block, Borders, Paragraph, Gauge (though Paragraph and Gauge aren’t used yet, they’re common for dashboards).
  • Layout::default().direction(Direction::Vertical): We first split the screen vertically.
    • The Constraint::Length(3) makes the top chunk exactly 3 lines tall for our header.
    • Constraint::Min(0) makes the remaining space take up the rest of the screen.
  • Layout::default().direction(Direction::Horizontal): We then take the second chunk (chunks[1], which is the main content area) and split it horizontally into two equal halves (Constraint::Percentage(50)).
  • We create Block widgets for the header, CPU, and Memory sections, giving them borders and titles. We also apply different foreground colors for visual distinction.
  • f.render_widget(): This function takes a widget and an area (a Rect) and draws the widget within that area.

Run cargo run now. You should see a terminal with three bordered sections: “Ratatui System Monitor”, “CPU Usage”, and “Memory Usage”. Press ‘q’ to quit.

Step 4: Displaying CPU and Memory Usage with Gauge

Now that we have our layout, let’s fill it with actual data! We’ll use the Gauge widget for a visual representation of usage.

Inside the terminal.draw closure, after rendering the cpu_block and mem_block, add the following code:

// ... (previous code for layout and blocks)

            // --- CPU Gauge ---
            let cpu_usage = app.sys.global_cpu_info().cpu_usage();
            let cpu_gauge = Gauge::default()
                .block(Block::default().borders(Borders::NONE)) // No extra borders, just content
                .gauge_style(Style::default().fg(Color::Green).bg(Color::DarkGray))
                .percent(cpu_usage as u16) // Gauge expects u16 percentage
                .label(format!("{:.1}%", cpu_usage)); // Display the exact percentage as a label

            // Calculate the inner area for the CPU gauge (inside the CPU block)
            let cpu_inner_area = main_chunks[0].inner(&ratatui::layout::Margin {
                vertical: 1,
                horizontal: 1,
            });
            f.render_widget(cpu_gauge, cpu_inner_area);


            // --- Memory Gauge ---
            let total_memory = app.sys.total_memory();
            let used_memory = app.sys.used_memory();
            let mem_percentage = if total_memory > 0 {
                (used_memory as f64 / total_memory as f64 * 100.0) as u16
            } else {
                0
            };
            let mem_label = format!("Used: {}MB / Total: {}MB ({:.1}%)",
                                    used_memory / 1024 / 1024, // Convert bytes to MB
                                    total_memory / 1024 / 1024,
                                    mem_percentage as f64);

            let mem_gauge = Gauge::default()
                .block(Block::default().borders(Borders::NONE))
                .gauge_style(Style::default().fg(Color::Yellow).bg(Color::DarkGray))
                .percent(mem_percentage)
                .label(mem_label);

            // Calculate the inner area for the Memory gauge
            let mem_inner_area = main_chunks[1].inner(&ratatui::layout::Margin {
                vertical: 1,
                horizontal: 1,
            });
            f.render_widget(mem_gauge, mem_inner_area);

Explanation:

  • CPU Usage:
    • app.sys.global_cpu_info().cpu_usage(): Retrieves the overall CPU usage percentage (as a f32).
    • Gauge::default().percent(cpu_usage as u16): Creates a Gauge widget and sets its fill percentage. We cast f32 to u16 as percent expects u16.
    • .label(format!("{:.1}%", cpu_usage)): Adds a text label to the gauge, showing the precise percentage.
    • main_chunks[0].inner(...): This is a crucial trick! We want the gauge to be inside the CPU block, not overlap its borders. The inner() method with Margin shrinks a Rect by the specified amount, giving us a smaller Rect to render the gauge into.
  • Memory Usage:
    • app.sys.total_memory() and app.sys.used_memory(): Get total and used memory in bytes.
    • We calculate the mem_percentage and handle the division to avoid errors if total_memory is zero.
    • The mem_label is formatted to show both MB and percentage for more detail.
    • Similar to CPU, mem_inner_area ensures the gauge is rendered neatly within its block.

Run cargo run again! You should now see a live, updating dashboard showing your CPU and memory usage. The gauges will animate and the percentages will change in real-time. This is exciting! Press ‘q’ to quit.

Step 5: Enhancing the Header with a Paragraph

Let’s add a bit more information to our header, perhaps the current date and time. We can use a Paragraph widget for this.

Inside the terminal.draw closure, after rendering the header_block, add the following:

// ... (previous code for header_block)

            // Header Paragraph (inside the header block)
            let now = chrono::Local::now(); // Requires `chrono` crate
            let datetime_str = now.format("%Y-%m-%d %H:%M:%S").to_string();

            let header_paragraph = Paragraph::new(format!("Current Time: {}", datetime_str))
                .style(Style::default().fg(Color::White))
                .alignment(ratatui::layout::Alignment::Center);

            let header_inner_area = chunks[0].inner(&ratatui::layout::Margin {
                vertical: 1,
                horizontal: 1,
            });
            f.render_widget(header_paragraph, header_inner_area);

// ... (rest of the drawing logic)

Wait! The chrono crate is not yet in our Cargo.toml. Let’s add it:

# In your Cargo.toml, under [dependencies]
chrono = "0.4.34" # Check crates.io for the absolute latest stable version, with "local-tz" feature

We need the local-tz feature for chrono::Local::now(). So, it should be:

# In your Cargo.toml, under [dependencies]
chrono = { version = "0.4.34", features = ["local-tz"] } # Check crates.io for the absolute latest stable version

Explanation:

  • We use the chrono crate (a popular date/time library in Rust) to get the current local time.
  • Paragraph::new(...): Creates a text widget.
  • .style(...) and .alignment(...): Style the text to be white and centered.
  • Again, header_inner_area ensures the paragraph is drawn inside the block’s borders.

Run cargo run again. Now your header will display the current date and time, updating every 250ms along with the system metrics!

Mini-Challenge: Extend the Dashboard

You’ve built a solid foundation for a monitoring dashboard! Now, let’s try to add another piece of information.

Challenge: Add a section to display the hostname of the system.

Hints:

  1. The sysinfo crate has a SystemExt::host_name() method.
  2. You’ll need to adjust your Layout to accommodate this new section. Perhaps you could make the CPU and Memory sections vertical, and then add the hostname below them, or create a third column.
  3. A Paragraph widget will be suitable for displaying the hostname.
  4. Remember to use inner() with Margin to place your Paragraph inside its Block.

What to Observe/Learn: This challenge will reinforce your understanding of Layout manipulation, accessing sysinfo data, and rendering text with Paragraph. Don’t be afraid to experiment with different layout configurations!

Common Pitfalls & Troubleshooting

  1. “Application doesn’t update, or updates too slowly”:
    • Cause: The event::poll timeout is too long, or app.update() is not being called frequently enough.
    • Fix: Ensure event::poll has a reasonable timeout (e.g., 100-500ms). Verify that app.update() is called in every iteration of the main loop.
  2. “UI elements overlap or are not positioned correctly”:
    • Cause: Incorrect Layout constraints, or widgets being rendered into the wrong Rect areas.
    • Fix: Carefully review your Layout::default().constraints(...) setup. Use f.render_widget(widget, area) with the correct area (e.g., chunks[0], main_chunks[1]). Remember to use area.inner(...) to get a sub-rectangle for content inside a bordered block.
  3. “Missing chrono or sysinfo functionality”:
    • Cause: You forgot to add the dependency to Cargo.toml, or you missed a required feature (like local-tz for chrono).
    • Fix: Double-check your Cargo.toml for all required crates and their features. Run cargo clean && cargo build to ensure all dependencies are fetched and compiled correctly.
  4. “Terminal not restoring correctly on exit”:
    • Cause: The disable_raw_mode() or LeaveAlternateScreen calls are missed, or an unhandled error prevents them from executing.
    • Fix: Ensure your main function’s setup and teardown logic is robust, especially the execute! calls for terminal restoration. The Result<(), Box<dyn Error>> return type helps ensure cleanup even if run_app fails.

Summary

Congratulations! You’ve successfully built a real-time system monitoring dashboard using Ratatui and sysinfo.

Here are the key takeaways from this chapter:

  • System Metrics Integration: You learned how to use the sysinfo crate to gather CPU and memory usage information from the underlying operating system.
  • Dynamic Layouts: You practiced using Layout with Direction::Vertical and Direction::Horizontal to create a structured dashboard interface.
  • Widget Application: You applied Block for borders and titles, Paragraph for displaying text, and Gauge for visual progress bars of system usage.
  • Real-time Updates: You implemented a robust event loop using crossterm::event::poll with a timeout to refresh data and redraw the UI periodically, creating a live monitoring experience.
  • Application State Management: You saw how to manage your application’s data (like sysinfo::System) within the App struct.

This project demonstrates the power and flexibility of Ratatui for building functional and visually appealing terminal applications.

What’s Next?

In the next chapter, we’ll delve into more advanced topics, perhaps exploring how to handle multiple screens or tabs within a single TUI application, or dive deeper into custom widgets and styling for even more dynamic interfaces. Keep building, keep experimenting!

References


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.