mirror of
https://github.com/grafana/grafana.git
synced 2025-07-30 03:42:08 +08:00
NodeGraph: Exploration mode (#33623)
* Add exploration option to node layout * Add hidden node count * Add grid layout option * Fix panning bounds calculation * Add legend with sorting * Allow sorting on any stats or arc value * Fix merge * Make sorting better * Reset focused node on layout change * Refactor limit hook a bit * Disable selected layout button * Don't show markers if only 1 node is hidden * Move legend to the bottom * Fix text backgrounds * Add show in graph layout action in grid layout * Center view on the focused node, fix perf issue when expanding big graph * Limit the node counting * Comment and linting fixes * Bit of code cleanup and comments * Add state for computing layout * Prevent computing map with partial data * Add rollup plugin for worker * Add rollup plugin for worker * Enhance data from worker * Fix perf issues with reduce and object creation * Improve comment * Fix tests * Css fixes * Remove worker plugin * Add comments * Fix test * Add test for exploration * Add test switching to grid layout * Apply suggestions from code review Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com> * Remove unused plugin * Fix function name * Remove unused rollup plugin * Review fixes * Fix context menu shown on layout change * Make buttons bigger * Moved NodeGraph to core grafana Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>
This commit is contained in:
213
public/app/plugins/panel/nodeGraph/useContextMenu.tsx
Normal file
213
public/app/plugins/panel/nodeGraph/useContextMenu.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
import React, { MouseEvent, useCallback, useState } from 'react';
|
||||
import { EdgeDatum, NodeDatum } from './types';
|
||||
import { DataFrame, Field, GrafanaTheme, LinkModel } from '@grafana/data';
|
||||
import { getEdgeFields, getNodeFields } from './utils';
|
||||
import { css } from '@emotion/css';
|
||||
import { Config } from './layout';
|
||||
import { ContextMenu, MenuGroup, MenuItem, stylesFactory, useTheme } from '@grafana/ui';
|
||||
|
||||
/**
|
||||
* 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,
|
||||
config: Config,
|
||||
setConfig: (config: Config) => void,
|
||||
setFocusedNodeId: (id: string) => void
|
||||
): {
|
||||
onEdgeOpen: (event: MouseEvent<SVGElement>, edge: EdgeDatum) => void;
|
||||
onNodeOpen: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
|
||||
MenuComponent: React.ReactNode;
|
||||
} {
|
||||
const [menu, setMenu] = useState<JSX.Element | undefined>(undefined);
|
||||
|
||||
const onNodeOpen = useCallback(
|
||||
(event, node) => {
|
||||
const extraNodeItem = config.gridLayout
|
||||
? [
|
||||
{
|
||||
label: 'Show in Graph layout',
|
||||
onClick: (node: NodeDatum) => {
|
||||
setFocusedNodeId(node.id);
|
||||
setConfig({ ...config, gridLayout: false });
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
const renderer = getItemsRenderer(getLinks(nodes, node.dataFrameRowIndex), node, extraNodeItem);
|
||||
|
||||
if (renderer) {
|
||||
setMenu(
|
||||
<ContextMenu
|
||||
renderHeader={() => <NodeHeader node={node} nodes={nodes} />}
|
||||
renderMenuItems={renderer}
|
||||
onClose={() => setMenu(undefined)}
|
||||
x={event.pageX}
|
||||
y={event.pageY}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[config, nodes, getLinks, setMenu, setConfig, setFocusedNodeId]
|
||||
);
|
||||
|
||||
const onEdgeOpen = useCallback(
|
||||
(event, edge) => {
|
||||
const renderer = getItemsRenderer(getLinks(edges, edge.dataFrameRowIndex), edge);
|
||||
|
||||
if (renderer) {
|
||||
setMenu(
|
||||
<ContextMenu
|
||||
renderHeader={() => <EdgeHeader edge={edge} edges={edges} />}
|
||||
renderMenuItems={renderer}
|
||||
onClose={() => setMenu(undefined)}
|
||||
x={event.pageX}
|
||||
y={event.pageY}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[edges, getLinks, setMenu]
|
||||
);
|
||||
|
||||
return { onEdgeOpen, onNodeOpen, MenuComponent: menu };
|
||||
}
|
||||
|
||||
function getItemsRenderer<T extends NodeDatum | EdgeDatum>(
|
||||
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} ariaLabel={group.label}>
|
||||
{(group.items || []).map(mapMenuItem(item))}
|
||||
</MenuGroup>
|
||||
));
|
||||
|
||||
if (extraItems) {
|
||||
groups = [...extraItems.map(mapMenuItem(item)), ...groups];
|
||||
}
|
||||
return groups;
|
||||
};
|
||||
}
|
||||
|
||||
function mapMenuItem<T extends NodeDatum | EdgeDatum>(item: T) {
|
||||
return function NodeGraphMenuItem(link: LinkData<T>) {
|
||||
return (
|
||||
<MenuItem
|
||||
key={link.label}
|
||||
url={link.url}
|
||||
label={link.label}
|
||||
ariaLabel={link.ariaLabel || link.label}
|
||||
onClick={link.onClick ? () => link.onClick?.(item) : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
type LinkData<T extends NodeDatum | EdgeDatum> = {
|
||||
label: string;
|
||||
ariaLabel?: string;
|
||||
url?: string;
|
||||
onClick?: (item: T) => void;
|
||||
};
|
||||
|
||||
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,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user