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 6457d69  ui layer selection: URL is now a share-link seed, not a live 
state mirror
6457d69 is described below

commit 6457d69e88992d6e608936bbf0c0356243f7901c
Author: Wu Sheng <[email protected]>
AuthorDate: Sun May 17 20:12:37 2026 +0800

    ui layer selection: URL is now a share-link seed, not a live state mirror
    
    New Pinia store `state/layerSelection.ts` owns the live
    service/instance/endpoint selection. LayerShell hydrates it from
    the URL ONCE per (layer, scope) entry — every picker click after
    that updates the store in-memory and never touches the URL again.
    
    Before, every selection change called `router.replace({ query })`,
    which:
      - tripped Vue Router's `route.query` reactive source — observed
        by many composables, each one re-evaluating their computeds;
      - fanned out into watches that listened to URL params (some of
        which I'd already fixed to skip null transitions, but the
        underlying URL-as-state design made every interaction more
        expensive than it needed to be);
      - made operator-visible duplicate work easy to introduce by
        accident — if any composable was watching both an URL-derived
        ref AND its store equivalent, a click would fan twice.
    
    With selection isolated to the store, the only reactivity is the
    explicit refs downstream watches consume. The URL stays whatever
    the operator landed with; for sharing the current view we'll add
    a "Copy link" button that reads `store.shareQuery` (already
    exposed) and assembles a fresh URL on demand.
    
    Trade-offs:
      - Browser back/forward + reload no longer track picker state.
        Layer/scope navigation still works (those ARE the URL path).
      - A future Share button covers the "send my dashboard view to a
        teammate" workflow without bringing URL writes back.
    
    Three composables (useSelectedService / Instance / Endpoint) now
    read/write the store. Their signatures are unchanged — every
    caller continues to work.
---
 apps/ui/src/layer/LayerShell.vue         |  23 ++++++
 apps/ui/src/layer/useSelectedEndpoint.ts |  26 ++-----
 apps/ui/src/layer/useSelectedInstance.ts |  32 ++------
 apps/ui/src/layer/useSelectedService.ts  |  44 +++--------
 apps/ui/src/state/layerSelection.ts      | 128 +++++++++++++++++++++++++++++++
 5 files changed, 177 insertions(+), 76 deletions(-)

diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index 161c3b3..7bd797b 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -37,6 +37,7 @@ import { colorForMetric } from '@/utils/metricColor';
 import { useLayerLanding } from '@/layer/useLayerLanding';
 import { useLayers, firstLayerTab } from '@/shell/useLayers';
 import { useSelectedService } from '@/layer/useSelectedService';
+import { useLayerSelectionStore } from '@/state/layerSelection';
 import { useSetupStore } from '@/state/setup';
 import { fmtMetric } from '@/utils/formatters';
 import { parseServiceName } from '@/utils/serviceName';
@@ -44,6 +45,28 @@ import { parseServiceName } from '@/utils/serviceName';
 const route = useRoute();
 const router = useRouter();
 const layerKey = computed(() => String(route.params.layerKey ?? ''));
