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&#10;ms&#10;%"
-                  ></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">&gt;</option>
+                        <option value="lt">&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,

Reply via email to