Add Homepage dashboard + dual-export OpenCode traces
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) <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,7 @@ Tailscale. Coding agents, monitoring, voice — all self-hosted.
|
|||||||
|
|
||||||
| Port | Service | Notes |
|
| Port | Service | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| `7575` | **Homepage** | **Front door — start here.** Tile per service with live widgets. |
|
||||||
| `8080` | llama.cpp | Vulkan backend, `--metrics` for Prometheus |
|
| `8080` | llama.cpp | Vulkan backend, `--metrics` for Prometheus |
|
||||||
| `8000` | vLLM | ROCm; gfx1151 support varies |
|
| `8000` | vLLM | ROCm; gfx1151 support varies |
|
||||||
| `11434` | Ollama | ROCm with `HSA_OVERRIDE_GFX_VERSION=11.0.0` |
|
| `11434` | Ollama | ROCm with `HSA_OVERRIDE_GFX_VERSION=11.0.0` |
|
||||||
|
|||||||
@@ -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
|
// 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
|
// (6006). Earlier versions used a separate 4318 — override here if you
|
||||||
// ever pin Phoenix < 15.0.
|
// ever pin Phoenix < 15.0.
|
||||||
const endpoint =
|
const phoenixEndpoint =
|
||||||
process.env.PHOENIX_OTLP_ENDPOINT || "http://framework:6006/v1/traces";
|
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";
|
const serviceName = process.env.PHOENIX_SERVICE_NAME || "opencode";
|
||||||
// NodeSDK's `serviceName` constructor option is ignored in some
|
// NodeSDK's `serviceName` constructor option is ignored in some
|
||||||
// versions; setting OTEL_SERVICE_NAME forces the resource attribute
|
// 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) {
|
if (!process.env.OTEL_SERVICE_NAME) {
|
||||||
process.env.OTEL_SERVICE_NAME = serviceName;
|
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.
|
// Dynamic imports so a missing dep produces a warning, not a freeze.
|
||||||
let NodeSDK,
|
let NodeSDK,
|
||||||
@@ -105,13 +116,18 @@ export const PhoenixBridge = async ({ project, directory, worktree }) => {
|
|||||||
|
|
||||||
// NodeSDK accepts `serviceName` directly, sidestepping the Resource API
|
// NodeSDK accepts `serviceName` directly, sidestepping the Resource API
|
||||||
// (which broke between @opentelemetry/resources v1.x and v2.x).
|
// (which broke between @opentelemetry/resources v1.x and v2.x).
|
||||||
// SimpleSpanProcessor (vs BatchSpanProcessor) exports each span
|
// BatchSpanProcessor batches spans and flushes every ~5s — fine in
|
||||||
// immediately — easier to debug while we sort out the pipeline.
|
// steady state. Each destination gets its own processor + exporter so
|
||||||
const exporter = new OTLPTraceExporter({ url: endpoint });
|
// a hiccup at one (e.g. OpenLIT down) doesn't block the other.
|
||||||
const sdk = new NodeSDK({
|
const spanProcessors = [
|
||||||
serviceName,
|
new BatchSpanProcessor(new OTLPTraceExporter({ url: phoenixEndpoint })),
|
||||||
spanProcessors: [new SimpleSpanProcessor(exporter)],
|
];
|
||||||
});
|
if (openlitEndpoint) {
|
||||||
|
spanProcessors.push(
|
||||||
|
new BatchSpanProcessor(new OTLPTraceExporter({ url: openlitEndpoint })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const sdk = new NodeSDK({ serviceName, spanProcessors });
|
||||||
sdk.start();
|
sdk.start();
|
||||||
log("sdk.start() returned");
|
log("sdk.start() returned");
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
because Phoenix's OTLP receiver only speaks protobuf — the JSON variant
|
||||||
returns 415.
|
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):
|
Defaults can be overridden via env vars (set before launching opencode):
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
| Variable | Default | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PHOENIX_OTLP_ENDPOINT` | `http://framework:6006/v1/traces` | OTLP/HTTP target |
|
| `PHOENIX_OTLP_ENDPOINT` | `http://framework:6006/v1/traces` | Phoenix HTTP target |
|
||||||
| `PHOENIX_SERVICE_NAME` | `opencode` | Phoenix project name |
|
| `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 |
|
| `PHOENIX_OTEL_DEBUG` | unset | `1` to surface OTel internal logs |
|
||||||
|
|
||||||
### Verifying
|
### Verifying
|
||||||
|
|||||||
@@ -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`
|
Requires a reboot to activate — pyinfra rewrites `/etc/default/grub`
|
||||||
and runs `update-grub`, but won't reboot for you.
|
and runs `update-grub`, but won't reboot for you.
|
||||||
- `/models/<vendor>/` layout
|
- `/models/<vendor>/` 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
|
dropped in, not auto-started — you edit the model path then
|
||||||
`docker compose up -d`
|
`docker compose up -d`
|
||||||
(OpenWebUI needs no edits — it's pre-configured to find Ollama at
|
(OpenWebUI needs no edits — it's pre-configured to find Ollama at
|
||||||
`host.docker.internal:11434` and uses `searxng.n0n.io` for web search)
|
`host.docker.internal:11434` and uses `searxng.n0n.io` for web search)
|
||||||
(Beszel hub at :8090, OpenLIT UI at :3001, Phoenix UI at :6006,
|
(Beszel hub at :8090, OpenLIT UI at :3001, Phoenix UI at :6006,
|
||||||
OpenHands UI at :3030 — see "Monitoring stack" and "Agent harnesses"
|
OpenHands UI at :3030, Homepage at :7575 — see "Monitoring stack",
|
||||||
below)
|
"Agent harnesses", and "Front door" below)
|
||||||
|
|
||||||
If a previous run installed the native llama.cpp build / full ROCm /
|
If a previous run installed the native llama.cpp build / full ROCm /
|
||||||
native Ollama, those are auto-cleaned the next time `./run.sh` runs.
|
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.
|
https://github.com/Umio-Yasuno/amdgpu_top/releases.
|
||||||
|
|
||||||
Compose images in
|
Compose images in
|
||||||
`compose/{llama,vllm,ollama,openwebui,beszel,openlit,phoenix,openhands}.yml`
|
`compose/{llama,vllm,ollama,openwebui,beszel,openlit,phoenix,openhands,homepage}.yml`
|
||||||
— pin tags here.
|
— 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
|
## Monitoring stack
|
||||||
|
|
||||||
|
|||||||
28
pyinfra/framework/compose/homepage.yml
Normal file
28
pyinfra/framework/compose/homepage.yml
Normal file
@@ -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,*"
|
||||||
22
pyinfra/framework/compose/homepage/bookmarks.yaml
Normal file
22
pyinfra/framework/compose/homepage/bookmarks.yaml
Normal file
@@ -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
|
||||||
8
pyinfra/framework/compose/homepage/docker.yaml
Normal file
8
pyinfra/framework/compose/homepage/docker.yaml
Normal file
@@ -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
|
||||||
80
pyinfra/framework/compose/homepage/services.yaml
Normal file
80
pyinfra/framework/compose/homepage/services.yaml
Normal file
@@ -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)
|
||||||
26
pyinfra/framework/compose/homepage/settings.yaml
Normal file
26
pyinfra/framework/compose/homepage/settings.yaml
Normal file
@@ -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
|
||||||
19
pyinfra/framework/compose/homepage/widgets.yaml
Normal file
19
pyinfra/framework/compose/homepage/widgets.yaml
Normal file
@@ -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
|
||||||
@@ -338,6 +338,7 @@ for svc in (
|
|||||||
"openlit",
|
"openlit",
|
||||||
"phoenix",
|
"phoenix",
|
||||||
"openhands",
|
"openhands",
|
||||||
|
"homepage",
|
||||||
):
|
):
|
||||||
files.directory(
|
files.directory(
|
||||||
name=f"compose/{svc} dir",
|
name=f"compose/{svc} dir",
|
||||||
@@ -441,6 +442,34 @@ files.directory(
|
|||||||
_sudo=True,
|
_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 ----------------
|
# --- Cleanup of artifacts from the prior native-build deploy ----------------
|
||||||
# All idempotent — `present=False` is a no-op when the target is absent.
|
# All idempotent — `present=False` is a no-op when the target is absent.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user