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.
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 (usefig.get_facecolor()insavefigto 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').
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.
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)
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
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.
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).
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.
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
}
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.
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
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
-
CLI Patterns — subprocess, JSON processing
-
File Operations — pathlib, JSON/YAML parsing
-
jq Sysadmin — the shell commands this dashboard wraps
-
jq Infrastructure Queries — 7-level query progression