17 KiB
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:
- if
UWUDASH_OIDC_ADMIN_CLAIMis set and the token's claim (scalar or list) contains one ofUWUDASH_OIDC_ADMIN_VALUES→admin; - otherwise, if the user store is empty →
admin(the first person to log in bootstraps the dashboard); - 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 ..." }
}
]
}
idis generated from the name (slugified, unique) and stays stable across edits.urlmay include a path; that path becomes the landing path of the proxy.open_inisiframe(default) ornewtab.headersare injected into every proxied request to that service.enabled: falsekeeps the entry in the sidebar but the proxy returns403.
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"
}
]
}
idis 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, oneKey: valueper 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'sApiKey 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
seriesIdplus an episodetitle, so the request asks forincludeSeries=trueand reads the name off the nestedseriesobject (Radarr ignores that parameter). Plex episodes take the show fromgrandparentTitle(parentTitleis the season), skipped when it repeats the episode title. severityis notice-only:urgent/heads-up/infotints 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
calendarwidget, ascending by date) and one group perrecentwidget (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:
Locationheaders on3xxresponses are rewritten back onto the proxy prefix; redirects are not followed, so the browser stays on the proxy.text/htmlresponses are buffered and rewritten.application/jsonis 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'sinitialize.jsonreturnsurlBase/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, soContent-EncodingandContent-Lengthare never forwarded - the body the browser receives is always uncompressed and header-consistent. X-Frame-Optionsand CSPframe-ancestorsare stripped from proxied responses so the iframe can render (disable withUWUDASH_STRIP_FRAME_OPTIONS=false).- Hop-by-hop request/response headers are dropped; per-service
headersare merged into the upstream request. - Request bodies are buffered with a 10 MB cap (
413above 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
headerstoken. - 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.