diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 4e89937291..9e838f4377 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -11,6 +11,9 @@ import { renderLabelText, renderNodeType, renderNodeIcon, + renderLinkState, + renderLabelIcons, + renderIconPosition, redirectToDetailsPage, getHeight, getWidth, @@ -20,7 +23,8 @@ import { DEFAULT_RADIUS, DEFAULT_NODE_COLOR, DEFAULT_NODE_HIGHLIGHT_COLOR, - DEFAULT_NODE_LABEL_TEXT_COLOR, + DEFAULT_NODE_SYMBOL_TEXT_COLOR, + DEFAULT_NODE_STROKE_COLOR, DEFAULT_FONT_SIZE, SELECTOR, } from './constants'; @@ -95,6 +99,24 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { .forceSimulation(nodes) .force('center', d3.forceCenter(width / 2, height / 2)); simulation.tick(); + // build the arrow. + mesh + .append('defs') + .selectAll('marker') + .data(['end', 'end-active']) + .join('marker') + .attr('id', String) + .attr('viewBox', '0 -5 10 10') + .attr('refY', 0) + .attr('markerWidth', 6) + .attr('markerHeight', 6) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M0,-5L10,0L0,5'); + + mesh.select('#end').attr('refX', 23).attr('fill', '#ccc'); + mesh.select('#end-active').attr('refX', 18).attr('fill', '#0066CC'); + // Add links mesh .append('g') @@ -108,11 +130,13 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { .attr('y1', (d) => d.source.y) .attr('x2', (d) => d.target.x) .attr('y2', (d) => d.target.y) + .attr('marker-end', 'url(#end)') .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') + .style('stroke-dasharray', (d) => renderLinkState(d.link_state)) .attr('pointer-events', 'none') .on('mouseover', function showPointer() { d3.select(this).transition().style('cursor', 'pointer'); @@ -147,7 +171,7 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { .attr('class', (d) => d.node_type) .attr('class', (d) => `id-${d.id}`) .attr('fill', DEFAULT_NODE_COLOR) - .attr('stroke', DEFAULT_NODE_LABEL_TEXT_COLOR); + .attr('stroke', DEFAULT_NODE_STROKE_COLOR); // node type labels node @@ -157,41 +181,65 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { .attr('y', (d) => d.y) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'central') - .attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR); + .attr('fill', DEFAULT_NODE_SYMBOL_TEXT_COLOR); - // node hostname labels - const hostNames = node.append('g'); - hostNames + const placeholder = node.append('g').attr('class', 'placeholder'); + + placeholder .append('text') + .text((d) => renderLabelText(d.node_state, d.hostname)) .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('fill', 'black') + .attr('font-size', '18px') .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)); + .append('path') + .attr('d', (d) => renderLabelIcons(d.node_state)) + .attr('transform', (d) => renderIconPosition(d.node_state, bbox)) + .style('fill', 'black'); }); - svg.selectAll('text.placeholder').remove(); + + placeholder.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', (d) => d.x - bbox.width / 2) + .attr('y', bbox.y + 5) + .attr('width', bbox.width) + .attr('height', bbox.height) + .attr('rx', 8) + .attr('ry', 8) + .style('fill', (d) => renderStateColor(d.node_state)); + }); + + const hostNames = node.append('g'); hostNames .append('text') - .attr('x', (d) => d.x) - .attr('y', (d) => d.y + 38) .text((d) => renderLabelText(d.node_state, d.hostname)) + .attr('x', (d) => d.x + 6) + .attr('y', (d) => d.y + 42) + .attr('fill', 'white') .attr('font-size', DEFAULT_FONT_SIZE) - .attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR) - .attr('text-anchor', 'middle'); + .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('path') + .attr('class', (d) => `icon-${d.node_state}`) + .attr('d', (d) => renderLabelIcons(d.node_state)) + .attr('transform', (d) => renderIconPosition(d.node_state, bbox)) + .attr('fill', 'white'); + }); + svg.selectAll('g.placeholder').remove(); svg.call(zoom); @@ -208,7 +256,8 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { .selectAll(`.link-${s.index}`) .transition() .style('stroke', '#0066CC') - .style('stroke-width', '3px'); + .style('stroke-width', '3px') + .attr('marker-end', 'url(#end-active)'); }); } @@ -222,25 +271,33 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { svg .selectAll(`.link-${s.index}`) .transition() + .duration(50) .style('stroke', '#ccc') - .style('stroke-width', '2px'); + .style('stroke-width', '2px') + .attr('marker-end', 'url(#end)'); }); } 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); + svg + .select(`circle.id-${n.id}`) + .attr('stroke', '#ccc') + .attr('stroke-width', null); // show default empty state of tooltip setIsNodeSelected(false); setSelectedNode(null); return; } - svg.selectAll('circle').attr('stroke-width', null); + svg + .selectAll('circle') + .attr('stroke', '#ccc') + .attr('stroke-width', null); svg .select(`circle.id-${n.id}`) .attr('stroke-width', '5px') - .attr('stroke', '#D2D2D2'); + .attr('stroke', '#0066CC'); setIsNodeSelected(true); setSelectedNode(n); } diff --git a/awx/ui/src/screens/TopologyView/constants.js b/awx/ui/src/screens/TopologyView/constants.js index d217078f6c..e3ad1445ae 100644 --- a/awx/ui/src/screens/TopologyView/constants.js +++ b/awx/ui/src/screens/TopologyView/constants.js @@ -9,21 +9,22 @@ export const MESH_FORCE_LAYOUT = { defaultForceX: 0, defaultForceY: 0, }; -export const DEFAULT_NODE_COLOR = '#0066CC'; -export const DEFAULT_NODE_HIGHLIGHT_COLOR = '#16407C'; +export const DEFAULT_NODE_COLOR = 'white'; +export const DEFAULT_NODE_HIGHLIGHT_COLOR = '#eee'; export const DEFAULT_NODE_LABEL_TEXT_COLOR = 'white'; +export const DEFAULT_NODE_SYMBOL_TEXT_COLOR = 'black'; +export const DEFAULT_NODE_STROKE_COLOR = '#ccc'; 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', + unavailable: '#F0AB00', + ready: '#3E8635', + 'provision-fail': '#C9190B', + 'deprovision-fail': '#C9190B', + installed: '#0066CC', + provisioning: '#666', + deprovisioning: '#666', }; export const NODE_TYPE_SYMBOL_KEY = { @@ -32,3 +33,17 @@ export const NODE_TYPE_SYMBOL_KEY = { hybrid: 'Hy', control: 'C', }; + +export const ICONS = { + clock: + 'M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200zm61.8-104.4l-84.9-61.7c-3.1-2.3-4.9-5.9-4.9-9.7V116c0-6.6 5.4-12 12-12h32c6.6 0 12 5.4 12 12v141.7l66.8 48.6c5.4 3.9 6.5 11.4 2.6 16.8L334.6 349c-3.9 5.3-11.4 6.5-16.8 2.6z', + checkmark: + 'M173.898 439.404l-166.4-166.4c-9.997-9.997-9.997-26.206 0-36.204l36.203-36.204c9.997-9.998 26.207-9.998 36.204 0L192 312.69 432.095 72.596c9.997-9.997 26.207-9.997 36.204 0l36.203 36.204c9.997 9.997 9.997 26.206 0 36.204l-294.4 294.401c-9.998 9.997-26.207 9.997-36.204-.001z', + exclaimation: + 'M176 432c0 44.112-35.888 80-80 80s-80-35.888-80-80 35.888-80 80-80 80 35.888 80 80zM25.26 25.199l13.6 272C39.499 309.972 50.041 320 62.83 320h66.34c12.789 0 23.331-10.028 23.97-22.801l13.6-272C167.425 11.49 156.496 0 142.77 0H49.23C35.504 0 24.575 11.49 25.26 25.199z', + minus: + 'M416 208H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h384c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z', + plus: 'M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z', + empty: + 'M512,896 C300.2,896 128,723.9 128,512 C128,300.3 300.2,128 512,128 C723.7,128 896,300.2 896,512 C896,723.8 723.7,896 512,896 L512,896 Z M512.1,0 C229.7,0 0,229.8 0,512 C0,794.3 229.8,1024 512.1,1024 C794.4,1024 1024,794.3 1024,512 C1024,229.7 794.4,0 512.1,0 L512.1,0 Z', +}; diff --git a/awx/ui/src/screens/TopologyView/utils/helpers.js b/awx/ui/src/screens/TopologyView/utils/helpers.js index 11356d0e34..3d96692765 100644 --- a/awx/ui/src/screens/TopologyView/utils/helpers.js +++ b/awx/ui/src/screens/TopologyView/utils/helpers.js @@ -3,9 +3,9 @@ import { truncateString } from '../../../util/strings'; import { NODE_STATE_COLOR_KEY, - NODE_STATE_HTML_ENTITY_KEY, NODE_TYPE_SYMBOL_KEY, LABEL_TEXT_MAX_LENGTH, + ICONS, } from '../constants'; export function getWidth(selector) { @@ -22,12 +22,7 @@ export function renderStateColor(nodeState) { export function renderLabelText(nodeState, name) { if (typeof nodeState === 'string' && typeof name === 'string') { - return NODE_STATE_HTML_ENTITY_KEY[nodeState] - ? `${NODE_STATE_HTML_ENTITY_KEY[nodeState]} ${truncateString( - name, - LABEL_TEXT_MAX_LENGTH - )}` - : ` ${truncateString(name, LABEL_TEXT_MAX_LENGTH)}`; + return `${truncateString(name, LABEL_TEXT_MAX_LENGTH)}`; } return ``; } @@ -44,6 +39,41 @@ export function renderNodeIcon(selectedNode) { return false; } +export function renderLabelIcons(nodeState) { + if (nodeState) { + const nodeLabelIconMapper = { + unavailable: 'empty', + ready: 'checkmark', + installed: 'clock', + 'provision-fail': 'exclaimation', + 'deprovision-fail': 'exclaimation', + provisioning: 'plus', + deprovisioning: 'minus', + }; + return ICONS[nodeLabelIconMapper[nodeState]] + ? ICONS[nodeLabelIconMapper[nodeState]] + : ``; + } + return false; +} +export function renderIconPosition(nodeState, bbox) { + if (nodeState) { + const iconPositionMapper = { + unavailable: `translate(${bbox.x - 12}, ${bbox.y + 3}), scale(0.01)`, + ready: `translate(${bbox.x - 15}, ${bbox.y + 3}), scale(0.02)`, + installed: `translate(${bbox.x - 18}, ${bbox.y + 1}), scale(0.03)`, + 'provision-fail': `translate(${bbox.x - 9}, ${bbox.y + 3}), scale(0.02)`, + 'deprovision-fail': `translate(${bbox.x - 9}, ${ + bbox.y + 3 + }), scale(0.02)`, + provisioning: `translate(${bbox.x - 12}, ${bbox.y + 3}), scale(0.02)`, + deprovisioning: `translate(${bbox.x - 12}, ${bbox.y + 3}), scale(0.02)`, + }; + return iconPositionMapper[nodeState] ? iconPositionMapper[nodeState] : ``; + } + return false; +} + export function redirectToDetailsPage(selectedNode, history) { if (selectedNode && history) { const { id: nodeId } = selectedNode; @@ -53,6 +83,14 @@ export function redirectToDetailsPage(selectedNode, history) { return false; } +export function renderLinkState(linkState) { + const linkPattern = { + established: null, + adding: 3, + removing: 3, + }; + return linkPattern[linkState] ? linkPattern[linkState] : null; +} // DEBUG TOOLS export function getRandomInt(min, max) { min = Math.ceil(min); @@ -62,13 +100,20 @@ export function getRandomInt(min, max) { const generateRandomLinks = (n, r) => { const links = []; + function getRandomLinkState() { + return ['established', 'adding', 'removing'][getRandomInt(0, 2)]; + } 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, + link_state: getRandomLinkState(), }; - links.push(link); + if (link.source !== link.target) { + links.push(link); + } } + return { nodes: n, links }; }; @@ -78,7 +123,15 @@ export const generateRandomNodes = (n) => { return ['hybrid', 'execution', 'control', 'hop'][getRandomInt(0, 3)]; } function getRandomState() { - return ['healthy', 'error', 'disabled'][getRandomInt(0, 2)]; + return [ + 'ready', + 'provisioning', + 'deprovisioning', + 'installed', + 'unavailable', + 'provision-fail', + 'deprovision-fail', + ][getRandomInt(0, 6)]; } for (let i = 0; i < n; i++) { const id = i + 1; diff --git a/awx/ui/src/screens/TopologyView/utils/helpers__RTL.test.js b/awx/ui/src/screens/TopologyView/utils/helpers__RTL.test.js index 860b699468..4b0ec8cd40 100644 --- a/awx/ui/src/screens/TopologyView/utils/helpers__RTL.test.js +++ b/awx/ui/src/screens/TopologyView/utils/helpers__RTL.test.js @@ -10,7 +10,7 @@ import { describe('renderStateColor', () => { test('returns correct node state color', () => { - expect(renderStateColor('healthy')).toBe('#3E8635'); + expect(renderStateColor('ready')).toBe('#3E8635'); }); test('returns empty string if state is not found', () => { expect(renderStateColor('foo')).toBe(''); @@ -68,10 +68,10 @@ describe('getHeight', () => { }); describe('renderLabelText', () => { test('returns label text correctly', () => { - expect(renderLabelText('error', 'foo')).toBe('! foo'); + expect(renderLabelText('error', 'foo')).toBe('foo'); }); test('returns label text if invalid node state is passed', () => { - expect(renderLabelText('foo', 'bar')).toBe(' bar'); + expect(renderLabelText('foo', 'bar')).toBe('bar'); }); test('returns empty string if non string params are passed', () => { expect(renderLabelText(0, null)).toBe('');