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 7789b4128c026f6ed614849c151463974ceb056d
Author: Wu Sheng <[email protected]>
AuthorDate: Fri May 15 11:24:56 2026 +0800

    zipkin trace proxy + topology / dashboard fixes + log + sidebar polish
    
    Zipkin trace proxy (slice A — BFF + types + config)
      - New `oap.zipkinUrl` config (default `http://127.0.0.1:9412/zipkin`)
        so OAP's standalone Zipkin REST handler can be addressed per env.
        Demo config points at `https://demo.skywalking.apache.org/zipkin`.
      - New `apps/bff/src/oap/zipkin-routes.ts` proxies the Zipkin v2 REST
        API: services / spans / remote-services / traces / trace / traceMany.
        `/api/zipkin/traces` curates each Zipkin span array into a
        `ZipkinTraceListRow` (root summary + counts) so the list view ships
        O(traces) instead of O(spans).
      - Existing `ZipkinSpan` / `ZipkinTraceListResponse` /
        `ZipkinTraceDetailResponse` types in `api-client/trace.ts` reused;
        new `bffClient.zipkin{Services,Spans,RemoteServices,Traces,Trace}`
        helpers in `apps/ui/src/api/client.ts`.
      - Verified end-to-end against `demo.skywalking.apache.org`:
        `GET /api/zipkin/services` returns 18 mesh services;
        `GET /api/zipkin/traces?limit=3&lookback=900000` returns 3 trace
        summaries with proper `traceId / rootName / rootService / spanCount`.
    
    Topology
      - Drop the per-service-id 30-cap on layer-overview seeds — every
        service from `listServices(layer)` is now a seed (matches
        booster-ui's `selectorStore.services.map(d => d.id)`).
      - Drop the frontend's `NODES_PER_LAYER = 12` cap per column — booster
        doesn't cap either; SVG zoom + pan handle large graphs.
      - Replace "drop nodes with no metric data" with "drop nodes with no
        edges" — matches the booster demo behaviour for mesh / k8s
        (idle-but-connected services stay visible; truly-disconnected ones
        are off-map).
      - Edge traffic flow tuned for readability — discrete dots (`4 28`
        dash pattern, 3s loop) instead of dense scrolling stripes.
    
    LayerShell
      - Auto-redirect when URL targets a sub-route the layer doesn't
        support (e.g. `/layer/mesh_dp/service` → `/layer/mesh_dp/instance`).
        Predicate matrix covers all 10 scope segments + their gating
        caps/slots.
    
    Sidebar
      - URL-driven focus: navigating to `/layer/<key>/...` auto-expands
        both the layer row + its containing group.
      - Clicking a layer row also navigates to its `firstLayerTab(layer)`
        (matches group-toggle behaviour).
      - Sidebar route ↔ caps: drop `slots.services || caps.dashboards`
        fallback — `caps.dashboards` alone gates the Service tab.
    
    Topbar breadcrumbs
      - `/layer/<key>/<scope>` now reads as `<layer.name> · <slot alias>`
        (e.g. `ActiveMQ · Brokers`, `mesh_dp · Sidecars`, `browser · Pages`).
    
    Logs
      - Custom hover tooltip on the density bar (count + per-level
        breakdown) — replaces native `title` + `?` cursor.
      - JSON inline preview compact-serialized + length-uncapped (CSS
        clips at row width); YAML newline-flattened, indentation
        preserved.
      - Row click → full popout (inline expand removed); ESC closes.
      - Per-row format chip (`JSON` / `YAML` / `TEXT`).
      - Service auto-pick gated to log.scope === 'instance'/'endpoint'
        layers; service-scope keeps the default "All".
    
    ActiveMQ
      - Instance dashboard restructured to lead with 4 single-value
        cards (Connections / Producer Count / Consumer Count / Uptime),
        matching the operator-validated trace tab head row.
    
    Self-observability layer headers
      - Wrap labeled SERVICE_INSTANCE metrics with
        `sum(aggregate_labels(metric, sum))` so the picker column
        resolves to a service-wide scalar (Go / Java / OAP / Satellite).
    
    Service-id resolution
      - All BFF routes (log / trace / instance / endpoint) use a strict
        `<base64>.<digits>` regex so service names with embedded dots
        (`mesh-svr::r3-load.sample-services`) aren't misclassified as ids.
    
    Sidebar / Topology UI tuning
      - Section headers (group + standalone) brightened + bold; accent
        left rule on the open section; larger margin-top.
      - LayerShell auto-redirect uses `firstLayerTab(layer)`.
      - Standalone-layer rendering for ungrouped multi-feature layers
        (General / Browser) kept consistent with grouped sections.
    
    Misc
      - `BANYANDB` hidden from the menu (storage backend is monitored via
        self-obs; bundled template removed).
      - `mesh_dp` Sidecar dashboard restored to the upstream 10-widget
        set after probing OAP UI templates directly.
      - Empty trace-stage drop-down `All` defaults on log + trace pages.
---
 .gitignore                                         |   3 +
 .../bff/src/bundled_templates/layers/activemq.json | 145 +++----
 .../bundled_templates/layers/cilium_service.json   |   6 +
 apps/bff/src/bundled_templates/layers/k8s.json     |   3 +
 .../src/bundled_templates/layers/k8s_service.json  |   9 +
 apps/bff/src/bundled_templates/layers/mesh.json    |   9 +
 apps/bff/src/bundled_templates/layers/mesh_cp.json |   9 +
 apps/bff/src/bundled_templates/layers/mesh_dp.json |   9 +
 .../bundled_templates/layers/so11y_go_agent.json   |  42 ++-
 .../bundled_templates/layers/so11y_java_agent.json |  42 ++-
 .../src/bundled_templates/layers/so11y_oap.json    |  11 +-
 .../bundled_templates/layers/so11y_satellite.json  |  17 +-
 apps/bff/src/config/schema.ts                      |   8 +
 apps/bff/src/dashboard/routes.ts                   |  10 +
 apps/bff/src/layers/loader.ts                      |   7 +-
 apps/bff/src/oap/menu-routes.ts                    |  32 +-
 apps/bff/src/oap/topology-routes.ts                |  46 ++-
 apps/bff/src/oap/zipkin-routes.ts                  | 278 ++++++++++++++
 apps/bff/src/server.ts                             |   2 +
 apps/ui/src/api/client.ts                          |  49 +++
 apps/ui/src/components/charts/TimeChart.vue        |  12 +-
 apps/ui/src/components/shell/AppSidebar.vue        |  16 +-
 apps/ui/src/utils/serviceName.ts                   | 114 +++++-
 apps/ui/src/views/layer/LayerDashboardsView.vue    |  17 +-
 .../views/layer/LayerEndpointDependencyView.vue    |  36 +-
 apps/ui/src/views/layer/LayerServiceMapView.vue    | 417 ++++++++++++++++-----
 apps/ui/src/views/layer/LayerServiceSelector.vue   |  25 +-
 apps/ui/src/views/layer/LayerShell.vue             |  51 ++-
 horizon.example.yaml                               |   6 +
 packages/api-client/src/index.ts                   |  21 ++
 packages/api-client/src/menu.ts                    |  38 ++
 packages/api-client/src/profile.ts                 | 166 ++++++++
 32 files changed, 1402 insertions(+), 254 deletions(-)

diff --git a/.gitignore b/.gitignore
index 489d016..c354399 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,9 @@ dist-ssr
 *.local
 .DS_Store
 
+# Claude Code per-user settings — user-specific allow-list, not for git.
+.claude/settings.local.json
+
 # Editor
 .idea
 .vscode/*
diff --git a/apps/bff/src/bundled_templates/layers/activemq.json 
b/apps/bff/src/bundled_templates/layers/activemq.json
index 94df5a0..066b3c2 100644
--- a/apps/bff/src/bundled_templates/layers/activemq.json
+++ b/apps/bff/src/bundled_templates/layers/activemq.json
@@ -198,84 +198,75 @@
     ],
     "instance": [
       {
-        "id": "slave",
-        "title": "Slave Broker",
-        "type": "line",
+        "id": "connections_card",
+        "title": "Connections",
+        "tip": "Current TCP/JMS connections to this broker.",
+        "type": "card",
+        "unit": "conns",
         "expressions": [
-          "latest(meter_activemq_broker_state)"
+          "latest(meter_activemq_broker_current_connections)"
         ],
         "span": 3,
-        "rowSpan": 2
+        "rowSpan": 1,
+        "format": "int"
       },
       {
-        "id": "uptime",
-        "title": "Uptime (hours)",
-        "type": "line",
+        "id": "producer_count_card",
+        "title": "Producer Count",
+        "tip": "Active producer sessions on this broker.",
+        "type": "card",
+        "unit": "producers",
         "expressions": [
-          "latest(meter_activemq_broker_uptime)/1000/60/60"
+          
"latest(aggregate_labels(meter_activemq_broker_current_producer_count,sum))"
         ],
         "span": 3,
-        "rowSpan": 2
+        "rowSpan": 1,
+        "format": "int"
       },
       {
-        "id": "conns",
-        "title": "Connections",
-        "type": "line",
+        "id": "consumer_count_card",
+        "title": "Consumer Count",
+        "tip": "Active consumer sessions on this broker.",
+        "type": "card",
+        "unit": "consumers",
         "expressions": [
-          "meter_activemq_broker_current_connections"
+          
"latest(aggregate_labels(meter_activemq_broker_current_consumer_count,sum))"
         ],
         "span": 3,
-        "rowSpan": 2
+        "rowSpan": 1,
+        "format": "int"
       },
       {
-        "id": "msg_size",
-        "title": "Message Size (B)",
-        "type": "line",
+        "id": "uptime_card",
+        "title": "Uptime",
+        "tip": "Broker uptime in hours since the last restart.",
+        "type": "card",
+        "unit": "h",
         "expressions": [
-          "aggregate_labels(meter_activemq_broker_average_message_size,avg)",
-          "aggregate_labels(meter_activemq_broker_max_message_size,max)"
-        ],
-        "expressionLabels": [
-          "avg",
-          "max"
+          "latest(meter_activemq_broker_uptime)/1000/60/60"
         ],
         "span": 3,
-        "rowSpan": 2
+        "rowSpan": 1,
+        "format": "decimal"
       },
       {
-        "id": "producer_count",
-        "title": "Producer Count",
+        "id": "connections_line",
+        "title": "Connections (trend)",
         "type": "line",
+        "unit": "conns",
         "expressions": [
-          "aggregate_labels(meter_activemq_broker_producer_count,sum)",
-          
"latest(aggregate_labels(meter_activemq_broker_current_producer_count,sum))"
-        ],
-        "expressionLabels": [
-          "increased",
-          "current"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "consumer_count",
-        "title": "Consumer Count",
-        "type": "line",
-        "expressions": [
-          "aggregate_labels(meter_activemq_broker_consumer_count,sum)",
-          
"latest(aggregate_labels(meter_activemq_broker_current_consumer_count,sum))"
-        ],
-        "expressionLabels": [
-          "increased",
-          "current"
+          "meter_activemq_broker_current_connections"
         ],
         "span": 4,
-        "rowSpan": 2
+        "rowSpan": 2,
+        "format": "int"
       },
       {
-        "id": "eq_deq",
-        "title": "Enqueue/Dequeue Count",
+        "id": "enqueue_dequeue_line",
+        "title": "Enqueue / Dequeue Count",
+        "tip": "Per-minute enqueue + dequeue across destinations on this 
broker.",
         "type": "line",
+        "unit": "/min",
         "expressions": [
           "aggregate_labels(meter_activemq_broker_enqueue_count,sum)",
           "aggregate_labels(meter_activemq_broker_dequeue_count,sum)"
@@ -285,26 +276,31 @@
           "dequeue"
         ],
         "span": 4,
-        "rowSpan": 2
+        "rowSpan": 2,
+        "format": "int"
       },
       {
-        "id": "eq_deq_rate",
-        "title": "Enqueue/Dequeue Rate",
+        "id": "producer_consumer_inc_line",
+        "title": "Producer / Consumer Increase",
+        "tip": "New producer + consumer sessions opened per minute.",
         "type": "line",
+        "unit": "/min",
         "expressions": [
-          "aggregate_labels(meter_activemq_broker_enqueue_rate,avg)",
-          "aggregate_labels(meter_activemq_broker_dequeue_rate,avg)"
+          "aggregate_labels(meter_activemq_broker_producer_count,sum)",
+          "aggregate_labels(meter_activemq_broker_consumer_count,sum)"
         ],
         "expressionLabels": [
-          "enqueue",
-          "dequeue"
+          "new producers",
+          "new consumers"
         ],
         "span": 4,
-        "rowSpan": 2
+        "rowSpan": 2,
+        "format": "int"
       },
       {
-        "id": "memory_usage",
-        "title": "Memory Usage (MB)",
+        "id": "memory_usage_line",
+        "title": "Memory Usage",
+        "tip": "Aggregate memory usage across destinations (MB).",
         "type": "line",
         "unit": "MB",
         "expressions": [
@@ -314,22 +310,29 @@
         "rowSpan": 2
       },
       {
-        "id": "limits",
-        "title": "Usage Limits (GB)",
+        "id": "memory_limit_line",
+        "title": "Memory Limit",
+        "tip": "Configured memory ceiling across destinations (GB).",
         "type": "line",
         "unit": "GB",
         "expressions": [
-          
"aggregate_labels(meter_activemq_broker_memory_limit,sum)/1024/1024/1024",
-          
"aggregate_labels(meter_activemq_broker_store_limit,sum)/1024/1024/1024",
-          
"aggregate_labels(meter_activemq_broker_temp_limit,sum)/1024/1024/1024"
-        ],
-        "expressionLabels": [
-          "memory",
-          "store",
-          "temp"
+          
"aggregate_labels(meter_activemq_broker_memory_limit,sum)/1024/1024/1024"
         ],
         "span": 4,
         "rowSpan": 2
+      },
+      {
+        "id": "message_size_line",
+        "title": "Avg Message Size",
+        "tip": "Average message size across destinations.",
+        "type": "line",
+        "unit": "bytes",
+        "expressions": [
+          "aggregate_labels(meter_activemq_broker_average_message_size,avg)"
+        ],
+        "span": 4,
+        "rowSpan": 2,
+        "format": "int"
       }
     ],
     "endpoint": [
diff --git a/apps/bff/src/bundled_templates/layers/cilium_service.json 
b/apps/bff/src/bundled_templates/layers/cilium_service.json
index 3130dd3..e4a42d3 100644
--- a/apps/bff/src/bundled_templates/layers/cilium_service.json
+++ b/apps/bff/src/bundled_templates/layers/cilium_service.json
@@ -9,6 +9,12 @@
     "instances": "Pods",
     "endpoints": "Endpoints"
   },
+  "naming": {
+    "pattern": "^(?<service>[^.]+)\\.(?<namespace>[^.]+)(?:\\..*)?$",
+    "displayGroup": "service",
+    "valueGroup": "namespace",
+    "alias": "namespace"
+  },
   "components": {
     "service": true,
     "instances": true,
diff --git a/apps/bff/src/bundled_templates/layers/k8s.json 
b/apps/bff/src/bundled_templates/layers/k8s.json
index ba107ea..7e2fda8 100644
--- a/apps/bff/src/bundled_templates/layers/k8s.json
+++ b/apps/bff/src/bundled_templates/layers/k8s.json
@@ -368,5 +368,8 @@
         "rowSpan": 2
       }
     ]
+  },
+  "traces": {
+    "source": "zipkin"
   }
 }
diff --git a/apps/bff/src/bundled_templates/layers/k8s_service.json 
b/apps/bff/src/bundled_templates/layers/k8s_service.json
index ce855d7..287d928 100644
--- a/apps/bff/src/bundled_templates/layers/k8s_service.json
+++ b/apps/bff/src/bundled_templates/layers/k8s_service.json
@@ -9,6 +9,12 @@
     "instances": "Pods",
     "endpoints": "Endpoints"
   },
+  "naming": {
+    "pattern": "^(?<service>[^.]+)\\.(?<namespace>[^.]+)(?:\\..*)?$",
+    "displayGroup": "service",
+    "valueGroup": "namespace",
+    "alias": "namespace"
+  },
   "components": {
     "service": true,
     "instances": true,
@@ -568,5 +574,8 @@
         "aggregation": "avg"
       }
     ]
+  },
+  "traces": {
+    "source": "zipkin"
   }
 }
diff --git a/apps/bff/src/bundled_templates/layers/mesh.json 
b/apps/bff/src/bundled_templates/layers/mesh.json
index 72fdcc9..010e9ee 100644
--- a/apps/bff/src/bundled_templates/layers/mesh.json
+++ b/apps/bff/src/bundled_templates/layers/mesh.json
@@ -9,6 +9,12 @@
     "instances": "Sidecars",
     "endpoints": "Endpoints"
   },
+  "naming": {
+    "pattern": "^(?<service>[^.]+)\\.(?<namespace>[^.]+)(?:\\..*)?$",
+    "displayGroup": "service",
+    "valueGroup": "namespace",
+    "alias": "namespace"
+  },
   "components": {
     "service": true,
     "instances": true,
@@ -625,5 +631,8 @@
         "aggregation": "avg"
       }
     ]
+  },
+  "traces": {
+    "source": "zipkin"
   }
 }
diff --git a/apps/bff/src/bundled_templates/layers/mesh_cp.json 
b/apps/bff/src/bundled_templates/layers/mesh_cp.json
index e1fdead..7e355a0 100644
--- a/apps/bff/src/bundled_templates/layers/mesh_cp.json
+++ b/apps/bff/src/bundled_templates/layers/mesh_cp.json
@@ -7,6 +7,12 @@
   "aliases": {
     "services": "Control Planes"
   },
+  "naming": {
+    "pattern": "^(?<service>[^.]+)\\.(?<namespace>[^.]+)(?:\\..*)?$",
+    "displayGroup": "service",
+    "valueGroup": "namespace",
+    "alias": "namespace"
+  },
   "components": {
     "service": true,
     "instances": false,
@@ -226,5 +232,8 @@
         "rowSpan": 2
       }
     ]
+  },
+  "traces": {
+    "source": "zipkin"
   }
 }
diff --git a/apps/bff/src/bundled_templates/layers/mesh_dp.json 
b/apps/bff/src/bundled_templates/layers/mesh_dp.json
index 1ca48e5..b6730fc 100644
--- a/apps/bff/src/bundled_templates/layers/mesh_dp.json
+++ b/apps/bff/src/bundled_templates/layers/mesh_dp.json
@@ -8,6 +8,12 @@
     "services": "Sidecar services",
     "instances": "Sidecars"
   },
+  "naming": {
+    "pattern": "^(?<service>[^.]+)\\.(?<namespace>[^.]+)(?:\\..*)?$",
+    "displayGroup": "service",
+    "valueGroup": "namespace",
+    "alias": "namespace"
+  },
   "components": {
     "service": false,
     "instances": true,
@@ -222,5 +228,8 @@
         "format": "int"
       }
     ]
+  },
+  "traces": {
+    "source": "zipkin"
   }
 }
diff --git a/apps/bff/src/bundled_templates/layers/so11y_go_agent.json 
b/apps/bff/src/bundled_templates/layers/so11y_go_agent.json
index 8cc9c4a..cd5b13a 100644
--- a/apps/bff/src/bundled_templates/layers/so11y_go_agent.json
+++ b/apps/bff/src/bundled_templates/layers/so11y_go_agent.json
@@ -21,14 +21,29 @@
     "columns": [
       {
         "metric": "created",
-        "label": "Tracing/min",
-        "mqe": 
"aggregate_labels(meter_sw_go_created_tracing_context_count,sum)",
-        "aggregation": "sum"
+        "label": "Created RPM",
+        "mqe": 
"sum(aggregate_labels(meter_sw_go_created_tracing_context_count,sum))",
+        "aggregation": "sum",
+        "unit": "rpm"
+      },
+      {
+        "metric": "finished",
+        "label": "Finished RPM",
+        "mqe": 
"sum(aggregate_labels(meter_sw_go_finished_tracing_context_count,sum))",
+        "aggregation": "sum",
+        "unit": "rpm"
       },
       {
         "metric": "ignored",
-        "label": "Ignored/min",
-        "mqe": 
"aggregate_labels(meter_sw_go_created_ignored_context_count,sum)",
+        "label": "Ignored RPM",
+        "mqe": 
"sum(aggregate_labels(meter_sw_go_created_ignored_context_count,sum))",
+        "aggregation": "sum",
+        "unit": "rpm"
+      },
+      {
+        "metric": "leaked",
+        "label": "Leaks",
+        "mqe": "sum(meter_sw_go_possible_leaked_context_count)",
         "aggregation": "sum"
       }
     ]
@@ -36,21 +51,22 @@
   "overview": {
     "groups": [
       {
-        "title": "Agent throughput",
+        "title": "Tracing context",
         "size": "auto",
         "metrics": [
           {
             "id": "created",
-            "label": "Tracing/min",
-            "tip": "Tracing contexts created per minute (sum across 
instances).",
-            "mqe": 
"aggregate_labels(meter_sw_go_created_tracing_context_count,sum)",
+            "label": "Created",
+            "tip": "Tracing contexts created across all Go agents in this 
service per minute.",
+            "mqe": 
"sum(aggregate_labels(meter_sw_go_created_tracing_context_count,sum))",
+            "unit": "rpm",
             "aggregation": "sum"
           },
           {
-            "id": "ignored",
-            "label": "Ignored/min",
-            "tip": "Ignored contexts created per minute.",
-            "mqe": 
"aggregate_labels(meter_sw_go_created_ignored_context_count,sum)",
+            "id": "leaked",
+            "label": "Leaks",
+            "tip": "Possible leaked contexts \u2014 non-zero indicates 
instrumentation drift.",
+            "mqe": "sum(meter_sw_go_possible_leaked_context_count)",
             "aggregation": "sum"
           }
         ]
diff --git a/apps/bff/src/bundled_templates/layers/so11y_java_agent.json 
b/apps/bff/src/bundled_templates/layers/so11y_java_agent.json
index d66ea37..619587b 100644
--- a/apps/bff/src/bundled_templates/layers/so11y_java_agent.json
+++ b/apps/bff/src/bundled_templates/layers/so11y_java_agent.json
@@ -21,14 +21,29 @@
     "columns": [
       {
         "metric": "created",
-        "label": "Tracing/min",
-        "mqe": 
"aggregate_labels(meter_java_agent_created_tracing_context_count,sum)",
-        "aggregation": "sum"
+        "label": "Created RPM",
+        "mqe": 
"sum(aggregate_labels(meter_java_agent_created_tracing_context_count,sum))",
+        "aggregation": "sum",
+        "unit": "rpm"
+      },
+      {
+        "metric": "finished",
+        "label": "Finished RPM",
+        "mqe": 
"sum(aggregate_labels(meter_java_agent_finished_tracing_context_count,sum))",
+        "aggregation": "sum",
+        "unit": "rpm"
       },
       {
         "metric": "ignored",
-        "label": "Ignored/min",
-        "mqe": 
"aggregate_labels(meter_java_agent_created_ignored_context_count,sum)",
+        "label": "Ignored RPM",
+        "mqe": 
"sum(aggregate_labels(meter_java_agent_created_ignored_context_count,sum))",
+        "aggregation": "sum",
+        "unit": "rpm"
+      },
+      {
+        "metric": "leaked",
+        "label": "Leaks",
+        "mqe": "sum(meter_java_agent_possible_leaked_context_count)",
         "aggregation": "sum"
       }
     ]
@@ -36,21 +51,22 @@
   "overview": {
     "groups": [
       {
-        "title": "Agent throughput",
+        "title": "Tracing context",
         "size": "auto",
         "metrics": [
           {
             "id": "created",
-            "label": "Tracing/min",
-            "tip": "Tracing contexts created per minute (sum across 
instances).",
-            "mqe": 
"aggregate_labels(meter_java_agent_created_tracing_context_count,sum)",
+            "label": "Created",
+            "tip": "Tracing contexts created across all Java agents in this 
service per minute.",
+            "mqe": 
"sum(aggregate_labels(meter_java_agent_created_tracing_context_count,sum))",
+            "unit": "rpm",
             "aggregation": "sum"
           },
           {
-            "id": "ignored",
-            "label": "Ignored/min",
-            "tip": "Ignored contexts created per minute.",
-            "mqe": 
"aggregate_labels(meter_java_agent_created_ignored_context_count,sum)",
+            "id": "leaked",
+            "label": "Leaks",
+            "tip": "Possible leaked contexts \u2014 non-zero indicates 
instrumentation drift.",
+            "mqe": "sum(meter_java_agent_possible_leaked_context_count)",
             "aggregation": "sum"
           }
         ]
diff --git a/apps/bff/src/bundled_templates/layers/so11y_oap.json 
b/apps/bff/src/bundled_templates/layers/so11y_oap.json
index 5bea4b7..d4b3713 100644
--- a/apps/bff/src/bundled_templates/layers/so11y_oap.json
+++ b/apps/bff/src/bundled_templates/layers/so11y_oap.json
@@ -21,26 +21,29 @@
     "columns": [
       {
         "metric": "cpu",
-        "label": "CPU %",
+        "label": "Avg CPU",
         "unit": "%",
         "mqe": "avg(meter_oap_instance_cpu_percentage)",
         "aggregation": "avg"
       },
       {
         "metric": "persist",
-        "label": "Persist/min",
+        "label": "Persist",
+        "unit": "/min",
         "mqe": "sum(meter_oap_instance_persistence_execute_count)",
         "aggregation": "sum"
       },
       {
         "metric": "graphql",
-        "label": "GraphQL/min",
+        "label": "GraphQL",
+        "unit": "/min",
         "mqe": "sum(meter_oap_instance_graphql_query_count)",
         "aggregation": "sum"
       },
       {
         "metric": "graphqlErr",
-        "label": "GraphQL Err",
+        "label": "GraphQL err",
+        "unit": "/min",
         "mqe": "sum(meter_oap_instance_graphql_query_error_count)",
         "aggregation": "sum"
       }
diff --git a/apps/bff/src/bundled_templates/layers/so11y_satellite.json 
b/apps/bff/src/bundled_templates/layers/so11y_satellite.json
index 1f5fbb5..b15b6ea 100644
--- a/apps/bff/src/bundled_templates/layers/so11y_satellite.json
+++ b/apps/bff/src/bundled_templates/layers/so11y_satellite.json
@@ -20,27 +20,28 @@
     "columns": [
       {
         "metric": "conns",
-        "label": "gRPC Conns",
-        "mqe": "satellite_service_grpc_connect_count",
+        "label": "Connections",
+        "mqe": "sum(satellite_service_grpc_connect_count)",
         "aggregation": "sum"
       },
       {
         "metric": "cpu",
-        "label": "CPU %",
+        "label": "CPU",
         "unit": "%",
-        "mqe": "satellite_service_server_cpu_utilization",
+        "mqe": "avg(satellite_service_server_cpu_utilization)",
         "aggregation": "avg"
       },
       {
         "metric": "queueUsed",
-        "label": "Queue Used",
-        "mqe": "satellite_service_queue_used_count",
+        "label": "Queue used",
+        "mqe": "sum(satellite_service_queue_used_count)",
         "aggregation": "sum"
       },
       {
         "metric": "recvEvents",
-        "label": "Recv Events",
-        "mqe": "satellite_service_receive_event_count",
+        "label": "Events",
+        "unit": "/min",
+        "mqe": "sum(satellite_service_receive_event_count)",
         "aggregation": "sum"
       }
     ]
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index 370cacf..97d68fe 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -50,6 +50,14 @@ const oapSchema = z
       })
       .strict()
       .default({}),
+    // OAP's Zipkin REST endpoint (the `ZipkinQueryHandler`). Defaults
+    // to `<statusUrl-host>:9412/zipkin` per the upstream Armeria
+    // binding, but operators commonly proxy it under the same host as
+    // GraphQL (`<host>/zipkin/...`). Set explicitly when the demo /
+    // production OAP serves Zipkin from a non-standard origin. Used
+    // by the Zipkin trace viewer for mesh / k8s layers whose traces
+    // flow as Zipkin-format spans (Envoy ALS, rover).
+    zipkinUrl: z.string().url().default('http://127.0.0.1:9412/zipkin'),
   })
   .strict();
 
diff --git a/apps/bff/src/dashboard/routes.ts b/apps/bff/src/dashboard/routes.ts
index e83c985..e3e2883 100644
--- a/apps/bff/src/dashboard/routes.ts
+++ b/apps/bff/src/dashboard/routes.ts
@@ -657,6 +657,16 @@ export function registerDashboardRoute(app: 
FastifyInstance, deps: DashboardRout
       .strict()
       .optional(),
     widgets: z.array(widgetSchema).max(40).optional(),
+    naming: z
+      .object({
+        pattern: z.string().min(1),
+        flags: z.string().optional(),
+        displayGroup: z.string().optional(),
+        valueGroup: z.string().optional(),
+        alias: z.string().min(1),
+      })
+      .strict()
+      .optional(),
   });
 
   app.post(
diff --git a/apps/bff/src/layers/loader.ts b/apps/bff/src/layers/loader.ts
index cceba72..3d90997 100644
--- a/apps/bff/src/layers/loader.ts
+++ b/apps/bff/src/layers/loader.ts
@@ -39,12 +39,13 @@ import type {
   DashboardScope,
   DashboardWidget,
   EndpointDependencyConfig,
+  ServiceNamingRule,
   TopologyConfig,
   TopologyMetricDef,
   TracesConfig,
 } from '@skywalking-horizon-ui/api-client';
 
-export type { TopologyConfig, EndpointDependencyConfig, TopologyMetricDef, 
TracesConfig };
+export type { TopologyConfig, EndpointDependencyConfig, TopologyMetricDef, 
TracesConfig, ServiceNamingRule };
 
 export interface LayerComponentFlags {
   service?: boolean;
@@ -212,6 +213,10 @@ export interface LayerTemplate {
    *  along with every query — useful for layers whose logs are always
    *  filtered by `logger=` or `source=`. */
   log?: LogConfig;
+  /** Service-name parsing rule. Surfaced verbatim on the menu response
+   *  so the UI can derive `{ display, group }` per service and cluster
+   *  topology nodes by group. */
+  naming?: ServiceNamingRule;
 }
 
 export interface LogConfig {
diff --git a/apps/bff/src/oap/menu-routes.ts b/apps/bff/src/oap/menu-routes.ts
index 8873723..f34f66d 100644
--- a/apps/bff/src/oap/menu-routes.ts
+++ b/apps/bff/src/oap/menu-routes.ts
@@ -195,6 +195,7 @@ function deriveLayer(
       metrics: tpl.metrics,
       overview: tpl.overview,
       log: tpl.log,
+      naming: tpl.naming,
     };
   }
   const def = LAYER_DEFAULTS[rawKey] ?? DEFAULT_FOR_UNKNOWN_LAYER;
