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);