Files
wehub-resource-sync 9e8f1bbeed
Dashboard / frontend (push) Failing after 0s
Dashboard / api (push) Failing after 0s
Lint PowerShell / powershell-lint (ubuntu-latest) (push) Failing after 1s
Python Lint / Lint Python with Ruff (push) Failing after 1s
ShellCheck / Lint shell scripts (push) Failing after 1s
Matrix Smoke / linux-smoke (push) Failing after 1s
Matrix Smoke / distro: cachyos (push) Failing after 15s
Matrix Smoke / distro: linux-mint-21.3 (push) Failing after 15s
Matrix Smoke / distro: debian-12 (push) Failing after 5m21s
Matrix Smoke / distro: fedora-41 (push) Failing after 4m56s
Matrix Smoke / distro: ubuntu-24.04 (push) Failing after 2m13s
Matrix Smoke / distro: rocky-9 (push) Failing after 10m39s
Matrix Smoke / distro: manjaro (push) Failing after 12m11s
Matrix Smoke / distro: opensuse-tw (push) Failing after 11m53s
Matrix Smoke / distro: archlinux (push) Failing after 20m3s
Matrix Smoke / distro: ubuntu-22.04 (push) Failing after 13m49s
Validate .env Schema / tier-1-env-validation (push) Successful in 52s
Validate .env Schema / tier-2-env-validation (push) Successful in 44s
Validate .env Schema / tier-3-env-validation (push) Successful in 52s
Validate .env Schema / tier-4-env-validation (push) Successful in 51s
Validate Extensions Catalog / Check catalog is up-to-date (push) Failing after 9m47s
Secret Scan / Scan for secrets (push) Failing after 21m4s
Validate Docker Compose / Validate Docker Compose files (push) Has been cancelled
Python Type Check / Type check with mypy (push) Has been cancelled
Validate .env Schema / tier-0-env-validation (push) Has been cancelled
Test Linux / integration-smoke (push) Has been cancelled
Lint PowerShell / powershell-lint (windows-latest) (push) Has been cancelled
Matrix Smoke / macos-smoke (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:33 +08:00

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 .py files under extensions/services/dashboard-api/ on the host does not reload the running ods-dashboard-api container. The image bakes a copy into /app/ at build time.
  • Recommended dev workflow: stop both the dashboard and dashboard-api Docker containers to free host ports 3001 and 3002, then run Vite dev server for the dashboard frontend (npm run dev on port 3001) plus native uvicorn with --reload for the API (port 3002). Vite's built-in /api proxy already points at localhost: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 cp as 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:

  1. The Python source lives at /app/ inside the image. uvicorn is launched from that WORKDIR and imports main:app from /app/main.py. There is no live link back to the host filesystem.

  2. The compose service mounts the host repo at /ods (read-only) — see docker-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.py on the host updates /ods/extensions/services/dashboard-api/routers/setup.py inside 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.

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:

  1. 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.
  2. 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.
  3. 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).