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

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


The following commit(s) were added to refs/heads/main by this push:
     new c13f7b0  chore: env-driven dev boot + zipkin span overlay UX + log row 
fix (#21)
c13f7b0 is described below

commit c13f7b09436b787ea78f25dc124538391d48847d
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Wed May 27 20:04:57 2026 +0800

    chore: env-driven dev boot + zipkin span overlay UX + log row fix (#21)
    
    Local dev:
    - Vite (`apps/ui/vite.config.ts`) reads `UI_DEV_PORT` and `BFF_PORT` from
      env, defaults 9091/8081, so two parallel envs can coexist on the same
      box (e.g. alongside the legacy booster-ui on 8080).
    - All bundled BFF configs (`horizon.local.yaml`, `horizon.demo.yaml`,
      `horizon.ldap.yaml`) honour `${BFF_PORT:8081}` for `server.port` via
      the YAML loader's existing interpolation.
    - `horizon.local.yaml` now accepts `OAP_QUERY_URL` / `OAP_ADMIN_URL` /
      `OAP_ZIPKIN_URL` / `OAP_TIMEOUT_MS` env overrides, collapsing the
      separate `horizon.remote.yaml` (deleted) and `horizon.unreachable.yaml`
      (deleted) into one canonical no-auth config that flexes via env.
    - SKILL.md gains an "Environment variables (the dev contract)" reference
      table at the top, plus deeper "Custom ports" (two parallel envs, prod
      single-port) and "Custom OAP target" sections. Boot recipes throughout
      switched from hard-coded ports to `${BFF_PORT:-8081}` /
      `${UI_DEV_PORT:-9091}` shell expansions.
    
    Zipkin trace UX (`LayerZipkinTracesView.vue`, `ZipkinTracePopout.vue`):
    - Span detail panel now floats over the right edge of the waterfall as a
      `min(640px, 60%)`-wide overlay instead of compressing the waterfall to
      a fixed 320px sidebar. Long tag values (URLs, JSON bodies) read on one
      line without truncating bars.
    - Click-outside the panel or pressing Escape dismisses the pin. In the
      popout, Escape unwinds one layer at a time: clear span first, then
      close the popout.
    - Trace-detail card in selection mode uses the same sticky / max-height
      shape as the rail, and the grid is now `align-items: stretch` so the
      two cards share row height instead of one collapsing to content.
    - Global keydown + mousedown listeners on the popout are wired through
      `onMounted` / `onBeforeUnmount` instead of leaking at module scope.
    
    Logs list (`LayerLogsView.vue`):
    - Fix: rows without a `traceId` collapsed the 6-column grid one slot
      leftward, sizing the 1fr content cell to the 60px trace slot and
      ellipsing the preview at ~2 characters. The trace-link slot is now
      always rendered (link or empty placeholder), preserving the grid.
---
 .claude/skills/local-boot/SKILL.md                 | 157 ++++++++++++++++++---
 .claude/skills/local-boot/horizon.demo.yaml        |   2 +-
 .claude/skills/local-boot/horizon.ldap.yaml        |   2 +-
 .claude/skills/local-boot/horizon.local.yaml       |  35 +++--
 .claude/skills/local-boot/horizon.unreachable.yaml | 102 -------------
 apps/ui/src/layer/logs/LayerLogsView.vue           |   6 +
 apps/ui/src/layer/traces/LayerZipkinTracesView.vue |  84 ++++++++---
 apps/ui/src/layer/traces/ZipkinTracePopout.vue     |  78 ++++++++--
 apps/ui/vite.config.ts                             |  21 ++-
 9 files changed, 313 insertions(+), 174 deletions(-)

diff --git a/.claude/skills/local-boot/SKILL.md 
b/.claude/skills/local-boot/SKILL.md
index df4ba15..e623e65 100644
--- a/.claude/skills/local-boot/SKILL.md
+++ b/.claude/skills/local-boot/SKILL.md
@@ -8,7 +8,7 @@ user-invocable: true
 
 Two bundled static configs live next to this file:
 
-- `horizon.local.yaml` — local OAP on `127.0.0.1:12800`, no OAP network auth.
+- `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}`.
 
 Both define the same throwaway Horizon login users (password == username):
@@ -16,6 +16,61 @@ Both define the same throwaway Horizon login users (password 
== username):
 
 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" below. The defaults are what 
the examples use throughout this doc.
+
+## 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).
+
+| 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.
+
 ## 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`).
@@ -36,43 +91,111 @@ 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: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 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.
 
 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:
   ```bash
-  curl -s --noproxy '*' -b /tmp/sw.cookies 
http://127.0.0.1:8081/api/oap/config | grep -oE '"adminUrl":"[^"]*"'
+  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
   ```
-  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 `:8081` is 
free, relaunch.
+  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 :8081 is actually free — a stale
-#    BFF holding the port makes the new one die on EADDRINUSE while the
-#    old config keeps serving (see "stale-process trap"):
+# 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:8081 -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done
+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):
+# 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):
+#    --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:9091`** and log in as `admin` / `admin`.
+Then open **`http://127.0.0.1:${UI_DEV_PORT:-9091}`** and log in as `admin` / 
`admin`.
 
 ## Boot against the public demo OAP
 
@@ -94,7 +217,7 @@ fi
 
 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
+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" \
   pnpm --filter @skywalking-horizon-ui/bff run dev &
 ( cd "$REPO/apps/ui" && \
@@ -127,7 +250,7 @@ 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:8081 -sTCP:LISTEN >/dev/null 2>&1; do sleep 1; done
+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" \
   pnpm --filter @skywalking-horizon-ui/bff run dev &
 ```
@@ -147,7 +270,7 @@ Directory bind account: `cn=admin,dc=horizon,dc=test` / 
`admin` (override via `L
 ```bash
 # verify a login resolves the expected role:
 curl -s --noproxy '*' -H 'Content-Type: application/json' -X POST \
-  http://127.0.0.1:8081/api/auth/login -d 
'{"username":"admin","password":"admin"}'
+  "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
@@ -157,12 +280,12 @@ credentials (regular users usually can't read the group 
subtree).
 
 ```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:8081/api/auth/health; do sleep 1; done
+until curl -s --noproxy '*' -m2 -o /dev/null 
"http://127.0.0.1:${BFF_PORT:-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:8081/api/auth/login -d 
'{"username":"admin","password":"admin"}'
+  "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 :8081.
+# re-check the absolute HORIZON_CONFIG path and that no stale BFF holds the 
BFF port.
 ```
 
 ## Editing the configs
diff --git a/.claude/skills/local-boot/horizon.demo.yaml 
b/.claude/skills/local-boot/horizon.demo.yaml
index 242ac29..2185a52 100644
--- a/.claude/skills/local-boot/horizon.demo.yaml
+++ b/.claude/skills/local-boot/horizon.demo.yaml
@@ -9,7 +9,7 @@
 
 server:
   host: 127.0.0.1
-  port: 8081
+  port: ${BFF_PORT:8081}
 
 oap:
   queryUrl: https://demo.skywalking.apache.org:12800
diff --git a/.claude/skills/local-boot/horizon.ldap.yaml 
b/.claude/skills/local-boot/horizon.ldap.yaml
index 6c9e0d2..e324a59 100644
--- a/.claude/skills/local-boot/horizon.ldap.yaml
+++ b/.claude/skills/local-boot/horizon.ldap.yaml
@@ -23,7 +23,7 @@
 
 server:
   host: 127.0.0.1
-  port: 8081
+  port: ${BFF_PORT:8081}
 
 oap:
   queryUrl: http://localhost:12800
diff --git a/.claude/skills/local-boot/horizon.local.yaml 
b/.claude/skills/local-boot/horizon.local.yaml
index 58477e0..448d1b6 100644
--- a/.claude/skills/local-boot/horizon.local.yaml
+++ b/.claude/skills/local-boot/horizon.local.yaml
@@ -1,22 +1,29 @@
-# Static local-boot config for the Horizon UI BFF — points at a LOCAL
-# OAP on 127.0.0.1 with NO network auth. 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. For demo-OAP access use horizon.demo.yaml instead, which
-# keeps the OAP password out of git via ${OAP_PASSWORD}.
+# 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: 8081
+  port: ${BFF_PORT:8081}
 
 oap:
-  queryUrl: http://localhost:12800
-  # This OAP serves the debugging/admin REST surface on the SAME port
-  # as GraphQL (12800). Zipkin (9412) may not be exposed locally.
-  adminUrl: http://localhost:12800
-  zipkinUrl: http://localhost:9412/zipkin
-  timeoutMs: 15000
+  # 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
diff --git a/.claude/skills/local-boot/horizon.unreachable.yaml 
b/.claude/skills/local-boot/horizon.unreachable.yaml
deleted file mode 100644
index b9f81ec..0000000
--- a/.claude/skills/local-boot/horizon.unreachable.yaml
+++ /dev/null
@@ -1,102 +0,0 @@
-# Static local-boot config for previewing the "OAP query host is
-# unreachable" landing block. Points queryUrl + adminUrl at a port
-# nothing is listening on (127.0.0.1:12801 — adjacent to the normal
-# local OAP 12800), so every OAP request fails fast with ECONNREFUSED.
-# Auth + RBAC mirror horizon.local.yaml so admin/admin still logs in.
-
-server:
-  host: 127.0.0.1
-  port: 8081
-
-oap:
-  queryUrl: http://127.0.0.1:12801
-  adminUrl: http://127.0.0.1:12801
-  zipkinUrl: http://127.0.0.1:9412/zipkin
-  timeoutMs: 5000
-
-auth:
-  backend: local
-  local:
-    users:
-      - 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: 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"
-      - "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/apps/ui/src/layer/logs/LayerLogsView.vue 
b/apps/ui/src/layer/logs/LayerLogsView.vue
index d40f3de..3916fe1 100644
--- a/apps/ui/src/layer/logs/LayerLogsView.vue
+++ b/apps/ui/src/layer/logs/LayerLogsView.vue
@@ -877,7 +877,13 @@ function jumpToTrace(traceId: string, ts?: number): void {
                 >{{ parseServiceName(r.serviceName).group }}</span>
                 {{ r.serviceName ? parseServiceName(r.serviceName).base : '—' 
}}
               </span>
+              <!-- The trace-link slot is ALWAYS rendered, even when the
+                   row has no traceId — its 60px grid column is fixed,
+                   and skipping the slot collapses every subsequent
+                   column leftward (the 1fr content cell ends up sized
+                   at 60px, truncating the preview to two characters). -->
               <span v-if="r.traceId" class="lg-trace mono" 
@click.stop="jumpToTrace(r.traceId!, r.timestamp)">↗ trace</span>
+              <span v-else class="lg-trace-spacer" aria-hidden="true"></span>
               <!-- Format chip + flat content preview. Chip is always
                    rendered so the operator can tell at-a-glance which
                    rows are JSON / YAML / plain text. Preview is single
diff --git a/apps/ui/src/layer/traces/LayerZipkinTracesView.vue 
b/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
index 484b69a..c064b27 100644
--- a/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
+++ b/apps/ui/src/layer/traces/LayerZipkinTracesView.vue
@@ -22,7 +22,7 @@
   the parent-id-walking ZipkinTracePopout.
 -->
 <script setup lang="ts">
-import { computed, ref, watch } from 'vue';
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useRoute } from 'vue-router';
 import type { ZipkinTraceListRow } from '@skywalking-horizon-ui/api-client';
@@ -398,6 +398,33 @@ function clearSpan(): void {
 // Clear the pinned span when the operator switches trace.
 watch(selectedTraceId, () => { selectedSpanId.value = null; });
 
+// Dismiss the floating span-detail overlay on Escape or click-outside.
+// A click on another `.ztr-wf-row` is ignored here — the row's own
+// handler swaps to that span; if we closed first we'd lose the new pin.
+const spanDetailRef = ref<HTMLElement | null>(null);
+function onSpanDetailDocClick(e: MouseEvent): void {
+  if (!selectedSpan.value) return;
+  const t = e.target as Element | null;
+  if (!t) return;
+  if (spanDetailRef.value?.contains(t)) return;
+  if (t.closest?.('.ztr-wf-row')) return;
+  clearSpan();
+}
+function onSpanDetailKey(e: KeyboardEvent): void {
+  if (e.key === 'Escape' && selectedSpan.value) {
+    e.stopPropagation();
+    clearSpan();
+  }
+}
+onMounted(() => {
+  document.addEventListener('mousedown', onSpanDetailDocClick);
+  document.addEventListener('keydown', onSpanDetailKey);
+});
+onBeforeUnmount(() => {
+  document.removeEventListener('mousedown', onSpanDetailDocClick);
+  document.removeEventListener('keydown', onSpanDetailKey);
+});
+
 function fmtAbsTime(usSinceEpoch: number): string {
   if (!usSinceEpoch) return '—';
   const d = new Date(usSinceEpoch / 1000);
@@ -721,7 +748,7 @@ function openByInput(): void {
         </header>
         <div v-if="selectedLoading" class="ztr-empty hint">{{ t('loading 
spans…') }}</div>
         <div v-else-if="detailRows.length === 0" class="ztr-empty">{{ t('No 
spans for this trace.') }}</div>
-        <div v-else class="ztr-detail-body" :class="{ 'has-span': 
!!selectedSpan }">
+        <div v-else class="ztr-detail-body">
           <div class="ztr-waterfall">
             <div
               v-for="row in detailRows"
@@ -755,9 +782,11 @@ function openByInput(): void {
             </div>
           </div>
 
-          <!-- Lens-style span detail sidebar. Appears when a span is
-               pinned; renders identity block + tags + annotation timeline. -->
-          <aside v-if="selectedSpan" class="ztr-span-detail">
+          <!-- Span detail floats as a right-edge overlay (width capped
+               at min(640px, 60%)) so it can render long tag values
+               without compressing the waterfall bars. Click ×, click
+               the same span, or pick another span to dismiss / swap. -->
+          <aside v-if="selectedSpan" ref="spanDetailRef" 
class="ztr-span-detail">
             <header class="ztr-span-detail-head">
               <h5>{{ t('Span detail') }}</h5>
               <button class="sw-btn small ghost" type="button" 
:title="t('Close')" @click="clearSpan">×</button>
@@ -1020,7 +1049,13 @@ function openByInput(): void {
 .ztr-detail {
   display: flex;
   flex-direction: column;
-  max-height: 720px;
+  /* Match the rail's sticky viewport-anchored height so the two cards
+     read as equal-height side-by-side. Without this they sized to
+     content independently (rail was viewport-tall, detail capped at
+     720px) and looked visually mismatched. */
+  position: sticky;
+  top: 12px;
+  max-height: calc(100vh - 80px);
   min-height: 240px;
   overflow: hidden;
 }
@@ -1033,22 +1068,30 @@ function openByInput(): void {
 }
 .ztr-tid { flex: 1; color: var(--sw-fg-2); font-size: 11px; }
 
-/* When a span is pinned, the detail card splits into waterfall (left)
-   + span-detail panel (right) — same layout as the popout. */
+/* When a span is pinned, the detail panel floats over the right edge
+   of the waterfall as an overlay so it can be wide enough for long tag
+   values without compressing the waterfall bars. min(640px, 60%) caps
+   the panel at 640px on wide layouts but yields gracefully on narrow
+   ones. Pop-out has the same pattern (.zk-detail). */
 .ztr-detail-body {
   flex: 1;
+  position: relative;
   display: flex;
   min-height: 0;
   overflow: hidden;
 }
-.ztr-detail-body.has-span .ztr-waterfall {
-  border-right: 1px solid var(--sw-line);
-}
 .ztr-span-detail {
-  flex: 0 0 320px;
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  width: min(640px, 60%);
   overflow-y: auto;
   padding: 12px 14px;
   background: var(--sw-bg-1);
+  border-left: 1px solid var(--sw-line);
+  box-shadow: -8px 0 24px rgba(0, 0, 0, 0.45);
+  z-index: 5;
 }
 .ztr-span-detail-head {
   display: flex;
@@ -1098,14 +1141,8 @@ function openByInput(): void {
 .zk-annotations .dim { color: var(--sw-fg-3); }
 .zk-ann-val { color: var(--sw-fg-1); word-break: break-all; }
 
-@media (max-width: 1200px) {
-  .ztr-span-detail { flex-basis: 260px; }
-}
-@media (max-width: 980px) {
-  .ztr-detail-body { flex-direction: column; }
-  .ztr-detail-body.has-span .ztr-waterfall { border-right: none; 
border-bottom: 1px solid var(--sw-line); }
-  .ztr-span-detail { flex: 0 0 auto; max-height: 360px; }
-}
+/* Floating overlay scales with the container via min(640px, 60%) — no
+   width breakpoints needed. */
 
 /* Inline waterfall — each span is a row with `<label> <track> <dur>`.
    Track width is dynamic; the bar inside left/width % positions the
@@ -1191,7 +1228,12 @@ function openByInput(): void {
   display: grid;
   grid-template-columns: 320px 1fr;
   gap: 12px;
-  align-items: start;
+  /* `stretch` lets the two cards share the row height (max of each
+     side's natural height, capped by their respective max-height
+     rules). With `start` the right detail card collapsed to its
+     content height while the rail extended down with many trace
+     rows, producing a visibly mismatched pair. */
+  align-items: stretch;
 }
 .ztr-detail-split.rail-collapsed { grid-template-columns: 64px 1fr; }
 .ztr-rail {
diff --git a/apps/ui/src/layer/traces/ZipkinTracePopout.vue 
b/apps/ui/src/layer/traces/ZipkinTracePopout.vue
index 10a3b6d..71ca440 100644
--- a/apps/ui/src/layer/traces/ZipkinTracePopout.vue
+++ b/apps/ui/src/layer/traces/ZipkinTracePopout.vue
@@ -29,7 +29,7 @@
   is picked. ESC closes; backdrop click closes.
 -->
 <script setup lang="ts">
-import { computed, ref, watch } from 'vue';
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import type { ZipkinSpan } from '@skywalking-horizon-ui/api-client';
 
@@ -132,6 +132,20 @@ function clearSpan(): void {
 // Drop the selection when the trace changes.
 watch(traceIdRef, () => { selectedSpanId.value = null; });
 
+// Floating span-detail dismiss — click outside the .zk-detail panel
+// (but not on another row, which has its own swap handler) clears the
+// pin. Backdrop clicks still close the whole popout via the existing
+// `@click.self` on .zk-popout-backdrop.
+const spanDetailRef = ref<HTMLElement | null>(null);
+function onSpanDetailDocClick(e: MouseEvent): void {
+  if (!selectedSpan.value) return;
+  const t = e.target as Element | null;
+  if (!t) return;
+  if (spanDetailRef.value?.contains(t)) return;
+  if (t.closest?.('.zk-row')) return;
+  clearSpan();
+}
+
 // ── Color per service so each row reads as a band ────────────────
 // First entry tracks `--sw-accent` so the brand color in the trace
 // waterfall follows the active theme. Rest stay constant for service
@@ -168,15 +182,46 @@ function widthPct(us: number): number {
 }
 
 // ── ESC + backdrop close ─────────────────────────────────────────
+// Escape unwinds one layer at a time: clear span detail first (if a
+// span is pinned), then close the whole popout. Matches the dismiss
+// model of a modal stack — operators expect Escape not to nuke their
+// trace context when they only meant to dismiss the side panel.
 function onKeydown(ev: KeyboardEvent): void {
-  if (ev.key === 'Escape' && openTraceId.value) {
+  if (ev.key !== 'Escape') return;
+  if (selectedSpan.value) {
+    ev.preventDefault();
+    clearSpan();
+    return;
+  }
+  if (openTraceId.value) {
     ev.preventDefault();
     closeTrace();
   }
 }
-if (typeof window !== 'undefined') {
-  window.addEventListener('keydown', onKeydown);
-}
+
+// Global keydown + mousedown listeners are wired through Vue's
+// lifecycle so they're torn down on unmount. The previous module-level
+// `addEventListener` calls leaked one listener pair per component
+// instance — the popout is usually mounted once for the app's
+// lifetime, but the lifecycle pattern matches LayerZipkinTracesView
+// and is the correct shape for HMR / future split-mount scenarios.
+onMounted(() => {
+  if (typeof window !== 'undefined') {
+    window.addEventListener('keydown', onKeydown);
+  }
+  if (typeof document !== 'undefined') {
+    document.addEventListener('mousedown', onSpanDetailDocClick);
+  }
+});
+onBeforeUnmount(() => {
+  if (typeof window !== 'undefined') {
+    window.removeEventListener('keydown', onKeydown);
+  }
+  if (typeof document !== 'undefined') {
+    document.removeEventListener('mousedown', onSpanDetailDocClick);
+  }
+});
+
 function copyTraceId(): void {
   if (!traceIdRef.value) return;
   navigator.clipboard?.writeText(traceIdRef.value).catch(() => {});
@@ -199,7 +244,7 @@ function copyTraceId(): void {
         {{ t('No spans returned for this trace.') }}
       </div>
 
-      <div v-else class="zk-split" :class="{ 'no-detail': !selectedSpan }">
+      <div v-else class="zk-split">
         <!-- Waterfall column -->
         <div class="zk-waterfall">
           <div class="zk-time-axis">
@@ -240,8 +285,10 @@ function copyTraceId(): void {
           </div>
         </div>
 
-        <!-- Span detail rail -->
-        <aside v-if="selectedSpan" class="zk-detail">
+        <!-- Span detail floats as a right-edge overlay; width caps at
+             min(640px, 60%) so long tag values render without
+             compressing the waterfall. Mirrors LayerZipkinTracesView. -->
+        <aside v-if="selectedSpan" ref="spanDetailRef" class="zk-detail">
           <header class="zk-detail-head">
             <h5>{{ t('Span detail') }}</h5>
             <button class="sw-btn small ghost" type="button" 
@click="clearSpan">×</button>
@@ -327,17 +374,15 @@ function copyTraceId(): void {
 }
 
 .zk-split {
-  display: grid;
-  grid-template-columns: 1fr 360px;
+  position: relative;
   flex: 1;
   min-height: 0;
   overflow: hidden;
 }
-.zk-split.no-detail { grid-template-columns: 1fr; }
 
 .zk-waterfall {
+  height: 100%;
   overflow-y: auto;
-  border-right: 1px solid var(--sw-line);
   padding: 0;
 }
 .zk-time-axis {
@@ -430,8 +475,17 @@ function copyTraceId(): void {
 }
 
 .zk-detail {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  width: min(640px, 60%);
   overflow-y: auto;
   padding: 12px 14px;
+  background: var(--sw-bg-1);
+  border-left: 1px solid var(--sw-line);
+  box-shadow: -8px 0 24px rgba(0, 0, 0, 0.45);
+  z-index: 5;
 }
 .zk-detail-head {
   display: flex;
diff --git a/apps/ui/vite.config.ts b/apps/ui/vite.config.ts
index 574b816..4048b1b 100644
--- a/apps/ui/vite.config.ts
+++ b/apps/ui/vite.config.ts
@@ -20,6 +20,19 @@ import { defineConfig } from 'vite';
 import vue from '@vitejs/plugin-vue';
 import vueJsx from '@vitejs/plugin-vue-jsx';
 
+// Dev port for Vite itself. Default 9091; 9090 is commonly claimed by
+// ClashX / proxy tools, and 8080 is reserved for the legacy booster-ui
+// that operators may run side-by-side during migration. Override with
+// UI_DEV_PORT when a developer needs a second parallel env.
+const UI_DEV_PORT = Number(process.env.UI_DEV_PORT ?? 9091);
+
+// Where the BFF listens during dev. The /api proxy below targets this.
+// MUST match the `server.port` resolved by the BFF's HORIZON_CONFIG yaml
+// (the yaml resolves the same env var via ${BFF_PORT:8081}), otherwise
+// the proxy points at the wrong process. Prod is unaffected — there the
+// BFF serves the built UI directly on its single configured port.
+const BFF_PORT = Number(process.env.BFF_PORT ?? 8081);
+
 export default defineConfig({
   plugins: [vue(), vueJsx()],
   resolve: {
@@ -28,16 +41,12 @@ export default defineConfig({
     },
   },
   server: {
-    // 9091: horizon-side. 9090 is commonly claimed by ClashX / proxy
-    // tools that bind to all addresses; 8080 is reserved for the
-    // legacy booster-ui that operators may run side-by-side during
-    // migration.
-    port: 9091,
+    port: UI_DEV_PORT,
     strictPort: true,
     proxy: {
       // proxy to the BFF (`apps/bff`) during dev
       '/api': {
-        target: 'http://127.0.0.1:8081',
+        target: `http://127.0.0.1:${BFF_PORT}`,
         changeOrigin: true,
       },
     },


Reply via email to