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 4402793  menu: catalog order, legacy enum aliases, real service 
counts; sidebar shows only layers with services
4402793 is described below

commit 4402793bc64e1cb3c7b9c596ca5712a9ccf8a602
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 14:26:15 2026 +0800

    menu: catalog order, legacy enum aliases, real service counts; sidebar 
shows only layers with services
    
    BFF /api/menu:
    - Collapses legacy enum values into modern aliases (CACHE→VIRTUAL_CACHE,
      DATABASE→VIRTUAL_DATABASE, MQ→VIRTUAL_MQ, GENAI→VIRTUAL_GENAI). Removes
      the duplicate sidebar rows.
    - Preserves the order from getMenuItems so the sidebar follows the OAP
      menu catalog (mirrors menu.yaml) instead of alphabetising.
    - Fetches per-layer service counts via a single batched GraphQL request
      with aliased listServices(layer) — gives a real number per row.
    
    UI useLayers + sidebar:
    - availableLayers (serviceCount > 0) drives the sidebar; activeLayers
      (listLayers-only) stays around for the Setup page which shows every
      registered layer.
    - Layer rows render as widget-style rails with a prominent monospace
      count chip; the expanded row tints the chip with the accent.
    - Empty state ('no service reporting yet — set up a layer') links to /.
---
 apps/bff/src/oap/menu-routes.ts             | 114 ++++++++++++++++++++++------
 apps/ui/src/components/shell/AppSidebar.vue |  86 ++++++++++++++++++---
 apps/ui/src/composables/useLayers.ts        |  15 ++++
 3 files changed, 179 insertions(+), 36 deletions(-)

diff --git a/apps/bff/src/oap/menu-routes.ts b/apps/bff/src/oap/menu-routes.ts
index 46647b4..4d19cee 100644
--- a/apps/bff/src/oap/menu-routes.ts
+++ b/apps/bff/src/oap/menu-routes.ts
@@ -54,6 +54,21 @@ const MENU_QUERY = /* GraphQL */ `
   }
 `;
 
