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 6f04f3f  ui: live layer data via useLayers composable replacing static 
stub
6f04f3f is described below

commit 6f04f3fbec49a1d94ef4dab5e30625bc561f125a
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 14:08:38 2026 +0800

    ui: live layer data via useLayers composable replacing static stub
    
    - packages/api-client re-exports LayerDef / LayerSlots / LayerCaps /
      MenuResponse so both apps share the wire shape
    - apps/ui/composables/useLayers.ts: vue-query hook over /api/menu with
      30s staleTime, 60s refetchInterval; exposes layers, activeLayers,
      oapReachable, oapError, findLayer, hasTopology
    - AppSidebar consumes useLayers(); inactive layers render dim with a
      'no data' badge; an 'OAP unreachable' banner appears at the top of the
      Layers section when the BFF soft-failed
    - Static apps/ui/src/components/shell/layers.ts deleted; router no
      longer needs static layer metadata (placeholder titles fall back to
      the raw key humanised)
---
 apps/ui/src/api/client.ts                   |   9 ++
 apps/ui/src/components/shell/AppSidebar.vue |  56 +++++++--
 apps/ui/src/components/shell/layers.ts      | 183 ----------------------------
 apps/ui/src/composables/useLayers.ts        |  70 +++++++++++
 apps/ui/src/router/index.ts                 |  83 +++++--------
 5 files changed, 155 insertions(+), 246 deletions(-)

diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index fbc2427..39e7fc4 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -15,6 +15,10 @@
  * limitations under the License.
  */
 
