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,