This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/template-modes-env-config
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 3a1288a65278bc213d716a2dddbc8b1cd30e5bee
Author: Wu Sheng <[email protected]>
AuthorDate: Fri Jun 26 10:39:44 2026 +0800

    refactor(config): one committed env-driven horizon.yaml (drop the 
example/copy split)
    
    Collapse `horizon.example.yaml` + the gitignored local copy into a single
    committed `horizon.yaml` — the same fully tokenized file the image ships 
and the
    default `pnpm start` reads. Every field is a `${HORIZON_…:default}` token, 
so all
    config is supplied via environment variables; nothing is edited in the file.
    
    - `git mv horizon.example.yaml horizon.yaml`; un-ignore it in .gitignore +
      .dockerignore (now a tracked build input, not a local secret-bearing 
file).
    - Dockerfile bakes `dist/horizon.yaml` directly as `/app/horizon.yaml` — no 
more
      separate `/app/horizon.example.yaml` reference copy, no rename-on-copy.
    - package.mjs copies the committed `horizon.yaml` into dist/; release.sh + 
the
      parity tests (schema/loader) read `horizon.yaml`.
    - Docs: README, setup/overview, container-image, rbac, local-backend, ports 
no
      longer reference an example file; local-backend clarifies the committed 
file
      holds only `${HORIZON_…}` tokens (real hashes via 
HORIZON_AUTH_LOCAL_USERS).
    - local-boot skill: boots the repo `horizon.yaml` with OAP target + dev 
users
      (`dev-users.json`) / LDAP (`dev-ldap.json`) injected via HORIZON_* env 
vars;
      the three per-scenario yaml configs are removed. Documents the 
single-line-JSON
      constraint for env-injected lists/objects.
    
    Validated live: env-native boot (root horizon.yaml + env vars only) against 
the
    demo OAP — auth configured, login admin/admin → 200, OAP admin URL injected.
    type-check / lint / license / 162 BFF + 116 UI tests green.
---
 .claude/skills/local-boot/SKILL.md           | 354 +++++++++++----------------
 .claude/skills/local-boot/dev-ldap.json      |   1 +
 .claude/skills/local-boot/dev-users.json     |   1 +
 .claude/skills/local-boot/horizon.demo.yaml  | 119 ---------
 .claude/skills/local-boot/horizon.ldap.yaml  | 125 ----------
 .claude/skills/local-boot/horizon.local.yaml | 122 ---------
 .dockerignore                                |  14 +-
 .gitignore                                   |   4 +-
 CHANGELOG.md                                 |   2 +-
 Dockerfile                                   |   7 +-
 README.md                                    |   2 +-
 apps/bff/src/config/loader.test.ts           |   6 +-
 apps/bff/src/config/schema.test.ts           |  10 +-
 docs/access-control/local-backend.md         |   4 +-
 docs/compatibility/ports.md                  |   2 +-
 docs/setup/container-image.md                |   1 -
 docs/setup/overview.md                       |   5 +-
 docs/setup/rbac.md                           |   2 +-
 horizon.example.yaml => horizon.yaml         |   0
 scripts/package.mjs                          |   8 +-
 scripts/release.sh                           |   2 +-
 21 files changed, 184 insertions(+), 607 deletions(-)

diff --git a/.claude/skills/local-boot/SKILL.md 
b/.claude/skills/local-boot/SKILL.md
index e623e65..a7993e7 100644
--- a/.claude/skills/local-boot/SKILL.md
+++ b/.claude/skills/local-boot/SKILL.md
@@ -1,81 +1,71 @@
 ---
 name: local-boot
-description: Boot the Horizon UI dev env (BFF + UI) against a local OAP or the 
public Apache demo OAP, using the static configs bundled with this skill. 
Handles the apps/bff cwd / HORIZON_CONFIG gotcha and the demo OAP password 
(kept out of git via ${OAP_PASSWORD}).
+description: Boot the Horizon UI dev env (BFF + UI) against a local OAP or the 
public Apache demo OAP. Uses the repo's committed, env-driven horizon.yaml — 
the same config the image ships — and injects the OAP target + dev users purely 
via HORIZON_* environment variables. Handles the apps/bff cwd / HORIZON_CONFIG 
gotcha and the demo OAP password (kept out of git via the cached 
oap-password.local).
 user-invocable: true
 ---
 
 # Boot the Horizon UI local dev env
 
-Two bundled static configs live next to this file:
+There is **one config file**: the repo's committed `horizon.yaml` at the
+repo root. Every field in it is a `${HORIZON_…:default}` token, so dev boots
+use the SAME file the Docker image ships and override only what they need via
+**environment variables** — there are no per-scenario config files anymore.
 
-- `horizon.local.yaml` — no-auth OAP. Defaults to `127.0.0.1:12800`, but the 
OAP URLs are env-overridable (see "Custom OAP target" below) so the same file 
boots against any no-auth OAP (remote dev cluster, deliberately-unreachable 
port for the landing-block preview, etc.).
-- `horizon.demo.yaml` — public Apache demo OAP, OAP password read from 
`${OAP_PASSWORD}`.
+Two committed helpers live next to this file (throwaway dev values, safe to
+commit):
 
-Both define the same throwaway Horizon login users (password == username):
-`viewer`, `maintainer`, `operator`, `admin`.
+- `dev-users.json` — the four local login users (`viewer` / `maintainer` /
+  `operator` / `admin`, **password == username**), as a **single-line** JSON
+  array for `HORIZON_AUTH_LOCAL_USERS`.
+- `dev-ldap.json` — the test-OpenLDAP config for `HORIZON_AUTH_LDAP` (single 
line).
 
-The stack: **BFF** (Fastify) on `:8081`, **UI** (Vite) on `:9091` proxying 
`/api` → `:8081`. Open **`http://127.0.0.1:9091`** (use the IPv4 literal, not 
`localhost` — see the proxy/IPv4 section).
+The stack: **BFF** (Fastify) on `:8081`, **UI** (Vite) on `:9091` proxying
+`/api` → `:8081`. Open **`http://127.0.0.1:9091`** (use the IPv4 literal, not
+`localhost` — see the proxy/IPv4 section). Both ports are overridable — see
+"Custom ports".
 
-Both ports are overridable — see "Custom ports" below. The defaults are what 
the examples use throughout this doc.
+> **JSON env values must be SINGLE-LINE.** `HORIZON_AUTH_LOCAL_USERS`,
+> `HORIZON_OAP_AUTH`, `HORIZON_AUTH_LDAP`, etc. are spliced into the YAML 
before
+> parsing; a multi-line JSON value breaks the parse (`YAMLParseError: Flow
+> sequence … must be sufficiently indented`). The `dev-*.json` files are 
already
+> single-line — keep them that way.
 
 ## Environment variables (the dev contract)
 
-Local dev runs as **two processes / two ports**: the BFF (Fastify, owns
-`/api`) and Vite (serves the UI, proxies `/api` → BFF). They coordinate
-through env vars below. The BFF reads them via the YAML loader's
-`${VAR:default}` interpolation; Vite reads `BFF_PORT` + `UI_DEV_PORT`
-directly in `apps/ui/vite.config.ts`. Set them on **both** processes
-when overriding (or the proxy points at the wrong BFF).
+Local dev runs as **two processes / two ports**: the BFF (Fastify, owns `/api`)
+and Vite (serves the UI, proxies `/api` → BFF). The BFF reads `HORIZON_*` via 
the
+config loader's `${VAR:default}` interpolation; Vite reads `BFF_PORT` +
+`UI_DEV_PORT` directly in `apps/ui/vite.config.ts`.
 
-| Variable          | Default                          | Used by  | Purpose |
+| Variable                    | Default                    | Used by | Purpose 
|
 |---|---|---|---|
