Skip to content

Instantly share code, notes, and snippets.

@JCalebBR
Created January 17, 2026 16:06
Show Gist options
  • Select an option

  • Save JCalebBR/2ae7351f32be112ffbafe0e94dc16506 to your computer and use it in GitHub Desktop.

Select an option

Save JCalebBR/2ae7351f32be112ffbafe0e94dc16506 to your computer and use it in GitHub Desktop.
Pihole + Tampermonkey monitoring

Pi-hole System Stats Widgets - Complete Setup Guide

Overview

This guide sets up custom system monitoring widgets for your Pi-hole dashboard, including:

  • CPU Temperature
  • Memory Usage
  • Disk Space
  • Network Traffic
  • Uptime
  • Tailscale Status
  • Unbound DNS Status

Part 1: Create the Python API

1. Install Required Dependencies

sudo apt update
sudo apt install python3-psutil

2. Create the API Script

sudo nano /usr/local/bin/temp-api.py

Paste the python code, save and exit (Ctrl+X, Y, Enter)

Save and exit (Ctrl+X, Y, Enter)

3. Make the Script Executable

sudo chmod +x /usr/local/bin/temp-api.py

4. Test the Script Manually

sudo /usr/local/bin/temp-api.py

In another terminal, test the API:

curl http://localhost:8080/stats

You should see JSON output with all your system stats. Press Ctrl+C to stop the manual test.


Part 2: Create a Systemd Service

1. Create the Service File

sudo nano /etc/systemd/system/temp-api.service

Paste this content:

[Unit]
Description=Pi Temperature and Stats API
After=network.target

[Service]
Type=simple
User=pi
ExecStart=/usr/local/bin/temp-api.py
Restart=always

[Install]
WantedBy=multi-user.target

Save and exit (Ctrl+X, Y, Enter)

2. Enable and Start the Service

sudo systemctl daemon-reload
sudo systemctl enable temp-api.service
sudo systemctl start temp-api.service

3. Verify Service is Running

sudo systemctl status temp-api.service
curl http://localhost:8080/stats

Part 3: Install Tampermonkey Userscript

1. Install Tampermonkey

2. Create New Userscript

  1. Click Tampermonkey icon in browser toolbar
  2. Click "Create a new script"
  3. Delete the template code
  4. Paste the userscript code (see below)
  5. Save (Ctrl+S or File → Save)

3. Userscript Code

Note: Replace 192.168.0.13 with your Pi-hole's IP address if different

4. Configure Userscript for Your Pi-hole

If your Pi-hole has a different IP address or hostname, update these lines:

  • Line 8-13: @match patterns
  • Line 18: @connect directive
  • Line 23: STATS_API_URL

Part 4: Verify Everything Works

  1. Check the service is running:
   sudo systemctl status temp-api.service
  1. Test the API manually:
   curl http://192.168.0.13:8080/stats
  1. Open Pi-hole dashboard:
    • Navigate to your Pi-hole: http://192.168.0.13/admin
    • You should see 7 widgets on the right side

Troubleshooting

Service won't start

# Check logs
sudo journalctl -u temp-api.service -n 50

# Test script manually
sudo /usr/local/bin/temp-api.py

Widgets don't appear

  1. Check Tampermonkey dashboard - is script enabled?
  2. Check browser console (F12) for errors
  3. Verify @match patterns match your Pi-hole URL
  4. Verify API is accessible: curl http://YOUR_PI_IP:8080/stats

Wrong IP address in userscript

Edit the userscript and update:

  • All @match lines with your Pi-hole's URL
  • @connect line with your Pi's IP
  • STATS_API_URL with your Pi's IP

Customization

Change widget positions

Edit the positionWidgets() function in the userscript to adjust top and right values.

Change update interval

Edit UPDATE_INTERVAL in the userscript (default: 5000ms = 5 seconds)

Maintenance

Restart the service

sudo systemctl restart temp-api.service

Stop the service

sudo systemctl stop temp-api.service

Disable autostart

sudo systemctl disable temp-api.service

View service logs

sudo journalctl -u temp-api.service -f

Uninstall

Remove the service

sudo systemctl stop temp-api.service
sudo systemctl disable temp-api.service
sudo rm /etc/systemd/system/temp-api.service
sudo systemctl daemon-reload

Remove the API script

sudo rm /usr/local/bin/temp-api.py

Remove the userscript

  1. Open Tampermonkey dashboard
  2. Find "Pi-hole System Stats Widgets"
  3. Click the trash icon to delete
