diff --git a/package.json b/package.json index c4efcaeb4dd..e7bebec9493 100644 --- a/package.json +++ b/package.json @@ -208,6 +208,7 @@ "webpack-cli": "3.3.10", "webpack-dev-server": "3.11.1", "webpack-merge": "4.2.2", + "worker-loader": "^3.0.8", "zone.js": "0.7.8" }, "dependencies": { diff --git a/packages/grafana-data/src/utils/index.ts b/packages/grafana-data/src/utils/index.ts index 2e9c892b968..7ace16115e9 100644 --- a/packages/grafana-data/src/utils/index.ts +++ b/packages/grafana-data/src/utils/index.ts @@ -10,6 +10,7 @@ export * from './object'; export * from './namedColorsPalette'; export * from './series'; export * from './binaryOperators'; +export * from './nodeGraph'; export { PanelOptionsEditorBuilder, FieldConfigEditorBuilder } from './OptionsUIBuilders'; export { arrayUtils }; export { getFlotPairs, getFlotPairsConstant } from './flotPairs'; diff --git a/packages/grafana-data/src/utils/nodeGraph.ts b/packages/grafana-data/src/utils/nodeGraph.ts new file mode 100644 index 00000000000..03a1c48eda9 --- /dev/null +++ b/packages/grafana-data/src/utils/nodeGraph.ts @@ -0,0 +1,12 @@ +export enum NodeGraphDataFrameFieldNames { + id = 'id', + title = 'title', + subTitle = 'subTitle', + mainStat = 'mainStat', + secondaryStat = 'secondaryStat', + source = 'source', + target = 'target', + detail = 'detail__', + arc = 'arc__', + color = 'color', +} diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 7ce65c5e2a2..b6e3f8059dc 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -26,17 +26,17 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@emotion/react": "11.1.5", "@emotion/css": "11.1.3", + "@emotion/react": "11.1.5", + "@grafana/aws-sdk": "0.0.3", "@grafana/data": "7.5.0-pre.0", "@grafana/e2e-selectors": "7.5.0-pre.0", "@grafana/slate-react": "0.22.10-grafana", "@grafana/tsconfig": "^1.0.0-rc1", - "@grafana/aws-sdk": "0.0.3", + "@monaco-editor/react": "4.1.1", "@popperjs/core": "2.5.4", "@sentry/browser": "5.25.0", "@testing-library/jest-dom": "5.11.9", - "react-select": "4.3.0", "@types/hoist-non-react-statics": "3.3.1", "@types/react-beautiful-dnd": "12.1.2", "@types/react-color": "3.0.1", @@ -49,7 +49,6 @@ "@visx/scale": "1.4.0", "@visx/shape": "1.4.0", "@visx/tooltip": "1.7.2", - "react-router-dom": "^5.2.0", "classnames": "2.2.6", "d3": "5.15.0", "hoist-non-react-statics": "3.3.2", @@ -57,7 +56,6 @@ "jquery": "3.5.1", "lodash": "4.17.21", "moment": "2.24.0", - "@monaco-editor/react": "4.1.1", "monaco-editor": "0.21.2", "papaparse": "5.3.0", "rc-cascader": "1.0.1", @@ -73,6 +71,8 @@ "react-highlight-words": "0.16.0", "react-hook-form": "7.2.3", "react-popper": "2.2.4", + "react-router-dom": "^5.2.0", + "react-select": "4.3.0", "react-storybook-addon-props-combinations": "1.1.0", "react-table": "7.0.0", "react-transition-group": "4.4.1", @@ -89,7 +89,6 @@ "@storybook/addon-storysource": "6.2.7", "@storybook/react": "6.2.7", "@storybook/theming": "6.2.7", - "@types/react-router-dom": "^5.1.7", "@types/classnames": "2.2.7", "@types/common-tags": "^1.8.0", "@types/d3": "5.7.2", @@ -101,6 +100,7 @@ "@types/papaparse": "5.2.0", "@types/react": "16.9.9", "@types/react-custom-scrollbars": "4.0.5", + "@types/react-router-dom": "^5.1.7", "@types/react-test-renderer": "16.9.2", "@types/react-transition-group": "4.4.0", "@types/rollup-plugin-visualizer": "2.6.0", diff --git a/packages/grafana-ui/src/components/NodeGraph/index.ts b/packages/grafana-ui/src/components/NodeGraph/index.ts deleted file mode 100644 index 39cfa18225e..00000000000 --- a/packages/grafana-ui/src/components/NodeGraph/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { NodeGraph } from './NodeGraph'; -export { DataFrameFieldNames as NodeGraphDataFrameFieldNames } from './utils'; diff --git a/packages/grafana-ui/src/components/NodeGraph/layout.ts b/packages/grafana-ui/src/components/NodeGraph/layout.ts deleted file mode 100644 index 1c0e66e178d..00000000000 --- a/packages/grafana-ui/src/components/NodeGraph/layout.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { useEffect, useState } from 'react'; -import { forceSimulation, forceLink, forceCollide, forceX } from 'd3-force'; -import { EdgeDatum, NodeDatum } from './types'; - -export interface Config { - linkDistance: number; - linkStrength: number; - forceX: number; - forceXStrength: number; - forceCollide: number; - tick: number; -} - -export const defaultConfig: Config = { - linkDistance: 150, - linkStrength: 0.5, - forceX: 2000, - forceXStrength: 0.02, - forceCollide: 100, - tick: 300, -}; - -/** - * This will return copy of the nods and edges with x,y positions filled in. Also the layout changes source/target props - * in edges from string ids to actual nodes. - * TODO: the typing could probably be done better so it's clear that props are filled in after the layout - */ -export function useLayout( - rawNodes: NodeDatum[], - rawEdges: EdgeDatum[], - config: Config = defaultConfig -): { bounds: Bounds; nodes: NodeDatum[]; edges: EdgeDatum[] } { - const [nodes, setNodes] = useState([]); - const [edges, setEdges] = useState([]); - - // TODO the use effect is probably not needed here right now, but may make sense later if we decide to move the layout - // to webworker or just postpone until other things are rendered. Also right now it memoizes this for us. - useEffect(() => { - if (rawNodes.length === 0) { - return; - } - - // d3 just modifies the nodes directly, so lets make sure we don't leak that outside - const rawNodesCopy = rawNodes.map((n) => ({ ...n })); - const rawEdgesCopy = rawEdges.map((e) => ({ ...e })); - - // Start withs some hardcoded positions so it starts laid out from left to right - let { roots, secondLevelRoots } = initializePositions(rawNodesCopy, rawEdgesCopy); - - // There always seems to be one or more root nodes each with single edge and we want to have them static on the - // left neatly in something like grid layout - [...roots, ...secondLevelRoots].forEach((n, index) => { - n.fx = n.x; - }); - - const simulation = forceSimulation(rawNodesCopy) - .force( - 'link', - forceLink(rawEdgesCopy) - .id((d: any) => d.id) - .distance(config.linkDistance) - .strength(config.linkStrength) - ) - // to keep the left to right layout we add force that pulls all nodes to right but because roots are fixed it will - // apply only to non root nodes - .force('x', forceX(config.forceX).strength(config.forceXStrength)) - // Make sure nodes don't overlap - .force('collide', forceCollide(config.forceCollide)); - - // 300 ticks for the simulation are recommended but less would probably work too, most movement is done in first - // few iterations and then all the forces gets smaller https://github.com/d3/d3-force#simulation_alphaDecay - simulation.tick(config.tick); - simulation.stop(); - - // We do centering here instead of using centering force to keep this more stable - centerNodes(rawNodesCopy); - setNodes(rawNodesCopy); - setEdges(rawEdgesCopy); - }, [config, rawNodes, rawEdges]); - - return { - nodes, - edges, - bounds: graphBounds(nodes) /* momeoize? loops over all nodes every time and we do it 2 times */, - }; -} - -/** - * This initializes positions of the graph by going from the root to it's children and laying it out in a grid from left - * to right. This works only so, so because service map graphs can have cycles and children levels are not ordered in a - * way to minimize the edge lengths. Nevertheless this seems to make the graph easier to nudge with the forces later on - * than with the d3 default initial positioning. Also we can fix the root positions later on for a bit more neat - * organisation. - * - * This function directly modifies the nodes given and only returns references to root nodes so they do not have to be - * found again later on. - * - * How the spacing could look like approximately: - * 0 - 0 - 0 - 0 - * \- 0 - 0 | - * \- 0 -/ - * 0 - 0 -/ - */ -function initializePositions( - nodes: NodeDatum[], - edges: EdgeDatum[] -): { roots: NodeDatum[]; secondLevelRoots: NodeDatum[] } { - // To prevent going in cycles - const alreadyPositioned: { [id: string]: boolean } = {}; - - const nodesMap = nodes.reduce((acc, node) => ({ ...acc, [node.id]: node }), {} as Record); - const edgesMap = edges.reduce((acc, edge) => { - const sourceId = edge.source as number; - return { - ...acc, - [sourceId]: [...(acc[sourceId] || []), edge], - }; - }, {} as Record); - - let roots = nodes.filter((n) => n.incoming === 0); - - let secondLevelRoots = roots.reduce( - (acc, r) => [...acc, ...(edgesMap[r.id] ? edgesMap[r.id].map((e) => nodesMap[e.target as number]) : [])], - [] - ); - - const rootYSpacing = 300; - const nodeYSpacing = 200; - const nodeXSpacing = 200; - - let rootY = 0; - for (const root of roots) { - let graphLevel = [root]; - let x = 0; - while (graphLevel.length > 0) { - const nextGraphLevel: NodeDatum[] = []; - let y = rootY; - for (const node of graphLevel) { - if (alreadyPositioned[node.id]) { - continue; - } - // Initialize positions based on the spacing in the grid - node.x = x; - node.y = y; - alreadyPositioned[node.id] = true; - - // Move to next Y position for next node - y += nodeYSpacing; - if (edgesMap[node.id]) { - nextGraphLevel.push(...edgesMap[node.id].map((edge) => nodesMap[edge.target as number])); - } - } - - graphLevel = nextGraphLevel; - // Move to next X position for next level - x += nodeXSpacing; - // Reset Y back to baseline for this root - y = rootY; - } - rootY += rootYSpacing; - } - return { roots, secondLevelRoots }; -} - -/** - * Makes sure that the center of the graph based on it's bound is in 0, 0 coordinates. - * Modifies the nodes directly. - */ -function centerNodes(nodes: NodeDatum[]) { - const bounds = graphBounds(nodes); - const middleY = bounds.top + (bounds.bottom - bounds.top) / 2; - const middleX = bounds.left + (bounds.right - bounds.left) / 2; - - for (let node of nodes) { - node.x = node.x! - middleX; - node.y = node.y! - middleY; - } -} - -export interface Bounds { - top: number; - right: number; - bottom: number; - left: number; -} - -/** - * Get bounds of the graph meaning the extent of the nodes in all directions. - */ -function graphBounds(nodes: NodeDatum[]): Bounds { - if (nodes.length === 0) { - return { top: 0, right: 0, bottom: 0, left: 0 }; - } - - return nodes.reduce( - (acc, node) => { - if (node.x! > acc.right) { - acc.right = node.x!; - } - if (node.x! < acc.left) { - acc.left = node.x!; - } - if (node.y! > acc.bottom) { - acc.bottom = node.y!; - } - if (node.y! < acc.top) { - acc.top = node.y!; - } - return acc; - }, - { top: Infinity, right: -Infinity, bottom: -Infinity, left: Infinity } - ); -} diff --git a/packages/grafana-ui/src/components/NodeGraph/types.ts b/packages/grafana-ui/src/components/NodeGraph/types.ts deleted file mode 100644 index f7a7a2ff3d3..00000000000 --- a/packages/grafana-ui/src/components/NodeGraph/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force'; - -export type NodeDatum = SimulationNodeDatum & { - id: string; - title: string; - subTitle: string; - dataFrameRowIndex: number; - incoming: number; - mainStat: string; - secondaryStat: string; - arcSections: Array<{ - value: number; - color: string; - }>; - color: string; -}; -export type EdgeDatum = SimulationLinkDatum & { - id: string; - mainStat: string; - secondaryStat: string; - dataFrameRowIndex: number; -}; diff --git a/packages/grafana-ui/src/components/NodeGraph/useNodeLimit.ts b/packages/grafana-ui/src/components/NodeGraph/useNodeLimit.ts deleted file mode 100644 index 3acbc7e3d0b..00000000000 --- a/packages/grafana-ui/src/components/NodeGraph/useNodeLimit.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useMemo } from 'react'; -import { EdgeDatum, NodeDatum } from './types'; - -/** - * Limits the number of nodes by going from the roots breadth first until we have desired number of nodes. - * TODO: there is some possible perf gains as some of the processing is the same as in layout and so we do double - * the work. - */ -export function useNodeLimit( - nodes: NodeDatum[], - edges: EdgeDatum[], - limit: number -): { nodes: NodeDatum[]; edges: EdgeDatum[] } { - return useMemo(() => { - if (nodes.length <= limit) { - return { nodes, edges }; - } - - const edgesMap = edges.reduce<{ [id: string]: EdgeDatum[] }>((acc, e) => { - const sourceId = e.source as string; - return { - ...acc, - [sourceId]: [...(acc[sourceId] || []), e], - }; - }, {}); - - const nodesMap = nodes.reduce((acc, node) => ({ ...acc, [node.id]: node }), {} as Record); - - let roots = nodes.filter((n) => n.incoming === 0); - const newNodes: Record = {}; - const stack = [...roots]; - - while (Object.keys(newNodes).length < limit && stack.length > 0) { - let current = stack.shift()!; - if (newNodes[current!.id]) { - continue; - } - - newNodes[current.id] = current; - const edges = edgesMap[current.id] || []; - for (const edge of edges) { - stack.push(nodesMap[edge.target as string]); - } - } - - const newEdges = edges.filter((e) => newNodes[e.source as string] && newNodes[e.target as string]); - - return { nodes: Object.values(newNodes), edges: newEdges }; - }, [edges, limit, nodes]); -} diff --git a/packages/grafana-ui/src/components/NodeGraph/utils.test.ts b/packages/grafana-ui/src/components/NodeGraph/utils.test.ts deleted file mode 100644 index c0f7dbb7dba..00000000000 --- a/packages/grafana-ui/src/components/NodeGraph/utils.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { createTheme } from '@grafana/data'; -import { makeEdgesDataFrame, makeNodesDataFrame, processNodes } from './utils'; - -describe('processNodes', () => { - const theme = createTheme(); - - it('handles empty args', async () => { - expect(processNodes(undefined, undefined, theme)).toEqual({ nodes: [], edges: [] }); - }); - - it('returns proper nodes and edges', async () => { - expect( - processNodes( - makeNodesDataFrame(3), - makeEdgesDataFrame([ - [0, 1], - [0, 2], - [1, 2], - ]), - theme - ) - ).toEqual({ - nodes: [ - { - arcSections: [ - { - color: 'green', - value: 0.5, - }, - { - color: 'red', - value: 0.5, - }, - ], - color: 'rgb(226, 192, 61)', - dataFrameRowIndex: 0, - id: '0', - incoming: 0, - mainStat: '0.10', - secondaryStat: '2.00', - subTitle: 'service', - title: 'service:0', - }, - { - arcSections: [ - { - color: 'green', - value: 0.5, - }, - { - color: 'red', - value: 0.5, - }, - ], - color: 'rgb(226, 192, 61)', - dataFrameRowIndex: 1, - id: '1', - incoming: 1, - mainStat: '0.10', - secondaryStat: '2.00', - subTitle: 'service', - title: 'service:1', - }, - { - arcSections: [ - { - color: 'green', - value: 0.5, - }, - { - color: 'red', - value: 0.5, - }, - ], - color: 'rgb(226, 192, 61)', - dataFrameRowIndex: 2, - id: '2', - incoming: 2, - mainStat: '0.10', - secondaryStat: '2.00', - subTitle: 'service', - title: 'service:2', - }, - ], - edges: [ - { - dataFrameRowIndex: 0, - id: '0--1', - mainStat: '', - secondaryStat: '', - source: '0', - target: '1', - }, - { - dataFrameRowIndex: 1, - id: '0--2', - mainStat: '', - secondaryStat: '', - source: '0', - target: '2', - }, - { - dataFrameRowIndex: 2, - id: '1--2', - mainStat: '', - secondaryStat: '', - source: '1', - target: '2', - }, - ], - }); - }); -}); diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx index 8c8ad81b396..f9dca417753 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx @@ -10,7 +10,7 @@ import { mapMouseEventToMode } from './utils'; /** * @public */ -export const VizLegend: React.FunctionComponent = ({ +export function VizLegend({ items, displayMode, sortBy: sortKey, @@ -20,7 +20,8 @@ export const VizLegend: React.FunctionComponent = ({ onToggleSort, placement, className, -}) => { + itemRenderer, +}: LegendProps) { const { eventBus, onToggleSeriesVisibility } = usePanelContext(); const onMouseEnter = useCallback( @@ -73,7 +74,7 @@ export const VizLegend: React.FunctionComponent = ({ switch (displayMode) { case LegendDisplayMode.Table: return ( - className={className} items={items} placement={placement} @@ -83,22 +84,24 @@ export const VizLegend: React.FunctionComponent = ({ onToggleSort={onToggleSort} onLabelMouseEnter={onMouseEnter} onLabelMouseOut={onMouseOut} + itemRenderer={itemRenderer} /> ); case LegendDisplayMode.List: return ( - className={className} items={items} placement={placement} onLabelMouseEnter={onMouseEnter} onLabelMouseOut={onMouseOut} onLabelClick={onLegendLabelClick} + itemRenderer={itemRenderer} /> ); default: return null; } -}; +} VizLegend.displayName = 'Legend'; diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx index b958ab1e1ee..bbc0083b174 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx @@ -7,12 +7,12 @@ import { useStyles } from '../../themes'; import { GrafanaTheme } from '@grafana/data'; import { VizLegendListItem } from './VizLegendListItem'; -export interface Props extends VizLegendBaseProps {} +export interface Props extends VizLegendBaseProps {} /** * @internal */ -export const VizLegendList: React.FunctionComponent = ({ +export const VizLegendList = ({ items, itemRenderer, onLabelMouseEnter, @@ -20,7 +20,7 @@ export const VizLegendList: React.FunctionComponent = ({ onLabelClick, placement, className, -}) => { +}: Props) => { const styles = useStyles(getStyles); if (!itemRenderer) { @@ -35,11 +35,11 @@ export const VizLegendList: React.FunctionComponent = ({ ); } - const getItemKey = (item: VizLegendItem) => `${item.getItemKey ? item.getItemKey() : item.label}`; + const getItemKey = (item: VizLegendItem) => `${item.getItemKey ? item.getItemKey() : item.label}`; switch (placement) { case 'right': { - const renderItem = (item: VizLegendItem, index: number) => { + const renderItem = (item: VizLegendItem, index: number) => { return {itemRenderer!(item, index)}; }; @@ -51,7 +51,7 @@ export const VizLegendList: React.FunctionComponent = ({ } case 'bottom': default: { - const renderItem = (item: VizLegendItem, index: number) => { + const renderItem = (item: VizLegendItem, index: number) => { return {itemRenderer!(item, index)}; }; diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx index 2e5e2291bda..bef07f10cc6 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx @@ -7,10 +7,10 @@ import { useStyles } from '../../themes'; import { GrafanaTheme } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -export interface Props { - item: VizLegendItem; +export interface Props { + item: VizLegendItem; className?: string; - onLabelClick?: (item: VizLegendItem, event: React.MouseEvent) => void; + onLabelClick?: (item: VizLegendItem, event: React.MouseEvent) => void; onLabelMouseEnter?: (item: VizLegendItem, event: React.MouseEvent) => void; onLabelMouseOut?: (item: VizLegendItem, event: React.MouseEvent) => void; } @@ -18,12 +18,13 @@ export interface Props { /** * @internal */ -export const VizLegendListItem: React.FunctionComponent = ({ +export const VizLegendListItem = ({ item, onLabelClick, onLabelMouseEnter, onLabelMouseOut, -}) => { + className, +}: Props) => { const styles = useStyles(getStyles); const onMouseEnter = useCallback( @@ -54,13 +55,16 @@ export const VizLegendListItem: React.FunctionComponent = ({ ); return ( -
+
{item.label}
@@ -75,14 +79,18 @@ VizLegendListItem.displayName = 'VizLegendListItem'; const getStyles = (theme: GrafanaTheme) => ({ label: css` label: LegendLabel; - cursor: pointer; white-space: nowrap; `, + clickable: css` + label: LegendClickabel; + cursor: pointer; + `, labelDisabled: css` label: LegendLabelDisabled; color: ${theme.colors.linkDisabled}; `, itemWrapper: css` + label: LegendItemWrapper; display: flex; white-space: nowrap; align-items: center; diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx index 5949023aef9..ae87bec602c 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import React from 'react'; import { css, cx } from '@emotion/css'; import { VizLegendTableProps } from './types'; import { Icon } from '../Icon/Icon'; @@ -10,7 +10,7 @@ import { GrafanaTheme } from '@grafana/data'; /** * @internal */ -export const VizLegendTable: FC = ({ +export const VizLegendTable = ({ items, sortBy: sortKey, sortDesc, @@ -20,7 +20,7 @@ export const VizLegendTable: FC = ({ onLabelClick, onLabelMouseEnter, onLabelMouseOut, -}) => { +}: VizLegendTableProps): JSX.Element => { const styles = useStyles(getStyles); const columns = items diff --git a/packages/grafana-ui/src/components/VizLegend/types.ts b/packages/grafana-ui/src/components/VizLegend/types.ts index 366f1973313..55e03b6d97d 100644 --- a/packages/grafana-ui/src/components/VizLegend/types.ts +++ b/packages/grafana-ui/src/components/VizLegend/types.ts @@ -7,28 +7,28 @@ export enum SeriesVisibilityChangeBehavior { Hide, } -export interface VizLegendBaseProps { +export interface VizLegendBaseProps { placement: LegendPlacement; className?: string; - items: VizLegendItem[]; + items: Array>; seriesVisibilityChangeBehavior?: SeriesVisibilityChangeBehavior; - onLabelClick?: (item: VizLegendItem, event: React.MouseEvent) => void; - itemRenderer?: (item: VizLegendItem, index: number) => JSX.Element; + onLabelClick?: (item: VizLegendItem, event: React.MouseEvent) => void; + itemRenderer?: (item: VizLegendItem, index: number) => JSX.Element; onLabelMouseEnter?: (item: VizLegendItem, event: React.MouseEvent) => void; onLabelMouseOut?: (item: VizLegendItem, event: React.MouseEvent) => void; } -export interface VizLegendTableProps extends VizLegendBaseProps { +export interface VizLegendTableProps extends VizLegendBaseProps { sortBy?: string; sortDesc?: boolean; onToggleSort?: (sortBy: string) => void; } -export interface LegendProps extends VizLegendBaseProps, VizLegendTableProps { +export interface LegendProps extends VizLegendBaseProps, VizLegendTableProps { displayMode: LegendDisplayMode; } -export interface VizLegendItem { +export interface VizLegendItem { getItemKey?: () => string; label: string; color: string; @@ -37,4 +37,5 @@ export interface VizLegendItem { // displayValues?: DisplayValue[]; getDisplayValues?: () => DisplayValue[]; fieldIndex?: DataFrameFieldIndex; + data?: T; } diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index e8ace482167..b7c45a0817e 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -101,6 +101,7 @@ export { VizLayout, VizLayoutComponentType, VizLayoutLegendProps, VizLayoutProps export { VizLegendItem, SeriesVisibilityChangeBehavior } from './VizLegend/types'; export { LegendPlacement, LegendDisplayMode, VizLegendOptions } from './VizLegend/models.gen'; export { VizLegend } from './VizLegend/VizLegend'; +export { VizLegendListItem } from './VizLegend/VizLegendListItem'; export { Alert, AlertVariant } from './Alert/Alert'; export { GraphSeriesToggler, GraphSeriesTogglerAPI } from './Graph/GraphSeriesToggler'; @@ -238,5 +239,4 @@ export { useGraphNGContext } from './GraphNG/hooks'; export { preparePlotFrame } from './GraphNG/utils'; export { GraphNGLegendEvent } from './GraphNG/types'; export * from './PanelChrome/types'; -export * from './NodeGraph'; export { EmotionPerfTest } from './ThemeDemos/EmotionPerfTest'; diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index 81f1fc7c533..98c851c5c14 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -14,3 +14,4 @@ export { DOMUtil }; export { renderOrCallToRender } from './renderOrCallToRender'; export { createLogger } from './logger'; export { attachDebugger } from './debug'; +export * from './nodeGraph'; diff --git a/packages/grafana-ui/src/utils/nodeGraph.ts b/packages/grafana-ui/src/utils/nodeGraph.ts new file mode 100644 index 00000000000..55a1b917893 --- /dev/null +++ b/packages/grafana-ui/src/utils/nodeGraph.ts @@ -0,0 +1,15 @@ +/** + * @deprecated use it from @grafana/data. Kept here for backward compatibility. + */ +export enum NodeGraphDataFrameFieldNames { + id = 'id', + title = 'title', + subTitle = 'subTitle', + mainStat = 'mainStat', + secondaryStat = 'secondaryStat', + source = 'source', + target = 'target', + detail = 'detail__', + arc = 'arc__', + color = 'color', +} diff --git a/public/app/features/explore/NodeGraphContainer.tsx b/public/app/features/explore/NodeGraphContainer.tsx index 3a06af9f699..07a295b706f 100644 --- a/public/app/features/explore/NodeGraphContainer.tsx +++ b/public/app/features/explore/NodeGraphContainer.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import { Badge, NodeGraph, Collapse } from '@grafana/ui'; +import { Badge, Collapse } from '@grafana/ui'; import { DataFrame, TimeRange } from '@grafana/data'; import { ExploreId, StoreState } from '../../types'; import { splitOpen } from './state/main'; import { connect, ConnectedProps } from 'react-redux'; import { useLinks } from './utils/links'; +import { NodeGraph } from '../../plugins/panel/nodeGraph'; interface Props { // Edges and Nodes are separate frames diff --git a/public/app/plugins/datasource/jaeger/graphTransform.ts b/public/app/plugins/datasource/jaeger/graphTransform.ts index f86208f2192..bbe98a5b129 100644 --- a/public/app/plugins/datasource/jaeger/graphTransform.ts +++ b/public/app/plugins/datasource/jaeger/graphTransform.ts @@ -1,5 +1,4 @@ -import { DataFrame, FieldType, MutableDataFrame } from '@grafana/data'; -import { NodeGraphDataFrameFieldNames as Fields } from '@grafana/ui'; +import { DataFrame, FieldType, MutableDataFrame, NodeGraphDataFrameFieldNames as Fields } from '@grafana/data'; import { Span, TraceResponse } from './types'; interface Node { diff --git a/public/app/plugins/datasource/tempo/graphTransform.ts b/public/app/plugins/datasource/tempo/graphTransform.ts index 2b2d0482924..eefa0f48d73 100644 --- a/public/app/plugins/datasource/tempo/graphTransform.ts +++ b/public/app/plugins/datasource/tempo/graphTransform.ts @@ -1,5 +1,10 @@ -import { DataFrame, DataFrameView, FieldType, MutableDataFrame } from '@grafana/data'; -import { NodeGraphDataFrameFieldNames as Fields } from '@grafana/ui'; +import { + DataFrame, + DataFrameView, + FieldType, + MutableDataFrame, + NodeGraphDataFrameFieldNames as Fields, +} from '@grafana/data'; interface Row { traceID: string; diff --git a/public/app/plugins/datasource/testdata/nodeGraphUtils.ts b/public/app/plugins/datasource/testdata/nodeGraphUtils.ts index 94dba8c0ddf..8e65b2ae0a0 100644 --- a/public/app/plugins/datasource/testdata/nodeGraphUtils.ts +++ b/public/app/plugins/datasource/testdata/nodeGraphUtils.ts @@ -1,6 +1,5 @@ -import { ArrayVector, FieldType, MutableDataFrame } from '@grafana/data'; +import { ArrayVector, FieldType, MutableDataFrame, NodeGraphDataFrameFieldNames } from '@grafana/data'; import { nodes, edges } from './testData/serviceMapResponse'; -import { NodeGraphDataFrameFieldNames } from '@grafana/ui'; export function generateRandomNodes(count = 10) { const nodes = []; diff --git a/public/app/plugins/datasource/testdata/testData/serviceMapResponse.ts b/public/app/plugins/datasource/testdata/testData/serviceMapResponse.ts index e47a6e1a587..dedde9de77e 100644 --- a/public/app/plugins/datasource/testdata/testData/serviceMapResponse.ts +++ b/public/app/plugins/datasource/testdata/testData/serviceMapResponse.ts @@ -1,5 +1,4 @@ -import { FieldColorModeId, FieldType, PreferredVisualisationType } from '@grafana/data'; -import { NodeGraphDataFrameFieldNames } from '@grafana/ui'; +import { FieldColorModeId, FieldType, PreferredVisualisationType, NodeGraphDataFrameFieldNames } from '@grafana/data'; export const nodes = { fields: [ diff --git a/packages/grafana-ui/src/components/NodeGraph/Edge.tsx b/public/app/plugins/panel/nodeGraph/Edge.tsx similarity index 100% rename from packages/grafana-ui/src/components/NodeGraph/Edge.tsx rename to public/app/plugins/panel/nodeGraph/Edge.tsx diff --git a/packages/grafana-ui/src/components/NodeGraph/EdgeArrowMarker.tsx b/public/app/plugins/panel/nodeGraph/EdgeArrowMarker.tsx similarity index 100% rename from packages/grafana-ui/src/components/NodeGraph/EdgeArrowMarker.tsx rename to public/app/plugins/panel/nodeGraph/EdgeArrowMarker.tsx diff --git a/packages/grafana-ui/src/components/NodeGraph/EdgeLabel.tsx b/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx similarity index 97% rename from packages/grafana-ui/src/components/NodeGraph/EdgeLabel.tsx rename to public/app/plugins/panel/nodeGraph/EdgeLabel.tsx index d5a9964f405..e97f56f4754 100644 --- a/packages/grafana-ui/src/components/NodeGraph/EdgeLabel.tsx +++ b/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx @@ -1,8 +1,8 @@ import React, { memo } from 'react'; import { EdgeDatum, NodeDatum } from './types'; import { css } from '@emotion/css'; -import { useStyles2 } from '../../themes'; import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; import { shortenLine } from './utils'; const getStyles = (theme: GrafanaTheme2) => { diff --git a/public/app/plugins/panel/nodeGraph/Legend.tsx b/public/app/plugins/panel/nodeGraph/Legend.tsx new file mode 100644 index 00000000000..968d7ae7145 --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/Legend.tsx @@ -0,0 +1,88 @@ +import React, { useCallback } from 'react'; +import { NodeDatum } from './types'; +import { Field, FieldColorModeId, getColorForTheme, GrafanaTheme } from '@grafana/data'; +import { identity } from 'lodash'; +import { Config } from './layout'; +import { css } from '@emotion/css'; +import { Icon, LegendDisplayMode, useStyles, useTheme, VizLegend, VizLegendItem, VizLegendListItem } from '@grafana/ui'; + +function getStyles() { + return { + item: css` + label: LegendItem; + flex-grow: 0; + `, + }; +} + +interface Props { + nodes: NodeDatum[]; + onSort: (sort: Config['sort']) => void; + sort?: Config['sort']; + sortable: boolean; +} + +export const Legend = function Legend(props: Props) { + const { nodes, onSort, sort, sortable } = props; + + const theme = useTheme(); + const styles = useStyles(getStyles); + const colorItems = getColorLegendItems(nodes, theme); + + const onClick = useCallback( + (item) => { + onSort({ + field: item.data!.field, + ascending: item.data!.field === sort?.field ? !sort?.ascending : true, + }); + }, + [sort, onSort] + ); + + return ( + + displayMode={LegendDisplayMode.List} + placement={'bottom'} + items={colorItems} + itemRenderer={(item) => { + return ( + <> + + {sortable && + (sort?.field === item.data!.field ? : '')} + + ); + }} + /> + ); +}; + +interface ItemData { + field: Field; +} + +function getColorLegendItems(nodes: NodeDatum[], theme: GrafanaTheme): Array> { + const fields = [nodes[0].mainStat, nodes[0].secondaryStat].filter(identity) as Field[]; + + const node = nodes.find((n) => n.arcSections.length > 0); + if (node) { + if (node.arcSections[0]!.config?.color?.mode === FieldColorModeId.Fixed) { + // We assume in this case we have a set of fixed colors which map neatly into a basic legend. + + // Lets collect and deduplicate as there isn't a requirement for 0 size arc section to be defined + fields.push(...new Set(nodes.map((n) => n.arcSections).flat())); + } else { + // TODO: probably some sort of gradient which we will have to deal with later + return []; + } + } + + return fields.map((f) => { + return { + label: f.config.displayName || f.name, + color: getColorForTheme(f.config.color?.fixedColor || '', theme), + yAxis: 0, + data: { field: f }, + }; + }); +} diff --git a/public/app/plugins/panel/nodeGraph/Marker.tsx b/public/app/plugins/panel/nodeGraph/Marker.tsx new file mode 100644 index 00000000000..cee0ff33b93 --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/Marker.tsx @@ -0,0 +1,61 @@ +import React, { MouseEvent, memo } from 'react'; +import { NodesMarker } from './types'; +import { GrafanaTheme } from '@grafana/data'; +import { css } from 'emotion'; +import { stylesFactory, useTheme } from '@grafana/ui'; + +const nodeR = 40; + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + mainGroup: css` + cursor: pointer; + font-size: 10px; + `, + + mainCircle: css` + fill: ${theme.colors.panelBg}; + stroke: ${theme.colors.border3}; + `, + text: css` + width: 50px; + height: 50px; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + `, +})); + +export const Marker = memo(function Marker(props: { + marker: NodesMarker; + onClick?: (event: MouseEvent, marker: NodesMarker) => void; +}) { + const { marker, onClick } = props; + const { node } = marker; + const styles = getStyles(useTheme()); + + if (!(node.x !== undefined && node.y !== undefined)) { + return null; + } + + return ( + { + onClick?.(event, marker); + }} + aria-label={`Hidden nodes marker: ${node.id}`} + > + + + +
+ {/* we limit the count to 101 so if we have more than 100 nodes we don't have exact count */} + {marker.count > 100 ? '>100' : marker.count} nodes +
+
+
+
+ ); +}); diff --git a/packages/grafana-ui/src/components/NodeGraph/Node.tsx b/public/app/plugins/panel/nodeGraph/Node.tsx similarity index 75% rename from packages/grafana-ui/src/components/NodeGraph/Node.tsx rename to public/app/plugins/panel/nodeGraph/Node.tsx index e3bcacef31f..e7978801cc8 100644 --- a/packages/grafana-ui/src/components/NodeGraph/Node.tsx +++ b/public/app/plugins/panel/nodeGraph/Node.tsx @@ -1,31 +1,32 @@ import React, { MouseEvent, memo } from 'react'; -import { css } from '@emotion/css'; -import tinycolor from 'tinycolor2'; import cx from 'classnames'; -import { getColorForTheme, GrafanaTheme } from '@grafana/data'; +import { getColorForTheme, GrafanaTheme2 } from '@grafana/data'; +import { useStyles2, useTheme } from '@grafana/ui'; import { NodeDatum } from './types'; -import { stylesFactory, useTheme } from '../../themes'; +import { css } from 'emotion'; +import tinycolor from 'tinycolor2'; +import { statToString } from './utils'; const nodeR = 40; -const getStyles = stylesFactory((theme: GrafanaTheme) => ({ +const getStyles = (theme: GrafanaTheme2) => ({ mainGroup: css` cursor: pointer; font-size: 10px; `, mainCircle: css` - fill: ${theme.colors.panelBg}; + fill: ${theme.components.panel.background}; `, hoverCircle: css` opacity: 0.5; fill: transparent; - stroke: ${theme.colors.textBlue}; + stroke: ${theme.colors.primary.text}; `, text: css` - fill: ${theme.colors.text}; + fill: ${theme.colors.text.primary}; `, titleText: css` @@ -33,7 +34,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({ text-overflow: ellipsis; overflow: hidden; white-space: nowrap; - background-color: ${tinycolor(theme.colors.bodyBg).setAlpha(0.6).toHex8String()}; + background-color: ${tinycolor(theme.colors.background.primary).setAlpha(0.6).toHex8String()}; width: 100px; `, @@ -48,10 +49,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({ textHovering: css` width: 200px; & span { - background-color: ${tinycolor(theme.colors.bodyBg).setAlpha(0.8).toHex8String()}; + background-color: ${tinycolor(theme.colors.background.primary).setAlpha(0.8).toHex8String()}; } `, -})); +}); export const Node = memo(function Node(props: { node: NodeDatum; @@ -61,7 +62,7 @@ export const Node = memo(function Node(props: { hovering: boolean; }) { const { node, onMouseEnter, onMouseLeave, onClick, hovering } = props; - const styles = getStyles(useTheme()); + const styles = useStyles2(getStyles); if (!(node.x !== undefined && node.y !== undefined)) { return null; @@ -69,6 +70,7 @@ export const Node = memo(function Node(props: { return ( { onMouseEnter(node.id); @@ -87,9 +89,9 @@ export const Node = memo(function Node(props: {
- {node.mainStat} + {node.mainStat && statToString(node.mainStat, node.dataFrameRowIndex)}
- {node.secondaryStat} + {node.secondaryStat && statToString(node.secondaryStat, node.dataFrameRowIndex)}
s.value === 1); + const fullStat = node.arcSections.find((s) => s.values.get(node.dataFrameRowIndex) === 1); const theme = useTheme(); if (fullStat) { @@ -122,7 +124,7 @@ function ColorCircle(props: { node: NodeDatum }) { return ( s.value !== 0); + const nonZero = node.arcSections.filter((s) => s.values.get(node.dataFrameRowIndex) !== 0); if (nonZero.length === 0) { // Fallback if no arc is defined return ; @@ -139,20 +141,22 @@ function ColorCircle(props: { node: NodeDatum }) { const { elements } = nonZero.reduce( (acc, section) => { + const color = section.config.color?.fixedColor || ''; + const value = section.values.get(node.dataFrameRowIndex); const el = ( ); acc.elements.push(el); - acc.percent = acc.percent + section.value; + acc.percent = acc.percent + value; return acc; }, { elements: [] as React.ReactNode[], percent: 0 } diff --git a/packages/grafana-ui/src/components/NodeGraph/NodeGraph.test.tsx b/public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx similarity index 60% rename from packages/grafana-ui/src/components/NodeGraph/NodeGraph.test.tsx rename to public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx index e9416706a24..9317dc065e6 100644 --- a/packages/grafana-ui/src/components/NodeGraph/NodeGraph.test.tsx +++ b/public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx @@ -4,6 +4,25 @@ import userEvent from '@testing-library/user-event'; import { NodeGraph } from './NodeGraph'; import { makeEdgesDataFrame, makeNodesDataFrame } from './utils'; +jest.mock('./layout.worker.js', () => { + const { layout } = jest.requireActual('./layout.worker.js'); + class TestWorker { + constructor() {} + postMessage(data: any) { + const { nodes, edges, config } = data; + setTimeout(() => { + layout(nodes, edges, config); + // @ts-ignore + this.onmessage({ data: { nodes, edges } }); + }, 1); + } + } + return { + __esModule: true, + default: TestWorker, + }; +}); + describe('NodeGraph', () => { it('doesnt fail without any data', async () => { render( []} />); @@ -13,9 +32,6 @@ describe('NodeGraph', () => { render( []} />); const zoomIn = await screen.findByTitle(/Zoom in/); const zoomOut = await screen.findByTitle(/Zoom out/); - const zoomLevel = await screen.findByTitle(/Zoom level/); - - expect(zoomLevel.textContent).toContain('1.00x'); expect(getScale()).toBe(1); userEvent.click(zoomIn); @@ -38,7 +54,10 @@ describe('NodeGraph', () => { /> ); + await screen.findByLabelText('Node: service:1'); + panView({ x: 10, y: 10 }); + screen.debug(getSvg()); // Though we try to pan down 10px we are rendering in straight line 3 nodes so there are bounds preventing // as panning vertically await waitFor(() => expect(getTranslate()).toEqual({ x: 10, y: 0 })); @@ -78,7 +97,7 @@ describe('NodeGraph', () => { await screen.findByText(/Edge traces/); }); - it('lays out 3 nodes in single line', () => { + it('lays out 3 nodes in single line', async () => { render( { /> ); - expectNodePositionCloseTo('service:0', { x: -221, y: 0 }); - expectNodePositionCloseTo('service:1', { x: -21, y: 0 }); - expectNodePositionCloseTo('service:2', { x: 221, y: 0 }); + await expectNodePositionCloseTo('service:0', { x: -221, y: 0 }); + await expectNodePositionCloseTo('service:1', { x: -21, y: 0 }); + await expectNodePositionCloseTo('service:2', { x: 221, y: 0 }); }); - it('lays out first children on one vertical line', () => { + it('lays out first children on one vertical line', async () => { render( { ); // Should basically look like < - expectNodePositionCloseTo('service:0', { x: -100, y: 0 }); - expectNodePositionCloseTo('service:1', { x: 100, y: -100 }); - expectNodePositionCloseTo('service:2', { x: 100, y: 100 }); + await expectNodePositionCloseTo('service:0', { x: -100, y: 0 }); + await expectNodePositionCloseTo('service:1', { x: 100, y: -100 }); + await expectNodePositionCloseTo('service:2', { x: 100, y: 100 }); }); - it('limits the number of nodes shown and shows a warning', () => { + it('limits the number of nodes shown and shows a warning', async () => { render( []} @@ -132,20 +153,76 @@ describe('NodeGraph', () => { /> ); - const nodes = screen.getAllByLabelText(/Node: service:\d/); + const nodes = await screen.findAllByLabelText(/Node: service:\d/); expect(nodes.length).toBe(2); screen.getByLabelText(/Nodes hidden warning/); + + const markers = await screen.findAllByLabelText(/Hidden nodes marker: \d/); + expect(markers.length).toBe(1); + }); + + it('allows expanding the nodes when limiting visible nodes', async () => { + render( + []} + nodeLimit={3} + /> + ); + + const node = await screen.findByLabelText(/Node: service:0/); + expect(node).toBeInTheDocument(); + + const marker = await screen.findByLabelText(/Hidden nodes marker: 3/); + userEvent.click(marker); + + expect(screen.queryByLabelText(/Node: service:0/)).not.toBeInTheDocument(); + expect(screen.getByLabelText(/Node: service:4/)).toBeInTheDocument(); + + const nodes = await screen.findAllByLabelText(/Node: service:\d/); + expect(nodes.length).toBe(3); + }); + + it('can switch to grid layout', async () => { + render( + []} + nodeLimit={3} + /> + ); + + const button = await screen.findByTitle(/Grid layout/); + userEvent.click(button); + + await expectNodePositionCloseTo('service:0', { x: -180, y: -60 }); + await expectNodePositionCloseTo('service:1', { x: -60, y: -60 }); + await expectNodePositionCloseTo('service:2', { x: 60, y: -60 }); }); }); -function expectNodePositionCloseTo(node: string, pos: { x: number; y: number }) { - const nodePos = getNodeXY(node); +async function expectNodePositionCloseTo(node: string, pos: { x: number; y: number }) { + const nodePos = await getNodeXY(node); expect(nodePos.x).toBeCloseTo(pos.x, -1); expect(nodePos.y).toBeCloseTo(pos.y, -1); } -function getNodeXY(node: string) { - const group = screen.getByLabelText(new RegExp(`Node: ${node}`)); +async function getNodeXY(node: string) { + const group = await screen.findByLabelText(new RegExp(`Node: ${node}`)); const circle = getByText(group, '', { selector: 'circle' }); return getXY(circle); } diff --git a/packages/grafana-ui/src/components/NodeGraph/NodeGraph.tsx b/public/app/plugins/panel/nodeGraph/NodeGraph.tsx similarity index 58% rename from packages/grafana-ui/src/components/NodeGraph/NodeGraph.tsx rename to public/app/plugins/panel/nodeGraph/NodeGraph.tsx index fc7008edb5b..57a43d8857e 100644 --- a/packages/grafana-ui/src/components/NodeGraph/NodeGraph.tsx +++ b/public/app/plugins/panel/nodeGraph/NodeGraph.tsx @@ -1,26 +1,29 @@ -import React, { memo, MutableRefObject, useCallback, useMemo, useState, MouseEvent } from 'react'; +import React, { memo, MouseEvent, MutableRefObject, useCallback, useMemo, useState } from 'react'; import cx from 'classnames'; import useMeasure from 'react-use/lib/useMeasure'; +import { Icon, Spinner, useStyles2, useTheme2 } from '@grafana/ui'; import { usePanning } from './usePanning'; -import { EdgeDatum, NodeDatum } from './types'; +import { EdgeDatum, NodeDatum, NodesMarker } from './types'; import { Node } from './Node'; import { Edge } from './Edge'; import { ViewControls } from './ViewControls'; import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data'; import { useZoom } from './useZoom'; -import { Bounds, Config, defaultConfig, useLayout } from './layout'; +import { Config, defaultConfig, useLayout } from './layout'; import { EdgeArrowMarker } from './EdgeArrowMarker'; -import { stylesFactory, useTheme2 } from '../../themes'; import { css } from '@emotion/css'; import { useCategorizeFrames } from './useCategorizeFrames'; import { EdgeLabel } from './EdgeLabel'; import { useContextMenu } from './useContextMenu'; -import { processNodes } from './utils'; -import { Icon } from '..'; -import { useNodeLimit } from './useNodeLimit'; +import { processNodes, Bounds } from './utils'; +import { Marker } from './Marker'; +import { Legend } from './Legend'; +import { useHighlight } from './useHighlight'; +import { useFocusPositionOnLayout } from './useFocusPositionOnLayout'; -const getStyles = stylesFactory((theme: GrafanaTheme2) => ({ +const getStyles = (theme: GrafanaTheme2) => ({ wrapper: css` + label: wrapper; height: 100%; width: 100%; overflow: hidden; @@ -28,6 +31,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => ({ `, svg: css` + label: svg; height: 100%; width: 100%; overflow: visible; @@ -36,19 +40,34 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => ({ `, svgPanning: css` + label: svgPanning; user-select: none; `, mainGroup: css` + label: mainGroup; will-change: transform; `, viewControls: css` + label: viewControls; position: absolute; - left: 10px; - top: 10px; + left: 2px; + bottom: 3px; + right: 0; + display: flex; + align-items: flex-end; + justify-content: space-between; + `, + legend: css` + label: legend; + background: ${theme.colors.background.secondary}; + box-shadow: ${theme.shadows.z1}; + padding-bottom: 5px; + margin-right: 10px; `, alert: css` + label: alert; padding: 5px 8px; font-size: 10px; text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2); @@ -60,10 +79,19 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => ({ background: ${theme.colors.warning.main}; color: ${theme.colors.warning.contrastText}; `, -})); + loadingWrapper: css` + label: loadingWrapper; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + `, +}); -// This is mainly for performance reasons. -const defaultNodeCountLimit = 1500; +// Limits the number of visible nodes, mainly for performance reasons. Nodes above the limit are accessible by expanding +// parts of the graph. The specific number is arbitrary but should be a number of nodes where panning, zooming and other +// interactions will be without any lag for most users. +const defaultNodeCountLimit = 200; interface Props { dataFrames: DataFrame[]; @@ -79,10 +107,7 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) { // We need hover state here because for nodes we also highlight edges and for edges have labels separate to make // sure they are visible on top of everything else - const [nodeHover, setNodeHover] = useState(undefined); - const clearNodeHover = useCallback(() => setNodeHover(undefined), [setNodeHover]); - const [edgeHover, setEdgeHover] = useState(undefined); - const clearEdgeHover = useCallback(() => setEdgeHover(undefined), [setEdgeHover]); + const { nodeHover, setNodeHover, clearNodeHover, edgeHover, setEdgeHover, clearEdgeHover } = useHover(); const firstNodesDataFrame = nodesDataFrames[0]; const firstEdgesDataFrame = edgesDataFrames[0]; @@ -97,15 +122,38 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) { theme, ]); - const { nodes: rawNodes, edges: rawEdges } = useNodeLimit(processed.nodes, processed.edges, nodeCountLimit); - const hiddenNodesCount = processed.nodes.length - rawNodes.length; + // This is used for navigation from grid to graph view. This node will be centered and briefly highlighted. + const [focusedNodeId, setFocusedNodeId] = useState(); + const setFocused = useCallback((e: MouseEvent, m: NodesMarker) => setFocusedNodeId(m.node.id), [setFocusedNodeId]); - const { nodes, edges, bounds } = useLayout(rawNodes, rawEdges, config); - const { panRef, zoomRef, onStepUp, onStepDown, isPanning, position, scale, isMaxZoom, isMinZoom } = usePanAndZoom( - bounds + // May seem weird that we do layout first and then limit the nodes shown but the problem is we want to keep the node + // position stable which means we need the full layout first and then just visually hide the nodes. As hiding/showing + // nodes should not have effect on layout it should not be recalculated. + const { nodes, edges, markers, bounds, hiddenNodesCount, loading } = useLayout( + processed.nodes, + processed.edges, + config, + nodeCountLimit, + focusedNodeId ); - const { onEdgeOpen, onNodeOpen, MenuComponent } = useContextMenu(getLinks, nodesDataFrames[0], edgesDataFrames[0]); - const styles = getStyles(theme); + + // If we move from grid to graph layout and we have focused node lets get it's position to center there. We want do + // do it specifically only in that case. + const focusPosition = useFocusPositionOnLayout(config, nodes, focusedNodeId); + const { panRef, zoomRef, onStepUp, onStepDown, isPanning, position, scale, isMaxZoom, isMinZoom } = usePanAndZoom( + bounds, + focusPosition + ); + + const { onEdgeOpen, onNodeOpen, MenuComponent } = useContextMenu( + getLinks, + firstNodesDataFrame, + firstEdgesDataFrame, + config, + setConfig, + setFocusedNodeId + ); + const styles = useStyles2(getStyles); // This cannot be inline func or it will create infinite render cycle. const topLevelRef = useCallback( @@ -116,8 +164,17 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) { [measureRef, zoomRef] ); + const highlightId = useHighlight(focusedNodeId); + return (
+ {loading ? ( +
+ Computing layout  + +
+ ) : null} + - + {!config.gridLayout && ( + + )} + + {/*We split the labels from edges so that they are shown on top of everything else*/} - + {!config.gridLayout && }
+ {nodes.length && ( +
+ { + setConfig({ + ...config, + sort: sort, + }); + }} + /> +
+ )} + config={config} - onConfigChange={setConfig} + onConfigChange={(cfg) => { + if (cfg.gridLayout !== config.gridLayout) { + setFocusedNodeId(undefined); + } + setConfig(cfg); + }} onMinus={onStepDown} onPlus={onStepUp} scale={scale} @@ -171,7 +253,7 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) { ); } -// These 3 components are here as a perf optimisation to prevent going through all nodes and edges on every pan/zoom. +// These components are here as a perf optimisation to prevent going through all nodes and edges on every pan/zoom. interface NodesProps { nodes: NodeDatum[]; @@ -197,6 +279,20 @@ const Nodes = memo(function Nodes(props: NodesProps) { ); }); +interface MarkersProps { + markers: NodesMarker[]; + onClick: (event: MouseEvent, marker: NodesMarker) => void; +} +const Markers = memo(function Nodes(props: MarkersProps) { + return ( + <> + {props.markers.map((m) => ( + + ))} + + ); +}); + interface EdgesProps { edges: EdgeDatum[]; nodeHoveringId?: string; @@ -246,12 +342,22 @@ const EdgeLabels = memo(function EdgeLabels(props: EdgeLabelsProps) { ); }); -function usePanAndZoom(bounds: Bounds) { +function usePanAndZoom(bounds: Bounds, focus?: { x: number; y: number }) { const { scale, onStepDown, onStepUp, ref, isMax, isMin } = useZoom(); const { state: panningState, ref: panRef } = usePanning({ scale, bounds, + focus, }); const { position, isPanning } = panningState; return { zoomRef: ref, panRef, position, isPanning, scale, onStepDown, onStepUp, isMaxZoom: isMax, isMinZoom: isMin }; } + +function useHover() { + const [nodeHover, setNodeHover] = useState(undefined); + const clearNodeHover = useCallback(() => setNodeHover(undefined), [setNodeHover]); + const [edgeHover, setEdgeHover] = useState(undefined); + const clearEdgeHover = useCallback(() => setEdgeHover(undefined), [setEdgeHover]); + + return { nodeHover, setNodeHover, clearNodeHover, edgeHover, setEdgeHover, clearEdgeHover }; +} diff --git a/public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx b/public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx index 9ffefbdd820..c61ce2984bf 100644 --- a/public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx +++ b/public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { PanelProps } from '@grafana/data'; import { Options } from './types'; -import { NodeGraph } from '@grafana/ui'; +import { NodeGraph } from './NodeGraph'; import { useLinks } from '../../../features/explore/utils/links'; export const NodeGraphPanel: React.FunctionComponent> = ({ width, height, data }) => { diff --git a/packages/grafana-ui/src/components/NodeGraph/ViewControls.tsx b/public/app/plugins/panel/nodeGraph/ViewControls.tsx similarity index 50% rename from packages/grafana-ui/src/components/NodeGraph/ViewControls.tsx rename to public/app/plugins/panel/nodeGraph/ViewControls.tsx index a1bcb0e244e..4654186a4ab 100644 --- a/packages/grafana-ui/src/components/NodeGraph/ViewControls.tsx +++ b/public/app/plugins/panel/nodeGraph/ViewControls.tsx @@ -1,21 +1,5 @@ import React, { useState } from 'react'; -import { Button } from '../Button'; -import { stylesFactory, useTheme } from '../../themes'; -import { GrafanaTheme } from '@grafana/data'; -import { css } from '@emotion/css'; -import { HorizontalGroup } from '..'; - -const getStyles = stylesFactory((theme: GrafanaTheme) => ({ - scale: css` - font-size: ${theme.typography.size.sm}; - color: ${theme.colors.textFaint}; - `, - - scrollHelp: css` - font-size: ${theme.typography.size.xs}; - color: ${theme.colors.textFaint}; - `, -})); +import { Button, HorizontalGroup, VerticalGroup } from '@grafana/ui'; interface Props { config: Config; @@ -31,38 +15,53 @@ interface Props { * Control buttons for zoom but also some layout config inputs mainly for debugging. */ export function ViewControls>(props: Props) { - const { config, onConfigChange, onPlus, onMinus, scale, disableZoomOut, disableZoomIn } = props; + const { config, onConfigChange, onPlus, onMinus, disableZoomOut, disableZoomIn } = props; const [showConfig, setShowConfig] = useState(false); - const styles = getStyles(useTheme()); // For debugging the layout, should be removed here and maybe moved to panel config later on const allowConfiguration = false; return ( - <> - -
))} - +
); } diff --git a/public/app/plugins/panel/nodeGraph/index.ts b/public/app/plugins/panel/nodeGraph/index.ts new file mode 100644 index 00000000000..ab8f952ff37 --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/index.ts @@ -0,0 +1 @@ +export { NodeGraph } from './NodeGraph'; diff --git a/public/app/plugins/panel/nodeGraph/layout.ts b/public/app/plugins/panel/nodeGraph/layout.ts new file mode 100644 index 00000000000..349feabcd0b --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/layout.ts @@ -0,0 +1,183 @@ +import { useEffect, useMemo, useState } from 'react'; +import { EdgeDatum, EdgeDatumLayout, NodeDatum } from './types'; +import { Field } from '@grafana/data'; +import { useNodeLimit } from './useNodeLimit'; +import useMountedState from 'react-use/lib/useMountedState'; +import { graphBounds } from './utils'; +// @ts-ignore +import LayoutWorker from './layout.worker.js'; + +export interface Config { + linkDistance: number; + linkStrength: number; + forceX: number; + forceXStrength: number; + forceCollide: number; + tick: number; + gridLayout: boolean; + sort?: { + // Either a arc field or stats field + field: Field; + ascending: boolean; + }; +} + +// Config mainly for the layout but also some other parts like current layout. The layout variables can be changed only +// if you programmatically enable the config editor (for development only) see ViewControls. These could be moved to +// panel configuration at some point (apart from gridLayout as that can be switched be user right now.). +export const defaultConfig: Config = { + linkDistance: 150, + linkStrength: 0.5, + forceX: 2000, + forceXStrength: 0.02, + forceCollide: 100, + tick: 300, + gridLayout: false, +}; + +/** + * This will return copy of the nods and edges with x,y positions filled in. Also the layout changes source/target props + * in edges from string ids to actual nodes. + */ +export function useLayout( + rawNodes: NodeDatum[], + rawEdges: EdgeDatum[], + config: Config = defaultConfig, + nodeCountLimit: number, + rootNodeId?: string +) { + const [nodesGrid, setNodesGrid] = useState([]); + const [edgesGrid, setEdgesGrid] = useState([]); + + const [nodesGraph, setNodesGraph] = useState([]); + const [edgesGraph, setEdgesGraph] = useState([]); + + const [loading, setLoading] = useState(false); + + const isMounted = useMountedState(); + + // Also we compute both layouts here. Grid layout should not add much time and we can more easily just cache both + // so this should happen only once for a given response data. + // + // Also important note is that right now this works on all the nodes even if they are not visible. This means that + // the node position is stable even when expanding different parts of graph. It seems like a reasonable thing but + // implications are that: + // - limiting visible nodes count does not have a positive perf effect + // - graphs with high node count can seem weird (very sparse or spread out) when we show only some nodes but layout + // is done for thousands of nodes but we also do this only once in the graph lifecycle. + // We could re-layout this on visible nodes change but this may need smaller visible node limit to keep the perf + // (as we would run layout on every click) and also would be very weird without any animation to understand what is + // happening as already visible nodes would change positions. + useEffect(() => { + if (rawNodes.length === 0) { + return; + } + + setLoading(true); + + // d3 just modifies the nodes directly, so lets make sure we don't leak that outside + let rawNodesCopy = rawNodes.map((n) => ({ ...n })); + let rawEdgesCopy = rawEdges.map((e) => ({ ...e })); + + // This is async but as I wanted to still run the sync grid layout and you cannot return promise from effect having + // callback seem ok here. + defaultLayout(rawNodesCopy, rawEdgesCopy, ({ nodes, edges }) => { + // TODO: it would be better to cancel the worker somehow but probably not super important right now. + if (isMounted()) { + setNodesGraph(nodes); + setEdgesGraph(edges as EdgeDatumLayout[]); + setLoading(false); + } + }); + + rawNodesCopy = rawNodes.map((n) => ({ ...n })); + rawEdgesCopy = rawEdges.map((e) => ({ ...e })); + gridLayout(rawNodesCopy, config.sort); + + setNodesGrid(rawNodesCopy); + setEdgesGrid(rawEdgesCopy as EdgeDatumLayout[]); + }, [config.sort, rawNodes, rawEdges, isMounted]); + + // Limit the nodes so we don't show all for performance reasons. Here we don't compute both at the same time so + // changing the layout can trash internal memoization at the moment. + const { nodes: nodesWithLimit, edges: edgesWithLimit, markers } = useNodeLimit( + config.gridLayout ? nodesGrid : nodesGraph, + config.gridLayout ? edgesGrid : edgesGraph, + nodeCountLimit, + config, + rootNodeId + ); + + // Get bounds based on current limited number of nodes. + const bounds = useMemo(() => graphBounds([...nodesWithLimit, ...(markers || []).map((m) => m.node)]), [ + nodesWithLimit, + markers, + ]); + + return { + nodes: nodesWithLimit, + edges: edgesWithLimit, + markers, + bounds, + hiddenNodesCount: rawNodes.length - nodesWithLimit.length, + loading, + }; +} + +/** + * Wraps the layout code in a worker as it can take long and we don't want to block the main thread. + */ +function defaultLayout( + nodes: NodeDatum[], + edges: EdgeDatum[], + done: (data: { nodes: NodeDatum[]; edges: EdgeDatum[] }) => void +) { + const worker = new LayoutWorker(); + worker.onmessage = (event: MessageEvent<{ nodes: NodeDatum[]; edges: EdgeDatumLayout[] }>) => { + for (let i = 0; i < nodes.length; i++) { + // These stats needs to be Field class but the data is stringified over the worker boundary + event.data.nodes[i] = { + ...event.data.nodes[i], + mainStat: nodes[i].mainStat, + secondaryStat: nodes[i].secondaryStat, + arcSections: nodes[i].arcSections, + }; + } + done(event.data); + }; + + worker.postMessage({ nodes, edges, config: defaultConfig }); +} + +/** + * Set the nodes in simple grid layout sorted by some stat. + */ +function gridLayout( + nodes: NodeDatum[], + sort?: { + field: Field; + ascending: boolean; + } +) { + const spacingVertical = 140; + const spacingHorizontal = 120; + // TODO probably make this based on the width of the screen + const perRow = 4; + + if (sort) { + nodes.sort((node1, node2) => { + const val1 = sort!.field.values.get(node1.dataFrameRowIndex); + const val2 = sort!.field.values.get(node2.dataFrameRowIndex); + + // Lets pretend we don't care about type for a while + return sort!.ascending ? val2 - val1 : val1 - val2; + }); + } + + for (const [index, node] of nodes.entries()) { + const row = Math.floor(index / perRow); + const column = index % perRow; + node.x = -180 + column * spacingHorizontal; + node.y = -60 + row * spacingVertical; + } +} diff --git a/public/app/plugins/panel/nodeGraph/layout.worker.js b/public/app/plugins/panel/nodeGraph/layout.worker.js new file mode 100644 index 00000000000..a943cf68b7e --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/layout.worker.js @@ -0,0 +1,176 @@ +import { forceSimulation, forceLink, forceCollide, forceX } from 'd3-force'; + +addEventListener('message', (event) => { + const { nodes, edges, config } = event.data; + layout(nodes, edges, config); + postMessage({ nodes, edges }); +}); + +/** + * Use d3 force layout to lay the nodes in a sensible way. This function modifies the nodes adding the x,y positions + * and also fills in node references in edges instead of node ids. + */ +export function layout(nodes, edges, config) { + // Start with some hardcoded positions so it starts laid out from left to right + let { roots, secondLevelRoots } = initializePositions(nodes, edges); + + // There always seems to be one or more root nodes each with single edge and we want to have them static on the + // left neatly in something like grid layout + [...roots, ...secondLevelRoots].forEach((n, index) => { + n.fx = n.x; + }); + + const simulation = forceSimulation(nodes) + .force( + 'link', + forceLink(edges) + .id((d) => d.id) + .distance(config.linkDistance) + .strength(config.linkStrength) + ) + // to keep the left to right layout we add force that pulls all nodes to right but because roots are fixed it will + // apply only to non root nodes + .force('x', forceX(config.forceX).strength(config.forceXStrength)) + // Make sure nodes don't overlap + .force('collide', forceCollide(config.forceCollide)); + + // 300 ticks for the simulation are recommended but less would probably work too, most movement is done in first + // few iterations and then all the forces gets smaller https://github.com/d3/d3-force#simulation_alphaDecay + simulation.tick(config.tick); + simulation.stop(); + + // We do centering here instead of using centering force to keep this more stable + centerNodes(nodes); +} + +/** + * This initializes positions of the graph by going from the root to it's children and laying it out in a grid from left + * to right. This works only so, so because service map graphs can have cycles and children levels are not ordered in a + * way to minimize the edge lengths. Nevertheless this seems to make the graph easier to nudge with the forces later on + * than with the d3 default initial positioning. Also we can fix the root positions later on for a bit more neat + * organisation. + * + * This function directly modifies the nodes given and only returns references to root nodes so they do not have to be + * found again later on. + * + * How the spacing could look like approximately: + * 0 - 0 - 0 - 0 + * \- 0 - 0 | + * \- 0 -/ + * 0 - 0 -/ + */ +function initializePositions(nodes, edges) { + // To prevent going in cycles + const alreadyPositioned = {}; + + const nodesMap = nodes.reduce((acc, node) => { + acc[node.id] = node; + return acc; + }, {}); + const edgesMap = edges.reduce((acc, edge) => { + const sourceId = edge.source; + acc[sourceId] = [...(acc[sourceId] || []), edge]; + return acc; + }, {}); + + let roots = nodes.filter((n) => n.incoming === 0); + + // For things like service maps we assume there is some root (client) node but if there is none then selecting + // any node as a starting point should work the same. + if (!roots.length) { + roots = [nodes[0]]; + } + + let secondLevelRoots = roots.reduce((acc, r) => { + acc.push(...(edgesMap[r.id] ? edgesMap[r.id].map((e) => nodesMap[e.target]) : [])); + return acc; + }, []); + + const rootYSpacing = 300; + const nodeYSpacing = 200; + const nodeXSpacing = 200; + + let rootY = 0; + for (const root of roots) { + let graphLevel = [root]; + let x = 0; + while (graphLevel.length > 0) { + const nextGraphLevel = []; + let y = rootY; + for (const node of graphLevel) { + if (alreadyPositioned[node.id]) { + continue; + } + // Initialize positions based on the spacing in the grid + node.x = x; + node.y = y; + alreadyPositioned[node.id] = true; + + // Move to next Y position for next node + y += nodeYSpacing; + if (edgesMap[node.id]) { + nextGraphLevel.push(...edgesMap[node.id].map((edge) => nodesMap[edge.target])); + } + } + + graphLevel = nextGraphLevel; + // Move to next X position for next level + x += nodeXSpacing; + // Reset Y back to baseline for this root + y = rootY; + } + rootY += rootYSpacing; + } + return { roots, secondLevelRoots }; +} + +/** + * Makes sure that the center of the graph based on it's bound is in 0, 0 coordinates. + * Modifies the nodes directly. + */ +function centerNodes(nodes) { + const bounds = graphBounds(nodes); + for (let node of nodes) { + node.x = node.x - bounds.center.x; + node.y = node.y - bounds.center.y; + } +} + +/** + * Get bounds of the graph meaning the extent of the nodes in all directions. + */ +function graphBounds(nodes) { + if (nodes.length === 0) { + return { top: 0, right: 0, bottom: 0, left: 0, center: { x: 0, y: 0 } }; + } + + const bounds = nodes.reduce( + (acc, node) => { + if (node.x > acc.right) { + acc.right = node.x; + } + if (node.x < acc.left) { + acc.left = node.x; + } + if (node.y > acc.bottom) { + acc.bottom = node.y; + } + if (node.y < acc.top) { + acc.top = node.y; + } + return acc; + }, + { top: Infinity, right: -Infinity, bottom: -Infinity, left: Infinity } + ); + + const y = bounds.top + (bounds.bottom - bounds.top) / 2; + const x = bounds.left + (bounds.right - bounds.left) / 2; + + return { + ...bounds, + center: { + x, + y, + }, + }; +} diff --git a/public/app/plugins/panel/nodeGraph/types.ts b/public/app/plugins/panel/nodeGraph/types.ts index 88025b922d0..03bce5f7df2 100644 --- a/public/app/plugins/panel/nodeGraph/types.ts +++ b/public/app/plugins/panel/nodeGraph/types.ts @@ -1 +1,41 @@ +import { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force'; +import { Field } from '@grafana/data'; + export interface Options {} + +export type NodeDatum = SimulationNodeDatum & { + id: string; + title: string; + subTitle: string; + dataFrameRowIndex: number; + incoming: number; + mainStat?: Field; + secondaryStat?: Field; + arcSections: Field[]; + color: string; +}; + +// This is the data we have before the graph is laid out with source and target being string IDs. +type LinkDatum = SimulationLinkDatum & { + source: string; + target: string; +}; + +// This is some additional data we expect with the edges. +export type EdgeDatum = LinkDatum & { + id: string; + mainStat: string; + secondaryStat: string; + dataFrameRowIndex: number; +}; + +// After layout is run D3 will change the string IDs for actual references to the nodes. +export type EdgeDatumLayout = EdgeDatum & { + source: NodeDatum; + target: NodeDatum; +}; + +export type NodesMarker = { + node: NodeDatum; + count: number; +}; diff --git a/packages/grafana-ui/src/components/NodeGraph/useCategorizeFrames.ts b/public/app/plugins/panel/nodeGraph/useCategorizeFrames.ts similarity index 100% rename from packages/grafana-ui/src/components/NodeGraph/useCategorizeFrames.ts rename to public/app/plugins/panel/nodeGraph/useCategorizeFrames.ts diff --git a/packages/grafana-ui/src/components/NodeGraph/useContextMenu.tsx b/public/app/plugins/panel/nodeGraph/useContextMenu.tsx similarity index 54% rename from packages/grafana-ui/src/components/NodeGraph/useContextMenu.tsx rename to public/app/plugins/panel/nodeGraph/useContextMenu.tsx index 060850ff1b1..daf87c69e52 100644 --- a/packages/grafana-ui/src/components/NodeGraph/useContextMenu.tsx +++ b/public/app/plugins/panel/nodeGraph/useContextMenu.tsx @@ -1,13 +1,10 @@ import React, { MouseEvent, useCallback, useState } from 'react'; import { EdgeDatum, NodeDatum } from './types'; import { DataFrame, Field, GrafanaTheme, LinkModel } from '@grafana/data'; -import { ContextMenu } from '../ContextMenu/ContextMenu'; -import { useTheme } from '../../themes/ThemeContext'; -import { stylesFactory } from '../../themes/stylesFactory'; import { getEdgeFields, getNodeFields } from './utils'; import { css } from '@emotion/css'; -import { MenuGroup } from '../Menu/MenuGroup'; -import { MenuItem } from '../Menu/MenuItem'; +import { Config } from './layout'; +import { ContextMenu, MenuGroup, MenuItem, stylesFactory, useTheme } from '@grafana/ui'; /** * Hook that contains state of the context menu, both for edges and nodes and provides appropriate component when @@ -16,83 +13,113 @@ import { MenuItem } from '../Menu/MenuItem'; export function useContextMenu( getLinks: (dataFrame: DataFrame, rowIndex: number) => LinkModel[], nodes: DataFrame, - edges: DataFrame + edges: DataFrame, + config: Config, + setConfig: (config: Config) => void, + setFocusedNodeId: (id: string) => void ): { onEdgeOpen: (event: MouseEvent, edge: EdgeDatum) => void; onNodeOpen: (event: MouseEvent, node: NodeDatum) => void; MenuComponent: React.ReactNode; } { - const [openedNode, setOpenedNode] = useState<{ node: NodeDatum; event: MouseEvent } | undefined>(undefined); - const onNodeOpen = useCallback((event, node) => setOpenedNode({ node, event }), []); + const [menu, setMenu] = useState(undefined); - const [openedEdge, setOpenedEdge] = useState<{ edge: EdgeDatum; event: MouseEvent } | undefined>(undefined); - const onEdgeOpen = useCallback((event, edge) => setOpenedEdge({ edge, event }), []); + const onNodeOpen = useCallback( + (event, node) => { + const extraNodeItem = config.gridLayout + ? [ + { + label: 'Show in Graph layout', + onClick: (node: NodeDatum) => { + setFocusedNodeId(node.id); + setConfig({ ...config, gridLayout: false }); + }, + }, + ] + : undefined; + const renderer = getItemsRenderer(getLinks(nodes, node.dataFrameRowIndex), node, extraNodeItem); - let MenuComponent = null; + if (renderer) { + setMenu( + } + renderMenuItems={renderer} + onClose={() => setMenu(undefined)} + x={event.pageX} + y={event.pageY} + /> + ); + } + }, + [config, nodes, getLinks, setMenu, setConfig, setFocusedNodeId] + ); - if (openedNode) { - const items = getItems(getLinks(nodes, openedNode.node.dataFrameRowIndex)); - const renderMenuGroupItems = () => { - return items?.map((group, index) => ( - - {(group.items || []).map((item) => ( - - ))} - - )); - }; - if (items.length) { - MenuComponent = ( - } - renderMenuItems={renderMenuGroupItems} - onClose={() => setOpenedNode(undefined)} - x={openedNode.event.pageX} - y={openedNode.event.pageY} - /> - ); - } - } + const onEdgeOpen = useCallback( + (event, edge) => { + const renderer = getItemsRenderer(getLinks(edges, edge.dataFrameRowIndex), edge); - if (openedEdge) { - const items = getItems(getLinks(edges, openedEdge.edge.dataFrameRowIndex)); - const renderMenuGroupItems = () => { - return items?.map((group, index) => ( - - {(group.items || []).map((item) => ( - - ))} - - )); - }; - if (items.length) { - MenuComponent = ( - } - renderMenuItems={renderMenuGroupItems} - onClose={() => setOpenedEdge(undefined)} - x={openedEdge.event.pageX} - y={openedEdge.event.pageY} - /> - ); - } - } + if (renderer) { + setMenu( + } + renderMenuItems={renderer} + onClose={() => setMenu(undefined)} + x={event.pageX} + y={event.pageY} + /> + ); + } + }, + [edges, getLinks, setMenu] + ); - return { onEdgeOpen, onNodeOpen, MenuComponent }; + return { onEdgeOpen, onNodeOpen, MenuComponent: menu }; } +function getItemsRenderer( + links: LinkModel[], + item: T, + extraItems?: Array> | undefined +) { + if (!(links.length || extraItems?.length)) { + return undefined; + } + const items = getItems(links); + return () => { + let groups = items?.map((group, index) => ( + + {(group.items || []).map(mapMenuItem(item))} + + )); + + if (extraItems) { + groups = [...extraItems.map(mapMenuItem(item)), ...groups]; + } + return groups; + }; +} + +function mapMenuItem(item: T) { + return function NodeGraphMenuItem(link: LinkData) { + return ( + link.onClick?.(item) : undefined} + /> + ); + }; +} + +type LinkData = { + label: string; + ariaLabel?: string; + url?: string; + onClick?: (item: T) => void; +}; + function getItems(links: LinkModel[]) { const defaultGroup = 'Open in Explore'; const groups = links.reduce<{ [group: string]: Array<{ l: LinkModel; newTitle?: string }> }>((acc, l) => { diff --git a/public/app/plugins/panel/nodeGraph/useFocusPositionOnLayout.ts b/public/app/plugins/panel/nodeGraph/useFocusPositionOnLayout.ts new file mode 100644 index 00000000000..22da763b92e --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/useFocusPositionOnLayout.ts @@ -0,0 +1,19 @@ +import usePrevious from 'react-use/lib/usePrevious'; +import { Config } from './layout'; +import { NodeDatum } from './types'; + +export function useFocusPositionOnLayout(config: Config, nodes: NodeDatum[], focusedNodeId: string | undefined) { + const prevLayoutGrid = usePrevious(config.gridLayout); + let focusPosition; + if (prevLayoutGrid === true && !config.gridLayout && focusedNodeId) { + const node = nodes.find((n) => n.id === focusedNodeId); + if (node) { + focusPosition = { + x: -node.x!, + y: -node.y!, + }; + } + } + + return focusPosition; +} diff --git a/public/app/plugins/panel/nodeGraph/useHighlight.ts b/public/app/plugins/panel/nodeGraph/useHighlight.ts new file mode 100644 index 00000000000..8f9018249b5 --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/useHighlight.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; + +export function useHighlight(focusedNodeId?: string) { + const [highlightId, setHighlightId] = useState(); + const mounted = useMountedState(); + useEffect(() => { + if (focusedNodeId) { + setHighlightId(focusedNodeId); + setTimeout(() => { + if (mounted()) { + setHighlightId(undefined); + } + }, 500); + } + }, [focusedNodeId, mounted]); + + return highlightId; +} diff --git a/public/app/plugins/panel/nodeGraph/useNodeLimit.ts b/public/app/plugins/panel/nodeGraph/useNodeLimit.ts new file mode 100644 index 00000000000..6f0cbb07680 --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/useNodeLimit.ts @@ -0,0 +1,225 @@ +import { fromPairs, uniq } from 'lodash'; +import { useMemo } from 'react'; +import { EdgeDatumLayout, NodeDatum, NodesMarker } from './types'; +import { Config } from './layout'; + +type NodesMap = Record; +type EdgesMap = Record; + +/** + * Limits the number of nodes by going from the roots breadth first until we have desired number of nodes. + */ +export function useNodeLimit( + nodes: NodeDatum[], + edges: EdgeDatumLayout[], + limit: number, + config: Config, + rootId?: string +): { nodes: NodeDatum[]; edges: EdgeDatumLayout[]; markers?: NodesMarker[] } { + // This is pretty expensive also this happens once in the layout code when initializing position but it's a bit + // tricky to do it only once and reuse the results because layout directly modifies the nodes. + const [edgesMap, nodesMap] = useMemo(() => { + // Make sure we don't compute this until we have all the data. + if (!(nodes.length && edges.length)) { + return [{}, {}]; + } + + const edgesMap = edges.reduce((acc, e) => { + acc[e.source.id] = [...(acc[e.source.id] ?? []), e]; + acc[e.target.id] = [...(acc[e.target.id] ?? []), e]; + return acc; + }, {}); + + const nodesMap = nodes.reduce((acc, node) => { + acc[node.id] = node; + return acc; + }, {}); + return [edgesMap, nodesMap]; + }, [edges, nodes]); + + return useMemo(() => { + if (nodes.length <= limit) { + return { nodes, edges }; + } + + if (config.gridLayout) { + return limitGridLayout(nodes, limit, rootId); + } + + return limitGraphLayout(nodes, edges, nodesMap, edgesMap, limit, rootId); + }, [edges, edgesMap, limit, nodes, nodesMap, rootId, config.gridLayout]); +} + +export function limitGraphLayout( + nodes: NodeDatum[], + edges: EdgeDatumLayout[], + nodesMap: NodesMap, + edgesMap: EdgesMap, + limit: number, + rootId?: string +) { + let roots; + if (rootId) { + roots = [nodesMap[rootId]]; + } else { + roots = nodes.filter((n) => n.incoming === 0); + // TODO: same code as layout + if (!roots.length) { + roots = [nodes[0]]; + } + } + + const { visibleNodes, markers } = collectVisibleNodes(limit, roots, nodesMap, edgesMap); + + const markersWithStats = collectMarkerStats(markers, visibleNodes, nodesMap, edgesMap); + const markersMap = fromPairs(markersWithStats.map((m) => [m.node.id, m])); + + for (const marker of markersWithStats) { + if (marker.count === 1) { + delete markersMap[marker.node.id]; + visibleNodes[marker.node.id] = marker.node; + } + } + + // Show all edges between visible nodes or placeholder markers + const visibleEdges = edges.filter( + (e) => + (visibleNodes[e.source.id] || markersMap[e.source.id]) && (visibleNodes[e.target.id] || markersMap[e.target.id]) + ); + + return { + nodes: Object.values(visibleNodes), + edges: visibleEdges, + markers: Object.values(markersMap), + }; +} + +export function limitGridLayout(nodes: NodeDatum[], limit: number, rootId?: string) { + let start = 0; + let stop = limit; + let markers: NodesMarker[] = []; + + if (rootId) { + const index = nodes.findIndex((node) => node.id === rootId); + const prevLimit = Math.floor(limit / 2); + let afterLimit = prevLimit; + start = index - prevLimit; + if (start < 0) { + afterLimit += Math.abs(start); + start = 0; + } + stop = index + afterLimit + 1; + + if (stop > nodes.length) { + if (start > 0) { + start = Math.max(0, start - (stop - nodes.length)); + } + stop = nodes.length; + } + + if (start > 1) { + markers.push({ node: nodes[start - 1], count: start }); + } + + if (nodes.length - stop > 1) { + markers.push({ node: nodes[stop], count: nodes.length - stop }); + } + } else { + if (nodes.length - limit > 1) { + markers = [{ node: nodes[limit], count: nodes.length - limit }]; + } + } + + return { + nodes: nodes.slice(start, stop), + edges: [], + markers, + }; +} + +/** + * Breath first traverse of the graph collecting all the nodes until we reach the limit. It also returns markers which + * are nodes on the edges which did not make it into the limit but can be used as clickable markers for manually + * expanding the graph. + * @param limit + * @param roots - Nodes where to start the traversal. In case of exploration this can be any node that user clicked on. + * @param nodesMap - Node id to node + * @param edgesMap - This is a map of node id to a list of edges (both ingoing and outgoing) + */ +function collectVisibleNodes( + limit: number, + roots: NodeDatum[], + nodesMap: Record, + edgesMap: Record +): { visibleNodes: Record; markers: NodeDatum[] } { + const visibleNodes: Record = {}; + let stack = [...roots]; + + while (Object.keys(visibleNodes).length < limit && stack.length > 0) { + let current = stack.shift()!; + + // We are already showing this node. This can happen because graphs can be cyclic + if (visibleNodes[current!.id]) { + continue; + } + + // Show this node + visibleNodes[current.id] = current; + const edges = edgesMap[current.id] || []; + + // Add any nodes that are connected to it on top of the stack to be considered in the next pass + const connectedNodes = edges.map((e) => { + // We don't care about direction here. Should not make much difference but argument could be made that with + // directed graphs it should walk the graph directionally. Problem is when we focus on a node in the middle of + // graph (not going from the "natural" root) we also want to show what was "before". + const id = e.source.id === current.id ? e.target.id : e.source.id; + return nodesMap[id]; + }); + stack = stack.concat(connectedNodes); + } + + // Right now our stack contains all the nodes which are directly connected to the graph but did not make the cut. + // Some of them though can be nodes we already are showing so we have to filter them and then use them as markers. + const markers = uniq(stack.filter((n) => !visibleNodes[n.id])); + + return { visibleNodes, markers }; +} + +function collectMarkerStats( + markers: NodeDatum[], + visibleNodes: Record, + nodesMap: Record, + edgesMap: Record +): NodesMarker[] { + return markers.map((marker) => { + const nodesToCount: Record = {}; + let count = 0; + let stack = [marker]; + while (stack.length > 0 && count <= 101) { + let current = stack.shift()!; + + // We are showing this node so not going to count it as hidden. + if (visibleNodes[current.id] || nodesToCount[current.id]) { + continue; + } + + if (!nodesToCount[current.id]) { + count++; + } + nodesToCount[current.id] = current; + + const edges = edgesMap[current.id] || []; + + const connectedNodes = edges.map((e) => { + const id = e.source.id === current.id ? e.target.id : e.source.id; + return nodesMap[id]; + }); + stack = stack.concat(connectedNodes); + } + + return { + node: marker, + count: count, + }; + }); +} diff --git a/packages/grafana-ui/src/components/NodeGraph/usePanning.ts b/public/app/plugins/panel/nodeGraph/usePanning.ts similarity index 60% rename from packages/grafana-ui/src/components/NodeGraph/usePanning.ts rename to public/app/plugins/panel/nodeGraph/usePanning.ts index e0b03476556..e83ff2bdd86 100644 --- a/packages/grafana-ui/src/components/NodeGraph/usePanning.ts +++ b/public/app/plugins/panel/nodeGraph/usePanning.ts @@ -1,5 +1,7 @@ -import { useEffect, useRef, RefObject, useState } from 'react'; +import { useEffect, useRef, RefObject, useState, useMemo } from 'react'; import useMountedState from 'react-use/lib/useMountedState'; +import { Bounds } from './utils'; +import usePrevious from 'react-use/lib/usePrevious'; export interface State { isPanning: boolean; @@ -11,34 +13,53 @@ export interface State { interface Options { scale?: number; - bounds?: { top: number; bottom: number; right: number; left: number }; + bounds?: Bounds; + focus?: { + x: number; + y: number; + }; } /** * Based on https://github.com/streamich/react-use/blob/master/src/useSlider.ts * Returns position x/y coordinates which can be directly used in transform: translate(). - * @param scale Can be used when we want to scale the movement if we are moving a scaled element. We need to do it + * @param scale - Can be used when we want to scale the movement if we are moving a scaled element. We need to do it * here because we don't want to change the pos when scale changes. - * @param bounds If set the panning cannot go outside of those bounds. + * @param bounds - If set the panning cannot go outside of those bounds. + * @param focus - Position to focus on. */ -export function usePanning( - { scale = 1, bounds }: Options = { scale: 1 } -): { state: State; ref: RefObject } { +export function usePanning({ scale = 1, bounds, focus }: Options = {}): { + state: State; + ref: RefObject; +} { const isMounted = useMountedState(); const isPanning = useRef(false); const frame = useRef(0); const panRef = useRef(null); + const initial = { x: 0, y: 0 }; + // As we return a diff of the view port to be applied we need as translate coordinates we have to invert the + // bounds of the content to get the bounds of the view port diff. + const viewBounds = useMemo( + () => ({ + right: bounds ? -bounds.left : Infinity, + left: bounds ? -bounds.right : -Infinity, + bottom: bounds ? -bounds.top : -Infinity, + top: bounds ? -bounds.bottom : Infinity, + }), + [bounds] + ); + // We need to keep some state so we can compute the position diff and add that to the previous position. - const startMousePosition = useRef({ x: 0, y: 0 }); - const prevPosition = useRef({ x: 0, y: 0 }); + const startMousePosition = useRef(initial); + const prevPosition = useRef(initial); // We cannot use the state as that would rerun the effect on each state change which we don't want so we have to keep // separate variable for the state that won't cause useEffect eval - const currentPosition = useRef({ x: 0, y: 0 }); + const currentPosition = useRef(initial); const [state, setState] = useState({ isPanning: false, - position: { x: 0, y: 0 }, + position: initial, }); useEffect(() => { @@ -92,8 +113,8 @@ export function usePanning( // Add the diff to the position from the moment we started panning. currentPosition.current = { - x: inBounds(prevPosition.current.x + xDiff / scale, bounds?.left, bounds?.right), - y: inBounds(prevPosition.current.y + yDiff / scale, bounds?.top, bounds?.bottom), + x: inBounds(prevPosition.current.x + xDiff / scale, viewBounds.left, viewBounds.right), + y: inBounds(prevPosition.current.y + yDiff / scale, viewBounds.top, viewBounds.bottom), }; setState((state) => ({ ...state, @@ -116,9 +137,45 @@ export function usePanning( ref.removeEventListener('touchstart', onPanStart); } }; - }, [scale, bounds?.left, bounds?.right, bounds?.top, bounds?.bottom, isMounted]); + }, [scale, viewBounds, isMounted]); - return { state, ref: panRef }; + const previousFocus = usePrevious(focus); + + // We need to update the state in case need to focus on something but we want to do it only once when the focus + // changes to something new. + useEffect(() => { + if (focus && previousFocus?.x !== focus.x && previousFocus?.y !== focus.y) { + const position = { + x: inBounds(focus.x, viewBounds.left, viewBounds.right), + y: inBounds(focus.y, viewBounds.top, viewBounds.bottom), + }; + setState({ + position, + isPanning: false, + }); + + currentPosition.current = position; + prevPosition.current = position; + } + }, [focus, previousFocus, viewBounds, currentPosition, prevPosition]); + + let position = state.position; + // This part prevents an ugly jump from initial position to the focused one as the set state in the effects is after + // initial render. + if (focus && previousFocus?.x !== focus.x && previousFocus?.y !== focus.y) { + position = focus; + } + + return { + state: { + ...state, + position: { + x: inBounds(position.x, viewBounds.left, viewBounds.right), + y: inBounds(position.y, viewBounds.top, viewBounds.bottom), + }, + }, + ref: panRef, + }; } function inBounds(value: number, min: number | undefined, max: number | undefined) { diff --git a/packages/grafana-ui/src/components/NodeGraph/useZoom.ts b/public/app/plugins/panel/nodeGraph/useZoom.ts similarity index 100% rename from packages/grafana-ui/src/components/NodeGraph/useZoom.ts rename to public/app/plugins/panel/nodeGraph/useZoom.ts diff --git a/public/app/plugins/panel/nodeGraph/utils.test.ts b/public/app/plugins/panel/nodeGraph/utils.test.ts new file mode 100644 index 00000000000..e214cad28d6 --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/utils.test.ts @@ -0,0 +1,195 @@ +import { ArrayVector, createTheme } from '@grafana/data'; +import { makeEdgesDataFrame, makeNodesDataFrame, processNodes } from './utils'; + +describe('processNodes', () => { + const theme = createTheme(); + + it('handles empty args', async () => { + expect(processNodes(undefined, undefined, theme)).toEqual({ nodes: [], edges: [] }); + }); + + it('returns proper nodes and edges', async () => { + const { nodes, edges, legend } = processNodes( + makeNodesDataFrame(3), + makeEdgesDataFrame([ + [0, 1], + [0, 2], + [1, 2], + ]), + theme + ); + + expect(nodes).toEqual([ + { + arcSections: [ + { + config: { + color: { + fixedColor: 'green', + }, + }, + name: 'arc__success', + type: 'number', + values: new ArrayVector([0.5, 0.5, 0.5]), + }, + { + config: { + color: { + fixedColor: 'red', + }, + }, + name: 'arc__errors', + type: 'number', + values: new ArrayVector([0.5, 0.5, 0.5]), + }, + ], + color: 'rgb(226, 192, 61)', + dataFrameRowIndex: 0, + id: '0', + incoming: 0, + mainStat: { + config: {}, + index: 3, + name: 'mainStat', + type: 'number', + values: new ArrayVector([0.1, 0.1, 0.1]), + }, + secondaryStat: { + config: {}, + index: 4, + name: 'secondaryStat', + type: 'number', + values: new ArrayVector([2, 2, 2]), + }, + subTitle: 'service', + title: 'service:0', + }, + { + arcSections: [ + { + config: { + color: { + fixedColor: 'green', + }, + }, + name: 'arc__success', + type: 'number', + values: new ArrayVector([0.5, 0.5, 0.5]), + }, + { + config: { + color: { + fixedColor: 'red', + }, + }, + name: 'arc__errors', + type: 'number', + values: new ArrayVector([0.5, 0.5, 0.5]), + }, + ], + color: 'rgb(226, 192, 61)', + dataFrameRowIndex: 1, + id: '1', + incoming: 1, + mainStat: { + config: {}, + index: 3, + name: 'mainStat', + type: 'number', + values: new ArrayVector([0.1, 0.1, 0.1]), + }, + secondaryStat: { + config: {}, + index: 4, + name: 'secondaryStat', + type: 'number', + values: new ArrayVector([2, 2, 2]), + }, + subTitle: 'service', + title: 'service:1', + }, + { + arcSections: [ + { + config: { + color: { + fixedColor: 'green', + }, + }, + name: 'arc__success', + type: 'number', + values: new ArrayVector([0.5, 0.5, 0.5]), + }, + { + config: { + color: { + fixedColor: 'red', + }, + }, + name: 'arc__errors', + type: 'number', + values: new ArrayVector([0.5, 0.5, 0.5]), + }, + ], + color: 'rgb(226, 192, 61)', + dataFrameRowIndex: 2, + id: '2', + incoming: 2, + mainStat: { + config: {}, + index: 3, + name: 'mainStat', + type: 'number', + values: new ArrayVector([0.1, 0.1, 0.1]), + }, + secondaryStat: { + config: {}, + index: 4, + name: 'secondaryStat', + type: 'number', + values: new ArrayVector([2, 2, 2]), + }, + subTitle: 'service', + title: 'service:2', + }, + ]); + + expect(edges).toEqual([ + { + dataFrameRowIndex: 0, + id: '0--1', + mainStat: '', + secondaryStat: '', + source: '0', + target: '1', + }, + { + dataFrameRowIndex: 1, + id: '0--2', + mainStat: '', + secondaryStat: '', + source: '0', + target: '2', + }, + { + dataFrameRowIndex: 2, + id: '1--2', + mainStat: '', + secondaryStat: '', + source: '1', + target: '2', + }, + ]); + + expect(legend).toEqual([ + { + color: 'green', + name: 'arc__success', + }, + { + color: 'red', + name: 'arc__errors', + }, + ]); + }); +}); diff --git a/packages/grafana-ui/src/components/NodeGraph/utils.ts b/public/app/plugins/panel/nodeGraph/utils.ts similarity index 71% rename from packages/grafana-ui/src/components/NodeGraph/utils.ts rename to public/app/plugins/panel/nodeGraph/utils.ts index c37a81bff7f..a12d367fbed 100644 --- a/packages/grafana-ui/src/components/NodeGraph/utils.ts +++ b/public/app/plugins/panel/nodeGraph/utils.ts @@ -7,9 +7,9 @@ import { getFieldColorModeForField, GrafanaTheme2, MutableDataFrame, + NodeGraphDataFrameFieldNames, } from '@grafana/data'; import { EdgeDatum, NodeDatum } from './types'; -import { NodeGraphDataFrameFieldNames } from './index'; type Line = { x1: number; y1: number; x2: number; y2: number }; @@ -38,26 +38,26 @@ export function shortenLine(line: Line, length: number): Line { export function getNodeFields(nodes: DataFrame) { const fieldsCache = new FieldCache(nodes); return { - id: fieldsCache.getFieldByName(DataFrameFieldNames.id), - title: fieldsCache.getFieldByName(DataFrameFieldNames.title), - subTitle: fieldsCache.getFieldByName(DataFrameFieldNames.subTitle), - mainStat: fieldsCache.getFieldByName(DataFrameFieldNames.mainStat), - secondaryStat: fieldsCache.getFieldByName(DataFrameFieldNames.secondaryStat), - arc: findFieldsByPrefix(nodes, DataFrameFieldNames.arc), - details: findFieldsByPrefix(nodes, DataFrameFieldNames.detail), - color: fieldsCache.getFieldByName(DataFrameFieldNames.color), + id: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id), + title: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.title), + subTitle: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.subTitle), + mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat), + secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat), + arc: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.arc), + details: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.detail), + color: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.color), }; } export function getEdgeFields(edges: DataFrame) { const fieldsCache = new FieldCache(edges); return { - id: fieldsCache.getFieldByName(DataFrameFieldNames.id), - source: fieldsCache.getFieldByName(DataFrameFieldNames.source), - target: fieldsCache.getFieldByName(DataFrameFieldNames.target), - mainStat: fieldsCache.getFieldByName(DataFrameFieldNames.mainStat), - secondaryStat: fieldsCache.getFieldByName(DataFrameFieldNames.secondaryStat), - details: findFieldsByPrefix(edges, DataFrameFieldNames.detail), + id: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id), + source: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.source), + target: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.target), + mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat), + secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat), + details: findFieldsByPrefix(edges, NodeGraphDataFrameFieldNames.detail), }; } @@ -65,19 +65,6 @@ function findFieldsByPrefix(frame: DataFrame, prefix: string) { return frame.fields.filter((f) => f.name.match(new RegExp('^' + prefix))); } -export enum DataFrameFieldNames { - id = 'id', - title = 'title', - subTitle = 'subTitle', - mainStat = 'mainStat', - secondaryStat = 'secondaryStat', - source = 'source', - target = 'target', - detail = 'detail__', - arc = 'arc__', - color = 'color', -} - /** * Transform nodes and edges dataframes into array of objects that the layout code can then work with. */ @@ -85,7 +72,14 @@ export function processNodes( nodes: DataFrame | undefined, edges: DataFrame | undefined, theme: GrafanaTheme2 -): { nodes: NodeDatum[]; edges: EdgeDatum[] } { +): { + nodes: NodeDatum[]; + edges: EdgeDatum[]; + legend?: Array<{ + color: string; + name: string; + }>; +} { if (!nodes) { return { nodes: [], edges: [] }; } @@ -103,14 +97,9 @@ export function processNodes( subTitle: nodeFields.subTitle ? nodeFields.subTitle.values.get(index) : '', dataFrameRowIndex: index, incoming: 0, - mainStat: nodeFields.mainStat ? statToString(nodeFields.mainStat, index) : '', - secondaryStat: nodeFields.secondaryStat ? statToString(nodeFields.secondaryStat, index) : '', - arcSections: nodeFields.arc.map((f) => { - return { - value: f.values.get(index), - color: f.config.color?.fixedColor || '', - }; - }), + mainStat: nodeFields.mainStat, + secondaryStat: nodeFields.secondaryStat, + arcSections: nodeFields.arc, color: nodeFields.color ? getColor(nodeFields.color, index, theme) : '', }; return acc; @@ -144,10 +133,16 @@ export function processNodes( return { nodes: Object.values(nodesMap), edges: edgesMapped || [], + legend: nodeFields.arc.map((f) => { + return { + color: f.config.color?.fixedColor ?? '', + name: f.config.displayName || f.name, + }; + }), }; } -function statToString(field: Field, index: number) { +export function statToString(field: Field, index: number) { if (field.type === FieldType.string) { return field.values.get(index); } else { @@ -283,3 +278,53 @@ function getColor(field: Field, index: number, theme: GrafanaTheme2): string { return getFieldColorModeForField(field).getCalculator(field, theme)(0, field.values.get(index)); } + +export interface Bounds { + top: number; + right: number; + bottom: number; + left: number; + center: { + x: number; + y: number; + }; +} + +/** + * Get bounds of the graph meaning the extent of the nodes in all directions. + */ +export function graphBounds(nodes: NodeDatum[]): Bounds { + if (nodes.length === 0) { + return { top: 0, right: 0, bottom: 0, left: 0, center: { x: 0, y: 0 } }; + } + + const bounds = nodes.reduce( + (acc, node) => { + if (node.x! > acc.right) { + acc.right = node.x!; + } + if (node.x! < acc.left) { + acc.left = node.x!; + } + if (node.y! > acc.bottom) { + acc.bottom = node.y!; + } + if (node.y! < acc.top) { + acc.top = node.y!; + } + return acc; + }, + { top: Infinity, right: -Infinity, bottom: -Infinity, left: Infinity } + ); + + const y = bounds.top + (bounds.bottom - bounds.top) / 2; + const x = bounds.left + (bounds.right - bounds.left) / 2; + + return { + ...bounds, + center: { + x, + y, + }, + }; +} diff --git a/scripts/webpack/webpack.common.js b/scripts/webpack/webpack.common.js index a3e5752cb18..f894f2dff95 100644 --- a/scripts/webpack/webpack.common.js +++ b/scripts/webpack/webpack.common.js @@ -158,6 +158,10 @@ module.exports = { loader: 'file-loader', options: { name: 'static/img/[name].[hash:8].[ext]' }, }, + { + test: /\.worker\.js$/, + use: { loader: 'worker-loader' }, + }, ], }, // https://webpack.js.org/plugins/split-chunks-plugin/#split-chunks-example-3 diff --git a/yarn.lock b/yarn.lock index 0e55f5c19e2..0a3a94e693d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25547,6 +25547,14 @@ worker-farm@^1.7.0: dependencies: errno "~0.1.7" +worker-loader@^3.0.8: + version "3.0.8" + resolved "https://registry.yarnpkg.com/worker-loader/-/worker-loader-3.0.8.tgz#5fc5cda4a3d3163d9c274a4e3a811ce8b60dbb37" + integrity sha512-XQyQkIFeRVC7f7uRhFdNMe/iJOdO6zxAaR3EWbDp45v3mDhrTi+++oswKNxShUNjPC/1xUp5DB29YKLhFo129g== + dependencies: + loader-utils "^2.0.0" + schema-utils "^3.0.0" + worker-rpc@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/worker-rpc/-/worker-rpc-0.1.1.tgz#cb565bd6d7071a8f16660686051e969ad32f54d5"