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;