You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/06/15 11:16:12 UTC

(skywalking-horizon-ui) branch feat/multi-entity-lock created (now 5d4de13)

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a change to branch feat/multi-entity-lock
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


      at 5d4de13  feat(layer): lock & compare multiple entities on layer dashboards

This branch includes the following new commits:

     new 5d4de13  feat(layer): lock & compare multiple entities on layer dashboards

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(skywalking-horizon-ui) 01/01: feat(layer): lock & compare multiple entities on layer dashboards

Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/multi-entity-lock
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 5d4de13f7812ed0f5052e9f706ae217fae673691
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Mon Jun 15 19:15:53 2026 +0800

    feat(layer): lock & compare multiple entities on layer dashboards
    
    Lock several services / instances / endpoints — including ones from
    different services — and compare them inline on the service, instance,
    and endpoint dashboards. Behind the FF_ENTITY_COMPARE preview flag
    (VITE_FF_ENTITY_COMPARE=1, or localStorage 'horizon:ff:entity-compare'),
    off by default, so pages with nothing locked render exactly as before.
    
    - The currently-viewed (banner) entity is always the CURRENT member of
      the comparison (accent hue, drives the header KPIs); pins add to it
      (up to six, each a stable hue). Comparison-bar chips are display-only
      — they never refocus the banner; the top selector / list does. '×'
      unpins.
    - Each widget compares inline in its own tile: line = N overlaid hued
      series; card = N rows; top-N / record = per-entity tabs + a merged
      "All"; table = an Entity column with an (others) fold. Labeled series
      lead with the label so it survives truncation; a widget shows whenever
      any compared entity has it.
    - Per-entity fan-out (one dashboard request per entity, bounded
      concurrency), progressive loading, per-entity error isolation.
    
    Also in this change:
    - Chart axes/tooltips render large numbers with SI suffixes (45.1k,
      1.34M) instead of scientific notation; the multi-series tooltip is a
      fixed, aligned table (name truncates, values right-align, unit in the
      header).
    - Fix Go runtime "Metadata Mspan"/"Metadata Mcache" widgets to report
      KB — they were showing raw bytes mislabeled as MB (~1000x too large).
    - Type the infra-3d OrbitControls / camera template refs (drop 3 `any`).
    - Carries pre-existing in-flight DSL rule-editor and banyandb template
      tweaks.
---
 CHANGELOG.md                                       |  15 +
 .../bff/src/bundled_templates/layers/banyandb.json |   3 +-
 apps/bff/src/bundled_templates/layers/general.json |  12 +-
 apps/bff/src/http/admin/dsl/_shared.ts             |  54 ++
 apps/bff/src/http/admin/dsl/rule.ts                |   8 +-
 apps/bff/src/http/query/dashboard.ts               |   8 +-
 apps/ui/src/components/charts/TimeChart.vue        | 156 ++++--
 apps/ui/src/components/icons/Icon.vue              |   8 +-
 apps/ui/src/features/infra-3d/Infra3DScene.vue     |  41 +-
 apps/ui/src/features/operate/dsl/DslEditorView.vue |  91 +++-
 apps/ui/src/features/operate/dsl/useRuleEditor.ts  |  47 ++
 apps/ui/src/i18n/locales/de.json                   |  21 +-
 apps/ui/src/i18n/locales/en.json                   |  22 +-
 apps/ui/src/i18n/locales/es.json                   |  21 +-
 apps/ui/src/i18n/locales/fr.json                   |  21 +-
 apps/ui/src/i18n/locales/ja.json                   |  23 +-
 apps/ui/src/i18n/locales/ko.json                   |  21 +-
 apps/ui/src/i18n/locales/pt.json                   |  21 +-
 apps/ui/src/i18n/locales/zh-CN.json                |  21 +-
 apps/ui/src/layer/LayerServiceSelector.vue         | 117 ++++-
 apps/ui/src/layer/LayerShell.vue                   |  24 +-
 apps/ui/src/layer/useLayerInstances.ts             |   9 +-
 apps/ui/src/layer/useLayerServiceName.ts           |  11 +-
 apps/ui/src/layer/useSelectedEndpoint.ts           |  19 +-
 apps/ui/src/layer/useSelectedInstance.ts           |  19 +-
 apps/ui/src/layer/useSelectedService.ts            |   8 +-
 .../render/layer-dashboard/LayerDashboardsView.vue | 569 ++++++++++++++++++++-
 .../render/layer-dashboard/entityFanout.test.ts    | 135 +++++
 apps/ui/src/render/layer-dashboard/entityFanout.ts | 145 ++++++
 .../render/layer-dashboard/useLayerDashboard.ts    | 186 ++++++-
 apps/ui/src/render/widgets/TableWidget.vue         | 112 +++-
 apps/ui/src/state/layerSelection.test.ts           | 126 +++++
 apps/ui/src/state/layerSelection.ts                |  98 ++++
 .../featureFlags.test.ts}                          |  36 +-
 apps/ui/src/utils/featureFlags.ts                  |  53 ++
 apps/ui/src/utils/metricColor.ts                   |  18 +
 apps/ui/src/utils/serviceName.ts                   |  15 +
 apps/ui/src/utils/useEntityPalette.test.ts         |  82 +++
 apps/ui/src/utils/useEntityPalette.ts              |  93 ++++
 packages/api-client/src/dashboard.ts               |   4 +
 40 files changed, 2341 insertions(+), 152 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ab442b..933ed60 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -55,6 +55,21 @@ The version line is shared by every package in the monorepo (apps + shared packa
 - When OAP's template store is unreachable, the instance map now shows the same empty + connectivity-banner state as the service map, rather than a misleading "not supported" — block and unsupported are no longer conflated.
 - **Localized across all eight UI languages.** The instance-map UI, the template-store-unreachable banner, and the remaining alarm / live-debugger strings are now translated in zh-CN, ja, ko, es, pt, de and fr (English stays the source) — no feature renders English-only for non-English operators.
 
+### Lock & compare entities on a layer dashboard
+
+- **Lock several services, instances, or endpoints — including ones from different services — and compare them in place** (preview — enable per build with `VITE_FF_ENTITY_COMPARE=1`, or per browser via `localStorage['horizon:ff:entity-compare'] = '1'` then reload). Pin entities from the service picker or the instance / endpoint list; instance and endpoint pins are cross-service, so instances belonging to different services can be compared side by side. A persistent, scope-aware **compari [...]
+- **The entity you're viewing is always part of the comparison** — it appears first, tagged `CURRENT` in the accent color (and still drives the header KPIs); pinned entities add to it, each in its own stable hue (up to six pins). The comparison-bar chips are display-only: clicking a chip never changes what you're viewing (no disruptive reload) and `×` unpins — switch the focused entity from the top selector / list as usual.
+- **Each widget compares inline in its own tile** — line widgets overlay one hued series per entity; card widgets show one row per entity; top-N and record widgets get per-entity tabs plus a merged "All" tab; table widgets gain an **Entity** column that groups rows by entity and folds each entity's long tail into one `(others)` row (summed for counts, count-only for latencies / percentiles where a sum would mislead). With nothing locked, every page renders exactly as before.
+- **Labeled series lead with the meaningful dimension** — a multi-label series reads `<label> · <service> · <instance>` so the label (e.g. a JVM thread state) survives when a long instance / endpoint id has to truncate; entities stay distinguishable by color. A widget that only some compared entities expose (e.g. JVM widgets when a Java service is pinned alongside a non-JVM one) shows whenever *any* compared entity has it.
+- **Progressive, per-entity loading** — each entity loads as its own request; tiles fill in as entities arrive and one slow or failed entity never blanks the others.
+- The Topology, Deployment, trace, and log pages are unaffected — comparison applies to the service / instance / endpoint dashboards only.
+- **Widget tooltips and legends show the widget title, never the raw MQE expression**, for un-labeled single-series line widgets; the multi-series tooltip is a fixed, aligned table — the entity name truncates and the values form one clean right-aligned column with the unit in the header.
+
+### Charts & bundled metrics
+
+- **Large numbers on chart axes and tooltips now use compact SI suffixes** (`45.1k`, `1.34M`, `2.5G`) instead of scientific notation (`4.51e4`), which operators found hard to read.
+- **Go runtime "Metadata Mspan" and "Metadata Mcache" widgets now report KB**, fixing values that were displayed as a mislabeled "MB" of raw bytes (≈1000× too large) — they now read in the same KB scale as the other Go metadata-size widgets.
+
 ### Deployment
 
 - New per-layer **Deployment** tab — the **deployment topology of all of a service's instances**: the instance-to-instance call graph **within a single service**. Where the instance map drills into the instances *between* two services, this shows how one service's own instances are deployed and talk to each other (e.g. a clustered store's nodes calling each other). Pick a service from the layer's Service header and the tab draws its instances as health-ring nodes with the intra-service c [...]
diff --git a/apps/bff/src/bundled_templates/layers/banyandb.json b/apps/bff/src/bundled_templates/layers/banyandb.json
index d78733f..6757f5c 100644
--- a/apps/bff/src/bundled_templates/layers/banyandb.json
+++ b/apps/bff/src/bundled_templates/layers/banyandb.json
@@ -41,7 +41,8 @@
       },
       {
         "metric": "err",
-        "label": "Errors/min",
+        "label": "Errors",
+        "unit": "/min",
         "mqe": "meter_banyandb_cluster_error_rate",
         "aggregation": "avg"
       }
diff --git a/apps/bff/src/bundled_templates/layers/general.json b/apps/bff/src/bundled_templates/layers/general.json
index b1bf624..6245233 100644
--- a/apps/bff/src/bundled_templates/layers/general.json
+++ b/apps/bff/src/bundled_templates/layers/general.json
@@ -634,9 +634,9 @@
         "id": "go_mspan",
         "title": "Golang Metadata Mspan",
         "type": "line",
-        "unit": "MB",
-        "expressions": ["meter_instance_golang_metadata_mspan_labeled"],
-        "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_metadata_mspan_labeled", "op": "exists" },
+        "unit": "KB",
+        "expressions": ["meter_instance_golang_metadata_mspan_labeled/1024"],
+        "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_metadata_mspan_labeled/1024", "op": "exists" },
         "span": 3,
         "rowSpan": 2
       },
@@ -644,9 +644,9 @@
         "id": "go_mcache",
         "title": "Golang Metadata Mcache",
         "type": "line",
-        "unit": "MB",
-        "expressions": ["meter_instance_golang_metadata_mcache_labeled"],
-        "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_metadata_mcache_labeled", "op": "exists" },
+        "unit": "KB",
+        "expressions": ["meter_instance_golang_metadata_mcache_labeled/1024"],
+        "visibleWhen": { "kind": "mqe", "expression": "meter_instance_golang_metadata_mcache_labeled/1024", "op": "exists" },
         "span": 3,
         "rowSpan": 2
       },
diff --git a/apps/bff/src/http/admin/dsl/_shared.ts b/apps/bff/src/http/admin/dsl/_shared.ts
index c3583b3..a7ba512 100644
--- a/apps/bff/src/http/admin/dsl/_shared.ts
+++ b/apps/bff/src/http/admin/dsl/_shared.ts
@@ -118,6 +118,60 @@ export function passOapError(err: unknown, reply: FastifyReply): FastifyReply {
   });
 }
 
+/**
+ * A structural rule apply (revertToBundled, a storage-change save) can take
+ * longer than OAP's admin request timeout: OAP returns 503
+ * `RequestTimeoutException` at ~10s, or the BFF's own fetch aborts first —
+ * but the apply keeps running under OAP's per-file lock and completes
+ * regardless (verified in the OAP runtime-rule engine logs). Treating that
+ * as a hard failure makes operators retry, which piles new waiters on the
+ * same lock. Instead we report `202 submitted` so the UI confirms by polling
+ * the rule status. Genuine 4xx rejections (409 requires_inactivate_first /
+ * requires_revert_to_bundled, 400 no_bundled_twin, …) are NOT timeouts and
+ * fall through to the normal error path.
+ */
+export function isOapApplyTimeout(err: unknown): boolean {
+  if (err instanceof RuntimeRuleApiError) {
+    return err.status === 503 || err.status === 504;
+  }
+  // The BFF's own AbortController fired (oap.timeoutMs) or a network timeout.
+  return err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError');
+}
+
+/** Map an apply-timeout to `202 submitted`; everything else to the normal
+ *  audited OAP-error path. Used by the structural mutation routes. */
+export function passSubmittedOrError(
+  err: unknown,
+  reply: FastifyReply,
+  deps: DslRouteDeps,
+  req: FastifyRequest,
+  action: string,
+  verb: string,
+  catalog: Catalog,
+  name: string,
+  details: Record<string, unknown> = {},
+): FastifyReply {
+  if (isOapApplyTimeout(err)) {
+    deps.audit.record({
+      action,
+      verb,
+      actor: req.session?.username ?? null,
+      outcome: 'submitted',
+      details: { catalog, name, ...details },
+      fromIp: req.ip,
+      sessionId: req.session?.sid,
+    });
+    return reply.code(202).send({
+      applyStatus: 'submitted',
+      catalog,
+      name,
+      message:
+        'OAP accepted the change and is applying it asynchronously; a structural apply can exceed the admin request timeout. Poll the rule status to confirm.',
+    });
+  }
+  return passOapErrorAudit(err, reply, deps, req, action, verb, catalog, name, details);
+}
+
 export function passOapErrorAudit(
   err: unknown,
   reply: FastifyReply,
diff --git a/apps/bff/src/http/admin/dsl/rule.ts b/apps/bff/src/http/admin/dsl/rule.ts
index 99bc4df..9390930 100644
--- a/apps/bff/src/http/admin/dsl/rule.ts
+++ b/apps/bff/src/http/admin/dsl/rule.ts
@@ -35,7 +35,7 @@ import {
   parseDeleteMode,
   parseRequiredCatalog,
   passOapError,
-  passOapErrorAudit,
+  passSubmittedOrError,
 } from './_shared.js';
 
 export function registerDslRuleRoutes(app: FastifyInstance, deps: DslRouteDeps): void {
@@ -107,7 +107,7 @@ export function registerDslRuleRoutes(app: FastifyInstance, deps: DslRouteDeps):
       });
       return reply.send(result);
     } catch (err) {
-      return passOapErrorAudit(err, reply, deps, req, 'addOrUpdate', verb, catalog, q.name);
+      return passSubmittedOrError(err, reply, deps, req, 'addOrUpdate', verb, catalog, q.name);
     }
   });
 
@@ -125,7 +125,7 @@ export function registerDslRuleRoutes(app: FastifyInstance, deps: DslRouteDeps):
         auditMutation(deps, req, 'inactivate', 'rule:write', catalog, q.name, result.applyStatus);
         return reply.send(result);
       } catch (err) {
-        return passOapErrorAudit(
+        return passSubmittedOrError(
           err,
           reply,
           deps,
@@ -159,7 +159,7 @@ export function registerDslRuleRoutes(app: FastifyInstance, deps: DslRouteDeps):
         auditMutation(deps, req, 'delete', verb, catalog, q.name, result.applyStatus, { mode });
         return reply.send(result);
       } catch (err) {
-        return passOapErrorAudit(err, reply, deps, req, 'delete', verb, catalog, q.name, { mode });
+        return passSubmittedOrError(err, reply, deps, req, 'delete', verb, catalog, q.name, { mode });
       }
     },
   );
diff --git a/apps/bff/src/http/query/dashboard.ts b/apps/bff/src/http/query/dashboard.ts
index 3a60584..6d152f9 100644
--- a/apps/bff/src/http/query/dashboard.ts
+++ b/apps/bff/src/http/query/dashboard.ts
@@ -906,8 +906,12 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar
           yAxisIndex?: number;
           unit?: string;
         }> = [];
