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

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

commit fd0aa980d5bea3d993ff4551bb0462979c71d407
Author: Wu Sheng <[email protected]>
AuthorDate: Mon May 25 11:53:28 2026 +0800

    feat(service-map): Smartscape cross-layer hierarchy overlay
    
    Adds OAP 10 service-hierarchy as a focus+context+suggestions overlay
    on the per-layer service map. Lazy-probed chip on the selected hex
    opens a dimmed canvas where the focused hex re-renders at its exact
    topology position + scale, peers fan vertically by listLayerLevels
    order (booster-ui rule), and the auto-refresh ticker pauses for the
    duration so the background doesn't shift. Two-step peer open: click
    hex to arm, click the side action chip to open the destination layer
    in a new browser tab with the peer pre-selected.
    
    The destination tab now validates URL-pinned services against the
    layer's real roster via a new GET /api/layer/:key/services endpoint
    (served from the BFF's existing 60s catalog cache — no extra OAP
    traffic). A genuinely missing id pops a 'Service not found in this
    layer' modal; a valid id is trusted even when landing's top-N misses
    it. Fixes the silent service-swap that previously hit low-traffic
    deep links and left the page stuck on 'Resolving service…'.
---
 CHANGELOG.md                                       |  43 ++
 apps/bff/src/http/query/services.ts                |  82 +++
 apps/bff/src/http/query/topology.ts                |  32 +
 apps/bff/src/logic/oap/hierarchy.ts                | 260 +++++++
 apps/bff/src/rbac/route-policy.ts                  |   2 +
 apps/bff/src/server.ts                             |   2 +
 apps/ui/src/api/scopes/layer.ts                    |  33 +
 apps/ui/src/layer/LayerShell.vue                   |  98 +++
 .../src/layer/service-map/LayerServiceMapView.vue  | 104 +++
 .../layer/service-map/ServiceHierarchyOverlay.vue  | 804 +++++++++++++++++++++
 apps/ui/src/layer/service-map/hierarchyStore.ts    | 108 +++
 .../src/layer/service-map/useServiceHierarchy.ts   |  60 ++
 apps/ui/src/layer/useLayerServices.ts              |  53 ++
 .../render/layer-dashboard/LayerDashboardsView.vue |  34 +-
 packages/api-client/src/index.ts                   |   7 +
 packages/api-client/src/service-hierarchy.ts       |  96 +++
 16 files changed, 1804 insertions(+), 14 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ae2648..82ab1d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,49 @@ packages) plus the BFF's `HORIZON_VERSION` default.
 
 ## 0.6.0
 
+### Smartscape service hierarchy
+
+OAP 10's cross-layer service hierarchy is now reachable from any layer's
+service map — a logical service projected across observation layers
+(GENERAL agent ↔ MESH sidecar ↔ MESH_DP data-plane ↔ K8S_SERVICE pod) is
+one click away on every selected hex.
+
+- **Lazy-probed chip on the selected hex.** Picking a node fires one
+  `getServiceHierarchy` call; if the service has cross-layer peers, a
+  small chevron-stack chip clips to the hex's right edge. No probe, no
+  chip on services with no peers.
+- **Focus + context + suggestions overlay.** Click the chip and the
+  topology dims under a transparent canvas; the focused hex re-renders
+  bright at the exact same screen position and scale as the underlying
+  hex (the topology's d3 zoom transform is mirrored onto the overlay).
+  Peers fan vertically from the focus column using OAP's
+  `listLayerLevels` order — higher-level (request-near) layers above,
+  lower-level (infra-near) layers below, matching booster-ui's
+  hierarchy rendering rule.
+- **Auto-refresh pauses while the overlay is open** so the background
+  topology and KPI panels don't shift under the operator. Closing the
+  overlay (`×` button, ESC, or click-on-dim) resumes the ticker and
+  fires one immediate tick so the page snaps back to live data.
+- **Two-step peer open.** First click on a peer hex arms it (selection
+  halo + side `↗ Open in <Layer>` action chip); second click on the
+  chip opens the destination layer in a new browser tab, pre-selecting
+  the peer service. Peers in layers Horizon has no template for render
+  dimmed with a `cursor: not-allowed`; clicking them logs *"No layer
+  template configured for &lt;Layer&gt;"* to the event log instead.
+- **URL-pinned service validator on the destination tab.** Every
+  per-layer page now validates the URL-hydrated `?service=<id>`
+  against the layer's real service roster (the new
+  `GET /api/layer/:key/services`, served from the BFF's 60s catalog
+  cache so it adds no extra OAP traffic). A genuinely missing id pops
+  a `Service not found in this layer` modal with a one-click fallback
+  to the first available service; a valid id is trusted even when
+  landing's top-N rollup doesn't sample it (the cause of the previous
+  silent service-swap on low-traffic deep links).
+- **Service-name resolution** on the layer dashboard now consults the
+  roster after landing's top-N, so deep links to low-traffic services
+  no longer sit on *"Resolving service…"* forever waiting for a row
+  that won't arrive.
+
 ### BanyanDB cold-stage query
 
 The cold lifecycle stage is now reachable from the UI on BanyanDB
diff --git a/apps/bff/src/http/query/services.ts 
b/apps/bff/src/http/query/services.ts
new file mode 100644
index 0000000..43e0f63
--- /dev/null
+++ b/apps/bff/src/http/query/services.ts
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * `GET /api/layer/:key/services` — full service roster for one layer.
+ *
+ * Reads from the shared {@link serviceLayerCatalog} (the BFF's
+ * single-flight, 60s-TTL fan-out over `listLayers` + `listServices`),
+ * so this route adds no extra OAP traffic — every caller (sidebar
+ * counts, alarms tagger, layer-shell URL validator, …) sees the same
+ * snapshot.
+ *
+ * Used by the layer shell to validate a URL-pinned `?service=<id>`
+ * against the layer's real service set (independent of landing's
+ * top-N rollup, which can miss low-traffic services). When a deep
+ * link refers to a service that's truly absent the shell pops a
+ * "service not found" notice; when present, the operator's pick is
+ * trusted regardless of whether it shows up in landing's top-N.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type { FetchLike } 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 { serviceLayerCatalog } from 
'../../logic/services/service-layer-catalog.js';
+
+export interface LayerServicesRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  fetch?: FetchLike;
+}
+
+export function registerLayerServicesRoute(
+  app: FastifyInstance,
+  deps: LayerServicesRouteDeps,
+): void {
+  const auth = requireAuth(deps);
+  const catalog = serviceLayerCatalog({ config: deps.config, fetch: deps.fetch 
});
+  app.get(
+    '/api/layer/:key/services',
+    { 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 layerUpper = layerKey.toUpperCase();
+      try {
+        const snap = await catalog.get();
+        const rows = snap.byLayer.get(layerUpper) ?? [];
+        return reply.send({
+          reachable: true,
+          layer: layerUpper,
+          services: rows.map((r) => ({ id: r.id, name: r.name, normal: 
r.normal })),
+        });
+      } catch (err) {
+        return reply.send({
+          reachable: false,
+          layer: layerUpper,
+          services: [],
+          error: err instanceof Error ? err.message : String(err),
+        });
+      }
+    },
+  );
+}
diff --git a/apps/bff/src/http/query/topology.ts 
b/apps/bff/src/http/query/topology.ts
index d42a31d..703efcc 100644
--- a/apps/bff/src/http/query/topology.ts
+++ b/apps/bff/src/http/query/topology.ts
@@ -49,6 +49,7 @@ import {  graphqlPost, buildOapOpts } from 
'../../client/graphql.js';
 import { withColdStage } from '../../util/duration.js';
 import { defaultMinuteWindow, windowFromRange, type TimeStep, type Window } 
from '../../util/window.js';
 import { getLayerTemplate, topologyConfigFor } from 
'../../logic/layers/loader.js';
+import { getServiceHierarchy } from '../../logic/oap/hierarchy.js';
 
 export interface TopologyRouteDeps {
   config: ConfigSource;
@@ -595,4 +596,35 @@ export function registerTopologyRoute(app: 
FastifyInstance, deps: TopologyRouteD
       } satisfies TopologyResponse);
     },
   );
+
+  // ── Service hierarchy probe — Smartscape overlay on the service map.
+  //
+  // The UI calls this lazily on node-select (one round-trip per selected
+  // node) to decide whether to render the "expand hierarchy" chip and to
+  // populate the focus+context+suggestions overlay when the operator
+  // opens it. Not used by the overview topology widget (intentionally
+  // non-interactive there).
+  app.get(
+    '/api/layer/:key/service-hierarchy',
+    { 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 { service?: string };
+      const serviceId = (q.service ?? '').trim();
+      if (!serviceId) {
+        return reply.code(400).send({ error: 'missing_service' });
+      }
+      const result = await getServiceHierarchy(
+        deps.config.current,
+        serviceId,
+        layerKey,
+        deps.fetch,
+      );
+      return reply.send(result);
+    },
+  );
 }
