Input & UX

Live Dashboards with Rich Live in Python CLIs

Show progress across many tasks with a Rich Live table that redraws in place, falls back to one log line per change without a terminal, and is fully testable.

Updated

A deploy that rolls out across five regions, a batch of twenty migrations, a test run over a dozen services: when several things progress at once, a single progress bar is not enough and a scrolling log is too much. What people want is a small table that updates in place — one row per task with its state, progress and elapsed time — and that stays on screen as a summary when everything is done. Rich's Live display does exactly that inside an ordinary command, without a full-screen TUI. This guide builds such a dashboard, keeps rendering separate from data so it is testable, makes it fall back to plain log lines when there is no terminal, and covers the details that make live displays behave: refresh rates, printing while live, and Ctrl+C. It belongs to the interactive terminal UI with Rich topic.

Prerequisites

How Live works

A Live display loop A Live context holds a renderable, the program fetches new data periodically, builds a new renderable and updates the Live display, which redraws in place. A Live display loop with Live(...) owns the region Fetch data every N seconds Build table from data live.update() redraw in place start render refresh Build a fresh renderable each tick; do not mutate one that is being drawn.

Live takes over a region of the terminal — not the whole screen — and redraws a renderable (a table, a panel, a group of them) in that region, several times a second. Your code fetches new data, builds a new renderable and hands it to live.update(). When the with block ends, the last frame stays in the scrollback as a permanent summary (unless transient=True, which erases it). Everything printed through the live console appears above the display, so log messages and the dashboard coexist.

The recipe

# src/mytool/dashboard.py
from __future__ import annotations

import sys
import time
from collections.abc import Callable
from dataclasses import dataclass

from rich.console import Console
from rich.live import Live
from rich.table import Table


@dataclass(frozen=True)
class RegionState:
    region: str
    state: str          # waiting | rolling | done | failed
    done: int
    total: int
    elapsed: float | None


STYLE = {"waiting": "dim", "rolling": "yellow", "done": "green", "failed": "bold red"}


def render(states: list[RegionState]) -> Table:
    """Pure function: data in, renderable out. Easy to test and reuse."""
    table = Table("REGION", "STATE", "PROGRESS", "ELAPSED", box=None, header_style="bold")
    for s in states:
        elapsed = "-" if s.elapsed is None else f"{int(s.elapsed // 60)}:{int(s.elapsed % 60):02d}"
        table.add_row(s.region, f"[{STYLE[s.state]}]{s.state}[/]", f"{s.done}/{s.total}", elapsed)
    return table


def finished(states: list[RegionState]) -> bool:
    return all(s.state in ("done", "failed") for s in states)


def watch(fetch: Callable[[], list[RegionState]], *, interval: float = 1.0,
          console: Console | None = None, sleep: Callable[[float], None] = time.sleep) -> list[RegionState]:
    """Redraw in place on a terminal; print one line per change everywhere else."""
    console = console or Console()
    states = fetch()
    if console.is_terminal:
        with Live(render(states), console=console, refresh_per_second=4, transient=False) as live:
            while not finished(states):
                sleep(interval)
                states = fetch()
                live.update(render(states))
        return states
    last: dict[str, str] = {}
    while True:
        for s in states:
            if last.get(s.region) != s.state:
                print(f"{s.region}: {s.state} ({s.done}/{s.total})", file=sys.stderr, flush=True)
                last[s.region] = s.state
        if finished(states):
            return states
        sleep(interval)
        states = fetch()

A command calls it with a function that returns the current state — polling an API, or reading results collected by worker threads:

import typer

from mytool.dashboard import watch

app = typer.Typer()


@app.command()
def deploy(all_regions: bool = typer.Option(False, "--all-regions")) -> None:
    """Deploy and show progress per region."""
    job = start_rollout(all_regions)                 # returns a handle with .status()
    final = watch(job.status, interval=2.0)
    failed = [s.region for s in final if s.state == "failed"]
    raise typer.Exit(1 if failed else 0)

Why it is built this way

Render is a pure function. render(states) turns data into a Table and does nothing else. Building a fresh renderable per tick avoids mutating a table while Rich is drawing it, and makes the visual logic testable without a terminal.

One code path decides the mode. console.is_terminal is false in pipes, CI logs and files. There, a live display would write a stream of redraws; instead the dashboard prints one line per state change, which is exactly the information someone reading a log needs. The broader rules are in detecting CI environments and non-interactive shells.

Refresh rate is decoupled from polling. refresh_per_second=4 controls redrawing (spinners and elapsed times stay smooth); interval controls how often data is fetched — usually much less often, to be kind to the API.

Sleep is injected. Tests pass a no-op sleep and a scripted fetch function, so the whole loop runs in milliseconds.

A deploy dashboard, redrawn in place Terminal output of a live dashboard showing deployment progress for several regions with states and elapsed time. A deploy dashboard, redrawn in place bash $ mytool deploy --all-regions --watch REGION STATE PROGRESS ELAPSED eu-west-1 done 10/10 0:41 us-east-1 rolling 6/10 0:33 ap-south-1 waiting 0/10 - In CI the same command prints one line per state change instead.