+
+// Seed the selection store from the URL ONCE per (layer, scope)
+// entry. After this point the store owns the live picker state and
+// the URL stays frozen — see state/layerSelection.ts for the
+// contract. We rehydrate when the layer or scope segment of the
+// route changes so deep-linking still works for cross-layer
+// navigation. The scope segment is extracted from `/layer/<key>/<scope>`
+// without depending on the nested route's `meta` since that resolves
+// later than this watch needs.
+const selectionStore = useLayerSelectionStore();
+const scopeSegment = computed<string>(() => {
+  const m = route.path.match(/^\/layer\/[^/]+\/([^/?]+)/);
+  return m ? m[1] : 'service';
+});
+watch(
+  [layerKey, scopeSegment],
+  ([key, scope]) => {
+    if (!key) return;
+    selectionStore.hydrateFromQuery(key, scope, route.query);
+  },
+  { immediate: true },
+);
 const { layers, isLoading: layersLoading } = useLayers();
 const layer = computed<LayerDef | null>(() => {
   const found = layers.value.find((l) => l.key === layerKey.value);
diff --git a/apps/ui/src/layer/useSelectedEndpoint.ts 
b/apps/ui/src/layer/useSelectedEndpoint.ts
index df80174..8331488 100644
--- a/apps/ui/src/layer/useSelectedEndpoint.ts
+++ b/apps/ui/src/layer/useSelectedEndpoint.ts
@@ -16,32 +16,22 @@
  */
 
 /**
- * Page-wide selected-endpoint state. Mirrors `useSelectedInstance`:
- * URL-backed (`?endpoint=<name>`) so the choice is shareable and
- * survives a reload. The Endpoint scope's widget queries thread this
- * value to the BFF; other scopes ignore it.
+ * Selected endpoint for the per-layer page. Backed by the
+ * `layerSelection` Pinia store — URL hydrates on entry, store
+ * holds the live value, URL stays frozen. See
+ * `state/layerSelection.ts` for the contract.
  */
 
 import { computed } from 'vue';
-import { useRoute, useRouter } from 'vue-router';
+import { useLayerSelectionStore } from '@/state/layerSelection';
 
 export function useSelectedEndpoint() {
-  const route = useRoute();
-  const router = useRouter();
+  const store = useLayerSelectionStore();
 
-  const selectedEndpoint = computed<string | null>(() => {
-    const v = route.query.endpoint;
-    if (typeof v === 'string' && v.length > 0) return v;
-    return null;
-  });
+  const selectedEndpoint = computed<string | null>(() => store.endpoint);
 
   function setSelectedEndpoint(name: string | null): void {
-    const current = typeof route.query.endpoint === 'string' ? 
route.query.endpoint : null;
-    if (name === current) return;
-    const next = { ...route.query };
-    if (name) next.endpoint = name;
-    else delete next.endpoint;
-    void router.replace({ path: route.path, query: next });
+    store.setEndpoint(name);
   }
 
   return { selectedEndpoint, setSelectedEndpoint };
diff --git a/apps/ui/src/layer/useSelectedInstance.ts 
b/apps/ui/src/layer/useSelectedInstance.ts
index f284182..6009a7a 100644
--- a/apps/ui/src/layer/useSelectedInstance.ts
+++ b/apps/ui/src/layer/useSelectedInstance.ts
@@ -16,38 +16,22 @@
  */
 
 /**
- * Page-wide selected-instance state. Mirrors `useSelectedService`:
- * a single URL-backed selection (`?instance=<name>`) so the
- * dashboard query, breadcrumbs, and selector all stay in sync and
- * the choice survives a reload.
- *
- * The selection is **service-derived** — instances belong to a
- * service, so the selector is only meaningful when a service is
- * already picked. `useLayerDashboard` reads the instance value and
- * forwards it to the BFF on the Instance scope only; other scopes
- * (Service / Endpoint / Trace …) ignore it.
+ * Selected instance for the per-layer page. Backed by the
+ * `layerSelection` Pinia store — URL hydrates on entry, store
+ * holds the live value, URL stays frozen. See
+ * `state/layerSelection.ts` for the contract.
  */
 
 import { computed } from 'vue';
-import { useRoute, useRouter } from 'vue-router';
+import { useLayerSelectionStore } from '@/state/layerSelection';
 
 export function useSelectedInstance() {
-  const route = useRoute();
-  const router = useRouter();
+  const store = useLayerSelectionStore();
 
-  const selectedInstance = computed<string | null>(() => {
-    const v = route.query.instance;
-    if (typeof v === 'string' && v.length > 0) return v;
-    return null;
-  });
+  const selectedInstance = computed<string | null>(() => store.instance);
 
   function setSelectedInstance(name: string | null): void {
-    const current = typeof route.query.instance === 'string' ? 
route.query.instance : null;
-    if (name === current) return;
-    const next = { ...route.query };
-    if (name) next.instance = name;
-    else delete next.instance;
-    void router.replace({ path: route.path, query: next });
+    store.setInstance(name);
   }
 
   return { selectedInstance, setSelectedInstance };
diff --git a/apps/ui/src/layer/useSelectedService.ts 
b/apps/ui/src/layer/useSelectedService.ts
index d090144..2957551 100644
--- a/apps/ui/src/layer/useSelectedService.ts
+++ b/apps/ui/src/layer/useSelectedService.ts
@@ -16,47 +16,23 @@
  */
 
 /**
- * Page-wide selected-service state for the per-layer page. Backed by the
- * URL's `?service=` query parameter so the selection is shareable +
- * survives a full reload. Every tab inside the LayerShell reads via this
- * composable; the LayerServiceSelector at the top of the page is the
- * single writer.
+ * Selected service for the per-layer page. Backed by the
+ * `layerSelection` Pinia store, which is seeded from the URL on
+ * landing and updated in-memory afterwards. The URL is a
+ * shareable starting point, not a live state mirror — see
+ * `state/layerSelection.ts` for the contract.
  */
 
 import { computed } from 'vue';
-import { useRoute, useRouter } from 'vue-router';
+import { useLayerSelectionStore } from '@/state/layerSelection';
 
 export function useSelectedService() {
-  const route = useRoute();
-  const router = useRouter();
+  const store = useLayerSelectionStore();
 
-  const selectedId = computed<string | null>(() => {
-    const v = route.query.service;
-    if (typeof v === 'string' && v.length > 0) return v;
-    return null;
-  });
+  const selectedId = computed<string | null>(() => store.service);
 
-  function setSelected(id: string | null): void {
-    const next = { ...route.query };
-    const current = typeof route.query.service === 'string' ? 
route.query.service : null;
-    if (id === current) return;
-    if (id) next.service = id;
-    else delete next.service;
-    // Drop the narrower picks (?instance=, ?endpoint=) ONLY when
-    // we're replacing an existing service. First-time auto-fill
-    // (`current === null` — URL arrived with no ?service= at all)
-    // preserves any `?instance=` / `?endpoint=` URL hints the
-    // operator typed or that a sharable link carried, because
-    // those were the explicit intent. Without this, hitting
-    // `/layer/general/instance?instance=X` dropped X immediately
-    // and the auto-picked service's first instance won the race.
-    if (current !== null) {
-      delete next.instance;
-      delete next.endpoint;
-    }
-    // `replace` instead of `push` — switching services shouldn't bloat
-    // the browser back stack with N entries.
-    void router.replace({ path: route.path, query: next });
+  function setSelected(id: string | null, opts: { keepNarrower?: boolean } = 
{}): void {
+    store.setService(id, opts);
   }
 
   return { selectedId, setSelected };
diff --git a/apps/ui/src/state/layerSelection.ts 
b/apps/ui/src/state/layerSelection.ts
new file mode 100644
index 0000000..3b6be96
--- /dev/null
+++ b/apps/ui/src/state/layerSelection.ts
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * In-memory store for the layer dashboard's current
+ * service/instance/endpoint selection.
+ *
+ * URL contract: the `?service=` / `?instance=` / `?endpoint=` query
+ * params are READ ONCE per (layer, scope) entry to seed the store
+ * (`hydrateFromQuery`). After that, every selection change — service
+ * picker click, instance auto-pick, endpoint picker click — updates
+ * THIS STORE ONLY and never touches the URL again. The URL stays
+ * frozen at the landing values; it's a shareable starting point, not
+ * a live state mirror.
+ *
+ * Why: a URL-as-state design re-triggered router-level reactivity on
+ * every interaction (Vue Router's `route.query` is a hot reactive
+ * source many composables observe), making clicks feel sluggish and
+ * occasionally fanning out into duplicate fetches. With selection
+ * isolated to a store, the only reactivity is the explicit refs
+ * downstream watches consume.
+ *
+ * For sharing the current view, generate a URL from the store on
+ * demand (`buildShareUrl()` — to be added when the operator-facing
+ * Share button lands). Browser back/forward + reload still works
+ * for layer/scope navigation; only the in-page picker state is
+ * detached from history.
+ */
+
+import { computed } from 'vue';
+import { defineStore } from 'pinia';
+import { ref } from 'vue';
+
+function pickQueryString(v: unknown): string | null {
+  if (typeof v !== 'string' || v.length === 0) return null;
+  return v;
+}
+
+export const useLayerSelectionStore = defineStore('layer-selection', () => {
+  const service = ref<string | null>(null);
+  const instance = ref<string | null>(null);
+  const endpoint = ref<string | null>(null);
+  /** Key of the (layer, scope) the store was last hydrated for —
+   *  guards against re-hydration on every render of the same page
+   *  and lets `hydrateFromQuery` short-circuit when the caller
+   *  passes the same key. */
+  const hydratedKey = ref<string | null>(null);
+
+  /**
+   * Seed the store from a route's query params, but only when the
+   * (layer, scope) differs from what we previously hydrated for.
+   * Idempotent — calling repeatedly with the same key is a no-op.
+   * Used by LayerShell on mount and on layer/scope changes.
+   */
+  function hydrateFromQuery(
+    layerKey: string,
+    scope: string,
+    query: Record<string, unknown>,
+  ): void {
+    const key = `${layerKey}|${scope}`;
+    if (hydratedKey.value === key) return;
+    hydratedKey.value = key;
+    service.value = pickQueryString(query.service);
+    instance.value = pickQueryString(query.instance);
+    endpoint.value = pickQueryString(query.endpoint);
+  }
+
+  /**
+   * Service pick. Default semantics drop the narrower picks because
+   * the previous instance/endpoint belonged to the OLD service and
+   * almost certainly won't exist on the new one. `keepNarrower`
+   * preserves them — used by the auto-repair path so a URL-hint
+   * survives the first-visit service auto-fill (`current === null`).
+   */
+  function setService(id: string | null, opts: { keepNarrower?: boolean } = 
{}): void {
+    if (id === service.value) return;
+    const current = service.value;
+    service.value = id;
+    if (current !== null && !opts.keepNarrower) {
+      instance.value = null;
+      endpoint.value = null;
+    }
+  }
+  function setInstance(name: string | null): void {
+    if (name === instance.value) return;
+    instance.value = name;
+  }
+  function setEndpoint(name: string | null): void {
+    if (name === endpoint.value) return;
+    endpoint.value = name;
+  }
+
+  /** Build a shareable URL fragment for the current selection.
+   *  Operator-facing Share button (not wired yet) will use this. */
+  const shareQuery = computed<string>(() => {
+    const parts: string[] = [];
+    if (service.value) 
parts.push(`service=${encodeURIComponent(service.value)}`);
+    if (instance.value) 
parts.push(`instance=${encodeURIComponent(instance.value)}`);
+    if (endpoint.value) 
parts.push(`endpoint=${encodeURIComponent(endpoint.value)}`);
+    return parts.length > 0 ? `?${parts.join('&')}` : '';
+  });
+
+  return {
+    service,
+    instance,
+    endpoint,
+    hydratedKey,
+    hydrateFromQuery,
+    setService,
+    setInstance,
+    setEndpoint,
+    shareQuery,
+  };
+});

Reply via email to