Merge pull request #11501 from kialam/add-mesh-viz

Add mesh visualizer
This commit is contained in:
kialam 2022-03-10 11:48:03 -08:00 committed by GitHub
commit d02cef9d92
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1034 additions and 0 deletions

View File

@ -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(() => {

View File

@ -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,

View File

@ -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;

View 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;

View File

@ -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', () => {

View File

@ -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;

View File

@ -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',
]);
});
});

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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',
};

View File

@ -0,0 +1 @@
export { default } from './TopologyView';

View 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;
}

View 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,
};
}

View 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 });
};

View File

@ -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)}...`;
};

View File

@ -0,0 +1,3 @@
export default function webWorker() {
return new Worker(new URL('./simulationWorker.js', import.meta.url));
}