diff --git a/apps/bff/src/logic/oap/hierarchy.ts 
b/apps/bff/src/logic/oap/hierarchy.ts
new file mode 100644
index 0000000..e33530b
--- /dev/null
+++ b/apps/bff/src/logic/oap/hierarchy.ts
@@ -0,0 +1,260 @@
+/*
+ * 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.
+ */
+
+/**
+ * Service-hierarchy fetcher — wraps OAP's `getServiceHierarchy` +
+ * `listLayerLevels`, flattens the upper/lower relations into a per-layer
+ * peer list, and caches the level table per OAP queryUrl for the BFF
+ * process lifetime (the level mapping is immutable per deployment).
+ *
+ * Powers the per-layer service map's Smartscape overlay. NOT used by
+ * the overview dashboard's topology widget — that view is intentionally
+ * non-interactive and would fight the focus+context geometry.
+ */
+
+import type {
+  FetchLike,
+  HierarchyLayerGroup,
+  HierarchyPeer,
+  LayerLevel,
+  ServiceHierarchyResponse,
+} from '@skywalking-horizon-ui/api-client';
+import type { HorizonConfig } from '../../config/schema.js';
+import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
+
+const HIERARCHY_QUERY = /* GraphQL */ `
+  query HorizonServiceHierarchy($serviceId: ID!, $layer: String!) {
+    hierarchy: getServiceHierarchy(serviceId: $serviceId, layer: $layer) {
+      relations {
+        upperService { id name layer normal }
+        lowerService { id name layer normal }
+      }
+    }
+  }
+`;
+
+const LEVELS_QUERY = /* GraphQL */ `
+  query HorizonLayerLevels {
+    levels: listLayerLevels { layer level }
+  }
+`;
+
+interface RawRelService {
+  id: string;
+  name: string;
+  layer: string;
+  normal: boolean;
+}
+interface RawHierarchyResp {
+  hierarchy: {
+    relations: Array<{
+      upperService: RawRelService;
+      lowerService: RawRelService;
+    }>;
+  };
+}
+interface RawLevelsResp {
+  levels: LayerLevel[];
+}
+
+// Per-queryUrl process-lifetime cache. The level table doesn't change
+// at OAP runtime; a new deployment recycles the BFF anyway. We still
+// short-cache failures (60s) so a transient OAP outage recovers
+// without forever returning stale-or-empty.
+interface LevelEntry {
+  levels: LayerLevel[] | null;
+  fetchedAt: number;
+  ok: boolean;
+}
+const levelsCache = new Map<string, LevelEntry>();
+const LEVELS_FAIL_MS = 60_000;
+
+/** Test-only — clear the level cache. */
+export function _resetLevelsCache(): void {
+  levelsCache.clear();
+}
+
+async function getLayerLevels(
+  config: HorizonConfig,
+  fetchImpl?: FetchLike,
+): Promise<LayerLevel[]> {
+  const key = config.oap.queryUrl;
+  const hit = levelsCache.get(key);
+  if (hit && hit.ok) return hit.levels ?? [];
+  if (hit && !hit.ok && Date.now() - hit.fetchedAt < LEVELS_FAIL_MS) {
+    return hit.levels ?? [];
+  }
+  try {
+    const data = await graphqlPost<RawLevelsResp>(
+      buildOapOpts(config, fetchImpl),
+      LEVELS_QUERY,
+    );
+    const levels = (data.levels ?? []).slice().sort((a, b) => a.level - 
b.level);
+    levelsCache.set(key, { levels, fetchedAt: Date.now(), ok: true });
+    return levels;
+  } catch {
+    // Cache the failure briefly; downstream callers still get a usable
+    // response (UI groups by layer name, just without canonical order).
+    levelsCache.set(key, { levels: null, fetchedAt: Date.now(), ok: false });
+    return [];
+  }
+}
+
+/** Group peers by layer in `levels` order, with the focused service
+ *  always appearing under its own layer as `role: 'self'`. */
+function groupPeers(
+  serviceId: string,
+  focusLayer: string,
+  raw: RawHierarchyResp['hierarchy']['relations'],
+  levels: LayerLevel[],
+): { groups: HierarchyLayerGroup[]; relationCount: number } {
+  const byLayer = new Map<string, Map<string, HierarchyPeer>>();
+  const ensure = (layer: string): Map<string, HierarchyPeer> => {
+    let m = byLayer.get(layer);
+    if (!m) {
+      m = new Map();
+      byLayer.set(layer, m);
+    }
+    return m;
+  };
+
+  // Self always appears, even if OAP returned no relations — we want the
+  // focused service on its own layer ribbon as the anchor for connectors.
+  // OAP doesn't echo `name` on the hierarchy response for the self id; the
+  // UI already has the name from the topology query, so we just store the
+  // id and let the UI substitute. Marking name === id is a safe fallback
+  // when the UI hasn't pre-loaded it (e.g. deep-linked overlay).
+  let selfName: string | null = null;
+  let selfNormal = true;
+
+  for (const r of raw) {
+    const u = r.upperService;
+    const l = r.lowerService;
+    if (u.id === serviceId) {
+      selfName = u.name;
+      selfNormal = u.normal;
+      // Counterparty is the lower service.
+      ensure(l.layer).set(l.id, { id: l.id, name: l.name, normal: l.normal, 
role: 'lower' });
+    } else if (l.id === serviceId) {
+      selfName = l.name;
+      selfNormal = l.normal;
+      // Counterparty is the upper service.
+      ensure(u.layer).set(u.id, { id: u.id, name: u.name, normal: u.normal, 
role: 'upper' });
+    } else {
+      // Neither side is the focused service — OAP shouldn't return
+      // these but cope defensively by tagging both ends as siblings.
+      // Sibling peers (both upper/lower link to OTHER services) are
+      // a hierarchy-graph oddity; surfacing them as `lower` is the
+      // least-misleading choice (still cross-layer, still navigable).
+      ensure(u.layer).set(u.id, { id: u.id, name: u.name, normal: u.normal, 
role: 'lower' });
+      ensure(l.layer).set(l.id, { id: l.id, name: l.name, normal: l.normal, 
role: 'lower' });
+    }
+  }
+
+  // Inject self under its own layer.
+  ensure(focusLayer).set(serviceId, {
+    id: serviceId,
+    name: selfName ?? serviceId,
+    normal: selfNormal,
+    role: 'self',
+  });
+
+  // Order layers by `level`; layers OAP didn't return a level for fall
+  // to the end (alphabetical for stability).
+  const levelOf = new Map(levels.map((L) => [L.layer, L.level] as const));
+  const orderedLayers = Array.from(byLayer.keys()).sort((a, b) => {
+    const la = levelOf.get(a);
+    const lb = levelOf.get(b);
+    if (la !== undefined && lb !== undefined) return la - lb;
+    if (la !== undefined) return -1;
+    if (lb !== undefined) return 1;
+    return a.localeCompare(b);
+  });
+
+  // Within a layer: self first, then upper-role peers, then lower-role
+  // peers, each group alphabetical for stable rendering.
+  const roleOrder: Record<HierarchyPeer['role'], number> = { self: 0, upper: 
1, lower: 2 };
+  const groups: HierarchyLayerGroup[] = orderedLayers.map((layer) => {
+    const services = Array.from(byLayer.get(layer)!.values()).sort((a, b) => {
+      const r = roleOrder[a.role] - roleOrder[b.role];
+      return r !== 0 ? r : a.name.localeCompare(b.name);
+    });
+    return { layer, services };
+  });
+
+  // Final peer count = every non-self service across all layer groups.
+  // This is the number the UI uses to decide "show expand chip?" — it
+  // mirrors what the operator will actually see in the overlay (direct
+  // peers + transitively-discovered cross-layer projections of those
+  // peers), not the raw upper/lower relation count from OAP.
+  const peerCount = groups.reduce(
+    (sum, g) => sum + g.services.filter((s) => s.role !== 'self').length,
+    0,
+  );
+  return { groups, relationCount: peerCount };
+}
+
+/**
+ * Fetch the service hierarchy and shape it for the UI. Never throws —
+ * an unreachable OAP yields `reachable: false` so the overlay can
+ * degrade gracefully.
+ */
+export async function getServiceHierarchy(
+  config: HorizonConfig,
+  serviceId: string,
+  layer: string,
+  fetchImpl?: FetchLike,
+): Promise<ServiceHierarchyResponse> {
+  const opts = buildOapOpts(config, fetchImpl);
+  const layerUpper = layer.toUpperCase();
+  let levels: LayerLevel[] = [];
+  try {
+    levels = await getLayerLevels(config, fetchImpl);
+  } catch {
+    // Non-fatal — proceed with empty level table, peers still group.
+  }
+  try {
+    const data = await graphqlPost<RawHierarchyResp>(opts, HIERARCHY_QUERY, {
+      serviceId,
+      layer: layerUpper,
+    });
+    const { groups, relationCount } = groupPeers(
+      serviceId,
+      layerUpper,
+      data.hierarchy?.relations ?? [],
+      levels,
+    );
+    return {
+      reachable: true,
+      layer: layerUpper,
+      serviceId,
+      levels,
+      relations: relationCount,
+      peers: groups,
+    };
+  } catch (err) {
+    return {
+      reachable: false,
+      layer: layerUpper,
+      serviceId,
+      levels,
+      relations: 0,
+      peers: [],
+      error: err instanceof Error ? err.message : String(err),
+    };
+  }
+}
diff --git a/apps/bff/src/rbac/route-policy.ts 
b/apps/bff/src/rbac/route-policy.ts
index 0658daa..6d178b6 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -98,6 +98,7 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   // ── Topology (read) ──────────────────────────────────────────────
   'GET /api/layer/:key/topology':                  'topology:read',
   'GET /api/layer/:key/endpoint-dependency':       'topology:read',
+  'GET /api/layer/:key/service-hierarchy':         'topology:read',
 
   // ── Metrics & layer-level reads ──────────────────────────────────
   'POST /api/layer/:key/dashboard':                'metrics:read',
@@ -105,6 +106,7 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'POST /api/layer/:key/landing':                  'metrics:read',
   'GET /api/layer/:key/instances':                 'metrics:read',
   'GET /api/layer/:key/endpoints':                 'metrics:read',
+  'GET /api/layer/:key/services':                  'metrics:read',
 
   // ── Profiling — agent / async / pprof / eBPF / eBPF network ──────
   // GETs + analyze are reads; POST <family>/tasks creates a task.
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 9198917..633d3c5 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 { registerLayerServicesRoute } from './http/query/services.js';
 import { registerEndpointDependencyRoute } from 
'./http/query/endpoint-dependency.js';
 import { registerTraceRoutes } from './http/query/trace.js';
 import { registerTraceTagRoutes } from './http/query/trace-tag.js';
@@ -165,6 +166,7 @@ registerLandingRoute(app, { config: source, sessions });
 registerInstanceRoute(app, { config: source, sessions });
 registerEndpointRoute(app, { config: source, sessions });
 registerTopologyRoute(app, { config: source, sessions });
