快轉到主要內容
  1. 教學文章/

Rich Logging Dashboard 實戰:進度、表格與 Log Console 整合

·7 分鐘· loading · loading · ·
Python Rich Logging Cli Dashboard Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 73: 本文

featured

一. 前言:長時間任務最怕「看起來還活著」
#

很多 Python 小工具一開始都很簡單:

python sync_reports.py

跑完就結束,錯了就看 traceback。

可是工具一旦變成每天跑、批次跑、CI 跑、或同事也會跑,問題就開始浮出來:

  • 現在處理到哪一批資料?
  • 目前成功幾筆、失敗幾筆?
  • 是卡住了,還是在慢慢前進?
  • 錯誤訊息到底是警告、重試,還是真的壞掉?
  • log 太多時,哪些訊息值得看?

只靠 print() 會很吵,只靠 logging 會很乾,只靠進度條又看不到上下文。

今天拍拍君要做一個更實用的組合:Rich logging dashboard

我們會把 Python 標準庫的 logging、Rich 的 LiveLayoutTablePanelProgress 整合起來,做出一個適合批次任務與 CLI 工具的終端機 dashboard。

如果你還沒看過前面的文章,可以先快速掃一下:

這篇不重講入門,而是把它們合成一個真正能放進專案的觀察介面。

二. 安裝與範例目標
#

這篇只需要一個外部套件:rich

uv 建專案的話:

uv init rich-log-dashboard
cd rich-log-dashboard
uv add rich
uv add --dev pytest

今天的範例會做一個假想的報表同步工具:

主要檔案先用 dashboard_demo.py,把狀態模型、logging buffer、Rich render function 與假同步流程放在一起。正式專案再拆成 dashboard.pylogging.pyservice.py 也不遲。

最終畫面會有四塊:

  • 上方:任務標題與目前狀態
  • 左側:即時統計表格
  • 右側:最近 log 訊息
  • 下方:進度條與目前處理項目

這不是要取代正式監控系統。它適合的是本機 CLI、資料處理腳本、內部工具、migration、批次匯入,或任何「跑幾分鐘,需要知道它有沒有正常前進」的場景。

三. 先整理資料狀態:不要讓 UI 直接猜
#

做 dashboard 前,拍拍君建議先定義一個狀態物件。

UI 不應該到處猜目前成功幾筆、錯誤幾筆;這些狀態應該集中管理。

# dashboard_demo.py
from dataclasses import dataclass, field
from time import monotonic


@dataclass
class JobStats:
    total: int
    processed: int = 0
    success: int = 0
    failed: int = 0
    skipped: int = 0
    current_item: str = "-"
    started_at: float = field(default_factory=monotonic)

    @property
    def elapsed(self) -> float:
        return monotonic() - self.started_at

    @property
    def success_rate(self) -> float:
        if self.processed == 0:
            return 0.0
        return self.success / self.processed

這個物件很普通,但非常重要。

如果 dashboard 的每個 render function 都只吃 JobStats,整個程式會變得很好測,也很好維護。

拍拍君不喜歡讓 terminal UI 直接混在業務邏輯裡,因為那會讓工具從「漂亮」變成「難改」。

四. 建立 Console、Theme 與 Log Buffer
#

Rich 的核心是 Console

你可以直接 Console(),但專案裡通常會想統一顏色、錯誤風格與輸出寬度。

import logging
from collections import deque
from logging import Handler, LogRecord

from rich.console import Console
from rich.theme import Theme


theme = Theme(
    {
        "ok": "bold green",
        "warn": "bold yellow",
        "err": "bold red",
        "muted": "dim",
        "title": "bold cyan",
        "value": "bold white",
    }
)

console = Console(theme=theme)


class LogBufferHandler(Handler):
    def __init__(self, max_lines: int = 8) -> None:
        super().__init__()
        self.lines: deque[tuple[str, str]] = deque(maxlen=max_lines)

    def emit(self, record: LogRecord) -> None:
        self.lines.append((record.levelname, self.format(record)))

為什麼不用 RichHandler 直接印?

因為等等 Live 會持續刷新畫面。如果其他地方也直接往 stdout 寫,畫面容易被打亂。

比較穩的做法是:log 先進 buffer,再由 dashboard 顯示最近幾行。

def configure_logging(max_lines: int = 8) -> tuple[logging.Logger, LogBufferHandler]:
    logger = logging.getLogger("pypy.sync")
    logger.setLevel(logging.INFO)
    logger.handlers.clear()
    logger.propagate = False

    handler = LogBufferHandler(max_lines=max_lines)
    handler.setFormatter(
        logging.Formatter("%(asctime)s %(levelname)s %(message)s", "%H:%M:%S")
    )
    logger.addHandler(handler)
    return logger, handler