+import type { MenuResponse } from '@skywalking-horizon-ui/api-client';
+
+export type { MenuResponse, LayerDef, LayerCaps, LayerSlots } from 
'@skywalking-horizon-ui/api-client';
+
 export interface MeResponse {
   username: string;
   roles: string[];
@@ -86,6 +90,11 @@ export class BffClient {
     return this.request<MeResponse>('GET', '/api/auth/me');
   }
 
+  // ── menu / layers ────────────────────────────────────────────────────
+  menu(): Promise<MenuResponse> {
+    return this.request<MenuResponse>('GET', '/api/menu');
+  }
+
   // ── cluster / preflight ──────────────────────────────────────────────
   preflight(): Promise<unknown> {
     return this.request('GET', '/api/preflight');
diff --git a/apps/ui/src/components/shell/AppSidebar.vue 
b/apps/ui/src/components/shell/AppSidebar.vue
index bc2d198..77210e5 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -15,12 +15,12 @@
   limitations under the License.
 -->
 <script setup lang="ts">
-import { ref } from 'vue';
+import { ref, watch } from 'vue';
 import { RouterLink, useRoute, useRouter } from 'vue-router';
 import Icon, { type IconName } from '@/components/icons/Icon.vue';
 import logoSw from '@/assets/icons/logo-sw.svg?raw';
 import { useAuthStore } from '@/stores/auth';
-import { LAYERS, hasTopology } from './layers';
+import { useLayers } from '@/composables/useLayers';
 
 const auth = useAuthStore();
 const router = useRouter();
@@ -29,7 +29,25 @@ async function signOut(): Promise<void> {
   await router.push({ name: 'login' });
 }
 
-const expandedLayer = ref<string | null>('general');
+const { layers, oapReachable, oapError, hasTopology } = useLayers();
+
+// Default-open the first active layer once data arrives; user clicks
+// thereafter take over.
+const expandedLayer = ref<string | null>(null);
+let userTouched = false;
+watch(
+  layers,
+  (rows) => {
+    if (userTouched || expandedLayer.value) return;
+    const first = rows.find((L) => L.active) ?? rows[0];
+    if (first) expandedLayer.value = first.key;
+  },
+  { immediate: true },
+);
+function toggleLayer(key: string): void {
+  userTouched = true;
+  expandedLayer.value = expandedLayer.value === key ? null : key;
+}
 
 const route = useRoute();
 function isActive(path: string): boolean {
@@ -122,17 +140,21 @@ const sections: NavSection[] = [
 
       <div class="sw-nav-section sw-row" style="justify-content: 
space-between">
         <span>Layers</span>
-        <span style="color: var(--sw-fg-3); font-weight: 400">{{ LAYERS.length 
}} layers</span>
+        <span style="color: var(--sw-fg-3); font-weight: 400">{{ layers.length 
}} layers</span>
       </div>
-      <template v-for="L in LAYERS" :key="L.key">
+      <div v-if="!oapReachable && oapError" class="oap-banner" 
:title="oapError">
+        OAP unreachable
+      </div>
+      <template v-for="L in layers" :key="L.key">
         <div
           class="sw-nav-item"
-          :class="{ 'is-active': expandedLayer === L.key }"
-          @click="expandedLayer = expandedLayer === L.key ? null : L.key"
+          :class="{ 'is-active': expandedLayer === L.key, 'is-inactive': 
!L.active }"
+          @click="toggleLayer(L.key)"
         >
           <span class="layer-dot" :style="{ background: L.color }" />
           <span :style="{ fontWeight: expandedLayer === L.key ? 600 : 500 
}">{{ L.name }}</span>
-          <span class="sw-badge" style="margin-left: auto">{{ L.serviceCount 
}}</span>
+          <span v-if="L.serviceCount >= 0" class="sw-badge" 
style="margin-left: auto">{{ L.serviceCount }}</span>
+          <span v-else-if="!L.active" class="sw-badge" style="margin-left: 
auto">no data</span>
           <span class="caret" :class="{ open: expandedLayer === L.key }">
             <Icon name="caret" :size="10" />
           </span>
@@ -174,7 +196,7 @@ const sections: NavSection[] = [
           </RouterLink>
 
           <RouterLink
-            v-if="hasTopology(L.caps)"
+            v-if="hasTopology(L)"
             :to="`/layer/${L.key}/topology`"
             class="sw-nav-item"
             :class="{ 'is-active': isActive(`/layer/${L.key}/topology`) }"
@@ -328,4 +350,20 @@ const sections: NavSection[] = [
 .sw-nav-item.lead {
   margin-top: 4px;
 }
+.sw-nav-item.is-inactive .layer-dot {
+  opacity: 0.4;
+}
+.sw-nav-item.is-inactive > span:nth-child(2) {
+  color: var(--sw-fg-3);
+}
+.oap-banner {
+  margin: 4px 10px 8px;
+  padding: 6px 8px;
+  font-size: 10.5px;
+  color: #f87171;
+  background: var(--sw-err-soft);
+  border: 1px solid rgba(239, 68, 68, 0.3);
+  border-radius: 4px;
+  letter-spacing: 0.02em;
+}
 </style>
diff --git a/apps/ui/src/components/shell/layers.ts 
b/apps/ui/src/components/shell/layers.ts
deleted file mode 100644
index 9bc9f5a..0000000
--- a/apps/ui/src/components/shell/layers.ts
+++ /dev/null
@@ -1,183 +0,0 @@
-/*
- * 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.
- */
-
-// Phase 2 will replace this static stub with real getMenuItems / listLayers
-// data + per-layer overrides from the BFF dashboard-template bundle. The
-// shape is what the sidebar and router will consume regardless.
-//
-// Aliases (`slots.*`) are a GLOBAL term presenter — the same alias is used
-// in the sidebar, breadcrumbs, table headers, dashboard titles, drill-down
-// labels, etc. "Endpoint" → "API" (General), "API → API" (the resulting
-// endpoint-relation feature) → "API dependency".
-//
-// `caps` is a pickable feature set per layer. Setting `caps.services =
-// false` hides the services slot entirely (e.g. a layer with only a single
-// virtual service can disable `instances` and `endpoints` but keep
-// `services`).
-//
-// Term aliases AND cap toggles are both editable from the Phase 7 admin UI
-// (Layer config) and persisted in the BFF JSON store. The values below are
-// the shipped defaults for each known layer.
-
-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 — e.g. "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 {
-  /** Per-layer landing page with KPIs / constellation / health. */
-  overview?: boolean;
-  /** Service map (service topology). */
-  serviceMap?: boolean;
-  /** Endpoint-to-endpoint dependency (a.k.a. "API dependency" when aliased). 
*/
-  endpointDependency?: boolean;
-  /** Instance / pod / broker topology. */
-  instanceTopology?: boolean;
-  /** Process topology (eBPF / rover sourced). */
-  processTopology?: boolean;
-  /** Per-scope dashboards (Service / Instance / Endpoint / Glance). */
-  dashboards?: boolean;
-  /** Trace explorer (SkyWalking native or Zipkin sources). */
-  traces?: boolean;
-  /** Log explorer. */
-  logs?: boolean;
-  /** Any of the profiling subsystems (sampled / async-profiler / eBPF / 
pprof). */
-  profiling?: boolean;
-  /** Event timeline. */
-  events?: boolean;
-}
-
-/** Convenience: `caps.serviceMap || caps.instanceTopology || 
caps.processTopology`. */
-export function hasTopology(caps: LayerCaps): boolean {
-  return Boolean(caps.serviceMap || caps.instanceTopology || 
caps.processTopology);
-}
-
-export interface LayerDef {
-  key: string;
-  name: string;
-  /** CSS color (token var or hex). */
-  color: string;
-  /** Stub count — Phase 2 pulls the real number from listServices(layer). */
-  serviceCount: number;
-  slots: LayerSlots;
-  caps: LayerCaps;
-}
-
-export const LAYERS: readonly LayerDef[] = [
-  {
-    key: 'general',
-    name: 'General Service',
-    color: 'var(--sw-accent)',
-    serviceCount: 84,
-    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,
-    },
-  },
-  {
-    key: 'mesh',
-    name: 'Service Mesh',
-    color: 'var(--sw-info)',
-    serviceCount: 22,
-    slots: { services: 'Services', instances: 'Sidecars', endpoints: 
'Endpoints' },
-    caps: {
-      overview: true,
-      serviceMap: true,
-      endpointDependency: true,
-      instanceTopology: true,
-      dashboards: true,
-      traces: true,
-      logs: true,
-      events: true,
-    },
-  },
-  {
-    key: 'k8s',
-    name: 'Kubernetes',
-    color: 'var(--sw-purple)',
-    serviceCount: 62,
-    slots: { services: 'Workloads', instances: 'Pods' },
-    caps: { overview: true, serviceMap: true, instanceTopology: true, 
dashboards: true, events: true },
-  },
-  {
-    key: 'rum',
-    name: 'Browser (RUM)',
-    color: 'var(--sw-cyan)',
-    serviceCount: 8,
-    slots: { services: 'Applications', instances: 'Sessions', endpoints: 
'Pages' },
-    caps: { overview: true, dashboards: true, traces: true, logs: true },
-  },
-  {
-    key: 'mq',
-    name: 'Virtual MQ',
-    color: 'var(--sw-ok)',
-    serviceCount: 6,
-    slots: { services: 'Clusters', instances: 'Brokers', endpoints: 'Topics' },
-    caps: { overview: true, dashboards: true },
-  },
-  {
-    key: 'db',
-    name: 'Virtual Database',
-    color: 'var(--sw-warn)',
-    serviceCount: 6,
-    slots: { services: 'Databases', instances: 'Nodes' },
-    caps: { overview: true, dashboards: true },
-  },
-  {
-    key: 'otel',
-    name: 'OpenTelemetry',
-    color: 'var(--sw-purple)',
-    serviceCount: 18,
-    slots: { services: 'Services', instances: 'Instances', endpoints: 
'Endpoints' },
-    caps: {
-      overview: true,
-      serviceMap: true,
-      endpointDependency: true,
-      dashboards: true,
-      traces: true,
-      logs: true,
-    },
-  },
-  {
-    key: 'faas',
-    name: 'FaaS',
-    color: 'var(--sw-err)',
-    serviceCount: 3,
-    slots: { services: 'Functions', instances: 'Versions', endpoints: 
'Invocations' },
-    caps: { overview: true, dashboards: true, traces: true },
-  },
-];
-
-export function findLayer(key: string | undefined): LayerDef | undefined {
-  if (!key) return undefined;
-  return LAYERS.find((L) => L.key === key);
-}
diff --git a/apps/ui/src/composables/useLayers.ts 
b/apps/ui/src/composables/useLayers.ts
new file mode 100644
index 0000000..4e7aa80
--- /dev/null
+++ b/apps/ui/src/composables/useLayers.ts
@@ -0,0 +1,70 @@
+/*
+ * 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 { computed } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import type { LayerDef } from '@skywalking-horizon-ui/api-client';
+import { bffClient } from '@/api/client';
+
+/**
+ * Live OAP-driven layer + menu state. Fed by `GET /api/menu`. Refetches on
+ * window focus + every 60s.
+ *
+ * `data.value` is `null` while loading. `oapReachable` is false when the
+ * BFF returned a soft-fail body (OAP down); the UI should render a banner
+ * but otherwise keep the empty layer list rendered without crashing.
+ */
+export function useLayers() {
+  const q = useQuery({
+    queryKey: ['menu'],
+    queryFn: () => bffClient.menu(),
+    staleTime: 30_000,
+    refetchInterval: 60_000,
+    refetchOnWindowFocus: true,
+  });
+
+  const layers = computed<LayerDef[]>(() => q.data.value?.layers ?? []);
+  const activeLayers = computed<LayerDef[]>(() => layers.value.filter((L) => 
L.active));
+  const oapReachable = computed<boolean>(() => q.data.value?.oap.reachable ?? 
false);
+  const oapError = computed<string | undefined>(() => q.data.value?.oap.error);
+
+  function findLayer(key: string | undefined): LayerDef | undefined {
+    if (!key) return undefined;
+    return layers.value.find((L) => L.key === key);
+  }
+
+  /**
+   * `caps.serviceMap || caps.instanceTopology || caps.processTopology`.
+   * Pulled out of `layers.ts` so the sidebar can stay UI-only.
+   */
+  function hasTopology(L: LayerDef | undefined): boolean {
+    if (!L) return false;
+    return Boolean(L.caps.serviceMap || L.caps.instanceTopology || 
L.caps.processTopology);
+  }
+
+  return {
+    isLoading: q.isLoading,
+    isError: q.isError,
+    layers,
+    activeLayers,
+    oapReachable,
+    oapError,
+    findLayer,
+    hasTopology,
+    refetch: q.refetch,
+  };
+}
diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts
index 094512d..79fdcd5 100644
--- a/apps/ui/src/router/index.ts
+++ b/apps/ui/src/router/index.ts
@@ -15,77 +15,52 @@
  * limitations under the License.
  */
 import { createRouter, createWebHistory, type RouteRecordRaw } from 
'vue-router';
-import { findLayer } from '@/components/shell/layers';
 import { useAuthStore } from '@/stores/auth';
 
 const placeholder = () => import('@/views/PlaceholderView.vue');
 
-// Build a per-layer route bundle from the layer feature config. Each cap that
-// the layer declares becomes a sub-route under /layer/:layerKey/...
-// Unknown layer keys fall back to a generic "not found" via the catch-all.
+function humanKey(k: string): string {
+  return k.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+// Layer sub-routes are open-ended — any `:layerKey` is accepted. The real
+// per-layer view (Phase 2.6+) will read live cap data via useLayers() and
+// render a 'doesn't expose' note when the cap is off. The placeholder here
+// only needs the raw key + the feature label.
 function layerSubRoutes(): RouteRecordRaw[] {
   const sub: RouteRecordRaw[] = [];
-
   sub.push({
     path: 'layer/:layerKey',
     component: placeholder,
-    props: (r) => {
-      const L = findLayer(String(r.params.layerKey));
-      return {
-        title: L ? `${L.name} · Overview` : `Layer · ${r.params.layerKey}`,
-        phase: 'Phase 2',
-        note: L
-          ? 'Per-layer landing: KPIs, throughput, service constellation, 
services table.'
-          : 'Unknown layer key.',
-      };
-    },
+    props: (r) => ({
+      title: `${humanKey(String(r.params.layerKey))} · Overview`,
+      phase: 'Phase 2',
+      note: 'Per-layer landing: KPIs, throughput, service constellation, 
services table.',
+    }),
   });
 
-  for (const slot of ['services', 'instances', 'endpoints'] as const) {
-    sub.push({
-      path: `layer/:layerKey/${slot}`,
-      component: placeholder,
-      props: (r) => {
-        const L = findLayer(String(r.params.layerKey));
-        const label = L?.slots[slot] ?? slot;
-        return {
-          title: L ? `${L.name} · ${label}` : `Layer · ${slot}`,
-          phase: 'Phase 2 / 3',
-        };
-      },
-    });
-  }
-
-  const layerFeatures: { path: string; label: string; phase: string; 
capCheck?: (caps: NonNullable<ReturnType<typeof findLayer>>['caps']) => boolean 
}[] = [
-    {
-      path: 'topology',
-      label: 'Topology',
-      phase: 'Phase 4',
-      capCheck: (c) => Boolean(c.serviceMap || c.instanceTopology || 
c.processTopology),
-    },
-    { path: 'dependency', label: 'API dependency', phase: 'Phase 4', capCheck: 
(c) => Boolean(c.endpointDependency) },
-    { path: 'dashboards', label: 'Dashboards', phase: 'Phase 3', capCheck: (c) 
=> Boolean(c.dashboards) },
-    { path: 'traces', label: 'Traces', phase: 'Phase 5', capCheck: (c) => 
Boolean(c.traces) },
-    { path: 'logs', label: 'Logs', phase: 'Phase 5', capCheck: (c) => 
Boolean(c.logs) },
-    { path: 'profiling', label: 'Profiling', phase: 'Phase 8', capCheck: (c) 
=> Boolean(c.profiling) },
-    { path: 'events', label: 'Events', phase: 'Phase 5', capCheck: (c) => 
Boolean(c.events) },
+  const features: { path: string; label: string; phase: string }[] = [
+    { path: 'services', label: 'Services', phase: 'Phase 2 / 3' },
+    { path: 'instances', label: 'Instances', phase: 'Phase 2 / 3' },
+    { path: 'endpoints', label: 'Endpoints', phase: 'Phase 2 / 3' },
+    { path: 'topology', label: 'Topology', phase: 'Phase 4' },
+    { path: 'dependency', label: 'API dependency', phase: 'Phase 4' },
+    { path: 'dashboards', label: 'Dashboards', phase: 'Phase 3' },
+    { path: 'traces', label: 'Traces', phase: 'Phase 5' },
+    { path: 'logs', label: 'Logs', phase: 'Phase 5' },
+    { path: 'profiling', label: 'Profiling', phase: 'Phase 8' },
+    { path: 'events', label: 'Events', phase: 'Phase 5' },
   ];
-  for (const f of layerFeatures) {
+  for (const f of features) {
     sub.push({
       path: `layer/:layerKey/${f.path}`,
       component: placeholder,
-      props: (r) => {
-        const L = findLayer(String(r.params.layerKey));
-        const supported = L && (!f.capCheck || f.capCheck(L.caps));
-        return {
-          title: L ? `${L.name} · ${f.label}` : `Layer · ${f.label}`,
-          phase: f.phase,
-          note: L && !supported ? `${L.name} doesn't expose 
${f.label.toLowerCase()}.` : undefined,
-        };
-      },
+      props: (r) => ({
+        title: `${humanKey(String(r.params.layerKey))} · ${f.label}`,
+        phase: f.phase,
+      }),
     });
   }
-
   return sub;
 }
 

Reply via email to