This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch feat/widget-visible-when in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit c23bf36ab9c4aa440530cf0357d598e6a1e67f9b Author: Wu Sheng <[email protected]> AuthorDate: Mon Jun 8 20:54:18 2026 +0800 feat(dashboard): structured visibleWhen widget gates with server-side eval Replace the free-text `visibleWhen` predicate with a structured gate evaluated BFF-side, so a widget renders only when it's relevant to the selected entity: - MQE gate: exists / > / < over an expression (existential across every labeled series and bucket). Naming the widget's own expression self-gates it with no extra query; naming a different metric gates a whole group on one shared signal — probed once and skipping the group's MQE entirely when it's empty (a non-JVM instance never runs the JVM widget queries). - Entity gate (Instance scope only): exists / equals against the selected instance's attributes, e.g. language equals JAVA (case-insensitive); present-and-non-empty for exists. The SPA drops widgets the BFF flags `hidden` (the client-side isVisible is retired). Legacy "<metric> has value" / #entity.* strings are tolerated (parsed to ungated), not migrated. Admin editor: structured gate control, per-expression rows (expression + label + unit + axis edited together, replacing the index-aligned parallel textareas), a reusable MqeExpressionInput pop-out for long expressions (shared by the layer and overview editors), and a sticky full-height widget drawer. Bundled layer templates: 71 gates ported to the structured form, all as self-gates. Validated against the public Apache demo OAP: self / group / entity / threshold / legacy-tolerance / layer-scope scenarios plus the bundled instance dashboard render. --- CHANGELOG.md | 21 ++ .../bundled_templates/layers/envoy_ai_gateway.json | 10 +- apps/bff/src/bundled_templates/layers/general.json | 108 +++--- apps/bff/src/bundled_templates/layers/mesh.json | 20 +- .../src/bundled_templates/layers/so11y_oap.json | 4 +- apps/bff/src/http/query/dashboard.ts | 238 ++++++++++++- .../features/admin/_shared/MqeExpressionInput.vue | 223 ++++++++++++ .../admin/layer-templates/LayerDashboardsAdmin.vue | 395 ++++++++++++++++----- .../overview-templates/OverviewTemplatesAdmin.vue | 5 +- .../render/layer-dashboard/LayerDashboardsView.vue | 52 +-- packages/api-client/src/dashboard.ts | 46 ++- packages/api-client/src/index.ts | 1 + 12 files changed, 893 insertions(+), 230 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d34be79..f135c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,27 @@ packages) plus the BFF's `HORIZON_VERSION` default. ## 0.7.0 +### Dashboard widget visibility + +- Layer-dashboard widgets gain a structured **Visible when** gate (Layer + dashboards admin → widget drawer) so a widget only renders when it's + relevant to the selected entity. Two kinds: + - **MQE metric** — show the widget only when an expression *has value*, or + when any value is **>** / **<** a threshold. Naming the widget's *own* + metric self-gates it (the JVM widgets appear only on JVM instances, the + MQ widgets only on MQ producers, …); naming a *different* metric gates a + whole group on one shared signal — that metric is checked once and the + entire group's queries are **skipped** when it's empty, so e.g. a non-JVM + instance no longer runs the JVM widget queries at all. + - **Entity attribute** — on the Instance scope, gate on the selected + instance's attributes, e.g. *language equals JAVA* (case-insensitive) or + an attribute simply being present. Service / Endpoint entities carry no + attributes, so entity gates are ignored on those scopes. +- Gates are evaluated server-side; gated-out widgets just don't appear in the + grid. **Note:** a layer dashboard saved before this release that used the + old free-text predicate loses its gate (the widget renders ungated) until + you re-set the gate in the new editor and save the dashboard. + ### Instance topology - The per-layer **Topology** map gains an **instance map** drill-down on diff --git a/apps/bff/src/bundled_templates/layers/envoy_ai_gateway.json b/apps/bff/src/bundled_templates/layers/envoy_ai_gateway.json index 3dc20e0..b7aaac6 100644 --- a/apps/bff/src/bundled_templates/layers/envoy_ai_gateway.json +++ b/apps/bff/src/bundled_templates/layers/envoy_ai_gateway.json @@ -268,7 +268,7 @@ "expressions": [ "meter_envoy_ai_gw_mcp_request_cpm" ], - "visibleWhen": "meter_envoy_ai_gw_mcp_request_cpm has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_envoy_ai_gw_mcp_request_cpm", "op": "exists" }, "span": 4, "rowSpan": 2 }, @@ -280,7 +280,7 @@ "expressions": [ "meter_envoy_ai_gw_mcp_request_latency_avg" ], - "visibleWhen": "meter_envoy_ai_gw_mcp_request_latency_avg has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_envoy_ai_gw_mcp_request_latency_avg", "op": "exists" }, "span": 4, "rowSpan": 2 }, @@ -291,7 +291,7 @@ "expressions": [ "meter_envoy_ai_gw_mcp_error_cpm" ], - "visibleWhen": "meter_envoy_ai_gw_mcp_error_cpm has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_envoy_ai_gw_mcp_error_cpm", "op": "exists" }, "span": 4, "rowSpan": 2 }, @@ -302,7 +302,7 @@ "expressions": [ "aggregate_labels(meter_envoy_ai_gw_mcp_method_cpm,sum(mcp_method_name))" ], - "visibleWhen": "meter_envoy_ai_gw_mcp_method_cpm has value", + "visibleWhen": { "kind": "mqe", "expression": "aggregate_labels(meter_envoy_ai_gw_mcp_method_cpm,sum(mcp_method_name))", "op": "exists" }, "span": 6, "rowSpan": 2 }, @@ -313,7 +313,7 @@ "expressions": [ "aggregate_labels(meter_envoy_ai_gw_mcp_backend_request_cpm,sum(mcp_backend))" ], - "visibleWhen": "meter_envoy_ai_gw_mcp_backend_request_cpm has value", + "visibleWhen": { "kind": "mqe", "expression": "aggregate_labels(meter_envoy_ai_gw_mcp_backend_request_cpm,sum(mcp_backend))", "op": "exists" }, "span": 6, "rowSpan": 2 } diff --git a/apps/bff/src/bundled_templates/layers/general.json b/apps/bff/src/bundled_templates/layers/general.json index 49e22fe..31623ab 100644 --- a/apps/bff/src/bundled_templates/layers/general.json +++ b/apps/bff/src/bundled_templates/layers/general.json @@ -226,7 +226,7 @@ "type": "line", "unit": "%", "expressions": ["instance_jvm_cpu"], - "visibleWhen": "instance_jvm_cpu has value", + "visibleWhen": { "kind": "mqe", "expression": "instance_jvm_cpu", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -243,7 +243,7 @@ "instance_jvm_memory_noheap_max/1048576" ], "expressionLabels": ["heap", "heap max", "non-heap", "non-heap max"], - "visibleWhen": "instance_jvm_memory_heap has value", + "visibleWhen": { "kind": "mqe", "expression": "instance_jvm_memory_heap/1048576", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -279,7 +279,7 @@ "codeheap (profiled nmethods)", "codeheap (non-profiled nmethods)" ], - "visibleWhen": "instance_jvm_memory_pool_code_cache has value", + "visibleWhen": { "kind": "mqe", "expression": "instance_jvm_memory_pool_code_cache/1048576", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -293,7 +293,7 @@ "instance_jvm_thread_peak_count" ], "expressionLabels": ["live", "daemon", "peak"], - "visibleWhen": "instance_jvm_thread_live_count has value", + "visibleWhen": { "kind": "mqe", "expression": "instance_jvm_thread_live_count", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -309,7 +309,7 @@ "instance_jvm_thread_timed_waiting_state_thread_count" ], "expressionLabels": ["runnable", "blocked", "waiting", "timed-waiting"], - "visibleWhen": "instance_jvm_thread_runnable_state_thread_count has value", + "visibleWhen": { "kind": "mqe", "expression": "instance_jvm_thread_runnable_state_thread_count", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -324,7 +324,7 @@ "instance_jvm_normal_gc_time" ], "expressionLabels": ["young", "old", "normal"], - "visibleWhen": "instance_jvm_young_gc_time has value", + "visibleWhen": { "kind": "mqe", "expression": "instance_jvm_young_gc_time", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -338,7 +338,7 @@ "instance_jvm_normal_gc_count" ], "expressionLabels": ["young", "old", "normal"], - "visibleWhen": "instance_jvm_young_gc_count has value", + "visibleWhen": { "kind": "mqe", "expression": "instance_jvm_young_gc_count", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -352,7 +352,7 @@ "instance_jvm_class_total_unloaded_class_count" ], "expressionLabels": ["loaded", "total loaded", "total unloaded"], - "visibleWhen": "instance_jvm_class_loaded_class_count has value", + "visibleWhen": { "kind": "mqe", "expression": "instance_jvm_class_loaded_class_count", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -363,7 +363,7 @@ "type": "line", "unit": "%", "expressions": ["instance_clr_cpu"], - "visibleWhen": "instance_clr_cpu has value", + "visibleWhen": { "kind": "mqe", "expression": "instance_clr_cpu", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -377,7 +377,7 @@ "instance_clr_max_completion_port_threads" ], "expressionLabels": ["worker available", "completion-port available", "completion-port max"], - "visibleWhen": "instance_clr_available_worker_threads has value", + "visibleWhen": { "kind": "mqe", "expression": "instance_clr_available_worker_threads", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -387,7 +387,7 @@ "type": "line", "unit": "MB", "expressions": ["instance_clr_heap_memory/1048576"], - "visibleWhen": "instance_clr_heap_memory has value", + "visibleWhen": { "kind": "mqe", "expression": "instance_clr_heap_memory/1048576", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -401,7 +401,7 @@ "instance_clr_gen2_collect_count" ], "expressionLabels": ["gen 0", "gen 1", "gen 2"], - "visibleWhen": "instance_clr_gen0_collect_count has value", + "visibleWhen": { "kind": "mqe", "expression": "instance_clr_gen0_collect_count", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -411,7 +411,7 @@ "tip": "Spring Sleuth / Spring Boot Actuator — http.server.requests counter.", "type": "line", "expressions": ["meter_http_server_requests_count"], - "visibleWhen": "meter_http_server_requests_count has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_http_server_requests_count", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -421,7 +421,7 @@ "type": "line", "unit": "ms", "expressions": ["meter_http_server_requests_duration"], - "visibleWhen": "meter_http_server_requests_duration has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_http_server_requests_duration", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -431,7 +431,7 @@ "type": "line", "unit": "%", "expressions": ["meter_process_cpu_usage"], - "visibleWhen": "meter_process_cpu_usage has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_process_cpu_usage", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -441,7 +441,7 @@ "type": "line", "unit": "%", "expressions": ["meter_system_cpu_usage"], - "visibleWhen": "meter_system_cpu_usage has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_system_cpu_usage", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -450,7 +450,7 @@ "title": "Spring OS System Load", "type": "line", "expressions": ["meter_system_load_average_1m"], - "visibleWhen": "meter_system_load_average_1m has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_system_load_average_1m", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -463,7 +463,7 @@ "meter_process_files_max" ], "expressionLabels": ["open", "max"], - "visibleWhen": "meter_process_files_open has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_process_files_open", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -473,7 +473,7 @@ "type": "line", "unit": "ms", "expressions": ["meter_jvm_gc_pause_duration"], - "visibleWhen": "meter_jvm_gc_pause_duration has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_jvm_gc_pause_duration", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -487,7 +487,7 @@ "meter_jvm_memory_max/1048576" ], "expressionLabels": ["used", "max"], - "visibleWhen": "meter_jvm_memory_used has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_jvm_memory_used/1048576", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -501,7 +501,7 @@ "meter_jvm_threads_peak" ], "expressionLabels": ["live", "daemon", "peak"], - "visibleWhen": "meter_jvm_threads_live has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_jvm_threads_live", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -514,7 +514,7 @@ "meter_jvm_classes_unloaded" ], "expressionLabels": ["loaded", "unloaded"], - "visibleWhen": "meter_jvm_classes_loaded has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_jvm_classes_loaded", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -524,7 +524,7 @@ "tip": "HikariCP / Spring Boot datasource metrics — connection counts per pool.", "type": "line", "expressions": ["meter_datasource"], - "visibleWhen": "meter_datasource has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_datasource", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -534,7 +534,7 @@ "tip": "Spring Boot thread pool meters — active, queued, max threads per executor.", "type": "line", "expressions": ["meter_thread_pool"], - "visibleWhen": "meter_thread_pool has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_thread_pool", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -548,7 +548,7 @@ "meter_jdbc_connections_max" ], "expressionLabels": ["active", "idle", "max"], - "visibleWhen": "meter_jdbc_connections_active has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_jdbc_connections_active", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -562,7 +562,7 @@ "meter_tomcat_sessions_rejected" ], "expressionLabels": ["active", "max", "rejected"], - "visibleWhen": "meter_tomcat_sessions_active_current has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_tomcat_sessions_active_current", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -576,7 +576,7 @@ "meter_instance_golang_os_threads_num" ], "expressionLabels": ["goroutines", "OS threads"], - "visibleWhen": "meter_instance_golang_live_goroutines_num has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_live_goroutines_num", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -586,7 +586,7 @@ "type": "line", "unit": "ms", "expressions": ["meter_instance_golang_gc_pauses/1000000"], - "visibleWhen": "meter_instance_golang_gc_pauses has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_gc_pauses/1000000", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -595,7 +595,7 @@ "title": "Golang GC Count (per minute)", "type": "line", "expressions": ["meter_instance_golang_gc_count_labeled"], - "visibleWhen": "meter_instance_golang_gc_count_labeled has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_gc_count_labeled", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -605,7 +605,7 @@ "type": "line", "unit": "MB", "expressions": ["meter_instance_golang_heap_alloc_rate/1048576"], - "visibleWhen": "meter_instance_golang_heap_alloc_rate has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_heap_alloc_rate/1048576", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -615,7 +615,7 @@ "type": "line", "unit": "ms", "expressions": ["meter_instance_golang_sched_latencies/1000000"], - "visibleWhen": "meter_instance_golang_sched_latencies has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_sched_latencies/1000000", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -625,7 +625,7 @@ "type": "line", "unit": "MB", "expressions": ["meter_instance_golang_heap_frees/1048576"], - "visibleWhen": "meter_instance_golang_heap_frees has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_heap_frees/1048576", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -635,7 +635,7 @@ "type": "line", "unit": "KB", "expressions": ["meter_instance_golang_gc_heap_allocs_by_size/1024"], - "visibleWhen": "meter_instance_golang_gc_heap_allocs_by_size has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_gc_heap_allocs_by_size/1024", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -645,7 +645,7 @@ "type": "line", "unit": "KB", "expressions": ["meter_instance_golang_gc_heap_frees_by_size/1024"], - "visibleWhen": "meter_instance_golang_gc_heap_frees_by_size has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_gc_heap_frees_by_size/1024", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -654,7 +654,7 @@ "title": "Golang Heap Objects", "type": "line", "expressions": ["meter_instance_golang_gc_heap_objects"], - "visibleWhen": "meter_instance_golang_gc_heap_objects has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_gc_heap_objects", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -664,7 +664,7 @@ "type": "line", "unit": "MB", "expressions": ["meter_instance_golang_memory_heap_labeled/1048576"], - "visibleWhen": "meter_instance_golang_memory_heap_labeled has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_memory_heap_labeled/1048576", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -674,7 +674,7 @@ "type": "line", "unit": "MB", "expressions": ["meter_instance_golang_metadata_mspan_labeled"], - "visibleWhen": "meter_instance_golang_metadata_mspan_labeled has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_metadata_mspan_labeled", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -684,7 +684,7 @@ "type": "line", "unit": "MB", "expressions": ["meter_instance_golang_metadata_mcache_labeled"], - "visibleWhen": "meter_instance_golang_metadata_mcache_labeled has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_metadata_mcache_labeled", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -694,7 +694,7 @@ "type": "line", "unit": "MB", "expressions": ["meter_instance_golang_gc_heap_goal/1048576"], - "visibleWhen": "meter_instance_golang_gc_heap_goal has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_gc_heap_goal/1048576", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -703,7 +703,7 @@ "title": "Golang CGO Calls (per minute)", "type": "line", "expressions": ["meter_instance_golang_cgo_calls"], - "visibleWhen": "meter_instance_golang_cgo_calls has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_cgo_calls", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -718,7 +718,7 @@ "meter_instance_pvm_process_cpu_utilization" ], "expressionLabels": ["host", "process"], - "visibleWhen": "meter_instance_pvm_process_cpu_utilization has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_pvm_process_cpu_utilization", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -732,7 +732,7 @@ "meter_instance_pvm_process_mem_utilization" ], "expressionLabels": ["host", "process"], - "visibleWhen": "meter_instance_pvm_process_mem_utilization has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_pvm_process_mem_utilization", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -741,7 +741,7 @@ "title": "Python Thread Count", "type": "line", "expressions": ["meter_instance_pvm_thread_active_count"], - "visibleWhen": "meter_instance_pvm_thread_active_count has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_pvm_thread_active_count", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -755,7 +755,7 @@ "meter_instance_pvm_gc_g2" ], "expressionLabels": ["gen 0", "gen 1", "gen 2"], - "visibleWhen": "meter_instance_pvm_gc_g0 has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_pvm_gc_g0", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -765,7 +765,7 @@ "type": "line", "unit": "ms", "expressions": ["meter_instance_pvm_gc_time"], - "visibleWhen": "meter_instance_pvm_gc_time has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_pvm_gc_time", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -776,7 +776,7 @@ "type": "line", "unit": "%", "expressions": ["meter_instance_ruby_cpu_usage_percent"], - "visibleWhen": "meter_instance_ruby_cpu_usage_percent has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_ruby_cpu_usage_percent", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -786,7 +786,7 @@ "type": "line", "unit": "MB", "expressions": ["meter_instance_ruby_memory_rss_mb"], - "visibleWhen": "meter_instance_ruby_memory_rss_mb has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_ruby_memory_rss_mb", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -796,7 +796,7 @@ "type": "line", "unit": "%", "expressions": ["meter_instance_ruby_memory_usage_percent"], - "visibleWhen": "meter_instance_ruby_memory_usage_percent has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_ruby_memory_usage_percent", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -809,7 +809,7 @@ "meter_instance_ruby_thread_count_running" ], "expressionLabels": ["active", "running"], - "visibleWhen": "meter_instance_ruby_thread_count_active has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_ruby_thread_count_active", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -823,7 +823,7 @@ "meter_instance_ruby_gc_major_count_total" ], "expressionLabels": ["total", "minor", "major"], - "visibleWhen": "meter_instance_ruby_gc_count_total has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_ruby_gc_count_total", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -833,7 +833,7 @@ "type": "line", "unit": "ms", "expressions": ["meter_instance_ruby_gc_time_total"], - "visibleWhen": "meter_instance_ruby_gc_time_total has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_ruby_gc_time_total", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -843,7 +843,7 @@ "type": "line", "unit": "%", "expressions": ["meter_instance_ruby_heap_usage_percent"], - "visibleWhen": "meter_instance_ruby_heap_usage_percent has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_ruby_heap_usage_percent", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -856,7 +856,7 @@ "meter_instance_ruby_heap_available_slots_count" ], "expressionLabels": ["live", "available"], - "visibleWhen": "meter_instance_ruby_heap_live_slots_count has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_instance_ruby_heap_live_slots_count", "op": "exists" }, "span": 3, "rowSpan": 2 } @@ -907,7 +907,7 @@ "type": "line", "unit": "ms", "expressions": ["endpoint_mq_consume_latency"], - "visibleWhen": "endpoint_mq_consume_latency has value", + "visibleWhen": { "kind": "mqe", "expression": "endpoint_mq_consume_latency", "op": "exists" }, "span": 12, "rowSpan": 2 } diff --git a/apps/bff/src/bundled_templates/layers/mesh.json b/apps/bff/src/bundled_templates/layers/mesh.json index 80ce3fe..8ca2229 100644 --- a/apps/bff/src/bundled_templates/layers/mesh.json +++ b/apps/bff/src/bundled_templates/layers/mesh.json @@ -314,7 +314,7 @@ "expressions": [ "envoy_cluster_up_rq_active" ], - "visibleWhen": "envoy_cluster_up_rq_active has value", + "visibleWhen": { "kind": "mqe", "expression": "envoy_cluster_up_rq_active", "op": "exists" }, "span": 4, "rowSpan": 2 }, @@ -326,7 +326,7 @@ "expressions": [ "envoy_cluster_up_rq_incr" ], - "visibleWhen": "envoy_cluster_up_rq_incr has value", + "visibleWhen": { "kind": "mqe", "expression": "envoy_cluster_up_rq_incr", "op": "exists" }, "span": 4, "rowSpan": 2 }, @@ -338,7 +338,7 @@ "expressions": [ "envoy_cluster_up_rq_pending_active" ], - "visibleWhen": "envoy_cluster_up_rq_pending_active has value", + "visibleWhen": { "kind": "mqe", "expression": "envoy_cluster_up_rq_pending_active", "op": "exists" }, "span": 4, "rowSpan": 2 }, @@ -349,7 +349,7 @@ "expressions": [ "envoy_cluster_up_cx_active" ], - "visibleWhen": "envoy_cluster_up_cx_active has value", + "visibleWhen": { "kind": "mqe", "expression": "envoy_cluster_up_cx_active", "op": "exists" }, "span": 4, "rowSpan": 2 }, @@ -360,7 +360,7 @@ "expressions": [ "envoy_cluster_up_cx_incr" ], - "visibleWhen": "envoy_cluster_up_cx_incr has value", + "visibleWhen": { "kind": "mqe", "expression": "envoy_cluster_up_cx_incr", "op": "exists" }, "span": 4, "rowSpan": 2 }, @@ -372,7 +372,7 @@ "expressions": [ "envoy_cluster_membership_healthy" ], - "visibleWhen": "envoy_cluster_membership_healthy has value", + "visibleWhen": { "kind": "mqe", "expression": "envoy_cluster_membership_healthy", "op": "exists" }, "span": 4, "rowSpan": 2 }, @@ -388,7 +388,7 @@ "total", "parent" ], - "visibleWhen": "envoy_total_connections_used has value", + "visibleWhen": { "kind": "mqe", "expression": "envoy_total_connections_used", "op": "exists" }, "span": 4, "rowSpan": 2 }, @@ -414,7 +414,7 @@ "physical", "physical max" ], - "visibleWhen": "envoy_heap_memory_used has value", + "visibleWhen": { "kind": "mqe", "expression": "envoy_heap_memory_used/1024/1024", "op": "exists" }, "span": 6, "rowSpan": 2 }, @@ -430,7 +430,7 @@ "live", "max" ], - "visibleWhen": "envoy_worker_threads has value", + "visibleWhen": { "kind": "mqe", "expression": "envoy_worker_threads", "op": "exists" }, "span": 3, "rowSpan": 2 }, @@ -442,7 +442,7 @@ "expressions": [ "envoy_bug_failures" ], - "visibleWhen": "envoy_bug_failures has value", + "visibleWhen": { "kind": "mqe", "expression": "envoy_bug_failures", "op": "exists" }, "span": 3, "rowSpan": 2 } diff --git a/apps/bff/src/bundled_templates/layers/so11y_oap.json b/apps/bff/src/bundled_templates/layers/so11y_oap.json index 509ba49..b1e4465 100644 --- a/apps/bff/src/bundled_templates/layers/so11y_oap.json +++ b/apps/bff/src/bundled_templates/layers/so11y_oap.json @@ -351,7 +351,7 @@ "stream single", "property" ], - "visibleWhen": "meter_oap_banyandb_write_latency_percentile has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_oap_banyandb_write_latency_percentile{catalog='measure',operation='bulk_write'}", "op": "exists" }, "span": 6, "rowSpan": 2 }, @@ -368,7 +368,7 @@ "single", "bulk" ], - "visibleWhen": "meter_oap_elasticsearch_write_latency_percentile has value", + "visibleWhen": { "kind": "mqe", "expression": "meter_oap_elasticsearch_write_latency_percentile{operation='single_write,single_update,single_delete'}", "op": "exists" }, "span": 6, "rowSpan": 2 }, diff --git a/apps/bff/src/http/query/dashboard.ts b/apps/bff/src/http/query/dashboard.ts index 4da9e6e..a715ed3 100644 --- a/apps/bff/src/http/query/dashboard.ts +++ b/apps/bff/src/http/query/dashboard.ts @@ -39,6 +39,7 @@ import type { DashboardWidgetResult, FetchLike, UITemplateClient, + VisibleWhen, } from '@skywalking-horizon-ui/api-client'; import type { ConfigSource } from '../../config/loader.js'; import type { SessionStore } from '../../user/sessions.js'; @@ -88,7 +89,30 @@ export const widgetSchema = z.object({ showTableValues: z.boolean().optional(), span: z.number().int().min(1).max(12).optional(), rowSpan: z.number().int().min(1).max(64).optional(), - visibleWhen: z.string().optional(), + // Structured visibility gate. `.catch(undefined)` makes the parse + // TOLERANT: a legacy free-text predicate (`"<metric> has value"` / + // `#entity.x`) left over in an OAP-stored dashboard resolves to + // `undefined` (ungated) instead of failing the whole widget/template + // parse. New gates are authored as the structured object. + visibleWhen: z + .union([ + z.object({ kind: z.literal('mqe'), expression: z.string().min(1), op: z.literal('exists') }), + z.object({ + kind: z.literal('mqe'), + expression: z.string().min(1), + op: z.enum(['gt', 'lt']), + value: z.number(), + }), + z.object({ kind: z.literal('entity'), attribute: z.string().min(1), op: z.literal('exists') }), + z.object({ + kind: z.literal('entity'), + attribute: z.string().min(1), + op: z.literal('eq'), + value: z.string(), + }), + ]) + .optional() + .catch(undefined), layerScope: z.boolean().optional(), // Legacy x/y/w/h kept optional for back-compat. x: z.number().int().min(0).optional(), @@ -192,6 +216,18 @@ const FIND_FIRST_ENDPOINT = /* GraphQL */ ` } } `; +/** Selected-instance attribute feed for `entity` visibility gates. Mirrors + * the field set in http/query/instance.ts — `ServiceInstance` is the only + * entity scope OAP exposes an attribute bag on. */ +const LIST_INSTANCE_ATTRS = /* GraphQL */ ` + query InstanceAttrs($serviceId: ID!, $duration: Duration!) { + instances: listInstances(serviceId: $serviceId, duration: $duration) { + name + language + attributes { name value } + } + } +`; const DEFAULT_WINDOW_MIN = 60; function defaultWindow(offsetMinutes: number) { @@ -405,6 +441,78 @@ export function parseTable( return rows; } +/* ------------------------------------------------------------------- * + * Visibility gates (`visibleWhen`). + * + * `mqe` gates are existential over the WHOLE result — every labeled + * series (relabels / histogram), every bucket — so `flattenValues` + * collapses an MqeResultShape to its non-null numeric value set and the + * op tests "at least one value …". `entity` gates read the selected + * instance's attribute bag. + * ------------------------------------------------------------------- */ + +/** Every non-null numeric value across all labeled series + buckets. */ +function flattenValues(r: MqeResultShape | undefined): number[] { + if (!r || r.error) return []; + const out: number[] = []; + for (const rs of r.results ?? []) { + for (const v of rs.values ?? []) { + if (v.value === null || v.value === undefined) continue; + const n = Number(v.value); + if (Number.isFinite(n)) out.push(n); + } + } + return out; +} + +function mqeGatePass(op: 'exists' | 'gt' | 'lt', value: number | undefined, vals: number[]): boolean { + if (op === 'gt') return value !== undefined && vals.some((v) => v > value); + if (op === 'lt') return value !== undefined && vals.some((v) => v < value); + return vals.length > 0; // exists +} + +/** Attribute lookup for the selected instance: `language` + named + * attributes, keyed lower-case. Empty values are dropped so `exists` + * means present-AND-non-empty (OAP reports `namespace`/`cluster` as + * empty-string keys). */ +function buildAttrMap( + language: string | null | undefined, + attrs: Array<{ name: string; value: string }>, +): Map<string, string> { + const m = new Map<string, string>(); + if (language && language.trim()) m.set('language', language.trim()); + for (const a of attrs) { + const v = a.value == null ? '' : String(a.value); + if (v.trim() !== '') m.set(a.name.toLowerCase(), v); + } + return m; +} + +/** Evaluate an entity gate. `attrs === null` means "no entity context" + * (non-Instance scope / probe failed) → no-op (visible). */ +function entityGatePass( + vw: Extract<VisibleWhen, { kind: 'entity' }>, + attrs: Map<string, string> | null, +): boolean { + if (!attrs) return true; + const val = attrs.get(vw.attribute.toLowerCase()); + if (vw.op === 'eq') return val !== undefined && val.toLowerCase() === vw.value.toLowerCase(); + return val !== undefined; // exists (map already excludes empties) +} + +/** Normalize a widget's gate — overlay-sourced widgets may still carry a + * legacy string; anything that isn't the structured object is no gate. */ +function vwOf(w: DashboardWidget): VisibleWhen | undefined { + const vw = w.visibleWhen as unknown; + return vw && typeof vw === 'object' && 'kind' in (vw as object) ? (vw as VisibleWhen) : undefined; +} + +/** A `mqe` gate whose expression the widget already queries — evaluated + * from the widget's own batch result (no extra probe, no skip). */ +function isSelfGate(w: DashboardWidget, vw: VisibleWhen): boolean { + return vw.kind === 'mqe' && w.expressions.includes(vw.expression); +} + export function registerDashboardQueryRoute(app: FastifyInstance, deps: DashboardRouteDeps): void { const auth = requireAuth(deps); app.post( @@ -550,6 +658,96 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar } } + const scopeHonorsInstance = scope === 'instance'; + const scopeHonorsEndpoint = scope === 'endpoint'; + + // Step 1c — resolve visibility gates BEFORE the widget batch so a + // failing GROUP or ENTITY gate skips its widgets' MQE entirely (a + // non-JVM instance never runs the JVM widget group). SELF gates + // (the predicate names one of the widget's own expressions) add + // zero queries — they're read from the widget's own batch result + // in Step 3. Probes here only run when such gates are present, so + // the common all-self-gate templates keep today's query cost. + const entityGated = widgets.some((w) => vwOf(w)?.kind === 'entity'); + // Group-gate probes keyed by (expression + layerScope): a + // layer-scoped widget's gate must be probed at `{ scope: All }` and + // a normally-scoped one at the active entity scope, so the two can + // never share a verdict. + const gateKey = (expr: string, layerScope: boolean): string => `${layerScope ? 'L' : 'S'}|${expr}`; + const groupGates = new Map<string, { expression: string; layerScope: boolean }>(); + for (const w of widgets) { + const vw = vwOf(w); + if (vw?.kind === 'mqe' && !isSelfGate(w, vw)) { + const ls = w.layerScope === true; + groupGates.set(gateKey(vw.expression, ls), { expression: vw.expression, layerScope: ls }); + } + } + // `null` = no entity context / probe failed → entity gates no-op. + let entityAttrs: Map<string, string> | null = null; + // expression → flattened values, or `null` when the probe failed + // (fail OPEN: run the widgets rather than hide on an OAP hiccup). + const groupGateVals = new Map<string, number[] | null>(); + await Promise.all([ + (async () => { + if (!entityGated || !scopeHonorsInstance || !selectedInstance || !serviceId) return; + try { + const d = await graphqlPost<{ + instances: Array<{ + name: string; + language?: string | null; + attributes?: Array<{ name: string; value: string }> | null; + }>; + }>(opts, LIST_INSTANCE_ATTRS, { + serviceId, + duration: withColdStage(req, { start: window.start, end: window.end, step: window.step }), + }); + const inst = (d.instances ?? []).find((i) => i.name === selectedInstance); + if (inst) entityAttrs = buildAttrMap(inst.language, inst.attributes ?? []); + } catch { + /* leave entityAttrs null → entity gates stay visible */ + } + })(), + (async () => { + if (groupGates.size === 0) return; + const entries = [...groupGates.entries()]; + try { + const fragments = entries.map(([, g], i) => + buildFragment(`g${i}`, g.expression, serviceName, normal, window, { + layerScope: g.layerScope, + serviceInstanceName: g.layerScope || !scopeHonorsInstance ? null : selectedInstance, + endpointName: g.layerScope || !scopeHonorsEndpoint ? null : selectedEndpoint, + coldStage: !!req.coldStage, + }), + ); + const probe = await graphqlPost<Record<string, MqeResultShape>>( + opts, + `query GateProbe { ${fragments.join('\n ')} }`, + ); + entries.forEach(([key], i) => groupGateVals.set(key, flattenValues(probe[`g${i}`]))); + } catch { + for (const [key] of entries) groupGateVals.set(key, null); + } + })(), + ]); + + /** Widgets whose GROUP/ENTITY gate failed — excluded from the + * batch and returned `hidden: true`. Self gates are applied after + * the batch (they need the widget's own data). */ + const skipped = new Set<number>(); + widgets.forEach((w, i) => { + const vw = vwOf(w); + if (!vw) return; + if (vw.kind === 'entity') { + if (!entityGatePass(vw, entityAttrs)) skipped.add(i); + return; + } + if (!isSelfGate(w, vw)) { + const vals = groupGateVals.get(gateKey(vw.expression, w.layerScope === true)); + if (vals == null) return; // probe failed / missing → fail open + if (!mqeGatePass(vw.op, 'value' in vw ? vw.value : undefined, vals)) skipped.add(i); + } + }); + // Step 2 — batch widget × expression queries via aliased // `execExpression(...)` fragments. Mirrors booster-ui's // `useExpressionsProcessor.fetchMetrics`: chunk widgets into @@ -561,18 +759,15 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar // batch — losing every cell instead of degrading per chunk. // Chunked + Promise.all keeps the wall-clock close to a single // round-trip while staying inside OAP's per-query budget. + // Gate-skipped widgets are excluded here (their wIdx keeps its + // original index so Step 3's result map still lines up). const MAX_WIDGETS_PER_BATCH = 6; - const aliasMap = new Map<string, { wIdx: number; eIdx: number }>(); - const scopeHonorsInstance = scope === 'instance'; - const scopeHonorsEndpoint = scope === 'endpoint'; + const batchWidgets = widgets + .map((widget, wIdx) => ({ widget, wIdx })) + .filter(({ wIdx }) => !skipped.has(wIdx)); const widgetChunks: { widget: DashboardWidget; wIdx: number }[][] = []; - for (let i = 0; i < widgets.length; i += MAX_WIDGETS_PER_BATCH) { - widgetChunks.push( - widgets.slice(i, i + MAX_WIDGETS_PER_BATCH).map((widget, idxInChunk) => ({ - widget, - wIdx: i + idxInChunk, - })), - ); + for (let i = 0; i < batchWidgets.length; i += MAX_WIDGETS_PER_BATCH) { + widgetChunks.push(batchWidgets.slice(i, i + MAX_WIDGETS_PER_BATCH)); } const data: Record<string, MqeResultShape> = {}; try { @@ -582,7 +777,6 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar for (const { widget, wIdx } of chunk) { widget.expressions.forEach((expr, eIdx) => { const alias = `w${wIdx}_e${eIdx}`; - aliasMap.set(alias, { wIdx, eIdx }); fragments.push( buildFragment(alias, expr, serviceName, normal, window, { layerScope: widget.layerScope === true, @@ -618,7 +812,7 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar // both the simple case (1 expression → 1 series) and the // relabels() case (1 expression → N labeled series) // - 'top': extract sorted list from the first expression - const results: DashboardWidgetResult[] = widgets.map((widget, wIdx) => { + const collapse = (widget: DashboardWidget, wIdx: number): DashboardWidgetResult => { if (widget.type === 'top') { const groups: Array<{ label: string; @@ -733,6 +927,24 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar }); if (flat.length === 0) return { id: widget.id, error: 'no data' }; return { id: widget.id, series: flat }; + }; + + const results: DashboardWidgetResult[] = widgets.map((widget, wIdx) => { + // Group/entity gate already decided this one out (no MQE ran). + if (skipped.has(wIdx)) return { id: widget.id, hidden: true }; + const result = collapse(widget, wIdx); + // SELF gate — evaluate the predicate against the widget's own + // gate expression result (exact: only that expression's values, + // not the whole flattened widget result). + const vw = vwOf(widget); + if (vw?.kind === 'mqe' && isSelfGate(widget, vw)) { + const eIdx = widget.expressions.indexOf(vw.expression); + const vals = flattenValues(data[`w${wIdx}_e${eIdx}`]); + if (!mqeGatePass(vw.op, 'value' in vw ? vw.value : undefined, vals)) { + return { id: widget.id, hidden: true }; + } + } + return result; }); return reply.send({ ...baseResp, widgets: results }); diff --git a/apps/ui/src/features/admin/_shared/MqeExpressionInput.vue b/apps/ui/src/features/admin/_shared/MqeExpressionInput.vue new file mode 100644 index 0000000..9e1cbd0 --- /dev/null +++ b/apps/ui/src/features/admin/_shared/MqeExpressionInput.vue @@ -0,0 +1,223 @@ +<!-- + 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. +--> +<!-- + MQE expression field. A single-line inline input for quick edits plus an + expand button that opens a larger pop-out so a long expression can be + read and edited in full. Shared by the layer-dashboard and overview + template editors via `v-model`. Self-contained (own teleported overlay) + so it carries no cross-feature dependency. +--> +<script setup lang="ts"> +import { nextTick, ref } from 'vue'; + +const props = withDefaults( + defineProps<{ + /** Optional so callers can bind an `mqe?: string` field directly. */ + modelValue?: string; + placeholder?: string; + readonly?: boolean; + /** Header label for the pop-out. */ + title?: string; + }>(), + { modelValue: '', placeholder: '', readonly: false, title: 'MQE expression' }, +); +const emit = defineEmits<{ 'update:modelValue': [value: string] }>(); + +const open = ref(false); +const draft = ref(''); +const taRef = ref<HTMLTextAreaElement | null>(null); + +function openPopout(): void { + draft.value = props.modelValue ?? ''; + open.value = true; + void nextTick(() => taRef.value?.focus()); +} +function apply(): void { + if (props.readonly) { + open.value = false; + return; + } + // MQE is single-line; fold any stray newlines the larger box invited + // back into spaces so the saved expression stays valid. + emit('update:modelValue', draft.value.replace(/\s*\n\s*/g, ' ').trim()); + open.value = false; +} +function cancel(): void { + open.value = false; +} +function onKeydown(e: KeyboardEvent): void { + if (e.key === 'Escape') { + e.preventDefault(); + cancel(); + } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + apply(); + } +} +</script> + +<template> + <div class="mqe-input"> + <input + class="mono mqe-inline" + :value="modelValue" + :placeholder="placeholder" + :readonly="readonly" + @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)" + /> + <button type="button" class="mqe-expand" :title="`Expand — ${title}`" @click="openPopout">⤢</button> + </div> + + <Teleport to="body"> + <div v-if="open" class="mqe-pop-backdrop" @mousedown.self="cancel"> + <div class="mqe-pop" role="dialog" aria-modal="true" @keydown="onKeydown"> + <header class="mqe-pop-head"> + <span>{{ title }}</span> + <button type="button" class="mqe-pop-close" title="Close (Esc)" @click="cancel">×</button> + </header> + <textarea + ref="taRef" + v-model="draft" + class="mono mqe-pop-area" + :readonly="readonly" + :placeholder="placeholder" + spellcheck="false" + ></textarea> + <footer class="mqe-pop-foot"> + <span class="mqe-pop-hint">⌘/Ctrl + Enter to apply · Esc to cancel</span> + <span class="spacer"></span> + <button type="button" class="sw-btn ghost small" @click="cancel">Cancel</button> + <button type="button" class="sw-btn small" :disabled="readonly" @click="apply">Apply</button> + </footer> + </div> + </div> + </Teleport> +</template> + +<style scoped> +.mqe-input { + display: flex; + gap: 4px; + align-items: center; + min-width: 0; + width: 100%; +} +.mqe-inline { + flex: 1 1 auto; + min-width: 0; + height: 26px; + padding: 0 8px; + background: var(--sw-bg-2); + border: 1px solid var(--sw-line-2); + border-radius: 4px; + color: var(--sw-fg-0); + font-family: var(--sw-mono); + font-size: 11px; +} +.mqe-inline:focus { + outline: none; + border-color: var(--sw-accent-line); +} +.mqe-expand { + flex: 0 0 auto; + width: 24px; + height: 26px; + background: var(--sw-bg-2); + border: 1px solid var(--sw-line-2); + border-radius: 4px; + color: var(--sw-fg-3); + cursor: pointer; + font-size: 12px; + line-height: 1; +} +.mqe-expand:hover { + color: var(--sw-fg-0); + border-color: var(--sw-accent-line); +} + +.mqe-pop-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.5); + display: grid; + place-items: center; +} +.mqe-pop { + width: min(760px, 90vw); + background: var(--sw-bg-1); + border: 1px solid var(--sw-line); + border-radius: 8px; + display: flex; + flex-direction: column; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); +} +.mqe-pop-head { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--sw-line); + font-size: 12px; + font-weight: 600; + color: var(--sw-fg-0); +} +.mqe-pop-close { + margin-left: auto; + width: 24px; + height: 24px; + background: none; + border: none; + color: var(--sw-fg-3); + font-size: 14px; + cursor: pointer; +} +.mqe-pop-close:hover { + color: var(--sw-fg-0); +} +.mqe-pop-area { + margin: 12px 14px; + min-height: 220px; + resize: vertical; + background: var(--sw-bg-2); + border: 1px solid var(--sw-line-2); + border-radius: 6px; + color: var(--sw-fg-0); + font-family: var(--sw-mono); + font-size: 12.5px; + line-height: 1.6; + padding: 10px 12px; +} +.mqe-pop-area:focus { + outline: none; + border-color: var(--sw-accent-line); +} +.mqe-pop-foot { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-top: 1px solid var(--sw-line); +} +.mqe-pop-foot .spacer { + flex: 1; +} +.mqe-pop-hint { + font-size: 10px; + color: var(--sw-fg-3); +} +</style> diff --git a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue index db3f1ee..87a0fe5 100644 --- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue +++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue @@ -59,6 +59,7 @@ import { mockCardValue, mockLineSeries, mockRecordRows, mockTopGroups } from './ import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue'; import { refreshConfigBundle } from '@/controls/configBundle'; import TemplateStatusBadge from '@/features/admin/_shared/TemplateStatusBadge.vue'; +import MqeExpressionInput from '@/features/admin/_shared/MqeExpressionInput.vue'; import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync'; import Modal from '@/features/operate/_shared/Modal.vue'; import MonacoDiff from '@/features/operate/_shared/MonacoDiff.vue'; @@ -824,14 +825,67 @@ async function addAndSelectWidget(): Promise<void> { selectedIdx.value = currentWidgets.value.length - 1; } -function expressionsToText(arr: string[]): string { - return arr.join('\n'); +/* ------------------------------------------------------------------- * + * Per-expression rows. Each MQE expression carries its own label / unit + * / y-axis on one row, so the three used to be index-aligned by line + * number across separate textareas — now they're edited together and + * can't drift. Label / unit / axis arrays are kept padded to the + * expression count so index i always lines up. + * ------------------------------------------------------------------- */ +/** Label + unit only matter for tab-switching `top` widgets and + * multi-series `line` widgets; a single-expression card/line hides + * them to keep the row compact. */ +const showExprMeta = computed(() => { + const w = selectedWidget.value; + return !!w && (w.type === 'top' || (w.expressions?.length ?? 0) > 1); +}); +function padTo<T>(arr: T[] | undefined, len: number, fill: T): T[] { + const a = [...(arr ?? [])]; + while (a.length < len) a.push(fill); + return a; +} +function updateExpr(i: number, v: string): void { + const w = selectedWidget.value; + if (!w) return; + const a = [...w.expressions]; + a[i] = v; + w.expressions = a; +} +function updateExprLabel(i: number, v: string): void { + const w = selectedWidget.value; + if (!w) return; + const a = padTo(w.expressionLabels, w.expressions.length, ''); + a[i] = v; + w.expressionLabels = a; +} +function updateExprUnit(i: number, v: string): void { + const w = selectedWidget.value; + if (!w) return; + const a = padTo(w.expressionUnits, w.expressions.length, ''); + a[i] = v; + w.expressionUnits = a; } -function textToExpressions(s: string): string[] { - return s - .split('\n') - .map((x) => x.trim()) - .filter((x) => x.length > 0); +function updateExprAxis(i: number, v: number): void { + const w = selectedWidget.value; + if (!w) return; + const a = padTo(w.expressionAxes, w.expressions.length, 0); + a[i] = v === 1 ? 1 : 0; + w.expressionAxes = a; +} +function addExpr(): void { + const w = selectedWidget.value; + if (!w) return; + w.expressions = [...w.expressions, '']; +} +function removeExpr(i: number): void { + const w = selectedWidget.value; + if (!w || w.expressions.length <= 1) return; + const drop = <T>(arr: T[] | undefined): T[] | undefined => + arr ? arr.filter((_, j) => j !== i) : arr; + w.expressions = w.expressions.filter((_, j) => j !== i); + w.expressionLabels = drop(w.expressionLabels); + w.expressionUnits = drop(w.expressionUnits); + w.expressionAxes = drop(w.expressionAxes); } @@ -1290,46 +1344,93 @@ const effectiveOrderBy = computed( ); /** - * Scope-aware `visibleWhen` placeholder + hover hint. Two supported - * predicate forms: - * - * <metric> has value The SPA evaluates this against the widget's - * own MQE result. If at least one bucket is - * non-null, the widget renders. Useful when - * the metric only exists for some services - * (e.g. service_mq_consume_count only on MQ - * producers). - * - * #entity.<attr> The SPA evaluates this against the *entity's* - * attributes. Only meaningful on scopes where - * entities carry attributes — i.e. INSTANCE - * (jvm / language / host etc.) and to a lesser - * extent ENDPOINT. Service-scope entities - * don't expose attributes, so the predicate is - * a no-op there. - * - * The placeholder / tooltip swap so operators editing an instance - * widget see entity-attribute syntax, and service-widget operators see - * the metric-has-value form. + * Structured `visibleWhen` editor. The gate is one of: + * - none — always visible + * - mqe — exists / > / < over an MQE expression's result + * - entity — exists / equals against the selected instance's attributes + * The five computeds below bridge the discriminated-union shape to flat + * v-model bindings; switching kind / op rebuilds the object so its fields + * always match (e.g. `exists` carries no `value`). */ -function visibleWhenPlaceholder(scope: DashboardScope): string { - if (scope === 'instance') return "#entity.jvm or instance_jvm_cpu has value"; - if (scope === 'endpoint') return 'endpoint_cpm has value'; - return 'service_mq_consume_count has value'; -} +type VwKind = 'none' | 'mqe' | 'entity'; + function visibleWhenHint(scope: DashboardScope): string { - const common = - 'Hide the widget unless this predicate is truthy.\n' + - '\n' + - " <metric> has value — the widget's own MQE returned non-null data."; - const entityHint = - '\n #entity.<attr> — the active entity has the named attribute (e.g. #entity.jvm,\n' + - ' #entity.language). Only meaningful for instance / endpoint scopes;\n' + - " service-scope entities don't carry attributes."; - if (scope === 'instance' || scope === 'endpoint') return common + entityHint; - return common + '\n (#entity.* predicates are entity-attribute-based and apply best on Instance scope.)'; + const base = + 'Hide this widget unless the gate passes.\n' + + 'MQE — has value: the expression returns any value; > / <: any value above / below the threshold.\n' + + "A gate naming one of the widget's own expressions self-gates (free); a different metric gates the whole group (probed once, skips the group when empty)."; + const entity = + scope === 'instance' + ? '\nEntity — matches the selected instance’s attributes (e.g. language equals JAVA). exists = present & non-empty; equals is case-insensitive.' + : '\nEntity gates apply only on the Instance scope (Service / Endpoint entities carry no attributes) and are ignored elsewhere.'; + return base + entity; } +const vwKindModel = computed<VwKind>({ + get() { + const vw = selectedWidget.value?.visibleWhen; + if (!vw) return 'none'; + return vw.kind === 'entity' ? 'entity' : 'mqe'; + }, + set(k) { + const w = selectedWidget.value; + if (!w) return; + if (k === 'none') w.visibleWhen = undefined; + else if (k === 'mqe') w.visibleWhen = { kind: 'mqe', expression: w.expressions?.[0] ?? '', op: 'exists' }; + else w.visibleWhen = { kind: 'entity', attribute: 'language', op: 'eq', value: 'JAVA' }; + }, +}); +const vwTarget = computed<string>({ + get() { + const vw = selectedWidget.value?.visibleWhen; + if (!vw) return ''; + return vw.kind === 'mqe' ? vw.expression : vw.attribute; + }, + set(v) { + const vw = selectedWidget.value?.visibleWhen; + if (!vw) return; + if (vw.kind === 'mqe') vw.expression = v; + else vw.attribute = v; + }, +}); +const vwOp = computed<string>({ + get() { + return selectedWidget.value?.visibleWhen?.op ?? 'exists'; + }, + set(op) { + const w = selectedWidget.value; + const vw = w?.visibleWhen; + if (!w || !vw) return; + if (vw.kind === 'mqe') { + w.visibleWhen = + op === 'exists' + ? { kind: 'mqe', expression: vw.expression, op: 'exists' } + : { kind: 'mqe', expression: vw.expression, op: op === 'lt' ? 'lt' : 'gt', value: 'value' in vw ? vw.value : 0 }; + } else { + w.visibleWhen = + op === 'eq' + ? { kind: 'entity', attribute: vw.attribute, op: 'eq', value: 'value' in vw ? String(vw.value) : '' } + : { kind: 'entity', attribute: vw.attribute, op: 'exists' }; + } + }, +}); +const vwNeedsValue = computed(() => { + const op = selectedWidget.value?.visibleWhen?.op; + return op === 'gt' || op === 'lt' || op === 'eq'; +}); +const vwValue = computed<string>({ + get() { + const vw = selectedWidget.value?.visibleWhen; + return vw && 'value' in vw ? String(vw.value) : ''; + }, + set(v) { + const vw = selectedWidget.value?.visibleWhen; + if (!vw || !('value' in vw)) return; + if (vw.kind === 'mqe') vw.value = Number(v) || 0; + else vw.value = v; + }, +}); + /** * Component toggles surfaced in the admin editor. Each entry binds to * a key on the template's `components` block; flipping the toggle @@ -2958,62 +3059,103 @@ const namingTest = computed<NamingTestResult>(() => { </div> <div class="d-section"> <span class="d-label">MQE expressions</span> - <textarea - class="mono" - rows="4" - :value="expressionsToText(selectedWidget.expressions)" - @input="selectedWidget.expressions = textToExpressions(($event.target as HTMLTextAreaElement).value)" - placeholder="one expression per line" - ></textarea> + <div v-if="showExprMeta" class="expr-cols"> + <span class="expr-col-mqe">expression</span> + <span class="expr-col-label">{{ selectedWidget.type === 'top' ? 'tab label' : 'series label' }}</span> + <span class="expr-col-unit">unit</span> + <span v-if="selectedWidget.type === 'line'" class="expr-col-axis">axis</span> + <span class="expr-col-del"></span> + </div> + <div class="expr-rows"> + <div v-for="(expr, i) in selectedWidget.expressions" :key="i" class="expr-row"> + <MqeExpressionInput + class="expr-mqe" + :model-value="expr" + placeholder="instance_jvm_cpu" + :title="`Expression ${i + 1}`" + @update:model-value="updateExpr(i, $event)" + /> + <input + v-if="showExprMeta" + class="expr-label" + :value="selectedWidget.expressionLabels?.[i] ?? ''" + @input="updateExprLabel(i, ($event.target as HTMLInputElement).value)" + :placeholder="selectedWidget.type === 'top' ? 'Traffic' : 'p99'" + /> + <input + v-if="showExprMeta" + class="mono expr-unit" + :value="selectedWidget.expressionUnits?.[i] ?? ''" + @input="updateExprUnit(i, ($event.target as HTMLInputElement).value)" + placeholder="ms" + /> + <select + v-if="showExprMeta && selectedWidget.type === 'line'" + class="mono expr-axis" + :value="String(selectedWidget.expressionAxes?.[i] ?? 0)" + @change="updateExprAxis(i, Number(($event.target as HTMLSelectElement).value))" + title="Y-axis (Left / Right) — for dual-axis line widgets" + > + <option value="0">L</option> + <option value="1">R</option> + </select> + <button + type="button" + class="expr-del" + title="Remove expression" + :disabled="selectedWidget.expressions.length <= 1" + @click="removeExpr(i)" + >×</button> + </div> + </div> + <button type="button" class="sw-btn ghost small expr-add" @click="addExpr">+ expression</button> <p class="d-hint"> - For <code>top</code> widgets, each expression becomes a tab. - For <code>line</code>, each becomes a series. + For <code>top</code> widgets each expression is a switchable tab; for + <code>line</code> each is a series. Label / unit / axis apply per expression. </p> </div> - <div - v-if="selectedWidget.type === 'top' || (selectedWidget.expressions?.length ?? 0) > 1" - class="d-section" - > - <span class="d-label">Expression labels (one per line)</span> - <textarea - rows="3" - :value="expressionsToText(selectedWidget.expressionLabels ?? [])" - @input="selectedWidget.expressionLabels = textToExpressions(($event.target as HTMLTextAreaElement).value)" - :placeholder="selectedWidget.type === 'top' ? 'Traffic\nSlow\nSuccessful Rate' : 'count\nlatency'" - ></textarea> - </div> - <div - v-if="selectedWidget.type === 'top' || (selectedWidget.expressions?.length ?? 0) > 1" - class="d-section" - > - <span class="d-label">Expression units (one per line)</span> - <textarea - class="mono" - rows="2" - :value="expressionsToText(selectedWidget.expressionUnits ?? [])" - @input="selectedWidget.expressionUnits = textToExpressions(($event.target as HTMLTextAreaElement).value)" - placeholder="rpm ms %" - ></textarea> - </div> - <div v-if="selectedWidget.type === 'line'" class="d-section"> - <span class="d-label">Y-axis index per expression (0 = left, 1 = right)</span> - <input - class="mono" - :value="(selectedWidget.expressionAxes ?? []).join(',')" - @input="selectedWidget.expressionAxes = (($event.target as HTMLInputElement).value || '').split(',').map((s) => Number(s.trim())).filter((n) => Number.isFinite(n))" - placeholder="0,1" - /> - <p class="d-hint">Comma-separated. Use for dual-axis line widgets.</p> - </div> <div class="d-section"> <span class="d-label" :title="visibleWhenHint(activeScope)"> Visible when (optional) </span> - <input - class="mono" - v-model="selectedWidget.visibleWhen" - :placeholder="visibleWhenPlaceholder(activeScope)" - /> + <div class="vw-row"> + <select class="mono" v-model="vwKindModel"> + <option value="none">Always visible</option> + <option value="mqe">MQE metric…</option> + <option value="entity">Entity attribute…</option> + </select> + <template v-if="vwKindModel === 'mqe'"> + <MqeExpressionInput + class="vw-target" + v-model="vwTarget" + placeholder="instance_jvm_cpu" + title="Gate expression" + /> + <select class="mono" v-model="vwOp"> + <option value="exists">has value</option> + <option value="gt">></option> + <option value="lt"><</option> + </select> + </template> + <template v-else-if="vwKindModel === 'entity'"> + <input class="mono vw-target" v-model="vwTarget" placeholder="language" /> + <select class="mono" v-model="vwOp"> + <option value="exists">exists</option> + <option value="eq">equals</option> + </select> + </template> + <input + v-if="vwNeedsValue" + class="mono vw-val" + v-model="vwValue" + :type="vwKindModel === 'mqe' ? 'number' : 'text'" + :placeholder="vwKindModel === 'entity' ? 'JAVA' : '0'" + /> + </div> + <p v-if="vwKindModel === 'mqe' && !vwTarget.trim()" class="d-hint" style="color: var(--sw-warn)"> + Set a metric expression — an empty gate is ignored and the widget always shows. + </p> + <p class="d-hint" style="white-space: pre-line">{{ visibleWhenHint(activeScope) }}</p> </div> <div class="d-section"> <label class="d-check"> @@ -4559,7 +4701,16 @@ const namingTest = computed<NamingTestResult>(() => { flex-direction: column; background: var(--sw-bg-1); border-left: 1px solid var(--sw-line); - max-height: calc(100vh - 120px); + /* Sticky within the scrolling main pane (topbar is fixed above it, so + * top: 0 pins just below it) and full-height, so the editor uses the + * whole viewport and follows the canvas scroll instead of being a short + * box stranded at the top of a tall grid cell. `align-self: start` + * stops the grid from stretching it (which would defeat sticky). */ + position: sticky; + top: 0; + align-self: start; + height: calc(100vh - 52px); + max-height: calc(100vh - 52px); } .drawer-head { display: flex; @@ -4594,6 +4745,8 @@ const namingTest = computed<NamingTestResult>(() => { flex-direction: column; gap: 12px; overflow-y: auto; + flex: 1 1 auto; + min-height: 0; } .d-row { display: flex; @@ -4661,6 +4814,62 @@ const namingTest = computed<NamingTestResult>(() => { outline: none; border-color: var(--sw-accent-line); } +.d-section select { + background: var(--sw-bg-2); + border: 1px solid var(--sw-line-2); + border-radius: 4px; + color: var(--sw-fg-0); + font: inherit; + font-size: 11px; + height: 26px; + padding: 0 6px; +} +.d-section select.mono { font-family: var(--sw-mono); } +.d-section select:focus { outline: none; border-color: var(--sw-accent-line); } +.vw-row { + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; +} +.vw-row .vw-target { flex: 1 1 140px; min-width: 90px; } +.vw-row .vw-val { width: 72px; flex: 0 0 auto; } +.vw-row select { flex: 0 0 auto; } +.expr-rows { display: flex; flex-direction: column; gap: 4px; } +.expr-row { display: flex; gap: 6px; align-items: center; } +.expr-row .expr-mqe { flex: 1 1 auto; min-width: 0; } +.expr-row .expr-label { flex: 0 0 84px; width: 84px; } +.expr-row .expr-unit { flex: 0 0 52px; width: 52px; } +.expr-row .expr-axis { flex: 0 0 46px; width: 46px; padding: 0 4px; } +.expr-del { + flex: 0 0 auto; + width: 24px; + height: 26px; + background: var(--sw-bg-2); + border: 1px solid var(--sw-line-2); + border-radius: 4px; + color: var(--sw-fg-3); + cursor: pointer; + font-size: 14px; + line-height: 1; +} +.expr-del:hover:not([disabled]) { color: var(--sw-err); border-color: var(--sw-err); } +.expr-del[disabled] { opacity: 0.35; cursor: default; } +.expr-cols { + display: flex; + gap: 6px; + margin-bottom: 1px; + font-size: 9.5px; + color: var(--sw-fg-3); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.expr-cols .expr-col-mqe { flex: 1 1 auto; } +.expr-cols .expr-col-label { flex: 0 0 84px; } +.expr-cols .expr-col-unit { flex: 0 0 52px; } +.expr-cols .expr-col-axis { flex: 0 0 46px; } +.expr-cols .expr-col-del { flex: 0 0 24px; } +.expr-add { margin-top: 4px; align-self: flex-start; } .d-hint { font-size: 10px; color: var(--sw-fg-3); diff --git a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue index 48bee30..8fb7ef8 100644 --- a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue +++ b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue @@ -56,6 +56,7 @@ import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue'; import { refreshConfigBundle } from '@/controls/configBundle'; import { stableStringify } from '@/utils/stableJson'; import TemplateStatusBadge from '@/features/admin/_shared/TemplateStatusBadge.vue'; +import MqeExpressionInput from '@/features/admin/_shared/MqeExpressionInput.vue'; import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync'; // Real overview widget primitives — the preview renders these with mock // data so it matches the live page pixel-for-pixel (no bespoke copy). @@ -1375,7 +1376,7 @@ function widgetKindLabel(type: OverviewWidget['type']): string { <div v-if="w.type === 'metric'" class="ot__row"> <label class="ot__field ot__field--wide"> <span>MQE</span> - <input v-model="w.mqe" type="text" class="ot__in ot__in--mono" placeholder="service_cpm" /> + <MqeExpressionInput v-model="w.mqe" placeholder="service_cpm" title="Widget MQE" /> </label> <label class="ot__field"> <span>Unit</span> @@ -1454,7 +1455,7 @@ function widgetKindLabel(type: OverviewWidget['type']): string { </label> <label v-if="(k.source ?? 'mqe') === 'mqe'" class="ot__field ot__field--full"> <span>MQE</span> - <input v-model="k.mqe" type="text" class="ot__in ot__in--mono" /> + <MqeExpressionInput v-model="k.mqe" title="KPI MQE" /> </label> <p v-else class="ot__none">Value comes from the service count (listServices) — no MQE.</p> <div class="ot__kpi-card-grid"> diff --git a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue index 4c6085e..709894b 100644 --- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue +++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue @@ -501,51 +501,13 @@ function hasTopData(w: { id: string; type: string }): boolean { } /** - * Evaluate a widget's `visibleWhen` predicate. - * - `<metric_name> has value` → the widget's result has a non-null - * scalar / a non-empty series. - * - `#entity.<key>` → entity attribute exists (deferred — - * we don't surface entity attributes yet; defaults true). - * - anything else → treated as "always visible". - * - * Empty / unset → always visible. Predicates that mention a metric not - * in the widget's own results never hide the widget either; they're - * advisory hints for the operator's mental model. + * `visibleWhen` is now evaluated BFF-side: a gated-out widget comes back + * flagged `hidden: true` (group/entity misses also skip their MQE there). + * The grid drops those; a widget with no result yet (loading) stays + * visible until its result lands. */ -function isVisible( - w: { id: string; visibleWhen?: string }, - result: - | { - value?: number | null; - series?: Array<{ data: Array<number | null> }>; - topList?: Array<unknown>; - topGroups?: Array<{ items: Array<unknown> }>; - records?: Array<unknown>; - table?: Array<unknown>; - } - | undefined, -): boolean { - const cond = w.visibleWhen?.trim(); - if (!cond) return true; - const hasValueMatch = /^(\S+)\s+has\s+value$/i.exec(cond); - if (hasValueMatch && result) { - if (result.value !== undefined && result.value !== null) return true; - if (result.series && result.series.some((s) => s.data.some((v) => v !== null))) return true; - // Top + record widgets: a non-empty list counts as "has value". - // Without these checks, every gated `top` / `record` widget would - // hide itself the moment the BFF returns its rows, since neither - // .value nor .series is populated. - if (result.topList && result.topList.length > 0) return true; - if (result.topGroups && result.topGroups.some((g) => g.items.length > 0)) return true; - if (result.records && result.records.length > 0) return true; - return false; - } - if (cond.startsWith('#entity.')) { - // Entity-attribute predicates need an attributes feed we don't - // surface yet. Render the widget unconditionally for now. - return true; - } - return true; +function isHidden(id: string): boolean { + return resultsById.value.get(id)?.hidden === true; } </script> @@ -745,7 +707,7 @@ function isVisible( </div> <div v-else class="grid"> <div - v-for="w in widgets.filter((wi) => isVisible(wi, resultsById.get(wi.id)))" + v-for="w in widgets.filter((wi) => !isHidden(wi.id))" :key="w.id" class="widget sw-card" :style="{ ...gridStyle(w), '--widget-accent': widgetColor(w) }" diff --git a/packages/api-client/src/dashboard.ts b/packages/api-client/src/dashboard.ts index 08774b4..23b975c 100644 --- a/packages/api-client/src/dashboard.ts +++ b/packages/api-client/src/dashboard.ts @@ -52,6 +52,39 @@ */ export type DashboardWidgetType = 'card' | 'line' | 'top' | 'record' | 'table'; +/** + * Structured widget visibility predicate. When set on a widget, the BFF + * evaluates it and flags the widget's result `hidden: true` when the + * predicate is false; the SPA drops hidden widgets from the grid. + * + * Two kinds: + * - `mqe` — gate on an MQE expression's result. `exists` passes when + * at least one non-null value appears anywhere in the result + * (every labeled series, every bucket). `gt` / `lt` pass when + * at least one value is above / below `value`. When the + * expression is one of the widget's own `expressions` it is a + * *self-gate* (evaluated from the widget's own data, no extra + * query); otherwise it is a *group-gate* — the BFF probes the + * expression once for all widgets that share it and skips the + * whole group's MQE when it fails. + * - `entity` — gate on the active entity's attributes. Only meaningful on + * the Instance scope (Service / Endpoint entities carry no + * attribute bag). `exists` passes when the named attribute is + * present with a non-empty value; `eq` compares it to `value` + * case-insensitively (e.g. `attribute: 'language', value: 'java'` + * matches OAP's uppercase `JAVA`). On non-Instance scopes an + * entity gate is a no-op (always visible). + * + * Legacy free-text predicates (`"<metric> has value"`, `#entity.<key>`) are + * no longer parsed — they degrade to ungated (the BFF's tolerant schema maps + * any non-conforming value to `undefined`). + */ +export type VisibleWhen = + | { kind: 'mqe'; expression: string; op: 'exists' } + | { kind: 'mqe'; expression: string; op: 'gt' | 'lt'; value: number } + | { kind: 'entity'; attribute: string; op: 'exists' } + | { kind: 'entity'; attribute: string; op: 'eq'; value: string }; + /** * Per-entity dashboard scope. Each layer carries an independent widget * set per scope; the SPA picks the right set based on the active @@ -143,13 +176,10 @@ export interface DashboardWidget { /** Row span (number of 14px rows). Default 8. */ rowSpan?: number; /** - * Optional visibility predicate. When set, the widget only renders if - * the predicate is truthy for the active entity. Supported forms: - * - `#entity.<key>` — entity attribute exists - * - `<metric_name> has value` — at least one bucket is non-null - * Future-compatible; the SPA evaluates this client-side. + * Optional visibility predicate — see {@link VisibleWhen}. Evaluated + * BFF-side; hidden widgets come back flagged `hidden: true`. */ - visibleWhen?: string; + visibleWhen?: VisibleWhen; /** * When true, the BFF runs this widget's MQE against the whole layer * rather than scoping it to the currently-selected service. Used for @@ -228,6 +258,10 @@ export interface DashboardWidgetResult { id: string; /** Set when every MQE expression for this widget errored. */ error?: string; + /** Set when the widget's `visibleWhen` predicate evaluated false. The + * SPA drops these from the grid; group/entity-gated misses also carry + * no payload (their MQE was skipped server-side). */ + hidden?: boolean; /** `card` payload — single scalar (avg across the time window). */ value?: number | null; /** `line` payload — one entry per expression. The line chart picks diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 5a35336..8225052 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -49,6 +49,7 @@ export type { DashboardWidget, DashboardWidgetResult, DashboardWidgetType, + VisibleWhen, } from './dashboard.js'; export type { TopologyMetricDef,
