mirror of
https://github.com/ansible/awx.git
synced 2026-05-11 19:37:38 -02:30
@@ -7,6 +7,7 @@ import { mountWithContexts } from '../testUtils/enzymeHelpers';
|
|||||||
import App, { ProtectedRoute } from './App';
|
import App, { ProtectedRoute } from './App';
|
||||||
|
|
||||||
jest.mock('./api');
|
jest.mock('./api');
|
||||||
|
jest.mock('util/webWorker', () => jest.fn());
|
||||||
|
|
||||||
describe('<App />', () => {
|
describe('<App />', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import Jobs from './models/Jobs';
|
|||||||
import JobEvents from './models/JobEvents';
|
import JobEvents from './models/JobEvents';
|
||||||
import Labels from './models/Labels';
|
import Labels from './models/Labels';
|
||||||
import Me from './models/Me';
|
import Me from './models/Me';
|
||||||
|
import Mesh from './models/Mesh';
|
||||||
import Metrics from './models/Metrics';
|
import Metrics from './models/Metrics';
|
||||||
import NotificationTemplates from './models/NotificationTemplates';
|
import NotificationTemplates from './models/NotificationTemplates';
|
||||||
import Notifications from './models/Notifications';
|
import Notifications from './models/Notifications';
|
||||||
@@ -67,6 +68,7 @@ const JobsAPI = new Jobs();
|
|||||||
const JobEventsAPI = new JobEvents();
|
const JobEventsAPI = new JobEvents();
|
||||||
const LabelsAPI = new Labels();
|
const LabelsAPI = new Labels();
|
||||||
const MeAPI = new Me();
|
const MeAPI = new Me();
|
||||||
|
const MeshAPI = new Mesh();
|
||||||
const MetricsAPI = new Metrics();
|
const MetricsAPI = new Metrics();
|
||||||
const NotificationTemplatesAPI = new NotificationTemplates();
|
const NotificationTemplatesAPI = new NotificationTemplates();
|
||||||
const NotificationsAPI = new Notifications();
|
const NotificationsAPI = new Notifications();
|
||||||
@@ -114,6 +116,7 @@ export {
|
|||||||
JobEventsAPI,
|
JobEventsAPI,
|
||||||
LabelsAPI,
|
LabelsAPI,
|
||||||
MeAPI,
|
MeAPI,
|
||||||
|
MeshAPI,
|
||||||
MetricsAPI,
|
MetricsAPI,
|
||||||
NotificationTemplatesAPI,
|
NotificationTemplatesAPI,
|
||||||
NotificationsAPI,
|
NotificationsAPI,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class Instances extends Base {
|
|||||||
|
|
||||||
this.readHealthCheckDetail = this.readHealthCheckDetail.bind(this);
|
this.readHealthCheckDetail = this.readHealthCheckDetail.bind(this);
|
||||||
this.healthCheck = this.healthCheck.bind(this);
|
this.healthCheck = this.healthCheck.bind(this);
|
||||||
|
this.readInstanceGroup = this.readInstanceGroup.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
healthCheck(instanceId) {
|
healthCheck(instanceId) {
|
||||||
@@ -16,6 +17,10 @@ class Instances extends Base {
|
|||||||
readHealthCheckDetail(instanceId) {
|
readHealthCheckDetail(instanceId) {
|
||||||
return this.http.get(`${this.baseUrl}${instanceId}/health_check/`);
|
return this.http.get(`${this.baseUrl}${instanceId}/health_check/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readInstanceGroup(instanceId) {
|
||||||
|
return this.http.get(`${this.baseUrl}${instanceId}/instance_groups/`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Instances;
|
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';
|
import App from './App';
|
||||||
|
|
||||||
jest.mock('react-dom', () => ({ render: jest.fn() }));
|
jest.mock('react-dom', () => ({ render: jest.fn() }));
|
||||||
|
jest.mock('util/webWorker', () => jest.fn());
|
||||||
|
|
||||||
describe('index.jsx', () => {
|
describe('index.jsx', () => {
|
||||||
it('renders ok', () => {
|
it('renders ok', () => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import Schedules from 'screens/Schedule';
|
|||||||
import Settings from 'screens/Setting';
|
import Settings from 'screens/Setting';
|
||||||
import Teams from 'screens/Team';
|
import Teams from 'screens/Team';
|
||||||
import Templates from 'screens/Template';
|
import Templates from 'screens/Template';
|
||||||
|
import TopologyView from 'screens/TopologyView';
|
||||||
import Users from 'screens/User';
|
import Users from 'screens/User';
|
||||||
import WorkflowApprovals from 'screens/WorkflowApproval';
|
import WorkflowApprovals from 'screens/WorkflowApproval';
|
||||||
import { Jobs } from 'screens/Job';
|
import { Jobs } from 'screens/Job';
|
||||||
@@ -147,6 +148,11 @@ function getRouteConfig(userProfile = {}) {
|
|||||||
path: '/execution_environments',
|
path: '/execution_environments',
|
||||||
screen: ExecutionEnvironments,
|
screen: ExecutionEnvironments,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: <Trans>Topology View</Trans>,
|
||||||
|
path: '/topology_view',
|
||||||
|
screen: TopologyView,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -179,6 +185,7 @@ function getRouteConfig(userProfile = {}) {
|
|||||||
deleteRoute('management_jobs');
|
deleteRoute('management_jobs');
|
||||||
if (userProfile?.isOrgAdmin) return routeConfig;
|
if (userProfile?.isOrgAdmin) return routeConfig;
|
||||||
deleteRoute('instance_groups');
|
deleteRoute('instance_groups');
|
||||||
|
deleteRoute('topology_view');
|
||||||
if (!userProfile?.isNotificationAdmin) deleteRoute('notification_templates');
|
if (!userProfile?.isNotificationAdmin) deleteRoute('notification_templates');
|
||||||
|
|
||||||
return routeConfig;
|
return routeConfig;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import getRouteConfig from './routeConfig';
|
import getRouteConfig from './routeConfig';
|
||||||
|
jest.mock('util/webWorker', () => jest.fn());
|
||||||
|
|
||||||
const userProfile = {
|
const userProfile = {
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
@@ -43,6 +44,7 @@ describe('getRouteConfig', () => {
|
|||||||
'/instances',
|
'/instances',
|
||||||
'/applications',
|
'/applications',
|
||||||
'/execution_environments',
|
'/execution_environments',
|
||||||
|
'/topology_view',
|
||||||
'/settings',
|
'/settings',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -71,6 +73,7 @@ describe('getRouteConfig', () => {
|
|||||||
'/instances',
|
'/instances',
|
||||||
'/applications',
|
'/applications',
|
||||||
'/execution_environments',
|
'/execution_environments',
|
||||||
|
'/topology_view',
|
||||||
'/settings',
|
'/settings',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -98,6 +101,7 @@ describe('getRouteConfig', () => {
|
|||||||
'/instances',
|
'/instances',
|
||||||
'/applications',
|
'/applications',
|
||||||
'/execution_environments',
|
'/execution_environments',
|
||||||
|
'/topology_view',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -233,6 +237,7 @@ describe('getRouteConfig', () => {
|
|||||||
'/instances',
|
'/instances',
|
||||||
'/applications',
|
'/applications',
|
||||||
'/execution_environments',
|
'/execution_environments',
|
||||||
|
'/topology_view',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,6 +268,7 @@ describe('getRouteConfig', () => {
|
|||||||
'/instances',
|
'/instances',
|
||||||
'/applications',
|
'/applications',
|
||||||
'/execution_environments',
|
'/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(
|
/^[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
|
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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user