UwuDash MK3

A single Python app that is both the frontend and the backend for a dashboard of internal services. The sidebar has one button per service; clicking it loads the service in an iframe through the dashboard's built-in reverse proxy, so the upstream services never need to be public. The sidebar is one viewport tall - once the buttons overflow it, its own list scrolls and the rest of the page stays put.

browser ──► uwudash (host:port) ──► /proxy/<id>/<path> ──► upstream origin

Run

uv sync
uv run uwudash-mk3
# http://127.0.0.1:8000

Development reload:

uv run uwudash-mk3 --reload

Container

docker build -t uwudash-mk3 .
docker run -d -p 8000:8000 -v uwudash-data:/app/data uwudash-mk3

# git.lovelynet.net also serves a container registry on the same host
docker login https://git.lovelynet.net
docker tag uwudash-mk3 git.lovelynet.net/benjamyn/uwudash_mk3:latest
docker push git.lovelynet.net/benjamyn/uwudash_mk3:latest

docker pull git.lovelynet.net/benjamyn/uwudash_mk3:latest   # any other machine

docker login wants the forge's registry credentials, not the OIDC sign-in password - a wrong password fails with unauthorized at /v2/, and a successful login is remembered in ~/.docker/config.json for later pushes.

The image is two-stage: uv build produces the wheel from the src/ layout, a clean stage installs it into /app/.venv, and the entrypoint is the uwudash-mk3 console script. It runs as uid 10001 and binds 0.0.0.0:8000 (UWUDASH_HOST / UWUDASH_PORT are baked in as ENV defaults - override both the --env and the -p mapping if you change the port).

/app/data is a VOLUME holding services.json, widgets.json (and users.json once auth is on): a named volume keeps your service list across restarts, a bind mount (-v ./data:/app/data) seeds the container from the host file instead. data/ is in .dockerignore, so internal URLs and per-service headers tokens never land in an image layer. With a bind mount the directory must be writable by uid 10001 (chown -R 10001 data); a named volume inherits the image's ownership.

Sign-in from a container - secrets come from the environment, never the build context (.dockerignore excludes .secrets, .oauth_creds and start.sh):

docker run -d -p 8000:8000 -v uwudash-data:/app/data \
  --env UWUDASH_OIDC_ISSUER=https://id.example.com \
  --env UWUDASH_OIDC_CLIENT_ID=uwudash \
  --env UWUDASH_OIDC_CLIENT_SECRET=... \
  --env UWUDASH_SESSION_SECRET="$(openssl rand -hex 32)" \
  --env UWUDASH_PUBLIC_URL=https://dash.example.com \
  uwudash-mk3

HEALTHCHECK polls /health, the one route that needs no session, so the container stays healthy with auth on.

Command line flags

Flag Default Meaning
--host 127.0.0.1 bind address
--port 8000 bind port
--data-dir data directory holding services.json
--services-file <data-dir>/services.json explicit store path
--widgets-file <data-dir>/widgets.json explicit widget store path
--oidc-issuer unset OIDC issuer; enables sign-in together with the client env vars
--public-url request origin external base URL used for the OIDC redirect_uri
--reload off uvicorn reload on source changes

Environment variables

Variable Default Meaning
UWUDASH_HOST 127.0.0.1 bind address
UWUDASH_PORT 8000 bind port
UWUDASH_DATA_DIR data data directory
UWUDASH_SERVICES_FILE data/services.json services store path
UWUDASH_WIDGETS_FILE data/widgets.json widget store path
UWUDASH_WIDGET_CACHE_TTL 300 seconds a live widget's upstream payload is reused (0 = fetch every render)
UWUDASH_PROXY_TIMEOUT 30.0 upstream request timeout (seconds)
UWUDASH_STRIP_FRAME_OPTIONS true strip X-Frame-Options and CSP frame-ancestors from proxied responses
UWUDASH_OIDC_ISSUER unset OIDC issuer, e.g. https://id.example.com (auth stays off unless issuer, client id and secret are all set)
UWUDASH_OIDC_CLIENT_ID unset OIDC client id
UWUDASH_OIDC_CLIENT_SECRET unset OIDC client secret
UWUDASH_OIDC_SCOPE openid email profile requested scope
UWUDASH_OIDC_ADMIN_CLAIM unset claim to inspect for admin group membership, e.g. groups
UWUDASH_OIDC_ADMIN_VALUES admin comma-separated claim values that grant admin
UWUDASH_SESSION_SECRET random per start signed-session cookie secret (set it, or sessions die on restart)
UWUDASH_SESSION_TTL 3600 session lifetime in seconds
UWUDASH_USERS_FILE <data-dir>/users.json user store path
UWUDASH_PUBLIC_URL request origin external base URL for the OIDC redirect_uri

