mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 10:00:01 -03:30
commit
d02cef9d92
@ -7,6 +7,7 @@ import { mountWithContexts } from '../testUtils/enzymeHelpers';
|
||||
import App, { ProtectedRoute } from './App';
|
||||
|
||||
jest.mock('./api');
|
||||
jest.mock('util/webWorker', () => jest.fn());
|
||||
|
||||
describe('<App />', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
9
awx/ui/src/api/models/Mesh.js
Normal file
9
awx/ui/src/api/models/Mesh.js
Normal file
@ -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;
|
||||
@ -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', () => {
|
||||
|
||||
@ -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: <Trans>Topology View</Trans>,
|
||||
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;
|
||||
|
||||
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
46
awx/ui/src/screens/TopologyView/ContentLoading.js
Normal file
46
awx/ui/src/screens/TopologyView/ContentLoading.js
Normal file
@ -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 }) => (
|
||||
<EmptyState variant="full" className={className} data-cy={className}>
|
||||
<TopologyIcon />
|
||||
<Progress
|
||||
value={progress}
|
||||
measureLocation={ProgressMeasureLocation.inside}
|
||||
aria-label={t`content-loading-in-progress`}
|
||||
style={{ margin: '20px' }}
|
||||
/>
|
||||
<TextContent style={{ margin: '20px' }}>
|
||||
<Text
|
||||
component={TextVariants.small}
|
||||
style={{ fontWeight: 'bold', color: 'black' }}
|
||||
>
|
||||
{t`Please wait until the topology view is populated...`}
|
||||
</Text>
|
||||
</TextContent>
|
||||
</EmptyState>
|
||||
);
|
||||
|
||||
export default ContentLoading;
|
||||
117
awx/ui/src/screens/TopologyView/Header.js
Normal file
117
awx/ui/src/screens/TopologyView/Header.js
Normal file
@ -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 (
|
||||
<PageSection variant={light}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
minHeight: '31px',
|
||||
}}
|
||||
>
|
||||
<Title size="2xl" headingLevel="h2" data-cy="screen-title">
|
||||
{title}
|
||||
</Title>
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip content={t`Zoom in`} position="top">
|
||||
<Button
|
||||
ouiaId="zoom-in-button"
|
||||
aria-label={t`Zoom in`}
|
||||
variant="plain"
|
||||
icon={<SearchPlusIcon />}
|
||||
onClick={zoomIn}
|
||||
isDisabled={!showZoomControls}
|
||||
>
|
||||
<SearchPlusIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t`Zoom out`} position="top">
|
||||
<Button
|
||||
ouiaId="zoom-out-button"
|
||||
aria-label={t`Zoom out`}
|
||||
variant="plain"
|
||||
icon={<SearchMinusIcon />}
|
||||
onClick={zoomOut}
|
||||
isDisabled={!showZoomControls}
|
||||
>
|
||||
<SearchMinusIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t`Fit to screen`} position="top">
|
||||
<Button
|
||||
ouiaId="fit-to-screen-button"
|
||||
aria-label={t`Fit to screen`}
|
||||
variant="plain"
|
||||
icon={<ExpandArrowsAltIcon />}
|
||||
onClick={zoomFit}
|
||||
isDisabled={!showZoomControls}
|
||||
>
|
||||
<ExpandArrowsAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t`Reset zoom`} position="top">
|
||||
<Button
|
||||
ouiaId="reset-zoom-button"
|
||||
aria-label={t`Reset zoom`}
|
||||
variant="plain"
|
||||
icon={<ExpandIcon />}
|
||||
onClick={resetZoom}
|
||||
isDisabled={!showZoomControls}
|
||||
>
|
||||
<ExpandIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t`Toggle legend`} position="top">
|
||||
<Switch
|
||||
id="legend-toggle-switch"
|
||||
label={t`Legend`}
|
||||
isChecked={toggleState}
|
||||
onChange={() => handleSwitchToggle(!toggleState)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
);
|
||||
};
|
||||
|
||||
Header.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default Header;
|
||||
140
awx/ui/src/screens/TopologyView/Legend.js
Normal file
140
awx/ui/src/screens/TopologyView/Legend.js
Normal file
@ -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 (
|
||||
<Wrapper className="legend" data-cy="legend">
|
||||
<TextContent>
|
||||
<Text
|
||||
component={TextVariants.small}
|
||||
style={{ fontWeight: 'bold', color: 'black' }}
|
||||
>
|
||||
{t`Legend`}
|
||||
</Text>
|
||||
<Divider component="div" />
|
||||
<Text component={TextVariants.small}>{t`Node types`}</Text>
|
||||
</TextContent>
|
||||
<DescriptionList isHorizontal isFluid>
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>
|
||||
<Button variant="primary" isSmall>
|
||||
{t`C`}
|
||||
</Button>
|
||||
</DescriptionListTerm>
|
||||
<DescriptionListDescription>{t`Control node`}</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>
|
||||
<Button variant="primary" isSmall>
|
||||
{t`Ex`}
|
||||
</Button>
|
||||
</DescriptionListTerm>
|
||||
<DescriptionListDescription>
|
||||
{t`Execution node`}
|
||||
</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>
|
||||
<Button variant="primary" isSmall>
|
||||
{t`Hy`}
|
||||
</Button>
|
||||
</DescriptionListTerm>
|
||||
<DescriptionListDescription>{t`Hybrid node`}</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>
|
||||
<Button variant="primary" isSmall>
|
||||
{t`h`}
|
||||
</Button>
|
||||
</DescriptionListTerm>
|
||||
<DescriptionListDescription>{t`Hop node`}</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
</DescriptionList>
|
||||
<TextContent>
|
||||
<Text component={TextVariants.small}>{t`Status types`}</Text>
|
||||
</TextContent>
|
||||
<DescriptionList isHorizontal isFluid>
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>
|
||||
<Button
|
||||
icon={<CheckIcon />}
|
||||
isSmall
|
||||
style={{ border: '1px solid gray', backgroundColor: '#3E8635' }}
|
||||
/>
|
||||
</DescriptionListTerm>
|
||||
<DescriptionListDescription>{t`Healthy`}</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>
|
||||
<Button variant="danger" icon={<ExclamationIcon />} isSmall />
|
||||
</DescriptionListTerm>
|
||||
<DescriptionListDescription>{t`Error`}</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>
|
||||
<Button
|
||||
isSmall
|
||||
style={{ border: '1px solid gray', backgroundColor: '#e6e6e6' }}
|
||||
/>
|
||||
</DescriptionListTerm>
|
||||
<DescriptionListDescription>{t`Disabled`}</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
</DescriptionList>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default Legend;
|
||||
270
awx/ui/src/screens/TopologyView/MeshGraph.js
Normal file
270
awx/ui/src/screens/TopologyView/MeshGraph.js
Normal file
@ -0,0 +1,270 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import debounce from 'util/debounce';
|
||||
import * as d3 from 'd3';
|
||||
import Legend from './Legend';
|
||||
import Tooltip from './Tooltip';
|
||||
import ContentLoading from './ContentLoading';
|
||||
import {
|
||||
renderStateColor,
|
||||
renderLabelText,
|
||||
renderNodeType,
|
||||
renderNodeIcon,
|
||||
redirectToDetailsPage,
|
||||
getHeight,
|
||||
getWidth,
|
||||
} from './utils/helpers';
|
||||
import webWorker from '../../util/webWorker';
|
||||
import {
|
||||
DEFAULT_RADIUS,
|
||||
DEFAULT_NODE_COLOR,
|
||||
DEFAULT_NODE_HIGHLIGHT_COLOR,
|
||||
DEFAULT_NODE_LABEL_TEXT_COLOR,
|
||||
DEFAULT_FONT_SIZE,
|
||||
SELECTOR,
|
||||
} from './constants';
|
||||
|
||||
const Loader = styled(ContentLoading)`
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
background: white;
|
||||
`;
|
||||
function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
|
||||
const [isNodeSelected, setIsNodeSelected] = useState(false);
|
||||
const [selectedNode, setSelectedNode] = useState(null);
|
||||
const [nodeDetail, setNodeDetail] = useState(null);
|
||||
const [simulationProgress, setSimulationProgress] = useState(null);
|
||||
const history = useHistory();
|
||||
|
||||
const draw = () => {
|
||||
setShowZoomControls(false);
|
||||
const width = getWidth(SELECTOR);
|
||||
const height = getHeight(SELECTOR);
|
||||
|
||||
/* Add SVG */
|
||||
d3.selectAll(`#chart > svg`).remove();
|
||||
const svg = d3
|
||||
.select('#chart')
|
||||
.append('svg')
|
||||
.attr('class', 'mesh-svg')
|
||||
.attr('width', `${width}px`)
|
||||
.attr('height', `100%`);
|
||||
const mesh = svg.append('g').attr('class', 'mesh');
|
||||
|
||||
const graph = data;
|
||||
|
||||
/* WEB WORKER */
|
||||
const worker = webWorker();
|
||||
worker.postMessage({
|
||||
nodes: graph.nodes,
|
||||
links: graph.links,
|
||||
});
|
||||
|
||||
worker.onmessage = function handleWorkerEvent(event) {
|
||||
switch (event.data.type) {
|
||||
case 'tick':
|
||||
return ticked(event.data);
|
||||
case 'end':
|
||||
return ended(event.data);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
function ticked({ progress }) {
|
||||
const calculatedPercent = Math.round(progress * 100);
|
||||
setSimulationProgress(calculatedPercent);
|
||||
}
|
||||
|
||||
function ended({ nodes, links }) {
|
||||
// Remove loading screen
|
||||
d3.select('.simulation-loader').style('visibility', 'hidden');
|
||||
setShowZoomControls(true);
|
||||
// Center the mesh
|
||||
const simulation = d3
|
||||
.forceSimulation(nodes)
|
||||
.force('center', d3.forceCenter(width / 2, height / 2));
|
||||
simulation.tick();
|
||||
// Add links
|
||||
mesh
|
||||
.append('g')
|
||||
.attr('class', `links`)
|
||||
.attr('data-cy', 'links')
|
||||
.selectAll('line')
|
||||
.data(links)
|
||||
.enter()
|
||||
.append('line')
|
||||
.attr('x1', (d) => d.source.x)
|
||||
.attr('y1', (d) => d.source.y)
|
||||
.attr('x2', (d) => d.target.x)
|
||||
.attr('y2', (d) => d.target.y)
|
||||
.attr('class', (_, i) => `link-${i}`)
|
||||
.attr('data-cy', (d) => `${d.source.hostname}-${d.target.hostname}`)
|
||||
.style('fill', 'none')
|
||||
.style('stroke', '#ccc')
|
||||
.style('stroke-width', '2px')
|
||||
.attr('pointer-events', 'none')
|
||||
.on('mouseover', function showPointer() {
|
||||
d3.select(this).transition().style('cursor', 'pointer');
|
||||
});
|
||||
// add nodes
|
||||
const node = mesh
|
||||
.append('g')
|
||||
.attr('class', 'nodes')
|
||||
.attr('data-cy', 'nodes')
|
||||
.selectAll('g')
|
||||
.data(nodes)
|
||||
.enter()
|
||||
.append('g')
|
||||
.on('mouseenter', function handleNodeHover(_, d) {
|
||||
d3.select(this).transition().style('cursor', 'pointer');
|
||||
highlightSiblings(d);
|
||||
})
|
||||
.on('mouseleave', (_, d) => {
|
||||
deselectSiblings(d);
|
||||
})
|
||||
.on('click', (_, d) => {
|
||||
setNodeDetail(d);
|
||||
highlightSelected(d);
|
||||
});
|
||||
|
||||
// node circles
|
||||
node
|
||||
.append('circle')
|
||||
.attr('r', DEFAULT_RADIUS)
|
||||
.attr('cx', (d) => d.x)
|
||||
.attr('cy', (d) => d.y)
|
||||
.attr('class', (d) => d.node_type)
|
||||
.attr('class', (d) => `id-${d.id}`)
|
||||
.attr('fill', DEFAULT_NODE_COLOR)
|
||||
.attr('stroke', DEFAULT_NODE_LABEL_TEXT_COLOR);
|
||||
|
||||
// node type labels
|
||||
node
|
||||
.append('text')
|
||||
.text((d) => renderNodeType(d.node_type))
|
||||
.attr('x', (d) => d.x)
|
||||
.attr('y', (d) => d.y)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'central')
|
||||
.attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR);
|
||||
|
||||
// node hostname labels
|
||||
const hostNames = node.append('g');
|
||||
hostNames
|
||||
.append('text')
|
||||
.attr('x', (d) => d.x)
|
||||
.attr('y', (d) => d.y + 40)
|
||||
.text((d) => renderLabelText(d.node_state, d.hostname))
|
||||
.attr('class', 'placeholder')
|
||||
.attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR)
|
||||
.attr('text-anchor', 'middle')
|
||||
.each(function calculateLabelWidth() {
|
||||
// eslint-disable-next-line react/no-this-in-sfc
|
||||
const bbox = this.getBBox();
|
||||
// eslint-disable-next-line react/no-this-in-sfc
|
||||
d3.select(this.parentNode)
|
||||
.append('rect')
|
||||
.attr('x', bbox.x)
|
||||
.attr('y', bbox.y)
|
||||
.attr('width', bbox.width)
|
||||
.attr('height', bbox.height)
|
||||
.attr('rx', 8)
|
||||
.attr('ry', 8)
|
||||
.style('fill', (d) => renderStateColor(d.node_state));
|
||||
});
|
||||
svg.selectAll('text.placeholder').remove();
|
||||
hostNames
|
||||
.append('text')
|
||||
.attr('x', (d) => d.x)
|
||||
.attr('y', (d) => d.y + 38)
|
||||
.text((d) => renderLabelText(d.node_state, d.hostname))
|
||||
.attr('font-size', DEFAULT_FONT_SIZE)
|
||||
.attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR)
|
||||
.attr('text-anchor', 'middle');
|
||||
|
||||
svg.call(zoom);
|
||||
|
||||
function highlightSiblings(n) {
|
||||
svg
|
||||
.select(`circle.id-${n.id}`)
|
||||
.attr('fill', DEFAULT_NODE_HIGHLIGHT_COLOR);
|
||||
const immediate = links.filter(
|
||||
(l) =>
|
||||
n.hostname === l.source.hostname || n.hostname === l.target.hostname
|
||||
);
|
||||
immediate.forEach((s) => {
|
||||
svg
|
||||
.selectAll(`.link-${s.index}`)
|
||||
.transition()
|
||||
.style('stroke', '#0066CC')
|
||||
.style('stroke-width', '3px');
|
||||
});
|
||||
}
|
||||
|
||||
function deselectSiblings(n) {
|
||||
svg.select(`circle.id-${n.id}`).attr('fill', DEFAULT_NODE_COLOR);
|
||||
const immediate = links.filter(
|
||||
(l) =>
|
||||
n.hostname === l.source.hostname || n.hostname === l.target.hostname
|
||||
);
|
||||
immediate.forEach((s) => {
|
||||
svg
|
||||
.selectAll(`.link-${s.index}`)
|
||||
.transition()
|
||||
.style('stroke', '#ccc')
|
||||
.style('stroke-width', '2px');
|
||||
});
|
||||
}
|
||||
|
||||
function highlightSelected(n) {
|
||||
if (svg.select(`circle.id-${n.id}`).attr('stroke-width') !== null) {
|
||||
// toggle rings
|
||||
svg.select(`circle.id-${n.id}`).attr('stroke-width', null);
|
||||
// show default empty state of tooltip
|
||||
setIsNodeSelected(false);
|
||||
setSelectedNode(null);
|
||||
return;
|
||||
}
|
||||
svg.selectAll('circle').attr('stroke-width', null);
|
||||
svg
|
||||
.select(`circle.id-${n.id}`)
|
||||
.attr('stroke-width', '5px')
|
||||
.attr('stroke', '#D2D2D2');
|
||||
setIsNodeSelected(true);
|
||||
setSelectedNode(n);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
d3.select('.simulation-loader').style('visibility', 'visible');
|
||||
setSelectedNode(null);
|
||||
setIsNodeSelected(false);
|
||||
draw();
|
||||
}
|
||||
window.addEventListener('resize', debounce(handleResize, 500));
|
||||
handleResize();
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div id="chart" style={{ position: 'relative', height: '100%' }}>
|
||||
{showLegend && <Legend />}
|
||||
<Tooltip
|
||||
isNodeSelected={isNodeSelected}
|
||||
renderNodeIcon={renderNodeIcon(selectedNode)}
|
||||
nodeDetail={nodeDetail}
|
||||
redirectToDetailsPage={() =>
|
||||
redirectToDetailsPage(selectedNode, history)
|
||||
}
|
||||
/>
|
||||
<Loader className="simulation-loader" progress={simulationProgress} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MeshGraph;
|
||||
110
awx/ui/src/screens/TopologyView/Tooltip.js
Normal file
110
awx/ui/src/screens/TopologyView/Tooltip.js
Normal file
@ -0,0 +1,110 @@
|
||||
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,
|
||||
Divider,
|
||||
TextContent,
|
||||
Text as PFText,
|
||||
TextVariants,
|
||||
} from '@patternfly/react-core';
|
||||
import StatusLabel from 'components/StatusLabel';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: 0;
|
||||
padding: 10px;
|
||||
width: 20%;
|
||||
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 DescriptionList = styled(PFDescriptionList)`
|
||||
gap: 0;
|
||||
`;
|
||||
const DescriptionListGroup = styled(PFDescriptionListGroup)`
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
`;
|
||||
const Text = styled(PFText)`
|
||||
margin: 10px 0 5px;
|
||||
`;
|
||||
function Tooltip({
|
||||
isNodeSelected,
|
||||
renderNodeIcon,
|
||||
nodeDetail,
|
||||
redirectToDetailsPage,
|
||||
}) {
|
||||
return (
|
||||
<Wrapper className="tooltip" data-cy="tooltip">
|
||||
{isNodeSelected === false ? (
|
||||
<TextContent>
|
||||
<Text
|
||||
component={TextVariants.small}
|
||||
style={{ fontWeight: 'bold', color: 'black' }}
|
||||
>
|
||||
{t`Details`}
|
||||
</Text>
|
||||
<Divider component="div" />
|
||||
<Text component={TextVariants.small}>
|
||||
{t`Click on a node icon to display the details.`}
|
||||
</Text>
|
||||
</TextContent>
|
||||
) : (
|
||||
<>
|
||||
<TextContent>
|
||||
<Text
|
||||
component={TextVariants.small}
|
||||
style={{ fontWeight: 'bold', color: 'black' }}
|
||||
>
|
||||
{t`Details`}
|
||||
</Text>
|
||||
<Divider component="div" />
|
||||
</TextContent>
|
||||
<DescriptionList isHorizontal isFluid>
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>
|
||||
<Button variant="primary" isSmall>
|
||||
{renderNodeIcon}
|
||||
</Button>
|
||||
</DescriptionListTerm>
|
||||
<DescriptionListDescription>
|
||||
<PFButton
|
||||
variant="link"
|
||||
isInline
|
||||
onClick={redirectToDetailsPage}
|
||||
>
|
||||
{nodeDetail.hostname}
|
||||
</PFButton>
|
||||
</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>{t`Type`}</DescriptionListTerm>
|
||||
<DescriptionListDescription>
|
||||
{nodeDetail.node_type} {t`node`}
|
||||
</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
<DescriptionListGroup>
|
||||
<DescriptionListTerm>{t`Status`}</DescriptionListTerm>
|
||||
<DescriptionListDescription>
|
||||
<StatusLabel status={nodeDetail.node_state} />
|
||||
</DescriptionListDescription>
|
||||
</DescriptionListGroup>
|
||||
</DescriptionList>
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default Tooltip;
|
||||
77
awx/ui/src/screens/TopologyView/TopologyView.js
Normal file
77
awx/ui/src/screens/TopologyView/TopologyView.js
Normal file
@ -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 (
|
||||
<>
|
||||
<Header
|
||||
title={t`Topology View`}
|
||||
handleSwitchToggle={setShowLegend}
|
||||
toggleState={showLegend}
|
||||
zoomIn={zoomIn}
|
||||
zoomOut={zoomOut}
|
||||
zoomFit={zoomFit}
|
||||
resetZoom={resetZoom}
|
||||
showZoomControls={showZoomControls}
|
||||
/>
|
||||
{fetchInitialError ? (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<ContentError error={fetchInitialError} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</PageSection>
|
||||
) : (
|
||||
<PageSection>
|
||||
<Card style={{ height: '100%' }}>
|
||||
<CardBody>
|
||||
{!isLoading && (
|
||||
<MeshGraph
|
||||
data={meshData}
|
||||
showLegend={showLegend}
|
||||
zoom={zoom}
|
||||
setShowZoomControls={setShowZoomControls}
|
||||
/>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</PageSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopologyView;
|
||||
34
awx/ui/src/screens/TopologyView/constants.js
Normal file
34
awx/ui/src/screens/TopologyView/constants.js
Normal file
@ -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',
|
||||
};
|
||||
1
awx/ui/src/screens/TopologyView/index.js
Normal file
1
awx/ui/src/screens/TopologyView/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './TopologyView';
|
||||
89
awx/ui/src/screens/TopologyView/utils/helpers.js
Normal file
89
awx/ui/src/screens/TopologyView/utils/helpers.js
Normal file
@ -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;
|
||||
}
|
||||
74
awx/ui/src/screens/TopologyView/utils/useZoom.js
Normal file
74
awx/ui/src/screens/TopologyView/utils/useZoom.js
Normal file
@ -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:
|
||||
* <div id="chart">
|
||||
* <svg><-- parent -->
|
||||
* <g><-- child -->
|
||||
* </svg>
|
||||
* </div>
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
34
awx/ui/src/util/simulationWorker.js
Normal file
34
awx/ui/src/util/simulationWorker.js
Normal file
@ -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 });
|
||||
};
|
||||
@ -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)}...`;
|
||||
};
|
||||
|
||||
3
awx/ui/src/util/webWorker.js
Normal file
3
awx/ui/src/util/webWorker.js
Normal file
@ -0,0 +1,3 @@
|
||||
export default function webWorker() {
|
||||
return new Worker(new URL('./simulationWorker.js', import.meta.url));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user