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 792b47b bff: menu endpoint aliasing listLayers + getMenuItems +
listLayerLevels
792b47b is described below
commit 792b47b373caf7fe571e9138c00b71a095734c00
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 14:03:01 2026 +0800
bff: menu endpoint aliasing listLayers + getMenuItems + listLayerLevels
GET /api/menu issues a single GraphQL request against OAP's query port
that aliases three queries (listLayers, getMenuItems, listLayerLevels).
The response is stitched with horizon-side defaults (term aliases,
colors, cap picks) into the LayerDef shape the sidebar already consumes.
- packages/api-client/menu.ts: shared wire types (LayerSlots, LayerCaps,
LayerDef, MenuResponse)
- apps/bff/oap/graphql-client.ts: minimal GraphQL POSTer with timeout
+ GraphqlError class
- apps/bff/oap/menu-routes.ts: route handler + per-layer defaults for
every Layer enum value currently shipping in OAP
Soft-fails when OAP unreachable: HTTP 200 with reachable=false + error
string so the UI can render a banner instead of crashing.
---
apps/bff/src/oap/graphql-client.ts | 77 +++++++++++++++
apps/bff/src/oap/menu-routes.ts | 192 +++++++++++++++++++++++++++++++++++++
apps/bff/src/server.ts | 2 +
packages/api-client/src/index.ts | 1 +
packages/api-client/src/menu.ts | 77 +++++++++++++++
5 files changed, 349 insertions(+)
diff --git a/apps/bff/src/oap/graphql-client.ts
b/apps/bff/src/oap/graphql-client.ts
new file mode 100644
index 0000000..5b0833a
--- /dev/null
+++ b/apps/bff/src/oap/graphql-client.ts
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+
+export interface GraphqlOptions {
+ statusUrl: string;
+ timeoutMs: number;
+ fetch?: FetchLike;
+}
+
+export class GraphqlError extends Error {
+ readonly statusCode: number;
+ readonly errors?: ReadonlyArray<{ message: string; path?:
ReadonlyArray<string | number> }>;
+ constructor(
+ statusCode: number,
+ message: string,
+ errors?: ReadonlyArray<{ message: string; path?: ReadonlyArray<string |
number> }>,
+ ) {
+ super(message);
+ this.name = 'GraphqlError';
+ this.statusCode = statusCode;
+ this.errors = errors;
+ }
+}
+
+/**
+ * POST a GraphQL query to OAP's `/graphql` endpoint and return the unwrapped
+ * `data` field. Throws on transport errors and on GraphQL-level error arrays.
+ */
+export async function graphqlPost<T>(
+ opts: GraphqlOptions,
+ query: string,
+ variables?: Record<string, unknown>,
+): Promise<T> {
+ const f = opts.fetch ?? globalThis.fetch.bind(globalThis);
+ const url = opts.statusUrl.replace(/\/$/, '') + '/graphql';
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
+ let res: Response;
+ try {
+ res = await f(url, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ query, variables: variables ?? {} }),
+ signal: controller.signal,
+ });
+ } finally {
+ clearTimeout(timer);
+ }
+ if (!res.ok) {
+ const text = await res.text().catch(() => '');
+ throw new GraphqlError(res.status, `graphql http ${res.status}:
${text.slice(0, 200)}`);
+ }
+ const body = (await res.json()) as { data?: T; errors?: ReadonlyArray<{
message: string; path?: ReadonlyArray<string | number> }> };
+ if (body.errors && body.errors.length) {
+ throw new GraphqlError(200, body.errors.map((e) => e.message).join('; '),
body.errors);
+ }
+ if (body.data === undefined || body.data === null) {
+ throw new GraphqlError(200, 'graphql response had no data field');
+ }
+ return body.data;
+}
diff --git a/apps/bff/src/oap/menu-routes.ts b/apps/bff/src/oap/menu-routes.ts
new file mode 100644
index 0000000..46647b4
--- /dev/null
+++ b/apps/bff/src/oap/menu-routes.ts
@@ -0,0 +1,192 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type {
+ FetchLike,
+ LayerCaps,
+ LayerDef,
+ LayerSlots,
+ MenuResponse,
+} from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../config/loader.js';
+import type { SessionStore } from '../auth/sessions.js';
+import { requireAuth } from '../auth/middleware.js';
+import { graphqlPost } from './graphql-client.js';
+
+export interface MenuRouteDeps {
+ config: ConfigSource;
+ sessions: SessionStore;
+ fetch?: FetchLike;
+}
+
+// One round-trip, three aliased queries.
+const MENU_QUERY = /* GraphQL */ `
+ query HorizonMenu {
+ layers: listLayers
+ items: getMenuItems {
+ title
+ icon
+ layer
+ activate
+ description
+ documentLink
+ i18nKey
+ }
+ levels: listLayerLevels {
+ layer
+ level
+ }
+ }
+`;
+
+interface MenuRaw {
+ layers: string[];
+ items: Array<{
+ title: string;
+ icon?: string | null;
+ layer: string;
+ activate?: boolean | null;
+ description?: string | null;
+ documentLink?: string | null;
+ i18nKey?: string | null;
+ }>;
+ levels: Array<{ layer: string; level: number }>;
+}
+
+/**
+ * Horizon-side defaults for per-layer term aliases and color. OAP doesn't
+ * expose these — they live alongside the UI's sidebar config. Operators can
+ * override via `horizon.yaml.layers.<key>` (future Phase 7 admin).
+ *
+ * Keys match `Layer.name` in OAP's enum (UPPER_SNAKE_CASE).
+ */
+const LAYER_DEFAULTS: Record<string, { color: string; slots: LayerSlots; caps:
LayerCaps }> = {
+ GENERAL: {
+ color: 'var(--sw-accent)',
+ slots: { services: 'Services', instances: 'Instances', endpoints: 'API',
endpointDependency: 'API dependency' },
+ caps: {
+ overview: true, serviceMap: true, endpointDependency: true,
instanceTopology: true, processTopology: true,
+ dashboards: true, traces: true, logs: true, profiling: true, events:
true,
+ },
+ },
+ MESH: {
+ color: 'var(--sw-info)',
+ slots: { services: 'Services', instances: 'Sidecars', endpoints:
'Endpoints' },
+ caps: {
+ overview: true, serviceMap: true, endpointDependency: true,
instanceTopology: true,
+ dashboards: true, traces: true, logs: true, events: true,
+ },
+ },
+ MESH_CP: { color: 'var(--sw-info)', slots: { services: 'Control-plane
services' }, caps: { overview: true, dashboards: true } },
+ MESH_DP: { color: 'var(--sw-info)', slots: { services: 'Data-plane
services', instances: 'Sidecars' }, caps: { overview: true, dashboards: true,
instanceTopology: true } },
+ K8S: { color: 'var(--sw-purple)', slots: { services: 'Workloads', instances:
'Pods' }, caps: { overview: true, serviceMap: true, instanceTopology: true,
dashboards: true, events: true } },
+ K8S_SERVICE: { color: 'var(--sw-purple)', slots: { services: 'K8s services',
instances: 'Pods' }, caps: { overview: true, serviceMap: true,
instanceTopology: true, dashboards: true } },
+ BROWSER: { color: 'var(--sw-cyan)', slots: { services: 'Applications',
instances: 'Versions', endpoints: 'Pages' }, caps: { overview: true,
dashboards: true, traces: true, logs: true } },
+ MYSQL: { color: 'var(--sw-warn)', slots: { services: 'Instances' }, caps: {
overview: true, dashboards: true } },
+ POSTGRESQL: { color: 'var(--sw-warn)', slots: { services: 'Instances' },
caps: { overview: true, dashboards: true } },
+ ELASTICSEARCH: { color: 'var(--sw-warn)', slots: { services: 'Clusters',
instances: 'Nodes' }, caps: { overview: true, dashboards: true } },
+ REDIS: { color: 'var(--sw-warn)', slots: { services: 'Instances' }, caps: {
overview: true, dashboards: true } },
+ MONGODB: { color: 'var(--sw-warn)', slots: { services: 'Clusters',
instances: 'Nodes' }, caps: { overview: true, dashboards: true } },
+ CLICKHOUSE: { color: 'var(--sw-warn)', slots: { services: 'Services',
instances: 'Instances' }, caps: { overview: true, dashboards: true } },
+ KAFKA: { color: 'var(--sw-ok)', slots: { services: 'Clusters', instances:
'Brokers' }, caps: { overview: true, dashboards: true } },
+ PULSAR: { color: 'var(--sw-ok)', slots: { services: 'Clusters', instances:
'Brokers' }, caps: { overview: true, dashboards: true } },
+ ROCKETMQ: { color: 'var(--sw-ok)', slots: { services: 'Clusters', instances:
'Brokers', endpoints: 'Topics' }, caps: { overview: true, dashboards: true } },
+ RABBITMQ: { color: 'var(--sw-ok)', slots: { services: 'Clusters', instances:
'Nodes' }, caps: { overview: true, dashboards: true } },
+ ACTIVEMQ: { color: 'var(--sw-ok)', slots: { services: 'Clusters', instances:
'Brokers', endpoints: 'Destinations' }, caps: { overview: true, dashboards:
true } },
+ VIRTUAL_DATABASE: { color: 'var(--sw-warn)', slots: { services: 'Databases'
}, caps: { overview: true, dashboards: true } },
+ VIRTUAL_CACHE: { color: 'var(--sw-warn)', slots: { services: 'Caches' },
caps: { overview: true, dashboards: true } },
+ VIRTUAL_MQ: { color: 'var(--sw-ok)', slots: { services: 'Queues' }, caps: {
overview: true, dashboards: true } },
+ VIRTUAL_GENAI: { color: 'var(--sw-purple)', slots: { services: 'Providers',
instances: 'Models' }, caps: { overview: true, dashboards: true } },
+};
+
+const DEFAULT_FOR_UNKNOWN_LAYER = {
+ color: 'var(--sw-fg-2)',
+ slots: { services: 'Services' } as LayerSlots,
+ caps: { overview: true, dashboards: true } as LayerCaps,
+};
+
+function deriveLayer(
+ rawKey: string,
+ active: boolean,
+ level: number | null,
+ items: MenuRaw['items'],
+): LayerDef {
+ const item = items.find((i) => i.layer === rawKey);
+ const def = LAYER_DEFAULTS[rawKey] ?? DEFAULT_FOR_UNKNOWN_LAYER;
+ return {
+ key: rawKey.toLowerCase(),
+ name: item?.title?.trim() || rawKey.replace(/_/g, '
').toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase()),
+ color: def.color,
+ serviceCount: -1, // Phase 2.x will fold in `listServices(layer)` counts.
+ active,
+ level,
+ documentLink: item?.documentLink ?? undefined,
+ slots: def.slots,
+ caps: def.caps,
+ };
+}
+
+export function registerMenuRoute(app: FastifyInstance, deps: MenuRouteDeps):
void {
+ const auth = requireAuth(deps);
+ app.get('/api/menu', { preHandler: auth }, async (_req: FastifyRequest,
reply: FastifyReply) => {
+ const cfg = deps.config.current;
+ const statusUrl = cfg.oap.statusUrl;
+ try {
+ const raw = await graphqlPost<MenuRaw>(
+ { statusUrl, timeoutMs: cfg.oap.timeoutMs, fetch: deps.fetch },
+ MENU_QUERY,
+ );
+ const levelByLayer = new Map(raw.levels.map((l) => [l.layer, l.level]));
+ const allKeys = new Set<string>([
+ ...raw.layers,
+ ...raw.items.map((i) => i.layer),
+ ]);
+ const layers = [...allKeys]
+ .map((key) =>
+ deriveLayer(
+ key,
+ raw.layers.includes(key),
+ levelByLayer.has(key) ? (levelByLayer.get(key) ?? null) : null,
+ raw.items,
+ ),
+ )
+ .sort((a, b) => {
+ // Active layers first, then by name. UI re-sorts as needed.
+ if (a.active !== b.active) return a.active ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ });
+ const body: MenuResponse = {
+ layers,
+ generatedAt: Date.now(),
+ oap: { reachable: true, statusUrl },
+ };
+ return reply.send(body);
+ } catch (err) {
+ const body: MenuResponse = {
+ layers: [],
+ generatedAt: Date.now(),
+ oap: {
+ reachable: false,
+ statusUrl,
+ error: err instanceof Error ? err.message : String(err),
+ },
+ };
+ return reply.status(200).send(body); // soft-fail so the UI shows a
banner, not a 5xx
+ }
+ });
+}
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 6acae05..1c93364 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -21,6 +21,7 @@ import { AuditLogger } from './audit/logger.js';
import { registerAuthRoutes } from './auth/routes.js';
import { SessionStore } from './auth/sessions.js';
import { loadConfig, type ConfigSource } from './config/loader.js';
+import { registerMenuRoute } from './oap/menu-routes.js';
import { registerOapRoutes } from './oap/routes.js';
import { registerPreflightRoutes } from './oap/preflight-routes.js';
import { HttpError } from './errors.js';
@@ -53,6 +54,7 @@ await app.register(cookie);
app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body,
done) => done(null, body));
registerAuthRoutes(app, source, sessions, audit);
+registerMenuRoute(app, { config: source, sessions });
registerOapRoutes(app, { config: source, sessions, audit });
registerPreflightRoutes(app, { config: source, sessions });
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 4913ff5..c3e5294 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -16,6 +16,7 @@
*/
export * from './types.js';
+export type { LayerSlots, LayerCaps, LayerDef, MenuResponse } from './menu.js';
export {
RuntimeRuleClient,
type RuntimeRuleClientOptions,
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
new file mode 100644
index 0000000..640dbc4
--- /dev/null
+++ b/packages/api-client/src/menu.ts
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Wire types for `GET /api/menu`. The BFF aliases three OAP GraphQL queries
+ * (`listLayers`, `getMenuItems`, `listLayerLevels`) into a single roundtrip
+ * and stitches the result into the shape below — same as what the sidebar
+ * (`apps/ui/src/components/shell/layers.ts`) renders.
+ *
+ * `caps` flags reflect what the LAYER supports; the UI hides rows whose
+ * cap is false. `slots` carries per-layer term aliases (e.g. General's
+ * endpoint → "API"). Layer-level overrides (term aliases, menu mode) live
+ * in `horizon.yaml` and per-user state — the BFF merges all three sources.
+ */
+
+export interface LayerSlots {
+ /** Renamed service-equivalent (functions / workloads / clusters / apps /
databases / virtual service / …). */
+ services?: string;
+ /** Renamed instance-equivalent (versions / pods / brokers / sessions /
nodes / …). */
+ instances?: string;
+ /** Renamed endpoint-equivalent. "API" for General, "Topics" for MQ, "Pages"
for Browser. */
+ endpoints?: string;
+ /** Label for the endpoint-to-endpoint dependency feature. Defaults to
`${endpoints} dependency`. */
+ endpointDependency?: string;
+}
+
+export interface LayerCaps {
+ overview?: boolean;
+ serviceMap?: boolean;
+ endpointDependency?: boolean;
+ instanceTopology?: boolean;
+ processTopology?: boolean;
+ dashboards?: boolean;
+ traces?: boolean;
+ logs?: boolean;
+ profiling?: boolean;
+ events?: boolean;
+}
+
+export interface LayerDef {
+ key: string;
+ /** Display name from OAP `getMenuItems.title` (preserving casing). */
+ name: string;
+ /** Hex / CSS color from horizon-side defaults; OAP doesn't provide one. */
+ color: string;
+ /** From `listServices(layer)` count; -1 if the BFF couldn't reach OAP. */
+ serviceCount: number;
+ /** True iff OAP returned this layer in `listLayers` (services reporting). */
+ active: boolean;
+ /** Hierarchy level from `listLayerLevels`; null if not in the hierarchy
table. */
+ level: number | null;
+ /** External documentation link from `getMenuItems.documentLink`. */
+ documentLink?: string;
+ slots: LayerSlots;
+ caps: LayerCaps;
+}
+
+export interface MenuResponse {
+ layers: LayerDef[];
+ generatedAt: number;
+ /** Best-effort status of the upstream OAP query host. */
+ oap: { reachable: boolean; statusUrl: string; error?: string };
+}