Python Visualization

Data visualization patterns with matplotlib for system monitoring and infrastructure dashboards.

Core Concepts

matplotlib’s object hierarchy: Figure (the window) → Axes (individual plots) → Artists (lines, bars, text). GridSpec manages multi-panel layouts — specify rows × columns, then address panels by grid position.

Minimal Figure + GridSpec
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec

fig = plt.figure(figsize=(18, 10), facecolor='#1e1e2e')
gs = gridspec.GridSpec(2, 3, figure=fig, hspace=0.4, wspace=0.35)

ax1 = fig.add_subplot(gs[0, 0])  # Row 0, Col 0
ax2 = fig.add_subplot(gs[0, 1])  # Row 0, Col 1
ax3 = fig.add_subplot(gs[1, :])  # Row 1, all columns (span)

fig.savefig('/tmp/example.png', dpi=150, bbox_inches='tight',
            facecolor=fig.get_facecolor())
plt.close(fig)

Key parameters:

  • figsize=(width, height) — inches at the given DPI

  • hspace / wspace — vertical/horizontal gap between panels (fraction of axes height/width)

  • top, bottom, left, right — figure margins (0.0 to 1.0)

  • facecolor — background color (use fig.get_facecolor() in savefig to preserve it)

Text Panels

Use ax.text() with transform=ax.transAxes for coordinate-independent positioning (0.0 to 1.0). Turn off all axis chrome with ax.axis('off').

System Info Text Panel
def render_system_info(ax, data):
    ax.axis('off')
    ax.set_facecolor('#181825')

    lines = [
        ('HOSTNAME', data['hostname'], '#cba6f7'),
        ('KERNEL',   data['kernel'],   '#89b4fa'),
        ('CPU',      data['cpu_model'],'#94e2d5'),
        ('UPTIME',   data['uptime'],   '#a6e3a1'),
    ]

    y = 0.92
    for label, value, color in lines:
        # Label column
        ax.text(0.05, y, f"{label}:", transform=ax.transAxes,
                fontsize=8, color='#a6adc8', fontfamily='monospace',
                verticalalignment='top')
        # Value column
        ax.text(0.35, y, value, transform=ax.transAxes,
                fontsize=9, color=color, fontfamily='monospace',
                verticalalignment='top')
        y -= 0.15

transAxes means (0,0) is bottom-left and (1,1) is top-right of the axes, regardless of data coordinates. This is essential for text-only panels where you have no data axis to work with.

Bar Charts (barh)

Horizontal bars are more readable for labels. Stack segments with the left= parameter — each segment starts where the previous one ended.

Stacked Memory Bar
def render_memory(ax, data):
    total = data['total_gb']
    segments = [
        ('Used',    data['used_gb'],    '#f38ba8'),  # red
        ('Cached',  data['cached_gb'],  '#89b4fa'),  # blue
        ('Buffers', data['buffers_gb'], '#94e2d5'),  # teal
        ('Free',    data['free_gb'],    '#a6e3a1'),  # green
    ]

    left = 0
    bars = []
    for label, val, color in segments:
        bar = ax.barh(0, val, left=left, color=color, height=0.5)
        bars.append(bar)
        left += val

    ax.set_xlim(0, total)
    ax.set_yticks([])  # No y-axis labels for single bar
    ax.legend([b[0] for b in bars], [s[0] for s in segments],
              loc='upper right', fontsize=7)
Multi-Row Storage Bars with Conditional Coloring
def render_storage(ax, mounts):
    labels, percents, colors = [], [], []
    for m in sorted(mounts, key=lambda d: d['mountpoint']):
        labels.append(f"{m['mountpoint']} ({m['size']})")
        pct = m['percent']
        percents.append(pct)
        # Threshold coloring: green < 60%, yellow 60-80%, red > 80%
        if pct >= 80:
            colors.append('#f38ba8')
        elif pct >= 60:
            colors.append('#f9e2af')
        else:
            colors.append('#a6e3a1')

    y_pos = range(len(labels))
    ax.barh(y_pos, percents, color=colors, height=0.6)
    ax.set_yticks(y_pos)
    ax.set_yticklabels(labels, fontsize=7)
    ax.set_xlim(0, 100)
    ax.axvline(x=80, color='#f38ba8', linestyle='--', alpha=0.4)  # Threshold line