@@ -299,16 +300,27 @@ export function registerMenuRoute(app: FastifyInstance, 
deps: MenuRouteDeps): vo
         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),
-          normalByCanonical.get(key) ?? null,
-          raw.items,
-        ),
-      );
+      // Layers we deliberately drop from the sidebar even when OAP
+      // surfaces them. BanyanDB is OAP's storage backend — it shows
+      // up as a Layer in `getMenuItems`, but the operator monitors it
+      // via the OAP self-observability dashboard (CPU / memory / GC
+      // metrics there cover the storage node too). Keeping it as a
+      // standalone Databases-ish row was confusing per operator
+      // feedback. Add more keys here if other internal-only layers
+      // need the same treatment.
+      const HIDDEN_LAYERS = new Set(['BANYANDB']);
+      const layers = ordered
+        .filter((key) => !HIDDEN_LAYERS.has(key))
+        .map((key) =>
+          deriveLayer(
+            key,
+            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,
+          ),
+        );
 
       const body: MenuResponse = {
         layers,
diff --git a/apps/bff/src/oap/topology-routes.ts 
b/apps/bff/src/oap/topology-routes.ts
index 78e50e7..19de95d 100644
--- a/apps/bff/src/oap/topology-routes.ts
+++ b/apps/bff/src/oap/topology-routes.ts
@@ -302,7 +302,17 @@ export function registerTopologyRoute(app: 
FastifyInstance, deps: TopologyRouteD
           }
           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);
