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 27ca015  ui events: replace per-query firings with a sequential 
page-init orchestrator
27ca015 is described below

commit 27ca01574fea84deb2ec5a146b57396fba9bf5aa
Author: Wu Sheng <[email protected]>
AuthorDate: Sun May 17 11:58:52 2026 +0800

    ui events: replace per-query firings with a sequential page-init 
orchestrator
    
    The previous design pushed events from inside individual query
    composables (useLayerLanding / Instances / Endpoints /
    useLayerDashboard) — that surfaced as a scrambled timeline in
    the EventTicker because vue-queries fire in parallel and complete
    in race order, not in dependency order. The operator complained
    the sequence looked random.
    
    New design: a single useLayerPageOrchestrator owns the event
    stream. It watches the reactive output of the existing query
    composables and emits one phase event at a time, gated by the
    prior phase:
    
      1. config       → bundle / network config resolved
      2. services     → service list arrived
      3. service      → effective service is resolved (URL or auto)
      4. instances    → instance list arrived (scope=instance only)
      5. instance     → effective instance resolved
      4. endpoints    → endpoint list arrived (scope=endpoint only)
      5. endpoint     → effective endpoint resolved
      6. dashboard    → widget MQE batch returned
    
    Each step only fires after its prereq stamp is true and re-arms
    on (layerKey, scope) change so a sidebar nav produces a fresh
    top-to-bottom sequence.
    
    Side-effects:
    - Removed per-query pushEvent / useQueryEvents calls from the
      individual composables; they're now data-only. The orchestrator
      is the single source of init events.
    - Removed the now-unused controls/useQueryEvents.ts helper.
---
 apps/ui/src/controls/useQueryEvents.ts             |  94 --------
 apps/ui/src/layer/useLayerEndpoints.ts             |  15 --
 apps/ui/src/layer/useLayerInstances.ts             |  12 -
 apps/ui/src/layer/useLayerLanding.ts               |  13 -
 .../render/layer-dashboard/LayerDashboardsView.vue |  19 ++
 .../render/layer-dashboard/useLayerDashboard.ts    |  57 +----
 .../layer-dashboard/useLayerPageOrchestrator.ts    | 261 +++++++++++++++++++++
 7 files changed, 283 insertions(+), 188 deletions(-)

