Skip to content

Commit 095a724

Browse files
lheinshivamG640
authored andcommitted
feat: Topology view
1 parent 0fc9351 commit 095a724

21 files changed

Lines changed: 628 additions & 116 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,4 @@ storybook-static
149149
.bob/notes
150150
.bob/skills
151151
.codex
152+
.claude/

packages/ui/src/components/Visualization/Canvas/Canvas.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,15 @@ interface CanvasProps {
5050
entitiesCount: number;
5151
isVizNodesResolving?: boolean;
5252
contextToolbar?: ReactNode;
53+
isTopologyView?: boolean;
5354
}
5455

5556
export const Canvas: FunctionComponent<PropsWithChildren<CanvasProps>> = ({
5657
vizNodes,
5758
entitiesCount,
5859
isVizNodesResolving = false,
5960
contextToolbar,
61+
isTopologyView = false,
6062
}) => {
6163
const settingsAdapter = useContext(SettingsContext);
6264
const settingsLayout = useMemo(
@@ -103,14 +105,21 @@ export const Canvas: FunctionComponent<PropsWithChildren<CanvasProps>> = ({
103105
const nodes: CanvasNode[] = [];
104106
const edges: CanvasEdge[] = [];
105107

106-
vizNodes.forEach((vizNode) => {
107-
const { nodes: childNodes, edges: childEdges } = FlowService.getFlowDiagram(
108-
vizNode.getId() ?? vizNode.id,
109-
vizNode,
110-
);
108+
if (isTopologyView) {
109+
const { nodes: childNodes, edges: childEdges } = FlowService.getTopologyFlowDiagram(vizNodes);
111110
nodes.push(...childNodes);
112111
edges.push(...childEdges);
113-
});
112+
} else {
113+
vizNodes.forEach((vizNode) => {
114+
const { nodes: childNodes, edges: childEdges } = FlowService.getFlowDiagram(
115+
vizNode.getId() ?? vizNode.id,
116+
vizNode,
117+
{ removePlaceholder: isTopologyView ? true : false, isTopologyView },
118+
);
119+
nodes.push(...childNodes);
120+
edges.push(...childEdges);
121+
});
122+
}
114123

115124
const model: Model = {
116125
nodes,

packages/ui/src/components/Visualization/Canvas/controller.service.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { DagreGroupsLayout, ModelKind, Visualization } from '@patternfly/react-t
22

33
import { CustomGroupWithSelection } from '../Custom';
44
import { CustomEdge } from '../Custom/Edge/CustomEdge';
5+
import TopologyEdge from '../Custom/Edge/TopologyEdge';
56
import { PlaceholderNode } from '../Custom/Node/PlaceholderNode';
7+
import { TopologyNode } from '../Custom/Node/TopologyNode';
68
import { CanvasDefaults } from './canvas.defaults';
79
import { LayoutType } from './canvas.models';
810
import { ControllerService } from './controller.service';
@@ -75,6 +77,18 @@ describe('ControllerService', () => {
7577
expect(component).toBe(CustomEdge);
7678
});
7779

80+
it('should return TopologyNode for topology-node type', () => {
81+
const component = ControllerService.baselineComponentFactory(ModelKind.node, 'topology-node');
82+
83+
expect(component).toBe(TopologyNode);
84+
});
85+
86+
it('should return TopologyEdge for topology-edge type', () => {
87+
const component = ControllerService.baselineComponentFactory(ModelKind.edge, 'topology-edge');
88+
89+
expect(component).toBe(TopologyEdge);
90+
});
91+
7892
it('should return undefined for an unknown type', () => {
7993
const component = ControllerService.baselineComponentFactory({} as ModelKind, 'unknown');
8094

packages/ui/src/components/Visualization/Canvas/controller.service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import {
1212

1313
import { CustomGroupWithSelection, CustomNodeWithSelection, NoBendpointsEdge } from '../Custom';
1414
import { CustomEdge } from '../Custom/Edge/CustomEdge';
15+
import TopologyEdge from '../Custom/Edge/TopologyEdge';
1516
import { CustomGraphWithSelection } from '../Custom/Graph/CustomGraph';
1617
import { PlaceholderNode } from '../Custom/Node/PlaceholderNode';
18+
import { TopologyNode } from '../Custom/Node/TopologyNode';
1719
import { LayoutType } from './canvas.models';
1820

1921
export class ControllerService {
@@ -52,6 +54,10 @@ export class ControllerService {
5254
return CustomGroupWithSelection;
5355
case 'node-placeholder':
5456
return PlaceholderNode;
57+
case 'topology-edge':
58+
return TopologyEdge;
59+
case 'topology-node':
60+
return TopologyNode;
5561
default:
5662
switch (kind) {
5763
case ModelKind.graph:

packages/ui/src/components/Visualization/Canvas/flow.service.ts

Lines changed: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,45 @@ export class FlowService {
88
static nodes: CanvasNode[] = [];
99
static edges: CanvasEdge[] = [];
1010
private static visitedNodes: string[] = [];
11+
private static consumersByEndpoint: Map<string, string[]> = new Map();
12+
private static outgoingByEntity: Map<string, string[]> = new Map();
13+
private static PRODUCER_KEYS = ['to', 'toD', 'wireTap', 'enrich', 'pollEnrich'] as const;
14+
private static IN_VM_ENDPOINT_PREFIXES = ['direct:', 'seda:', 'vm:', 'direct-vm:'];
15+
16+
private static normalizeEndpoint = (uri: string | undefined): string | undefined => {
17+
if (!uri) {
18+
return undefined;
19+
}
20+
const stripped = uri.split('?')[0];
21+
return this.IN_VM_ENDPOINT_PREFIXES.some((p) => stripped.startsWith(p)) ? stripped : undefined;
22+
};
23+
24+
private static getUri = (value: unknown): string | undefined => {
25+
if (typeof value === 'string') {
26+
return value;
27+
}
28+
if (value && typeof value === 'object') {
29+
const obj = value as { uri?: unknown; parameters?: { name?: unknown } };
30+
const rawUri = obj.uri;
31+
if (typeof rawUri === 'string') {
32+
// Camel allows `uri: direct` + `parameters.name: foo` as an equivalent of `uri: direct:foo`.
33+
// Compose the canonical form here so endpoint matching works for both spellings.
34+
if (!rawUri.includes(':')) {
35+
const name = obj.parameters?.name;
36+
if (typeof name === 'string') {
37+
return `${rawUri}:${name}`;
38+
}
39+
}
40+
return rawUri;
41+
}
42+
}
43+
return undefined;
44+
};
1145

1246
static getFlowDiagram(
1347
scope: string,
1448
vizNode: IVisualizationNode,
15-
options: { removePlaceholder?: boolean } = {},
49+
options: { removePlaceholder?: boolean; isTopologyView?: boolean } = {},
1650
): CanvasNodesAndEdges {
1751
this.nodes = [];
1852
this.edges = [];
@@ -37,7 +71,7 @@ export class FlowService {
3771
/** Method for iterating over all the IVisualizationNode and its children using a depth-first algorithm */
3872
private static appendNodesAndEdges(
3973
vizNodeParam: IVisualizationNode,
40-
options: { removePlaceholder?: boolean } = {},
74+
options: { removePlaceholder?: boolean; isTopologyView?: boolean } = {},
4175
): void {
4276
const removePlaceholder = options.removePlaceholder ?? false;
4377
if (this.visitedNodes.includes(vizNodeParam.id) || (removePlaceholder && vizNodeParam.data.isPlaceholder)) {
@@ -77,6 +111,81 @@ export class FlowService {
77111
this.edges.push(...this.getEdgesFromVizNode(vizNodeParam, options));
78112
}
79113

114+
private static appendTopologyNodesAndEdges(
115+
vizNodeParam: IVisualizationNode,
116+
options: { isTopologyView?: boolean } = {},
117+
): void {
118+
let node: CanvasNode;
119+
120+
let children = vizNodeParam.getChildren() ?? [];
121+
children = children.filter((child) => !child.data.isPlaceholder);
122+
const hasRealChildren = children.length > 0;
123+
124+
if (vizNodeParam.data.isGroup && vizNodeParam.getParentNode() === undefined) {
125+
node = this.getTopologyNode(vizNodeParam);
126+
/** Add node */
127+
this.nodes.push(node);
128+
this.visitedNodes.push(node.id);
129+
}
130+
131+
if (vizNodeParam.data.isGroup && hasRealChildren) {
132+
children.forEach((child) => {
133+
this.appendTopologyNodesAndEdges(child, options);
134+
});
135+
} else {
136+
const processorName = vizNodeParam.data.processorName;
137+
const def = vizNodeParam.getNodeDefinition();
138+
if (processorName === 'from') {
139+
const endpoint = this.normalizeEndpoint(this.getUri(def));
140+
if (endpoint) this.consumersByEndpoint.set(endpoint, [vizNodeParam.getId() ?? vizNodeParam.id]);
141+
}
142+
if (this.PRODUCER_KEYS.includes(processorName as (typeof this.PRODUCER_KEYS)[number])) {
143+
const rawUri = this.getUri(def);
144+
const endpoint = this.normalizeEndpoint(rawUri);
145+
if (endpoint) {
146+
const producerId = vizNodeParam.getId() ?? vizNodeParam.id;
147+
const existingEndpoints = this.outgoingByEntity.get(producerId) ?? [];
148+
this.outgoingByEntity.set(producerId, [...existingEndpoints, endpoint]);
149+
}
150+
}
151+
}
152+
}
153+
154+
static getTopologyFlowDiagram(vizNodes: IVisualizationNode[]): CanvasNodesAndEdges {
155+
this.nodes = [];
156+
this.edges = [];
157+
this.visitedNodes = [];
158+
159+
vizNodes.forEach((vizNode) => {
160+
this.appendTopologyNodesAndEdges(vizNode, { isTopologyView: true });
161+
});
162+
163+
this.outgoingByEntity.forEach((endpoints, producerId) => {
164+
const producerTop = this.nodes.map((node) => node.id.split('|')[0]).includes(producerId);
165+
if (!producerTop) {
166+
return;
167+
}
168+
endpoints.forEach((endpoint) => {
169+
const consumerIds = this.consumersByEndpoint.get(endpoint);
170+
171+
// The endpoint is consumed somewhere in this file (possibly only by the producer itself).
172+
// Don't treat it as external; emit edges to any non-self consumer.
173+
if (consumerIds && consumerIds.length > 0) {
174+
consumerIds.forEach((consumerId) => {
175+
if (consumerId === producerId) {
176+
return;
177+
}
178+
if (this.nodes.map((node) => node.id.split('|')[0]).includes(consumerId)) {
179+
this.edges.push(this.getEdge(producerId, consumerId, true));
180+
}
181+
});
182+
}
183+
});
184+
});
185+
186+
return { nodes: this.nodes, edges: this.edges };
187+
}
188+
80189
private static getCanvasNode(vizNodeParam: IVisualizationNode): CanvasNode {
81190
/** Join the parent if exist to form a group */
82191
const parentNode =
@@ -158,10 +267,22 @@ export class FlowService {
158267
};
159268
}
160269

161-
private static getEdge(source: string, target: string): CanvasEdge {
270+
private static getTopologyNode(vizNodeParam: IVisualizationNode): CanvasNode {
271+
return {
272+
id: vizNodeParam.getId() ?? vizNodeParam.id,
273+
type: 'topology-node',
274+
label: vizNodeParam.getId(),
275+
data: { vizNode: vizNodeParam },
276+
width: CanvasDefaults.DEFAULT_NODE_WIDTH,
277+
height: CanvasDefaults.DEFAULT_NODE_HEIGHT,
278+
shape: CanvasDefaults.DEFAULT_NODE_SHAPE,
279+
};
280+
}
281+
282+
private static getEdge(source: string, target: string, isTopologyEdge?: boolean): CanvasEdge {
162283
return {
163284
id: `${source} >>> ${target}`,
164-
type: 'edge',
285+
type: isTopologyEdge ? 'topology-edge' : 'edge',
165286
source,
166287
target,
167288
edgeStyle: EdgeStyle.solid,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { EdgeTerminalType, GraphElement, isEdge, TaskEdge } from '@patternfly/react-topology';
2+
import { observer } from 'mobx-react';
3+
import { FunctionComponent } from 'react';
4+
5+
/** Spacing for TaskEdge integral bends — aligned with Dagre nodesep/ranksep in topology layout. */
6+
const TOPOLOGY_EDGE_NODE_SEPARATION = 20;
7+
8+
interface TopologyEdgeProps {
9+
element: GraphElement;
10+
}
11+
12+
const TopologyEdge: FunctionComponent<TopologyEdgeProps> = ({ element, ...props }) => {
13+
if (!isEdge(element)) {
14+
throw new Error('TopologyEdge must be used only on Edge elements');
15+
}
16+
17+
return (
18+
<TaskEdge
19+
element={element}
20+
endTerminalType={EdgeTerminalType.directional}
21+
nodeSeparation={TOPOLOGY_EDGE_NODE_SEPARATION}
22+
{...props}
23+
/>
24+
);
25+
};
26+
27+
export default observer(TopologyEdge);

packages/ui/src/components/Visualization/Custom/Node/CustomNode.tsx

Lines changed: 1 addition & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import './CustomNode.scss';
22

33
import { isDefined } from '@kaoto/forms';
4-
import { Icon } from '@patternfly/react-core';
5-
import { ExclamationCircleIcon } from '@patternfly/react-icons';
64
import {
75
AnchorEnd,
86
DEFAULT_LAYER,
@@ -28,7 +26,6 @@ import {
2826
withContextMenu,
2927
withSelection,
3028
} from '@patternfly/react-topology';
31-
import clsx from 'clsx';
3229
import { FunctionComponent, useContext, useMemo, useRef } from 'react';
3330

3431
import { CatalogModalContext } from '../../../../dynamic-catalog/catalog-modal.provider';
@@ -45,6 +42,7 @@ import { NodeContextMenuFn } from '../ContextMenu/NodeContextMenu';
4542
import { getDropTargetContainerClassNames, GROUP_DRAG_TYPE, NODE_DRAG_TYPE } from '../customComponentUtils';
4643
import { TargetAnchor } from '../target-anchor';
4744
import { CustomNodeContainer } from './CustomNodeContainer';
45+
import { CustomNodeLabel } from './CustomNodeLabels';
4846
import {
4947
checkNodeDropCompatibility,
5048
getNodeDragAndDropDirection,
@@ -60,50 +58,6 @@ interface CustomNodeProps extends DefaultNodeProps {
6058
onCollapseToggle?: () => void;
6159
}
6260

63-
interface CustomNodeLabelProps {
64-
label: string;
65-
doesHaveWarnings?: boolean;
66-
validationText?: string;
67-
x?: number;
68-
y?: number;
69-
transform?: string;
70-
width?: number;
71-
height?: number;
72-
className?: string;
73-
}
74-
75-
const CustomNodeLabel: FunctionComponent<CustomNodeLabelProps> = ({
76-
label,
77-
doesHaveWarnings = false,
78-
validationText,
79-
x,
80-
y,
81-
transform,
82-
width = CanvasDefaults.DEFAULT_LABEL_WIDTH,
83-
height = CanvasDefaults.DEFAULT_LABEL_HEIGHT,
84-
className = 'custom-node__label',
85-
}) => (
86-
<foreignObject
87-
width={width}
88-
height={height}
89-
className={className}
90-
{...(transform ? { transform } : { x: x!, y: y! })}
91-
>
92-
<div
93-
className={clsx('custom-node__label__text', {
94-
'custom-node__label__text__error': doesHaveWarnings,
95-
})}
96-
>
97-
{doesHaveWarnings && (
98-
<Icon status="danger" title={validationText} data-warning={doesHaveWarnings}>
99-
<ExclamationCircleIcon />
100-
</Icon>
101-
)}
102-
<span title={label}>{label}</span>
103-
</div>
104-
</foreignObject>
105-
);
106-
10761
function getShouldShowToolbar(
10862
trigger: NodeToolbarTrigger | undefined,
10963
isGHover: boolean,

0 commit comments

Comments
 (0)