-| `HORIZON_CONFIG`  | _(none — required)_              | BFF      | 
**Absolute** path to the yaml config. A bare `./horizon.yaml` resolves under 
`apps/bff/` and silently boots with zero users — see "The one gotcha" below. |
-| `BFF_PORT`        | `8081`                           | BFF + UI | BFF listen 
port (yaml `server.port`) AND Vite's proxy target. Set the same value on both. 
Prod uses this as the single port (BFF serves the built UI). |
-| `UI_DEV_PORT`     | `9091`                           | UI       | Vite 
listen port. Dev-only — meaningless in prod. |
-| `OAP_QUERY_URL`   | `http://localhost:12800`         | BFF      | OAP 
GraphQL endpoint (applies to `horizon.local.yaml`). |
-| `OAP_ADMIN_URL`   | `http://localhost:12800`         | BFF      | OAP admin 
REST endpoint. Often differs from GraphQL on real deployments (e.g. demo splits 
on `:17128`) — if BFF logs `UITemplate 404`, override this. |
-| `OAP_ZIPKIN_URL`  | `http://localhost:9412/zipkin`   | BFF      | Zipkin 
query endpoint (Zipkin trace layer). |
-| `OAP_TIMEOUT_MS`  | `15000`                          | BFF      | OAP 
request timeout. Lower it (`5000`) when previewing the "OAP unreachable" 
landing block so errors surface fast. |
-| `OAP_PASSWORD`    | _(none)_                         | BFF      | Demo OAP 
basic-auth password (`horizon.demo.yaml` only). Cached at `oap-password.local` 
(git-ignored). |
-| `LDAP_BIND_PW`    | `admin` _(test value)_           | BFF      | LDAP 
service-bind password (`horizon.ldap.yaml` only). Set a real value before 
pointing at a real directory. |
-
-Minimal local dev (defaults, no overrides):
-
-```bash
-REPO="$(git rev-parse --show-toplevel)"
-HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \
-  pnpm --filter @skywalking-horizon-ui/bff run dev &           # → :8081
-( cd "$REPO/apps/ui" && node_modules/.bin/vite --host 127.0.0.1 & ) # → :9091
-```
-
-Custom ports for a parallel env (e.g. coexist with booster-ui on 8080):
-
-```bash
-BFF_PORT=10081 UI_DEV_PORT=10091 \
-  HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \
-  pnpm --filter @skywalking-horizon-ui/bff run dev &
-( cd "$REPO/apps/ui" && BFF_PORT=10081 UI_DEV_PORT=10091 \
-    node_modules/.bin/vite --host 127.0.0.1 & )
-```
-
-Remote OAP (no auth) via env override:
-
-```bash
-OAP_QUERY_URL=http://oap.dev.example:12800 \
-OAP_ADMIN_URL=http://oap.dev.example:17128 \
-HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \
-  pnpm --filter @skywalking-horizon-ui/bff run dev &
-```
-
-The longer "Custom ports" and "Custom OAP target" sections below cover
-the prod single-port model, the GraphQL/admin port split, and the
-unreachable-OAP preview.
+| `HORIZON_CONFIG`            | _(none — required)_        | BFF     | 
**Absolute** path to the config. Always `"$REPO/horizon.yaml"`. A bare 
`./horizon.yaml` resolves under `apps/bff/` — see "The one gotcha". |
+| `HORIZON_SERVER_PORT`       | `8081`                     | BFF     | BFF 
listen port (`server.port`). For a custom port set this AND `BFF_PORT` to the 
same value. |
+| `BFF_PORT`                  | `8081`                     | UI      | Vite's 
`/api` proxy target. Must match `HORIZON_SERVER_PORT`. |
+| `UI_DEV_PORT`               | `9091`                     | UI      | Vite 
listen port. Dev-only. |
+| `HORIZON_OAP_QUERY_URL`     | `http://127.0.0.1:12800`   | BFF     | OAP 
GraphQL endpoint. |
+| `HORIZON_OAP_ADMIN_URL`     | `http://127.0.0.1:17128`   | BFF     | OAP 
admin REST endpoint. Often a different port from GraphQL (the demo splits on 
`:17128`) — if the BFF logs `UITemplate 404`, this is wrong. |
+| `HORIZON_OAP_ZIPKIN_URL`    | `http://127.0.0.1:9412/zipkin` | BFF | Zipkin 
query endpoint (Zipkin trace layer). |
+| `HORIZON_OAP_TIMEOUT_MS`    | `15000`                    | BFF     | OAP 
request timeout. Lower it (`4000`) when previewing the "OAP unreachable" 
landing block so errors surface fast. |
+| `HORIZON_OAP_AUTH`          | _(none)_                   | BFF     | OAP 
basic-auth as JSON, e.g. `{"username":"admin","password":"…"}`. The demo needs 
it (password from `oap-password.local`). |
+| `HORIZON_AUTH_LOCAL_USERS`  | `[]`                       | BFF     | Local 
login users (JSON array, single-line). Use `$(cat dev-users.json)`. |
+| `HORIZON_AUTH_BACKEND`      | `local`                    | BFF     | `local` 
or `ldap`. |
+| `HORIZON_AUTH_LDAP`         | _(none)_                   | BFF     | LDAP 
config (JSON, single-line) when backend=ldap. Use `$(cat dev-ldap.json)`. |
+| `HORIZON_TEMPLATES_MODE`    | `live`                     | BFF     | `live` 
(seed + read ui_template) or `readonly` (render the bundle, config read-only). |
+
+RBAC is left to the built-in role defaults (the `horizon.yaml` token
+`roles: ${HORIZON_RBAC_ROLES:null}` falls through to them), so no roles env var
+is needed for dev.
 
 ## The one gotcha that bites every time
 
