- JavaScript 72.7%
- CSS 18.3%
- HTML 4.7%
- Python 2.6%
- Dockerfile 0.9%
- Other 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| api | ||
| icons | ||
| time_to_leave@2b7ac76eb0 | ||
| .dockerignore | ||
| .gitignore | ||
| apple-touch-icon.png | ||
| docker-compose.yml | ||
| docker-entrypoint.sh | ||
| Dockerfile | ||
| favicon.png | ||
| favicon.svg | ||
| gpu_stats.py | ||
| index.html | ||
| nginx.conf | ||
| README.md | ||
| script.js | ||
| style.css | ||
Homelab Dashboard
A small, self-contained single-page dashboard that links all your homelab services. Static site (HTML + CSS + JS), served by a lightweight nginx image. The optional calendar widget needs the container backend to refresh Google/ICS feeds, but the rest of the dashboard is pure static files.
Services
Internal: TimeToLeave, Open WebUI, ComfyUI, Gitea, Jellyfin, Immich, Home Assistant, Audio Server / Dubplate, Nextcloud, Vaultwarden, Nginx Proxy Manager, Netbird.
External: gitlab-ixsol, gem360.
Features
- Top widgets: Today (calendar events for today, with in-progress highlighting), TimeToLeave (today's upcoming events with live train/bike leave-by Outlook (unread mail), Netbird status (reachability + latency), Time tracking (current month's per-project hours from the time-track server), Git activity (recent commits from Gitea and GitLab — 3 rows visible, scroll for more), GPU + Ollama + llama.cpp (live AMD ROCm stats: utilization, VRAM, temperature, power; plus loaded Ollama models with their GPU/CPU split, and each llama.cpp server's loaded model, slots, unload button and offline state)
- Inline Open WebUI prompt line with model selection in the Open WebUI card
(requires
OPENWEBUI_TOKEN) - Responsive grid of service cards with icons, categories and real online status (server-side health checks every minute: green = online, amber = 5xx error, red = unreachable; hover the badge for latency/details)
- Open (new tab) + Copy URL per service
- Live search / filter across names, categories and URLs
- Live local clock
- Calendar section aggregating multiple ICS feeds (refreshed every 15 min in the container)
- Dark, themeable design; respects
prefers-reduced-motion
Run with Docker
docker compose up -d --build
Then open http://localhost:8888.
The dashboard is static, but the widgets are populated by a small Node.js updater
(api/update-data.js) that runs inside the container and aggregates ICS feeds,
Netbird status, git activity and Open WebUI models every 15 minutes. A second
Node process (api/server.js) proxies chat prompts to Open WebUI.
Environment variables
Secrets (API keys) live in .env (gitignored) — docker compose reads it
automatically. Non-secret settings go directly in docker-compose.yml:
| Variable | Purpose |
|---|---|
TZ |
Timezone used for the "Today" widget (e.g. Europe/Vienna) |
OPENWEBUI_TOKEN |
Open WebUI API key — enables the prompt line + model list (set in .env) |
GITEA_TOKEN |
Gitea API token — private repo stats (public repos work without it) |
GITLAB_TOKEN |
GitLab API token — private project stats (public projects work without it) |
GITLAB_PROXY |
Forward proxy (HTTP CONNECT) for all GitLab requests — git stats and the online badge (set in .env). Change the URL when the proxy stack moves; unset = direct connection. With the proxy on the docker host use http://host.docker.internal:8118 (the container resolves host.docker.internal via extra_hosts) |
GITEA_URL / GITLAB_URL / NETBIRD_URL / OPENWEBUI_URL |
Override the default service URLs |
GPU_STATS_URL |
GPU stats upstream (default: the gpu-stats compose service) |
OLLAMA_URL |
Ollama instance for loaded-model stats (default: http://100.103.83.12:11435) |
LLAMACPP_URLS / LLAMACPP_API_KEY |
llama.cpp servers shown in the GPU widget — comma-separated list (default: http://100.103.83.12:8850,http://100.103.83.12:8087,http://100.103.83.12:8880); offline servers are shown as such. A single LLAMACPP_URL still works. The servers require an API key for /props + /metrics — set LLAMACPP_API_KEY in .env (shared by all servers; the KV-cache/requests rows are omitted when /metrics is unavailable) |
TIMETRACK_URL / TIMETRACK_TOKEN |
Time-track server feeding the time-tracking widget (default: http://100.103.83.12:3008). Set TIMETRACK_TOKEN in .env — it is the server's SYNC_API_TOKEN (bearer) |
OUTLOOK_CLIENT_ID |
Entra ID app client ID for the unread-mail widget (set in .env) |
OUTLOOK_TENANT |
Azure tenant (default: organizations; set to your Directory (tenant) ID if the generic endpoint returns AADSTS50059) |
DEPLOYMENT_URL |
TimeToLeave public base URL, used for Google OAuth redirects (default: http://100.103.83.12:3300) |
CORS_ALLOWED_ORIGINS |
TimeToLeave: comma-separated origins allowed to call its /api/* routes (default: the deployment URL + http://localhost:3300) |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
TimeToLeave Google Calendar sync — optional, unset disables the Google tab (set in .env) |
TIMETOLEAVE_URL |
TimeToLeave backend used by the departures widget updater (default: http://time-to-leave:3000, the compose service) |
TIMETOLEAVE_ORIGIN_EXT_ID / TIMETOLEAVE_ORIGIN_NAME / TIMETOLEAVE_BUFFER_MINUTES |
Departure widget origin station + arrival buffer (defaults: Mödling Bahnhof 1231701, 5 min) |
TIMETOLEAVE_ORIGIN_LAT / TIMETOLEAVE_ORIGIN_LNG |
Departure widget bike start coordinates (default: the TimeToLeave origin address in Mödling) |
Outlook unread mail
The Outlook widget (and the unread count) uses the Microsoft Graph API with a one-time device login. Setup:
- Register an app in Microsoft Entra ID (portal.azure.com → Entra ID → App registrations → New registration): any name (e.g. "Homelab Dashboard"), account type "Accounts in this organizational directory only", no redirect URI.
- In the app: Authentication → Allow public client flows → Yes, then API permissions → Add → Microsoft Graph → Delegated → Mail.Read.
- Copy the Application (client) ID — from App registrations → your app →
Overview. Beware: this is not the "Directory (tenant) ID" and not the
"Object ID". A valid-format but wrong GUID produces AADSTS50059
("No tenant-identifying information found"). Put it in
.envasOUTLOOK_CLIENT_ID=. - Set
OUTLOOK_TENANTin.envto your Directory (tenant) ID. Some directories are not resolvable through the genericorganizationsendpoint (also AADSTS50059 with a valid client ID) — the tenant-specific endpoint always works. docker compose up -d --build- Log in once:
Open the printed URL, enter the code and sign in with your work account. The refresh token is persisted in thedocker exec -it homelab-dashboard node /opt/dashboard/outlook-auth.jsoutlook-tokensvolume.
api/update-outlook.js then refreshes unread mail every minute (caching the
access token, and re-running outlook-auth.js is only needed if login is
revoked). If your tenant requires admin consent, ask your admin to grant it
for Mail.Read.
TimeToLeave
The time-to-leave compose service builds the Next.js web app from
time_to_leave/ (npm-workspaces monorepo; only apps/web
and packages/ are used in the build — mobile/native dirs are pruned via its
.dockerignore). It is published on port 3300 (3000/3001 are taken on
this host) and appears as the first card on the dashboard.
The app validates DEPLOYMENT_URL / CORS_ALLOWED_ORIGINS at build time
(see time_to_leave/apps/web/next.config.ts) and reads them again at runtime,
so they are set both as build args and container environment. Transit backends
(ÖBB HAFAS, Nominatim, OSRM, Wiener Linien, GTFS) have built-in defaults.
The TimeToLeave departures widget (top widget row, between Today and
Outlook) is fed by api/update-timetoleave.js
(cron, every 5 min): it takes today's upcoming events from data.json and asks
the TimeToLeave app's /api/departure endpoint (added in
time_to_leave/apps/web/src/app/api/departure/route.ts) for the latest train
connection and the bike option that still arrive in time — the same
journey-picking logic the app itself uses. Events show the train connection
(departure, changes, delay, final walk) plus a 🚲 by bike line with distance,
duration and its own leave-by time; bikeable events without any transit
connection fall back to a bike-only chip. The browser only renders
timetoleave.json and ticks the countdowns; all HAFAS/geocoding/routing stays
inside the TimeToLeave app.
Note: the Google Calendar "Connect" flow sets Secure cookies in production,
so it only works when the app is reached over HTTPS (plain HTTP drops the
calendar token). ICS URL/file import is unaffected.
GPU stats service
The gpu-stats compose service runs gpu_stats.py, a tiny HTTP
server that queries rocm-smi. It needs the host's ROCm install and GPU device
nodes, so the compose file mounts /opt/rocm and passes /dev/kfd + /dev/dri.
Set GPU_NAME in docker-compose.yml to your card's name (rocm-smi can't read it
inside the container without libdrm_amdgpu). The widget polls it every 5 s
through /api/gpu-stats.
To run it on the host instead: python3 gpu_stats.py, set
GPU_STATS_URL=http://host.docker.internal:9101/stats on the dashboard service
and add extra_hosts: ["host.docker.internal:host-gateway"].
llama.cpp servers + unload button
The llama.cpp block in the GPU widget shows one row per configured server
(LLAMACPP_URLS, comma-separated) with one card per model read from
GET /models, including the load state: loaded (with port, slots,
KV cache, busy requests and an Unload button — two clicks to confirm),
💤 sleeping (--sleep-idle-seconds: auto-unloaded after idle, wakes on
the next request), unloaded and loading. Offline servers are
displayed as such instead of hiding them.
The button calls POST /models/unload on the selected server, which only
exists in router server mode (--models-dir instead of --model;
--models-preset keeps per-model parameters, --models-autoload reloads
models on demand). With a plain --model start the endpoint is not
registered (HTTP 404) and the widget shows a hint instead. Router mode and
--sleep-idle-seconds combine fine — the router passes the idle timer to
each per-model child process.
The llm-owui service in the Open WebUI stack runs in router mode: model
file bind-mounted as models-router/qwen3.8.gguf (keeps the Open WebUI
model ID qwen3.8), per-model parameters in
llamacpp-router/preset.ini, 10 min idle sleep.
Without Docker (quick dev)
Any static file server works for the dashboard itself, but the widgets will show errors because the data fetcher and chat proxy run server-side:
# from this directory
python3 -m http.server 8888
# or: npx serve . -l 8888
Edit services
All services live in one array at the top of script.js.
Add / remove / reorder entries there:
{ section: "internal", name: "My Service", url: "http://10.0.0.5:9000", cat: "infra", icon: "⚙️" }
section is either "internal" or "external".
cat is any key from the CATEGORIES map (or add your own).
Build the image manually
docker build -t homelab-dashboard .
docker run -d --name homelab-dashboard -p 8888:80 --restart unless-stopped homelab-dashboard
Notes
- Online badges are real health checks:
api/update-health.js(cron, every minute) pings each service URL from the container with a 5 s timeout and writeshealth.json; the frontend refreshes badges every 30 s. Any HTTP response below 500 counts as online (401/404 still means the server is up); 5xx shows as "Error", timeouts/DNS failures as "Offline". TLS certificate validity is ignored (reachability only). - Calendar dependencies are bundled in
api/node_modulesso the Docker build works offline. To update them, runnpm installin theapi/directory. - Port 8888 is published by the Compose file; change it there if needed.