From 218a8de22083fdf266783d98da129774b1c1380e Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Tue, 19 Jan 2021 16:34:43 +0100 Subject: [PATCH] NodeGraph: Add node graph visualization (#29706) * Add GraphView component * Add service map panel * Add more metadata visuals * Add context menu on click * Add context menu for services * Fix service map in dashboard * Add field proxy in explore linkSupplier * Refactor the link creation * Remove test file * Fix scale change when view is panned * Fix node centering * Don't show context menu if no links * Fix service map containers * Add collapsible around the service map * Fix stats computation * Remove debug log * Fix time stats * Allow string timestamp * Make panning bounded * Add zooming by mouse wheel * Clean up the colors * Fix stats for single trace graph * Don't show debug config * Add more complex layout * Update layout with better fixing of the root nodes * Code cleanup * Change how we pass in link creation function and some more cleanup * Refactor the panel section into separate render methods * Make the edge hover more readable * Move stats computation to data source * Put edge labels to front * Simplify layout for better multi graph layout * Update for dark theme * Move function to utils * Visual improvements * Improve context menu detail * Allow custom details * Rename to NodeGraph * Remove unused dependencies * Use named color palette and add some fallbacks for missing data * Add test data scenario * Rename plugin * Switch scroll zoom direction to align with google maps * Do some perf optimisations and rise the node limit * Update alert styling * Rename function * Add tests * Add more tests * Change data frame column mapping to use column names * Fix test * Fix type errors * Don't show context menu without links * Add beta status to panel * Fix tests * Changed function to standard methods * Fix typing * Clean up yarn.lock * Add some UI improvements - better styling of the zoom buttons - disable buttons when max reached * Fix panel references after rename * Add panel icon --- package.json | 2 + .../grafana-data/src/dataframe/FieldCache.ts | 9 + packages/grafana-data/src/types/data.ts | 2 +- .../src/components/Collapse/Collapse.tsx | 4 +- .../src/components/NodeGraph/Edge.tsx | 59 +++ .../components/NodeGraph/EdgeArrowMarker.tsx | 24 + .../src/components/NodeGraph/EdgeLabel.tsx | 67 +++ .../src/components/NodeGraph/Node.tsx | 170 +++++++ .../components/NodeGraph/NodeGraph.test.tsx | 188 ++++++++ .../src/components/NodeGraph/NodeGraph.tsx | 247 +++++++++++ .../src/components/NodeGraph/ViewControls.tsx | 91 ++++ .../src/components/NodeGraph/index.ts | 2 + .../src/components/NodeGraph/layout.ts | 213 +++++++++ .../src/components/NodeGraph/types.ts | 21 + .../NodeGraph/useCategorizeFrames.ts | 25 ++ .../components/NodeGraph/useContextMenu.tsx | 152 +++++++ .../src/components/NodeGraph/useNodeLimit.ts | 50 +++ .../src/components/NodeGraph/usePanning.ts | 135 ++++++ .../src/components/NodeGraph/useZoom.ts | 88 ++++ .../src/components/NodeGraph/utils.test.ts | 106 +++++ .../src/components/NodeGraph/utils.ts | 254 +++++++++++ packages/grafana-ui/src/components/index.ts | 1 + pkg/tsdb/testdatasource/scenarios.go | 6 + public/app/features/explore/Explore.test.tsx | 1 + public/app/features/explore/Explore.tsx | 140 +++--- .../features/explore/NodeGraphContainer.tsx | 49 +++ public/app/features/explore/state/main.ts | 5 + public/app/features/explore/state/query.ts | 17 +- .../features/explore/utils/decorators.test.ts | 12 +- .../app/features/explore/utils/decorators.ts | 8 +- public/app/features/explore/utils/links.ts | 27 ++ .../app/features/plugins/built_in_plugins.ts | 2 + .../datasource/testdata/QueryEditor.tsx | 6 +- .../testdata/components/NodeGraphEditor.tsx | 42 ++ .../plugins/datasource/testdata/datasource.ts | 21 + .../datasource/testdata/nodeGraphUtils.ts | 155 +++++++ .../testdata/testData/serviceMapResponse.ts | 416 ++++++++++++++++++ .../app/plugins/datasource/testdata/types.ts | 6 + .../panel/nodeGraph/NodeGraphPanel.tsx | 23 + .../panel/nodeGraph/img/icn-node-graph.svg | 1 + public/app/plugins/panel/nodeGraph/module.tsx | 5 + .../app/plugins/panel/nodeGraph/plugin.json | 17 + public/app/plugins/panel/nodeGraph/types.ts | 1 + public/app/types/explore.ts | 2 + yarn.lock | 29 ++ 45 files changed, 2838 insertions(+), 63 deletions(-) create mode 100644 packages/grafana-ui/src/components/NodeGraph/Edge.tsx create mode 100644 packages/grafana-ui/src/components/NodeGraph/EdgeArrowMarker.tsx create mode 100644 packages/grafana-ui/src/components/NodeGraph/EdgeLabel.tsx create mode 100644 packages/grafana-ui/src/components/NodeGraph/Node.tsx create mode 100644 packages/grafana-ui/src/components/NodeGraph/NodeGraph.test.tsx create mode 100644 packages/grafana-ui/src/components/NodeGraph/NodeGraph.tsx create mode 100644 packages/grafana-ui/src/components/NodeGraph/ViewControls.tsx create mode 100644 packages/grafana-ui/src/components/NodeGraph/index.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/layout.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/types.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/useCategorizeFrames.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/useContextMenu.tsx create mode 100644 packages/grafana-ui/src/components/NodeGraph/useNodeLimit.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/usePanning.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/useZoom.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/utils.test.ts create mode 100644 packages/grafana-ui/src/components/NodeGraph/utils.ts create mode 100644 public/app/features/explore/NodeGraphContainer.tsx create mode 100644 public/app/plugins/datasource/testdata/components/NodeGraphEditor.tsx create mode 100644 public/app/plugins/datasource/testdata/nodeGraphUtils.ts create mode 100644 public/app/plugins/datasource/testdata/testData/serviceMapResponse.ts create mode 100644 public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx create mode 100644 public/app/plugins/panel/nodeGraph/img/icn-node-graph.svg create mode 100644 public/app/plugins/panel/nodeGraph/module.tsx create mode 100644 public/app/plugins/panel/nodeGraph/plugin.json create mode 100644 public/app/plugins/panel/nodeGraph/types.ts diff --git a/package.json b/package.json index ce68a0b4fb1..05e6983af14 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@types/classnames": "2.2.9", "@types/clipboard": "2.0.1", "@types/d3": "5.7.2", + "@types/d3-force": "^2.1.0", "@types/d3-scale-chromatic": "1.3.1", "@types/enzyme": "3.10.5", "@types/enzyme-adapter-react-16": "1.0.6", @@ -230,6 +231,7 @@ "common-tags": "^1.8.0", "core-js": "3.6.4", "d3": "5.15.0", + "d3-force": "^2.1.1", "d3-scale-chromatic": "1.5.0", "dangerously-set-html-content": "1.0.6", "emotion": "10.0.27", diff --git a/packages/grafana-data/src/dataframe/FieldCache.ts b/packages/grafana-data/src/dataframe/FieldCache.ts index ab4a167978c..9901bce00e3 100644 --- a/packages/grafana-data/src/dataframe/FieldCache.ts +++ b/packages/grafana-data/src/dataframe/FieldCache.ts @@ -77,4 +77,13 @@ export class FieldCache { getFieldByName(name: string): FieldWithIndex | undefined { return this.fieldByName[name]; } + + /** + * Returns the fields with the given label. + */ + getFieldsByLabel(label: string, value: string): FieldWithIndex[] { + return Object.values(this.fieldByName).filter(f => { + return f.labels && f.labels[label] === value; + }); + } } diff --git a/packages/grafana-data/src/types/data.ts b/packages/grafana-data/src/types/data.ts index 5c8702b1cc0..bb0c8b91c47 100644 --- a/packages/grafana-data/src/types/data.ts +++ b/packages/grafana-data/src/types/data.ts @@ -20,7 +20,7 @@ export enum DataTopic { Annotations = 'annotations', } -export type PreferredVisualisationType = 'graph' | 'table' | 'logs' | 'trace'; +export type PreferredVisualisationType = 'graph' | 'table' | 'logs' | 'trace' | 'nodeGraph'; /** * @public diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.tsx b/packages/grafana-ui/src/components/Collapse/Collapse.tsx index 0dfae60a281..25207d84348 100644 --- a/packages/grafana-ui/src/components/Collapse/Collapse.tsx +++ b/packages/grafana-ui/src/components/Collapse/Collapse.tsx @@ -94,8 +94,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({ export interface Props { /** Expand or collapse te content */ isOpen?: boolean; - /** Text for the Collapse header */ - label: string; + /** Element or text for the Collapse header */ + label: React.ReactNode; /** Indicates loading state of the content */ loading?: boolean; /** Toggle collapsed header icon */ diff --git a/packages/grafana-ui/src/components/NodeGraph/Edge.tsx b/packages/grafana-ui/src/components/NodeGraph/Edge.tsx new file mode 100644 index 00000000000..19991062171 --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/Edge.tsx @@ -0,0 +1,59 @@ +import React, { MouseEvent, memo } from 'react'; +import { EdgeDatum, NodeDatum } from './types'; +import { shortenLine } from './utils'; + +interface Props { + edge: EdgeDatum; + hovering: boolean; + onClick: (event: MouseEvent, link: EdgeDatum) => void; + onMouseEnter: (id: string) => void; + onMouseLeave: (id: string) => void; +} +export const Edge = memo(function Edge(props: Props) { + const { edge, onClick, onMouseEnter, onMouseLeave, hovering } = props; + // Not great typing but after we do layout these properties are full objects not just references + const { source, target } = edge as { source: NodeDatum; target: NodeDatum }; + + // As the nodes have some radius we want edges to end outside of the node circle. + const line = shortenLine( + { + x1: source.x!, + y1: source.y!, + x2: target.x!, + y2: target.y!, + }, + 90 + ); + + return ( + onClick(event, edge)} + style={{ cursor: 'pointer' }} + aria-label={`Edge from: ${(edge.source as NodeDatum).id} to: ${(edge.target as NodeDatum).id}`} + > + + { + onMouseEnter(edge.id); + }} + onMouseLeave={() => { + onMouseLeave(edge.id); + }} + /> + + ); +}); diff --git a/packages/grafana-ui/src/components/NodeGraph/EdgeArrowMarker.tsx b/packages/grafana-ui/src/components/NodeGraph/EdgeArrowMarker.tsx new file mode 100644 index 00000000000..56193e54784 --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/EdgeArrowMarker.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +/** + * In SVG you need to supply this kind of marker that can be then referenced from a line segment as an ending of the + * line turning in into arrow. Needs to be included in the svg element and then referenced as markerEnd="url(#triangle)" + */ +export function EdgeArrowMarker() { + return ( + + + + + + ); +} diff --git a/packages/grafana-ui/src/components/NodeGraph/EdgeLabel.tsx b/packages/grafana-ui/src/components/NodeGraph/EdgeLabel.tsx new file mode 100644 index 00000000000..6fb7a52233c --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/EdgeLabel.tsx @@ -0,0 +1,67 @@ +import React, { memo } from 'react'; +import { EdgeDatum, NodeDatum } from './types'; +import { css } from 'emotion'; +import { stylesFactory, useTheme } from '../../themes'; +import { GrafanaTheme } from '@grafana/data'; +import tinycolor from 'tinycolor2'; +import lightTheme from '../../themes/light'; +import darkTheme from '../../themes/dark'; +import { shortenLine } from './utils'; + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + const inverseTheme = theme.isDark ? lightTheme : darkTheme; + return { + mainGroup: css` + pointer-events: none; + font-size: 8px; + `, + + background: css` + fill: ${tinycolor(inverseTheme.colors.bodyBg) + .setAlpha(0.8) + .toHex8String()}; + `, + + text: css` + fill: ${inverseTheme.colors.text}; + `, + }; +}); + +interface Props { + edge: EdgeDatum; +} +export const EdgeLabel = memo(function EdgeLabel(props: Props) { + const { edge } = props; + // Not great typing but after we do layout these properties are full objects not just references + const { source, target } = edge as { source: NodeDatum; target: NodeDatum }; + + // As the nodes have some radius we want edges to end outside of the node circle. + const line = shortenLine( + { + x1: source.x!, + y1: source.y!, + x2: target.x!, + y2: target.y!, + }, + 90 + ); + + const middle = { + x: line.x1 + (line.x2 - line.x1) / 2, + y: line.y1 + (line.y2 - line.y1) / 2, + }; + const styles = getStyles(useTheme()); + + return ( + + + + {edge.mainStat} + + + {edge.secondaryStat} + + + ); +}); diff --git a/packages/grafana-ui/src/components/NodeGraph/Node.tsx b/packages/grafana-ui/src/components/NodeGraph/Node.tsx new file mode 100644 index 00000000000..1fc1f7e1be5 --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/Node.tsx @@ -0,0 +1,170 @@ +import React, { MouseEvent, memo } from 'react'; +import { NodeDatum } from './types'; +import { stylesFactory, useTheme } from '../../themes'; +import { getColorForTheme, GrafanaTheme } from '@grafana/data'; +import { css } from 'emotion'; +import tinycolor from 'tinycolor2'; + +const nodeR = 40; + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + mainGroup: css` + cursor: pointer; + font-size: 10px; + `, + + mainCircle: css` + fill: ${theme.colors.panelBg}; + `, + + hoverCircle: css` + opacity: 0.5; + fill: transparent; + stroke: ${theme.colors.textBlue}; + `, + + text: css` + fill: ${theme.colors.text}; + `, + + titleText: css` + text-align: center; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + background-color: ${tinycolor(theme.colors.bodyBg) + .setAlpha(0.6) + .toHex8String()}; + `, +})); + +export const Node = memo(function Node(props: { + node: NodeDatum; + onMouseEnter: (id: string) => void; + onMouseLeave: (id: string) => void; + onClick: (event: MouseEvent, node: NodeDatum) => void; + hovering: boolean; +}) { + const { node, onMouseEnter, onMouseLeave, onClick, hovering } = props; + const styles = getStyles(useTheme()); + + if (!(node.x !== undefined && node.y !== undefined)) { + return null; + } + + return ( + { + onMouseEnter(node.id); + }} + onMouseLeave={() => { + onMouseLeave(node.id); + }} + onClick={event => { + onClick(event, node); + }} + aria-label={`Node: ${node.title}`} + > + + {hovering && } + + + + {node.mainStat} + + + {node.secondaryStat} + + +
+ {node.title} +
+ {node.subTitle} +
+
+
+
+ ); +}); + +/** + * Shows the outer segmented circle with different color for each response type. + */ +function ResponseTypeCircle(props: { node: NodeDatum }) { + const { node } = props; + const fullStat = node.arcSections.find(s => s.value === 1); + const theme = useTheme(); + + if (fullStat) { + // Doing arc with path does not work well so it's better to just do a circle in that case + return ( + + ); + } + + const nonZero = node.arcSections.filter(s => s.value !== 0); + + const { elements } = nonZero.reduce( + (acc, section) => { + const el = ( + + ); + acc.elements.push(el); + acc.percent = acc.percent + section.value; + return acc; + }, + { elements: [] as React.ReactNode[], percent: 0 } + ); + + return <>{elements}; +} + +function ArcSection({ + r, + x, + y, + startPercent, + percent, + color, + strokeWidth = 2, +}: { + r: number; + x: number; + y: number; + startPercent: number; + percent: number; + color: string; + strokeWidth?: number; +}) { + const endPercent = startPercent + percent; + const startXPos = x + Math.sin(2 * Math.PI * startPercent) * r; + const startYPos = y - Math.cos(2 * Math.PI * startPercent) * r; + const endXPos = x + Math.sin(2 * Math.PI * endPercent) * r; + const endYPos = y - Math.cos(2 * Math.PI * endPercent) * r; + const largeArc = percent > 0.5 ? '1' : '0'; + return ( + + ); +} diff --git a/packages/grafana-ui/src/components/NodeGraph/NodeGraph.test.tsx b/packages/grafana-ui/src/components/NodeGraph/NodeGraph.test.tsx new file mode 100644 index 00000000000..6ef17704095 --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/NodeGraph.test.tsx @@ -0,0 +1,188 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, getByText } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { NodeGraph } from './NodeGraph'; +import { makeEdgesDataFrame, makeNodesDataFrame } from './utils'; + +describe('NodeGraph', () => { + it('doesnt fail without any data', async () => { + render( []} />); + }); + + it('can zoom in and out', async () => { + 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); + expect(getScale()).toBe(1.5); + userEvent.click(zoomOut); + expect(getScale()).toBe(1); + }); + + it('can pan the graph', async () => { + render( + []} + /> + ); + + panView({ x: 10, y: 10 }); + // 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 })); + }); + + it('renders with single node', async () => { + render( []} />); + const circle = await screen.findByText('', { selector: 'circle' }); + await screen.findByText(/service:0/); + expect(getXY(circle)).toEqual({ x: 0, y: 0 }); + }); + + it('shows context menu when clicking on node or edge', async () => { + render( + { + return [ + { + title: dataFrame.fields.find(f => f.name === 'source') ? 'Edge traces' : 'Node traces', + href: '', + origin: null, + target: '_self', + }, + ]; + }} + /> + ); + const node = await screen.findByLabelText(/Node: service:0/); + // This shows warning because there is no position for the click. We cannot add any because we use pageX/Y in the + // context menu which is experimental (but supported) property and userEvents does not seem to support that + userEvent.click(node); + await screen.findByText(/Node traces/); + + const edge = await screen.findByLabelText(/Edge from/); + userEvent.click(edge); + await screen.findByText(/Edge traces/); + }); + + it('lays out 3 nodes in single line', () => { + render( + []} + /> + ); + + expectNodePositionCloseTo('service:0', { x: -221, y: 0 }); + expectNodePositionCloseTo('service:1', { x: -21, y: 0 }); + expectNodePositionCloseTo('service:2', { x: 221, y: 0 }); + }); + + it('lays out first children on one vertical line', () => { + 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 }); + }); + + it('limits the number of nodes shown and shows a warning', () => { + render( + []} + nodeLimit={2} + /> + ); + + const nodes = screen.getAllByLabelText(/Node: service:\d/); + expect(nodes.length).toBe(2); + screen.getByLabelText(/Nodes hidden warning/); + }); +}); + +function expectNodePositionCloseTo(node: string, pos: { x: number; y: number }) { + const nodePos = 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}`)); + const circle = getByText(group, '', { selector: 'circle' }); + return getXY(circle); +} + +function panView(toPos: { x: number; y: number }) { + const svg = getSvg(); + fireEvent(svg, new MouseEvent('mousedown', { clientX: 0, clientY: 0 })); + fireEvent(document, new MouseEvent('mousemove', { clientX: toPos.x, clientY: toPos.y })); + fireEvent(document, new MouseEvent('mouseup')); +} + +function getSvg() { + return screen.getAllByText('', { selector: 'svg' })[0]; +} + +function getTransform() { + const svg = getSvg(); + const group = svg.children[0] as SVGElement; + return group.style.getPropertyValue('transform'); +} + +function getScale() { + const scale = getTransform().match(/scale\(([\d\.]+)\)/)![1]; + return parseFloat(scale); +} + +function getTranslate() { + const matches = getTransform().match(/translate\((\d+)px, (\d+)px\)/); + return { + x: parseFloat(matches![1]), + y: parseFloat(matches![2]), + }; +} + +function getXY(e: Element) { + return { + x: parseFloat(e.attributes.getNamedItem('cx')?.value || ''), + y: parseFloat(e.attributes.getNamedItem('cy')?.value || ''), + }; +} diff --git a/packages/grafana-ui/src/components/NodeGraph/NodeGraph.tsx b/packages/grafana-ui/src/components/NodeGraph/NodeGraph.tsx new file mode 100644 index 00000000000..dd47d9ada63 --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/NodeGraph.tsx @@ -0,0 +1,247 @@ +import React, { memo, MutableRefObject, useCallback, useMemo, useState, MouseEvent } from 'react'; +import cx from 'classnames'; +import useMeasure from 'react-use/lib/useMeasure'; +import { usePanning } from './usePanning'; +import { EdgeDatum, NodeDatum } from './types'; +import { Node } from './Node'; +import { Edge } from './Edge'; +import { ViewControls } from './ViewControls'; +import { DataFrame, GrafanaTheme, LinkModel } from '@grafana/data'; +import { useZoom } from './useZoom'; +import { Bounds, Config, defaultConfig, useLayout } from './layout'; +import { EdgeArrowMarker } from './EdgeArrowMarker'; +import { stylesFactory, useTheme } from '../../themes'; +import { css } from 'emotion'; +import { useCategorizeFrames } from './useCategorizeFrames'; +import { EdgeLabel } from './EdgeLabel'; +import { useContextMenu } from './useContextMenu'; +import { processNodes } from './utils'; +import { Icon } from '..'; +import { useNodeLimit } from './useNodeLimit'; + +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ + wrapper: css` + height: 100%; + width: 100%; + overflow: hidden; + position: relative; + `, + + svg: css` + height: 100%; + width: 100%; + overflow: visible; + font-size: 10px; + cursor: move; + `, + + svgPanning: css` + user-select: none; + `, + + mainGroup: css` + will-change: transform; + `, + + viewControls: css` + position: absolute; + left: 10px; + top: 10px; + `, + alert: css` + padding: 5px 8px; + font-size: 10px; + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2); + border-radius: ${theme.border.radius.md}; + align-items: center; + position: absolute; + top: 0; + right: 0; + background: ${theme.palette.warn}; + color: ${theme.palette.white}; + `, +})); + +// This is mainly for performance reasons. +const defaultNodeCountLimit = 1500; + +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(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] = useState(undefined); + const clearNodeHover = useCallback(() => setNodeHover(undefined), [setNodeHover]); + const [edgeHover, setEdgeHover] = useState(undefined); + const clearEdgeHover = useCallback(() => setEdgeHover(undefined), [setEdgeHover]); + + // 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(nodesDataFrames[0], edgesDataFrames[0]), [ + nodesDataFrames[0], + edgesDataFrames[0], + ]); + + const { nodes: rawNodes, edges: rawEdges } = useNodeLimit(processed.nodes, processed.edges, nodeCountLimit); + const hiddenNodesCount = processed.nodes.length - rawNodes.length; + + const { nodes, edges, bounds } = useLayout(rawNodes, rawEdges, config); + const { panRef, zoomRef, onStepUp, onStepDown, isPanning, position, scale, isMaxZoom, isMinZoom } = usePanAndZoom( + bounds + ); + const { onEdgeOpen, onNodeOpen, MenuComponent } = useContextMenu(getLinks, nodesDataFrames[0], edgesDataFrames[0]); + const styles = getStyles(useTheme()); + + return ( +
{ + measureRef(r); + (zoomRef as MutableRefObject).current = r; + }} + className={styles.wrapper} + > + + + + + + {/*We split the labels from edges so that they are shown on top of everything else*/} + + + + +
+ + config={config} + onConfigChange={setConfig} + onMinus={onStepDown} + onPlus={onStepUp} + scale={scale} + disableZoomIn={isMaxZoom} + disableZoomOut={isMinZoom} + /> +
+ + {hiddenNodesCount > 0 && ( + + )} + + {MenuComponent} +
+ ); +} + +// These 3 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, node: NodeDatum) => void; + hoveringId?: string; +} +const Nodes = memo(function Nodes(props: NodesProps) { + return ( + <> + {props.nodes.map(n => ( + + ))} + + ); +}); + +interface EdgesProps { + edges: EdgeDatum[]; + nodeHoveringId?: string; + edgeHoveringId?: string; + onClick: (event: MouseEvent, link: EdgeDatum) => void; + onMouseEnter: (id: string) => void; + onMouseLeave: (id: string) => void; +} +const Edges = memo(function Edges(props: EdgesProps) { + return ( + <> + {props.edges.map(e => ( + + ))} + + ); +}); + +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; + return shouldShow && ; + })} + + ); +}); + +function usePanAndZoom(bounds: Bounds) { + const { scale, onStepDown, onStepUp, ref, isMax, isMin } = useZoom(); + const { state: panningState, ref: panRef } = usePanning({ + scale, + bounds, + }); + const { position, isPanning } = panningState; + return { zoomRef: ref, panRef, position, isPanning, scale, onStepDown, onStepUp, isMaxZoom: isMax, isMinZoom: isMin }; +} diff --git a/packages/grafana-ui/src/components/NodeGraph/ViewControls.tsx b/packages/grafana-ui/src/components/NodeGraph/ViewControls.tsx new file mode 100644 index 00000000000..2bb27d70ca1 --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/ViewControls.tsx @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import { Button } from '../Button'; +import { stylesFactory, useTheme } from '../../themes'; +import { GrafanaTheme } from '@grafana/data'; +import { css } from 'emotion'; +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}; + `, +})); + +interface Props { + config: Config; + onConfigChange: (config: Config) => void; + onPlus: () => void; + onMinus: () => void; + scale: number; + disableZoomOut?: boolean; + disableZoomIn?: boolean; +} + +/** + * 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 [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 ( + <> + + + )} + + {allowConfiguration && + showConfig && + Object.keys(config) + .filter(k => k !== 'show') + .map(k => ( +
+ {k} + { + onConfigChange({ ...config, [k]: parseFloat(e.target.value) }); + }} + /> +
+ ))} + + ); +} diff --git a/packages/grafana-ui/src/components/NodeGraph/index.ts b/packages/grafana-ui/src/components/NodeGraph/index.ts new file mode 100644 index 00000000000..39cfa18225e --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/index.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 00000000000..7fc20eea560 --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/layout.ts @@ -0,0 +1,213 @@ +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 new file mode 100644 index 00000000000..561fbeef621 --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/types.ts @@ -0,0 +1,21 @@ +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; + }>; +}; +export type EdgeDatum = SimulationLinkDatum & { + id: string; + mainStat: string; + secondaryStat: string; + dataFrameRowIndex: number; +}; diff --git a/packages/grafana-ui/src/components/NodeGraph/useCategorizeFrames.ts b/packages/grafana-ui/src/components/NodeGraph/useCategorizeFrames.ts new file mode 100644 index 00000000000..b2852477cf9 --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/useCategorizeFrames.ts @@ -0,0 +1,25 @@ +import { useMemo } from 'react'; +import { DataFrame } from '@grafana/data'; + +/** + * As we need 2 dataframes for the service map, one with nodes and one with edges we have to figure out which is which. + * Right now we do not have any metadata for it so we just check preferredVisualisationType and then column names. + * TODO: maybe we could use column labels to have a better way to do this + */ +export function useCategorizeFrames(series: DataFrame[]) { + return useMemo(() => { + const serviceMapFrames = series.filter(frame => frame.meta?.preferredVisualisationType === 'nodeGraph'); + return serviceMapFrames.reduce( + (acc, frame) => { + const sourceField = frame.fields.filter(f => f.name === 'source'); + if (sourceField.length) { + acc.edges.push(frame); + } else { + acc.nodes.push(frame); + } + return acc; + }, + { edges: [], nodes: [] } as { nodes: DataFrame[]; edges: DataFrame[] } + ); + }, [series]); +} diff --git a/packages/grafana-ui/src/components/NodeGraph/useContextMenu.tsx b/packages/grafana-ui/src/components/NodeGraph/useContextMenu.tsx new file mode 100644 index 00000000000..e29d682e31d --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/useContextMenu.tsx @@ -0,0 +1,152 @@ +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'; + +/** + * Hook that contains state of the context menu, both for edges and nodes and provides appropriate component when + * opened context menu should be opened. + */ +export function useContextMenu( + getLinks: (dataFrame: DataFrame, rowIndex: number) => LinkModel[], + nodes: DataFrame, + edges: DataFrame +): { + 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 [openedEdge, setOpenedEdge] = useState<{ edge: EdgeDatum; event: MouseEvent } | undefined>(undefined); + const onEdgeOpen = useCallback((event, edge) => setOpenedEdge({ edge, event }), []); + + let MenuComponent = null; + + if (openedNode) { + const items = getItems(getLinks(nodes, openedNode.node.dataFrameRowIndex)); + if (items.length) { + MenuComponent = ( + } + items={items} + onClose={() => setOpenedNode(undefined)} + x={openedNode.event.pageX} + y={openedNode.event.pageY} + /> + ); + } + } + + if (openedEdge) { + const items = getItems(getLinks(edges, openedEdge.edge.dataFrameRowIndex)); + if (items.length) { + MenuComponent = ( + } + items={items} + onClose={() => setOpenedEdge(undefined)} + x={openedEdge.event.pageX} + y={openedEdge.event.pageY} + /> + ); + } + } + + return { onEdgeOpen, onNodeOpen, MenuComponent }; +} + +function getItems(links: LinkModel[]) { + const defaultGroup = 'Open in Explore'; + const groups = links.reduce<{ [group: string]: Array<{ l: LinkModel; newTitle?: string }> }>((acc, l) => { + let group; + let title; + if (l.title.indexOf('/') !== -1) { + group = l.title.split('/')[0]; + title = l.title.split('/')[1]; + acc[group] = acc[group] || []; + acc[group].push({ l, newTitle: title }); + } else { + acc[defaultGroup] = acc[defaultGroup] || []; + acc[defaultGroup].push({ l }); + } + + return acc; + }, {}); + + return Object.keys(groups).map(key => { + return { + label: key, + items: groups[key].map(link => ({ + label: link.newTitle || link.l.title, + url: link.l.href, + onClick: link.l.onClick, + })), + }; + }); +} + +function NodeHeader(props: { node: NodeDatum; nodes: DataFrame }) { + const index = props.node.dataFrameRowIndex; + const fields = getNodeFields(props.nodes); + return ( +
+ {fields.title &&
+ ); +} + +function EdgeHeader(props: { edge: EdgeDatum; edges: DataFrame }) { + const index = props.edge.dataFrameRowIndex; + const fields = getEdgeFields(props.edges); + return ( +
+ {fields.details.map(f => ( +
+ ); +} + +export const getLabelStyles = stylesFactory((theme: GrafanaTheme) => { + return { + label: css` + label: Label; + line-height: 1.25; + margin: ${theme.spacing.formLabelMargin}; + padding: ${theme.spacing.formLabelPadding}; + color: ${theme.colors.textFaint}; + font-size: ${theme.typography.size.sm}; + font-weight: ${theme.typography.weight.semibold}; + `, + value: css` + label: Value; + font-size: ${theme.typography.size.sm}; + font-weight: ${theme.typography.weight.semibold}; + color: ${theme.colors.formLabel}; + margin-top: ${theme.spacing.xxs}; + display: block; + `, + }; +}); +function Label(props: { field: Field; index: number }) { + const { field, index } = props; + const value = field.values.get(index) || ''; + const styles = getLabelStyles(useTheme()); + + return ( +
+
{field.config.displayName || field.name}
+ {value} +
+ ); +} diff --git a/packages/grafana-ui/src/components/NodeGraph/useNodeLimit.ts b/packages/grafana-ui/src/components/NodeGraph/useNodeLimit.ts new file mode 100644 index 00000000000..767d719618a --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/useNodeLimit.ts @@ -0,0 +1,50 @@ +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, nodes]); +} diff --git a/packages/grafana-ui/src/components/NodeGraph/usePanning.ts b/packages/grafana-ui/src/components/NodeGraph/usePanning.ts new file mode 100644 index 00000000000..82b109e05d7 --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/usePanning.ts @@ -0,0 +1,135 @@ +import { useEffect, useRef, RefObject, useState } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; + +export interface State { + isPanning: boolean; + position: { + x: number; + y: number; + }; +} + +interface Options { + scale?: number; + bounds?: { top: number; bottom: number; right: number; left: 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 + * here because we don't wont to change the pos when scale changes. + * @param bounds If set the panning cannot go outside of those bounds. + */ +export function usePanning( + { scale = 1, bounds }: Options = { scale: 1 } +): { state: State; ref: RefObject } { + const isMounted = useMountedState(); + const isPanning = useRef(false); + const frame = useRef(0); + const panRef = useRef(null); + + // 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 }); + // 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 [state, setState] = useState({ + isPanning: false, + position: { x: 0, y: 0 }, + }); + + useEffect(() => { + const startPanning = (event: Event) => { + if (!isPanning.current && isMounted()) { + isPanning.current = true; + // Snapshot the current position of both mouse pointer and the element + startMousePosition.current = getEventXY(event); + prevPosition.current = { ...currentPosition.current }; + setState(state => ({ ...state, isPanning: true })); + bindEvents(); + } + }; + + const stopPanning = () => { + if (isPanning.current && isMounted()) { + isPanning.current = false; + setState(state => ({ ...state, isPanning: false })); + unbindEvents(); + } + }; + + const onPanStart = (event: Event) => { + startPanning(event); + onPan(event); + }; + + const bindEvents = () => { + document.addEventListener('mousemove', onPan); + document.addEventListener('mouseup', stopPanning); + document.addEventListener('touchmove', onPan); + document.addEventListener('touchend', stopPanning); + }; + + const unbindEvents = () => { + document.removeEventListener('mousemove', onPan); + document.removeEventListener('mouseup', stopPanning); + document.removeEventListener('touchmove', onPan); + document.removeEventListener('touchend', stopPanning); + }; + + const onPan = (event: Event) => { + cancelAnimationFrame(frame.current); + const pos = getEventXY(event); + + frame.current = requestAnimationFrame(() => { + if (isMounted() && panRef.current) { + // Get the diff by which we moved the mouse. + let xDiff = pos.x - startMousePosition.current.x; + let yDiff = pos.y - startMousePosition.current.y; + + // 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), + }; + setState(state => ({ + ...state, + position: { + ...currentPosition.current, + }, + })); + } + }); + }; + + if (panRef.current) { + panRef.current.addEventListener('mousedown', onPanStart); + panRef.current.addEventListener('touchstart', onPanStart); + } + return () => { + if (panRef.current) { + panRef.current.removeEventListener('mousedown', onPanStart); + panRef.current.removeEventListener('touchstart', onPanStart); + } + }; + }, [scale, bounds?.left, bounds?.right, bounds?.top, bounds?.bottom]); + + return { state, ref: panRef }; +} + +function inBounds(value: number, min: number | undefined, max: number | undefined) { + return Math.min(Math.max(value, min ?? -Infinity), max ?? Infinity); +} + +function getEventXY(event: Event): { x: number; y: number } { + if ((event as any).changedTouches) { + const e = event as TouchEvent; + return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY }; + } else { + const e = event as MouseEvent; + return { x: e.clientX, y: e.clientY }; + } +} diff --git a/packages/grafana-ui/src/components/NodeGraph/useZoom.ts b/packages/grafana-ui/src/components/NodeGraph/useZoom.ts new file mode 100644 index 00000000000..379547c40f0 --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/useZoom.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +const defaultOptions: Options = { + stepDown: s => s / 1.5, + stepUp: s => s * 1.5, + min: 0.13, + max: 2.25, +}; + +interface Options { + /** + * Allows you to specify how the step up will be handled so you can do fractional steps based on previous value. + */ + stepUp: (scale: number) => number; + stepDown: (scale: number) => number; + + /** + * Set max and min values. If stepUp/down overshoots these bounds this will return min or max but internal scale value + * will still be what ever the step functions returned last. + */ + min?: number; + max?: number; +} + +/** + * Keeps state and returns handlers that can be used to implement zooming functionality ideally by using it with + * 'transform: scale'. It returns handler for manual buttons with zoom in/zoom out function and a ref that can be + * used to zoom in/out with mouse wheel. + */ +export function useZoom({ stepUp, stepDown, min, max } = defaultOptions) { + const ref = useRef(null); + const [scale, setScale] = useState(1); + + const onStepUp = useCallback(() => { + if (scale < (max ?? Infinity)) { + setScale(stepUp(scale)); + } + }, [scale, stepUp, max]); + + const onStepDown = useCallback(() => { + if (scale > (min ?? -Infinity)) { + setScale(stepDown(scale)); + } + }, [scale, stepDown, min]); + + const onWheel = useCallback( + function(event: Event) { + // Seems like typing for the addEventListener is lacking a bit + const wheelEvent = event as WheelEvent; + + // Only do this with special key pressed similar to how google maps work. + // TODO: I would guess this won't work very well with touch right now + if (wheelEvent.ctrlKey || wheelEvent.metaKey) { + event.preventDefault(); + + if (wheelEvent.deltaY < 0) { + onStepUp(); + } else if (wheelEvent.deltaY > 0) { + onStepDown(); + } + } + }, + [onStepDown, onStepUp] + ); + + useEffect(() => { + if (ref.current) { + // Adds listener for wheel event, we need the passive: false to be able to prevent default otherwise that + // cannot be used with passive listeners. + ref.current.addEventListener('wheel', onWheel, { passive: false }); + return () => { + if (ref.current) { + ref.current.removeEventListener('wheel', onWheel); + } + }; + } + return undefined; + }, [ref.current, onWheel]); + + return { + onStepUp, + onStepDown, + scale: Math.max(Math.min(scale, max ?? Infinity), min ?? -Infinity), + isMax: scale >= (max ?? Infinity), + isMin: scale <= (min ?? -Infinity), + ref, + }; +} diff --git a/packages/grafana-ui/src/components/NodeGraph/utils.test.ts b/packages/grafana-ui/src/components/NodeGraph/utils.test.ts new file mode 100644 index 00000000000..6a49be65737 --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/utils.test.ts @@ -0,0 +1,106 @@ +import { makeEdgesDataFrame, makeNodesDataFrame, processNodes } from './utils'; + +describe('processNodes', () => { + it('handles empty args', async () => { + expect(processNodes()).toEqual({ nodes: [], edges: [] }); + }); + + it('returns proper nodes and edges', async () => { + expect( + processNodes( + makeNodesDataFrame(3), + makeEdgesDataFrame([ + [0, 1], + [0, 2], + [1, 2], + ]) + ) + ).toEqual({ + nodes: [ + { + arcSections: [ + { + color: 'green', + value: 0.5, + }, + { + color: 'red', + value: 0.5, + }, + ], + 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, + }, + ], + 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, + }, + ], + 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/NodeGraph/utils.ts b/packages/grafana-ui/src/components/NodeGraph/utils.ts new file mode 100644 index 00000000000..8312bbf7132 --- /dev/null +++ b/packages/grafana-ui/src/components/NodeGraph/utils.ts @@ -0,0 +1,254 @@ +import { DataFrame, Field, FieldCache, FieldType, ArrayVector, MutableDataFrame } from '@grafana/data'; +import { EdgeDatum, NodeDatum } from './types'; +import { NodeGraphDataFrameFieldNames } from './index'; + +type Line = { x1: number; y1: number; x2: number; y2: number }; + +/** + * Makes line shorter while keeping the middle in he same place. + */ +export function shortenLine(line: Line, length: number): Line { + const vx = line.x2 - line.x1; + const vy = line.y2 - line.y1; + const mag = Math.sqrt(vx * vx + vy * vy); + const ratio = Math.max((mag - length) / mag, 0); + const vx2 = vx * ratio; + const vy2 = vy * ratio; + const xDiff = vx - vx2; + const yDiff = vy - vy2; + const newx1 = line.x1 + xDiff / 2; + const newy1 = line.y1 + yDiff / 2; + return { + x1: newx1, + y1: newy1, + x2: newx1 + vx2, + y2: newy1 + vy2, + }; +} + +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), + }; +} + +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), + }; +} + +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__', +} + +/** + * Transform nodes and edges dataframes into array of objects that the layout code can then work with. + */ +export function processNodes(nodes?: DataFrame, edges?: DataFrame): { nodes: NodeDatum[]; edges: EdgeDatum[] } { + if (!nodes) { + return { nodes: [], edges: [] }; + } + + const nodeFields = getNodeFields(nodes); + if (!nodeFields.id) { + throw new Error('id field is required for nodes data frame.'); + } + + const nodesMap = + nodeFields.id.values.toArray().reduce<{ [id: string]: NodeDatum }>((acc, id, index) => { + acc[id] = { + id: id, + title: nodeFields.title?.values.get(index) || '', + 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 || '', + }; + }), + }; + return acc; + }, {}) || {}; + + let edgesMapped: EdgeDatum[] = []; + // We may not have edges in case of single node + if (edges) { + const edgeFields = getEdgeFields(edges); + if (!edgeFields.id) { + throw new Error('id field is required for edges data frame.'); + } + + edgesMapped = edgeFields.id.values.toArray().map((id, index) => { + const target = edgeFields.target?.values.get(index); + const source = edgeFields.source?.values.get(index); + // We are adding incoming edges count so we can later on find out which nodes are the roots + nodesMap[target].incoming++; + + return { + id, + dataFrameRowIndex: index, + source, + target, + mainStat: edgeFields.mainStat ? statToString(edgeFields.mainStat, index) : '', + secondaryStat: edgeFields.secondaryStat ? statToString(edgeFields.secondaryStat, index) : '', + } as EdgeDatum; + }); + } + + return { + nodes: Object.values(nodesMap), + edges: edgesMapped || [], + }; +} + +function statToString(field: Field, index: number) { + if (field.type === FieldType.string) { + return field.values.get(index); + } else { + const decimals = field.config.decimals || 2; + const val = field.values.get(index); + if (Number.isFinite(val)) { + return field.values.get(index).toFixed(decimals) + (field.config.unit ? ' ' + field.config.unit : ''); + } else { + return ''; + } + } +} + +/** + * Utilities mainly for testing + */ + +export function makeNodesDataFrame(count: number) { + const frame = nodesFrame(); + for (let i = 0; i < count; i++) { + frame.add(makeNode(i)); + } + + return frame; +} + +function makeNode(index: number) { + return { + id: index.toString(), + title: `service:${index}`, + subTitle: 'service', + arc__success: 0.5, + arc__errors: 0.5, + mainStat: 0.1, + secondaryStat: 2, + }; +} + +function nodesFrame() { + const fields: any = { + [NodeGraphDataFrameFieldNames.id]: { + values: new ArrayVector(), + type: FieldType.string, + }, + [NodeGraphDataFrameFieldNames.title]: { + values: new ArrayVector(), + type: FieldType.string, + }, + [NodeGraphDataFrameFieldNames.subTitle]: { + values: new ArrayVector(), + type: FieldType.string, + }, + [NodeGraphDataFrameFieldNames.mainStat]: { + values: new ArrayVector(), + type: FieldType.number, + }, + [NodeGraphDataFrameFieldNames.secondaryStat]: { + values: new ArrayVector(), + type: FieldType.number, + }, + [NodeGraphDataFrameFieldNames.arc + 'success']: { + values: new ArrayVector(), + type: FieldType.number, + config: { color: { fixedColor: 'green' } }, + }, + [NodeGraphDataFrameFieldNames.arc + 'errors']: { + values: new ArrayVector(), + type: FieldType.number, + config: { color: { fixedColor: 'red' } }, + }, + }; + + return new MutableDataFrame({ + name: 'nodes', + fields: Object.keys(fields).map(key => ({ + ...fields[key], + name: key, + })), + meta: { preferredVisualisationType: 'nodeGraph' }, + }); +} + +export function makeEdgesDataFrame(edges: Array<[number, number]>) { + const frame = edgesFrame(); + for (const edge of edges) { + frame.add({ + id: edge[0] + '--' + edge[1], + source: edge[0].toString(), + target: edge[1].toString(), + }); + } + + return frame; +} + +function edgesFrame() { + const fields: any = { + [NodeGraphDataFrameFieldNames.id]: { + values: new ArrayVector(), + type: FieldType.string, + }, + [NodeGraphDataFrameFieldNames.source]: { + values: new ArrayVector(), + type: FieldType.string, + }, + [NodeGraphDataFrameFieldNames.target]: { + values: new ArrayVector(), + type: FieldType.string, + }, + }; + + return new MutableDataFrame({ + name: 'edges', + fields: Object.keys(fields).map(key => ({ + ...fields[key], + name: key, + })), + meta: { preferredVisualisationType: 'nodeGraph' }, + }); +} diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index e1f519ead12..b5a97322136 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -208,3 +208,4 @@ export { useRefreshAfterGraphRendered } from './uPlot/hooks'; export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context'; export { GraphNG, FIXED_UNIT } from './GraphNG/GraphNG'; export { GraphNGLegendEvent, GraphNGLegendEventMode } from './GraphNG/types'; +export * from './NodeGraph'; diff --git a/pkg/tsdb/testdatasource/scenarios.go b/pkg/tsdb/testdatasource/scenarios.go index dc5852b0327..d250f0d76d5 100644 --- a/pkg/tsdb/testdatasource/scenarios.go +++ b/pkg/tsdb/testdatasource/scenarios.go @@ -423,6 +423,12 @@ func init() { return queryRes }, }) + + registerScenario(&Scenario{ + Id: "node_graph", + Name: "Node Graph", + // Data generated in JS + }) } // PredictablePulseDesc is the description for the Predictable Pulse scenerio. diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index ecf3c834b68..f3fc5ebe352 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -100,6 +100,7 @@ const dummyProps: ExploreProps = { showLogs: true, showTable: true, showTrace: true, + showNodeGraph: true, splitOpen: (() => {}) as any, }; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 4e4a569d8e2..e81f64567ec 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -19,10 +19,10 @@ import { TimeZone, ExploreUrlState, LogsModel, - DataFrame, EventBusExtended, EventBusSrv, TraceViewData, + DataFrame, } from '@grafana/data'; import store from 'app/core/store'; @@ -54,6 +54,7 @@ import { TraceView } from './TraceView/TraceView'; import { SecondaryActions } from './SecondaryActions'; import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, FilterItem } from '@grafana/ui/src/components/Table/types'; import { ExploreGraphNGPanel } from './ExploreGraphNGPanel'; +import { NodeGraphContainer } from './NodeGraphContainer'; const getStyles = stylesFactory((theme: GrafanaTheme) => { return { @@ -113,6 +114,7 @@ export interface ExploreProps { showTable: boolean; showLogs: boolean; showTrace: boolean; + showNodeGraph: boolean; splitOpen: typeof splitOpen; } @@ -276,13 +278,89 @@ export class Explore extends React.PureComponent { } }; - renderEmptyState = () => { + renderEmptyState() { return (
); - }; + } + + renderGraphPanel(width: number) { + const { graphResult, absoluteRange, timeZone, splitOpen, queryResponse } = this.props; + const isLoading = queryResponse.state === LoadingState.Loading; + return ( + + ); + } + + renderTablePanel(width: number) { + const { exploreId, datasourceInstance } = this.props; + return ( + + ); + } + + renderLogsPanel(width: number) { + const { exploreId, syncedTimes } = this.props; + return ( + + ); + } + + renderNodeGraphPanel() { + const { exploreId, showTrace, queryResponse } = this.props; + return ( + + ); + } + + getNodeGraphDataFrames = memoizeOne((frames: DataFrame[]) => { + // TODO: this not in sync with how other types of responses are handled. Other types have a query response + // processing pipeline which ends up populating redux state with proper data. As we move towards more dataFrame + // oriented API it seems like a better direction to move such processing into to visualisations and do minimal + // and lazy processing here. Needs bigger refactor so keeping nodeGraph and Traces as they are for now. + return frames.filter(frame => frame.meta?.preferredVisualisationType === 'nodeGraph'); + }); + + renderTraceViewPanel() { + const { queryResponse, splitOpen } = this.props; + const dataFrames = queryResponse.series.filter(series => series.meta?.preferredVisualisationType === 'trace'); + + return ( + // We expect only one trace at the moment to be in the dataframe + // If there is no data (like 404) we show a separate error so no need to show anything here + dataFrames[0] && ( + + ) + ); + } render() { const { @@ -292,24 +370,20 @@ export class Explore extends React.PureComponent { split, queryKeys, graphResult, - absoluteRange, - timeZone, queryResponse, - syncedTimes, isLive, theme, showMetrics, showTable, showLogs, showTrace, - splitOpen, + showNodeGraph, } = this.props; const { openDrawer } = this.state; const exploreClass = split ? 'explore explore-split' : 'explore'; const styles = getStyles(theme); const StartPage = datasourceInstance?.components?.ExploreStartPage; const showStartPage = !queryResponse || queryResponse.state === LoadingState.NotStarted; - const isLoading = queryResponse.state === LoadingState.Loading; // gets an error without a refID, so non-query-row-related error, like a connection error const queryErrors = @@ -360,49 +434,11 @@ export class Explore extends React.PureComponent { )} {!showStartPage && ( <> - {showMetrics && graphResult && ( - - )} - {showTable && ( - - )} - {showLogs && ( - - )} - {/* TODO:unification */} - {showTrace && - // We expect only one trace at the moment to be in the dataframe - // If there is not data (like 404) we show a separate error so no need to show anything here - queryResponse.series[0] && ( - - )} + {showMetrics && graphResult && this.renderGraphPanel(width)} + {showTable && this.renderTablePanel(width)} + {showLogs && this.renderLogsPanel(width)} + {showNodeGraph && this.renderNodeGraphPanel()} + {showTrace && this.renderTraceViewPanel()} )} {showRichHistory && ( @@ -455,6 +491,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partia showTrace, absoluteRange, queryResponse, + showNodeGraph, } = item; const { datasource, queries, range: urlRange, originPanelId } = (urlState || {}) as ExploreUrlState; @@ -486,6 +523,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partia showMetrics, showTable, showTrace, + showNodeGraph, }; } diff --git a/public/app/features/explore/NodeGraphContainer.tsx b/public/app/features/explore/NodeGraphContainer.tsx new file mode 100644 index 00000000000..b289d84457e --- /dev/null +++ b/public/app/features/explore/NodeGraphContainer.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Badge, NodeGraph } 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 { Collapse } from '@grafana/ui'; +import { useLinks } from './utils/links'; + +interface Props { + // Edges and Nodes are separate frames + dataFrames: DataFrame[]; + exploreId: ExploreId; + range: TimeRange; + splitOpen: typeof splitOpen; + short?: boolean; +} +export function UnconnectedNodeGraphContainer(props: Props & ConnectedProps) { + const { dataFrames, range, splitOpen, short } = props; + const getLinks = useLinks(range, splitOpen); + + return ( +
+ + Node graph + + } + isOpen + > + + +
+ ); +} + +function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { + return { + range: state.explore[exploreId].range, + }; +} + +const mapDispatchToProps = { + splitOpen, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); +export const NodeGraphContainer = connector(UnconnectedNodeGraphContainer); diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts index 5e2bf7a5351..22493e7b5cd 100644 --- a/public/app/features/explore/state/main.ts +++ b/public/app/features/explore/state/main.ts @@ -86,6 +86,11 @@ export function splitOpen(options?: { rightState.queryKeys = []; urlState.queries = []; rightState.urlState = urlState; + rightState.showLogs = false; + rightState.showMetrics = false; + rightState.showNodeGraph = false; + rightState.showTrace = false; + rightState.showTable = false; if (options.range) { urlState.range = options.range.raw; // This is super hacky. In traces to logs we want to create a link but also internally open split window. diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 90015ba561f..414cf670f9f 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -29,7 +29,7 @@ import { getShiftedTimeRange } from 'app/core/utils/timePicker'; import { notifyApp } from '../../../core/actions'; import { preProcessPanelData, runRequest } from '../../query/state/runRequest'; import { - decorateWithGraphLogsTraceAndTable, + decorateWithFrameTypeMetadata, decorateWithGraphResult, decorateWithLogsResult, decorateWithTableResult, @@ -356,7 +356,7 @@ export const runQueries = (exploreId: ExploreId): ThunkResult => { // actually can see what is happening. live ? throttleTime(500) : identity, map((data: PanelData) => preProcessPanelData(data, queryResponse)), - map(decorateWithGraphLogsTraceAndTable), + map(decorateWithFrameTypeMetadata), map(decorateWithGraphResult), map(decorateWithLogsResult({ absoluteRange, refreshInterval })), mergeMap(decorateWithTableResult) @@ -639,7 +639,17 @@ export const processQueryResponse = ( action: PayloadAction ): ExploreItemState => { const { response } = action.payload; - const { request, state: loadingState, series, error, graphResult, logsResult, tableResult, traceFrames } = response; + const { + request, + state: loadingState, + series, + error, + graphResult, + logsResult, + tableResult, + traceFrames, + nodeGraphFrames, + } = response; if (error) { if (error.type === DataQueryErrorType.Timeout) { @@ -692,5 +702,6 @@ export const processQueryResponse = ( showMetrics: !!graphResult, showTable: !!tableResult, showTrace: !!traceFrames.length, + showNodeGraph: !!nodeGraphFrames.length, }; }; diff --git a/public/app/features/explore/utils/decorators.test.ts b/public/app/features/explore/utils/decorators.test.ts index 073e456e801..24c17aed6c6 100644 --- a/public/app/features/explore/utils/decorators.test.ts +++ b/public/app/features/explore/utils/decorators.test.ts @@ -15,7 +15,7 @@ import { } from '@grafana/data'; import { - decorateWithGraphLogsTraceAndTable, + decorateWithFrameTypeMetadata, decorateWithGraphResult, decorateWithLogsResult, decorateWithTableResult, @@ -78,6 +78,7 @@ const createExplorePanelData = (args: Partial): ExplorePanelDa tableFrames: [], tableResult: (undefined as unknown) as null, traceFrames: [], + nodeGraphFrames: [], }; return { ...defaults, ...args }; @@ -93,7 +94,7 @@ describe('decorateWithGraphLogsTraceAndTable', () => { timeRange: ({} as unknown) as TimeRange, }; - expect(decorateWithGraphLogsTraceAndTable(panelData)).toEqual({ + expect(decorateWithFrameTypeMetadata(panelData)).toEqual({ series, state: LoadingState.Done, timeRange: {}, @@ -101,6 +102,7 @@ describe('decorateWithGraphLogsTraceAndTable', () => { tableFrames: [table, emptyTable], logsFrames: [logs], traceFrames: [], + nodeGraphFrames: [], graphResult: null, tableResult: null, logsResult: null, @@ -115,7 +117,7 @@ describe('decorateWithGraphLogsTraceAndTable', () => { timeRange: ({} as unknown) as TimeRange, }; - expect(decorateWithGraphLogsTraceAndTable(panelData)).toEqual({ + expect(decorateWithFrameTypeMetadata(panelData)).toEqual({ series: [], state: LoadingState.Done, timeRange: {}, @@ -123,6 +125,7 @@ describe('decorateWithGraphLogsTraceAndTable', () => { tableFrames: [], logsFrames: [], traceFrames: [], + nodeGraphFrames: [], graphResult: null, tableResult: null, logsResult: null, @@ -139,7 +142,7 @@ describe('decorateWithGraphLogsTraceAndTable', () => { timeRange: ({} as unknown) as TimeRange, }; - expect(decorateWithGraphLogsTraceAndTable(panelData)).toEqual({ + expect(decorateWithFrameTypeMetadata(panelData)).toEqual({ series: [timeSeries, logs, table], error: {}, state: LoadingState.Error, @@ -148,6 +151,7 @@ describe('decorateWithGraphLogsTraceAndTable', () => { tableFrames: [], logsFrames: [], traceFrames: [], + nodeGraphFrames: [], graphResult: null, tableResult: null, logsResult: null, diff --git a/public/app/features/explore/utils/decorators.ts b/public/app/features/explore/utils/decorators.ts index 019858c5203..32c7c781765 100644 --- a/public/app/features/explore/utils/decorators.ts +++ b/public/app/features/explore/utils/decorators.ts @@ -20,7 +20,7 @@ import { ExplorePanelData } from '../../../types'; * dataFrames with different type of data. This is later used for type specific processing. As we use this in * Observable pipeline, it decorates the existing panelData to pass the results to later processing stages. */ -export const decorateWithGraphLogsTraceAndTable = (data: PanelData): ExplorePanelData => { +export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData => { if (data.error) { return { ...data, @@ -28,6 +28,7 @@ export const decorateWithGraphLogsTraceAndTable = (data: PanelData): ExplorePane tableFrames: [], logsFrames: [], traceFrames: [], + nodeGraphFrames: [], graphResult: null, tableResult: null, logsResult: null, @@ -38,6 +39,7 @@ export const decorateWithGraphLogsTraceAndTable = (data: PanelData): ExplorePane const tableFrames: DataFrame[] = []; const logsFrames: DataFrame[] = []; const traceFrames: DataFrame[] = []; + const nodeGraphFrames: DataFrame[] = []; for (const frame of data.series) { switch (frame.meta?.preferredVisualisationType) { @@ -53,6 +55,9 @@ export const decorateWithGraphLogsTraceAndTable = (data: PanelData): ExplorePane case 'table': tableFrames.push(frame); break; + case 'nodeGraph': + nodeGraphFrames.push(frame); + break; default: if (isTimeSeries(frame)) { graphFrames.push(frame); @@ -70,6 +75,7 @@ export const decorateWithGraphLogsTraceAndTable = (data: PanelData): ExplorePane tableFrames, logsFrames, traceFrames, + nodeGraphFrames, graphResult: null, tableResult: null, logsResult: null, diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index a9c9e6ea4f5..8dce44f0dc3 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { Field, LinkModel, @@ -92,3 +93,29 @@ function getTitleFromHref(href: string): string { } return title; } + +/** + * Hook that returns a function that can be used to retrieve all the links for a row. This returns all the links from + * all the fields so is useful for visualisation where the whole row is represented as single clickable item like a + * service map. + */ +export function useLinks(range: TimeRange, splitOpenFn?: typeof splitOpen) { + return useCallback( + (dataFrame: DataFrame, rowIndex: number) => { + return dataFrame.fields.flatMap(f => { + if (f.config?.links && f.config?.links.length) { + return getFieldLinksForExplore({ + field: f, + rowIndex: rowIndex, + range, + dataFrame, + splitOpenFn, + }); + } else { + return []; + } + }); + }, + [range, splitOpenFn] + ); +} diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index f731c6be991..9277f2b99b6 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -59,6 +59,7 @@ import * as logsPanel from 'app/plugins/panel/logs/module'; import * as newsPanel from 'app/plugins/panel/news/module'; import * as livePanel from 'app/plugins/panel/live/module'; import * as welcomeBanner from 'app/plugins/panel/welcome/module'; +import * as nodeGraph from 'app/plugins/panel/nodeGraph/module'; const builtInPlugins: any = { 'app/plugins/datasource/graphite/module': graphitePlugin, @@ -102,6 +103,7 @@ const builtInPlugins: any = { 'app/plugins/panel/bargauge/module': barGaugePanel, 'app/plugins/panel/logs/module': logsPanel, 'app/plugins/panel/welcome/module': welcomeBanner, + 'app/plugins/panel/nodeGraph/module': nodeGraph, }; export default builtInPlugins; diff --git a/public/app/plugins/datasource/testdata/QueryEditor.tsx b/public/app/plugins/datasource/testdata/QueryEditor.tsx index f3fd3f889ec..a97f9c3acb2 100644 --- a/public/app/plugins/datasource/testdata/QueryEditor.tsx +++ b/public/app/plugins/datasource/testdata/QueryEditor.tsx @@ -10,11 +10,12 @@ import { StreamingClientEditor, ManualEntryEditor, RandomWalkEditor } from './co // Types import { TestDataDataSource } from './datasource'; -import { TestDataQuery, Scenario } from './types'; +import { TestDataQuery, Scenario, NodesQuery } from './types'; import { PredictablePulseEditor } from './components/PredictablePulseEditor'; import { CSVWaveEditor } from './components/CSVWaveEditor'; import { defaultQuery } from './constants'; import { GrafanaLiveEditor } from './components/GrafanaLiveEditor'; +import { NodeGraphEditor } from './components/NodeGraphEditor'; const showLabelsFor = ['random_walk', 'predictable_pulse', 'predictable_csv_wave']; const endpoints = [ @@ -234,6 +235,9 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props) {scenarioId === 'predictable_pulse' && } {scenarioId === 'predictable_csv_wave' && } + {scenarioId === 'node_graph' && ( + onChange({ ...query, nodes: val })} query={query} /> + )} ); }; diff --git a/public/app/plugins/datasource/testdata/components/NodeGraphEditor.tsx b/public/app/plugins/datasource/testdata/components/NodeGraphEditor.tsx new file mode 100644 index 00000000000..9a80b1acdda --- /dev/null +++ b/public/app/plugins/datasource/testdata/components/NodeGraphEditor.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Input, InlineFieldRow, InlineField, Select } from '@grafana/ui'; +import { NodesQuery, TestDataQuery } from '../types'; + +export interface Props { + onChange: (value: NodesQuery) => void; + query: TestDataQuery; +} +export function NodeGraphEditor({ query, onChange }: Props) { + const type = query.nodes?.type || 'random'; + return ( + + + + options={options.map(o => ({ + label: o, + value: o, + }))} + value={options.find(item => item === type)} + onChange={value => onChange({ ...query.nodes, type: value.value! })} + width={32} + /> + + {type === 'random' && ( + + + onChange({ ...query.nodes, count: e.currentTarget.value ? parseInt(e.currentTarget.value, 10) : 0 }) + } + placeholder="10" + /> + + )} + + ); +} + +const options: Array = ['random', 'response']; diff --git a/public/app/plugins/datasource/testdata/datasource.ts b/public/app/plugins/datasource/testdata/datasource.ts index 634e1e97762..f15858cdee7 100644 --- a/public/app/plugins/datasource/testdata/datasource.ts +++ b/public/app/plugins/datasource/testdata/datasource.ts @@ -32,6 +32,7 @@ import { queryMetricTree } from './metricTree'; import { runStream } from './runStreams'; import { getSearchFilterScopedVar } from 'app/features/variables/utils'; import { TestDataVariableSupport } from './variables'; +import { generateRandomNodes, savedNodesResponse } from './nodeGraphUtils'; type TestData = TimeSeries | TableData; @@ -75,6 +76,9 @@ export class TestDataDataSource extends DataSourceApi { case 'variables-query': streams.push(this.variablesQuery(target, options)); break; + case 'node_graph': + streams.push(this.nodesQuery(target, options)); + break; default: queries.push({ ...target, @@ -204,6 +208,23 @@ export class TestDataDataSource extends DataSourceApi { return of({ data: [dataFrame] }).pipe(delay(100)); } + + nodesQuery(target: TestDataQuery, options: DataQueryRequest): Observable { + const type = target.nodes?.type || 'random'; + let frames: DataFrame[]; + switch (type) { + case 'random': + frames = generateRandomNodes(target.nodes?.count); + break; + case 'response': + frames = savedNodesResponse(); + break; + default: + throw new Error(`Unknown node_graph sub type ${type}`); + } + + return of({ data: frames }).pipe(delay(100)); + } } function runArrowFile(target: TestDataQuery, req: DataQueryRequest): Observable { diff --git a/public/app/plugins/datasource/testdata/nodeGraphUtils.ts b/public/app/plugins/datasource/testdata/nodeGraphUtils.ts new file mode 100644 index 00000000000..756c2230945 --- /dev/null +++ b/public/app/plugins/datasource/testdata/nodeGraphUtils.ts @@ -0,0 +1,155 @@ +import { ArrayVector, FieldType, MutableDataFrame } from '@grafana/data'; +import { nodes, edges } from './testData/serviceMapResponse'; +import { NodeGraphDataFrameFieldNames } from '@grafana/ui'; + +export function generateRandomNodes(count = 10) { + const nodes = []; + + const root = { + id: '0', + title: 'root', + subTitle: 'client', + success: 1, + error: 0, + stat1: Math.random(), + stat2: Math.random(), + edges: [] as any[], + }; + nodes.push(root); + const nodesWithoutMaxEdges = [root]; + + const maxEdges = 3; + + for (let i = 1; i < count; i++) { + const node = makeRandomNode(i); + nodes.push(node); + const sourceIndex = Math.floor(Math.random() * Math.floor(nodesWithoutMaxEdges.length - 1)); + const source = nodesWithoutMaxEdges[sourceIndex]; + source.edges.push(node.id); + if (source.edges.length >= maxEdges) { + nodesWithoutMaxEdges.splice(sourceIndex, 1); + } + nodesWithoutMaxEdges.push(node); + } + + // Add some random edges to create possible cycle + const additionalEdges = Math.floor(count / 2); + for (let i = 0; i <= additionalEdges; i++) { + const sourceIndex = Math.floor(Math.random() * Math.floor(nodes.length - 1)); + const targetIndex = Math.floor(Math.random() * Math.floor(nodes.length - 1)); + if (sourceIndex === targetIndex || nodes[sourceIndex].id === '0' || nodes[sourceIndex].id === '0') { + continue; + } + + nodes[sourceIndex].edges.push(nodes[sourceIndex].id); + } + + const nodeFields: any = { + [NodeGraphDataFrameFieldNames.id]: { + values: new ArrayVector(), + type: FieldType.string, + }, + [NodeGraphDataFrameFieldNames.title]: { + values: new ArrayVector(), + type: FieldType.string, + }, + [NodeGraphDataFrameFieldNames.subTitle]: { + values: new ArrayVector(), + type: FieldType.string, + }, + [NodeGraphDataFrameFieldNames.mainStat]: { + values: new ArrayVector(), + type: FieldType.number, + }, + [NodeGraphDataFrameFieldNames.secondaryStat]: { + values: new ArrayVector(), + type: FieldType.number, + }, + [NodeGraphDataFrameFieldNames.arc + 'success']: { + values: new ArrayVector(), + type: FieldType.number, + config: { color: { fixedColor: 'green' } }, + }, + [NodeGraphDataFrameFieldNames.arc + 'errors']: { + values: new ArrayVector(), + type: FieldType.number, + config: { color: { fixedColor: 'red' } }, + }, + }; + + const nodeFrame = new MutableDataFrame({ + name: 'nodes', + fields: Object.keys(nodeFields).map(key => ({ + ...nodeFields[key], + name: key, + })), + meta: { preferredVisualisationType: 'nodeGraph' }, + }); + + const edgeFields: any = { + [NodeGraphDataFrameFieldNames.id]: { + values: new ArrayVector(), + type: FieldType.string, + }, + [NodeGraphDataFrameFieldNames.source]: { + values: new ArrayVector(), + type: FieldType.string, + }, + [NodeGraphDataFrameFieldNames.target]: { + values: new ArrayVector(), + type: FieldType.string, + }, + }; + + const edgesFrame = new MutableDataFrame({ + name: 'edges', + fields: Object.keys(edgeFields).map(key => ({ + ...edgeFields[key], + name: key, + })), + meta: { preferredVisualisationType: 'nodeGraph' }, + }); + + const edgesSet = new Set(); + for (const node of nodes) { + nodeFields.id.values.add(node.id); + nodeFields.title.values.add(node.title); + nodeFields.subTitle.values.add(node.subTitle); + nodeFields.mainStat.values.add(node.stat1); + nodeFields.secondaryStat.values.add(node.stat2); + nodeFields.arc__success.values.add(node.success); + nodeFields.arc__errors.values.add(node.error); + for (const edge of node.edges) { + const id = `${node.id}--${edge}`; + // We can have duplicate edges when we added some more by random + if (edgesSet.has(id)) { + continue; + } + edgesSet.add(id); + edgeFields.id.values.add(`${node.id}--${edge}`); + edgeFields.source.values.add(node.id); + edgeFields.target.values.add(edge); + } + } + + return [nodeFrame, edgesFrame]; +} + +function makeRandomNode(index: number) { + const success = Math.random(); + const error = 1 - success; + return { + id: index.toString(), + title: `service:${index}`, + subTitle: 'service', + success, + error, + stat1: Math.random(), + stat2: Math.random(), + edges: [], + }; +} + +export function savedNodesResponse(): any { + return [new MutableDataFrame(nodes), new MutableDataFrame(edges)]; +} diff --git a/public/app/plugins/datasource/testdata/testData/serviceMapResponse.ts b/public/app/plugins/datasource/testdata/testData/serviceMapResponse.ts new file mode 100644 index 00000000000..e47a6e1a587 --- /dev/null +++ b/public/app/plugins/datasource/testdata/testData/serviceMapResponse.ts @@ -0,0 +1,416 @@ +import { FieldColorModeId, FieldType, PreferredVisualisationType } from '@grafana/data'; +import { NodeGraphDataFrameFieldNames } from '@grafana/ui'; + +export const nodes = { + fields: [ + { + name: NodeGraphDataFrameFieldNames.id, + type: FieldType.string, + config: { + links: [ + { + title: 'Traces/All', + url: '', + internal: { + query: { + queryType: 'getTraceSummaries', + query: 'service(id(name: "${__data.fields.name}", type: "${__data.fields.type}"))', + }, + datasourceUid: 'Ax4erxHGz', + datasourceName: 'Trace data source', + }, + }, + { + title: 'Traces/OK', + url: '', + internal: { + query: { + queryType: 'getTraceSummaries', + query: 'service(id(name: "${__data.fields.name}", type: "${__data.fields.type}")) { ok = true }', + }, + datasourceUid: 'Ax4erxHGz', + datasourceName: 'Trace data source', + }, + }, + { + title: 'Traces/Errors', + url: '', + internal: { + query: { + queryType: 'getTraceSummaries', + query: 'service(id(name: "${__data.fields.name}", type: "${__data.fields.type}")) { error = true }', + }, + datasourceUid: 'Ax4erxHGz', + datasourceName: 'Trace data source', + }, + }, + { + title: 'Traces/Faults', + url: '', + internal: { + query: { + queryType: 'getTraceSummaries', + query: 'service(id(name: "${__data.fields.name}", type: "${__data.fields.type}")) { fault = true }', + }, + datasourceUid: 'Ax4erxHGz', + datasourceName: 'Trace data source', + }, + }, + ], + }, + values: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + }, + { + name: NodeGraphDataFrameFieldNames.title, + type: FieldType.string, + config: { displayName: 'Name' }, + values: [ + 'auth', + 'products', + 'customers', + 'orders', + 'products', + 'orders', + 'api', + 'shipping', + 'orders', + 'execute-api', + 'shipping', + 'www', + 'api', + 'www', + 'products', + ], + }, + { + name: NodeGraphDataFrameFieldNames.subTitle, + type: FieldType.string, + config: { displayName: 'Type' }, + values: [ + 'Compute', + 'SQL', + 'SQL', + 'SQL', + 'remote', + 'Function', + 'Compute', + 'Function', + 'Function', + 'remote', + 'Function', + 'Compute', + 'client', + 'client', + 'Compute', + ], + }, + { + name: NodeGraphDataFrameFieldNames.mainStat, + type: FieldType.number, + config: { unit: 'ms/t', displayName: 'Average response time' }, + values: [ + 3.5394042646735553, + 15.906441318223264, + 4.913011921591567, + 7.4163203042094095, + 1092, + 22.85961441405067, + 56.135855729084696, + 4.45946191601527, + 12.818300278280843, + 4.25, + 12.565442646791492, + 77.63447512700567, + 40.387096774193544, + 77.63447512700567, + 27.648950187374872, + ], + }, + { + name: NodeGraphDataFrameFieldNames.secondaryStat, + type: FieldType.number, + config: { unit: 't/min', displayName: 'Transactions per minute' }, + values: [ + 50.56317154501667, + 682.4, + 512.8416666666667, + 125.64444444444445, + 0.005585812037424941, + 137.59722222222223, + 300.0527777777778, + 30.582348853370394, + 125.77222222222223, + 0.028706417080318163, + 30.582348853370394, + 165.675, + 0.100021510002151, + 165.675, + 162.33055555555555, + ], + }, + { + name: NodeGraphDataFrameFieldNames.arc + 'success', + type: FieldType.number, + config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'green' }, displayName: 'Sucesss' }, + values: [ + 0.9338865684765882, + 1, + 1, + 1, + 0.5, + 1, + 0.9901128505170387, + 0.9069260134520997, + 1, + 0, + 0.9069260134520997, + 0.9624432037288534, + 0, + 0.9624432037288534, + 0.9824945669843769, + ], + }, + { + name: NodeGraphDataFrameFieldNames.arc + 'faults', + type: FieldType.number, + config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'red' }, displayName: 'faults' }, + values: [ + 0, + 0, + 0, + 0, + 0.5, + 0, + 0.009479813736472288, + 0, + 0, + 0, + 0, + 0.017168821152524185, + 0, + 0.017168821152524185, + 0.01750543301562313, + ], + }, + { + name: NodeGraphDataFrameFieldNames.arc + 'errors', + type: FieldType.number, + config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'semi-dark-yellow' }, displayName: 'Errors' }, + values: [ + 0.06611343152341174, + 0, + 0, + 0, + 0, + 0, + 0.0004073357464890436, + 0.09307398654790038, + 0, + 1, + 0.09307398654790038, + 0.02038797511862247, + 1, + 0.02038797511862247, + 0, + ], + }, + { + name: NodeGraphDataFrameFieldNames.arc + 'throttled', + type: FieldType.number, + config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'purple' }, displayName: 'Throttled' }, + values: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + ], + meta: { preferredVisualisationType: 'nodeGraph' as PreferredVisualisationType }, + name: 'nodes', +}; + +export const edges = { + fields: [ + { + name: NodeGraphDataFrameFieldNames.id, + type: FieldType.string, + config: { + links: [ + { + title: 'Traces/All', + url: '', + internal: { + query: { + queryType: 'getTraceSummaries', + query: 'edge("${__data.fields.sourceName}", "${__data.fields.targetName}")', + }, + datasourceUid: 'Ax4erxHGz', + datasourceName: 'Trace data source', + }, + }, + { + title: 'Traces/OK', + url: '', + internal: { + query: { + queryType: 'getTraceSummaries', + query: 'edge("${__data.fields.sourceName}", "${__data.fields.targetName}") { ok = true }', + }, + datasourceUid: 'Ax4erxHGz', + datasourceName: 'Trace data source', + }, + }, + { + title: 'Traces/Errors', + url: '', + internal: { + query: { + queryType: 'getTraceSummaries', + query: 'edge("${__data.fields.sourceName}", "${__data.fields.targetName}") { error = true }', + }, + datasourceUid: 'Ax4erxHGz', + datasourceName: 'Trace data source', + }, + }, + { + title: 'Traces/Faults', + url: '', + internal: { + query: { + queryType: 'getTraceSummaries', + query: 'edge("${__data.fields.sourceName}", "${__data.fields.targetName}") { fault = true }', + }, + datasourceUid: 'Ax4erxHGz', + datasourceName: 'Trace data source', + }, + }, + ], + }, + values: [ + '0__2', + '5__8', + '6__0', + '6__5', + '6__9', + '6__2', + '6__14', + '6__4', + '8__3', + '10__7', + '11__0', + '11__6', + '12__6', + '13__11', + '14__1', + '14__2', + '14__10', + ], + }, + { + name: NodeGraphDataFrameFieldNames.source, + type: FieldType.string, + config: {}, + values: [0, 5, 6, 6, 6, 6, 6, 6, 8, 10, 11, 11, 12, 13, 14, 14, 14], + }, + { + name: 'sourceName', + type: FieldType.string, + config: {}, + values: [ + 'auth', + 'orders', + 'api', + 'api', + 'api', + 'api', + 'api', + 'api', + 'orders', + 'shipping', + 'www', + 'www', + 'api', + 'www', + 'products', + 'products', + 'products', + ], + }, + { + name: NodeGraphDataFrameFieldNames.target, + type: FieldType.string, + config: {}, + values: [2, 8, 0, 5, 9, 2, 14, 4, 3, 7, 0, 6, 6, 11, 1, 2, 10], + }, + { + name: 'targetName', + type: FieldType.string, + config: {}, + values: [ + 'customers', + 'orders', + 'auth', + 'orders', + 'execute-api', + 'customers', + 'products', + 'products', + 'orders', + 'shipping', + 'auth', + 'api', + 'api', + 'www', + 'products', + 'customers', + 'shipping', + ], + }, + { + name: NodeGraphDataFrameFieldNames.mainStat, + type: FieldType.string, + config: { displayName: 'Response percentage' }, + values: [ + 'Success 100.00%', + 'Success 100.00%', + 'Success 100.00%', + 'Success 100.00%', + 'Errors 100.00%', + 'Success 100.00%', + 'Faults 1.75%', + 'Faults 50.00%', + 'Success 100.00%', + 'Errors 9.31%', + 'Errors 6.62%', + 'Faults 1.13%', + 'Errors 100.00%', + 'Faults 1.72%', + 'Success 100.00%', + 'Success 100.00%', + 'Faults 9.30%', + ], + }, + { + name: NodeGraphDataFrameFieldNames.secondaryStat, + type: FieldType.number, + config: { unit: 't/min', displayName: 'Transactions per minute' }, + values: [ + 50.56317154501667, + 125.77222222222223, + 0.03333333333333333, + 137.59722222222223, + 0.022222222222222223, + 299.96666666666664, + 162.33055555555555, + 0.005555555555555556, + 125.64444444444445, + 30.582348853370394, + 50.51111111111111, + 299.9166666666667, + 0.100021510002151, + 165.675, + 682.4, + 162.33055555555555, + 30.558333333333334, + ], + }, + ], + meta: { preferredVisualisationType: 'nodeGraph' as PreferredVisualisationType }, + name: 'edges', +}; diff --git a/public/app/plugins/datasource/testdata/types.ts b/public/app/plugins/datasource/testdata/types.ts index 43c6003ee7b..8621b9decba 100644 --- a/public/app/plugins/datasource/testdata/types.ts +++ b/public/app/plugins/datasource/testdata/types.ts @@ -26,6 +26,12 @@ export interface TestDataQuery extends DataQuery { lines?: number; levelColumn?: boolean; channel?: string; // for grafana live + nodes?: NodesQuery; +} + +export interface NodesQuery { + type?: 'random' | 'response'; + count?: number; } export interface StreamingQuery { diff --git a/public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx b/public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx new file mode 100644 index 00000000000..e6775ca2793 --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { PanelProps } from '@grafana/data'; +import { Options } from './types'; +import { NodeGraph } from '@grafana/ui'; +import { useLinks } from '../../../features/explore/utils/links'; + +export const NodeGraphPanel: React.FunctionComponent> = ({ width, height, data }) => { + if (!data || !data.series.length) { + return ( +
+

No data found in response

+
+ ); + } + + const getLinks = useLinks(data.timeRange); + + return ( +
+ +
+ ); +}; diff --git a/public/app/plugins/panel/nodeGraph/img/icn-node-graph.svg b/public/app/plugins/panel/nodeGraph/img/icn-node-graph.svg new file mode 100644 index 00000000000..b84bfb0de01 --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/img/icn-node-graph.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/app/plugins/panel/nodeGraph/module.tsx b/public/app/plugins/panel/nodeGraph/module.tsx new file mode 100644 index 00000000000..60aec529aac --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/module.tsx @@ -0,0 +1,5 @@ +import { PanelPlugin } from '@grafana/data'; +import { NodeGraphPanel } from './NodeGraphPanel'; +import { Options } from './types'; + +export const plugin = new PanelPlugin(NodeGraphPanel); diff --git a/public/app/plugins/panel/nodeGraph/plugin.json b/public/app/plugins/panel/nodeGraph/plugin.json new file mode 100644 index 00000000000..eeddf8d5c0b --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/plugin.json @@ -0,0 +1,17 @@ +{ + "type": "panel", + "name": "Node Graph", + "id": "nodeGraph", + "state": "beta", + + "info": { + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/icn-node-graph.svg", + "large": "img/icn-node-graph.svg" + } + } +} diff --git a/public/app/plugins/panel/nodeGraph/types.ts b/public/app/plugins/panel/nodeGraph/types.ts new file mode 100644 index 00000000000..88025b922d0 --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/types.ts @@ -0,0 +1 @@ +export interface Options {} diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index e8a148dbe53..a23391fd55f 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -172,6 +172,7 @@ export interface ExploreItemState { showMetrics?: boolean; showTable?: boolean; showTrace?: boolean; + showNodeGraph?: boolean; } export interface ExploreUpdateState { @@ -215,6 +216,7 @@ export interface ExplorePanelData extends PanelData { tableFrames: DataFrame[]; logsFrames: DataFrame[]; traceFrames: DataFrame[]; + nodeGraphFrames: DataFrame[]; graphResult: DataFrame[] | null; tableResult: DataFrame | null; logsResult: LogsModel | null; diff --git a/yarn.lock b/yarn.lock index 311371dd623..54882cdfea7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6028,6 +6028,11 @@ resolved "https://registry.yarnpkg.com/@types/d3-force/-/d3-force-1.2.1.tgz#c28803ea36fe29788db69efa0ad6c2dc09544e83" integrity sha512-jqK+I36uz4kTBjyk39meed5y31Ab+tXYN/x1dn3nZEus9yOHCLc+VrcIYLc/aSQ0Y7tMPRlIhLetulME76EiiA== +"@types/d3-force@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-force/-/d3-force-2.1.0.tgz#6a2210f04d02a0862c6b069de91bad904143e7b5" + integrity sha512-LGDtC2YADu8OBniq9EBx/MOsXsMcJbEkmfSpXuz6oVdRamB+3CLCiq5EKFPEILGZQckkilGFq1ZTJ7kc289k+Q== + "@types/d3-format@*": version "1.3.1" resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-1.3.1.tgz#35bf88264bd6bcda39251165bb827f67879c4384" @@ -11089,6 +11094,11 @@ d3-dispatch@1: resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.5.tgz#e25c10a186517cd6c82dd19ea018f07e01e39015" integrity sha512-vwKx+lAqB1UuCeklr6Jh1bvC4SZgbSqbkGBLClItFBIYH4vqDJCA7qfoy14lXmJdnBOdxndAMxjCbImJYW7e6g== +"d3-dispatch@1 - 2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-2.0.0.tgz#8a18e16f76dd3fcaef42163c97b926aa9b55e7cf" + integrity sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA== + d3-drag@1: version "1.2.4" resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-1.2.4.tgz#ba9331d68158ad14cf0b4b28a8afa9e78c7d99ad" @@ -11133,6 +11143,15 @@ d3-force@1: d3-quadtree "1" d3-timer "1" +d3-force@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-2.1.1.tgz#f20ccbf1e6c9e80add1926f09b51f686a8bc0937" + integrity sha512-nAuHEzBqMvpFVMf9OX75d00OxvOXdxY+xECIXjW6Gv8BRrXu6gAWbv/9XKrvfJ5i5DCokDW7RYE50LRoK092ew== + dependencies: + d3-dispatch "1 - 2" + d3-quadtree "1 - 2" + d3-timer "1 - 2" + d3-format@1: version "1.4.1" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.1.tgz#c45f74b17c5a290c072a4ba7039dd19662cd5ce6" @@ -11179,6 +11198,11 @@ d3-quadtree@1: resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-1.0.6.tgz#d1ab2a95a7f27bbde88582c94166f6ae35f32056" integrity sha512-NUgeo9G+ENQCQ1LsRr2qJg3MQ4DJvxcDNCiohdJGHt5gRhBW6orIB5m5FJ9kK3HNL8g9F4ERVoBzcEwQBfXWVA== +"d3-quadtree@1 - 2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-2.0.0.tgz#edbad045cef88701f6fee3aee8e93fb332d30f9d" + integrity sha512-b0Ed2t1UUalJpc3qXzKi+cPGxeXRr4KU9YSlocN74aTzp6R/Ud43t79yLLqxHRWZfsvWXmbDWPpoENK1K539xw== + d3-random@1: version "1.1.2" resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-1.1.2.tgz#2833be7c124360bf9e2d3fd4f33847cfe6cab291" @@ -11238,6 +11262,11 @@ d3-timer@1: resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.9.tgz#f7bb8c0d597d792ff7131e1c24a36dd471a471ba" integrity sha512-rT34J5HnQUHhcLvhSB9GjCkN0Ddd5Y8nCwDBG2u6wQEeYxT/Lf51fTFFkldeib/sE/J0clIe0pnCfs6g/lRbyg== +"d3-timer@1 - 2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-2.0.0.tgz#055edb1d170cfe31ab2da8968deee940b56623e6" + integrity sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA== + d3-timer@~1.0.6: version "1.0.10" resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.10.tgz#dfe76b8a91748831b13b6d9c793ffbd508dd9de5"