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 eec8dff  bff/ui: carry oap.auth on all admin/zipkin paths; cluster 
zipkin pane; RBAC + inspect/sidebar fixes
eec8dff is described below

commit eec8dff92c0460a57796973264e3113fe1b77c06
Author: Wu Sheng <[email protected]>
AuthorDate: Wed May 20 17:49:18 2026 +0800

    bff/ui: carry oap.auth on all admin/zipkin paths; cluster zipkin pane; RBAC 
+ inspect/sidebar fixes
    
    - auth: preflight, mqe-target, and inspect-exec built their own OAP
      fetchers that bypassed the auth-injecting client layer — they now send
      oap.auth, so an authed OAP no longer 401s the admin pane, Inspect, or
      MQE values. Inspect exec also targets oap.queryUrl (correct scheme +
      auth) instead of a rediscovered http:// REST port.
    - cluster status: add a Zipkin/OTLP reachability pane (probed in parallel
      with the GraphQL info call), scoped to the trace menu only.
    - alerting rules: per-node ok checked errorMsg === null, but OAP omits the
      key on success — treat missing as no-error so reachable reports true.
    - inspect widget: larger legend + entity-name type/spacing; clickable nav
      arrows bright / disabled dim; legend pager inactive direction dimmed;
      card overflow visible so the entity editor popout isn't clipped.
    - sidebar: don't auto-expand the first layer by default (expand on click
      or route); scroll the active item into view on mount; gate Platform
      monitoring on cluster:read (maintainer+).
    - rbac: overview-templates admin editor is operate-only (overview:write).
---
 apps/bff/src/http/admin/alarm-rules.ts             |  6 +--
 apps/bff/src/http/admin/inspect.ts                 |  1 +
 apps/bff/src/http/query/info.ts                    | 46 ++++++++++++++++-
 apps/bff/src/logic/inspect/exec.ts                 | 16 ++++--
 apps/bff/src/logic/preflight/preflight.ts          | 10 +++-
 apps/bff/src/rbac/route-policy.ts                  |  8 ++-
 apps/bff/src/util/mqe-target.ts                    | 25 ++++++++--
 apps/ui/src/features/admin/roles/RolesView.vue     |  3 +-
 .../features/operate/cluster/ClusterStatusView.vue | 58 +++++++++++++++++++++-
 .../src/features/operate/inspect/InspectView.vue   | 53 +++++++++++++-------
 apps/ui/src/shell/AppSidebar.vue                   | 32 +++++++++---
 packages/api-client/src/alarm-status.ts            |  7 +--
 packages/api-client/src/oap-info.ts                | 10 ++++
 13 files changed, 228 insertions(+), 47 deletions(-)

