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 };
+}

Reply via email to