1
1
<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 " ;
8
8
import { ControlButton , Controls } from " @vue-flow/controls" ;
9
- import { useMessageStore } from " @/stores/MessageStore" ;
9
+ import { useMessageStore } from " @/stores/MessageStore.ts " ;
10
10
import LoadingSpinner from " @/components/LoadingSpinner.vue" ;
11
11
import { storeToRefs } from " pinia" ;
12
12
import EndpointDetails from " @/resources/EndpointDetails.ts" ;
13
13
import { hexToCSSFilter } from " hex-to-css-filter" ;
14
14
import TextEllipses from " @/components/TextEllipses.vue" ;
15
+ import { useLayout } from " @/components/messages2/FlowDiagram/useLayout.ts" ;
15
16
16
17
enum MessageType {
17
18
Event = " Event message" ,
18
19
Timeout = " Timeout message" ,
19
20
Command = " Command message" ,
20
21
}
21
22
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 {
24
33
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 ;
25
51
messageId: string ;
26
52
sendingEndpoint: EndpointDetails ;
27
53
receivingEndpoint: EndpointDetails ;
28
- parentId: string ;
29
- parentEndpoint: string ;
30
- type: MessageType ;
31
54
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 ;
41
62
}
42
63
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
+ }
45
96
46
- const store = useMessageStore ();
47
- const { state } = storeToRefs ( store );
97
+ function constructNodes( messages : Message []) : Node < NodeData >[] {
98
+ const messageMap = new Map ( );
48
99
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
+ });
51
105
52
- return store . conversationData . data ;
106
+ return Array . from ( messageMap . values ()) ;
53
107
}
54
108
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
+ });
64
137
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
+ }
68
160
}
69
161
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
+ }
75
164
165
+ function addConnection(parentMessage : Node <NodeData >, childMessage : Node <NodeData >): DefaultEdge {
76
166
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 ,
95
174
};
96
175
}
97
176
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 ();
163
181
164
182
onMounted (async () => {
165
183
if (! state .value .data .conversation_id ) return ;
166
184
167
185
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 ));
185
186
186
- elements .value = [... nodes , ... edges ];
187
+ nodes .value = constructNodes (messages );
188
+ edges .value = constructEdges (nodes .value );
187
189
});
188
190
191
+ async function layoutGraph() {
192
+ nodes .value = layout (nodes .value , edges .value );
193
+
194
+ await nextTick (() => {
195
+ fitView ();
196
+ });
197
+ }
198
+
189
199
function typeIcon(type : MessageType ) {
190
200
switch (type ) {
191
201
case MessageType .Timeout :
@@ -211,32 +221,32 @@ const greenColor = hexToCSSFilter("#00c468").filter;
211
221
<div v-if =" store.conversationData.failed_to_load" class =" alert alert-info" >FlowDiagram data is unavailable.</div >
212
222
<LoadingSpinner v-else-if =" store.conversationData.loading" />
213
223
<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 " >
215
225
<Controls position =" top-left" class =" controls" >
216
226
<ControlButton v-tippy =" showAddress ? `Hide endpoints` : `Show endpoints`" @click =" toggleAddress" >
217
227
<i class =" fa pa-flow-endpoint" :style =" { filter: showAddress ? greenColor : blackColor }" ></i >
218
228
</ControlButton >
219
229
</Controls >
220
- <template #node-message =" { data }: { data: MappedMessage } " >
230
+ <template #node-message =" { id , data }: { id: string ; data : NodeData } " >
221
231
<div v-if =" showAddress" >
222
232
<TextEllipses class =" address" :text =" `${data.sendingEndpoint.name}@${data.sendingEndpoint.host}`" />
223
233
</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 }" >
225
235
<div class =" node-text" >
226
236
<i v-if =" data.isError" class =" fa pa-flow-failed" />
227
237
<i class =" fa" :class =" typeIcon(data.type)" v-tippy =" data.type" />
228
238
<div class =" lead" >
229
239
<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 >
232
242
</strong >
233
243
</div >
234
244
<div class =" time-sent" >
235
245
<time-since class =" time-since" :date-utc =" data.timeSent" />
236
246
</div >
237
- <template v-if = " data .sagaName " >
247
+ <template v-for = " saga in data .sagaInvocations " : key = " saga . id " >
238
248
<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 >
240
250
</template >
241
251
</div >
242
252
</div >
@@ -255,7 +265,7 @@ const greenColor = hexToCSSFilter("#00c468").filter;
255
265
</style >
256
266
257
267
<style scoped>
258
- @import " ../list.css" ;
268
+ @import " ../../ list.css" ;
259
269
260
270
.controls {
261
271
display : flex ;
0 commit comments