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