WIP add network visualizer to Controller UI.

This commit is contained in:
Kia Lam 2022-01-04 07:34:03 -08:00
parent 1ed0b70601
commit 1246b14e7e
6 changed files with 316 additions and 0 deletions

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

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

@ -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,
},
],
},
{

View File

@ -0,0 +1,252 @@
import React, { useEffect, useCallback } from 'react';
import { t } from '@lingui/macro';
import * as d3 from 'd3';
function MeshGraph({ data }) {
console.log('data', data);
const draw = useCallback(() => {
const margin = 80;
const getWidth = () => {
let width;
// This is in an a try/catch due to an error from jest.
// Even though the d3.select returns a valid selector with
// style function, it says it is null in the test
try {
width =
parseInt(d3.select(`#chart`).style('width'), 10) - margin || 700;
} catch (error) {
width = 700;
}
return width;
};
const width = getWidth();
const height = 600;
/* Add SVG */
d3.selectAll(`#chart > *`).remove();
const svg = d3
.select('#chart')
.append('svg')
.attr('width', `${width + margin}px`)
.attr('height', `${height + margin}px`)
.append('g')
.attr('transform', `translate(${margin}, ${margin})`);
const color = d3.scaleOrdinal(d3.schemeCategory10);
const simulation = d3
.forceSimulation()
.force(
'link',
d3.forceLink().id(function (d) {
return d.hostname;
})
)
.force('charge', d3.forceManyBody().strength(-350))
.force(
'collide',
d3.forceCollide(function (d) {
return d.node_type === 'execution' || d.node_type === 'hop'
? 75
: 100;
})
)
.force('center', d3.forceCenter(width / 2, height / 2));
const graph = data;
const link = svg
.append('g')
.attr('class', 'links')
.selectAll('path')
.data(graph.links)
.enter()
.append('path')
.style('fill', 'none')
.style('stroke', '#ccc')
.style('stroke-width', '2px')
.attr('pointer-events', 'visibleStroke')
.on('mouseover', function (event, d) {
tooltip
.html(`source: ${d.source.hostname} <br>target: ${d.target.hostname}`)
.style('visibility', 'visible');
d3.select(this).transition().style('cursor', 'pointer');
})
.on('mousemove', function () {
tooltip
.style('top', event.pageY - 10 + 'px')
.style('left', event.pageX + 10 + 'px');
})
.on('mouseout', function () {
tooltip.html(``).style('visibility', 'hidden');
});
const node = svg
.append('g')
.attr('class', 'nodes')
.selectAll('g')
.data(graph.nodes)
.enter()
.append('g')
.on('mouseover', function (event, d) {
tooltip
.html(
`name: ${d.hostname} <br>type: ${d.node_type} <br>status: ${d.node_state}`
)
.style('visibility', 'visible');
// d3.select(this).transition().attr('r', 9).style('cursor', 'pointer');
})
.on('mousemove', function () {
tooltip
.style('top', event.pageY - 10 + 'px')
.style('left', event.pageX + 10 + 'px');
})
.on('mouseout', function () {
tooltip.html(``).style('visibility', 'hidden');
// d3.select(this).attr('r', 6);
});
const healthRings = node
.append('circle')
.attr('r', 8)
.attr('class', (d) => d.node_state)
.attr('stroke', d => d.node_state === 'disabled' ? '#c6c6c6' : '#50D050')
.attr('fill', d => d.node_state === 'disabled' ? '#c6c6c6' : '#50D050');
const nodeRings = node
.append('circle')
.attr('r', 6)
.attr('class', (d) => d.node_type)
.attr('fill', function (d) {
return color(d.node_type);
});
svg.call(expandGlow);
const legend = svg
.append('g')
.attr('class', 'chart-legend')
.selectAll('g')
.data(graph.nodes)
.enter()
.append('circle')
.attr('cx', 10)
.attr('cy', function (d, i) {
return 100 + i * 25;
})
.attr('r', 7)
.attr('class', (d) => d.node_type)
.style('fill', function (d) {
return color(d.node_type);
});
const legend_text = svg
.append('g')
.attr('class', 'chart-text')
.selectAll('g')
.data(graph.nodes)
.enter()
.append('text')
.attr('x', 20)
.attr('y', function (d, i) {
return 100 + i * 25;
})
.text((d) => `${d.hostname} - ${d.node_type}`)
.attr('text-anchor', 'left')
.style('alignment-baseline', 'middle');
const tooltip = d3
.select('#chart')
.append('div')
.attr('class', 'd3-tooltip')
.style('position', 'absolute')
.style('z-index', '10')
.style('visibility', 'hidden')
.style('padding', '15px')
.style('background', 'rgba(0,0,0,0.6)')
.style('border-radius', '5px')
.style('color', '#fff')
.style('font-family', 'sans-serif')
.text('a simple tooltip');
const labels = node
.append('text')
.text(function (d) {
return d.hostname;
})
.attr('x', 16)
.attr('y', 3);
simulation.nodes(graph.nodes).on('tick', ticked);
simulation.force('link').links(graph.links);
function ticked() {
link.attr('d', linkArc);
node.attr('transform', function (d) {
return 'translate(' + d.x + ',' + d.y + ')';
});
}
function linkArc(d) {
var dx = d.target.x - d.source.x,
dy = d.target.y - d.source.y,
dr = Math.sqrt(dx * dx + dy * dy);
return (
'M' +
d.source.x +
',' +
d.source.y +
'A' +
dr +
',' +
dr +
' 0 0,1 ' +
d.target.x +
',' +
d.target.y
);
}
function contractGlow() {
healthRings
.transition()
.duration(1000)
.attr('stroke-width', '1px')
.on('end', expandGlow);
}
function expandGlow() {
healthRings
.transition()
.duration(1000)
.attr('stroke-width', '4.5px')
.on('end', contractGlow);
}
const zoom = d3
.zoom()
.scaleExtent([1, 8])
.on('zoom', function (event) {
svg.selectAll('.links, .nodes').attr('transform', event.transform);
});
svg.call(zoom);
}, [data]);
useEffect(() => {
function handleResize() {
draw();
}
window.addEventListener('resize', handleResize);
handleResize();
return () => window.removeEventListener('resize', handleResize);
}, [draw]);
return <div id="chart" />;
}
export default MeshGraph;

View File

@ -0,0 +1,45 @@
import React, { useEffect, useCallback } from 'react';
import { t } from '@lingui/macro';
import ScreenHeader from 'components/ScreenHeader/ScreenHeader';
import {
PageSection,
Card,
CardHeader,
CardBody,
} from '@patternfly/react-core';
import MeshGraph from './MeshGraph';
import useRequest from 'hooks/useRequest';
import { MeshAPI } from 'api';
function TopologyView() {
const {
result: { meshData },
error: fetchInitialError,
request: fetchMeshVisualizer,
} = useRequest(
useCallback(async () => {
const { data } = await MeshAPI.read();
return {
meshData: data,
};
}, []),
{ meshData: { nodes: [], links: [] } }
);
useEffect(() => {
fetchMeshVisualizer();
}, [fetchMeshVisualizer]);
return (
<>
<ScreenHeader breadcrumbConfig={{ '/topology_view': t`Topology View` }} />
<PageSection>
<Card>
<CardBody>{meshData && <MeshGraph data={meshData} />}</CardBody>
</Card>
</PageSection>
</>
);
}
export default TopologyView;

View File

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