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/07 02:38:10 UTC

(skywalking-horizon-ui) branch feat/instance-topology-preview created (now 4397390)

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

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


      at 4397390  feat(layer): list every service in the landing picker; landing + i18n hardening

This branch includes the following new commits:

     new 8d51e2d  feat(layer): instance topology, remote-first templates, preview everywhere
     new 4397390  feat(layer): list every service in the landing picker; landing + i18n hardening

The 2 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) 02/02: feat(layer): list every service in the landing picker; landing + i18n hardening

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/instance-topology-preview
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 4397390ab1e0229aafd6dfa4d8736ed8b9494611
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun Jun 7 10:37:35 2026 +0800

    feat(layer): list every service in the landing picker; landing + i18n hardening
    
    - Landing picker lists the whole layer, not just the metric-probed top-N:
      services below the cap show `low` in the order-by column (`—` elsewhere),
      still searchable and selectable. Header reads "metrics: top N". New
      `query.landingServiceCap` (default 100) bounds the metric fan-out; a
      cheap single-metric ranking pass picks the true top-N when a layer
      exceeds it.
    - Remove the stale "Landing KPI tile" Headline/Trend admin controls and all
      dead throughput/spark config (api-client wire types, BFF landing compute +
      setup schema + loader migration, UI data layer + metric catalog). The
      layer header renders every configured column as its own KPI with its own
      trend; the headline/trend fields drove nothing on screen.
    - i18n is remote-only at runtime: a missing OAP overlay falls back to the
      English source, never to bundled disk overlays (those stay for boot-seed /
      reset / diff only).
    - Dashboard + overview data caches key on the full widget config, not just
      ids, so a remote sync or preview edit that changes an MQE refires instead
      of serving stale data.
    - Config-bundle preload failure no longer strands the shell on
      "Initializing…": it seeds an empty unreachable bundle so routes render and
      the connectivity banner can recover.
    - An off-sample selected service resolves its name from the full roster, so
      the header KPIs match the dashboard the body queries.
---
 CHANGELOG.md                                       |  27 +++
 apps/bff/src/config/schema.ts                      |  16 ++
 apps/bff/src/http/config/bundle.ts                 |  37 +--
 apps/bff/src/http/config/dashboard.ts              |   3 +-
 apps/bff/src/http/config/overview.ts               |   4 +-
 apps/bff/src/http/config/setup.ts                  |  25 +-
 apps/bff/src/http/query/landing.ts                 | 264 ++++++++-------------
 apps/bff/src/http/query/menu.ts                    |   6 +-
 apps/bff/src/i18n/merge.ts                         |  51 ++--
 apps/bff/src/logic/layers/loader.ts                |  34 +--
 apps/bff/src/logic/templates/overlay.ts            |  17 +-
 apps/ui/src/api/client.ts                          |   9 +-
 apps/ui/src/api/scopes/layer.test.ts               |   4 +-
 apps/ui/src/api/scopes/layer.ts                    |   2 -
 apps/ui/src/controls/configBundle.ts               |  23 +-
 .../admin/layer-templates/LayerDashboardsAdmin.vue | 209 +---------------
 apps/ui/src/i18n/locales/de.json                   |   3 +
 apps/ui/src/i18n/locales/en.json                   |   3 +
 apps/ui/src/i18n/locales/es.json                   |   3 +
 apps/ui/src/i18n/locales/fr.json                   |   3 +
 apps/ui/src/i18n/locales/ja.json                   |   3 +
 apps/ui/src/i18n/locales/ko.json                   |   3 +
 apps/ui/src/i18n/locales/pt.json                   |   3 +
 apps/ui/src/i18n/locales/zh-CN.json                |   3 +
 apps/ui/src/layer/LayerServiceSelector.vue         | 164 ++++++++++---
 apps/ui/src/layer/LayerShell.vue                   |  49 ++--
 apps/ui/src/layer/useLayerLanding.ts               |   2 -
 .../render/layer-dashboard/useLayerDashboard.ts    |   6 +-
 .../ui/src/render/overview/useOverviewDashboard.ts |   5 +-
 apps/ui/src/state/setup.ts                         |   6 -
 apps/ui/src/utils/metricCatalog.ts                 |  13 -
 docs/setup/horizon-yaml.md                         |  35 +++
 packages/api-client/src/index.ts                   |   1 -
 packages/api-client/src/landing.ts                 |  23 +-
 packages/api-client/src/menu.ts                    |   5 -
 packages/api-client/src/setup.ts                   |  23 --
 36 files changed, 466 insertions(+), 621 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9ec7a25..e1949da 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -87,6 +87,33 @@ packages) plus the BFF's `HORIZON_VERSION` default.
   when you click **Reset to bundled** — matching the runtime, which renders the
   published version or blocks, never the bundle.
 
+### Layer landing & service list
+
+- **The layer landing now shows your services, not just an arbitrary 25.** It
+  used to cap the metric fan-out at the first 25 services *by list order* —
+  so larger layers hid the rest, and the "top" services weren't even the true
+  top (the cap happened before the ranking). Now it probes **all** services up
+  to a configurable cap and, when a layer exceeds it, runs a cheap single-
+  metric ranking pass to pick the **true top-N** by the landing's order-by
+  column. The service picker surfaces **"top N of M"** so the trim is never
+  silent. Queries drain through a bounded-concurrency pool, so a big layer
+  fans out in controlled waves rather than a thundering herd.
+- New `query.landingServiceCap` in `horizon.yaml` (default **100**) tunes how
+  many services a landing probes per request — raise it if your OAP + storage
+  can take the larger fan-out, lower it to protect a modest deployment.
+- **The service picker now lists the *whole* layer, not only the metric-probed
+  top-N.** Services that ranked below the metric cap on the order-by column now
+  appear as their own rows with **`low`** in that column (and `—` for the
+  others, which were never probed) instead of being hidden — every service
+  stays browsable, searchable, and selectable regardless of the cap. The
+  header chip reads **"metrics: top N"** to make the metric trim explicit.
+- **Removed the stale "Landing KPI tile" controls** (Headline / Trend line)
+  from the Layer-dashboards admin. They no longer matched the rendered layer
+  header — which shows every configured metric column as its own KPI with its
+  own trend line — so editing them changed nothing on screen. The header is
+  driven entirely by the service-list columns + default sort; the preview now
+  reflects that.
+
 ### Documentation & release tooling
 
 - The website docs were brought current with the 0.6.0 build and the
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index 37a0005..0aea48f 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -304,6 +304,21 @@ const debugLogSchema = z
     redactAuthHeaders: true,
   });
 
+const querySchema = z
+  .object({
+    /** Max services a layer landing runs metric MQE for, per request. The
+     *  landing always lists ALL services, but only fetches column metrics
+     *  for up to this many — the TRUE top-N by the landing's `orderBy`
+     *  column (a cheap single-metric ranking pass picks them when a layer
+     *  exceeds the cap). The UI surfaces "top N of M" whenever the cap
+     *  bites, so nothing is silently dropped. Raise it if your OAP +
+     *  storage backend can take the larger fan-out; lower it to protect a
+     *  modest deployment. Default 100. */
+    landingServiceCap: z.number().int().positive().default(100),
+  })
+  .strict()
+  .default({ landingServiceCap: 100 });
+
 export const configSchema = z
   .object({
     server: serverSchema.default({}),
@@ -315,6 +330,7 @@ export const configSchema = z
     setup: setupSchema,
     alarms: alarmsSchema,
     debugLog: debugLogSchema,
+    query: querySchema,
     // Deprecated + ignored. The 3D-map config moved to OAP (a template kind);
     // the old file-backed `infra3d.file` knob is gone. Accepted here (rather
     // than rejected by `.strict()`) so an existing config carrying the block
diff --git a/apps/bff/src/http/config/bundle.ts b/apps/bff/src/http/config/bundle.ts
index 9f6a945..eff0884 100644
--- a/apps/bff/src/http/config/bundle.ts
+++ b/apps/bff/src/http/config/bundle.ts
@@ -71,12 +71,7 @@ import { formatName, parseEnvelope } from '../../logic/templates/names.js';
 import { resync as resyncTemplates } from '../../logic/templates/sync.js';
 import { logger } from '../../logger.js';
 import type { Locale } from '../../i18n/index.js';
-import {
-  localizeContent,
-  getLayerOverlay,
-  getOverviewOverlay,
-  localeFromRequest,
-} from '../../i18n/index.js';
+import { localizeContent, localeFromRequest } from '../../i18n/index.js';
 
 export interface ConfigBundleDeps {
   config: ConfigSource;
@@ -175,15 +170,10 @@ async function buildBundle(
   const layers: Record<string, ScopeMap> = {};
   // Localize + slice a resolved layer template into per-scope widget sets.
   const addLayer = (picked: LayerTemplate): void => {
-    // Localize: OAP overlay wins where present, disk overlay fills the
-    // rest, English source falls through for the remainder. Both overlays
-    // key on the layer's `key` (upper-snake, matching OAP's enum).
-    const effective = localizeContent(
-      picked,
-      oapOverlayFor('layer', picked.key),
-      getLayerOverlay(picked.key, locale),
-      locale,
-    );
+    // Localize against the OAP overlay row (keyed on the layer's
+    // upper-snake `key`); English fills the rest. Disk i18n is seed/reset
+    // only, never a runtime fill — same remote-first rule as the template.
+    const effective = localizeContent(picked, oapOverlayFor('layer', picked.key), locale);
     const scopes: ScopeMap = {};
     for (const scope of ['service', 'instance', 'endpoint'] as const) {
       const ws = widgetsForScope(effective, scope);
@@ -216,14 +206,7 @@ async function buildBundle(
     diskOverviewIds.add(dash.id);
     const picked = pickOverviewContent(dash, remoteByName, preferLocal);
     if (picked === null) continue; // disabled
-    overviews.push(
-      localizeContent(
-        picked,
-        oapOverlayFor('overview', picked.id),
-        getOverviewOverlay(picked.id, locale),
-        locale,
-      ),
-    );
+    overviews.push(localizeContent(picked, oapOverlayFor('overview', picked.id), locale));
   }
   // Remote-only overviews: dashboards that live on OAP with no on-disk
   // base — created in the admin UI and pushed. The disk loop can't see
@@ -236,11 +219,9 @@ async function buildBundle(
     if (!env || !isOverviewLike(env.content)) continue;
     const dash = env.content as OverviewDashboard;
     if (diskOverviewIds.has(dash.id)) continue; // already handled above
-    // Remote-only dashboards: no disk overlay, but a per-locale OAP
-    // overlay row may still apply.
-    overviews.push(
-      localizeContent(dash, oapOverlayFor('overview', dash.id), null, locale),
-    );
+    // Remote-only dashboards: localize against the per-locale OAP overlay
+    // row when one exists, else English.
+    overviews.push(localizeContent(dash, oapOverlayFor('overview', dash.id), locale));
   }
 
   const syncStatus: BundleSyncStatus = {
diff --git a/apps/bff/src/http/config/dashboard.ts b/apps/bff/src/http/config/dashboard.ts
index 4e94509..a492840 100644
--- a/apps/bff/src/http/config/dashboard.ts
+++ b/apps/bff/src/http/config/dashboard.ts
@@ -36,7 +36,7 @@ import { resolveEffectiveLayer } from '../../logic/layers/effective.js';
 import { oapOverlayContentFor } from '../../logic/templates/overlay.js';
 import { defaultWidgetsFor } from '../../logic/dashboard/defaults.js';
 import { scopeSchema } from '../query/dashboard.js';
-import { localizeContent, getLayerOverlay, localeFromRequest } from '../../i18n/index.js';
+import { localizeContent, localeFromRequest } from '../../i18n/index.js';
 
 export interface DashboardConfigDeps {
   config: ConfigSource;
@@ -75,7 +75,6 @@ export function registerDashboardConfigRoute(app: FastifyInstance, deps: Dashboa
             // (`GENERAL`), not the lowercase URL param — pass the template's
             // own key so the OAP translation overlay row actually matches.
             await oapOverlayContentFor(deps.uiTemplateClient, 'layer', rawTpl.key, locale),
-            getLayerOverlay(rawTpl.key, locale),
             locale,
           )
         : null;
diff --git a/apps/bff/src/http/config/overview.ts b/apps/bff/src/http/config/overview.ts
index 8fca605..369f78e 100644
--- a/apps/bff/src/http/config/overview.ts
+++ b/apps/bff/src/http/config/overview.ts
@@ -45,7 +45,7 @@ import {
   resolveEffectiveOverview,
 } from '../../logic/overview/effective.js';
 import { oapOverlayContentFor } from '../../logic/templates/overlay.js';
-import { localizeContent, getOverviewOverlay, localeFromRequest } from '../../i18n/index.js';
+import { localizeContent, localeFromRequest } from '../../i18n/index.js';
 
 export interface OverviewRouteDeps {
   config: ConfigSource;
@@ -68,7 +68,6 @@ export function registerOverviewRoutes(app: FastifyInstance, deps: OverviewRoute
           const ld = localizeContent<OverviewDashboard>(
             d,
             await oapOverlayContentFor(deps.uiTemplateClient, 'overview', d.id, locale),
-            getOverviewOverlay(d.id, locale),
             locale,
           );
           return {
@@ -110,7 +109,6 @@ export function registerOverviewRoutes(app: FastifyInstance, deps: OverviewRoute
         dashboard: localizeContent<OverviewDashboard>(
           dash,
           await oapOverlayContentFor(deps.uiTemplateClient, 'overview', dash.id, locale),
-          getOverviewOverlay(dash.id, locale),
           locale,
         ),
         reachable: true,
diff --git a/apps/bff/src/http/config/setup.ts b/apps/bff/src/http/config/setup.ts
index b4272b9..6c473ba 100644
--- a/apps/bff/src/http/config/setup.ts
+++ b/apps/bff/src/http/config/setup.ts
@@ -46,35 +46,18 @@ const landingColumnSchema = z
   })
   .strict();
 
-const throughputSchema = z
-  .object({
-    metric: z.string().min(1),
-    label: z.string().optional(),
-    unit: z.string().optional(),
-    mqe: z.string().optional(),
-    aggregation: aggregationSchema.optional(),
-    scale: z.number().finite().optional(),
-    precision: z.number().int().min(0).max(6).optional(),
-  })
-  .strict();
-
 const landingSchema = z
   .object({
     priority: z.number().int().min(0).max(99),
     topN: z.number().int().min(5).max(8),
     orderBy: z.string().min(1),
     columns: z.array(landingColumnSchema).max(5),
-    spark: z
-      .object({
-        metric: z.string().min(1),
-        height: z.number().int().positive(),
-      })
-      .strict()
-      .optional(),
-    throughput: throughputSchema.optional(),
     style: z.enum(['table', 'bar', 'mini-topology']),
   })
-  .strict();
+  // `.strip()` (not `.strict()`): setups persisted by older builds carry the
+  // retired `spark` / `throughput` keys — drop them silently on re-save
+  // instead of failing the whole payload.
+  .strip();
 
 const layerConfigSchema = z
   .object({
diff --git a/apps/bff/src/http/query/landing.ts b/apps/bff/src/http/query/landing.ts
index a7565df..3f8ef37 100644
--- a/apps/bff/src/http/query/landing.ts
+++ b/apps/bff/src/http/query/landing.ts
@@ -24,16 +24,13 @@
  * ```
  *  {
  *    topN, orderBy, columns: LandingColumn[],
- *    spark?: { metric, height },
- *    throughput?: ThroughputConfig,
  *  }
  * ```
  *
  * One GraphQL trip lists services, a second batches per-service column
- * MQE values (one alias per service × column), a third optional trip
- * fetches the sparkline + throughput series for the surviving topN
- * rows. Errors anywhere in the MQE batch are local — failing cells
- * become `null`, the rest of the response stands.
+ * MQE values (one alias per service × column). Errors anywhere in the
+ * MQE batch are local — failing cells become `null`, the rest of the
+ * response stands.
  */
 
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
@@ -94,7 +91,27 @@ const LIST_SERVICES_QUERY = /* GraphQL */ `
 `;
 
 const DEFAULT_WINDOW_MIN = 60;
-const SERVICE_QUERY_CAP = 25;
+// Services × columns are chunked into batches of N services per OAP
+// round-trip — OAP enforces a per-request GraphQL complexity ceiling, so a
+// 25×10 single batch reliably 5xx'd busy backends and blanked every cell.
+// The batches then drain through a bounded-concurrency pool so a large
+// layer fans out in controlled waves, not a thundering herd. The number of
+// services probed per request is itself bounded by `query.landingServiceCap`.
+const MAX_SERVICES_PER_BATCH = 6;
+const LANDING_BATCH_CONCURRENCY = 8;
+
+/** Run `fn` over `items` with at most `limit` promises in flight at once. */
+async function mapPool<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
+  let next = 0;
+  await Promise.all(
+    Array.from({ length: Math.min(limit, items.length) }, async () => {
+      while (next < items.length) {
+        const i = next++;
+        await fn(items[i]);
+      }
+    }),
+  );
+}
 
 const aggSchema = z.enum(['sum', 'avg']);
 const columnSchema = z.object({
@@ -107,15 +124,6 @@ const columnSchema = z.object({
   scale: z.number().finite().optional(),
   precision: z.number().int().min(0).max(6).optional(),
 });
-const throughputSchema = z.object({
-  metric: z.string().min(1),
-  label: z.string().optional(),
-  unit: z.string().optional(),
-  mqe: z.string().optional(),
-  aggregation: aggSchema.optional(),
-  scale: z.number().finite().optional(),
-  precision: z.number().int().min(0).max(6).optional(),
-});
 const bodySchema = z.object({
   topN: z.number().int().min(1).max(8),
   orderBy: z.string().min(1),
@@ -124,10 +132,6 @@ const bodySchema = z.object({
   // per-layer header columns. Up to 3 + 5 = 8 in the worst case; 10
   // gives headroom without making the BFF wide-open.
   columns: z.array(columnSchema).max(10),
-  spark: z
-    .object({ metric: z.string().min(1), height: z.number().int().positive() })
-    .optional(),
-  throughput: throughputSchema.optional(),
   // Topbar time picker — same triplet shape the dashboard route accepts.
   // When all three are present the BFF queries OAP at the requested
   // window/precision; otherwise it falls back to the last-hour MINUTE
@@ -226,10 +230,6 @@ function aggregateSeries(
   return out;
 }
 
-function alias(serviceIdx: number, columnIdx: number): string {
-  return `r${serviceIdx}_c${columnIdx}`;
-}
-
 interface MqeRequest {
   expression: string;
   serviceName: string;
@@ -329,69 +329,97 @@ export function registerLandingRoute(app: FastifyInstance, deps: LandingRouteDep
       }
 
       // Step 2 — resolve MQE expressions per column.
-      const sampled = services.slice(0, SERVICE_QUERY_CAP);
       const resolved = cfg.columns.map((c) => ({
         column: c,
         expression: resolveMqe(c.metric, c.mqe, layerKey),
       }));
-
       const coldStage = !!req.coldStage;
-      // Chunk services × columns into batches of N services per
-      // round-trip, fired in parallel. OAP's GraphQL server enforces a
-      // per-request complexity ceiling — same reason the dashboard route
-      // pins at 6 widgets per trip (see dashboard.ts:555). A 25-service
-      // × 10-column landing in one batch is 250 fragments and reliably
-      // 5xx'd on busy backends, blanking every cell. Chunked +
-      // Promise.all keeps wall-clock close to a single round-trip.
-      const MAX_SERVICES_PER_BATCH = 6;
-      const serviceChunks: { svc: (typeof sampled)[number]; sIdx: number }[][] = [];
-      for (let i = 0; i < sampled.length; i += MAX_SERVICES_PER_BATCH) {
-        serviceChunks.push(
-          sampled.slice(i, i + MAX_SERVICES_PER_BATCH).map((svc, idxInChunk) => ({
-            svc,
-            sIdx: i + idxInChunk,
-          })),
-        );
-      }
-      let mqeData: Record<string, MqeResultShape> = {};
-      if (sampled.length > 0 && resolved.some((r) => !!r.expression)) {
-        try {
-          const chunkResults = await Promise.all(
-            serviceChunks.map(async (chunk) => {
-              const fragments: string[] = [];
-              for (const { svc, sIdx } of chunk) {
-                resolved.forEach(({ expression }, cIdx) => {
-                  if (!expression) return;
-                  fragments.push(
-                    buildMqeFragment(
-                      alias(sIdx, cIdx),
-                      { expression, serviceName: svc.value, normal: svc.normal !== false },
-                      window,
-                      coldStage,
-                    ),
-                  );
-                });
-              }
-              if (fragments.length === 0) return {} as Record<string, MqeResultShape>;
-              const query = `query LandingMqe { ${fragments.join('\n    ')} }`;
-              return graphqlPost<Record<string, MqeResultShape>>(opts, query);
-            }),
-          );
-          for (const chunk of chunkResults) Object.assign(mqeData, chunk);
-        } catch {
-          mqeData = {};
+
+      // Probe `cols` for every service in `svcList`, chunked into
+      // per-request batches and drained through the bounded pool. Keyed by
+      // `${serviceId}#${colIdx}` so the row assembly reads back by id, not
+      // by a fragile global index. Per-batch failures are local — those
+      // cells just stay empty.
+      const probeColumns = async (
+        svcList: typeof services,
+        cols: typeof resolved,
+      ): Promise<Map<string, MqeResultShape>> => {
+        const out = new Map<string, MqeResultShape>();
+        if (svcList.length === 0 || !cols.some((c) => !!c.expression)) return out;
+        const chunks: (typeof svcList)[] = [];
+        for (let i = 0; i < svcList.length; i += MAX_SERVICES_PER_BATCH) {
+          chunks.push(svcList.slice(i, i + MAX_SERVICES_PER_BATCH));
+        }
+        await mapPool(chunks, LANDING_BATCH_CONCURRENCY, async (batch) => {
+          const fragments: string[] = [];
+          const back: { a: string; key: string }[] = [];
+          batch.forEach((svc, li) => {
+            cols.forEach(({ expression }, ci) => {
+              if (!expression) return;
+              const a = `s${li}c${ci}`;
+              back.push({ a, key: `${svc.id}#${ci}` });
+              fragments.push(
+                buildMqeFragment(
+                  a,
+                  { expression, serviceName: svc.value, normal: svc.normal !== false },
+                  window,
+                  coldStage,
+                ),
+              );
+            });
+          });
+          if (fragments.length === 0) return;
+          try {
+            const data = await graphqlPost<Record<string, MqeResultShape>>(
+              opts,
+              `query LandingMqe { ${fragments.join('\n    ')} }`,
+            );
+            for (const { a, key } of back) {
+              if (data[a] !== undefined) out.set(key, data[a]);
+            }
+          } catch {
+            /* batch-local failure → leave those cells empty */
+          }
+        });
+        return out;
+      };
+
+      // Bound the column fan-out to `landingServiceCap` services. The
+      // landing lists ALL services; when a layer exceeds the cap we run a
+      // cheap single-metric ranking pass (the `orderBy` column over every
+      // service) to pick the TRUE top-`cap`, then fetch the full columns for
+      // just those. At or under the cap we probe everyone directly. The UI
+      // surfaces "top N of M" so the trim is never silent.
+      const cap = deps.config.current.query.landingServiceCap;
+      let sampled = services;
+      if (totalServiceCount > cap) {
+        const orderByCol = resolved.find((r) => r.column.metric === cfg.orderBy && r.expression);
+        if (orderByCol) {
+          const ranked = await probeColumns(services, [orderByCol]);
+          const scored = services.map((svc) => ({ svc, v: collapseToScalar(ranked.get(`${svc.id}#0`)) }));
+          scored.sort((a, b) => {
+            if (a.v == null && b.v == null) return 0;
+            if (a.v == null) return 1;
+            if (b.v == null) return -1;
+            return b.v - a.v;
+          });
+          sampled = scored.slice(0, cap).map((x) => x.svc);
+        } else {
+          // No orderBy column to rank by — fall back to the first `cap`.
+          sampled = services.slice(0, cap);
         }
       }
+      const probed = await probeColumns(sampled, resolved);
 
       // Step 3 — assemble per-row metrics + retain the per-bucket
       // series so the layer header can render a sparkline under each
       // KPI (aggregated point-wise across topN below).
       const seriesByServiceMetric = new Map<string, Map<string, Array<number | null>>>();
-      const rows: LandingServiceRow[] = sampled.map((svc, sIdx) => {
+      const rows: LandingServiceRow[] = sampled.map((svc) => {
         const metrics: Record<string, number | null> = {};
         const seriesMap = new Map<string, Array<number | null>>();
         resolved.forEach(({ column }, cIdx) => {
-          const raw = mqeData[alias(sIdx, cIdx)];
+          const raw = probed.get(`${svc.id}#${cIdx}`);
           const series = collapseToSeries(raw);
           if (series) {
             seriesMap.set(
@@ -426,69 +454,10 @@ export function registerLandingRoute(app: FastifyInstance, deps: LandingRouteDep
       });
       const topRows = rows.slice(0, cfg.topN);
 
-      // Step 5 — sparkline + throughput series for the surviving topN.
-      const sparkExpr = cfg.spark ? resolveMqe(cfg.spark.metric, undefined, layerKey) : null;
-      const throughputCol = cfg.throughput;
-      const throughputExpr = throughputCol
-        ? resolveMqe(throughputCol.metric, throughputCol.mqe, layerKey)
-        : null;
-
-      // The throughput tile reuses one MQE call per surviving row — but
-      // only when it's a different expression than the column already
-      // fetched. Most setups will pick throughput = orderBy, so we just
-      // reuse `rows` values in that case.
-      const sparkSeriesByRow = new Map<number, Array<number | null>>();
-      const throughputSeriesByRow = new Map<number, Array<number | null>>();
-
-      if ((sparkExpr || throughputExpr) && topRows.length > 0) {
-        const sparkFragments: string[] = [];
-        topRows.forEach((row, i) => {
-          const svc = sampled.find((s) => s.id === row.serviceId);
-          if (!svc) return;
-          const r: MqeRequest = { expression: '', serviceName: svc.value, normal: svc.normal !== false };
-          if (sparkExpr) {
-            sparkFragments.push(buildMqeFragment(`s${i}`, { ...r, expression: sparkExpr }, window, coldStage));
-          }
-          if (throughputExpr && throughputExpr !== sparkExpr) {
-            sparkFragments.push(buildMqeFragment(`t${i}`, { ...r, expression: throughputExpr }, window, coldStage));
-          }
-        });
-        if (sparkFragments.length > 0) {
-          const sparkQuery = `query LandingSpark { ${sparkFragments.join('\n    ')} }`;
-          try {
-            const sparkData = await graphqlPost<Record<string, MqeResultShape>>(opts, sparkQuery);
-            topRows.forEach((row, i) => {
-              const sk = sparkExpr ? collapseToSeries(sparkData[`s${i}`]) : null;
-              if (sk && cfg.spark) {
-                // Spark inherits the orderBy column's scale/precision if
-                // we have a matching column; otherwise raw.
-                const matchedCol = cfg.columns.find((c) => c.metric === cfg.spark!.metric);
-                const scaled = sk.map((v) =>
-                  postProcess(v, matchedCol?.scale, matchedCol?.precision),
-                );
-                row.spark = scaled;
-                sparkSeriesByRow.set(i, scaled);
-              }
-              if (throughputExpr) {
-                const series =
-                  throughputExpr === sparkExpr
-                    ? sk
-                    : collapseToSeries(sparkData[`t${i}`]);
-                if (series) {
-                  const scaled = series.map((v) =>
-                    postProcess(v, throughputCol?.scale, throughputCol?.precision),
-                  );
-                  throughputSeriesByRow.set(i, scaled);
-                }
-              }
-            });
-          } catch {
-            // Soft-fail: leave spark / throughput-spark empty.
-          }
-        }
-      }
-
-      // Step 6 — aggregates for the KPI tile.
+      // Step 5 — aggregates for the KPI tile. Each header column becomes a
+      // KPI: a point value (sum/avg across the topN per the column's
+      // `aggregation`) plus the point-wise aggregated series the header
+      // renders as a trend line beneath it.
       const aggregates: LandingAggregates = {
         serviceCount: totalServiceCount,
         metrics: {},
@@ -508,31 +477,6 @@ export function registerLandingRoute(app: FastifyInstance, deps: LandingRouteDep
         const agg = aggregateSeries(colSeries, kind);
         if (agg) aggregates.seriesByMetric[col.metric] = agg;
       }
-      if (throughputCol) {
-        const kind: AggregationKind = throughputCol.aggregation ?? 'sum';
-        // Value: either reuse the per-row column value (when throughput
-        // matches a column) or compute it now from the throughput series.
-        const matchingCol = cfg.columns.find((c) => c.metric === throughputCol.metric);
-        const values = matchingCol
-          ? topRows.map((r) => r.metrics[throughputCol.metric] ?? null)
-          : topRows.map((_, i) => {
-              const series = throughputSeriesByRow.get(i);
-              if (!series) return null;
-              const finite = series.filter((v): v is number => v !== null);
-              if (finite.length === 0) return null;
-              return finite.reduce((a, b) => a + b, 0) / finite.length;
-            });
-        aggregates.throughputMetric = throughputCol.metric;
-        aggregates.throughputValue = aggregate(values, kind);
-        const seriesList = topRows.map((_, i) => throughputSeriesByRow.get(i));
-        aggregates.spark = aggregateSeries(seriesList, kind);
-      } else if (cfg.spark) {
-        // No throughput configured — surface the spark metric's aggregated
-        // series as a fallback so the KPI tile still has a trend line.
-        const kind: AggregationKind = 'avg';
-        const seriesList = topRows.map((_, i) => sparkSeriesByRow.get(i));
-        aggregates.spark = aggregateSeries(seriesList, kind);
-      }
 
       const body: LandingResponse = {
         layer: layerKey,
diff --git a/apps/bff/src/http/query/menu.ts b/apps/bff/src/http/query/menu.ts
index 2193493..8846059 100644
--- a/apps/bff/src/http/query/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -36,7 +36,7 @@ import type { TemplateRow } from '../../logic/templates/sync.js';
 import type { ServiceLayerCatalog } from '../../logic/services/service-layer-catalog.js';
 import { logger } from '../../logger.js';
 import type { Locale } from '../../i18n/index.js';
-import { localizeContent, getLayerOverlay, localeFromRequest } from '../../i18n/index.js';
+import { localizeContent, localeFromRequest } from '../../i18n/index.js';
 import { oapOverlayContentFromRows } from '../../logic/templates/overlay.js';
 
 /**
@@ -267,9 +267,7 @@ function deriveLayer(
   // seed/reset source, never a render-time fallback; the per-page
   // features block in that state while the sidebar still navigates.
   const rawTpl = resolveLayerTemplate(rawKey, layerRowsByName);
-  const tpl = rawTpl
-    ? localizeContent<LayerTemplate>(rawTpl, oapOverlay, getLayerOverlay(rawKey, locale), locale)
-    : null;
+  const tpl = rawTpl ? localizeContent<LayerTemplate>(rawTpl, oapOverlay, locale) : null;
   if (tpl) {
     return {
       key: rawKey.toLowerCase(),
diff --git a/apps/bff/src/i18n/merge.ts b/apps/bff/src/i18n/merge.ts
index 6d5abe4..b39229b 100644
--- a/apps/bff/src/i18n/merge.ts
+++ b/apps/bff/src/i18n/merge.ts
@@ -81,33 +81,22 @@ export function localize<T>(source: T, overlay: unknown, locale: string): T {
 }
 
 /**
- * Variant for layer / overview templates whose translations live as
- * sibling rows on OAP (`horizon.<kind>.<key>.i18n.<locale>`) plus
- * sibling files on disk (`*.i18n.<lang>.json`).
+ * Localize a layer / overview template against its **OAP** translation
+ * overlay row (`horizon.<kind>.<key>.i18n.<locale>`), most-specific-wins
+ * per leaf: the OAP overlay value, else the English source.
  *
- * Lookup precedence (most-specific wins per leaf):
- *   1. `oapOverlay` — operator's pushed translations from the sibling
- *      OAP row.
- *   2. `diskOverlay` — sibling `*.i18n.<lang>.json` file from disk.
- *   3. Source (English).
+ * Runtime is REMOTE-only. The disk `*.i18n.<lang>.json` files are
+ * seed/reset defaults — boot-seed pushes each as a sibling OAP overlay
+ * row — NOT a render-time fill. So a key the OAP overlay doesn't carry
+ * falls through to **English**, never to the disk-shipped translation;
+ * the bundled overlay reaches the UI only by being synced to OAP, exactly
+ * like bundled templates. (Operators who want the full shipped
+ * translation reset-to-bundled, which re-seeds the OAP row.)
  *
- * The merge is per-leaf: where the OAP overlay has a value the OAP
- * value wins; where it's blank or absent the disk overlay fills in;
- * where neither has it the English source falls through. So operators
- * never lose disk-shipped translations by pushing one row — they
- * compose.
- *
- * Defensive: any embedded `i18n` block on the source content is
- * stripped before the merge. The split layout never writes embedded
- * i18n; this guard exists only to keep us safe against hand-edited
- * rows or future regressions.
+ * Defensive: any embedded `i18n` block on the source content is stripped
+ * before the merge — the split layout never writes embedded i18n.
  */
-export function localizeContent<T>(
-  content: T,
-  oapOverlay: unknown,
-  diskOverlay: unknown,
-  locale: string,
-): T {
+export function localizeContent<T>(content: T, oapOverlay: unknown, locale: string): T {
   let source: T = content;
   if (content !== null && typeof content === 'object' && !Array.isArray(content)) {
     const record = content as unknown as Record<string, unknown>;
@@ -117,16 +106,6 @@ export function localizeContent<T>(
       source = rest as unknown as T;
     }
   }
-  if (locale === 'en') return source;
-  // Apply disk first, then OAP — OAP wins where it has a value, disk
-  // fills the gaps. mergeLocalizedNode preserves source-shape and
-  // falls through to the previous-layer value on empty/missing.
-  let merged: T = source;
-  if (diskOverlay !== null && diskOverlay !== undefined) {
-    merged = mergeLocalizedNode(merged, diskOverlay) as T;
-  }
-  if (oapOverlay !== null && oapOverlay !== undefined) {
-    merged = mergeLocalizedNode(merged, oapOverlay) as T;
-  }
-  return merged;
+  if (locale === 'en' || oapOverlay === null || oapOverlay === undefined) return source;
+  return mergeLocalizedNode(source, oapOverlay) as T;
 }
diff --git a/apps/bff/src/logic/layers/loader.ts b/apps/bff/src/logic/layers/loader.ts
index db7d3e6..768cf93 100644
--- a/apps/bff/src/logic/layers/loader.ts
+++ b/apps/bff/src/logic/layers/loader.ts
@@ -145,17 +145,12 @@ export interface OverviewGroup {
 
 /**
  * Overview-tile config. `groups` is the canonical shape; legacy
- * `metrics`/`throughput`/`spark` are migrated to a single auto-size
- * group at load time.
+ * `metrics` is migrated to a single auto-size group at load time.
  */
 export interface LayerOverviewConfig {
   groups?: OverviewGroup[];
   /** @deprecated — wrapped into a single auto-size group on load. */
   metrics?: OverviewMetric[];
-  /** @deprecated — migrated to the first group's first metric. */
-  throughput?: string;
-  /** @deprecated — sparkline follows the headline metric. */
-  spark?: string;
 }
 
 /**
@@ -468,32 +463,13 @@ function load(): Map<string, LayerTemplate> {
     if (!parsed.header) parsed.header = { columns: [] };
     parsed.metrics = parsed.header;
 
-    // Legacy: `metrics.throughput` + `metrics.spark` used to sit
-    // inside the header block. Promote to top-level `overview` so the
-    // tile rendering doesn't have to know about either spelling.
-    const legacyMetrics = parsed.metrics as
-      | (LayerHeaderConfig & { throughput?: string; spark?: string })
-      | undefined;
-    if (legacyMetrics && (legacyMetrics.throughput || legacyMetrics.spark)) {
-      const ov: LayerOverviewConfig = { ...(parsed.overview ?? {}) };
-      if (!ov.throughput && legacyMetrics.throughput) ov.throughput = legacyMetrics.throughput;
-      if (!ov.spark && legacyMetrics.spark) ov.spark = legacyMetrics.spark;
-      parsed.overview = ov;
-      delete legacyMetrics.throughput;
-      delete legacyMetrics.spark;
-    }
-
     // Overview tile schema: self-contained `OverviewMetric[]`. Support
-    // three input shapes and normalize to the new one:
+    // two input shapes and normalize to the new one:
     //   1. `metrics: OverviewMetric[]`   ← new shape, pass through.
     //   2. `metrics: string[]`           ← previous shape (key refs
     //      into the header columns); resolve each ref to a full entry.
-    //   3. `throughput` / `spark` strings ← oldest shape; resolve same
-    //      way the column-ref path does.
     if (parsed.overview) {
       const ov = parsed.overview as LayerOverviewConfig & {
-        throughput?: string;
-        spark?: string;
         metrics?: unknown;
       };
       const columns = parsed.header?.columns ?? [];
@@ -530,10 +506,6 @@ function load(): Map<string, LayerTemplate> {
           }
         }
       }
-      if (resolved.length === 0) {
-        if (ov.throughput) resolved.push(fromRef(ov.throughput));
-        if (ov.spark && ov.spark !== ov.throughput) resolved.push(fromRef(ov.spark));
-      }
       // Assign auto-ids to any unkeyed entry. The id is what the SPA
       // threads through the landing query as the synthetic column key.
       resolved = resolved.map((m, i) => ({ id: m.id ?? `ov_${i}`, ...m }));
@@ -564,8 +536,6 @@ function load(): Map<string, LayerTemplate> {
       // Keep the legacy `metrics` array in sync with the flattened
       // groups so any caller still reading the old field keeps working.
       ov.metrics = (ov.groups ?? []).flatMap((g) => g.metrics);
-      delete ov.throughput;
-      delete ov.spark;
     }
     out.set(parsed.key.toUpperCase(), parsed);
   }
diff --git a/apps/bff/src/logic/templates/overlay.ts b/apps/bff/src/logic/templates/overlay.ts
index f72479f..2f755e8 100644
--- a/apps/bff/src/logic/templates/overlay.ts
+++ b/apps/bff/src/logic/templates/overlay.ts
@@ -19,15 +19,14 @@
  * Resolve the live **OAP translation overlay** content for one
  * (kind, key, locale) — the operator-pushed per-locale row.
  *
- * The disk `*.i18n.<lang>.json` files are only seeds; the live
- * translation is an OAP overlay row. The config bundle already merges
- * this row on top of the disk overlay (`localizeContent(content,
- * oapOverlay, diskOverlay, locale)`), so render routes that localize
- * with the disk overlay ALONE show stale text once an operator pushes a
- * translation. This helper gives those direct routes the same remote-
- * first overlay the bundle uses. Reads the shared 30s sync cache, so
- * it's cheap on the hot path; soft-fails to `null` (disk-only) on
- * English / no-client / OAP-unreachable / parse error.
+ * Runtime translation is REMOTE-only: `localizeContent(content,
+ * oapOverlay, locale)` applies this OAP overlay over the English source,
+ * and nothing else — the disk `*.i18n.<lang>.json` files are seed / reset
+ * / diff sources, never a render-time fill (same remote-first rule as
+ * bundled templates). This helper gives the direct render routes the same
+ * OAP overlay the config bundle uses. Reads the shared 30s sync cache, so
+ * it's cheap on the hot path; soft-fails to `null` (→ English) on English
+ * / no-client / OAP-unreachable / parse error.
  */
 
 import type { UITemplateClient } from '@skywalking-horizon-ui/api-client';
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 6f714a6..7d82380 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -37,6 +37,7 @@ import type {
   DashboardWidget,
   DslDebuggingStatus,
   EndpointDependencyConfig,
+  LayerOverviewConfig,
   LocalState,
   MetricRow,
   ProcessTopologyConfig,
@@ -288,10 +289,10 @@ export interface AdminLayerTemplate {
       precision?: number;
     }>;
   };
-  overview?: {
-    throughput?: string;
-    spark?: string;
-  };
+  /** Overview-tile config (group list). Edited on the Overview-templates
+   *  admin, not here — surfaced for the translation preview + so a
+   *  round-trip save preserves it. */
+  overview?: LayerOverviewConfig;
   widgets: DashboardWidget[];
   topology?: TopologyConfig;
   endpointDependency?: EndpointDependencyConfig;
diff --git a/apps/ui/src/api/scopes/layer.test.ts b/apps/ui/src/api/scopes/layer.test.ts
index 5357577..d7752c7 100644
--- a/apps/ui/src/api/scopes/layer.test.ts
+++ b/apps/ui/src/api/scopes/layer.test.ts
@@ -47,8 +47,6 @@ describe('LayerApi.landing', () => {
         topN: 5,
         orderBy: 'cpm',
         columns: [{ metric: 'cpm', label: 'CPM', mqe: 'service_cpm', aggregation: 'sum' }],
-        spark: { metric: 'service_cpm', height: 18 },
-        throughput: { metric: 'cpm', mqe: 'service_cpm', label: 'CPM', unit: 'rpm' },
         style: 'table',
       },
       { step: 'MINUTE', startMs: 1, endMs: 2 },
@@ -59,10 +57,10 @@ describe('LayerApi.landing', () => {
     expect(calls[0][2]).toMatchObject({
       topN: 5,
       orderBy: 'cpm',
+      columns: [{ metric: 'cpm', label: 'CPM', mqe: 'service_cpm', aggregation: 'sum' }],
       step: 'MINUTE',
       startMs: 1,
       endMs: 2,
-      spark: { metric: 'service_cpm', height: 18 },
     });
   });
 
diff --git a/apps/ui/src/api/scopes/layer.ts b/apps/ui/src/api/scopes/layer.ts
index f434fdd..d97744f 100644
--- a/apps/ui/src/api/scopes/layer.ts
+++ b/apps/ui/src/api/scopes/layer.ts
@@ -52,8 +52,6 @@ export class LayerApi {
       topN: cfg.topN,
       orderBy: cfg.orderBy,
       columns: cfg.columns,
-      ...(cfg.spark ? { spark: cfg.spark } : {}),
-      ...(cfg.throughput ? { throughput: cfg.throughput } : {}),
     };
     if (range) {
       body.step = range.step;
diff --git a/apps/ui/src/controls/configBundle.ts b/apps/ui/src/controls/configBundle.ts
index 3772157..c9513b6 100644
--- a/apps/ui/src/controls/configBundle.ts
+++ b/apps/ui/src/controls/configBundle.ts
@@ -155,7 +155,28 @@ export function ensureConfigBundle(): Promise<void> {
         'err',
         `Config preload failed: ${err instanceof Error ? err.message : String(err)}`,
       );
-      // Don't rethrow — the SPA falls back to per-page network reads.
+      // Don't rethrow — but DO unblock the shell. A network / non-2xx
+      // failure with no cached copy leaves `state` null, and the AppShell
+      // waits on `loaded` before rendering ANY route — so the app would
+      // hang on "Initializing…" forever. Seed an empty, unreachable bundle
+      // so `loaded` flips true: routes render, per-page reads + the
+      // connectivity banner take over, and the banner's retry can recover.
+      if (state.value === null) {
+        const now = Date.now();
+        state.value = {
+          etag: '',
+          generatedAt: now,
+          layers: {},
+          overviews: [],
+          syncStatus: {
+            unreachable: true,
+            lastSuccessfulSyncAt: null,
+            generatedAt: now,
+            badges: [],
+            conflicts: [],
+          },
+        };
+      }
     }
   })();
   return loadPromise;
diff --git a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index 1ede4eb..4dd7e6e 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -53,7 +53,6 @@ import { buildExportEnvelope, downloadJson, pickJsonFile, validateImport } from
 import { usePreviewOverride } from '@/controls/previewOverride';
 import TimeChart from '@/components/charts/TimeChart.vue';
 import TopList from '@/components/charts/TopList.vue';
-import Sparkline from '@/components/charts/Sparkline.vue';
 import { fmtMetric } from '@/utils/formatters';
 import { stableStringify } from '@/utils/stableJson';
 import { mockCardValue, mockLineSeries, mockRecordRows, mockTopGroups } from './widget-mock';
@@ -1222,9 +1221,7 @@ const TRACE_SOURCE_OPTIONS: Array<{ value: TraceSource; label: string; hint: str
  * honors at runtime to pick the trace backend. */
 
 /**
- * Metrics block editor — drives the service-list columns + default
- * sort. Overview-only fields (throughput, spark) live in a separate
- * block, so they're edited in their own card.
+ * Metrics block editor — drives the service-list columns + default sort.
  */
 function ensureMetrics(): NonNullable<AdminLayerTemplate['metrics']> {
   if (!draft.template) throw new Error('no template selected');
@@ -1233,23 +1230,11 @@ function ensureMetrics(): NonNullable<AdminLayerTemplate['metrics']> {
   }
   return draft.template.metrics as NonNullable<AdminLayerTemplate['metrics']>;
 }
-function ensureOverview(): NonNullable<AdminLayerTemplate['overview']> {
-  if (!draft.template) throw new Error('no template selected');
-  if (!draft.template.overview) {
-    (draft.template as AdminLayerTemplate).overview = {};
-  }
-  return draft.template.overview as NonNullable<AdminLayerTemplate['overview']>;
-}
 const metricsModel = computed(() => {
   if (!draft.template) return null;
   ensureMetrics();
   return draft.template.metrics as NonNullable<AdminLayerTemplate['metrics']>;
 });
-const overviewModel = computed(() => {
-  if (!draft.template) return null;
-  ensureOverview();
-  return draft.template.overview as NonNullable<AdminLayerTemplate['overview']>;
-});
 const metricsColumns = computed(() => {
   if (!draft.template) return [];
   const m = ensureMetrics();
@@ -1290,36 +1275,6 @@ const effectiveOrderBy = computed(
   () => metricsModel.value?.orderBy ?? metricsColumns.value[0]?.metric,
 );
 
-type MetricColumn = NonNullable<NonNullable<AdminLayerTemplate['metrics']>['columns']>[number];
-/** Headline column for the landing KPI tile: explicit `overview.throughput`,
- *  else the default-sort column. */
-const kpiHeadlineCol = computed<MetricColumn | undefined>(() => {
-  const key = overviewModel.value?.throughput ?? effectiveOrderBy.value;
-  return metricsColumns.value.find((c) => c.metric === key) ?? metricsColumns.value[0];
-});
-/** Trend column for the sparkline: explicit `overview.spark`, else the
- *  headline column. */
-const kpiSparkCol = computed<MetricColumn | undefined>(() => {
-  const key = overviewModel.value?.spark ?? overviewModel.value?.throughput ?? effectiveOrderBy.value;
-  return metricsColumns.value.find((c) => c.metric === key) ?? kpiHeadlineCol.value;
-});
-/** Headline number — fmtMetric of a mock aggregate for the headline
- *  column (the real tile shows the value bare; unit lives in the label). */
-const kpiHeadlineValue = computed(() =>
-  kpiHeadlineCol.value ? fmtMetric(previewBase(1) * (kpiHeadlineCol.value.scale ?? 1)) : '—',
-);
-/** Mock sparkline series for the real Sparkline component — deterministic
- *  per spark-column so the trend reads as real movement without MQE. */
-const kpiSparkValues = computed<number[]>(() => {
-  const seed = (kpiSparkCol.value?.metric ?? 'x').length;
-  const n = 24;
-  const a: number[] = [];
-  for (let i = 0; i < n; i++) a.push(14 + Math.sin(i * 0.7 + seed) * 7 + Math.sin(i * 0.23 + seed) * 4);
-  return a;
-});
-/** Two-letter mark for the header icon tile (mirrors the live header). */
-const previewInitials = computed(() => (selectedTpl.value?.key ?? '??').slice(0, 2).toUpperCase());
-
 /**
  * Scope-aware `visibleWhen` placeholder + hover hint. Two supported
  * predicate forms:
@@ -2126,54 +2081,12 @@ const namingTest = computed<NamingTestResult>(() => {
               </tr>
             </tbody>
           </table>
-          <!-- Preview: the per-layer landing KPI tile (headline + trend)
-               at the head — its config picks WHICH service-list column
-               feeds the tile, no new metrics — then the service-list
-               sample table. Mock values, no MQE fired. -->
+          <!-- Preview: the service-list sample table — shows how the
+               configured columns render (label, scale, precision, unit,
+               default-sort marker). Mock values, no MQE fired. -->
           <div v-if="metricsColumns.length > 0" class="metrics-preview">
             <div class="metrics-preview-head">
-              Preview <span class="sub">how this layer’s landing KPI tile + service list render (sample data)</span>
-            </div>
-            <div v-if="overviewModel" class="kpi-block">
-              <div class="kpi-config">
-                <div class="kpi-config-title">Landing KPI tile <span class="sub">which column is the headline + trend</span></div>
-                <label>
-                  <span>Headline (throughput)</span>
-                  <select v-model="overviewModel.throughput">
-                    <option :value="undefined">(default sort)</option>
-                    <option v-for="c in metricsColumns" :key="c.metric" :value="c.metric">{{ c.label || c.metric }}</option>
-                  </select>
-                </label>
-                <label>
-                  <span>Trend line (spark)</span>
-                  <select v-model="overviewModel.spark">
-                    <option :value="undefined">(headline)</option>
-                    <option v-for="c in metricsColumns" :key="c.metric" :value="c.metric">{{ c.label || c.metric }}</option>
-                  </select>
-                </label>
-              </div>
-              <!-- Faithful copy of the real layer header (LayerShell):
-                   icon tile + identity + the kpi-strip. Reuses the same
-                   class names + the Sparkline component so the preview
-                   matches the live page. -->
-              <header class="sw-card preview-layer-head">
-                <div class="layer-id-row">
-                  <div class="icon-tile" :style="{ background: selectedTpl.color || 'var(--sw-fg-3)' }">{{ previewInitials }}</div>
-                  <div class="identity-text">
-                    <div class="title-row"><h1>{{ selectedTpl.alias || selectedTpl.key }}</h1></div>
-                    <div class="sub">{{ SAMPLE_SERVICES.length }} {{ (selectedTpl.slots?.services || 'services').toLowerCase() }}</div>
-                  </div>
-                  <div class="kpi-strip">
-                    <div class="kpi">
-                      <div class="kpi-label">
-                        {{ kpiHeadlineCol?.label || kpiHeadlineCol?.metric || '—' }}<span v-if="kpiHeadlineCol?.unit" class="unit">({{ kpiHeadlineCol.unit }})</span>
-                      </div>
-                      <div class="kpi-value">{{ kpiHeadlineValue }}</div>
-                      <Sparkline class="kpi-spark" :values="kpiSparkValues" :width="84" :height="18" color="var(--sw-accent)" :stroke="1.25" />
-                    </div>
-                  </div>
-                </div>
-              </header>
+              Preview <span class="sub">how this layer’s service list renders (sample data)</span>
             </div>
             <div class="metrics-preview-scroll">
               <table class="sw-table preview-table">
@@ -4141,118 +4054,6 @@ const namingTest = computed<NamingTestResult>(() => {
   font-size: 10px;
 }
 
-/* Landing KPI tile preview: config head + a faithful copy of the real
- * layer header. The header classes below mirror LayerShell so the
- * preview matches the live page (kept in sync intentionally). */
-.kpi-block {
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
-  padding: 4px 4px 14px;
-}
-.kpi-config {
-  display: flex;
-  flex-wrap: wrap;
-  align-items: flex-end;
-  gap: 10px;
-}
-.kpi-config-title {
-  flex: 1 0 100%;
-  font-size: 11px;
-  font-weight: 600;
-  color: var(--sw-fg-1);
-}
-.kpi-config-title .sub {
-  margin-left: 6px;
-  font-weight: 400;
-  font-size: 10px;
-  color: var(--sw-fg-3);
-}
-.kpi-config label {
-  display: flex;
-  flex-direction: column;
-  gap: 3px;
-  font-size: 10px;
-  text-transform: uppercase;
-  letter-spacing: 0.04em;
-  color: var(--sw-fg-3);
-}
-.kpi-config select {
-  height: 28px;
-  padding: 0 8px;
-  background: var(--sw-bg-2);
-  border: 1px solid var(--sw-line-2);
-  border-radius: 4px;
-  color: var(--sw-fg-0);
-  font: inherit;
-  font-size: 11.5px;
-}
-.kpi-config select:focus { outline: none; border-color: var(--sw-accent); }
-/* ↓ mirrors LayerShell.vue .layer-head / .layer-id-row / .kpi-strip. */
-.preview-layer-head { padding: 14px; }
-.preview-layer-head .layer-id-row {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  min-width: 0;
-}
-.preview-layer-head .icon-tile {
-  width: 40px;
-  height: 40px;
-  border-radius: 10px;
-  display: grid;
-  place-items: center;
-  color: #fff;
-  font-weight: 700;
-  font-size: 14px;
-  letter-spacing: -0.02em;
-  flex: 0 0 40px;
-  background-blend-mode: multiply;
-  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
-}
-.preview-layer-head .identity-text { min-width: 0; }
-.preview-layer-head .title-row h1 {
-  margin: 0;
-  font-size: 18px;
-  font-weight: 600;
-  color: var(--sw-fg-0);
-  letter-spacing: -0.02em;
-}
-.preview-layer-head .sub {
-  margin-top: 4px;
-  font-size: 11.5px;
-  color: var(--sw-fg-3);
-}
-.preview-layer-head .kpi-strip {
-  display: flex;
-  gap: 22px;
-  flex-wrap: wrap;
-  align-items: flex-end;
-  margin-left: auto;
-}
-.preview-layer-head .kpi { text-align: right; min-width: 80px; }
-.preview-layer-head .kpi-label {
-  font-size: 10px;
-  text-transform: uppercase;
-  letter-spacing: 0.08em;
-  color: var(--sw-fg-3);
-  margin-bottom: 2px;
-}
-.preview-layer-head .kpi-label .unit {
-  text-transform: none;
-  letter-spacing: 0;
-  margin-left: 2px;
-  font-size: 9.5px;
-}
-.preview-layer-head .kpi-value {
-  font-size: 18px;
-  font-weight: 600;
-  font-variant-numeric: tabular-nums;
-  letter-spacing: -0.02em;
-  color: var(--sw-fg-1);
-}
-.preview-layer-head .kpi-spark { display: block; margin-top: 4px; margin-left: auto; }
-
 /* Menu-label (slot alias) editor grid. */
 .alias-grid {
   display: grid;
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 9066b5c..6e6e161 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -361,6 +361,9 @@
   "No matches": "Keine Treffer",
   "filter by name…": "nach Name filtern…",
   "{n} of {total}": "{n} von {total}",
+  "metrics: top {n}": "Metriken: Top {n}",
+  "low": "niedrig",
+  "Metrics are probed for the top {n} services by {metric}; the rest are listed as low-traffic. Raise query.landingServiceCap to probe more.": "Metriken werden nur für die Top-{n}-Dienste nach {metric} erfasst; die übrigen werden als verkehrsarm aufgeführt. Erhöhen Sie query.landingServiceCap, um mehr zu erfassen.",
   "No services match": "Keine Services passen",
   "Last 15 min": "Letzte 15 Min",
   "Last 30 min": "Letzte 30 Min",
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 0230eac..cdda62a 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -365,6 +365,9 @@
   "No matches": "No matches",
   "filter by name…": "filter by name…",
   "{n} of {total}": "{n} of {total}",
+  "metrics: top {n}": "metrics: top {n}",
+  "low": "low",
+  "Metrics are probed for the top {n} services by {metric}; the rest are listed as low-traffic. Raise query.landingServiceCap to probe more.": "Metrics are probed for the top {n} services by {metric}; the rest are listed as low-traffic. Raise query.landingServiceCap to probe more.",
   "No services match": "No services match",
   "Last 15 min": "Last 15 min",
   "Last 30 min": "Last 30 min",
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 28927a6..74f6fd6 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -361,6 +361,9 @@
   "No matches": "Sin coincidencias",
   "filter by name…": "filtrar por nombre…",
   "{n} of {total}": "{n} de {total}",
+  "metrics: top {n}": "métricas: top {n}",
+  "low": "bajo",
+  "Metrics are probed for the top {n} services by {metric}; the rest are listed as low-traffic. Raise query.landingServiceCap to probe more.": "Las métricas se obtienen para los {n} servicios principales por {metric}; el resto se listan como de bajo tráfico. Aumente query.landingServiceCap para obtener más.",
   "No services match": "Ningún servicio coincide",
   "Last 15 min": "Últimos 15 min",
   "Last 30 min": "Últimos 30 min",
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 254f381..3c2cabf 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -361,6 +361,9 @@
   "No matches": "Aucune correspondance",
   "filter by name…": "filtrer par nom…",
   "{n} of {total}": "{n} sur {total}",
+  "metrics: top {n}": "métriques : top {n}",
+  "low": "faible",
+  "Metrics are probed for the top {n} services by {metric}; the rest are listed as low-traffic. Raise query.landingServiceCap to probe more.": "Les métriques sont collectées pour les {n} premiers services par {metric} ; les autres sont listés comme à faible trafic. Augmentez query.landingServiceCap pour en collecter davantage.",
   "No services match": "Aucun service ne correspond",
   "Last 15 min": "15 dernières minutes",
   "Last 30 min": "30 dernières minutes",
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 6629ec3..9262f2e 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -361,6 +361,9 @@
   "No matches": "該当なし",
   "filter by name…": "名前で絞り込み…",
   "{n} of {total}": "{total} 件中 {n} 件",
+  "metrics: top {n}": "指標:上位 {n}",
+  "low": "低い",
+  "Metrics are probed for the top {n} services by {metric}; the rest are listed as low-traffic. Raise query.landingServiceCap to probe more.": "{metric} の上位 {n} サービスのみ指標を取得しています。残りは低トラフィックとして表示されます。さらに取得するには query.landingServiceCap を上げてください。",
   "No services match": "一致するサービスがありません",
   "Last 15 min": "直近 15 分",
   "Last 30 min": "直近 30 分",
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 069ade1..b422261 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -361,6 +361,9 @@
   "No matches": "일치 항목 없음",
   "filter by name…": "이름으로 필터링…",
   "{n} of {total}": "{total}건 중 {n}건",
+  "metrics: top {n}": "지표: 상위 {n}",
+  "low": "낮음",
+  "Metrics are probed for the top {n} services by {metric}; the rest are listed as low-traffic. Raise query.landingServiceCap to probe more.": "{metric} 기준 상위 {n}개 서비스만 지표를 수집합니다. 나머지는 낮은 트래픽으로 표시됩니다. 더 수집하려면 query.landingServiceCap을 높이세요.",
   "No services match": "일치하는 서비스가 없습니다",
   "Last 15 min": "최근 15분",
   "Last 30 min": "최근 30분",
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index b3514fe..824bae0 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -361,6 +361,9 @@
   "No matches": "Nenhuma correspondência",
   "filter by name…": "filtrar por nome…",
   "{n} of {total}": "{n} de {total}",
+  "metrics: top {n}": "métricas: top {n}",
+  "low": "baixo",
+  "Metrics are probed for the top {n} services by {metric}; the rest are listed as low-traffic. Raise query.landingServiceCap to probe more.": "As métricas são coletadas para os {n} principais serviços por {metric}; os demais são listados como de baixo tráfego. Aumente query.landingServiceCap para coletar mais.",
   "No services match": "Nenhum serviço corresponde",
   "Last 15 min": "Últimos 15 min",
   "Last 30 min": "Últimos 30 min",
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index 83ee593..839fb80 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -361,6 +361,9 @@
   "No matches": "无匹配",
   "filter by name…": "按名称过滤…",
   "{n} of {total}": "{n} / {total}",
+  "metrics: top {n}": "指标:前 {n}",
+  "low": "偏低",
+  "Metrics are probed for the top {n} services by {metric}; the rest are listed as low-traffic. Raise query.landingServiceCap to probe more.": "仅为按 {metric} 排名前 {n} 的服务采集指标;其余按低流量列出。如需采集更多,请调高 query.landingServiceCap。",
   "No services match": "无匹配的服务",
   "Last 15 min": "最近 15 分钟",
   "Last 30 min": "最近 30 分钟",
diff --git a/apps/ui/src/layer/LayerServiceSelector.vue b/apps/ui/src/layer/LayerServiceSelector.vue
index 8cb63aa..b96d101 100644
--- a/apps/ui/src/layer/LayerServiceSelector.vue
+++ b/apps/ui/src/layer/LayerServiceSelector.vue
@@ -51,6 +51,22 @@ const props = withDefaults(
      *  "ActiveMQ clusters", "Databases"). Falls back to the generic
      *  "Service" when the layer defines no alias. */
     serviceLabel?: string;
+    /** Full layer roster (id + name for EVERY service). When supplied,
+     *  the picker lists the WHOLE layer — services beyond the
+     *  metric-probed `services` set (those that ranked below
+     *  `query.landingServiceCap` on the order-by metric) render as
+     *  "low <orderBy>" tail rows instead of being hidden, so every
+     *  service stays browsable / searchable / selectable regardless of
+     *  the metric fan-out cap. */
+    roster?: ReadonlyArray<{ id: string; name: string }>;
+    /** Order-by metric key — labels the tail rows ("low RPM"). */
+    orderBy?: string;
+    /** Total services in the layer (landing aggregates). When it exceeds
+     *  the probed `services` count, the landing capped the metric fan-out
+     *  at `query.landingServiceCap`: the top rows carry metrics, the rest
+     *  list as "low <orderBy>". Surfaced as a "metrics: top N" chip so
+     *  the trim is never silent. */
+    totalCount?: number;
   }>(),
   {
     accent: 'var(--sw-accent)',
@@ -66,10 +82,47 @@ const emit = defineEmits<{ (e: 'select', id: string): void }>();
 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.
+type PickerRow =
+  | { kind: 'probed'; id: string; name: string; row: LandingServiceRow }
+  | { kind: 'tail'; id: string; name: string };
+
+// Probed rows first (already sorted by orderBy from the BFF), then the
+// roster tail that wasn't metric-probed. Without a roster prop the
+// behaviour is unchanged — just the probed set.
+const allRows = computed<PickerRow[]>(() => {
+  const probed: PickerRow[] = props.services.map((r) => ({
+    kind: 'probed',
+    id: r.serviceId,
+    name: r.serviceName,
+    row: r,
+  }));
+  const roster = props.roster;
+  if (!roster || roster.length === 0) return probed;
+  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 }));
+  return [...probed, ...tail];
+});
+
+// Display label for the order-by metric, used in the "low <metric>"
+// tail message. Prefer the matching column's header; fall back to the
+// catalog short label, then the raw key.
+const orderByLabel = computed(() => {
+  const ob = props.orderBy;
+  if (!ob) return '';
+  const col = props.columns.find((c) => c.metric === ob);
+  return col?.label ?? metricMeta(ob).label ?? ob;
+});
+
+const probedCount = computed(() => props.services.length);
+
 const filtered = computed(() => {
   const q = filter.value.trim().toLowerCase();
-  if (q.length === 0) return props.services;
-  return props.services.filter((s) => s.serviceName.toLowerCase().includes(q));
+  if (q.length === 0) return allRows.value;
+  return allRows.value.filter((r) => r.name.toLowerCase().includes(q));
 });
 const pageCount = computed(() => Math.max(1, Math.ceil(filtered.value.length / props.pageSize)));
 const currentPage = computed(() => Math.min(page.value, pageCount.value - 1));
@@ -94,7 +147,12 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
         spellcheck="false"
         autocomplete="off"
       />
-      <span class="count">{{ t('{n} of {total}', { n: filtered.length, total: services.length }) }}</span>
+      <span class="count">{{ t('{n} of {total}', { n: filtered.length, total: allRows.length }) }}</span>
+      <span
+        v-if="allRows.length > probedCount"
+        class="count capped"
+        :title="t('Metrics are probed for the top {n} services by {metric}; the rest are listed as low-traffic. Raise query.landingServiceCap to probe more.', { n: probedCount, metric: orderByLabel })"
+      >{{ t('metrics: top {n}', { n: probedCount }) }}</span>
     </header>
     <table class="sw-table picker-table">
       <thead>
@@ -111,31 +169,61 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
         </tr>
       </thead>
       <tbody>
-        <tr
-          v-for="row in visible"
-          :key="row.serviceId"
-          class="row"
-          :class="{ active: row.serviceId === selectedId }"
-          @click="emit('select', row.serviceId)"
-        >
-          <td class="svc-col" :title="row.serviceName">
-            <span class="pulse" :style="{ background: colorForStatus(statusForMetrics(row.metrics)) }" />
-            <span v-if="identity(row.serviceName).cluster" class="group-chip">
-              <span class="chip-alias">{{ identity(row.serviceName).clusterAlias }}</span>
-              <span class="chip-val">{{ identity(row.serviceName).cluster }}</span>
-            </span>
-            <span class="name-text">{{ row.shortName || identity(row.serviceName).display }}</span>
-          </td>
-          <td
-            v-for="c in columns"
-            :key="c.metric"
-            class="num"
-            :class="{ muted: row.metrics[c.metric] == null }"
-            :style="{ color: thresholdColor(c.metric, row.metrics[c.metric] ?? null) ?? undefined }"
+        <template v-for="r in visible" :key="r.id">
+          <tr
+            v-if="r.kind === 'probed'"
+            class="row"
+            :class="{ active: r.id === selectedId }"
+            @click="emit('select', r.id)"
           >
-            {{ fmtMetric(row.metrics[c.metric]) }}
-          </td>
-        </tr>
+            <td class="svc-col" :title="r.name">
+              <span class="pulse" :style="{ background: colorForStatus(statusForMetrics(r.row.metrics)) }" />
+              <span v-if="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>
+            </td>
+            <td
+              v-for="c in columns"
+              :key="c.metric"
+              class="num"
+              :class="{ muted: r.row.metrics[c.metric] == null }"
+              :style="{ color: thresholdColor(c.metric, r.row.metrics[c.metric] ?? null) ?? undefined }"
+            >
+              {{ fmtMetric(r.row.metrics[c.metric]) }}
+            </td>
+          </tr>
+          <!-- Below the metric cap: roster-only row, no numbers probed.
+               Still selectable — picking it loads that service's own
+               dashboard (which probes per-service metrics directly). -->
+          <tr
+            v-else
+            class="row tail"
+            :class="{ active: r.id === selectedId }"
+            @click="emit('select', r.id)"
+          >
+            <td class="svc-col" :title="r.name">
+              <span class="pulse tail-dot" />
+              <span v-if="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>
+            </td>
+            <!-- "low" sits in the order-by column (that's the metric it
+                 ranked low on); the others show "—" — they were never
+                 probed, so they're unknown, not zero. -->
+            <td
+              v-for="c in columns"
+              :key="c.metric"
+              class="num"
+              :class="{ 'low-tail': c.metric === orderBy, muted: c.metric !== orderBy }"
+            >
+              {{ c.metric === orderBy ? t('low') : '—' }}
+            </td>
+          </tr>
+        </template>
         <tr v-if="visible.length === 0">
           <td :colspan="columns.length + 1" class="empty">
             {{ t('No services match') }} <code>{{ filter }}</code>.
@@ -188,6 +276,11 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
   margin-left: auto;
   font-variant-numeric: tabular-nums;
 }
+.count.capped {
+  margin-left: 8px;
+  color: var(--sw-warn);
+  cursor: help;
+}
 .picker-table {
   width: 100%;
   font-size: 11.5px;
@@ -217,6 +310,23 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
 .picker-table td.muted {
   color: var(--sw-fg-3);
 }
+.picker-table tr.tail td {
+  /* The unprobed long-tail reads a notch quieter than measured rows. */
+  opacity: 0.8;
+}
+.picker-table td.low-tail {
+  text-align: right;
+  color: var(--sw-fg-3);
+  font-size: 10.5px;
+  font-style: italic;
+  letter-spacing: 0.02em;
+}
+.pulse.tail-dot {
+  /* Neutral grey, never green: this service was NOT measured, so its
+     dot must not read as "healthy". */
+  background: var(--sw-fg-3);
+  opacity: 0.5;
+}
 .picker-table tr.row {
   cursor: pointer;
 }
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index fbf4e3c..40ed0e6 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -29,7 +29,7 @@
 import { computed, onBeforeUnmount, ref, watch } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
-import type { LayerDef } from '@skywalking-horizon-ui/api-client';
+import type { LayerDef, LandingServiceRow } from '@skywalking-horizon-ui/api-client';
 import { bffClient } from '@/api/client';
 import { useAuthStore } from '@/state/auth';
 import Icon from '@/components/icons/Icon.vue';
@@ -268,13 +268,37 @@ const aggregates = computed(() =>
 // null or no longer in the roster.
 const { selectedId, setSelected } = useSelectedService();
 const sampledServices = computed(() => landing.data.value?.sampledRows ?? landing.rows.value ?? []);
-const selectorColumns = computed(() => safeCfg.value.columns);
-const selectedRow = computed(
-  () =>
-    sampledServices.value.find((s) => s.serviceId === selectedId.value) ??
-    sampledServices.value[0] ??
-    null,
+// Total services in the layer — when it exceeds the probed sample, the
+// landing capped the metric fan-out (query.landingServiceCap) and the
+// selector surfaces "top N of M".
+const layerServiceTotal = computed<number | undefined>(
+  () => landing.data.value?.aggregates?.serviceCount ?? undefined,
 );
+const selectorColumns = computed(() => safeCfg.value.columns);
+// Full service roster (the layer's REAL catalog, independent of landing's
+// top-N sample which misses low-traffic services / anything beyond the
+// landingServiceCap). A URL `?service=` is validated against THIS, not the
+// sample, and `selectedRow` resolves an off-sample selection from it.
+const { services: fullRoster, isLoading: rosterLoading } = useLayerServices(layerKey);
+const selectedRow = computed<LandingServiceRow | null>(() => {
+  const id = selectedId.value;
+  if (id) {
+    // The sampled row carries metrics — prefer it.
+    const inSample = sampledServices.value.find((s) => s.serviceId === id);
+    if (inSample) return inSample;
+    // Valid but off-sample (low-traffic, or beyond landingServiceCap):
+    // resolve the NAME from the full roster so the header + KPI strip
+    // reflect the ACTUAL selected service the dashboard queries — not the
+    // top sampled one. Metrics aren't in the sample, so the KPIs show
+    // dashes (honest "not probed") rather than another service's numbers.
+    const inRoster = fullRoster.value.find((s) => s.id === id);
+    if (inRoster) {
+      return { serviceId: inRoster.id, serviceName: inRoster.name, metrics: {} };
+    }
+  }
+  // No (or genuinely stale) selection → default to the first sampled row.
+  return sampledServices.value[0] ?? null;
+});
 const selectedParsed = computed(() => parseServiceName(selectedRow.value?.serviceName));
 const selectedGroup = computed(() => selectedParsed.value.group);
 // Switch-button label — base name only when the service has a group
@@ -304,12 +328,6 @@ const isZipkinTrace = computed<boolean>(() => {
   return scopeSegment.value === 'trace' && layer.value?.traces?.source === 'zipkin';
 });
 
-// Full service roster (the layer's REAL catalog, independent of landing's
-// top-N sample which misses low-traffic services). A URL `?service=` is
-// validated against THIS, not the sample — otherwise a valid but
-// low-traffic deep link is wrongly treated as stale.
-const { services: fullRoster, isLoading: rosterLoading } = useLayerServices(layerKey);
-
 // Keep the URL-backed service selection honest for every page that
 // uses the shell picker. A `?service=` outside the landing sample is
 // trusted when it exists in the full roster; only a genuinely stale id
@@ -585,13 +603,16 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
          Sits below the General header so the page reads top-to-bottom:
          layer identity → expanded service picker → sub-route body. -->
     <LayerServiceSelector
-      v-if="layer && pickerOpen && sampledServices.length > 0 && !viewOwnsServiceSelector"
+      v-if="layer && pickerOpen && (sampledServices.length > 0 || fullRoster.length > 0) && !viewOwnsServiceSelector"
       :services="sampledServices"
+      :roster="fullRoster"
+      :order-by="safeCfg.orderBy"
       :columns="selectorColumns"
       :selected-id="selectedId"
       :accent="layer.color"
       :naming-rule="layer.naming ?? null"
       :service-label="layer.slots.services"
+      :total-count="layerServiceTotal"
       @select="pickService"
     />
 
diff --git a/apps/ui/src/layer/useLayerLanding.ts b/apps/ui/src/layer/useLayerLanding.ts
index 246b21e..16ccca7 100644
--- a/apps/ui/src/layer/useLayerLanding.ts
+++ b/apps/ui/src/layer/useLayerLanding.ts
@@ -52,8 +52,6 @@ export function useLayerLanding(
     topN: cfg.value.topN,
     orderBy: cfg.value.orderBy,
     columns: cfg.value.columns,
-    spark: cfg.value.spark,
-    throughput: cfg.value.throughput,
   }));
   const rangeRef = range ?? computed<LandingRange | null>(() => null);
   const rangeKey = computed(() => {
diff --git a/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts b/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
index 7b86a2b..eacc193 100644
--- a/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
+++ b/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
@@ -159,7 +159,11 @@ export function useLayerDashboard(
       entityRefs.instance ?? computed(() => null),
       entityRefs.endpoint ?? computed(() => null),
       rangeKey,
-      computed(() => widgetsList?.value.map((w) => w.id).join('|') ?? null),
+      // Key on the FULL widget config, not just ids: a remote sync or
+      // preview edit that keeps a widget's id but changes its MQE
+      // expressions / type must refire — an id-only key would serve the
+      // stale (wrong-expression) data from cache.
+      computed(() => (widgetsList?.value ? JSON.stringify(widgetsList.value) : null)),
     ],
     queryFn: async () => {
       const total = widgetsList?.value.length ?? 0;
diff --git a/apps/ui/src/render/overview/useOverviewDashboard.ts b/apps/ui/src/render/overview/useOverviewDashboard.ts
index 5210747..c344ba0 100644
--- a/apps/ui/src/render/overview/useOverviewDashboard.ts
+++ b/apps/ui/src/render/overview/useOverviewDashboard.ts
@@ -143,7 +143,10 @@ export function useOverviewDashboard(idRef: Ref<string>) {
       const entries = Array.from(layerRequests.value.entries());
       const range = rangeKey.value;
       return entries.map(([layer, reqs]) => ({
-        queryKey: ['overview-dashboard-data', idRef.value, layer, range],
+        // Include the MQE column set (`reqs`), not just the overview id:
+        // a remote sync or preview edit that keeps the id but changes a
+        // widget's MQE must refire, or the cache serves stale data.
+        queryKey: ['overview-dashboard-data', idRef.value, layer, range, JSON.stringify(reqs)],
         queryFn: () => {
           /* Service-count KPIs read from `aggregates.serviceCount`
            * — strip them from the MQE column list to avoid sending
diff --git a/apps/ui/src/state/setup.ts b/apps/ui/src/state/setup.ts
index b596bb2..22e506f 100644
--- a/apps/ui/src/state/setup.ts
+++ b/apps/ui/src/state/setup.ts
@@ -167,7 +167,6 @@ export function defaultLandingFor(
   }
   for (const c of ovCols) combined.push(c);
 
-  const headline = ovIds[0] ?? orderBy;
   return {
     priority: defaultPriority(layerKey),
     topN: 5,
@@ -180,11 +179,6 @@ export function defaultLandingFor(
     // metrics are SERVICE_INSTANCE-only and show as `—` on the
     // Service page header).
     headerColumns: headerCols,
-    spark: { metric: headline, height: 28 },
-    throughput: {
-      metric: headline,
-      aggregation: defaultAggregationFor(headline),
-    },
     // Flat list of overview metric ids (legacy back-compat for any
     // code path still reading it). Same set, flattened from groups.
     overviewMetrics: ovIds.length > 0 ? ovIds : [orderBy],
diff --git a/apps/ui/src/utils/metricCatalog.ts b/apps/ui/src/utils/metricCatalog.ts
index df555ae..07a265f 100644
--- a/apps/ui/src/utils/metricCatalog.ts
+++ b/apps/ui/src/utils/metricCatalog.ts
@@ -382,8 +382,6 @@ interface DefaultLandingSet {
   columns: Array<{ metric: string; label?: string; unit?: string }>;
   /** Metric key used to rank the top-N. */
   orderBy: string;
-  /** Sparkline metric (defaults to `orderBy` when omitted). */
-  spark?: string;
 }
 
 const LAYER_TYPE_DEFAULTS: Record<LayerCategory, DefaultLandingSet> = {
@@ -412,7 +410,6 @@ const LAYER_TYPE_DEFAULTS: Record<LayerCategory, DefaultLandingSet> = {
       { metric: 'k8s.restart' },
     ],
     orderBy: 'k8s.cpu',
-    spark: 'k8s.cpu',
   },
   browser: {
     columns: [
@@ -422,7 +419,6 @@ const LAYER_TYPE_DEFAULTS: Record<LayerCategory, DefaultLandingSet> = {
       { metric: 'browser.js-err' },
     ],
     orderBy: 'browser.pv',
-    spark: 'browser.pv',
   },
   database: {
     columns: [
@@ -432,7 +428,6 @@ const LAYER_TYPE_DEFAULTS: Record<LayerCategory, DefaultLandingSet> = {
       { metric: 'db.conn' },
     ],
     orderBy: 'db.qps',
-    spark: 'db.qps',
   },
   mq: {
     columns: [
@@ -441,7 +436,6 @@ const LAYER_TYPE_DEFAULTS: Record<LayerCategory, DefaultLandingSet> = {
       { metric: 'mq.consumer-lag' },
     ],
     orderBy: 'mq.msg-rate',
-    spark: 'mq.msg-rate',
   },
   faas: {
     columns: [
@@ -451,7 +445,6 @@ const LAYER_TYPE_DEFAULTS: Record<LayerCategory, DefaultLandingSet> = {
       { metric: 'err' },
     ],
     orderBy: 'faas.invocations',
-    spark: 'faas.invocations',
   },
   genai: {
     columns: [
@@ -460,7 +453,6 @@ const LAYER_TYPE_DEFAULTS: Record<LayerCategory, DefaultLandingSet> = {
       { metric: 'genai.latency' },
     ],
     orderBy: 'genai.req',
-    spark: 'genai.tokens',
   },
 };
 
@@ -485,11 +477,6 @@ export function defaultOrderByForLayer(layerKey: string): string {
   return LAYER_TYPE_DEFAULTS[layerCategory(layerKey)].orderBy;
 }
 
-/** Sparkline metric for the landing card (falls back to the order-by key). */
-export function defaultSparkForLayer(layerKey: string): string {
-  const set = LAYER_TYPE_DEFAULTS[layerCategory(layerKey)];
-  return set.spark ?? set.orderBy;
-}
 
 /**
  * Generic RPC-shaped metrics every layer can render — surfaced as a
diff --git a/docs/setup/horizon-yaml.md b/docs/setup/horizon-yaml.md
index c2f8840..9a99286 100644
--- a/docs/setup/horizon-yaml.md
+++ b/docs/setup/horizon-yaml.md
@@ -14,6 +14,7 @@ This page is the top-level map. Each subsection has its own detail page:
 | `audit` | Audit log file path. | [audit](audit.md) |
 | `setup` / `alarms` | State file paths. | [files](files.md) |
 | `debugLog` | Wire-level request/response log for troubleshooting. | [debugLog](debug-log.md) |
+| `query` | Per-request query limits (the layer-landing service cap). | [below](#query-limits) |
 
 ## Top-level shape
 
@@ -93,6 +94,40 @@ Two changes require a process restart:
 - `server.host`, `server.port` — the listener already bound.
 - Capability probes — the OAP schema introspection cache is per-process.
 
+## Query limits
+
+```yaml
+query:
+  landingServiceCap: 100   # default
+```
+
+`query.landingServiceCap` bounds how many services a **layer landing** runs
+column-metric MQE for, per request. The service picker always **lists every
+service** in the layer, but only fetches metric columns for up to this many —
+and when a layer has more, the BFF runs one cheap single-metric pass (the
+landing's order-by column over every service) to pick the **true top-N**, then
+fetches the full columns for just those. Services below the cap still appear in
+the picker, showing **`low`** in the order-by column (and `—` for the others,
+which were never probed) — every service stays browsable and selectable. The
+picker header reads **"metrics: top N"** so the metric trim is never silent.
+
+- **Default `100`.** Most layers have fewer services and render in full.
+- **Raise it** (e.g. `300`, `500`) if your OAP and storage backend can take
+  the larger fan-out and you want metrics for more services at once.
+- **Lower it** to protect a modest deployment from heavy landings.
+
+**What it bounds.** The cap limits the **full-column** MQE fan-out (the
+expensive part — every configured column × service). When a layer exceeds
+it, the **true top-N** is found by a single cheap pass that evaluates only
+the order-by column for every service — so on a very large layer that one
+ranking pass still scales with the service count (it's one metric, batched
+through a bounded-concurrency pool, not the full column set). The cap is
+therefore a bound on the *expensive* fan-out, not a hard ceiling on total
+OAP traffic. If you need a hard ceiling on a pathological layer, lower the
+cap and pair it with a tighter OAP rate limit.
+
+Hot-reloadable — a change takes effect on the next landing request.
+
 ## Cross-references
 
 - A field that affects user-visible behavior at runtime is also visible on **Admin → Auth Status** (`/admin/auth-status`) for live verification — see [Admin Pages](../access-control/admin-pages.md).
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 74ad536..5a35336 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -36,7 +36,6 @@ export type {
   LayerConfig,
   SetupResponse,
   SetupSavePayload,
-  ThroughputConfig,
 } from './setup.js';
 export type { LandingAggregates, LandingResponse, LandingServiceRow } from './landing.js';
 export type {
diff --git a/packages/api-client/src/landing.ts b/packages/api-client/src/landing.ts
index 88d2757..48fb2ef 100644
--- a/packages/api-client/src/landing.ts
+++ b/packages/api-client/src/landing.ts
@@ -34,12 +34,6 @@ export interface LandingServiceRow {
    * renders `null` as a muted em-dash.
    */
   metrics: Record<string, number | null>;
-  /**
-   * Sparkline series for `cfg.spark.metric`, when configured. Same order
-   * as the `step` buckets returned by OAP — left-to-right oldest-to-newest.
-   * `null` entries mark missing samples.
-   */
-  spark?: Array<number | null>;
 }
 
 /**
@@ -59,15 +53,6 @@ export interface LandingAggregates {
    *  metrics, avg for ratio/latency metrics. Used by the per-layer
    *  header to render a trend line under each KPI. */
   seriesByMetric: Record<string, Array<number | null>>;
-  /** Aggregated sparkline series for the `throughput.metric` (or `spark`)
-   *  using the throughput aggregation. `null` when not configured. */
-  spark?: Array<number | null> | null;
-  /** Echo of the throughput metric key the spark series was computed
-   *  against (so the UI can label the tile). */
-  throughputMetric?: string;
-  /** Value of the throughput metric across the layer (null when
-   *  unconfigured or unmapped). */
-  throughputValue?: number | null;
 }
 
 export interface LandingResponse {
@@ -83,10 +68,10 @@ export interface LandingResponse {
   durationEnd: string;
   rows: LandingServiceRow[];
   /**
-   * All services the BFF probed for this layer (up to its internal cap,
-   * currently 25). `rows` is a sorted+sliced subset of this — the Overview
-   * card uses `rows`, the per-layer constellation / table uses the full
-   * `sampledRows` so deep-dive views don't lose context.
+   * Every service the BFF probed for this layer (up to `query.landingServiceCap`,
+   * default 100; the true top-N by `orderBy` when the layer exceeds it).
+   * `rows` is a sorted+sliced subset (the top-`topN`); the per-layer service
+   * picker uses the full `sampledRows` so it can list the whole layer.
    */
   sampledRows?: LandingServiceRow[];
   /** Whole-layer rollup KPIs for the Overview strip tile. */
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index c55f161..d2f7f7c 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -199,16 +199,11 @@ export interface OverviewGroup {
  *   - `metrics: string[]` of column-key refs → resolved against
  *                                     `layer-header.columns` then
  *                                     wrapped as above.
- *   - `throughput` / `spark` (oldest) → resolved same way.
  */
 export interface LayerOverviewConfig {
   groups?: OverviewGroup[];
   /** @deprecated — wrapped into a single auto-size group on load. */
   metrics?: OverviewMetric[];
-  /** @deprecated — migrated to the first group's first metric. */
-  throughput?: string;
-  /** @deprecated — sparkline follows the headline metric. */
-  spark?: string;
 }
 
 export interface LayerDef {
diff --git a/packages/api-client/src/setup.ts b/packages/api-client/src/setup.ts
index 43b091e..5397907 100644
--- a/packages/api-client/src/setup.ts
+++ b/packages/api-client/src/setup.ts
@@ -74,25 +74,6 @@ export interface LandingColumn {
   precision?: number;
 }
 
-/**
- * Headline throughput metric for the per-layer KPI strip tile. Optional —
- * when omitted, the strip falls back to the `orderBy` column's value
- * (also aggregated per the column's `aggregation` field).
- */
-export interface ThroughputConfig {
-  /** Short metric key (must match a column or stand alone). */
-  metric: string;
-  /** Display label override (default falls through to the metric catalog). */
-  label?: string;
-  unit?: string;
-  /** MQE override — same semantics as `LandingColumn.mqe`. */
-  mqe?: string;
-  /** Aggregation across services (defaults to `sum`). */
-  aggregation?: AggregationKind;
-  scale?: number;
-  precision?: number;
-}
-
 export interface LandingConfig {
   /** Lower number → higher on the Overview. */
   priority: number;
@@ -101,10 +82,6 @@ export interface LandingConfig {
   /** Metric key used to rank the top-N. */
   orderBy: string;
   columns: LandingColumn[];
-  /** Optional sparkline column. */
-  spark?: { metric: string; height: number };
-  /** Optional headline metric for the per-layer KPI strip tile. */
-  throughput?: ThroughputConfig;
   /** @deprecated kept for back-compat; new code reads `overviewGroups`. */
   overviewMetrics?: string[];
   /** Explicit per-layer page header columns — distinct from `columns`


(skywalking-horizon-ui) 01/02: feat(layer): instance topology, remote-first templates, preview everywhere

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/instance-topology-preview
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 8d51e2d806a43f1420860805ecb0cd78ebdac5e4
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sat Jun 6 22:03:01 2026 +0800

    feat(layer): instance topology, remote-first templates, preview everywhere
    
    Instance topology
    - Edge-driven drill-down from the service map into a client<->server
      instance map: relationship-aware service pickers (drawn from the call
      graph, collapsing to plain text when there is no real choice),
      per-service grouping boxes, a node-ring colour legend read from the
      metric thresholds, aligned client|server edge metrics, and an
      instance-dashboard jump that is hidden for conjectured / cross-layer
      callees that have no dashboard here. Enabled on general / mesh /
      k8s_service / cilium_service, admin-configurable under the Topology
      scope; metrics validated against the live demo OAP.
    
    Remote-first templates
    - Runtime renders only what is published to OAP (or the in-code default);
      bundled disk templates are never a render-time fallback - they reach the
      UI only via boot-seed / admin reset / preview. An unreachable template
      store blocks the dashboard / overview / topology surfaces with a banner
      instead of silently serving bundled. Menu and config bundle enumerate
      from remote rows; per-page routes block (not default) on unreachable or
      disabled templates.
    
    Preview
    - The admin Preview button now drives every template-rendered page -
      overview detail, topology, instance map, endpoint dependency, traces,
      network profiling - by sending the draft config to the BFF so its MQE
      runs against live OAP. Preview and the absent-remote path stay strictly
      separate: a draft renders only in ?mode=preview.
    
    Admin editors
    - Layer / overview / translation editors no longer auto-load bundled when
      remote is absent; they show a "no published version" panel and load
      bundled only on explicit Reset to bundled.
    
    Misc
    - Shared, auto-refresh-wired layer service roster cache.
    - Fixed cross-layer service name resolution and the template-store-down
      status text; docs (layer-templates) and CHANGELOG updated.
---
 CHANGELOG.md                                       |  61 ++
 .../layers/cilium_service.i18n.de.json             |  12 +-
 .../layers/cilium_service.i18n.es.json             |  12 +-
 .../layers/cilium_service.i18n.fr.json             |  12 +-
 .../layers/cilium_service.i18n.ja.json             |  12 +-
 .../layers/cilium_service.i18n.ko.json             |  12 +-
 .../layers/cilium_service.i18n.pt.json             |  12 +-
 .../layers/cilium_service.i18n.zh-CN.json          |  12 +-
 .../bundled_templates/layers/cilium_service.json   |  12 +-
 .../bundled_templates/layers/general.i18n.de.json  |  21 +-
 .../bundled_templates/layers/general.i18n.es.json  |  21 +-
 .../bundled_templates/layers/general.i18n.fr.json  |  21 +-
 .../bundled_templates/layers/general.i18n.ja.json  |  21 +-
 .../bundled_templates/layers/general.i18n.ko.json  |  21 +-
 .../bundled_templates/layers/general.i18n.pt.json  |  21 +-
 .../layers/general.i18n.zh-CN.json                 |  21 +-
 apps/bff/src/bundled_templates/layers/general.json |  21 +-
 .../layers/k8s_service.i18n.de.json                |  17 +-
 .../layers/k8s_service.i18n.es.json                |  17 +-
 .../layers/k8s_service.i18n.fr.json                |  17 +-
 .../layers/k8s_service.i18n.ja.json                |  17 +-
 .../layers/k8s_service.i18n.ko.json                |  17 +-
 .../layers/k8s_service.i18n.pt.json                |  17 +-
 .../layers/k8s_service.i18n.zh-CN.json             |  17 +-
 .../src/bundled_templates/layers/k8s_service.json  |  17 +-
 .../src/bundled_templates/layers/mesh.i18n.de.json |  21 +-
 .../src/bundled_templates/layers/mesh.i18n.es.json |  21 +-
 .../src/bundled_templates/layers/mesh.i18n.fr.json |  21 +-
 .../src/bundled_templates/layers/mesh.i18n.ja.json |  21 +-
 .../src/bundled_templates/layers/mesh.i18n.ko.json |  21 +-
 .../src/bundled_templates/layers/mesh.i18n.pt.json |  21 +-
 .../bundled_templates/layers/mesh.i18n.zh-CN.json  |  21 +-
 apps/bff/src/bundled_templates/layers/mesh.json    |  21 +-
 .../bundled_templates/overviews/mesh.i18n.de.json  |  40 +-
 .../bundled_templates/overviews/mesh.i18n.fr.json  |  40 +-
 .../overviews/services.i18n.de.json                |  84 ++-
 .../overviews/services.i18n.fr.json                |  84 ++-
 apps/bff/src/http/config/bundle.ts                 |  63 +-
 apps/bff/src/http/config/dashboard.ts              |  29 +-
 apps/bff/src/http/config/overview.ts               |  58 +-
 apps/bff/src/http/query/dashboard.ts               |  21 +-
 apps/bff/src/http/query/ebpf.ts                    |  24 +-
 apps/bff/src/http/query/endpoint-dependency.ts     |  25 +-
 apps/bff/src/http/query/instance-topology.ts       | 483 ++++++++++++
 apps/bff/src/http/query/menu.ts                    | 156 ++--
 apps/bff/src/http/query/topology.ts                |  39 +-
 apps/bff/src/http/query/trace.ts                   |  27 +-
 apps/bff/src/logic/dashboard/defaults.ts           |  20 +-
 apps/bff/src/logic/layers/effective.ts             | 105 +++
 apps/bff/src/logic/layers/loader.ts                |  18 +-
 apps/bff/src/logic/layers/preview.ts               | 117 +++
 apps/bff/src/logic/overview/effective.ts           |  92 +++
 apps/bff/src/logic/templates/overlay.ts            |  78 ++
 apps/bff/src/rbac/route-policy.ts                  |   1 +
 apps/bff/src/server.ts                             |  48 +-
 apps/ui/src/api/client.ts                          |   6 +-
 apps/ui/src/api/scopes/layer.ts                    |  35 +
 apps/ui/src/api/scopes/network-profile.ts          |   2 +
 apps/ui/src/api/scopes/trace.ts                    |   2 +
 apps/ui/src/controls/configBundle.ts               |  10 +
 apps/ui/src/controls/previewConfig.ts              |  55 ++
 .../features/admin/_shared/TemplateStatusBadge.vue |   2 +-
 .../admin/layer-templates/LayerDashboardsAdmin.vue | 420 ++++++++++-
 .../overview-templates/OverviewTemplatesAdmin.vue  |  54 +-
 .../admin/translations/TranslationsView.vue        |  14 +-
 apps/ui/src/i18n/locales/en.json                   |  28 +-
 .../useLayerEndpointDependency.ts                  |   6 +-
 .../layer/profiling/LayerNetworkProfilingView.vue  |   5 +
 .../service-map/LayerInstanceTopologyView.vue      | 831 +++++++++++++++++++++
 .../src/layer/service-map/LayerServiceMapView.vue  |  45 +-
 apps/ui/src/layer/service-map/LayerTopologyTab.vue |  46 ++
 ...{useLayerTopology.ts => useInstanceTopology.ts} |  52 +-
 apps/ui/src/layer/service-map/useLayerTopology.ts  |   9 +-
 apps/ui/src/layer/traces/useLayerTraces.ts         |   5 +
 apps/ui/src/layer/useLayerServices.ts              |  19 +-
 .../ui/src/render/overview/useOverviewDashboard.ts |  18 +-
 apps/ui/src/shell/AppSidebar.vue                   |   4 +-
 apps/ui/src/shell/GlobalConnectivityBanner.vue     |  67 +-
 apps/ui/src/shell/layerFromTemplate.ts             |  22 +-
 apps/ui/src/shell/router/index.ts                  |   4 +-
 docs/customization/layer-templates.md              |  80 +-
 packages/api-client/src/index.ts                   |   4 +
 packages/api-client/src/menu.ts                    |   4 +
 packages/api-client/src/topology.ts                |  67 ++
 84 files changed, 3841 insertions(+), 356 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5a82aa8..9ec7a25 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,37 @@ packages) plus the BFF's `HORIZON_VERSION` default.
 
 ## 0.7.0
 
+### Instance topology
+
+- The per-layer **Topology** map gains an **instance map** drill-down on
+  layers that enable instance topology. Click a call between two services
+  and then **Instance map →** to open it: the instances of each service
+  as two columns (left = client, right = server) with the instance-level
+  calls between them — pan/zoom, animated client→server flow, the same
+  node health-ring + per-call client/server metric sidebar the service
+  map uses, and a node popover with **Open instance dashboard**. A back
+  button returns to the service map; a toolbar pair-picker swaps the two
+  services. The two service pickers are **relationship-aware**, drawn from
+  the service-topology call graph (including conjectured / cross-layer
+  callees like `rcmd:80`, named the same as on the service map): the server
+  list is the chosen client's callees and the client list is the chosen
+  server's callers, each re-deriving when the other changes without
+  resetting your current pick. A side the graph leaves no real choice for
+  (e.g. a single caller) shows as plain text instead of a one-option
+  dropdown. Each service's instances sit inside a labelled grouping box —
+  named with the service, using the same `<group>::` prefix handling as
+  the service map so a name reads identically on both — and a ring-colour
+  legend explains what the node health bands (green → red) mean for the
+  configured ring metric. Labels follow the layer's own terms (e.g.
+  *Pods* on Kubernetes, *Sidecars* on the data plane).
+- **Configurable like the service map.** The Layer-dashboards admin →
+  **Topology** scope now has an **Enable instance topology** toggle and
+  its own node / server-edge / client-edge metric editors, kept visually
+  separate from the service-topology metrics so the two are never
+  confused. Enabled out of the box on **General**, **Service Mesh**,
+  **Kubernetes Service**, and **Cilium Service**; the config rides each
+  layer's topology template (so it travels with template export/import).
+
 ### Dashboard template portability
 
 - Every template admin page — Overview templates, Layer dashboards, and the
@@ -26,6 +57,36 @@ packages) plus the BFF's `HORIZON_VERSION` default.
   templates and their translations are edited on separate pages, so their
   import/export are separate too — each on its own page.)
 
+### Template store reliability
+
+- **Runtime config is strictly what's on OAP.** Layer dashboards, overviews,
+  and topology now render only the version published to OAP's UI-template
+  store (or the in-code minimal default for a layer that has none). The
+  disk-bundled templates reach a running UI **only** by being synced to OAP
+  (first boot / admin reset) or through the admin **Preview** button — they
+  are never a silent live fallback. So an operator always sees the live
+  published config, not a stale bundled copy masquerading as current.
+- **Unreachable template store is a visible block, not a quiet fallback.**
+  When OAP's UI-template host can't be reached, a banner (same red treatment
+  as the OAP-query-unreachable strip) reports it, and the dashboard / overview
+  / topology surfaces stay empty rather than back-filling bundled defaults
+  that could be read as real. The sidebar still navigates so the rest of the
+  app is reachable.
+- The admin **Preview** button now drives **every** template-rendered page —
+  the **overview detail** view and the per-layer **topology** (incl. the
+  **instance map**), **API dependency**, **traces**, and **network-profiling**
+  pages — not just the layer dashboards. Previewing renders the draft's
+  metrics/config against live OAP, so an edit to topology or dependency metrics
+  is visible before you publish. Preview and the absent-remote path stay
+  strictly separate: a draft renders only in `?mode=preview`; normal reads
+  never carry one.
+- **Editors no longer silently fall back to the bundled default.** When a
+  layer / overview / translation has no version published to OAP, the editor
+  shows a *"No published version on OAP"* panel instead of quietly loading the
+  shipped bundled copy as if it were live. Bundled now reaches the editor only
+  when you click **Reset to bundled** — matching the runtime, which renders the
+  published version or blocks, never the bundle.
+
 ### Documentation & release tooling
 
 - The website docs were brought current with the 0.6.0 build and the
diff --git a/apps/bff/src/bundled_templates/layers/cilium_service.i18n.de.json b/apps/bff/src/bundled_templates/layers/cilium_service.i18n.de.json
index ce45bac..6058b72 100644
--- a/apps/bff/src/bundled_templates/layers/cilium_service.i18n.de.json
+++ b/apps/bff/src/bundled_templates/layers/cilium_service.i18n.de.json
@@ -301,7 +301,17 @@
       {
         "label": "Mittlere Latenz"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "HTTP RPM" },
+        { "label": "Mittlere Latenz" }
+      ],
+      "linkServerMetrics": [
+        { "label": "HTTP RPM" },
+        { "label": "Mittlere Latenz" }
+      ]
+    }
   },
   "slots": {
     "services": "Dienste",
diff --git a/apps/bff/src/bundled_templates/layers/cilium_service.i18n.es.json b/apps/bff/src/bundled_templates/layers/cilium_service.i18n.es.json
index 29a164f..c69d7e0 100644
--- a/apps/bff/src/bundled_templates/layers/cilium_service.i18n.es.json
+++ b/apps/bff/src/bundled_templates/layers/cilium_service.i18n.es.json
@@ -60,7 +60,17 @@
       {
         "label": "Latencia media"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM HTTP" },
+        { "label": "Latencia media" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM HTTP" },
+        { "label": "Latencia media" }
+      ]
+    }
   },
   "slots": {
     "services": "Servicios",
diff --git a/apps/bff/src/bundled_templates/layers/cilium_service.i18n.fr.json b/apps/bff/src/bundled_templates/layers/cilium_service.i18n.fr.json
index 8121a55..d2b8af0 100644
--- a/apps/bff/src/bundled_templates/layers/cilium_service.i18n.fr.json
+++ b/apps/bff/src/bundled_templates/layers/cilium_service.i18n.fr.json
@@ -205,7 +205,17 @@
       {
         "label": "Latence moyenne"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM HTTP" },
+        { "label": "Latence moyenne" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM HTTP" },
+        { "label": "Latence moyenne" }
+      ]
+    }
   },
   "slots": {
     "services": "Services",
diff --git a/apps/bff/src/bundled_templates/layers/cilium_service.i18n.ja.json b/apps/bff/src/bundled_templates/layers/cilium_service.i18n.ja.json
index 8e98fa1..0b2d066 100644
--- a/apps/bff/src/bundled_templates/layers/cilium_service.i18n.ja.json
+++ b/apps/bff/src/bundled_templates/layers/cilium_service.i18n.ja.json
@@ -205,7 +205,17 @@
       {
         "label": "平均レイテンシ"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "HTTP RPM" },
+        { "label": "平均レイテンシ" }
+      ],
+      "linkServerMetrics": [
+        { "label": "HTTP RPM" },
+        { "label": "平均レイテンシ" }
+      ]
+    }
   },
   "slots": {
     "services": "サービス",
diff --git a/apps/bff/src/bundled_templates/layers/cilium_service.i18n.ko.json b/apps/bff/src/bundled_templates/layers/cilium_service.i18n.ko.json
index bdd4ecc..defaefe 100644
--- a/apps/bff/src/bundled_templates/layers/cilium_service.i18n.ko.json
+++ b/apps/bff/src/bundled_templates/layers/cilium_service.i18n.ko.json
@@ -205,7 +205,17 @@
       {
         "label": "평균 지연 시간"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "HTTP RPM" },
+        { "label": "평균 지연 시간" }
+      ],
+      "linkServerMetrics": [
+        { "label": "HTTP RPM" },
+        { "label": "평균 지연 시간" }
+      ]
+    }
   },
   "slots": {
     "services": "서비스",
diff --git a/apps/bff/src/bundled_templates/layers/cilium_service.i18n.pt.json b/apps/bff/src/bundled_templates/layers/cilium_service.i18n.pt.json
index bf61a73..85df1db 100644
--- a/apps/bff/src/bundled_templates/layers/cilium_service.i18n.pt.json
+++ b/apps/bff/src/bundled_templates/layers/cilium_service.i18n.pt.json
@@ -207,6 +207,16 @@
       {
         "label": "Latência média"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM HTTP" },
+        { "label": "Latência média" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM HTTP" },
+        { "label": "Latência média" }
+      ]
+    }
   }
 }
diff --git a/apps/bff/src/bundled_templates/layers/cilium_service.i18n.zh-CN.json b/apps/bff/src/bundled_templates/layers/cilium_service.i18n.zh-CN.json
index 373174d..dbbc704 100644
--- a/apps/bff/src/bundled_templates/layers/cilium_service.i18n.zh-CN.json
+++ b/apps/bff/src/bundled_templates/layers/cilium_service.i18n.zh-CN.json
@@ -110,6 +110,16 @@
     "linkServerMetrics": [
       { "label": "HTTP RPM" },
       { "label": "平均延迟" }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "HTTP RPM" },
+        { "label": "平均延迟" }
+      ],
+      "linkServerMetrics": [
+        { "label": "HTTP RPM" },
+        { "label": "平均延迟" }
+      ]
+    }
   }
 }
diff --git a/apps/bff/src/bundled_templates/layers/cilium_service.json b/apps/bff/src/bundled_templates/layers/cilium_service.json
index e4a42d3..42f0701 100644
--- a/apps/bff/src/bundled_templates/layers/cilium_service.json
+++ b/apps/bff/src/bundled_templates/layers/cilium_service.json
@@ -482,6 +482,16 @@
         "unit": "ms",
         "aggregation": "avg"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "id": "httpCpm", "label": "HTTP RPM", "mqe": "cilium_service_instance_protocol_http_call_cpm", "unit": "rpm", "role": "center", "aggregation": "avg" },
+        { "id": "httpRespTime", "label": "Avg Latency", "mqe": "cilium_service_instance_protocol_http_call_duration/cilium_service_instance_protocol_http_call_cpm/1000000", "unit": "ms", "role": "secondary", "aggregation": "avg" }
+      ],
+      "linkServerMetrics": [
+        { "id": "cpm", "label": "HTTP RPM", "mqe": "cilium_service_instance_relation_server_protocol_http_call_cpm", "unit": "rpm", "role": "lineServer", "aggregation": "avg" },
+        { "id": "respTime", "label": "Avg Latency", "mqe": "cilium_service_instance_relation_server_protocol_http_call_duration/cilium_service_instance_relation_server_protocol_http_call_cpm/1000000", "unit": "ms", "aggregation": "avg" }
+      ]
+    }
   }
 }
diff --git a/apps/bff/src/bundled_templates/layers/general.i18n.de.json b/apps/bff/src/bundled_templates/layers/general.i18n.de.json
index c5d0159..95e45a7 100644
--- a/apps/bff/src/bundled_templates/layers/general.i18n.de.json
+++ b/apps/bff/src/bundled_templates/layers/general.i18n.de.json
@@ -722,7 +722,26 @@
       {
         "label": "SLA"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "SLA" },
+        { "label": "Latenz" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "Mittlere Antwortzeit" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "Mittlere Antwortzeit" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ]
+    }
   },
   "endpointDependency": {
     "nodeMetrics": [
diff --git a/apps/bff/src/bundled_templates/layers/general.i18n.es.json b/apps/bff/src/bundled_templates/layers/general.i18n.es.json
index 401184e..5666470 100644
--- a/apps/bff/src/bundled_templates/layers/general.i18n.es.json
+++ b/apps/bff/src/bundled_templates/layers/general.i18n.es.json
@@ -443,7 +443,26 @@
       {
         "label": "SLA"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "SLA" },
+        { "label": "Latencia" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "Tiempo medio de respuesta" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "Tiempo medio de respuesta" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ]
+    }
   },
   "endpointDependency": {
     "nodeMetrics": [
diff --git a/apps/bff/src/bundled_templates/layers/general.i18n.fr.json b/apps/bff/src/bundled_templates/layers/general.i18n.fr.json
index c6d754e..6b7e192 100644
--- a/apps/bff/src/bundled_templates/layers/general.i18n.fr.json
+++ b/apps/bff/src/bundled_templates/layers/general.i18n.fr.json
@@ -444,7 +444,26 @@
       {
         "label": "SLA"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "SLA" },
+        { "label": "Latence" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "Temps de réponse moyen" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "Temps de réponse moyen" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ]
+    }
   },
   "endpointDependency": {
     "nodeMetrics": [
diff --git a/apps/bff/src/bundled_templates/layers/general.i18n.ja.json b/apps/bff/src/bundled_templates/layers/general.i18n.ja.json
index 62b560d..73a345b 100644
--- a/apps/bff/src/bundled_templates/layers/general.i18n.ja.json
+++ b/apps/bff/src/bundled_templates/layers/general.i18n.ja.json
@@ -444,7 +444,26 @@
       {
         "label": "SLA"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "SLA" },
+        { "label": "レイテンシ" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "平均応答時間" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "平均応答時間" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ]
+    }
   },
   "endpointDependency": {
     "nodeMetrics": [
diff --git a/apps/bff/src/bundled_templates/layers/general.i18n.ko.json b/apps/bff/src/bundled_templates/layers/general.i18n.ko.json
index 0a3e28c..a1bbc95 100644
--- a/apps/bff/src/bundled_templates/layers/general.i18n.ko.json
+++ b/apps/bff/src/bundled_templates/layers/general.i18n.ko.json
@@ -444,7 +444,26 @@
       {
         "label": "SLA"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "SLA" },
+        { "label": "지연 시간" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "평균 응답 시간" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "평균 응답 시간" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ]
+    }
   },
   "endpointDependency": {
     "nodeMetrics": [
diff --git a/apps/bff/src/bundled_templates/layers/general.i18n.pt.json b/apps/bff/src/bundled_templates/layers/general.i18n.pt.json
index f4fe9f7..dbfc918 100644
--- a/apps/bff/src/bundled_templates/layers/general.i18n.pt.json
+++ b/apps/bff/src/bundled_templates/layers/general.i18n.pt.json
@@ -450,7 +450,26 @@
       {
         "label": "SLA"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "SLA" },
+        { "label": "Latência" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "Tempo médio de resposta" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "Tempo médio de resposta" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ]
+    }
   },
   "endpointDependency": {
     "nodeMetrics": [
diff --git a/apps/bff/src/bundled_templates/layers/general.i18n.zh-CN.json b/apps/bff/src/bundled_templates/layers/general.i18n.zh-CN.json
index 11a2050..c24500d 100644
--- a/apps/bff/src/bundled_templates/layers/general.i18n.zh-CN.json
+++ b/apps/bff/src/bundled_templates/layers/general.i18n.zh-CN.json
@@ -248,7 +248,26 @@
       { "label": "平均响应时间" },
       { "label": "p95" },
       { "label": "SLA" }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "SLA" },
+        { "label": "延迟" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "平均响应时间" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "平均响应时间" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ]
+    }
   },
   "endpointDependency": {
     "nodeMetrics": [
diff --git a/apps/bff/src/bundled_templates/layers/general.json b/apps/bff/src/bundled_templates/layers/general.json
index c27261d..49e22fe 100644
--- a/apps/bff/src/bundled_templates/layers/general.json
+++ b/apps/bff/src/bundled_templates/layers/general.json
@@ -948,7 +948,26 @@
       { "id": "respTime", "label": "Avg response time", "mqe": "service_relation_client_resp_time", "unit": "ms", "aggregation": "avg" },
       { "id": "p95", "label": "p95", "mqe": "service_relation_client_percentile{p='95'}", "unit": "ms", "aggregation": "avg" },
       { "id": "sla", "label": "SLA", "mqe": "service_relation_client_call_sla/100", "unit": "%", "aggregation": "avg" }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "id": "cpm", "label": "RPM", "mqe": "service_instance_cpm", "unit": "rpm", "role": "center", "aggregation": "avg" },
+        { "id": "sla", "label": "SLA", "mqe": "service_instance_sla/100", "unit": "%", "role": "ring", "aggregation": "avg", "thresholds": { "invertHealth": true, "invertBase": 100, "ok": 0.1, "warn": 1, "danger": 5 } },
+        { "id": "respTime", "label": "Latency", "mqe": "service_instance_resp_time", "unit": "ms", "role": "secondary", "aggregation": "avg" }
+      ],
+      "linkServerMetrics": [
+        { "id": "cpm", "label": "RPM", "mqe": "service_instance_relation_server_cpm", "unit": "rpm", "role": "lineServer", "aggregation": "avg" },
+        { "id": "respTime", "label": "Avg response time", "mqe": "service_instance_relation_server_resp_time", "unit": "ms", "aggregation": "avg" },
+        { "id": "p95", "label": "p95", "mqe": "service_instance_relation_server_percentile{p='95'}", "unit": "ms", "aggregation": "avg" },
+        { "id": "sla", "label": "SLA", "mqe": "service_instance_relation_server_call_sla/100", "unit": "%", "aggregation": "avg" }
+      ],
+      "linkClientMetrics": [
+        { "id": "cpm", "label": "RPM", "mqe": "service_instance_relation_client_cpm", "unit": "rpm", "role": "lineClient", "aggregation": "avg" },
+        { "id": "respTime", "label": "Avg response time", "mqe": "service_instance_relation_client_resp_time", "unit": "ms", "aggregation": "avg" },
+        { "id": "p95", "label": "p95", "mqe": "service_instance_relation_client_percentile{p='95'}", "unit": "ms", "aggregation": "avg" },
+        { "id": "sla", "label": "SLA", "mqe": "service_instance_relation_client_call_sla/100", "unit": "%", "aggregation": "avg" }
+      ]
+    }
   },
   "endpointDependency": {
     "nodeMetrics": [
diff --git a/apps/bff/src/bundled_templates/layers/k8s_service.i18n.de.json b/apps/bff/src/bundled_templates/layers/k8s_service.i18n.de.json
index 8d53bdf..a6f6e4d 100644
--- a/apps/bff/src/bundled_templates/layers/k8s_service.i18n.de.json
+++ b/apps/bff/src/bundled_templates/layers/k8s_service.i18n.de.json
@@ -368,7 +368,22 @@
       {
         "label": "Mittlere Antwortzeit"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "Erfolgsrate" },
+        { "label": "Latenz" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "Mittlere Antwortzeit" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "Mittlere Antwortzeit" }
+      ]
+    }
   },
   "slots": {
     "services": "K8s-Dienste",
diff --git a/apps/bff/src/bundled_templates/layers/k8s_service.i18n.es.json b/apps/bff/src/bundled_templates/layers/k8s_service.i18n.es.json
index 08d6b11..ca06246 100644
--- a/apps/bff/src/bundled_templates/layers/k8s_service.i18n.es.json
+++ b/apps/bff/src/bundled_templates/layers/k8s_service.i18n.es.json
@@ -250,7 +250,22 @@
       {
         "label": "Tiempo medio de respuesta"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "Tasa de éxito" },
+        { "label": "Latencia" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "Tiempo medio de respuesta" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "Tiempo medio de respuesta" }
+      ]
+    }
   },
   "slots": {
     "services": "Servicios de K8s",
diff --git a/apps/bff/src/bundled_templates/layers/k8s_service.i18n.fr.json b/apps/bff/src/bundled_templates/layers/k8s_service.i18n.fr.json
index 33e3637..9351509 100644
--- a/apps/bff/src/bundled_templates/layers/k8s_service.i18n.fr.json
+++ b/apps/bff/src/bundled_templates/layers/k8s_service.i18n.fr.json
@@ -255,7 +255,22 @@
       {
         "label": "Temps de réponse moyen"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "Taux de succès" },
+        { "label": "Latence" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "Temps de réponse moyen" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "Temps de réponse moyen" }
+      ]
+    }
   },
   "slots": {
     "services": "Services K8s",
diff --git a/apps/bff/src/bundled_templates/layers/k8s_service.i18n.ja.json b/apps/bff/src/bundled_templates/layers/k8s_service.i18n.ja.json
index 0d2fca3..ac3e73a 100644
--- a/apps/bff/src/bundled_templates/layers/k8s_service.i18n.ja.json
+++ b/apps/bff/src/bundled_templates/layers/k8s_service.i18n.ja.json
@@ -255,7 +255,22 @@
       {
         "label": "平均応答時間"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "成功率" },
+        { "label": "レイテンシ" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "平均応答時間" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "平均応答時間" }
+      ]
+    }
   },
   "slots": {
     "services": "K8s サービス",
diff --git a/apps/bff/src/bundled_templates/layers/k8s_service.i18n.ko.json b/apps/bff/src/bundled_templates/layers/k8s_service.i18n.ko.json
index 86167d3..46533cd 100644
--- a/apps/bff/src/bundled_templates/layers/k8s_service.i18n.ko.json
+++ b/apps/bff/src/bundled_templates/layers/k8s_service.i18n.ko.json
@@ -255,7 +255,22 @@
       {
         "label": "평균 응답 시간"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "성공률" },
+        { "label": "지연 시간" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "평균 응답 시간" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "평균 응답 시간" }
+      ]
+    }
   },
   "slots": {
     "services": "K8s 서비스",
diff --git a/apps/bff/src/bundled_templates/layers/k8s_service.i18n.pt.json b/apps/bff/src/bundled_templates/layers/k8s_service.i18n.pt.json
index f1c153b..eb434d4 100644
--- a/apps/bff/src/bundled_templates/layers/k8s_service.i18n.pt.json
+++ b/apps/bff/src/bundled_templates/layers/k8s_service.i18n.pt.json
@@ -256,6 +256,21 @@
       {
         "label": "Tempo médio de resposta"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "Taxa de sucesso" },
+        { "label": "Latência" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "Tempo médio de resposta" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "Tempo médio de resposta" }
+      ]
+    }
   }
 }
diff --git a/apps/bff/src/bundled_templates/layers/k8s_service.i18n.zh-CN.json b/apps/bff/src/bundled_templates/layers/k8s_service.i18n.zh-CN.json
index cd98d1e..3b57f2f 100644
--- a/apps/bff/src/bundled_templates/layers/k8s_service.i18n.zh-CN.json
+++ b/apps/bff/src/bundled_templates/layers/k8s_service.i18n.zh-CN.json
@@ -135,6 +135,21 @@
     "linkClientMetrics": [
       { "label": "RPM" },
       { "label": "平均响应时间" }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "成功率" },
+        { "label": "延迟" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "平均响应时间" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "平均响应时间" }
+      ]
+    }
   }
 }
diff --git a/apps/bff/src/bundled_templates/layers/k8s_service.json b/apps/bff/src/bundled_templates/layers/k8s_service.json
index 98620e6..4e5fd9b 100644
--- a/apps/bff/src/bundled_templates/layers/k8s_service.json
+++ b/apps/bff/src/bundled_templates/layers/k8s_service.json
@@ -595,7 +595,22 @@
         "unit": "ms",
         "aggregation": "avg"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "id": "httpCpm", "label": "RPM", "mqe": "kubernetes_service_instance_http_call_cpm", "unit": "rpm", "role": "center", "aggregation": "avg" },
+        { "id": "httpSuccess", "label": "Success Rate", "mqe": "kubernetes_service_instance_http_call_successful_rate/100", "unit": "%", "role": "ring", "aggregation": "avg", "thresholds": { "invertHealth": true, "invertBase": 100, "ok": 0.1, "warn": 1, "danger": 5 } },
+        { "id": "httpRespTime", "label": "Latency", "mqe": "kubernetes_service_instance_http_call_time", "unit": "ms", "role": "secondary", "aggregation": "avg" }
+      ],
+      "linkServerMetrics": [
+        { "id": "cpm", "label": "RPM", "mqe": "kubernetes_service_instance_relation_server_http_call_cpm", "unit": "rpm", "role": "lineServer", "aggregation": "avg" },
+        { "id": "respTime", "label": "Avg response time", "mqe": "kubernetes_service_instance_relation_server_http_call_time", "unit": "ms", "aggregation": "avg" }
+      ],
+      "linkClientMetrics": [
+        { "id": "cpm", "label": "RPM", "mqe": "kubernetes_service_instance_relation_client_http_call_cpm", "unit": "rpm", "role": "lineClient", "aggregation": "avg" },
+        { "id": "respTime", "label": "Avg response time", "mqe": "kubernetes_service_instance_relation_client_http_call_time", "unit": "ms", "aggregation": "avg" }
+      ]
+    }
   },
   "traces": {
     "source": "zipkin"
diff --git a/apps/bff/src/bundled_templates/layers/mesh.i18n.de.json b/apps/bff/src/bundled_templates/layers/mesh.i18n.de.json
index cd69125..098e94f 100644
--- a/apps/bff/src/bundled_templates/layers/mesh.i18n.de.json
+++ b/apps/bff/src/bundled_templates/layers/mesh.i18n.de.json
@@ -355,7 +355,26 @@
       {
         "label": "SLA"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "SLA" },
+        { "label": "Latenz" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "Mittlere Antwortzeit" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "Mittlere Antwortzeit" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ]
+    }
   },
   "slots": {
     "services": "Dienste",
diff --git a/apps/bff/src/bundled_templates/layers/mesh.i18n.es.json b/apps/bff/src/bundled_templates/layers/mesh.i18n.es.json
index 1121a04..0490f8b 100644
--- a/apps/bff/src/bundled_templates/layers/mesh.i18n.es.json
+++ b/apps/bff/src/bundled_templates/layers/mesh.i18n.es.json
@@ -237,7 +237,26 @@
       {
         "label": "SLA"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "SLA" },
+        { "label": "Latencia" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "Tiempo medio de respuesta" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "Tiempo medio de respuesta" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ]
+    }
   },
   "slots": {
     "services": "Servicios",
diff --git a/apps/bff/src/bundled_templates/layers/mesh.i18n.fr.json b/apps/bff/src/bundled_templates/layers/mesh.i18n.fr.json
index 4299d3b..4918454 100644
--- a/apps/bff/src/bundled_templates/layers/mesh.i18n.fr.json
+++ b/apps/bff/src/bundled_templates/layers/mesh.i18n.fr.json
@@ -242,7 +242,26 @@
       {
         "label": "SLA"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "SLA" },
+        { "label": "Latence" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "Temps de réponse moyen" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "Temps de réponse moyen" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ]
+    }
   },
   "slots": {
     "services": "Services",
diff --git a/apps/bff/src/bundled_templates/layers/mesh.i18n.ja.json b/apps/bff/src/bundled_templates/layers/mesh.i18n.ja.json
index 7fffb58..6b8e6bc 100644
--- a/apps/bff/src/bundled_templates/layers/mesh.i18n.ja.json
+++ b/apps/bff/src/bundled_templates/layers/mesh.i18n.ja.json
@@ -242,7 +242,26 @@
       {
         "label": "SLA"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "SLA" },
+        { "label": "レイテンシ" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "平均応答時間" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "平均応答時間" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ]
+    }
   },
   "slots": {
     "services": "サービス",
diff --git a/apps/bff/src/bundled_templates/layers/mesh.i18n.ko.json b/apps/bff/src/bundled_templates/layers/mesh.i18n.ko.json
index 76f012c..ec388d9 100644
--- a/apps/bff/src/bundled_templates/layers/mesh.i18n.ko.json
+++ b/apps/bff/src/bundled_templates/layers/mesh.i18n.ko.json
@@ -242,7 +242,26 @@
       {
         "label": "SLA"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "SLA" },
+        { "label": "지연 시간" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "평균 응답 시간" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "평균 응답 시간" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ]
+    }
   },
   "slots": {
     "services": "서비스",
diff --git a/apps/bff/src/bundled_templates/layers/mesh.i18n.pt.json b/apps/bff/src/bundled_templates/layers/mesh.i18n.pt.json
index 0c6327a..ebe3537 100644
--- a/apps/bff/src/bundled_templates/layers/mesh.i18n.pt.json
+++ b/apps/bff/src/bundled_templates/layers/mesh.i18n.pt.json
@@ -244,6 +244,25 @@
       {
         "label": "SLA"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "SLA" },
+        { "label": "Latência" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "Tempo médio de resposta" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "Tempo médio de resposta" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ]
+    }
   }
 }
diff --git a/apps/bff/src/bundled_templates/layers/mesh.i18n.zh-CN.json b/apps/bff/src/bundled_templates/layers/mesh.i18n.zh-CN.json
index 9c22c13..00254b2 100644
--- a/apps/bff/src/bundled_templates/layers/mesh.i18n.zh-CN.json
+++ b/apps/bff/src/bundled_templates/layers/mesh.i18n.zh-CN.json
@@ -140,6 +140,25 @@
       { "label": "平均响应时间" },
       { "label": "p95" },
       { "label": "SLA" }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "label": "RPM" },
+        { "label": "SLA" },
+        { "label": "延迟" }
+      ],
+      "linkServerMetrics": [
+        { "label": "RPM" },
+        { "label": "平均响应时间" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ],
+      "linkClientMetrics": [
+        { "label": "RPM" },
+        { "label": "平均响应时间" },
+        { "label": "p95" },
+        { "label": "SLA" }
+      ]
+    }
   }
 }
diff --git a/apps/bff/src/bundled_templates/layers/mesh.json b/apps/bff/src/bundled_templates/layers/mesh.json
index 04e4eb7..80ce3fe 100644
--- a/apps/bff/src/bundled_templates/layers/mesh.json
+++ b/apps/bff/src/bundled_templates/layers/mesh.json
@@ -608,7 +608,26 @@
         "unit": "%",
         "aggregation": "avg"
       }
-    ]
+    ],
+    "instanceTopology": {
+      "nodeMetrics": [
+        { "id": "cpm", "label": "RPM", "mqe": "service_instance_cpm", "unit": "rpm", "role": "center", "aggregation": "avg" },
+        { "id": "sla", "label": "SLA", "mqe": "service_instance_sla/100", "unit": "%", "role": "ring", "aggregation": "avg", "thresholds": { "invertHealth": true, "invertBase": 100, "ok": 0.1, "warn": 1, "danger": 5 } },
+        { "id": "respTime", "label": "Latency", "mqe": "service_instance_resp_time", "unit": "ms", "role": "secondary", "aggregation": "avg" }
+      ],
+      "linkServerMetrics": [
+        { "id": "cpm", "label": "RPM", "mqe": "service_instance_relation_server_cpm", "unit": "rpm", "role": "lineServer", "aggregation": "avg" },
+        { "id": "respTime", "label": "Avg response time", "mqe": "service_instance_relation_server_resp_time", "unit": "ms", "aggregation": "avg" },
+        { "id": "p95", "label": "p95", "mqe": "service_instance_relation_server_percentile{p='95'}", "unit": "ms", "aggregation": "avg" },
+        { "id": "sla", "label": "SLA", "mqe": "service_instance_relation_server_call_sla/100", "unit": "%", "aggregation": "avg" }
+      ],
+      "linkClientMetrics": [
+        { "id": "cpm", "label": "RPM", "mqe": "service_instance_relation_client_cpm", "unit": "rpm", "role": "lineClient", "aggregation": "avg" },
+        { "id": "respTime", "label": "Avg response time", "mqe": "service_instance_relation_client_resp_time", "unit": "ms", "aggregation": "avg" },
+        { "id": "p95", "label": "p95", "mqe": "service_instance_relation_client_percentile{p='95'}", "unit": "ms", "aggregation": "avg" },
+        { "id": "sla", "label": "SLA", "mqe": "service_instance_relation_client_call_sla/100", "unit": "%", "aggregation": "avg" }
+      ]
+    }
   },
   "traces": {
     "source": "zipkin"
diff --git a/apps/bff/src/bundled_templates/overviews/mesh.i18n.de.json b/apps/bff/src/bundled_templates/overviews/mesh.i18n.de.json
index e754d00..7fad9f3 100644
--- a/apps/bff/src/bundled_templates/overviews/mesh.i18n.de.json
+++ b/apps/bff/src/bundled_templates/overviews/mesh.i18n.de.json
@@ -9,9 +9,15 @@
       "title": "Istio-verwaltete Services",
       "tip": "Services, die durch die Istio-Data-Plane geleitet werden.",
       "kpis": [
-        { "label": "RPM" },
-        { "label": "P95" },
-        { "label": "SLA" }
+        {
+          "label": "RPM"
+        },
+        {
+          "label": "P95"
+        },
+        {
+          "label": "SLA"
+        }
       ]
     },
     {
@@ -20,7 +26,9 @@
       "kpis": [
         null,
         null,
-        { "label": "Services" },
+        {
+          "label": "Services"
+        },
         null
       ]
     },
@@ -42,16 +50,28 @@
       "title": "Cluster-Kapazität & -Auslastung",
       "tip": "Knoten / Namespaces / Workloads, plus CPU-, Speicher- und Speichernutzung auf Cluster-Ebene.",
       "kpis": [
-        { "label": "Knoten" },
+        {
+          "label": "Knoten"
+        },
         null,
         null,
         null,
         null,
-        { "label": "Services" },
-        { "label": "Container" },
-        { "label": "CPU" },
-        { "label": "Speicher" },
-        { "label": "Speicherplatz" }
+        {
+          "label": "Services"
+        },
+        {
+          "label": "Container"
+        },
+        {
+          "label": "CPU"
+        },
+        {
+          "label": "Speicher"
+        },
+        {
+          "label": "Speicherplatz"
+        }
       ]
     }
   ]
diff --git a/apps/bff/src/bundled_templates/overviews/mesh.i18n.fr.json b/apps/bff/src/bundled_templates/overviews/mesh.i18n.fr.json
index 548b00c..274e830 100644
--- a/apps/bff/src/bundled_templates/overviews/mesh.i18n.fr.json
+++ b/apps/bff/src/bundled_templates/overviews/mesh.i18n.fr.json
@@ -9,9 +9,15 @@
       "title": "Services gérés par Istio",
       "tip": "Services qui transitent par le plan de données Istio.",
       "kpis": [
-        { "label": "RPM" },
-        { "label": "P95" },
-        { "label": "SLA" }
+        {
+          "label": "RPM"
+        },
+        {
+          "label": "P95"
+        },
+        {
+          "label": "SLA"
+        }
       ]
     },
     {
@@ -20,7 +26,9 @@
       "kpis": [
         null,
         null,
-        { "label": "Services" },
+        {
+          "label": "Services"
+        },
         null
       ]
     },
@@ -42,16 +50,28 @@
       "title": "Capacité et utilisation du cluster",
       "tip": "Nœuds / namespaces / workloads, plus l'utilisation CPU, mémoire et stockage au niveau du cluster.",
       "kpis": [
-        { "label": "Nœuds" },
+        {
+          "label": "Nœuds"
+        },
         null,
         null,
         null,
         null,
-        { "label": "Services" },
-        { "label": "Conteneurs" },
-        { "label": "CPU" },
-        { "label": "Mémoire" },
-        { "label": "Stockage" }
+        {
+          "label": "Services"
+        },
+        {
+          "label": "Conteneurs"
+        },
+        {
+          "label": "CPU"
+        },
+        {
+          "label": "Mémoire"
+        },
+        {
+          "label": "Stockage"
+        }
       ]
     }
   ]
diff --git a/apps/bff/src/bundled_templates/overviews/services.i18n.de.json b/apps/bff/src/bundled_templates/overviews/services.i18n.de.json
index 4f527ae..76dab14 100644
--- a/apps/bff/src/bundled_templates/overviews/services.i18n.de.json
+++ b/apps/bff/src/bundled_templates/overviews/services.i18n.de.json
@@ -9,45 +9,75 @@
       "title": "Allgemeine Services",
       "tip": "Auf der General-Ebene erfasste Anwendungs-Services.",
       "kpis": [
-        { "label": "RPM" },
-        { "label": "Latenz" },
-        { "label": "SLA" }
+        {
+          "label": "RPM"
+        },
+        {
+          "label": "Latenz"
+        },
+        {
+          "label": "SLA"
+        }
       ]
     },
     {
       "title": "Virtuelle Datenbanken",
       "tip": "Backend-Datenbanken, beobachtet über clientseitige Spans.",
       "kpis": [
-        { "label": "RPM" },
-        { "label": "Latenz" },
-        { "label": "SLA" }
+        {
+          "label": "RPM"
+        },
+        {
+          "label": "Latenz"
+        },
+        {
+          "label": "SLA"
+        }
       ]
     },
     {
       "title": "Virtuelle Caches",
       "tip": "Caches (Redis / Memcached / …), beobachtet über clientseitige Spans.",
       "kpis": [
-        { "label": "RPM" },
-        { "label": "Latenz" },
-        { "label": "SLA" }
+        {
+          "label": "RPM"
+        },
+        {
+          "label": "Latenz"
+        },
+        {
+          "label": "SLA"
+        }
       ]
     },
     {
       "title": "Virtuelle Message Queues",
       "tip": "Message Queues, beobachtet über Consume- und Produce-Spans.",
       "kpis": [
-        { "label": "Consume" },
-        { "label": "Produce" },
-        { "label": "Consume-Latenz" }
+        {
+          "label": "Consume"
+        },
+        {
+          "label": "Produce"
+        },
+        {
+          "label": "Consume-Latenz"
+        }
       ]
     },
     {
       "title": "Virtuelles GenAI",
       "tip": "GenAI-Backends, beobachtet über instrumentierte Client-Spans.",
       "kpis": [
-        { "label": "RPM" },
-        { "label": "Latenz" },
-        { "label": "SLA" }
+        {
+          "label": "RPM"
+        },
+        {
+          "label": "Latenz"
+        },
+        {
+          "label": "SLA"
+        }
       ]
     },
     {
@@ -68,16 +98,28 @@
       "title": "Cluster-Kapazität & -Auslastung",
       "tip": "Knoten / Namespaces / Workloads, plus CPU-, Speicher- und Speichernutzung auf Cluster-Ebene.",
       "kpis": [
-        { "label": "Knoten" },
+        {
+          "label": "Knoten"
+        },
         null,
         null,
         null,
         null,
-        { "label": "Services" },
-        { "label": "Container" },
-        { "label": "CPU" },
-        { "label": "Speicher" },
-        { "label": "Speicherplatz" }
+        {
+          "label": "Services"
+        },
+        {
+          "label": "Container"
+        },
+        {
+          "label": "CPU"
+        },
+        {
+          "label": "Speicher"
+        },
+        {
+          "label": "Speicherplatz"
+        }
       ]
     }
   ]
diff --git a/apps/bff/src/bundled_templates/overviews/services.i18n.fr.json b/apps/bff/src/bundled_templates/overviews/services.i18n.fr.json
index b5c915b..6d94f95 100644
--- a/apps/bff/src/bundled_templates/overviews/services.i18n.fr.json
+++ b/apps/bff/src/bundled_templates/overviews/services.i18n.fr.json
@@ -9,45 +9,75 @@
       "title": "Services généraux",
       "tip": "Services applicatifs tracés sur la couche General.",
       "kpis": [
-        { "label": "RPM" },
-        { "label": "Latence" },
-        { "label": "SLA" }
+        {
+          "label": "RPM"
+        },
+        {
+          "label": "Latence"
+        },
+        {
+          "label": "SLA"
+        }
       ]
     },
     {
       "title": "Bases de données virtuelles",
       "tip": "Bases de données backend observées via des spans côté client.",
       "kpis": [
-        { "label": "RPM" },
-        { "label": "Latence" },
-        { "label": "SLA" }
+        {
+          "label": "RPM"
+        },
+        {
+          "label": "Latence"
+        },
+        {
+          "label": "SLA"
+        }
       ]
     },
     {
       "title": "Caches virtuels",
       "tip": "Caches (Redis / Memcached / …) observés via des spans côté client.",
       "kpis": [
-        { "label": "RPM" },
-        { "label": "Latence" },
-        { "label": "SLA" }
+        {
+          "label": "RPM"
+        },
+        {
+          "label": "Latence"
+        },
+        {
+          "label": "SLA"
+        }
       ]
     },
     {
       "title": "Files de messages virtuelles",
       "tip": "Files de messages observées via les spans de consommation et de production.",
       "kpis": [
-        { "label": "Consommation" },
-        { "label": "Production" },
-        { "label": "Latence de consommation" }
+        {
+          "label": "Consommation"
+        },
+        {
+          "label": "Production"
+        },
+        {
+          "label": "Latence de consommation"
+        }
       ]
     },
     {
       "title": "GenAI virtuel",
       "tip": "Backends GenAI observés via des spans clients instrumentés.",
       "kpis": [
-        { "label": "RPM" },
-        { "label": "Latence" },
-        { "label": "SLA" }
+        {
+          "label": "RPM"
+        },
+        {
+          "label": "Latence"
+        },
+        {
+          "label": "SLA"
+        }
       ]
     },
     {
@@ -68,16 +98,28 @@
       "title": "Capacité et utilisation du cluster",
       "tip": "Nœuds / namespaces / workloads, plus l'utilisation CPU, mémoire et stockage au niveau du cluster.",
       "kpis": [
-        { "label": "Nœuds" },
+        {
+          "label": "Nœuds"
+        },
         null,
         null,
         null,
         null,
-        { "label": "Services" },
-        { "label": "Conteneurs" },
-        { "label": "CPU" },
-        { "label": "Mémoire" },
-        { "label": "Stockage" }
+        {
+          "label": "Services"
+        },
+        {
+          "label": "Conteneurs"
+        },
+        {
+          "label": "CPU"
+        },
+        {
+          "label": "Mémoire"
+        },
+        {
+          "label": "Stockage"
+        }
       ]
     }
   ]
diff --git a/apps/bff/src/http/config/bundle.ts b/apps/bff/src/http/config/bundle.ts
index 3f53f64..9f6a945 100644
--- a/apps/bff/src/http/config/bundle.ts
+++ b/apps/bff/src/http/config/bundle.ts
@@ -22,12 +22,16 @@
  * lot in localStorage and serve config lookups synchronously after the
  * first visit.
  *
- * Layer / overview content reflects the merged view from the sync
- * orchestrator: when OAP holds a (non-disabled) remote template under
- * the matching `horizon.*` name, that wins over bundled — operators
- * edit OAP, the BFF reflects what they edited. When OAP admin is
- * unreachable OR the remote is missing, bundled is used. Disabled
- * templates are dropped from the bundle entirely.
+ * Layer / overview content is strictly REMOTE at runtime: a (non-
+ * disabled) remote OAP template under the matching `horizon.*` name is
+ * rendered; anything else is dropped from the bundle. Bundled disk
+ * content is NEVER served here at runtime — it reaches the UI only by
+ * being synced INTO OAP (boot seed / admin reset) or via the explicit
+ * `?prefer=local` preview. So when OAP admin is unreachable, or a
+ * template is disabled / missing its remote row, that entry is simply
+ * absent from the bundle (the SPA blocks via the connectivity banner /
+ * falls to per-page in-code defaults), rather than masked by stale
+ * bundled config.
  *
  * `syncStatus` carries per-template badges for the admin pages so the
  * SPA can render `synced / diverged / disabled / remote-only /
@@ -169,13 +173,11 @@ async function buildBundle(
   };
 
   const layers: Record<string, ScopeMap> = {};
-  for (const tpl of allLayerTemplates()) {
-    const picked = pickLayerContent(tpl, remoteByName, preferLocal);
-    if (picked === null) continue; // disabled
+  // Localize + slice a resolved layer template into per-scope widget sets.
+  const addLayer = (picked: LayerTemplate): void => {
     // Localize: OAP overlay wins where present, disk overlay fills the
-    // rest, English source falls through for the remainder. Both
-    // overlays are keyed by the layer key (the source's `key`,
-    // upper-snake to match OAP's enum).
+    // rest, English source falls through for the remainder. Both overlays
+    // key on the layer's `key` (upper-snake, matching OAP's enum).
     const effective = localizeContent(
       picked,
       oapOverlayFor('layer', picked.key),
@@ -188,6 +190,24 @@ async function buildBundle(
       if (ws.length > 0) scopes[scope] = ws;
     }
     layers[effective.key.toLowerCase()] = scopes;
+  };
+  if (preferLocal) {
+    // Preview (`?prefer=local`): the disk-bundled copy is the thing being
+    // previewed for diverged rows, so reading disk here is the allowed
+    // preview path.
+    for (const tpl of allLayerTemplates()) {
+      const picked = pickLayerContent(tpl, remoteByName, true);
+      if (picked) addLayer(picked);
+    }
+  } else {
+    // Normal runtime: enumerate the REMOTE layer rows only — never the disk
+    // bundle. A bundled-but-unsynced (or disabled / unreachable) layer is
+    // simply absent, exactly like the per-page routes' remote-or-default.
+    for (const row of sync.rows) {
+      if (row.kind !== 'layer' || row.status === 'disabled' || !row.remote || row.locale !== undefined) continue;
+      const env = parseEnvelope(row.remote.configuration);
+      if (env && isLayerLike(env.content)) addLayer(env.content as LayerTemplate);
+    }
   }
 
   const overviews: OverviewDashboard[] = [];
@@ -255,18 +275,19 @@ async function buildBundle(
   return { etag, generatedAt: Date.now(), ...body };
 }
 
-/** Choose remote envelope.content over bundled when the row is synced or
- *  diverged. `disabled` returns null (drop from bundle). `bundled-fallback`,
- *  `unknown`, and `remote-only` fall to bundled (remote-only is impossible
- *  here because the bundled iterator would have included it — but be
- *  defensive). */
+/** Choose the remote envelope content when the row is synced or diverged.
+ *  Runtime is REMOTE-ONLY: a missing row / `disabled` / `bundled-fallback`
+ *  (remote absent) all return `null` so the entry is dropped from the
+ *  bundle — bundled disk content is never served at runtime. The ONE
+ *  exception is `preferLocal` (the `?prefer=local` preview), where bundled
+ *  IS the thing being previewed. */
 function pickLayerContent(
   bundled: LayerTemplate,
   byName: Map<string, TemplateRow>,
   preferLocal = false,
 ): LayerTemplate | null {
   const row = byName.get(formatName('layer', bundled.key));
-  if (!row) return bundled;
+  if (!row) return preferLocal ? bundled : null;
   if (row.status === 'disabled') return null;
   // Operator opted to preview unpublished local edits: bundled wins for
   // diverged templates (synced rows are byte-equal, so it's a no-op there).
@@ -277,7 +298,7 @@ function pickLayerContent(
       return env.content as LayerTemplate;
     }
   }
-  return bundled;
+  return preferLocal ? bundled : null;
 }
 
 function pickOverviewContent(
@@ -286,7 +307,7 @@ function pickOverviewContent(
   preferLocal = false,
 ): OverviewDashboard | null {
   const row = byName.get(formatName('overview', bundled.id));
-  if (!row) return bundled;
+  if (!row) return preferLocal ? bundled : null;
   if (row.status === 'disabled') return null;
   if (preferLocal && row.status === 'diverged') return bundled;
   if (row.effective === 'remote' && row.remote) {
@@ -295,7 +316,7 @@ function pickOverviewContent(
       return env.content as OverviewDashboard;
     }
   }
-  return bundled;
+  return preferLocal ? bundled : null;
 }
 
 function isLayerLike(v: unknown): boolean {
diff --git a/apps/bff/src/http/config/dashboard.ts b/apps/bff/src/http/config/dashboard.ts
index 4d681d9..4e94509 100644
--- a/apps/bff/src/http/config/dashboard.ts
+++ b/apps/bff/src/http/config/dashboard.ts
@@ -24,21 +24,26 @@
  */
 
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type { UITemplateClient } from '@skywalking-horizon-ui/api-client';
 import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
 import {
-  getLayerTemplate,
   widgetsForScope,
   type LayerTemplate,
 } from '../../logic/layers/loader.js';
+import { resolveEffectiveLayer } from '../../logic/layers/effective.js';
+import { oapOverlayContentFor } from '../../logic/templates/overlay.js';
 import { defaultWidgetsFor } from '../../logic/dashboard/defaults.js';
 import { scopeSchema } from '../query/dashboard.js';
-import { localize, getLayerOverlay, localeFromRequest } from '../../i18n/index.js';
+import { localizeContent, getLayerOverlay, localeFromRequest } from '../../i18n/index.js';
 
 export interface DashboardConfigDeps {
   config: ConfigSource;
   sessions: SessionStore;
+  /** OAP UI-template client — serve the in-use (remote-or-bundled)
+   *  widget config, matching the admin + the config-bundle endpoint. */
+  uiTemplateClient?: () => UITemplateClient;
 }
 
 export function registerDashboardConfigRoute(app: FastifyInstance, deps: DashboardConfigDeps): void {
@@ -55,12 +60,26 @@ export function registerDashboardConfigRoute(app: FastifyInstance, deps: Dashboa
       const q = req.query as { scope?: string };
       const scopeParsed = q.scope ? scopeSchema.safeParse(q.scope) : null;
       const scope = scopeParsed?.success ? scopeParsed.data : 'service';
-      const rawTpl = getLayerTemplate(layerKey);
+      const eff = await resolveEffectiveLayer(deps.uiTemplateClient, layerKey);
+      if (eff.blocked) {
+        // Template store unreachable / layer disabled — block: empty grid,
+        // no in-code defaults. The SPA's banner explains the empty state.
+        return reply.send({ layer: layerKey, scope, widgets: [] });
+      }
+      const rawTpl = eff.template;
       const locale = localeFromRequest(req);
       const tpl = rawTpl
-        ? localize<LayerTemplate>(rawTpl, getLayerOverlay(layerKey, locale), locale)
+        ? localizeContent<LayerTemplate>(
+            rawTpl,
+            // Overlay rows key on the canonical UPPER_SNAKE layer key
+            // (`GENERAL`), not the lowercase URL param — pass the template's
+            // own key so the OAP translation overlay row actually matches.
+            await oapOverlayContentFor(deps.uiTemplateClient, 'layer', rawTpl.key, locale),
+            getLayerOverlay(rawTpl.key, locale),
+            locale,
+          )
         : null;
-      const widgets = tpl ? widgetsForScope(tpl, scope) : defaultWidgetsFor(layerKey);
+      const widgets = tpl ? widgetsForScope(tpl, scope) : defaultWidgetsFor(rawTpl, layerKey);
       return reply.send({ layer: layerKey, scope, widgets });
     },
   );
diff --git a/apps/bff/src/http/config/overview.ts b/apps/bff/src/http/config/overview.ts
index 34a60ee..8fca605 100644
--- a/apps/bff/src/http/config/overview.ts
+++ b/apps/bff/src/http/config/overview.ts
@@ -35,16 +35,24 @@ import type {
   OverviewDashboard,
   OverviewDashboardListResponse,
   OverviewDashboardResponse,
+  UITemplateClient,
 } from '@skywalking-horizon-ui/api-client';
 import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
-import { getOverviewDashboard, loadOverviewDashboards } from '../../logic/overview/loader.js';
-import { localize, getOverviewOverlay, localeFromRequest } from '../../i18n/index.js';
+import {
+  resolveEffectiveOverviews,
+  resolveEffectiveOverview,
+} from '../../logic/overview/effective.js';
+import { oapOverlayContentFor } from '../../logic/templates/overlay.js';
+import { localizeContent, getOverviewOverlay, localeFromRequest } from '../../i18n/index.js';
 
 export interface OverviewRouteDeps {
   config: ConfigSource;
   sessions: SessionStore;
+  /** OAP UI-template client — serve the in-use (remote-or-bundled)
+   *  overviews + apply OAP translation overlays, matching the bundle. */
+  uiTemplateClient?: () => UITemplateClient;
 }
 
 export function registerOverviewRoutes(app: FastifyInstance, deps: OverviewRouteDeps): void {
@@ -52,22 +60,29 @@ export function registerOverviewRoutes(app: FastifyInstance, deps: OverviewRoute
 
   app.get('/api/overview/dashboards', { preHandler: auth }, async (req, reply) => {
     const locale = localeFromRequest(req);
-    const dashboards = loadOverviewDashboards();
+    const dashboards = await resolveEffectiveOverviews(deps.uiTemplateClient);
     const body: OverviewDashboardListResponse = {
       generatedAt: Date.now(),
-      dashboards: dashboards.map((d) => {
-        const ld = localize<OverviewDashboard>(d, getOverviewOverlay(d.id, locale), locale);
-        return {
-          id: ld.id,
-          title: ld.title,
-          description: ld.description,
-          visibility: ld.visibility,
-          icon: ld.icon,
-          order: ld.order,
-          layers: ld.layers,
-          widgetCount: ld.widgets.length,
-        };
-      }),
+      dashboards: await Promise.all(
+        dashboards.map(async (d) => {
+          const ld = localizeContent<OverviewDashboard>(
+            d,
+            await oapOverlayContentFor(deps.uiTemplateClient, 'overview', d.id, locale),
+            getOverviewOverlay(d.id, locale),
+            locale,
+          );
+          return {
+            id: ld.id,
+            title: ld.title,
+            description: ld.description,
+            visibility: ld.visibility,
+            icon: ld.icon,
+            order: ld.order,
+            layers: ld.layers,
+            widgetCount: ld.widgets.length,
+          };
+        }),
+      ),
     };
     return reply.send(body);
   });
@@ -77,8 +92,10 @@ export function registerOverviewRoutes(app: FastifyInstance, deps: OverviewRoute
     { preHandler: auth },
     async (req: FastifyRequest, reply: FastifyReply) => {
       const { id } = req.params as { id: string };
-      const dash = getOverviewDashboard(id);
+      const dash = await resolveEffectiveOverview(deps.uiTemplateClient, id);
       if (!dash) {
+        // null ⇒ unknown OR admin-disabled on OAP — either way it doesn't
+        // render (a disabled overview must not resurrect from bundled).
         const body: OverviewDashboardResponse = {
           generatedAt: Date.now(),
           dashboard: { id, title: id, widgets: [] },
@@ -90,7 +107,12 @@ export function registerOverviewRoutes(app: FastifyInstance, deps: OverviewRoute
       const locale = localeFromRequest(req);
       const body: OverviewDashboardResponse = {
         generatedAt: Date.now(),
-        dashboard: localize<OverviewDashboard>(dash, getOverviewOverlay(dash.id, locale), locale),
+        dashboard: localizeContent<OverviewDashboard>(
+          dash,
+          await oapOverlayContentFor(deps.uiTemplateClient, 'overview', dash.id, locale),
+          getOverviewOverlay(dash.id, locale),
+          locale,
+        ),
         reachable: true,
       };
       return reply.send(body);
diff --git a/apps/bff/src/http/query/dashboard.ts b/apps/bff/src/http/query/dashboard.ts
index 0a9b49d..4da9e6e 100644
--- a/apps/bff/src/http/query/dashboard.ts
+++ b/apps/bff/src/http/query/dashboard.ts
@@ -38,6 +38,7 @@ import type {
   DashboardWidget,
   DashboardWidgetResult,
   FetchLike,
+  UITemplateClient,
 } from '@skywalking-horizon-ui/api-client';
 import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
@@ -52,16 +53,17 @@ import {
   type TimeStep,
   type Window,
 } from '../../util/window.js';
-import {
-  getLayerTemplate,
-  widgetsForScope,
-} from '../../logic/layers/loader.js';
+import { widgetsForScope } from '../../logic/layers/loader.js';
+import { resolveEffectiveLayer } from '../../logic/layers/effective.js';
 import { defaultWidgetsFor } from '../../logic/dashboard/defaults.js';
 
 export interface DashboardRouteDeps {
   config: ConfigSource;
   sessions: SessionStore;
   fetch?: FetchLike;
+  /** OAP UI-template client — serve the in-use (remote-or-bundled)
+   *  widget set when the caller doesn't pass explicit widgets. */
+  uiTemplateClient?: () => UITemplateClient;
 }
 
 /** Shared with config/layer-template.ts — kept here because the
@@ -424,10 +426,17 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar
       const mockTopRaw = (req.query as { mockTop?: string }).mockTop;
       const mockTopN = mockTopRaw ? Math.max(0, Math.min(40, Number(mockTopRaw))) : 0;
       const scope = parsed.data.scope ?? 'service';
-      const tpl = getLayerTemplate(layerKey);
+      const eff = await resolveEffectiveLayer(deps.uiTemplateClient, layerKey);
+      // Blocked (template store unreachable / layer disabled) → no
+      // BFF-derived widgets and no in-code defaults; the grid stays empty.
+      // An explicit `widgets[]` in the body still runs — the caller owns it.
       const widgets: DashboardWidget[] =
         parsed.data.widgets ??
-        (tpl ? widgetsForScope(tpl, scope) : defaultWidgetsFor(layerKey));
+        (eff.blocked
+          ? []
+          : eff.template
+            ? widgetsForScope(eff.template, scope)
+            : defaultWidgetsFor(eff.template, layerKey));
       let serviceName = parsed.data.service ?? '';
       let serviceId = '';
       let normal = true;
diff --git a/apps/bff/src/http/query/ebpf.ts b/apps/bff/src/http/query/ebpf.ts
index 894ccdd..5202842 100644
--- a/apps/bff/src/http/query/ebpf.ts
+++ b/apps/bff/src/http/query/ebpf.ts
@@ -47,6 +47,7 @@ import type {
   ProcessRelationMetricsResponse,
   ProcessTopologyResponse,
   TopologyMetricDef,
+  UITemplateClient,
 } from '@skywalking-horizon-ui/api-client';
 import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
@@ -54,12 +55,16 @@ import { requireAuth } from '../../user/middleware.js';
 import { graphqlPost, buildOapOpts } from '../../client/graphql.js';
 import { withColdStage } from '../../util/duration.js';
 import { fmtMinute, getServerOffsetMinutes } from '../../util/window.js';
-import { getLayerTemplate, processTopologyConfigFor } from '../../logic/layers/loader.js';
+import { processTopologyConfigFor, type ProcessTopologyConfig } from '../../logic/layers/loader.js';
+import { parsePreviewProcessTopology } from '../../logic/layers/preview.js';
+import { resolveEffectiveLayer } from '../../logic/layers/effective.js';
 
 export interface EBPFRouteDeps {
   config: ConfigSource;
   sessions: SessionStore;
   fetch?: FetchLike;
+  /** OAP UI-template client — serve the in-use (remote-or-bundled) config. */
+  uiTemplateClient?: () => UITemplateClient;
 }
 
 const LIST_SERVICES_FOR_RESOLVE = /* GraphQL */ `
@@ -670,6 +675,8 @@ export function registerEBPFRoutes(app: FastifyInstance, deps: EBPFRouteDeps): v
             endTime?: number;
             /** Fallback when no task window is supplied. */
             windowMinutes?: number;
+            /** Admin preview: the operator's draft `processTopology` block. */
+            previewConfig?: string;
           }
         | undefined;
       const payload: ProcessRelationMetricsResponse = { client: [], server: [], reachable: true };
@@ -680,7 +687,20 @@ export function registerEBPFRoutes(app: FastifyInstance, deps: EBPFRouteDeps): v
         return reply.send(payload);
       }
 
-      const cfg = processTopologyConfigFor(getLayerTemplate(params.key));
+      // Admin Preview: render the draft `processTopology` block (bypasses
+      // remote resolve + block); else the remote-or-default config.
+      const previewPt = parsePreviewProcessTopology(body?.previewConfig);
+      let cfg: ProcessTopologyConfig;
+      if (previewPt) {
+        cfg = previewPt;
+      } else {
+        const eff = await resolveEffectiveLayer(deps.uiTemplateClient, params.key);
+        if (eff.blocked) {
+          // Template store unreachable / layer disabled — block, no defaults.
+          return reply.send(payload);
+        }
+        cfg = processTopologyConfigFor(eff.template);
+      }
       // Prefer the profiling-task time range (the data only exists for
       // that span). Fall back to a rolling window when none is given.
       let startMs: number;
diff --git a/apps/bff/src/http/query/endpoint-dependency.ts b/apps/bff/src/http/query/endpoint-dependency.ts
index 7d2418a..bd0f6b2 100644
--- a/apps/bff/src/http/query/endpoint-dependency.ts
+++ b/apps/bff/src/http/query/endpoint-dependency.ts
@@ -38,17 +38,22 @@ import type {
   EndpointDependencyResponse,
   FetchLike,
   TopologyMetricDef,
+  UITemplateClient,
 } from '@skywalking-horizon-ui/api-client';
 import { requireAuth } from '../../user/middleware.js';
 import {  graphqlPost, buildOapOpts } from '../../client/graphql.js';
 import { withColdStage } from '../../util/duration.js';
 import { defaultMinuteWindow, getServerOffsetMinutes } from '../../util/window.js';
-import { endpointDependencyConfigFor, getLayerTemplate } from '../../logic/layers/loader.js';
+import { endpointDependencyConfigFor } from '../../logic/layers/loader.js';
+import { resolveEffectiveLayer } from '../../logic/layers/effective.js';
+import { parsePreviewEndpointDep } from '../../logic/layers/preview.js';
 
 export interface EndpointDependencyRouteDeps {
   config: ConfigSource;
   sessions: SessionStore;
   fetch?: FetchLike;
+  /** OAP UI-template client — serve the in-use (remote-or-bundled) config. */
+  uiTemplateClient?: () => UITemplateClient;
 }
 
 interface OapEpNode {
@@ -248,14 +253,26 @@ export function registerEndpointDependencyRoute(
       if (!layerKey || !/^[a-z0-9_]+$/i.test(layerKey)) {
         return reply.code(400).send({ error: 'invalid_layer_key' });
       }
-      const q = req.query as { service?: string; endpoint?: string };
+      const q = req.query as { service?: string; endpoint?: string; previewConfig?: string };
       const serviceArg = (q.service ?? '').trim();
       const endpointArg = (q.endpoint ?? '').trim();
       if (!serviceArg) return reply.code(400).send({ error: 'missing_service' });
       if (!endpointArg) return reply.code(400).send({ error: 'missing_endpoint' });
 
-      const template = getLayerTemplate(layerKey);
-      const epCfg: EndpointDependencyConfig = endpointDependencyConfigFor(template);
+      // Admin Preview: render the operator's draft `endpointDependency`
+      // block when forwarded + valid (bypasses the remote resolve + block).
+      const previewCfg = parsePreviewEndpointDep(q.previewConfig);
+      let epCfg: EndpointDependencyConfig;
+      if (previewCfg) {
+        epCfg = previewCfg;
+      } else {
+        const eff = await resolveEffectiveLayer(deps.uiTemplateClient, layerKey);
+        if (eff.blocked) {
+          // Template store unreachable / layer disabled — block, no defaults.
+          return reply.send(emptyResponse(layerKey, serviceArg, endpointArg, null, { nodeMetrics: [] }, true));
+        }
+        epCfg = endpointDependencyConfigFor(eff.template);
+      }
 
       const cfgCurrent = deps.config.current;
       const opts = buildOapOpts(cfgCurrent, deps.fetch);
diff --git a/apps/bff/src/http/query/instance-topology.ts b/apps/bff/src/http/query/instance-topology.ts
new file mode 100644
index 0000000..78992f0
--- /dev/null
+++ b/apps/bff/src/http/query/instance-topology.ts
@@ -0,0 +1,483 @@
+/*
+ * 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.
+ */
+
+/**
+ * `GET /api/layer/:key/instance-topology?client=<svcId>&server=<svcId>`
+ *
+ * Instance-to-instance topology drill-down for one service→service edge,
+ * opened from the per-layer service map. The graph comes from OAP's
+ * `getServiceInstanceTopology(clientServiceId, serverServiceId,
+ * duration)` — nodes are the instances of the two services, calls are
+ * the instance-level dependencies between them.
+ *
+ *  - Per-node MQE evaluates under `{ scope: ServiceInstance }`.
+ *  - Per-edge MQE evaluates under ServiceInstanceRelation, split into a
+ *    server family + a client family. Same per-side rule as the service
+ *    map: fetch server metrics only when the DEST instance is real,
+ *    client metrics only when the SOURCE instance is real — OAP returns
+ *    nothing for the missing side otherwise.
+ *
+ * The metric set is the LAYER TEMPLATE's `topology.instanceTopology`
+ * block. When a layer doesn't define one the route 404s — the service
+ * map only offers the drill-down for layers that echo the config, so a
+ * direct hit on an unconfigured layer is the only way to reach this.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import type {
+  FetchLike,
+  InstanceTopologyCall,
+  InstanceTopologyConfig,
+  InstanceTopologyNode,
+  InstanceTopologyResponse,
+  TopologyMetricDef,
+  UITemplateClient,
+} from '@skywalking-horizon-ui/api-client';
+import { requireAuth } from '../../user/middleware.js';
+import { graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { withColdStage } from '../../util/duration.js';
+import {
+  defaultMinuteWindow,
+  getServerOffsetMinutes,
+  windowFromRange,
+  type TimeStep,
+  type Window,
+} from '../../util/window.js';
+import { instanceTopologyConfigFor } from '../../logic/layers/loader.js';
+import { resolveEffectiveLayerTemplate } from '../../logic/layers/effective.js';
+import { parsePreviewTopology } from '../../logic/layers/preview.js';
+import { aggregateMqe, seriesFromMqe, type MqeShape } from './topology.js';
+
+export interface InstanceTopologyRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  fetch?: FetchLike;
+  /** OAP UI-template client — lets the route serve the in-use
+   *  (remote-or-bundled) topology config, matching the admin + sidebar. */
+  uiTemplateClient?: () => UITemplateClient;
+}
+
+interface OapInstNode {
+  id: string;
+  name: string;
+  serviceName: string;
+  serviceId: string;
+  isReal: boolean;
+}
+interface OapInstCall {
+  id: string;
+  source: string;
+  target: string;
+  detectPoints: string[];
+}
+interface InstanceTopologyResp {
+  topology: { nodes: OapInstNode[]; calls: OapInstCall[] };
+}
+
+const INSTANCE_TOPOLOGY = /* GraphQL */ `
+  query InstanceTopology($clientServiceId: ID!, $serverServiceId: ID!, $duration: Duration!) {
+    topology: getServiceInstanceTopology(
+      clientServiceId: $clientServiceId
+      serverServiceId: $serverServiceId
+      duration: $duration
+    ) {
+      nodes { id name serviceName serviceId isReal }
+      calls { id source target detectPoints }
+    }
+  }
+`;
+
+const LIST_SERVICES_FOR_RESOLVE = /* GraphQL */ `
+  query ListServicesForInstanceTopology($layer: String!) {
+    services: listServices(layer: $layer) {
+      id
+      name
+      normal
+    }
+  }
+`;
+
+const DEFAULT_WINDOW_MIN = 60;
+
+/** Per-instance fragment under `{ scope: ServiceInstance }`. */
+function nodeFragment(
+  alias: string,
+  m: TopologyMetricDef,
+  serviceName: string,
+  instanceName: string,
+  normal: boolean,
+  w: Window,
+  coldStage: boolean,
+): string {
+  const coldFrag = coldStage ? ', coldStage: true' : '';
+  return (
+    `${alias}: execExpression(\n` +
+    `      expression: ${JSON.stringify(m.mqe)},\n` +
+    `      entity: { scope: ServiceInstance, serviceName: ${JSON.stringify(serviceName)},` +
+    ` normal: ${normal ? 'true' : 'false'}, serviceInstanceName: ${JSON.stringify(instanceName)} },\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: ${w.step}${coldFrag} }\n` +
+    `    ) { type error results { values { value } } }`
+  );
+}
+
+/**
+ * Per-edge fragment for ServiceInstanceRelation. As with the service
+ * map's relation fragment we do NOT set `scope` — OAP infers it from
+ * the metric name (`service_instance_relation_server_*` →
+ * ServiceInstanceRelation server, `*_client_*` → client). The entity
+ * carries both endpoints' service + instance names.
+ */
+function relationFragment(
+  alias: string,
+  m: TopologyMetricDef,
+  srcServiceName: string,
+  srcInstanceName: string,
+  srcNormal: boolean,
+  dstServiceName: string,
+  dstInstanceName: string,
+  dstNormal: boolean,
+  w: Window,
+  coldStage: boolean,
+): string {
+  const coldFrag = coldStage ? ', coldStage: true' : '';
+  return (
+    `${alias}: execExpression(\n` +
+    `      expression: ${JSON.stringify(m.mqe)},\n` +
+    `      entity: {` +
+    ` serviceName: ${JSON.stringify(srcServiceName)},` +
+    ` normal: ${srcNormal ? 'true' : 'false'},` +
+    ` serviceInstanceName: ${JSON.stringify(srcInstanceName)},` +
+    ` destServiceName: ${JSON.stringify(dstServiceName)},` +
+    ` destNormal: ${dstNormal ? 'true' : 'false'},` +
+    ` destServiceInstanceName: ${JSON.stringify(dstInstanceName)} },\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: ${w.step}${coldFrag} }\n` +
+    `    ) { type error results { values { value } } }`
+  );
+}
+
+function emptyResponse(
+  layerKey: string,
+  clientServiceId: string,
+  serverServiceId: string,
+  cfg: InstanceTopologyConfig,
+  reachable: boolean,
+  err?: string,
+): InstanceTopologyResponse {
+  return {
+    layer: layerKey,
+    clientServiceId,
+    serverServiceId,
+    clientServiceName: null,
+    serverServiceName: null,
+    generatedAt: Date.now(),
+    config: cfg,
+    nodes: [],
+    calls: [],
+    reachable,
+    ...(err ? { error: err } : {}),
+  };
+}
+
+export function registerInstanceTopologyRoute(
+  app: FastifyInstance,
+  deps: InstanceTopologyRouteDeps,
+): void {
+  const auth = requireAuth(deps);
+  app.get(
+    '/api/layer/:key/instance-topology',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { key: string };
+      const layerKey = params.key;
+      if (!layerKey || !/^[a-z0-9_]+$/i.test(layerKey)) {
+        return reply.code(400).send({ error: 'invalid_layer_key' });
+      }
+      const q = req.query as {
+        client?: string;
+        server?: string;
+        step?: string;
+        startMs?: string;
+        endMs?: string;
+        previewConfig?: string;
+      };
+      const clientServiceId = (q.client ?? '').trim();
+      const serverServiceId = (q.server ?? '').trim();
+      if (!clientServiceId || !serverServiceId) {
+        return reply.code(400).send({ error: 'missing_service_ids' });
+      }
+
+      // Admin Preview: the page forwards the draft `topology` block; the
+      // instance map reads its nested `instanceTopology`. When previewing,
+      // that draft decides support (404 if the draft has no instance map),
+      // bypassing the remote template entirely.
+      const previewTopo = parsePreviewTopology(q.previewConfig);
+      const instCfg: InstanceTopologyConfig | null = previewTopo
+        ? (previewTopo.instanceTopology ?? null)
+        : instanceTopologyConfigFor(
+            await resolveEffectiveLayerTemplate(deps.uiTemplateClient, layerKey),
+          );
+      if (!instCfg) {
+        return reply.code(404).send({ error: 'instance_topology_not_supported' });
+      }
+
+      const cfgCurrent = deps.config.current;
+      const opts = buildOapOpts(cfgCurrent, deps.fetch);
+      const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
+      // Honor the SPA's topbar picker triplet; else fall back to the
+      // last-hour MINUTE window (dashboards family — minute precision).
+      const stepArg = (q.step ?? '').toUpperCase() as TimeStep;
+      const startMs = Number(q.startMs);
+      const endMs = Number(q.endMs);
+      const window: Window =
+        (stepArg === 'MINUTE' || stepArg === 'HOUR' || stepArg === 'DAY') &&
+        Number.isFinite(startMs) &&
+        Number.isFinite(endMs)
+          ? windowFromRange(stepArg, startMs, endMs, offset) ??
+            defaultMinuteWindow(offset, DEFAULT_WINDOW_MIN)
+          : defaultMinuteWindow(offset, DEFAULT_WINDOW_MIN);
+      const oapLayer = layerKey.toUpperCase();
+      const durationVar = withColdStage(req, { start: window.start, end: window.end, step: window.step });
+      const coldStage = !!req.coldStage;
+
+      // ── Resolve the two services' names + normal-flags. The instance
+      // node entity needs the SERVICE's normal flag (not the instance's);
+      // booster resolves `normal = node.normal || isReal`.
+      const knownServices = new Map<string, { name: string; normal: boolean }>();
+      try {
+        const data = await graphqlPost<{
+          services: Array<{ id: string; name: string; normal?: boolean | null }>;
+        }>(opts, LIST_SERVICES_FOR_RESOLVE, { layer: oapLayer });
+        for (const s of data.services) {
+          knownServices.set(s.id, { name: s.name, normal: s.normal !== false });
+        }
+      } catch (err) {
+        return reply.send(
+          emptyResponse(layerKey, clientServiceId, serverServiceId, instCfg, false,
+            err instanceof Error ? err.message : String(err)),
+        );
+      }
+      const clientSvc = knownServices.get(clientServiceId) ?? null;
+      const serverSvc = knownServices.get(serverServiceId) ?? null;
+
+      // ── Fetch the instance topology between the two services.
+      let topo: { nodes: OapInstNode[]; calls: OapInstCall[] };
+      try {
+        const data = await graphqlPost<InstanceTopologyResp>(opts, INSTANCE_TOPOLOGY, {
+          clientServiceId,
+          serverServiceId,
+          duration: durationVar,
+        });
+        topo = data.topology;
+      } catch (err) {
+        return reply.send(
+          emptyResponse(layerKey, clientServiceId, serverServiceId, instCfg, false,
+            err instanceof Error ? err.message : String(err)),
+        );
+      }
+
+      const nodes = topo.nodes ?? [];
+      const calls = topo.calls ?? [];
+      const nodeById = new Map<string, OapInstNode>();
+      for (const n of nodes) nodeById.set(n.id, n);
+      // Per-instance `normal` resolves off the owning service's flag;
+      // fall back to the instance node's `isReal` when the service isn't
+      // in the catalog snapshot (conjured peers).
+      function normalFor(n: OapInstNode): boolean {
+        return knownServices.get(n.serviceId)?.normal ?? n.isReal;
+      }
+
+      // ── Per-node MQE.
+      const nodeMetricVals = new Map<string, Record<string, number | null>>();
+      const realNodes = nodes.filter((n) => n.isReal);
+      if (realNodes.length > 0 && instCfg.nodeMetrics.length > 0) {
+        const fragments: string[] = [];
+        const aliasMap = new Map<string, { nodeId: string; metric: TopologyMetricDef }>();
+        realNodes.forEach((n, i) => {
+          instCfg.nodeMetrics.forEach((m, j) => {
+            const alias = `n${i}_${j}`;
+            aliasMap.set(alias, { nodeId: n.id, metric: m });
+            fragments.push(nodeFragment(alias, m, n.serviceName, n.name, normalFor(n), window, coldStage));
+          });
+        });
+        const CHUNK = 150;
+        for (let i = 0; i < fragments.length; i += CHUNK) {
+          const slice = fragments.slice(i, i + CHUNK);
+          const query = `query InstanceNodeMetrics {\n  ${slice.join('\n  ')}\n}`;
+          let env: Record<string, MqeShape>;
+          try {
+            env = await graphqlPost<Record<string, MqeShape>>(opts, query);
+          } catch {
+            break; // soft-fail: keep the graph with null node metrics
+          }
+          for (const [alias, shape] of Object.entries(env)) {
+            const info = aliasMap.get(alias);
+            if (!info) continue;
+            const v = aggregateMqe(shape, info.metric.aggregation ?? 'avg');
+            const rec = nodeMetricVals.get(info.nodeId) ?? {};
+            rec[info.metric.id] = v;
+            nodeMetricVals.set(info.nodeId, rec);
+          }
+        }
+      }
+
+      // ── Per-edge MQE (server + client families, per-side gate).
+      const serverMetricVals = new Map<string, Record<string, number | null>>();
+      const clientMetricVals = new Map<string, Record<string, number | null>>();
+      const serverMetricSeries = new Map<string, Record<string, Array<number | null> | null>>();
+      const clientMetricSeries = new Map<string, Record<string, Array<number | null> | null>>();
+      const linkSrv = instCfg.linkServerMetrics ?? [];
+      const linkCli = instCfg.linkClientMetrics ?? [];
+      const candidateEdges = calls.filter((c) => {
+        const a = nodeById.get(c.source);
+        const b = nodeById.get(c.target);
+        return !!a && !!b && !!a.name && !!b.name;
+      });
+      if (candidateEdges.length > 0 && (linkSrv.length > 0 || linkCli.length > 0)) {
+        const fragments: string[] = [];
+        const aliasMap = new Map<
+          string,
+          { callId: string; metric: TopologyMetricDef; side: 'server' | 'client' }
+        >();
+        candidateEdges.forEach((c, i) => {
+          const src = nodeById.get(c.source)!;
+          const dst = nodeById.get(c.target)!;
+          const srcNormal = normalFor(src);
+          const dstNormal = normalFor(dst);
+          if (dst.isReal) {
+            linkSrv.forEach((m, j) => {
+              const alias = `s${i}_${j}`;
+              aliasMap.set(alias, { callId: c.id, metric: m, side: 'server' });
+              fragments.push(
+                relationFragment(alias, m, src.serviceName, src.name, srcNormal, dst.serviceName, dst.name, dstNormal, window, coldStage),
+              );
+            });
+          }
+          if (src.isReal) {
+            linkCli.forEach((m, j) => {
+              const alias = `c${i}_${j}`;
+              aliasMap.set(alias, { callId: c.id, metric: m, side: 'client' });
+              fragments.push(
+                relationFragment(alias, m, src.serviceName, src.name, srcNormal, dst.serviceName, dst.name, dstNormal, window, coldStage),
+              );
+            });
+          }
+        });
+        const CHUNK = 200;
+        for (let i = 0; i < fragments.length; i += CHUNK) {
+          const slice = fragments.slice(i, i + CHUNK);
+          const query = `query InstanceEdgeMetrics {\n  ${slice.join('\n  ')}\n}`;
+          let env: Record<string, MqeShape>;
+          try {
+            env = await graphqlPost<Record<string, MqeShape>>(opts, query);
+          } catch {
+            break;
+          }
+          for (const [alias, shape] of Object.entries(env)) {
+            const info = aliasMap.get(alias);
+            if (!info) continue;
+            const v = aggregateMqe(shape, info.metric.aggregation ?? 'avg');
+            const valBucket = info.side === 'server' ? serverMetricVals : clientMetricVals;
+            const seriesBucket = info.side === 'server' ? serverMetricSeries : clientMetricSeries;
+            const valRec = valBucket.get(info.callId) ?? {};
+            valRec[info.metric.id] = v;
+            valBucket.set(info.callId, valRec);
+            const sRec = seriesBucket.get(info.callId) ?? {};
+            sRec[info.metric.id] = seriesFromMqe(shape);
+            seriesBucket.set(info.callId, sRec);
+          }
+        }
+      }
+
+      // ── Build response. Connected instances only — an instance with no
+      // edge in the window doesn't belong on the drill-down graph.
+      const connectedNodeIds = new Set<string>();
+      for (const c of calls) {
+        connectedNodeIds.add(c.source);
+        connectedNodeIds.add(c.target);
+      }
+      const liveNodes: InstanceTopologyNode[] = [];
+      for (const n of nodes) {
+        if (!connectedNodeIds.has(n.id)) continue;
+        const m = nodeMetricVals.get(n.id) ?? {};
+        const filled: Record<string, number | null> = {};
+        for (const def of instCfg.nodeMetrics) filled[def.id] = m[def.id] ?? null;
+        liveNodes.push({
+          id: n.id,
+          name: n.name,
+          serviceId: n.serviceId,
+          serviceName: n.serviceName,
+          isReal: n.isReal,
+          metrics: filled,
+        });
+      }
+      const liveNodeIds = new Set(liveNodes.map((n) => n.id));
+      const liveCalls: InstanceTopologyCall[] = [];
+      for (const c of calls) {
+        if (!liveNodeIds.has(c.source) || !liveNodeIds.has(c.target)) continue;
+        const sm = serverMetricVals.get(c.id) ?? {};
+        const cm = clientMetricVals.get(c.id) ?? {};
+        const ss = serverMetricSeries.get(c.id) ?? {};
+        const cs = clientMetricSeries.get(c.id) ?? {};
+        const filledSrv: Record<string, number | null> = {};
+        const filledSrvSeries: Record<string, Array<number | null> | null> = {};
+        for (const def of linkSrv) {
+          filledSrv[def.id] = sm[def.id] ?? null;
+          filledSrvSeries[def.id] = ss[def.id] ?? null;
+        }
+        const filledCli: Record<string, number | null> = {};
+        const filledCliSeries: Record<string, Array<number | null> | null> = {};
+        for (const def of linkCli) {
+          filledCli[def.id] = cm[def.id] ?? null;
+          filledCliSeries[def.id] = cs[def.id] ?? null;
+        }
+        liveCalls.push({
+          id: c.id,
+          source: c.source,
+          target: c.target,
+          detectPoints: c.detectPoints ?? [],
+          serverMetrics: filledSrv,
+          clientMetrics: filledCli,
+          serverMetricSeries: filledSrvSeries,
+          clientMetricSeries: filledCliSeries,
+        });
+      }
+
+      // Resolve the display name. The layer roster (`listServices`) only
+      // carries entry-point services, so a callee addressed by host:port
+      // (e.g. `rcmd:80`) — real, but not a roster entry — misses there.
+      // OAP already hands us the decoded `serviceName` on each instance
+      // node, so fall back to that before surfacing the raw base64 id.
+      const nameForService = (id: string): string | null =>
+        nodes.find((n) => n.serviceId === id)?.serviceName ?? null;
+
+      return reply.send({
+        layer: layerKey,
+        clientServiceId,
+        serverServiceId,
+        clientServiceName: clientSvc?.name ?? nameForService(clientServiceId),
+        serverServiceName: serverSvc?.name ?? nameForService(serverServiceId),
+        generatedAt: Date.now(),
+        config: instCfg,
+        nodes: liveNodes,
+        calls: liveCalls,
+        reachable: true,
+      } satisfies InstanceTopologyResponse);
+    },
+  );
+}
diff --git a/apps/bff/src/http/query/menu.ts b/apps/bff/src/http/query/menu.ts
index 12529c5..2193493 100644
--- a/apps/bff/src/http/query/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -28,7 +28,7 @@ import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
 import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
-import { allLayerTemplates, getLayerTemplate, type LayerComponentFlags, type LayerTemplate } from '../../logic/layers/loader.js';
+import type { LayerComponentFlags, LayerTemplate } from '../../logic/layers/loader.js';
 import { getSyncStatus } from '../../logic/templates/sync.js';
 import { iterateBundledTemplates } from '../../logic/templates/aggregator.js';
 import { formatName, parseEnvelope } from '../../logic/templates/names.js';
@@ -36,7 +36,8 @@ import type { TemplateRow } from '../../logic/templates/sync.js';
 import type { ServiceLayerCatalog } from '../../logic/services/service-layer-catalog.js';
 import { logger } from '../../logger.js';
 import type { Locale } from '../../i18n/index.js';
-import { localize, getLayerOverlay, localeFromRequest } from '../../i18n/index.js';
+import { localizeContent, getLayerOverlay, localeFromRequest } from '../../i18n/index.js';
+import { oapOverlayContentFromRows } from '../../logic/templates/overlay.js';
 
 /**
  * Map the JSON config's `components.*` flags onto the wire `caps`
@@ -44,20 +45,6 @@ import { localize, getLayerOverlay, localeFromRequest } from '../../i18n/index.j
  * consults. We expand a few aliases (service ⇒ no separate cap; the
  * components flag is the source of truth for whether the page exists).
  */
-/** Fill component flags the live template omits from the bundled one, so
- *  a newly-shipped capability surfaces on an OAP whose stored template
- *  predates it (no re-push needed). Flags the live template defines
- *  (true OR false) are kept; bundled only fills `undefined` keys. */
-function mergeComponentFallback(rawKey: string, live: LayerComponentFlags): LayerComponentFlags {
-  const bundled = getLayerTemplate(rawKey)?.components;
-  if (!bundled) return live;
-  const merged: LayerComponentFlags = { ...live };
-  for (const [k, v] of Object.entries(bundled) as [keyof LayerComponentFlags, boolean][]) {
-    if (merged[k] === undefined) merged[k] = v;
-  }
-  return merged;
-}
-
 function componentsToCaps(components: LayerComponentFlags): LayerCaps {
   return {
     dashboards: components.service !== false,
@@ -65,7 +52,10 @@ function componentsToCaps(components: LayerComponentFlags): LayerCaps {
     endpoints: !!components.endpoints,
     endpointDependency: !!components.endpointDependency,
     serviceMap: !!components.topology,
-    instanceTopology: !!components.topology,
+    // instanceTopology is gated by the presence of the
+    // topology.instanceTopology config block, not the component flag —
+    // overridden per-layer at the call site (see resolveLayerDef).
+    instanceTopology: false,
     processTopology: !!components.topology,
     traces: !!components.traces,
     logs: !!components.logs,
@@ -108,16 +98,20 @@ interface LayerSyncSnapshot {
    *  `pickLayerContent`. Empty when OAP is unreachable; the menu
    *  then falls back to bundled cleanly. */
   layerRowsByName: Map<string, TemplateRow>;
+  /** All sync rows (incl. per-locale overlay rows) so the menu can apply
+   *  the live OAP translation overlay on top of the disk overlay — same
+   *  remote-first localization the config-bundle endpoint does. */
+  rows: TemplateRow[];
 }
 
-/** Read the shared 30s sync cache once and project the two things the
- *  menu needs out of it: which layer rows are admin-disabled, and the
- *  full per-name remote envelope content so the menu can prefer
- *  operator edits over disk-bundled defaults. Soft-fails to an empty
- *  snapshot so the sidebar never breaks because the template status
- *  couldn't be read. */
+/** Read the shared 30s sync cache once and project the things the menu
+ *  needs out of it: which layer rows are admin-disabled, the full
+ *  per-name remote envelope content so the menu can prefer operator edits
+ *  over disk-bundled defaults, and all rows for OAP translation overlays.
+ *  Soft-fails to an empty snapshot so the sidebar never breaks because the
+ *  template status couldn't be read. */
 async function layerSyncSnapshot(deps: MenuRouteDeps): Promise<LayerSyncSnapshot> {
-  const empty: LayerSyncSnapshot = { disabled: new Set(), layerRowsByName: new Map() };
+  const empty: LayerSyncSnapshot = { disabled: new Set(), layerRowsByName: new Map(), rows: [] };
   if (!deps.uiTemplateClient) return empty;
   try {
     const sync = await getSyncStatus({
@@ -136,7 +130,7 @@ async function layerSyncSnapshot(deps: MenuRouteDeps): Promise<LayerSyncSnapshot
         disabled.add(canonical(row.key.toUpperCase()));
       }
     }
-    return { disabled, layerRowsByName };
+    return { disabled, layerRowsByName, rows: sync.rows };
   } catch {
     // Status unavailable — show every layer rather than hide wrongly.
   }
@@ -188,7 +182,7 @@ const LAYER_DEFAULTS: Record<string, { color: string; slots: LayerSlots; caps: L
     color: 'var(--sw-accent)',
     slots: { services: 'Services', instances: 'Instances', endpoints: 'API', endpointDependency: 'API dependency' },
     caps: {
-      serviceMap: true, endpointDependency: true, instanceTopology: true, processTopology: true,
+      serviceMap: true, endpointDependency: true, processTopology: true,
       dashboards: true, traces: true, logs: true,
       traceProfiling: true, ebpfProfiling: true, asyncProfiling: true, events: true,
     },
@@ -197,14 +191,14 @@ const LAYER_DEFAULTS: Record<string, { color: string; slots: LayerSlots; caps: L
     color: 'var(--sw-info)',
     slots: { services: 'Services', instances: 'Sidecars', endpoints: 'Endpoints' },
     caps: {
-      serviceMap: true, endpointDependency: true, instanceTopology: true,
+      serviceMap: true, endpointDependency: true,
       dashboards: true, traces: true, logs: true, events: true,
     },
   },
   MESH_CP: { color: 'var(--sw-info)', slots: { services: 'Control-plane services' }, caps: { dashboards: true } },
-  MESH_DP: { color: 'var(--sw-info)', slots: { services: 'Data-plane services', instances: 'Sidecars' }, caps: { dashboards: true, instanceTopology: true } },
-  K8S: { color: 'var(--sw-purple)', slots: { services: 'Workloads', instances: 'Pods' }, caps: { serviceMap: true, instanceTopology: true, dashboards: true, events: true } },
-  K8S_SERVICE: { color: 'var(--sw-purple)', slots: { services: 'K8s services', instances: 'Pods' }, caps: { serviceMap: true, instanceTopology: true, dashboards: true } },
+  MESH_DP: { color: 'var(--sw-info)', slots: { services: 'Data-plane services', instances: 'Sidecars' }, caps: { dashboards: true } },
+  K8S: { color: 'var(--sw-purple)', slots: { services: 'Workloads', instances: 'Pods' }, caps: { serviceMap: true, dashboards: true, events: true } },
+  K8S_SERVICE: { color: 'var(--sw-purple)', slots: { services: 'K8s services', instances: 'Pods' }, caps: { serviceMap: true, dashboards: true } },
   BROWSER: { color: 'var(--sw-cyan)', slots: { services: 'Applications', instances: 'Versions', endpoints: 'Pages' }, caps: { dashboards: true, traces: true, logs: true } },
   MYSQL: { color: 'var(--sw-warn)', slots: { services: 'Instances' }, caps: { dashboards: true } },
   POSTGRESQL: { color: 'var(--sw-warn)', slots: { services: 'Instances' }, caps: { dashboards: true } },
@@ -230,21 +224,19 @@ const DEFAULT_FOR_UNKNOWN_LAYER = {
 };
 
 /** Resolve the layer template the menu should serve for `rawKey`,
- *  matching the bundle endpoint's `pickLayerContent` precedence:
+ *  matching the bundle endpoint + the per-page routes: REMOTE-only.
  *    1. remote OAP UI-template row, when present + not disabled
  *       (operator edits go live the moment they push) ;
- *    2. disk-bundled template, when remote is absent ;
- *    3. null when neither exists (caller falls back to LAYER_DEFAULTS).
- *  Mirrors the bundle endpoint so the sidebar metadata and the
- *  rendered per-layer pages agree on which version of a template is
- *  live — previously the menu always read disk, so an operator who
- *  pushed an edited `components` set saw the dashboard reflect it
- *  while the sidebar caps / slots / alias stayed on the disk copy. */
+ *    2. null otherwise — the caller (`deriveLayer`) then renders the
+ *       in-code `LAYER_DEFAULTS`, NOT the disk-bundled template.
+ *  Bundled disk content is the seed/reset source (it syncs INTO remote
+ *  on boot), never a render-time fallback: when the template store is
+ *  unreachable, the snapshot is empty → every layer resolves to its
+ *  hard-coded default here, and the per-page features block. */
 function resolveLayerTemplate(
   rawKey: string,
   layerRowsByName: Map<string, TemplateRow>,
 ): LayerTemplate | null {
-  const bundled = getLayerTemplate(rawKey);
   const row = layerRowsByName.get(formatName('layer', rawKey.toUpperCase()));
   if (row && row.status !== 'disabled' && row.effective === 'remote' && row.remote) {
     const env = parseEnvelope(row.remote.configuration);
@@ -252,7 +244,7 @@ function resolveLayerTemplate(
       return env.content as LayerTemplate;
     }
   }
-  return bundled;
+  return null;
 }
 
 function deriveLayer(
@@ -263,17 +255,20 @@ function deriveLayer(
   normal: boolean | null,
   locale: Locale,
   layerRowsByName: Map<string, TemplateRow>,
+  /** Live OAP translation overlay content for this (layer, locale), or
+   *  null — applied on top of the disk overlay (remote wins per leaf). */
+  oapOverlay: unknown,
 ): LayerDef {
   // Remote (OAP) wins when present — alias / color / slots / caps /
   // components / documentLink follow the operator's published edits.
-  // Disk-bundled template is the fallback for remote-absent layers
-  // (bundled-only / OAP unreachable). Hardcoded LAYER_DEFAULTS only
-  // applies for layers without ANY template (older OAP layers, custom
-  // layers added on-the-fly by an OAP plugin we don't ship a template
-  // for).
+  // When no remote row is live (not yet synced, OR the template store is
+  // unreachable so the snapshot is empty), we fall to the hard-coded
+  // LAYER_DEFAULTS — NOT the disk-bundled template. Bundled is the
+  // seed/reset source, never a render-time fallback; the per-page
+  // features block in that state while the sidebar still navigates.
   const rawTpl = resolveLayerTemplate(rawKey, layerRowsByName);
   const tpl = rawTpl
-    ? localize<LayerTemplate>(rawTpl, getLayerOverlay(rawKey, locale), locale)
+    ? localizeContent<LayerTemplate>(rawTpl, oapOverlay, getLayerOverlay(rawKey, locale), locale)
     : null;
   if (tpl) {
     return {
@@ -288,9 +283,26 @@ function deriveLayer(
       visibility: tpl.visibility,
       documentLink: tpl.documentLink ?? undefined,
       slots: tpl.slots,
-      // Bundled fills component flags the live template omits (see
-      // mergeComponentFallback) — scoped to caps, not widgets/metrics.
-      caps: componentsToCaps(mergeComponentFallback(rawKey, tpl.components)),
+      // Caps come straight from the in-use (remote-or-bundled) template's
+      // component flags — remote-first. Missing flags fall to the
+      // componentsToCaps defaults, never to the bundled copy of an
+      // already-published layer: bundled is the seed/reset source (it
+      // syncs INTO remote on boot), not a render-time merge partner.
+      // instanceTopology cap follows the served topology config's
+      // `instanceTopology` block (same source the topology routes read),
+      // so the Instance-map drill-down only appears where it's configured.
+      caps: ((): LayerCaps => {
+        const c = componentsToCaps(tpl.components);
+        // Read the EFFECTIVE (remote-or-bundled) template — the same one
+        // the topology routes serve — so the Instance-map drill-down is
+        // available iff it's enabled on the in-use template, matching the
+        // admin. Gate on the parent Topology component (`serviceMap` =
+        // `components.topology`): instance topology is a drill-down OF the
+        // topology map, so disabling the Topology component must hide it
+        // too — even if a stale `topology.instanceTopology` block lingers.
+        c.instanceTopology = c.serviceMap && !!rawTpl?.topology?.instanceTopology;
+        return c;
+      })(),
       header: tpl.header,
       metrics: tpl.metrics,
       overview: tpl.overview,
@@ -348,14 +360,19 @@ export function registerMenuRoute(app: FastifyInstance, deps: MenuRouteDeps): vo
         }
       }
 
-      // Catalog order = the order the JSON layer templates are loaded
-      // (filesystem order under bundled_templates/layers/, alphabetical
-      // by basename). Active OAP-reported layers that have no template
-      // get appended at the end so nothing disappears from the sidebar.
+      // Runtime registry = the layer templates synced to OAP, NOT the disk
+      // bundle. The same snapshot also gives the disabled set + per-layer
+      // remote content for deriveLayer. (Disk files reach OAP only via
+      // boot-seed / admin reset; they're never read at render time.)
+      const { disabled, layerRowsByName, rows } = await layerSyncSnapshot(deps);
+
+      // Order = the synced layer rows (sorted by key), then any OAP-active
+      // layer with no synced template, appended so nothing disappears.
       const ordered: string[] = [];
       const seen = new Set<string>();
-      for (const tpl of allLayerTemplates()) {
-        const k = canonical(tpl.key.toUpperCase());
+      for (const row of rows) {
+        if (row.kind !== 'layer' || row.locale !== undefined) continue;
+        const k = canonical(row.key.toUpperCase());
         if (seen.has(k)) continue;
         seen.add(k);
         ordered.push(k);
@@ -376,12 +393,8 @@ export function registerMenuRoute(app: FastifyInstance, deps: MenuRouteDeps): vo
       // feedback. Add more keys here if other internal-only layers
       // need the same treatment.
       const HIDDEN_LAYERS = new Set(['BANYANDB']);
-      // Layers whose template was disabled in the admin — soft-deleted, so
-      // drop them from the sidebar (matches how disabled overviews vanish).
-      // Same sync read also gives us the per-layer remote envelope content
-      // so deriveLayer can prefer operator-pushed edits over disk-bundled
-      // defaults (alias / components / slots / caps / colour).
-      const { disabled, layerRowsByName } = await layerSyncSnapshot(deps);
+      // Disabled-in-admin layers are soft-deleted — drop from the sidebar
+      // (matches how disabled overviews vanish).
       const layers = ordered
         .filter((key) => !HIDDEN_LAYERS.has(key) && !disabled.has(key))
         .map((key) =>
@@ -393,6 +406,7 @@ export function registerMenuRoute(app: FastifyInstance, deps: MenuRouteDeps): vo
             normalByCanonical.get(key) ?? null,
             locale,
             layerRowsByName,
+            oapOverlayContentFromRows(rows, 'layer', key, locale),
           ),
         );
 
@@ -403,22 +417,22 @@ export function registerMenuRoute(app: FastifyInstance, deps: MenuRouteDeps): vo
       };
       return reply.send(body);
     } catch (err) {
-      // OAP unreachable — fall back to the bundled layer templates so the
-      // sidebar still renders navigation (each page surfaces its own
-      // OAP-down state). Service counts are unknown (-1) and layers are
-      // marked inactive; everything else (alias / color / slots / caps)
-      // comes from the bundled template — no remote rows available, so
-      // we pass an empty row map and deriveLayer falls through to
-      // bundled cleanly.
+      // OAP query unreachable — no layer list from OAP. Render a hard-coded
+      // skeleton from the well-known LAYER_DEFAULTS keys so the shell still
+      // navigates (each page surfaces its own OAP-down state, and the
+      // banner explains). NOT the disk bundle: the runtime never reads
+      // bundled files; defaults here are the in-code LAYER_DEFAULTS that
+      // deriveLayer falls to on an empty row map. Counts unknown (-1),
+      // layers inactive.
       const HIDDEN_LAYERS = new Set(['BANYANDB']);
       const seen = new Set<string>();
       const layers: LayerDef[] = [];
       const emptyRows = new Map<string, TemplateRow>();
-      for (const tpl of allLayerTemplates()) {
-        const key = canonical(tpl.key.toUpperCase());
+      for (const rawKey of Object.keys(LAYER_DEFAULTS)) {
+        const key = canonical(rawKey);
         if (seen.has(key) || HIDDEN_LAYERS.has(key)) continue;
         seen.add(key);
-        layers.push(deriveLayer(key, false, null, -1, null, locale, emptyRows));
+        layers.push(deriveLayer(key, false, null, -1, null, locale, emptyRows, null));
       }
       const body: MenuResponse = {
         layers,
diff --git a/apps/bff/src/http/query/topology.ts b/apps/bff/src/http/query/topology.ts
index 6818f0c..1794b6d 100644
--- a/apps/bff/src/http/query/topology.ts
+++ b/apps/bff/src/http/query/topology.ts
@@ -43,6 +43,7 @@ import type {
   TopologyMetricDef,
   TopologyNode,
   TopologyResponse,
+  UITemplateClient,
 } from '@skywalking-horizon-ui/api-client';
 import { requireAuth } from '../../user/middleware.js';
 import {  graphqlPost, buildOapOpts } from '../../client/graphql.js';
@@ -54,13 +55,18 @@ import {
   type TimeStep,
   type Window,
 } from '../../util/window.js';
-import { getLayerTemplate, topologyConfigFor } from '../../logic/layers/loader.js';
+import { topologyConfigFor } from '../../logic/layers/loader.js';
+import { resolveEffectiveLayer } from '../../logic/layers/effective.js';
+import { parsePreviewTopology } from '../../logic/layers/preview.js';
 import { getServiceHierarchy } from '../../logic/oap/hierarchy.js';
 
 export interface TopologyRouteDeps {
   config: ConfigSource;
   sessions: SessionStore;
   fetch?: FetchLike;
+  /** OAP UI-template client — lets the route serve the in-use
+   *  (remote-or-bundled) topology config, matching the admin + sidebar. */
+  uiTemplateClient?: () => UITemplateClient;
 }
 
 interface OapTopoNode {
@@ -107,7 +113,7 @@ interface MqeValueRow {
 interface MqeResult {
   values?: MqeValueRow[];
 }
-interface MqeShape {
+export interface MqeShape {
   type?: string;
   error?: string | null;
   results?: MqeResult[];
@@ -166,7 +172,7 @@ function relationFragment(
   );
 }
 
-function aggregateMqe(env: MqeShape | undefined, kind: 'avg' | 'sum'): number | null {
+export function aggregateMqe(env: MqeShape | undefined, kind: 'avg' | 'sum'): number | null {
   if (!env || env.error) return null;
   const values = env.results?.[0]?.values ?? [];
   const nums: number[] = [];
@@ -186,7 +192,7 @@ function aggregateMqe(env: MqeShape | undefined, kind: 'avg' | 'sum'): number |
  * sparkline chart (client | server) so the operator sees the trend
  * shape over the duration window rather than a single scalar.
  */
-function seriesFromMqe(env: MqeShape | undefined): Array<number | null> | null {
+export function seriesFromMqe(env: MqeShape | undefined): Array<number | null> | null {
   if (!env || env.error) return null;
   const values = env.results?.[0]?.values ?? [];
   if (values.length === 0) return null;
@@ -266,12 +272,33 @@ export function registerTopologyRoute(app: FastifyInstance, deps: TopologyRouteD
         step?: string;
         startMs?: string;
         endMs?: string;
+        previewConfig?: string;
       };
       const serviceArg = (q.service ?? '').trim();
       const depth = Math.max(1, Math.min(3, Number(q.depth) || 1));
 
-      const template = getLayerTemplate(layerKey);
-      const topoCfg: TopologyConfig = topologyConfigFor(template);
+      // Admin "Preview" mode: the page forwards the operator's unpublished
+      // draft `topology` block so we render it against live OAP without
+      // publishing first. When present + valid it wins outright — the
+      // remote-resolved config and its block/disabled gate don't apply to
+      // a draft the operator is actively editing.
+      const previewCfg = parsePreviewTopology(q.previewConfig);
+      let topoCfg: TopologyConfig;
+      if (previewCfg) {
+        topoCfg = previewCfg;
+      } else {
+        const eff = await resolveEffectiveLayer(deps.uiTemplateClient, layerKey);
+        if (eff.blocked) {
+          // Template store unreachable or this layer's template disabled —
+          // block: serve no map and no in-code default config. The SPA's
+          // connectivity banner explains the empty state (unreachable); a
+          // disabled layer is hidden from the sidebar so it isn't reached.
+          return reply.send(
+            emptyResponse(layerKey, serviceArg, depth, { nodeMetrics: [], linkServerMetrics: [], linkClientMetrics: [] }, true),
+          );
+        }
+        topoCfg = topologyConfigFor(eff.template);
+      }
 
       const cfgCurrent = deps.config.current;
       const opts = buildOapOpts(cfgCurrent, deps.fetch);
diff --git a/apps/bff/src/http/query/trace.ts b/apps/bff/src/http/query/trace.ts
index b19c011..3dc134c 100644
--- a/apps/bff/src/http/query/trace.ts
+++ b/apps/bff/src/http/query/trace.ts
@@ -45,6 +45,7 @@ import type {
   TraceQueryState,
   TraceSource,
   TracesConfig,
+  UITemplateClient,
   ZipkinTraceDetailResponse,
   ZipkinTraceListResponse,
 } from '@skywalking-horizon-ui/api-client';
@@ -52,7 +53,9 @@ import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
 import {  graphqlPost, buildOapOpts, type GraphqlOptions } from '../../client/graphql.js';
-import { getLayerTemplate, tracesConfigFor } from '../../logic/layers/loader.js';
+import { tracesConfigFor } from '../../logic/layers/loader.js';
+import { resolveEffectiveLayer } from '../../logic/layers/effective.js';
+import { parsePreviewTraces } from '../../logic/layers/preview.js';
 import { detectTraceQueryApi } from '../../util/trace-protocol-cache.js';
 import { withColdStage } from '../../util/duration.js';
 import { fmtSecond, getServerOffsetMinutes, windowFromRange } from '../../util/window.js';
@@ -62,6 +65,8 @@ export interface TraceRouteDeps {
   config: ConfigSource;
   sessions: SessionStore;
   fetch?: FetchLike;
+  /** OAP UI-template client — serve the in-use (remote-or-bundled) config. */
+  uiTemplateClient?: () => UITemplateClient;
 }
 
 const DEFAULT_WINDOW_MIN = 30;
@@ -125,6 +130,10 @@ interface TraceListBody {
   start?: string;
   /** Explicit ISO end (UTC). Pair with `start`. */
   end?: string;
+  /** Admin Preview: the operator's draft `traces` block (JSON string).
+   *  When present + valid, it picks the source instead of the remote
+   *  template — same preview path as topology / endpoint-dependency. */
+  previewConfig?: string;
 }
 
 // ── Native GraphQL queries ────────────────────────────────────────
@@ -405,8 +414,20 @@ export function registerTraceRoutes(app: FastifyInstance, deps: TraceRouteDeps):
         return reply.code(400).send({ error: 'invalid_layer_key' });
       }
       const body = (req.body ?? {}) as TraceListBody;
-      const template = getLayerTemplate(layerKey);
-      const tracesCfg: TracesConfig = tracesConfigFor(template);
+      // Admin Preview: draft `traces` block wins (bypasses remote + block).
+      const previewCfg = parsePreviewTraces(body.previewConfig);
+      let tracesCfg: TracesConfig;
+      if (previewCfg) {
+        tracesCfg = previewCfg;
+      } else {
+        const eff = await resolveEffectiveLayer(deps.uiTemplateClient, layerKey);
+        if (eff.blocked) {
+          // Template store unreachable / layer disabled — block: serve no
+          // traces rather than guessing the source from a default config.
+          return reply.send({ generatedAt: Date.now(), source: body.source ?? 'native' });
+        }
+        tracesCfg = tracesConfigFor(eff.template);
+      }
       const requestedSource: TraceSource = body.source ?? tracesCfg.source;
       const opts = buildOapOpts(deps.config.current, deps.fetch);
       const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
diff --git a/apps/bff/src/logic/dashboard/defaults.ts b/apps/bff/src/logic/dashboard/defaults.ts
index 88a1bc9..a26be14 100644
--- a/apps/bff/src/logic/dashboard/defaults.ts
+++ b/apps/bff/src/logic/dashboard/defaults.ts
@@ -28,7 +28,7 @@
  */
 
 import type { DashboardWidget } from '@skywalking-horizon-ui/api-client';
-import { getLayerTemplate } from '../layers/loader.js';
+import type { LayerTemplate } from '../layers/loader.js';
 
 /** Service-scope service-shaped layers (general / mesh / k8s_service). */
 const SERVICE_WIDGETS: DashboardWidget[] = [
@@ -180,14 +180,18 @@ const GENERIC_WIDGETS: DashboardWidget[] = [
 ];
 
 /**
- * Resolve the default widget set for `(layerKey)`. First tries the
- * JSON layer template (`src/bundled_templates/layers/<key>.json`); falls back to
- * the hardcoded TS sets above when no JSON exists for the layer. JSON
- * wins because that's where operators will eventually edit widgets
- * via the admin page.
+ * Last-resort widget set when no remote template resolved (`tpl` is
+ * `null` — layer not yet synced to OAP, admin-disabled, or the template
+ * store is unreachable). These hardcoded sets are NOT the disk-bundled
+ * templates: bundled config only ever reaches the UI via OAP (sync /
+ * reset / preview). This is a minimal generic KPI seed so a bare grid
+ * isn't completely empty; the connectivity banner separately signals an
+ * unreachable store. When `tpl` carries widgets, those win.
  */
-export function defaultWidgetsFor(layerKey: string): DashboardWidget[] {
-  const tpl = getLayerTemplate(layerKey);
+export function defaultWidgetsFor(
+  tpl: LayerTemplate | null,
+  layerKey: string,
+): DashboardWidget[] {
   if (tpl && tpl.widgets && tpl.widgets.length > 0) return tpl.widgets;
   const k = layerKey.toUpperCase();
   if (k === 'MESH_CP' || k === 'MESH_DP') return SERVICE_WIDGETS;
diff --git a/apps/bff/src/logic/layers/effective.ts b/apps/bff/src/logic/layers/effective.ts
new file mode 100644
index 0000000..d2443ec
--- /dev/null
+++ b/apps/bff/src/logic/layers/effective.ts
@@ -0,0 +1,105 @@
+/*
+ * 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.
+ */
+
+/**
+ * Resolve the **in-use** layer template — strictly REMOTE, with an
+ * explicit block/default distinction.
+ *
+ * Runtime is remote-only: the disk-bundled template reaches the UI ONLY
+ * by being synced INTO OAP (boot seed / admin reset-from-bundle) or via
+ * admin preview — it is NEVER a render-time fallback. {@link
+ * resolveEffectiveLayer} folds every state into two signals routes act on:
+ *
+ *   - `template` (non-null) — the remote OAP row's content, render it.
+ *   - `blocked: true`       — the template store is UNREACHABLE, or the
+ *     layer's row is admin-DISABLED. A deliberate **feature block**: the
+ *     route serves nothing (empty) and the UI surfaces the connectivity
+ *     banner (unreachable) / the layer is hidden from nav (disabled).
+ *     Routes MUST NOT fall back to in-code defaults here.
+ *   - `template: null, blocked: false` — reachable but NO remote row
+ *     (an OAP layer Horizon ships no bundled template for, or one not yet
+ *     synced). Routes render their hard-coded in-code **defaults** — this
+ *     is the "remote OR default" runtime, never "remote OR bundled".
+ *
+ * Reads the shared 30s sync cache, so it's cheap on the hot path.
+ */
+
+import type { UITemplateClient } from '@skywalking-horizon-ui/api-client';
+import type { LayerTemplate } from './loader.js';
+import { getSyncStatus } from '../templates/sync.js';
+import { iterateBundledTemplates } from '../templates/aggregator.js';
+import { formatName, parseEnvelope } from '../templates/names.js';
+import { logger } from '../../logger.js';
+
+export interface EffectiveLayer {
+  /** Remote OAP row content to render, or `null` (use in-code defaults
+   *  when `blocked` is false, render nothing when `blocked` is true). */
+  template: LayerTemplate | null;
+  /** Template store unreachable OR this layer's row disabled — block the
+   *  feature (no defaults, no bundled). */
+  blocked: boolean;
+}
+
+export async function resolveEffectiveLayer(
+  uiTemplateClient: (() => UITemplateClient) | undefined,
+  layerKey: string,
+): Promise<EffectiveLayer> {
+  // Unconfigured (tests / no OAP admin wired) — never hard-block on a
+  // missing client; fall through to in-code defaults.
+  if (!uiTemplateClient) return { template: null, blocked: false };
+  try {
+    const sync = await getSyncStatus({
+      client: uiTemplateClient(),
+      bundled: () => iterateBundledTemplates(),
+      logger,
+    });
+    if (sync.unreachable) return { template: null, blocked: true }; // store down → block
+    const name = formatName('layer', layerKey.toUpperCase());
+    const row = sync.rows.find(
+      (r) => r.name === name && r.kind === 'layer' && r.locale === undefined,
+    );
+    // No remote row → reachable but unknown/unsynced layer → in-code defaults.
+    if (!row) return { template: null, blocked: false };
+    // Admin disabled the layer's template → block (the sidebar also hides it).
+    if (row.status === 'disabled') return { template: null, blocked: true };
+    if (row.effective === 'remote' && row.remote) {
+      const env = parseEnvelope(row.remote.configuration);
+      if (env?.content && typeof env.content === 'object' && 'key' in env.content) {
+        return { template: env.content as LayerTemplate, blocked: false };
+      }
+    }
+    // Bundled-fallback (seed didn't land) or unparseable remote → we do
+    // NOT resurrect bundled at render time; fall to in-code defaults.
+    return { template: null, blocked: false };
+  } catch {
+    // Unexpected read error (getSyncStatus soft-fails internally, so this
+    // is rare) — default rather than blank the app on a transient bug.
+    return { template: null, blocked: false };
+  }
+}
+
+/** Back-compat: the resolved remote template, or `null` for both the
+ *  blocked and the use-defaults cases. Callers that localize / read the
+ *  template content but don't gate on the block (e.g. translation
+ *  overlay) use this; routes that must block use {@link
+ *  resolveEffectiveLayer} directly. */
+export async function resolveEffectiveLayerTemplate(
+  uiTemplateClient: (() => UITemplateClient) | undefined,
+  layerKey: string,
+): Promise<LayerTemplate | null> {
+  return (await resolveEffectiveLayer(uiTemplateClient, layerKey)).template;
+}
diff --git a/apps/bff/src/logic/layers/loader.ts b/apps/bff/src/logic/layers/loader.ts
index 42621b9..db7d3e6 100644
--- a/apps/bff/src/logic/layers/loader.ts
+++ b/apps/bff/src/logic/layers/loader.ts
@@ -39,6 +39,7 @@ import type {
   DashboardScope,
   DashboardWidget,
   EndpointDependencyConfig,
+  InstanceTopologyConfig,
   ProcessTopologyConfig,
   ServiceNamingRule,
   TopologyConfig,
@@ -47,7 +48,7 @@ import type {
 } from '@skywalking-horizon-ui/api-client';
 import { isOverlayFilename, reloadI18nStore } from '../../i18n/store.js';
 
-export type { TopologyConfig, EndpointDependencyConfig, ProcessTopologyConfig, TopologyMetricDef, TracesConfig, ServiceNamingRule };
+export type { TopologyConfig, InstanceTopologyConfig, EndpointDependencyConfig, ProcessTopologyConfig, TopologyMetricDef, TracesConfig, ServiceNamingRule };
 
 export interface LayerComponentFlags {
   service?: boolean;
@@ -78,6 +79,10 @@ export interface LayerSlotsConfig {
   instances?: string;
   endpoints?: string;
   endpointDependency?: string;
+  /** Service-topology tab label (default "Topology"). */
+  topology?: string;
+  /** Instance-topology sub-tab label (default "Instance map"). */
+  instanceTopology?: string;
 }
 
 export interface LayerMetricColumn {
@@ -585,6 +590,17 @@ export function topologyConfigFor(template: LayerTemplate | null): TopologyConfi
   return BOOSTER_TOPOLOGY_DEFAULTS;
 }
 
+/** Resolve the instance-topology drill-down config, or `null` when the
+ *  layer doesn't opt in. Nested under the layer's `topology` block (so it
+ *  rides the same import/export tooling); only the layers that ship it
+ *  (general / mesh / k8s_service / cilium_service) return non-null. The
+ *  service map keys the edge drill-down affordance off this presence. */
+export function instanceTopologyConfigFor(
+  template: LayerTemplate | null,
+): InstanceTopologyConfig | null {
+  return template?.topology?.instanceTopology ?? null;
+}
+
 /** Resolve the endpoint-dependency config — same fallback rule. */
 export function endpointDependencyConfigFor(
   template: LayerTemplate | null,
diff --git a/apps/bff/src/logic/layers/preview.ts b/apps/bff/src/logic/layers/preview.ts
new file mode 100644
index 0000000..62d581a
--- /dev/null
+++ b/apps/bff/src/logic/layers/preview.ts
@@ -0,0 +1,117 @@
+/*
+ * 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.
+ */
+
+/**
+ * Parse + bound a client-supplied **preview** config block for the
+ * topology / endpoint-dependency / trace pages.
+ *
+ * The admin "Preview" button opens the live layer page in `?mode=preview`
+ * and the page forwards the operator's unpublished draft config here, so
+ * the BFF can render the draft's MQE against live OAP without publishing
+ * to OAP first. This is the only path that runs config the UI hands us —
+ * so it is bounded: a hard size ceiling, capped metric/expression counts,
+ * and required string fields. The MQE itself is operator-authored (they
+ * hold `dashboard:write` to even produce a draft) and runs read-only,
+ * the same trust class as the dashboard POST's `widgets[]`.
+ *
+ * Returns the typed config on success, or `null` for absent / oversized /
+ * malformed input — callers then fall back to the normal remote-resolved
+ * config (i.e. preview silently no-ops rather than erroring).
+ */
+
+import type {
+  TopologyConfig,
+  EndpointDependencyConfig,
+  TracesConfig,
+  ProcessTopologyConfig,
+  TopologyMetricDef,
+} from './loader.js';
+
+const MAX_RAW_BYTES = 32_000;
+const MAX_METRICS = 32;
+const MAX_MQE_LEN = 512;
+
+function isMetricList(v: unknown): v is TopologyMetricDef[] {
+  if (!Array.isArray(v) || v.length > MAX_METRICS) return false;
+  return v.every((m) => {
+    if (!m || typeof m !== 'object') return false;
+    const d = m as Record<string, unknown>;
+    return (
+      typeof d.id === 'string' &&
+      d.id.length > 0 &&
+      d.id.length <= 64 &&
+      typeof d.mqe === 'string' &&
+      d.mqe.length > 0 &&
+      d.mqe.length <= MAX_MQE_LEN
+    );
+  });
+}
+
+function parseJson(raw: string | undefined): Record<string, unknown> | null {
+  if (!raw || raw.length > MAX_RAW_BYTES) return null;
+  try {
+    const o = JSON.parse(raw);
+    return o && typeof o === 'object' && !Array.isArray(o) ? (o as Record<string, unknown>) : null;
+  } catch {
+    return null;
+  }
+}
+
+/** `topology` block — node + per-side link metric lists (+ optional
+ *  nested `instanceTopology`, which rides through verbatim once its own
+ *  metric lists pass the same bound). */
+export function parsePreviewTopology(raw: string | undefined): TopologyConfig | null {
+  const o = parseJson(raw);
+  if (!o || !isMetricList(o.nodeMetrics)) return null;
+  if (o.linkServerMetrics !== undefined && !isMetricList(o.linkServerMetrics)) return null;
+  if (o.linkClientMetrics !== undefined && !isMetricList(o.linkClientMetrics)) return null;
+  const inst = o.instanceTopology as Record<string, unknown> | undefined;
+  if (inst !== undefined) {
+    if (!inst || typeof inst !== 'object' || !isMetricList(inst.nodeMetrics)) return null;
+    if (inst.linkServerMetrics !== undefined && !isMetricList(inst.linkServerMetrics)) return null;
+    if (inst.linkClientMetrics !== undefined && !isMetricList(inst.linkClientMetrics)) return null;
+  }
+  return o as unknown as TopologyConfig;
+}
+
+/** `endpointDependency` block — node + (server-only) link metric lists. */
+export function parsePreviewEndpointDep(raw: string | undefined): EndpointDependencyConfig | null {
+  const o = parseJson(raw);
+  if (!o || !isMetricList(o.nodeMetrics)) return null;
+  if (o.linkMetrics !== undefined && !isMetricList(o.linkMetrics)) return null;
+  return o as unknown as EndpointDependencyConfig;
+}
+
+/** `traces` block — just the source selector. */
+export function parsePreviewTraces(raw: string | undefined): TracesConfig | null {
+  const o = parseJson(raw);
+  if (!o) return null;
+  const source = o.source;
+  if (source !== 'native' && source !== 'zipkin' && source !== 'both') return null;
+  return { source } as TracesConfig;
+}
+
+/** `processTopology` block — the network-profiling edge-detail metric
+ *  lists (ProcessRelation scope, client + server). */
+export function parsePreviewProcessTopology(raw: string | undefined): ProcessTopologyConfig | null {
+  const o = parseJson(raw);
+  if (!o) return null;
+  if (o.edgeClientMetrics !== undefined && !isMetricList(o.edgeClientMetrics)) return null;
+  if (o.edgeServerMetrics !== undefined && !isMetricList(o.edgeServerMetrics)) return null;
+  if (o.edgeClientMetrics === undefined && o.edgeServerMetrics === undefined) return null;
+  return o as unknown as ProcessTopologyConfig;
+}
diff --git a/apps/bff/src/logic/overview/effective.ts b/apps/bff/src/logic/overview/effective.ts
new file mode 100644
index 0000000..43be41e
--- /dev/null
+++ b/apps/bff/src/logic/overview/effective.ts
@@ -0,0 +1,92 @@
+/*
+ * 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.
+ */
+
+/**
+ * Resolve the **in-use** overview dashboards — strictly REMOTE.
+ *
+ * Runtime is remote-only: bundled disk overviews reach the UI only by
+ * being synced INTO OAP (boot seed / admin reset) or via admin preview —
+ * never as a render-time fallback. So overviews are enumerated from the
+ * OAP rows: non-disabled remote rows win; admin-disabled overviews are
+ * dropped (`null` for a single id). When the template store is
+ * unreachable this returns empty / `null` — a deliberate feature block
+ * (the UI shows the connectivity banner), not stale bundled config.
+ *
+ * The direct overview routes (`GET /api/overview/dashboards` + `/:id`)
+ * MUST go through this — `/:id` is the primary render path. Mirrors
+ * {@link resolveEffectiveLayerTemplate} for layers.
+ */
+
+import type { OverviewDashboard, UITemplateClient } from '@skywalking-horizon-ui/api-client';
+import { getSyncStatus, type TemplateRow } from '../templates/sync.js';
+import { iterateBundledTemplates } from '../templates/aggregator.js';
+import { formatName, parseEnvelope } from '../templates/names.js';
+import { logger } from '../../logger.js';
+
+function isOverviewLike(v: unknown): v is OverviewDashboard {
+  return !!v && typeof v === 'object' && 'id' in (v as Record<string, unknown>);
+}
+
+async function snapshot(
+  uiTemplateClient: () => UITemplateClient,
+): Promise<{ rows: TemplateRow[]; unreachable: boolean }> {
+  const sync = await getSyncStatus({
+    client: uiTemplateClient(),
+    bundled: () => iterateBundledTemplates(),
+    logger,
+  });
+  return { rows: sync.rows, unreachable: sync.unreachable };
+}
+
+export async function resolveEffectiveOverviews(
+  uiTemplateClient: (() => UITemplateClient) | undefined,
+): Promise<OverviewDashboard[]> {
+  if (!uiTemplateClient) return [];
+  try {
+    const sync = await snapshot(uiTemplateClient);
+    if (sync.unreachable) return []; // template store down → blocked
+    const out: OverviewDashboard[] = [];
+    for (const row of sync.rows) {
+      if (row.kind !== 'overview' || row.status === 'disabled' || !row.remote) continue;
+      if (row.locale !== undefined) continue; // skip per-locale overlay rows
+      const env = parseEnvelope(row.remote.configuration);
+      if (env && isOverviewLike(env.content)) out.push(env.content);
+    }
+    return out;
+  } catch {
+    return [];
+  }
+}
+
+export async function resolveEffectiveOverview(
+  uiTemplateClient: (() => UITemplateClient) | undefined,
+  id: string,
+): Promise<OverviewDashboard | null> {
+  if (!uiTemplateClient) return null;
+  try {
+    const sync = await snapshot(uiTemplateClient);
+    if (sync.unreachable) return null;
+    const row = sync.rows.find(
+      (r) => r.name === formatName('overview', id) && r.kind === 'overview' && r.locale === undefined,
+    );
+    if (!row || row.status === 'disabled' || !row.remote) return null;
+    const env = parseEnvelope(row.remote.configuration);
+    return env && isOverviewLike(env.content) ? env.content : null;
+  } catch {
+    return null;
+  }
+}
diff --git a/apps/bff/src/logic/templates/overlay.ts b/apps/bff/src/logic/templates/overlay.ts
new file mode 100644
index 0000000..f72479f
--- /dev/null
+++ b/apps/bff/src/logic/templates/overlay.ts
@@ -0,0 +1,78 @@
+/*
+ * 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.
+ */
+
+/**
+ * Resolve the live **OAP translation overlay** content for one
+ * (kind, key, locale) — the operator-pushed per-locale row.
+ *
+ * The disk `*.i18n.<lang>.json` files are only seeds; the live
+ * translation is an OAP overlay row. The config bundle already merges
+ * this row on top of the disk overlay (`localizeContent(content,
+ * oapOverlay, diskOverlay, locale)`), so render routes that localize
+ * with the disk overlay ALONE show stale text once an operator pushes a
+ * translation. This helper gives those direct routes the same remote-
+ * first overlay the bundle uses. Reads the shared 30s sync cache, so
+ * it's cheap on the hot path; soft-fails to `null` (disk-only) on
+ * English / no-client / OAP-unreachable / parse error.
+ */
+
+import type { UITemplateClient } from '@skywalking-horizon-ui/api-client';
+import type { Locale } from '../../i18n/index.js';
+import { getSyncStatus, type TemplateRow } from './sync.js';
+import { iterateBundledTemplates } from './aggregator.js';
+import { parseEnvelope, type TemplateKind } from './names.js';
+import { logger } from '../../logger.js';
+
+/** Sync variant — for callers that already hold the sync rows (e.g. the
+ *  menu's `layerSyncSnapshot`), so they don't re-hit `getSyncStatus`.
+ *  Mirrors `findOverlayRow`: the live per-locale overlay row, not
+ *  disabled. */
+export function oapOverlayContentFromRows(
+  rows: TemplateRow[],
+  kind: TemplateKind,
+  key: string,
+  locale: Locale,
+): unknown {
+  if (locale === 'en') return null;
+  for (const r of rows) {
+    if (r.locale === locale && r.kind === kind && r.key === key && !!r.remote && !r.remote.disabled) {
+      const env = parseEnvelope(r.remote.configuration);
+      return env?.content ?? null;
+    }
+  }
+  return null;
+}
+
+export async function oapOverlayContentFor(
+  uiTemplateClient: (() => UITemplateClient) | undefined,
+  kind: TemplateKind,
+  key: string,
+  locale: Locale,
+): Promise<unknown> {
+  if (locale === 'en' || !uiTemplateClient) return null;
+  try {
+    const sync = await getSyncStatus({
+      client: uiTemplateClient(),
+      bundled: () => iterateBundledTemplates(),
+      logger,
+    });
+    if (sync.unreachable) return null;
+    return oapOverlayContentFromRows(sync.rows, kind, key, locale);
+  } catch {
+    return null;
+  }
+}
diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts
index fff3f37..6c470b9 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -99,6 +99,7 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
 
   // ── Topology (read) ──────────────────────────────────────────────
   'GET /api/layer/:key/topology':                  'topology:read',
+  'GET /api/layer/:key/instance-topology':         'topology:read',
   'GET /api/layer/:key/endpoint-dependency':       'topology:read',
   'GET /api/layer/:key/service-hierarchy':         'topology:read',
 
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index f056d65..0efb562 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -35,6 +35,7 @@ import { registerLandingRoute } from './http/query/landing.js';
 import { registerInstanceRoute } from './http/query/instance.js';
 import { registerEndpointRoute } from './http/query/endpoint.js';
 import { registerTopologyRoute } from './http/query/topology.js';
+import { registerInstanceTopologyRoute } from './http/query/instance-topology.js';
 import { registerLayerServicesRoute } from './http/query/services.js';
 import { registerEndpointDependencyRoute } from './http/query/endpoint-dependency.js';
 import { registerTraceRoutes } from './http/query/trace.js';
@@ -168,24 +169,53 @@ registerMenuRoute(app, {
 registerLandingRoute(app, { config: source, sessions });
 registerInstanceRoute(app, { config: source, sessions });
 registerEndpointRoute(app, { config: source, sessions });
-registerTopologyRoute(app, { config: source, sessions });
+registerTopologyRoute(app, {
+  config: source,
+  sessions,
+  uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
+});
+registerInstanceTopologyRoute(app, {
+  config: source,
+  sessions,
+  uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
+});
 registerLayerServicesRoute(app, { config: source, sessions });
-registerEndpointDependencyRoute(app, { config: source, sessions });
-registerTraceRoutes(app, { config: source, sessions });
+registerEndpointDependencyRoute(app, {
+  config: source,
+  sessions,
+  uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
+});
+registerTraceRoutes(app, {
+  config: source,
+  sessions,
+  uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
+});
 registerTraceTagRoutes(app, { config: source, sessions });
 registerZipkinRoutes(app, { config: source, sessions });
 registerLogRoute(app, { config: source, sessions });
 registerPodLogRoutes(app, { config: source, sessions });
-registerDashboardQueryRoute(app, { config: source, sessions });
+registerDashboardQueryRoute(app, {
+  config: source,
+  sessions,
+  uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
+});
 registerAlarmsQueryRoutes(app, { config: source, sessions, serviceLayer });
 registerPreflightRoutes(app, { config: source, sessions });
 registerTtlRoute(app, { config: source, sessions });
 registerProfileRoutes(app, { config: source, sessions });
-registerEBPFRoutes(app, { config: source, sessions });
+registerEBPFRoutes(app, {
+  config: source,
+  sessions,
+  uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
+});
 registerAsyncProfileRoutes(app, { config: source, sessions });
 
 // ── Config ─────────────────────────────────────────────────────────
-registerDashboardConfigRoute(app, { config: source, sessions });
+registerDashboardConfigRoute(app, {
+  config: source,
+  sessions,
+  uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
+});
 registerLayerTemplateRoutes(app, { config: source, sessions });
 // Spawn the bundled-template fs.watch ONLY in development. Bundled
 // templates ship inside the BFF image — they're immutable in prod
@@ -205,7 +235,11 @@ registerInfra3dConfigRoutes(app, {
 });
 registerInfra3dMetricsRoute(app, { config: source, sessions });
 registerSetupRoutes(app, { config: source, sessions, audit, store: setupStore });
-registerOverviewRoutes(app, { config: source, sessions });
+registerOverviewRoutes(app, {
+  config: source,
+  sessions,
+  uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
+});
 registerConfigBundleRoute(app, {
   config: source,
   sessions,
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 573c601..6f714a6 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -120,10 +120,14 @@ export type {
   DashboardWidgetResult,
   TopologyMetricDef,
   TopologyConfig,
+  InstanceTopologyConfig,
   EndpointDependencyConfig,
   TopologyNode,
   TopologyCall,
   TopologyResponse,
+  InstanceTopologyNode,
+  InstanceTopologyCall,
+  InstanceTopologyResponse,
   EndpointDependencyNode,
   EndpointDependencyCall,
   EndpointDependencyResponse,
@@ -257,7 +261,7 @@ export interface AdminLayerTemplate {
   /** `public` (default) surfaces in the Layers section; `operate`
    *  surfaces in the Self-Observability block under Manage. */
   visibility?: 'public' | 'operate';
-  slots: { services?: string; instances?: string; endpoints?: string; endpointDependency?: string };
+  slots: { services?: string; instances?: string; endpoints?: string; endpointDependency?: string; topology?: string; instanceTopology?: string };
   components: {
     service?: boolean;
     instances?: boolean;
diff --git a/apps/ui/src/api/scopes/layer.ts b/apps/ui/src/api/scopes/layer.ts
index 22b5681..f434fdd 100644
--- a/apps/ui/src/api/scopes/layer.ts
+++ b/apps/ui/src/api/scopes/layer.ts
@@ -20,6 +20,7 @@ import type {
   DashboardResponse,
   DashboardWidget,
   EndpointDependencyResponse,
+  InstanceTopologyResponse,
   LandingConfig,
   LandingResponse,
   ServiceHierarchyResponse,
@@ -187,6 +188,9 @@ export class LayerApi {
     service?: string,
     depth = 1,
     range?: { step: 'MINUTE' | 'HOUR' | 'DAY'; startMs: number; endMs: number },
+    /** Admin preview: the operator's draft `topology` block (JSON string).
+     *  Renders the draft against live OAP instead of the remote template. */
+    previewConfig?: string,
   ): Promise<TopologyResponse> {
     const qs = new URLSearchParams();
     if (service) qs.set('service', service);
@@ -196,18 +200,49 @@ export class LayerApi {
       qs.set('startMs', String(range.startMs));
       qs.set('endMs', String(range.endMs));
     }
+    if (previewConfig) qs.set('previewConfig', previewConfig);
     return this.bff.request(
       'GET',
       `/api/layer/${encodeURIComponent(layerKey)}/topology?${qs.toString()}`,
     );
   }
 
+  /** Instance-to-instance topology between two services, opened from a
+   *  service-map edge. `client` = the edge source service, `server` =
+   *  the edge target service (matches OAP's getServiceInstanceTopology
+   *  clientServiceId / serverServiceId). Only the layers whose topology
+   *  config carries an `instanceTopology` block answer this (404 else). */
+  instanceTopology(
+    layerKey: string,
+    clientServiceId: string,
+    serverServiceId: string,
+    range?: { step: 'MINUTE' | 'HOUR' | 'DAY'; startMs: number; endMs: number },
+    /** Admin preview: the operator's draft `topology` block (JSON string);
+     *  the BFF reads its nested `instanceTopology`. */
+    previewConfig?: string,
+  ): Promise<InstanceTopologyResponse> {
+    const qs = new URLSearchParams({ client: clientServiceId, server: serverServiceId });
+    if (range) {
+      qs.set('step', range.step);
+      qs.set('startMs', String(range.startMs));
+      qs.set('endMs', String(range.endMs));
+    }
+    if (previewConfig) qs.set('previewConfig', previewConfig);
+    return this.bff.request(
+      'GET',
+      `/api/layer/${encodeURIComponent(layerKey)}/instance-topology?${qs.toString()}`,
+    );
+  }
+
   endpointDependency(
     layerKey: string,
     service: string,
     endpoint: string,
+    /** Admin preview: the operator's draft `endpointDependency` block. */
+    previewConfig?: string,
   ): Promise<EndpointDependencyResponse> {
     const qs = new URLSearchParams({ service, endpoint });
+    if (previewConfig) qs.set('previewConfig', previewConfig);
     return this.bff.request(
       'GET',
       `/api/layer/${encodeURIComponent(layerKey)}/endpoint-dependency?${qs.toString()}`,
diff --git a/apps/ui/src/api/scopes/network-profile.ts b/apps/ui/src/api/scopes/network-profile.ts
index 6a0a2d2..4db25e5 100644
--- a/apps/ui/src/api/scopes/network-profile.ts
+++ b/apps/ui/src/api/scopes/network-profile.ts
@@ -92,6 +92,8 @@ export class NetworkProfileApi {
       startTime?: number;
       endTime?: number;
       windowMinutes?: number;
+      /** Admin preview: the operator's draft `processTopology` block. */
+      previewConfig?: string;
     },
   ): Promise<ProcessRelationMetricsResponse> {
     return this.bff.request<ProcessRelationMetricsResponse>(
diff --git a/apps/ui/src/api/scopes/trace.ts b/apps/ui/src/api/scopes/trace.ts
index 1dd436c..09c2486 100644
--- a/apps/ui/src/api/scopes/trace.ts
+++ b/apps/ui/src/api/scopes/trace.ts
@@ -47,6 +47,8 @@ export class TraceApi {
       windowMinutes?: number;
       start?: string;
       end?: string;
+      /** Admin preview: the operator's draft `traces` block (JSON string). */
+      previewConfig?: string;
     } = {},
   ): Promise<TraceListResponse> {
     return this.bff.request<TraceListResponse>(
diff --git a/apps/ui/src/controls/configBundle.ts b/apps/ui/src/controls/configBundle.ts
index 4782739..3772157 100644
--- a/apps/ui/src/controls/configBundle.ts
+++ b/apps/ui/src/controls/configBundle.ts
@@ -66,6 +66,16 @@ function previewContentFor<T>(name: string): T | undefined {
   return previewOverride.get<T>(name) ?? localEdits.get<T>(name);
 }
 
+/** Preview overlay content for a template `name` (a layer / overview
+ *  edit name), or `undefined` when not in preview mode or no override
+ *  exists for it. Exposed so pages that fetch a SINGLE template directly
+ *  from the BFF (e.g. the overview-detail page) honour the same preview
+ *  the bundle already applies to its list views — without it, the admin
+ *  Preview button is a no-op on those pages. */
+export function getPreviewContentFor<T>(name: string): T | undefined {
+  return previewContentFor<T>(name);
+}
+
 /** `local` only when the operator opted to preview unpublished edits;
  *  otherwise `remote` (the default runtime source of truth). */
 function preferParam(): 'local' | 'remote' {
diff --git a/apps/ui/src/controls/previewConfig.ts b/apps/ui/src/controls/previewConfig.ts
new file mode 100644
index 0000000..f7fcde5
--- /dev/null
+++ b/apps/ui/src/controls/previewConfig.ts
@@ -0,0 +1,55 @@
+/*
+ * 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.
+ */
+
+/**
+ * Forward an operator's unpublished draft config block to the BFF in
+ * preview mode.
+ *
+ * Pages that fetch a SINGLE template-driven config directly from the BFF
+ * (topology, endpoint-dependency, traces, network-profiling) resolve the
+ * REMOTE template server-side, so they ignore a local draft unless the UI
+ * hands the draft over. This composable produces a reactive JSON string of
+ * the previewed layer template's `<block>` — `undefined` outside preview
+ * mode or when there's no draft — for passing to that route's
+ * `previewConfig` arg. Including it in the route's queryKey makes the page
+ * re-render when the operator edits + re-previews. Mirrors how the config
+ * bundle already overlays drafts for the dashboard / overview list views.
+ */
+
+import { computed, type ComputedRef } from 'vue';
+import { usePreviewMode } from '@/controls/previewMode';
+import { getPreviewContentFor } from '@/controls/configBundle';
+import { layerEditName } from '@/controls/localTemplateEdits';
+
+export type PreviewBlock =
+  | 'topology'
+  | 'endpointDependency'
+  | 'traces'
+  | 'processTopology';
+
+export function usePreviewLayerBlock(
+  layerKey: ComputedRef<string> | { value: string },
+  block: PreviewBlock,
+): ComputedRef<string | undefined> {
+  const previewMode = usePreviewMode();
+  return computed<string | undefined>(() => {
+    if (!previewMode.value) return undefined;
+    const tpl = getPreviewContentFor<Record<string, unknown>>(layerEditName(layerKey.value));
+    const cfg = tpl?.[block];
+    return cfg ? JSON.stringify(cfg) : undefined;
+  });
+}
diff --git a/apps/ui/src/features/admin/_shared/TemplateStatusBadge.vue b/apps/ui/src/features/admin/_shared/TemplateStatusBadge.vue
index e45adc3..f00df80 100644
--- a/apps/ui/src/features/admin/_shared/TemplateStatusBadge.vue
+++ b/apps/ui/src/features/admin/_shared/TemplateStatusBadge.vue
@@ -53,7 +53,7 @@ function title(s: TemplateStatus): string {
     case 'diverged': return 'OAP-stored copy differs from bundled. OAP wins at render time.';
     case 'disabled': return 'Template is disabled on OAP. Hidden from render until re-enabled.';
     case 'remote-only': return 'OAP has this template but bundled does not. OAP is the only source.';
-    case 'bundled-fallback': return 'OAP has no copy of this template right now — rendering bundled.';
+    case 'bundled-fallback': return 'Shipped with Horizon but not synced to OAP yet. It is NOT rendered as-is — push it (or let boot-seed run) to publish; until then the page uses built-in defaults, and blocks if OAP’s template store is unreachable.';
     default: return '';
   }
 }
diff --git a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index ea08d37..1ede4eb 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -325,15 +325,17 @@ function loadFrom(src: 'local' | 'bundled' | 'remote'): void {
   saveMsg.value = null;
 }
 /** Seed the editor when the selected layer changes. Remote is the
- *  canonical baseline — it's what `pickLayerContent` in the runtime
- *  bundle serves to end users for synced / diverged / remote-only
- *  rows, so the editor opens from remote whenever remote is reachable.
+ *  canonical baseline — it's what the runtime bundle serves to end users
+ *  for synced / diverged / remote-only rows, so the editor opens from
+ *  remote whenever remote is reachable.
  *  Priority:
  *    1. Local draft — unpublished in-progress edits in this browser.
  *    2. Remote — the default for every re-mount when remote exists.
- *    3. Bundled — only when remote is absent (bundled-fallback). The
- *       operator can also hit "Reset to bundled" explicitly to swap;
- *       that path goes through `resetTo`, not this seed function. */
+ *    3. Otherwise: NOTHING. We do NOT auto-load bundled when remote is
+ *       absent — bundled is the seed/reset source, not the working copy,
+ *       and the runtime doesn't render it either. The editor shows a
+ *       "no published version" panel; the operator hits "Reset to bundled"
+ *       to explicitly start from the shipped default (via `resetTo`). */
 function syncDraft(): void {
   if (hasLocalDraft.value) {
     loadFrom('local');
@@ -343,9 +345,23 @@ function syncDraft(): void {
     loadFrom('remote');
     return;
   }
-  loadFrom('bundled');
+  draft.template = null;
+  loadedSnapshot.value = '';
+  saveMsg.value = null;
 }
 
+/** Selected layer exists but has neither a local draft nor an OAP row, so
+ *  nothing is loaded (we don't fall back to bundled). Drives the "no
+ *  published version" panel + its Reset-to-bundled CTA. */
+const noPublishedVersion = computed<boolean>(
+  () =>
+    sourcesReady.value &&
+    !!selectedKey.value &&
+    !hasLocalDraft.value &&
+    !remoteAvailable.value &&
+    !draft.template,
+);
+
 /** Write the editor content to the local draft. If it equals remote, the
  *  draft is cleared instead (no point keeping a draft identical to live) —
  *  this is what makes "Reset to remote" + an edit back to remote disable
@@ -478,6 +494,57 @@ watch(visibleScopes, (scopes) => {
   if (!scopes.includes(activeScope.value)) {
     activeScope.value = scopes[0] ?? 'service';
   }
+  void nextTick(updateScopeScroll);
+});
+
+// ── Scope-tab strip horizontal scroll. The strip can hold ~11 scopes;
+// on a narrow editor it overflows, so we let it scroll and surface
+// chevron buttons that appear only on the side(s) with hidden tabs.
+const scopeNav = ref<HTMLElement | null>(null);
+const canScrollScopeLeft = ref(false);
+const canScrollScopeRight = ref(false);
+function updateScopeScroll(): void {
+  const el = scopeNav.value;
+  if (!el) {
+    canScrollScopeLeft.value = false;
+    canScrollScopeRight.value = false;
+    return;
+  }
+  canScrollScopeLeft.value = el.scrollLeft > 2;
+  canScrollScopeRight.value = Math.ceil(el.scrollLeft + el.clientWidth) < el.scrollWidth - 1;
+}
+function scrollScopeTabs(dir: -1 | 1): void {
+  const el = scopeNav.value;
+  if (!el) return;
+  el.scrollBy({ left: dir * Math.max(180, el.clientWidth * 0.7), behavior: 'smooth' });
+}
+// Attach the ResizeObserver when the <nav> actually mounts — the scope
+// strip only renders once a template is selected (async after loadAll),
+// so it's null at onMounted. Watching the template ref catches it
+// appearing AND re-runs the overflow check then.
+let scopeResizeObs: ResizeObserver | null = null;
+watch(
+  scopeNav,
+  (el) => {
+    scopeResizeObs?.disconnect();
+    scopeResizeObs = null;
+    if (el && typeof ResizeObserver !== 'undefined') {
+      scopeResizeObs = new ResizeObserver(() => updateScopeScroll());
+      scopeResizeObs.observe(el);
+    }
+    void nextTick(updateScopeScroll);
+  },
+  { immediate: true },
+);
+// Content changes (scopes toggled, labels/counts) change scrollWidth
+// without resizing the nav, so refresh on those too.
+watch([visibleScopes, activeScope], () => void nextTick(updateScopeScroll));
+function onWinResizeScope(): void { updateScopeScroll(); }
+onMounted(() => window.addEventListener('resize', onWinResizeScope));
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', onWinResizeScope);
+  scopeResizeObs?.disconnect();
+  scopeResizeObs = null;
 });
 
 // Unsaved keystrokes: editor differs from the content last loaded/saved.
@@ -1039,7 +1106,10 @@ function ensureProcessTopology(): ProcessTopologyConfig {
   return tpl.processTopology;
 }
 
-type MetricBucket = 'node' | 'linkServer' | 'linkClient' | 'link' | 'edgeClient' | 'edgeServer';
+type MetricBucket =
+  | 'node' | 'linkServer' | 'linkClient'
+  | 'instNode' | 'instLinkServer' | 'instLinkClient'
+  | 'link' | 'edgeClient' | 'edgeServer';
 
 function getMetricList(bucket: MetricBucket): TopologyMetricDef[] {
   if (!draft.template) return [];
@@ -1048,6 +1118,12 @@ function getMetricList(bucket: MetricBucket): TopologyMetricDef[] {
     if (bucket === 'node') return t.nodeMetrics;
     if (bucket === 'linkServer') return t.linkServerMetrics ?? [];
     if (bucket === 'linkClient') return t.linkClientMetrics ?? [];
+    // Instance-topology buckets read straight off the nested block; they
+    // never auto-create it (only the explicit enable toggle does), so a
+    // read while disabled returns an empty detached list.
+    if (bucket === 'instNode') return t.instanceTopology?.nodeMetrics ?? [];
+    if (bucket === 'instLinkServer') return t.instanceTopology?.linkServerMetrics ?? [];
+    if (bucket === 'instLinkClient') return t.instanceTopology?.linkClientMetrics ?? [];
   } else if (activeScope.value === 'dependency') {
     const t = ensureEndpointDep();
     if (bucket === 'node') return t.nodeMetrics;
@@ -1093,6 +1169,24 @@ function toggleThresholds(m: TopologyMetricDef): void {
 const topologyNodeMetrics = computed(() => getMetricList('node'));
 const topologyServerMetrics = computed(() => getMetricList('linkServer'));
 const topologyClientMetrics = computed(() => getMetricList('linkClient'));
+// Instance-topology drill-down config (optional, nested under topology).
+const instanceTopologyEnabled = computed(() => !!draft.template?.topology?.instanceTopology);
+const instanceNodeMetrics = computed(() => getMetricList('instNode'));
+const instanceServerMetrics = computed(() => getMetricList('instLinkServer'));
+const instanceClientMetrics = computed(() => getMetricList('instLinkClient'));
+function toggleInstanceTopology(): void {
+  const t = ensureTopology();
+  if (t.instanceTopology) {
+    delete t.instanceTopology;
+  } else {
+    // Start empty — the operator authors the instance-scope metrics
+    // (service_instance_* / service_instance_relation_*) via the editors.
+    // We deliberately do NOT copy the service-topology metrics: those are
+    // service-scope MQE and would be wrong (and misleading) at instance
+    // scope.
+    t.instanceTopology = { nodeMetrics: [], linkServerMetrics: [], linkClientMetrics: [] };
+  }
+}
 const epDepNodeMetrics = computed(() => activeScope.value === 'dependency' ? getMetricList('node') : []);
 const epDepLinkMetrics = computed(() => getMetricList('link'));
 const processEdgeClientMetrics = computed(() =>
@@ -1277,8 +1371,9 @@ const COMPONENT_TOGGLES: Array<{ key: ComponentKey; label: string; hint: string
   { key: 'service', label: 'Service', hint: "The layer's primary landing — widget grid driven by dashboards.service." },
   { key: 'instances', label: 'Instances', hint: 'Per-instance dashboard (dashboards.instance widget set).' },
   { key: 'endpoints', label: 'Endpoints', hint: 'Per-endpoint dashboard (dashboards.endpoint widget set).' },
-  { key: 'endpointDependency', label: 'API dependency', hint: 'Endpoint-to-endpoint dependency view.' },
+  // Order mirrors the real sidebar: Topology sits before API dependency.
   { key: 'topology', label: 'Topology', hint: 'Service topology graph for this layer.' },
+  { key: 'endpointDependency', label: 'API dependency', hint: 'Endpoint-to-endpoint dependency view.' },
   { key: 'traces', label: 'Traces', hint: 'Trace explorer scoped to this layer.' },
   { key: 'logs', label: 'Logs', hint: 'Log explorer scoped to this layer.' },
   { key: 'podLogs', label: 'Pod Logs', hint: 'On-demand Kubernetes pod-log live tail. Only K8s-deployed layers (k8s_service, mesh) carry pods that resolve.' },
@@ -1330,20 +1425,35 @@ const COMPONENT_SLOT: Partial<Record<ComponentKey, keyof NonNullable<AdminLayerT
   instances: 'instances',
   endpoints: 'endpoints',
   endpointDependency: 'endpointDependency',
+  topology: 'topology',
 };
 /** The layer's sidebar menu as the operator would see it — only the
  *  enabled components, in COMPONENT_TOGGLES order, labelled with the
  *  layer's slot aliases where defined. Drives the menu preview. */
-const menuItems = computed(() => {
+const menuItems = computed<Array<{ key: string; label: string; scope: AdminScope; child?: boolean }>>(() => {
   const slots = draft.template?.slots ?? {};
-  return COMPONENT_TOGGLES.filter((t) => !!draft.template?.components?.[t.key]).map((t) => {
+  const items = COMPONENT_TOGGLES.filter((t) => !!draft.template?.components?.[t.key]).map((t) => {
     const slotKey = COMPONENT_SLOT[t.key];
     return {
-      key: t.key,
+      key: t.key as string,
       label: (slotKey && slots[slotKey]) || t.label,
       scope: COMPONENT_SCOPE[t.key],
+      child: false,
     };
   });
+  // Instance topology has no component flag of its own (it's a drill-down
+  // of the topology tab, not a sidebar entry); show it as a NESTED child
+  // under Topology so the preview reads "reached from Topology" — and so
+  // it doesn't double-highlight when the topology scope is active. Only
+  // when the Topology component is itself enabled (`topoIdx >= 0`): a
+  // drill-down of a disabled tab must not appear, even if a stale
+  // `topology.instanceTopology` block lingers in the config.
+  const topoIdx = items.findIndex((i) => i.key === 'topology');
+  if (instanceTopologyEnabled.value && topoIdx >= 0) {
+    const instItem = { key: 'instanceTopology', label: slots.instanceTopology || 'Instance map', scope: 'topology' as AdminScope, child: true };
+    items.splice(topoIdx + 1, 0, instItem);
+  }
+  return items;
 });
 /** Menu-preview click: focus the component's scope (if surfaced) and
  *  scroll the scope editor into view so config + preview follow the
@@ -1370,17 +1480,28 @@ function scopeLabel(s: AdminScope): string {
   const sk = SCOPE_SLOT[s];
   return (sk && draft.template?.slots?.[sk]) || SCOPE_LABELS[s];
 }
+// Alias-aware nouns for the topology editor: a service-topology node IS
+// a service, an instance-topology node IS an instance — so their section
+// headings read in the layer's own vocabulary (e.g. "Pods", "Sidecars").
+const serviceNoun = computed(() => scopeLabel('service'));
+const instanceNoun = computed(() => scopeLabel('instance'));
 
 /** The four configurable slot aliases. Shown for the components the
  *  layer actually exposes so the editor mirrors the menu. */
-const ALIAS_FIELDS: Array<{ slot: SlotKey; label: string; comp: ComponentKey; def: string }> = [
+const ALIAS_FIELDS: Array<{ slot: SlotKey; label: string; comp: ComponentKey; def: string; requireInstanceTopology?: boolean }> = [
+  // Order mirrors the real sidebar / menu: Topology (+ its Instance map
+  // drill-down) sits before API dependency.
   { slot: 'services', label: 'Services', comp: 'service', def: 'Service' },
   { slot: 'instances', label: 'Instances', comp: 'instances', def: 'Instance' },
   { slot: 'endpoints', label: 'Endpoints', comp: 'endpoints', def: 'Endpoint' },
+  { slot: 'topology', label: 'Topology', comp: 'topology', def: 'Topology' },
+  { slot: 'instanceTopology', label: 'Instance topology', comp: 'topology', def: 'Instance map', requireInstanceTopology: true },
   { slot: 'endpointDependency', label: 'API dependency', comp: 'endpointDependency', def: 'Dependency' },
 ];
 const visibleAliasFields = computed(() =>
-  ALIAS_FIELDS.filter((f) => !!draft.template?.components?.[f.comp]),
+  ALIAS_FIELDS.filter(
+    (f) => !!draft.template?.components?.[f.comp] && (!f.requireInstanceTopology || instanceTopologyEnabled.value),
+  ),
 );
 function ensureSlots(): NonNullable<AdminLayerTemplate['slots']> {
   if (!draft.template) throw new Error('no template selected');
@@ -1882,11 +2003,11 @@ const namingTest = computed<NamingTestResult>(() => {
                 :key="m.key"
                 type="button"
                 class="menu-item"
-                :class="{ on: activeScope === m.scope }"
+                :class="{ on: activeScope === m.scope && !m.child, 'is-child': m.child }"
                 :title="`Jump to ${m.label} config`"
                 @click="jumpToComponent(m.scope)"
               >
-                <span class="menu-item-label">{{ m.label }}</span>
+                <span class="menu-item-label">{{ m.child ? '↳ ' : '' }}{{ m.label }}</span>
                 <span class="menu-item-arrow">›</span>
               </button>
             </div>
@@ -2077,19 +2198,35 @@ const namingTest = computed<NamingTestResult>(() => {
         </section>
 
         <!-- Scope tabs -->
-        <nav id="scope-editor" class="scope-tabs sw-card">
+        <div id="scope-editor" class="scope-tabs-wrap sw-card">
           <button
-            v-for="s in visibleScopes"
-            :key="s"
-            class="scope-tab"
-            :class="{ on: activeScope === s }"
+            v-show="canScrollScopeLeft"
+            class="scope-scroll left"
             type="button"
-            @click="activeScope = s"
-          >
-            {{ scopeLabel(s) }}
-            <span class="count">{{ widgetsFor(s).length }}</span>
-          </button>
-        </nav>
+            aria-label="Scroll tabs left"
+            @click="scrollScopeTabs(-1)"
+          >‹</button>
+          <nav ref="scopeNav" class="scope-tabs" @scroll="updateScopeScroll">
+            <button
+              v-for="s in visibleScopes"
+              :key="s"
+              class="scope-tab"
+              :class="{ on: activeScope === s }"
+              type="button"
+              @click="activeScope = s"
+            >
+              {{ scopeLabel(s) }}
+              <span class="count">{{ widgetsFor(s).length }}</span>
+            </button>
+          </nav>
+          <button
+            v-show="canScrollScopeRight"
+            class="scope-scroll right"
+            type="button"
+            aria-label="Scroll tabs right"
+            @click="scrollScopeTabs(1)"
+          >›</button>
+        </div>
 
         <!-- Widget editor: canvas + drawer.
              Canvas (left): 12-col grid background, widgets placed at
@@ -2257,10 +2394,14 @@ const namingTest = computed<NamingTestResult>(() => {
             </span>
           </div>
           <div class="topo-cfg-body">
+            <div class="topo-cfg-group">
+              <span class="tg-title">Service topology</span>
+              <span class="tg-sub">node = {{ serviceNoun }} · edges = service-to-service relations</span>
+            </div>
             <div class="topo-cfg-section">
               <header class="topo-cfg-head">
-                <h5>Node metrics</h5>
-                <span class="sub">drives node center number + ring colour band</span>
+                <h5>{{ serviceNoun }} node metrics</h5>
+                <span class="sub">drives each node's center number + ring colour band</span>
                 <button class="sw-btn add" type="button" @click="addMetric('node')">+ Add</button>
               </header>
               <div v-if="topologyNodeMetrics.length === 0" class="topo-cfg-empty">No node metrics. Click "+ Add" to start.</div>
@@ -2383,6 +2524,123 @@ const namingTest = computed<NamingTestResult>(() => {
                 </article>
               </div>
             </div>
+
+            <!-- ── Instance topology (optional drill-down) — kept in its
+                 own bordered block so it never blurs into the service
+                 topology metrics above. ── -->
+            <div class="topo-cfg-instance-block" :class="{ enabled: instanceTopologyEnabled }">
+            <div class="topo-cfg-group with-toggle">
+              <div class="tg-text">
+                <span class="tg-title">Instance topology</span>
+                <span class="tg-sub">node = {{ instanceNoun }} · drill-down between two services' instances</span>
+              </div>
+              <label class="comp-toggle" :class="{ on: instanceTopologyEnabled }">
+                <input type="checkbox" :checked="instanceTopologyEnabled" @change="toggleInstanceTopology" />
+                <span class="comp-label">Enable instance topology</span>
+              </label>
+            </div>
+
+            <template v-if="instanceTopologyEnabled">
+              <div class="topo-cfg-section">
+                <header class="topo-cfg-head">
+                  <h5>{{ instanceNoun }} node metrics</h5>
+                  <span class="sub">per-instance — queried as <code>service_instance_*</code></span>
+                  <button class="sw-btn add" type="button" @click="addMetric('instNode')">+ Add</button>
+                </header>
+                <div v-if="instanceNodeMetrics.length === 0" class="topo-cfg-empty">No node metrics. Click "+ Add" to start.</div>
+                <div v-else class="metric-list">
+                  <article v-for="(m, i) in instanceNodeMetrics" :key="i" class="metric-row">
+                    <div class="metric-row-head">
+                      <label class="mf"><span>id</span><input v-model="m.id" type="text" class="mf-input mono" /></label>
+                      <label class="mf"><span>label</span><input v-model="m.label" type="text" class="mf-input" /></label>
+                      <label class="mf mf-wide"><span>MQE</span><input v-model="m.mqe" type="text" class="mf-input mono" placeholder="service_instance_cpm" /></label>
+                      <label class="mf mf-narrow"><span>unit</span><input v-model="m.unit" type="text" class="mf-input" placeholder="rpm" /></label>
+                      <label class="mf"><span>role</span>
+                        <select v-model="m.role" class="mf-input">
+                          <option v-for="o in TOPOLOGY_ROLE_OPTIONS" :key="String(o.value)" :value="o.value || undefined">{{ o.label }}</option>
+                        </select>
+                      </label>
+                      <label class="mf mf-narrow"><span>agg</span>
+                        <select v-model="m.aggregation" class="mf-input">
+                          <option value="avg">avg</option>
+                          <option value="sum">sum</option>
+                        </select>
+                      </label>
+                      <div class="metric-row-actions">
+                        <button class="sw-btn small ghost" type="button" :disabled="i === 0" title="Move up" @click="moveMetric('instNode', i, -1)">↑</button>
+                        <button class="sw-btn small ghost" type="button" :disabled="i === instanceNodeMetrics.length - 1" title="Move down" @click="moveMetric('instNode', i, 1)">↓</button>
+                        <button class="sw-btn small ghost danger" type="button" title="Remove" @click="removeMetric('instNode', i)">×</button>
+                      </div>
+                    </div>
+                    <div class="metric-thresholds">
+                      <button class="sw-btn small ghost" type="button" @click="toggleThresholds(m)">{{ m.thresholds ? '− Thresholds' : '+ Thresholds' }}</button>
+                      <template v-if="m.thresholds">
+                        <label class="mf mf-narrow"><span>ok ≤</span><input v-model.number="m.thresholds.ok" type="number" step="0.1" class="mf-input" /></label>
+                        <label class="mf mf-narrow"><span>warn ≤</span><input v-model.number="m.thresholds.warn" type="number" step="0.1" class="mf-input" /></label>
+                        <label class="mf mf-narrow"><span>danger ≤</span><input v-model.number="m.thresholds.danger" type="number" step="0.1" class="mf-input" /></label>
+                        <label class="mf mf-checkbox"><input v-model="m.thresholds.invertHealth" type="checkbox" /><span>invert (higher = better)</span></label>
+                        <label v-if="m.thresholds.invertHealth" class="mf mf-narrow"><span>base</span><input v-model.number="m.thresholds.invertBase" type="number" step="1" class="mf-input" placeholder="100" /></label>
+                      </template>
+                    </div>
+                  </article>
+                </div>
+              </div>
+
+              <div class="topo-cfg-section">
+                <header class="topo-cfg-head">
+                  <h5>Link · server-side metrics</h5>
+                  <span class="sub">edge metrics queried as <code>service_instance_relation_server_*</code></span>
+                  <button class="sw-btn add" type="button" @click="addMetric('instLinkServer')">+ Add</button>
+                </header>
+                <div v-if="instanceServerMetrics.length === 0" class="topo-cfg-empty">No server-side metrics.</div>
+                <div v-else class="metric-list">
+                  <article v-for="(m, i) in instanceServerMetrics" :key="i" class="metric-row">
+                    <div class="metric-row-head">
+                      <label class="mf"><span>id</span><input v-model="m.id" type="text" class="mf-input mono" /></label>
+                      <label class="mf"><span>label</span><input v-model="m.label" type="text" class="mf-input" /></label>
+                      <label class="mf mf-wide"><span>MQE</span><input v-model="m.mqe" type="text" class="mf-input mono" /></label>
+                      <label class="mf mf-narrow"><span>unit</span><input v-model="m.unit" type="text" class="mf-input" /></label>
+                      <label class="mf mf-narrow"><span>agg</span>
+                        <select v-model="m.aggregation" class="mf-input"><option value="avg">avg</option><option value="sum">sum</option></select>
+                      </label>
+                      <div class="metric-row-actions">
+                        <button class="sw-btn small ghost" type="button" :disabled="i === 0" @click="moveMetric('instLinkServer', i, -1)">↑</button>
+                        <button class="sw-btn small ghost" type="button" :disabled="i === instanceServerMetrics.length - 1" @click="moveMetric('instLinkServer', i, 1)">↓</button>
+                        <button class="sw-btn small ghost danger" type="button" @click="removeMetric('instLinkServer', i)">×</button>
+                      </div>
+                    </div>
+                  </article>
+                </div>
+              </div>
+
+              <div class="topo-cfg-section">
+                <header class="topo-cfg-head">
+                  <h5>Link · client-side metrics</h5>
+                  <span class="sub">edge metrics queried as <code>service_instance_relation_client_*</code></span>
+                  <button class="sw-btn add" type="button" @click="addMetric('instLinkClient')">+ Add</button>
+                </header>
+                <div v-if="instanceClientMetrics.length === 0" class="topo-cfg-empty">No client-side metrics.</div>
+                <div v-else class="metric-list">
+                  <article v-for="(m, i) in instanceClientMetrics" :key="i" class="metric-row">
+                    <div class="metric-row-head">
+                      <label class="mf"><span>id</span><input v-model="m.id" type="text" class="mf-input mono" /></label>
+                      <label class="mf"><span>label</span><input v-model="m.label" type="text" class="mf-input" /></label>
+                      <label class="mf mf-wide"><span>MQE</span><input v-model="m.mqe" type="text" class="mf-input mono" /></label>
+                      <label class="mf mf-narrow"><span>unit</span><input v-model="m.unit" type="text" class="mf-input" /></label>
+                      <label class="mf mf-narrow"><span>agg</span>
+                        <select v-model="m.aggregation" class="mf-input"><option value="avg">avg</option><option value="sum">sum</option></select>
+                      </label>
+                      <div class="metric-row-actions">
+                        <button class="sw-btn small ghost" type="button" :disabled="i === 0" @click="moveMetric('instLinkClient', i, -1)">↑</button>
+                        <button class="sw-btn small ghost" type="button" :disabled="i === instanceClientMetrics.length - 1" @click="moveMetric('instLinkClient', i, 1)">↓</button>
+                        <button class="sw-btn small ghost danger" type="button" @click="removeMetric('instLinkClient', i)">×</button>
+                      </div>
+                    </div>
+                  </article>
+                </div>
+              </div>
+            </template>
+            </div>
           </div>
         </section>
 
@@ -2863,6 +3121,22 @@ const namingTest = computed<NamingTestResult>(() => {
           </div>
         </section>
       </main>
+
+      <!-- Layer selected but no working copy: no local draft and no OAP
+           row. We deliberately do NOT auto-load the bundled default here
+           (bundled is the seed/reset source, never the runtime render);
+           the operator adopts it explicitly. -->
+      <main v-else-if="noPublishedVersion" class="detail">
+        <section class="sw-card no-remote-card">
+          <h3>{{ t('No published version on OAP') }}</h3>
+          <p>
+            {{ t('This layer has no template stored on OAP. Horizon ships a bundled default, but it is not loaded for editing and the live UI does not render it — push a version to publish. Reset to bundled to start from the shipped default, then edit and publish.') }}
+          </p>
+          <button class="sw-btn primary" type="button" :disabled="!bundledExists" @click="loadFrom('bundled')">
+            {{ t('Reset to bundled') }}
+          </button>
+        </section>
+      </main>
     </div>
 
     <!-- Push confirm: shows the remote → local diff before publishing. -->
@@ -2938,6 +3212,21 @@ const namingTest = computed<NamingTestResult>(() => {
   color: var(--sw-fg-3);
   font-size: 12px;
 }
+.no-remote-card {
+  padding: 24px;
+  max-width: 600px;
+}
+.no-remote-card h3 {
+  margin: 0 0 8px;
+  font-size: 14px;
+  color: var(--sw-fg-0);
+}
+.no-remote-card p {
+  margin: 0 0 16px;
+  font-size: 12px;
+  line-height: 1.6;
+  color: var(--sw-fg-2);
+}
 .empty.inset {
   padding: 18px;
   font-size: 11.5px;
@@ -3353,13 +3642,43 @@ const namingTest = computed<NamingTestResult>(() => {
 .identity-delete { display: flex; align-items: center; gap: 8px; }
 .del-note { font-size: 10.5px; color: var(--sw-fg-3); font-style: italic; }
 
+.scope-tabs-wrap {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  padding: 6px;
+}
 .scope-tabs {
   display: flex;
   gap: 2px;
-  padding: 6px;
+  flex: 1;
+  min-width: 0;
+  overflow-x: auto;
+  scroll-behavior: smooth;
+  scrollbar-width: none;
+}
+.scope-tabs::-webkit-scrollbar { display: none; }
+.scope-scroll {
+  flex: 0 0 auto;
+  width: 24px;
+  height: 34px;
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  background: var(--sw-bg-1);
+  color: var(--sw-fg-2);
+  font-size: 16px;
+  line-height: 1;
+  cursor: pointer;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
 }
+.scope-scroll:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
 .scope-tab {
-  flex: 1;
+  /* Fill the strip when there's room, but never shrink/wrap — overflow
+     scrolls instead (see the chevron buttons + .scope-tabs overflow). */
+  flex: 1 0 auto;
+  white-space: nowrap;
   padding: 8px 12px;
   font-size: 11.5px;
   font-weight: 500;
@@ -3475,6 +3794,9 @@ const namingTest = computed<NamingTestResult>(() => {
 .menu-item-label { flex: 1; }
 .menu-item-arrow { color: var(--sw-fg-3); font-size: 13px; }
 .menu-item.on .menu-item-arrow { color: var(--sw-accent-2); }
+/* Instance map — a nested drill-down of Topology, not a sidebar entry. */
+.menu-item.is-child { margin-left: 16px; font-size: 11.5px; color: var(--sw-fg-3); }
+.menu-item.is-child:hover { color: var(--sw-fg-1); }
 .setup-right {
   display: flex;
   flex-direction: column;
@@ -4004,6 +4326,42 @@ const namingTest = computed<NamingTestResult>(() => {
 }
 /* Topology / endpoint-dep form editor */
 .topo-cfg-card .topo-cfg-body { padding: 4px 0 0; }
+/* Group labels that separate Service topology from Instance topology. */
+.topo-cfg-group {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  margin: 4px 16px 2px;
+  padding-bottom: 6px;
+  border-bottom: 1px solid var(--sw-line);
+}
+.topo-cfg-group.with-toggle {
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-top: 22px;
+}
+.topo-cfg-group .tg-text { display: flex; flex-direction: column; gap: 2px; }
+.topo-cfg-group .tg-title { font-size: 12px; font-weight: 700; color: var(--sw-fg-0); }
+.topo-cfg-group .tg-sub { font-size: 10.5px; color: var(--sw-fg-3); }
+/* Instance-topology config lives in its own bordered card so it reads as
+   a distinct block from the service-topology metrics above it. */
+.topo-cfg-instance-block {
+  margin: 22px 16px 8px;
+  border: 1px solid var(--sw-line-2);
+  border-radius: 8px;
+  background: var(--sw-bg-1);
+  overflow: hidden;
+}
+.topo-cfg-instance-block .topo-cfg-group.with-toggle {
+  margin: 0;
+  padding: 10px 14px;
+  background: var(--sw-bg-2);
+}
+.topo-cfg-instance-block.enabled .topo-cfg-group.with-toggle { border-bottom: 1px solid var(--sw-line); }
+.topo-cfg-instance-block .topo-cfg-section { padding: 12px 14px; }
+.topo-cfg-instance-block .topo-cfg-section:last-child { border-bottom: none; }
 .topo-cfg-section {
   padding: 12px 16px;
   border-bottom: 1px solid var(--sw-line);
diff --git a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
index 05a5d58..48bee30 100644
--- a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
+++ b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
@@ -700,18 +700,37 @@ const editorDiffersFromRemote = computed<boolean>(() => {
 });
 
 // Which source to seed the editor from for the current selection.
-// Remote is the canonical baseline — it's what `pickOverviewContent`
-// in the runtime bundle serves to end users — so the editor opens
-// from remote on every re-mount when remote is reachable. Priority:
+// Remote is the canonical baseline — it's what the runtime bundle serves
+// to end users — so the editor opens from remote on every re-mount when
+// remote is reachable. Priority:
 //   1. Local draft — unpublished in-progress edits in this browser.
 //   2. Remote — the default for every re-entry when remote exists.
-//   3. Bundled — fallback for bundled-only overviews (no OAP row).
-function defaultEditorSource(): 'local' | 'bundled' | 'remote' {
-  if (hasLocalDraft.value) return 'local';
-  if (remoteAvailable.value) return 'remote';
-  if (bundledContent()) return 'bundled';
-  return 'bundled';
-}
+//   3. Otherwise NOTHING. We do NOT auto-load bundled (even for a
+//      bundled-only overview): bundled is the seed/reset source, never the
+//      runtime render, so the editor shows a "no published version" panel
+//      and the operator resets to bundled explicitly.
+function seedEditor(): void {
+  if (hasLocalDraft.value) {
+    loadFrom('local');
+    return;
+  }
+  if (remoteAvailable.value) {
+    loadFrom('remote');
+    return;
+  }
+  draft.value = null;
+  loadedSnapshot.value = '';
+}
+/** Selected overview exists but has neither a local draft nor an OAP row,
+ *  so nothing is loaded. Drives the "no published version" panel. */
+const noPublishedVersion = computed<boolean>(
+  () =>
+    sourcesReady.value &&
+    !!selectedId.value &&
+    !hasLocalDraft.value &&
+    !remoteAvailable.value &&
+    !draft.value,
+);
 
 // Seed the editor when the selected overview (or a background refetch)
 // changes — but never clobber unsaved keystrokes OR an operator-driven
@@ -724,7 +743,7 @@ watch(
   () => {
     if (!sourcesReady.value) return;
     if (isDirty.value) return;
-    loadFrom(defaultEditorSource());
+    seedEditor();
   },
   { immediate: true },
 );
@@ -733,7 +752,7 @@ watch(
 // run the seed for the currently-selected overview once.
 watch(sourcesReady, (ready, wasReady) => {
   if (ready && !wasReady && selectedId.value && !isDirty.value) {
-    loadFrom(defaultEditorSource());
+    seedEditor();
   }
 });
 
@@ -1107,6 +1126,14 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
 
       <section class="ot__detail">
         <div v-if="detailQuery.isPending.value && !draft" class="ot__empty">loading…</div>
+        <!-- Overview selected but no working copy: no local draft, no OAP
+             row. We do NOT auto-load the bundled default (seed/reset source
+             only); the operator adopts it explicitly. -->
+        <div v-else-if="noPublishedVersion" class="ot__empty ot__no-remote">
+          <h3>{{ t('No published version on OAP') }}</h3>
+          <p>{{ t('This overview has no version stored on OAP. A bundled default may ship with Horizon, but it is not loaded for editing and the live UI does not render it. Reset to bundled to start from the shipped default, then edit and publish.') }}</p>
+          <button class="ot__btn ot__btn--primary" type="button" :disabled="!bundledExists" @click="loadFrom('bundled')">{{ t('Reset to bundled') }}</button>
+        </div>
         <template v-else-if="draft">
           <header class="ot__detail-head">
             <h2><code>{{ draft.id }}</code></h2>
@@ -1745,6 +1772,9 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
   color: var(--sw-fg-0); background: var(--sw-bg-2); padding: 1px 5px; border-radius: 3px;
 }
 .ot__empty { padding: 32px; text-align: center; color: var(--sw-fg-3); font-size: 12px; }
+.ot__no-remote { text-align: left; max-width: 600px; padding: 24px; }
+.ot__no-remote h3 { margin: 0 0 8px; font-size: 14px; color: var(--sw-fg-0); }
+.ot__no-remote p { margin: 0 0 16px; font-size: 12px; line-height: 1.6; color: var(--sw-fg-2); }
 .ot__toolbar { display: flex; justify-content: flex-end; margin: 8px 0; }
 
 .ot__main { display: flex; flex-direction: column; gap: 12px; }
diff --git a/apps/ui/src/features/admin/translations/TranslationsView.vue b/apps/ui/src/features/admin/translations/TranslationsView.vue
index 34f958b..f21ccf9 100644
--- a/apps/ui/src/features/admin/translations/TranslationsView.vue
+++ b/apps/ui/src/features/admin/translations/TranslationsView.vue
@@ -199,16 +199,20 @@ watch(
 
 // ── Source + draft state ───────────────────────────────────────────
 
-/** Effective SOURCE for the picked template — remote (OAP) overrides
- *  bundled. Embedded `i18n` is no longer written by this page (the
- *  split storage uses sibling overlay rows). Any historical embedded
- *  block is stripped defensively. */
+/** Effective SOURCE for the picked template — strictly REMOTE (the
+ *  version published to OAP). We translate the PUBLISHED template's
+ *  fields, not the bundled disk copy: the runtime localizes against the
+ *  remote source, so enumerating fields from bundled would let an
+ *  operator translate fields that never reach end users. A template with
+ *  no OAP row therefore isn't translatable until it's published (the
+ *  editor shows the empty state). Embedded `i18n` is no longer written by
+ *  this page; any historical embedded block is stripped defensively. */
 const effective = computed<{ source: Record<string, unknown> } | null>(() => {
   const name = selectedName.value;
   const kind = selectedKind.value;
   if (!name) return null;
   const sources = kind === 'overview' ? overviewSources : layerSources;
-  const raw = sources.remote<Record<string, unknown>>(name) ?? sources.bundled<Record<string, unknown>>(name);
+  const raw = sources.remote<Record<string, unknown>>(name);
   if (!raw) return null;
   const { i18n: _i18n, ...rest } = raw as { i18n?: Record<string, unknown> };
   void _i18n;
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index ef9ec97..0230eac 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -141,6 +141,10 @@
   "Initializing…": "Initializing…",
   "Loading layer registry and dashboard templates. Watch the topbar event line for progress.": "Loading layer registry and dashboard templates. Watch the topbar event line for progress.",
   "OAP query port unreachable": "OAP query port unreachable",
+  "Dashboard template store unreachable": "Dashboard template store unreachable",
+  "Layer dashboards, overviews and topology are blocked until OAP’s UI-template store is reachable.": "Layer dashboards, overviews and topology are blocked until OAP’s UI-template store is reachable.",
+  "Last sync": "Last sync",
+  "never": "never",
   "Last seen": "Last seen",
   "Auto-retry cadence while OAP is unreachable": "Auto-retry cadence while OAP is unreachable",
   "retry": "retry",
@@ -1176,6 +1180,9 @@
   "Editor buffer differs from OAP — not the live version.": "Editor buffer differs from OAP — not the live version.",
   "from local": "from local",
   "from bundled": "from bundled",
+  "No published version on OAP": "No published version on OAP",
+  "This layer has no template stored on OAP. Horizon ships a bundled default, but it is not loaded for editing and the live UI does not render it — push a version to publish. Reset to bundled to start from the shipped default, then edit and publish.": "This layer has no template stored on OAP. Horizon ships a bundled default, but it is not loaded for editing and the live UI does not render it — push a version to publish. Reset to bundled to start from the shipped default, then edit and p [...]
+  "This overview has no version stored on OAP. A bundled default may ship with Horizon, but it is not loaded for editing and the live UI does not render it. Reset to bundled to start from the shipped default, then edit and publish.": "This overview has no version stored on OAP. A bundled default may ship with Horizon, but it is not loaded for editing and the live UI does not render it. Reset to bundled to start from the shipped default, then edit and publish.",
   "(synced)": "(synced)",
   "Unpublished local edits — Push to publish to OAP.": "Unpublished local edits — Push to publish to OAP.",
   "Showing the shipped bundled default — Push to overwrite OAP with bundled.": "Showing the shipped bundled default — Push to overwrite OAP with bundled.",
@@ -1293,5 +1300,24 @@
   "Import failed: {error}": "Import failed: {error}",
   "Unsupported language: {locale}": "Unsupported language: {locale}",
   "Template {name} is not loaded on this deployment.": "Template {name} is not loaded on this deployment.",
-  "Could not load the target template.": "Could not load the target template."
+  "Could not load the target template.": "Could not load the target template.",
+  "client": "client",
+  "server": "server",
+  "Client": "Client",
+  "Server": "Server",
+  "relation": "relation",
+  "no line metrics configured": "no line metrics configured",
+  "No instance topology in this window.": "No instance topology in this window.",
+  "Open instance dashboard": "Open instance dashboard",
+  "Fit to screen": "Fit to screen",
+  "Fit": "Fit",
+  "no value": "no value",
+  "Service map": "Service map",
+  "Instance map": "Instance map",
+  "Client service…": "Client service…",
+  "Server service…": "Server service…",
+  "Pick a client and server service to see their instance topology.": "Pick a client and server service to see their instance topology.",
+  "higher = better": "higher = better",
+  "lower = better": "lower = better",
+  "Node ring": "Node ring"
 }
diff --git a/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts b/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
index 0116444..2d56a11 100644
--- a/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
+++ b/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
@@ -24,6 +24,7 @@
 import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import { useAutoRefreshSubscribe } from '../../controls/useAutoRefreshSubscribe';
+import { usePreviewLayerBlock } from '@/controls/previewConfig';
 import { bffClient } from '@/api/client';
 
 export function useLayerEndpointDependency(
@@ -31,13 +32,16 @@ export function useLayerEndpointDependency(
   service: Ref<string | null>,
   endpoint: Ref<string | null>,
 ) {
+  // Preview-only: forward the draft `endpointDependency` block.
+  const previewCfg = usePreviewLayerBlock(layerKey, 'endpointDependency');
   const q = useQuery({
-    queryKey: ['layer-endpoint-dependency', layerKey, service, endpoint],
+    queryKey: ['layer-endpoint-dependency', layerKey, service, endpoint, previewCfg],
     queryFn: () =>
       bffClient.layer.endpointDependency(
         layerKey.value,
         service.value ?? '',
         endpoint.value ?? '',
+        previewCfg.value,
       ),
     enabled: computed(
       () => layerKey.value.length > 0 && !!service.value && !!endpoint.value,
diff --git a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
index 6245cae..5a9b4c6 100644
--- a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
@@ -36,6 +36,7 @@ import { useRoute } from 'vue-router';
 import { useLayerInstances } from '@/layer/useLayerInstances';
 import { useSelectedService } from '@/layer/useSelectedService';
 import { bffClient } from '@/api/client';
+import { usePreviewLayerBlock } from '@/controls/previewConfig';
 import type {
   EBPFTask,
   NetworkProfilingSampling,
@@ -51,6 +52,9 @@ import Icon from '@/components/icons/Icon.vue';
 
 const route = useRoute();
 const layerKey = computed(() => String(route.params.layerKey ?? ''));
+// Preview-only: forward the draft `processTopology` block so a clicked
+// edge's metrics reflect the unpublished config.
+const previewProcessTopology = usePreviewLayerBlock(layerKey, 'processTopology');
 const { selectedId: serviceId } = useSelectedService();
 
 // Instance picker (binds to ?serviceInstance= via plain ref state — the
@@ -212,6 +216,7 @@ watch(selectedCall, async (call) => {
       source: endpointRef(src),
       dest: endpointRef(dst),
       ...taskWindow,
+      ...(previewProcessTopology.value ? { previewConfig: previewProcessTopology.value } : {}),
     });
     if (!relationMetrics.value.reachable && relationMetrics.value.error) {
       relationError.value = relationMetrics.value.error;
diff --git a/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue b/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue
new file mode 100644
index 0000000..4580e71
--- /dev/null
+++ b/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue
@@ -0,0 +1,831 @@
+<!--
+  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.
+-->
+<!--
+  Instance map — the "Instance topology" sub-tab of the per-layer Topology
+  page. Renders the instance-to-instance topology between two chosen
+  services as two columns (client instances | server instances).
+
+  The service pair is URL-driven (?client=&server=): the in-toolbar
+  pickers write it, and the Service-map tab's edge drill-down navigates
+  here pre-filled. Pan/zoom, animated client→server flow, node popover
+  (with "Open instance dashboard"), and the aligned client|server edge
+  sidebar all match the service map's vocabulary.
+-->
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import * as d3 from 'd3';
+import { useI18n } from 'vue-i18n';
+import { useRoute, useRouter } from 'vue-router';
+import type {
+  InstanceTopologyCall,
+  InstanceTopologyNode,
+  LayerDef,
+  TopologyMetricDef,
+} from '@/api/client';
+import { useInstanceTopology } from '@/layer/service-map/useInstanceTopology';
+import { useLayerTopology } from '@/layer/service-map/useLayerTopology';
+import { useLayerServices } from '@/layer/useLayerServices';
+import { useLayers } from '@/shell/useLayers';
+import { fmtMetric } from '@/utils/formatters';
+import { resolveServiceIdentity } from '@/utils/serviceName';
+import Sparkline from '@/components/charts/Sparkline.vue';
+import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue';
+
+const route = useRoute();
+const router = useRouter();
+const { t } = useI18n({ useScope: 'global' });
+
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+const { layers } = useLayers();
+const layer = computed<LayerDef | null>(() => layers.value.find((l) => l.key === layerKey.value) ?? null);
+const instanceWord = computed(() => layer.value?.slots?.instances ?? 'Instances');
+const serviceWord = computed(() => layer.value?.slots?.services ?? 'Services');
+const instanceMapLabel = computed(() => layer.value?.slots?.instanceTopology || t('Instance map'));
+// Back to the service map — the instance map is a drill-down, not a tab,
+// so it carries its own return affordance. Drops the instance-only query
+// (view / client / server) so the topology page lands on the service map.
+function backToServiceMap(): void {
+  const q = { ...route.query };
+  delete q.view;
+  delete q.client;
+  delete q.server;
+  void router.push({ path: `/layer/${layerKey.value}/topology`, query: q });
+}
+const namingRule = computed(() => layer.value?.naming ?? null);
+function displayName(name: string | null | undefined): string {
+  return resolveServiceIdentity(name, namingRule.value).display;
+}
+
+// ── Picker candidates come from the SERVICE TOPOLOGY (the call graph) —
+// not just the layer roster — so they include conjectured / cross-layer
+// callees (e.g. `rcmd:80`) exactly as the service map shows them, and the
+// two selectors stay relationship-aware: the SERVER list is the selected
+// client's callees, the CLIENT list is the selected server's callers. We
+// fetch the layer-wide graph (the service map's default; usually already
+// cached from the drill-down). The roster is kept only for the name
+// fallback on a selection the graph hasn't loaded yet.
+const roster = useLayerServices(layerKey);
+const topoFocus = ref<string | null>(null);
+const topoDepth = ref(2);
+const { nodes: topoNodes, calls: topoCalls } = useLayerTopology(layerKey, topoFocus, topoDepth);
+
+const topoNameById = computed<Map<string, string>>(() => {
+  const m = new Map<string, string>();
+  for (const n of topoNodes.value) m.set(n.id, n.name);
+  return m;
+});
+/** Services related to `anchorId` along the call graph: its callees
+ *  (outbound) or callers (inbound). No anchor yet ⇒ the whole graph. */
+function relatedIds(anchorId: string | null, dir: 'callees' | 'callers'): string[] {
+  if (!anchorId) return topoNodes.value.map((n) => n.id);
+  return topoCalls.value
+    .filter((c) => (dir === 'callees' ? c.source === anchorId : c.target === anchorId))
+    .map((c) => (dir === 'callees' ? c.target : c.source));
+}
+function toOptions(ids: string[], excludeId: string | null): Array<{ value: string; label: string }> {
+  const seen = new Set<string>();
+  const out: Array<{ value: string; label: string }> = [];
+  for (const id of ids) {
+    if (!id || id === excludeId || seen.has(id)) continue;
+    seen.add(id);
+    out.push({ value: id, label: displayName(topoNameById.value.get(id) ?? id) });
+  }
+  return out;
+}
+// Force-add the current selection even when the graph doesn't (yet) carry
+// it, so the live value always shows AND the picker never auto-resets it
+// when the OTHER selector changes the option set. That injection is what
+// keeps "update the other's list" from ever writing back the value the
+// operator just set — both lists are pure computeds, so neither triggers
+// the other.
+function withCurrent(
+  base: Array<{ value: string; label: string }>,
+  id: string | null,
+  label: string,
+): Array<{ value: string; label: string }> {
+  if (id && !base.some((o) => o.value === id)) {
+    return [{ value: id, label: label || displayName(id) }, ...base];
+  }
+  return base;
+}
+const clientOptions = computed(() =>
+  withCurrent(toOptions(relatedIds(serverId.value, 'callers'), serverId.value), clientId.value, clientName.value),
+);
+const serverOptions = computed(() =>
+  withCurrent(toOptions(relatedIds(clientId.value, 'callees'), clientId.value), serverId.value, serverName.value),
+);
+function nameOf(id: string | null): string {
+  if (!id) return '';
+  return displayName(
+    topoNameById.value.get(id) ?? roster.services.value.find((s) => s.id === id)?.name ?? id,
+  );
+}
+
+// ── URL-driven service pair. The pickers + the Service-map drill-down
+// both write `?client=&server=`; we mirror the query into local refs.
+const clientId = ref<string | null>((route.query.client as string) || null);
+const serverId = ref<string | null>((route.query.server as string) || null);
+watch(
+  () => [route.query.client, route.query.server] as const,
+  ([c, s]) => {
+    clientId.value = (c as string) || null;
+    serverId.value = (s as string) || null;
+  },
+);
+function pick(which: 'client' | 'server', val: string): void {
+  void router.replace({ query: { ...route.query, view: 'instance', [which]: val } });
+}
+
+const enabled = computed(() => !!clientId.value && !!serverId.value);
+const { data, nodes, calls, isFetching } = useInstanceTopology(layerKey, clientId, serverId, enabled);
+
+// Resolve both names through the SAME layer naming rule the service map
+// uses (`resolveServiceIdentity` → `display`), so the `<group>::` prefix
+// is stripped here exactly as it is on the service-topology nodes — an
+// edge between `agent::app` and `agent::gateway` shows `app` / `gateway`
+// in both maps, not raw on one and stripped on the other.
+const clientName = computed(() => displayName(data.value?.clientServiceName) || nameOf(clientId.value));
+const serverName = computed(() => displayName(data.value?.serverServiceName) || nameOf(serverId.value));
+
+const cfg = computed(() => data.value?.config ?? { nodeMetrics: [] as TopologyMetricDef[] });
+function pickByRole(defs: TopologyMetricDef[], role: TopologyMetricDef['role']): TopologyMetricDef | null {
+  return defs.find((d) => d.role === role) ?? null;
+}
+const centerDef = computed(() => pickByRole(cfg.value.nodeMetrics, 'center'));
+const ringDef = computed(() => pickByRole(cfg.value.nodeMetrics, 'ring'));
+
+function nodeVal(n: InstanceTopologyNode, def: TopologyMetricDef | null): number | null {
+  return def ? (n.metrics?.[def.id] ?? null) : null;
+}
+function fmtVal(v: number | null, unit?: string): string {
+  if (v === null) return '—';
+  return unit ? `${fmtMetric(v)}${unit === '%' ? '' : ' '}${unit}` : fmtMetric(v);
+}
+function bandColor(value: number, th: NonNullable<TopologyMetricDef['thresholds']>): string {
+  const base = th.invertBase ?? 100;
+  const v = th.invertHealth ? Math.max(0, base - value) : value;
+  if (v > (th.danger ?? 5)) return 'var(--sw-err)';
+  if (v > (th.warn ?? 1)) return 'var(--sw-warn)';
+  if (v > (th.ok ?? 0.1)) return '#fbbf24';
+  return 'var(--sw-ok)';
+}
+function ringColor(n: InstanceTopologyNode): string {
+  const def = ringDef.value;
+  if (!def) return 'var(--sw-line-2)';
+  const v = nodeVal(n, def);
+  if (v === null) return 'var(--sw-fg-3)';
+  if (def.thresholds) return bandColor(v, def.thresholds);
+  const healthHigh = /sla|success|apdex/i.test(def.id) || /sla|apdex|success/i.test(def.label);
+  const errPct = healthHigh ? Math.max(0, 100 - v) : v;
+  if (errPct > 5) return 'var(--sw-err)';
+  if (errPct > 1) return 'var(--sw-warn)';
+  if (errPct > 0.1) return '#fbbf24';
+  return 'var(--sw-ok)';
+}
+
+// ── Ring-colour legend. Mirrors the service map: the four break-point
+// labels come from the ring metric's own `thresholds` block, mapped
+// back to user-space when the metric is "higher = better" (SLA / apdex /
+// success rate). That keeps the green→amber→warn→red ramp reading
+// correctly instead of a hard-coded error-rate scale — answering "what
+// does green / red mean here?" straight from the config.
+const ringScaleLabels = computed<string[]>(() => {
+  const def = ringDef.value;
+  if (!def) return [];
+  const th = def.thresholds ?? { ok: 0.1, warn: 1, danger: 5 };
+  const heuristicInvert = /sla|success|apdex/i.test(def.id) || /sla|apdex|success/i.test(def.label);
+  const invert = th.invertHealth === undefined ? heuristicInvert : Boolean(th.invertHealth);
+  const base = th.invertBase ?? 100;
+  const ok = th.ok ?? 0.1;
+  const warn = th.warn ?? 1;
+  const danger = th.danger ?? 5;
+  const unit = def.unit ?? '';
+  const breaks = invert ? [base, base - ok, base - warn, base - danger] : [0, ok, warn, danger];
+  const fmt = (n: number): string => {
+    const s = Number.isInteger(n) ? n.toString() : n.toFixed(2).replace(/\.?0+$/, '');
+    return `${s}${unit}`;
+  };
+  const out = breaks.map(fmt);
+  if (out.length > 0) out[out.length - 1] = out[out.length - 1] + (invert ? '-' : '+');
+  return out;
+});
+const ringDirectionHint = computed<string>(() => {
+  const def = ringDef.value;
+  if (!def) return '';
+  if (def.thresholds?.invertHealth) return t('higher = better');
+  if (/sla|success|apdex/i.test(def.id) || /sla|apdex|success/i.test(def.label)) return t('higher = better');
+  return t('lower = better');
+});
+
+// ── Two-column layout.
+const NODE_R = 26;
+const ROW_GAP = 92;
+const TOP = 56;
+const LEFT_X = 150;
+const VIEW_W = 760;
+const RIGHT_X = VIEW_W - 150;
+const clientNodes = computed<InstanceTopologyNode[]>(() => nodes.value.filter((n) => n.serviceId === clientId.value));
+const serverNodes = computed<InstanceTopologyNode[]>(() => nodes.value.filter((n) => n.serviceId !== clientId.value));
+interface Pos { cx: number; cy: number }
+const pos = computed<Map<string, Pos>>(() => {
+  const m = new Map<string, Pos>();
+  clientNodes.value.forEach((n, i) => m.set(n.id, { cx: LEFT_X, cy: TOP + i * ROW_GAP }));
+  serverNodes.value.forEach((n, i) => m.set(n.id, { cx: RIGHT_X, cy: TOP + i * ROW_GAP }));
+  return m;
+});
+const W = VIEW_W;
+const H = computed(() => {
+  const rows = Math.max(1, clientNodes.value.length, serverNodes.value.length);
+  return TOP + (rows - 1) * ROW_GAP + NODE_R + 36;
+});
+// ── Per-service grouping box (the "cluster group" container). One
+// rounded box per column, headed with the service name + side, so it's
+// obvious which instances belong to the client vs the server service.
+// Drawn inside the zoom layer so it pans / zooms with the nodes.
+interface GroupBox { side: 'client' | 'server'; name: string; role: string; x: number; y: number; w: number; h: number }
+const GROUP_HEAD_H = 26;   // band above the topmost node, holds the header
+const GROUP_LABEL_H = 22;  // the pod-name label rendered beneath each node
+const GROUP_PAD_X = 18;
+const GROUP_CHAR_W = 6.3;  // ~width of one mono char at the node-label size
+function colBox(colNodes: InstanceTopologyNode[], cx: number, name: string, side: 'client' | 'server', role: string): GroupBox | null {
+  if (colNodes.length === 0) return null;
+  const ys = colNodes.map((n) => pos.value.get(n.id)?.cy ?? 0);
+  const minY = Math.min(...ys);
+  const maxY = Math.max(...ys);
+  // The box must hold the WIDER of two things, or its dashed border clips
+  // one of them:
+  //   - node labels (pod names), CENTRED on the column ⇒ need labelW/2 each side;
+  //   - the header "<name> <instances> · <role>", LEFT-aligned from x=16 ⇒
+  //     need (16 + headerW + right-pad) total. Short pod labels (e.g.
+  //     `v1.0.0`) used to under-size the box and the header spilled out.
+  const nodeLabelW = Math.max(NODE_R * 2, ...colNodes.map((n) => n.name.length * GROUP_CHAR_W));
+  // name renders @12px bold (~7.5px/char), the role tail @9px uppercase
+  // with letter-spacing (~6.2px/char), separated by the tspan's dx=8.
+  const headerW = name.length * 7.5 + 8 + `${instanceWord.value} · ${role}`.length * 6.2;
+  // Cap the half-width so the two columns never collide or run off-canvas
+  // (LEFT_X=150 / RIGHT_X=610 / VIEW_W=760 ⇒ cap 150 keeps both inside + a gap).
+  const halfW = Math.min(
+    150,
+    Math.max(NODE_R + 14, nodeLabelW / 2 + GROUP_PAD_X, (16 + headerW + 14) / 2),
+  );
+  return {
+    side,
+    name,
+    role,
+    x: cx - halfW,
+    y: minY - NODE_R - GROUP_HEAD_H,
+    w: halfW * 2,
+    h: (maxY - minY) + NODE_R * 2 + GROUP_HEAD_H + GROUP_LABEL_H,
+  };
+}
+const groupBoxes = computed<GroupBox[]>(() => {
+  const out: GroupBox[] = [];
+  const c = colBox(clientNodes.value, LEFT_X, clientName.value, 'client', t('client'));
+  const s = colBox(serverNodes.value, RIGHT_X, serverName.value, 'server', t('server'));
+  if (c) out.push(c);
+  if (s) out.push(s);
+  return out;
+});
+
+const visibleCalls = computed<InstanceTopologyCall[]>(() =>
+  calls.value.filter((c) => pos.value.has(c.source) && pos.value.has(c.target)),
+);
+function edgePathD(c: InstanceTopologyCall): string {
+  const a = pos.value.get(c.source);
+  const b = pos.value.get(c.target);
+  if (!a || !b) return '';
+  const dir = a.cx <= b.cx ? 1 : -1;
+  const x1 = a.cx + dir * NODE_R;
+  const x2 = b.cx - dir * NODE_R;
+  const mx = (x1 + x2) / 2;
+  return `M ${x1} ${a.cy} C ${mx} ${a.cy} ${mx} ${b.cy} ${x2} ${b.cy}`;
+}
+
+// ── Pan + zoom.
+const svgEl = ref<SVGSVGElement | null>(null);
+const zoomLayerEl = ref<SVGGElement | null>(null);
+const containerEl = ref<HTMLDivElement | null>(null);
+let zoomBehaviour: d3.ZoomBehavior<SVGSVGElement, unknown> | null = null;
+const zoomT = ref<{ k: number; x: number; y: number }>({ k: 1, x: 0, y: 0 });
+function viewportSize(): { width: number; height: number } {
+  const el = containerEl.value;
+  if (!el) return { width: W, height: H.value };
+  const r = el.getBoundingClientRect();
+  return { width: r.width || W, height: r.height || H.value };
+}
+function fitToScreen(animate = true): void {
+  if (!svgEl.value || !zoomBehaviour) return;
+  const vp = viewportSize();
+  const pad = 28;
+  const fit = Math.min((vp.width - pad * 2) / W, (vp.height - pad * 2) / H.value);
+  const k = Math.max(0.2, Math.min(fit, 1));
+  const tx = (vp.width - W * k) / 2;
+  const ty = (vp.height - H.value * k) / 2;
+  const transform = d3.zoomIdentity.translate(tx, ty).scale(k);
+  const sel = d3.select(svgEl.value);
+  if (animate) sel.transition().duration(200).call(zoomBehaviour.transform, transform);
+  else sel.call(zoomBehaviour.transform, transform);
+}
+function zoomBy(factor: number): void {
+  if (!svgEl.value || !zoomBehaviour) return;
+  d3.select(svgEl.value).transition().duration(150).call(zoomBehaviour.scaleBy, factor);
+}
+function installZoom(): void {
+  if (!svgEl.value || !zoomLayerEl.value) return;
+  const sel = d3.select(svgEl.value);
+  zoomBehaviour = d3
+    .zoom<SVGSVGElement, unknown>()
+    .scaleExtent([0.2, 5])
+    .filter((event) => {
+      if (event.type === 'mousedown' && (event as MouseEvent).button !== 0) return false;
+      const target = event.target as Element | null;
+      if (target?.closest?.('[data-node-id], [data-edge-id]')) return false;
+      return !(event as MouseEvent).button;
+    })
+    .on('zoom', (ev) => {
+      zoomT.value = { k: ev.transform.k, x: ev.transform.x, y: ev.transform.y };
+      d3.select(zoomLayerEl.value).attr('transform', ev.transform.toString());
+    });
+  sel.call(zoomBehaviour);
+  sel.on('dblclick.zoom', null);
+  sel.on('dblclick', () => fitToScreen(true));
+}
+function installZoomAndFit(): void {
+  if (!svgEl.value || !zoomLayerEl.value) return;
+  installZoom();
+  void nextTick(() => fitToScreen(false));
+}
+// The <svg> lives behind a v-else and unmounts whenever a new pair's data
+// is in flight (the query key changes → data drops → "Reading data…"),
+// then remounts when it lands. d3.zoom listeners bind to the specific
+// element, so we must re-bind on every (re)mount — a one-shot latch would
+// leave drag-pan / wheel-zoom / dblclick-fit dead after the first pair
+// switch (the +/−/Fit buttons would still work, masking the bug).
+watch(svgEl, (el) => { if (el && zoomLayerEl.value) installZoomAndFit(); }, { flush: 'post' });
+// Re-fit when the graph shape changes while the element persists (e.g.
+// auto-refresh keeps the SVG mounted but the instance set shifts).
+watch(
+  () => `${nodes.value.length}|${visibleCalls.value.length}`,
+  () => { if (svgEl.value && zoomBehaviour) void nextTick(() => fitToScreen(false)); },
+);
+
+// ── Selection (edge → sidebar, node → popover). Reset on pair change.
+const selectedCallId = ref<string | null>(null);
+const popoverNodeId = ref<string | null>(null);
+function selectEdge(id: string): void {
+  popoverNodeId.value = null;
+  selectedCallId.value = selectedCallId.value === id ? null : id;
+}
+function selectNode(id: string): void {
+  selectedCallId.value = null;
+  popoverNodeId.value = popoverNodeId.value === id ? null : id;
+}
+watch([clientId, serverId], () => { selectedCallId.value = null; popoverNodeId.value = null; });
+const selectedCall = computed<InstanceTopologyCall | null>(() => calls.value.find((c) => c.id === selectedCallId.value) ?? null);
+const popoverNode = computed<InstanceTopologyNode | null>(() => nodes.value.find((n) => n.id === popoverNodeId.value) ?? null);
+function instById(id: string): InstanceTopologyNode | null {
+  return nodes.value.find((n) => n.id === id) ?? null;
+}
+const POP_W = 210;
+const popoverStyle = computed<Record<string, string>>(() => {
+  const n = popoverNode.value;
+  const p = n ? pos.value.get(n.id) : null;
+  const el = containerEl.value;
+  if (!p || !el) return { display: 'none' };
+  const z = zoomT.value;
+  const cw = el.clientWidth;
+  const ch = el.clientHeight;
+  const nx = z.x + p.cx * z.k;
+  const ny = z.y + p.cy * z.k;
+  const r = NODE_R * z.k;
+  const openRight = p.cx > VIEW_W / 2;
+  let left = openRight ? nx + r + 10 : nx - r - 10 - POP_W;
+  left = Math.max(8, Math.min(left, cw - POP_W - 8));
+  const top = Math.max(72, Math.min(ny, ch - 72));
+  const style: Record<string, string> = { left: `${left}px`, top: `${top}px`, width: `${POP_W}px`, transform: 'translateY(-50%)' };
+  return style;
+});
+// A node's service is part of THIS layer only when it's a `getAllServices`
+// entry. Conjectured / cross-layer callees (e.g. `rcmd:80`, surfaced only
+// because a layer service calls them) are NOT — they have no instance
+// dashboard in this layer, so we don't offer to open one. The check is
+// per-node, so it gates both the client and the server side.
+const layerServiceIds = computed<Set<string>>(
+  () => new Set(roster.services.value.map((s) => s.id)),
+);
+function nodeInLayer(n: InstanceTopologyNode): boolean {
+  return layerServiceIds.value.has(n.serviceId);
+}
+function openInstanceDashboard(n: InstanceTopologyNode): void {
+  if (!nodeInLayer(n)) return; // defensive — the button is hidden for these
+  const href = router.resolve({
+    path: `/layer/${layerKey.value}/instance`,
+    query: { service: n.serviceId, instance: n.name },
+  }).href;
+  window.open(href, '_blank', 'noopener');
+}
+
+// ── Edge detail rows (aligned client | server) — same as the service map.
+interface EdgeRow { id: string; label: string; unit: string; serverDef: TopologyMetricDef | null; clientDef: TopologyMetricDef | null }
+const edgeRows = computed<EdgeRow[]>(() => {
+  const map = new Map<string, EdgeRow>();
+  for (const m of cfg.value.linkServerMetrics ?? []) {
+    const row = map.get(m.id) ?? { id: m.id, label: m.label, unit: m.unit ?? '', serverDef: null, clientDef: null };
+    row.serverDef = m;
+    map.set(m.id, row);
+  }
+  for (const m of cfg.value.linkClientMetrics ?? []) {
+    const row = map.get(m.id) ?? { id: m.id, label: m.label, unit: m.unit ?? '', serverDef: null, clientDef: null };
+    row.clientDef = m;
+    if (!row.label) row.label = m.label;
+    if (!row.unit) row.unit = m.unit ?? '';
+    map.set(m.id, row);
+  }
+  return [...map.values()];
+});
+function edgeVal(c: InstanceTopologyCall, side: 'server' | 'client', def: TopologyMetricDef | null): number | null {
+  if (!def) return null;
+  const b = side === 'server' ? c.serverMetrics : c.clientMetrics;
+  return b?.[def.id] ?? null;
+}
+function edgeSeries(c: InstanceTopologyCall, side: 'server' | 'client', def: TopologyMetricDef | null): Array<number | null> {
+  if (!def) return [];
+  const b = side === 'server' ? c.serverMetricSeries : c.clientMetricSeries;
+  return b?.[def.id] ?? [];
+}
+function seriesAt(arr: Array<number | null>, idx: number | null): number | null {
+  if (idx === null || idx < 0 || idx >= arr.length) return null;
+  return arr[idx];
+}
+type EdgeRowKind = 'both' | 'client-only' | 'server-only' | 'none';
+function edgeRowValues(c: InstanceTopologyCall, row: EdgeRow): { kind: EdgeRowKind; clientV: number | null; serverV: number | null } {
+  const clientV = row.clientDef ? edgeVal(c, 'client', row.clientDef) : null;
+  const serverV = row.serverDef ? edgeVal(c, 'server', row.serverDef) : null;
+  if (clientV !== null && serverV !== null) return { kind: 'both', clientV, serverV };
+  if (clientV !== null) return { kind: 'client-only', clientV, serverV };
+  if (serverV !== null) return { kind: 'server-only', clientV, serverV };
+  return { kind: 'none', clientV, serverV };
+}
+const hoveredEdgeRowId = ref<string | null>(null);
+const hoveredEdgeBucket = ref<number | null>(null);
+function onEdgeBucketHover(rowId: string, bucket: number): void { hoveredEdgeRowId.value = rowId; hoveredEdgeBucket.value = bucket; }
+function onEdgeBucketLeave(): void { hoveredEdgeRowId.value = null; hoveredEdgeBucket.value = null; }
+function rowCrosshair(rowId: string): number | null { return hoveredEdgeRowId.value === rowId ? hoveredEdgeBucket.value : null; }
+function diffAt(row: EdgeRow, bucket: number | null): number | null {
+  const c = selectedCall.value;
+  if (bucket === null || !row.clientDef || !row.serverDef || !c) return null;
+  const cv = seriesAt(edgeSeries(c, 'client', row.clientDef), bucket);
+  const sv = seriesAt(edgeSeries(c, 'server', row.serverDef), bucket);
+  if (cv === null || sv === null) return null;
+  return sv - cv;
+}
+function diffColor(d: number | null): string {
+  if (d === null || Math.abs(d) < 0.001) return 'var(--sw-fg-3)';
+  return d > 0 ? 'var(--sw-warn)' : 'var(--sw-ok)';
+}
+function diffText(d: number | null): string {
+  if (d === null) return '—';
+  return (d >= 0 ? '+' : '') + fmtMetric(d);
+}
+
+const showPickPrompt = computed(() => !enabled.value);
+const showLoading = computed(() => enabled.value && isFetching.value && nodes.value.length === 0);
+const isEmpty = computed(() => enabled.value && !isFetching.value && nodes.value.length === 0);
+
+function onKeyDown(e: KeyboardEvent): void {
+  if (e.key !== 'Escape') return;
+  if (popoverNodeId.value) popoverNodeId.value = null;
+  else if (selectedCallId.value) selectedCallId.value = null;
+  else return;
+  e.preventDefault();
+}
+onMounted(() => window.addEventListener('keydown', onKeyDown, true));
+onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, true));
+</script>
+
+<template>
+  <div class="imv">
+    <!-- Pair picker toolbar -->
+    <div class="imv-toolbar">
+      <button class="sw-btn small ghost imv-back" type="button" @click="backToServiceMap">← {{ t('Service map') }}</button>
+      <span class="imv-title">{{ instanceMapLabel }}</span>
+      <span class="imv-divider" />
+      <span class="imv-pick-label">{{ serviceWord }}:</span>
+      <!-- A picker collapses to plain text when the call graph leaves no
+           real choice (e.g. the server has a single caller) — a one-option
+           dropdown is just noise. Switch the other side, or use the back
+           button, to reach a different pair. -->
+      <TypeaheadSelect
+        v-if="clientOptions.length > 1"
+        :model-value="clientId"
+        :options="clientOptions"
+        :placeholder="t('Client service…')"
+        :min-panel-width="260"
+        @update:model-value="(v: string) => pick('client', v)"
+      />
+      <span v-else class="imv-fixed-svc mono">{{ clientName }}</span>
+      <span class="imv-arrow">→</span>
+      <TypeaheadSelect
+        v-if="serverOptions.length > 1"
+        :model-value="serverId"
+        :options="serverOptions"
+        :placeholder="t('Server service…')"
+        :min-panel-width="260"
+        @update:model-value="(v: string) => pick('server', v)"
+      />
+      <span v-else class="imv-fixed-svc mono">{{ serverName }}</span>
+      <span v-if="isFetching" class="imv-hint">{{ t('Reading data…') }}</span>
+      <div class="imv-spacer" />
+      <div v-if="cfg.nodeMetrics.length > 0" class="imv-legend">
+        <span v-for="def in cfg.nodeMetrics" :key="def.id" class="lg-item">
+          <span class="lg-dot" :class="def.role || 'plain'" />{{ def.label }}<span v-if="def.unit" class="lg-unit"> ({{ def.unit }})</span>
+        </span>
+      </div>
+    </div>
+
+    <div class="imv-body" :class="{ 'no-selection': !selectedCall }">
+      <div ref="containerEl" class="imv-canvas">
+        <div v-if="showPickPrompt" class="imv-state">{{ t('Pick a client and server service to see their instance topology.') }}</div>
+        <div v-else-if="showLoading" class="imv-state">{{ t('Reading data…') }}</div>
+        <div v-else-if="isEmpty" class="imv-state">{{ t('No instance topology in this window.') }}</div>
+        <template v-else>
+          <svg ref="svgEl" class="imv-svg" width="100%" height="100%">
+            <g ref="zoomLayerEl" :class="{ 'has-pop': !!popoverNodeId }">
+              <g class="imv-groups">
+                <g v-for="g in groupBoxes" :key="g.side" :transform="`translate(${g.x}, ${g.y})`">
+                  <rect :width="g.w" :height="g.h" rx="14" ry="14" class="imv-grp-rect" />
+                  <text x="16" y="18" class="imv-grp-head">
+                    <tspan class="imv-grp-name mono">{{ g.name }}</tspan>
+                    <tspan class="imv-grp-role" dx="8">{{ instanceWord }} · {{ g.role }}</tspan>
+                  </text>
+                </g>
+              </g>
+              <g v-for="c in visibleCalls" :key="c.id" class="imv-edge" :data-edge-id="c.id" @click.stop="selectEdge(c.id)">
+                <path :d="edgePathD(c)" fill="none" stroke="transparent" stroke-width="14" style="cursor: pointer" />
+                <path
+                  :d="edgePathD(c)" fill="none"
+                  :stroke="selectedCallId === c.id ? 'var(--sw-accent-2)' : 'var(--sw-accent)'"
+                  :stroke-width="selectedCallId === c.id ? 3 : 1.6"
+                  :opacity="selectedCallId === c.id ? 1 : 0.6"
+                  stroke-linecap="round" style="pointer-events: none"
+                />
+                <path
+                  :d="edgePathD(c)" fill="none"
+                  :stroke="selectedCallId === c.id ? 'var(--sw-accent-2)' : 'var(--sw-accent)'"
+                  :stroke-width="selectedCallId === c.id ? 4 : 3"
+                  stroke-linecap="round" stroke-dasharray="4 28" opacity="0.95" style="pointer-events: none"
+                >
+                  <animate attributeName="stroke-dashoffset" from="32" to="0" dur="3s" repeatCount="indefinite" />
+                </path>
+              </g>
+              <g
+                v-for="n in nodes" :key="n.id" class="imv-node"
+                :class="{ sel: popoverNodeId === n.id }" :data-node-id="n.id"
+                :transform="`translate(${pos.get(n.id)?.cx ?? 0}, ${pos.get(n.id)?.cy ?? 0})`"
+                @click.stop="selectNode(n.id)"
+              >
+                <circle :r="NODE_R" class="node-bg" :stroke="ringColor(n)" :stroke-width="popoverNodeId === n.id ? 4 : 3" />
+                <text class="node-center" text-anchor="middle" :dy="centerDef?.unit ? '-1' : '0.36em'">{{ fmtVal(nodeVal(n, centerDef)) }}</text>
+                <text v-if="centerDef?.unit" class="node-unit" text-anchor="middle" dy="12">{{ centerDef.unit }}</text>
+                <text class="node-label mono" text-anchor="middle" :y="NODE_R + 15">{{ n.name }}</text>
+              </g>
+            </g>
+          </svg>
+
+          <div v-if="popoverNode" class="imv-node-pop sw-card" :style="popoverStyle">
+            <header class="np-head">
+              <span class="np-name mono">{{ popoverNode.name }}</span>
+              <button class="sw-btn small ghost" type="button" @click="popoverNodeId = null">×</button>
+            </header>
+            <div class="np-svc mono dim">{{ displayName(popoverNode.serviceName) }}</div>
+            <dl class="np-kv">
+              <template v-for="def in cfg.nodeMetrics" :key="def.id">
+                <dt>{{ def.label }}</dt>
+                <dd class="mono">{{ fmtVal(nodeVal(popoverNode, def), def.unit) }}</dd>
+              </template>
+            </dl>
+            <button
+              v-if="nodeInLayer(popoverNode)"
+              class="sw-btn small primary np-open"
+              type="button"
+              @click="openInstanceDashboard(popoverNode)"
+            >
+              {{ t('Open instance dashboard') }} ↗
+            </button>
+            <p v-else class="np-note">{{ t('Callee outside this layer — no instance dashboard here.') }}</p>
+          </div>
+
+          <div class="imv-zoom">
+            <button class="sw-btn small" type="button" :title="t('Zoom in')" @click="zoomBy(1.25)">+</button>
+            <button class="sw-btn small" type="button" :title="t('Zoom out')" @click="zoomBy(1 / 1.25)">−</button>
+            <button class="sw-btn small" type="button" :title="t('Fit to screen')" @click="fitToScreen(true)">{{ t('Fit') }}</button>
+          </div>
+
+          <!-- Ring-colour ramp: what the node-circle BORDER colour means
+               for the configured ring metric (green = healthy → red). The
+               break labels sit at the band boundaries (read from the
+               metric's thresholds), so e.g. red begins exactly at the
+               configured danger value. -->
+          <div v-if="ringDef" class="imv-ring-legend">
+            <div class="lg-label">
+              {{ t('Node ring') }} · {{ ringDef.label }}
+              <span class="lg-direction">{{ ringDirectionHint }}</span>
+            </div>
+            <div class="lg-ramp">
+              <span style="background: var(--sw-ok)" />
+              <span style="background: #fbbf24" />
+              <span style="background: var(--sw-warn)" />
+              <span style="background: var(--sw-err)" />
+            </div>
+            <div class="lg-scale">
+              <span v-for="(lbl, i) in ringScaleLabels" :key="i">{{ lbl }}</span>
+            </div>
+          </div>
+        </template>
+      </div>
+
+      <aside v-if="selectedCall" class="imv-panel">
+        <header class="imv-panel-head">
+          <div class="ip-edge mono">
+            <span>{{ instById(selectedCall.source)?.name }}</span>
+            <span class="imv-arrow">→</span>
+            <span>{{ instById(selectedCall.target)?.name }}</span>
+          </div>
+          <button class="sw-btn small ghost" type="button" @click="selectedCallId = null">×</button>
+        </header>
+        <div class="ip-tags">
+          <span class="sw-tag">{{ selectedCall.detectPoints.join(' · ') || t('relation') }}</span>
+        </div>
+        <div class="imv-panel-body">
+          <div v-if="edgeRows.length > 0" class="ip-edge-rows">
+            <div v-for="row in edgeRows" :key="row.id" class="ip-edge-row">
+              <div class="ip-edge-row-head">
+                <span class="ip-edge-row-label">{{ row.label }}<span v-if="row.unit" class="ru"> ({{ row.unit }})</span></span>
+                <span v-if="hoveredEdgeRowId === row.id && hoveredEdgeBucket !== null" class="ip-edge-tip">
+                  <template v-if="row.clientDef"><span class="tip-tag" style="color: var(--sw-info)">C</span><span class="tip-val">{{ fmtMetric(seriesAt(edgeSeries(selectedCall, 'client', row.clientDef), hoveredEdgeBucket)) }}</span></template>
+                  <template v-if="row.serverDef"><span class="tip-sep">·</span><span class="tip-tag" style="color: var(--sw-accent)">S</span><span class="tip-val">{{ fmtMetric(seriesAt(edgeSeries(selectedCall, 'server', row.serverDef), hoveredEdgeBucket)) }}</span></template>
+                  <template v-if="row.clientDef && row.serverDef"><span class="tip-sep">·</span><span class="tip-tag">Δ</span><span class="tip-val" :style="{ color: diffColor(diffAt(row, hoveredEdgeBucket)) }">{{ diffText(diffAt(row, hoveredEdgeBucket)) }}</span></template>
+                </span>
+              </div>
+              <template v-if="edgeRowValues(selectedCall, row).kind === 'both'">
+                <div class="ip-edge-pair">
+                  <div class="ip-edge-cell">
+                    <div class="ip-edge-cell-head"><span class="tag c">{{ t('Client') }}</span><span class="num">{{ fmtMetric(edgeRowValues(selectedCall, row).clientV) }}</span></div>
+                    <Sparkline :values="edgeSeries(selectedCall, 'client', row.clientDef)" color="var(--sw-info)" :height="36" :stroke="1.4" fluid :crosshair-bucket="rowCrosshair(row.id)" @bucket-hover="(b: number) => onEdgeBucketHover(row.id, b)" @bucket-leave="onEdgeBucketLeave" />
+                  </div>
+                  <div class="ip-edge-cell">
+                    <div class="ip-edge-cell-head"><span class="tag s">{{ t('Server') }}</span><span class="num">{{ fmtMetric(edgeRowValues(selectedCall, row).serverV) }}</span></div>
+                    <Sparkline :values="edgeSeries(selectedCall, 'server', row.serverDef)" color="var(--sw-accent)" :height="36" :stroke="1.4" fluid :crosshair-bucket="rowCrosshair(row.id)" @bucket-hover="(b: number) => onEdgeBucketHover(row.id, b)" @bucket-leave="onEdgeBucketLeave" />
+                  </div>
+                </div>
+              </template>
+              <template v-else-if="edgeRowValues(selectedCall, row).kind === 'client-only'">
+                <div class="ip-edge-cell">
+                  <div class="ip-edge-cell-head"><span class="tag c">{{ t('Client') }}</span><span class="num">{{ fmtMetric(edgeRowValues(selectedCall, row).clientV) }}</span><span class="side-note">{{ t('client') }}</span></div>
+                  <Sparkline :values="edgeSeries(selectedCall, 'client', row.clientDef)" color="var(--sw-info)" :height="36" :stroke="1.4" fluid :crosshair-bucket="rowCrosshair(row.id)" @bucket-hover="(b: number) => onEdgeBucketHover(row.id, b)" @bucket-leave="onEdgeBucketLeave" />
+                </div>
+              </template>
+              <template v-else-if="edgeRowValues(selectedCall, row).kind === 'server-only'">
+                <div class="ip-edge-cell">
+                  <div class="ip-edge-cell-head"><span class="tag s">{{ t('Server') }}</span><span class="num">{{ fmtMetric(edgeRowValues(selectedCall, row).serverV) }}</span><span class="side-note">{{ t('server') }}</span></div>
+                  <Sparkline :values="edgeSeries(selectedCall, 'server', row.serverDef)" color="var(--sw-accent)" :height="36" :stroke="1.4" fluid :crosshair-bucket="rowCrosshair(row.id)" @bucket-hover="(b: number) => onEdgeBucketHover(row.id, b)" @bucket-leave="onEdgeBucketLeave" />
+                </div>
+              </template>
+              <template v-else>
+                <div class="ip-edge-none">{{ t('no value') }}</div>
+              </template>
+            </div>
+          </div>
+          <div v-else class="ip-empty">{{ t('no line metrics configured') }}</div>
+        </div>
+      </aside>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.imv { display: flex; flex-direction: column; gap: 10px; height: 100%; min-height: 0; }
+.imv-toolbar {
+  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
+  padding: 8px 10px; border: 1px solid var(--sw-line); border-radius: 6px; background: var(--sw-bg-1);
+}
+.imv-back { flex: 0 0 auto; }
+.imv-title { font-size: 12px; font-weight: 700; color: var(--sw-fg-0); }
+.imv-divider { width: 1px; height: 18px; background: var(--sw-line-2); margin: 0 2px; }
+.imv-pick-label { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--sw-fg-3); }
+.imv-fixed-svc {
+  font-size: 12px; color: var(--sw-fg-1);
+  padding: 4px 9px; border-radius: 5px;
+  border: 1px solid var(--sw-line); background: var(--sw-bg-2);
+}
+.imv-arrow { color: var(--sw-accent); font-weight: 700; }
+.imv-hint { font-size: 10.5px; color: var(--sw-fg-3); }
+.imv-spacer { flex: 1; }
+.imv-legend { display: flex; gap: 14px; align-items: center; }
+.lg-item { display: inline-flex; align-items: center; gap: 5px; font-size: 10px; color: var(--sw-fg-2); }
+.lg-dot { width: 11px; height: 11px; border-radius: 50%; flex: 0 0 auto; }
+.lg-dot.center { background: var(--sw-bg-3); border: 1.5px solid var(--sw-fg-1); }
+.lg-dot.ring { background: transparent; border: 2px solid var(--sw-fg-2); }
+.lg-dot.secondary { width: 8px; height: 8px; background: var(--sw-fg-3); }
+.lg-dot.lineServer, .lg-dot.lineClient, .lg-dot.plain { width: 8px; height: 8px; background: var(--sw-fg-3); border-radius: 2px; }
+.lg-unit { color: var(--sw-fg-3); }
+
+.imv-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 1fr 380px; gap: 10px; }
+.imv-body.no-selection { grid-template-columns: 1fr; }
+.imv-canvas {
+  position: relative; overflow: hidden; min-width: 0; min-height: 420px;
+  border: 1px solid var(--sw-line); border-radius: 6px;
+  background: radial-gradient(circle at center, var(--sw-bg-1) 0%, var(--sw-bg-0) 100%);
+}
+.imv-state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--sw-fg-3); font-size: 12px; text-align: center; padding: 24px; }
+.imv-grp-rect { fill: var(--sw-bg-1); fill-opacity: 0.35; stroke: var(--sw-line-2); stroke-width: 1; stroke-dasharray: 4 5; }
+.imv-grp-name { fill: var(--sw-fg-1); font-size: 12px; font-weight: 700; }
+.imv-grp-role { fill: var(--sw-fg-3); font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; }
+.imv-svg { width: 100%; height: 100%; display: block; cursor: grab; }
+.imv-svg:active { cursor: grabbing; }
+.imv-node { cursor: pointer; }
+.node-bg { fill: var(--sw-bg-2); transition: stroke-width 0.1s ease; }
+.imv-node:hover .node-bg { fill: var(--sw-bg-3); }
+.node-center { fill: var(--sw-fg-0); font-size: 12px; font-weight: 700; font-family: var(--sw-mono); }
+.node-unit { fill: var(--sw-fg-3); font-size: 8px; font-family: var(--sw-mono); text-transform: uppercase; letter-spacing: 0.04em; }
+.node-label { fill: var(--sw-fg-2); font-size: 10.5px; }
+.has-pop .imv-edge { opacity: 0.16; transition: opacity 0.12s ease; }
+.has-pop .imv-node:not(.sel) { opacity: 0.3; transition: opacity 0.12s ease; }
+
+.imv-node-pop { position: absolute; z-index: 5; padding: 8px 10px 10px; box-shadow: 0 6px 22px rgba(0,0,0,0.4); }
+.np-head { display: flex; align-items: center; gap: 6px; }
+.np-name { font-size: 11.5px; color: var(--sw-fg-0); flex: 1; word-break: break-all; }
+.np-svc { font-size: 10px; margin-top: 2px; word-break: break-all; }
+.np-kv { display: grid; grid-template-columns: 1fr auto; gap: 3px 10px; margin: 8px 0; font-size: 11px; }
+.np-kv dt { color: var(--sw-fg-3); }
+.np-kv dd { margin: 0; color: var(--sw-fg-1); text-align: right; }
+.np-open { width: 100%; justify-content: center; }
+.np-note { margin: 6px 0 0; font-size: 10.5px; line-height: 1.4; color: var(--sw-fg-3); }
+
+.imv-zoom { position: absolute; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 4px; z-index: 3; }
+.imv-zoom .sw-btn.small { width: 28px; height: 26px; padding: 0; justify-content: center; }
+
+.imv-ring-legend {
+  position: absolute; left: 12px; bottom: 12px; z-index: 3;
+  padding: 8px 10px; min-width: 170px; font-size: 10.5px;
+  background: rgba(15, 19, 26, 0.92); backdrop-filter: blur(8px);
+  border: 1px solid var(--sw-line); border-radius: 6px;
+}
+.imv-ring-legend .lg-label {
+  font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.08em;
+  color: var(--sw-fg-3); margin-bottom: 4px; display: flex; align-items: baseline; gap: 6px;
+}
+.imv-ring-legend .lg-direction {
+  font-size: 9px; letter-spacing: 0.04em; text-transform: none;
+  color: var(--sw-fg-3); font-style: italic; opacity: 0.85;
+}
+/* Ramp + scale share one 4-column grid so every break label sits at its
+ * band's left (healthy) edge — the boundary between two swatches — instead
+ * of drifting across a wider row. So `95%-` lands exactly where red starts. */
+.imv-ring-legend .lg-ramp { display: grid; grid-template-columns: repeat(4, 1fr); gap: 2px; margin-bottom: 3px; }
+.imv-ring-legend .lg-ramp span { height: 8px; border-radius: 2px; display: block; }
+.imv-ring-legend .lg-scale { display: grid; grid-template-columns: repeat(4, 1fr); color: var(--sw-fg-3); font-size: 9px; }
+.imv-ring-legend .lg-scale span { text-align: left; }
+
+.imv-panel { border: 1px solid var(--sw-line); border-radius: 6px; background: var(--sw-bg-1); display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
+.imv-panel-head { display: flex; align-items: flex-start; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--sw-line); flex: 0 0 auto; }
+.ip-edge { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; flex: 1; font-size: 11px; color: var(--sw-fg-0); word-break: break-all; }
+.ip-tags { padding: 6px 12px 0; }
+.imv-panel-body { flex: 1; overflow-y: auto; padding: 10px 12px 16px; }
+.ip-edge-rows { display: flex; flex-direction: column; gap: 10px; }
+.ip-edge-row { border: 1px solid var(--sw-line); border-radius: 4px; padding: 6px 8px; background: var(--sw-bg-0); }
+.ip-edge-row-head { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; margin-bottom: 4px; }
+.ip-edge-row-label { font-size: 10.5px; color: var(--sw-fg-2); }
+.ip-edge-row-label .ru { color: var(--sw-fg-3); }
+.ip-edge-tip { display: inline-flex; align-items: baseline; gap: 4px; font-size: 10px; font-family: var(--sw-mono); }
+.ip-edge-tip .tip-tag { font-weight: 700; }
+.ip-edge-tip .tip-val { color: var(--sw-fg-1); }
+.ip-edge-tip .tip-sep { color: var(--sw-fg-3); }
+.ip-edge-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+.ip-edge-cell { min-width: 0; }
+.ip-edge-cell-head { display: flex; align-items: baseline; gap: 6px; margin-bottom: 2px; }
+.ip-edge-cell-head .tag { font-size: 8.5px; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700; }
+.ip-edge-cell-head .tag.c { color: var(--sw-info); }
+.ip-edge-cell-head .tag.s { color: var(--sw-accent); }
+.ip-edge-cell-head .num { font-family: var(--sw-mono); font-size: 11px; color: var(--sw-fg-0); }
+.ip-edge-cell-head .side-note { font-size: 8.5px; color: var(--sw-fg-3); margin-left: auto; }
+.ip-edge-none, .ip-empty { color: var(--sw-fg-3); font-size: 11px; padding: 6px 0; }
+.dim { color: var(--sw-fg-3); }
+.mono { font-family: var(--sw-mono); }
+.sw-btn.small { height: 24px; padding: 0 10px; font-size: 11px; }
+.sw-btn.ghost { background: transparent; border: 1px solid var(--sw-line-2); color: var(--sw-fg-2); cursor: pointer; }
+</style>
diff --git a/apps/ui/src/layer/service-map/LayerServiceMapView.vue b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
index f0447d8..21425cf 100644
--- a/apps/ui/src/layer/service-map/LayerServiceMapView.vue
+++ b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
@@ -53,6 +53,7 @@
 <script setup lang="ts">
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
 import * as d3 from 'd3';
+import { useI18n } from 'vue-i18n';
 import { useRoute, useRouter } from 'vue-router';
 import type {
   LayerDef,
@@ -83,6 +84,7 @@ import { useServiceHierarchy } from '@/layer/service-map/useServiceHierarchy';
 const props = defineProps<{ layerKey?: string; embedded?: boolean }>();
 const route = useRoute();
 const router = useRouter();
+const { t } = useI18n({ useScope: 'global' });
 const layerKey = computed(() =>
   props.layerKey && props.layerKey.length > 0 ? props.layerKey : String(route.params.layerKey ?? ''),
 );
@@ -984,6 +986,30 @@ const selectedCallTarget = computed<LayoutNode | null>(() => {
   return layoutNodes.value.find((n) => n.id === c.target) ?? null;
 });
 
+// ── Instance-topology drill-down. Offered on an edge only when the
+// layer ships an `instanceTopology` config (echoed in `cfg`) AND both
+// endpoints are real services — OAP's getServiceInstanceTopology needs
+// two real services. Clicking it switches the Topology tab to the
+// Instance map sub-tab, pre-filled with this edge's client/server pair
+// (edge source = client, target = server).
+const hasInstanceTopology = computed<boolean>(() => Boolean(cfg.value?.instanceTopology));
+const canDrillInstance = computed<boolean>(
+  () =>
+    !embedded.value &&
+    hasInstanceTopology.value &&
+    Boolean(selectedCallSource.value?.isReal && selectedCallTarget.value?.isReal),
+);
+function openInstanceTopology(): void {
+  const c = selectedCall.value;
+  const src = selectedCallSource.value;
+  const dst = selectedCallTarget.value;
+  if (!c || !src || !dst || !src.isReal || !dst.isReal) return;
+  void router.push({
+    path: `/layer/${layerKey.value}/topology`,
+    query: { ...route.query, view: 'instance', client: c.source, server: c.target },
+  });
+}
+
 /**
  * Visual focus sets. When a node OR an edge is selected, we keep the
  * "related" subgraph at full opacity and dim everything else, so the
@@ -1487,14 +1513,6 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
             <option :value="3">3 hops</option>
           </select>
         </label>
-        <!-- Open this layer's topology in the 3D map, focused on just
-             this layer (the existing /3d/map scene scoped via ?layer). -->
-        <router-link
-          class="sw-btn small"
-          :to="{ path: '/3d/map', query: { layer: layerKey } }"
-          target="_blank"
-          title="View this layer's topology in 3D"
-        >View in 3D</router-link>
         <button class="sw-btn small" type="button" @click="() => refetch()">Refresh</button>
       </div>
     </header>
@@ -2052,7 +2070,15 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
               <span class="sw-tag">{{ selectedCall.detectPoints.join(' · ') || 'relation' }}</span>
             </div>
           </div>
-          <button class="sw-btn small" type="button" @click="selectedCallId = null">×</button>
+          <div class="sp-head-actions">
+            <button
+              v-if="canDrillInstance"
+              class="sw-btn small primary"
+              type="button"
+              @click="openInstanceTopology"
+            >{{ t('Instance map') }} →</button>
+            <button class="sw-btn small" type="button" @click="selectedCallId = null">×</button>
+          </div>
         </header>
         <!-- Edge line metrics — one card per metric. Inside, client
              and server cells sit SIDE-BY-SIDE (left | right) and each
@@ -2752,6 +2778,7 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st
   border-bottom: 1px solid var(--sw-line);
 }
 .sp-id { min-width: 0; flex: 1; }
+.sp-head-actions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
 .sp-mono {
   font-family: var(--sw-mono);
   font-size: 12.5px;
diff --git a/apps/ui/src/layer/service-map/LayerTopologyTab.vue b/apps/ui/src/layer/service-map/LayerTopologyTab.vue
new file mode 100644
index 0000000..d30691b
--- /dev/null
+++ b/apps/ui/src/layer/service-map/LayerTopologyTab.vue
@@ -0,0 +1,46 @@
+<!--
+  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.
+-->
+<!--
+  Per-layer Topology tab shell. The service map is the page; the instance
+  map is a drill-down reached by clicking a call edge → "Instance map →"
+  (which sets `?view=instance&client=&server=`). There is deliberately no
+  Service-map/Instance-map tab toggle — the instance map is an in-context
+  drill-down with its own "back to service map" affordance, not a
+  standalone entrance. A stray `?view=instance` on a layer that doesn't
+  enable instance topology falls back to the service map.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useRoute } from 'vue-router';
+import type { LayerDef } from '@/api/client';
+import { useLayers } from '@/shell/useLayers';
+import LayerServiceMapView from '@/layer/service-map/LayerServiceMapView.vue';
+import LayerInstanceTopologyView from '@/layer/service-map/LayerInstanceTopologyView.vue';
+
+const route = useRoute();
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+const { layers } = useLayers();
+const layer = computed<LayerDef | null>(() => layers.value.find((l) => l.key === layerKey.value) ?? null);
+const showInstance = computed(
+  () => Boolean(layer.value?.caps?.instanceTopology) && route.query.view === 'instance',
+);
+</script>
+
+<template>
+  <LayerInstanceTopologyView v-if="showInstance" />
+  <LayerServiceMapView v-else />
+</template>
diff --git a/apps/ui/src/layer/service-map/useLayerTopology.ts b/apps/ui/src/layer/service-map/useInstanceTopology.ts
similarity index 53%
copy from apps/ui/src/layer/service-map/useLayerTopology.ts
copy to apps/ui/src/layer/service-map/useInstanceTopology.ts
index 3a43b6e..94775f2 100644
--- a/apps/ui/src/layer/service-map/useLayerTopology.ts
+++ b/apps/ui/src/layer/service-map/useInstanceTopology.ts
@@ -16,48 +16,62 @@
  */
 
 /**
- * vue-query wrapper around `GET /api/layer/:key/topology`. The query
- * fires whenever the selected service, BFS depth, OR the global
- * topbar time picker changes — the picker is part of the queryKey so
- * the operator sees topology metrics that line up with whatever
- * window they're looking at (including cold-stage windows when the
- * Cold pill is on; the X-Horizon-Cold-Stage header is appended by
- * the api-client interceptor).
+ * vue-query wrapper around `GET /api/layer/:key/instance-topology`.
+ *
+ * Drives the per-layer Instance map view. The query is gated by `enabled`
+ * (a client + server service are both picked, and the view is active) so
+ * it only fires while the operator is actually looking at the drill-down.
+ * Same time-picker queryKey wiring as the service map so the instance
+ * metrics line up with the window the operator is on.
  */
 
 import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import { useAutoRefreshSubscribe } from '../../controls/useAutoRefreshSubscribe';
 import { useTimeRangeStore } from '../../controls/timeRange';
+import { usePreviewLayerBlock } from '@/controls/previewConfig';
 import { bffClient } from '@/api/client';
 
-export function useLayerTopology(
+export function useInstanceTopology(
   layerKey: Ref<string>,
-  service: Ref<string | null>,
-  depth: Ref<number>,
+  clientServiceId: Ref<string | null>,
+  serverServiceId: Ref<string | null>,
+  enabled: Ref<boolean>,
 ) {
   const timeRange = useTimeRangeStore();
-  // Re-resolve range / step on every read so the queryKey changes on
-  // picker flips. A stable triplet (step + ms-rounded bounds) prevents
-  // identity-thrash on every store tick.
+  // Preview-only: the draft `topology` block (the BFF reads its nested
+  // `instanceTopology`), so the instance map previews the draft too.
+  const previewCfg = usePreviewLayerBlock(layerKey, 'topology');
   const rangeKey = computed(() => ({
     step: timeRange.step,
     startMs: timeRange.range.startMs,
     endMs: timeRange.range.endMs,
   }));
+  const isEnabled = computed(
+    () =>
+      enabled.value &&
+      layerKey.value.length > 0 &&
+      !!clientServiceId.value &&
+      !!serverServiceId.value,
+  );
   const q = useQuery({
-    queryKey: ['layer-topology', layerKey, service, depth, rangeKey],
+    queryKey: ['layer-instance-topology', layerKey, clientServiceId, serverServiceId, rangeKey, previewCfg],
     queryFn: () =>
-      bffClient.layer.topology(
+      bffClient.layer.instanceTopology(
         layerKey.value,
-        service.value ?? undefined,
-        depth.value,
+        clientServiceId.value as string,
+        serverServiceId.value as string,
         rangeKey.value,
+        previewCfg.value,
       ),
-    enabled: computed(() => layerKey.value.length > 0),
+    enabled: isEnabled,
     staleTime: 30_000,
   });
-  useAutoRefreshSubscribe(() => q.refetch());
+  // Only ride the global ticker while the view is active — a forced
+  // refetch on a closed/disabled query would fetch needlessly.
+  useAutoRefreshSubscribe(() => {
+    if (isEnabled.value) void q.refetch();
+  });
 
   return {
     data: computed(() => q.data.value ?? null),
diff --git a/apps/ui/src/layer/service-map/useLayerTopology.ts b/apps/ui/src/layer/service-map/useLayerTopology.ts
index 3a43b6e..029c035 100644
--- a/apps/ui/src/layer/service-map/useLayerTopology.ts
+++ b/apps/ui/src/layer/service-map/useLayerTopology.ts
@@ -29,6 +29,7 @@ import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import { useAutoRefreshSubscribe } from '../../controls/useAutoRefreshSubscribe';
 import { useTimeRangeStore } from '../../controls/timeRange';
+import { usePreviewLayerBlock } from '@/controls/previewConfig';
 import { bffClient } from '@/api/client';
 
 export function useLayerTopology(
@@ -37,6 +38,11 @@ export function useLayerTopology(
   depth: Ref<number>,
 ) {
   const timeRange = useTimeRangeStore();
+  // In `?mode=preview` only: forward the operator's draft `topology` block
+  // so the map renders the unpublished edit. Empty otherwise — a normal
+  // (absent-remote) read never carries a draft, keeping the two paths
+  // cleanly separate.
+  const previewCfg = usePreviewLayerBlock(layerKey, 'topology');
   // Re-resolve range / step on every read so the queryKey changes on
   // picker flips. A stable triplet (step + ms-rounded bounds) prevents
   // identity-thrash on every store tick.
@@ -46,13 +52,14 @@ export function useLayerTopology(
     endMs: timeRange.range.endMs,
   }));
   const q = useQuery({
-    queryKey: ['layer-topology', layerKey, service, depth, rangeKey],
+    queryKey: ['layer-topology', layerKey, service, depth, rangeKey, previewCfg],
     queryFn: () =>
       bffClient.layer.topology(
         layerKey.value,
         service.value ?? undefined,
         depth.value,
         rangeKey.value,
+        previewCfg.value,
       ),
     enabled: computed(() => layerKey.value.length > 0),
     staleTime: 30_000,
diff --git a/apps/ui/src/layer/traces/useLayerTraces.ts b/apps/ui/src/layer/traces/useLayerTraces.ts
index 4cf1e86..b10997a 100644
--- a/apps/ui/src/layer/traces/useLayerTraces.ts
+++ b/apps/ui/src/layer/traces/useLayerTraces.ts
@@ -23,6 +23,7 @@
 
 import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
+import { usePreviewLayerBlock } from '@/controls/previewConfig';
 import { bffClient } from '@/api/client';
 import type {
   TraceDetailResponse,
@@ -59,10 +60,13 @@ export interface TraceListParams {
 }
 
 export function useLayerTraces(layerKey: Ref<string>, params: TraceListParams) {
+  // Preview-only: forward the draft `traces` block (source selector).
+  const previewCfg = usePreviewLayerBlock(layerKey, 'traces');
   const q = useQuery<TraceListResponse>({
     queryKey: [
       'layer-traces',
       layerKey,
+      previewCfg,
       params.source,
       params.service,
       params.instanceId,
@@ -96,6 +100,7 @@ export function useLayerTraces(layerKey: Ref<string>, params: TraceListParams) {
         ...(params.customStart.value && params.customEnd.value
           ? { start: params.customStart.value, end: params.customEnd.value }
           : { windowMinutes: params.windowMinutes.value }),
+        ...(previewCfg.value ? { previewConfig: previewCfg.value } : {}),
       }),
     enabled: computed(
       () => layerKey.value.length > 0 && (params.enabled ? params.enabled.value : true),
diff --git a/apps/ui/src/layer/useLayerServices.ts b/apps/ui/src/layer/useLayerServices.ts
index 4422ef9..252bb40 100644
--- a/apps/ui/src/layer/useLayerServices.ts
+++ b/apps/ui/src/layer/useLayerServices.ts
@@ -27,6 +27,7 @@
 
 import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
+import { useAutoRefreshSubscribe } from '@/controls/useAutoRefreshSubscribe';
 import { bffClient } from '@/api/client';
 
 export interface LayerServiceRow {
@@ -35,15 +36,29 @@ export interface LayerServiceRow {
   normal: boolean | null;
 }
 
+/**
+ * Reactive, shared roster for a layer. vue-query keys it by
+ * `['layer-services', layerKey]`, so every caller of this composable (and
+ * anyone using that same key) reads ONE cached copy — the app-wide page
+ * cache for the service list; no repeated reads within or across the
+ * pages that need it. The 60s `staleTime` matches the BFF catalog's TTL.
+ *
+ * It also rides the global auto-refresh ticker: a manual / interval
+ * refresh re-pulls the roster (cheap — the BFF returns its cached
+ * catalog snapshot), so services that came online or went away show up
+ * without a navigation. Pages that own their time range suspend the
+ * ticker, so they don't thrash it either.
+ */
 export function useLayerServices(layerKey: Ref<string>) {
   const q = useQuery({
     queryKey: ['layer-services', layerKey],
     queryFn: () => bffClient.layer.services(layerKey.value),
     enabled: computed(() => layerKey.value.length > 0),
-    // The BFF caches the catalog snapshot for 60s server-side. Match
-    // that here so the UI doesn't re-fire on every layer entry.
     staleTime: 60_000,
   });
+  useAutoRefreshSubscribe(() => {
+    if (layerKey.value.length > 0) void q.refetch();
+  });
   return {
     data: computed(() => q.data.value ?? null),
     services: computed<LayerServiceRow[]>(() => q.data.value?.services ?? []),
diff --git a/apps/ui/src/render/overview/useOverviewDashboard.ts b/apps/ui/src/render/overview/useOverviewDashboard.ts
index fd731e4..5210747 100644
--- a/apps/ui/src/render/overview/useOverviewDashboard.ts
+++ b/apps/ui/src/render/overview/useOverviewDashboard.ts
@@ -20,6 +20,8 @@ import { useQueries, useQuery } from '@tanstack/vue-query';
 import type { LandingConfig, OverviewDashboard, OverviewWidget } from '@skywalking-horizon-ui/api-client';
 import { bffClient } from '@/api/client';
 import { useTimeRangeStore } from '@/controls/timeRange';
+import { getPreviewContentFor } from '@/controls/configBundle';
+import { overviewEditName } from '@/controls/localTemplateEdits';
 
 /**
  * Resolved value for one overview widget. The renderer reads
@@ -105,7 +107,20 @@ export function useOverviewDashboard(idRef: Ref<string>) {
     staleTime: 60_000,
   });
 
-  const widgets = computed<OverviewWidget[]>(() => dash.data.value?.dashboard.widgets ?? []);
+  // Preview overlay: when the admin opens this page in `?mode=preview`,
+  // render the previewed source (local draft / bundled / remote snapshot)
+  // instead of the fetched live remote — the same overlay the config
+  // bundle applies to overview LIST views, so the admin Preview button
+  // works on the detail page too. Both the rendered widgets AND the MQE
+  // fan-out below read from this, so previewed metrics resolve live.
+  const dashboard = computed<OverviewDashboard | null>(
+    () =>
+      getPreviewContentFor<OverviewDashboard>(overviewEditName(idRef.value)) ??
+      dash.data.value?.dashboard ??
+      null,
+  );
+
+  const widgets = computed<OverviewWidget[]>(() => dashboard.value?.widgets ?? []);
   const layerRequests = computed(() => groupByLayer(widgets.value));
 
   // The topbar time picker is part of every overview query so flipping
@@ -204,7 +219,6 @@ export function useOverviewDashboard(idRef: Ref<string>) {
     return out;
   });
 
-  const dashboard = computed<OverviewDashboard | null>(() => dash.data.value?.dashboard ?? null);
   const isLoadingData = computed(() => layerQueries.value.some((q) => q.isLoading));
 
   return {
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index dbe367d..6b359ee 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -537,7 +537,7 @@ watch(
                   class="sw-nav-item"
                   :class="{ 'is-active': isActive(`/layer/${L.key}/topology`) }"
                 >
-                  <Icon name="topo" /><span>Topology</span>
+                  <Icon name="topo" /><span>{{ L.slots.topology ?? 'Topology' }}</span>
                 </RouterLink>
                 <RouterLink
                   v-if="L.caps.endpointDependency"
@@ -684,7 +684,7 @@ watch(
             class="sw-nav-item"
             :class="{ 'is-active': isActive(`/layer/${E.layer.key}/topology`) }"
           >
-            <Icon name="topo" /><span>Topology</span>
+            <Icon name="topo" /><span>{{ E.layer.slots.topology ?? 'Topology' }}</span>
           </RouterLink>
           <RouterLink
             v-if="E.layer.caps.endpointDependency"
diff --git a/apps/ui/src/shell/GlobalConnectivityBanner.vue b/apps/ui/src/shell/GlobalConnectivityBanner.vue
index ad513fc..04db616 100644
--- a/apps/ui/src/shell/GlobalConnectivityBanner.vue
+++ b/apps/ui/src/shell/GlobalConnectivityBanner.vue
@@ -26,11 +26,18 @@
  * default 30s `useOapInfo` cadence — so recovery shows up in the UI
  * without an operator-triggered refresh.
  *
- * Only the query port (`:12800`) drives this strip. Admin-port
- * (`:17128`) failures render in-page via `AdminFeatureWarning`
- * inside each admin-host route — they don't blanket-warn because a
- * dead admin host is normal in deploys that opted out of those
- * modules.
+ * Two fundamental failures drive this strip, same red theme, only one
+ * at a time (query takes precedence):
+ *   1. Query port (`:12800`) unreachable — no telemetry at all.
+ *   2. OAP UI-template store unreachable — the BFF then serves NO
+ *      per-layer dashboards / overviews / topology (bundled is never a
+ *      render-time fallback), so the entire dashboard surface is blocked,
+ *      not silently empty. Signalled by `syncStatus.unreachable` on the
+ *      config bundle.
+ *
+ * Admin-port write failures still render in-page via `AdminFeatureWarning`
+ * inside each admin-host route — those are edit-surface warnings, distinct
+ * from the read-surface block above.
  */
 import { computed, onBeforeUnmount, ref, watch } from 'vue';
 import { useRoute } from 'vue-router';
@@ -38,6 +45,7 @@ import { useI18n } from 'vue-i18n';
 
 const { t } = useI18n({ useScope: 'global' });
 import { useOapInfo } from '@/shell/useOapInfo';
+import { useConfigBundle, refreshConfigBundle } from '@/controls/configBundle';
 import Icon from '@/components/icons/Icon.vue';
 
 type RetryChoice = 'off' | '5' | '15' | '60';
@@ -138,6 +146,37 @@ const errorText = computed<string>(() => info.value?.error ?? 'no response');
 // already on the cluster page — hide it there.
 const onClusterPage = computed<boolean>(() => route.path.startsWith('/operate/cluster'));
 const queryUrl = computed<string | undefined>(() => info.value?.queryUrl);
+
+// --- Template store (OAP UI-template overlay) unreachable -------------
+// Second fundamental failure. `syncStatus.unreachable` rides on the
+// config bundle the shell already preloads. When set, every dashboard /
+// overview / topology route returns nothing (no bundled fallback at
+// render time), so the operator must see the surface is BLOCKED. The
+// query-port strip wins when both are down — they share one OAP, so a
+// full outage shows the more fundamental message only.
+const { bundle } = useConfigBundle();
+const templateStoreDown = computed<boolean>(
+  () => !unreachable.value && bundle.value?.syncStatus?.unreachable === true,
+);
+const lastSyncLabel = computed<string>(() => {
+  const at = bundle.value?.syncStatus?.lastSuccessfulSyncAt;
+  if (!at) return t('never');
+  const sec = Math.max(1, Math.floor((nowMs.value - at) / 1000));
+  if (sec < 60) return `${sec}s ago`;
+  const min = Math.floor(sec / 60);
+  if (min < 60) return `${min}m ago`;
+  return `${Math.floor(min / 60)}h ${min % 60}m ago`;
+});
+const retryingTemplates = ref(false);
+async function retryTemplates(): Promise<void> {
+  if (retryingTemplates.value) return;
+  retryingTemplates.value = true;
+  try {
+    await refreshConfigBundle({ force: true });
+  } finally {
+    retryingTemplates.value = false;
+  }
+}
 </script>
 
 <template>
@@ -168,6 +207,24 @@ const queryUrl = computed<string | undefined>(() => info.value?.queryUrl);
     <button type="button" class="now" @click="() => refetch()">{{ t('retry now') }}</button>
     <RouterLink v-if="!onClusterPage" to="/operate/cluster" class="link">{{ t('View cluster status →') }}</RouterLink>
   </div>
+
+  <div v-else-if="templateStoreDown && !isPublicRoute" class="banner" role="alert">
+    <span class="icon">
+      <Icon name="alert" :size="14" />
+    </span>
+    <div class="msg">
+      <strong>{{ t('Dashboard template store unreachable') }}</strong>
+      <span class="detail">
+        {{ t('Layer dashboards, overviews and topology are blocked until OAP’s UI-template store is reachable.') }}
+        · {{ t('Last sync') }} <span class="when">{{ lastSyncLabel }}</span>
+      </span>
+    </div>
+
+    <button type="button" class="now" :disabled="retryingTemplates" @click="retryTemplates">
+      {{ t('retry now') }}
+    </button>
+    <RouterLink v-if="!onClusterPage" to="/operate/cluster" class="link">{{ t('View cluster status →') }}</RouterLink>
+  </div>
 </template>
 
 <style scoped>
diff --git a/apps/ui/src/shell/layerFromTemplate.ts b/apps/ui/src/shell/layerFromTemplate.ts
index f082ddf..ca11273 100644
--- a/apps/ui/src/shell/layerFromTemplate.ts
+++ b/apps/ui/src/shell/layerFromTemplate.ts
@@ -39,16 +39,28 @@ export interface LayerTemplateContent {
   overview?: LayerDef['overview'];
   naming?: LayerDef['naming'];
   traces?: LayerDef['traces'];
+  /** Only the `instanceTopology` presence is read here, to gate the
+   *  Instance-map drill-down cap — same rule the menu's `deriveLayer` uses. */
+  topology?: { instanceTopology?: unknown };
 }
 
-/** `components.*` → `caps.*` (the tab-visibility flags the sidebar reads). */
-export function componentsToCaps(components: Record<string, boolean | undefined> | undefined): LayerCaps {
+/** `components.*` → `caps.*` (the tab-visibility flags the sidebar reads).
+ *  `instanceTopology` is NOT a component flag: like the menu, it's gated on
+ *  the parent Topology component (`serviceMap`) AND the presence of a
+ *  `topology.instanceTopology` block — so a draft that enables it opens the
+ *  Instance map in preview, and one that drops it hides it. */
+export function componentsToCaps(
+  components: Record<string, boolean | undefined> | undefined,
+  topology?: { instanceTopology?: unknown },
+): LayerCaps {
   const c = components ?? {};
+  const serviceMap = !!c.topology;
   return {
     dashboards: !!c.service,
     instances: !!c.instances,
     endpoints: !!c.endpoints,
-    serviceMap: !!c.topology,
+    serviceMap,
+    instanceTopology: serviceMap && !!topology?.instanceTopology,
     endpointDependency: !!c.endpointDependency,
     traces: !!c.traces,
     logs: !!c.logs,
@@ -74,7 +86,7 @@ export function layerContentToDef(t: LayerTemplateContent): LayerDef {
     normal: null,
     documentLink: t.documentLink,
     slots: t.slots ?? {},
-    caps: componentsToCaps(t.components),
+    caps: componentsToCaps(t.components, t.topology),
     header: t.metrics,
     metrics: t.metrics,
     overview: t.overview,
@@ -90,6 +102,6 @@ export function overlayLayerDef(base: LayerDef, t: LayerTemplateContent): LayerD
   return {
     ...base,
     slots: { ...base.slots, ...(t.slots ?? {}) },
-    caps: componentsToCaps(t.components),
+    caps: componentsToCaps(t.components, t.topology),
   };
 }
diff --git a/apps/ui/src/shell/router/index.ts b/apps/ui/src/shell/router/index.ts
index ae0acc3..2689908 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -45,7 +45,9 @@ function layerRoute(): RouteRecordRaw {
       { path: 'endpoint', component: () => import('@/render/layer-dashboard/LayerDashboardsView.vue') },
       {
         path: 'topology',
-        component: () => import('@/layer/service-map/LayerServiceMapView.vue'),
+        // Topology tab shell: Service map, plus an Instance map sub-tab
+        // when the layer enables instance topology (?view=service|instance).
+        component: () => import('@/layer/service-map/LayerTopologyTab.vue'),
         // The topology page ships its own in-box service-focus selector
         // (the map is layer-wide by default). Declaring it here keeps
         // the LayerShell's header picker hidden for this route — no
diff --git a/docs/customization/layer-templates.md b/docs/customization/layer-templates.md
index 1858413..5edd4b1 100644
--- a/docs/customization/layer-templates.md
+++ b/docs/customization/layer-templates.md
@@ -67,16 +67,16 @@ Layer-specific term overrides used in UI labels.
 
 ```json
 "slots": {
-  "service":      "service",
-  "services":     "services",
-  "instance":     "instance",
-  "instances":    "instances",
-  "endpoint":     "endpoint",
-  "endpoints":    "endpoints"
+  "services":         "services",
+  "instances":        "instances",
+  "endpoints":        "endpoints",
+  "endpointDependency": "API dependency",
+  "topology":         "Topology",
+  "instanceTopology": "Instance map"
 }
 ```
 
-A Kubernetes layer might use `pod` / `pods` instead of `instance` / `instances`. The page titles and pickers pick up the override automatically.
+A Kubernetes layer might use `Pods` instead of `Instances`. The page titles, sidebar tabs, and pickers pick up the override automatically. `topology` renames the **Topology** sidebar tab; `instanceTopology` renames the **Instance map** drill-down. Edit these in the admin under **Menu labels** (the alias fields render in sidebar/menu order, showing only the entries the layer's enabled components expose).
 
 ## `components`
 
@@ -267,26 +267,68 @@ A `line` widget with a scalar-collapsed MQE renders a one-point chart and confus
 
 ## `topology`
 
-Per-layer override for the service-map view's MQE.
+Config for the **Topology** map (the service-map view): which MQE metrics decorate each service node and each service-to-service call edge — and, optionally, the **instance map** drill-down. Edited in the admin under the layer's **Topology** scope (node-metric / server-edge / client-edge editors). Without a block, a sensible default metric set is used.
 
 ```json
 "topology": {
-  "metric": "service_resp_time"
+  "nodeMetrics": [
+    { "id": "cpm",      "label": "RPM",     "mqe": "service_cpm",       "unit": "rpm", "role": "center",    "aggregation": "avg" },
+    { "id": "sla",      "label": "SLA",     "mqe": "service_sla/100",   "unit": "%",   "role": "ring",      "aggregation": "avg",
+      "thresholds": { "invertHealth": true, "ok": 0.1, "warn": 1, "danger": 5 } },
+    { "id": "respTime", "label": "Latency", "mqe": "service_resp_time", "unit": "ms",  "role": "secondary", "aggregation": "avg" }
+  ],
+  "linkServerMetrics": [
+    { "id": "cpm", "label": "RPM", "mqe": "service_relation_server_cpm", "unit": "rpm", "role": "lineServer", "aggregation": "avg" }
+  ],
+  "linkClientMetrics": [
+    { "id": "cpm", "label": "RPM", "mqe": "service_relation_client_cpm", "unit": "rpm", "role": "lineClient", "aggregation": "avg" }
+  ],
+  "instanceTopology": { "nodeMetrics": [ ... ], "linkServerMetrics": [ ... ], "linkClientMetrics": [ ... ] }
 }
 ```
 
-Without an override, topology uses a default metric appropriate to the layer.
+| Field | Notes |
+|---|---|
+| `nodeMetrics[]` | Per-service-node metrics. `role`: `center` (the number inside the node), `ring` (the health colour band on the node), `secondary` (surfaced in the node detail). |
+| `linkServerMetrics[]` / `linkClientMetrics[]` | Per-call-edge metrics — server side (`service_relation_server_*`) and client side (`service_relation_client_*`). Ids that match across the two render aligned in the edge detail panel. |
+| `*.id` / `*.label` / `*.mqe` / `*.unit` | Stable id, display name, MQE expression, optional unit. Everything on screen — names, values, legend — comes from these, nothing is hardcoded. |
+| `*.role` | Visual binding (above). Edge metrics use `lineServer` / `lineClient`. |
+| `*.aggregation` | `sum` or `avg` across the window. |
+| `*.thresholds` | Four-band colour for a `ring` metric: `ok` / `warn` / `danger` boundaries, plus `invertHealth: true` for higher-is-better metrics (SLA, apdex, success rate) and an optional `invertBase` (default 100). |
+| `instanceTopology` | **Optional.** Enables the instance map (see below). Same `nodeMetrics` / `linkServerMetrics` / `linkClientMetrics` shape, but the MQE is evaluated at **instance** scope (`service_instance_*` and `service_instance_relation_server/client_*`). Absent ⇒ the layer offers no instance map. |
+
+### Instance map
+
+When `topology.instanceTopology` is set, the Topology map gains an **instance-to-instance** drill-down. On the service map, select a call between two services and click **Instance map →**: it opens the instances of each service as two columns (left = client, right = server) with the instance-level calls between them — the same node health-ring (with a colour legend reading the ring metric's thresholds), per-service grouping boxes, per-call client/server metric panel, and pan/zoom as the  [...]
+
+Enable and configure it in the admin: open the layer's **Topology** scope and turn on **Enable instance topology**, which reveals its own node / server-edge / client-edge metric editors (kept separate from the service-topology metrics). Horizon ships it pre-enabled for **GENERAL**, **MESH**, **K8S_SERVICE**, and **CILIUM_SERVICE**; it rides the topology block, so it travels with template export/import.
 
 ## `endpointDependency`
 
-Per-layer override for the API-dependency dashboard.
+Config for the **API dependency** view — the endpoint-to-endpoint dependency map: which MQE metrics decorate each endpoint node and each endpoint-to-endpoint call edge. Same metric-def shape as [`topology`](#topology), but the MQE is evaluated at **endpoint** scope (`endpoint_*`) for nodes and **endpoint-relation** scope (`endpoint_relation_*`) for edges. Without a block, a sensible default metric set is used.
 
 ```json
 "endpointDependency": {
-  "metric": "endpoint_avg"
+  "nodeMetrics": [
+    { "id": "cpm",      "label": "RPM",     "mqe": "endpoint_cpm",       "unit": "rpm", "role": "center",    "aggregation": "avg" },
+    { "id": "sla",      "label": "SLA",     "mqe": "endpoint_sla/100",   "unit": "%",   "role": "ring",      "aggregation": "avg" },
+    { "id": "respTime", "label": "Latency", "mqe": "endpoint_resp_time", "unit": "ms",  "role": "secondary", "aggregation": "avg" }
+  ],
+  "linkMetrics": [
+    { "id": "cpm",      "label": "RPM",               "mqe": "endpoint_relation_cpm",        "unit": "rpm", "role": "lineServer", "aggregation": "avg" },
+    { "id": "respTime", "label": "Avg response time", "mqe": "endpoint_relation_resp_time",  "unit": "ms",  "aggregation": "avg" }
+  ]
 }
 ```
 
+| Field | Notes |
+|---|---|
+| `nodeMetrics[]` | Per-endpoint-node metrics. Same `id` / `label` / `mqe` / `unit` / `role` / `aggregation` / `thresholds` fields as the topology node metrics. |
+| `linkMetrics[]` | Per-call-edge metrics. **Server-side only** — OAP exposes no `endpoint_relation_client_*` family, so (unlike the service map) there's a single edge metric list; use `role: lineServer`. |
+| `showGroup` | Group endpoints by their naming rule in the node panel, same semantics as the topology `showGroup`. |
+
+Edited in the admin under the layer's **API dependency** scope.
+
 ## `traces`
 
 ```json
@@ -339,6 +381,20 @@ Your work-in-progress lives **in your browser**, never on the server until you p
 
 A top banner summarizes page state — *Synced from OAP — N diverged, Y local* — and **Diverged** / **Local** filters narrow the picker. Each row shows a status chip: **synced** (bundled == OAP), **diverged** (OAP differs from bundled — OAP wins at render), **remote-only** (on OAP, no bundled default), **disabled** (deleted — see below), or **bundled** (OAP has no copy right now).
 
+### Bundled defaults vs. your OAP-published templates
+
+Each layer template has two copies: the **bundled** default shipped with Horizon, and the **remote** copy stored on OAP (what end users actually render — OAP wins at render time). On boot, Horizon seeds OAP **only with templates that are absent there** — a brand-new layer with no remote copy yet is pushed automatically so it works out of the box.
+
+It does **not** overwrite a template that already exists on OAP. So when you upgrade Horizon and a bundled default changes — a new metric, a new capability such as the instance map, a tweaked widget — layers you've already published show as **diverged**: OAP keeps winning at render and your published edits are preserved. The new bundled default is *offered*, not forced.
+
+To adopt a new bundled default on an existing layer, publish it from the admin:
+
+- the **Diverged** filter narrows the picker to the affected layers;
+- **Reset to ▾ → Bundled** loads the shipped default into the editor, then **Check diff & push** publishes it to OAP; or
+- review the *remote → bundled* diff first and keep any of your own changes before pushing.
+
+This is why a freshly shipped capability can read **diverged / off** until you push it: the new config is bundled, but your OAP copy stays the source of truth and only changes when you publish. (New layers absent from OAP are the one case that goes live automatically, via the boot-time seed above.)
+
 ### Import / Export
 
 **Export** downloads the layer's **in-use version** — what end users render now (the OAP-live copy, or the bundled default when OAP has none) — as a JSON file, for backup, sharing, or moving the dashboard to another OAP.
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 6b06961..74ad536 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -54,6 +54,7 @@ export type {
 export type {
   TopologyMetricDef,
   TopologyConfig,
+  InstanceTopologyConfig,
   EndpointDependencyConfig,
   ProcessTopologyConfig,
   ProcessRelationMetric,
@@ -62,6 +63,9 @@ export type {
   TopologyNode,
   TopologyCall,
   TopologyResponse,
+  InstanceTopologyNode,
+  InstanceTopologyCall,
+  InstanceTopologyResponse,
   EndpointDependencyNode,
   EndpointDependencyCall,
   EndpointDependencyResponse,
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index fd59f65..c55f161 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -36,6 +36,10 @@ export interface LayerSlots {
   endpoints?: string;
   /** Label for the endpoint-to-endpoint dependency feature. Defaults to `${endpoints} dependency`. */
   endpointDependency?: string;
+  /** Label for the service-topology tab. Defaults to "Topology". */
+  topology?: string;
+  /** Label for the instance-topology sub-tab. Defaults to "Instance map". */
+  instanceTopology?: string;
 }
 
 export interface LayerCaps {
diff --git a/packages/api-client/src/topology.ts b/packages/api-client/src/topology.ts
index 4cfd970..4cbdcbf 100644
--- a/packages/api-client/src/topology.ts
+++ b/packages/api-client/src/topology.ts
@@ -103,6 +103,29 @@ export interface TopologyConfig {
    *  When falsy (default), the prefix is dropped from the UI
    *  entirely — base name everywhere. */
   showGroup?: boolean;
+  /** Optional instance-to-instance topology drill-down config. Present
+   *  only on layers that support it (the BFF seeds general / mesh /
+   *  k8s_service / cilium_service). Same metric shape as the service
+   *  map but evaluated under the ServiceInstance / ServiceInstanceRelation
+   *  scope. Echoed in the topology response's `config`, so the service
+   *  map can decide whether to offer the edge drill-down without a
+   *  second round-trip. Absent ⇒ the map offers no instance drill-down. */
+  instanceTopology?: InstanceTopologyConfig;
+}
+
+/** Operator-editable instance-topology config. Same node + per-side edge
+ *  metric shape as {@link TopologyConfig}, but the node MQE evaluates
+ *  under `{ scope: ServiceInstance }` and the edge MQE under
+ *  ServiceInstanceRelation (server / client families). */
+export interface InstanceTopologyConfig {
+  /** Per-instance MQE under `{ scope: ServiceInstance }`. */
+  nodeMetrics: TopologyMetricDef[];
+  /** Per-edge MQE under ServiceInstanceRelation, server side. */
+  linkServerMetrics?: TopologyMetricDef[];
+  /** Per-edge MQE under ServiceInstanceRelation, client side. Some
+   *  layers (e.g. cilium_service) only expose a server family — then
+   *  this is absent and edges render server-only. */
+  linkClientMetrics?: TopologyMetricDef[];
 }
 
 /** Operator-editable process-topology (network-profiling) dashboard
@@ -216,6 +239,50 @@ export interface TopologyResponse {
   error?: string;
 }
 
+/** One instance node in the instance-topology drill-down. `id` is OAP's
+ *  `<serviceId>_<base64-instance>` composite; `serviceId` splits it back
+ *  to the owning service so the UI can group nodes into the two
+ *  client / server columns. */
+export interface InstanceTopologyNode {
+  id: string;
+  /** Instance name (e.g. `frontend-deployment-6ff5cbbdd5-8rxlg`). */
+  name: string;
+  serviceId: string;
+  serviceName: string;
+  isReal: boolean;
+  /** Keyed by `InstanceTopologyConfig.nodeMetrics[].id`. */
+  metrics: Record<string, number | null>;
+}
+
+/** One instance-to-instance call. Same per-side metric shape as
+ *  {@link TopologyCall}. */
+export interface InstanceTopologyCall {
+  id: string;
+  source: string;
+  target: string;
+  detectPoints: string[];
+  serverMetrics: Record<string, number | null>;
+  clientMetrics: Record<string, number | null>;
+  serverMetricSeries: Record<string, Array<number | null> | null>;
+  clientMetricSeries: Record<string, Array<number | null> | null>;
+}
+
+/** Response of `GET /api/layer/:key/instance-topology`. The graph is the
+ *  instance topology between exactly two services (client + server). */
+export interface InstanceTopologyResponse {
+  layer: string;
+  clientServiceId: string;
+  serverServiceId: string;
+  clientServiceName: string | null;
+  serverServiceName: string | null;
+  generatedAt: number;
+  config: InstanceTopologyConfig;
+  nodes: InstanceTopologyNode[];
+  calls: InstanceTopologyCall[];
+  reachable: boolean;
+  error?: string;
+}
+
 export interface EndpointDependencyNode {
   id: string;
   name: string;