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

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


The following commit(s) were added to refs/heads/main by this push:
     new a0ab0f7  feat(infra-3d): live topology + full/light refresh for the 3D 
map (#32)
a0ab0f7 is described below

commit a0ab0f7782c720dacc013d0aa9d580e42db7aad6
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Sat May 30 23:22:42 2026 +0800

    feat(infra-3d): live topology + full/light refresh for the 3D map (#32)
    
    ## Live topology + refresh model for the 3D Infrastructure Map
    
    Builds `/3d/map` from **live OAP** (per-layer `services` + `topology`) 
instead of the committed snapshot, with a deliberate two-tier refresh, a tuned 
camera, and the experimental label dropped. Backend contracts unchanged.
    
    ### Initial load — render once, complete
    Held behind a **"Reading topology…"** gate until the full context (layers, 
templates, services, topology, clustering) is assembled, then rendered **once** 
— no snapshot/partial flash. `liveTopo` publishes atomically at the end of the 
topology stage. `?live=0` forces the committed snapshot for comparison.
    
    ### Refresh — full vs. light
    - **Landing + manual ↻** run the **FULL** pipeline; templates + 
layout/clustering resolve **once** here and the known-layer set is captured.
    - **60s auto-refresh** runs **LIGHT** — only **services + topologies + 
metrics** re-run (`run()` gained an `only` stage filter); templates + layout 
keep their last full-run state.
    - A layer appearing only on a light refresh has no template yet → its 
services are **hidden** until the next full refresh, and **listed in the 
services stage's status detail**.
    
    ### Camera + interaction
    - Initial framing tuned to a near-front **azimuth 15° / polar 62°** 3/4 
heading (spherical, scale-invariant), shifted left of the TIERS panel. The 
**reset** and **side-panel focus** share the same heading, so panel clicks 
glide without changing the angle (this also fixed the selection flash appearing 
a tier low).
    - WASD/arrow keyboard pan step halved.
    
    ### Polish
    - Header counts/scope chip removed (the bottom strip already carries them).
    - Stage-detail drawer no longer occluded by the brand mark.
    - Topbar pill no longer says **EXPERIMENTAL**; hover expands to the full 
**3D Infrastructure Map**.
    
    ### Validation & review
    Live OAP replay: 73 services / 17 layers, 3 topology maps, ~6s, zero 
errors. UI + BFF `type-check` and `license-eye` clean. A 5-dimension 
adversarial review (live-data, render-gating, camera, lifecycle/flash, 
regression) found **no must-fix functional bug** introduced by this branch — 
confirmed items were by-design (the light/full split), pre-existing on main 
(cross-edge/pan NaN — the latter unreachable, polar capped at 88°), or a known 
BFF protocol limitation (metrics keyed by servic [...]
---
 CHANGELOG.md                                       |   7 +
 apps/ui/src/features/infra-3d/CLAUDE.md            |  15 +-
 apps/ui/src/features/infra-3d/Infra3DScene.vue     |  52 ++++-
 apps/ui/src/features/infra-3d/Infra3DView.vue      | 232 +++++++++++++++++----
 apps/ui/src/features/infra-3d/PipelineTimeline.vue |   8 +-
 .../infra-3d/composables/useInfra3dPipeline.ts     |  13 +-
 .../infra-3d/composables/useLiveTopology.ts        | 204 ++++++++++++++++++
 apps/ui/src/shell/AppTopbar.vue                    |  29 +--
 8 files changed, 481 insertions(+), 79 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf6bd9b..211d569 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -517,6 +517,13 @@ link into that layer's dashboard.
   the active scopes (`metrics 2h · alarms 20m · ↻ 1m`). An alarmed cube
   burns red with a radiating ripple, matched to its service by (layer,
   name) so only the firing service in the right tier is flagged.
+- **Live topology.** The deployment structure is read live from OAP rather
+  than a bundled snapshot: each layer's service roster and service map are
+  fetched one at a time (low concurrency) and assembled into the scene, so
+  the map is correct on any deployment. It refreshes on the same one-minute
+  cycle — an unchanged structure updates metrics/alarms in place without
+  disturbing the camera, while a service appearing or disappearing rebuilds
+  the affected tier. The load progresses stage by stage in the status strip.
 - **Beacon mode.** A toolbar toggle dims every healthy cube to a
   wireframe ghost and lets only alarming cubes glow, so the services
   that are firing jump out instantly during an incident.
diff --git a/apps/ui/src/features/infra-3d/CLAUDE.md 
b/apps/ui/src/features/infra-3d/CLAUDE.md
index 2633b1f..d5d0bd0 100644
--- a/apps/ui/src/features/infra-3d/CLAUDE.md
+++ b/apps/ui/src/features/infra-3d/CLAUDE.md
@@ -474,10 +474,21 @@ raycaster's bounding box mid-pointer-event and made 
clicks miss /
 require a second tap). Same single `BoxGeometry` for every cube;
 every transition is a property change on the existing mesh.
 
+## Live data vs. the snapshot
+
+The scene now reads its structure live from OAP by default: the pipeline's
+`services` / `topologies` stages fetch each layer's roster + service map one
+at a time (`useLiveTopology.ts`), assembling a `DemoTopology` that
+`buildSceneGraph` consumes exactly like the snapshot. `data/demo-topology.json`
+remains the **fallback** — rendered until the first sequential load lands, if
+the load comes back empty, or when `?live=0` forces it for comparison. The
+scene is re-keyed on a per-layer structure hash so an unchanged refresh keeps
+the camera; only a roster/edge change rebuilds. The cross-layer hierarchy
+(`hierarchy`) is still left empty in live mode — Smartscape peers are a later,
+per-service fetch.
+
 ## What this PoC is NOT
 
-- **Not** wired to live OAP data yet. Demo data is a snapshot committed
-  in `data/demo-topology.json`.
 - **Not** showing cross-plane edges as static geometry — that's a
   future interaction.
 - **Not** persisting visibility / camera state across reloads.
diff --git a/apps/ui/src/features/infra-3d/Infra3DScene.vue 
b/apps/ui/src/features/infra-3d/Infra3DScene.vue
index e92a974..3b0fc3f 100644
--- a/apps/ui/src/features/infra-3d/Infra3DScene.vue
+++ b/apps/ui/src/features/infra-3d/Infra3DScene.vue
@@ -59,6 +59,7 @@ import {
 import {
   buildSceneGraph,
   loadDemoTopology,
+  type DemoTopology,
   type SceneCallEdge,
   type SceneCrossLayerEdge,
   type SceneHierarchyEdge,
@@ -116,6 +117,11 @@ interface Props {
    *  layer with a rule yielding ≥2 clusters is laid out cluster-by-
    *  cluster (k8s/mesh namespace grouping), matching the 2D map. */
   namingByLayer?: Record<string, ServiceNamingRule | null>;
+  /** Live-assembled topology to render instead of the committed snapshot.
+   *  Null/absent ⇒ the snapshot. The build below runs once at setup, so
+   *  the parent re-keys this component to rebuild when the live structure
+   *  changes. */
+  topology?: DemoTopology | null;
 }
 const props = defineProps<Props>();
 const emit = defineEmits<{
@@ -132,7 +138,7 @@ const emit = defineEmits<{
 // loaded before mounting this component (see Infra3DView.vue), so
 // `levelForLayer` returns deterministic values here, not the synchronous
 // fallback. `planeOrder` is the source of truth for vertical stacking.
-const topo = loadDemoTopology();
+const topo = props.topology ?? loadDemoTopology();
 const graph = buildSceneGraph(topo, levelForLayer);
 const placement = computePlacement(graph, props.planeOrder, props.groups, 
props.namingByLayer);
 
@@ -1256,18 +1262,42 @@ watch(
 // The initial pose is applied via `:position` / `:target` on first
 // mount; after that, all updates go through the refs.
 
+// Initial camera heading — azimuth 15° off front, 62° polar from vertical
+// (matching OrbitControls' getAzimuthal/getPolarAngle), tuned on the
+// showcase to read the stacked tiers. Scale-invariant; the reset AND the
+// side-panel focus reuse it so every camera move keeps the same angle.
+const CAMERA_AZ = (15 * Math.PI) / 180;
+const CAMERA_POL = (62 * Math.PI) / 180;
+/** Unit camera→position offset direction for the tuned heading. */
+function headingDir(): Vector3 {
+  const s = Math.sin(CAMERA_POL);
+  return new Vector3(s * Math.sin(CAMERA_AZ), Math.cos(CAMERA_POL), s * 
Math.cos(CAMERA_AZ));
+}
+
 function defaultCameraPos(): [number, number, number] {
   const b = placement.bounds;
-  const cx = (b.minX + b.maxX) / 2;
-  const cz = (b.minZ + b.maxZ) / 2;
+  const [tx, ty, tz] = defaultTargetPos();
   const spanXZ = Math.max(b.maxX - b.minX, b.maxZ - b.minZ);
-  const spanY = Math.max(8, b.maxY - b.minY);
-  const radius = spanXZ * 1.0 + 6;
-  return [cx + radius * 0.8, b.minY + spanY * 0.55 + radius * 0.55, cz + 
radius * 0.8];
+  // Distance scales with the scene footprint so larger / smaller deployments
+  // keep the same fill.
+  const dist = spanXZ * 0.97 + 6;
+  const o = headingDir().multiplyScalar(dist);
+  return [tx + o.x, ty + o.y, tz + o.z];
 }
 function defaultTargetPos(): [number, number, number] {
   const b = placement.bounds;
-  return [(b.minX + b.maxX) / 2, b.minY + (b.maxY - b.minY) * 0.4, (b.minZ + 
b.maxZ) / 2];
+  const cx = (b.minX + b.maxX) / 2;
+  const cz = (b.minZ + b.maxZ) / 2;
+  const spanXZ = Math.max(b.maxX - b.minX, b.maxZ - b.minZ);
+  // Shift the look-point along the camera's screen-right axis (cos az, 0,
+  // -sin az) so the scene sits left-of-centre, clear of the right-hand
+  // TIERS panel.
+  const shift = spanXZ * 0.1;
+  return [
+    cx + Math.cos(CAMERA_AZ) * shift,
+    b.minY + (b.maxY - b.minY) * 0.4,
+    cz - Math.sin(CAMERA_AZ) * shift,
+  ];
 }
 const initialCameraPos = defaultCameraPos();
 const initialTarget = defaultTargetPos();
@@ -1373,13 +1403,13 @@ function resetView(): void {
   c.update();
 }
 
-/** Glide the camera to face `target` from the default isometric heading,
- *  zoomed to fit `radius` (side panel — moves, doesn't pivot in place). */
+/** Glide the camera to face `target` from the tuned heading (same angle as
+ *  the default / reset), zoomed to fit `radius`. Side panel — moves, doesn't
+ *  pivot in place. */
 function focusOn(t: { x: number; y: number; z: number }, radius: number): void 
{
   const target = new Vector3(t.x, t.y, t.z);
-  const dir = new Vector3(0.62, 0.62, 0.62).normalize();
   const dist = Math.min(220, Math.max(9, radius * 2.6 + 6));
-  camGoal.value = { target, pos: target.clone().addScaledVector(dir, dist) };
+  camGoal.value = { target, pos: target.clone().addScaledVector(headingDir(), 
dist) };
 }
 function flashZones(layerKeys: string[]): void {
   flashState.value = { keys: new Set(layerKeys), start: sceneElapsed };
diff --git a/apps/ui/src/features/infra-3d/Infra3DView.vue 
b/apps/ui/src/features/infra-3d/Infra3DView.vue
index 4a83a10..c971488 100644
--- a/apps/ui/src/features/infra-3d/Infra3DView.vue
+++ b/apps/ui/src/features/infra-3d/Infra3DView.vue
@@ -22,7 +22,7 @@
   operator can toggle whole tiers or individual zones.
 -->
 <script setup lang="ts">
-import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
+import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
 import { useRoute } from 'vue-router';
 import { useLayers } from '@/shell/useLayers';
 import type { ServiceNamingRule } from '@skywalking-horizon-ui/api-client';
@@ -31,6 +31,7 @@ import PipelineTimeline from './PipelineTimeline.vue';
 import {
   buildSceneGraph,
   loadDemoTopology,
+  type DemoTopology,
   type SceneServiceNode,
 } from './composables/useDemoTopology';
 import {
@@ -43,6 +44,14 @@ import logoSw from '@/assets/icons/logo-sw.svg?raw';
 import { useInfra3dConfig } from './composables/useInfra3dConfig';
 import { useInfra3dPipeline, type PipelineStageId, type StageImpl } from 
'./composables/useInfra3dPipeline';
 import { setValues as setMetricValues, setUnitForLayer, reset as resetMetrics 
} from './composables/useInfra3dMetrics';
+import {
+  isTopologyBearing,
+  liveRoster,
+  liveSkeleton,
+  loadLiveServices,
+  loadLiveTopologies,
+  type LiveWindow,
+} from './composables/useLiveTopology';
 import { bff, type Infra3dConfig } from '@/api/client';
 
 /** Imperative handle on the scene's camera-control methods. The
@@ -139,7 +148,80 @@ const { stages, stageOrder, running: pipelineRunning, run: 
runPipelineState } =
 
 interface PipelineCtx {
   servicesByLayer: Record<string, SceneServiceNode[]>;
+  /** Live path only: the DemoTopology assembled as stages land. The
+   *  snapshot impls leave it null and read loadDemoTopology() directly. */
+  topo: DemoTopology | null;
+}
+
+// Live data is the default. `?live=0` forces the committed snapshot (a
+// debug / comparison escape hatch). `liveTopo` holds the latest sequential
+// assembly, published atomically once a run has the full structure so the
+// scene renders complete (see sceneReady), never piecemeal.
+const liveTopologyEnabled = computed(() => route.query.live !== '0');
+const liveTopo = shallowRef<DemoTopology | null>(null);
+const liveWindow = (): LiveWindow => ({
+  startMs: Date.now() - 2 * 3600_000,
+  endMs: Date.now(),
+  step: 'HOUR',
+});
+
+// Topology the scene renders. The scene builds its graph once at setup, so
+// it is re-keyed on a STRUCTURE hash (per-layer service rosters + edge
+// counts): a 60s refresh that finds the same structure leaves the key
+// unchanged — no remount, so the camera and metric/alarm visuals persist —
+// while a service appearing / disappearing rebuilds the scene.
+const sceneTopology = computed<DemoTopology | null>(() => {
+  if (!liveTopologyEnabled.value) return null;
+  const t = liveTopo.value;
+  if (!t || Object.keys(t.servicesByLayer).length === 0) return null;
+  return t;
+});
+const sceneKey = computed(() => {
+  const naming = namingReady.value ? 'n' : '-';
+  const t = sceneTopology.value;
+  if (!t) return `${naming}:snapshot`;
+  const struct = t.layers
+    .map((L) => {
+      const ids = (t.servicesByLayer[L.key] ?? []).map((s) => s.id).sort();
+      const calls = t.topologies[L.key]?.calls.length ?? 0;
+      return `${L.key}:${calls}:${ids.join(',')}`;
+    })
+    .join('|');
+  return `${naming}:${struct}`;
+});
+
+// Render gate: in live mode hold the scene until the first full assembly
+// lands so it appears complete, not piecemeal. Snapshot mode (?live=0) is
+// ready immediately.
+const sceneReady = computed(() => !liveTopologyEnabled.value || liveTopo.value 
!== null);
+
+// Refresh modes. Landing + manual refresh run the FULL pipeline (templates
+// + layout/clustering resolve here, once). The 60s auto-refresh runs LIGHT
+// — services + topologies + metrics only — reusing templates/layout from
+// the last full run. `knownLayers` is the template set captured at the last
+// full run; a layer that appears only on a light refresh has no template,
+// so its services are hidden until the next full (manual / page) refresh.
+type PipelineMode = 'full' | 'light';
+const pipelineMode = ref<PipelineMode>('full');
+const knownLayers = shallowRef<Set<string> | null>(null);
+
+// SceneServiceNode view of a DemoTopology — what the metrics stage and the
+// scene's node grid consume. shortName = last `::` segment, pre-dot.
+function sceneNodesFrom(topo: DemoTopology): Record<string, 
SceneServiceNode[]> {
+  const byLayer: Record<string, SceneServiceNode[]> = {};
+  for (const [key, refs] of Object.entries(topo.servicesByLayer)) {
+    byLayer[key] = refs.map((s) => ({
+      nodeId: `${key.toUpperCase()}::${s.id}`,
+      layerKey: key,
+      serviceId: s.id,
+      name: s.name,
+      shortName: s.name.split('::').slice(-1)[0]!.split('.')[0]!,
+      normal: s.normal,
+    }));
+  }
+  return byLayer;
 }
+
 const pipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = {
   services: async (rep, ctx) => {
     rep.start();
@@ -341,9 +423,88 @@ const pipelineImpls: Record<PipelineStageId, 
StageImpl<PipelineCtx>> = {
   },
 };
 
-async function runPipeline(): Promise<void> {
-  const ctx: PipelineCtx = { servicesByLayer: {} };
-  await runPipelineState(ctx, pipelineImpls);
+// Live variant of the pipeline: `services` / `topologies` read from
+// sequential per-layer OAP queries (assembled into a DemoTopology); the
+// `liveTopo` ref is published atomically at the END of `topologies`, so the
+// scene renders the full structure once. `templates` / `layout` resolve
+// once per FULL run (skipped on light auto-refresh). `metrics` is shared.
+const livePipelineImpls: Record<PipelineStageId, StageImpl<PipelineCtx>> = {
+  services: async (rep, ctx) => {
+    const roster = liveRoster(menuLayers.value);
+    const known = knownLayers.value;
+    const light = pipelineMode.value === 'light' && known !== null;
+    // Light refresh renders only layers known at the last full load. A layer
+    // that appeared since has no template — hide its services until the next
+    // full (manual / page) refresh, and surface it in the stage detail.
+    const active = light ? roster.filter((L) => known!.has(L.key)) : roster;
+    const hidden = light ? roster.filter((L) => !known!.has(L.key)).map((L) => 
L.key) : [];
+    const topo = liveSkeleton(active);
+    await loadLiveServices(rep, active, topo, hidden);
+    ctx.topo = topo;
+    ctx.servicesByLayer = sceneNodesFrom(topo);
+  },
+  templates: async (rep, ctx) => {
+    rep.start();
+    const rosterKeys = (ctx.topo?.layers ?? []).map((l) => l.key);
+    // The template set — captured once per full run. Light refreshes hide
+    // any layer outside it (no template loaded for it yet).
+    knownLayers.value = new Set(rosterKeys);
+    const withTopology = menuLayers.value
+      .filter((L) => knownLayers.value!.has(L.key) && isTopologyBearing(L))
+      .map((L) => L.key);
+    const withoutTopology = rosterKeys.filter((k) => 
!withTopology.includes(k));
+    rep.ok(`${withTopology.length} topology-bearing`, {
+      kind: 'templates',
+      layersWithTopology: withTopology,
+      layersWithoutTopology: withoutTopology,
+    });
+  },
+  topologies: async (rep, ctx) => {
+    const topo = ctx.topo;
+    if (!topo) {
+      rep.ok('no topology', { kind: 'topologies', probes: [] });
+      return;
+    }
+    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());
+    // Publish the complete structure (services + topology) in one shot.
+    liveTopo.value = topo;
+  },
+  layout: async (rep, ctx) => {
+    rep.start();
+    const t0 = performance.now();
+    const g = buildSceneGraph(ctx.topo ?? loadDemoTopology(), levelForLayer);
+    const p = computePlacement(g, planeOrder.value, infraGroups.value);
+    const ms = Math.round(performance.now() - t0);
+    rep.ok(`${p.zones.length} zones laid in ${ms} ms`, {
+      kind: 'layout',
+      layersReLaid: p.zones.length,
+      ms,
+    });
+  },
+  // Shared with the snapshot pipeline: it reads ctx.servicesByLayer, which
+  // both services stages populate with the same SceneServiceNode[] shape.
+  metrics: pipelineImpls.metrics,
+};
+
+// FULL run — landing + manual refresh. Resolves templates + layout/clustering
+// and captures the known-layer set.
+async function runFull(): Promise<void> {
+  pipelineMode.value = 'full';
+  const ctx: PipelineCtx = { servicesByLayer: {}, topo: null };
+  await runPipelineState(ctx, liveTopologyEnabled.value ? livePipelineImpls : 
pipelineImpls);
+}
+
+// LIGHT run — the 60s auto-refresh. Re-reads services + topologies + metrics
+// only; templates + layout keep their last full-run state in the strip.
+// Snapshot mode (?live=0) has no cheaper path, so it re-runs the full
+// in-memory pipeline.
+async function runLight(): Promise<void> {
+  if (!liveTopologyEnabled.value) return runFull();
+  pipelineMode.value = 'light';
+  const ctx: PipelineCtx = { servicesByLayer: {}, topo: null };
+  await runPipelineState(ctx, livePipelineImpls, ['services', 'topologies', 
'metrics']);
 }
 
 /** Arm (or re-arm) the auto-refresh timer + countdown anchor. */
@@ -351,15 +512,15 @@ function scheduleRefresh(): void {
   if (refreshTimer !== null) clearTimeout(refreshTimer);
   nextRefreshAt.value = Date.now() + REFRESH_MS;
   refreshTimer = setTimeout(() => {
-    void runPipeline();
+    void runLight();
     scheduleRefresh();
   }, REFRESH_MS);
 }
 
-/** Manual refresh from the timeline strip — run now and reset the
- *  countdown so the next auto-refresh is a full interval away. */
+/** Manual refresh from the timeline strip — a FULL reload (re-checks
+ *  templates + layout) and resets the countdown. */
 function refreshNow(): void {
-  void runPipeline();
+  void runFull();
   scheduleRefresh();
 }
 
@@ -543,8 +704,8 @@ function onKeyDown(e: KeyboardEvent): void {
       return;
   }
   e.preventDefault();
-  // Shift = bigger step (3×) for fast traverse across the scene.
-  const factor = e.shiftKey ? 3 : 1;
+  // Half-step pan by default; Shift = 3× for fast traverse.
+  const factor = e.shiftKey ? 1.5 : 0.5;
   sceneRef.value?.pan(rx * factor, uy * factor);
 }
 
@@ -576,11 +737,11 @@ onMounted(async () => {
     setTimeout(() => { stop(); resolve(); }, 4000);
   });
   ready.value = true;
-  // Kick the loading pipeline once, then refresh every minute. Alarms
-  // poll on their own 1-min timer (20m window); metrics ride the
-  // pipeline (2h @ HOUR). The timeline strip's refresh button still
-  // forces an immediate run.
-  void runPipeline();
+  // Full load once at landing (templates + layout + structure), then a
+  // light refresh every minute (services + topologies + metrics). Alarms
+  // poll on their own 1-min timer (20m window). The strip's refresh button
+  // forces an immediate full run.
+  void runFull();
   scheduleRefresh();
 });
 onUnmounted(() => {
@@ -644,17 +805,6 @@ function onPanelZoneFocus(zoneKey: string): void {
   sceneRef.value?.flashZones(zoneLayerKeys(z));
 }
 
-const totalServices = computed(() =>
-  Object.values(nodesByLayer.value).reduce((acc, arr) => acc + arr.length, 0),
-);
-const visibleServices = computed(() => {
-  let n = 0;
-  for (const [k, arr] of Object.entries(nodesByLayer.value)) {
-    if (visibleLayers.value.has(k)) n += arr.length;
-  }
-  return n;
-});
-
 </script>
 
 <template>
@@ -678,19 +828,9 @@ const visibleServices = computed(() => {
           <span class="hint">apps · service mesh · middleware · infra · drag 
to rotate · scroll to zoom · arrow keys / WASD to pan</span>
         </template>
       </div>
+      <!-- Counts + query-scope live in the bottom status strip; the
+           header keeps only the title and the exit affordance. -->
       <div class="stats">
-        <span class="stat">
-          <strong>{{ visibleServices }}</strong> / {{ totalServices }} services
-        </span>
-        <span class="stat">
-          <strong>{{ zones.length }}</strong> layers · <strong>{{ 
planes.length }}</strong> levels
-        </span>
-        <!-- Query scopes so the operator knows what window each signal
-             reflects: topology/metrics roll up the last 2h (HOUR step),
-             alarms the last 20m; everything refreshes each minute. -->
-        <span class="stat scope" title="Topology &amp; metrics: last 2h (HOUR 
step) · Alarms: last 20m · auto-refresh every 1 min">
-          metrics <strong>2h</strong> · alarms <strong>20m</strong> · ↻ 
<strong>1m</strong>
-        </span>
         <router-link class="back" to="/" title="Exit 3D map">×</router-link>
       </div>
     </header>
@@ -703,11 +843,17 @@ const visibleServices = computed(() => {
         <router-link class="cfg-error__back" to="/">← Back</router-link>
       </div>
       <div v-else-if="!ready" class="cfg-loading">Loading 3D map 
configuration…</div>
+      <!-- Hold the render until the FULL context is in hand (layers,
+           templates, services, topology, clustering) so the scene appears
+           once, complete — never a partial layout that rebuilds as data
+           lands. The bottom strip shows which stage is in flight. -->
+      <div v-else-if="!sceneReady" class="cfg-loading">Reading topology from 
OAP…</div>
       <Infra3DScene
         v-else
-        :key="namingReady ? 'with-naming' : 'no-naming'"
+        :key="sceneKey"
         ref="sceneRef"
         :plane-order="planeOrder"
+        :topology="sceneTopology"
         :visible-layers="visibleLayers"
         :hovered-node-id="hoveredNodeId"
         :selected-node-id="selectedNodeId"
@@ -726,7 +872,7 @@ const visibleServices = computed(() => {
       <!-- Top-left camera-control toolbar. Mouse rotate/zoom/pan still
            work; these buttons give explicit affordances for the same
            gestures (useful on trackpads + as a discoverability cue). -->
-      <aside class="cam-tools">
+      <aside v-if="sceneReady" class="cam-tools">
         <div class="cam-row">
           <button class="cam-btn" title="zoom in" @click="btnZoomIn">+</button>
           <button class="cam-btn" title="zoom out" 
@click="btnZoomOut">−</button>
@@ -747,6 +893,7 @@ const visibleServices = computed(() => {
       <!-- Beacon mode toggle — dims the scene to wireframe so only
            alarming cubes stand out. Sits under the camera toolbar. -->
       <button
+        v-if="sceneReady"
         class="beacon-toggle"
         :class="{ 'is-on': beaconMode }"
         :title="beaconMode ? 'Beacon mode on — click to show all' : 'Beacon 
mode — focus on alarms'"
@@ -758,7 +905,7 @@ const visibleServices = computed(() => {
 
       <!-- Side panel: tier → layer/logic-group. Click focuses + flashes
            that region; the eye toggles the tier. -->
-      <aside class="layer-panel">
+      <aside v-if="sceneReady" class="layer-panel">
         <div class="panel-head">
           <span>Tiers</span>
           <button type="button" class="panel-reset" title="Reset the view to 
the default framing" @click="btnReset">⌂ Reset</button>
@@ -903,7 +1050,8 @@ const visibleServices = computed(() => {
   overflow: hidden;
 }
 
-/* SkyWalking brand — bottom-left, sits above the timeline strip's z.
+/* SkyWalking brand — bottom-left. Sits below the timeline (z 80) so the
+   stage-detail drawer, which expands up over this corner, is not blocked.
    Subtle glass background so it reads on bright cube tints behind it. */
 .sw-brand {
   position: absolute;
diff --git a/apps/ui/src/features/infra-3d/PipelineTimeline.vue 
b/apps/ui/src/features/infra-3d/PipelineTimeline.vue
index 56e6c99..e11a41d 100644
--- a/apps/ui/src/features/infra-3d/PipelineTimeline.vue
+++ b/apps/ui/src/features/infra-3d/PipelineTimeline.vue
@@ -135,6 +135,10 @@ const openStageState = computed<ROStageState | null>(() => 
{
               <b>removed since last run</b><span>{{ 
openStageState.detail.removedSince }}</span>
             </li>
           </ul>
+          <details v-if="(openStageState.detail.hiddenNoTemplate?.length ?? 0) 
> 0">
+            <summary>hidden — no layer template (refresh to load)</summary>
+            <code class="layer-list">{{ 
openStageState.detail.hiddenNoTemplate?.join(', ') }}</code>
+          </details>
         </template>
 
         <!-- Templates -->
@@ -249,7 +253,9 @@ const openStageState = computed<ROStageState | null>(() => {
   background: rgba(15, 19, 26, 0.92);
   border-top: 1px solid var(--sw-line);
   backdrop-filter: blur(6px);
-  z-index: 60;
+  /* Above the bottom-left brand mark (z 70) so the stage-detail drawer,
+   *  which expands up into the brand's corner, is never occluded by it. */
+  z-index: 80;
 }
 
 /* Strip */
diff --git a/apps/ui/src/features/infra-3d/composables/useInfra3dPipeline.ts 
b/apps/ui/src/features/infra-3d/composables/useInfra3dPipeline.ts
index a19fd13..ee7399b 100644
--- a/apps/ui/src/features/infra-3d/composables/useInfra3dPipeline.ts
+++ b/apps/ui/src/features/infra-3d/composables/useInfra3dPipeline.ts
@@ -72,7 +72,7 @@ export interface StageState {
 }
 
 export type StageDetail =
-  | { kind: 'services'; servicesTotal: number; layersTotal: number; 
addedSince: number | null; removedSince: number | null }
+  | { kind: 'services'; servicesTotal: number; layersTotal: number; 
addedSince: number | null; removedSince: number | null; hiddenNoTemplate?: 
string[] }
   | { kind: 'templates'; layersWithTopology: string[]; layersWithoutTopology: 
string[] }
   | { kind: 'topologies'; probes: TopologyProbe[] }
   | { kind: 'layout'; layersReLaid: number; ms: number }
@@ -153,11 +153,17 @@ export type StageImpl<C> = (reporter: StageReporter, ctx: 
C) => Promise<void>;
  *  Cancellation: re-invoking `run()` while one is in-flight is a
  *  no-op; the caller should debounce or wait. The current call's
  *  signal is exposed via `running`. */
-export async function run<C>(ctx: C, impls: Record<PipelineStageId, 
StageImpl<C>>): Promise<void> {
+export async function run<C>(
+  ctx: C,
+  impls: Record<PipelineStageId, StageImpl<C>>,
+  only?: PipelineStageId[],
+): Promise<void> {
   if (running.value) return;
   running.value = true;
   completedAt.value = null;
-  stages.value = initialState();
+  // A full run resets every stage; a partial (light) run runs only `only`
+  // and leaves the unselected stages showing their last full-run result.
+  if (!only) stages.value = initialState();
   await ensureInfraConfigLoaded().catch(() => {
     // Config load is a hard prereq; without it the scene can't even
     // mount, so a failure here should still let the pipeline fail
@@ -166,6 +172,7 @@ export async function run<C>(ctx: C, impls: 
Record<PipelineStageId, StageImpl<C>
   });
   try {
     for (const id of STAGE_ORDER) {
+      if (only && !only.includes(id)) continue;
       const reporter = reporterFor(id);
       try {
         await impls[id](reporter, ctx);
diff --git a/apps/ui/src/features/infra-3d/composables/useLiveTopology.ts 
b/apps/ui/src/features/infra-3d/composables/useLiveTopology.ts
new file mode 100644
index 0000000..2814826
--- /dev/null
+++ b/apps/ui/src/features/infra-3d/composables/useLiveTopology.ts
@@ -0,0 +1,204 @@
+/*
+ * 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.
+ */
+
+/**
+ * Live assembly of the 3D-map topology — the sequential, low-concurrency
+ * counterpart to the committed `demo-topology.json` snapshot.
+ *
+ * The output is a `DemoTopology` byte-compatible with `loadDemoTopology()`,
+ * so `buildSceneGraph` consumes either source unchanged. It is built one
+ * layer at a time (concurrency 1) from OAP: per-layer service rosters
+ * (stage `services`) and per-layer service maps (stage `topologies`).
+ *
+ * Phase-1 scope: cross-layer call edges fall out of `buildSceneGraph`
+ * itself (it derives them from cross-layer entries in `topologies[].calls`),
+ * so nothing here pre-computes them; `hierarchy` stays `[]` (the Smartscape
+ * peers are a later phase, fetched per-service). The reporter wiring is
+ * split across the two stages by the caller so each owns its own
+ * `StageReporter` — see Infra3DView's live pipeline impls.
+ */
+
+import type { LayerDef } from '@skywalking-horizon-ui/api-client';
+import { bff } from '@/api/client';
+import type {
+  DemoLayer,
+  DemoLayerTopology,
+  DemoServiceRef,
+  DemoTopology,
+  DemoTopologyCall,
+} from './useDemoTopology';
+import type { StageReporter, TopologyProbe } from './useInfra3dPipeline';
+
+export interface LiveWindow {
+  startMs: number;
+  endMs: number;
+  step: 'MINUTE' | 'HOUR' | 'DAY';
+}
+
+/** A layer ships a service-map iff OAP advertises the `serviceMap` cap —
+ *  the same signal that gates the 2D Service Map page and the only layers
+ *  `bff.layer.topology` returns edges for. */
+export function isTopologyBearing(L: LayerDef): boolean {
+  return L.caps.serviceMap === true;
+}
+
+/** Active layers with at least one reporting service, tier-ordered (level
+ *  asc, then key) to match the snapshot's ordering. `serviceCount` of -1
+ *  (OAP unreachable) or 0 is dropped so the scene never gains empty zones. */
+export function liveRoster(layers: LayerDef[]): LayerDef[] {
+  return layers
+    .filter((L) => L.active && L.serviceCount > 0)
+    .slice()
+    .sort(
+      (a, b) =>
+        (a.level ?? Number.MAX_SAFE_INTEGER) - (b.level ?? 
Number.MAX_SAFE_INTEGER) ||
+        a.key.localeCompare(b.key),
+    );
+}
+
+function toDemoLayer(L: LayerDef): DemoLayer {
+  return {
+    key: L.key,
+    name: L.name,
+    level: L.level,
+    group: L.group ?? null,
+    serviceCount: L.serviceCount,
+    color: L.color,
+  };
+}
+
+/** Layers-only skeleton; `servicesByLayer` / `topologies` fill in as the
+ *  sequential stages land. `hierarchy` stays `[]` in Phase 1. */
+export function liveSkeleton(roster: LayerDef[]): DemoTopology {
+  return {
+    capturedAt: new Date().toISOString(),
+    oapDemo: 'live',
+    layers: roster.map(toDemoLayer),
+    servicesByLayer: {},
+    hierarchy: [],
+    topologies: {},
+  };
+}
+
+/** Stage `services` — read each layer's roster from OAP one at a time,
+ *  reporting per-layer progress. Mutates `topo.servicesByLayer` and returns
+ *  the running service total. An unreachable layer contributes nothing
+ *  (never a fabricated row) and is logged, not thrown — a single failure
+ *  must not abort the pipeline. */
+export async function loadLiveServices(
+  rep: StageReporter,
+  roster: LayerDef[],
+  topo: DemoTopology,
+  hiddenNoTemplate: string[] = [],
+): Promise<number> {
+  rep.start();
+  let total = 0;
+  for (let i = 0; i < roster.length; i++) {
+    const L = roster[i]!;
+    rep.progress(`reading ${L.key} services (${i + 1}/${roster.length})`, {
+      kind: 'services',
+      servicesTotal: total,
+      layersTotal: Object.keys(topo.servicesByLayer).length,
+      addedSince: null,
+      removedSince: null,
+      hiddenNoTemplate,
+    });
+    try {
+      const resp = await bff.layer.services(L.key);
+      if (resp.reachable && resp.services.length > 0) {
+        const refs: DemoServiceRef[] = resp.services.map((s) => ({
+          id: s.id,
+          name: s.name,
+          normal: s.normal ?? true,
+        }));
+        topo.servicesByLayer[L.key] = refs;
+        total += refs.length;
+      }
+    } catch (err) {
+      console.warn(`[infra-3d] live services failed for ${L.key}:`, err);
+    }
+  }
+  const layerCount = Object.keys(topo.servicesByLayer).length;
+  const summary =
+    hiddenNoTemplate.length > 0
+      ? `${total} services / ${layerCount} layers · ${hiddenNoTemplate.length} 
hidden (no template)`
+      : `${total} services / ${layerCount} layers`;
+  rep.ok(summary, {
+    kind: 'services',
+    servicesTotal: total,
+    layersTotal: layerCount,
+    addedSince: null,
+    removedSince: null,
+    hiddenNoTemplate,
+  });
+  return total;
+}
+
+/** Stage `topologies` — pull each topology-bearing layer's service map one
+ *  at a time. Builds a `DemoLayerTopology` (calls + faithful-but-inert nodes)
+ *  and a per-layer `TopologyProbe`. Per-layer try/catch so one failure
+ *  records a `failed` probe and continues. */
+export async function loadLiveTopologies(
+  rep: StageReporter,
+  topoLayers: LayerDef[],
+  topo: DemoTopology,
+  window: LiveWindow,
+): Promise<TopologyProbe[]> {
+  rep.start();
+  const probes: TopologyProbe[] = [];
+  for (let i = 0; i < topoLayers.length; i++) {
+    const L = topoLayers[i]!;
+    rep.progress(`fetching ${L.key} topology (${i + 1}/${topoLayers.length})`, 
{
+      kind: 'topologies',
+      probes,
+    });
+    const t0 = performance.now();
+    try {
+      const resp = await bff.layer.topology(L.key, undefined, 1, window);
+      const map: DemoLayerTopology = {
+        nodes: resp.nodes.map((n) => ({ id: n.id, name: n.name, layer: L.key 
})),
+        calls: resp.calls.map<DemoTopologyCall>((c) => ({
+          source: c.source,
+          target: c.target,
+          detectPoints: c.detectPoints,
+        })),
+      };
+      topo.topologies[L.key] = map;
+      probes.push({
+        layerKey: L.key,
+        status: resp.reachable ? (map.calls.length > 0 ? 'ok' : 'empty') : 
'failed',
+        ms: Math.round(performance.now() - t0),
+        nodeCount: map.nodes.length,
+        edgeCount: map.calls.length,
+        error: resp.reachable ? undefined : resp.error,
+      });
+    } catch (err) {
+      probes.push({
+        layerKey: L.key,
+        status: 'failed',
+        ms: Math.round(performance.now() - t0),
+        error: err instanceof Error ? err.message : String(err),
+      });
+    }
+  }
+  const okCount = probes.filter((p) => p.status === 'ok').length;
+  const failed = probes.filter((p) => p.status === 'failed').length;
+  const detail = { kind: 'topologies' as const, probes };
+  if (failed > 0) rep.warn(`${okCount} maps with edges · ${failed} failed`, 
detail);
+  else rep.ok(`${okCount} maps with edges`, detail);
+  return probes;
+}
diff --git a/apps/ui/src/shell/AppTopbar.vue b/apps/ui/src/shell/AppTopbar.vue
index 0ffff79..742bab6 100644
--- a/apps/ui/src/shell/AppTopbar.vue
+++ b/apps/ui/src/shell/AppTopbar.vue
@@ -85,7 +85,7 @@ const route = useRoute();
 
 /** The 3D Infra Map entry pill lives in the topbar on every page. It
  *  stays compact ("3D Infra") by default and expands to the full
- *  "3D Infra Map · EXPERIMENTAL" wordmark ONLY on hover — including
+ *  "3D Infrastructure Map" wordmark ONLY on hover — including
  *  while the operator is on /3d/map itself. The 3D route already
  *  collapses the sidebar to give the canvas every horizontal pixel;
  *  keeping the topbar pill compact in 3D mode matches that intent
@@ -495,16 +495,16 @@ function formatRangeStamp(ms: number, step: TimeStep): 
string {
       <small>Horizon</small>
     </RouterLink>
     <!-- 3D Infra Map entry point — always-on in the topbar. Compact
-         "3D Infra" by default; expands to "3D Infra Map EXPERIMENTAL"
-         on hover or while the operator is already on /3d/map. The
-         stacked-tier icon mirrors the page's three planes (apps /
+         "3D Infra" by default; expands to the full "3D Infra Map"
+         wordmark on hover or while the operator is already on /3d/map.
+         The stacked-tier icon mirrors the page's three planes (apps /
          mesh / infra) using the same tint colors so the pill reads
          as a microcosm of the view it leads to. -->
     <RouterLink
       to="/3d/map"
       class="exp-badge"
       :class="{ 'is-on': exp3dExpanded }"
-      aria-label="3D Infra Map — experimental"
+      aria-label="3D Infra Map"
       @mouseenter="exp3dHover = true"
       @mouseleave="exp3dHover = false"
     >
@@ -529,8 +529,7 @@ function formatRangeStamp(ms: number, step: TimeStep): 
string {
         <path d="M12 13 L21 17 L12 21 L3 17 Z" fill="url(#expTierMesh)" 
opacity="0.18" />
         <path d="M12 16 L21 20 L12 24 L3 20 Z" fill="url(#expTierInfra)" />
       </svg>
-      <span class="exp-name">{{ exp3dExpanded ? '3D Infra Map' : '3D Infra' 
}}</span>
-      <span v-if="exp3dExpanded" class="exp-tag">EXPERIMENTAL</span>
+      <span class="exp-name">{{ exp3dExpanded ? '3D Infrastructure Map' : '3D 
Infra' }}</span>
     </RouterLink>
     <div class="sw-top-spacer" />
     <div class="sw-top-actions">
@@ -798,9 +797,9 @@ function formatRangeStamp(ms: number, step: TimeStep): 
string {
 }
 
 /* ── 3D Infra Map entry pill ────────────────────────────────────────
-   Always-present topbar affordance for the experimental 3D view.
-   Compact "3D Infra" by default; on hover OR while on /3d/map it
-   swaps content to the full "3D Infra Map" + "EXPERIMENTAL" tag
+   Always-present topbar affordance for the 3D view. Compact "3D Infra"
+   by default; on hover OR while on /3d/map it swaps content to the full
+   "3D Infrastructure Map" wordmark
    (driven from the `.is-on` class — see exp3dExpanded in <script>).
    The container width changes naturally with the rendered text; we
    transition the border/glow/background so the visual emphasis lands
@@ -851,16 +850,6 @@ function formatRangeStamp(ms: number, step: TimeStep): 
string {
   white-space: nowrap;
   line-height: 1;
 }
-.exp-tag {
-  font-size: 9px;
-  font-weight: 800;
-  letter-spacing: 0.18em;
-  color: #fb923c;
-  text-transform: uppercase;
-  white-space: nowrap;
-  margin-left: 2px;
-  line-height: 1;
-}
 
 /* Disabled state for global time-range / refresh chips when the
    current page owns its own time range. Greys out without removing


Reply via email to