From 0e6f7eb03fda027877618b930030ac43d6a58333 Mon Sep 17 00:00:00 2001 From: Jason Madigan Date: Mon, 30 Sep 2024 15:03:45 +0100 Subject: [PATCH] PF topology Signed-off-by: Jason Madigan --- package.json | 4 +- src/components/DropdownWithKebab.tsx | 21 +- src/components/PolicyTopologyPage.tsx | 397 +- src/components/ResourceList.tsx | 123 +- src/components/kuadrant.css | 56 +- src/utils/getModelFromResource.ts | 37 +- src/utils/modelUtils.ts | 50 +- yarn.lock | 5680 +++---------------------- 8 files changed, 1070 insertions(+), 5298 deletions(-) diff --git a/package.json b/package.json index 9b169de..5a609e2 100644 --- a/package.json +++ b/package.json @@ -92,10 +92,10 @@ "@babel/preset-react": "^7.24.7", "@patternfly/react-code-editor": "^5.4.0", "@patternfly/react-table": "^5.3.3", + "@patternfly/react-topology": "^5.4.0", "babel-loader": "^8.2.0", "graphlib": "^2.1.8", - "graphlib-dot": "^0.6.4", - "react-policy-topology": "^0.1.10" + "graphlib-dot": "^0.6.4" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/components/DropdownWithKebab.tsx b/src/components/DropdownWithKebab.tsx index e699fa8..0f3b03c 100644 --- a/src/components/DropdownWithKebab.tsx +++ b/src/components/DropdownWithKebab.tsx @@ -1,6 +1,14 @@ import * as React from 'react'; import { EllipsisVIcon } from '@patternfly/react-icons'; -import { Dropdown, DropdownItem, DropdownList, MenuToggle, MenuToggleElement, Button, ButtonVariant } from '@patternfly/react-core'; +import { + Dropdown, + DropdownItem, + DropdownList, + MenuToggle, + MenuToggleElement, + Button, + ButtonVariant, +} from '@patternfly/react-core'; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@patternfly/react-core/next'; import { k8sDelete, K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; import getModelFromResource from '../utils/getModelFromResource'; // Assume you have a utility for getting the model from the resource @@ -33,7 +41,10 @@ const DropdownWithKebab: React.FC = ({ obj }) => { setIsDeleteModalOpen(true); }; - const onSelect = (_event: React.MouseEvent | undefined, value: string | number | undefined) => { + const onSelect = ( + _event: React.MouseEvent | undefined, + value: string | number | undefined, + ) => { setIsOpen(false); if (value === 'delete') { onDeleteClick(); @@ -84,7 +95,11 @@ const DropdownWithKebab: React.FC = ({ obj }) => { - diff --git a/src/components/PolicyTopologyPage.tsx b/src/components/PolicyTopologyPage.tsx index d5f548c..ad31b09 100644 --- a/src/components/PolicyTopologyPage.tsx +++ b/src/components/PolicyTopologyPage.tsx @@ -1,17 +1,303 @@ import * as React from 'react'; import Helmet from 'react-helmet'; -import { Page, PageSection, Title, Card, CardTitle, CardBody } from '@patternfly/react-core'; +import { + Page, + PageSection, + Title, + Card, + CardTitle, + CardBody, + TextContent, + Text, +} from '@patternfly/react-core'; import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; -import PolicyTopology from 'react-policy-topology'; +import { + DagreLayout, + DefaultEdge, + DefaultNode, + ModelKind, + GraphComponent, + NodeShape, + TopologyControlBar, + TopologyView, + Visualization, + VisualizationProvider, + VisualizationSurface, + withPanZoom, + withSelection, + createTopologyControlButtons, + defaultControlButtonsOptions, + LabelPosition, + EdgeStyle, + EdgeAnimationSpeed, + withContextMenu, + ContextMenuItem, + action, + DefaultGroup, +} from '@patternfly/react-topology'; + +import { CubesIcon, CloudUploadAltIcon, TopologyIcon, RouteIcon } from '@patternfly/react-icons'; import * as dot from 'graphlib-dot'; +import { kindToAbbr } from '../utils/modelUtils'; import './kuadrant.css'; +// TODO: need a generic way to fetch latest versions of resources based on kind + group +const resourceGVKMapping: { [key: string]: { group: string; version: string; kind: string } } = { + Gateway: { group: 'gateway.networking.k8s.io', version: 'v1', kind: 'Gateway' }, + HTTPRoute: { group: 'gateway.networking.k8s.io', version: 'v1', kind: 'HTTPRoute' }, + TLSPolicy: { group: 'kuadrant.io', version: 'v1alpha1', kind: 'TLSPolicy' }, + DNSPolicy: { group: 'kuadrant.io', version: 'v1alpha1', kind: 'DNSPolicy' }, + AuthPolicy: { group: 'kuadrant.io', version: 'v1beta2', kind: 'AuthPolicy' }, + RateLimitPolicy: { group: 'kuadrant.io', version: 'v1beta2', kind: 'RateLimitPolicy' }, + ConfigMap: { group: '', version: 'v1', kind: 'ConfigMap' }, + Listener: { group: 'gateway.networking.k8s.io', version: 'v1', kind: 'Listener' }, + GatewayClass: { group: 'gateway.networking.k8s.io', version: 'v1', kind: 'GatewayClass' }, +}; + +// Convert DOT graph to PatternFly node/edge models +const parseDotToModel = (dotString: string): { nodes: any[]; edges: any[] } => { + try { + const graph = dot.read(dotString); + const nodes: any[] = []; + const edges: any[] = []; + const groups: any[] = []; + + const shapeMapping: { [key: string]: NodeShape } = { + Gateway: NodeShape.rect, + HTTPRoute: NodeShape.rect, + TLSPolicy: NodeShape.rect, + DNSPolicy: NodeShape.rect, + AuthPolicy: NodeShape.rect, + RateLimitPolicy: NodeShape.rect, + ConfigMap: NodeShape.ellipse, + Listener: NodeShape.rect, + GatewayClass: NodeShape.rect, + Kuadrant: NodeShape.ellipse, + }; + + const connectedNodeIds = new Set(); + + graph.edges().forEach((edge) => { + const sourceNode = graph.node(edge.v); + const targetNode = graph.node(edge.w); + + if (!sourceNode || !targetNode) return; + + connectedNodeIds.add(edge.v); + connectedNodeIds.add(edge.w); + + const isPolicy = ['TLSPolicy', 'DNSPolicy', 'AuthPolicy', 'RateLimitPolicy'].includes( + sourceNode.type, + ); + + edges.push({ + id: `edge-${edge.v}-${edge.w}`, + type: 'edge', + source: edge.v, + target: edge.w, + edgeStyle: isPolicy ? EdgeStyle.dashedMd : EdgeStyle.default, + animationSpeed: isPolicy ? EdgeAnimationSpeed.medium : undefined, + style: { + strokeWidth: 2, + stroke: '#393F44', + }, + }); + }); + + graph.nodes().forEach((nodeId: string) => { + const nodeData = graph.node(nodeId); + const [resourceType, resourceName] = nodeData.label.split('\\n'); + + nodes.push({ + id: nodeId, + type: 'node', + label: resourceName, + resourceType, + width: 120, + height: 65, + labelPosition: LabelPosition.bottom, + shape: shapeMapping[resourceType] || NodeShape.rect, + data: { + label: resourceName, + type: resourceType, + badge: kindToAbbr(resourceType), + badgeColor: '#2b9af3', + }, + }); + }); + + const unconnectedNodes = nodes.filter((node) => !connectedNodeIds.has(node.id)); + if (unconnectedNodes.length > 0) { + groups.push({ + id: 'group-unconnected', + children: unconnectedNodes.map((node) => node.id), + type: 'group', + group: true, + label: 'Unassociated Policies and Resources', + style: { + padding: 40, + }, + }); + } + + const finalNodes = [...nodes, ...groups]; + return { nodes: finalNodes, edges }; + } catch (error) { + console.error('Error parsing DOT string:', error); + throw error; + } +}; + +const CustomNode: React.FC = ({ + element, + onSelect, + selected, + onContextMenu, + contextMenuOpen, +}) => { + const data = element.getData(); + const { type, badge, badgeColor } = data; + + const isPolicyNode = ['TLSPolicy', 'DNSPolicy', 'AuthPolicy', 'RateLimitPolicy'].includes(type); + + let IconComponent; + switch (type) { + case 'Gateway': + IconComponent = CubesIcon; + break; + case 'HTTPRoute': + IconComponent = RouteIcon; + break; + case 'TLSPolicy': + case 'DNSPolicy': + IconComponent = CloudUploadAltIcon; + break; + case 'ConfigMap': + case 'Listener': + IconComponent = TopologyIcon; + break; + case 'GatewayClass': + IconComponent = CubesIcon; + break; + default: + IconComponent = TopologyIcon; + break; + } + + const iconSize = 35; + const paddingTop = 5; + const paddingBottom = 15; + const nodeHeight = element.getBounds().height; + const nodeWidth = element.getBounds().width; + + return ( + + + + + + + + + {type} + + + + ); +}; + +const goToResource = (resourceType: string, resourceName: string) => { + let finalResourceType = resourceType; + let finalGVK = resourceGVKMapping[resourceType]; + + // special case - Listener should go to associated Gateway + if (resourceType === 'Listener') { + finalResourceType = 'Gateway'; + finalGVK = resourceGVKMapping[finalResourceType]; + } + + const [namespace, name] = resourceName.includes('/') + ? resourceName.split('/') + : [null, resourceName]; + + if (!finalGVK) { + console.error(`GVK mapping not found for resource type: ${finalResourceType}`); + return; + } + + const url = namespace + ? `/k8s/ns/${namespace}/${finalGVK.group}~${finalGVK.version}~${finalGVK.kind}/${name}` + : `/k8s/cluster/${finalGVK.group}~${finalGVK.version}~${finalGVK.kind}/${name}`; + + window.location.href = url; +}; + +const customLayoutFactory = (type: string, graph: any): any => { + return new DagreLayout(graph, { + rankdir: 'TB', + nodesep: 60, + ranksep: 0, + nodeDistance: 80, + }); +}; + +const customComponentFactory = (kind: ModelKind, type: string) => { + const contextMenuItem = (resourceType: string, resourceName: string) => ( + goToResource(resourceType, resourceName)}> + Go to Resource + + ); + + const contextMenu = (element: any) => { + const resourceType = element.getData().type; + const resourceName = element.getLabel(); + return [contextMenuItem(resourceType, resourceName)]; + }; + + switch (type) { + case 'group': + return DefaultGroup; + default: + switch (kind) { + case ModelKind.graph: + return withPanZoom()(GraphComponent); + case ModelKind.node: + return withContextMenu(contextMenu)(CustomNode); + case ModelKind.edge: + return withSelection()(DefaultEdge); + default: + return undefined; + } + } +}; + const PolicyTopologyPage: React.FC = () => { const [parseError, setParseError] = React.useState(null); - const [safeDotString, setSafeDotString] = React.useState(''); // Watch the ConfigMap named "topology" in the "kuadrant-system" namespace - // TODO: watch for these in any NS with `Kuadrant` resources + // TODO: lookup instance of `Kuadrant` and read topology from same NS. const [configMap, loaded, loadError] = useK8sWatchResource({ groupVersionKind: { version: 'v1', @@ -21,31 +307,70 @@ const PolicyTopologyPage: React.FC = () => { namespace: 'kuadrant-system', }); + const controllerRef = React.useRef(null); + + React.useEffect(() => { + if (!controllerRef.current) { + const initialModel = { + nodes: [], + edges: [], + graph: { + id: 'g1', + type: 'graph', + layout: 'Dagre', + }, + }; + + const visualization = new Visualization(); + visualization.registerLayoutFactory(customLayoutFactory); + visualization.registerComponentFactory(customComponentFactory); + visualization.setRenderConstraint(false); + visualization.fromModel(initialModel, false); + controllerRef.current = visualization; + } + + // Cleanup on unmount + return () => { + controllerRef.current = null; + }; + }, []); + + // Handle data updates React.useEffect(() => { if (loaded && !loadError) { const dotString = configMap.data?.topology || ''; - if (dotString) { try { - // parse the DOT string for sanity - const parsedGraph = dot.read(dotString); - console.log(parsedGraph); + const { nodes, edges } = parseDotToModel(dotString); setParseError(null); - setSafeDotString(dotString); - } catch (e) { - // Catch and handle parsing errors - setParseError((e as Error).message); - setSafeDotString(''); + + if (controllerRef.current) { + const newModel = { + nodes, + edges, + graph: { + id: 'g1', + type: 'graph', + layout: 'Dagre', + }, + }; + + controllerRef.current.fromModel(newModel, false); + controllerRef.current.getGraph().layout(); + controllerRef.current.getGraph().fit(80); + } + } catch (error) { + setParseError('Failed to parse topology data.'); } - } else { - setSafeDotString(''); } } else if (loadError) { - console.error('Error loading config map:', loadError); - setSafeDotString(''); + setParseError('Failed to load topology data.'); } }, [configMap, loaded, loadError]); + // Memoize the controller + const controller = controllerRef.current; + return ( <> @@ -59,16 +384,48 @@ const PolicyTopologyPage: React.FC = () => { Topology View + + + This view visualizes the relationships and interactions between different + resources within your cluster related to Kuadrant, allowing you to explore + connections between Gateways, HTTPRoutes and Kuadrant Policies. + + {!loaded ? (
Loading...
) : loadError ? (
Error loading topology: {loadError.message}
) : parseError ? ( - // parsing error
Error parsing topology: {parseError}
) : ( - // render PolicyTopology if there are no errors in parsing - + controller && ( + { + controller.getGraph().scaleBy(4 / 3); + }), + zoomOutCallback: action(() => { + controller.getGraph().scaleBy(0.75); + }), + fitToScreenCallback: action(() => { + controller.getGraph().fit(80); + }), + legend: false, + })} + /> + } + > + + + + + ) )}
diff --git a/src/components/ResourceList.tsx b/src/components/ResourceList.tsx index d10ed0c..fccde83 100644 --- a/src/components/ResourceList.tsx +++ b/src/components/ResourceList.tsx @@ -28,11 +28,19 @@ import DropdownWithKebab from './DropdownWithKebab'; const getStatusLabel = (obj: any) => { // Gateway or HTTPRoute if (obj.kind === 'Gateway' || obj.kind === 'HTTPRoute') { - const acceptedCondition = obj.status?.conditions?.find((cond: any) => cond.type === 'Accepted' && cond.status === 'True'); - const programmedCondition = obj.status?.conditions?.find((cond: any) => cond.type === 'Programmed' && cond.status === 'True'); - const conflictedCondition = obj.status?.conditions?.find((cond: any) => cond.type === 'Conflicted' && cond.status === 'True'); - const resolvedRefsCondition = obj.status?.conditions?.find((cond: any) => cond.type === 'ResolvedRefs' && cond.status === 'True'); - + const acceptedCondition = obj.status?.conditions?.find( + (cond: any) => cond.type === 'Accepted' && cond.status === 'True', + ); + const programmedCondition = obj.status?.conditions?.find( + (cond: any) => cond.type === 'Programmed' && cond.status === 'True', + ); + const conflictedCondition = obj.status?.conditions?.find( + (cond: any) => cond.type === 'Conflicted' && cond.status === 'True', + ); + const resolvedRefsCondition = obj.status?.conditions?.find( + (cond: any) => cond.type === 'ResolvedRefs' && cond.status === 'True', + ); + let labelText: string; let color: 'green' | 'blue' | 'red' | 'orange'; let icon: React.ReactNode; @@ -59,24 +67,42 @@ const getStatusLabel = (obj: any) => { icon = ; } - return ; + return ( + + ); } if (!obj.status || !obj.status.conditions || obj.status.conditions.length === 0) { // No status/conditions return ( -