jerry@homelab:~/projects$ cat dashboard.md
home-dashboard
My homelab start page: every service as a tile with a live status dot, plus panels for what needs attention, host load, backups, builds, and the personal stuff (agenda, tasks, mail, RSS). It's one FastAPI app and one static page, running on Wasteland, my main server, behind single sign-on.
Why I built it
I used Homepage for a while. It's good, but I kept wanting functionality it didn't have, and a look and feel that was my own. And honestly: I like building stuff.
Building my own also let me fix the thing that bugged me most. Ready-made dashboards want you to keep a list of links and health checks inside the dashboard. I already had both elsewhere: every container is defined in a compose file, and Gatus already checks every endpoint. So home-dashboard owns no list at all. A container shows up as a tile when its compose file says so:
labels:
home.enable: "true"
home.name: "Paperless-ngx"
home.group: "Documents"
home.icon: "paperless-ngx"
home.href: "https://paperless.example.com"
The status dot comes from Gatus, matched by name. Deploy a new service with these labels and it appears on the page within a minute. Remove it and the tile goes too.
What it shows
- Tiles, grouped, built from Docker labels across all hosts, Wasteland and Badlands included (read through Dockhand). Each tile shows whether the service answers (Gatus), whether its container runs (Docker state: exited, restarting, unhealthy), whether an image update is waiting, and uptime over 24 hours and 7 days.
- Needs attention in the header: one chip per real problem (a down check, an exited container, an unreachable host, a failed build, an overdue backup, a filling disk), oldest first. The same count shows on the browser tab.
- Systems row: CPU, RAM and disk per host from Beszel, with a "disk full in ~10 days" forecast when a disk is clearly filling up.
- Today row: agenda (CalDAV plus ICS feeds, like a football club's fixtures), tasks, the latest mail, unread RSS.
- Widgets: backups, CI builds, Pi-hole, the Paperless inbox, failing n8n workflows, Immich jobs, Ollama on Sanctuary (my laptop, so "offline" is normal there), Minecraft servers, renewals, and the visitor count of this website.
- A layout editor: drag panels and tile groups around, resize columns, set the phone order, and save. It's also stored as a
layout.yamlin git. - Three looks, each in dark and light, all checked for WCAG AA contrast. One of them borrows this site's pink.
How it's built
A Python backend polls every source on a timer and keeps the results in memory. The page only ever reads those cached results, so it never waits on a slow upstream.
browser โโโถ Caddy + Authelia (SSO) โโโถ home-dashboard
โ
static page โโโโโโโโโโค /api/*: served from memory
(ES modules, โ
no build step) โผ
poller, every 60 s / 5 min
each source isolated, own timeout
โโโโโโโโโโโฌโโโโโโโโโโฌโโโโโโโโโโฌโโโโโโดโโโโฌโโโโโโโโโโฌโโโโโโโโโโโ
Dockhand Gatus Beszel Forgejo Pi-hole Radicale, IMAP,
containers status hosts builds DNS Miniflux, Memos,
+ labels + uptime + disks Immich, n8n, โฆ
โ
SQLite: uptime history,
disk trend, problem start
- Backend: FastAPI on Python 3.12,
httpxfor the async clients,asyncio.to_threadaround the blocking ones (CalDAV, IMAP). About 18 integrations, each its own module and cache. A slow or broken one can't hold up the rest. - Frontend: plain ES modules, no framework and no bundler. Later polls update tiles in place, so focus and open tooltips survive a refresh.
- History: a small SQLite file using the standard-library
sqlite3, which gives uptime percentages, the disk forecast and "exited ยท 3 days". - Access: no login code in the app. It sits behind Caddy with Authelia forward-auth, publishes no ports, and only the reverse proxy can reach it.
- Container: read-only filesystem, all Linux capabilities dropped, non-root user, strict Content-Security-Policy, no inline script.
- CI/CD: Forgejo Actions runs
ruffandpytestinside the image's test stage. Then it builds, pushes to my own registry, and deploys over SSH with a pinned host key. Playwright scripts take screenshots at four widths and check every look for contrast.
Problems worth telling
A status page that lies is worse than none
The easy way to build a dashboard is to show whatever data you last got. But when a source stops answering, that old data still looks perfectly current, and you don't notice something's wrong until it's too late. The rule this whole codebase follows is that the page must never look fresher than it is:
- Each source tracks when it last succeeded, on the server's monotonic clock, so an NTP jump can't make old data look fresh. After three missed cycles it's marked stale: dimmed, with the reason in a tooltip, and the footer names the source.
- An unexpected answer is a failure, not "nothing". A login page or a changed API used to parse as an empty list, so "Down now" went quiet exactly when things were broken. Every client now raises on a response it doesn't understand, and the poller keeps the previous data, marked stale.
- An empty panel says when it was checked ("No recent mail ยท checked 2m ago"), so you can tell "nothing there" from "nothing loaded".
When "empty" means "unreachable"
Dockhand answers an empty container list, not an error, when it can't reach a host's Docker. So a host going offline made all its tiles silently vanish. The fix compares the list against Dockhand's own container count for that host: empty plus "should have 20" means unreachable. The tiles stay, dimmed with a dashed border and "(unreachable)". They're also saved to disk, so a restart during an outage doesn't lose them.
Sleeping isn't down
My Minecraft servers shut themselves down when nobody's playing. Gatus then reports them as down, so the dashboard showed red whenever nobody was playing. Now a failing check that belongs to a container stopped on purpose shows a grey "asleep" dot and stays out of "Needs attention". A container that crashed, or that runs but doesn't answer, still goes red. The dashboard never guesses "asleep".
Read-only means really read-only
The mail panel must never mark anything as read in my real inbox. It opens the mailbox read-only and fetches headers with BODY.PEEK, two independent safeguards where either alone would do. RSS and calendars are read-only in the same way.
The one write is guarded
Saving the layout is the only write (besides a wake-on-LAN button). It needs a matching Origin, a custom header and a JSON body, so cross-site forms and scripts can't reach it. It also carries the version it was based on, so two devices can't overwrite each other's layout.
What's next
- More actions from the page, like re-enabling Pi-hole blocking or waking a Minecraft server. They'd reuse the same write guard, but each one is a decision about what a start page should be allowed to do.
- A post-deploy health check with automatic rollback.
jerry@homelab:~/projects$ โ
cd ..