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 3c1215d  fix(infra-3d): GPU geometry leak, metrics chunk cap, 
config-load failure state (#28)
3c1215d is described below

commit 3c1215d0ec5ea1dcac0ec2679f53cd2d2ea4cfb1
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Thu May 28 20:35:52 2026 +0800

    fix(infra-3d): GPU geometry leak, metrics chunk cap, config-load failure 
state (#28)
    
    * fix(infra-3d): GPU geometry leak, metrics chunk cap, config-load failure 
state
    
    Address review findings on the 3D Infra Map:
    
    - HIGH — TubeGeometry leak. callTubes / crossTubes / verticalTubes /
      hierarchyTubes mint a fresh TubeGeometry per edge on every recompute
      (layer-visibility toggle, selection change), but only the current
      batch was freed on unmount. Dispose the previous batch when each set
      changes (flush:'post', after the new geometries have rendered).
    
    - MED — metricChunkSize cap mismatch. Admin/validation allowed up to 50
      while the metrics route rejects >12 (OAP complexity ceiling), so any
      oversized chunk 5xx'd. Cap config validation + the admin input at 12.
    
    - MED — config-load failure state. /3d/map's ensureLoaded() had no
      try/catch, so a reject (OAP offline, or a role without infra-3d:read)
      hung on "Loading 3D map configuration…" forever. Catch it and show a
      reason + access hint + Back link instead.
    
    - LOW — remove orphaned .plane-label CSS and a stale "label rendering"
      prop comment (no plane-label render path exists).
    
    Validated: UI + BFF type-check, UI build, eslint clean on touched files.
    
    * fix(infra-3d): keep traffic chips under the chrome panels
    
    The per-cube traffic chip set no cientos z-index-range, so it inherited
    the default (~16.7M max) and a near-camera chip painted over the side
    panel / header (z-index 40-70). Pin the chip range to [35, 1] so it
    sits above the canvas but below all chrome, matching the tooltip/card
    which already force explicit ranges (theirs high, to stay on top).
    
    * feat(infra-3d): red top-cap alarm style + calmer cube colors
    
    - Alarm style: drop the pulsing red beacon sphere/halo; an alarmed cube
      now wears a static red cap over its top ~20% (0.22 of the 1.0-tall
      cube), footprint slightly oversized to clear the cube faces, raycast
      disabled so it can't steal the cube's clicks. Reads as a state, not an
      animation.
    - Cube colors were too saturated against the dark scene (the cache red
      especially). Pull each resolved layer color toward a calmer tone
      (saturation ×0.6, slight lightness bump) for both the default and
      hover cube materials. Side-panel swatches keep the full tint.
---
 apps/bff/src/logic/infra-3d/validate.ts            |   6 +-
 .../features/admin/infra-3d/Infra3dAdminView.vue   |   4 +-
 apps/ui/src/features/infra-3d/Infra3DScene.vue     | 136 +++++++++------------
 apps/ui/src/features/infra-3d/Infra3DView.vue      |  44 ++++++-
 4 files changed, 105 insertions(+), 85 deletions(-)

diff --git a/apps/bff/src/logic/infra-3d/validate.ts 
b/apps/bff/src/logic/infra-3d/validate.ts
index 5a3d7dc..4ddb1bf 100644
--- a/apps/bff/src/logic/infra-3d/validate.ts
+++ b/apps/bff/src/logic/infra-3d/validate.ts
@@ -90,7 +90,11 @@ const configSchema = z
       .strict(),
     pipeline: z
       .object({
-        metricChunkSize: z.number().int().min(1).max(50),
+        // Cap matches the metrics route's MAX_SERVICES (infra-3d-metrics.ts):
+        // each metric chunk is one GraphQL request, and OAP's complexity
+        // ceiling 5xx's beyond 12 services. A larger chunk size makes every
+        // oversized request fail, so reject it at config-save time.
+        metricChunkSize: z.number().int().min(1).max(12),
         topologyConcurrency: z.number().int().min(1).max(16),
         templateConcurrency: z.number().int().min(1).max(32),
       })
diff --git a/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue 
b/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue
index 79871bd..d32f1f9 100644
--- a/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue
+++ b/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue
@@ -726,7 +726,9 @@ function edgeRef(key: 'hierarchy' | 'crossLevelCall' | 
'intraCall'): InfraEdgeSt
           <div class="sect-body">
             <label class="field">
               <span class="lbl">metricChunkSize</span>
-              <input type="number" class="inp" 
v-model.number="draft.pipeline.metricChunkSize" min="1" max="50" />
+              <!-- Capped at 12: the BFF metrics route (MAX_SERVICES) rejects
+                   larger chunks — OAP's GraphQL complexity ceiling 5xx's. -->
+              <input type="number" class="inp" 
v-model.number="draft.pipeline.metricChunkSize" min="1" max="12" />
             </label>
             <label class="field">
               <span class="lbl">topologyConcurrency</span>
diff --git a/apps/ui/src/features/infra-3d/Infra3DScene.vue 
b/apps/ui/src/features/infra-3d/Infra3DScene.vue
index 154088d..14b6a4b 100644
--- a/apps/ui/src/features/infra-3d/Infra3DScene.vue
+++ b/apps/ui/src/features/infra-3d/Infra3DScene.vue
@@ -76,8 +76,8 @@ import { useInfra3dMetrics, formatMetricValue } from 
'./composables/useInfra3dMe
 
 interface Props {
   /** Ordered (top-down) list of planes from the admin config. Drives
-   *  vertical stacking AND label rendering; passed as a prop so the
-   *  Scene stays a pure render of whatever the parent resolved. */
+   *  vertical stacking; passed as a prop so the Scene stays a pure
+   *  render of whatever the parent resolved. */
   planeOrder: PlaneSpec[];
   visibleLayers: Set<string>;
   hoveredNodeId: string | null;
@@ -478,11 +478,25 @@ function zoneMaterial(hex: string): MeshBasicMaterial {
   return m;
 }
 
+// Cubes were reading too saturated against the dark scene (the cache
+// red especially). Pull each resolved layer color toward a calmer tone
+// — drop saturation, nudge lightness up a touch — so the cubes read
+// softer while keeping the hue recognizable next to the side-panel
+// swatches (which stay the full tint). Cloned before mutating; the
+// shared cached Color from colorByHex must not be touched.
+const _hsl = { h: 0, s: 0, l: 0 };
+function mutedCubeColor(hex: string): Color {
+  const c = colorByHex(hex).clone();
+  c.getHSL(_hsl);
+  c.setHSL(_hsl.h, _hsl.s * 0.6, Math.min(1, _hsl.l * 1.06 + 0.03));
+  return c;
+}
+
 const nodeMaterials = new Map<string, MeshLambertMaterial>();
 function nodeMaterial(hex: string): MeshLambertMaterial {
   let m = nodeMaterials.get(hex);
   if (!m) {
-    const base = colorByHex(hex);
+    const base = mutedCubeColor(hex);
     m = new MeshLambertMaterial({ color: base, emissive: 
base.clone().multiplyScalar(0.3) });
     nodeMaterials.set(hex, m);
   }
@@ -498,7 +512,7 @@ const hoverMaterials = new Map<string, 
MeshLambertMaterial>();
 function hoverMaterial(hex: string): MeshLambertMaterial {
   let m = hoverMaterials.get(hex);
   if (!m) {
-    const base = colorByHex(hex);
+    const base = mutedCubeColor(hex);
     m = new MeshLambertMaterial({
       color: base.clone().lerp(new Color(0xffffff), 0.25),
       emissive: base.clone(),
@@ -510,27 +524,18 @@ function hoverMaterial(hex: string): MeshLambertMaterial {
 }
 const selectedMat = new MeshLambertMaterial({ color: 0xf97316, emissive: 
0xf97316, emissiveIntensity: 0.85 });
 
-// Alarm beacon — sits on the top-back corner of the cube as a small
-// pulsing red sphere with a soft halo. Two-piece design:
-//   - inner: solid red sphere with high emissive (the "bulb")
-//   - outer: a larger, mostly-transparent sphere whose opacity pulses
-//     in anti-phase, so the beacon visibly breathes without changing
-//     position or affecting the cube's own material.
-// The cube itself keeps its layer color — operators read the beacon
-// as "an alarm fired on this service" instead of mis-reading a red
-// cube as a different layer.
-const sirenInnerGeometry = new SphereGeometry(0.13, 14, 12);
-const sirenHaloGeometry = new SphereGeometry(0.22, 14, 12);
-const sirenInnerMat = new MeshLambertMaterial({
+// Alarm marker — the top ~20% of an alarmed cube is capped in red.
+// The cube is 1.0 tall, so a 0.22-high flat box at its crown reads as
+// "the top fifth is red". Footprint is a hair larger than the cube
+// (1.32 vs 1.3) so the cap's sides clear the cube faces without
+// z-fighting, and it sits just above the cube's top face. Static red,
+// no pulse — the alert is a state, not an animation; the cube keeps
+// its layer color below the cap.
+const alarmCapGeometry = new BoxGeometry(1.32, 0.22, 1.32);
+const alarmCapMat = new MeshLambertMaterial({
   color: 0xef4444,
   emissive: 0xef4444,
-  emissiveIntensity: 0.95,
-});
-const sirenHaloMat = new MeshBasicMaterial({
-  color: new Color(0xef4444),
-  transparent: true,
-  opacity: 0.35,
-  depthWrite: false,
+  emissiveIntensity: 0.5,
 });
 
 /** Intra-layer call edge — calls between two services in the same
@@ -603,6 +608,20 @@ const hierarchyTubes = computed(() =>
   })),
 );
 
+// Each tube computed mints a fresh TubeGeometry per edge on every
+// recompute (a layer visibility toggle or selection change re-runs the
+// `visible*Edges` deps). The old GPU buffers are otherwise abandoned —
+// only the *current* batch was freed, on unmount. Free the previous
+// batch when the set changes; flush:'post' so the new geometries have
+// already rendered and the old meshes are detached before we dispose.
+function disposeTubeBatch(batch: ReadonlyArray<{ geometry: TubeGeometry }>): 
void {
+  for (const t of batch) t.geometry.dispose();
+}
+watch(callTubes, (_now, prev) => disposeTubeBatch(prev), { flush: 'post' });
+watch(crossTubes, (_now, prev) => disposeTubeBatch(prev), { flush: 'post' });
+watch(verticalTubes, (_now, prev) => disposeTubeBatch(prev), { flush: 'post' 
});
+watch(hierarchyTubes, (_now, prev) => disposeTubeBatch(prev), { flush: 'post' 
});
+
 const crossArrowGeometry = new ConeGeometry(0.18, 0.5, 10);
 
 // ── Animated traffic packets — call edges only. ────────────────────────
@@ -735,15 +754,6 @@ function onSceneLoop(ctx: { elapsed: number; delta: number 
}): void {
     updateSelectedSide();
     updateCloseNodes();
   }
-  // Alarm beacon pulse — one shared sine wave drives the inner bulb's
-  // emissive intensity and the halo's opacity in anti-phase, so the
-  // beacon visibly breathes from any camera angle. Period ~1.2s reads
-  // as urgent without strobing.
-  if (alarmedNodes.value.length > 0) {
-    const pulse = 0.5 + 0.5 * Math.sin(ctx.elapsed * 5.2);
-    sirenInnerMat.emissiveIntensity = 0.55 + pulse * 0.55;
-    sirenHaloMat.opacity = 0.18 + (1 - pulse) * 0.35;
-  }
 }
 
 // ── Pointer interactions ─────────────────────────────────────────────
@@ -1094,10 +1104,8 @@ onUnmounted(() => {
   crossArrowMat.dispose();
   hierarchyMat.dispose();
   crossArrowGeometry.dispose();
-  sirenInnerGeometry.dispose();
-  sirenHaloGeometry.dispose();
-  sirenInnerMat.dispose();
-  sirenHaloMat.dispose();
+  alarmCapGeometry.dispose();
+  alarmCapMat.dispose();
   for (const m of iconStampMaterials.values()) m.dispose();
   disposeLayerIconTextures();
   for (const t of callTubes.value) t.geometry.dispose();
@@ -1203,28 +1211,19 @@ onUnmounted(() => {
         />
       </TresMesh>
 
-      <!-- Alarm beacon — small red pulsing sphere + halo on the top-
-           back-right corner of every alarmed cube. The cube keeps
-           its layer color; the beacon is the alert signal.
-           Position: corner offset is ~0.55 (cube half-extent is 0.65,
-           inset slightly so the bulb visually sits on the cube top
-           rather than floating off the edge). -->
-      <template v-for="n in alarmedNodes" :key="`siren:${n.node.nodeId}`">
-        <TresMesh
-          :position="[n.pos.x + 0.5, n.pos.y + 1.22, n.pos.z + 0.5]"
-          :ref="(el) => disableRaycast(el)"
-        >
-          <primitive :object="sirenHaloGeometry" />
-          <primitive :object="sirenHaloMat" />
-        </TresMesh>
-        <TresMesh
-          :position="[n.pos.x + 0.5, n.pos.y + 1.22, n.pos.z + 0.5]"
-          :ref="(el) => disableRaycast(el)"
-        >
-          <primitive :object="sirenInnerGeometry" />
-          <primitive :object="sirenInnerMat" />
-        </TresMesh>
-      </template>
+      <!-- Alarm cap — the top ~20% of an alarmed cube, painted red. The
+           cube keeps its layer color below; the red crown is the alert
+           signal. raycast disabled (it sits on the cube top and would
+           otherwise steal clicks aimed at the cube). -->
+      <TresMesh
+        v-for="n in alarmedNodes"
+        :key="`alarmcap:${n.node.nodeId}`"
+        :position="[n.pos.x, n.pos.y + 0.95, n.pos.z]"
+        :ref="(el) => disableRaycast(el)"
+      >
+        <primitive :object="alarmCapGeometry" />
+        <primitive :object="alarmCapMat" />
+      </TresMesh>
 
       <!-- Traffic chip — small numeric pill that LIVES WITH its cube.
            Anchored at the cube's centre so the chip follows the
@@ -1245,6 +1244,7 @@ onUnmounted(() => {
           center
           :occlude="false"
           pointer-events="none"
+          :z-index-range="[35, 1]"
         >
           <div class="traffic-chip" :style="{ borderColor: n.colorHex }">
             <span class="val">{{ trafficText(n.node) }}</span>
@@ -1415,26 +1415,6 @@ onUnmounted(() => {
 </style>
 
 <style>
-.plane-label {
-  display: flex;
-  align-items: center;
-  padding: 4px 9px;
-  font-size: 11px;
-  font-weight: 700;
-  letter-spacing: 0.08em;
-  text-transform: uppercase;
-  color: var(--sw-fg-0);
-  background: rgba(15, 19, 26, 0.93);
-  border: 1px solid var(--sw-line-2);
-  border-radius: 5px;
-  white-space: nowrap;
-  transform: translateX(-100%);
-  pointer-events: none;
-}
-.plane-label[data-plane='apps']  { border-color: rgba(249, 115, 22, 0.65); 
color: #fb923c; }
-.plane-label[data-plane='mesh']  { border-color: rgba(168, 85, 247, 0.65); 
color: #c084fc; }
-.plane-label[data-plane='infra'] { border-color: rgba(34, 197, 94, 0.65); 
color: #4ade80; }
-
 /* NOTE: pointer-events on cientos <Html> wrappers is set via the
    component's `pointer-events="none"` prop (NOT a CSS class). Cientos
    applies `pointerEvents` as an INLINE style on the wrapper, which
diff --git a/apps/ui/src/features/infra-3d/Infra3DView.vue 
b/apps/ui/src/features/infra-3d/Infra3DView.vue
index 6f199c3..9b0d39d 100644
--- a/apps/ui/src/features/infra-3d/Infra3DView.vue
+++ b/apps/ui/src/features/infra-3d/Infra3DView.vue
@@ -71,6 +71,10 @@ const focusTarget = ref<{ x: number; y: number; z: number } 
| null>(null);
 // bundled defaults are in hand.
 const { config: infraConfig, levelsOrdered, ensureLoaded, levelForLayer } = 
useInfra3dConfig();
 const ready = ref(false);
+// Set when the config fetch rejects (OAP/BFF offline, or a role without
+// `infra-3d:read`). Without this the page sat on "Loading…" forever —
+// the operator couldn't tell a slow load from a hard failure.
+const configError = ref<string | null>(null);
 
 // Resolver + plane order are bound to the loaded config; both are
 // passed into the Scene AND used to build the local placement copy
@@ -459,10 +463,16 @@ function onKeyDown(e: KeyboardEvent): void {
 // pan + arrow keys / WASD work without any chrome to fight.
 onMounted(async () => {
   window.addEventListener('keydown', onKeyDown);
-  // Fetch the admin config; gate scene mount on success. A failure
-  // (offline / 401) leaves `ready` false and the operator sees the
-  // load message — no broken-render between 3-plane and 4-plane.
-  await ensureLoaded();
+  // Fetch the admin config; gate scene mount on success. On failure
+  // (offline / 401 from a role without infra-3d:read) surface the
+  // reason instead of hanging on the load message — no broken-render
+  // between 3-plane and 4-plane.
+  try {
+    await ensureLoaded();
+  } catch (err) {
+    configError.value = err instanceof Error ? err.message : String(err);
+    return;
+  }
   ready.value = true;
   // Kick the loading pipeline once. Subsequent runs are operator-
   // initiated (timeline strip's refresh button).
@@ -518,7 +528,13 @@ const visibleServices = computed(() => {
     </header>
 
     <div class="canvas-shell">
-      <div v-if="!ready" class="cfg-loading">Loading 3D map 
configuration…</div>
+      <div v-if="configError" class="cfg-error">
+        <strong>Couldn’t load the 3D map.</strong>
+        <span class="cfg-error__detail">{{ configError }}</span>
+        <span class="cfg-error__hint">Check that OAP is reachable and your 
role has 3D Infra Map access (<code>infra-3d:read</code>).</span>
+        <router-link class="cfg-error__back" to="/">← Back</router-link>
+      </div>
+      <div v-else-if="!ready" class="cfg-loading">Loading 3D map 
configuration…</div>
       <Infra3DScene
         v-else
         ref="sceneRef"
@@ -757,6 +773,24 @@ const visibleServices = computed(() => {
   letter-spacing: 0.02em;
   color: var(--sw-fg-3);
 }
+.cfg-error {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  padding: 24px;
+  text-align: center;
+  font-size: 12px;
+  color: var(--sw-fg-1);
+}
+.cfg-error strong { color: var(--sw-err); font-size: 13px; }
+.cfg-error__detail { color: var(--sw-fg-2); font-family: var(--sw-font-mono, 
monospace); font-size: 11px; }
+.cfg-error__hint { color: var(--sw-fg-3); max-width: 420px; line-height: 1.5; }
+.cfg-error__back { margin-top: 6px; color: var(--sw-accent); text-decoration: 
none; }
+.cfg-error__back:hover { text-decoration: underline; }
 .pipeline-strip {
   position: absolute;
   left: 0;

Reply via email to