Sorted Process Bars
def render_processes(ax, procs):
    cmds = [p['cmd'][:30] for p in procs]  # Truncate long commands
    cpus = [p['cpu'] for p in procs]

    ax.barh(range(len(cmds)), cpus, color='#89b4fa', height=0.6)
    ax.set_yticks(range(len(cmds)))
    ax.set_yticklabels(cmds, fontsize=7, fontfamily='monospace')
    ax.invert_yaxis()  # Highest value at top

invert_yaxis() puts the first bar (highest CPU) at the top — natural reading order for ranked data.

Tables

ax.table() renders tabular data directly in a panel — useful for network interfaces, service lists, or any structured data.

Network Interface Table with State Coloring
def render_network(ax, interfaces):
    ax.axis('off')

    col_labels = ['Interface', 'IPv4', 'State', 'Role']
    cell_text = [[d['name'], d['ipv4'], d['state'], d['role']]
                 for d in interfaces[:8]]

    table = ax.table(cellText=cell_text, colLabels=col_labels,
                     loc='center', cellLoc='center')
    table.auto_set_font_size(False)
    table.set_fontsize(8)
    table.scale(1, 1.3)  # Row height multiplier

    # Per-cell styling
    for (row, col), cell in table.get_celld().items():
        cell.set_edgecolor('#313244')
        if row == 0:  # Header
            cell.set_facecolor('#313244')
            cell.set_text_props(color='#cba6f7', fontweight='bold')
        else:
            cell.set_facecolor('#181825')
            cell.set_text_props(color='#cdd6f4')
            if col == 2:  # State column — conditional color
                state = cell_text[row - 1][2]
                color = {'UP': '#a6e3a1', 'DOWN': '#f38ba8'}.get(state, '#f9e2af')
                cell.set_text_props(color=color)

get_celld() returns a dict keyed by (row, col) — row 0 is the header. scale(x, y) multiplies column width and row height respectively.

Pie / Donut Charts

A donut is a pie with wedgeprops=dict(width=0.4) — the width controls the ring thickness. Center text goes at coordinate (0, 0).

Systemd Service Health Donut
def render_services(ax, counts):
    sizes = [counts.get('running', 0), counts.get('exited', 0),
             counts.get('failed', 0)]
    labels = ['Running', 'Exited', 'Failed']
    colors = ['#a6e3a1', '#585b70', '#f38ba8']

    # Filter zeros
    filtered = [(s, l, c) for s, l, c in zip(sizes, labels, colors) if s > 0]
    sizes, labels, colors = zip(*filtered)

    wedges, texts, autotexts = ax.pie(
        sizes, labels=labels, colors=colors,
        autopct='%1.0f%%', startangle=90,
        wedgeprops=dict(width=0.4, edgecolor='#181825'),  # Donut
        textprops=dict(color='#cdd6f4', fontsize=8),
        pctdistance=0.78
    )

    # Center summary
    ax.text(0, 0, str(sum(sizes)), ha='center', va='center',
            fontsize=16, fontweight='bold', color='#cdd6f4')
    ax.text(0, -0.15, 'services', ha='center', va='center',
            fontsize=8, color='#a6adc8')

pctdistance controls where the percentage label sits (0.0 = center, 1.0 = edge). startangle=90 begins the first slice at 12 o’clock.

Dark Theme: Catppuccin Mocha

Define the palette once as a dict, reference it everywhere.