logger.propagate = False 很重要。

如果沒有關掉,訊息可能會繼續往 root logger 傳,然後又印到終端機,結果 dashboard 就破圖。終端機 UI 很可愛,但也很脆弱,請溫柔一點。

五. 畫統計表格與最近 Log
#

Rich 的 Table.grid() 很適合做小面板裡的 key-value 顯示。

from rich.table import Table
from rich.text import Text


def render_stats(stats: JobStats) -> Table:
    table = Table.grid(padding=(0, 1))
    table.add_column(style="muted")
    table.add_column(style="value", justify="right")

    table.add_row("Total", str(stats.total))
    table.add_row("Processed", str(stats.processed))
    table.add_row("Success", f"[ok]{stats.success}[/]")
    table.add_row("Failed", f"[err]{stats.failed}[/]" if stats.failed else "0")
    table.add_row("Skipped", f"[warn]{stats.skipped}[/]" if stats.skipped else "0")
    table.add_row("Success Rate", f"{stats.success_rate:.1%}")
    table.add_row("Elapsed", f"{stats.elapsed:,.1f}s")
    return table

Log 區塊則用 Text,因為不同 level 可以套不同顏色。

def render_logs(logs: LogBufferHandler) -> Text:
    text = Text()

    if not logs.lines:
        text.append("尚無 log 訊息", style="muted")
        return text

    for level, message in logs.lines:
        if level == "ERROR":
            style = "err"
        elif level == "WARNING":
            style = "warn"
        else:
            style = "white"

        text.append(message, style=style)
        text.append("\n")

    return text

拍拍君的原則是:真正重要的狀態才給顏色,其他東西安靜一點。

Dashboard 最怕資訊多到看不懂。

六. 用 Progress、Layout 與 Live 組出畫面
#

Rich 的 Progress 可以獨立使用,也可以放進 dashboard。

進度條本身可以用 Progress 加上 SpinnerColumnBarColumnTaskProgressColumnTimeRemainingColumntransient=False 會讓任務結束後保留結果,對 dashboard 來說比自動清掉畫面更好 debug。

接著用 Layout 把終端機切成幾塊。

from rich.layout import Layout
from rich.panel import Panel
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn, TimeRemainingColumn


def make_progress() -> Progress:
    return Progress(
        SpinnerColumn(),
        TextColumn("[title]{task.description}"),
        BarColumn(),
        TaskProgressColumn(),
        TimeRemainingColumn(),
        transient=False,
    )


def make_layout() -> Layout:
    layout = Layout()
    layout.split_column(
        Layout(name="header", size=3),
        Layout(name="body"),
        Layout(name="footer", size=5),
    )
    layout["body"].split_row(
        Layout(name="stats", ratio=1),
        Layout(name="logs", ratio=2),
    )
    return layout


def update_layout(
    layout: Layout,
    stats: JobStats,
    logs: LogBufferHandler,
    progress: Progress,
) -> None:
    layout["header"].update(
        Panel(
            f"[title]Daily Pypy Report Sync[/] [muted]current={stats.current_item}[/]",
            border_style="cyan",
        )
    )
    layout["stats"].update(Panel(render_stats(stats), title="Stats", border_style="green"))
    layout["logs"].update(Panel(render_logs(logs), title="Recent Logs", border_style="yellow"))
    layout["footer"].update(Panel(progress, title="Progress", border_style="blue"))

這個 function 沒有做任何業務邏輯,只負責把目前狀態畫出來。

這是 dashboard 程式碼最重要的分界:資料怎麼變是一件事,畫面怎麼畫是另一件事。

七. 跑起來:Live 會持續刷新畫面
#

Live 會持續刷新某個 renderable。

搭配 layout 後,我們可以在迴圈裡更新狀態,再重新 render。

import random
import time
from rich.live import Live


def run_sync(total: int = 30) -> JobStats:
    logger, log_buffer = configure_logging()
    stats = JobStats(total=total)
    layout = make_layout()
    progress = make_progress()
    task_id = progress.add_task("同步報表", total=total)

    update_layout(layout, stats, log_buffer, progress)

    with Live(layout, console=console, refresh_per_second=8, screen=False):
        logger.info("開始同步 %s 筆報表", total)

        for index in range(1, total + 1):
            item = f"report-{index:03d}"
            stats.current_item = item
            logger.info("處理 %s", item)

            time.sleep(0.08)
            roll = random.random()

            if roll < 0.08:
                stats.failed += 1
                logger.error("%s 同步失敗,稍後人工檢查", item)
            elif roll < 0.18:
                stats.skipped += 1
                logger.warning("%s 缺少 optional 欄位,已略過", item)
            else:
                stats.success += 1

            stats.processed += 1
            progress.update(task_id, advance=1)
            update_layout(layout, stats, log_buffer, progress)

        logger.info("同步完成:success=%s failed=%s skipped=%s", stats.success, stats.failed, stats.skipped)
        stats.current_item = "done"
        update_layout(layout, stats, log_buffer, progress)

    return stats


