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 |
|
||||
|---|---|---|
|
||||
| `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` |
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<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
|
||||
`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
|
||||
|
||||
|
||||
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",
|
||||
"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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user