diff --git a/apps/bff/src/http/admin/alarm-rules.ts 
b/apps/bff/src/http/admin/alarm-rules.ts
index 59fd60e..cb98409 100644
--- a/apps/bff/src/http/admin/alarm-rules.ts
+++ b/apps/bff/src/http/admin/alarm-rules.ts
@@ -116,7 +116,7 @@ function pivot(
 ): Pick<AlertingRulesListResponse, 'rules' | 'nodes'> {
   const nodes = listResp.oapInstances.map((i) => ({
     address: i.address,
-    ok: i.errorMsg === null && !!i.status,
+    ok: !i.errorMsg && !!i.status,
     error: i.errorMsg ?? undefined,
   }));
   const totalNodes = nodes.length || 1;
@@ -152,7 +152,7 @@ function pivot(
       detail: bestDetail,
       nodes: listResp.oapInstances.map((i) => ({
         address: i.address,
-        ok: i.errorMsg === null && !!i.status,
+        ok: !i.errorMsg && !!i.status,
         error: i.errorMsg ?? undefined,
         loaded: loadedAddrs.has(i.address),
       })),
@@ -254,7 +254,7 @@ export function registerAlarmRulesRoutes(
         detail: AlarmRuleDetail | null;
       }>((i: InstanceAlarmStatus<AlarmRuleDetail>) => ({
         address: i.address,
-        ok: i.errorMsg === null && !!i.status,
+        ok: !i.errorMsg && !!i.status,
         error: i.errorMsg ?? undefined,
         detail: i.status ?? null,
       }));
diff --git a/apps/bff/src/http/admin/inspect.ts 
b/apps/bff/src/http/admin/inspect.ts
index 1b4cea5..44d1455 100644
--- a/apps/bff/src/http/admin/inspect.ts
+++ b/apps/bff/src/http/admin/inspect.ts
@@ -235,6 +235,7 @@ export function registerInspectRoutes(app: FastifyInstance, 
deps: InspectRouteDe
         const result = await fireMqe(target, body, {
           fetch: fetchImpl,
           timeoutMs: cfg.oap.timeoutMs,
+          ...(cfg.oap.auth ? { auth: cfg.oap.auth } : {}),
         });
         return reply.send(result);
       } catch (err) {
diff --git a/apps/bff/src/http/query/info.ts b/apps/bff/src/http/query/info.ts
index c0ba056..90f6e40 100644
--- a/apps/bff/src/http/query/info.ts
+++ b/apps/bff/src/http/query/info.ts
@@ -20,7 +20,7 @@ import type { FetchLike } 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 { buildOapOpts, graphqlPost } from '../../client/graphql.js';
+import { basicAuthHeader, buildOapOpts, graphqlPost } from 
'../../client/graphql.js';
 import { getOapCapabilities } from '../../logic/oap/capabilities.js';
 
 /**
@@ -61,20 +61,55 @@ export interface InfoRouteDeps {
   fetch?: FetchLike;
 }
 
+/** Probe OAP's Zipkin v2 REST endpoint for reachability. Hits the
+ *  cheapest path (`/api/v2/services`) and treats any 2xx as up. Never
+ *  throws — a down Zipkin is a normal state (only the Zipkin trace menu
+ *  depends on it), so failure is reported as `reachable: false`, not an
+ *  exception that would mask the GraphQL info the same poll carries. */
+async function probeZipkin(
+  cfg: ConfigSource['current'],
+  fetchFn: FetchLike | undefined,
+): Promise<{ reachable: boolean; error?: string }> {
+  const f = fetchFn ?? globalThis.fetch.bind(globalThis);
+  const url = cfg.oap.zipkinUrl.replace(/\/$/, '') + '/api/v2/services';
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), cfg.oap.timeoutMs);
+  const headers: Record<string, string> = { accept: 'application/json' };
+  if (cfg.oap.auth) {
+    headers.authorization = basicAuthHeader(cfg.oap.auth.username, 
cfg.oap.auth.password);
+  }
+  try {
+    const res = await f(url, { method: 'GET', headers, signal: 
controller.signal });
+    if (!res.ok) return { reachable: false, error: `HTTP ${res.status} at 
${url}` };
+    return { reachable: true };
+  } catch (err) {
+    return { reachable: false, error: err instanceof Error ? err.message : 
String(err) };
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
 export function registerOapInfoRoute(app: FastifyInstance, deps: 
InfoRouteDeps): void {
   const auth = requireAuth(deps);
   app.get('/api/oap/info', { preHandler: auth }, async (_req: FastifyRequest, 
reply: FastifyReply) => {
     const cfg = deps.config.current;
     const queryUrl = cfg.oap.queryUrl;
+    const zipkinUrl = cfg.oap.zipkinUrl;
+    /* Zipkin reachability is probed independently of the GraphQL info
+     * call (separate endpoint, often a different port/module). It never
+     * rejects, so it stays available on both the success and catch paths
+     * below. */
+    const zipkinP = probeZipkin(cfg, deps.fetch);
     try {
       /* Capability probe runs in parallel with the info call — both
        * are GraphQL POSTs to the same endpoint; serialising would add
        * round-trip latency to every poll without changing semantics.
        * The probe is internally cached for 5 min so the wire traffic
        * is one-off per OAP restart, not per call. */
-      const [raw, capabilities] = await Promise.all([
+      const [raw, capabilities, zipkin] = await Promise.all([
         graphqlPost<InfoRaw>(buildOapOpts(cfg, deps.fetch), INFO_QUERY),
         getOapCapabilities(cfg, deps.fetch),
+        zipkinP,
       ]);
       const body: OapInfo = {
         reachable: true,
@@ -85,13 +120,20 @@ export function registerOapInfoRoute(app: FastifyInstance, 
deps: InfoRouteDeps):
         healthScore: raw.health?.score ?? undefined,
         healthDetails: raw.health?.details ?? undefined,
         capabilities,
+        zipkinUrl,
+        zipkinReachable: zipkin.reachable,
+        zipkinError: zipkin.error,
       };
       return reply.send(body);
     } catch (err) {
+      const zipkin = await zipkinP;
       const body: OapInfo = {
         reachable: false,
         queryUrl,
         error: err instanceof Error ? err.message : String(err),
+        zipkinUrl,
+        zipkinReachable: zipkin.reachable,
+        zipkinError: zipkin.error,
       };
       return reply.status(200).send(body);
     }
diff --git a/apps/bff/src/logic/inspect/exec.ts 
b/apps/bff/src/logic/inspect/exec.ts
index 8192a98..f762495 100644
--- a/apps/bff/src/logic/inspect/exec.ts
+++ b/apps/bff/src/logic/inspect/exec.ts
@@ -54,6 +54,9 @@ export interface ExecDeps {
   fetch: FetchLike;
   /** Per-call timeout (ms). 0 disables. */
   timeoutMs: number;
+  /** Basic-auth for the OAP GraphQL endpoint — same credentials the
+   *  rest of the client layer uses. Omit when OAP is unauthenticated. */
+  auth?: { username: string; password: string };
 }
 
 /* SkyWalking's MQE entry point is on `Query`, not `Mutation`
@@ -155,12 +158,17 @@ export async function fireMqe(
       debug: req.debug ?? false,
     },
   };
+  const headers: Record<string, string> = {
+    'Content-Type': 'application/json',
+    Accept: 'application/json',
+  };
+  if (deps.auth) {
+    headers.authorization =
+      'Basic ' + Buffer.from(`${deps.auth.username}:${deps.auth.password}`, 
'utf8').toString('base64');
+  }
   let init: RequestInit = {
     method: 'POST',
-    headers: {
-      'Content-Type': 'application/json',
-      Accept: 'application/json',
-    },
+    headers,
     body: JSON.stringify(payload),
   };
   let timer: ReturnType<typeof setTimeout> | null = null;
diff --git a/apps/bff/src/logic/preflight/preflight.ts 
b/apps/bff/src/logic/preflight/preflight.ts
index 5e2fb4c..6026a81 100644
--- a/apps/bff/src/logic/preflight/preflight.ts
+++ b/apps/bff/src/logic/preflight/preflight.ts
@@ -84,7 +84,7 @@ export async function runPreflight(
 ): Promise<PreflightResult> {
   const adminUrl = config.oap.adminUrl;
   const generatedAt = Date.now();
-  const dump = await fetchConfigDump(adminUrl, fetch, config.oap.timeoutMs);
+  const dump = await fetchConfigDump(adminUrl, fetch, config.oap.timeoutMs, 
config.oap.auth);
 
   if (!dump.ok) {
     return {
@@ -140,9 +140,15 @@ async function fetchConfigDump(
   adminUrl: string,
   fetch: FetchLike,
   timeoutMs: number,
+  auth?: { username: string; password: string },
 ): Promise<DumpOk | DumpErr> {
   const url = `${adminUrl.replace(/\/$/, '')}/debugging/config/dump`;
-  let init: RequestInit = { method: 'GET', headers: { Accept: 
'application/json' } };
+  const headers: Record<string, string> = { Accept: 'application/json' };
+  if (auth) {
+    const b64 = Buffer.from(`${auth.username}:${auth.password}`, 
'utf8').toString('base64');
+    headers.authorization = `Basic ${b64}`;
+  }
+  let init: RequestInit = { method: 'GET', headers };
   let timer: ReturnType<typeof setTimeout> | null = null;
   if (timeoutMs > 0) {
     const ctrl = new AbortController();
diff --git a/apps/bff/src/rbac/route-policy.ts 
b/apps/bff/src/rbac/route-policy.ts
index 2f02c50..92374d8 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -183,8 +183,12 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'GET /api/admin/alarm-rules/:id':                'alarm-rule:read',
 
   // ── Overview-template editor (admin) ─────────────────────────────
-  'GET /api/admin/overview-templates':             'overview:read',
-  'GET /api/admin/overview-templates/:id':         'overview:read',
+  // 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`.
+  '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
diff --git a/apps/bff/src/util/mqe-target.ts b/apps/bff/src/util/mqe-target.ts
index 58944f1..5aa55c6 100644
--- a/apps/bff/src/util/mqe-target.ts
+++ b/apps/bff/src/util/mqe-target.ts
@@ -98,9 +98,22 @@ async function resolveMqeTarget(deps: ResolveDeps): 
Promise<MqeTarget> {
     };
   }
 
-  // Otherwise we need the config dump.
+  // No override: the configured GraphQL query endpoint IS the MQE
+  // surface — `execExpression` is a query-protocol query served by the
+  // same `/graphql` the rest of the app uses. Use `oap.queryUrl`
+  // verbatim so scheme (http/https), host, port, and the basic-auth the
+  // GraphQL client already carries all line up. Rediscovering a REST
+  // host:port from the admin dump only rebuilds this same endpoint while
+  // dropping the scheme + auth (it always emits `http://` and a bind
+  // host that's often a wildcard / cluster-internal IP).
+  if (configured.host === undefined && configured.port === undefined) {
+    return { baseUrl: cfg.queryUrl.replace(/\/$/, ''), via: 'oap.queryUrl', 
configured };
+  }
+
+  // Partial override (only host OR only port) — discover the missing
+  // half from the admin config dump and combine.
   const adminUrl = cfg.adminUrl;
-  const dump = await fetchConfigDump(adminUrl, deps.fetch, cfg.timeoutMs);
+  const dump = await fetchConfigDump(adminUrl, deps.fetch, cfg.timeoutMs, 
cfg.auth);
   const adminHost = new URL(adminUrl).hostname;
 
   const picked = pickFromDump(dump, adminHost);
@@ -173,10 +186,16 @@ async function fetchConfigDump(
   adminUrl: string,
   fetch: FetchLike,
   timeoutMs: number,
+  auth?: { username: string; password: string },
 ): Promise<Record<string, string>> {
   const base = adminUrl.replace(/\/$/, '');
   const url = `${base}/debugging/config/dump`;
-  let init: RequestInit = { method: 'GET', headers: { Accept: 
'application/json' } };
+  const headers: Record<string, string> = { Accept: 'application/json' };
+  if (auth) {
+    const b64 = Buffer.from(`${auth.username}:${auth.password}`, 
'utf8').toString('base64');
+    headers.authorization = `Basic ${b64}`;
+  }
+  let init: RequestInit = { method: 'GET', headers };
   let timer: ReturnType<typeof setTimeout> | null = null;
   if (timeoutMs > 0) {
     const ctrl = new AbortController();
diff --git a/apps/ui/src/features/admin/roles/RolesView.vue 
b/apps/ui/src/features/admin/roles/RolesView.vue
index 2c79822..3b1a1f7 100644
--- a/apps/ui/src/features/admin/roles/RolesView.vue
+++ b/apps/ui/src/features/admin/roles/RolesView.vue
@@ -71,11 +71,12 @@ const MENU_GATES: ReadonlyArray<{ label: string; verb: 
string | null }> = [
   { label: 'Alarms', verb: 'alarms:read' },
   { label: 'Overviews', verb: 'overview:read' },
   { label: 'Cluster status', verb: 'cluster:read' },
+  { label: 'Platform monitoring (layers)', verb: 'cluster:read' },
   { label: 'Metrics Inspect', verb: 'inspect:read' },
   { label: 'Alerting rules', verb: 'alarm-rule:read' },
   { label: 'Live debugger · Capture history', verb: 'live-debug:read' },
   { label: 'DSL Management', verb: 'rule:read' },
-  { label: 'Overview templates', verb: 'overview:read' },
+  { label: 'Overview templates', verb: 'overview:write' },
   { label: 'Layer dashboards', verb: 'dashboard:read' },
   { label: 'Alert page', verb: 'alarm-setup:read' },
   { label: 'Global defaults', verb: 'setup:read' },
diff --git a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue 
b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
index ebd4179..d7a1871 100644
--- a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
+++ b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
@@ -94,6 +94,20 @@ const adminGeneratedAt = computed<string>(() => {
   return new Date(ts).toLocaleTimeString();
 });
 
+// Zipkin / OTLP trace endpoint. Probed on the same poll as Pane A but
+// independently — it only feeds the Zipkin/OTLP trace menu, so a red
+// dot here is NOT a cluster-wide outage. Reachability is undefined
+// until the first /api/oap/info lands.
+const zipkinReachable = computed<boolean | undefined>(() => 
info.value?.zipkinReachable);
+const zipkinBadgeState = computed<'ok' | 'err' | 'unknown'>(() => {
+  if (zipkinReachable.value === undefined) return 'unknown';
+  return zipkinReachable.value ? 'ok' : 'err';
+});
+const zipkinBadgeLabel = computed<string>(() => {
+  if (zipkinReachable.value === undefined) return 'loading…';
+  return zipkinReachable.value ? 'reachable' : 'unreachable';
+});
+
 function refreshAll(): void {
   void refetchInfo();
   void refetchPreflight();
@@ -109,8 +123,9 @@ function refreshAll(): void {
         <p class="lede">
           Two-port view of the OAP backend horizon is connected to.
           Query / GraphQL (<code>:12800</code>) drives every observability 
page;
-          the admin host (<code>:17128</code>) gates DSL Management, Live 
Debugger, Inspect, and Dump.
-          Both are polled independently — if one shows red the other can still 
be green.
+          the admin host (<code>:17128</code>) gates DSL Management, Live 
Debugger, Inspect, and Dump;
+          the Zipkin / OTLP endpoint feeds only the Zipkin trace menu.
+          All three are polled independently — if one shows red the others can 
still be green.
         </p>
       </div>
       <button type="button" class="refresh" @click="refreshAll">refresh 
both</button>
@@ -217,6 +232,45 @@ function refreshAll(): void {
         </tbody>
       </table>
     </section>
+
+    <!-- ── Pane C · Zipkin / OTLP trace endpoint ─────────────────── -->
+    <section class="pane">
+      <header class="pane-head">
+        <h2>Zipkin / OTLP traces <span class="port">v2 REST</span></h2>
+        <span class="sw-badge" :class="`is-${zipkinBadgeState}`">
+          <span class="state-dot" />{{ zipkinBadgeLabel }}
+        </span>
+      </header>
+
+      <p class="pane-lede">
+        OAP's Zipkin v2 endpoint, source for the <strong>OpenTelemetry &amp; 
Zipkin</strong>
+        trace menu (shown when a layer's trace source is <code>zipkin</code> 
or <code>both</code>).
+        This is the <em>only</em> page affected — if it's unreachable, native 
traces and every
+        other observability page keep working.
+      </p>
+
+      <div class="grid">
+        <div class="sw-card kpi">
+          <div class="sw-card-head"><h4>Endpoint</h4></div>
+          <div class="kpi-body">
+            <div class="kpi-value mono">{{ zipkinBadgeLabel }}</div>
+            <div class="kpi-label">{{ info?.zipkinUrl ?? '—' }}</div>
+          </div>
+        </div>
+      </div>
+
+      <div v-if="zipkinReachable === false" class="last-error block">
+        <strong>Zipkin endpoint unreachable</strong>
+        <code v-if="info?.zipkinError">{{ info.zipkinError }}</code>
+        <p class="hint">
+          Tried <code>{{ info?.zipkinUrl }}/api/v2/services</code>.
+          Confirm OAP's Zipkin receiver / query is enabled and the
+          <code>oap.zipkinUrl</code> in horizon's config points at the right 
host:port
+          (shared GraphQL port → <code>&lt;queryUrl&gt;/zipkin</code>; 
standalone → <code>:9412/zipkin</code>).
+          Only the Zipkin trace menu is affected.
+        </p>
+      </div>
+    </section>
   </div>
 </template>
 
diff --git a/apps/ui/src/features/operate/inspect/InspectView.vue 
b/apps/ui/src/features/operate/inspect/InspectView.vue
index 4e61964..1b715e6 100644
--- a/apps/ui/src/features/operate/inspect/InspectView.vue
+++ b/apps/ui/src/features/operate/inspect/InspectView.vue
@@ -1038,7 +1038,7 @@ function buildOption(w: Widget): echarts.EChartsOption {
 
   const showLegend = series.length > 1;
   return {
-    grid: { left: 32, right: 6, top: showLegend ? 22 : 6, bottom: 18 },
+    grid: { left: 32, right: 6, top: showLegend ? 30 : 6, bottom: 18 },
     tooltip: {
       trigger: 'axis',
       backgroundColor: '#1c2630',
@@ -1048,10 +1048,15 @@ function buildOption(w: Widget): echarts.EChartsOption {
     legend: showLegend
       ? {
           top: 0, left: 0, right: 0, type: 'scroll',
-          textStyle: { color: '#8a96a3', fontSize: 9, fontFamily: mono },
-          itemHeight: 5, itemWidth: 8,
-          pageIconColor: '#5e6c79',
-          pageTextStyle: { color: '#5e6c79', fontSize: 9 },
+          textStyle: { color: '#c2cbd4', fontSize: 11, fontFamily: mono },
+          itemHeight: 8, itemWidth: 14, itemGap: 16,
+          // Active page direction bright; the unavailable one (e.g. the
+          // left arrow on page 1) clearly dim so it doesn't read as
+          // clickable.
+          pageIconColor: '#c2cbd4',
+          pageIconInactiveColor: '#3a4651',
+          pageIconSize: 11,
+          pageTextStyle: { color: '#8a96a3', fontSize: 10 },
         }
       : undefined,
     xAxis: {
@@ -1756,7 +1761,11 @@ function scopeShort(scope: InspectScope): string {
   row-gap: 6px;
   position: relative;
   min-width: 0;
-  overflow: hidden;
+  /* `visible`, not `hidden`: the entity editor popout is absolutely
+   * positioned at `top:100%` and must escape the card to overlay
+   * neighbours below. The card's own children (title, pills, chart)
+   * each clip themselves, so the card doesn't need to. */
+  overflow: visible;
 }
 .card__head {
   display: grid;
@@ -1818,10 +1827,10 @@ function scopeShort(scope: InspectScope): string {
   display: flex;
   align-items: center;
   justify-content: space-between;
-  gap: 6px;
+  gap: 10px;
   font-family: var(--rr-font-mono);
-  font-size: 11.5px;
-  padding: 3px 7px;
+  font-size: 13px;
+  padding: 4px 9px;
   background: var(--rr-bg);
   color: var(--rr-ink);
   border: 1px solid var(--rr-border);
@@ -1838,25 +1847,35 @@ function scopeShort(scope: InspectScope): string {
   text-overflow: ellipsis;
   white-space: nowrap;
   min-width: 0;
+  letter-spacing: 0.01em;
 }
 .entity__decoded--empty { color: var(--rr-dim); font-style: italic; }
-.entity__idx { font-size: 10px; color: var(--rr-dim); flex-shrink: 0; }
+.entity__idx { font-size: 11px; color: var(--rr-dim); flex-shrink: 0; }
+/* Clickable = bright + accent border (high-emphasis affordance);
+ * disabled = dim/gray with a faint border. The earlier styling read
+ * inverted — muted ink for live arrows, which looked like the
+ * disabled state. */
 .entity-nav {
   font-family: var(--rr-font-mono);
-  font-size: 10px;
-  padding: 2px 6px;
+  font-size: 12px;
+  padding: 3px 8px;
   background: transparent;
-  color: var(--rr-ink2);
-  border: 1px solid var(--rr-border);
+  color: var(--rr-heading);
+  border: 1px solid var(--rr-border2);
   border-radius: var(--rr-radius-sm);
   cursor: pointer;
   line-height: 1.4;
 }
 .entity-nav:hover:not(:disabled) {
-  color: var(--rr-heading);
-  border-color: var(--rr-border2);
+  color: var(--rr-accent);
+  border-color: var(--rr-accent);
+}
+.entity-nav:disabled {
+  color: var(--rr-dim);
+  border-color: var(--rr-border);
+  opacity: 0.5;
+  cursor: not-allowed;
 }
-.entity-nav:disabled { opacity: 0.4; cursor: not-allowed; }
 .link {
   background: transparent;
   border: 0;
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index 22b3695..d134ff8 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -15,7 +15,7 @@
   limitations under the License.
 -->
 <script setup lang="ts">
-import { computed, ref, watch } from 'vue';
+import { computed, nextTick, onMounted, ref, watch } from 'vue';
 import { RouterLink, useRoute, useRouter } from 'vue-router';
 import Icon, { type IconName } from '@/components/icons/Icon.vue';
 // Full "SkyWalking" wordmark + moon. The shipped file is white-fill
@@ -134,6 +134,11 @@ function isActiveExact(path: string): boolean {
   return route.path === path;
 }
 
+// Only the layer the route currently points at is auto-expanded. We do
+// NOT pre-expand the first layer (General) on a non-layer landing —
+// expanding a section is an explicit user action (a click), and a
+// default-open accordion misleads operators into thinking that layer is
+// "selected" when they've navigated nowhere.
 watch(
   [() => route.path, orderedLayers],
   ([path, rows]) => {
@@ -142,15 +147,22 @@ watch(
       const key = m[1];
       const L = rows.find((l) => l.key === key);
       if (L) expandedLayer.value = key;
-      return;
-    }
-    if (!expandedLayer.value && rows.length > 0) {
-      expandedLayer.value = rows[0].key;
     }
   },
   { immediate: true },
 );
 
+// On first paint, bring the route's selected nav item into view — on a
+// long sidebar the active layer/tab can land below the fold, so landing
+// deep-linked (or after a reload) would otherwise show no visible
+// selection. Wait a tick so the route-driven expand above has rendered
+// the L2 children that may contain the active row.
+const navRef = ref<HTMLElement | null>(null);
+onMounted(async () => {
+  await nextTick();
+  navRef.value?.querySelector('.is-active')?.scrollIntoView({ block: 'nearest' 
});
+});
+
 interface NavRow {
   icon: IconName;
   label: string;
@@ -213,7 +225,7 @@ const sections: NavSection[] = [
   {
     kicker: 'Dashboard setup',
     links: [
-      { icon: 'set', label: 'Overview templates', to: 
'/admin/overview-templates', verb: 'overview:read' },
+      { icon: 'set', label: 'Overview templates', to: 
'/admin/overview-templates', verb: 'overview:write' },
       { icon: 'metric', label: 'Layer dashboards', to: 
'/admin/layer-dashboards', verb: 'dashboard:read' },
       { icon: 'alert', label: 'Alert page', to: '/admin/alert-page-setup', 
verb: 'alarm-setup:read' },
       { icon: 'set', label: 'Global defaults', to: '/admin/global-defaults', 
verb: 'setup:read' },
@@ -284,7 +296,7 @@ watch(
       <small>Horizon</small>
     </RouterLink>
 
-    <nav class="sw-nav">
+    <nav ref="navRef" class="sw-nav">
       <!-- Overviews are gated by `overview:read` (operator / admin). -->
       <template v-if="auth.hasVerb('overview:read')">
         <div class="sw-nav-section sw-nav-section--icon">
@@ -611,7 +623,11 @@ watch(
         </div>
       </template>
 
-      <template v-if="operateLayers.length > 0">
+      <!-- Platform monitoring (OAP self-observability layers) is the
+           maintainer tier, not the viewer data catalog. Gate on
+           `cluster:read` — the verb horizon.yaml grants maintainer /
+           operator / admin but not viewer. -->
+      <template v-if="operateLayers.length > 0 && 
auth.hasVerb('cluster:read')">
         <div class="sw-nav-section sw-nav-section--icon">
           <Icon :name="sectionIcon('Platform monitoring')" />
           <span>Platform monitoring</span>
diff --git a/packages/api-client/src/alarm-status.ts 
b/packages/api-client/src/alarm-status.ts
index 6ea663d..db77730 100644
--- a/packages/api-client/src/alarm-status.ts
+++ b/packages/api-client/src/alarm-status.ts
@@ -51,9 +51,10 @@ export interface InstanceAlarmStatus<T> {
   /** gRPC address of the OAP node — `host:port` for peers, `Self()`
    *  literal for the node serving the HTTP request. */
   address: string;
-  /** Failure reason for THIS node only. Null when the node responded
-   *  successfully (status field is then populated). */
-  errorMsg: string | null;
+  /** Failure reason for THIS node only. Null/absent when the node
+   *  responded successfully (status field is then populated) — OAP
+   *  omits the key entirely on success rather than sending `null`. */
+  errorMsg?: string | null;
   status: T | null;
 }
 
diff --git a/packages/api-client/src/oap-info.ts 
b/packages/api-client/src/oap-info.ts
index 5d48ceb..ba58b98 100644
--- a/packages/api-client/src/oap-info.ts
+++ b/packages/api-client/src/oap-info.ts
@@ -43,6 +43,16 @@ export interface OapInfo {
    *  query shapes (e.g. `getAlarm` vs `queryAlarms`). */
   capabilities?: OapCapabilities;
   error?: string;
+  /** OAP's Zipkin v2 REST base URL (`oap.zipkinUrl`). Only the Zipkin /
+   *  OTLP trace menu reads from it — an unreachable Zipkin endpoint does
+   *  NOT degrade any other page, so the cluster-status pane scopes it
+   *  to the trace component explicitly. */
+  zipkinUrl?: string;
+  /** Whether `GET {zipkinUrl}/api/v2/services` answered 2xx. Probed in
+   *  parallel with the GraphQL info call and independent of `reachable`
+   *  (query port can be up while Zipkin is off, and vice versa). */
+  zipkinReachable?: boolean;
+  zipkinError?: string;
 }
 
 export interface OapCapabilities {

Reply via email to