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
The following commit(s) were added to refs/heads/main by this push:
new 61616b4 feat(dashboard): structured visibleWhen widget gates with
server-side eval (#46)
61616b4 is described below
commit 61616b4395e69aa90cf98ee9d26b680e17f2059e
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Mon Jun 8 22:08:25 2026 +0800
feat(dashboard): structured visibleWhen widget gates with server-side eval
(#46)
Replaces the free-text `visibleWhen` widget predicate with a **structured
gate evaluated server-side**, so a dashboard widget renders only when it's
relevant to the selected entity — and a gated-out group skips its queries
entirely.
`visibleWhen` is now a discriminated object:
```ts
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 };
```
### Gate semantics
- **MQE** — `exists` / `>` / `<`, existential across **every labeled series
and bucket** of the expression's result.
- Naming the widget's **own** expression → *self-gate* (evaluated from
the widget's own batch result; **no extra query**).
- Naming a **different** metric → *group-gate* on one shared signal:
probed **once** (deduped, keyed by expression + `layerScope`) and the whole
group's MQE is **skipped** when empty — a non-JVM instance never runs the JVM
widget queries.
- **Entity** (Instance scope only) — `exists` (present & non-empty) / `eq`
(case-insensitive, e.g. `language` equals `JAVA`) against the selected
instance's attributes. Service / Endpoint entities carry no attributes, so
entity gates are ignored there.
Evaluation moved to the BFF; the SPA drops widgets flagged `hidden` (the
client-side `isVisible` is retired).
### Migration
None. Layer dashboards saved with the old free-text predicate (`"<metric>
has value"`, `#entity.*`) are **tolerated** — the schema maps any
non-conforming value to `undefined` (ungated) rather than failing the parse —
and keep rendering. Re-set the gate in the new editor to restore it.
## Admin editor improvements
- Structured **Visible when** control (kind → op → value), replacing the
unvalidated free-text box; corrected the inaccurate entity hint (`#entity.jvm`
was fictional → `language equals JAVA`).
- **Per-expression rows**: each MQE expression edits its `expression +
label + unit + axis` together, replacing the three index-aligned parallel
textareas.
- Reusable **`MqeExpressionInput`** with an expand pop-out for long
expressions — used by both the layer and overview editors.
- Sticky, full-height widget drawer (was a short box stranded in a tall
grid cell).
---
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,