You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/07/08 12:21:21 UTC
(skywalking-horizon-ui) 05/17: refactor(ai): unify map + figure static mode as replay/replayData, frozen-always, zero-query
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 <wu...@foxmail.com>
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 {