CLI flags override environment variables.

Authentication (opt-in)

With no issuer set the dashboard is open, exactly as before: no /auth/* routes are registered and every API route is reachable from the LAN.

Setting UWUDASH_OIDC_ISSUER + UWUDASH_OIDC_CLIENT_ID + UWUDASH_OIDC_CLIENT_SECRET turns sign-in on. The provider is discovered from <issuer>/.well-known/openid-configuration (Authlib reads the authorize, token, JWKS and end_session_endpoint URLs), so nothing here is provider-specific - Keycloak, Authentik, Auth0 and the like all work.

export UWUDASH_OIDC_ISSUER=https://id.example.com
export UWUDASH_OIDC_CLIENT_ID=uwudash
export UWUDASH_OIDC_CLIENT_SECRET=...
export UWUDASH_OIDC_ADMIN_CLAIM=groups
export UWUDASH_OIDC_ADMIN_VALUES=dash-admin
export UWUDASH_SESSION_SECRET=$(openssl rand -hex 32)
uv run uwudash-mk3 --public-url https://dash.example.com

Flow: / with no session redirects to /auth/login → Sign in → /auth/authorize → the provider → /auth/callback validates the id_token against the issuer's JWKS → a signed session cookie (HttpOnly, SameSite=lax, max_age=UWUDASH_SESSION_TTL) holds the user id → /.

/auth/callback also copes with providers whose id_token authlib cannot read: an encrypted one (a 5-segment JWE, e.g. an Authentik provider with an Encryption Key) is resolved through the issuer's userinfo_endpoint, and an HS256 one (what Authentik issues when no Signing Key is configured) is verified with the client secret. Claims that fail validation - wrong iss, aud, exp or nonce - are rejected outright: the login redirects to /auth/login?error=... instead of returning a 500.

Once auth is on, every route except /health, /auth/*, /static and FastAPI's own /docs / /openapi.json requires a session: unauthenticated requests get 401 (API) or a redirect to /auth/login (the HTML page).

Roles

Role Can
admin everything a viewer can, plus create/update/delete services and list or change user roles
viewer read the service list, open services through the proxy, see their own profile

The role is recomputed on every login, but a login only ever promotes: a matching UWUDASH_OIDC_ADMIN_CLAIM turns an existing viewer into admin, while an existing admin is never demoted by a login - demotions go through PATCH /api/users/{id} (or the Settings page), so a misconfigured claim cannot lock the dashboard out:

  1. if UWUDASH_OIDC_ADMIN_CLAIM is set and the token's claim (scalar or list) contains one of UWUDASH_OIDC_ADMIN_VALUES → admin;
  2. otherwise, if the user store is empty → admin (the first person to log in bootstraps the dashboard);
  3. otherwise → viewer.

Users are stored in data/users.json (same atomic-write format as data/services.json), keyed by the OIDC sub; email and name are refreshed on each login.

Signing out

/auth/logout (GET or POST) always clears the local session. If the issuer advertises an end_session_endpoint, it then does RP-initiated logout with the saved id_token and returns to /auth/login afterwards; otherwise it returns to /auth/login directly.

Services

Services live in data/services.json:

{
  "version": 1,
  "services": [
    {
      "id": "grafana",
      "name": "Grafana",
      "url": "http://10.6.9.199:3000/dashboards",
      "description": "Metrics dashboards",
      "icon": "G",
      "open_in": "iframe",
      "enabled": true,
      "headers": { "Authorization": "Bearer ..." }
    }
  ]
}
  • id is generated from the name (slugified, unique) and stays stable across edits.
  • url may include a path; that path becomes the landing path of the proxy.
  • open_in is iframe (default) or newtab.
  • headers are injected into every proxied request to that service.
  • enabled: false keeps the entry in the sidebar but the proxy returns 403.

Edit them from the Settings page in the UI, or via the API.

Widgets

Widgets are native dashboard content - no iframe, no URL bar. They live in data/widgets.json (same atomic-write/lock format as services.json) and come in three kinds:

kind what it shows what it needs
notice pinned text, nothing fetched body (≤ 5000 chars, line breaks kept), severity (info/heads-up/urgent, default info)
calendar a release window from a Sonarr-family API base_url, api_prefix (default /api/v3), days (1-90), date_type (auto/physical/digital/cinemas/release)
recent what just landed (<base_url>/library/recentlyAdded) base_url, limit (1-50)
{
  "version": 1,
  "widgets": [
    {
      "id": "upcoming",
      "name": "Upcoming",
      "icon": "",
      "kind": "calendar",
      "body": null,
      "severity": "info",
      "base_url": "http://internal-host:8080",
      "headers": { "X-Api-Key": "redacted" },
      "api_prefix": "/api/v3",
      "days": 14,
      "date_type": "auto",
      "limit": 12,
      "created_at": "2026-10-28T09:29:31+00:00",
      "updated_at": "2026-10-28T09:29:31+00:00"
    }
  ]
}
  • id is slugified from the name and stays stable across edits.
  • Live widgets are fetched at render time, never at save time: the first render after a save fetches, later renders reuse the payload until UWUDASH_WIDGET_CACHE_TTL (default 300 s) expires. The cache key is <id>@<updated_at>, so editing a widget invalidates it immediately. Errors cache for at most 60 s so a fixed upstream recovers quickly.
  • Upstream keys go in headers (the form's "Headers" textarea, one Key: value per line) - they stay on the server-to-server hop and never appear in the browser.
  • Unreachable upstreams degrade instead of blanking the board: the card shows unreachable: <error> and the Settings table's Test button relays the exact upstream message (connection refused, Sonarr's ApiKey was not provided...).
  • Artwork (Radarr posters, Plex thumbs) is relayed through /api/widgets/{id}/image?u=... - the browser cannot reach the internal host, and the relay only ever fetches from that widget's own origin.
  • Calendar cards always name the show, not just the episode: Sonarr's calendar items carry only seriesId plus an episode title, so the request asks for includeSeries=true and reads the name off the nested series object (Radarr ignores that parameter). Plex episodes take the show from grandparentTitle (parentTitle is the season), skipped when it repeats the episode title.
  • severity is notice-only: urgent/heads-up/info tints the card edge, the chip, and the icon on Home. Setting it on a live widget is a validation error (live rows have no severity to sort by).
  • Home renders one merged release group (every calendar widget, ascending by date) and one group per recent widget (newest first) - each group is a header (title, refresh stamp, per-widget errors) followed by one card per release, laid out as a wrapping grid - then notices, urgent first, then heads-up then info, each group in creation order. Cards are tinted by the app that produced them (Sonarr teal, Radarr violet, Plex orange - separate light/dark shades of each), on the card edge, the source chip, and a legend chip per origin in the group header, so a merged timeline says which app each card came from. The next upcoming release that carries artwork also gets a full-width hero tile above the groups (its relayed thumb plus title and date), ringed in the same origin hue. Each widget gets its own sidebar button for a single-widget view, its left edge tinted with the same origin hue once the widget has returned rows (an errored or empty widget stays accent-blue).
  • Writes are admin-only when OIDC is on, reads any session - same gate as services.

Edit them from the Settings page (the "Add widget" form and the widget table, which carries a Test button per live widget), or via the API.

How the proxy works

Only the upstream origin (scheme://host:port) is mapped onto the dashboard:

/proxy/<id>            -> <origin>/
/proxy/<id>/<path>     -> <origin>/<path>

So a service with url = http://internal-host:8080/sub/page is served at /proxy/<id>/sub/page, and any link the upstream page emits to http://internal-host:8080/... or //internal-host:8080/... is rewritten to /proxy/<id>/.... Root-relative links (/foo/bar) are pulled back onto the proxy prefix too - they would otherwise resolve against the dashboard and 404. Links relative to the current page (foo/bar) need no rewriting.

Details:

  • Location headers on 3xx responses are rewritten back onto the proxy prefix; redirects are not followed, so the browser stays on the proxy.
  • text/html responses are buffered and rewritten. application/json is rewritten as well, but only values that start with the service's own base path: single-page apps read their base path from an API config (Sonarr's initialize.json returns urlBase/apiRoot), while plain data fields such as filesystem paths are left alone. Everything else (CSS, images, downloads) is streamed through unchanged.
  • Upstream compression (gzip, deflate, br) is decoded by httpx, so Content-Encoding and Content-Length are never forwarded - the body the browser receives is always uncompressed and header-consistent.
  • X-Frame-Options and CSP frame-ancestors are stripped from proxied responses so the iframe can render (disable with UWUDASH_STRIP_FRAME_OPTIONS=false).
  • Hop-by-hop request/response headers are dropped; per-service headers are merged into the upstream request.
  • Request bodies are buffered with a 10 MB cap (413 above it).
  • WebSocket upgrades are rejected with 400 - the proxy is HTTP only.

API

Method Path Purpose
GET /api/services list services
POST /api/services create
GET /api/services/{id} read
PATCH / PUT /api/services/{id} update
DELETE /api/services/{id} delete
GET /api/services/{id}/health upstream reachability check
GET /api/widgets every widget, with live data inlined for calendar/recent
POST /api/widgets create - admin only
GET /api/widgets/{id} read the stored config
PATCH /api/widgets/{id} update - admin only
POST /api/widgets/{id}/refresh bypass the cache and fetch now - admin only
GET /api/widgets/{id}/image?u=... relay one artwork URL from that widget's own origin
DELETE /api/widgets/{id} delete - admin only
GET /api/config effective config (never includes the OIDC secret)
GET /api/me current user (only when auth is enabled)
GET /api/users list users - admin only
PATCH /api/users/{id} change a role - admin only
GET /auth/login sign-in page
GET /auth/authorize start the OIDC flow
GET /auth/callback provider redirect target
GET / POST /auth/logout clear the session, then RP-initiated logout
GET /health dashboard health
ANY /proxy/{id} and /proxy/{id}/{path} reverse proxy

Layout

src/uwudash_mk3/
  config.py      env-driven Config dataclass
  models.py      Service / ServiceInput / ServicePatch / Widget / WidgetInput / WidgetPatch / User / UserPatch + URL helpers
  store.py       JSON file store (atomic writes, lock)
  widgets.py     JSON widget store + server-side fetch/cache/relay layer
  users.py       JSON user store (same pattern as store.py)
  auth.py        OIDC client, login/callback/logout, role dependencies
  proxy.py       reverse proxy: header filtering, HTML/redirect rewriting
  api.py         /api routes
  app.py         FastAPI factory, session middleware, static mount, proxy routes
  main.py        CLI entrypoint
  templates/     index.html, login.html
  static/        styles.css, app.js

Limitations

  • Authentication is opt-in OIDC (see above). Without an issuer the dashboard is still open by design - it is meant for a trusted LAN. Sign-in gates the dashboard, not the upstream services: the proxy does not forward the dashboard session, so each service still needs its own login or a per-service headers token.
  • Roles are decided at first login and only change through the admin API; there is no group sync after that, and no per-service permissions.
  • No WebSocket proxying. Apps that use WebSockets for live updates (Sonarr's SignalR) fall back to SSE/long-polling through the proxy.
  • Upstream cookies work, but they are scoped to the dashboard origin, so cross-service cookie sharing is possible if two services share a proxy host.
S
Description
No description provided
Readme
461 KiB
0 Stars 1 Watchers 0 Forks
Languages
Python 60%
JavaScript 22%
CSS 10.7%
HTML 5.5%
Dockerfile 1.8%