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

commit 42897f1dac0810dd169455f21cfedf311c53fbfe
Author: Wu Sheng <[email protected]>
AuthorDate: Thu May 14 17:37:37 2026 +0800

    ui: topology rework + Istio rename + virtual-mq normal-flag fix
    
    Topology view (LayerServiceMapView.vue + topology-routes.ts):
    - Drop heaviest-path overlay: legend caption, node halo, edge stroke
      width/opacity, side-panel "main path" tags, and the sort tiebreaker
      it fed. Every edge reads as visually peer now.
    - Animated directional flow on every edge via `stroke-dashoffset`
      scrolling source → target at 1.2s/cycle; selected edges brighten.
    - New in-box focus picker: searchable multi-select popover with
      group-prefixed (`<group>::`) section headers. Replaces the plain
      <select>. The "Hops" control hides when focus = all services
      (BFF already caps the seed at 30 there).
    - BFF: `?service=` accepts comma-separated names/ids so multi-seed
      rides the existing query param. Unmatched entries reported back
      inline instead of failing the whole request.
    
    Service-name parsing — new apps/ui/src/utils/serviceName.ts splits
    OAP's `<group>::<base>` (e.g. `agent::rating`). Applied at every
    service-name display point so the raw `::` syntax never bleeds into
    the UI:
    - Topology canvas node label = base only; group surfaces as accent
      chip in the right-sidebar tags
    - Layer service picker rows = [GROUP chip] base
    - LayerShell Switch button = same chip + base
    
    Mesh → Istio rename across the bundled templates:
    - MESH alias: Service Mesh → Istio Managed SVCs
    - MESH_CP alias: Mesh Control Plane → Istio Control Plane
    - MESH_DP alias: Mesh Data Plane → Istio Data Plane
    - overviews/mesh.json: title, description, every widget title + tip
      swept (Active Istio services / Istio RPM / Istio service topology /
      Istio data-plane).
    
    Other:
    - Default dashboard / landing / topology / instance / endpoint /
      dependency time window 15min → 60min. The old default was too
      narrow for sparse-traffic layers (VIRTUAL_MQ, AWS_*) — empty cards
      for what is actually live data.
    - Dashboard route: always look up the service in `listServices` to
      ride the correct `normal` flag through to the MQE entity. Fixes
      the empty-Virtual-MQ-dashboard bug — VIRTUAL_MQ / VIRTUAL_DATABASE
      / VIRTUAL_CACHE / AWS_* services are `normal: false` and the
      previous code defaulted to `true`, so every MQE returned null.
    - /api/menu now carries `layer.normal` sampled from the first service
      of each layer so the UI + future routes can pivot scope without a
      re-query (LayerDef.normal in @skywalking-horizon-ui/api-client).
    - Sidebar: drop the per-row service-count prefix chip.
    - LayerShell: topology route declares `meta.ownsServiceSelector: true`
      so the header service picker hides on the topology view (the
      in-box focus picker is the right surface for service scoping).
---
 apps/bff/src/bundled_templates/layers/mesh.json    |   2 +-
 apps/bff/src/bundled_templates/layers/mesh_cp.json |   2 +-
 apps/bff/src/bundled_templates/layers/mesh_dp.json |   2 +-
 apps/bff/src/bundled_templates/overviews/mesh.json |  18 +-
 apps/bff/src/dashboard/routes.ts                   |  59 ++--
 apps/bff/src/oap/endpoint-dependency-routes.ts     |   2 +-
 apps/bff/src/oap/endpoint-routes.ts                |   2 +-
 apps/bff/src/oap/instance-routes.ts                |   2 +-
 apps/bff/src/oap/landing-routes.ts                 |   2 +-
 apps/bff/src/oap/menu-routes.ts                    |  38 ++-
 apps/bff/src/oap/topology-routes.ts                |  23 +-
 apps/ui/src/components/shell/AppSidebar.vue        |  27 --
 apps/ui/src/router/index.ts                        |  10 +-
 apps/ui/src/utils/serviceName.ts                   |  60 ++++
 apps/ui/src/views/layer/LayerServiceMapView.vue    | 341 +++++++++++++--------
 apps/ui/src/views/layer/LayerServiceSelector.vue   |  25 +-
 apps/ui/src/views/layer/LayerShell.vue             |  50 ++-
 packages/api-client/src/menu.ts                    |   7 +
 18 files changed, 454 insertions(+), 218 deletions(-)

