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,
},
},