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 d778a38  ui events: drive ticker off query status, not queryFn
d778a38 is described below

commit d778a3888de1572c8cdb494e5e14e44f10879644
Author: Wu Sheng <[email protected]>
AuthorDate: Sun May 17 11:47:55 2026 +0800

    ui events: drive ticker off query status, not queryFn
    
    The original wiring pushed start/ok/err from inside `queryFn`. That
    went silent the moment vue-query served from cache — no queryFn
    invocation means no event — which is why so11y_java_agent's
    instance load showed "loading services" but no "loading instances"
    when the cache was warm.
    
    The new `controls/useQueryEvents.ts` watches the query's reactive
    `isFetching` ref:
      - false → true  ⇒ push `start` ("Loading …")
      - true  → false ⇒ push `ok` ("Loaded N …") or `err`
    And on composable mount, if `data` is already populated and we're
    not currently fetching, push a one-shot `info` ("… cached") so the
    ticker reports the cache-hit instead of going silent.
    
    Every composable that used the in-queryFn pushEvent pattern
    (useLayerLanding, useLayerInstances, useLayerEndpoints,
    useLayerDashboard) now wraps its query with `useQueryEvents`.
---
 apps/ui/src/controls/useQueryEvents.ts             | 85 ++++++++++++++++++++++
 apps/ui/src/layer/useLayerEndpoints.ts             | 31 ++++----
 apps/ui/src/layer/useLayerInstances.ts             | 26 +++----
 apps/ui/src/layer/useLayerLanding.ts               | 27 +++----
 .../render/layer-dashboard/useLayerDashboard.ts    | 74 +++++++++----------
 5 files changed, 166 insertions(+), 77 deletions(-)

diff --git a/apps/ui/src/controls/useQueryEvents.ts 
b/apps/ui/src/controls/useQueryEvents.ts
new file mode 100644
index 0000000..1b3a32d
--- /dev/null
+++ b/apps/ui/src/controls/useQueryEvents.ts
@@ -0,0 +1,85 @@
+/*
+ * 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.
+ */
+
+/**
+ * Bind a TanStack Query to the eventLog. The earlier approach pushed
+ * events from inside `queryFn`, which silently went missing whenever
+ * vue-query served a cached / fresh-in-cache hit (queryFn never runs
+ * in that case). Tracking the reactive `isFetching` + `data` /
+ * `error` refs catches every state transition — including the
+ * cache-hit case, surfaced as a one-shot `info` event so the operator
+ * can see the SPA is using stored data instead of going to the BFF.
+ */
+import { watch, type Ref } from 'vue';
+import { pushEvent, type EventKind } from '@/controls/eventLog';
+
+interface QueryLike<T> {
+  isFetching: Ref<boolean>;
+  data: Ref<T | undefined>;
+  error: Ref<unknown>;
+}
+
+export interface QueryEventLabels<T> {
+  start: () => string;
+  ok: (data: T) => string;
+  err: (error: unknown) => string;
+  /** Optional — when present, fires once on mount if `data` is
+   *  already populated (cache hit), so the operator sees the SPA
+   *  using stored data without a network round-trip. */
+  cached?: (data: T) => string;
+}
+
+/**
+ * Wire a vue-query result to the event log.
+ *
+ * @param topic  Stable identifier for paired start/ok/err lookup
+ *               (e.g. `'instances'`, `'services'`, `'dashboard'`).
+ * @param q      The query result handle (isFetching / data / error refs).
+ * @param labels Functions that produce the event text from the
+ *               query's current data / error.
+ */
+export function useQueryEvents<T>(
+  topic: string,
+  q: QueryLike<T>,
+  labels: QueryEventLabels<T>,
+): void {
+  // Cache-hit echo — when the composable mounts and the query already
+  // has data, emit an info line so the ticker doesn't go silent on
+  // fast-path revisits.
+  if (labels.cached && q.data.value !== undefined && !q.isFetching.value) {
+    pushEvent(topic, 'info', labels.cached(q.data.value));
+  }
+
+  // Fetch in-flight — push `start` on rising edge, push `ok`/`err`
+  // on falling edge based on whether the result is an error.
+  watch(
+    () => q.isFetching.value,
+    (now, before) => {
+      if (now && !before) {
+        pushEvent(topic, 'start', labels.start());
+        return;
+      }
+      if (!now && before) {
+        const err = q.error.value;
+        const kind: EventKind = err ? 'err' : 'ok';
+        const text = err ? labels.err(err) : labels.ok(q.data.value as T);
+        pushEvent(topic, kind, text);
+      }
+    },
+    { immediate: false },
+  );
+}
diff --git a/apps/ui/src/layer/useLayerEndpoints.ts 
b/apps/ui/src/layer/useLayerEndpoints.ts
index cc221f2..e07f759 100644
--- a/apps/ui/src/layer/useLayerEndpoints.ts
+++ b/apps/ui/src/layer/useLayerEndpoints.ts
@@ -26,7 +26,9 @@
 import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import { bffClient } from '@/api/client';
