You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/06/21 00:32:42 UTC

(skywalking-horizon-ui) branch main updated: fix(rbac): per-kind template-save permission, surface infra-3d:read, plus docs + UI fixes (#60)

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 213db03  fix(rbac): per-kind template-save permission, surface infra-3d:read, plus docs + UI fixes (#60)
213db03 is described below

commit 213db03b69c9d4c7b435c7572bdda28cc9823d62
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun Jun 21 08:32:36 2026 +0800

    fix(rbac): per-kind template-save permission, surface infra-3d:read, plus docs + UI fixes (#60)
    
    RBAC:
    - Add `infra-3d:read` to the verb catalog so it appears on the Roles board and Auth Status. It was already enforced and granted to every built-in role (viewer and up) but was invisible there.
    - Enforce template save per kind: a layer template now requires `dashboard:write` (the verb the layer-dashboard editor advertises); overview / alert / 3D-map kinds keep `overview:write`. Built-in roles are unaffected (operator and admin hold both).
    - Regroup `ROUTE_POLICY` by the lowest built-in role that can reach each route (viewer ⊂ maintainer ⊂ operator ⊂ admin) and trim comments. Behavior-neutral — every route→verb mapping is unchanged.
    
    UI fixes:
    - Metrics Inspect: render the crosshair tooltip at body level (appendToBody) so it is no longer clipped by the widget overflow or painted under the sidebar.
    - 3D map config: require a saved local draft before "Check diff & push"; give the push diff a definite height (fit-body modal + flex wrapper) so the side-by-side comparison renders instead of an empty panel.
    
    Docs: operator-perspective accuracy sweep — Cluster Status three panes / no per-node member list, container readiness probe → public /api/health, layer-template `components` default + `aliases` authoring key, removal of the dead `visibleWhen` free-text and embedded-i18n shapes, data-retention cold stage, and RBAC wording reconciled to the per-kind gate.
    
    i18n: two new UI strings translated across all 8 locales. CHANGELOG updated under 0.7.0.
---
 CHANGELOG.md                                       |  11 ++
 README.md                                          |   6 +-
 apps/bff/src/http/admin/template-sync.ts           |  13 ++
 apps/bff/src/rbac/route-policy.ts                  | 158 +++++++++------------
 apps/bff/src/rbac/verbs.ts                         |   1 +
 .../features/admin/infra-3d/Infra3dAdminView.vue   |  12 +-
 apps/ui/src/features/admin/roles/RolesView.vue     |   5 +-
 .../src/features/operate/inspect/InspectView.vue   |   2 +
 apps/ui/src/i18n/locales/de.json                   |   2 +
 apps/ui/src/i18n/locales/en.json                   |   2 +
 apps/ui/src/i18n/locales/es.json                   |   2 +
 apps/ui/src/i18n/locales/fr.json                   |   2 +
 apps/ui/src/i18n/locales/ja.json                   |   2 +
 apps/ui/src/i18n/locales/ko.json                   |   2 +
 apps/ui/src/i18n/locales/pt.json                   |   2 +
 apps/ui/src/i18n/locales/zh-CN.json                |   2 +
 docs/access-control/admin-pages.md                 |   7 +-
 docs/access-control/audit-log.md                   |  11 +-
 docs/access-control/rbac.md                        |   5 +-
 docs/compatibility/cluster-status.md               |  37 ++---
 docs/compatibility/oap-version.md                  |   4 +-
 docs/compatibility/ports.md                        |   9 +-
 docs/compatibility/required-modules.md             |   2 +-
 docs/components/dashboard-widgets.md               |  11 +-
 docs/customization/i18n.md                         |  40 ++----
 docs/customization/layer-templates.md              |  16 +--
 docs/operate/cluster-metadata.md                   |  32 ++---
 docs/operate/data-retention.md                     |  13 +-
 docs/operate/infra-3d-map.md                       |   2 +-
 docs/operate/inspect.md                            |  12 +-
 docs/operate/oap-configuration.md                  |   2 +-
 docs/operate/runtime-rules.md                      |   5 +
 docs/setup/container-image.md                      |  12 +-
 docs/setup/oap.md                                  |   2 +-
 docs/setup/overview.md                             |   4 +-
 docs/setup/rbac.md                                 |   6 +-
 docs/setup/session.md                              |   5 +-
 37 files changed, 224 insertions(+), 237 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 04997f2..f95b75b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -121,8 +121,19 @@ The version line is shared by every package in the monorepo (apps + shared packa
 - **Selecting a low-traffic (below-cap) service now works on *every* tab**, not just the dashboard. Logs, traces, and endpoint-dependency resolved the picked service's name from the landing sample only — so a tail service queried as blank (and Logs even snapped the pick back to the top service). All per-layer tabs now resolve the name from the full roster, so a `low` service drills in everywhere.
 - **Profiling scopes no longer show an editor grid that goes nowhere.** Trace / eBPF / async profiling are built-in runtime views with nothing to author, so the admin now shows a "configured at runtime" note for them instead of a widget grid whose widgets never rendered.
 
+### Access control & permissions
+
+- The **Roles & Permissions** board now lists `infra-3d:read` — the permission to view the **3D Infrastructure Map** — under the data-catalog group, with a matching "3D infrastructure map" row in the menu-visibility matrix. It was already enforced and granted to every built-in role (viewer and up), but it never appeared on the board, so an admin couldn't see who held it.
+- Editing a **layer dashboard template** is now gated on the `dashboard:write` permission the editor already advertises; publishing overview, alert, and 3D-map configs stays on `overview:write`. The required permission is resolved per template kind at save time. Built-in roles are unaffected (`operator` and `admin` hold both), but a custom role granted only `dashboard:write` can now save layer dashboards.
+
+### Fixes
+
+- **Metrics Inspect** — the crosshair value tooltip is no longer clipped behind the navigation sidebar when you hover near a widget's left edge; it now renders above the page chrome.
+- **3D Infrastructure Map config** — "Check diff & push" now requires saving a local draft first (it was selectable while edits were still unsaved), and the push dialog renders the side-by-side before/after JSON diff instead of an empty panel.
+
 ### Documentation & release tooling
 
+- A further accuracy pass corrected the Cluster Status page (three panes — Query, Admin, Zipkin/OTLP — and no per-node member list), the Kubernetes readiness-probe guidance (point it at the public `/api/health`, not the authenticated `/api/oap/info`), the layer-template `components` default (only the service dashboard is on when a key is omitted) and the `aliases` authoring key, the removed `visibleWhen` free-text and embedded-i18n template shapes, and the data-retention cold-stage controls.
 - The website docs were brought current with the 0.6.0 build and the configuration pages restructured around the admin UI — the JSON shape is now a reference appendix, not an authoring surface (these admin pages are structured editors, not raw-JSON editors). Accuracy fixes span the RBAC verbs (incl. `infra-3d:read`), the audit-log action set, the Metrics Inspect API paths, the layer-template component flags, and the redesigned 3D-map config + loading stages. A new `docs/CLAUDE.md` record [...]
 - The container image is published to Docker Hub by CI on every `v*` tag; the post-vote finalize script now only verifies the published tags (the manual local-push fallback and Docker Hub login preflight were removed).
 
diff --git a/README.md b/README.md
index 0bfa0a7..9484144 100644
--- a/README.md
+++ b/README.md
@@ -22,16 +22,16 @@ Full docs ship at [skywalking.apache.org → Horizon UI](https://skywalking.apac
 - **Trace explorer** — duration scatter, sortable list, waterfall with per-span service / kind / error highlighting.
 - **Log explorer** — chip filters, saved views, facets sidebar, timeline brush, mixed JSON / YAML / raw payload tree, top patterns roll-up.
 - **Alarms** — active alarms only, read-only (no acknowledge / close — recovery is backend-driven), with a Live Debug panel for MQE expressions.
-- **Admin** — local + LDAP auth, RBAC (4 built-in roles, 28 verbs), audit log, layer admin, system / OAP cluster health, 5 bundled themes (Horizon · Meridian · Obsidian · Daybreak · Aurora).
+- **Admin** — local + LDAP auth, RBAC (4 built-in roles, fine-grained verbs), audit log, layer admin, system / OAP cluster health, 5 bundled themes (Horizon · Meridian · Obsidian · Daybreak · Aurora).
 - **Profiling** — flame graphs over trace-based / async-profiler / eBPF / pprof data.
 
 ## Tech stack
 
-Vue 3 + TypeScript on Vite 5, Pinia, vue-router 4, Apache ECharts 6, D3 v7, vue-grid-layout, d3-flame-graph, Monaco, Vitest. The BFF is Fastify on Node 20+.
+Vue 3 + TypeScript on Vite, Pinia, vue-router 4, Apache ECharts 6, D3 v7, vue-grid-layout, d3-flame-graph, Monaco, Vitest. The BFF is Fastify on Node.js.
 
 ## Development
 
-Requires Node.js 20+ and pnpm 10+ (pinned via Corepack).
+Requires a recent LTS Node.js and pnpm (pinned via Corepack).
 
 ```bash
 pnpm install                     # one-time / after lockfile changes
diff --git a/apps/bff/src/http/admin/template-sync.ts b/apps/bff/src/http/admin/template-sync.ts
index a3c300c..dbec057 100644
--- a/apps/bff/src/http/admin/template-sync.ts
+++ b/apps/bff/src/http/admin/template-sync.ts
@@ -55,6 +55,7 @@ import type { UITemplateClient } from '@skywalking-horizon-ui/api-client';
 import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
+import { sessionHasVerb } from '../../rbac/policy.js';
 import {
   createAndConfirm,
   updateAndConfirm,
@@ -558,6 +559,18 @@ export function registerTemplateSyncAdminRoutes(
         message: `expected horizon.<overview|layer|alert>.<key>, got ${JSON.stringify(name)}`,
       });
     }
+    // Per-kind write authority (route is gated `auth`; the real verb check
+    // is here, before any validation or OAP write). A layer-template save is
+    // gated on `dashboard:write` — the verb the layer-dashboard editor
+    // advertises; every other kind on `overview:write`.
+    const saveVerb = parsed.kind === 'layer' ? 'dashboard:write' : 'overview:write';
+    const session = req.session;
+    if (!session) {
+      return reply.code(401).send({ error: 'unauthenticated' });
+    }
+    if (!sessionHasVerb(deps.config.current, session.roles, saveVerb)) {
+      return reply.code(403).send({ error: 'permission_denied', verb: saveVerb });
+    }
     // Per-kind content validation. The envelope machinery is content-opaque,
     // so the 3D-map config (the one kind with a strict structural schema)
     // is checked here before it can reach OAP — a bad regex / dangling
diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts
index 40c4025..d2fe8ec 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -16,18 +16,12 @@
  */
 
 /**
- * Single source of truth for which RBAC verb gates each route.
- *
- *   - 'public'  → no auth, no verb (login / logout / health).
- *   - 'auth'    → authenticated session required, no verb check.
- *                 Used for routes the shell itself needs to render
- *                 (menu, oap-info, preflight, configs bundle, /me).
- *   - '<verb>'  → authenticated session + verb grant required.
- *
- * The `onRoute` hook in `server.ts` reads this table and attaches the
- * appropriate pre-handler to each registered route. Adding a new route
- * without a policy entry logs a warning at startup and defaults to
- * 'auth' (fail-safe: at least require login, never silently open).
+ * Single source of truth for the RBAC verb gating each route.
+ *   - 'public' → no auth (login / logout / health).
+ *   - 'auth'   → signed-in session, no verb (shell bootstrap routes).
+ *   - '<verb>' → signed-in session + that verb grant.
+ * The `onRoute` hook (server.ts) attaches the matching pre-handler. A
+ * missing `/api/*` entry hard-throws at boot — never silently open.
  */
 
 import type { FastifyReply, FastifyRequest, RouteOptions } from 'fastify';
@@ -54,28 +48,30 @@ export function checkVerb(deps: AuthDeps, verb: string) {
   };
 }
 
-/** Routes keyed by `${METHOD} ${url}` (method uppercase, url as registered with `:param` placeholders). */
+/**
+ * Keyed by `${METHOD} ${url}` (url as registered, with `:param` placeholders).
+ * Grouped by the lowest built-in role that can reach each route:
+ * viewer ⊂ maintainer ⊂ operator ⊂ admin.
+ */
 export const ROUTE_POLICY: Record<string, RoutePolicy> = {
-  // ── Public (no auth) ─────────────────────────────────────────────
+  // ── Public — no auth ──
   'POST /api/auth/login':                          'public',
   'POST /api/auth/logout':                         'public',
   'GET /api/health':                               'public',
   'GET /api/auth/health':                          'public',
 
-  // ── Auth-only (every authenticated user) ─────────────────────────
-  // Anything the shell needs to render before the user lands anywhere.
+  // ── Authenticated — any signed-in user (shell bootstrap) ──
   'GET /api/auth/me':                              'auth',
   'GET /api/oap/info':                             'auth',
   'GET /api/menu':                                 'auth',
   'GET /api/preflight':                            'auth',
   'GET /api/configs/bundle':                       'auth',
 
-  // ── Alarms (read) ────────────────────────────────────────────────
+  // ── Viewer — read-only data catalog ──
   'GET /api/alarms':                               'alarms:read',
   'GET /api/alarms/count':                         'alarms:read',
   'GET /api/alarms/services':                      'alarms:read',
 
-  // ── Traces (read) ────────────────────────────────────────────────
   'POST /api/layer/:key/traces':                   'traces:read',
   'GET /api/trace/:traceId':                       'traces:read',
   'GET /api/trace-tags/keys':                      'traces:read',
@@ -89,7 +85,6 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'GET /api/zipkin/autocomplete/values':           'traces:read',
   'GET /api/zipkin/traceMany':                     'traces:read',
 
-  // ── Logs (read) ──────────────────────────────────────────────────
   'POST /api/layer/:key/logs':                     'logs:read',
   'POST /api/layer/:key/logs/facets':              'logs:read',
   'GET /api/log-tags/keys':                        'logs:read',
@@ -97,23 +92,16 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'GET /api/layer/:key/pod-logs/containers':       'logs:read',
   'POST /api/layer/:key/pod-logs':                 'logs:read',
 
-  // ── Browser errors + source maps (#6784) ─────────────────────────
-  // Listing + resolving rides on the read verb; upload/delete of maps
-  // (which sit in BFF memory) needs the dedicated write verb.
   'POST /api/layer/:key/browser-errors':           'browser-errors:read',
   'GET /api/browser-errors/source-maps':           'browser-errors:read',
   'POST /api/browser-errors/resolve':              'browser-errors:read',
-  'POST /api/browser-errors/source-maps':          'source-map:write',
-  'DELETE /api/browser-errors/source-maps/:id':    'source-map:write',
 
-  // ── Topology (read) ──────────────────────────────────────────────
   'GET /api/layer/:key/topology':                  'topology:read',
   'GET /api/layer/:key/instance-topology':         'topology:read',
   'GET /api/layer/:key/deployment':                'topology:read',
   'GET /api/layer/:key/endpoint-dependency':       'topology:read',
   'GET /api/layer/:key/service-hierarchy':         'topology:read',
 
-  // ── Metrics & layer-level reads ──────────────────────────────────
   'POST /api/layer/:key/dashboard':                'metrics:read',
   'GET /api/layer/:key/dashboard/config':          'metrics:read',
   'POST /api/layer/:key/landing':                  'metrics:read',
@@ -121,66 +109,34 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'GET /api/layer/:key/endpoints':                 'metrics:read',
   'GET /api/layer/:key/services':                  'metrics:read',
 
-  // ── Profiling — agent / async / pprof / eBPF / eBPF network ──────
-  // GETs + analyze are reads; POST <family>/tasks creates a task.
+  // Profiling reads — task-creation is operator (profile:enable) below.
   'GET /api/layer/:key/profile/tasks':             'profile:read',
-  'POST /api/layer/:key/profile/tasks':            'profile:enable',
   'GET /api/profile/tasks/:taskId/segments':       'profile:read',
   'GET /api/profile/tasks/:taskId/logs':           'profile:read',
   'POST /api/profile/analyze':                     'profile:read',
-
   'GET /api/layer/:key/async/tasks':               'profile:read',
-  'POST /api/layer/:key/async/tasks':              'profile:enable',
   'GET /api/async/tasks/:taskId/progress':         'profile:read',
   'POST /api/async/analyze':                       'profile:read',
-
   'GET /api/layer/:key/pprof/tasks':               'profile:read',
-  'POST /api/layer/:key/pprof/tasks':              'profile:enable',
   'GET /api/pprof/tasks/:taskId/progress':         'profile:read',
   'POST /api/pprof/analyze':                       'profile:read',
-
   'GET /api/layer/:key/ebpf/tasks':                'profile:read',
-  'POST /api/layer/:key/ebpf/tasks':               'profile:enable',
   'GET /api/ebpf/tasks/:taskId/schedules':         'profile:read',
   'POST /api/ebpf/analyze':                        'profile:read',
-  // eBPF network sub-family (extra surface beyond the cpu profiler).
   'GET /api/layer/:key/ebpf/network/tasks':        'profile:read',
-  'POST /api/layer/:key/ebpf/network/tasks':       'profile:enable',
   'GET /api/ebpf/network/tasks':                   'profile:read',
-  'POST /api/ebpf/network/tasks':                  'profile:enable',
   'GET /api/ebpf/network/topology':                'profile:read',
   'POST /api/layer/:key/ebpf/network/process-relation-metrics': 'profile:read',
-  'POST /api/ebpf/network/tasks/:taskId/keep-alive': 'profile:enable',
 
-  // ── Config — alarm-page setup, layer setup, overview, dashboards ─
-  'GET /api/alarms/config':                        'alarm-setup:read',
-  'POST /api/alarms/config':                       'alarm-setup:write',
-  'GET /api/infra-3d/config':                      'infra-3d:read',
-  'POST /api/infra-3d/metrics':                    'infra-3d:read',
-  'GET /api/setup':                                'setup:read',
-  'POST /api/setup':                               'setup:write',
   'GET /api/overview/dashboards':                  'overview:read',
   'GET /api/overview/dashboards/:id':              'overview:read',
-  'GET /api/admin/layer-templates':                'dashboard:read',
-  // POST /api/admin/layer-templates/:key removed — updates go through
-  // `/api/admin/templates/save` (OAP-backed). See template-sync.ts.
+  'GET /api/admin/templates/sync-status':          'overview:read',
+  'GET /api/admin/templates/:name/i18n/:locale':   'overview:read',
 
-  // ── DSL / OAL / MQE rules (admin operate) ────────────────────────
-  'GET /api/rule':                                 'rule:read',
-  'GET /api/rule/status':                          'rule:read',
-  'POST /api/rule':                                'rule:write',
-  'POST /api/rule/inactivate':                     'rule:write',
-  'POST /api/rule/delete':                         'rule:delete',
-  'GET /api/dump':                                 'rule:debug',
-  'GET /api/dump/:catalog':                        'rule:debug',
-  'GET /api/oal/files':                            'rule:read',
-  'GET /api/oal/files/:name':                      'rule:read',
-  'GET /api/oal/rules':                            'rule:read',
-  'GET /api/oal/rules/:source':                    'rule:read',
-  'GET /api/catalog/list':                         'rule:read',
-  'GET /api/catalog/bundled':                      'rule:read',
+  'GET /api/infra-3d/config':                      'infra-3d:read',
+  'POST /api/infra-3d/metrics':                    'infra-3d:read',
 
-  // ── Platform monitoring (read) ───────────────────────────────────
+  // ── Maintainer — platform-monitoring reads ──
   'GET /api/cluster/state':                        'cluster:read',
   'GET /api/inspect/metrics':                      'inspect:read',
   'GET /api/inspect/catalog':                      'inspect:read',
@@ -191,40 +147,22 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'GET /api/oap/ttl':                              'ttl:read',
   'GET /api/oap/config':                           'config:read',
 
-  // ── Live debugger (admin operate) ────────────────────────────────
-  'POST /api/debug/session':                       'live-debug:write',
-  'GET /api/debug/session/:id':                    'live-debug:read',
-  'POST /api/debug/session/:id/stop':              'live-debug:write',
-  'GET /api/debug/sessions':                       'live-debug:read',
-  'GET /api/debug/status':                         'live-debug:read',
+  // ── Operator — config / dashboard / rule / diagnostics writes ──
+  'GET /api/setup':                                'setup:read',
+  'POST /api/setup':                               'setup:write',
+  'GET /api/alarms/config':                        'alarm-setup:read',
+  'POST /api/alarms/config':                       'alarm-setup:write',
 
-  // ── Alarm-rule catalog (admin read-only) ─────────────────────────
-  'GET /api/admin/alarm-rules':                    'alarm-rule:read',
-  'GET /api/admin/alarm-rules/:id':                'alarm-rule:read',
-  'GET /api/admin/alarm-rules/:id/context':        'alarm-rule:read',
+  'GET /api/admin/layer-templates':                'dashboard:read',
 
-  // ── Overview-template editor (admin) ─────────────────────────────
-  // The admin editor is an operate-only surface — even reading the
-  // template catalog here needs `overview:write` (operator / admin).
-  // Plain viewers/maintainers consume rendered overviews via
-  // `GET /api/overview/dashboards`, which stays `overview:read`.
+  // Overview-template editor + OAP UI-template sync. The editor catalog needs
+  // write even to read it; rendered overviews stay 'overview:read' above.
   'GET /api/admin/overview-templates':             'overview:write',
   'GET /api/admin/overview-templates/:id':         'overview:write',
   'POST /api/admin/overview-templates':            'overview:write',
-  // POST /api/admin/overview-templates/:id removed — operator updates
-  // now go through `/api/admin/templates/save` (OAP-backed). Bundled
-  // JSON is immutable at runtime.
   'DELETE /api/admin/overview-templates/:id':      'overview:write',
-
-  // ── Template sync (admin) — OAP UI-template REST overlay ─────────
-  // Read = anyone with overview:read can see status. Write actions
-  // (push-bundled, save, resync) need overview:write because save is
-  // the only path that mutates OAP UI-templates.
-  'GET /api/admin/templates/sync-status':          'overview:read',
-  'GET /api/admin/templates/:name/i18n/:locale':   'overview:read',
   'POST /api/admin/templates/resync':              'overview:write',
   'POST /api/admin/templates/resolve-conflicts':   'overview:write',
-  'POST /api/admin/templates/save':                'overview:write',
   'POST /api/admin/templates/save-translation':    'overview:write',
   'POST /api/admin/templates/delete-translation':  'overview:write',
   'POST /api/admin/templates/save-local':          'overview:write',
@@ -232,8 +170,46 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'POST /api/admin/templates/revert-local':        'overview:write',
   'POST /api/admin/templates/:name/push-bundled':  'overview:write',
   'POST /api/admin/templates/sync-all':            'overview:write',
+  // `save` is gated 'auth'; the real per-kind verb (layer → dashboard:write,
+  // else → overview:write) is enforced in the handler before any OAP write.
+  'POST /api/admin/templates/save':                'auth',
+
+  'GET /api/rule':                                 'rule:read',
+  'GET /api/rule/status':                          'rule:read',
+  'GET /api/oal/files':                            'rule:read',
+  'GET /api/oal/files/:name':                      'rule:read',
+  'GET /api/oal/rules':                            'rule:read',
+  'GET /api/oal/rules/:source':                    'rule:read',
+  'GET /api/catalog/list':                         'rule:read',
+  'GET /api/catalog/bundled':                      'rule:read',
+  'POST /api/rule':                                'rule:write',
+  'POST /api/rule/inactivate':                     'rule:write',
+  'POST /api/rule/delete':                         'rule:delete',
+  'GET /api/dump':                                 'rule:debug',
+  'GET /api/dump/:catalog':                        'rule:debug',
+
+  'GET /api/debug/session/:id':                    'live-debug:read',
+  'GET /api/debug/sessions':                       'live-debug:read',
+  'GET /api/debug/status':                         'live-debug:read',
+  'POST /api/debug/session':                       'live-debug:write',
+  'POST /api/debug/session/:id/stop':              'live-debug:write',
+
+  'GET /api/admin/alarm-rules':                    'alarm-rule:read',
+  'GET /api/admin/alarm-rules/:id':                'alarm-rule:read',
+  'GET /api/admin/alarm-rules/:id/context':        'alarm-rule:read',
+
+  'POST /api/layer/:key/profile/tasks':            'profile:enable',
+  'POST /api/layer/:key/async/tasks':              'profile:enable',
+  'POST /api/layer/:key/pprof/tasks':              'profile:enable',
+  'POST /api/layer/:key/ebpf/tasks':               'profile:enable',
+  'POST /api/layer/:key/ebpf/network/tasks':       'profile:enable',
+  'POST /api/ebpf/network/tasks':                  'profile:enable',
+  'POST /api/ebpf/network/tasks/:taskId/keep-alive': 'profile:enable',
+
+  'POST /api/browser-errors/source-maps':          'source-map:write',
+  'DELETE /api/browser-errors/source-maps/:id':    'source-map:write',
 
-  // ── Auth/admin self-introspection ────────────────────────────────
+  // ── Admin — auth + user administration ──
   'GET /api/admin/auth-status':                    'auth:read',
   'POST /api/admin/auth-status/probe':             'auth:read',
   'GET /api/admin/users':                          'user:read',
diff --git a/apps/bff/src/rbac/verbs.ts b/apps/bff/src/rbac/verbs.ts
index 6692401..43ce4a8 100644
--- a/apps/bff/src/rbac/verbs.ts
+++ b/apps/bff/src/rbac/verbs.ts
@@ -31,6 +31,7 @@ export const VERBS = {
   browserErrorsRead: 'browser-errors:read',
   topologyRead: 'topology:read',
   profileRead: 'profile:read',
+  infra3dRead: 'infra-3d:read',
 
   // Operate writes
   ruleRead: 'rule:read',
diff --git a/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue b/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue
index c82fce2..7022448 100644
--- a/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue
+++ b/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue
@@ -655,7 +655,7 @@ const stats = computed(() => {
         <button class="btn" :disabled="!dirty" @click="save">
           {{ hasLocalDraft && !dirty ? t('Saved local') : t('Save local') }}
         </button>
-        <button class="btn primary" :disabled="readOnly || (!dirty && !hasLocalDraft)" @click="openPushDiff">
+        <button class="btn primary" :disabled="readOnly || dirty || !hasLocalDraft" @click="openPushDiff">
           {{ t('Check diff & push') }}
         </button>
       </div>
@@ -879,7 +879,7 @@ const stats = computed(() => {
     </template>
 
     <!-- ── Check diff & push ──────────────────────────────────────────── -->
-    <Modal :open="pushDiffOpen" :title="t('Push 3D-map config to OAP')" width="min(1100px, 94vw)" @close="pushDiffOpen = false">
+    <Modal :open="pushDiffOpen" :title="t('Push 3D-map config to OAP')" width="min(1100px, 94vw)" fit-body @close="pushDiffOpen = false">
       <div class="push-modal">
         <p class="push-lede">
           {{ t('Left = live on OAP (remote). Right = your local draft. Pushing replaces the OAP copy — the map renders it on the next visit.') }}
@@ -887,7 +887,9 @@ const stats = computed(() => {
         <ul v-if="pushErr && pushErr.length" class="issues">
           <li v-for="(it, i) in pushErr" :key="i"><code>{{ it }}</code></li>
         </ul>
-        <MonacoDiff :original="pushRemotePretty" :modified="pushLocalPretty" language="json" />
+        <div class="push-diff">
+          <MonacoDiff :original="pushRemotePretty" :modified="pushLocalPretty" language="json" />
+        </div>
       </div>
       <template #footer>
         <button class="btn" :disabled="pushing" @click="pushDiffOpen = false">{{ t('Cancel') }}</button>
@@ -987,7 +989,9 @@ export { parseHexColor };
 .reset-suffix { color: var(--sw-fg-3); font-size: 10px; }
 
 /* Push diff modal */
-.push-modal { display: flex; flex-direction: column; gap: 10px; min-width: min(1040px, 90vw); }
+.push-modal { display: flex; flex-direction: column; gap: 10px; min-width: min(1040px, 90vw); flex: 1; min-height: 0; }
+/* Definite height for the Monaco diff to fill (else it collapses). */
+.push-diff { flex: 1; min-height: 0; }
 .push-lede { font-size: 11.5px; color: var(--sw-fg-2); margin: 0; }
 
 /* Buttons */
diff --git a/apps/ui/src/features/admin/roles/RolesView.vue b/apps/ui/src/features/admin/roles/RolesView.vue
index c82c7fe..d1d6bb0 100644
--- a/apps/ui/src/features/admin/roles/RolesView.vue
+++ b/apps/ui/src/features/admin/roles/RolesView.vue
@@ -73,6 +73,7 @@ const MENU_GATES = computed<ReadonlyArray<{ label: string; verb: string | null }
   { label: t('Layers (per-layer data)'), verb: null },
   { label: t('Alarms'), verb: 'alarms:read' },
   { label: t('Overviews'), verb: 'overview:read' },
+  { label: t('3D infrastructure map'), verb: 'infra-3d:read' },
   { label: t('Cluster status'), verb: 'cluster:read' },
   { label: t('Platform monitoring (layers)'), verb: 'cluster:read' },
   { label: t('Metrics inspect'), verb: 'inspect:read' },
@@ -127,6 +128,7 @@ const VERB_LABELS = computed<Record<string, { label: string; hint?: string }>>((
   'logs:read':              { label: t('See logs') },
   'topology:read':          { label: t('See service & endpoint topology') },
   'profile:read':           { label: t('See profiling results') },
+  'infra-3d:read':          { label: t('See the 3D infrastructure map') },
   'cluster:read':           { label: t('See OAP cluster status') },
   'inspect:read':           { label: t('Inspect OAP internals (catalog, MQE)') },
   'overview:read':          { label: t('See overview templates') },
@@ -182,9 +184,10 @@ const VERB_GROUPS = computed<VerbGroup[]>(() => [
       { label: t('Logs'), icon: '≡' },
       { label: t('Topology'), icon: '◌' },
       { label: t('Profiling results'), icon: '▦' },
+      { label: t('3D infrastructure map'), icon: '⬡' },
       { label: t('Alarms'), icon: '!' },
     ],
-    verbs: ['metrics:read', 'alarms:read', 'traces:read', 'logs:read', 'topology:read', 'profile:read'],
+    verbs: ['metrics:read', 'alarms:read', 'traces:read', 'logs:read', 'topology:read', 'profile:read', 'infra-3d:read'],
   },
   {
     title: t('Platform monitoring'),
diff --git a/apps/ui/src/features/operate/inspect/InspectView.vue b/apps/ui/src/features/operate/inspect/InspectView.vue
index 434cbd8..1e9672d 100644
--- a/apps/ui/src/features/operate/inspect/InspectView.vue
+++ b/apps/ui/src/features/operate/inspect/InspectView.vue
@@ -1053,6 +1053,8 @@ function buildOption(w: Widget): echarts.EChartsOption {
     grid: { left: 32, right: 6, top: showLegend ? 30 : 6, bottom: 18 },
     tooltip: {
       trigger: 'axis',
+      // Body-level so it isn't clipped by the card overflow or the sidebar.
+      appendToBody: true,
       backgroundColor: '#1c2630',
       borderWidth: 0,
       textStyle: { color: '#e6edf3', fontSize: 10.5, fontFamily: mono },
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index ef3c8aa..8397df8 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1292,6 +1292,8 @@
   "Select a container, then press Start to tail its logs.": "Wähle einen Container und drücke Start, um seine Logs live zu verfolgen.",
   "Dashboard setup · 3D Infra Map": "Dashboard-Einrichtung · 3D Infra Map",
   "3D Infrastructure Map": "3D-Infrastrukturkarte",
+  "3D infrastructure map": "3D-Infrastrukturkarte",
+  "See the 3D infrastructure map": "Die 3D-Infrastrukturkarte ansehen",
   "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback.": "Konfiguration für die Ansicht {mapPath}, auf OAP veröffentlicht. Ebenen steuern die vertikale Stapelung; Farbe und Metriken pro Ebene bestimmen jeden Würfel. Änderungen werden in einem {localDraft} gespeichert; {checkDiffPush} verö [...]
   "local draft in this browser": "lokaler Entwurf in diesem Browser",
   "editing: {source}": "Bearbeitung: {source}",
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 8e3cc70..e4b5e8f 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1273,6 +1273,8 @@
   "Select a container, then press Start to tail its logs.": "Select a container, then press Start to tail its logs.",
   "Dashboard setup · 3D Infra Map": "Dashboard setup · 3D Infra Map",
   "3D Infrastructure Map": "3D Infrastructure Map",
+  "3D infrastructure map": "3D infrastructure map",
+  "See the 3D infrastructure map": "See the 3D infrastructure map",
   "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback.": "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundle [...]
   "local draft in this browser": "local draft in this browser",
   "editing: {source}": "editing: {source}",
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 547f466..6cca984 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1292,6 +1292,8 @@
   "Select a container, then press Start to tail its logs.": "Selecciona un contenedor y pulsa Inicio para seguir sus logs en vivo.",
   "Dashboard setup · 3D Infra Map": "Configuración de paneles · 3D Infra Map",
   "3D Infrastructure Map": "3D Infrastructure Map",
+  "3D infrastructure map": "Mapa de infraestructura 3D",
+  "See the 3D infrastructure map": "Ver el mapa de infraestructura 3D",
   "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback.": "Configuración de la vista {mapPath}, publicada en OAP. Los niveles controlan el apilado vertical; el color y las métricas de cada capa definen cada cubo. Las ediciones se guardan en un {localDraft}; {checkDiffPush} publica en OAP — [...]
   "local draft in this browser": "borrador local en este navegador",
   "editing: {source}": "editando: {source}",
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 99ed545..c41443f 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1292,6 +1292,8 @@
   "Select a container, then press Start to tail its logs.": "Sélectionnez un conteneur, puis appuyez sur Début pour suivre ses logs en direct.",
   "Dashboard setup · 3D Infra Map": "Configuration du tableau de bord · 3D Infra Map",
   "3D Infrastructure Map": "Carte d'infrastructure 3D",
+  "3D infrastructure map": "Carte d'infrastructure 3D",
+  "See the 3D infrastructure map": "Voir la carte d'infrastructure 3D",
   "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback.": "Configuration de la vue {mapPath}, publiée sur OAP. Les niveaux contrôlent l'empilement vertical ; la couleur et les métriques propres à chaque couche pilotent chaque cube. Les modifications sont enregistrées dans un {localDraft} ; [...]
   "local draft in this browser": "brouillon local dans ce navigateur",
   "editing: {source}": "édition : {source}",
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 067f5cf..1667004 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1292,6 +1292,8 @@
   "Select a container, then press Start to tail its logs.": "コンテナを選択し、「開始」を押してログをライブで追尾します。",
   "Dashboard setup · 3D Infra Map": "ダッシュボード設定 · 3D Infra Map",
   "3D Infrastructure Map": "3D インフラマップ",
+  "3D infrastructure map": "3D インフラマップ",
+  "See the 3D infrastructure map": "3D インフラマップを確認",
   "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback.": "{mapPath} ビューの設定で、OAP に公開されます。レベルは垂直方向の積み重ねを制御し、レイヤーごとの色とメトリックが各キューブを表現します。編集内容は {localDraft} に保存され、{checkDiffPush} で OAP に公開されます。マップはリモート版を表示し、バンドルのデフォルトがフォールバックとなります。",
   "local draft in this browser": "このブラウザ内のローカル草稿",
   "editing: {source}": "編集中: {source}",
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index fb6a072..9b29aee 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1292,6 +1292,8 @@
   "Select a container, then press Start to tail its logs.": "컨테이너를 선택한 다음 시작을 눌러 로그를 실시간으로 확인하세요.",
   "Dashboard setup · 3D Infra Map": "대시보드 설정 · 3D Infra Map",
   "3D Infrastructure Map": "3D 인프라 맵",
+  "3D infrastructure map": "3D 인프라 맵",
+  "See the 3D infrastructure map": "3D 인프라 맵 보기",
   "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback.": "OAP에 게시되는 {mapPath} 화면의 설정입니다. 레벨은 수직 적층 순서를 정하고, 각 큐브는 레이어별 색상과 메트릭으로 표현됩니다. 편집 내용은 {localDraft}에 저장되며, {checkDiffPush}로 OAP에 게시합니다 — 맵은 원격 버전을 렌더링하고 번들 기본값을 폴백으로 사용합니다.",
   "local draft in this browser": "이 브라우저의 로컬 초안",
   "editing: {source}": "편집 중: {source}",
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index a1bd3d6..3856c61 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1292,6 +1292,8 @@
   "Select a container, then press Start to tail its logs.": "Selecione um contêiner e pressione Início para acompanhar seus logs ao vivo.",
   "Dashboard setup · 3D Infra Map": "Configuração de painéis · 3D Infra Map",
   "3D Infrastructure Map": "3D Infrastructure Map",
+  "3D infrastructure map": "Mapa de infraestrutura 3D",
+  "See the 3D infrastructure map": "Ver o mapa de infraestrutura 3D",
   "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback.": "Configuração da visão {mapPath}, publicada no OAP. Os níveis controlam o empilhamento vertical; a cor e as métricas de cada camada definem cada cubo. As edições são salvas em um {localDraft}; {checkDiffPush} publica no OAP — o mapa [...]
   "local draft in this browser": "rascunho local neste navegador",
   "editing: {source}": "editando: {source}",
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index 2412473..2dfe257 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1292,6 +1292,8 @@
   "Select a container, then press Start to tail its logs.": "选择一个容器,然后点击“开始”以实时查看其日志。",
   "Dashboard setup · 3D Infra Map": "仪表板设置 · 3D Infra Map",
   "3D Infrastructure Map": "3D 基础设施地图",
+  "3D infrastructure map": "3D 基础设施地图",
+  "See the 3D infrastructure map": "查看 3D 基础设施地图",
   "Config for the {mapPath} view, published to OAP. Levels control the vertical stack; per-layer color + metrics drive each cube. Edits save to a {localDraft}; {checkDiffPush} publishes to OAP — the map renders the remote, with bundled defaults as fallback.": "{mapPath} 视图的配置,发布到 OAP。层级(level)控制垂直堆叠;每个 layer 的颜色与指标决定对应方块的呈现。修改会保存到{localDraft};{checkDiffPush}将其发布到 OAP —— 地图渲染的是远端版本,内置默认值作为兜底。",
   "local draft in this browser": "当前浏览器中的本地草稿",
   "editing: {source}": "正在编辑:{source}",
diff --git a/docs/access-control/admin-pages.md b/docs/access-control/admin-pages.md
index b8b1b41..3365e9b 100644
--- a/docs/access-control/admin-pages.md
+++ b/docs/access-control/admin-pages.md
@@ -92,10 +92,9 @@ Renders a read-only board of roles × verbs as a check-mark grid. The intent is
 
 Layout:
 
-- **Left rail**: verb areas (`metrics`, `alarms`, `rule`, etc.).
-- **Main grid**: rows = areas, columns = roles, cells = check marks per verb in that area.
-- **Header strip**: built-in roles flagged distinctly from custom roles.
-- **Footer**: legend explaining the four grant patterns (`*`, exact, `area:*`, `*:read`).
+- **Menu visibility matrix**: which sidebar destinations each role can see — sidebar item × role.
+- **Per-area cards**: one card per feature area (Data catalog, Platform monitoring, Layer dashboards, …). Each pairs a **scope** list (the screens that area governs) with an **Actions** grid of roles × capabilities, check-marked where the role holds the verb.
+- Any verb not yet placed in an area falls into an **Other** card, so nothing is silently dropped.
 
 A red banner appears when `rbac.enabled: false` — the page warns that every authenticated session is granted `*`.
 
diff --git a/docs/access-control/audit-log.md b/docs/access-control/audit-log.md
index 57008a2..1275cef 100644
--- a/docs/access-control/audit-log.md
+++ b/docs/access-control/audit-log.md
@@ -12,7 +12,7 @@ Each event has these fields:
 | `actor` | Username, or `null` for system events. |
 | `action` | Operation name, such as `auth.login` or `addOrUpdate`. |
 | `verb` | RBAC verb checked, when applicable. |
-| `target` | Resource id or name, when applicable. |
+| `target` | Reserved — currently unused. DSL mutations carry the rule name in `details.name` and the catalog in `details.catalog` instead. |
 | `outcome` | `success` / `ok` / `break-glass` for normal flows; the OAP `applyStatus` or `http_<code>` on an upstream failure. |
 | `details` | Extra context for the operation. |
 | `fromIp` | Requester IP. |
@@ -33,11 +33,11 @@ The recorded set can grow between releases. The current set:
 | `auth.login` | `success`, `failure` | Standard login. `details.backend` is `local` or `ldap`. On success `details.roles` carries the resolved role list. |
 | `auth.login.break-glass` | `break-glass` | Emergency admin login. Logged in addition to a WARN application log line. |
 | `auth.logout` | `success` | Explicit logout (cookie cleared). Sessions that simply expire are not logged. |
-| `addOrUpdate` | OAP `applyStatus`, or `http_<code>` on failure | DSL Management — create / update a rule. `target` is the rule name; `details.catalog` is the rule catalog (alarm, MAL, OAL, …). |
+| `addOrUpdate` | OAP `applyStatus`, or `http_<code>` on failure | DSL Management — create / update a rule. `details.name` is the rule name; `details.catalog` is the rule catalog (alarm, MAL, OAL, …). |
 | `inactivate` | OAP `applyStatus`, or `http_<code>` | DSL Management — deactivate a rule. |
 | `delete` | OAP `applyStatus`, or `http_<code>` | DSL Management — delete a rule. `details.mode` is the delete mode. |
-| `alarms.config.save` | `ok`, or an error status on failure | Alarm page setup save. |
-| `setup.save` | `success`, or an error status on failure | Per-user setup state write (service / instance / endpoint setup). |
+| `alarms.config.save` | `ok` | Alarm page setup save. Recorded on success only — an invalid payload is rejected before any audit entry. |
+| `setup.save` | `success` | Per-user setup state write (service / instance / endpoint setup). Recorded on success only — an invalid payload is rejected before any audit entry. |
 | `debug.start` | `ok`, or an error status on failure | Live Debugger — start a session. |
 | `debug.stop` | `ok`, or an error status on failure | Live Debugger — stop a session. |
 
@@ -96,9 +96,8 @@ The recorded set can grow between releases. The current set:
   "actor": "alice",
   "action": "addOrUpdate",
   "verb": "rule:write",
-  "target": "service_resp_time_rule",
   "outcome": "success",
-  "details": { "catalog": "alarm" },
+  "details": { "name": "service_resp_time_rule", "catalog": "alarm" },
   "fromIp": "10.0.5.12",
   "sessionId": "k7r..."
 }
diff --git a/docs/access-control/rbac.md b/docs/access-control/rbac.md
index 79eca5e..3056c26 100644
--- a/docs/access-control/rbac.md
+++ b/docs/access-control/rbac.md
@@ -36,7 +36,8 @@ Known verbs are grouped into areas:
 | `overview:write` | Overview templates (`/admin/overview-templates`) and the 3D-map config (`/admin/3d-map`): edit / publish. |
 | `dashboard:read` / `dashboard:write` | Layer dashboard templates admin page: list / edit. |
 | `alarm-setup:read` / `alarm-setup:write` | Alarm Setup page: list / edit. |
-| `alarm-rule:read` / `alarm-rule:write` | Alarm Rule catalog: list / edit. |
+| `alarm-rule:read` | Alarm Rule catalog: list (read-only — alarm-rule edits go through the OAP alarm-rule YAML, not this page). |
+| `alarm-rule:write` | Reserved (the catalog is read-only; no write endpoint). |
 | `setup:read` / `setup:write` | Service / instance / endpoint setup pages. |
 | `rule:read` | DSL Management — list rules. |
 | `rule:write` | DSL Management — content edits (non-structural). |
@@ -96,7 +97,7 @@ Default definitions (used when `rbac.roles` is not overridden):
 Read-only data catalog. Deliberately limited — does not include `*:read` so a viewer cannot peek at rule definitions, live-debug sessions, setup screens, or platform internals.
 
 ```
-metrics:read, alarms:read, traces:read, logs:read, browser-errors:read, topology:read, profile:read, overview:read
+metrics:read, alarms:read, traces:read, logs:read, browser-errors:read, topology:read, profile:read, overview:read, infra-3d:read
 ```
 
 ### `maintainer`
diff --git a/docs/compatibility/cluster-status.md b/docs/compatibility/cluster-status.md
index f54a8af..eda8435 100644
--- a/docs/compatibility/cluster-status.md
+++ b/docs/compatibility/cluster-status.md
@@ -1,8 +1,8 @@
 # Cluster Status Check Sequence
 
-The Cluster Status page (`/operate/cluster`, sidebar **Operate → Cluster**) is the operator's single pane for "is the OAP backend healthy and configured correctly?" It runs **two independent checks in parallel** against the two OAP ports — they do not block each other, and the page surfaces each pane's result independently.
+The Cluster Status page (`/operate/cluster`, sidebar **Operate → Cluster**) is the operator's single pane for "is the OAP backend healthy and configured correctly?" It runs **three independent checks in parallel** — the Query and Admin OAP ports plus a Zipkin/OTLP trace-source probe — they do not block each other, and the page surfaces each pane's result independently.
 
-This page is intentionally two-pane: a healthy `:12800` with broken `:17128` is a real and recoverable state (forgot to expose the admin port behind a Kubernetes Service), and Horizon makes that diagnosis obvious.
+The panes are independent: a healthy `:12800` with broken `:17128` is a real and recoverable state (forgot to expose the admin port behind a Kubernetes Service), and Horizon makes that diagnosis obvious. The Zipkin/OTLP pane is informational for the trace menu — a red dot there is not a cluster-wide outage.
 
 ## Pane A — Query / GraphQL port (`:12800`)
 
@@ -45,24 +45,16 @@ query {
 GET <adminUrl>/debugging/config/dump
 ```
 
-OAP returns a flat key/value map. The BFF parses it and reports, per required module, whether **any** key with that module's prefix appears.
+OAP returns a flat key/value map. Each required module is reported as enabled when **any** key with that module's prefix appears in the dump.
 
-### Check sequence (per refresh)
+### What you see (per refresh)
 
-The checks run in this strict order — earlier failures short-circuit later ones:
+If the admin host is unreachable, every module shows off and the whole pane goes red. If it is reachable, each module shows enabled or disabled independently based on whether its keys appear in the config dump:
 
-1. **Admin host reachable?** TCP / HTTP connect succeeds.
-   - Fail → `adminReachable: false`, every module reports `enabled: false`. Whole pane red.
-2. **`admin-server` module loaded?** Any `admin-server.*` key in the dump.
-   - Fail → admin host responded but does not expose the admin selector. (Should not happen — the dump endpoint is itself served by admin-server. In practice this case means a custom OAP build.)
-3. **`receiver-runtime-rule` loaded?** Any `receiver-runtime-rule.*` key.
-   - Fail → DSL Management, alarm rules, cluster rule matrix disabled. Yellow badge.
-4. **`dsl-debugging` loaded?** Any `dsl-debugging.*` key.
-   - Fail → Live Debugger disabled. Yellow badge.
-5. **`inspect` loaded?** Any `inspect.*` key.
-   - Fail → Inspect page disabled. Yellow badge.
-
-The sequence is fail-fast: once `admin-server` itself is off, the dump is empty so steps 3–5 all report off. The UI does not stack three separate warnings — it shows one root cause.
+- **`admin-server`** — when off, the admin host responded but does not expose the admin selector. The dump endpoint is itself served by admin-server, so in practice this means a custom OAP build. When admin-server is off the dump is empty, so the other three modules all show off as well — one root cause, not three stacked warnings.
+- **`receiver-runtime-rule`** — when off, DSL Management, alarm rules, and the cluster rule matrix are disabled. Yellow badge.
+- **`dsl-debugging`** — when off, the Live Debugger is disabled. Yellow badge.
+- **`inspect`** — when off, the Inspect page is disabled. Yellow badge.
 
 ### What the pane shows
 
@@ -78,15 +70,9 @@ The sequence is fail-fast: once `admin-server` itself is off, the dump is empty
 - Stale-time: 30 s
 - Refetch interval: 60 s
 
-## Per-node cluster discovery (`/status/cluster/nodes`)
-
-In addition to the two health panes, the page lists OAP cluster members:
-
-- **Source**: `GET <queryUrl>/status/cluster/nodes`.
-- **Returns**: per-node host, port, role, heartbeat.
-- **Use**: confirm cluster size matches expectations (e.g., 3-node OAP behind one DNS name should show three rows).
+## Pane C — Zipkin / OTLP traces
 
-Cluster discovery is **not** required for Horizon to function — it is purely informational. If `/status/cluster/nodes` fails, the cluster pane shows "unknown" but the rest of the UI keeps working.
+A third pane probes OAP's Zipkin v2 REST endpoint and reports reachability. It feeds only the Zipkin/OTLP trace menu — a red dot here is **not** a cluster-wide outage; the rest of the UI keeps working and only Zipkin/OTLP trace views are affected.
 
 ## Reading the page during an incident
 
@@ -95,5 +81,4 @@ The triage flow during "Horizon shows banners I don't understand":
 1. **Is the Query pane green?** If not, OAP itself is down / unreachable — fix OAP first, the rest is downstream.
 2. **Is the Admin pane green?** If not, expose port 17128 and / or turn on the four selectors — see the per-module hints.
 3. **Is the health score `> 0`?** OAP is up but degraded — pull `details` from `checkHealth` (visible in the Query pane) and triage on the OAP side.
-4. **Cluster member count off?** Either DNS / Service config is wrong, or one OAP node is down — check `/status/cluster/nodes` output and your OAP cluster controller.
 
diff --git a/docs/compatibility/oap-version.md b/docs/compatibility/oap-version.md
index 05ceaf5..59c6a5c 100644
--- a/docs/compatibility/oap-version.md
+++ b/docs/compatibility/oap-version.md
@@ -2,7 +2,7 @@
 
 ## Native: OAP 11.x; partial support: OAP 10.x
 
-Horizon UI is **built natively against Apache SkyWalking OAP 11.x** — the full feature set assumes v11. **OAP 10.x is partially supported**: the data-plane stack (dashboards, traces, logs, topology, alarms, profiling) renders correctly because it only touches the query GraphQL port. Everything that lives on OAP's **admin port** — Inspect, DSL Management, Live Debugger, Alarm Rule editor, Cluster Status → Admin pane, OAP UI-template sync — depends on modules (`admin-server`, `receiver-run [...]
+Horizon UI is **built natively against Apache SkyWalking OAP 11.x** — the full feature set assumes the modules and GraphQL fields that v11 ships. **OAP 10.x is partially supported**: the data-plane stack (dashboards, traces, logs, topology, alarms, profiling) renders correctly because it only touches the query GraphQL port. Everything that lives on OAP's **admin port** — Inspect, DSL Management, Live Debugger, Alarm Rule editor, Cluster Status → Admin pane, OAP UI-template sync — depends [...]
 
 Older 9.x OAPs are not supported — the layer concept, the MQE language baseline Horizon assumes, and the admin port layout all settled later.
 
@@ -24,7 +24,7 @@ Older 9.x OAPs are not supported — the layer concept, the MQE language baselin
 ### What "partial support on v10" means in practice
 
 - **Data-plane pages just work.** Dashboards, overviews, alarms (read), traces, logs, topology, and profiling all render on v10. The GraphQL query port (default `:12800`) is what they use, and the protocol is stable across both lines.
-- **Admin port is dark on v10.** The entire admin port (default `:17128`) is gone — `admin-server`, `receiver-runtime-rule`, `dsl-debugging`, and `inspect` are v11-only. Anything that depends on them (Inspect, DSL Management, Live Debugger, Alarm Rule editor, Cluster Status → Admin pane, OAP UI-template sync) is unavailable. The sidebar entries are hidden so operators don't see broken pages.
+- **Admin port is dark on v10.** The entire admin port (default `:17128`) is gone — `admin-server`, `receiver-runtime-rule`, `dsl-debugging`, and `inspect` are not run by a v10 OAP. Horizon detects this by their absence from the config dump, not by reading the version number; when those modules don't report, anything that depends on them (Inspect, DSL Management, Live Debugger, Alarm Rule editor, Cluster Status → Admin pane, OAP UI-template sync) is unavailable and the corresponding side [...]
 - **MQE target resolution** falls back to OAP's `core.restHost`/`core.restPort` instead of the v11 `sharing-server.default.restPort` default. Works fine, just a different code path.
 - **Admin template editing** is read-only — the dashboard / overview / alert admin pages render bundled JSON and show the OAP-unreachable banner. Saves are blocked. Display still works.
 
diff --git a/docs/compatibility/ports.md b/docs/compatibility/ports.md
index 48221d4..6707bb5 100644
--- a/docs/compatibility/ports.md
+++ b/docs/compatibility/ports.md
@@ -4,9 +4,9 @@ Horizon talks to OAP on three ports. Two are required; one is only used if you s
 
 | Port | Protocol | OAP module | Horizon usage | Required |
 |---|---|---|---|---|
-| **12800** | HTTP / GraphQL | `query-graphql`, `sharing-server` | All metric, alarm, trace, log, topology, profiling reads. Cluster Status → Query pane. Menu / layer enumeration. MQE execution. `/status/cluster/nodes`. | **Yes.** |
+| **12800** | HTTP / GraphQL | `query-graphql`, `sharing-server` | All metric, alarm, trace, log, topology, profiling reads. Cluster Status → Query pane. Menu / layer enumeration. MQE execution. | **Yes.** |
 | **17128** | HTTP / REST | `admin-server` and its three sub-selectors | Runtime rule list / create / update / delete. DSL debugging. Inspect API. Config dump for module-activity probe. | **Yes** (for Cluster, Inspect, DSL Management, Live Debugger pages). |
-| **9412** | HTTP / Zipkin v2 REST | `query-zipkin` | Trace export endpoint when a layer is configured with `traces.source: zipkin` or `both`. | Only if using Zipkin trace source. |
+| **9412** | HTTP / Zipkin v2 REST | `query-zipkin` | Trace export endpoint when a layer is configured with `traces.source: zipkin` or `both`. Always probed for the Cluster Status → Zipkin/OTLP pane. | Functionally only when a layer's trace source is `zipkin` or `both`. |
 
 ## `horizon.yaml` configuration
 
@@ -88,8 +88,9 @@ For production deployment behind a TLS terminator:
 
 | Endpoint | Returns | Use case |
 |---|---|---|
-| `GET /api/oap/info` | OAP version, server timezone, current timestamp, health score, reachable bool | Topbar status chip; Cluster Status → Query pane. |
+| `GET /api/health` | `{ status: "ok", version }` — no auth, no OAP dependency | **Recommended** container liveness / readiness probe. |
+| `GET /api/oap/info` | OAP version, server timezone, current timestamp, health score, reachable bool | Authenticated, in-app reachability indicator (topbar status chip; Cluster Status → Query pane). Not a probe target. |
 | `GET /api/preflight` | Per-module enabled / disabled state from the OAP config dump | Cluster Status → Admin pane; sidebar "Operate" section visibility. |
 | `GET /api/auth/health` | Auth backend state (local / LDAP reachable / unreachable) | Login page chip; admin Auth Status page. |
 
-The first two together cover the OAP-side liveness; the third covers the auth-side liveness. Wire your container readiness probe to `GET /api/oap/info` if you want to gate ingress on OAP reachability, or skip readiness gating and let the UI surface the banner instead.
+Wire your container liveness and readiness probes to the public `GET /api/health` — it needs no auth and does not depend on OAP. Use `GET /api/auth/health` if you also want auth-backend health folded in. `GET /api/oap/info` is **auth-gated**, so an unauthenticated probe pointed at it always returns 401 and the pod never becomes Ready — use it only as an in-app reachability indicator, not a probe.
diff --git a/docs/compatibility/required-modules.md b/docs/compatibility/required-modules.md
index 8d625d3..c02c39f 100644
--- a/docs/compatibility/required-modules.md
+++ b/docs/compatibility/required-modules.md
@@ -11,7 +11,7 @@ The admin-port endpoints are gated by per-module selectors on the OAP side. Hori
 
 | Module | OAP env-var | Min OAP | Endpoints Horizon hits | What breaks if disabled |
 |---|---|---|---|---|
-| **admin-server** | `SW_ADMIN_SERVER=default` | 11.x | `GET /debugging/config/dump`, `/ui-management/templates*` | Everything on the admin port. With admin-server off, all other admin-port selectors report `enabled: false` regardless of their actual state. The template-sync admin pages fall back to bundled read-only. |
+| **admin-server** | `SW_ADMIN_SERVER=default` | 11.x | `GET /debugging/config/dump`, `/ui-management/templates*` | Everything on the admin port. admin-server serves the config-dump endpoint itself, so with it off Horizon cannot read module state at all and reports every admin-port selector off (and OAP itself fails to start the other three admin modules). The template-sync admin pages fall back to bundled read-only. |
 | **receiver-runtime-rule** | `SW_RECEIVER_RUNTIME_RULE=default` | 11.x | `GET /runtime/rule/list`, `GET /runtime/rule`, `POST /runtime/rule/addOrUpdate`, `POST /runtime/rule/delete`, `GET /runtime/rule/bundled` | DSL Management page; alarm rule editor save/load; cluster-status rule matrix; Live Debugger rule picker; Inspect page source attribution. |
 | **dsl-debugging** | `SW_DSL_DEBUGGING=default` | 11.x | `GET /dsl-debugging/status`, `GET /status/alarm/*` | Live Debugger (MAL / LAL / OAL session start, poll, stop); cluster-status DSL health pane; alarm rule diagnostics. |
 | **inspect** | `SW_INSPECT=default` | 11.x | `GET /inspect/metrics`, `GET /inspect/entities` | Inspect page (returns 404 from OAP). |
diff --git a/docs/components/dashboard-widgets.md b/docs/components/dashboard-widgets.md
index d1166cc..0ef33d0 100644
--- a/docs/components/dashboard-widgets.md
+++ b/docs/components/dashboard-widgets.md
@@ -28,7 +28,7 @@ Five widget types render on per-layer dashboards. Each `widget.type` you set in
 | `format` | `int`, `decimal`, `compact`. |
 | `tableHeaders` | `table` only — `[, valueHeader]`; the value column's header. Label columns are headed by their dimension name. |
 | `showTableValues` | `table` only — show the value column. `false` for presence-only lists (e.g. node conditions). Default `true`. |
-| `visibleWhen` | Predicate. `#entity.<key>` (hides the widget unless the named entity is selected) or `<metric> has value` (hides unless the metric returns data). |
+| `visibleWhen` | Structured visibility predicate (object form). An MQE gate `{ "kind": "mqe", "expression": "<mqe>", "op": "exists" \| "gt" \| "lt", "value"?: <n> }` hides the widget unless the expression returns data (`exists`) or crosses a threshold; an entity gate `{ "kind": "entity", "attribute": "<attr>", "op": "exists" \| "eq", "value"?: "<v>" }` hides it unless the selected entity has that attribute (Instance-scope only). |
 | `layerScope` | Evaluate against the whole layer rather than the selected service. |
 
 ## `card`
@@ -208,13 +208,12 @@ Renders columns `Condition | Node` (one per label key). A widget like Deployment
 
 ## Visibility predicates
 
-`visibleWhen` lets a widget hide itself based on context:
+`visibleWhen` lets a widget hide itself based on context. It takes a structured object in one of two forms:
 
-- `#entity.serviceInstance` — only show when an instance is selected. Useful for "instance details" widgets on the service page that should not render at the service-only level.
-- `#entity.endpoint` — only show when an endpoint is selected.
-- `<metric-name> has value` — only show when the named metric returns non-null data. Useful for layer-conditional widgets (e.g. JVM metrics only on JVM-based services).
+- **MQE gate** — `{ "kind": "mqe", "expression": "<mqe>", "op": "exists" | "gt" | "lt", "value"?: <n> }`. With `op: "exists"` the widget shows only when the expression returns non-null data (e.g. JVM metrics only on JVM-based services); `gt` / `lt` compare the result against `value`.
+- **Entity gate** — `{ "kind": "entity", "attribute": "<attr>", "op": "exists" | "eq", "value"?: "<v>" }`. Shows the widget only when the selected entity carries that attribute (`exists`) or matches `value` (`eq`). Entity gates are Instance-scope only — use them for "instance details" widgets on the service page that should not render at the service-only level.
 
-The predicate is evaluated on every data refresh; the widget disappears (rather than rendering empty) when the predicate is false.
+The predicate is evaluated on every data refresh; the widget disappears (rather than rendering empty) when it does not hold.
 
 ## Layer scope
 
diff --git a/docs/customization/i18n.md b/docs/customization/i18n.md
index 5474274..fd722f0 100644
--- a/docs/customization/i18n.md
+++ b/docs/customization/i18n.md
@@ -30,7 +30,7 @@ All eight languages are first-class across the surfaces Horizon controls. The sc
 | UI chrome — buttons, labels, modals, login, topbar, sidebar | In the browser (vue-i18n) | All 8 |
 | Bundled layer-dashboard templates | On the BFF, from sibling overlay catalogs | All 8 |
 | Bundled overview templates | On the BFF | All 8 |
-| User-maintained dashboards saved to OAP | On the BFF, from each dashboard's embedded `i18n` block | English + whatever the author provides |
+| User-maintained dashboards saved to OAP | On the BFF, from per-language translation overlays stored on OAP | English + whatever the author provides |
 | OAP-supplied data — service / instance / endpoint / alarm names, tags, log lines, trace ops | not translated | rendered verbatim |
 
 A missing key in any translated scope falls back to English at the leaf, so a partially-translated catalog renders better than English-only.
@@ -58,7 +58,7 @@ new locale takes effect without a page reload.
 | UI chrome (buttons, labels, modals, time picker, login page) | OAP-supplied data: service names, instance names, endpoint names, alarm rule names, tag values, log messages, trace span names |
 | Bundled layer-dashboard templates (widget titles, KPI labels, group titles, tooltips) | Layer keys (`GENERAL`, `MESH`, `K8S_SERVICE`, …) |
 | Bundled overview templates | Metric ids, MQE expressions |
-| User-maintained templates that carry an `i18n` block | Units (`ms`, `rpm`, `%`) — abbreviated technical conventions |
+| User-maintained templates (via per-language overlays on the Translations page) | Units (`ms`, `rpm`, `%`) — abbreviated technical conventions |
 | Layer `alias` and `aliases.*` (the display labels we author) | OAP scope enums (`Service`, `ServiceInstance`, `Endpoint`, `Process`) |
 
 Product and project names — SkyWalking, Kubernetes, Envoy, Istio, OAP,
@@ -83,37 +83,15 @@ welcome** — target the matching catalog file:
 
 ## Translating a custom dashboard
 
-User-maintained dashboards (saved via the admin Layer Dashboards or
-Overview Templates editor) can carry an embedded `i18n` block inside
-their `configuration` JSON:
-
-```json
-{
-  "title": "My Custom Dashboard",
-  "widgets": [
-    { "title": "Top 20 APIs", "tip": "Top endpoints by traffic" }
-  ],
-  "i18n": {
-    "zh-CN": {
-      "title": "我的自定义仪表板",
-      "widgets": [
-        { "title": "前 20 接口", "tip": "按流量排序的前 20 接口" }
-      ]
-    }
-  }
-}
-```
+Translations are **not** embedded in the dashboard you author. The English source dashboard (layer or overview) stays single-language; each locale is a separate translation overlay stored on OAP and edited on the **Translations** page, one language at a time.
+
+To translate a dashboard you've published:
 
-Rules:
+1. Open the **Translations** page, pick the template and the **Target** language.
+2. Fill the translated fields — the overlay mirrors the English source's structure, so you fill only the fields you want translated. Anything you leave blank falls back to English at the leaf.
+3. **Check diff & push** shows a side-by-side diff of your draft against the version live on OAP and publishes the overlay. Keys with no matching field in the source are dropped — the source is the schema.
 
-- The `i18n.<locale>` block mirrors the English source's structure;
-  fill only the fields you want translated.
-- Anything missing falls back to English at render time.
-- Keys in the overlay that have no matching field in the source are
-  silently dropped — the source is the schema.
-- A `Refill from shared phrases` action (in the editor) populates the
-  common widget vocabulary from the shared lexicon so you only need
-  to translate your own prose.
+The overlay never travels inside the dashboard JSON, so a dashboard exported to another OAP arrives with its English source only; move its translations across on the Translations page if you need them there.
 
 ## Import / Export
 
diff --git a/docs/customization/layer-templates.md b/docs/customization/layer-templates.md
index 18899e3..e6fdb45 100644
--- a/docs/customization/layer-templates.md
+++ b/docs/customization/layer-templates.md
@@ -14,7 +14,7 @@ There is **one template per layer**. Horizon ships bundled templates for the com
   "visibility": "public",
   "color": "var(--sw-accent)",
   "documentLink": "https://skywalking.apache.org/docs/main/next/en/concepts-and-designs/scopes/",
-  "slots": { ... },
+  "aliases": { ... },
   "components": { ... },
   "header": { ... },
   "dashboards": {
@@ -50,8 +50,8 @@ Every field is optional except `key`. Defaults are baked in for the rest.
 | `visibility` | `public` \| `operate` | `public` | Section placement. `operate` puts the layer under the Operate group. |
 | `color` | string | `var(--sw-accent)` | Hex or CSS variable for the layer's accent. |
 | `documentLink` | string (URL) | — | External docs URL; renders as a small chip on the layer page. |
-| `slots` | object | OAP defaults | Per-layer entity term overrides (see below). |
-| `components` | object | all-`true` | Which sub-tabs are enabled (see below). |
+| `aliases` | object | OAP defaults | Per-layer entity term overrides (see below). `slots` is an accepted legacy alias for this key. |
+| `components` | object | — | Which sub-tabs are enabled (see below). Every tab except the service dashboard is off unless explicitly set `true`. |
 | `header` | object | — | Service-list picker columns + default sort. |
 | `dashboards` | object | — | Per-scope widget arrays (the bulk of the template). |
 | `topology` | object | — | Topology MQE override for the service-map view. |
@@ -60,12 +60,12 @@ Every field is optional except `key`. Defaults are baked in for the rest.
 | `log` | object | — | Logs tab scope (service / instance / endpoint). |
 | `naming` | object | — | Service-name parsing rule (extracts cluster or other tokens from the OAP-reported name). |
 
-## `slots`
+## `aliases`
 
-Layer-specific term overrides used in UI labels.
+Layer-specific term overrides used in UI labels. (`slots` is an accepted legacy alias for this key; every bundled template uses `aliases`.)
 
 ```json
-"slots": {
+"aliases": {
   "services":         "services",
   "instances":        "instances",
   "endpoints":        "endpoints",
@@ -98,7 +98,7 @@ Per-tab feature toggles. A `false` value hides the tab.
 }
 ```
 
-The keys are the per-layer sub-tabs. `networkProfiling` and `podLogs` are also available; any key omitted defaults to enabled. The landing tab when a layer is clicked is the **first enabled** in the priority order `service → instances → endpoints → endpointDependency → topology → traces → logs → traceProfiling`.
+The keys are the per-layer sub-tabs. `networkProfiling` and `podLogs` are also available. Only the **service** dashboard is on when its key is omitted; every other tab is **off unless explicitly set `true`** — the bundled templates enable each tab they want (`general.json` sets every flag `true` for exactly this reason). The landing tab when a layer is clicked is the **first enabled** in the priority order `service → instances → endpoints → endpointDependency → topology → traces → logs → [...]
 
 `deployment` is the exception: it is **off by default** and only appears when the layer also carries a [`deployment`](#deployment) config block — see [Deployment](#deployment) below.
 
@@ -212,7 +212,7 @@ A layer without an explicit `instance` widget set will reuse `service` widgets o
 | `format` | Numeric formatting: `int`, `decimal`, `compact` (K / M suffixes). |
 | `span` | Column span in the 12-col grid. Default 4 = three widgets per row. |
 | `rowSpan` | Vertical span. Default 1 (one 120 px row). |
-| `visibleWhen` | Predicate. Two supported shapes: `#entity.<key>` (truthy if the named entity key is set; e.g. `#entity.serviceInstance` to show only when an instance is selected) and `<metric> has value` (only show if the metric returns data). |
+| `visibleWhen` | Structured visibility predicate (object form). An MQE gate `{ "kind": "mqe", "expression": "<mqe>", "op": "exists" \| "gt" \| "lt", "value"?: <n> }` shows the widget only when the expression returns data (`exists`) or crosses a threshold; an entity gate `{ "kind": "entity", "attribute": "<attr>", "op": "exists" \| "eq", "value"?: "<v>" }` shows it only when the selected entity has that attribute — entity gates are Instance-scope only. |
 | `layerScope` | If true, MQE evaluates against the whole layer rather than the selected service. Used for layer-level summaries on the service page. |
 
 ### Choosing `type`
diff --git a/docs/operate/cluster-metadata.md b/docs/operate/cluster-metadata.md
index 4cf0a66..5e5d536 100644
--- a/docs/operate/cluster-metadata.md
+++ b/docs/operate/cluster-metadata.md
@@ -1,12 +1,12 @@
-# Cluster Status & Metadata
+# Cluster Status
 
 Path: `/operate/cluster`. Verb: `cluster:read` (granted by maintainer, operator, admin).
 
 This is the operator's single pane for "is the OAP backend wired correctly?". It surfaces:
 
-- **Live health** of the two OAP ports.
+- **Live health** of the OAP query and admin ports.
 - **Required-module state** for the four admin selectors.
-- **Cluster member discovery** (OAP nodes behind the configured URL).
+- **Zipkin / OTLP trace-source reachability** for the trace menu.
 
 The triage flow during a banner-heavy incident lives here. The full check sequence is documented in [Compatibility → Cluster Status Check Sequence](../compatibility/cluster-status.md); this page focuses on what the operator sees and does.
 
@@ -14,7 +14,7 @@ The triage flow during a banner-heavy incident lives here. The full check sequen
 
 ```
 ┌─────────────────────────────────────────────────────────────────┐
-│ Cluster Status                                                  │
+│ OAP cluster                                                     │
 ├─────────────────────────────────────────────────────────────────┤
 │  Query (:12800)                  │  Admin (:17128)              │
 │  ─────────────────────           │  ─────────────────────       │
@@ -24,43 +24,32 @@ The triage flow during a banner-heavy incident lives here. The full check sequen
 │  Timestamp  2026-05-18 09:14:02  │  dsl-debugging        ✓      │
 │  Health     0 (OK)               │  inspect              ✓      │
 ├─────────────────────────────────────────────────────────────────┤
-│ Cluster members                                                 │
-│ ────────────────                                                │
-│  Host                Port   Role        Heartbeat                │
-│  oap-1.example.com   12800  receiver    2s ago                  │
-│  oap-2.example.com   12800  receiver    1s ago                  │
-│  oap-3.example.com   12800  receiver    3s ago                  │
+│ Zipkin / OTLP traces                                            │
+│ ────────────────────                                            │
+│  Reachable  ✓                                                   │
 └─────────────────────────────────────────────────────────────────┘
 ```
 
 ## Live health (top row)
 
-Two **independent** panes, refreshed in parallel.
+The Query and Admin panes are **independent**, refreshed in parallel. A third Zipkin/OTLP pane (below) probes the trace source separately.
 
 ### Query pane
 
-- **Source:** `GET /api/oap/info` → BFF GraphQL call to OAP.
 - **Refresh:** 30 s.
 - **Fields:** reachable, version, server timezone (UTC offset), server timestamp, health score.
 - **Failure modes:** see [Cluster Status Check Sequence](../compatibility/cluster-status.md#pane-a--query--graphql-port-12800).
 
 ### Admin pane
 
-- **Source:** `GET /api/preflight` → BFF call to OAP `/debugging/config/dump`.
 - **Refresh:** 60 s.
 - **Per-module rows:** `admin-server`, `receiver-runtime-rule`, `dsl-debugging`, `inspect`.
 - Each row carries a tooltip with the env-var needed to enable it (e.g. `SW_INSPECT=default`).
 - **Failure modes:** see [Cluster Status Check Sequence](../compatibility/cluster-status.md#pane-b--admin-host-17128).
 
-## Cluster members
+## Zipkin / OTLP traces pane
 
-- **Source:** `GET <queryUrl>/status/cluster/nodes`.
-- **Returns:** per-node host, port, role, last heartbeat.
-- **Refresh:** Same cadence as the Query pane.
-
-This is **informational only**. A 3-node OAP cluster behind one DNS name should show three rows. One row means the DNS resolves to a single IP (intentional in some deployments). Zero rows means OAP did not return cluster data — usually a single-node deployment without cluster module enabled, which is fine.
-
-The cluster-members section is **not** required for Horizon to function; it is a sanity check that the operator's expectation matches reality.
+A third pane probes OAP's Zipkin v2 REST endpoint and reports reachability. It feeds only the Zipkin/OTLP trace menu — a red dot here is **not** a cluster-wide outage. The rest of the UI keeps working when this pane is red; only Zipkin/OTLP trace views are affected.
 
 ## Reading the page during an incident
 
@@ -69,7 +58,6 @@ The cluster-members section is **not** required for Horizon to function; it is a
 3. **Query green, Admin red?** OAP is up but the admin port is not reachable. Likely causes: admin port not exposed by your Kubernetes Service, firewall rule, OAP missing `SW_ADMIN_SERVER=default`. Operate-section features (Cluster, Inspect, DSL Management, Live Debugger) are unavailable until fixed.
 4. **Admin pane mostly green but one yellow?** That feature is degraded — e.g., `dsl-debugging` off means the Live Debugger doesn't work. Set the corresponding env-var on OAP and restart.
 5. **Query pane shows health > 0?** OAP is up but degraded. The pane shows the score; `details` from `checkHealth` (also visible) names the degraded subsystem (storage lag, receiver backlog).
-6. **Cluster members count off?** Either DNS / Service is misconfigured, or an OAP node is down. The Cluster Status page does not page or alert — it shows. Wire your own alerting on `/api/preflight` and `/api/oap/info` if you want notifications.
 
 ## Related
 
diff --git a/docs/operate/data-retention.md b/docs/operate/data-retention.md
index 563bc85..d37833f 100644
--- a/docs/operate/data-retention.md
+++ b/docs/operate/data-retention.md
@@ -10,10 +10,19 @@ The page has two sections:
 
 | Section | What it means |
 |---|---|
-| Records | Event-style data such as traces, Zipkin traces, logs, and browser error logs. |
+| Records | Event-style data such as Normal records, traces, Zipkin traces, logs, and browser error logs. |
 | Metrics | Aggregated metric tiers: minute, hour, day, and metadata when the OAP backend exposes it. |
 
-Values are shown in days. A cold value means BanyanDB cold-stage retention. `no cold stage` means the connected OAP has no cold storage stage for that data class.
+Values are shown in days.
+
+## Stages
+
+Retention is reported in two stages:
+
+- **HOT + WARM** — the default view, the collapsed value OAP returns for each data class.
+- **COLD** — a separate cold-storage stage, present only on a BanyanDB backend. When the backend exposes it, the page renders a **Data lifecycle** bar; otherwise the page shows a single uniform stage and no cold pane.
+
+On a BanyanDB backend, a **cold-stage** toggle appears in the topbar. Enabling it switches reads to cold-only, so recent windows go empty — pick an older time range when the toggle is on. `no cold stage` next to a value means the connected OAP has no cold storage stage for that data class.
 
 ## Requirements
 
diff --git a/docs/operate/infra-3d-map.md b/docs/operate/infra-3d-map.md
index 770a9db..827fce3 100644
--- a/docs/operate/infra-3d-map.md
+++ b/docs/operate/infra-3d-map.md
@@ -40,8 +40,8 @@ Horizon ships four bundled tiers:
 | Tier | What lives here | Examples |
 |---|---|---|
 | **Apps** (top) | The application surfaces and their direct dependencies as the app sees them | General (agent) services, Browser/RUM, iOS, mini-programs, and the Virtual* targets (database / cache / MQ / gateway / GenAI) |
-| **Service Mesh** | The mesh that fronts the apps | Istio managed services, Istio data plane (Envoy sidecars), Istio control plane, Cilium, Envoy AI Gateway |
 | **Middleware** | The data and messaging services, gateways, and self-observability | MySQL, PostgreSQL, Redis, MongoDB, Elasticsearch, Kafka, RocketMQ, RabbitMQ, Pulsar, APISIX, Nginx, Kong, Flink, the SkyWalking SO11Y components, and cloud-managed data services |
+| **Service Mesh** | The mesh that fronts the apps | Istio managed services, Istio data plane (Envoy sidecars), Istio control plane, Cilium, Envoy AI Gateway |
 | **Infra** (bottom) | The platform the rest runs on | Kubernetes cluster + service, Linux/Windows hosts, virtual machines, EKS |
 
 Every layer OAP reports is placed onto exactly one tier. A layer that
diff --git a/docs/operate/inspect.md b/docs/operate/inspect.md
index 6f374c7..61a4f37 100644
--- a/docs/operate/inspect.md
+++ b/docs/operate/inspect.md
@@ -11,7 +11,7 @@ Common scenarios:
 - **"What metrics does this OAP build expose?"** — search the catalog by regex, filter by metric type / catalog / MQE-queryable.
 - **"Does this metric have data?"** — for a chosen metric, list the entities currently reporting.
 - **"What's the right scope for this metric?"** — the catalog row shows the metric's scope (Service / ServiceInstance / Endpoint / Process / All); this is load-bearing when authoring widgets.
-- **"Which receiver populated this metric?"** — source attribution, when available, surfaces the originating module.
+- **"Which analysis rule defines this metric?"** — rule-source attribution, when available, surfaces the DSL rule file (OAL, MAL·OTEL, MAL·Telegraf, LAL→MAL, …) that defines the metric.
 
 ## Prerequisites
 
@@ -24,9 +24,7 @@ If any of these is missing, the page surfaces a hint banner directing the operat
 
 ## Catalog browser
 
-**Endpoint:** `GET /api/inspect/metrics?regex=<pattern>&type=<type>&catalog=<catalog>&mqeQueryable=<bool>`
-
-Filters:
+The catalog browser lists the metrics OAP exposes. Filters:
 
 | Filter | Notes |
 |---|---|
@@ -41,22 +39,20 @@ Each catalog row shows:
 - Type (counter, gauge, histogram, …).
 - Scope (Service, ServiceInstance, Endpoint, ServiceRelation, ServiceInstanceRelation, EndpointRelation, Process, All).
 - Catalog.
-- Source module (when surfaced).
+- Rule source / file (when surfaced) — which DSL rule file defines the metric.
 - A button to drop the metric into the entity enumerator.
 
 The list is virtualized — a typical OAP exposes hundreds of metrics; scrolling is smooth.
 
 ## Entity enumerator
 
-**Endpoint:** `GET /api/inspect/entities?metric=<name>&start=<...>&end=<...>&step=<DAY|HOUR|MINUTE>&limit=<n>`
-
 For a chosen metric, OAP returns the set of entities that have data in the window. Useful for:
 
 - Confirming a metric is actually being populated.
 - Finding the exact entity id to use in an MQE expression.
 - Spotting metric-scope mismatches (a Service-scope metric will only return service entities, never instances).
 
-Time-format rules per `step` (UTC-interpreted on the OAP side):
+The page takes a browser-local time range and converts it; the strings are interpreted in the OAP server's timezone. Time-format rules per `step`:
 
 | step | date format |
 |---|---|
diff --git a/docs/operate/oap-configuration.md b/docs/operate/oap-configuration.md
index 6919d5f..aff797d 100644
--- a/docs/operate/oap-configuration.md
+++ b/docs/operate/oap-configuration.md
@@ -12,7 +12,7 @@ Secret values are masked by OAP before Horizon displays them. Masked values appe
 
 ## Requirements
 
-- OAP 11.x.
+- A recent OAP that ships the admin-server module.
 - `SW_ADMIN_SERVER=default` on OAP.
 - The OAP admin port, usually `17128`, reachable from Horizon.
 - The logged-in user has `config:read`.
diff --git a/docs/operate/runtime-rules.md b/docs/operate/runtime-rules.md
index 6e79c81..33f3f35 100644
--- a/docs/operate/runtime-rules.md
+++ b/docs/operate/runtime-rules.md
@@ -4,6 +4,11 @@ Path: `/operate/dsl`. Verbs: `rule:read` to browse, `rule:write` / `rule:write:s
 
 DSL Management lets you edit a connected OAP's analysis rules — MAL (`otel-rules`, `telegraf-rules`, `log-mal-rules`) and LAL (`lal`) — at runtime, without restarting the backend. You browse a catalog, open a rule in the editor, and save; OAP applies the change live across the cluster. Bundled rules shipped with OAP can be overridden, inactivated, or reverted to their bundled version.
 
+Two companion pages, both `rule:read`-gated, sit alongside the editable catalogs:
+
+- **OAL catalog** (`/operate/oal`) — a read-only browser of the connected OAP's OAL rules. OAL rules are **not** runtime-editable; this page lets you read them, but changes go through the OAP build, not the live editor.
+- **Dump & restore** (`/operate/dsl/dump`) — exports the connected OAP's runtime rules for backup and re-imports them onto another OAP.
+
 ## The two kinds of edit
 
 When you save, OAP classifies the change and one of two things happens:
diff --git a/docs/setup/container-image.md b/docs/setup/container-image.md
index 3043df5..cf1b4c2 100644
--- a/docs/setup/container-image.md
+++ b/docs/setup/container-image.md
@@ -161,7 +161,7 @@ spec:
             - name: state
               mountPath: /data
           readinessProbe:
-            httpGet: { path: /api/oap/info, port: 8081 }
+            httpGet: { path: /api/health, port: 8081 }
             periodSeconds: 10
       volumes:
         - name: config
@@ -327,11 +327,13 @@ Wire your platform's readiness probe to one of:
 
 | Endpoint | What it verifies |
 |---|---|
-| `GET /api/oap/info` | BFF is up **and** OAP query port is reachable. Strict — readiness gates on OAP. |
-| `GET /api/auth/health` | BFF is up + auth backend is healthy. Useful if you want readiness independent of OAP. |
-| TCP probe on 8081 | BFF process is listening. Loosest — does not verify OAP wiring. |
+| `GET /api/health` | **Recommended.** Public, unauthenticated, no OAP dependency — returns `{ status: "ok", version }` as soon as the BFF process is serving. Use this for both readiness and liveness. |
+| `GET /api/auth/health` | BFF is up + auth backend is healthy. Public. Useful if you want readiness to fold in auth-backend health. |
+| TCP probe on 8081 | BFF process is listening. Loosest — does not verify HTTP serving. |
 
-Liveness probes should be TCP-only (the BFF process is up). Wiring OAP into liveness creates a cascade failure when OAP blips.
+Do **not** point a probe at `GET /api/oap/info`: it is authenticated, so an unauthenticated probe gets HTTP 401 and the pod never becomes Ready. It is an in-app, authenticated OAP-reachability indicator, not a probe target.
+
+Liveness probes should use the public `GET /api/health` (or TCP-only on 8081). Wiring OAP reachability into liveness creates a cascade failure when OAP blips.
 
 ## Common mistakes
 
diff --git a/docs/setup/oap.md b/docs/setup/oap.md
index 0498747..aa683a3 100644
--- a/docs/setup/oap.md
+++ b/docs/setup/oap.md
@@ -30,7 +30,7 @@ oap:
 
 | URL | Hit by |
 |---|---|
-| `queryUrl` | GraphQL (`version`, `getTimeInfo`, `checkHealth`, `listLayers`, `listServices`, `getMenuItems`, `listLayerLevels`, `execExpression`, alarm queries, trace queries, log queries, topology queries, profiling queries). Status endpoint: `/status/cluster/nodes`. |
+| `queryUrl` | GraphQL (`version`, `getTimeInfo`, `checkHealth`, `listLayers`, `listServices`, `getMenuItems`, `listLayerLevels`, `execExpression`, alarm queries, trace queries, log queries, topology queries, profiling queries). |
 | `adminUrl` | `/debugging/config/dump`, `/runtime/rule/*`, `/dsl-debugging/*`, `/inspect/metrics`, `/inspect/entities`, `/status/alarm/*`. |
 | `zipkinUrl` | Zipkin v2 trace queries when a layer declares `traces.source: zipkin` or `both`. |
 
diff --git a/docs/setup/overview.md b/docs/setup/overview.md
index 614de02..143122f 100644
--- a/docs/setup/overview.md
+++ b/docs/setup/overview.md
@@ -6,7 +6,7 @@ This page is the shortest path from "no Horizon" to "Horizon in front of a runni
 
 - Apache SkyWalking **OAP 11.x** (native). OAP 10.x runs the data-plane stack (dashboards, traces, logs, topology, alarms, profiling) but the entire admin port — Inspect, DSL Management, Live Debugger, Alarm Rule editor, Cluster Status → Admin pane, and OAP UI-template sync — is v11-only. See [Compatibility → OAP Version](../compatibility/oap-version.md) for the feature-vs-version matrix.
 - Network reachability from the Horizon BFF to the OAP query port (`:12800`) and admin port (`:17128`). See [Network Ports](../compatibility/ports.md).
-- Node.js 20+ for the binary tarball. Source builds also need pnpm 10+.
+- A recent LTS Node.js runtime for the binary tarball. Source builds also need pnpm (pinned via Corepack).
 
 ## Five-step start
 
@@ -123,7 +123,7 @@ HORIZON_CONFIG=./horizon.yaml HORIZON_STATIC_DIR=./dist/static node dist/server.
 - [ ] `audit.file` writes to durable storage (not a container tmpfs).
 - [ ] `debugLog.enabled: false` (or rotate aggressively).
 - [ ] OAP credentials, LDAP bind password, and break-glass hash use `${ENV_VAR}` interpolation, not literal values.
-- [ ] Container readiness probe wired to `GET /api/oap/info` (or accept that the UI surfaces unreachable as a banner).
+- [ ] Container readiness probe wired to the public `GET /api/health` (not `/api/oap/info`, which is authenticated and returns 401 to an unauthenticated probe).
 
 ## Hot reload
 
diff --git a/docs/setup/rbac.md b/docs/setup/rbac.md
index 5120ad0..31b195a 100644
--- a/docs/setup/rbac.md
+++ b/docs/setup/rbac.md
@@ -8,8 +8,8 @@ Role-Based Access Control. Defines the role → verb grants and the post-login l
 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, ttl:read, config:read, inspect:read]
+    viewer:     [metrics:read, alarms:read, traces:read, logs:read, browser-errors:read, topology:read, profile:read, overview:read, infra-3d:read]
+    maintainer: [metrics:read, alarms:read, traces:read, logs:read, browser-errors:read, topology:read, profile:read, overview:read, infra-3d:read, cluster:read, ttl:read, config:read, inspect:read]
     operator:   [metrics:read, ..., rule:*, live-debug:*, profile:enable]
     admin:      ["*"]
   landingByRole:
@@ -31,7 +31,7 @@ rbac:
 
 | Role | Purpose | Grants |
 |---|---|---|
-| `viewer` | Read-only data catalog and public overviews. | `metrics:read`, `alarms:read`, `traces:read`, `logs:read`, `topology:read`, `profile:read`, `overview:read`, `infra-3d:read`. Deliberately not `*:read` so the viewer cannot see rule definitions, live-debug sessions, setup screens, or platform internals. |
+| `viewer` | Read-only data catalog and public overviews. | `metrics:read`, `alarms:read`, `traces:read`, `logs:read`, `browser-errors:read`, `topology:read`, `profile:read`, `overview:read`, `infra-3d:read`. Deliberately not `*:read` so the viewer cannot see rule definitions, live-debug sessions, setup screens, or platform internals. |
 | `maintainer` | Viewer + platform monitoring. | viewer baseline + `cluster:read`, `ttl:read`, `config:read`, `inspect:read`. |
 | `operator` | Configures observability. | maintainer baseline + `overview:write`, `setup:read/write`, `dashboard:read/write`, `alarm-setup:read/write`, `alarm-rule:read/write`, `rule:*` (including `rule:write:structural`, `rule:delete`, `rule:debug`), `live-debug:*`, `profile:enable`. |
 | `admin` | Unrestricted. | `*`. |
diff --git a/docs/setup/session.md b/docs/setup/session.md
index 74064f6..4487e78 100644
--- a/docs/setup/session.md
+++ b/docs/setup/session.md
@@ -13,7 +13,7 @@ session:
 
 | Field | Type | Default | Required | Notes |
 |---|---|---|---|---|
-| `ttlMinutes` | number | `60` | no | Session lifetime in minutes. Sessions are sliding: each request bumps `lastSeenAt`. A session that goes idle for longer than `ttlMinutes` is reaped and the next request returns 401. Positive integer. |
+| `ttlMinutes` | number | `60` | no | Session lifetime in minutes. Sessions are sliding: each authenticated request extends the session's last-seen time. A session that goes idle for longer than `ttlMinutes` is reaped and the next request returns 401. Positive integer. |
 | `cookieName` | string | `horizon_sid` | no | Name of the session cookie. Change only if you are running multiple Horizon instances on the same hostname / different paths and need distinct cookies. |
 | `cookieSecure` | boolean | `false` | no | When `true`, cookies carry the `Secure` flag (browser only sends over HTTPS). **Set to `true` in production behind a TLS terminator.** |
 
@@ -34,8 +34,7 @@ The cookie carries only a session id. The server-side session map holds username
 
 - In-memory `Map<sid, Session>`.
 - A background reaper (60 s interval) deletes expired sessions.
-- `sessions.touch(sid)` extends `lastSeenAt` on every authenticated request.
-- `sessions.get(sid)` is a read-without-touch path used by some identity-only checks.
+- Each authenticated request extends the session's last-seen time (sliding TTL). A few identity-only checks read the session without extending it.
 
 There is **no shared session store** between BFF instances. If you run multiple BFF replicas behind a load balancer, use sticky sessions, or accept that a failover causes a re-login.