+          // Layer-overview topology — seed with EVERY service the layer
+          // exposes. Booster-ui does the same: it computes the topology
+          // off `selectorStore.services.map(d => d.id)`, no cap. The
+          // earlier 30-service cap was leftover from a per-node MQE
+          // batch-size worry, but the MQE step already chunks at 150
+          // fragments per query (see below), so a layer with hundreds
+          // of services scales fine.
+          seedIds = data.services.map((s) => s.id);
+          // Debug log so the response size is visible while we
+          // diagnose why layers with many services come back small.
+          console.log(`[topology] layer=${oapLayer} 
seed-services=${seedIds.length}`);
         }
       } catch (err) {
         return reply.send(
@@ -356,6 +366,12 @@ export function registerTopologyRoute(app: 
FastifyInstance, deps: TopologyRouteD
         );
       }
 
+      // (Disconnected services are dropped a few lines below — they
+      // don't belong on a topology map. The earlier "fill them in as
+      // standalone nodes" pass was reverted after a closer look at
+      // booster-ui's demo, which only renders connected nodes too.)
+      console.log(`[topology] layer=${oapLayer} returned-nodes=${nodes.size} 
edges=${calls.size}`);
+
       // ── Per-node MQE. Builds fragments off the layer's
       // `topology.nodeMetrics`. Synthetic nodes (User / external) are
       // skipped since OAP has no metrics for them.
@@ -485,17 +501,23 @@ export function registerTopologyRoute(app: 
FastifyInstance, deps: TopologyRouteD
         }
       }
 