-import { pushEvent } from '@/controls/eventLog';
+import { useQueryEvents } from '@/controls/useQueryEvents';
+
+type EndpointsResp = Awaited<ReturnType<typeof bffClient.layer.endpoints>>;
 
 export function useLayerEndpoints(
   layerKey: Ref<string>,
@@ -36,22 +38,23 @@ export function useLayerEndpoints(
 ) {
   const q = useQuery({
     queryKey: ['layer-endpoints', layerKey, service, query, limit],
-    queryFn: async () => {
-      const label = query.value ? `"${query.value}"` : 'top';
-      pushEvent('endpoints', 'start', `Loading endpoints (${label}) for 
${service.value}…`);
-      try {
-        const r = await bffClient.layer.endpoints(layerKey.value, 
service.value ?? '', query.value, limit.value);
-        const n = r.endpoints?.length ?? 0;
-        pushEvent('endpoints', 'ok', `Loaded ${n} endpoint${n === 1 ? '' : 
's'}`);
-        return r;
-      } catch (err) {
-        pushEvent('endpoints', 'err', `Endpoint list failed: ${err instanceof 
Error ? err.message : String(err)}`);
-        throw err;
-      }
-    },
+    queryFn: () =>
+      bffClient.layer.endpoints(layerKey.value, service.value ?? '', 
query.value, limit.value),
     enabled: computed(() => layerKey.value.length > 0 && !!service.value),
     staleTime: 30_000,
   });
+  useQueryEvents<EndpointsResp>('endpoints', q, {
+    start: () => {
+      const label = query.value ? `"${query.value}"` : 'top';
+      return `Loading endpoints (${label}) for ${service.value}…`;
+    },
+    ok: (r) => {
+      const n = r.endpoints?.length ?? 0;
+      return `Loaded ${n} endpoint${n === 1 ? '' : 's'} for ${service.value}`;
+    },
+    err: (e) => `Endpoint list failed: ${e instanceof Error ? e.message : 
String(e)}`,
+    cached: (r) => `Endpoints cached (${r.endpoints?.length ?? 0}) for 
${service.value}`,
+  });
   return {
     data: computed(() => q.data.value ?? null),
     endpoints: computed(() => q.data.value?.endpoints ?? []),
diff --git a/apps/ui/src/layer/useLayerInstances.ts 
b/apps/ui/src/layer/useLayerInstances.ts
index 2e1e3c2..5915c67 100644
--- a/apps/ui/src/layer/useLayerInstances.ts
+++ b/apps/ui/src/layer/useLayerInstances.ts
@@ -25,26 +25,26 @@
 import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import { bffClient } from '@/api/client';
-import { pushEvent } from '@/controls/eventLog';
+import { useQueryEvents } from '@/controls/useQueryEvents';
+
+type InstancesResp = Awaited<ReturnType<typeof bffClient.layer.instances>>;
 
 export function useLayerInstances(layerKey: Ref<string>, service: Ref<string | 
null>) {
   const q = useQuery({
     queryKey: ['layer-instances', layerKey, service],
-    queryFn: async () => {
-      pushEvent('instances', 'start', `Loading instances for 
${service.value}…`);
-      try {
-        const r = await bffClient.layer.instances(layerKey.value, 
service.value ?? '');
-        const n = r.instances?.length ?? 0;
-        pushEvent('instances', 'ok', `Loaded ${n} instance${n === 1 ? '' : 
's'}`);
-        return r;
-      } catch (err) {
-        pushEvent('instances', 'err', `Instance list failed: ${err instanceof 
Error ? err.message : String(err)}`);
-        throw err;
-      }
-    },
+    queryFn: () => bffClient.layer.instances(layerKey.value, service.value ?? 
''),
     enabled: computed(() => layerKey.value.length > 0 && !!service.value),
     staleTime: 30_000,
   });
+  useQueryEvents<InstancesResp>('instances', q, {
+    start: () => `Loading instances for ${service.value}…`,
+    ok: (r) => {
+      const n = r.instances?.length ?? 0;
+      return `Loaded ${n} instance${n === 1 ? '' : 's'} for ${service.value}`;
+    },
+    err: (e) => `Instance list failed: ${e instanceof Error ? e.message : 
String(e)}`,
+    cached: (r) => `Instances cached (${r.instances?.length ?? 0}) for 
${service.value}`,
+  });
   return {
     data: computed(() => q.data.value ?? null),
     instances: computed(() => q.data.value?.instances ?? []),
diff --git a/apps/ui/src/layer/useLayerLanding.ts 
b/apps/ui/src/layer/useLayerLanding.ts
index f2d71b2..92e6d99 100644
--- a/apps/ui/src/layer/useLayerLanding.ts
+++ b/apps/ui/src/layer/useLayerLanding.ts
@@ -18,7 +18,7 @@
 import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import { useAutoRefreshSubscribe } from '../controls/useAutoRefreshSubscribe';
-import { pushEvent } from '@/controls/eventLog';
+import { useQueryEvents } from '@/controls/useQueryEvents';
 import type { LandingConfig, LandingResponse, LayerDef } from 
'@skywalking-horizon-ui/api-client';
 import { bffClient } from '@/api/client';
 
@@ -49,23 +49,24 @@ export function useLayerLanding(
 
   const q = useQuery({
     queryKey: ['layer-landing', layerKey, cfgHash],
-    queryFn: async () => {
-      pushEvent('services', 'start', `Loading services for 
${layerKey.value}…`);
-      try {
-        const r = await bffClient.layer.landing(layerKey.value, cfg.value);
-        const count = (r.sampledRows ?? r.rows ?? []).length;
-        pushEvent('services', 'ok', `Loaded ${count} service${count === 1 ? '' 
: 's'} for ${layerKey.value}`);
-        return r;
-      } catch (err) {
-        pushEvent('services', 'err', `Service list failed: ${err instanceof 
Error ? err.message : String(err)}`);
-        throw err;
-      }
-    },
+    queryFn: () => bffClient.layer.landing(layerKey.value, cfg.value),
     staleTime: 45_000,
     refetchInterval: 60_000,
     refetchOnWindowFocus: true,
     retry: 1,
   });
+  useQueryEvents<LandingResponse>('services', q, {
+    start: () => `Loading services for ${layerKey.value}…`,
+    ok: (r) => {
+      const count = (r.sampledRows ?? r.rows ?? []).length;
+      return `Loaded ${count} service${count === 1 ? '' : 's'} for 
${layerKey.value}`;
+    },
+    err: (e) => `Service list failed: ${e instanceof Error ? e.message : 
String(e)}`,
+    cached: (r) => {
+      const count = (r.sampledRows ?? r.rows ?? []).length;
+      return `Services cached (${count}) for ${layerKey.value}`;
+    },
+  });
 
   useAutoRefreshSubscribe(() => q.refetch());
 
diff --git a/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts 
b/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
index 5e44264..39acda3 100644
--- a/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
+++ b/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
@@ -29,7 +29,7 @@
 
 import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
-import { pushEvent } from '@/controls/eventLog';
+import { useQueryEvents } from '@/controls/useQueryEvents';
 import { useAutoRefreshSubscribe } from 
'../../controls/useAutoRefreshSubscribe';
 import { bffClient } from '@/api/client';
 import {
@@ -37,7 +37,7 @@ import {
   getDashboardConfig,
   useConfigBundle,
 } from '@/controls/configBundle';
-import type { DashboardConfig } from '@skywalking-horizon-ui/api-client';
+import type { DashboardConfig, DashboardResponse } from 
'@skywalking-horizon-ui/api-client';
 
 export function useLayerDashboardConfig(layerKey: Ref<string>, scope?: 
Ref<string>) {
   // Prefer the preloaded bundle. The bundle preload kicks off at app
@@ -110,41 +110,17 @@ export function useLayerDashboard(
       entityRefs.instance ?? computed(() => null),
       entityRefs.endpoint ?? computed(() => null),
     ],
-    queryFn: async () => {
-      const s = scope?.value ?? 'service';
-      const label = entityRefs.instance?.value
-        ? `${service.value} / ${entityRefs.instance.value}`
-        : entityRefs.endpoint?.value
-          ? `${service.value} / ${entityRefs.endpoint.value}`
-          : service.value ?? layerKey.value;
-      pushEvent('dashboard', 'start', `Querying ${s} dashboard for ${label}…`);
-      try {
-        const r = await bffClient.layer.dashboard(
-          layerKey.value,
-          {
-            ...(service.value ? { service: service.value } : {}),
-            ...(scope?.value ? { scope: scope.value } : {}),
-            ...(entityRefs.instance?.value ? { serviceInstance: 
entityRefs.instance.value } : {}),
-            ...(entityRefs.endpoint?.value ? { endpoint: 
entityRefs.endpoint.value } : {}),
-          },
-          mockTop?.value ? { mockTop: mockTop.value } : {},
-        );
-        const total = r.widgets?.length ?? 0;
-        const withData = (r.widgets ?? []).filter(
-          (w) =>
-            !w.error &&
-            (w.value != null ||
-              (w.series?.length ?? 0) > 0 ||
-              (w.topList?.length ?? 0) > 0 ||
-              (w.topGroups?.length ?? 0) > 0),
-        ).length;
-        pushEvent('dashboard', 'ok', `Rendered ${withData}/${total} 
widget${total === 1 ? '' : 's'} with data`);
-        return r;
-      } catch (err) {
-        pushEvent('dashboard', 'err', `Dashboard query failed: ${err 
instanceof Error ? err.message : String(err)}`);
-        throw err;
-      }
-    },
+    queryFn: () =>
+      bffClient.layer.dashboard(
+        layerKey.value,
+        {
+          ...(service.value ? { service: service.value } : {}),
+          ...(scope?.value ? { scope: scope.value } : {}),
+          ...(entityRefs.instance?.value ? { serviceInstance: 
entityRefs.instance.value } : {}),
+          ...(entityRefs.endpoint?.value ? { endpoint: 
entityRefs.endpoint.value } : {}),
+        },
+        mockTop?.value ? { mockTop: mockTop.value } : {},
+      ),
     // Gate the metric query on the entity actually being resolved.
     // Otherwise the dashboard fires twice on landing: once with
     // `service: null` (BFF then auto-picks the first service by
@@ -171,6 +147,30 @@ export function useLayerDashboard(
     refetchOnWindowFocus: computed(() => METRIC_SCOPES.has(scope?.value ?? 
'service')),
     retry: 1,
   });
+  useQueryEvents<DashboardResponse>('dashboard', q, {
+    start: () => {
+      const s = scope?.value ?? 'service';
+      const label = entityRefs.instance?.value
+        ? `${service.value} / ${entityRefs.instance.value}`
+        : entityRefs.endpoint?.value
+          ? `${service.value} / ${entityRefs.endpoint.value}`
+          : service.value ?? layerKey.value;
+      return `Querying ${s} dashboard for ${label}…`;
+    },
+    ok: (r) => {
+      const total = r.widgets?.length ?? 0;
+      const withData = (r.widgets ?? []).filter(
+        (w) =>
+          !w.error &&
+          (w.value != null ||
+            (w.series?.length ?? 0) > 0 ||
+            (w.topList?.length ?? 0) > 0 ||
+            (w.topGroups?.length ?? 0) > 0),
+      ).length;
+      return `Rendered ${withData}/${total} widget${total === 1 ? '' : 's'} 
with data`;
+    },
+    err: (e) => `Dashboard query failed: ${e instanceof Error ? e.message : 
String(e)}`,
+  });
   return {
     data: computed(() => q.data.value ?? null),
     isLoading: q.isLoading,

Reply via email to