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 dd8526f3107df301e1255d7c1e4d87e626da5ce7 Author: Wu Sheng <[email protected]> AuthorDate: Wed Jul 8 15:33:08 2026 +0800 refactor(ai): unify map + figure static mode as replay/replayData, frozen-always, zero-query One vocabulary for the captured/static-file mode across all AI-chat map components and figures: `replay` (boolean) + `replayData` (the captured payload) + capturedAt, with every disabled thing (main query, landing, roster, endpoint list, click-detail, node-expand, hierarchy probe) gated by `replay`. Replaces the scattered snapshot/seed/initialData naming. Frozen-always: a captured block is a static file of what was read — the map tools ALWAYS attach replayData, and an empty / unreachable / too-large / no-endpoint read replays as a clean no-value state ("no data" / "unreachable" / "too large" / "No API-dependency data captured"), never re-queries. Figures get the same no-value state ("No data in the captured window" / "read failed at capture"). This reverses the earlier omit-on-unreachable self-heal in favour of faithful point-in-time replay. A replay map now fires ZERO OAP requests on mount or click — the picker-support queries (landing / roster / endpoint list) AND the endpoint node-expand handle are gated, so a reloaded map (or one whose OAP is down) stays fully offline. Also: adaptive B/KB/MB history-usage meter (was frozen at "0.0" MB for KB-scale usage); the topology "no metric data" message no longer stacks under the "OAP unreachable" banner. --- apps/bff/src/ai/resources/CLAUDE.md | 2 +- apps/bff/src/ai/skill/visualization/tools.test.ts | 22 +++++++------ apps/bff/src/ai/skill/visualization/tools.ts | 24 +++++++++------ apps/bff/src/ai/types.ts | 12 ++++---- apps/ui/src/ai/AiFullPageView.vue | 11 +++++-- apps/ui/src/ai/ChatDeploymentBlock.vue | 3 +- apps/ui/src/ai/ChatEndpointDependencyBlock.vue | 3 +- apps/ui/src/ai/ChatHierarchyBlock.vue | 3 +- apps/ui/src/ai/ChatInstanceTopologyBlock.vue | 3 +- apps/ui/src/ai/ChatTopologyBlock.vue | 3 +- apps/ui/src/ai/ChatWidgetRenderer.vue | 34 +++++++++++++++++++- apps/ui/src/ai/types.ts | 14 ++++----- .../LayerEndpointDependencyView.vue | 36 +++++++++++++--------- .../useLayerEndpointDependency.ts | 13 ++++---- .../src/layer/service-map/LayerDeploymentView.vue | 11 ++++--- .../service-map/LayerInstanceTopologyView.vue | 17 +++++----- .../src/layer/service-map/LayerServiceMapView.vue | 33 +++++++++++--------- .../layer/service-map/ServiceHierarchyOverlay.vue | 11 ++++--- apps/ui/src/layer/service-map/useDeployment.ts | 15 ++++----- .../src/layer/service-map/useInstanceTopology.ts | 15 ++++----- apps/ui/src/layer/service-map/useLayerTopology.ts | 22 +++++++------ .../src/layer/service-map/useServiceHierarchy.ts | 11 ++++--- apps/ui/src/layer/useLayerEndpoints.ts | 6 +++- apps/ui/src/layer/useLayerLanding.ts | 10 +++++- apps/ui/src/layer/useLayerServices.ts | 11 ++++--- 25 files changed, 214 insertions(+), 131 deletions(-) diff --git a/apps/bff/src/ai/resources/CLAUDE.md b/apps/bff/src/ai/resources/CLAUDE.md index dea54a9..3feebf0 100644 --- a/apps/bff/src/ai/resources/CLAUDE.md +++ b/apps/bff/src/ai/resources/CLAUDE.md @@ -55,7 +55,7 @@ The metric-vs-record and sampled-vs-topN split is IMPLICIT in the type + the out A persisted conversation must re-render on reload from what was captured — not a fresh live query. Because the components above are code-STATIC, a reloaded block seeds the SAME renderer with the captured (template/OAP-resolved) data and draws identically. So: rich reads capture the WHOLE component response (nodes+edges WITH metrics), and static-reload seeds the real view from it — never a bespoke second renderer. Any new component/tool must be seedable the same way (its data-in path take [...] -This is IMPLEMENTED for figures AND all five MAP components. The five map tools resolve the full `*Response` through a shared `logic/oap/` builder (the same fan-out the layer route uses) and attach it as `spec.snapshot`; the embedded `Layer*View` seeds from it (vue-query `initialData` + `enabled:false`, ticker skipped), so a reloaded map replays the exact graph + its edge sparkline part-graphs with ZERO OAP fetch — offline-safe, frozen to its capture instant (stamped "captured <when&g [...] +This is IMPLEMENTED for figures AND all five MAP components under ONE consistent vocabulary: **`replay` (boolean) + `replayData` (the captured payload) + `capturedAt`**, and every disabled/gated thing is gated by `replay`. The five map tools resolve the full `*Response` through a shared `logic/oap/` builder (the same fan-out the layer route uses) and attach it as `spec.replayData`; the chat block mounts the embedded `Layer*View` with `replay=true`, and in replay mode the view renders fro [...] ## Maintaining the skills diff --git a/apps/bff/src/ai/skill/visualization/tools.test.ts b/apps/bff/src/ai/skill/visualization/tools.test.ts index c54058f..5581011 100644 --- a/apps/bff/src/ai/skill/visualization/tools.test.ts +++ b/apps/bff/src/ai/skill/visualization/tools.test.ts @@ -22,7 +22,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../../../logic/dashboard/run.js', () => ({ runWidgets: vi.fn() })); vi.mock('../../../logic/oap/hierarchy.js', () => ({ getServiceHierarchy: vi.fn() })); // The map tools now call the shared logic/oap builders (the same fan-out the -// layer routes use) and attach the response as spec.snapshot. +// layer routes use) and attach the response as spec.replayData. vi.mock('../../../logic/oap/service-topology.js', () => ({ buildServiceTopology: vi.fn() })); vi.mock('../../../logic/oap/instance-topology.js', () => ({ buildInstanceTopology: vi.fn() })); vi.mock('../../../logic/oap/deployment.js', () => ({ buildDeployment: vi.fn() })); @@ -155,12 +155,12 @@ describe('visualization render tools', () => { expect(getHierarchy).toHaveBeenCalledWith(expect.anything(), 'svc-1', 'GENERAL', undefined); const spec = emitHierarchy.mock.calls[0][0] as { groups: Array<{ layer: string; peers: Array<{ role: string }> }>; - snapshot?: unknown; + replayData?: unknown; }; expect(spec.groups.map((g) => g.layer)).toEqual(['GENERAL', 'K8S_SERVICE']); expect(spec.groups[0].peers[0].role).toBe('self'); // Reachable ⇒ the raw hierarchy rides along as the seed for static replay. - expect(spec.snapshot).toBeDefined(); + expect(spec.replayData).toBeDefined(); }); it('show_topology runs the depth-1 builder and emits the ego graph + snapshot', async () => { @@ -191,16 +191,16 @@ describe('visualization render tools', () => { const spec = emitTopology.mock.calls[0][0] as { upstream: Array<{ name: string }>; downstream: Array<{ name: string; isReal: boolean }>; - snapshot?: unknown; + replayData?: unknown; }; // call target === focus ⇒ its source is upstream; call source === focus ⇒ downstream. expect(spec.upstream[0].name).toBe('agent::gateway'); expect(spec.downstream[0].isReal).toBe(false); - expect(spec.snapshot).toBeDefined(); + expect(spec.replayData).toBeDefined(); expect(out).toMatch(/upstream caller/i); }); - it('show_topology omits the snapshot on an unreachable read (so the view can self-heal)', async () => { + it('show_topology still carries the snapshot on an unreachable read (frozen replay of the no-value state)', async () => { buildTopo.mockResolvedValue({ layer: 'GENERAL', service: 'svc-1', depth: 1, reachable: false, generatedAt: 0, config: { nodeMetrics: [], linkServerMetrics: [], linkClientMetrics: [] }, @@ -208,8 +208,10 @@ describe('visualization render tools', () => { }); const { ctx, emitTopology } = mockCtx(); const out = String(await byName(ctx).show_topology.invoke({ layer: 'GENERAL', service: 'agent::songs' })); - const spec = emitTopology.mock.calls[0][0] as { snapshot?: unknown }; - expect(spec.snapshot).toBeUndefined(); + const spec = emitTopology.mock.calls[0][0] as { replayData?: { reachable?: boolean } }; + // Always frozen — the block replays the captured (unreachable) state, never re-queries. + expect(spec.replayData).toBeDefined(); + expect(spec.replayData?.reachable).toBe(false); expect(out).toMatch(/unreachable/i); }); @@ -236,7 +238,7 @@ describe('visualization render tools', () => { serverService: string; serverServiceId: string; windowMinutes?: number; - snapshot?: unknown; + replayData?: unknown; }; // source → client, dest → server (must NOT be swapped). expect(spec.clientService).toBe('agent::gateway'); @@ -244,7 +246,7 @@ describe('visualization render tools', () => { expect(spec.serverService).toBe('agent::songs'); expect(spec.serverServiceId).toBe('svc-1'); expect(spec.windowMinutes).toBe(10); // (endMs 600_000 - 0) / 60_000 - expect(spec.snapshot).toBeDefined(); + expect(spec.replayData).toBeDefined(); expect(out).toContain('agent::gateway'); }); diff --git a/apps/bff/src/ai/skill/visualization/tools.ts b/apps/bff/src/ai/skill/visualization/tools.ts index 3a908df..6cdc716 100644 --- a/apps/bff/src/ai/skill/visualization/tools.ts +++ b/apps/bff/src/ai/skill/visualization/tools.ts @@ -437,9 +437,9 @@ export function visualizationTools(ctx: AiRequestContext): StructuredToolInterfa groups, reachable: res.reachable, errorReason: res.reachable ? null : (res.error ?? 'hierarchy unreachable'), - // Carry the raw hierarchy so the embedded overlay seeds statically (no - // re-query on reload). Structural fan — no metrics, no series, tiny. - snapshot: res.reachable ? res : undefined, + // Always frozen: carry the raw hierarchy so the embedded overlay replays + // statically (or its empty/unreachable state); it never re-queries. + replayData: res, }); const peerCount = groups.reduce((n, g) => n + g.peers.filter((p) => p.role !== 'self').length, 0); return res.reachable @@ -516,10 +516,11 @@ export function visualizationTools(ctx: AiRequestContext): StructuredToolInterfa reachable: snapshot.reachable, errorReason: snapshot.reachable ? null : (snapshot.error ?? 'topology unreachable'), windowMinutes, - // Attach the snapshot ONLY on a clean, reachable graph — a too-large fan - // (guard dropped the graph) or an unreachable read emit NO snapshot so the - // view degrades to a live fetch that can self-heal, matching the siblings. - snapshot: tooLarge || !snapshot.reachable ? undefined : snapshot, + // ALWAYS carry the replayData — the block is a static file of what was read. + // If the read had no value (empty / unreachable / too-large), the seeded + // view replays THAT state ("no data" / "unreachable" / "too large"), + // frozen; it never re-queries on reload. + replayData: snapshot, }); if (!snapshot.reachable) return `Topology for ${service} is unreachable (${snapshot.error ?? 'no data'}).`; if (tooLarge) return `Topology for ${service} is too large to draw legibly (${snapshot.tooLarge!.nodes} nodes, ${snapshot.tooLarge!.edges} edges). Narrow the scope.`; @@ -568,7 +569,8 @@ export function visualizationTools(ctx: AiRequestContext): StructuredToolInterfa service, serviceId: row.id, windowMinutes, - snapshot: snapshot.reachable ? snapshot : undefined, + // Always frozen: replay the captured graph (or its empty/unreachable state). + replayData: snapshot, }); if (!snapshot.reachable) return `Deployment for ${service} is unreachable (${snapshot.error ?? 'no data'}).`; // A role-clustered deployment (e.g. BanyanDB) keeps its metric defs under @@ -629,7 +631,8 @@ export function visualizationTools(ctx: AiRequestContext): StructuredToolInterfa serverService: destService, serverServiceId: server.id, windowMinutes, - snapshot: snapshot.reachable ? snapshot : undefined, + // Always frozen: replay the captured pair map (or its empty/unreachable state). + replayData: snapshot, }); if (!snapshot.reachable) return `The instance map for ${sourceService} → ${destService} is unreachable (${snapshot.error ?? 'no data'}).`; if (snapshot.nodes.length === 0) return `${sourceService} → ${destService}: no instance-level call relationship in this window (empty map).`; @@ -682,7 +685,8 @@ export function visualizationTools(ctx: AiRequestContext): StructuredToolInterfa service, serviceId: row.id, windowMinutes, - snapshot: snapshot.reachable && snapshot.endpointId ? snapshot : undefined, + // Always frozen: replay the captured chain (or its empty/no-endpoint state). + replayData: snapshot, }); if (!snapshot.reachable) return `API dependency for ${service} is unreachable (${snapshot.error ?? 'no data'}).`; if (!snapshot.endpointId) return `${service} exposes no endpoints in this window (no dependency chain to draw).`; diff --git a/apps/bff/src/ai/types.ts b/apps/bff/src/ai/types.ts index 184b358..1a906c9 100644 --- a/apps/bff/src/ai/types.ts +++ b/apps/bff/src/ai/types.ts @@ -139,7 +139,7 @@ export interface HierarchySpec { /** The captured raw hierarchy (the overlay's native shape). Present ⇒ the * embedded overlay SEEDS from it and never re-queries — a reloaded fan * replays statically. `groups` stays for the LLM text summary. */ - snapshot?: ServiceHierarchyResponse; + replayData?: ServiceHierarchyResponse; } /** A direct neighbour of the focus service in the one-hop ego topology. @@ -172,7 +172,7 @@ export interface TopologySpec { * series). When present the embedded view SEEDS from it and never re-queries * OAP — so a reloaded conversation replays the exact point-in-time map + its * edge part-graphs. Absent (too-large / degrade) ⇒ the view live-fetches. */ - snapshot?: TopologyResponse; + replayData?: TopologyResponse; } /** A mounted DEPLOYMENT view — the real per-service instance-to-instance call @@ -188,7 +188,7 @@ export interface DeploymentSpec { /** Captured render-ready graph (instances + intra-service edges WITH metric * values + twin edge series). Present ⇒ the embedded view seeds statically * and replays the edge part-graphs on reload; absent ⇒ live fetch. */ - snapshot?: DeploymentResponse; + replayData?: DeploymentResponse; } /** A mounted INSTANCE-TOPOLOGY view — the real per-pair instance map embedded @@ -205,8 +205,8 @@ export interface InstanceTopologySpec { serverServiceId: string; windowMinutes?: number; /** Captured render-ready pair map (instances + edges WITH metric values + - * twin edge series). Present ⇒ seed statically + replay edge part-graphs. */ - snapshot?: InstanceTopologyResponse; + * twin edge series). Present ⇒ replay statically + edge part-graphs. */ + replayData?: InstanceTopologyResponse; } /** A mounted ENDPOINT-DEPENDENCY view — the real per-endpoint API-dependency @@ -223,7 +223,7 @@ export interface EndpointDependencySpec { /** Captured render-ready chain (endpoints + edges WITH metric values + edge * series). The response's `endpointId` PINS which endpoint was drawn, so a * reload replays the SAME chain — not the now-busiest endpoint. */ - snapshot?: EndpointDependencyResponse; + replayData?: EndpointDependencyResponse; } /** A mounted TRACES view — the real native Traces view embedded read-only in diff --git a/apps/ui/src/ai/AiFullPageView.vue b/apps/ui/src/ai/AiFullPageView.vue index c513cd5..a12e62e 100644 --- a/apps/ui/src/ai/AiFullPageView.vue +++ b/apps/ui/src/ai/AiFullPageView.vue @@ -74,8 +74,13 @@ function clickClear(): void { confirmingClear.value = false; void conv.clearAll().then(refreshUsage); } -function fmtMb(bytes: number): string { - return (bytes / (1024 * 1024)).toFixed(1); +// Adaptive unit: real usage is KB-scale against a huge (500 MB) cap, so a +// fixed "MB, 1 decimal" reads 0.0 forever. Show B / KB / MB so small usage — and +// its growth — is actually visible. +function fmtSize(bytes: number): string { + if (bytes < 1024) return `${Math.round(bytes)} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } // Refresh usage when a turn finishes — covers growth WITHIN a conversation, not // just a change in conversation count. @@ -164,7 +169,7 @@ function when(ts: number): string { <div class="aifp__usage-bar"> <div class="aifp__usage-fill" :style="{ width: Math.min(100, (usedBytes / maxBytes) * 100) + '%' }" /> </div> - <span class="aifp__usage-txt">{{ fmtMb(usedBytes) }} / {{ fmtMb(maxBytes) }} MB</span> + <span class="aifp__usage-txt">{{ fmtSize(usedBytes) }} / {{ fmtSize(maxBytes) }}</span> </div> <div v-if="ordered.length > 0" class="aifp__clear"> <button v-if="!confirmingClear" type="button" class="aifp__clear-btn" @click="clickClear">{{ t('Clear all') }}</button> diff --git a/apps/ui/src/ai/ChatDeploymentBlock.vue b/apps/ui/src/ai/ChatDeploymentBlock.vue index f113d66..e26bbc7 100644 --- a/apps/ui/src/ai/ChatDeploymentBlock.vue +++ b/apps/ui/src/ai/ChatDeploymentBlock.vue @@ -45,7 +45,8 @@ const captured = computed<string>(() => :layer-key="spec.layer.toLowerCase()" :focus-service-id="spec.serviceId" :focus-window-minutes="spec.windowMinutes" - :snapshot="spec.snapshot" + :replay="true" + :replay-data="spec.replayData" /> </div> </div> diff --git a/apps/ui/src/ai/ChatEndpointDependencyBlock.vue b/apps/ui/src/ai/ChatEndpointDependencyBlock.vue index 3ead5c5..d37a0ba 100644 --- a/apps/ui/src/ai/ChatEndpointDependencyBlock.vue +++ b/apps/ui/src/ai/ChatEndpointDependencyBlock.vue @@ -47,7 +47,8 @@ const captured = computed<string>(() => :focus-service="spec.service" :focus-service-id="spec.serviceId" :focus-window-minutes="spec.windowMinutes" - :snapshot="spec.snapshot" + :replay="true" + :replay-data="spec.replayData" /> </div> </div> diff --git a/apps/ui/src/ai/ChatHierarchyBlock.vue b/apps/ui/src/ai/ChatHierarchyBlock.vue index 8599ac6..464394c 100644 --- a/apps/ui/src/ai/ChatHierarchyBlock.vue +++ b/apps/ui/src/ai/ChatHierarchyBlock.vue @@ -48,7 +48,8 @@ function noNodePos(): null { <ServiceHierarchyOverlay :standalone="true" :focus="{ serviceId: spec.serviceId, layer: spec.layer, serviceName: spec.service }" - :snapshot="spec.snapshot" + :replay="true" + :replay-data="spec.replayData" :view-box-w="0" :view-box-h="0" :resolve-node-pos="noNodePos" diff --git a/apps/ui/src/ai/ChatInstanceTopologyBlock.vue b/apps/ui/src/ai/ChatInstanceTopologyBlock.vue index 45d4a0b..2bc06bb 100644 --- a/apps/ui/src/ai/ChatInstanceTopologyBlock.vue +++ b/apps/ui/src/ai/ChatInstanceTopologyBlock.vue @@ -47,7 +47,8 @@ const captured = computed<string>(() => :focus-client-service-id="spec.clientServiceId" :focus-server-service-id="spec.serverServiceId" :focus-window-minutes="spec.windowMinutes" - :snapshot="spec.snapshot" + :replay="true" + :replay-data="spec.replayData" /> </div> </div> diff --git a/apps/ui/src/ai/ChatTopologyBlock.vue b/apps/ui/src/ai/ChatTopologyBlock.vue index b4fed99..a679352 100644 --- a/apps/ui/src/ai/ChatTopologyBlock.vue +++ b/apps/ui/src/ai/ChatTopologyBlock.vue @@ -50,7 +50,8 @@ const captured = computed<string>(() => :fit-scale="1.2" :zoom-controls="true" :focus-window-minutes="spec.windowMinutes" - :snapshot="spec.snapshot" + :replay="true" + :replay-data="spec.replayData" /> </div> </div> diff --git a/apps/ui/src/ai/ChatWidgetRenderer.vue b/apps/ui/src/ai/ChatWidgetRenderer.vue index 330c920..954aba2 100644 --- a/apps/ui/src/ai/ChatWidgetRenderer.vue +++ b/apps/ui/src/ai/ChatWidgetRenderer.vue @@ -48,11 +48,37 @@ const cardText = computed<string>(() => { // Document the captured analysis: the metric's explanation (template tip) + the // exact MQE that produced this frozen result. const mqe = computed<string>(() => (spec.value.expressions ?? []).filter(Boolean).join(' · ')); + +// A captured figure is a static file of what was read — if the read had no value +// (empty window or a failed read at capture), replay says so, the same frozen +// no-value contract the map blocks follow. A real 0 is a value, not no-value. +const noValue = computed<boolean>(() => { + const r = result.value; + if (r.error) return true; + switch (spec.value.type) { + case 'card': + return r.value == null; + case 'line': + return !r.series?.some((s) => s.data?.some((v) => v != null)); + case 'top': + return !(r.topList?.length || r.topGroups?.length); + case 'table': + return !r.table?.length; + case 'record': + return !r.records?.length; + default: + return false; + } +}); </script> <template> <div class="cwr"> - <div v-if="spec.type === 'card'" class="cwr-card"> + <div v-if="noValue" class="cwr-empty"> + {{ result.error ? 'No data — the read failed when this was captured.' : 'No data in the captured window.' }} + </div> + + <div v-else-if="spec.type === 'card'" class="cwr-card"> <span class="cwr-card-val">{{ cardText }}</span> <span v-if="spec.unit" class="cwr-card-unit">{{ spec.unit }}</span> </div> @@ -124,6 +150,12 @@ const mqe = computed<string>(() => (spec.value.expressions ?? []).filter(Boolean color: var(--sw-fg-3); padding: 8px; } +.cwr-empty { + font-size: var(--sw-fs-sm); + color: var(--sw-fg-3); + padding: 14px 8px; + text-align: center; +} .cwr-meta { margin-top: 6px; padding-top: 6px; diff --git a/apps/ui/src/ai/types.ts b/apps/ui/src/ai/types.ts index e146813..c17ea50 100644 --- a/apps/ui/src/ai/types.ts +++ b/apps/ui/src/ai/types.ts @@ -119,7 +119,7 @@ export interface HierarchySpec { errorReason?: string | null; /** Captured raw hierarchy (the overlay's native shape). Present ⇒ the embedded * overlay seeds from it and never re-queries — a reloaded fan replays static. */ - snapshot?: ServiceHierarchyResponse; + replayData?: ServiceHierarchyResponse; } // A focused one-hop ego topology drawn inline: the focus service + its DIRECT @@ -147,7 +147,7 @@ export interface TopologySpec { /** Captured render-ready graph (nodes+edges WITH metric values + edge series). * Present ⇒ the embedded map SEEDS from it and never re-queries OAP, so a * reloaded conversation replays the exact map + edge part-graphs statically. */ - snapshot?: TopologyResponse; + replayData?: TopologyResponse; } // A mounted Deployment view (real feature view, embedded read-only) focused on a @@ -160,8 +160,8 @@ export interface DeploymentSpec { serviceId: string; windowMinutes?: number; /** Captured render-ready graph (instances + edges WITH values + edge series). - * Present ⇒ seed the embedded view statically + replay the edge part-graphs. */ - snapshot?: DeploymentResponse; + * Present ⇒ replay statically + the edge part-graphs. */ + replayData?: DeploymentResponse; } // A mounted instance-topology view (real feature view, embedded read-only) for a @@ -176,8 +176,8 @@ export interface InstanceTopologySpec { serverServiceId: string; windowMinutes?: number; /** Captured render-ready pair map (instances + edges WITH values + edge series). - * Present ⇒ seed statically + replay the edge part-graphs. */ - snapshot?: InstanceTopologyResponse; + * Present ⇒ replay statically + the edge part-graphs. */ + replayData?: InstanceTopologyResponse; } // A mounted endpoint-dependency view (real feature view, embedded read-only) @@ -192,7 +192,7 @@ export interface EndpointDependencySpec { /** Captured render-ready chain (endpoints + edges WITH values + edge series); * its `endpointId` PINS which endpoint was drawn so a reload replays the SAME * chain, not the now-busiest endpoint. */ - snapshot?: EndpointDependencyResponse; + replayData?: EndpointDependencyResponse; } // A mounted native Traces view (real feature view, embedded read-only) focused diff --git a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue index 3be05a4..243c959 100644 --- a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue +++ b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue @@ -75,10 +75,11 @@ const props = defineProps<{ /** Embedded look-back window (minutes); the query owns it and skips the global * topbar picker + auto-refresh ticker, like the topology / deployment blocks. */ focusWindowMinutes?: number; - /** Captured chain (AI chat replay). Present ⇒ the view SEEDS from it and never - * fetches; the snapshot's endpointId PINS which endpoint is drawn, so a reload - * replays the SAME chain (not the now-busiest endpoint) + its edge sparklines. */ - snapshot?: EndpointDependencyResponse; + /** REPLAY mode (AI chat): render statically from `replayData`, never fetch; the + * replayData's endpointId PINS which endpoint is drawn, so a reload replays the + * SAME chain (not the now-busiest endpoint) + its edge sparklines. */ + replay?: boolean; + replayData?: EndpointDependencyResponse; }>(); const route = useRoute(); @@ -117,7 +118,9 @@ const namingRule = computed(() => layer.value?.naming ?? null); function identity(name: string | null | undefined): ServiceIdentity { return resolveServiceIdentity(name, namingRule.value); } -const landing = useLayerLanding(safeLayer, safeCfg); +// 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 resolvedServiceName = useLayerServiceName(layerKey, landing); const serviceName = computed<string | null>(() => embedded.value ? (props.focusService ?? null) : resolvedServiceName.value, @@ -151,6 +154,8 @@ const { endpoints: endpointList, isFetching: endpointsLoading } = useLayerEndpoi serviceName, endpointQuery, endpointLimit, + // A replay map pins its endpoint from replayData + hides the picker — no fetch. + computed(() => props.replay ?? false), ); watch(serviceName, (next, prev) => { if (prev !== undefined && next !== prev && selectedEndpoint.value) { @@ -161,13 +166,13 @@ watch(serviceName, (next, prev) => { // pick). The `return` after step 1 prevents racing the endpoint pick // before `serviceName` has propagated and the endpoint list refreshed. watchEffect(() => { - // A seeded (replayed) chain PINS its endpoint: seed the local pick from the - // snapshot's 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.snapshot) { + // A REPLAY chain PINS its endpoint: seed the local pick from the replayData's + // 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 (!selectedEndpoint.value) { - const pinned = props.snapshot.nodes.find((n) => n.id === props.snapshot!.endpointId)?.name; + const pinned = props.replayData?.nodes.find((n) => n.id === props.replayData?.endpointId)?.name; if (pinned) setSelectedEndpoint(pinned); } return; @@ -193,13 +198,13 @@ watchEffect(() => { const focusWindowMinutes = computed<number | null>(() => embedded.value ? (props.focusWindowMinutes ?? 60) : null, ); -const endpointSeed = computed<EndpointDependencyResponse | null>(() => props.snapshot ?? null); +const replayDataRef = computed<EndpointDependencyResponse | null>(() => props.replayData ?? null); const { nodes: baseNodes, calls: baseCalls, isLoading, isFetching, data } = useLayerEndpointDependency( layerKey, serviceName, selectedEndpoint, focusWindowMinutes, - endpointSeed, + replayDataRef, ); const reachable = computed(() => data.value?.reachable !== false); const errorText = computed(() => data.value?.error ?? null); @@ -984,7 +989,7 @@ function edgeRowCrosshair(rowId: string): number | null { (loading callers & callees) → `+` accent (expanded) or a faded `·` (no further dependency). --> <g - v-if="selectedNodeId === n.id && n.id !== focusedId" + v-if="selectedNodeId === n.id && n.id !== focusedId && !embedded" class="ep-expand" :class="{ exhausted: isExhausted(n), loading: isLoadingExpansion(n) }" :transform="`translate(${NW - 9}, -9)`" @@ -1183,7 +1188,8 @@ function edgeRowCrosshair(rowId: string): number | null { </section> <section v-else-if="serviceName" class="empty"> - Select an endpoint above to see its dependency chain. + <template v-if="embedded">No API-dependency data was captured for {{ serviceName }} in this window.</template> + <template v-else>Select an endpoint above to see its dependency chain.</template> </section> </div> </template> diff --git a/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts b/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts index 886a65d..20b4ed2 100644 --- a/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts +++ b/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts @@ -37,10 +37,11 @@ export function useLayerEndpointDependency( * OWN frozen look-back window and does NOT follow the global topbar picker or * auto-refresh ticker — the interactive route omits it. */ windowMinutes?: Ref<number | null>, - /** Captured chain to SEED from (AI chat static replay). Present ⇒ start with it - * and NEVER fetch, so a reload replays the SAME pinned endpoint chain offline. */ - seed?: Ref<EndpointDependencyResponse | null>, + /** REPLAY mode: the captured chain to render from. Present ⇒ start with it and + * NEVER fetch, so a reload replays the SAME pinned endpoint chain offline. */ + replayData?: Ref<EndpointDependencyResponse | null>, ) { + const replay = computed(() => !!replayData?.value); const ownsWindow = (windowMinutes?.value ?? 0) > 0; // Preview-only: forward the draft `endpointDependency` block. const previewCfg = usePreviewLayerBlock(layerKey, 'endpointDependency'); @@ -68,12 +69,12 @@ export function useLayerEndpointDependency( previewCfg.value, ), enabled: computed( - () => layerKey.value.length > 0 && !!service.value && !!endpoint.value && !seed?.value, + () => layerKey.value.length > 0 && !!service.value && !!endpoint.value && !replay.value, ), - initialData: () => seed?.value ?? undefined, + initialData: () => replayData?.value ?? undefined, staleTime: 30_000, }); - if (!ownsWindow && !seed?.value) useAutoRefreshSubscribe(() => q.refetch()); + if (!ownsWindow && !replay.value) useAutoRefreshSubscribe(() => q.refetch()); return { data: computed(() => q.data.value ?? null), diff --git a/apps/ui/src/layer/service-map/LayerDeploymentView.vue b/apps/ui/src/layer/service-map/LayerDeploymentView.vue index ad61e92..c872de7 100644 --- a/apps/ui/src/layer/service-map/LayerDeploymentView.vue +++ b/apps/ui/src/layer/service-map/LayerDeploymentView.vue @@ -79,9 +79,10 @@ const props = defineProps<{ /** Embedded look-back window (minutes); the query owns it and skips the global * topbar picker + auto-refresh ticker, like the topology block. */ focusWindowMinutes?: number; - /** Captured graph (AI chat replay). Present ⇒ the view SEEDS from it and never - * fetches, so a reload replays the exact graph + edge part-graphs offline. */ - snapshot?: DeploymentResponse; + /** REPLAY mode (AI chat): render statically from `replayData`, never fetch — a + * reload replays the exact graph + edge part-graphs offline. */ + replay?: boolean; + replayData?: DeploymentResponse; }>(); const route = useRoute(); @@ -116,8 +117,8 @@ const enabled = computed(() => !!selectedId.value); const focusWindowMinutes = computed<number | null>(() => embedded.value ? (props.focusWindowMinutes ?? 60) : null, ); -const deploymentSeed = computed<DeploymentResponse | null>(() => props.snapshot ?? null); -const { data, nodes, calls, isFetching } = useDeployment(layerKey, selectedId, enabled, focusWindowMinutes, deploymentSeed); +const replayDataRef = computed<DeploymentResponse | null>(() => props.replayData ?? null); +const { data, nodes, calls, isFetching } = useDeployment(layerKey, selectedId, enabled, focusWindowMinutes, replayDataRef); const serviceName = computed(() => displayServiceName(data.value?.serviceName) || ''); const metricsPartial = computed(() => data.value?.metricsPartial ?? null); diff --git a/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue b/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue index 10fa7ed..095a9bf 100644 --- a/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue +++ b/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue @@ -59,9 +59,10 @@ const props = defineProps<{ /** Embedded look-back window (minutes); the query owns it and skips the global * topbar picker + auto-refresh ticker, like the topology / deployment blocks. */ focusWindowMinutes?: number; - /** Captured pair map (AI chat replay). Present ⇒ the view SEEDS from it and - * never fetches, so a reload replays the exact map + edge part-graphs offline. */ - snapshot?: InstanceTopologyResponse; + /** REPLAY mode (AI chat): render statically from `replayData`, never fetch — a + * reload replays the exact map + edge part-graphs offline. */ + replay?: boolean; + replayData?: InstanceTopologyResponse; }>(); const route = useRoute(); @@ -113,13 +114,15 @@ function displayName(name: string | null | undefined): string { // fallback on a selection the graph hasn't loaded yet. // Embedded blocks own a frozen window, so the roster (name fallback for the // pickers) must not ride the global ticker either. -const roster = useLayerServices(layerKey, { rideTicker: !props.embedded }); +// A replay map uses replayData node names + hides the pickers, so the roster +// (name fallback for the pickers) fires ZERO queries — gated by replay mode. +const roster = useLayerServices(layerKey, { rideTicker: !props.embedded, replay: computed(() => props.replay ?? false) }); const topoFocus = ref<string | null>(null); const topoDepth = ref(2); // A seeded (replayed) block is static — suppress the layer-wide topology fetch // that only feeds the interactive swap pickers, so a reload stays offline (it // would otherwise fire a fresh-Date.now()-window request). Empty key ⇒ disabled. -const pickerLayerKey = computed(() => (props.snapshot ? '' : layerKey.value)); +const pickerLayerKey = computed(() => (props.replay ? '' : layerKey.value)); const { nodes: topoNodes, calls: topoCalls } = useLayerTopology( pickerLayerKey, topoFocus, @@ -209,14 +212,14 @@ function pick(which: 'client' | 'server', val: string): void { } const enabled = computed(() => !!clientId.value && !!serverId.value); -const instanceSeed = computed<InstanceTopologyResponse | null>(() => props.snapshot ?? null); +const replayDataRef = computed<InstanceTopologyResponse | null>(() => props.replayData ?? null); const { data, nodes, calls, isFetching } = useInstanceTopology( layerKey, clientId, serverId, enabled, focusWindowMinutes, - instanceSeed, + replayDataRef, ); const metricsPartial = computed(() => data.value?.metricsPartial ?? null); diff --git a/apps/ui/src/layer/service-map/LayerServiceMapView.vue b/apps/ui/src/layer/service-map/LayerServiceMapView.vue index c946c7f..bfe0717 100644 --- a/apps/ui/src/layer/service-map/LayerServiceMapView.vue +++ b/apps/ui/src/layer/service-map/LayerServiceMapView.vue @@ -112,12 +112,13 @@ const props = defineProps<{ * owns this window and ignores the global topbar picker + auto-refresh * ticker — the chat block owns its time like the traces/logs blocks. */ focusWindowMinutes?: number; - /** Captured graph to render statically (AI chat replay). When present the map - * SEEDS from it (no OAP fetch, frozen point-in-time) AND the embedded detail - * panel is re-enabled so a click shows the captured node/edge metrics + the - * edge part-graph sparklines. The overview widget passes embedded WITHOUT a - * snapshot, so it stays non-interactive. */ - snapshot?: TopologyResponse; + /** REPLAY mode (AI chat): render statically from `replayData` — no OAP fetch, + * frozen point-in-time — AND re-enable the embedded detail panel so a click + * shows the captured node/edge metrics + edge part-graph sparklines. The + * overview widget is embedded but NOT replay, so it stays non-interactive. */ + replay?: boolean; + /** The captured graph to render in replay mode. */ + replayData?: TopologyResponse; }>(); const route = useRoute(); const router = useRouter(); @@ -152,7 +153,9 @@ const safeCfg = computed(() => { slots: layer.value.slots, caps: layer.value.caps, metrics: layer.value.metrics, }).landing; }); -const landing = useLayerLanding(safeLayer, safeCfg); +// A replay map hides the service picker this rollup feeds, so it fires no landing +// fetch and no ticker — gated by replay mode. +const landing = useLayerLanding(safeLayer, safeCfg, undefined, computed(() => props.replay ?? false)); const landingRows = computed(() => landing.data.value?.sampledRows ?? landing.rows.value ?? []); // Focus-service is local to the topology view (NOT the header's @@ -194,13 +197,13 @@ function truncateLabel(s: string, n: number): string { const depth = ref<number>(props.focusDepth ?? 2); const focusWindowMinutes = computed<number | null>(() => props.focusWindowMinutes ?? null); -const topologySeed = computed<TopologyResponse | null>(() => props.snapshot ?? null); +const replayDataRef = computed<TopologyResponse | null>(() => props.replayData ?? null); const { nodes, calls, isLoading, isFetching, data, refetch } = useLayerTopology( layerKey, serviceName, depth, focusWindowMinutes, - topologySeed, + replayDataRef, ); const reachable = computed(() => data.value?.reachable !== false); const errorText = computed(() => data.value?.error ?? null); @@ -582,14 +585,14 @@ function edgeMidpoint(c: TopologyCall): { x: number; y: number } | null { const selectedNodeId = ref<string | null>(null); const selectedCallId = ref<string | null>(null); function selectNode(id: string | null): void { - // The overview widget (embedded, no snapshot) renders a static map with no - // detail sidebar — click is a no-op. A seeded chat replay (embedded WITH a - // snapshot) re-enables click so the captured node/edge detail can open. - if (embedded.value && !props.snapshot) return; + // The overview widget (embedded, not replay) renders a static map with no + // detail sidebar — click is a no-op. A chat REPLAY (embedded WITH replay) + // re-enables click so the captured node/edge detail can open. + if (embedded.value && !props.replay) return; selectedNodeId.value = selectedNodeId.value === id ? null : id; } function selectCall(id: string | null): void { - if (embedded.value && !props.snapshot) return; + if (embedded.value && !props.replay) return; selectedCallId.value = selectedCallId.value === id ? null : id; } // Escape closes whichever detail panel (node or edge) is open. @@ -1217,7 +1220,7 @@ onBeforeUnmount(() => { All nodes are hidden by the current filter. <button class="sw-btn small" type="button" @click="resetFilter">Reset filter</button> </div> - <div v-else-if="!tooLarge" class="loader"> + <div v-else-if="!tooLarge && reachable" class="loader"> No services with metric data in this layer for the last 15 minutes. </div> diff --git a/apps/ui/src/layer/service-map/ServiceHierarchyOverlay.vue b/apps/ui/src/layer/service-map/ServiceHierarchyOverlay.vue index fb7e837..51fa24d 100644 --- a/apps/ui/src/layer/service-map/ServiceHierarchyOverlay.vue +++ b/apps/ui/src/layer/service-map/ServiceHierarchyOverlay.vue @@ -68,9 +68,10 @@ const props = defineProps<{ * guarded on this, so the interactive topology overlay is unchanged. */ standalone?: boolean; focus?: { serviceId: string; layer: string; serviceName: string }; - /** Captured hierarchy (AI chat replay, standalone only). Present ⇒ the overlay - * SEEDS from it and never re-queries, so a reloaded fan replays statically. */ - snapshot?: ServiceHierarchyResponse; + /** REPLAY mode (AI chat, standalone only): render statically from `replayData`, + * never re-query — a reloaded fan replays statically. */ + replay?: boolean; + replayData?: ServiceHierarchyResponse; }>(); const store = useHierarchyOverlayStore(); @@ -86,8 +87,8 @@ const isOpen = computed<boolean>(() => (props.standalone ? true : store.isOpen)) const layerKey = computed(() => (fLayer.value ?? '').toLowerCase()); const focusServiceId = computed(() => fServiceId.value); -const hierarchySeed = computed<ServiceHierarchyResponse | null>(() => props.snapshot ?? null); -const { data, isLoading } = useServiceHierarchy(layerKey, focusServiceId, hierarchySeed); +const replayDataRef = computed<ServiceHierarchyResponse | null>(() => props.replayData ?? null); +const { data, isLoading } = useServiceHierarchy(layerKey, focusServiceId, replayDataRef); /** Look up a layer's color from the menu registry; fall back to the * accent for layers we don't have an entry for (e.g. SO11Y_OAP). */ diff --git a/apps/ui/src/layer/service-map/useDeployment.ts b/apps/ui/src/layer/service-map/useDeployment.ts index 95de296..4089035 100644 --- a/apps/ui/src/layer/service-map/useDeployment.ts +++ b/apps/ui/src/layer/service-map/useDeployment.ts @@ -40,10 +40,11 @@ export function useDeployment( * OWN frozen look-back window and does NOT follow the global topbar picker or * auto-refresh ticker — the interactive route omits it. */ windowMinutes?: Ref<number | null>, - /** Captured graph to SEED from (AI chat static replay). Present ⇒ start with it - * and NEVER fetch, so a reload replays the exact graph + edge series offline. */ - seed?: Ref<DeploymentResponse | null>, + /** REPLAY mode: the captured graph to render from. Present ⇒ start with it and + * NEVER fetch, so a reload replays the exact graph + edge series offline. */ + replayData?: Ref<DeploymentResponse | null>, ) { + const replay = computed(() => !!replayData?.value); const ownsWindow = (windowMinutes?.value ?? 0) > 0; const timeRange = useTimeRangeStore(); // Preview-only: the draft top-level `deployment` block, so @@ -62,7 +63,7 @@ export function useDeployment( }; }); const isEnabled = computed( - () => enabled.value && layerKey.value.length > 0 && !!serviceId.value && !seed?.value, + () => enabled.value && layerKey.value.length > 0 && !!serviceId.value && !replay.value, ); const q = useQuery({ queryKey: ['layer-deployment', layerKey, serviceId, rangeKey, previewCfg], @@ -74,13 +75,13 @@ export function useDeployment( previewCfg.value, ), enabled: isEnabled, - initialData: () => seed?.value ?? undefined, + initialData: () => replayData?.value ?? undefined, staleTime: 30_000, }); // Only ride the global ticker while the view is active — a forced refetch // on a closed/disabled query would fetch needlessly. The embedded chat map - // owns a frozen window, and a seeded (replayed) map never fetches at all. - if (!ownsWindow && !seed?.value) { + // owns a frozen window, and a replay map never fetches at all. + if (!ownsWindow && !replay.value) { useAutoRefreshSubscribe(() => { if (isEnabled.value) void q.refetch(); }); diff --git a/apps/ui/src/layer/service-map/useInstanceTopology.ts b/apps/ui/src/layer/service-map/useInstanceTopology.ts index fd80ecc..382621a 100644 --- a/apps/ui/src/layer/service-map/useInstanceTopology.ts +++ b/apps/ui/src/layer/service-map/useInstanceTopology.ts @@ -42,10 +42,11 @@ export function useInstanceTopology( * OWN frozen look-back window and does NOT follow the global topbar picker or * auto-refresh ticker — the interactive route omits it. */ windowMinutes?: Ref<number | null>, - /** Captured graph to SEED from (AI chat static replay). Present ⇒ start with it - * and NEVER fetch, so a reload replays the exact map + edge series offline. */ - seed?: Ref<InstanceTopologyResponse | null>, + /** REPLAY mode: the captured graph to render from. Present ⇒ start with it and + * NEVER fetch, so a reload replays the exact map + edge series offline. */ + replayData?: Ref<InstanceTopologyResponse | null>, ) { + const replay = computed(() => !!replayData?.value); const ownsWindow = (windowMinutes?.value ?? 0) > 0; const timeRange = useTimeRangeStore(); // Preview-only: the draft `topology` block (the BFF reads its nested @@ -69,7 +70,7 @@ export function useInstanceTopology( layerKey.value.length > 0 && !!clientServiceId.value && !!serverServiceId.value && - !seed?.value, + !replay.value, ); const q = useQuery({ queryKey: ['layer-instance-topology', layerKey, clientServiceId, serverServiceId, rangeKey, previewCfg], @@ -82,13 +83,13 @@ export function useInstanceTopology( previewCfg.value, ), enabled: isEnabled, - initialData: () => seed?.value ?? undefined, + initialData: () => replayData?.value ?? undefined, staleTime: 30_000, }); // Only ride the global ticker while the view is active — a forced // refetch on a closed/disabled query would fetch needlessly. The embedded - // chat map owns a frozen window, and a seeded map never fetches at all. - if (!ownsWindow && !seed?.value) { + // chat map owns a frozen window, and a replay map never fetches at all. + if (!ownsWindow && !replay.value) { useAutoRefreshSubscribe(() => { if (isEnabled.value) void q.refetch(); }); diff --git a/apps/ui/src/layer/service-map/useLayerTopology.ts b/apps/ui/src/layer/service-map/useLayerTopology.ts index 7a78dc8..df4f3b0 100644 --- a/apps/ui/src/layer/service-map/useLayerTopology.ts +++ b/apps/ui/src/layer/service-map/useLayerTopology.ts @@ -41,12 +41,14 @@ export function useLayerTopology( * OWN window (a frozen look-back snapshot) and does NOT follow the global * topbar picker or auto-refresh ticker — the interactive route omits it. */ windowMinutes?: Ref<number | null>, - /** Captured graph to SEED from (AI chat static replay). When present the query + /** REPLAY mode: the captured graph to render from. When present the query * starts with it and NEVER fetches — a reloaded conversation replays the exact - * snapshot (nodes+edges+series) with zero OAP round-trip, so it can't slide to - * a fresh window and survives an offline OAP. */ - seed?: Ref<TopologyResponse | null>, + * data (nodes+edges+series) with zero OAP round-trip, so it can't slide to a + * fresh window and survives an offline OAP. */ + replayData?: Ref<TopologyResponse | null>, ) { + // replay mode is on whenever captured data is supplied. + const replay = computed(() => !!replayData?.value); const ownsWindow = (windowMinutes?.value ?? 0) > 0; const timeRange = useTimeRangeStore(); // In `?mode=preview` only: forward the operator's draft `topology` block @@ -81,15 +83,15 @@ export function useLayerTopology( rangeKey.value, previewCfg.value, ), - // A seeded map is static: start with the captured graph and never fetch. - enabled: computed(() => layerKey.value.length > 0 && !seed?.value), - initialData: () => seed?.value ?? undefined, + // Replay is static: start with the captured graph and never fetch. + 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 - // the global ticker — only the interactive route subscribes. A seeded (replayed) - // map never fetches either. - if (!ownsWindow && !seed?.value) useAutoRefreshSubscribe(() => q.refetch()); + // the global ticker — only the interactive route subscribes. A replay map never + // fetches either. + if (!ownsWindow && !replay.value) useAutoRefreshSubscribe(() => q.refetch()); return { data: computed(() => q.data.value ?? null), diff --git a/apps/ui/src/layer/service-map/useServiceHierarchy.ts b/apps/ui/src/layer/service-map/useServiceHierarchy.ts index 84a52d4..23d05cf 100644 --- a/apps/ui/src/layer/service-map/useServiceHierarchy.ts +++ b/apps/ui/src/layer/service-map/useServiceHierarchy.ts @@ -37,16 +37,17 @@ import { bffClient } from '@/api/client'; export function useServiceHierarchy( layerKey: Ref<string>, serviceId: Ref<string | null>, - /** Captured hierarchy to SEED from (AI chat static replay). Present ⇒ start - * with it and NEVER fetch, so a reloaded fan replays statically. */ - seed?: Ref<ServiceHierarchyResponse | null>, + /** REPLAY mode: the captured hierarchy to render from. Present ⇒ start with it + * and NEVER fetch, so a reloaded fan replays statically. */ + replayData?: Ref<ServiceHierarchyResponse | null>, ) { + const replay = computed(() => !!replayData?.value); const q = useQuery({ queryKey: ['layer-service-hierarchy', layerKey, serviceId], queryFn: (): Promise<ServiceHierarchyResponse> => bffClient.layer.serviceHierarchy(layerKey.value, serviceId.value!), - enabled: computed(() => layerKey.value.length > 0 && !!serviceId.value && !seed?.value), - initialData: () => seed?.value ?? undefined, + enabled: computed(() => layerKey.value.length > 0 && !!serviceId.value && !replay.value), + initialData: () => replayData?.value ?? undefined, staleTime: 60_000, }); diff --git a/apps/ui/src/layer/useLayerEndpoints.ts b/apps/ui/src/layer/useLayerEndpoints.ts index 6315c4c..42311cc 100644 --- a/apps/ui/src/layer/useLayerEndpoints.ts +++ b/apps/ui/src/layer/useLayerEndpoints.ts @@ -32,12 +32,16 @@ export function useLayerEndpoints( service: Ref<string | null>, query: Ref<string>, limit: Ref<number>, + /** REPLAY mode gate: a replay endpoint-dependency map pins its endpoint from the + * replayData and hides the picker this list feeds, so it passes a true ref to + * fire ZERO queries. Defaults off (live) for the interactive route. */ + replay?: Ref<boolean>, ) { const q = useQuery({ queryKey: ['layer-endpoints', layerKey, service, query, limit], queryFn: () => bffClient.layer.endpoints(layerKey.value, service.value ?? '', query.value, limit.value), - enabled: computed(() => layerKey.value.length > 0 && !!service.value), + enabled: computed(() => !(replay?.value ?? false) && layerKey.value.length > 0 && !!service.value), staleTime: 30_000, }); return { diff --git a/apps/ui/src/layer/useLayerLanding.ts b/apps/ui/src/layer/useLayerLanding.ts index 16ccca7..e2debc2 100644 --- a/apps/ui/src/layer/useLayerLanding.ts +++ b/apps/ui/src/layer/useLayerLanding.ts @@ -43,6 +43,10 @@ export function useLayerLanding( * + queryKey so a time-picker change refires the landing * rollup the same way a layer change does. */ range?: Ref<LandingRange | null>, + /** REPLAY mode gate: a replay (captured) map hides the service picker this + * rollup feeds and must fire ZERO queries, so it passes a true ref to suppress + * the fetch. Defaults off (live) for the interactive route. */ + replay?: Ref<boolean>, ) { const layerKey = computed(() => layer.value.key); // Cache key reflects every field that changes the server response — @@ -68,6 +72,7 @@ export function useLayerLanding( // 2. The manual refresh button in LayerShell — `q.refetch()`. // No silent vue-query-driven refetch under the operator, so the // service list never moves on its own between operator actions. + const isEnabled = computed(() => !(replay?.value ?? false)); const q = useQuery({ queryKey: ['layer-landing', layerKey, cfgHash, rangeKey], queryFn: () => @@ -76,12 +81,15 @@ export function useLayerLanding( cfg.value, rangeRef.value ?? undefined, ), + enabled: isEnabled, staleTime: Infinity, refetchOnWindowFocus: false, retry: 1, }); - useAutoRefreshSubscribe(() => q.refetch()); + useAutoRefreshSubscribe(() => { + if (isEnabled.value) void q.refetch(); + }); const data = computed<LandingResponse | null>(() => q.data.value ?? null); const rows = computed(() => data.value?.rows ?? []); diff --git a/apps/ui/src/layer/useLayerServices.ts b/apps/ui/src/layer/useLayerServices.ts index 155eb34..4f0313b 100644 --- a/apps/ui/src/layer/useLayerServices.ts +++ b/apps/ui/src/layer/useLayerServices.ts @@ -53,18 +53,21 @@ export function useLayerServices( layerKey: Ref<string>, /** Embedded (chat) callers pass `{ rideTicker: false }` so the roster does not * subscribe to the global auto-refresh ticker — an embedded block owns its own - * frozen window and must not add a ticker-driven refetch. Default rides it. */ - opts: { rideTicker?: boolean } = {}, + * frozen window and must not add a ticker-driven refetch. Default rides it. + * `replay: true` suppresses the fetch entirely — a replay map hides the pickers + * this roster feeds and must fire ZERO queries. */ + opts: { rideTicker?: boolean; replay?: Ref<boolean> } = {}, ) { + const isEnabled = computed(() => !(opts.replay?.value ?? false) && layerKey.value.length > 0); const q = useQuery({ queryKey: ['layer-services', layerKey], queryFn: () => bffClient.layer.services(layerKey.value), - enabled: computed(() => layerKey.value.length > 0), + enabled: isEnabled, staleTime: 60_000, }); if (opts.rideTicker !== false) { useAutoRefreshSubscribe(() => { - if (layerKey.value.length > 0) void q.refetch(); + if (isEnabled.value) void q.refetch(); }); } return {
