You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/05/15 06:18:50 UTC

(skywalking-horizon-ui) branch main updated (de3881b -> ea51f89)

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a change to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


    from de3881b  0.2.0: topology booster-port + logs rebuild + per-scope dashboards + sidebar polish
     new 07c5076  ui: log condition rebuild + sidebar URL-driven focus + BFF service-id fix
     new 7789b41  zipkin trace proxy + topology / dashboard fixes + log + sidebar polish
     new ea51f89  zipkin trace UI + profiling stack (trace/eBPF/async/network/pprof) + service-name model

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .gitignore                                         |    3 +
 .../bff/src/bundled_templates/layers/activemq.json |  145 +--
 .../bff/src/bundled_templates/layers/banyandb.json |  263 ----
 .../bundled_templates/layers/cilium_service.json   |    6 +
 apps/bff/src/bundled_templates/layers/general.json |    4 +-
 apps/bff/src/bundled_templates/layers/k8s.json     |    3 +
 .../src/bundled_templates/layers/k8s_service.json  |   11 +-
 apps/bff/src/bundled_templates/layers/mesh.json    |   12 +-
 apps/bff/src/bundled_templates/layers/mesh_cp.json |    9 +
 apps/bff/src/bundled_templates/layers/mesh_dp.json |  155 ++-
 .../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 +-
 .../bundled_templates/layers/virtual_genai.json    |    8 +-
 apps/bff/src/config/schema.ts                      |    8 +
 apps/bff/src/dashboard/routes.ts                   |   10 +
 apps/bff/src/layers/loader.ts                      |   11 +-
 apps/bff/src/oap/async-profile-routes.ts           |  391 ++++++
 apps/bff/src/oap/ebpf-routes.ts                    |  473 +++++++
 apps/bff/src/oap/endpoint-routes.ts                |    7 +-
 apps/bff/src/oap/instance-routes.ts                |   11 +-
 apps/bff/src/oap/log-routes.ts                     |   12 +-
 apps/bff/src/oap/menu-routes.ts                    |   37 +-
 apps/bff/src/oap/profile-routes.ts                 |  313 +++++
 apps/bff/src/oap/topology-routes.ts                |   46 +-
 apps/bff/src/oap/trace-routes.ts                   |    9 +-
 apps/bff/src/oap/zipkin-routes.ts                  |  316 +++++
 apps/bff/src/server.ts                             |    8 +
 apps/ui/src/api/client.ts                          |  338 +++++
 apps/ui/src/components/charts/TimeChart.vue        |   12 +-
 .../components/profile/ProcessTopologyGraph.vue    |  288 +++++
 .../src/components/profile/ProfileFlameGraph.vue   |  267 ++++
 apps/ui/src/components/profile/ProfileStackRow.vue |  135 ++
 .../src/components/profile/ProfileStackTable.vue   |  199 +++
 apps/ui/src/components/shell/AppShell.vue          |    6 +
 apps/ui/src/components/shell/AppSidebar.vue        |  130 +-
 apps/ui/src/components/shell/AppTopbar.vue         |   58 +-
 apps/ui/src/components/trace/ZipkinTracePopout.vue |  487 ++++++++
 apps/ui/src/composables/useLayers.ts               |   14 +-
 apps/ui/src/composables/useSelectedEndpoint.ts     |    2 +
 apps/ui/src/composables/useSelectedInstance.ts     |    2 +
 apps/ui/src/composables/useSelectedService.ts      |    7 +
 ...SelectedEndpoint.ts => useZipkinTracePopout.ts} |   30 +-
 apps/ui/src/composables/useZipkinTraces.ts         |  114 ++
 apps/ui/src/router/index.ts                        |   18 +-
 apps/ui/src/utils/serviceName.ts                   |  133 +-
 apps/ui/src/views/admin/LayerDashboardsAdmin.vue   |  413 ++++++-
 .../ui/src/views/layer/LayerAsyncProfilingView.vue |  634 ++++++++++
 apps/ui/src/views/layer/LayerDashboardsView.vue    |   28 +-
 apps/ui/src/views/layer/LayerEBPFProfilingView.vue |  959 ++++++++++++++
 .../views/layer/LayerEndpointDependencyView.vue    |   45 +-
 apps/ui/src/views/layer/LayerLogsView.vue          |  347 ++++--
 .../src/views/layer/LayerNetworkProfilingView.vue  |  723 +++++++++++
 .../ui/src/views/layer/LayerPprofProfilingView.vue |  586 +++++++++
 apps/ui/src/views/layer/LayerServiceMapView.vue    |  788 ++++++++++--
 apps/ui/src/views/layer/LayerServiceSelector.vue   |   27 +-
 apps/ui/src/views/layer/LayerShell.vue             |   72 +-
 .../ui/src/views/layer/LayerTraceProfilingView.vue | 1036 ++++++++++++++++
 apps/ui/src/views/layer/LayerTracesEntry.vue       |  116 ++
 apps/ui/src/views/layer/LayerZipkinTracesView.vue  | 1307 ++++++++++++++++++++
 horizon.example.yaml                               |    6 +
 packages/api-client/src/async-profile.ts           |  162 +++
 packages/api-client/src/ebpf.ts                    |  192 +++
 packages/api-client/src/index.ts                   |   69 ++
 packages/api-client/src/menu.ts                    |   49 +
 packages/api-client/src/profile.ts                 |  166 +++
 packages/api-client/src/topology.ts                |   10 +
 68 files changed, 11630 insertions(+), 728 deletions(-)
 delete mode 100644 apps/bff/src/bundled_templates/layers/banyandb.json
 create mode 100644 apps/bff/src/oap/async-profile-routes.ts
 create mode 100644 apps/bff/src/oap/ebpf-routes.ts
 create mode 100644 apps/bff/src/oap/profile-routes.ts
 create mode 100644 apps/bff/src/oap/zipkin-routes.ts
 create mode 100644 apps/ui/src/components/profile/ProcessTopologyGraph.vue
 create mode 100644 apps/ui/src/components/profile/ProfileFlameGraph.vue
 create mode 100644 apps/ui/src/components/profile/ProfileStackRow.vue
 create mode 100644 apps/ui/src/components/profile/ProfileStackTable.vue
 create mode 100644 apps/ui/src/components/trace/ZipkinTracePopout.vue
 copy apps/ui/src/composables/{useSelectedEndpoint.ts => useZipkinTracePopout.ts} (58%)
 create mode 100644 apps/ui/src/composables/useZipkinTraces.ts
 create mode 100644 apps/ui/src/views/layer/LayerAsyncProfilingView.vue
 create mode 100644 apps/ui/src/views/layer/LayerEBPFProfilingView.vue
 create mode 100644 apps/ui/src/views/layer/LayerNetworkProfilingView.vue
 create mode 100644 apps/ui/src/views/layer/LayerPprofProfilingView.vue
 create mode 100644 apps/ui/src/views/layer/LayerTraceProfilingView.vue
 create mode 100644 apps/ui/src/views/layer/LayerTracesEntry.vue
 create mode 100644 apps/ui/src/views/layer/LayerZipkinTracesView.vue
 create mode 100644 packages/api-client/src/async-profile.ts
 create mode 100644 packages/api-client/src/ebpf.ts
 create mode 100644 packages/api-client/src/profile.ts


(skywalking-horizon-ui) 02/03: zipkin trace proxy + topology / dashboard fixes + log + sidebar polish

Posted by wu...@apache.org.
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 <wu...@foxmail.com>
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;
+}


(skywalking-horizon-ui) 03/03: zipkin trace UI + profiling stack (trace/eBPF/async/network/pprof) + service-name model

Posted by wu...@apache.org.
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 ea51f8946d3d43b385cf9c4b0c77c4ffee42cde6
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri May 15 14:17:22 2026 +0800

    zipkin trace UI + profiling stack (trace/eBPF/async/network/pprof) + service-name model
    
    UI
    
    - Zipkin trace tab (LayerTracesEntry dispatcher → LayerZipkinTracesView /
      LayerTracesView based on layer template `traces.source`). Native-card row
      list with proportional duration bars, folder-style rail with collapsed
      progress-bar mode, inline span-detail panel (Lens-style) showing identity
      / tags / annotations, decoupled from SkyWalking's service universe with
      service-scoped span + remote-service autocomplete and OAP annotation-key
      autocomplete. Custom absolute time range alongside presets. Separate
      ZipkinTracePopout mounted globally on its own `?openZipkinTraceId=` key.
    
    - Profiling tabs split out per type — LayerTraceProfilingView /
      LayerEBPFProfilingView / LayerAsyncProfilingView / LayerNetworkProfilingView
      / LayerPprofProfilingView. Shared components/profile/{ProfileFlameGraph,
      ProfileStackTable, ProfileStackRow, ProcessTopologyGraph}. Sidebar /
      router / topbar / LayerShell wired for the new scopes; first-layer-tab
      picker now honors `caps.dashboards` so sidecar-only layers don't land on
      an empty service page.
    
    - Service-name model rewrite. ServiceIdentity now carries
      `{ display, cluster, clusterAlias, legacyGroup }`. The OAP `<group>::`
      legacy prefix is always stripped from displayed labels by default;
      TopologyConfig / EndpointDependencyConfig opt in via `showGroup` to
      surface it as a separate chip in node detail panels. Cluster + legacy
      conventions can stack (e.g. `mesh-svr::reviews.default`). Consumers
      (LayerServiceSelector, LayerEndpointDependencyView) updated.
    
    BFF
    
    - New routes: profile-routes (trace profiling), ebpf-routes (CPU + network),
      async-profile-routes (JVM async + Go pprof). Zipkin routes extended with
      `/autocomplete/keys` + `/autocomplete/values` proxies.
    
    - menu-routes exposes `traces.source` and `networkProfiling` /
      `pprofProfiling` caps. Layer templates: k8s_service alias →
      "Kubernetes Services"; mesh + general flip on networkProfiling /
      pprofProfiling components.
    
    api-client
    
    - LayerCaps gains networkProfiling + pprofProfiling. LayerDef carries
      `traces: { source: 'native' | 'zipkin' | 'both' }`. New ebpf.ts and
      async-profile.ts type modules re-exported through the index.
---
 apps/bff/src/bundled_templates/layers/general.json |    4 +-
 .../src/bundled_templates/layers/k8s_service.json  |    2 +-
 apps/bff/src/bundled_templates/layers/mesh.json    |    3 +-
 apps/bff/src/layers/loader.ts                      |    8 +-
 apps/bff/src/oap/async-profile-routes.ts           |  391 ++++++
 apps/bff/src/oap/ebpf-routes.ts                    |  473 +++++++
 apps/bff/src/oap/menu-routes.ts                    |    3 +
 apps/bff/src/oap/profile-routes.ts                 |  313 +++++
 apps/bff/src/oap/zipkin-routes.ts                  |   38 +
 apps/bff/src/server.ts                             |    6 +
 apps/ui/src/api/client.ts                          |  289 +++++
 .../components/profile/ProcessTopologyGraph.vue    |  288 +++++
 .../src/components/profile/ProfileFlameGraph.vue   |  267 ++++
 apps/ui/src/components/profile/ProfileStackRow.vue |  135 ++
 .../src/components/profile/ProfileStackTable.vue   |  199 +++
 apps/ui/src/components/shell/AppShell.vue          |    6 +
 apps/ui/src/components/shell/AppSidebar.vue        |   32 +
 apps/ui/src/components/shell/AppTopbar.vue         |    2 +
 apps/ui/src/components/trace/ZipkinTracePopout.vue |  487 ++++++++
 apps/ui/src/composables/useLayers.ts               |   10 +-
 apps/ui/src/composables/useZipkinTracePopout.ts    |   50 +
 apps/ui/src/composables/useZipkinTraces.ts         |  114 ++
 apps/ui/src/router/index.ts                        |   18 +-
 apps/ui/src/utils/serviceName.ts                   |  141 ++-
 apps/ui/src/views/admin/LayerDashboardsAdmin.vue   |  413 ++++++-
 .../ui/src/views/layer/LayerAsyncProfilingView.vue |  634 ++++++++++
 apps/ui/src/views/layer/LayerEBPFProfilingView.vue |  959 ++++++++++++++
 .../views/layer/LayerEndpointDependencyView.vue    |   21 +-
 .../src/views/layer/LayerNetworkProfilingView.vue  |  723 +++++++++++
 .../ui/src/views/layer/LayerPprofProfilingView.vue |  586 +++++++++
 apps/ui/src/views/layer/LayerServiceMapView.vue    |  555 +++++++--
 apps/ui/src/views/layer/LayerServiceSelector.vue   |   14 +-
 apps/ui/src/views/layer/LayerShell.vue             |    2 +
 .../ui/src/views/layer/LayerTraceProfilingView.vue | 1036 ++++++++++++++++
 apps/ui/src/views/layer/LayerTracesEntry.vue       |  116 ++
 apps/ui/src/views/layer/LayerZipkinTracesView.vue  | 1307 ++++++++++++++++++++
 packages/api-client/src/async-profile.ts           |  162 +++
 packages/api-client/src/ebpf.ts                    |  192 +++
 packages/api-client/src/index.ts                   |   48 +
 packages/api-client/src/menu.ts                    |   13 +-
 packages/api-client/src/topology.ts                |   10 +
 41 files changed, 9876 insertions(+), 194 deletions(-)

diff --git a/apps/bff/src/bundled_templates/layers/general.json b/apps/bff/src/bundled_templates/layers/general.json
index 377fe4a..b0b5b38 100644
--- a/apps/bff/src/bundled_templates/layers/general.json
+++ b/apps/bff/src/bundled_templates/layers/general.json
@@ -19,7 +19,9 @@
     "logs": true,
     "traceProfiling": true,
     "ebpfProfiling": true,
-    "asyncProfiling": true
+    "asyncProfiling": true,
+    "networkProfiling": true,
+    "pprofProfiling": true
   },
   "layer-header": {
     "orderBy": "cpm",
diff --git a/apps/bff/src/bundled_templates/layers/k8s_service.json b/apps/bff/src/bundled_templates/layers/k8s_service.json
index 287d928..5922e65 100644
--- a/apps/bff/src/bundled_templates/layers/k8s_service.json
+++ b/apps/bff/src/bundled_templates/layers/k8s_service.json
@@ -1,6 +1,6 @@
 {
   "key": "K8S_SERVICE",
-  "alias": "K8s Service",
+  "alias": "Kubernetes Services",
   "group": "Kubernetes",
   "color": "var(--sw-purple)",
   "documentLink": "https://skywalking.apache.org/docs/main/next/en/setup/backend/backend-k8s-network-monitoring/",
diff --git a/apps/bff/src/bundled_templates/layers/mesh.json b/apps/bff/src/bundled_templates/layers/mesh.json
index 010e9ee..e667cae 100644
--- a/apps/bff/src/bundled_templates/layers/mesh.json
+++ b/apps/bff/src/bundled_templates/layers/mesh.json
@@ -23,7 +23,8 @@
     "topology": true,
     "traces": true,
     "logs": true,
-    "ebpfProfiling": true
+    "ebpfProfiling": true,
+    "networkProfiling": true
   },
   "layer-header": {
     "orderBy": "cpm",
diff --git a/apps/bff/src/layers/loader.ts b/apps/bff/src/layers/loader.ts
index 3d90997..7964eea 100644
--- a/apps/bff/src/layers/loader.ts
+++ b/apps/bff/src/layers/loader.ts
@@ -61,6 +61,10 @@ export interface LayerComponentFlags {
   ebpfProfiling?: boolean;
   /** JVM async-profiler integration. */
   asyncProfiling?: boolean;
+  /** eBPF network profiling (process-level conversation graph). */
+  networkProfiling?: boolean;
+  /** Go pprof integration. */
+  pprofProfiling?: boolean;
 }
 
 export interface LayerSlotsConfig {
@@ -214,8 +218,8 @@ export interface LayerTemplate {
    *  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. */
+   *  so the UI can derive `{ display, cluster }` per service and
+   *  cluster topology nodes accordingly. */
   naming?: ServiceNamingRule;
 }
 
diff --git a/apps/bff/src/oap/async-profile-routes.ts b/apps/bff/src/oap/async-profile-routes.ts
new file mode 100644
index 0000000..1c1c6c8
--- /dev/null
+++ b/apps/bff/src/oap/async-profile-routes.ts
@@ -0,0 +1,391 @@
+/*
+ * 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.
+ */
+
+/**
+ * Async profiler (Java) + pprof (Go) routes.
+ *
+ *   GET  /api/layer/:key/async/tasks?service=
+ *   POST /api/layer/:key/async/tasks
+ *   GET  /api/async/tasks/:taskId/progress
+ *   POST /api/async/analyze
+ *
+ *   GET  /api/layer/:key/pprof/tasks?service=
+ *   POST /api/layer/:key/pprof/tasks
+ *   GET  /api/pprof/tasks/:taskId/progress
+ *   POST /api/pprof/analyze
+ *
+ * The two clients use distinct OAP entry points (`queryAsyncProfilerTask*`
+ * vs `queryPprofTask*`) but share request/response *shape*, so the route
+ * implementations are mostly templated.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type {
+  AsyncProfilingAnalyzeResponse,
+  AsyncProfilingEvent,
+  AsyncProfilingProgressResponse,
+  AsyncProfilingTaskCreationRequest,
+  AsyncProfilingTaskCreationResponse,
+  AsyncProfilingTaskListResponse,
+  FetchLike,
+  PprofAnalyzeResponse,
+  PprofProgressResponse,
+  PprofTaskCreationRequest,
+  PprofTaskCreationResponse,
+  PprofTaskListResponse,
+} from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../config/loader.js';
+import type { SessionStore } from '../auth/sessions.js';
+import { requireAuth } from '../auth/middleware.js';
+import { graphqlPost, buildOapOpts } from './graphql-client.js';
+
+export interface AsyncProfileRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  fetch?: FetchLike;
+}
+
+// ── Async profiler queries ──────────────────────────────────────────
+
+const LIST_SERVICES_FOR_RESOLVE = /* GraphQL */ `
+  query ListServicesForAsyncResolve($layer: String!) {
+    services: listServices(layer: $layer) {
+      id
+      name
+      normal
+    }
+  }
+`;
+
+const GET_ASYNC_TASK_LIST = /* GraphQL */ `
+  query GetAsyncTaskList($request: AsyncProfilerTaskListRequest!) {
+    asyncTaskList: queryAsyncProfilerTaskList(request: $request) {
+      errorReason
+      tasks {
+        id
+        serviceId
+        serviceInstanceIds
+        createTime
+        events
+        duration
+        execArgs
+      }
+    }
+  }
+`;
+
+const GET_ASYNC_PROGRESS = /* GraphQL */ `
+  query GetAsyncProgress($taskId: String!) {
+    taskProgress: queryAsyncProfilerTaskProgress(taskId: $taskId) {
+      logs { id instanceId instanceName operationType operationTime }
+      errorInstanceIds
+      successInstanceIds
+    }
+  }
+`;
+
+const CREATE_ASYNC_TASK = /* GraphQL */ `
+  mutation CreateAsyncTask($asyncProfilerTaskCreationRequest: AsyncProfilerTaskCreationRequest!) {
+    task: createAsyncProfilerTask(asyncProfilerTaskCreationRequest: $asyncProfilerTaskCreationRequest) {
+      id
+      errorReason
+      code
+    }
+  }
+`;
+
+const GET_ASYNC_ANALYZE = /* GraphQL */ `
+  query GetAsyncAnalyze($request: AsyncProfilerAnalyzationRequest!) {
+    analysisResult: queryAsyncProfilerAnalyze(request: $request) {
+      tree {
+        type
+        elements { id parentId symbol: codeSignature dumpCount: total self }
+      }
+    }
+  }
+`;
+
+// ── pprof queries (same shape, different OAP entry points) ──────────
+
+const GET_PPROF_TASK_LIST = /* GraphQL */ `
+  query GetPprofTaskList($request: PprofTaskListRequest!) {
+    pprofTaskList: queryPprofTaskList(request: $request) {
+      errorReason
+      tasks {
+        id
+        serviceId
+        serviceInstanceIds
+        createTime
+        events
+        duration
+        dumpPeriod
+      }
+    }
+  }
+`;
+
+const GET_PPROF_PROGRESS = /* GraphQL */ `
+  query GetPprofProgress($taskId: String!) {
+    taskProgress: queryPprofTaskProgress(taskId: $taskId) {
+      logs { id instanceId instanceName operationType operationTime }
+      errorInstanceIds
+      successInstanceIds
+    }
+  }
+`;
+
+const CREATE_PPROF_TASK = /* GraphQL */ `
+  mutation CreatePprofTask($pprofTaskCreationRequest: PprofTaskCreationRequest!) {
+    task: createPprofTask(pprofTaskCreationRequest: $pprofTaskCreationRequest) {
+      id
+      errorReason
+      code
+    }
+  }
+`;
+
+const GET_PPROF_ANALYZE = /* GraphQL */ `
+  query GetPprofAnalyze($request: PprofAnalyzationRequest!) {
+    analysisResult: queryPprofAnalyze(request: $request) {
+      tree {
+        elements { id parentId symbol: codeSignature dumpCount: total self }
+      }
+    }
+  }
+`;
+
+function softErr<T extends { reachable: boolean; error?: string }>(p: T, e: unknown): T {
+  p.reachable = false;
+  p.error = e instanceof Error ? e.message : String(e);
+  return p;
+}
+
+async function resolveServiceId(
+  opts: ReturnType<typeof buildOapOpts>,
+  layerKey: string,
+  serviceArg: string,
+): Promise<string | null> {
+  if (/^[A-Za-z0-9+/=]+\.\d+$/.test(serviceArg)) return serviceArg;
+  const data = await graphqlPost<{
+    services: Array<{ id: string; name: string; normal?: boolean }>;
+  }>(opts, LIST_SERVICES_FOR_RESOLVE, { layer: layerKey.toUpperCase() });
+  return (
+    data.services.find((s) => s.name === serviceArg)?.id ??
+    data.services.find((s) => s.id === serviceArg)?.id ??
+    null
+  );
+}
+
+export function registerAsyncProfileRoutes(
+  app: FastifyInstance,
+  deps: AsyncProfileRouteDeps,
+): void {
+  const auth = requireAuth(deps);
+
+  // ── Async profiler ──────────────────────────────────────────────
+  app.get(
+    '/api/layer/:key/async/tasks',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { key: string };
+      const q = req.query as { service?: string };
+      const serviceArg = (q.service ?? '').trim();
+      const payload: AsyncProfilingTaskListResponse = { tasks: [], reachable: true };
+      if (!serviceArg) return reply.send(payload);
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const serviceId = await resolveServiceId(opts, params.key, serviceArg);
+        if (!serviceId) return reply.send(payload);
+        const data = await graphqlPost<{
+          asyncTaskList: { errorReason?: string; tasks: AsyncProfilingTaskListResponse['tasks'] };
+        }>(opts, GET_ASYNC_TASK_LIST, { request: { serviceId, limit: 10000 } });
+        payload.tasks = data.asyncTaskList?.tasks ?? [];
+        payload.errorReason = data.asyncTaskList?.errorReason;
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
+  app.post(
+    '/api/layer/:key/async/tasks',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const body = req.body as AsyncProfilingTaskCreationRequest | undefined;
+      const payload: AsyncProfilingTaskCreationResponse = { reachable: true };
+      if (!body?.serviceId) {
+        payload.errorReason = 'missing serviceId';
+        return reply.send(payload);
+      }
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const data = await graphqlPost<{
+          task: { id?: string; errorReason?: string; code?: string };
+        }>(opts, CREATE_ASYNC_TASK, { asyncProfilerTaskCreationRequest: body });
+        payload.id = data.task?.id;
+        payload.code = data.task?.code;
+        payload.errorReason = data.task?.errorReason;
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
+  app.get(
+    '/api/async/tasks/:taskId/progress',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { taskId: string };
+      const payload: AsyncProfilingProgressResponse = { progress: null, reachable: true };
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const data = await graphqlPost<{
+          taskProgress: AsyncProfilingProgressResponse['progress'];
+        }>(opts, GET_ASYNC_PROGRESS, { taskId: params.taskId });
+        payload.progress = data.taskProgress ?? null;
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
+  app.post(
+    '/api/async/analyze',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const body = req.body as
+        | { taskId: string; instanceIds: string[]; eventType: string }
+        | undefined;
+      const payload: AsyncProfilingAnalyzeResponse = { tree: null, reachable: true };
+      if (!body?.taskId || !body.instanceIds?.length) return reply.send(payload);
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const data = await graphqlPost<{
+          analysisResult: { tree: AsyncProfilingAnalyzeResponse['tree'] } | null;
+        }>(opts, GET_ASYNC_ANALYZE, { request: body });
+        payload.tree = data.analysisResult?.tree ?? null;
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
+
+  // ── pprof (Go) ──────────────────────────────────────────────────
+  app.get(
+    '/api/layer/:key/pprof/tasks',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { key: string };
+      const q = req.query as { service?: string };
+      const serviceArg = (q.service ?? '').trim();
+      const payload: PprofTaskListResponse = { tasks: [], reachable: true };
+      if (!serviceArg) return reply.send(payload);
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const serviceId = await resolveServiceId(opts, params.key, serviceArg);
+        if (!serviceId) return reply.send(payload);
+        const data = await graphqlPost<{
+          pprofTaskList: { errorReason?: string; tasks: PprofTaskListResponse['tasks'] };
+        }>(opts, GET_PPROF_TASK_LIST, { request: { serviceId, limit: 10000 } });
+        payload.tasks = data.pprofTaskList?.tasks ?? [];
+        payload.errorReason = data.pprofTaskList?.errorReason;
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
+  app.post(
+    '/api/layer/:key/pprof/tasks',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const body = req.body as PprofTaskCreationRequest | undefined;
+      const payload: PprofTaskCreationResponse = { reachable: true };
+      if (!body?.serviceId) {
+        payload.errorReason = 'missing serviceId';
+        return reply.send(payload);
+      }
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const data = await graphqlPost<{
+          task: { id?: string; errorReason?: string; code?: string };
+        }>(opts, CREATE_PPROF_TASK, { pprofTaskCreationRequest: body });
+        payload.id = data.task?.id;
+        payload.code = data.task?.code;
+        payload.errorReason = data.task?.errorReason;
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
+  app.get(
+    '/api/pprof/tasks/:taskId/progress',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { taskId: string };
+      const payload: PprofProgressResponse = { progress: null, reachable: true };
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const data = await graphqlPost<{ taskProgress: PprofProgressResponse['progress'] }>(
+          opts,
+          GET_PPROF_PROGRESS,
+          { taskId: params.taskId },
+        );
+        payload.progress = data.taskProgress ?? null;
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
+  app.post(
+    '/api/pprof/analyze',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const body = req.body as
+        | { taskId: string; instanceIds: string[]; eventType: string }
+        | undefined;
+      const payload: PprofAnalyzeResponse = { tree: null, reachable: true };
+      if (!body?.taskId || !body.instanceIds?.length) return reply.send(payload);
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const data = await graphqlPost<{
+          analysisResult: { tree: PprofAnalyzeResponse['tree'] } | null;
+        }>(opts, GET_PPROF_ANALYZE, { request: body });
+        payload.tree = data.analysisResult?.tree ?? null;
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
+}
+
+// Keep eventType-from-event mapping for the UI: CPU/WALL/CTIMER/ITIMER
+// all roll up into EXECUTION_SAMPLE; LOCK and TLAB allocs keep their
+// own enum. The UI uses this to choose which `tree` to show after an
+// async analyze (the result graph contains one tree per JFR type).
+export const EVENT_TO_JFR: Record<AsyncProfilingEvent, string> = {
+  CPU: 'EXECUTION_SAMPLE',
+  WALL: 'EXECUTION_SAMPLE',
+  CTIMER: 'EXECUTION_SAMPLE',
+  ITIMER: 'EXECUTION_SAMPLE',
+  LOCK: 'LOCK',
+  ALLOC: 'OBJECT_ALLOCATION_IN_NEW_TLAB',
+};
diff --git a/apps/bff/src/oap/ebpf-routes.ts b/apps/bff/src/oap/ebpf-routes.ts
new file mode 100644
index 0000000..408869c
--- /dev/null
+++ b/apps/bff/src/oap/ebpf-routes.ts
@@ -0,0 +1,473 @@
+/*
+ * 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.
+ */
+
+/**
+ * eBPF profiling (kernel-level ON_CPU / OFF_CPU) routes.
+ *
+ *   GET  /api/layer/:key/ebpf/tasks?service=
+ *        — list tasks + queryPrepareCreateEBPFProfilingTaskData metadata.
+ *   POST /api/layer/:key/ebpf/tasks
+ *        — create a fixed-time eBPF task.
+ *   GET  /api/ebpf/tasks/:taskId/schedules
+ *        — list per-process schedules captured by a task.
+ *   POST /api/ebpf/analyze
+ *        — resolve schedule + time-range data into stack trees.
+ *
+ * Network-profiling routes live alongside in {@link registerNetworkProfilingRoutes}.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type {
+  EBPFAnalyzeRequest,
+  EBPFAnalyzeResponse,
+  EBPFSchedulesResponse,
+  EBPFTaskCreationRequest,
+  EBPFTaskCreationResponse,
+  EBPFTaskListResponse,
+  FetchLike,
+  NetworkProfilingCreateRequest,
+  NetworkProfilingCreateResponse,
+  NetworkProfilingKeepAliveResponse,
+  ProcessTopologyResponse,
+} from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../config/loader.js';
+import type { SessionStore } from '../auth/sessions.js';
+import { requireAuth } from '../auth/middleware.js';
+import { graphqlPost, buildOapOpts } from './graphql-client.js';
+
+export interface EBPFRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  fetch?: FetchLike;
+}
+
+const LIST_SERVICES_FOR_RESOLVE = /* GraphQL */ `
+  query ListServicesForEBPFResolve($layer: String!) {
+    services: listServices(layer: $layer) {
+      id
+      name
+      normal
+    }
+  }
+`;
+
+const QUERY_CREATE_TASK_DATA = /* GraphQL */ `
+  query queryCreateTaskData($serviceId: ID!) {
+    createTaskData: queryPrepareCreateEBPFProfilingTaskData(serviceId: $serviceId) {
+      couldProfiling
+      processLabels
+    }
+  }
+`;
+
+const QUERY_EBPF_TASKS = /* GraphQL */ `
+  query queryEBPFTasks(
+    $serviceId: ID
+    $serviceInstanceId: ID
+    $targets: [EBPFProfilingTargetType!]
+    $triggerType: EBPFProfilingTriggerType
+  ) {
+    queryEBPFTasks: queryEBPFProfilingTasks(
+      serviceId: $serviceId
+      serviceInstanceId: $serviceInstanceId
+      targets: $targets
+      triggerType: $triggerType
+    ) {
+      taskId
+      serviceName
+      serviceId
+      serviceInstanceId
+      serviceInstanceName
+      processLabels
+      processName
+      processId
+      taskStartTime
+      triggerType
+      fixedTriggerDuration
+      targetType
+      createTime
+      continuousProfilingCauses {
+        type
+        singleValue { threshold current }
+        uri { uriRegex uriPath threshold current }
+        message
+      }
+    }
+  }
+`;
+
+const QUERY_EBPF_SCHEDULES = /* GraphQL */ `
+  query queryEBPFSchedules($taskId: ID!) {
+    eBPFSchedules: queryEBPFProfilingSchedules(taskId: $taskId) {
+      scheduleId
+      taskId
+      process {
+        id
+        name
+        serviceId
+        serviceName
+        instanceId
+        instanceName
+        agentId
+        detectType
+        attributes { name value }
+        labels
+      }
+      startTime
+      endTime
+    }
+  }
+`;
+
+const ANALYSIS_EBPF_RESULT = /* GraphQL */ `
+  query analysisEBPF(
+    $scheduleIdList: [ID!]!
+    $timeRanges: [EBPFProfilingAnalyzeTimeRange!]!
+    $aggregateType: EBPFProfilingAnalyzeAggregateType
+  ) {
+    analysisEBPFResult: analysisEBPFProfilingResult(
+      scheduleIdList: $scheduleIdList
+      timeRanges: $timeRanges
+      aggregateType: $aggregateType
+    ) {
+      tip
+      trees {
+        elements { id parentId symbol stackType dumpCount }
+      }
+    }
+  }
+`;
+
+const GET_PROCESS_TOPOLOGY = /* GraphQL */ `
+  query getProcessTopology($serviceInstanceId: ID!, $duration: Duration!) {
+    topology: getProcessTopology(serviceInstanceId: $serviceInstanceId, duration: $duration) {
+      nodes {
+        id
+        name
+        isReal
+        serviceName
+        serviceId
+        serviceInstanceId
+        serviceInstanceName
+      }
+      calls {
+        id
+        source
+        target
+        detectPoints
+        sourceComponents
+        targetComponents
+      }
+    }
+  }
+`;
+
+const NEW_NETWORK_PROFILING = /* GraphQL */ `
+  mutation newNetworkProfiling($request: EBPFProfilingNetworkTaskRequest!) {
+    createEBPFNetworkProfiling(request: $request) {
+      status
+      errorReason
+      id
+    }
+  }
+`;
+
+const KEEP_ALIVE_NETWORK_PROFILING = /* GraphQL */ `
+  mutation aliveNetworkProfiling($taskId: ID!) {
+    keepEBPFNetworkProfiling(taskId: $taskId) {
+      status
+      errorReason
+    }
+  }
+`;
+
+const CREATE_EBPF_FIXED_TASK = /* GraphQL */ `
+  mutation createEBPFFixedTask($request: EBPFProfilingTaskFixedTimeCreationRequest!) {
+    createTaskData: createEBPFProfilingFixedTimeTask(request: $request) {
+      status
+      errorReason
+      id
+    }
+  }
+`;
+
+function softErr<T extends { reachable: boolean; error?: string }>(p: T, e: unknown): T {
+  p.reachable = false;
+  p.error = e instanceof Error ? e.message : String(e);
+  return p;
+}
+
+async function resolveServiceId(
+  opts: ReturnType<typeof buildOapOpts>,
+  layerKey: string,
+  serviceArg: string,
+): Promise<string | null> {
+  if (/^[A-Za-z0-9+/=]+\.\d+$/.test(serviceArg)) return serviceArg;
+  const data = await graphqlPost<{
+    services: Array<{ id: string; name: string; normal?: boolean }>;
+  }>(opts, LIST_SERVICES_FOR_RESOLVE, { layer: layerKey.toUpperCase() });
+  return (
+    data.services.find((s) => s.name === serviceArg)?.id ??
+    data.services.find((s) => s.id === serviceArg)?.id ??
+    null
+  );
+}
+
+export function registerEBPFRoutes(app: FastifyInstance, deps: EBPFRouteDeps): void {
+  const auth = requireAuth(deps);
+
+  // ── list tasks + couldProfiling metadata ──────────────────────────
+  app.get(
+    '/api/layer/:key/ebpf/tasks',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { key: string };
+      const q = req.query as { service?: string };
+      const serviceArg = (q.service ?? '').trim();
+      const payload: EBPFTaskListResponse = {
+        tasks: [],
+        couldProfiling: false,
+        processLabels: [],
+        reachable: true,
+      };
+      if (!serviceArg) return reply.send(payload);
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const serviceId = await resolveServiceId(opts, params.key, serviceArg);
+        if (!serviceId) return reply.send(payload);
+
+        const [meta, list] = await Promise.all([
+          graphqlPost<{ createTaskData: { couldProfiling: boolean; processLabels: string[] } }>(
+            opts,
+            QUERY_CREATE_TASK_DATA,
+            { serviceId },
+          ).catch(() => ({ createTaskData: { couldProfiling: false, processLabels: [] } })),
+          graphqlPost<{ queryEBPFTasks: EBPFTaskListResponse['tasks'] }>(opts, QUERY_EBPF_TASKS, {
+            serviceId,
+            targets: ['ON_CPU', 'OFF_CPU'],
+            triggerType: 'FIXED_TIME',
+          }),
+        ]);
+        payload.couldProfiling = meta.createTaskData?.couldProfiling ?? false;
+        payload.processLabels = meta.createTaskData?.processLabels ?? [];
+        payload.tasks = list.queryEBPFTasks ?? [];
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
+
+  // ── create task ───────────────────────────────────────────────────
+  app.post(
+    '/api/layer/:key/ebpf/tasks',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const body = req.body as EBPFTaskCreationRequest | undefined;
+      const payload: EBPFTaskCreationResponse = { status: false, reachable: true };
+      if (!body?.serviceId) {
+        payload.errorReason = 'missing serviceId';
+        return reply.send(payload);
+      }
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const data = await graphqlPost<{
+          createTaskData: { status: boolean; errorReason?: string; id?: string };
+        }>(opts, CREATE_EBPF_FIXED_TASK, { request: body });
+        payload.status = data.createTaskData?.status ?? false;
+        payload.errorReason = data.createTaskData?.errorReason;
+        payload.id = data.createTaskData?.id;
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
+
+  // ── per-task schedules ────────────────────────────────────────────
+  app.get(
+    '/api/ebpf/tasks/:taskId/schedules',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { taskId: string };
+      const payload: EBPFSchedulesResponse = { schedules: [], reachable: true };
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const data = await graphqlPost<{ eBPFSchedules: EBPFSchedulesResponse['schedules'] }>(
+          opts,
+          QUERY_EBPF_SCHEDULES,
+          { taskId: params.taskId },
+        );
+        payload.schedules = data.eBPFSchedules ?? [];
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
+
+  // ── network profiling ─────────────────────────────────────────────
+  /** List network-profile tasks for a service. Same OAP entry-point as
+   *  ON_CPU/OFF_CPU tasks, just with target=NETWORK + trigger=CONTINUOUS_PROFILING. */
+  app.get(
+    '/api/layer/:key/ebpf/network/tasks',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { key: string };
+      const q = req.query as { service?: string; serviceInstance?: string };
+      const serviceArg = (q.service ?? '').trim();
+      const instanceArg = (q.serviceInstance ?? '').trim();
+      const payload: EBPFTaskListResponse = {
+        tasks: [],
+        couldProfiling: true,
+        processLabels: [],
+        reachable: true,
+      };
+      if (!serviceArg && !instanceArg) return reply.send(payload);
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const serviceId = serviceArg
+          ? await resolveServiceId(opts, params.key, serviceArg)
+          : null;
+        const data = await graphqlPost<{ queryEBPFTasks: EBPFTaskListResponse['tasks'] }>(
+          opts,
+          QUERY_EBPF_TASKS,
+          {
+            serviceId: serviceId ?? undefined,
+            serviceInstanceId: instanceArg || undefined,
+            targets: ['NETWORK'],
+            triggerType: 'CONTINUOUS_PROFILING',
+          },
+        );
+        payload.tasks = data.queryEBPFTasks ?? [];
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
+
+  /** Process-level topology for a service instance — the network
+   *  profiling view's main visualization. */
+  app.get(
+    '/api/ebpf/network/topology',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const q = req.query as { serviceInstance?: string; windowMinutes?: string };
+      const instance = (q.serviceInstance ?? '').trim();
+      const payload: ProcessTopologyResponse = { nodes: [], calls: [], reachable: true };
+      if (!instance) return reply.send(payload);
+      const minutes = Math.max(5, Math.min(180, Number(q.windowMinutes) || 30));
+      const end = new Date();
+      const start = new Date(end.getTime() - minutes * 60_000);
+      const fmt = (d: Date) => {
+        const z = (n: number) => String(n).padStart(2, '0');
+        return `${d.getUTCFullYear()}-${z(d.getUTCMonth() + 1)}-${z(d.getUTCDate())} ${z(d.getUTCHours())}${z(d.getUTCMinutes())}`;
+      };
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const data = await graphqlPost<{
+          topology: { nodes: ProcessTopologyResponse['nodes']; calls: ProcessTopologyResponse['calls'] };
+        }>(opts, GET_PROCESS_TOPOLOGY, {
+          serviceInstanceId: instance,
+          duration: { start: fmt(start), end: fmt(end), step: 'MINUTE' },
+        });
+        payload.nodes = data.topology?.nodes ?? [];
+        payload.calls = data.topology?.calls ?? [];
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
+
+  /** Create a network-profile task on a specific service instance. */
+  app.post(
+    '/api/ebpf/network/tasks',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const body = req.body as NetworkProfilingCreateRequest | undefined;
+      const payload: NetworkProfilingCreateResponse = { status: false, reachable: true };
+      if (!body?.instanceId) {
+        payload.errorReason = 'missing instanceId';
+        return reply.send(payload);
+      }
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const data = await graphqlPost<{
+          createEBPFNetworkProfiling: { status: boolean; errorReason?: string; id?: string };
+        }>(opts, NEW_NETWORK_PROFILING, { request: body });
+        payload.status = data.createEBPFNetworkProfiling?.status ?? false;
+        payload.errorReason = data.createEBPFNetworkProfiling?.errorReason;
+        payload.id = data.createEBPFNetworkProfiling?.id;
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
+
+  /** Keep-alive ping for a continuous-profiling network task — without
+   *  it OAP will close the task after the configured timeout. */
+  app.post(
+    '/api/ebpf/network/tasks/:taskId/keep-alive',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { taskId: string };
+      const payload: NetworkProfilingKeepAliveResponse = { status: false, reachable: true };
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const data = await graphqlPost<{
+          keepEBPFNetworkProfiling: { status: boolean; errorReason?: string };
+        }>(opts, KEEP_ALIVE_NETWORK_PROFILING, { taskId: params.taskId });
+        payload.status = data.keepEBPFNetworkProfiling?.status ?? false;
+        payload.errorReason = data.keepEBPFNetworkProfiling?.errorReason;
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
+
+  // ── analyze ───────────────────────────────────────────────────────
+  app.post(
+    '/api/ebpf/analyze',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const body = req.body as EBPFAnalyzeRequest | undefined;
+      const payload: EBPFAnalyzeResponse = { tip: null, trees: [], reachable: true };
+      if (!body || !body.scheduleIdList?.length || !body.timeRanges?.length) {
+        return reply.send(payload);
+      }
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const data = await graphqlPost<{
+          analysisEBPFResult: { tip: string | null; trees: EBPFAnalyzeResponse['trees'] };
+        }>(opts, ANALYSIS_EBPF_RESULT, {
+          scheduleIdList: body.scheduleIdList,
+          timeRanges: body.timeRanges,
+          aggregateType: body.aggregateType,
+        });
+        payload.tip = data.analysisEBPFResult?.tip ?? null;
+        payload.trees = data.analysisEBPFResult?.trees ?? [];
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
+}
diff --git a/apps/bff/src/oap/menu-routes.ts b/apps/bff/src/oap/menu-routes.ts
index f34f66d..3d00a5a 100644
--- a/apps/bff/src/oap/menu-routes.ts
+++ b/apps/bff/src/oap/menu-routes.ts
@@ -49,6 +49,8 @@ function componentsToCaps(components: LayerComponentFlags): LayerCaps {
     traceProfiling: !!components.traceProfiling,
     ebpfProfiling: !!components.ebpfProfiling,
     asyncProfiling: !!components.asyncProfiling,
+    networkProfiling: !!components.networkProfiling,
+    pprofProfiling: !!components.pprofProfiling,
     events: false,
     // Bundled service-count tile defaults on — every layer benefits
     // from the headline count, and operators can opt out per-layer
@@ -195,6 +197,7 @@ function deriveLayer(
       metrics: tpl.metrics,
       overview: tpl.overview,
       log: tpl.log,
+      traces: tpl.traces,
       naming: tpl.naming,
     };
   }
diff --git a/apps/bff/src/oap/profile-routes.ts b/apps/bff/src/oap/profile-routes.ts
new file mode 100644
index 0000000..669afcd
--- /dev/null
+++ b/apps/bff/src/oap/profile-routes.ts
@@ -0,0 +1,313 @@
+/*
+ * 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.
+ */
+
+/**
+ * Trace-driven (agent) profiling routes.
+ *
+ *   GET  /api/layer/:key/profile/tasks?service=&endpoint=
+ *        — list profile tasks for a service (+ optional endpoint filter).
+ *   POST /api/layer/:key/profile/tasks
+ *        — create a new profile task.
+ *   GET  /api/profile/tasks/:taskId/segments
+ *        — list sampled trace segments collected for a task (incl. spans).
+ *   GET  /api/profile/tasks/:taskId/logs
+ *        — list operation logs for a task (instance-side state changes).
+ *   POST /api/profile/analyze
+ *        — analyze profiled span time-ranges into call trees.
+ *
+ * Wraps OAP's GraphQL profile fragment. Service-id resolution mirrors the
+ * pattern in `endpoint-routes.ts` so the UI can pass a service name OR id.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type {
+  FetchLike,
+  ProfileAnalyzationResponse,
+  ProfileAnalyzeQuery,
+  ProfileSegmentsResponse,
+  ProfileTaskCreationRequest,
+  ProfileTaskCreationResponse,
+  ProfileTaskListResponse,
+  ProfileTaskLogsResponse,
+} from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../config/loader.js';
+import type { SessionStore } from '../auth/sessions.js';
+import { requireAuth } from '../auth/middleware.js';
+import { graphqlPost, buildOapOpts } from './graphql-client.js';
+
+export interface ProfileRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  fetch?: FetchLike;
+}
+
+const LIST_SERVICES_FOR_RESOLVE = /* GraphQL */ `
+  query ListServicesForProfileResolve($layer: String!) {
+    services: listServices(layer: $layer) {
+      id
+      name
+      normal
+    }
+  }
+`;
+
+const GET_PROFILE_TASK_LIST = /* GraphQL */ `
+  query getProfileTaskList($endpointName: String, $serviceId: ID) {
+    taskList: getProfileTaskList(endpointName: $endpointName, serviceId: $serviceId) {
+      id
+      serviceId
+      endpointName
+      startTime
+      duration
+      minDurationThreshold
+      dumpPeriod
+      maxSamplingCount
+      logs {
+        id
+        instanceId
+        instanceName
+        operationType
+        operationTime
+      }
+    }
+  }
+`;
+
+const GET_PROFILE_TASK_SEGMENTS = /* GraphQL */ `
+  query getProfileTaskSegmentList($taskID: ID!) {
+    segmentList: getProfileTaskSegments(taskID: $taskID) {
+      traceId
+      instanceId
+      instanceName
+      endpointNames
+      duration
+      start
+      spans {
+        spanId
+        parentSpanId
+        segmentId
+        refs { traceId parentSegmentId parentSpanId type }
+        serviceCode
+        serviceInstanceName
+        startTime
+        endTime
+        endpointName
+        type
+        peer
+        component
+        isError
+        layer
+        tags { key value }
+        logs { time data { key value } }
+        profiled
+      }
+    }
+  }
+`;
+
+const GET_PROFILE_TASK_LOGS = /* GraphQL */ `
+  query profileTaskLogs($taskID: String) {
+    taskLogs: getProfileTaskLogs(taskID: $taskID) {
+      id
+      instanceId
+      instanceName
+      operationTime
+      operationType
+    }
+  }
+`;
+
+const GET_PROFILE_ANALYZE = /* GraphQL */ `
+  query getProfileAnalyze($queries: [SegmentProfileAnalyzeQuery!]!) {
+    analyze: getSegmentsProfileAnalyze(queries: $queries) {
+      tip
+      trees {
+        elements {
+          id
+          parentId
+          codeSignature
+          duration
+          durationChildExcluded
+          count
+        }
+      }
+    }
+  }
+`;
+
+const CREATE_PROFILE_TASK = /* GraphQL */ `
+  mutation createProfileTask($creationRequest: ProfileTaskCreationRequest) {
+    task: createProfileTask(creationRequest: $creationRequest) {
+      id
+      errorReason
+    }
+  }
+`;
+
+function softError<T extends { reachable: boolean; error?: string }>(payload: T, err: unknown): T {
+  payload.reachable = false;
+  payload.error = err instanceof Error ? err.message : String(err);
+  return payload;
+}
+
+async function resolveServiceId(
+  opts: ReturnType<typeof buildOapOpts>,
+  layerKey: string,
+  serviceArg: string,
+): Promise<string | null> {
+  if (/^[A-Za-z0-9+/=]+\.\d+$/.test(serviceArg)) return serviceArg;
+  const data = await graphqlPost<{
+    services: Array<{ id: string; name: string; normal?: boolean }>;
+  }>(opts, LIST_SERVICES_FOR_RESOLVE, { layer: layerKey.toUpperCase() });
+  const match =
+    data.services.find((s) => s.name === serviceArg) ??
+    data.services.find((s) => s.id === serviceArg) ??
+    null;
+  return match?.id ?? null;
+}
+
+export function registerProfileRoutes(app: FastifyInstance, deps: ProfileRouteDeps): void {
+  const auth = requireAuth(deps);
+
+  // ── list tasks ─────────────────────────────────────────────────────
+  app.get(
+    '/api/layer/:key/profile/tasks',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { key: string };
+      const layerKey = params.key;
+      const q = req.query as { service?: string; endpoint?: string };
+      const serviceArg = (q.service ?? '').trim();
+      const endpointName = (q.endpoint ?? '').trim();
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      const payload: ProfileTaskListResponse = { tasks: [], reachable: true };
+
+      if (!serviceArg) return reply.send(payload);
+      try {
+        const serviceId = await resolveServiceId(opts, layerKey, serviceArg);
+        if (!serviceId) return reply.send(payload);
+        const data = await graphqlPost<{ taskList: ProfileTaskListResponse['tasks'] }>(
+          opts,
+          GET_PROFILE_TASK_LIST,
+          { serviceId, endpointName: endpointName || '' },
+        );
+        payload.tasks = data.taskList ?? [];
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softError(payload, err));
+      }
+    },
+  );
+
+  // ── create task ────────────────────────────────────────────────────
+  app.post(
+    '/api/layer/:key/profile/tasks',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const body = req.body as ProfileTaskCreationRequest | undefined;
+      const payload: ProfileTaskCreationResponse = { reachable: true };
+      if (!body || !body.serviceId) {
+        payload.errorReason = 'missing serviceId';
+        return reply.send(payload);
+      }
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const data = await graphqlPost<{ task: { id?: string; errorReason?: string } }>(
+          opts,
+          CREATE_PROFILE_TASK,
+          { creationRequest: body },
+        );
+        payload.id = data.task?.id;
+        payload.errorReason = data.task?.errorReason;
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softError(payload, err));
+      }
+    },
+  );
+
+  // ── list segments for a task ──────────────────────────────────────
+  app.get(
+    '/api/profile/tasks/:taskId/segments',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { taskId: string };
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      const payload: ProfileSegmentsResponse = { segments: [], reachable: true };
+      try {
+        const data = await graphqlPost<{ segmentList: ProfileSegmentsResponse['segments'] }>(
+          opts,
+          GET_PROFILE_TASK_SEGMENTS,
+          { taskID: params.taskId },
+        );
+        payload.segments = data.segmentList ?? [];
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softError(payload, err));
+      }
+    },
+  );
+
+  // ── list operation logs for a task ────────────────────────────────
+  app.get(
+    '/api/profile/tasks/:taskId/logs',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { taskId: string };
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      const payload: ProfileTaskLogsResponse = { logs: [], reachable: true };
+      try {
+        const data = await graphqlPost<{ taskLogs: ProfileTaskLogsResponse['logs'] }>(
+          opts,
+          GET_PROFILE_TASK_LOGS,
+          { taskID: params.taskId },
+        );
+        payload.logs = data.taskLogs ?? [];
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softError(payload, err));
+      }
+    },
+  );
+
+  // ── analyze profiled span time-ranges ─────────────────────────────
+  app.post(
+    '/api/profile/analyze',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const body = req.body as { queries?: ProfileAnalyzeQuery[] } | undefined;
+      const payload: ProfileAnalyzationResponse = {
+        tip: null,
+        trees: [],
+        reachable: true,
+      };
+      const queries = body?.queries ?? [];
+      if (!queries.length) return reply.send(payload);
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const data = await graphqlPost<{
+          analyze: { tip: string | null; trees: ProfileAnalyzationResponse['trees'] };
+        }>(opts, GET_PROFILE_ANALYZE, { queries });
+        payload.tip = data.analyze?.tip ?? null;
+        payload.trees = data.analyze?.trees ?? [];
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softError(payload, err));
+      }
+    },
+  );
+}
diff --git a/apps/bff/src/oap/zipkin-routes.ts b/apps/bff/src/oap/zipkin-routes.ts
index dc32370..ab06a72 100644
--- a/apps/bff/src/oap/zipkin-routes.ts
+++ b/apps/bff/src/oap/zipkin-routes.ts
@@ -251,6 +251,44 @@ export function registerZipkinRoutes(app: FastifyInstance, deps: ZipkinRouteDeps
     },
   );
 
+  // GET /api/zipkin/autocomplete/keys
+  // GET /api/zipkin/autocomplete/values?key=X
+  //
+  // Proxies for `/api/v2/autocompleteKeys` + `/api/v2/autocompleteValues`.
+  // OAP keeps a pre-configured set of "autocomplete keys" (operator
+  // adds to a config list) and indexes their distinct values from
+  // recent Zipkin spans. The Zipkin trace UI uses these to power
+  // typeahead on the `annotationQuery` field — same UX Zipkin Lens
+  // ships, but routed through Horizon so the basic-auth handshake +
+  // CORS stay invisible to the SPA.
+  app.get('/api/zipkin/autocomplete/keys', { preHandler: auth }, async (_req, reply) => {
+    try {
+      const { status, body } = await zipkinFetch(
+        deps.config.current,
+        deps.fetch,
+        '/api/v2/autocompleteKeys',
+      );
+      return reply.code(status).send(body);
+    } catch (err) {
+      return reply.code(200).send({ keys: [], reachable: false, error: String(err) });
+    }
+  });
+  app.get('/api/zipkin/autocomplete/values', { preHandler: auth }, async (req, reply) => {
+    const q = req.query as { key?: string };
+    if (!q.key) return reply.code(400).send({ error: 'missing_key' });
+    try {
+      const { status, body } = await zipkinFetch(
+        deps.config.current,
+        deps.fetch,
+        '/api/v2/autocompleteValues',
+        { key: q.key },
+      );
+      return reply.code(status).send(body);
+    } catch (err) {
+      return reply.code(200).send({ values: [], reachable: false, error: String(err) });
+    }
+  });
+
   // 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 };
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 3fb16b4..8659da2 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -34,6 +34,9 @@ import { registerLogRoute } from './oap/log-routes.js';
 import { registerMenuRoute } from './oap/menu-routes.js';
 import { registerOapRoutes } from './oap/routes.js';
 import { registerPreflightRoutes } from './oap/preflight-routes.js';
+import { registerProfileRoutes } from './oap/profile-routes.js';
+import { registerEBPFRoutes } from './oap/ebpf-routes.js';
+import { registerAsyncProfileRoutes } from './oap/async-profile-routes.js';
 import { registerTopologyRoute } from './oap/topology-routes.js';
 import { registerTraceRoutes } from './oap/trace-routes.js';
 import { registerTraceTagRoutes } from './oap/trace-tag-routes.js';
@@ -89,6 +92,9 @@ registerDashboardRoute(app, { config: source, sessions });
 registerSetupRoutes(app, { config: source, sessions, audit, store: setupStore });
 registerOapRoutes(app, { config: source, sessions, audit });
 registerPreflightRoutes(app, { config: source, sessions });
+registerProfileRoutes(app, { config: source, sessions });
+registerEBPFRoutes(app, { config: source, sessions });
+registerAsyncProfileRoutes(app, { config: source, sessions });
 
 // Serve the built SPA out of the BFF when HORIZON_STATIC_DIR points at a
 // directory (Docker image layout: /app/static contains the Vite dist).
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index f12be55..834d93d 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -28,6 +28,33 @@ import type {
   LogsResponse,
   MenuResponse,
   OapInfo,
+  ProfileAnalyzationResponse,
+  ProfileAnalyzeQuery,
+  ProfileSegmentsResponse,
+  ProfileTaskCreationRequest,
+  ProfileTaskCreationResponse,
+  ProfileTaskListResponse,
+  ProfileTaskLogsResponse,
+  EBPFTaskListResponse,
+  EBPFTaskCreationRequest,
+  EBPFTaskCreationResponse,
+  EBPFSchedulesResponse,
+  EBPFAnalyzeRequest,
+  EBPFAnalyzeResponse,
+  ProcessTopologyResponse,
+  NetworkProfilingCreateRequest,
+  NetworkProfilingCreateResponse,
+  NetworkProfilingKeepAliveResponse,
+  AsyncProfilingTaskListResponse,
+  AsyncProfilingTaskCreationRequest,
+  AsyncProfilingTaskCreationResponse,
+  AsyncProfilingProgressResponse,
+  AsyncProfilingAnalyzeResponse,
+  PprofTaskListResponse,
+  PprofTaskCreationRequest,
+  PprofTaskCreationResponse,
+  PprofProgressResponse,
+  PprofAnalyzeResponse,
   SetupResponse,
   SetupSavePayload,
   TopologyConfig,
@@ -114,6 +141,67 @@ export type {
   LogQueryRequest,
   LogsResponse,
   LogFacetsResponse,
+  ProfileTask,
+  ProfileTaskLog,
+  ProfileTaskListResponse,
+  ProfileTaskLogsResponse,
+  ProfileSpan,
+  ProfileSpanRef,
+  ProfileSpanTag,
+  ProfileSpanLog,
+  ProfileSpanLogData,
+  ProfileSegment,
+  ProfileSegmentsResponse,
+  ProfileAnalyzationElement,
+  ProfileAnalyzationTree,
+  ProfileAnalyzationResponse,
+  ProfileTimeRange,
+  ProfileAnalyzeQuery,
+  ProfileTaskCreationRequest,
+  ProfileTaskCreationResponse,
+  EBPFTargetType,
+  EBPFTriggerType,
+  EBPFAggregateType,
+  EBPFTask,
+  EBPFProcess,
+  EBPFSchedule,
+  EBPFStackElement,
+  EBPFAnalysisTree,
+  EBPFTaskCreationRequest,
+  EBPFTaskCreationResult,
+  EBPFTaskListResponse,
+  EBPFSchedulesResponse,
+  EBPFAnalyzeRequest,
+  EBPFAnalyzeResponse,
+  EBPFTaskCreationResponse,
+  ProcessNode,
+  ProcessCall,
+  ProcessTopologyResponse,
+  NetworkProfilingSampling,
+  NetworkProfilingCreateRequest,
+  NetworkProfilingCreateResponse,
+  NetworkProfilingKeepAliveResponse,
+  AsyncProfilingEvent,
+  AsyncJFREventType,
+  AsyncProfilingTask,
+  AsyncProfilingProgressLog,
+  AsyncProfilingProgress,
+  AsyncProfilingStackElement,
+  AsyncProfilingTree,
+  AsyncProfilingTaskCreationRequest,
+  AsyncProfilingTaskListResponse,
+  AsyncProfilingProgressResponse,
+  AsyncProfilingAnalyzeResponse,
+  AsyncProfilingTaskCreationResponse,
+  PprofTask,
+  PprofProgress,
+  PprofStackElement,
+  PprofTree,
+  PprofTaskCreationRequest,
+  PprofTaskListResponse,
+  PprofProgressResponse,
+  PprofAnalyzeResponse,
+  PprofTaskCreationResponse,
 } from '@skywalking-horizon-ui/api-client';
 
 
@@ -168,6 +256,15 @@ export interface AdminLayerTemplate {
   endpointDependency?: EndpointDependencyConfig;
   /** Per-layer trace source default. */
   traces?: TracesConfig;
+  /** Service-name parsing rule for the topology cluster feature.
+   *  See `ServiceNamingRule` in @skywalking-horizon-ui/api-client. */
+  naming?: {
+    pattern: string;
+    flags?: string;
+    displayGroup?: string;
+    valueGroup?: string;
+    alias: string;
+  };
 }
 
 export class BffApiError extends Error {
@@ -488,6 +585,19 @@ export class BffClient {
   zipkinTrace(traceId: string): Promise<ZipkinTraceDetailResponse> {
     return this.request<ZipkinTraceDetailResponse>('GET', `/api/zipkin/trace/${encodeURIComponent(traceId)}`);
   }
+  /** Zipkin annotation autocomplete — keys configured on the OAP
+   *  side (`/api/v2/autocompleteKeys`). */
+  zipkinAutocompleteKeys(): Promise<string[]> {
+    return this.request<string[]>('GET', '/api/zipkin/autocomplete/keys');
+  }
+  /** Zipkin annotation autocomplete — values for one key
+   *  (`/api/v2/autocompleteValues?key=...`). */
+  zipkinAutocompleteValues(key: string): Promise<string[]> {
+    return this.request<string[]>(
+      'GET',
+      `/api/zipkin/autocomplete/values?key=${encodeURIComponent(key)}`,
+    );
+  }
 
   /** List active instances for a service. The per-layer Instance
    *  dashboard surfaces a second selector below the service picker;
@@ -531,6 +641,185 @@ export class BffClient {
     );
   }
 
+  // ── trace (agent) profiling ──────────────────────────────────────────
+  /** List profile tasks for a service (+ optional endpoint filter). */
+  layerProfileTasks(
+    layerKey: string,
+    service: string,
+    endpoint = '',
+  ): Promise<ProfileTaskListResponse> {
+    const qs = new URLSearchParams({ service });
+    if (endpoint) qs.set('endpoint', endpoint);
+    return this.request<ProfileTaskListResponse>(
+      'GET',
+      `/api/layer/${encodeURIComponent(layerKey)}/profile/tasks?${qs.toString()}`,
+    );
+  }
+
+  /** Create a profile task on the target service / endpoint. */
+  createProfileTask(
+    layerKey: string,
+    body: ProfileTaskCreationRequest,
+  ): Promise<ProfileTaskCreationResponse> {
+    return this.request<ProfileTaskCreationResponse>(
+      'POST',
+      `/api/layer/${encodeURIComponent(layerKey)}/profile/tasks`,
+      body,
+    );
+  }
+
+  /** Sampled trace segments captured by a profile task. */
+  profileTaskSegments(taskId: string): Promise<ProfileSegmentsResponse> {
+    return this.request<ProfileSegmentsResponse>(
+      'GET',
+      `/api/profile/tasks/${encodeURIComponent(taskId)}/segments`,
+    );
+  }
+
+  /** Per-instance operation log for a profile task (start / stop events). */
+  profileTaskLogs(taskId: string): Promise<ProfileTaskLogsResponse> {
+    return this.request<ProfileTaskLogsResponse>(
+      'GET',
+      `/api/profile/tasks/${encodeURIComponent(taskId)}/logs`,
+    );
+  }
+
+  /** Resolve profile data for one or more span time-ranges into call trees. */
+  profileAnalyze(queries: ProfileAnalyzeQuery[]): Promise<ProfileAnalyzationResponse> {
+    return this.request<ProfileAnalyzationResponse>('POST', '/api/profile/analyze', { queries });
+  }
+
+  // ── eBPF profiling (ON_CPU / OFF_CPU, fixed-time) ───────────────────
+  /** List eBPF profile tasks + the `couldProfiling` / process-label
+   *  metadata used by the New Task form. */
+  layerEBPFTasks(layerKey: string, service: string): Promise<EBPFTaskListResponse> {
+    const qs = new URLSearchParams({ service });
+    return this.request<EBPFTaskListResponse>(
+      'GET',
+      `/api/layer/${encodeURIComponent(layerKey)}/ebpf/tasks?${qs.toString()}`,
+    );
+  }
+  /** Create an eBPF fixed-time task. */
+  createEBPFTask(
+    layerKey: string,
+    body: EBPFTaskCreationRequest,
+  ): Promise<EBPFTaskCreationResponse> {
+    return this.request<EBPFTaskCreationResponse>(
+      'POST',
+      `/api/layer/${encodeURIComponent(layerKey)}/ebpf/tasks`,
+      body,
+    );
+  }
+  /** Per-task schedules — one entry per (process, time-slice) captured
+   *  by the task. */
+  ebpfTaskSchedules(taskId: string): Promise<EBPFSchedulesResponse> {
+    return this.request<EBPFSchedulesResponse>(
+      'GET',
+      `/api/ebpf/tasks/${encodeURIComponent(taskId)}/schedules`,
+    );
+  }
+  /** Roll a set of schedules into stack-trees keyed by `aggregateType`
+   *  (COUNT / DURATION). */
+  ebpfAnalyze(body: EBPFAnalyzeRequest): Promise<EBPFAnalyzeResponse> {
+    return this.request<EBPFAnalyzeResponse>('POST', '/api/ebpf/analyze', body);
+  }
+
+  // ── eBPF network profiling ───────────────────────────────────────────
+  /** List NETWORK / CONTINUOUS_PROFILING eBPF tasks for a service or
+   *  service-instance. */
+  layerNetworkTasks(
+    layerKey: string,
+    args: { service?: string; serviceInstance?: string },
+  ): Promise<EBPFTaskListResponse> {
+    const qs = new URLSearchParams();
+    if (args.service) qs.set('service', args.service);
+    if (args.serviceInstance) qs.set('serviceInstance', args.serviceInstance);
+    return this.request<EBPFTaskListResponse>(
+      'GET',
+      `/api/layer/${encodeURIComponent(layerKey)}/ebpf/network/tasks?${qs.toString()}`,
+    );
+  }
+  /** Process-level topology centred on a service-instance — the network
+   *  profiling view's main visualization. */
+  ebpfNetworkTopology(serviceInstance: string, windowMinutes = 30): Promise<ProcessTopologyResponse> {
+    const qs = new URLSearchParams({ serviceInstance, windowMinutes: String(windowMinutes) });
+    return this.request<ProcessTopologyResponse>('GET', `/api/ebpf/network/topology?${qs.toString()}`);
+  }
+  /** Create an eBPF network-profile task bound to an instance + sampling
+   *  rules (4xx / 5xx / min-duration / URI regex). */
+  createNetworkProfilingTask(body: NetworkProfilingCreateRequest): Promise<NetworkProfilingCreateResponse> {
+    return this.request<NetworkProfilingCreateResponse>('POST', '/api/ebpf/network/tasks', body);
+  }
+  /** Heartbeat a continuous-profiling task so OAP keeps capturing. */
+  keepAliveNetworkProfilingTask(taskId: string): Promise<NetworkProfilingKeepAliveResponse> {
+    return this.request<NetworkProfilingKeepAliveResponse>(
+      'POST',
+      `/api/ebpf/network/tasks/${encodeURIComponent(taskId)}/keep-alive`,
+    );
+  }
+
+  // ── Async profiler (Java) ─────────────────────────────────────────
+  layerAsyncTasks(layerKey: string, service: string): Promise<AsyncProfilingTaskListResponse> {
+    return this.request<AsyncProfilingTaskListResponse>(
+      'GET',
+      `/api/layer/${encodeURIComponent(layerKey)}/async/tasks?service=${encodeURIComponent(service)}`,
+    );
+  }
+  createAsyncProfilingTask(
+    layerKey: string,
+    body: AsyncProfilingTaskCreationRequest,
+  ): Promise<AsyncProfilingTaskCreationResponse> {
+    return this.request<AsyncProfilingTaskCreationResponse>(
+      'POST',
+      `/api/layer/${encodeURIComponent(layerKey)}/async/tasks`,
+      body,
+    );
+  }
+  asyncTaskProgress(taskId: string): Promise<AsyncProfilingProgressResponse> {
+    return this.request<AsyncProfilingProgressResponse>(
+      'GET',
+      `/api/async/tasks/${encodeURIComponent(taskId)}/progress`,
+    );
+  }
+  asyncAnalyze(body: {
+    taskId: string;
+    instanceIds: string[];
+    eventType: string;
+  }): Promise<AsyncProfilingAnalyzeResponse> {
+    return this.request<AsyncProfilingAnalyzeResponse>('POST', '/api/async/analyze', body);
+  }
+
+  // ── pprof (Go) ────────────────────────────────────────────────────
+  layerPprofTasks(layerKey: string, service: string): Promise<PprofTaskListResponse> {
+    return this.request<PprofTaskListResponse>(
+      'GET',
+      `/api/layer/${encodeURIComponent(layerKey)}/pprof/tasks?service=${encodeURIComponent(service)}`,
+    );
+  }
+  createPprofTask(
+    layerKey: string,
+    body: PprofTaskCreationRequest,
+  ): Promise<PprofTaskCreationResponse> {
+    return this.request<PprofTaskCreationResponse>(
+      'POST',
+      `/api/layer/${encodeURIComponent(layerKey)}/pprof/tasks`,
+      body,
+    );
+  }
+  pprofTaskProgress(taskId: string): Promise<PprofProgressResponse> {
+    return this.request<PprofProgressResponse>(
+      'GET',
+      `/api/pprof/tasks/${encodeURIComponent(taskId)}/progress`,
+    );
+  }
+  pprofAnalyze(body: {
+    taskId: string;
+    instanceIds: string[];
+    eventType: string;
+  }): Promise<PprofAnalyzeResponse> {
+    return this.request<PprofAnalyzeResponse>('POST', '/api/pprof/analyze', body);
+  }
+
   // ── cluster / preflight ──────────────────────────────────────────────
   preflight(): Promise<unknown> {
     return this.request('GET', '/api/preflight');
diff --git a/apps/ui/src/components/profile/ProcessTopologyGraph.vue b/apps/ui/src/components/profile/ProcessTopologyGraph.vue
new file mode 100644
index 0000000..99c8403
--- /dev/null
+++ b/apps/ui/src/components/profile/ProcessTopologyGraph.vue
@@ -0,0 +1,288 @@
+<!--
+  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.
+-->
+<!--
+  Process-level topology for network profiling. Renders the
+  `getProcessTopology` payload as a d3-force-directed graph with:
+    - real processes drawn as filled circles
+    - virtual peers (`isReal: false`) drawn as hollow triangles
+    - directional curved edges with detect-point pills
+    - drag to reposition, click to select
+
+  Emits:
+    select-node — full ProcessNode object
+    select-call — full ProcessCall object (with attached source/target)
+-->
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import * as d3 from 'd3';
+import type { ProcessCall, ProcessNode } from '@/api/client';
+
+interface SimNode extends ProcessNode {
+  x?: number;
+  y?: number;
+  vx?: number;
+  vy?: number;
+  fx?: number | null;
+  fy?: number | null;
+}
+interface SimLink {
+  id: string;
+  source: SimNode;
+  target: SimNode;
+  detectPoints: string[];
+  arcOffset: number;
+}
+
+const props = defineProps<{ nodes: ProcessNode[]; calls: ProcessCall[] }>();
+const emit = defineEmits<{
+  (e: 'select-node', n: ProcessNode | null): void;
+  (e: 'select-call', c: ProcessCall | null): void;
+}>();
+
+const host = ref<HTMLDivElement | null>(null);
+const selectedNodeId = ref<string | null>(null);
+const selectedCallId = ref<string | null>(null);
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let simulation: any = null;
+
+function buildLinks(): SimLink[] {
+  const seen = new Map<string, number>();
+  const links: SimLink[] = [];
+  const idx = new Map<string, SimNode>();
+  for (const n of props.nodes) idx.set(n.id, n as SimNode);
+  for (const c of props.calls) {
+    const src = idx.get(c.source);
+    const tgt = idx.get(c.target);
+    if (!src || !tgt) continue;
+    const pairKey = [c.source, c.target].sort().join('|');
+    const offset = seen.get(pairKey) ?? 0;
+    seen.set(pairKey, offset + 1);
+    links.push({
+      id: c.id,
+      source: src,
+      target: tgt,
+      detectPoints: c.detectPoints ?? [],
+      arcOffset: offset,
+    });
+  }
+  return links;
+}
+
+function render(): void {
+  if (!host.value) return;
+  host.value.innerHTML = '';
+  const w = host.value.getBoundingClientRect().width || 640;
+  const h = host.value.getBoundingClientRect().height || 480;
+
+  const svg = d3
+    .select(host.value)
+    .append('svg')
+    .attr('width', w)
+    .attr('height', h);
+
+  // Pan/zoom group
+  const g = svg.append('g').attr('class', 'g-root');
+  svg.call(
+    d3
+      .zoom<SVGSVGElement, unknown>()
+      .scaleExtent([0.3, 4])
+      .on('zoom', (ev) => g.attr('transform', ev.transform.toString())) as never,
+  );
+  // Arrow marker
+  svg
+    .append('defs')
+    .append('marker')
+    .attr('id', 'arrow-pn')
+    .attr('viewBox', '0 -5 10 10')
+    .attr('refX', 16)
+    .attr('refY', 0)
+    .attr('markerWidth', 6)
+    .attr('markerHeight', 6)
+    .attr('orient', 'auto')
+    .append('path')
+    .attr('d', 'M0,-5 L10,0 L0,5')
+    .attr('fill', 'var(--sw-fg-3, #6c7080)');
+
+  const nodes = props.nodes.map((n) => ({ ...(n as SimNode) }));
+  const links = buildLinks();
+  // Re-target SimLinks against the mutable nodes (forceLink mutates them).
+  const byId = new Map(nodes.map((n) => [n.id, n]));
+  for (const l of links) {
+    l.source = byId.get(l.source.id)!;
+    l.target = byId.get(l.target.id)!;
+  }
+
+  simulation = d3
+    .forceSimulation<SimNode>(nodes)
+    .force(
+      'link',
+      d3
+        .forceLink<SimNode, SimLink>(links)
+        .id((d) => d.id)
+        .distance(120)
+        .strength(0.6),
+    )
+    .force('charge', d3.forceManyBody().strength(-300))
+    .force('center', d3.forceCenter(w / 2, h / 2))
+    .force('collide', d3.forceCollide<SimNode>().radius(28));
+
+  // Edges
+  const linkG = g.append('g').attr('class', 'links');
+  const edge = linkG
+    .selectAll('path.edge')
+    .data(links)
+    .enter()
+    .append('path')
+    .attr('class', 'edge')
+    .attr('fill', 'none')
+    .attr('stroke', 'var(--sw-line-2, #3a3d47)')
+    .attr('stroke-width', 1.4)
+    .attr('marker-end', 'url(#arrow-pn)')
+    .style('cursor', 'pointer')
+    .on('click', (_ev, d) => {
+      selectedCallId.value = d.id;
+      selectedNodeId.value = null;
+      emit(
+        'select-call',
+        props.calls.find((c) => c.id === d.id) ?? null,
+      );
+    });
+  // Detect-point pills
+  const pills = linkG
+    .selectAll('g.pill')
+    .data(links.filter((l) => l.detectPoints.length))
+    .enter()
+    .append('g')
+    .attr('class', 'pill');
+  pills
+    .append('rect')
+    .attr('x', -22)
+    .attr('y', -7)
+    .attr('width', 44)
+    .attr('height', 14)
+    .attr('rx', 7)
+    .attr('fill', 'var(--sw-bg-2, #1f2129)')
+    .attr('stroke', 'var(--sw-line, #2a2d36)');
+  pills
+    .append('text')
+    .attr('text-anchor', 'middle')
+    .attr('dy', '0.32em')
+    .attr('fill', 'var(--sw-fg-2, #b4b7c2)')
+    .style('font-family', 'var(--sw-mono, monospace)')
+    .style('font-size', '9.5px')
+    .text((d) => d.detectPoints.join(','));
+
+  // Nodes
+  const nodeG = g.append('g').attr('class', 'nodes');
+  const node = nodeG
+    .selectAll<SVGGElement, SimNode>('g.node')
+    .data(nodes, (d) => d.id)
+    .enter()
+    .append('g')
+    .attr('class', 'node')
+    .style('cursor', 'grab')
+    .on('click', (_ev, d) => {
+      selectedNodeId.value = d.id;
+      selectedCallId.value = null;
+      emit(
+        'select-node',
+        props.nodes.find((n) => n.id === d.id) ?? null,
+      );
+    })
+    .call(
+      d3
+        .drag<SVGGElement, SimNode>()
+        .on('start', (event, d) => {
+          if (!event.active) simulation.alphaTarget(0.3).restart();
+          d.fx = d.x;
+          d.fy = d.y;
+        })
+        .on('drag', (event, d) => {
+          d.fx = event.x;
+          d.fy = event.y;
+        })
+        .on('end', (event, d) => {
+          if (!event.active) simulation.alphaTarget(0);
+          d.fx = null;
+          d.fy = null;
+        }) as never,
+    );
+  // Real process — circle. Virtual peer — triangle.
+  node
+    .filter((d) => d.isReal)
+    .append('circle')
+    .attr('r', 9)
+    .attr('fill', 'var(--sw-accent, #f97316)')
+    .attr('stroke', 'var(--sw-bg-1, #15171c)')
+    .attr('stroke-width', 1.5);
+  node
+    .filter((d) => !d.isReal)
+    .append('path')
+    .attr('d', 'M-9,8 L9,8 L0,-8 Z')
+    .attr('fill', 'var(--sw-bg-2, #1f2129)')
+    .attr('stroke', 'var(--sw-line-2, #3a3d47)')
+    .attr('stroke-width', 1.4);
+  node
+    .append('text')
+    .attr('x', 14)
+    .attr('dy', '0.32em')
+    .attr('fill', 'var(--sw-fg-1, #d4d6dd)')
+    .style('font-family', 'var(--sw-mono, monospace)')
+    .style('font-size', '11px')
+    .text((d) => d.name);
+
+  simulation.on('tick', () => {
+    edge.attr('d', (d) => {
+      const sx = d.source.x ?? 0;
+      const sy = d.source.y ?? 0;
+      const tx = d.target.x ?? 0;
+      const ty = d.target.y ?? 0;
+      const dx = tx - sx;
+      const dy = ty - sy;
+      const dist = Math.sqrt(dx * dx + dy * dy) || 1;
+      // Curve more for each repeat of the (s,t) pair so directed
+      // reverse edges don't overlap.
+      const curve = d.arcOffset * 30 + (d.source.id > d.target.id ? 18 : 0);
+      return `M ${sx} ${sy} A ${dist + curve} ${dist + curve} 0 0 1 ${tx} ${ty}`;
+    });
+    pills.attr('transform', (d) => {
+      const mx = ((d.source.x ?? 0) + (d.target.x ?? 0)) / 2;
+      const my = ((d.source.y ?? 0) + (d.target.y ?? 0)) / 2;
+      return `translate(${mx},${my})`;
+    });
+    node.attr('transform', (d) => `translate(${d.x ?? 0},${d.y ?? 0})`);
+  });
+}
+
+onMounted(render);
+watch(() => [props.nodes, props.calls], render);
+onBeforeUnmount(() => simulation?.stop());
+</script>
+
+<template>
+  <div ref="host" class="topo-host"></div>
+</template>
+
+<style scoped>
+.topo-host {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  background: var(--sw-bg-0, #0d0f14);
+}
+</style>
diff --git a/apps/ui/src/components/profile/ProfileFlameGraph.vue b/apps/ui/src/components/profile/ProfileFlameGraph.vue
new file mode 100644
index 0000000..e0a4914
--- /dev/null
+++ b/apps/ui/src/components/profile/ProfileFlameGraph.vue
@@ -0,0 +1,267 @@
+<!--
+  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.
+-->
+<!--
+  d3-flame-graph wrapper for trace profiling analyze results. Mirrors
+  the booster-ui Content.vue drawing logic but extracted as a focused
+  component the trace + eBPF + async + pprof views can all reuse.
+
+  Inputs:
+    trees: ProfileAnalyzationTree[]  — server-returned analyze trees
+    metricKey: 'count' | 'duration'  — what value drives the box width
+                                       (count for trace, duration for
+                                       eBPF / async / pprof). Default
+                                       is `count`.
+-->
+<script setup lang="ts">
+import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
+import * as d3 from 'd3';
+import { flamegraph } from 'd3-flame-graph';
+import type { ProfileAnalyzationElement, ProfileAnalyzationTree } from '@/api/client';
+
+interface FlameNode {
+  name: string;
+  value: number;
+  count: number;
+  duration: number;
+  durationChildExcluded: number;
+  codeSignature: string;
+  parentId: string;
+  originId: string;
+  children?: FlameNode[];
+}
+
+const props = withDefaults(
+  defineProps<{
+    trees: ProfileAnalyzationTree[];
+    metricKey?: 'count' | 'duration';
+  }>(),
+  { metricKey: 'count' },
+);
+
+const root = ref<HTMLDivElement | null>(null);
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let chart: any = null;
+
+function metricFor(el: ProfileAnalyzationElement): number {
+  if (props.metricKey === 'duration') return Math.max(0, el.duration);
+  return Math.max(0, el.count);
+}
+
+function buildVirtualRoot(): FlameNode | null {
+  if (!props.trees.length) return null;
+  const all: number[] = [];
+  for (const t of props.trees) for (const el of t.elements) all.push(metricFor(el));
+  const min = all.length ? Math.min(...all) : 0;
+  const max = all.length ? Math.max(...all) : 1;
+  const scale = d3.scaleLinear().domain([min, max]).range([1, 200]);
+
+  function processTree(arr: ProfileAnalyzationElement[]): FlameNode | null {
+    const items = arr.map<FlameNode>((el) => ({
+      name: el.codeSignature,
+      value: Number(scale(metricFor(el)).toFixed(4)),
+      count: el.count,
+      duration: el.duration,
+      durationChildExcluded: el.durationChildExcluded,
+      codeSignature: el.codeSignature,
+      parentId: String(Number(el.parentId) + 1),
+      originId: String(Number(el.id) + 1),
+    }));
+    const idx: Record<string, FlameNode> = {};
+    for (const it of items) idx[it.originId] = it;
+    let r: FlameNode | null = null;
+    for (const it of items) {
+      if (it.parentId === '1') r = it;
+      const me = idx[it.originId];
+      const parent = idx[me.parentId];
+      if (parent) (parent.children ??= []).push(me);
+    }
+    return r;
+  }
+
+  const virtRoot: FlameNode = {
+    name: 'Virtual Root',
+    value: 0,
+    count: 0,
+    duration: 0,
+    durationChildExcluded: 0,
+    codeSignature: 'Virtual Root',
+    parentId: '0',
+    originId: '1',
+    children: [],
+  };
+  for (const tree of props.trees) {
+    const r = processTree(tree.elements);
+    if (r) virtRoot.children?.push(r);
+  }
+  // Roll up so a parent never reports less than the sum of its children
+  // — d3-flame-graph requires that to lay frames out correctly.
+  function rollup(n: FlameNode): void {
+    if (n.children?.length) {
+      let s = 0;
+      for (const c of n.children) {
+        rollup(c);
+        s += c.value;
+      }
+      if (n.value < s) n.value = s;
+    }
+  }
+  for (const c of virtRoot.children ?? []) rollup(c);
+  let total = 0;
+  for (const c of virtRoot.children ?? []) total += c.value;
+  virtRoot.value = total;
+  return virtRoot;
+}
+
+function draw(): void {
+  if (!root.value) return;
+  if (chart) {
+    try {
+      chart.destroy();
+            // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    } catch (_e) {
+      /* no-op */
+    }
+    chart = null;
+  }
+  root.value.innerHTML = '';
+  const tree = buildVirtualRoot();
+  if (!tree) return;
+  const width = Math.max(root.value.getBoundingClientRect().width, 600);
+  chart = flamegraph()
+    .width(width - 12)
+    .cellHeight(18)
+    .transitionDuration(450)
+    .minFrameSize(1)
+    .transitionEase(d3.easeCubic as never)
+    .sort(true)
+    .title('')
+    .selfValue(false)
+    .inverted(true)
+    .setColorMapper((d: { highlight: boolean }, original: string) =>
+      d.highlight ? '#6aff8f' : original,
+    );
+
+  // Plain in-DOM tooltip (the booster-ui implementation pulled in
+  // d3-tip; we use a single appended <div> instead to avoid the dep).
+  const tip = document.createElement('div');
+  tip.className = 'fg-tip';
+  document.body.appendChild(tip);
+  chart.tooltip(false);
+  d3.select(root.value).datum(tree).call(chart);
+  const svg = root.value.querySelector('svg');
+  if (svg) {
+    svg.addEventListener('mousemove', (event) => {
+      const target = (event.target as Element).closest('g');
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const data: any = target && (d3.select(target as Element).datum() as any);
+      if (!data || !data.data) {
+        tip.style.display = 'none';
+        return;
+      }
+      const d = data.data as FlameNode;
+      const pctRoot = tree.count
+        ? ((d.count / tree.count) * 100).toFixed(2)
+        : '0';
+      tip.innerHTML = `
+        <div class="t-row"><strong>${escapeHtml(d.codeSignature)}</strong></div>
+        <div class="t-row">Dump Count: ${d.count}</div>
+        <div class="t-row">Duration: ${d.duration} ns</div>
+        <div class="t-row">Duration (excl. children): ${d.durationChildExcluded} ns</div>
+        <div class="t-row">% of root: ${pctRoot}%</div>
+      `;
+      tip.style.display = 'block';
+      const me = event as MouseEvent;
+      tip.style.left = me.clientX + 16 + 'px';
+      tip.style.top = me.clientY + 16 + 'px';
+    });
+    svg.addEventListener('mouseleave', () => {
+      tip.style.display = 'none';
+    });
+    // Make absolutely sure the tip leaves with us on unmount.
+    onBeforeUnmount(() => tip.remove());
+  }
+}
+
+function escapeHtml(s: string): string {
+  return s.replace(/[&<>"']/g, (c) => {
+    switch (c) {
+      case '&': return '&amp;';
+      case '<': return '&lt;';
+      case '>': return '&gt;';
+      case '"': return '&quot;';
+      case "'": return '&#39;';
+      default: return c;
+    }
+  });
+}
+
+onMounted(draw);
+watch(() => [props.trees, props.metricKey], draw);
+onBeforeUnmount(() => {
+  if (chart) {
+    try {
+      chart.destroy();
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    } catch (_e) {
+      /* no-op */
+    }
+    chart = null;
+  }
+});
+</script>
+
+<template>
+  <div ref="root" class="fg-host"></div>
+</template>
+
+<style scoped>
+.fg-host {
+  width: 100%;
+  height: 100%;
+  overflow: auto;
+  cursor: pointer;
+}
+.fg-host :deep(svg) {
+  width: 100%;
+}
+</style>
+
+<style>
+.fg-tip {
+  position: fixed;
+  display: none;
+  pointer-events: none;
+  z-index: 9999;
+  background: var(--sw-bg-1, #1b1d24);
+  color: var(--sw-fg-0, #f5f7fb);
+  border: 1px solid var(--sw-line, #2a2d36);
+  border-radius: 4px;
+  padding: 8px 10px;
+  font-family: var(--sw-mono, monospace);
+  font-size: 11px;
+  max-width: 420px;
+  line-height: 1.45;
+  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
+}
+.fg-tip .t-row + .t-row {
+  margin-top: 3px;
+}
+.fg-tip strong {
+  color: var(--sw-fg-0, #f5f7fb);
+  font-weight: 600;
+}
+</style>
diff --git a/apps/ui/src/components/profile/ProfileStackRow.vue b/apps/ui/src/components/profile/ProfileStackRow.vue
new file mode 100644
index 0000000..19ca655
--- /dev/null
+++ b/apps/ui/src/components/profile/ProfileStackRow.vue
@@ -0,0 +1,135 @@
+<!--
+  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.
+-->
+<!--
+  A single row in the recursive profile stack table. Recursion is what
+  forces this to be its own component; the parent table renders one
+  ProfileStackRow per root and lets each row paint its own subtree.
+-->
+<script setup lang="ts">
+import { ref } from 'vue';
+import type { ProfileAnalyzationElement } from '@/api/client';
+
+interface StackNode extends ProfileAnalyzationElement {
+  topDur: boolean;
+  children?: StackNode[];
+}
+
+const props = defineProps<{
+  node: StackNode;
+  thread: number;
+  depth: number;
+}>();
+
+const open = ref(true);
+const indent = props.depth * 12;
+</script>
+
+<template>
+  <div class="row">
+    <div
+      class="cell sig"
+      :style="{ width: thread + 'px', paddingLeft: 8 + indent + 'px' }"
+      :class="{ 'is-top': node.topDur, 'is-root': node.parentId === '0' }"
+    >
+      <span
+        v-if="node.children && node.children.length"
+        class="caret"
+        :class="{ open }"
+        @click="open = !open"
+      >▸</span>
+      <span v-else class="caret-spacer"></span>
+      <span class="sig-text" :title="node.codeSignature">{{ node.codeSignature }}</span>
+    </div>
+    <div class="cell num">{{ node.duration }}</div>
+    <div class="cell num self">{{ node.durationChildExcluded }}</div>
+    <div class="cell num">{{ node.count }}</div>
+  </div>
+  <template v-if="open && node.children">
+    <ProfileStackRow
+      v-for="(c, i) in node.children"
+      :key="'c' + i"
+      :node="c"
+      :thread="thread"
+      :depth="depth + 1"
+    />
+  </template>
+</template>
+
+<style scoped>
+.row {
+  display: flex;
+  align-items: center;
+  border-bottom: 1px dotted var(--sw-line);
+}
+.row:hover {
+  background: var(--sw-bg-2);
+}
+.cell {
+  height: 26px;
+  line-height: 26px;
+  padding: 0 8px;
+  border-right: 1px dotted var(--sw-line-2);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.cell.num {
+  width: 140px;
+  flex: 0 0 140px;
+  text-align: right;
+  color: var(--sw-fg-2);
+}
+.cell.num.self {
+  color: var(--sw-fg-1);
+  font-weight: 600;
+}
+.cell.sig {
+  flex: 0 0 auto;
+  color: var(--sw-fg-1);
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+.cell.sig.is-top {
+  color: var(--sw-accent);
+}
+.cell.sig.is-root {
+  background: var(--sw-bg-2);
+  border-left: 3px solid var(--sw-accent);
+  padding-left: 5px !important;
+}
+.caret {
+  width: 12px;
+  display: inline-block;
+  transition: transform 0.15s;
+  color: var(--sw-fg-3);
+  cursor: pointer;
+  user-select: none;
+}
+.caret.open {
+  transform: rotate(90deg);
+}
+.caret-spacer {
+  width: 12px;
+  display: inline-block;
+}
+.sig-text {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+</style>
diff --git a/apps/ui/src/components/profile/ProfileStackTable.vue b/apps/ui/src/components/profile/ProfileStackTable.vue
new file mode 100644
index 0000000..d67c9bd
--- /dev/null
+++ b/apps/ui/src/components/profile/ProfileStackTable.vue
@@ -0,0 +1,199 @@
+<!--
+  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.
+-->
+<!--
+  Recursive tree-graph for a profile analyze result. Mirrors the
+  booster-ui Stack/Container + Stack/Item pair: top row is the column
+  header (Thread Stack / Duration / Self Duration / Dump Count), each
+  branch indents children, the leftmost column is draggable to widen.
+
+  Inputs:
+    trees: ProfileAnalyzationTree[]  — raw analyze result trees
+    highlightTop: boolean            — when on, the top-10 slow methods
+                                       by `durationChildExcluded` get an
+                                       accent-coloured row
+-->
+<script setup lang="ts">
+import { computed, ref, onMounted } from 'vue';
+import type { ProfileAnalyzationElement, ProfileAnalyzationTree } from '@/api/client';
+import ProfileStackRow from './ProfileStackRow.vue';
+
+interface StackNode extends ProfileAnalyzationElement {
+  topDur: boolean;
+  children?: StackNode[];
+}
+
+const props = defineProps<{
+  trees: ProfileAnalyzationTree[];
+  highlightTop: boolean;
+}>();
+const emit = defineEmits<{
+  (e: 'toggle-highlight'): void;
+}>();
+
+// ── Build the indented tree ─────────────────────────────────────────
+// booster-ui's sortArr keyed by `id` and walked the array twice;
+// the same approach is fine here for the smallish element counts
+// returned by getSegmentsProfileAnalyze.
+const tableData = computed<StackNode[]>(() => {
+  if (!props.trees.length) return [];
+  const allDurChildExcluded = props.trees.flatMap((t) =>
+    t.elements.map((e) => e.durationChildExcluded),
+  );
+  const topDur = [...allDurChildExcluded]
+    .sort((a, b) => b - a)
+    .filter((v, i) => i < 10 && v !== 0);
+
+  const roots: StackNode[] = [];
+  for (const tree of props.trees) {
+    const idx: Record<string, StackNode> = {};
+    const items = tree.elements.map<StackNode>((el) => ({
+      ...el,
+      topDur: topDur.includes(el.durationChildExcluded) && props.highlightTop,
+    }));
+    for (const it of items) idx[it.id] = it;
+    for (const it of items) {
+      if (it.parentId === '0') {
+        roots.push(it);
+        continue;
+      }
+      const parent = idx[it.parentId];
+      if (parent) (parent.children ??= []).push(it);
+    }
+  }
+  return roots;
+});
+
+// ── Draggable left-column width ─────────────────────────────────────
+const thread = ref<number>(520);
+const dragger = ref<HTMLElement | null>(null);
+onMounted(() => {
+  const el = dragger.value;
+  if (!el) return;
+  el.addEventListener('mousedown', (event) => {
+    const start = event.clientX;
+    const base = thread.value;
+    const move = (e: MouseEvent) => {
+      thread.value = Math.max(120, base + (e.clientX - start));
+    };
+    const up = () => {
+      document.removeEventListener('mousemove', move);
+      document.removeEventListener('mouseup', up);
+    };
+    document.addEventListener('mousemove', move);
+    document.addEventListener('mouseup', up);
+  });
+});
+</script>
+
+<template>
+  <div class="stack-table">
+    <div class="stack-header">
+      <div class="cell sig" :style="{ width: thread + 'px' }">
+        Thread Stack
+        <span ref="dragger" class="dragger" title="Drag to resize">⇿</span>
+      </div>
+      <div class="cell num">Duration (ms)</div>
+      <div class="cell num">
+        Self Duration (ms)
+        <button
+          type="button"
+          class="top-slow"
+          :class="{ 'is-on': highlightTop }"
+          @click="emit('toggle-highlight')"
+          title="Highlight top 10 slow methods"
+        >
+          top slow
+        </button>
+      </div>
+      <div class="cell num">Dump Count</div>
+    </div>
+    <div class="stack-body">
+      <div v-if="!tableData.length" class="empty">No analyze data yet — pick a profiled span and click Analyze.</div>
+      <ProfileStackRow v-for="(n, i) in tableData" :key="'r' + i" :node="n" :thread="thread" :depth="0" />
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.stack-table {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  font-size: 11.5px;
+  color: var(--sw-fg-1);
+  font-family: var(--sw-mono);
+}
+.stack-header {
+  display: flex;
+  align-items: center;
+  background: var(--sw-bg-2);
+  border-bottom: 1px solid var(--sw-line);
+  flex: 0 0 auto;
+}
+.cell {
+  height: 28px;
+  line-height: 28px;
+  padding: 0 8px;
+  border-right: 1px dotted var(--sw-line-2);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  font-weight: 600;
+  color: var(--sw-fg-1);
+}
+.cell.num {
+  width: 140px;
+  flex: 0 0 140px;
+  position: relative;
+}
+.cell.sig {
+  flex: 0 0 auto;
+  position: relative;
+}
+.dragger {
+  position: absolute;
+  right: 4px;
+  top: 0;
+  cursor: col-resize;
+  color: var(--sw-fg-3);
+  user-select: none;
+}
+.top-slow {
+  margin-left: 6px;
+  font-size: 10px;
+  padding: 1px 6px;
+  border-radius: 3px;
+  border: 1px solid var(--sw-line-2);
+  background: transparent;
+  color: var(--sw-fg-2);
+  cursor: pointer;
+}
+.top-slow.is-on {
+  color: var(--sw-accent);
+  border-color: var(--sw-accent);
+}
+.stack-body {
+  flex: 1 1 0;
+  overflow: auto;
+}
+.empty {
+  padding: 16px;
+  text-align: center;
+  color: var(--sw-fg-3);
+  font-family: inherit;
+}
+</style>
diff --git a/apps/ui/src/components/shell/AppShell.vue b/apps/ui/src/components/shell/AppShell.vue
index 8e832d5..514ae7c 100644
--- a/apps/ui/src/components/shell/AppShell.vue
+++ b/apps/ui/src/components/shell/AppShell.vue
@@ -19,6 +19,7 @@ import { RouterView } from 'vue-router';
 import AppSidebar from './AppSidebar.vue';
 import AppTopbar from './AppTopbar.vue';
 import TracePopout from '@/components/trace/TracePopout.vue';
+import ZipkinTracePopout from '@/components/trace/ZipkinTracePopout.vue';
 </script>
 
 <template>
@@ -31,5 +32,10 @@ import TracePopout from '@/components/trace/TracePopout.vue';
     <!-- Global trace-id popout: any page can call useTracePopout().openTrace(id)
          and this modal renders the waterfall + span detail. -->
     <TracePopout />
+    <!-- Zipkin trace popout — separate URL key (`?openZipkinTraceId=`)
+         so the native + Zipkin popouts can be open in parallel without
+         collision (e.g. an operator drilling into a Zipkin trace from
+         a Logs row → trace link on a mesh layer). -->
+    <ZipkinTracePopout />
   </div>
 </template>
diff --git a/apps/ui/src/components/shell/AppSidebar.vue b/apps/ui/src/components/shell/AppSidebar.vue
index ac709a9..76b8358 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -396,6 +396,22 @@ const sections: NavSection[] = [
                 >
                   <Icon name="prof" /><span>Async Profiling</span>
                 </RouterLink>
+                <RouterLink
+                  v-if="L.caps.networkProfiling"
+                  :to="`/layer/${L.key}/network-profiling`"
+                  class="sw-nav-item"
+                  :class="{ 'is-active': isActive(`/layer/${L.key}/network-profiling`) }"
+                >
+                  <Icon name="prof" /><span>Network Profiling</span>
+                </RouterLink>
+                <RouterLink
+                  v-if="L.caps.pprofProfiling"
+                  :to="`/layer/${L.key}/pprof`"
+                  class="sw-nav-item"
+                  :class="{ 'is-active': isActive(`/layer/${L.key}/pprof`) }"
+                >
+                  <Icon name="prof" /><span>pprof (Go)</span>
+                </RouterLink>
               </div>
             </template>
           </template>
@@ -509,6 +525,22 @@ const sections: NavSection[] = [
           >
             <Icon name="flame" /><span>eBPF Profiling</span>
           </RouterLink>
+          <RouterLink
+            v-if="E.layer.caps.networkProfiling"
+            :to="`/layer/${E.layer.key}/network-profiling`"
+            class="sw-nav-item"
+            :class="{ 'is-active': isActive(`/layer/${E.layer.key}/network-profiling`) }"
+          >
+            <Icon name="prof" /><span>Network Profiling</span>
+          </RouterLink>
+          <RouterLink
+            v-if="E.layer.caps.pprofProfiling"
+            :to="`/layer/${E.layer.key}/pprof`"
+            class="sw-nav-item"
+            :class="{ 'is-active': isActive(`/layer/${E.layer.key}/pprof`) }"
+          >
+            <Icon name="prof" /><span>pprof (Go)</span>
+          </RouterLink>
           <RouterLink
             v-if="E.layer.caps.asyncProfiling"
             :to="`/layer/${E.layer.key}/async-profiling`"
diff --git a/apps/ui/src/components/shell/AppTopbar.vue b/apps/ui/src/components/shell/AppTopbar.vue
index d0ce812..e2f4b4d 100644
--- a/apps/ui/src/components/shell/AppTopbar.vue
+++ b/apps/ui/src/components/shell/AppTopbar.vue
@@ -53,6 +53,8 @@ const SCOPE_LITERAL: Record<string, string> = {
   'trace-profiling': 'Trace Profiling',
   'ebpf-profiling': 'eBPF Profiling',
   'async-profiling': 'Async Profiling',
+  'network-profiling': 'Network Profiling',
+  pprof: 'pprof (Go)',
 };
 const crumbs = computed<string[]>(() => {
   const segs = route.path.split('/').filter(Boolean);
diff --git a/apps/ui/src/components/trace/ZipkinTracePopout.vue b/apps/ui/src/components/trace/ZipkinTracePopout.vue
new file mode 100644
index 0000000..a6bba11
--- /dev/null
+++ b/apps/ui/src/components/trace/ZipkinTracePopout.vue
@@ -0,0 +1,487 @@
+<!--
+  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 popout — span-tree waterfall driven by Zipkin's
+  `parentId` linkage. Distinct from `TracePopout.vue` (which renders
+  SkyWalking native traces from segment/spanId pairs) because the
+  data shape differs:
+    - Zipkin spans have a flat `parentId` field instead of segment refs
+    - Timestamps + durations are in microseconds (Zipkin convention)
+    - Service / endpoint live on `localEndpoint.serviceName` / `name`
+    - Kind is CLIENT/SERVER/PRODUCER/CONSUMER/INTERNAL
+
+  Shape stays consistent with the native popout: dark backdrop, fixed-
+  width waterfall column, span detail rail on the right when a span
+  is picked. ESC closes; backdrop click closes.
+-->
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import type { ZipkinSpan } from '@skywalking-horizon-ui/api-client';
+import { useZipkinTracePopout } from '@/composables/useZipkinTracePopout';
+import { useZipkinTrace } from '@/composables/useZipkinTraces';
+
+const { openTraceId, closeTrace } = useZipkinTracePopout();
+const traceIdRef = computed(() => openTraceId.value);
+const { spans, isLoading, error } = useZipkinTrace(traceIdRef);
+
+// ── Span tree → flat waterfall rows ────────────────────────────────
+interface WaterfallRow {
+  span: ZipkinSpan;
+  depth: number;
+  startOffsetUs: number;
+  durationUs: number;
+}
+
+/** Window bounds derived from the span set: earliest timestamp →
+ *  latest end. Used to compute each row's left% / width%. */
+const bounds = computed(() => {
+  let t0 = Infinity;
+  let t1 = -Infinity;
+  for (const s of spans.value) {
+    const start = s.timestamp ?? 0;
+    const dur = s.duration ?? 0;
+    if (start && start < t0) t0 = start;
+    if (start && (start + dur) > t1) t1 = start + dur;
+  }
+  if (!Number.isFinite(t0)) t0 = 0;
+  if (!Number.isFinite(t1) || t1 <= t0) t1 = t0 + 1;
+  return { t0, t1, totalUs: t1 - t0 };
+});
+
+const waterfall = computed<WaterfallRow[]>(() => {
+  const list = spans.value;
+  if (list.length === 0) return [];
+  // Build parent → children map. Some traces have shared spans (same
+  // id reported by both sides) — Zipkin merges them via `shared:true`
+  // but the wire shape still has both. We just walk parentId; if a
+  // span has no parent, it's a root.
+  const byParent = new Map<string, ZipkinSpan[]>();
+  for (const s of list) {
+    const p = s.parentId ?? '';
+    if (!byParent.has(p)) byParent.set(p, []);
+    byParent.get(p)!.push(s);
+  }
+  // Sort siblings by timestamp so the waterfall reads top-down in
+  // chronological order.
+  for (const arr of byParent.values()) {
+    arr.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
+  }
+  const out: WaterfallRow[] = [];
+  const { t0 } = bounds.value;
+  function walk(spanId: string, depth: number): void {
+    const kids = byParent.get(spanId) ?? [];
+    for (const s of kids) {
+      const start = s.timestamp ?? t0;
+      out.push({
+        span: s,
+        depth,
+        startOffsetUs: start - t0,
+        durationUs: s.duration ?? 0,
+      });
+      walk(s.id, depth + 1);
+    }
+  }
+  // Roots = spans with no parentId OR whose parentId isn't in the set.
+  const spanIds = new Set(list.map((s) => s.id));
+  const rootKey = '';
+  const orphanRoots: ZipkinSpan[] = [];
+  for (const s of list) {
+    if (!s.parentId) continue;
+    if (!spanIds.has(s.parentId)) orphanRoots.push(s);
+  }
+  // Walk the canonical roots first.
+  walk(rootKey, 0);
+  // Then any orphan roots (parent missing from the response).
+  for (const s of orphanRoots) {
+    const start = s.timestamp ?? t0;
+    out.push({ span: s, depth: 0, startOffsetUs: start - t0, durationUs: s.duration ?? 0 });
+    walk(s.id, 1);
+  }
+  return out;
+});
+
+const selectedSpanId = ref<string | null>(null);
+const selectedSpan = computed<ZipkinSpan | null>(() => {
+  if (!selectedSpanId.value) return null;
+  return spans.value.find((s) => s.id === selectedSpanId.value) ?? null;
+});
+function selectSpan(s: ZipkinSpan): void {
+  selectedSpanId.value = s.id;
+}
+function clearSpan(): void {
+  selectedSpanId.value = null;
+}
+// Drop the selection when the trace changes.
+watch(traceIdRef, () => { selectedSpanId.value = null; });
+
+// ── Color per service so each row reads as a band ────────────────
+const SERVICE_PALETTE = [
+  '#f97316', '#60a5fa', '#a78bfa', '#22d3ee',
+  '#f472b6', '#34d399', '#fbbf24', '#fb7185',
+];
+function serviceColor(name: string | null | undefined): string {
+  if (!name) return 'var(--sw-fg-3)';
+  let h = 0;
+  for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
+  return SERVICE_PALETTE[Math.abs(h) % SERVICE_PALETTE.length];
+}
+
+// ── Formatting ────────────────────────────────────────────────────
+function fmtMs(us: number): string {
+  if (us < 1000) return `${us}μs`;
+  if (us < 1_000_000) return `${(us / 1000).toFixed(2)}ms`;
+  return `${(us / 1_000_000).toFixed(2)}s`;
+}
+function fmtAbsTime(usSinceEpoch: number): string {
+  if (!usSinceEpoch) return '';
+  return new Date(usSinceEpoch / 1000).toLocaleString();
+}
+function offsetPct(us: number): number {
+  const total = bounds.value.totalUs || 1;
+  return Math.max(0, Math.min(100, (us / total) * 100));
+}
+function widthPct(us: number): number {
+  if (us <= 0) return 0.4; // give zero-duration spans a sliver so they render
+  const total = bounds.value.totalUs || 1;
+  return Math.max(0.4, Math.min(100, (us / total) * 100));
+}
+
+// ── ESC + backdrop close ─────────────────────────────────────────
+function onKeydown(ev: KeyboardEvent): void {
+  if (ev.key === 'Escape' && openTraceId.value) {
+    ev.preventDefault();
+    closeTrace();
+  }
+}
+if (typeof window !== 'undefined') {
+  window.addEventListener('keydown', onKeydown);
+}
+function copyTraceId(): void {
+  if (!traceIdRef.value) return;
+  navigator.clipboard?.writeText(traceIdRef.value).catch(() => {});
+}
+</script>
+
+<template>
+  <div v-if="openTraceId" class="zk-popout-backdrop" @click.self="closeTrace">
+    <article class="zk-popout sw-card">
+      <header class="zk-head">
+        <span class="kicker">Zipkin trace</span>
+        <code class="zk-tid mono">{{ openTraceId }}</code>
+        <button class="sw-btn small" type="button" @click="copyTraceId">Copy id</button>
+        <span v-if="isLoading" class="hint">loading…</span>
+        <span v-if="error" class="hint err">{{ String(error) }}</span>
+        <button class="sw-btn small ghost zk-close" type="button" @click="closeTrace">×</button>
+      </header>
+
+      <div v-if="!isLoading && spans.length === 0" class="zk-empty">
+        No spans returned for this trace.
+      </div>
+
+      <div v-else class="zk-split" :class="{ 'no-detail': !selectedSpan }">
+        <!-- Waterfall column -->
+        <div class="zk-waterfall">
+          <div class="zk-time-axis">
+            <span class="t-tick first">0</span>
+            <span class="t-tick">{{ fmtMs(bounds.totalUs * 0.25) }}</span>
+            <span class="t-tick">{{ fmtMs(bounds.totalUs * 0.5) }}</span>
+            <span class="t-tick">{{ fmtMs(bounds.totalUs * 0.75) }}</span>
+            <span class="t-tick last">{{ fmtMs(bounds.totalUs) }}</span>
+          </div>
+          <div
+            v-for="row in waterfall"
+            :key="row.span.id"
+            class="zk-row"
+            :class="{ on: selectedSpanId === row.span.id, err: row.span.tags?.error != null }"
+            @click="selectSpan(row.span)"
+          >
+            <span class="zk-row-label mono" :style="{ paddingLeft: row.depth * 14 + 'px' }">
+              <span class="zk-row-svc" :style="{ color: serviceColor(row.span.localEndpoint?.serviceName) }">
+                {{ row.span.localEndpoint?.serviceName ?? '—' }}
+              </span>
+              <span class="zk-row-name">{{ row.span.name || '(unnamed)' }}</span>
+            </span>
+            <div class="zk-track">
+              <span class="t-grid q1" /><span class="t-grid q2" /><span class="t-grid q3" />
+              <div
+                class="zk-bar"
+                :style="{
+                  left: offsetPct(row.startOffsetUs) + '%',
+                  width: widthPct(row.durationUs) + '%',
+                  background: serviceColor(row.span.localEndpoint?.serviceName),
+                  borderColor: row.span.tags?.error != null ? 'var(--sw-err)' : 'transparent',
+                }"
+              />
+              <span class="zk-dur mono" :style="{ left: `calc(${offsetPct(row.startOffsetUs + row.durationUs)}% + 6px)` }">
+                {{ fmtMs(row.durationUs) }}
+              </span>
+            </div>
+          </div>
+        </div>
+
+        <!-- Span detail rail -->
+        <aside v-if="selectedSpan" class="zk-detail">
+          <header class="zk-detail-head">
+            <h5>Span detail</h5>
+            <button class="sw-btn small ghost" type="button" @click="clearSpan">×</button>
+          </header>
+          <dl class="zk-kv">
+            <dt>Service</dt>
+            <dd class="mono" :style="{ color: serviceColor(selectedSpan.localEndpoint?.serviceName) }">
+              {{ selectedSpan.localEndpoint?.serviceName ?? '—' }}
+            </dd>
+            <dt>Name</dt><dd class="mono wba">{{ selectedSpan.name ?? '—' }}</dd>
+            <dt>Kind</dt><dd>{{ selectedSpan.kind ?? '—' }}</dd>
+            <dt>Span id</dt><dd class="mono wba">{{ selectedSpan.id }}</dd>
+            <dt v-if="selectedSpan.parentId">Parent id</dt>
+            <dd v-if="selectedSpan.parentId" class="mono wba">{{ selectedSpan.parentId }}</dd>
+            <dt>Start</dt><dd class="mono">{{ fmtAbsTime(selectedSpan.timestamp ?? 0) }}</dd>
+            <dt>Duration</dt><dd class="mono">{{ fmtMs(selectedSpan.duration ?? 0) }}</dd>
+            <dt v-if="selectedSpan.remoteEndpoint?.serviceName">Peer</dt>
+            <dd v-if="selectedSpan.remoteEndpoint?.serviceName" class="mono wba">
+              {{ selectedSpan.remoteEndpoint.serviceName }}
+            </dd>
+          </dl>
+          <section v-if="selectedSpan.tags && Object.keys(selectedSpan.tags).length > 0" class="zk-tags">
+            <h6>Tags</h6>
+            <dl class="zk-kv">
+              <template v-for="(v, k) in selectedSpan.tags" :key="k">
+                <dt class="mono">{{ k }}</dt>
+                <dd class="mono wba">{{ v }}</dd>
+              </template>
+            </dl>
+          </section>
+          <section
+            v-if="selectedSpan.annotations && selectedSpan.annotations.length > 0"
+            class="zk-annotations"
+          >
+            <h6>Annotations</h6>
+            <ul>
+              <li v-for="(a, i) in selectedSpan.annotations" :key="i" class="mono">
+                <span class="dim">{{ fmtAbsTime(a.timestamp) }}</span>
+                <span class="zk-ann-val">{{ a.value }}</span>
+              </li>
+            </ul>
+          </section>
+        </aside>
+      </div>
+    </article>
+  </div>
+</template>
+
+<style scoped>
+.zk-popout-backdrop {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.55);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 100;
+  padding: 24px;
+}
+.zk-popout {
+  width: min(1200px, 96vw);
+  max-height: 90vh;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+.zk-head {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 10px 14px;
+  border-bottom: 1px solid var(--sw-line);
+}
+.zk-tid { color: var(--sw-fg-2); font-size: 11px; }
+.zk-close { margin-left: auto; }
+.hint { font-size: 10.5px; color: var(--sw-fg-3); }
+.hint.err { color: var(--sw-err); }
+.zk-empty {
+  padding: 32px;
+  text-align: center;
+  color: var(--sw-fg-3);
+  font-size: 12px;
+}
+
+.zk-split {
+  display: grid;
+  grid-template-columns: 1fr 360px;
+  flex: 1;
+  min-height: 0;
+  overflow: hidden;
+}
+.zk-split.no-detail { grid-template-columns: 1fr; }
+
+.zk-waterfall {
+  overflow-y: auto;
+  border-right: 1px solid var(--sw-line);
+  padding: 0;
+}
+.zk-time-axis {
+  display: flex;
+  justify-content: space-between;
+  padding: 6px 12px;
+  border-bottom: 1px solid var(--sw-line);
+  font-size: 10px;
+  color: var(--sw-fg-3);
+  font-family: var(--sw-mono);
+}
+.zk-row {
+  display: grid;
+  grid-template-columns: 280px 1fr;
+  align-items: center;
+  gap: 8px;
+  padding: 3px 12px;
+  border-bottom: 1px solid var(--sw-line);
+  cursor: pointer;
+}
+.zk-row:hover { background: var(--sw-bg-2); }
+.zk-row.on { background: var(--sw-bg-3); }
+.zk-row.err { box-shadow: inset 3px 0 0 var(--sw-err); }
+.zk-row-label {
+  display: inline-flex;
+  align-items: baseline;
+  gap: 6px;
+  font-size: 10.5px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  min-width: 0;
+}
+.zk-row-svc {
+  flex: 0 0 auto;
+  font-weight: 700;
+  max-width: 100px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.zk-row-name {
+  flex: 1 1 auto;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  color: var(--sw-fg-1);
+}
+.zk-track {
+  position: relative;
+  height: 18px;
+  background: var(--sw-bg-1);
+  border-radius: 2px;
+  overflow: visible;
+}
+.t-grid {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  width: 1px;
+  background: var(--sw-line);
+  opacity: 0.55;
+  pointer-events: none;
+}
+.t-grid.q1 { left: 25%; }
+.t-grid.q2 { left: 50%; }
+.t-grid.q3 { left: 75%; }
+.zk-bar {
+  position: absolute;
+  top: 2px;
+  bottom: 2px;
+  border-radius: 2px;
+  border: 1px solid transparent;
+}
+.zk-dur {
+  position: absolute;
+  top: 50%;
+  transform: translateY(-50%);
+  font-size: 10px;
+  color: var(--sw-fg-2);
+  pointer-events: none;
+  white-space: nowrap;
+}
+
+.zk-detail {
+  overflow-y: auto;
+  padding: 12px 14px;
+}
+.zk-detail-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8px;
+}
+.zk-detail-head h5 {
+  margin: 0;
+  font-size: 12px;
+  color: var(--sw-fg-0);
+}
+.zk-tags, .zk-annotations { margin-top: 12px; }
+.zk-tags h6, .zk-annotations h6 {
+  margin: 0 0 6px;
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--sw-fg-3);
+  font-weight: 600;
+}
+.zk-kv {
+  display: grid;
+  grid-template-columns: max-content 1fr;
+  gap: 4px 12px;
+  font-size: 11px;
+}
+.zk-kv dt { color: var(--sw-fg-3); }
+.zk-kv dd { margin: 0; color: var(--sw-fg-1); }
+.zk-kv dd.wba { word-break: break-all; }
+.zk-annotations ul {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.zk-annotations li {
+  display: grid;
+  grid-template-columns: 110px 1fr;
+  gap: 8px;
+  font-size: 10.5px;
+}
+.zk-annotations .dim { color: var(--sw-fg-3); }
+.zk-ann-val { color: var(--sw-fg-1); word-break: break-all; }
+.mono { font-family: var(--sw-mono); }
+.kicker {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.1em;
+  color: var(--sw-accent);
+  font-weight: 600;
+}
+.sw-btn.primary {
+  background: var(--sw-accent);
+  color: var(--sw-bg-0);
+  border: none;
+  height: 26px;
+  padding: 0 14px;
+  border-radius: 4px;
+  font-size: 11px;
+  font-weight: 600;
+  cursor: pointer;
+}
+.sw-btn.small { height: 24px; padding: 0 10px; font-size: 11px; }
+.sw-btn.ghost { background: transparent; border: 1px solid var(--sw-line-2); color: var(--sw-fg-2); }
+</style>
diff --git a/apps/ui/src/composables/useLayers.ts b/apps/ui/src/composables/useLayers.ts
index daade64..2c13c4c 100644
--- a/apps/ui/src/composables/useLayers.ts
+++ b/apps/ui/src/composables/useLayers.ts
@@ -94,7 +94,13 @@ export function useLayers() {
  */
 export function firstLayerTab(L: LayerDef | undefined): string {
   if (!L) return 'service';
-  if (L.slots?.services || L.caps?.dashboards) return 'service';
+  // `caps.dashboards` is derived from `components.service !== false`,
+  // so it's the authoritative enable-flag for the per-service page.
+  // Some layers (MESH_DP — sidecar-only; SO11Y_*_AGENT — per-JVM) have
+  // a non-empty `slots.services` label (used elsewhere for breadcrumbs)
+  // but no service component; previously the truthy slot label was
+  // pushing those layers onto an empty `/service` page.
+  if (L.caps?.dashboards) return 'service';
   if (L.caps?.instances ?? Boolean(L.slots?.instances)) return 'instance';
   if (L.caps?.endpoints ?? Boolean(L.slots?.endpoints)) return 'endpoint';
   if (L.caps?.serviceMap || L.caps?.instanceTopology || L.caps?.processTopology) return 'topology';
@@ -103,6 +109,8 @@ export function firstLayerTab(L: LayerDef | undefined): string {
   if (L.caps?.logs) return 'logs';
   if (L.caps?.traceProfiling) return 'trace-profiling';
   if (L.caps?.ebpfProfiling) return 'ebpf-profiling';
+  if (L.caps?.networkProfiling) return 'network-profiling';
   if (L.caps?.asyncProfiling) return 'async-profiling';
+  if (L.caps?.pprofProfiling) return 'pprof';
   return 'service';
 }
diff --git a/apps/ui/src/composables/useZipkinTracePopout.ts b/apps/ui/src/composables/useZipkinTracePopout.ts
new file mode 100644
index 0000000..a44244f
--- /dev/null
+++ b/apps/ui/src/composables/useZipkinTracePopout.ts
@@ -0,0 +1,50 @@
+/*
+ * 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.
+ */
+
+/**
+ * URL-backed popout state for Zipkin traces. Mirrors `useTracePopout`
+ * (the native popout) but reads/writes `?openZipkinTraceId=<id>` so
+ * the two trace kinds can be open simultaneously without colliding.
+ */
+
+import { computed } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+
+export function useZipkinTracePopout() {
+  const route = useRoute();
+  const router = useRouter();
+
+  const openTraceId = computed<string | null>(() => {
+    const v = route.query.openZipkinTraceId;
+    return typeof v === 'string' && v.length > 0 ? v : null;
+  });
+
+  function openTrace(id: string): void {
+    if (!id) return;
+    const next = { ...route.query, openZipkinTraceId: id };
+    void router.replace({ path: route.path, query: next });
+  }
+
+  function closeTrace(): void {
+    if (!openTraceId.value) return;
+    const next = { ...route.query };
+    delete next.openZipkinTraceId;
+    void router.replace({ path: route.path, query: next });
+  }
+
+  return { openTraceId, openTrace, closeTrace };
+}
diff --git a/apps/ui/src/composables/useZipkinTraces.ts b/apps/ui/src/composables/useZipkinTraces.ts
new file mode 100644
index 0000000..513b9d7
--- /dev/null
+++ b/apps/ui/src/composables/useZipkinTraces.ts
@@ -0,0 +1,114 @@
+/*
+ * 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 composables. Two queries:
+ *   1. `useZipkinTraces({...})` — list of `ZipkinTraceListRow` summaries
+ *      with the standard Zipkin v2 query params (serviceName / spanName
+ *      / minDuration / lookback / endTs / limit).
+ *   2. `useZipkinTrace(traceId)` — full span tree for one trace.
+ *
+ * Both call through `bffClient.zipkin*` which proxies OAP's
+ * `ZipkinQueryHandler`. The composables stay deliberately thin — the
+ * BFF route already curates the list shape, so the SPA just renders.
+ */
+
+import { computed, type Ref } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import type {
+  ZipkinTraceDetailResponse,
+  ZipkinTraceListResponse,
+} from '@skywalking-horizon-ui/api-client';
+import { bffClient, type ZipkinTraceQuery } from '@/api/client';
+
+export interface ZipkinTracesParams {
+  serviceName: Ref<string | null>;
+  remoteServiceName?: Ref<string | null>;
+  spanName?: Ref<string | null>;
+  minDuration?: Ref<number | null>;
+  maxDuration?: Ref<number | null>;
+  /** Window end in ms since epoch. null = "now". */
+  endTs?: Ref<number | null>;
+  /** Window size in ms. */
+  lookback: Ref<number>;
+  limit: Ref<number>;
+  annotationQuery?: Ref<string | null>;
+  /** Run only when the user has clicked Run query — mirrors the native
+   *  trace tab's "explicit fetch" model. */
+  enabled?: Ref<boolean>;
+}
+
+export function useLayerZipkinTraces(params: ZipkinTracesParams) {
+  // Zipkin allows "all services" — a blank serviceName queries every
+  // local endpoint. Only gate on the operator's explicit `enabled`
+  // flag (Run query click), not on serviceName presence.
+  const enabled = computed(() => (params.enabled ? params.enabled.value : true));
+  const q = useQuery<ZipkinTraceListResponse>({
+    queryKey: [
+      'zipkin-traces',
+      params.serviceName,
+      params.remoteServiceName ?? computed(() => null),
+      params.spanName ?? computed(() => null),
+      params.minDuration ?? computed(() => null),
+      params.maxDuration ?? computed(() => null),
+      params.endTs ?? computed(() => null),
+      params.lookback,
+      params.limit,
+      params.annotationQuery ?? computed(() => null),
+    ],
+    queryFn: () => {
+      const body: ZipkinTraceQuery = {
+        ...(params.serviceName.value ? { serviceName: params.serviceName.value } : {}),
+        ...(params.remoteServiceName?.value ? { remoteServiceName: params.remoteServiceName.value } : {}),
+        ...(params.spanName?.value ? { spanName: params.spanName.value } : {}),
+        ...(params.minDuration?.value ? { minDuration: params.minDuration.value } : {}),
+        ...(params.maxDuration?.value ? { maxDuration: params.maxDuration.value } : {}),
+        ...(params.endTs?.value ? { endTs: params.endTs.value } : {}),
+        ...(params.annotationQuery?.value ? { annotationQuery: params.annotationQuery.value } : {}),
+        lookback: params.lookback.value,
+        limit: params.limit.value,
+      };
+      return bffClient.zipkinTraces(body);
+    },
+    enabled,
+    staleTime: 15_000,
+  });
+  return {
+    data: computed(() => q.data.value ?? null),
+    traces: computed(() => q.data.value?.traces ?? []),
+    isLoading: q.isLoading,
+    isFetching: q.isFetching,
+    error: q.error,
+    refetch: q.refetch,
+  };
+}
+
+export function useZipkinTrace(traceId: Ref<string | null>) {
+  const q = useQuery<ZipkinTraceDetailResponse>({
+    queryKey: ['zipkin-trace', traceId],
+    queryFn: () => bffClient.zipkinTrace(traceId.value!),
+    enabled: computed(() => Boolean(traceId.value)),
+    staleTime: 60_000,
+  });
+  return {
+    data: computed(() => q.data.value ?? null),
+    spans: computed(() => q.data.value?.spans ?? []),
+    isLoading: q.isLoading,
+    isFetching: q.isFetching,
+    error: q.error,
+  };
+}
diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts
index 502dff2..f652253 100644
--- a/apps/ui/src/router/index.ts
+++ b/apps/ui/src/router/index.ts
@@ -59,11 +59,21 @@ function layerRoute(): RouteRecordRaw {
         meta: { ownsServiceSelector: true },
       },
       { path: 'dependency', component: () => import('@/views/layer/LayerEndpointDependencyView.vue') },
-      { path: 'trace', component: () => import('@/views/layer/LayerTracesView.vue') },
+      // `LayerTracesEntry` is a runtime dispatcher: it inspects the
+      // layer template's `traces.source` and renders either the native
+      // trace view or the Zipkin one. Mesh / k8s layers land on Zipkin.
+      // `ownsServiceSelector` hides the shell's SkyWalking service
+      // picker — the trace tabs (both native + Zipkin) carry their
+      // own service input, and Zipkin's service universe is decoupled
+      // from SkyWalking's anyway (different name index, no `normal`
+      // flag, queried via `/api/v2/services`).
+      { path: 'trace', component: () => import('@/views/layer/LayerTracesEntry.vue'), meta: { ownsServiceSelector: true } },
       { path: 'logs', component: () => import('@/views/layer/LayerLogsView.vue') },
-      { path: 'trace-profiling', component: () => import('@/views/layer/LayerDashboardsView.vue') },
-      { path: 'ebpf-profiling', component: () => import('@/views/layer/LayerDashboardsView.vue') },
-      { path: 'async-profiling', component: () => import('@/views/layer/LayerDashboardsView.vue') },
+      { path: 'trace-profiling', component: () => import('@/views/layer/LayerTraceProfilingView.vue') },
+      { path: 'ebpf-profiling', component: () => import('@/views/layer/LayerEBPFProfilingView.vue') },
+      { path: 'async-profiling', component: () => import('@/views/layer/LayerAsyncProfilingView.vue') },
+      { path: 'network-profiling', component: () => import('@/views/layer/LayerNetworkProfilingView.vue') },
+      { path: 'pprof', component: () => import('@/views/layer/LayerPprofProfilingView.vue') },
       // Old single-profiling URL → redirect to the trace-profiling page
       // for back-compat with bookmarks taken before the split.
       {
diff --git a/apps/ui/src/utils/serviceName.ts b/apps/ui/src/utils/serviceName.ts
index d5ccfbc..c41b125 100644
--- a/apps/ui/src/utils/serviceName.ts
+++ b/apps/ui/src/utils/serviceName.ts
@@ -18,26 +18,31 @@
 import type { ServiceNamingRule } from '@skywalking-horizon-ui/api-client';
 
 /**
- * Service-name parsing. Two flavours coexist:
+ * Two name conventions co-exist:
  *
- *   - 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`).
+ *   - **OAP `<group>::<base>` prefix** (layer-agnostic, historical).
+ *     Example: `agent::checkout`, `mesh-svr::reviews`. The `<group>`
+ *     part is operationally meaningful (fleet / agent class) but the
+ *     `::` syntax bleeds badly into UI labels — by default we always
+ *     strip it from the display label and expose it separately on
+ *     `legacyGroup`. The node detail panel can opt-in to surfacing it
+ *     as a chip via `TopologyConfig.showGroup`.
  *
- * 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.
+ *   - **Per-layer topology-cluster rule** (k8s / mesh / cilium). A
+ *     named-capture regex (see `ServiceNamingRule`) splits the name
+ *     into `display` + `cluster` (e.g. namespace). When the rule
+ *     matches, its captures win.
  *
- * 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.
+ * Both conventions can apply to one name. Real mesh services look
+ * like `mesh-svr::reviews.default` — the cluster rule extracts
+ * `service=mesh-svr::reviews`, `namespace=default`, and we then strip
+ * `mesh-svr::` from the captured service to leave `display=reviews`
+ * + `legacyGroup=mesh-svr` + `cluster=default`.
+ *
+ * The resolver is the single read-side helper: every UI display site
+ * goes through it so chips, labels, and groupings stay consistent.
  */
+
 export interface ParsedServiceName {
   /** Group prefix when the raw name contains `::`. */
   group: string | null;
@@ -67,40 +72,29 @@ export function serviceGroupName(raw: string | null | undefined): string | null
 }
 
 /**
- * Per-layer identity resolution. Given a service name and the layer's
- * `ServiceNamingRule` (if any), returns the trio every UI surface
- * needs:
+ * Layer-aware identity for a service name.
+ *
+ * `display` is **always** the pure service label — no `<group>::`
+ * prefix, no `.<namespace>` suffix when the cluster rule consumed
+ * one. Topology nodes and chip labels read this verbatim.
  *
- *   - `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.
+ * `cluster` + `clusterAlias` come from a matching topology-cluster
+ * rule (k8s namespace, mesh namespace, …). Both are `null` when no
+ * rule was supplied or the regex didn't match.
  *
- * 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.
+ * `legacyGroup` is OAP's historical `<group>::` prefix on the raw
+ * name (or on the captured display, in case both conventions stack).
+ * The node detail panel surfaces it as a separate chip only when
+ * `TopologyConfig.showGroup` is true; everywhere else (topology
+ * label, focus picker, lists) ignores it.
  */
 export interface ServiceIdentity {
   display: string;
-  group: string | null;
-  alias: string | null;
+  cluster: string | null;
+  clusterAlias: string | null;
+  legacyGroup: 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 {
@@ -115,32 +109,57 @@ export function resolveServiceIdentity(
   rule: ServiceNamingRule | null | undefined,
 ): ServiceIdentity {
   const r = raw ?? '';
-  // Rule-based parse first — layer config wins when present and the
-  // pattern actually matches the name.
+  // Always extract the legacy `::` group up-front. It can apply both
+  // to names that don't match the cluster rule (`agent::checkout`)
+  // and to names that DO match it (`mesh-svr::reviews.default` — the
+  // cluster rule captures `mesh-svr::reviews` as the service, and we
+  // then split off `mesh-svr` here).
+  let legacyGroup: string | null = null;
+  let workingName = r;
+
+  // Cluster rule first.
   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 };
+      const captured = m.groups[displayKey];
+      const cluster = m.groups[valueKey];
+      if (captured && cluster) {
+        // Strip the `<group>::` prefix from the captured service so
+        // the display label is the pure base name.
+        const stripped = parseServiceName(captured);
+        return {
+          display: stripped.base,
+          cluster,
+          clusterAlias: rule.alias,
+          legacyGroup: stripped.group,
+        };
       }
-      if (display) {
-        return { display, group: null, alias: null };
+      if (captured) {
+        // Partial match — capture had display but not cluster.
+        const stripped = parseServiceName(captured);
+        return {
+          display: stripped.base,
+          cluster: null,
+          clusterAlias: null,
+          legacyGroup: stripped.group,
+        };
       }
     }
+    // Regex didn't match — fall through to the legacy parser.
   }
-  // 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 };
+
+  // Legacy `<group>::<base>` fallback. By default we DON'T surface
+  // the prefix anywhere in the UI; just record it on `legacyGroup`
+  // so the (opt-in) node panel chip can pick it up.
+  const legacy = parseServiceName(workingName);
+  legacyGroup = legacy.group;
+  return {
+    display: legacy.base,
+    cluster: null,
+    clusterAlias: null,
+    legacyGroup,
+  };
 }
diff --git a/apps/ui/src/views/admin/LayerDashboardsAdmin.vue b/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
index fb2aa98..c564d71 100644
--- a/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
@@ -28,6 +28,7 @@
 -->
 <script setup lang="ts">
 import { computed, reactive, ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
 import type { AdminLayerTemplate } from '@/api/client';
 import type {
   DashboardScope,
@@ -94,17 +95,51 @@ async function loadAll(): Promise<void> {
   try {
     const res = await bffClient.adminLayerTemplates();
     templates.value = res.templates;
-    if (res.templates.length > 0 && !selectedKey.value) {
+    // Hydrate from `?layer=&scope=` first; fall back to the first
+    // template only when the URL doesn't pin a layer. This preserves
+    // refresh state.
+    const queryLayer = String(route.query.layer ?? '').toUpperCase();
+    const matchedQuery = res.templates.find((t) => t.key === queryLayer);
+    if (matchedQuery) {
+      selectedKey.value = matchedQuery.key;
+    } else if (res.templates.length > 0 && !selectedKey.value) {
       selectedKey.value = res.templates[0].key;
     }
+    const queryScope = String(route.query.scope ?? '');
+    if (SCOPES.includes(queryScope as DashboardScope)) {
+      activeScope.value = queryScope as DashboardScope;
+    }
     syncDraft();
   } catch (err) {
     error.value = err instanceof Error ? err.message : String(err);
   } finally {
     isLoading.value = false;
+    // Hand control to the URL-sync watcher only after the initial
+    // hydrate so it doesn't immediately overwrite the seed query
+    // params we just read in.
+    nextTick(() => {
+      suppressRouteSync = false;
+    });
   }
 }
 
+// URL ↔ state sync. Pushes `?layer=&scope=` on every selection change
+// so refreshing the page (or sharing the URL) keeps the admin focused
+// on the same layer + scope. Skipped while initial templates load so
+// the boot-up `loadAll()` hydrate doesn't bounce the URL.
+const route = useRoute();
+const router = useRouter();
+let suppressRouteSync = true;
+watch(
+  [selectedKey, activeScope],
+  ([key, scope]) => {
+    if (suppressRouteSync) return;
+    if (!key) return;
+    const q = { ...route.query, layer: key, scope };
+    void router.replace({ path: route.path, query: q });
+  },
+);
+
 function syncDraft(): void {
   const tpl = templates.value.find((t) => t.key === selectedKey.value);
   draft.template = tpl ? JSON.parse(JSON.stringify(tpl)) : null;
@@ -664,6 +699,99 @@ function toggleComponent(key: ComponentKey): void {
   const c = ensureComponents();
   c[key] = !c[key];
 }
+
+// ── Topology cluster setup — rule editor + live tester.
+// The rule is a named-capture regex run against every service name in
+// the topology + service pickers. Operators bind which capture maps to
+// the display label vs the cluster value (e.g. k8s namespace), and
+// pick a human-readable alias (`namespace`, `tenant`, …). The tester
+// evaluates the live draft against a sample name so operators can see
+// the resolved `{ display, cluster, alias }` before saving. Distinct
+// from OAP's layer-agnostic `<group>::<base>` prefix (which is a
+// global naming convention, never produces a cluster box).
+type NamingRule = NonNullable<AdminLayerTemplate['naming']>;
+function namingDefault(): NamingRule {
+  return {
+    pattern: '^(?<service>[^.]+)\\.(?<namespace>[^.]+)(?:\\..*)?$',
+    flags: '',
+    displayGroup: 'service',
+    valueGroup: 'namespace',
+    alias: 'namespace',
+  };
+}
+function enableNaming(): void {
+  if (!draft.template) return;
+  draft.template.naming = namingDefault();
+}
+function disableNaming(): void {
+  if (!draft.template) return;
+  draft.template.naming = undefined;
+}
+/**
+ * Toggle the `showGroup` flag on the current scope's config block
+ * (topology or endpoint-dependency). Controls whether the legacy
+ * `<group>::` prefix surfaces as a separate chip in the node detail
+ * panel of that scope. Default off everywhere.
+ */
+function toggleShowGroup(): void {
+  if (!draft.template) return;
+  if (activeScope.value === 'topology') {
+    const t = ensureTopology();
+    t.showGroup = !t.showGroup;
+  } else if (activeScope.value === 'dependency') {
+    const t = ensureEndpointDep();
+    t.showGroup = !t.showGroup;
+  }
+}
+const showGroupForScope = computed<boolean>(() => {
+  const t = draft.template;
+  if (!t) return false;
+  if (activeScope.value === 'topology') return Boolean(t.topology?.showGroup);
+  if (activeScope.value === 'dependency') return Boolean(t.endpointDependency?.showGroup);
+  return false;
+});
+
+// Pointer from the topology scope panel ⤴ to the always-visible
+// Topology cluster card. Smooth-scrolls and briefly highlights so the
+// operator's eye lands on it without ambiguity.
+function scrollToClusterCard(): void {
+  const el = document.getElementById('topology-cluster');
+  if (!el) return;
+  el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+  el.classList.add('cluster-pulse');
+  setTimeout(() => el.classList.remove('cluster-pulse'), 1400);
+}
+const namingSample = ref<string>('songs.sample.svc.cluster.local');
+interface NamingTestResult {
+  ok: boolean;
+  display: string | null;
+  group: string | null;
+  alias: string | null;
+  error: string | null;
+}
+const namingTest = computed<NamingTestResult>(() => {
+  const rule = draft.template?.naming;
+  if (!rule) return { ok: false, display: null, group: null, alias: null, error: 'no rule configured' };
+  if (!rule.pattern) return { ok: false, display: null, group: null, alias: null, error: 'pattern required' };
+  let re: RegExp;
+  try {
+    re = new RegExp(rule.pattern, rule.flags ?? '');
+  } catch (err) {
+    return { ok: false, display: null, group: null, alias: null, error: err instanceof Error ? err.message : 'invalid regex' };
+  }
+  const m = namingSample.value.match(re);
+  if (!m || !m.groups) {
+    return { ok: false, display: null, group: null, alias: rule.alias, error: 'no match' };
+  }
+  const displayKey = rule.displayGroup || 'service';
+  const valueKey = rule.valueGroup || 'group';
+  const display = m.groups[displayKey] ?? null;
+  const group = m.groups[valueKey] ?? null;
+  if (!display && !group) {
+    return { ok: false, display: null, group: null, alias: rule.alias, error: `neither capture "${displayKey}" nor "${valueKey}" resolved` };
+  }
+  return { ok: true, display, group, alias: rule.alias, error: null };
+});
 </script>
 
 <template>
@@ -788,6 +916,127 @@ function toggleComponent(key: ComponentKey): void {
           </div>
         </section>
 
+        <!-- Topology cluster setup: a named-capture regex run against
+             every service name. The topology view buckets nodes by the
+             resolved cluster value (e.g. k8s namespace) and renders an
+             `<alias · value>` chip next to the service display label.
+             Distinct from the layer-agnostic OAP `<group>::<base>`
+             prefix convention, which is global and never produces a
+             topology cluster — clusters are explicit per-layer opt-in.
+             Live tester at the bottom evaluates the current draft
+             against a sample name so the operator can see the result
+             before saving. -->
+        <section id="topology-cluster" class="sw-card components-card">
+          <div class="card-head">
+            <h4>Topology cluster setup</h4>
+            <span class="sub">parse service name → display label + cluster dimension (k8s/mesh namespace, tenant, fleet, …)</span>
+            <button
+              v-if="!selectedTpl.naming"
+              class="sw-btn add"
+              type="button"
+              @click="enableNaming"
+            >+ Enable rule</button>
+            <button
+              v-else
+              class="sw-btn small ghost danger"
+              type="button"
+              @click="disableNaming"
+            >Remove rule</button>
+          </div>
+          <div v-if="!selectedTpl.naming" class="topo-cfg-help" style="padding: 12px 16px;">
+            No cluster rule configured — the topology view renders without cluster bounding
+            boxes. Enable a rule for layers whose service names encode a cluster dimension
+            (k8s namespace, fleet, tenant) so topology can cluster nodes accordingly.
+          </div>
+          <div v-else class="naming-body">
+            <div class="naming-row">
+              <label class="mf mf-wide">
+                <span>regex pattern</span>
+                <input
+                  v-model="selectedTpl.naming.pattern"
+                  class="mf-input mono"
+                  type="text"
+                  spellcheck="false"
+                  placeholder="^(?<service>[^.]+)\.(?<namespace>[^.]+)$"
+                />
+              </label>
+              <label class="mf mf-narrow" title="JavaScript regex flags: i = case-insensitive, m = multiline, s = dotall, u = unicode. Service names are case-sensitive single-line strings, so this is almost always empty.">
+                <span>regex flags</span>
+                <input
+                  v-model="selectedTpl.naming.flags"
+                  class="mf-input mono"
+                  type="text"
+                  spellcheck="false"
+                  placeholder="(empty)"
+                />
+              </label>
+            </div>
+            <div class="naming-flags-hint">
+              <code>flags</code> are passed as the second argument to
+              <code>new RegExp(pattern, flags)</code>. Common values: <code>i</code>
+              (case-insensitive), <code>m</code> (multiline). Leave empty for typical
+              k8s/mesh service names.
+            </div>
+            <div class="naming-row">
+              <label class="mf">
+                <span>display capture</span>
+                <input
+                  v-model="selectedTpl.naming.displayGroup"
+                  class="mf-input mono"
+                  type="text"
+                  placeholder="service"
+                />
+              </label>
+              <label class="mf">
+                <span>cluster capture</span>
+                <input
+                  v-model="selectedTpl.naming.valueGroup"
+                  class="mf-input mono"
+                  type="text"
+                  placeholder="namespace"
+                />
+              </label>
+              <label class="mf">
+                <span>alias (cluster label)</span>
+                <input
+                  v-model="selectedTpl.naming.alias"
+                  class="mf-input"
+                  type="text"
+                  placeholder="namespace"
+                />
+              </label>
+            </div>
+            <div class="naming-test">
+              <label class="mf mf-wide">
+                <span>test service name</span>
+                <input
+                  v-model="namingSample"
+                  class="mf-input mono"
+                  type="text"
+                  spellcheck="false"
+                  placeholder="songs.sample"
+                />
+              </label>
+              <div class="naming-result" :class="{ ok: namingTest.ok, err: !namingTest.ok }">
+                <div v-if="namingTest.error" class="naming-error">
+                  <span class="naming-tag">error</span>
+                  <span>{{ namingTest.error }}</span>
+                </div>
+                <template v-else>
+                  <div class="naming-result-row">
+                    <span class="naming-tag">display</span>
+                    <span class="mono">{{ namingTest.display ?? '—' }}</span>
+                  </div>
+                  <div class="naming-result-row">
+                    <span class="naming-tag">{{ namingTest.alias ?? 'group' }}</span>
+                    <span class="mono accent">{{ namingTest.group ?? '—' }}</span>
+                  </div>
+                </template>
+              </div>
+            </div>
+          </div>
+        </section>
+
         <!-- Service-list metrics: the columns shown in the picker
              zone's services table + the default sort. Used across
              the per-layer page. -->
@@ -906,6 +1155,34 @@ function toggleComponent(key: ComponentKey): void {
             <h4>Topology config</h4>
             <span class="sub">node + server-side + client-side line metrics. Add rows; bind a metric to a visual role.</span>
           </div>
+          <div class="topo-cluster-pointer">
+            <span class="topo-cluster-pointer-icon">⤴</span>
+            <span>
+              Cluster setup (k8s/mesh namespace grouping) lives in
+              <a href="#topology-cluster" @click.prevent="scrollToClusterCard">Topology cluster setup</a>
+              above.
+            </span>
+          </div>
+          <!-- showGroup: per-topology toggle for the legacy `<group>::`
+               prefix chip in the node detail panel. Off (default) ⇒
+               prefix never appears in the UI. On ⇒ surfaces as its own
+               chip in the panel header. Topology node labels stay
+               clean regardless. -->
+          <div class="naming-prefix-row">
+            <label class="comp-toggle" :class="{ on: showGroupForScope }">
+              <input
+                type="checkbox"
+                :checked="showGroupForScope"
+                @change="toggleShowGroup"
+              />
+              <span class="comp-label">Show <code>&lt;group&gt;::</code> as a chip in the node panel</span>
+            </label>
+            <span class="naming-prefix-hint">
+              Off: <code>mesh-svr::reviews</code> reads as <code>reviews</code> everywhere.
+              On: <code>mesh-svr</code> appears as a separate chip in the clicked-node panel.
+              Topology graph labels are always pure service names.
+            </span>
+          </div>
           <div class="topo-cfg-body">
             <div class="topo-cfg-section">
               <header class="topo-cfg-head">
@@ -1044,6 +1321,17 @@ function toggleComponent(key: ComponentKey): void {
             <h4>API dependency config</h4>
             <span class="sub">node + line metrics (server-side only — OAP has no endpoint client family).</span>
           </div>
+          <div class="naming-prefix-row">
+            <label class="comp-toggle" :class="{ on: showGroupForScope }">
+              <input
+                type="checkbox"
+                :checked="showGroupForScope"
+                @change="toggleShowGroup"
+              />
+              <span class="comp-label">Show <code>&lt;group&gt;::</code> as a chip in the node panel</span>
+            </label>
+            <span class="naming-prefix-hint">Same semantics as the topology view's flag.</span>
+          </div>
           <div class="topo-cfg-body">
             <div class="topo-cfg-section">
               <header class="topo-cfg-head">
@@ -1713,6 +2001,129 @@ function toggleComponent(key: ComponentKey): void {
   border-color: var(--sw-accent-line);
   color: var(--sw-accent-2);
 }
+/* Topology-config → cluster-setup pointer banner. */
+.topo-cluster-pointer {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 10px 14px 0;
+  padding: 8px 12px;
+  background: var(--sw-accent-soft);
+  border: 1px solid var(--sw-accent-line);
+  border-radius: 6px;
+  font-size: 11.5px;
+  color: var(--sw-fg-1);
+}
+.topo-cluster-pointer-icon {
+  font-size: 14px;
+  color: var(--sw-accent-2);
+}
+.topo-cluster-pointer a {
+  color: var(--sw-accent-2);
+  text-decoration: underline;
+}
+/* Brief pulse when arrowed-into from the topology config panel. */
+@keyframes cluster-pulse-frames {
+  0%   { box-shadow: 0 0 0 0 var(--sw-accent-line); }
+  40%  { box-shadow: 0 0 0 6px var(--sw-accent-line); }
+  100% { box-shadow: 0 0 0 0 transparent; }
+}
+.cluster-pulse {
+  animation: cluster-pulse-frames 1.2s ease-out;
+}
+.naming-flags-hint {
+  margin: -4px 0 4px;
+  font-size: 11px;
+  color: var(--sw-fg-3);
+  padding: 0 4px;
+}
+.naming-flags-hint code {
+  font-family: var(--sw-mono);
+  background: var(--sw-bg-2);
+  padding: 1px 4px;
+  border-radius: 3px;
+  color: var(--sw-fg-1);
+}
+/* Always-visible legacy-prefix toggle inside the cluster card. */
+.naming-prefix-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 10px 14px 0;
+  flex-wrap: wrap;
+}
+.naming-prefix-row .comp-toggle { flex: 0 0 auto; }
+.naming-prefix-hint {
+  font-size: 11px;
+  color: var(--sw-fg-3);
+}
+.naming-prefix-hint code {
+  font-family: var(--sw-mono);
+  background: var(--sw-bg-2);
+  padding: 1px 4px;
+  border-radius: 3px;
+  color: var(--sw-fg-1);
+}
+/* Service-naming rule editor — pattern + capture mapping + live test. */
+.naming-body {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  padding: 12px 14px;
+}
+.naming-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+.naming-row .mf {
+  flex: 1 1 160px;
+}
+.naming-test {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  align-items: flex-end;
+  margin-top: 6px;
+  padding-top: 10px;
+  border-top: 1px dashed var(--sw-line);
+}
+.naming-result {
+  display: flex;
+  gap: 14px;
+  align-items: center;
+  padding: 8px 12px;
+  border-radius: 6px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  font-size: 11.5px;
+  flex: 1 1 280px;
+}
+.naming-result.ok { border-color: var(--sw-accent-line); }
+.naming-result.err { border-color: var(--sw-err); background: rgba(239, 68, 68, 0.06); }
+.naming-result-row {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+.naming-tag {
+  font-size: 9.5px;
+  letter-spacing: 0.04em;
+  text-transform: uppercase;
+  color: var(--sw-fg-3);
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  padding: 1px 6px;
+  border-radius: 4px;
+}
+.naming-result .mono { font-family: var(--sw-mono); color: var(--sw-fg-0); font-weight: 600; }
+.naming-result .mono.accent { color: var(--sw-accent-2); }
+.naming-error {
+  display: inline-flex;
+  gap: 6px;
+  align-items: center;
+  color: var(--sw-err);
+}
 .comp-toggle input {
   accent-color: var(--sw-accent);
   margin: 0;
diff --git a/apps/ui/src/views/layer/LayerAsyncProfilingView.vue b/apps/ui/src/views/layer/LayerAsyncProfilingView.vue
new file mode 100644
index 0000000..497a332
--- /dev/null
+++ b/apps/ui/src/views/layer/LayerAsyncProfilingView.vue
@@ -0,0 +1,634 @@
+<!--
+  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.
+-->
+<!--
+  Per-layer Async-profiler tab (Java only).
+
+    ┌──────────────┬────────────────────────────────────────────────┐
+    │ Task list    │ Toolbar: event-type · per-instance toggle row  │
+    │ + New Task   ├────────────────────────────────────────────────┤
+    │              │ Flame graph for the chosen event-type tree     │
+    └──────────────┴────────────────────────────────────────────────┘
+-->
+<script setup lang="ts">
+import { computed, reactive, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+import { useLayerInstances } from '@/composables/useLayerInstances';
+import { useSelectedService } from '@/composables/useSelectedService';
+import { bffClient } from '@/api/client';
+import type {
+  AsyncProfilingEvent,
+  AsyncProfilingTask,
+  AsyncProfilingTree,
+  ProfileAnalyzationTree,
+} from '@/api/client';
+import ProfileFlameGraph from '@/components/profile/ProfileFlameGraph.vue';
+
+const route = useRoute();
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+const { selectedId: serviceId } = useSelectedService();
+const instances = useLayerInstances(layerKey, serviceId);
+
+const tasks = ref<AsyncProfilingTask[]>([]);
+const tasksError = ref<string | null>(null);
+const currentTask = ref<AsyncProfilingTask | null>(null);
+const tasksLoading = ref(false);
+
+const selectedInstances = ref<string[]>([]);
+const eventType = ref<string>('EXECUTION_SAMPLE');
+const tree = ref<AsyncProfilingTree | null>(null);
+const analyzeError = ref<string | null>(null);
+const analyzeLoading = ref(false);
+
+const showNewTask = ref(false);
+const newTask = reactive({
+  instances: [] as string[],
+  duration: 60,
+  events: ['CPU'] as AsyncProfilingEvent[],
+  execArgs: '',
+});
+const newTaskError = ref<string | null>(null);
+
+const DURATION_OPTS = [
+  { v: 30, label: '30 sec' },
+  { v: 60, label: '1 min' },
+  { v: 300, label: '5 min' },
+  { v: 600, label: '10 min' },
+  { v: 900, label: '15 min' },
+];
+const EVENTS: AsyncProfilingEvent[] = ['CPU', 'ALLOC', 'LOCK', 'WALL', 'CTIMER', 'ITIMER'];
+
+watch(
+  () => layerKey.value + '|' + (serviceId.value ?? ''),
+  () => void refreshTasks(),
+  { immediate: true },
+);
+
+async function refreshTasks(): Promise<void> {
+  tasksError.value = null;
+  if (!serviceId.value) return;
+  tasksLoading.value = true;
+  try {
+    const resp = await bffClient.layerAsyncTasks(layerKey.value, serviceId.value);
+    if (!resp.reachable && resp.error) tasksError.value = resp.error;
+    tasks.value = resp.tasks ?? [];
+    currentTask.value = tasks.value[0] ?? null;
+    if (currentTask.value) syncInstancesFromTask(currentTask.value);
+    tree.value = null;
+  } catch (e) {
+    tasksError.value = e instanceof Error ? e.message : String(e);
+  } finally {
+    tasksLoading.value = false;
+  }
+}
+
+function syncInstancesFromTask(t: AsyncProfilingTask): void {
+  selectedInstances.value = [...(t.serviceInstanceIds ?? [])];
+  // default event type to the first one collectable from CPU-like
+  const ev = t.events?.[0];
+  if (ev === 'LOCK') eventType.value = 'LOCK';
+  else if (ev === 'ALLOC') eventType.value = 'OBJECT_ALLOCATION_IN_NEW_TLAB';
+  else eventType.value = 'EXECUTION_SAMPLE';
+}
+
+async function runAnalyze(): Promise<void> {
+  if (!currentTask.value) return;
+  if (!selectedInstances.value.length) {
+    analyzeError.value = 'Select at least one instance.';
+    return;
+  }
+  analyzeError.value = null;
+  analyzeLoading.value = true;
+  try {
+    const resp = await bffClient.asyncAnalyze({
+      taskId: currentTask.value.id,
+      instanceIds: selectedInstances.value,
+      eventType: eventType.value,
+    });
+    if (!resp.reachable && resp.error) analyzeError.value = resp.error;
+    tree.value = resp.tree;
+  } catch (e) {
+    analyzeError.value = e instanceof Error ? e.message : String(e);
+  } finally {
+    analyzeLoading.value = false;
+  }
+}
+
+const profileTrees = computed<ProfileAnalyzationTree[]>(() => {
+  if (!tree.value) return [];
+  return [
+    {
+      elements: tree.value.elements.map((el) => ({
+        id: el.id,
+        parentId: el.parentId,
+        codeSignature: el.symbol,
+        count: el.dumpCount,
+        duration: el.dumpCount,
+        durationChildExcluded: el.self,
+      })),
+    },
+  ];
+});
+
+function toggleEvent(e: AsyncProfilingEvent): void {
+  const i = newTask.events.indexOf(e);
+  if (i === -1) newTask.events.push(e);
+  else newTask.events.splice(i, 1);
+}
+function toggleInstance(id: string, dst: 'filter' | 'new'): void {
+  const target = dst === 'filter' ? selectedInstances.value : newTask.instances;
+  const i = target.indexOf(id);
+  if (i === -1) target.push(id);
+  else target.splice(i, 1);
+}
+
+async function submitNewTask(): Promise<void> {
+  if (!serviceId.value) {
+    newTaskError.value = 'Pick a service first';
+    return;
+  }
+  if (!newTask.instances.length) {
+    newTaskError.value = 'Pick at least one instance.';
+    return;
+  }
+  if (!newTask.events.length) {
+    newTaskError.value = 'Pick at least one event.';
+    return;
+  }
+  newTaskError.value = null;
+  try {
+    const resp = await bffClient.createAsyncProfilingTask(layerKey.value, {
+      serviceId: serviceId.value,
+      serviceInstanceIds: newTask.instances,
+      duration: Number(newTask.duration),
+      events: newTask.events,
+      execArgs: newTask.execArgs || undefined,
+    });
+    if (resp.errorReason) {
+      newTaskError.value = resp.errorReason;
+      return;
+    }
+    showNewTask.value = false;
+    await refreshTasks();
+  } catch (e) {
+    newTaskError.value = e instanceof Error ? e.message : String(e);
+  }
+}
+
+function fmtTime(ms: number): string {
+  if (!ms) return '—';
+  const d = new Date(ms);
+  const z = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}:${z(d.getSeconds())}`;
+}
+function instanceName(id: string): string {
+  return instances.instances.value.find((i) => i.id === id)?.name ?? id;
+}
+</script>
+
+<template>
+  <div class="sw-card ap-shell">
+    <div class="ap-side">
+      <div class="side-head between">
+        <span>Async profile tasks</span>
+        <button
+          class="btn-new"
+          :disabled="!serviceId"
+          @click="showNewTask = true"
+        >+ New Task</button>
+      </div>
+      <div v-if="tasksError" class="side-err">{{ tasksError }}</div>
+      <div v-else-if="tasksLoading && !tasks.length" class="side-empty">Loading…</div>
+      <div v-else-if="!tasks.length" class="side-empty">
+        {{ serviceId ? 'No async profile tasks yet.' : 'Pick a service to load tasks.' }}
+      </div>
+      <ul v-else class="side-list">
+        <li
+          v-for="t in tasks"
+          :key="t.id"
+          :class="{ 'is-active': currentTask?.id === t.id }"
+          @click="currentTask = t; syncInstancesFromTask(t); tree = null"
+        >
+          <div class="row1">
+            <span class="t-tag">{{ t.events?.join(',') }}</span>
+            <span class="ep">{{ t.serviceInstanceIds?.length ?? 0 }} instance{{ (t.serviceInstanceIds?.length ?? 0) === 1 ? '' : 's' }}</span>
+          </div>
+          <div class="row2">
+            <span>{{ fmtTime(t.createTime) }}</span>
+            <span class="muted">· {{ t.duration }}s</span>
+          </div>
+        </li>
+      </ul>
+    </div>
+
+    <div class="ap-main">
+      <div class="filter-bar">
+        <div class="tb-block grow">
+          <label class="lbl">Instances ({{ selectedInstances.length }} / {{ currentTask?.serviceInstanceIds?.length ?? 0 }})</label>
+          <div class="chip-row">
+            <button
+              v-for="id in currentTask?.serviceInstanceIds ?? []"
+              :key="id"
+              class="chip"
+              :class="{ on: selectedInstances.includes(id) }"
+              :title="id"
+              @click="toggleInstance(id, 'filter')"
+            >{{ instanceName(id) }}</button>
+            <span v-if="!currentTask" class="muted">Pick a task on the left.</span>
+          </div>
+        </div>
+        <div class="tb-block">
+          <label class="lbl">Event type</label>
+          <select v-model="eventType" class="sel">
+            <option value="EXECUTION_SAMPLE">EXECUTION_SAMPLE (CPU/Wall/Timer)</option>
+            <option value="LOCK">LOCK</option>
+            <option value="OBJECT_ALLOCATION_IN_NEW_TLAB">OBJECT_ALLOCATION_IN_NEW_TLAB</option>
+            <option value="OBJECT_ALLOCATION_OUTSIDE_TLAB">OBJECT_ALLOCATION_OUTSIDE_TLAB</option>
+            <option value="PROFILER_LIVE_OBJECT">PROFILER_LIVE_OBJECT</option>
+          </select>
+        </div>
+        <button
+          class="btn-primary"
+          :disabled="analyzeLoading || !currentTask"
+          @click="runAnalyze"
+        >{{ analyzeLoading ? 'Analyzing…' : 'Analyze' }}</button>
+      </div>
+      <div v-if="analyzeError" class="banner err">{{ analyzeError }}</div>
+
+      <div class="result">
+        <ProfileFlameGraph
+          v-if="profileTrees.length"
+          :trees="profileTrees"
+          metric-key="count"
+        />
+        <div v-else-if="!analyzeLoading" class="result-empty">
+          {{ currentTask ? 'Pick instances + event type, then click Analyze.' : 'Select a task to inspect its async profile.' }}
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div v-if="showNewTask" class="dlg-mask" @click.self="showNewTask = false">
+    <div class="dlg">
+      <div class="dlg-head">
+        <div>New async profile task</div>
+        <button class="x" @click="showNewTask = false">×</button>
+      </div>
+      <div class="dlg-body">
+        <div class="field">
+          <label>Instances</label>
+          <div class="chip-row">
+            <button
+              v-for="i in instances.instances.value"
+              :key="i.id"
+              type="button"
+              class="chip"
+              :class="{ on: newTask.instances.includes(i.id) }"
+              @click="toggleInstance(i.id, 'new')"
+            >{{ i.name }}</button>
+            <span v-if="!instances.instances.value.length" class="muted">No instances available.</span>
+          </div>
+        </div>
+        <div class="field-row">
+          <div class="field">
+            <label>Duration</label>
+            <select v-model.number="newTask.duration" class="sel">
+              <option v-for="o in DURATION_OPTS" :key="o.v" :value="o.v">{{ o.label }}</option>
+            </select>
+          </div>
+          <div class="field grow">
+            <label>Exec args (async-profiler -e flags)</label>
+            <input class="ti-input wide" v-model="newTask.execArgs" placeholder="e.g. interval=10000000,jstackdepth=2048" />
+          </div>
+        </div>
+        <div class="field">
+          <label>Events</label>
+          <div class="chip-row">
+            <button
+              v-for="e in EVENTS"
+              :key="e"
+              type="button"
+              class="chip"
+              :class="{ on: newTask.events.includes(e) }"
+              @click="toggleEvent(e)"
+            >{{ e }}</button>
+          </div>
+        </div>
+        <div v-if="newTaskError" class="dlg-err">{{ newTaskError }}</div>
+      </div>
+      <div class="dlg-foot">
+        <button class="btn-secondary" @click="showNewTask = false">Cancel</button>
+        <button class="btn-primary" @click="submitNewTask">Create task</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.ap-shell {
+  display: flex;
+  height: calc(100vh - 280px);
+  min-height: 520px;
+  padding: 0;
+  overflow: hidden;
+}
+.ap-side {
+  width: 320px;
+  flex: 0 0 320px;
+  display: flex;
+  flex-direction: column;
+  border-right: 1px solid var(--sw-line);
+}
+.side-head {
+  padding: 6px 10px;
+  font-size: 11.5px;
+  font-weight: 600;
+  color: var(--sw-fg-1);
+  background: var(--sw-bg-2);
+  border-bottom: 1px solid var(--sw-line);
+}
+.side-head.between {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.btn-new {
+  font-size: 10.5px;
+  padding: 2px 8px;
+  border-radius: 3px;
+  border: 1px solid var(--sw-line-2);
+  background: var(--sw-bg-1);
+  color: var(--sw-fg-1);
+  cursor: pointer;
+}
+.btn-new:hover:not(:disabled) {
+  border-color: var(--sw-accent);
+  color: var(--sw-accent);
+}
+.btn-new:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+.side-err,
+.side-empty {
+  padding: 12px;
+  text-align: center;
+  font-size: 11.5px;
+  color: var(--sw-fg-3);
+}
+.side-err {
+  color: #ff8888;
+}
+.side-list {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  flex: 1 1 0;
+  overflow-y: auto;
+}
+.side-list li {
+  padding: 8px 10px;
+  border-bottom: 1px solid var(--sw-line);
+  cursor: pointer;
+}
+.side-list li:hover {
+  background: var(--sw-bg-2);
+}
+.side-list li.is-active {
+  background: var(--sw-bg-3, var(--sw-bg-2));
+  border-left: 3px solid var(--sw-accent);
+  padding-left: 7px;
+}
+.row1,
+.row2 {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+.row2 {
+  margin-top: 4px;
+  font-size: 10.5px;
+  color: var(--sw-fg-2);
+}
+.t-tag {
+  font-size: 10px;
+  padding: 0 5px;
+  border-radius: 2px;
+  background: var(--sw-accent);
+  color: #fff;
+  font-weight: 600;
+}
+.ep {
+  font-size: 12px;
+  color: var(--sw-fg-0);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.muted {
+  color: var(--sw-fg-3);
+}
+
+.ap-main {
+  flex: 1 1 0;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+}
+.filter-bar {
+  display: flex;
+  gap: 12px;
+  padding: 8px 12px;
+  border-bottom: 1px solid var(--sw-line);
+  align-items: end;
+  flex-wrap: wrap;
+}
+.tb-block {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+}
+.tb-block.grow {
+  flex: 1 1 auto;
+  min-width: 0;
+}
+.lbl {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+}
+.chip-row {
+  display: flex;
+  gap: 4px;
+  flex-wrap: wrap;
+  align-items: center;
+  min-height: 26px;
+}
+.chip {
+  font-size: 11px;
+  padding: 3px 9px;
+  border-radius: 12px;
+  border: 1px solid var(--sw-line-2);
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-1);
+  cursor: pointer;
+}
+.chip.on {
+  background: var(--sw-accent);
+  border-color: var(--sw-accent);
+  color: #fff;
+}
+.sel,
+.ti-input,
+.ti-input.wide {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-0);
+  border: 1px solid var(--sw-line);
+  border-radius: 3px;
+  padding: 4px 6px;
+  font-size: 11.5px;
+  font-family: var(--sw-mono);
+  outline: none;
+}
+.ti-input.wide {
+  width: 100%;
+}
+.btn-primary {
+  background: var(--sw-accent);
+  color: #fff;
+  border: none;
+  border-radius: 3px;
+  padding: 6px 14px;
+  font-size: 11.5px;
+  font-weight: 600;
+  cursor: pointer;
+}
+.btn-primary:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+.btn-secondary {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-1);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 3px;
+  padding: 5px 12px;
+  font-size: 11.5px;
+  cursor: pointer;
+}
+.banner.err {
+  padding: 6px 12px;
+  font-size: 11px;
+  color: #ff8888;
+  background: var(--sw-bg-2);
+  border-bottom: 1px solid var(--sw-line);
+}
+.result {
+  flex: 1 1 0;
+  min-height: 0;
+  position: relative;
+  overflow: hidden;
+}
+.result-empty {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--sw-fg-3);
+  font-size: 12px;
+}
+
+.dlg-mask {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.55);
+  z-index: 50;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.dlg {
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  width: 560px;
+  max-width: 92vw;
+  max-height: 90vh;
+  display: flex;
+  flex-direction: column;
+}
+.dlg-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  background: var(--sw-bg-2);
+  font-weight: 600;
+  font-size: 12.5px;
+  color: var(--sw-fg-0);
+}
+.x {
+  background: transparent;
+  border: none;
+  color: var(--sw-fg-3);
+  font-size: 18px;
+  cursor: pointer;
+}
+.dlg-body {
+  padding: 14px;
+  overflow-y: auto;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  font-size: 11.5px;
+}
+.field label {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+}
+.field.grow {
+  flex: 1 1 0;
+}
+.field-row {
+  display: flex;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+.field-row .field {
+  min-width: 130px;
+}
+.dlg-foot {
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+  padding: 10px 14px;
+  border-top: 1px solid var(--sw-line);
+}
+.dlg-err {
+  color: #ff7676;
+  font-size: 11px;
+}
+</style>
diff --git a/apps/ui/src/views/layer/LayerEBPFProfilingView.vue b/apps/ui/src/views/layer/LayerEBPFProfilingView.vue
new file mode 100644
index 0000000..fb7e444
--- /dev/null
+++ b/apps/ui/src/views/layer/LayerEBPFProfilingView.vue
@@ -0,0 +1,959 @@
+<!--
+  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.
+-->
+<!--
+  Per-layer eBPF profiling tab — feature parity with booster-ui
+  `dashboard/related/ebpf`.
+
+    ┌───────────┬────────────────────────────────────────────────┐
+    │  Task     │ Filter bar: labels • aggregate (OFF_CPU only)  │
+    │  list     │           • process picker • Analyze button    │
+    │           ├────────────────────────────────────────────────┤
+    │  + New    │   Schedule duration banner                     │
+    │           │                                                │
+    │           │   Flame graph / Tree view (toggle)             │
+    └───────────┴────────────────────────────────────────────────┘
+-->
+<script setup lang="ts">
+import { computed, reactive, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+import { useLayers } from '@/composables/useLayers';
+import { useSelectedService } from '@/composables/useSelectedService';
+import { useLayerLanding } from '@/composables/useLayerLanding';
+import { useSetupStore } from '@/stores/setup';
+import { bffClient } from '@/api/client';
+import type {
+  EBPFAnalysisTree,
+  EBPFProcess,
+  EBPFSchedule,
+  EBPFTargetType,
+  EBPFTask,
+  LayerDef,
+  ProfileAnalyzationTree,
+} from '@/api/client';
+import ProfileFlameGraph from '@/components/profile/ProfileFlameGraph.vue';
+import ProfileStackTable from '@/components/profile/ProfileStackTable.vue';
+
+const route = useRoute();
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+const { layers } = useLayers();
+const layer = computed<LayerDef | null>(
+  () => layers.value.find((l) => l.key === layerKey.value) ?? null,
+);
+
+// Resolve URL-bound service id → name (same trick as the trace tab).
+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: {},
+    },
+);
+const setup = useSetupStore();
+const safeCfg = computed(() => {
+  if (!layer.value)
+    return { priority: 99, topN: 5, orderBy: 'cpm', columns: [], style: 'table' as const };
+  return setup.ensure(layer.value.key, {
+    slots: layer.value.slots,
+    caps: layer.value.caps,
+    metrics: layer.value.metrics,
+    overview: layer.value.overview,
+  }).landing;
+});
+const landing = useLayerLanding(safeLayer, safeCfg);
+const { selectedId } = useSelectedService();
+const serviceName = computed<string | null>(() => {
+  const rows = landing.data.value?.sampledRows ?? landing.rows.value ?? [];
+  return rows.find((r) => r.serviceId === selectedId.value)?.serviceName ?? null;
+});
+
+// ── State ─────────────────────────────────────────────────────────
+const tasks = ref<EBPFTask[]>([]);
+const tasksError = ref<string | null>(null);
+const tasksLoading = ref(false);
+const couldProfiling = ref(false);
+const allProcessLabels = ref<string[]>([]);
+const currentTask = ref<EBPFTask | null>(null);
+
+const schedules = ref<EBPFSchedule[]>([]);
+const schedulesError = ref<string | null>(null);
+
+const selectedLabels = ref<string[]>([]); // empty list = all labels
+const selectedProcessIds = ref<string[]>([]);
+const aggregateType = ref<'COUNT' | 'DURATION'>('COUNT');
+
+const analyzeTrees = ref<EBPFAnalysisTree[]>([]);
+const analyzeTip = ref<string>('');
+const analyzeLoading = ref(false);
+const displayMode = ref<'flame' | 'tree'>('flame');
+const highlightTop = ref(true);
+
+// Process picker state
+const processSearch = ref('');
+const showProcessPicker = ref(false);
+
+// New task modal
+const showNewTask = ref(false);
+const newTask = reactive({
+  labels: [] as string[],
+  targetType: 'ON_CPU' as EBPFTargetType,
+  monitorTime: 'now' as 'now' | 'set',
+  monitorTimeAt: new Date(),
+  monitorMinutes: 10,
+});
+const newTaskError = ref<string | null>(null);
+
+watch(
+  () => layerKey.value + '|' + (selectedId.value ?? ''),
+  () => void refreshTasks(),
+  { immediate: true },
+);
+
+async function refreshTasks(): Promise<void> {
+  tasksError.value = null;
+  if (!selectedId.value) {
+    tasks.value = [];
+    currentTask.value = null;
+    return;
+  }
+  tasksLoading.value = true;
+  try {
+    const resp = await bffClient.layerEBPFTasks(layerKey.value, selectedId.value);
+    if (!resp.reachable && resp.error) tasksError.value = resp.error;
+    tasks.value = resp.tasks ?? [];
+    couldProfiling.value = resp.couldProfiling;
+    allProcessLabels.value = resp.processLabels ?? [];
+    if (tasks.value.length) {
+      await pickTask(tasks.value[0]);
+    } else {
+      currentTask.value = null;
+      schedules.value = [];
+      analyzeTrees.value = [];
+    }
+  } catch (e) {
+    tasksError.value = e instanceof Error ? e.message : String(e);
+  } finally {
+    tasksLoading.value = false;
+  }
+}
+
+async function pickTask(t: EBPFTask): Promise<void> {
+  currentTask.value = t;
+  analyzeTrees.value = [];
+  analyzeTip.value = '';
+  schedulesError.value = null;
+  try {
+    const resp = await bffClient.ebpfTaskSchedules(t.taskId);
+    if (!resp.reachable && resp.error) schedulesError.value = resp.error;
+    schedules.value = resp.schedules ?? [];
+    resetFiltersForTask();
+    // Auto-trigger analyze on task switch (booster-ui behaviour).
+    if (schedules.value.length) await runAnalyze();
+  } catch (e) {
+    schedulesError.value = e instanceof Error ? e.message : String(e);
+  }
+}
+
+function resetFiltersForTask(): void {
+  selectedLabels.value = [];
+  selectedProcessIds.value = [];
+  aggregateType.value = 'COUNT';
+}
+
+// Distinct labels across this task's schedules
+const labelOptions = computed<string[]>(() => {
+  const s = new Set<string>();
+  for (const sc of schedules.value)
+    for (const l of sc.process.labels ?? []) s.add(l);
+  return [...s];
+});
+// Distinct processes (collapsed by id)
+const processes = computed<EBPFProcess[]>(() => {
+  const byId = new Map<string, EBPFProcess>();
+  for (const sc of schedules.value) byId.set(sc.process.id, sc.process);
+  return [...byId.values()];
+});
+const filteredProcesses = computed<EBPFProcess[]>(() => {
+  const q = processSearch.value.toLowerCase().trim();
+  if (!q) return processes.value;
+  return processes.value.filter((p) => {
+    if (p.name?.toLowerCase().includes(q)) return true;
+    if (p.instanceName?.toLowerCase().includes(q)) return true;
+    const cmd = (p.attributes ?? []).find((a) => a.name === 'command_line');
+    return Boolean(cmd?.value.toLowerCase().includes(q));
+  });
+});
+// Schedule duration banner (min start → max end)
+const scheduleDuration = computed(() => {
+  if (!schedules.value.length) return null;
+  const starts = schedules.value.map((s) => s.startTime);
+  const ends = schedules.value.map((s) => s.endTime);
+  return { start: Math.min(...starts), end: Math.max(...ends) };
+});
+
+async function runAnalyze(): Promise<void> {
+  if (!schedules.value.length) {
+    analyzeTrees.value = [];
+    analyzeTip.value = '';
+    return;
+  }
+  const wantedLabels =
+    selectedLabels.value.length === 0 ? labelOptions.value : selectedLabels.value;
+  const matching = schedules.value.filter((sc) => {
+    const labels = sc.process.labels ?? [];
+    const labelHit = labels.some((l) => wantedLabels.includes(l));
+    const idHit = selectedProcessIds.value.includes(sc.process.id);
+    return labelHit || idHit;
+  });
+  if (!matching.length) {
+    analyzeTrees.value = [];
+    analyzeTip.value = 'No schedules match the current filter.';
+    return;
+  }
+  // Merge overlapping time ranges (mirrors booster-ui logic).
+  const ranges: { start: number; end: number }[] = matching
+    .map((sc) => ({ start: sc.startTime, end: sc.endTime }))
+    .sort((a, b) => a.start - b.start);
+  const merged: { start: number; end: number }[] = [];
+  for (const r of ranges) {
+    if (merged.length && r.start <= merged[merged.length - 1].end) {
+      merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, r.end);
+    } else {
+      merged.push({ ...r });
+    }
+  }
+  analyzeLoading.value = true;
+  analyzeTip.value = '';
+  try {
+    const resp = await bffClient.ebpfAnalyze({
+      scheduleIdList: matching.map((sc) => sc.scheduleId),
+      timeRanges: merged,
+      aggregateType: aggregateType.value,
+    });
+    analyzeTrees.value = resp.trees ?? [];
+    analyzeTip.value = resp.tip ?? '';
+    if (!resp.reachable && resp.error) analyzeTip.value = resp.error;
+  } catch (e) {
+    analyzeTip.value = e instanceof Error ? e.message : String(e);
+    analyzeTrees.value = [];
+  } finally {
+    analyzeLoading.value = false;
+  }
+}
+
+// ProfileFlameGraph + ProfileStackTable expect the trace-profile element
+// shape `{id, parentId, codeSignature, count, duration, durationChildExcluded}`.
+// eBPF returns `{id, parentId, symbol, stackType, dumpCount}` — translate.
+function asProfileTrees(): ProfileAnalyzationTree[] {
+  return analyzeTrees.value.map((t) => ({
+    elements: t.elements.map((e) => ({
+      id: e.id,
+      parentId: e.parentId,
+      codeSignature: e.symbol,
+      count: e.dumpCount,
+      duration: e.dumpCount,
+      durationChildExcluded: e.dumpCount,
+    })),
+  }));
+}
+
+async function submitNewTask(): Promise<void> {
+  if (!selectedId.value) {
+    newTaskError.value = 'Pick a service first';
+    return;
+  }
+  newTaskError.value = null;
+  const start =
+    newTask.monitorTime === 'now' ? Date.now() : newTask.monitorTimeAt.getTime();
+  try {
+    const resp = await bffClient.createEBPFTask(layerKey.value, {
+      serviceId: selectedId.value,
+      processLabels: newTask.labels,
+      startTime: start,
+      duration: Number(newTask.monitorMinutes) * 60,
+      targetType: newTask.targetType,
+    });
+    if (!resp.status && resp.errorReason) {
+      newTaskError.value = resp.errorReason;
+      return;
+    }
+    if (!resp.reachable && resp.error) {
+      newTaskError.value = resp.error;
+      return;
+    }
+    showNewTask.value = false;
+    newTask.labels = [];
+    await refreshTasks();
+  } catch (e) {
+    newTaskError.value = e instanceof Error ? e.message : String(e);
+  }
+}
+
+function fmtTime(ms: number): string {
+  if (!ms) return '—';
+  const d = new Date(ms);
+  const z = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}:${z(d.getSeconds())}`;
+}
+function attrLine(p: EBPFProcess): string {
+  return (p.attributes ?? []).map((a) => `${a.name}=${a.value}`).join(' · ');
+}
+function toggleProcessId(id: string): void {
+  const i = selectedProcessIds.value.indexOf(id);
+  if (i === -1) selectedProcessIds.value.push(id);
+  else selectedProcessIds.value.splice(i, 1);
+}
+function toggleLabel(l: string): void {
+  const i = selectedLabels.value.indexOf(l);
+  if (i === -1) selectedLabels.value.push(l);
+  else selectedLabels.value.splice(i, 1);
+}
+function toggleNewTaskLabel(l: string): void {
+  const i = newTask.labels.indexOf(l);
+  if (i === -1) newTask.labels.push(l);
+  else newTask.labels.splice(i, 1);
+}
+</script>
+
+<template>
+  <div class="sw-card ebpf-shell">
+    <!-- Tasks -->
+    <div class="ebpf-side">
+      <div class="side-head">
+        <span>eBPF profile tasks</span>
+        <button
+          class="btn-new"
+          :disabled="!selectedId || !couldProfiling"
+          :title="
+            !selectedId
+              ? 'Pick a service'
+              : couldProfiling
+                ? 'Create a new eBPF task'
+                : 'OAP reports no profilable processes for this service'
+          "
+          @click="showNewTask = true"
+        >+ New Task</button>
+      </div>
+      <div v-if="tasksError" class="side-err">{{ tasksError }}</div>
+      <div v-else-if="tasksLoading && !tasks.length" class="side-empty">Loading…</div>
+      <div v-else-if="!tasks.length" class="side-empty">
+        {{
+          selectedId
+            ? couldProfiling
+              ? 'No eBPF tasks yet — kick off one.'
+              : 'OAP can not eBPF-profile this service.'
+            : 'Pick a service to load tasks.'
+        }}
+      </div>
+      <ul v-else class="side-list">
+        <li
+          v-for="t in tasks"
+          :key="t.taskId"
+          :class="{ 'is-active': currentTask?.taskId === t.taskId }"
+          @click="pickTask(t)"
+        >
+          <div class="row1">
+            <span class="t-tag" :class="{ off: t.targetType === 'OFF_CPU' }">{{ t.targetType }}</span>
+            <span class="ep">{{ t.processLabels?.length ? t.processLabels.join(' · ') : 'All processes' }}</span>
+          </div>
+          <div class="row2">
+            <span>{{ fmtTime(t.taskStartTime) }}</span>
+            <span class="muted">→</span>
+            <span>{{ fmtTime(t.taskStartTime + (t.fixedTriggerDuration ?? 0) * 1000) }}</span>
+          </div>
+        </li>
+      </ul>
+    </div>
+
+    <!-- Main pane -->
+    <div class="ebpf-main">
+      <div class="filter-bar">
+        <div class="tb-block">
+          <label class="lbl">Labels</label>
+          <div class="chip-row">
+            <button
+              v-for="l in labelOptions"
+              :key="l"
+              class="chip"
+              :class="{ on: selectedLabels.includes(l) }"
+              @click="toggleLabel(l)"
+            >{{ l }}</button>
+            <span v-if="!labelOptions.length" class="muted">—</span>
+          </div>
+        </div>
+        <div class="tb-block">
+          <label class="lbl">Aggregate</label>
+          <div class="seg">
+            <button :class="{ on: aggregateType === 'COUNT' }" @click="aggregateType = 'COUNT'">Count</button>
+            <button
+              :class="{ on: aggregateType === 'DURATION' }"
+              :disabled="currentTask?.targetType !== 'OFF_CPU'"
+              :title="currentTask?.targetType === 'OFF_CPU' ? '' : 'Duration is OFF_CPU-only'"
+              @click="aggregateType = 'DURATION'"
+            >Duration</button>
+          </div>
+        </div>
+        <div class="tb-block">
+          <label class="lbl">Display</label>
+          <div class="seg">
+            <button :class="{ on: displayMode === 'flame' }" @click="displayMode = 'flame'">Flame</button>
+            <button :class="{ on: displayMode === 'tree' }" @click="displayMode = 'tree'">Tree</button>
+          </div>
+        </div>
+        <div class="tb-block grow">
+          <label class="lbl">Processes ({{ selectedProcessIds.length }} pinned)</label>
+          <button class="btn-secondary" @click="showProcessPicker = !showProcessPicker">
+            {{ showProcessPicker ? 'Hide picker' : 'Pick processes' }}
+          </button>
+        </div>
+        <button
+          class="btn-primary"
+          :disabled="analyzeLoading || !schedules.length"
+          @click="runAnalyze"
+        >{{ analyzeLoading ? 'Analyzing…' : 'Analyze' }}</button>
+      </div>
+
+      <div v-if="schedulesError" class="banner err">{{ schedulesError }}</div>
+      <div v-else-if="scheduleDuration" class="banner">
+        Captured between
+        <strong>{{ fmtTime(scheduleDuration.start) }}</strong>
+        and
+        <strong>{{ fmtTime(scheduleDuration.end) }}</strong>
+        — {{ schedules.length }} schedule{{ schedules.length === 1 ? '' : 's' }}
+      </div>
+
+      <div v-if="showProcessPicker" class="process-picker">
+        <input
+          v-model="processSearch"
+          placeholder="Search by name / instance / command line…"
+          class="ti-input wide"
+        />
+        <div class="proc-table">
+          <div class="ph">
+            <div class="cc cc-sel"></div>
+            <div class="cc cc-name">Process</div>
+            <div class="cc cc-inst">Instance</div>
+            <div class="cc cc-attrs">Attributes</div>
+          </div>
+          <div v-if="!filteredProcesses.length" class="empty">No matches.</div>
+          <label
+            v-for="p in filteredProcesses"
+            :key="p.id"
+            class="pr"
+            :class="{ on: selectedProcessIds.includes(p.id) }"
+          >
+            <div class="cc cc-sel">
+              <input
+                type="checkbox"
+                :checked="selectedProcessIds.includes(p.id)"
+                @change="toggleProcessId(p.id)"
+              />
+            </div>
+            <div class="cc cc-name" :title="p.name">{{ p.name }}</div>
+            <div class="cc cc-inst" :title="p.instanceName ?? ''">{{ p.instanceName }}</div>
+            <div class="cc cc-attrs" :title="attrLine(p)">{{ attrLine(p) }}</div>
+          </label>
+        </div>
+      </div>
+
+      <div class="result">
+        <div v-if="analyzeTip" class="tip">{{ analyzeTip }}</div>
+        <template v-if="analyzeTrees.length">
+          <ProfileFlameGraph
+            v-if="displayMode === 'flame'"
+            :trees="asProfileTrees()"
+            metric-key="count"
+          />
+          <ProfileStackTable
+            v-else
+            :trees="asProfileTrees()"
+            :highlight-top="highlightTop"
+            @toggle-highlight="highlightTop = !highlightTop"
+          />
+        </template>
+        <div v-else-if="!analyzeTip && !analyzeLoading" class="result-empty">
+          {{
+            currentTask
+              ? schedules.length
+                ? 'No analyze data yet — click Analyze.'
+                : 'No schedules captured for this task.'
+              : 'Select a task to inspect its eBPF profile.'
+          }}
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- New task modal -->
+  <div v-if="showNewTask" class="dlg-mask" @click.self="showNewTask = false">
+    <div class="dlg">
+      <div class="dlg-head">
+        <div>
+          New eBPF profile task
+          <span v-if="serviceName" class="muted">on {{ serviceName }}</span>
+        </div>
+        <button class="x" @click="showNewTask = false">×</button>
+      </div>
+      <div class="dlg-body">
+        <div class="field">
+          <label>Process labels (filter; leave empty = all)</label>
+          <div class="chip-row">
+            <button
+              v-for="l in allProcessLabels"
+              :key="l"
+              type="button"
+              class="chip"
+              :class="{ on: newTask.labels.includes(l) }"
+              @click="toggleNewTaskLabel(l)"
+            >{{ l }}</button>
+            <span v-if="!allProcessLabels.length" class="muted">No labels surfaced by OAP.</span>
+          </div>
+        </div>
+        <div class="field-row">
+          <div class="field">
+            <label>Target</label>
+            <div class="seg">
+              <button :class="{ on: newTask.targetType === 'ON_CPU' }" @click="newTask.targetType = 'ON_CPU'">ON_CPU</button>
+              <button :class="{ on: newTask.targetType === 'OFF_CPU' }" @click="newTask.targetType = 'OFF_CPU'">OFF_CPU</button>
+            </div>
+          </div>
+          <div class="field">
+            <label>Start when</label>
+            <div class="seg">
+              <button :class="{ on: newTask.monitorTime === 'now' }" @click="newTask.monitorTime = 'now'">now</button>
+              <button :class="{ on: newTask.monitorTime === 'set' }" @click="newTask.monitorTime = 'set'">set time</button>
+            </div>
+            <input
+              v-if="newTask.monitorTime === 'set'"
+              type="datetime-local"
+              class="ti-input"
+              :value="new Date(newTask.monitorTimeAt.getTime() - newTask.monitorTimeAt.getTimezoneOffset() * 60_000).toISOString().slice(0, 16)"
+              @input="(ev: Event) => (newTask.monitorTimeAt = new Date((ev.target as HTMLInputElement).value))"
+            />
+          </div>
+          <div class="field">
+            <label>Duration (min, 1–60)</label>
+            <input
+              type="number"
+              class="ti-input"
+              min="1"
+              max="60"
+              v-model.number="newTask.monitorMinutes"
+            />
+          </div>
+        </div>
+        <div v-if="newTaskError" class="dlg-err">{{ newTaskError }}</div>
+      </div>
+      <div class="dlg-foot">
+        <button class="btn-secondary" @click="showNewTask = false">Cancel</button>
+        <button class="btn-primary" @click="submitNewTask">Create task</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.ebpf-shell {
+  display: flex;
+  height: calc(100vh - 280px);
+  min-height: 520px;
+  padding: 0;
+  overflow: hidden;
+}
+.ebpf-side {
+  width: 310px;
+  flex: 0 0 310px;
+  display: flex;
+  flex-direction: column;
+  border-right: 1px solid var(--sw-line);
+}
+.side-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 6px 10px;
+  background: var(--sw-bg-2);
+  border-bottom: 1px solid var(--sw-line);
+  font-weight: 600;
+  font-size: 11.5px;
+  color: var(--sw-fg-1);
+}
+.btn-new {
+  font-size: 10.5px;
+  padding: 2px 8px;
+  border-radius: 3px;
+  border: 1px solid var(--sw-line-2);
+  background: var(--sw-bg-1);
+  color: var(--sw-fg-1);
+  cursor: pointer;
+}
+.btn-new:hover:not(:disabled) {
+  border-color: var(--sw-accent);
+  color: var(--sw-accent);
+}
+.btn-new:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+.side-err,
+.side-empty {
+  padding: 12px;
+  font-size: 11.5px;
+  color: var(--sw-fg-3);
+  text-align: center;
+}
+.side-err {
+  color: #ff8888;
+}
+.side-list {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  overflow-y: auto;
+  flex: 1 1 0;
+}
+.side-list li {
+  padding: 8px 10px;
+  border-bottom: 1px solid var(--sw-line);
+  cursor: pointer;
+}
+.side-list li:hover {
+  background: var(--sw-bg-2);
+}
+.side-list li.is-active {
+  background: var(--sw-bg-3, var(--sw-bg-2));
+  border-left: 3px solid var(--sw-accent);
+  padding-left: 7px;
+}
+.row1 {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+.row2 {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 10.5px;
+  color: var(--sw-fg-2);
+  margin-top: 4px;
+}
+.muted {
+  color: var(--sw-fg-3);
+}
+.t-tag {
+  display: inline-block;
+  font-size: 10px;
+  padding: 0 5px;
+  border-radius: 2px;
+  background: var(--sw-accent);
+  color: #fff;
+  font-weight: 600;
+  letter-spacing: 0.04em;
+}
+.t-tag.off {
+  background: #4a8cf2;
+}
+.ep {
+  font-size: 12px;
+  color: var(--sw-fg-0);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.ebpf-main {
+  flex: 1 1 0;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+}
+.filter-bar {
+  display: flex;
+  align-items: end;
+  gap: 12px;
+  padding: 8px 12px;
+  background: var(--sw-bg-1);
+  border-bottom: 1px solid var(--sw-line);
+  flex-wrap: wrap;
+}
+.tb-block {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+}
+.tb-block.grow {
+  flex: 1 1 auto;
+}
+.lbl {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+}
+.chip-row {
+  display: flex;
+  gap: 4px;
+  flex-wrap: wrap;
+  min-height: 26px;
+  align-items: center;
+}
+.chip {
+  font-size: 11px;
+  padding: 3px 9px;
+  border-radius: 12px;
+  border: 1px solid var(--sw-line-2);
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-1);
+  cursor: pointer;
+}
+.chip.on {
+  background: var(--sw-accent);
+  color: #fff;
+  border-color: var(--sw-accent);
+}
+.seg {
+  display: inline-flex;
+  border: 1px solid var(--sw-line-2);
+  border-radius: 3px;
+  overflow: hidden;
+  align-self: start;
+}
+.seg button {
+  background: transparent;
+  border: none;
+  color: var(--sw-fg-2);
+  padding: 4px 10px;
+  font-size: 11px;
+  cursor: pointer;
+}
+.seg button:disabled {
+  opacity: 0.4;
+  cursor: not-allowed;
+}
+.seg button.on {
+  background: var(--sw-accent);
+  color: #fff;
+}
+.btn-primary {
+  background: var(--sw-accent);
+  color: #fff;
+  border: none;
+  border-radius: 3px;
+  padding: 6px 14px;
+  font-size: 11.5px;
+  font-weight: 600;
+  cursor: pointer;
+}
+.btn-primary:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+.btn-secondary {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-1);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 3px;
+  padding: 5px 12px;
+  font-size: 11px;
+  cursor: pointer;
+}
+.banner {
+  padding: 6px 12px;
+  font-size: 11px;
+  color: var(--sw-fg-2);
+  background: var(--sw-bg-2);
+  border-bottom: 1px solid var(--sw-line);
+}
+.banner.err {
+  color: #ff8888;
+}
+.banner strong {
+  color: var(--sw-fg-0);
+  font-weight: 600;
+}
+
+.process-picker {
+  border-bottom: 1px solid var(--sw-line);
+  background: var(--sw-bg-1);
+  padding: 8px 12px;
+  max-height: 220px;
+  overflow: auto;
+}
+.ti-input,
+.ti-input.wide {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-0);
+  border: 1px solid var(--sw-line);
+  border-radius: 3px;
+  padding: 4px 6px;
+  font-size: 11.5px;
+  font-family: var(--sw-mono);
+  outline: none;
+}
+.ti-input.wide {
+  width: 100%;
+  margin-bottom: 8px;
+}
+.proc-table {
+  border: 1px solid var(--sw-line);
+  border-radius: 3px;
+}
+.ph,
+.pr {
+  display: grid;
+  grid-template-columns: 30px minmax(120px, 1.2fr) minmax(120px, 1fr) minmax(160px, 2fr);
+  align-items: center;
+  font-size: 11px;
+}
+.ph {
+  background: var(--sw-bg-2);
+  border-bottom: 1px solid var(--sw-line);
+}
+.ph .cc {
+  padding: 6px 8px;
+  font-weight: 600;
+  color: var(--sw-fg-1);
+}
+.pr {
+  border-bottom: 1px dotted var(--sw-line);
+  cursor: pointer;
+}
+.pr:hover {
+  background: var(--sw-bg-2);
+}
+.pr.on {
+  background: var(--sw-bg-3, var(--sw-bg-2));
+}
+.pr .cc {
+  padding: 5px 8px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.result {
+  flex: 1 1 0;
+  min-height: 0;
+  position: relative;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+.tip {
+  padding: 10px 14px;
+  background: var(--sw-bg-2);
+  color: #ffb86b;
+  font-size: 11.5px;
+  border-bottom: 1px solid var(--sw-line);
+}
+.result-empty {
+  margin: auto;
+  color: var(--sw-fg-3);
+  font-size: 12px;
+  text-align: center;
+}
+
+.dlg-mask {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.55);
+  z-index: 50;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.dlg {
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  width: 560px;
+  max-width: 92vw;
+  max-height: 90vh;
+  display: flex;
+  flex-direction: column;
+}
+.dlg-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  background: var(--sw-bg-2);
+  font-weight: 600;
+  font-size: 12.5px;
+  color: var(--sw-fg-0);
+}
+.x {
+  background: transparent;
+  border: none;
+  color: var(--sw-fg-3);
+  font-size: 18px;
+  cursor: pointer;
+}
+.dlg-body {
+  padding: 14px;
+  overflow-y: auto;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  font-size: 11.5px;
+  color: var(--sw-fg-1);
+}
+.field label {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+}
+.field-row {
+  display: flex;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+.field-row .field {
+  flex: 1 1 0;
+  min-width: 130px;
+}
+.dlg-foot {
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+  padding: 10px 14px;
+  border-top: 1px solid var(--sw-line);
+}
+.dlg-err {
+  color: #ff7676;
+  font-size: 11px;
+}
+.empty {
+  padding: 10px;
+  text-align: center;
+  font-size: 11px;
+  color: var(--sw-fg-3);
+}
+</style>
diff --git a/apps/ui/src/views/layer/LayerEndpointDependencyView.vue b/apps/ui/src/views/layer/LayerEndpointDependencyView.vue
index 9bef6dd..4b8827b 100644
--- a/apps/ui/src/views/layer/LayerEndpointDependencyView.vue
+++ b/apps/ui/src/views/layer/LayerEndpointDependencyView.vue
@@ -233,6 +233,7 @@ const cfg = computed(() => data.value?.config ?? {
   nodeMetrics: [] as TopologyMetricDef[],
   linkMetrics: [] as TopologyMetricDef[],
 });
+const showLegacyGroup = computed<boolean>(() => Boolean(cfg.value?.showGroup));
 function pickByRole(defs: TopologyMetricDef[], role: TopologyMetricDef['role']): TopologyMetricDef | null {
   return defs.find((d) => d.role === role) ?? null;
 }
@@ -648,9 +649,13 @@ function edgeRowCrosshair(rowId: string): number | null {
         <span class="kicker">API dependency</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 v-if="identity(serviceName).cluster" class="sw-tag accent tiny inline-tag">
+            <span class="tag-alias">{{ identity(serviceName).clusterAlias }}</span>
+            <span class="tag-val">{{ identity(serviceName).cluster }}</span>
+          </span>
+          <span v-if="showLegacyGroup && identity(serviceName).legacyGroup" class="sw-tag tiny inline-tag">
+            <span class="tag-alias">group</span>
+            <span class="tag-val">{{ identity(serviceName).legacyGroup }}</span>
           </span>
           <b>{{ identity(serviceName).display }}</b>
         </span>
@@ -1002,9 +1007,13 @@ function edgeRowCrosshair(rowId: string): number | null {
         <header class="ed-head">
           <div class="ed-id">
             <div class="ed-kind-row">
-              <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 v-if="identity(selectedNode.serviceName).cluster" class="sw-tag accent tiny">
+                <span class="tag-alias">{{ identity(selectedNode.serviceName).clusterAlias }}</span>
+                <span class="tag-val">{{ identity(selectedNode.serviceName).cluster }}</span>
+              </span>
+              <span v-if="showLegacyGroup && identity(selectedNode.serviceName).legacyGroup" class="sw-tag tiny">
+                <span class="tag-alias">group</span>
+                <span class="tag-val">{{ identity(selectedNode.serviceName).legacyGroup }}</span>
               </span>
               <span class="ed-svc">{{ identity(selectedNode.serviceName).display }}</span>
               <span v-if="selectedNode.id === focusedId" class="sw-tag accent">focus</span>
diff --git a/apps/ui/src/views/layer/LayerNetworkProfilingView.vue b/apps/ui/src/views/layer/LayerNetworkProfilingView.vue
new file mode 100644
index 0000000..b8aee76
--- /dev/null
+++ b/apps/ui/src/views/layer/LayerNetworkProfilingView.vue
@@ -0,0 +1,723 @@
+<!--
+  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.
+-->
+<!--
+  Per-layer Network Profiling tab — feature parity with booster-ui
+  `dashboard/related/network-profiling`. Mounts on a service instance
+  and surfaces eBPF NETWORK + CONTINUOUS_PROFILING tasks plus the
+  process-level topology snapshot.
+
+    ┌──────────────┬─────────────────────────────────────────────┐
+    │  Instance    │ Topology window selector  · Keep-alive btn  │
+    │  picker      ├─────────────────────────────────────────────┤
+    │  Tasks list  │                                             │
+    │  + New Task  │   ProcessTopologyGraph (d3 force layout)    │
+    │              │                                             │
+    │              ├─────────────────────────────────────────────┤
+    │              │  Selected node / call detail panel          │
+    └──────────────┴─────────────────────────────────────────────┘
+-->
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+import { useLayerInstances } from '@/composables/useLayerInstances';
+import { useSelectedService } from '@/composables/useSelectedService';
+import { bffClient } from '@/api/client';
+import type {
+  EBPFTask,
+  NetworkProfilingSampling,
+  ProcessCall,
+  ProcessNode,
+} from '@/api/client';
+import ProcessTopologyGraph from '@/components/profile/ProcessTopologyGraph.vue';
+
+const route = useRoute();
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+const { selectedId: serviceId } = useSelectedService();
+
+// Instance picker (binds to ?serviceInstance= via plain ref state — the
+// network view needs an *instance* to be useful, so we don't reuse the
+// generic `useSelectedInstance` composable's URL contract).
+const instances = useLayerInstances(layerKey, serviceId);
+const selectedInstanceId = ref<string | null>(null);
+watch(
+  () => instances.instances.value,
+  (rows) => {
+    if (rows.length && !rows.some((i) => i.id === selectedInstanceId.value)) {
+      selectedInstanceId.value = rows[0]?.id ?? null;
+    }
+  },
+  { immediate: true },
+);
+
+// ── Tasks ──────────────────────────────────────────────────────────
+const tasks = ref<EBPFTask[]>([]);
+const tasksError = ref<string | null>(null);
+const tasksLoading = ref(false);
+const currentTask = ref<EBPFTask | null>(null);
+const aliveStatus = ref<boolean | null>(null);
+
+watch(
+  () => layerKey.value + '|' + (serviceId.value ?? '') + '|' + (selectedInstanceId.value ?? ''),
+  () => void refreshTasks(),
+  { immediate: true },
+);
+
+async function refreshTasks(): Promise<void> {
+  tasksError.value = null;
+  tasks.value = [];
+  currentTask.value = null;
+  if (!serviceId.value && !selectedInstanceId.value) return;
+  tasksLoading.value = true;
+  try {
+    const resp = await bffClient.layerNetworkTasks(layerKey.value, {
+      service: serviceId.value ?? undefined,
+      serviceInstance: selectedInstanceId.value ?? undefined,
+    });
+    if (!resp.reachable && resp.error) tasksError.value = resp.error;
+    tasks.value = resp.tasks ?? [];
+    currentTask.value = tasks.value[0] ?? null;
+    await loadTopology();
+  } catch (e) {
+    tasksError.value = e instanceof Error ? e.message : String(e);
+  } finally {
+    tasksLoading.value = false;
+  }
+}
+
+// ── Process topology ──────────────────────────────────────────────
+const nodes = ref<ProcessNode[]>([]);
+const calls = ref<ProcessCall[]>([]);
+const topologyLoading = ref(false);
+const topologyError = ref<string | null>(null);
+const windowMinutes = ref(30);
+
+async function loadTopology(): Promise<void> {
+  nodes.value = [];
+  calls.value = [];
+  topologyError.value = null;
+  if (!selectedInstanceId.value) return;
+  topologyLoading.value = true;
+  try {
+    const resp = await bffClient.ebpfNetworkTopology(
+      selectedInstanceId.value,
+      windowMinutes.value,
+    );
+    if (!resp.reachable && resp.error) topologyError.value = resp.error;
+    nodes.value = resp.nodes ?? [];
+    calls.value = resp.calls ?? [];
+  } catch (e) {
+    topologyError.value = e instanceof Error ? e.message : String(e);
+  } finally {
+    topologyLoading.value = false;
+  }
+}
+
+const selectedNode = ref<ProcessNode | null>(null);
+const selectedCall = ref<ProcessCall | null>(null);
+
+async function keepAlive(): Promise<void> {
+  if (!currentTask.value) return;
+  aliveStatus.value = null;
+  try {
+    const resp = await bffClient.keepAliveNetworkProfilingTask(currentTask.value.taskId);
+    aliveStatus.value = resp.status;
+  } catch {
+    aliveStatus.value = false;
+  }
+}
+
+// ── New task modal ────────────────────────────────────────────────
+const showNewTask = ref(false);
+const newTaskError = ref<string | null>(null);
+const samplings = ref<NetworkProfilingSampling[]>([
+  { uriRegex: '', minDuration: 0, when4xx: true, when5xx: true, settings: { requireRequest: true, requireResponse: true } },
+]);
+function addSampling(): void {
+  samplings.value.push({ minDuration: 0, when4xx: false, when5xx: false });
+}
+function removeSampling(i: number): void {
+  samplings.value.splice(i, 1);
+}
+async function submitNewTask(): Promise<void> {
+  if (!selectedInstanceId.value) {
+    newTaskError.value = 'Pick an instance first';
+    return;
+  }
+  newTaskError.value = null;
+  try {
+    const resp = await bffClient.createNetworkProfilingTask({
+      instanceId: selectedInstanceId.value,
+      samplings: samplings.value,
+    });
+    if (!resp.status && resp.errorReason) {
+      newTaskError.value = resp.errorReason;
+      return;
+    }
+    if (!resp.reachable && resp.error) {
+      newTaskError.value = resp.error;
+      return;
+    }
+    showNewTask.value = false;
+    await refreshTasks();
+  } catch (e) {
+    newTaskError.value = e instanceof Error ? e.message : String(e);
+  }
+}
+
+function fmtTime(ms: number): string {
+  if (!ms) return '—';
+  const d = new Date(ms);
+  const z = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}:${z(d.getSeconds())}`;
+}
+</script>
+
+<template>
+  <div class="sw-card net-shell">
+    <!-- Side: instance picker + tasks -->
+    <div class="net-side">
+      <div class="side-head">
+        <span>Instance</span>
+      </div>
+      <div class="picker">
+        <select v-model="selectedInstanceId" class="sel wide" :disabled="!instances.instances.value.length">
+          <option v-if="!instances.instances.value.length" :value="null">— no instances —</option>
+          <option v-for="inst in instances.instances.value" :key="inst.id" :value="inst.id">
+            {{ inst.name }}
+          </option>
+        </select>
+      </div>
+
+      <div class="side-head between">
+        <span>Network tasks</span>
+        <button
+          class="btn-new"
+          :disabled="!selectedInstanceId"
+          @click="showNewTask = true"
+        >+ New Task</button>
+      </div>
+      <div v-if="tasksError" class="side-err">{{ tasksError }}</div>
+      <div v-else-if="tasksLoading && !tasks.length" class="side-empty">Loading…</div>
+      <div v-else-if="!tasks.length" class="side-empty">
+        {{ selectedInstanceId ? 'No network tasks on this instance.' : 'Pick an instance to load tasks.' }}
+      </div>
+      <ul v-else class="side-list">
+        <li
+          v-for="t in tasks"
+          :key="t.taskId"
+          :class="{ 'is-active': currentTask?.taskId === t.taskId }"
+          @click="currentTask = t"
+        >
+          <div class="row1">
+            <span class="t-tag net">NETWORK</span>
+            <span class="ep">{{ t.serviceInstanceName || t.taskId.slice(0, 12) }}</span>
+          </div>
+          <div class="row2">
+            <span>{{ fmtTime(t.taskStartTime) }}</span>
+          </div>
+        </li>
+      </ul>
+    </div>
+
+    <!-- Main: topology + detail -->
+    <div class="net-main">
+      <div class="filter-bar">
+        <div class="tb-block">
+          <label class="lbl">Window</label>
+          <select v-model.number="windowMinutes" class="sel" @change="loadTopology">
+            <option :value="5">5 min</option>
+            <option :value="15">15 min</option>
+            <option :value="30">30 min</option>
+            <option :value="60">1 hr</option>
+            <option :value="180">3 hr</option>
+          </select>
+        </div>
+        <button
+          class="btn-secondary"
+          :disabled="!currentTask"
+          @click="keepAlive"
+        >Keep-alive ping</button>
+        <span v-if="aliveStatus !== null" :class="['alive', aliveStatus ? 'ok' : 'err']">
+          {{ aliveStatus ? 'Sent ✓' : 'OAP rejected' }}
+        </span>
+        <span class="spacer"></span>
+        <span class="muted" v-if="!topologyLoading">{{ nodes.length }} processes · {{ calls.length }} edges</span>
+        <span v-if="topologyLoading" class="muted">loading topology…</span>
+      </div>
+
+      <div v-if="topologyError" class="banner err">{{ topologyError }}</div>
+
+      <div class="topology">
+        <ProcessTopologyGraph
+          v-if="nodes.length"
+          :nodes="nodes"
+          :calls="calls"
+          @select-node="selectedNode = $event; selectedCall = null"
+          @select-call="selectedCall = $event; selectedNode = null"
+        />
+        <div v-else-if="!topologyLoading" class="topology-empty">
+          {{ selectedInstanceId
+            ? 'No process topology data in the selected window. Create a network profile task and let it run, then refresh.'
+            : 'Pick an instance to view its process topology.' }}
+        </div>
+      </div>
+
+      <div v-if="selectedNode || selectedCall" class="detail">
+        <div v-if="selectedNode">
+          <h5>Process</h5>
+          <dl class="kv">
+            <dt>Name</dt><dd>{{ selectedNode.name }}</dd>
+            <dt>Real?</dt><dd>{{ selectedNode.isReal ? 'yes' : 'virtual peer' }}</dd>
+            <dt>Service</dt><dd>{{ selectedNode.serviceName }}</dd>
+            <dt>Instance</dt><dd>{{ selectedNode.serviceInstanceName }}</dd>
+            <dt>ID</dt><dd class="mono">{{ selectedNode.id }}</dd>
+          </dl>
+        </div>
+        <div v-else-if="selectedCall">
+          <h5>Edge</h5>
+          <dl class="kv">
+            <dt>Detect points</dt><dd>{{ selectedCall.detectPoints.join(', ') }}</dd>
+            <dt>Source comp.</dt><dd>{{ (selectedCall.sourceComponents ?? []).join(', ') || '—' }}</dd>
+            <dt>Target comp.</dt><dd>{{ (selectedCall.targetComponents ?? []).join(', ') || '—' }}</dd>
+            <dt>ID</dt><dd class="mono">{{ selectedCall.id }}</dd>
+          </dl>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div v-if="showNewTask" class="dlg-mask" @click.self="showNewTask = false">
+    <div class="dlg wide">
+      <div class="dlg-head">
+        <div>New network profile task</div>
+        <button class="x" @click="showNewTask = false">×</button>
+      </div>
+      <div class="dlg-body">
+        <p class="hint">
+          OAP captures one connection sample per matching rule. Leave URI
+          empty to match any request; toggle 4xx/5xx to scope by status.
+        </p>
+        <div v-for="(s, i) in samplings" :key="i" class="sampling">
+          <div class="sampling-head">
+            <strong>Sampling rule {{ i + 1 }}</strong>
+            <button
+              v-if="samplings.length > 1"
+              class="del"
+              type="button"
+              @click="removeSampling(i)"
+            >× remove</button>
+          </div>
+          <div class="field-row">
+            <div class="field grow">
+              <label>URI regex (optional)</label>
+              <input class="ti-input wide" v-model="s.uriRegex" placeholder="e.g. ^/api/.*" />
+            </div>
+            <div class="field">
+              <label>Min duration (ms)</label>
+              <input class="ti-input" type="number" min="0" v-model.number="s.minDuration" />
+            </div>
+          </div>
+          <div class="check-row">
+            <label class="cb"><input type="checkbox" v-model="s.when4xx" /> when 4xx</label>
+            <label class="cb"><input type="checkbox" v-model="s.when5xx" /> when 5xx</label>
+            <label class="cb"><input type="checkbox" v-model="s.settings!.requireRequest" /> capture request</label>
+            <label class="cb"><input type="checkbox" v-model="s.settings!.requireResponse" /> capture response</label>
+          </div>
+        </div>
+        <button class="btn-secondary" type="button" @click="addSampling">+ add another sampling rule</button>
+        <div v-if="newTaskError" class="dlg-err">{{ newTaskError }}</div>
+      </div>
+      <div class="dlg-foot">
+        <button class="btn-secondary" @click="showNewTask = false">Cancel</button>
+        <button class="btn-primary" @click="submitNewTask">Create task</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.net-shell {
+  display: flex;
+  height: calc(100vh - 280px);
+  min-height: 520px;
+  padding: 0;
+  overflow: hidden;
+}
+.net-side {
+  width: 310px;
+  flex: 0 0 310px;
+  display: flex;
+  flex-direction: column;
+  border-right: 1px solid var(--sw-line);
+}
+.side-head {
+  padding: 6px 10px;
+  font-size: 11.5px;
+  font-weight: 600;
+  color: var(--sw-fg-1);
+  background: var(--sw-bg-2);
+  border-bottom: 1px solid var(--sw-line);
+}
+.side-head.between {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 0;
+}
+.picker {
+  padding: 8px 10px;
+  border-bottom: 1px solid var(--sw-line);
+}
+.btn-new {
+  font-size: 10.5px;
+  padding: 2px 8px;
+  border-radius: 3px;
+  border: 1px solid var(--sw-line-2);
+  background: var(--sw-bg-1);
+  color: var(--sw-fg-1);
+  cursor: pointer;
+}
+.btn-new:hover:not(:disabled) {
+  border-color: var(--sw-accent);
+  color: var(--sw-accent);
+}
+.btn-new:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+.side-err,
+.side-empty {
+  padding: 12px;
+  font-size: 11.5px;
+  color: var(--sw-fg-3);
+  text-align: center;
+}
+.side-err {
+  color: #ff8888;
+}
+.side-list {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  overflow-y: auto;
+  flex: 1 1 0;
+}
+.side-list li {
+  padding: 8px 10px;
+  border-bottom: 1px solid var(--sw-line);
+  cursor: pointer;
+}
+.side-list li:hover {
+  background: var(--sw-bg-2);
+}
+.side-list li.is-active {
+  background: var(--sw-bg-3, var(--sw-bg-2));
+  border-left: 3px solid var(--sw-accent);
+  padding-left: 7px;
+}
+.row1,
+.row2 {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+.row2 {
+  margin-top: 4px;
+  font-size: 10.5px;
+  color: var(--sw-fg-2);
+}
+.t-tag {
+  display: inline-block;
+  font-size: 10px;
+  padding: 0 5px;
+  border-radius: 2px;
+  background: var(--sw-accent);
+  color: #fff;
+  font-weight: 600;
+}
+.t-tag.net {
+  background: #58a6ff;
+}
+.ep {
+  font-size: 12px;
+  color: var(--sw-fg-0);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.muted {
+  color: var(--sw-fg-3);
+}
+
+.net-main {
+  flex: 1 1 0;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+}
+.filter-bar {
+  display: flex;
+  align-items: end;
+  gap: 12px;
+  padding: 8px 12px;
+  background: var(--sw-bg-1);
+  border-bottom: 1px solid var(--sw-line);
+}
+.tb-block {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+}
+.lbl {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+}
+.sel,
+.ti-input {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-0);
+  border: 1px solid var(--sw-line);
+  border-radius: 3px;
+  padding: 4px 6px;
+  font-size: 11.5px;
+  font-family: var(--sw-mono);
+  outline: none;
+}
+.sel.wide,
+.ti-input.wide {
+  width: 100%;
+}
+.btn-primary {
+  background: var(--sw-accent);
+  color: #fff;
+  border: none;
+  border-radius: 3px;
+  padding: 6px 14px;
+  font-size: 11.5px;
+  font-weight: 600;
+  cursor: pointer;
+}
+.btn-secondary {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-1);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 3px;
+  padding: 5px 12px;
+  font-size: 11.5px;
+  cursor: pointer;
+}
+.btn-secondary:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+.alive {
+  font-size: 10.5px;
+  padding: 0 6px;
+  border-radius: 2px;
+}
+.alive.ok {
+  color: var(--sw-accent);
+}
+.alive.err {
+  color: #ff7676;
+}
+.spacer {
+  flex: 1 1 0;
+}
+.banner.err {
+  padding: 6px 12px;
+  font-size: 11px;
+  color: #ff8888;
+  background: var(--sw-bg-2);
+  border-bottom: 1px solid var(--sw-line);
+}
+.topology {
+  flex: 1 1 0;
+  position: relative;
+  min-height: 240px;
+}
+.topology-empty {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 11.5px;
+  color: var(--sw-fg-3);
+  padding: 20px;
+  text-align: center;
+}
+.detail {
+  border-top: 1px solid var(--sw-line);
+  background: var(--sw-bg-1);
+  padding: 8px 14px;
+  max-height: 220px;
+  overflow-y: auto;
+}
+.detail h5 {
+  margin: 0 0 6px;
+  font-size: 10.5px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-1);
+}
+.kv {
+  display: grid;
+  grid-template-columns: 130px 1fr;
+  row-gap: 4px;
+  font-size: 11.5px;
+  margin: 0;
+}
+.kv dt {
+  color: var(--sw-fg-3);
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  align-self: center;
+}
+.kv dd {
+  margin: 0;
+  color: var(--sw-fg-0);
+}
+.kv dd.mono {
+  font-family: var(--sw-mono);
+  font-size: 10.5px;
+  color: var(--sw-fg-1);
+}
+
+.dlg-mask {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.55);
+  z-index: 50;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.dlg {
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  width: 560px;
+  max-width: 92vw;
+  max-height: 90vh;
+  display: flex;
+  flex-direction: column;
+}
+.dlg.wide {
+  width: 640px;
+}
+.dlg-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  background: var(--sw-bg-2);
+  font-weight: 600;
+  font-size: 12.5px;
+  color: var(--sw-fg-0);
+}
+.x {
+  background: transparent;
+  border: none;
+  color: var(--sw-fg-3);
+  font-size: 18px;
+  cursor: pointer;
+}
+.dlg-body {
+  padding: 14px;
+  overflow-y: auto;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+.hint {
+  font-size: 11px;
+  color: var(--sw-fg-2);
+  margin: 0;
+  line-height: 1.5;
+}
+.sampling {
+  border: 1px solid var(--sw-line);
+  border-radius: 4px;
+  padding: 10px;
+  background: var(--sw-bg-2);
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.sampling-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 11px;
+  color: var(--sw-fg-1);
+}
+.sampling-head .del {
+  background: transparent;
+  border: none;
+  color: #ff7676;
+  cursor: pointer;
+  font-size: 11px;
+}
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.field.grow {
+  flex: 1 1 0;
+}
+.field label {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+}
+.field-row {
+  display: flex;
+  gap: 8px;
+  align-items: end;
+}
+.check-row {
+  display: flex;
+  gap: 14px;
+  flex-wrap: wrap;
+  font-size: 11.5px;
+  color: var(--sw-fg-1);
+}
+.cb {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  cursor: pointer;
+}
+.dlg-foot {
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+  padding: 10px 14px;
+  border-top: 1px solid var(--sw-line);
+}
+.dlg-err {
+  color: #ff7676;
+  font-size: 11px;
+}
+</style>
diff --git a/apps/ui/src/views/layer/LayerPprofProfilingView.vue b/apps/ui/src/views/layer/LayerPprofProfilingView.vue
new file mode 100644
index 0000000..6b9ca1a
--- /dev/null
+++ b/apps/ui/src/views/layer/LayerPprofProfilingView.vue
@@ -0,0 +1,586 @@
+<!--
+  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.
+-->
+<!--
+  Per-layer pprof (Go) profiling tab. Mirrors the async-profiler view
+  shape since OAP's pprof entry points use the same task / progress /
+  analyze contract; the only practical differences are:
+    - Event types are pprof-flavoured strings (`CPU`, `HEAP`, `BLOCK`,
+      `GOROUTINE`, `MUTEX`, `ALLOCS`, `THREADCREATE`).
+    - A `dumpPeriod` (sampling tick) is captured per task.
+    - Result graph is a SINGLE tree (no per-event split).
+-->
+<script setup lang="ts">
+import { computed, reactive, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+import { useLayerInstances } from '@/composables/useLayerInstances';
+import { useSelectedService } from '@/composables/useSelectedService';
+import { bffClient } from '@/api/client';
+import type { PprofTask, PprofTree, ProfileAnalyzationTree } from '@/api/client';
+import ProfileFlameGraph from '@/components/profile/ProfileFlameGraph.vue';
+
+const route = useRoute();
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+const { selectedId: serviceId } = useSelectedService();
+const instances = useLayerInstances(layerKey, serviceId);
+
+const tasks = ref<PprofTask[]>([]);
+const tasksError = ref<string | null>(null);
+const currentTask = ref<PprofTask | null>(null);
+const tasksLoading = ref(false);
+
+const selectedInstances = ref<string[]>([]);
+const eventType = ref<string>('CPU');
+const tree = ref<PprofTree | null>(null);
+const analyzeError = ref<string | null>(null);
+const analyzeLoading = ref(false);
+
+const showNewTask = ref(false);
+const newTask = reactive({
+  instances: [] as string[],
+  duration: 60,
+  events: ['CPU'] as string[],
+  dumpPeriod: 10,
+});
+const newTaskError = ref<string | null>(null);
+
+const PPROF_EVENTS = ['CPU', 'HEAP', 'BLOCK', 'GOROUTINE', 'MUTEX', 'ALLOCS', 'THREADCREATE'];
+const DURATION_OPTS = [
+  { v: 30, label: '30 sec' },
+  { v: 60, label: '1 min' },
+  { v: 300, label: '5 min' },
+  { v: 600, label: '10 min' },
+];
+
+watch(
+  () => layerKey.value + '|' + (serviceId.value ?? ''),
+  () => void refreshTasks(),
+  { immediate: true },
+);
+
+async function refreshTasks(): Promise<void> {
+  tasksError.value = null;
+  if (!serviceId.value) return;
+  tasksLoading.value = true;
+  try {
+    const resp = await bffClient.layerPprofTasks(layerKey.value, serviceId.value);
+    if (!resp.reachable && resp.error) tasksError.value = resp.error;
+    tasks.value = resp.tasks ?? [];
+    currentTask.value = tasks.value[0] ?? null;
+    if (currentTask.value) {
+      selectedInstances.value = [...(currentTask.value.serviceInstanceIds ?? [])];
+      eventType.value = currentTask.value.events?.[0] ?? 'CPU';
+    }
+    tree.value = null;
+  } catch (e) {
+    tasksError.value = e instanceof Error ? e.message : String(e);
+  } finally {
+    tasksLoading.value = false;
+  }
+}
+
+async function runAnalyze(): Promise<void> {
+  if (!currentTask.value || !selectedInstances.value.length) return;
+  analyzeError.value = null;
+  analyzeLoading.value = true;
+  try {
+    const resp = await bffClient.pprofAnalyze({
+      taskId: currentTask.value.id,
+      instanceIds: selectedInstances.value,
+      eventType: eventType.value,
+    });
+    if (!resp.reachable && resp.error) analyzeError.value = resp.error;
+    tree.value = resp.tree;
+  } catch (e) {
+    analyzeError.value = e instanceof Error ? e.message : String(e);
+  } finally {
+    analyzeLoading.value = false;
+  }
+}
+
+const profileTrees = computed<ProfileAnalyzationTree[]>(() => {
+  if (!tree.value) return [];
+  return [
+    {
+      elements: tree.value.elements.map((el) => ({
+        id: el.id,
+        parentId: el.parentId,
+        codeSignature: el.symbol,
+        count: el.dumpCount,
+        duration: el.dumpCount,
+        durationChildExcluded: el.self,
+      })),
+    },
+  ];
+});
+
+function toggleEvent(e: string): void {
+  const i = newTask.events.indexOf(e);
+  if (i === -1) newTask.events.push(e);
+  else newTask.events.splice(i, 1);
+}
+function toggleInstance(id: string, dst: 'filter' | 'new'): void {
+  const target = dst === 'filter' ? selectedInstances.value : newTask.instances;
+  const i = target.indexOf(id);
+  if (i === -1) target.push(id);
+  else target.splice(i, 1);
+}
+
+async function submitNewTask(): Promise<void> {
+  if (!serviceId.value || !newTask.instances.length || !newTask.events.length) {
+    newTaskError.value = 'Pick a service, instances, and events.';
+    return;
+  }
+  newTaskError.value = null;
+  try {
+    const resp = await bffClient.createPprofTask(layerKey.value, {
+      serviceId: serviceId.value,
+      serviceInstanceIds: newTask.instances,
+      duration: Number(newTask.duration),
+      events: newTask.events,
+      dumpPeriod: Number(newTask.dumpPeriod),
+    });
+    if (resp.errorReason) {
+      newTaskError.value = resp.errorReason;
+      return;
+    }
+    showNewTask.value = false;
+    await refreshTasks();
+  } catch (e) {
+    newTaskError.value = e instanceof Error ? e.message : String(e);
+  }
+}
+
+function fmtTime(ms: number): string {
+  if (!ms) return '—';
+  const d = new Date(ms);
+  const z = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}:${z(d.getSeconds())}`;
+}
+function instanceName(id: string): string {
+  return instances.instances.value.find((i) => i.id === id)?.name ?? id;
+}
+</script>
+
+<template>
+  <div class="sw-card pp-shell">
+    <div class="pp-side">
+      <div class="side-head between">
+        <span>pprof tasks</span>
+        <button class="btn-new" :disabled="!serviceId" @click="showNewTask = true">+ New Task</button>
+      </div>
+      <div v-if="tasksError" class="side-err">{{ tasksError }}</div>
+      <div v-else-if="tasksLoading && !tasks.length" class="side-empty">Loading…</div>
+      <div v-else-if="!tasks.length" class="side-empty">
+        {{ serviceId ? 'No pprof tasks. pprof works with Go services only.' : 'Pick a service to load tasks.' }}
+      </div>
+      <ul v-else class="side-list">
+        <li
+          v-for="t in tasks"
+          :key="t.id"
+          :class="{ 'is-active': currentTask?.id === t.id }"
+          @click="currentTask = t; selectedInstances = [...(t.serviceInstanceIds ?? [])]; tree = null"
+        >
+          <div class="row1">
+            <span class="t-tag">{{ t.events?.join(',') }}</span>
+            <span class="ep">{{ t.serviceInstanceIds?.length ?? 0 }} instance{{ (t.serviceInstanceIds?.length ?? 0) === 1 ? '' : 's' }}</span>
+          </div>
+          <div class="row2">
+            <span>{{ fmtTime(t.createTime) }}</span>
+            <span class="muted">· {{ t.duration }}s · {{ t.dumpPeriod ?? '?' }}ms</span>
+          </div>
+        </li>
+      </ul>
+    </div>
+
+    <div class="pp-main">
+      <div class="filter-bar">
+        <div class="tb-block grow">
+          <label class="lbl">Instances ({{ selectedInstances.length }} / {{ currentTask?.serviceInstanceIds?.length ?? 0 }})</label>
+          <div class="chip-row">
+            <button
+              v-for="id in currentTask?.serviceInstanceIds ?? []"
+              :key="id"
+              class="chip"
+              :class="{ on: selectedInstances.includes(id) }"
+              @click="toggleInstance(id, 'filter')"
+            >{{ instanceName(id) }}</button>
+            <span v-if="!currentTask" class="muted">Pick a task on the left.</span>
+          </div>
+        </div>
+        <div class="tb-block">
+          <label class="lbl">Event</label>
+          <select v-model="eventType" class="sel">
+            <option v-for="e in PPROF_EVENTS" :key="e" :value="e">{{ e }}</option>
+          </select>
+        </div>
+        <button class="btn-primary" :disabled="analyzeLoading || !currentTask" @click="runAnalyze">
+          {{ analyzeLoading ? 'Analyzing…' : 'Analyze' }}
+        </button>
+      </div>
+      <div v-if="analyzeError" class="banner err">{{ analyzeError }}</div>
+      <div class="result">
+        <ProfileFlameGraph v-if="profileTrees.length" :trees="profileTrees" metric-key="count" />
+        <div v-else-if="!analyzeLoading" class="result-empty">
+          {{ currentTask ? 'Pick instances + event, then click Analyze.' : 'Select a task.' }}
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div v-if="showNewTask" class="dlg-mask" @click.self="showNewTask = false">
+    <div class="dlg">
+      <div class="dlg-head">
+        <div>New pprof task</div>
+        <button class="x" @click="showNewTask = false">×</button>
+      </div>
+      <div class="dlg-body">
+        <div class="field">
+          <label>Instances (Go services only)</label>
+          <div class="chip-row">
+            <button
+              v-for="i in instances.instances.value"
+              :key="i.id"
+              type="button"
+              class="chip"
+              :class="{ on: newTask.instances.includes(i.id) }"
+              @click="toggleInstance(i.id, 'new')"
+            >{{ i.name }}</button>
+            <span v-if="!instances.instances.value.length" class="muted">No instances available.</span>
+          </div>
+        </div>
+        <div class="field-row">
+          <div class="field">
+            <label>Duration</label>
+            <select v-model.number="newTask.duration" class="sel">
+              <option v-for="o in DURATION_OPTS" :key="o.v" :value="o.v">{{ o.label }}</option>
+            </select>
+          </div>
+          <div class="field">
+            <label>Dump period (ms)</label>
+            <input type="number" min="1" v-model.number="newTask.dumpPeriod" class="ti-input" />
+          </div>
+        </div>
+        <div class="field">
+          <label>Events</label>
+          <div class="chip-row">
+            <button
+              v-for="e in PPROF_EVENTS"
+              :key="e"
+              type="button"
+              class="chip"
+              :class="{ on: newTask.events.includes(e) }"
+              @click="toggleEvent(e)"
+            >{{ e }}</button>
+          </div>
+        </div>
+        <div v-if="newTaskError" class="dlg-err">{{ newTaskError }}</div>
+      </div>
+      <div class="dlg-foot">
+        <button class="btn-secondary" @click="showNewTask = false">Cancel</button>
+        <button class="btn-primary" @click="submitNewTask">Create task</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.pp-shell {
+  display: flex;
+  height: calc(100vh - 280px);
+  min-height: 520px;
+  padding: 0;
+  overflow: hidden;
+}
+.pp-side {
+  width: 320px;
+  flex: 0 0 320px;
+  display: flex;
+  flex-direction: column;
+  border-right: 1px solid var(--sw-line);
+}
+.side-head {
+  padding: 6px 10px;
+  font-size: 11.5px;
+  font-weight: 600;
+  background: var(--sw-bg-2);
+  border-bottom: 1px solid var(--sw-line);
+  color: var(--sw-fg-1);
+}
+.side-head.between {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.btn-new {
+  font-size: 10.5px;
+  padding: 2px 8px;
+  border-radius: 3px;
+  border: 1px solid var(--sw-line-2);
+  background: var(--sw-bg-1);
+  color: var(--sw-fg-1);
+  cursor: pointer;
+}
+.btn-new:hover:not(:disabled) {
+  border-color: var(--sw-accent);
+  color: var(--sw-accent);
+}
+.btn-new:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+.side-err,
+.side-empty {
+  padding: 12px;
+  text-align: center;
+  font-size: 11.5px;
+  color: var(--sw-fg-3);
+}
+.side-err {
+  color: #ff8888;
+}
+.side-list {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  flex: 1 1 0;
+  overflow-y: auto;
+}
+.side-list li {
+  padding: 8px 10px;
+  border-bottom: 1px solid var(--sw-line);
+  cursor: pointer;
+}
+.side-list li:hover {
+  background: var(--sw-bg-2);
+}
+.side-list li.is-active {
+  background: var(--sw-bg-3, var(--sw-bg-2));
+  border-left: 3px solid var(--sw-accent);
+  padding-left: 7px;
+}
+.row1,
+.row2 {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+.row2 {
+  margin-top: 4px;
+  font-size: 10.5px;
+  color: var(--sw-fg-2);
+}
+.t-tag {
+  font-size: 10px;
+  padding: 0 5px;
+  border-radius: 2px;
+  background: #6c5cd9;
+  color: #fff;
+  font-weight: 600;
+}
+.ep {
+  font-size: 12px;
+  color: var(--sw-fg-0);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.muted {
+  color: var(--sw-fg-3);
+}
+
+.pp-main {
+  flex: 1 1 0;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+}
+.filter-bar {
+  display: flex;
+  gap: 12px;
+  padding: 8px 12px;
+  border-bottom: 1px solid var(--sw-line);
+  align-items: end;
+  flex-wrap: wrap;
+}
+.tb-block {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+}
+.tb-block.grow {
+  flex: 1 1 auto;
+}
+.lbl {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+}
+.chip-row {
+  display: flex;
+  gap: 4px;
+  flex-wrap: wrap;
+  align-items: center;
+  min-height: 26px;
+}
+.chip {
+  font-size: 11px;
+  padding: 3px 9px;
+  border-radius: 12px;
+  border: 1px solid var(--sw-line-2);
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-1);
+  cursor: pointer;
+}
+.chip.on {
+  background: var(--sw-accent);
+  border-color: var(--sw-accent);
+  color: #fff;
+}
+.sel,
+.ti-input {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-0);
+  border: 1px solid var(--sw-line);
+  border-radius: 3px;
+  padding: 4px 6px;
+  font-size: 11.5px;
+  font-family: var(--sw-mono);
+  outline: none;
+}
+.btn-primary {
+  background: var(--sw-accent);
+  color: #fff;
+  border: none;
+  border-radius: 3px;
+  padding: 6px 14px;
+  font-size: 11.5px;
+  font-weight: 600;
+  cursor: pointer;
+}
+.btn-primary:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+.btn-secondary {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-1);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 3px;
+  padding: 5px 12px;
+  font-size: 11.5px;
+  cursor: pointer;
+}
+.banner.err {
+  padding: 6px 12px;
+  font-size: 11px;
+  color: #ff8888;
+  background: var(--sw-bg-2);
+  border-bottom: 1px solid var(--sw-line);
+}
+.result {
+  flex: 1 1 0;
+  min-height: 0;
+  position: relative;
+  overflow: hidden;
+}
+.result-empty {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--sw-fg-3);
+  font-size: 12px;
+}
+
+.dlg-mask {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.55);
+  z-index: 50;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.dlg {
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  width: 560px;
+  max-width: 92vw;
+  max-height: 90vh;
+  display: flex;
+  flex-direction: column;
+}
+.dlg-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  background: var(--sw-bg-2);
+  font-weight: 600;
+  font-size: 12.5px;
+  color: var(--sw-fg-0);
+}
+.x {
+  background: transparent;
+  border: none;
+  color: var(--sw-fg-3);
+  font-size: 18px;
+  cursor: pointer;
+}
+.dlg-body {
+  padding: 14px;
+  overflow-y: auto;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  font-size: 11.5px;
+}
+.field label {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+}
+.field-row {
+  display: flex;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+.field-row .field {
+  min-width: 130px;
+}
+.dlg-foot {
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+  padding: 10px 14px;
+  border-top: 1px solid var(--sw-line);
+}
+.dlg-err {
+  color: #ff7676;
+  font-size: 11px;
+}
+</style>
diff --git a/apps/ui/src/views/layer/LayerServiceMapView.vue b/apps/ui/src/views/layer/LayerServiceMapView.vue
index a617c74..5b11286 100644
--- a/apps/ui/src/views/layer/LayerServiceMapView.vue
+++ b/apps/ui/src/views/layer/LayerServiceMapView.vue
@@ -85,11 +85,11 @@ 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.
+// Per-layer topology-cluster rule (k8s/mesh ⇒ namespace) + label
+// policy for OAP's `<group>::<base>` prefix. `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 cluster bounding box.
 const namingRule = computed(() => layer.value?.naming ?? null);
 function identity(name: string | null | undefined): ServiceIdentity {
   return resolveServiceIdentity(name, namingRule.value);
@@ -134,9 +134,9 @@ const groupedRows = computed<Map<string, GroupedRow[]>>(() => {
   for (const r of landingRows.value) {
     const id = identity(r.serviceName);
     if (term && !r.serviceName.toLowerCase().includes(term)) continue;
-    const key = id.group ?? '';
+    const key = id.cluster ?? '';
     if (!map.has(key)) map.set(key, []);
-    map.get(key)!.push({ group: id.group, name: id.display, id: r.serviceId, raw: r.serviceName });
+    map.get(key)!.push({ group: id.cluster, name: id.display, id: r.serviceId, raw: r.serviceName });
   }
   return map;
 });
@@ -185,6 +185,11 @@ const cfg = computed(() => data.value?.config ?? {
   linkServerMetrics: [] as TopologyMetricDef[],
   linkClientMetrics: [] as TopologyMetricDef[],
 });
+// Per-topology-config flag: render the legacy `<group>::` prefix as a
+// SEPARATE chip in the node detail panel. Default off — by default
+// the prefix never appears in the UI; opting in surfaces it in the
+// panel only (topology node labels stay clean either way).
+const showLegacyGroup = computed<boolean>(() => Boolean(cfg.value?.showGroup));
 function pickByRole(defs: TopologyMetricDef[], role: TopologyMetricDef['role']): TopologyMetricDef | null {
   return defs.find((d) => d.role === role) ?? null;
 }
@@ -219,6 +224,89 @@ const ringDef = computed(() => {
   const def = pickByRole(cfg.value.nodeMetrics, 'ring');
   return def ? mergeThresholdOverride(def, 'topology') : null;
 });
+
+/**
+ * Legend rendering for the ring colour band.
+ *
+ * Earlier the legend hard-coded `0% / 0.1% / 1% / 5%+` — that's
+ * accurate for an error-rate ring (lower = healthier ⇒ left=green) but
+ * wildly wrong for SLA / Apdex / Success Rate, which are inverted
+ * (higher = healthier). On a layer with `service_sla/100` the
+ * renderer correctly inverts via `invertHealth`, but the legend was
+ * still claiming `0% / 0.1% / 1% / 5%+` next to a green→red ramp that
+ * actually pivots at 100% / 99.9% / 99% / 95%.
+ *
+ * `ringScaleLabels` derives the four break-point labels from the
+ * metric's own thresholds, transforming them back into user-space
+ * when the metric is inverted. The order stays green→red (left→right)
+ * so the ramp visual reads the same; only the numbers change.
+ *
+ * `ringDirectionHint` adds a small "higher = better" / "lower = better"
+ * tag so the operator can sanity-check the colour mapping at a glance.
+ */
+const ringScaleLabels = computed<string[]>(() => {
+  const def = ringDef.value;
+  if (!def) return [];
+  const t = def.thresholds ?? { ok: 0.1, warn: 1, danger: 5 };
+  // When thresholds don't carry an explicit `invertHealth`, fall back
+  // to the legacy id/label heuristic that `ringColor()` already uses
+  // for SLA / Apdex / Success-Rate metrics. Without this, an
+  // unmigrated `sla` template would render a green-at-100% ramp but
+  // print `0% / 0.1% / 1% / 5%+` labels next to it.
+  const heuristicInvert =
+    /sla|success|apdex/i.test(def.id) || /sla|apdex|success/i.test(def.label);
+  const invert = t.invertHealth === undefined ? heuristicInvert : Boolean(t.invertHealth);
+  const base = t.invertBase ?? 100;
+  const ok = t.ok ?? 0.1;
+  const warn = t.warn ?? 1;
+  const danger = t.danger ?? 5;
+  // Respect the metric's own unit verbatim — most ring metrics carry
+  // `%`, but Apdex is unitless on 0-1 and a custom layer may use a
+  // raw count. Defaulting to `%` here would print a misleading suffix.
+  const unit = def.unit ?? '';
+  // The colour ramp is rendered green → yellow → warn → red, four
+  // bands. The three breakpoints sit at the band edges. For a
+  // non-inverted metric (error-rate style), the breakpoints already
+  // describe value boundaries left→right (low→high).
+  //
+  // For an inverted metric (SLA-style), the colour ramp's left edge
+  // corresponds to value=base (healthiest), and each subsequent
+  // boundary is `base - threshold`. The numbers naturally come out
+  // descending, which is the user-facing reading direction.
+  const breaks = invert
+    ? [base, base - ok, base - warn, base - danger]
+    : [0, ok, warn, danger];
+  const fmt = (n: number): string => {
+    // Drop trailing zeros, but keep up to 2 decimal places for sub-1
+    // values. `99.9` and `100` both look cleaner than `99.90` / `100.00`.
+    const s = Number.isInteger(n) ? n.toString() : n.toFixed(2).replace(/\.?0+$/, '');
+    return `${s}${unit ?? ''}`;
+  };
+  const out = breaks.map(fmt);
+  // Decorate the END label with a trailing `+` / `-` so the reader
+  // knows that band is "values past this boundary":
+  //   - non-invert (left=low good): the LAST band continues to `+∞`,
+  //     so suffix with `+`.
+  //   - invert (left=high good):   the LAST band continues to `-∞`
+  //     (or 0), so suffix with `-`.
+  if (out.length > 0) {
+    out[out.length - 1] = out[out.length - 1] + (invert ? '-' : '+');
+  }
+  return out;
+});
+const ringDirectionHint = computed<string>(() => {
+  const def = ringDef.value;
+  if (!def) return '';
+  const t = def.thresholds;
+  if (t?.invertHealth) return 'higher = better';
+  // Without an explicit `invertHealth`, fall back to the legacy
+  // heuristic on the metric id/label so SLA-style metrics that
+  // haven't migrated to thresholds still read correctly.
+  if (/sla|success|apdex/i.test(def.id) || /sla|apdex|success/i.test(def.label)) {
+    return 'higher = better';
+  }
+  return 'lower = better';
+});
 const centerDef = computed(() => pickByRole(cfg.value.nodeMetrics, 'center'));
 const secondaryDef = computed(() => pickByRole(cfg.value.nodeMetrics, 'secondary'));
 // (lineServerDef / lineClientDef were only used by the dropped
@@ -395,20 +483,20 @@ function computeBoosterLevels(
   }
   return merged;
 }
-// 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; }
+// Sentinel encoding for the cluster key — `null` (unclustered) gets
+// pinned to a stable string so it can serve as a Map key alongside
+// real cluster values. Decoded back on the read side.
+const UNCLUSTERED = '__ungrouped__';
+function ckeyEnc(k: string | null): string { return k ?? UNCLUSTERED; }
+function ckeyDec(s: string): string | null { return s === UNCLUSTERED ? 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.
+ * One topology-cluster bucket. 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-cluster BFS over cross-cluster calls.
  */
-interface GroupBucket {
+interface ClusterBucket {
   key: string | null;
   alias: string | null;
   nodes: LayoutNode[];        // intra-bucket BFS-ordered nodes with `layerIdx`
@@ -425,24 +513,24 @@ 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.
+ * Two-level layout: bucket by the layer-resolved topology cluster,
+ * BFS each bucket internally, then BFS the inter-cluster call graph
+ * to decide bucket order along the X axis. Returns the buckets in
+ * render order with each bucket's anchor 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.
+ * Unclustered nodes (layer has no topology-cluster rule, or the rule
+ * didn't match — synthetic User, external endpoints, …) collapse
+ * into a single null-key bucket that renders WITHOUT a bounding box,
+ * preserving the look of layers that haven't opted in to clustering.
  */
-const groupBuckets = computed<GroupBucket[]>(() => {
+const clusterBuckets = computed<ClusterBucket[]>(() => {
   const all = nodes.value;
   if (all.length === 0) return [];
   // 1. Bucket nodes by resolved group key.
   const byGroup = new Map<string, TopologyNode[]>();
   for (const n of all) {
     const id = identity(n.name);
-    const k = gkeyEnc(id.group);
+    const k = ckeyEnc(id.cluster);
     if (!byGroup.has(k)) byGroup.set(k, []);
     byGroup.get(k)!.push(n);
   }
@@ -450,12 +538,12 @@ const groupBuckets = computed<GroupBucket[]>(() => {
   //    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 clusterOfId = new Map<string, string>();
+  for (const n of all) clusterOfId.set(n.id, ckeyEnc(identity(n.name).cluster));
   const interGroupCalls: TopologyCall[] = [];
   for (const c of calls.value) {
-    const s = groupOfId.get(c.source);
-    const t = groupOfId.get(c.target);
+    const s = clusterOfId.get(c.source);
+    const t = clusterOfId.get(c.target);
     if (s === undefined || t === undefined) continue;
     if (s === t) continue;
     interGroupCalls.push({
@@ -471,7 +559,7 @@ const groupBuckets = computed<GroupBucket[]>(() => {
   }
   const groupKeys = [...byGroup.keys()];
   const metaNodes: TopologyNode[] = groupKeys.map((k) => ({
-    id: k, name: k === UNGROUPED ? '' : k,
+    id: k, name: k === UNCLUSTERED ? '' : k,
     type: null, isReal: true, layers: [],
     metrics: {}, cpm: null, respTime: null, sla: null,
   }));
@@ -483,7 +571,7 @@ const groupBuckets = computed<GroupBucket[]>(() => {
   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[] = [];
+  const buckets: ClusterBucket[] = [];
   for (const k of ordered) {
     const groupNodes = byGroup.get(k) ?? [];
     if (groupNodes.length === 0) continue;
@@ -506,8 +594,12 @@ const groupBuckets = computed<GroupBucket[]>(() => {
     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),
+      key: ckeyDec(k),
+      // Cluster boxes / alias chips only render when the layer carries
+      // an explicit topology-cluster rule. Layers without one have all
+      // nodes in the null-key bucket and never reach this branch with
+      // a non-null key.
+      alias: namingRule.value?.alias ?? null,
       nodes: lay,
       cols,
       maxRowsPerCol,
@@ -523,7 +615,10 @@ const groupBuckets = computed<GroupBucket[]>(() => {
     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 };
+    // y=110 gives ~40px headroom above the cluster boxes so the
+    // floating alias·value chip (which extends ~30px above each
+    // cluster's top edge) has room without clipping at the SVG top.
+    b.rect = { x: cursorX, y: 110, w, h };
     cursorX += w + GROUP_GAP_X;
   }
   return buckets;
@@ -531,10 +626,10 @@ const groupBuckets = computed<GroupBucket[]>(() => {
 
 // `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`.
+// per-bucket geometry is read off `clusterBuckets`.
 const layoutNodes = computed<LayoutNode[]>(() => {
   const out: LayoutNode[] = [];
-  for (const b of groupBuckets.value) for (const n of b.nodes) out.push(n);
+  for (const b of clusterBuckets.value) for (const n of b.nodes) out.push(n);
   return out;
 });
 
@@ -557,16 +652,27 @@ const COL_GAP = 220;
 // reach a clearly distinct row instead of crowding the spine.
 const ROW_GAP = Math.round((NODE_R * 2 + 90) * 1.2);
 const W = computed(() => {
-  const b = groupBuckets.value;
+  const b = clusterBuckets.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 anchorRight = last.rect.x + last.rect.w + 40;
+  // Cluster rects can extend beyond their BFS anchor once nodes are
+  // dragged; widen the canvas to accommodate the live boxes.
+  const clusterRight = clusterRects.value.reduce(
+    (m, c) => Math.max(m, c.rect.x + c.rect.w + 40),
+    0,
+  );
+  return Math.max(820, anchorRight, clusterRight);
 });
 const H = computed(() => {
-  const b = groupBuckets.value;
+  const b = clusterBuckets.value;
   if (b.length === 0) return 360;
-  const tallest = Math.max(...b.map((x) => x.rect.y + x.rect.h));
-  return tallest + 60;
+  const anchorBottom = Math.max(...b.map((x) => x.rect.y + x.rect.h));
+  const clusterBottom = clusterRects.value.reduce(
+    (m, c) => Math.max(m, c.rect.y + c.rect.h),
+    0,
+  );
+  return Math.max(anchorBottom, clusterBottom) + 60;
 });
 
 /**
@@ -605,7 +711,7 @@ watch(
 
 const nodePos = computed<Map<string, Pos>>(() => {
   const map = new Map<string, Pos>();
-  for (const b of groupBuckets.value) {
+  for (const b of clusterBuckets.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
@@ -635,6 +741,61 @@ const visibleCalls = computed<TopologyCall[]>(() => {
   return calls.value.filter((c) => ids.has(c.source) && ids.has(c.target));
 });
 
+/**
+ * Live cluster bounding rects — derived from the actual `nodePos` map
+ * (which already folds in drag overrides) rather than the BFS-anchor
+ * `bucket.rect`. As soon as a node is dragged, the cluster box grows
+ * or shrinks to keep it enclosed. Only buckets with a non-null key
+ * render — layers without a topology-cluster rule never enter here.
+ */
+interface ClusterRect {
+  key: string;
+  alias: string | null;
+  rect: { x: number; y: number; w: number; h: number };
+}
+const CLUSTER_MARGIN = 36; // breathing room around the outermost node centres
+const CLUSTER_HEAD_HEIGHT = 32; // space reserved above the topmost node for the chip
+const clusterRects = computed<ClusterRect[]>(() => {
+  const out: ClusterRect[] = [];
+  for (const b of clusterBuckets.value) {
+    if (!b.key) continue;
+    let minX = Number.POSITIVE_INFINITY;
+    let minY = Number.POSITIVE_INFINITY;
+    let maxX = Number.NEGATIVE_INFINITY;
+    let maxY = Number.NEGATIVE_INFINITY;
+    let count = 0;
+    for (const n of b.nodes) {
+      const p = nodePos.value.get(n.id);
+      if (!p) continue;
+      count++;
+      if (p.cx < minX) minX = p.cx;
+      if (p.cy < minY) minY = p.cy;
+      if (p.cx > maxX) maxX = p.cx;
+      if (p.cy > maxY) maxY = p.cy;
+    }
+    if (count === 0) continue;
+    // Each "centre" needs room for the node circle (NODE_R) plus the
+    // label text rendered beneath the node (~26px). Inflate by both.
+    const padTop = NODE_R + CLUSTER_HEAD_HEIGHT;
+    const padBot = NODE_R + 32; // label + RPM
+    const padSide = NODE_R + 18;
+    const x = minX - padSide - CLUSTER_MARGIN;
+    // Floor the cluster top so the floating chip (~40px above the top
+    // border) never clips against the SVG's y=0 edge even when the
+    // operator drags a node to the very top of the canvas. When we
+    // clamp the top up, shrink the height by the same amount so the
+    // bottom edge stays anchored to maxY + padding.
+    const CHIP_HEADROOM = 44;
+    const rawY = minY - padTop - CLUSTER_MARGIN;
+    const y = Math.max(CHIP_HEADROOM, rawY);
+    const w = (maxX - minX) + (padSide + CLUSTER_MARGIN) * 2;
+    const naturalH = (maxY - minY) + padTop + padBot + CLUSTER_MARGIN * 2;
+    const h = naturalH - (y - rawY);
+    out.push({ key: b.key, alias: b.alias, rect: { x, y, w, h } });
+  }
+  return out;
+});
+
 /**
  * Resolve the 4-band colour for a node from the ring-metric value.
  * Operator-configured thresholds (in the metric def's `thresholds`
@@ -808,6 +969,58 @@ const selectedCallTarget = computed<LayoutNode | null>(() => {
   return layoutNodes.value.find((n) => n.id === c.target) ?? null;
 });
 
+/**
+ * Visual focus sets. When a node OR an edge is selected, we keep the
+ * "related" subgraph at full opacity and dim everything else, so the
+ * operator's eye lands on the conversation they're inspecting:
+ *
+ *   - selected NODE      ⇒ keep selected + its upstream + downstream +
+ *                          the edges joining them.
+ *   - selected EDGE      ⇒ keep edge + both endpoints.
+ *   - BOTH selected      ⇒ union of the two sets (drag-select effect).
+ *   - nothing selected   ⇒ everything renders at full opacity (sets are
+ *                          empty; `hasSelection` is false; the dim
+ *                          branch never fires).
+ */
+const hasSelection = computed<boolean>(
+  () => Boolean(selectedNode.value) || Boolean(selectedCall.value),
+);
+const highlightedNodeIds = computed<Set<string>>(() => {
+  const out = new Set<string>();
+  const selN = selectedNode.value;
+  if (selN) {
+    out.add(selN.id);
+    for (const c of calls.value) {
+      if (c.source === selN.id) out.add(c.target);
+      if (c.target === selN.id) out.add(c.source);
+    }
+  }
+  const selC = selectedCall.value;
+  if (selC) {
+    out.add(selC.source);
+    out.add(selC.target);
+  }
+  return out;
+});
+const highlightedCallIds = computed<Set<string>>(() => {
+  const out = new Set<string>();
+  const selN = selectedNode.value;
+  if (selN) {
+    for (const c of calls.value) {
+      if (c.source === selN.id || c.target === selN.id) out.add(c.id);
+    }
+  }
+  const selC = selectedCall.value;
+  if (selC) out.add(selC.id);
+  return out;
+});
+function isNodeFocused(id: string): boolean {
+  return !hasSelection.value || highlightedNodeIds.value.has(id);
+}
+function isCallFocused(id: string): boolean {
+  return !hasSelection.value || highlightedCallIds.value.has(id);
+}
+
 /**
  * Build a row per metric for the edge-detail sidebar. We pair up the
  * server + client metric defs by `id`, so a metric defined on only
@@ -874,19 +1087,61 @@ function edgeRowValues(c: TopologyCall, row: EdgeRow): {
   if (hasServerV) return { kind: 'server-only', clientV, serverV };
   return { kind: 'none', clientV, serverV };
 }
+// Convention used across the detail panel: the call CHAIN is read
+// from selected service outward.
+//   - `upstream`   = services the selected service depends on (calls
+//                    flow OUTBOUND: sel → X). They sit "earlier" in
+//                    the chain that the selected service is part of.
+//   - `downstream` = services that call the selected service (calls
+//                    flow INBOUND: X → sel). They sit "later" in the
+//                    chain (their request reaches sel as a hop).
+// This matches the user's mental model: "something calling current
+// service ⇒ downstream".
 const upstream = computed<LayoutNode[]>(() => {
   const sel = selectedNode.value;
   if (!sel) return [];
-  const ids = new Set(calls.value.filter((c) => c.target === sel.id).map((c) => c.source));
+  const ids = new Set(calls.value.filter((c) => c.source === sel.id).map((c) => c.target));
   return layoutNodes.value.filter((n) => ids.has(n.id));
 });
 const downstream = computed<LayoutNode[]>(() => {
   const sel = selectedNode.value;
   if (!sel) return [];
-  const ids = new Set(calls.value.filter((c) => c.source === sel.id).map((c) => c.target));
+  const ids = new Set(calls.value.filter((c) => c.target === sel.id).map((c) => c.source));
   return layoutNodes.value.filter((n) => ids.has(n.id));
 });
 
+/**
+ * Sub-bucket a related-services list (upstream / downstream) by the
+ * identity group of each row. When the layer has a topology-cluster
+ * rule, the detail-panel reader can scan namespace-by-namespace; when
+ * the rule isn't configured (or no row has a group) everything
+ * collapses to a single null-key bucket and renders unchanged.
+ *
+ * The output preserves the natural BFS order within each bucket and
+ * orders buckets by first appearance.
+ */
+interface RelatedGroup {
+  key: string | null;
+  alias: string | null;
+  rows: LayoutNode[];
+}
+function bucketRelated(list: LayoutNode[]): RelatedGroup[] {
+  const map = new Map<string, RelatedGroup>();
+  const order: string[] = [];
+  for (const n of list) {
+    const id = identity(n.name);
+    const k = id.cluster ?? '';
+    if (!map.has(k)) {
+      map.set(k, { key: id.cluster, alias: id.clusterAlias, rows: [] });
+      order.push(k);
+    }
+    map.get(k)!.rows.push(n);
+  }
+  return order.map((k) => map.get(k)!);
+}
+const upstreamByNs = computed<RelatedGroup[]>(() => bucketRelated(upstream.value));
+const downstreamByNs = computed<RelatedGroup[]>(() => bucketRelated(downstream.value));
+
 /**
  * Resolve the layer key we should jump into for the selected node.
  * A service may belong to multiple OAP layers (e.g. a Java service
@@ -1197,18 +1452,20 @@ 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)" />
 
-            <!-- 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})`">
+            <!-- Topology cluster bounding boxes — one rounded-rect per
+                 cluster value (e.g. k8s namespace) with a dashed
+                 border. Rects are derived live from current node
+                 positions so dragging a node grows / shrinks the box
+                 to keep it enclosed. The alias·value chip floats
+                 ABOVE the box top so the rounded border reads
+                 unbroken. Layers without a topology-cluster rule
+                 produce zero entries here ⇒ no box renders. -->
+            <g class="sm-cluster-layer">
+              <template v-for="c in clusterRects" :key="c.key">
+                <g :transform="`translate(${c.rect.x}, ${c.rect.y})`">
                   <rect
-                    :width="b.rect.w"
-                    :height="b.rect.h"
+                    :width="c.rect.w"
+                    :height="c.rect.h"
                     rx="14"
                     ry="14"
                     fill="var(--sw-bg-1)"
@@ -1217,36 +1474,40 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
                     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)">
+                  <!-- Floating chip: sits with its baseline ~18px
+                       above the box's top edge so neither the border
+                       nor the chip overlap the cluster contents. The
+                       cluster *value* is rendered noticeably larger
+                       than the alias label — the name is the signal,
+                       the alias is just a qualifier. -->
+                  <g transform="translate(20, -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"
+                      y="-19"
+                      :width="14 + (c.alias ?? '').length * 7.5 + 12 + (c.key ?? '').length * 11 + 18"
+                      height="32"
+                      rx="16"
+                      ry="16"
                       fill="var(--sw-bg-0)"
                       stroke="var(--sw-accent-line)"
-                      stroke-width="1"
+                      stroke-width="1.2"
                     />
                     <text
-                      x="10"
-                      y="2"
+                      x="14"
+                      y="4"
                       fill="var(--sw-fg-3)"
-                      font-size="10"
+                      font-size="12"
                       font-family="var(--sw-mono)"
                       font-weight="500"
-                    >{{ b.alias }} ·</text>
+                    >{{ c.alias }} ·</text>
                     <text
-                      :x="10 + (b.alias?.length ?? 0) * 6 + 12"
-                      y="2"
+                      :x="14 + (c.alias ?? '').length * 7.5 + 12"
+                      y="5"
                       fill="var(--sw-accent-2)"
-                      font-size="11"
+                      font-size="18"
                       font-family="var(--sw-mono)"
                       font-weight="700"
-                    >{{ b.key }}</text>
+                    >{{ c.key }}</text>
                   </g>
                 </g>
               </template>
@@ -1256,6 +1517,7 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
               v-for="c in visibleCalls"
               :key="c.id"
               class="sm-edge"
+              :class="{ 'sm-dim': !isCallFocused(c.id) }"
               @click.stop="selectCall(c.id)"
             >
               <!-- Invisible wide hit-path: gives the operator a
@@ -1366,6 +1628,7 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
               :data-node-id="n.id"
               :transform="`translate(${nodePos.get(n.id)!.cx}, ${nodePos.get(n.id)!.cy})`"
               class="sm-node"
+              :class="{ 'sm-dim': !isNodeFocused(n.id) }"
               style="cursor: grab"
               @click.stop="selectNode(n.id)"
             >
@@ -1532,7 +1795,10 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
         </div>
 
         <div class="legend">
-          <div v-if="ringDef" class="lg-label">{{ ringDef.label }}</div>
+          <div v-if="ringDef" class="lg-label">
+            {{ ringDef.label }}
+            <span class="lg-direction">{{ ringDirectionHint }}</span>
+          </div>
           <div v-if="ringDef" class="lg-ramp">
             <span style="background: var(--sw-ok)" />
             <span style="background: #fbbf24" />
@@ -1540,7 +1806,7 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
             <span style="background: var(--sw-err)" />
           </div>
           <div v-if="ringDef" class="lg-scale">
-            <span>0%</span><span>0.1%</span><span>1%</span><span>5%+</span>
+            <span v-for="(lbl, i) in ringScaleLabels" :key="i">{{ lbl }}</span>
           </div>
           <div class="lg-rule" />
           <div class="lg-row">
@@ -1564,9 +1830,13 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
           <div class="sp-id">
             <div class="sp-mono">{{ identity(selectedNode.name).display }}</div>
             <div class="sp-tags">
-              <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 v-if="identity(selectedNode.name).cluster" class="sw-tag accent">
+                <span class="tag-alias">{{ identity(selectedNode.name).clusterAlias }}</span>
+                <span class="tag-val">{{ identity(selectedNode.name).cluster }}</span>
+              </span>
+              <span v-if="showLegacyGroup && identity(selectedNode.name).legacyGroup" class="sw-tag">
+                <span class="tag-alias">group</span>
+                <span class="tag-val">{{ identity(selectedNode.name).legacyGroup }}</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>
@@ -1583,33 +1853,39 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
           </div>
         </div>
         <div class="sp-section">
-          <div class="sp-section-title">Upstream callers ({{ upstream.length }})</div>
+          <div class="sp-section-title">Upstream — services this one calls ({{ upstream.length }})</div>
           <ul class="sp-list">
-            <li v-for="u in upstream" :key="u.id">
-              <span class="sp-pulse" :style="{ color: ringColor(u) }">●</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>
+            <template v-for="(g, gi) in upstreamByNs" :key="gi">
+              <li v-if="g.key" class="sp-ns-head">
+                <span class="sp-ns-alias">{{ g.alias }}</span>
+                <span class="sp-ns-val">{{ g.key }}</span>
+                <span class="sp-ns-count">{{ g.rows.length }}</span>
+              </li>
+              <li v-for="u in g.rows" :key="u.id">
+                <span class="sp-pulse" :style="{ color: ringColor(u) }">●</span>
+                <span class="sp-mono small">{{ identity(u.name).display }}</span>
+                <span class="sp-cpm">{{ fmtWithUnit(nodeVal(u, centerDef), centerDef?.unit) }}</span>
+              </li>
+            </template>
+            <li v-if="upstream.length === 0" class="sp-empty">no outbound calls in window</li>
           </ul>
         </div>
         <div class="sp-section">
-          <div class="sp-section-title">Downstream deps ({{ downstream.length }})</div>
+          <div class="sp-section-title">Downstream — services calling this one ({{ downstream.length }})</div>
           <ul class="sp-list">
-            <li v-for="d in downstream" :key="d.id">
-              <span class="sp-pulse" :style="{ color: ringColor(d) }">●</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>
+            <template v-for="(g, gi) in downstreamByNs" :key="gi">
+              <li v-if="g.key" class="sp-ns-head">
+                <span class="sp-ns-alias">{{ g.alias }}</span>
+                <span class="sp-ns-val">{{ g.key }}</span>
+                <span class="sp-ns-count">{{ g.rows.length }}</span>
+              </li>
+              <li v-for="d in g.rows" :key="d.id">
+                <span class="sp-pulse" :style="{ color: ringColor(d) }">●</span>
+                <span class="sp-mono small">{{ identity(d.name).display }}</span>
+                <span class="sp-cpm">{{ fmtWithUnit(nodeVal(d, secondaryDef), secondaryDef?.unit) }}</span>
+              </li>
+            </template>
+            <li v-if="downstream.length === 0" class="sp-empty">no inbound callers in window</li>
           </ul>
         </div>
         <!-- Node-only dashboard jumps. Edges deliberately have no
@@ -1634,17 +1910,25 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
           <div class="sp-id">
             <div class="sp-edge-row">
               <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 v-if="identity(selectedCallSource.name).cluster" class="sw-tag accent tiny">
+                  <span class="tag-alias">{{ identity(selectedCallSource.name).clusterAlias }}</span>
+                  <span class="tag-val">{{ identity(selectedCallSource.name).cluster }}</span>
+                </span>
+                <span v-if="showLegacyGroup && identity(selectedCallSource.name).legacyGroup" class="sw-tag tiny">
+                  <span class="tag-alias">group</span>
+                  <span class="tag-val">{{ identity(selectedCallSource.name).legacyGroup }}</span>
                 </span>
                 <span class="sp-mono small">{{ identity(selectedCallSource.name).display }}</span>
               </span>
               <span class="sp-edge-arrow">→</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 v-if="identity(selectedCallTarget.name).cluster" class="sw-tag accent tiny">
+                  <span class="tag-alias">{{ identity(selectedCallTarget.name).clusterAlias }}</span>
+                  <span class="tag-val">{{ identity(selectedCallTarget.name).cluster }}</span>
+                </span>
+                <span v-if="showLegacyGroup && identity(selectedCallTarget.name).legacyGroup" class="sw-tag tiny">
+                  <span class="tag-alias">group</span>
+                  <span class="tag-val">{{ identity(selectedCallTarget.name).legacyGroup }}</span>
                 </span>
                 <span class="sp-mono small">{{ identity(selectedCallTarget.name).display }}</span>
               </span>
@@ -2054,6 +2338,17 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
   filter: drop-shadow(0 0 4px rgba(249, 115, 22, 0.45));
 }
 .sm-edge { cursor: pointer; }
+/* Selection focus: when ANY node or edge is selected, every node /
+   edge that's NOT part of the related subgraph (selected node + its
+   upstream + downstream + their joining edges; or selected edge +
+   both endpoints) gets the `.sm-dim` class. The dimmed treatment
+   keeps the canvas readable as context without competing for the
+   eye. Opacity is animated so quick selection cycling reads as a
+   smooth focus pull, not a flash. */
+.sm-node, .sm-edge { transition: opacity 0.15s ease; }
+.sm-node.sm-dim { opacity: 0.22; }
+.sm-edge.sm-dim { opacity: 0.12; }
+.sm-node.sm-dim:hover, .sm-edge.sm-dim:hover { opacity: 0.55; }
 .sm-edge:hover path:nth-of-type(2) {
   stroke: var(--sw-accent-2) !important;
   opacity: 1 !important;
@@ -2226,6 +2521,17 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
   letter-spacing: 0.08em;
   color: var(--sw-fg-3);
   margin-bottom: 4px;
+  display: flex;
+  align-items: baseline;
+  gap: 6px;
+}
+.lg-direction {
+  font-size: 9px;
+  letter-spacing: 0.04em;
+  text-transform: none;
+  color: var(--sw-fg-3);
+  font-style: italic;
+  opacity: 0.85;
 }
 .lg-ramp {
   display: flex;
@@ -2366,6 +2672,39 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
   font-size: 11px;
 }
 .sp-list li:last-child { border-bottom: none; }
+/* Sub-header for related-service lists when grouped by cluster (k8s
+   namespace / mesh namespace). Shows the alias·value chip + the row
+   count for that bucket and visually separates the rows beneath. */
+.sp-ns-head {
+  font-size: 9.5px;
+  letter-spacing: 0.04em;
+  text-transform: uppercase;
+  color: var(--sw-fg-3);
+  background: var(--sw-bg-2);
+  padding: 4px 8px !important;
+  margin-top: 4px;
+  border-radius: 4px;
+  border-bottom: 1px solid var(--sw-line) !important;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+.sp-ns-head:first-child { margin-top: 0; }
+.sp-ns-alias { color: var(--sw-fg-3); }
+.sp-ns-alias::after { content: '·'; margin-left: 4px; }
+.sp-ns-val {
+  color: var(--sw-accent-2);
+  font-family: var(--sw-mono);
+  text-transform: none;
+  letter-spacing: 0;
+  font-weight: 700;
+}
+.sp-ns-count {
+  margin-left: auto;
+  color: var(--sw-fg-3);
+  font-family: var(--sw-mono);
+  font-weight: 500;
+}
 .sp-mono.small { font-family: var(--sw-mono); font-size: 11px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--sw-fg-1); }
 .sp-pulse { font-size: 8px; }
 .sp-cpm { font-family: var(--sw-mono); font-size: 10.5px; color: var(--sw-fg-3); }
diff --git a/apps/ui/src/views/layer/LayerServiceSelector.vue b/apps/ui/src/views/layer/LayerServiceSelector.vue
index b8ad39d..9f5d2dc 100644
--- a/apps/ui/src/views/layer/LayerServiceSelector.vue
+++ b/apps/ui/src/views/layer/LayerServiceSelector.vue
@@ -38,9 +38,11 @@ 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. */
+    /** Per-layer topology-cluster rule. When supplied, rows render a
+     *  cluster chip; when null, no chip. The OAP `<group>::` legacy
+     *  prefix is always stripped from the displayed label in this
+     *  selector — it's the global service picker, not the topology
+     *  view, so per-topology `showGroup` doesn't apply here. */
     namingRule?: ServiceNamingRule | null;
   }>(),
   {
@@ -111,9 +113,9 @@ 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="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 v-if="identity(row.serviceName).cluster" class="group-chip">
+              <span class="chip-alias">{{ identity(row.serviceName).clusterAlias }}</span>
+              <span class="chip-val">{{ identity(row.serviceName).cluster }}</span>
             </span>
             <span class="name-text">{{ row.shortName || identity(row.serviceName).display }}</span>
           </td>
diff --git a/apps/ui/src/views/layer/LayerShell.vue b/apps/ui/src/views/layer/LayerShell.vue
index 157b436..2b926c1 100644
--- a/apps/ui/src/views/layer/LayerShell.vue
+++ b/apps/ui/src/views/layer/LayerShell.vue
@@ -77,6 +77,8 @@ const SCOPE_CAP_PREDICATE: Record<string, (L: LayerDef) => boolean> = {
   'trace-profiling': (L) => Boolean(L.caps?.traceProfiling),
   'ebpf-profiling': (L) => Boolean(L.caps?.ebpfProfiling),
   'async-profiling': (L) => Boolean(L.caps?.asyncProfiling),
+  'network-profiling': (L) => Boolean(L.caps?.networkProfiling),
+  pprof: (L) => Boolean(L.caps?.pprofProfiling),
 };
 watch(
   [() => route.path, layer],
diff --git a/apps/ui/src/views/layer/LayerTraceProfilingView.vue b/apps/ui/src/views/layer/LayerTraceProfilingView.vue
new file mode 100644
index 0000000..47d6804
--- /dev/null
+++ b/apps/ui/src/views/layer/LayerTraceProfilingView.vue
@@ -0,0 +1,1036 @@
+<!--
+  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.
+-->
+<!--
+  Per-layer Trace Profiling tab — feature parity with the booster-ui
+  `dashboard/related/profile` widget. Layout:
+
+    ┌───────────────┬──────────────────────────────────────────────┐
+    │  Task list    │   Trace-ID + display-mode + analyze toolbar  │
+    │  (top)        │   Span table  (top half of the right pane)   │
+    │  ▸ New Task ┼─                                            ─│
+    │  Segments     │   Tree / Flame visualization (bottom half)   │
+    │  (bottom)     │                                              │
+    └───────────────┴──────────────────────────────────────────────┘
+
+  The widget binds to the URL-selected service. Operators kick off a
+  new profile task with the ▸ New Task chip; once OAP fans the task
+  out to instances and sampled segments come back, the operator clicks
+  a segment to load its spans, picks a profiled span, hits Analyze,
+  and inspects the resulting call tree as either an indented stack
+  table or a flame graph.
+-->
+<script setup lang="ts">
+import { computed, reactive, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+import { useLayers } from '@/composables/useLayers';
+import { useLayerEndpoints } from '@/composables/useLayerEndpoints';
+import { useSelectedService } from '@/composables/useSelectedService';
+import { useLayerLanding } from '@/composables/useLayerLanding';
+import { useSetupStore } from '@/stores/setup';
+import { bffClient } from '@/api/client';
+import type {
+  LayerDef,
+  ProfileAnalyzationTree,
+  ProfileAnalyzeQuery,
+  ProfileSegment,
+  ProfileSpan,
+  ProfileTask,
+  ProfileTaskLog,
+  ProfileTimeRange,
+} from '@/api/client';
+import ProfileStackTable from '@/components/profile/ProfileStackTable.vue';
+import ProfileFlameGraph from '@/components/profile/ProfileFlameGraph.vue';
+
+const route = useRoute();
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+const { layers } = useLayers();
+const layer = computed<LayerDef | null>(
+  () => layers.value.find((l) => l.key === layerKey.value) ?? null,
+);
+
+// Reuse the landing feed to resolve the URL `?service=` id back to a
+// human name for the topbar — same trick the Logs / Traces tabs use.
+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: {},
+    },
+);
+const store = useSetupStore();
+const safeCfg = computed(() => {
+  if (!layer.value)
+    return { priority: 99, topN: 5, orderBy: 'cpm', columns: [], style: 'table' as const };
+  return store.ensure(layer.value.key, {
+    slots: layer.value.slots,
+    caps: layer.value.caps,
+    metrics: layer.value.metrics,
+    overview: layer.value.overview,
+  }).landing;
+});
+const landing = useLayerLanding(safeLayer, safeCfg);
+const { selectedId } = useSelectedService();
+const serviceName = computed<string | null>(() => {
+  const rows = landing.data.value?.sampledRows ?? landing.rows.value ?? [];
+  return rows.find((r) => r.serviceId === selectedId.value)?.serviceName ?? null;
+});
+
+// ── Task list + segments ────────────────────────────────────────────
+const tasks = ref<ProfileTask[]>([]);
+const tasksError = ref<string | null>(null);
+const tasksLoading = ref(false);
+const currentTask = ref<ProfileTask | null>(null);
+
+const segments = ref<ProfileSegment[]>([]);
+const segmentsLoading = ref(false);
+const segmentsError = ref<string | null>(null);
+const currentSegment = ref<ProfileSegment | null>(null);
+
+const segmentSpans = computed<ProfileSpan[]>(() => currentSegment.value?.spans ?? []);
+const currentSpan = ref<ProfileSpan | null>(null);
+
+// Track the operator's open task-detail modal independently of the
+// "current selected task" — opening the eye icon mustn't swap the
+// segment list out.
+const taskDetailFor = ref<ProfileTask | null>(null);
+const taskDetailLogs = ref<ProfileTaskLog[]>([]);
+
+// ── Analyze result ──────────────────────────────────────────────────
+const analyzeTrees = ref<ProfileAnalyzationTree[]>([]);
+const analyzeLoading = ref(false);
+const analyzeMessage = ref('');
+const displayMode = ref<'tree' | 'flame'>('tree');
+const dataMode = ref<'include' | 'exclude'>('include');
+const highlightTop = ref(true);
+
+watch(
+  () => layerKey.value + '|' + (selectedId.value ?? ''),
+  () => {
+    void refreshTasks();
+  },
+  { immediate: true },
+);
+
+async function refreshTasks(): Promise<void> {
+  tasksError.value = null;
+  if (!selectedId.value) {
+    tasks.value = [];
+    currentTask.value = null;
+    segments.value = [];
+    currentSegment.value = null;
+    return;
+  }
+  tasksLoading.value = true;
+  try {
+    const resp = await bffClient.layerProfileTasks(layerKey.value, selectedId.value);
+    if (!resp.reachable && resp.error) tasksError.value = resp.error;
+    tasks.value = resp.tasks ?? [];
+    if (tasks.value.length) {
+      await pickTask(tasks.value[0]);
+    } else {
+      currentTask.value = null;
+      segments.value = [];
+      currentSegment.value = null;
+      analyzeTrees.value = [];
+    }
+  } catch (e) {
+    tasksError.value = e instanceof Error ? e.message : String(e);
+  } finally {
+    tasksLoading.value = false;
+  }
+}
+
+async function pickTask(t: ProfileTask): Promise<void> {
+  currentTask.value = t;
+  analyzeTrees.value = [];
+  segments.value = [];
+  currentSegment.value = null;
+  currentSpan.value = null;
+  segmentsLoading.value = true;
+  segmentsError.value = null;
+  try {
+    const resp = await bffClient.profileTaskSegments(t.id);
+    if (!resp.reachable && resp.error) segmentsError.value = resp.error;
+    segments.value = resp.segments ?? [];
+    if (segments.value.length) {
+      pickSegment(segments.value[0]);
+    }
+  } catch (e) {
+    segmentsError.value = e instanceof Error ? e.message : String(e);
+  } finally {
+    segmentsLoading.value = false;
+  }
+}
+
+function pickSegment(s: ProfileSegment): void {
+  currentSegment.value = s;
+  // Stamp the traceId onto each span so the span table can deep-link
+  // back to the trace explorer without re-fetching.
+  for (const sp of s.spans ?? []) sp.traceId = s.traceId;
+  // Default-select the deepest (=last) span so analyze is one click away.
+  currentSpan.value = s.spans?.length ? s.spans[s.spans.length - 1] : null;
+  analyzeTrees.value = [];
+}
+
+async function openTaskDetail(t: ProfileTask, ev: Event): Promise<void> {
+  ev.stopPropagation();
+  taskDetailFor.value = t;
+  taskDetailLogs.value = [];
+  try {
+    const resp = await bffClient.profileTaskLogs(t.id);
+    taskDetailLogs.value = resp.logs ?? [];
+  } catch {
+    taskDetailLogs.value = [];
+  }
+}
+
+// ── Analyze ─────────────────────────────────────────────────────────
+function timeRangesFor(span: ProfileSpan): ProfileTimeRange[] {
+  if (dataMode.value === 'include') {
+    return [{ start: span.startTime, end: span.endTime }];
+  }
+  const children = span.children ?? [];
+  if (!children.length) return [{ start: span.startTime, end: span.endTime }];
+  let ranges: ProfileTimeRange[] = [];
+  for (const c of children) {
+    ranges.push({ start: span.startTime, end: c.startTime });
+    ranges.push({ start: c.endTime, end: span.endTime });
+  }
+  ranges = ranges.reduce<ProfileTimeRange[]>((acc, cur) => {
+    let merged = false;
+    for (const r of acc) {
+      if (cur.start <= r.end && r.start <= cur.start) {
+        r.start = Math.max(r.start, cur.start);
+        r.end = Math.min(r.end, cur.end);
+        merged = true;
+      }
+    }
+    if (!merged) acc.push(cur);
+    return acc;
+  }, []);
+  return ranges.filter((r) => r.start !== r.end);
+}
+
+async function runAnalyze(): Promise<void> {
+  const span = currentSpan.value;
+  if (!span?.profiled) {
+    analyzeMessage.value = "It's a un-profiled span";
+    return;
+  }
+  analyzeMessage.value = '';
+  analyzeLoading.value = true;
+  try {
+    const queries: ProfileAnalyzeQuery[] = timeRangesFor(span).map((tr) => ({
+      segmentId: span.segmentId,
+      timeRange: tr,
+    }));
+    const resp = await bffClient.profileAnalyze(queries);
+    if (resp.tip) {
+      analyzeMessage.value = resp.tip;
+      analyzeTrees.value = [];
+    } else {
+      analyzeTrees.value = resp.trees ?? [];
+    }
+    if (!resp.reachable && resp.error) analyzeMessage.value = resp.error;
+  } catch (e) {
+    analyzeMessage.value = e instanceof Error ? e.message : String(e);
+  } finally {
+    analyzeLoading.value = false;
+  }
+}
+
+// ── New Task dialog ─────────────────────────────────────────────────
+const showNewTask = ref(false);
+const taskCreateError = ref<string | null>(null);
+const endpointKeyword = ref('');
+const endpointLimit = ref(20);
+const endpointPicks = useLayerEndpoints(layerKey, selectedId, endpointKeyword, endpointLimit);
+const newTask = reactive({
+  endpointName: '',
+  monitorTime: 'now' as 'now' | 'set',
+  monitorTimeAt: new Date(),
+  monitorDuration: 5,
+  minThreshold: 0,
+  dumpPeriod: 10,
+  maxSamplingCount: 5,
+});
+function resetNewTask(): void {
+  newTask.endpointName = '';
+  newTask.monitorTime = 'now';
+  newTask.monitorTimeAt = new Date();
+  newTask.monitorDuration = 5;
+  newTask.minThreshold = 0;
+  newTask.dumpPeriod = 10;
+  newTask.maxSamplingCount = 5;
+  taskCreateError.value = null;
+}
+async function submitNewTask(): Promise<void> {
+  if (!selectedId.value) {
+    taskCreateError.value = 'Pick a service first';
+    return;
+  }
+  taskCreateError.value = null;
+  try {
+    const startTime =
+      newTask.monitorTime === 'now' ? Date.now() : newTask.monitorTimeAt.getTime();
+    const resp = await bffClient.createProfileTask(layerKey.value, {
+      serviceId: selectedId.value,
+      endpointName: newTask.endpointName,
+      startTime,
+      duration: Number(newTask.monitorDuration),
+      minDurationThreshold: Number(newTask.minThreshold),
+      dumpPeriod: Number(newTask.dumpPeriod),
+      maxSamplingCount: Number(newTask.maxSamplingCount),
+    });
+    if (resp.errorReason) {
+      taskCreateError.value = resp.errorReason;
+      return;
+    }
+    if (!resp.reachable && resp.error) {
+      taskCreateError.value = resp.error;
+      return;
+    }
+    showNewTask.value = false;
+    resetNewTask();
+    await refreshTasks();
+  } catch (e) {
+    taskCreateError.value = e instanceof Error ? e.message : String(e);
+  }
+}
+
+function fmtTime(ms: number): string {
+  if (!ms) return '—';
+  const d = new Date(ms);
+  const z = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}:${z(d.getSeconds())}`;
+}
+function fmtDuration(span: ProfileSpan): string {
+  const ms = span.endTime - span.startTime;
+  return `${ms} ms`;
+}
+function spanComponent(s: ProfileSpan): string {
+  if (s.peer) return `${s.component} → ${s.peer}`;
+  return s.component;
+}
+</script>
+
+<template>
+  <div class="sw-card prof-shell">
+    <div class="prof-side">
+      <!-- Tasks -->
+      <div class="side-pane">
+        <div class="side-head">
+          <span>Profile tasks</span>
+          <button
+            type="button"
+            class="btn-new"
+            :disabled="!selectedId"
+            :title="selectedId ? 'Create a new profile task' : 'Pick a service first'"
+            @click="showNewTask = true"
+          >+ New Task</button>
+        </div>
+        <div v-if="tasksError" class="side-err">{{ tasksError }}</div>
+        <div v-else-if="tasksLoading && !tasks.length" class="side-empty">Loading…</div>
+        <div v-else-if="!tasks.length" class="side-empty">
+          {{ selectedId ? 'No tasks yet for this service.' : 'Pick a service to load profile tasks.' }}
+        </div>
+        <ul v-else class="side-list">
+          <li
+            v-for="t in tasks"
+            :key="t.id"
+            :class="{ 'is-active': currentTask?.id === t.id }"
+            @click="pickTask(t)"
+          >
+            <div class="row1">
+              <span class="ep" :title="t.endpointName">{{ t.endpointName || '(no endpoint)' }}</span>
+              <button
+                type="button"
+                class="row-eye"
+                title="View task detail"
+                @click.stop="openTaskDetail(t, $event)"
+              >i</button>
+            </div>
+            <div class="row2">
+              <span>{{ fmtTime(t.startTime) }}</span>
+              <span class="muted">→</span>
+              <span>{{ fmtTime(t.startTime + t.duration * 60_000) }}</span>
+            </div>
+          </li>
+        </ul>
+      </div>
+      <!-- Segments -->
+      <div class="side-pane">
+        <div class="side-head">
+          <span>Sampled traces</span>
+          <span v-if="currentTask" class="side-counter">{{ segments.length }}</span>
+        </div>
+        <div v-if="segmentsError" class="side-err">{{ segmentsError }}</div>
+        <div v-else-if="segmentsLoading && !segments.length" class="side-empty">Loading…</div>
+        <div v-else-if="!segments.length" class="side-empty">No sampled segments collected.</div>
+        <ul v-else class="side-list">
+          <li
+            v-for="s in segments"
+            :key="s.traceId"
+            :class="{ 'is-active': currentSegment?.traceId === s.traceId, err: s.isError }"
+            @click="pickSegment(s)"
+          >
+            <div class="row1">
+              <span class="ep" :title="s.endpointNames?.[0]">{{ s.endpointNames?.[0] ?? '—' }}</span>
+            </div>
+            <div class="row2">
+              <span class="dur-chip">{{ s.duration }} ms</span>
+              <span class="muted">{{ fmtTime(Number(s.start)) }}</span>
+            </div>
+          </li>
+        </ul>
+      </div>
+    </div>
+
+    <div class="prof-main">
+      <div class="main-toolbar">
+        <div class="tb-block">
+          <label class="lbl">Trace ID</label>
+          <input class="ti-input" readonly :value="currentSegment?.traceId ?? ''" />
+        </div>
+        <div class="tb-block">
+          <label class="lbl">Data mode</label>
+          <select v-model="dataMode" class="sel">
+            <option value="include">Include children</option>
+            <option value="exclude">Exclude children</option>
+          </select>
+        </div>
+        <div class="tb-block">
+          <label class="lbl">Display</label>
+          <div class="seg">
+            <button :class="{ on: displayMode === 'tree' }" @click="displayMode = 'tree'">Tree</button>
+            <button :class="{ on: displayMode === 'flame' }" @click="displayMode = 'flame'">Flame</button>
+          </div>
+        </div>
+        <button
+          class="btn-primary"
+          :disabled="!currentSpan?.profiled || analyzeLoading"
+          :title="currentSpan?.profiled ? 'Analyze selected span' : 'Select a profiled span to analyze'"
+          @click="runAnalyze"
+        >{{ analyzeLoading ? 'Analyzing…' : 'Analyze' }}</button>
+      </div>
+
+      <!-- Span table -->
+      <div class="span-table">
+        <div class="st-head">
+          <div class="c c-name">Span</div>
+          <div class="c c-svc">Service / Instance</div>
+          <div class="c c-comp">Component</div>
+          <div class="c c-dur">Duration</div>
+          <div class="c c-prof">Profiled</div>
+        </div>
+        <div v-if="!segmentSpans.length" class="st-empty">
+          {{ currentTask ? 'No spans in selected segment.' : 'Select a task to load spans.' }}
+        </div>
+        <div
+          v-for="(sp, i) in segmentSpans"
+          :key="i"
+          class="st-row"
+          :class="{ 'is-active': currentSpan === sp, err: sp.isError, profiled: sp.profiled }"
+          @click="currentSpan = sp"
+        >
+          <div class="c c-name" :title="sp.endpointName">
+            <span class="t-tag">{{ sp.type }}</span>
+            <span class="t-name">{{ sp.endpointName }}</span>
+          </div>
+          <div class="c c-svc">
+            <div class="cs1">{{ sp.serviceCode }}</div>
+            <div class="cs2">{{ sp.serviceInstanceName }}</div>
+          </div>
+          <div class="c c-comp">{{ spanComponent(sp) }}</div>
+          <div class="c c-dur">{{ fmtDuration(sp) }}</div>
+          <div class="c c-prof">
+            <span v-if="sp.profiled" class="b-yes">yes</span>
+            <span v-else class="b-no">no</span>
+          </div>
+        </div>
+      </div>
+
+      <!-- Result panel -->
+      <div class="result">
+        <div v-if="analyzeMessage" class="result-tip">{{ analyzeMessage }}</div>
+        <ProfileStackTable
+          v-if="displayMode === 'tree'"
+          :trees="analyzeTrees"
+          :highlight-top="highlightTop"
+          @toggle-highlight="highlightTop = !highlightTop"
+        />
+        <ProfileFlameGraph v-else :trees="analyzeTrees" metric-key="count" />
+      </div>
+    </div>
+  </div>
+
+  <!-- New task modal -->
+  <div v-if="showNewTask" class="dlg-mask" @click.self="showNewTask = false">
+    <div class="dlg">
+      <div class="dlg-head">
+        <div>
+          New profile task
+          <span v-if="serviceName" class="muted">on {{ serviceName }}</span>
+        </div>
+        <button class="x" @click="showNewTask = false">×</button>
+      </div>
+      <div class="dlg-body">
+        <div class="field">
+          <label>Endpoint name</label>
+          <input
+            v-model="endpointKeyword"
+            placeholder="Type to search…"
+            class="ti-input wide"
+          />
+          <select v-model="newTask.endpointName" class="sel wide">
+            <option value="">(any)</option>
+            <option v-for="e in endpointPicks.endpoints.value" :key="e.id" :value="e.name">
+              {{ e.name }}
+            </option>
+          </select>
+        </div>
+        <div class="field-row">
+          <div class="field">
+            <label>Start when</label>
+            <div class="seg">
+              <button :class="{ on: newTask.monitorTime === 'now' }" @click="newTask.monitorTime = 'now'">now</button>
+              <button :class="{ on: newTask.monitorTime === 'set' }" @click="newTask.monitorTime = 'set'">set time</button>
+            </div>
+            <input
+              v-if="newTask.monitorTime === 'set'"
+              type="datetime-local"
+              class="ti-input"
+              :value="new Date(newTask.monitorTimeAt.getTime() - newTask.monitorTimeAt.getTimezoneOffset() * 60_000).toISOString().slice(0, 16)"
+              @input="(ev: Event) => (newTask.monitorTimeAt = new Date((ev.target as HTMLInputElement).value))"
+            />
+          </div>
+          <div class="field">
+            <label>Duration (min)</label>
+            <select v-model.number="newTask.monitorDuration" class="sel">
+              <option :value="5">5</option>
+              <option :value="10">10</option>
+              <option :value="15">15</option>
+            </select>
+          </div>
+        </div>
+        <div class="field-row">
+          <div class="field">
+            <label>Min threshold (ms)</label>
+            <input type="number" min="0" v-model.number="newTask.minThreshold" class="ti-input" />
+          </div>
+          <div class="field">
+            <label>Dump period (ms)</label>
+            <select v-model.number="newTask.dumpPeriod" class="sel">
+              <option :value="10">10</option>
+              <option :value="20">20</option>
+              <option :value="50">50</option>
+              <option :value="100">100</option>
+            </select>
+          </div>
+          <div class="field">
+            <label>Max sampling count</label>
+            <select v-model.number="newTask.maxSamplingCount" class="sel">
+              <option v-for="n in [1,2,3,4,5,6,7,8,9]" :key="n" :value="n">{{ n }}</option>
+            </select>
+          </div>
+        </div>
+        <div v-if="taskCreateError" class="dlg-err">{{ taskCreateError }}</div>
+      </div>
+      <div class="dlg-foot">
+        <button class="btn-secondary" @click="showNewTask = false">Cancel</button>
+        <button class="btn-primary" @click="submitNewTask">Create task</button>
+      </div>
+    </div>
+  </div>
+
+  <!-- Task detail modal -->
+  <div v-if="taskDetailFor" class="dlg-mask" @click.self="taskDetailFor = null">
+    <div class="dlg wide">
+      <div class="dlg-head">
+        <div>Profile task detail</div>
+        <button class="x" @click="taskDetailFor = null">×</button>
+      </div>
+      <div class="dlg-body">
+        <dl class="kv">
+          <dt>Service</dt><dd>{{ serviceName ?? taskDetailFor.serviceId }}</dd>
+          <dt>Endpoint</dt><dd>{{ taskDetailFor.endpointName }}</dd>
+          <dt>Start time</dt><dd>{{ fmtTime(taskDetailFor.startTime) }}</dd>
+          <dt>Duration</dt><dd>{{ taskDetailFor.duration }} min</dd>
+          <dt>Min threshold</dt><dd>{{ taskDetailFor.minDurationThreshold }} ms</dd>
+          <dt>Dump period</dt><dd>{{ taskDetailFor.dumpPeriod }}</dd>
+          <dt>Max sampling count</dt><dd>{{ taskDetailFor.maxSamplingCount }}</dd>
+        </dl>
+        <div v-if="taskDetailLogs.length" class="logs">
+          <h5>Instance logs</h5>
+          <div v-for="(log, i) in taskDetailLogs" :key="i" class="log-line">
+            <span class="t-tag">{{ log.operationType }}</span>
+            <span class="muted">{{ log.instanceName }}</span>
+            <span>{{ fmtTime(log.operationTime) }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.prof-shell {
+  display: flex;
+  height: calc(100vh - 280px);
+  min-height: 520px;
+  padding: 0;
+  overflow: hidden;
+}
+.prof-side {
+  width: 310px;
+  display: flex;
+  flex-direction: column;
+  border-right: 1px solid var(--sw-line);
+  flex: 0 0 310px;
+}
+.side-pane {
+  display: flex;
+  flex-direction: column;
+  flex: 1 1 50%;
+  min-height: 0;
+}
+.side-pane + .side-pane {
+  border-top: 1px solid var(--sw-line);
+}
+.side-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 6px 10px;
+  background: var(--sw-bg-2);
+  border-bottom: 1px solid var(--sw-line);
+  font-size: 11.5px;
+  font-weight: 600;
+  color: var(--sw-fg-1);
+}
+.btn-new {
+  font-size: 10.5px;
+  padding: 2px 8px;
+  border-radius: 3px;
+  border: 1px solid var(--sw-line-2);
+  background: var(--sw-bg-1);
+  color: var(--sw-fg-1);
+  cursor: pointer;
+}
+.btn-new:hover:not(:disabled) {
+  border-color: var(--sw-accent);
+  color: var(--sw-accent);
+}
+.btn-new:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+.side-counter {
+  font-weight: 500;
+  font-size: 10.5px;
+  color: var(--sw-fg-3);
+}
+.side-err {
+  padding: 8px 12px;
+  font-size: 11px;
+  color: #ff8888;
+}
+.side-empty {
+  padding: 12px;
+  font-size: 11.5px;
+  color: var(--sw-fg-3);
+  text-align: center;
+}
+.side-list {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  overflow-y: auto;
+  flex: 1 1 0;
+}
+.side-list li {
+  padding: 7px 10px;
+  border-bottom: 1px solid var(--sw-line);
+  cursor: pointer;
+}
+.side-list li:hover {
+  background: var(--sw-bg-2);
+}
+.side-list li.is-active {
+  background: var(--sw-bg-3, var(--sw-bg-2));
+  border-left: 3px solid var(--sw-accent);
+  padding-left: 7px;
+}
+.side-list li.err .ep {
+  color: #ff7676;
+}
+.row1 {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 6px;
+}
+.ep {
+  font-size: 12px;
+  color: var(--sw-fg-0);
+  font-weight: 500;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.row-eye {
+  background: transparent;
+  border: none;
+  color: var(--sw-fg-3);
+  cursor: pointer;
+  padding: 0;
+}
+.row-eye:hover {
+  color: var(--sw-accent);
+}
+.row2 {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 10.5px;
+  color: var(--sw-fg-2);
+  margin-top: 3px;
+}
+.muted {
+  color: var(--sw-fg-3);
+}
+.dur-chip {
+  background: var(--sw-bg-3, var(--sw-bg-2));
+  border-radius: 2px;
+  padding: 0 5px;
+  color: var(--sw-fg-1);
+}
+
+.prof-main {
+  flex: 1 1 0;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+}
+.main-toolbar {
+  display: flex;
+  align-items: end;
+  gap: 12px;
+  padding: 8px 12px;
+  background: var(--sw-bg-1);
+  border-bottom: 1px solid var(--sw-line);
+}
+.tb-block {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+}
+.lbl {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+}
+.ti-input,
+.sel {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-0);
+  border: 1px solid var(--sw-line);
+  border-radius: 3px;
+  padding: 4px 6px;
+  font-size: 11.5px;
+  font-family: var(--sw-mono);
+  outline: none;
+}
+.ti-input {
+  width: 260px;
+}
+.ti-input.wide,
+.sel.wide {
+  width: 100%;
+}
+.sel {
+  min-width: 140px;
+}
+.seg {
+  display: inline-flex;
+  border: 1px solid var(--sw-line-2);
+  border-radius: 3px;
+  overflow: hidden;
+}
+.seg button {
+  background: transparent;
+  border: none;
+  color: var(--sw-fg-2);
+  padding: 4px 10px;
+  font-size: 11px;
+  cursor: pointer;
+}
+.seg button.on {
+  background: var(--sw-accent);
+  color: #fff;
+}
+.btn-primary {
+  background: var(--sw-accent);
+  color: #fff;
+  border: none;
+  border-radius: 3px;
+  padding: 6px 14px;
+  font-size: 11.5px;
+  font-weight: 600;
+  cursor: pointer;
+}
+.btn-primary:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+.btn-secondary {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-1);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 3px;
+  padding: 6px 14px;
+  font-size: 11.5px;
+  cursor: pointer;
+}
+
+.span-table {
+  flex: 0 0 auto;
+  max-height: 36%;
+  overflow-y: auto;
+  border-bottom: 1px solid var(--sw-line);
+}
+.st-head,
+.st-row {
+  display: grid;
+  grid-template-columns: minmax(280px, 2fr) minmax(180px, 1.2fr) minmax(160px, 1fr) 110px 90px;
+  align-items: center;
+  font-size: 11.5px;
+  color: var(--sw-fg-1);
+  font-family: var(--sw-mono);
+}
+.st-head {
+  background: var(--sw-bg-2);
+  border-bottom: 1px solid var(--sw-line);
+  position: sticky;
+  top: 0;
+  z-index: 1;
+}
+.st-head .c {
+  padding: 6px 8px;
+  font-weight: 600;
+  color: var(--sw-fg-1);
+}
+.st-row {
+  border-bottom: 1px dotted var(--sw-line);
+  cursor: pointer;
+}
+.st-row:hover {
+  background: var(--sw-bg-2);
+}
+.st-row.is-active {
+  background: var(--sw-bg-3, var(--sw-bg-2));
+  border-left: 3px solid var(--sw-accent);
+}
+.st-row.err {
+  color: #ff8e8e;
+}
+.st-row .c {
+  padding: 5px 8px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.st-row .c-name {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+.t-tag {
+  display: inline-block;
+  font-size: 10px;
+  letter-spacing: 0.04em;
+  padding: 0 5px;
+  border-radius: 2px;
+  background: var(--sw-bg-3, var(--sw-bg-2));
+  color: var(--sw-fg-2);
+}
+.st-row.profiled .t-tag {
+  background: var(--sw-accent);
+  color: #fff;
+}
+.t-name {
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.cs1 {
+  color: var(--sw-fg-0);
+}
+.cs2 {
+  font-size: 10px;
+  color: var(--sw-fg-3);
+}
+.b-yes {
+  color: var(--sw-accent);
+  font-weight: 600;
+}
+.b-no {
+  color: var(--sw-fg-3);
+}
+.st-empty {
+  padding: 14px;
+  font-size: 11.5px;
+  color: var(--sw-fg-3);
+  text-align: center;
+}
+.result {
+  flex: 1 1 0;
+  min-height: 0;
+  overflow: hidden;
+  position: relative;
+  display: flex;
+  flex-direction: column;
+}
+.result-tip {
+  padding: 10px 14px;
+  background: var(--sw-bg-2);
+  color: #ffb86b;
+  font-size: 11.5px;
+  border-bottom: 1px solid var(--sw-line);
+}
+
+.dlg-mask {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.55);
+  z-index: 50;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.dlg {
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  width: 520px;
+  max-width: 92vw;
+  max-height: 90vh;
+  display: flex;
+  flex-direction: column;
+}
+.dlg.wide {
+  width: 640px;
+}
+.dlg-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  background: var(--sw-bg-2);
+  font-weight: 600;
+  font-size: 12.5px;
+  color: var(--sw-fg-0);
+}
+.x {
+  background: transparent;
+  border: none;
+  color: var(--sw-fg-3);
+  font-size: 18px;
+  cursor: pointer;
+  line-height: 1;
+}
+.dlg-body {
+  padding: 14px;
+  overflow-y: auto;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  font-size: 11.5px;
+  color: var(--sw-fg-1);
+}
+.field label {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+}
+.field-row {
+  display: flex;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+.field-row .field {
+  flex: 1 1 0;
+  min-width: 140px;
+}
+.dlg-foot {
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+  padding: 10px 14px;
+  border-top: 1px solid var(--sw-line);
+}
+.dlg-err {
+  color: #ff7676;
+  font-size: 11px;
+}
+.kv {
+  display: grid;
+  grid-template-columns: 140px 1fr;
+  row-gap: 6px;
+  font-size: 11.5px;
+  margin: 0;
+}
+.kv dt {
+  color: var(--sw-fg-3);
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  font-size: 10px;
+  align-self: center;
+}
+.kv dd {
+  margin: 0;
+  color: var(--sw-fg-0);
+}
+.logs h5 {
+  margin: 14px 0 6px;
+  font-size: 11px;
+  color: var(--sw-fg-1);
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+}
+.log-line {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  font-size: 11px;
+  padding: 4px 0;
+  border-bottom: 1px dotted var(--sw-line);
+}
+</style>
diff --git a/apps/ui/src/views/layer/LayerTracesEntry.vue b/apps/ui/src/views/layer/LayerTracesEntry.vue
new file mode 100644
index 0000000..98ba7a8
--- /dev/null
+++ b/apps/ui/src/views/layer/LayerTracesEntry.vue
@@ -0,0 +1,116 @@
+<!--
+  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.
+-->
+<!--
+  Per-layer Traces tab dispatcher. Routes route `/layer/:key/trace` to
+  either `LayerTracesView.vue` (SkyWalking-native) or
+  `LayerZipkinTracesView.vue` based on the layer template's
+  `traces.source` flag. For `source: 'both'` we render a small
+  source-toggle bar above the active view so the operator can flip
+  between the two trace stores.
+
+  The router can't make this decision statically (it has no menu data
+  at route-resolve time), so this dispatcher reads `useLayers()`
+  client-side and swaps the inner component.
+-->
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+import type { LayerDef } from '@skywalking-horizon-ui/api-client';
+import { useLayers } from '@/composables/useLayers';
+import LayerTracesView from './LayerTracesView.vue';
+import LayerZipkinTracesView from './LayerZipkinTracesView.vue';
+
+const route = useRoute();
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+const { layers } = useLayers();
+const layer = computed<LayerDef | null>(() => layers.value.find((l) => l.key === layerKey.value) ?? null);
+
+/** `source: 'native' | 'zipkin' | 'both'` — defaults to native when
+ *  the layer template doesn't carry a `traces` block. */
+const configuredSource = computed<'native' | 'zipkin' | 'both'>(
+  () => layer.value?.traces?.source ?? 'native',
+);
+/** Active source. When `configured = both`, the operator toggle wins;
+ *  otherwise the configured value is locked. */
+const activeSource = ref<'native' | 'zipkin'>('native');
+watch(
+  configuredSource,
+  (s) => {
+    if (s === 'zipkin') activeSource.value = 'zipkin';
+    else if (s === 'native') activeSource.value = 'native';
+    else if (activeSource.value !== 'zipkin') activeSource.value = 'native';
+  },
+  { immediate: true },
+);
+const showToggle = computed(() => configuredSource.value === 'both');
+</script>
+
+<template>
+  <div class="trc-entry">
+    <div v-if="showToggle" class="trc-source-toggle">
+      <span class="kicker">Source</span>
+      <button
+        type="button"
+        class="trc-source-btn"
+        :class="{ on: activeSource === 'native' }"
+        @click="activeSource = 'native'"
+      >Native</button>
+      <button
+        type="button"
+        class="trc-source-btn"
+        :class="{ on: activeSource === 'zipkin' }"
+        @click="activeSource = 'zipkin'"
+      >Zipkin</button>
+    </div>
+    <LayerZipkinTracesView v-if="activeSource === 'zipkin'" />
+    <LayerTracesView v-else />
+  </div>
+</template>
+
+<style scoped>
+.trc-entry { display: flex; flex-direction: column; gap: 8px; }
+.trc-source-toggle {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 4px 8px;
+  align-self: flex-start;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+}
+.kicker {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.1em;
+  color: var(--sw-fg-3);
+  font-weight: 600;
+  margin-right: 2px;
+}
+.trc-source-btn {
+  padding: 4px 10px;
+  font-size: 11px;
+  font-weight: 500;
+  color: var(--sw-fg-2);
+  background: transparent;
+  border: none;
+  border-radius: 3px;
+  cursor: pointer;
+}
+.trc-source-btn:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+.trc-source-btn.on { background: var(--sw-bg-3); color: var(--sw-fg-0); font-weight: 600; }
+</style>
diff --git a/apps/ui/src/views/layer/LayerZipkinTracesView.vue b/apps/ui/src/views/layer/LayerZipkinTracesView.vue
new file mode 100644
index 0000000..6437e31
--- /dev/null
+++ b/apps/ui/src/views/layer/LayerZipkinTracesView.vue
@@ -0,0 +1,1307 @@
+<!--
+  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.
+-->
+<!--
+  Per-layer Traces tab — Zipkin source. Active for layers whose
+  data path ships Zipkin-format spans (mesh / k8s — Envoy ALS, rover).
+  Mirrors LayerTracesView's voice (run-query toolbar + condition grid
+  + sortable list) but the wire shape is Zipkin v2 and the popout is
+  the parent-id-walking ZipkinTracePopout.
+-->
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+import type { ZipkinTraceListRow } from '@skywalking-horizon-ui/api-client';
+import { useLayerZipkinTraces, useZipkinTrace } from '@/composables/useZipkinTraces';
+import { useZipkinTracePopout } from '@/composables/useZipkinTracePopout';
+import { bffClient } from '@/api/client';
+
+// Zipkin trace data is keyed by its own service universe (the names
+// reported in span `localEndpoint.serviceName`), not by SkyWalking's
+// per-page service picker. This view deliberately does NOT read the
+// URL-backed selected service or the layer's landing rows — those
+// belong to the SkyWalking metric stack. Service filtering happens
+// entirely through the operator's input + the Zipkin `/api/v2/services`
+// autocomplete list.
+const route = useRoute();
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+
+// ── Conditions ──────────────────────────────────────────────────
+// Sentinel duration that means "use customStartMs / customEndMs
+// instead of a preset window." Picked as -1 so it can't collide with
+// any real ms value.
+const CUSTOM_RANGE = -1;
+const lookbackMs = ref<number>(30 * 60_000); // 30 min default
+const TIME_PRESETS: Array<{ label: string; ms: number }> = [
+  { label: 'Last 15 min', ms: 15 * 60_000 },
+  { label: 'Last 30 min', ms: 30 * 60_000 },
+  { label: 'Last 1 hour', ms: 60 * 60_000 },
+  { label: 'Last 3 hours', ms: 3 * 60 * 60_000 },
+  { label: 'Last 6 hours', ms: 6 * 60 * 60_000 },
+  { label: 'Last 12 hours', ms: 12 * 60 * 60_000 },
+  { label: 'Last 24 hours', ms: 24 * 60 * 60_000 },
+  { label: 'Custom range…', ms: CUSTOM_RANGE },
+];
+// `<input type="datetime-local">` produces "YYYY-MM-DDTHH:MM" in the
+// browser's local zone (no seconds, no tz). We pre-seed with a sane
+// pair (now − default lookback → now) so flipping to Custom shows
+// readable values instead of an empty input.
+function toLocalDtValue(ms: number): string {
+  const d = new Date(ms);
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+const customStart = ref<string>(toLocalDtValue(Date.now() - 60 * 60_000));
+const customEnd = ref<string>(toLocalDtValue(Date.now()));
+const isCustomRange = computed(() => lookbackMs.value === CUSTOM_RANGE);
+function parseLocalDt(v: string): number | null {
+  if (!v) return null;
+  const t = new Date(v).getTime();
+  return Number.isFinite(t) ? t : null;
+}
+const limit = ref<number>(30);
+const spanName = ref<string>('');
+const remoteServiceName = ref<string>('');
+const minDurationMs = ref<number | null>(null);
+const maxDurationMs = ref<number | null>(null);
+const annotationQuery = ref<string>('');
+const traceIdInput = ref<string>('');
+/**
+ * The Zipkin query optionally narrows by serviceName. We default the
+ * input to the page's selected service for ergonomic landing (so the
+ * first query is scoped to what the operator is looking at), but the
+ * input is fully editable — including blank for "all services".
+ * Driven by `zipkinServiceFilter`, not the URL service picker.
+ */
+const zipkinServiceFilter = ref<string>('');
+const zipkinServiceOptions = ref<string[]>([]);
+/** Span-name / remote-service autocomplete inputs are service-scoped in
+ *  Zipkin (`/api/v2/spans?serviceName=`, `/api/v2/remoteServices?serviceName=`).
+ *  Lens-UI mirror: both inputs are disabled until a service is picked. */
+const hasService = computed<boolean>(() => zipkinServiceFilter.value.trim().length > 0);
+
+// Committed snapshot — only changes when Run query is clicked.
+const cService = ref<string | null>(null);
+const cLookback = ref<number>(lookbackMs.value);
+const cEndTs = ref<number | null>(null); // absolute window end (ms), null = now
+const cLimit = ref<number>(limit.value);
+const cSpan = ref<string | null>(null);
+const cRemote = ref<string | null>(null);
+const cMinDurUs = ref<number | null>(null);
+const cMaxDurUs = ref<number | null>(null);
+const cAnno = ref<string | null>(null);
+const hasQueried = ref<boolean>(false);
+const queryEnabled = computed(() => hasQueried.value);
+
+const { traces, isFetching, error, refetch } = useLayerZipkinTraces({
+  serviceName: cService,
+  remoteServiceName: cRemote,
+  spanName: cSpan,
+  minDuration: cMinDurUs,
+  maxDuration: cMaxDurUs,
+  endTs: cEndTs,
+  lookback: cLookback,
+  limit: cLimit,
+  annotationQuery: cAnno,
+  enabled: queryEnabled,
+});
+const { openTrace } = useZipkinTracePopout();
+
+function runQuery(): void {
+  // Service filter is now operator-driven (free-text + datalist), not
+  // bound to the URL service picker. Empty = "All services" in
+  // Zipkin's API — pass null and OAP returns the full set.
+  cService.value = zipkinServiceFilter.value.trim() || null;
+  // Time range — preset emits {endTs: now, lookback: preset}; Custom
+  // commits absolute start/end → {endTs: end, lookback: end - start}.
+  // Zipkin queries the window [endTs - lookback, endTs] so this is a
+  // straightforward translation.
+  if (isCustomRange.value) {
+    const s = parseLocalDt(customStart.value);
+    const e = parseLocalDt(customEnd.value);
+    if (s != null && e != null && e > s) {
+      cEndTs.value = e;
+      cLookback.value = e - s;
+    } else {
+      cEndTs.value = null;
+      cLookback.value = 30 * 60_000;
+    }
+  } else {
+    cEndTs.value = null;
+    cLookback.value = lookbackMs.value;
+  }
+  cLimit.value = limit.value;
+  cSpan.value = spanName.value.trim() || null;
+  cRemote.value = remoteServiceName.value.trim() || null;
+  cAnno.value = annotationQuery.value.trim() || null;
+  cMinDurUs.value = minDurationMs.value != null ? Math.max(0, minDurationMs.value * 1000) : null;
+  cMaxDurUs.value = maxDurationMs.value != null ? Math.max(0, maxDurationMs.value * 1000) : null;
+  hasQueried.value = true;
+  void refetch();
+}
+
+// URL `?traceId=<id>` opens the popout directly (e.g. log-row link
+// → trace jump). We just route those through the Zipkin popout state.
+watch(
+  () => route.query.traceId,
+  (raw) => {
+    const id = typeof raw === 'string' ? raw : null;
+    if (id) openTrace(id);
+  },
+  { immediate: true },
+);
+
+// Zipkin's service universe is INDEPENDENT of SkyWalking's per-page
+// service picker — different name index (no `<group>::` prefix, no
+// `normal` flag, different list endpoint). We don't seed from the URL
+// SkyWalking service; the input defaults to empty (= All services
+// known to Zipkin) and the operator narrows by picking from the
+// `/api/v2/services` dropdown.
+//
+// Auto-fire one query on landing so the table isn't empty on first
+// arrival — runs with "All" so every layer (mesh / k8s / etc.) shows
+// something immediately.
+watch(layerKey, () => {
+  if (!hasQueried.value) runQuery();
+}, { immediate: true });
+
+// Load the full Zipkin service list once for the filter dropdown.
+// Best-effort: a failed fetch leaves the input as plain text.
+async function loadServiceOptions(): Promise<void> {
+  try {
+    const res = await bffClient.zipkinServices();
+    // De-duplicate (OAP sometimes returns the same name twice).
+    zipkinServiceOptions.value = Array.from(new Set(Array.isArray(res) ? res : []));
+  } catch { /* noop */ }
+}
+watch(layerKey, () => { void loadServiceOptions(); }, { immediate: true });
+
+// ── Auto-complete for Span name / Remote service ─────────────────
+// Mirrors Zipkin Lens's search pattern. The two endpoints are
+// SERVICE-SCOPED in Zipkin (`/api/v2/spans?serviceName=` and
+// `/api/v2/remoteServices?serviceName=`), so the options refresh as
+// the operator types a service in the filter — same dependency
+// chain Zipkin Lens follows. When the service is blank ("All"), we
+// clear both lists because Zipkin doesn't expose a "all spans" /
+// "all remotes" endpoint.
+const spanNameOptions = ref<string[]>([]);
+const remoteSvcOptions = ref<string[]>([]);
+async function loadAutocomplete(svc: string): Promise<void> {
+  if (!svc) {
+    spanNameOptions.value = [];
+    remoteSvcOptions.value = [];
+    return;
+  }
+  try {
+    const sp = await bffClient.zipkinSpans(svc);
+    spanNameOptions.value = Array.isArray(sp) ? sp : [];
+  } catch { spanNameOptions.value = []; }
+  try {
+    const rs = await bffClient.zipkinRemoteServices(svc);
+    remoteSvcOptions.value = Array.isArray(rs) ? rs : [];
+  } catch { remoteSvcOptions.value = []; }
+}
+// Debounce so typing doesn't fire on every keystroke. When the
+// service is cleared we also reset the dependent fields — running a
+// query with stale span/remote values against "All services" would
+// otherwise silently filter out everything.
+let autocompleteTimer: ReturnType<typeof setTimeout> | null = null;
+watch(zipkinServiceFilter, (v) => {
+  if (autocompleteTimer) clearTimeout(autocompleteTimer);
+  const trimmed = v.trim();
+  if (!trimmed) {
+    spanName.value = '';
+    remoteServiceName.value = '';
+  }
+  autocompleteTimer = setTimeout(() => { void loadAutocomplete(trimmed); }, 250);
+}, { immediate: true });
+
+// ── Annotation autocomplete ─────────────────────────────────────
+// Zipkin annotation queries are `key` (presence) or `key=value`
+// pairs, AND-joined with space. OAP exposes a pre-configured key set
+// via `/api/v2/autocompleteKeys` + per-key values via
+// `/api/v2/autocompleteValues?key=`. We mirror the log-tag pattern:
+// one `<datalist>` for the annotations input, content swapping
+// between known keys (before `=`) and per-key values (after).
+const annotationKeyOptions = ref<string[]>([]);
+const annotationValueOptions = ref<string[]>([]);
+const annotationValueKey = ref<string>('');
+async function loadAnnotationKeys(): Promise<void> {
+  try {
+    const res = await bffClient.zipkinAutocompleteKeys();
+    annotationKeyOptions.value = Array.isArray(res) ? res : [];
+  } catch { /* best-effort */ }
+}
+async function loadAnnotationValues(key: string): Promise<void> {
+  if (!key || key === annotationValueKey.value) return;
+  annotationValueKey.value = key;
+  try {
+    const res = await bffClient.zipkinAutocompleteValues(key);
+    annotationValueOptions.value = Array.isArray(res) ? res : [];
+  } catch { /* noop */ }
+}
+function onAnnotationInput(): void {
+  // Operator can chain `k1=v1 k2=v2`; we look at the last token to
+  // decide whether to show keys or values.
+  const last = (annotationQuery.value.split(/\s+/).pop() ?? '').trim();
+  const eq = last.indexOf('=');
+  if (eq === -1) return; // keys datalist is active by default
+  const key = last.slice(0, eq).trim();
+  if (key) void loadAnnotationValues(key);
+}
+const annotationDatalistOptions = computed<string[]>(() => {
+  const last = (annotationQuery.value.split(/\s+/).pop() ?? '').trim();
+  const eq = last.indexOf('=');
+  if (eq === -1) return annotationKeyOptions.value;
+  const key = last.slice(0, eq).trim();
+  return annotationValueOptions.value.map((v) => `${key}=${v}`);
+});
+// Eagerly load keys once we know the layer — keys aren't service-
+// scoped, so a single load on mount is enough.
+watch(layerKey, (k) => { if (k) void loadAnnotationKeys(); }, { immediate: true });
+
+// ── Inline selection (list-left, detail-right) ──────────────────
+// Mirrors `LayerTracesView`'s native pattern: a row click commits a
+// selection to local state instead of opening the global popout.
+// The selected trace's full spans render in the side rail. The
+// trace-id input + URL-driven `?openZipkinTraceId=` still fall back
+// to the popout for the "paste an id" / "share a link" flows.
+const selectedTraceId = ref<string | null>(null);
+function selectRow(row: ZipkinTraceListRow): void {
+  selectedTraceId.value = row.traceId;
+}
+function closeDetail(): void {
+  selectedTraceId.value = null;
+}
+/** Rail toggle — when a trace is selected, the result list collapses
+ *  to a narrow rail with progress-bar-only entries (folder style).
+ *  Click any bar to swap the inline detail without expanding back. */
+const railOpen = ref<boolean>(true);
+const selectedRow = computed<ZipkinTraceListRow | null>(() => {
+  if (!selectedTraceId.value) return null;
+  return traces.value.find((t) => t.traceId === selectedTraceId.value) ?? null;
+});
+// Reset when the query parameters change so a stale selection
+// doesn't outlive its result set.
+watch([cService, cLookback, cLimit, cSpan, cRemote, cAnno], () => {
+  selectedTraceId.value = null;
+});
+
+// ── Inline span tree for the selected trace ─────────────────────
+// Fetches the full Zipkin span set + flattens into a depth-indented
+// waterfall row list. Reused-but-simplified from `ZipkinTracePopout`:
+// same parent-id walk, fewer per-row affordances since the rail is
+// narrower than the popout.
+const selectedTraceIdRef = computed<string | null>(() => selectedTraceId.value);
+const { spans: selectedSpans, isLoading: selectedLoading } = useZipkinTrace(selectedTraceIdRef);
+interface DetailRow { span: import('@skywalking-horizon-ui/api-client').ZipkinSpan; depth: number; offsetUs: number; durUs: number; }
+const detailBounds = computed(() => {
+  let t0 = Infinity;
+  let t1 = -Infinity;
+  for (const s of selectedSpans.value) {
+    const start = s.timestamp ?? 0;
+    const dur = s.duration ?? 0;
+    if (start && start < t0) t0 = start;
+    if (start && (start + dur) > t1) t1 = start + dur;
+  }
+  if (!Number.isFinite(t0)) t0 = 0;
+  if (!Number.isFinite(t1) || t1 <= t0) t1 = t0 + 1;
+  return { t0, t1, totalUs: t1 - t0 };
+});
+const detailRows = computed<DetailRow[]>(() => {
+  const list = selectedSpans.value;
+  if (list.length === 0) return [];
+  const byParent = new Map<string, typeof list>();
+  for (const s of list) {
+    const p = s.parentId ?? '';
+    if (!byParent.has(p)) byParent.set(p, [] as typeof list);
+    (byParent.get(p) as typeof list).push(s);
+  }
+  for (const arr of byParent.values()) arr.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
+  const out: DetailRow[] = [];
+  const { t0 } = detailBounds.value;
+  function walk(parentId: string, depth: number): void {
+    for (const s of (byParent.get(parentId) ?? [])) {
+      const start = s.timestamp ?? t0;
+      out.push({ span: s, depth, offsetUs: start - t0, durUs: s.duration ?? 0 });
+      walk(s.id, depth + 1);
+    }
+  }
+  // Real roots have no parent or a parent missing from the set.
+  const ids = new Set(list.map((s) => s.id));
+  walk('', 0);
+  for (const s of list) {
+    if (!s.parentId) continue;
+    if (ids.has(s.parentId)) continue;
+    const start = s.timestamp ?? t0;
+    out.push({ span: s, depth: 0, offsetUs: start - t0, durUs: s.duration ?? 0 });
+    walk(s.id, 1);
+  }
+  return out;
+});
+const SERVICE_PALETTE = ['#f97316', '#60a5fa', '#a78bfa', '#22d3ee', '#f472b6', '#34d399', '#fbbf24', '#fb7185'];
+function detailColor(name: string | null | undefined): string {
+  if (!name) return 'var(--sw-fg-3)';
+  let h = 0;
+  for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
+  return SERVICE_PALETTE[Math.abs(h) % SERVICE_PALETTE.length];
+}
+function detailLeftPct(us: number): number {
+  return Math.max(0, Math.min(100, (us / (detailBounds.value.totalUs || 1)) * 100));
+}
+function detailWidthPct(us: number): number {
+  if (us <= 0) return 0.6;
+  return Math.max(0.6, Math.min(100, (us / (detailBounds.value.totalUs || 1)) * 100));
+}
+function expandInPopout(): void {
+  if (selectedTraceId.value) openTrace(selectedTraceId.value);
+}
+
+// ── Span detail (Lens-style inline panel) ───────────────────────
+// Clicking a waterfall row pins that span. The panel mirrors Zipkin
+// Lens's span sidebar: service header, key-value identity block,
+// tag table, annotation timeline. Selection is local to the inline
+// view — the popout has its own.
+const selectedSpanId = ref<string | null>(null);
+const selectedSpan = computed<import('@skywalking-horizon-ui/api-client').ZipkinSpan | null>(() => {
+  if (!selectedSpanId.value) return null;
+  return selectedSpans.value.find((s) => s.id === selectedSpanId.value) ?? null;
+});
+function selectSpan(s: import('@skywalking-horizon-ui/api-client').ZipkinSpan): void {
+  selectedSpanId.value = selectedSpanId.value === s.id ? null : s.id;
+}
+function clearSpan(): void {
+  selectedSpanId.value = null;
+}
+// Clear the pinned span when the operator switches trace.
+watch(selectedTraceId, () => { selectedSpanId.value = null; });
+
+function fmtAbsTime(usSinceEpoch: number): string {
+  if (!usSinceEpoch) return '—';
+  const d = new Date(usSinceEpoch / 1000);
+  return `${d.toLocaleTimeString('en-US', { hour12: false })}.${String(d.getMilliseconds()).padStart(3, '0')}`;
+}
+
+// ── Rendering helpers ───────────────────────────────────────────
+function fmtMs(us: number | null): string {
+  if (us == null) return '—';
+  if (us < 1000) return `${us}μs`;
+  if (us < 1_000_000) return `${(us / 1000).toFixed(2)}ms`;
+  return `${(us / 1_000_000).toFixed(2)}s`;
+}
+// `fmtTime` removed — absolute start moved to `fmtRelativeAgo` in
+// the row meta strip; the card layout drops the wall-clock column.
+const sortedTraces = computed<ZipkinTraceListRow[]>(() => {
+  return [...traces.value].sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
+});
+/** Largest duration in the visible set — drives the proportional
+ *  fill of each row's duration bar. Matches the native trace tab's
+ *  "% of slowest" presentation. */
+const maxTraceDurationUs = computed<number>(() => {
+  let m = 0;
+  for (const t of traces.value) {
+    if ((t.duration ?? 0) > m) m = t.duration ?? 0;
+  }
+  return m || 1;
+});
+/** Tint for a row's duration bar — green for fast, amber for mid,
+ *  red for slow. Error rows override to red regardless. Matches the
+ *  native list's `rowDurationColor` heuristic. */
+function rowDurationColor(durUs: number): string {
+  const max = maxTraceDurationUs.value || 1;
+  const pct = durUs / max;
+  if (pct > 0.75) return 'var(--sw-err)';
+  if (pct > 0.4) return 'var(--sw-warn)';
+  return 'var(--sw-ok)';
+}
+function fmtRelativeAgo(usSinceEpoch: number | null): string {
+  if (!usSinceEpoch) return '';
+  const seconds = Math.floor((Date.now() - usSinceEpoch / 1000) / 1000);
+  if (seconds < 60) return `${seconds}s ago`;
+  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
+  if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
+  return `${Math.floor(seconds / 86400)}d ago`;
+}
+function openByInput(): void {
+  const v = traceIdInput.value.trim();
+  if (v) openTrace(v);
+}
+</script>
+
+<template>
+  <div class="ztr-tab">
+    <header class="ztr-toolbar sw-card">
+      <div class="ztr-head">
+        <span class="kicker">Traces · Zipkin</span>
+        <span v-if="zipkinServiceFilter" class="for-svc">
+          service <b class="mono">{{ zipkinServiceFilter }}</b>
+        </span>
+        <span v-else class="for-svc dim">all services</span>
+        <span v-if="isCustomRange && customStart && customEnd" class="for-svc dim">
+          · <b class="mono">{{ customStart.replace('T', ' ') }} → {{ customEnd.replace('T', ' ') }}</b>
+        </span>
+        <span v-if="isFetching" class="hint">refreshing…</span>
+        <button class="sw-btn primary ztr-run-btn" type="button" @click="runQuery">Run query</button>
+      </div>
+      <div class="ztr-conditions">
+        <!-- Service condition — free-text + datalist with All. Empty
+             commits as `null` (= every service) to the Zipkin query. -->
+        <label class="cf">
+          <span>Service</span>
+          <input
+            v-model="zipkinServiceFilter"
+            class="cf-input"
+            type="text"
+            placeholder="All"
+            list="ztr-svc-suggest"
+            @keyup.enter="runQuery"
+          />
+          <datalist id="ztr-svc-suggest">
+            <option v-for="s in zipkinServiceOptions" :key="s" :value="s" />
+          </datalist>
+        </label>
+        <label class="cf" :class="{ disabled: !hasService }">
+          <span>Remote service <small v-if="!hasService" class="dim">— pick a service</small></span>
+          <input
+            v-model="remoteServiceName"
+            class="cf-input"
+            type="text"
+            :placeholder="hasService ? 'any' : 'select a service first'"
+            :disabled="!hasService"
+            list="ztr-remote-svc-suggest"
+            @keyup.enter="runQuery"
+          />
+          <datalist id="ztr-remote-svc-suggest">
+            <option v-for="rs in remoteSvcOptions" :key="rs" :value="rs" />
+          </datalist>
+        </label>
+        <label class="cf cf-wide" :class="{ disabled: !hasService }">
+          <span>Span name <small v-if="!hasService" class="dim">— pick a service</small></span>
+          <input
+            v-model="spanName"
+            class="cf-input"
+            type="text"
+            :placeholder="hasService ? 'any' : 'select a service first'"
+            :disabled="!hasService"
+            list="ztr-span-suggest"
+            @keyup.enter="runQuery"
+          />
+          <datalist id="ztr-span-suggest">
+            <option v-for="sp in spanNameOptions" :key="sp" :value="sp" />
+          </datalist>
+        </label>
+        <label class="cf">
+          <span>Min duration (ms)</span>
+          <input v-model.number.lazy="minDurationMs" class="cf-input" type="number" min="0" placeholder="—" />
+        </label>
+        <label class="cf">
+          <span>Max duration (ms)</span>
+          <input v-model.number.lazy="maxDurationMs" class="cf-input" type="number" min="0" placeholder="—" />
+        </label>
+        <label class="cf cf-wide">
+          <span>Annotations</span>
+          <input
+            v-model="annotationQuery"
+            class="cf-input mono"
+            type="text"
+            placeholder="error or key=value, AND-joined"
+            list="ztr-annotation-suggest"
+            @input="onAnnotationInput"
+            @keyup.enter="runQuery"
+          />
+          <datalist id="ztr-annotation-suggest">
+            <option v-for="opt in annotationDatalistOptions" :key="opt" :value="opt" />
+          </datalist>
+        </label>
+        <label class="cf cf-wide">
+          <span>Open trace ID</span>
+          <input v-model="traceIdInput" class="cf-input mono" type="text" placeholder="paste trace id…" @keyup.enter="openByInput" />
+        </label>
+        <label class="cf">
+          <span>Limit</span>
+          <select v-model.number="limit" class="cf-input">
+            <option :value="10">10</option>
+            <option :value="30">30</option>
+            <option :value="50">50</option>
+            <option :value="100">100</option>
+            <option :value="200">200</option>
+          </select>
+        </label>
+        <!-- Time range pinned to its own final row so the (optional)
+             custom-range pair sits beside it on the same line. -->
+        <label class="cf row-break">
+          <span>Time range</span>
+          <select v-model.number="lookbackMs" class="cf-input">
+            <option v-for="p in TIME_PRESETS" :key="p.ms" :value="p.ms">{{ p.label }}</option>
+          </select>
+        </label>
+        <!-- Custom-range inputs — show only when "Custom range…" is
+             picked. `datetime-local` parses to the operator's local
+             zone; we convert to ms-since-epoch on Run query. -->
+        <label v-if="isCustomRange" class="cf">
+          <span>From</span>
+          <input
+            v-model="customStart"
+            class="cf-input mono"
+            type="datetime-local"
+            @keyup.enter="runQuery"
+          />
+        </label>
+        <label v-if="isCustomRange" class="cf">
+          <span>To</span>
+          <input
+            v-model="customEnd"
+            class="cf-input mono"
+            type="datetime-local"
+            @keyup.enter="runQuery"
+          />
+        </label>
+      </div>
+    </header>
+
+    <!-- No selection — full-width list. Once an operator clicks a
+         row, the section flips below to the rail+detail split. -->
+    <section v-if="!selectedTraceId" class="ztr-split">
+      <article class="ztr-list sw-card">
+        <header class="ztr-list-head">
+          <span class="kicker">Results</span>
+          <span class="hint">{{ sortedTraces.length }} trace{{ sortedTraces.length === 1 ? '' : 's' }}</span>
+        </header>
+        <div v-if="error" class="banner err">
+          <strong>Zipkin query failed.</strong> {{ String(error) }}
+        </div>
+        <div v-else-if="!hasQueried" class="ztr-empty">
+          Click <b>Run query</b> to fetch Zipkin traces for this service.
+        </div>
+        <div v-else-if="sortedTraces.length === 0 && !isFetching" class="ztr-empty">
+          No Zipkin traces in the selected window. Widen the time range or relax conditions.
+        </div>
+        <!-- Result cards — same shape as the SkyWalking native trace
+             list. Each row carries: status flag at top, endpoint /
+             root span name, a proportional duration bar (% of the
+             slowest visible trace, color tinted by latency band, red
+             on error), and a trace-id snippet + relative-time meta
+             below. No raw "Service / Root span" columns — those bleed
+             into the bar / meta rows so the list reads as cards. -->
+        <ul v-else class="ztr-rowlist">
+          <li
+            v-for="r in sortedTraces"
+            :key="r.traceId"
+            class="ztr-row-card"
+            :class="{ err: r.errorCount > 0, ok: r.errorCount === 0, on: selectedTraceId === r.traceId }"
+            @click="selectRow(r)"
+          >
+            <div class="ztr-row-head">
+              <span class="ztr-ep mono" :class="{ red: r.errorCount > 0, blue: r.errorCount === 0 }">
+                {{ r.rootName ?? r.rootService ?? '—' }}
+              </span>
+              <span class="status-flag" :class="r.errorCount > 0 ? 'flag-err' : 'flag-ok'">
+                <span class="flag-dot" />
+                {{ r.errorCount > 0 ? 'ERR' : 'OK' }}
+              </span>
+            </div>
+            <div
+              class="ztr-row-bar"
+              :title="`${fmtMs(r.duration)} — ${Math.round(((r.duration ?? 0) / (maxTraceDurationUs || 1)) * 100)}% of slowest`"
+            >
+              <div
+                class="ztr-row-bar-fill"
+                :style="{
+                  width: maxTraceDurationUs > 0
+                    ? Math.max(2, ((r.duration ?? 0) / maxTraceDurationUs) * 100) + '%'
+                    : '0%',
+                  background: r.errorCount > 0 ? 'var(--sw-err)' : rowDurationColor(r.duration ?? 0),
+                }"
+              />
+              <span class="ztr-row-bar-label mono">{{ fmtMs(r.duration) }}</span>
+            </div>
+            <div class="ztr-row-meta">
+              <span class="mono dim ztr-svc">{{ r.rootService ?? '—' }}</span>
+              <span class="mono dim ztr-tid-snip" :title="r.traceId">{{ r.traceId.slice(0, 18) }}…</span>
+              <span class="mono dim ml-auto">{{ fmtRelativeAgo(r.timestamp) }}</span>
+            </div>
+          </li>
+        </ul>
+      </article>
+    </section>
+
+    <!-- Selection mode — native trace pattern: a narrow rail of
+         compact cards on the left + the detail (span tree) on the
+         right. Rail can collapse to a column of progress bars for
+         "folder style" navigation between traces without rebuilding
+         the page layout. -->
+    <section v-else class="ztr-detail-split" :class="{ 'rail-collapsed': !railOpen }">
+      <aside class="ztr-rail sw-card">
+        <header class="ztr-rail-head">
+          <button class="rail-handle" type="button" :title="railOpen ? 'Collapse list' : 'Expand list'" @click="railOpen = !railOpen">
+            <span v-if="railOpen">«</span><span v-else>»</span>
+          </button>
+          <h4 v-if="railOpen">Traces</h4>
+          <span v-if="railOpen" class="hint">{{ sortedTraces.length }}</span>
+        </header>
+        <ul v-if="railOpen" class="ztr-rowlist rail-list">
+          <li
+            v-for="r in sortedTraces"
+            :key="r.traceId"
+            class="ztr-row-card compact"
+            :class="{ on: selectedTraceId === r.traceId, err: r.errorCount > 0, ok: r.errorCount === 0 }"
+            @click="selectRow(r)"
+          >
+            <div class="rail-row-top">
+              <span
+                class="dur-tag mono"
+                :style="{ background: rowDurationColor(r.duration ?? 0) + '22', color: rowDurationColor(r.duration ?? 0) }"
+              >{{ fmtMs(r.duration) }}</span>
+              <span class="status-flag" :class="r.errorCount > 0 ? 'flag-err' : 'flag-ok'">
+                <span class="flag-dot" />
+                {{ r.errorCount > 0 ? 'ERR' : 'OK' }}
+              </span>
+            </div>
+            <div class="ztr-ep rail-ep mono" :class="{ red: r.errorCount > 0 }">
+              {{ r.rootName ?? r.rootService ?? '—' }}
+            </div>
+            <div class="ztr-row-meta">
+              <span class="mono dim ml-auto">{{ fmtRelativeAgo(r.timestamp) }}</span>
+            </div>
+          </li>
+        </ul>
+        <!-- Folded rail: clickable progress bars per trace. Click
+             switches the inline detail; doesn't expand the rail. -->
+        <ul v-if="!railOpen" class="rail-mini">
+          <li
+            v-for="r in sortedTraces"
+            :key="r.traceId"
+            class="rail-mini-row"
+            :class="{ on: selectedTraceId === r.traceId }"
+            :title="`${r.rootName ?? r.rootService ?? '—'} · ${fmtMs(r.duration)} · ${r.errorCount > 0 ? 'err' : 'ok'}`"
+            @click="selectRow(r)"
+          >
+            <div class="rail-mini-bar">
+              <div
+                class="rail-mini-fill"
+                :style="{
+                  width: maxTraceDurationUs > 0 ? Math.max(8, ((r.duration ?? 0) / maxTraceDurationUs) * 100) + '%' : '0%',
+                  background: r.errorCount > 0 ? 'var(--sw-err)' : 'var(--sw-ok)',
+                }"
+              />
+            </div>
+          </li>
+        </ul>
+      </aside>
+
+      <article class="ztr-detail sw-card">
+        <header class="ztr-detail-head">
+          <span class="kicker">Trace</span>
+          <code class="ztr-tid mono">{{ selectedTraceId.slice(0, 18) }}…</code>
+          <span v-if="selectedRow" class="dim small">{{ fmtMs(selectedRow.duration) }} · {{ selectedRow.spanCount }} spans</span>
+          <button class="sw-btn small" type="button" @click="expandInPopout">Expand</button>
+          <button class="sw-btn small ghost" type="button" @click="closeDetail">×</button>
+        </header>
+        <div v-if="selectedLoading" class="ztr-empty hint">loading spans…</div>
+        <div v-else-if="detailRows.length === 0" class="ztr-empty">No spans for this trace.</div>
+        <div v-else class="ztr-detail-body" :class="{ 'has-span': !!selectedSpan }">
+          <div class="ztr-waterfall">
+            <div
+              v-for="row in detailRows"
+              :key="row.span.id"
+              class="ztr-wf-row"
+              :class="{
+                err: row.span.tags?.error != null,
+                on: selectedSpanId === row.span.id,
+              }"
+              :title="row.span.name ?? ''"
+              @click="selectSpan(row.span)"
+            >
+              <span class="ztr-wf-label mono" :style="{ paddingLeft: row.depth * 12 + 'px' }">
+                <span class="ztr-wf-svc" :style="{ color: detailColor(row.span.localEndpoint?.serviceName) }">
+                  {{ row.span.localEndpoint?.serviceName ?? '—' }}
+                </span>
+                <span class="ztr-wf-name">{{ row.span.name || '(unnamed)' }}</span>
+              </span>
+              <div class="ztr-wf-track">
+                <div
+                  class="ztr-wf-bar"
+                  :style="{
+                    left: detailLeftPct(row.offsetUs) + '%',
+                    width: detailWidthPct(row.durUs) + '%',
+                    background: detailColor(row.span.localEndpoint?.serviceName),
+                    borderColor: row.span.tags?.error != null ? 'var(--sw-err)' : 'transparent',
+                  }"
+                />
+              </div>
+              <span class="ztr-wf-dur mono">{{ fmtMs(row.durUs) }}</span>
+            </div>
+          </div>
+
+          <!-- Lens-style span detail sidebar. Appears when a span is
+               pinned; renders identity block + tags + annotation timeline. -->
+          <aside v-if="selectedSpan" class="ztr-span-detail">
+            <header class="ztr-span-detail-head">
+              <h5>Span detail</h5>
+              <button class="sw-btn small ghost" type="button" title="Close" @click="clearSpan">×</button>
+            </header>
+            <dl class="zk-kv">
+              <dt>Service</dt>
+              <dd
+                class="mono"
+                :style="{ color: detailColor(selectedSpan.localEndpoint?.serviceName) }"
+              >
+                {{ selectedSpan.localEndpoint?.serviceName ?? '—' }}
+              </dd>
+              <dt>Name</dt><dd class="mono wba">{{ selectedSpan.name || '(unnamed)' }}</dd>
+              <dt v-if="selectedSpan.kind">Kind</dt>
+              <dd v-if="selectedSpan.kind">{{ selectedSpan.kind }}</dd>
+              <dt>Span id</dt><dd class="mono wba">{{ selectedSpan.id }}</dd>
+              <dt v-if="selectedSpan.parentId">Parent id</dt>
+              <dd v-if="selectedSpan.parentId" class="mono wba">{{ selectedSpan.parentId }}</dd>
+              <dt>Start</dt><dd class="mono">{{ fmtAbsTime(selectedSpan.timestamp ?? 0) }}</dd>
+              <dt>Duration</dt><dd class="mono">{{ fmtMs(selectedSpan.duration ?? 0) }}</dd>
+              <dt v-if="selectedSpan.remoteEndpoint?.serviceName">Peer</dt>
+              <dd v-if="selectedSpan.remoteEndpoint?.serviceName" class="mono wba">
+                {{ selectedSpan.remoteEndpoint.serviceName }}
+              </dd>
+              <dt v-if="selectedSpan.remoteEndpoint?.ipv4 || selectedSpan.remoteEndpoint?.ipv6">Peer addr</dt>
+              <dd v-if="selectedSpan.remoteEndpoint?.ipv4 || selectedSpan.remoteEndpoint?.ipv6" class="mono wba">
+                {{ selectedSpan.remoteEndpoint.ipv4 ?? selectedSpan.remoteEndpoint.ipv6
+                  }}<template v-if="selectedSpan.remoteEndpoint.port">:{{ selectedSpan.remoteEndpoint.port }}</template>
+              </dd>
+            </dl>
+            <section
+              v-if="selectedSpan.tags && Object.keys(selectedSpan.tags).length > 0"
+              class="zk-tags"
+            >
+              <h6>Tags</h6>
+              <dl class="zk-kv">
+                <template v-for="(v, k) in selectedSpan.tags" :key="k">
+                  <dt class="mono">{{ k }}</dt>
+                  <dd class="mono wba" :class="{ err: k === 'error' }">{{ v }}</dd>
+                </template>
+              </dl>
+            </section>
+            <section
+              v-if="selectedSpan.annotations && selectedSpan.annotations.length > 0"
+              class="zk-annotations"
+            >
+              <h6>Annotations</h6>
+              <ul>
+                <li v-for="(a, i) in selectedSpan.annotations" :key="i">
+                  <span class="mono dim">{{ fmtAbsTime(a.timestamp) }}</span>
+                  <span class="zk-ann-val mono">{{ a.value }}</span>
+                </li>
+              </ul>
+            </section>
+          </aside>
+        </div>
+      </article>
+    </section>
+  </div>
+</template>
+
+<style scoped>
+.ztr-tab { display: flex; flex-direction: column; gap: 12px; padding: 4px 0 0; }
+.ztr-toolbar { padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; overflow: visible; }
+.ztr-head { display: flex; align-items: baseline; gap: 10px; }
+.ztr-run-btn { margin-left: auto; }
+.kicker {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.1em;
+  color: var(--sw-accent);
+  font-weight: 600;
+}
+.for-svc { font-size: 11.5px; color: var(--sw-fg-3); }
+.for-svc b { color: var(--sw-fg-1); font-family: var(--sw-mono); font-weight: 500; }
+.hint { font-size: 10.5px; color: var(--sw-fg-3); }
+.ztr-conditions {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 8px 10px;
... 961 lines suppressed ...


(skywalking-horizon-ui) 01/03: ui: log condition rebuild + sidebar URL-driven focus + BFF service-id fix

Posted by wu...@apache.org.
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 07c5076677dbba1d3a75f3ec1efb5e80bd7268c2
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri May 15 09:34:27 2026 +0800

    ui: log condition rebuild + sidebar URL-driven focus + BFF service-id fix
    
    Logs
      - Conditions bar restructured to match the trace tab: head row with
        kicker + orange `Run query` button on the right, conditions in a
        4-col grid using `.cf` / `.cf-input` voice
      - `log.scope` typed as `'service' | 'instance' | 'endpoint'`; the
        three scopes drive which selectors render
      - Instance + Endpoint default to `All`; auto-pick disabled on logs
        (and traces) — operator opts into narrowing. Metrics-scope pages
        (Instance / Endpoint dashboards) keep their auto-pick
      - Endpoint search-and-select combobox (single input + dropdown,
        debounced OAP search-as-you-type, click-outside closes, × clears)
      - Tag autocomplete now uses OAP-native
        `queryLogTagAutocomplete{Keys,Values}` via new
        `/api/log-tags/{keys,values}` routes (replaces the sample-based
        suggest endpoint)
      - Tag input gets a `Tags` label; chips row reuses trace markup +
        css so the two pages read identically
      - Trace-ID is a plain input (no Pin/Clear button)
      - Time range with `Custom…` escape hatch (datetime-local inputs);
        BFF accepts explicit `startTime`/`endTime` alongside
        `windowMinutes`
      - YAML format detection, flat JSON / YAML inline preview that
        keeps row to one line (CSS-clipped), per-format chip on each row
      - Row click → full popout (no inline expand); ESC closes; copy
        button + tag table in popout
      - Density bar: x time / y count / level color + custom hover
        tooltip (replaces the `?` cursor + native title); 5 x-axis ticks
        underneath
      - Service chip dropped from the toolbar (duplicate with the layer
        header); sidecar picker still shown for instance-scope layers
    
    BFF service-id resolution
      - Tightened `<base64>.<digits>` heuristic across log / trace /
        instance / endpoint routes — the previous "contains `.`" check
        mis-classified mesh / k8s service names with embedded dots
        (`mesh-svr::r3-load.sample-services`) as ids and OAP returned
        empty / "service not found"
    
    Sidebar
      - URL-driven focus: navigating to `/layer/<key>/...` auto-expands
        the layer row AND its containing group, so deep links land with
        the right rails open + the active tab highlighted
      - Service tab visibility gated on `caps.dashboards` alone (drop
        the `slots.services` fallback). Fixes BanyanDB-style cases
        where the alias kept the tab visible after
        `components.service` was flipped off
      - Section headers (group + standalone) brightened, bolder font,
        accent-orange left rule on the open section, larger margin
      - General / Browser (ungrouped multi-feature) render as their own
        implicit section header so the hierarchy is consistent
      - `firstLayerTab(layer)` helper picks the first available sub-
        route from caps/slots; replaces the hardcoded `/service`
      - Drop the service-count chip from group rows
    
    Topbar
      - Breadcrumb uses layer aliases: `instance` → `Brokers` for
        ActiveMQ, `Sidecars` for mesh_dp, `Pages` for browser, …
      - Logs added to `TIME_RANGE_OPT_OUT` (already shipped, kept)
    
    Dashboard
      - First service auto-picked for every scope (not just instance) —
        Virtual MQ / Virtual Database land populated
      - Dashboard query gated on `service.value` for service/instance/
        endpoint scopes so it doesn't fire once with null
    
    mesh_dp
      - Instance widget set restored to the upstream OAP UI template
        (the envoy_cluster_* family I had dropped in 0.2.0 — the MAL
        comment block I read was deprecated; live UI templates DO ship
        those metrics)
    
    Layers
      - BanyanDB template removed (BanyanDB is monitored under
        self-observability rather than a standalone Databases layer;
        falls back to BFF defaults if OAP still exposes a `BANYANDB`
        layer)
      - mesh_dp keeps the Self-Observability sidecar focus
    
    Topology
      - Node hexagons keep the same envelope as before; metric line
        suppressed for nodes with no RPM; right detail rail hidden
        until selection
---
 .../bff/src/bundled_templates/layers/banyandb.json | 263 ----------------
 apps/bff/src/bundled_templates/layers/mesh_dp.json | 146 ++++++---
 .../bundled_templates/layers/virtual_genai.json    |   8 +-
 apps/bff/src/oap/endpoint-routes.ts                |   7 +-
 apps/bff/src/oap/instance-routes.ts                |  11 +-
 apps/bff/src/oap/log-routes.ts                     |  12 +-
 apps/bff/src/oap/menu-routes.ts                    |   2 +
 apps/bff/src/oap/trace-routes.ts                   |   9 +-
 apps/ui/src/components/shell/AppSidebar.vue        |  82 +++--
 apps/ui/src/components/shell/AppTopbar.vue         |  56 +++-
 apps/ui/src/composables/useLayers.ts               |   4 +-
 apps/ui/src/composables/useSelectedEndpoint.ts     |   2 +
 apps/ui/src/composables/useSelectedInstance.ts     |   2 +
 apps/ui/src/composables/useSelectedService.ts      |   7 +
 apps/ui/src/views/layer/LayerDashboardsView.vue    |  11 +-
 apps/ui/src/views/layer/LayerLogsView.vue          | 347 ++++++++++++++++-----
 apps/ui/src/views/layer/LayerShell.vue             |  19 +-
 packages/api-client/src/menu.ts                    |   2 +
 18 files changed, 554 insertions(+), 436 deletions(-)

diff --git a/apps/bff/src/bundled_templates/layers/banyandb.json b/apps/bff/src/bundled_templates/layers/banyandb.json
deleted file mode 100644
index 2e1aea7..0000000
--- a/apps/bff/src/bundled_templates/layers/banyandb.json
+++ /dev/null
@@ -1,263 +0,0 @@
-{
-  "key": "BANYANDB",
-  "alias": "BanyanDB",
-  "group": "Self-Observability",
-  "color": "var(--sw-cyan)",
-  "documentLink": "https://skywalking.apache.org/docs/main/next/en/banyandb/banyandb-introduction/",
-  "aliases": {
-    "services": "BanyanDB clusters",
-    "instances": "Nodes"
-  },
-  "components": {
-    "service": false,
-    "instances": true,
-    "endpoints": false,
-    "topology": false,
-    "traces": false,
-    "logs": false
-  },
-  "layer-header": {
-    "orderBy": "writeRate",
-    "columns": [
-      {
-        "metric": "writeRate",
-        "label": "Write/s",
-        "mqe": "latest(meter_banyandb_write_rate)",
-        "aggregation": "sum"
-      },
-      {
-        "metric": "queryRate",
-        "label": "Query/s",
-        "mqe": "latest(meter_banyandb_query_rate{method='query'})",
-        "aggregation": "sum"
-      },
-      {
-        "metric": "errRate",
-        "label": "Err/s",
-        "mqe": "latest(meter_banyandb_write_and_query_errors_rate)",
-        "aggregation": "sum"
-      },
-      {
-        "metric": "active",
-        "label": "Active",
-        "mqe": "meter_banyandb_active_instance",
-        "aggregation": "sum"
-      }
-    ]
-  },
-  "overview": {
-    "groups": [
-      {
-        "title": "Throughput",
-        "size": "auto",
-        "metrics": [
-          {
-            "id": "writeRate",
-            "label": "Write/s",
-            "tip": "Writes per second across the cluster.",
-            "mqe": "latest(meter_banyandb_write_rate)",
-            "aggregation": "sum"
-          },
-          {
-            "id": "queryRate",
-            "label": "Query/s",
-            "tip": "Queries per second.",
-            "mqe": "latest(meter_banyandb_query_rate{method='query'})",
-            "aggregation": "sum"
-          },
-          {
-            "id": "errRate",
-            "label": "Errors/s",
-            "tip": "Combined write + query error rate.",
-            "mqe": "latest(meter_banyandb_write_and_query_errors_rate)",
-            "aggregation": "sum"
-          }
-        ]
-      }
-    ]
-  },
-  "dashboards": {
-    "instance": [
-      {
-        "id": "write_rate",
-        "title": "Write Rate",
-        "type": "line",
-        "expressions": [
-          "latest(meter_banyandb_instance_write_rate)"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "query_rate",
-        "title": "Query Rate",
-        "type": "line",
-        "expressions": [
-          "latest(meter_banyandb_instance_query_rate{method='query'})"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "err_rate",
-        "title": "Error Rate",
-        "type": "line",
-        "expressions": [
-          "latest(meter_banyandb_instance_write_and_query_errors_rate)"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "memory",
-        "title": "Total Memory (GB)",
-        "type": "line",
-        "unit": "GB",
-        "expressions": [
-          "latest(meter_banyandb_instance_total_memory/1024/1024/1024)"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "disk",
-        "title": "Disk Usage (GB)",
-        "type": "line",
-        "unit": "GB",
-        "expressions": [
-          "latest(meter_banyandb_instance_disk_usage/1024/1024/1024)"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "cpu",
-        "title": "CPU",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_cpu_usage/1000"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "etcd",
-        "title": "Etcd Operation Rate",
-        "type": "line",
-        "expressions": [
-          "latest(meter_banyandb_instance_etcd_operation_rate)"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "rss",
-        "title": "RSS Memory",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_rss_memory_usage/1000"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "network",
-        "title": "Network (KB/s)",
-        "type": "line",
-        "unit": "KB/s",
-        "expressions": [
-          "meter_banyandb_instance_network_usage_recv/1024",
-          "meter_banyandb_instance_network_usage_sent/1024"
-        ],
-        "expressionLabels": [
-          "recv",
-          "sent"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "totals",
-        "title": "Total Data + Series",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_total_data",
-          "meter_banyandb_instance_total_series"
-        ],
-        "expressionLabels": [
-          "data",
-          "series"
-        ],
-        "span": 6,
-        "rowSpan": 2
-      },
-      {
-        "id": "merge",
-        "title": "Merge File Latency / Data / Partitions",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_merge_file_latency/1000",
-          "meter_banyandb_instance_merge_file_data",
-          "meter_banyandb_instance_merge_file_partitions"
-        ],
-        "expressionLabels": [
-          "latency",
-          "data",
-          "partitions"
-        ],
-        "span": 6,
-        "rowSpan": 2
-      },
-      {
-        "id": "stream_write",
-        "title": "Stream Write Rate",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_stream_write_rate"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "group_write",
-        "title": "Group Write Rate",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_storage_write_rate/1000"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "series_write",
-        "title": "Series Write Rate",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_series_write_rate/1000"
-        ],
-        "span": 4,
-        "rowSpan": 2
-      },
-      {
-        "id": "query_latency",
-        "title": "Query Latency",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_query_latency/1000"
-        ],
-        "span": 6,
-        "rowSpan": 2
-      },
-      {
-        "id": "term_search",
-        "title": "Term Search Rate",
-        "type": "line",
-        "expressions": [
-          "meter_banyandb_instance_term_search_rate/1000"
-        ],
-        "span": 6,
-        "rowSpan": 2
-      }
-    ]
-  }
-}
diff --git a/apps/bff/src/bundled_templates/layers/mesh_dp.json b/apps/bff/src/bundled_templates/layers/mesh_dp.json
index 48f17a0..1ca48e5 100644
--- a/apps/bff/src/bundled_templates/layers/mesh_dp.json
+++ b/apps/bff/src/bundled_templates/layers/mesh_dp.json
@@ -70,84 +70,156 @@
   "dashboards": {
     "instance": [
       {
-        "id": "connections",
-        "title": "Connections",
-        "tip": "Server-side total + parent connections (envoy_total_connections_used / envoy_parent_connections_used).",
+        "id": "up_rq_active",
+        "title": "Upstream Request Active",
+        "tip": "Total active upstream requests from this Envoy sidecar's clusters (envoy_cluster_up_rq_active).",
+        "type": "line",
+        "unit": "reqs",
+        "expressions": [
+          "envoy_cluster_up_rq_active"
+        ],
+        "span": 3,
+        "rowSpan": 2,
+        "format": "int"
+      },
+      {
+        "id": "connections_used",
+        "title": "Connections Used",
+        "tip": "Server-side total + parent connections used (envoy_total_connections_used / envoy_parent_connections_used).",
         "type": "line",
         "unit": "conns",
         "expressions": [
           "envoy_total_connections_used",
           "envoy_parent_connections_used"
         ],
-        "span": 4,
+        "expressionLabels": [
+          "total",
+          "parent"
+        ],
+        "span": 3,
         "rowSpan": 2,
         "format": "int"
       },
       {
-        "id": "worker_threads",
-        "title": "Worker Threads",
-        "tip": "Concurrent worker threads in use vs the max observed (envoy_worker_threads / envoy_worker_threads_max).",
+        "id": "membership_healthy",
+        "title": "Membership Healthy",
+        "tip": "Healthy endpoints across all outbound/inbound clusters (envoy_cluster_membership_healthy).",
         "type": "line",
-        "unit": "",
+        "unit": "endpoints",
         "expressions": [
-          "envoy_worker_threads",
-          "envoy_worker_threads_max"
+          "envoy_cluster_membership_healthy"
         ],
-        "span": 4,
+        "span": 3,
         "rowSpan": 2,
         "format": "int"
       },
+      {
+        "id": "up_cx_incr",
+        "title": "Upstream Connection Increase",
+        "tip": "New upstream connections opened per minute (envoy_cluster_up_cx_incr).",
+        "type": "line",
+        "unit": "/min",
+        "expressions": [
+          "envoy_cluster_up_cx_incr"
+        ],
+        "span": 3,
+        "rowSpan": 2,
+        "format": "int"
+      },
+      {
+        "id": "up_rq_pending",
+        "title": "Upstream Request Pending",
+        "tip": "Requests pending in upstream queues (envoy_cluster_up_rq_pending_active).",
+        "type": "line",
+        "unit": "reqs",
+        "expressions": [
+          "envoy_cluster_up_rq_pending_active"
+        ],
+        "span": 3,
+        "rowSpan": 2,
+        "format": "int"
+      },
+      {
+        "id": "heap_memory",
+        "title": "Server Memory",
+        "tip": "Heap / allocated / physical memory: current + window max (envoy_heap_memory_used / envoy_memory_allocated / envoy_memory_physical_size, paired with their *_max variants).",
+        "type": "line",
+        "unit": "bytes",
+        "expressions": [
+          "envoy_heap_memory_used",
+          "envoy_heap_memory_max_used",
+          "envoy_memory_allocated",
+          "envoy_memory_allocated_max",
+          "envoy_memory_physical_size",
+          "envoy_memory_physical_size_max"
+        ],
+        "expressionLabels": [
+          "heap",
+          "heap max",
+          "allocated",
+          "allocated max",
+          "physical",
+          "physical max"
+        ],
+        "span": 6,
+        "rowSpan": 3
+      },
       {
         "id": "bug_failures",
         "title": "Bug Failures",
-        "tip": "Envoy internal bug-failure counter (envoy_bug_failures). Should normally be zero \u2014 non-zero indicates an Envoy assertion / debug check tripped.",
+        "tip": "Envoy internal bug-failure counter \u2014 non-zero indicates an assertion / debug check tripped (envoy_bug_failures).",
         "type": "line",
         "unit": "count",
         "expressions": [
           "envoy_bug_failures"
         ],
-        "span": 4,
+        "span": 3,
         "rowSpan": 2,
         "format": "int"
       },
       {
-        "id": "heap_memory",
-        "title": "Heap Memory",
-        "tip": "Server heap size in bytes \u2014 current value + window max (envoy_heap_memory_used / envoy_heap_memory_max_used).",
+        "id": "worker_threads",
+        "title": "Worker Threads",
+        "tip": "Concurrent worker threads in use vs the window max (envoy_worker_threads / envoy_worker_threads_max).",
         "type": "line",
-        "unit": "bytes",
+        "unit": "threads",
         "expressions": [
-          "envoy_heap_memory_used",
-          "envoy_heap_memory_max_used"
+          "envoy_worker_threads",
+          "envoy_worker_threads_max"
+        ],
+        "expressionLabels": [
+          "current",
+          "max"
         ],
-        "span": 4,
-        "rowSpan": 2
+        "span": 3,
+        "rowSpan": 2,
+        "format": "int"
       },
       {
-        "id": "memory_allocated",
-        "title": "Memory Allocated",
-        "tip": "Server allocated memory (envoy_memory_allocated / envoy_memory_allocated_max).",
+        "id": "up_cx_active",
+        "title": "Upstream Connection Active",
+        "tip": "Active upstream connections (envoy_cluster_up_cx_active).",
         "type": "line",
-        "unit": "bytes",
+        "unit": "conns",
         "expressions": [
-          "envoy_memory_allocated",
-          "envoy_memory_allocated_max"
+          "envoy_cluster_up_cx_active"
         ],
-        "span": 4,
-        "rowSpan": 2
+        "span": 3,
+        "rowSpan": 2,
+        "format": "int"
       },
       {
-        "id": "memory_physical",
-        "title": "Physical Memory",
-        "tip": "Server physical memory footprint (envoy_memory_physical_size / envoy_memory_physical_size_max).",
+        "id": "up_rq_incr",
+        "title": "Upstream Request Increase",
+        "tip": "New upstream requests per minute (envoy_cluster_up_rq_incr).",
         "type": "line",
-        "unit": "bytes",
+        "unit": "/min",
         "expressions": [
-          "envoy_memory_physical_size",
-          "envoy_memory_physical_size_max"
+          "envoy_cluster_up_rq_incr"
         ],
-        "span": 4,
-        "rowSpan": 2
+        "span": 3,
+        "rowSpan": 2,
+        "format": "int"
       }
     ]
   }
diff --git a/apps/bff/src/bundled_templates/layers/virtual_genai.json b/apps/bff/src/bundled_templates/layers/virtual_genai.json
index f56c6f5..fb62934 100644
--- a/apps/bff/src/bundled_templates/layers/virtual_genai.json
+++ b/apps/bff/src/bundled_templates/layers/virtual_genai.json
@@ -6,12 +6,12 @@
   "documentLink": "https://skywalking.apache.org/docs/main/next/en/setup/service-agent/virtual-genai/",
   "aliases": {
     "services": "GenAI Providers",
-    "endpoints": "Models"
+    "instances": "Models"
   },
   "components": {
     "service": true,
-    "instances": false,
-    "endpoints": true,
+    "instances": true,
+    "endpoints": false,
     "topology": false,
     "traces": false,
     "logs": false
@@ -179,7 +179,7 @@
         "rowSpan": 2
       }
     ],
-    "endpoint": [
+    "instance": [
       {
         "id": "cpm",
         "title": "Calls / min",
diff --git a/apps/bff/src/oap/endpoint-routes.ts b/apps/bff/src/oap/endpoint-routes.ts
index eb80273..d910341 100644
--- a/apps/bff/src/oap/endpoint-routes.ts
+++ b/apps/bff/src/oap/endpoint-routes.ts
@@ -118,10 +118,11 @@ export function registerEndpointRoute(app: FastifyInstance, deps: EndpointRouteD
       const opts = buildOapOpts(cfgCurrent, deps.fetch);
       const window = defaultWindow();
 
-      // Resolve a plain service name to an OAP id when needed (id-shaped
-      // values contain a `.` separator; names don't).
+      // OAP service-id shape: `<base64>.<digits>`. Anything else
+      // (including names like `mesh-svr::r3-load.sample-services` that
+      // embed `.`) needs a `listServices` lookup.
       let serviceId = serviceArg;
-      if (!serviceArg.includes('.') || /\s/.test(serviceArg)) {
+      if (!/^[A-Za-z0-9+/=]+\.\d+$/.test(serviceArg)) {
         try {
           const data = await graphqlPost<{
             services: Array<{ id: string; name: string; normal?: boolean }>;
diff --git a/apps/bff/src/oap/instance-routes.ts b/apps/bff/src/oap/instance-routes.ts
index bb17821..457a93b 100644
--- a/apps/bff/src/oap/instance-routes.ts
+++ b/apps/bff/src/oap/instance-routes.ts
@@ -130,11 +130,14 @@ export function registerInstanceRoute(app: FastifyInstance, deps: InstanceRouteD
       const cfgCurrent = deps.config.current;
       const opts = buildOapOpts(cfgCurrent, deps.fetch);
       const window = defaultWindow();
-      // OAP-side ids look like base64-ish blobs (e.g. "Y2hlY2tvdXQ=.1");
-      // names are plain words. If we don't see a `.` separator, treat
-      // the arg as a name and resolve to an id first.
+      // OAP service-id shape: `<base64>.<digits>` (e.g.
+      // `Y2hlY2tvdXQ=.1`). Anything else — including names that
+      // happen to contain `.` like `mesh-svr::r3-load.sample-services`
+      // — needs a `listServices` lookup. The earlier "contains `.`"
+      // heuristic was too loose and broke mesh / k8s_service instance
+      // queries whose service names embed dots.
       let serviceId = serviceArg;
-      if (!serviceArg.includes('.') || /\s/.test(serviceArg)) {
+      if (!/^[A-Za-z0-9+/=]+\.\d+$/.test(serviceArg)) {
         try {
           const data = await graphqlPost<ListServicesResp>(opts, LIST_SERVICES_FOR_RESOLVE, {
             layer: layerKey.toUpperCase(),
diff --git a/apps/bff/src/oap/log-routes.ts b/apps/bff/src/oap/log-routes.ts
index 7026fef..9813628 100644
--- a/apps/bff/src/oap/log-routes.ts
+++ b/apps/bff/src/oap/log-routes.ts
@@ -121,13 +121,23 @@ interface OapLogRow {
   tags?: LogKeyValue[] | null;
 }
 
+/**
+ * Resolve a service argument to an OAP service id. The arg can be
+ * either a name (`mesh-svr::songs.sample-services`) or an id
+ * (`bWVzaC1zdnI6OnNvbmdzLnNhbXBsZS1zZXJ2aWNlcw==.1`). OAP ids are
+ * `<base64>.<digits>` — match strictly to avoid the previous bug
+ * where a name containing `.` (e.g. `*.sample-services`) was wrongly
+ * accepted as an id, leading to OAP returning empty / "service not
+ * found" on the log query.
+ */
+const OAP_SERVICE_ID_RE = /^[A-Za-z0-9+/=]+\.\d+$/;
 async function resolveServiceId(
   opts: GraphqlOptions,
   layer: string,
   serviceArg: string,
 ): Promise<string | null> {
   if (!serviceArg) return null;
-  if (serviceArg.includes('.') && !/\s/.test(serviceArg)) return serviceArg;
+  if (OAP_SERVICE_ID_RE.test(serviceArg)) return serviceArg;
   const data = await graphqlPost<{ services: Array<{ id: string; name: string }> }>(
     opts,
     LIST_SERVICES_FOR_RESOLVE,
diff --git a/apps/bff/src/oap/menu-routes.ts b/apps/bff/src/oap/menu-routes.ts
index aff03a4..8873723 100644
--- a/apps/bff/src/oap/menu-routes.ts
+++ b/apps/bff/src/oap/menu-routes.ts
@@ -38,6 +38,8 @@ import { getLayerTemplate, type LayerComponentFlags } from '../layers/loader.js'
 function componentsToCaps(components: LayerComponentFlags): LayerCaps {
   return {
     dashboards: components.service !== false,
+    instances: !!components.instances,
+    endpoints: !!components.endpoints,
     endpointDependency: !!components.endpointDependency,
     serviceMap: !!components.topology,
     instanceTopology: !!components.topology,
diff --git a/apps/bff/src/oap/trace-routes.ts b/apps/bff/src/oap/trace-routes.ts
index 0dc7be3..709145c 100644
--- a/apps/bff/src/oap/trace-routes.ts
+++ b/apps/bff/src/oap/trace-routes.ts
@@ -210,15 +210,18 @@ const QUERY_TRACE_DETAIL = /* GraphQL */ `
 
 // ── Helpers ────────────────────────────────────────────────────────
 
+// OAP service-id shape: `<base64>.<digits>`. Match strictly so we
+// don't mis-classify names containing `.` (e.g. `*.sample-services`)
+// as ids — the earlier "contains `.` and no whitespace" heuristic was
+// too loose and broke trace queries on mesh-layer services.
+const OAP_SERVICE_ID_RE = /^[A-Za-z0-9+/=]+\.\d+$/;
 async function resolveServiceId(
   opts: GraphqlOptions,
   layer: string,
   serviceArg: string,
 ): Promise<string | null> {
   if (!serviceArg) return null;
-  // Ids contain `.`; names rarely do. Short-circuit when it's already
-  // an id.
-  if (serviceArg.includes('.') && !/\s/.test(serviceArg)) return serviceArg;
+  if (OAP_SERVICE_ID_RE.test(serviceArg)) return serviceArg;
   const data = await graphqlPost<{
     services: Array<{ id: string; name: string }>;
   }>(opts, LIST_SERVICES_FOR_RESOLVE, { layer: layer.toUpperCase() });
diff --git a/apps/ui/src/components/shell/AppSidebar.vue b/apps/ui/src/components/shell/AppSidebar.vue
index 433d702..f8a973e 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -42,8 +42,14 @@ const orderedLayers = useLandingOrder(availableLayers);
  * page (which IS the dashboard for virtual / cache / database / MQ
  * scopes). */
 type SidebarLayer = (typeof orderedLayers.value)[number];
+function hasInstances(L: SidebarLayer): boolean {
+  return L.caps.instances ?? Boolean(L.slots.instances);
+}
+function hasEndpoints(L: SidebarLayer): boolean {
+  return L.caps.endpoints ?? Boolean(L.slots.endpoints);
+}
 function isSingleFeatureLayer(L: SidebarLayer): boolean {
-  if (L.slots.instances || L.slots.endpoints) return false;
+  if (hasInstances(L) || hasEndpoints(L)) return false;
   if (hasTopology(L)) return false;
   const c = L.caps;
   if (c.traces || c.logs || c.traceProfiling || c.ebpfProfiling || c.asyncProfiling || c.events) return false;
@@ -51,20 +57,16 @@ function isSingleFeatureLayer(L: SidebarLayer): boolean {
   return true;
 }
 
-// Default-open the first available layer once data arrives; user clicks
-// thereafter take over.
+// Which layer's row is expanded — the tab strip (Service / Instance /
+// Endpoint / Topology / …) renders only beneath the expanded layer.
+// Auto-driven by the URL: whatever layer the route is on stays
+// expanded so a cold reload of `/layer/<key>/...` shows the tabs +
+// the active tab highlighted. The initial-load watcher only fires
+// when the route ISN'T on a layer page (the top-level / overview) —
+// in that case we pick the first available layer so the sidebar
+// doesn't look closed on first visit.
 const expandedLayer = ref<string | null>(null);
-let userTouched = false;
-watch(
-  orderedLayers,
-  (rows) => {
-    if (userTouched || expandedLayer.value) return;
-    if (rows.length > 0) expandedLayer.value = rows[0].key;
-  },
-  { immediate: true },
-);
 function toggleLayer(key: string): void {
-  userTouched = true;
   expandedLayer.value = expandedLayer.value === key ? null : key;
 }
 
@@ -116,17 +118,35 @@ function isActive(path: string): boolean {
   return route.path === path || route.path.startsWith(path + '/');
 }
 
-// Auto-open the group that contains the active layer so reload-on-URL
-// doesn't hide the user's current location behind a collapsed section.
+// URL-driven sidebar focus. When the route is on a layer page, the
+// containing group expands AND the layer's row expands so the tabs +
+// active-tab highlight are visible. Fires on every route change so
+// deep-linking from outside (bookmark, paste-in) lands the operator
+// with the right rails open. When the route ISN'T on a layer, fall
+// back to the first available layer so the sidebar isn't empty on
+// the overview / setup pages.
 watch(
-  () => route.path,
-  () => {
-    const m = route.path.match(/^\/layer\/([^/]+)/);
-    if (!m) return;
-    const key = m[1];
-    const L = orderedLayers.value.find((l) => l.key === key);
-    if (L?.group && !openGroups.value.has(L.group)) {
-      openGroups.value = new Set([...openGroups.value, L.group]);
+  [() => route.path, orderedLayers],
+  ([path, rows]) => {
+    const m = path.match(/^\/layer\/([^/]+)/);
+    if (m) {
+      const key = m[1];
+      const L = rows.find((l) => l.key === key);
+      if (L) {
+        // Expand this layer's tab strip.
+        expandedLayer.value = key;
+        // Open its parent group if grouped.
+        if (L.group && !openGroups.value.has(L.group)) {
+          openGroups.value = new Set([...openGroups.value, L.group]);
+        }
+      }
+      return;
+    }
+    // No layer in URL — only seed the default expansion if nothing is
+    // currently expanded (don't yank a layer the operator was looking
+    // at on the previous route).
+    if (!expandedLayer.value && rows.length > 0) {
+      expandedLayer.value = rows[0].key;
     }
   },
   { immediate: true },
@@ -291,20 +311,20 @@ const sections: NavSection[] = [
                   <span class="sw-badge" style="margin-left: auto">{{ L.serviceCount }}</span>
                 </RouterLink>
                 <RouterLink
-                  v-if="L.slots.instances"
+                  v-if="hasInstances(L)"
                   :to="`/layer/${L.key}/instance`"
                   class="sw-nav-item"
                   :class="{ 'is-active': isActive(`/layer/${L.key}/instance`) }"
                 >
-                  <Icon name="prof" /><span>{{ L.slots.instances }}</span>
+                  <Icon name="prof" /><span>{{ L.slots.instances ?? 'Instance' }}</span>
                 </RouterLink>
                 <RouterLink
-                  v-if="L.slots.endpoints"
+                  v-if="hasEndpoints(L)"
                   :to="`/layer/${L.key}/endpoint`"
                   class="sw-nav-item"
                   :class="{ 'is-active': isActive(`/layer/${L.key}/endpoint`) }"
                 >
-                  <Icon name="ep" /><span>{{ L.slots.endpoints }}</span>
+                  <Icon name="ep" /><span>{{ L.slots.endpoints ?? 'Endpoint' }}</span>
                 </RouterLink>
                 <RouterLink
                   v-if="hasTopology(L)"
@@ -412,20 +432,20 @@ const sections: NavSection[] = [
             <span class="sw-badge" style="margin-left: auto">{{ E.layer.serviceCount }}</span>
           </RouterLink>
           <RouterLink
-            v-if="E.layer.slots.instances"
+            v-if="hasInstances(E.layer)"
             :to="`/layer/${E.layer.key}/instance`"
             class="sw-nav-item"
             :class="{ 'is-active': isActive(`/layer/${E.layer.key}/instance`) }"
           >
-            <Icon name="prof" /><span>{{ E.layer.slots.instances }}</span>
+            <Icon name="prof" /><span>{{ E.layer.slots.instances ?? 'Instance' }}</span>
           </RouterLink>
           <RouterLink
-            v-if="E.layer.slots.endpoints"
+            v-if="hasEndpoints(E.layer)"
             :to="`/layer/${E.layer.key}/endpoint`"
             class="sw-nav-item"
             :class="{ 'is-active': isActive(`/layer/${E.layer.key}/endpoint`) }"
           >
-            <Icon name="ep" /><span>{{ E.layer.slots.endpoints }}</span>
+            <Icon name="ep" /><span>{{ E.layer.slots.endpoints ?? 'Endpoint' }}</span>
           </RouterLink>
           <RouterLink
             v-if="hasTopology(E.layer)"
diff --git a/apps/ui/src/components/shell/AppTopbar.vue b/apps/ui/src/components/shell/AppTopbar.vue
index 0e5c466..d0ce812 100644
--- a/apps/ui/src/components/shell/AppTopbar.vue
+++ b/apps/ui/src/components/shell/AppTopbar.vue
@@ -19,15 +19,67 @@ import { computed, ref, watch } from 'vue';
 import { RouterLink, useRoute } from 'vue-router';
 import Icon from '@/components/icons/Icon.vue';
 import { useOapInfo } from '@/composables/useOapInfo';
+import { useLayers } from '@/composables/useLayers';
 import { useAutoRefreshStore } from '@/stores/autoRefresh';
 
 const route = useRoute();
+const { layers } = useLayers();
 
-// Trivial breadcrumb derivation from the path. Real breadcrumb metadata
-// lands when individual views start setting `route.meta.breadcrumbs`.
+/**
+ * Breadcrumb derived from the route path PLUS the layer's display
+ * config so the trail reads the same as the sidebar. For
+ * `/layer/<key>/<scope>` we:
+ *   - Replace the layer key with its alias (`activemq` → `ActiveMQ`).
+ *   - Replace the scope segment with the layer's slot alias when one
+ *     exists (`instance` → `Brokers` for ActiveMQ, `Sidecars` for
+ *     mesh_dp, `Pages` for browser, …). Falls back to the
+ *     capitalized URL segment when no alias applies.
+ *
+ * The mapping lives here (and not in the route definition) because
+ * the layer JSON is the source of truth for the operator-facing
+ * terms; the route segments stay in the canonical `instance` /
+ * `endpoint` / etc. shape for back-compat with bookmarks.
+ */
+const SCOPE_SLOT_KEY: Record<string, 'instances' | 'endpoints' | 'services' | 'endpointDependency'> = {
+  instance: 'instances',
+  endpoint: 'endpoints',
+  service: 'services',
+  dependency: 'endpointDependency',
+};
+const SCOPE_LITERAL: Record<string, string> = {
+  topology: 'Topology',
+  trace: 'Traces',
+  logs: 'Logs',
+  'trace-profiling': 'Trace Profiling',
+  'ebpf-profiling': 'eBPF Profiling',
+  'async-profiling': 'Async Profiling',
+};
 const crumbs = computed<string[]>(() => {
   const segs = route.path.split('/').filter(Boolean);
   if (segs.length === 0) return ['Home'];
+  // Layer-aware path: `/layer/<key>/<scope?>/...`
+  if (segs[0] === 'layer' && segs[1]) {
+    const layerKey = segs[1];
+    const layer = layers.value.find((l) => l.key === layerKey);
+    const out: string[] = [layer?.name ?? layerKey.replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase())];
+    for (let i = 2; i < segs.length; i++) {
+      const seg = segs[i];
+      // Slot alias (services/instances/endpoints/dependency).
+      const slotKey = SCOPE_SLOT_KEY[seg];
+      if (slotKey && layer?.slots?.[slotKey]) {
+        out.push(String(layer.slots[slotKey]));
+        continue;
+      }
+      // Known literal scope (topology / trace / logs / profilings).
+      if (SCOPE_LITERAL[seg]) {
+        out.push(SCOPE_LITERAL[seg]);
+        continue;
+      }
+      // Fallback: capitalize the segment.
+      out.push(seg.replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase()));
+    }
+    return out;
+  }
   return segs.map((s) => s.replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase()));
 });
 
diff --git a/apps/ui/src/composables/useLayers.ts b/apps/ui/src/composables/useLayers.ts
index 7776f7f..daade64 100644
--- a/apps/ui/src/composables/useLayers.ts
+++ b/apps/ui/src/composables/useLayers.ts
@@ -95,8 +95,8 @@ export function useLayers() {
 export function firstLayerTab(L: LayerDef | undefined): string {
   if (!L) return 'service';
   if (L.slots?.services || L.caps?.dashboards) return 'service';
-  if (L.slots?.instances) return 'instance';
-  if (L.slots?.endpoints) return 'endpoint';
+  if (L.caps?.instances ?? Boolean(L.slots?.instances)) return 'instance';
+  if (L.caps?.endpoints ?? Boolean(L.slots?.endpoints)) return 'endpoint';
   if (L.caps?.serviceMap || L.caps?.instanceTopology || L.caps?.processTopology) return 'topology';
   if (L.caps?.endpointDependency) return 'dependency';
   if (L.caps?.traces) return 'trace';
diff --git a/apps/ui/src/composables/useSelectedEndpoint.ts b/apps/ui/src/composables/useSelectedEndpoint.ts
index 6db5367..df80174 100644
--- a/apps/ui/src/composables/useSelectedEndpoint.ts
+++ b/apps/ui/src/composables/useSelectedEndpoint.ts
@@ -36,6 +36,8 @@ export function useSelectedEndpoint() {
   });
 
   function setSelectedEndpoint(name: string | null): void {
+    const current = typeof route.query.endpoint === 'string' ? route.query.endpoint : null;
+    if (name === current) return;
     const next = { ...route.query };
     if (name) next.endpoint = name;
     else delete next.endpoint;
diff --git a/apps/ui/src/composables/useSelectedInstance.ts b/apps/ui/src/composables/useSelectedInstance.ts
index 4707e41..f284182 100644
--- a/apps/ui/src/composables/useSelectedInstance.ts
+++ b/apps/ui/src/composables/useSelectedInstance.ts
@@ -42,6 +42,8 @@ export function useSelectedInstance() {
   });
 
   function setSelectedInstance(name: string | null): void {
+    const current = typeof route.query.instance === 'string' ? route.query.instance : null;
+    if (name === current) return;
     const next = { ...route.query };
     if (name) next.instance = name;
     else delete next.instance;
diff --git a/apps/ui/src/composables/useSelectedService.ts b/apps/ui/src/composables/useSelectedService.ts
index 726144e..a8d84b5 100644
--- a/apps/ui/src/composables/useSelectedService.ts
+++ b/apps/ui/src/composables/useSelectedService.ts
@@ -38,8 +38,15 @@ export function useSelectedService() {
 
   function setSelected(id: string | null): void {
     const next = { ...route.query };
+    const current = typeof route.query.service === 'string' ? route.query.service : null;
+    if (id === current) return;
     if (id) next.service = id;
     else delete next.service;
+    // Instance / endpoint choices are derived from the selected service.
+    // When the service changes, drop the narrower entity so each
+    // dashboard can auto-pick from the new service's own list.
+    delete next.instance;
+    delete next.endpoint;
     // `replace` instead of `push` — switching services shouldn't bloat
     // the browser back stack with N entries.
     void router.replace({ path: route.path, query: next });
diff --git a/apps/ui/src/views/layer/LayerDashboardsView.vue b/apps/ui/src/views/layer/LayerDashboardsView.vue
index 49c0042..f7995b8 100644
--- a/apps/ui/src/views/layer/LayerDashboardsView.vue
+++ b/apps/ui/src/views/layer/LayerDashboardsView.vue
@@ -125,10 +125,11 @@ const expandedInstance = ref<string | null>(null);
 const { setSelected: setSelectedService } = useSelectedService();
 const landingRows = computed(() => landing.data.value?.sampledRows ?? landing.rows.value ?? []);
 watch(landingRows, (rows) => {
-  if (selectedId.value) return;
   const first = rows[0];
   if (!first) return;
-  setSelectedService(first.serviceId);
+  if (!selectedId.value || !rows.some((r) => r.serviceId === selectedId.value)) {
+    setSelectedService(first.serviceId);
+  }
 }, { immediate: true });
 // Drop the stale instance whenever the service changes — the new
 // service's instance list almost never matches the previous pick.
@@ -142,7 +143,11 @@ watch(serviceName, (next, prev) => {
 // their URL on every visit).
 watch([instanceList, scope], ([list, s]) => {
   if (s !== 'instance') return;
-  if (!selectedInstance.value && list.length > 0) {
+  if (list.length === 0) {
+    if (selectedInstance.value) setSelectedInstance(null);
+    return;
+  }
+  if (!selectedInstance.value || !list.some((i) => i.name === selectedInstance.value)) {
     setSelectedInstance(list[0].name);
   }
 });
diff --git a/apps/ui/src/views/layer/LayerLogsView.vue b/apps/ui/src/views/layer/LayerLogsView.vue
index 4af3707..8a589a3 100644
--- a/apps/ui/src/views/layer/LayerLogsView.vue
+++ b/apps/ui/src/views/layer/LayerLogsView.vue
@@ -69,9 +69,11 @@ const landingRows = computed(() => landing.data.value?.sampledRows ?? landing.ro
 watch(
   landingRows,
   (rows) => {
-    if (selectedId.value) return;
     const first = rows[0];
-    if (first) setSelectedService(first.serviceId);
+    if (!first) return;
+    if (!selectedId.value || !rows.some((r) => r.serviceId === selectedId.value)) {
+      setSelectedService(first.serviceId);
+    }
   },
   { immediate: true },
 );
@@ -99,18 +101,11 @@ const showEndpointSelector = computed(() => logScope.value !== 'endpoint');
 //   - `endpoint` scope: optional narrower as well.
 const { selectedInstance, setSelectedInstance } = useSelectedInstance();
 const { instances: instanceList } = useLayerInstances(layerKey, serviceName);
-// Auto-pick the first instance ONLY when the layer pins instance via
-// `log.scope === 'instance'` (mesh_dp sidecar logs). For service /
-// endpoint scopes the picker stays empty by default so the operator's
-// landing view is "all instances of the picked service" — auto-binding
-// to one instance narrows the query and routinely returns zero rows
-// when that specific instance happens to be quiet.
-watch([instanceList, logScope], ([list, scope]) => {
-  if (scope !== 'instance') return;
-  if (selectedInstance.value) return;
-  const first = list[0];
-  if (first) setSelectedInstance(first.name);
-});
+// Logs (and traces) intentionally do NOT auto-select an instance.
+// Default is `All` so the stream starts broad; the operator opts into
+// narrowing by picking from the dropdown. Auto-selection is reserved
+// for metrics-scope pages (instance / endpoint dashboards), where a
+// chosen entity is needed to render the metric widgets at all.
 watch(serviceName, (next, prev) => {
   if (prev !== undefined && next !== prev && selectedInstance.value) {
     setSelectedInstance(null);
@@ -167,16 +162,8 @@ const { endpoints: endpointList, isFetching: endpointsLoading } = useLayerEndpoi
   endpointQuery,
   endpointLimit,
 );
-// Auto-pick first endpoint ONLY when the layer pins endpoint via
-// `log.scope === 'endpoint'`. Same reasoning as the instance picker:
-// auto-pinning narrows the query and the operator typically wants the
-// landing view to be "any endpoint of the picked service".
-watch([endpointList, logScope], ([list, scope]) => {
-  if (scope !== 'endpoint') return;
-  if (selectedEndpoint.value) return;
-  const first = list[0];
-  if (first) setSelectedEndpoint(first.name);
-});
+// No endpoint auto-pick on Logs either — same reasoning as the
+// instance picker above. Default is `All`; operator narrows by hand.
 watch(serviceName, (next, prev) => {
   if (prev !== undefined && next !== prev && selectedEndpoint.value) {
     setSelectedEndpoint(null);
@@ -467,15 +454,11 @@ const levelFacet = computed<Record<Level, number>>(() => {
 // reflect it — no client-side narrowing needed.
 const filteredLogs = computed<LogRow[]>(() => logs.value);
 
-// ── Row expand state. Loki / Datadog use inline expand for the
-// detail rather than a separate right pane. ----------------------
-const expandedId = ref<string | null>(null);
+// Row keys for `<template v-for>`. Inline expand is gone — click
+// now opens the full-canvas popout via `onRowClick(r)`.
 function rowKey(r: LogRow, idx: number): string {
   return `${r.timestamp}-${r.traceId ?? ''}-${idx}`;
 }
-function toggleExpand(key: string): void {
-  expandedId.value = expandedId.value === key ? null : key;
-}
 
 function fmtTime(ts: number): string {
   const d = new Date(ts);
@@ -486,10 +469,50 @@ function fmtDate(ts: number): string {
   const d = new Date(ts);
   return d.toLocaleDateString(undefined, { month: 'short', day: '2-digit' });
 }
+/**
+ * Render a one-line inline preview. Length is NOT capped here — the
+ * row's CSS (`.lg-content-body { white-space: nowrap; overflow:
+ * hidden; text-overflow: ellipsis }`) clips at the actual visible
+ * width, so wider viewports surface more of the payload while narrow
+ * ones still get a clean ellipsis. The slicing-at-220-chars heuristic
+ * I had before always cut at the same offset regardless of width.
+ *
+ *   - JSON: re-serialize to the tightest single-line form so the row
+ *     reads as `{"level":"error","msg":"…"}` and not the raw
+ *     whitespace-laden source.
+ *   - YAML: collapse newlines into a single space so the row still
+ *     carries the keys (`apiVersion: v1 kind: Pod spec: containers:
+ *     - name: nginx`). Indentation chars stay, which preserves a
+ *     visual sense of the hierarchy even on one line. The popout is
+ *     the right place to read the proper multi-line structure.
+ *   - Text: whitespace collapsed, same as before.
+ */
 function summariseContent(r: LogRow): string {
   if (!r.content) return '';
-  const oneLine = r.content.replace(/\s+/g, ' ').trim();
-  return oneLine.length > 220 ? oneLine.slice(0, 218) + '…' : oneLine;
+  const fmt = detectFormat(r);
+  if (fmt === 'json') {
+    try {
+      return JSON.stringify(JSON.parse(r.content));
+    } catch {
+      /* fall through to the plain compaction below */
+    }
+  }
+  if (fmt === 'yaml') {
+    // Replace newlines with a single space — keeps the indentation
+    // characters that sit at the start of each line, which gives the
+    // operator a visual cue of nesting depth even when flattened.
+    return r.content.replace(/\n+/g, ' ').trim();
+  }
+  // Plain text — collapse any runs of whitespace.
+  return r.content.replace(/\s+/g, ' ').trim();
+}
+/** With the new flattening rule above, every payload has a usable
+ *  inline preview — JSON, YAML, and multi-line text all flatten to
+ *  one line cleanly. We no longer need a hidden-payload affordance.
+ *  Returns false unconditionally; kept so the template doesn't have
+ *  to change. */
+function hasHiddenPayload(_r: LogRow): boolean {
+  return false;
 }
 function tryPrettyJson(content: string): string {
   try {
@@ -546,6 +569,49 @@ async function copyPopout(): Promise<void> {
     /* clipboard may be blocked; silently no-op */
   }
 }
+// ESC closes the popout. Bound on the window so it works whether or
+// not the popout has keyboard focus (clicking the modal sometimes
+// drops focus into the underlying row).
+function onGlobalKeydown(ev: KeyboardEvent): void {
+  if (ev.key === 'Escape' && popoutRow.value) {
+    ev.preventDefault();
+    closePopout();
+  }
+}
+if (typeof window !== 'undefined') {
+  window.addEventListener('keydown', onGlobalKeydown);
+}
+
+/**
+ * Row click → open popout. The inline expand is intentionally gone —
+ * for multi-line / YAML / JSON payloads it rendered as a cramped strip
+ * inside the row band; the popout has the full canvas. Trace-id chip
+ * and group decoder still propagate stop-events so their own clicks
+ * don't bubble.
+ */
+function onRowClick(r: LogRow): void {
+  openPopout(r);
+}
+
+// Custom hover tooltip state for the density bar. Native browser
+// `title` was making the cursor render as `?` (help-cursor) instead
+// of showing the count, which read like a UI bug.
+const hoveredBin = ref<number | null>(null);
+function fmtBucketRange(idx: number, t0: number, t1: number): string {
+  if (!t0 || !t1) return '';
+  const span = (t1 - t0) || 1;
+  const start = new Date(t0 + (span * idx) / BINS);
+  const end = new Date(t0 + (span * (idx + 1)) / BINS);
+  const pad = (n: number) => String(n).padStart(2, '0');
+  const fmt = (d: Date) => `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+  return `${fmt(start)} – ${fmt(end)}`;
+}
+function fmtAxisTime(ts: number): string {
+  if (!ts) return '';
+  const d = new Date(ts);
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
 
 /** Open the trace in the global popout overlay rather than navigating
  *  to the Traces tab — keeps the operator in the log stream, lets them
@@ -724,23 +790,60 @@ function jumpToTrace(traceId: string): void {
           </span>
         </div>
 
-        <!-- Density bar -->
-        <div class="lg-density" v-if="histogram.bins.length > 0">
-          <div
-            v-for="(bin, i) in histogram.bins"
-            :key="i"
-            class="lg-density-bin"
-            :title="histogram.t0 ? new Date(histogram.t0 + ((histogram.t1 - histogram.t0) * (i + 0.5)) / 60).toLocaleString() : ''"
-          >
-            <span
-              v-for="l in LEVEL_ORDER"
-              :key="l"
-              class="lg-density-segment"
-              :style="{
-                background: LEVEL_COLOR[l],
-                height: histogram.max ? (bin[l] / histogram.max * 100) + '%' : '0%',
-              }"
-            />
+        <!-- Density bar — x: time, y: count, color: level. Hover a
+             bin: a custom tooltip (NOT the native `title` — the
+             native cursor was rendering as a help cursor `?` instead
+             of the count, which was confusing) shows the bucket time
+             range + per-level counts. Axis tick labels under the bar
+             carry the time scale. -->
+        <div class="lg-density-wrap" v-if="histogram.bins.length > 0" @mouseleave="hoveredBin = null">
+          <div class="lg-density">
+            <div
+              v-for="(bin, i) in histogram.bins"
+              :key="i"
+              class="lg-density-bin"
+              @mouseenter="hoveredBin = i"
+            >
+              <span
+                v-for="l in LEVEL_ORDER"
+                :key="l"
+                class="lg-density-segment"
+                :style="{
+                  background: LEVEL_COLOR[l],
+                  height: histogram.max ? (bin[l] / histogram.max * 100) + '%' : '0%',
+                }"
+              />
+            </div>
+            <!-- Custom hover tooltip — replaces the native browser
+                 tooltip which was both slow to appear AND coupled to
+                 the `cursor: help` rendering (the `?` cursor was the
+                 thing the operator was reporting). -->
+            <div
+              v-if="hoveredBin !== null"
+              class="lg-density-tip"
+              :style="{ left: ((hoveredBin + 0.5) / 60) * 100 + '%' }"
+            >
+              <div class="lg-density-tip-time">
+                {{ fmtBucketRange(hoveredBin, histogram.t0, histogram.t1) }}
+              </div>
+              <div class="lg-density-tip-total">
+                {{ histogram.bins[hoveredBin].error + histogram.bins[hoveredBin].warn + histogram.bins[hoveredBin].info + histogram.bins[hoveredBin].debug + histogram.bins[hoveredBin].other }} log<template v-if="(histogram.bins[hoveredBin].error + histogram.bins[hoveredBin].warn + histogram.bins[hoveredBin].info + histogram.bins[hoveredBin].debug + histogram.bins[hoveredBin].other) !== 1">s</template>
+              </div>
+              <div class="lg-density-tip-rows">
+                <span v-for="l in LEVEL_ORDER" :key="l" v-show="histogram.bins[hoveredBin][l] > 0" class="lg-density-tip-row">
+                  <span class="lvl-dot" :style="{ background: LEVEL_COLOR[l] }" />
+                  <span class="lg-density-tip-name">{{ l }}</span>
+                  <span class="lg-density-tip-val mono">{{ histogram.bins[hoveredBin][l] }}</span>
+                </span>
+              </div>
+            </div>
+          </div>
+          <div class="lg-density-axis">
+            <span class="t-tick">{{ fmtAxisTime(histogram.t0) }}</span>
+            <span class="t-tick">{{ fmtAxisTime(histogram.t0 + (histogram.t1 - histogram.t0) * 0.25) }}</span>
+            <span class="t-tick">{{ fmtAxisTime(histogram.t0 + (histogram.t1 - histogram.t0) * 0.5) }}</span>
+            <span class="t-tick">{{ fmtAxisTime(histogram.t0 + (histogram.t1 - histogram.t0) * 0.75) }}</span>
+            <span class="t-tick">{{ fmtAxisTime(histogram.t1) }}</span>
           </div>
         </div>
 
@@ -750,14 +853,14 @@ function jumpToTrace(traceId: string): void {
         </div>
         <div v-else class="lg-stream">
           <template v-for="(r, idx) in filteredLogs" :key="rowKey(r, idx)">
-            <div class="lg-row" :class="`lv-${levelOf(r)}`" @click="toggleExpand(rowKey(r, idx))">
+            <!-- Row click → open the popout. Inline expand removed:
+                 YAML / JSON / multi-line text rendered cramped inside
+                 the row band and reads as truncated. The popout has
+                 the full canvas + format-aware pretty-print. -->
+            <div class="lg-row" :class="`lv-${levelOf(r)}`" @click="onRowClick(r)">
               <span class="lg-time mono">{{ fmtTime(r.timestamp) }}</span>
               <span class="lg-date mono dim">{{ fmtDate(r.timestamp) }}</span>
               <span class="lg-lvl" :style="{ color: LEVEL_COLOR[levelOf(r)] }">{{ levelOf(r) }}</span>
-              <!-- Decode the OAP `<group>::<base>` convention so the
-                   row reads as "<chip> base" instead of dumping the
-                   raw `agent::songs` string. Falls back to the plain
-                   name when no group prefix is present. -->
               <span class="lg-svc mono dim">
                 <span
                   v-if="r.serviceName && parseServiceName(r.serviceName).group"
@@ -766,30 +869,21 @@ function jumpToTrace(traceId: string): void {
                 {{ r.serviceName ? parseServiceName(r.serviceName).base : '—' }}
               </span>
               <span v-if="r.traceId" class="lg-trace mono" @click.stop="jumpToTrace(r.traceId!)">↗ trace</span>
-              <span class="lg-content mono">{{ summariseContent(r) }}</span>
-            </div>
-            <div v-if="expandedId === rowKey(r, idx)" class="lg-expand">
-              <pre
-                v-if="detectFormat(r) === 'json'"
-                class="lg-payload json"
-              >{{ prettyForFormat(r, 'json') }}</pre>
-              <pre
-                v-else-if="detectFormat(r) === 'yaml'"
-                class="lg-payload yaml"
-              >{{ r.content }}</pre>
-              <pre v-else class="lg-payload text">{{ r.content }}</pre>
-              <div v-if="r.tags.length > 0" class="lg-tag-row">
-                <span v-for="t in r.tags" :key="`${t.key}=${t.value}`" class="lg-tag">
-                  <span class="lg-tag-k">{{ t.key }}</span>
-                  <span class="lg-tag-v mono">{{ t.value }}</span>
+              <!-- Format chip + flat content preview. Chip is always
+                   rendered so the operator can tell at-a-glance which
+                   rows are JSON / YAML / plain text. Preview is single
+                   line, length-capped, and trimmed-with-ellipsis via
+                   `.lg-content` CSS so even when JSON contains long
+                   strings the row stays one line. -->
+              <span class="lg-content mono">
+                <span class="lg-fmt-chip" :class="`fmt-${detectFormat(r)}`">{{ detectFormat(r).toUpperCase() }}</span>
+                <span class="lg-content-body">
+                  <template v-if="hasHiddenPayload(r)">
+                    <em class="lg-content-hint">click to view</em>
+                  </template>
+                  <template v-else>{{ summariseContent(r) }}</template>
                 </span>
-              </div>
-              <div class="lg-meta-row">
-                <span v-if="r.serviceInstanceName" class="lg-meta">instance <code>{{ r.serviceInstanceName }}</code></span>
-                <span v-if="r.endpointName" class="lg-meta">endpoint <code>{{ r.endpointName }}</code></span>
-                <span class="lg-meta">type <code>{{ detectFormat(r).toUpperCase() }}</code></span>
-                <button class="sw-btn small" type="button" @click.stop="openPopout(r)">View full</button>
-              </div>
+              </span>
             </div>
           </template>
         </div>
@@ -900,7 +994,7 @@ function jumpToTrace(traceId: string): void {
 }
 /* Trace-style toolbar layout (same voice as `LayerTracesView`). */
 .lg-toolbar { padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; overflow: visible; }
-.lg-toolbar-head { display: flex; align-items: baseline; gap: 10px; }
+.lg-toolbar-head { display: flex; align-items: center; gap: 10px; width: 100%; }
 /* Run-query button: SkyWalking orange, sits at the right edge of the
    toolbar head row. Matches `LayerTracesView.tr-run-btn` exactly so the
    two pages read identically. `.sw-btn.primary` is locally scoped per
@@ -1001,7 +1095,7 @@ function jumpToTrace(traceId: string): void {
   overflow: hidden;
   text-overflow: ellipsis;
 }
-.cf-combo-item em { color: var(--sw-fg-3); font-style: italic; }
+.cf-combo-item em { color: var(--sw-fg-1); font-style: normal; font-family: var(--sw-mono); }
 .cf-combo-item:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
 .cf-combo-item.on { background: var(--sw-accent-soft); color: var(--sw-accent-2); font-weight: 600; }
 .cf-combo-empty { padding: 6px 8px; font-size: 10.5px; color: var(--sw-fg-3); }
@@ -1118,15 +1212,20 @@ function jumpToTrace(traceId: string): void {
   flex-direction: column;
   min-height: 0;
 }
+/* Density-bar wrapper: the 60 stacked bin bars on top, x-axis tick
+   strip underneath so the time scale is readable at a glance. */
+.lg-density-wrap {
+  padding: 8px 12px 4px;
+  border-bottom: 1px solid var(--sw-line);
+  background: var(--sw-bg-1);
+}
 .lg-density {
   display: grid;
   grid-template-columns: repeat(60, 1fr);
   align-items: end;
   gap: 1px;
   height: 60px;
-  padding: 8px 12px;
-  border-bottom: 1px solid var(--sw-line);
-  background: var(--sw-bg-1);
+  position: relative; /* anchor for the absolute-positioned tooltip */
 }
 .lg-density-bin {
   display: flex;
@@ -1135,8 +1234,62 @@ function jumpToTrace(traceId: string): void {
   background: var(--sw-bg-2);
   border-radius: 1px;
   overflow: hidden;
+  /* No `cursor: help` — the `?` cursor was misread as a UI error.
+     The bin reads as informational (hover surfaces a count tooltip),
+     so a default pointer is the right affordance. */
 }
+.lg-density-bin:hover { outline: 1px solid var(--sw-accent-line); }
 .lg-density-segment { display: block; }
+/* Custom hover tooltip — anchored to the hovered bin via the
+   `left: <bin-center>%` inline style. Wider than a single bin so it
+   doesn't clip; transforms back by 50% to centre on the bin. */
+.lg-density-tip {
+  position: absolute;
+  bottom: calc(100% + 6px);
+  transform: translateX(-50%);
+  min-width: 160px;
+  padding: 6px 9px;
+  background: var(--sw-bg-0);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  box-shadow: 0 8px 20px rgba(0,0,0,0.45);
+  font-size: 11px;
+  color: var(--sw-fg-1);
+  pointer-events: none;
+  z-index: 5;
+}
+.lg-density-tip-time { color: var(--sw-fg-3); font-family: var(--sw-mono); font-size: 10px; margin-bottom: 2px; }
+.lg-density-tip-total { color: var(--sw-fg-0); font-weight: 700; font-size: 12px; margin-bottom: 4px; }
+.lg-density-tip-rows { display: flex; flex-direction: column; gap: 2px; }
+.lg-density-tip-row { display: inline-flex; align-items: center; gap: 6px; font-size: 10.5px; }
+.lg-density-tip-row .lvl-dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 7px; }
+.lg-density-tip-name { color: var(--sw-fg-2); flex: 1; text-transform: capitalize; }
+.lg-density-tip-val { color: var(--sw-fg-0); font-weight: 600; font-variant-numeric: tabular-nums; }
+/* X-axis tick strip — 5 evenly-spaced labels (start / 25% / 50% /
+   75% / end) underneath the bars, in tabular nums so they line up. */
+.lg-density-axis {
+  display: flex;
+  justify-content: space-between;
+  font-family: var(--sw-mono);
+  font-size: 9.5px;
+  color: var(--sw-fg-3);
+  font-variant-numeric: tabular-nums;
+  margin-top: 4px;
+  padding: 0 2px;
+}
+.lg-density-axis .t-tick:first-child { text-align: left; }
+.lg-density-axis .t-tick:last-child { text-align: right; }
+
+/* Row-content hint when the inline preview is suppressed (multi-line,
+   YAML, JSON). Italic gray so it reads as "this is a placeholder, not
+   the actual content". */
+.lg-content-hint {
+  color: var(--sw-fg-3);
+  font-style: italic;
+  font-size: 10.5px;
+  letter-spacing: 0.04em;
+}
+.lg-row { cursor: pointer; }
 .lg-empty {
   padding: 32px;
   text-align: center;
@@ -1186,6 +1339,36 @@ function jumpToTrace(traceId: string): void {
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 0;
+}
+/* Format chip — small uppercase tag rendered before the preview. Per-
+   format color keeps JSON / YAML / TEXT visually distinct without
+   adding chrome. Tabular-nums + monospace so the three chips align. */
+.lg-fmt-chip {
+  flex: 0 0 auto;
+  display: inline-block;
+  padding: 0 5px;
+  height: 14px;
+  line-height: 14px;
+  font-size: 9px;
+  font-weight: 700;
+  letter-spacing: 0.04em;
+  border-radius: 3px;
+  text-transform: uppercase;
+  font-family: var(--sw-mono);
+}
+.lg-fmt-chip.fmt-json { background: var(--sw-info-soft); color: var(--sw-info); }
+.lg-fmt-chip.fmt-yaml { background: rgba(251, 191, 36, 0.18); color: #fbbf24; }
+.lg-fmt-chip.fmt-text { background: var(--sw-bg-3); color: var(--sw-fg-2); }
+.lg-content-body {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 .lg-expand {
   padding: 10px 14px 14px 28px;
diff --git a/apps/ui/src/views/layer/LayerShell.vue b/apps/ui/src/views/layer/LayerShell.vue
index 9c379e6..1b6a5a1 100644
--- a/apps/ui/src/views/layer/LayerShell.vue
+++ b/apps/ui/src/views/layer/LayerShell.vue
@@ -26,7 +26,7 @@
       default entry; the cross-layer Overview lives at `/`.
 -->
 <script setup lang="ts">
-import { computed, ref } from 'vue';
+import { computed, ref, watch } from 'vue';
 import { RouterLink, RouterView, useRoute } from 'vue-router';
 import type { LayerDef } from '@skywalking-horizon-ui/api-client';
 import Icon from '@/components/icons/Icon.vue';
@@ -103,6 +103,23 @@ const selectedName = computed(() => {
 // say) can flip the same meta flag without touching this file.
 const viewOwnsServiceSelector = computed(() => Boolean(route.meta?.ownsServiceSelector));
 
+// Keep the URL-backed service selection honest for every page that
+// uses the shell picker. A stale `?service=` can survive navigation or
+// manual URL entry; the switch label used to fall back visually to the
+// first row while the metric query still waited for a valid service.
+watch(
+  [sampledServices, selectedId, viewOwnsServiceSelector],
+  ([rows, id, ownsSelector]) => {
+    if (ownsSelector) return;
+    const first = rows[0];
+    if (!first) return;
+    if (!id || !rows.some((s) => s.serviceId === id)) {
+      setSelected(first.serviceId);
+    }
+  },
+  { immediate: true },
+);
+
 // Picker toggle state. Lives at the shell level so the header's Switch
 // button and the picker section render against the same state.
 const pickerOpen = ref(false);
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index 12e6fed..bb236c1 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -41,6 +41,8 @@ export interface LayerSlots {
 export interface LayerCaps {
   serviceMap?: boolean;
   endpointDependency?: boolean;
+  instances?: boolean;
+  endpoints?: boolean;
   instanceTopology?: boolean;
   processTopology?: boolean;
   dashboards?: boolean;