mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
NodeGraph: Support icons for nodes (#60989)
This commit is contained in:
parent
fa35ed8141
commit
78b39bb282
@ -112,11 +112,13 @@ Required fields:
|
|||||||
|
|
||||||
Optional fields:
|
Optional fields:
|
||||||
|
|
||||||
| Field name | Type | Description |
|
| Field name | Type | Description |
|
||||||
| ------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| title | string | Name of the node visible in just under the node. |
|
| title | string | Name of the node visible in just under the node. |
|
||||||
| subtitle | string | Additional, name, type or other identifier shown under the title. |
|
| subtitle | string | Additional, name, type or other identifier shown under the title. |
|
||||||
| mainstat | string/number | First stat shown inside the node itself. It can either be a string showing the value as is or a number. If it is a number, any unit associated with that field is also shown. |
|
| mainstat | string/number | First stat shown inside the node itself. It can either be a string showing the value as is or a number. If it is a number, any unit associated with that field is also shown. |
|
||||||
| secondarystat | string/number | Same as mainStat, but shown under it inside the node. |
|
| secondarystat | string/number | Same as mainStat, but shown under it inside the node. |
|
||||||
| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
|
| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
|
||||||
| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
|
| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
|
||||||
|
| color | string/number | Can be used to specify a single color instead of using the `arc__` fields to specify color sections. It can be either a string which should then be an acceptable HTML color string or it can be a number in which case the behaviour depends on `field.config.color.mode` setting. This can be for example used to create gradient colors controlled by the field value. |
|
||||||
|
| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana built in icons are allowed (see the available icons [here](https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview)). |
|
||||||
|
@ -1,12 +1,28 @@
|
|||||||
export enum NodeGraphDataFrameFieldNames {
|
export enum NodeGraphDataFrameFieldNames {
|
||||||
|
// Unique identifier [required] [nodes + edges]
|
||||||
id = 'id',
|
id = 'id',
|
||||||
|
// Text to show under the node [nodes]
|
||||||
title = 'title',
|
title = 'title',
|
||||||
|
// Text to show under the node as second line [nodes]
|
||||||
subTitle = 'subtitle',
|
subTitle = 'subtitle',
|
||||||
|
// Main value to be shown inside the node [nodes]
|
||||||
mainStat = 'mainstat',
|
mainStat = 'mainstat',
|
||||||
|
// Second value to be shown inside the node under the mainStat [nodes]
|
||||||
secondaryStat = 'secondarystat',
|
secondaryStat = 'secondarystat',
|
||||||
source = 'source',
|
// Prefix for fields which value will represent part of the color circle around the node, values should add up to 1 [nodes]
|
||||||
target = 'target',
|
|
||||||
detail = 'detail__',
|
|
||||||
arc = 'arc__',
|
arc = 'arc__',
|
||||||
|
// Will show a named icon inside the node circle if defined. Can be used only with icons already available in
|
||||||
|
// grafana/ui [nodes]
|
||||||
|
icon = 'icon',
|
||||||
|
// Defines a single color if string (hex or html named value) or color mode config can be used as threshold or
|
||||||
|
// gradient. arc__ fields must not be defined if used [nodes]
|
||||||
color = 'color',
|
color = 'color',
|
||||||
|
|
||||||
|
// Id of the source node [required] [edges]
|
||||||
|
source = 'source',
|
||||||
|
// Id of the target node [required] [edges]
|
||||||
|
target = 'target',
|
||||||
|
|
||||||
|
// Prefix for fields which will be shown in a context menu [nodes + edges]
|
||||||
|
detail = 'detail__',
|
||||||
}
|
}
|
||||||
|
@ -97,6 +97,10 @@ export function generateRandomNodes(count = 10) {
|
|||||||
type: FieldType.number,
|
type: FieldType.number,
|
||||||
config: { color: { fixedColor: 'red', mode: FieldColorModeId.Fixed }, displayName: 'Errors' },
|
config: { color: { fixedColor: 'red', mode: FieldColorModeId.Fixed }, displayName: 'Errors' },
|
||||||
},
|
},
|
||||||
|
[NodeGraphDataFrameFieldNames.icon]: {
|
||||||
|
values: new ArrayVector(),
|
||||||
|
type: FieldType.string,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const nodeFrame = new MutableDataFrame({
|
const nodeFrame = new MutableDataFrame({
|
||||||
@ -128,6 +132,8 @@ export function generateRandomNodes(count = 10) {
|
|||||||
nodeFields[NodeGraphDataFrameFieldNames.secondaryStat].values.add(node.stat2);
|
nodeFields[NodeGraphDataFrameFieldNames.secondaryStat].values.add(node.stat2);
|
||||||
nodeFields.arc__success.values.add(node.success);
|
nodeFields.arc__success.values.add(node.success);
|
||||||
nodeFields.arc__errors.values.add(node.error);
|
nodeFields.arc__errors.values.add(node.error);
|
||||||
|
const rnd = Math.random();
|
||||||
|
nodeFields[NodeGraphDataFrameFieldNames.icon].values.add(rnd > 0.9 ? 'database' : rnd < 0.1 ? 'cloud' : '');
|
||||||
for (const edge of node.edges) {
|
for (const edge of node.edges) {
|
||||||
const id = `${node.id}--${edge}`;
|
const id = `${node.id}--${edge}`;
|
||||||
// We can have duplicate edges when we added some more by random
|
// We can have duplicate edges when we added some more by random
|
||||||
|
@ -29,10 +29,10 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export const EdgeLabel = memo(function EdgeLabel(props: Props) {
|
export const EdgeLabel = memo(function EdgeLabel(props: Props) {
|
||||||
const { edge } = props;
|
const { edge } = props;
|
||||||
// Not great typing but after we do layout these properties are full objects not just references
|
// 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 };
|
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.
|
// As the nodes have some radius we want edges to end outside the node circle.
|
||||||
const line = shortenLine(
|
const line = shortenLine(
|
||||||
{
|
{
|
||||||
x1: source.x!,
|
x1: source.x!,
|
||||||
@ -49,15 +49,32 @@ export const EdgeLabel = memo(function EdgeLabel(props: Props) {
|
|||||||
};
|
};
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const stats = [edge.mainStat, edge.secondaryStat].filter((x) => x);
|
||||||
|
const height = stats.length > 1 ? '30' : '15';
|
||||||
|
const middleOffset = stats.length > 1 ? 15 : 7.5;
|
||||||
|
let offset = stats.length > 1 ? -5 : 2.5;
|
||||||
|
|
||||||
|
const contents: JSX.Element[] = [];
|
||||||
|
stats.forEach((stat, index) => {
|
||||||
|
contents.push(
|
||||||
|
<text key={index} className={styles.text} x={middle.x} y={middle.y + offset} textAnchor={'middle'}>
|
||||||
|
{stat}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
offset += 15;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g className={styles.mainGroup}>
|
<g className={styles.mainGroup}>
|
||||||
<rect className={styles.background} x={middle.x - 40} y={middle.y - 15} width="80" height="30" rx="5" />
|
<rect
|
||||||
<text className={styles.text} x={middle.x} y={middle.y - 5} textAnchor={'middle'}>
|
className={styles.background}
|
||||||
{edge.mainStat}
|
x={middle.x - 40}
|
||||||
</text>
|
y={middle.y - middleOffset}
|
||||||
<text className={styles.text} x={middle.x} y={middle.y + 10} textAnchor={'middle'}>
|
width="80"
|
||||||
{edge.secondaryStat}
|
height={height}
|
||||||
</text>
|
rx="5"
|
||||||
|
/>
|
||||||
|
{contents}
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
56
public/app/plugins/panel/nodeGraph/Node.test.tsx
Normal file
56
public/app/plugins/panel/nodeGraph/Node.test.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { ArrayVector, FieldType } from '@grafana/data/src';
|
||||||
|
|
||||||
|
import { Node } from './Node';
|
||||||
|
|
||||||
|
describe('Node', () => {
|
||||||
|
it('renders correct data', async () => {
|
||||||
|
render(
|
||||||
|
<svg>
|
||||||
|
<Node
|
||||||
|
node={nodeDatum}
|
||||||
|
onMouseEnter={() => {}}
|
||||||
|
onMouseLeave={() => {}}
|
||||||
|
onClick={() => {}}
|
||||||
|
hovering={'default'}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('node title')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('node subtitle')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1234.00')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('9876.00')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders icon', async () => {
|
||||||
|
render(
|
||||||
|
<svg>
|
||||||
|
<Node
|
||||||
|
node={{ ...nodeDatum, icon: 'database' }}
|
||||||
|
onMouseEnter={() => {}}
|
||||||
|
onMouseLeave={() => {}}
|
||||||
|
onClick={() => {}}
|
||||||
|
hovering={'default'}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('node-icon-database')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeDatum = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
id: '1',
|
||||||
|
title: 'node title',
|
||||||
|
subTitle: 'node subtitle',
|
||||||
|
dataFrameRowIndex: 0,
|
||||||
|
incoming: 0,
|
||||||
|
mainStat: { name: 'stat', values: new ArrayVector([1234]), type: FieldType.number, config: {} },
|
||||||
|
secondaryStat: { name: 'stat2', values: new ArrayVector([9876]), type: FieldType.number, config: {} },
|
||||||
|
arcSections: [],
|
||||||
|
};
|
@ -4,7 +4,7 @@ import React, { MouseEvent, memo } from 'react';
|
|||||||
import tinycolor from 'tinycolor2';
|
import tinycolor from 'tinycolor2';
|
||||||
|
|
||||||
import { Field, getFieldColorModeForField, GrafanaTheme2 } from '@grafana/data';
|
import { Field, getFieldColorModeForField, GrafanaTheme2 } from '@grafana/data';
|
||||||
import { useTheme2 } from '@grafana/ui';
|
import { Icon, useTheme2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { HoverState } from './NodeGraph';
|
import { HoverState } from './NodeGraph';
|
||||||
import { NodeDatum } from './types';
|
import { NodeDatum } from './types';
|
||||||
@ -61,10 +61,10 @@ const getStyles = (theme: GrafanaTheme2, hovering: HoverState) => ({
|
|||||||
|
|
||||||
export const Node = memo(function Node(props: {
|
export const Node = memo(function Node(props: {
|
||||||
node: NodeDatum;
|
node: NodeDatum;
|
||||||
|
hovering: HoverState;
|
||||||
onMouseEnter: (id: string) => void;
|
onMouseEnter: (id: string) => void;
|
||||||
onMouseLeave: (id: string) => void;
|
onMouseLeave: (id: string) => void;
|
||||||
onClick: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
|
onClick: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
|
||||||
hovering: HoverState;
|
|
||||||
}) {
|
}) {
|
||||||
const { node, onMouseEnter, onMouseLeave, onClick, hovering } = props;
|
const { node, onMouseEnter, onMouseLeave, onClick, hovering } = props;
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
@ -94,18 +94,7 @@ export const Node = memo(function Node(props: {
|
|||||||
{isHovered && <circle className={styles.hoverCircle} r={nodeR - 3} cx={node.x} cy={node.y} strokeWidth={2} />}
|
{isHovered && <circle className={styles.hoverCircle} r={nodeR - 3} cx={node.x} cy={node.y} strokeWidth={2} />}
|
||||||
<ColorCircle node={node} />
|
<ColorCircle node={node} />
|
||||||
<g className={styles.text}>
|
<g className={styles.text}>
|
||||||
<foreignObject x={node.x - (isHovered ? 100 : 35)} y={node.y - 15} width={isHovered ? '200' : '70'} height="40">
|
<NodeContents node={node} hovering={hovering} />
|
||||||
<div className={cx(styles.statsText, isHovered && styles.textHovering)}>
|
|
||||||
<span>
|
|
||||||
{node.mainStat && statToString(node.mainStat.config, node.mainStat.values.get(node.dataFrameRowIndex))}
|
|
||||||
</span>
|
|
||||||
<br />
|
|
||||||
<span>
|
|
||||||
{node.secondaryStat &&
|
|
||||||
statToString(node.secondaryStat.config, node.secondaryStat.values.get(node.dataFrameRowIndex))}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</foreignObject>
|
|
||||||
<foreignObject
|
<foreignObject
|
||||||
x={node.x - (isHovered ? 100 : 50)}
|
x={node.x - (isHovered ? 100 : 50)}
|
||||||
y={node.y + nodeR + 5}
|
y={node.y + nodeR + 5}
|
||||||
@ -123,6 +112,40 @@ export const Node = memo(function Node(props: {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows contents of the node which can be either an Icon or a main and secondary stat values.
|
||||||
|
*/
|
||||||
|
function NodeContents({ node, hovering }: { node: NodeDatum; hovering: HoverState }) {
|
||||||
|
const theme = useTheme2();
|
||||||
|
const styles = getStyles(theme, hovering);
|
||||||
|
const isHovered = hovering === 'active';
|
||||||
|
|
||||||
|
if (!(node.x !== undefined && node.y !== undefined)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return node.icon ? (
|
||||||
|
<foreignObject x={node.x - 35} y={node.y - 20} width="70" height="40">
|
||||||
|
<div style={{ width: 70, overflow: 'hidden', display: 'flex', justifyContent: 'center', marginTop: -4 }}>
|
||||||
|
<Icon data-testid={`node-icon-${node.icon}`} name={node.icon} size={'xxxl'} />
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
) : (
|
||||||
|
<foreignObject x={node.x - (isHovered ? 100 : 35)} y={node.y - 15} width={isHovered ? '200' : '70'} height="40">
|
||||||
|
<div className={cx(styles.statsText, isHovered && styles.textHovering)}>
|
||||||
|
<span>
|
||||||
|
{node.mainStat && statToString(node.mainStat.config, node.mainStat.values.get(node.dataFrameRowIndex))}
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
<span>
|
||||||
|
{node.secondaryStat &&
|
||||||
|
statToString(node.secondaryStat.config, node.secondaryStat.values.get(node.dataFrameRowIndex))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows the outer segmented circle with different colors based on the supplied data.
|
* Shows the outer segmented circle with different colors based on the supplied data.
|
||||||
*/
|
*/
|
||||||
@ -164,13 +187,13 @@ function ColorCircle(props: { node: NodeDatum }) {
|
|||||||
elements: React.ReactNode[];
|
elements: React.ReactNode[];
|
||||||
percent: number;
|
percent: number;
|
||||||
}>(
|
}>(
|
||||||
(acc, section) => {
|
(acc, section, index) => {
|
||||||
const color = section.config.color?.fixedColor || '';
|
const color = section.config.color?.fixedColor || '';
|
||||||
const value = section.values.get(node.dataFrameRowIndex);
|
const value = section.values.get(node.dataFrameRowIndex);
|
||||||
|
|
||||||
const el = (
|
const el = (
|
||||||
<ArcSection
|
<ArcSection
|
||||||
key={color}
|
key={index}
|
||||||
r={nodeR}
|
r={nodeR}
|
||||||
x={node.x!}
|
x={node.x!}
|
||||||
y={node.y!}
|
y={node.y!}
|
||||||
|
@ -15,11 +15,6 @@ jest.mock('react-use/lib/useMeasure', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('NodeGraph', () => {
|
describe('NodeGraph', () => {
|
||||||
const origError = console.error;
|
|
||||||
const consoleErrorMock = jest.fn();
|
|
||||||
afterEach(() => (console.error = origError));
|
|
||||||
beforeEach(() => (console.error = consoleErrorMock));
|
|
||||||
|
|
||||||
it('shows no data message without any data', async () => {
|
it('shows no data message without any data', async () => {
|
||||||
render(<NodeGraph dataFrames={[]} getLinks={() => []} />);
|
render(<NodeGraph dataFrames={[]} getLinks={() => []} />);
|
||||||
|
|
||||||
@ -88,6 +83,12 @@ describe('NodeGraph', () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// We mock this because for some reason the simulated click events don't have pageX/Y values resulting in some NaNs
|
||||||
|
// for positioning and this creates a warning message.
|
||||||
|
const origError = console.error;
|
||||||
|
console.error = jest.fn();
|
||||||
|
|
||||||
const node = await screen.findByLabelText(/Node: service:0/);
|
const node = await screen.findByLabelText(/Node: service:0/);
|
||||||
await userEvent.click(node);
|
await userEvent.click(node);
|
||||||
await screen.findByText(/Node traces/);
|
await screen.findByText(/Node traces/);
|
||||||
@ -95,6 +96,7 @@ describe('NodeGraph', () => {
|
|||||||
const edge = await screen.findByLabelText(/Edge from/);
|
const edge = await screen.findByLabelText(/Edge from/);
|
||||||
await userEvent.click(edge);
|
await userEvent.click(edge);
|
||||||
await screen.findByText(/Edge traces/);
|
await screen.findByText(/Edge traces/);
|
||||||
|
console.error = origError;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lays out 3 nodes in single line', async () => {
|
it('lays out 3 nodes in single line', async () => {
|
||||||
|
@ -368,10 +368,13 @@ const EdgeLabels = memo(function EdgeLabels(props: EdgeLabelsProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.edges.map((e, index) => {
|
{props.edges.map((e, index) => {
|
||||||
|
// We show the edge label in case user hovers over the edge directly or if they hover over node edge is
|
||||||
|
// connected to.
|
||||||
const shouldShow =
|
const shouldShow =
|
||||||
(e.source as NodeDatum).id === props.nodeHoveringId ||
|
(e.source as NodeDatum).id === props.nodeHoveringId ||
|
||||||
(e.target as NodeDatum).id === props.nodeHoveringId ||
|
(e.target as NodeDatum).id === props.nodeHoveringId ||
|
||||||
props.edgeHoveringId === e.id;
|
props.edgeHoveringId === e.id;
|
||||||
|
|
||||||
const hasStats = e.mainStat || e.secondaryStat;
|
const hasStats = e.mainStat || e.secondaryStat;
|
||||||
return shouldShow && hasStats && <EdgeLabel key={e.id} edge={e} />;
|
return shouldShow && hasStats && <EdgeLabel key={e.id} edge={e} />;
|
||||||
})}
|
})}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force';
|
import { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force';
|
||||||
|
|
||||||
import { Field } from '@grafana/data';
|
import { Field, IconName } from '@grafana/data';
|
||||||
|
|
||||||
export { PanelOptions as NodeGraphOptions, ArcOption } from './panelcfg.gen';
|
export { PanelOptions as NodeGraphOptions, ArcOption } from './panelcfg.gen';
|
||||||
|
|
||||||
@ -14,6 +14,7 @@ export type NodeDatum = SimulationNodeDatum & {
|
|||||||
secondaryStat?: Field;
|
secondaryStat?: Field;
|
||||||
arcSections: Field[];
|
arcSections: Field[];
|
||||||
color?: Field;
|
color?: Field;
|
||||||
|
icon?: IconName;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NodeDatumFromEdge = NodeDatum & { mainStatNumeric?: number; secondaryStatNumeric?: number };
|
export type NodeDatumFromEdge = NodeDatum & { mainStatNumeric?: number; secondaryStatNumeric?: number };
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React, { MouseEvent, useCallback, useState } from 'react';
|
import React, { MouseEvent, useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data';
|
import { DataFrame, Field, GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||||
import { ContextMenu, MenuGroup, MenuItem, useStyles2, useTheme2 } from '@grafana/ui';
|
import { ContextMenu, MenuGroup, MenuItem, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { Config } from './layout';
|
import { Config } from './layout';
|
||||||
import { EdgeDatum, NodeDatum } from './types';
|
import { EdgeDatum, NodeDatum } from './types';
|
||||||
import { getEdgeFields, getNodeFields } from './utils';
|
import { getEdgeFields, getNodeFields, statToString } from './utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook that contains state of the context menu, both for edges and nodes and provides appropriate component when
|
* Hook that contains state of the context menu, both for edges and nodes and provides appropriate component when
|
||||||
@ -47,10 +47,7 @@ export function useContextMenu(
|
|||||||
|
|
||||||
const links = nodes ? getLinks(nodes, node.dataFrameRowIndex) : [];
|
const links = nodes ? getLinks(nodes, node.dataFrameRowIndex) : [];
|
||||||
const renderer = getItemsRenderer(links, node, extraNodeItem);
|
const renderer = getItemsRenderer(links, node, extraNodeItem);
|
||||||
|
setMenu(makeContextMenu(<NodeHeader node={node} nodes={nodes} />, event, setMenu, renderer));
|
||||||
if (renderer) {
|
|
||||||
setMenu(makeContextMenu(<NodeHeader node={node} nodes={nodes} />, renderer, event, setMenu));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[config, nodes, getLinks, setMenu, setConfig, setFocusedNodeId]
|
[config, nodes, getLinks, setMenu, setConfig, setFocusedNodeId]
|
||||||
);
|
);
|
||||||
@ -64,10 +61,7 @@ export function useContextMenu(
|
|||||||
}
|
}
|
||||||
const links = getLinks(edges, edge.dataFrameRowIndex);
|
const links = getLinks(edges, edge.dataFrameRowIndex);
|
||||||
const renderer = getItemsRenderer(links, edge);
|
const renderer = getItemsRenderer(links, edge);
|
||||||
|
setMenu(makeContextMenu(<EdgeHeader edge={edge} edges={edges} />, event, setMenu, renderer));
|
||||||
if (renderer) {
|
|
||||||
setMenu(makeContextMenu(<EdgeHeader edge={edge} edges={edges} />, renderer, event, setMenu));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[edges, getLinks, setMenu]
|
[edges, getLinks, setMenu]
|
||||||
);
|
);
|
||||||
@ -77,9 +71,9 @@ export function useContextMenu(
|
|||||||
|
|
||||||
function makeContextMenu(
|
function makeContextMenu(
|
||||||
header: JSX.Element,
|
header: JSX.Element,
|
||||||
renderer: () => React.ReactNode,
|
|
||||||
event: MouseEvent<SVGElement>,
|
event: MouseEvent<SVGElement>,
|
||||||
setMenu: (el: JSX.Element | undefined) => void
|
setMenu: (el: JSX.Element | undefined) => void,
|
||||||
|
renderer?: () => React.ReactNode
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
@ -179,73 +173,78 @@ function getItems(links: LinkModel[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function NodeHeader({ node, nodes }: { node: NodeDatum; nodes?: DataFrame }) {
|
function FieldRow({ field, index }: { field: Field; index: number }) {
|
||||||
const index = node.dataFrameRowIndex;
|
return (
|
||||||
if (nodes) {
|
<HeaderRow
|
||||||
const fields = getNodeFields(nodes);
|
label={field.config?.displayName || field.name}
|
||||||
|
value={statToString(field.config, field.values.get(index) || '')}
|
||||||
return (
|
/>
|
||||||
<div>
|
);
|
||||||
{fields.title && (
|
|
||||||
<Label
|
|
||||||
label={fields.title.config.displayName || fields.title.name}
|
|
||||||
value={fields.title.values.get(index) || ''}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{fields.subTitle && (
|
|
||||||
<Label
|
|
||||||
label={fields.subTitle.config.displayName || fields.subTitle.name}
|
|
||||||
value={fields.subTitle.values.get(index) || ''}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{fields.details.map((f) => (
|
|
||||||
<Label key={f.name} label={f.config.displayName || f.name} value={f.values.get(index) || ''} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Fallback if we don't have nodes dataFrame. Can happen if we use just the edges frame to construct this.
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{node.title && <Label label={'Title'} value={node.title} />}
|
|
||||||
{node.subTitle && <Label label={'Subtitle'} value={node.subTitle} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HeaderRow({ label, value }: { label: string; value: string }) {
|
||||||
|
const styles = useStyles2(getLabelStyles);
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td className={styles.label}>{label}: </td>
|
||||||
|
<td className={styles.value}>{value}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows some field values in a table on top of the context menu.
|
||||||
|
*/
|
||||||
|
function NodeHeader({ node, nodes }: { node: NodeDatum; nodes?: DataFrame }) {
|
||||||
|
const rows = [];
|
||||||
|
if (nodes) {
|
||||||
|
const fields = getNodeFields(nodes);
|
||||||
|
for (const f of [fields.title, fields.subTitle, fields.mainStat, fields.secondaryStat, ...fields.details]) {
|
||||||
|
if (f && f.values.get(node.dataFrameRowIndex)) {
|
||||||
|
rows.push(<FieldRow field={f} index={node.dataFrameRowIndex} />);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback if we don't have nodes dataFrame. Can happen if we use just the edges frame to construct this.
|
||||||
|
if (node.title) {
|
||||||
|
rows.push(<HeaderRow label={'Title'} value={node.title} />);
|
||||||
|
}
|
||||||
|
if (node.subTitle) {
|
||||||
|
rows.push(<HeaderRow label={'Subtitle'} value={node.subTitle} />);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table style={{ width: '100%' }}>
|
||||||
|
<tbody>{rows}</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows some of the field values in a table on top of the context menu.
|
||||||
|
*/
|
||||||
function EdgeHeader(props: { edge: EdgeDatum; edges: DataFrame }) {
|
function EdgeHeader(props: { edge: EdgeDatum; edges: DataFrame }) {
|
||||||
const index = props.edge.dataFrameRowIndex;
|
const index = props.edge.dataFrameRowIndex;
|
||||||
const styles = getLabelStyles(useTheme2());
|
|
||||||
const fields = getEdgeFields(props.edges);
|
const fields = getEdgeFields(props.edges);
|
||||||
const valueSource = fields.source?.values.get(index) || '';
|
const valueSource = fields.source?.values.get(index) || '';
|
||||||
const valueTarget = fields.target?.values.get(index) || '';
|
const valueTarget = fields.target?.values.get(index) || '';
|
||||||
|
|
||||||
return (
|
const rows = [];
|
||||||
<div>
|
if (valueSource && valueTarget) {
|
||||||
{fields.source && fields.target && (
|
rows.push(<HeaderRow label={'Source → Target'} value={`${valueSource} → ${valueTarget}`} />);
|
||||||
<div className={styles.label}>
|
}
|
||||||
<div>Source → Target</div>
|
|
||||||
<span className={styles.value}>
|
|
||||||
{valueSource} → {valueTarget}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{fields.details.map((f) => (
|
|
||||||
<Label key={f.name} label={f.config.displayName || f.name} value={f.values.get(index) || ''} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Label({ label, value }: { label: string; value: string | number }) {
|
for (const f of [fields.mainStat, fields.secondaryStat, ...fields.details]) {
|
||||||
const styles = useStyles2(getLabelStyles);
|
if (f && f.values.get(index)) {
|
||||||
|
rows.push(<FieldRow field={f} index={index} />);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.label}>
|
<table style={{ width: '100%' }}>
|
||||||
<div>{label}</div>
|
<tbody>{rows}</tbody>
|
||||||
<span className={styles.value}>{value}</span>
|
</table>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -254,19 +253,16 @@ export const getLabelStyles = (theme: GrafanaTheme2) => {
|
|||||||
label: css`
|
label: css`
|
||||||
label: Label;
|
label: Label;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
margin-bottom: ${theme.spacing(0.5)};
|
|
||||||
padding-left: ${theme.spacing(0.25)};
|
|
||||||
color: ${theme.colors.text.disabled};
|
color: ${theme.colors.text.disabled};
|
||||||
font-size: ${theme.typography.size.sm};
|
font-size: ${theme.typography.size.sm};
|
||||||
font-weight: ${theme.typography.fontWeightMedium};
|
font-weight: ${theme.typography.fontWeightMedium};
|
||||||
|
padding-right: ${theme.spacing(1)};
|
||||||
`,
|
`,
|
||||||
value: css`
|
value: css`
|
||||||
label: Value;
|
label: Value;
|
||||||
font-size: ${theme.typography.size.sm};
|
font-size: ${theme.typography.size.sm};
|
||||||
font-weight: ${theme.typography.fontWeightMedium};
|
font-weight: ${theme.typography.fontWeightMedium};
|
||||||
color: ${theme.colors.text.primary};
|
color: ${theme.colors.text.primary};
|
||||||
margin-top: ${theme.spacing(0.25)};
|
|
||||||
display: block;
|
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -311,6 +311,7 @@ function makeNodeDatum(options: Partial<NodeDatum> = {}) {
|
|||||||
},
|
},
|
||||||
subTitle: 'service',
|
subTitle: 'service',
|
||||||
title: 'service:0',
|
title: 'service:0',
|
||||||
|
icon: 'database',
|
||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -45,6 +45,7 @@ export type NodeFields = {
|
|||||||
arc: Field[];
|
arc: Field[];
|
||||||
details: Field[];
|
details: Field[];
|
||||||
color?: Field;
|
color?: Field;
|
||||||
|
icon?: Field;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getNodeFields(nodes: DataFrame): NodeFields {
|
export function getNodeFields(nodes: DataFrame): NodeFields {
|
||||||
@ -62,6 +63,7 @@ export function getNodeFields(nodes: DataFrame): NodeFields {
|
|||||||
arc: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.arc),
|
arc: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.arc),
|
||||||
details: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.detail),
|
details: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.detail),
|
||||||
color: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.color),
|
color: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.color),
|
||||||
|
icon: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.icon),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -287,17 +289,18 @@ function makeSimpleNodeDatum(name: string, index: number): NodeDatumFromEdge {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeNodeDatum(id: string, nodeFields: NodeFields, index: number) {
|
function makeNodeDatum(id: string, nodeFields: NodeFields, index: number): NodeDatum {
|
||||||
return {
|
return {
|
||||||
id: id,
|
id: id,
|
||||||
title: nodeFields.title?.values.get(index) || '',
|
title: nodeFields.title?.values.get(index) || '',
|
||||||
subTitle: nodeFields.subTitle ? nodeFields.subTitle.values.get(index) : '',
|
subTitle: nodeFields.subTitle?.values.get(index) || '',
|
||||||
dataFrameRowIndex: index,
|
dataFrameRowIndex: index,
|
||||||
incoming: 0,
|
incoming: 0,
|
||||||
mainStat: nodeFields.mainStat,
|
mainStat: nodeFields.mainStat,
|
||||||
secondaryStat: nodeFields.secondaryStat,
|
secondaryStat: nodeFields.secondaryStat,
|
||||||
arcSections: nodeFields.arc,
|
arcSections: nodeFields.arc,
|
||||||
color: nodeFields.color,
|
color: nodeFields.color,
|
||||||
|
icon: nodeFields.icon?.values.get(index) || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -337,6 +340,7 @@ function makeNode(index: number) {
|
|||||||
mainstat: 0.1,
|
mainstat: 0.1,
|
||||||
secondarystat: 2,
|
secondarystat: 2,
|
||||||
color: 0.5,
|
color: 0.5,
|
||||||
|
icon: 'database',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -372,12 +376,15 @@ function nodesFrame() {
|
|||||||
type: FieldType.number,
|
type: FieldType.number,
|
||||||
config: { color: { fixedColor: 'red' } },
|
config: { color: { fixedColor: 'red' } },
|
||||||
},
|
},
|
||||||
|
|
||||||
[NodeGraphDataFrameFieldNames.color]: {
|
[NodeGraphDataFrameFieldNames.color]: {
|
||||||
values: new ArrayVector(),
|
values: new ArrayVector(),
|
||||||
type: FieldType.number,
|
type: FieldType.number,
|
||||||
config: { color: { mode: 'continuous-GrYlRd' } },
|
config: { color: { mode: 'continuous-GrYlRd' } },
|
||||||
},
|
},
|
||||||
|
[NodeGraphDataFrameFieldNames.icon]: {
|
||||||
|
values: new ArrayVector(),
|
||||||
|
type: FieldType.string,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return new MutableDataFrame({
|
return new MutableDataFrame({
|
||||||
|
Loading…
Reference in New Issue
Block a user