mirror of
https://github.com/grafana/grafana.git
synced 2026-07-30 08:18:10 -05:00
Variables: Adds variables inspection (#25214)
* Chore: initial commit * Refactor: adds usages * Refactor: adds FeatureInfoBox * Refactor: uses graphs instead * Refactor: adds id, name, title to prop instead of key * Refactor: adds usages to VariablesList * Refactor: Moves unknowns to first page * Refactor: minor stylings and icons * Refactor: styling * Refactor: changed to button with modal * Refactor: adds VariablesDependenciesButton * Refactor: changes after UX feedback * Refactor: renames heading * Refactor: changes after PR comments * Refactor: small changes after PR comments
This commit is contained in:
+2
-1
@@ -288,7 +288,8 @@
|
||||
"tinycolor2": "1.4.1",
|
||||
"tti-polyfill": "0.2.2",
|
||||
"uuid": "8.3.0",
|
||||
"whatwg-fetch": "3.1.0"
|
||||
"whatwg-fetch": "3.1.0",
|
||||
"visjs-network": "4.25.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"caniuse-db": "1.0.30000772"
|
||||
|
||||
@@ -2,14 +2,14 @@ import React from 'react';
|
||||
import { Portal } from '../Portal/Portal';
|
||||
import { cx } from 'emotion';
|
||||
import { withTheme } from '../../themes';
|
||||
import { IconName } from '../../types';
|
||||
import { Themeable } from '../../types';
|
||||
import { IconName, Themeable } from '../../types';
|
||||
import { getModalStyles } from './getModalStyles';
|
||||
import { ModalHeader } from './ModalHeader';
|
||||
import { IconButton } from '../IconButton/IconButton';
|
||||
|
||||
export interface Props extends Themeable {
|
||||
icon?: IconName;
|
||||
iconTooltip?: string;
|
||||
/** Title for the modal or custom header element */
|
||||
title: string | JSX.Element;
|
||||
className?: string;
|
||||
@@ -33,9 +33,9 @@ export class UnthemedModal extends React.PureComponent<Props> {
|
||||
};
|
||||
|
||||
renderDefaultHeader(title: string) {
|
||||
const { icon } = this.props;
|
||||
const { icon, iconTooltip } = this.props;
|
||||
|
||||
return <ModalHeader icon={icon} title={title} />;
|
||||
return <ModalHeader icon={icon} iconTooltip={iconTooltip} title={title} />;
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -3,20 +3,27 @@ import { getModalStyles } from './getModalStyles';
|
||||
import { IconName } from '../../types';
|
||||
import { ThemeContext } from '../../themes';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { Tooltip } from '..';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
icon?: IconName;
|
||||
iconTooltip?: string;
|
||||
}
|
||||
|
||||
export const ModalHeader: React.FC<Props> = ({ icon, title, children }) => {
|
||||
export const ModalHeader: React.FC<Props> = ({ icon, iconTooltip, title, children }) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const styles = getModalStyles(theme);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className={styles.modalHeaderTitle}>
|
||||
{icon && <Icon name={icon} size="lg" className={styles.modalHeaderIcon} />}
|
||||
{icon && !iconTooltip && <Icon name={icon} size="lg" className={styles.modalHeaderIcon} />}
|
||||
{icon && iconTooltip && (
|
||||
<Tooltip content={iconTooltip}>
|
||||
<Icon name={icon} size="lg" className={styles.modalHeaderIcon} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { NEW_VARIABLE_ID, toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types';
|
||||
import { StoreState } from '../../../types';
|
||||
import { VariableEditorList } from './VariableEditorList';
|
||||
import { VariableEditorEditor } from './VariableEditorEditor';
|
||||
import { MapDispatchToProps, MapStateToProps } from 'react-redux';
|
||||
import { connectWithStore } from '../../../core/utils/connectWithReduxStore';
|
||||
@@ -12,12 +11,17 @@ import { getEditorVariables } from '../state/selectors';
|
||||
import { VariableModel } from '../types';
|
||||
import { switchToEditMode, switchToListMode, switchToNewMode } from './actions';
|
||||
import { changeVariableOrder, duplicateVariable, removeVariable } from '../state/sharedReducer';
|
||||
import { VariableEditorList } from './VariableEditorList';
|
||||
import { DashboardModel } from '../../dashboard/state';
|
||||
import { VariablesUnknownTable } from '../inspect/VariablesUnknownTable';
|
||||
import { VariablesDependenciesButton } from '../inspect/VariablesDependenciesButton';
|
||||
|
||||
interface OwnProps {}
|
||||
|
||||
interface ConnectedProps {
|
||||
idInEditor: string | null;
|
||||
variables: VariableModel[];
|
||||
dashboard: DashboardModel | null;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
@@ -96,26 +100,33 @@ class VariableEditorContainerUnconnected extends PureComponent<Props> {
|
||||
|
||||
<div className="page-action-bar__spacer" />
|
||||
{this.props.variables.length > 0 && variableToEdit === null && (
|
||||
<a
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={this.onNewVariable}
|
||||
aria-label={selectors.pages.Dashboard.Settings.Variables.List.newButton}
|
||||
>
|
||||
New
|
||||
</a>
|
||||
<>
|
||||
<VariablesDependenciesButton variables={this.props.variables} />
|
||||
<a
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={this.onNewVariable}
|
||||
aria-label={selectors.pages.Dashboard.Settings.Variables.List.newButton}
|
||||
>
|
||||
New
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!variableToEdit && (
|
||||
<VariableEditorList
|
||||
variables={this.props.variables}
|
||||
onAddClick={this.onNewVariable}
|
||||
onEditClick={this.onEditVariable}
|
||||
onChangeVariableOrder={this.onChangeVariableOrder}
|
||||
onDuplicateVariable={this.onDuplicateVariable}
|
||||
onRemoveVariable={this.onRemoveVariable}
|
||||
/>
|
||||
<>
|
||||
<VariableEditorList
|
||||
dashboard={this.props.dashboard}
|
||||
variables={this.props.variables}
|
||||
onAddClick={this.onNewVariable}
|
||||
onEditClick={this.onEditVariable}
|
||||
onChangeVariableOrder={this.onChangeVariableOrder}
|
||||
onDuplicateVariable={this.onDuplicateVariable}
|
||||
onRemoveVariable={this.onRemoveVariable}
|
||||
/>
|
||||
<VariablesUnknownTable dashboard={this.props.dashboard} variables={this.props.variables} />
|
||||
</>
|
||||
)}
|
||||
{variableToEdit && <VariableEditorEditor identifier={toVariableIdentifier(variableToEdit)} />}
|
||||
</div>
|
||||
@@ -126,6 +137,7 @@ class VariableEditorContainerUnconnected extends PureComponent<Props> {
|
||||
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = state => ({
|
||||
variables: getEditorVariables(state),
|
||||
idInEditor: state.templating.editor.id,
|
||||
dashboard: state.dashboard.getModel(),
|
||||
});
|
||||
|
||||
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import React, { MouseEvent, PureComponent } from 'react';
|
||||
import { IconButton } from '@grafana/ui';
|
||||
import React, { FC, MouseEvent, PureComponent } from 'react';
|
||||
import { css } from 'emotion';
|
||||
import { Icon, IconButton, useStyles } from '@grafana/ui';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
|
||||
import EmptyListCTA from '../../../core/components/EmptyListCTA/EmptyListCTA';
|
||||
import { QueryVariableModel, VariableModel } from '../types';
|
||||
import { toVariableIdentifier, VariableIdentifier } from '../state/types';
|
||||
import { DashboardModel } from '../../dashboard/state';
|
||||
import { getVariableUsages } from '../inspect/utils';
|
||||
import { isAdHoc } from '../guard';
|
||||
import { VariableUsagesButton } from '../inspect/VariableUsagesButton';
|
||||
|
||||
export interface Props {
|
||||
variables: VariableModel[];
|
||||
dashboard: DashboardModel | null;
|
||||
onAddClick: (event: MouseEvent<HTMLAnchorElement>) => void;
|
||||
onEditClick: (identifier: VariableIdentifier) => void;
|
||||
onChangeVariableOrder: (identifier: VariableIdentifier, fromIndex: number, toIndex: number) => void;
|
||||
@@ -79,12 +86,14 @@ export class VariableEditorList extends PureComponent<Props> {
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Definition</th>
|
||||
<th colSpan={5} />
|
||||
<th colSpan={6} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{this.props.variables.map((state, index) => {
|
||||
const variable = state as QueryVariableModel;
|
||||
const usages = getVariableUsages(variable.id, this.props.variables, this.props.dashboard);
|
||||
const passed = usages > 0 || isAdHoc(variable);
|
||||
return (
|
||||
<tr key={`${variable.name}-${index}`}>
|
||||
<td style={{ width: '1%' }}>
|
||||
@@ -109,6 +118,18 @@ export class VariableEditorList extends PureComponent<Props> {
|
||||
{variable.definition ? variable.definition : variable.query}
|
||||
</td>
|
||||
|
||||
<td style={{ width: '1%' }}>
|
||||
<VariableCheckIndicator passed={passed} />
|
||||
</td>
|
||||
|
||||
<td style={{ width: '1%' }}>
|
||||
<VariableUsagesButton
|
||||
variable={variable}
|
||||
variables={this.props.variables}
|
||||
dashboard={this.props.dashboard}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td style={{ width: '1%' }}>
|
||||
{index > 0 && (
|
||||
<IconButton
|
||||
@@ -121,6 +142,7 @@ export class VariableEditorList extends PureComponent<Props> {
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td style={{ width: '1%' }}>
|
||||
{index < this.props.variables.length - 1 && (
|
||||
<IconButton
|
||||
@@ -133,6 +155,7 @@ export class VariableEditorList extends PureComponent<Props> {
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td style={{ width: '1%' }}>
|
||||
<IconButton
|
||||
onClick={event => this.onDuplicateVariable(event, toVariableIdentifier(variable))}
|
||||
@@ -143,6 +166,7 @@ export class VariableEditorList extends PureComponent<Props> {
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td style={{ width: '1%' }}>
|
||||
<IconButton
|
||||
onClick={event => this.onRemoveVariable(event, toVariableIdentifier(variable))}
|
||||
@@ -165,3 +189,37 @@ export class VariableEditorList extends PureComponent<Props> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface VariableCheckIndicatorProps {
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
const VariableCheckIndicator: FC<VariableCheckIndicatorProps> = ({ passed }) => {
|
||||
const style = useStyles(getStyles);
|
||||
if (passed) {
|
||||
return (
|
||||
<Icon
|
||||
name="check"
|
||||
className={style.iconPassed}
|
||||
title="This variable is referenced by other variables or dashboard"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Icon
|
||||
name="exclamation-triangle"
|
||||
className={style.iconFailed}
|
||||
title="This variable is not referenced by any variable or dashboard"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
iconPassed: css`
|
||||
color: ${theme.palette.greenBase};
|
||||
`,
|
||||
iconFailed: css`
|
||||
color: ${theme.palette.orange};
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { FC, useCallback, useEffect, useRef } from 'react';
|
||||
// @ts-ignore
|
||||
import vis from 'visjs-network';
|
||||
import { GraphEdge, GraphNode, toVisNetworkEdges, toVisNetworkNodes } from './utils';
|
||||
|
||||
interface OwnProps {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
direction?: 'UD' | 'DU' | 'LR' | 'RL';
|
||||
onDoubleClick?: (node: string) => void;
|
||||
width?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
interface ConnectedProps {}
|
||||
|
||||
interface DispatchProps {}
|
||||
|
||||
export type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||
|
||||
export const NetworkGraph: FC<Props> = ({ nodes, edges, direction, width, height, onDoubleClick }) => {
|
||||
let network: any = null;
|
||||
const ref = useRef(null);
|
||||
|
||||
const onNodeDoubleClick = useCallback(
|
||||
(params: { nodes: string[] }) => {
|
||||
if (onDoubleClick) {
|
||||
onDoubleClick(params.nodes[0]);
|
||||
}
|
||||
},
|
||||
[onDoubleClick]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const data = {
|
||||
nodes: toVisNetworkNodes(nodes),
|
||||
edges: toVisNetworkEdges(edges),
|
||||
};
|
||||
|
||||
const options = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
autoResize: true,
|
||||
layout: {
|
||||
improvedLayout: true,
|
||||
hierarchical: {
|
||||
enabled: true,
|
||||
direction: direction ?? 'DU',
|
||||
sortMethod: 'directed',
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
navigationButtons: true,
|
||||
dragNodes: false,
|
||||
},
|
||||
};
|
||||
|
||||
network = new vis.Network(ref.current, data, options);
|
||||
network.on('doubleClick', onNodeDoubleClick);
|
||||
|
||||
return () => {
|
||||
// unsubscribe event handlers
|
||||
if (network) {
|
||||
network.off('doubleClick');
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={ref} style={{ width: width ?? '100%', height: height ?? '60vh' }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { GraphEdge, GraphNode } from './utils';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Modal } from '@grafana/ui';
|
||||
import { NetworkGraph, Props as NetWorkGraphProps } from './NetworkGraph';
|
||||
|
||||
interface NetworkGraphModalApi {
|
||||
showModal: () => void;
|
||||
}
|
||||
|
||||
interface OwnProps extends Pick<NetWorkGraphProps, 'direction'> {
|
||||
show: boolean;
|
||||
title: string;
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
children: (api: NetworkGraphModalApi) => JSX.Element;
|
||||
}
|
||||
|
||||
interface ConnectedProps {}
|
||||
|
||||
interface DispatchProps {}
|
||||
|
||||
type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||
|
||||
export function NetworkGraphModal({ edges, nodes, show: propsShow, title, children }: Props): JSX.Element {
|
||||
const [show, setShow] = useState(propsShow);
|
||||
const showModal = useCallback(() => setShow(true), [setShow]);
|
||||
const onClose = useCallback(() => setShow(false), [setShow]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={show}
|
||||
title={title}
|
||||
icon="info-circle"
|
||||
iconTooltip="The graph can be moved, zoomed in and zoomed out."
|
||||
onClickBackdrop={onClose}
|
||||
onDismiss={onClose}
|
||||
>
|
||||
<NetworkGraph nodes={nodes} edges={edges} />
|
||||
</Modal>
|
||||
{children({ showModal })}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { IconButton } from '@grafana/ui';
|
||||
|
||||
import { createUsagesNetwork, transformUsagesToNetwork } from './utils';
|
||||
import { store } from '../../../store/store';
|
||||
import { isAdHoc } from '../guard';
|
||||
import { NetworkGraphModal } from './NetworkGraphModal';
|
||||
import { VariableModel } from '../types';
|
||||
import { DashboardModel } from '../../dashboard/state';
|
||||
|
||||
interface OwnProps {
|
||||
variables: VariableModel[];
|
||||
variable: VariableModel;
|
||||
dashboard: DashboardModel | null;
|
||||
}
|
||||
|
||||
interface ConnectedProps {}
|
||||
|
||||
interface DispatchProps {}
|
||||
|
||||
type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||
|
||||
export const UnProvidedVariableUsagesGraphButton: FC<Props> = ({ variables, variable, dashboard }) => {
|
||||
const { id } = variable;
|
||||
const { usages } = useMemo(() => createUsagesNetwork(variables, dashboard), [variables, dashboard]);
|
||||
const network = useMemo(() => transformUsagesToNetwork(usages).find(n => n.variable.id === id), [usages, id]);
|
||||
const adhoc = useMemo(() => isAdHoc(variable), [variable]);
|
||||
|
||||
if (usages.length === 0 || adhoc || !network) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodes = network.nodes.map(n => {
|
||||
if (n.label.includes(`$${id}`)) {
|
||||
return { ...n, color: '#FB7E81' };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
|
||||
return (
|
||||
<NetworkGraphModal show={false} title={`Showing usages for: $${id}`} nodes={nodes} edges={network.edges}>
|
||||
{({ showModal }) => {
|
||||
return <IconButton onClick={() => showModal()} name="code-branch" title="Show usages" />;
|
||||
}}
|
||||
</NetworkGraphModal>
|
||||
);
|
||||
};
|
||||
|
||||
export const VariableUsagesButton: FC<Props> = props => (
|
||||
<Provider store={store}>
|
||||
<UnProvidedVariableUsagesGraphButton {...props} />
|
||||
</Provider>
|
||||
);
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
// @ts-ignore
|
||||
import { Button } from '@grafana/ui';
|
||||
import { createDependencyEdges, createDependencyNodes, filterNodesWithDependencies } from './utils';
|
||||
import { store } from '../../../store/store';
|
||||
import { VariableModel } from '../types';
|
||||
import { NetworkGraphModal } from './NetworkGraphModal';
|
||||
|
||||
interface OwnProps {
|
||||
variables: VariableModel[];
|
||||
}
|
||||
|
||||
interface ConnectedProps {}
|
||||
|
||||
interface DispatchProps {}
|
||||
|
||||
type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||
|
||||
export const UnProvidedVariablesDependenciesButton: FC<Props> = ({ variables }) => {
|
||||
const nodes = useMemo(() => createDependencyNodes(variables), [variables]);
|
||||
const edges = useMemo(() => createDependencyEdges(variables), [variables]);
|
||||
|
||||
return (
|
||||
<NetworkGraphModal
|
||||
show={false}
|
||||
title="Dependencies"
|
||||
nodes={filterNodesWithDependencies(nodes, edges)}
|
||||
edges={edges}
|
||||
>
|
||||
{({ showModal }) => {
|
||||
return (
|
||||
<Button onClick={() => showModal()} icon="channel-add" variant="secondary">
|
||||
Show dependencies
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
</NetworkGraphModal>
|
||||
);
|
||||
};
|
||||
|
||||
export const VariablesDependenciesButton: FC<Props> = props => (
|
||||
<Provider store={store}>
|
||||
<UnProvidedVariablesDependenciesButton {...props} />
|
||||
</Provider>
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { IconButton } from '@grafana/ui';
|
||||
import { createUsagesNetwork, transformUsagesToNetwork } from './utils';
|
||||
import { store } from '../../../store/store';
|
||||
import { VariableModel } from '../types';
|
||||
import { DashboardModel } from '../../dashboard/state';
|
||||
import { NetworkGraphModal } from './NetworkGraphModal';
|
||||
|
||||
interface OwnProps {
|
||||
variable: VariableModel;
|
||||
variables: VariableModel[];
|
||||
dashboard: DashboardModel | null;
|
||||
}
|
||||
|
||||
interface ConnectedProps {}
|
||||
|
||||
interface DispatchProps {}
|
||||
|
||||
type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||
|
||||
export const UnProvidedVariablesUnknownGraphButton: FC<Props> = ({ variable, variables, dashboard }) => {
|
||||
const { id } = variable;
|
||||
const { unknown } = useMemo(() => createUsagesNetwork(variables, dashboard), [variables, dashboard]);
|
||||
const network = useMemo(() => transformUsagesToNetwork(unknown).find(n => n.variable.id === id), [id, unknown]);
|
||||
const unknownExist = useMemo(() => Object.keys(unknown).length > 0, [unknown]);
|
||||
|
||||
if (!unknownExist || !network) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodes = network.nodes.map(n => {
|
||||
if (n.label.includes(`$${id}`)) {
|
||||
return { ...n, color: '#FB7E81' };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
|
||||
return (
|
||||
<NetworkGraphModal show={false} title={`Showing usages for: $${id}`} nodes={nodes} edges={network.edges}>
|
||||
{({ showModal }) => {
|
||||
return <IconButton onClick={() => showModal()} name="code-branch" title="Show usages" />;
|
||||
}}
|
||||
</NetworkGraphModal>
|
||||
);
|
||||
};
|
||||
|
||||
export const VariablesUnknownButton: FC<Props> = props => (
|
||||
<Provider store={store}>
|
||||
<UnProvidedVariablesUnknownGraphButton {...props} />
|
||||
</Provider>
|
||||
);
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { css } from 'emotion';
|
||||
import { Icon, Tooltip, useStyles } from '@grafana/ui';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { createUsagesNetwork, transformUsagesToNetwork } from './utils';
|
||||
import { store } from '../../../store/store';
|
||||
import { VariablesUnknownButton } from './VariablesUnknownButton';
|
||||
import { VariableModel } from '../types';
|
||||
import { DashboardModel } from '../../dashboard/state';
|
||||
|
||||
interface OwnProps {
|
||||
variables: VariableModel[];
|
||||
dashboard: DashboardModel | null;
|
||||
}
|
||||
|
||||
interface ConnectedProps {}
|
||||
|
||||
interface DispatchProps {}
|
||||
|
||||
type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||
|
||||
export const UnProvidedVariablesUnknownTable: FC<Props> = ({ variables, dashboard }) => {
|
||||
const style = useStyles(getStyles);
|
||||
const { unknown } = useMemo(() => createUsagesNetwork(variables, dashboard), [variables, dashboard]);
|
||||
const networks = useMemo(() => transformUsagesToNetwork(unknown), [unknown]);
|
||||
const unknownExist = useMemo(() => Object.keys(unknown).length > 0, [unknown]);
|
||||
|
||||
if (!unknownExist) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<h5>
|
||||
Unknown Variables
|
||||
<Tooltip content="This table lists all variable references that no longer exist in this dashboard.">
|
||||
<Icon name="info-circle" className={style.infoIcon} />
|
||||
</Tooltip>
|
||||
</h5>
|
||||
|
||||
<div>
|
||||
<table className="filter-table filter-table--hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th colSpan={5} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{networks.map(network => {
|
||||
const { variable } = network;
|
||||
const { id, name } = variable;
|
||||
return (
|
||||
<tr key={id}>
|
||||
<td className={style.firstColumn}>
|
||||
<span>{name}</span>
|
||||
</td>
|
||||
<td className={style.defaultColumn} />
|
||||
<td className={style.defaultColumn} />
|
||||
<td className={style.defaultColumn} />
|
||||
<td className={style.lastColumn}>
|
||||
<VariablesUnknownButton variable={variable} variables={variables} dashboard={dashboard} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
container: css`
|
||||
margin-top: ${theme.spacing.xl};
|
||||
padding-top: ${theme.spacing.xl};
|
||||
border-top: 1px solid ${theme.colors.panelBorder};
|
||||
`,
|
||||
infoIcon: css`
|
||||
margin-left: ${theme.spacing.sm};
|
||||
`,
|
||||
defaultColumn: css`
|
||||
width: 1%;
|
||||
`,
|
||||
firstColumn: css`
|
||||
width: 1%;
|
||||
vertical-align: top;
|
||||
color: ${theme.colors.textStrong};
|
||||
`,
|
||||
lastColumn: css`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
`,
|
||||
});
|
||||
|
||||
export const VariablesUnknownTable: FC<Props> = props => (
|
||||
<Provider store={store}>
|
||||
<UnProvidedVariablesUnknownTable {...props} />
|
||||
</Provider>
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { getPropsWithVariable } from './utils';
|
||||
|
||||
describe('getPropsWithVariable', () => {
|
||||
it('when called it should return the correct graph', () => {
|
||||
const result = getPropsWithVariable(
|
||||
'$unknownVariable',
|
||||
{
|
||||
key: 'model',
|
||||
value: {
|
||||
templating: {
|
||||
list: [
|
||||
{
|
||||
current: {
|
||||
selected: false,
|
||||
text: 'No data sources found',
|
||||
value: '',
|
||||
},
|
||||
hide: 0,
|
||||
includeAll: false,
|
||||
label: null,
|
||||
multi: false,
|
||||
name: 'dependsOnUnknown',
|
||||
options: [],
|
||||
query: 'prometheus',
|
||||
refresh: 1,
|
||||
regex: '/.*$unknownVariable.*/',
|
||||
skipUrlSync: false,
|
||||
type: 'datasource',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
templating: {
|
||||
list: {
|
||||
dependsOnUnknown: {
|
||||
regex: '/.*$unknownVariable.*/',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
// @ts-ignore
|
||||
import vis from 'visjs-network';
|
||||
|
||||
import { variableAdapters } from '../adapters';
|
||||
import { DashboardModel } from '../../dashboard/state';
|
||||
import { isAdHoc } from '../guard';
|
||||
import { safeStringifyValue } from '../../../core/utils/explore';
|
||||
import { VariableModel } from '../types';
|
||||
import { containsVariable, variableRegex } from '../utils';
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export const createDependencyNodes = (variables: VariableModel[]): GraphNode[] => {
|
||||
const nodes: GraphNode[] = [];
|
||||
|
||||
for (const variable of variables) {
|
||||
nodes.push({ id: variable.id, label: `${variable.id}` });
|
||||
}
|
||||
|
||||
return nodes;
|
||||
};
|
||||
|
||||
export const filterNodesWithDependencies = (nodes: GraphNode[], edges: GraphEdge[]): GraphNode[] => {
|
||||
return nodes.filter(node => edges.some(edge => edge.from === node.id || edge.to === node.id));
|
||||
};
|
||||
|
||||
export const createDependencyEdges = (variables: VariableModel[]): GraphEdge[] => {
|
||||
const edges: GraphEdge[] = [];
|
||||
|
||||
for (const variable of variables) {
|
||||
for (const other of variables) {
|
||||
if (variable === other) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dependsOn = variableAdapters.get(variable.type).dependsOn(variable, other);
|
||||
|
||||
if (dependsOn) {
|
||||
edges.push({ from: variable.id, to: other.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
};
|
||||
|
||||
export const toVisNetworkNodes = (nodes: GraphNode[]): any[] => {
|
||||
const nodesWithStyle: any[] = nodes.map(node => ({
|
||||
...node,
|
||||
shape: 'box',
|
||||
}));
|
||||
return new vis.DataSet(nodesWithStyle);
|
||||
};
|
||||
|
||||
export const toVisNetworkEdges = (edges: GraphEdge[]): any[] => {
|
||||
const edgesWithStyle: any[] = edges.map(edge => ({ ...edge, arrows: 'to', dashes: true }));
|
||||
return new vis.DataSet(edgesWithStyle);
|
||||
};
|
||||
|
||||
export const getUnknownVariableStrings = (variables: VariableModel[], model: any) => {
|
||||
const unknownVariableNames: string[] = [];
|
||||
const modelAsString = safeStringifyValue(model, 2);
|
||||
const matches = modelAsString.match(variableRegex);
|
||||
|
||||
if (!matches) {
|
||||
return unknownVariableNames;
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match.indexOf('$__') !== -1) {
|
||||
// ignore builtin variables
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match.indexOf('$hashKey') !== -1) {
|
||||
// ignore Angular props
|
||||
continue;
|
||||
}
|
||||
|
||||
const variableName = match.slice(1);
|
||||
|
||||
if (variables.some(variable => variable.id === variableName)) {
|
||||
// ignore defined variables
|
||||
continue;
|
||||
}
|
||||
|
||||
if (unknownVariableNames.find(name => name === variableName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unknownVariableNames.push(variableName);
|
||||
}
|
||||
|
||||
return unknownVariableNames;
|
||||
};
|
||||
|
||||
export const getPropsWithVariable = (variableId: string, parent: { key: string; value: any }, result: any) => {
|
||||
const stringValues = Object.keys(parent.value).reduce((all, key) => {
|
||||
const value = parent.value[key];
|
||||
if (value && typeof value === 'string' && containsVariable(value, variableId)) {
|
||||
all = {
|
||||
...all,
|
||||
[key]: value,
|
||||
};
|
||||
}
|
||||
|
||||
return all;
|
||||
}, {});
|
||||
|
||||
const objectValues = Object.keys(parent.value).reduce((all, key) => {
|
||||
const value = parent.value[key];
|
||||
if (value && typeof value === 'object' && Object.keys(value).length) {
|
||||
const id = value.title || value.name || value.id || key;
|
||||
const newResult = getPropsWithVariable(variableId, { key, value }, {});
|
||||
if (Object.keys(newResult).length) {
|
||||
all = {
|
||||
...all,
|
||||
[id]: newResult,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return all;
|
||||
}, {});
|
||||
|
||||
if (Object.keys(stringValues).length || Object.keys(objectValues).length) {
|
||||
result = {
|
||||
...result,
|
||||
...stringValues,
|
||||
...objectValues,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export interface VariableUsages {
|
||||
unUsed: VariableModel[];
|
||||
unknown: Array<{ variable: VariableModel; tree: any }>;
|
||||
usages: Array<{ variable: VariableModel; tree: any }>;
|
||||
}
|
||||
|
||||
export const createUsagesNetwork = (variables: VariableModel[], dashboard: DashboardModel | null): VariableUsages => {
|
||||
if (!dashboard) {
|
||||
return { unUsed: [], unknown: [], usages: [] };
|
||||
}
|
||||
|
||||
const unUsed: VariableModel[] = [];
|
||||
let usages: Array<{ variable: VariableModel; tree: any }> = [];
|
||||
let unknown: Array<{ variable: VariableModel; tree: any }> = [];
|
||||
const model = dashboard.getSaveModelClone();
|
||||
|
||||
const unknownVariables = getUnknownVariableStrings(variables, model);
|
||||
for (const unknownVariable of unknownVariables) {
|
||||
const props = getPropsWithVariable(unknownVariable, { key: 'model', value: model }, {});
|
||||
if (Object.keys(props).length) {
|
||||
const variable = ({ id: unknownVariable, name: unknownVariable } as unknown) as VariableModel;
|
||||
unknown.push({ variable, tree: props });
|
||||
}
|
||||
}
|
||||
|
||||
for (const variable of variables) {
|
||||
const variableId = variable.id;
|
||||
const props = getPropsWithVariable(variableId, { key: 'model', value: model }, {});
|
||||
if (!Object.keys(props).length && !isAdHoc(variable)) {
|
||||
unUsed.push(variable);
|
||||
}
|
||||
|
||||
if (Object.keys(props).length) {
|
||||
usages.push({ variable, tree: props });
|
||||
}
|
||||
}
|
||||
|
||||
return { unUsed, unknown, usages };
|
||||
};
|
||||
|
||||
export interface UsagesToNetwork {
|
||||
variable: VariableModel;
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
showGraph: boolean;
|
||||
}
|
||||
|
||||
export const traverseTree = (usage: UsagesToNetwork, parent: { id: string; value: any }): UsagesToNetwork => {
|
||||
const { id, value } = parent;
|
||||
const { nodes, edges } = usage;
|
||||
|
||||
if (value && typeof value === 'string') {
|
||||
const leafId = `${parent.id}-${value}`;
|
||||
nodes.push({ id: leafId, label: value });
|
||||
edges.push({ from: leafId, to: id });
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const keys = Object.keys(value);
|
||||
for (const key of keys) {
|
||||
const leafId = `${parent.id}-${key}`;
|
||||
nodes.push({ id: leafId, label: key });
|
||||
edges.push({ from: leafId, to: id });
|
||||
usage = traverseTree(usage, { id: leafId, value: value[key] });
|
||||
}
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
return usage;
|
||||
};
|
||||
|
||||
export const transformUsagesToNetwork = (usages: Array<{ variable: VariableModel; tree: any }>): UsagesToNetwork[] => {
|
||||
const results: UsagesToNetwork[] = [];
|
||||
|
||||
for (const usage of usages) {
|
||||
const { variable, tree } = usage;
|
||||
const result: UsagesToNetwork = {
|
||||
variable,
|
||||
nodes: [{ id: 'dashboard', label: 'dashboard' }],
|
||||
edges: [],
|
||||
showGraph: false,
|
||||
};
|
||||
results.push(traverseTree(result, { id: 'dashboard', value: tree }));
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const countLeaves = (object: any): number => {
|
||||
const total = Object.values(object).reduce((count: number, value: any) => {
|
||||
if (typeof value === 'object') {
|
||||
return count + countLeaves(value);
|
||||
}
|
||||
|
||||
return count + 1;
|
||||
}, 0);
|
||||
|
||||
return (total as unknown) as number;
|
||||
};
|
||||
|
||||
export const getVariableUsages = (
|
||||
variableId: string,
|
||||
variables: VariableModel[],
|
||||
dashboard: DashboardModel | null
|
||||
): number => {
|
||||
const { usages } = createUsagesNetwork(variables, dashboard);
|
||||
const usage = usages.find(usage => usage.variable.id === variableId);
|
||||
if (!usage) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return countLeaves(usage.tree);
|
||||
};
|
||||
@@ -12029,6 +12029,11 @@ emittery@^0.7.1:
|
||||
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.1.tgz#c02375a927a40948c0345cc903072597f5270451"
|
||||
integrity sha512-d34LN4L6h18Bzz9xpoku2nPwKxCPlPMr3EEKTkoEBi+1/+b0lcRkRJ1UVyyZaKNeqGR3swcGl6s390DNO4YVgQ==
|
||||
|
||||
emitter-component@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/emitter-component/-/emitter-component-1.1.1.tgz#065e2dbed6959bf470679edabeaf7981d1003ab6"
|
||||
integrity sha1-Bl4tvtaVm/RwZ57avq95gdEAOrY=
|
||||
|
||||
"emoji-regex@>=6.0.0 <=6.1.1":
|
||||
version "6.1.1"
|
||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.1.1.tgz#c6cd0ec1b0642e2a3c67a1137efc5e796da4f88e"
|
||||
@@ -14537,6 +14542,11 @@ gzip-size@5.1.1, gzip-size@^5.0.0:
|
||||
duplexer "^0.1.1"
|
||||
pify "^4.0.1"
|
||||
|
||||
hammerjs@^2.0.8:
|
||||
version "2.0.8"
|
||||
resolved "https://registry.yarnpkg.com/hammerjs/-/hammerjs-2.0.8.tgz#04ef77862cff2bb79d30f7692095930222bf60f1"
|
||||
integrity sha1-BO93hiz/K7edMPdpIJWTAiK/YPE=
|
||||
|
||||
handle-thing@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.0.tgz#0e039695ff50c93fc288557d696f3c1dc6776754"
|
||||
@@ -17436,6 +17446,11 @@ just-extend@^4.0.2:
|
||||
resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.0.2.tgz#f3f47f7dfca0f989c55410a7ebc8854b07108afc"
|
||||
integrity sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw==
|
||||
|
||||
keycharm@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/keycharm/-/keycharm-0.2.0.tgz#fa6ea2e43b90a68028843d27f2075d35a8c3e6f9"
|
||||
integrity sha1-+m6i5DuQpoAohD0n8gddNajD5vk=
|
||||
|
||||
killable@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892"
|
||||
@@ -21366,6 +21381,13 @@ prop-types@15.7.2, prop-types@15.x, prop-types@^15.0.0, prop-types@^15.5.0, prop
|
||||
object-assign "^4.1.1"
|
||||
react-is "^16.8.1"
|
||||
|
||||
propagating-hammerjs@^1.4.6:
|
||||
version "1.4.7"
|
||||
resolved "https://registry.yarnpkg.com/propagating-hammerjs/-/propagating-hammerjs-1.4.7.tgz#671ab1791a7f88b9dccce8fd8b14a9655950e7d6"
|
||||
integrity sha512-oW9Wd+W2Tp5uOz6Fh4mEU7p+FoyU85smLH/mPga83Loh0pHa6AH4ZHGywvwMk3TWP31l7iUsvJyW265p4Ipwrg==
|
||||
dependencies:
|
||||
hammerjs "^2.0.8"
|
||||
|
||||
property-information@^5.0.0, property-information@^5.0.1, property-information@^5.3.0:
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.3.0.tgz#bc87ac82dc4e72a31bb62040544b1bf9653da039"
|
||||
@@ -26722,6 +26744,18 @@ vfile@^4.0.0:
|
||||
unist-util-stringify-position "^2.0.0"
|
||||
vfile-message "^2.0.0"
|
||||
|
||||
visjs-network@4.25.0:
|
||||
version "4.25.0"
|
||||
resolved "https://registry.yarnpkg.com/visjs-network/-/visjs-network-4.25.0.tgz#a3f0a9ac490b49346a0284986fcd40f8c0622335"
|
||||
integrity sha512-DL6RXd+G6XME2qHMrSh9VYPbkqrY4Hs/liBuNCA1E0wq6bUpQljrM7ePhZMy7zD0yA3wQziOQFB78tvSOevKEQ==
|
||||
dependencies:
|
||||
emitter-component "^1.1.1"
|
||||
hammerjs "^2.0.8"
|
||||
keycharm "^0.2.0"
|
||||
moment "^2.20.1"
|
||||
propagating-hammerjs "^1.4.6"
|
||||
timsort "^0.3.0"
|
||||
|
||||
vm-browserify@^1.0.1:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.0.tgz#bd76d6a23323e2ca8ffa12028dc04559c75f9019"
|
||||
|
||||
Reference in New Issue
Block a user