-The BFF dev script is `tsx watch src/server.ts` and pnpm runs it with **cwd = 
`apps/bff`**. The config path is `process.env.HORIZON_CONFIG ?? 
'./horizon.yaml'`, resolved relative to cwd — so a bare `./horizon.yaml` points 
at the non-existent `apps/bff/horizon.yaml`, and the loader silently falls back 
to **defaults with zero users** (every login then fails with "invalid 
credentials", and the boot log warns `auth.local.users is empty`).
+The BFF dev script is `tsx watch src/server.ts` and pnpm runs it with **cwd =
+`apps/bff`**. The config path defaults to `./horizon.yaml`, resolved relative 
to
+cwd — so a bare `./horizon.yaml` points at the non-existent 
`apps/bff/horizon.yaml`
+(NOT the repo-root one), and the loader silently falls back to schema defaults
+with **zero users** (every login then fails "invalid credentials").
 
-**Always pass `HORIZON_CONFIG` as an ABSOLUTE path.** Use 
`"$REPO/.claude/skills/local-boot/<file>"`.
+**Always pass `HORIZON_CONFIG` as an ABSOLUTE path:** `"$REPO/horizon.yaml"`.
 
 ## Proxy + IPv4 (the second gotcha)
 
@@ -91,119 +81,23 @@ env | grep -iE '^(http_proxy|https_proxy|all_proxy)=' && 
echo "local proxy detec
 
 The browser uses its OWN proxy settings (not the shell's), so the developer 
must also let `127.0.0.1` / `localhost` go direct (ClashX "bypass localhost" / 
system proxy no-proxy list). The env-level bypass below only fixes CLI tools 
and Vite's own fetches.
 
-## Custom ports (two parallel envs, prod single-port)
-
-The defaults are BFF `:8081` and UI `:9091`. To run a second Horizon
-side-by-side (e.g. comparing two OAPs, or coexisting with the legacy
-booster-ui on 8080) override with two env vars **at boot time**:
-
-- `BFF_PORT` — the Fastify listen port. Read by the BFF yaml as
-  `port: ${BFF_PORT:8081}` (interpolated by the loader before parse) AND
-  by Vite's dev server when building the `/api` proxy target. **Set the
-  same value on both processes** or the proxy points at the wrong BFF.
-- `UI_DEV_PORT` — the Vite listen port. Dev-only, read by
-  `apps/ui/vite.config.ts`.
-
-```bash
-# parallel env on 10081/10091 (e.g. against a second OAP):
-BFF_PORT=10081 UI_DEV_PORT=10091 \
-  HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.<file>.yaml" \
-  pnpm --filter @skywalking-horizon-ui/bff run dev &
-( cd "$REPO/apps/ui" && BFF_PORT=10081 UI_DEV_PORT=10091 \
-    env -u http_proxy -u https_proxy -u all_proxy ... \
-        node_modules/.bin/vite --host 127.0.0.1 & )
-```
-
-The recipes below all use `${BFF_PORT:-8081}` / `${UI_DEV_PORT:-9091}`
-shell expansions, so they pick up an override that's already exported
-(or just use the defaults if not).
-
-**Prod is single-port.** The BFF serves the built UI as static files via
-`@fastify/static`, so `UI_DEV_PORT` is meaningless outside Vite. In prod
-only `server.port` (i.e. `BFF_PORT`) matters.
-
-## Custom OAP target (point horizon.local.yaml at any no-auth OAP)
-
-`horizon.local.yaml` is the canonical no-auth config — there are no
-separate `horizon.remote.yaml` / `horizon.unreachable.yaml` files
-anymore. The four OAP fields all accept env-var overrides via the
-loader's `${VAR:default}` syntax, so the same config boots against:
-
-- **A LOCAL OAP** (default). No env vars needed.
-- **A REMOTE OAP** — `OAP_QUERY_URL` + `OAP_ADMIN_URL` + `OAP_ZIPKIN_URL`.
-- **A deliberately-unreachable port** — to preview the "OAP query host
-  unreachable" landing block. Pair with a short `OAP_TIMEOUT_MS` so
-  errors surface fast.
-
-Heads-up: many real deployments split the GraphQL surface (12800) and
-the admin REST surface (often 17128 — same split as the public demo).
-If the BFF startup log warns `UITemplate 404` on `/ui-management/...`,
-the admin URL is wrong — override `OAP_ADMIN_URL` separately.
-
-```bash
-# Remote OAP (GraphQL 12800, admin REST split on 17128):
-OAP_QUERY_URL=http://oap.dev.example:12800 \
-OAP_ADMIN_URL=http://oap.dev.example:17128 \
-HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \
-  pnpm --filter @skywalking-horizon-ui/bff run dev &
-
-# Unreachable-OAP landing-block preview:
-OAP_QUERY_URL=http://127.0.0.1:12801 \
-OAP_ADMIN_URL=http://127.0.0.1:12801 \
-OAP_TIMEOUT_MS=5000 \
-HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \
-  pnpm --filter @skywalking-horizon-ui/bff run dev &
-```
-
 ## The stale-process trap (why a config switch "didn't take")
 
-`tsx watch` keeps the old BFF alive, so a freshly launched BFF with a NEW 
config silently dies on `EADDRINUSE: 127.0.0.1:${BFF_PORT:-8081}` while the OLD 
process keeps serving the OLD OAP. Symptom: you switch local↔demo, everything 
looks fine, but the UI still shows the previous OAP's data.
+`tsx watch` keeps the old BFF alive, so a freshly launched BFF silently dies 
on `EADDRINUSE: 127.0.0.1:8081` while the OLD process keeps serving the OLD 
env. Symptom: you switch demo↔local, everything looks fine, but the UI still 
shows the previous OAP's data.
 
-Killing matters in two ways:
-- `pkill -f "tsx watch src/server.ts"` may miss the actual listener — also 
`pkill -f "tsx/dist/cli.mjs watch"`, and **verify the port is actually free 
with `lsof` before relaunching** (loop until free; the watcher can respawn a 
child).
-- After boot, **confirm which OAP the LIVE process is using** — don't trust 
that your new process won the port. The authoritative check:
+- `pkill -f "tsx watch src/server.ts"` may miss the actual listener — also 
`pkill -f "tsx/dist/cli.mjs watch"`, and **verify the port is free with `lsof` 
before relaunching** (loop until free; the watcher respawns a child).
+- After boot, **confirm which OAP the LIVE process uses** — don't trust that 
your new process won the port:
   ```bash
-  curl -s --noproxy '*' -b /tmp/sw.cookies 
"http://127.0.0.1:${BFF_PORT:-8081}/api/oap/config"; | grep -oE 
'"adminUrl":"[^"]*"'
-  # local => http://localhost:12800 ; demo => 
https://demo.skywalking.apache.org:17128
+  curl -s --noproxy '*' -b /tmp/sw.cookies 
"http://127.0.0.1:8081/api/oap/config"; | grep -oE '"adminUrl":"[^"]*"'
+  # local => http://127.0.0.1:17128 ; demo => 
https://demo.skywalking.apache.org:17128
   ```
-  Also grep the boot log for `EADDRINUSE` and the `configPath` line. If the 
adminUrl is wrong, a stale BFF is still bound — kill it, confirm the port is 
free, relaunch.
-
-## Boot against the local OAP
-
-```bash
-REPO="$(git rev-parse --show-toplevel)"
-# 1. Kill prior dev servers AND confirm the BFF port is actually free —
-#    a stale BFF holding it makes the new one die on EADDRINUSE while
-#    the old config keeps serving (see "stale-process trap"). For a
-#    parallel env on custom ports, export BFF_PORT / UI_DEV_PORT first.
-pkill -f "tsx watch src/server.ts" 2>/dev/null
-pkill -f "tsx/dist/cli.mjs watch" 2>/dev/null; pkill -f vite 2>/dev/null
-until ! lsof -nP -iTCP:"${BFF_PORT:-8081}" -sTCP:LISTEN >/dev/null 2>&1; do 
sleep 1; done
-
-# 2. BFF — absolute config path is mandatory (see gotcha above). The
-#    yaml resolves ${BFF_PORT:8081} at load time, so just exporting
-#    BFF_PORT before this command is enough:
-HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.local.yaml" \
-  pnpm --filter @skywalking-horizon-ui/bff run dev &
-
-# 3. UI — IPv4 host + loopback proxy bypass (run the binary directly so
-#    --host actually applies). vite.config.ts reads BFF_PORT and
-#    UI_DEV_PORT from this env:
-( cd "$REPO/apps/ui" && \
-  env -u http_proxy -u https_proxy -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY 
-u ALL_PROXY \
-      no_proxy="localhost,127.0.0.1,::1" NO_PROXY="localhost,127.0.0.1,::1" \
-      node_modules/.bin/vite --host 127.0.0.1 & )
-```
-
-Then open **`http://127.0.0.1:${UI_DEV_PORT:-9091}`** and log in as `admin` / 
`admin`.
 
 ## Boot against the public demo OAP
 
-The demo OAP needs basic-auth (network username `admin`). The password is
-NOT committed — it lives in `oap-password.local` next to this file, which
-is git-ignored via the repo-wide `*.local` rule. Source it before booting;
-if the file is missing, ask the developer and recreate it (one line, the
-password only):
+The demo OAP needs basic-auth (network username `admin`). The password is NOT
+committed — it lives in `oap-password.local` next to this file (git-ignored via
+the repo-wide `*.local` rule). Source it; if missing, ask the developer and
+recreate it (one line, the password only).
 
 ```bash
 REPO="$(git rev-parse --show-toplevel)"
@@ -215,83 +109,129 @@ else
   printf '%s\n' "$OAP_PASSWORD" > "$SECRET" && chmod 600 "$SECRET"  # cache 
for next boot
 fi
 
+# Kill prior dev servers + confirm the port is free (see stale-process trap).
 pkill -f "tsx watch src/server.ts" 2>/dev/null
 pkill -f "tsx/dist/cli.mjs watch" 2>/dev/null; pkill -f vite 2>/dev/null
-until ! lsof -nP -iTCP:"${BFF_PORT:-8081}" -sTCP:LISTEN >/dev/null 2>&1; do 
sleep 1; done
-HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.demo.yaml" \
+until ! lsof -nP -iTCP:8081 -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done
+
+# BFF — repo horizon.yaml + everything via env. JSON values are single-line.
+SK="$REPO/.claude/skills/local-boot"
+HORIZON_CONFIG="$REPO/horizon.yaml" \
+HORIZON_OAP_QUERY_URL=https://demo.skywalking.apache.org:12800 \
+HORIZON_OAP_ADMIN_URL=https://demo.skywalking.apache.org:17128 \
+HORIZON_OAP_ZIPKIN_URL=https://demo.skywalking.apache.org:9412/zipkin \
+HORIZON_OAP_AUTH="{\"username\":\"admin\",\"password\":\"$OAP_PASSWORD\"}" \
+HORIZON_AUTH_LOCAL_USERS="$(cat "$SK/dev-users.json")" \
   pnpm --filter @skywalking-horizon-ui/bff run dev &
+
+# UI — IPv4 host + loopback proxy bypass (run the binary directly so --host 
applies).
 ( cd "$REPO/apps/ui" && \
   env -u http_proxy -u https_proxy -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY 
-u ALL_PROXY \
       no_proxy="localhost,127.0.0.1,::1" NO_PROXY="localhost,127.0.0.1,::1" \
       node_modules/.bin/vite --host 127.0.0.1 & )
 ```
 
-The cached `oap-password.local` is git-ignored, so it is safe to keep on
-disk between boots. If it is absent and `OAP_PASSWORD` is unset, ask the
-developer rather than guessing. The OAP network username is fixed as
-`admin` in `horizon.demo.yaml`.
+Then open **`http://127.0.0.1:9091`** and log in as `admin` / `admin`. To boot 
in
+read-only template mode, add `HORIZON_TEMPLATES_MODE=readonly` to the BFF line.
+
+## Boot against a local / remote no-auth OAP
+
+Same recipe, different OAP env vars (no `HORIZON_OAP_AUTH` for a no-auth OAP):
+
+```bash
+REPO="$(git rev-parse --show-toplevel)"; SK="$REPO/.claude/skills/local-boot"
+pkill -f "tsx watch src/server.ts" 2>/dev/null; pkill -f "tsx/dist/cli.mjs 
watch" 2>/dev/null; pkill -f vite 2>/dev/null
+until ! lsof -nP -iTCP:8081 -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done
+
+# LOCAL OAP (defaults already point at 127.0.0.1, so only users are needed):
+HORIZON_CONFIG="$REPO/horizon.yaml" \
+HORIZON_AUTH_LOCAL_USERS="$(cat "$SK/dev-users.json")" \
+  pnpm --filter @skywalking-horizon-ui/bff run dev &
+
+# REMOTE no-auth OAP (GraphQL 12800, admin split on 17128):
+HORIZON_CONFIG="$REPO/horizon.yaml" \
+HORIZON_OAP_QUERY_URL=http://oap.dev.example:12800 \
+HORIZON_OAP_ADMIN_URL=http://oap.dev.example:17128 \
+HORIZON_AUTH_LOCAL_USERS="$(cat "$SK/dev-users.json")" \
+  pnpm --filter @skywalking-horizon-ui/bff run dev &
+
+# UNREACHABLE-OAP landing-block preview (short timeout so errors surface fast):
+HORIZON_CONFIG="$REPO/horizon.yaml" \
+HORIZON_OAP_QUERY_URL=http://127.0.0.1:12801 
HORIZON_OAP_ADMIN_URL=http://127.0.0.1:12801 \
+HORIZON_OAP_TIMEOUT_MS=4000 \
+HORIZON_AUTH_LOCAL_USERS="$(cat "$SK/dev-users.json")" \
+  pnpm --filter @skywalking-horizon-ui/bff run dev &
+```
+
+## Custom ports (two parallel envs)
+
+Defaults are BFF `:8081`, UI `:9091`. To run a second Horizon side-by-side, set
+the BFF port via **`HORIZON_SERVER_PORT`** (the config's `server.port` token) 
AND
+**`BFF_PORT`** (Vite's proxy target) to the same value, plus `UI_DEV_PORT`:
+
+```bash
+HORIZON_SERVER_PORT=10081 BFF_PORT=10081 UI_DEV_PORT=10091 \
+  HORIZON_CONFIG="$REPO/horizon.yaml" \
+  HORIZON_AUTH_LOCAL_USERS="$(cat "$SK/dev-users.json")" \
+  pnpm --filter @skywalking-horizon-ui/bff run dev &
+( cd "$REPO/apps/ui" && BFF_PORT=10081 UI_DEV_PORT=10091 \
+    env -u http_proxy -u https_proxy -u all_proxy \
+        node_modules/.bin/vite --host 127.0.0.1 & )
+```
+
+**Prod is single-port.** The BFF serves the built UI as static files, so
+`UI_DEV_PORT` is meaningless outside Vite; only `HORIZON_SERVER_PORT` matters.
 
 ## Boot against an LDAP directory (test)
 
-`horizon.ldap.yaml` points the BFF at a throwaway OpenLDAP seeded from
-`ldap-seed.ldif`. Stand the directory up, seed it, then boot:
+Stand up a throwaway OpenLDAP, seed it from `ldap-seed.ldif`, then boot with
+`HORIZON_AUTH_BACKEND=ldap` and the LDAP config from `dev-ldap.json`. Test 
logins
+mirror the local set (password == username): `admin` → admin, `operator` →
+operator, `maintainer` → maintainer, `viewer` → viewer (`*` fallback). The bind
+account is `cn=admin,dc=horizon,dc=test` / `admin`.
 
 ```bash
-REPO="$(git rev-parse --show-toplevel)"
+REPO="$(git rev-parse --show-toplevel)"; SK="$REPO/.claude/skills/local-boot"
 docker rm -f horizon-ldap 2>/dev/null
 docker run -d --name horizon-ldap -p 389:389 -p 636:636 \
   --env LDAP_ORGANISATION="Horizon Test" --env LDAP_DOMAIN="horizon.test" \
   --env LDAP_ADMIN_PASSWORD="admin" osixia/openldap:1.5.0
-# wait for slapd, then seed:
 until docker exec horizon-ldap ldapwhoami -x -H ldap://localhost \
   -D "cn=admin,dc=horizon,dc=test" -w admin >/dev/null 2>&1; do sleep 1; done
-docker cp "$REPO/.claude/skills/local-boot/ldap-seed.ldif" 
horizon-ldap:/tmp/seed.ldif
+docker cp "$SK/ldap-seed.ldif" horizon-ldap:/tmp/seed.ldif
 docker exec horizon-ldap ldapadd -x -H ldap://localhost \
   -D "cn=admin,dc=horizon,dc=test" -w admin -f /tmp/seed.ldif
 
 pkill -f "tsx watch src/server.ts" 2>/dev/null; pkill -f "tsx/dist/cli.mjs 
watch" 2>/dev/null
-until ! lsof -nP -iTCP:"${BFF_PORT:-8081}" -sTCP:LISTEN >/dev/null 2>&1; do 
sleep 1; done
-HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.ldap.yaml" \
+until ! lsof -nP -iTCP:8081 -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done
+HORIZON_CONFIG="$REPO/horizon.yaml" \
+HORIZON_AUTH_BACKEND=ldap \
+HORIZON_AUTH_LDAP="$(cat "$SK/dev-ldap.json")" \
   pnpm --filter @skywalking-horizon-ui/bff run dev &
 ```
 
-Test accounts (seeded by `ldap-seed.ldif`). Login users are named after
-their role and **password == username**, mirroring `horizon.local.yaml`:
-
-| Login | Group | Role |
-|---|---|---|
-| `admin` | cn=horizon-admin | admin |
-| `operator` | cn=sre | operator |
-| `maintainer` | cn=platform | maintainer |
-| `viewer` | (none) | viewer (`*` fallback) |
-
-Directory bind account: `cn=admin,dc=horizon,dc=test` / `admin` (override via 
`LDAP_BIND_PW`).
-
-```bash
-# verify a login resolves the expected role:
-curl -s --noproxy '*' -H 'Content-Type: application/json' -X POST \
-  "http://127.0.0.1:${BFF_PORT:-8081}/api/auth/login"; -d 
'{"username":"admin","password":"admin"}'
-```
-
-Note: group resolution runs on the **service bind**, not the user's
-credentials (regular users usually can't read the group subtree).
+`dev-ldap.json` reads the bind password as the test value `admin`; for a real
+directory, edit it (or build the JSON with your own bind password) and never
+commit a real one. Group resolution runs on the **service bind**, not the 
user's
+credentials.
 
 ## Verify the BFF is healthy (no browser)
 
 ```bash
 # --noproxy so a local proxy (ClashX etc.) doesn't 502 the loopback call.
-until curl -s --noproxy '*' -m2 -o /dev/null 
"http://127.0.0.1:${BFF_PORT:-8081}/api/auth/health";; do sleep 1; done
+until curl -s --noproxy '*' -m2 -o /dev/null 
"http://127.0.0.1:8081/api/auth/health";; do sleep 1; done
 curl -s --noproxy '*' -c /tmp/sw.cookies -H 'Content-Type: application/json' 
-X POST \
-  "http://127.0.0.1:${BFF_PORT:-8081}/api/auth/login"; -d 
'{"username":"admin","password":"admin"}'
-# Expect 200 with {username, roles, verbs, landingRoute}. A 401
-# "invalid credentials" almost always means the wrong config loaded —
-# re-check the absolute HORIZON_CONFIG path and that no stale BFF holds the 
BFF port.
+  "http://127.0.0.1:8081/api/auth/login"; -d 
'{"username":"admin","password":"admin"}'
+# Expect 200 with {username, roles, verbs, landingRoute}. A 401 "invalid
+# credentials" usually means HORIZON_AUTH_LOCAL_USERS wasn't passed (or the
+# wrong HORIZON_CONFIG path / a stale BFF holds the port).
 ```
 
-## Editing the configs
+## Editing the dev values
 
-These files are the source of truth for dev boot. Keep secrets out:
-local-user hashes (password == username) are fine to commit; real OAP
-passwords must stay in `${OAP_PASSWORD}` (the loader's `interpolateEnv`
-expands `${VAR}` / `${VAR:default}` in the raw YAML before parsing).
-To mint a new local-user hash: `pnpm --filter @skywalking-horizon-ui/bff 
cli:hash`.
+`horizon.yaml` (repo root) is the committed, env-driven config — leave it as-is
+and override via `HORIZON_*` env vars. The dev users / LDAP config live in
+`dev-users.json` / `dev-ldap.json` here (throwaway, single-line JSON). Real OAP
+or LDAP passwords stay out of git: the demo OAP password in 
`oap-password.local`
+(git-ignored), real bind passwords supplied at boot. To mint a new local-user
+hash: `pnpm --filter @skywalking-horizon-ui/bff cli:hash`.
diff --git a/.claude/skills/local-boot/dev-ldap.json 
b/.claude/skills/local-boot/dev-ldap.json
new file mode 100644
index 0000000..f52c039
--- /dev/null
+++ b/.claude/skills/local-boot/dev-ldap.json
@@ -0,0 +1 @@
+{"url":"ldap://localhost:389","bindDn":"cn=admin,dc=horizon,dc=test","bindPassword":"admin","userBaseDn":"ou=people,dc=horizon,dc=test","userFilter":";(uid={username})","displayNameAttr":"cn","groupStrategy":"search","groupBaseDn":"ou=groups,dc=horizon,dc=test","memberAttr":"member","timeoutMs":5000,"tlsInsecure":false,"groupMappings":[{"group":"cn=horizon-admin,ou=groups,dc=horizon,dc=test","role":"admin"},{"group":"cn=sre,ou=groups,dc=horizon,dc=test","role":"operator"},{"group":"cn=pla
 [...]
diff --git a/.claude/skills/local-boot/dev-users.json 
b/.claude/skills/local-boot/dev-users.json
new file mode 100644
index 0000000..a0f7972
--- /dev/null
+++ b/.claude/skills/local-boot/dev-users.json
@@ -0,0 +1 @@
+[{"username":"viewer","passwordHash":"$argon2id$v=19$m=65536,t=3,p=4$Gp175hqr+EF2iZ7v1fndvw$w6w9hDI59/UA+CRARChDoGRlR1TkVt6kqzApa021K+0","roles":["viewer"]},{"username":"maintainer","passwordHash":"$argon2id$v=19$m=65536,t=3,p=4$w7ULwB3/jzH9FxVoHJ238A$y+qGoX6IPeOoGywLQCpfpAN5VJXcaevoWeJQhaybvQU","roles":["maintainer"]},{"username":"operator","passwordHash":"$argon2id$v=19$m=65536,t=3,p=4$nzoI4RqiobprtzX/mJqe5Q$FY2Hi7mKep0DPHoaE++r/KD++WLUwTgRUFLde87j2Wg","roles":["operator"]},{"username"
 [...]
diff --git a/.claude/skills/local-boot/horizon.demo.yaml 
b/.claude/skills/local-boot/horizon.demo.yaml
deleted file mode 100644
index f68f222..0000000
--- a/.claude/skills/local-boot/horizon.demo.yaml
+++ /dev/null
@@ -1,119 +0,0 @@
-# Static demo-boot config for the Horizon UI BFF — points at the public
-# Apache demo OAP (demo.skywalking.apache.org). The OAP network basic-auth
-# password is NOT committed: it is read from ${OAP_PASSWORD} at boot.
-# Ask the developer for it before booting and export it, e.g.
-#   read -rs OAP_PASSWORD && export OAP_PASSWORD
-# Boot via the `local-boot` skill (see SKILL.md), which passes this file
-# through HORIZON_CONFIG as an ABSOLUTE path. Horizon login users below
-# are throwaway dev credentials (password == username).
-
-server:
-  host: 127.0.0.1
-  port: ${BFF_PORT:8081}
-
-oap:
-  queryUrl: https://demo.skywalking.apache.org:12800
-  adminUrl: https://demo.skywalking.apache.org:17128
-  zipkinUrl: https://demo.skywalking.apache.org:9412/zipkin
-  timeoutMs: 15000
-  auth:
-    username: admin
-    # Supplied via the environment so the demo password stays out of git.
-    # interpolateEnv() in apps/bff/src/config/loader.ts expands 
${OAP_PASSWORD}.
-    password: "${OAP_PASSWORD}"
-
-auth:
-  backend: local
-  local:
-    users:
-      - username: viewer
-        # password: viewer
-        passwordHash: 
"$argon2id$v=19$m=65536,t=3,p=4$Gp175hqr+EF2iZ7v1fndvw$w6w9hDI59/UA+CRARChDoGRlR1TkVt6kqzApa021K+0"
-        roles: [viewer]
-      - username: maintainer
-        # password: maintainer
-        passwordHash: 
"$argon2id$v=19$m=65536,t=3,p=4$w7ULwB3/jzH9FxVoHJ238A$y+qGoX6IPeOoGywLQCpfpAN5VJXcaevoWeJQhaybvQU"
-        roles: [maintainer]
-      - username: operator
-        # password: operator
-        passwordHash: 
"$argon2id$v=19$m=65536,t=3,p=4$nzoI4RqiobprtzX/mJqe5Q$FY2Hi7mKep0DPHoaE++r/KD++WLUwTgRUFLde87j2Wg"
-        roles: [operator]
-      - username: admin
-        # password: admin
-        passwordHash: 
"$argon2id$v=19$m=65536,t=3,p=4$joV9AVlyLS3pqq4mLrYokQ$pJLkTKrz9/LzEH6YaFljdz9k8dyBiryjwSB26Diiz9U"
-        roles: [admin]
-
-rbac:
-  enabled: true
-  roles:
-    viewer:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "browser-errors:read"
-      - "topology:read"
-      - "profile:read"
-      - "overview:read"
-    maintainer:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "browser-errors:read"
-      - "topology:read"
-      - "profile:read"
-      - "overview:read"
-      - "cluster:read"
-      - "inspect:read"
-      - "ttl:read"
-      - "config:read"
-    operator:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "browser-errors:read"
-      - "source-map:write"
-      - "topology:read"
-      - "profile:read"
-      - "cluster:read"
-      - "inspect:read"
-      - "ttl:read"
-      - "config:read"
-      - "overview:read"
-      - "overview:write"
-      - "setup:read"
-      - "setup:write"
-      - "dashboard:read"
-      - "dashboard:write"
-      - "alarm-setup:read"
-      - "alarm-setup:write"
-      - "alarm-rule:read"
-      - "alarm-rule:write"
-      - "rule:read"
-      - "rule:write"
-      - "rule:write:structural"
-      - "rule:delete"
-      - "rule:debug"
-      - "live-debug:read"
-      - "live-debug:write"
-      - "profile:enable"
-    admin:
-      - "*"
-  landingByRole:
-    viewer: /
-    maintainer: /operate/cluster
-    operator: /
-    admin: /
-
-session:
-  ttlMinutes: 60
-  cookieName: horizon_sid
-  cookieSecure: false
-
-audit:    { file: ./horizon-audit.jsonl }
-setup:    { file: ./horizon-setup.json }
-alarms:   { file: ./horizon-alarms.json }
-debugLog: { enabled: false, file: ./horizon-wire.jsonl, maxBodyChars: 8192, 
redactAuthHeaders: true }
-sourceMaps: { enabled: true, maxFileBytes: 67108864, maxTotalBytes: 536870912, 
maxFileCount: 128, bootMountDir: "" }
diff --git a/.claude/skills/local-boot/horizon.ldap.yaml 
b/.claude/skills/local-boot/horizon.ldap.yaml
deleted file mode 100644
index e324a59..0000000
--- a/.claude/skills/local-boot/horizon.ldap.yaml
+++ /dev/null
@@ -1,125 +0,0 @@
-# LDAP-backend boot config for the Horizon UI BFF. Authenticates against
-# an external directory; Horizon stores no passwords. This file targets
-# the throwaway test OpenLDAP described in SKILL.md ("Test the LDAP
-# backend"): base dc=horizon,dc=test, users under ou=people, groups
-# (groupOfNames) under ou=groups, seeded from ldap-seed.ldif.
-#
-# Test directory accounts (all seeded by ldap-seed.ldif). Login users
-# are named after their role and password == username, mirroring
-# horizon.local.yaml:
-#   directory admin (bindDn):  cn=admin,dc=horizon,dc=test  /  admin
-#   login users (password == username):
-#     admin       -> cn=horizon-admin   -> role admin
-#     operator    -> cn=sre             -> role operator
-#     maintainer  -> cn=platform        -> role maintainer
-#     viewer      -> (no group)         -> role viewer (the "*" fallback)
-#
-# The directory bind password is read from ${LDAP_BIND_PW} (defaults to
-# the test value `admin`); for a real directory, export LDAP_BIND_PW and
-# never commit it.
-#
-# OAP still points at a local OAP so the rest of the app works. Boot via
-# the local-boot skill with an ABSOLUTE HORIZON_CONFIG path.
-
-server:
-  host: 127.0.0.1
-  port: ${BFF_PORT:8081}
-
-oap:
-  queryUrl: http://localhost:12800
-  adminUrl: http://localhost:12800
-  zipkinUrl: http://localhost:9412/zipkin
-  timeoutMs: 15000
-
-auth:
-  backend: ldap
-  ldap:
-    url: ldap://localhost:389
-    bindDn: "cn=admin,dc=horizon,dc=test"
-    bindPassword: "${LDAP_BIND_PW:admin}"
-    userBaseDn: "ou=people,dc=horizon,dc=test"
-    userFilter: "(uid={username})"
-    displayNameAttr: cn
-    # This test directory uses groupOfNames groups without a memberOf
-    # overlay, so resolve membership by searching ou=groups for the
-    # user's DN. Switch to `memberOf` against directories that populate it.
-    groupStrategy: search
-    groupBaseDn: "ou=groups,dc=horizon,dc=test"
-    memberAttr: member
-    timeoutMs: 5000
-    tlsInsecure: false
-    groupMappings:
-      - { group: "cn=horizon-admin,ou=groups,dc=horizon,dc=test", role: admin }
-      - { group: "cn=sre,ou=groups,dc=horizon,dc=test", role: operator }
-      - { group: "cn=platform,ou=groups,dc=horizon,dc=test", role: maintainer }
-      - { group: "*", role: viewer }
-
-rbac:
-  enabled: true
-  roles:
-    viewer:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "topology:read"
-      - "profile:read"
-      - "overview:read"
-    maintainer:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "topology:read"
-      - "profile:read"
-      - "overview:read"
-      - "cluster:read"
-      - "inspect:read"
-      - "ttl:read"
-      - "config:read"
-    operator:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "topology:read"
-      - "profile:read"
-      - "cluster:read"
-      - "inspect:read"
-      - "ttl:read"
-      - "config:read"
-      - "overview:read"
-      - "overview:write"
-      - "setup:read"
-      - "setup:write"
-      - "dashboard:read"
-      - "dashboard:write"
-      - "alarm-setup:read"
-      - "alarm-setup:write"
-      - "alarm-rule:read"
-      - "alarm-rule:write"
-      - "rule:read"
-      - "rule:write"
-      - "rule:write:structural"
-      - "rule:delete"
-      - "rule:debug"
-      - "live-debug:read"
-      - "live-debug:write"
-      - "profile:enable"
-    admin:
-      - "*"
-  landingByRole:
-    viewer: /
-    maintainer: /operate/cluster
-    operator: /
-    admin: /
-
-session:
-  ttlMinutes: 60
-  cookieName: horizon_sid
-  cookieSecure: false
-
-audit:    { file: ./horizon-audit.jsonl }
-setup:    { file: ./horizon-setup.json }
-alarms:   { file: ./horizon-alarms.json }
-debugLog: { enabled: false, file: ./horizon-wire.jsonl, maxBodyChars: 8192, 
redactAuthHeaders: true }
diff --git a/.claude/skills/local-boot/horizon.local.yaml 
b/.claude/skills/local-boot/horizon.local.yaml
deleted file mode 100644
index c21c19b..0000000
--- a/.claude/skills/local-boot/horizon.local.yaml
+++ /dev/null
@@ -1,122 +0,0 @@
-# Static local-boot config for the Horizon UI BFF. Defaults to a LOCAL
-# OAP on 127.0.0.1 with NO network auth, but the four OAP fields below
-# accept env-var overrides so the same file boots against a remote OAP
-# (any URL), a deliberately-unreachable port (for the "OAP unreachable"
-# landing-block preview), or any other no-auth OAP. For demo-OAP access
-# use horizon.demo.yaml instead, which keeps the password out of git
-# via ${OAP_PASSWORD}.
-#
-# Boot via the `local-boot` skill (see SKILL.md) which passes this
-# file through HORIZON_CONFIG as an ABSOLUTE path. The local-user
-# password hashes below are throwaway dev credentials (password ==
-# username) — safe to commit; do NOT add real secrets here.
-
-server:
-  host: 127.0.0.1
-  port: ${BFF_PORT:8081}
-
-oap:
-  # On the default LOCAL OAP, GraphQL and the admin REST surface share
-  # the same port (12800). On the public demo and some k8s deployments
-  # they split (12800 / 17128) — override OAP_ADMIN_URL accordingly.
-  # Zipkin (9412) may not be exposed locally.
-  queryUrl: ${OAP_QUERY_URL:http://localhost:12800}
-  adminUrl: ${OAP_ADMIN_URL:http://localhost:12800}
-  zipkinUrl: ${OAP_ZIPKIN_URL:http://localhost:9412/zipkin}
-  timeoutMs: ${OAP_TIMEOUT_MS:15000}
-
-auth:
-  backend: local
-  local:
-    # One account per role for manual menu-RBAC checks. Password == username.
-    users:
-      - username: viewer
-        # password: viewer
-        passwordHash: 
"$argon2id$v=19$m=65536,t=3,p=4$Gp175hqr+EF2iZ7v1fndvw$w6w9hDI59/UA+CRARChDoGRlR1TkVt6kqzApa021K+0"
-        roles: [viewer]
-      - username: maintainer
-        # password: maintainer
-        passwordHash: 
"$argon2id$v=19$m=65536,t=3,p=4$w7ULwB3/jzH9FxVoHJ238A$y+qGoX6IPeOoGywLQCpfpAN5VJXcaevoWeJQhaybvQU"
-        roles: [maintainer]
-      - username: operator
-        # password: operator
-        passwordHash: 
"$argon2id$v=19$m=65536,t=3,p=4$nzoI4RqiobprtzX/mJqe5Q$FY2Hi7mKep0DPHoaE++r/KD++WLUwTgRUFLde87j2Wg"
-        roles: [operator]
-      - username: admin
-        # password: admin
-        passwordHash: 
"$argon2id$v=19$m=65536,t=3,p=4$joV9AVlyLS3pqq4mLrYokQ$pJLkTKrz9/LzEH6YaFljdz9k8dyBiryjwSB26Diiz9U"
-        roles: [admin]
-
-rbac:
-  enabled: true
-  roles:
-    viewer:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "browser-errors:read"
-      - "topology:read"
-      - "profile:read"
-      - "overview:read"
-    maintainer:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "browser-errors:read"
-      - "topology:read"
-      - "profile:read"
-      - "overview:read"
-      - "cluster:read"
-      - "inspect:read"
-      - "ttl:read"
-      - "config:read"
-    operator:
-      - "metrics:read"
-      - "alarms:read"
-      - "traces:read"
-      - "logs:read"
-      - "browser-errors:read"
-      - "source-map:write"
-      - "topology:read"
-      - "profile:read"
-      - "cluster:read"
-      - "inspect:read"
-      - "ttl:read"
-      - "config:read"
-      - "overview:read"
-      - "overview:write"
-      - "setup:read"
-      - "setup:write"
-      - "dashboard:read"
-      - "dashboard:write"
-      - "alarm-setup:read"
-      - "alarm-setup:write"
-      - "alarm-rule:read"
-      - "alarm-rule:write"
-      - "rule:read"
-      - "rule:write"
-      - "rule:write:structural"
-      - "rule:delete"
-      - "rule:debug"
-      - "live-debug:read"
-      - "live-debug:write"
-      - "profile:enable"
-    admin:
-      - "*"
-  landingByRole:
-    viewer: /
-    maintainer: /operate/cluster
-    operator: /
-    admin: /
-
-session:
-  ttlMinutes: 60
-  cookieName: horizon_sid
-  cookieSecure: false
-
-audit:    { file: ./horizon-audit.jsonl }
-setup:    { file: ./horizon-setup.json }
-alarms:   { file: ./horizon-alarms.json }
-debugLog: { enabled: false, file: ./horizon-wire.jsonl, maxBodyChars: 8192, 
redactAuthHeaders: true }
diff --git a/.dockerignore b/.dockerignore
index d3ea2be..4690a97 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -16,8 +16,8 @@
 # Denylist style: the image builds FROM SOURCE (see Dockerfile), so the
 # context is the source tree. Exclude only what must NOT enter the build —
 # host-built artifacts (arch-specific node_modules + dist), VCS, CI config,
-# local secrets, and scratch dirs. Everything else (source, lockfile,
-# horizon.example.yaml) is needed by `pnpm install` + `pnpm package`.
+# local secrets, and scratch dirs. Everything else (source, lockfile, the
+# committed horizon.yaml) is needed by `pnpm install` + `pnpm package`.
 
 **/node_modules
 **/dist
@@ -29,6 +29,10 @@ scripts/.release-work
 scripts/.finalize-work
 .claude
 
-# Local runtime config may carry secrets — never ship it. The committed
-# horizon.example.yaml stays (package.mjs reads it into dist/).
-horizon.yaml
+# horizon.yaml is the committed, env-driven config (package.mjs reads it into
+# dist/, the Dockerfile bakes it as /app/horizon.yaml) — keep it in the 
context.
+# Runtime state files carry local data; never ship them.
+horizon-audit.jsonl
+horizon-wire.jsonl
+horizon-setup.json
+horizon-alarms.json
diff --git a/.gitignore b/.gitignore
index e06b6d8..c4ae196 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,11 +40,11 @@ coverage
 # OS
 Thumbs.db
 
-# BFF runtime files
-horizon.yaml
+# BFF runtime files (horizon.yaml itself is committed — the env-driven config)
 horizon-audit.jsonl
 horizon-wire.jsonl
 horizon-setup.json
+horizon-alarms.json
 
 # Release
 scripts/.release-work
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fedb72b..916148a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,7 @@ The version line is shared by every package in the monorepo 
(apps + shared packa
 ### Deployment & configuration
 
 - **Run on the bundled templates, read-only — no OAP ui_template API needed.** 
A new `templates.mode` setting (`HORIZON_TEMPLATES_MODE`) adds a `readonly` 
mode: Horizon renders every dashboard / overview / alert-page / 3D-map / 
translation from the **local bundle** and never calls OAP's ui_template admin 
API. The whole config surface goes **read-only** — the admin pages still open 
and show the bundled config, but editing and publishing are disabled (and the 
BFF rejects a write even if it [...]
-- **The container image runs with environment variables only — no mounted 
config file.** The image now bakes a **fully tokenized** `horizon.yaml` where 
every field is a `${HORIZON_…:default}` env var, so `docker run -e 
HORIZON_OAP_QUERY_URL=… -e HORIZON_AUTH_LOCAL_USERS='[…]' …` is enough — no 
`-v` mount, no repackaging. Previously `oap.*`, `auth.*`, users, LDAP, RBAC, 
and performance tuning were YAML-only. Lists and secrets (users, LDAP, OAP 
auth) are set as JSON-string env vars; preced [...]
+- **The container image runs with environment variables only — no mounted 
config file.** There is now **one committed, env-driven `horizon.yaml`** (the 
former `horizon.example.yaml` / local-copy split is gone): every field is a 
`${HORIZON_…:default}` token, and the image bakes that same file. So `docker 
run -e HORIZON_OAP_QUERY_URL=… -e HORIZON_AUTH_LOCAL_USERS='[…]' …` is enough — 
no `-v` mount, no repackaging. Previously `oap.*`, `auth.*`, users, LDAP, RBAC, 
and performance tuning were [...]
 - **Cluster Status now reports admin-feature reachability, not just 
config-presence.** The admin-host pane fires a safe GET at the real REST path 
each feature calls on OAP — dashboard templates → `/ui-management/templates`, 
DSL management → `/runtime/rule/list`, live debugger → `/dsl-debugging/status`, 
Inspect → `/inspect/metrics` — and colors each row by whether that path 
actually responds. A feature whose module is loaded but whose endpoint 404s (a 
renamed or forked module, a selector  [...]
 
 ### General Service — PHP runtime (PHM)
diff --git a/Dockerfile b/Dockerfile
index 8e39e30..f5c0318 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -58,12 +58,11 @@ COPY --from=build /src/dist/server.js              
./server.js
 COPY --from=build /src/dist/package.json           ./package.json
 COPY --from=build /src/dist/node_modules           ./node_modules
 COPY --from=build /src/dist/static                 ./static
-COPY --from=build /src/dist/horizon.example.yaml   ./horizon.example.yaml
-# Bake the (fully tokenized) example AS the active config so the image runs
-# with NO mounted file: every field is a `${HORIZON_…:default}` token, so
+# The fully tokenized horizon.yaml is the active config — every field is a
+# `${HORIZON_…:default}` token, so the image runs with NO mounted file:
 # `docker run -e HORIZON_OAP_QUERY_URL=… -e HORIZON_AUTH_LOCAL_USERS='[…]' …`
 # is enough. A bind-mount at /app/horizon.yaml still overrides it.
-COPY --from=build /src/dist/horizon.example.yaml   ./horizon.yaml
+COPY --from=build /src/dist/horizon.yaml           ./horizon.yaml
 
 COPY --from=build --chown=horizon:horizon /src/dist/bundled_templates  
./bundled_templates
 
diff --git a/README.md b/README.md
index b25be5d..f24e853 100644
--- a/README.md
+++ b/README.md
@@ -127,7 +127,7 @@ See 
[`docs/setup/container-image.md`](docs/setup/container-image.md) for image t
 
 ## Configuration
 
-Horizon UI is configured by a single `horizon.yaml` (hot-reloaded, with 
`${VAR}` environment-variable interpolation) — see `horizon.example.yaml`. Key 
sections:
+Horizon UI is configured by a single `horizon.yaml` (hot-reloaded, with 
`${VAR}` environment-variable interpolation) — see `horizon.yaml`. Key sections:
 
 - `server` — host / port.
 - `oap` — `queryUrl`, `adminUrl`, `zipkinUrl`, `timeoutMs`, and optional 
outbound basic-auth.
diff --git a/apps/bff/src/config/loader.test.ts 
b/apps/bff/src/config/loader.test.ts
index aa629f8..bae60ca 100644
--- a/apps/bff/src/config/loader.test.ts
+++ b/apps/bff/src/config/loader.test.ts
@@ -60,11 +60,11 @@ describe('stripNullish', () => {
   });
 });
 
-// The env-native contract: the tokenized horizon.example.yaml, interpolated +
+// The env-native contract: the tokenized horizon.yaml, interpolated +
 // stripped + parsed, must accept env overrides for every kind of field.
-describe('env-native config (horizon.example.yaml + env)', () => {
+describe('env-native config (horizon.yaml + env)', () => {
   const here = dirname(fileURLToPath(import.meta.url));
-  const raw = readFileSync(resolve(here, '../../../../horizon.example.yaml'), 
'utf8');
+  const raw = readFileSync(resolve(here, '../../../../horizon.yaml'), 'utf8');
   const load = (env: NodeJS.ProcessEnv): ReturnType<typeof configSchema.parse> 
=>
     configSchema.parse(stripNullish(YAML.parse(interpolateEnv(raw, env)) ?? 
{}));
 
diff --git a/apps/bff/src/config/schema.test.ts 
b/apps/bff/src/config/schema.test.ts
index 74bc26d..ae16cd0 100644
--- a/apps/bff/src/config/schema.test.ts
+++ b/apps/bff/src/config/schema.test.ts
@@ -29,15 +29,15 @@ describe('configSchema defaults', () => {
   });
 });
 
-// horizon.example.yaml is the SHIPPED default + the env-var reference: every
+// horizon.yaml is the SHIPPED default + the env-var reference: every
 // field is a `${HORIZON_…:default}` token. Two contracts guarded here:
 //   1. With NO env set, the tokens' defaults parse to EXACTLY the schema
 //      defaults — so the file is a faithful "this is what you get" reference.
 //   2. Every top-level config section appears in the example, so a new
 //      section can't be added to the schema without an env-overridable token.
-describe('horizon.example.yaml — tokenized default + parity', () => {
+describe('horizon.yaml — tokenized default + parity', () => {
   const here = dirname(fileURLToPath(import.meta.url));
-  const examplePath = resolve(here, '../../../../horizon.example.yaml');
+  const examplePath = resolve(here, '../../../../horizon.yaml');
   const raw = readFileSync(examplePath, 'utf8');
 
   it('parses to exactly the schema defaults (token defaults match the 
schema)', () => {
@@ -63,7 +63,7 @@ describe('horizon.example.yaml — tokenized default + parity', 
() => {
     for (const s of sections) {
       // `infra3d` is the deprecated/ignored passthrough — never tokenized.
       if (s === 'infra3d') continue;
-      expect(exampleKeys, `config section "${s}" is missing from 
horizon.example.yaml`).toContain(s);
+      expect(exampleKeys, `config section "${s}" is missing from 
horizon.yaml`).toContain(s);
     }
   });
 
@@ -107,6 +107,6 @@ describe('horizon.example.yaml — tokenized default + 
parity', () => {
       uncovered.push(`${path} (leaf not tokenized)`);
     };
     walk(defaults, example, '');
-    expect(uncovered, `fields lacking an env token in horizon.example.yaml:\n  
${uncovered.join('\n  ')}`).toEqual([]);
+    expect(uncovered, `fields lacking an env token in horizon.yaml:\n  
${uncovered.join('\n  ')}`).toEqual([]);
   });
 });
diff --git a/docs/access-control/local-backend.md 
b/docs/access-control/local-backend.md
index dc01ce7..6ff3d86 100644
--- a/docs/access-control/local-backend.md
+++ b/docs/access-control/local-backend.md
@@ -60,10 +60,10 @@ The session captures the role list at authentication time, 
not on every request.
 
 ## File permissions
 
-`horizon.yaml` contains password hashes. Treat it as a secret-bearing file:
+The `horizon.yaml` shipped in the repo and image is **env-driven and holds no 
secrets** — `local.users` defaults to the `HORIZON_AUTH_LOCAL_USERS` variable. 
Supply your users (with their Argon2id hashes) through that environment 
variable so hashes never land in a version-controlled file. If you instead 
write hashes directly into a `horizon.yaml` you deploy, treat that copy as a 
secret-bearing file:
 
 - Filesystem perms `0600` (BFF user only).
-- Not in version control. The repo's `.gitignore` excludes `horizon.yaml`; 
only `horizon.example.yaml` is committed.
+- Keep it out of version control — the committed `horizon.yaml` holds only 
`${HORIZON_…}` tokens, never real hashes.
 - If you store the file in configuration management (Ansible, Helm secret, 
etc.), encrypt at rest.
 
 ## Mixing with LDAP
diff --git a/docs/compatibility/ports.md b/docs/compatibility/ports.md
index 6707bb5..0a3d51b 100644
--- a/docs/compatibility/ports.md
+++ b/docs/compatibility/ports.md
@@ -38,7 +38,7 @@ The OAP defaults. Each module binds its own port:
 - `:17128` for admin
 - `:9412` for Zipkin
 
-This is what `horizon.example.yaml` shows.
+This is what `horizon.yaml` shows.
 
 ### Shared port (Docker / Kubernetes presets)
 
diff --git a/docs/setup/container-image.md b/docs/setup/container-image.md
index 75c12de..0e89006 100644
--- a/docs/setup/container-image.md
+++ b/docs/setup/container-image.md
@@ -28,7 +28,6 @@ The full commit SHA is the canonical, immutable identifier. 
Moving tags are conv
 | `/app/server.js` | root | no | Compiled BFF entry point. `CMD` runs `node 
server.js`. |
 | `/app/node_modules/` | root | no | Production npm dependencies. |
 | `/app/static/` | root | no | Built UI assets (Vite `dist/`). |
-| `/app/horizon.example.yaml` | root | no | Example config — **read-only 
reference**, copy from it. |
 | `/app/horizon.yaml` | root | no | The **active** config — a **baked, fully 
tokenized default** (every field is a `${HORIZON_…:default}` env token). The 
image runs with no mounted file; override any field via env (see [Run with env 
vars only](#run-with-env-vars-only-no-mounted-file)), or bind-mount your own to 
replace it. |
 | `/app/bundled_templates/` | **horizon** | **yes** | Layer + overview JSON 
templates. Owned by `horizon` because the admin **Layer-Templates** and 
**Overview-Templates** editors write into per-key files here. |
 | `/data/` | **horizon** | **yes** | Declared `VOLUME`. Default destination 
for the audit log, setup state, alarm state, and wire debug log. Mount a PVC / 
named volume / host bind here for durable storage. |
diff --git a/docs/setup/overview.md b/docs/setup/overview.md
index 143122f..ea218d7 100644
--- a/docs/setup/overview.md
+++ b/docs/setup/overview.md
@@ -12,15 +12,14 @@ This page is the shortest path from "no Horizon" to 
"Horizon in front of a runni
 
 ### 1. Unpack Horizon
 
-Unpack the binary tarball (substitute the release version you downloaded for 
`<version>`) and copy the example config:
+Unpack the binary tarball (substitute the release version you downloaded for 
`<version>`):
 
 ```sh
 tar -xzf apache-skywalking-horizon-ui-<version>-bin.tar.gz
 cd apache-skywalking-horizon-ui-<version>-bin
-cp horizon.example.yaml horizon.yaml
 ```
 
-The binary is self-contained: `server.js`, `node_modules/`, `static/`, and 
bundled templates are already present. There is no `pnpm install` step.
+The binary is self-contained: `server.js`, `node_modules/`, `static/`, bundled 
templates, and the config `horizon.yaml` are already present. There is no `pnpm 
install` step. `horizon.yaml` is **env-driven** — every field is a 
`${HORIZON_…:default}` variable, so you can leave the file as-is and set only 
the environment variables you need (starting with your OAP address), or edit 
the file directly.
 
 ### 2. Point Horizon at OAP
 
diff --git a/docs/setup/rbac.md b/docs/setup/rbac.md
index 31b195a..346c119 100644
--- a/docs/setup/rbac.md
+++ b/docs/setup/rbac.md
@@ -24,7 +24,7 @@ rbac:
 | Field | Type | Default | Required | Notes |
 |---|---|---|---|---|
 | `enabled` | boolean | `true` | no | When `false`, every authenticated 
session is granted `*` (full access). Useful for dev. **Set `true` in 
production.** |
-| `roles` | object | the four built-ins | no | Custom role definitions. Keys 
are role names; values are arrays of permission strings. **Omitting this block 
uses the four built-ins** (`viewer`, `maintainer`, `operator`, `admin`) — see 
`horizon.example.yaml` for the full grants. Defining the block at all overrides 
the built-ins entirely; redefine all four if you want to keep them. |
+| `roles` | object | the four built-ins | no | Custom role definitions. Keys 
are role names; values are arrays of permission strings. **Omitting this block 
uses the four built-ins** (`viewer`, `maintainer`, `operator`, `admin`) — see 
`horizon.yaml` for the full grants. Defining the block at all overrides the 
built-ins entirely; redefine all four if you want to keep them. |
 | `landingByRole` | object | see below | no | Post-login redirect route per 
role. First role on the user wins. |
 
 ## Built-in roles (used when `roles` is not set)
diff --git a/horizon.example.yaml b/horizon.yaml
similarity index 100%
rename from horizon.example.yaml
rename to horizon.yaml
diff --git a/scripts/package.mjs b/scripts/package.mjs
index 7355445..b750d47 100644
--- a/scripts/package.mjs
+++ b/scripts/package.mjs
@@ -30,7 +30,7 @@
  *       node_modules/          — production deps (npm + workspace dists)
  *       bundled_templates/     — layer + overview JSON templates
  *       static/                — built UI (Vite dist)
- *       horizon.example.yaml   — copy-and-edit starting point
+ *       horizon.yaml           — env-driven config (override fields via 
HORIZON_… env vars)
  *
  * The build:
  *   1. Builds each workspace package under `packages/*` (tsc → dist/).
@@ -102,7 +102,7 @@ cpSync(
   { recursive: true },
 );
 cpSync(resolve(root, 'apps/ui/dist'), resolve(dist, 'static'), { recursive: 
true });
-cpSync(resolve(root, 'horizon.example.yaml'), resolve(dist, 
'horizon.example.yaml'));
+cpSync(resolve(root, 'horizon.yaml'), resolve(dist, 'horizon.yaml'));
 
 step('Done');
 console.log(`
@@ -110,8 +110,8 @@ Target binary: ${resolve(dist, 'server.js')}
 
 Boot it:
 
-    cp horizon.example.yaml horizon.yaml         # configure once
-    HORIZON_CONFIG=./horizon.yaml \\
+    HORIZON_CONFIG=./horizon.yaml \\             # every field is a 
\${HORIZON_…} env var
+      HORIZON_OAP_QUERY_URL=http://oap:12800 \\  # override only what you need
       HORIZON_STATIC_DIR=./dist/static \\
       node dist/server.js
 
diff --git a/scripts/release.sh b/scripts/release.sh
index 9320107..7b2cf10 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -504,7 +504,7 @@ Guide to build the release from source:
  * cd into the extracted directory
  * pnpm install --frozen-lockfile
  * pnpm package
- * node dist/server.js (after copying horizon.example.yaml → horizon.yaml)
+ * node dist/server.js (after copying horizon.yaml → horizon.yaml)
 
 Voting will start now (${VOTE_DATE}) and will remain open for at least
 72 hours. PMC members, please cast your vote.

Reply via email to