This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/ai-history-and-enhancement
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit d3ae9c0818560ed21ac98fcd85944dfb0e8680b8
Author: Wu Sheng <[email protected]>
AuthorDate: Wed Jul 8 16:12:42 2026 +0800

    fix(ai): replay must not touch the live query cache; harden replay fallbacks
    
    Four review findings on the replay/replayData static-file model:
    
    - HIGH — the five map composables seeded captured data via vue-query
      `initialData` on the SAME query keys live views use, so a chat snapshot
      could serve a live view during staleTime (and a live result could bleed
      into a replay). Replay now renders straight from `replayData` locally
      (`replay ? replayData : q.data`) and never enters the shared cache.
    
    - MEDIUM — a replay=true endpoint-dependency block with no `replayData`
      (old / corrupt) rendered empty: replay disabled the endpoint list and
      returned before auto-picking. Gate replay behavior on
      `props.replay && props.replayData` so a payload-less block falls back to
      live, matching the other map blocks.
    
    - MEDIUM — show_topology's template-store-blocked path emitted a topology
      block with reachable:false but no frozen response, which the seeded view
      treated as live and could re-query. It now attaches an
      emptyTopologyResponse, honoring the zero-query replay contract.
    
    - LOW — HistoryStore.save() returns the kept set after byte-cap eviction,
      but persist() ignored it, so the UI kept showing conversations IndexedDB
      had dropped until reload. persist() now reconciles the in-memory list
      (and re-points currentId if the open conversation was evicted).
    
    Live-validated show_topology on the demo OAP: the success path still
    streams full replayData (nodes+edges with metrics + edge series).
---
 apps/bff/src/ai/skill/visualization/tools.ts               | 13 ++++++++++++-
 apps/ui/src/ai/useAiConversations.ts                       | 14 +++++++++++++-
 .../endpoint-dependency/LayerEndpointDependencyView.vue    | 11 ++++++++---
 .../endpoint-dependency/useLayerEndpointDependency.ts      | 11 +++++++----
 apps/ui/src/layer/service-map/useDeployment.ts             | 11 +++++++----
 apps/ui/src/layer/service-map/useInstanceTopology.ts       | 11 +++++++----
 apps/ui/src/layer/service-map/useLayerTopology.ts          | 13 ++++++++-----
 apps/ui/src/layer/service-map/useServiceHierarchy.ts       | 11 ++++++++---
 8 files changed, 70 insertions(+), 25 deletions(-)

diff --git a/apps/bff/src/ai/skill/visualization/tools.ts 
b/apps/bff/src/ai/skill/visualization/tools.ts
index c8f27c2..2fb3331 100644
--- a/apps/bff/src/ai/skill/visualization/tools.ts
+++ b/apps/bff/src/ai/skill/visualization/tools.ts
@@ -49,7 +49,7 @@ import {
 import { flattenTabWidgets } from '../../../logic/dashboard/gates.js';
 import { serviceLayerCatalog } from 
'../../../logic/services/service-layer-catalog.js';
 import { getServiceHierarchy } from '../../../logic/oap/hierarchy.js';
-import { buildServiceTopology } from '../../../logic/oap/service-topology.js';
+import { buildServiceTopology, emptyTopologyResponse } from 
'../../../logic/oap/service-topology.js';
 import { buildDeployment } from '../../../logic/oap/deployment.js';
 import { buildInstanceTopology } from 
'../../../logic/oap/instance-topology.js';
 import { buildEndpointDependency } from 
'../../../logic/oap/endpoint-dependency.js';
@@ -480,6 +480,17 @@ export function visualizationTools(ctx: AiRequestContext): 
StructuredToolInterfa
           reachable: false,
           errorReason: 'template store unreachable',
           windowMinutes,
+          // Frozen empty response so the seeded view replays the unreachable
+          // state on reload instead of treating a missing payload as live and
+          // re-querying — the same zero-query contract as the success path.
+          replayData: emptyTopologyResponse(
+            layer.toUpperCase(),
+            row.id,
+            1,
+            topologyConfigFor(null),
+            false,
+            'template store unreachable',
+          ),
         });
         return `Topology for ${service} is unavailable (template store 
unreachable).`;
       }
