11<script setup lang="ts">
2- import { onMounted , ref } from " vue" ;
3- import { type DefaultEdge , MarkerType , type Node , type Styles , VueFlow } from " @vue-flow/core" ;
4- import TimeSince from " ../TimeSince.vue" ;
5- import routeLinks from " @/router/routeLinks" ;
6- import Message , { MessageStatus } from " @/resources/Message" ;
7- import { NServiceBusHeaders } from " @/resources/Header" ;
2+ import { onMounted , ref , nextTick } from " vue" ;
3+ import { type DefaultEdge , MarkerType , type Node , type Styles , useVueFlow , VueFlow , XYPosition } from " @vue-flow/core" ;
4+ import TimeSince from " ../../ TimeSince.vue" ;
5+ import routeLinks from " @/router/routeLinks.ts " ;
6+ import Message , { MessageIntent , MessageStatus , SagaInfo } from " @/resources/Message.ts " ;
7+ import { NServiceBusHeaders } from " @/resources/Header.ts " ;
88import { ControlButton , Controls } from " @vue-flow/controls" ;
9- import { useMessageStore } from " @/stores/MessageStore" ;
9+ import { useMessageStore } from " @/stores/MessageStore.ts " ;
1010import LoadingSpinner from " @/components/LoadingSpinner.vue" ;
1111import { storeToRefs } from " pinia" ;
1212import EndpointDetails from " @/resources/EndpointDetails.ts" ;
1313import { hexToCSSFilter } from " hex-to-css-filter" ;
1414import TextEllipses from " @/components/TextEllipses.vue" ;
15+ import { useLayout } from " @/components/messages2/FlowDiagram/useLayout.ts" ;
1516
1617enum MessageType {
1718 Event = " Event message" ,
1819 Timeout = " Timeout message" ,
1920 Command = " Command message" ,
2021}
2122
22- interface MappedMessage {
23- nodeName: string ;
23+ const store = useMessageStore ();
24+ const { state } = storeToRefs (store );
25+
26+ async function getConversation(conversationId : string ) {
27+ await store .loadConversation (conversationId );
28+
29+ return store .conversationData .data ;
30+ }
31+
32+ class SagaInvocation {
2433 id: string ;
34+ sagaType: string ;
35+ isSagaCompleted: boolean ;
36+ isSagaInitiated: boolean ;
37+
38+ constructor (saga : SagaInfo , message : Message ) {
39+ const sagaIdHeader = getHeaderByKey (message , NServiceBusHeaders .SagaId );
40+ const originatedSagaIdHeader = getHeaderByKey (message , NServiceBusHeaders .OriginatingSagaId );
41+ this .id = saga .saga_id ;
42+ this .sagaType = saga .saga_type ;
43+ this .isSagaCompleted = saga .change_status === " Completed" ;
44+ this .isSagaInitiated = sagaIdHeader === undefined && originatedSagaIdHeader !== undefined ;
45+ }
46+ }
47+
48+ interface NodeData {
49+ label: string ;
50+ timeSent: string ;
2551 messageId: string ;
2652 sendingEndpoint: EndpointDetails ;
2753 receivingEndpoint: EndpointDetails ;
28- parentId: string ;
29- parentEndpoint: string ;
30- type: MessageType ;
3154 isError: boolean ;
32- sagaName: string ;
33- link: {
34- name: string ;
35- nodeName: string ;
36- };
37- timeSent: string ;
38- level: number ;
39- width: number ;
40- XPos: number ;
55+ sagaInvocations: SagaInvocation [];
56+ isPublished: boolean ;
57+ isTimeout: boolean ;
58+ isEvent: boolean ;
59+ isCommand: boolean ;
60+ message: Message ;
61+ type: MessageType ;
4162}
4263
43- const nodeSpacingX = 300 ;
44- const nodeSpacingY = 200 ;
64+ class MessageNode implements Node <NodeData > {
65+ readonly id: string ;
66+ readonly type: string ;
67+ readonly data: NodeData ;
68+ readonly position: XYPosition ;
69+ readonly draggable: boolean ;
70+
71+ constructor (message : Message ) {
72+ this .id = message .id ;
73+ this .type = " message" ;
74+ this .position = { x: 0 , y: 0 };
75+ this .draggable = false ;
76+
77+ const isPublished = message .message_intent === MessageIntent .Publish ;
78+ const isTimeout = getHeaderByKey (message , NServiceBusHeaders .IsSagaTimeoutMessage )?.toLowerCase () === " true" ;
79+ this .data = {
80+ label: message .message_type ,
81+ timeSent: message .time_sent ,
82+ messageId: message .message_id ,
83+ sendingEndpoint: message .sending_endpoint ,
84+ receivingEndpoint: message .receiving_endpoint ,
85+ isError: message .status !== MessageStatus .Successful && message .status !== MessageStatus .ResolvedSuccessfully ,
86+ sagaInvocations: message .invoked_sagas ?.map ((saga ) => new SagaInvocation (saga , message )) || [],
87+ isPublished ,
88+ isTimeout ,
89+ isEvent: isPublished && isTimeout ,
90+ isCommand: ! isPublished && isTimeout ,
91+ message ,
92+ type: isPublished ? MessageType .Event : isTimeout ? MessageType .Timeout : MessageType .Command ,
93+ };
94+ }
95+ }
4596
46- const store = useMessageStore ();
47- const { state } = storeToRefs ( store );
97+ function constructNodes( messages : Message []) : Node < NodeData >[] {
98+ const messageMap = new Map ( );
4899
49- async function getConversation(conversationId : string ) {
50- await store .loadConversation (conversationId );
100+ messages .forEach ((message ) => {
101+ if (! messageMap .has (message .id )) {
102+ messageMap .set (message .id , new MessageNode (message ));
103+ }
104+ });
51105
52- return store . conversationData . data ;
106+ return Array . from ( messageMap . values ()) ;
53107}
54108
55- function mapMessage(message : Message ): MappedMessage {
56- let parentId = " " ,
57- parentEndpoint = " " ,
58- sagaName = " " ;
59- const header = message .headers .find ((header ) => header .key === NServiceBusHeaders .RelatedTo );
60- if (header ) {
61- parentId = header .value ?? " " ;
62- parentEndpoint = message .headers .find ((h ) => h .key === " NServiceBus.OriginatingEndpoint" )?.value ?? " " ;
63- }
109+ function getHeaderByKey(message : Message , key : NServiceBusHeaders ) {
110+ return message .headers .find ((header ) => header .key === key )?.value ;
111+ }
112+
113+ function constructEdges(nodes : Node <NodeData >[]): DefaultEdge [] {
114+ const edges: DefaultEdge [] = [];
115+
116+ for (const node of nodes ) {
117+ const message = node .data ?.message ;
118+ if (message === undefined ) continue ;
119+
120+ const relatedTo = getHeaderByKey (message , NServiceBusHeaders .RelatedTo );
121+ if (! relatedTo && relatedTo !== message .message_id ) {
122+ continue ;
123+ }
124+
125+ let parentMessages = nodes .filter ((n ) => {
126+ const m = n .data ?.message ;
127+ if (m === undefined ) return false ;
128+ return m .receiving_endpoint !== undefined && m .sending_endpoint !== undefined && m .message_id === relatedTo && m .receiving_endpoint .name === message .sending_endpoint .name ;
129+ });
130+
131+ if (parentMessages .length === 0 ) {
132+ parentMessages = nodes .filter ((n ) => {
133+ const m = n .data ?.message ;
134+ if (m === undefined ) return false ;
135+ return m .receiving_endpoint !== undefined && m .sending_endpoint !== undefined && m .message_id === relatedTo && m .message_intent !== MessageIntent .Publish ;
136+ });
64137
65- const sagaHeader = message .headers .find ((header ) => header .key === NServiceBusHeaders .OriginatingSagaType );
66- if (sagaHeader ) {
67- sagaName = sagaHeader .value ?.split (" , " )[0 ] ?? " " ;
138+ if (parentMessages .length === 0 ) {
139+ console .log (` Fall back to match only on RelatedToMessageId for message with Id '${message .message_id }' matched but link could be invalid. ` );
140+ }
141+ }
142+
143+ switch (parentMessages .length ) {
144+ case 0 :
145+ console .log (
146+ ` No parent could be resolved for the message with Id '${message .message_id }' which has RelatedToMessageId set. This can happen if the parent has been purged due to retention expiration, an ServiceControl node to be unavailable, or because the parent message not been stored (yet). `
147+ );
148+ break ;
149+ case 1 :
150+ // Log nothing, this is what it should be
151+ break ;
152+ default :
153+ console .log (` Multiple parents matched for message id '${message .message_id }' possibly due to more-than-once processing, linking to all as it is unknown which processing attempt generated the message. ` );
154+ break ;
155+ }
156+
157+ for (const parentMessage of parentMessages ) {
158+ edges .push (addConnection (parentMessage , node ));
159+ }
68160 }
69161
70- const type = (() => {
71- if (message .headers .find ((header ) => header .key === NServiceBusHeaders .MessageIntent )?.value === " Publish" ) return MessageType .Event ;
72- else if (message .headers .find ((header ) => header .key === NServiceBusHeaders .IsSagaTimeoutMessage )?.value ?.toLowerCase () === " true" ) return MessageType .Timeout ;
73- return MessageType .Command ;
74- })();
162+ return edges ;
163+ }
75164
165+ function addConnection(parentMessage : Node <NodeData >, childMessage : Node <NodeData >): DefaultEdge {
76166 return {
77- nodeName: message .message_type ,
78- id: message .id ,
79- messageId: message .message_id ,
80- sendingEndpoint: message .sending_endpoint ,
81- receivingEndpoint: message .receiving_endpoint ,
82- parentId ,
83- parentEndpoint ,
84- type ,
85- isError: message .status !== MessageStatus .Successful && message .status !== MessageStatus .ResolvedSuccessfully ,
86- sagaName ,
87- level: 0 ,
88- width: 0 ,
89- XPos: 0 ,
90- link: {
91- name: ` Link ${message .id } ` ,
92- nodeName: message .id ,
93- },
94- timeSent: message .time_sent ,
167+ id: ` ${parentMessage .id }##${childMessage .id } ` ,
168+ source: ` ${parentMessage .id } ` ,
169+ target: ` ${childMessage .id } ` ,
170+ markerEnd: MarkerType .ArrowClosed ,
171+ style: {
172+ " stroke-dasharray" : childMessage .data ?.isEvent && " 5, 3" ,
173+ } as Styles ,
95174 };
96175}
97176
98- function constructNodes(mappedMessages : MappedMessage []): Node <MappedMessage >[] {
99- return (
100- mappedMessages
101- // group by level
102- .reduce ((groups : MappedMessage [][], message : MappedMessage ) => {
103- groups [message .level ] = [... (groups [message .level ] ?? []), message ];
104- return groups ;
105- }, [])
106- // ensure each level has their items in the same "grouped" order as the level above
107- .map ((group , level , messagesByLevel ) => {
108- const previousLevel = level > 0 ? messagesByLevel [level - 1 ] : null ;
109- return group .sort (
110- (a , b ) =>
111- (previousLevel ?.findIndex ((plMessage ) => a .parentId === plMessage .messageId && a .parentEndpoint === plMessage .receivingEndpoint .name ) ?? 1 ) -
112- (previousLevel ?.findIndex ((plMessage ) => b .parentId === plMessage .messageId && b .parentEndpoint === plMessage .receivingEndpoint .name ) ?? 1 )
113- );
114- })
115- // flatten to actual flow diagram nodes, with positioning based on parent node/level
116- .flatMap ((group , level , messagesByLevel ) => {
117- const previousLevel = level > 0 ? messagesByLevel [level - 1 ] : null ;
118- return group .reduce (
119- ({ result , currentWidth , previousParent }, message ) => {
120- // position on current level needs to be based on parent Node, so see if one exists
121- const parentMessage = previousLevel ?.find ((plMessage ) => message .parentId === plMessage .messageId && message .parentEndpoint === plMessage .receivingEndpoint .name ) ?? null ;
122- // if the current parent node is the same as the previous parent node, then the current position needs to be to the right of siblings
123- const currentParentWidth = previousParent === parentMessage ? currentWidth : 0 ;
124- const startX = parentMessage == null ? 0 : parentMessage .XPos - parentMessage .width / 2 ;
125- // store the position of the node against the message, so child nodes can use it to determine their start position
126- message .XPos = startX + (currentParentWidth + message .width / 2 );
127- return {
128- result: [
129- ... result ,
130- {
131- id: ` ${message .messageId }##${message .receivingEndpoint .name } ` ,
132- type: " message" ,
133- data: message ,
134- label: message .nodeName ,
135- position: { x: message .XPos * nodeSpacingX , y: message .level * nodeSpacingY },
136- },
137- ],
138- currentWidth: currentParentWidth + message .width ,
139- previousParent: parentMessage ,
140- };
141- },
142- { result: [] as Node [], currentWidth: 0 , previousParent: null as MappedMessage | null }
143- ).result ;
144- })
145- );
146- }
147-
148- function constructEdges(mappedMessages : MappedMessage []): DefaultEdge [] {
149- return mappedMessages
150- .filter ((message ) => message .parentId )
151- .map ((message ) => ({
152- id: ` ${message .parentId }##${message .messageId } ` ,
153- source: ` ${message .parentId }##${message .parentEndpoint } ` ,
154- target: ` ${message .messageId }##${message .receivingEndpoint .name } ` ,
155- markerEnd: MarkerType .ArrowClosed ,
156- style: {
157- " stroke-dasharray" : message .type === MessageType .Event && " 5, 3" ,
158- } as Styles ,
159- }));
160- }
161-
162- const elements = ref <(Node | DefaultEdge )[]>([]);
177+ const nodes = ref <Node []>([]);
178+ const edges = ref <DefaultEdge []>([]);
179+ const { layout } = useLayout ();
180+ const { fitView } = useVueFlow ();
163181
164182onMounted (async () => {
165183 if (! state .value .data .conversation_id ) return ;
166184
167185 const messages = await getConversation (state .value .data .conversation_id );
168- const mappedMessages = messages .map (mapMessage );
169-
170- const assignDescendantLevelsAndWidth = (message : MappedMessage , level = 0 ) => {
171- message .level = level ;
172- const children = mappedMessages .filter ((mm ) => mm .parentId === message .messageId && mm .parentEndpoint === message .receivingEndpoint .name );
173- message .width =
174- children .length === 0
175- ? 1 // leaf node
176- : children .map ((child ) => (child .width === 0 ? assignDescendantLevelsAndWidth (child , level + 1 ) : child )).reduce ((sum , { width }) => sum + width , 0 );
177- return message ;
178- };
179- for (const root of mappedMessages .filter ((message ) => ! message .parentId )) {
180- assignDescendantLevelsAndWidth (root );
181- }
182-
183- const nodes = constructNodes (mappedMessages );
184- const edges = constructEdges (nodes .map ((n ) => n .data as MappedMessage ));
185186
186- elements .value = [... nodes , ... edges ];
187+ nodes .value = constructNodes (messages );
188+ edges .value = constructEdges (nodes .value );
187189});
188190
191+ async function layoutGraph() {
192+ nodes .value = layout (nodes .value , edges .value );
193+
194+ await nextTick (() => {
195+ fitView ();
196+ });
197+ }
198+
189199function typeIcon(type : MessageType ) {
190200 switch (type ) {
191201 case MessageType .Timeout :
@@ -211,32 +221,32 @@ const greenColor = hexToCSSFilter("#00c468").filter;
211221 <div v-if =" store.conversationData.failed_to_load" class =" alert alert-info" >FlowDiagram data is unavailable.</div >
212222 <LoadingSpinner v-else-if =" store.conversationData.loading" />
213223 <div v-else id =" tree-container" >
214- <VueFlow v-model = " elements " :min-zoom =" 0.1" :fit-view-on-init =" true" :only-render-visible-elements =" true" >
224+ <VueFlow :nodes = " nodes " :edges = " edges " : min-zoom =" 0.1" :fit-view-on-init =" true" :only-render-visible-elements =" true" @nodes-initialized = " layoutGraph " >
215225 <Controls position =" top-left" class =" controls" >
216226 <ControlButton v-tippy =" showAddress ? `Hide endpoints` : `Show endpoints`" @click =" toggleAddress" >
217227 <i class =" fa pa-flow-endpoint" :style =" { filter: showAddress ? greenColor : blackColor }" ></i >
218228 </ControlButton >
219229 </Controls >
220- <template #node-message =" { data }: { data: MappedMessage } " >
230+ <template #node-message =" { id , data }: { id: string ; data : NodeData } " >
221231 <div v-if =" showAddress" >
222232 <TextEllipses class =" address" :text =" `${data.sendingEndpoint.name}@${data.sendingEndpoint.host}`" />
223233 </div >
224- <div class =" node" :class =" { error: data.isError, 'current-message': data. id === store.state.data.id }" >
234+ <div class =" node" :class =" { error: data.isError, 'current-message': id === store.state.data.id }" >
225235 <div class =" node-text" >
226236 <i v-if =" data.isError" class =" fa pa-flow-failed" />
227237 <i class =" fa" :class =" typeIcon(data.type)" v-tippy =" data.type" />
228238 <div class =" lead" >
229239 <strong >
230- <RouterLink v-if =" data.isError" :to =" { path: routeLinks.messages.failedMessage.link(data. id) }" ><TextEllipses style =" width : 204px " :text =" data.nodeName " ellipses-style =" LeftSide" /></RouterLink >
231- <RouterLink v-else :to =" { path: routeLinks.messages.successMessage.link(data.messageId, data. id) }" ><TextEllipses style =" width : 204px " :text =" data.nodeName " ellipses-style =" LeftSide" /></RouterLink >
240+ <RouterLink v-if =" data.isError" :to =" { path: routeLinks.messages.failedMessage.link(id) }" ><TextEllipses style =" width : 204px " :text =" data.label " ellipses-style =" LeftSide" /></RouterLink >
241+ <RouterLink v-else :to =" { path: routeLinks.messages.successMessage.link(data.messageId, id) }" ><TextEllipses style =" width : 204px " :text =" data.label " ellipses-style =" LeftSide" /></RouterLink >
232242 </strong >
233243 </div >
234244 <div class =" time-sent" >
235245 <time-since class =" time-since" :date-utc =" data.timeSent" />
236246 </div >
237- <template v-if = " data .sagaName " >
247+ <template v-for = " saga in data .sagaInvocations " : key = " saga . id " >
238248 <i class =" fa pa-flow-saga" />
239- <div class =" saga lead" ><TextEllipses style =" width : 182px " :text =" data.sagaName " ellipses-style =" LeftSide" /></div >
249+ <div class =" saga lead" ><TextEllipses style =" width : 182px " :text =" saga.sagaType " ellipses-style =" LeftSide" /></div >
240250 </template >
241251 </div >
242252 </div >
@@ -255,7 +265,7 @@ const greenColor = hexToCSSFilter("#00c468").filter;
255265 </style >
256266
257267<style scoped>
258- @import " ../list.css" ;
268+ @import " ../../ list.css" ;
259269
260270.controls {
261271 display : flex ;
0 commit comments