From 15330c3e66155e1e84f9db664cf7d1e994e6b12f Mon Sep 17 00:00:00 2001 From: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Wed, 2 Apr 2025 15:56:22 +0100 Subject: [PATCH] NodeGraph: Add node graph algorithm layout option (#102760) * Add layout buttons * Add config for node graph panel * Tests * Update test * Updates * Move grid button and cache nodes * Remove limit and add warning * Update default --- .../panelcfg/x/NodeGraphPanelCfg_types.gen.ts | 10 ++ .../NodeGraph/NodeGraphContainer.test.tsx | 35 +++++++ .../explore/NodeGraph/NodeGraphContainer.tsx | 8 +- .../panel/nodeGraph/NodeGraph.test.tsx | 28 +++++- .../app/plugins/panel/nodeGraph/NodeGraph.tsx | 97 +++++++++++++++--- .../panel/nodeGraph/NodeGraphPanel.tsx | 3 +- .../plugins/panel/nodeGraph/ViewControls.tsx | 18 ---- public/app/plugins/panel/nodeGraph/layout.ts | 98 +++++++++++++++++-- public/app/plugins/panel/nodeGraph/module.tsx | 14 ++- .../app/plugins/panel/nodeGraph/panelcfg.cue | 5 +- .../plugins/panel/nodeGraph/panelcfg.gen.ts | 10 ++ 11 files changed, 278 insertions(+), 48 deletions(-) diff --git a/packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts index b6c0e360636..db41a34dc9e 100644 --- a/packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts @@ -26,6 +26,12 @@ export enum ZoomMode { Greedy = 'greedy', } +export enum LayoutAlgorithm { + Force = 'force', + Grid = 'grid', + Layered = 'layered', +} + export interface Options { edges?: { /** @@ -37,6 +43,10 @@ export interface Options { */ secondaryStatUnit?: string; }; + /** + * How to layout the nodes in the node graph + */ + layoutAlgorithm?: LayoutAlgorithm; nodes?: { /** * Unit for the main stat to override what ever is set in the data frame. diff --git a/public/app/features/explore/NodeGraph/NodeGraphContainer.test.tsx b/public/app/features/explore/NodeGraph/NodeGraphContainer.test.tsx index 0b21c899eb2..a4172d54ea6 100644 --- a/public/app/features/explore/NodeGraph/NodeGraphContainer.test.tsx +++ b/public/app/features/explore/NodeGraph/NodeGraphContainer.test.tsx @@ -1,9 +1,44 @@ import { render, screen } from '@testing-library/react'; import { getDefaultTimeRange, MutableDataFrame } from '@grafana/data'; +import { NodeDatum } from 'app/plugins/panel/nodeGraph/types'; import { UnconnectedNodeGraphContainer } from './NodeGraphContainer'; +jest.mock('../../../plugins/panel/nodeGraph/createLayoutWorker', () => { + const createMockWorker = () => { + const onmessage = jest.fn(); + const postMessage = jest.fn(); + const terminate = jest.fn(); + + const worker = { + onmessage: onmessage, + postMessage: postMessage, + terminate: terminate, + }; + + postMessage.mockImplementation((data) => { + if (worker.onmessage) { + const event = { + data: { + nodes: (data.nodes || []).map((n: NodeDatum) => ({ ...n, x: 0, y: 0 })), + edges: data.edges || [], + }, + }; + setTimeout(() => worker.onmessage(event), 0); + } + }); + + return worker; + }; + + return { + __esModule: true, + createWorker: createMockWorker, + createMsaglWorker: createMockWorker, + }; +}); + describe('NodeGraphContainer', () => { it('is collapsed if shown with traces', () => { const { container } = render( diff --git a/public/app/features/explore/NodeGraph/NodeGraphContainer.tsx b/public/app/features/explore/NodeGraph/NodeGraphContainer.tsx index adaa5e058d6..1c3c12a1794 100644 --- a/public/app/features/explore/NodeGraph/NodeGraphContainer.tsx +++ b/public/app/features/explore/NodeGraph/NodeGraphContainer.tsx @@ -6,8 +6,10 @@ import { useToggle, useWindowSize } from 'react-use'; import { applyFieldOverrides, DataFrame, GrafanaTheme2, SplitOpen } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; import { useStyles2, useTheme2, PanelChrome } from '@grafana/ui'; +import { layeredLayoutThreshold } from 'app/plugins/panel/nodeGraph/NodeGraph'; import { NodeGraph } from '../../../plugins/panel/nodeGraph'; +import { LayoutAlgorithm } from '../../../plugins/panel/nodeGraph/panelcfg.gen'; import { useCategorizeFrames } from '../../../plugins/panel/nodeGraph/useCategorizeFrames'; import { StoreState } from '../../../types'; import { useLinks } from '../utils/links'; @@ -57,6 +59,10 @@ export function UnconnectedNodeGraphContainer(props: Props) { const { nodes } = useCategorizeFrames(frames); const [collapsed, toggleCollapsed] = useToggle(true); + // Determine default layout algorithm based on node count + const nodeCount = nodes[0]?.length || 0; + const layoutAlgorithm = nodeCount > layeredLayoutThreshold ? LayoutAlgorithm.Force : LayoutAlgorithm.Layered; + const toggled = () => { toggleCollapsed(); reportInteraction('grafana_traces_node_graph_panel_clicked', { @@ -103,7 +109,7 @@ export function UnconnectedNodeGraphContainer(props: Props) { } } > - + ); diff --git a/public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx b/public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx index 437c20de0af..a6765cda0ab 100644 --- a/public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx +++ b/public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx @@ -2,9 +2,20 @@ import { render, screen, fireEvent, waitFor, getByText } from '@testing-library/ import userEvent from '@testing-library/user-event'; import { NodeGraph } from './NodeGraph'; -import { ZoomMode } from './panelcfg.gen'; +import { LayoutAlgorithm, ZoomMode } from './panelcfg.gen'; import { makeEdgesDataFrame, makeNodesDataFrame } from './utils'; +jest.mock('./layout', () => { + const actual = jest.requireActual('./layout'); + return { + ...actual, + defaultConfig: { + ...actual.defaultConfig, + layoutAlgorithm: 'force', + }, + }; +}); + jest.mock('react-use/lib/useMeasure', () => { return { __esModule: true, @@ -26,6 +37,7 @@ describe('NodeGraph', () => { []} + layoutAlgorithm={LayoutAlgorithm.Force} /> ); const zoomIn = await screen.findByTitle(/Zoom in/); @@ -44,6 +56,7 @@ describe('NodeGraph', () => { dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]} zoomMode={ZoomMode.Cooperative} getLinks={() => []} + layoutAlgorithm={LayoutAlgorithm.Force} /> ); @@ -62,6 +75,7 @@ describe('NodeGraph', () => { dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]} zoomMode={ZoomMode.Greedy} getLinks={() => []} + layoutAlgorithm={LayoutAlgorithm.Force} /> ); @@ -85,6 +99,7 @@ describe('NodeGraph', () => { ]), ]} getLinks={() => []} + layoutAlgorithm={LayoutAlgorithm.Force} /> ); @@ -97,7 +112,9 @@ describe('NodeGraph', () => { }); it('renders with single node', async () => { - render( []} />); + render( + []} layoutAlgorithm={LayoutAlgorithm.Force} /> + ); const circle = await screen.findByText('', { selector: 'circle' }); await screen.findByText(/service:0/); expect(getXY(circle)).toEqual({ x: 0, y: 0 }); @@ -117,6 +134,7 @@ describe('NodeGraph', () => { }, ]; }} + layoutAlgorithm={LayoutAlgorithm.Force} /> ); @@ -146,6 +164,7 @@ describe('NodeGraph', () => { ]), ]} getLinks={() => []} + layoutAlgorithm={LayoutAlgorithm.Force} /> ); @@ -165,6 +184,7 @@ describe('NodeGraph', () => { ]), ]} getLinks={() => []} + layoutAlgorithm={LayoutAlgorithm.Force} /> ); @@ -188,6 +208,7 @@ describe('NodeGraph', () => { ]} getLinks={() => []} nodeLimit={2} + layoutAlgorithm={LayoutAlgorithm.Force} /> ); @@ -213,6 +234,7 @@ describe('NodeGraph', () => { ]} getLinks={() => []} nodeLimit={3} + layoutAlgorithm={LayoutAlgorithm.Force} /> ); @@ -244,7 +266,7 @@ describe('NodeGraph', () => { /> ); - const button = await screen.findByTitle(/Grid layout/); + const button = await screen.findByText('Grid'); await userEvent.click(button); await expectNodePositionCloseTo('service:0', { x: -60, y: -60 }); diff --git a/public/app/plugins/panel/nodeGraph/NodeGraph.tsx b/public/app/plugins/panel/nodeGraph/NodeGraph.tsx index ffd741ca52c..b07512ee3c0 100644 --- a/public/app/plugins/panel/nodeGraph/NodeGraph.tsx +++ b/public/app/plugins/panel/nodeGraph/NodeGraph.tsx @@ -1,10 +1,10 @@ import { css } from '@emotion/css'; import cx from 'classnames'; -import { memo, MouseEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { memo, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import useMeasure from 'react-use/lib/useMeasure'; import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data'; -import { Icon, Spinner, useStyles2 } from '@grafana/ui'; +import { Icon, RadioButtonGroup, Spinner, useStyles2 } from '@grafana/ui'; import { Edge } from './Edge'; import { EdgeLabel } from './EdgeLabel'; @@ -12,7 +12,8 @@ import { Legend } from './Legend'; import { Marker } from './Marker'; import { Node } from './Node'; import { ViewControls } from './ViewControls'; -import { Config, defaultConfig, useLayout } from './layout'; +import { Config, defaultConfig, useLayout, LayoutCache } from './layout'; +import { LayoutAlgorithm } from './panelcfg.gen'; import { EdgeDatumLayout, NodeDatum, NodesMarker, ZoomMode } from './types'; import { useCategorizeFrames } from './useCategorizeFrames'; import { useContextMenu } from './useContextMenu'; @@ -70,6 +71,14 @@ const getStyles = (theme: GrafanaTheme2) => ({ justifyContent: 'space-between', pointerEvents: 'none', }), + layoutAlgorithm: css({ + label: 'layoutAlgorithm', + pointerEvents: 'all', + position: 'absolute', + top: '8px', + right: '8px', + zIndex: 1, + }), legend: css({ label: 'legend', background: theme.colors.background.secondary, @@ -88,7 +97,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ borderRadius: theme.shape.radius.default, alignItems: 'center', position: 'absolute', - top: 0, right: 0, background: theme.colors.warning.main, color: theme.colors.warning.contrastText, @@ -107,20 +115,39 @@ const getStyles = (theme: GrafanaTheme2) => ({ // interactions will be without any lag for most users. const defaultNodeCountLimit = 200; +export const layeredLayoutThreshold = 500; + interface Props { dataFrames: DataFrame[]; getLinks: (dataFrame: DataFrame, rowIndex: number) => LinkModel[]; nodeLimit?: number; panelId?: string; zoomMode?: ZoomMode; + layoutAlgorithm?: LayoutAlgorithm; } -export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode }: Props) { +export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode, layoutAlgorithm }: Props) { const nodeCountLimit = nodeLimit || defaultNodeCountLimit; const { edges: edgesDataFrames, nodes: nodesDataFrames } = useCategorizeFrames(dataFrames); const [measureRef, { width, height }] = useMeasure(); const [config, setConfig] = useState(defaultConfig); + // Layout cache to avoid recalculating layouts + const layoutCacheRef = useRef({}); + + // Update the config when layoutAlgorithm changes via the panel options + useEffect(() => { + if (layoutAlgorithm) { + setConfig((prevConfig) => { + return { + ...prevConfig, + gridLayout: layoutAlgorithm === LayoutAlgorithm.Grid, + layoutAlgorithm, + }; + }); + } + }, [layoutAlgorithm]); + const firstNodesDataFrame = nodesDataFrames[0]; const firstEdgesDataFrame = edgesDataFrames[0]; @@ -166,7 +193,8 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode } nodeCountLimit, width, focusedNodeId, - processed.hasFixedPositions + processed.hasFixedPositions, + layoutCacheRef.current ); // If we move from grid to graph layout, and we have focused node lets get its position to center there. We want to @@ -199,6 +227,18 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode } const highlightId = useHighlight(focusedNodeId); + const handleLayoutChange = (cfg: Config) => { + if (cfg.layoutAlgorithm !== config.layoutAlgorithm) { + setFocusedNodeId(undefined); + } + setConfig(cfg); + }; + + // Clear the layout cache when data changes + useEffect(() => { + layoutCacheRef.current = {}; + }, [firstNodesDataFrame, firstEdgesDataFrame]); + return (
{loading ? ( @@ -208,6 +248,27 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode }
) : null} + {!panelId && ( +
+ { + handleLayoutChange({ + ...config, + gridLayout: value === LayoutAlgorithm.Grid, + layoutAlgorithm: value, + }); + }} + /> +
+ )} + {dataFrames.length && processed.nodes.length ? ( config={config} - onConfigChange={(cfg) => { - if (cfg.gridLayout !== config.gridLayout) { - setFocusedNodeId(undefined); - } - setConfig(cfg); - }} + onConfigChange={handleLayoutChange} onMinus={onStepDown} onPlus={onStepUp} scale={scale} @@ -283,11 +339,26 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode } {hiddenNodesCount > 0 && ( - ); diff --git a/public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx b/public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx index 72bfa35f4bf..57c5a74179d 100644 --- a/public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx +++ b/public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx @@ -6,7 +6,7 @@ import { PanelProps } from '@grafana/data'; import { useLinks } from '../../../features/explore/utils/links'; import { NodeGraph } from './NodeGraph'; -import { NodeGraphOptions } from './types'; +import { Options as NodeGraphOptions } from './panelcfg.gen'; import { getNodeGraphDataFrames } from './utils'; export const NodeGraphPanel = ({ width, height, data, options }: PanelProps) => { @@ -29,6 +29,7 @@ export const NodeGraphPanel = ({ width, height, data, options }: PanelProps ); diff --git a/public/app/plugins/panel/nodeGraph/ViewControls.tsx b/public/app/plugins/panel/nodeGraph/ViewControls.tsx index 98b39f78ceb..2977cc6ab4a 100644 --- a/public/app/plugins/panel/nodeGraph/ViewControls.tsx +++ b/public/app/plugins/panel/nodeGraph/ViewControls.tsx @@ -54,24 +54,6 @@ export function ViewControls>(props: Props - -