+registerLayerServicesRoute(app, { config: source, sessions });
 registerEndpointDependencyRoute(app, { config: source, sessions });
 registerTraceRoutes(app, { config: source, sessions });
 registerTraceTagRoutes(app, { config: source, sessions });
diff --git a/apps/ui/src/api/scopes/layer.ts b/apps/ui/src/api/scopes/layer.ts
index 54734c5..22b5681 100644
--- a/apps/ui/src/api/scopes/layer.ts
+++ b/apps/ui/src/api/scopes/layer.ts
@@ -22,6 +22,7 @@ import type {
   EndpointDependencyResponse,
   LandingConfig,
   LandingResponse,
+  ServiceHierarchyResponse,
   TopologyResponse,
 } from '@skywalking-horizon-ui/api-client';
 import { pushEvent } from '@/controls/eventLog';
@@ -212,4 +213,36 @@ export class LayerApi {
       
`/api/layer/${encodeURIComponent(layerKey)}/endpoint-dependency?${qs.toString()}`,
     );
   }
+
+  /** Probe a service's cross-layer hierarchy peers. Called lazily by
+   *  the service-map view on node-select to decide whether to render
+   *  the Smartscape expand chip, then re-used to populate the focus +
+   *  context + suggestions overlay when the operator opens it. */
+  serviceHierarchy(
+    layerKey: string,
+    service: string,
+  ): Promise<ServiceHierarchyResponse> {
+    const qs = new URLSearchParams({ service });
+    return this.bff.request(
+      'GET',
+      
`/api/layer/${encodeURIComponent(layerKey)}/service-hierarchy?${qs.toString()}`,
+    );
+  }
+
+  /** Full service roster for a layer (id + name + normal-flag), read
+   *  from the BFF's cached `listServices` snapshot. The layer shell
+   *  uses this to validate a URL-pinned `?service=<id>` against the
+   *  layer's real catalog — independent of landing's top-N rollup
+   *  which can miss low-traffic services. */
+  services(layerKey: string): Promise<{
+    reachable: boolean;
+    layer: string;
+    services: Array<{ id: string; name: string; normal: boolean | null }>;
+    error?: string;
+  }> {
+    return this.bff.request(
+      'GET',
+      `/api/layer/${encodeURIComponent(layerKey)}/services`,
+    );
+  }
 }
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index d5bc582..1c5d7b7 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -42,7 +42,9 @@ import { useTimeRangeStore } from '@/controls/timeRange';
 import { useLayers, firstLayerTab } from '@/shell/useLayers';
 import { layerContentToDef, type LayerTemplateContent } from 
'@/shell/layerFromTemplate';
 import { useSelectedService } from '@/layer/useSelectedService';
+import { useLayerServices } from '@/layer/useLayerServices';
 import { useLayerSelectionStore } from '@/state/layerSelection';
+import Modal from '@/features/operate/_shared/Modal.vue';
 import { useSetupStore } from '@/state/setup';
 import { fmtMetric } from '@/utils/formatters';
 import { parseServiceName } from '@/utils/serviceName';
@@ -237,6 +239,59 @@ const aggregates = computed(() =>
 
 // Page-wide selected service — URL-backed, shared with every tab body.
 const { selectedId, setSelected } = useSelectedService();
+
+// ──────────────────────────────────────────────────────────────────
+// URL-pinned service validation
+// ──────────────────────────────────────────────────────────────────
+// Validate the URL-hydrated `?service=<id>` against the layer's
+// REAL service roster (independent of landing's top-N rollup, which
+// can miss low-traffic services and used to silently switch the
+// operator's pick to an unrelated service). Three outcomes:
+//
+//   1. URL service is in the roster → trust the pick; no notice.
+//   2. URL service is NOT in the roster AND the roster is non-empty
+//      → pop the "service not found" modal, offer to auto-pick the
+//      first service in the layer; operator can also cancel and pick
+//      manually.
+//   3. Roster failed to load (BFF / OAP outage) → silent fallback to
+//      first landing service via the existing dashboard-view watch;
+//      no modal (the OAP-unreachable banner is the real signal).
+const { services: layerServices, isFetching: servicesFetching } = 
useLayerServices(layerKey);
+/** True once we've validated the current selectedId against the
+ *  roster — gated so we don't re-pop the modal on every reactive
+ *  trigger of the same id. Reset when layer / id changes. */
+const validatedFor = ref<string | null>(null);
+/** Modal state: which URL service id the modal is about (null = closed). */
+const missingServiceId = ref<string | null>(null);
+const missingServiceFallbackId = computed<string | null>(() =>
+  layerServices.value[0]?.id ?? null,
+);
+const missingServiceFallbackName = computed<string | null>(() =>
+  layerServices.value[0]?.name ?? null,
+);
+
+watch(
+  [layerServices, selectedId, layerKey, servicesFetching],
+  ([roster, sid, lkey, fetching]) => {
+    if (!lkey || !sid) return;
+    if (fetching) return;              // wait for the roster to finish loading
+    if (roster.length === 0) return;   // outage / empty layer — handled 
elsewhere
+    const tag = `${lkey}::${sid}`;
+    if (validatedFor.value === tag) return;
+    validatedFor.value = tag;
+    if (roster.some((s) => s.id === sid)) return; // valid — trust the URL pick
+    missingServiceId.value = sid;
+  },
+);
+
+function acceptFallback(): void {
+  const next = missingServiceFallbackId.value;
+  missingServiceId.value = null;
+  if (next) setSelected(next);
+}
+function dismissMissing(): void {
+  missingServiceId.value = null;
+}
 const sampledServices = computed(() => landing.data.value?.sampledRows ?? 
landing.rows.value ?? []);
 const selectorColumns = computed(() => safeCfg.value.columns);
 const selectedRow = computed(
@@ -584,6 +639,49 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
     <div v-if="layer" class="tab-body">
       <RouterView />
     </div>
+
+    <!-- URL-pinned-service-not-found dialog. Fires when a deep link
+         (or hierarchy peer click) lands with a `?service=<id>` that
+         isn't in the layer's actual service roster. Operator can
+         accept the fallback to the first available service or
+         dismiss and pick manually. -->
+    <Modal
+      :open="missingServiceId !== null"
+      title="Service not found in this layer"
+      width="440px"
+      @close="dismissMissing"
+    >
+      <p style="margin: 0 0 12px 0; line-height: 1.5;">
+        The service id
+        <code style="font-family: var(--sw-mono); color: var(--sw-fg-1);">{{ 
missingServiceId }}</code>
+        is not in the
+        <b>{{ layer?.name ?? layerKey }}</b>
+        layer's current roster.
+      </p>
+      <p
+        v-if="missingServiceFallbackName"
+        style="margin: 0 0 12px 0; color: var(--sw-fg-2); font-size: 12px;"
+      >
+        Use the layer's first available service instead — <b>{{ 
missingServiceFallbackName }}</b> — or dismiss and pick one manually from the 
service header.
+      </p>
+      <p
+        v-else
+        style="margin: 0 0 12px 0; color: var(--sw-fg-2); font-size: 12px;"
+      >
+        This layer has no services to fall back to. Dismiss to stay on the 
empty view.
+      </p>
+      <template #footer>
+        <button type="button" class="sw-btn" 
@click="dismissMissing">Dismiss</button>
+        <button
+          v-if="missingServiceFallbackId"
+          type="button"
+          class="sw-btn is-primary"
+          @click="acceptFallback"
+        >
+          Use {{ missingServiceFallbackName }}
+        </button>
+      </template>
+    </Modal>
   </div>
 </template>
 
diff --git a/apps/ui/src/layer/service-map/LayerServiceMapView.vue 
b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
index 2404099..6cf1df2 100644
--- a/apps/ui/src/layer/service-map/LayerServiceMapView.vue
+++ b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
@@ -71,6 +71,9 @@ import {
 } from '@/utils/serviceName';
 import Sparkline from '@/components/charts/Sparkline.vue';
 import { isUserNode } from '@/layer/service-map/useTopologyIcons';
