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,
