mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
6f0bfa78ec
commit
218a8de220
@ -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",
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 */
|
||||
|
59
packages/grafana-ui/src/components/NodeGraph/Edge.tsx
Normal file
59
packages/grafana-ui/src/components/NodeGraph/Edge.tsx
Normal file
@ -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<SVGElement>, 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 (
|
||||
<g
|
||||
onClick={event => onClick(event, edge)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
aria-label={`Edge from: ${(edge.source as NodeDatum).id} to: ${(edge.target as NodeDatum).id}`}
|
||||
>
|
||||
<line
|
||||
strokeWidth={hovering ? 2 : 1}
|
||||
stroke={'#999'}
|
||||
x1={line.x1}
|
||||
y1={line.y1}
|
||||
x2={line.x2}
|
||||
y2={line.y2}
|
||||
markerEnd="url(#triangle)"
|
||||
/>
|
||||
<line
|
||||
stroke={'transparent'}
|
||||
x1={line.x1}
|
||||
y1={line.y1}
|
||||
x2={line.x2}
|
||||
y2={line.y2}
|
||||
strokeWidth={20}
|
||||
onMouseEnter={() => {
|
||||
onMouseEnter(edge.id);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
onMouseLeave(edge.id);
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
});
|
@ -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 (
|
||||
<defs>
|
||||
<marker
|
||||
id="triangle"
|
||||
viewBox="0 0 10 10"
|
||||
refX="8"
|
||||
refY="5"
|
||||
markerUnits="strokeWidth"
|
||||
markerWidth="10"
|
||||
markerHeight="10"
|
||||
orient="auto"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
|
||||
</marker>
|
||||
</defs>
|
||||
);
|
||||
}
|
67
packages/grafana-ui/src/components/NodeGraph/EdgeLabel.tsx
Normal file
67
packages/grafana-ui/src/components/NodeGraph/EdgeLabel.tsx
Normal file
@ -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 (
|
||||
<g className={styles.mainGroup}>
|
||||
<rect className={styles.background} x={middle.x - 40} y={middle.y - 15} width="80" height="30" rx="5" />
|
||||
<text className={styles.text} x={middle.x} y={middle.y - 5} textAnchor={'middle'}>
|
||||
{edge.mainStat}
|
||||
</text>
|
||||
<text className={styles.text} x={middle.x} y={middle.y + 10} textAnchor={'middle'}>
|
||||
{edge.secondaryStat}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
});
|
170
packages/grafana-ui/src/components/NodeGraph/Node.tsx
Normal file
170
packages/grafana-ui/src/components/NodeGraph/Node.tsx
Normal file
@ -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<SVGElement>, 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 (
|
||||
<g
|
||||
className={styles.mainGroup}
|
||||
onMouseEnter={() => {
|
||||
onMouseEnter(node.id);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
onMouseLeave(node.id);
|
||||
}}
|
||||
onClick={event => {
|
||||
onClick(event, node);
|
||||
}}
|
||||
aria-label={`Node: ${node.title}`}
|
||||
>
|
||||
<circle className={styles.mainCircle} r={nodeR} cx={node.x} cy={node.y} />
|
||||
{hovering && <circle className={styles.hoverCircle} r={nodeR - 3} cx={node.x} cy={node.y} strokeWidth={2} />}
|
||||
<ResponseTypeCircle node={node} />
|
||||
<g className={styles.text}>
|
||||
<text x={node.x} y={node.y - 5} textAnchor={'middle'}>
|
||||
{node.mainStat}
|
||||
</text>
|
||||
<text x={node.x} y={node.y + 10} textAnchor={'middle'}>
|
||||
{node.secondaryStat}
|
||||
</text>
|
||||
<foreignObject x={node.x - 50} y={node.y + nodeR + 5} width="100" height="30">
|
||||
<div className={styles.titleText}>
|
||||
{node.title}
|
||||
<br />
|
||||
{node.subTitle}
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<circle
|
||||
fill="none"
|
||||
stroke={getColorForTheme(fullStat.color, theme)}
|
||||
strokeWidth={2}
|
||||
r={nodeR}
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const nonZero = node.arcSections.filter(s => s.value !== 0);
|
||||
|
||||
const { elements } = nonZero.reduce(
|
||||
(acc, section) => {
|
||||
const el = (
|
||||
<ArcSection
|
||||
key={section.color}
|
||||
r={nodeR}
|
||||
x={node.x!}
|
||||
y={node.y!}
|
||||
startPercent={acc.percent}
|
||||
percent={section.value}
|
||||
color={getColorForTheme(section.color, theme)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
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 (
|
||||
<path
|
||||
fill="none"
|
||||
d={`M ${startXPos} ${startYPos} A ${r} ${r} 0 ${largeArc} 1 ${endXPos} ${endYPos}`}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
);
|
||||
}
|
188
packages/grafana-ui/src/components/NodeGraph/NodeGraph.test.tsx
Normal file
188
packages/grafana-ui/src/components/NodeGraph/NodeGraph.test.tsx
Normal file
@ -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(<NodeGraph dataFrames={[]} getLinks={() => []} />);
|
||||
});
|
||||
|
||||
it('can zoom in and out', async () => {
|
||||
render(<NodeGraph dataFrames={[]} getLinks={() => []} />);
|
||||
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(
|
||||
<NodeGraph
|
||||
dataFrames={[
|
||||
makeNodesDataFrame(3),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
/>
|
||||
);
|
||||
|
||||
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(<NodeGraph dataFrames={[makeNodesDataFrame(1)]} getLinks={() => []} />);
|
||||
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(
|
||||
<NodeGraph
|
||||
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([[0, 1]])]}
|
||||
getLinks={dataFrame => {
|
||||
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(
|
||||
<NodeGraph
|
||||
dataFrames={[
|
||||
makeNodesDataFrame(3),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<NodeGraph
|
||||
dataFrames={[
|
||||
makeNodesDataFrame(3),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[0, 2],
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<NodeGraph
|
||||
dataFrames={[
|
||||
makeNodesDataFrame(3),
|
||||
makeEdgesDataFrame([
|
||||
[0, 1],
|
||||
[0, 2],
|
||||
]),
|
||||
]}
|
||||
getLinks={() => []}
|
||||
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 || ''),
|
||||
};
|
||||
}
|
247
packages/grafana-ui/src/components/NodeGraph/NodeGraph.tsx
Normal file
247
packages/grafana-ui/src/components/NodeGraph/NodeGraph.tsx
Normal file
@ -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<Config>(defaultConfig);
|
||||
|
||||
// We need hover state here because for nodes we also highlight edges and for edges have labels separate to make
|
||||
// sure they are visible on top of everything else
|
||||
const [nodeHover, setNodeHover] = useState<string | undefined>(undefined);
|
||||
const clearNodeHover = useCallback(() => setNodeHover(undefined), [setNodeHover]);
|
||||
const [edgeHover, setEdgeHover] = useState<string | undefined>(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 (
|
||||
<div
|
||||
ref={r => {
|
||||
measureRef(r);
|
||||
(zoomRef as MutableRefObject<HTMLElement | null>).current = r;
|
||||
}}
|
||||
className={styles.wrapper}
|
||||
>
|
||||
<svg
|
||||
ref={panRef}
|
||||
viewBox={`${-(width / 2)} ${-(height / 2)} ${width} ${height}`}
|
||||
className={cx(styles.svg, isPanning && styles.svgPanning)}
|
||||
>
|
||||
<g
|
||||
className={styles.mainGroup}
|
||||
style={{ transform: `scale(${scale}) translate(${Math.floor(position.x)}px, ${Math.floor(position.y)}px)` }}
|
||||
>
|
||||
<EdgeArrowMarker />
|
||||
<Edges
|
||||
edges={edges}
|
||||
nodeHoveringId={nodeHover}
|
||||
edgeHoveringId={edgeHover}
|
||||
onClick={onEdgeOpen}
|
||||
onMouseEnter={setEdgeHover}
|
||||
onMouseLeave={clearEdgeHover}
|
||||
/>
|
||||
<Nodes
|
||||
nodes={nodes}
|
||||
onMouseEnter={setNodeHover}
|
||||
onMouseLeave={clearNodeHover}
|
||||
onClick={onNodeOpen}
|
||||
hoveringId={nodeHover}
|
||||
/>
|
||||
{/*We split the labels from edges so that they are shown on top of everything else*/}
|
||||
<EdgeLabels edges={edges} nodeHoveringId={nodeHover} edgeHoveringId={edgeHover} />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div className={styles.viewControls}>
|
||||
<ViewControls<Config>
|
||||
config={config}
|
||||
onConfigChange={setConfig}
|
||||
onMinus={onStepDown}
|
||||
onPlus={onStepUp}
|
||||
scale={scale}
|
||||
disableZoomIn={isMaxZoom}
|
||||
disableZoomOut={isMinZoom}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hiddenNodesCount > 0 && (
|
||||
<div className={styles.alert} aria-label={'Nodes hidden warning'}>
|
||||
<Icon size="sm" name={'info-circle'} /> {hiddenNodesCount} nodes are hidden for performance reasons.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{MenuComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// These 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<SVGElement>, node: NodeDatum) => void;
|
||||
hoveringId?: string;
|
||||
}
|
||||
const Nodes = memo(function Nodes(props: NodesProps) {
|
||||
return (
|
||||
<>
|
||||
{props.nodes.map(n => (
|
||||
<Node
|
||||
key={n.id}
|
||||
node={n}
|
||||
onMouseEnter={props.onMouseEnter}
|
||||
onMouseLeave={props.onMouseLeave}
|
||||
onClick={props.onClick}
|
||||
hovering={props.hoveringId === n.id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface EdgesProps {
|
||||
edges: EdgeDatum[];
|
||||
nodeHoveringId?: string;
|
||||
edgeHoveringId?: string;
|
||||
onClick: (event: MouseEvent<SVGElement>, link: EdgeDatum) => void;
|
||||
onMouseEnter: (id: string) => void;
|
||||
onMouseLeave: (id: string) => void;
|
||||
}
|
||||
const Edges = memo(function Edges(props: EdgesProps) {
|
||||
return (
|
||||
<>
|
||||
{props.edges.map(e => (
|
||||
<Edge
|
||||
key={e.id}
|
||||
edge={e}
|
||||
hovering={
|
||||
(e.source as NodeDatum).id === props.nodeHoveringId ||
|
||||
(e.target as NodeDatum).id === props.nodeHoveringId ||
|
||||
props.edgeHoveringId === e.id
|
||||
}
|
||||
onClick={props.onClick}
|
||||
onMouseEnter={props.onMouseEnter}
|
||||
onMouseLeave={props.onMouseLeave}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface EdgeLabelsProps {
|
||||
edges: EdgeDatum[];
|
||||
nodeHoveringId?: string;
|
||||
edgeHoveringId?: string;
|
||||
}
|
||||
const EdgeLabels = memo(function EdgeLabels(props: EdgeLabelsProps) {
|
||||
return (
|
||||
<>
|
||||
{props.edges.map((e, index) => {
|
||||
const shouldShow =
|
||||
(e.source as NodeDatum).id === props.nodeHoveringId ||
|
||||
(e.target as NodeDatum).id === props.nodeHoveringId ||
|
||||
props.edgeHoveringId === e.id;
|
||||
return shouldShow && <EdgeLabel key={e.id} edge={e} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function usePanAndZoom(bounds: Bounds) {
|
||||
const { scale, onStepDown, onStepUp, ref, isMax, isMin } = useZoom();
|
||||
const { state: panningState, ref: panRef } = usePanning<SVGSVGElement>({
|
||||
scale,
|
||||
bounds,
|
||||
});
|
||||
const { position, isPanning } = panningState;
|
||||
return { zoomRef: ref, panRef, position, isPanning, scale, onStepDown, onStepUp, isMaxZoom: isMax, isMinZoom: isMin };
|
||||
}
|
@ -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: 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<Config extends Record<string, any>>(props: Props<Config>) {
|
||||
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 (
|
||||
<>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Button
|
||||
icon={'plus-circle'}
|
||||
onClick={onPlus}
|
||||
size={'sm'}
|
||||
title={'Zoom in'}
|
||||
variant="secondary"
|
||||
disabled={disableZoomIn}
|
||||
/>
|
||||
<Button
|
||||
icon={'minus-circle'}
|
||||
onClick={onMinus}
|
||||
size={'sm'}
|
||||
title={'Zoom out'}
|
||||
variant="secondary"
|
||||
disabled={disableZoomOut}
|
||||
/>
|
||||
<span className={styles.scale} title={'Zoom level'}>
|
||||
{' '}
|
||||
{scale.toFixed(2)}x
|
||||
</span>
|
||||
</HorizontalGroup>
|
||||
<div className={styles.scrollHelp}>Or ctrl/meta + scroll</div>
|
||||
{allowConfiguration && (
|
||||
<Button size={'xs'} variant={'link'} onClick={() => setShowConfig(showConfig => !showConfig)}>
|
||||
{showConfig ? 'Hide config' : 'Show config'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{allowConfiguration &&
|
||||
showConfig &&
|
||||
Object.keys(config)
|
||||
.filter(k => k !== 'show')
|
||||
.map(k => (
|
||||
<div key={k}>
|
||||
{k}
|
||||
<input
|
||||
style={{ width: 50 }}
|
||||
type={'number'}
|
||||
value={config[k]}
|
||||
onChange={e => {
|
||||
onConfigChange({ ...config, [k]: parseFloat(e.target.value) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
2
packages/grafana-ui/src/components/NodeGraph/index.ts
Normal file
2
packages/grafana-ui/src/components/NodeGraph/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { NodeGraph } from './NodeGraph';
|
||||
export { DataFrameFieldNames as NodeGraphDataFrameFieldNames } from './utils';
|
213
packages/grafana-ui/src/components/NodeGraph/layout.ts
Normal file
213
packages/grafana-ui/src/components/NodeGraph/layout.ts
Normal file
@ -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<NodeDatum[]>([]);
|
||||
const [edges, setEdges] = useState<EdgeDatum[]>([]);
|
||||
|
||||
// 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<string, NodeDatum>);
|
||||
const edgesMap = edges.reduce((acc, edge) => {
|
||||
const sourceId = edge.source as number;
|
||||
return {
|
||||
...acc,
|
||||
[sourceId]: [...(acc[sourceId] || []), edge],
|
||||
};
|
||||
}, {} as Record<string, EdgeDatum[]>);
|
||||
|
||||
let roots = nodes.filter(n => n.incoming === 0);
|
||||
|
||||
let secondLevelRoots = roots.reduce<NodeDatum[]>(
|
||||
(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 }
|
||||
);
|
||||
}
|
21
packages/grafana-ui/src/components/NodeGraph/types.ts
Normal file
21
packages/grafana-ui/src/components/NodeGraph/types.ts
Normal file
@ -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<NodeDatum> & {
|
||||
id: string;
|
||||
mainStat: string;
|
||||
secondaryStat: string;
|
||||
dataFrameRowIndex: number;
|
||||
};
|
@ -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]);
|
||||
}
|
152
packages/grafana-ui/src/components/NodeGraph/useContextMenu.tsx
Normal file
152
packages/grafana-ui/src/components/NodeGraph/useContextMenu.tsx
Normal file
@ -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<SVGElement>, edge: EdgeDatum) => void;
|
||||
onNodeOpen: (event: MouseEvent<SVGElement>, 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 = (
|
||||
<ContextMenu
|
||||
renderHeader={() => <NodeHeader node={openedNode.node} nodes={nodes} />}
|
||||
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 = (
|
||||
<ContextMenu
|
||||
renderHeader={() => <EdgeHeader edge={openedEdge.edge} edges={edges} />}
|
||||
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 (
|
||||
<div>
|
||||
{fields.title && <Label field={fields.title} index={index} />}
|
||||
{fields.subTitle && <Label field={fields.subTitle} index={index} />}
|
||||
{fields.details.map(f => (
|
||||
<Label key={f.name} field={f} index={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EdgeHeader(props: { edge: EdgeDatum; edges: DataFrame }) {
|
||||
const index = props.edge.dataFrameRowIndex;
|
||||
const fields = getEdgeFields(props.edges);
|
||||
return (
|
||||
<div>
|
||||
{fields.details.map(f => (
|
||||
<Label key={f.name} field={f} index={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.label}>
|
||||
<div>{field.config.displayName || field.name}</div>
|
||||
<span className={styles.value}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
50
packages/grafana-ui/src/components/NodeGraph/useNodeLimit.ts
Normal file
50
packages/grafana-ui/src/components/NodeGraph/useNodeLimit.ts
Normal file
@ -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<string, NodeDatum>);
|
||||
|
||||
let roots = nodes.filter(n => n.incoming === 0);
|
||||
const newNodes: Record<string, NodeDatum> = {};
|
||||
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]);
|
||||
}
|
135
packages/grafana-ui/src/components/NodeGraph/usePanning.ts
Normal file
135
packages/grafana-ui/src/components/NodeGraph/usePanning.ts
Normal file
@ -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<T extends Element>(
|
||||
{ scale = 1, bounds }: Options = { scale: 1 }
|
||||
): { state: State; ref: RefObject<T> } {
|
||||
const isMounted = useMountedState();
|
||||
const isPanning = useRef(false);
|
||||
const frame = useRef(0);
|
||||
const panRef = useRef<T>(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<State>({
|
||||
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 };
|
||||
}
|
||||
}
|
88
packages/grafana-ui/src/components/NodeGraph/useZoom.ts
Normal file
88
packages/grafana-ui/src/components/NodeGraph/useZoom.ts
Normal file
@ -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<HTMLElement>(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,
|
||||
};
|
||||
}
|
106
packages/grafana-ui/src/components/NodeGraph/utils.test.ts
Normal file
106
packages/grafana-ui/src/components/NodeGraph/utils.test.ts
Normal file
@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
254
packages/grafana-ui/src/components/NodeGraph/utils.ts
Normal file
254
packages/grafana-ui/src/components/NodeGraph/utils.ts
Normal file
@ -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' },
|
||||
});
|
||||
}
|
@ -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';
|
||||
|
@ -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.
|
||||
|
@ -100,6 +100,7 @@ const dummyProps: ExploreProps = {
|
||||
showLogs: true,
|
||||
showTable: true,
|
||||
showTrace: true,
|
||||
showNodeGraph: true,
|
||||
splitOpen: (() => {}) as any,
|
||||
};
|
||||
|
||||
|
@ -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<ExploreProps, ExploreState> {
|
||||
}
|
||||
};
|
||||
|
||||
renderEmptyState = () => {
|
||||
renderEmptyState() {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<NoDataSourceCallToAction />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
renderGraphPanel(width: number) {
|
||||
const { graphResult, absoluteRange, timeZone, splitOpen, queryResponse } = this.props;
|
||||
const isLoading = queryResponse.state === LoadingState.Loading;
|
||||
return (
|
||||
<ExploreGraphNGPanel
|
||||
data={graphResult!}
|
||||
width={width}
|
||||
absoluteRange={absoluteRange}
|
||||
timeZone={timeZone}
|
||||
onUpdateTimeRange={this.onUpdateTimeRange}
|
||||
annotations={queryResponse.annotations}
|
||||
splitOpenFn={splitOpen}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderTablePanel(width: number) {
|
||||
const { exploreId, datasourceInstance } = this.props;
|
||||
return (
|
||||
<TableContainer
|
||||
ariaLabel={selectors.pages.Explore.General.table}
|
||||
width={width}
|
||||
exploreId={exploreId}
|
||||
onCellFilterAdded={datasourceInstance?.modifyQuery ? this.onCellFilterAdded : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderLogsPanel(width: number) {
|
||||
const { exploreId, syncedTimes } = this.props;
|
||||
return (
|
||||
<LogsContainer
|
||||
width={width}
|
||||
exploreId={exploreId}
|
||||
syncedTimes={syncedTimes}
|
||||
onClickFilterLabel={this.onClickFilterLabel}
|
||||
onClickFilterOutLabel={this.onClickFilterOutLabel}
|
||||
onStartScanning={this.onStartScanning}
|
||||
onStopScanning={this.onStopScanning}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderNodeGraphPanel() {
|
||||
const { exploreId, showTrace, queryResponse } = this.props;
|
||||
return (
|
||||
<NodeGraphContainer
|
||||
dataFrames={this.getNodeGraphDataFrames(queryResponse.series)}
|
||||
exploreId={exploreId}
|
||||
short={showTrace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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] && (
|
||||
<TraceView trace={dataFrames[0].fields[0].values.get(0) as TraceViewData | undefined} splitOpenFn={splitOpen} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
@ -292,24 +370,20 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
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<ExploreProps, ExploreState> {
|
||||
)}
|
||||
{!showStartPage && (
|
||||
<>
|
||||
{showMetrics && graphResult && (
|
||||
<ExploreGraphNGPanel
|
||||
data={graphResult}
|
||||
width={width}
|
||||
absoluteRange={absoluteRange}
|
||||
timeZone={timeZone}
|
||||
onUpdateTimeRange={this.onUpdateTimeRange}
|
||||
annotations={queryResponse.annotations}
|
||||
splitOpenFn={splitOpen}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
{showTable && (
|
||||
<TableContainer
|
||||
ariaLabel={selectors.pages.Explore.General.table}
|
||||
width={width}
|
||||
exploreId={exploreId}
|
||||
onCellFilterAdded={
|
||||
this.props.datasourceInstance?.modifyQuery ? this.onCellFilterAdded : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showLogs && (
|
||||
<LogsContainer
|
||||
width={width}
|
||||
exploreId={exploreId}
|
||||
syncedTimes={syncedTimes}
|
||||
onClickFilterLabel={this.onClickFilterLabel}
|
||||
onClickFilterOutLabel={this.onClickFilterOutLabel}
|
||||
onStartScanning={this.onStartScanning}
|
||||
onStopScanning={this.onStopScanning}
|
||||
/>
|
||||
)}
|
||||
{/* 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] && (
|
||||
<TraceView
|
||||
trace={queryResponse.series[0].fields[0].values.get(0) as TraceViewData | undefined}
|
||||
splitOpenFn={splitOpen}
|
||||
/>
|
||||
)}
|
||||
{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,
|
||||
};
|
||||
}
|
||||
|
||||
|
49
public/app/features/explore/NodeGraphContainer.tsx
Normal file
49
public/app/features/explore/NodeGraphContainer.tsx
Normal file
@ -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<typeof connector>) {
|
||||
const { dataFrames, range, splitOpen, short } = props;
|
||||
const getLinks = useLinks(range, splitOpen);
|
||||
|
||||
return (
|
||||
<div style={{ height: short ? 300 : 600 }}>
|
||||
<Collapse
|
||||
label={
|
||||
<span>
|
||||
Node graph <Badge text={'Beta'} color={'blue'} icon={'rocket'} tooltip={'This visualization is in beta'} />
|
||||
</span>
|
||||
}
|
||||
isOpen
|
||||
>
|
||||
<NodeGraph dataFrames={dataFrames} getLinks={getLinks} />
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
@ -86,6 +86,11 @@ export function splitOpen<T extends DataQuery = any>(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.
|
||||
|
@ -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<void> => {
|
||||
// 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<QueryEndedPayload>
|
||||
): 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,
|
||||
};
|
||||
};
|
||||
|
@ -15,7 +15,7 @@ import {
|
||||
} from '@grafana/data';
|
||||
|
||||
import {
|
||||
decorateWithGraphLogsTraceAndTable,
|
||||
decorateWithFrameTypeMetadata,
|
||||
decorateWithGraphResult,
|
||||
decorateWithLogsResult,
|
||||
decorateWithTableResult,
|
||||
@ -78,6 +78,7 @@ const createExplorePanelData = (args: Partial<ExplorePanelData>): 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,
|
||||
|
@ -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,
|
||||
|
@ -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]
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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' && <PredictablePulseEditor onChange={onPulseWaveChange} query={query} />}
|
||||
{scenarioId === 'predictable_csv_wave' && <CSVWaveEditor onChange={onCSVWaveChange} query={query} />}
|
||||
{scenarioId === 'node_graph' && (
|
||||
<NodeGraphEditor onChange={(val: NodesQuery) => onChange({ ...query, nodes: val })} query={query} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
42
public/app/plugins/datasource/testdata/components/NodeGraphEditor.tsx
vendored
Normal file
42
public/app/plugins/datasource/testdata/components/NodeGraphEditor.tsx
vendored
Normal file
@ -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 (
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Data type" labelWidth={14}>
|
||||
<Select<NodesQuery['type']>
|
||||
options={options.map(o => ({
|
||||
label: o,
|
||||
value: o,
|
||||
}))}
|
||||
value={options.find(item => item === type)}
|
||||
onChange={value => onChange({ ...query.nodes, type: value.value! })}
|
||||
width={32}
|
||||
/>
|
||||
</InlineField>
|
||||
{type === 'random' && (
|
||||
<InlineField label="Count" labelWidth={14}>
|
||||
<Input
|
||||
type="number"
|
||||
name="count"
|
||||
value={query.nodes?.count}
|
||||
width={32}
|
||||
onChange={e =>
|
||||
onChange({ ...query.nodes, count: e.currentTarget.value ? parseInt(e.currentTarget.value, 10) : 0 })
|
||||
}
|
||||
placeholder="10"
|
||||
/>
|
||||
</InlineField>
|
||||
)}
|
||||
</InlineFieldRow>
|
||||
);
|
||||
}
|
||||
|
||||
const options: Array<NodesQuery['type']> = ['random', 'response'];
|
@ -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<TestDataQuery> {
|
||||
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<TestDataQuery> {
|
||||
|
||||
return of({ data: [dataFrame] }).pipe(delay(100));
|
||||
}
|
||||
|
||||
nodesQuery(target: TestDataQuery, options: DataQueryRequest<TestDataQuery>): Observable<DataQueryResponse> {
|
||||
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<TestDataQuery>): Observable<DataQueryResponse> {
|
||||
|
155
public/app/plugins/datasource/testdata/nodeGraphUtils.ts
vendored
Normal file
155
public/app/plugins/datasource/testdata/nodeGraphUtils.ts
vendored
Normal file
@ -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)];
|
||||
}
|
416
public/app/plugins/datasource/testdata/testData/serviceMapResponse.ts
vendored
Normal file
416
public/app/plugins/datasource/testdata/testData/serviceMapResponse.ts
vendored
Normal file
@ -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',
|
||||
};
|
@ -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 {
|
||||
|
23
public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx
Normal file
23
public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx
Normal file
@ -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<PanelProps<Options>> = ({ width, height, data }) => {
|
||||
if (!data || !data.series.length) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>No data found in response</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getLinks = useLinks(data.timeRange);
|
||||
|
||||
return (
|
||||
<div style={{ width, height }}>
|
||||
<NodeGraph dataFrames={data.series} getLinks={getLinks} />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 78.59 78.59"><defs><style>.cls-1{fill:#84aff1;}.cls-2{fill:#3865ab;}.cls-3{fill:url(#New_Gradient_Swatch_1);}</style><linearGradient id="New_Gradient_Swatch_1" x1="35.98" y1="47.25" x2="62.65" y2="47.25" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Icons"><path class="cls-1" d="M78.06,68.37a7.51,7.51,0,0,0-4.23-4.24c-.23-.09-.46-.17-.7-.24l0-4.91L77,59l-5.89-7.77-5.75,7.88,3.78,0,0,4.86a7.57,7.57,0,0,0-.95.32,7.49,7.49,0,1,0,10.41,6.9A7.26,7.26,0,0,0,78.06,68.37Z"/><path class="cls-1" d="M24.12,44.42,13.65,48c-.11-.15-.22-.31-.34-.45L29.12,25.22l3.12,2.18L32,17.66l-9.25,3.06,3.14,2.2L10.05,45.26a7.48,7.48,0,1,0,4.93,7c0-.15,0-.31,0-.46l10.5-3.63,1.26,3.58,5.44-8.09L22.84,40.8Z"/><circle class="cls-2" cx="71.1" cy="39.86" r="7.49"/><path class="cls-2" d="M63.54,32.12,62.2,22.46l-2.73,2.69-13.67-14A7.5,7.5,0,1,0,43,14L56.62,28l-2.71,2.67Z"/><path class="cls-2" d="M59.62,71.1l-7.81-5.82V69.1H23.7a7.49,7.49,0,1,0,0,4H51.81v3.82Z"/><path class="cls-3" d="M62.65,62l-.8-9.71L59,54.8,49.7,44.12a7.48,7.48,0,1,0-6,3.34,7.35,7.35,0,0,0,2.93-.72L56,57.44,53.11,60Z"/></g></g></svg>
|
After Width: | Height: | Size: 1.3 KiB |
5
public/app/plugins/panel/nodeGraph/module.tsx
Normal file
5
public/app/plugins/panel/nodeGraph/module.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { PanelPlugin } from '@grafana/data';
|
||||
import { NodeGraphPanel } from './NodeGraphPanel';
|
||||
import { Options } from './types';
|
||||
|
||||
export const plugin = new PanelPlugin<Options>(NodeGraphPanel);
|
17
public/app/plugins/panel/nodeGraph/plugin.json
Normal file
17
public/app/plugins/panel/nodeGraph/plugin.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
1
public/app/plugins/panel/nodeGraph/types.ts
Normal file
1
public/app/plugins/panel/nodeGraph/types.ts
Normal file
@ -0,0 +1 @@
|
||||
export interface Options {}
|
@ -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;
|
||||
|
29
yarn.lock
29
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"
|
||||
|
Loading…
Reference in New Issue
Block a user