2026-07-22 Glance Dashboard Prototype
Context: Dotfiles (.config). Wanted a single self-hosted entry point giving an overview of all deployed services.
What I set out to do
Survey the homelab-dashboard space, compare the two leading candidates (Homepage vs Glance) with real screenshots, pick one, and prototype it.
What I actually did
Comparison. Homepage (gethomepage.dev) is a pure ops panel: service cards with live API stats, 100+ integrations, huge showcase community. Glance (glanceapp/glance) is a feed-reader start page (HN, RSS, releases, markets) with homelab widgets (docker-containers, monitors) as one part. Chose Glance: the daily-driver feed page model appealed more than a stats wall.
Prototype (committed bd25922). New HM module nix/home-manager/modules/glance.nix using upstream services.glance (native Go binary, launchd agent, YAML from Nix attrs). Spec at docs/specs/2026-07-22-glance-dashboard-design.md. Three pages at http://localhost:8280:
- Home — search, calendar, weather (Union City NJ), HN + Lobsters, RSS (Simon Willison, Julia Evans), GitHub releases (glance/home-manager/neovim/signoz), markets.
- Homelab — up/down monitors (SigNoz, Open WebUI, Seerr, SillyTavern, LiteLLM, Suwayomi, media gateway), docker-containers widget off the socket (all ~24 containers), server-stats, bookmarks incl. athena’s ntfy + Obsidian MCP. LiteLLM is a native launchd agent on :4000 (invisible to the docker widget), so it got a monitor with
check-url=/health/livelinessand click-through to/ui/(commitc5110a7). - Media — *arr monitors through the nginx subpath gateway (
/sonarr,/radarr,/prowlarr,/bazarr,/qbt/,/komga) plus Seerr/Suwayomi direct.
All monitors verified green; agent bootstrapped into gui/$UID, HTTP 200.
What was striking
- The launchd domain trap moved. HM’s
launchd.agents.<name>.domainoption now defaults togui(since 2026-06-18), which is why atuin/tealdeer overrides were dropped in695aebf— but upstreamservices.glancesetsdomain = lib.mkDefault "user", and a module-levelmkDefaultoutranks an option default. Glance therefore still needed the explicitdomain = "gui"or it would EIO on macOS 26. Any future upstream HM service module that setsmkDefault "user"has the same trap. - Port 8080 (Glance’s default) is already the media-stack nginx gateway; picked 8280.
- The docker-containers widget flags the one-shot init containers (orchestrator, signoz-init/retention/migrator) as warnings because they exited — cosmetic, hideable later via
glance.hidelabels on those containers.
Round 2 (same day, commit 5fb8a19)
Full build-out after liking the prototype: custom-api live stats (qBittorrent transfer via the nginx in-network auth allowlist, unauthenticated; Sonarr/Radarr queue status with X-Api-Key; SigNoz firing-alert count via the identn impersonation header), a glance-wrapper exec script that exports the *arr API keys at launch so the yaml’s ${VAR} interpolation resolves without keys entering the store, theme derived from the stylix base16 scheme with a pure-Nix RGB→HSL converter, tailscale serve on :8444 for the phone, ntfy monitor as an atlas→athena reachability check, one-shot containers hidden via the widget’s containers map (no cross-repo compose label edits needed), server-stats disk repointed at /System/Volumes/Data (root snapshot reads a misleading 100%), search bangs, reddit group, and icon fixes (si:signoz→di:signoz, LiteLLM→BerriAI GitHub avatar).
Gotchas hit: the repo .gitignore’s **secret** pattern swallowed the wrapper’s original glance-with-secrets name (an untracked file is invisible to pure flake eval), fixed by renaming; “missing” monitor icons were mostly the fade-in transition racing screenshots, only two were real 404s; Glance pages are JS-hydrated shells, so curl | grep cannot verify rendered content.
Round 3: phone routing (commits ceb421a..fa22dc5)
All click-through URLs now use tailnet endpoints where one exists, while health checks stay on localhost (check-url), so the dashboard works fully from the phone at atlas.tail0cef90.ts.net:8444. New tailscale serve mappings in media-stack.nix: :8445 → nginx gateway (Sonarr/Radarr/Prowlarr/Bazarr/qBittorrent/Komga subpaths), :8446 → Seerr, :8447 → Suwayomi. Security note recorded in the module: gateway reachability IS authorization (nginx injects X-Auth-User), tailnet-only serve preserves that trust model. Gotcha: Komga’s add-slash redirect 400s behind the tailscale hop, so its link needs the trailing slash. Also: weather switched to celsius; SillyTavern icon needed the dashboard-icons PNG URL directly (di: fetches svg variants, sillytavern is png-only); LiteLLM’s “grapes” were the BerriAI org avatar, replaced with sh:litellm from selfh.st.
Follow-up (56c6df4): SillyTavern was the last localhost-only link; now served on :8448. Its whitelist needed the Tailscale CGNAT range (100.64.0.0/10) because enableForwardedWhitelist: true judges the forwarded tailnet client IP, not the loopback peer (403 until added). Phone confirmed working end to end.
Top 3 next
- Live with it a few days; prune/add feeds and widgets that earn their place.
- Optionally add
glance.hide: truelabels to the one-shot containers to clean up the warnings. - Consider a tailscale-serve mapping so the phone can reach it like Open WebUI/SigNoz.