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