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:31 UTC
(skywalking-horizon-ui) 15/17: feat(ai): capture the network process-graph as a frozen block; honest fallbacks
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 2df204c77fc3f4b7c443e168cf3f58ce89411b30
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Wed Jul 8 19:19:38 2026 +0800
feat(ai): capture the network process-graph as a frozen block; honest fallbacks
Closes a capture-replay gap the scan found: network profiling's result is a
process-conversation graph — DATA we can read, not a reason to fall back to
text. analyze_profiling(network) now reads getProcessTopology (one instance)
and emits a frozen process-topology block that renders the stateless
ProcessTopologyGraph from the captured nodes/calls — replays on reload with no
re-query, stamped + replay-badged like every other captured block.
Text is the last resort, only when there's nothing to capture:
- No Rover/eBPF agent reporting → no block, the agent says network profiling is
unavailable at this deployment.
- A fired flame task that ran but produced no stacks → say nothing met the
threshold; if it was created minutes ago and still empty, say profiling is
unsupported here and STOP — don't retry indefinitely (only a just-created task
gets the 2–4 min "let it collect" note).
New 'process-topology' block: spec (BFF+UI lockstep) + SSE + emitter trio +
append + dispatch + ChatProcessTopologyBlock.vue. skills.md states the rule:
never point the user at a live tab (it drifts) — replay the snapshot or say it
in text. Validated live: network analyze on the demo (no rover) emitted no
block and the agent relayed the unavailable message.
---
apps/bff/src/ai/context.ts | 3 +
apps/bff/src/ai/figure-buffer.ts | 9 +++
apps/bff/src/ai/http/chat.ts | 2 +
apps/bff/src/ai/resources/prompts/skills.md | 2 +-
apps/bff/src/ai/skill/triggers/tools.test.ts | 1 +
apps/bff/src/ai/skill/triggers/tools.ts | 27 ++++++---
apps/bff/src/ai/types.ts | 14 +++++
apps/bff/src/logic/oap/profiling.ts | 53 +++++++++--------
apps/ui/src/ai/ChatProcessTopologyBlock.vue | 89 ++++++++++++++++++++++++++++
apps/ui/src/ai/ChatTranscript.vue | 2 +
apps/ui/src/ai/types.ts | 14 +++++
apps/ui/src/ai/useAiConversations.ts | 1 +
apps/ui/src/i18n/locales/en.json | 2 +
13 files changed, 186 insertions(+), 33 deletions(-)
diff --git a/apps/bff/src/ai/context.ts b/apps/bff/src/ai/context.ts
index a0070c3..88f776b 100644
--- a/apps/bff/src/ai/context.ts
+++ b/apps/bff/src/ai/context.ts
@@ -37,6 +37,7 @@ import type {
HierarchySpec,
InstanceTopologySpec,
PodLogSpec,
+ ProcessTopologySpec,
ProfilingResultSpec,
ProposalSpec,
BrowserErrorsSpec,
@@ -79,6 +80,8 @@ export interface AiRequestContext {
emitProposal(spec: ProposalSpec): void;
/** Emit a captured profiling-result flame block (read-only, frozen on reload). */
emitProfiling(spec: ProfilingResultSpec): void;
+ /** Emit a captured network process-conversation graph block (frozen on reload). */
+ emitProcessTopology(spec: ProcessTopologySpec): void;
/** Mount a LIVE-TAIL pod-log pane inline. Carries the resolved pod/container +
* an initial snapshot; the UI pane re-polls OAP on an interval to fetch newer
* lines (on-demand logs are a live tail, not a one-shot figure). Verb-gated
diff --git a/apps/bff/src/ai/figure-buffer.ts b/apps/bff/src/ai/figure-buffer.ts
index 47a9de4..af5a278 100644
--- a/apps/bff/src/ai/figure-buffer.ts
+++ b/apps/bff/src/ai/figure-buffer.ts
@@ -30,6 +30,7 @@ import type {
HierarchySpec,
InstanceTopologySpec,
PodLogSpec,
+ ProcessTopologySpec,
ProfilingResultSpec,
ProposalSpec,
SseEvent,
@@ -50,6 +51,8 @@ export interface FigureBuffer {
emitProposal(spec: ProposalSpec): void;
/** Emit a captured profiling-result (flame) block. Same numbering; flushes first. */
emitProfiling(spec: ProfilingResultSpec): void;
+ /** Emit a captured network process-conversation graph block. Same numbering; flushes first. */
+ emitProcessTopology(spec: ProcessTopologySpec): void;
/** Emit a live-tail pod-log pane. Same numbering; flushes any pending group. */
emitPodLogs(spec: PodLogSpec): void;
/** Emit a cross-layer hierarchy block. Same numbering; flushes any pending group. */
@@ -111,6 +114,11 @@ export function createFigureBuffer(send: (ev: SseEvent) => void): FigureBuffer {
send({ type: 'profiling', n: ++figureN, spec });
};
+ const emitProcessTopology = (spec: ProcessTopologySpec): void => {
+ flushFigures();
+ send({ type: 'process-topology', n: ++figureN, spec });
+ };
+
const emitPodLogs = (spec: PodLogSpec): void => {
flushFigures();
send({ type: 'podlogs', n: ++figureN, spec });
@@ -166,6 +174,7 @@ export function createFigureBuffer(send: (ev: SseEvent) => void): FigureBuffer {
emitSubPage,
emitProposal,
emitProfiling,
+ emitProcessTopology,
emitPodLogs,
emitHierarchy,
emitTopology,
diff --git a/apps/bff/src/ai/http/chat.ts b/apps/bff/src/ai/http/chat.ts
index 283a86e..49e91f1 100644
--- a/apps/bff/src/ai/http/chat.ts
+++ b/apps/bff/src/ai/http/chat.ts
@@ -165,6 +165,7 @@ export function registerAiRoutes(app: FastifyInstance, deps: AiChatRouteDeps): v
emitSubPage,
emitProposal,
emitProfiling,
+ emitProcessTopology,
emitPodLogs,
emitHierarchy,
emitTopology,
@@ -190,6 +191,7 @@ export function registerAiRoutes(app: FastifyInstance, deps: AiChatRouteDeps): v
emitSubPage,
emitProposal,
emitProfiling,
+ emitProcessTopology,
emitPodLogs,
emitHierarchy,
emitTopology,
diff --git a/apps/bff/src/ai/resources/prompts/skills.md b/apps/bff/src/ai/resources/prompts/skills.md
index c24e70b..576ee12 100644
--- a/apps/bff/src/ai/resources/prompts/skills.md
+++ b/apps/bff/src/ai/resources/prompts/skills.md
@@ -72,4 +72,4 @@ SUB-PAGE TOOLS — mount an interactive feature view as a card (opens the full p
TRIGGERS skill — gated profiling (propose_profiling) + analysis (analyze_profiling)
- You are READ-ONLY except for propose_profiling. When metrics + pod logs cannot localise a cause and a PROFILE would confirm a specific hypothesis, call propose_profiling — it presents a decision card (your analysed cause, why profiling, what you expect); the USER approves it in a popout; it runs only after approval; you analyse the result in a LATER turn. Never assume it ran, and never fabricate its output.
- Pick the profilingType by READING, not assuming: kb_layer_capabilities(layer).components says which of trace/async/pprof/ebpf/network the layer supports; async-profiler is JVM-only and pprof is Go-only (read the instance language via kb_resolve_scope_drill to choose), trace + eBPF are language-agnostic, network profiling yields a process-conversation graph. The tool re-validates the type against the layer and resolves target instances.
-- analyze_profiling(layer, service, profilingType) is READ-only — call it in that later turn (or whenever the user asks to analyse an existing profile). trace/pprof/async/ebpf render a flame graph inline and the reply lists the hottest self-time frames (use them to name the cause); network has no flame, so it returns a TEXT read-out of the process-conversation graph — relay that. Never point the user at a live tab for a result: a tab re-queries and drifts from the data you captured — eit [...]
+- analyze_profiling(layer, service, profilingType) is READ-only — call it in that later turn (or whenever the user asks to analyse an existing profile). trace/pprof/async/ebpf render a flame graph inline and the reply lists the hottest self-time frames (use them to name the cause); network CAPTURES + renders the process-conversation graph inline when processes report, and when no Rover/eBPF agent is present it says so in text — relay that. Never point the user at a live tab for a result: [...]
diff --git a/apps/bff/src/ai/skill/triggers/tools.test.ts b/apps/bff/src/ai/skill/triggers/tools.test.ts
index b8b91b8..59877de 100644
--- a/apps/bff/src/ai/skill/triggers/tools.test.ts
+++ b/apps/bff/src/ai/skill/triggers/tools.test.ts
@@ -29,6 +29,7 @@ vi.mock('../../../logic/oap/profiling.js', () => ({
{ id: 'i-2', name: 'inst-2', language: 'java' },
]),
analyzeProfiling: vi.fn(),
+ analyzeNetworkProfiling: vi.fn(),
}));
import { triggerTools } from './tools.js';
diff --git a/apps/bff/src/ai/skill/triggers/tools.ts b/apps/bff/src/ai/skill/triggers/tools.ts
index b832986..4732a33 100644
--- a/apps/bff/src/ai/skill/triggers/tools.ts
+++ b/apps/bff/src/ai/skill/triggers/tools.ts
@@ -165,15 +165,23 @@ export function triggerTools(ctx: AiRequestContext): StructuredToolInterface[] {
const analyze = tool(
async ({ layer, service, profilingType, taskId }): Promise<string> => {
// Network profiling has no flame — its result is a process-conversation
- // graph. Read it out as TEXT rather than hand off to a live tab (which would
- // drift from what we read).
+ // graph. CAPTURE it and render a frozen block (never a live tab, which would
+ // drift). When no processes report — Rover/eBPF absent — say it out in text.
if (profilingType === 'network') {
- const s = await analyzeNetworkProfiling(ctx.opts, layer, service, ctx.window);
- if (!s.reachable) return `Could not read the network-profiling result for ${service}: ${s.error ?? 'unreachable'}.`;
- if (!s.processCount) {
- return `No process-conversation data for ${service} — network profiling needs a Rover eBPF agent, and none is reporting here.`;
+ const r = await analyzeNetworkProfiling(ctx.opts, layer, service, ctx.window);
+ const topo = r.topology;
+ if (!topo.reachable) return `Could not read the network-profiling result for ${service}: ${topo.error ?? 'unreachable'}.`;
+ if (!topo.nodes.length) {
+ return `No process-conversation data for ${service} — network/eBPF profiling needs a Rover eBPF agent, and none is reporting at this deployment. Tell the user network profiling is unavailable here.`;
}
- return `Network profiling for ${service} (instance ${s.instanceName}): ${s.processCount} process(es) in ${s.conversationCount} conversation(s). Top: ${s.conversations.join('; ')}.`;
+ ctx.emitProcessTopology({
+ title: `Network profiling — ${service}`,
+ layer: layer.toUpperCase(),
+ service,
+ instanceName: r.instanceName,
+ replayData: topo,
+ });
+ return `Rendered the network process-conversation graph for ${service} (instance ${r.instanceName}): ${topo.nodes.length} process(es), ${topo.calls.length} conversation(s).`;
}
const a = await analyzeProfiling({ opts: ctx.opts, profilingType, layerKey: layer, service, taskId });
ctx.emitProfiling({
@@ -192,7 +200,10 @@ export function triggerTools(ctx: AiRequestContext): StructuredToolInterface[] {
});
if (!a.reachable) return `Could not read the ${profilingType} profile for ${service}: ${a.error ?? 'unreachable'}.`;
if (!a.trees.length) {
- return `The ${profilingType} profiling task for ${service} has not collected analyzable data yet${a.error ? ` (${a.error})` : ''}. Ask the user to let it finish, then analyze again.`;
+ const collected = a.logs.length > 0 || (a.summary.segmentCount ?? 0) > 0;
+ return collected
+ ? `The ${profilingType} profiling task for ${service} ran but produced no analyzable stacks${a.error ? ` (${a.error})` : ''} — nothing met the sampling threshold. Tell the user; do not retry indefinitely.`
+ : `The ${profilingType} profiling task for ${service} returned no data${a.error ? ` (${a.error})` : ''}. If it was created a few minutes ago and still has nothing, ${profilingType} profiling is likely unsupported at this deployment (the agent may lack the profiling plugin) — tell the user that rather than retrying. If it was JUST created, give it 2–4 minutes to collect, then analyze once more.`;
}
return `Rendered the ${profilingType} profile for ${service}: ${a.summary.frameCount} stack frames${a.tip ? ` (partial — ${a.tip})` : ''}.${summarizeProfile(a)}`;
},
diff --git a/apps/bff/src/ai/types.ts b/apps/bff/src/ai/types.ts
index 428a61b..2f7362e 100644
--- a/apps/bff/src/ai/types.ts
+++ b/apps/bff/src/ai/types.ts
@@ -31,6 +31,7 @@ import type {
EndpointDependencyResponse,
InstanceTopologyResponse,
LogsResponse,
+ ProcessTopologyResponse,
ProfileAnalyzationTree,
ServiceHierarchyResponse,
TopologyResponse,
@@ -104,6 +105,18 @@ export interface ProposalSpec {
expectation: string;
}
+/** A captured, frozen network process-conversation graph (network profiling's
+ * result). ProcessTopologyGraph is a stateless renderer, so the block replays
+ * `replayData` directly — no re-query. Emitted only when processes exist; an
+ * absent Rover/eBPF agent is said out in text instead. */
+export interface ProcessTopologySpec {
+ title: string;
+ layer: string;
+ service: string;
+ instanceName: string | null;
+ replayData: ProcessTopologyResponse;
+}
+
/** A captured, frozen profiling result — analyzed flame `trees` + task facts +
* logs. Renders the Profiling-tab flame from `trees`; never re-queries on
* reload (empty `trees` = nothing collected yet). */
@@ -327,6 +340,7 @@ export type SseEvent =
| { type: 'subpage'; n: number; spec: SubPageSpec }
| { type: 'proposal'; n: number; spec: ProposalSpec }
| { type: 'profiling'; n: number; spec: ProfilingResultSpec }
+ | { type: 'process-topology'; n: number; spec: ProcessTopologySpec }
| { type: 'podlogs'; n: number; spec: PodLogSpec }
| { type: 'hierarchy'; n: number; spec: HierarchySpec }
| { type: 'topology'; n: number; spec: TopologySpec }
diff --git a/apps/bff/src/logic/oap/profiling.ts b/apps/bff/src/logic/oap/profiling.ts
index 264cca9..cec3f7a 100644
--- a/apps/bff/src/logic/oap/profiling.ts
+++ b/apps/bff/src/logic/oap/profiling.ts
@@ -25,7 +25,13 @@
* no-data states, not a reason to retry.
*/
-import type { ProfileAnalyzationElement, ProfileAnalyzationTree } from '@skywalking-horizon-ui/api-client';
+import type {
+ ProcessCall,
+ ProcessNode,
+ ProcessTopologyResponse,
+ ProfileAnalyzationElement,
+ ProfileAnalyzationTree,
+} from '@skywalking-horizon-ui/api-client';
import type { GraphqlOptions } from '../../client/graphql.js';
import { graphqlPost } from '../../client/graphql.js';
@@ -224,50 +230,49 @@ export async function listServiceInstances(
const GET_PROCESS_TOPOLOGY = /* GraphQL */ `
query AiProcessTopology($serviceInstanceId: ID!, $duration: Duration!) {
topology: getProcessTopology(serviceInstanceId: $serviceInstanceId, duration: $duration) {
- nodes { id name }
- calls { source target }
+ nodes { id name isReal serviceName serviceId serviceInstanceId serviceInstanceName }
+ calls { id source target detectPoints sourceComponents targetComponents }
}
}
`;
-export interface NetworkProfilingSummary {
+export interface NetworkProfilingResult {
instanceName: string | null;
- processCount: number;
- conversationCount: number;
- /** "procA → procB" for the first conversations, for the agent to read out. */
- conversations: string[];
- reachable: boolean;
- error?: string;
+ /** The captured process-conversation graph — frozen render input for
+ * ProcessTopologyGraph, so the block replays without re-querying. */
+ topology: ProcessTopologyResponse;
}
-/** Network profiling's result is a process-conversation graph, not a flame. It is
- * summarised to TEXT (never handed to a live tab, which would drift from what the
- * agent read). Empty ⇒ no Rover eBPF agent is reporting. */
+/** Network profiling's result is a process-conversation graph. Read it (one
+ * instance's process topology) and return it as CAPTURED render data — the block
+ * freezes + replays it, never a live tab. Empty ⇒ no Rover eBPF agent reporting. */
export async function analyzeNetworkProfiling(
opts: GraphqlOptions,
layerKey: string,
service: string,
window: { start: string; end: string; step: string },
-): Promise<NetworkProfilingSummary> {
- const empty: NetworkProfilingSummary = { instanceName: null, processCount: 0, conversationCount: 0, conversations: [], reachable: true };
+): Promise<NetworkProfilingResult> {
+ const empty = (reachable: boolean, error?: string): NetworkProfilingResult => ({
+ instanceName: null,
+ topology: { nodes: [], calls: [], reachable, ...(error ? { error } : {}) },
+ });
try {
const serviceId = await resolveServiceId(opts, layerKey, service);
- if (!serviceId) return { ...empty, reachable: false, error: `Unknown service "${service}" in layer ${layerKey}.` };
+ if (!serviceId) return empty(false, `Unknown service "${service}" in layer ${layerKey}.`);
const insts = await listServiceInstances(opts, serviceId, window);
- if (!insts.length) return empty;
+ if (!insts.length) return empty(true);
const inst = insts[0];
- const data = await graphqlPost<{ topology: { nodes: Array<{ id: string; name: string }>; calls: Array<{ source: string; target: string }> } | null }>(
+ const data = await graphqlPost<{ topology: { nodes: ProcessNode[]; calls: ProcessCall[] } | null }>(
opts,
GET_PROCESS_TOPOLOGY,
{ serviceInstanceId: inst.id, duration: { start: window.start, end: window.end, step: window.step } },
);
- const nodes = data.topology?.nodes ?? [];
- const calls = data.topology?.calls ?? [];
- const nameById = new Map(nodes.map((n) => [n.id, n.name]));
- const conversations = calls.slice(0, 12).map((c) => `${nameById.get(c.source) ?? c.source} → ${nameById.get(c.target) ?? c.target}`);
- return { instanceName: inst.name, processCount: nodes.length, conversationCount: calls.length, conversations, reachable: true };
+ return {
+ instanceName: inst.name,
+ topology: { nodes: data.topology?.nodes ?? [], calls: data.topology?.calls ?? [], reachable: true },
+ };
} catch (err) {
- return { ...empty, reachable: false, error: err instanceof Error ? err.message : String(err) };
+ return empty(false, err instanceof Error ? err.message : String(err));
}
}
diff --git a/apps/ui/src/ai/ChatProcessTopologyBlock.vue b/apps/ui/src/ai/ChatProcessTopologyBlock.vue
new file mode 100644
index 0000000..82d688d
--- /dev/null
+++ b/apps/ui/src/ai/ChatProcessTopologyBlock.vue
@@ -0,0 +1,89 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<!-- Captured network process-conversation graph (network profiling's result).
+ ProcessTopologyGraph is a stateless renderer (nodes/calls in), so the block
+ replays the captured graph directly — no query, frozen on reload. -->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import ProcessTopologyGraph from '@/layer/profiling/ProcessTopologyGraph.vue';
+import ChatCapturedTag from './ChatCapturedTag.vue';
+import type { ProcessTopologySpec } from './types';
+
+const props = defineProps<{ n: number; spec: ProcessTopologySpec; capturedAt?: number }>();
+const { t } = useI18n({ useScope: 'global' });
+
+const hasData = computed<boolean>(() => props.spec.replayData.reachable && props.spec.replayData.nodes.length > 0);
+</script>
+
+<template>
+ <div class="cpt">
+ <div class="cpt__cap">
+ {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag :at="capturedAt" />
+ </div>
+ <div class="cpt__facts">
+ <span v-if="spec.instanceName" class="cpt__fact">{{ spec.instanceName }}</span>
+ <span v-if="hasData" class="cpt__fact">{{ t('{n} processes', { n: spec.replayData.nodes.length }) }}</span>
+ </div>
+ <div v-if="hasData" class="cpt__graph">
+ <ProcessTopologyGraph :nodes="spec.replayData.nodes" :calls="spec.replayData.calls" />
+ </div>
+ <div v-else class="cpt__empty">{{ t('No process-conversation data was captured.') }}</div>
+ </div>
+</template>
+
+<style scoped>
+.cpt {
+ border: 1px solid var(--sw-line, #2a2d36);
+ border-radius: 8px;
+ background: var(--sw-bg-1, #1b1d24);
+ overflow: hidden;
+ margin: 8px 0;
+}
+.cpt__cap {
+ padding: 8px 12px;
+ font-size: 12px;
+ color: var(--sw-fg-2, #9aa0ac);
+ border-bottom: 1px solid var(--sw-line, #2a2d36);
+}
+.cpt__facts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 8px 12px 0;
+ font-size: 11px;
+ color: var(--sw-fg-2, #9aa0ac);
+}
+.cpt__fact {
+ font-variant-numeric: tabular-nums;
+}
+.cpt__fact + .cpt__fact::before {
+ content: '·';
+ margin-right: 6px;
+ color: var(--sw-fg-3, #6b6f7a);
+}
+.cpt__graph {
+ height: 420px;
+ margin-top: 8px;
+}
+.cpt__empty {
+ padding: 24px 12px;
+ text-align: center;
+ font-size: 12px;
+ color: var(--sw-fg-3, #6b6f7a);
+}
+</style>
diff --git a/apps/ui/src/ai/ChatTranscript.vue b/apps/ui/src/ai/ChatTranscript.vue
index 9ec511a..19e6b2e 100644
--- a/apps/ui/src/ai/ChatTranscript.vue
+++ b/apps/ui/src/ai/ChatTranscript.vue
@@ -23,6 +23,7 @@ import ChatFigureBlock from './ChatFigureBlock.vue';
import ChatSubPageBlock from './ChatSubPageBlock.vue';
import ChatProposalBlock from './ChatProposalBlock.vue';
import ChatProfilingBlock from './ChatProfilingBlock.vue';
+import ChatProcessTopologyBlock from './ChatProcessTopologyBlock.vue';
import ChatPodLogsBlock from './ChatPodLogsBlock.vue';
import ChatHierarchyBlock from './ChatHierarchyBlock.vue';
import ChatTopologyBlock from './ChatTopologyBlock.vue';
@@ -96,6 +97,7 @@ function fmtTime(at: number): string {
<ChatSubPageBlock v-else-if="b.kind === 'subpage'" :n="b.n" :spec="b.spec" />
<ChatProposalBlock v-else-if="b.kind === 'proposal'" :block="b" />
<ChatProfilingBlock v-else-if="b.kind === 'profiling'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" />
+ <ChatProcessTopologyBlock v-else-if="b.kind === 'process-topology'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" />
<ChatPodLogsBlock v-else-if="b.kind === 'podlogs'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" />
<ChatHierarchyBlock v-else-if="b.kind === 'hierarchy'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" />
<ChatTopologyBlock v-else-if="b.kind === 'topology'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" />
diff --git a/apps/ui/src/ai/types.ts b/apps/ui/src/ai/types.ts
index def09cf..c2b97a0 100644
--- a/apps/ui/src/ai/types.ts
+++ b/apps/ui/src/ai/types.ts
@@ -30,6 +30,7 @@ import type {
ZipkinTraceListResponse,
LogsResponse,
BrowserErrorsResponse,
+ ProcessTopologyResponse,
} from '@skywalking-horizon-ui/api-client';
export type FigureLayout = 'single' | 'tabs' | 'stack' | 'grid';
@@ -117,6 +118,17 @@ export interface ProfilingResultSpec {
error?: string | null;
}
+// A captured network process-conversation graph (network profiling's result).
+// ProcessTopologyGraph is a stateless renderer, so the block replays replayData
+// directly — no re-query. Mirror of the BFF spec.
+export interface ProcessTopologySpec {
+ title: string;
+ layer: string;
+ service: string;
+ instanceName: string | null;
+ replayData: ProcessTopologyResponse;
+}
+
// One line of on-demand pod-log output. `timestamp` is epoch-ms (or null).
export interface PodLogLine {
content: string;
@@ -293,6 +305,7 @@ export type SseEvent =
| { type: 'subpage'; n: number; spec: SubPageSpec }
| { type: 'proposal'; n: number; spec: ProposalSpec }
| { type: 'profiling'; n: number; spec: ProfilingResultSpec }
+ | { type: 'process-topology'; n: number; spec: ProcessTopologySpec }
| { type: 'podlogs'; n: number; spec: PodLogSpec }
| { type: 'hierarchy'; n: number; spec: HierarchySpec }
| { type: 'topology'; n: number; spec: TopologySpec }
@@ -314,6 +327,7 @@ export type Block =
| { kind: 'subpage'; n: number; spec: SubPageSpec }
| { kind: 'proposal'; n: number; spec: ProposalSpec; status: ProposalStatus; taskId?: string; error?: string }
| { kind: 'profiling'; n: number; spec: ProfilingResultSpec; capturedAt?: number }
+ | { kind: 'process-topology'; n: number; spec: ProcessTopologySpec; capturedAt?: number }
| { kind: 'podlogs'; n: number; spec: PodLogSpec; capturedAt?: number }
| { kind: 'hierarchy'; n: number; spec: HierarchySpec; capturedAt?: number }
| { kind: 'topology'; n: number; spec: TopologySpec; capturedAt?: number }
diff --git a/apps/ui/src/ai/useAiConversations.ts b/apps/ui/src/ai/useAiConversations.ts
index a14d8d6..c095e4f 100644
--- a/apps/ui/src/ai/useAiConversations.ts
+++ b/apps/ui/src/ai/useAiConversations.ts
@@ -242,6 +242,7 @@ async function send(text: string): Promise<void> {
else if (ev.type === 'subpage') assistant.blocks.push({ kind: 'subpage', n: ev.n, spec: ev.spec });
else if (ev.type === 'proposal') assistant.blocks.push({ kind: 'proposal', n: ev.n, spec: ev.spec, status: 'pending' });
else if (ev.type === 'profiling') assistant.blocks.push({ kind: 'profiling', n: ev.n, spec: ev.spec, capturedAt: Date.now() });
+ else if (ev.type === 'process-topology') assistant.blocks.push({ kind: 'process-topology', n: ev.n, spec: ev.spec, capturedAt: Date.now() });
else if (ev.type === 'podlogs') assistant.blocks.push({ kind: 'podlogs', n: ev.n, spec: ev.spec, capturedAt: Date.now() });
else if (ev.type === 'hierarchy') assistant.blocks.push({ kind: 'hierarchy', n: ev.n, spec: ev.spec, capturedAt: Date.now() });
else if (ev.type === 'topology') assistant.blocks.push({ kind: 'topology', n: ev.n, spec: ev.spec, capturedAt: Date.now() });
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index b684690..08cace9 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1645,6 +1645,8 @@
"Captured snapshot — replayed from history, not live data": "Captured snapshot — replayed from history, not live data",
"{n} segments": "{n} segments",
"{n} frames": "{n} frames",
+ "{n} processes": "{n} processes",
+ "No process-conversation data was captured.": "No process-conversation data was captured.",
"all endpoints": "all endpoints",
"Could not read the profile.": "Could not read the profile.",
"No profile data was collected in this task yet.": "No profile data was collected in this task yet.",