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 a8081d1  fix: 3D-map fps cap, layer-URL hydration, API dependency zoom 
(#33)
a8081d1 is described below

commit a8081d1a007837ba17844083e43f9aad478cca55
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Sun May 31 20:53:51 2026 +0800

    fix: 3D-map fps cap, layer-URL hydration, API dependency zoom (#33)
    
    ## 1. 3D map — render-loop CPU cap
    The `/3d/map` scene renders continuously; on a high-refresh display the 
loop ran at 60–120fps and pegged a CPU core (the per-frame cientos `<Html>` 
reprojection for ~80 chips/labels dominates). Cap the whole loop with TresJS's 
native `fpsLimit=30` — render + animation + `<Html>` updates all throttle 
together, roughly halving (or quartering at 120Hz) the renderer CPU with no 
visual change.
    
    ## 2. Per-layer URL / dashboard hydration fixes
    A chain of related deep-link / config-readiness bugs in the layer shell + 
dashboards:
    - **Same-layer rehydrate** — the shell watched only `layerKey`, and 
`resetForLayer` early-returned on the same layer, so a same-layer deep link 
with new `?service/?instance/?endpoint` kept the stale pick. Watch the seed 
params and re-seed on same-layer params (sticky pick preserved for paramless 
tab nav).
    - **`?service=` outside the landing sample** — validated only against the 
top-N sample, so a valid low-traffic service was overwritten by the first row 
(clearing instance/endpoint). Validate against the full roster 
(`useLayerServices`); only a genuinely-absent id auto-corrects.
    - **`?endpoint=` outside the recent top-N** — discarded; add a targeted 
name lookup, and make `effectiveEndpoint` accept the confirmed pin so it 
actually drives the fetch.
    - **Metrics before config** — the dashboard query fired with an empty 
widget list (→ BFF defaults) before the config bundle resolved. Gate on 
resolved widgets; an empty resolved config shows **No widgets defined** instead 
of looping on **Reading data…**.
    - The page-init orchestrator now receives the *effective* (validated) 
entity refs, matching the actual fetch gate.
    
    ## 3. API dependency graph — zoom + fit + name clipping
    The endpoint-dependency graph rendered at full width in a scroll container, 
so wider chains clipped and edges to off-screen nodes looked like stray lines. 
Now:
    - **Fit-to-view by default** (viewBox + `preserveAspectRatio`) — every 
column visible, no dangling edges.
    - **Zoom** via wheel + drag-pan + +/−/fit buttons (toolbar over the canvas).
    - **Clip node text** to the box (clipPath + tighter truncation) so long 
endpoint names no longer overflow.
    - Drop the `L0 / L+1` column-name headers (operator feedback).
---
 apps/ui/src/features/infra-3d/Infra3DScene.vue     |   1 +
 apps/ui/src/layer/LayerShell.vue                   |  49 ++++--
 .../LayerEndpointDependencyView.vue                | 184 ++++++++++++++++-----
 .../render/layer-dashboard/LayerDashboardsView.vue |  40 ++++-
 .../render/layer-dashboard/useLayerDashboard.ts    |  10 ++
 apps/ui/src/state/layerSelection.ts                |   8 +-
 6 files changed, 232 insertions(+), 60 deletions(-)

diff --git a/apps/ui/src/features/infra-3d/Infra3DScene.vue 
b/apps/ui/src/features/infra-3d/Infra3DScene.vue
index 3b0fc3f..983cc08 100644
--- a/apps/ui/src/features/infra-3d/Infra3DScene.vue
+++ b/apps/ui/src/features/infra-3d/Infra3DScene.vue
@@ -1592,6 +1592,7 @@ onUnmounted(() => {
       clear-color="#0a0d12"
       :antialias="true"
       power-preference="high-performance"
+      :fps-limit="30"
       @loop="onSceneLoop"
     >
       <TresPerspectiveCamera
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index 75b58d5..e0b2438 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -42,6 +42,7 @@ import { useTimeRangeStore } from '@/controls/timeRange';
 import { useLayers, firstLayerTab } from '@/shell/useLayers';
 import { layerContentToDef, type LayerTemplateContent } from 
'@/shell/layerFromTemplate';
 import { useSelectedService } from '@/layer/useSelectedService';
+import { useLayerServices } from '@/layer/useLayerServices';
 import { useLayerSelectionStore } from '@/state/layerSelection';
 import { useSetupStore } from '@/state/setup';
 import { fmtMetric } from '@/utils/formatters';
@@ -80,16 +81,26 @@ const scopeSegment = computed<string>(() => {
 onBeforeUnmount(() => {
   selectionStore.clear();
 });
+// Re-seed the selection store on layer ENTRY and on any SAME-LAYER
+// navigation that arrives with fresh ?service/?instance/?endpoint (deep
+// links into the layer the operator is already on). Keyed on the layer
+// key plus the three seed params — but the strip below removes those
+// params right after seeding, and that removal (params → absent) must NOT
+// re-seed, so we only act when the layer changed OR seed params are
+// actually present.
 watch(
-  layerKey,
-  (key) => {
+  [layerKey, () => route.query.service, () => route.query.instance, () => 
route.query.endpoint],
+  ([key], prev) => {
     if (!key) return;
-    selectionStore.resetForLayer(key, route.query);
-    // After hydrating, strip the seed params so the address bar reads
-    // as a clean `/layer/<key>/<scope>` URL. The store now owns the
-    // live selection; the params were a one-shot seed.
     const q = route.query;
-    if (q.service != null || q.instance != null || q.endpoint != null) {
+    const hasSeed = q.service != null || q.instance != null || q.endpoint != 
null;
+    const layerChanged = key !== (prev?.[0] as string | undefined);
+    if (!layerChanged && !hasSeed) return;
+    selectionStore.resetForLayer(key, q);
+    // After hydrating, strip the seed params so the address bar reads as a
+    // clean `/layer/<key>/<scope>` URL. The store now owns the live
+    // selection; the params were a one-shot seed.
+    if (hasSeed) {
       const { service: _s, instance: _i, endpoint: _e, ...rest } = q;
       void _s; void _i; void _e;
       void router.replace({ path: route.path, query: rest });
@@ -293,19 +304,31 @@ const isZipkinTrace = computed<boolean>(() => {
   return scopeSegment.value === 'trace' && layer.value?.traces?.source === 
'zipkin';
 });
 
+// Full service roster (the layer's REAL catalog, independent of landing's
+// top-N sample which misses low-traffic services). A URL `?service=` is
+// validated against THIS, not the sample — otherwise a valid but
+// low-traffic deep link is wrongly treated as stale.
+const { services: fullRoster, isLoading: rosterLoading } = 
useLayerServices(layerKey);
+
 // Keep the URL-backed service selection honest for every page that
-// uses the shell picker. A stale `?service=` can survive navigation or
-// manual URL entry; the switch label used to fall back visually to the
-// first row while the metric query still waited for a valid service.
+// uses the shell picker. A `?service=` outside the landing sample is
+// trusted when it exists in the full roster; only a genuinely stale id
+// (absent from the roster) auto-corrects to the first sampled row, and
+// only once the roster has loaded so a valid pin isn't clobbered in flight.
 watch(
-  [sampledServices, selectedId, viewOwnsServiceSelector],
-  ([rows, id, ownsSelector]) => {
+  [sampledServices, selectedId, viewOwnsServiceSelector, fullRoster, 
rosterLoading],
+  ([rows, id, ownsSelector, roster, rosterIsLoading]) => {
     if (ownsSelector) return;
     const first = rows[0];
     if (!first) return;
-    if (!id || !rows.some((s) => s.serviceId === id)) {
+    if (!id) {
       setSelected(first.serviceId);
+      return;
     }
+    if (rows.some((s) => s.serviceId === id)) return; // in the sample → keep
+    if (rosterIsLoading) return; // don't clobber a pin while the roster loads
+    if (roster.some((s) => s.id === id)) return; // valid in the full roster → 
keep
+    setSelected(first.serviceId); // genuinely stale → fall back
   },
   { immediate: true },
 );
diff --git 
a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue 
b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
index 90a72d6..6e506b4 100644
--- a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
+++ b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
@@ -432,6 +432,71 @@ const visibleCalls = computed<EndpointDependencyCall[]>(() 
=> {
   return calls.value.filter((c) => ids.has(c.source) && ids.has(c.target));
 });
 
+// ── Pan / zoom. The SVG fits the whole graph by default (viewBox = full
+// extent, aspect-preserved), so every column is visible — no edge dangles
+// off a clipped column. Wheel + +/−/fit buttons zoom; drag pans.
+const svgRef = ref<SVGSVGElement | null>(null);
+const viewBox = ref<{ x: number; y: number; w: number; h: number } | 
null>(null);
+const viewBoxStr = computed(() => {
+  const v = viewBox.value ?? { x: 0, y: 0, w: W.value, h: H.value };
+  return `${v.x} ${v.y} ${v.w} ${v.h}`;
+});
+function fitView(): void {
+  viewBox.value = { x: 0, y: 0, w: W.value, h: H.value };
+}
+// Refit when the graph itself changes (focus pick / first load / refresh
+// that adds or drops a column). Operator zoom/pan persists otherwise.
+watch([focusedId, () => layerColumns.value.length], () => fitView(), { 
immediate: true });
+
+/** Rendered scale + letterbox offset for the current viewBox under
+ *  preserveAspectRatio="xMidYMid meet" — so cursor zoom + drag pan map
+ *  screen pixels to graph coordinates exactly. */
+function viewMetrics() {
+  const v = viewBox.value ?? { x: 0, y: 0, w: W.value, h: H.value };
+  const r = svgRef.value?.getBoundingClientRect();
+  const rw = r?.width || v.w;
+  const rh = r?.height || v.h;
+  const scale = Math.min(rw / v.w, rh / v.h) || 1;
+  return { v, left: r?.left ?? 0, top: r?.top ?? 0, scale, offX: (rw - v.w * 
scale) / 2, offY: (rh - v.h * scale) / 2 };
+}
+function clientToView(clientX: number, clientY: number): { x: number; y: 
number } {
+  const { v, left, top, scale, offX, offY } = viewMetrics();
+  return { x: v.x + (clientX - left - offX) / scale, y: v.y + (clientY - top - 
offY) / scale };
+}
+function zoomAround(factor: number, cx: number, cy: number): void {
+  const v = viewBox.value ?? { x: 0, y: 0, w: W.value, h: H.value };
+  // viewBox width bounded to [30%, 160%] of the full graph (zoom-in / out 
caps).
+  const newW = Math.min(W.value * 1.6, Math.max(W.value * 0.3, v.w * factor));
+  const k = newW / v.w;
+  viewBox.value = { x: cx - (cx - v.x) * k, y: cy - (cy - v.y) * k, w: newW, 
h: v.h * k };
+}
+function onWheel(e: WheelEvent): void {
+  e.preventDefault();
+  const p = clientToView(e.clientX, e.clientY);
+  zoomAround(e.deltaY > 0 ? 1.12 : 0.89, p.x, p.y);
+}
+function zoomBtn(factor: number): void {
+  const v = viewBox.value ?? { x: 0, y: 0, w: W.value, h: H.value };
+  zoomAround(factor, v.x + v.w / 2, v.y + v.h / 2);
+}
+// Drag-pan from the background (node/edge clicks keep their own handlers).
+let panning = false;
+let panStart = { cx: 0, cy: 0, vx: 0, vy: 0 };
+function onPanStart(e: PointerEvent): void {
+  panning = true;
+  const v = viewBox.value ?? { x: 0, y: 0, w: W.value, h: H.value };
+  panStart = { cx: e.clientX, cy: e.clientY, vx: v.x, vy: v.y };
+  (e.target as Element).setPointerCapture?.(e.pointerId);
+}
+function onPanMove(e: PointerEvent): void {
+  if (!panning) return;
+  const { v, scale } = viewMetrics();
+  viewBox.value = { ...v, x: panStart.vx - (e.clientX - panStart.cx) / scale, 
y: panStart.vy - (e.clientY - panStart.cy) / scale };
+}
+function onPanEnd(): void {
+  panning = false;
+}
+
 // Kind colour band — uses the endpoint's `type` field, then service
 // name fallbacks (db/cache/mq/ext).
 /**
@@ -724,26 +789,45 @@ function edgeRowCrosshair(rowId: string): number | null {
           </span>
         </header>
 
-        <!-- Layer headers row -->
-        <div class="layer-hdr-row" :style="{ minWidth: W + 'px' }">
-          <div
-            v-for="(col, i) in layerColumns"
-            :key="col.index"
-            class="layer-hdr"
-            :style="{ left: 40 + i * COL_GAP + 'px', width: NW + 'px' }"
-          >
-            <span>{{ col.label }}</span>
-            <span v-if="col.hidden > 0" class="hdr-overflow">+{{ col.hidden }} 
more</span>
-          </div>
-        </div>
-
         <div class="ep-scroll">
+        <!-- Zoom toolbar — over the canvas (not the header); wheel + drag
+             also work directly on the graph. -->
+        <div v-if="layoutNodes.length > 0" class="ep-zoom">
+          <button type="button" title="Zoom in" 
@click="zoomBtn(0.8)">+</button>
+          <button type="button" title="Zoom out" 
@click="zoomBtn(1.25)">−</button>
+          <button type="button" title="Fit to view" @click="fitView">⤢</button>
+        </div>
         <svg
           v-if="layoutNodes.length > 0"
-          :viewBox="`0 0 ${W} ${H}`"
-          :style="{ width: W + 'px', height: H + 'px', display: 'block' }"
+          ref="svgRef"
+          class="ep-svg"
+          :viewBox="viewBoxStr"
+          preserveAspectRatio="xMidYMid meet"
+          @wheel="onWheel"
         >
           <!-- No arrow markers — the animated dots advertise direction. -->
+          <defs>
+            <!-- Clip node text to the box interior so long endpoint names
+                 are cut at the boundary instead of overflowing it. Evaluated
+                 in each node's local space, so one def clips every node. -->
+            <clipPath id="ep-node-text-clip">
+              <rect :x="8" :y="0" :width="NW - 16" :height="NH" />
+            </clipPath>
+          </defs>
+          <!-- Background pan target. Behind everything; node / edge clicks
+               keep their own handlers. -->
+          <rect
+            class="ep-pan-bg"
+            :x="-W"
+            :y="-H"
+            :width="W * 3"
+            :height="H * 3"
+            fill="transparent"
+            @pointerdown="onPanStart"
+            @pointermove="onPanMove"
+            @pointerup="onPanEnd"
+            @pointerleave="onPanEnd"
+          />
 
           <!-- column guide lines -->
           <line
@@ -904,9 +988,10 @@ function edgeRowCrosshair(rowId: string): number | null {
               fill="var(--sw-fg-3)"
               font-size="10"
               font-family="var(--sw-mono)"
+              clip-path="url(#ep-node-text-clip)"
             >
               <title>{{ n.serviceName }}</title>
-              {{ identity(n.serviceName).display.length > 26 ? 
identity(n.serviceName).display.slice(0, 24) + '…' : 
identity(n.serviceName).display }}
+              {{ identity(n.serviceName).display.length > 24 ? 
identity(n.serviceName).display.slice(0, 22) + '…' : 
identity(n.serviceName).display }}
             </text>
             <!-- Row 2: API (endpoint) name — the headline. -->
             <text
@@ -916,9 +1001,10 @@ function edgeRowCrosshair(rowId: string): number | null {
               font-size="12"
               font-family="var(--sw-mono)"
               :font-weight="n.id === focusedId ? 700 : 600"
+              clip-path="url(#ep-node-text-clip)"
             >
               <title>{{ n.name }}</title>
-              {{ n.name.length > 28 ? n.name.slice(0, 26) + '…' : n.name }}
+              {{ n.name.length > 21 ? n.name.slice(0, 19) + '…' : n.name }}
             </text>
             <!-- Row 3: configured `center` metric (typically RPM).
                  Coloured in the ring band so the visual signal
@@ -1306,6 +1392,7 @@ function edgeRowCrosshair(rowId: string): number | null {
   border-radius: 2px;
 }
 .ep-graph {
+  position: relative;
   min-width: 0;
   display: flex;
   flex-direction: column;
@@ -1524,37 +1611,52 @@ function edgeRowCrosshair(rowId: string): number | null 
{
   font-weight: 600;
   color: var(--sw-fg-0);
 }
-.layer-hdr-row {
+/* The graph fits-to-view by default and zooms via the viewBox, so the
+   container no longer scrolls — it just gives the SVG its height. */
+.ep-scroll {
   position: relative;
-  height: 30px;
-  border-bottom: 1px solid var(--sw-line);
+  flex: 1;
+  min-height: 0;
+  overflow: hidden;
 }
-.layer-hdr {
+.ep-svg {
+  width: 100%;
+  height: 100%;
+  display: block;
+  /* Stop the page from scrolling while wheel-zooming / dragging. */
+  touch-action: none;
+}
+.ep-pan-bg {
+  cursor: grab;
+}
+.ep-pan-bg:active {
+  cursor: grabbing;
+}
+/* Zoom toolbar — top-right of the graph column. */
+.ep-zoom {
   position: absolute;
   top: 8px;
+  right: 8px;
+  z-index: 2;
   display: flex;
-  align-items: baseline;
-  gap: 8px;
-  font-size: 9.5px;
-  text-transform: uppercase;
-  letter-spacing: 0.06em;
-  color: var(--sw-fg-3);
-  font-weight: 700;
+  gap: 4px;
 }
-.hdr-overflow {
-  font-size: 9px;
-  color: var(--sw-fg-2);
-  padding: 1px 5px;
-  background: var(--sw-bg-2);
-  border-radius: 3px;
-  text-transform: none;
-  letter-spacing: 0;
-  font-weight: 500;
+.ep-zoom button {
+  width: 24px;
+  height: 24px;
+  display: grid;
+  place-items: center;
+  font-size: 13px;
+  line-height: 1;
+  color: var(--sw-fg-1);
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  cursor: pointer;
 }
-.ep-scroll {
-  position: relative;
-  overflow: auto;
-  max-height: 640px;
+.ep-zoom button:hover {
+  border-color: var(--sw-accent);
+  color: var(--sw-fg-0);
 }
 .ep-node { cursor: pointer; }
 .ep-node:hover rect { stroke: var(--sw-accent-2); }
diff --git a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue 
b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
index dedc6f3..3a49d8f 100644
--- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
@@ -264,6 +264,18 @@ const { endpoints: endpointList, isFetching: 
endpointsLoading } = useLayerEndpoi
   endpointQuery,
   endpointLimit,
 );
+// URL-pinned endpoint validation. The list above is the recent top-N
+// (empty query); a deep-linked endpoint outside it would look "stale".
+// This re-queries by the pinned endpoint's own name to confirm it really
+// exists for this service before we discard the deep link. Inactive
+// (empty query) once the pin is null or already present in the default list.
+const pinnedEndpointQuery = computed(() => {
+  const pinned = selectedEndpoint.value;
+  if (!pinned) return '';
+  return endpointList.value.some((e) => e.name === pinned) ? '' : pinned;
+});
+const { endpoints: pinnedEndpointMatches, isFetching: pinnedEndpointLoading } =
+  useLayerEndpoints(layerKey, serviceName, pinnedEndpointQuery, endpointLimit);
 // Endpoint-scope orchestration — explicit sequence so the loading
 // flow is deterministic:
 //   1. wait for landing rows
@@ -293,10 +305,14 @@ watchEffect(() => {
     return;
   }
   if (!list.some((e) => e.name === selectedEndpoint.value)) {
+    // Outside the default top-N — confirm via the targeted name search
+    // before discarding the deep link.
+    if (pinnedEndpointQuery.value && pinnedEndpointLoading.value) return; // 
wait for the lookup
+    if (pinnedEndpointMatches.value.some((e) => e.name === 
selectedEndpoint.value)) return; // valid → keep
     pushEvent(
       'fallback',
       'info',
-      `URL endpoint "${selectedEndpoint.value}" not in ${serviceName.value} · 
falling back to "${list[0].name}"`,
+      `URL endpoint "${selectedEndpoint.value}" not found in 
${serviceName.value} · falling back to "${list[0].name}"`,
     );
     setSelectedEndpoint(list[0].name);
   }
@@ -331,9 +347,19 @@ const effectiveInstance = computed<string | null>(() => {
 const effectiveEndpoint = computed<string | null>(() => {
   const v = selectedEndpoint.value;
   if (!v) return null;
-  return endpointList.value.some((e) => e.name === v) ? v : null;
+  // Valid if in the default top-N OR confirmed by the targeted name lookup
+  // (a deep-linked endpoint outside the recent list) — otherwise the
+  // dashboard would stay gated forever for a perfectly valid pin.
+  if (endpointList.value.some((e) => e.name === v)) return v;
+  if (pinnedEndpointMatches.value.some((e) => e.name === v)) return v;
+  return null;
 });
 const widgetsForQuery = computed(() => config.value?.widgets ?? []);
+// Hold the metrics fetch until the config bundle has resolved WITH widgets.
+// A resolved-but-empty config means "no dashboard for this layer/scope",
+// so we don't fire (which would otherwise make the BFF substitute its own
+// default widget set); metrics run only for resolved widgets.
+const configReady = computed(() => widgetsForQuery.value.length > 0);
 const { data, isFetching, error } = useLayerDashboard(
   layerKey,
   serviceName,
@@ -342,6 +368,7 @@ const { data, isFetching, error } = useLayerDashboard(
   { instance: effectiveInstance, endpoint: effectiveEndpoint },
   rangeRef,
   widgetsForQuery,
+  configReady,
 );
 
 // Sequential page-init events for the EventTicker — config →
@@ -356,9 +383,9 @@ useLayerPageOrchestrator({
   serviceList: landingRows,
   effectiveService: serviceName,
   instanceList,
-  effectiveInstance: selectedInstance,
+  effectiveInstance,
   endpointList,
-  effectiveEndpoint: selectedEndpoint,
+  effectiveEndpoint,
   dashboard: data,
 });
 
@@ -677,7 +704,10 @@ function isVisible(
          sequence (which read as a slow, jumpy entry). The "no widgets"
          branch below only shows once config has actually loaded and the
          layer genuinely defines none. -->
-    <div v-if="reachable && (configLoading || !dataIsFresh)" class="empty 
reading">
+    <div
+      v-if="reachable && (configLoading || (!dataIsFresh && widgets.length > 
0))"
+      class="empty reading"
+    >
       <span class="reading-dot" />
       <span>
         Reading data
diff --git a/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts 
b/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
index e70fe72..7b86a2b 100644
--- a/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
+++ b/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
@@ -110,6 +110,13 @@ export function useLayerDashboard(
    *  back to a single BFF call that resolves widgets server-side
    *  (used by callers that don't have the config bundle handy). */
   widgetsList?: Ref<DashboardWidget[]>,
+  /** Optional config-bundle readiness gate. When supplied, the metrics
+   *  query waits until it is true, so the dashboard fires ONCE with the
+   *  resolved widget list instead of firing first with an empty list
+   *  (which makes the BFF substitute defaults) and refetching when the
+   *  bundle lands. Callers without a config bundle omit it (treated as
+   *  ready) and keep the server-resolves-widgets behaviour. */
+  configReady?: Ref<boolean>,
 ) {
   // Auto-refresh is metrics-only. Trace / log / profiling pages are
   // explore-style (operator-driven queries, log tails, etc.) and would
@@ -194,6 +201,9 @@ export function useLayerDashboard(
     //   - endpoint scope needs service + endpoint.
     enabled: computed(() => {
       if (layerKey.value.length === 0) return false;
+      // Wait for the config bundle so widgets are resolved before the
+      // metrics fire (no empty-list → BFF-default → refetch round-trip).
+      if (configReady && !configReady.value) return false;
       const s = scope?.value ?? 'service';
       if (s === 'service') return Boolean(service.value);
       if (s === 'instance') return Boolean(service.value && 
entityRefs.instance?.value);
diff --git a/apps/ui/src/state/layerSelection.ts 
b/apps/ui/src/state/layerSelection.ts
index 6d5d02c..0422cbf 100644
--- a/apps/ui/src/state/layerSelection.ts
+++ b/apps/ui/src/state/layerSelection.ts
@@ -64,7 +64,13 @@ export const useLayerSelectionStore = 
defineStore('layer-selection', () => {
    * a no-op — the operator's pick survives.
    */
   function resetForLayer(layerKey: string, query: Record<string, unknown>): 
void {
-    if (ownerKey.value === layerKey) return;
+    const hasSeed =
+      query.service != null || query.instance != null || query.endpoint != 
null;
+    // Same layer + NO deep-link params → keep the sticky pick (scope/tab
+    // nav within the layer). Same layer WITH params (a deep link into the
+    // layer the operator is already on) DOES re-seed, otherwise the new
+    // ?service/?instance/?endpoint would be silently ignored.
+    if (ownerKey.value === layerKey && !hasSeed) return;
     ownerKey.value = layerKey;
     service.value = pickQueryString(query.service);
     instance.value = pickQueryString(query.instance);

Reply via email to