+import ServiceHierarchyOverlay from 
'@/layer/service-map/ServiceHierarchyOverlay.vue';
+import { useHierarchyOverlayStore } from '@/layer/service-map/hierarchyStore';
+import { useServiceHierarchy } from '@/layer/service-map/useServiceHierarchy';
 
 /** When embedded as a widget (e.g. inside the Services / Mesh overview
  *  dashboards) the host passes the layer key directly and asks for the
@@ -1241,6 +1244,58 @@ function zoomBy(factor: number): void {
   
d3.select(svgEl.value).transition().duration(160).call(zoomBehaviour.scaleBy, 
factor);
 }
 
+// ────────────────────────────────────────────────────────────────────
+// Smartscape hierarchy overlay — lazy-probed on node-select; chip on
+// the focused hex's right edge opens the focus+context+suggestions
+// overlay. The store gates the global auto-refresh ticker so the
+// background topology doesn't shift while the operator pans through
+// peers. Disabled in embedded (widget) mode — the snapshot widget is
+// intentionally non-interactive.
+// ────────────────────────────────────────────────────────────────────
+const hierarchy = useHierarchyOverlayStore();
+const { hasPeers: hierarchyHasPeers } = useServiceHierarchy(layerKey, 
selectedNodeId);
+
+function openHierarchy(): void {
+  if (embedded.value) return;
+  const sel = selectedNode.value;
+  if (!sel) return;
+  // Snapshot the live zoom so the overlay anchors peers on the same
+  // screen position as the focused hex underneath.
+  hierarchy.open({
+    serviceId: sel.id,
+    serviceName: identity(sel.name).display,
+    layer: layerKey.value,
+    zoom: { k: zoomT.value.k, x: zoomT.value.x, y: zoomT.value.y },
+  });
+}
+
+/** Resolver passed to the overlay so it can place peers relative to
+ *  the focused node's current topology coords. */
+function resolveNodePos(id: string): { cx: number; cy: number } | null {
+  const p = nodePos.value.get(id);
+  return p ? { cx: p.cx, cy: p.cy } : null;
+}
+
+// Peer clicks in the overlay open the destination layer in a NEW
+// BROWSER TAB (so the source tab keeps its overlay state), which means
+// no `?hierarchy=1` URL coordination is needed here.
+
+// Mirror live pan/zoom into the overlay's snapshot — the overlay
+// re-draws the focused hex at its underlying topology position +
+// scale, so it must follow whatever pan/zoom the operator does
+// while the overlay is up (otherwise the "focus" hex drifts away
+// from the hex it's supposed to overlap).
+watch(zoomT, (z) => {
+  if (hierarchy.isOpen) hierarchy.updateZoom({ k: z.k, x: z.x, y: z.y });
+});
+
+// Tear down the overlay (and re-enable the ticker) on unmount —
+// otherwise leaving the tab while open would freeze refresh
+// indefinitely.
+onBeforeUnmount(() => {
+  if (hierarchy.isOpen) hierarchy.close();
+});
+
 function installZoom(): void {
   if (!svgEl.value || !zoomLayerEl.value) return;
   const sel = d3.select(svgEl.value);
@@ -1750,6 +1805,27 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
                 <animate attributeName="opacity" values="1;0.2;1" dur="2s" 
repeatCount="indefinite" />
               </circle>
 
+              <!-- Smartscape hierarchy chip — anchored on the right
+                   vertex of the selected hex. Visible only once the
+                   lazy probe confirms cross-layer peers exist; click
+                   opens the focus+context+suggestions overlay. -->
+              <g
+                v-if="selectedNodeId === n.id && hierarchyHasPeers && 
!embedded"
+                transform="translate(48, 0)"
+                class="sm-h-chip"
+                @click.stop="openHierarchy()"
+              >
+                <title>Show service hierarchy (cross-layer peers)</title>
+                <circle r="11" fill="var(--sw-accent)" />
+                <circle r="11" fill="none" stroke="var(--sw-bg-0)" 
stroke-width="2" />
+                <!-- Stacked-layers glyph — three offset chevrons -->
+                <g stroke="var(--sw-bg-0)" stroke-width="1.6" fill="none" 
stroke-linecap="round" stroke-linejoin="round">
+                  <path d="M-4 -3 L0 -1 L4 -3" />
+                  <path d="M-4 0 L0 2 L4 0" />
+                  <path d="M-4 3 L0 5 L4 3" />
+                </g>
+              </g>
+
               <!-- RPM (center metric) above the hex body. No label —
                    the unit chip on the right is enough to disambiguate
                    it from the latency line beneath the name. Hidden
@@ -1802,6 +1878,18 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
           No services with metric data in this layer for the last 15 minutes.
         </div>
 
+        <!-- Smartscape hierarchy overlay — focus + context + suggestions.
+             Mounted inside .sm-graph so it overlays the topology
+             container exactly; gated on `embedded` so the widget
+             snapshot view never grows a dimmed canvas. -->
+        <ServiceHierarchyOverlay
+          v-if="!embedded"
+          :view-box-w="W"
+          :view-box-h="H"
+          :resolve-node-pos="resolveNodePos"
+          :show-legacy-group="showLegacyGroup"
+        />
+
         <!-- Floating zoom controls — top-right, mirror the map
              toolbar's affordance vocabulary (small ghost buttons).
              Hidden in embedded (dashboard widget) mode — the snapshot
@@ -2588,6 +2676,22 @@ function fmtWithUnit(v: number | null | undefined, unit: 
string | undefined): st
 .lg-swatch { width: 18px; height: 3px; border-radius: 1px; display: block; }
 .lg-aside { color: var(--sw-fg-3); font-size: 9.5px; }
 .lg-swatch-other { background: var(--sw-line-3); }
+
+/* Smartscape hierarchy chip — anchored on the right side of the
+   selected hex. Click opens the focus+context+suggestions overlay.
+   Visible only when the lazy probe found at least one cross-layer
+   peer (see `hierarchyHasPeers`). */
+.sm-h-chip {
+  cursor: pointer;
+  transition: transform 0.12s ease;
+}
+.sm-h-chip:hover {
+  transform: translate(48px, 0) scale(1.1);
+}
+.sm-h-chip:hover circle:first-child {
+  filter: drop-shadow(0 0 6px var(--sw-accent));
+}
+
 /* Floating zoom + fit controls at the top-right of the map area.
    Absolute-positioned over the SVG so they ride above any node /
    edge without taking layout space. */
diff --git a/apps/ui/src/layer/service-map/ServiceHierarchyOverlay.vue 
b/apps/ui/src/layer/service-map/ServiceHierarchyOverlay.vue
new file mode 100644
index 0000000..a58e2ee
--- /dev/null
+++ b/apps/ui/src/layer/service-map/ServiceHierarchyOverlay.vue
@@ -0,0 +1,804 @@
+<!--
+  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.
+-->
+<!--
+  Smartscape focus + context + suggestions overlay.
+
+  The overlay re-draws the focused hex at the SAME screen position +
+  same scale as the topology hex underneath (the live topology zoom
+  transform is mirrored onto the overlay's content group), then fans
+  hierarchy peers vertically around that origin:
+
+    - upper-level peers (request-near, higher OAP `level`) sit ABOVE
+    - lower-level peers (infra-near) sit BELOW
+    - same-level peers spread horizontally in the same lane
+
+  Sort matches booster-ui's `computeHierarchyLevels()` (level DESC).
+  Service names use `resolveServiceIdentity` against each peer's own
+  layer naming rule, so the base / cluster / legacy-group display
+  matches what the topology renders for the same service.
+
+  The basic topology stays visible under a dim layer for spatial
+  context; the global auto-refresh ticker is paused while the overlay
+  is open so nothing shifts under the focus (see hierarchyStore).
+-->
+<script setup lang="ts">
+import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue';
+// router/route used to live here for in-place navigation; peer clicks
+// now open a NEW TAB via window.open, so no router needed.
+import type {
+  HierarchyPeer,
+  LayerLevel,
+  ServiceNamingRule,
+} from '@skywalking-horizon-ui/api-client';
+import { firstLayerTab, useLayers } from '@/shell/useLayers';
+import { pushEvent } from '@/controls/eventLog';
+import { resolveServiceIdentity, type ServiceIdentity } from 
'@/utils/serviceName';
+import { useServiceHierarchy } from './useServiceHierarchy';
+import { useHierarchyOverlayStore } from './hierarchyStore';
+
+const props = defineProps<{
+  /** Topology viewBox dimensions — shared so the overlay's SVG
+   *  anchors on the same screen rectangle as the topology underneath. */
+  viewBoxW: number;
+  viewBoxH: number;
+  /** Resolver: `nodePos` lookup for the focused id. Returns null
+   *  while a layer/service swap is mid-flight after a peer click. */
+  resolveNodePos: (id: string) => { cx: number; cy: number } | null;
+  /** Whether the host layer's template surfaces the legacy
+   *  `<group>::` prefix as a chip — mirrored on the overlay so its
+   *  service-name affordances match the topology's. */
+  showLegacyGroup?: boolean;
+}>();
+
+const store = useHierarchyOverlayStore();
+const { layers: allLayers, findLayer } = useLayers();
+
+const layerKey = computed(() => (store.focusLayer ?? '').toLowerCase());
+const focusServiceId = computed(() => store.focusServiceId);
+const { data, isLoading } = useServiceHierarchy(layerKey, focusServiceId);
+
+/** Look up a layer's color from the menu registry; fall back to the
+ *  accent for layers we don't have an entry for (e.g. SO11Y_OAP). */
+function colorForLayer(layer: string): string {
+  const def = allLayers.value.find((L) => L.key.toUpperCase() === 
layer.toUpperCase());
+  return def?.color ?? 'var(--sw-accent)';
+}
+
+function labelForLayer(layer: string): string {
+  const def = allLayers.value.find((L) => L.key.toUpperCase() === 
layer.toUpperCase());
+  return def?.name ?? layer;
+}
+
+/** Per-layer naming rule — the same `LayerDef.naming` the topology
+ *  feeds into `resolveServiceIdentity` for that layer's nodes. Lets
+ *  the overlay render `mesh-svr::reviews.default` as `reviews` with
+ *  `default` cluster chip + `mesh-svr` legacy-group chip, exactly the
+ *  way the layer's own service-map renders it. */
+function namingForLayer(layer: string): ServiceNamingRule | null {
+  const def = allLayers.value.find((L) => L.key.toUpperCase() === 
layer.toUpperCase());
+  return def?.naming ?? null;
+}
+
+function identityFor(name: string, layer: string): ServiceIdentity {
+  return resolveServiceIdentity(name, namingForLayer(layer));
+}
+
+/** Focus position in topology viewBox coords — same value the
+ *  topology's `nodePos` exposes, BEFORE the zoom transform. We apply
+ *  the zoom to the overlay's content group below, so the focus hex
+ *  visually overlaps the underlying hex pixel-for-pixel. */
+const focusPos = computed(() => {
+  const id = focusServiceId.value;
+  return id ? props.resolveNodePos(id) : null;
+});
+
+/** Mirror the topology's live d3 zoom transform onto the overlay so
+ *  focus + peers track pan/zoom of the underlying topology. */
+const zoomTransform = computed(() => {
+  const z = store.zoom;
+  return `translate(${z.x}, ${z.y}) scale(${z.k})`;
+});
+
+/** Vertical lane spacing in TOPOLOGY pixel units — the topology zoom
+ *  transform scales this in screen space, so the lane spacing
+ *  visually tracks the topology's own node spacing regardless of
+ *  zoom. */
+const LANE_DY = 150;
+/** Horizontal spacing inside a single same-level lane. */
+const PEER_DX = 110;
+
+interface RenderedPeer {
+  key: string;
+  peer: HierarchyPeer;
+  layer: string;
+  color: string;
+  x: number;
+  y: number;
+  identity: ServiceIdentity;
+}
+
+/** Booster-ui's hierarchy renderer assigns Y by **sort-index in a
+ *  level-DESC sorted list**, not by raw OAP level. We mirror that:
+ *  every layer (peers + focus's home) is sorted level DESC (tie-
+ *  break alphabetical), and each layer gets a lane Y = its sort
+ *  index minus the focus's sort index, times LANE_DY. So the top of
+ *  the stack always shows the highest-level layer first, with GENERAL
+ *  ahead of MESH at the same level because of the alphabetical
+ *  tiebreaker. */
+const sortedLayers = computed<string[]>(() => {
+  const d = data.value;
+  if (!d || !store.focusLayer) return [];
+  const levelOf = new Map<string, number>(d.levels.map((L: LayerLevel) => 
[L.layer, L.level]));
+  const all = new Set<string>([store.focusLayer]);
+  for (const g of d.peers) {
+    if (g.services.some((s) => s.role !== 'self') || g.layer === 
store.focusLayer) {
+      all.add(g.layer);
+    }
+  }
+  return Array.from(all).sort((a, b) => {
+    const la = levelOf.get(a);
+    const lb = levelOf.get(b);
+    if (la !== undefined && lb !== undefined && la !== lb) return lb - la; // 
DESC
+    if (la !== undefined && lb === undefined) return -1;
+    if (lb !== undefined && la === undefined) return 1;
+    return a.localeCompare(b);
+  });
+});
+
+const focusLayerIdx = computed<number>(() =>
+  sortedLayers.value.indexOf(store.focusLayer ?? ''),
+);
+
+const renderedPeers = computed<RenderedPeer[]>(() => {
+  const fp = focusPos.value;
+  const d = data.value;
+  if (!fp || !d) return [];
+  const order = sortedLayers.value;
+  const fIdx = focusLayerIdx.value;
+  if (fIdx < 0) return [];
+
+  const out: RenderedPeer[] = [];
+  for (let idx = 0; idx < order.length; idx++) {
+    if (idx === fIdx) continue;            // focus layer renders the focus hex
+    const layer = order[idx];
+    const group = d.peers.find((g) => g.layer === layer);
+    if (!group) continue;
+    const peers = group.services.filter((s) => s.role !== 'self');
+    if (peers.length === 0) continue;
+    // Sort-index based lane: above focus when peer sort-index < focus
+    // (higher OAP level, request-near), below when peer sort-index >
+    // focus (lower OAP level, infra-near).
+    const laneDy = (idx - fIdx) * LANE_DY;
+    const color = colorForLayer(layer);
+    peers.forEach((peer, i) => {
+      const offset = (i - (peers.length - 1) / 2) * PEER_DX;
+      out.push({
+        key: `${layer}::${peer.id}`,
+        peer,
+        layer,
+        color,
+        x: fp.cx + offset,
+        y: fp.cy + laneDy,
+        identity: identityFor(peer.name, layer),
+      });
+    });
+  }
+  return out;
+});
+
+/** Lane labels — one per distinct peer layer at the lane's Y, so the
+ *  operator can read which layer each peer cluster belongs to without
+ *  hovering. Drawn inside the zoomed content so they track pan/zoom
+ *  with the peers. */
+interface LaneLabel {
+  layer: string;
+  name: string;
+  color: string;
+  x: number;
+  y: number;
+}
+const laneLabels = computed<LaneLabel[]>(() => {
+  const fp = focusPos.value;
+  if (!fp) return [];
+  // One label per distinct peer layer at the lane's Y. Peer hexes in
+  // the lane carry the count visually; no need for a "· N" badge.
+  const seen = new Map<string, LaneLabel>();
+  for (const p of renderedPeers.value) {
+    if (seen.has(p.layer)) continue;
+    seen.set(p.layer, {
+      layer: p.layer,
+      name: labelForLayer(p.layer),
+      color: p.color,
+      // Label sits to the left of the focus column at a comfortable
+      // gap; same Y as the peers in that lane.
+      x: fp.cx - 220,
+      y: p.y,
+    });
+  }
+  return Array.from(seen.values());
+});
+
+/** Focus identity — same resolver the topology uses, so the
+ *  `<group>::` / cluster chips on the focus card match what the right
+ *  detail panel of the topology displays for this service. */
+const focusIdentity = computed<ServiceIdentity | null>(() => {
+  if (!store.focusLayer || !store.focusServiceName) return null;
+  return identityFor(store.focusServiceName, store.focusLayer);
+});
+
+/** Six-vertex flat-top hex outline — matches LayerServiceMapView's
+ *  `hexPoints` exactly so the silhouette reads identically through
+ *  the overlay and the topology underneath. */
+function hexPoints(r: number): string {
+  const pts: string[] = [];
+  for (let i = 0; i < 6; i++) {
+    const a = ((i * 60) - 90) * Math.PI / 180;
+    pts.push(`${(Math.cos(a) * r).toFixed(2)},${(Math.sin(a) * 
r).toFixed(2)}`);
+  }
+  return pts.join(' ');
+}
+
+function onDimClick(): void {
+  store.close();
+}
+
+/** Currently armed peer — first click on a peer hex selects it (shows
+ *  an "Open in <layer>" action chip next to the hex); the second
+ *  click on that chip is what actually opens the new tab. Prevents
+ *  accidental navigation when the operator is just scanning peers. */
+const armedPeerKey = ref<string | null>(null);
+
+// Disarm whenever the overlay closes or focus changes, so a stale
+// arming from a previous focus doesn't carry into a new exploration.
+watch(
+  [() => store.isOpen, () => store.focusServiceId],
+  () => {
+    armedPeerKey.value = null;
+  },
+);
+
+/** True when the peer's layer has an active template the UI can route
+ *  to. OAP can report cross-layer relations into layers the operator
+ *  hasn't configured a Horizon template for (e.g. an obscure
+ *  observability layer); we surface the peer hex but disable the
+ *  action chip so a misleading "page not found" never happens. */
+function hasTemplate(layer: string): boolean {
+  const def = findLayer(layer.toLowerCase());
+  return Boolean(def && def.active);
+}
+
+/** First click: arm the peer (show the side action chip). Second
+ *  click on the same hex toggles it off. Clicking a different peer
+ *  re-arms onto that one. Disabled peers (no layer template) skip
+ *  arming and surface a notice instead — there's no useful chip to
+ *  show. */
+function onPeerClick(peerKey: string, layer: string): void {
+  if (!hasTemplate(layer)) {
+    pushEvent(
+      'hierarchy',
+      'err',
+      `No layer template configured for ${labelForLayer(layer)} (${layer}). 
The peer service exists on OAP but Horizon has no menu / page set up for this 
layer.`,
+    );
+    return;
+  }
+  armedPeerKey.value = armedPeerKey.value === peerKey ? null : peerKey;
+}
+
+/** Confirm the open — fired by the side action chip click. Opens the
+ *  destination layer's first menu tab (per `firstLayerTab`) in a NEW
+ *  BROWSER TAB with the peer service pre-selected via `?service=<id>`.
+ *  The destination view itself owns the cascade-strict auto-pick of
+ *  the first instance / endpoint (see `LayerDashboardsView.vue`), so
+ *  no extra URL flag is required — landing → serviceName → list →
+ *  pick happens naturally there. */
+function confirmOpen(p: HierarchyPeer, layer: string): void {
+  const def = findLayer(layer.toLowerCase());
+  if (!def || !def.active) return;
+  const tab = firstLayerTab(def);
+  const url = 
`/layer/${def.key.toLowerCase()}/${tab}?service=${encodeURIComponent(p.id)}`;
+  // `noopener` so the new tab can't reach back into window.opener.
+  window.open(url, '_blank', 'noopener');
+  armedPeerKey.value = null;
+}
+
+/** Human label for the action chip — e.g. "Open in MESH_DP". The
+ *  destination tab is implied (first menu of the layer). */
+function openLabelFor(layer: string): string {
+  const def = findLayer(layer.toLowerCase());
+  if (!def) return `Open in ${layer}`;
+  return `Open in ${def.name}`;
+}
+
+function truncate(s: string, n: number): string {
+  return s.length <= n ? s : s.slice(0, n - 1) + '…';
+}
+
+// ESC closes the overlay — the dim click is the primary affordance,
+// but operators reach for ESC reflexively on modal-style overlays.
+function onKeydown(ev: KeyboardEvent): void {
+  if (ev.key === 'Escape' && store.isOpen) {
+    store.close();
+  }
+}
+onMounted(() => window.addEventListener('keydown', onKeydown));
+onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));
+</script>
+
+<template>
+  <div v-if="store.isOpen" class="sm-hierarchy-overlay">
+    <!-- Dim background — click-to-close. The topology stays visible
+         underneath so the operator keeps spatial context. -->
+    <div class="sm-hierarchy-dim" @click="onDimClick" />
+
+    <!-- IMPORTANT: no viewBox. The topology SVG also has no viewBox
+         (it lets browsers interpret coords as raw pixels and lets d3
+         do the zoom). Adding a viewBox here would introduce a second
+         scaling step and the focus hex would no longer overlap the
+         underlying topology hex pixel-for-pixel. -->
+    <svg
+      class="sm-hierarchy-svg"
+      width="100%"
+      height="100%"
+    >
+      <!-- Zoom-mirrored content: focus + peers + labels all sit
+           inside this transform so they visually overlap the
+           topology hex pixel-for-pixel at every zoom level. -->
+      <g :transform="zoomTransform">
+        <!-- Spotlight halo around the focus so the eye anchors there
+             even with peers fanned in both directions. -->
+        <circle
+          v-if="focusPos"
+          :cx="focusPos.cx"
+          :cy="focusPos.cy"
+          r="220"
+          fill="url(#sm-h-spot)"
+        />
+
+        <!-- Connectors: dashed accent lines from focus → each peer,
+             coloured by peer layer so a quick glance separates lanes
+             without reading the labels. -->
+        <g v-if="focusPos">
+          <line
+            v-for="p in renderedPeers"
+            :key="`l-${p.key}`"
+            :x1="focusPos.cx"
+            :y1="focusPos.cy"
+            :x2="p.x"
+            :y2="p.y"
+            :stroke="p.color"
+            stroke-opacity="0.7"
+            stroke-width="1.8"
+            stroke-dasharray="5 6"
+          />
+        </g>
+
+        <!-- Lane labels: one chip per peer layer, anchored to the
+             left of the focus column at the lane's Y. Just the layer
+             dot + name — the peer hexes themselves carry the count
+             visually, so a "· N" badge would be redundant. -->
+        <g v-for="L in laneLabels" :key="`ll-${L.layer}`" 
:transform="`translate(${L.x}, ${L.y})`">
+          <rect
+            x="0"
+            y="-16"
+            :width="labelForLayer(L.layer).length * 9 + 36"
+            height="32"
+            rx="6"
+            :fill="L.color"
+            fill-opacity="0.12"
+            :stroke="L.color"
+            stroke-opacity="0.6"
+            stroke-width="1.2"
+          />
+          <circle cx="14" cy="0" r="4" :fill="L.color" />
+          <text
+            x="26"
+            y="5"
+            :fill="L.color"
+            font-size="13"
+            font-family="var(--sw-mono)"
+            font-weight="700"
+          >
+            {{ L.name }}
+          </text>
+        </g>
+
+        <!-- Focus hex — re-drawn at the underlying topology position.
+             Matches the topology's selected-hex silhouette so the
+             overlay reads as "the same hex you clicked, just lit up." -->
+        <g
+          v-if="focusPos"
+          :transform="`translate(${focusPos.cx}, ${focusPos.cy})`"
+        >
+          <polygon
+            :points="hexPoints(56)"
+            fill="var(--sw-accent)"
+            opacity="0.18"
+          />
+          <polygon
+            :points="hexPoints(50)"
+            fill="none"
+            stroke="var(--sw-accent)"
+            stroke-width="1.4"
+            stroke-dasharray="3 4"
+            opacity="0.85"
+          />
+          <polygon
+            :points="hexPoints(42)"
+            fill="var(--sw-bg-1)"
+            stroke="var(--sw-accent)"
+            stroke-width="2.5"
+            style="filter: drop-shadow(0 0 14px var(--sw-accent))"
+          />
+          <polygon
+            :points="hexPoints(32)"
+            fill="var(--sw-bg-2)"
+            stroke="var(--sw-line-2)"
+            stroke-width="1"
+          />
+          <g transform="translate(-14, -14)">
+            <polygon points="14,0 28,7 14,14 0,7" fill="#94a3b8" />
+            <polygon points="0,7 14,14 14,28 0,21" fill="#5b6373" />
+            <polygon points="28,7 14,14 14,28 28,21" fill="#3a4456" />
+          </g>
+          <!-- Base name below the hex — same position and treatment
+               the topology uses. `identity().display` strips the
+               `<group>::` prefix and any cluster suffix, mirroring
+               the topology's label rule. -->
+          <text
+            text-anchor="middle"
+            y="58"
+            fill="var(--sw-fg-0)"
+            font-size="16"
+            font-family="var(--sw-mono)"
+            font-weight="700"
+          >
+            {{ truncate(focusIdentity?.display ?? store.focusServiceName ?? 
'', 22) }}
+          </text>
+          <!-- Cluster + legacy-group chips beneath the name (only
+               when the layer's naming rule surfaces them, and only
+               when `showLegacyGroup` is on). Mirrors the chips the
+               right detail panel of the topology shows. -->
+          <g v-if="focusIdentity?.cluster" transform="translate(0, 78)">
+            <rect
+              :x="-(focusIdentity.cluster.length + 
(focusIdentity.clusterAlias?.length ?? 0) + 4) * 4"
+              y="-9"
+              :width="(focusIdentity.cluster.length + 
(focusIdentity.clusterAlias?.length ?? 0) + 4) * 8"
+              height="18"
+              rx="9"
+              fill="var(--sw-bg-1)"
+              stroke="var(--sw-accent)"
+              stroke-opacity="0.7"
+            />
+            <text
+              text-anchor="middle"
+              y="4"
+              fill="var(--sw-accent-2)"
+              font-size="10.5"
+              font-family="var(--sw-mono)"
+              font-weight="700"
+            >
+              <tspan fill="var(--sw-fg-3)">{{ focusIdentity.clusterAlias ?? 
'cluster' }}·</tspan>{{ focusIdentity.cluster }}
+            </text>
+          </g>
+          <g v-if="showLegacyGroup && focusIdentity?.legacyGroup" 
:transform="`translate(0, ${focusIdentity?.cluster ? 100 : 78})`">
+            <text
+              text-anchor="middle"
+              y="0"
+              fill="var(--sw-fg-3)"
+              font-size="10"
+              font-family="var(--sw-mono)"
+            >
+              {{ focusIdentity.legacyGroup }}::
+            </text>
+          </g>
+          <!-- FOCUS tag pill — small, off to the upper-right. -->
+          <g transform="translate(48, -38)">
+            <rect
+              x="0"
+              y="0"
+              width="56"
+              height="20"
+              rx="4"
+              fill="var(--sw-bg-1)"
+              stroke="var(--sw-accent)"
+            />
+            <text
+              x="28"
+              y="14"
+              text-anchor="middle"
+              font-size="10.5"
+              font-weight="700"
+              fill="var(--sw-accent-2)"
+              font-family="var(--sw-mono)"
+            >
+              FOCUS
+            </text>
+          </g>
+        </g>
+
+        <!-- Peer hexes — smaller than focus, layer-colored stroke,
+             clickable. Names use the peer's OWN layer naming rule via
+             `identityFor`, so e.g. a MESH peer named
+             `mesh-svr::reviews.default` displays as `reviews` (with
+             cluster `default` available beneath). -->
+        <g
+          v-for="r in renderedPeers"
+          :key="`p-${r.key}`"
+          :transform="`translate(${r.x}, ${r.y})`"
+          class="sm-h-peer"
+          :class="{
+            'is-disabled': !hasTemplate(r.layer),
+            'is-armed': armedPeerKey === r.key,
+          }"
+          @click.stop="onPeerClick(r.key, r.layer)"
+        >
+          <title>
+            {{ hasTemplate(r.layer)
+              ? `Click to select ${r.identity.display}; the action chip opens 
it in ${openLabelFor(r.layer).replace('Open in ', '')} (new tab)`
+              : `No Horizon layer template configured for 
${labelForLayer(r.layer)}` }}
+          </title>
+          <!-- Selection ring when armed — same vocabulary as the
+               topology's selected-hex halo, scaled for the peer's
+               smaller silhouette. -->
+          <polygon
+            v-if="armedPeerKey === r.key"
+            :points="hexPoints(46)"
+            :fill="r.color"
+            opacity="0.14"
+          />
+          <polygon
+            v-if="armedPeerKey === r.key"
+            :points="hexPoints(40)"
+            fill="none"
+            :stroke="r.color"
+            stroke-width="1.2"
+            stroke-dasharray="3 4"
+            opacity="0.85"
+          />
+          <polygon
+            :points="hexPoints(34)"
+            fill="var(--sw-bg-1)"
+            :stroke="r.color"
+            stroke-width="2.4"
+          />
+          <polygon
+            :points="hexPoints(24)"
+            :fill="r.color"
+            fill-opacity="0.22"
+          />
+          <g transform="translate(-11, -11)">
+            <polygon points="11,0 22,5 11,10 0,5" :fill="r.color" 
opacity="0.95" />
+            <polygon points="0,5 11,10 11,22 0,17" :fill="r.color" 
opacity="0.6" />
+            <polygon points="22,5 11,10 11,22 22,17" :fill="r.color" 
opacity="0.35" />
+          </g>
+          <text
+            text-anchor="middle"
+            y="52"
+            fill="var(--sw-fg-0)"
+            font-size="13"
+            font-family="var(--sw-mono)"
+            font-weight="600"
+          >
+            {{ truncate(r.identity.display, 22) }}
+          </text>
+          <text
+            v-if="r.identity.cluster"
+            text-anchor="middle"
+            y="66"
+            fill="var(--sw-accent-2)"
+            font-size="10"
+            font-family="var(--sw-mono)"
+            font-weight="600"
+          >
+            {{ r.identity.clusterAlias ?? 'cluster' }}·{{ r.identity.cluster }}
+          </text>
+          <text
+            v-else-if="showLegacyGroup && r.identity.legacyGroup"
+            text-anchor="middle"
+            y="66"
+            fill="var(--sw-fg-3)"
+            font-size="10"
+            font-family="var(--sw-mono)"
+          >
+            {{ r.identity.legacyGroup }}::
+          </text>
+          <text
+            v-if="!r.peer.normal"
+            text-anchor="middle"
+            :y="(r.identity.cluster || (showLegacyGroup && 
r.identity.legacyGroup)) ? 78 : 66"
+            fill="var(--sw-fg-3)"
+            font-size="9.5"
+            font-family="var(--sw-mono)"
+          >
+            virtual
+          </text>
+        </g>
+
+        <!-- Action chip for the armed peer: "Open in <Layer>" with an
+             external-link glyph. Click → opens a new browser tab to
+             the destination layer's first menu tab with the peer
+             pre-selected. Renders LAST so it draws above adjacent
+             peer hexes. -->
+        <g
+          v-for="r in renderedPeers"
+          :key="`a-${r.key}`"
+          v-show="armedPeerKey === r.key"
+          :transform="`translate(${r.x + 44}, ${r.y})`"
+          class="sm-h-action"
+          @click.stop="confirmOpen(r.peer, r.layer)"
+        >
+          <title>Open {{ r.identity.display }} in {{ labelForLayer(r.layer) }} 
(new tab)</title>
+          <rect
+            x="0"
+            y="-14"
+            :width="openLabelFor(r.layer).length * 7.6 + 36"
+            height="28"
+            rx="6"
+            :fill="r.color"
+            fill-opacity="0.18"
+            :stroke="r.color"
+            stroke-width="1.6"
+          />
+          <text
+            x="12"
+            y="5"
+            font-size="12"
+            font-weight="700"
+            :fill="r.color"
+            font-family="var(--sw-mono)"
+          >
+            {{ openLabelFor(r.layer) }}
+          </text>
+          <!-- External-link glyph: square with arrow out the top-right -->
+          <g
+            :transform="`translate(${openLabelFor(r.layer).length * 7.6 + 18}, 
0)`"
+            :stroke="r.color"
+            stroke-width="1.6"
+            fill="none"
+            stroke-linecap="round"
+            stroke-linejoin="round"
+          >
+            <path d="M-6 -3 L-6 5 L4 5 L4 -1" />
+            <path d="M0 -5 L6 -5 L6 1" />
+            <path d="M6 -5 L0 1" />
+          </g>
+        </g>
+
+        <!-- Empty-state guard — shouldn't fire (chip is gated on
+             `relations > 0`) but covers the rare swap-race. -->
+        <g
+          v-if="focusPos && !isLoading && renderedPeers.length === 0 && 
data?.reachable"
+          :transform="`translate(${focusPos.cx}, ${focusPos.cy + 130})`"
+        >
+          <rect x="-110" y="-14" width="220" height="28" rx="14"
+                fill="var(--sw-bg-1)" stroke="var(--sw-line)" />
+          <text text-anchor="middle" y="4" font-size="11.5"
+                font-family="var(--sw-mono)" fill="var(--sw-fg-2)">
+            No cross-layer projections
+          </text>
+        </g>
+      </g>
+
+      <defs>
+        <radialGradient id="sm-h-spot" cx="50%" cy="50%" r="55%">
+          <stop offset="0%" stop-color="rgba(15,19,26,0)" />
+          <stop offset="100%" stop-color="rgba(15,19,26,0.45)" />
+        </radialGradient>
+      </defs>
+    </svg>
+
+    <!-- Floating close button (top-right) — dim-click and ESC both
+         close too, but a visible × is the conventional discoverable
+         affordance for a modal-style overlay. -->
+    <button
+      class="sm-hierarchy-close"
+      type="button"
+      aria-label="Close hierarchy"
+      @click="store.close()"
+    >×</button>
+  </div>
+</template>
+
+<style scoped>
+.sm-hierarchy-overlay {
+  position: absolute;
+  inset: 0;
+  z-index: 20;
+  pointer-events: none;
+}
+.sm-hierarchy-dim {
+  position: absolute;
+  inset: 0;
+  background: rgba(15, 19, 26, 0.72);
+  backdrop-filter: blur(2px);
+  pointer-events: auto;
+  cursor: zoom-out;
+}
+.sm-hierarchy-svg {
+  position: absolute;
+  inset: 0;
+  width: 100%;
+  height: 100%;
+  pointer-events: none;
+}
+.sm-h-peer {
+  cursor: pointer;
+  pointer-events: auto;
+  transition: opacity 0.12s ease;
+}
+.sm-h-peer:hover polygon[fill-opacity="0.22"] {
+  fill-opacity: 0.36;
+}
+.sm-h-peer:hover polygon[stroke-width="2.4"] {
+  filter: drop-shadow(0 0 8px currentColor);
+}
+/* Peer whose layer has no Horizon template configured — clicking
+ * surfaces a notice in the event log instead of navigating. The
+ * dimmed look is a discoverable affordance for "nothing to open
+ * here". */
+.sm-h-peer.is-disabled {
+  cursor: not-allowed;
+  opacity: 0.45;
+}
+.sm-h-peer.is-disabled:hover polygon[fill-opacity="0.22"] {
+  fill-opacity: 0.22;
+}
+.sm-h-peer.is-disabled:hover polygon[stroke-width="2.4"] {
+  filter: none;
+}
+/* Armed peer: the action chip beside it confirms navigation. The
+ * peer itself reads "selected" via the dashed halo polygons added
+ * in the template. */
+.sm-h-peer.is-armed {
+  cursor: default;
+}
+
+.sm-h-action {
+  cursor: pointer;
+  pointer-events: auto;
+}
+.sm-h-action:hover rect {
+  fill-opacity: 0.32;
+}
+.sm-h-action rect {
+  filter: drop-shadow(0 4px 14px rgba(0, 0, 0, 0.55));
+}
+
+/* ── Floating close button ─────────────────────────────────────── */
+.sm-hierarchy-close {
+  position: absolute;
+  top: 14px;
+  right: 14px;
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  border: 1px solid var(--sw-line);
+  background: rgba(15, 19, 26, 0.92);
+  color: var(--sw-fg-2);
+  font-size: 20px;
+  line-height: 1;
+  cursor: pointer;
+  pointer-events: auto;
+  display: grid;
+  place-items: center;
+  backdrop-filter: blur(4px);
+}
+.sm-hierarchy-close:hover {
+  color: var(--sw-fg-0);
+  border-color: var(--sw-accent-line);
+}
+</style>
diff --git a/apps/ui/src/layer/service-map/hierarchyStore.ts 
b/apps/ui/src/layer/service-map/hierarchyStore.ts
new file mode 100644
index 0000000..171d788
--- /dev/null
+++ b/apps/ui/src/layer/service-map/hierarchyStore.ts
@@ -0,0 +1,108 @@
+/*
+ * 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.
+ */
+
+/**
+ * Pinia store for the per-layer service map's Smartscape hierarchy
+ * overlay. Holds open/close state, the focused node id, and the
+ * captured zoom transform at open time (the topology pan/zoom is
+ * frozen while the overlay is up so peers don't drift under the
+ * operator).
+ *
+ * The store also gates the global auto-refresh ticker via
+ * suspend()/resume() — pausing all topology refetches (and any other
+ * subscriber on the page) while the operator explores the hierarchy
+ * so the background graph doesn't shift under the spotlight. The
+ * ticker's `resume()` fires one immediate tick on close, so the topology
+ * snaps back to live data the moment the overlay shuts.
+ */
+
+import { defineStore } from 'pinia';
+import { ref } from 'vue';
+import { useAutoRefreshStore } from '@/controls/autoRefresh';
+
+/** Captured zoom transform — d3.ZoomTransform-equivalent. Held on the
+ *  store so the overlay can re-anchor peers when the topology view
+ *  re-mounts (e.g. tab swap and back). */
+export interface ZoomSnapshot {
+  k: number;
+  x: number;
+  y: number;
+}
+
+export const useHierarchyOverlayStore = 
defineStore('service-hierarchy-overlay', () => {
+  const isOpen = ref(false);
+  /** OAP service id of the focused node. Null when overlay is closed. */
+  const focusServiceId = ref<string | null>(null);
+  /** Service name of the focused node — used in the rail header and as
+   *  a fallback if the BFF response's `self` peer is missing (rare). */
+  const focusServiceName = ref<string | null>(null);
+  /** OAP layer key of the focus's home layer (e.g. `GENERAL`). */
+  const focusLayer = ref<string | null>(null);
+  /** Zoom snapshot taken when the overlay opens. The overlay re-applies
+   *  this to its own SVG group so peers + connectors track the focus
+   *  hex's current screen position; the topology's own zoom behaviour
+   *  is paused while the overlay is up. */
+  const zoom = ref<ZoomSnapshot>({ k: 1, x: 0, y: 0 });
+
+  function open(args: {
+    serviceId: string;
+    serviceName: string;
+    layer: string;
+    zoom: ZoomSnapshot;
+  }): void {
+    focusServiceId.value = args.serviceId;
+    focusServiceName.value = args.serviceName;
+    focusLayer.value = args.layer.toUpperCase();
+    zoom.value = args.zoom;
+    isOpen.value = true;
+    // Freeze every refetch path on the page (topology, KPIs, dependency
+    // graph, etc.) — operators expect the background to stay still
+    // while they pan through the hierarchy.
+    useAutoRefreshStore().suspend();
+  }
+
+  function close(): void {
+    if (!isOpen.value) return;
+    isOpen.value = false;
+    focusServiceId.value = null;
+    focusServiceName.value = null;
+    focusLayer.value = null;
+    // resume() fires one immediate tick — the topology snaps back to
+    // live data the moment the overlay shuts. Operators reading
+    // "closed" expect the numbers to refresh, not stay stale.
+    useAutoRefreshStore().resume();
+  }
+
+  /** Replace the captured zoom — called by the topology view when the
+   *  operator changes layer/service while the overlay is open (the
+   *  overlay stays up via the URL `?hierarchy=1` flag; we just re-aim
+   *  it at the new focus). */
+  function updateZoom(next: ZoomSnapshot): void {
+    zoom.value = next;
+  }
+
+  return {
+    isOpen,
+    focusServiceId,
+    focusServiceName,
+    focusLayer,
+    zoom,
+    open,
+    close,
+    updateZoom,
+  };
+});
diff --git a/apps/ui/src/layer/service-map/useServiceHierarchy.ts 
b/apps/ui/src/layer/service-map/useServiceHierarchy.ts
new file mode 100644
index 0000000..75d5d0f
--- /dev/null
+++ b/apps/ui/src/layer/service-map/useServiceHierarchy.ts
@@ -0,0 +1,60 @@
+/*
+ * 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.
+ */
+
+/**
+ * vue-query wrapper around `GET /api/layer/:key/service-hierarchy`.
+ *
+ * Lazy-probed on node-select in the service map: one round-trip per
+ * selected service answers both "should the expand chip show?" and
+ * "what populates the overlay?" — `relations === 0` means the focused
+ * service has no cross-layer projections, so the chip stays hidden.
+ *
+ * Not subscribed to the global auto-refresh ticker. The hierarchy
+ * table doesn't drift on the OAP minute boundary; the only thing that
+ * invalidates it is a layer/service switch, which is part of the
+ * queryKey. Topology pages own enough refetch noise already.
+ */
+
+import { computed, type Ref } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import type { ServiceHierarchyResponse } from 
'@skywalking-horizon-ui/api-client';
+import { bffClient } from '@/api/client';
+
+export function useServiceHierarchy(
+  layerKey: Ref<string>,
+  serviceId: Ref<string | null>,
+) {
+  const q = useQuery({
+    queryKey: ['layer-service-hierarchy', layerKey, serviceId],
+    queryFn: (): Promise<ServiceHierarchyResponse> =>
+      bffClient.layer.serviceHierarchy(layerKey.value, serviceId.value!),
+    enabled: computed(() => layerKey.value.length > 0 && !!serviceId.value),
+    staleTime: 60_000,
+  });
+
+  return {
+    data: computed<ServiceHierarchyResponse | null>(() => q.data.value ?? 
null),
+    /** `true` once a probe lands AND the focused service has at least
+     *  one cross-layer peer. The expand chip on the selected hex reads
+     *  from this — it stays hidden until the probe confirms peers. */
+    hasPeers: computed<boolean>(() => (q.data.value?.relations ?? 0) > 0),
+    isLoading: q.isLoading,
+    isFetching: q.isFetching,
+    error: q.error,
+    refetch: q.refetch,
+  };
+}
diff --git a/apps/ui/src/layer/useLayerServices.ts 
b/apps/ui/src/layer/useLayerServices.ts
new file mode 100644
index 0000000..4422ef9
--- /dev/null
+++ b/apps/ui/src/layer/useLayerServices.ts
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Reactive full-service-roster lookup for a layer. Backs the layer
+ * shell's URL-service validator — when a deep link or hierarchy peer
+ * click arrives with `?service=<id>`, the shell checks the id against
+ * this roster (the layer's REAL service list, independent of
+ * landing's top-N rollup which can miss low-traffic services). A
+ * missing id pops the "service not found" notice; a present id is
+ * trusted regardless of landing visibility.
+ */
+
+import { computed, type Ref } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import { bffClient } from '@/api/client';
+
+export interface LayerServiceRow {
+  id: string;
+  name: string;
+  normal: boolean | null;
+}
+
+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,
+  });
+  return {
+    data: computed(() => q.data.value ?? null),
+    services: computed<LayerServiceRow[]>(() => q.data.value?.services ?? []),
+    isLoading: q.isLoading,
+    isFetching: q.isFetching,
+  };
+}
diff --git a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue 
b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
index ff37698..e502e3e 100644
--- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
@@ -44,6 +44,7 @@ import { useLayers } from '@/shell/useLayers';
 import { useSelectedEndpoint } from '@/layer/useSelectedEndpoint';
 import { useSelectedInstance } from '@/layer/useSelectedInstance';
 import { useSelectedService } from '@/layer/useSelectedService';
+import { useLayerServices } from '@/layer/useLayerServices';
 import { useSetupStore } from '@/state/setup';
 import { fmtMetricAs } from '@/utils/formatters';
 import { ref, watch, watchEffect } from 'vue';
@@ -117,10 +118,22 @@ function xLabelsForLen(len: number): string[] {
   );
 }
 const landing = useLayerLanding(safeLayer, safeCfg, rangeRef);
+// The layer's REAL service roster (id + name), independent of the
+// landing top-N. The cascade prefers landing rows when they include
+// the selectedId (so the display name has the same casing / cluster
+// suffix the landing rollup uses), but falls back to the full roster
+// for low-traffic services that never make landing's sample. Without
+// the roster fallback the dashboard would sit on "Resolving service…"
+// forever for any deep-link / hierarchy-peer entry whose service
+// isn't in landing's top-N.
+const { services: layerServices } = useLayerServices(layerKey);
 const serviceName = computed<string | null>(() => {
   const rows = landing.data.value?.sampledRows ?? landing.rows.value ?? [];
   const match = rows.find((r) => r.serviceId === selectedId.value);
-  return match?.serviceName ?? null;
+  if (match) return match.serviceName;
+  const fromRoster = layerServices.value.find((s) => s.id === 
selectedId.value);
+  if (fromRoster) return fromRoster.name;
+  return null;
 });
 // Dev-only escape hatch: appending `?mockTop=10` to the page URL pads
 // every TopList result to N synthetic rows. Helps operators verify
@@ -164,23 +177,16 @@ const landingRows = computed(() => 
landing.data.value?.sampledRows ?? landing.ro
 watch(landingRows, (rows) => {
   const first = rows[0];
   if (!first) return;
-  // First-visit (no ?service= in URL) → quietly auto-pick. Stale
-  // URL pick (id present but not in the layer) → log a debug
-  // event so the operator sees the fallback in the event panel,
-  // THEN auto-pick. Distinguishes the two so the silent default
-  // isn't conflated with a fallback in the timeline.
+  // First-visit (no ?service= in URL) → quietly auto-pick the first
+  // landing service so the page renders something. Stale-id recovery
+  // for URL-pinned services is handled at the shell level
+  // (LayerShell pops a "service not found" modal against the full
+  // roster) so the dashboard doesn't second-guess a valid id that
+  // simply missed landing's top-N rollup.
   if (!selectedId.value) {
     setSelectedService(first.serviceId);
     return;
   }
-  if (!rows.some((r) => r.serviceId === selectedId.value)) {
-    pushEvent(
-      'fallback',
-      'info',
-      `URL service "${selectedId.value}" not in ${layerKey.value} · falling 
back to "${first.serviceName}"`,
-    );
-    setSelectedService(first.serviceId);
-  }
 }, { immediate: true });
 // Drop the stale instance whenever the service ACTUALLY changes —
 // the new service's instance list almost never matches the previous
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 8596d2a..dbb8651 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -66,6 +66,13 @@ export type {
   EndpointDependencyCall,
   EndpointDependencyResponse,
 } from './topology.js';
+export type {
+  LayerLevel,
+  HierarchyPeer,
+  HierarchyPeerRole,
+  HierarchyLayerGroup,
+  ServiceHierarchyResponse,
+} from './service-hierarchy.js';
 export type {
   TraceSource,
   TracesConfig,
diff --git a/packages/api-client/src/service-hierarchy.ts 
b/packages/api-client/src/service-hierarchy.ts
new file mode 100644
index 0000000..73c850b
--- /dev/null
+++ b/packages/api-client/src/service-hierarchy.ts
@@ -0,0 +1,96 @@
+/*
+ * 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.
+ */
+
+/**
+ * Service-hierarchy wire shape — the BFF-side view of OAP's
+ * `getServiceHierarchy` + `listLayerLevels`. Powers the Smartscape
+ * focus+context+suggestions overlay on the per-layer service map.
+ *
+ * SkyWalking's hierarchy is **one hop**: a logical service projected
+ * across multiple observation layers (e.g. an app seen at GENERAL via
+ * the Java agent, at MESH as an Envoy sidecar, and at K8S_SERVICE as a
+ * Kubernetes Service object). Instances / pods / processes are
+ * children of the service — they are NOT additional hierarchy levels.
+ *
+ * The BFF flattens OAP's `upperService`/`lowerService` relations into a
+ * single peer list per layer, tags the focused service with
+ * `role: 'self'`, and merges in the canonical layer ordering from
+ * `listLayerLevels` so the UI can render lanes without re-deriving the
+ * order client-side.
+ */
+
+/** One layer's canonical hierarchy position. Lower `level` is closer to
+ *  the request edge (e.g. browser RUM); higher `level` is closer to the
+ *  infrastructure (e.g. Kubernetes / OS). The ordering is OAP-supplied
+ *  via `listLayerLevels` and cached BFF-process-lifetime. */
+export interface LayerLevel {
+  layer: string;
+  level: number;
+}
+
+/** One peer service in the hierarchy of the focused service.
+ *  `self` is the focused service itself, included so the UI can place
+ *  it on the layer ribbon alongside its peers. */
+export type HierarchyPeerRole = 'upper' | 'lower' | 'self';
+
+export interface HierarchyPeer {
+  /** OAP service id. */
+  id: string;
+  /** Service name (no group prefix). */
+  name: string;
+  /** OAP's `normal` flag — `false` for virtual peers (databases, MQs,
+   *  etc.). The UI may surface this as a "virtual" tag. */
+  normal: boolean;
+  /** Relation to the focused service. `upper` = request-near peer
+   *  (closer to user / browser layer); `lower` = infra-near peer
+   *  (closer to platform); `self` = the focused service itself. */
+  role: HierarchyPeerRole;
+}
+
+/** Peers grouped by layer. Layers are returned in the order they appear
+ *  in `levels`; layers with no peers (other than `self`) are omitted. */
+export interface HierarchyLayerGroup {
+  layer: string;
+  /** Sorted: `self` first, then `upper` peers, then `lower` peers. */
+  services: HierarchyPeer[];
+}
+
+/** Wire shape of `GET /api/layer/:key/service-hierarchy?service=<id>`.
+ *  Never throws on an unreachable OAP — `reachable: false` + `error`
+ *  carries the diagnostic so the overlay can degrade rather than
+ *  hard-fail. `relations` is the raw peer count across layers (excluding
+ *  `self`) — the UI uses it as a quick "show expand chip?" check. */
+export interface ServiceHierarchyResponse {
+  reachable: boolean;
+  error?: string;
+  /** Echoed inputs so cache keys + UI labels don't drift. */
+  layer: string;
+  serviceId: string;
+  /** Canonical layer ordering (request-near → infra-near). Cached
+   *  BFF-process-lifetime — OAP's level table is immutable per
+   *  deployment. */
+  levels: LayerLevel[];
+  /** Peer count across all layers, excluding `self`. `0` means the
+   *  focused service has no cross-layer projections — the UI hides the
+   *  expand affordance. Counts everything that will actually render in
+   *  the overlay (direct peers + their transitive cross-layer twins),
+   *  not just OAP's direct upper/lower relation count. */
+  relations: number;
+  /** Peers grouped by layer in `levels` order. The focused service
+   *  appears under its own layer with `role: 'self'`. */
+  peers: HierarchyLayerGroup[];
+}

Reply via email to