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 <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[]; +}