Catppuccin Mocha Palette
MOCHA = {
    'base':     '#1e1e2e',  # Figure background
    'mantle':   '#181825',  # Panel background
    'crust':    '#11111b',  # Deepest background
    'text':     '#cdd6f4',  # Primary text
    'subtext0': '#a6adc8',  # Secondary text, labels
    'mauve':    '#cba6f7',  # Accent — titles, headings
    'blue':     '#89b4fa',  # Accent — bars, links
    'green':    '#a6e3a1',  # OK / healthy
    'yellow':   '#f9e2af',  # Warning
    'red':      '#f38ba8',  # Critical / error
    'teal':     '#94e2d5',  # Info accent
    'peach':    '#fab387',  # Warm accent
    'surface0': '#313244',  # Grid lines, spines, borders
    'surface1': '#45475a',  # Lighter surface
}
Apply to Figure and Axes
fig = plt.figure(facecolor=MOCHA['base'])

# Per-axes styling helper
def style_axes(ax, title=''):
    ax.set_facecolor(MOCHA['mantle'])
    ax.tick_params(colors=MOCHA['subtext0'], labelsize=8)
    for spine in ax.spines.values():
        spine.set_color(MOCHA['surface0'])
    if title:
        ax.set_title(title, color=MOCHA['mauve'], fontsize=11,
                     fontweight='bold', pad=8, loc='left')

# CRITICAL: preserve background in savefig
fig.savefig('out.png', facecolor=fig.get_facecolor())

Without facecolor=fig.get_facecolor() in savefig(), the output reverts to white regardless of the figure’s facecolor.

Pipeline: Query → Transform → Visualize

The dashboard bridges shell and Python: subprocess.run() calls the same JSON-producing commands documented in jq Sysadmin, then Python transforms and renders.

Pattern: subprocess → json.loads → render
import subprocess, json

def collect_network():
    # Same command as jq-sysadmin.adoc: ip -j addr
    result = subprocess.run(['ip', '-j', 'addr'],
                            capture_output=True, text=True)
    data = json.loads(result.stdout)

    interfaces = []
    for iface in data:
        name = iface.get('ifname', '')
        state = iface.get('operstate', 'UNKNOWN')

        # Role classification (from infrastructure-queries.adoc level 3)
        if name.startswith(('wl', 'wlan')):
            role = 'wifi'
        elif name.startswith(('en', 'eth')):
            role = 'wired'
        elif name.startswith(('docker', 'br-', 'veth', 'podman')):
            role = 'container'
        elif name.startswith(('wg', 'tun', 'tap', 'tailscale')):
            role = 'vpn'
        else:
            role = 'other'

        # Extract first IPv4
        ipv4 = None
        for addr_info in iface.get('addr_info', []):
            if addr_info.get('family') == 'inet':
                ipv4 = addr_info['local']
                break

        interfaces.append({'name': name, 'state': state,
                           'ipv4': ipv4, 'role': role})
    return interfaces
Pattern: pathlib for /proc files (no subprocess needed)
from pathlib import Path

def collect_memory():
    meminfo = {}
    for line in Path('/proc/meminfo').read_text().splitlines():
        if ':' in line:
            key, val = line.split(':', 1)
            val = val.strip().replace(' kB', '')
            try:
                meminfo[key] = int(val) / 1048576  # kB → GB
            except ValueError:
                pass

    total = meminfo['MemTotal']
    free = meminfo['MemFree']
    cached = meminfo['Cached']
    buffers = meminfo['Buffers']
    used = total - free - buffers - cached
    return {'total_gb': total, 'used_gb': used,
            'cached_gb': cached, 'free_gb': free}

When to use each:

  • subprocess — when the tool produces structured JSON (ip -j, lsblk -J, systemctl --output=json)

  • pathlib — when reading pseudo-files (/proc/meminfo, /proc/cpuinfo, /proc/loadavg)

  • Never both for the same data — pick the cleaner source

See Also