diff --git a/apps/bff/src/bundled_templates/layers/mesh.json 
b/apps/bff/src/bundled_templates/layers/mesh.json
index d7b34b1..f0cea8b 100644
--- a/apps/bff/src/bundled_templates/layers/mesh.json
+++ b/apps/bff/src/bundled_templates/layers/mesh.json
@@ -1,6 +1,6 @@
 {
   "key": "MESH",
-  "alias": "Service Mesh",
+  "alias": "Istio Managed SVCs",
   "color": "var(--sw-info)",
   "documentLink": 
"https://skywalking.apache.org/docs/main/next/en/setup/envoy/als_setting/";,
   "aliases": {
diff --git a/apps/bff/src/bundled_templates/layers/mesh_cp.json 
b/apps/bff/src/bundled_templates/layers/mesh_cp.json
index 15e03e5..f7272b7 100644
--- a/apps/bff/src/bundled_templates/layers/mesh_cp.json
+++ b/apps/bff/src/bundled_templates/layers/mesh_cp.json
@@ -1,6 +1,6 @@
 {
   "key": "MESH_CP",
-  "alias": "Mesh Control Plane",
+  "alias": "Istio Control Plane",
   "color": "var(--sw-info)",
   "documentLink": 
"https://skywalking.apache.org/docs/main/next/en/setup/istio/readme/";,
   "aliases": { "services": "Control Planes" },
diff --git a/apps/bff/src/bundled_templates/layers/mesh_dp.json 
b/apps/bff/src/bundled_templates/layers/mesh_dp.json
index 1a56ef6..1a7cc0a 100644
--- a/apps/bff/src/bundled_templates/layers/mesh_dp.json
+++ b/apps/bff/src/bundled_templates/layers/mesh_dp.json
@@ -1,6 +1,6 @@
 {
   "key": "MESH_DP",
-  "alias": "Mesh Data Plane",
+  "alias": "Istio Data Plane",
   "color": "var(--sw-info)",
   "documentLink": 
"https://skywalking.apache.org/docs/main/next/en/setup/envoy/metrics_service_setting/";,
   "aliases": { "services": "Sidecar services", "instances": "Sidecars" },
diff --git a/apps/bff/src/bundled_templates/overviews/mesh.json 
b/apps/bff/src/bundled_templates/overviews/mesh.json
index 700f7f2..a9c5ae9 100644
--- a/apps/bff/src/bundled_templates/overviews/mesh.json
+++ b/apps/bff/src/bundled_templates/overviews/mesh.json
@@ -1,12 +1,12 @@
 {
   "id": "mesh",
-  "title": "Mesh service",
-  "description": "Service-mesh overview — control-plane + data-plane signals 
with the K8s service-count cross-referenced.",
+  "title": "Istio Managed SVCs",
+  "description": "Istio-mesh overview — control-plane + data-plane signals 
with the K8s service-count cross-referenced.",
   "widgets": [
     {
       "id": "m_count",
-      "title": "Active mesh services",
-      "tip": "Services routed through the mesh data-plane.",
+      "title": "Active Istio services",
+      "tip": "Services routed through the Istio data-plane.",
       "layer": "MESH",
       "type": "service-count",
       "span": 3,
@@ -14,8 +14,8 @@
     },
     {
       "id": "m_rpm",
-      "title": "Mesh RPM",
-      "tip": "Mesh-wide requests per minute.",
+      "title": "Istio RPM",
+      "tip": "Istio-mesh-wide requests per minute.",
       "layer": "MESH",
       "type": "metric",
       "mqe": "service_mesh_cpm",
@@ -27,7 +27,7 @@
     {
       "id": "m_p99",
       "title": "P99 latency",
-      "tip": "99th-percentile response time across mesh-routed traffic.",
+      "tip": "99th-percentile response time across Istio-routed traffic.",
       "layer": "MESH",
       "type": "metric",
       "mqe": "service_mesh_percentile{p='99'}",
@@ -71,8 +71,8 @@
     },
     {
       "id": "m_topo",
-      "title": "Mesh service topology",
-      "tip": "Live service map for the mesh data-plane.",
+      "title": "Istio service topology",
+      "tip": "Live service map for the Istio data-plane.",
       "layer": "MESH",
       "type": "topology",
       "span": 12,
diff --git a/apps/bff/src/dashboard/routes.ts b/apps/bff/src/dashboard/routes.ts
index a09636b..e83c985 100644
--- a/apps/bff/src/dashboard/routes.ts
+++ b/apps/bff/src/dashboard/routes.ts
@@ -145,7 +145,7 @@ const LIST_FIRST_SERVICE = /* GraphQL */ `
   }
 `;
 
-const DEFAULT_WINDOW_MIN = 15;
+const DEFAULT_WINDOW_MIN = 60;
 
 interface Window {
   start: string;
@@ -349,33 +349,48 @@ export function registerDashboardRoute(app: 
FastifyInstance, deps: DashboardRout
         reachable: true,
       };
 
-      // Step 1 — resolve service if not provided.
-      if (!serviceName) {
-        try {
-          const data = await graphqlPost<{ services: Array<{ id: string; name: 
string; normal: boolean }> }>(
-            opts,
-            LIST_FIRST_SERVICE,
-            { layer: layerKey.toUpperCase() },
-          );
-          const first = data.services?.[0];
-          if (first) {
-            serviceName = first.name;
-            normal = first.normal !== false;
-            baseResp.service = serviceName;
-          } else {
+      // Step 1 — resolve service. We always probe `listServices` so the
+      // correct `normal` flag rides along with the service entity. Some
+      // layers (VIRTUAL_MQ, VIRTUAL_DATABASE, VIRTUAL_CACHE, AWS_*) use
+      // `normal: false` services — without this look-up every MQE on
+      // those layers comes back null because the entity-scope filter
+      // doesn't match the data dimension OAP stored them under.
+      try {
+        const data = await graphqlPost<{ services: Array<{ id: string; name: 
string; normal: boolean }> }>(
+          opts,
+          LIST_FIRST_SERVICE,
+          { layer: layerKey.toUpperCase() },
+        );
+        const all = data.services ?? [];
+        let picked: { id: string; name: string; normal: boolean } | undefined;
+        if (serviceName) {
+          picked = all.find((s) => s.name === serviceName) ?? all.find((s) => 
s.id === serviceName);
+          if (!picked) {
+            return reply.send({
+              ...baseResp,
+              service: serviceName,
+              widgets: widgets.map((w) => ({ id: w.id, error: `service 
"${serviceName}" not in layer` })),
+            });
+          }
+        } else {
+          picked = all[0];
+          if (!picked) {
             return reply.send({
               ...baseResp,
               widgets: widgets.map((w) => ({ id: w.id, error: 'no service in 
layer' })),
             });
           }
-        } catch (err) {
-          return reply.send({
-            ...baseResp,
-            reachable: false,
-            error: err instanceof Error ? err.message : String(err),
-            widgets: widgets.map((w) => ({ id: w.id, error: 'oap unreachable' 
})),
-          });
         }
+        serviceName = picked.name;
+        normal = picked.normal !== false;
+        baseResp.service = serviceName;
+      } catch (err) {
+        return reply.send({
+          ...baseResp,
+          reachable: false,
+          error: err instanceof Error ? err.message : String(err),
+          widgets: widgets.map((w) => ({ id: w.id, error: 'oap unreachable' 
})),
+        });
       }
 
       // Step 2 — batch all widget × expression queries into one GraphQL trip.
diff --git a/apps/bff/src/oap/endpoint-dependency-routes.ts 
b/apps/bff/src/oap/endpoint-dependency-routes.ts
index ec7d186..34963e6 100644
--- a/apps/bff/src/oap/endpoint-dependency-routes.ts
+++ b/apps/bff/src/oap/endpoint-dependency-routes.ts
@@ -93,7 +93,7 @@ const ENDPOINT_DEPENDENCY = /* GraphQL */ `
   }
 `;
 
-const DEFAULT_WINDOW_MIN = 15;
+const DEFAULT_WINDOW_MIN = 60;
 function fmtMinute(d: Date): string {
   const yyyy = d.getUTCFullYear();
   const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
diff --git a/apps/bff/src/oap/endpoint-routes.ts 
b/apps/bff/src/oap/endpoint-routes.ts
index 6d6213e..eb80273 100644
--- a/apps/bff/src/oap/endpoint-routes.ts
+++ b/apps/bff/src/oap/endpoint-routes.ts
@@ -65,7 +65,7 @@ const FIND_ENDPOINTS = /* GraphQL */ `
   }
 `;
 
-const DEFAULT_WINDOW_MIN = 15;
+const DEFAULT_WINDOW_MIN = 60;
 
 function fmtMinute(d: Date): string {
   const yyyy = d.getUTCFullYear();
diff --git a/apps/bff/src/oap/instance-routes.ts 
b/apps/bff/src/oap/instance-routes.ts
index 8b62d71..bb17821 100644
--- a/apps/bff/src/oap/instance-routes.ts
+++ b/apps/bff/src/oap/instance-routes.ts
@@ -79,7 +79,7 @@ const LIST_INSTANCES = /* GraphQL */ `
   }
 `;
 
-const DEFAULT_WINDOW_MIN = 15;
+const DEFAULT_WINDOW_MIN = 60;
 
 function fmtMinute(d: Date): string {
   const yyyy = d.getUTCFullYear();
diff --git a/apps/bff/src/oap/landing-routes.ts 
b/apps/bff/src/oap/landing-routes.ts
index 1badbad..06f40d0 100644
--- a/apps/bff/src/oap/landing-routes.ts
+++ b/apps/bff/src/oap/landing-routes.ts
@@ -87,7 +87,7 @@ const LIST_SERVICES_QUERY = /* GraphQL */ `
   }
 `;
 
-const DEFAULT_WINDOW_MIN = 15;
+const DEFAULT_WINDOW_MIN = 60;
 const SERVICE_QUERY_CAP = 25;
 
 const aggSchema = z.enum(['sum', 'avg']);
diff --git a/apps/bff/src/oap/menu-routes.ts b/apps/bff/src/oap/menu-routes.ts
index 29fef8a..99c9f94 100644
--- a/apps/bff/src/oap/menu-routes.ts
+++ b/apps/bff/src/oap/menu-routes.ts
@@ -168,6 +168,7 @@ function deriveLayer(
   active: boolean,
   level: number | null,
   serviceCount: number,
+  normal: boolean | null,
   items: MenuRaw['items'],
 ): LayerDef {
   const item = items.find((i) => canonical(i.layer) === rawKey);
@@ -183,6 +184,7 @@ function deriveLayer(
       serviceCount,
       active,
       level,
+      normal,
       documentLink: tpl.documentLink ?? item?.documentLink ?? undefined,
       slots: tpl.slots,
       caps: componentsToCaps(tpl.components),
@@ -199,6 +201,7 @@ function deriveLayer(
     serviceCount,
     active,
     level,
+    normal,
     documentLink: item?.documentLink ?? undefined,
     slots: def.slots,
     caps: def.caps,
@@ -213,19 +216,29 @@ function deriveLayer(
 async function fetchCountsForLayers(
   layers: readonly string[],
   opts: GraphqlOptions,
-): Promise<Map<string, number>> {
-  const map = new Map<string, number>();
+): Promise<Map<string, { count: number; normal: boolean | null }>> {
+  const map = new Map<string, { count: number; normal: boolean | null }>();
   if (layers.length === 0) return map;
-  // GraphQL aliases must be valid identifiers — index-keyed.
+  // GraphQL aliases must be valid identifiers — index-keyed. Also pull
+  // the `normal` flag off the first service so callers can pivot the
+  // MQE entity scope (`{ normal: true|false }`) without a separate
+  // listServices roundtrip on every dashboard hit.
   const aliased = layers
-    .map((l, i) => `_${i}: listServices(layer: ${JSON.stringify(l)}) { id }`)
+    .map((l, i) => `_${i}: listServices(layer: ${JSON.stringify(l)}) { id 
normal }`)
     .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));
+    const data = await graphqlPostShim<
+      Record<string, Array<{ id: string; normal?: boolean | null }>>
+    >(opts, query);
+    layers.forEach((l, i) => {
+      const rows = data[`_${i}`] ?? [];
+      const first = rows[0];
+      const normal = first ? (first.normal === false ? false : first.normal 
=== true ? true : null) : null;
+      map.set(l, { count: rows.length, normal });
+    });
   } catch {
-    // Soft-fail: leave the map empty so deriveLayer falls back to -1.
+    // Soft-fail: leave the map empty so deriveLayer falls back to -1 / null.
   }
   return map;
 }
@@ -250,9 +263,17 @@ export function registerMenuRoute(app: FastifyInstance, 
deps: MenuRouteDeps): vo
       // alias collapse is only a presentation concern.
       const counts = await fetchCountsForLayers(raw.layers, opts);
       const countByCanonical = new Map<string, number>();
+      const normalByCanonical = new Map<string, boolean | null>();
       for (const rawLayer of raw.layers) {
         const key = canonical(rawLayer);
-        countByCanonical.set(key, (countByCanonical.get(key) ?? 0) + 
(counts.get(rawLayer) ?? 0));
+        const c = counts.get(rawLayer);
+        countByCanonical.set(key, (countByCanonical.get(key) ?? 0) + (c?.count 
?? 0));
+        // First non-null `normal` value wins for the canonical key —
+        // raw layers that fold into one canonical (e.g. mesh / mesh_cp)
+        // share the same `normal` in practice, so collisions are safe.
+        if (c?.normal !== undefined && c.normal !== null && 
!normalByCanonical.has(key)) {
+          normalByCanonical.set(key, c.normal);
+        }
       }
 
       // Catalog order = the order OAP returned `getMenuItems` (mirrors
@@ -280,6 +301,7 @@ export function registerMenuRoute(app: FastifyInstance, 
deps: MenuRouteDeps): vo
           activeCanonical.has(key),
           levelByCanonical.has(key) ? (levelByCanonical.get(key) ?? null) : 
null,
           countByCanonical.get(key) ?? (activeCanonical.has(key) ? 0 : -1),
+          normalByCanonical.get(key) ?? null,
           raw.items,
         ),
       );
diff --git a/apps/bff/src/oap/topology-routes.ts 
b/apps/bff/src/oap/topology-routes.ts
index 7ab9aa4..78e50e7 100644
--- a/apps/bff/src/oap/topology-routes.ts
+++ b/apps/bff/src/oap/topology-routes.ts
@@ -90,7 +90,7 @@ const LIST_SERVICES_FOR_RESOLVE = /* GraphQL */ `
   }
 `;
 
-const DEFAULT_WINDOW_MIN = 15;
+const DEFAULT_WINDOW_MIN = 60;
 function fmtMinute(d: Date): string {
   const yyyy = d.getUTCFullYear();
   const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
@@ -284,16 +284,23 @@ export function registerTopologyRoute(app: 
FastifyInstance, deps: TopologyRouteD
           knownServices.set(s.id, { id: s.id, name: s.name, normal: s.normal 
!== false });
         }
         if (serviceArg) {
-          const match =
-            data.services.find((s) => s.id === serviceArg) ??
-            data.services.find((s) => s.name === serviceArg) ??
-            null;
-          if (!match) {
+          // `service` accepts a comma-separated list of names/ids so
+          // the SPA can multi-seed without a separate query param. Any
+          // entry that doesn't resolve is reported back individually
+          // instead of failing the whole request.
+          const wants = serviceArg.split(',').map((s) => 
s.trim()).filter(Boolean);
+          const matches = wants.map((w) =>
+            data.services.find((s) => s.id === w) ??
+            data.services.find((s) => s.name === w) ??
+            null,
+          );
+          const missing = wants.filter((_, i) => matches[i] === null);
+          if (matches.every((m) => m === null)) {
             return reply.send(
-              emptyResponse(layerKey, serviceArg, depth, topoCfg, true, 
'service not found'),
+              emptyResponse(layerKey, serviceArg, depth, topoCfg, true, 
`service${wants.length === 1 ? '' : 's'} not found: ${missing.join(', ')}`),
             );
           }
-          seedIds = [match.id];
+          seedIds = matches.filter((m): m is { id: string; name: string; 
normal?: boolean | null } => m !== null).map((m) => m.id);
         } else {
           seedIds = data.services.slice(0, 30).map((s) => s.id);
         }
diff --git a/apps/ui/src/components/shell/AppSidebar.vue 
b/apps/ui/src/components/shell/AppSidebar.vue
index 699a3dd..113c4e3 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -185,9 +185,6 @@ const sections: NavSection[] = [
           :class="{ 'is-active': isActive(`/layer/${L.key}`) }"
         >
           <span class="layer-dot" :style="{ background: L.color }" />
-          <span class="layer-count" :title="`${L.serviceCount} 
service${L.serviceCount === 1 ? '' : 's'} reporting`">
-            {{ L.serviceCount }}
-          </span>
           <span class="layer-name">{{ L.name }}</span>
         </RouterLink>
 
@@ -199,9 +196,6 @@ const sections: NavSection[] = [
           @click="toggleLayer(L.key)"
         >
           <span class="layer-dot" :style="{ background: L.color }" />
-          <span class="layer-count" :title="`${L.serviceCount} 
service${L.serviceCount === 1 ? '' : 's'} reporting`">
-            {{ L.serviceCount }}
-          </span>
           <span class="layer-name" :style="{ fontWeight: expandedLayer === 
L.key ? 600 : 500 }">
             {{ L.name }}
           </span>
@@ -390,27 +384,6 @@ const sections: NavSection[] = [
   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 4px;
-  /* Fixed width so every row's name starts at the same column even when
-     counts swing wildly (1 ↔ 1000+). The chip is right-aligned inside
-     so the digit always sits flush against the name. */
-  width: 34px;
-  flex: 0 0 34px;
-  text-align: right;
-}
-.layer-row.is-active .layer-count {
-  color: var(--sw-accent-2);
-  background: var(--sw-accent-soft);
-  border-color: var(--sw-accent-line);
-}
 .layer-row.direct {
   text-decoration: none;
 }
diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts
index 1abdf0b..502dff2 100644
--- a/apps/ui/src/router/index.ts
+++ b/apps/ui/src/router/index.ts
@@ -49,7 +49,15 @@ function layerRoute(): RouteRecordRaw {
       { path: 'service', component: () => 
import('@/views/layer/LayerDashboardsView.vue') },
       { path: 'instance', component: () => 
import('@/views/layer/LayerDashboardsView.vue') },
       { path: 'endpoint', component: () => 
import('@/views/layer/LayerDashboardsView.vue') },
-      { path: 'topology', component: () => 
import('@/views/layer/LayerServiceMapView.vue') },
+      {
+        path: 'topology',
+        component: () => import('@/views/layer/LayerServiceMapView.vue'),
+        // The topology page ships its own in-box service-focus selector
+        // (the map is layer-wide by default). Declaring it here keeps
+        // the LayerShell's header picker hidden for this route — no
+        // route-string sniffing in the shell.
+        meta: { ownsServiceSelector: true },
+      },
       { path: 'dependency', component: () => 
import('@/views/layer/LayerEndpointDependencyView.vue') },
       { path: 'trace', component: () => 
import('@/views/layer/LayerTracesView.vue') },
       { path: 'logs', component: () => 
import('@/views/layer/LayerLogsView.vue') },
diff --git a/apps/ui/src/utils/serviceName.ts b/apps/ui/src/utils/serviceName.ts
new file mode 100644
index 0000000..0f5e051
--- /dev/null
+++ b/apps/ui/src/utils/serviceName.ts
@@ -0,0 +1,60 @@
+/*
+ * 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.
+ */
+
+/**
+ * Service-name group parsing. OAP encodes a group prefix with `::` —
+ * e.g. `agent::songs`, `mesh::checkout`. The prefix is a deployment
+ * grouping (k8s namespace, fleet, source) that operators want surfaced
+ * as a separate visual element rather than crowded into the service
+ * name itself.
+ *
+ * Rendering rule applied everywhere across the UI:
+ *   - Service lists / pickers / KPI strips → render `<group-chip> <base-name>`
+ *     so the group reads as a category tag and the eye lands on the base name
+ *   - Topology nodes → render `base` only, with the group available in hover
+ *     / detail panels (the SVG label area is too tight for both)
+ *
+ * Multiple `::` segments collapse to: first segment = group, remainder = base
+ * (so `eu::prod::checkout` → group: `eu`, base: `prod::checkout`).
+ */
+export interface ParsedServiceName {
+  /** Group prefix when the raw name contains `::`. */
+  group: string | null;
+  /** Service name with the `<group>::` prefix stripped; equal to `raw`
+   *  when there is no group. */
+  base: string;
+  /** Original full name (echoed so callers don't have to keep it around). */
+  raw: string;
+}
+
+export function parseServiceName(raw: string | null | undefined): 
ParsedServiceName {
+  const r = raw ?? '';
+  const idx = r.indexOf('::');
+  if (idx <= 0) return { group: null, base: r, raw: r };
+  return { group: r.slice(0, idx), base: r.slice(idx + 2), raw: r };
+}
+
+/** Display helper — base only. Use in tight spots (graph nodes, chips). */
+export function serviceBaseName(raw: string | null | undefined): string {
+  return parseServiceName(raw).base;
+}
+
+/** Display helper — group only (null when no group). Renderers should
+ *  treat null as "no group chip". */
+export function serviceGroupName(raw: string | null | undefined): string | 
null {
+  return parseServiceName(raw).group;
+}
diff --git a/apps/ui/src/views/layer/LayerServiceMapView.vue 
b/apps/ui/src/views/layer/LayerServiceMapView.vue
index 0f284c9..4774c81 100644
--- a/apps/ui/src/views/layer/LayerServiceMapView.vue
+++ b/apps/ui/src/views/layer/LayerServiceMapView.vue
@@ -61,18 +61,17 @@ import type {
   TopologyNode,
 } from '@/api/client';
 import { useLayerTopology } from '@/composables/useLayerTopology';
-import { useSelectedService } from '@/composables/useSelectedService';
 import { useLayerLanding } from '@/composables/useLayerLanding';
 import { useLayers } from '@/composables/useLayers';
 import { useSetupStore } from '@/stores/setup';
 import { fmtMetric } from '@/utils/formatters';
+import { parseServiceName, serviceBaseName } from '@/utils/serviceName';
 import Sparkline from '@/components/charts/Sparkline.vue';
 import { isUserNode } from '@/composables/useTopologyIcons';
 
 const route = useRoute();
 const router = useRouter();
 const layerKey = computed(() => String(route.params.layerKey ?? ''));
-const { selectedId, setSelected: setSelectedService } = useSelectedService();
 
 const { layers } = useLayers();
 const layer = computed<LayerDef | null>(
@@ -90,22 +89,50 @@ const safeCfg = computed(() => {
   }).landing;
 });
 const landing = useLayerLanding(safeLayer, safeCfg);
-const serviceName = computed<string | null>(() => {
-  const rows = landing.data.value?.sampledRows ?? landing.rows.value ?? [];
-  const match = rows.find((r) => r.serviceId === selectedId.value);
-  return match?.serviceName ?? null;
-});
 const landingRows = computed(() => landing.data.value?.sampledRows ?? 
landing.rows.value ?? []);
-watch(
-  landingRows,
-  (rows) => {
-    if (selectedId.value) return;
-    const first = rows[0];
-    if (first) setSelectedService(first.serviceId);
-  },
-  { immediate: true },
+
+// Focus-service is local to the topology view (NOT the header's
+// `useSelectedService` — the topology map is layer-wide by default).
+//   - empty array = no focus, BFF seeds from up-to-30 services in the
+//     layer for a layer-overview graph
+//   - one or more entries = comma-joined and passed as `?service=` so
+//     the BFF seeds BFS from each selected service (multi-select)
+const focusServiceNames = ref<string[]>([]);
+const focusSearch = ref<string>('');
+const focusPickerOpen = ref(false);
+function toggleFocusPicker(): void { focusPickerOpen.value = 
!focusPickerOpen.value; }
+function toggleService(name: string): void {
+  const i = focusServiceNames.value.indexOf(name);
+  if (i >= 0) focusServiceNames.value.splice(i, 1);
+  else focusServiceNames.value.push(name);
+}
+function clearFocus(): void { focusServiceNames.value = []; 
focusPickerOpen.value = false; }
+const serviceName = computed<string | null>(() =>
+  focusServiceNames.value.length === 0 ? null : 
focusServiceNames.value.join(','),
 );
 
+// Service-list rows grouped by `<group>::` prefix so the search panel
+// can render "agent" / "mesh" / "" sections.
+interface GroupedRow { group: string | null; name: string; id: string }
+const groupedRows = computed<Map<string, GroupedRow[]>>(() => {
+  const map = new Map<string, GroupedRow[]>();
+  const term = focusSearch.value.trim().toLowerCase();
+  for (const r of landingRows.value) {
+    const { group, base } = parseServiceName(r.serviceName);
+    if (term && !r.serviceName.toLowerCase().includes(term)) continue;
+    const key = group ?? '';
+    if (!map.has(key)) map.set(key, []);
+    map.get(key)!.push({ group, name: base, id: r.serviceId });
+  }
+  return map;
+});
+
+// Defensive truncate for long node labels — preserves the head + an
+// ellipsis so cluster IDs that share a long prefix still distinguish.
+function truncateLabel(s: string, n: number): string {
+  return s.length > n ? s.slice(0, n - 2) + '…' : s;
+}
+
 const depth = ref<number>(2);
 const { nodes, calls, isLoading, isFetching, data, refetch } = 
useLayerTopology(
   layerKey,
@@ -288,74 +315,9 @@ const layoutNodes = computed<LayoutNode[]>(() => {
   return all.map((n) => ({ ...n, layerIdx: layerOf.get(n.id)! }));
 });
 
-// ── Heaviest-path. Walk from the busiest entry; at each step pick the
-// outgoing edge with the highest server-cpm (preferred) or client-cpm
-// (fallback). The order of preference is operator-controlled via the
-// node ordering in `linkServerMetrics` / `linkClientMetrics`.
-function edgeWeight(c: TopologyCall): number {
-  const s = edgeVal(c, 'server', lineServerDef.value);
-  if (s !== null) return s;
-  const cl = edgeVal(c, 'client', lineClientDef.value);
-  if (cl !== null) return cl;
-  return 0;
-}
-const heaviestEdges = computed<Set<string>>(() => {
-  const out = new Set<string>();
-  const callsList = calls.value;
-  if (callsList.length === 0) return out;
-  const byId = new Map(layoutNodes.value.map((n) => [n.id, n]));
-  const outBy = new Map<string, TopologyCall[]>();
-  for (const c of callsList) {
-    if (!outBy.has(c.source)) outBy.set(c.source, []);
-    outBy.get(c.source)!.push(c);
-  }
-  const roots = layoutNodes.value.filter((n) => n.layerIdx === 0);
-  function rootScore(n: LayoutNode): number {
-    const own = nodeVal(n, centerDef.value);
-    if (own !== null) return own;
-    const outs = outBy.get(n.id) ?? [];
-    let best = 0;
-    for (const c of outs) {
-      const t = byId.get(c.target);
-      if (t) best = Math.max(best, nodeVal(t, centerDef.value) ?? 0, 
edgeWeight(c));
-    }
-    return best;
-  }
-  const sortedRoots = [...roots].sort((a, b) => rootScore(b) - rootScore(a));
-  const start = sortedRoots[0];
-  if (!start) return out;
-  let cursor: LayoutNode | undefined = start;
-  const seen = new Set<string>();
-  while (cursor && !seen.has(cursor.id)) {
-    seen.add(cursor.id);
-    const outs = outBy.get(cursor.id) ?? [];
-    if (outs.length === 0) break;
-    let best: TopologyCall | null = null;
-    let bestScore = -Infinity;
-    for (const c of outs) {
-      const score = edgeWeight(c);
-      if (score > bestScore) {
-        bestScore = score;
-        best = c;
-      }
-    }
-    if (!best) break;
-    out.add(best.id);
-    cursor = byId.get(best.target);
-  }
-  return out;
-});
-const heaviestNodes = computed<Set<string>>(() => {
-  const set = new Set<string>();
-  const byId = new Map(calls.value.map((c) => [c.id, c]));
-  for (const id of heaviestEdges.value) {
-    const c = byId.get(id);
-    if (!c) continue;
-    set.add(c.source);
-    set.add(c.target);
-  }
-  return set;
-});
+// (Heaviest-path overlay removed — every edge now reads as equally
+// important. The line is constant-weight; direction is conveyed by an
+// animated dashed flow on every edge.)
 
 const NODES_PER_LAYER = 12;
 interface LayerColumn {
@@ -371,18 +333,18 @@ const layerColumns = computed<LayerColumn[]>(() => {
     byLayer.get(n.layerIdx)!.push(n);
   }
   const indices = [...byLayer.keys()].sort((a, b) => a - b);
-  const heavy = heaviestNodes.value;
   return indices.map((i) => {
+    // Per-column sort: busiest by `center` metric first. No heaviest-path
+    // boost — every node is treated as a peer; overflow is purely by
+    // metric value (so the visible 12 are the loudest, not the ones on
+    // a synthetic critical path).
     const list = byLayer.get(i)!.slice().sort((a, b) => {
-      const hA = heavy.has(a.id) ? 1 : 0;
-      const hB = heavy.has(b.id) ? 1 : 0;
-      if (hA !== hB) return hB - hA;
       return (nodeVal(b, centerDef.value) ?? 0) - (nodeVal(a, centerDef.value) 
?? 0);
     });
     const keep: LayoutNode[] = [];
     const overflow: LayoutNode[] = [];
     for (const n of list) {
-      if (keep.length < NODES_PER_LAYER || heavy.has(n.id)) keep.push(n);
+      if (keep.length < NODES_PER_LAYER) keep.push(n);
       else overflow.push(n);
     }
     const label = i === 0 ? 'L0 · Entry' : `L${i} · Tier ${i}`;
@@ -850,12 +812,67 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
     <header class="sm-toolbar sw-card">
       <div class="left">
         <span class="kicker">Topology</span>
-        <span v-if="serviceName" class="for-svc">centred on <b>{{ serviceName 
}}</b></span>
-        <span v-else class="for-svc">layer overview</span>
+        <span v-if="focusServiceNames.length === 0" class="for-svc">layer 
overview · all services</span>
+        <span v-else class="for-svc">
+          focused on
+          <b>{{ focusServiceNames.length === 1 ? 
serviceBaseName(focusServiceNames[0]) : `${focusServiceNames.length} services` 
}}</b>
+        </span>
         <span v-if="isFetching" class="hint">refreshing…</span>
       </div>
       <div class="right">
-        <label class="depth-pick">
+        <!-- Focus picker — lives INSIDE the topology box so the
+             header service picker stays out of the layer-wide map.
+             Supports multi-select + search. Closes by clicking outside
+             the chips (via the wrapper @click.stop). -->
+        <div class="focus-wrap" @click.stop>
+          <button class="focus-btn sw-btn small" type="button" 
@click="toggleFocusPicker">
+            <span class="focus-btn-label">
+              {{ focusServiceNames.length === 0 ? 'All services' : 
focusServiceNames.length + ' selected' }}
+            </span>
+            <span class="caret" :class="{ open: focusPickerOpen }">▾</span>
+          </button>
+          <div v-if="focusPickerOpen" class="focus-pop sw-card">
+            <input
+              v-model="focusSearch"
+              class="focus-search"
+              type="text"
+              placeholder="Search services…"
+              autofocus
+            />
+            <div class="focus-list">
+              <button
+                class="focus-row clear"
+                :class="{ selected: focusServiceNames.length === 0 }"
+                type="button"
+                @click="clearFocus"
+              >
+                <span class="focus-check">{{ focusServiceNames.length === 0 ? 
'●' : '○' }}</span>
+                <span class="focus-name">All services</span>
+                <span class="focus-aside">{{ landingRows.length }} total</span>
+              </button>
+              <template v-for="[gkey, rows] in groupedRows" :key="gkey">
+                <div v-if="gkey" class="focus-group-head">{{ gkey }}</div>
+                <button
+                  v-for="r in rows"
+                  :key="r.id"
+                  class="focus-row"
+                  :class="{ selected: focusServiceNames.includes((r.group ? 
r.group + '::' : '') + r.name) }"
+                  type="button"
+                  @click="toggleService((r.group ? r.group + '::' : '') + 
r.name)"
+                >
+                  <span class="focus-check">{{ 
focusServiceNames.includes((r.group ? r.group + '::' : '') + r.name) ? '●' : 
'○' }}</span>
+                  <span class="focus-name">{{ r.name }}</span>
+                </button>
+              </template>
+              <div v-if="groupedRows.size === 0" class="focus-empty">no 
matches</div>
+            </div>
+          </div>
+        </div>
+        <!-- Depth is only meaningful when a focus seed is picked —
+             "All services" already seeds from up to 30 layer services
+             so a BFS-depth control would either be redundant or
+             explode the graph. -->
+        <label v-if="focusServiceNames.length > 0" class="depth-pick">
           <span>Depth</span>
           <select v-model.number="depth">
             <option :value="1">1 hop</option>
@@ -928,36 +945,40 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
                 stroke-width="14"
                 style="cursor: pointer"
               />
+              <!-- Base edge line. Uniform width across the canvas now
+                   that heaviest-path is gone — every edge is visually
+                   peer; selection brightens. -->
               <path
                 :d="callPathD(c)"
                 fill="none"
                 :stroke="selectedCallId === c.id ? 'var(--sw-accent-2)' : 
'var(--sw-accent)'"
-                :stroke-width="selectedCallId === c.id ? 3.4 : 
heaviestEdges.has(c.id) ? 2.8 : 1.4"
-                :opacity="selectedCallId === c.id ? 1 : 
heaviestEdges.has(c.id) ? 0.95 : 0.6"
+                :stroke-width="selectedCallId === c.id ? 3.2 : 1.8"
+                :opacity="selectedCallId === c.id ? 1 : 0.7"
                 stroke-linecap="round"
                 style="pointer-events: none"
               />
-              <!-- Animated traffic dots — only on heavy / selected
-                   edges so the canvas doesn't shimmer everywhere.
-                   Mirrors the polished linear-chain design's "live
-                   flow" suggestion. -->
-              <template v-if="heaviestEdges.has(c.id) || selectedCallId === 
c.id">
-                <circle
-                  v-for="off in [0, 0.5, 1.0]"
-                  :key="off"
-                  r="2.2"
-                  :fill="selectedCallId === c.id ? 'var(--sw-accent-2)' : 
'var(--sw-accent)'"
-                  opacity="0.85"
-                  style="pointer-events: none"
-                >
-                  <animateMotion
-                    :dur="`${2.4 + (off * 0.4)}s`"
-                    :begin="`${off}s`"
-                    repeatCount="indefinite"
-                    :path="callPathD(c)"
-                  />
-                </circle>
-              </template>
+              <!-- Direction overlay: a dashed stroke that scrolls along
+                   the path from source → target. Same stroke colour but
+                   higher-frequency dashes so the motion reads even on
+                   dense graphs without competing with the base line. -->
+              <path
+                :d="callPathD(c)"
+                fill="none"
+                :stroke="selectedCallId === c.id ? 'var(--sw-accent-2)' : 
'var(--sw-accent)'"
+                :stroke-width="selectedCallId === c.id ? 3.2 : 1.8"
+                stroke-linecap="round"
+                stroke-dasharray="6 10"
+                opacity="0.9"
+                style="pointer-events: none"
+              >
+                <animate
+                  attributeName="stroke-dashoffset"
+                  from="16"
+                  to="0"
+                  dur="1.2s"
+                  repeatCount="indefinite"
+                />
+              </path>
               <!-- Edge metric chip — sits on the line midpoint with a
                    pill background. Compact by design (edge metrics
                    aren't the headline signal; they ride alongside the
@@ -979,14 +1000,14 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
                     height="20"
                     rx="10"
                     fill="var(--sw-bg-1)"
-                    :stroke="selectedCallId === c.id ? 'var(--sw-accent-2)' : 
heaviestEdges.has(c.id) ? 'var(--sw-accent)' : 'var(--sw-line-2)'"
+                    :stroke="selectedCallId === c.id ? 'var(--sw-accent-2)' : 
'var(--sw-line-2)'"
                     :stroke-width="selectedCallId === c.id ? 1.4 : 1"
                   />
                   <text
                     x="38"
                     y="14"
                     text-anchor="middle"
-                    :fill="selectedCallId === c.id ? 'var(--sw-accent-2)' : 
heaviestEdges.has(c.id) ? 'var(--sw-accent-2)' : 'var(--sw-fg-1)'"
+                    :fill="selectedCallId === c.id ? 'var(--sw-accent-2)' : 
'var(--sw-fg-1)'"
                     font-size="11"
                     font-family="var(--sw-mono)"
                     font-weight="700"
@@ -1031,13 +1052,6 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
                   opacity="0.7"
                 />
               </template>
-              <!-- Heavy-path halo when not selected. -->
-              <circle
-                v-else-if="heaviestNodes.has(n.id)"
-                r="50"
-                fill="var(--sw-accent)"
-                opacity="0.10"
-              />
               <!-- Outer ring (health). Dashed for client / external
                    to signal "untraced" (no agent here). Solid stroke
                    for real services. -->
@@ -1123,6 +1137,10 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
               <!-- Name below the node. Slightly larger now that the
                    circle radius is smaller — keeps the label as the
                    readable anchor for the node. -->
+              <!-- Node label = base name only. The group prefix
+                   (`<group>::base`) appears as a chip in the right
+                   sidebar; jamming it into the canvas label competes
+                   with the metric line below. -->
               <text
                 text-anchor="middle"
                 y="58"
@@ -1131,7 +1149,7 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
                 font-family="var(--sw-mono)"
                 :font-weight="selectedNodeId === n.id ? 700 : 600"
               >
-                {{ n.name.length > 22 ? n.name.slice(0, 20) + '…' : n.name }}
+                {{ truncateLabel(serviceBaseName(n.name), 22) }}
               </text>
               <!-- Metric line. Operator-configured `center` metric
                    in the ring colour; `secondary` next to it muted.
@@ -1185,9 +1203,7 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
           <div class="lg-row">
             <span class="lg-swatch" style="background: var(--sw-accent)" />
             <span>Calls</span>
-            <span class="lg-aside">
-              thicker = heaviest (by {{ lineServerDef?.label ?? 'server' 
}}<template v-if="lineClientDef"> · falls back to {{ lineClientDef.label 
}}</template>)
-            </span>
+            <span class="lg-aside">direction shown by flow animation</span>
           </div>
         </div>
         <div v-if="elidedTotal > 0" class="cap-chip">
@@ -1208,9 +1224,9 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
           <div class="sp-id">
             <div class="sp-mono">{{ selectedNode.name }}</div>
             <div class="sp-tags">
+              <span v-if="parseServiceName(selectedNode.name).group" 
class="sw-tag accent">{{ parseServiceName(selectedNode.name).group }}</span>
               <span v-for="l in selectedNode.layers" :key="l" 
class="sw-tag">{{ l }}</span>
               <span v-if="!selectedNode.isReal" class="sw-tag">virtual</span>
-              <span v-if="heaviestNodes.has(selectedNode.id)" class="sw-tag 
accent">main path</span>
             </div>
           </div>
           <button class="sw-btn small" type="button" @click="selectedNodeId = 
null">×</button>
@@ -1269,7 +1285,6 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
             </div>
             <div class="sp-tags">
               <span class="sw-tag">{{ selectedCall.detectPoints.join(' · ') || 
'relation' }}</span>
-              <span v-if="heaviestEdges.has(selectedCall.id)" class="sw-tag 
accent">main path</span>
             </div>
           </div>
           <button class="sw-btn small" type="button" @click="selectedCallId = 
null">×</button>
@@ -1465,6 +1480,74 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
   font: inherit;
   font-size: 11px;
 }
+/* Focus selector — opens a search-driven multi-select panel anchored
+   to the button. Wide enough to read group + name in one row even on
+   long k8s service names. */
+.focus-wrap { position: relative; }
+.focus-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 160px;
+  justify-content: space-between;
+}
+.focus-btn .caret { font-size: 9px; color: var(--sw-fg-3); transition: 
transform 0.12s; }
+.focus-btn .caret.open { transform: rotate(180deg); }
+.focus-pop {
+  position: absolute;
+  top: calc(100% + 6px);
+  right: 0;
+  width: 360px;
+  max-height: 380px;
+  display: flex;
+  flex-direction: column;
+  padding: 8px;
+  z-index: 30;
+}
+.focus-search {
+  height: 32px;
+  padding: 0 10px;
+  margin-bottom: 6px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 6px;
+  color: var(--sw-fg-0);
+  font: inherit;
+  font-size: 13px;
+  outline: none;
+}
+.focus-search:focus { border-color: var(--sw-accent-line); }
+.focus-list { overflow-y: auto; flex: 1 1 auto; }
+.focus-group-head {
+  font-size: 10.5px;
+  font-weight: 600;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  color: var(--sw-fg-3);
+  padding: 6px 8px 4px;
+}
+.focus-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+  padding: 6px 8px;
+  background: transparent;
+  border: 0;
+  border-radius: 4px;
+  color: var(--sw-fg-1);
+  font: inherit;
+  font-size: 12px;
+  text-align: left;
+  cursor: pointer;
+}
+.focus-row:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+.focus-row.selected { color: var(--sw-accent-2); }
+.focus-row .focus-check { width: 12px; text-align: center; color: 
var(--sw-accent); }
+.focus-row .focus-name { flex: 1; font-family: var(--sw-mono); }
+.focus-row .focus-aside { font-size: 10.5px; color: var(--sw-fg-3); }
+.focus-row.clear { border-bottom: 1px dashed var(--sw-line); padding-bottom: 
8px; margin-bottom: 4px; }
+.focus-empty { padding: 16px; text-align: center; color: var(--sw-fg-3); 
font-size: 11.5px; }
 .banner.err {
   padding: 8px 12px;
   background: var(--sw-err-soft);
diff --git a/apps/ui/src/views/layer/LayerServiceSelector.vue 
b/apps/ui/src/views/layer/LayerServiceSelector.vue
index a1f1106..4e0dd2b 100644
--- a/apps/ui/src/views/layer/LayerServiceSelector.vue
+++ b/apps/ui/src/views/layer/LayerServiceSelector.vue
@@ -28,6 +28,7 @@ import type { LandingColumn, LandingServiceRow } from 
'@skywalking-horizon-ui/ap
 import { metricMeta } from '@/composables/metricCatalog';
 import { statusForMetrics, thresholdColor } from '@/composables/metricColor';
 import { fmtMetric } from '@/utils/formatters';
+import { parseServiceName } from '@/utils/serviceName';
 
 const props = withDefaults(
   defineProps<{
@@ -101,7 +102,8 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
         >
           <td class="svc-col" :title="row.serviceName">
             <span class="pulse" :style="{ background: 
colorForStatus(statusForMetrics(row.metrics)) }" />
-            <span class="name-text">{{ row.shortName || row.serviceName 
}}</span>
+            <span v-if="parseServiceName(row.serviceName).group" 
class="group-chip">{{ parseServiceName(row.serviceName).group }}</span>
+            <span class="name-text">{{ row.shortName || 
parseServiceName(row.serviceName).base }}</span>
           </td>
           <td
             v-for="c in columns"
@@ -247,6 +249,27 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
   color: var(--sw-fg-0);
   font-size: 11.5px;
 }
+/* Group prefix from OAP's `<group>::<base>` naming — surfaced as a
+   compact tag so the base name is the first thing the eye lands on.
+   Trims `agent::rating` → [agent] rating, etc. */
+.group-chip {
+  display: inline-block;
+  margin-right: 6px;
+  padding: 1px 6px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  font-size: 9.5px;
+  font-weight: 600;
+  letter-spacing: 0.04em;
+  color: var(--sw-fg-2);
+  text-transform: uppercase;
+  vertical-align: middle;
+}
+.row.active .group-chip {
+  color: var(--sw-accent-2);
+  border-color: var(--sw-accent-line);
+}
 .pager {
   display: flex;
   align-items: center;
diff --git a/apps/ui/src/views/layer/LayerShell.vue 
b/apps/ui/src/views/layer/LayerShell.vue
index 76b0aba..9c379e6 100644
--- a/apps/ui/src/views/layer/LayerShell.vue
+++ b/apps/ui/src/views/layer/LayerShell.vue
@@ -39,6 +39,7 @@ import { useLayers } from '@/composables/useLayers';
 import { useSelectedService } from '@/composables/useSelectedService';
 import { useSetupStore } from '@/stores/setup';
 import { fmtMetric } from '@/utils/formatters';
+import { parseServiceName } from '@/utils/serviceName';
 
 const route = useRoute();
 const layerKey = computed(() => String(route.params.layerKey ?? ''));
@@ -84,9 +85,23 @@ const selectedRow = computed(
     sampledServices.value[0] ??
     null,
 );
-const selectedName = computed(
-  () => selectedRow.value?.serviceName ?? (sampledServices.value.length > 0 ? 
'pick a service' : '—'),
-);
+const selectedParsed = computed(() => 
parseServiceName(selectedRow.value?.serviceName));
+const selectedGroup = computed(() => selectedParsed.value.group);
+// Switch-button label — base name only when the service has a group
+// prefix (`<group>::<base>` from OAP). The group is rendered as a
+// separate chip next to the caret so the user reads it without the
+// raw `::` syntax bleeding into the UI.
+const selectedName = computed(() => {
+  if (selectedRow.value) return selectedParsed.value.base;
+  return sampledServices.value.length > 0 ? 'pick a service' : '—';
+});
+
+// Routes can declare `meta: { ownsServiceSelector: true }` to opt out
+// of the shell-level service picker. Topology is the canonical example
+// (its in-box focus selector is the right place to scope the map);
+// future component-driven views (dashboards with their own filters,
+// say) can flip the same meta flag without touching this file.
+const viewOwnsServiceSelector = computed(() => 
Boolean(route.meta?.ownsServiceSelector));
 
 // Picker toggle state. Lives at the shell level so the header's Switch
 // button and the picker section render against the same state.
@@ -234,8 +249,13 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
       <!-- Row 2: Switch button + selected-service KPIs. Distinct from
            the layer aggregates above — these are the picked service's
            per-row metric values (no sparklines; the service-scope
-           dashboard below carries the trend charts). -->
-      <div v-if="sampledServices.length > 0" class="service-row">
+           dashboard below carries the trend charts).
+
+           Hidden on the Topology route: the service map is layer-wide
+           by design (all services in the layer), and its in-box focus
+           selector is the right place to scope to a specific service
+           from inside that view. -->
+      <div v-if="sampledServices.length > 0 && !viewOwnsServiceSelector" 
class="service-row">
         <button
           class="sw-btn switch"
           type="button"
@@ -243,6 +263,7 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
           @click="togglePicker"
         >
           <span class="caret">▾</span>
+          <span v-if="selectedGroup" class="svc-group">{{ selectedGroup 
}}</span>
           <span class="svc-name">{{ selectedName }}</span>
         </button>
         <div class="kpi-strip service-kpis">
@@ -262,7 +283,7 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
          Sits below the General header so the page reads top-to-bottom:
          layer identity → expanded service picker → sub-route body. -->
     <LayerServiceSelector
-      v-if="layer && pickerOpen && sampledServices.length > 0"
+      v-if="layer && pickerOpen && sampledServices.length > 0 && 
!viewOwnsServiceSelector"
       :services="sampledServices"
       :columns="selectorColumns"
       :selected-id="selectedId"
@@ -376,6 +397,23 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
   color: var(--sw-fg-0);
   letter-spacing: -0.01em;
 }
+/* Group chip on the Switch button — surfaces OAP's `<group>::<base>`
+   prefix so the base name reads clean (e.g. `[agent] rating` instead
+   of `agent::rating`). */
+.switch .svc-group {
+  display: inline-block;
+  margin-right: 6px;
+  padding: 1px 6px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  font-size: 9.5px;
+  font-weight: 600;
+  letter-spacing: 0.04em;
+  color: var(--sw-fg-2);
+  text-transform: uppercase;
+  vertical-align: middle;
+}
 .icon-tile {
   width: 40px;
   height: 40px;
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index 0aaf9a1..3195cac 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -173,6 +173,13 @@ export interface LayerDef {
   serviceCount: number;
   /** True iff OAP returned this layer in `listLayers` (services reporting). */
   active: boolean;
+  /** OAP's per-service `normal` flag, sampled from the first service in
+   *  the layer. In practice every service within a layer shares the
+   *  same value (VIRTUAL_*, AWS_* are all `false`; GENERAL/MESH/etc are
+   *  all `true`), so a single bool per layer is faithful. `null` when
+   *  the layer has no services reporting. The MQE entity scope on the
+   *  dashboard / landing routes pivots on this. */
+  normal?: boolean | null;
   /** Hierarchy level from `listLayerLevels`; null if not in the hierarchy 
table. */
   level: number | null;
   /** External documentation link from `getMenuItems.documentLink`. */

Reply via email to