This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
The following commit(s) were added to refs/heads/main by this push:
new 4dee096 feat(service-map): Smartscape cross-layer hierarchy overlay
(#14)
4dee096 is described below
commit 4dee09616725ecebdc2bb4376d87e945d689ad85
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Mon May 25 12:15:40 2026 +0800
feat(service-map): Smartscape cross-layer hierarchy overlay (#14)
---
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 <Layer>"* 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[];
+}