From 178d7d3c0f5ef6fd5cfeca83143f7f4d5c7fcafd Mon Sep 17 00:00:00 2001 From: noisedestroyers Date: Fri, 8 May 2026 12:00:05 -0400 Subject: [PATCH] Add Homepage dashboard + dual-export OpenCode traces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Homepage as the front door: single page at framework:7575 with one tile per service, live widgets where the upstream supports it (Ollama loaded models, container state via docker.sock, etc.), bookmarks for reference docs. Config files are pyinfra-managed — source of truth lives in compose/homepage/, sync by editing there and re-running ./run.sh. OpenCode plugin now dual-exports spans to Phoenix and OpenLIT in parallel. Phoenix remains the per-trace waterfall view; OpenLIT picks up the same data for fleet-level metrics. Each destination has its own batch processor so a hiccup at one doesn't block the other. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 1 + opencode/.opencode/plugin/phoenix-bridge.js | 34 +++++--- opencode/README.md | 9 ++- pyinfra/framework/README.md | 29 +++++-- pyinfra/framework/compose/homepage.yml | 28 +++++++ .../framework/compose/homepage/bookmarks.yaml | 22 +++++ .../framework/compose/homepage/docker.yaml | 8 ++ .../framework/compose/homepage/services.yaml | 80 +++++++++++++++++++ .../framework/compose/homepage/settings.yaml | 26 ++++++ .../framework/compose/homepage/widgets.yaml | 19 +++++ pyinfra/framework/deploy.py | 29 +++++++ 11 files changed, 269 insertions(+), 16 deletions(-) create mode 100644 pyinfra/framework/compose/homepage.yml create mode 100644 pyinfra/framework/compose/homepage/bookmarks.yaml create mode 100644 pyinfra/framework/compose/homepage/docker.yaml create mode 100644 pyinfra/framework/compose/homepage/services.yaml create mode 100644 pyinfra/framework/compose/homepage/settings.yaml create mode 100644 pyinfra/framework/compose/homepage/widgets.yaml diff --git a/README.md b/README.md index d036dad..c929c47 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Tailscale. Coding agents, monitoring, voice — all self-hosted. | Port | Service | Notes | |---|---|---| +| `7575` | **Homepage** | **Front door — start here.** Tile per service with live widgets. | | `8080` | llama.cpp | Vulkan backend, `--metrics` for Prometheus | | `8000` | vLLM | ROCm; gfx1151 support varies | | `11434` | Ollama | ROCm with `HSA_OVERRIDE_GFX_VERSION=11.0.0` | diff --git a/opencode/.opencode/plugin/phoenix-bridge.js b/opencode/.opencode/plugin/phoenix-bridge.js index 56bab5b..6a9adba 100644 --- a/opencode/.opencode/plugin/phoenix-bridge.js +++ b/opencode/.opencode/plugin/phoenix-bridge.js @@ -40,8 +40,17 @@ export const PhoenixBridge = async ({ project, directory, worktree }) => { // Phoenix 15.x serves OTLP/HTTP at /v1/traces on the same port as the UI // (6006). Earlier versions used a separate 4318 — override here if you // ever pin Phoenix < 15.0. - const endpoint = + const phoenixEndpoint = process.env.PHOENIX_OTLP_ENDPOINT || "http://framework:6006/v1/traces"; + // OpenLIT's OTLP/HTTP receiver, host-mapped to 4328 in + // pyinfra/framework/compose/openlit.yml. Set OPENLIT_OTLP_ENDPOINT to + // an empty string (or "off") to disable the secondary export. + const openlitEndpointRaw = + process.env.OPENLIT_OTLP_ENDPOINT === undefined + ? "http://framework:4328/v1/traces" + : process.env.OPENLIT_OTLP_ENDPOINT; + const openlitEndpoint = + openlitEndpointRaw && openlitEndpointRaw !== "off" ? openlitEndpointRaw : null; const serviceName = process.env.PHOENIX_SERVICE_NAME || "opencode"; // NodeSDK's `serviceName` constructor option is ignored in some // versions; setting OTEL_SERVICE_NAME forces the resource attribute @@ -49,7 +58,9 @@ export const PhoenixBridge = async ({ project, directory, worktree }) => { if (!process.env.OTEL_SERVICE_NAME) { process.env.OTEL_SERVICE_NAME = serviceName; } - log(`endpoint=${endpoint} serviceName=${serviceName}`); + log( + `phoenix=${phoenixEndpoint} openlit=${openlitEndpoint || "off"} serviceName=${serviceName}`, + ); // Dynamic imports so a missing dep produces a warning, not a freeze. let NodeSDK, @@ -105,13 +116,18 @@ export const PhoenixBridge = async ({ project, directory, worktree }) => { // NodeSDK accepts `serviceName` directly, sidestepping the Resource API // (which broke between @opentelemetry/resources v1.x and v2.x). - // SimpleSpanProcessor (vs BatchSpanProcessor) exports each span - // immediately — easier to debug while we sort out the pipeline. - const exporter = new OTLPTraceExporter({ url: endpoint }); - const sdk = new NodeSDK({ - serviceName, - spanProcessors: [new SimpleSpanProcessor(exporter)], - }); + // BatchSpanProcessor batches spans and flushes every ~5s — fine in + // steady state. Each destination gets its own processor + exporter so + // a hiccup at one (e.g. OpenLIT down) doesn't block the other. + const spanProcessors = [ + new BatchSpanProcessor(new OTLPTraceExporter({ url: phoenixEndpoint })), + ]; + if (openlitEndpoint) { + spanProcessors.push( + new BatchSpanProcessor(new OTLPTraceExporter({ url: openlitEndpoint })), + ); + } + const sdk = new NodeSDK({ serviceName, spanProcessors }); sdk.start(); log("sdk.start() returned"); diff --git a/opencode/README.md b/opencode/README.md index 7c366b4..448e661 100644 --- a/opencode/README.md +++ b/opencode/README.md @@ -75,12 +75,17 @@ The plugin uses `@opentelemetry/exporter-trace-otlp-proto` (not `-http`) because Phoenix's OTLP receiver only speaks protobuf — the JSON variant returns 415. +Spans are dual-exported: Phoenix (per-trace waterfall) and OpenLIT (fleet +metrics). Each destination has its own batch processor so a hiccup at +one doesn't block the other. + Defaults can be overridden via env vars (set before launching opencode): | Variable | Default | Purpose | |---|---|---| -| `PHOENIX_OTLP_ENDPOINT` | `http://framework:6006/v1/traces` | OTLP/HTTP target | -| `PHOENIX_SERVICE_NAME` | `opencode` | Phoenix project name | +| `PHOENIX_OTLP_ENDPOINT` | `http://framework:6006/v1/traces` | Phoenix HTTP target | +| `OPENLIT_OTLP_ENDPOINT` | `http://framework:4328/v1/traces` | OpenLIT HTTP target. Set to `off` to disable. | +| `PHOENIX_SERVICE_NAME` | `opencode` | Service / project name (both backends) | | `PHOENIX_OTEL_DEBUG` | unset | `1` to surface OTel internal logs | ### Verifying diff --git a/pyinfra/framework/README.md b/pyinfra/framework/README.md index 38af3bb..92ec00d 100644 --- a/pyinfra/framework/README.md +++ b/pyinfra/framework/README.md @@ -42,14 +42,14 @@ Or run it ephemerally without installing: `uvx pyinfra inventory.py deploy.py`. Requires a reboot to activate — pyinfra rewrites `/etc/default/grub` and runs `update-grub`, but won't reboot for you. - `/models//` layout -- `/srv/docker/{llama,vllm,ollama,openwebui,beszel,openlit,phoenix,openhands}/docker-compose.yml` +- `/srv/docker/{llama,vllm,ollama,openwebui,beszel,openlit,phoenix,openhands,homepage}/docker-compose.yml` dropped in, not auto-started — you edit the model path then `docker compose up -d` (OpenWebUI needs no edits — it's pre-configured to find Ollama at `host.docker.internal:11434` and uses `searxng.n0n.io` for web search) (Beszel hub at :8090, OpenLIT UI at :3001, Phoenix UI at :6006, - OpenHands UI at :3030 — see "Monitoring stack" and "Agent harnesses" - below) + OpenHands UI at :3030, Homepage at :7575 — see "Monitoring stack", + "Agent harnesses", and "Front door" below) If a previous run installed the native llama.cpp build / full ROCm / native Ollama, those are auto-cleaned the next time `./run.sh` runs. @@ -80,8 +80,27 @@ Top of `deploy.py`: https://github.com/Umio-Yasuno/amdgpu_top/releases. Compose images in -`compose/{llama,vllm,ollama,openwebui,beszel,openlit,phoenix,openhands}.yml` -— pin tags here. +`compose/{llama,vllm,ollama,openwebui,beszel,openlit,phoenix,openhands,homepage}.yml` +— pin tags here. Homepage's tile/layout config is in `compose/homepage/` +(`services.yaml`, `settings.yaml`, etc.); edit there, `./run.sh`, restart +the homepage container. + +## Front door + +- **Homepage** (`/srv/docker/homepage`, http://framework:7575) — single + page with one tile per service in this stack, with native widgets + pulling live state (loaded models from Ollama, container status from + the docker socket, etc.). Bookmarks for the reference docs across the + bottom. Use as the default landing page on this network. + + Bring-up: `cd /srv/docker/homepage && docker compose up -d`. No + first-run setup — it reads `/srv/docker/homepage/config/*.yaml` and + renders. + + To customize: edit `pyinfra/framework/compose/homepage/*.yaml` in the + repo, run `./run.sh`, then `docker compose restart homepage` on the + box. Direct edits to `/srv/docker/homepage/config/*.yaml` will be + overwritten on the next pyinfra deploy. ## Monitoring stack diff --git a/pyinfra/framework/compose/homepage.yml b/pyinfra/framework/compose/homepage.yml new file mode 100644 index 0000000..1a259e2 --- /dev/null +++ b/pyinfra/framework/compose/homepage.yml @@ -0,0 +1,28 @@ +# Homepage — service launcher + status dashboard for the localgenai stack. +# https://gethomepage.dev +# +# Single tile per service, with live widgets (loaded models, container +# state, request rates) where the upstream supports it. Reads its config +# from /srv/docker/homepage/config; pyinfra ships the initial files there +# and leaves them alone afterwards (edit them directly to add bookmarks, +# tweak the layout, etc). +# +# Bring-up: `docker compose up -d`. UI at http://framework:7575. +services: + homepage: + image: ghcr.io/gethomepage/homepage:latest + container_name: homepage + restart: unless-stopped + ports: + # 7575 picked to avoid the soup of 30xx ports already in use + # (OpenWebUI 3000, OpenLIT 3001, OpenHands 3030). + - "7575:3000" + volumes: + - /srv/docker/homepage/config:/app/config + # Read-only docker socket so homepage can render container status + # (running / stopped, CPU, memory) on each tile. + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + # Required when accessed from a host other than localhost (i.e. + # over Tailscale by hostname). Comma-separated list; * matches all. + HOMEPAGE_ALLOWED_HOSTS: "framework:7575,framework,localhost:7575,localhost,*" diff --git a/pyinfra/framework/compose/homepage/bookmarks.yaml b/pyinfra/framework/compose/homepage/bookmarks.yaml new file mode 100644 index 0000000..8482f3f --- /dev/null +++ b/pyinfra/framework/compose/homepage/bookmarks.yaml @@ -0,0 +1,22 @@ +# https://gethomepage.dev/configs/bookmarks/ + +- Reference: + - Strix Halo wiki: + - abbr: SH + href: https://strixhalo.wiki + + - kyuz0 backend benchmarks: + - abbr: KB + href: https://kyuz0.github.io/amd-strix-halo-toolboxes/ + + - llama.cpp: + - abbr: LC + href: https://github.com/ggml-org/llama.cpp + + - OpenCode: + - abbr: OC + href: https://opencode.ai + + - Phoenix: + - abbr: PX + href: https://arize.com/docs/phoenix diff --git a/pyinfra/framework/compose/homepage/docker.yaml b/pyinfra/framework/compose/homepage/docker.yaml new file mode 100644 index 0000000..688d72c --- /dev/null +++ b/pyinfra/framework/compose/homepage/docker.yaml @@ -0,0 +1,8 @@ +# Docker integration. https://gethomepage.dev/configs/docker/ +# +# `localhost-docker` is the name services.yaml refers to via +# `server: localhost-docker`. Homepage uses the mounted /var/run/docker.sock +# (read-only) to render container state on each tile. + +localhost-docker: + socket: /var/run/docker.sock diff --git a/pyinfra/framework/compose/homepage/services.yaml b/pyinfra/framework/compose/homepage/services.yaml new file mode 100644 index 0000000..4814c4a --- /dev/null +++ b/pyinfra/framework/compose/homepage/services.yaml @@ -0,0 +1,80 @@ +# Service tiles for the localgenai stack. Edit in place — pyinfra +# ships this once and never overwrites. +# +# Widget reference: https://gethomepage.dev/widgets/ + +- Inference: + - Ollama: + icon: ollama.svg + href: http://framework:11434 + description: Local model server (Qwen3-Coder-30B and friends) + server: localhost-docker + container: ollama + widget: + type: ollama + url: http://framework:11434 + + - llama.cpp: + icon: si-llama + href: http://framework:8080 + description: Vulkan-backed llama.cpp server (gfx1151) + server: localhost-docker + container: llama + # No native widget; a ping check confirms liveness. + widget: + type: customapi + url: http://framework:8080/health + refreshInterval: 30000 + mappings: + - field: status + label: Status + + - vLLM: + icon: mdi-server-network + href: http://framework:8000 + description: Batched OpenAI-compatible serving (ROCm) + server: localhost-docker + container: vllm + +- Agent UIs: + - OpenWebUI: + icon: open-webui.svg + href: http://framework:3000 + description: Chat UI in front of Ollama, with SearXNG search + server: localhost-docker + container: openwebui + + - OpenHands: + icon: mdi-robot + href: http://framework:3030 + description: Autonomous coding agent (loopback — needs SSH tunnel) + server: localhost-docker + container: openhands + +- Observability: + - Beszel: + icon: beszel.svg + href: http://framework:8090 + description: Host + container + AMD GPU dashboard + server: localhost-docker + container: beszel + + - OpenLIT: + icon: mdi-chart-line-variant + href: http://framework:3001 + description: LLM fleet metrics (cost, tokens, latency) + server: localhost-docker + container: openlit + + - Phoenix: + icon: arize-phoenix.svg + href: http://framework:6006 + description: Per-trace agent waterfall / flamegraph + server: localhost-docker + container: phoenix + +- External: + - SearXNG: + icon: searxng.svg + href: https://searxng.n0n.io + description: Self-hosted metasearch (used by OpenWebUI + OpenCode) diff --git a/pyinfra/framework/compose/homepage/settings.yaml b/pyinfra/framework/compose/homepage/settings.yaml new file mode 100644 index 0000000..d8184ac --- /dev/null +++ b/pyinfra/framework/compose/homepage/settings.yaml @@ -0,0 +1,26 @@ +# Top-level dashboard settings. https://gethomepage.dev/configs/settings/ + +title: localgenai +favicon: https://gethomepage.dev/favicon.ico +theme: dark +color: slate + +headerStyle: clean +language: en +target: _blank + +# Layout: keep the four service groups in a 3-column grid each so a tile +# fits comfortably without wrapping on a 1440-wide laptop. +layout: + Inference: + style: row + columns: 3 + Agent UIs: + style: row + columns: 3 + Observability: + style: row + columns: 3 + External: + style: row + columns: 3 diff --git a/pyinfra/framework/compose/homepage/widgets.yaml b/pyinfra/framework/compose/homepage/widgets.yaml new file mode 100644 index 0000000..fc63bca --- /dev/null +++ b/pyinfra/framework/compose/homepage/widgets.yaml @@ -0,0 +1,19 @@ +# Top-of-page widgets. https://gethomepage.dev/widgets/info/ + +- search: + provider: custom + url: https://searxng.n0n.io/search?q= + target: _blank + focus: true + +- resources: + label: framework + cpu: true + memory: true + disk: / + +- datetime: + text_size: xl + format: + timeStyle: short + dateStyle: medium diff --git a/pyinfra/framework/deploy.py b/pyinfra/framework/deploy.py index 5aef4ea..c71ece0 100644 --- a/pyinfra/framework/deploy.py +++ b/pyinfra/framework/deploy.py @@ -338,6 +338,7 @@ for svc in ( "openlit", "phoenix", "openhands", + "homepage", ): files.directory( name=f"compose/{svc} dir", @@ -441,6 +442,34 @@ files.directory( _sudo=True, ) +# Homepage config. The compose loop above only copies homepage.yml; the +# YAML config files live in compose/homepage/ on the source side and at +# /srv/docker/homepage/config/ on the box. Source-of-truth is the repo — +# `./run.sh` syncs the config files. Edits should happen here in the +# repo, not on the box, since pyinfra will overwrite drift. +files.directory( + name="Homepage config dir", + path=f"{COMPOSE_DIR}/homepage/config", + group="docker", + mode="2775", + _sudo=True, +) +for cfg in ( + "services.yaml", + "settings.yaml", + "widgets.yaml", + "docker.yaml", + "bookmarks.yaml", +): + files.put( + name=f"homepage config: {cfg}", + src=f"compose/homepage/{cfg}", + dest=f"{COMPOSE_DIR}/homepage/config/{cfg}", + group="docker", + mode="664", + _sudo=True, + ) + # --- Cleanup of artifacts from the prior native-build deploy ---------------- # All idempotent — `present=False` is a no-op when the target is absent.