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 & 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