// ==UserScript==
// @name Pi-hole Temperature Display
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Display Raspberry Pi temperature in Pi-hole dashboard
// @author You
// @match http://192.168.0.13/admin*
// @match https://192.168.0.13/admin*
// @match http://pihole/admin*
// @match https://pihole/admin*
// @grant GM_xmlhttpRequest
// @connect 192.168.0.13
// ==/UserScript==
(function() {
'use strict';
const STATS_API_URL = 'http://192.168.0.13:8080/stats';
const UPDATE_INTERVAL = 5000;
let previousNetworkData = null;
let lastUpdateTime = null;
function createWidget(id, title, iconPath) {
const widget = document.createElement('div');
widget.id = id;
widget.style.cssText = `
position: fixed;
background: #667eea;
color: white;
padding: 12px 16px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-family: Poppins, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
z-index: 9999;
width: 200px;
height: 90px;
transition: background 0.3s ease;
overflow: hidden;
`;
widget.innerHTML = `
<svg style="position: absolute; right: -5px; top: 50%; transform: translateY(-50%); opacity: 0.15; width: 80px; height: 80px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="${iconPath}"></path>
</svg>
<div style="font-size: 12px; font-weight: 600; margin-bottom: 6px; position: relative; z-index: 1;">
${title}
</div>
<div class="stat-value" style="font-size: 32px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1; position: relative; z-index: 1;">
--
</div>
<div class="stat-subtitle" style="font-size: 11px; margin-top: 4px; opacity: 0.9; position: relative; z-index: 1;">
</div>
`;
document.body.appendChild(widget);
return widget;
}
function positionWidgets() {
const widgets = [
{id: 'cpu-temp-widget', top: 70, right: 20},
{id: 'memory-widget', top: 175, right: 20},
{id: 'disk-widget', top: 280, right: 20},
{id: 'network-widget', top: 385, right: 20},
{id: 'uptime-widget', top: 490, right: 20},
{id: 'tailscale-widget', top: 595, right: 20},
{id: 'unbound-widget', top: 700, right: 20}
];
widgets.forEach(w => {
const el = document.getElementById(w.id);
if (el) {
el.style.top = `${w.top}px`;
el.style.right = `${w.right}px`;
}
});
}
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B/s`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB/s`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB/s`;
}
function updateStats() {
GM_xmlhttpRequest({
method: 'GET',
url: STATS_API_URL,
onload: function(response) {
try {
const data = JSON.parse(response.responseText);
const currentTime = Date.now();
// Temperature
const tempValue = parseFloat(data.temperature);
const tempWidget = document.getElementById('cpu-temp-widget');
if (tempWidget) {
tempWidget.querySelector('.stat-value').textContent = `${tempValue.toFixed(1)}°C`;
if (tempValue < 60) {
tempWidget.style.background = '#667eea';
} else if (tempValue < 70) {
tempWidget.style.background = '#3b82f6';
} else if (tempValue < 80) {
tempWidget.style.background = '#f59e0b';
} else {
tempWidget.style.background = '#ef4444';
}
}
// Memory
const memWidget = document.getElementById('memory-widget');
if (memWidget) {
memWidget.querySelector('.stat-value').textContent = `${data.memory.percent}%`;
memWidget.querySelector('.stat-subtitle').textContent =
`${data.memory.used_mb.toFixed(0)} / ${data.memory.total_mb.toFixed(0)} MB`;
if (data.memory.percent < 60) {
memWidget.style.background = '#10b981';
} else if (data.memory.percent < 80) {
memWidget.style.background = '#f59e0b';
} else {
memWidget.style.background = '#ef4444';
}
}
// Disk
const diskWidget = document.getElementById('disk-widget');
if (diskWidget) {
diskWidget.querySelector('.stat-value').textContent = `${data.disk.percent}%`;
diskWidget.querySelector('.stat-subtitle').textContent =
`${data.disk.used_gb.toFixed(1)} / ${data.disk.total_gb.toFixed(1)} GB`;
if (data.disk.percent < 60) {
diskWidget.style.background = '#8b5cf6';
} else if (data.disk.percent < 80) {
diskWidget.style.background = '#f59e0b';
} else {
diskWidget.style.background = '#ef4444';
}
}
// Network - calculate speed
const netWidget = document.getElementById('network-widget');
if (netWidget && previousNetworkData && lastUpdateTime) {
const timeDiff = (currentTime - lastUpdateTime) / 1000;
const sentDiff = data.network.bytes_sent - previousNetworkData.bytes_sent;
const recvDiff = data.network.bytes_recv - previousNetworkData.bytes_recv;
const uploadSpeed = sentDiff / timeDiff;
const downloadSpeed = recvDiff / timeDiff;
netWidget.querySelector('.stat-value').style.fontSize = '20px';
netWidget.querySelector('.stat-value').innerHTML =
`<span style="font-size: 14px;">↓</span>${formatBytes(downloadSpeed)}`;
netWidget.querySelector('.stat-subtitle').textContent =
`↑ ${formatBytes(uploadSpeed)}`;
netWidget.style.background = '#06b6d4';
}
previousNetworkData = data.network;
lastUpdateTime = currentTime;
// Uptime
const uptimeWidget = document.getElementById('uptime-widget');
if (uptimeWidget) {
uptimeWidget.querySelector('.stat-value').textContent = formatUptime(data.uptime_seconds);
uptimeWidget.style.background = '#ec4899';
}
// Tailscale
const tailscaleWidget = document.getElementById('tailscale-widget');
if (tailscaleWidget) {
tailscaleWidget.querySelector('.stat-value').style.fontSize = '24px';
tailscaleWidget.querySelector('.stat-value').textContent = data.tailscale.status;
tailscaleWidget.querySelector('.stat-subtitle').textContent = data.tailscale.ip;
if (data.tailscale.status === 'Connected') {
tailscaleWidget.style.background = '#10b981';
} else if (data.tailscale.status === 'Disconnected') {
tailscaleWidget.style.background = '#f59e0b';
} else {
tailscaleWidget.style.background = '#6b7280';
}
}
// Unbound
const unboundWidget = document.getElementById('unbound-widget');
if (unboundWidget) {
unboundWidget.querySelector('.stat-value').style.fontSize = '28px';
unboundWidget.querySelector('.stat-value').textContent = data.unbound.status;
if (data.unbound.status === 'Running') {
unboundWidget.style.background = '#10b981';
} else if (data.unbound.status === 'Stopped') {
unboundWidget.style.background = '#ef4444';
} else {
unboundWidget.style.background = '#6b7280';
}
}
} catch (e) {
console.error('Failed to parse stats:', e);
}
},
onerror: function(error) {
console.error('Failed to fetch stats:', error);
}
});
}
function init() {
if (document.body) {
// Thermometer icon
createWidget('cpu-temp-widget', 'CPU Temperature',
'M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z');
// Memory chip icon
createWidget('memory-widget', 'Memory Usage',
'M6 2h12M6 22h12M2 6v12M22 6v12M6 6h12v12H6z');
// Hard drive icon
createWidget('disk-widget', 'Disk Space',
'M22 12H2M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11zM6 16h.01M10 16h.01');
// Network icon
createWidget('network-widget', 'Network Traffic',
'M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5');
// Clock icon
createWidget('uptime-widget', 'Uptime',
'M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83');
// Tailscale logo-like icon (network connections)
createWidget('tailscale-widget', 'Tailscale',
'M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5');
// DNS/Unbound icon (server/shield)
createWidget('unbound-widget', 'Unbound DNS',
'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z');
positionWidgets();
updateStats();
setInterval(updateStats, UPDATE_INTERVAL);
} else {
setTimeout(init, 100);
}
}
init();
})();
#!/usr/bin/env python3
from http.server import HTTPServer, BaseHTTPRequestHandler
import subprocess
import json
import psutil
import time
import re
class StatsHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/stats':
# Temperature
temp = subprocess.check_output(['vcgencmd', 'measure_temp']).decode()
temp_value = temp.split('=')[1].split("'")[0]
# Memory
mem = psutil.virtual_memory()
mem_used_mb = mem.used / (1024 * 1024)
mem_total_mb = mem.total / (1024 * 1024)
mem_percent = mem.percent
# Disk
disk = psutil.disk_usage('/')
disk_used_gb = disk.used / (1024 * 1024 * 1024)
disk_total_gb = disk.total / (1024 * 1024 * 1024)
disk_percent = disk.percent
# Network (bytes sent/received since boot)
net = psutil.net_io_counters()
# Uptime
uptime_seconds = time.time() - psutil.boot_time()
# Tailscale status
tailscale_status = "Unknown"
tailscale_ip = "N/A"
try:
ts_output = subprocess.check_output(['tailscale', 'status', '--json'],
stderr=subprocess.DEVNULL).decode()
ts_data = json.loads(ts_output)
if ts_data.get('BackendState') == 'Running':
tailscale_status = "Connected"
# Get tailscale IP
self_data = ts_data.get('Self', {})
ips = self_data.get('TailscaleIPs', [])
if ips:
tailscale_ip = ips[0]
else:
tailscale_status = "Disconnected"
except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError):
tailscale_status = "Not Installed"
# Unbound status
unbound_status = "Unknown"
try:
result = subprocess.run(['systemctl', 'is-active', 'unbound'],
capture_output=True, text=True)
if result.stdout.strip() == 'active':
unbound_status = "Running"
else:
unbound_status = "Stopped"
except (subprocess.CalledProcessError, FileNotFoundError):
unbound_status = "Not Installed"
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
response = json.dumps({
'temperature': temp_value,
'memory': {
'used_mb': round(mem_used_mb, 1),
'total_mb': round(mem_total_mb, 1),
'percent': round(mem_percent, 1)
},
'disk': {
'used_gb': round(disk_used_gb, 2),
'total_gb': round(disk_total_gb, 2),
'percent': round(disk_percent, 1)
},
'network': {
'bytes_sent': net.bytes_sent,
'bytes_recv': net.bytes_recv
},
'uptime_seconds': int(uptime_seconds),
'tailscale': {
'status': tailscale_status,
'ip': tailscale_ip
},
'unbound': {
'status': unbound_status
}
})
self.wfile.write(response.encode())
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass
if __name__ == '__main__':
server = HTTPServer(('0.0.0.0', 8080), StatsHandler)
print('System stats API running on port 8080')
server.serve_forever()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment