A live metrics dashboard for this Raspberry Pi. Go backend, no dependencies, frontend embedded in the binary.
https://<node>.<tailnet>.ts.net:8443/ # via `tailscale serve`, real cert
http://127.0.0.1:8090/ # what pidash itself listens on
install.sh prints whichever applies when it finishes. The service binds
loopback by default because the API is unauthenticated — see HTTPS.
Grab a prebuilt binary from Releases — no Go toolchain needed, and no runtime dependencies, since the frontend is embedded in the binary:
curl -LO https://github.com/rosselm/pidash/releases/latest/download/pidash-linux-arm64
chmod +x pidash-linux-arm64
./pidash-linux-arm64 -addr 127.0.0.1:8090(-addr :8090 listens on the LAN instead, in the clear — see
Security before doing that.)
pidash-linux-arm64 covers a Pi 3/4/5 on 64-bit Raspberry Pi OS; armv7 and
armv6 builds cover 32-bit installs and the Zero. To run it as a service
instead, clone and use install.sh — see Install.
If a Pi ships its metrics to a collector elsewhere, the dashboard goes dark at
precisely the moment you want it: a network partition, a full disk, a wedged
agent. pidash reads /proc, /sys and the VideoCore firmware directly and
serves its own UI from the same binary, so it keeps working when the telemetry
path does not. It shares no code, no configuration and no port with whatever
agent is already running.
| Panel | Source |
|---|---|
| Temp / CPU / memory gauges | /sys/class/thermal, /proc/stat, /proc/meminfo |
| Processor history, per-core bars | /proc/stat deltas, /sys/.../cpufreq |
| Thermal & throttle flags | vcgencmd get_throttled / measure_volts core |
| Memory breakdown, load | /proc/meminfo, /proc/loadavg |
| Storage | /proc/mounts + statfs(2) |
| Network | /proc/net/dev deltas, /sys/class/net/*/operstate |
| Containers | Docker Engine API over /var/run/docker.sock |
| Services | systemctl show |
| Top processes | /proc/<pid>/{stat,statm,cmdline,status} |
Journal drawer (J) |
one shared journalctl -f -o json, whole journal by default |
Throttle flags are reported in both tenses: the low bits of the firmware word are
"right now", the same bits 16 places up are "since boot". This board currently
reads 0x50000 — under-voltage and throttling have occurred, nothing active.
That is a power-supply signal worth keeping visible, which is why a past-only
flag still lights the panel amber.
The journal is not a card. It lives in a drawer fixed to the bottom of the viewport, because a log panel at the end of a long grid is only reachable by scrolling past everything else — and the moment you actually want logs is the moment you are staring at a spike somewhere further up the page.
J(or backtick) toggles it,Esccloses it.- The whole journal is tailed by default. The unit dropdown is built from
what actually arrives, so it lists the units that are really logging rather
than a list configured up front.
-log-unitsnarrows it at the source. - Drag its top edge to resize; open/closed and height are remembered.
- It follows the newest line by default and re-arms as soon as you scroll back
down.
↓ latestappears while you are scrolled away. - While the drawer is shut, the
journalbutton in the top bar counts anything at warning priority or worse, so a failure still announces itself.
500 lines stay searchable in the buffer; 300 are rendered. New lines are appended rather than re-rendering the list, so the arrival animation fires only on genuinely new rows.
Every card is movable and resizable, and the arrangement is remembered per browser:
- Drag a card by its header to move it. A dashed placeholder shows where it will land.
- Drag the grip in the bottom-right corner to resize: horizontally it snaps
to the 12-column grid (3–12 columns), vertically it sets a pixel height. A
readout shows
span/12 · heightwhile you drag. reset layoutin the top bar restores the defaults. It only appears once you have actually changed something.
Layout lives in localStorage under pidash.layout.v1 — order, spans and
heights, nothing else. It is per-browser, not shared between devices, and the
server never sees it. Charts repaint through a ResizeObserver, so a resized
card redraws its chart at the new size rather than stretching a stale bitmap.
Below 1280px the grid reflows on its own and saved spans give way to the responsive rules; the three gauges stay on one row as far down as they fit.
./install.sh # build, install to /usr/local/bin, enable the unit
systemctl status pidash
journalctl -u pidash -fRe-run install.sh after any change; it rebuilds and restarts in place.
The service runs as a login user rather than a dedicated service account,
because it needs three group memberships: docker (the socket), video
(/dev/vchiq, for vcgencmd) and adm (the journal). install.sh sets
User= to whoever runs it and warns about any of the three that are missing.
The unit is otherwise read-only — ProtectSystem=strict, ProtectHome=read-only, NoNewPrivileges.
Two hardening settings are deliberately not tightened further:
PrivateDevicesstays off. Turning it on hides/dev/vchiq, which silently removes SoC temperature, core voltage and the throttle flags.RestrictAddressFamiliesincludesAF_NETLINK. Go enumerates interface addresses over netlink; without it every interface renders with a blank IP.
Site-specific settings belong in a systemd drop-in rather than in the unit that
ships here, so install.sh never overwrites them and they never end up in
version control:
# /etc/systemd/system/pidash.service.d/local.conf
[Service]
ExecStart=
ExecStart=/usr/local/bin/pidash -addr 127.0.0.1:8090 -units ssh,docker,my-agentsudo systemctl daemon-reload && sudo systemctl restart pidash-addr :8090 listen address
-interval 1s sampling interval
-proc-interval 3s how often to walk /proc for the process table
-top 8 processes in the top table
-expose-cmdline false publish full process command lines
-units ssh,docker systemd units for the Services panel
-log-units (empty) units to tail; empty = the whole journal
-docker-sock /var/run/docker.sock empty to disable the panel
-version print version and exit
-proc-interval is separate from -interval because walking every
/proc/<pid>/stat is by far the most expensive thing done per tick, and a top-N
table does not need to move as fast as a gauge. Measured on an idle Pi 4 with
~190 processes, over two 60-second windows:
-proc-interval |
CPU used in 60s | share of one core |
|---|---|---|
1s |
1.24 s | 2.07% |
3s (default) |
0.89 s | 1.48% |
A monitoring tool that perturbs what it measures is a problem, so this is worth
re-checking after a change: systemctl show pidash -p CPUUsageNSec twice, a
minute apart, is enough.
Three endpoints, all useful from the shell:
curl -s localhost:8090/api/snapshot | jq . # one pretty-printed sample
curl -sN localhost:8090/api/stream # SSE, one snapshot per interval
curl -sN localhost:8090/api/logs # SSE, journal lines
curl -s localhost:8090/healthz/api/stream and /api/logs are Server-Sent Events. One sampler goroutine
feeds every connected browser: opening ten tabs does not multiply the /proc
traffic, and all tabs agree on the same CPU percentages.
There is no authentication. Anyone who can reach the port can read every metric. That is a deliberate fit for a trusted home LAN, but it has one consequence worth stating plainly:
/proc/<pid>/cmdline routinely contains credentials — --token=, --password=,
--api-key= — passed as flags. On an ordinary Pi a handful of the running
processes will match that pattern. So only argv[0] is published:
the executable path, never the arguments. -expose-cmdline opts back in to full
command lines and logs a warning at startup.
The shipped unit therefore binds loopback only — see HTTPS for
putting a certificate in front of it, or for the SSH-tunnel alternative. Pass
-addr :8090 to listen on the LAN in the clear, which is worth doing only on a
network you fully trust.
The service itself needs no privileges beyond the three groups in the unit, and
never writes anything: ProtectSystem=strict, ProtectHome=read-only,
NoNewPrivileges.
pidash speaks plain HTTP and has no certificate handling of its own — that is deliberate. Put a TLS terminator in front of it and bind pidash to loopback, so the unencrypted port is not spoken on the wire at all. That is what the shipped unit does:
ExecStart=/usr/local/bin/pidash -addr 127.0.0.1:8090tailscale serve obtains and renews a real Let's Encrypt certificate for the
node's MagicDNS name and proxies to pidash over loopback. Nothing is published
to the internet, and nothing needs renewing by hand:
sudo tailscale up
sudo tailscale serve --bg --https=8443 http://127.0.0.1:8090
tailscale serve statusRequires MagicDNS and HTTPS Certificates enabled for the tailnet, under
DNS settings. The result is a genuine
padlock at https://<node>.<tailnet>.ts.net:8443/, reachable from any device
signed into the same tailnet and from nowhere else — the name is NXDOMAIN on
public resolvers.
The trade-off is that every device that wants in has to join the tailnet. For a name that resolves on a plain LAN with no client software, register a domain, point an A record at the Pi's private address (private IPs are legal in public DNS) and issue a certificate by DNS-01 challenge, which needs no inbound ports either.
If k3s is running on the same host, its servicelb installs an iptables rule with no destination-address match:
-A CNI-DN-... -p tcp -m tcp --dport 443 -j DNAT --to-destination 10.42.0.7:443
Every packet to port 443 on any local address is redirected to Traefik,
including the Tailscale address — so tailscaled binds 100.x.y.z:443
successfully and still never receives a connection. The symptom is a
CN = TRAEFIK DEFAULT CERT certificate where a Let's Encrypt one was expected.
Serving on 8443 sidesteps it without touching the cluster.
An SSH tunnel needs nothing installed on the Pi:
ssh -N -L 8090:127.0.0.1:8090 pi@<host> # then open http://localhost:8090/The firmware's sticky "since boot" throttle bits tell you under-voltage
happened, but not when, for how long, or under what conditions — and they
cannot be cleared without a reboot. tools/psu-test.sh
pins every core while sampling the live bits each second, so a marginal supply
becomes a measurement:
./tools/psu-test.sh # 20s idle, 90s loaded, 20s idle
LOAD=180 ./tools/psu-test.sh # longer soak baseline ....
load .!!!!!!.!!!!!!
cooldown !!!!
under-voltage : 16 of 22 seconds (73%)
longest spell : 10 seconds
while loaded : 12 of 14 (86%)
while idle : 4 of 8 (50%)
temperature : 42.3 -> 48.2 C
VERDICT: under-voltage even at idle — suspect the supply or its cable.
Run it once as a baseline, then again after changing exactly one thing — a different wall socket, no extension lead, another supply — and compare. It exits non-zero when under-voltage is seen, so it also works in a cron check. Temperature is reported alongside so a hot board is not mistaken for a weak supply; thermal throttling starts far above anything a supply problem produces.
Needs nothing installed: vcgencmd, awk and a shell.
go test ./... # parsers, decoders, and the sampler's ranking
go test -race ./... # note: does not run on a Pi, see belowThe suite covers the pure functions — /proc/stat, /proc/meminfo,
/proc/net/dev and /proc/<pid>/stat parsing, the throttle bit-word, journald's
two MESSAGE encodings, Docker's CPU-percent and page-cache arithmetic,
systemctl show records, and process ranking — at 90–100% each. Two of them are
regression tests for bugs that actually shipped: filesystems deduplicating by
device, and argv never being published by default.
-race aborts on a Raspberry Pi with ThreadSanitizer: unsupported VMA range
(the Pi kernel uses a 39-bit address space, TSan wants 48). CI runs it on amd64.
/sys/fs/cgroup/cgroup.controllers lists cpuset cpu io pids — no memory.
Raspberry Pi OS ships that way. The consequence is that Docker's stats endpoint
returns an empty memory_stats and systemd reports MemoryCurrent=[not set],
so neither container nor service memory is directly readable.
pidash works around it by summing the RSS of every process in the cgroup
(cgroup.procs is present regardless of controller). That over-counts shared
pages slightly, but it is a real number rather than a dash.
To get exact accounting instead, append to /boot/firmware/cmdline.txt (one
line, space-separated) and reboot:
cgroup_memory=1 cgroup_enable=memory
That costs a small amount of kernel memory overhead and requires a reboot, so it is left as a deliberate choice rather than done for you.
pidash is a visual program, and for several commits it was verified only by
reading its own source — which is how a table whose numeric columns collided,
and a Storage panel listing the root filesystem three times, both shipped.
tools/uicheck.py drives the running dashboard in headless
Chromium and fails on the things source review cannot see:
sudo apt install chromium
python3 -m venv .venv && .venv/bin/pip install playwright
.venv/bin/python tools/uicheck.py --url http://localhost:8090 --out /tmp/shotsIt asserts no console errors, no horizontal overflow at 1680/1280/980/820/420px,
no card clipping its own content, no table cell wrapping, that the drawer opens
on J, and that the per-core bars, filesystems and four throttle flags all
render. It also writes screenshots of the dashboard and the open drawer.
Playwright ships no arm64 browser build, so it drives the system Chromium and downloads nothing.
main.go flags, HTTP routes, SSE plumbing
sampler.go the single sampling loop; fans one snapshot out to all viewers
metrics.go /proc, /sys and vcgencmd readers
docker.go Engine API over the unix socket
units.go systemd unit state
logs.go shared journal tail with a replay backlog
web/ index.html, style.css, app.js — embedded via go:embed
Standard library only. The frontend has no build step and no external assets: no CDN, no web fonts, so it loads fine on a LAN with no internet route.
MIT — see LICENSE.