+/**
+ * Legacy enum values OAP keeps for backward compatibility — collapse to the
+ * modern equivalent so the sidebar shows one row per logical layer.
+ */
+const LAYER_ALIAS: Record<string, string> = {
+  CACHE: 'VIRTUAL_CACHE',
+  DATABASE: 'VIRTUAL_DATABASE',
+  MQ: 'VIRTUAL_MQ',
+  GENAI: 'VIRTUAL_GENAI',
+};
+
+function canonical(layer: string): string {
+  return LAYER_ALIAS[layer] ?? layer;
+}
+
 interface MenuRaw {
   layers: string[];
   items: Array<{
@@ -124,15 +139,16 @@ function deriveLayer(
   rawKey: string,
   active: boolean,
   level: number | null,
+  serviceCount: number,
   items: MenuRaw['items'],
 ): LayerDef {
-  const item = items.find((i) => i.layer === rawKey);
+  const item = items.find((i) => canonical(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.
+    serviceCount,
     active,
     level,
     documentLink: item?.documentLink ?? undefined,
@@ -141,35 +157,85 @@ function deriveLayer(
   };
 }
 
+/**
+ * Fetch per-layer service counts in a single GraphQL request with aliased
+ * `listServices(layer)` queries (one alias per active layer). Returns a
+ * map keyed by the layer's RAW (pre-canonical) name.
+ */
+async function fetchCountsForLayers(
+  layers: readonly string[],
+  opts: { statusUrl: string; timeoutMs: number; fetch?: FetchLike },
+): Promise<Map<string, number>> {
+  const map = new Map<string, number>();
+  if (layers.length === 0) return map;
+  // GraphQL aliases must be valid identifiers — index-keyed.
+  const aliased = layers
+    .map((l, i) => `_${i}: listServices(layer: ${JSON.stringify(l)}) { id }`)
+    .join('\n');
+  const query = `query HorizonCounts { ${aliased} }`;
+  try {
+    const data = await graphqlPostShim<Record<string, Array<{ id: string 
}>>>(opts, query);
+    layers.forEach((l, i) => map.set(l, (data[`_${i}`] ?? []).length));
+  } catch {
+    // Soft-fail: leave the map empty so deriveLayer falls back to -1.
+  }
+  return map;
+}
+
+// Local re-import to avoid a circular dep — graphqlPost is in the same dir.
+import { graphqlPost as graphqlPostShim } from './graphql-client.js';
+
 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;
+    const opts = { statusUrl, timeoutMs: cfg.oap.timeoutMs, fetch: deps.fetch 
};
     try {
-      const raw = await graphqlPost<MenuRaw>(
-        { statusUrl, timeoutMs: cfg.oap.timeoutMs, fetch: deps.fetch },
-        MENU_QUERY,
+      const raw = await graphqlPost<MenuRaw>(opts, MENU_QUERY);
+
+      // Active list collapsed by alias (CACHE → VIRTUAL_CACHE, etc.).
+      const activeCanonical = new Set(raw.layers.map(canonical));
+      const levelByCanonical = new Map(raw.levels.map((l) => 
[canonical(l.layer), l.level]));
+
+      // Service-count batch — uses the RAW layer names from OAP since the
+      // alias collapse is only a presentation concern.
+      const counts = await fetchCountsForLayers(raw.layers, opts);
+      const countByCanonical = new Map<string, number>();
+      for (const rawLayer of raw.layers) {
+        const key = canonical(rawLayer);
+        countByCanonical.set(key, (countByCanonical.get(key) ?? 0) + 
(counts.get(rawLayer) ?? 0));
+      }
+
+      // Catalog order = the order OAP returned `getMenuItems` (mirrors
+      // menu.yaml). Active-only keys not in the catalog get appended at
+      // the end so nothing disappears.
+      const ordered: string[] = [];
+      const seen = new Set<string>();
+      for (const item of raw.items) {
+        if (!item.layer) continue;
+        const k = canonical(item.layer);
+        if (seen.has(k)) continue;
+        seen.add(k);
+        ordered.push(k);
+      }
+      for (const rawLayer of raw.layers) {
+        const k = canonical(rawLayer);
+        if (seen.has(k)) continue;
+        seen.add(k);
+        ordered.push(k);
+      }
+
+      const layers = ordered.map((key) =>
+        deriveLayer(
+          key,
+          activeCanonical.has(key),
+          levelByCanonical.has(key) ? (levelByCanonical.get(key) ?? null) : 
null,
+          countByCanonical.get(key) ?? (activeCanonical.has(key) ? 0 : -1),
+          raw.items,
+        ),
       );
-      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(),
diff --git a/apps/ui/src/components/shell/AppSidebar.vue 
b/apps/ui/src/components/shell/AppSidebar.vue
index 77210e5..d09d05e 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -29,18 +29,17 @@ async function signOut(): Promise<void> {
   await router.push({ name: 'login' });
 }
 
-const { layers, oapReachable, oapError, hasTopology } = useLayers();
+const { availableLayers, oapReachable, oapError, hasTopology } = useLayers();
 
-// Default-open the first active layer once data arrives; user clicks
+// Default-open the first available layer once data arrives; user clicks
 // thereafter take over.
 const expandedLayer = ref<string | null>(null);
 let userTouched = false;
 watch(
-  layers,
+  availableLayers,
   (rows) => {
     if (userTouched || expandedLayer.value) return;
-    const first = rows.find((L) => L.active) ?? rows[0];
-    if (first) expandedLayer.value = first.key;
+    if (rows.length > 0) expandedLayer.value = rows[0].key;
   },
   { immediate: true },
 );
@@ -140,21 +139,32 @@ 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">
+          {{ availableLayers.length }} with services
+        </span>
       </div>
       <div v-if="!oapReachable && oapError" class="oap-banner" 
:title="oapError">
         OAP unreachable
       </div>
-      <template v-for="L in layers" :key="L.key">
+      <div v-else-if="availableLayers.length === 0" class="empty-layers">
+        no service reporting yet —
+        <RouterLink to="/" style="color: var(--sw-accent-2); text-decoration: 
none">
+          set up a layer
+        </RouterLink>
+      </div>
+      <template v-for="L in availableLayers" :key="L.key">
         <div
-          class="sw-nav-item"
-          :class="{ 'is-active': expandedLayer === L.key, 'is-inactive': 
!L.active }"
+          class="layer-row"
+          :class="{ 'is-active': expandedLayer === L.key }"
           @click="toggleLayer(L.key)"
         >
           <span class="layer-dot" :style="{ background: L.color }" />
-          <span :style="{ fontWeight: expandedLayer === L.key ? 600 : 500 
}">{{ L.name }}</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="layer-name" :style="{ fontWeight: expandedLayer === 
L.key ? 600 : 500 }">
+            {{ L.name }}
+          </span>
+          <span class="layer-count" :title="`${L.serviceCount} 
service${L.serviceCount === 1 ? '' : 's'} reporting`">
+            {{ L.serviceCount }}
+          </span>
           <span class="caret" :class="{ open: expandedLayer === L.key }">
             <Icon name="caret" :size="10" />
           </span>
@@ -318,6 +328,58 @@ const sections: NavSection[] = [
   margin-left: 2px;
   letter-spacing: 0.02em;
 }
+.layer-row {
+  display: flex;
+  align-items: center;
+  gap: 9px;
+  padding: 7px 10px;
+  border-radius: 6px;
+  color: var(--sw-fg-1);
+  font-size: 12px;
+  cursor: pointer;
+  user-select: none;
+  position: relative;
+}
+.layer-row:hover {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-0);
+}
+.layer-row.is-active {
+  background: var(--sw-bg-3);
+  color: var(--sw-fg-0);
+  box-shadow: inset 2px 0 0 var(--sw-accent);
+}
+.layer-row .layer-name {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.layer-row .layer-count {
+  font-family: var(--sw-mono);
+  font-size: 10.5px;
+  font-variant-numeric: tabular-nums;
+  color: var(--sw-fg-1);
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  padding: 1px 6px;
+  min-width: 24px;
+  text-align: center;
+}
+.layer-row.is-active .layer-count {
+  color: var(--sw-accent-2);
+  background: var(--sw-accent-soft);
+  border-color: var(--sw-accent-line);
+}
+.empty-layers {
+  margin: 4px 10px 8px;
+  padding: 6px 8px;
+  font-size: 10.5px;
+  color: var(--sw-fg-3);
+  font-style: italic;
+}
 .layer-dot {
   width: 7px;
   height: 7px;
diff --git a/apps/ui/src/composables/useLayers.ts 
b/apps/ui/src/composables/useLayers.ts
index 4e7aa80..e4b315d 100644
--- a/apps/ui/src/composables/useLayers.ts
+++ b/apps/ui/src/composables/useLayers.ts
@@ -38,7 +38,21 @@ export function useLayers() {
   });
 
   const layers = computed<LayerDef[]>(() => q.data.value?.layers ?? []);
+  /**
+   * Layers known to OAP (returned by `listLayers`). May include stale
+   * registry entries — receivers that ever ingested but currently have no
+   * services. Use this for the Setup page; users want to see and configure
+   * every layer they could enable.
+   */
   const activeLayers = computed<LayerDef[]>(() => layers.value.filter((L) => 
L.active));
+  /**
+   * Layers with at least one currently-reporting service (`listServices`
+   * count > 0). The sidebar uses this so the user doesn't see ghost
+   * entries that no longer carry data.
+   */
+  const availableLayers = computed<LayerDef[]>(() =>
+    layers.value.filter((L) => L.serviceCount > 0),
+  );
   const oapReachable = computed<boolean>(() => q.data.value?.oap.reachable ?? 
false);
   const oapError = computed<string | undefined>(() => q.data.value?.oap.error);
 
@@ -61,6 +75,7 @@ export function useLayers() {
     isError: q.isError,
     layers,
     activeLayers,
+    availableLayers,
     oapReachable,
     oapError,
     findLayer,

Reply via email to