-      // ── Build response. Per operator direction: nodes with zero
-      // real metric values (after resolution) are dropped. This keeps
-      // ghost services off the graph when OAP returns a row but no
-      // data populated yet. Synthetic nodes (User / external) are
-      // kept so the graph still has its entry/exit anchors.
-      function hasAnyValue(r: Record<string, number | null>): boolean {
-        for (const v of Object.values(r)) if (v !== null) return true;
-        return false;
+      // ── Build response. Connected nodes only — a service with zero
+      // edges in the duration window doesn't belong on the topology
+      // map; it's a "service" not a "topology participant". This
+      // matches booster-ui's demo at
+      // https://demo.skywalking.apache.org/Service-Mesh/Services and
+      // /Kubernetes/Service: the canvas only renders nodes that are
+      // endpoints of at least one call edge. We keep idle-but-still-
+      // connected nodes (their metrics may be null on the windowed
+      // sample, but they still take part in the topology graph).
+      const connectedNodeIds = new Set<string>();
+      for (const c of calls.values()) {
+        connectedNodeIds.add(c.source);
+        connectedNodeIds.add(c.target);
       }
       const liveNodes: TopologyNode[] = [];
       for (const n of nodes.values()) {
+        if (!connectedNodeIds.has(n.id)) continue;
         const m = nodeMetricVals.get(n.id) ?? {};
         // Pad with explicit nulls so every metric id is present in the
         // wire shape — UI binds by id, an absent key would look the
@@ -504,12 +526,6 @@ export function registerTopologyRoute(app: 
FastifyInstance, deps: TopologyRouteD
         for (const def of topoCfg.nodeMetrics) {
           filled[def.id] = m[def.id] ?? null;
         }
-        if (n.isReal && !hasAnyValue(filled)) {
-          // No data — drop. Per user direction: "if can't read data we
-          // could ignore". Synthetic User/external nodes survive
-          // because they have no metric scope to begin with.
-          continue;
-        }
         liveNodes.push({
           id: n.id,
           name: n.name,
diff --git a/apps/bff/src/oap/zipkin-routes.ts 
b/apps/bff/src/oap/zipkin-routes.ts
new file mode 100644
index 0000000..dc32370
--- /dev/null
+++ b/apps/bff/src/oap/zipkin-routes.ts
@@ -0,0 +1,278 @@
+/*
+ * 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.
+ */
+
+/**
+ * Zipkin trace proxy.
+ *
+ * OAP serves the Zipkin v2 REST API via `ZipkinQueryHandler` (separate
+ * from the GraphQL `queryBasicTraces` flow). Layers whose data path
+ * ships Zipkin-format spans (Envoy ALS, k8s rover) need the operator
+ * to query those endpoints instead of OAP's native trace store.
+ *
+ * These routes thin-proxy to:
+ *   GET /api/v2/services
+ *   GET /api/v2/spans?serviceName=
+ *   GET /api/v2/remoteServices?serviceName=
+ *   GET /api/v2/traces?...
+ *   GET /api/v2/trace/{traceId}
+ *
+ * Base URL is `cfg.oap.zipkinUrl` (default `http://127.0.0.1:9412/zipkin`).
+ * Auth piggy-backs on the same `cfg.oap.auth` block the GraphQL client
+ * uses, since the demo OAP gates Zipkin behind the same basic-auth.
+ *
+ * No GraphQL — we forward fetch directly. The response bodies are
+ * standard Zipkin v2 JSON (arrays of arrays of spans for the list
+ * endpoint, array of spans for the detail).
+ */
+
+import type {
+  FetchLike,
+  ZipkinSpan,
+  ZipkinTraceListResponse,
+  ZipkinTraceListRow,
+  ZipkinTraceDetailResponse,
+} from '@skywalking-horizon-ui/api-client';
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type { ConfigSource } from '../config/loader.js';
+import type { SessionStore } from '../auth/sessions.js';
+import { requireAuth } from '../auth/middleware.js';
+import { basicAuthHeader } from './graphql-client.js';
+
+export interface ZipkinRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  fetch?: FetchLike;
+}
+
+/** Derive a one-row summary (`ZipkinTraceListRow`) from a trace's
+ *  full span array. Zipkin's REST list endpoint returns the full span
+ *  set per trace — the SPA's list view doesn't need every span, just
+ *  the root + counts. */
+function summariseTrace(spans: ZipkinSpan[]): ZipkinTraceListRow {
+  // Root = span with no parent. Falls back to the earliest span when
+  // every span has a parentId (broken trace).
+  const root = spans.find((s) => !s.parentId)
+    ?? spans.slice().sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))[0]
+    ?? null;
+  const errorCount = spans.reduce((n, s) => {
+    const t = s.tags ?? {};
+    if (t['error'] != null || t['http.status_code']?.startsWith('5') || 
t['otel.status_code'] === 'ERROR') {
+      return n + 1;
+    }
+    return n;
+  }, 0);
+  return {
+    traceId: root?.traceId ?? (spans[0]?.traceId ?? ''),
+    rootName: root?.name ?? null,
+    rootService: root?.localEndpoint?.serviceName ?? null,
+    timestamp: root?.timestamp ?? null,
+    duration: root?.duration ?? null,
+    spanCount: spans.length,
+    errorCount,
+  };
+}
+
+async function zipkinFetch(
+  cfg: ZipkinRouteDeps['config']['current'],
+  fetchFn: FetchLike | undefined,
+  path: string,
+  query?: Record<string, string | number | undefined>,
+): Promise<{ status: number; body: unknown }> {
+  const f = fetchFn ?? globalThis.fetch.bind(globalThis);
+  const base = cfg.oap.zipkinUrl.replace(/\/$/, '');
+  const url = new URL(base + path);
+  if (query) {
+    for (const [k, v] of Object.entries(query)) {
+      if (v === undefined || v === null || v === '') continue;
+      url.searchParams.set(k, String(v));
+    }
+  }
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), cfg.oap.timeoutMs);
+  const headers: Record<string, string> = { accept: 'application/json' };
+  if (cfg.oap.auth) {
+    headers.authorization = basicAuthHeader(cfg.oap.auth.username, 
cfg.oap.auth.password);
+  }
+  try {
+    const res = await f(url.toString(), {
+      method: 'GET',
+      headers,
+      signal: controller.signal,
+    });
+    const text = await res.text();
+    let body: unknown;
+    try { body = text ? JSON.parse(text) : null; } catch { body = text; }
+    return { status: res.status, body };
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+export function registerZipkinRoutes(app: FastifyInstance, deps: 
ZipkinRouteDeps): void {
+  const auth = requireAuth(deps);
+
+  // GET /api/zipkin/services
+  app.get('/api/zipkin/services', { preHandler: auth }, async (_req, reply) => 
{
+    try {
+      const { status, body } = await zipkinFetch(deps.config.current, 
deps.fetch, '/api/v2/services');
+      return reply.code(status).send(body);
+    } catch (err) {
+      return reply
+        .code(200)
+        .send({ services: [], reachable: false, error: err instanceof Error ? 
err.message : String(err) });
+    }
+  });
+
+  // GET /api/zipkin/spans?serviceName=
+  app.get('/api/zipkin/spans', { preHandler: auth }, async (req, reply) => {
+    const q = req.query as { serviceName?: string };
+    if (!q.serviceName) return reply.code(400).send({ error: 
'missing_serviceName' });
+    try {
+      const { status, body } = await zipkinFetch(deps.config.current, 
deps.fetch, '/api/v2/spans', {
+        serviceName: q.serviceName,
+      });
+      return reply.code(status).send(body);
+    } catch (err) {
+      return reply.code(200).send({ spans: [], reachable: false, error: 
String(err) });
+    }
+  });
+
+  // GET /api/zipkin/remote-services?serviceName=
+  app.get('/api/zipkin/remote-services', { preHandler: auth }, async (req, 
reply) => {
+    const q = req.query as { serviceName?: string };
+    if (!q.serviceName) return reply.code(400).send({ error: 
'missing_serviceName' });
+    try {
+      const { status, body } = await zipkinFetch(
+        deps.config.current,
+        deps.fetch,
+        '/api/v2/remoteServices',
+        { serviceName: q.serviceName },
+      );
+      return reply.code(status).send(body);
+    } catch (err) {
+      return reply.code(200).send({ remoteServices: [], reachable: false, 
error: String(err) });
+    }
+  });
+
+  // GET 
/api/zipkin/traces?serviceName=&spanName=&minDuration=&maxDuration=&annotationQuery=&endTs=&lookback=&limit=
+  app.get('/api/zipkin/traces', { preHandler: auth }, async (req, reply) => {
+    const q = req.query as Record<string, string | undefined>;
+    const limit = q.limit ? Math.max(1, Math.min(200, Number(q.limit))) : 30;
+    const lookback = q.lookback ? Number(q.lookback) : 30 * 60_000; // 30 min 
default (ms)
+    const endTs = q.endTs ? Number(q.endTs) : Date.now();
+    try {
+      const { status, body } = await zipkinFetch(
+        deps.config.current,
+        deps.fetch,
+        '/api/v2/traces',
+        {
+          serviceName: q.serviceName,
+          remoteServiceName: q.remoteServiceName,
+          spanName: q.spanName,
+          annotationQuery: q.annotationQuery,
+          minDuration: q.minDuration,
+          maxDuration: q.maxDuration,
+          endTs,
+          lookback,
+          limit,
+        },
+      );
+      // Zipkin's `/traces` returns `Array<Array<Span>>` — one inner
+      // array per trace. Compress each into a `ZipkinTraceListRow`
+      // (root summary + counts) so the SPA's list view doesn't have
+      // to ship the full span tree just to render rows. The detail
+      // route serves the full spans for the popout.
+      const raw = Array.isArray(body) ? (body as ZipkinSpan[][]) : [];
+      const summaries: ZipkinTraceListRow[] = raw.map(summariseTrace);
+      const response: ZipkinTraceListResponse = {
+        source: 'zipkin',
+        traces: summaries,
+        reachable: true,
+      };
+      return reply.code(status).send(response);
+    } catch (err) {
+      const response: ZipkinTraceListResponse = {
+        source: 'zipkin',
+        traces: [],
+        reachable: false,
+        error: err instanceof Error ? err.message : String(err),
+      };
+      return reply.code(200).send(response);
+    }
+  });
+
+  // GET /api/zipkin/trace/:traceId
+  app.get<{ Params: { traceId: string } }>(
+    '/api/zipkin/trace/:traceId',
+    { preHandler: auth },
+    async (req, reply) => {
+      const { traceId } = req.params;
+      if (!traceId || !/^[0-9a-fA-F]+$/.test(traceId)) {
+        return reply.code(400).send({ error: 'invalid_trace_id' });
+      }
+      try {
+        const { status, body } = await zipkinFetch(
+          deps.config.current,
+          deps.fetch,
+          `/api/v2/trace/${encodeURIComponent(traceId)}`,
+        );
+        const detail: ZipkinTraceDetailResponse = {
+          source: 'zipkin',
+          traceId,
+          spans: Array.isArray(body) ? (body as ZipkinSpan[]) : [],
+          reachable: status !== 404,
+          ...(status === 404 ? { error: 'trace not found' } : {}),
+        };
+        return reply.code(status === 404 ? 200 : status).send(detail);
+      } catch (err) {
+        const detail: ZipkinTraceDetailResponse = {
+          source: 'zipkin',
+          traceId,
+          spans: [],
+          reachable: false,
+          error: err instanceof Error ? err.message : String(err),
+        };
+        return reply.code(200).send(detail);
+      }
+    },
+  );
+
+  // GET /api/zipkin/traceMany?traceIds=t1,t2,t3
+  app.get('/api/zipkin/traceMany', { preHandler: auth }, async (req: 
FastifyRequest, reply: FastifyReply) => {
+    const q = req.query as { traceIds?: string };
+    const ids = (q.traceIds ?? '').split(',').map((s) => 
s.trim()).filter(Boolean);
+    if (ids.length === 0) return reply.code(400).send({ error: 
'missing_traceIds' });
+    try {
+      const { status, body } = await zipkinFetch(
+        deps.config.current,
+        deps.fetch,
+        '/api/v2/traceMany',
+        { traceIds: ids.join(',') },
+      );
+      return reply.code(status).send({
+        traces: Array.isArray(body) ? body : [],
+        reachable: true,
+      });
+    } catch (err) {
+      return reply.code(200).send({
+        traces: [],
+        reachable: false,
+        error: err instanceof Error ? err.message : String(err),
+      });
+    }
+  });
+}
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 7e3d955..3fb16b4 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -37,6 +37,7 @@ import { registerPreflightRoutes } from 
'./oap/preflight-routes.js';
 import { registerTopologyRoute } from './oap/topology-routes.js';
 import { registerTraceRoutes } from './oap/trace-routes.js';
 import { registerTraceTagRoutes } from './oap/trace-tag-routes.js';
+import { registerZipkinRoutes } from './oap/zipkin-routes.js';
 import { registerOverviewRoutes } from './overview/routes.js';
 import { registerSetupRoutes } from './setup/routes.js';
 import { SetupStore } from './setup/store.js';
@@ -81,6 +82,7 @@ registerTopologyRoute(app, { config: source, sessions });
 registerEndpointDependencyRoute(app, { config: source, sessions });
 registerTraceRoutes(app, { config: source, sessions });
 registerTraceTagRoutes(app, { config: source, sessions });
+registerZipkinRoutes(app, { config: source, sessions });
 registerLogRoute(app, { config: source, sessions });
 registerOverviewRoutes(app, { config: source, sessions });
 registerDashboardRoute(app, { config: source, sessions });
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 2e5a7bf..f12be55 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -38,8 +38,29 @@ import type {
   TraceQueryState,
   TraceSource,
   TracesConfig,
+  ZipkinTraceListResponse,
+  ZipkinTraceDetailResponse,
 } from '@skywalking-horizon-ui/api-client';
 
+/** Query shape for `/api/zipkin/traces`. Mirrors the Zipkin v2 REST
+ *  params Zipkin's UI uses — all optional, `endTs` defaults to now and
+ *  `lookback` to 30 min (ms) server-side. */
+export interface ZipkinTraceQuery {
+  serviceName?: string;
+  remoteServiceName?: string;
+  spanName?: string;
+  annotationQuery?: string;
+  /** microseconds */
+  minDuration?: number;
+  /** microseconds */
+  maxDuration?: number;
+  /** ms since epoch */
+  endTs?: number;
+  /** ms */
+  lookback?: number;
+  limit?: number;
+}
+
 export type {
   MenuResponse,
   LayerDef,
@@ -440,6 +461,34 @@ export class BffClient {
     );
   }
 
+  // ── Zipkin trace endpoints (proxied to OAP's ZipkinQueryHandler) ──
+  /** List services known to OAP's Zipkin store. */
+  zipkinServices(): Promise<string[]> {
+    return this.request('GET', '/api/zipkin/services');
+  }
+  /** List span names for a Zipkin service. */
+  zipkinSpans(serviceName: string): Promise<string[]> {
+    return this.request('GET', 
`/api/zipkin/spans?serviceName=${encodeURIComponent(serviceName)}`);
+  }
+  /** List remote (peer) services seen by a Zipkin service. */
+  zipkinRemoteServices(serviceName: string): Promise<string[]> {
+    return this.request('GET', 
`/api/zipkin/remote-services?serviceName=${encodeURIComponent(serviceName)}`);
+  }
+  /** Search Zipkin traces. Mirrors Zipkin v2 `/api/v2/traces`. */
+  zipkinTraces(q: ZipkinTraceQuery = {}): Promise<ZipkinTraceListResponse> {
+    const params = new URLSearchParams();
+    for (const [k, v] of Object.entries(q)) {
+      if (v === undefined || v === null || v === '') continue;
+      params.set(k, String(v));
+    }
+    const qs = params.toString();
+    return this.request<ZipkinTraceListResponse>('GET', 
`/api/zipkin/traces${qs ? '?' + qs : ''}`);
+  }
+  /** Fetch a single Zipkin trace by id. */
+  zipkinTrace(traceId: string): Promise<ZipkinTraceDetailResponse> {
+    return this.request<ZipkinTraceDetailResponse>('GET', 
`/api/zipkin/trace/${encodeURIComponent(traceId)}`);
+  }
+
   /** List active instances for a service. The per-layer Instance
    *  dashboard surfaces a second selector below the service picker;
    *  this feeds it. Accepts the service id or name. */
diff --git a/apps/ui/src/components/charts/TimeChart.vue 
b/apps/ui/src/components/charts/TimeChart.vue
index 2979884..4b03a67 100644
--- a/apps/ui/src/components/charts/TimeChart.vue
+++ b/apps/ui/src/components/charts/TimeChart.vue
@@ -124,7 +124,17 @@ function buildOption(): echarts.EChartsCoreOption {
       // widget card's overflow:hidden. Otherwise the tooltip cuts off
       // at the card edge whenever a chart sits near the boundary.
       appendToBody: true,
-      confine: false,
+      // Containerless trigger needs `confine: true` so the popup
+      // sticks to the viewport edges when there are many series — the
+      // `extraCssText` cap below limits the inner height + adds
+      // scroll so labeled metrics with dozens of series (Envoy
+      // membership health per cluster, K8s pod_status per pod) don't
+      // overflow past the screen. Without these two, the bottom rows
+      // of the tooltip vanish off-screen.
+      confine: true,
+      extraCssText:
+        'max-height: 60vh; overflow-y: auto; max-width: 360px; ' +
+        'box-shadow: 0 8px 24px rgba(0,0,0,0.45);',
       valueFormatter: (v: unknown) =>
         typeof v === 'number' && Number.isFinite(v)
           ? `${formatVal(v)}${props.unit ? ` ${props.unit}` : ''}`
diff --git a/apps/ui/src/components/shell/AppSidebar.vue 
b/apps/ui/src/components/shell/AppSidebar.vue
index f8a973e..ac709a9 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -67,7 +67,21 @@ function isSingleFeatureLayer(L: SidebarLayer): boolean {
 // doesn't look closed on first visit.
 const expandedLayer = ref<string | null>(null);
 function toggleLayer(key: string): void {
-  expandedLayer.value = expandedLayer.value === key ? null : key;
+  const wasExpanded = expandedLayer.value === key;
+  expandedLayer.value = wasExpanded ? null : key;
+  // Opening a layer (transition closed → open) also navigates to its
+  // first available sub-tab so the operator lands on actionable
+  // content. Collapsing is purely a section close (no nav). Skip when
+  // the route is already on this layer — we'd be navigating to the
+  // same place. This matches the group toggle's behaviour.
+  if (!wasExpanded) {
+    const L = orderedLayers.value.find((l) => l.key === key);
+    if (!L) return;
+    const target = `/layer/${L.key}/${firstLayerTab(L)}`;
+    if (route.path === target) return;
+    if (route.path.startsWith(`/layer/${L.key}/`)) return;
+    void router.push(target);
+  }
 }
 
 // Bucket the ordered layer list by the template's `group` field so the
diff --git a/apps/ui/src/utils/serviceName.ts b/apps/ui/src/utils/serviceName.ts
index 0f5e051..d5ccfbc 100644
--- a/apps/ui/src/utils/serviceName.ts
+++ b/apps/ui/src/utils/serviceName.ts
@@ -15,21 +15,28 @@
  * limitations under the License.
  */
 
+import type { ServiceNamingRule } from '@skywalking-horizon-ui/api-client';
+
 /**
- * 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`).
+ * Service-name parsing. Two flavours coexist:
+ *
+ *   - Legacy `<group>::<base>` (OAP's historical encoding for fleet /
+ *     deployment prefix) — used by general-purpose layers like
+ *     `agent::songs`.
+ *   - Per-layer `ServiceNamingRule` (named-capture regex) — used by
+ *     k8s / mesh / cilium layers where the encoded grouping dimension
+ *     is the namespace (`songs.sample` → `songs` + namespace `sample`).
+ *
+ * Rendering rule applied across the UI:
+ *   - Service lists / pickers / KPI strips → render `<alias-chip> <display>`
+ *     so the grouping reads as a category tag and the eye lands on the
+ *     service label.
+ *   - Topology nodes → render `display` only; alias-chip lives in
+ *     the right-sidebar detail panel and the group bounding box title.
+ *
+ * The two parsers are intentionally separate: legacy `::` is layer-
+ * agnostic and always available; the rule-based parser only fires
+ * when a layer config carries an explicit `naming` rule.
  */
 export interface ParsedServiceName {
   /** Group prefix when the raw name contains `::`. */
@@ -58,3 +65,82 @@ export function serviceBaseName(raw: string | null | 
undefined): string {
 export function serviceGroupName(raw: string | null | undefined): string | 
null {
   return parseServiceName(raw).group;
 }
+
+/**
+ * Per-layer identity resolution. Given a service name and the layer's
+ * `ServiceNamingRule` (if any), returns the trio every UI surface
+ * needs:
+ *
+ *   - `display`  the label shown next to the node / on the chip / in
+ *                lists. Always non-empty (falls back to the raw name).
+ *   - `group`    the value used for clustering (e.g. namespace name).
+ *                `null` when no grouping applies — UI hides the chip.
+ *   - `alias`    human label for the dimension (e.g. `namespace`,
+ *                `group`). `null` when `group` is null; otherwise
+ *                non-empty. Surfaced as the chip prefix (`namespace ·
+ *                sample`) and the group-box title.
+ *
+ * Resolution order:
+ *   1. If `rule` is non-null and its regex matches, use the captured
+ *      groups + rule.alias.
+ *   2. Else if the name contains `::`, treat it as legacy group/base
+ *      with alias `group`.
+ *   3. Else: display=raw, group=null, alias=null.
+ */
+export interface ServiceIdentity {
+  display: string;
+  group: string | null;
+  alias: string | null;
+}
+
+/**
+ * Compile a `ServiceNamingRule` into a memoisable RegExp.
+ *
+ * Returns `null` when the pattern is invalid — callers fall through to
+ * the legacy `::` parser. Pattern compilation errors are swallowed by
+ * design; mis-typed regexes in operator-edited config should never
+ * crash the topology view, just behave as if no rule was configured.
+ */
+function compileRule(rule: ServiceNamingRule | null | undefined): RegExp | 
null {
+  if (!rule || !rule.pattern) return null;
+  try {
+    return new RegExp(rule.pattern, rule.flags ?? '');
+  } catch {
+    return null;
+  }
+}
+
+export function resolveServiceIdentity(
+  raw: string | null | undefined,
+  rule: ServiceNamingRule | null | undefined,
+): ServiceIdentity {
+  const r = raw ?? '';
+  // Rule-based parse first — layer config wins when present and the
+  // pattern actually matches the name.
+  const re = compileRule(rule);
+  if (re && rule) {
+    const m = r.match(re);
+    if (m && m.groups) {
+      const displayKey = rule.displayGroup ?? 'service';
+      const valueKey = rule.valueGroup ?? 'group';
+      const display = m.groups[displayKey];
+      const group = m.groups[valueKey];
+      // Only honour the rule when BOTH expected captures resolved to
+      // non-empty strings. Partial matches (e.g. one capture missing)
+      // fall through to the legacy parser so the operator's bad
+      // pattern doesn't strip half the data.
+      if (display && group) {
+        return { display, group, alias: rule.alias };
+      }
+      if (display) {
+        return { display, group: null, alias: null };
+      }
+    }
+  }
+  // Legacy `<group>::<base>` fallback.
+  const legacy = parseServiceName(r);
+  if (legacy.group) {
+    return { display: legacy.base, group: legacy.group, alias: 'group' };
+  }
+  return { display: r, group: null, alias: null };
+}
diff --git a/apps/ui/src/views/layer/LayerDashboardsView.vue 
b/apps/ui/src/views/layer/LayerDashboardsView.vue
index f7995b8..2d54ca9 100644
--- a/apps/ui/src/views/layer/LayerDashboardsView.vue
+++ b/apps/ui/src/views/layer/LayerDashboardsView.vue
@@ -87,6 +87,19 @@ const serviceName = computed<string | null>(() => {
   const match = rows.find((r) => r.serviceId === selectedId.value);
   return match?.serviceName ?? null;
 });
+/**
+ * Service handle used for downstream picker queries (instance list,
+ * endpoint list, dashboard widgets). The landing route resolves
+ * `selectedId` → `serviceName` once its row sample arrives, which can
+ * take a moment on first paint. The BFF picker / dashboard routes
+ * accept either a name OR an id, so we fire those queries with
+ * `selectedId` immediately and let `serviceName` overtake when ready.
+ * Avoids the "instance list empty for a beat after landing" hiccup
+ * the operator reported.
+ */
+const serviceHandle = computed<string | null>(
+  () => serviceName.value ?? selectedId.value ?? null,
+);
 
 // Dev-only escape hatch: appending `?mockTop=10` to the page URL pads
 // every TopList result to N synthetic rows. Helps operators verify
@@ -107,7 +120,7 @@ const { config, isLoading: configLoading } = 
useLayerDashboardConfig(layerKey, s
 const { selectedInstance, setSelectedInstance } = useSelectedInstance();
 const { instances: instanceList, isFetching: instancesLoading } = 
useLayerInstances(
   layerKey,
-  serviceName,
+  serviceHandle,
 );
 /** Track which row's attributes panel is open. Mutually exclusive —
  *  expanding one collapses the previous so the list stays compact. */
@@ -173,7 +186,7 @@ function clearEndpointSearch(): void {
 }
 const { endpoints: endpointList, isFetching: endpointsLoading } = 
useLayerEndpoints(
   layerKey,
-  serviceName,
+  serviceHandle,
   endpointQuery,
   endpointLimit,
 );
diff --git a/apps/ui/src/views/layer/LayerEndpointDependencyView.vue 
b/apps/ui/src/views/layer/LayerEndpointDependencyView.vue
index 0d3fe1e..9bef6dd 100644
--- a/apps/ui/src/views/layer/LayerEndpointDependencyView.vue
+++ b/apps/ui/src/views/layer/LayerEndpointDependencyView.vue
@@ -49,6 +49,7 @@ import { useSelectedEndpoint } from 
'@/composables/useSelectedEndpoint';
 import { useSelectedService } from '@/composables/useSelectedService';
 import { useSetupStore } from '@/stores/setup';
 import { fmtMetric } from '@/utils/formatters';
+import { resolveServiceIdentity, type ServiceIdentity } from 
'@/utils/serviceName';
 import { watch } from 'vue';
 import Sparkline from '@/components/charts/Sparkline.vue';
 
@@ -72,6 +73,13 @@ const safeCfg = computed(() => {
     slots: layer.value.slots, caps: layer.value.caps, metrics: 
layer.value.metrics, overview: layer.value.overview,
   }).landing;
 });
+// Layer-aware identity resolver — mirrors the topology view. Endpoint
+// dependency nodes carry a `serviceName` field; we render it through
+// the rule so k8s/mesh endpoints get a namespace chip.
+const namingRule = computed(() => layer.value?.naming ?? null);
+function identity(name: string | null | undefined): ServiceIdentity {
+  return resolveServiceIdentity(name, namingRule.value);
+}
 const landing = useLayerLanding(safeLayer, safeCfg);
 const serviceName = computed<string | null>(() => {
   const rows = landing.data.value?.sampledRows ?? landing.rows.value ?? [];
@@ -638,7 +646,14 @@ function edgeRowCrosshair(rowId: string): number | null {
     <section class="ep-picker sw-card">
       <header class="picker-head">
         <span class="kicker">API dependency</span>
-        <span v-if="serviceName" class="for-svc">on <b>{{ serviceName 
}}</b></span>
+        <span v-if="serviceName" class="for-svc">
+          on
+          <span v-if="identity(serviceName).group" class="sw-tag accent tiny 
inline-tag">
+            <span class="tag-alias">{{ identity(serviceName).alias }}</span>
+            <span class="tag-val">{{ identity(serviceName).group }}</span>
+          </span>
+          <b>{{ identity(serviceName).display }}</b>
+        </span>
         <span v-if="isFetching" class="hint">refreshing…</span>
       </header>
       <div v-if="!serviceName" class="empty inline">
@@ -886,7 +901,7 @@ function edgeRowCrosshair(rowId: string): number | null {
               font-family="var(--sw-mono)"
             >
               <title>{{ n.serviceName }}</title>
-              {{ n.serviceName.length > 26 ? n.serviceName.slice(0, 24) + '…' 
: n.serviceName }}
+              {{ identity(n.serviceName).display.length > 26 ? 
identity(n.serviceName).display.slice(0, 24) + '…' : 
identity(n.serviceName).display }}
             </text>
             <!-- Row 2: API (endpoint) name — the headline. -->
             <text
@@ -987,7 +1002,11 @@ function edgeRowCrosshair(rowId: string): number | null {
         <header class="ed-head">
           <div class="ed-id">
             <div class="ed-kind-row">
-              <span class="ed-svc">{{ selectedNode.serviceName }}</span>
+              <span v-if="identity(selectedNode.serviceName).group" 
class="sw-tag accent tiny">
+                <span class="tag-alias">{{ 
identity(selectedNode.serviceName).alias }}</span>
+                <span class="tag-val">{{ 
identity(selectedNode.serviceName).group }}</span>
+              </span>
+              <span class="ed-svc">{{ 
identity(selectedNode.serviceName).display }}</span>
               <span v-if="selectedNode.id === focusedId" class="sw-tag 
accent">focus</span>
             </div>
             <div class="ed-name">{{ selectedNode.name }}</div>
@@ -1124,8 +1143,17 @@ function edgeRowCrosshair(rowId: string): number | null {
   color: var(--sw-accent);
   font-weight: 600;
 }
-.for-svc { font-size: 11px; color: var(--sw-fg-3); }
+.for-svc { font-size: 11px; color: var(--sw-fg-3); display: inline-flex; 
align-items: center; gap: 6px; }
 .for-svc b { color: var(--sw-fg-1); font-family: var(--sw-mono); font-weight: 
500; }
+.sw-tag.tiny {
+  font-size: 9.5px;
+  padding: 0 5px;
+  line-height: 14px;
+  height: 14px;
+}
+.sw-tag .tag-alias { opacity: 0.7; font-weight: 500; margin-right: 4px; }
+.sw-tag .tag-alias::after { content: '·'; margin-left: 4px; }
+.sw-tag .tag-val { font-family: var(--sw-mono); font-weight: 600; }
 .hint { font-size: 10.5px; color: var(--sw-fg-3); margin-left: auto; }
 .ep-controls {
   display: flex;
diff --git a/apps/ui/src/views/layer/LayerServiceMapView.vue 
b/apps/ui/src/views/layer/LayerServiceMapView.vue
index 0cbac35..a617c74 100644
--- a/apps/ui/src/views/layer/LayerServiceMapView.vue
+++ b/apps/ui/src/views/layer/LayerServiceMapView.vue
@@ -65,7 +65,10 @@ 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 {
+  resolveServiceIdentity,
+  type ServiceIdentity,
+} from '@/utils/serviceName';
 import Sparkline from '@/components/charts/Sparkline.vue';
 import { isUserNode } from '@/composables/useTopologyIcons';
 
@@ -82,6 +85,15 @@ const safeLayer = computed<LayerDef>(() => layer.value ?? {
   key: layerKey.value, name: layerKey.value, color: 'var(--sw-fg-2)',
   serviceCount: -1, active: false, level: null, slots: {}, caps: {},
 });
+// Per-layer service-name parsing rule (k8s/mesh ⇒ namespace, generic ⇒
+// legacy `::`). `identity()` is the single read-side helper: every
+// display site goes through it so the chip alias + group value stay
+// consistent across the focus picker, node label, detail panels, and
+// the group bounding box.
+const namingRule = computed(() => layer.value?.naming ?? null);
+function identity(name: string | null | undefined): ServiceIdentity {
+  return resolveServiceIdentity(name, namingRule.value);
+}
 const safeCfg = computed(() => {
   if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns: 
[], style: 'table' as const };
   return store.ensure(layer.value.key, {
@@ -111,21 +123,24 @@ 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 }
+// Service-list rows grouped by the layer-resolved group value (k8s/mesh
+// ⇒ namespace; generic ⇒ legacy `::` prefix). The search panel renders
+// one section per group; the section heading shows the alias·value
+// (e.g. `namespace · sample`).
+interface GroupedRow { group: string | null; name: string; id: string; raw: 
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);
+    const id = identity(r.serviceName);
     if (term && !r.serviceName.toLowerCase().includes(term)) continue;
-    const key = group ?? '';
+    const key = id.group ?? '';
     if (!map.has(key)) map.set(key, []);
-    map.get(key)!.push({ group, name: base, id: r.serviceId });
+    map.get(key)!.push({ group: id.group, name: id.display, id: r.serviceId, 
raw: r.serviceName });
   }
   return map;
 });
+const groupAliasLabel = computed<string>(() => namingRule.value?.alias ?? 
'group');
 
 // Defensive truncate for long node labels — preserves the head + an
 // ellipsis so cluster IDs that share a long prefix still distinguish.
@@ -380,23 +395,146 @@ function computeBoosterLevels(
   }
   return merged;
 }
-const layoutNodes = computed<LayoutNode[]>(() => {
+// Sentinel encoding for the group key — `null` (ungrouped) gets pinned
+// to a stable string so it can serve as a Map key alongside named
+// groups. Decoded back on the read side.
+const UNGROUPED = '�__ungrouped__';
+function gkeyEnc(k: string | null): string { return k ?? UNGROUPED; }
+function gkeyDec(s: string): string | null { return s === UNGROUPED ? null : 
s; }
+
+/**
+ * One namespace / group bucket inside the topology canvas. Each bucket
+ * runs its own internal BFS column layout (mirroring the legacy single-
+ * graph layout) and is positioned into a row of bucket regions whose
+ * order is decided by an inter-group BFS over the cross-bucket calls.
+ */
+interface GroupBucket {
+  key: string | null;
+  alias: string | null;
+  nodes: LayoutNode[];        // intra-bucket BFS-ordered nodes with `layerIdx`
+  cols: number;               // internal BFS columns
+  maxRowsPerCol: number;      // tallest column in this bucket
+  rect: { x: number; y: number; w: number; h: number };
+}
+
+// Group bounding-box paddings. Top padding is bigger so the alias chip
+// (`namespace · sample`) has room to live above the inner column area.
+const GROUP_PAD_X = 36;
+const GROUP_PAD_TOP = 38;
+const GROUP_PAD_BOTTOM = 28;
+const GROUP_GAP_X = 80;
+
+/**
+ * Two-level layout: bucket by the layer-resolved group, BFS each
+ * bucket internally, then BFS the inter-bucket call graph to decide
+ * the bucket order along the X axis. Returns the buckets in render
+ * order with each bucket's rect already positioned.
+ *
+ * Ungrouped nodes (no `naming` rule match, or synthetic User /
+ * external nodes whose name has no group component) collapse into a
+ * single "null-key" bucket that renders WITHOUT a bounding box — it
+ * preserves the look of layers that don't configure a naming rule.
+ */
+const groupBuckets = computed<GroupBucket[]>(() => {
   const all = nodes.value;
   if (all.length === 0) return [];
-  const levels = computeBoosterLevels(calls.value, all, []);
-  const out: LayoutNode[] = [];
-  levels.forEach((lvl, idx) => {
-    for (const n of lvl) out.push({ ...n, layerIdx: idx });
-  });
-  // Truly-isolated nodes that never got BFS'd (no edges at all,
-  // booster's pool is empty by recursion but ours may still have
-  // them if `nodes` and `calls` disagree). Tuck them on as an extra
-  // rightmost column so they don't drop off the canvas.
-  const seen = new Set(out.map((n) => n.id));
-  const orphanIdx = levels.length;
+  // 1. Bucket nodes by resolved group key.
+  const byGroup = new Map<string, TopologyNode[]>();
   for (const n of all) {
-    if (!seen.has(n.id)) out.push({ ...n, layerIdx: orphanIdx });
+    const id = identity(n.name);
+    const k = gkeyEnc(id.group);
+    if (!byGroup.has(k)) byGroup.set(k, []);
+    byGroup.get(k)!.push(n);
   }
+  // 2. Run BFS on the inter-group meta-graph to decide bucket order.
+  //    Each group becomes a meta-node; cross-group calls become meta-
+  //    edges. `computeBoosterLevels` is reused with synthesised
+  //    TopologyNode / TopologyCall shells.
+  const groupOfId = new Map<string, string>();
+  for (const n of all) groupOfId.set(n.id, gkeyEnc(identity(n.name).group));
+  const interGroupCalls: TopologyCall[] = [];
+  for (const c of calls.value) {
+    const s = groupOfId.get(c.source);
+    const t = groupOfId.get(c.target);
+    if (s === undefined || t === undefined) continue;
+    if (s === t) continue;
+    interGroupCalls.push({
+      id: `${s}->${t}`,
+      source: s,
+      target: t,
+      detectPoints: [],
+      serverMetrics: {}, clientMetrics: {},
+      serverMetricSeries: {}, clientMetricSeries: {},
+      serverCpm: null, serverRespTime: null,
+      clientCpm: null, clientRespTime: null,
+    });
+  }
+  const groupKeys = [...byGroup.keys()];
+  const metaNodes: TopologyNode[] = groupKeys.map((k) => ({
+    id: k, name: k === UNGROUPED ? '' : k,
+    type: null, isReal: true, layers: [],
+    metrics: {}, cpm: null, respTime: null, sla: null,
+  }));
+  const metaLevels = computeBoosterLevels(interGroupCalls, metaNodes, []);
+  const ordered: string[] = [];
+  for (const level of metaLevels) for (const n of level) ordered.push(n.id);
+  // Any groups missed by BFS (no inter-group edges) are tacked on the
+  // end in deterministic order so the canvas doesn't drop them.
+  for (const k of groupKeys) if (!ordered.includes(k)) ordered.push(k);
+  // 3. Internal BFS per bucket — restrict the call graph to the
+  //    bucket's own ids so the seed-pick + traversal don't leak.
+  const buckets: GroupBucket[] = [];
+  for (const k of ordered) {
+    const groupNodes = byGroup.get(k) ?? [];
+    if (groupNodes.length === 0) continue;
+    const ids = new Set(groupNodes.map((n) => n.id));
+    const internalCalls = calls.value.filter((c) => ids.has(c.source) && 
ids.has(c.target));
+    const levels = computeBoosterLevels(internalCalls, groupNodes, []);
+    const lay: LayoutNode[] = [];
+    levels.forEach((lvl, idx) => {
+      for (const n of lvl) lay.push({ ...n, layerIdx: idx });
+    });
+    // Tuck in any nodes the internal BFS missed (isolated nodes that
+    // only have cross-group edges) so they still render.
+    const seen = new Set(lay.map((n) => n.id));
+    const orphanCol = levels.length;
+    for (const n of groupNodes) {
+      if (!seen.has(n.id)) lay.push({ ...n, layerIdx: orphanCol });
+    }
+    const cols = Math.max(1, lay.reduce((m, n) => Math.max(m, n.layerIdx), -1) 
+ 1);
+    const rowsByCol = new Map<number, number>();
+    for (const n of lay) rowsByCol.set(n.layerIdx, (rowsByCol.get(n.layerIdx) 
?? 0) + 1);
+    const maxRowsPerCol = Math.max(1, ...rowsByCol.values());
+    buckets.push({
+      key: gkeyDec(k),
+      alias: namingRule.value?.alias ?? (gkeyDec(k) ? 'group' : null),
+      nodes: lay,
+      cols,
+      maxRowsPerCol,
+      rect: { x: 0, y: 0, w: 0, h: 0 },
+    });
+  }
+  // 4. Position bucket rects left-to-right. Each bucket's width =
+  //    internal-cols * COL_GAP + horizontal padding; its height =
+  //    tallest-col * ROW_GAP + top/bottom padding.
+  let cursorX = 40;
+  for (const b of buckets) {
+    const innerW = b.cols * COL_GAP;
+    const innerH = b.maxRowsPerCol * ROW_GAP;
+    const w = innerW + GROUP_PAD_X * 2;
+    const h = innerH + GROUP_PAD_TOP + GROUP_PAD_BOTTOM;
+    b.rect = { x: cursorX, y: 60, w, h };
+    cursorX += w + GROUP_GAP_X;
+  }
+  return buckets;
+});
+
+// `layoutNodes` survives only as the flat list the rest of the view
+// (drag, selection, edges) reads. Order doesn't matter for them; the
+// per-bucket geometry is read off `groupBuckets`.
+const layoutNodes = computed<LayoutNode[]>(() => {
+  const out: LayoutNode[] = [];
+  for (const b of groupBuckets.value) for (const n of b.nodes) out.push(n);
   return out;
 });
 
@@ -404,35 +542,7 @@ const layoutNodes = computed<LayoutNode[]>(() => {
 // important. The line is constant-weight; direction is conveyed by an
 // animated dashed flow on every edge.)
 
-const NODES_PER_LAYER = 12;
-interface LayerColumn {
-  index: number;
-  label: string;
-  visible: LayoutNode[];
-  hidden: number;
-}
-const layerColumns = computed<LayerColumn[]>(() => {
-  // Booster-ui's algorithm already returned levels in the right order
-  // (seed in level 0, then BFS-expansion). We just preserve that order
-  // and cap each column at NODES_PER_LAYER. No barycentric reorder, no
-  // metric sort, no User-pin — the seed-driven BFS naturally puts the
-  // dominant chain at the top row of each column.
-  const byLayer = new Map<number, LayoutNode[]>();
-  for (const n of layoutNodes.value) {
-    if (!byLayer.has(n.layerIdx)) byLayer.set(n.layerIdx, []);
-    byLayer.get(n.layerIdx)!.push(n);
-  }
-  const indices = [...byLayer.keys()].sort((a, b) => a - b);
-  return indices.map((i) => {
-    const all = byLayer.get(i)!;
-    const visible = all.slice(0, NODES_PER_LAYER);
-    const hidden = Math.max(0, all.length - NODES_PER_LAYER);
-    const label = i === 0 ? 'L0 · Entry' : `L${i} · Tier ${i}`;
-    return { index: i, label, visible, hidden };
-  });
-});
-
-// ── SVG layout math (circles).
+// ── SVG layout math (circles + group bounding boxes).
 /**
  * Node geometry — radius drives the cube/icon size and the column
  * spacing. Sized smaller than the design's r=42 so a chain reads
@@ -446,10 +556,17 @@ const COL_GAP = 220;
 // `agent::songs`) has more breathing room and the diagonal calls
 // reach a clearly distinct row instead of crowding the spine.
 const ROW_GAP = Math.round((NODE_R * 2 + 90) * 1.2);
-const W = computed(() => Math.max(820, layerColumns.value.length * COL_GAP + 
80));
+const W = computed(() => {
+  const b = groupBuckets.value;
+  if (b.length === 0) return 820;
+  const last = b[b.length - 1];
+  return Math.max(820, last.rect.x + last.rect.w + 40);
+});
 const H = computed(() => {
-  const maxNodes = Math.max(1, ...layerColumns.value.map((c) => 
c.visible.length));
-  return 90 + maxNodes * ROW_GAP + 40;
+  const b = groupBuckets.value;
+  if (b.length === 0) return 360;
+  const tallest = Math.max(...b.map((x) => x.rect.y + x.rect.h));
+  return tallest + 60;
 });
 
 /**
@@ -488,13 +605,24 @@ watch(
 
 const nodePos = computed<Map<string, Pos>>(() => {
   const map = new Map<string, Pos>();
-  layerColumns.value.forEach((col, colIdx) => {
-    const cx = 40 + colIdx * COL_GAP + NODE_R + 4;
-    col.visible.forEach((n, rowIdx) => {
-      const cy = 110 + rowIdx * ROW_GAP + NODE_R;
-      map.set(n.id, { cx, cy });
-    });
-  });
+  for (const b of groupBuckets.value) {
+    // Bucket-local column buckets — needed to place each node at its
+    // row index *within its column*. The internal BFS already assigns
+    // each node a `layerIdx`; the row is the node's position in the
+    // column-bucket order, mirroring the legacy single-graph layout.
+    const byCol = new Map<number, LayoutNode[]>();
+    for (const n of b.nodes) {
+      if (!byCol.has(n.layerIdx)) byCol.set(n.layerIdx, []);
+      byCol.get(n.layerIdx)!.push(n);
+    }
+    for (const [colIdx, list] of byCol) {
+      const cx = b.rect.x + GROUP_PAD_X + colIdx * COL_GAP + NODE_R + 4;
+      list.forEach((n, rowIdx) => {
+        const cy = b.rect.y + GROUP_PAD_TOP + rowIdx * ROW_GAP + NODE_R;
+        map.set(n.id, { cx, cy });
+      });
+    }
+  }
   // Drag overrides win — but only when the node is still in the
   // visible set, so a stale id from a previous layer doesn't bleed.
   for (const [id, p] of dragOverrides.value) {
@@ -506,9 +634,6 @@ const visibleCalls = computed<TopologyCall[]>(() => {
   const ids = new Set(nodePos.value.keys());
   return calls.value.filter((c) => ids.has(c.source) && ids.has(c.target));
 });
-const elidedTotal = computed(() =>
-  layerColumns.value.reduce((acc, c) => acc + c.hidden, 0),
-);
 
 /**
  * Resolve the 4-band colour for a node from the ring-metric value.
@@ -972,7 +1097,7 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
         <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>
+          <b>{{ focusServiceNames.length === 1 ? 
identity(focusServiceNames[0]).display : `${focusServiceNames.length} services` 
}}</b>
         </span>
         <span v-if="isFetching" class="hint">refreshing…</span>
       </div>
@@ -1008,16 +1133,19 @@ function fmtWithUnit(v: number | null | undefined, 
unit: string | undefined): st
                 <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>
+                <div v-if="gkey" class="focus-group-head">
+                  <span class="focus-group-alias">{{ groupAliasLabel }}</span>
+                  <span class="focus-group-val">{{ gkey }}</span>
+                </div>
                 <button
                   v-for="r in rows"
                   :key="r.id"
                   class="focus-row"
-                  :class="{ selected: focusServiceNames.includes((r.group ? 
r.group + '::' : '') + r.name) }"
+                  :class="{ selected: focusServiceNames.includes(r.raw) }"
                   type="button"
-                  @click="toggleService((r.group ? r.group + '::' : '') + 
r.name)"
+                  @click="toggleService(r.raw)"
                 >
-                  <span class="focus-check">{{ 
focusServiceNames.includes((r.group ? r.group + '::' : '') + r.name) ? '●' : 
'○' }}</span>
+                  <span class="focus-check">{{ 
focusServiceNames.includes(r.raw) ? '●' : '○' }}</span>
                   <span class="focus-name">{{ r.name }}</span>
                 </button>
               </template>
@@ -1069,11 +1197,60 @@ function fmtWithUnit(v: number | null | undefined, 
unit: string | undefined): st
             <!-- Soft radial glow behind the chain — pure decoration. -->
             <rect :width="W" :height="H" fill="url(#sm-bg-glow)" />
 
-            <!-- Row-baseline guides were dropped — they assumed strict
-                 columns, but the honeycomb stagger (odd columns shifted
-                 down by half a row) makes the dashed lines look broken.
-                 The animated edges + node halos carry enough visual
-                 anchoring on their own. -->
+            <!-- Group bounding boxes — one rounded-rect per namespace /
+                 group with a dashed border + an alias·value chip
+                 anchored top-left. Rendered BENEATH edges so cross-
+                 group calls visually cross the box boundary. The
+                 implicit "no-group" bucket (synthetic User / external)
+                 renders no box. -->
+            <g class="sm-group-layer">
+              <template v-for="b in groupBuckets" :key="b.key ?? '__none__'">
+                <g v-if="b.key" :transform="`translate(${b.rect.x}, 
${b.rect.y})`">
+                  <rect
+                    :width="b.rect.w"
+                    :height="b.rect.h"
+                    rx="14"
+                    ry="14"
+                    fill="var(--sw-bg-1)"
+                    fill-opacity="0.35"
+                    stroke="var(--sw-line-2)"
+                    stroke-width="1"
+                    stroke-dasharray="4 5"
+                  />
+                  <!-- alias chip top-left, inset by the same horizontal
+                       padding as the node columns. -->
+                  <g transform="translate(14, 18)">
+                    <rect
+                      x="0"
+                      y="-12"
+                      :width="Math.max(80, (b.alias ?? '').length * 6 + (b.key 
?? '').length * 7 + 24)"
+                      height="20"
+                      rx="10"
+                      ry="10"
+                      fill="var(--sw-bg-0)"
+                      stroke="var(--sw-accent-line)"
+                      stroke-width="1"
+                    />
+                    <text
+                      x="10"
+                      y="2"
+                      fill="var(--sw-fg-3)"
+                      font-size="10"
+                      font-family="var(--sw-mono)"
+                      font-weight="500"
+                    >{{ b.alias }} ·</text>
+                    <text
+                      :x="10 + (b.alias?.length ?? 0) * 6 + 12"
+                      y="2"
+                      fill="var(--sw-accent-2)"
+                      font-size="11"
+                      font-family="var(--sw-mono)"
+                      font-weight="700"
+                    >{{ b.key }}</text>
+                  </g>
+                </g>
+              </template>
+            </g>
 
             <g
               v-for="c in visibleCalls"
@@ -1104,25 +1281,29 @@ function fmtWithUnit(v: number | null | undefined, 
unit: string | undefined): st
                 stroke-linecap="round"
                 style="pointer-events: none"
               />
-              <!-- 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. -->
+              <!-- Direction overlay: short dot-like dashes that drift
+                   from source → target. Spacing + speed are tuned for
+                   readability — earlier the dashes were dense (6-on /
+                   10-off, 1.2s) and read as a fast-scrolling solid
+                   line. Now they're discrete particles (4-on / 28-off,
+                   3s) so the eye can track a single dot along the
+                   path. Stroke is slightly thicker + round-capped so
+                   each dot reads as a circle rather than a tick. -->
               <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-width="selectedCallId === c.id ? 4 : 3"
                 stroke-linecap="round"
-                stroke-dasharray="6 10"
-                opacity="0.9"
+                stroke-dasharray="4 28"
+                opacity="0.95"
                 style="pointer-events: none"
               >
                 <animate
                   attributeName="stroke-dashoffset"
-                  from="16"
+                  from="32"
                   to="0"
-                  dur="1.2s"
+                  dur="3s"
                   repeatCount="indefinite"
                 />
               </path>
@@ -1317,7 +1498,7 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
                 font-family="var(--sw-mono)"
                 :font-weight="selectedNodeId === n.id ? 700 : 600"
               >
-                {{ truncateLabel(serviceBaseName(n.name), 22) }}
+                {{ truncateLabel(identity(n.name).display, 22) }}
               </text>
               <!-- Latency (secondary metric) below the name. No label
                    — the unit chip disambiguates from RPM. Hidden when
@@ -1368,9 +1549,6 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
             <span class="lg-aside">direction shown by flow animation</span>
           </div>
         </div>
-        <div v-if="elidedTotal > 0" class="cap-chip">
-          {{ elidedTotal }} node{{ elidedTotal === 1 ? '' : 's' }} elided 
across columns
-        </div>
       </div>
 
       <!-- Right sidebar — node panel on top, edge panel underneath.
@@ -1384,9 +1562,12 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
       <article v-if="selectedNode" class="sm-panel">
         <header class="sp-head">
           <div class="sp-id">
-            <div class="sp-mono">{{ selectedNode.name }}</div>
+            <div class="sp-mono">{{ identity(selectedNode.name).display 
}}</div>
             <div class="sp-tags">
-              <span v-if="parseServiceName(selectedNode.name).group" 
class="sw-tag accent">{{ parseServiceName(selectedNode.name).group }}</span>
+              <span v-if="identity(selectedNode.name).group" class="sw-tag 
accent">
+                <span class="tag-alias">{{ identity(selectedNode.name).alias 
}}</span>
+                <span class="tag-val">{{ identity(selectedNode.name).group 
}}</span>
+              </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>
             </div>
@@ -1406,7 +1587,11 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
           <ul class="sp-list">
             <li v-for="u in upstream" :key="u.id">
               <span class="sp-pulse" :style="{ color: ringColor(u) }">●</span>
-              <span class="sp-mono small">{{ u.name }}</span>
+              <span v-if="identity(u.name).group" class="sw-tag accent tiny">
+                <span class="tag-alias">{{ identity(u.name).alias }}</span>
+                <span class="tag-val">{{ identity(u.name).group }}</span>
+              </span>
+              <span class="sp-mono small">{{ identity(u.name).display }}</span>
               <span class="sp-cpm">{{ fmtWithUnit(nodeVal(u, centerDef), 
centerDef?.unit) }}</span>
             </li>
             <li v-if="upstream.length === 0" class="sp-empty">no upstream 
callers in window</li>
@@ -1417,7 +1602,11 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
           <ul class="sp-list">
             <li v-for="d in downstream" :key="d.id">
               <span class="sp-pulse" :style="{ color: ringColor(d) }">●</span>
-              <span class="sp-mono small">{{ d.name }}</span>
+              <span v-if="identity(d.name).group" class="sw-tag accent tiny">
+                <span class="tag-alias">{{ identity(d.name).alias }}</span>
+                <span class="tag-val">{{ identity(d.name).group }}</span>
+              </span>
+              <span class="sp-mono small">{{ identity(d.name).display }}</span>
               <span class="sp-cpm">{{ fmtWithUnit(nodeVal(d, secondaryDef), 
secondaryDef?.unit) }}</span>
             </li>
             <li v-if="downstream.length === 0" class="sp-empty">no downstream 
deps in window</li>
@@ -1444,9 +1633,21 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
         <header class="sp-head">
           <div class="sp-id">
             <div class="sp-edge-row">
-              <span class="sp-mono small">{{ selectedCallSource.name }}</span>
+              <span class="sp-svc">
+                <span v-if="identity(selectedCallSource.name).group" 
class="sw-tag accent tiny">
+                  <span class="tag-alias">{{ 
identity(selectedCallSource.name).alias }}</span>
+                  <span class="tag-val">{{ 
identity(selectedCallSource.name).group }}</span>
+                </span>
+                <span class="sp-mono small">{{ 
identity(selectedCallSource.name).display }}</span>
+              </span>
               <span class="sp-edge-arrow">→</span>
-              <span class="sp-mono small">{{ selectedCallTarget.name }}</span>
+              <span class="sp-svc">
+                <span v-if="identity(selectedCallTarget.name).group" 
class="sw-tag accent tiny">
+                  <span class="tag-alias">{{ 
identity(selectedCallTarget.name).alias }}</span>
+                  <span class="tag-val">{{ 
identity(selectedCallTarget.name).group }}</span>
+                </span>
+                <span class="sp-mono small">{{ 
identity(selectedCallTarget.name).display }}</span>
+              </span>
             </div>
             <div class="sp-tags">
               <span class="sw-tag">{{ selectedCall.detectPoints.join(' · ') || 
'relation' }}</span>
@@ -1701,6 +1902,17 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
   text-transform: uppercase;
   color: var(--sw-fg-3);
   padding: 6px 8px 4px;
+  display: inline-flex;
+  align-items: baseline;
+  gap: 6px;
+}
+.focus-group-alias { color: var(--sw-fg-3); }
+.focus-group-alias::after { content: '·'; margin-left: 4px; color: 
var(--sw-fg-3); }
+.focus-group-val {
+  color: var(--sw-accent-2);
+  font-family: var(--sw-mono);
+  text-transform: none;
+  letter-spacing: 0;
 }
 .focus-row {
   display: flex;
@@ -1850,11 +2062,34 @@ function fmtWithUnit(v: number | null | undefined, 
unit: string | undefined): st
   display: inline-flex;
   align-items: center;
   gap: 6px;
+  flex-wrap: wrap;
 }
 .sp-edge-arrow {
   color: var(--sw-fg-3);
   font-size: 11px;
 }
+.sp-svc {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  min-width: 0;
+}
+.sw-tag.tiny {
+  font-size: 9.5px;
+  padding: 0 5px;
+  line-height: 14px;
+  height: 14px;
+}
+/* `<alias · value>` chip layout — used by node / edge / list / group
+   bounding-box title rows so the dimension label (e.g. `namespace`)
+   reads as a subdued prefix and the value pops in accent colour. */
+.sw-tag .tag-alias {
+  opacity: 0.7;
+  font-weight: 500;
+  margin-right: 4px;
+}
+.sw-tag .tag-alias::after { content: '·'; margin-left: 4px; }
+.sw-tag .tag-val { font-family: var(--sw-mono); font-weight: 600; }
 /* Edge line-metric cards. One card per metric, two sparkline cells
    per card (client | server). The pair grid stays 1:1 even when
    only one side has data — the empty side renders a full-width cell
diff --git a/apps/ui/src/views/layer/LayerServiceSelector.vue 
b/apps/ui/src/views/layer/LayerServiceSelector.vue
index 4e0dd2b..b8ad39d 100644
--- a/apps/ui/src/views/layer/LayerServiceSelector.vue
+++ b/apps/ui/src/views/layer/LayerServiceSelector.vue
@@ -28,7 +28,8 @@ 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';
+import { resolveServiceIdentity } from '@/utils/serviceName';
+import type { ServiceNamingRule } from '@skywalking-horizon-ui/api-client';
 
 const props = withDefaults(
   defineProps<{
@@ -37,12 +38,20 @@ const props = withDefaults(
     selectedId: string | null;
     accent?: string;
     pageSize?: number;
+    /** Per-layer service-name parsing rule. When supplied, rows render
+     *  `<alias · value>` chip + display label; when null, falls back to
+     *  the legacy `<group>::base` parser. */
+    namingRule?: ServiceNamingRule | null;
   }>(),
   {
     accent: 'var(--sw-accent)',
     pageSize: 8,
+    namingRule: null,
   },
 );
+function identity(name: string | null | undefined) {
+  return resolveServiceIdentity(name, props.namingRule);
+}
 const emit = defineEmits<{ (e: 'select', id: string): void }>();
 
 const filter = ref('');
@@ -102,8 +111,11 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
         >
           <td class="svc-col" :title="row.serviceName">
             <span class="pulse" :style="{ background: 
colorForStatus(statusForMetrics(row.metrics)) }" />
-            <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>
+            <span v-if="identity(row.serviceName).group" class="group-chip">
+              <span class="chip-alias">{{ identity(row.serviceName).alias 
}}</span>
+              <span class="chip-val">{{ identity(row.serviceName).group 
}}</span>
+            </span>
+            <span class="name-text">{{ row.shortName || 
identity(row.serviceName).display }}</span>
           </td>
           <td
             v-for="c in columns"
@@ -253,7 +265,9 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
    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;
+  display: inline-flex;
+  align-items: baseline;
+  gap: 4px;
   margin-right: 6px;
   padding: 1px 6px;
   background: var(--sw-bg-2);
@@ -266,6 +280,9 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
   text-transform: uppercase;
   vertical-align: middle;
 }
+.group-chip .chip-alias { opacity: 0.7; font-weight: 500; }
+.group-chip .chip-alias::after { content: '·'; margin: 0 2px; }
+.group-chip .chip-val { font-family: var(--sw-mono); text-transform: none; 
letter-spacing: 0; }
 .row.active .group-chip {
   color: var(--sw-accent-2);
   border-color: var(--sw-accent-line);
diff --git a/apps/ui/src/views/layer/LayerShell.vue 
b/apps/ui/src/views/layer/LayerShell.vue
index 1b6a5a1..157b436 100644
--- a/apps/ui/src/views/layer/LayerShell.vue
+++ b/apps/ui/src/views/layer/LayerShell.vue
@@ -27,7 +27,7 @@
 -->
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue';
-import { RouterLink, RouterView, useRoute } from 'vue-router';
+import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
 import type { LayerDef } from '@skywalking-horizon-ui/api-client';
 import Icon from '@/components/icons/Icon.vue';
 import Sparkline from '@/components/charts/Sparkline.vue';
@@ -35,19 +35,65 @@ import LayerServiceSelector from 
'./LayerServiceSelector.vue';
 import { metricMeta } from '@/composables/metricCatalog';
 import { colorForMetric } from '@/composables/metricColor';
 import { useLayerLanding } from '@/composables/useLayerLanding';
-import { useLayers } from '@/composables/useLayers';
+import { useLayers, firstLayerTab } 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 router = useRouter();
 const layerKey = computed(() => String(route.params.layerKey ?? ''));
 const { layers } = useLayers();
 const layer = computed<LayerDef | null>(() => {
   const found = layers.value.find((l) => l.key === layerKey.value);
   return found ?? null;
 });
+
+// Auto-redirect when the URL targets a sub-route the layer doesn't
+// support — e.g. `/layer/mesh_dp/service` on a layer with
+// `components.service: false`. Without this the operator lands on an
+// empty "No widgets defined" page even though the layer DOES have
+// other tabs (Instance / Logs / …). Fires once per change so the
+// browser-back button works as expected.
+//
+// Matrix of route segments that need the layer cap to be present:
+//   service     ⇒ caps.dashboards
+//   instance    ⇒ slots.instances
+//   endpoint    ⇒ slots.endpoints
+//   topology    ⇒ caps.serviceMap | caps.instanceTopology | 
caps.processTopology
+//   dependency  ⇒ caps.endpointDependency
+//   trace       ⇒ caps.traces
+//   logs        ⇒ caps.logs
+//   *-profiling ⇒ caps.*Profiling
+const SCOPE_CAP_PREDICATE: Record<string, (L: LayerDef) => boolean> = {
+  service: (L) => Boolean(L.caps?.dashboards),
+  instance: (L) => Boolean(L.slots?.instances),
+  endpoint: (L) => Boolean(L.slots?.endpoints),
+  topology: (L) => Boolean(L.caps?.serviceMap || L.caps?.instanceTopology || 
L.caps?.processTopology),
+  dependency: (L) => Boolean(L.caps?.endpointDependency),
+  trace: (L) => Boolean(L.caps?.traces),
+  logs: (L) => Boolean(L.caps?.logs),
+  'trace-profiling': (L) => Boolean(L.caps?.traceProfiling),
+  'ebpf-profiling': (L) => Boolean(L.caps?.ebpfProfiling),
+  'async-profiling': (L) => Boolean(L.caps?.asyncProfiling),
+};
+watch(
+  [() => route.path, layer],
+  ([path, L]) => {
+    if (!L) return;
+    const m = path.match(/^\/layer\/[^/]+\/([^/?]+)/);
+    if (!m) return;
+    const scope = m[1];
+    const predicate = SCOPE_CAP_PREDICATE[scope];
+    if (!predicate) return; // unknown scope — let the router resolve
+    if (predicate(L)) return; // layer supports this scope, nothing to do
+    const fallback = firstLayerTab(L);
+    if (fallback === scope) return; // already at the best fallback
+    void router.replace({ path: `/layer/${L.key}/${fallback}`, query: 
route.query });
+  },
+  { immediate: true },
+);
 const store = useSetupStore();
 const cfg = computed(() => {
   if (!layer.value) return null;
@@ -305,6 +351,7 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
       :columns="selectorColumns"
       :selected-id="selectedId"
       :accent="layer.color"
+      :naming-rule="layer.naming ?? null"
       @select="pickService"
     />
 
diff --git a/horizon.example.yaml b/horizon.example.yaml
index e495b95..7aba7cd 100644
--- a/horizon.example.yaml
+++ b/horizon.example.yaml
@@ -25,6 +25,12 @@ oap:
     - http://127.0.0.1:17128
   # OAP query/status host (port 12800 by default; GraphQL + /status/*).
   statusUrl: http://127.0.0.1:12800
+  # OAP's Zipkin v2 REST host. Defaults to a standalone port (9412 +
+  # /zipkin) per the upstream Armeria binding. When OAP is configured
+  # to share the GraphQL port (typical for the demo / k8s deploys),
+  # use `<statusUrl>/zipkin` instead. Used by the Zipkin-source trace
+  # views on mesh / k8s layers.
+  zipkinUrl: http://127.0.0.1:9412/zipkin
   timeoutMs: 15000
   # Optional basic-auth credentials for outbound OAP calls (GraphQL,
   # /status, Zipkin /api/v2/*). The public demo at
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index b056077..8477ddf 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -27,6 +27,7 @@ export type {
   MenuResponse,
   OverviewGroup,
   OverviewMetric,
+  ServiceNamingRule,
 } from './menu.js';
 export type {
   AggregationKind,
@@ -93,6 +94,26 @@ export type {
   LogFacetsResponse,
 } from './logs.js';
 export type { OapInfo } from './oap-info.js';
+export type {
+  ProfileTask,
+  ProfileTaskLog,
+  ProfileTaskListResponse,
+  ProfileTaskLogsResponse,
+  ProfileSpan,
+  ProfileSpanRef,
+  ProfileSpanTag,
+  ProfileSpanLog,
+  ProfileSpanLogData,
+  ProfileSegment,
+  ProfileSegmentsResponse,
+  ProfileAnalyzationElement,
+  ProfileAnalyzationTree,
+  ProfileAnalyzationResponse,
+  ProfileTimeRange,
+  ProfileAnalyzeQuery,
+  ProfileTaskCreationRequest,
+  ProfileTaskCreationResponse,
+} from './profile.js';
 export type {
   OverviewWidgetType,
   OverviewWidget,
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index bb236c1..6df4bab 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -97,6 +97,40 @@ export interface LayerHeaderConfig {
 /** @deprecated alias kept for callers — same shape as LayerHeaderConfig. */
 export type LayerMetricsConfig = LayerHeaderConfig;
 
+/**
+ * Per-layer service-name parsing rule. Some layers (k8s, mesh, cilium)
+ * encode a grouping dimension into the service name itself:
+ *
+ *   - `songs.sample`     → display: `songs`,    group: `sample` (k8s/mesh ⇒ 
namespace)
+ *   - `agent::checkout`  → display: `checkout`, group: `agent`  (generic ⇒ 
group)
+ *
+ * The rule is a named-capture regex evaluated against the raw service
+ * name. `display` is what UI surfaces as the service label; `group` is
+ * the value used for clustering nodes in topology. `alias` is the
+ * human-readable category label for that group (`namespace`, `group`,
+ * `tenant`, `fleet`, …) and shows up next to the value in chips and
+ * group bounding boxes.
+ *
+ * When the regex doesn't match a given name, the UI falls back to the
+ * legacy `<group>::<base>` parser, then to "no group".
+ */
+export interface ServiceNamingRule {
+  /** JavaScript regex source. MUST contain named groups for both
+   *  `display` and `group` (the names below override the captures). */
+  pattern: string;
+  /** Flags passed to `new RegExp(pattern, flags)`. Default `''`. */
+  flags?: string;
+  /** Named-capture group name that yields the displayable service
+   *  label. Defaults to `'service'`. */
+  displayGroup?: string;
+  /** Named-capture group name that yields the group/namespace value.
+   *  Defaults to `'group'`. */
+  valueGroup?: string;
+  /** Human label for the dimension (e.g. `namespace`, `group`,
+   *  `tenant`). Surfaced as a chip prefix and group-box title. */
+  alias: string;
+}
+
 /**
  * One self-contained metric on the Overview tile. Each carries its own
  * MQE expression + label + presentation hints; the Overview tile does
@@ -210,6 +244,10 @@ export interface LayerDef {
    *  agent-traced layers carry per-service logs. Drives the UI scope +
    *  the BFF query filter ride-along. */
   log?: LogConfig;
+  /** Per-layer service-name parsing rule. When present, the UI runs
+   *  every service name through this regex to derive `{ display, group }`
+   *  and clusters topology nodes by group. Absent ⇒ legacy `::` parser. */
+  naming?: ServiceNamingRule;
 }
 
 export interface LogConfig {
diff --git a/packages/api-client/src/profile.ts 
b/packages/api-client/src/profile.ts
new file mode 100644
index 0000000..ab5f34e
--- /dev/null
+++ b/packages/api-client/src/profile.ts
@@ -0,0 +1,166 @@
+/*
+ * 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-level types for the BFF's profile (trace-driven thread profiling) 
routes.
+ *
+ * Mirrors the OAP query-protocol `ProfileTask*` / `Segments*` shape; field 
names
+ * are kept identical so downstream code can pass payloads straight through.
+ */
+
+export interface ProfileTaskLog {
+  id: string;
+  instanceId: string;
+  instanceName: string;
+  operationType: string;
+  operationTime: number;
+}
+
+export interface ProfileTask {
+  id: string;
+  serviceId: string;
+  serviceName?: string;
+  endpointName: string;
+  startTime: number;
+  duration: number;
+  minDurationThreshold: number;
+  dumpPeriod: number;
+  maxSamplingCount: number;
+  logs?: ProfileTaskLog[];
+}
+
+export interface ProfileTaskListResponse {
+  tasks: ProfileTask[];
+  reachable: boolean;
+  error?: string;
+}
+
+export interface ProfileTaskLogsResponse {
+  logs: ProfileTaskLog[];
+  reachable: boolean;
+  error?: string;
+}
+
+export interface ProfileSpanRef {
+  traceId: string;
+  parentSegmentId: string;
+  parentSpanId: number;
+  type: string;
+}
+
+export interface ProfileSpanTag {
+  key: string;
+  value: string;
+}
+
+export interface ProfileSpanLogData {
+  key: string;
+  value: string;
+}
+
+export interface ProfileSpanLog {
+  time: number;
+  data: ProfileSpanLogData[];
+}
+
+export interface ProfileSpan {
+  spanId: number;
+  parentSpanId: number;
+  segmentId: string;
+  refs?: ProfileSpanRef[];
+  serviceCode: string;
+  serviceInstanceName: string;
+  startTime: number;
+  endTime: number;
+  endpointName: string;
+  type: string;
+  peer: string;
+  component: string;
+  isError: boolean;
+  layer: string;
+  tags?: ProfileSpanTag[];
+  logs?: ProfileSpanLog[];
+  profiled: boolean;
+  /** Convenience copy from the containing segment (BFF-attached). */
+  traceId?: string;
+  /** Recursive child spans assembled client-side. */
+  children?: ProfileSpan[];
+}
+
+export interface ProfileSegment {
+  traceId: string;
+  instanceId: string;
+  instanceName: string;
+  endpointNames: string[];
+  duration: number;
+  start: string;
+  isError?: boolean;
+  spans: ProfileSpan[];
+}
+
+export interface ProfileSegmentsResponse {
+  segments: ProfileSegment[];
+  reachable: boolean;
+  error?: string;
+}
+
+export interface ProfileAnalyzationElement {
+  id: string;
+  parentId: string;
+  codeSignature: string;
+  duration: number;
+  durationChildExcluded: number;
+  count: number;
+}
+
+export interface ProfileAnalyzationTree {
+  elements: ProfileAnalyzationElement[];
+}
+
+export interface ProfileAnalyzationResponse {
+  tip: string | null;
+  trees: ProfileAnalyzationTree[];
+  reachable: boolean;
+  error?: string;
+}
+
+export interface ProfileTimeRange {
+  start: number;
+  end: number;
+}
+
+export interface ProfileAnalyzeQuery {
+  segmentId: string;
+  timeRange: ProfileTimeRange;
+}
+
+export interface ProfileTaskCreationRequest {
+  serviceId: string;
+  endpointName: string;
+  startTime: number;
+  duration: number;
+  minDurationThreshold: number;
+  dumpPeriod: number;
+  maxSamplingCount: number;
+}
+
+export interface ProfileTaskCreationResponse {
+  id?: string;
+  errorReason?: string;
+  reachable: boolean;
+  error?: string;
+}

Reply via email to