diff --git a/apps/ui/src/ai/useAiConversations.ts 
b/apps/ui/src/ai/useAiConversations.ts
index c57d1ac..dd0e0e8 100644
--- a/apps/ui/src/ai/useAiConversations.ts
+++ b/apps/ui/src/ai/useAiConversations.ts
@@ -127,8 +127,20 @@ function persist(): void {
   const s = store;
   const own = owner;
   const wasReal = storeIsReal;
-  void s.save(own, conversations.value).then(() => {
+  const snapshot = conversations.value;
+  void s.save(own, snapshot).then((kept) => {
     if (wasReal) channel?.postMessage({ owner: own });
+    // Reflect byte-cap eviction in the in-memory list so the UI doesn't keep
+    // showing conversations IndexedDB already dropped. Only the snapshot's own
+    // rows can be evicted — anything added after the save() call is untouched.
+    if (own !== owner || kept.length === snapshot.length) return;
+    const keptIds = new Set(kept.map((c) => c.id));
+    const evicted = new Set(snapshot.filter((c) => !keptIds.has(c.id)).map((c) 
=> c.id));
+    if (evicted.size === 0) return;
+    conversations.value = conversations.value.filter((c) => 
!evicted.has(c.id));
+    if (currentId.value && evicted.has(currentId.value)) {
+      currentId.value = conversations.value[0]?.id ?? null;
+    }
   });
 }
 
diff --git 
a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue 
b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
index 243c959..214b574 100644
--- a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
+++ b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
@@ -118,9 +118,14 @@ const namingRule = computed(() => layer.value?.naming ?? 
null);
 function identity(name: string | null | undefined): ServiceIdentity {
   return resolveServiceIdentity(name, namingRule.value);
 }
+// Effective replay: an intentional replay block MUST also carry replayData to
+// render statically. A replay=true block with no captured payload (old / 
corrupt
+// / fallback) drops to live behavior instead of rendering empty — matching the
+// other map blocks, whose composables already key replay off replayData 
presence.
+const isReplay = computed(() => props.replay === true && !!props.replayData);
 // A replay map takes its service from props.focusService (not the landing 
rollup)
 // and hides the picker, so it fires ZERO landing queries — gated by replay 
mode.
-const landing = useLayerLanding(safeLayer, safeCfg, undefined, computed(() => 
props.replay ?? false));
+const landing = useLayerLanding(safeLayer, safeCfg, undefined, isReplay);
 const resolvedServiceName = useLayerServiceName(layerKey, landing);
 const serviceName = computed<string | null>(() =>
   embedded.value ? (props.focusService ?? null) : resolvedServiceName.value,
@@ -155,7 +160,7 @@ const { endpoints: endpointList, isFetching: 
endpointsLoading } = useLayerEndpoi
   endpointQuery,
   endpointLimit,
   // A replay map pins its endpoint from replayData + hides the picker — no 
fetch.
-  computed(() => props.replay ?? false),
+  isReplay,
 );
 watch(serviceName, (next, prev) => {
   if (prev !== undefined && next !== prev && selectedEndpoint.value) {
@@ -170,7 +175,7 @@ watchEffect(() => {
   // endpointId (its focused node) so the graph card renders — the template 
gates
   // on a non-null selectedEndpoint — and never auto-pick over it, or a reload
   // would redraw a different (now-busier) endpoint.
-  if (props.replay) {
+  if (isReplay.value) {
     if (!selectedEndpoint.value) {
       const pinned = props.replayData?.nodes.find((n) => n.id === 
props.replayData?.endpointId)?.name;
       if (pinned) setSelectedEndpoint(pinned);
diff --git 
a/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts 
b/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
index 20b4ed2..bb97402 100644
--- a/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
+++ b/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
@@ -71,15 +71,18 @@ export function useLayerEndpointDependency(
     enabled: computed(
       () => layerKey.value.length > 0 && !!service.value && !!endpoint.value 
&& !replay.value,
     ),
-    initialData: () => replayData?.value ?? undefined,
     staleTime: 30_000,
   });
   if (!ownsWindow && !replay.value) useAutoRefreshSubscribe(() => q.refetch());
 
+  // Replay renders straight from the captured payload — NOT through the shared
+  // query cache. Seeding initialData under the live query key would let a chat
+  // snapshot serve a live view during staleTime (and vice-versa).
+  const data = computed(() => (replay.value ? (replayData?.value ?? null) : 
(q.data.value ?? null)));
   return {
-    data: computed(() => q.data.value ?? null),
-    nodes: computed(() => q.data.value?.nodes ?? []),
-    calls: computed(() => q.data.value?.calls ?? []),
+    data,
+    nodes: computed(() => data.value?.nodes ?? []),
+    calls: computed(() => data.value?.calls ?? []),
     isLoading: q.isLoading,
     isFetching: q.isFetching,
     error: q.error,
diff --git a/apps/ui/src/layer/service-map/useDeployment.ts 
b/apps/ui/src/layer/service-map/useDeployment.ts
index 4089035..aeeaef7 100644
--- a/apps/ui/src/layer/service-map/useDeployment.ts
+++ b/apps/ui/src/layer/service-map/useDeployment.ts
@@ -75,7 +75,6 @@ export function useDeployment(
         previewCfg.value,
       ),
     enabled: isEnabled,
-    initialData: () => replayData?.value ?? undefined,
     staleTime: 30_000,
   });
   // Only ride the global ticker while the view is active — a forced refetch
@@ -87,10 +86,14 @@ export function useDeployment(
     });
   }
 
+  // Replay renders straight from the captured payload — NOT through the shared
+  // query cache. Seeding initialData under the live query key would let a chat
+  // snapshot serve a live view during staleTime (and vice-versa).
+  const data = computed(() => (replay.value ? (replayData?.value ?? null) : 
(q.data.value ?? null)));
   return {
-    data: computed(() => q.data.value ?? null),
-    nodes: computed(() => q.data.value?.nodes ?? []),
-    calls: computed(() => q.data.value?.calls ?? []),
+    data,
+    nodes: computed(() => data.value?.nodes ?? []),
+    calls: computed(() => data.value?.calls ?? []),
     isLoading: q.isLoading,
     isFetching: q.isFetching,
     error: q.error,
diff --git a/apps/ui/src/layer/service-map/useInstanceTopology.ts 
b/apps/ui/src/layer/service-map/useInstanceTopology.ts
index 382621a..c6b1b8f 100644
--- a/apps/ui/src/layer/service-map/useInstanceTopology.ts
+++ b/apps/ui/src/layer/service-map/useInstanceTopology.ts
@@ -83,7 +83,6 @@ export function useInstanceTopology(
         previewCfg.value,
       ),
     enabled: isEnabled,
-    initialData: () => replayData?.value ?? undefined,
     staleTime: 30_000,
   });
   // Only ride the global ticker while the view is active — a forced
@@ -95,10 +94,14 @@ export function useInstanceTopology(
     });
   }
 
+  // Replay renders straight from the captured payload — NOT through the shared
+  // query cache. Seeding initialData under the live query key would let a chat
+  // snapshot serve a live view during staleTime (and vice-versa).
+  const data = computed(() => (replay.value ? (replayData?.value ?? null) : 
(q.data.value ?? null)));
   return {
-    data: computed(() => q.data.value ?? null),
-    nodes: computed(() => q.data.value?.nodes ?? []),
-    calls: computed(() => q.data.value?.calls ?? []),
+    data,
+    nodes: computed(() => data.value?.nodes ?? []),
+    calls: computed(() => data.value?.calls ?? []),
     isLoading: q.isLoading,
     isFetching: q.isFetching,
     error: q.error,
diff --git a/apps/ui/src/layer/service-map/useLayerTopology.ts 
b/apps/ui/src/layer/service-map/useLayerTopology.ts
index df4f3b0..309e9dd 100644
--- a/apps/ui/src/layer/service-map/useLayerTopology.ts
+++ b/apps/ui/src/layer/service-map/useLayerTopology.ts
@@ -83,9 +83,8 @@ export function useLayerTopology(
         rangeKey.value,
         previewCfg.value,
       ),
-    // Replay is static: start with the captured graph and never fetch.
+    // Replay is static: never fetch (data comes from replayData below).
     enabled: computed(() => layerKey.value.length > 0 && !replay.value),
-    initialData: () => replayData?.value ?? undefined,
     staleTime: 30_000,
   });
   // The embedded chat map owns its own frozen window, so it must NOT refetch 
on
@@ -93,10 +92,14 @@ export function useLayerTopology(
   // fetches either.
   if (!ownsWindow && !replay.value) useAutoRefreshSubscribe(() => q.refetch());
 
+  // Replay renders straight from the captured payload — NOT through the shared
+  // query cache. Seeding initialData under the live query key would let a chat
+  // snapshot serve a live view during staleTime (and vice-versa).
+  const data = computed(() => (replay.value ? (replayData?.value ?? null) : 
(q.data.value ?? null)));
   return {
-    data: computed(() => q.data.value ?? null),
-    nodes: computed(() => q.data.value?.nodes ?? []),
-    calls: computed(() => q.data.value?.calls ?? []),
+    data,
+    nodes: computed(() => data.value?.nodes ?? []),
+    calls: computed(() => data.value?.calls ?? []),
     isLoading: q.isLoading,
     isFetching: q.isFetching,
     error: q.error,
diff --git a/apps/ui/src/layer/service-map/useServiceHierarchy.ts 
b/apps/ui/src/layer/service-map/useServiceHierarchy.ts
index 23d05cf..4dfd34c 100644
--- a/apps/ui/src/layer/service-map/useServiceHierarchy.ts
+++ b/apps/ui/src/layer/service-map/useServiceHierarchy.ts
@@ -47,16 +47,21 @@ export function useServiceHierarchy(
     queryFn: (): Promise<ServiceHierarchyResponse> =>
       bffClient.layer.serviceHierarchy(layerKey.value, serviceId.value!),
     enabled: computed(() => layerKey.value.length > 0 && !!serviceId.value && 
!replay.value),
-    initialData: () => replayData?.value ?? undefined,
     staleTime: 60_000,
   });
 
+  // Replay renders straight from the captured payload — NOT through the shared
+  // query cache. Seeding initialData under the live query key would let a chat
+  // snapshot serve a live probe during staleTime (and vice-versa).
+  const data = computed<ServiceHierarchyResponse | null>(() =>
+    replay.value ? (replayData?.value ?? null) : (q.data.value ?? null),
+  );
   return {
-    data: computed<ServiceHierarchyResponse | null>(() => q.data.value ?? 
null),
+    data,
     /** `true` once a probe lands AND the focused service has at least
      *  one cross-layer peer. The expand chip on the selected hex reads
      *  from this — it stays hidden until the probe confirms peers. */
-    hasPeers: computed<boolean>(() => (q.data.value?.relations ?? 0) > 0),
+    hasPeers: computed<boolean>(() => (data.value?.relations ?? 0) > 0),
     isLoading: q.isLoading,
     isFetching: q.isFetching,
     error: q.error,

Reply via email to