10 KiB
Dashboard API Development
This guide covers how to iterate on extensions/services/dashboard-api/ (the FastAPI backend that powers the Dashboard UI) without losing your sanity.
TL;DR
- Editing
.pyfiles underextensions/services/dashboard-api/on the host does not reload the runningods-dashboard-apicontainer. The image bakes a copy into/app/at build time. - Recommended dev workflow: stop both the
dashboardanddashboard-apiDocker containers to free host ports 3001 and 3002, then run Vite dev server for the dashboard frontend (npm run devon port 3001) plus native uvicorn with--reloadfor the API (port 3002). Vite's built-in/apiproxy already points atlocalhost:3002, so the two host processes wire up automatically. The rest of the compose stack (llama-server, host-agent, etc.) stays running. - Only rebuild the image (or
docker cpas a stop-gap) when you actually need to ship the change.
The Trap
The Dockerfile (extensions/services/dashboard-api/Dockerfile) does this at build time:
WORKDIR /app
COPY main.py config.py models.py security.py gpu.py helpers.py agent_monitor.py user_extensions.py ./
COPY routers/ routers/
...
CMD uvicorn main:app --host 0.0.0.0 --port ${DASHBOARD_API_PORT}
Two consequences:
-
The Python source lives at
/app/inside the image. uvicorn is launched from thatWORKDIRand importsmain:appfrom/app/main.py. There is no live link back to the host filesystem. -
The compose service mounts the host repo at
/ods(read-only) — seedocker-compose.base.yml:volumes: - ./scripts:/ods/scripts:ro - ./config:/ods/config:ro - ./extensions:/ods/extensions:ro - ./.env:/ods/.env:ro ...That bind-mount exists so the API can read manifests, scripts, and config at runtime. It is not on Python's import path. Editing
ods/extensions/services/dashboard-api/routers/setup.pyon the host updates/ods/extensions/services/dashboard-api/routers/setup.pyinside the container — a path nothing imports from. The running uvicorn keeps serving the baked/app/routers/setup.py.
If you only test by editing host files and reloading the dashboard, your changes silently no-op. This has bitten contributors before; don't waste an afternoon on it.
Recommended Workflow: Vite + Native uvicorn
The clean dev story is to swap the dashboard and dashboard-api Docker containers out for host-side processes that share the same ports, leaving the rest of the compose stack (llama-server, host-agent, etc.) running. Vite's built-in proxy at extensions/services/dashboard/vite.config.js already routes /api to http://localhost:3002:
// vite.config.js (already in repo, no change needed)
server: {
port: 3001,
proxy: { '/api': { target: 'http://localhost:3002', changeOrigin: true } }
}
So the dev proxy chain becomes:
browser → http://localhost:3001 (Vite dev) → /api/* → http://localhost:3002 (host uvicorn)
Why both containers must be stopped
docker-compose.base.yml binds host port 3001 to the dashboard (nginx) container and host port 3002 to the dashboard-api (uvicorn) container — both with ${BIND_ADDRESS:-127.0.0.1} defaults. If either is running, the matching host-side process refuses to bind.
Stopping only dashboard-api (an earlier draft of this guide's recommendation) is wrong: the Docker dashboard container's nginx config hardcodes proxy_pass http://dashboard-api:3002 (see extensions/services/dashboard/nginx.conf). Without the Docker dashboard-api running, every /api/* request the Docker nginx proxies returns 502 Bad Gateway. The correct path is to stop both containers and replace them with Vite + uvicorn running on the same host ports.
You can keep the rest of the stack (llama-server, ods-host-agent, qdrant, etc.) running. Only the two dashboard containers need to step aside.
One-time setup
# API venv
cd ods/extensions/services/dashboard-api
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# Frontend deps
cd ../dashboard
npm install
Each session
Stop the two dashboard containers so host ports 3001 and 3002 are free:
cd /path/to/ods
docker compose stop dashboard dashboard-api
Terminal 1 — Vite dev server (claims host port 3001):
cd ods/extensions/services/dashboard
npm run dev
Terminal 2 — native uvicorn (claims host port 3002). Set ODS_INSTALL_DIR to the path of your installed ODS repo (the same path the container sees as /ods):
cd ods/extensions/services/dashboard-api
source .venv/bin/activate
ODS_INSTALL_DIR=/path/to/ods \
ODS_DATA_DIR=/path/to/ods/data \
ODS_AGENT_HOST=127.0.0.1 \
ODS_AGENT_PORT=7710 \
DASHBOARD_API_KEY="$(grep ^DASHBOARD_API_KEY /path/to/ods/.env | cut -d= -f2-)" \
ODS_AGENT_KEY="$(grep ^ODS_AGENT_KEY /path/to/ods/.env | cut -d= -f2-)" \
uvicorn main:app --host 127.0.0.1 --port 3002 --reload
ODS_AGENT_HOST=127.0.0.1 is required when running uvicorn natively on the host — config.py defaults it to host.docker.internal, which Docker Desktop only injects inside containers. Without this override, any host-agent-backed dashboard-api route (extensions install/start, model download, /v1/env/update, etc.) fails DNS resolution and returns 502.
uvicorn's --reload uses watchfiles and picks up edits to any .py file under the working directory. Vite hot-reloads JSX/CSS as you edit.
Browse to http://localhost:3001 (Vite). Backend traffic flows through Vite's proxy to your host uvicorn; the rest of the stack (host-agent on 7710, llama-server on 8080, etc.) is reached the same way it was via the Docker dashboard.
When you're done, restart the production containers:
docker compose start dashboard dashboard-api
Mirror any other env vars your code path reads (OLLAMA_URL, LLM_MODEL, KOKORO_URL, etc.) from the values in .env. A small wrapper script that exports the relevant subset is a reasonable thing to keep locally. Note that DASHBOARD_API_KEY and ODS_AGENT_KEY are independent secrets (since PR #979 they are no longer aliased) — if you only export one, calls in the direction that needs the other will return 401.
Stop-gap: docker cp
If running native uvicorn isn't an option (you only have Docker, you're debugging something that depends on the container's exact env, etc.), copy the file straight into /app/ and restart the service:
cd ods/extensions/services/dashboard-api
docker cp routers/setup.py ods-dashboard-api:/app/routers/setup.py
docker compose restart dashboard-api
This survives until the next image rebuild, after which the baked copy wins again. It is a survivable stop-gap for hot patches; it is not a substitute for committing the change.
Permanent Change: Rebuild the Image
Anything you want to ship has to make it into the image:
docker compose build dashboard-api
docker compose up -d dashboard-api
This is what running installs pick up the next time they pull or rebuild.
Why Not Just Bind-Mount the Source? (Option B, rejected)
Mapping ./extensions/services/dashboard-api:/app would give "live edits in the container" at the cost of:
- Writable mount required. Python writes
__pycache__/next to source files. A read-only bind-mount makes every import fail; a read-write mount lets the container scribble bytecode back into the contributor's working tree. - Breaks standalone use of the prebuilt image. Anyone running
docker run light-heart-labs/dashboard-api(or pulling the image without our compose file) would get an empty/app/because the bind-mount isn't there. The image would no longer be self-contained. - Divergent dev and prod images. Dev would import from the bind-mount; prod from the baked copy. Bugs that depend on file layout, permissions, or pycache state would only show up on one path.
Native uvicorn gets the same iteration speed without any of those drawbacks.
Why Not a --reload Compose Overlay? (Option C, rejected)
The obvious alternative is a docker-compose.dev.yml that overrides the dashboard-api command:
# rejected
services:
dashboard-api:
command: uvicorn main:app --host 0.0.0.0 --port 3002 --reload --reload-dir /ods/extensions/services/dashboard-api
The trap: WORKDIR is still /app. uvicorn launches in /app, imports main:app from /app/main.py, and watches /ods/extensions/services/dashboard-api/ for file changes. The watcher fires correctly, uvicorn reloads — and re-imports the same baked /app/ code. The reload appears to work and the change appears to do nothing.
To actually pick up host edits, the overlay has to also change WORKDIR (or wrap the command in bash -c "cd /ods/extensions/services/dashboard-api && uvicorn ...") and ensure Python's import path points at the bind-mount. At that point you've effectively re-implemented Option B inside an overlay, with all of its drawbacks plus a confusing dev-only command. Running uvicorn on the host is shorter and clearer.
Cross-Platform Notes
- macOS (Apple Silicon / Intel). Native uvicorn works perfectly. Watchfiles uses FSEvents; reloads are fast and reliable. No osxfs involvement because the source is on the host filesystem to begin with.
- Linux. Native uvicorn works perfectly. Watchfiles uses inotify.
- Windows / WSL2. Run the WSL2 instance (uvicorn) with the repo on the WSL2 filesystem (e.g.
~/ODS). Editing on/mnt/c/...(the Windows NTFS mount) makes inotify watches unreliable — file change events drop or arrive late. Keep the working tree inside the WSL2 home directory, edit it via VS Code's WSL remote, and reload triggers behave the same as on Linux.
See Also
- EXTENSIONS.md — adding a new service extension.
- HOST-AGENT-API.md — the host-agent endpoints the dashboard-api calls into.
extensions/services/dashboard-api/Dockerfile— the source of truth for what gets baked into/app/.docker-compose.base.yml— the dashboard-api service definition (volumes, env,extra_hosts).