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

Reply via email to