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/liveliness and click-through to /ui/ (commit c5110a7).
  • 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>.domain option now defaults to gui (since 2026-06-18), which is why atuin/tealdeer overrides were dropped in 695aebf — but upstream services.glance sets domain = lib.mkDefault "user", and a module-level mkDefault outranks an option default. Glance therefore still needed the explicit domain = "gui" or it would EIO on macOS 26. Any future upstream HM service module that sets mkDefault "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.hide labels 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:signozdi: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

  1. Live with it a few days; prune/add feeds and widgets that earn their place.
  2. Optionally add glance.hide: true labels to the one-shot containers to clean up the warnings.
  3. Consider a tailscale-serve mapping so the phone can reach it like Open WebUI/SigNoz.

Homelab Services Architecture