-        widget.expressions.forEach((expr, eIdx) => {
-          const labeled = parseLabeledSeries(data[`w${wIdx}_e${eIdx}`], expr);
+        widget.expressions.forEach((_expr, eIdx) => {
+          // Fallback label for an un-labeled single series is the widget
+          // TITLE, not the raw MQE — operators read titles, never MQE.
+          // `expressionLabels[eIdx]` (when set) still takes precedence
+          // below; this only affects the no-label, no-override case.
+          const labeled = parseLabeledSeries(data[`w${wIdx}_e${eIdx}`], widget.title);
           if (!labeled) return;
           const labelOverride = widget.expressionLabels?.[eIdx];
           const axis = widget.expressionAxes?.[eIdx];
diff --git a/apps/ui/src/components/charts/TimeChart.vue b/apps/ui/src/components/charts/TimeChart.vue
index 4558278..a9aacdb 100644
--- a/apps/ui/src/components/charts/TimeChart.vue
+++ b/apps/ui/src/components/charts/TimeChart.vue
@@ -26,6 +26,7 @@
 <script setup lang="ts">
 import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
 import { formatDuration } from '@/utils/formatters';
+import { ENTITY_PALETTE } from '@/utils/metricColor';
 import * as echarts from 'echarts/core';
 import { LineChart } from 'echarts/charts';
 import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
@@ -40,6 +41,10 @@ interface Series {
   /** `0` = left axis (default), `1` = right axis. */
   yAxisIndex?: number;
   unit?: string;
+  /** Explicit per-series hue (CSS color or var). Overrides the
+   *  positional accent/SECONDARY cycle — used by the multi-entity
+   *  overlay so each entity keeps its palette hue. */
+  color?: string;
 }
 
 const props = withDefaults(
@@ -66,25 +71,59 @@ const props = withDefaults(
   },
 );
 
-// Scientific notation, trailing mantissa zeros trimmed. Axis and tooltip
-// share it so a tick and its hovered value read the same.
-function sci(v: number): string {
+// Compact magnitude with an SI suffix (k / M / G / T), ~3 significant
+// figures, trailing zeros trimmed — `45.1k`, not the scientific `4.51e4`
+// operators found unreadable. Axis and tooltip share it so a tick and its
+// hovered value read the same.
+function humanize(v: number): string {
   if (v === 0) return '0';
-  return v.toExponential(2).replace(/\.?0+e/, 'e').replace('e+', 'e');
+  const abs = Math.abs(v);
+  const units: Array<[number, string]> = [
+    [1e12, 'T'],
+    [1e9, 'G'],
+    [1e6, 'M'],
+    [1e3, 'k'],
+  ];
+  for (let i = 0; i < units.length; i += 1) {
+    const [scale, suffix] = units[i];
+    if (abs >= scale) {
+      const n = v / scale;
+      const dec = Math.abs(n) >= 100 ? 0 : Math.abs(n) >= 10 ? 1 : 2;
+      const rounded = parseFloat(n.toFixed(dec));
+      // Carry across the decade boundary: 999_999 rounds to 1000 at 'k' — show
+      // it in the next-larger unit ('1M') instead of the ugly '1000k'.
+      if (Math.abs(rounded) >= 1000 && i > 0) {
+        const [s2, suf2] = units[i - 1];
+        const n2 = v / s2;
+        const d2 = Math.abs(n2) >= 100 ? 0 : Math.abs(n2) >= 10 ? 1 : 2;
+        return `${parseFloat(n2.toFixed(d2))}${suf2}`;
+      }
+      return `${rounded}${suffix}`;
+    }
+  }
+  return String(v);
 }
 function formatVal(v: number): string {
   if (props.format === 'duration') return `${formatDuration(v)} ago`;
-  if (Math.abs(v) >= 10_000) return sci(v);
+  if (Math.abs(v) >= 10_000) return humanize(v);
   if (props.format === 'int') return Math.round(v).toString();
   if (props.format === 'decimal') return v.toFixed(1);
   return v.toFixed(2);
 }
 function formatAxis(v: number): string {
   if (props.format === 'duration') return formatDuration(v, true);
-  if (Math.abs(v) >= 10_000) return sci(v);
+  if (Math.abs(v) >= 10_000) return humanize(v);
   if (props.format === 'int') return Math.round(v).toString();
   return String(v);
 }
+// Tooltip rows are hand-built HTML (the tooltip is appendToBody, so scoped
+// CSS can't reach it) — escape the OAP-supplied series labels before
+// interpolating them in.
+function escHtml(s: string): string {
+  return s.replace(/[&<>"]/g, (c) =>
+    c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&quot;',
+  );
+}
 
 /**
  * Resolve a CSS variable like `var(--sw-accent)` to its computed RGB
@@ -106,14 +145,7 @@ function cssVar(token: string): string {
  * accent prop); subsequent lines pick from this palette so the lines
  * are distinguishable. Picks deliberately don't reuse the accent.
  */
-const SECONDARY = [
-  '#60a5fa', // info-ish (blue)
-  '#a78bfa', // purple
-  '#22d3ee', // cyan
-  '#f472b6', // pink
-  '#34d399', // ok-ish (green)
-  '#fbbf24', // amber
-];
+const SECONDARY = ENTITY_PALETTE;
 
 const container = ref<HTMLDivElement | null>(null);
 let chart: EChartsType | null = null;
@@ -140,7 +172,7 @@ function buildOption(): echarts.EChartsCoreOption {
       // at the card edge whenever a chart sits near the boundary.
       appendToBody: true,
       extraCssText:
-        'max-height: 60vh; overflow-y: auto; max-width: 360px; ' +
+        'max-height: 60vh; overflow-y: auto; max-width: 420px; ' +
         'box-shadow: 0 8px 24px rgba(0,0,0,0.45);',
       // Position callback returns an OBJECT form ({top, left/right,
       // bottom}) keyed to the chart's own bbox. With appendToBody:true
@@ -164,26 +196,78 @@ function buildOption(): echarts.EChartsCoreOption {
         _rect: unknown,
         size: { contentSize: [number, number]; viewSize: [number, number] },
       ): Record<string, number> {
+        // Returned anchors are CHART-LOCAL; with appendToBody ECharts offsets
+        // them by the chart's screen position, so the tooltip's viewport
+        // position is `containerRect.{top,left} + local`. We clamp the local
+        // anchor against the real VIEWPORT — `size.viewSize` is only the
+        // chart's own box (~160px here), far too small to place a tall
+        // multi-entity tooltip (N entities × percentile rows), which then
+        // bottom-anchored and ran off the top of the page. When the popup is
+        // larger than the viewport it pins to the top/left edge and scrolls
+        // (max-height + overflow-y in extraCssText above).
         const [tw, th] = size.contentSize;
-        const [vw, vh] = size.viewSize;
         const gap = 12;
-        const obj: Record<string, number> = {};
-        if (point[0] + tw + gap * 2 > vw) {
-          obj.right = vw - point[0] + gap;
-        } else {
-          obj.left = point[0] + gap;
-        }
-        if (point[1] + th + gap * 2 > vh) {
-          obj.bottom = vh - point[1] + gap;
-        } else {
-          obj.top = point[1] + gap;
-        }
-        return obj;
+        const m = 8; // keep this far clear of the viewport edges
+        const r = container.value?.getBoundingClientRect();
+        const rTop = r ? r.top : 0;
+        const rLeft = r ? r.left : 0;
+        // Prefer right-of / below the cursor; flip to the other side when
+        // that edge would overflow, then clamp into the viewport.
+        let left = point[0] + gap;
+        if (rLeft + left + tw > window.innerWidth - m) left = point[0] - tw - gap;
+        let top = point[1] + gap;
+        if (rTop + top + th > window.innerHeight - m) top = point[1] - th - gap;
+        const maxLeft = window.innerWidth - m - tw - rLeft;
+        const maxTop = window.innerHeight - m - th - rTop;
+        return {
+          left: Math.max(m - rLeft, Math.min(left, maxLeft)),
+          top: Math.max(m - rTop, Math.min(top, maxTop)),
+        };
+      },
+      // Custom row layout: [dot] [name — flex, ellipsized] [value — right,
+      // bold, never wraps]. The default item layout collided the value with
+      // long multi-entity labels (`service · <hash>@<ip>`), pushing the value
+      // onto its own line or dropping it; here the name truncates and the
+      // value stays put on the same row.
+      formatter(params: unknown): string {
+        const arr = (Array.isArray(params) ? params : [params]) as Array<{
+          axisValueLabel?: string;
+          seriesName?: string;
+          value?: unknown;
+          color?: string;
+        }>;
+        const header = arr[0]?.axisValueLabel ? escHtml(arr[0].axisValueLabel) : '';
+        // Fixed-layout TABLE — `table-layout:fixed` + a hard `width` is the
+        // ONLY layout that deterministically pins the columns here: ECharts'
+        // appendToBody tooltip box ignores `max-width`, so any auto/flex/grid
+        // sizing lets the long labels balloon the box and the columns run
+        // ragged. With fixed layout the column widths are frozen (dot 14 /
+        // name rest / value 72) so every name ellipsizes at the SAME boundary
+        // and the numbers form one clean right-aligned column. The unit is
+        // shown once in the header (not repeated on 22 rows), which also keeps
+        // the value column narrow and unit-agnostic (MB / calls·min / ms / %).
+        const unitTag = props.unit
+          ? ` <span style="opacity:0.55;font-weight:400;">${escHtml(props.unit)}</span>`
+          : '';
+        const rows = arr
+          .map((p) => {
+            const val =
+              typeof p.value === 'number' && Number.isFinite(p.value) ? formatVal(p.value) : '—';
+            return (
+              '<tr>' +
+              `<td style="width:14px;padding:2px 0;vertical-align:middle;"><span style="display:block;width:9px;height:9px;border-radius:50%;background:${p.color ?? '#888'};"></span></td>` +
+              `<td style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:2px 6px 2px 0;vertical-align:middle;">${escHtml(p.seriesName ?? '')}</td>` +
+              `<td style="width:72px;text-align:right;font-weight:600;white-space:nowrap;padding:2px 0;vertical-align:middle;">${escHtml(val)}</td>` +
+              '</tr>'
+            );
+          })
+          .join('');
+        return (
+          `<div style="font-weight:600;margin-bottom:4px;">${header}${unitTag}</div>` +
+          '<table style="width:360px;table-layout:fixed;border-collapse:collapse;border-spacing:0;">' +
+          `${rows}</table>`
+        );
       },
-      valueFormatter: (v: unknown) =>
-        typeof v === 'number' && Number.isFinite(v)
-          ? `${formatVal(v)}${props.unit ? ` ${props.unit}` : ''}`
-          : '—',
     },
     legend: {
       show: props.series.length > 1,
@@ -259,7 +343,11 @@ function buildOption(): echarts.EChartsCoreOption {
     })(),
     series: props.series.map((s, i) => {
       const accentHex = cssVar(props.accent);
-      const color = i === 0 ? accentHex : SECONDARY[(i - 1) % SECONDARY.length];
+      const color = s.color
+        ? cssVar(s.color)
+        : i === 0
+          ? accentHex
+          : SECONDARY[(i - 1) % SECONDARY.length];
       // For dual-axis widgets, append the per-series unit to the
       // legend label so operators can tell which line is on which
       // axis at a glance (e.g. "count (/min)" vs "latency (ms)").
@@ -307,7 +395,7 @@ function buildOption(): echarts.EChartsCoreOption {
  *  in on the right. */
 function seriesFingerprint(series: Series[]): string {
   return series
-    .map((s) => `${s.label}|${s.yAxisIndex ?? 0}|${s.unit ?? ''}|${s.data.length}`)
+    .map((s) => `${s.label}|${s.yAxisIndex ?? 0}|${s.unit ?? ''}|${s.color ?? ''}|${s.data.length}`)
     .join('::');
 }
 let prevFingerprint = '';
diff --git a/apps/ui/src/components/icons/Icon.vue b/apps/ui/src/components/icons/Icon.vue
index 6434339..6d971cc 100644
--- a/apps/ui/src/components/icons/Icon.vue
+++ b/apps/ui/src/components/icons/Icon.vue
@@ -52,7 +52,8 @@ export type IconName =
   | 'fn'
   | 'cache'
   | 'topic'
-  | 'snowflake';
+  | 'snowflake'
+  | 'pin';
 </script>
 
 <template>
@@ -227,5 +228,10 @@ export type IconName =
       <path d="M12 3v18M3 12h18M5 5l14 14M19 5L5 19" />
       <path d="M9 6l3-3 3 3M15 18l-3 3-3-3M6 9L3 12l3 3M18 9l3 3-3 3" />
     </template>
+    <template v-else-if="name === 'pin'">
+      <!-- Pushpin — "lock this entity into the comparison set". -->
+      <path d="M9 3h6l-1 5 3 3v2H7v-2l3-3z" />
+      <path d="M12 13v8" />
+    </template>
   </svg>
 </template>
diff --git a/apps/ui/src/features/infra-3d/Infra3DScene.vue b/apps/ui/src/features/infra-3d/Infra3DScene.vue
index 6fef286..0b687a0 100644
--- a/apps/ui/src/features/infra-3d/Infra3DScene.vue
+++ b/apps/ui/src/features/infra-3d/Infra3DScene.vue
@@ -1313,23 +1313,40 @@ const initialTarget = defaultTargetPos();
 //
 // Both refs may still be `null` between mount-tick and the THREE
 // object actually attaching, so every accessor guards for that.
-const controlsRef = shallowRef<any>(null);
-function getControls(): any | null {
+// The only bits of THREE.OrbitControls the camera helpers touch. cientos
+// doesn't ship a stable type for the exposed instance, so we narrow to this
+// structurally rather than depend on its internals.
+interface OrbitControlsLike {
+  update: () => void;
+  target: Vector3;
+}
+function asOrbitControls(v: unknown): OrbitControlsLike | null {
+  if (typeof v !== 'object' || v === null) return null;
+  const o = v as { update?: unknown; target?: unknown };
+  return typeof o.update === 'function' && o.target ? (v as OrbitControlsLike) : null;
+}
+// `unknown` (not the controls type): the template ref is set to cientos's
+// exposed component object, whose shape we probe at runtime in getControls.
+const controlsRef = shallowRef<unknown>(null);
+function getControls(): OrbitControlsLike | null {
   const r = controlsRef.value;
-  if (!r) return null;
-  // Defensive: handle both auto-unwrapped expose AND the (older /
-  // alternative) shape where `instance` would still be a ref.
-  const inst = r.instance;
+  if (!r || typeof r !== 'object') return null;
+  const inst = (r as { instance?: unknown }).instance;
   if (!inst) return null;
-  if (typeof inst.update === 'function' && inst.target) return inst;
-  if (inst.value && typeof inst.value.update === 'function') return inst.value;
-  return null;
+  // Defensive: handle both the auto-unwrapped expose (`inst` IS the controls)
+  // AND the older / alternative shape where `instance` is still a ref.
+  return asOrbitControls(inst) ?? asOrbitControls((inst as { value?: unknown }).value);
 }
 function getCamera(): PerspectiveCamera | null {
-  const r = cameraRef.value as any;
+  // TresJS may set the ref to the THREE camera directly or to a wrapper whose
+  // `.value` holds it — probe both shapes through `unknown`.
+  const r = cameraRef.value as unknown;
   if (!r) return null;
-  if (r.isPerspectiveCamera) return r as PerspectiveCamera;
-  if (r.value?.isPerspectiveCamera) return r.value as PerspectiveCamera;
+  if ((r as { isPerspectiveCamera?: boolean }).isPerspectiveCamera) return r as PerspectiveCamera;
+  const inner = (r as { value?: unknown }).value;
+  if (inner && (inner as { isPerspectiveCamera?: boolean }).isPerspectiveCamera) {
+    return inner as PerspectiveCamera;
+  }
   return null;
 }
 
diff --git a/apps/ui/src/features/operate/dsl/DslEditorView.vue b/apps/ui/src/features/operate/dsl/DslEditorView.vue
index cb363ee..9e196a1 100644
--- a/apps/ui/src/features/operate/dsl/DslEditorView.vue
+++ b/apps/ui/src/features/operate/dsl/DslEditorView.vue
@@ -15,7 +15,7 @@
   limitations under the License.
 -->
 <script setup lang="ts">
-import { computed, ref, shallowRef } from 'vue';
+import { computed, ref, shallowRef, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useRoute, useRouter } from 'vue-router';
 import { isCatalog, type Catalog, type RuleResponse } from '@skywalking-horizon-ui/api-client';
@@ -95,6 +95,34 @@ function setFlash(msg: string): void {
   }, 4000);
 }
 
+// A structural apply (revert-to-bundled, storage-change save) runs past
+// OAP's admin request timeout, so the mutation returns `pending` before it
+// lands. Poll the rule to its expected end state instead of re-firing
+// (a retry just queues another waiter on OAP's per-file lock). The action
+// row stays disabled via `applying` for the duration so the operator can't
+// stack retries.
+const applying = ref(false);
+async function trackApply(op: 'revert' | 'inactivate' | 'delete'): Promise<void> {
+  applying.value = true;
+  setFlash(t('submitted — OAP is applying it; a structural apply can take a minute…'));
+  const done =
+    op === 'revert'
+      ? (r: RuleResponse | null): boolean => r != null && (r.status === 'BUNDLED' || r.status === 'STATIC')
+      : op === 'inactivate'
+        ? (r: RuleResponse | null): boolean => r?.status === 'INACTIVE'
+        : (r: RuleResponse | null): boolean => r === null;
+  const res = await editor.awaitApplied(done);
+  applying.value = false;
+  if (res === 'applied') {
+    setFlash(t('applied ✓'));
+    if (op === 'delete') {
+      await router.push({ name: 'catalog', params: { catalog: catalog.value ?? '' } });
+    }
+  } else {
+    setFlash(t('still applying on OAP — refresh in a moment to confirm'));
+  }
+}
+
 async function onSave(): Promise<void> {
   const r = await editor.save({ force: force.value });
   if (r.kind === 'ok') {
@@ -129,6 +157,10 @@ async function onInactivate(): Promise<void> {
     setFlash(t('inactivated · {status}', { status: r.result.applyStatus }));
     return;
   }
+  if (r.kind === 'pending') {
+    void trackApply('inactivate');
+    return;
+  }
   if (r.kind === 'error') {
     setFlash(extractErrorMessage(r.error));
   }
@@ -141,6 +173,10 @@ async function onDeleteDefault(): Promise<void> {
     await router.push({ name: 'catalog', params: { catalog: catalog.value ?? '' } });
     return;
   }
+  if (r.kind === 'pending') {
+    void trackApply('delete');
+    return;
+  }
   if (r.kind === 'needs-inactivate-first') {
     setFlash(t('rule is ACTIVE — inactivate first, then delete'));
     return;
@@ -171,6 +207,10 @@ function onDeleteRevertToBundled(): void {
         await router.push({ name: 'catalog', params: { catalog: catalog.value ?? '' } });
         return;
       }
+      if (r.kind === 'pending') {
+        void trackApply('revert');
+        return;
+      }
       if (r.kind === 'no-bundled-twin') {
         setFlash(t('no bundled version on disk for this rule'));
         return;
@@ -237,9 +277,34 @@ const statusTone = computed(() => {
   return 'dim' as const;
 });
 
-const hasBundledTwin = computed(
-  () => editor.original.value?.source === 'static' || bundled.value !== null,
+// Probe the bundled twin eagerly on every (catalog, name) so the action
+// buttons reflect its existence on load — NOT only after the operator opens
+// the "diff vs. bundled" tab (the old `source === 'static'` clause was dead:
+// RuleSource is only ever 'runtime' | 'bundled'). The fetched content is
+// cached in `bundled`, so the diff tab then opens instantly. Guarded against
+// a stale fetch resolving after a newer rule was selected.
+watch(
+  [catalog, name],
+  () => {
+    bundled.value = null;
+    const c = catalog.value;
+    const n = name.value;
+    void editor
+      .fetchBundled()
+      .then((b) => {
+        if (catalog.value === c && name.value === n) bundled.value = b;
+      })
+      .catch(() => {});
+  },
+  { immediate: true },
 );
+const hasBundledTwin = computed<boolean>(() => bundled.value !== null);
+// An operator-pushed runtime row (vs. a pure bundled rule served from disk).
+// Only operator rows can be inactivated / deleted / reverted.
+const isOperatorRow = computed<boolean>(() => {
+  const s = editor.original.value?.status;
+  return s === 'ACTIVE' || s === 'INACTIVE';
+});
 </script>
 
 <template>
@@ -255,7 +320,8 @@ const hasBundledTwin = computed(
         {{ editor.original.value.status }}
       </Pill>
       <Pill v-if="editor.dirty.value" tone="active">{{ t('unsaved') }}</Pill>
-      <Pill v-if="editor.lastApplyStatus.value" tone="info">
+      <Pill v-if="applying" tone="info">{{ t('applying…') }}</Pill>
+      <Pill v-if="!applying && editor.lastApplyStatus.value" tone="info">
         {{ editor.lastApplyStatus.value }}
       </Pill>
       <div class="ed__spacer" />
@@ -295,29 +361,36 @@ const hasBundledTwin = computed(
 
       <Btn
         kind="primary"
-        :disabled="!canWrite || !editor.dirty.value || editor.saving.value"
+        :disabled="!canWrite || !editor.dirty.value || editor.saving.value || applying"
         :data-testid="'editor-save'"
         @click="onSave"
       >
         {{ editor.saving.value ? t('saving…') : t('save') }}
       </Btn>
+      <!-- Inactivate only applies to an ACTIVE operator row — hidden once
+           the rule is already INACTIVE (or a pure bundled rule). -->
       <Btn
-        :disabled="!canWrite || !editor.exists.value || editor.saving.value"
+        v-if="editor.original.value?.status === 'ACTIVE'"
+        :disabled="!canWrite || editor.saving.value || applying"
         @click="onInactivate"
       >
         {{ t('inactivate') }}
       </Btn>
+      <!-- Plain delete is valid ONLY when there's no bundled twin; OAP
+           refuses it with 409 requires_revert_to_bundled when one exists,
+           so for those rules we surface "revert to bundled" instead. -->
       <Btn
+        v-if="isOperatorRow && !hasBundledTwin"
         kind="danger"
-        :disabled="!canDelete || !editor.exists.value || editor.saving.value"
+        :disabled="!canDelete || editor.saving.value || applying"
         @click="onDeleteDefault"
       >
         {{ t('delete') }}
       </Btn>
       <Btn
-        v-if="hasBundledTwin"
+        v-if="isOperatorRow && hasBundledTwin"
         kind="danger"
-        :disabled="!canDelete || !canWriteStructural || editor.saving.value"
+        :disabled="!canDelete || !canWriteStructural || editor.saving.value || applying"
         :data-testid="'editor-revert'"
         @click="onDeleteRevertToBundled"
       >
diff --git a/apps/ui/src/features/operate/dsl/useRuleEditor.ts b/apps/ui/src/features/operate/dsl/useRuleEditor.ts
index d3b9c77..4ccfad1 100644
--- a/apps/ui/src/features/operate/dsl/useRuleEditor.ts
+++ b/apps/ui/src/features/operate/dsl/useRuleEditor.ts
@@ -29,13 +29,19 @@ import { computed, ref, watch, type Ref } from 'vue';
 import type { ApplyResult, Catalog, DeleteMode, RuleResponse } from '@skywalking-horizon-ui/api-client';
 import { bff, type BffApiError } from '@/api/client';
 
+// `pending` = the BFF returned 202 `submitted`: OAP accepted a structural
+// apply that runs past its admin request timeout, so the HTTP call can't
+// confirm completion. The caller polls (see `awaitApplied`) instead of
+// retrying — a retry would pile another waiter on OAP's per-file lock.
 export type SaveOutcome =
   | { kind: 'ok'; result: ApplyResult }
+  | { kind: 'pending'; result: ApplyResult }
   | { kind: 'needs-storage-change'; result: ApplyResult }
   | { kind: 'error'; error: BffApiError | Error };
 
 export type DeleteOutcome =
   | { kind: 'ok'; result: ApplyResult }
+  | { kind: 'pending'; result: ApplyResult }
   | { kind: 'needs-inactivate-first'; result: ApplyResult }
   | { kind: 'no-bundled-twin'; result: ApplyResult }
   | { kind: 'error'; error: BffApiError | Error };
@@ -120,6 +126,7 @@ export function useRuleEditor(opts: UseRuleEditorOptions) {
         ...args,
       });
       lastApplyStatus.value = result.applyStatus;
+      if (result.applyStatus === 'submitted') return { kind: 'pending', result };
       // Refresh the original to the just-pushed body so dirty resets.
       await load();
       return { kind: 'ok', result };
@@ -144,6 +151,7 @@ export function useRuleEditor(opts: UseRuleEditorOptions) {
     try {
       const result = await client.dsl.inactivateRule(opts.catalog.value, opts.name.value);
       lastApplyStatus.value = result.applyStatus;
+      if (result.applyStatus === 'submitted') return { kind: 'pending', result };
       await load();
       return { kind: 'ok', result };
     } catch (err) {
@@ -161,6 +169,7 @@ export function useRuleEditor(opts: UseRuleEditorOptions) {
     try {
       const result = await client.dsl.deleteRule(opts.catalog.value, opts.name.value, mode);
       lastApplyStatus.value = result.applyStatus;
+      if (result.applyStatus === 'submitted') return { kind: 'pending', result };
       return { kind: 'ok', result };
     } catch (err) {
       if (isApiError(err) && err.status === 409) {
@@ -181,6 +190,43 @@ export function useRuleEditor(opts: UseRuleEditorOptions) {
     }
   }
 
+  /**
+   * Confirm an async ("submitted") apply by polling the rule until `done`
+   * holds or the budget elapses. OAP runs structural applies past its admin
+   * request timeout, so the mutation HTTP call returns before the apply
+   * lands — we re-read the rule here rather than re-firing the mutation
+   * (which would queue another waiter on OAP's per-file lock). On success it
+   * refreshes `original` so the editor reflects the new state. `done`
+   * receives the polled rule (or `null` when the rule no longer exists).
+   */
+  async function awaitApplied(
+    done: (rule: RuleResponse | null) => boolean,
+    o: { timeoutMs?: number; intervalMs?: number } = {},
+  ): Promise<'applied' | 'timeout'> {
+    if (!opts.catalog.value || !opts.name.value) return 'timeout';
+    const cat = opts.catalog.value;
+    const nm = opts.name.value;
+    const timeoutMs = o.timeoutMs ?? 150_000;
+    const intervalMs = o.intervalMs ?? 4_000;
+    const start = Date.now();
+    while (Date.now() - start < timeoutMs) {
+      await new Promise((r) => setTimeout(r, intervalMs));
+      // Operator navigated to a different rule — stop polling the old one.
+      if (opts.catalog.value !== cat || opts.name.value !== nm) return 'timeout';
+      let rule: RuleResponse | null;
+      try {
+        rule = await client.dsl.getRule({ catalog: cat, name: nm });
+      } catch {
+        continue; // transient read error mid-apply — keep waiting
+      }
+      if (done(rule)) {
+        await load();
+        return 'applied';
+      }
+    }
+    return 'timeout';
+  }
+
   // Auto-load whenever (catalog, name) settles.
   watch(
     [opts.catalog, opts.name],
@@ -204,6 +250,7 @@ export function useRuleEditor(opts: UseRuleEditorOptions) {
     save,
     inactivate,
     deleteRule,
+    awaitApplied,
   };
 }
 
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index a934d07..a3e934b 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1407,5 +1407,24 @@
   "Upload rejected: {reason}": "Upload abgelehnt: {reason}",
   "Could not resolve: {reason}": "Konnte nicht aufgelöst werden: {reason}",
   "{count} logs": "{count} Logs",
-  "grade": "Stufe"
+  "grade": "Stufe",
+  "submitted — OAP is applying it; a structural apply can take a minute…": "übermittelt — OAP wendet es an; ein struktureller Apply kann eine Minute dauern…",
+  "applied ✓": "angewendet ✓",
+  "still applying on OAP — refresh in a moment to confirm": "wird auf OAP noch angewendet — in Kürze aktualisieren, um zu bestätigen",
+  "Add to comparison": "Zum Vergleich hinzufügen",
+  "Remove from comparison": "Aus Vergleich entfernen",
+  "Comparison is full ({max} max)": "Vergleich ist voll (max. {max})",
+  "Cannot compare the blank service": "Der leere Dienst kann nicht verglichen werden",
+  "Current — drives the header KPIs": "Aktuell — bestimmt die KPIs in der Kopfzeile",
+  "CURRENT": "AKTUELL",
+  "Entity": "Entität",
+  "latest": "neueste",
+  "avg": "Ø",
+  "Comparing {n} services": "Vergleiche {n} Dienste",
+  "Comparing {n} instances of {service}": "Vergleiche {n} Instanzen von {service}",
+  "Comparing {n} endpoints of {service}": "Vergleiche {n} Endpunkte von {service}",
+  "Comparing {n} instances across services": "Vergleiche {n} Instanzen über Services hinweg",
+  "Comparing {n} endpoints across services": "Vergleiche {n} Endpunkte über Services hinweg",
+  "Failed to load": "Laden fehlgeschlagen",
+  "{n} locked · lock 1 more to compare": "{n} gesperrt · 1 weitere(n) sperren zum Vergleichen"
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index cdb07f6..e7da311 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1407,5 +1407,25 @@
   "Upload rejected: {reason}": "Upload rejected: {reason}",
   "Could not resolve: {reason}": "Could not resolve: {reason}",
   "{count} logs": "{count} logs",
-  "grade": "grade"
+  "grade": "grade",
+  "submitted — OAP is applying it; a structural apply can take a minute…": "submitted — OAP is applying it; a structural apply can take a minute…",
+  "applied ✓": "applied ✓",
+  "still applying on OAP — refresh in a moment to confirm": "still applying on OAP — refresh in a moment to confirm",
+  "Add to comparison": "Add to comparison",
+  "Remove from comparison": "Remove from comparison",
+  "Comparison is full ({max} max)": "Comparison is full ({max} max)",
+  "Cannot compare the blank service": "Cannot compare the blank service",
+  "Current — drives the header KPIs": "Current — drives the header KPIs",
+  "CURRENT": "CURRENT",
+  "Entity": "Entity",
+  "latest": "latest",
+  "avg": "avg",
+  "Comparing {n} services": "Comparing {n} services",
+  "Comparing {n} instances of {service}": "Comparing {n} instances of {service}",
+  "Comparing {n} endpoints of {service}": "Comparing {n} endpoints of {service}",
+  "Comparing {n} instances across services": "Comparing {n} instances across services",
+  "Comparing {n} endpoints across services": "Comparing {n} endpoints across services",
+  "Failed to load": "Failed to load",
+  "{n} locked · lock 1 more to compare": "{n} locked · lock 1 more to compare",
+  "All": "All"
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 11e75c0..9a87719 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1407,5 +1407,24 @@
   "Upload rejected: {reason}": "Subida rechazada: {reason}",
   "Could not resolve: {reason}": "No se pudo resolver: {reason}",
   "{count} logs": "{count} logs",
-  "grade": "grado"
+  "grade": "grado",
+  "submitted — OAP is applying it; a structural apply can take a minute…": "enviado — OAP lo está aplicando; un cambio estructural puede tardar un minuto…",
+  "applied ✓": "aplicado ✓",
+  "still applying on OAP — refresh in a moment to confirm": "aún se está aplicando en OAP — actualiza en un momento para confirmar",
+  "Add to comparison": "Añadir a la comparación",
+  "Remove from comparison": "Quitar de la comparación",
+  "Comparison is full ({max} max)": "La comparación está llena (máx. {max})",
+  "Cannot compare the blank service": "No se puede comparar el servicio en blanco",
+  "Current — drives the header KPIs": "Actual: determina los KPI del encabezado",
+  "CURRENT": "ACTUAL",
+  "Entity": "Entidad",
+  "latest": "último",
+  "avg": "med",
+  "Comparing {n} services": "Comparando {n} servicios",
+  "Comparing {n} instances of {service}": "Comparando {n} instancias de {service}",
+  "Comparing {n} endpoints of {service}": "Comparando {n} endpoints de {service}",
+  "Comparing {n} instances across services": "Comparando {n} instancias entre servicios",
+  "Comparing {n} endpoints across services": "Comparando {n} endpoints entre servicios",
+  "Failed to load": "Error al cargar",
+  "{n} locked · lock 1 more to compare": "{n} bloqueado(s) · bloquea 1 más para comparar"
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index c520752..4fc2ffb 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1407,5 +1407,24 @@
   "Upload rejected: {reason}": "Import refusé : {reason}",
   "Could not resolve: {reason}": "Résolution impossible : {reason}",
   "{count} logs": "{count} logs",
-  "grade": "niveau"
+  "grade": "niveau",
+  "submitted — OAP is applying it; a structural apply can take a minute…": "soumis — OAP l'applique ; un changement structurel peut prendre une minute…",
+  "applied ✓": "appliqué ✓",
+  "still applying on OAP — refresh in a moment to confirm": "toujours en cours d'application sur OAP — actualisez dans un instant pour confirmer",
+  "Add to comparison": "Ajouter à la comparaison",
+  "Remove from comparison": "Retirer de la comparaison",
+  "Comparison is full ({max} max)": "Comparaison pleine (max. {max})",
+  "Cannot compare the blank service": "Impossible de comparer le service vide",
+  "Current — drives the header KPIs": "Actuel — pilote les KPI de l’en-tête",
+  "CURRENT": "ACTUEL",
+  "Entity": "Entité",
+  "latest": "dernier",
+  "avg": "moy",
+  "Comparing {n} services": "Comparaison de {n} services",
+  "Comparing {n} instances of {service}": "Comparaison de {n} instances de {service}",
+  "Comparing {n} endpoints of {service}": "Comparaison de {n} endpoints de {service}",
+  "Comparing {n} instances across services": "Comparaison de {n} instances entre services",
+  "Comparing {n} endpoints across services": "Comparaison de {n} endpoints entre services",
+  "Failed to load": "Échec du chargement",
+  "{n} locked · lock 1 more to compare": "{n} verrouillé(s) · verrouillez-en 1 de plus pour comparer"
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 100c16f..e2ff61d 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1102,7 +1102,7 @@
   "Zipkin / OTLP traces": "Zipkin / OTLP トレース",
   "advanced": "高度",
   "and the metrics derived from them. Browse the loaded files below; install a live debug session from": "およびそこから派生するメトリクスを定義します。下のロード済みファイルを参照してください。次からライブデバッグセッションをインストールできます:",
-  "applying…": "適用しています…",
+  "applying…": "適用中…",
   "cancel": "キャンセル",
   "cold": "cold",
   "confirm": "確認",
@@ -1407,5 +1407,24 @@
   "Upload rejected: {reason}": "アップロードが拒否されました:{reason}",
   "Could not resolve: {reason}": "解決できませんでした:{reason}",
   "{count} logs": "{count} 件のログ",
-  "grade": "グレード"
+  "grade": "グレード",
+  "submitted — OAP is applying it; a structural apply can take a minute…": "送信しました — OAP が適用中です。構造的な適用には1分ほどかかることがあります…",
+  "applied ✓": "適用済み ✓",
+  "still applying on OAP — refresh in a moment to confirm": "OAP でまだ適用中です — 少し後に更新して確認してください",
+  "Add to comparison": "比較に追加",
+  "Remove from comparison": "比較から削除",
+  "Comparison is full ({max} max)": "比較は上限に達しました(最大 {max})",
+  "Cannot compare the blank service": "空のサービスは比較できません",
+  "Current — drives the header KPIs": "現在 — ヘッダーの KPI を表示",
+  "CURRENT": "現在",
+  "Entity": "エンティティ",
+  "latest": "最新",
+  "avg": "平均",
+  "Comparing {n} services": "{n} 件のサービスを比較中",
+  "Comparing {n} instances of {service}": "{service} の {n} 件のインスタンスを比較中",
+  "Comparing {n} endpoints of {service}": "{service} の {n} 件のエンドポイントを比較中",
+  "Comparing {n} instances across services": "サービスをまたいで {n} 件のインスタンスを比較中",
+  "Comparing {n} endpoints across services": "サービスをまたいで {n} 件のエンドポイントを比較中",
+  "Failed to load": "読み込みに失敗しました",
+  "{n} locked · lock 1 more to compare": "{n} 件ロック中 · 比較するにはもう 1 件ロック"
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 0c24c67..fdc38f7 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1407,5 +1407,24 @@
   "Upload rejected: {reason}": "업로드가 거부되었습니다: {reason}",
   "Could not resolve: {reason}": "변환할 수 없습니다: {reason}",
   "{count} logs": "로그 {count}개",
-  "grade": "등급"
+  "grade": "등급",
+  "submitted — OAP is applying it; a structural apply can take a minute…": "제출됨 — OAP가 적용 중입니다. 구조적 적용은 1분 정도 걸릴 수 있습니다…",
+  "applied ✓": "적용됨 ✓",
+  "still applying on OAP — refresh in a moment to confirm": "OAP에서 아직 적용 중입니다 — 잠시 후 새로 고침하여 확인하세요",
+  "Add to comparison": "비교에 추가",
+  "Remove from comparison": "비교에서 제거",
+  "Comparison is full ({max} max)": "비교가 가득 찼습니다(최대 {max}개)",
+  "Cannot compare the blank service": "빈 서비스는 비교할 수 없습니다",
+  "Current — drives the header KPIs": "현재 — 헤더 KPI를 결정",
+  "CURRENT": "현재",
+  "Entity": "엔터티",
+  "latest": "최신",
+  "avg": "평균",
+  "Comparing {n} services": "{n}개 서비스 비교 중",
+  "Comparing {n} instances of {service}": "{service}의 인스턴스 {n}개 비교 중",
+  "Comparing {n} endpoints of {service}": "{service}의 엔드포인트 {n}개 비교 중",
+  "Comparing {n} instances across services": "여러 서비스에 걸쳐 인스턴스 {n}개 비교 중",
+  "Comparing {n} endpoints across services": "여러 서비스에 걸쳐 엔드포인트 {n}개 비교 중",
+  "Failed to load": "불러오지 못했습니다",
+  "{n} locked · lock 1 more to compare": "{n}개 잠금 · 비교하려면 1개 더 잠그세요"
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 0fcdf17..6a76e35 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1407,5 +1407,24 @@
   "Upload rejected: {reason}": "Envio rejeitado: {reason}",
   "Could not resolve: {reason}": "Não foi possível resolver: {reason}",
   "{count} logs": "{count} logs",
-  "grade": "grau"
+  "grade": "grau",
+  "submitted — OAP is applying it; a structural apply can take a minute…": "enviado — o OAP está aplicando; uma alteração estrutural pode levar um minuto…",
+  "applied ✓": "aplicado ✓",
+  "still applying on OAP — refresh in a moment to confirm": "ainda aplicando no OAP — atualize em instantes para confirmar",
+  "Add to comparison": "Adicionar à comparação",
+  "Remove from comparison": "Remover da comparação",
+  "Comparison is full ({max} max)": "Comparação cheia (máx. {max})",
+  "Cannot compare the blank service": "Não é possível comparar o serviço em branco",
+  "Current — drives the header KPIs": "Atual — define os KPIs do cabeçalho",
+  "CURRENT": "ATUAL",
+  "Entity": "Entidade",
+  "latest": "mais recente",
+  "avg": "méd",
+  "Comparing {n} services": "Comparando {n} serviços",
+  "Comparing {n} instances of {service}": "Comparando {n} instâncias de {service}",
+  "Comparing {n} endpoints of {service}": "Comparando {n} endpoints de {service}",
+  "Comparing {n} instances across services": "Comparando {n} instâncias entre serviços",
+  "Comparing {n} endpoints across services": "Comparando {n} endpoints entre serviços",
+  "Failed to load": "Falha ao carregar",
+  "{n} locked · lock 1 more to compare": "{n} bloqueado(s) · bloqueie mais 1 para comparar"
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index 92954d4..6ab4fb6 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1407,5 +1407,24 @@
   "Upload rejected: {reason}": "上传被拒绝:{reason}",
   "Could not resolve: {reason}": "无法解析:{reason}",
   "{count} logs": "{count} 条日志",
-  "grade": "级别"
+  "grade": "级别",
+  "submitted — OAP is applying it; a structural apply can take a minute…": "已提交 — OAP 正在应用;结构性变更可能需要一分钟…",
+  "applied ✓": "已应用 ✓",
+  "still applying on OAP — refresh in a moment to confirm": "OAP 仍在应用 — 稍后刷新以确认",
+  "Add to comparison": "添加到对比",
+  "Remove from comparison": "从对比中移除",
+  "Comparison is full ({max} max)": "对比已满(最多 {max} 个)",
+  "Cannot compare the blank service": "无法对比空白服务",
+  "Current — drives the header KPIs": "当前 — 决定头部 KPI",
+  "CURRENT": "当前",
+  "Entity": "实体",
+  "latest": "最新",
+  "avg": "平均",
+  "Comparing {n} services": "正在对比 {n} 个服务",
+  "Comparing {n} instances of {service}": "正在对比 {service} 的 {n} 个实例",
+  "Comparing {n} endpoints of {service}": "正在对比 {service} 的 {n} 个端点",
+  "Comparing {n} instances across services": "正在跨服务对比 {n} 个实例",
+  "Comparing {n} endpoints across services": "正在跨服务对比 {n} 个端点",
+  "Failed to load": "加载失败",
+  "{n} locked · lock 1 more to compare": "已锁定 {n} 个 · 再锁定 1 个即可对比"
 }
diff --git a/apps/ui/src/layer/LayerServiceSelector.vue b/apps/ui/src/layer/LayerServiceSelector.vue
index 1f9d6bb..f9a7f80 100644
--- a/apps/ui/src/layer/LayerServiceSelector.vue
+++ b/apps/ui/src/layer/LayerServiceSelector.vue
@@ -31,8 +31,10 @@ const { t } = useI18n({ useScope: 'global' });
 import { metricMeta } from '@/utils/metricCatalog';
 import { statusForMetrics, thresholdColor } from '@/utils/metricColor';
 import { fmtMetric } from '@/utils/formatters';
-import { resolveServiceIdentity } from '@/utils/serviceName';
+import { resolveServiceIdentity, isBlankServiceName, BLANK_SERVICE_NAME } from '@/utils/serviceName';
 import type { ServiceNamingRule } from '@skywalking-horizon-ui/api-client';
+import Icon from '@/components/icons/Icon.vue';
+import { MAX_LOCKED } from '@/state/layerSelection';
 
 const props = withDefaults(
   defineProps<{
@@ -61,26 +63,54 @@ const props = withDefaults(
     roster?: ReadonlyArray<{ id: string; name: string }>;
     /** Order-by metric key — labels the tail rows ("low RPM"). */
     orderBy?: string;
+    /** Multi-entity lock (cross-check). When enabled, each row gets a
+     *  lock pin; `lockedIds` are the locked service ids and `lockHueFor`
+     *  maps a locked id to its palette hue for the dot. */
+    lockEnabled?: boolean;
+    lockedIds?: ReadonlyArray<string>;
+    lockHueFor?: (id: string) => string;
   }>(),
   {
     accent: 'var(--sw-accent)',
     pageSize: 8,
     namingRule: null,
+    lockEnabled: false,
+    lockedIds: () => [],
   },
 );
 function identity(name: string | null | undefined) {
   return resolveServiceIdentity(name, props.namingRule);
 }
-const emit = defineEmits<{ (e: 'select', id: string): void }>();
+const emit = defineEmits<{
+  (e: 'select', id: string): void;
+  (e: 'toggle-lock', id: string): void;
+}>();
+
+const lockedSet = computed(() => new Set(props.lockedIds));
+const atCap = computed(() => props.lockedIds.length >= MAX_LOCKED);
+function isLocked(id: string): boolean {
+  return lockedSet.value.has(id);
+}
+function lockDotHue(id: string): string {
+  return props.lockHueFor?.(id) ?? 'var(--sw-accent)';
+}
+function lockTitle(r: PickerRow): string {
+  if (r.blank) return t('Cannot compare the blank service');
+  if (isLocked(r.id)) return t('Remove from comparison');
+  if (atCap.value) return t('Comparison is full ({max} max)', { max: MAX_LOCKED });
+  return t('Add to comparison');
+}
 
 const filter = ref('');
 const page = ref(0);
 
 // A probed row carries landing metrics; a tail row is a roster-only
 // service that ranked below the metric cap — id+name with no numbers.
+// `blank` flags OAP's synthetic `_blank` bucket — shown so the operator
+// knows it exists, but rendered disabled (never selectable / queryable).
 type PickerRow =
-  | { kind: 'probed'; id: string; name: string; row: LandingServiceRow }
-  | { kind: 'tail'; id: string; name: string };
+  | { kind: 'probed'; id: string; name: string; blank: boolean; row: LandingServiceRow }
+  | { kind: 'tail'; id: string; name: string; blank: boolean };
 
 // Probed rows first (already sorted by orderBy from the BFF), then the
 // roster tail that wasn't metric-probed. Without a roster prop the
@@ -90,6 +120,7 @@ const allRows = computed<PickerRow[]>(() => {
     kind: 'probed',
     id: r.serviceId,
     name: r.serviceName,
+    blank: isBlankServiceName(r.serviceName),
     row: r,
   }));
   const roster = props.roster;
@@ -97,7 +128,7 @@ const allRows = computed<PickerRow[]>(() => {
   const probedIds = new Set(probed.map((p) => p.id));
   const tail: PickerRow[] = roster
     .filter((r) => !probedIds.has(r.id))
-    .map((r) => ({ kind: 'tail', id: r.id, name: r.name }));
+    .map((r) => ({ kind: 'tail', id: r.id, name: r.name, blank: isBlankServiceName(r.name) }));
   return [...probed, ...tail];
 });
 
@@ -151,6 +182,7 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
     <table class="sw-table picker-table">
       <thead>
         <tr>
+          <th v-if="lockEnabled" class="lock-col" aria-hidden="true" />
           <th class="svc-col">{{ serviceLabel || t('Service') }}</th>
           <th
             v-for="c in columns"
@@ -170,13 +202,26 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
             :class="{ active: r.id === selectedId }"
             @click="emit('select', r.id)"
           >
-            <td class="svc-col" :title="r.name">
+            <td v-if="lockEnabled" class="lock-col" @click.stop>
+              <button
+                type="button"
+                class="lock-btn"
+                :class="{ locked: isLocked(r.id) }"
+                :disabled="r.blank || (!isLocked(r.id) && atCap)"
+                :title="lockTitle(r)"
+                @click="emit('toggle-lock', r.id)"
+              >
+                <span v-if="isLocked(r.id)" class="lock-dot" :style="{ background: lockDotHue(r.id) }" />
+                <Icon v-else name="pin" :size="11" />
+              </button>
+            </td>
+            <td class="svc-col" :title="r.blank ? BLANK_SERVICE_NAME : r.name">
               <span class="pulse" :style="{ background: colorForStatus(statusForMetrics(r.row.metrics)) }" />
-              <span v-if="identity(r.name).cluster" class="group-chip">
+              <span v-if="!r.blank && identity(r.name).cluster" class="group-chip">
                 <span class="chip-alias">{{ identity(r.name).clusterAlias }}</span>
                 <span class="chip-val">{{ identity(r.name).cluster }}</span>
               </span>
-              <span class="name-text">{{ r.row.shortName || identity(r.name).display }}</span>
+              <span class="name-text">{{ r.blank ? BLANK_SERVICE_NAME : (r.row.shortName || identity(r.name).display) }}</span>
             </td>
             <td
               v-for="c in columns"
@@ -197,13 +242,26 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
             :class="{ active: r.id === selectedId }"
             @click="emit('select', r.id)"
           >
-            <td class="svc-col" :title="r.name">
+            <td v-if="lockEnabled" class="lock-col" @click.stop>
+              <button
+                type="button"
+                class="lock-btn"
+                :class="{ locked: isLocked(r.id) }"
+                :disabled="r.blank || (!isLocked(r.id) && atCap)"
+                :title="lockTitle(r)"
+                @click="emit('toggle-lock', r.id)"
+              >
+                <span v-if="isLocked(r.id)" class="lock-dot" :style="{ background: lockDotHue(r.id) }" />
+                <Icon v-else name="pin" :size="11" />
+              </button>
+            </td>
+            <td class="svc-col" :title="r.blank ? BLANK_SERVICE_NAME : r.name">
               <span class="pulse tail-dot" />
-              <span v-if="identity(r.name).cluster" class="group-chip">
+              <span v-if="!r.blank && identity(r.name).cluster" class="group-chip">
                 <span class="chip-alias">{{ identity(r.name).clusterAlias }}</span>
                 <span class="chip-val">{{ identity(r.name).cluster }}</span>
               </span>
-              <span class="name-text">{{ identity(r.name).display }}</span>
+              <span class="name-text">{{ r.blank ? BLANK_SERVICE_NAME : identity(r.name).display }}</span>
             </td>
             <!-- "low" sits in the order-by column (that's the metric it
                  ranked low on); the others show "—" — they were never
@@ -219,7 +277,7 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
           </tr>
         </template>
         <tr v-if="visible.length === 0">
-          <td :colspan="columns.length + 1" class="empty">
+          <td :colspan="columns.length + 1 + (lockEnabled ? 1 : 0)" class="empty">
             {{ t('No services match') }} <code>{{ filter }}</code>.
           </td>
         </tr>
@@ -421,4 +479,39 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
   color: var(--sw-fg-2);
   font-variant-numeric: tabular-nums;
 }
+.lock-col {
+  width: 30px;
+  text-align: center;
+  padding-right: 0 !important;
+}
+.lock-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 22px;
+  height: 22px;
+  padding: 0;
+  background: transparent;
+  border: 1px solid transparent;
+  border-radius: 4px;
+  color: var(--sw-fg-3);
+  cursor: pointer;
+}
+.lock-btn:hover:not(:disabled) {
+  color: var(--sw-fg-1);
+  background: var(--sw-bg-2);
+}
+.lock-btn.locked {
+  color: var(--sw-fg-0);
+}
+.lock-btn:disabled {
+  opacity: 0.3;
+  cursor: not-allowed;
+}
+.lock-dot {
+  width: 9px;
+  height: 9px;
+  border-radius: 50%;
+  display: inline-block;
+}
 </style>
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index 2c4a06e..ac9802a 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -46,7 +46,8 @@ import { useLayerServices } from '@/layer/useLayerServices';
 import { useLayerSelectionStore } from '@/state/layerSelection';
 import { useSetupStore } from '@/state/setup';
 import { fmtMetric } from '@/utils/formatters';
-import { parseServiceName } from '@/utils/serviceName';
+import { parseServiceName, isBlankServiceName, BLANK_SERVICE_NAME } from '@/utils/serviceName';
+import { FF_ENTITY_COMPARE } from '@/utils/featureFlags';
 
 const route = useRoute();
 const router = useRouter();
@@ -268,7 +269,7 @@ const aggregates = computed(() =>
 // `resetForLayer` watch above; the dashboard view's auto-pick watch
 // (further down) sets the first roster entry when the selection is
 // null or no longer in the roster.
-const { selectedId, setSelected } = useSelectedService();
+const { selectedId, setSelected, lockedServiceIds, toggleLockService } = useSelectedService();
 const sampledServices = computed(() => landing.data.value?.sampledRows ?? landing.rows.value ?? []);
 const selectorColumns = computed(() => safeCfg.value.columns);
 // Full service roster (the layer's REAL catalog, independent of landing's
@@ -302,7 +303,10 @@ const selectedGroup = computed(() => selectedParsed.value.group);
 // separate chip next to the caret so the user reads it without the
 // raw `::` syntax bleeding into the UI.
 const selectedName = computed(() => {
-  if (selectedRow.value) return selectedParsed.value.base;
+  if (selectedRow.value) {
+    // OAP's blank-entity service has an empty name — show its `_blank` label.
+    return isBlankServiceName(selectedRow.value.serviceName) ? BLANK_SERVICE_NAME : selectedParsed.value.base;
+  }
   return sampledServices.value.length > 0 ? 'pick a service' : '—';
 });
 
@@ -313,6 +317,17 @@ const selectedName = computed(() => {
 // say) can flip the same meta flag without touching this file.
 const viewOwnsServiceSelector = computed(() => Boolean(route.meta?.ownsServiceSelector));
 
+// --- Multi-entity compare (service scope) -----------------------------
+// The picker's lock pins + the header chip bar show only on the SERVICE
+// dashboard route — the shell owns service selection; instance/endpoint
+// locking lives in the dashboard view's row list. Behind FF_ENTITY_COMPARE.
+// Service-scope lock is enabled here (the picker rows own the pins); the
+// locked cohort is DISPLAYED by the unified compare bar in the dashboard
+// view, so the shell only gates the picker pins, not a chip bar.
+const comparable = computed(
+  () => FF_ENTITY_COMPARE && !viewOwnsServiceSelector.value && scopeSegment.value === 'service',
+);
+
 // Zipkin trace mode is a self-contained, cross-service explorer (its
 // own service/duration/annotation filters), so the layer identity +
 // service KPI header adds nothing and crowds it out — hide the shell
@@ -608,7 +623,10 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
       :accent="layer.color"
       :naming-rule="layer.naming ?? null"
       :service-label="layer.slots.services"
+      :lock-enabled="comparable"
+      :locked-ids="lockedServiceIds"
       @select="pickService"
+      @toggle-lock="toggleLockService"
     />
 
     <!-- Loading state for the menu fetch — appears in the login →
diff --git a/apps/ui/src/layer/useLayerInstances.ts b/apps/ui/src/layer/useLayerInstances.ts
index 2f7977a..7d10d49 100644
--- a/apps/ui/src/layer/useLayerInstances.ts
+++ b/apps/ui/src/layer/useLayerInstances.ts
@@ -35,7 +35,14 @@ export function useLayerInstances(layerKey: Ref<string>, service: Ref<string | n
   });
   return {
     data: computed(() => q.data.value ?? null),
-    instances: computed(() => q.data.value?.instances ?? []),
+    // Sort by name so the selector is scannable. OAP/BFF return instances in
+    // registration order, which reads as random (data-hot-1, liaison-1,
+    // data-warm-1, …). `numeric` keeps `liaison-1 < liaison-10`.
+    instances: computed(() =>
+      [...(q.data.value?.instances ?? [])].sort((a, b) =>
+        a.name.localeCompare(b.name, undefined, { numeric: true }),
+      ),
+    ),
     isLoading: q.isLoading,
     isFetching: q.isFetching,
     error: q.error,
diff --git a/apps/ui/src/layer/useLayerServiceName.ts b/apps/ui/src/layer/useLayerServiceName.ts
index c1abaab..0b55ae7 100644
--- a/apps/ui/src/layer/useLayerServiceName.ts
+++ b/apps/ui/src/layer/useLayerServiceName.ts
@@ -19,6 +19,7 @@ import { computed, type ComputedRef, type Ref } from 'vue';
 import { useSelectedService } from './useSelectedService';
 import { useLayerServices } from './useLayerServices';
 import type { useLayerLanding } from './useLayerLanding';
+import { isBlankServiceName, BLANK_SERVICE_NAME } from '@/utils/serviceName';
 
 /**
  * Resolve the selected service's NAME for a layer tab — sample first,
@@ -45,10 +46,16 @@ export function useLayerServiceName(
   const { selectedId } = useSelectedService();
   const { services: roster } = useLayerServices(layerKey);
   return computed<string | null>(() => {
+    // OAP's reserved blank-entity service reports an EMPTY name over the wire
+    // (its id base64-decodes to `_blank`). Resolve it to the literal `_blank`
+    // so every downstream gate (`Boolean(serviceName)`) and MQE entity uses a
+    // non-empty, queryable key — OAP coerces `_blank` back to the same id. An
+    // empty name here would no-op every per-service query and hang the tab.
     const rows = landing.data.value?.sampledRows ?? landing.rows.value ?? [];
     const match = rows.find((r) => r.serviceId === selectedId.value);
-    if (match) return match.serviceName;
+    if (match) return isBlankServiceName(match.serviceName) ? BLANK_SERVICE_NAME : match.serviceName;
     const fromRoster = roster.value.find((s) => s.id === selectedId.value);
-    return fromRoster?.name ?? null;
+    if (fromRoster) return isBlankServiceName(fromRoster.name) ? BLANK_SERVICE_NAME : fromRoster.name;
+    return null;
   });
 }
diff --git a/apps/ui/src/layer/useSelectedEndpoint.ts b/apps/ui/src/layer/useSelectedEndpoint.ts
index 8331488..15bc357 100644
--- a/apps/ui/src/layer/useSelectedEndpoint.ts
+++ b/apps/ui/src/layer/useSelectedEndpoint.ts
@@ -34,5 +34,22 @@ export function useSelectedEndpoint() {
     store.setEndpoint(name);
   }
 
-  return { selectedEndpoint, setSelectedEndpoint };
+  // Multi-entity lock (endpoint scope). Locked set is CROSS-SERVICE
+  // compound keys; `isEndpointLocked` checks `name` under the current
+  // primary service via the store (compound-aware).
+  const lockedEndpointNames = computed<string[]>(() => store.activeCompareSet('endpoint'));
+  function toggleLockEndpoint(name: string): void {
+    store.toggleLock('endpoint', name);
+  }
+  function isEndpointLocked(name: string): boolean {
+    return store.isLocked('endpoint', name);
+  }
+
+  return {
+    selectedEndpoint,
+    setSelectedEndpoint,
+    lockedEndpointNames,
+    toggleLockEndpoint,
+    isEndpointLocked,
+  };
 }
diff --git a/apps/ui/src/layer/useSelectedInstance.ts b/apps/ui/src/layer/useSelectedInstance.ts
index 6009a7a..9e3d06d 100644
--- a/apps/ui/src/layer/useSelectedInstance.ts
+++ b/apps/ui/src/layer/useSelectedInstance.ts
@@ -34,5 +34,22 @@ export function useSelectedInstance() {
     store.setInstance(name);
   }
 
-  return { selectedInstance, setSelectedInstance };
+  // Multi-entity lock (instance scope). The locked set is CROSS-SERVICE
+  // compound keys; `isInstanceLocked` checks `name` under the current
+  // primary service via the store (compound-aware).
+  const lockedInstanceNames = computed<string[]>(() => store.activeCompareSet('instance'));
+  function toggleLockInstance(name: string): void {
+    store.toggleLock('instance', name);
+  }
+  function isInstanceLocked(name: string): boolean {
+    return store.isLocked('instance', name);
+  }
+
+  return {
+    selectedInstance,
+    setSelectedInstance,
+    lockedInstanceNames,
+    toggleLockInstance,
+    isInstanceLocked,
+  };
 }
diff --git a/apps/ui/src/layer/useSelectedService.ts b/apps/ui/src/layer/useSelectedService.ts
index 2957551..efe3b82 100644
--- a/apps/ui/src/layer/useSelectedService.ts
+++ b/apps/ui/src/layer/useSelectedService.ts
@@ -35,5 +35,11 @@ export function useSelectedService() {
     store.setService(id, opts);
   }
 
-  return { selectedId, setSelected };
+  // Multi-entity lock (service scope).
+  const lockedServiceIds = computed<string[]>(() => store.activeCompareSet('service'));
+  function toggleLockService(id: string): void {
+    store.toggleLock('service', id);
+  }
+
+  return { selectedId, setSelected, lockedServiceIds, toggleLockService };
 }
diff --git a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
index 428c460..bfa97fc 100644
--- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
@@ -38,6 +38,7 @@ import { useLayerDashboard, useLayerDashboardConfig } from '@/render/layer-dashb
 import { useLayerPageOrchestrator } from '@/render/layer-dashboard/useLayerPageOrchestrator';
 import { useLayerEndpoints } from '@/layer/useLayerEndpoints';
 import { useLayerInstances } from '@/layer/useLayerInstances';
+import { useLayerServices } from '@/layer/useLayerServices';
 import { useLayerLanding } from '@/layer/useLayerLanding';
 import { useTimeRangeStore, type TimeStep } from '@/controls/timeRange';
 import { pushEvent } from '@/controls/eventLog';
@@ -49,6 +50,20 @@ import { useLayerServiceName } from '@/layer/useLayerServiceName';
 import { useSetupStore } from '@/state/setup';
 import { fmtMetricAs, type MetricFormat } from '@/utils/formatters';
 import { ref, watch, watchEffect } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { FF_ENTITY_COMPARE } from '@/utils/featureFlags';
+import { useEntityPalette } from '@/utils/useEntityPalette';
+import { serviceBaseName, isBlankServiceName, BLANK_SERVICE_NAME } from '@/utils/serviceName';
+import {
+  MAX_LOCKED,
+  type CompareScope,
+  useLayerSelectionStore,
+  compoundKey,
+  splitCompound,
+} from '@/state/layerSelection';
+import Icon from '@/components/icons/Icon.vue';
+
+const { t } = useI18n({ useScope: 'global' });
 
 const route = useRoute();
 const layerKey = computed(() => String(route.params.layerKey ?? ''));
@@ -74,7 +89,9 @@ const scope = computed<string>(() => {
   }
   return 'service';
 });
-const { selectedId } = useSelectedService();
+const { selectedId, lockedServiceIds } = useSelectedService();
+// Direct store handle for exact-key cohort removal (cross-service ×).
+const selectionStore = useLayerSelectionStore();
 const { layers } = useLayers();
 const layer = computed<LayerDef | null>(() => layers.value.find((l) => l.key === layerKey.value) ?? null);
 
@@ -160,7 +177,8 @@ const { config, isLoading: configLoading } = useLayerDashboardConfig(layerKey, s
 // selector renders whenever a service is picked AND scope === instance.
 // Cleared automatically when the service changes so a stale instance
 // from a previous service doesn't bleed into the next one's queries.
-const { selectedInstance, setSelectedInstance } = useSelectedInstance();
+const { selectedInstance, setSelectedInstance, lockedInstanceNames, toggleLockInstance, isInstanceLocked } =
+  useSelectedInstance();
 // Instance list waits for `serviceName` (post-landing), not the URL
 // id fallback. Enforces the cascade: landing → service → instance →
 // metrics, each step firing exactly once after the prior resolves.
@@ -183,6 +201,9 @@ const expandedInstance = ref<string | null>(null);
  *  bound to it. */
 const { setSelected: setSelectedService } = useSelectedService();
 const landingRows = computed(() => landing.data.value?.sampledRows ?? landing.rows.value ?? []);
+// Full service roster (all ids→names) — resolves cohort labels for
+// locked services beyond the landing top-N sample.
+const { services: serviceRoster } = useLayerServices(layerKey);
 watch(landingRows, (rows) => {
   const first = rows[0];
   if (!first) return;
@@ -250,7 +271,8 @@ watch([instanceList, scope], ([list, s]) => {
 // there's no full-page paging; the picker is top 20…50. Search fires
 // on Enter (not on every keystroke) so the operator can type a
 // multi-word query without watching the BFF re-query each character.
-const { selectedEndpoint, setSelectedEndpoint } = useSelectedEndpoint();
+const { selectedEndpoint, setSelectedEndpoint, lockedEndpointNames, toggleLockEndpoint, isEndpointLocked } =
+  useSelectedEndpoint();
 /** Live text in the input — bound via v-model. */
 const endpointSearchInput = ref('');
 /** Committed keyword that actually drives the BFF query. Updated on
@@ -395,7 +417,51 @@ const widgetsForQuery = computed(() => config.value?.widgets ?? []);
 // so we don't fire (which would otherwise make the BFF substitute its own
 // default widget set); metrics run only for resolved widgets.
 const configReady = computed(() => widgetsForQuery.value.length > 0);
-const { data, isFetching, error } = useLayerDashboard(
+
+// Multi-entity compare. The locked set for the CURRENT scope (service /
+// instance / endpoint); empty unless the operator locked a cohort. Gated
+// to the lockable scopes — layer-wide scopes (topology / logs / …) never
+// compare. Feeds the per-entity fan-out in `useLayerDashboard`.
+const compareScope = computed<CompareScope | null>(() => {
+  if (scope.value === 'service' || scope.value === 'instance' || scope.value === 'endpoint') {
+    return scope.value;
+  }
+  return null;
+});
+const activeSet = computed<string[]>(() => {
+  if (!FF_ENTITY_COMPARE) return [];
+  if (compareScope.value === 'service') return lockedServiceIds.value;
+  if (compareScope.value === 'instance') return lockedInstanceNames.value;
+  if (compareScope.value === 'endpoint') return lockedEndpointNames.value;
+  return [];
+});
+// Canonical key of the CURRENT primary entity, in the SAME representation
+// the locked sets use (service id; `compoundKey(serviceId, name)` for
+// instance/endpoint). Fed to the fan-out so the primary dedupes out of the
+// comparison set instead of being fetched twice. Also drives the cohort
+// bar's "primary" chip + the row-list highlight.
+const scopePrimaryKey = computed<string | null>(() => {
+  if (compareScope.value === 'service') return selectedId.value;
+  const svc = selectedId.value ?? '';
+  if (compareScope.value === 'instance') {
+    return selectedInstance.value ? compoundKey(svc, selectedInstance.value) : null;
+  }
+  if (compareScope.value === 'endpoint') {
+    return selectedEndpoint.value ? compoundKey(svc, selectedEndpoint.value) : null;
+  }
+  return null;
+});
+
+const {
+  data,
+  isFetching,
+  error,
+  resultByEntity,
+  compareActive,
+  compareEntities,
+  entityState,
+  entityProgress,
+} = useLayerDashboard(
   layerKey,
   serviceName,
   scope,
@@ -404,8 +470,229 @@ const { data, isFetching, error } = useLayerDashboard(
   rangeRef,
   widgetsForQuery,
   configReady,
+  activeSet,
+  scopePrimaryKey,
+);
+
+const compareMode = computed(() => FF_ENTITY_COMPARE && compareActive.value);
+// In compare mode the tiles render from the per-entity fan-out, not the
+// primary `q` — so widget "no data" fallbacks must consult cohort progress
+// (entities arrived/total), not `isFetching` (the primary query only).
+// Otherwise a tile shows "no data" while the fan-out is still in flight.
+const compareLoading = computed(
+  () => compareMode.value && entityProgress.value.arrived < entityProgress.value.total,
+);
+const palette = useEntityPalette();
+// The banner (primary) entity always renders in the reserved accent; only the
+// PINS draw from the 6-hue palette — so the palette never needs a 7th slot and
+// the "current" entity stays visually tied to the orange header KPIs.
+watch(
+  compareEntities,
+  (ids) => palette.syncToIds(ids.filter((id) => id !== scopePrimaryKey.value)),
+  { immediate: true },
+);
+function compareHue(key: string): string {
+  return key === scopePrimaryKey.value ? 'var(--sw-accent)' : palette.hueFor(key);
+}
+// Hue for an instance/endpoint ROW (raw name) under the current service —
+// the palette is keyed by the cross-service compound key.
+function rowHue(name: string): string {
+  return compareHue(compoundKey(selectedId.value ?? '', name));
+}
+// Display label for an entity key, scope-aware: service ids resolve to a
+// base name from the roster; instance/endpoint names render as-is.
+function serviceLabelFor(id: string): string {
+  // landing sample first (has metrics), then the FULL roster so a locked
+  // low-traffic service beyond the top-N sample still resolves to a name.
+  const name =
+    landingRows.value.find((s) => s.serviceId === id)?.serviceName ??
+    serviceRoster.value.find((s) => s.id === id)?.name ??
+    id;
+  return isBlankServiceName(name) ? BLANK_SERVICE_NAME : serviceBaseName(name);
+}
+function entityLabel(key: string): string {
+  if (compareScope.value === 'service') return serviceLabelFor(key);
+  // instance/endpoint: cross-service compound key → "<serviceBase> · <name>".
+  const { service: svc, name } = splitCompound(key);
+  return svc ? `${serviceLabelFor(svc)} · ${name}` : name;
+}
+function resultFor(widgetId: string, entityKey: string) {
+  return resultByEntity.value.get(entityKey)?.get(widgetId);
+}
+// Table widgets compare per-entity: gather each entity's rows tagged
+// with its key (Option B puts no entityKey on the wire) for TableWidget.
+const compareTableEntities = computed(() =>
+  compareEntities.value.map((e) => ({ key: e, name: entityLabel(e), hue: compareHue(e) })),
+);
+function compareTableRows(widgetId: string) {
+  return compareEntities.value.flatMap((e) =>
+    (resultFor(widgetId, e)?.table ?? []).map((r) => ({ ...r, entityKey: e })),
+  );
+}
+
+// --- Unified compare cohort bar (scope-aware) ---------------------------
+// The persistent working-set surface, decoupled from the (service-
+// dependent, paginated) selection list. Shows the comparison set = the
+// locked set; the list pins are the ADD affordance. All three scopes
+// differ in key/label/primary/unlock, handled below. (`scopePrimaryKey`
+// is defined above the dashboard call so the fan-out can dedupe with it.)
+interface CohortChip {
+  key: string;
+  label: string;
+  hue: string;
+  primary: boolean;
+  /** Per-entity fetch state — surfaces the isolated fan-out error/loading
+   *  on the chip so a failed entity is visible (not silently blank). */
+  state: 'loading' | 'ready' | 'error';
+}
+const cohortChips = computed<CohortChip[]>(() =>
+  compareEntities.value.map((key) => ({
+    key,
+    label: entityLabel(key),
+    hue: compareHue(key),
+    primary: key === scopePrimaryKey.value,
+    state: entityState(key),
+  })),
 );
+// Distinct services represented in the locked instance/endpoint cohort.
+// A cross-service cohort (size > 1) can't be summarised as "of {service}".
+const cohortServices = computed<string[]>(() => {
+  const set = new Set<string>();
+  for (const key of compareEntities.value) set.add(splitCompound(key).service);
+  return [...set];
+});
+// Show the cohort bar from the FIRST lock (so a single lock is visible
+// regardless of list churn); the grid itself still needs >=2.
+const cohortVisible = computed(() => activeSet.value.length >= 1);
+const cohortHeader = computed<string>(() => {
+  const n = compareEntities.value.length;
+  if (n < 2) return t('{n} locked · lock 1 more to compare', { n });
+  if (compareScope.value === 'service') return t('Comparing {n} services', { n });
+  // instance/endpoint: name the service only when the whole cohort shares
+  // one; a mixed-service cohort says "across services" (chips carry the
+  // per-entity service prefix).
+  const svcs = cohortServices.value;
+  const single = svcs.length === 1 ? serviceLabelFor(svcs[0]) : null;
+  if (compareScope.value === 'instance') {
+    return single
+      ? t('Comparing {n} instances of {service}', { n, service: single })
+      : t('Comparing {n} instances across services', { n });
+  }
+  return single
+    ? t('Comparing {n} endpoints of {service}', { n, service: single })
+    : t('Comparing {n} endpoints across services', { n });
+});
+// Cohort chips are display-only: a pin is a comparison member, NOT a
+// banner control. Clicking a chip must NOT refocus the banner (that would
+// re-query the primary + reload the instance/endpoint list — the disruptive
+// "refresh" we explicitly avoid). The banner changes only through the top
+// selector / list rows; chips offer just the × (unpin), and the CURRENT chip
+// (the banner entity) isn't removable.
+function unlockChip(key: string): void {
+  // Remove the EXACT key (compound for instance/endpoint) — it may
+  // belong to a service other than the current primary.
+  if (compareScope.value) selectionStore.removeKey(compareScope.value, key);
+}
 
+// --- Multi-entity INLINE rendering -------------------------------------
+// In compare mode every widget keeps its normal tile and renders all N
+// entities inside it: card -> one row per entity, line -> N overlaid
+// (entity-hued) lines, top/record -> per-entity tabs + a merged "All".
+function cardValueFor(wid: string, e: string): number | null {
+  return resultFor(wid, e)?.value ?? null;
+}
+function cardTextFor(w: { id: string; format?: MetricFormat; valueMap?: Record<string, string> }, e: string): string {
+  const v = cardValueFor(w.id, e);
+  if (v != null && w.format === 'enum' && w.valueMap) {
+    const lbl = w.valueMap[String(Math.round(v))];
+    if (lbl != null) return lbl;
+  }
+  return fmtMetricAs(v, w.format);
+}
+interface CompareSeries {
+  label: string;
+  data: Array<number | null>;
+  yAxisIndex?: number;
+  unit?: string;
+  color: string;
+}
+function multiLineSeries(wid: string): CompareSeries[] {
+  const out: CompareSeries[] = [];
+  for (const e of compareEntities.value) {
+    const series = resultFor(wid, e)?.series ?? [];
+    const multi = series.length > 1;
+    for (const s of series) {
+      out.push({
+        // Label FIRST, then the entity (`service · instance/endpoint`): the
+        // per-series label (e.g. a JVM thread state) is the valuable tag and
+        // must survive truncation, whereas a long instance/endpoint name can
+        // ellipsize at the tail. Entities stay distinguishable by hue + the
+        // (differing) start of the name.
+        label: multi ? `${s.label} · ${entityLabel(e)}` : entityLabel(e),
+        data: s.data,
+        ...(s.yAxisIndex !== undefined ? { yAxisIndex: s.yAxisIndex } : {}),
+        ...(s.unit ? { unit: s.unit } : {}),
+        color: compareHue(e),
+      });
+    }
+  }
+  return out;
+}
+function lineLen(wid: string): number {
+  for (const e of compareEntities.value) {
+    const d = resultFor(wid, e)?.series?.[0]?.data;
+    if (d && d.length) return d.length;
+  }
+  return 0;
+}
+interface TopItem {
+  name: string;
+  value: number | null;
+}
+function topItemsFor(wid: string, e: string): TopItem[] {
+  const r = resultFor(wid, e);
+  if (!r) return [];
+  if (r.topList) return r.topList;
+  if (r.topGroups && r.topGroups[0]) return r.topGroups[0].items;
+  if (r.records) return r.records.map((x) => ({ name: x.name, value: x.value ?? null }));
+  return [];
+}
+interface TopGroup {
+  label: string;
+  expression: string;
+  items: TopItem[];
+}
+// "All" follows the response's own asc/desc (MQE-fixed) then prefixes the
+// entity; per-entity groups keep each entity's native order.
+function multiTopGroups(wid: string): TopGroup[] {
+  const ents = compareEntities.value;
+  // Detect the MQE's fixed asc/desc from the FIRST entity that actually has
+  // >=2 items — probing only ents[0] inverts the merged sort when the primary
+  // happens to be sparse while a pinned entity carries the full ranking.
+  const probe = ents.map((e) => topItemsFor(wid, e)).find((it) => it.length >= 2) ?? [];
+  const desc = probe.length < 2 || (probe[0].value ?? 0) >= (probe[probe.length - 1].value ?? 0);
+  const all: TopItem[] = ents.flatMap((e) =>
+    topItemsFor(wid, e).map((it) => ({ name: `${entityLabel(e)} · ${it.name}`, value: it.value })),
+  );
+  all.sort((a, b) => {
+    const av = a.value ?? (desc ? -Infinity : Infinity);
+    const bv = b.value ?? (desc ? -Infinity : Infinity);
+    return desc ? bv - av : av - bv;
+  });
+  const groups: TopGroup[] = [{ label: t('All'), expression: '', items: all }];
+  for (const e of ents) groups.push({ label: entityLabel(e), expression: '', items: topItemsFor(wid, e) });
+  return groups;
+}
+function hasMultiTopData(wid: string): boolean {
+  return compareEntities.value.some((e) => topItemsFor(wid, e).length > 0);
+}
+// Instance/endpoint row lock pins (service-scope locking lives in the
+// shell). Shown behind the flag; cap-guarded per scope.
+const compareLockable = computed(() => FF_ENTITY_COMPARE);
+const instAtCap = computed(() => lockedInstanceNames.value.length >= MAX_LOCKED);
+const epAtCap = computed(() => lockedEndpointNames.value.length >= MAX_LOCKED);
+
+// Line-cell drill-out → multi-entity overlay chart.
 // Sequential page-init events for the EventTicker — config →
 // services → service → instances/endpoints → instance/endpoint →
 // dashboard. The orchestrator watches the refs above and emits one
@@ -521,13 +808,35 @@ function hasTopData(w: { id: string; type: string }): boolean {
 }
 
 /**
- * `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.
+ * `visibleWhen` is evaluated BFF-side PER ENTITY: a gated-out widget comes
+ * back flagged `hidden: true` (group/entity misses also skip their MQE
+ * there). A widget with no result yet (loading) stays visible until its
+ * result lands.
+ *
+ * In compare mode the gate is the UNION across the whole cohort — the widget
+ * shows if ANY compared entity has it. `visibleWhen` keys off per-entity
+ * facts (a metric existing, an instance attribute like `language=java`), so
+ * judging visibility by the banner alone would drop, say, every JVM widget
+ * the moment the banner is a non-JVM (e.g. LUA) service even though Java pins
+ * carry rich JVM data. It hides only once EVERY entity has loaded and all
+ * flag it hidden; while any entity is still loading it stays visible.
  */
 function isHidden(id: string): boolean {
-  return resultsById.value.get(id)?.hidden === true;
+  if (!compareMode.value) {
+    return resultsById.value.get(id)?.hidden === true;
+  }
+  let allLoaded = true;
+  for (const e of compareEntities.value) {
+    const r = resultByEntity.value.get(e)?.get(id);
+    if (!r) {
+      allLoaded = false;
+      continue;
+    }
+    if (r.hidden !== true) return false; // at least one entity shows it
+  }
+  // No entity shows it: hide only when the whole cohort has reported in;
+  // otherwise keep it visible while results are still arriving.
+  return allLoaded && compareEntities.value.length > 0;
 }
 </script>
 
@@ -578,8 +887,20 @@ function isHidden(id: string): boolean {
           v-for="i in instanceList"
           :key="i.id"
           class="ib-row"
-          :class="{ on: selectedInstance === i.name }"
+          :class="{ on: selectedInstance === i.name, 'has-lock': compareLockable }"
         >
+          <button
+            v-if="compareLockable"
+            type="button"
+            class="ib-lock"
+            :class="{ locked: isInstanceLocked(i.name) }"
+            :disabled="!isInstanceLocked(i.name) && instAtCap"
+            :title="isInstanceLocked(i.name) ? t('Remove from comparison') : t('Add to comparison')"
+            @click.stop="toggleLockInstance(i.name)"
+          >
+            <span v-if="isInstanceLocked(i.name)" class="ib-lock-dot" :style="{ background: rowHue(i.name) }" />
+            <Icon v-else name="pin" :size="11" />
+          </button>
           <button
             type="button"
             class="ib-pick-btn"
@@ -678,8 +999,20 @@ function isHidden(id: string): boolean {
             v-for="e in endpointList"
             :key="e.id"
             class="ib-row"
-            :class="{ on: selectedEndpoint === e.name }"
+            :class="{ on: selectedEndpoint === e.name, 'has-lock': compareLockable }"
           >
+            <button
+              v-if="compareLockable"
+              type="button"
+              class="ib-lock"
+              :class="{ locked: isEndpointLocked(e.name) }"
+              :disabled="!isEndpointLocked(e.name) && epAtCap"
+              :title="isEndpointLocked(e.name) ? t('Remove from comparison') : t('Add to comparison')"
+              @click.stop="toggleLockEndpoint(e.name)"
+            >
+              <span v-if="isEndpointLocked(e.name)" class="ib-lock-dot" :style="{ background: rowHue(e.name) }" />
+              <Icon v-else name="pin" :size="11" />
+            </button>
             <button
               type="button"
               class="ib-pick-btn"
@@ -725,7 +1058,42 @@ function isHidden(id: string): boolean {
     <div v-else-if="widgets.length === 0" class="empty">
       No widgets defined for this layer. Add some via Dashboard setup → Layer dashboards.
     </div>
-    <div v-else class="grid">
+    <template v-else>
+    <!-- Unified compare cohort bar — the persistent comparison-set surface
+         for all three scopes (service / instance / endpoint). The banner
+         entity is the CURRENT member (accent, not removable); pins add to it
+         and carry × to unpin. Chips are display-only — none switch the
+         banner (that stays with the top selector / list rows). -->
+    <div v-if="cohortVisible" class="cohort-bar">
+      <span class="cohort-head">{{ cohortHeader }}</span>
+      <div class="cohort-chips">
+        <div
+          v-for="c in cohortChips"
+          :key="c.key"
+          class="cohort-chip"
+          :class="{ primary: c.primary, 'is-error': c.state === 'error', 'is-loading': c.state === 'loading' }"
+          :style="c.primary ? { borderColor: c.hue } : {}"
+          :title="c.state === 'error'
+            ? t('Failed to load')
+            : c.primary ? t('Current — drives the header KPIs') : c.label"
+        >
+          <span class="cohort-dot" :style="{ background: c.hue }" />
+          <span class="cohort-name">{{ c.label }}</span>
+          <span v-if="c.primary" class="cohort-tag">{{ t('CURRENT') }}</span>
+          <button
+            v-else
+            type="button"
+            class="cohort-x"
+            :title="t('Remove from comparison')"
+            @click="unlockChip(c.key)"
+          >×</button>
+        </div>
+      </div>
+    </div>
+    <!-- Tile grid keeps its normal layout in compare mode; each widget
+         renders all N entities inline (card rows / overlaid lines /
+         per-entity tabs). -->
+    <div class="grid">
       <div
         v-for="w in widgets.filter((wi) => !isHidden(wi.id))"
         :key="w.id"
@@ -757,11 +1125,23 @@ function isHidden(id: string): boolean {
           </div>
         </div>
         <div class="w-body" :class="`type-${w.type}`">
-          <template v-if="resultsById.get(w.id)?.error">
+          <!-- The primary query's per-widget error gates ONLY the single-
+               entity view. In compare mode the grid renders from the
+               per-entity fan-out (errors are isolated per entity — a bad
+               primary, or a primary outside the locked cohort, must not
+               blank valid locked-entity results). -->
+          <template v-if="!compareMode && resultsById.get(w.id)?.error">
             <span class="muted">{{ resultsById.get(w.id)!.error }}</span>
           </template>
           <template v-else-if="w.type === 'card'">
-            <div class="card-value">
+            <div v-if="compareMode" class="card-compare">
+              <div v-for="e in compareEntities" :key="e" class="cc-row">
+                <span class="cc-dot" :style="{ background: compareHue(e) }" />
+                <span class="cc-name" :title="entityLabel(e)">{{ entityLabel(e) }}</span>
+                <span class="cc-val" :class="{ muted: cardValueFor(w.id, e) == null }">{{ cardTextFor(w, e) }}</span>
+              </div>
+            </div>
+            <div v-else class="card-value">
               <span class="num" :class="{ muted: resultsById.get(w.id)?.value == null }">
                 {{ resultsById.has(w.id)
                   ? cardText(w)
@@ -772,19 +1152,27 @@ function isHidden(id: string): boolean {
           </template>
           <template v-else-if="w.type === 'line'">
             <TimeChart
-              v-if="resultsById.get(w.id)?.series?.length"
-              :series="resultsById.get(w.id)!.series!"
+              v-if="compareMode ? multiLineSeries(w.id).length : resultsById.get(w.id)?.series?.length"
+              :series="compareMode ? multiLineSeries(w.id) : resultsById.get(w.id)!.series!"
               :unit="w.unit"
               :height="(w.rowSpan ?? 1) * 110 - 50"
               :accent="widgetColor(w)"
               :format="w.format"
-              :x-labels="xLabelsForLen(resultsById.get(w.id)!.series![0]?.data.length ?? 0)"
+              :x-labels="xLabelsForLen(compareMode ? lineLen(w.id) : (resultsById.get(w.id)!.series![0]?.data.length ?? 0))"
             />
-            <span v-else class="muted">{{ isFetching && !resultsById.has(w.id) ? 'loading…' : 'no data' }}</span>
+            <span v-else class="muted">{{ (compareMode ? compareLoading : (isFetching && !resultsById.has(w.id))) ? 'loading…' : 'no data' }}</span>
           </template>
           <template v-else-if="w.type === 'top'">
             <TopList
-              v-if="resultsById.get(w.id)?.topGroups?.length"
+              v-if="compareMode && hasMultiTopData(w.id)"
+              :ref="(el) => setTopListRef(w.id, el)"
+              :groups="multiTopGroups(w.id)"
+              :unit="w.unit"
+              :color="widgetColor(w)"
+              :title="w.title"
+            />
+            <TopList
+              v-else-if="!compareMode && resultsById.get(w.id)?.topGroups?.length"
               :ref="(el) => setTopListRef(w.id, el)"
               :groups="resultsById.get(w.id)!.topGroups!"
               :unit="w.unit"
@@ -792,43 +1180,54 @@ function isHidden(id: string): boolean {
               :title="w.title"
             />
             <TopList
-              v-else-if="resultsById.get(w.id)?.topList?.length"
+              v-else-if="!compareMode && resultsById.get(w.id)?.topList?.length"
               :ref="(el) => setTopListRef(w.id, el)"
               :items="resultsById.get(w.id)!.topList!"
               :unit="w.unit"
               :color="widgetColor(w)"
               :title="w.title"
             />
-            <span v-else class="muted">{{ isFetching && !resultsById.has(w.id) ? 'loading…' : 'no data' }}</span>
+            <span v-else class="muted">{{ (compareMode ? compareLoading : (isFetching && !resultsById.has(w.id))) ? 'loading…' : 'no data' }}</span>
           </template>
           <template v-else-if="w.type === 'record'">
             <!-- Slow-statement / record table — reuses the TopList
                  renderer since the shape (name + value) is identical.
                  Future runtime work will add trace-id drill-in. -->
             <TopList
-              v-if="resultsById.get(w.id)?.records?.length"
+              v-if="compareMode && hasMultiTopData(w.id)"
+              :ref="(el) => setTopListRef(w.id, el)"
+              :groups="multiTopGroups(w.id)"
+              :unit="w.unit"
+              :color="widgetColor(w)"
+              :title="w.title"
+            />
+            <TopList
+              v-else-if="!compareMode && resultsById.get(w.id)?.records?.length"
               :ref="(el) => setTopListRef(w.id, el)"
               :items="resultsById.get(w.id)!.records!.map((r) => ({ name: r.name, value: r.value ?? null }))"
               :unit="w.unit"
               :color="widgetColor(w)"
               :title="w.title"
             />
-            <span v-else class="muted">{{ isFetching && !resultsById.has(w.id) ? 'loading…' : 'no data' }}</span>
+            <span v-else class="muted">{{ (compareMode ? compareLoading : (isFetching && !resultsById.has(w.id))) ? 'loading…' : 'no data' }}</span>
           </template>
           <template v-else-if="w.type === 'table'">
             <TableWidget
-              v-if="resultsById.get(w.id)?.table?.length"
-              :rows="resultsById.get(w.id)!.table!"
+              v-if="compareMode ? compareTableRows(w.id).length : resultsById.get(w.id)?.table?.length"
+              :rows="compareMode ? compareTableRows(w.id) : resultsById.get(w.id)!.table!"
+              :entities="compareMode ? compareTableEntities : undefined"
+              :label-top-n="w.labelTopN"
               :headers="w.tableHeaders"
               :show-values="w.showTableValues !== false"
               :unit="w.unit"
               :format="w.format"
             />
-            <span v-else class="muted">{{ isFetching && !resultsById.has(w.id) ? 'loading…' : 'no data' }}</span>
+            <span v-else class="muted">{{ (compareMode ? compareLoading : (isFetching && !resultsById.has(w.id))) ? 'loading…' : 'no data' }}</span>
           </template>
         </div>
       </div>
     </div>
+    </template>
   </div>
 </template>
 
@@ -957,6 +1356,124 @@ function isHidden(id: string): boolean {
   padding: 2px 10px;
   border-bottom: 1px solid var(--sw-line);
 }
+.ib-row.has-lock {
+  grid-template-columns: auto 1fr auto;
+}
+.ib-lock {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 20px;
+  height: 20px;
+  padding: 0;
+  align-self: center;
+  background: transparent;
+  border: 1px solid transparent;
+  border-radius: 4px;
+  color: var(--sw-fg-3);
+  cursor: pointer;
+}
+.ib-lock:hover:not(:disabled) {
+  color: var(--sw-fg-1);
+  background: var(--sw-bg-2);
+}
+.ib-lock:disabled {
+  opacity: 0.3;
+  cursor: not-allowed;
+}
+.ib-lock-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  display: inline-block;
+}
+.cohort-bar {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 10px;
+  padding: 8px 12px;
+  margin-bottom: 10px;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+}
+.cohort-head {
+  font-size: 11px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+  white-space: nowrap;
+}
+.cohort-chips {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+.cohort-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  height: 24px;
+  padding: 0 8px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 12px;
+  color: var(--sw-fg-1);
+  font-size: 11px;
+  /* Display-only — the chip itself isn't a control (only its × is). */
+  cursor: default;
+}
+.cohort-chip.primary {
+  font-weight: 700;
+  color: var(--sw-fg-0);
+}
+.cohort-tag {
+  margin-left: 2px;
+  padding: 0 5px;
+  border-radius: 7px;
+  background: var(--sw-accent-soft);
+  color: var(--sw-accent);
+  font-size: 8.5px;
+  font-weight: 700;
+  letter-spacing: 0.04em;
+}
+/* Isolated per-entity fan-out state: a failed entity is tinted (not
+   silently blank); a still-loading one dims until its slot resolves. */
+.cohort-chip.is-error {
+  border-color: var(--sw-err);
+  color: var(--sw-err);
+}
+.cohort-chip.is-loading {
+  opacity: 0.6;
+}
+.cohort-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  flex: none;
+}
+.cohort-name {
+  font-family: var(--sw-mono);
+  max-width: 200px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.cohort-x {
+  margin-left: 2px;
+  padding: 0 2px;
+  border: none;
+  background: none;
+  color: var(--sw-fg-3);
+  font-size: 13px;
+  line-height: 1;
+  cursor: pointer;
+}
+.cohort-x:hover {
+  color: var(--sw-err);
+}
 .ib-row:last-child { border-bottom: none; }
 .ib-row.on {
   background: var(--sw-accent-soft);
diff --git a/apps/ui/src/render/layer-dashboard/entityFanout.test.ts b/apps/ui/src/render/layer-dashboard/entityFanout.test.ts
new file mode 100644
index 0000000..87624c0
--- /dev/null
+++ b/apps/ui/src/render/layer-dashboard/entityFanout.test.ts
@@ -0,0 +1,135 @@
+/*
+ * 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.
+ */
+
+import { describe, expect, it } from 'vitest';
+import {
+  createLimiter,
+  entityDashboardBody,
+  entityDashboardKey,
+  fanoutEntities,
+} from './entityFanout';
+
+describe('fanoutEntities', () => {
+  it('de-duplicates the primary out of the locked set and drops empties', () => {
+    expect(fanoutEntities('p', ['a', 'p', 'b'])).toEqual(['a', 'b']);
+    expect(fanoutEntities('p', ['a', '', 'b'])).toEqual(['a', 'b']);
+  });
+
+  it('a null primary fans out the whole (non-empty) locked set', () => {
+    expect(fanoutEntities(null, ['a', '', 'b'])).toEqual(['a', 'b']);
+    expect(fanoutEntities('p', [])).toEqual([]);
+  });
+
+  it('when the primary is the only lock there is nothing to fan out', () => {
+    expect(fanoutEntities('p', ['p'])).toEqual([]);
+  });
+});
+
+describe('entityDashboardBody — slot substitution per scope', () => {
+  const range = { step: 'MINUTE' as const, startMs: 1000, endMs: 2000 };
+
+  it('service scope puts the service in `service` (entityService), no name', () => {
+    expect(entityDashboardBody('service', 'svcB', '', null, null)).toEqual({
+      scope: 'service',
+      service: 'svcB',
+    });
+  });
+
+  it('instance scope keeps the primary service and sets serviceInstance', () => {
+    expect(entityDashboardBody('instance', 'svcA', 'inst2', range, null)).toEqual({
+      scope: 'instance',
+      service: 'svcA',
+      serviceInstance: 'inst2',
+      step: 'MINUTE',
+      startMs: 1000,
+      endMs: 2000,
+    });
+  });
+
+  it('endpoint scope keeps the primary service and sets endpoint', () => {
+    expect(entityDashboardBody('endpoint', 'svcA', 'ep2', null, null)).toEqual({
+      scope: 'endpoint',
+      service: 'svcA',
+      endpoint: 'ep2',
+    });
+  });
+});
+
+describe('entityDashboardKey — mirrors the single-query key shape', () => {
+  it('places the entity in the scope slot, leaving others null', () => {
+    expect(entityDashboardKey('GENERAL', 'instance', 'svcA', 'inst2', 0, 'MINUTE:1:2', null)).toEqual([
+      'dashboard',
+      'GENERAL',
+      'svcA',
+      'instance',
+      0,
+      'inst2',
+      null,
+      'MINUTE:1:2',
+      null,
+    ]);
+  });
+
+  it('distinct entities produce distinct keys (per-entity cache)', () => {
+    const a = entityDashboardKey('GENERAL', 'service', 'svcA', '', 0, null, null);
+    const b = entityDashboardKey('GENERAL', 'service', 'svcB', '', 0, null, null);
+    expect(a).not.toEqual(b);
+  });
+});
+
+describe('createLimiter — bounded concurrency', () => {
+  it('never runs more than `max` tasks at once', async () => {
+    const limit = createLimiter(2);
+    let active = 0;
+    let peak = 0;
+    const task = () =>
+      new Promise<number>((resolve) => {
+        active += 1;
+        peak = Math.max(peak, active);
+        setTimeout(() => {
+          active -= 1;
+          resolve(active);
+        }, 10);
+      });
+    const results = await Promise.all(Array.from({ length: 6 }, () => limit(task)));
+    expect(peak).toBe(2);
+    expect(results).toHaveLength(6);
+  });
+
+  it('max = 1 serializes (peak 1)', async () => {
+    const limit = createLimiter(1);
+    let active = 0;
+    let peak = 0;
+    const task = () =>
+      new Promise<void>((resolve) => {
+        active += 1;
+        peak = Math.max(peak, active);
+        setTimeout(() => {
+          active -= 1;
+          resolve();
+        }, 5);
+      });
+    await Promise.all(Array.from({ length: 4 }, () => limit(task)));
+    expect(peak).toBe(1);
+  });
+
+  it('releases the slot even when a task rejects', async () => {
+    const limit = createLimiter(1);
+    await expect(limit(() => Promise.reject(new Error('boom')))).rejects.toThrow('boom');
+    await expect(limit(() => Promise.resolve('ok'))).resolves.toBe('ok');
+  });
+});
diff --git a/apps/ui/src/render/layer-dashboard/entityFanout.ts b/apps/ui/src/render/layer-dashboard/entityFanout.ts
new file mode 100644
index 0000000..dc43380
--- /dev/null
+++ b/apps/ui/src/render/layer-dashboard/entityFanout.ts
@@ -0,0 +1,145 @@
+/*
+ * 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.
+ */
+
+/**
+ * Pure helpers for the multi-entity dashboard fan-out (Option B): when a
+ * cohort of entities is locked the UI issues ONE single-entity dashboard
+ * request per locked entity (reusing the existing endpoint) instead of
+ * folding them into one server-side call. These functions are the
+ * testable core — the composable (`useLayerDashboard`) wires them to
+ * vue-query's `useQueries`.
+ */
+
+import type { DashboardWidget } from '@skywalking-horizon-ui/api-client';
+
+/** Max simultaneous in-flight per-entity requests. Sequential at 1; the
+ *  cohort cap (6) bounds the total. Keeps a locked cohort from spiking
+ *  the OAP with N concurrent dashboard batches. */
+export const ENTITY_FANOUT_CONCURRENCY = 3;
+
+export type FanoutScope = 'service' | 'instance' | 'endpoint';
+
+export interface FanoutRange {
+  step: 'MINUTE' | 'HOUR' | 'DAY';
+  startMs: number;
+  endMs: number;
+}
+
+/** Single-entity dashboard request body (subset of the BFF contract). */
+export interface DashboardBody {
+  service?: string;
+  serviceInstance?: string;
+  endpoint?: string;
+  scope?: string;
+  step?: 'MINUTE' | 'HOUR' | 'DAY';
+  startMs?: number;
+  endMs?: number;
+  widgets?: DashboardWidget[];
+}
+
+/**
+ * Bound concurrent async tasks at `max`; excess tasks queue FIFO and run
+ * as slots free. `run(task)` resolves with the task's result (or rejects
+ * if it throws — the slot is still released). `max = 1` ⇒ sequential.
+ */
+export function createLimiter(max: number) {
+  let active = 0;
+  const queue: Array<() => void> = [];
+  function release(): void {
+    active -= 1;
+    const next = queue.shift();
+    if (next) next();
+  }
+  return async function run<T>(task: () => Promise<T>): Promise<T> {
+    if (active >= max) {
+      await new Promise<void>((resolve) => {
+        queue.push(resolve);
+      });
+    }
+    active += 1;
+    try {
+      return await task();
+    } finally {
+      release();
+    }
+  };
+}
+
+/**
+ * The entities to FAN OUT — the locked comparison set minus the primary
+ * (and minus empties). The primary is already served by the single
+ * dashboard query, so the fan-out only fetches the additional locked
+ * entities. The comparison set's render order is just the locked set as-is
+ * (the primary is a member iff it's locked); there's no primary-first
+ * reordering, so callers use the locked array directly for ordering.
+ */
+export function fanoutEntities(primary: string | null, locked: readonly string[]): string[] {
+  return locked.filter((e) => e.length > 0 && e !== primary);
+}
+
+/**
+ * Build a single-entity request body. `entityService` is the service to
+ * query; `entityName` is the instance/endpoint within it (empty at
+ * service scope, where the service IS the entity). Instance/endpoint
+ * pins are cross-service, so each carries its OWN service here.
+ */
+export function entityDashboardBody(
+  scope: FanoutScope,
+  entityService: string | null,
+  entityName: string,
+  range: FanoutRange | null,
+  widgets: DashboardWidget[] | null,
+): DashboardBody {
+  const body: DashboardBody = { scope };
+  if (entityService) body.service = entityService;
+  if (scope === 'instance' && entityName) body.serviceInstance = entityName;
+  if (scope === 'endpoint' && entityName) body.endpoint = entityName;
+  if (range) {
+    body.step = range.step;
+    body.startMs = range.startMs;
+    body.endMs = range.endMs;
+  }
+  if (widgets && widgets.length > 0) body.widgets = widgets;
+  return body;
+}
+
+/**
+ * vue-query key for one entity's dashboard — mirrors the single query's
+ * key shape (the entity's own service + the instance/endpoint name) so a
+ * previously viewed entity is served warm from cache when it's locked.
+ */
+export function entityDashboardKey(
+  layerKey: string,
+  scope: FanoutScope,
+  entityService: string | null,
+  entityName: string,
+  mockTop: number,
+  rangeKey: string | null,
+  widgetsJson: string | null,
+): Array<string | number | null> {
+  return [
+    'dashboard',
+    layerKey,
+    entityService,
+    scope,
+    mockTop,
+    scope === 'instance' ? entityName : null,
+    scope === 'endpoint' ? entityName : null,
+    rangeKey,
+    widgetsJson,
+  ];
+}
diff --git a/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts b/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
index eacc193..ce2e54a 100644
--- a/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
+++ b/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
@@ -28,7 +28,7 @@
  */
 
 import { computed, ref, type Ref } from 'vue';
-import { useQuery } from '@tanstack/vue-query';
+import { useQueries, useQuery } from '@tanstack/vue-query';
 import { useAutoRefreshSubscribe } from '../../controls/useAutoRefreshSubscribe';
 import { bffClient } from '@/api/client';
 import {
@@ -36,9 +36,20 @@ import {
   getDashboardConfig,
   useConfigBundle,
 } from '@/controls/configBundle';
+import {
+  ENTITY_FANOUT_CONCURRENCY,
+  type FanoutScope,
+  createLimiter,
+  entityDashboardBody,
+  entityDashboardKey,
+  fanoutEntities,
+} from './entityFanout';
+import { compoundKey, splitCompound } from '@/state/layerSelection';
 import type {
   DashboardConfig,
+  DashboardResponse,
   DashboardWidget,
+  DashboardWidgetResult,
 } from '@skywalking-horizon-ui/api-client';
 
 export function useLayerDashboardConfig(layerKey: Ref<string>, scope?: Ref<string>) {
@@ -117,6 +128,23 @@ export function useLayerDashboard(
    *  bundle lands. Callers without a config bundle omit it (treated as
    *  ready) and keep the server-resolves-widgets behaviour. */
   configReady?: Ref<boolean>,
+  /** Locked comparison set for this scope (Option B multi-entity
+   *  cross-check). When it holds >=2 entities (counting the primary)
+   *  the composable fans out one single-entity dashboard query per
+   *  locked-but-non-primary entity (concurrency-bounded) and assembles
+   *  `resultByEntity`. Empty/absent ⇒ the single-query path is
+   *  byte-identical. */
+  activeSet?: Ref<string[]>,
+  /** Canonical key of the PRIMARY entity in the SAME representation the
+   *  `activeSet` uses (service id at service scope; `compoundKey(serviceId,
+   *  name)` at instance/endpoint scope). Supplied so the fan-out can dedupe
+   *  the primary out of the comparison set instead of fetching it twice —
+   *  the dashboard query itself still goes out by service NAME (`service`
+   *  arg above), which the BFF resolves to the same entity. When absent the
+   *  composable falls back to reconstructing the key from `service` +
+   *  `entityRefs` (name-based — only correct when the caller keys locks by
+   *  name too). */
+  primaryKey?: Ref<string | null>,
 ) {
   // Auto-refresh is metrics-only. Trace / log / profiling pages are
   // explore-style (operator-driven queries, log tails, etc.) and would
@@ -219,6 +247,150 @@ export function useLayerDashboard(
     refetchOnWindowFocus: computed(() => METRIC_SCOPES.has(scope?.value ?? 'service')),
     retry: 1,
   });
+
+  // --- Multi-entity fan-out (Option B) -------------------------------
+  // `q` above always serves the PRIMARY entity (and is the whole story
+  // when nothing is locked). When >=2 entities are in the comparison set
+  // we additionally fan out one single-entity query per LOCKED-but-non-
+  // primary entity (concurrency-bounded), then merge primary + others
+  // into `resultByEntity`. N<=1 leaves all of this inert.
+  const widgetsJson = computed(() => (widgetsList?.value ? JSON.stringify(widgetsList.value) : null));
+  // Instance/endpoint entity keys are CROSS-SERVICE compounds, so the
+  // primary's key carries the current service too (matches the locked
+  // compound keys when the current selection is itself pinned).
+  const primaryEntity = computed<string | null>(() => {
+    // Prefer the caller-supplied canonical key — it matches the locked-set
+    // representation (service id), so the primary dedupes out of the fan-out
+    // instead of being fetched twice. The reconstruction below is the
+    // name-based fallback for callers that key locks by name.
+    if (primaryKey) return primaryKey.value;
+    const s = scope?.value ?? 'service';
+    if (s === 'instance') {
+      const n = entityRefs.instance?.value;
+      return n ? compoundKey(service.value ?? '', n) : null;
+    }
+    if (s === 'endpoint') {
+      const n = entityRefs.endpoint?.value;
+      return n ? compoundKey(service.value ?? '', n) : null;
+    }
+    return service.value;
+  });
+  // The comparison set = the BANNER (primary) entity ALWAYS first, then each
+  // pinned entity (de-duped, empties dropped). The banner is what the top
+  // selector points at — it drives the header KPIs and renders as the
+  // "current" member (reserved accent hue); the pins add to it. So viewing
+  // one entity + a single pin already compares two. No hue collision: the
+  // primary owns the accent, leaving the 6-hue palette for up to six pins.
+  // The banner is served by `q`; the pins by the fan-out.
+  const compareOrder = computed<string[]>(() => {
+    const p = primaryEntity.value;
+    const pins = (activeSet?.value ?? []).filter((e) => e && e !== p);
+    return p ? [p, ...pins] : pins;
+  });
+  const compareActive = computed<boolean>(() => compareOrder.value.length >= 2);
+  // Fan out the non-primary locked entities; the primary (when it is in
+  // the locked set) is served by `q`, the rest by `useQueries`.
+  const fanoutList = computed<string[]>(() =>
+    fanoutEntities(primaryEntity.value, activeSet?.value ?? []),
+  );
+
+  const limit = createLimiter(ENTITY_FANOUT_CONCURRENCY);
+  const entityQueries = useQueries({
+    queries: () => {
+      // Only fan out once there's an actual comparison (>=2 in the set);
+      // a single lock shows in the cohort bar but doesn't fetch.
+      if (!compareActive.value) return [];
+      if (configReady && !configReady.value) return [];
+      const raw = scope?.value ?? 'service';
+      if (raw !== 'service' && raw !== 'instance' && raw !== 'endpoint') return [];
+      const s: FanoutScope = raw;
+      const opts = mockTop?.value ? { mockTop: mockTop.value } : {};
+      return fanoutList.value.map((entity) => {
+        // Decode the cross-service compound key (instance/endpoint) into
+        // its own service + name; at service scope the entity IS the svc.
+        const { service: svc, name } =
+          s === 'service' ? { service: entity, name: '' } : splitCompound(entity);
+        return {
+          queryKey: entityDashboardKey(
+            layerKey.value,
+            s,
+            svc,
+            name,
+            mockTop?.value ?? 0,
+            rangeKey.value,
+            widgetsJson.value,
+          ),
+          queryFn: (): Promise<DashboardResponse> =>
+            limit(() =>
+              bffClient.layer.dashboard(
+                layerKey.value,
+                entityDashboardBody(s, svc, name, rangeRef.value, widgetsList?.value ?? null),
+                opts,
+              ),
+            ),
+          staleTime: 25_000,
+          refetchInterval: refetchIntervalRef,
+          refetchOnWindowFocus: false,
+          retry: 1,
+        };
+      });
+    },
+  });
+
+  function indexById(widgets: DashboardWidgetResult[]): Map<string, DashboardWidgetResult> {
+    const m = new Map<string, DashboardWidgetResult>();
+    for (const w of widgets) m.set(w.id, w);
+    return m;
+  }
+
+  /** Map<entityName, Map<widgetId, result>> — primary from `q`, the
+   *  other locked entities from the fan-out. Assembles progressively as
+   *  each independent query lands. */
+  const resultByEntity = computed<Map<string, Map<string, DashboardWidgetResult>>>(() => {
+    const out = new Map<string, Map<string, DashboardWidgetResult>>();
+    const p = primaryEntity.value;
+    const pData = q.data.value;
+    if (p && pData?.widgets) out.set(p, indexById(pData.widgets));
+    const results = entityQueries.value;
+    fanoutList.value.forEach((entity, i) => {
+      const data = results[i]?.data as DashboardResponse | undefined;
+      if (data?.widgets) out.set(entity, indexById(data.widgets));
+    });
+    return out;
+  });
+
+  /** Entities resolved (success OR error) / total — progressive hint.
+   *  Counts over the compare set: the primary (if in it) via `q`, the
+   *  rest via their fan-out query. */
+  const entityProgress = computed<{ arrived: number; total: number }>(() => {
+    const order = compareOrder.value;
+    const fan = fanoutList.value;
+    let arrived = 0;
+    for (const e of order) {
+      if (e === primaryEntity.value) {
+        if (q.data.value || q.isError.value) arrived += 1;
+      } else {
+        const i = fan.indexOf(e);
+        const r = i >= 0 ? entityQueries.value[i] : undefined;
+        if (r && (r.data !== undefined || r.isError)) arrived += 1;
+      }
+    }
+    return { arrived, total: order.length };
+  });
+
+  /** Loading state for one entity's row in the compare grid. */
+  function entityState(entity: string): 'loading' | 'ready' | 'error' {
+    if (entity === primaryEntity.value) {
+      if (q.isError.value) return 'error';
+      return q.data.value ? 'ready' : 'loading';
+    }
+    const i = fanoutList.value.indexOf(entity);
+    const r = i >= 0 ? entityQueries.value[i] : undefined;
+    if (!r) return 'loading';
+    if (r.isError) return 'error';
+    return r.data !== undefined ? 'ready' : 'loading';
+  }
+
   return {
     data: computed(() => q.data.value ?? null),
     isLoading: q.isLoading,
@@ -230,5 +402,17 @@ export function useLayerDashboard(
      *  chunk's BFF call resolves. `total` is 0 when the legacy
      *  no-widgetsList path is in use. */
     progress,
+    // --- Multi-entity compare (Option B). Inert (single-entity) until a
+    // cohort is locked; consumed by the compare grid in a later phase.
+    /** Comparison-set entity order, primary first. */
+    compareEntities: compareOrder,
+    /** True once >=2 entities are in the comparison set. */
+    compareActive,
+    /** Map<entityName, Map<widgetId, result>>, assembled progressively. */
+    resultByEntity,
+    /** Entities resolved / total, for the progressive loading hint. */
+    entityProgress,
+    /** 'loading' | 'ready' | 'error' for a single entity row. */
+    entityState,
   };
 }
diff --git a/apps/ui/src/render/widgets/TableWidget.vue b/apps/ui/src/render/widgets/TableWidget.vue
index 2a73c3b..c9e41d9 100644
--- a/apps/ui/src/render/widgets/TableWidget.vue
+++ b/apps/ui/src/render/widgets/TableWidget.vue
@@ -20,27 +20,49 @@
   (status / phase / condition / entity dimension). The value column is
   optional (showValues=false renders a presence list, e.g. node
   conditions whose value is always 1).
+
+  Multi-entity compare: when `entities` (>=2) is supplied, rows carry an
+  `entityKey` (tagged client-side) and a prepended Entity column groups
+  them; per entity the first `labelTopN` rows show and the rest fold into
+  one `(others)` row — summed for count-like formats, count-only for
+  non-additive ones (a sum of percentiles / latencies would be wrong).
 -->
 <script setup lang="ts">
 import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
 import type { DashboardTableRow } from '@skywalking-horizon-ui/api-client';
 import { fmtMetricAs } from '@/utils/formatters';
 
+const { t } = useI18n({ useScope: 'global' });
+
+interface CompareEntity {
+  key: string;
+  name: string;
+  hue: string;
+}
+type Row = DashboardTableRow & { entityKey?: string };
+
 const props = withDefaults(
   defineProps<{
-    rows: DashboardTableRow[];
+    rows: Row[];
     /** Optional value-column header; the label columns are headed by
      *  their dimension key. `[, valueHeader]` — first entry unused. */
     headers?: [string, string];
     showValues?: boolean;
     unit?: string;
     format?: 'int' | 'decimal' | 'compact' | 'duration' | 'enum';
+    /** Multi-entity compare cohort. When >=2, prepend an Entity column
+     *  and group rows by entity (in this order). */
+    entities?: ReadonlyArray<CompareEntity>;
+    /** Per-entity label-row cap before folding into `(others)`. */
+    labelTopN?: number;
   }>(),
-  { showValues: true },
+  { showValues: true, labelTopN: 8 },
 );
 
-/** Ordered union of label dimension keys across all rows — one table
- *  column per dimension (e.g. `condition`, `node`). */
+const entityList = computed<ReadonlyArray<CompareEntity>>(() => props.entities ?? []);
+const multiEntity = computed(() => entityList.value.length >= 2);
+
 const labelKeys = computed<string[]>(() => {
   const seen = new Set<string>();
   const keys: string[] = [];
@@ -54,11 +76,57 @@ const labelKeys = computed<string[]>(() => {
   }
   return keys;
 });
+
+interface DisplayRow {
+  entityKey?: string;
+  bandStart: boolean;
+  labels: Array<{ key: string; value: string }>;
+  value: number | null;
+  others: number; // 0 = a normal row; >0 = an "(others)" fold of N rows
+}
+
+const displayRows = computed<DisplayRow[]>(() => {
+  if (!multiEntity.value) {
+    return props.rows.map((r) => ({
+      entityKey: undefined,
+      bandStart: false,
+      labels: r.labels,
+      value: r.value,
+      others: 0,
+    }));
+  }
+  // A sum is only meaningful for count-like metrics; everything else
+  // (latency / percentile / sla / enum) folds to a count, value null.
+  const additive = props.format === 'int' || props.format === 'compact';
+  const out: DisplayRow[] = [];
+  for (const ent of entityList.value) {
+    const rows = props.rows.filter((r) => r.entityKey === ent.key);
+    const head = rows.slice(0, props.labelTopN);
+    head.forEach((r, i) =>
+      out.push({ entityKey: ent.key, bandStart: i === 0, labels: r.labels, value: r.value, others: 0 }),
+    );
+    const tail = rows.slice(props.labelTopN);
+    if (tail.length > 0) {
+      out.push({
+        entityKey: ent.key,
+        bandStart: head.length === 0,
+        labels: [],
+        value: additive ? tail.reduce((a, r) => a + (r.value ?? 0), 0) : null,
+        others: tail.length,
+      });
+    }
+  }
+  return out;
+});
+
+function entityFor(key?: string): CompareEntity | undefined {
+  return entityList.value.find((e) => e.key === key);
+}
 function titleCase(k: string): string {
   return k.replace(/[_.]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
 }
 const valueHeader = computed(() => props.headers?.[1] || 'Value');
-function cell(r: DashboardTableRow, key: string): string {
+function cell(r: DisplayRow, key: string): string {
   return r.labels.find((l) => l.key === key)?.value ?? '—';
 }
 function fmt(v: number | null): string {
@@ -73,13 +141,26 @@ function fmt(v: number | null): string {
     <table class="tw__table">
       <thead>
         <tr>
+          <th v-if="multiEntity" class="tw__entity-col">{{ t('Entity') }}</th>
           <th v-for="k in labelKeys" :key="k">{{ titleCase(k) }}</th>
           <th v-if="showValues" class="tw__num">{{ valueHeader }}</th>
         </tr>
       </thead>
       <tbody>
-        <tr v-for="(r, i) in rows" :key="i">
-          <td v-for="k in labelKeys" :key="k" class="tw__cell mono" :title="cell(r, k)">{{ cell(r, k) }}</td>
+        <tr v-for="(r, i) in displayRows" :key="i" :class="{ 'tw__band-start': multiEntity && r.bandStart }">
+          <td v-if="multiEntity" class="tw__entity-col mono">
+            <template v-if="r.bandStart">
+              <span class="tw__dot" :style="{ background: entityFor(r.entityKey)?.hue }" />
+              {{ entityFor(r.entityKey)?.name ?? r.entityKey }}
+            </template>
+          </td>
+          <template v-if="r.others > 0">
+            <td class="tw__cell mono others">(others) · +{{ r.others }}</td>
+            <td v-for="k in labelKeys.slice(1)" :key="k" class="tw__cell mono">—</td>
+          </template>
+          <template v-else>
+            <td v-for="k in labelKeys" :key="k" class="tw__cell mono" :title="cell(r, k)">{{ cell(r, k) }}</td>
+          </template>
           <td v-if="showValues" class="tw__num mono">{{ fmt(r.value) }}</td>
         </tr>
       </tbody>
@@ -105,6 +186,7 @@ function fmt(v: number | null): string {
   border-bottom: 1px solid var(--sw-line);
   white-space: nowrap;
 }
+.tw__table th.tw__num { text-align: right; }
 .tw__table td {
   padding: 3px 8px;
   border-bottom: 1px solid var(--sw-line-2, var(--sw-line));
@@ -118,4 +200,20 @@ function fmt(v: number | null): string {
 }
 .tw__num { text-align: right; white-space: nowrap; color: var(--sw-fg-0); width: 1%; }
 .tw__table tbody tr:hover td { background: var(--sw-bg-2); }
+.tw__entity-col {
+  white-space: nowrap;
+  color: var(--sw-fg-0);
+  font-weight: 600;
+}
+.tw__band-start td { border-top: 1px solid var(--sw-line); }
+.tw__dot {
+  display: inline-block;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  margin-right: 6px;
+  vertical-align: middle;
+}
+.others { color: var(--sw-fg-3); font-style: italic; }
+.mono { font-family: var(--sw-mono); }
 </style>
diff --git a/apps/ui/src/state/layerSelection.test.ts b/apps/ui/src/state/layerSelection.test.ts
new file mode 100644
index 0000000..76c7f04
--- /dev/null
+++ b/apps/ui/src/state/layerSelection.test.ts
@@ -0,0 +1,126 @@
+/*
+ * 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.
+ */
+
+import { beforeEach, describe, expect, it } from 'vitest';
+import { createPinia, setActivePinia } from 'pinia';
+import { useLayerSelectionStore, MAX_LOCKED, compoundKey, splitCompound } from './layerSelection';
+
+describe('layerSelection — multi-entity lock', () => {
+  beforeEach(() => {
+    setActivePinia(createPinia());
+  });
+
+  it('compoundKey / splitCompound round-trip', () => {
+    const k = compoundKey('svcA', 'inst-1');
+    expect(splitCompound(k)).toEqual({ service: 'svcA', name: 'inst-1' });
+    // a plain (non-compound) key decodes to name-only
+    expect(splitCompound('plain')).toEqual({ service: '', name: 'plain' });
+  });
+
+  it('toggleLock adds then removes a service', () => {
+    const s = useLayerSelectionStore();
+    s.toggleLock('service', 'a');
+    expect(s.activeCompareSet('service')).toEqual(['a']);
+    s.toggleLock('service', 'a');
+    expect(s.activeCompareSet('service')).toEqual([]);
+  });
+
+  it('caps each set at MAX_LOCKED — the 7th lock is a no-op', () => {
+    const s = useLayerSelectionStore();
+    for (let i = 1; i <= MAX_LOCKED + 1; i += 1) s.toggleLock('service', `svc${i}`);
+    expect(s.activeCompareSet('service')).toHaveLength(MAX_LOCKED);
+    expect(s.activeCompareSet('service')).not.toContain(`svc${MAX_LOCKED + 1}`);
+  });
+
+  it('service / instance / endpoint sets are independent', () => {
+    const s = useLayerSelectionStore();
+    s.setService('svc1');
+    s.toggleLock('service', 'svcA');
+    s.toggleLock('instance', 'i1');
+    s.toggleLock('endpoint', 'e1');
+    expect(s.activeCompareSet('service')).toEqual(['svcA']);
+    expect(s.activeCompareSet('instance')).toEqual([compoundKey('svc1', 'i1')]);
+    expect(s.activeCompareSet('endpoint')).toEqual([compoundKey('svc1', 'e1')]);
+  });
+
+  it('instance pins are CROSS-SERVICE — different services kept together, never dropped on switch', () => {
+    const s = useLayerSelectionStore();
+    s.setService('svc1');
+    s.toggleLock('instance', 'i1');
+    s.setService('svc2'); // switch primary — must NOT drop svc1's pin
+    s.toggleLock('instance', 'i2');
+    expect(s.activeCompareSet('instance')).toEqual([
+      compoundKey('svc1', 'i1'),
+      compoundKey('svc2', 'i2'),
+    ]);
+    s.setService('svc1'); // switch back — still both
+    expect(s.activeCompareSet('instance')).toEqual([
+      compoundKey('svc1', 'i1'),
+      compoundKey('svc2', 'i2'),
+    ]);
+  });
+
+  it('isLocked is compound-aware — a pin shows locked only under its OWN service', () => {
+    const s = useLayerSelectionStore();
+    s.setService('svc1');
+    s.toggleLock('instance', 'i1');
+    expect(s.isLocked('instance', 'i1')).toBe(true);
+    s.setService('svc2');
+    // i1 belongs to svc1 — not "locked" relative to svc2's row list
+    expect(s.isLocked('instance', 'i1')).toBe(false);
+  });
+
+  it('removeKey removes an exact compound key regardless of current service', () => {
+    const s = useLayerSelectionStore();
+    s.setService('svc1');
+    s.toggleLock('instance', 'i1');
+    s.setService('svc2');
+    s.toggleLock('instance', 'i2');
+    // unlock svc1's pin while the primary is svc2 (cohort chip ×)
+    s.removeKey('instance', compoundKey('svc1', 'i1'));
+    expect(s.activeCompareSet('instance')).toEqual([compoundKey('svc2', 'i2')]);
+  });
+
+  it('activeCompareSet is empty for instance/endpoint when no primary', () => {
+    const s = useLayerSelectionStore();
+    expect(s.activeCompareSet('service')).toEqual([]);
+    expect(s.activeCompareSet('instance')).toEqual([]);
+    expect(s.activeCompareSet('endpoint')).toEqual([]);
+  });
+
+  it('clear() wipes every lock list', () => {
+    const s = useLayerSelectionStore();
+    s.setService('svc1');
+    s.toggleLock('service', 'svcA');
+    s.toggleLock('instance', 'i1');
+    s.toggleLock('endpoint', 'e1');
+    s.clear();
+    expect(s.lockedServices).toEqual([]);
+    expect(s.lockedInstances).toEqual([]);
+    expect(s.lockedEndpoints).toEqual([]);
+  });
+
+  it('keepNarrower preserves the instance pick across a service switch', () => {
+    const s = useLayerSelectionStore();
+    s.setService('svc1');
+    s.setInstance('i1');
+    s.setService('svc2', { keepNarrower: true });
+    expect(s.instance).toBe('i1');
+    s.setService('svc3');
+    expect(s.instance).toBe(null);
+  });
+});
diff --git a/apps/ui/src/state/layerSelection.ts b/apps/ui/src/state/layerSelection.ts
index 0422cbf..e58b05d 100644
--- a/apps/ui/src/state/layerSelection.ts
+++ b/apps/ui/src/state/layerSelection.ts
@@ -47,6 +47,26 @@ function pickQueryString(v: unknown): string | null {
   return v;
 }
 
+/** The three lockable entity scopes for multi-entity cross-check. */
+export type CompareScope = 'service' | 'instance' | 'endpoint';
+
+/** Hard cap on the active comparison set — matches the six-hue
+ *  `ENTITY_PALETTE` so every locked entity gets a distinct color. */
+export const MAX_LOCKED = 6;
+
+/** Instance/endpoint locks are CROSS-SERVICE: a pin carries its service
+ *  so pins from different services compare together. Each is stored as a
+ *  compound `service<SEP>name` key; `compoundKey`/`splitCompound` encode
+ *  and decode it. (Service-scope locks are plain service ids.) */
+const CK_SEP = '\u0001';
+export function compoundKey(service: string, name: string): string {
+  return `${service}${CK_SEP}${name}`;
+}
+export function splitCompound(key: string): { service: string; name: string } {
+  const i = key.indexOf(CK_SEP);
+  return i < 0 ? { service: '', name: key } : { service: key.slice(0, i), name: key.slice(i + 1) };
+}
+
 export const useLayerSelectionStore = defineStore('layer-selection', () => {
   const service = ref<string | null>(null);
   const instance = ref<string | null>(null);
@@ -56,6 +76,15 @@ export const useLayerSelectionStore = defineStore('layer-selection', () => {
    *  don't (the operator's pick stays sticky across tabs). */
   const ownerKey = ref<string | null>(null);
 
+  // --- Multi-entity lock (cross-check) state ---
+  // `lockedServices` = service-scope set (service ids). `lockedInstances`
+  // / `lockedEndpoints` = CROSS-SERVICE sets of compound `service<SEP>
+  // name` keys, so pins from different services stay locked together and
+  // compare side by side. Every read goes through `activeCompareSet`.
+  const lockedServices = ref<string[]>([]);
+  const lockedInstances = ref<string[]>([]);
+  const lockedEndpoints = ref<string[]>([]);
+
   /**
    * Bind the store to a layer + seed from the URL query. On layer
    * change, drops the previous pick and reads any
@@ -86,6 +115,9 @@ export const useLayerSelectionStore = defineStore('layer-selection', () => {
     service.value = null;
     instance.value = null;
     endpoint.value = null;
+    lockedServices.value = [];
+    lockedInstances.value = [];
+    lockedEndpoints.value = [];
   }
 
   /**
@@ -113,6 +145,61 @@ export const useLayerSelectionStore = defineStore('layer-selection', () => {
     endpoint.value = name;
   }
 
+  // The compound-key list backing a non-service scope.
+  function listRefFor(scope: CompareScope) {
+    if (scope === 'service') return lockedServices;
+    return scope === 'instance' ? lockedInstances : lockedEndpoints;
+  }
+  function addOrRemove(scope: CompareScope, key: string): void {
+    const r = listRefFor(scope);
+    if (r.value.includes(key)) r.value = r.value.filter((k) => k !== key);
+    else if (r.value.length < MAX_LOCKED) r.value = [...r.value, key];
+  }
+
+  /** Toggle a lock on `name` at `scope`, from the picker / row list.
+   *  Service scope toggles the service id; instance/endpoint toggle the
+   *  compound (CURRENT primary service, name) so a pin remembers which
+   *  service it belongs to and pins of DIFFERENT services coexist.
+   *  Cap-guarded at MAX_LOCKED; instance/endpoint no-op without a
+   *  primary service. */
+  function toggleLock(scope: CompareScope, name: string): void {
+    if (!name) return;
+    if (scope === 'service') {
+      addOrRemove('service', name);
+      return;
+    }
+    const primary = service.value;
+    if (!primary) return;
+    addOrRemove(scope, compoundKey(primary, name));
+  }
+
+  /** Remove an EXACT locked key — used by the cohort chip's ×, where the
+   *  key may belong to a service other than the current primary. */
+  function removeKey(scope: CompareScope, key: string): void {
+    const r = listRefFor(scope);
+    r.value = r.value.filter((k) => k !== key);
+  }
+
+  /** Is `name` locked at `scope` under the CURRENT primary service (for
+   *  instance/endpoint)? Drives the picker / row pin state. */
+  function isLocked(scope: CompareScope, name: string): boolean {
+    if (scope === 'service') return lockedServices.value.includes(name);
+    const primary = service.value;
+    if (!primary) return false;
+    return listRefFor(scope).value.includes(compoundKey(primary, name));
+  }
+
+  function clearLocks(scope: CompareScope): void {
+    listRefFor(scope).value = [];
+  }
+
+  /** THE read seam — the locked set for a scope. Service ids for service
+   *  scope; cross-service compound `service<SEP>name` keys for
+   *  instance/endpoint (decode with `splitCompound`). */
+  function activeCompareSet(scope: CompareScope): string[] {
+    return listRefFor(scope).value;
+  }
+
   return {
     service,
     instance,
@@ -123,5 +210,16 @@ export const useLayerSelectionStore = defineStore('layer-selection', () => {
     setService,
     setInstance,
     setEndpoint,
+    // Multi-entity lock. App code reads through activeCompareSet(scope)
+    // + isLocked()/toggleLock()/removeKey(); the raw lists are exposed
+    // for devtools + unit tests only.
+    lockedServices,
+    lockedInstances,
+    lockedEndpoints,
+    toggleLock,
+    removeKey,
+    isLocked,
+    clearLocks,
+    activeCompareSet,
   };
 });
diff --git a/apps/ui/src/layer/useSelectedService.ts b/apps/ui/src/utils/featureFlags.test.ts
similarity index 51%
copy from apps/ui/src/layer/useSelectedService.ts
copy to apps/ui/src/utils/featureFlags.test.ts
index 2957551..7364083 100644
--- a/apps/ui/src/layer/useSelectedService.ts
+++ b/apps/ui/src/utils/featureFlags.test.ts
@@ -15,25 +15,23 @@
  * limitations under the License.
  */
 
-/**
- * Selected service for the per-layer page. Backed by the
- * `layerSelection` Pinia store, which is seeded from the URL on
- * landing and updated in-memory afterwards. The URL is a
- * shareable starting point, not a live state mirror — see
- * `state/layerSelection.ts` for the contract.
- */
-
-import { computed } from 'vue';
-import { useLayerSelectionStore } from '@/state/layerSelection';
-
-export function useSelectedService() {
-  const store = useLayerSelectionStore();
+import { describe, expect, it } from 'vitest';
+import { __evalEntityCompareFlag } from './featureFlags';
 
-  const selectedId = computed<string | null>(() => store.service);
+describe('__evalEntityCompareFlag', () => {
+  it('is on when the env var is "1" or "true"', () => {
+    expect(__evalEntityCompareFlag('1', null)).toBe(true);
+    expect(__evalEntityCompareFlag('true', null)).toBe(true);
+  });
 
-  function setSelected(id: string | null, opts: { keepNarrower?: boolean } = {}): void {
-    store.setService(id, opts);
-  }
+  it('is on when localStorage opts in', () => {
+    expect(__evalEntityCompareFlag(undefined, '1')).toBe(true);
+    expect(__evalEntityCompareFlag(undefined, 'true')).toBe(true);
+  });
 
-  return { selectedId, setSelected };
-}
+  it('is off by default and for non-truthy values', () => {
+    expect(__evalEntityCompareFlag(undefined, null)).toBe(false);
+    expect(__evalEntityCompareFlag('0', null)).toBe(false);
+    expect(__evalEntityCompareFlag('', 'false')).toBe(false);
+  });
+});
diff --git a/apps/ui/src/utils/featureFlags.ts b/apps/ui/src/utils/featureFlags.ts
new file mode 100644
index 0000000..a152483
--- /dev/null
+++ b/apps/ui/src/utils/featureFlags.ts
@@ -0,0 +1,53 @@
+/*
+ * 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.
+ */
+
+/**
+ * Build-/runtime feature flags. Evaluated ONCE at module load and frozen
+ * as `const` booleans so unused branches tree-shake out of the prod
+ * bundle when off.
+ *
+ * `FF_ENTITY_COMPARE` gates the multi-entity lock + cross-check preview.
+ * Enable per build with `VITE_FF_ENTITY_COMPARE=1`, or per browser for
+ * dogfooding without a rebuild via
+ * `localStorage['horizon:ff:entity-compare'] = '1'` (then reload).
+ */
+
+const FLAG_KEY = 'horizon:ff:entity-compare';
+
+/** Pure evaluator — exported for unit tests; not used directly by app
+ *  code (which reads the frozen `FF_ENTITY_COMPARE` below). */
+export function __evalEntityCompareFlag(
+  envValue: string | undefined,
+  lsValue: string | null,
+): boolean {
+  const truthy = (v: string | null | undefined): boolean => v === '1' || v === 'true';
+  return truthy(envValue) || truthy(lsValue);
+}
+
+function readLocalStorage(key: string): string | null {
+  try {
+    return typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null;
+  } catch {
+    // Private-mode / disabled storage — treat as unset.
+    return null;
+  }
+}
+
+export const FF_ENTITY_COMPARE: boolean = __evalEntityCompareFlag(
+  import.meta.env.VITE_FF_ENTITY_COMPARE as string | undefined,
+  readLocalStorage(FLAG_KEY),
+);
diff --git a/apps/ui/src/utils/metricColor.ts b/apps/ui/src/utils/metricColor.ts
index ebd4aa5..0c6a531 100644
--- a/apps/ui/src/utils/metricColor.ts
+++ b/apps/ui/src/utils/metricColor.ts
@@ -15,6 +15,24 @@
  * limitations under the License.
  */
 
+/**
+ * Stable per-entity color palette for multi-entity compare (lock N
+ * service / instance / endpoint entities and cross-check). Six visually
+ * distinct hues — one per locked entity, assigned by id-keyed slot so a
+ * given entity keeps its color across the chip bar, the compare grid,
+ * and the overlay drill-out. Byte-identical to TimeChart's historic
+ * multi-series `SECONDARY` palette, which now consumes this single
+ * source; six hues is also what caps the lockable set at six.
+ */
+export const ENTITY_PALETTE = [
+  '#60a5fa', // info-ish (blue)
+  '#a78bfa', // purple
+  '#22d3ee', // cyan
+  '#f472b6', // pink
+  '#34d399', // ok-ish (green)
+  '#fbbf24', // amber
+] as const;
+
 /**
  * Per-metric color mapping for KPI tiles. Keeps the per-layer header
  * + selector pinned-bar visually consistent with the design's
diff --git a/apps/ui/src/utils/serviceName.ts b/apps/ui/src/utils/serviceName.ts
index 3b195e7..b11705b 100644
--- a/apps/ui/src/utils/serviceName.ts
+++ b/apps/ui/src/utils/serviceName.ts
@@ -71,6 +71,21 @@ export function serviceGroupName(raw: string | null | undefined): string | null
   return parseServiceName(raw).group;
 }
 
+/**
+ * OAP buckets metrics that carry no service binding under a synthetic
+ * service whose name comes back empty (its id base64-decodes to `_blank`).
+ * Surface it in pickers so operators can see it exists, but NEVER
+ * auto-select or query it: an empty service name no-ops every per-service
+ * MQE / instance / endpoint query, and the dashboard gate `Boolean(service)`
+ * then never fires — the page would sit on "Reading data…" forever.
+ * `BLANK_SERVICE_NAME` is the verbatim label we show in its place.
+ */
+export const BLANK_SERVICE_NAME = '_blank';
+export function isBlankServiceName(name: string | null | undefined): boolean {
+  const n = (name ?? '').trim();
+  return n === '' || n === BLANK_SERVICE_NAME;
+}
+
 /**
  * Layer-aware identity for a service name.
  *
diff --git a/apps/ui/src/utils/useEntityPalette.test.ts b/apps/ui/src/utils/useEntityPalette.test.ts
new file mode 100644
index 0000000..933fe98
--- /dev/null
+++ b/apps/ui/src/utils/useEntityPalette.test.ts
@@ -0,0 +1,82 @@
+/*
+ * 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.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { ENTITY_PALETTE } from './metricColor';
+import { useEntityPalette } from './useEntityPalette';
+import { MAX_LOCKED } from '../state/layerSelection';
+
+describe('useEntityPalette — stable id-keyed slots', () => {
+  it('assigns slots 0..n for a fresh set', () => {
+    const p = useEntityPalette();
+    p.syncToIds(['a', 'b', 'c']);
+    expect(p.byName.value.get('a')?.slot).toBe(0);
+    expect(p.byName.value.get('b')?.slot).toBe(1);
+    expect(p.byName.value.get('c')?.slot).toBe(2);
+    expect(p.hueFor('a')).toBe(ENTITY_PALETTE[0]);
+    expect(p.hueFor('c')).toBe(ENTITY_PALETTE[2]);
+  });
+
+  it('keeps survivors on their slot when the middle entity is removed', () => {
+    const p = useEntityPalette();
+    p.syncToIds(['a', 'b', 'c']);
+    p.syncToIds(['a', 'c']);
+    expect(p.byName.value.get('a')?.slot).toBe(0);
+    expect(p.byName.value.get('c')?.slot).toBe(2);
+    expect(p.byName.value.has('b')).toBe(false);
+  });
+
+  it('reuses the lowest freed slot for a new entity', () => {
+    const p = useEntityPalette();
+    p.syncToIds(['a', 'b', 'c']);
+    p.syncToIds(['a', 'c']); // frees slot 1
+    p.syncToIds(['a', 'c', 'd']);
+    expect(p.byName.value.get('d')?.slot).toBe(1);
+  });
+
+  it('is idempotent for an unchanged set', () => {
+    const p = useEntityPalette();
+    p.syncToIds(['a', 'b', 'c']);
+    const before = p.byName.value.get('b')?.slot;
+    p.syncToIds(['a', 'b', 'c']);
+    expect(p.byName.value.get('b')?.slot).toBe(before);
+  });
+
+  it('list follows lock order, not slot order', () => {
+    const p = useEntityPalette();
+    p.syncToIds(['a', 'b', 'c']);
+    p.syncToIds(['c', 'a']); // c keeps slot 2, a keeps slot 0
+    expect(p.list.value.map((h) => h.name)).toEqual(['c', 'a']);
+    expect(p.list.value.map((h) => h.slot)).toEqual([2, 0]);
+  });
+
+  it('palette length equals the lock cap', () => {
+    expect(ENTITY_PALETTE.length).toBe(6);
+    expect(ENTITY_PALETTE.length).toBe(MAX_LOCKED);
+  });
+
+  it('palette is the historic SECONDARY hues (byte-identical)', () => {
+    expect([...ENTITY_PALETTE]).toEqual([
+      '#60a5fa',
+      '#a78bfa',
+      '#22d3ee',
+      '#f472b6',
+      '#34d399',
+      '#fbbf24',
+    ]);
+  });
+});
diff --git a/apps/ui/src/utils/useEntityPalette.ts b/apps/ui/src/utils/useEntityPalette.ts
new file mode 100644
index 0000000..a23e3e8
--- /dev/null
+++ b/apps/ui/src/utils/useEntityPalette.ts
@@ -0,0 +1,93 @@
+/*
+ * 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.
+ */
+
+/**
+ * Stable, id-keyed hue assignment for a locked-entity set. Each entity
+ * holds its palette slot across set mutations: removing one entity never
+ * re-hues another, and a new entity takes the LOWEST freed slot — so the
+ * same entity keeps one color across the chip bar, the compare grid, and
+ * the overlay drill-out (decode the color map once).
+ *
+ * Slot assignment lives in the plain `syncToIds(ids)` function (call it
+ * from a `watch` on the active set), NOT a side-effecting computed —
+ * a getter that mutated its own backing Map would double-fire under
+ * dev StrictMode-style double-eval.
+ */
+
+import { computed, ref } from 'vue';
+import { ENTITY_PALETTE } from './metricColor';
+
+export interface EntityHue {
+  name: string;
+  hue: string;
+  slot: number;
+}
+
+export function useEntityPalette() {
+  // name -> palette slot, stable across mutations.
+  const slotByName = ref<Map<string, number>>(new Map());
+  // last-synced id order — drives `list` (lock order, not slot order).
+  const orderedIds = ref<string[]>([]);
+
+  /** Reconcile slots to `ids`: survivors keep their slot, departed
+   *  entities free theirs, and each new entity takes the lowest free
+   *  slot. Idempotent for an unchanged set. Call from a watcher. */
+  function syncToIds(ids: readonly string[]): void {
+    const current = slotByName.value;
+    const next = new Map<string, number>();
+    const used = new Set<number>();
+    // 1. survivors keep their existing slot.
+    for (const id of ids) {
+      const s = current.get(id);
+      if (s !== undefined && !used.has(s)) {
+        next.set(id, s);
+        used.add(s);
+      }
+    }
+    // 2. new entities take the lowest free slot.
+    for (const id of ids) {
+      if (next.has(id)) continue;
+      let s = 0;
+      while (used.has(s)) s += 1;
+      used.add(s);
+      next.set(id, s);
+    }
+    slotByName.value = next;
+    orderedIds.value = [...ids];
+  }
+
+  const byName = computed<Map<string, EntityHue>>(() => {
+    const out = new Map<string, EntityHue>();
+    for (const [name, slot] of slotByName.value) {
+      out.set(name, { name, hue: ENTITY_PALETTE[slot % ENTITY_PALETTE.length], slot });
+    }
+    return out;
+  });
+
+  /** Hues in lock order (the order entities appear in the active set). */
+  const list = computed<EntityHue[]>(() =>
+    orderedIds.value
+      .map((n) => byName.value.get(n))
+      .filter((h): h is EntityHue => Boolean(h)),
+  );
+
+  function hueFor(name: string): string {
+    return byName.value.get(name)?.hue ?? ENTITY_PALETTE[0];
+  }
+
+  return { byName, list, hueFor, syncToIds };
+}
diff --git a/packages/api-client/src/dashboard.ts b/packages/api-client/src/dashboard.ts
index 332243b..24864e6 100644
--- a/packages/api-client/src/dashboard.ts
+++ b/packages/api-client/src/dashboard.ts
@@ -201,6 +201,10 @@ export interface DashboardWidget {
    * layer"). MQE entity flips to `{ scope: All }`.
    */
   layerScope?: boolean;
+  /** Cap on label rows kept per entity in a labeled table widget under
+   *  multi-entity compare; the remainder fold into one `(others)` row.
+   *  Defaults to 8. */
+  labelTopN?: number;
   /** Legacy 24-col grid coordinates — kept for back-compat during the
    *  span-based flow-layout migration. New widgets should leave these
    *  unset and use `span` / `rowSpan` instead. */