From 3f04989223e6d99041e5a5f43c3b89b5f2d37831 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Mon, 2 Dec 2024 13:42:57 -0700 Subject: [PATCH] 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 --- .../settings/VariablesEditView.tsx | 28 +++ .../settings/variables/VariableEditorList.tsx | 7 + .../variables/VariableEditorListRow.tsx | 46 +++- .../variables/VariablesUnknownTable.test.tsx | 151 ++++++++++++ .../variables/VariablesUnknownTable.tsx | 154 ++++++++++++ .../variables/VariableUsagesButton.tsx | 47 ++++ .../dashboard-scene/variables/utils.test.ts | 127 +++++++++- .../dashboard-scene/variables/utils.ts | 225 +++++++++++++++++- public/locales/en-US/grafana.json | 6 + public/locales/pseudo-LOCALE/grafana.json | 6 + 10 files changed, 794 insertions(+), 3 deletions(-) create mode 100644 public/app/features/dashboard-scene/settings/variables/VariablesUnknownTable.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/variables/VariablesUnknownTable.tsx create mode 100644 public/app/features/dashboard-scene/variables/VariableUsagesButton.tsx diff --git a/public/app/features/dashboard-scene/settings/VariablesEditView.tsx b/public/app/features/dashboard-scene/settings/VariablesEditView.tsx index ffdfe4e7856..18e4382eca4 100644 --- a/public/app/features/dashboard-scene/settings/VariablesEditView.tsx +++ b/public/app/features/dashboard-scene/settings/VariablesEditView.tsx @@ -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 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) { @@ -206,6 +228,9 @@ function VariableEditorSettingsListView({ model }: SceneComponentProps 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 + ); } diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditorList.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditorList.tsx index 8a579c1d792..d4a902b7acf 100644 --- a/public/app/features/dashboard-scene/settings/variables/VariableEditorList.tsx +++ b/public/app/features/dashboard-scene/settings/variables/VariableEditorList.tsx @@ -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>; + 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} /> ); })} diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditorListRow.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditorListRow.tsx index 9d95b792407..460b82bb351 100644 --- a/public/app/features/dashboard-scene/settings/variables/VariableEditorListRow.tsx +++ b/public/app/features/dashboard-scene/settings/variables/VariableEditorListRow.tsx @@ -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({
+ + { 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 ( + + + + ); + } + + return ( + + + + ); +} + function getStyles(theme: GrafanaTheme2) { return { dragHandle: css({ diff --git a/public/app/features/dashboard-scene/settings/variables/VariablesUnknownTable.test.tsx b/public/app/features/dashboard-scene/settings/variables/VariablesUnknownTable.test.tsx new file mode 100644 index 00000000000..c05b0c83afe --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/VariablesUnknownTable.test.tsx @@ -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 | 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(); + 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; + + 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), + }) + ); + }); + }); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/variables/VariablesUnknownTable.tsx b/public/app/features/dashboard-scene/settings/variables/VariablesUnknownTable.tsx new file mode 100644 index 00000000000..9b96d5bc9ae --- /dev/null +++ b/public/app/features/dashboard-scene/settings/variables/VariablesUnknownTable.tsx @@ -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>; + 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 ( +
+ } isOpen={open} onToggle={onToggle}> + {loading || !usages ? ( + + + + Loading... + + + + + ) : usages.length > 0 ? ( + + ) : ( + + )} + +
+ ); +} + +function CollapseLabel(): ReactElement { + const style = useStyles2(getStyles); + return ( +
+ Renamed or missing variables + + + +
+ ); +} + +function NoUnknowns(): ReactElement { + return ( + + No renamed or missing variables found. + + ); +} + +function UnknownTable({ usages }: { usages: UsagesToNetwork[] }): ReactElement { + const style = useStyles2(getStyles); + return ( + + + + + + + + {usages.map((usage) => { + const name = typeof usage.variable === 'string' ? usage.variable : usage.variable.state.name; + return ( + + + + + ); + })} + +
+ Variable + +
+ {name} + + + + + +
+ ); +} + +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', + }), +}); diff --git a/public/app/features/dashboard-scene/variables/VariableUsagesButton.tsx b/public/app/features/dashboard-scene/variables/VariableUsagesButton.tsx new file mode 100644 index 00000000000..bc83a262bc7 --- /dev/null +++ b/public/app/features/dashboard-scene/variables/VariableUsagesButton.tsx @@ -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 ( + + {({ showModal }) => { + return ( + { + reportInteraction('Show variable usages'); + showModal(); + }} + name="code-branch" + tooltip="Show usages" + /> + ); + }} + + ); +}; diff --git a/public/app/features/dashboard-scene/variables/utils.test.ts b/public/app/features/dashboard-scene/variables/utils.test.ts index 9d5d99e4b88..4ddab168cb2 100644 --- a/public/app/features/dashboard-scene/variables/utils.test.ts +++ b/public/app/features/dashboard-scene/variables/utils.test.ts @@ -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'); + }); +}); diff --git a/public/app/features/dashboard-scene/variables/utils.ts b/public/app/features/dashboard-scene/variables/utils.ts index d5bc99e401c..dd9595ea547 100644 --- a/public/app/features/dashboard-scene/variables/utils.ts +++ b/public/app/features/dashboard-scene/variables/utils.ts @@ -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>): GraphNode[] { return variables.map((variable) => ({ id: variable.state.name, label: `${variable.state.name}` })); @@ -26,3 +30,222 @@ export const createDependencyEdges = (variables: Array; + tree: unknown; +} + +export interface UsagesToNetwork { + /** string when unknown/missing variable otherwise SceneVariable */ + variable: string | SceneVariable; + nodes: GraphNode[]; + edges: GraphEdge[]; + showGraph: boolean; +} + +export function createUsagesNetwork(variables: Array>, 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 +): 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((count, value) => { + if (typeof value === 'object') { + return count + countLeaves(value); + } + + return count + 1; + }, 0); + + return total; +}; + +export async function getUnknownsNetwork( + variables: Array>, + dashboard: Dashboard | null +): Promise { + 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>, + 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>, 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index bf8c92baf02..f430b69d65d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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 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" } } } diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index b6d68de3c92..fe6defcf56c 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -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 ƒőř 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äřįäþľę" } } }