Dashy Board Sync
Two tools for keeping the Dashy board honest against what Proxmox and Traefik actually hold.
The board is built from Traefik’s live router table, not from guest names or compose labels, and that table drifts in two directions that look nothing alike:
- a guest is destroyed and its tile keeps pointing at a route that answers 502
- a guest is created — or recreated, which is the one that catches people — and comes back with the stock community-script description, so its label block is gone and it is running, healthy, and reachable by nobody
Neither is reported anywhere. The first shows up as a red tile eventually; the second shows up as a tile that used to work.
board_diff.py — what changed
Section titled “board_diff.py — what changed”Runs on the workstation. Reads the Proxmox API, Traefik’s router table and
community-scripts-lxc/dashy/user-data/conf.yml, and prints the three-way diff.
python3 utils/dashy-board-sync/board_diff.pypython3 utils/dashy-board-sync/board_diff.py --probe # HTTP-check everything tooCredentials come from .claude/creds.txt (gitignored) or from PROXMOX_URL /
PROXMOX_TOKEN_SECRET in the environment. Stdlib only — no pip install.
Three sections come out:
| Section | Means |
|---|---|
| tiles with no route | Split by probe: not answering is a real problem, answering is served by the prod-talos cluster or the tunnel and is expected |
| routed, no tile | Candidates for the board. .fr twins of tiled .lan hosts are filtered out — the board deliberately links the .lan name |
| running guests with no route | Mostly deliberate: plenty are reached by IP or consumed by another service |
That last section compares backend IPs, not names. The proxmox provider builds each
service URL from the guest’s own address, so the IP is exact; an earlier version matched
on names and reported all 65 LXCs as unrouted, because remmina-kasm serves remmina,
networking-tools serves networking-toolbox and step-ca serves labca.
Use it to find guests running a web UI that nothing routes — they are invisible to Dashy
and Raindrop too. Check what each listens on with
pct exec <vmid> -- ss -lntH, ignoring the agent ports that sit on most guests (3553
arcane, 7007 dozzle, 2375 docker-proxy, 25 postfix), then probe what is left: an API
answers 200 with no <title>, a real UI has one or redirects to a login.
pve_label_guest.py — give a guest a route
Section titled “pve_label_guest.py — give a guest a route”Runs on the Proxmox node; it shells out to pct / qm. Copy it over first.
python3 pve_label_guest.py 236 komga 25600 # stage and show the blockpython3 pve_label_guest.py 236 komga 25600 --apply # write itpython3 pve_label_guest.py --restore 236 # put the old value backIt appends the standard LAN-only block — internal Step CA cert,
secure-chain-no-auth@file, no Authelia — mirroring what radarr, upsnap and whois
already carry. Staging and applying are separate steps because applying writes to a live
guest.
Three things about the description field it handles for you:
- It is one API field, so every edit rewrites the whole thing. The original is saved
URL-encoded to
/root/pct-backups/<vmid>-desc.encbefore anything is written. - It is capped at 8192 characters, and an oversize value is rejected atomically —
nothing is written, the old value survives. VM 217 hit that ceiling, which is why its
routes live in the file provider
now. The script refuses rather than letting
pct setfail. pct configreturns it URL-encoded. Appending without decoding first buries the block in an escaped blob and the plugin never sees it.
Re-running on an already-labelled guest is a no-op, so it is safe in a loop.
The plugin polls every 30 seconds. No Traefik restart — this is provider state, not static config.
What neither of them does
Section titled “What neither of them does”Guest create/destroy history, which is how you find out that a tile’s guest is gone
rather than merely unrouted. The API token in .claude/creds.txt cannot read
/nodes/{node}/tasks, so this goes over SSH to the node:
pvesh get /nodes/pve-dell/tasks --limit 4000 --output-format json \ | python3 -c "import sys,json,time;[print(time.strftime('%Y-%m-%d %H:%M',time.localtime(t['starttime'])),t['type'],t.get('id',''),t.get('status','')) for t in json.load(sys.stdin) if t['type'] in ('vzcreate','vzdestroy','qmcreate','qmdestroy')]"vmids get reused. CT 208 was prowlarr and is now bookorbit; CT 230 was
openwebui and is now prowlarr. A vzcreate for an id you recognise is not
necessarily the guest you remember, so check the name.