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 = '