diff --git a/apps/ui/src/controls/useQueryEvents.ts 
b/apps/ui/src/controls/useQueryEvents.ts
deleted file mode 100644
index ce685b3..0000000
--- a/apps/ui/src/controls/useQueryEvents.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * 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 {
-  // Snapshot the mount-time state. vue-query can flip `isFetching`
-  // synchronously during the surrounding `useQuery(...)` call (the
-  // query is auto-fired the moment `enabled` is truthy), so by the
-  // time this composable runs we may have already missed the
-  // false→true edge. Cover the three possible initial states
-  // explicitly:
-  //   - in-flight  ⇒ retroactive `start` (the watch below will then
-  //                    pick up the matching falling edge as `ok`/`err`).
-  //   - cache hit  ⇒ one-shot `info` line so the ticker doesn't go
-  //                    silent on fast-path revisits.
-  //   - empty       ⇒ no event yet; the watch will catch the upcoming
-  //                    rising edge once the query actually fires.
-  if (q.isFetching.value) {
-    pushEvent(topic, 'start', labels.start());
-  } else if (labels.cached && q.data.value !== undefined) {
-    pushEvent(topic, 'info', labels.cached(q.data.value));
-  }
-
-  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 e07f759..6315c4c 100644
--- a/apps/ui/src/layer/useLayerEndpoints.ts
+++ b/apps/ui/src/layer/useLayerEndpoints.ts
@@ -26,9 +26,6 @@
 import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import { bffClient } from '@/api/client';
-import { useQueryEvents } from '@/controls/useQueryEvents';
-
-type EndpointsResp = Awaited<ReturnType<typeof bffClient.layer.endpoints>>;
 
 export function useLayerEndpoints(
   layerKey: Ref<string>,
@@ -43,18 +40,6 @@ export function useLayerEndpoints(
     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 5915c67..2f7977a 100644
--- a/apps/ui/src/layer/useLayerInstances.ts
+++ b/apps/ui/src/layer/useLayerInstances.ts
@@ -25,9 +25,6 @@
 import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import { bffClient } from '@/api/client';
-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({
@@ -36,15 +33,6 @@ export function useLayerInstances(layerKey: Ref<string>, 
service: Ref<string | n
     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 92e6d99..b22b199 100644
--- a/apps/ui/src/layer/useLayerLanding.ts
+++ b/apps/ui/src/layer/useLayerLanding.ts
@@ -18,7 +18,6 @@
 import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import { useAutoRefreshSubscribe } from '../controls/useAutoRefreshSubscribe';
-import { useQueryEvents } from '@/controls/useQueryEvents';
 import type { LandingConfig, LandingResponse, LayerDef } from 
'@skywalking-horizon-ui/api-client';
 import { bffClient } from '@/api/client';
 
@@ -55,18 +54,6 @@ export function useLayerLanding(
     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/LayerDashboardsView.vue 
b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
index 800ab83..122c8f2 100644
--- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
@@ -33,6 +33,7 @@ import TimeChart from '@/components/charts/TimeChart.vue';
 import TopList from '@/components/charts/TopList.vue';
 import { colorForMetric } from '@/utils/metricColor';
 import { useLayerDashboard, useLayerDashboardConfig } from 
'@/render/layer-dashboard/useLayerDashboard';
+import { useLayerPageOrchestrator } from 
'@/render/layer-dashboard/useLayerPageOrchestrator';
 import { useLayerEndpoints } from '@/layer/useLayerEndpoints';
 import { useLayerInstances } from '@/layer/useLayerInstances';
 import { useLayerLanding } from '@/layer/useLayerLanding';
@@ -231,6 +232,24 @@ const { data, isFetching, error } = useLayerDashboard(
   { instance: selectedInstance, endpoint: selectedEndpoint },
 );
 
+// Sequential page-init events for the EventTicker — config →
+// services → service → instances/endpoints → instance/endpoint →
+// dashboard. The orchestrator watches the refs above and emits one
+// numbered step at a time, so the ticker reads top-to-bottom in
+// dependency order even when the underlying vue-queries race.
+useLayerPageOrchestrator({
+  layerKey,
+  scope,
+  config,
+  serviceList: landingRows,
+  effectiveService: serviceName,
+  instanceList,
+  effectiveInstance: selectedInstance,
+  endpointList,
+  effectiveEndpoint: selectedEndpoint,
+  dashboard: data,
+});
+
 const widgets = computed(() => config.value?.widgets ?? []);
 const resultsById = computed(() => {
   const out = new Map<string, NonNullable<typeof 
data.value>['widgets'][number]>();
diff --git a/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts 
b/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
index 8311ce1..8b54611 100644
--- a/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
+++ b/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
@@ -27,10 +27,8 @@
  * viewed service is instant.
  */
 
-import { computed, watch, type Ref } from 'vue';
+import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
-import { useQueryEvents } from '@/controls/useQueryEvents';
-import { pushEvent } from '@/controls/eventLog';
 import { useAutoRefreshSubscribe } from 
'../../controls/useAutoRefreshSubscribe';
 import { bffClient } from '@/api/client';
 import {
@@ -38,7 +36,7 @@ import {
   getDashboardConfig,
   useConfigBundle,
 } from '@/controls/configBundle';
-import type { DashboardConfig, DashboardResponse } from 
'@skywalking-horizon-ui/api-client';
+import type { DashboardConfig } 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
@@ -64,33 +62,8 @@ export function useLayerDashboardConfig(layerKey: 
Ref<string>, scope?: Ref<strin
   });
   useAutoRefreshSubscribe(() => q.refetch());
 
-  const config = computed(() => bundled.value ?? q.data.value ?? null);
-  // Surface the config-resolution step in the event ticker so the
-  // operator sees the page-assembly sequence start at the very top
-  // (config → services → instances/endpoints → widgets). The bundle
-  // hit fires the moment localStorage resolves; the network fallback
-  // gets its own start/ok event via useQueryEvents below.
-  let configReported = false;
-  watch(
-    config,
-    (c) => {
-      if (!c || configReported) return;
-      const s = (scope?.value ?? 'service') as string;
-      const widgetN = c.widgets?.length ?? 0;
-      const source = bundled.value ? 'preloaded' : 'network';
-      pushEvent('config', 'info', `${s} dashboard config ready · ${widgetN} 
widget${widgetN === 1 ? '' : 's'} (${source})`);
-      configReported = true;
-    },
-    { immediate: true },
-  );
-  useQueryEvents('config-net', q, {
-    start: () => `Fetching ${scope?.value ?? 'service'} dashboard config for 
${layerKey.value}…`,
-    ok: () => `Dashboard config loaded from BFF`,
-    err: (e) => `Dashboard config fetch failed: ${e instanceof Error ? 
e.message : String(e)}`,
-  });
-
   return {
-    config,
+    config: computed(() => bundled.value ?? q.data.value ?? null),
     isLoading: computed(() => !loaded.value && q.isLoading.value),
     error: q.error,
   };
@@ -173,30 +146,6 @@ 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,
diff --git a/apps/ui/src/render/layer-dashboard/useLayerPageOrchestrator.ts 
b/apps/ui/src/render/layer-dashboard/useLayerPageOrchestrator.ts
new file mode 100644
index 0000000..6b82d3a
--- /dev/null
+++ b/apps/ui/src/render/layer-dashboard/useLayerPageOrchestrator.ts
@@ -0,0 +1,261 @@
+/*
+ * 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.
+ */
+
+/**
+ * Page-init state machine for the layer dashboard view. Drives the
+ * EventTicker with a strict ordered sequence — each phase only emits
+ * once the prior phase has reported ready — instead of the noisy
+ * "every vue-query fire emits an event" pattern that surfaced as a
+ * scrambled timeline whenever queries raced.
+ *
+ * Phases (one event each, in this order):
+ *   1. config       — dashboard widget set resolved (from preloaded
+ *                     bundle or BFF fallback).
+ *   2. services     — service list loaded (or service auto-picked
+ *                     from the existing URL pick).
+ *   3. service      — effective service is resolved + visible to the
+ *                     widgets that need it.
+ *   4. instances    — only fires when scope === 'instance'.
+ *   5. instance     — effective instance is resolved.
+ *   6. endpoints    — only fires when scope === 'endpoint'.
+ *   7. endpoint     — effective endpoint is resolved.
+ *   8. dashboard    — widget MQE batch returned, widgets rendered.
+ *
+ * The orchestrator doesn't replace the existing vue-query
+ * composables — those still own the actual fetching. It watches
+ * their reactive state and gates the next phase's event on the
+ * prior phase being marked done, so the EventTicker always reads
+ * top-to-bottom in dependency order regardless of which network
+ * call physically returns first.
+ *
+ * Per-route reset: the orchestrator re-arms whenever the
+ * (layerKey, scope) pair changes so a sidebar click produces a
+ * fresh top-to-bottom sequence instead of accumulating phases from
+ * a prior page.
+ */
+
+import { reactive, ref, watch, type Ref } from 'vue';
+import { pushEvent } from '@/controls/eventLog';
+
+export interface OrchestratorRefs {
+  layerKey: Ref<string>;
+  scope: Ref<string>;
+  /** Dashboard widget config (preload bundle or network). */
+  config: Ref<{ widgets?: unknown[] } | null>;
+  /** Landing rows — the service list. */
+  serviceList: Ref<ReadonlyArray<{ serviceId: string; serviceName: string }>>;
+  /** Currently-effective service name (URL pick or auto-pick). */
+  effectiveService: Ref<string | null>;
+  /** Instance list for the current service. Only consulted when
+   *  scope === 'instance'. */
+  instanceList: Ref<ReadonlyArray<{ id: string; name: string }>>;
+  /** Currently-effective instance pick (URL or auto). */
+  effectiveInstance: Ref<string | null>;
+  /** Endpoint list — only consulted when scope === 'endpoint'. */
+  endpointList: Ref<ReadonlyArray<{ id: string; name: string }>>;
+  effectiveEndpoint: Ref<string | null>;
+  /** Dashboard response — widgets rendered. */
+  dashboard: Ref<{ widgets?: Array<{ error?: string; value?: unknown; series?: 
unknown[]; topList?: unknown[]; topGroups?: unknown[] }> } | null>;
+}
+
+type Phase =
+  | 'config'
+  | 'services'
+  | 'service'
+  | 'instances'
+  | 'instance'
+  | 'endpoints'
+  | 'endpoint'
+  | 'dashboard';
+
+interface PhaseStamps {
+  config: boolean;
+  services: boolean;
+  service: boolean;
+  instances: boolean;
+  instance: boolean;
+  endpoints: boolean;
+  endpoint: boolean;
+  dashboard: boolean;
+}
+function freshStamps(): PhaseStamps {
+  return {
+    config: false,
+    services: false,
+    service: false,
+    instances: false,
+    instance: false,
+    endpoints: false,
+    endpoint: false,
+    dashboard: false,
+  };
+}
+
+/**
+ * Returns a flag that mirrors whether the orchestrator has reached
+ * the final 'dashboard' phase — view code can use it to gate optional
+ * spinners or skip-skeleton paints if it wants to.
+ */
+export function useLayerPageOrchestrator(refs: OrchestratorRefs): {
+  done: Ref<boolean>;
+} {
+  const stamps = reactive(freshStamps());
+  const done = ref(false);
+  // ----------------------------------------------------------------
+  // The cascade. Each step emits exactly once per (layerKey, scope)
+  // arming, only after the prior step is `true`.
+  // ----------------------------------------------------------------
+
+  function report(phase: Phase, text: string): void {
+    pushEvent(`init/${phase}`, 'ok', text);
+  }
+
+  // 1. config
+  watch(
+    () => refs.config.value,
+    (c) => {
+      if (!c || stamps.config) return;
+      const n = c.widgets?.length ?? 0;
+      report('config', `Step 1 · config ready · ${n} widget${n === 1 ? '' : 
's'}`);
+      stamps.config = true;
+    },
+    { immediate: true },
+  );
+
+  // 2. services (after config)
+  watch(
+    [() => stamps.config, () => refs.serviceList.value.length],
+    ([configDone, count]) => {
+      if (!configDone || stamps.services) return;
+      if (count === 0) return;
+      report('services', `Step 2 · services ready · ${count} service${count 
=== 1 ? '' : 's'}`);
+      stamps.services = true;
+    },
+    { immediate: true },
+  );
+
+  // 3. service resolved (after services)
+  watch(
+    [() => stamps.services, () => refs.effectiveService.value],
+    ([servicesDone, svc]) => {
+      if (!servicesDone || stamps.service) return;
+      if (!svc) return;
+      report('service', `Step 3 · service: ${svc}`);
+      stamps.service = true;
+    },
+    { immediate: true },
+  );
+
+  // 4a. instances (after service, scope=instance only)
+  watch(
+    [
+      () => stamps.service,
+      () => refs.scope.value,
+      () => refs.instanceList.value.length,
+    ],
+    ([serviceDone, scope, count]) => {
+      if (scope !== 'instance') return;
+      if (!serviceDone || stamps.instances) return;
+      if (count === 0) return;
+      report('instances', `Step 4 · instances ready · ${count} instance${count 
=== 1 ? '' : 's'}`);
+      stamps.instances = true;
+    },
+    { immediate: true },
+  );
+
+  // 5a. instance resolved (after instances)
+  watch(
+    [() => stamps.instances, () => refs.effectiveInstance.value, () => 
refs.scope.value],
+    ([instancesDone, inst, scope]) => {
+      if (scope !== 'instance') return;
+      if (!instancesDone || stamps.instance) return;
+      if (!inst) return;
+      report('instance', `Step 5 · instance: ${inst}`);
+      stamps.instance = true;
+    },
+    { immediate: true },
+  );
+
+  // 4b. endpoints (after service, scope=endpoint only)
+  watch(
+    [
+      () => stamps.service,
+      () => refs.scope.value,
+      () => refs.endpointList.value.length,
+    ],
+    ([serviceDone, scope, count]) => {
+      if (scope !== 'endpoint') return;
+      if (!serviceDone || stamps.endpoints) return;
+      if (count === 0) return;
+      report('endpoints', `Step 4 · endpoints ready · ${count} endpoint${count 
=== 1 ? '' : 's'}`);
+      stamps.endpoints = true;
+    },
+    { immediate: true },
+  );
+
+  // 5b. endpoint resolved
+  watch(
+    [() => stamps.endpoints, () => refs.effectiveEndpoint.value, () => 
refs.scope.value],
+    ([endpointsDone, ep, scope]) => {
+      if (scope !== 'endpoint') return;
+      if (!endpointsDone || stamps.endpoint) return;
+      if (!ep) return;
+      report('endpoint', `Step 5 · endpoint: ${ep}`);
+      stamps.endpoint = true;
+    },
+    { immediate: true },
+  );
+
+  // 6. dashboard (after the deepest prereq for the scope)
+  watch(
+    [() => refs.scope.value, () => refs.dashboard.value, () => ({ ...stamps 
})],
+    ([scope, d, st]) => {
+      if (!d || stamps.dashboard) return;
+      // Gate by the deepest required stamp for the active scope.
+      if (scope === 'instance' && !st.instance) return;
+      if (scope === 'endpoint' && !st.endpoint) return;
+      if (scope === 'service' && !st.service) return;
+      const widgets = d.widgets ?? [];
+      const total = widgets.length;
+      const withData = widgets.filter(
+        (w) =>
+          !w.error &&
+          (w.value != null ||
+            (Array.isArray(w.series) && w.series.length > 0) ||
+            (Array.isArray(w.topList) && w.topList.length > 0) ||
+            (Array.isArray(w.topGroups) && w.topGroups.length > 0)),
+      ).length;
+      report('dashboard', `Step 6 · widgets rendered · ${withData}/${total} 
with data`);
+      stamps.dashboard = true;
+      done.value = true;
+    },
+    { immediate: true },
+  );
+
+  // Re-arm on route / scope change. Both the stamps and `done` reset
+  // so the next page produces a fresh top-to-bottom sequence; the
+  // event log itself is reset by the router.afterEach hook.
+  watch(
+    [() => refs.layerKey.value, () => refs.scope.value],
+    () => {
+      Object.assign(stamps, freshStamps());
+      done.value = false;
+    },
+  );
+
+  return { done };
+}

Reply via email to