/usr/share/grafana/public/app/plugins/panel/nodeGraph
import { css } from '@emotion/css'; import cx from 'classnames'; 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 { Trans, t } from '@grafana/i18n'; import { Icon, RadioButtonGroup, Spinner, useStyles2 } from '@grafana/ui'; import { Edge } from './Edge'; import { EdgeLabel } from './EdgeLabel'; import { Legend } from './Legend'; import { Marker } from './Marker'; import { Node } from './Node'; import { ViewControls } from './ViewControls'; 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'; import { useFocusPositionOnLayout } from './useFocusPositionOnLayout'; import { useHighlight } from './useHighlight'; import { usePanning } from './usePanning'; import { useZoom } from './useZoom'; import { processNodes, Bounds, findConnectedNodesForEdge, findConnectedNodesForNode } from './utils'; const getStyles = (theme: GrafanaTheme2) => ({ wrapper: css({ label: 'wrapper', height: '100%', width: '100%', overflow: 'hidden', position: 'relative', }), svg: css({ label: 'svg', height: '100%', width: '100%', overflow: 'visible', fontSize: '10px', cursor: 'move', }), svgPanning: css({ label: 'svgPanning', userSelect: 'none', }), noDataMsg: css({ height: '100%', width: '100%', display: 'grid', placeItems: 'center', fontSize: theme.typography.h4.fontSize, color: theme.colors.text.secondary, }), mainGroup: css({ label: 'mainGroup', willChange: 'transform', }), viewControls: css({ label: 'viewControls', position: 'absolute', left: '2px', bottom: '3px', right: 0, display: 'flex', alignItems: 'flex-end', 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, boxShadow: theme.shadows.z1, paddingBottom: '5px', marginRight: '10px', }), viewControlsWrapper: css({ marginLeft: 'auto', }), alert: css({ label: 'alert', padding: '5px 8px', fontSize: '10px', textShadow: '0 1px 0 rgba(0, 0, 0, 0.2)', borderRadius: theme.shape.radius.default, alignItems: 'center', position: 'absolute', right: 0, background: theme.colors.warning.main, color: theme.colors.warning.contrastText, }), loadingWrapper: css({ label: 'loadingWrapper', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', }), }); // 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; 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, layoutAlgorithm }: Props) { const nodeCountLimit = nodeLimit || defaultNodeCountLimit; const { edges: edgesDataFrames, nodes: nodesDataFrames } = useCategorizeFrames(dataFrames); const [measureRef, { width, height }] = useMeasure(); const [config, setConfig] = useState<Config>(defaultConfig); // Layout cache to avoid recalculating layouts const layoutCacheRef = useRef<LayoutCache>({}); // 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]; // Ensure we use unique IDs for the marker tip elements, since IDs are global // in the entire HTML document. This prevents hidden tips when an earlier // occurence is hidden (editor is open in front of an existing node graph // panel) or when the earlier tips have different properties (color, size, or // shape for example). const svgIdNamespace = panelId || 'nodegraphpanel'; // TODO we should be able to allow multiple dataframes for both edges and nodes, could be issue with node ids which in // that case should be unique or figure a way to link edges and nodes dataframes together. const processed = useMemo( () => processNodes(firstNodesDataFrame, firstEdgesDataFrame), [firstEdgesDataFrame, firstNodesDataFrame] ); // 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, clearNodeHover, edgeHover, setEdgeHover, clearEdgeHover } = useHover(); const [hoveringIds, setHoveringIds] = useState<string[]>([]); useEffect(() => { let linked: string[] = []; if (nodeHover) { linked = findConnectedNodesForNode(processed.nodes, processed.edges, nodeHover); } else if (edgeHover) { linked = findConnectedNodesForEdge(processed.nodes, processed.edges, edgeHover); } setHoveringIds(linked); }, [nodeHover, edgeHover, processed]); // This is used for navigation from grid to graph view. This node will be centered and briefly highlighted. const [focusedNodeId, setFocusedNodeId] = useState<string>(); const setFocused = useCallback((e: MouseEvent, m: NodesMarker) => setFocusedNodeId(m.node.id), [setFocusedNodeId]); // 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, width, focusedNodeId, 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 // 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, zoomMode ); 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( (r: HTMLDivElement) => { measureRef(r); zoomRef.current = r; }, [measureRef, zoomRef] ); 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 ( <div ref={topLevelRef} className={styles.wrapper}> {loading ? ( <div className={styles.loadingWrapper}> <Trans i18nKey="nodeGraph.node-graph.computing-layout">Computing layout</Trans> <Spinner /> </div> ) : null} {!panelId && ( <div className={styles.layoutAlgorithm}> <RadioButtonGroup size="sm" options={[ { label: 'Layered', value: LayoutAlgorithm.Layered }, { label: 'Force', value: LayoutAlgorithm.Force }, { label: 'Grid', value: LayoutAlgorithm.Grid }, ]} value={config.gridLayout ? LayoutAlgorithm.Grid : config.layoutAlgorithm} onChange={(value) => { handleLayoutChange({ ...config, gridLayout: value === LayoutAlgorithm.Grid, layoutAlgorithm: value, }); }} /> </div> )} {dataFrames.length && processed.nodes.length ? ( <svg ref={panRef} viewBox={`${-(width / 2)} ${-(height / 2)} ${width} ${height}`} className={cx(styles.svg, isPanning && styles.svgPanning)} > <g className={styles.mainGroup} style={{ transform: `scale(${scale}) translate(${Math.floor(position.x)}px, ${Math.floor(position.y)}px)` }} > {!config.gridLayout && ( <Edges edges={edges} nodeHoveringId={nodeHover} edgeHoveringId={edgeHover} onClick={onEdgeOpen} onMouseEnter={setEdgeHover} onMouseLeave={clearEdgeHover} svgIdNamespace={svgIdNamespace} processedNodesLength={processed.nodes.length} processedEdgesLength={processed.edges.length} /> )} <Nodes nodes={nodes} onMouseEnter={setNodeHover} onMouseLeave={clearNodeHover} onClick={onNodeOpen} hoveringIds={hoveringIds || [highlightId]} /> <Markers markers={markers || []} onClick={setFocused} /> {/*We split the labels from edges so that they are shown on top of everything else*/} {!config.gridLayout && <EdgeLabels edges={edges} nodeHoveringId={nodeHover} edgeHoveringId={edgeHover} />} </g> </svg> ) : ( <div className={styles.noDataMsg}> <Trans i18nKey="nodeGraph.node-graph.no-data">No data</Trans> </div> )} <div className={styles.viewControls}> {nodes.length ? ( <div className={styles.legend}> <Legend sortable={config.gridLayout} nodes={nodes} sort={config.sort} onSort={(sort) => { setConfig({ ...config, sort: sort, }); }} /> </div> ) : null} <div className={styles.viewControlsWrapper}> <ViewControls<Config> config={config} onConfigChange={handleLayoutChange} onMinus={onStepDown} onPlus={onStepUp} scale={scale} disableZoomIn={isMaxZoom} disableZoomOut={isMinZoom} /> </div> </div> {hiddenNodesCount > 0 && ( <div className={styles.alert} style={{ top: panelId ? '0px' : '40px' }} // panelId is undefined in Explore aria-label={t('nodeGraph.node-graph.aria-label-nodes-hidden-warning', 'Nodes hidden warning')} > <Trans i18nKey="nodeGraph.node-graph.hidden-nodes" count={hiddenNodesCount}> <Icon size="sm" name={'info-circle'} /> {'{{count}}'} nodes are hidden for performance reasons. </Trans> </div> )} {config.layoutAlgorithm === LayoutAlgorithm.Layered && processed.nodes.length > layeredLayoutThreshold && ( <div className={styles.alert} style={{ top: panelId ? '30px' : '70px' }} aria-label={t( 'nodeGraph.node-graph.aria-label-layered-layout-performance-warning', 'Layered layout performance warning' )} > <Trans i18nKey="nodeGraph.node-graph.processed-nodes" count={processed.nodes.length}> <Icon size="sm" name={'exclamation-triangle'} /> Layered layout may be slow with {'{{count}}'} nodes. </Trans> </div> )} {MenuComponent} </div> ); } // Active -> emphasized, inactive -> de-emphasized, and default -> normal styling export type HoverState = 'active' | 'inactive' | 'default'; // These components are here as a perf optimisation to prevent going through all nodes and edges on every pan/zoom. interface NodesProps { nodes: NodeDatum[]; onMouseEnter: (id: string) => void; onMouseLeave: (id: string) => void; onClick: (event: MouseEvent<SVGElement>, node: NodeDatum) => void; hoveringIds?: string[]; } const Nodes = memo(function Nodes(props: NodesProps) { return ( <> {props.nodes.map((n) => ( <Node key={n.id} node={n} onMouseEnter={props.onMouseEnter} onMouseLeave={props.onMouseLeave} onClick={props.onClick} hovering={ !props.hoveringIds || props.hoveringIds.length === 0 ? 'default' : props.hoveringIds?.includes(n.id) ? 'active' : 'inactive' } /> ))} </> ); }); interface MarkersProps { markers: NodesMarker[]; onClick: (event: MouseEvent<SVGElement>, marker: NodesMarker) => void; } const Markers = memo(function Nodes(props: MarkersProps) { return ( <> {props.markers.map((m) => ( <Marker key={'marker-' + m.node.id} marker={m} onClick={props.onClick} /> ))} </> ); }); interface EdgesProps { edges: EdgeDatumLayout[]; nodeHoveringId?: string; edgeHoveringId?: string; svgIdNamespace: string; onClick: (event: MouseEvent<SVGElement>, link: EdgeDatumLayout) => void; onMouseEnter: (id: string) => void; onMouseLeave: (id: string) => void; processedNodesLength: number; processedEdgesLength: number; } const Edges = memo(function Edges(props: EdgesProps) { return ( <> {props.edges.map((e, index) => { return ( <Edge key={`${e.id}-${e.source.y ?? ''}-${props.processedNodesLength}-${props.processedEdgesLength}-${index}`} edge={e} hovering={ (e.source as NodeDatum).id === props.nodeHoveringId || (e.target as NodeDatum).id === props.nodeHoveringId || props.edgeHoveringId === e.id } onClick={props.onClick} onMouseEnter={props.onMouseEnter} onMouseLeave={props.onMouseLeave} svgIdNamespace={props.svgIdNamespace} /> ); })} </> ); }); interface EdgeLabelsProps { edges: EdgeDatumLayout[]; nodeHoveringId?: string; edgeHoveringId?: string; } const EdgeLabels = memo(function EdgeLabels(props: EdgeLabelsProps) { return ( <> {props.edges.map((e) => { // We show the edge label in case user hovers over the edge directly or if they hover over node edge is // connected to. const shouldShow = (e.source as NodeDatum).id === props.nodeHoveringId || (e.target as NodeDatum).id === props.nodeHoveringId || props.edgeHoveringId === e.id; const hasStats = e.mainStat || e.secondaryStat; return shouldShow && hasStats && <EdgeLabel key={e.id} edge={e} />; })} </> ); }); function usePanAndZoom(bounds: Bounds, focus?: { x: number; y: number }, zoomMode?: ZoomMode) { const { scale, onStepDown, onStepUp, ref, isMax, isMin } = useZoom({ zoomMode }); const { state: panningState, ref: panRef } = usePanning<SVGSVGElement>({ 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<string | undefined>(undefined); const clearNodeHover = useCallback(() => setNodeHover(undefined), [setNodeHover]); const [edgeHover, setEdgeHover] = useState<string | undefined>(undefined); const clearEdgeHover = useCallback(() => setEdgeHover(undefined), [setEdgeHover]); return { nodeHover, setNodeHover, clearNodeHover, edgeHover, setEdgeHover, clearEdgeHover }; }
.
Edit
..
Edit
Edge.tsx
Edit
EdgeArrowMarker.tsx
Edit
EdgeLabel.tsx
Edit
Legend.test.tsx
Edit
Legend.tsx
Edit
Marker.tsx
Edit
Node.test.tsx
Edit
Node.tsx
Edit
NodeGraph.test.tsx
Edit
NodeGraph.tsx
Edit
NodeGraphPanel.tsx
Edit
README.md
Edit
ViewControls.tsx
Edit
createLayoutWorker.ts
Edit
editor
Edit
forceLayout.js
Edit
img
Edit
layeredLayout.js
Edit
layeredLayout.test.ts
Edit
layeredLayout.worker.js
Edit
layout.test.ts
Edit
layout.ts
Edit
layout.worker.js
Edit
module.tsx
Edit
panelcfg.cue
Edit
panelcfg.gen.ts
Edit
plugin.json
Edit
suggestions.ts
Edit
types.ts
Edit
useCategorizeFrames.ts
Edit
useContextMenu.tsx
Edit
useFocusPositionOnLayout.ts
Edit
useHighlight.ts
Edit
useNodeLimit.ts
Edit
usePanning.ts
Edit
useZoom.ts
Edit
utils.test.ts
Edit
utils.ts
Edit