if __name__ == "__main__":
    run_sync()

執行:

python dashboard_demo.py

你會看到一個持續更新的終端機畫面。

這種 dashboard 對短任務可能有點多餘,但對長時間資料處理、同步任務、爬蟲、批次 API 呼叫,非常舒服。

八. 讓它更像正式工具
#

第一個原則:dashboard 模式下,不要在業務邏輯裡直接 print()。所有訊息都走 logger,terminal dashboard 顯示最近幾行,完整 log 則可以另外用 FileHandler 存檔。

第二個原則:在 CI、cron、Docker logs 或非互動環境裡要能降級。

def should_use_dashboard(console: Console) -> bool:
    return console.is_terminal and not console.is_dumb_terminal

如果你接 Typer,可以給使用者一個 --dashboard/--no-dashboard 選項。Dashboard 再漂亮,CLI 工具仍然要讓 shell、CI、排程器知道成功或失敗;任務失敗就該回傳非零 exit code。

九. 測試、常見坑與適用情境
#

Rich 畫面本身不需要每個字元都 snapshot。比較值得測的是 JobStats 的成功率、logger 是否有裝正確 handler、buffer 是否只保留最近幾行,以及非互動環境是否會走 plain logging。

例如:

def test_success_rate() -> None:
    stats = JobStats(total=10, processed=4, success=3)

    assert stats.success_rate == 0.75

如果你真的想測 Rich render 結果,可以用 Console(record=True),但拍拍君通常不建議 snapshot 整個 dashboard。終端機寬度、Rich 版本、Unicode 寬度都可能影響輸出,測太細會讓測試變脆。

幾個常見坑先記起來:

  • Live 畫面被打亂:通常是還有 print() 或其他 stdout 輸出
  • log 重複出現:通常是 logger 重複 add handler
  • CI 裡顯示怪怪的:請提供 --no-dashboard
  • 顏色太多:把顏色留給成功、警告、錯誤
  • dashboard 裡塞太多資料:完整資料請輸出 JSON、CSV 或 log file

拍拍君會在這些情境使用 Rich logging dashboard:

  • 批次處理 100 筆以上資料
  • API 同步可能有重試、略過與失敗
  • 任務會跑超過 1 分鐘
  • 使用者需要知道目前進度
  • 結束後需要一眼看成功與失敗摘要
  • 工具主要在本機或互動式 shell 使用

它不太適合 0.5 秒就結束的小指令、需要純文字逐行 log 的 CI、或正式服務的長期監控。正式服務請用 Prometheus、Grafana、OpenTelemetry、Sentry 之類的工具;Rich dashboard 是 CLI 的觀察窗,不是 production observability 的替代品。

結語
#

今天這篇把 Rich 和 logging 往前推了一步:不只是「輸出漂亮一點」,而是讓長時間任務變得可觀察、可理解、可收尾。

核心觀念其實很簡單:

  • JobStats 集中管理狀態
  • 用 logging handler 收集訊息
  • Table 顯示統計
  • Progress 顯示進度
  • LayoutLive 組成即時畫面
  • 在 CI 或非互動環境中降級成普通 log

拍拍君很喜歡這種工具感:不是華麗到搶戲,而是讓你少盯著一坨文字猜現在到底發生什麼事。

終端機不是只能黑底白字。只要設計得克制,它也可以很清楚、很可靠,甚至有點可愛。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 73: 本文

相關文章

Python difflib 實戰:文字差異比對、相似度比較與 patch 輸出完全攻略
·10 分鐘· loading · loading
Python Difflib Text-Processing Developer-Tools Cli
Python prompt_toolkit 實戰:打造互動式 CLI、Auto-Completion 與 REPL 完全攻略
·10 分鐘· loading · loading
Python Prompt_toolkit Cli REPL Developer-Tools
Python uv scripts 實戰:PEP 723、inline dependencies 與單檔工具
·6 分鐘· loading · loading
Python Uv PEP 723 Script Developer-Tools Automation
Python pytest fixtures 進階:conftest、factory 與測試資料管理
·8 分鐘· loading · loading
Python Pytest Fixtures Testing Conftest Monkeypatch Developer-Tools
Python SQLAlchemy 2.0 實戰:Typed ORM、Session 與查詢模式
·9 分鐘· loading · loading
Python SQLAlchemy ORM Database SQLite Developer-Tools
FastAPI + Streamlit 實戰:API 後端與互動前端分工
·9 分鐘· loading · loading
Python Fastapi Streamlit Api Frontend Developer-Tools