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 &
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><queryUrl>/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 {