Live display or full TUI?

Live display or full TUI? A comparison of Rich Live displays and Textual applications for dashboard-style output by interactivity, complexity and where they run. Live display or full TUI? Aspect Rich Live Textual app Input none (Ctrl+C to stop) keys, mouse, focus Code a loop and a table an app, widgets, CSS Screen a region of scrollback the whole terminal Fits watching progress browsing and acting If the user only watches, Live is enough and a fraction of the code.

If the user only watches, Live is enough and a fraction of the code of a TUI. The moment they need to act on the display — select a row, cancel a task, filter — a Textual application is the better tool; see building terminal UIs with Textual.

UX considerations

  • Leave a summary. With transient=False, the final table remains after the command finishes — a free report of what happened. Use transient=True only for purely transitional displays.
  • Print through the live console. live.console.print("warning: ...") puts messages above the table. Bare print() while a Live is active corrupts the display.
  • Keep the table short. A live region taller than the terminal cannot be redrawn in place and degrades into scrolling. For many tasks, show the active and failed ones plus a summary line ("37 done, 2 failed, 11 waiting").
  • Handle Ctrl+C. Leaving the with block — including through KeyboardInterrupt — stops the display cleanly and leaves the last frame. Catch the interrupt in the command to report what was still running and exit 130.
  • Colour is a hint, not the message. The state column says "failed", not just red; see respecting NO_COLOR and FORCE_COLOR.

Testing the behaviour

With rendering pure and fetch and sleep injected, every behaviour is testable: the table's content, the live loop running to completion, and the non-terminal fallback printing one line per change:

# tests/test_dashboard.py
import io

from rich.console import Console

from mytool.dashboard import RegionState, render, watch


def scripted(*frames):
    it = iter(frames)
    last = []

    def fetch():
        nonlocal last
        last = next(it, last)
        return last
    return fetch


F1 = [RegionState("eu-west-1", "rolling", 3, 10, 20.0), RegionState("us-east-1", "waiting", 0, 10, None)]
F2 = [RegionState("eu-west-1", "done", 10, 10, 41.0), RegionState("us-east-1", "rolling", 6, 10, 33.0)]
F3 = [RegionState("eu-west-1", "done", 10, 10, 41.0), RegionState("us-east-1", "done", 10, 10, 52.0)]


def text_of(renderable) -> str:
    console = Console(file=io.StringIO(), width=80, color_system=None)
    console.print(renderable)
    return console.file.getvalue()


def test_render_is_plain_data_to_table():
    out = text_of(render(F2))
    assert "eu-west-1" in out and "10/10" in out and "0:41" in out


def test_terminal_mode_redraws_until_finished():
    console = Console(file=io.StringIO(), force_terminal=True, width=80)
    final = watch(scripted(F1, F2, F3), console=console, sleep=lambda s: None)
    assert final == F3


def test_non_terminal_prints_one_line_per_change(capsys):
    console = Console(file=io.StringIO(), force_terminal=False)
    watch(scripted(F1, F2, F2, F3), console=console, sleep=lambda s: None)
    lines = capsys.readouterr().err.splitlines()
    assert lines == ["eu-west-1: rolling (3/10)", "us-east-1: waiting (0/10)",
                     "eu-west-1: done (10/10)", "us-east-1: rolling (6/10)", "us-east-1: done (10/10)"]

The last test is the one that protects CI logs: repeated identical frames (F2, F2) produce no extra lines, and every transition appears exactly once.

Conclusion

Live gives a command a compact, in-place dashboard with very little code: fetch state, build a fresh table from it, hand it to live.update(), and let the final frame remain as a summary. Keep rendering pure, decouple refresh from polling, print through the live console, fall back to one log line per state change when there is no terminal, and inject fetch and sleep so the loop is testable. For watching many things at once, that is usually all the interface a CLI needs.

Frequently asked questions

Can Live combine a progress bar and a table?

Yes. Put a Progress instance and a Table in a rich.console.Group (or a Layout) and pass the group to Live; update the progress normally and rebuild the table per tick.

How do I feed the dashboard from concurrent workers?

Let workers write their latest state into a dictionary keyed by task (guarded by a lock, or via a queue.Queue drained by the main thread), and make fetch return a snapshot of it. The main thread then owns the Live display entirely, workers never touch the terminal, and the same watch function works whether states come from threads, asyncio tasks or a remote API.

Why does my live display flicker?

Usually because the renderable is rebuilt far more often than the data changes, or because something prints with print() while live. Fetch at a sensible interval, update only when data changes, and print through live.console.

Should the final frame go to stdout or stderr?

A live dashboard is narration, so create its console with stderr=True when the command also prints results on stdout. The final summary then stays visible in the terminal while a script capturing stdout receives only the data it asked for.

Does Live work over SSH and in tmux?

Yes. It uses standard cursor movement, which works in any modern terminal, multiplexer or SSH session. With TERM=dumb Rich treats the output as non-interactive.

How do I stop the display from a worker thread?

Set an event that the watching loop checks, and let the loop exit its with block on the main thread. Do not call live.stop() from other threads.