This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch fix/infra-3d-review in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 2317e3a59a36522d7cb07b373bb79e2f116bf5a0 Author: Wu Sheng <[email protected]> AuthorDate: Thu May 28 18:42:39 2026 +0800 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. --- apps/bff/src/logic/infra-3d/validate.ts | 6 ++- .../features/admin/infra-3d/Infra3dAdminView.vue | 4 +- apps/ui/src/features/infra-3d/Infra3DScene.vue | 38 ++++++++----------- apps/ui/src/features/infra-3d/Infra3DView.vue | 44 +++++++++++++++++++--- 4 files changed, 63 insertions(+), 29 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..bdacde9 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; @@ -603,6 +603,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. ──────────────────────── @@ -1415,26 +1429,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;
