mirror of
https://github.com/grafana/grafana.git
synced 2025-02-20 11:48:34 -06:00
* Update dependency prettier to v2.5.1 * prettier fixes * chore(toolkit): bump prettier to 2.5.1 * style(eslint): bump grafana config to 2.5.2 in core and toolkit * style(mssql-datasource): fix no-inferrable-types eslint errors Co-authored-by: Renovate Bot <bot@renovateapp.com> Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com> Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
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, 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 { Config, defaultConfig, useLayout } from './layout';
|
|
import { EdgeArrowMarker } from './EdgeArrowMarker';
|
|
import { css } from '@emotion/css';
|
|
import { useCategorizeFrames } from './useCategorizeFrames';
|
|
import { EdgeLabel } from './EdgeLabel';
|
|
import { useContextMenu } from './useContextMenu';
|
|
import { processNodes, Bounds } from './utils';
|
|
import { Marker } from './Marker';
|
|
import { Legend } from './Legend';
|
|
import { useHighlight } from './useHighlight';
|
|
import { useFocusPositionOnLayout } from './useFocusPositionOnLayout';
|
|
|
|
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;
|
|
font-size: 10px;
|
|
cursor: move;
|
|
`,
|
|
|
|
svgPanning: css`
|
|
label: svgPanning;
|
|
user-select: none;
|
|
`,
|
|
|
|
noDataMsg: css`
|
|
height: 100%;
|
|
width: 100%;
|
|
display: grid;
|
|
place-items: center;
|
|
font-size: ${theme.typography.h4.fontSize};
|
|
color: ${theme.colors.text.secondary};
|
|
`,
|
|
|
|
mainGroup: css`
|
|
label: mainGroup;
|
|
will-change: transform;
|
|
`,
|
|
|
|
viewControls: css`
|
|
label: viewControls;
|
|
position: absolute;
|
|
left: 2px;
|
|
bottom: 3px;
|
|
right: 0;
|
|
display: flex;
|
|
align-items: flex-end;
|
|
justify-content: space-between;
|
|
pointer-events: none;
|
|
`,
|
|
legend: css`
|
|
label: legend;
|
|
background: ${theme.colors.background.secondary};
|
|
box-shadow: ${theme.shadows.z1};
|
|
padding-bottom: 5px;
|
|
margin-right: 10px;
|
|
`,
|
|
viewControlsWrapper: css`
|
|
margin-left: auto;
|
|
`,
|
|
alert: css`
|
|
label: alert;
|
|
padding: 5px 8px;
|
|
font-size: 10px;
|
|
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
|
|
border-radius: ${theme.shape.borderRadius()};
|
|
align-items: center;
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
background: ${theme.colors.warning.main};
|
|
color: ${theme.colors.warning.contrastText};
|
|
`,
|
|
loadingWrapper: css`
|
|
label: loadingWrapper;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: 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;
|
|
|
|
interface Props {
|
|
dataFrames: DataFrame[];
|
|
getLinks: (dataFrame: DataFrame, rowIndex: number) => LinkModel[];
|
|
nodeLimit?: number;
|
|
}
|
|
export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) {
|
|
const nodeCountLimit = nodeLimit || defaultNodeCountLimit;
|
|
const { edges: edgesDataFrames, nodes: nodesDataFrames } = useCategorizeFrames(dataFrames);
|
|
|
|
const [measureRef, { width, height }] = useMeasure();
|
|
const [config, setConfig] = useState<Config>(defaultConfig);
|
|
|
|
// 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 firstNodesDataFrame = nodesDataFrames[0];
|
|
const firstEdgesDataFrame = edgesDataFrames[0];
|
|
|
|
const theme = useTheme2();
|
|
|
|
// 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, theme),
|
|
[firstEdgesDataFrame, firstNodesDataFrame, theme]
|
|
);
|
|
|
|
// 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
|
|
);
|
|
|
|
// 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(
|
|
(r) => {
|
|
measureRef(r);
|
|
(zoomRef as MutableRefObject<HTMLElement | null>).current = r;
|
|
},
|
|
[measureRef, zoomRef]
|
|
);
|
|
|
|
const highlightId = useHighlight(focusedNodeId);
|
|
|
|
return (
|
|
<div ref={topLevelRef} className={styles.wrapper}>
|
|
{loading ? (
|
|
<div className={styles.loadingWrapper}>
|
|
Computing layout
|
|
<Spinner />
|
|
</div>
|
|
) : null}
|
|
|
|
{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)` }}
|
|
>
|
|
<EdgeArrowMarker />
|
|
{!config.gridLayout && (
|
|
<Edges
|
|
edges={edges}
|
|
nodeHoveringId={nodeHover}
|
|
edgeHoveringId={edgeHover}
|
|
onClick={onEdgeOpen}
|
|
onMouseEnter={setEdgeHover}
|
|
onMouseLeave={clearEdgeHover}
|
|
/>
|
|
)}
|
|
<Nodes
|
|
nodes={nodes}
|
|
onMouseEnter={setNodeHover}
|
|
onMouseLeave={clearNodeHover}
|
|
onClick={onNodeOpen}
|
|
hoveringId={nodeHover || 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}>No data</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={(cfg) => {
|
|
if (cfg.gridLayout !== config.gridLayout) {
|
|
setFocusedNodeId(undefined);
|
|
}
|
|
setConfig(cfg);
|
|
}}
|
|
onMinus={onStepDown}
|
|
onPlus={onStepUp}
|
|
scale={scale}
|
|
disableZoomIn={isMaxZoom}
|
|
disableZoomOut={isMinZoom}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{hiddenNodesCount > 0 && (
|
|
<div className={styles.alert} aria-label={'Nodes hidden warning'}>
|
|
<Icon size="sm" name={'info-circle'} /> {hiddenNodesCount} nodes are hidden for performance reasons.
|
|
</div>
|
|
)}
|
|
|
|
{MenuComponent}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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;
|
|
hoveringId?: 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.hoveringId === n.id}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
});
|
|
|
|
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: EdgeDatum[];
|
|
nodeHoveringId?: string;
|
|
edgeHoveringId?: string;
|
|
onClick: (event: MouseEvent<SVGElement>, link: EdgeDatum) => void;
|
|
onMouseEnter: (id: string) => void;
|
|
onMouseLeave: (id: string) => void;
|
|
}
|
|
const Edges = memo(function Edges(props: EdgesProps) {
|
|
return (
|
|
<>
|
|
{props.edges.map((e) => (
|
|
<Edge
|
|
key={e.id}
|
|
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}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
});
|
|
|
|
interface EdgeLabelsProps {
|
|
edges: EdgeDatum[];
|
|
nodeHoveringId?: string;
|
|
edgeHoveringId?: string;
|
|
}
|
|
const EdgeLabels = memo(function EdgeLabels(props: EdgeLabelsProps) {
|
|
return (
|
|
<>
|
|
{props.edges.map((e, index) => {
|
|
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 }) {
|
|
const { scale, onStepDown, onStepUp, ref, isMax, isMin } = useZoom();
|
|
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 };
|
|
}
|