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,
+ };
+});