The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/plugins/panel/nodeGraph/useContextMenu.tsx

282 lines
8.3 KiB

import { css } from '@emotion/css';
import { MouseEvent, useCallback, useState } from 'react';
import * as React from 'react';
import { DataFrame, Field, GrafanaTheme2, LinkModel, LinkTarget } from '@grafana/data';
import { useTranslate } from '@grafana/i18n';
import { ContextMenu, MenuGroup, MenuItem, useStyles2 } from '@grafana/ui';
import { Config } from './layout';
import { EdgeDatumLayout, NodeDatum } from './types';
import { getEdgeFields, getNodeFields, statToString } from './utils';
/**
* 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[],
// This can be undefined if we only use edge dataframe
nodes: DataFrame | undefined,
// This can be undefined if we have only single node
edges: DataFrame | undefined,
config: Config,
setConfig: (config: Config) => void,
setFocusedNodeId: (id: string) => void
): {
onEdgeOpen: (event: MouseEvent<SVGElement>, edge: EdgeDatumLayout) => void;
onNodeOpen: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
MenuComponent: React.ReactNode;
} {
const [menu, setMenu] = useState<JSX.Element | undefined>(undefined);
const onNodeOpen = useCallback(
(event: MouseEvent<SVGElement>, node: NodeDatum) => {
const [label, showGridLayout] = config.gridLayout
? ['Show in Graph layout', false]
: ['Show in Grid layout', true];
const extraNodeItem = [
{
label: label,
onClick: (node: NodeDatum) => {
setFocusedNodeId(node.id);
setConfig({ ...config, gridLayout: showGridLayout });
setMenu(undefined);
},
},
];
const links = nodes ? getLinks(nodes, node.dataFrameRowIndex) : [];
const renderer = getItemsRenderer(links, node, extraNodeItem);
setMenu(makeContextMenu(<NodeHeader node={node} nodes={nodes} />, event, setMenu, renderer));
},
[config, nodes, getLinks, setMenu, setConfig, setFocusedNodeId]
);
const onEdgeOpen = useCallback(
(event: MouseEvent<SVGElement>, edge: EdgeDatumLayout) => {
if (!edges) {
// This could happen if we have only one node and no edges, in which case this is not needed as there is no edge
// to click on.
return;
}
const links = getLinks(edges, edge.dataFrameRowIndex);
const renderer = getItemsRenderer(links, edge);
setMenu(makeContextMenu(<EdgeHeader edge={edge} edges={edges} />, event, setMenu, renderer));
},
[edges, getLinks, setMenu]
);
return { onEdgeOpen, onNodeOpen, MenuComponent: menu };
}
function makeContextMenu(
header: JSX.Element,
event: MouseEvent<SVGElement>,
setMenu: (el: JSX.Element | undefined) => void,
renderer?: () => React.ReactNode
) {
return (
<ContextMenu
renderHeader={() => header}
renderMenuItems={renderer}
onClose={() => setMenu(undefined)}
x={event.pageX}
y={event.pageY}
/>
);
}
function getItemsRenderer<T extends NodeDatum | EdgeDatumLayout>(
links: LinkModel[],
item: T,
extraItems?: Array<LinkData<T>> | undefined
) {
if (!(links.length || extraItems?.length)) {
return undefined;
}
const items = getItems(links);
return () => {
let groups = items?.map((group, index) => (
<MenuGroup key={`${group.label}${index}`} label={group.label}>
{(group.items || []).map(mapMenuItem(item))}
</MenuGroup>
));
if (extraItems) {
groups = [...extraItems.map(mapMenuItem(item)), ...groups];
}
return groups;
};
}
function mapMenuItem<T extends NodeDatum | EdgeDatumLayout>(item: T) {
return function NodeGraphMenuItem(link: LinkData<T>) {
return (
<MenuItem
key={link.label}
url={link.url}
label={link.label}
ariaLabel={link.ariaLabel}
onClick={
link.onClick
? (event) => {
if (!(event?.ctrlKey || event?.metaKey || event?.shiftKey)) {
event?.preventDefault();
event?.stopPropagation();
link.onClick?.(item);
}
}
: undefined
}
target={link.target || '_self'}
/>
);
};
}
type LinkData<T extends NodeDatum | EdgeDatumLayout> = {
label: string;
ariaLabel?: string;
url?: string;
onClick?: (item: T) => void;
target?: LinkTarget;
};
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,
ariaLabel: key,
items: groups[key].map((link) => ({
label: link.newTitle || link.l.title,
ariaLabel: link.newTitle || link.l.title,
url: link.l.href,
onClick: link.l.onClick,
target: link.l.target,
})),
};
});
}
function FieldRow({ field, index }: { field: Field; index: number }) {
return (
<HeaderRow
label={field.config?.displayName || field.name}
value={statToString(field.config, field.values[index] || '')}
/>
);
}
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 { t } = useTranslate();
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[node.dataFrameRowIndex]) {
rows.push(<FieldRow key={f.name} 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 key="title" label={t('nodeGraph.node-header.label-title', 'Title')} value={node.title} />);
}
if (node.subTitle) {
rows.push(
<HeaderRow key="subtitle" label={t('nodeGraph.node-header.label-subtitle', '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: EdgeDatumLayout; edges: DataFrame }) {
const { t } = useTranslate();
const index = props.edge.dataFrameRowIndex;
const fields = getEdgeFields(props.edges);
const valueSource = fields.source?.values[index] || '';
const valueTarget = fields.target?.values[index] || '';
const rows = [];
if (valueSource && valueTarget) {
rows.push(
<HeaderRow
key={'header-row'}
label={t('nodeGraph.edge-header.label-source-target', 'Source → Target')}
value={`${valueSource}${valueTarget}`}
/>
);
}
for (const f of [fields.mainStat, fields.secondaryStat, ...fields.details]) {
if (f && f.values[index]) {
rows.push(<FieldRow key={`field-row-${index}`} field={f} index={index} />);
}
}
return (
<table style={{ width: '100%' }}>
<tbody>{rows}</tbody>
</table>
);
}
export const getLabelStyles = (theme: GrafanaTheme2) => {
return {
label: css({
label: 'Label',
lineHeight: 1.25,
color: theme.colors.text.disabled,
fontSize: theme.typography.size.sm,
fontWeight: theme.typography.fontWeightMedium,
paddingRight: theme.spacing(1),
}),
value: css({
label: 'Value',
fontSize: theme.typography.size.sm,
fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.text.primary,
}),
};
};