mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Dashboard Scene: Shows usages in variables list (#96000)
* "Show usages" functionality * Don't rely on the inital model but current state; add tests * Fix typo * variable check indicators * Abaility to see renamed or missing variables * clean up missing variables section async logic * remove variable type casting --------- Co-authored-by: Sergej-Vlasov <sergej.s.vlasov@gmail.com>
This commit is contained in:
parent
20a27da636
commit
3f04989223
@ -1,15 +1,20 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data';
|
||||
import { SceneComponentProps, SceneObjectBase, SceneVariable, SceneVariables, sceneGraph } from '@grafana/scenes';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { NavToolbarActions } from '../scene/NavToolbarActions';
|
||||
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
|
||||
import { getDashboardSceneFor } from '../utils/utils';
|
||||
import { createUsagesNetwork, transformUsagesToNetwork } from '../variables/utils';
|
||||
|
||||
import { EditListViewSceneUrlSync } from './EditListViewSceneUrlSync';
|
||||
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
|
||||
import { VariableEditorForm } from './variables/VariableEditorForm';
|
||||
import { VariableEditorList } from './variables/VariableEditorList';
|
||||
import { VariablesUnknownTable } from './variables/VariablesUnknownTable';
|
||||
import {
|
||||
EditableVariableType,
|
||||
RESERVED_GLOBAL_VARIABLE_NAME_REGEX,
|
||||
@ -17,6 +22,7 @@ import {
|
||||
getVariableDefault,
|
||||
getVariableScene,
|
||||
} from './variables/utils';
|
||||
|
||||
export interface VariablesEditViewState extends DashboardEditViewState {
|
||||
editIndex?: number | undefined;
|
||||
}
|
||||
@ -197,6 +203,22 @@ export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> i
|
||||
|
||||
return [false, null];
|
||||
};
|
||||
|
||||
public getSaveModel = () => {
|
||||
return transformSceneToSaveModel(this.getDashboard());
|
||||
};
|
||||
|
||||
public getUsages = () => {
|
||||
const model = this.getSaveModel();
|
||||
const usages = createUsagesNetwork(this.getVariables(), model);
|
||||
return usages;
|
||||
};
|
||||
|
||||
public getUsagesNetwork = () => {
|
||||
const usages = this.getUsages();
|
||||
const usagesNetwork = transformUsagesToNetwork(usages);
|
||||
return usagesNetwork;
|
||||
};
|
||||
}
|
||||
|
||||
function VariableEditorSettingsListView({ model }: SceneComponentProps<VariablesEditView>) {
|
||||
@ -206,6 +228,9 @@ function VariableEditorSettingsListView({ model }: SceneComponentProps<Variables
|
||||
const { onDelete, onDuplicated, onOrderChanged, onEdit, onTypeChange, onGoBack, onAdd } = model;
|
||||
const { variables } = model.getVariableSet().useState();
|
||||
const { editIndex } = model.useState();
|
||||
const usagesNetwork = useMemo(() => model.getUsagesNetwork(), [model]);
|
||||
const usages = useMemo(() => model.getUsages(), [model]);
|
||||
const saveModel = model.getSaveModel();
|
||||
|
||||
if (editIndex !== undefined && variables[editIndex]) {
|
||||
const variable = variables[editIndex];
|
||||
@ -230,12 +255,15 @@ function VariableEditorSettingsListView({ model }: SceneComponentProps<Variables
|
||||
<NavToolbarActions dashboard={dashboard} />
|
||||
<VariableEditorList
|
||||
variables={variables}
|
||||
usages={usages}
|
||||
usagesNetwork={usagesNetwork}
|
||||
onDelete={onDelete}
|
||||
onDuplicate={onDuplicated}
|
||||
onChangeOrder={onOrderChanged}
|
||||
onAdd={onAdd}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
<VariablesUnknownTable variables={variables} dashboard={saveModel} />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
@ -10,11 +10,14 @@ import { useStyles2, Stack, Button, EmptyState, TextLink } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { VariablesDependenciesButton } from '../../variables/VariablesDependenciesButton';
|
||||
import { UsagesToNetwork, VariableUsageTree } from '../../variables/utils';
|
||||
|
||||
import { VariableEditorListRow } from './VariableEditorListRow';
|
||||
|
||||
export interface Props {
|
||||
variables: Array<SceneVariable<SceneVariableState>>;
|
||||
usages: VariableUsageTree[];
|
||||
usagesNetwork: UsagesToNetwork[];
|
||||
onAdd: () => void;
|
||||
onChangeOrder: (fromIndex: number, toIndex: number) => void;
|
||||
onDuplicate: (identifier: string) => void;
|
||||
@ -24,6 +27,8 @@ export interface Props {
|
||||
|
||||
export function VariableEditorList({
|
||||
variables,
|
||||
usages,
|
||||
usagesNetwork,
|
||||
onChangeOrder,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
@ -71,6 +76,8 @@ export function VariableEditorList({
|
||||
onDelete={onDelete}
|
||||
onDuplicate={onDuplicate}
|
||||
onEdit={onEdit}
|
||||
usageTree={usages}
|
||||
usagesNetwork={usagesNetwork}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
@ -6,13 +6,18 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { SceneVariable } from '@grafana/scenes';
|
||||
import { Button, ConfirmModal, Icon, IconButton, useStyles2, useTheme2 } from '@grafana/ui';
|
||||
import { Button, ConfirmModal, Icon, IconButton, Tooltip, useStyles2, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { VariableUsagesButton } from '../../variables/VariableUsagesButton';
|
||||
import { UsagesToNetwork, VariableUsageTree, getVariableUsages } from '../../variables/utils';
|
||||
|
||||
import { getDefinition } from './utils';
|
||||
|
||||
export interface VariableEditorListRowProps {
|
||||
index: number;
|
||||
variable: SceneVariable;
|
||||
usageTree: VariableUsageTree[];
|
||||
usagesNetwork: UsagesToNetwork[];
|
||||
onEdit: (identifier: string) => void;
|
||||
onDuplicate: (identifier: string) => void;
|
||||
onDelete: (identifier: string) => void;
|
||||
@ -21,6 +26,8 @@ export interface VariableEditorListRowProps {
|
||||
export function VariableEditorListRow({
|
||||
index,
|
||||
variable,
|
||||
usageTree,
|
||||
usagesNetwork,
|
||||
onEdit: propsOnEdit,
|
||||
onDuplicate: propsOnDuplicate,
|
||||
onDelete: propsOnDelete,
|
||||
@ -30,6 +37,8 @@ export function VariableEditorListRow({
|
||||
const definition = getDefinition(variable);
|
||||
const variableState = variable.state;
|
||||
const identifier = variableState.name;
|
||||
const usages = getVariableUsages(identifier, usageTree);
|
||||
const passed = usages > 0 || variableState.type === 'adhoc';
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const handleDeleteVariableModal = (show: boolean) => () => {
|
||||
setShowDeleteModal(show);
|
||||
@ -79,6 +88,12 @@ export function VariableEditorListRow({
|
||||
|
||||
<td role="gridcell" className={styles.column}>
|
||||
<div className={styles.icons}>
|
||||
<VariableCheckIndicator passed={passed} />
|
||||
<VariableUsagesButton
|
||||
id={variableState.name}
|
||||
isAdhoc={variableState.type === 'adhoc'}
|
||||
usages={usagesNetwork}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
@ -122,6 +137,35 @@ export function VariableEditorListRow({
|
||||
);
|
||||
}
|
||||
|
||||
interface VariableCheckIndicatorProps {
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
function VariableCheckIndicator({ passed }: VariableCheckIndicatorProps): ReactElement {
|
||||
const styles = useStyles2(getStyles);
|
||||
if (passed) {
|
||||
return (
|
||||
<Tooltip content="This variable is referenced by other variables or dashboard.">
|
||||
<Icon
|
||||
name="check"
|
||||
className={styles.iconPassed}
|
||||
aria-label="This variable is referenced by other variables or dashboard."
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content="This variable is not referenced by other variables or dashboard.">
|
||||
<Icon
|
||||
name="exclamation-triangle"
|
||||
className={styles.iconFailed}
|
||||
aria-label="This variable is not referenced by any variable or dashboard."
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
dragHandle: css({
|
||||
|
@ -0,0 +1,151 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import * as runtime from '@grafana/runtime';
|
||||
import { TestVariable } from '@grafana/scenes';
|
||||
|
||||
import * as utils from '../../variables/utils';
|
||||
import { UsagesToNetwork } from '../../variables/utils';
|
||||
|
||||
import {
|
||||
SLOW_VARIABLES_EXPANSION_THRESHOLD,
|
||||
VariablesUnknownTable,
|
||||
VariablesUnknownTableProps,
|
||||
} from './VariablesUnknownTable';
|
||||
|
||||
async function getTestContext(
|
||||
overrides: Partial<VariablesUnknownTableProps> | undefined = {},
|
||||
usages: UsagesToNetwork[] = []
|
||||
) {
|
||||
jest.clearAllMocks();
|
||||
const reportInteractionSpy = jest.spyOn(runtime, 'reportInteraction').mockImplementation();
|
||||
const getUnknownsNetworkSpy = jest.spyOn(utils, 'getUnknownsNetwork').mockResolvedValue(usages);
|
||||
const defaults: VariablesUnknownTableProps = {
|
||||
variables: [],
|
||||
dashboard: null,
|
||||
};
|
||||
const props = { ...defaults, ...overrides };
|
||||
const { rerender } = render(<VariablesUnknownTable {...props} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('heading', { name: /renamed or missing variables/i })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
return { reportInteractionSpy, getUnknownsNetworkSpy, rerender };
|
||||
}
|
||||
|
||||
describe('VariablesUnknownTable', () => {
|
||||
describe('when rendered', () => {
|
||||
it('then it should render the section header', async () => {
|
||||
await getTestContext();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when expanding the section', () => {
|
||||
it('then it should call getUnknownsNetwork', async () => {
|
||||
const { getUnknownsNetworkSpy } = await getTestContext();
|
||||
|
||||
await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
|
||||
await waitFor(() => expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('then it should report the interaction', async () => {
|
||||
const { reportInteractionSpy } = await getTestContext();
|
||||
|
||||
await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
|
||||
|
||||
expect(reportInteractionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(reportInteractionSpy).toHaveBeenCalledWith('Unknown variables section expanded');
|
||||
});
|
||||
|
||||
describe('but when expanding it again without changes to variables or dashboard', () => {
|
||||
it('then it should not call getUnknownsNetwork', async () => {
|
||||
const { getUnknownsNetworkSpy } = await getTestContext();
|
||||
|
||||
await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
|
||||
await waitFor(() => expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true'));
|
||||
expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
|
||||
await waitFor(() => expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false'));
|
||||
|
||||
await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
|
||||
await waitFor(() => expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true'));
|
||||
|
||||
expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and there are no renamed or missing variables', () => {
|
||||
it('then it should render the correct message', async () => {
|
||||
await getTestContext();
|
||||
|
||||
await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
|
||||
|
||||
expect(screen.getByText('No renamed or missing variables found.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('and there are renamed or missing variables', () => {
|
||||
it('then it should render the table', async () => {
|
||||
const variable = new TestVariable({ name: 'Renamed Variable', query: 'A.*', value: '', text: '', options: [] });
|
||||
const usages = [{ variable, nodes: [], edges: [], showGraph: false }];
|
||||
const { reportInteractionSpy } = await getTestContext({}, usages);
|
||||
|
||||
await userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
|
||||
|
||||
expect(screen.queryByText('No renamed or missing variables found.')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Renamed Variable')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Show usages')).toBeInTheDocument();
|
||||
|
||||
// make sure we don't report the interaction for slow expansion
|
||||
expect(reportInteractionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(reportInteractionSpy).toHaveBeenCalledWith('Unknown variables section expanded');
|
||||
});
|
||||
|
||||
describe('but when the unknown processing takes a while', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
// Need to use delay: null here to work with fakeTimers
|
||||
// see https://github.com/testing-library/user-event/issues/833
|
||||
user = userEvent.setup({ delay: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('then it should report slow expansion', async () => {
|
||||
const variable = new TestVariable({
|
||||
name: 'Renamed Variable',
|
||||
query: 'A.*',
|
||||
value: '',
|
||||
text: '',
|
||||
options: [],
|
||||
});
|
||||
const usages = [{ variable, nodes: [], edges: [], showGraph: false }];
|
||||
const { getUnknownsNetworkSpy, reportInteractionSpy } = await getTestContext({}, usages);
|
||||
getUnknownsNetworkSpy.mockImplementation(() => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(usages);
|
||||
}, SLOW_VARIABLES_EXPANSION_THRESHOLD);
|
||||
});
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
|
||||
|
||||
jest.advanceTimersByTime(SLOW_VARIABLES_EXPANSION_THRESHOLD);
|
||||
|
||||
// make sure we report the interaction for slow expansion
|
||||
await waitFor(() =>
|
||||
expect(reportInteractionSpy).toHaveBeenCalledWith('Slow unknown variables expansion', {
|
||||
elapsed: expect.any(Number),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,154 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { ReactElement, useEffect, useState } from 'react';
|
||||
import { useAsyncFn } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { SceneVariable, SceneVariableState } from '@grafana/scenes';
|
||||
import { Dashboard } from '@grafana/schema/dist/esm/index.gen';
|
||||
import { CollapsableSection, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
|
||||
import { VariableUsagesButton } from '../../variables/VariableUsagesButton';
|
||||
import { getUnknownsNetwork, UsagesToNetwork } from '../../variables/utils';
|
||||
|
||||
export const SLOW_VARIABLES_EXPANSION_THRESHOLD = 1000;
|
||||
|
||||
export interface VariablesUnknownTableProps {
|
||||
variables: Array<SceneVariable<SceneVariableState>>;
|
||||
dashboard: Dashboard | null;
|
||||
}
|
||||
|
||||
export function VariablesUnknownTable({ variables, dashboard }: VariablesUnknownTableProps): ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [changed, setChanged] = useState(0);
|
||||
const style = useStyles2(getStyles);
|
||||
|
||||
useEffect(() => setChanged((prevState) => prevState + 1), [variables, dashboard]);
|
||||
|
||||
const [{ loading, value: usages }, getUnknowns] = useAsyncFn(async () => {
|
||||
const start = Date.now();
|
||||
const unknownsNetwork = await getUnknownsNetwork(variables, dashboard);
|
||||
const stop = Date.now();
|
||||
const elapsed = stop - start;
|
||||
if (elapsed >= SLOW_VARIABLES_EXPANSION_THRESHOLD) {
|
||||
reportInteraction('Slow unknown variables expansion', { elapsed });
|
||||
}
|
||||
setChanged(0);
|
||||
|
||||
return unknownsNetwork;
|
||||
}, [variables, dashboard]);
|
||||
|
||||
const onToggle = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
reportInteraction('Unknown variables section expanded');
|
||||
|
||||
// make sure we only fetch when opened and variables or dashboard have changed
|
||||
if (changed > 0) {
|
||||
getUnknowns();
|
||||
}
|
||||
}
|
||||
|
||||
setOpen(isOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<CollapsableSection label={<CollapseLabel />} isOpen={open} onToggle={onToggle}>
|
||||
{loading || !usages ? (
|
||||
<Stack justifyContent="center" direction="column">
|
||||
<Stack justifyContent="center">
|
||||
<span>
|
||||
<Trans i18nKey="variables.unknown-table.loading">Loading...</Trans>
|
||||
</span>
|
||||
<Spinner />
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : usages.length > 0 ? (
|
||||
<UnknownTable usages={usages} />
|
||||
) : (
|
||||
<NoUnknowns />
|
||||
)}
|
||||
</CollapsableSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapseLabel(): ReactElement {
|
||||
const style = useStyles2(getStyles);
|
||||
return (
|
||||
<h5>
|
||||
<Trans i18nKey="variables.unknown-table.renamed-or-missing-variables">Renamed or missing variables</Trans>
|
||||
<Tooltip content="Click to expand a list with all variable references that have been renamed or are missing from the dashboard.">
|
||||
<Icon name="info-circle" className={style.infoIcon} />
|
||||
</Tooltip>
|
||||
</h5>
|
||||
);
|
||||
}
|
||||
|
||||
function NoUnknowns(): ReactElement {
|
||||
return (
|
||||
<span>
|
||||
<Trans i18nKey="variables.unknown-table.no-unknowns">No renamed or missing variables found.</Trans>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function UnknownTable({ usages }: { usages: UsagesToNetwork[] }): ReactElement {
|
||||
const style = useStyles2(getStyles);
|
||||
return (
|
||||
<table className="filter-table filter-table--hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<Trans i18nKey="variables.unknown-table.variable">Variable</Trans>
|
||||
</th>
|
||||
<th colSpan={5} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usages.map((usage) => {
|
||||
const name = typeof usage.variable === 'string' ? usage.variable : usage.variable.state.name;
|
||||
return (
|
||||
<tr key={name}>
|
||||
<td className={style.firstColumn}>
|
||||
<span>{name}</span>
|
||||
</td>
|
||||
<td className={style.defaultColumn} />
|
||||
<td className={style.defaultColumn} />
|
||||
<td className={style.defaultColumn} />
|
||||
<td className={style.lastColumn}>
|
||||
<VariableUsagesButton id={name} usages={usages} isAdhoc={false} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
marginTop: theme.spacing(4),
|
||||
paddingTop: theme.spacing(4),
|
||||
}),
|
||||
infoIcon: css({
|
||||
marginLeft: theme.spacing(1),
|
||||
}),
|
||||
defaultColumn: css({
|
||||
width: '1%',
|
||||
}),
|
||||
firstColumn: css({
|
||||
width: '1%',
|
||||
verticalAlign: 'top',
|
||||
color: theme.colors.text.maxContrast,
|
||||
}),
|
||||
lastColumn: css({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
width: '100%',
|
||||
textAlign: 'right',
|
||||
}),
|
||||
});
|
@ -0,0 +1,47 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { IconButton } from '@grafana/ui';
|
||||
import { NetworkGraphModal } from 'app/features/variables/inspect/NetworkGraphModal';
|
||||
|
||||
import { UsagesToNetwork } from './utils';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
usages: UsagesToNetwork[];
|
||||
isAdhoc: boolean;
|
||||
}
|
||||
|
||||
export const VariableUsagesButton = ({ id, usages, isAdhoc }: Props) => {
|
||||
const network = useMemo(
|
||||
() => usages.find((n) => (typeof n.variable === 'string' ? n.variable : n.variable.state.name) === id),
|
||||
[usages, id]
|
||||
);
|
||||
if (usages.length === 0 || isAdhoc || !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={() => {
|
||||
reportInteraction('Show variable usages');
|
||||
showModal();
|
||||
}}
|
||||
name="code-branch"
|
||||
tooltip="Show usages"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</NetworkGraphModal>
|
||||
);
|
||||
};
|
@ -1,10 +1,17 @@
|
||||
import { TestVariable } from '@grafana/scenes';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { variableAdapters } from 'app/features/variables/adapters';
|
||||
import { createCustomVariableAdapter } from 'app/features/variables/custom/adapter';
|
||||
import { createDataSourceVariableAdapter } from 'app/features/variables/datasource/adapter';
|
||||
import { createQueryVariableAdapter } from 'app/features/variables/query/adapter';
|
||||
|
||||
import { createDependencyEdges, createDependencyNodes } from './utils';
|
||||
import {
|
||||
createDependencyEdges,
|
||||
getVariableName,
|
||||
createDependencyNodes,
|
||||
createUsagesNetwork,
|
||||
transformUsagesToNetwork,
|
||||
} from './utils';
|
||||
|
||||
variableAdapters.setInit(() => [
|
||||
createDataSourceVariableAdapter(),
|
||||
@ -12,6 +19,54 @@ variableAdapters.setInit(() => [
|
||||
createQueryVariableAdapter(),
|
||||
]);
|
||||
|
||||
const dashboardMock: Dashboard = {
|
||||
panels: [
|
||||
{
|
||||
datasource: {
|
||||
type: 'prometheus',
|
||||
uid: 'gdev-prometheus',
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
datasource: {
|
||||
type: 'prometheus',
|
||||
uid: 'gdev-prometheus',
|
||||
},
|
||||
disableTextWrap: false,
|
||||
editorMode: 'code',
|
||||
expr: 'go_gc_duration_seconds{job=$query0)',
|
||||
fullMetaSearch: false,
|
||||
includeNullMetadata: true,
|
||||
instant: false,
|
||||
legendFormat: '__auto',
|
||||
range: true,
|
||||
refId: 'A',
|
||||
useBackend: false,
|
||||
},
|
||||
{
|
||||
datasource: {
|
||||
type: 'prometheus',
|
||||
uid: 'gdev-prometheus',
|
||||
},
|
||||
disableTextWrap: false,
|
||||
editorMode: 'code',
|
||||
expr: 'go_gc_duration_seconds{job=$query1)',
|
||||
fullMetaSearch: false,
|
||||
includeNullMetadata: true,
|
||||
instant: false,
|
||||
legendFormat: '__auto',
|
||||
range: true,
|
||||
refId: 'A',
|
||||
useBackend: false,
|
||||
},
|
||||
],
|
||||
title: 'Panel Title',
|
||||
type: 'timeseries',
|
||||
},
|
||||
],
|
||||
schemaVersion: 40,
|
||||
};
|
||||
|
||||
describe('createDependencyNodes', () => {
|
||||
it('should create node for each variable', () => {
|
||||
const variables = [
|
||||
@ -38,3 +93,73 @@ describe('createDependencyEdges', () => {
|
||||
expect(graphEdges).toContainEqual({ from: 'C', to: 'B' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUsagesNetwork', () => {
|
||||
it('should create usage network for variables', () => {
|
||||
const variables = [
|
||||
new TestVariable({
|
||||
type: 'query',
|
||||
name: 'query0',
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
new TestVariable({
|
||||
type: 'query',
|
||||
name: 'query1',
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
];
|
||||
|
||||
const usagesNetwork = createUsagesNetwork(variables, dashboardMock);
|
||||
expect(usagesNetwork).toHaveLength(2);
|
||||
expect(usagesNetwork[0].variable.state.name).toBe('query0');
|
||||
expect(usagesNetwork[1].variable.state.name).toBe('query1');
|
||||
});
|
||||
|
||||
it('should not create usage network for variables that are not part of the dashboard', () => {
|
||||
const variables = [
|
||||
new TestVariable({
|
||||
type: 'query',
|
||||
name: 'query3',
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
];
|
||||
|
||||
const usagesNetwork = createUsagesNetwork(variables, dashboardMock);
|
||||
expect(usagesNetwork).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformUsagesToNetwork', () => {
|
||||
it('should transform usages to network', () => {
|
||||
const variables = [
|
||||
new TestVariable({ name: 'A', query: 'A.*', value: '', text: '', options: [] }),
|
||||
new TestVariable({ name: 'B', query: 'B.*', value: '', text: '', options: [] }),
|
||||
];
|
||||
const usages = [
|
||||
{ variable: variables[0], tree: { key: 'value' } },
|
||||
{ variable: variables[1], tree: { key: 'value' } },
|
||||
];
|
||||
|
||||
const network = transformUsagesToNetwork(usages);
|
||||
expect(network).toHaveLength(2);
|
||||
expect(network[0].nodes).toContainEqual({ id: 'dashboard', label: 'dashboard' });
|
||||
expect(network[0].edges).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVariableName', () => {
|
||||
it('should return undefined if no match is found', () => {
|
||||
expect(getVariableName('no variable here')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if variable matches inherited object prop names', () => {
|
||||
expect(getVariableName('${toString}')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the variable name if it exists and does not match inherited object prop names', () => {
|
||||
expect(getVariableName('${myVariable}')).toBe('myVariable');
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,9 @@
|
||||
import { SceneVariable, SceneVariableState } from '@grafana/scenes';
|
||||
import { GraphEdge, GraphNode } from 'app/features/variables/inspect/utils';
|
||||
import { Dashboard } from '@grafana/schema/dist/esm/index.gen';
|
||||
import { safeStringifyValue } from 'app/core/utils/explore';
|
||||
import { GraphEdge, GraphNode, getPropsWithVariable } from 'app/features/variables/inspect/utils';
|
||||
|
||||
export const variableRegex = /\$(\w+)|\[\[(\w+?)(?::(\w+))?\]\]|\${(\w+)(?:\.([^:^\}]+))?(?::([^\}]+))?}/g;
|
||||
|
||||
export function createDependencyNodes(variables: Array<SceneVariable<SceneVariableState>>): GraphNode[] {
|
||||
return variables.map((variable) => ({ id: variable.state.name, label: `${variable.state.name}` }));
|
||||
@ -26,3 +30,222 @@ export const createDependencyEdges = (variables: Array<SceneVariable<SceneVariab
|
||||
|
||||
return edges;
|
||||
};
|
||||
|
||||
export interface VariableUsageTree {
|
||||
variable: SceneVariable<SceneVariableState>;
|
||||
tree: unknown;
|
||||
}
|
||||
|
||||
export interface UsagesToNetwork {
|
||||
/** string when unknown/missing variable otherwise SceneVariable */
|
||||
variable: string | SceneVariable<SceneVariableState>;
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
showGraph: boolean;
|
||||
}
|
||||
|
||||
export function createUsagesNetwork(variables: Array<SceneVariable<SceneVariableState>>, dashboard: Dashboard) {
|
||||
if (!dashboard) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let usages: VariableUsageTree[] = [];
|
||||
|
||||
for (const variable of variables) {
|
||||
const variableId = variable.state.name;
|
||||
const props = getPropsWithVariable(variableId, { key: 'model', value: dashboard }, {});
|
||||
|
||||
if (Object.keys(props).length) {
|
||||
usages.push({ variable, tree: props });
|
||||
}
|
||||
}
|
||||
|
||||
return usages;
|
||||
}
|
||||
|
||||
export function transformUsagesToNetwork(
|
||||
usages: Array<VariableUsageTree | UnknownVariableUsageTree>
|
||||
): 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;
|
||||
}
|
||||
|
||||
export const traverseTree = (usage: UsagesToNetwork, parent: { id: string; value: unknown }): 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 (isRecord(value)) {
|
||||
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 getVariableUsages = (variableId: string, usages: VariableUsageTree[]): number => {
|
||||
const usage = usages.find((usage) => usage.variable.state.name === variableId);
|
||||
if (!usage) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (isRecord(usage.tree)) {
|
||||
return countLeaves(usage.tree);
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const countLeaves = (object: object): number => {
|
||||
const total = Object.values(object).reduce<number>((count, value) => {
|
||||
if (typeof value === 'object') {
|
||||
return count + countLeaves(value);
|
||||
}
|
||||
|
||||
return count + 1;
|
||||
}, 0);
|
||||
|
||||
return total;
|
||||
};
|
||||
|
||||
export async function getUnknownsNetwork(
|
||||
variables: Array<SceneVariable<SceneVariableState>>,
|
||||
dashboard: Dashboard | null
|
||||
): Promise<UsagesToNetwork[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// can be an expensive call so we avoid blocking the main thread
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const unknowns = createUnknownsNetwork(variables, dashboard);
|
||||
resolve(transformUsagesToNetwork(unknowns));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
type UnknownVariableUsageTree = {
|
||||
variable: string;
|
||||
tree: unknown;
|
||||
};
|
||||
|
||||
function createUnknownsNetwork(
|
||||
variables: Array<SceneVariable<SceneVariableState>>,
|
||||
dashboard: Dashboard | null
|
||||
): UnknownVariableUsageTree[] {
|
||||
if (!dashboard) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let unknown: UnknownVariableUsageTree[] = [];
|
||||
const unknownVariables = getUnknownVariableStrings(variables, dashboard);
|
||||
for (const unknownVariable of unknownVariables) {
|
||||
const props = getPropsWithVariable(unknownVariable, { key: 'model', value: dashboard }, {});
|
||||
if (Object.keys(props).length) {
|
||||
unknown.push({ variable: unknownVariable, tree: props });
|
||||
}
|
||||
}
|
||||
|
||||
return unknown;
|
||||
}
|
||||
|
||||
export const getUnknownVariableStrings = (variables: Array<SceneVariable<SceneVariableState>>, model: Dashboard) => {
|
||||
variableRegex.lastIndex = 0;
|
||||
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('${__') !== -1) {
|
||||
// ignore builtin variables
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match.indexOf('$hashKey') !== -1) {
|
||||
// ignore Angular props
|
||||
continue;
|
||||
}
|
||||
|
||||
const variableName = getVariableName(match);
|
||||
|
||||
if (variables.some((variable) => variable.state.name === variableName)) {
|
||||
// ignore defined variables
|
||||
continue;
|
||||
}
|
||||
|
||||
if (unknownVariableNames.find((name) => name === variableName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (variableName) {
|
||||
unknownVariableNames.push(variableName);
|
||||
}
|
||||
}
|
||||
|
||||
return unknownVariableNames;
|
||||
};
|
||||
|
||||
export function getVariableName(expression: string) {
|
||||
const match = variableRegexExec(expression);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const variableName = match.slice(1).find((match) => match !== undefined);
|
||||
|
||||
// ignore variables that match inherited object prop names
|
||||
if (variableName! in {}) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return variableName;
|
||||
}
|
||||
|
||||
// Helper function since lastIndex is not reset
|
||||
export const variableRegexExec = (variableString: string) => {
|
||||
variableRegex.lastIndex = 0;
|
||||
return variableRegex.exec(variableString);
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
@ -3336,6 +3336,12 @@
|
||||
"info-box-content": "Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server or sensor names in your metric queries you can use variables in their place. Variables are shown as list boxes at the top of the dashboard. These drop-down lists make it easy to change the data being displayed in your dashboard.",
|
||||
"info-box-content-2": "Check out the <2>Templates and variables documentation</2> for more information.",
|
||||
"title": "There are no variables added yet"
|
||||
},
|
||||
"unknown-table": {
|
||||
"loading": "Loading...",
|
||||
"no-unknowns": "No renamed or missing variables found.",
|
||||
"renamed-or-missing-variables": "Renamed or missing variables",
|
||||
"variable": "Variable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3336,6 +3336,12 @@
|
||||
"info-box-content": "Väřįäþľęş ęʼnäþľę mőřę įʼnŧęřäčŧįvę äʼnđ đyʼnämįč đäşĥþőäřđş. Ĩʼnşŧęäđ őƒ ĥäřđ-čőđįʼnģ ŧĥįʼnģş ľįĸę şęřvęř őř şęʼnşőř ʼnämęş įʼn yőūř męŧřįč qūęřįęş yőū čäʼn ūşę väřįäþľęş įʼn ŧĥęįř pľäčę. Väřįäþľęş äřę şĥőŵʼn äş ľįşŧ þőχęş äŧ ŧĥę ŧőp őƒ ŧĥę đäşĥþőäřđ. Ŧĥęşę đřőp-đőŵʼn ľįşŧş mäĸę įŧ ęäşy ŧő čĥäʼnģę ŧĥę đäŧä þęįʼnģ đįşpľäyęđ įʼn yőūř đäşĥþőäřđ.",
|
||||
"info-box-content-2": "Cĥęčĸ őūŧ ŧĥę <2>Ŧęmpľäŧęş äʼnđ väřįäþľęş đőčūmęʼnŧäŧįőʼn</2> ƒőř mőřę įʼnƒőřmäŧįőʼn.",
|
||||
"title": "Ŧĥęřę äřę ʼnő väřįäþľęş äđđęđ yęŧ"
|
||||
},
|
||||
"unknown-table": {
|
||||
"loading": "Ŀőäđįʼnģ...",
|
||||
"no-unknowns": "Ńő řęʼnämęđ őř mįşşįʼnģ väřįäþľęş ƒőūʼnđ.",
|
||||
"renamed-or-missing-variables": "Ŗęʼnämęđ őř mįşşįʼnģ väřįäþľęş",
|
||||
"variable": "Väřįäþľę"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user