This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch feat/service-internal-topology in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit d0af7e74050556097aa61c4ad5256d4cdc04ccea Author: Wu Sheng <[email protected]> AuthorDate: Tue Jun 9 11:39:44 2026 +0800 feat(infra-3d): per-layer internal-topology toggle on the 3D map Layers that enable the Service Internal Topology component gain a toggle in the 3D map's side-panel layer row. When on, that layer's zone renders its INSTANCES and their intra-service relations instead of service cubes: the topology stage swaps the per-layer service-map probe for getServiceInstanceTopology(svc, svc) per service (loadLiveInternalTopologies), overriding the layer's servicesByLayer + topologies in place so the existing scene/layout/raycast pipeline renders instance cubes unchanged. - MapServiceRef / SceneServiceNode carry optional ownerServiceId + instanceName for instance nodes; buildSceneGraph passes them through. - Detail-card "Open dashboard" targets the INSTANCE dashboard (/layer/:key/instance?service=&instance=) for instance nodes, the service dashboard otherwise. - Toggle is gated on caps.serviceInternalTopology, re-runs the light pipeline, and is folded into the scene structure key so it rebuilds. In-memory only (mode isn't persisted across reloads). A layer with no intra-service instance relations collapses to an empty zone (expected — the data is backend-dependent). --- CHANGELOG.md | 5 ++ apps/ui/src/features/infra-3d/Infra3DScene.vue | 9 +++- apps/ui/src/features/infra-3d/Infra3DView.vue | 62 +++++++++++++++++++++- .../infra-3d/composables/useLiveTopology.ts | 53 ++++++++++++++++++ .../infra-3d/composables/useMapTopology.ts | 13 +++++ 5 files changed, 140 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc574bb..e00e6c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,11 @@ packages) plus the BFF's `HORIZON_VERSION` default. scope) plus the clustering-rule picker. The config is a self-contained block on the layer template, so it travels with template export/import and is independent of the service-map topology config. +- **3D infra map integration.** On the 3D map, a layer that enables internal + topology gains a per-layer toggle in the side panel: switch it on and that + layer's zone renders its **instances** and their intra-service relations + instead of service cubes — selecting an instance cube opens the **instance** + dashboard. Toggle it off to return to the service view. ### API dependency diff --git a/apps/ui/src/features/infra-3d/Infra3DScene.vue b/apps/ui/src/features/infra-3d/Infra3DScene.vue index 8315fc8..cb0d23e 100644 --- a/apps/ui/src/features/infra-3d/Infra3DScene.vue +++ b/apps/ui/src/features/infra-3d/Infra3DScene.vue @@ -1496,7 +1496,14 @@ const openDashboardHref = computed<string>(() => { const d = selectedNodeDetail.value; if (!d) return import.meta.env.BASE_URL; const base = import.meta.env.BASE_URL; // ends with '/' - return `${base}layer/${d.node.layerKey}/service?service=${encodeURIComponent(d.node.serviceId)}`; + const n = d.node; + // Instance node (layer in internal-topology mode) → instance dashboard, + // pre-selecting the owning service + the instance. Service node → the + // service dashboard. + if (n.instanceName && n.ownerServiceId) { + return `${base}layer/${n.layerKey}/instance?service=${encodeURIComponent(n.ownerServiceId)}&instance=${encodeURIComponent(n.instanceName)}`; + } + return `${base}layer/${n.layerKey}/service?service=${encodeURIComponent(n.serviceId)}`; }); // ── Detail-card side: flip to whichever side of the canvas has more diff --git a/apps/ui/src/features/infra-3d/Infra3DView.vue b/apps/ui/src/features/infra-3d/Infra3DView.vue index 20000eb..644a9e0 100644 --- a/apps/ui/src/features/infra-3d/Infra3DView.vue +++ b/apps/ui/src/features/infra-3d/Infra3DView.vue @@ -51,6 +51,7 @@ import { liveSkeleton, loadLiveServices, loadLiveTopologies, + loadLiveInternalTopologies, loadLiveHierarchy, type LiveWindow, } from './composables/useLiveTopology'; @@ -161,6 +162,14 @@ interface PipelineCtx { // scene renders complete (see sceneReady), never piecemeal. const liveTopologyEnabled = computed(() => route.query.live !== '0'); const liveTopo = shallowRef<MapTopology | null>(null); +// Layers the operator switched into INTERNAL-TOPOLOGY mode (side-panel +// toggle, only offered for layers carrying the serviceInternalTopology +// cap). Their zone renders instance cubes + instance-relation arcs instead +// of service cubes — see the `topologies` stage + loadLiveInternalTopologies. +// In-memory only (visibility/mode aren't persisted across reloads, per +// CLAUDE.md). Read inside the pipeline stage, so a toggle re-runs the light +// pipeline to re-fetch. +const internalLayers = ref<Set<string>>(new Set()); const liveWindow = (): LiveWindow => ({ startMs: Date.now() - 2 * 3600_000, endMs: Date.now(), @@ -197,7 +206,10 @@ const sceneKey = computed(() => { .map((h) => `${h.fromLayer}/${h.fromService.id}:${h.peers.reduce((a, p) => a + p.services.length, 0)}`) .sort() .join(','); - return `${naming}:${struct}#${hier}`; + // Fold the internal-topology mode set in too, so toggling a layer re-keys + // the scene even in the (degenerate) case the swapped roster hashes the same. + const internal = [...internalLayers.value].sort().join(','); + return `${naming}:${struct}#${hier}@${internal}`; }); // Render gate: in live mode hold the scene until the first full assembly @@ -500,6 +512,14 @@ const livePipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = { const rosterKeys = new Set(topo.layers.map((l) => l.key)); const bearing = menuLayers.value.filter((L) => rosterKeys.has(L.key) && isTopologyBearing(L)); await loadLiveTopologies(rep, bearing, topo, liveWindow()); + // Internal-topology mode: for toggled-on layers carrying the cap, swap + // their zone to the within-service instance graph. Runs after the + // service-map fetch so it overrides those layers' servicesByLayer + + // topologies in place. + const internal = menuLayers.value.filter( + (L) => rosterKeys.has(L.key) && internalLayers.value.has(L.key) && !!L.caps.serviceInternalTopology, + ); + if (internal.length > 0) await loadLiveInternalTopologies(internal, topo, liveWindow()); }, hierarchy: async (rep, ctx) => { const topo = ctx.topo; @@ -555,6 +575,26 @@ async function runLight(): Promise<void> { await runPipelineState(ctx, livePipelineImpls, ['services', 'topologies', 'hierarchy', 'metrics']); } +/** True when a side-panel layer entry can offer the internal-topology + * toggle — i.e. the layer carries the `serviceInternalTopology` cap. A + * group entry (clustered layers) never qualifies. */ +function layerSupportsInternal(layerKey: string): boolean { + return !!menuLayers.value.find((L) => L.key === layerKey)?.caps.serviceInternalTopology; +} +function isInternalOn(layerKey: string): boolean { + return internalLayers.value.has(layerKey); +} +/** Flip a layer between service-topology and internal (instance) topology. + * Re-runs the light pipeline so the toggled layer's zone re-fetches with + * the new mode; the scene re-keys off `sceneKey` and rebuilds. */ +function toggleInternal(layerKey: string): void { + const next = new Set(internalLayers.value); + if (next.has(layerKey)) next.delete(layerKey); + else next.add(layerKey); + internalLayers.value = next; + void runLight(); +} + /** Arm (or re-arm) the auto-refresh timer + countdown anchor. */ function scheduleRefresh(): void { if (refreshTimer !== null) clearTimeout(refreshTimer); @@ -1004,6 +1044,17 @@ function onPanelZoneFocus(zoneKey: string): void { <span class="lr-dot" :style="e.color ? { background: e.color } : undefined" /> <span class="lr-name">{{ e.name }}</span> <span v-if="e.kind === 'group'" class="lr-tag">group</span> + <!-- Internal-topology toggle — only for layers carrying the + cap. On = the zone shows instance-to-instance topology + within each service instead of service cubes. --> + <button + v-if="e.kind === 'layer' && layerSupportsInternal(e.key)" + type="button" + class="lr-internal" + :class="{ on: isInternalOn(e.key) }" + :title="isInternalOn(e.key) ? 'Showing internal (instance) topology — click for service topology' : 'Show internal (instance) topology'" + @click.stop="toggleInternal(e.key)" + >⊟</button> <span class="lr-stat">{{ e.services }}</span> </li> </ul> @@ -1312,6 +1363,15 @@ function onPanelZoneFocus(zoneKey: string): void { border-radius: 3px; padding: 0 4px; } .lr-stat { font-size: 9.5px; color: var(--sw-fg-3); font-variant-numeric: tabular-nums; } +.lr-internal { + flex: 0 0 auto; width: 18px; height: 18px; padding: 0; + display: inline-flex; align-items: center; justify-content: center; + border: 1px solid var(--sw-line-2); border-radius: 4px; + background: transparent; color: var(--sw-fg-3); cursor: pointer; + font-size: 11px; line-height: 1; +} +.lr-internal:hover { color: var(--sw-fg-1); border-color: var(--sw-line-3); } +.lr-internal.on { color: var(--sw-accent-2); border-color: var(--sw-accent-line); background: var(--sw-accent-soft); } .tier-name { flex: 1; min-width: 0; diff --git a/apps/ui/src/features/infra-3d/composables/useLiveTopology.ts b/apps/ui/src/features/infra-3d/composables/useLiveTopology.ts index f2ba3e5..d6f4b74 100644 --- a/apps/ui/src/features/infra-3d/composables/useLiveTopology.ts +++ b/apps/ui/src/features/infra-3d/composables/useLiveTopology.ts @@ -203,6 +203,59 @@ export async function loadLiveTopologies( return probes; } +/** + * Internal-topology override — for layers the operator toggled into + * "internal topology" mode (3D side panel). For each such layer, replace + * its zone's SERVICE nodes with the INSTANCE-to-instance graph WITHIN each + * of its services: one `getServiceInstanceTopology(svc, svc)` per service, + * unioned. The result is written back onto the SAME `MapTopology` shape + * (`servicesByLayer` + `topologies`) the scene already renders, so instance + * cubes + instance-relation arcs render through the unchanged pipeline. Each + * instance ref carries its owning service id + instance name so the detail + * card can open the instance dashboard. + * + * A layer whose services have no intra-service instance relations in the + * window collapses to an empty zone (the expected "blank topo" — the data + * is backend-dependent). Per-service try/catch so one failure never aborts. + */ +export async function loadLiveInternalTopologies( + layers: LayerDef[], + topo: MapTopology, + window: LiveWindow, +): Promise<void> { + for (const L of layers) { + const services = topo.servicesByLayer[L.key] ?? []; + const instById = new Map<string, MapServiceRef>(); + const calls: MapTopologyCall[] = []; + for (const svc of services) { + try { + const resp = await bff.layer.serviceInternalTopology(L.key, svc.id, window); + for (const n of resp.nodes) { + if (instById.has(n.id)) continue; + instById.set(n.id, { + id: n.id, + name: n.name, + normal: svc.normal, + ownerServiceId: n.serviceId, + instanceName: n.name, + }); + } + for (const c of resp.calls) { + calls.push({ source: c.source, target: c.target, detectPoints: c.detectPoints }); + } + } catch (err) { + console.warn(`[infra-3d] live internal topology failed for ${L.key}/${svc.id}:`, err); + } + } + const instances = [...instById.values()]; + topo.servicesByLayer[L.key] = instances; + topo.topologies[L.key] = { + nodes: instances.map((r) => ({ id: r.id, name: r.name, layer: L.key })), + calls, + }; + } +} + /** Cache key — `LAYER::serviceId`, the unique identity of a service projection. */ function hierKey(layer: string, id: string): string { return `${layer.toUpperCase()}::${id}`; diff --git a/apps/ui/src/features/infra-3d/composables/useMapTopology.ts b/apps/ui/src/features/infra-3d/composables/useMapTopology.ts index 642df9a..5b16d8a 100644 --- a/apps/ui/src/features/infra-3d/composables/useMapTopology.ts +++ b/apps/ui/src/features/infra-3d/composables/useMapTopology.ts @@ -41,6 +41,12 @@ export interface MapServiceRef { id: string; name: string; normal: boolean; + /** Set only when this ref is an INSTANCE node (a layer in + * internal-topology mode): the owning service's id + the instance name, + * so the scene's "open dashboard" can target the instance dashboard. + * Absent for ordinary service nodes. */ + ownerServiceId?: string; + instanceName?: string; } export interface MapHierarchyPeer { layer: string; @@ -127,6 +133,11 @@ export interface SceneServiceNode { name: string; shortName: string; normal: boolean; + /** Instance-node fields (layer in internal-topology mode): the owning + * service id + instance name. When set, the node is an instance, not a + * service — the detail card opens the instance dashboard. */ + ownerServiceId?: string; + instanceName?: string; } export interface SceneHierarchyEdge { @@ -215,6 +226,8 @@ export function buildSceneGraph( name: s.name, shortName: shortName(s.name), normal: s.normal, + ownerServiceId: s.ownerServiceId, + instanceName: s.instanceName, })); // Intra-layer call edges from the topology snapshot. We filter to // calls where BOTH endpoints are services that belong to this
