diff --git a/awx/ui/src/App.test.js b/awx/ui/src/App.test.js index de080062fd..e1f2fb3bc3 100644 --- a/awx/ui/src/App.test.js +++ b/awx/ui/src/App.test.js @@ -7,6 +7,7 @@ import { mountWithContexts } from '../testUtils/enzymeHelpers'; import App, { ProtectedRoute } from './App'; jest.mock('./api'); +jest.mock('util/webWorker', () => jest.fn()); describe('', () => { beforeEach(() => { diff --git a/awx/ui/src/api/index.js b/awx/ui/src/api/index.js index a098f28781..5281ad861d 100644 --- a/awx/ui/src/api/index.js +++ b/awx/ui/src/api/index.js @@ -21,6 +21,7 @@ import Jobs from './models/Jobs'; import JobEvents from './models/JobEvents'; import Labels from './models/Labels'; import Me from './models/Me'; +import Mesh from './models/Mesh'; import Metrics from './models/Metrics'; import NotificationTemplates from './models/NotificationTemplates'; import Notifications from './models/Notifications'; @@ -67,6 +68,7 @@ const JobsAPI = new Jobs(); const JobEventsAPI = new JobEvents(); const LabelsAPI = new Labels(); const MeAPI = new Me(); +const MeshAPI = new Mesh(); const MetricsAPI = new Metrics(); const NotificationTemplatesAPI = new NotificationTemplates(); const NotificationsAPI = new Notifications(); @@ -114,6 +116,7 @@ export { JobEventsAPI, LabelsAPI, MeAPI, + MeshAPI, MetricsAPI, NotificationTemplatesAPI, NotificationsAPI, diff --git a/awx/ui/src/api/models/Instances.js b/awx/ui/src/api/models/Instances.js index 78ea59d1dd..07ee085c14 100644 --- a/awx/ui/src/api/models/Instances.js +++ b/awx/ui/src/api/models/Instances.js @@ -7,6 +7,7 @@ class Instances extends Base { this.readHealthCheckDetail = this.readHealthCheckDetail.bind(this); this.healthCheck = this.healthCheck.bind(this); + this.readInstanceGroup = this.readInstanceGroup.bind(this); } healthCheck(instanceId) { @@ -16,6 +17,10 @@ class Instances extends Base { readHealthCheckDetail(instanceId) { return this.http.get(`${this.baseUrl}${instanceId}/health_check/`); } + + readInstanceGroup(instanceId) { + return this.http.get(`${this.baseUrl}${instanceId}/instance_groups/`); + } } export default Instances; diff --git a/awx/ui/src/api/models/Mesh.js b/awx/ui/src/api/models/Mesh.js new file mode 100644 index 0000000000..d7ad08067c --- /dev/null +++ b/awx/ui/src/api/models/Mesh.js @@ -0,0 +1,9 @@ +import Base from '../Base'; + +class Mesh extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/mesh_visualizer/'; + } +} +export default Mesh; diff --git a/awx/ui/src/index.test.js b/awx/ui/src/index.test.js index 49ae9e2317..a0419c9933 100644 --- a/awx/ui/src/index.test.js +++ b/awx/ui/src/index.test.js @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom'; import App from './App'; jest.mock('react-dom', () => ({ render: jest.fn() })); +jest.mock('util/webWorker', () => jest.fn()); describe('index.jsx', () => { it('renders ok', () => { diff --git a/awx/ui/src/routeConfig.js b/awx/ui/src/routeConfig.js index 339e52a228..76bb2e39a5 100644 --- a/awx/ui/src/routeConfig.js +++ b/awx/ui/src/routeConfig.js @@ -19,6 +19,7 @@ import Schedules from 'screens/Schedule'; import Settings from 'screens/Setting'; import Teams from 'screens/Team'; import Templates from 'screens/Template'; +import TopologyView from 'screens/TopologyView'; import Users from 'screens/User'; import WorkflowApprovals from 'screens/WorkflowApproval'; import { Jobs } from 'screens/Job'; @@ -147,6 +148,11 @@ function getRouteConfig(userProfile = {}) { path: '/execution_environments', screen: ExecutionEnvironments, }, + { + title: Topology View, + path: '/topology_view', + screen: TopologyView, + }, ], }, { @@ -179,6 +185,7 @@ function getRouteConfig(userProfile = {}) { deleteRoute('management_jobs'); if (userProfile?.isOrgAdmin) return routeConfig; deleteRoute('instance_groups'); + deleteRoute('topology_view'); if (!userProfile?.isNotificationAdmin) deleteRoute('notification_templates'); return routeConfig; diff --git a/awx/ui/src/routeConfig.test.js b/awx/ui/src/routeConfig.test.js index da0dc7e536..5a7def4348 100644 --- a/awx/ui/src/routeConfig.test.js +++ b/awx/ui/src/routeConfig.test.js @@ -1,4 +1,5 @@ import getRouteConfig from './routeConfig'; +jest.mock('util/webWorker', () => jest.fn()); const userProfile = { isSuperUser: false, @@ -43,6 +44,7 @@ describe('getRouteConfig', () => { '/instances', '/applications', '/execution_environments', + '/topology_view', '/settings', ]); }); @@ -71,6 +73,7 @@ describe('getRouteConfig', () => { '/instances', '/applications', '/execution_environments', + '/topology_view', '/settings', ]); }); @@ -98,6 +101,7 @@ describe('getRouteConfig', () => { '/instances', '/applications', '/execution_environments', + '/topology_view', ]); }); @@ -233,6 +237,7 @@ describe('getRouteConfig', () => { '/instances', '/applications', '/execution_environments', + '/topology_view', ]); }); @@ -263,6 +268,7 @@ describe('getRouteConfig', () => { '/instances', '/applications', '/execution_environments', + '/topology_view', ]); }); }); diff --git a/awx/ui/src/screens/TopologyView/ContentLoading.js b/awx/ui/src/screens/TopologyView/ContentLoading.js new file mode 100644 index 0000000000..656edfe505 --- /dev/null +++ b/awx/ui/src/screens/TopologyView/ContentLoading.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { t } from '@lingui/macro'; + +import styled from 'styled-components'; +import { + EmptyState as PFEmptyState, + Progress, + ProgressMeasureLocation, + Text, + TextContent, + TextVariants, +} from '@patternfly/react-core'; + +import { TopologyIcon as PFTopologyIcon } from '@patternfly/react-icons'; + +const EmptyState = styled(PFEmptyState)` + --pf-c-empty-state--m-lg--MaxWidth: none; + min-height: 250px; +`; + +const TopologyIcon = styled(PFTopologyIcon)` + font-size: 3em; + fill: #6a6e73; +`; + +const ContentLoading = ({ className, progress }) => ( + + + + + + {t`Please wait until the topology view is populated...`} + + + +); + +export default ContentLoading; diff --git a/awx/ui/src/screens/TopologyView/Header.js b/awx/ui/src/screens/TopologyView/Header.js new file mode 100644 index 0000000000..1b287023e5 --- /dev/null +++ b/awx/ui/src/screens/TopologyView/Header.js @@ -0,0 +1,117 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { t } from '@lingui/macro'; +import { + Button, + PageSection, + PageSectionVariants, + Switch, + Title, + Tooltip, +} from '@patternfly/react-core'; + +import { + SearchMinusIcon, + SearchPlusIcon, + ExpandArrowsAltIcon, + ExpandIcon, +} from '@patternfly/react-icons'; + +const Header = ({ + title, + handleSwitchToggle, + toggleState, + zoomIn, + zoomOut, + resetZoom, + zoomFit, + showZoomControls, +}) => { + const { light } = PageSectionVariants; + return ( + +
+
+ + {title} + +
+
+ + + + + + + + + + + + + + handleSwitchToggle(!toggleState)} + /> + +
+
+
+ ); +}; + +Header.propTypes = { + title: PropTypes.string.isRequired, +}; + +export default Header; diff --git a/awx/ui/src/screens/TopologyView/Legend.js b/awx/ui/src/screens/TopologyView/Legend.js new file mode 100644 index 0000000000..5fe35beb51 --- /dev/null +++ b/awx/ui/src/screens/TopologyView/Legend.js @@ -0,0 +1,140 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import { + Button as PFButton, + DescriptionList as PFDescriptionList, + DescriptionListTerm, + DescriptionListGroup as PFDescriptionListGroup, + DescriptionListDescription as PFDescriptionListDescription, + Divider, + TextContent, + Text as PFText, + TextVariants, +} from '@patternfly/react-core'; + +import { + ExclamationIcon as PFExclamationIcon, + CheckIcon as PFCheckIcon, +} from '@patternfly/react-icons'; + +const Wrapper = styled.div` + position: absolute; + top: -20px; + left: 0; + padding: 10px; + width: 150px; + background-color: rgba(255, 255, 255, 0.85); +`; +const Button = styled(PFButton)` + width: 20px; + height: 20px; + border-radius: 10px; + padding: 0; + font-size: 11px; +`; +const DescriptionListDescription = styled(PFDescriptionListDescription)` + font-size: 11px; +`; +const ExclamationIcon = styled(PFExclamationIcon)` + fill: white; + margin-left: 2px; +`; +const CheckIcon = styled(PFCheckIcon)` + fill: white; + margin-left: 2px; +`; +const DescriptionList = styled(PFDescriptionList)` + gap: 7px; +`; +const DescriptionListGroup = styled(PFDescriptionListGroup)` + align-items: center; +`; +const Text = styled(PFText)` + margin: 10px 0 5px; +`; + +function Legend() { + return ( + + + + {t`Legend`} + + + {t`Node types`} + + + + + + + {t`Control node`} + + + + + + + {t`Execution node`} + + + + + + + {t`Hybrid node`} + + + + + + {t`Hop node`} + + + + {t`Status types`} + + + + + + + + + {nodeDetail.hostname} + + + + + {t`Type`} + + {nodeDetail.node_type} {t`node`} + + + + {t`Status`} + + + + + + + )} + + ); +} + +export default Tooltip; diff --git a/awx/ui/src/screens/TopologyView/TopologyView.js b/awx/ui/src/screens/TopologyView/TopologyView.js new file mode 100644 index 0000000000..6ef10ca9da --- /dev/null +++ b/awx/ui/src/screens/TopologyView/TopologyView.js @@ -0,0 +1,77 @@ +import React, { useEffect, useCallback, useState } from 'react'; +import { t } from '@lingui/macro'; +import { PageSection, Card, CardBody } from '@patternfly/react-core'; +import ContentError from 'components/ContentError'; +import useRequest from 'hooks/useRequest'; +import { MeshAPI } from 'api'; +import Header from './Header'; +import MeshGraph from './MeshGraph'; +import useZoom from './utils/useZoom'; +import { CHILDSELECTOR, PARENTSELECTOR } from './constants'; + +function TopologyView() { + const [showLegend, setShowLegend] = useState(true); + const [showZoomControls, setShowZoomControls] = useState(false); + const { + isLoading, + result: { meshData }, + error: fetchInitialError, + request: fetchMeshVisualizer, + } = useRequest( + useCallback(async () => { + const { data } = await MeshAPI.read(); + return { + meshData: data, + }; + }, []), + { meshData: { nodes: [], links: [] } } + ); + useEffect(() => { + fetchMeshVisualizer(); + }, [fetchMeshVisualizer]); + const { zoom, zoomFit, zoomIn, zoomOut, resetZoom } = useZoom( + PARENTSELECTOR, + CHILDSELECTOR + ); + + return ( + <> +
+ {fetchInitialError ? ( + + + + + + + + ) : ( + + + + {!isLoading && ( + + )} + + + + )} + + ); +} + +export default TopologyView; diff --git a/awx/ui/src/screens/TopologyView/constants.js b/awx/ui/src/screens/TopologyView/constants.js new file mode 100644 index 0000000000..d217078f6c --- /dev/null +++ b/awx/ui/src/screens/TopologyView/constants.js @@ -0,0 +1,34 @@ +export const SELECTOR = '#chart'; +export const PARENTSELECTOR = '.mesh-svg'; +export const CHILDSELECTOR = '.mesh'; +export const DEFAULT_RADIUS = 16; +export const MESH_FORCE_LAYOUT = { + defaultCollisionFactor: DEFAULT_RADIUS * 2 + 30, + defaultForceStrength: -50, + defaultForceBody: 15, + defaultForceX: 0, + defaultForceY: 0, +}; +export const DEFAULT_NODE_COLOR = '#0066CC'; +export const DEFAULT_NODE_HIGHLIGHT_COLOR = '#16407C'; +export const DEFAULT_NODE_LABEL_TEXT_COLOR = 'white'; +export const DEFAULT_FONT_SIZE = '12px'; +export const LABEL_TEXT_MAX_LENGTH = 15; +export const MARGIN = 15; +export const NODE_STATE_COLOR_KEY = { + disabled: '#6A6E73', + healthy: '#3E8635', + error: '#C9190B', +}; +export const NODE_STATE_HTML_ENTITY_KEY = { + disabled: '\u25EF', + healthy: '\u2713', + error: '\u0021', +}; + +export const NODE_TYPE_SYMBOL_KEY = { + hop: 'h', + execution: 'Ex', + hybrid: 'Hy', + control: 'C', +}; diff --git a/awx/ui/src/screens/TopologyView/index.js b/awx/ui/src/screens/TopologyView/index.js new file mode 100644 index 0000000000..b0983be986 --- /dev/null +++ b/awx/ui/src/screens/TopologyView/index.js @@ -0,0 +1 @@ +export { default } from './TopologyView'; diff --git a/awx/ui/src/screens/TopologyView/utils/helpers.js b/awx/ui/src/screens/TopologyView/utils/helpers.js new file mode 100644 index 0000000000..f8dee9866c --- /dev/null +++ b/awx/ui/src/screens/TopologyView/utils/helpers.js @@ -0,0 +1,89 @@ +import * as d3 from 'd3'; +import { truncateString } from '../../../util/strings'; + +import { + NODE_STATE_COLOR_KEY, + NODE_STATE_HTML_ENTITY_KEY, + NODE_TYPE_SYMBOL_KEY, + LABEL_TEXT_MAX_LENGTH, +} from '../constants'; + +export function renderStateColor(nodeState) { + return NODE_STATE_COLOR_KEY[nodeState]; +} + +export function renderLabelText(nodeState, name) { + return `${NODE_STATE_HTML_ENTITY_KEY[nodeState]} ${truncateString( + name, + LABEL_TEXT_MAX_LENGTH + )}`; +} + +export function renderNodeType(nodeType) { + return NODE_TYPE_SYMBOL_KEY[nodeType]; +} + +export function renderNodeIcon(selectedNode) { + if (selectedNode) { + const { node_type: nodeType } = selectedNode; + return NODE_TYPE_SYMBOL_KEY[nodeType]; + } + + return false; +} + +export function redirectToDetailsPage(selectedNode, history) { + const { id: nodeId } = selectedNode; + const constructedURL = `/instances/${nodeId}/details`; + history.push(constructedURL); +} + +// DEBUG TOOLS +export function getRandomInt(min, max) { + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +const generateRandomLinks = (n, r) => { + const links = []; + for (let i = 0; i < r; i++) { + const link = { + source: n[getRandomInt(0, n.length - 1)].hostname, + target: n[getRandomInt(0, n.length - 1)].hostname, + }; + links.push(link); + } + return { nodes: n, links }; +}; + +export const generateRandomNodes = (n) => { + const nodes = []; + function getRandomType() { + return ['hybrid', 'execution', 'control', 'hop'][getRandomInt(0, 3)]; + } + function getRandomState() { + return ['healthy', 'error', 'disabled'][getRandomInt(0, 2)]; + } + for (let i = 0; i < n; i++) { + const id = i + 1; + const randomType = getRandomType(); + const randomState = getRandomState(); + const node = { + id, + hostname: `node-${id}`, + node_type: randomType, + node_state: randomState, + }; + nodes.push(node); + } + return generateRandomLinks(nodes, getRandomInt(1, n - 1)); +}; + +export function getWidth(selector) { + return selector ? d3.select(selector).node().clientWidth : 700; +} + +export function getHeight(selector) { + return selector !== null ? d3.select(selector).node().clientHeight : 600; +} diff --git a/awx/ui/src/screens/TopologyView/utils/useZoom.js b/awx/ui/src/screens/TopologyView/utils/useZoom.js new file mode 100644 index 0000000000..d163dd5520 --- /dev/null +++ b/awx/ui/src/screens/TopologyView/utils/useZoom.js @@ -0,0 +1,74 @@ +import * as d3 from 'd3'; +import { getWidth, getHeight } from './helpers'; + +/** + * useZoom provides a collection of zoom behaviors/functions for D3 graphs + * Params: string value of parent and child classnames + * The following hierarchy should be followed: + *
+ * <-- parent --> + * <-- child --> + * + *
+ * Returns: { + * zoom: d3 zoom behavior/object/function to apply on selected elements + * zoomIn: function that zooms in + * zoomOut: function that zooms out + * zoomFit: function that scales child element to fit within parent element + * resetZoom: function resets the zoom level to its initial value + * } + */ + +export default function useZoom(parentSelector, childSelector) { + const zoom = d3.zoom().on('zoom', ({ transform }) => { + d3.select(childSelector).attr('transform', transform); + }); + const zoomIn = () => { + d3.select(parentSelector).transition().call(zoom.scaleBy, 2); + }; + const zoomOut = () => { + d3.select(parentSelector).transition().call(zoom.scaleBy, 0.5); + }; + const resetZoom = () => { + const parent = d3.select(parentSelector).node(); + const width = parent.clientWidth; + const height = parent.clientHeight; + d3.select(parentSelector) + .transition() + .duration(750) + .call( + zoom.transform, + d3.zoomIdentity, + d3 + .zoomTransform(d3.select(parentSelector).node()) + .invert([width / 2, height / 2]) + ); + }; + const zoomFit = () => { + const bounds = d3.select(childSelector).node().getBBox(); + const fullWidth = getWidth(parentSelector); + const fullHeight = getHeight(parentSelector); + const { width, height } = bounds; + const midX = bounds.x + width / 2; + const midY = bounds.y + height / 2; + if (width === 0 || height === 0) return; // nothing to fit + const scale = 0.8 / Math.max(width / fullWidth, height / fullHeight); + const translate = [ + fullWidth / 2 - scale * midX, + fullHeight / 2 - scale * midY, + ]; + const [x, y] = translate; + d3.select(parentSelector) + .transition() + .duration(750) + .call(zoom.transform, d3.zoomIdentity.translate(x, y).scale(scale)); + }; + + return { + zoom, + zoomIn, + zoomOut, + zoomFit, + resetZoom, + }; +} diff --git a/awx/ui/src/util/simulationWorker.js b/awx/ui/src/util/simulationWorker.js new file mode 100644 index 0000000000..d743e2e76c --- /dev/null +++ b/awx/ui/src/util/simulationWorker.js @@ -0,0 +1,34 @@ +/* eslint-disable no-undef */ +importScripts('https://d3js.org/d3-collection.v1.min.js'); +importScripts('https://d3js.org/d3-dispatch.v1.min.js'); +importScripts('https://d3js.org/d3-quadtree.v1.min.js'); +importScripts('https://d3js.org/d3-timer.v1.min.js'); +importScripts('https://d3js.org/d3-force.v1.min.js'); + +onmessage = function calculateLayout({ data: { nodes, links } }) { + const simulation = d3 + .forceSimulation(nodes) + .force('charge', d3.forceManyBody(15).strength(-50)) + .force( + 'link', + d3.forceLink(links).id((d) => d.hostname) + ) + .force('collide', d3.forceCollide(62)) + .force('forceX', d3.forceX(0)) + .force('forceY', d3.forceY(0)) + .stop(); + + for ( + let i = 0, + n = Math.ceil( + Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay()) + ); + i < n; + ++i + ) { + postMessage({ type: 'tick', progress: i / n }); + simulation.tick(); + } + + postMessage({ type: 'end', nodes, links }); +}; diff --git a/awx/ui/src/util/strings.js b/awx/ui/src/util/strings.js index 2eee9bbe96..9fd250e450 100644 --- a/awx/ui/src/util/strings.js +++ b/awx/ui/src/util/strings.js @@ -17,3 +17,10 @@ export const stringIsUUID = (value) => /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi.test( value ); + +export const truncateString = (str, num) => { + if (str.length <= num) { + return str; + } + return `${str.slice(0, num)}...`; +}; diff --git a/awx/ui/src/util/webWorker.js b/awx/ui/src/util/webWorker.js new file mode 100644 index 0000000000..7babb68f38 --- /dev/null +++ b/awx/ui/src/util/webWorker.js @@ -0,0 +1,3 @@ +export default function webWorker() { + return new Worker(new URL('./simulationWorker.js', import.meta.url)); +}