mirror of
https://github.com/grafana/grafana.git
synced 2025-08-01 15:32:09 +08:00

* 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
320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
import { fromPairs } from 'lodash';
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useUnmount } from 'react-use';
|
|
import useMountedState from 'react-use/lib/useMountedState';
|
|
|
|
import { Field } from '@grafana/data';
|
|
|
|
import { createWorker, createMsaglWorker } from './createLayoutWorker';
|
|
import { LayoutAlgorithm } from './panelcfg.gen';
|
|
import { EdgeDatum, EdgeDatumLayout, NodeDatum } from './types';
|
|
import { useNodeLimit } from './useNodeLimit';
|
|
import { graphBounds } from './utils';
|
|
|
|
export interface Config {
|
|
layoutAlgorithm: LayoutAlgorithm;
|
|
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 = {
|
|
layoutAlgorithm: LayoutAlgorithm.Layered,
|
|
linkDistance: 150,
|
|
linkStrength: 0.5,
|
|
forceX: 2000,
|
|
forceXStrength: 0.02,
|
|
forceCollide: 100,
|
|
tick: 300,
|
|
gridLayout: false,
|
|
};
|
|
|
|
export interface LayoutCache {
|
|
[LayoutAlgorithm.Force]?: { nodes: NodeDatum[]; edges: EdgeDatumLayout[] };
|
|
[LayoutAlgorithm.Layered]?: { nodes: NodeDatum[]; edges: EdgeDatumLayout[] };
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
width: number,
|
|
rootNodeId?: string,
|
|
hasFixedPositions?: boolean,
|
|
layoutCache?: LayoutCache
|
|
) {
|
|
const [nodesGraph, setNodesGraph] = useState<NodeDatum[]>([]);
|
|
const [edgesGraph, setEdgesGraph] = useState<EdgeDatumLayout[]>([]);
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// Store current data signature to detect changes
|
|
const dataSignatureRef = useRef<string>('');
|
|
const currentSignature = createDataSignature(rawNodes, rawEdges);
|
|
|
|
const isMounted = useMountedState();
|
|
const layoutWorkerCancelRef = useRef<(() => void) | undefined>();
|
|
|
|
useUnmount(() => {
|
|
if (layoutWorkerCancelRef.current) {
|
|
layoutWorkerCancelRef.current();
|
|
}
|
|
});
|
|
|
|
// 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) {
|
|
setNodesGraph([]);
|
|
setEdgesGraph([]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (hasFixedPositions) {
|
|
setNodesGraph(rawNodes);
|
|
// The layout function turns source and target fields from string to NodeDatum, so we do that here as well.
|
|
const nodesMap = fromPairs(rawNodes.map((node) => [node.id, node]));
|
|
setEdgesGraph(
|
|
rawEdges.map(
|
|
(e): EdgeDatumLayout => ({
|
|
...e,
|
|
source: nodesMap[e.source],
|
|
target: nodesMap[e.target],
|
|
})
|
|
)
|
|
);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Layered layout is better but also more expensive.
|
|
let layoutType: 'force' | 'layered' = 'force';
|
|
let algorithmType = LayoutAlgorithm.Force;
|
|
|
|
if (config.layoutAlgorithm === LayoutAlgorithm.Layered) {
|
|
layoutType = 'layered';
|
|
algorithmType = LayoutAlgorithm.Layered;
|
|
}
|
|
|
|
// Check if data has changed since last render
|
|
const hasDataChanged = dataSignatureRef.current !== currentSignature;
|
|
|
|
// Clear cache if data has changed
|
|
if (hasDataChanged) {
|
|
dataSignatureRef.current = currentSignature;
|
|
|
|
if (layoutCache) {
|
|
delete layoutCache[LayoutAlgorithm.Force];
|
|
delete layoutCache[LayoutAlgorithm.Layered];
|
|
}
|
|
}
|
|
|
|
// Check if we have a cached layout for this algorithm
|
|
if (layoutCache && layoutCache[algorithmType]) {
|
|
setNodesGraph(layoutCache[algorithmType]?.nodes ?? []);
|
|
setEdgesGraph(layoutCache[algorithmType]?.edges ?? []);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
const cancel = layout(rawNodes, rawEdges, layoutType, ({ nodes, edges }) => {
|
|
if (isMounted()) {
|
|
setNodesGraph(nodes);
|
|
setEdgesGraph(edges);
|
|
setLoading(false);
|
|
|
|
// Cache the calculated layout
|
|
if (layoutCache) {
|
|
layoutCache[algorithmType] = { nodes, edges };
|
|
}
|
|
}
|
|
});
|
|
layoutWorkerCancelRef.current = cancel;
|
|
return cancel;
|
|
}, [hasFixedPositions, rawNodes, rawEdges, isMounted, config.layoutAlgorithm, layoutCache, currentSignature]);
|
|
|
|
// Compute grid separately as it is sync and do not need to be inside effect. Also it is dependant on width while
|
|
// default layout does not care and we don't want to recalculate that on panel resize.
|
|
const [nodesGrid, edgesGrid] = useMemo(() => {
|
|
if (rawNodes.length === 0) {
|
|
return [[], []];
|
|
}
|
|
|
|
const rawNodesCopy = rawNodes.map((n) => ({ ...n }));
|
|
const rawEdgesCopy = rawEdges.map((e) => ({ ...e }));
|
|
gridLayout(rawNodesCopy, width, config.sort);
|
|
|
|
return [rawNodesCopy, rawEdgesCopy as EdgeDatumLayout[]];
|
|
}, [config.sort, rawNodes, rawEdges, width]);
|
|
|
|
// 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.
|
|
* Returns a cancel function to terminate the worker.
|
|
*/
|
|
function layout(
|
|
nodes: NodeDatum[],
|
|
edges: EdgeDatum[],
|
|
engine: 'force' | 'layered',
|
|
done: (data: { nodes: NodeDatum[]; edges: EdgeDatumLayout[] }) => void
|
|
) {
|
|
const worker = engine === 'force' ? createWorker() : createMsaglWorker();
|
|
|
|
worker.onmessage = (event: MessageEvent<{ nodes: NodeDatum[]; edges: EdgeDatumLayout[] }>) => {
|
|
const nodesMap = fromPairs(nodes.map((node) => [node.id, node]));
|
|
|
|
// Add the x,y coordinates from the layout algorithm to the original nodes.
|
|
event.data.nodes = event.data.nodes.map((node) => {
|
|
return {
|
|
...nodesMap[node.id],
|
|
...node,
|
|
};
|
|
});
|
|
|
|
done(event.data);
|
|
};
|
|
|
|
worker.postMessage({
|
|
nodes: nodes.map((n) => ({
|
|
id: n.id,
|
|
incoming: n.incoming,
|
|
})),
|
|
edges,
|
|
config: defaultConfig,
|
|
});
|
|
|
|
return () => {
|
|
worker.terminate();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set the nodes in simple grid layout sorted by some stat.
|
|
*/
|
|
function gridLayout(
|
|
nodes: NodeDatum[],
|
|
width: number,
|
|
sort?: {
|
|
field: Field;
|
|
ascending: boolean;
|
|
}
|
|
) {
|
|
const spacingVertical = 140;
|
|
const spacingHorizontal = 120;
|
|
const padding = spacingHorizontal / 2;
|
|
const perRow = Math.min(Math.floor((width - padding * 2) / spacingVertical), nodes.length);
|
|
const midPoint = Math.floor(((perRow - 1) * spacingHorizontal) / 2);
|
|
|
|
if (sort) {
|
|
nodes.sort((node1, node2) => {
|
|
const val1 = sort!.field.values[node1.dataFrameRowIndex];
|
|
const val2 = sort!.field.values[node2.dataFrameRowIndex];
|
|
|
|
// Let's pretend we don't care about type of the stats for a while (they can be strings)
|
|
return sort!.ascending ? val1 - val2 : val2 - val1;
|
|
});
|
|
}
|
|
|
|
for (const [index, node] of nodes.entries()) {
|
|
const row = Math.floor(index / perRow);
|
|
const column = index % perRow;
|
|
node.x = column * spacingHorizontal - midPoint;
|
|
node.y = -60 + row * spacingVertical;
|
|
}
|
|
}
|
|
|
|
function createDataSignature(nodes: NodeDatum[], edges: EdgeDatum[]): string {
|
|
const signature = [`n:${nodes.length}`, `e:${edges.length}`];
|
|
|
|
if (nodes.length > 0) {
|
|
const firstNode = nodes[0].id ?? '';
|
|
signature.push(`f:${firstNode}`);
|
|
|
|
// Middle node (if there are at least 3 nodes)
|
|
if (nodes.length >= 3) {
|
|
const middleIndex = Math.floor(nodes.length / 2);
|
|
const middleNode = nodes[middleIndex].id ?? '';
|
|
signature.push(`m:${middleNode}`);
|
|
}
|
|
|
|
const lastNode = nodes[nodes.length - 1].id ?? '';
|
|
signature.push(`l:${lastNode}`);
|
|
|
|
// Add basic connectivity information
|
|
let connectedNodesCount = 0;
|
|
let maxConnections = 0;
|
|
|
|
for (const node of nodes) {
|
|
const connections = node.incoming || 0;
|
|
if (connections > 0) {
|
|
connectedNodesCount++;
|
|
}
|
|
maxConnections = Math.max(maxConnections, connections);
|
|
}
|
|
|
|
signature.push(`cn:${connectedNodesCount}`);
|
|
signature.push(`mc:${maxConnections}`);
|
|
}
|
|
|
|
return signature.join('_');
|
|
}
|