From d38f0e5d9ed0a6e1f7c7cf900b9544fbc13d642a Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Wed, 16 Feb 2022 09:34:32 +0100 Subject: [PATCH 001/104] Prometheus: Offer hint for metrics with labels (#45396) * Prometheus: Offer hint for counter metric with labels * Update comment * Fix hinting for buckets with labe-values --- .../datasource/prometheus/query_hints.test.ts | 30 +++++++++++++++++++ .../datasource/prometheus/query_hints.ts | 7 +++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/public/app/plugins/datasource/prometheus/query_hints.test.ts b/public/app/plugins/datasource/prometheus/query_hints.test.ts index 381934e694d..66b97fa7d33 100644 --- a/public/app/plugins/datasource/prometheus/query_hints.test.ts +++ b/public/app/plugins/datasource/prometheus/query_hints.test.ts @@ -88,6 +88,21 @@ describe('getQueryHints()', () => { expect(hints).toEqual([]); }); + it('returns a rate hint with action for a counter metric with labels', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('metric_total{job="grafana"}', series); + expect(hints!.length).toBe(1); + expect(hints![0].label).toContain('Selected metric looks like a counter'); + expect(hints![0].fix).toBeDefined(); + }); + it('returns a rate hint w/o action for a complex counter metric', () => { const series = [ { @@ -118,6 +133,21 @@ describe('getQueryHints()', () => { }); }); + it('returns a histogram hint with action for a bucket with labels', () => { + const series = [ + { + datapoints: [ + [23, 1000], + [24, 1001], + ], + }, + ]; + const hints = getQueryHints('metric_bucket{job="grafana"}', series); + expect(hints!.length).toBe(1); + expect(hints![0].label).toContain('Selected metric has buckets.'); + expect(hints![0].fix).toBeDefined(); + }); + it('returns a sum hint when many time series results are returned for a simple metric', () => { const seriesCount = SUM_HINT_THRESHOLD_COUNT; const series = Array.from({ length: seriesCount }, (_) => ({ diff --git a/public/app/plugins/datasource/prometheus/query_hints.ts b/public/app/plugins/datasource/prometheus/query_hints.ts index 5ab6b8c2832..50bb918b958 100644 --- a/public/app/plugins/datasource/prometheus/query_hints.ts +++ b/public/app/plugins/datasource/prometheus/query_hints.ts @@ -11,7 +11,7 @@ export function getQueryHints(query: string, series?: any[], datasource?: Promet const hints = []; // ..._bucket metric needs a histogram_quantile() - const histogramMetric = query.trim().match(/^\w+_bucket$/); + const histogramMetric = query.trim().match(/^\w+_bucket$|^\w+_bucket{.*}$/); if (histogramMetric) { const label = 'Selected metric has buckets.'; hints.push({ @@ -53,12 +53,13 @@ export function getQueryHints(query: string, series?: any[], datasource?: Promet } if (counterNameMetric) { - const simpleMetric = query.trim().match(/^\w+$/); + // FixableQuery consists of metric name and optionally label-value pairs. We are not offering fix for complex queries yet. + const fixableQuery = query.trim().match(/^\w+$|^\w+{.*}$/); const verb = certain ? 'is' : 'looks like'; let label = `Selected metric ${verb} a counter.`; let fix: QueryFix | undefined; - if (simpleMetric) { + if (fixableQuery) { fix = { label: 'Consider calculating rate of counter by adding rate().', action: { From a1a96a732470e2dd1993717830dd51a41f52f5b3 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Wed, 16 Feb 2022 09:40:04 +0100 Subject: [PATCH 002/104] Prometheus: Fix removing of labels when removed outside of component (#45424) * Prometheus: Don't remove lables when switching metrics * Always update labels when labels from prop change * Add test * Update variable name * Revert "Update variable name" This reverts commit ca186f8b0b2e638665a2f5664d03de0ead5ae116. --- .../components/MetricSelect.test.tsx | 6 +++--- .../querybuilder/components/MetricSelect.tsx | 8 ++++---- .../querybuilder/shared/LabelFilters.test.tsx | 16 ++++++++++++++-- .../querybuilder/shared/LabelFilters.tsx | 14 ++++++++++---- 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.test.tsx index afcc3056f17..6dbfb42f8fb 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.test.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.test.tsx @@ -57,7 +57,7 @@ describe('MetricSelect', () => { await waitFor(() => expect(screen.getAllByLabelText('Select option')).toHaveLength(2)); }); - it('highlihts matching string', async () => { + it('highlights matching string', async () => { const { container } = render(); await openMetricSelect(); const input = screen.getByRole('combobox'); @@ -65,7 +65,7 @@ describe('MetricSelect', () => { await waitFor(() => expect(container.querySelectorAll('mark')).toHaveLength(1)); }); - it('highlihts multiple matching strings in 1 input row', async () => { + it('highlights multiple matching strings in 1 input row', async () => { const { container } = render(); await openMetricSelect(); const input = screen.getByRole('combobox'); @@ -73,7 +73,7 @@ describe('MetricSelect', () => { await waitFor(() => expect(container.querySelectorAll('mark')).toHaveLength(2)); }); - it('highlihts multiple matching strings in multiple input rows', async () => { + it('highlights multiple matching strings in multiple input rows', async () => { const { container } = render(); await openMetricSelect(); const input = screen.getByRole('combobox'); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx index eccd302aaa4..86e7b676686 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx @@ -42,11 +42,11 @@ export function MetricSelect({ query, onChange, onGetMetrics }: Props) { ); }, - [styles.hightlight] + [styles.highlight] ); return ( @@ -69,7 +69,7 @@ export function MetricSelect({ query, onChange, onGetMetrics }: Props) { options={state.metrics} onChange={({ value }) => { if (value) { - onChange({ ...query, metric: value, labels: [] }); + onChange({ ...query, metric: value }); } }} /> @@ -82,7 +82,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ select: css` min-width: 125px; `, - hightlight: css` + highlight: css` label: select__match-highlight; background: inherit; padding: inherit; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.test.tsx index b13702c4226..0872aadc47f 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.test.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.test.tsx @@ -47,6 +47,18 @@ describe('LabelFilters', () => { userEvent.click(screen.getByLabelText(/remove/)); expect(onChange).toBeCalledWith([]); }); + + it('renders empty input when labels are deleted from outside ', async () => { + const { rerender } = setup([{ label: 'foo', op: '=', value: 'bar' }]); + expect(screen.getByText(/foo/)).toBeInTheDocument(); + expect(screen.getByText(/bar/)).toBeInTheDocument(); + rerender( + + ); + expect(screen.getAllByText(/Choose/)).toHaveLength(2); + expect(screen.getByText(/=/)).toBeInTheDocument(); + expect(getAddButton()).toBeInTheDocument(); + }); }); function setup(labels: QueryBuilderLabelFilter[] = []) { @@ -64,8 +76,8 @@ function setup(labels: QueryBuilderLabelFilter[] = []) { ], }; - render(); - return props; + const { rerender } = render(); + return { ...props, rerender }; } function getAddButton() { diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.tsx index d3c4daf546b..bc0ca2608a6 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters.tsx @@ -1,7 +1,7 @@ import { SelectableValue } from '@grafana/data'; import { EditorField, EditorFieldGroup, EditorList } from '@grafana/experimental'; import { isEqual } from 'lodash'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { QueryBuilderLabelFilter } from '../shared/types'; import { LabelFilterItem } from './LabelFilterItem'; @@ -14,9 +14,15 @@ export interface Props { export function LabelFilters({ labelsFilters, onChange, onGetLabelNames, onGetLabelValues }: Props) { const defaultOp = '='; - const [items, setItems] = useState>>( - labelsFilters.length === 0 ? [{ op: defaultOp }] : labelsFilters - ); + const [items, setItems] = useState>>([{ op: defaultOp }]); + + useEffect(() => { + if (labelsFilters.length > 0) { + setItems(labelsFilters); + } else { + setItems([{ op: defaultOp }]); + } + }, [labelsFilters]); const onLabelsChange = (newItems: Array>) => { setItems(newItems); From 6584735beffb380560ae2b52f8149dbfb23fcdf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 16 Feb 2022 09:46:46 +0100 Subject: [PATCH 003/104] Chore: Replaces enzyme with RTL in ConfirmButton.test.tsx (#45435) --- .betterer.results | 3 - .../ConfirmButton/ConfirmButton.test.tsx | 84 ++++++++++++++----- 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/.betterer.results b/.betterer.results index f540cfeba20..a740699f920 100644 --- a/.betterer.results +++ b/.betterer.results @@ -17,9 +17,6 @@ exports[`no enzyme tests`] = { "packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx:1355456933": [ [1, 31, 13, "RegExp match", "2409514259"] ], - "packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx:3194817479": [ - [2, 33, 13, "RegExp match", "2409514259"] - ], "packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx:3838344574": [ [1, 17, 13, "RegExp match", "2409514259"] ], diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx index e3117ec9336..c9e4d9cd4f3 100644 --- a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx @@ -1,34 +1,72 @@ import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + import { ConfirmButton } from './ConfirmButton'; -import { mount, ShallowWrapper } from 'enzyme'; -import { Button } from '../Button'; +import { expect } from '../../../../../public/test/lib/common'; describe('ConfirmButton', () => { - let wrapper: any; - let deleted: any; - - beforeAll(() => { - deleted = false; - - function deleteItem() { - deleted = true; - } - - wrapper = mount( - deleteItem()}> + it('should show confirm delete when clicked', () => { + const onConfirm = jest.fn(); + render( + Delete ); + + // Confirm button should not be visible before clicking the Delete button + expect(screen.queryByRole('button', { name: 'Confirm delete' })).not.toBeInTheDocument(); + + userEvent.click(screen.getByRole('button', { name: 'Delete' })); + + // Confirm button should now be visible + expect(screen.getByRole('button', { name: 'Confirm delete' })).toBeInTheDocument(); + userEvent.click(screen.getByRole('button', { name: 'Confirm delete' })); + expect(onConfirm).toHaveBeenCalled(); + + // Confirm button should be visible if closeOnConfirm is false + expect(screen.queryByRole('button', { name: 'Confirm delete' })).toBeInTheDocument(); }); - it('should show confirm delete when clicked', () => { - expect(deleted).toBe(false); - wrapper - .find(Button) - .findWhere((n: ShallowWrapper) => { - return n.text() === 'Confirm delete' && n.type() === Button; - }) - .simulate('click'); - expect(deleted).toBe(true); + it('should hide confirm delete when closeOnConfirm is true', () => { + render( + {}} closeOnConfirm={true}> + Delete + + ); + + // Confirm button should not be visible before clicking the Delete button + expect(screen.queryByRole('button', { name: 'Confirm delete' })).not.toBeInTheDocument(); + + userEvent.click(screen.getByRole('button', { name: 'Delete' })); + + // Confirm button should now be visible + expect(screen.getByRole('button', { name: 'Confirm delete' })).toBeInTheDocument(); + userEvent.click(screen.getByRole('button', { name: 'Confirm delete' })); + + // Confirm button should not be visible if closeOnConfirm is true + expect(screen.queryByRole('button', { name: 'Confirm delete' })).not.toBeInTheDocument(); + }); + + it('should show cancel when clicked', () => { + const onCancel = jest.fn(); + render( + {}}> + Delete + + ); + + // Cancel button should not be visible before clicking the Delete button + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument(); + + userEvent.click(screen.getByRole('button', { name: 'Delete' })); + + // Cancel button should now be visible + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCancel).toHaveBeenCalled(); + + // Cancel button should not be visible after click + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument(); }); }); From 5760a18da556ee127c34605288a90b7d82ec07a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 16 Feb 2022 09:48:13 +0100 Subject: [PATCH 004/104] Chore: Replace enzyme with RTL in OrgSwitcher.test.tsx (#45439) --- .betterer.results | 3 - public/app/core/components/OrgSwitcher.tsx | 132 +++++++++------------ public/app/core/specs/OrgSwitcher.test.tsx | 68 +++++++---- 3 files changed, 101 insertions(+), 102 deletions(-) diff --git a/.betterer.results b/.betterer.results index a740699f920..2245d8d5880 100644 --- a/.betterer.results +++ b/.betterer.results @@ -194,9 +194,6 @@ exports[`no enzyme tests`] = { "public/app/core/components/Select/MetricSelect.test.tsx:3409251428": [ [1, 19, 13, "RegExp match", "2409514259"] ], - "public/app/core/specs/OrgSwitcher.test.tsx:848670248": [ - [2, 19, 13, "RegExp match", "2409514259"] - ], "public/app/features/alerting/AlertRuleList.test.tsx:1800339390": [ [1, 19, 13, "RegExp match", "2409514259"] ], diff --git a/public/app/core/components/OrgSwitcher.tsx b/public/app/core/components/OrgSwitcher.tsx index 861e455218e..79189a12b56 100644 --- a/public/app/core/components/OrgSwitcher.tsx +++ b/public/app/core/components/OrgSwitcher.tsx @@ -1,91 +1,67 @@ -import React from 'react'; - -import { getBackendSrv } from '@grafana/runtime'; +import React, { ReactElement } from 'react'; +import { css } from '@emotion/css'; import { UserOrgDTO } from '@grafana/data'; -import { Modal, Button, CustomScrollbar } from '@grafana/ui'; +import { Button, CustomScrollbar, Modal } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; import config from 'app/core/config'; -import { css } from '@emotion/css'; +import { api } from '../../features/profile/api'; +import { useAsync } from 'react-use'; interface Props { onDismiss: () => void; } -interface State { - orgs: UserOrgDTO[]; -} - -export class OrgSwitcher extends React.PureComponent { - state: State = { - orgs: [], +export function OrgSwitcher({ onDismiss }: Props): ReactElement { + const { value: orgs = [] } = useAsync(() => { + return api.loadOrgs(); + }, []); + const currentOrgId = contextSrv.user.orgId; + const contentClassName = css({ + display: 'flex', + maxHeight: 'calc(85vh - 42px)', + }); + const setCurrentOrg = async (org: UserOrgDTO) => { + await api.setUserOrg(org); + window.location.href = `${config.appSubUrl}${config.appSubUrl.endsWith('/') ? '' : '/'}?orgId=${org.orgId}`; }; - componentDidMount() { - this.getUserOrgs(); - } - - getUserOrgs = async () => { - const orgs: UserOrgDTO[] = await getBackendSrv().get('/api/user/orgs'); - this.setState({ orgs }); - }; - - setCurrentOrg = async (org: UserOrgDTO) => { - await getBackendSrv().post(`/api/user/using/${org.orgId}`); - this.setWindowLocation(`${config.appSubUrl}${config.appSubUrl.endsWith('/') ? '' : '/'}?orgId=${org.orgId}`); - }; - - setWindowLocation(href: string) { - window.location.href = href; - } - - render() { - const { onDismiss } = this.props; - const { orgs } = this.state; - - const currentOrgId = contextSrv.user.orgId; - const contentClassName = css({ - display: 'flex', - maxHeight: 'calc(85vh - 42px)', - }); - - return ( - - - - - - - -
NameRole + return ( + + + + + + + + + + + {orgs.map((org) => ( + + + + - - - {orgs.map((org) => ( - - - - - - ))} - -
NameRole +
{org.name}{org.role} + {org.orgId === currentOrgId ? ( + + ) : ( + + )} +
{org.name}{org.role} - {org.orgId === currentOrgId ? ( - - ) : ( - - )} -
-
-
- ); - } + ))} + +
+
+
+ ); } diff --git a/public/app/core/specs/OrgSwitcher.test.tsx b/public/app/core/specs/OrgSwitcher.test.tsx index 9ab35afa612..829fa3ac9c0 100644 --- a/public/app/core/specs/OrgSwitcher.test.tsx +++ b/public/app/core/specs/OrgSwitcher.test.tsx @@ -1,15 +1,12 @@ import React from 'react'; -import { OrgSwitcher } from '../components/OrgSwitcher'; -import { shallow } from 'enzyme'; -import { OrgRole } from '@grafana/data'; +import { render, screen, waitFor, within } from '@testing-library/react'; -const postMock = jest.fn().mockImplementation(jest.fn()); +import { OrgSwitcher } from '../components/OrgSwitcher'; +import { api } from '../../features/profile/api'; +import userEvent from '@testing-library/user-event'; +import { OrgRole } from '../../types'; jest.mock('@grafana/runtime', () => ({ - getBackendSrv: () => ({ - get: jest.fn().mockResolvedValue([]), - post: postMock, - }), config: { appSubUrl: '/subUrl', }, @@ -21,25 +18,54 @@ jest.mock('app/core/services/context_srv', () => ({ }, })); -let wrapper; -let orgSwitcher: OrgSwitcher; - describe('OrgSwitcher', () => { + const { location } = window; + let setUserOrgSpy: jest.SpyInstance; + + beforeEach(async () => { + jest.clearAllMocks(); + const orgs = [ + { orgId: 1, name: 'Main Org.', role: OrgRole.Admin }, + { orgId: 2, name: 'Org 2', role: OrgRole.Admin }, + ]; + const loadOrgsSpy = jest.spyOn(api, 'loadOrgs').mockResolvedValue(orgs); + setUserOrgSpy = jest.spyOn(api, 'setUserOrg').mockResolvedValue(undefined); + + // @ts-ignore + delete window.location; + window.location = {} as Location; + + render( {}} />); + await waitFor(() => expect(loadOrgsSpy).toHaveBeenCalledTimes(1)); + }); + + afterEach(() => { + window.location = location; + }); + describe('when switching org', () => { - beforeEach(async () => { - wrapper = shallow( {}} />); - orgSwitcher = wrapper.instance() as OrgSwitcher; - orgSwitcher.setWindowLocation = jest.fn(); - wrapper.update(); - await orgSwitcher.setCurrentOrg({ name: 'mock org', orgId: 2, role: OrgRole.Viewer }); + it('should render correct rows', async () => { + expect(screen.getAllByRole('row')).toHaveLength(3); // header + 2 orgs + expect(screen.getByRole('row', { name: /main org. admin current/i })).toBeInTheDocument(); + expect(screen.getByRole('row', { name: /org 2 admin switch to/i })).toBeInTheDocument(); }); - it('should switch orgId in call to backend', () => { - expect(postMock).toBeCalledWith('/api/user/using/2'); + it('should switch orgId in call to backend', async () => { + const row = screen.getByRole('row', { name: /org 2 admin switch to/i }); + const switchToButton = within(row).getByText(/switch to/i); + userEvent.click(switchToButton); + + await waitFor(() => expect(setUserOrgSpy).toBeCalledWith({ orgId: 2, name: 'Org 2', role: 'Admin' })); }); - it('should switch orgId in url and redirect to home page', () => { - expect(orgSwitcher.setWindowLocation).toBeCalledWith('/subUrl/?orgId=2'); + it('should redirect to home page', async () => { + expect(window.location.href).toBeUndefined(); + + const row = screen.getByRole('row', { name: /org 2 admin switch to/i }); + const switchToButton = within(row).getByText(/switch to/i); + userEvent.click(switchToButton); + + await waitFor(() => expect(window.location.href).toEqual('/subUrl/?orgId=2')); }); }); }); From ae8c8a29104232c71e14a4dd82eece4e3357c613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 16 Feb 2022 09:49:06 +0100 Subject: [PATCH 005/104] Chore: Replace enzyme with RTL (#45434) --- .betterer.results | 3 -- .../CallToActionCard.test.tsx | 36 +++++++------ .../CallToActionCard.test.tsx.snap | 52 ------------------- 3 files changed, 21 insertions(+), 70 deletions(-) delete mode 100644 packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap diff --git a/.betterer.results b/.betterer.results index 2245d8d5880..3e95eafec50 100644 --- a/.betterer.results +++ b/.betterer.results @@ -11,9 +11,6 @@ exports[`no enzyme tests`] = { "packages/grafana-ui/src/components/BigValue/BigValue.test.tsx:261212211": [ [1, 19, 13, "RegExp match", "2409514259"] ], - "packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx:2771878691": [ - [1, 18, 13, "RegExp match", "2409514259"] - ], "packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx:1355456933": [ [1, 31, 13, "RegExp match", "2409514259"] ], diff --git a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx index 633902d32df..1e2a72d8f81 100644 --- a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx +++ b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx @@ -1,33 +1,39 @@ import React from 'react'; -import { render } from 'enzyme'; +import { render, screen } from '@testing-library/react'; import { CallToActionCard } from './CallToActionCard'; +import { expect } from '../../../../../public/test/lib/common'; describe('CallToActionCard', () => { describe('rendering', () => { - it('when no message and footer provided', () => { - const tree = render(Click me} />); - expect(tree).toMatchSnapshot(); + it('should render callToActionElement', () => { + render(Click me} />); + expect(screen.getByRole('link', { name: 'Click me' })).toBeInTheDocument(); }); - it('when message and no footer provided', () => { - const tree = render( - Click me} - /> + it('should render message when provided', () => { + render( + Click me} /> ); - expect(tree).toMatchSnapshot(); + expect(screen.getByText('Click button below')).toBeInTheDocument(); }); - it('when message and footer provided', () => { - const tree = render( + it('should render footer when provided', () => { + render( + Click me} /> + ); + expect(screen.getByText('footer content')).toBeInTheDocument(); + }); + + it('should render both message and footer when provided', () => { + render( Click me} /> ); - expect(tree).toMatchSnapshot(); + expect(screen.getByText('Click button below')).toBeInTheDocument(); + expect(screen.getByText('footer content')).toBeInTheDocument(); }); }); }); diff --git a/packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap b/packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap deleted file mode 100644 index 511fd516da3..00000000000 --- a/packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap +++ /dev/null @@ -1,52 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CallToActionCard rendering when message and footer provided 1`] = ` -
-
- Click button bellow -
- - Click me - -
- footer content -
-
-`; - -exports[`CallToActionCard rendering when message and no footer provided 1`] = ` -
-
- Click button bellow -
- - Click me - -
-`; - -exports[`CallToActionCard rendering when no message and footer provided 1`] = ` - -`; From 6a38ce230784556a2e89dc58285e0771a725d26a Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Wed, 16 Feb 2022 10:59:34 +0200 Subject: [PATCH 006/104] Resolve depedency cycle (#45427) --- pkg/cmd/grafana-cli/commands/commands.go | 12 +----------- pkg/services/sqlstore/sqlstore.go | 4 ++-- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/grafana-cli/commands/commands.go b/pkg/cmd/grafana-cli/commands/commands.go index 82b1bff1ef1..809dd35adf3 100644 --- a/pkg/cmd/grafana-cli/commands/commands.go +++ b/pkg/cmd/grafana-cli/commands/commands.go @@ -12,9 +12,6 @@ import ( "github.com/grafana/grafana/pkg/cmd/grafana-cli/services" "github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/hooks" - "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrations" "github.com/grafana/grafana/pkg/setting" @@ -59,14 +56,7 @@ func runDbCommand(command func(commandLine utils.CommandLine, sqlStore *sqlstore return errutil.Wrap("failed to initialize tracer service", err) } - hooksService := hooks.ProvideService() - ossLicensingService := licensing.ProvideService(cfg, hooksService) - featureManager, err := featuremgmt.ProvideManagerService(cfg, ossLicensingService) - if err != nil { - return errutil.Wrap("failed to initialize feature manager service", err) - } - - sqlStore, err := sqlstore.ProvideService(cfg, nil, bus.GetBus(), &migrations.OSSMigrations{}, tracer, featureManager) + sqlStore, err := sqlstore.ProvideService(cfg, nil, bus.GetBus(), &migrations.OSSMigrations{}, tracer) if err != nil { return errutil.Wrap("failed to initialize SQL store", err) } diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index b86c1eae856..b83ee8cbba1 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -56,7 +56,7 @@ type SQLStore struct { tracer tracing.Tracer } -func ProvideService(cfg *setting.Cfg, cacheService *localcache.CacheService, bus bus.Bus, migrations registry.DatabaseMigrator, tracer tracing.Tracer, features *featuremgmt.FeatureManager) (*SQLStore, error) { +func ProvideService(cfg *setting.Cfg, cacheService *localcache.CacheService, bus bus.Bus, migrations registry.DatabaseMigrator, tracer tracing.Tracer) (*SQLStore, error) { // This change will make xorm use an empty default schema for postgres and // by that mimic the functionality of how it was functioning before // xorm's changes above. @@ -66,7 +66,7 @@ func ProvideService(cfg *setting.Cfg, cacheService *localcache.CacheService, bus return nil, err } - if err := s.Migrate(features.IsEnabled(featuremgmt.FlagMigrationLocking)); err != nil { + if err := s.Migrate(cfg.IsFeatureToggleEnabled(featuremgmt.FlagMigrationLocking)); err != nil { return nil, err } From b4dd3138ced9268e4e4fb8d09dc7c80ada2d7ee6 Mon Sep 17 00:00:00 2001 From: Joey Tawadrous <90795735+joey-grafana@users.noreply.github.com> Date: Wed, 16 Feb 2022 09:16:22 +0000 Subject: [PATCH 007/104] InfluxDB: metricFindQuery migration to backend (#44546) * Send metricFindQuery to backend * Added feature toggle * Changed flag name * metricFindQuery backend * Added test for metricFindQuery backend parse * Added test for show tag values * Test for metricFindQueries * Updated tests * Put back comment * Case insensitive in transformRows * Check length ov value pair * Sep tests * Updated valuePair index --- pkg/tsdb/influxdb/response_parser.go | 63 ++++++++++---- pkg/tsdb/influxdb/response_parser_test.go | 86 ++++++++++++++++++- .../plugins/datasource/influxdb/datasource.ts | 3 +- 3 files changed, 132 insertions(+), 20 deletions(-) diff --git a/pkg/tsdb/influxdb/response_parser.go b/pkg/tsdb/influxdb/response_parser.go index f38e69fbe25..52bf876fd38 100644 --- a/pkg/tsdb/influxdb/response_parser.go +++ b/pkg/tsdb/influxdb/response_parser.go @@ -56,32 +56,59 @@ func parseJSON(buf io.ReadCloser) (Response, error) { func transformRows(rows []Row, query Query) data.Frames { frames := data.Frames{} for _, row := range rows { - for columnIndex, column := range row.Columns { - if column == "time" { - continue - } + var hasTimeCol = false - var timeArray []time.Time - var valueArray []*float64 + for _, column := range row.Columns { + if column == "time" { + hasTimeCol = true + } + } + + if !hasTimeCol { + var values []string for _, valuePair := range row.Values { - timestamp, timestampErr := parseTimestamp(valuePair[0]) - // we only add this row if the timestamp is valid - if timestampErr == nil { - value := parseValue(valuePair[columnIndex]) - timeArray = append(timeArray, timestamp) - valueArray = append(valueArray, value) + if strings.Contains(strings.ToLower(query.RawQuery), strings.ToLower("SHOW TAG VALUES")) { + if len(valuePair) >= 2 { + values = append(values, valuePair[1].(string)) + } + } else { + if len(valuePair) >= 1 { + values = append(values, valuePair[0].(string)) + } } } - name := formatFrameName(row, column, query) - timeField := data.NewField("time", nil, timeArray) - valueField := data.NewField("value", row.Tags, valueArray) + field := data.NewField("value", nil, values) + frames = append(frames, data.NewFrame(row.Name, field)) + } else { + for columnIndex, column := range row.Columns { + if column == "time" { + continue + } - // set a nice name on the value-field - valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: name}) + var timeArray []time.Time + var valueArray []*float64 - frames = append(frames, newDataFrame(name, query.RawQuery, timeField, valueField)) + for _, valuePair := range row.Values { + timestamp, timestampErr := parseTimestamp(valuePair[0]) + // we only add this row if the timestamp is valid + if timestampErr == nil { + value := parseValue(valuePair[columnIndex]) + timeArray = append(timeArray, timestamp) + valueArray = append(valueArray, value) + } + } + name := formatFrameName(row, column, query) + + timeField := data.NewField("time", nil, timeArray) + valueField := data.NewField("value", row.Tags, valueArray) + + // set a nice name on the value-field + valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: name}) + + frames = append(frames, newDataFrame(name, query.RawQuery, timeField, valueField)) + } } } diff --git a/pkg/tsdb/influxdb/response_parser_test.go b/pkg/tsdb/influxdb/response_parser_test.go index e47c6b4cae7..56599b2bfd6 100644 --- a/pkg/tsdb/influxdb/response_parser_test.go +++ b/pkg/tsdb/influxdb/response_parser_test.go @@ -91,6 +91,85 @@ func TestInfluxdbResponseParser(t *testing.T) { } }) + t.Run("Influxdb response parser should parse metricFindQueries normally", func(t *testing.T) { + parser := &ResponseParser{} + + response := ` + { + "results": [ + { + "series": [ + { + "refId": "metricFindQuery", + "name": "cpu", + "values": [ + ["cpu"], + ["disk"], + ["logs"] + ] + } + ] + } + ] + } + ` + + var queries []Query + queries = append(queries, Query{RefID: "metricFindQuery"}) + newField := data.NewField("value", nil, []string{ + "cpu", "disk", "logs", + }) + testFrame := data.NewFrame("cpu", + newField, + ) + + result := parser.Parse(prepare(response), queries) + + frame := result.Responses["metricFindQuery"] + if diff := cmp.Diff(testFrame, frame.Frames[0], data.FrameTestCompareOptions()...); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("Influxdb response parser should parse metricFindQueries->SHOW TAG VALUES normally", func(t *testing.T) { + parser := &ResponseParser{} + + response := ` + { + "results": [ + { + "series": [ + { + "name": "cpu", + "values": [ + ["values", "cpu-total"], + ["values", "cpu0"], + ["values", "cpu1"] + ] + } + ] + } + ] + } + ` + + var queries []Query + queries = append(queries, Query{RawQuery: "SHOW TAG VALUES", RefID: "metricFindQuery"}) + newField := data.NewField("value", nil, []string{ + "cpu-total", "cpu0", "cpu1", + }) + testFrame := data.NewFrame("cpu", + newField, + ) + + result := parser.Parse(prepare(response), queries) + + frame := result.Responses["metricFindQuery"] + if diff := cmp.Diff(testFrame, frame.Frames[0], data.FrameTestCompareOptions()...); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + }) + t.Run("Influxdb response parser should parse two responses with different refIDs", func(t *testing.T) { parser := &ResponseParser{} @@ -130,7 +209,12 @@ func TestInfluxdbResponseParser(t *testing.T) { "series": [ { "name": "cpu", - "columns": ["time","mean"] + "columns": ["time","cpu"], + "values": [ + ["values", "cpu-total"], + ["values", "cpu0"], + ["values", "cpu1"] + ] } ] } diff --git a/public/app/plugins/datasource/influxdb/datasource.ts b/public/app/plugins/datasource/influxdb/datasource.ts index dba531a1e88..e9cff1ca03a 100644 --- a/public/app/plugins/datasource/influxdb/datasource.ts +++ b/public/app/plugins/datasource/influxdb/datasource.ts @@ -445,10 +445,11 @@ export default class InfluxDatasource extends DataSourceWithBackend { - if (this.isFlux) { + if (this.isFlux || (config.featureToggles.influxdbBackendMigration && this.access === 'proxy')) { const target: InfluxQuery = { refId: 'metricFindQuery', query, + rawQuery: true, }; return lastValueFrom( super.query({ From befb2afccc627e959cc494e2872f3e2d3629f92e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Bedi?= Date: Wed, 16 Feb 2022 10:34:29 +0100 Subject: [PATCH 008/104] QueryEditor: Fix crash in angular panels when query have error (#45422) --- .../query/components/QueryEditorRow.test.ts | 22 +++++++++++++++---- .../query/components/QueryEditorRow.tsx | 2 ++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/public/app/features/query/components/QueryEditorRow.test.ts b/public/app/features/query/components/QueryEditorRow.test.ts index b754fa5710a..e5bac2a77c3 100644 --- a/public/app/features/query/components/QueryEditorRow.test.ts +++ b/public/app/features/query/components/QueryEditorRow.test.ts @@ -54,10 +54,24 @@ describe('filterPanelDataToQuery', () => { const panelData = filterPanelDataToQuery(withError, 'B'); expect(panelData).toBeDefined(); + expect(panelData?.state).toBe(LoadingState.Error); + expect(panelData?.error).toBe(withError.error); + }); - // @ts-ignore typescript doesn't understand that panelData can't be undefined here - expect(panelData.state).toBe(LoadingState.Error); - // @ts-ignore typescript doesn't understand that panelData can't be undefined here - expect(panelData.error).toBe(withError.error); + it('should set the state to done if the frame has no errors', () => { + const withError = { + ...data, + }; + withError.state = LoadingState.Error; + + const panelDataB = filterPanelDataToQuery(withError, 'B'); + expect(panelDataB?.series.length).toBe(3); + expect(panelDataB?.series[0].refId).toBe('B'); + expect(panelDataB?.state).toBe(LoadingState.Error); + + const panelDataA = filterPanelDataToQuery(withError, 'A'); + expect(panelDataA?.series.length).toBe(1); + expect(panelDataA?.series[0].refId).toBe('A'); + expect(panelDataA?.state).toBe(LoadingState.Done); }); }); diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index 5dab18c9886..7462010ce6d 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -480,6 +480,8 @@ export function filterPanelDataToQuery(data: PanelData, refId: string): PanelDat const error = data.error && data.error.refId === refId ? data.error : undefined; if (error) { state = LoadingState.Error; + } else { + state = LoadingState.Done; } const timeRange = data.timeRange; From d9a5af3d6f1d6fb37866a46d36c1b57aaa981149 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 16 Feb 2022 10:51:40 +0100 Subject: [PATCH 009/104] Bump actions/github-script from 5 to 6 (#45360) Bumps [actions/github-script](https://github.com/actions/github-script) from 5 to 6. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/github-script dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../workflows/detect-breaking-changes-build.yml | 2 +- .../workflows/detect-breaking-changes-report.yml | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/detect-breaking-changes-build.yml b/.github/workflows/detect-breaking-changes-build.yml index 41d3a05aa39..87096eaf159 100644 --- a/.github/workflows/detect-breaking-changes-build.yml +++ b/.github/workflows/detect-breaking-changes-build.yml @@ -112,7 +112,7 @@ jobs: - name: Get link for the Github Action job id: job - uses: actions/github-script@v5 + uses: actions/github-script@v6 with: script: | const script = require('./.github/workflows/scripts/pr-get-job-link.js') diff --git a/.github/workflows/detect-breaking-changes-report.yml b/.github/workflows/detect-breaking-changes-report.yml index f7a096a288e..d7a9f052a48 100644 --- a/.github/workflows/detect-breaking-changes-report.yml +++ b/.github/workflows/detect-breaking-changes-report.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v2 - name: 'Download artifact' - uses: actions/github-script@v5 + uses: actions/github-script@v6 env: RUN_ID: ${{ github.event.workflow_run.id }} with: @@ -53,7 +53,7 @@ jobs: run: unzip "${ARTIFACT_FOLDER}/${ARTIFACT_NAME}.zip" -d "${ARTIFACT_FOLDER}" - name: Parsing levitate result - uses: actions/github-script@v5 + uses: actions/github-script@v6 id: levitate-run with: script: | @@ -63,7 +63,7 @@ jobs: - name: Check if "breaking change" label exists id: does-label-exist - uses: actions/github-script@v5 + uses: actions/github-script@v6 env: PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} with: @@ -119,7 +119,7 @@ jobs: - name: Add "breaking change" label if: ${{ steps.levitate-run.outputs.exit_code == 1 && steps.does-label-exist.outputs.result == 0 }} - uses: actions/github-script@v5 + uses: actions/github-script@v6 env: PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} with: @@ -134,7 +134,7 @@ jobs: - name: Remove "breaking change" label if: ${{ steps.levitate-run.outputs.exit_code == 0 && steps.does-label-exist.outputs.result == 1 }} - uses: actions/github-script@v5 + uses: actions/github-script@v6 env: PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} with: @@ -151,7 +151,7 @@ jobs: # Related issue: https://github.com/renovatebot/renovate/issues/1908 - name: Add "grafana/plugins-platform-frontend" as a reviewer if: ${{ steps.levitate-run.outputs.exit_code == 1 }} - uses: actions/github-script@v5 + uses: actions/github-script@v6 env: PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} with: @@ -167,7 +167,7 @@ jobs: - name: Remove "grafana/plugins-platform-frontend" from the list of reviewers if: ${{ steps.levitate-run.outputs.exit_code == 0 }} - uses: actions/github-script@v5 + uses: actions/github-script@v6 env: PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} with: From 612a51034c7a1b03620dca24d769692a9117a8c8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 16 Feb 2022 10:02:26 +0000 Subject: [PATCH 010/104] Update dependency eslint-plugin-jsdoc to v37.9.1 (#45338) Co-authored-by: Renovate Bot --- package.json | 2 +- yarn.lock | 23 +++++++++++++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 08ac67a5423..3ee1fb7e8b9 100644 --- a/package.json +++ b/package.json @@ -172,7 +172,7 @@ "eslint": "8.8.0", "eslint-config-prettier": "8.3.0", "eslint-plugin-jest": "^26.1.0", - "eslint-plugin-jsdoc": "37.8.0", + "eslint-plugin-jsdoc": "37.9.1", "eslint-plugin-lodash": "^7.2.0", "eslint-plugin-prettier": "4.0.0", "eslint-plugin-react": "7.28.0", diff --git a/yarn.lock b/yarn.lock index 9f64a161e9b..df698acc4f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3827,6 +3827,17 @@ __metadata: languageName: node linkType: hard +"@es-joy/jsdoccomment@npm:~0.19.0": + version: 0.19.0 + resolution: "@es-joy/jsdoccomment@npm:0.19.0" + dependencies: + comment-parser: 1.3.0 + esquery: ^1.4.0 + jsdoc-type-pratt-parser: ~2.2.2 + checksum: 60614ccb08f421ff23a300063989c334d76450c44ed293fdfaaf53b5d4d96a79550873b4e054d75f293433ded5ee201758134f78dd54e9461cf102c08da0727a + languageName: node + linkType: hard + "@eslint/eslintrc@npm:^0.4.2": version: 0.4.3 resolution: "@eslint/eslintrc@npm:0.4.3" @@ -18195,11 +18206,11 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-jsdoc@npm:37.8.0": - version: 37.8.0 - resolution: "eslint-plugin-jsdoc@npm:37.8.0" +"eslint-plugin-jsdoc@npm:37.9.1": + version: 37.9.1 + resolution: "eslint-plugin-jsdoc@npm:37.9.1" dependencies: - "@es-joy/jsdoccomment": ~0.18.0 + "@es-joy/jsdoccomment": ~0.19.0 comment-parser: 1.3.0 debug: ^4.3.3 escape-string-regexp: ^4.0.0 @@ -18209,7 +18220,7 @@ __metadata: spdx-expression-parse: ^3.0.1 peerDependencies: eslint: ^7.0.0 || ^8.0.0 - checksum: db31419d9d59d55b4a3e51917185ea68847ef61bc65eb677ae80ee816bce5131900e93bed8c6af366728bd7cef674278e84803984f3c7a66ee5ed36b91483e65 + checksum: 32186fda33838a86df98c86febadf283b7c85ab3fe54e70d3a209af968cce05bba6abdbedc43888fb9a6f143e0c14e11114a14bc7c9449a23182fdc7fcde81ac languageName: node linkType: hard @@ -20459,7 +20470,7 @@ __metadata: eslint: 8.8.0 eslint-config-prettier: 8.3.0 eslint-plugin-jest: ^26.1.0 - eslint-plugin-jsdoc: 37.8.0 + eslint-plugin-jsdoc: 37.9.1 eslint-plugin-lodash: ^7.2.0 eslint-plugin-prettier: 4.0.0 eslint-plugin-react: 7.28.0 From 1886a59a12a64878d270676cddfa7d1dbf230318 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Wed, 16 Feb 2022 11:03:53 +0100 Subject: [PATCH 011/104] Fix notification routes editing when filters are applied (#45380) * Fix route editing when filters are applied * Fix route delete operation, reset expanded item when filters change * Refactor edit and delete functions, add tests * Fix comment --- .../components/amroutes/AmRoutesTable.test.ts | 78 ++++++++++++++++++- .../components/amroutes/AmRoutesTable.tsx | 45 ++++++----- 2 files changed, 103 insertions(+), 20 deletions(-) diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.test.ts b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.test.ts index af54659548d..a7fcee29adf 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.test.ts +++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.test.ts @@ -1,7 +1,7 @@ import { MatcherOperator } from 'app/plugins/datasource/alertmanager/types'; import { FormAmRoute } from '../../types/amroutes'; import { MatcherFieldValue } from '../../types/silence-form'; -import { getFilteredRoutes } from './AmRoutesTable'; +import { deleteRoute, getFilteredRoutes, updatedRoute } from './AmRoutesTable'; const defaultAmRoute: FormAmRoute = { id: '', @@ -110,3 +110,79 @@ describe('getFilteredRoutes', () => { expect(filteredRoutes).toContain(routes[2]); }); }); + +describe('updatedRoute', () => { + it('Should update an item of the same id', () => { + // Arrange + const routes: FormAmRoute[] = [buildAmRoute({ id: '1' }), buildAmRoute({ id: '2' }), buildAmRoute({ id: '3' })]; + + const routeUpdate: FormAmRoute = { + ...routes[1], + object_matchers: [buildMatcher('severity', 'critical', MatcherOperator.equal)], + }; + + // Act + const updatedRoutes = updatedRoute(routes, routeUpdate); + + // Assert + expect(updatedRoutes).toHaveLength(3); + const changedRoute = updatedRoutes[1]; + + expect(changedRoute.object_matchers).toHaveLength(1); + expect(changedRoute.object_matchers[0].name).toBe('severity'); + expect(changedRoute.object_matchers[0].value).toBe('critical'); + expect(changedRoute.object_matchers[0].operator).toBe(MatcherOperator.equal); + }); + + it('Should not update any element when an element of matching id not found', () => { + // Arrange + const routes: FormAmRoute[] = [buildAmRoute({ id: '1' }), buildAmRoute({ id: '2' }), buildAmRoute({ id: '3' })]; + + const routeUpdate: FormAmRoute = { + ...routes[1], + id: '-1', + object_matchers: [buildMatcher('severity', 'critical', MatcherOperator.equal)], + }; + + // Act + const updatedRoutes = updatedRoute(routes, routeUpdate); + + // Assert + expect(updatedRoutes).toHaveLength(3); + + updatedRoutes.forEach((route) => { + expect(route.object_matchers).toHaveLength(0); + }); + }); +}); + +describe('deleteRoute', () => { + it('Should delete an element of the same id', () => { + // Arrange + const routes: FormAmRoute[] = [buildAmRoute({ id: '1' }), buildAmRoute({ id: '2' }), buildAmRoute({ id: '3' })]; + + const routeToDelete = routes[1]; + + // Act + const updatedRoutes = deleteRoute(routes, routeToDelete); + + // Assert + expect(updatedRoutes).toHaveLength(2); + expect(updatedRoutes[0].id).toBe('1'); + expect(updatedRoutes[1].id).toBe('3'); + }); + + it('Should not delete anything when an element of matching id not found', () => { + // Arrange + const routes: FormAmRoute[] = [buildAmRoute({ id: '1' }), buildAmRoute({ id: '2' }), buildAmRoute({ id: '3' })]; + + // Act + const updatedRoutes = deleteRoute(routes, buildAmRoute({ id: '-1' })); + + // Assert + expect(updatedRoutes).toHaveLength(3); + expect(updatedRoutes[0].id).toBe('1'); + expect(updatedRoutes[1].id).toBe('2'); + expect(updatedRoutes[2].id).toBe('3'); + }); +}); diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx index b76807394e8..e5cb1df5d46 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx +++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx @@ -44,6 +44,23 @@ export const getFilteredRoutes = (routes: FormAmRoute[], labelMatcherQuery?: str return filteredRoutes; }; +export const updatedRoute = (routes: FormAmRoute[], updatedRoute: FormAmRoute): FormAmRoute[] => { + const newRoutes = [...routes]; + const editIndex = newRoutes.findIndex((route) => route.id === updatedRoute.id); + + if (editIndex >= 0) { + newRoutes[editIndex] = { + ...newRoutes[editIndex], + ...updatedRoute, + }; + } + return newRoutes; +}; + +export const deleteRoute = (routes: FormAmRoute[], routeToRemove: FormAmRoute): FormAmRoute[] => { + return routes.filter((route) => route.id !== routeToRemove.id); +}; + export const AmRoutesTable: FC = ({ isAddMode, onCancelAdd, @@ -92,7 +109,7 @@ export const AmRoutesTable: FC = ({ id: 'actions', label: 'Actions', // eslint-disable-next-line react/display-name - renderCell: (item, index) => { + renderCell: (item) => { if (item.renderExpandedContent) { return null; } @@ -118,10 +135,7 @@ export const AmRoutesTable: FC = ({ aria-label="Delete route" name="trash-alt" onClick={() => { - const newRoutes = [...routes]; - - newRoutes.splice(index, 1); - + const newRoutes = deleteRoute(routes, item.data); onChange(newRoutes); }} type="button" @@ -144,11 +158,14 @@ export const AmRoutesTable: FC = ({ [isAddMode, routes, filteredRoutes] ); - // expand the last item when adding + // expand the last item when adding or reset when the length changed useEffect(() => { if (isAddMode && dynamicTableRoutes.length) { setExpandedId(dynamicTableRoutes[dynamicTableRoutes.length - 1].id); } + if (!isAddMode && dynamicTableRoutes.length) { + setExpandedId(undefined); + } }, [isAddMode, dynamicTableRoutes]); if (routes.length > 0 && filteredRoutes.length === 0) { @@ -168,7 +185,7 @@ export const AmRoutesTable: FC = ({ onCollapse={collapseItem} onExpand={expandItem} isExpanded={(item) => expandedId === item.id} - renderExpandedContent={(item: RouteTableItemProps, index) => + renderExpandedContent={(item: RouteTableItemProps) => isAddMode || editMode ? ( { @@ -178,12 +195,8 @@ export const AmRoutesTable: FC = ({ setEditMode(false); }} onSave={(data) => { - const newRoutes = [...routes]; + const newRoutes = updatedRoute(routes, data); - newRoutes[index] = { - ...newRoutes[index], - ...data, - }; setEditMode(false); onChange(newRoutes); }} @@ -193,13 +206,7 @@ export const AmRoutesTable: FC = ({ ) : ( { - const newRoutes = [...routes]; - - newRoutes[index] = { - ...item.data, - ...data, - }; - + const newRoutes = updatedRoute(routes, data); onChange(newRoutes); }} receivers={receivers} From 78deffb2e0bc1b5b3c1ca5fcb6676b5435dbf55b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 16 Feb 2022 10:05:42 +0000 Subject: [PATCH 012/104] Update dependency eslint to v8.9.0 (#45344) Co-authored-by: Renovate Bot --- package.json | 2 +- yarn.lock | 54 ++++++++++++++++++++++++++-------------------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 3ee1fb7e8b9..b81fbf52e43 100644 --- a/package.json +++ b/package.json @@ -169,7 +169,7 @@ "cypress": "9.3.1", "enzyme": "3.11.0", "enzyme-to-json": "3.6.2", - "eslint": "8.8.0", + "eslint": "8.9.0", "eslint-config-prettier": "8.3.0", "eslint-plugin-jest": "^26.1.0", "eslint-plugin-jsdoc": "37.9.1", diff --git a/yarn.lock b/yarn.lock index df698acc4f4..edcee522128 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3855,20 +3855,20 @@ __metadata: languageName: node linkType: hard -"@eslint/eslintrc@npm:^1.0.5": - version: 1.0.5 - resolution: "@eslint/eslintrc@npm:1.0.5" +"@eslint/eslintrc@npm:^1.1.0": + version: 1.1.0 + resolution: "@eslint/eslintrc@npm:1.1.0" dependencies: ajv: ^6.12.4 debug: ^4.3.2 - espree: ^9.2.0 + espree: ^9.3.1 globals: ^13.9.0 ignore: ^4.0.6 import-fresh: ^3.2.1 js-yaml: ^4.1.0 minimatch: ^3.0.4 strip-json-comments: ^3.1.1 - checksum: b35b50d7b65bd8acd92a05b6fb15ac62c0cefa40dfef0324ca5bf8632bf3679bab6e173c53b3ad1e1d837701cecdbd9c144b35f46588cdf4e046a9caa272488d + checksum: 784aa2157e2808b52bbbaf1d1cfca9a6ba0b2faaa3696eb7a1229d4b357400fbd8a6aa09a16e7ae0868ea075d3a8f365cf5928b6d05a1df47f40a1167423a4fa languageName: node linkType: hard @@ -18303,13 +18303,13 @@ __metadata: languageName: node linkType: hard -"eslint-scope@npm:^7.1.0": - version: 7.1.0 - resolution: "eslint-scope@npm:7.1.0" +"eslint-scope@npm:^7.1.1": + version: 7.1.1 + resolution: "eslint-scope@npm:7.1.1" dependencies: esrecurse: ^4.3.0 estraverse: ^5.2.0 - checksum: 2070470a0725438ed47075b2574a4c03cf59aa32648da8cff9e3548c84f6b0079cfdb9ee1dd7ab0bfe97011f64b2af5bfd4b69cf14a1292130dec661eec7914a + checksum: 9f6e974ab2db641ca8ab13508c405b7b859e72afe9f254e8131ff154d2f40c99ad4545ce326fd9fde3212ff29707102562a4834f1c48617b35d98c71a97fbf3e languageName: node linkType: hard @@ -18354,10 +18354,10 @@ __metadata: languageName: node linkType: hard -"eslint-visitor-keys@npm:^3.1.0, eslint-visitor-keys@npm:^3.2.0": - version: 3.2.0 - resolution: "eslint-visitor-keys@npm:3.2.0" - checksum: fdadbb26f9e6417d3db7ad4f00bb0d573b6031c32fa72e8cdae32d038223faaeddff2ee443c90cb489bf774e75bff765c00912b8f9106d65e4f202ccd78c1b18 +"eslint-visitor-keys@npm:^3.3.0": + version: 3.3.0 + resolution: "eslint-visitor-keys@npm:3.3.0" + checksum: d59e68a7c5a6d0146526b0eec16ce87fbf97fe46b8281e0d41384224375c4e52f5ffb9e16d48f4ea50785cde93f766b0c898e31ab89978d88b0e1720fbfb7808 languageName: node linkType: hard @@ -18426,11 +18426,11 @@ __metadata: languageName: node linkType: hard -"eslint@npm:8.8.0": - version: 8.8.0 - resolution: "eslint@npm:8.8.0" +"eslint@npm:8.9.0": + version: 8.9.0 + resolution: "eslint@npm:8.9.0" dependencies: - "@eslint/eslintrc": ^1.0.5 + "@eslint/eslintrc": ^1.1.0 "@humanwhocodes/config-array": ^0.9.2 ajv: ^6.10.0 chalk: ^4.0.0 @@ -18438,10 +18438,10 @@ __metadata: debug: ^4.3.2 doctrine: ^3.0.0 escape-string-regexp: ^4.0.0 - eslint-scope: ^7.1.0 + eslint-scope: ^7.1.1 eslint-utils: ^3.0.0 - eslint-visitor-keys: ^3.2.0 - espree: ^9.3.0 + eslint-visitor-keys: ^3.3.0 + espree: ^9.3.1 esquery: ^1.4.0 esutils: ^2.0.2 fast-deep-equal: ^3.1.3 @@ -18467,7 +18467,7 @@ __metadata: v8-compile-cache: ^2.0.3 bin: eslint: bin/eslint.js - checksum: 41a7e85bf84cf9f2d758ef3e8d08020a39a2836703728b59535684681349bd021c2c6e24174462b844a914870d707d2151e0371198899d957b444de91adaa435 + checksum: 8efecdb9752ee6cb4d2787a14e00cbeab29562ed95dd71c6f3f8ac410426a067d5aa659416d2290e46ca44bc5607e6a6e6c62f814694d8639f80666f522022a7 languageName: node linkType: hard @@ -18489,14 +18489,14 @@ __metadata: languageName: node linkType: hard -"espree@npm:^9.2.0, espree@npm:^9.3.0": - version: 9.3.0 - resolution: "espree@npm:9.3.0" +"espree@npm:^9.3.1": + version: 9.3.1 + resolution: "espree@npm:9.3.1" dependencies: acorn: ^8.7.0 acorn-jsx: ^5.3.1 - eslint-visitor-keys: ^3.1.0 - checksum: c0f1885c4eab652f9be08eb9228cea0df046b559b29d4aed8d6590ea9bd60177d4cb245d204a6f737a79a096861bb4ab8e480aeb8c1dbafef5beec1157353ce4 + eslint-visitor-keys: ^3.3.0 + checksum: d7161db30b65427e0799383699ac4c441533a38faee005153694b68b933ba7a24666680edfc490fa77e3a84a22dbd955768034a6f811af5049774eead83063a5 languageName: node linkType: hard @@ -20467,7 +20467,7 @@ __metadata: emotion: 11.0.0 enzyme: 3.11.0 enzyme-to-json: 3.6.2 - eslint: 8.8.0 + eslint: 8.9.0 eslint-config-prettier: 8.3.0 eslint-plugin-jest: ^26.1.0 eslint-plugin-jsdoc: 37.9.1 From da91c93f4acedfa38305a6bbe85864fe5ae92f22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Bedi?= Date: Wed, 16 Feb 2022 11:48:22 +0100 Subject: [PATCH 013/104] QueryEditor: Fix setting panel state to done if query had no error (#45453) --- .../query/components/QueryEditorRow.test.ts | 17 +++++++++++++++++ .../query/components/QueryEditorRow.tsx | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/public/app/features/query/components/QueryEditorRow.test.ts b/public/app/features/query/components/QueryEditorRow.test.ts index e5bac2a77c3..6117923d66c 100644 --- a/public/app/features/query/components/QueryEditorRow.test.ts +++ b/public/app/features/query/components/QueryEditorRow.test.ts @@ -74,4 +74,21 @@ describe('filterPanelDataToQuery', () => { expect(panelDataA?.series[0].refId).toBe('A'); expect(panelDataA?.state).toBe(LoadingState.Done); }); + + it('should not set the state to done if the frame is loading and has no errors', () => { + const loadingData: PanelData = { + state: LoadingState.Loading, + series: [ + toDataFrame({ refId: 'A', fields: [{ name: 'AAA' }], meta: {} }), + toDataFrame({ refId: 'B', fields: [{ name: 'B111' }], meta: {} }), + ], + timeRange: { from: dateTime(), to: dateTime(), raw: { from: 'now-1d', to: 'now' } }, + }; + + const panelDataB = filterPanelDataToQuery(loadingData, 'B'); + expect(panelDataB?.state).toBe(LoadingState.Loading); + + const panelDataA = filterPanelDataToQuery(loadingData, 'A'); + expect(panelDataA?.state).toBe(LoadingState.Loading); + }); }); diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index 7462010ce6d..fb7a32444c3 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -480,7 +480,7 @@ export function filterPanelDataToQuery(data: PanelData, refId: string): PanelDat const error = data.error && data.error.refId === refId ? data.error : undefined; if (error) { state = LoadingState.Error; - } else { + } else if (!error && data.state === LoadingState.Error) { state = LoadingState.Done; } From cfa24a3cfbcedc311bf9b49383f43135c6694566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 16 Feb 2022 14:06:33 +0100 Subject: [PATCH 014/104] Prometheus: Auto legend handling (#45367) * Legend editor is working * It's working * Progress on auto legend mode * Fixes * added unit tests * Added go tests * Fixing tests * Fix issue with timing and internal state * Update public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> --- .../RadioButtonGroup/RadioButtonGroup.tsx | 9 +- pkg/tsdb/prometheus/time_series_query.go | 19 ++- pkg/tsdb/prometheus/time_series_query_test.go | 24 ++++ .../PromQueryBuilderOptions.test.tsx | 76 ++++++++++++ .../components/PromQueryBuilderOptions.tsx | 40 +++---- .../components/PromQueryCodeEditor.tsx | 39 ++++-- .../PromQueryEditorSelector.test.tsx | 4 - .../components/PromQueryLegendEditor.tsx | 112 ++++++++++++++++++ .../prometheus/querybuilder/types.ts | 12 +- .../plugins/datasource/prometheus/types.ts | 11 ++ 10 files changed, 293 insertions(+), 53 deletions(-) create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.test.tsx create mode 100644 public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryLegendEditor.tsx diff --git a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx index dacd001a8a1..17134a109e7 100644 --- a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx +++ b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx @@ -9,6 +9,7 @@ import { useStyles2 } from '../../../themes'; export interface RadioButtonGroupProps { value?: T; + id?: string; disabled?: boolean; disabledOptions?: T[]; options: Array>; @@ -28,6 +29,7 @@ export function RadioButtonGroup({ disabled, disabledOptions, size = 'md', + id, className, fullWidth = false, autoFocus = false, @@ -52,8 +54,9 @@ export function RadioButtonGroup({ }, [onClick] ); - const id = uniqueId('radiogroup-'); - const groupName = useRef(id); + + const internalId = id ?? uniqueId('radiogroup-'); + const groupName = useRef(internalId); const styles = useStyles2(getStyles); const activeButtonRef = useRef(null); @@ -76,7 +79,7 @@ export function RadioButtonGroup({ aria-label={o.ariaLabel} onChange={handleOnChange(o)} onClick={handleOnClick(o)} - id={`option-${o.value}-${id}`} + id={`option-${o.value}-${internalId}`} name={groupName.current} description={o.description} fullWidth={fullWidth} diff --git a/pkg/tsdb/prometheus/time_series_query.go b/pkg/tsdb/prometheus/time_series_query.go index b38ffd33d8b..a4d1b57e168 100644 --- a/pkg/tsdb/prometheus/time_series_query.go +++ b/pkg/tsdb/prometheus/time_series_query.go @@ -39,6 +39,8 @@ const ( varRateIntervalAlt = "${__rate_interval}" ) +const legendFormatAuto = "__auto" + type TimeSeriesQueryType string const ( @@ -137,11 +139,14 @@ func (s *Service) executeTimeSeriesQuery(ctx context.Context, req *backend.Query } func formatLegend(metric model.Metric, query *PrometheusQuery) string { - var legend string + var legend = metric.String() - if query.LegendFormat == "" { - legend = metric.String() - } else { + if query.LegendFormat == legendFormatAuto { + // If we have labels set legend to empty string to utilize the auto naming system + if len(metric) > 0 { + legend = "" + } + } else if query.LegendFormat != "" { result := legendFormat.ReplaceAllFunc([]byte(query.LegendFormat), func(in []byte) []byte { labelName := strings.Replace(string(in), "{{", "", 1) labelName = strings.Replace(labelName, "}}", "", 1) @@ -335,8 +340,12 @@ func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery, frames data timeField.Name = data.TimeSeriesTimeFieldName timeField.Config = &data.FieldConfig{Interval: float64(query.Step.Milliseconds())} valueField.Name = data.TimeSeriesValueFieldName - valueField.Config = &data.FieldConfig{DisplayNameFromDS: name} valueField.Labels = tags + + if name != "" { + valueField.Config = &data.FieldConfig{DisplayNameFromDS: name} + } + frames = append(frames, newDataFrame(name, "matrix", timeField, valueField)) } diff --git a/pkg/tsdb/prometheus/time_series_query_test.go b/pkg/tsdb/prometheus/time_series_query_test.go index fc50c3fb78f..f98d7484a8f 100644 --- a/pkg/tsdb/prometheus/time_series_query_test.go +++ b/pkg/tsdb/prometheus/time_series_query_test.go @@ -52,6 +52,30 @@ func TestPrometheus_timeSeriesQuery_formatLeged(t *testing.T) { require.Equal(t, `{job="grafana"}`, formatLegend(metric, query)) }) + + t.Run("When legendFormat = __auto and no labels", func(t *testing.T) { + metric := map[p.LabelName]p.LabelValue{} + + query := &PrometheusQuery{ + LegendFormat: legendFormatAuto, + Expr: `{job="grafana"}`, + } + + require.Equal(t, `{job="grafana"}`, formatLegend(metric, query)) + }) + + t.Run("When legendFormat = __auto with labels", func(t *testing.T) { + metric := map[p.LabelName]p.LabelValue{ + p.LabelName("app"): p.LabelValue("backend"), + } + + query := &PrometheusQuery{ + LegendFormat: legendFormatAuto, + Expr: `{job="grafana"}`, + } + + require.Equal(t, "", formatLegend(metric, query)) + }) } func TestPrometheus_timeSeriesQuery_parseTimeSeriesQuery(t *testing.T) { diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.test.tsx new file mode 100644 index 00000000000..ac6b5132951 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { PromQuery } from '../../types'; +import { getQueryWithDefaults } from '../types'; +import { CoreApp } from '@grafana/data'; +import { PromQueryBuilderOptions } from './PromQueryBuilderOptions'; +import { selectOptionInTest } from '@grafana/ui'; + +describe('PromQueryBuilderOptions', () => { + it('Can change query type', async () => { + const { props } = setup(); + + screen.getByTitle('Click to edit options').click(); + expect(screen.getByLabelText('Range')).toBeChecked(); + + screen.getByLabelText('Instant').click(); + + expect(props.onChange).toHaveBeenCalledWith({ + ...props.query, + instant: true, + range: false, + exemplar: false, + }); + }); + + it('Legend format default to Auto', async () => { + setup(); + expect(screen.getByText('Legend: Auto')).toBeInTheDocument(); + }); + + it('Can change legend format to verbose', async () => { + const { props } = setup(); + + screen.getByTitle('Click to edit options').click(); + + let legendModeSelect = screen.getByText('Auto').parentElement!; + legendModeSelect.click(); + + await selectOptionInTest(legendModeSelect as HTMLElement, 'Verbose'); + + expect(props.onChange).toHaveBeenCalledWith({ + ...props.query, + legendFormat: '', + }); + }); + + it('Can change legend format to custom', async () => { + const { props } = setup(); + + screen.getByTitle('Click to edit options').click(); + + let legendModeSelect = screen.getByText('Auto').parentElement!; + legendModeSelect.click(); + + await selectOptionInTest(legendModeSelect as HTMLElement, 'Custom'); + + expect(props.onChange).toHaveBeenCalledWith({ + ...props.query, + legendFormat: '{{label_name}}', + }); + }); +}); + +function setup(queryOverrides: Partial = {}) { + const props = { + query: { + ...getQueryWithDefaults({ refId: 'A' } as PromQuery, CoreApp.PanelEditor), + queryOverrides, + }, + onRunQuery: jest.fn(), + onChange: jest.fn(), + }; + + const { container } = render(); + return { container, props }; +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx index 64f1a5ea653..33bcc9ae8e4 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx @@ -6,6 +6,7 @@ import { QueryOptionGroup } from '../shared/QueryOptionGroup'; import { PromQuery } from '../../types'; import { FORMAT_OPTIONS, INTERVAL_FACTOR_OPTIONS } from '../../components/PromQueryEditor'; import { getQueryTypeChangeHandler, getQueryTypeOptions } from '../../components/PromExploreExtraField'; +import { getLegendModeLabel, PromQueryLegendEditor } from './PromQueryLegendEditor'; export interface Props { query: PromQuery; @@ -15,18 +16,11 @@ export interface Props { } export const PromQueryBuilderOptions = React.memo(({ query, app, onChange, onRunQuery }) => { - const formatOption = FORMAT_OPTIONS.find((option) => option.value === query.format) || FORMAT_OPTIONS[0]; - const onChangeFormat = (value: SelectableValue) => { onChange({ ...query, format: value.value }); onRunQuery(); }; - const onLegendFormatChanged = (evt: React.FocusEvent) => { - onChange({ ...query, legendFormat: evt.currentTarget.value }); - onRunQuery(); - }; - const onChangeStep = (evt: React.FocusEvent) => { onChange({ ...query, interval: evt.currentTarget.value }); onRunQuery(); @@ -46,15 +40,14 @@ export const PromQueryBuilderOptions = React.memo(({ query, app, onChange onRunQuery(); }; + const formatOption = FORMAT_OPTIONS.find((option) => option.value === query.format) || FORMAT_OPTIONS[0]; + const queryTypeValue = getQueryTypeValue(query); + const queryTypeLabel = queryTypeOptions.find((x) => x.value === queryTypeValue)!.label; + return ( - - - - + + (({ query, app, onChange defaultValue={query.interval} /> - + )} + {mode !== LegendFormatMode.Custom && ( + { + onChange({ + ...value, + value: v.currentTarget.value, + }); + }} + /> + + ); +}; diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/editor/helper.ts b/public/app/core/components/TransformersUI/calculateHeatmap/editor/helper.ts new file mode 100644 index 00000000000..5ba5721f43d --- /dev/null +++ b/public/app/core/components/TransformersUI/calculateHeatmap/editor/helper.ts @@ -0,0 +1,33 @@ +import { PanelOptionsEditorBuilder } from '@grafana/data'; + +import { HeatmapCalculationMode, HeatmapCalculationOptions } from '../models.gen'; +import { AxisEditor } from './AxisEditor'; + +export function addHeatmapCalculationOptions( + prefix: string, + builder: PanelOptionsEditorBuilder, + source?: HeatmapCalculationOptions, + category?: string[] +) { + builder.addCustomEditor({ + id: 'xAxis', + path: `${prefix}xAxis`, + name: 'X Buckets', + editor: AxisEditor, + category, + defaultValue: { + mode: HeatmapCalculationMode.Size, + }, + }); + + builder.addCustomEditor({ + id: 'yAxis', + path: `${prefix}yAxis`, + name: 'Y Buckets', + editor: AxisEditor, + category, + defaultValue: { + mode: HeatmapCalculationMode.Size, + }, + }); +} diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.test.ts b/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.test.ts new file mode 100644 index 00000000000..58303f46cf5 --- /dev/null +++ b/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.test.ts @@ -0,0 +1,23 @@ +import { FieldType } from '@grafana/data'; +import { toDataFrame } from '@grafana/data/src/dataframe/processDataFrame'; +import { calculateHeatmapFromData } from './heatmap'; +import { HeatmapCalculationOptions } from './models.gen'; + +describe('Heatmap transformer', () => { + it('calculate heatmap from input data', async () => { + const options: HeatmapCalculationOptions = { + // + }; + + const data = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1, 2, 3, 4] }, + { name: 'temp', type: FieldType.number, values: [1.1, 2.2, 3.3, 4.4] }, + ], + }); + + const heatmap = calculateHeatmapFromData([data], options); + + expect(heatmap).toBeDefined(); + }); +}); diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.ts b/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.ts new file mode 100644 index 00000000000..1ca19f436ec --- /dev/null +++ b/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.ts @@ -0,0 +1,372 @@ +import { + ArrayVector, + DataFrame, + DataTransformerID, + FieldType, + incrRoundUp, + incrRoundDn, + SynchronousDataTransformerInfo, + DataFrameType, + getFieldDisplayName, + Field, +} from '@grafana/data'; +import { map } from 'rxjs'; +import { HeatmapCalculationMode, HeatmapCalculationOptions } from './models.gen'; +import { niceLinearIncrs, niceTimeIncrs } from './utils'; + +export interface HeatmapTransformerOptions extends HeatmapCalculationOptions { + /** the raw values will still exist in results after transformation */ + keepOriginalData?: boolean; +} + +export const heatmapTransformer: SynchronousDataTransformerInfo = { + id: DataTransformerID.heatmap, + name: 'Create heatmap', + description: 'calculate heatmap from source data', + defaultOptions: {}, + + operator: (options) => (source) => source.pipe(map((data) => heatmapTransformer.transformer(options)(data))), + + transformer: (options: HeatmapTransformerOptions) => { + return (data: DataFrame[]) => { + const v = calculateHeatmapFromData(data, options); + if (options.keepOriginalData) { + return [v, ...data]; + } + return [v]; + }; + }, +}; + +export function sortAscStrInf(aName?: string | null, bName?: string | null) { + let aBound = aName === '+Inf' ? Infinity : +(aName ?? 0); + let bBound = bName === '+Inf' ? Infinity : +(bName ?? 0); + + return aBound - bBound; +} + +/** Given existing buckets, create a values style frame */ +export function createHeatmapFromBuckets(frames: DataFrame[]): DataFrame { + frames = frames.slice(); + + // sort ASC by frame.name (Prometheus bucket bound) + // or use frame.fields[1].config.displayNameFromDS ? + frames.sort((a, b) => sortAscStrInf(a.name, b.name)); + + const bucketBounds = frames.map((frame, i) => { + return i; // until we have y ordinal scales working for facets/scatter + + /* + let bound: number; + + if (frame.name === '+Inf') { + // TODO: until we have labeled y, treat +Inf as previous bucket + 10% + bound = +(frames[i - 1].name ?? 0) * 1.1; + } else { + bound = +(frame.name ?? 0); + } + + return bound; + */ + }); + + // assumes all Time fields are identical + // TODO: handle null-filling w/ fields[0].config.interval? + const xField = frames[0].fields[0]; + const xValues = xField.values.toArray(); + const yField = frames[0].fields[1]; + + // similar to initBins() below + const len = xValues.length * bucketBounds.length; + const xs = new Array(len); + const ys = new Array(len); + const counts2 = new Array(len); + + // cumulative counts + const counts = frames.map((frame) => frame.fields[1].values.toArray().slice()); + + // de-accumulate + counts.reverse(); + counts.forEach((bucketCounts, bi) => { + if (bi < counts.length - 1) { + for (let i = 0; i < bucketCounts.length; i++) { + bucketCounts[i] -= counts[bi + 1][i]; + } + } + }); + counts.reverse(); + + // transpose + counts.forEach((bucketCounts, bi) => { + for (let i = 0; i < bucketCounts.length; i++) { + counts2[counts.length * i + bi] = bucketCounts[i]; + } + }); + + // fill flat/repeating array + for (let i = 0, yi = 0, xi = 0; i < len; yi = ++i % bucketBounds.length) { + ys[i] = bucketBounds[yi]; + + if (yi === 0 && i >= bucketBounds.length) { + xi++; + } + + xs[i] = xValues[xi]; + } + + return { + length: xs.length, + meta: { + type: DataFrameType.HeatmapScanlines, + }, + fields: [ + { + name: 'xMax', + type: xField.type, + values: new ArrayVector(xs), + config: xField.config, + }, + { + name: 'yMax', + type: FieldType.number, + values: new ArrayVector(ys), + config: yField.config, + }, + { + name: 'count', + type: FieldType.number, + values: new ArrayVector(counts2), + config: { + unit: 'short', + }, + }, + ], + }; +} + +export function calculateHeatmapFromData(frames: DataFrame[], options: HeatmapCalculationOptions): DataFrame { + //console.time('calculateHeatmapFromData'); + + let xs: number[] = []; + let ys: number[] = []; + + // optimization + //let xMin = Infinity; + //let xMax = -Infinity; + + let xField: Field | undefined = undefined; + let yField: Field | undefined = undefined; + + for (let frame of frames) { + // TODO: assumes numeric timestamps, ordered asc, without nulls + const x = frame.fields.find((f) => f.type === FieldType.time); + if (!x) { + continue; + } + + if (!xField) { + xField = x; // the first X + } + + const xValues = x.values.toArray(); + for (let field of frame.fields) { + if (field !== x && field.type === FieldType.number) { + xs = xs.concat(xValues); + ys = ys.concat(field.values.toArray()); + + if (!yField) { + yField = field; + } + } + } + } + + if (!xField || !yField) { + throw 'no heatmap fields found'; + } + + const heat2d = heatmap(xs, ys, { + xSorted: true, + xTime: xField.type === FieldType.time, + xMode: options.xAxis?.mode, + xSize: +(options.xAxis?.value ?? 0), + yMode: options.yAxis?.mode, + ySize: +(options.yAxis?.value ?? 0), + }); + + const frame = { + length: heat2d.x.length, + name: getFieldDisplayName(yField), + meta: { + type: DataFrameType.HeatmapScanlines, + }, + fields: [ + { + name: 'xMin', + type: xField.type, + values: new ArrayVector(heat2d.x), + config: xField.config, + }, + { + name: 'yMin', + type: FieldType.number, + values: new ArrayVector(heat2d.y), + config: yField.config, // keep units from the original source + }, + { + name: 'count', + type: FieldType.number, + values: new ArrayVector(heat2d.count), + config: {}, + }, + ], + }; + + //console.timeEnd('calculateHeatmapFromData'); + + //console.log({ tiles: frame.length }); + + return frame; +} + +interface HeatmapOpts { + // default is 10% of data range, snapped to a "nice" increment + xMode?: HeatmapCalculationMode; + yMode?: HeatmapCalculationMode; + xSize?: number; + ySize?: number; + + // use Math.ceil instead of Math.floor for bucketing + xCeil?: boolean; + yCeil?: boolean; + + // log2 or log10 buckets + xLog?: 2 | 10; + yLog?: 2 | 10; + + xTime?: boolean; + yTime?: boolean; + + // optimization hints for known data ranges (sorted, pre-scanned, etc) + xMin?: number; + xMax?: number; + yMin?: number; + yMax?: number; + + xSorted?: boolean; + ySorted?: boolean; +} + +// TODO: handle NaN, Inf, -Inf, null, undefined values in xs & ys +function heatmap(xs: number[], ys: number[], opts?: HeatmapOpts) { + let len = xs.length; + + let xSorted = opts?.xSorted ?? false; + let ySorted = opts?.ySorted ?? false; + + // find x and y limits to pre-compute buckets struct + let minX = xSorted ? xs[0] : Infinity; + let minY = ySorted ? ys[0] : Infinity; + let maxX = xSorted ? xs[len - 1] : -Infinity; + let maxY = ySorted ? ys[len - 1] : -Infinity; + + for (let i = 0; i < len; i++) { + if (!xSorted) { + minX = Math.min(minX, xs[i]); + maxX = Math.max(maxX, xs[i]); + } + + if (!ySorted) { + minY = Math.min(minY, ys[i]); + maxY = Math.max(maxY, ys[i]); + } + } + + //let scaleX = opts?.xLog === 10 ? Math.log10 : opts?.xLog === 2 ? Math.log2 : (v: number) => v; + //let scaleY = opts?.yLog === 10 ? Math.log10 : opts?.yLog === 2 ? Math.log2 : (v: number) => v; + + let xBinIncr = opts?.xSize ?? 0; + let yBinIncr = opts?.ySize ?? 0; + let xMode = opts?.xMode; + let yMode = opts?.yMode; + + // fall back to 10 buckets if invalid settings + if (!Number.isFinite(xBinIncr) || xBinIncr <= 0) { + xMode = HeatmapCalculationMode.Count; + xBinIncr = 20; + } + if (!Number.isFinite(yBinIncr) || yBinIncr <= 0) { + yMode = HeatmapCalculationMode.Count; + yBinIncr = 10; + } + + if (xMode === HeatmapCalculationMode.Count) { + // TODO: optionally use view range min/max instead of data range for bucket sizing + let approx = (maxX - minX) / Math.max(xBinIncr - 1, 1); + // nice-ify + let xIncrs = opts?.xTime ? niceTimeIncrs : niceLinearIncrs; + let xIncrIdx = xIncrs.findIndex((bucketSize) => bucketSize > approx) - 1; + xBinIncr = xIncrs[Math.max(xIncrIdx, 0)]; + } + + if (yMode === HeatmapCalculationMode.Count) { + // TODO: optionally use view range min/max instead of data range for bucket sizing + let approx = (maxY - minY) / Math.max(yBinIncr - 1, 1); + // nice-ify + let yIncrs = opts?.yTime ? niceTimeIncrs : niceLinearIncrs; + let yIncrIdx = yIncrs.findIndex((bucketSize) => bucketSize > approx) - 1; + yBinIncr = yIncrs[Math.max(yIncrIdx, 0)]; + } + + // console.log({ + // yBinIncr, + // xBinIncr, + // }); + + let binX = opts?.xCeil ? (v: number) => incrRoundUp(v, xBinIncr) : (v: number) => incrRoundDn(v, xBinIncr); + let binY = opts?.yCeil ? (v: number) => incrRoundUp(v, yBinIncr) : (v: number) => incrRoundDn(v, yBinIncr); + + let minXBin = binX(minX); + let maxXBin = binX(maxX); + let minYBin = binY(minY); + let maxYBin = binY(maxY); + + let xBinQty = Math.round((maxXBin - minXBin) / xBinIncr) + 1; + let yBinQty = Math.round((maxYBin - minYBin) / yBinIncr) + 1; + + let [xs2, ys2, counts] = initBins(xBinQty, yBinQty, minXBin, xBinIncr, minYBin, yBinIncr); + + for (let i = 0; i < len; i++) { + const xi = (binX(xs[i]) - minXBin) / xBinIncr; + const yi = (binY(ys[i]) - minYBin) / yBinIncr; + const ci = xi * yBinQty + yi; + + counts[ci]++; + } + + return { + x: xs2, + y: ys2, + count: counts, + }; +} + +function initBins(xQty: number, yQty: number, xMin: number, xIncr: number, yMin: number, yIncr: number) { + const len = xQty * yQty; + const xs = new Array(len); + const ys = new Array(len); + const counts = new Array(len); + + for (let i = 0, yi = 0, x = xMin; i < len; yi = ++i % yQty) { + counts[i] = 0; + ys[i] = yMin + yi * yIncr; + + if (yi === 0 && i >= yQty) { + x += xIncr; + } + + xs[i] = x; + } + + return [xs, ys, counts]; +} diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/models.gen.ts b/public/app/core/components/TransformersUI/calculateHeatmap/models.gen.ts new file mode 100644 index 00000000000..eda086f883b --- /dev/null +++ b/public/app/core/components/TransformersUI/calculateHeatmap/models.gen.ts @@ -0,0 +1,18 @@ +import { DataFrameType } from '@grafana/data'; + +export enum HeatmapCalculationMode { + Size = 'size', + Count = 'count', +} + +export interface HeatmapCalculationAxisConfig { + mode?: HeatmapCalculationMode; + value?: string; // number or interval string ie 10s +} + +export interface HeatmapCalculationOptions { + xAxis?: HeatmapCalculationAxisConfig; + yAxis?: HeatmapCalculationAxisConfig; + xAxisField?: string; // name of the x field + encoding?: DataFrameType.HeatmapBuckets | DataFrameType.HeatmapScanlines; +} diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/utils.ts b/public/app/core/components/TransformersUI/calculateHeatmap/utils.ts new file mode 100644 index 00000000000..3851395f197 --- /dev/null +++ b/public/app/core/components/TransformersUI/calculateHeatmap/utils.ts @@ -0,0 +1,125 @@ +const { abs, round, pow } = Math; + +export function roundDec(val: number, dec: number) { + return round(val * (dec = 10 ** dec)) / dec; +} + +export const fixedDec = new Map(); + +export function guessDec(num: number) { + return (('' + num).split('.')[1] || '').length; +} + +export function genIncrs(base: number, minExp: number, maxExp: number, mults: number[]) { + let incrs = []; + + let multDec = mults.map(guessDec); + + for (let exp = minExp; exp < maxExp; exp++) { + let expa = abs(exp); + let mag = roundDec(pow(base, exp), expa); + + for (let i = 0; i < mults.length; i++) { + let _incr = mults[i] * mag; + let dec = (_incr >= 0 && exp >= 0 ? 0 : expa) + (exp >= multDec[i] ? 0 : multDec[i]); + let incr = roundDec(_incr, dec); + incrs.push(incr); + fixedDec.set(incr, dec); + } + } + + return incrs; +} + +const onlyWhole = (v: number) => v % 1 === 0; + +const allMults = [1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5]; + +// ...0.01, 0.02, 0.025, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1, 0.2, 0.25, 0.3, 0.4, 0.5... +export const decIncrs = genIncrs(10, -16, 0, allMults); + +// 1, 2, 2.5, 3, 4, 5, 6, 7, 8, 9, 10, 20, 25, 30, 40, 50... +export const oneIncrs = genIncrs(10, 0, 16, allMults); + +// 1, 2, 3, 4, 5, 10, 20, 25, 50... +export const wholeIncrs = oneIncrs.filter(onlyWhole); + +export const numIncrs = decIncrs.concat(oneIncrs); + +export const niceLinearIncrs = decIncrs.concat(wholeIncrs); + +const sec = 1 * 1e3; +const min = 60 * sec; +const hour = 60 * min; +const day = 24 * hour; +const year = 365 * day; + +// in milliseconds +export const niceTimeIncrs = [ + 1, + 2, + 4, + 5, + 10, + 20, + 25, + 40, + 50, + 100, + 200, + 250, + 400, + 500, + + sec, + 2 * sec, + 4 * sec, + 5 * sec, + 10 * sec, + 15 * sec, + 20 * sec, + 30 * sec, + + min, + 2 * min, + 4 * min, + 5 * min, + 10 * min, + 15 * min, + 20 * min, + 30 * min, + + hour, + 2 * hour, + 4 * hour, + 6 * hour, + 8 * hour, + 12 * hour, + 18 * hour, + + day, + 2 * day, + 3 * day, + 4 * day, + 5 * day, + 6 * day, + 7 * day, + 10 * day, + 15 * day, + 30 * day, + 45 * day, + 60 * day, + 90 * day, + 180 * day, + + year, + 2 * year, + 3 * year, + 4 * year, + 5 * year, + 6 * year, + 7 * year, + 8 * year, + 9 * year, + 10 * year, +]; diff --git a/public/app/core/components/TransformersUI/spatial/optionsHelper.tsx b/public/app/core/components/TransformersUI/spatial/optionsHelper.tsx index 1d2ee131700..07ade5b3824 100644 --- a/public/app/core/components/TransformersUI/spatial/optionsHelper.tsx +++ b/public/app/core/components/TransformersUI/spatial/optionsHelper.tsx @@ -5,10 +5,9 @@ import { NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders'; import { set, get as lodashGet } from 'lodash'; import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils'; import { fillOptionsPaneItems } from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions'; -import { SpatialTransformOptions } from './models.gen'; export function getTransformerOptionPane( - props: TransformerUIProps, + props: TransformerUIProps, supplier: PanelOptionsSupplier ): OptionsPaneCategoryDescriptor { const context: StandardEditorContext = { diff --git a/public/app/core/utils/standardTransformers.ts b/public/app/core/utils/standardTransformers.ts index a8815ae8adf..41f29b975d0 100644 --- a/public/app/core/utils/standardTransformers.ts +++ b/public/app/core/utils/standardTransformers.ts @@ -20,6 +20,7 @@ import { prepareTimeseriesTransformerRegistryItem } from '../components/Transfor import { convertFieldTypeTransformRegistryItem } from '../components/TransformersUI/ConvertFieldTypeTransformerEditor'; import { fieldLookupTransformRegistryItem } from '../components/TransformersUI/lookupGazetteer/FieldLookupTransformerEditor'; import { extractFieldsTransformRegistryItem } from '../components/TransformersUI/extractFields/ExtractFieldsTransformerEditor'; +import { heatmapTransformRegistryItem } from '../components/TransformersUI/calculateHeatmap/HeatmapTransformerEditor'; import { spatialTransformRegistryItem } from '../components/TransformersUI/spatial/SpatialTransformerEditor'; export const getStandardTransformers = (): Array> => { @@ -46,5 +47,6 @@ export const getStandardTransformers = (): Array> = spatialTransformRegistryItem, fieldLookupTransformRegistryItem, extractFieldsTransformRegistryItem, + heatmapTransformRegistryItem, ]; }; diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index c542aa7dbb4..ebe372a6fff 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -50,6 +50,7 @@ import * as dashListPanel from 'app/plugins/panel/dashlist/module'; import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module'; import * as alertListPanel from 'app/plugins/panel/alertlist/module'; import * as annoListPanel from 'app/plugins/panel/annolist/module'; +import * as heatmapPanelNG from 'app/plugins/panel/heatmap-new/module'; import * as tablePanel from 'app/plugins/panel/table/module'; import * as statPanel from 'app/plugins/panel/stat/module'; import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module'; @@ -113,6 +114,7 @@ const builtInPlugins: any = { 'app/plugins/panel/alertlist/module': alertListPanel, 'app/plugins/panel/annolist/module': annoListPanel, 'app/plugins/panel/heatmap/module': heatmapPanel, + 'app/plugins/panel/heatmap-new/module': heatmapPanelNG, 'app/plugins/panel/table/module': tablePanel, 'app/plugins/panel/table-old/module': tableOldPanel, 'app/plugins/panel/news/module': newsPanel, diff --git a/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx b/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx new file mode 100644 index 00000000000..de0f990dd25 --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/HeatmapHoverView.tsx @@ -0,0 +1,194 @@ +import React, { useEffect, useRef } from 'react'; +import { Field, FieldType, formattedValueToString, LinkModel } from '@grafana/data'; + +import { HeatmapHoverEvent } from './utils'; +import { BucketLayout, HeatmapData } from './fields'; +import { LinkButton, VerticalGroup } from '@grafana/ui'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; + +type Props = { + data: HeatmapData; + hover: HeatmapHoverEvent; + showHistogram?: boolean; +}; + +export const HeatmapHoverView = ({ data, hover, showHistogram }: Props) => { + const xField = data.heatmap?.fields[0]; + const yField = data.heatmap?.fields[1]; + const countField = data.heatmap?.fields[2]; + + const xDisp = (v: any) => { + if (xField?.display) { + return formattedValueToString(xField.display(v)); + } + if (xField?.type === FieldType.time) { + const tooltipTimeFormat = 'YYYY-MM-DD HH:mm:ss'; + const dashboard = getDashboardSrv().getCurrent(); + return dashboard?.formatDate(v, tooltipTimeFormat); + } + return `${v}XX`; + }; + + const xVals = xField?.values.toArray(); + const yVals = yField?.values.toArray(); + const countVals = countField?.values.toArray(); + + let yDispSrc, yDisp; + + // labeled buckets + if (data.yAxisValues) { + yDispSrc = data.yAxisValues; + yDisp = (v: any) => v; + } else { + yDispSrc = yVals; + yDisp = (v: any) => { + if (yField?.display) { + return formattedValueToString(yField.display(v)); + } + return `${v}YYY`; + }; + } + + const yValueIdx = hover.index % data.yBucketCount! ?? 0; + + const yMinIdx = data.yLayout === BucketLayout.le ? yValueIdx - 1 : yValueIdx; + const yMaxIdx = data.yLayout === BucketLayout.le ? yValueIdx : yValueIdx + 1; + + const yBucketMin = yDispSrc?.[yMinIdx]; + const yBucketMax = yDispSrc?.[yMaxIdx]; + + const xBucketMin = xVals?.[hover.index]; + const xBucketMax = xBucketMin + data.xBucketSize; + + const count = countVals?.[hover.index]; + + const visibleFields = data.heatmap?.fields.filter((f) => !Boolean(f.config.custom?.hideFrom?.tooltip)); + const links: Array> = []; + const linkLookup = new Set(); + + for (const field of visibleFields ?? []) { + // TODO: Currently always undefined? (getLinks) + if (field.getLinks) { + const v = field.values.get(hover.index); + const disp = field.display ? field.display(v) : { text: `${v}`, numeric: +v }; + + field.getLinks({ calculatedValue: disp, valueRowIndex: hover.index }).forEach((link) => { + const key = `${link.title}/${link.href}`; + if (!linkLookup.has(key)) { + links.push(link); + linkLookup.add(key); + } + }); + } + } + + let can = useRef(null); + + let histCssWidth = 150; + let histCssHeight = 50; + let histCanWidth = Math.round(histCssWidth * devicePixelRatio); + let histCanHeight = Math.round(histCssHeight * devicePixelRatio); + + useEffect( + () => { + if (showHistogram) { + let histCtx = can.current?.getContext('2d'); + + if (histCtx && xVals && yVals && countVals) { + let fromIdx = hover.index; + + while (xVals[fromIdx--] === xVals[hover.index]) {} + + fromIdx++; + + let toIdx = fromIdx + data.yBucketCount!; + + let maxCount = 0; + + let i = fromIdx; + while (i < toIdx) { + let c = countVals[i]; + maxCount = Math.max(maxCount, c); + i++; + } + + let pHov = new Path2D(); + let pRest = new Path2D(); + + i = fromIdx; + let j = 0; + while (i < toIdx) { + let c = countVals[i]; + + if (c > 0) { + let pctY = c / maxCount; + let pctX = j / (data.yBucketCount! + 1); + + let p = i === hover.index ? pHov : pRest; + + p.rect( + Math.round(histCanWidth * pctX), + Math.round(histCanHeight * (1 - pctY)), + Math.round(histCanWidth / data.yBucketCount!), + Math.round(histCanHeight * pctY) + ); + } + + i++; + j++; + } + + histCtx.clearRect(0, 0, histCanWidth, histCanHeight); + + histCtx.fillStyle = '#ffffff80'; + histCtx.fill(pRest); + + histCtx.fillStyle = '#ff000080'; + histCtx.fill(pHov); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [hover.index] + ); + + return ( + <> +
+
{xDisp(xBucketMin)}
+
{xDisp(xBucketMax)}
+
+ {showHistogram && ( + + )} +
+
+ Bucket: {yDisp(yBucketMin)} - {yDisp(yBucketMax)} +
+
Count: {count}
+
+ {links.length > 0 && ( + + {links.map((link, i) => ( + + {link.title} + + ))} + + )} + + ); +}; diff --git a/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx b/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx new file mode 100644 index 00000000000..7835e4d3d77 --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/HeatmapPanel.tsx @@ -0,0 +1,125 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2, PanelProps } from '@grafana/data'; +import { Portal, UPlotChart, useStyles2, useTheme2, VizLayout, VizTooltipContainer } from '@grafana/ui'; +import { PanelDataErrorView } from '@grafana/runtime'; + +import { HeatmapData, prepareHeatmapData } from './fields'; +import { PanelOptions } from './models.gen'; +import { quantizeScheme } from './palettes'; +import { HeatmapHoverEvent, prepConfig } from './utils'; +import { HeatmapHoverView } from './HeatmapHoverView'; +import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; + +interface HeatmapPanelProps extends PanelProps {} + +export const HeatmapPanel: React.FC = ({ + data, + id, + timeRange, + timeZone, + width, + height, + options, + fieldConfig, + onChangeTimeRange, + replaceVariables, +}) => { + const theme = useTheme2(); + const styles = useStyles2(getStyles); + + const info = useMemo(() => prepareHeatmapData(data.series, options, theme), [data, options, theme]); + + const facets = useMemo(() => [null, info.heatmap?.fields.map((f) => f.values.toArray())], [info.heatmap]); + + //console.log(facets); + + const palette = useMemo(() => quantizeScheme(options.color, theme), [options.color, theme]); + + const [hover, setHover] = useState(undefined); + const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState(false); + const isToolTipOpen = useRef(false); + + const onCloseToolTip = () => { + isToolTipOpen.current = false; + setShouldDisplayCloseButton(false); + onhover(null); + }; + + const onclick = () => { + isToolTipOpen.current = !isToolTipOpen.current; + + // Linking into useState required to re-render tooltip + setShouldDisplayCloseButton(isToolTipOpen.current); + }; + + const onhover = useCallback( + (evt?: HeatmapHoverEvent | null) => { + setHover(evt ?? undefined); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [options, data.structureRev] + ); + + const dataRef = useRef(info); + + dataRef.current = info; + + const builder = useMemo(() => { + return prepConfig({ + dataRef, + theme, + onhover: options.tooltip.show ? onhover : () => {}, + onclick: options.tooltip.show ? onclick : () => {}, + isToolTipOpen, + timeZone, + timeRange, + palette, + cellGap: options.cellGap, + hideThreshold: options.hideThreshold, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options, data.structureRev]); + + if (info.warning || !info.heatmap) { + return ; + } + + return ( + <> + + {(vizWidth: number, vizHeight: number) => ( + //
+          //   {JSON.stringify(scatterData, null, 2)}
+          // 
+ + {/*children ? children(config, alignedFrame) : null*/} + + )} +
+ + {hover && ( + + {shouldDisplayCloseButton && ( + <> + +
+ + )} + + + )} + + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + closeButtonSpacer: css` + margin-bottom: 15px; + `, +}); diff --git a/public/app/plugins/panel/heatmap-new/README.md b/public/app/plugins/panel/heatmap-new/README.md new file mode 100644 index 00000000000..626bf160c4f --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/README.md @@ -0,0 +1,7 @@ +# Heatmap Panel (NEXT GEN) - Native Plugin + +The Heatmap panel allows you to view histograms over time and is **included** with Grafana. + +Read more about it here: + +[http://docs.grafana.org/features/panels/heatmap/](http://docs.grafana.org/features/panels/heatmap/) diff --git a/public/app/plugins/panel/heatmap-new/fields.test.ts b/public/app/plugins/panel/heatmap-new/fields.test.ts new file mode 100644 index 00000000000..a8c5e7ee18a --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/fields.test.ts @@ -0,0 +1,13 @@ +import { createTheme } from '@grafana/data'; +import { PanelOptions } from './models.gen'; + +const theme = createTheme(); + +describe('Heatmap data', () => { + const options: PanelOptions = {} as PanelOptions; + + it('simple test stub', () => { + expect(theme).toBeDefined(); + expect(options).toBeDefined(); + }); +}); diff --git a/public/app/plugins/panel/heatmap-new/fields.ts b/public/app/plugins/panel/heatmap-new/fields.ts new file mode 100644 index 00000000000..3a2e0418451 --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/fields.ts @@ -0,0 +1,115 @@ +import { DataFrame, DataFrameType, getDisplayProcessor, GrafanaTheme2 } from '@grafana/data'; +import { + calculateHeatmapFromData, + createHeatmapFromBuckets, + sortAscStrInf, +} from 'app/core/components/TransformersUI/calculateHeatmap/heatmap'; +import { HeatmapSourceMode, PanelOptions } from './models.gen'; + +export const enum BucketLayout { + le = 'le', + ge = 'ge', +} + +export interface HeatmapData { + // List of heatmap frames + heatmap?: DataFrame; + + yAxisValues?: Array; + + xBucketSize?: number; + yBucketSize?: number; + + xBucketCount?: number; + yBucketCount?: number; + + xLayout?: BucketLayout; + yLayout?: BucketLayout; + + // Errors + warning?: string; +} + +export function prepareHeatmapData( + frames: DataFrame[] | undefined, + options: PanelOptions, + theme: GrafanaTheme2 +): HeatmapData { + if (!frames?.length) { + return {}; + } + + const { source } = options; + if (source === HeatmapSourceMode.Calculate) { + // TODO, check for error etc + return getHeatmapData(calculateHeatmapFromData(frames, options.heatmap ?? {}), theme); + } + + // Find a well defined heatmap + let heatmap = frames.find((f) => f.meta?.type === DataFrameType.HeatmapScanlines); + if (heatmap) { + return getHeatmapData(heatmap, theme); + } + + if (source === HeatmapSourceMode.Data) { + // TODO: check for names xMin, yMin etc... + return getHeatmapData(createHeatmapFromBuckets(frames), theme); + } + + // detect a frame-per-bucket heatmap frame + // TODO: improve heuristic? infer from fields[1].labels.le === '+Inf' ? + if (frames[0].meta?.custom?.resultType === 'matrix' && frames.some((f) => f.name?.startsWith('+Inf'))) { + return { + yAxisValues: frames.map((f) => f.name ?? null).sort(sortAscStrInf), + ...getHeatmapData(createHeatmapFromBuckets(frames), theme), + }; + } + + // TODO, check for error etc + return getHeatmapData(calculateHeatmapFromData(frames, options.heatmap ?? {}), theme); +} + +const getHeatmapData = (frame: DataFrame, theme: GrafanaTheme2): HeatmapData => { + if (frame.meta?.type !== DataFrameType.HeatmapScanlines) { + return { + warning: 'Expected heatmap scanlines format', + heatmap: frame, + }; + } + + if (frame.fields.length < 2 || frame.length < 2) { + return { heatmap: frame }; + } + + if (!frame.fields[1].display) { + frame.fields[1].display = getDisplayProcessor({ field: frame.fields[1], theme }); + } + + // infer bucket sizes from data (for now) + // the 'heatmap-scanlines' dense frame format looks like: + // x: 1,1,1,1,2,2,2,2 + // y: 3,4,5,6,3,4,5,6 + // count: 0,0,0,7,0,3,0,1 + + const xs = frame.fields[0].values.toArray(); + const ys = frame.fields[1].values.toArray(); + const dlen = xs.length; + + // below is literally copy/paste from the pathBuilder code in utils.ts + // detect x and y bin qtys by detecting layout repetition in x & y data + let yBinQty = dlen - ys.lastIndexOf(ys[0]); + let xBinQty = dlen / yBinQty; + let yBinIncr = ys[1] - ys[0]; + let xBinIncr = xs[yBinQty] - xs[0]; + + return { + heatmap: frame, + xBucketSize: xBinIncr, + yBucketSize: yBinIncr, + xBucketCount: xBinQty, + yBucketCount: yBinQty, + // TODO: improve heuristic + xLayout: frame.fields[0].name === 'xMax' ? BucketLayout.le : BucketLayout.ge, + yLayout: frame.fields[1].name === 'yMax' ? BucketLayout.le : BucketLayout.ge, + }; +}; diff --git a/public/app/plugins/panel/heatmap-new/img/heatmap.svg b/public/app/plugins/panel/heatmap-new/img/heatmap.svg new file mode 100644 index 00000000000..25d98a0d5fd --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/img/heatmap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/app/plugins/panel/heatmap-new/migrations.test.ts b/public/app/plugins/panel/heatmap-new/migrations.test.ts new file mode 100644 index 00000000000..acbfbbdfc9a --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/migrations.test.ts @@ -0,0 +1,135 @@ +import { PanelModel, FieldConfigSource } from '@grafana/data'; +import { heatmapChangedHandler } from './migrations'; + +describe('Heatmap Migrations', () => { + let prevFieldConfig: FieldConfigSource; + + beforeEach(() => { + prevFieldConfig = { + defaults: {}, + overrides: [], + }; + }); + + it('simple heatmap', () => { + const old: any = { + angular: oldHeatmap, + }; + const panel = {} as PanelModel; + panel.options = heatmapChangedHandler(panel, 'heatmap', old, prevFieldConfig); + expect(panel).toMatchInlineSnapshot(` + Object { + "fieldConfig": Object { + "defaults": Object {}, + "overrides": Array [], + }, + "options": Object { + "cellGap": 2, + "cellSize": 10, + "color": Object { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "scale": "exponential", + "scheme": "Oranges", + "steps": 256, + }, + "heatmap": Object { + "xAxis": Object { + "mode": "count", + "value": "100", + }, + "yAxis": Object { + "mode": "count", + "value": "20", + }, + }, + "legend": Object { + "calcs": Array [], + "displayMode": "list", + "placement": "bottom", + }, + "showValue": "never", + "source": "calculate", + "tooltip": Object { + "show": true, + "yHistogram": true, + }, + "yAxisLabels": "auto", + "yAxisReverse": false, + }, + } + `); + }); +}); + +const oldHeatmap = { + id: 4, + gridPos: { + x: 0, + y: 0, + w: 12, + h: 8, + }, + type: 'heatmap', + title: 'Panel Title', + datasource: { + uid: '000000051', + type: 'testdata', + }, + targets: [ + { + scenarioId: 'random_walk', + refId: 'A', + datasource: { + uid: '000000051', + type: 'testdata', + }, + startValue: 0, + seriesCount: 5, + spread: 10, + }, + ], + heatmap: {}, + cards: { + cardPadding: 2, + cardRound: 10, + }, + color: { + mode: 'spectrum', + cardColor: '#b4ff00', + colorScale: 'sqrt', + exponent: 0.5, + colorScheme: 'interpolateBuGn', + min: null, + max: null, + }, + legend: { + show: true, + }, + dataFormat: 'timeseries', + yBucketBound: 'auto', + reverseYBuckets: false, + xAxis: { + show: true, + }, + yAxis: { + show: true, + format: 'short', + decimals: null, + logBase: 1, + splitFactor: null, + min: null, + max: null, + }, + xBucketSize: null, + xBucketNumber: 100, + yBucketSize: null, + yBucketNumber: 20, + tooltip: { + show: true, + showHistogram: true, + }, + highlightCards: true, + hideZeroBuckets: true, +}; diff --git a/public/app/plugins/panel/heatmap-new/migrations.ts b/public/app/plugins/panel/heatmap-new/migrations.ts new file mode 100644 index 00000000000..aee97869fb5 --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/migrations.ts @@ -0,0 +1,83 @@ +import { FieldConfigSource, PanelModel, PanelTypeChangedHandler } from '@grafana/data'; +import { LegendDisplayMode, VisibilityMode } from '@grafana/schema'; +import { + HeatmapCalculationMode, + HeatmapCalculationOptions, +} from 'app/core/components/TransformersUI/calculateHeatmap/models.gen'; +import { HeatmapSourceMode, PanelOptions, defaultPanelOptions } from './models.gen'; + +/** + * This is called when the panel changes from another panel + */ +export const heatmapChangedHandler: PanelTypeChangedHandler = (panel, prevPluginId, prevOptions, prevFieldConfig) => { + if (prevPluginId === 'heatmap' && prevOptions.angular) { + const { fieldConfig, options } = angularToReactHeatmap({ + ...prevOptions.angular, + fieldConfig: prevFieldConfig, + }); + panel.fieldConfig = fieldConfig; // Mutates the incoming panel + return options; + } + return {}; +}; + +export function angularToReactHeatmap(angular: any): { fieldConfig: FieldConfigSource; options: PanelOptions } { + const fieldConfig: FieldConfigSource = { + defaults: {}, + overrides: [], + }; + + const source = angular.dataFormat === 'tsbuckets' ? HeatmapSourceMode.Data : HeatmapSourceMode.Calculate; + const heatmap: HeatmapCalculationOptions = { + ...defaultPanelOptions.heatmap, + }; + + if (source === HeatmapSourceMode.Calculate) { + if (angular.xBucketSize) { + heatmap.xAxis = { mode: HeatmapCalculationMode.Size, value: `${angular.xBucketSize}` }; + } else if (angular.xBucketNumber) { + heatmap.xAxis = { mode: HeatmapCalculationMode.Count, value: `${angular.xBucketNumber}` }; + } + + if (angular.yBucketSize) { + heatmap.yAxis = { mode: HeatmapCalculationMode.Size, value: `${angular.yBucketSize}` }; + } else if (angular.xBucketNumber) { + heatmap.yAxis = { mode: HeatmapCalculationMode.Count, value: `${angular.yBucketNumber}` }; + } + } + + const options: PanelOptions = { + source, + heatmap, + color: { + ...defaultPanelOptions.color, + steps: 256, // best match with existing colors + }, + cellGap: asNumber(angular.cards?.cardPadding), + cellSize: asNumber(angular.cards?.cardRound), + yAxisLabels: angular.yBucketBound, + yAxisReverse: angular.reverseYBuckets, + legend: { + displayMode: angular.legend.show ? LegendDisplayMode.List : LegendDisplayMode.Hidden, + calcs: [], + placement: 'bottom', + }, + showValue: VisibilityMode.Never, + tooltip: { + show: Boolean(angular.tooltip?.show), + yHistogram: Boolean(angular.tooltip?.showHistogram), + }, + }; + + return { fieldConfig, options }; +} + +function asNumber(v: any): number | undefined { + const num = +v; + return isNaN(num) ? undefined : num; +} + +export const heatmapMigrationHandler = (panel: PanelModel): Partial => { + // Nothing yet + return panel.options; +}; diff --git a/public/app/plugins/panel/heatmap-new/models.cue b/public/app/plugins/panel/heatmap-new/models.cue new file mode 100644 index 00000000000..1bb9205ed9a --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/models.cue @@ -0,0 +1,33 @@ +// Copyright 2021 Grafana Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grafanaschema + +Panel: { + lineages: [ + [ + { + PanelOptions: { + // anything for now + ... + } + PanelFieldConfig: { + // anything for now + ... + } + } + ] + ] + migrations: [] +} diff --git a/public/app/plugins/panel/heatmap-new/models.gen.ts b/public/app/plugins/panel/heatmap-new/models.gen.ts new file mode 100644 index 00000000000..5124a5c73c9 --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/models.gen.ts @@ -0,0 +1,92 @@ +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// NOTE: This file will be auto generated from models.cue +// It is currenty hand written but will serve as the target for cuetsy +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +import { HideableFieldConfig, LegendDisplayMode, OptionsWithLegend, VisibilityMode } from '@grafana/schema'; +import { HeatmapCalculationOptions } from 'app/core/components/TransformersUI/calculateHeatmap/models.gen'; + +export const modelVersion = Object.freeze([1, 0]); + +export enum HeatmapSourceMode { + Auto = 'auto', + Calculate = 'calculate', + Data = 'data', // Use the data as is +} + +export enum HeatmapColorMode { + Opacity = 'opacity', + Scheme = 'scheme', +} + +export enum HeatmapColorScale { + Linear = 'linear', + Exponential = 'exponential', +} + +export interface HeatmapColorOptions { + mode: HeatmapColorMode; + scheme: string; // when in scheme mode -- the d3 scheme name + fill: string; // when opacity mode, the target color + scale: HeatmapColorScale; // for opacity mode + exponent: number; // when scale== sqrt + steps: number; // 2-256 + + // Clamp the colors to the value range + field?: string; + min?: number; + max?: number; +} + +export interface HeatmapTooltip { + show: boolean; + yHistogram?: boolean; +} + +export interface PanelOptions extends OptionsWithLegend { + source: HeatmapSourceMode; + + color: HeatmapColorOptions; + heatmap?: HeatmapCalculationOptions; + showValue: VisibilityMode; + + cellGap?: number; // was cardPadding + cellSize?: number; // was cardRadius + + hideThreshold?: number; // was hideZeroBuckets + yAxisLabels?: string; + yAxisReverse?: boolean; + + tooltip: HeatmapTooltip; +} + +export const defaultPanelOptions: PanelOptions = { + source: HeatmapSourceMode.Auto, + color: { + mode: HeatmapColorMode.Scheme, + scheme: 'Oranges', + fill: 'dark-orange', + scale: HeatmapColorScale.Exponential, + exponent: 0.5, + steps: 64, + }, + showValue: VisibilityMode.Auto, + legend: { + displayMode: LegendDisplayMode.Hidden, + placement: 'bottom', + calcs: [], + }, + tooltip: { + show: true, + yHistogram: false, + }, + cellGap: 3, +}; + +export interface PanelFieldConfig extends HideableFieldConfig { + // TODO points vs lines etc +} + +export const defaultPanelFieldConfig: PanelFieldConfig = { + // default to points? +}; diff --git a/public/app/plugins/panel/heatmap-new/module.tsx b/public/app/plugins/panel/heatmap-new/module.tsx new file mode 100644 index 00000000000..6ab1eef3663 --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/module.tsx @@ -0,0 +1,223 @@ +import { GraphFieldConfig, VisibilityMode } from '@grafana/schema'; +import { Field, FieldType, PanelPlugin } from '@grafana/data'; +import { commonOptionsBuilder } from '@grafana/ui'; +import { HeatmapPanel } from './HeatmapPanel'; +import { + PanelOptions, + defaultPanelOptions, + HeatmapSourceMode, + HeatmapColorMode, + HeatmapColorScale, +} from './models.gen'; +import { HeatmapSuggestionsSupplier } from './suggestions'; +import { heatmapChangedHandler } from './migrations'; +import { addHeatmapCalculationOptions } from 'app/core/components/TransformersUI/calculateHeatmap/editor/helper'; +import { colorSchemes } from './palettes'; + +export const plugin = new PanelPlugin(HeatmapPanel) + .useFieldConfig() + .setPanelChangeHandler(heatmapChangedHandler) + // .setMigrationHandler(heatmapMigrationHandler) + .setPanelOptions((builder, context) => { + const opts = context.options ?? defaultPanelOptions; + + let category = ['Heatmap']; + + builder.addRadio({ + path: 'source', + name: 'Source', + defaultValue: HeatmapSourceMode.Auto, + category, + settings: { + options: [ + { label: 'Auto', value: HeatmapSourceMode.Auto }, + { label: 'Calculate', value: HeatmapSourceMode.Calculate }, + { label: 'Raw data', description: 'The results are already heatmap buckets', value: HeatmapSourceMode.Data }, + ], + }, + }); + + if (opts.source === HeatmapSourceMode.Calculate) { + addHeatmapCalculationOptions('heatmap.', builder, opts.heatmap, category); + } else if (opts.source === HeatmapSourceMode.Data) { + // builder.addSliderInput({ + // name: 'heatmap from the data...', + // path: 'xxx', + // }); + } + + category = ['Colors']; + + builder.addFieldNamePicker({ + path: `color.field`, + name: 'Color with field', + category, + settings: { + filter: (f: Field) => f.type === FieldType.number, + noFieldsMessage: 'No numeric fields found', + placeholderText: 'Auto', + }, + }); + + builder.addRadio({ + path: `color.mode`, + name: 'Mode', + defaultValue: defaultPanelOptions.color.mode, + category, + settings: { + options: [ + { label: 'Scheme', value: HeatmapColorMode.Scheme }, + { label: 'Opacity', value: HeatmapColorMode.Opacity }, + ], + }, + }); + + builder.addColorPicker({ + path: `color.fill`, + name: 'Color', + defaultValue: defaultPanelOptions.color.fill, + category, + showIf: (opts) => opts.color.mode === HeatmapColorMode.Opacity, + }); + + builder.addRadio({ + path: `color.scale`, + name: 'Scale', + description: '', + defaultValue: defaultPanelOptions.color.scale, + category, + settings: { + options: [ + { label: 'Exponential', value: HeatmapColorScale.Exponential }, + { label: 'Linear', value: HeatmapColorScale.Linear }, + ], + }, + showIf: (opts) => opts.color.mode === HeatmapColorMode.Opacity, + }); + + builder.addSliderInput({ + path: 'color.exponent', + name: 'Exponent', + defaultValue: defaultPanelOptions.color.exponent, + category, + settings: { + min: 0.1, // 1 for on/off? + max: 2, + step: 0.1, + }, + showIf: (opts) => + opts.color.mode === HeatmapColorMode.Opacity && opts.color.scale === HeatmapColorScale.Exponential, + }); + + builder.addSelect({ + path: `color.scheme`, + name: 'Scheme', + description: '', + defaultValue: defaultPanelOptions.color.scheme, + category, + settings: { + options: colorSchemes.map((scheme) => ({ + value: scheme.name, + label: scheme.name, + //description: 'Set a geometry field based on the results of other fields', + })), + }, + showIf: (opts) => opts.color.mode !== HeatmapColorMode.Opacity, + }); + + builder.addSliderInput({ + path: 'color.steps', + name: 'Max steps', + defaultValue: defaultPanelOptions.color.steps, + category, + settings: { + min: 2, // 1 for on/off? + max: 128, + step: 1, + }, + }); + + category = ['Display']; + + builder + .addRadio({ + path: 'showValue', + name: 'Show values', + defaultValue: defaultPanelOptions.showValue, + category, + settings: { + options: [ + { value: VisibilityMode.Auto, label: 'Auto' }, + { value: VisibilityMode.Always, label: 'Always' }, + { value: VisibilityMode.Never, label: 'Never' }, + ], + }, + }) + .addNumberInput({ + path: 'hideThreshold', + name: 'Hide cell counts <=', + defaultValue: 0.000_000_001, // 1e-9 + category, + }) + .addSliderInput({ + name: 'Cell gap', + path: 'cellGap', + defaultValue: defaultPanelOptions.cellGap, + category, + settings: { + min: 0, + max: 25, + }, + }) + // .addSliderInput({ + // name: 'Cell radius', + // path: 'cellRadius', + // defaultValue: defaultPanelOptions.cellRadius, + // category, + // settings: { + // min: 0, + // max: 100, + // }, + // }) + .addRadio({ + path: 'yAxisLabels', + name: 'Axis labels', + defaultValue: 'auto', + category, + settings: { + options: [ + { value: 'auto', label: 'Auto' }, + { value: 'middle', label: 'Middle' }, + { value: 'bottom', label: 'Bottom' }, + { value: 'top', label: 'Top' }, + ], + }, + }) + .addBooleanSwitch({ + path: 'yAxisReverse', + name: 'Reverse buckets', + defaultValue: defaultPanelOptions.yAxisReverse === true, + category, + }); + + category = ['Tooltip']; + + builder.addBooleanSwitch({ + path: 'tooltip.show', + name: 'Show tooltip', + defaultValue: defaultPanelOptions.tooltip.show, + category, + }); + + builder.addBooleanSwitch({ + path: 'tooltip.yHistogram', + name: 'Show histogram (Y axis)', + defaultValue: defaultPanelOptions.tooltip.yHistogram, + category, + showIf: (opts) => opts.tooltip.show, + }); + + // custom legend? + commonOptionsBuilder.addLegendOptions(builder); + }) + .setSuggestionsSupplier(new HeatmapSuggestionsSupplier()); diff --git a/public/app/plugins/panel/heatmap-new/palettes.ts b/public/app/plugins/panel/heatmap-new/palettes.ts new file mode 100644 index 00000000000..50fa0a54f9b --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/palettes.ts @@ -0,0 +1,107 @@ +import { GrafanaTheme2 } from '@grafana/data'; +import * as d3 from 'd3'; +import * as d3ScaleChromatic from 'd3-scale-chromatic'; +import { HeatmapColorOptions, defaultPanelOptions, HeatmapColorMode, HeatmapColorScale } from './models.gen'; +import tinycolor from 'tinycolor2'; + +// https://observablehq.com/@d3/color-schemes?collection=@d3/d3-scale-chromatic + +// the previous heatmap panel used d3 deps and some code to interpolate to static 9-color palettes. here we just hard-code them for clarity. +// if the need arises for configurable-sized palettes, we can bring back the deps & variable interpolation (see simplified code at end) + +// Schemes from d3-scale-chromatic +// https://github.com/d3/d3-scale-chromatic +export const colorSchemes = [ + // Diverging + { name: 'BrBG', invert: 'always' }, + { name: 'PiYG', invert: 'always' }, + { name: 'PRGn', invert: 'always' }, + { name: 'PuOr', invert: 'always' }, + { name: 'RdBu', invert: 'always' }, + { name: 'RdGy', invert: 'always' }, + { name: 'RdYlBu', invert: 'always' }, + { name: 'RdYlGn', invert: 'always' }, + { name: 'Spectral', invert: 'always' }, + + // Sequential (Single Hue) + { name: 'Blues', invert: 'dark' }, + { name: 'Greens', invert: 'dark' }, + { name: 'Greys', invert: 'dark' }, + { name: 'Oranges', invert: 'dark' }, + { name: 'Purples', invert: 'dark' }, + { name: 'Reds', invert: 'dark' }, + + // Sequential (Multi-Hue) + { name: 'Turbo', invert: 'light' }, + { name: 'Cividis', invert: 'light' }, + { name: 'Viridis', invert: 'light' }, + { name: 'Magma', invert: 'light' }, + { name: 'Inferno', invert: 'light' }, + { name: 'Plasma', invert: 'light' }, + { name: 'Warm', invert: 'light' }, + { name: 'Cool', invert: 'light' }, + { name: 'Cubehelix', invert: 'light', name2: 'CubehelixDefault' }, + { name: 'BuGn', invert: 'dark' }, + { name: 'BuPu', invert: 'dark' }, + { name: 'GnBu', invert: 'dark' }, + { name: 'OrRd', invert: 'dark' }, + { name: 'PuBuGn', invert: 'dark' }, + { name: 'PuBu', invert: 'dark' }, + { name: 'PuRd', invert: 'dark' }, + { name: 'RdPu', invert: 'dark' }, + { name: 'YlGnBu', invert: 'dark' }, + { name: 'YlGn', invert: 'dark' }, + { name: 'YlOrBr', invert: 'dark' }, + { name: 'YlOrRd', invert: 'dark' }, + + // Cyclical + { name: 'Rainbow', invert: 'always' }, + { name: 'Sinebow', invert: 'always' }, +]; + +type Interpolator = (t: number) => string; + +const DEFAULT_SCHEME = colorSchemes.find((scheme) => scheme.name === 'Spectral'); + +export function quantizeScheme(opts: HeatmapColorOptions, theme: GrafanaTheme2): string[] { + const options = { ...defaultPanelOptions.color, ...opts }; + const palette = []; + const steps = (options.steps ?? 128) - 1; + + if (opts.mode === HeatmapColorMode.Opacity) { + const fill = tinycolor(theme.visualization.getColorByName(opts.fill)).toPercentageRgb(); + + const scale = + options.scale === HeatmapColorScale.Exponential + ? d3.scalePow().exponent(options.exponent).domain([0, 1]).range([0, 1]) + : d3.scaleLinear().domain([0, 1]).range([0, 1]); + + for (let i = 0; i <= steps; i++) { + fill.a = scale(i / steps); + palette.push(tinycolor(fill).toString('hex8')); + } + } else { + const scheme = colorSchemes.find((scheme) => scheme.name === options.scheme) ?? DEFAULT_SCHEME!; + let fnName = 'interpolate' + (scheme.name2 ?? scheme.name); + const interpolate: Interpolator = (d3ScaleChromatic as any)[fnName]; + + for (let i = 0; i <= steps; i++) { + let rgbStr = interpolate(i / steps); + let rgb = + rgbStr.indexOf('rgb') === 0 + ? '#' + [...rgbStr.matchAll(/\d+/g)].map((v) => (+v[0]).toString(16).padStart(2, '0')).join('') + : rgbStr; + palette.push(rgb); + } + + if ( + scheme.invert === 'always' || + (scheme.invert === 'dark' && theme.isDark) || + (scheme.invert === 'light' && theme.isLight) + ) { + palette.reverse(); + } + } + + return palette; +} diff --git a/public/app/plugins/panel/heatmap-new/plugin.json b/public/app/plugins/panel/heatmap-new/plugin.json new file mode 100644 index 00000000000..4a320c089fb --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/plugin.json @@ -0,0 +1,18 @@ +{ + "type": "panel", + "name": "Heatmap (preview)", + "id": "heatmap-new", + "state": "alpha", + + "info": { + "description": "Next generation heatmap visualization", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/heatmap.svg", + "large": "img/heatmap.svg" + } + } +} diff --git a/public/app/plugins/panel/heatmap-new/suggestions.ts b/public/app/plugins/panel/heatmap-new/suggestions.ts new file mode 100644 index 00000000000..508c88b0040 --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/suggestions.ts @@ -0,0 +1,37 @@ +import { VisualizationSuggestionsBuilder } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { prepareHeatmapData } from './fields'; +import { PanelOptions, defaultPanelOptions } from './models.gen'; + +export class HeatmapSuggestionsSupplier { + getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { + const { dataSummary } = builder; + + if ( + !builder.data?.series || + !dataSummary.hasData || + dataSummary.timeFieldCount < 1 || + dataSummary.numberFieldCount < 2 || + dataSummary.numberFieldCount > 10 + ) { + return; + } + + const info = prepareHeatmapData(builder.data.series, defaultPanelOptions, config.theme2); + if (!info || info.warning) { + return; + } + + builder.getListAppender({ + name: '', + pluginId: 'heatmap-new', + options: {}, + fieldConfig: { + defaults: { + custom: {}, + }, + overrides: [], + }, + }); + } +} diff --git a/public/app/plugins/panel/heatmap-new/utils.ts b/public/app/plugins/panel/heatmap-new/utils.ts new file mode 100644 index 00000000000..caca5fc1dc0 --- /dev/null +++ b/public/app/plugins/panel/heatmap-new/utils.ts @@ -0,0 +1,395 @@ +import { MutableRefObject, RefObject } from 'react'; +import { GrafanaTheme2, TimeRange } from '@grafana/data'; +import { AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/schema'; +import { UPlotConfigBuilder } from '@grafana/ui'; +import uPlot from 'uplot'; + +import { pointWithin, Quadtree, Rect } from '../barchart/quadtree'; +import { BucketLayout, HeatmapData } from './fields'; + +interface PathbuilderOpts { + each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void; + gap?: number | null; + hideThreshold?: number; + xCeil?: boolean; + yCeil?: boolean; + disp: { + fill: { + values: (u: uPlot, seriesIndex: number) => number[]; + index: Array; + }; + }; +} + +export interface HeatmapHoverEvent { + index: number; + pageX: number; + pageY: number; +} + +interface PrepConfigOpts { + dataRef: RefObject; + theme: GrafanaTheme2; + onhover: (evt?: HeatmapHoverEvent | null) => void; + onclick: (evt?: any) => void; + isToolTipOpen: MutableRefObject; + timeZone: string; + timeRange: TimeRange; // should be getTimeRange() cause dynamic? + palette: string[]; + cellGap?: number | null; // in css pixels + hideThreshold?: number; +} + +export function prepConfig(opts: PrepConfigOpts) { + const { dataRef, theme, onhover, onclick, isToolTipOpen, timeZone, timeRange, palette, cellGap, hideThreshold } = + opts; + + let qt: Quadtree; + let hRect: Rect | null; + + let builder = new UPlotConfigBuilder(timeZone); + + let rect: DOMRect; + + builder.addHook('init', (u) => { + u.root.querySelectorAll('.u-cursor-pt').forEach((el) => { + Object.assign((el as HTMLElement).style, { + borderRadius: '0', + border: '1px solid white', + background: 'transparent', + }); + }); + u.over.addEventListener('click', onclick); + }); + + // rect of .u-over (grid area) + builder.addHook('syncRect', (u, r) => { + rect = r; + }); + + let pendingOnleave = 0; + + builder.addHook('setLegend', (u) => { + if (u.cursor.idxs != null) { + for (let i = 0; i < u.cursor.idxs.length; i++) { + const sel = u.cursor.idxs[i]; + if (sel != null && !isToolTipOpen.current) { + if (pendingOnleave) { + clearTimeout(pendingOnleave); + pendingOnleave = 0; + } + + onhover({ + index: sel, + pageX: rect.left + u.cursor.left!, + pageY: rect.top + u.cursor.top!, + }); + + return; // only show the first one + } + } + } + + if (!isToolTipOpen.current) { + // if tiles have gaps, reduce flashing / re-render (debounce onleave by 100ms) + if (!pendingOnleave) { + pendingOnleave = setTimeout(() => onhover(null), 100) as any; + } + } + }); + + builder.addHook('drawClear', (u) => { + qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); + + qt.clear(); + + // force-clear the path cache to cause drawBars() to rebuild new quadtree + u.series.forEach((s, i) => { + if (i > 0) { + // @ts-ignore + s._paths = null; + } + }); + }); + + builder.setMode(2); + + builder.addScale({ + scaleKey: 'x', + isTime: true, + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, + // TODO: expand by x bucket size and layout + range: [timeRange.from.valueOf(), timeRange.to.valueOf()], + }); + + builder.addAxis({ + scaleKey: 'x', + placement: AxisPlacement.Bottom, + theme: theme, + }); + + builder.addScale({ + scaleKey: 'y', + isTime: false, + // distribution: ScaleDistribution.Ordinal, // does not work with facets/scatter yet + orientation: ScaleOrientation.Vertical, + direction: ScaleDirection.Up, + range: (u, dataMin, dataMax) => { + let bucketSize = dataRef.current?.yBucketSize; + + if (dataRef.current?.yLayout === BucketLayout.le) { + dataMin -= bucketSize!; + } else { + dataMax += bucketSize!; + } + + return [dataMin, dataMax]; + }, + }); + + const hasLabeledY = dataRef.current?.yAxisValues != null; + + builder.addAxis({ + scaleKey: 'y', + placement: AxisPlacement.Left, + theme: theme, + splits: hasLabeledY + ? () => { + let ys = dataRef.current?.heatmap?.fields[1].values.toArray()!; + let splits = ys.slice(0, ys.length - ys.lastIndexOf(ys[0])); + + let bucketSize = dataRef.current?.yBucketSize!; + + if (dataRef.current?.yLayout === BucketLayout.le) { + splits.unshift(ys[0] - bucketSize); + } else { + splits.push(ys[ys.length - 1] + bucketSize); + } + + return splits; + } + : undefined, + values: hasLabeledY + ? () => { + let yAxisValues = dataRef.current?.yAxisValues?.slice()!; + + if (dataRef.current?.yLayout === BucketLayout.le) { + yAxisValues.unshift('0.0'); // assumes dense layout where lowest bucket's low bound is 0-ish + } else { + yAxisValues.push('+Inf'); + } + + return yAxisValues; + } + : undefined, + }); + + builder.addSeries({ + facets: [ + { + scale: 'x', + auto: true, + sorted: 1, + }, + { + scale: 'y', + auto: true, + }, + ], + pathBuilder: heatmapPaths({ + each: (u, seriesIdx, dataIdx, x, y, xSize, ySize) => { + qt.add({ + x: x - u.bbox.left, + y: y - u.bbox.top, + w: xSize, + h: ySize, + sidx: seriesIdx, + didx: dataIdx, + }); + }, + gap: cellGap, + hideThreshold, + xCeil: dataRef.current?.xLayout === BucketLayout.le, + yCeil: dataRef.current?.yLayout === BucketLayout.le, + disp: { + fill: { + values: (u, seriesIdx) => countsToFills(u, seriesIdx, palette), + index: palette, + }, + }, + }) as any, + theme, + scaleKey: '', // facets' scales used (above) + }); + + builder.setCursor({ + dataIdx: (u, seriesIdx) => { + if (seriesIdx === 1) { + hRect = null; + + let cx = u.cursor.left! * devicePixelRatio; + let cy = u.cursor.top! * devicePixelRatio; + + qt.get(cx, cy, 1, 1, (o) => { + if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) { + hRect = o; + } + }); + } + + return hRect && seriesIdx === hRect.sidx ? hRect.didx : null; + }, + points: { + fill: 'rgba(255,255,255, 0.3)', + bbox: (u, seriesIdx) => { + let isHovered = hRect && seriesIdx === hRect.sidx; + + return { + left: isHovered ? hRect!.x / devicePixelRatio : -10, + top: isHovered ? hRect!.y / devicePixelRatio : -10, + width: isHovered ? hRect!.w / devicePixelRatio : 0, + height: isHovered ? hRect!.h / devicePixelRatio : 0, + }; + }, + }, + }); + + return builder; +} + +export function heatmapPaths(opts: PathbuilderOpts) { + const { disp, each, gap, hideThreshold = 0, xCeil = false, yCeil = false } = opts; + + return (u: uPlot, seriesIdx: number) => { + uPlot.orient( + u, + seriesIdx, + ( + series, + dataX, + dataY, + scaleX, + scaleY, + valToPosX, + valToPosY, + xOff, + yOff, + xDim, + yDim, + moveTo, + lineTo, + rect, + arc + ) => { + let d = u.data[seriesIdx]; + const xs = d[0] as unknown as number[]; + const ys = d[1] as unknown as number[]; + const counts = d[2] as unknown as number[]; + const dlen = xs.length; + + // fill colors are mapped from interpolating densities / counts along some gradient + // (should be quantized to 64 colors/levels max. e.g. 16) + let fills = disp.fill.values(u, seriesIdx); + let fillPalette = disp.fill.index ?? [...new Set(fills)]; + + let fillPaths = fillPalette.map((color) => new Path2D()); + + // detect x and y bin qtys by detecting layout repetition in x & y data + let yBinQty = dlen - ys.lastIndexOf(ys[0]); + let xBinQty = dlen / yBinQty; + let yBinIncr = ys[1] - ys[0]; + let xBinIncr = xs[yBinQty] - xs[0]; + + // uniform tile sizes based on zoom level + let xSize = Math.abs(valToPosX(xBinIncr, scaleX, xDim, xOff) - valToPosX(0, scaleX, xDim, xOff)); + let ySize = Math.abs(valToPosY(yBinIncr, scaleY, yDim, yOff) - valToPosY(0, scaleY, yDim, yOff)); + + const autoGapFactor = 0.05; + + // tile gap control + let xGap = gap != null ? gap * devicePixelRatio : Math.max(0, autoGapFactor * Math.min(xSize, ySize)); + let yGap = xGap; + + // clamp min tile size to 1px + xSize = Math.max(1, Math.round(xSize - xGap)); + ySize = Math.max(1, Math.round(ySize - yGap)); + + // bucket agg direction + // let xCeil = false; + // let yCeil = false; + + let xOffset = xCeil ? -xSize : 0; + let yOffset = yCeil ? 0 : -ySize; + + // pre-compute x and y offsets + let cys = ys.slice(0, yBinQty).map((y) => Math.round(valToPosY(y, scaleY, yDim, yOff) + yOffset)); + let cxs = Array.from({ length: xBinQty }, (v, i) => + Math.round(valToPosX(xs[i * yBinQty], scaleX, xDim, xOff) + xOffset) + ); + + for (let i = 0; i < dlen; i++) { + // filter out 0 counts and out of view + if ( + counts[i] > hideThreshold && + xs[i] + xBinIncr >= scaleX.min! && + xs[i] - xBinIncr <= scaleX.max! && + ys[i] + yBinIncr >= scaleY.min! && + ys[i] - yBinIncr <= scaleY.max! + ) { + let cx = cxs[~~(i / yBinQty)]; + let cy = cys[i % yBinQty]; + + let fillPath = fillPaths[fills[i]]; + + rect(fillPath, cx, cy, xSize, ySize); + + each(u, 1, i, cx, cy, xSize, ySize); + } + } + + u.ctx.save(); + // u.ctx.globalAlpha = 0.8; + u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + fillPaths.forEach((p, i) => { + u.ctx.fillStyle = fillPalette[i]; + u.ctx.fill(p); + }); + u.ctx.restore(); + + return null; + } + ); + }; +} + +export const countsToFills = (u: uPlot, seriesIdx: number, palette: string[]) => { + let counts = u.data[seriesIdx][2] as unknown as number[]; + + // TODO: integrate 1e-9 hideThreshold? + const hideThreshold = 0; + + let minCount = Infinity; + let maxCount = -Infinity; + + for (let i = 0; i < counts.length; i++) { + if (counts[i] > hideThreshold) { + minCount = Math.min(minCount, counts[i]); + maxCount = Math.max(maxCount, counts[i]); + } + } + + let range = maxCount - minCount; + + let paletteSize = palette.length; + + let indexedFills = Array(counts.length); + + for (let i = 0; i < counts.length; i++) { + indexedFills[i] = + counts[i] === 0 ? -1 : Math.min(paletteSize - 1, Math.floor((paletteSize * (counts[i] - minCount)) / range)); + } + + return indexedFills; +}; From 10b47480ebda29de1fe78feacdcdfe640df09794 Mon Sep 17 00:00:00 2001 From: ying-jeanne <74549700+ying-jeanne@users.noreply.github.com> Date: Thu, 17 Feb 2022 16:31:26 +0800 Subject: [PATCH 027/104] add dashboardsnap as service of http server (#45461) --- pkg/api/dashboard_snapshot.go | 15 +++++++-------- pkg/api/dashboard_snapshot_test.go | 3 ++- pkg/api/http_server.go | 4 ++++ .../dashboardsnapshots/dashboardsnapshots.go | 4 ++-- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index 5179dc0094f..1d5c45cc5f7 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -133,7 +133,7 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res metrics.MApiDashboardSnapshotCreate.Inc() } - if err := hs.SQLStore.CreateDashboardSnapshot(c.Req.Context(), &cmd); err != nil { + if err := hs.DashboardsnapshotsService.CreateDashboardSnapshot(c.Req.Context(), &cmd); err != nil { c.JsonApiErr(500, "Failed to create snapshot", err) return nil } @@ -157,7 +157,7 @@ func (hs *HTTPServer) GetDashboardSnapshot(c *models.ReqContext) response.Respon query := &models.GetDashboardSnapshotQuery{Key: key} - err := hs.SQLStore.GetDashboardSnapshot(query) + err := hs.DashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query) if err != nil { return response.Error(500, "Failed to get dashboard snapshot", err) } @@ -224,8 +224,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) r } query := &models.GetDashboardSnapshotQuery{DeleteKey: key} - - err := hs.SQLStore.GetDashboardSnapshot(query) + err := hs.DashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query) if err != nil { return response.Error(500, "Failed to get dashboard snapshot", err) } @@ -239,7 +238,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) r cmd := &models.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey} - if err := hs.SQLStore.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil { + if err := hs.DashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil { return response.Error(500, "Failed to delete dashboard snapshot", err) } @@ -258,7 +257,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res query := &models.GetDashboardSnapshotQuery{Key: key} - err := hs.SQLStore.GetDashboardSnapshot(query) + err := hs.DashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query) if err != nil { return response.Error(500, "Failed to get dashboard snapshot", err) } @@ -287,7 +286,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res cmd := &models.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey} - if err := hs.SQLStore.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil { + if err := hs.DashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil { return response.Error(500, "Failed to delete dashboard snapshot", err) } @@ -313,7 +312,7 @@ func (hs *HTTPServer) SearchDashboardSnapshots(c *models.ReqContext) response.Re SignedInUser: c.SignedInUser, } - err := hs.SQLStore.SearchDashboardSnapshots(&searchQuery) + err := hs.DashboardsnapshotsService.SearchDashboardSnapshots(c.Req.Context(), &searchQuery) if err != nil { return response.Error(500, "Search failed", err) } diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go index 5bf2ae90c4b..434714ba131 100644 --- a/pkg/api/dashboard_snapshot_test.go +++ b/pkg/api/dashboard_snapshot_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -33,7 +34,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { editorRole := models.ROLE_EDITOR sqlmock := mockstore.NewSQLStoreMock() aclMockResp := []*models.DashboardAclInfoDTO{} - hs := &HTTPServer{SQLStore: sqlmock} + hs := &HTTPServer{DashboardsnapshotsService: &dashboardsnapshots.Service{SQLStore: sqlmock}} setUpSnapshotTest := func(t *testing.T) *models.DashboardSnapshot { t.Helper() diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 8fdd76cb7d7..b665b67f961 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -34,6 +34,7 @@ import ( "github.com/grafana/grafana/pkg/services/cleanup" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/encryption" @@ -136,6 +137,7 @@ type HTTPServer struct { folderService dashboards.FolderService DatasourcePermissionsService DatasourcePermissionsService AlertNotificationService *alerting.AlertNotificationService + DashboardsnapshotsService *dashboardsnapshots.Service } type ServerOptions struct { @@ -166,6 +168,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService, dashboardProvisioningService dashboards.DashboardProvisioningService, folderService dashboards.FolderService, datasourcePermissionsService DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService, + dashboardsnapshotsService *dashboardsnapshots.Service, ) (*HTTPServer, error) { web.Env = cfg.Env m := web.New() @@ -232,6 +235,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi folderService: folderService, DatasourcePermissionsService: datasourcePermissionsService, AlertNotificationService: alertNotificationService, + DashboardsnapshotsService: dashboardsnapshotsService, } if hs.Listener != nil { hs.log.Debug("Using provided listener") diff --git a/pkg/services/dashboardsnapshots/dashboardsnapshots.go b/pkg/services/dashboardsnapshots/dashboardsnapshots.go index 48dfb5ab2f1..990e60bb818 100644 --- a/pkg/services/dashboardsnapshots/dashboardsnapshots.go +++ b/pkg/services/dashboardsnapshots/dashboardsnapshots.go @@ -12,11 +12,11 @@ import ( type Service struct { Bus bus.Bus - SQLStore *sqlstore.SQLStore + SQLStore sqlstore.Store SecretsService secrets.Service } -func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, secretsService secrets.Service) *Service { +func ProvideService(bus bus.Bus, store sqlstore.Store, secretsService secrets.Service) *Service { s := &Service{ Bus: bus, SQLStore: store, From 3e53a08090ad8ac61d7d316b97a93a7a5ea63a57 Mon Sep 17 00:00:00 2001 From: Artur Wierzbicki Date: Thu, 17 Feb 2022 12:34:07 +0400 Subject: [PATCH 028/104] #44449: hide dashboard preview admin UI (#45493) --- packages/grafana-data/src/types/featureToggles.gen.ts | 1 + pkg/api/api.go | 9 ++++++--- pkg/services/featuremgmt/registry.go | 5 +++++ pkg/services/featuremgmt/toggles_gen.go | 4 ++++ public/app/features/admin/ServerStats.tsx | 2 +- .../components/DashboardSettings/GeneralSettings.tsx | 4 +++- 6 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index a5f13a7de0f..95bb511c0e9 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -23,6 +23,7 @@ export interface FeatureToggles { database_metrics?: boolean; dashboardPreviews?: boolean; dashboardPreviewsScheduler?: boolean; + dashboardPreviewsAdmin?: boolean; ['live-config']?: boolean; ['live-pipeline']?: boolean; ['live-service-web-worker']?: boolean; diff --git a/pkg/api/api.go b/pkg/api/api.go index 6da0aa3488c..8615e41a3ce 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -336,8 +336,11 @@ func (hs *HTTPServer) registerRoutes() { if hs.ThumbService != nil { dashboardRoute.Get("/uid/:uid/img/:kind/:theme", hs.ThumbService.GetImage) - dashboardRoute.Post("/uid/:uid/img/:kind/:theme", hs.ThumbService.SetImage) - dashboardRoute.Put("/uid/:uid/img/:kind/:theme", hs.ThumbService.UpdateThumbnailState) + + if hs.Features.IsEnabled(featuremgmt.FlagDashboardPreviewsAdmin) { + dashboardRoute.Post("/uid/:uid/img/:kind/:theme", reqGrafanaAdmin, hs.ThumbService.SetImage) + dashboardRoute.Put("/uid/:uid/img/:kind/:theme", reqGrafanaAdmin, hs.ThumbService.UpdateThumbnailState) + } } dashboardRoute.Post("/calculate-diff", routing.Wrap(hs.CalculateDashboardDiff)) @@ -470,7 +473,7 @@ func (hs *HTTPServer) registerRoutes() { adminRoute.Get("/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), routing.Wrap(hs.AdminGetStats)) adminRoute.Post("/pause-all-alerts", reqGrafanaAdmin, routing.Wrap(hs.PauseAllAlerts)) - if hs.ThumbService != nil { + if hs.ThumbService != nil && hs.Features.IsEnabled(featuremgmt.FlagDashboardPreviewsAdmin) { adminRoute.Post("/crawler/start", reqGrafanaAdmin, routing.Wrap(hs.ThumbService.StartCrawler)) adminRoute.Post("/crawler/stop", reqGrafanaAdmin, routing.Wrap(hs.ThumbService.StopCrawler)) adminRoute.Get("/crawler/status", reqGrafanaAdmin, routing.Wrap(hs.ThumbService.CrawlerStatus)) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index bd715f9457a..d95d6b385c1 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -38,6 +38,11 @@ var ( Description: "Schedule automatic updates to dashboard previews", State: FeatureStateAlpha, }, + { + Name: "dashboardPreviewsAdmin", + Description: "Manage the dashboard previews crawler process from the UI", + State: FeatureStateAlpha, + }, { Name: "live-config", Description: "Save grafana live configuration in SQL tables", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 960a7b0f511..7c54af1412e 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -31,6 +31,10 @@ const ( // Schedule automatic updates to dashboard previews FlagDashboardPreviewsScheduler = "dashboardPreviewsScheduler" + // FlagDashboardPreviewsAdmin + // Manage the dashboard previews crawler process from the UI + FlagDashboardPreviewsAdmin = "dashboardPreviewsAdmin" + // FlagLiveConfig // Save grafana live configuration in SQL tables FlagLiveConfig = "live-config" diff --git a/public/app/features/admin/ServerStats.tsx b/public/app/features/admin/ServerStats.tsx index 675b0ea38f9..c883750e161 100644 --- a/public/app/features/admin/ServerStats.tsx +++ b/public/app/features/admin/ServerStats.tsx @@ -94,7 +94,7 @@ export const ServerStats = () => {

No stats found.

)} - {config.featureToggles.dashboardPreviews && } + {config.featureToggles.dashboardPreviews && config.featureToggles.dashboardPreviewsAdmin && } ); }; diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx index 5ad19559403..5a509359a14 100644 --- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx @@ -122,7 +122,9 @@ export function GeneralSettingsUnconnected({ dashboard, updateTimeZone, updateWe
- {config.featureToggles.dashboardPreviews && } + {config.featureToggles.dashboardPreviews && config.featureToggles.dashboardPreviewsAdmin && ( + + )} Date: Thu, 17 Feb 2022 10:04:15 +0100 Subject: [PATCH 029/104] Docs: Update log level information and add tip (#45411) * Docs: Update conent for log level * Add tip --- docs/sources/explore/logs-integration.md | 4 +++- docs/sources/visualizations/logs-panel.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/sources/explore/logs-integration.md b/docs/sources/explore/logs-integration.md index 50f394afe38..51b33a7d632 100644 --- a/docs/sources/explore/logs-integration.md +++ b/docs/sources/explore/logs-integration.md @@ -29,7 +29,9 @@ If the data source does not support loading full range log volume histogram, the #### Log level -For logs where a level label is specified, we use the value of the label to determine the log level and update color accordingly. If the log doesn't have a level label specified, we try to parse the log using logfmt and JSON parsers to find out if its content matches any of the supported expressions (see below for more information). The log level is always determined by the first match. In case Grafana is not able to determine a log level, it will be visualized with an unknown log level. +For logs where a level label is specified, we use the value of the label to determine the log level and update color accordingly. If the log doesn't have a level label specified, we try to find out if its content matches any of the supported expressions (see below for more information). The log level is always determined by the first match. In case Grafana is not able to determine a log level, it will be visualized with an unknown log level. + +> **Tip:** If you use Loki data source and the "level" is in you log content, try to use parsers (JSON, logfmt, regex,..) to extract level information into level label that is used to determine log level. **Supported log levels and mapping of log level abbreviation and expressions:** diff --git a/docs/sources/visualizations/logs-panel.md b/docs/sources/visualizations/logs-panel.md index 179a25e8fbb..e16afd9d692 100644 --- a/docs/sources/visualizations/logs-panel.md +++ b/docs/sources/visualizations/logs-panel.md @@ -17,7 +17,7 @@ To limit the number of lines rendered, you can use the **Max data points** setti ## Log level -For logs where a **level** label is specified, we use the value of the label to determine the log level and update color accordingly. If the log doesn't have a level label specified, we parse the log to find out if its content matches any of the supported expressions (see below for more information). The log level is always determined by the first match. In case Grafana is not able to determine a log level, it will be visualized with **unknown** log level. See [supported log levels and mappings of log level abbreviation and expressions]({{< relref "../explore/_index.md#log-level" >}}). +For logs where a **level** label is specified, we use the value of the label to determine the log level and update color accordingly. If the log doesn't have a level label specified, we try to find out if its content matches any of the supported expressions (see below for more information). The log level is always determined by the first match. In case Grafana is not able to determine a log level, it will be visualized with **unknown** log level. See [supported log levels and mappings of log level abbreviation and expressions]({{< relref "../explore/_index.md#log-level" >}}). ## Log details From 7bc12629fd7635d2a64e8d4648108306f6c8a6ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 17 Feb 2022 11:41:05 +0100 Subject: [PATCH 030/104] Prometheus: Fixing state timing issues (#45458) --- .../components/PromQueryBuilderContainer.tsx | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx index 5df01f76347..6d9bbe328d5 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx @@ -1,5 +1,6 @@ import { PanelData } from '@grafana/data'; -import React, { useEffect, useState } from 'react'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import React, { useEffect, useReducer } from 'react'; import { PrometheusDatasource } from '../../datasource'; import { PromQuery } from '../../types'; @@ -27,20 +28,17 @@ export interface State { */ export function PromQueryBuilderContainer(props: Props) { const { query, onChange, onRunQuery, datasource, data } = props; - const [state, setState] = useState({ expr: query.expr }); + const [state, dispatch] = useReducer(stateSlice.reducer, { expr: query.expr }); // Only rebuild visual query if expr changes from outside useEffect(() => { - if (!state.visQuery || state.expr !== query.expr) { - const result = buildVisualQueryFromString(query.expr || ''); - setState({ visQuery: result.query, expr: query.expr }); - } - }, [query.expr, state.visQuery, state.expr]); + dispatch(exprChanged(query.expr)); + }, [query.expr]); - const onVisQueryChange = (newVisQuery: PromVisualQuery) => { - const rendered = promQueryModeller.renderQuery(newVisQuery); - onChange({ ...query, expr: rendered }); - setState({ visQuery: newVisQuery, expr: rendered }); + const onVisQueryChange = (visQuery: PromVisualQuery) => { + const expr = promQueryModeller.renderQuery(visQuery); + dispatch(visualQueryChange({ visQuery, expr })); + onChange({ ...props.query, expr: expr }); }; if (!state.visQuery) { @@ -60,3 +58,23 @@ export function PromQueryBuilderContainer(props: Props) { ); } + +const stateSlice = createSlice({ + name: 'prom-builder-container', + initialState: { expr: '' } as State, + reducers: { + visualQueryChange: (state, action: PayloadAction<{ visQuery: PromVisualQuery; expr: string }>) => { + state.expr = action.payload.expr; + state.visQuery = action.payload.visQuery; + }, + exprChanged: (state, action: PayloadAction) => { + if (!state.visQuery || state.expr !== action.payload) { + state.expr = action.payload; + const parseResult = buildVisualQueryFromString(action.payload); + state.visQuery = parseResult.query; + } + }, + }, +}); + +const { visualQueryChange, exprChanged } = stateSlice.actions; From 52d4e2ddcc4760353994cb4a4292d40457b020ef Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 17 Feb 2022 11:47:07 +0000 Subject: [PATCH 031/104] Update dependency axios to v0.26.0 (#45355) Co-authored-by: Renovate Bot --- package.json | 2 +- yarn.lock | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index b81fbf52e43..a3eae0a26d0 100644 --- a/package.json +++ b/package.json @@ -158,7 +158,7 @@ "@typescript-eslint/parser": "5.11.0", "@wojtekmaj/enzyme-adapter-react-17": "0.6.6", "autoprefixer": "10.4.2", - "axios": "0.25.0", + "axios": "0.26.0", "babel-jest": "27.5.0", "babel-loader": "8.2.3", "babel-plugin-angularjs-annotate": "0.10.0", diff --git a/yarn.lock b/yarn.lock index edcee522128..ac88ce5bfe7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13123,12 +13123,12 @@ __metadata: languageName: node linkType: hard -"axios@npm:0.25.0": - version: 0.25.0 - resolution: "axios@npm:0.25.0" +"axios@npm:0.26.0": + version: 0.26.0 + resolution: "axios@npm:0.26.0" dependencies: - follow-redirects: ^1.14.7 - checksum: 2a8a3787c05f2a0c9c3878f49782357e2a9f38945b93018fb0c4fd788171c43dceefbb577988628e09fea53952744d1ecebde234b561f1e703aa43e0a598a3ad + follow-redirects: ^1.14.8 + checksum: d7a8b898f4157bedeb2e06c03b16133b91b354c041205bea732ce58b7a21f373d22057b0eea0d482838145ce6ff482b359750d9bcb8dd19d45e3928e3c65c280 languageName: node linkType: hard @@ -19328,13 +19328,13 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.14.7": - version: 1.14.7 - resolution: "follow-redirects@npm:1.14.7" +"follow-redirects@npm:^1.14.8": + version: 1.14.8 + resolution: "follow-redirects@npm:1.14.8" peerDependenciesMeta: debug: optional: true - checksum: f6d03e5e30877431065bca0d1b2e3db93949eb799d368a5c07ea8a4b71205f0349a3f8f0191bf13a07c93885522834dca1dc8e527dc99a772c6911fba24edc5f + checksum: 40c67899c2e3149a27e8b6498a338ff27f39fe138fde8d7f0756cb44b073ba0bfec3d52af28f20c5bdd67263d564d0d8d7b5efefd431de95c18c42f7b4aef457 languageName: node linkType: hard @@ -20441,7 +20441,7 @@ __metadata: angular-sanitize: 1.8.2 app: "link:./public/app" autoprefixer: 10.4.2 - axios: 0.25.0 + axios: 0.26.0 babel-jest: 27.5.0 babel-loader: 8.2.3 babel-plugin-angularjs-annotate: 0.10.0 From 0502a84922853d9a5022b10d856dc094212a3f70 Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Thu, 17 Feb 2022 12:47:38 +0100 Subject: [PATCH 032/104] Alerting: Add choice to external alertmanagers (#45157) * implement alertmanagersChoice * return empty array and remove non null assertion --- .../alerting/unified/api/alertmanager.ts | 9 +- .../components/admin/AddAlertManagerModal.tsx | 8 +- .../admin/ExternalAlertmanagers.tsx | 162 ++++++++++++------ .../unified/hooks/useExternalAmSelector.ts | 2 +- .../alerting/unified/state/actions.ts | 7 +- .../plugins/datasource/alertmanager/types.ts | 6 + 6 files changed, 128 insertions(+), 66 deletions(-) diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts index 25cd0320f56..9986590701c 100644 --- a/public/app/features/alerting/unified/api/alertmanager.ts +++ b/public/app/features/alerting/unified/api/alertmanager.ts @@ -13,6 +13,7 @@ import { TestReceiversAlert, TestReceiversPayload, TestReceiversResult, + ExternalAlertmanagerConfig, } from 'app/plugins/datasource/alertmanager/types'; import { lastValueFrom } from 'rxjs'; import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; @@ -222,11 +223,11 @@ function getReceiverResultError(receiversResult: TestReceiversResult) { .join('; '); } -export async function addAlertManagers(alertManagers: string[]): Promise { +export async function addAlertManagers(alertManagerConfig: ExternalAlertmanagerConfig): Promise { await lastValueFrom( getBackendSrv().fetch({ method: 'POST', - data: { alertmanagers: alertManagers }, + data: alertManagerConfig, url: '/api/v1/ngalert/admin_config', showErrorAlert: false, showSuccessAlert: false, @@ -247,9 +248,9 @@ export async function fetchExternalAlertmanagers(): Promise { +export async function fetchExternalAlertmanagerConfig(): Promise { const result = await lastValueFrom( - getBackendSrv().fetch<{ alertmanagers: string[] }>({ + getBackendSrv().fetch({ method: 'GET', url: '/api/v1/ngalert/admin_config', showErrorAlert: false, diff --git a/public/app/features/alerting/unified/components/admin/AddAlertManagerModal.tsx b/public/app/features/alerting/unified/components/admin/AddAlertManagerModal.tsx index 82b258803bb..aabb017f588 100644 --- a/public/app/features/alerting/unified/components/admin/AddAlertManagerModal.tsx +++ b/public/app/features/alerting/unified/components/admin/AddAlertManagerModal.tsx @@ -1,19 +1,17 @@ import React, { FC, useMemo } from 'react'; import { css, cx } from '@emotion/css'; -import { useDispatch } from 'react-redux'; import { GrafanaTheme2 } from '@grafana/data'; import { Button, Field, FieldArray, Form, Icon, Input, Modal, useStyles2 } from '@grafana/ui'; -import { addExternalAlertmanagersAction } from '../../state/actions'; import { AlertmanagerUrl } from 'app/plugins/datasource/alertmanager/types'; interface Props { onClose: () => void; alertmanagers: AlertmanagerUrl[]; + onChangeAlertmanagerConfig: (alertmanagers: string[]) => void; } -export const AddAlertManagerModal: FC = ({ alertmanagers, onClose }) => { +export const AddAlertManagerModal: FC = ({ alertmanagers, onChangeAlertmanagerConfig, onClose }) => { const styles = useStyles2(getStyles); - const dispatch = useDispatch(); const defaultValues: Record = useMemo( () => ({ alertmanagers: alertmanagers, @@ -29,7 +27,7 @@ export const AddAlertManagerModal: FC = ({ alertmanagers, onClose }) => { ); const onSubmit = (values: Record) => { - dispatch(addExternalAlertmanagersAction(values.alertmanagers.map((am) => cleanAlertmanagerUrl(am.url)))); + onChangeAlertmanagerConfig(values.alertmanagers.map((am) => cleanAlertmanagerUrl(am.url))); onClose(); }; diff --git a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx index 9bb29cac46d..efcbccf9ab6 100644 --- a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx +++ b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx @@ -1,8 +1,18 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { css } from '@emotion/css'; +import { useDispatch, useSelector } from 'react-redux'; +import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -import { Button, ConfirmModal, HorizontalGroup, Icon, Tooltip, useStyles2 } from '@grafana/ui'; +import { + Button, + ConfirmModal, + Field, + HorizontalGroup, + Icon, + RadioButtonGroup, + Tooltip, + useStyles2, + useTheme2, +} from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import { AddAlertManagerModal } from './AddAlertManagerModal'; import { @@ -11,13 +21,25 @@ import { fetchExternalAlertmanagersConfigAction, } from '../../state/actions'; import { useExternalAmSelector } from '../../hooks/useExternalAmSelector'; +import { StoreState } from 'app/types/store'; + +const alertmanagerChoices = [ + { value: 'internal', label: 'Only Internal' }, + { value: 'external', label: 'Only External' }, + { value: 'all', label: 'Both internal and external' }, +]; export const ExternalAlertmanagers = () => { const styles = useStyles2(getStyles); const dispatch = useDispatch(); const [modalState, setModalState] = useState({ open: false, payload: [{ url: '' }] }); const [deleteModalState, setDeleteModalState] = useState({ open: false, index: 0 }); + const externalAlertManagers = useExternalAmSelector(); + const alertmanagersChoice = useSelector( + (state: StoreState) => state.unifiedAlerting.externalAlertmanagers.alertmanagerConfig.result?.alertmanagersChoice + ); + const theme = useTheme2(); useEffect(() => { dispatch(fetchExternalAlertmanagersAction()); @@ -37,10 +59,12 @@ export const ExternalAlertmanagers = () => { .map((am) => { return am.url; }); - dispatch(addExternalAlertmanagersAction(newList)); + dispatch( + addExternalAlertmanagersAction({ alertmanagers: newList, alertmanagersChoice: alertmanagersChoice ?? 'all' }) + ); setDeleteModalState({ open: false, index: 0 }); }, - [externalAlertManagers, dispatch] + [externalAlertManagers, dispatch, alertmanagersChoice] ); const onEdit = useCallback(() => { @@ -70,16 +94,26 @@ export const ExternalAlertmanagers = () => { })); }, [setModalState]); + const onChangeAlertmanagerChoice = (alertmanagersChoice: string) => { + dispatch( + addExternalAlertmanagersAction({ alertmanagers: externalAlertManagers.map((am) => am.url), alertmanagersChoice }) + ); + }; + + const onChangeAlertmanagers = (alertmanagers: string[]) => { + dispatch(addExternalAlertmanagersAction({ alertmanagers, alertmanagersChoice: alertmanagersChoice ?? 'all' })); + }; + const getStatusColor = (status: string) => { switch (status) { case 'active': - return 'green'; + return theme.colors.success.main; case 'pending': - return 'yellow'; + return theme.colors.warning.main; default: - return 'red'; + return theme.colors.error.main; } }; @@ -107,49 +141,63 @@ export const ExternalAlertmanagers = () => { buttonIcon="bell-slash" /> ) : ( - - - - - - - - - - {externalAlertManagers?.map((am, index) => { - return ( - - - - - - ); - })} - -
UrlStatusAction
- {am.url} - {am.actualUrl ? ( - - - - ) : null} - - - - - - - -
+ <> + + + + + + + + + + {externalAlertManagers?.map((am, index) => { + return ( + + + + + + ); + })} + +
UrlStatusAction
+ {am.url} + {am.actualUrl ? ( + + + + ) : null} + + + + + + + +
+
+ + onChangeAlertmanagerChoice(value!)} + /> + +
+ )} { onConfirm={() => onDelete(deleteModalState.index)} onDismiss={() => setDeleteModalState({ open: false, index: 0 })} /> - {modalState.open && } + {modalState.open && ( + + )} ); }; @@ -176,5 +230,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: flex; justify-content: flex-end; `, - table: css``, + table: css` + margin-bottom: ${theme.spacing(2)}; + `, }); diff --git a/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts b/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts index a4297f7b877..9bc63239fcb 100644 --- a/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts +++ b/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts @@ -4,7 +4,7 @@ import { StoreState } from '../../../../types'; const SUFFIX_REGEX = /\/api\/v[1|2]\/alerts/i; type AlertmanagerConfig = { url: string; status: string; actualUrl: string }; -export function useExternalAmSelector(): AlertmanagerConfig[] | undefined { +export function useExternalAmSelector(): AlertmanagerConfig[] | [] { const discoveredAlertmanagers = useSelector( (state: StoreState) => state.unifiedAlerting.externalAlertmanagers.discoveredAlertmanagers.result?.data ); diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index ead22ca396c..38e4a4fdb40 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -2,6 +2,7 @@ import { getBackendSrv, locationService } from '@grafana/runtime'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { AlertmanagerAlert, + ExternalAlertmanagerConfig, AlertManagerCortexConfig, AlertmanagerGroup, ExternalAlertmanagersResponse, @@ -122,7 +123,7 @@ export const fetchExternalAlertmanagersAction = createAsyncThunk( export const fetchExternalAlertmanagersConfigAction = createAsyncThunk( 'unifiedAlerting/fetchExternAlertmanagersConfig', - (): Promise<{ alertmanagers: string[] }> => { + (): Promise => { return withSerializedError(fetchExternalAlertmanagerConfig()); } ); @@ -808,11 +809,11 @@ export const updateLotexNamespaceAndGroupAction = createAsyncThunk( export const addExternalAlertmanagersAction = createAsyncThunk( 'unifiedAlerting/addExternalAlertmanagers', - async (alertManagerUrls: string[], thunkAPI): Promise => { + async (alertmanagerConfig: ExternalAlertmanagerConfig, thunkAPI): Promise => { return withAppEvents( withSerializedError( (async () => { - await addAlertManagers(alertManagerUrls); + await addAlertManagers(alertmanagerConfig); thunkAPI.dispatch(fetchExternalAlertmanagersConfigAction()); })() ), diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index ea3b649bb92..7c4f195e836 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -272,6 +272,12 @@ export interface ExternalAlertmanagersResponse { data: ExternalAlertmanagers; status: 'string'; } + +export interface ExternalAlertmanagerConfig { + alertmanagers: string[]; + alertmanagersChoice: string; +} + export enum AlertManagerImplementation { cortex = 'cortex', prometheus = 'prometheus', From 9275e889699ff0c14481fe3cf07d8c63b1ba8ff0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 17 Feb 2022 11:47:59 +0000 Subject: [PATCH 033/104] Update dependency @babel/plugin-proposal-object-rest-spread to v7.17.3 (#45451) Co-authored-by: Renovate Bot --- package.json | 2 +- yarn.lock | 29 ++++++++++++++++++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index a3eae0a26d0..172c24ecb10 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "devDependencies": { "@babel/core": "7.17.0", "@babel/plugin-proposal-nullish-coalescing-operator": "7.16.7", - "@babel/plugin-proposal-object-rest-spread": "7.16.7", + "@babel/plugin-proposal-object-rest-spread": "7.17.3", "@babel/plugin-proposal-optional-chaining": "7.16.7", "@babel/plugin-syntax-dynamic-import": "7.8.3", "@babel/plugin-transform-react-constant-elements": "7.16.7", diff --git a/yarn.lock b/yarn.lock index ac88ce5bfe7..b2501f2b85c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -64,7 +64,7 @@ __metadata: languageName: node linkType: hard -"@babel/compat-data@npm:^7.16.8": +"@babel/compat-data@npm:^7.16.8, @babel/compat-data@npm:^7.17.0": version: 7.17.0 resolution: "@babel/compat-data@npm:7.17.0" checksum: fe5afaf529d107a223cd5937dace248464b6df1e9f4ea4031a5723e9571b46a4db1c4ff226bac6351148b1bc02ba1b39cb142662cd235aa99c1dda77882f8c9d @@ -1343,18 +1343,18 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-proposal-object-rest-spread@npm:7.16.7, @babel/plugin-proposal-object-rest-spread@npm:^7.16.7": - version: 7.16.7 - resolution: "@babel/plugin-proposal-object-rest-spread@npm:7.16.7" +"@babel/plugin-proposal-object-rest-spread@npm:7.17.3": + version: 7.17.3 + resolution: "@babel/plugin-proposal-object-rest-spread@npm:7.17.3" dependencies: - "@babel/compat-data": ^7.16.4 + "@babel/compat-data": ^7.17.0 "@babel/helper-compilation-targets": ^7.16.7 "@babel/helper-plugin-utils": ^7.16.7 "@babel/plugin-syntax-object-rest-spread": ^7.8.3 "@babel/plugin-transform-parameters": ^7.16.7 peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 2d3740e4df6d3f51d57862100c45c000104571aa98b7f798fdfc05ae0c12b9e7cc9b55f4a28612d626e29f3369a1481a0ee8a0241b23508b9d3da00c55f99d41 + checksum: 02810f158db4aaf6883131621b5d2c7d901ea3c034df2c2b78663f8b26813795d78a346c37e56770a720c54773732fd1d7fe40947dbf11d1d8de0e9a38e856d3 languageName: node linkType: hard @@ -1373,6 +1373,21 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-proposal-object-rest-spread@npm:^7.16.7": + version: 7.16.7 + resolution: "@babel/plugin-proposal-object-rest-spread@npm:7.16.7" + dependencies: + "@babel/compat-data": ^7.16.4 + "@babel/helper-compilation-targets": ^7.16.7 + "@babel/helper-plugin-utils": ^7.16.7 + "@babel/plugin-syntax-object-rest-spread": ^7.8.3 + "@babel/plugin-transform-parameters": ^7.16.7 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 2d3740e4df6d3f51d57862100c45c000104571aa98b7f798fdfc05ae0c12b9e7cc9b55f4a28612d626e29f3369a1481a0ee8a0241b23508b9d3da00c55f99d41 + languageName: node + linkType: hard + "@babel/plugin-proposal-optional-catch-binding@npm:^7.13.8, @babel/plugin-proposal-optional-catch-binding@npm:^7.14.5": version: 7.14.5 resolution: "@babel/plugin-proposal-optional-catch-binding@npm:7.14.5" @@ -20309,7 +20324,7 @@ __metadata: dependencies: "@babel/core": 7.17.0 "@babel/plugin-proposal-nullish-coalescing-operator": 7.16.7 - "@babel/plugin-proposal-object-rest-spread": 7.16.7 + "@babel/plugin-proposal-object-rest-spread": 7.17.3 "@babel/plugin-proposal-optional-chaining": 7.16.7 "@babel/plugin-syntax-dynamic-import": 7.8.3 "@babel/plugin-transform-react-constant-elements": 7.16.7 From 8afd5d54f716b55edec207d3fe250c07233ae301 Mon Sep 17 00:00:00 2001 From: J Guerreiro Date: Thu, 17 Feb 2022 12:19:58 +0000 Subject: [PATCH 034/104] Service Account detailed edit (#45404) * ServiceAccounts: Update service account route * ServiceAccounts: Update service account tests * ServiceAccounts: remove extraneous file --- log | 0 pkg/services/serviceaccounts/api/api.go | 32 ++++- pkg/services/serviceaccounts/api/api_test.go | 129 ++++++++++++++++++ .../serviceaccounts/database/database.go | 60 +++++++- .../serviceaccounts/manager/service.go | 2 +- pkg/services/serviceaccounts/models.go | 8 +- .../serviceaccounts/serviceaccounts.go | 5 +- pkg/services/serviceaccounts/tests/common.go | 22 ++- 8 files changed, 250 insertions(+), 8 deletions(-) delete mode 100644 log diff --git a/log b/log deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index 3c5ade4d439..ce1818bcb81 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -71,6 +71,8 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints( api.RouterRegister.Group("/api/serviceaccounts", func(serviceAccountsRoute routing.RouteRegister) { serviceAccountsRoute.Get("/", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeAll)), routing.Wrap(api.ListServiceAccounts)) serviceAccountsRoute.Get("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeID)), routing.Wrap(api.RetrieveServiceAccount)) + serviceAccountsRoute.Patch("/:serviceAccountId", auth(middleware.ReqOrgAdmin, + accesscontrol.EvalPermission(serviceaccounts.ActionWrite, serviceaccounts.ScopeID)), routing.Wrap(api.updateServiceAccount)) serviceAccountsRoute.Delete("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionDelete, serviceaccounts.ScopeID)), routing.Wrap(api.DeleteServiceAccount)) serviceAccountsRoute.Post("/upgradeall", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionCreate)), routing.Wrap(api.UpgradeServiceAccounts)) serviceAccountsRoute.Post("/convert/:keyId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionCreate, serviceaccounts.ScopeID)), routing.Wrap(api.ConvertToServiceAccount)) @@ -86,7 +88,7 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints( // POST /api/serviceaccounts func (api *ServiceAccountsAPI) CreateServiceAccount(c *models.ReqContext) response.Response { - cmd := serviceaccounts.CreateServiceaccountForm{} + cmd := serviceaccounts.CreateServiceAccountForm{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "Bad request data", err) } @@ -189,3 +191,31 @@ func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) re } return response.JSON(http.StatusOK, serviceAccount) } + +func (api *ServiceAccountsAPI) updateServiceAccount(c *models.ReqContext) response.Response { + scopeID, err := strconv.ParseInt(web.Params(c.Req)[":serviceAccountId"], 10, 64) + if err != nil { + return response.Error(http.StatusBadRequest, "serviceAccountId is invalid", err) + } + + cmd := &serviceaccounts.UpdateServiceAccountForm{} + if err := web.Bind(c.Req, &cmd); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + + if cmd.Role != nil && !cmd.Role.IsValid() { + return response.Error(http.StatusBadRequest, "Invalid role specified", nil) + } + + resp, err := api.store.UpdateServiceAccount(c.Req.Context(), c.OrgId, scopeID, cmd) + if err != nil { + switch { + case errors.Is(err, serviceaccounts.ErrServiceAccountNotFound): + return response.Error(http.StatusNotFound, "Failed to retrieve service account", err) + default: + return response.Error(http.StatusInternalServerError, "Failed update service account", err) + } + } + + return response.JSON(http.StatusOK, resp) +} diff --git a/pkg/services/serviceaccounts/api/api_test.go b/pkg/services/serviceaccounts/api/api_test.go index 84172674f3e..b467ef6763b 100644 --- a/pkg/services/serviceaccounts/api/api_test.go +++ b/pkg/services/serviceaccounts/api/api_test.go @@ -1,9 +1,11 @@ package api import ( + "bytes" "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "testing" @@ -19,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/services/serviceaccounts/database" @@ -205,3 +208,129 @@ func TestServiceAccountsAPI_RetrieveServiceAccount(t *testing.T) { }) } } + +func newString(s string) *string { + return &s +} + +func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) { + store := sqlstore.InitTestDB(t) + svcmock := tests.ServiceAccountMock{} + type testUpdateSATestCase struct { + desc string + user *tests.TestUser + expectedCode int + acmock *accesscontrolmock.Mock + body *serviceaccounts.UpdateServiceAccountForm + Id int + } + + role := models.ROLE_ADMIN + var invalidRole models.RoleType = "InvalidRole" + testCases := []testUpdateSATestCase{ + { + desc: "should be ok to update serviceaccount with permissions", + user: &tests.TestUser{Login: "servicetest1@admin", IsServiceAccount: true, Role: "Editor", Name: "Unaltered"}, + body: &serviceaccounts.UpdateServiceAccountForm{Name: newString("New Name"), Role: &role}, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: serviceaccounts.ScopeAll}}, nil + }, + false, + ), + expectedCode: http.StatusOK, + }, + { + desc: "bad request when invalid role", + user: &tests.TestUser{Login: "servicetest3@admin", IsServiceAccount: true, Role: "Invalid", Name: "Unaltered"}, + body: &serviceaccounts.UpdateServiceAccountForm{Name: newString("NameB"), Role: &invalidRole}, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: serviceaccounts.ScopeAll}}, nil + }, + false, + ), + expectedCode: http.StatusBadRequest, + }, + { + desc: "should be forbidden to update serviceaccount if no permissions", + user: &tests.TestUser{Login: "servicetest2@admin", IsServiceAccount: true}, + body: nil, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{}, nil + }, + false, + ), + expectedCode: http.StatusForbidden, + }, + { + desc: "should be not found when the user doesnt exist", + user: nil, + body: nil, + Id: 12, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: serviceaccounts.ScopeAll}}, nil + }, + false, + ), + expectedCode: http.StatusNotFound, + }, + } + + var requestResponse = func(server *web.Mux, httpMethod, requestpath string, body io.Reader) *httptest.ResponseRecorder { + req, err := http.NewRequest(httpMethod, requestpath, body) + req.Header.Add("Content-Type", "application/json") + require.NoError(t, err) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, req) + return recorder + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + serviceAccountRequestScenario(t, http.MethodPatch, serviceaccountIDPath, tc.user, func(httpmethod string, endpoint string, user *tests.TestUser) { + scopeID := tc.Id + if tc.user != nil { + createdUser := tests.SetupUserServiceAccount(t, store, *tc.user) + scopeID = int(createdUser.Id) + } + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store) + + var rawBody io.Reader = http.NoBody + if tc.body != nil { + body, err := json.Marshal(tc.body) + require.NoError(t, err) + rawBody = bytes.NewReader(body) + } + + actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, scopeID), rawBody) + + actualCode := actual.Code + require.Equal(t, tc.expectedCode, actualCode) + + if actualCode == http.StatusOK { + actualBody := map[string]interface{}{} + err := json.Unmarshal(actual.Body.Bytes(), &actualBody) + require.NoError(t, err) + assert.Equal(t, scopeID, int(actualBody["id"].(float64))) + assert.Equal(t, string(*tc.body.Role), actualBody["role"].(string)) + assert.Equal(t, *tc.body.Name, actualBody["name"].(string)) + assert.Equal(t, tc.user.Login, actualBody["login"].(string)) + + // Ensure the user was updated in DB + query := models.GetOrgUsersQuery{UserID: int64(scopeID), OrgId: 1, IsServiceAccount: true} + err = store.GetOrgUsers(context.Background(), &query) + require.NoError(t, err) + require.Equal(t, *tc.body.Name, query.Result[0].Name) + require.Equal(t, string(*tc.body.Role), query.Result[0].Role) + } + }) + }) + } +} diff --git a/pkg/services/serviceaccounts/database/database.go b/pkg/services/serviceaccounts/database/database.go index e6f1191a584..35163803e48 100644 --- a/pkg/services/serviceaccounts/database/database.go +++ b/pkg/services/serviceaccounts/database/database.go @@ -25,7 +25,7 @@ func NewServiceAccountsStore(store *sqlstore.SQLStore) *ServiceAccountsStoreImpl } } -func (s *ServiceAccountsStoreImpl) CreateServiceAccount(ctx context.Context, sa *serviceaccounts.CreateServiceaccountForm) (saDTO *serviceaccounts.ServiceAccountDTO, err error) { +func (s *ServiceAccountsStoreImpl) CreateServiceAccount(ctx context.Context, sa *serviceaccounts.CreateServiceAccountForm) (saDTO *serviceaccounts.ServiceAccountDTO, err error) { // create a new service account - "user" with empty permissions generatedLogin := "Service-Account-" + uuid.New().String() cmd := models.CreateUserCommand{ @@ -185,6 +185,64 @@ func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, o return saProfile, err } +func (s *ServiceAccountsStoreImpl) UpdateServiceAccount(ctx context.Context, + orgID, serviceAccountID int64, + saForm *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) { + updatedUser := &models.OrgUserDTO{} + + err := s.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + query := models.GetOrgUsersQuery{UserID: serviceAccountID, OrgId: orgID, IsServiceAccount: true} + if err := s.sqlStore.GetOrgUsers(ctx, &query); err != nil { + return err + } + if len(query.Result) != 1 { + return serviceaccounts.ErrServiceAccountNotFound + } + + updatedUser = query.Result[0] + + if saForm.Name == nil && saForm.Role == nil { + return nil + } + + updateTime := time.Now() + if saForm.Role != nil { + var orgUser models.OrgUser + orgUser.Role = *saForm.Role + orgUser.Updated = updateTime + + if _, err := sess.ID(orgUser.Id).Update(&orgUser); err != nil { + return err + } + + updatedUser.Role = string(*saForm.Role) + } + + if saForm.Name != nil { + user := models.User{ + Name: *saForm.Name, + Updated: updateTime, + } + + if _, err := sess.ID(serviceAccountID).Update(&user); err != nil { + return err + } + + updatedUser.Name = *saForm.Name + } + + return nil + }) + + return &serviceaccounts.ServiceAccountDTO{ + Id: updatedUser.UserId, + Name: updatedUser.Name, + Login: updatedUser.Login, + Role: updatedUser.Role, + OrgId: updatedUser.OrgId, + }, err +} + func contains(s []int64, e int64) bool { for _, a := range s { if a == e { diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index 02a8b8f0154..e74aaa5a02c 100644 --- a/pkg/services/serviceaccounts/manager/service.go +++ b/pkg/services/serviceaccounts/manager/service.go @@ -49,7 +49,7 @@ func ProvideServiceAccountsService( return s, nil } -func (sa *ServiceAccountsService) CreateServiceAccount(ctx context.Context, saForm *serviceaccounts.CreateServiceaccountForm) (*serviceaccounts.ServiceAccountDTO, error) { +func (sa *ServiceAccountsService) CreateServiceAccount(ctx context.Context, saForm *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) { if !sa.features.IsEnabled(featuremgmt.FlagServiceAccounts) { sa.log.Debug(ServiceAccountFeatureToggleNotFound) return nil, nil diff --git a/pkg/services/serviceaccounts/models.go b/pkg/services/serviceaccounts/models.go index d5577e2ecc7..81ad0337b4d 100644 --- a/pkg/services/serviceaccounts/models.go +++ b/pkg/services/serviceaccounts/models.go @@ -3,6 +3,7 @@ package serviceaccounts import ( "time" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" ) @@ -22,7 +23,12 @@ type ServiceAccount struct { Id int64 } -type CreateServiceaccountForm struct { +type UpdateServiceAccountForm struct { + Name *string `json:"name"` + Role *models.RoleType `json:"role"` +} + +type CreateServiceAccountForm struct { OrgID int64 `json:"-"` Name string `json:"name" binding:"Required"` } diff --git a/pkg/services/serviceaccounts/serviceaccounts.go b/pkg/services/serviceaccounts/serviceaccounts.go index 80b2d258e1f..3aa6d895169 100644 --- a/pkg/services/serviceaccounts/serviceaccounts.go +++ b/pkg/services/serviceaccounts/serviceaccounts.go @@ -8,13 +8,14 @@ import ( // this should reflect the api type Service interface { - CreateServiceAccount(ctx context.Context, saForm *CreateServiceaccountForm) (*ServiceAccountDTO, error) + CreateServiceAccount(ctx context.Context, saForm *CreateServiceAccountForm) (*ServiceAccountDTO, error) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error } type Store interface { - CreateServiceAccount(ctx context.Context, saForm *CreateServiceaccountForm) (*ServiceAccountDTO, error) + CreateServiceAccount(ctx context.Context, saForm *CreateServiceAccountForm) (*ServiceAccountDTO, error) ListServiceAccounts(ctx context.Context, orgID, serviceAccountID int64) ([]*ServiceAccountDTO, error) + UpdateServiceAccount(ctx context.Context, orgID, serviceAccountID int64, saForm *UpdateServiceAccountForm) (*ServiceAccountDTO, error) RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*ServiceAccountProfileDTO, error) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error UpgradeServiceAccounts(ctx context.Context) error diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index 2262438e022..17e9f16d9b2 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -13,14 +13,23 @@ import ( ) type TestUser struct { + Name string + Role string Login string IsServiceAccount bool } func SetupUserServiceAccount(t *testing.T, sqlStore *sqlstore.SQLStore, testUser TestUser) *models.User { + role := string(models.ROLE_VIEWER) + if testUser.Role != "" { + role = testUser.Role + } + u1, err := sqlStore.CreateUser(context.Background(), models.CreateUserCommand{ Login: testUser.Login, IsServiceAccount: testUser.IsServiceAccount, + DefaultOrgRole: role, + Name: testUser.Name, }) require.NoError(t, err) return u1 @@ -29,7 +38,7 @@ func SetupUserServiceAccount(t *testing.T, sqlStore *sqlstore.SQLStore, testUser // create mock for serviceaccountservice type ServiceAccountMock struct{} -func (s *ServiceAccountMock) CreateServiceAccount(ctx context.Context, saForm *serviceaccounts.CreateServiceaccountForm) (*serviceaccounts.ServiceAccountDTO, error) { +func (s *ServiceAccountMock) CreateServiceAccount(ctx context.Context, saForm *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) { return nil, nil } @@ -65,13 +74,14 @@ type Calls struct { UpgradeServiceAccounts []interface{} ConvertServiceAccounts []interface{} ListTokens []interface{} + UpdateServiceAccount []interface{} } type ServiceAccountsStoreMock struct { Calls Calls } -func (s *ServiceAccountsStoreMock) CreateServiceAccount(ctx context.Context, cmd *serviceaccounts.CreateServiceaccountForm) (*serviceaccounts.ServiceAccountDTO, error) { +func (s *ServiceAccountsStoreMock) CreateServiceAccount(ctx context.Context, cmd *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) { // now we can test that the mock has these calls when we call the function s.Calls.CreateServiceAccount = append(s.Calls.CreateServiceAccount, []interface{}{ctx, cmd}) return nil, nil @@ -106,3 +116,11 @@ func (s *ServiceAccountsStoreMock) RetrieveServiceAccount(ctx context.Context, o s.Calls.RetrieveServiceAccount = append(s.Calls.RetrieveServiceAccount, []interface{}{ctx, orgID, serviceAccountID}) return nil, nil } + +func (s *ServiceAccountsStoreMock) UpdateServiceAccount(ctx context.Context, + orgID, serviceAccountID int64, + saForm *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) { + s.Calls.UpdateServiceAccount = append(s.Calls.UpdateServiceAccount, []interface{}{ctx, orgID, serviceAccountID, saForm}) + + return nil, nil +} From cdc08105c2f719120aa6bfa5e1bc7b68ee7cef2d Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Thu, 17 Feb 2022 14:03:45 +0100 Subject: [PATCH 035/104] Access control: Set default permissions for data sources when using access control (#45482) * Rename interfaces and use then with wire injection * Set default permissions when creating new data source --- pkg/api/common_test.go | 9 ++- pkg/api/http_server.go | 8 +-- pkg/api/pluginproxy/ds_proxy_test.go | 57 ++++++++++--------- pkg/api/team.go | 2 +- pkg/api/team_members.go | 9 ++- pkg/api/team_test.go | 3 +- pkg/server/wire.go | 2 - pkg/server/wireexts_oss.go | 2 + pkg/services/accesscontrol/accesscontrol.go | 7 ++- .../mock/permissions_services_mock.go | 27 +++++++++ .../accesscontrol/mock/service_mock.go | 40 +++++++++++++ .../ossaccesscontrol/ossaccesscontrol.go | 3 +- .../permissions_services.go} | 49 +++++++++++++--- .../resourcepermissions/service_mock.go | 40 ------------- .../datasources/service/datasource_service.go | 34 +++++++++-- .../service/datasource_service_test.go | 33 +++++------ pkg/tsdb/legacydata/service/service_test.go | 3 +- 17 files changed, 206 insertions(+), 122 deletions(-) create mode 100644 pkg/services/accesscontrol/mock/permissions_services_mock.go create mode 100644 pkg/services/accesscontrol/mock/service_mock.go rename pkg/services/accesscontrol/{resourceservices/resource_services.go => ossaccesscontrol/permissions_services.go} (57%) delete mode 100644 pkg/services/accesscontrol/resourcepermissions/service_mock.go diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index a4775311c80..f03eb0eb304 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -25,7 +25,6 @@ import ( acmiddleware "github.com/grafana/grafana/pkg/services/accesscontrol/middleware" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/resourceservices" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/dashboards" @@ -375,9 +374,9 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont acmock = acmock.WithDisabled() } hs.AccessControl = acmock - teamPermissionService, err := resourceservices.ProvideTeamPermissions(routeRegister, db, acmock, database.ProvideService(db)) + teamPermissionService, err := ossaccesscontrol.ProvideTeamPermissions(routeRegister, db, acmock, database.ProvideService(db)) require.NoError(t, err) - hs.TeamPermissionsService = teamPermissionService + hs.teamPermissionsService = teamPermissionService } else { ac := ossaccesscontrol.ProvideService(hs.Features, &usagestats.UsageStatsMock{T: t}, database.ProvideService(db), routing.NewRouteRegister()) @@ -387,9 +386,9 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont require.NoError(t, err) err = ac.RegisterFixedRoles() require.NoError(t, err) - teamPermissionService, err := resourceservices.ProvideTeamPermissions(routeRegister, db, ac, database.ProvideService(db)) + teamPermissionService, err := ossaccesscontrol.ProvideTeamPermissions(routeRegister, db, ac, database.ProvideService(db)) require.NoError(t, err) - hs.TeamPermissionsService = teamPermissionService + hs.teamPermissionsService = teamPermissionService } // Instantiate a new Server diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index b665b67f961..729bf865099 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -28,8 +28,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/plugincontext" "github.com/grafana/grafana/pkg/services/accesscontrol" acmiddleware "github.com/grafana/grafana/pkg/services/accesscontrol/middleware" - "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" - "github.com/grafana/grafana/pkg/services/accesscontrol/resourceservices" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/cleanup" "github.com/grafana/grafana/pkg/services/contexthandler" @@ -130,7 +128,7 @@ type HTTPServer struct { queryDataService *query.Service serviceAccountsService serviceaccounts.Service authInfoService login.AuthInfoService - TeamPermissionsService *resourcepermissions.Service + teamPermissionsService accesscontrol.PermissionsService NotificationService *notifications.NotificationService dashboardService dashboards.DashboardService dashboardProvisioningService dashboards.DashboardProvisioningService @@ -164,7 +162,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi pluginsUpdateChecker *updatechecker.PluginsService, searchUsersService searchusers.Service, dataSourcesService datasources.DataSourceService, secretsService secrets.Service, queryDataService *query.Service, ldapGroups ldap.Groups, teamGuardian teamguardian.TeamGuardian, serviceaccountsService serviceaccounts.Service, - authInfoService login.AuthInfoService, resourcePermissionServices *resourceservices.ResourceServices, + authInfoService login.AuthInfoService, permissionsServices accesscontrol.PermissionsServices, notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService, dashboardProvisioningService dashboards.DashboardProvisioningService, folderService dashboards.FolderService, datasourcePermissionsService DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService, @@ -228,12 +226,12 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi queryDataService: queryDataService, serviceAccountsService: serviceaccountsService, authInfoService: authInfoService, - TeamPermissionsService: resourcePermissionServices.GetTeamService(), NotificationService: notificationService, dashboardService: dashboardService, dashboardProvisioningService: dashboardProvisioningService, folderService: folderService, DatasourcePermissionsService: datasourcePermissionsService, + teamPermissionsService: permissionsServices.GetTeamService(), AlertNotificationService: alertNotificationService, DashboardsnapshotsService: dashboardsnapshotsService, } diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index bd1808aed6b..fb23163678d 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -21,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/plugins" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/fakes" @@ -129,7 +130,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -142,7 +143,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path and has dynamic url", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/common/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) proxy.matchedRoute = routes[3] @@ -154,7 +155,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path with no url", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) proxy.matchedRoute = routes[4] @@ -165,7 +166,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path and has dynamic body", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/body", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) proxy.matchedRoute = routes[5] @@ -179,7 +180,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("Validating request", func(t *testing.T) { t.Run("plugin route with valid role", func(t *testing.T) { ctx, _ := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) err = proxy.validateRequest() @@ -188,7 +189,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("plugin route with admin role and user is editor", func(t *testing.T) { ctx, _ := setUp() - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) err = proxy.validateRequest() @@ -198,7 +199,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("plugin route with admin role and user is admin", func(t *testing.T) { ctx, _ := setUp() ctx.SignedInUser.OrgRole = models.ROLE_ADMIN - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) err = proxy.validateRequest() @@ -289,7 +290,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { }, } - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg) @@ -305,7 +306,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { req, err := http.NewRequest("GET", "http://localhost/asd", nil) require.NoError(t, err) client = newFakeHTTPClient(t, json2) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken2", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[1], dsInfo, cfg) @@ -322,7 +323,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { require.NoError(t, err) client = newFakeHTTPClient(t, []byte{}) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg) @@ -344,7 +345,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { ctx := &models.ReqContext{} secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{BuildVersion: "5.3.0"}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -370,7 +371,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { ctx := &models.ReqContext{} var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -394,7 +395,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { ctx := &models.ReqContext{} var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -422,7 +423,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { ctx := &models.ReqContext{} var pluginRoutes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, pluginRoutes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -445,7 +446,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { ctx := &models.ReqContext{} var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -509,7 +510,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &mockAuthToken, dsService, tracer, secretsService) require.NoError(t, err) req, err = http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -642,7 +643,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { ctx, ds := setUp(t) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -660,7 +661,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { }) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -674,7 +675,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { ctx, ds := setUp(t) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -696,7 +697,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { }) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -721,7 +722,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { ctx.Req = httptest.NewRequest("GET", "/api/datasources/proxy/1/path/%2Ftest%2Ftest%2F?query=%2Ftest%2Ftest%2F", nil) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -745,7 +746,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { ctx.Req = httptest.NewRequest("GET", "/api/datasources/proxy/1/path/%2Ftest%2Ftest%2F?query=%2Ftest%2Ftest%2F", nil) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -770,7 +771,7 @@ func TestNewDataSourceProxy_InvalidURL(t *testing.T) { require.NoError(t, err) var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) _, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.Error(t, err) assert.True(t, strings.HasPrefix(err.Error(), `validation of data source URL "://host/root" failed`)) @@ -791,7 +792,7 @@ func TestNewDataSourceProxy_ProtocolLessURL(t *testing.T) { var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) _, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -834,7 +835,7 @@ func TestNewDataSourceProxy_MSSQL(t *testing.T) { var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) p, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) if tc.err == nil { require.NoError(t, err) @@ -861,7 +862,7 @@ func getDatasourceProxiedRequest(t *testing.T, ctx *models.ReqContext, cfg *sett var routes []*plugins.Route secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -985,7 +986,7 @@ func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, test *t require.NoError(t, err) var routes []*plugins.Route - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) @@ -1027,7 +1028,7 @@ func Test_PathCheck(t *testing.T) { } ctx, _ := setUp() secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) proxy, err := NewDataSourceProxy(&models.DataSource{}, routes, ctx, "b", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService) require.NoError(t, err) diff --git a/pkg/api/team.go b/pkg/api/team.go index be12efe992a..4f9a38ffb27 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -39,7 +39,7 @@ func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response { // the SignedInUser is an empty struct therefore // an additional check whether it is an actual user is required if c.SignedInUser.IsRealUser() { - if err := addOrUpdateTeamMember(c.Req.Context(), hs.TeamPermissionsService, c.SignedInUser.UserId, c.OrgId, team.Id, models.PERMISSION_ADMIN.String()); err != nil { + if err := addOrUpdateTeamMember(c.Req.Context(), hs.teamPermissionsService, c.SignedInUser.UserId, c.OrgId, team.Id, models.PERMISSION_ADMIN.String()); err != nil { c.Logger.Error("Could not add creator to team", "error", err) } } else { diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index e03170099a6..e39028872e8 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -85,7 +84,7 @@ func (hs *HTTPServer) AddTeamMember(c *models.ReqContext) response.Response { return response.Error(400, "User is already added to this team", nil) } - err = addOrUpdateTeamMember(c.Req.Context(), hs.TeamPermissionsService, cmd.UserId, cmd.OrgId, cmd.TeamId, getPermissionName(cmd.Permission)) + err = addOrUpdateTeamMember(c.Req.Context(), hs.teamPermissionsService, cmd.UserId, cmd.OrgId, cmd.TeamId, getPermissionName(cmd.Permission)) if err != nil { return response.Error(500, "Failed to add Member to Team", err) } @@ -125,7 +124,7 @@ func (hs *HTTPServer) UpdateTeamMember(c *models.ReqContext) response.Response { return response.Error(404, "Team member not found.", nil) } - err = addOrUpdateTeamMember(c.Req.Context(), hs.TeamPermissionsService, userId, orgId, teamId, getPermissionName(cmd.Permission)) + err = addOrUpdateTeamMember(c.Req.Context(), hs.teamPermissionsService, userId, orgId, teamId, getPermissionName(cmd.Permission)) if err != nil { return response.Error(500, "Failed to update team member.", err) } @@ -161,7 +160,7 @@ func (hs *HTTPServer) RemoveTeamMember(c *models.ReqContext) response.Response { } teamIDString := strconv.FormatInt(teamId, 10) - if _, err := hs.TeamPermissionsService.SetUserPermission(c.Req.Context(), orgId, accesscontrol.User{ID: userId}, teamIDString, ""); err != nil { + if _, err := hs.teamPermissionsService.SetUserPermission(c.Req.Context(), orgId, accesscontrol.User{ID: userId}, teamIDString, ""); err != nil { if errors.Is(err, models.ErrTeamNotFound) { return response.Error(404, "Team not found", nil) } @@ -178,7 +177,7 @@ func (hs *HTTPServer) RemoveTeamMember(c *models.ReqContext) response.Response { // addOrUpdateTeamMember adds or updates a team member. // // Stubbable by tests. -var addOrUpdateTeamMember = func(ctx context.Context, resourcePermissionService *resourcepermissions.Service, userID, orgID, teamID int64, permission string) error { +var addOrUpdateTeamMember = func(ctx context.Context, resourcePermissionService accesscontrol.PermissionsService, userID, orgID, teamID int64, permission string) error { teamIDString := strconv.FormatInt(teamID, 10) if _, err := resourcePermissionService.SetUserPermission(ctx, orgID, accesscontrol.User{ID: userID}, teamIDString, permission); err != nil { return fmt.Errorf("failed setting permissions for user %d in team %d: %w", userID, teamID, err) diff --git a/pkg/api/team_test.go b/pkg/api/team_test.go index 4246cafa1e9..d2995504d5f 100644 --- a/pkg/api/team_test.go +++ b/pkg/api/team_test.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" @@ -112,7 +111,7 @@ func TestTeamAPIEndpoint(t *testing.T) { teamName := "team foo" addTeamMemberCalled := 0 - addOrUpdateTeamMember = func(ctx context.Context, resourcePermissionService *resourcepermissions.Service, userID, orgID, teamID int64, + addOrUpdateTeamMember = func(ctx context.Context, resourcePermissionService accesscontrol.PermissionsService, userID, orgID, teamID int64, permission string) error { addTeamMemberCalled++ return nil diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 5d1b5cd5ad3..8f57ceb3a29 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -27,7 +27,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager" "github.com/grafana/grafana/pkg/plugins/manager/loader" "github.com/grafana/grafana/pkg/plugins/plugincontext" - "github.com/grafana/grafana/pkg/services/accesscontrol/resourceservices" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/cleanup" @@ -202,7 +201,6 @@ var wireBasicSet = wire.NewSet( wire.Bind(new(teamguardian.TeamGuardian), new(*teamguardianManager.Service)), featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, - resourceservices.ProvideResourceServices, dashboardservice.ProvideDashboardService, dashboardservice.ProvideFolderService, dashboardstore.ProvideDashboardStore, diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index 6c694411593..292b666c3f5 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -77,6 +77,8 @@ var wireExtsBasicSet = wire.NewSet( wire.Bind(new(ldap.Groups), new(*ldap.OSSGroups)), api.ProvideDatasourcePermissionsService, wire.Bind(new(api.DatasourcePermissionsService), new(*api.OSSDatasourcePermissionsService)), + ossaccesscontrol.ProvidePermissionsServices, + wire.Bind(new(accesscontrol.PermissionsServices), new(*ossaccesscontrol.PermissionsService)), ) var wireExtsSet = wire.NewSet( diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index c05280f5693..b2deeb36845 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -37,7 +37,12 @@ type PermissionsProvider interface { GetUserPermissions(ctx context.Context, query GetUserPermissionsQuery) ([]*Permission, error) } -type ResourcePermissionsService interface { +type PermissionsServices interface { + GetTeamService() PermissionsService + GetDataSourceService() PermissionsService +} + +type PermissionsService interface { // GetPermissions returns all permissions for given resourceID GetPermissions(ctx context.Context, orgID int64, resourceID string) ([]ResourcePermission, error) // SetUserPermission sets permission on resource for a user diff --git a/pkg/services/accesscontrol/mock/permissions_services_mock.go b/pkg/services/accesscontrol/mock/permissions_services_mock.go new file mode 100644 index 00000000000..b5d084bdce9 --- /dev/null +++ b/pkg/services/accesscontrol/mock/permissions_services_mock.go @@ -0,0 +1,27 @@ +package mock + +import ( + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +var _ accesscontrol.PermissionsServices = new(PermissionsServicesMock) + +func NewPermissionsServicesMock() *PermissionsServicesMock { + return &PermissionsServicesMock{ + teams: &MockPermissionsService{}, + datasources: &MockPermissionsService{}, + } +} + +type PermissionsServicesMock struct { + teams *MockPermissionsService + datasources *MockPermissionsService +} + +func (p PermissionsServicesMock) GetTeamService() accesscontrol.PermissionsService { + return p.teams +} + +func (p PermissionsServicesMock) GetDataSourceService() accesscontrol.PermissionsService { + return p.datasources +} diff --git a/pkg/services/accesscontrol/mock/service_mock.go b/pkg/services/accesscontrol/mock/service_mock.go new file mode 100644 index 00000000000..c5f57caf056 --- /dev/null +++ b/pkg/services/accesscontrol/mock/service_mock.go @@ -0,0 +1,40 @@ +package mock + +import ( + "context" + + "github.com/stretchr/testify/mock" + + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +var _ accesscontrol.PermissionsService = new(MockPermissionsService) + +type MockPermissionsService struct { + mock.Mock +} + +func (m *MockPermissionsService) GetPermissions(ctx context.Context, orgID int64, resourceID string) ([]accesscontrol.ResourcePermission, error) { + mockedArgs := m.Called(ctx, orgID, resourceID) + return mockedArgs.Get(0).([]accesscontrol.ResourcePermission), mockedArgs.Error(1) +} + +func (m *MockPermissionsService) SetUserPermission(ctx context.Context, orgID int64, user accesscontrol.User, resourceID, permission string) (*accesscontrol.ResourcePermission, error) { + mockedArgs := m.Called(ctx, orgID, user, resourceID, permission) + return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1) +} + +func (m *MockPermissionsService) SetTeamPermission(ctx context.Context, orgID, teamID int64, resourceID, permission string) (*accesscontrol.ResourcePermission, error) { + mockedArgs := m.Called(ctx, orgID, teamID, resourceID, permission) + return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1) +} + +func (m *MockPermissionsService) SetBuiltInRolePermission(ctx context.Context, orgID int64, builtInRole, resourceID, permission string) (*accesscontrol.ResourcePermission, error) { + mockedArgs := m.Called(ctx, orgID, builtInRole, resourceID, permission) + return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1) +} + +func (m *MockPermissionsService) SetPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) { + mockedArgs := m.Called(ctx, orgID, resourceID, commands) + return mockedArgs.Get(0).([]accesscontrol.ResourcePermission), mockedArgs.Error(1) +} diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go index efe6dc98118..51a928495c7 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/api" - "github.com/grafana/grafana/pkg/services/accesscontrol/resourceservices" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/prometheus/client_golang/prometheus" ) @@ -116,7 +115,7 @@ func (ac *OSSAccessControlService) GetUserPermissions(ctx context.Context, user OrgID: user.OrgId, UserID: user.UserId, Roles: ac.GetUserBuiltInRoles(user), - Actions: resourceservices.TeamAdminActions, + Actions: TeamAdminActions, }) if err != nil { return nil, err diff --git a/pkg/services/accesscontrol/resourceservices/resource_services.go b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go similarity index 57% rename from pkg/services/accesscontrol/resourceservices/resource_services.go rename to pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go index 3cf2adfb95f..578819c5cfa 100644 --- a/pkg/services/accesscontrol/resourceservices/resource_services.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go @@ -1,4 +1,4 @@ -package resourceservices +package ossaccesscontrol import ( "context" @@ -12,23 +12,26 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" ) -func ProvideResourceServices(router routing.RouteRegister, sql *sqlstore.SQLStore, ac accesscontrol.AccessControl, store resourcepermissions.Store) (*ResourceServices, error) { +func ProvidePermissionsServices(router routing.RouteRegister, sql *sqlstore.SQLStore, ac accesscontrol.AccessControl, store resourcepermissions.Store) (*PermissionsService, error) { teamPermissions, err := ProvideTeamPermissions(router, sql, ac, store) if err != nil { return nil, err } - return &ResourceServices{services: map[string]*resourcepermissions.Service{ - "teams": teamPermissions, - }}, nil + return &PermissionsService{teams: teamPermissions, datasources: provideEmptyPermissionsService()}, nil } -type ResourceServices struct { - services map[string]*resourcepermissions.Service +type PermissionsService struct { + teams accesscontrol.PermissionsService + datasources accesscontrol.PermissionsService } -func (s *ResourceServices) GetTeamService() *resourcepermissions.Service { - return s.services["teams"] +func (s *PermissionsService) GetTeamService() accesscontrol.PermissionsService { + return s.teams +} + +func (s *PermissionsService) GetDataSourceService() accesscontrol.PermissionsService { + return s.datasources } var ( @@ -101,3 +104,31 @@ func ProvideTeamPermissions(router routing.RouteRegister, sql *sqlstore.SQLStore return resourcepermissions.New(options, router, ac, store, sql) } + +func provideEmptyPermissionsService() accesscontrol.PermissionsService { + return &emptyPermissionsService{} +} + +var _ accesscontrol.PermissionsService = new(emptyPermissionsService) + +type emptyPermissionsService struct{} + +func (e emptyPermissionsService) GetPermissions(ctx context.Context, orgID int64, resourceID string) ([]accesscontrol.ResourcePermission, error) { + return nil, nil +} + +func (e emptyPermissionsService) SetUserPermission(ctx context.Context, orgID int64, user accesscontrol.User, resourceID, permission string) (*accesscontrol.ResourcePermission, error) { + return nil, nil +} + +func (e emptyPermissionsService) SetTeamPermission(ctx context.Context, orgID, teamID int64, resourceID, permission string) (*accesscontrol.ResourcePermission, error) { + return nil, nil +} + +func (e emptyPermissionsService) SetBuiltInRolePermission(ctx context.Context, orgID int64, builtInRole string, resourceID string, permission string) (*accesscontrol.ResourcePermission, error) { + return nil, nil +} + +func (e emptyPermissionsService) SetPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) { + return nil, nil +} diff --git a/pkg/services/accesscontrol/resourcepermissions/service_mock.go b/pkg/services/accesscontrol/resourcepermissions/service_mock.go deleted file mode 100644 index 41f78f4efcb..00000000000 --- a/pkg/services/accesscontrol/resourcepermissions/service_mock.go +++ /dev/null @@ -1,40 +0,0 @@ -package resourcepermissions - -import ( - "context" - - "github.com/stretchr/testify/mock" - - "github.com/grafana/grafana/pkg/services/accesscontrol" -) - -var _ accesscontrol.ResourcePermissionsService = new(MockService) - -type MockService struct { - mock.Mock -} - -func (m *MockService) GetPermissions(ctx context.Context, orgID int64, resourceID string) ([]accesscontrol.ResourcePermission, error) { - mockedArgs := m.Called(ctx, orgID, resourceID) - return mockedArgs.Get(0).([]accesscontrol.ResourcePermission), mockedArgs.Error(1) -} - -func (m *MockService) SetUserPermission(ctx context.Context, orgID int64, user accesscontrol.User, resourceID, permission string) (*accesscontrol.ResourcePermission, error) { - mockedArgs := m.Called(ctx, orgID, user, resourceID, permission) - return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1) -} - -func (m *MockService) SetTeamPermission(ctx context.Context, orgID, teamID int64, resourceID, permission string) (*accesscontrol.ResourcePermission, error) { - mockedArgs := m.Called(ctx, orgID, teamID, resourceID, permission) - return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1) -} - -func (m *MockService) SetBuiltInRolePermission(ctx context.Context, orgID int64, builtInRole, resourceID, permission string) (*accesscontrol.ResourcePermission, error) { - mockedArgs := m.Called(ctx, orgID, builtInRole, resourceID, permission) - return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1) -} - -func (m *MockService) SetPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) { - mockedArgs := m.Called(ctx, orgID, resourceID, commands) - return mockedArgs.Get(0).([]accesscontrol.ResourcePermission), mockedArgs.Error(1) -} diff --git a/pkg/services/datasources/service/datasource_service.go b/pkg/services/datasources/service/datasource_service.go index f25fb98ebe4..9f5e1cd273b 100644 --- a/pkg/services/datasources/service/datasource_service.go +++ b/pkg/services/datasources/service/datasource_service.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" @@ -23,9 +24,11 @@ import ( ) type Service struct { - Bus bus.Bus - SQLStore *sqlstore.SQLStore - SecretsService secrets.Service + Bus bus.Bus + SQLStore *sqlstore.SQLStore + SecretsService secrets.Service + features featuremgmt.FeatureToggles + permissionsService accesscontrol.PermissionsService ptc proxyTransportCache dsDecryptionCache secureJSONDecryptionCache @@ -51,7 +54,10 @@ type cachedDecryptedJSON struct { json map[string]string } -func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, secretsService secrets.Service, ac accesscontrol.AccessControl) *Service { +func ProvideService( + bus bus.Bus, store *sqlstore.SQLStore, secretsService secrets.Service, features featuremgmt.FeatureToggles, + ac accesscontrol.AccessControl, permissionsServices accesscontrol.PermissionsServices, +) *Service { s := &Service{ Bus: bus, SQLStore: store, @@ -62,6 +68,8 @@ func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, secretsService secret dsDecryptionCache: secureJSONDecryptionCache{ cache: make(map[int64]cachedDecryptedJSON), }, + features: features, + permissionsService: permissionsServices.GetDataSourceService(), } s.Bus.AddHandler(s.GetDataSources) @@ -128,7 +136,23 @@ func (s *Service) AddDataSource(ctx context.Context, cmd *models.AddDataSourceCo return err } - return s.SQLStore.AddDataSource(ctx, cmd) + if err := s.SQLStore.AddDataSource(ctx, cmd); err != nil { + return err + } + + if s.features.IsEnabled(featuremgmt.FlagAccesscontrol) { + if _, err := s.permissionsService.SetPermissions(ctx, cmd.OrgId, strconv.FormatInt(cmd.Result.Id, 10), accesscontrol.SetResourcePermissionCommand{ + BuiltinRole: "Viewer", + Permission: "Query", + }, accesscontrol.SetResourcePermissionCommand{ + BuiltinRole: "Editor", + Permission: "Query", + }); err != nil { + return err + } + } + + return nil } func (s *Service) DeleteDataSource(ctx context.Context, cmd *models.DeleteDataSourceCommand) error { diff --git a/pkg/services/datasources/service/datasource_service_test.go b/pkg/services/datasources/service/datasource_service_test.go index 99ddcbf8c8a..ef9159dbd5d 100644 --- a/pkg/services/datasources/service/datasource_service_test.go +++ b/pkg/services/datasources/service/datasource_service_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/database" "github.com/grafana/grafana/pkg/services/secrets/fakes" @@ -36,7 +37,7 @@ func TestService(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) - s := ProvideService(bus.New(), sqlStore, secretsService, &acmock.Mock{}) + s := ProvideService(bus.New(), sqlStore, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) var ds *models.DataSource @@ -151,7 +152,7 @@ func TestService_GetHttpTransport(t *testing.T) { } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) rt1, err := dsService.GetHTTPTransport(&ds, provider) require.NoError(t, err) @@ -184,7 +185,7 @@ func TestService_GetHttpTransport(t *testing.T) { json.Set("tlsAuthWithCACert", true) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) tlsCaCert, err := secretsService.Encrypt(context.Background(), []byte(caCert), secrets.WithoutScope()) require.NoError(t, err) @@ -234,7 +235,7 @@ func TestService_GetHttpTransport(t *testing.T) { json.Set("tlsAuth", true) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) tlsClientCert, err := secretsService.Encrypt(context.Background(), []byte(clientCert), secrets.WithoutScope()) require.NoError(t, err) @@ -277,7 +278,7 @@ func TestService_GetHttpTransport(t *testing.T) { json.Set("serverName", "server-name") secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) tlsCaCert, err := secretsService.Encrypt(context.Background(), []byte(caCert), secrets.WithoutScope()) require.NoError(t, err) @@ -314,7 +315,7 @@ func TestService_GetHttpTransport(t *testing.T) { json.Set("tlsSkipVerify", true) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) ds := models.DataSource{ Id: 1, @@ -345,7 +346,7 @@ func TestService_GetHttpTransport(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) encryptedData, err := secretsService.Encrypt(context.Background(), []byte(`Bearer xf5yhfkpsnmgo`), secrets.WithoutScope()) require.NoError(t, err) @@ -404,7 +405,7 @@ func TestService_GetHttpTransport(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) ds := models.DataSource{ Id: 1, @@ -437,7 +438,7 @@ func TestService_GetHttpTransport(t *testing.T) { require.NoError(t, err) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) ds := models.DataSource{ Type: models.DS_ES, @@ -471,7 +472,7 @@ func TestService_getTimeout(t *testing.T) { } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) for _, tc := range testCases { ds := &models.DataSource{ @@ -484,7 +485,7 @@ func TestService_getTimeout(t *testing.T) { func TestService_DecryptedValue(t *testing.T) { t.Run("When datasource hasn't been updated, encrypted JSON should be fetched from cache", func(t *testing.T) { secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) encryptedJsonData, err := secretsService.EncryptJsonData( context.Background(), @@ -538,7 +539,7 @@ func TestService_DecryptedValue(t *testing.T) { SecureJsonData: encryptedJsonData, } - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) // Populate cache password, ok := dsService.DecryptedValue(&ds, "password") @@ -574,7 +575,7 @@ func TestService_HTTPClientOptions(t *testing.T) { t.Cleanup(func() { ds.JsonData = emptyJsonData; ds.SecureJsonData = emptySecureJsonData }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) opts, err := dsService.httpClientOptions(&ds) require.NoError(t, err) @@ -592,7 +593,7 @@ func TestService_HTTPClientOptions(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) opts, err := dsService.httpClientOptions(&ds) require.NoError(t, err) @@ -611,7 +612,7 @@ func TestService_HTTPClientOptions(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) _, err := dsService.httpClientOptions(&ds) assert.Error(t, err) @@ -625,7 +626,7 @@ func TestService_HTTPClientOptions(t *testing.T) { }) secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) opts, err := dsService.httpClientOptions(&ds) require.NoError(t, err) diff --git a/pkg/tsdb/legacydata/service/service_test.go b/pkg/tsdb/legacydata/service/service_test.go index ccd402b25c6..f0b626671b9 100644 --- a/pkg/tsdb/legacydata/service/service_test.go +++ b/pkg/tsdb/legacydata/service/service_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/plugins" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -36,7 +37,7 @@ func TestHandleRequest(t *testing.T) { return backend.NewQueryDataResponse(), nil } secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{}) + dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock()) s := ProvideService(client, nil, dsService) ds := &models.DataSource{Id: 12, Type: "unregisteredType", JsonData: simplejson.New()} From 47e248ceabfe4ba6770064d510261d2fc4741fea Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Thu, 17 Feb 2022 14:06:34 +0100 Subject: [PATCH 036/104] AccessControl: Remove permissions on data source delete (#45504) * AccessControl: Remove permissions on datasource delete * Ensure legacy behavior is preserved --- pkg/services/sqlstore/datasource.go | 90 +++++++++++++----------- pkg/services/sqlstore/datasource_test.go | 45 +++++++++++- 2 files changed, 92 insertions(+), 43 deletions(-) diff --git a/pkg/services/sqlstore/datasource.go b/pkg/services/sqlstore/datasource.go index 0ad11bb52f0..7f3f0cd3219 100644 --- a/pkg/services/sqlstore/datasource.go +++ b/pkg/services/sqlstore/datasource.go @@ -2,6 +2,7 @@ package sqlstore import ( "context" + "errors" "fmt" "strings" "time" @@ -10,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/models" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/util/errutil" "xorm.io/xorm" ) @@ -20,26 +22,30 @@ func (ss *SQLStore) GetDataSource(ctx context.Context, query *models.GetDataSour metrics.MDBDataSourceQueryByID.Inc() return ss.WithDbSession(ctx, func(sess *DBSession) error { - if query.OrgId == 0 || (query.Id == 0 && len(query.Name) == 0 && len(query.Uid) == 0) { - return models.ErrDataSourceIdentifierNotSet - } - - datasource := &models.DataSource{Name: query.Name, OrgId: query.OrgId, Id: query.Id, Uid: query.Uid} - has, err := sess.Get(datasource) - - if err != nil { - sqlog.Error("Failed getting data source", "err", err, "uid", query.Uid, "id", query.Id, "name", query.Name, "orgId", query.OrgId) - return err - } else if !has { - return models.ErrDataSourceNotFound - } - - query.Result = datasource - - return nil + return ss.getDataSource(ctx, query, sess) }) } +func (ss *SQLStore) getDataSource(ctx context.Context, query *models.GetDataSourceQuery, sess *DBSession) error { + if query.OrgId == 0 || (query.Id == 0 && len(query.Name) == 0 && len(query.Uid) == 0) { + return models.ErrDataSourceIdentifierNotSet + } + + datasource := &models.DataSource{Name: query.Name, OrgId: query.OrgId, Id: query.Id, Uid: query.Uid} + has, err := sess.Get(datasource) + + if err != nil { + sqlog.Error("Failed getting data source", "err", err, "uid", query.Uid, "id", query.Id, "name", query.Name, "orgId", query.OrgId) + return err + } else if !has { + return models.ErrDataSourceNotFound + } + + query.Result = datasource + + return nil +} + func (ss *SQLStore) GetDataSources(ctx context.Context, query *models.GetDataSourcesQuery) error { var sess *xorm.Session return ss.WithDbSession(ctx, func(dbSess *DBSession) error { @@ -82,32 +88,34 @@ func (ss *SQLStore) GetDefaultDataSource(ctx context.Context, query *models.GetD } // DeleteDataSource removes a datasource by org_id as well as either uid (preferred), id, or name -// and is added to the bus. +// and is added to the bus. It also removes permissions related to the team. func (ss *SQLStore) DeleteDataSource(ctx context.Context, cmd *models.DeleteDataSourceCommand) error { - params := make([]interface{}, 0) - - makeQuery := func(sql string, p ...interface{}) { - params = append(params, sql) - params = append(params, p...) - } - - switch { - case cmd.OrgID == 0: - return models.ErrDataSourceIdentifierNotSet - case cmd.UID != "": - makeQuery("DELETE FROM data_source WHERE uid=? and org_id=?", cmd.UID, cmd.OrgID) - case cmd.ID != 0: - makeQuery("DELETE FROM data_source WHERE id=? and org_id=?", cmd.ID, cmd.OrgID) - case cmd.Name != "": - makeQuery("DELETE FROM data_source WHERE name=? and org_id=?", cmd.Name, cmd.OrgID) - default: - return models.ErrDataSourceIdentifierNotSet - } - return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { - result, err := sess.Exec(params...) - cmd.DeletedDatasourcesCount, _ = result.RowsAffected() + dsQuery := &models.GetDataSourceQuery{Id: cmd.ID, Uid: cmd.UID, Name: cmd.Name, OrgId: cmd.OrgID} + errGettingDS := ss.getDataSource(ctx, dsQuery, sess) + if errGettingDS != nil && !errors.Is(errGettingDS, models.ErrDataSourceNotFound) { + return errGettingDS + } + + ds := dsQuery.Result + if ds != nil { + // Delete the data source + result, err := sess.Exec("DELETE FROM data_source WHERE org_id=? AND id=?", ds.OrgId, ds.Id) + if err != nil { + return err + } + + cmd.DeletedDatasourcesCount, _ = result.RowsAffected() + + // Remove associated AccessControl permissions + if _, errDeletingPerms := sess.Exec("DELETE FROM permission WHERE scope=?", + ac.Scope("datasources", "id", fmt.Sprint(dsQuery.Result.Id))); errDeletingPerms != nil { + return errDeletingPerms + } + } + + // Publish data source deletion event sess.publishAfterCommit(&events.DataSourceDeleted{ Timestamp: time.Now(), Name: cmd.Name, @@ -116,7 +124,7 @@ func (ss *SQLStore) DeleteDataSource(ctx context.Context, cmd *models.DeleteData OrgID: cmd.OrgID, }) - return err + return nil }) } diff --git a/pkg/services/sqlstore/datasource_test.go b/pkg/services/sqlstore/datasource_test.go index b53a7703f56..46e36620f01 100644 --- a/pkg/services/sqlstore/datasource_test.go +++ b/pkg/services/sqlstore/datasource_test.go @@ -6,6 +6,7 @@ package sqlstore import ( "context" "errors" + "fmt" "strconv" "testing" "time" @@ -13,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/models" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -226,8 +228,10 @@ func TestDataAccess(t *testing.T) { sqlStore := InitTestDB(t) ds := initDatasource(sqlStore) - err := sqlStore.DeleteDataSource(context.Background(), &models.DeleteDataSourceCommand{ID: ds.Id, OrgID: 123123}) + err := sqlStore.DeleteDataSource(context.Background(), + &models.DeleteDataSourceCommand{ID: ds.Id, OrgID: 123123}) require.NoError(t, err) + query := models.GetDataSourcesQuery{OrgId: 10} err = sqlStore.GetDataSources(context.Background(), &query) require.NoError(t, err) @@ -246,7 +250,8 @@ func TestDataAccess(t *testing.T) { return nil }) - err := sqlStore.DeleteDataSource(context.Background(), &models.DeleteDataSourceCommand{ID: ds.Id, UID: "nisse-uid", Name: "nisse", OrgID: 123123}) + err := sqlStore.DeleteDataSource(context.Background(), + &models.DeleteDataSourceCommand{ID: ds.Id, UID: "nisse-uid", Name: "nisse", OrgID: int64(123123)}) require.NoError(t, err) require.Eventually(t, func() bool { @@ -273,6 +278,42 @@ func TestDataAccess(t *testing.T) { require.Equal(t, 0, len(query.Result)) }) + t.Run("DeleteDataSourceAccessControlPermissions", func(t *testing.T) { + sqlStore := InitTestDB(t) + ds := initDatasource(sqlStore) + + // Init associated permission + errAddPermissions := sqlStore.WithTransactionalDbSession(context.TODO(), func(sess *DBSession) error { + _, err := sess.Table("permission").Insert(ac.Permission{ + RoleID: 1, + Action: "datasources:read", + Scope: ac.Scope("datasources", "id", fmt.Sprintf("%d", ds.Id)), + Updated: time.Now(), + Created: time.Now(), + }) + return err + }) + require.NoError(t, errAddPermissions) + query := models.GetDataSourcesQuery{OrgId: 10} + + errDeletingDS := sqlStore.DeleteDataSource(context.Background(), + &models.DeleteDataSourceCommand{Name: ds.Name, OrgID: ds.OrgId}, + ) + require.NoError(t, errDeletingDS) + + // Check associated permission + permCount := int64(0) + errGetPermissions := sqlStore.WithTransactionalDbSession(context.TODO(), func(sess *DBSession) error { + var err error + permCount, err = sess.Table("permission").Count() + return err + }) + require.NoError(t, errGetPermissions) + require.Zero(t, permCount, "permissions associated to the data source should have been removed") + + require.Equal(t, 0, len(query.Result)) + }) + t.Run("GetDataSources", func(t *testing.T) { t.Run("Number of data sources returned limited to 6 per organization", func(t *testing.T) { sqlStore := InitTestDB(t) From 815ccb1a039141413552b3bbc5de6c672a7d1765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Bedi?= Date: Thu, 17 Feb 2022 14:18:09 +0100 Subject: [PATCH 037/104] Chore: Fix Debug Jest test command for vscode (#45420) --- .vscode/launch.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 5eaae3ff47b..112f73498bc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,7 +15,8 @@ "name": "Debug Jest test", "type": "node", "request": "launch", - "runtimeArgs": ["--inspect-brk", "${workspaceRoot}/node_modules/.bin/jest", "--runInBand", "${file}"], + "runtimeExecutable": "yarn", + "runtimeArgs": ["run", "jest", "--runInBand", "${file}"], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "port": 9229 From 8835020457d277c3dcfc395ecec2cb7b7ba884d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agn=C3=A8s=20Toulet?= <35176601+AgnesToulet@users.noreply.github.com> Date: Thu, 17 Feb 2022 14:20:37 +0100 Subject: [PATCH 038/104] Feature Highlights: add RS event for nav items (#45470) --- packages/grafana-data/src/types/navModel.ts | 1 + pkg/api/dtos/index.go | 1 + .../app/core/components/NavBar/NavBarItemMenuTrigger.tsx | 9 ++++++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index d4c885f11a5..781a6610270 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -20,6 +20,7 @@ export interface NavModelItem { onClick?: () => void; menuItemType?: NavMenuItemType; highlightText?: string; + highlightId?: string; tabSuffix?: ComponentType<{ className?: string }>; } diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index 5afb1e62064..64e2d5b531f 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -71,6 +71,7 @@ type NavLink struct { HideFromTabs bool `json:"hideFromTabs,omitempty"` Children []*NavLink `json:"children,omitempty"` HighlightText string `json:"highlightText,omitempty"` + HighlightID string `json:"highlightId,omitempty"` } // NavIDCfg is the id for org configuration navigation node diff --git a/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx b/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx index 7d85be8a65b..90df0a07fb1 100644 --- a/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx +++ b/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useState } from 'react'; +import React, { ReactElement, useEffect, useState } from 'react'; import { css, cx } from '@emotion/css'; import { Icon, IconName, Link, useTheme2 } from '@grafana/ui'; import { GrafanaTheme2, NavModelItem } from '@grafana/data'; @@ -13,6 +13,7 @@ import { FocusScope } from '@react-aria/focus'; import { NavBarItemMenuContext } from './context'; import { NavFeatureHighlight } from './NavFeatureHighlight'; +import { reportExperimentView } from '@grafana/runtime'; export interface NavBarItemMenuTriggerProps extends MenuTriggerProps { children: ReactElement; @@ -34,6 +35,12 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE const ref = React.useRef(null); const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref); + useEffect(() => { + if (item.highlightId) { + reportExperimentView(`feature-highlights-${item.highlightId}-nav`, 'test', ''); + } + }, [item.highlightId]); + const { hoverProps } = useHover({ onHoverChange: (isHovering) => { if (isHovering) { From af1691dbfb1fa060d44cce1163d7fccd5fc271fb Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Thu, 17 Feb 2022 15:26:38 +0200 Subject: [PATCH 039/104] Update grabpl (#45520) --- .drone.yml | 66 ++++++++++++++++++------------------ scripts/drone/steps/lib.star | 2 +- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.drone.yml b/.drone.yml index 21d0852c426..cc63711f2fa 100644 --- a/.drone.yml +++ b/.drone.yml @@ -11,7 +11,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -111,7 +111,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -362,7 +362,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -423,7 +423,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -524,7 +524,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -909,7 +909,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -977,7 +977,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -1060,7 +1060,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1142,7 +1142,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1446,7 +1446,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1567,7 +1567,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1651,7 +1651,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -1710,7 +1710,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2050,7 +2050,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2223,7 +2223,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2351,7 +2351,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -2426,7 +2426,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2504,7 +2504,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2565,7 +2565,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2644,7 +2644,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2706,7 +2706,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2742,7 +2742,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2789,7 +2789,7 @@ steps: name: initialize - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2837,7 +2837,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2900,7 +2900,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3159,7 +3159,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3273,7 +3273,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3350,7 +3350,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -3398,7 +3398,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3731,7 +3731,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3894,7 +3894,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4012,7 +4012,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -4209,6 +4209,6 @@ kind: secret name: gcp_upload_artifacts_key --- kind: signature -hmac: 559c02d03809e345c15ec6badc083947ac12d79dc851596a317a5bd83ad75941 +hmac: 174576f45b5f239ab17433b3113a9f3d87b90907bae43ba11dd5499c93e401b7 ... diff --git a/scripts/drone/steps/lib.star b/scripts/drone/steps/lib.star index ef92785c57e..98d2dc57ed1 100644 --- a/scripts/drone/steps/lib.star +++ b/scripts/drone/steps/lib.star @@ -1,6 +1,6 @@ load('scripts/drone/vault.star', 'from_secret', 'github_token', 'pull_secret', 'drone_token', 'prerelease_bucket') -grabpl_version = 'v2.9.0' +grabpl_version = 'v2.9.1' build_image = 'grafana/build-container:1.4.9' publish_image = 'grafana/grafana-ci-deploy:1.3.1' deploy_docker_image = 'us.gcr.io/kubernetes-dev/drone/plugins/deploy-image' From 773da0e3305ec9b40c186ae0f51869e66144d06e Mon Sep 17 00:00:00 2001 From: Kristina Date: Thu, 17 Feb 2022 07:39:02 -0600 Subject: [PATCH 040/104] Explore: Add t * keybindings to change time range (#45020) * Add keybindings to explore, allow override of dashboard model update for explore update * Remove edits to Dashboard Model, add definition when url params should be updated * Add tests * Add and expose util function instead of bringing in unrelated library do not define explicit path to library file * Use more generic model for TimeSrv * Remove url utility functions, use javascript function instead * Break out TimeModel into new type and bring it in * condense object creation --- e2e/various-suite/explore.spec.ts | 22 +++++ public/app/core/services/keybindingSrv.ts | 36 ++++---- .../DashNav/DashNavTimeControls.tsx | 10 +-- .../dashboard/services/TimeSrv.test.ts | 5 ++ .../features/dashboard/services/TimeSrv.ts | 88 ++++++++++--------- .../dashboard/state/DashboardModel.ts | 3 +- .../app/features/dashboard/state/TimeModel.ts | 10 +++ .../app/features/dashboard/state/actions.ts | 4 +- .../app/features/explore/state/explorePane.ts | 3 + public/app/features/explore/state/time.ts | 19 ++-- public/app/plugins/panel/graph/module.ts | 2 +- .../app/plugins/panel/heatmap/heatmap_ctrl.ts | 2 +- public/app/types/events.ts | 15 +++- 13 files changed, 140 insertions(+), 79 deletions(-) create mode 100644 public/app/features/dashboard/state/TimeModel.ts diff --git a/e2e/various-suite/explore.spec.ts b/e2e/various-suite/explore.spec.ts index 4e089a27b79..57dab64ceec 100644 --- a/e2e/various-suite/explore.spec.ts +++ b/e2e/various-suite/explore.spec.ts @@ -19,6 +19,28 @@ e2e.scenario({ cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').click(); + cy.location().then((loc) => { + const params = new URLSearchParams(loc.search); + const leftJSON = JSON.parse(params.get('left')); + expect(leftJSON.range.to).to.equal('now'); + expect(leftJSON.range.from).to.equal('now-1h'); + + cy.get('body').click(); + cy.get('body').type('t{leftarrow}'); + + cy.location().then((locPostKeypress) => { + const params = new URLSearchParams(locPostKeypress.search); + const leftJSON = JSON.parse(params.get('left')); + // be sure the keypress affected the time window + expect(leftJSON.range.to).to.not.equal('now'); + expect(leftJSON.range.from).to.not.equal('now-1h'); + // be sure the url does not contain dashboard range values + // eslint wants this to be a function, so we use this instead of to.be.false + expect(params.has('to')).to.equal(false); + expect(params.has('from')).to.equal(false); + }); + }); + const canvases = e2e().get('canvas'); canvases.should('have.length', 1); }, diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index a232a133e4f..765b2a09b61 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -11,7 +11,7 @@ import { exitKioskMode, toggleKioskMode } from '../navigation/kiosk'; import { RemovePanelEvent, ShiftTimeEvent, - ShiftTimeEventPayload, + ShiftTimeEventDirection, ShowModalReactEvent, ZoomOutEvent, AbsoluteTimeEvent, @@ -171,6 +171,24 @@ export class KeybindingSrv { this.bind(keyArg, withFocusedPanel(fn)); } + setupTimeRangeBindings(updateUrl = true) { + this.bind('t z', () => { + appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl })); + }); + + this.bind('ctrl+z', () => { + appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl })); + }); + + this.bind('t left', () => { + appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Left, updateUrl })); + }); + + this.bind('t right', () => { + appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Right, updateUrl })); + }); + } + setupDashboardBindings(dashboard: DashboardModel) { this.bind('mod+o', () => { dashboard.graphTooltip = (dashboard.graphTooltip + 1) % 3; @@ -191,21 +209,7 @@ export class KeybindingSrv { } }); - this.bind('t z', () => { - appEvents.publish(new ZoomOutEvent(2)); - }); - - this.bind('ctrl+z', () => { - appEvents.publish(new ZoomOutEvent(2)); - }); - - this.bind('t left', () => { - appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Left)); - }); - - this.bind('t right', () => { - appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Right)); - }); + this.setupTimeRangeBindings(); // edit panel this.bindWithPanelId('e', (panelId) => { diff --git a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx index 94313c0ec21..640efa92c3a 100644 --- a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx @@ -13,7 +13,7 @@ import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePicker // Utils & Services import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { appEvents } from 'app/core/core'; -import { ShiftTimeEvent, ShiftTimeEventPayload, ZoomOutEvent } from '../../../../types/events'; +import { ShiftTimeEvent, ShiftTimeEventDirection, ZoomOutEvent } from '../../../../types/events'; import { Unsubscribable } from 'rxjs'; export interface Props { @@ -38,16 +38,16 @@ export class DashNavTimeControls extends Component { }; onRefresh = () => { - getTimeSrv().refreshDashboard(); + getTimeSrv().refreshTimeModel(); return Promise.resolve(); }; onMoveBack = () => { - appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Left)); + appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Left })); }; onMoveForward = () => { - appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Right)); + appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Right })); }; onChangeTimePicker = (timeRange: TimeRange) => { @@ -77,7 +77,7 @@ export class DashNavTimeControls extends Component { }; onZoom = () => { - appEvents.publish(new ZoomOutEvent(2)); + appEvents.publish(new ZoomOutEvent({ scale: 2 })); }; render() { diff --git a/public/app/features/dashboard/services/TimeSrv.test.ts b/public/app/features/dashboard/services/TimeSrv.test.ts index 34a03f6dfe0..90220dd63c1 100644 --- a/public/app/features/dashboard/services/TimeSrv.test.ts +++ b/public/app/features/dashboard/services/TimeSrv.test.ts @@ -244,6 +244,11 @@ describe('timeSrv', () => { expect(locationUpdates[1].search).toEqual('?kiosk&from=now-1h&to=now-10s'); }); + + it('should not change the URL if the updateUrl param is false', () => { + timeSrv.setTime({ from: '1644340584281', to: '1644340584281' }, false); + expect(locationUpdates.length).toBe(0); + }); }); describe('pauseAutoRefresh', () => { diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts index 4892ac510a9..490f480ce55 100644 --- a/public/app/features/dashboard/services/TimeSrv.ts +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -9,14 +9,14 @@ import { TimeRange, toUtc, } from '@grafana/data'; -import { DashboardModel } from '../state/DashboardModel'; import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; import { config } from 'app/core/config'; import { getRefreshFromUrl } from '../utils/getRefreshFromUrl'; import { locationService } from '@grafana/runtime'; -import { AbsoluteTimeEvent, ShiftTimeEvent, ShiftTimeEventPayload, ZoomOutEvent } from '../../../types/events'; +import { AbsoluteTimeEvent, ShiftTimeEvent, ShiftTimeEventDirection, ZoomOutEvent } from '../../../types/events'; import { contextSrv, ContextSrv } from 'app/core/services/context_srv'; import appEvents from 'app/core/app_events'; +import { TimeModel } from '../state/TimeModel'; export class TimeSrv { time: any; @@ -24,21 +24,21 @@ export class TimeSrv { refresh: any; previousAutoRefresh: any; oldRefresh: string | null | undefined; - dashboard?: DashboardModel; + timeModel?: TimeModel; timeAtLoad: any; private autoRefreshBlocked?: boolean; constructor(private contextSrv: ContextSrv) { // default time this.time = getDefaultTimeRange().raw; - this.refreshDashboard = this.refreshDashboard.bind(this); + this.refreshTimeModel = this.refreshTimeModel.bind(this); appEvents.subscribe(ZoomOutEvent, (e) => { - this.zoomOut(e.payload); + this.zoomOut(e.payload.scale, e.payload.updateUrl); }); appEvents.subscribe(ShiftTimeEvent, (e) => { - this.shiftTime(e.payload); + this.shiftTime(e.payload.direction, e.payload.updateUrl); }); appEvents.subscribe(AbsoluteTimeEvent, () => { @@ -48,15 +48,15 @@ export class TimeSrv { document.addEventListener('visibilitychange', () => { if (this.autoRefreshBlocked && document.visibilityState === 'visible') { this.autoRefreshBlocked = false; - this.refreshDashboard(); + this.refreshTimeModel(); } }); } - init(dashboard: DashboardModel) { - this.dashboard = dashboard; - this.time = dashboard.time; - this.refresh = dashboard.refresh; + init(timeModel: TimeModel) { + this.timeModel = timeModel; + this.time = timeModel.time; + this.refresh = timeModel.refresh; this.initTimeFromUrl(); this.parseTime(); @@ -66,8 +66,8 @@ export class TimeSrv { const range = rangeUtil.convertRawToRange( this.time, - this.dashboard?.getTimezone(), - this.dashboard?.fiscalYearStartMonth + this.timeModel?.getTimezone(), + this.timeModel?.fiscalYearStartMonth ); if (range.to.isBefore(range.from)) { @@ -159,11 +159,11 @@ export class TimeSrv { this.time.to = this.parseUrlParam(params.get('to')!) || this.time.to; } - // if absolute ignore refresh option saved to dashboard + // if absolute ignore refresh option saved to timeModel if (params.get('to') && params.get('to')!.indexOf('now') === -1) { this.refresh = false; - if (this.dashboard) { - this.dashboard.refresh = false; + if (this.timeModel) { + this.timeModel.refresh = false; } } @@ -176,8 +176,8 @@ export class TimeSrv { this.refresh = getRefreshFromUrl({ params: paramsJSON, currentRefresh: this.refresh, - refreshIntervals: Array.isArray(this.dashboard?.timepicker?.refresh_intervals) - ? this.dashboard?.timepicker?.refresh_intervals + refreshIntervals: Array.isArray(this.timeModel?.timepicker?.refresh_intervals) + ? this.timeModel?.timepicker?.refresh_intervals : undefined, isAllowedIntervalFn: this.contextSrv.isAllowedInterval, minRefreshInterval: config.minRefreshInterval, @@ -213,8 +213,8 @@ export class TimeSrv { } setAutoRefresh(interval: any) { - if (this.dashboard) { - this.dashboard.refresh = interval; + if (this.timeModel) { + this.timeModel.refresh = interval; } this.stopAutoRefresh(); @@ -235,7 +235,7 @@ export class TimeSrv { this.refreshTimer = setTimeout(() => { this.startNextRefreshTimer(intervalMs); - this.refreshDashboard(); + this.refreshTimeModel(); }, intervalMs); const refresh = this.contextSrv.getValidInterval(interval); @@ -245,15 +245,15 @@ export class TimeSrv { } } - refreshDashboard() { - this.dashboard?.timeRangeUpdated(this.timeRange()); + refreshTimeModel() { + this.timeModel?.timeRangeUpdated(this.timeRange()); } private startNextRefreshTimer(afterMs: number) { this.refreshTimer = setTimeout(() => { this.startNextRefreshTimer(afterMs); if (this.contextSrv.isGrafanaVisible()) { - this.refreshDashboard(); + this.refreshTimeModel(); } else { this.autoRefreshBlocked = true; } @@ -264,10 +264,10 @@ export class TimeSrv { clearTimeout(this.refreshTimer); } - // store dashboard refresh value and pause auto-refresh in some places + // store timeModel refresh value and pause auto-refresh in some places // i.e panel edit pauseAutoRefresh() { - this.previousAutoRefresh = this.dashboard?.refresh; + this.previousAutoRefresh = this.timeModel?.refresh; this.setAutoRefresh(''); } @@ -276,20 +276,19 @@ export class TimeSrv { this.setAutoRefresh(this.previousAutoRefresh); } - setTime(time: RawTimeRange, fromRouteUpdate?: boolean) { + setTime(time: RawTimeRange, updateUrl = true) { extend(this.time, time); // disable refresh if zoom in or zoom out if (isDateTime(time.to)) { - this.oldRefresh = this.dashboard?.refresh || this.oldRefresh; + this.oldRefresh = this.timeModel?.refresh || this.oldRefresh; this.setAutoRefresh(false); - } else if (this.oldRefresh && this.oldRefresh !== this.dashboard?.refresh) { + } else if (this.oldRefresh && this.oldRefresh !== this.timeModel?.refresh) { this.setAutoRefresh(this.oldRefresh); this.oldRefresh = null; } - // update url - if (fromRouteUpdate !== true) { + if (updateUrl === true) { const urlRange = this.timeRangeForUrl(); const urlParams = locationService.getSearchObject(); @@ -303,7 +302,7 @@ export class TimeSrv { locationService.partial(urlParams); } - this.refreshDashboard(); + this.refreshTimeModel(); } timeRangeForUrl = () => { @@ -326,30 +325,33 @@ export class TimeSrv { to: isDateTime(this.time.to) ? dateTime(this.time.to) : this.time.to, }; - const timezone = this.dashboard ? this.dashboard.getTimezone() : undefined; + const timezone = this.timeModel ? this.timeModel.getTimezone() : undefined; return { - from: dateMath.parse(raw.from, false, timezone, this.dashboard?.fiscalYearStartMonth)!, - to: dateMath.parse(raw.to, true, timezone, this.dashboard?.fiscalYearStartMonth)!, + from: dateMath.parse(raw.from, false, timezone, this.timeModel?.fiscalYearStartMonth)!, + to: dateMath.parse(raw.to, true, timezone, this.timeModel?.fiscalYearStartMonth)!, raw: raw, }; } - zoomOut(factor: number) { + zoomOut(factor: number, updateUrl = true) { const range = this.timeRange(); const { from, to } = getZoomedTimeRange(range, factor); - this.setTime({ from: toUtc(from), to: toUtc(to) }); + this.setTime({ from: toUtc(from), to: toUtc(to) }, updateUrl); } - shiftTime(direction: ShiftTimeEventPayload) { + shiftTime(direction: ShiftTimeEventDirection, updateUrl = true) { const range = this.timeRange(); const { from, to } = getShiftedTimeRange(direction, range); - this.setTime({ - from: toUtc(from), - to: toUtc(to), - }); + this.setTime( + { + from: toUtc(from), + to: toUtc(to), + }, + updateUrl + ); } makeAbsoluteTime() { @@ -359,7 +361,7 @@ export class TimeSrv { } const { from, to } = this.timeRange(); - this.setTime({ from, to }); + this.setTime({ from, to }, true); } // isRefreshOutsideThreshold function calculates the difference between last refresh and now diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 212cb450d40..bbda7085b5d 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -20,6 +20,7 @@ import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT, REPEAT_DIR_VERT import { contextSrv } from 'app/core/services/context_srv'; // Types import { GridPos, PanelModel } from './PanelModel'; +import { TimeModel } from './TimeModel'; import { DashboardMigrator } from './DashboardMigrator'; import { AnnotationQuery, @@ -78,7 +79,7 @@ export interface DashboardLink { includeVars: boolean; } -export class DashboardModel { +export class DashboardModel implements TimeModel { id: any; uid: string; title: string; diff --git a/public/app/features/dashboard/state/TimeModel.ts b/public/app/features/dashboard/state/TimeModel.ts new file mode 100644 index 00000000000..b7df209d6f7 --- /dev/null +++ b/public/app/features/dashboard/state/TimeModel.ts @@ -0,0 +1,10 @@ +import { TimeRange, TimeZone } from '@grafana/data'; + +export interface TimeModel { + time: any; + fiscalYearStartMonth?: number; + refresh: any; + timepicker: any; + getTimezone(): TimeZone; + timeRangeUpdated(timeRange: TimeRange): void; +} diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 9fdd7d1b5eb..1a503a276ad 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -131,12 +131,12 @@ export const updateTimeZoneDashboard = (timeZone: TimeZone): ThunkResult => (dispatch) => { dispatch(updateTimeZoneForSession(timeZone)); - getTimeSrv().refreshDashboard(); + getTimeSrv().refreshTimeModel(); }; export const updateWeekStartDashboard = (weekStart: string): ThunkResult => (dispatch) => { dispatch(updateWeekStartForSession(weekStart)); - getTimeSrv().refreshDashboard(); + getTimeSrv().refreshTimeModel(); }; diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts index 31bb1b1da06..a40de3799cb 100644 --- a/public/app/features/explore/state/explorePane.ts +++ b/public/app/features/explore/state/explorePane.ts @@ -38,6 +38,7 @@ import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state import { getDataSourceSrv } from '@grafana/runtime'; import { getRichHistory } from '../../../core/utils/richHistory'; import { richHistoryUpdatedAction, stateSave } from './main'; +import { keybindingSrv } from 'app/core/services/keybindingSrv'; // // Actions and Payloads @@ -172,6 +173,8 @@ export function initializeExplore( } dispatch(updateTime({ exploreId })); + keybindingSrv.setupTimeRangeBindings(false); + if (instance) { // We do not want to add the url to browser history on init because when the pane is initialised it's because // we already have something in the url. Adding basically the same state as additional history item prevents diff --git a/public/app/features/explore/state/time.ts b/public/app/features/explore/state/time.ts index 9d8e7ab3c4b..5ab326f4646 100644 --- a/public/app/features/explore/state/time.ts +++ b/public/app/features/explore/state/time.ts @@ -14,7 +14,7 @@ import { ExploreItemState, ThunkResult } from 'app/types'; import { ExploreId } from 'app/types/explore'; import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors'; import { getTimeSrv } from '../../dashboard/services/TimeSrv'; -import { DashboardModel } from 'app/features/dashboard/state'; +import { TimeModel } from '../../dashboard/state/TimeModel'; import { runQueries } from './query'; import { syncTimesAction, stateSave } from './main'; @@ -95,14 +95,17 @@ export const updateTime = (config: { const range = getTimeRange(timeZone, rawRange, fiscalYearStartMonth); const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() }; + const timeModel: TimeModel = { + time: range.raw, + refresh: false, + timepicker: {}, + getTimezone: () => timeZone, + timeRangeUpdated: (rawTimeRange: RawTimeRange) => { + dispatch(updateTimeRange({ exploreId: exploreId, rawRange: rawTimeRange })); + }, + }; - getTimeSrv().init( - new DashboardModel({ - time: range.raw, - refresh: false, - timeZone, - }) - ); + getTimeSrv().init(timeModel); dispatch(changeRangeAction({ exploreId, range, absoluteRange })); }; diff --git a/public/app/plugins/panel/graph/module.ts b/public/app/plugins/panel/graph/module.ts index 37d0f433dba..cd7a23f6b23 100644 --- a/public/app/plugins/panel/graph/module.ts +++ b/public/app/plugins/panel/graph/module.ts @@ -187,7 +187,7 @@ export class GraphCtrl extends MetricsPanelCtrl { } zoomOut(evt: any) { - appEvents.publish(new ZoomOutEvent(2)); + appEvents.publish(new ZoomOutEvent({ scale: 2 })); } onDataSnapshotLoad(snapshotData: any) { diff --git a/public/app/plugins/panel/heatmap/heatmap_ctrl.ts b/public/app/plugins/panel/heatmap/heatmap_ctrl.ts index 54a24833cb3..c9effe62ba9 100644 --- a/public/app/plugins/panel/heatmap/heatmap_ctrl.ts +++ b/public/app/plugins/panel/heatmap/heatmap_ctrl.ts @@ -161,7 +161,7 @@ export class HeatmapCtrl extends MetricsPanelCtrl { } zoomOut(evt: any) { - appEvents.publish(new ZoomOutEvent(2)); + appEvents.publish(new ZoomOutEvent({ scale: 2 })); } onRender() { diff --git a/public/app/types/events.ts b/public/app/types/events.ts index 1acce376945..fc4cc3ff05e 100644 --- a/public/app/types/events.ts +++ b/public/app/types/events.ts @@ -132,14 +132,25 @@ export class RenderEvent extends BusEventBase { static type = 'render'; } -export class ZoomOutEvent extends BusEventWithPayload { +interface ZoomOutEventPayload { + scale: number; + updateUrl?: boolean; +} + +export class ZoomOutEvent extends BusEventWithPayload { static type = 'zoom-out'; } -export enum ShiftTimeEventPayload { +export enum ShiftTimeEventDirection { Left = -1, Right = 1, } + +interface ShiftTimeEventPayload { + direction: ShiftTimeEventDirection; + updateUrl?: boolean; +} + export class ShiftTimeEvent extends BusEventWithPayload { static type = 'shift-time'; } From 42d21e1af5c5e0b79a68642136b95145e434e5fd Mon Sep 17 00:00:00 2001 From: J Guerreiro Date: Thu, 17 Feb 2022 14:00:56 +0000 Subject: [PATCH 041/104] ServiceAccounts: Use http library for status codes (#45525) --- pkg/services/serviceaccounts/api/api.go | 2 +- pkg/services/serviceaccounts/api/token.go | 30 +++++++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index ce1818bcb81..b8ee87f00fa 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -122,7 +122,7 @@ func (api *ServiceAccountsAPI) UpgradeServiceAccounts(ctx *models.ReqContext) re if err := api.store.UpgradeServiceAccounts(ctx.Req.Context()); err == nil { return response.Success("service accounts upgraded") } else { - return response.Error(500, "Internal server error", err) + return response.Error(http.StatusInternalServerError, "Internal server error", err) } } diff --git a/pkg/services/serviceaccounts/api/token.go b/pkg/services/serviceaccounts/api/token.go index bfeef7b4185..506f06afb7a 100644 --- a/pkg/services/serviceaccounts/api/token.go +++ b/pkg/services/serviceaccounts/api/token.go @@ -38,9 +38,9 @@ func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Respo } } - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } else { - return response.Error(500, "Internal server error", err) + return response.Error(http.StatusInternalServerError, "Internal server error", err) } } @@ -71,33 +71,33 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon cmd.OrgId = c.OrgId if !cmd.Role.IsValid() { - return response.Error(400, "Invalid role specified", nil) + return response.Error(http.StatusBadRequest, "Invalid role specified", nil) } if api.cfg.ApiKeyMaxSecondsToLive != -1 { if cmd.SecondsToLive == 0 { - return response.Error(400, "Number of seconds before expiration should be set", nil) + return response.Error(http.StatusBadRequest, "Number of seconds before expiration should be set", nil) } if cmd.SecondsToLive > api.cfg.ApiKeyMaxSecondsToLive { - return response.Error(400, "Number of seconds before expiration is greater than the global limit", nil) + return response.Error(http.StatusBadRequest, "Number of seconds before expiration is greater than the global limit", nil) } } newKeyInfo, err := apikeygen.New(cmd.OrgId, cmd.Name) if err != nil { - return response.Error(500, "Generating API key failed", err) + return response.Error(http.StatusInternalServerError, "Generating API key failed", err) } cmd.Key = newKeyInfo.HashedKey if err := api.apiKeyStore.AddAPIKey(c.Req.Context(), &cmd); err != nil { if errors.Is(err, models.ErrInvalidApiKeyExpiration) { - return response.Error(400, err.Error(), nil) + return response.Error(http.StatusBadRequest, err.Error(), nil) } if errors.Is(err, models.ErrDuplicateApiKey) { - return response.Error(409, err.Error(), nil) + return response.Error(http.StatusConflict, err.Error(), nil) } - return response.Error(500, "Failed to add API Key", err) + return response.Error(http.StatusInternalServerError, "Failed to add API Key", err) } result := &dtos.NewApiKeyResult{ @@ -106,7 +106,7 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon Key: newKeyInfo.ClientSecret, } - return response.JSON(200, result) + return response.JSON(http.StatusOK, result) } // DeleteToken deletes service account tokens @@ -134,9 +134,9 @@ func (api *ServiceAccountsAPI) DeleteToken(c *models.ReqContext) response.Respon // confirm API key belongs to service account. TODO: refactor get & delete to single call cmdGet := &models.GetApiKeyByIdQuery{ApiKeyId: tokenID} if err = api.apiKeyStore.GetApiKeyById(c.Req.Context(), cmdGet); err != nil { - status := 404 + status := http.StatusNotFound if err != nil && !errors.Is(err, models.ErrApiKeyNotFound) { - status = 500 + status = http.StatusInternalServerError } else { err = models.ErrApiKeyNotFound } @@ -146,14 +146,14 @@ func (api *ServiceAccountsAPI) DeleteToken(c *models.ReqContext) response.Respon // verify service account ID matches the URL if *cmdGet.Result.ServiceAccountId != saID { - return response.Error(404, failedToDeleteMsg, err) + return response.Error(http.StatusNotFound, failedToDeleteMsg, err) } cmdDel := &models.DeleteApiKeyCommand{Id: tokenID, OrgId: c.OrgId} if err = api.apiKeyStore.DeleteApiKey(c.Req.Context(), cmdDel); err != nil { - status := 404 + status := http.StatusNotFound if err != nil && !errors.Is(err, models.ErrApiKeyNotFound) { - status = 500 + status = http.StatusInternalServerError } else { err = models.ErrApiKeyNotFound } From dac2b7ef73dcb956c0881677e8764363210c73da Mon Sep 17 00:00:00 2001 From: Isabella Siu Date: Thu, 17 Feb 2022 09:04:59 -0500 Subject: [PATCH 042/104] Cloudwatch: Fix resource variables (#45496) --- .../datasource/cloudwatch/datasource.ts | 77 +++++++++++++++++++ .../cloudwatch/specs/datasource.test.ts | 29 +++++++ 2 files changed, 106 insertions(+) diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index f92756527f4..91881025ddc 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -736,6 +736,83 @@ export class CloudWatchDatasource }); } + async metricFindQuery(query: string) { + let region; + let namespace; + let metricName; + let filterJson; + + const regionQuery = query.match(/^regions\(\)/); + if (regionQuery) { + return this.getRegions(); + } + + const namespaceQuery = query.match(/^namespaces\(\)/); + if (namespaceQuery) { + return this.getNamespaces(); + } + + const metricNameQuery = query.match(/^metrics\(([^\)]+?)(,\s?([^,]+?))?\)/); + if (metricNameQuery) { + namespace = metricNameQuery[1]; + region = metricNameQuery[3]; + return this.getMetrics(namespace, region); + } + + const dimensionKeysQuery = query.match(/^dimension_keys\(([^\)]+?)(,\s?([^,]+?))?\)/); + if (dimensionKeysQuery) { + namespace = dimensionKeysQuery[1]; + region = dimensionKeysQuery[3]; + return this.getDimensionKeys(namespace, region); + } + + const dimensionValuesQuery = query.match( + /^dimension_values\(([^,]+?),\s?([^,]+?),\s?([^,]+?),\s?([^,]+?)(,\s?(.+))?\)/ + ); + if (dimensionValuesQuery) { + region = dimensionValuesQuery[1]; + namespace = dimensionValuesQuery[2]; + metricName = dimensionValuesQuery[3]; + const dimensionKey = dimensionValuesQuery[4]; + filterJson = {}; + if (dimensionValuesQuery[6]) { + filterJson = JSON.parse(this.templateSrv.replace(dimensionValuesQuery[6])); + } + + return this.getDimensionValues(region, namespace, metricName, dimensionKey, filterJson); + } + + const ebsVolumeIdsQuery = query.match(/^ebs_volume_ids\(([^,]+?),\s?([^,]+?)\)/); + if (ebsVolumeIdsQuery) { + region = ebsVolumeIdsQuery[1]; + const instanceId = ebsVolumeIdsQuery[2]; + return this.getEbsVolumeIds(region, instanceId); + } + + const ec2InstanceAttributeQuery = query.match(/^ec2_instance_attribute\(([^,]+?),\s?([^,]+?),\s?(.+?)\)/); + if (ec2InstanceAttributeQuery) { + region = ec2InstanceAttributeQuery[1]; + const targetAttributeName = ec2InstanceAttributeQuery[2]; + filterJson = JSON.parse(this.templateSrv.replace(ec2InstanceAttributeQuery[3])); + return this.getEc2InstanceAttribute(region, targetAttributeName, filterJson); + } + + const resourceARNsQuery = query.match(/^resource_arns\(([^,]+?),\s?([^,]+?),\s?(.+?)\)/); + if (resourceARNsQuery) { + region = resourceARNsQuery[1]; + const resourceType = resourceARNsQuery[2]; + const tagsJSON = JSON.parse(this.templateSrv.replace(resourceARNsQuery[3])); + return this.getResourceARNs(region, resourceType, tagsJSON); + } + + const statsQuery = query.match(/^statistics\(\)/); + if (statsQuery) { + return this.standardStatistics.map((s: string) => ({ value: s, label: s, text: s })); + } + + return Promise.resolve([]); + } + annotationQuery(options: any) { const annotation = options.annotation; const statistic = this.templateSrv.replace(annotation.statistic); diff --git a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts index 6c608fcdf7b..d8d06ff56ec 100644 --- a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts @@ -506,6 +506,35 @@ describe('CloudWatchDatasource', () => { }); }); }); + describe('when regions query is used', () => { + describe('and region param is left out', () => { + it('should use the default region', async () => { + const { ds, instanceSettings } = getTestContext(); + ds.doMetricResourceRequest = jest.fn().mockResolvedValue([]); + + await ds.metricFindQuery('metrics(testNamespace)'); + + expect(ds.doMetricResourceRequest).toHaveBeenCalledWith('metrics', { + namespace: 'testNamespace', + region: instanceSettings.jsonData.defaultRegion, + }); + }); + }); + + describe('and region param is defined by user', () => { + it('should use the user defined region', async () => { + const { ds } = getTestContext(); + ds.doMetricResourceRequest = jest.fn().mockResolvedValue([]); + + await ds.metricFindQuery('metrics(testNamespace2, custom-region)'); + + expect(ds.doMetricResourceRequest).toHaveBeenCalledWith('metrics', { + namespace: 'testNamespace2', + region: 'custom-region', + }); + }); + }); + }); }); describe('When query region is "default"', () => { From 17dee4700cf878f271032c50f2f0fc61fb123e74 Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Thu, 17 Feb 2022 16:12:44 +0200 Subject: [PATCH 043/104] SQLStore: Fix migrator locking test for SQLite (#45523) * SQLStore: Fix migrator locking test for SQLite --- .../sqlstore/migrations/migrations_test.go | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/pkg/services/sqlstore/migrations/migrations_test.go b/pkg/services/sqlstore/migrations/migrations_test.go index 3ee4d111954..83a26cc52df 100644 --- a/pkg/services/sqlstore/migrations/migrations_test.go +++ b/pkg/services/sqlstore/migrations/migrations_test.go @@ -65,7 +65,7 @@ func TestMigrationLock(t *testing.T) { t.Skip() } - testDB := getTestDB(dbType) + testDB := getTestDB(t, dbType) x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) @@ -154,7 +154,7 @@ func TestMigrationLock(t *testing.T) { func TestMigratorLocking(t *testing.T) { dbType := getDBType() - testDB := getTestDB(dbType) + testDB := getTestDB(t, dbType) x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) @@ -172,7 +172,7 @@ func TestMigratorLocking(t *testing.T) { i := i // capture i variable t.Run(fmt.Sprintf("run migration %d", i), func(t *testing.T) { t.Parallel() - err = mg.Start(true, 0) + err := mg.Start(true, 0) if err != nil { if errors.Is(err, ErrMigratorIsLocked) { atomic.AddInt64(&errorNum, 1) @@ -191,7 +191,7 @@ func TestDatabaseLocking(t *testing.T) { t.Skip() } - testDB := getTestDB(dbType) + testDB := getTestDB(t, dbType) x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr) require.NoError(t, err) @@ -281,14 +281,24 @@ func getDBType() string { return dbType } -func getTestDB(dbType string) sqlutil.TestDB { +func getTestDB(t *testing.T, dbType string) sqlutil.TestDB { switch dbType { case "mysql": return sqlutil.MySQLTestDB() case "postgres": return sqlutil.PostgresTestDB() default: - return sqlutil.SQLite3TestDB() + f, err := os.CreateTemp(".", "grafana-test-db-") + require.NoError(t, err) + t.Cleanup(func() { + err := os.Remove(f.Name()) + require.NoError(t, err) + }) + + return sqlutil.TestDB{ + DriverName: "sqlite3", + ConnStr: f.Name(), + } } } From 846842eb99764e04fe50bfa24dd1577967d6c854 Mon Sep 17 00:00:00 2001 From: Robby Milo Date: Thu, 17 Feb 2022 15:27:15 +0100 Subject: [PATCH 044/104] Update README.md (#43358) --- docs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index 9d6abafad4d..d15a584b9c7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,7 @@ Yarn >= 1.22.4 ## Build the doc site 1. On the command line, first change to the docs folder: `cd docs`. -1. Run `make docs-quick`. This launches a preview of the website with the current grafana docs at `http://localhost:3002/docs/grafana/next/` which will refresh automatically when changes are made to content in the `sources` directory. +1. Run `make docs`. This launches a preview of the website with the current grafana docs at `http://localhost:3002/docs/grafana/next/` which will refresh automatically when changes are made to content in the `sources` directory. If you have the grafana/website repo checked out in the same directory as the grafana repo, then you can run `make docs-local-static` to use local assets (such as images). From 23144fbcad9b0cd360092cb57bb95a7c7b860281 Mon Sep 17 00:00:00 2001 From: "Grot (@grafanabot)" <43478413+grafanabot@users.noreply.github.com> Date: Thu, 17 Feb 2022 15:28:49 +0000 Subject: [PATCH 045/104] ReleaseNotes: Updated changelog and release notes for 8.4.0 (#45531) * ReleaseNotes: Updated changelog and release notes for 8.4.0 * Apply suggestions from code review * Apply suggestions from code review * Apply suggestions from code review Co-authored-by: Andrej Ocenas --- CHANGELOG.md | 55 ++++++++++++++++++ .../release-notes/release-notes-8-4-0.md | 58 +++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 docs/sources/release-notes/release-notes-8-4-0.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 18d57c1e256..8dc67628d87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,58 @@ + + +# 8.4.0 (2022-02-16) + +### Features and enhancements + +- **API:** Extract OpenAPI specification from source code using go-swagger. [#40528](https://github.com/grafana/grafana/pull/40528), [@papagian](https://github.com/papagian) +- **AccessControl:** Disable user remove and user update roles when they do not have the permissions. [#43429](https://github.com/grafana/grafana/pull/43429), [@Jguer](https://github.com/Jguer) +- **AccessControl:** Provisioning for teams. [#43767](https://github.com/grafana/grafana/pull/43767), [@gamab](https://github.com/gamab) +- **API:** Add usage stats preview endpoint. [#43899](https://github.com/grafana/grafana/pull/43899), [@Jguer](https://github.com/Jguer) +- **Alerting:** Move slow queries in the scheduler to another goroutine. [#44423](https://github.com/grafana/grafana/pull/44423), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Alerting:** Use time.Ticker instead of alerting.Ticker in ngalert. [#44395](https://github.com/grafana/grafana/pull/44395), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Alerting:** add custom grouping to Alert Panel. [#44559](https://github.com/grafana/grafana/pull/44559), [@gillesdemey](https://github.com/gillesdemey) +- **Analytics:** Add user id tracking to google analytics. [#42763](https://github.com/grafana/grafana/pull/42763), [@autoric](https://github.com/autoric) +- **Angular:** Add AngularJS plugin support deprecation plan to docs site. [#45149](https://github.com/grafana/grafana/pull/45149), [@torkelo](https://github.com/torkelo) +- **Auth:** implement auto_sign_up for auth.jwt. [#43502](https://github.com/grafana/grafana/pull/43502), [@sakjur](https://github.com/sakjur) +- **Azure Monitor Logs:** Order subscriptions in resource picker by name. [#45228](https://github.com/grafana/grafana/pull/45228), [@sunker](https://github.com/sunker) +- **Azure monitor Logs:** Optimize data fetching in resource picker. [#44549](https://github.com/grafana/grafana/pull/44549), [@sunker](https://github.com/sunker) +- **AzureMonitor:** Filter list of resources by resourceType. [#43522](https://github.com/grafana/grafana/pull/43522), [@andresmgot](https://github.com/andresmgot) +- **BarChart:** color by field, x time field, bar radius, label skipping. [#43257](https://github.com/grafana/grafana/pull/43257), [@leeoniya](https://github.com/leeoniya) +- **Chore:** Implement OpenTelemetry in Grafana. [#42674](https://github.com/grafana/grafana/pull/42674), [@idafurjes](https://github.com/idafurjes) +- **Cloud Monitoring:** Adds metric type to Metric drop down options. [#43268](https://github.com/grafana/grafana/pull/43268), [@tw1nk](https://github.com/tw1nk) +- **CloudWatch:** Add Data Lifecycle Manager metrics and dimension. [#43310](https://github.com/grafana/grafana/pull/43310), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch:** Add Missing Elasticache Host-level metrics. [#43455](https://github.com/grafana/grafana/pull/43455), [@dhendo](https://github.com/dhendo) +- **CloudWatch:** Add all ElastiCache Redis Metrics. [#43336](https://github.com/grafana/grafana/pull/43336), [@siavashs](https://github.com/siavashs) +- **CloudWatch:** Add new AWS/ES metrics. [#43034](https://github.com/grafana/grafana/pull/43034), [@sunker](https://github.com/sunker) +- **Cloudwatch:** Add syntax highlighting and autocomplete for "Metric Search". [#43985](https://github.com/grafana/grafana/pull/43985), [@sarahzinger](https://github.com/sarahzinger) +- **Explore:** Support custom display label for exemplar links for Prometheus datasource. [#42732](https://github.com/grafana/grafana/pull/42732), [@JokerQueue](https://github.com/JokerQueue) +- **Hotkeys:** Make time range absolute/permanent. [#43802](https://github.com/grafana/grafana/pull/43802), [@davkal](https://github.com/davkal) +- **Playlists:** Enable sharing direct links to playlists. [#44161](https://github.com/grafana/grafana/pull/44161), [@ashharrison90](https://github.com/ashharrison90) +- **SQLStore:** Prevent concurrent migrations. [#44101](https://github.com/grafana/grafana/pull/44101), [@papagian](https://github.com/papagian) +- **SSE:** Add Mode to drop NaN/Inf/Null in Reduction operations. [#43583](https://github.com/grafana/grafana/pull/43583), [@kylebrandt](https://github.com/kylebrandt) +- **Setting:** Support configuring feature toggles with bools instead of just passing an array. [#43326](https://github.com/grafana/grafana/pull/43326), [@bergquist](https://github.com/bergquist) +- **TimeSeries:** Add support for negative Y and constant transform. [#44774](https://github.com/grafana/grafana/pull/44774), [@dprokop](https://github.com/dprokop) +- **Transformations:** Add 'JSON' field type to ConvertFieldTypeTransformer. [#42624](https://github.com/grafana/grafana/pull/42624), [@sd2k](https://github.com/sd2k) + +### Bug fixes + +- **Auth:** Guarantee consistency of signed SigV4 headers. [#45054](https://github.com/grafana/grafana/pull/45054), [@wbrowne](https://github.com/wbrowne) +- **CloudWatch:** Fix MetricName resetting on Namespace change. [#44165](https://github.com/grafana/grafana/pull/44165), [@yaelleC](https://github.com/yaelleC) +- **Cloudwatch :** Fixed resetting metric name when changing namespace in Metric Query. [#44612](https://github.com/grafana/grafana/pull/44612), [@yaelleC](https://github.com/yaelleC) +- **Explore:** Avoid locking timepicker when range is inverted. [#44790](https://github.com/grafana/grafana/pull/44790), [@Elfo404](https://github.com/Elfo404) +- **Instrumentation:** Fix HTTP request instrumentation of authentication failures. [#44234](https://github.com/grafana/grafana/pull/44234), [@marefr](https://github.com/marefr) +- **LibraryPanels:** Prevent long descriptions and names from obscuring the delete button. [#45190](https://github.com/grafana/grafana/pull/45190), [@zuchka](https://github.com/zuchka) +- **OAuth:** Fix parsing of ID token if header contains non-string value. [#44159](https://github.com/grafana/grafana/pull/44159), [@marefr](https://github.com/marefr) +- **Panel Edit:** Visualization search now works correctly with special characters. [#45137](https://github.com/grafana/grafana/pull/45137), [@ashharrison90](https://github.com/ashharrison90) +- **Provisioning:** Fix duplicate validation when multiple organizations have been configured. [#44151](https://github.com/grafana/grafana/pull/44151), [@marefr](https://github.com/marefr) +- **QueryField:** Fix issue with undo history when suggestion is inserted (#28656). [#39114](https://github.com/grafana/grafana/pull/39114), [@glintik](https://github.com/glintik) +- **TablePanel:** Do not prefix columns with frame name if multiple frames and override active. [#45174](https://github.com/grafana/grafana/pull/45174), [@mdvictor](https://github.com/mdvictor) + +### Deprecations + +AngularJS plugin support is now in a deprecated state, meaning it will be removed in a future release. Currently, that is planned for version 10 (in 2023). The documentation site has an [article](https://grafana.com/docs/grafana/next/developers/angular_deprecation/) with more details on why, when, and how. Issue [#45149](https://github.com/grafana/grafana/issues/45149) + + # 8.4.0-beta1 (2022-02-02) diff --git a/docs/sources/release-notes/release-notes-8-4-0.md b/docs/sources/release-notes/release-notes-8-4-0.md new file mode 100644 index 00000000000..0576173e6e8 --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-4-0.md @@ -0,0 +1,58 @@ ++++ +title = "Release notes for Grafana 8.4.0" +hide_menu = true ++++ + + + +# Release notes for Grafana 8.4.0 + +### Features and enhancements + +- **API:** Extract OpenAPI specification from source code using go-swagger. [#40528](https://github.com/grafana/grafana/pull/40528), [@papagian](https://github.com/papagian) +- **AccessControl:** Disable user remove and user update roles when they do not have the permissions. [#43429](https://github.com/grafana/grafana/pull/43429), [@Jguer](https://github.com/Jguer) +- **AccessControl:** Provisioning for teams. [#43767](https://github.com/grafana/grafana/pull/43767), [@gamab](https://github.com/gamab) +- **API:** Add usage stats preview endpoint. [#43899](https://github.com/grafana/grafana/pull/43899), [@Jguer](https://github.com/Jguer) +- **Alerting:** Move slow queries in the scheduler to another goroutine. [#44423](https://github.com/grafana/grafana/pull/44423), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Alerting:** Use time.Ticker instead of alerting.Ticker in ngalert. [#44395](https://github.com/grafana/grafana/pull/44395), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Alerting:** add custom grouping to Alert Panel. [#44559](https://github.com/grafana/grafana/pull/44559), [@gillesdemey](https://github.com/gillesdemey) +- **Analytics:** Add user id tracking to google analytics. [#42763](https://github.com/grafana/grafana/pull/42763), [@autoric](https://github.com/autoric) +- **Angular:** Add AngularJS plugin support deprecation plan to docs site. [#45149](https://github.com/grafana/grafana/pull/45149), [@torkelo](https://github.com/torkelo) +- **Auth:** implement auto_sign_up for auth.jwt. [#43502](https://github.com/grafana/grafana/pull/43502), [@sakjur](https://github.com/sakjur) +- **Azure Monitor Logs:** Order subscriptions in resource picker by name. [#45228](https://github.com/grafana/grafana/pull/45228), [@sunker](https://github.com/sunker) +- **Azure monitor Logs:** Optimize data fetching in resource picker. [#44549](https://github.com/grafana/grafana/pull/44549), [@sunker](https://github.com/sunker) +- **AzureMonitor:** Filter list of resources by resourceType. [#43522](https://github.com/grafana/grafana/pull/43522), [@andresmgot](https://github.com/andresmgot) +- **BarChart:** color by field, x time field, bar radius, label skipping. [#43257](https://github.com/grafana/grafana/pull/43257), [@leeoniya](https://github.com/leeoniya) +- **Chore:** Implement OpenTelemetry in Grafana. [#42674](https://github.com/grafana/grafana/pull/42674), [@idafurjes](https://github.com/idafurjes) +- **Cloud Monitoring:** Adds metric type to Metric drop down options. [#43268](https://github.com/grafana/grafana/pull/43268), [@tw1nk](https://github.com/tw1nk) +- **CloudWatch:** Add Data Lifecycle Manager metrics and dimension. [#43310](https://github.com/grafana/grafana/pull/43310), [@ilyastoli](https://github.com/ilyastoli) +- **CloudWatch:** Add Missing Elasticache Host-level metrics. [#43455](https://github.com/grafana/grafana/pull/43455), [@dhendo](https://github.com/dhendo) +- **CloudWatch:** Add all ElastiCache Redis Metrics. [#43336](https://github.com/grafana/grafana/pull/43336), [@siavashs](https://github.com/siavashs) +- **CloudWatch:** Add new AWS/ES metrics. [#43034](https://github.com/grafana/grafana/pull/43034), [@sunker](https://github.com/sunker) +- **Cloudwatch:** Add syntax highlighting and autocomplete for "Metric Search". [#43985](https://github.com/grafana/grafana/pull/43985), [@sarahzinger](https://github.com/sarahzinger) +- **Explore:** Support custom display label for exemplar links for Prometheus datasource. [#42732](https://github.com/grafana/grafana/pull/42732), [@JokerQueue](https://github.com/JokerQueue) +- **Hotkeys:** Make time range absolute/permanent. [#43802](https://github.com/grafana/grafana/pull/43802), [@davkal](https://github.com/davkal) +- **Playlists:** Enable sharing direct links to playlists. [#44161](https://github.com/grafana/grafana/pull/44161), [@ashharrison90](https://github.com/ashharrison90) +- **SQLStore:** Prevent concurrent migrations. [#44101](https://github.com/grafana/grafana/pull/44101), [@papagian](https://github.com/papagian) +- **SSE:** Add Mode to drop NaN/Inf/Null in Reduction operations. [#43583](https://github.com/grafana/grafana/pull/43583), [@kylebrandt](https://github.com/kylebrandt) +- **Setting:** Support configuring feature toggles with bools instead of just passing an array. [#43326](https://github.com/grafana/grafana/pull/43326), [@bergquist](https://github.com/bergquist) +- **TimeSeries:** Add support for negative Y and constant transform. [#44774](https://github.com/grafana/grafana/pull/44774), [@dprokop](https://github.com/dprokop) +- **Transformations:** Add 'JSON' field type to ConvertFieldTypeTransformer. [#42624](https://github.com/grafana/grafana/pull/42624), [@sd2k](https://github.com/sd2k) + +### Bug fixes + +- **Auth:** Guarantee consistency of signed SigV4 headers. [#45054](https://github.com/grafana/grafana/pull/45054), [@wbrowne](https://github.com/wbrowne) +- **CloudWatch:** Fix MetricName resetting on Namespace change. [#44165](https://github.com/grafana/grafana/pull/44165), [@yaelleC](https://github.com/yaelleC) +- **Cloudwatch :** Fixed resetting metric name when changing namespace in Metric Query. [#44612](https://github.com/grafana/grafana/pull/44612), [@yaelleC](https://github.com/yaelleC) +- **Explore:** Avoid locking timepicker when range is inverted. [#44790](https://github.com/grafana/grafana/pull/44790), [@Elfo404](https://github.com/Elfo404) +- **Instrumentation:** Fix HTTP request instrumentation of authentication failures. [#44234](https://github.com/grafana/grafana/pull/44234), [@marefr](https://github.com/marefr) +- **LibraryPanels:** Prevent long descriptions and names from obscuring the delete button. [#45190](https://github.com/grafana/grafana/pull/45190), [@zuchka](https://github.com/zuchka) +- **OAuth:** Fix parsing of ID token if header contains non-string value. [#44159](https://github.com/grafana/grafana/pull/44159), [@marefr](https://github.com/marefr) +- **Panel Edit:** Visualization search now works correctly with special characters. [#45137](https://github.com/grafana/grafana/pull/45137), [@ashharrison90](https://github.com/ashharrison90) +- **Provisioning:** Fix duplicate validation when multiple organizations have been configured. [#44151](https://github.com/grafana/grafana/pull/44151), [@marefr](https://github.com/marefr) +- **QueryField:** Fix issue with undo history when suggestion is inserted (#28656). [#39114](https://github.com/grafana/grafana/pull/39114), [@glintik](https://github.com/glintik) +- **TablePanel:** Do not prefix columns with frame name if multiple frames and override active. [#45174](https://github.com/grafana/grafana/pull/45174), [@mdvictor](https://github.com/mdvictor) + +### Deprecations + +AngularJS plugin support is now in a deprecated state, meaning it will be removed in a future release. Currently, that is planned for version 10 (in 2023). The documentation site has an [article](https://grafana.com/docs/grafana/next/developers/angular_deprecation/) with more details on why, when, and how. Issue [#45149](https://github.com/grafana/grafana/issues/45149) From 6e07b300487d69374eb1f31599ff2bda65fbac05 Mon Sep 17 00:00:00 2001 From: Connor Lindsey Date: Thu, 17 Feb 2022 08:34:16 -0700 Subject: [PATCH 046/104] Chore: Clean up logs and deprecated library in tracing tests (#45486) * Fix logs and errors from tracing tests * Refactor SpanBar to remove recompose * Fix NodeGraph tests --- .betterer.results | 2 +- packages/jaeger-ui-components/package.json | 2 - .../src/TraceTimelineViewer/SpanBar.test.js | 9 +- .../src/TraceTimelineViewer/SpanBar.tsx | 69 ++++------ packages/jaeger-ui-components/src/index.ts | 6 - .../datasource/tempo/datasource.test.ts | 6 + .../datasource/zipkin/QueryField.test.tsx | 7 +- .../panel/nodeGraph/NodeGraph.test.tsx | 18 ++- yarn.lock | 118 +----------------- 9 files changed, 61 insertions(+), 176 deletions(-) diff --git a/.betterer.results b/.betterer.results index 3e95eafec50..3a1f939d9d5 100644 --- a/.betterer.results +++ b/.betterer.results @@ -110,7 +110,7 @@ exports[`no enzyme tests`] = { "packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.test.js:2288177454": [ [15, 19, 13, "RegExp match", "2409514259"] ], - "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js:2545268168": [ + "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js:2127169675": [ [15, 17, 13, "RegExp match", "2409514259"] ], "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js:2454947085": [ diff --git a/packages/jaeger-ui-components/package.json b/packages/jaeger-ui-components/package.json index ce1308cecb6..c68c7169f87 100644 --- a/packages/jaeger-ui-components/package.json +++ b/packages/jaeger-ui-components/package.json @@ -18,7 +18,6 @@ "@types/prop-types": "^15", "@types/react": "17.0.39", "@types/react-icons": "2.2.7", - "@types/recompose": "0.30.10", "@types/reselect": "2.2.0", "@types/tinycolor2": "1.4.3", "enzyme": "3.11.0", @@ -46,7 +45,6 @@ "react": "17.0.2", "react-dom": "17.0.2", "react-icons": "2.2.7", - "recompose": "0.30.0", "reselect": "4.1.5", "tinycolor2": "1.4.2", "tslib": "2.3.1", diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js index 09146b8e459..70cc7b87420 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js @@ -14,6 +14,7 @@ import React from 'react'; import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; import { Popover } from '../common/Popover'; import SpanBar from './SpanBar'; @@ -79,9 +80,13 @@ describe('', () => { const { onMouseOver, onMouseLeave } = wrapper.find('[data-test-id="SpanBar--wrapper"]').props(); const labelElm = wrapper.find('[data-test-id="SpanBar--label"]'); expect(labelElm.text()).toBe(shortLabel); - onMouseOver(); + act(() => { + onMouseOver(); + }); expect(labelElm.text()).toBe(longLabel); - onMouseLeave(); + act(() => { + onMouseLeave(); + }); expect(labelElm.text()).toBe(shortLabel); }); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx index c78db4b23ae..097bd362bda 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx @@ -15,8 +15,7 @@ import cx from 'classnames'; import { css } from '@emotion/css'; import { groupBy as _groupBy } from 'lodash'; -import React from 'react'; -import { compose, onlyUpdateForKeys, withProps, withState } from 'recompose'; +import React, { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import { autoColor } from '../Theme'; @@ -90,7 +89,7 @@ const getStyles = (theme: GrafanaTheme2) => { }; }; -type TCommonProps = { +type Props = { color: string; onClick?: (evt: React.MouseEvent) => void; viewEnd: number; @@ -107,39 +106,32 @@ type TCommonProps = { span: TraceSpan; className?: string; labelClassName?: string; -}; - -type TInnerProps = { - label: string; - setLongLabel: () => void; - setShortLabel: () => void; -} & TCommonProps; - -type TOuterProps = { longLabel: string; shortLabel: string; -} & TCommonProps; +}; function toPercent(value: number) { return `${(value * 100).toFixed(1)}%`; } -function SpanBar(props: TInnerProps) { - const { - viewEnd, - viewStart, - getViewedBounds, - color, - label, - onClick, - setLongLabel, - setShortLabel, - rpc, - traceStartTime, - span, - className, - labelClassName, - } = props; +function SpanBar({ + viewEnd, + viewStart, + getViewedBounds, + color, + shortLabel, + longLabel, + onClick, + rpc, + traceStartTime, + span, + className, + labelClassName, +}: Props) { + const [label, setLabel] = useState(shortLabel); + const setShortLabel = () => setLabel(shortLabel); + const setLongLabel = () => setLabel(longLabel); + // group logs based on timestamps const logGroups = _groupBy(span.logs, (log) => { const posPercent = getViewedBounds(log.timestamp, log.timestamp).start; @@ -196,21 +188,4 @@ function SpanBar(props: TInnerProps) { ); } -export default compose( - withState('label', 'setLabel', (props: { shortLabel: string }) => props.shortLabel), - withProps( - ({ - setLabel, - shortLabel, - longLabel, - }: { - setLabel: (label: string) => void; - shortLabel: string; - longLabel: string; - }) => ({ - setLongLabel: () => setLabel(longLabel), - setShortLabel: () => setLabel(shortLabel), - }) - ), - onlyUpdateForKeys(['label', 'rpc', 'viewStart', 'viewEnd']) -)(SpanBar); +export default React.memo(SpanBar); diff --git a/packages/jaeger-ui-components/src/index.ts b/packages/jaeger-ui-components/src/index.ts index 64ad6a2b383..f5737eff1f5 100644 --- a/packages/jaeger-ui-components/src/index.ts +++ b/packages/jaeger-ui-components/src/index.ts @@ -6,9 +6,3 @@ export { default as DetailState } from './TraceTimelineViewer/SpanDetail/DetailS export { default as transformTraceData } from './model/transform-trace-data'; export { default as filterSpans } from './utils/filter-spans'; export * from './Theme'; - -import { onlyUpdateForKeys } from 'recompose'; - -export default { - onlyUpdateForKeys, -} as any; diff --git a/public/app/plugins/datasource/tempo/datasource.test.ts b/public/app/plugins/datasource/tempo/datasource.test.ts index 99c08ac73f5..2fe42ea2da0 100644 --- a/public/app/plugins/datasource/tempo/datasource.test.ts +++ b/public/app/plugins/datasource/tempo/datasource.test.ts @@ -16,6 +16,12 @@ import { DEFAULT_LIMIT, TempoJsonData, TempoDatasource, TempoQuery } from './dat import mockJson from './mockJsonResponse.json'; describe('Tempo data source', () => { + // Mock the console error so that running the test suite doesnt throw the error + const origError = console.error; + const consoleErrorMock = jest.fn(); + afterEach(() => (console.error = origError)); + beforeEach(() => (console.error = consoleErrorMock)); + it('returns empty response when traceId is empty', async () => { const ds = new TempoDatasource(defaultSettings); const response = await lastValueFrom( diff --git a/public/app/plugins/datasource/zipkin/QueryField.test.tsx b/public/app/plugins/datasource/zipkin/QueryField.test.tsx index 76bd62d50c0..9ab560129b4 100644 --- a/public/app/plugins/datasource/zipkin/QueryField.test.tsx +++ b/public/app/plugins/datasource/zipkin/QueryField.test.tsx @@ -7,8 +7,9 @@ import { ZipkinQueryField, useLoadOptions, useServices } from './QueryField'; import { ZipkinQuery } from './types'; describe('QueryField', () => { - it('renders properly', () => { + it('renders properly', async () => { const ds = {} as ZipkinDatasource; + render( { /> ); - expect(screen.getByText(/1234/i)).toBeInTheDocument(); - expect(screen.getByText(/Traces/i)).toBeInTheDocument(); + expect(await screen.findByText(/1234/i)).toBeInTheDocument(); + expect(await screen.findByText(/Traces/i)).toBeInTheDocument(); }); }); diff --git a/public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx b/public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx index 58fa20e928f..e6bdf8380c7 100644 --- a/public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx +++ b/public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent, waitFor, getByText } from '@testing-library/ import userEvent from '@testing-library/user-event'; import { NodeGraph } from './NodeGraph'; import { makeEdgesDataFrame, makeNodesDataFrame } from './utils'; +import { act } from 'react-dom/test-utils'; jest.mock('react-use/lib/useMeasure', () => { return { @@ -14,6 +15,11 @@ jest.mock('react-use/lib/useMeasure', () => { }); describe('NodeGraph', () => { + const origError = console.error; + const consoleErrorMock = jest.fn(); + afterEach(() => (console.error = origError)); + beforeEach(() => (console.error = consoleErrorMock)); + it('shows no data message without any data', async () => { render( []} />); @@ -80,11 +86,15 @@ describe('NodeGraph', () => { const node = await screen.findByLabelText(/Node: service:0/); // This shows warning because there is no position for the click. We cannot add any because we use pageX/Y in the // context menu which is experimental (but supported) property and userEvents does not seem to support that - userEvent.click(node); + act(() => { + userEvent.click(node); + }); await screen.findByText(/Node traces/); const edge = await screen.findByLabelText(/Edge from/); - userEvent.click(edge); + act(() => { + userEvent.click(edge); + }); await screen.findByText(/Edge traces/); }); @@ -173,7 +183,9 @@ describe('NodeGraph', () => { expect(node).toBeInTheDocument(); const marker = await screen.findByLabelText(/Hidden nodes marker: 3/); - userEvent.click(marker); + act(() => { + userEvent.click(marker); + }); expect(screen.queryByLabelText(/Node: service:0/)).not.toBeInTheDocument(); expect(screen.getByLabelText(/Node: service:4/)).toBeInTheDocument(); diff --git a/yarn.lock b/yarn.lock index b2501f2b85c..d9669eb6682 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4580,7 +4580,6 @@ __metadata: "@types/prop-types": ^15 "@types/react": 17.0.39 "@types/react-icons": 2.2.7 - "@types/recompose": 0.30.10 "@types/reselect": 2.2.0 "@types/tinycolor2": 1.4.3 chance: ^1.0.10 @@ -4601,7 +4600,6 @@ __metadata: react: 17.0.2 react-dom: 17.0.2 react-icons: 2.2.7 - recompose: 0.30.0 reselect: 4.1.5 sinon: 13.0.1 tinycolor2: 1.4.2 @@ -10681,15 +10679,6 @@ __metadata: languageName: node linkType: hard -"@types/recompose@npm:0.30.10": - version: 0.30.10 - resolution: "@types/recompose@npm:0.30.10" - dependencies: - "@types/react": "*" - checksum: 2b58b9e75841c8f2ceb5b40974e958a815bd6a606869fb926ae27e40d9b3ef17ab6697f63d60a367e31fb2008bf35f74eaa6c6a9cd542f857e97568e8ebe322d - languageName: node - linkType: hard - "@types/redux-mock-store@npm:1.0.3": version: 1.0.3 resolution: "@types/redux-mock-store@npm:1.0.3" @@ -12912,7 +12901,7 @@ __metadata: languageName: node linkType: hard -"asap@npm:^2.0.0, asap@npm:~2.0.3": +"asap@npm:^2.0.0": version: 2.0.6 resolution: "asap@npm:2.0.6" checksum: b296c92c4b969e973260e47523207cd5769abd27c245a68c26dc7a0fe8053c55bb04360237cb51cab1df52be939da77150ace99ad331fb7fb13b3423ed73ff3d @@ -14469,13 +14458,6 @@ __metadata: languageName: node linkType: hard -"change-emitter@npm:^0.1.2": - version: 0.1.6 - resolution: "change-emitter@npm:0.1.6" - checksum: 0ed494ba9901ca56ea6f942668fd294465c334a9a0981dca96da5aea5e387c0023a630d7c658c1b532d203db54c928ddca2564e434b4a8b7f6d39155d09db255 - languageName: node - linkType: hard - "char-regex@npm:^1.0.2": version: 1.0.2 resolution: "char-regex@npm:1.0.2" @@ -15601,13 +15583,6 @@ __metadata: languageName: node linkType: hard -"core-js@npm:^1.0.0": - version: 1.2.7 - resolution: "core-js@npm:1.2.7" - checksum: 0b76371bfa98708351cde580f9287e2360d2209920e738ae950ae74ad08639a2e063541020bf666c28778956fc356ed9fe56d962129c88a87a6a4a0612526c75 - languageName: node - linkType: hard - "core-js@npm:^2.0.0, core-js@npm:^2.4.0": version: 2.6.12 resolution: "core-js@npm:2.6.12" @@ -17792,7 +17767,7 @@ __metadata: languageName: node linkType: hard -"encoding@npm:^0.1.11, encoding@npm:^0.1.12": +"encoding@npm:^0.1.12": version: 0.1.13 resolution: "encoding@npm:0.1.13" dependencies: @@ -19078,21 +19053,6 @@ __metadata: languageName: node linkType: hard -"fbjs@npm:^0.8.1": - version: 0.8.18 - resolution: "fbjs@npm:0.8.18" - dependencies: - core-js: ^1.0.0 - isomorphic-fetch: ^2.1.1 - loose-envify: ^1.0.0 - object-assign: ^4.1.0 - promise: ^7.1.1 - setimmediate: ^1.0.5 - ua-parser-js: ^0.7.30 - checksum: 668731b946a765908c9cbe51d5160f973abb78004b3d122587c3e930e3e1ddcc0ce2b17f2a8637dc9d733e149aa580f8d3035a35cc2d3bc78b78f1b19aab90e2 - languageName: node - linkType: hard - "fd-slicer@npm:~1.1.0": version: 1.1.0 resolution: "fd-slicer@npm:1.1.0" @@ -20989,13 +20949,6 @@ __metadata: languageName: node linkType: hard -"hoist-non-react-statics@npm:^2.3.1": - version: 2.5.5 - resolution: "hoist-non-react-statics@npm:2.5.5" - checksum: ee2d05e5c7e1398ad84a15b0327f66bd78f38a8e0015e852f954b36434e32eb7e942d5357505020a3a1147f247b165bf1e69d72393e3accab67cafdafeb86230 - languageName: node - linkType: hard - "hosted-git-info@npm:^2.1.4": version: 2.8.9 resolution: "hosted-git-info@npm:2.8.9" @@ -22503,7 +22456,7 @@ __metadata: languageName: node linkType: hard -"is-stream@npm:^1.0.1, is-stream@npm:^1.1.0": +"is-stream@npm:^1.1.0": version: 1.1.0 resolution: "is-stream@npm:1.1.0" checksum: 063c6bec9d5647aa6d42108d4c59723d2bd4ae42135a2d4db6eadbd49b7ea05b750fd69d279e5c7c45cf9da753ad2c00d8978be354d65aa9f6bb434969c6a2ae @@ -22703,16 +22656,6 @@ __metadata: languageName: node linkType: hard -"isomorphic-fetch@npm:^2.1.1": - version: 2.2.1 - resolution: "isomorphic-fetch@npm:2.2.1" - dependencies: - node-fetch: ^1.0.1 - whatwg-fetch: ">=0.10.0" - checksum: bb5daa7c3785d6742f4379a81e55b549a469503f7c9bf9411b48592e86632cf5e8fe8ea878dba185c0f33eb7c510c23abdeb55aebfdf5d3c70f031ced68c5424 - languageName: node - linkType: hard - "isstream@npm:~0.1.2": version: 0.1.2 resolution: "isstream@npm:0.1.2" @@ -26448,16 +26391,6 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^1.0.1": - version: 1.7.3 - resolution: "node-fetch@npm:1.7.3" - dependencies: - encoding: ^0.1.11 - is-stream: ^1.0.1 - checksum: 3bb0528c05d541316ebe52770d71ee25a6dce334df4231fd55df41a644143e07f068637488c18a5b0c43f05041dbd3346752f9e19b50df50569a802484544d5b - languageName: node - linkType: hard - "node-fetch@npm:^2.6.1": version: 2.6.5 resolution: "node-fetch@npm:2.6.5" @@ -29509,15 +29442,6 @@ __metadata: languageName: node linkType: hard -"promise@npm:^7.1.1": - version: 7.3.1 - resolution: "promise@npm:7.3.1" - dependencies: - asap: ~2.0.3 - checksum: 475bb069130179fbd27ed2ab45f26d8862376a137a57314cf53310bdd85cc986a826fd585829be97ebc0aaf10e9d8e68be1bfe5a4a0364144b1f9eedfa940cf1 - languageName: node - linkType: hard - "prompts@npm:^2.0.1, prompts@npm:^2.4.0, prompts@npm:^2.4.2": version: 2.4.2 resolution: "prompts@npm:2.4.2" @@ -30701,7 +30625,7 @@ __metadata: languageName: node linkType: hard -"react-lifecycles-compat@npm:^3.0.2, react-lifecycles-compat@npm:^3.0.4": +"react-lifecycles-compat@npm:^3.0.4": version: 3.0.4 resolution: "react-lifecycles-compat@npm:3.0.4" checksum: a904b0fc0a8eeb15a148c9feb7bc17cec7ef96e71188280061fc340043fd6d8ee3ff233381f0e8f95c1cf926210b2c4a31f38182c8f35ac55057e453d6df204f @@ -31397,22 +31321,6 @@ __metadata: languageName: node linkType: hard -"recompose@npm:0.30.0": - version: 0.30.0 - resolution: "recompose@npm:0.30.0" - dependencies: - "@babel/runtime": ^7.0.0 - change-emitter: ^0.1.2 - fbjs: ^0.8.1 - hoist-non-react-statics: ^2.3.1 - react-lifecycles-compat: ^3.0.2 - symbol-observable: ^1.0.4 - peerDependencies: - react: ^0.14.0 || ^15.0.0 || ^16.0.0 - checksum: 18e58252336d0628b22db1e38407d32e836648e6d5c9453ba37c9f8030138b3429ee3952b053a13b60311f8b60893b207a761466bb293083542db0cf317b7a41 - languageName: node - linkType: hard - "recursive-readdir@npm:^2.2.2": version: 2.2.2 resolution: "recursive-readdir@npm:2.2.2" @@ -32707,7 +32615,7 @@ __metadata: languageName: node linkType: hard -"setimmediate@npm:^1.0.4, setimmediate@npm:^1.0.5": +"setimmediate@npm:^1.0.4": version: 1.0.5 resolution: "setimmediate@npm:1.0.5" checksum: c9a6f2c5b51a2dabdc0247db9c46460152ffc62ee139f3157440bd48e7c59425093f42719ac1d7931f054f153e2d26cf37dfeb8da17a794a58198a2705e527fd @@ -34213,13 +34121,6 @@ __metadata: languageName: node linkType: hard -"symbol-observable@npm:^1.0.4": - version: 1.2.0 - resolution: "symbol-observable@npm:1.2.0" - checksum: 48ffbc22e3d75f9853b3ff2ae94a44d84f386415110aea5effc24d84c502e03a4a6b7a8f75ebaf7b585780bda34eb5d6da3121f826a6f93398429d30032971b6 - languageName: node - linkType: hard - "symbol-tree@npm:^3.2.2, symbol-tree@npm:^3.2.4": version: 3.2.4 resolution: "symbol-tree@npm:3.2.4" @@ -35457,13 +35358,6 @@ __metadata: languageName: node linkType: hard -"ua-parser-js@npm:^0.7.30": - version: 0.7.30 - resolution: "ua-parser-js@npm:0.7.30" - checksum: 19aad6e1b9e93cf3c57e139858811c2c94031ed769f1ac36dc3cda1e1e5d2c73c2e02ddf4f9e0ea140069f803315484f955488961172a6a01e9322b6c042ae98 - languageName: node - linkType: hard - "uglify-js@npm:3.4.x": version: 3.4.10 resolution: "uglify-js@npm:3.4.10" @@ -36851,7 +36745,7 @@ __metadata: languageName: node linkType: hard -"whatwg-fetch@npm:3.6.2, whatwg-fetch@npm:>=0.10.0": +"whatwg-fetch@npm:3.6.2": version: 3.6.2 resolution: "whatwg-fetch@npm:3.6.2" checksum: ee976b7249e7791edb0d0a62cd806b29006ad7ec3a3d89145921ad8c00a3a67e4be8f3fb3ec6bc7b58498724fd568d11aeeeea1f7827e7e1e5eae6c8a275afed From 42e547d27f31ac5723a1d5fb5bf26e109a96c5c4 Mon Sep 17 00:00:00 2001 From: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> Date: Thu, 17 Feb 2022 10:44:03 -0500 Subject: [PATCH 047/104] Docs: Updated 8.4 What's New (#45535) * replaced * with * Ran prettier check. From c8bb01d1ad98497bb7f9b9f64a992a8f6dc149cf Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Thu, 17 Feb 2022 10:50:22 -0500 Subject: [PATCH 048/104] Expressions: Add support for variables (#41778) --- .../about-expressions.md | 2 - .../expressions/ExpressionDatasource.test.ts | 37 +++++++++++++++++++ .../expressions/ExpressionDatasource.ts | 36 +++++++++++++++++- 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 public/app/features/expressions/ExpressionDatasource.test.ts diff --git a/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions.md b/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions.md index 3563b22e542..9085d2a400e 100644 --- a/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions.md +++ b/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions.md @@ -16,8 +16,6 @@ Expressions are primarily used by the new [Grafana 8 alerts]({{< relref "../../. > **Note:** Expressions do not work with legacy dashboard alerts. -> **Note:** Expressions do not work with dashboard variables. - Expressions are meant to augment data sources by enabling queries from different data sources to be combined or by providing operations unavailable in a data source. > **Note:** When possible, you should do data processing inside the data source. Copying data from storage to the Grafana server for processing is inefficient, so expressions are targeted at lightweight data processing. diff --git a/public/app/features/expressions/ExpressionDatasource.test.ts b/public/app/features/expressions/ExpressionDatasource.test.ts new file mode 100644 index 00000000000..139ec38c23c --- /dev/null +++ b/public/app/features/expressions/ExpressionDatasource.test.ts @@ -0,0 +1,37 @@ +import { DataSourceInstanceSettings } from '@grafana/data'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { ExpressionDatasourceApi } from './ExpressionDatasource'; +import { ExpressionQueryType } from './types'; + +jest.mock('@grafana/runtime', () => ({ + ...(jest.requireActual('@grafana/runtime') as unknown as object), + getBackendSrv: () => backendSrv, + getTemplateSrv: () => ({ + replace: (val: string) => (val ? val.replace('$input', '10').replace('$window', '10s') : val), + }), +})); + +describe('ExpressionDatasourceApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('expression queries with template variables', () => { + it('should interpolate template variables in expression query', () => { + const ds = new ExpressionDatasourceApi({} as DataSourceInstanceSettings); + const query = ds.applyTemplateVariables( + { type: ExpressionQueryType.math, refId: 'B', expression: '$input + 5 + $A' }, + {} + ); + expect(query.expression).toBe('10 + 5 + $A'); + }); + it('should interpolate template variables in expression query', () => { + const ds = new ExpressionDatasourceApi({} as DataSourceInstanceSettings); + const query = ds.applyTemplateVariables( + { type: ExpressionQueryType.resample, refId: 'B', window: '$window' }, + {} + ); + expect(query.window).toBe('10s'); + }); + }); +}); diff --git a/public/app/features/expressions/ExpressionDatasource.ts b/public/app/features/expressions/ExpressionDatasource.ts index cfc5be8e385..15983e97bf5 100644 --- a/public/app/features/expressions/ExpressionDatasource.ts +++ b/public/app/features/expressions/ExpressionDatasource.ts @@ -1,8 +1,16 @@ -import { DataSourceInstanceSettings, DataSourcePluginMeta, PluginType } from '@grafana/data'; +import { + DataQueryRequest, + DataQueryResponse, + DataSourceInstanceSettings, + DataSourcePluginMeta, + PluginType, + ScopedVars, +} from '@grafana/data'; import { ExpressionQuery, ExpressionQueryType } from './types'; import { ExpressionQueryEditor } from './ExpressionQueryEditor'; -import { DataSourceWithBackend } from '@grafana/runtime'; +import { DataSourceWithBackend, getDataSourceSrv, getTemplateSrv } from '@grafana/runtime'; import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; +import { Observable, from, mergeMap } from 'rxjs'; /** * This is a singleton instance that just pretends to be a DataSource @@ -12,10 +20,34 @@ export class ExpressionDatasourceApi extends DataSourceWithBackend { + const templateSrv = getTemplateSrv(); + return { + ...query, + expression: templateSrv.replace(query.expression, scopedVars), + window: templateSrv.replace(query.window, scopedVars), + }; + } + getCollapsedText(query: ExpressionQuery) { return `Expression: ${query.type}`; } + query(request: DataQueryRequest): Observable { + let targets = request.targets.map(async (query: ExpressionQuery): Promise => { + const ds = await getDataSourceSrv().get(query.datasource); + + if (!ds.interpolateVariablesInQueries) { + return query; + } + + return ds?.interpolateVariablesInQueries([query], {})[0] as ExpressionQuery; + }); + + let sub = from(Promise.all(targets)); + return sub.pipe(mergeMap((t) => super.query({ ...request, targets: t }))); + } + newQuery(query?: Partial): ExpressionQuery { return { refId: '--', // Replaced with query From d3ae8939af3fdddbcf593a244d0d60199eb26027 Mon Sep 17 00:00:00 2001 From: Kat Yang <69819079+yangkb09@users.noreply.github.com> Date: Thu, 17 Feb 2022 10:59:09 -0500 Subject: [PATCH 049/104] Chore: Remove x from health, alert notification, dashboard, stats, user (#45265) * Chore: Remove x from health * Chore: Remove x from dashboard and user * Chore: Remove x from alert notification * Chore: Remove x from stats * Fix: Update func signature in stats test * Refactor: Remove x from GetDashboardTags * Chore: Remove x from dashboard * Chore: Remove x from Stats * Fix: Update refs of HasAdminPermissionInFolders * Fix: Adjust funcs in tests to be sqlStore methods * Fix: Fix database folder test sqlstore methods --- pkg/api/api.go | 2 +- pkg/middleware/auth.go | 40 +-- .../database/database_dashboard_test.go | 2 +- .../database/database_folder_test.go | 12 +- pkg/services/sqlstore/alert_notification.go | 2 +- pkg/services/sqlstore/dashboard.go | 203 ++++++++-------- pkg/services/sqlstore/health.go | 2 +- pkg/services/sqlstore/mockstore/mockstore.go | 4 + pkg/services/sqlstore/stats.go | 227 +++++++++--------- pkg/services/sqlstore/stats_test.go | 4 +- pkg/services/sqlstore/store.go | 1 + pkg/services/sqlstore/user.go | 2 +- 12 files changed, 264 insertions(+), 237 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 8615e41a3ce..535cca95ab7 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -26,7 +26,7 @@ func (hs *HTTPServer) registerRoutes() { reqGrafanaAdmin := middleware.ReqGrafanaAdmin reqEditorRole := middleware.ReqEditorRole reqOrgAdmin := middleware.ReqOrgAdmin - reqOrgAdminFolderAdminOrTeamAdmin := middleware.OrgAdminFolderAdminOrTeamAdmin + reqOrgAdminFolderAdminOrTeamAdmin := middleware.OrgAdminFolderAdminOrTeamAdmin(hs.SQLStore) reqCanAccessTeams := middleware.AdminOrEditorAndFeatureEnabled(hs.Cfg.EditorsCanAdmin) reqSnapshotPublicModeOrSignedIn := middleware.SnapshotPublicModeOrSignedIn(hs.Cfg) redirectFromLegacyPanelEditURL := middleware.RedirectFromLegacyPanelEditURL(hs.Cfg) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index a3ff1276639..a3b2a2f2e89 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -191,28 +191,30 @@ func shouldForceLogin(c *models.ReqContext) bool { return forceLogin } -func OrgAdminFolderAdminOrTeamAdmin(c *models.ReqContext) { - if c.OrgRole == models.ROLE_ADMIN { - return - } +func OrgAdminFolderAdminOrTeamAdmin(ss sqlstore.Store) func(c *models.ReqContext) { + return func(c *models.ReqContext) { + if c.OrgRole == models.ROLE_ADMIN { + return + } - hasAdminPermissionInFoldersQuery := models.HasAdminPermissionInFoldersQuery{SignedInUser: c.SignedInUser} - if err := sqlstore.HasAdminPermissionInFolders(c.Req.Context(), &hasAdminPermissionInFoldersQuery); err != nil { - c.JsonApiErr(500, "Failed to check if user is a folder admin", err) - } + hasAdminPermissionInFoldersQuery := models.HasAdminPermissionInFoldersQuery{SignedInUser: c.SignedInUser} + if err := ss.HasAdminPermissionInFolders(c.Req.Context(), &hasAdminPermissionInFoldersQuery); err != nil { + c.JsonApiErr(500, "Failed to check if user is a folder admin", err) + } - if hasAdminPermissionInFoldersQuery.Result { - return - } + if hasAdminPermissionInFoldersQuery.Result { + return + } - isAdminOfTeamsQuery := models.IsAdminOfTeamsQuery{SignedInUser: c.SignedInUser} - if err := sqlstore.IsAdminOfTeams(c.Req.Context(), &isAdminOfTeamsQuery); err != nil { - c.JsonApiErr(500, "Failed to check if user is a team admin", err) - } + isAdminOfTeamsQuery := models.IsAdminOfTeamsQuery{SignedInUser: c.SignedInUser} + if err := sqlstore.IsAdminOfTeams(c.Req.Context(), &isAdminOfTeamsQuery); err != nil { + c.JsonApiErr(500, "Failed to check if user is a team admin", err) + } - if isAdminOfTeamsQuery.Result { - return - } + if isAdminOfTeamsQuery.Result { + return + } - accessForbidden(c) + accessForbidden(c) + } } diff --git a/pkg/services/dashboards/database/database_dashboard_test.go b/pkg/services/dashboards/database/database_dashboard_test.go index a6d5033d56e..f02745b4512 100644 --- a/pkg/services/dashboards/database/database_dashboard_test.go +++ b/pkg/services/dashboards/database/database_dashboard_test.go @@ -421,7 +421,7 @@ func TestDashboardDataAccessGivenPluginWithImportedDashboards(t *testing.T) { OrgId: 1, } - err := sqlstore.GetDashboardsByPluginId(context.Background(), &query) + err := sqlStore.GetDashboardsByPluginId(context.Background(), &query) require.NoError(t, err) require.Equal(t, len(query.Result), 2) } diff --git a/pkg/services/dashboards/database/database_folder_test.go b/pkg/services/dashboards/database/database_folder_test.go index c77ad36e1d4..4718c41a7fb 100644 --- a/pkg/services/dashboards/database/database_folder_test.go +++ b/pkg/services/dashboards/database/database_folder_test.go @@ -317,7 +317,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { OrgRole: models.ROLE_ADMIN, } - err := sqlstore.GetDashboardPermissionsForUser(context.Background(), &query) + err := sqlStore.GetDashboardPermissionsForUser(context.Background(), &query) require.NoError(t, err) require.Equal(t, len(query.Result), 2) @@ -340,7 +340,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { query := &models.HasAdminPermissionInFoldersQuery{ SignedInUser: &models.SignedInUser{UserId: adminUser.Id, OrgId: 1, OrgRole: models.ROLE_ADMIN}, } - err := sqlstore.HasAdminPermissionInFolders(context.Background(), query) + err := sqlStore.HasAdminPermissionInFolders(context.Background(), query) require.NoError(t, err) require.True(t, query.Result) }) @@ -370,7 +370,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { OrgRole: models.ROLE_EDITOR, } - err := sqlstore.GetDashboardPermissionsForUser(context.Background(), &query) + err := sqlStore.GetDashboardPermissionsForUser(context.Background(), &query) require.NoError(t, err) require.Equal(t, len(query.Result), 2) @@ -406,7 +406,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { query := &models.HasAdminPermissionInFoldersQuery{ SignedInUser: &models.SignedInUser{UserId: adminUser.Id, OrgId: 1, OrgRole: models.ROLE_EDITOR}, } - err := sqlstore.HasAdminPermissionInFolders(context.Background(), query) + err := sqlStore.HasAdminPermissionInFolders(context.Background(), query) require.NoError(t, err) require.False(t, query.Result) }) @@ -436,7 +436,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { OrgRole: models.ROLE_VIEWER, } - err := sqlstore.GetDashboardPermissionsForUser(context.Background(), &query) + err := sqlStore.GetDashboardPermissionsForUser(context.Background(), &query) require.NoError(t, err) require.Equal(t, len(query.Result), 2) @@ -474,7 +474,7 @@ func TestDashboardFolderDataAccess(t *testing.T) { query := &models.HasAdminPermissionInFoldersQuery{ SignedInUser: &models.SignedInUser{UserId: adminUser.Id, OrgId: 1, OrgRole: models.ROLE_VIEWER}, } - err := sqlstore.HasAdminPermissionInFolders(context.Background(), query) + err := sqlStore.HasAdminPermissionInFolders(context.Background(), query) require.NoError(t, err) require.False(t, query.Result) }) diff --git a/pkg/services/sqlstore/alert_notification.go b/pkg/services/sqlstore/alert_notification.go index b7d536d765e..0d6bd261415 100644 --- a/pkg/services/sqlstore/alert_notification.go +++ b/pkg/services/sqlstore/alert_notification.go @@ -513,7 +513,7 @@ func (ss *SQLStore) SetAlertNotificationStateToCompleteCommand(ctx context.Conte } func (ss *SQLStore) SetAlertNotificationStateToPendingCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToPendingCommand) error { - return withDbSession(ctx, x, func(sess *DBSession) error { + return ss.WithDbSession(ctx, func(sess *DBSession) error { newVersion := cmd.Version + 1 sql := `UPDATE alert_notification_state SET state = ?, diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index a593fbb9d6c..6241d347274 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -23,11 +23,6 @@ var shadowSearchCounter = prometheus.NewCounterVec( ) func init() { - bus.AddHandler("sql", GetDashboardSlugById) - bus.AddHandler("sql", GetDashboardsByPluginId) - bus.AddHandler("sql", GetDashboardPermissionsForUser) - bus.AddHandler("sql", HasAdminPermissionInFolders) - prometheus.MustRegister(shadowSearchCounter) } @@ -39,12 +34,16 @@ func (ss *SQLStore) addDashboardQueryAndCommandHandlers() { bus.AddHandler("sql", ss.DeleteDashboard) bus.AddHandler("sql", ss.GetDashboards) bus.AddHandler("sql", ss.HasEditPermissionInFolders) + bus.AddHandler("sql", ss.GetDashboardPermissionsForUser) + bus.AddHandler("sql", ss.GetDashboardsByPluginId) + bus.AddHandler("sql", ss.GetDashboardSlugById) + bus.AddHandler("sql", ss.HasAdminPermissionInFolders) } var generateNewUid func() string = util.GenerateShortUID func (ss *SQLStore) GetDashboard(ctx context.Context, query *models.GetDashboardQuery) error { - return withDbSession(ctx, x, func(dbSession *DBSession) error { + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { if query.Id == 0 && len(query.Slug) == 0 && len(query.Uid) == 0 { return models.ErrDashboardIdentifierNotSet } @@ -211,7 +210,8 @@ func makeQueryResult(query *search.FindPersistedDashboardsQuery, res []Dashboard } func (ss *SQLStore) GetDashboardTags(ctx context.Context, query *models.GetDashboardTagsQuery) error { - sql := `SELECT + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + sql := `SELECT COUNT(*) as count, term FROM dashboard @@ -220,10 +220,11 @@ func (ss *SQLStore) GetDashboardTags(ctx context.Context, query *models.GetDashb GROUP BY term ORDER BY term` - query.Result = make([]*models.DashboardTagCloudItem, 0) - sess := x.SQL(sql, query.OrgId) - err := sess.Find(&query.Result) - return err + query.Result = make([]*models.DashboardTagCloudItem, 0) + sess := dbSession.SQL(sql, query.OrgId) + err := sess.Find(&query.Result) + return err + }) } func (ss *SQLStore) DeleteDashboard(ctx context.Context, cmd *models.DeleteDashboardCommand) error { @@ -326,65 +327,68 @@ func deleteDashboard(cmd *models.DeleteDashboardCommand, sess *DBSession) error } func (ss *SQLStore) GetDashboards(ctx context.Context, query *models.GetDashboardsQuery) error { - if len(query.DashboardIds) == 0 { - return models.ErrCommandValidationFailed - } + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + if len(query.DashboardIds) == 0 { + return models.ErrCommandValidationFailed + } - var dashboards = make([]*models.Dashboard, 0) + var dashboards = make([]*models.Dashboard, 0) - err := x.In("id", query.DashboardIds).Find(&dashboards) - query.Result = dashboards - return err + err := dbSession.In("id", query.DashboardIds).Find(&dashboards) + query.Result = dashboards + return err + }) } // GetDashboardPermissionsForUser returns the maximum permission the specified user has for a dashboard(s) // The function takes in a list of dashboard ids and the user id and role -func GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashboardPermissionsForUserQuery) error { - if len(query.DashboardIds) == 0 { - return models.ErrCommandValidationFailed - } - - if query.OrgRole == models.ROLE_ADMIN { - var permissions = make([]*models.DashboardPermissionForUser, 0) - for _, d := range query.DashboardIds { - permissions = append(permissions, &models.DashboardPermissionForUser{ - DashboardId: d, - Permission: models.PERMISSION_ADMIN, - PermissionName: models.PERMISSION_ADMIN.String(), - }) +func (ss *SQLStore) GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashboardPermissionsForUserQuery) error { + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + if len(query.DashboardIds) == 0 { + return models.ErrCommandValidationFailed } - query.Result = permissions - return nil - } + if query.OrgRole == models.ROLE_ADMIN { + var permissions = make([]*models.DashboardPermissionForUser, 0) + for _, d := range query.DashboardIds { + permissions = append(permissions, &models.DashboardPermissionForUser{ + DashboardId: d, + Permission: models.PERMISSION_ADMIN, + PermissionName: models.PERMISSION_ADMIN.String(), + }) + } + query.Result = permissions - params := make([]interface{}, 0) + return nil + } - // check dashboards that have ACLs via user id, team id or role - sql := `SELECT d.id AS dashboard_id, MAX(COALESCE(da.permission, pt.permission)) AS permission + params := make([]interface{}, 0) + + // check dashboards that have ACLs via user id, team id or role + sql := `SELECT d.id AS dashboard_id, MAX(COALESCE(da.permission, pt.permission)) AS permission FROM dashboard AS d LEFT JOIN dashboard_acl as da on d.folder_id = da.dashboard_id or d.id = da.dashboard_id LEFT JOIN team_member as ugm on ugm.team_id = da.team_id LEFT JOIN org_user ou ON ou.role = da.role AND ou.user_id = ? ` - params = append(params, query.UserId) + params = append(params, query.UserId) - // check the user's role for dashboards that do not have hasAcl set - sql += `LEFT JOIN org_user ouRole ON ouRole.user_id = ? AND ouRole.org_id = ?` - params = append(params, query.UserId) - params = append(params, query.OrgId) + // check the user's role for dashboards that do not have hasAcl set + sql += `LEFT JOIN org_user ouRole ON ouRole.user_id = ? AND ouRole.org_id = ?` + params = append(params, query.UserId) + params = append(params, query.OrgId) - sql += ` + sql += ` LEFT JOIN (SELECT 1 AS permission, 'Viewer' AS role UNION SELECT 2 AS permission, 'Editor' AS role UNION SELECT 4 AS permission, 'Admin' AS role) pt ON ouRole.role = pt.role WHERE d.Id IN (?` + strings.Repeat(",?", len(query.DashboardIds)-1) + `) ` - for _, id := range query.DashboardIds { - params = append(params, id) - } + for _, id := range query.DashboardIds { + params = append(params, id) + } - sql += ` AND + sql += ` AND d.org_id = ? AND ( (d.has_acl = ? AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL)) @@ -392,48 +396,53 @@ func GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashbo ) group by d.id order by d.id asc` - params = append(params, query.OrgId) - params = append(params, dialect.BooleanStr(true)) - params = append(params, query.UserId) - params = append(params, query.UserId) - params = append(params, dialect.BooleanStr(false)) + params = append(params, query.OrgId) + params = append(params, dialect.BooleanStr(true)) + params = append(params, query.UserId) + params = append(params, query.UserId) + params = append(params, dialect.BooleanStr(false)) - err := x.SQL(sql, params...).Find(&query.Result) + err := dbSession.SQL(sql, params...).Find(&query.Result) - for _, p := range query.Result { - p.PermissionName = p.Permission.String() - } + for _, p := range query.Result { + p.PermissionName = p.Permission.String() + } - return err + return err + }) } -func GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error { - var dashboards = make([]*models.Dashboard, 0) - whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + dialect.BooleanStr(false) +func (ss *SQLStore) GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error { + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + var dashboards = make([]*models.Dashboard, 0) + whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + dialect.BooleanStr(false) - err := x.Where(whereExpr, query.OrgId, query.PluginId).Find(&dashboards) - query.Result = dashboards - return err + err := dbSession.Where(whereExpr, query.OrgId, query.PluginId).Find(&dashboards) + query.Result = dashboards + return err + }) } type DashboardSlugDTO struct { Slug string } -func GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error { - var rawSQL = `SELECT slug from dashboard WHERE Id=?` - var slug = DashboardSlugDTO{} +func (ss *SQLStore) GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error { + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + var rawSQL = `SELECT slug from dashboard WHERE Id=?` + var slug = DashboardSlugDTO{} - exists, err := x.SQL(rawSQL, query.Id).Get(&slug) + exists, err := dbSession.SQL(rawSQL, query.Id).Get(&slug) - if err != nil { - return err - } else if !exists { - return models.ErrDashboardNotFound - } + if err != nil { + return err + } else if !exists { + return models.ErrDashboardNotFound + } - query.Result = slug.Slug - return nil + query.Result = slug.Slug + return nil + }) } func (ss *SQLStore) GetDashboardUIDById(ctx context.Context, query *models.GetDashboardRefByIdQuery) error { @@ -457,7 +466,7 @@ func (ss *SQLStore) GetDashboardUIDById(ctx context.Context, query *models.GetDa // HasEditPermissionInFolders validates that an user have access to a certain folder func (ss *SQLStore) HasEditPermissionInFolders(ctx context.Context, query *models.HasEditPermissionInFoldersQuery) error { - return withDbSession(ctx, x, func(dbSession *DBSession) error { + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { if query.SignedInUser.HasRole(models.ROLE_EDITOR) { query.Result = true return nil @@ -483,26 +492,28 @@ func (ss *SQLStore) HasEditPermissionInFolders(ctx context.Context, query *model }) } -func HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error { - if query.SignedInUser.HasRole(models.ROLE_ADMIN) { - query.Result = true +func (ss *SQLStore) HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error { + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + if query.SignedInUser.HasRole(models.ROLE_ADMIN) { + query.Result = true + return nil + } + + builder := &SQLBuilder{} + builder.Write("SELECT COUNT(dashboard.id) AS count FROM dashboard WHERE dashboard.org_id = ? AND dashboard.is_folder = ?", query.SignedInUser.OrgId, dialect.BooleanStr(true)) + builder.WriteDashboardPermissionFilter(query.SignedInUser, models.PERMISSION_ADMIN) + + type folderCount struct { + Count int64 + } + + resp := make([]*folderCount, 0) + if err := dbSession.SQL(builder.GetSQLString(), builder.params...).Find(&resp); err != nil { + return err + } + + query.Result = len(resp) > 0 && resp[0].Count > 0 + return nil - } - - builder := &SQLBuilder{} - builder.Write("SELECT COUNT(dashboard.id) AS count FROM dashboard WHERE dashboard.org_id = ? AND dashboard.is_folder = ?", query.SignedInUser.OrgId, dialect.BooleanStr(true)) - builder.WriteDashboardPermissionFilter(query.SignedInUser, models.PERMISSION_ADMIN) - - type folderCount struct { - Count int64 - } - - resp := make([]*folderCount, 0) - if err := x.SQL(builder.GetSQLString(), builder.params...).Find(&resp); err != nil { - return err - } - - query.Result = len(resp) > 0 && resp[0].Count > 0 - - return nil + }) } diff --git a/pkg/services/sqlstore/health.go b/pkg/services/sqlstore/health.go index a0a1b4551fc..9e1e98728cc 100644 --- a/pkg/services/sqlstore/health.go +++ b/pkg/services/sqlstore/health.go @@ -9,7 +9,7 @@ import ( // GetDBHealthQuery executes a query to check // the availability of the database. func (ss *SQLStore) GetDBHealthQuery(ctx context.Context, query *models.GetDBHealthQuery) error { - return withDbSession(ctx, x, func(session *DBSession) error { + return ss.WithDbSession(ctx, func(session *DBSession) error { _, err := session.Exec("SELECT 1") return err }) diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go index 889e2d24fb0..6a5afe93c4d 100644 --- a/pkg/services/sqlstore/mockstore/mockstore.go +++ b/pkg/services/sqlstore/mockstore/mockstore.go @@ -637,3 +637,7 @@ func (m *SQLStoreMock) SearchOrgs(ctx context.Context, query *models.SearchOrgsQ query.Result = m.ExpectedSearchOrgList return m.ExpectedError } + +func (m *SQLStoreMock) HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error { + return m.ExpectedError +} diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/sqlstore/stats.go index 0a0a8da7fe4..cd1de67f128 100644 --- a/pkg/services/sqlstore/stats.go +++ b/pkg/services/sqlstore/stats.go @@ -9,12 +9,9 @@ import ( "github.com/grafana/grafana/pkg/models" ) -func init() { - bus.AddHandler("sql", GetSystemUserCountStats) -} - func (ss *SQLStore) addStatsQueryAndCommandHandlers() { bus.AddHandler("sql", ss.GetAdminStats) + bus.AddHandler("sql", ss.GetSystemUserCountStats) bus.AddHandler("sql", ss.GetAlertNotifiersUsageStats) bus.AddHandler("sql", ss.GetDataSourceAccessStats) bus.AddHandler("sql", ss.GetDataSourceStats) @@ -25,50 +22,57 @@ const activeUserTimeLimit = time.Hour * 24 * 30 const dailyActiveUserTimeLimit = time.Hour * 24 func (ss *SQLStore) GetAlertNotifiersUsageStats(ctx context.Context, query *models.GetAlertNotifierUsageStatsQuery) error { - var rawSQL = `SELECT COUNT(*) AS count, type FROM ` + dialect.Quote("alert_notification") + ` GROUP BY type` - query.Result = make([]*models.NotifierUsageStats, 0) - err := x.SQL(rawSQL).Find(&query.Result) - return err + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + var rawSQL = `SELECT COUNT(*) AS count, type FROM ` + dialect.Quote("alert_notification") + ` GROUP BY type` + query.Result = make([]*models.NotifierUsageStats, 0) + err := dbSession.SQL(rawSQL).Find(&query.Result) + return err + }) } func (ss *SQLStore) GetDataSourceStats(ctx context.Context, query *models.GetDataSourceStatsQuery) error { - var rawSQL = `SELECT COUNT(*) AS count, type FROM ` + dialect.Quote("data_source") + ` GROUP BY type` - query.Result = make([]*models.DataSourceStats, 0) - err := x.SQL(rawSQL).Find(&query.Result) - return err + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + var rawSQL = `SELECT COUNT(*) AS count, type FROM ` + dialect.Quote("data_source") + ` GROUP BY type` + query.Result = make([]*models.DataSourceStats, 0) + err := dbSession.SQL(rawSQL).Find(&query.Result) + return err + }) } func (ss *SQLStore) GetDataSourceAccessStats(ctx context.Context, query *models.GetDataSourceAccessStatsQuery) error { - var rawSQL = `SELECT COUNT(*) AS count, type, access FROM ` + dialect.Quote("data_source") + ` GROUP BY type, access` - query.Result = make([]*models.DataSourceAccessStats, 0) - err := x.SQL(rawSQL).Find(&query.Result) - return err + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + var rawSQL = `SELECT COUNT(*) AS count, type, access FROM ` + dialect.Quote("data_source") + ` GROUP BY type, access` + query.Result = make([]*models.DataSourceAccessStats, 0) + err := dbSession.SQL(rawSQL).Find(&query.Result) + return err + }) } func (ss *SQLStore) GetSystemStats(ctx context.Context, query *models.GetSystemStatsQuery) error { - sb := &SQLBuilder{} - sb.Write("SELECT ") - sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("user") + `) AS users,`) - sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("org") + `) AS orgs,`) - sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("data_source") + `) AS datasources,`) - sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("star") + `) AS stars,`) - sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("playlist") + `) AS playlists,`) - sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("alert") + `) AS alerts,`) + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + sb := &SQLBuilder{} + sb.Write("SELECT ") + sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("user") + `) AS users,`) + sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("org") + `) AS orgs,`) + sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("data_source") + `) AS datasources,`) + sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("star") + `) AS stars,`) + sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("playlist") + `) AS playlists,`) + sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("alert") + `) AS alerts,`) - now := time.Now() - activeUserDeadlineDate := now.Add(-activeUserTimeLimit) - sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS active_users,`, activeUserDeadlineDate) + now := time.Now() + activeUserDeadlineDate := now.Add(-activeUserTimeLimit) + sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS active_users,`, activeUserDeadlineDate) - dailyActiveUserDeadlineDate := now.Add(-dailyActiveUserTimeLimit) - sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS daily_active_users,`, dailyActiveUserDeadlineDate) + dailyActiveUserDeadlineDate := now.Add(-dailyActiveUserTimeLimit) + sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS daily_active_users,`, dailyActiveUserDeadlineDate) - monthlyActiveUserDeadlineDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) - sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS monthly_active_users,`, monthlyActiveUserDeadlineDate) + monthlyActiveUserDeadlineDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS monthly_active_users,`, monthlyActiveUserDeadlineDate) - sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS dashboards,`, dialect.BooleanStr(false)) - sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS folders,`, dialect.BooleanStr(true)) + sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS dashboards,`, dialect.BooleanStr(false)) + sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS folders,`, dialect.BooleanStr(true)) - sb.Write(`( + sb.Write(`( SELECT COUNT(acl.id) FROM `+dialect.Quote("dashboard_acl")+` AS acl INNER JOIN `+dialect.Quote("dashboard")+` AS d @@ -76,7 +80,7 @@ func (ss *SQLStore) GetSystemStats(ctx context.Context, query *models.GetSystemS WHERE d.is_folder = ? ) AS dashboard_permissions,`, dialect.BooleanStr(false)) - sb.Write(`( + sb.Write(`( SELECT COUNT(acl.id) FROM `+dialect.Quote("dashboard_acl")+` AS acl INNER JOIN `+dialect.Quote("dashboard")+` AS d @@ -84,40 +88,41 @@ func (ss *SQLStore) GetSystemStats(ctx context.Context, query *models.GetSystemS WHERE d.is_folder = ? ) AS folder_permissions,`, dialect.BooleanStr(true)) - sb.Write(viewersPermissionsCounterSQL("dashboards_viewers_can_edit", false, models.PERMISSION_EDIT)) - sb.Write(viewersPermissionsCounterSQL("dashboards_viewers_can_admin", false, models.PERMISSION_ADMIN)) - sb.Write(viewersPermissionsCounterSQL("folders_viewers_can_edit", true, models.PERMISSION_EDIT)) - sb.Write(viewersPermissionsCounterSQL("folders_viewers_can_admin", true, models.PERMISSION_ADMIN)) + sb.Write(viewersPermissionsCounterSQL("dashboards_viewers_can_edit", false, models.PERMISSION_EDIT)) + sb.Write(viewersPermissionsCounterSQL("dashboards_viewers_can_admin", false, models.PERMISSION_ADMIN)) + sb.Write(viewersPermissionsCounterSQL("folders_viewers_can_edit", true, models.PERMISSION_EDIT)) + sb.Write(viewersPermissionsCounterSQL("folders_viewers_can_admin", true, models.PERMISSION_ADMIN)) - sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_provisioning") + `) AS provisioned_dashboards,`) - sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_snapshot") + `) AS snapshots,`) - sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_version") + `) AS dashboard_versions,`) - sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("annotation") + `) AS annotations,`) - sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("team") + `) AS teams,`) - sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("user_auth_token") + `) AS auth_tokens,`) - sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("alert_rule") + `) AS alert_rules,`) - sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("api_key") + `) AS api_keys,`) - sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("library_element")+` WHERE kind = ?) AS library_panels,`, models.PanelElement) - sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("library_element")+` WHERE kind = ?) AS library_variables,`, models.VariableElement) + sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_provisioning") + `) AS provisioned_dashboards,`) + sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_snapshot") + `) AS snapshots,`) + sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_version") + `) AS dashboard_versions,`) + sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("annotation") + `) AS annotations,`) + sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("team") + `) AS teams,`) + sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("user_auth_token") + `) AS auth_tokens,`) + sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("alert_rule") + `) AS alert_rules,`) + sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("api_key") + `) AS api_keys,`) + sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("library_element")+` WHERE kind = ?) AS library_panels,`, models.PanelElement) + sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("library_element")+` WHERE kind = ?) AS library_variables,`, models.VariableElement) - sb.Write(roleCounterSQL(ctx)) + sb.Write(ss.roleCounterSQL(ctx)) - var stats models.SystemStats - _, err := x.SQL(sb.GetSQLString(), sb.params...).Get(&stats) - if err != nil { - return err - } + var stats models.SystemStats + _, err := dbSession.SQL(sb.GetSQLString(), sb.params...).Get(&stats) + if err != nil { + return err + } - query.Result = &stats + query.Result = &stats - return nil + return nil + }) } -func roleCounterSQL(ctx context.Context) string { +func (ss *SQLStore) roleCounterSQL(ctx context.Context) string { const roleCounterTimeout = 20 * time.Second ctx, cancel := context.WithTimeout(ctx, roleCounterTimeout) defer cancel() - _ = updateUserRoleCountsIfNecessary(ctx, false) + _ = ss.updateUserRoleCountsIfNecessary(ctx, false) sqlQuery := strconv.FormatInt(userStatsCache.total.Admins, 10) + ` AS admins, ` + strconv.FormatInt(userStatsCache.total.Editors, 10) + ` AS editors, ` + @@ -145,12 +150,13 @@ func viewersPermissionsCounterSQL(statName string, isFolder bool, permission mod } func (ss *SQLStore) GetAdminStats(ctx context.Context, query *models.GetAdminStatsQuery) error { - now := time.Now() - activeEndDate := now.Add(-activeUserTimeLimit) - dailyActiveEndDate := now.Add(-dailyActiveUserTimeLimit) - monthlyActiveEndDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + now := time.Now() + activeEndDate := now.Add(-activeUserTimeLimit) + dailyActiveEndDate := now.Add(-dailyActiveUserTimeLimit) + monthlyActiveEndDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) - var rawSQL = `SELECT + var rawSQL = `SELECT ( SELECT COUNT(*) FROM ` + dialect.Quote("org") + ` @@ -199,7 +205,7 @@ func (ss *SQLStore) GetAdminStats(ctx context.Context, query *models.GetAdminSta SELECT COUNT(*) FROM ` + dialect.Quote("user") + ` WHERE last_seen_at > ? ) AS monthly_active_users, - ` + roleCounterSQL(ctx) + `, + ` + ss.roleCounterSQL(ctx) + `, ( SELECT COUNT(*) FROM ` + dialect.Quote("user_auth_token") + ` WHERE rotated_at > ? @@ -209,18 +215,19 @@ func (ss *SQLStore) GetAdminStats(ctx context.Context, query *models.GetAdminSta FROM ` + dialect.Quote("user_auth_token") + ` WHERE rotated_at > ? ) AS daily_active_sessions` - var stats models.AdminStats - _, err := x.SQL(rawSQL, activeEndDate, dailyActiveEndDate, monthlyActiveEndDate, activeEndDate.Unix(), dailyActiveEndDate.Unix()).Get(&stats) - if err != nil { - return err - } + var stats models.AdminStats + _, err := dbSession.SQL(rawSQL, activeEndDate, dailyActiveEndDate, monthlyActiveEndDate, activeEndDate.Unix(), dailyActiveEndDate.Unix()).Get(&stats) + if err != nil { + return err + } - query.Result = &stats - return nil + query.Result = &stats + return nil + }) } -func GetSystemUserCountStats(ctx context.Context, query *models.GetSystemUserCountStatsQuery) error { - return withDbSession(ctx, x, func(sess *DBSession) error { +func (ss *SQLStore) GetSystemUserCountStats(ctx context.Context, query *models.GetSystemUserCountStatsQuery) error { + return ss.WithDbSession(ctx, func(sess *DBSession) error { var rawSQL = `SELECT COUNT(id) AS Count FROM ` + dialect.Quote("user") var stats models.SystemUserCountStats _, err := sess.SQL(rawSQL).Get(&stats) @@ -234,10 +241,10 @@ func GetSystemUserCountStats(ctx context.Context, query *models.GetSystemUserCou }) } -func updateUserRoleCountsIfNecessary(ctx context.Context, forced bool) error { +func (ss *SQLStore) updateUserRoleCountsIfNecessary(ctx context.Context, forced bool) error { memoizationPeriod := time.Now().Add(-userStatsCacheLimetime) if forced || userStatsCache.memoized.Before(memoizationPeriod) { - err := updateUserRoleCounts(ctx) + err := ss.updateUserRoleCounts(ctx) if err != nil { return err } @@ -258,8 +265,9 @@ var ( userStatsCacheLimetime = 5 * time.Minute ) -func updateUserRoleCounts(ctx context.Context) error { - query := ` +func (ss *SQLStore) updateUserRoleCounts(ctx context.Context) error { + return ss.WithDbSession(ctx, func(dbSession *DBSession) error { + query := ` SELECT role AS bitrole, active, COUNT(role) AS count FROM (SELECT last_seen_at>? AS active, last_seen_at>? AS daily_active, SUM(role) AS role FROM (SELECT @@ -275,42 +283,43 @@ SELECT role AS bitrole, active, COUNT(role) AS count FROM GROUP BY id, last_seen_at) AS t1 GROUP BY active, daily_active, role;` - activeUserDeadline := time.Now().Add(-activeUserTimeLimit) - dailyActiveUserDeadline := time.Now().Add(-dailyActiveUserTimeLimit) + activeUserDeadline := time.Now().Add(-activeUserTimeLimit) + dailyActiveUserDeadline := time.Now().Add(-dailyActiveUserTimeLimit) - type rolebitmap struct { - Active bool - DailyActive bool - Bitrole int64 - Count int64 - } - - bitmap := []rolebitmap{} - err := x.Context(ctx).SQL(query, activeUserDeadline, dailyActiveUserDeadline).Find(&bitmap) - if err != nil { - return err - } - - memo := memoUserStats{memoized: time.Now()} - for _, role := range bitmap { - roletype := models.ROLE_VIEWER - if role.Bitrole&0b100 != 0 { - roletype = models.ROLE_ADMIN - } else if role.Bitrole&0b10 != 0 { - roletype = models.ROLE_EDITOR + type rolebitmap struct { + Active bool + DailyActive bool + Bitrole int64 + Count int64 } - memo.total = addToStats(memo.total, roletype, role.Count) - if role.Active { - memo.active = addToStats(memo.active, roletype, role.Count) + bitmap := []rolebitmap{} + err := dbSession.Context(ctx).SQL(query, activeUserDeadline, dailyActiveUserDeadline).Find(&bitmap) + if err != nil { + return err } - if role.DailyActive { - memo.dailyActive = addToStats(memo.dailyActive, roletype, role.Count) - } - } - userStatsCache = memo - return nil + memo := memoUserStats{memoized: time.Now()} + for _, role := range bitmap { + roletype := models.ROLE_VIEWER + if role.Bitrole&0b100 != 0 { + roletype = models.ROLE_ADMIN + } else if role.Bitrole&0b10 != 0 { + roletype = models.ROLE_EDITOR + } + + memo.total = addToStats(memo.total, roletype, role.Count) + if role.Active { + memo.active = addToStats(memo.active, roletype, role.Count) + } + if role.DailyActive { + memo.dailyActive = addToStats(memo.dailyActive, roletype, role.Count) + } + } + + userStatsCache = memo + return nil + }) } func addToStats(base models.UserStats, role models.RoleType, count int64) models.UserStats { diff --git a/pkg/services/sqlstore/stats_test.go b/pkg/services/sqlstore/stats_test.go index 1100e8fafea..bd0a67c778d 100644 --- a/pkg/services/sqlstore/stats_test.go +++ b/pkg/services/sqlstore/stats_test.go @@ -32,7 +32,7 @@ func TestStatsDataAccess(t *testing.T) { t.Run("Get system user count stats should not results in error", func(t *testing.T) { query := models.GetSystemUserCountStatsQuery{} - err := GetSystemUserCountStats(context.Background(), &query) + err := sqlStore.GetSystemUserCountStats(context.Background(), &query) assert.NoError(t, err) }) @@ -124,7 +124,7 @@ func populateDB(t *testing.T, sqlStore *SQLStore) { require.NoError(t, err) // force renewal of user stats - err = updateUserRoleCountsIfNecessary(context.Background(), true) + err = sqlStore.updateUserRoleCountsIfNecessary(context.Background(), true) require.NoError(t, err) // add 1st api key diff --git a/pkg/services/sqlstore/store.go b/pkg/services/sqlstore/store.go index a5e96afbcb8..13b3cd87318 100644 --- a/pkg/services/sqlstore/store.go +++ b/pkg/services/sqlstore/store.go @@ -147,4 +147,5 @@ type Store interface { ExpireOldUserInvites(ctx context.Context, cmd *models.ExpireTempUsersCommand) error GetDBHealthQuery(ctx context.Context, query *models.GetDBHealthQuery) error SearchOrgs(ctx context.Context, query *models.SearchOrgsQuery) error + HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error } diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index b38c2f9e4ea..67a08cdcb02 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -321,7 +321,7 @@ func (ss *SQLStore) CreateUser(ctx context.Context, cmd models.CreateUserCommand } func (ss SQLStore) GetUserById(ctx context.Context, query *models.GetUserByIdQuery) error { - return withDbSession(ctx, x, func(sess *DBSession) error { + return ss.WithDbSession(ctx, func(sess *DBSession) error { user := new(models.User) has, err := sess.ID(query.Id).Get(user) From bbddab0294ab53a763b28ce4aa6e6ef0878f0bc2 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 17 Feb 2022 08:12:38 -0800 Subject: [PATCH 050/104] Chore: refactor core transformer folder structure (#45497) --- .github/CODEOWNERS | 2 +- public/app/app.ts | 2 +- public/app/core/utils/standardTransformers.ts | 52 ------------------- .../TransformationsEditor.test.tsx | 2 +- public/app/features/geo/format/utils.ts | 2 +- .../features/geo/gazetteer/gazetteer.test.ts | 2 +- .../FilterByValueFilterEditor.tsx | 0 .../FilterByValueTransformerEditor.tsx | 0 .../ValueMatchers/BasicMatcherEditor.tsx | 0 .../ValueMatchers/NoopMatcherEditor.tsx | 0 .../ValueMatchers/RangeMatcherEditor.tsx | 0 .../ValueMatchers/types.ts | 0 .../ValueMatchers/utils.ts | 0 .../ValueMatchers/valueMatchersUI.ts | 0 .../HeatmapTransformerEditor.tsx | 0 .../calculateHeatmap/editor/AxisEditor.tsx | 0 .../calculateHeatmap/editor/helper.ts | 0 .../calculateHeatmap/heatmap.test.ts | 0 .../transformers}/calculateHeatmap/heatmap.ts | 0 .../calculateHeatmap/models.gen.ts | 0 .../transformers}/calculateHeatmap/utils.ts | 0 .../ConfigFromQueryTransformerEditor.test.tsx | 0 .../ConfigFromQueryTransformerEditor.tsx | 0 .../configFromQuery/configFromQuery.test.ts | 0 .../configFromQuery/configFromQuery.ts | 0 .../CalculateFieldTransformerEditor.tsx | 0 .../editors}/ConcatenateTransformerEditor.tsx | 0 .../ConvertFieldTypeTransformerEditor.tsx | 0 .../FilterByNameTransformerEditor.tsx | 0 .../FilterByRefIdTransformerEditor.tsx | 0 .../editors}/GroupByTransformerEditor.tsx | 2 +- .../editors}/HistogramTransformerEditor.tsx | 0 .../LabelsToFieldsTransformerEditor.tsx | 0 .../editors}/MergeTransformerEditor.tsx | 0 .../OrganizeFieldsTransformerEditor.tsx | 2 +- .../editors}/ReduceTransformerEditor.tsx | 0 .../editors}/RenameByRegexTransformer.tsx | 0 .../SeriesToFieldsTransformerEditor.tsx | 2 +- .../SeriesToRowsTransformerEditor.tsx | 0 .../editors}/SortByTransformerEditor.tsx | 2 +- .../ExtractFieldsTransformerEditor.tsx | 0 .../extractFields/extractFields.test.ts | 0 .../extractFields/extractFields.ts | 0 .../extractFields/fieldExtractor.test.ts | 0 .../extractFields/fieldExtractors.ts | 0 .../FieldToConfigMappingEditor.test.tsx | 0 .../FieldToConfigMappingEditor.tsx | 0 .../fieldToConfigMapping.ts | 0 .../FieldLookupTransformerEditor.tsx | 0 .../lookupGazetteer/fieldLookup.test.ts | 0 .../lookupGazetteer/fieldLookup.ts | 0 .../PrepareTimeSeriesEditor.tsx | 0 .../prepareTimeSeries.test.ts | 0 .../prepareTimeSeries/prepareTimeSeries.ts | 0 .../RowsToFieldsTransformerEditor.test.tsx | 0 .../RowsToFieldsTransformerEditor.tsx | 0 .../rowsToFields/rowsToFields.test.ts | 0 .../rowsToFields/rowsToFields.ts | 0 .../spatial/SpatialTransformerEditor.tsx | 0 .../transformers}/spatial/models.gen.ts | 0 .../transformers}/spatial/optionsHelper.tsx | 0 .../spatial/spatialTransformer.test.ts | 0 .../spatial/spatialTransformer.ts | 0 .../transformers}/spatial/utils.ts | 0 .../transformers/standardTransformers.ts | 52 +++++++++++++++++++ .../transformers}/utils.ts | 0 .../app/plugins/panel/heatmap-new/fields.ts | 2 +- .../plugins/panel/heatmap-new/migrations.ts | 2 +- .../plugins/panel/heatmap-new/models.gen.ts | 2 +- .../app/plugins/panel/heatmap-new/module.tsx | 2 +- 70 files changed, 65 insertions(+), 65 deletions(-) delete mode 100644 public/app/core/utils/standardTransformers.ts rename public/app/{core/components/TransformersUI => features/transformers}/FilterByValueTransformer/FilterByValueFilterEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/FilterByValueTransformer/FilterByValueTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/FilterByValueTransformer/ValueMatchers/types.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/FilterByValueTransformer/ValueMatchers/utils.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/calculateHeatmap/HeatmapTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/calculateHeatmap/editor/AxisEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/calculateHeatmap/editor/helper.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/calculateHeatmap/heatmap.test.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/calculateHeatmap/heatmap.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/calculateHeatmap/models.gen.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/calculateHeatmap/utils.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/configFromQuery/ConfigFromQueryTransformerEditor.test.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/configFromQuery/ConfigFromQueryTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/configFromQuery/configFromQuery.test.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/configFromQuery/configFromQuery.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers/editors}/CalculateFieldTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers/editors}/ConcatenateTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers/editors}/ConvertFieldTypeTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers/editors}/FilterByNameTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers/editors}/FilterByRefIdTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers/editors}/GroupByTransformerEditor.tsx (98%) rename public/app/{core/components/TransformersUI => features/transformers/editors}/HistogramTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers/editors}/LabelsToFieldsTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers/editors}/MergeTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers/editors}/OrganizeFieldsTransformerEditor.tsx (99%) rename public/app/{core/components/TransformersUI => features/transformers/editors}/ReduceTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers/editors}/RenameByRegexTransformer.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers/editors}/SeriesToFieldsTransformerEditor.tsx (96%) rename public/app/{core/components/TransformersUI => features/transformers/editors}/SeriesToRowsTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers/editors}/SortByTransformerEditor.tsx (97%) rename public/app/{core/components/TransformersUI => features/transformers}/extractFields/ExtractFieldsTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/extractFields/extractFields.test.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/extractFields/extractFields.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/extractFields/fieldExtractor.test.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/extractFields/fieldExtractors.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/fieldToConfigMapping/FieldToConfigMappingEditor.test.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/fieldToConfigMapping/FieldToConfigMappingEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/fieldToConfigMapping/fieldToConfigMapping.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/lookupGazetteer/FieldLookupTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/lookupGazetteer/fieldLookup.test.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/lookupGazetteer/fieldLookup.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/prepareTimeSeries/PrepareTimeSeriesEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/prepareTimeSeries/prepareTimeSeries.test.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/prepareTimeSeries/prepareTimeSeries.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/rowsToFields/RowsToFieldsTransformerEditor.test.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/rowsToFields/RowsToFieldsTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/rowsToFields/rowsToFields.test.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/rowsToFields/rowsToFields.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/spatial/SpatialTransformerEditor.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/spatial/models.gen.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/spatial/optionsHelper.tsx (100%) rename public/app/{core/components/TransformersUI => features/transformers}/spatial/spatialTransformer.test.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/spatial/spatialTransformer.ts (100%) rename public/app/{core/components/TransformersUI => features/transformers}/spatial/utils.ts (100%) create mode 100644 public/app/features/transformers/standardTransformers.ts rename public/app/{core/components/TransformersUI => features/transformers}/utils.ts (100%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 88f575d5b9d..495abdd9d94 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -104,7 +104,7 @@ go.sum @grafana/backend-platform /public/app/features/live/ @grafana/grafana-edge-squad /public/app/features/explore/ @grafana/observability-experience-squad /public/app/features/plugins @grafana/plugins-platform-frontend -/public/app/core/components/TransformersUI/spatial @grafana/grafana-edge-squad +/public/app/features/transformers/spatial @grafana/grafana-edge-squad /public/app/plugins/panel/alertlist @grafana/alerting-squad /public/app/plugins/panel/barchart @grafana/grafana-bi-squad /public/app/plugins/panel/heatmap @grafana/grafana-bi-squad diff --git a/public/app/app.ts b/public/app/app.ts index 48167320b11..1ca8c0435d7 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -41,7 +41,7 @@ import 'app/features/all'; import { getScrollbarWidth } from '@grafana/ui'; import { variableAdapters } from './features/variables/adapters'; import { initDevFeatures } from './dev'; -import { getStandardTransformers } from 'app/core/utils/standardTransformers'; +import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; import { SentryEchoBackend } from './core/services/echo/backends/sentry/SentryBackend'; import { setVariableQueryRunner, VariableQueryRunner } from './features/variables/query/VariableQueryRunner'; import { configureStore } from './store/configureStore'; diff --git a/public/app/core/utils/standardTransformers.ts b/public/app/core/utils/standardTransformers.ts deleted file mode 100644 index 41f29b975d0..00000000000 --- a/public/app/core/utils/standardTransformers.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { TransformerRegistryItem } from '@grafana/data'; -import { reduceTransformRegistryItem } from '../components/TransformersUI/ReduceTransformerEditor'; -import { filterFieldsByNameTransformRegistryItem } from '../components/TransformersUI/FilterByNameTransformerEditor'; -import { filterFramesByRefIdTransformRegistryItem } from '../components/TransformersUI/FilterByRefIdTransformerEditor'; -import { filterByValueTransformRegistryItem } from '../components/TransformersUI/FilterByValueTransformer/FilterByValueTransformerEditor'; -import { organizeFieldsTransformRegistryItem } from '../components/TransformersUI/OrganizeFieldsTransformerEditor'; -import { seriesToFieldsTransformerRegistryItem } from '../components/TransformersUI/SeriesToFieldsTransformerEditor'; -import { calculateFieldTransformRegistryItem } from '../components/TransformersUI/CalculateFieldTransformerEditor'; -import { labelsToFieldsTransformerRegistryItem } from '../components/TransformersUI/LabelsToFieldsTransformerEditor'; -import { groupByTransformRegistryItem } from '../components/TransformersUI/GroupByTransformerEditor'; -import { sortByTransformRegistryItem } from '../components/TransformersUI/SortByTransformerEditor'; -import { mergeTransformerRegistryItem } from '../components/TransformersUI/MergeTransformerEditor'; -import { seriesToRowsTransformerRegistryItem } from '../components/TransformersUI/SeriesToRowsTransformerEditor'; -import { concatenateTransformRegistryItem } from '../components/TransformersUI/ConcatenateTransformerEditor'; -import { renameByRegexTransformRegistryItem } from '../components/TransformersUI/RenameByRegexTransformer'; -import { histogramTransformRegistryItem } from '../components/TransformersUI/HistogramTransformerEditor'; -import { rowsToFieldsTransformRegistryItem } from '../components/TransformersUI/rowsToFields/RowsToFieldsTransformerEditor'; -import { configFromQueryTransformRegistryItem } from '../components/TransformersUI/configFromQuery/ConfigFromQueryTransformerEditor'; -import { prepareTimeseriesTransformerRegistryItem } from '../components/TransformersUI/prepareTimeSeries/PrepareTimeSeriesEditor'; -import { convertFieldTypeTransformRegistryItem } from '../components/TransformersUI/ConvertFieldTypeTransformerEditor'; -import { fieldLookupTransformRegistryItem } from '../components/TransformersUI/lookupGazetteer/FieldLookupTransformerEditor'; -import { extractFieldsTransformRegistryItem } from '../components/TransformersUI/extractFields/ExtractFieldsTransformerEditor'; -import { heatmapTransformRegistryItem } from '../components/TransformersUI/calculateHeatmap/HeatmapTransformerEditor'; -import { spatialTransformRegistryItem } from '../components/TransformersUI/spatial/SpatialTransformerEditor'; - -export const getStandardTransformers = (): Array> => { - return [ - reduceTransformRegistryItem, - filterFieldsByNameTransformRegistryItem, - renameByRegexTransformRegistryItem, - filterFramesByRefIdTransformRegistryItem, - filterByValueTransformRegistryItem, - organizeFieldsTransformRegistryItem, - seriesToFieldsTransformerRegistryItem, - seriesToRowsTransformerRegistryItem, - concatenateTransformRegistryItem, - calculateFieldTransformRegistryItem, - labelsToFieldsTransformerRegistryItem, - groupByTransformRegistryItem, - sortByTransformRegistryItem, - mergeTransformerRegistryItem, - histogramTransformRegistryItem, - rowsToFieldsTransformRegistryItem, - configFromQueryTransformRegistryItem, - prepareTimeseriesTransformerRegistryItem, - convertFieldTypeTransformRegistryItem, - spatialTransformRegistryItem, - fieldLookupTransformRegistryItem, - extractFieldsTransformRegistryItem, - heatmapTransformRegistryItem, - ]; -}; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx index 5269a9a6d37..da6ff1462fd 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx @@ -4,7 +4,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { TransformationsEditor } from './TransformationsEditor'; import { PanelModel } from '../../state'; -import { getStandardTransformers } from 'app/core/utils/standardTransformers'; +import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; import { selectors } from '@grafana/e2e-selectors'; const setup = (transformations: DataTransformerConfig[] = []) => { diff --git a/public/app/features/geo/format/utils.ts b/public/app/features/geo/format/utils.ts index 8dd793dae6b..37ea8097d29 100644 --- a/public/app/features/geo/format/utils.ts +++ b/public/app/features/geo/format/utils.ts @@ -1,5 +1,5 @@ import { ArrayVector, Field, FieldConfig, FieldType } from '@grafana/data'; -import { getCenterPoint } from 'app/core/components/TransformersUI/spatial/utils'; +import { getCenterPoint } from 'app/features/transformers/spatial/utils'; import { Geometry, LineString, Point } from 'ol/geom'; import { fromLonLat } from 'ol/proj'; import { Gazetteer } from '../gazetteer/gazetteer'; diff --git a/public/app/features/geo/gazetteer/gazetteer.test.ts b/public/app/features/geo/gazetteer/gazetteer.test.ts index 4c9f455817e..7bff83cfb1c 100644 --- a/public/app/features/geo/gazetteer/gazetteer.test.ts +++ b/public/app/features/geo/gazetteer/gazetteer.test.ts @@ -1,4 +1,4 @@ -import { getCenterPointWGS84 } from 'app/core/components/TransformersUI/spatial/utils'; +import { getCenterPointWGS84 } from 'app/features/transformers/spatial/utils'; import { getGazetteer } from './gazetteer'; let backendResults: any = { hello: 'world' }; diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueFilterEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/FilterByValueFilterEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueFilterEditor.tsx rename to public/app/features/transformers/FilterByValueTransformer/FilterByValueFilterEditor.tsx diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueTransformerEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/FilterByValueTransformer/FilterByValueTransformerEditor.tsx rename to public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx rename to public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx rename to public/app/features/transformers/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.tsx diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx rename to public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.tsx diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/types.ts b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/types.ts similarity index 100% rename from public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/types.ts rename to public/app/features/transformers/FilterByValueTransformer/ValueMatchers/types.ts diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/utils.ts b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/utils.ts similarity index 100% rename from public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/utils.ts rename to public/app/features/transformers/FilterByValueTransformer/ValueMatchers/utils.ts diff --git a/public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts similarity index 100% rename from public/app/core/components/TransformersUI/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts rename to public/app/features/transformers/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/HeatmapTransformerEditor.tsx b/public/app/features/transformers/calculateHeatmap/HeatmapTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/calculateHeatmap/HeatmapTransformerEditor.tsx rename to public/app/features/transformers/calculateHeatmap/HeatmapTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/editor/AxisEditor.tsx b/public/app/features/transformers/calculateHeatmap/editor/AxisEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/calculateHeatmap/editor/AxisEditor.tsx rename to public/app/features/transformers/calculateHeatmap/editor/AxisEditor.tsx diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/editor/helper.ts b/public/app/features/transformers/calculateHeatmap/editor/helper.ts similarity index 100% rename from public/app/core/components/TransformersUI/calculateHeatmap/editor/helper.ts rename to public/app/features/transformers/calculateHeatmap/editor/helper.ts diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.test.ts b/public/app/features/transformers/calculateHeatmap/heatmap.test.ts similarity index 100% rename from public/app/core/components/TransformersUI/calculateHeatmap/heatmap.test.ts rename to public/app/features/transformers/calculateHeatmap/heatmap.test.ts diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/heatmap.ts b/public/app/features/transformers/calculateHeatmap/heatmap.ts similarity index 100% rename from public/app/core/components/TransformersUI/calculateHeatmap/heatmap.ts rename to public/app/features/transformers/calculateHeatmap/heatmap.ts diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/models.gen.ts b/public/app/features/transformers/calculateHeatmap/models.gen.ts similarity index 100% rename from public/app/core/components/TransformersUI/calculateHeatmap/models.gen.ts rename to public/app/features/transformers/calculateHeatmap/models.gen.ts diff --git a/public/app/core/components/TransformersUI/calculateHeatmap/utils.ts b/public/app/features/transformers/calculateHeatmap/utils.ts similarity index 100% rename from public/app/core/components/TransformersUI/calculateHeatmap/utils.ts rename to public/app/features/transformers/calculateHeatmap/utils.ts diff --git a/public/app/core/components/TransformersUI/configFromQuery/ConfigFromQueryTransformerEditor.test.tsx b/public/app/features/transformers/configFromQuery/ConfigFromQueryTransformerEditor.test.tsx similarity index 100% rename from public/app/core/components/TransformersUI/configFromQuery/ConfigFromQueryTransformerEditor.test.tsx rename to public/app/features/transformers/configFromQuery/ConfigFromQueryTransformerEditor.test.tsx diff --git a/public/app/core/components/TransformersUI/configFromQuery/ConfigFromQueryTransformerEditor.tsx b/public/app/features/transformers/configFromQuery/ConfigFromQueryTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/configFromQuery/ConfigFromQueryTransformerEditor.tsx rename to public/app/features/transformers/configFromQuery/ConfigFromQueryTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/configFromQuery/configFromQuery.test.ts b/public/app/features/transformers/configFromQuery/configFromQuery.test.ts similarity index 100% rename from public/app/core/components/TransformersUI/configFromQuery/configFromQuery.test.ts rename to public/app/features/transformers/configFromQuery/configFromQuery.test.ts diff --git a/public/app/core/components/TransformersUI/configFromQuery/configFromQuery.ts b/public/app/features/transformers/configFromQuery/configFromQuery.ts similarity index 100% rename from public/app/core/components/TransformersUI/configFromQuery/configFromQuery.ts rename to public/app/features/transformers/configFromQuery/configFromQuery.ts diff --git a/public/app/core/components/TransformersUI/CalculateFieldTransformerEditor.tsx b/public/app/features/transformers/editors/CalculateFieldTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/CalculateFieldTransformerEditor.tsx rename to public/app/features/transformers/editors/CalculateFieldTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/ConcatenateTransformerEditor.tsx b/public/app/features/transformers/editors/ConcatenateTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/ConcatenateTransformerEditor.tsx rename to public/app/features/transformers/editors/ConcatenateTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/ConvertFieldTypeTransformerEditor.tsx b/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/ConvertFieldTypeTransformerEditor.tsx rename to public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/FilterByNameTransformerEditor.tsx b/public/app/features/transformers/editors/FilterByNameTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/FilterByNameTransformerEditor.tsx rename to public/app/features/transformers/editors/FilterByNameTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/FilterByRefIdTransformerEditor.tsx b/public/app/features/transformers/editors/FilterByRefIdTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/FilterByRefIdTransformerEditor.tsx rename to public/app/features/transformers/editors/FilterByRefIdTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/GroupByTransformerEditor.tsx b/public/app/features/transformers/editors/GroupByTransformerEditor.tsx similarity index 98% rename from public/app/core/components/TransformersUI/GroupByTransformerEditor.tsx rename to public/app/features/transformers/editors/GroupByTransformerEditor.tsx index a85cc42e5fb..d6e6c372817 100644 --- a/public/app/core/components/TransformersUI/GroupByTransformerEditor.tsx +++ b/public/app/features/transformers/editors/GroupByTransformerEditor.tsx @@ -15,7 +15,7 @@ import { GroupByOperationID, GroupByTransformerOptions, } from '@grafana/data/src/transformations/transformers/groupBy'; -import { useAllFieldNamesFromDataFrames } from './utils'; +import { useAllFieldNamesFromDataFrames } from '../utils'; interface FieldProps { fieldName: string; diff --git a/public/app/core/components/TransformersUI/HistogramTransformerEditor.tsx b/public/app/features/transformers/editors/HistogramTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/HistogramTransformerEditor.tsx rename to public/app/features/transformers/editors/HistogramTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/LabelsToFieldsTransformerEditor.tsx b/public/app/features/transformers/editors/LabelsToFieldsTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/LabelsToFieldsTransformerEditor.tsx rename to public/app/features/transformers/editors/LabelsToFieldsTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/MergeTransformerEditor.tsx b/public/app/features/transformers/editors/MergeTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/MergeTransformerEditor.tsx rename to public/app/features/transformers/editors/MergeTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/OrganizeFieldsTransformerEditor.tsx b/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx similarity index 99% rename from public/app/core/components/TransformersUI/OrganizeFieldsTransformerEditor.tsx rename to public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx index a178567d995..2f7c9a2e023 100644 --- a/public/app/core/components/TransformersUI/OrganizeFieldsTransformerEditor.tsx +++ b/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx @@ -12,7 +12,7 @@ import { stylesFactory, useTheme, Input, IconButton, Icon, FieldValidationMessag import { OrganizeFieldsTransformerOptions } from '@grafana/data/src/transformations/transformers/organize'; import { createOrderFieldsComparer } from '@grafana/data/src/transformations/transformers/order'; -import { useAllFieldNamesFromDataFrames } from './utils'; +import { useAllFieldNamesFromDataFrames } from '../utils'; interface OrganizeFieldsTransformerEditorProps extends TransformerUIProps {} diff --git a/public/app/core/components/TransformersUI/ReduceTransformerEditor.tsx b/public/app/features/transformers/editors/ReduceTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/ReduceTransformerEditor.tsx rename to public/app/features/transformers/editors/ReduceTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/RenameByRegexTransformer.tsx b/public/app/features/transformers/editors/RenameByRegexTransformer.tsx similarity index 100% rename from public/app/core/components/TransformersUI/RenameByRegexTransformer.tsx rename to public/app/features/transformers/editors/RenameByRegexTransformer.tsx diff --git a/public/app/core/components/TransformersUI/SeriesToFieldsTransformerEditor.tsx b/public/app/features/transformers/editors/SeriesToFieldsTransformerEditor.tsx similarity index 96% rename from public/app/core/components/TransformersUI/SeriesToFieldsTransformerEditor.tsx rename to public/app/features/transformers/editors/SeriesToFieldsTransformerEditor.tsx index 4146cfb24d1..028c9c55d1a 100644 --- a/public/app/core/components/TransformersUI/SeriesToFieldsTransformerEditor.tsx +++ b/public/app/features/transformers/editors/SeriesToFieldsTransformerEditor.tsx @@ -9,7 +9,7 @@ import { import { Select } from '@grafana/ui'; import { SeriesToColumnsOptions } from '@grafana/data/src/transformations/transformers/seriesToColumns'; -import { useAllFieldNamesFromDataFrames } from './utils'; +import { useAllFieldNamesFromDataFrames } from '../utils'; export const SeriesToFieldsTransformerEditor: React.FC> = ({ input, diff --git a/public/app/core/components/TransformersUI/SeriesToRowsTransformerEditor.tsx b/public/app/features/transformers/editors/SeriesToRowsTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/SeriesToRowsTransformerEditor.tsx rename to public/app/features/transformers/editors/SeriesToRowsTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/SortByTransformerEditor.tsx b/public/app/features/transformers/editors/SortByTransformerEditor.tsx similarity index 97% rename from public/app/core/components/TransformersUI/SortByTransformerEditor.tsx rename to public/app/features/transformers/editors/SortByTransformerEditor.tsx index d23978972d2..51c931fc1b0 100644 --- a/public/app/core/components/TransformersUI/SortByTransformerEditor.tsx +++ b/public/app/features/transformers/editors/SortByTransformerEditor.tsx @@ -2,7 +2,7 @@ import React, { useCallback } from 'react'; import { DataTransformerID, standardTransformers, TransformerRegistryItem, TransformerUIProps } from '@grafana/data'; import { InlineField, InlineSwitch, InlineFieldRow, Select } from '@grafana/ui'; import { SortByField, SortByTransformerOptions } from '@grafana/data/src/transformations/transformers/sortBy'; -import { useAllFieldNamesFromDataFrames } from './utils'; +import { useAllFieldNamesFromDataFrames } from '../utils'; export const SortByTransformerEditor: React.FC> = ({ input, diff --git a/public/app/core/components/TransformersUI/extractFields/ExtractFieldsTransformerEditor.tsx b/public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/extractFields/ExtractFieldsTransformerEditor.tsx rename to public/app/features/transformers/extractFields/ExtractFieldsTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/extractFields/extractFields.test.ts b/public/app/features/transformers/extractFields/extractFields.test.ts similarity index 100% rename from public/app/core/components/TransformersUI/extractFields/extractFields.test.ts rename to public/app/features/transformers/extractFields/extractFields.test.ts diff --git a/public/app/core/components/TransformersUI/extractFields/extractFields.ts b/public/app/features/transformers/extractFields/extractFields.ts similarity index 100% rename from public/app/core/components/TransformersUI/extractFields/extractFields.ts rename to public/app/features/transformers/extractFields/extractFields.ts diff --git a/public/app/core/components/TransformersUI/extractFields/fieldExtractor.test.ts b/public/app/features/transformers/extractFields/fieldExtractor.test.ts similarity index 100% rename from public/app/core/components/TransformersUI/extractFields/fieldExtractor.test.ts rename to public/app/features/transformers/extractFields/fieldExtractor.test.ts diff --git a/public/app/core/components/TransformersUI/extractFields/fieldExtractors.ts b/public/app/features/transformers/extractFields/fieldExtractors.ts similarity index 100% rename from public/app/core/components/TransformersUI/extractFields/fieldExtractors.ts rename to public/app/features/transformers/extractFields/fieldExtractors.ts diff --git a/public/app/core/components/TransformersUI/fieldToConfigMapping/FieldToConfigMappingEditor.test.tsx b/public/app/features/transformers/fieldToConfigMapping/FieldToConfigMappingEditor.test.tsx similarity index 100% rename from public/app/core/components/TransformersUI/fieldToConfigMapping/FieldToConfigMappingEditor.test.tsx rename to public/app/features/transformers/fieldToConfigMapping/FieldToConfigMappingEditor.test.tsx diff --git a/public/app/core/components/TransformersUI/fieldToConfigMapping/FieldToConfigMappingEditor.tsx b/public/app/features/transformers/fieldToConfigMapping/FieldToConfigMappingEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/fieldToConfigMapping/FieldToConfigMappingEditor.tsx rename to public/app/features/transformers/fieldToConfigMapping/FieldToConfigMappingEditor.tsx diff --git a/public/app/core/components/TransformersUI/fieldToConfigMapping/fieldToConfigMapping.ts b/public/app/features/transformers/fieldToConfigMapping/fieldToConfigMapping.ts similarity index 100% rename from public/app/core/components/TransformersUI/fieldToConfigMapping/fieldToConfigMapping.ts rename to public/app/features/transformers/fieldToConfigMapping/fieldToConfigMapping.ts diff --git a/public/app/core/components/TransformersUI/lookupGazetteer/FieldLookupTransformerEditor.tsx b/public/app/features/transformers/lookupGazetteer/FieldLookupTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/lookupGazetteer/FieldLookupTransformerEditor.tsx rename to public/app/features/transformers/lookupGazetteer/FieldLookupTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/lookupGazetteer/fieldLookup.test.ts b/public/app/features/transformers/lookupGazetteer/fieldLookup.test.ts similarity index 100% rename from public/app/core/components/TransformersUI/lookupGazetteer/fieldLookup.test.ts rename to public/app/features/transformers/lookupGazetteer/fieldLookup.test.ts diff --git a/public/app/core/components/TransformersUI/lookupGazetteer/fieldLookup.ts b/public/app/features/transformers/lookupGazetteer/fieldLookup.ts similarity index 100% rename from public/app/core/components/TransformersUI/lookupGazetteer/fieldLookup.ts rename to public/app/features/transformers/lookupGazetteer/fieldLookup.ts diff --git a/public/app/core/components/TransformersUI/prepareTimeSeries/PrepareTimeSeriesEditor.tsx b/public/app/features/transformers/prepareTimeSeries/PrepareTimeSeriesEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/prepareTimeSeries/PrepareTimeSeriesEditor.tsx rename to public/app/features/transformers/prepareTimeSeries/PrepareTimeSeriesEditor.tsx diff --git a/public/app/core/components/TransformersUI/prepareTimeSeries/prepareTimeSeries.test.ts b/public/app/features/transformers/prepareTimeSeries/prepareTimeSeries.test.ts similarity index 100% rename from public/app/core/components/TransformersUI/prepareTimeSeries/prepareTimeSeries.test.ts rename to public/app/features/transformers/prepareTimeSeries/prepareTimeSeries.test.ts diff --git a/public/app/core/components/TransformersUI/prepareTimeSeries/prepareTimeSeries.ts b/public/app/features/transformers/prepareTimeSeries/prepareTimeSeries.ts similarity index 100% rename from public/app/core/components/TransformersUI/prepareTimeSeries/prepareTimeSeries.ts rename to public/app/features/transformers/prepareTimeSeries/prepareTimeSeries.ts diff --git a/public/app/core/components/TransformersUI/rowsToFields/RowsToFieldsTransformerEditor.test.tsx b/public/app/features/transformers/rowsToFields/RowsToFieldsTransformerEditor.test.tsx similarity index 100% rename from public/app/core/components/TransformersUI/rowsToFields/RowsToFieldsTransformerEditor.test.tsx rename to public/app/features/transformers/rowsToFields/RowsToFieldsTransformerEditor.test.tsx diff --git a/public/app/core/components/TransformersUI/rowsToFields/RowsToFieldsTransformerEditor.tsx b/public/app/features/transformers/rowsToFields/RowsToFieldsTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/rowsToFields/RowsToFieldsTransformerEditor.tsx rename to public/app/features/transformers/rowsToFields/RowsToFieldsTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/rowsToFields/rowsToFields.test.ts b/public/app/features/transformers/rowsToFields/rowsToFields.test.ts similarity index 100% rename from public/app/core/components/TransformersUI/rowsToFields/rowsToFields.test.ts rename to public/app/features/transformers/rowsToFields/rowsToFields.test.ts diff --git a/public/app/core/components/TransformersUI/rowsToFields/rowsToFields.ts b/public/app/features/transformers/rowsToFields/rowsToFields.ts similarity index 100% rename from public/app/core/components/TransformersUI/rowsToFields/rowsToFields.ts rename to public/app/features/transformers/rowsToFields/rowsToFields.ts diff --git a/public/app/core/components/TransformersUI/spatial/SpatialTransformerEditor.tsx b/public/app/features/transformers/spatial/SpatialTransformerEditor.tsx similarity index 100% rename from public/app/core/components/TransformersUI/spatial/SpatialTransformerEditor.tsx rename to public/app/features/transformers/spatial/SpatialTransformerEditor.tsx diff --git a/public/app/core/components/TransformersUI/spatial/models.gen.ts b/public/app/features/transformers/spatial/models.gen.ts similarity index 100% rename from public/app/core/components/TransformersUI/spatial/models.gen.ts rename to public/app/features/transformers/spatial/models.gen.ts diff --git a/public/app/core/components/TransformersUI/spatial/optionsHelper.tsx b/public/app/features/transformers/spatial/optionsHelper.tsx similarity index 100% rename from public/app/core/components/TransformersUI/spatial/optionsHelper.tsx rename to public/app/features/transformers/spatial/optionsHelper.tsx diff --git a/public/app/core/components/TransformersUI/spatial/spatialTransformer.test.ts b/public/app/features/transformers/spatial/spatialTransformer.test.ts similarity index 100% rename from public/app/core/components/TransformersUI/spatial/spatialTransformer.test.ts rename to public/app/features/transformers/spatial/spatialTransformer.test.ts diff --git a/public/app/core/components/TransformersUI/spatial/spatialTransformer.ts b/public/app/features/transformers/spatial/spatialTransformer.ts similarity index 100% rename from public/app/core/components/TransformersUI/spatial/spatialTransformer.ts rename to public/app/features/transformers/spatial/spatialTransformer.ts diff --git a/public/app/core/components/TransformersUI/spatial/utils.ts b/public/app/features/transformers/spatial/utils.ts similarity index 100% rename from public/app/core/components/TransformersUI/spatial/utils.ts rename to public/app/features/transformers/spatial/utils.ts diff --git a/public/app/features/transformers/standardTransformers.ts b/public/app/features/transformers/standardTransformers.ts new file mode 100644 index 00000000000..1d9792eb307 --- /dev/null +++ b/public/app/features/transformers/standardTransformers.ts @@ -0,0 +1,52 @@ +import { TransformerRegistryItem } from '@grafana/data'; +import { reduceTransformRegistryItem } from './editors/ReduceTransformerEditor'; +import { filterFieldsByNameTransformRegistryItem } from './editors/FilterByNameTransformerEditor'; +import { filterFramesByRefIdTransformRegistryItem } from './editors/FilterByRefIdTransformerEditor'; +import { filterByValueTransformRegistryItem } from './FilterByValueTransformer/FilterByValueTransformerEditor'; +import { organizeFieldsTransformRegistryItem } from './editors/OrganizeFieldsTransformerEditor'; +import { seriesToFieldsTransformerRegistryItem } from './editors/SeriesToFieldsTransformerEditor'; +import { calculateFieldTransformRegistryItem } from './editors/CalculateFieldTransformerEditor'; +import { labelsToFieldsTransformerRegistryItem } from './editors/LabelsToFieldsTransformerEditor'; +import { groupByTransformRegistryItem } from './editors/GroupByTransformerEditor'; +import { sortByTransformRegistryItem } from './editors/SortByTransformerEditor'; +import { mergeTransformerRegistryItem } from './editors/MergeTransformerEditor'; +import { seriesToRowsTransformerRegistryItem } from './editors/SeriesToRowsTransformerEditor'; +import { concatenateTransformRegistryItem } from './editors/ConcatenateTransformerEditor'; +import { renameByRegexTransformRegistryItem } from './editors/RenameByRegexTransformer'; +import { histogramTransformRegistryItem } from './editors/HistogramTransformerEditor'; +import { rowsToFieldsTransformRegistryItem } from './rowsToFields/RowsToFieldsTransformerEditor'; +import { configFromQueryTransformRegistryItem } from './configFromQuery/ConfigFromQueryTransformerEditor'; +import { prepareTimeseriesTransformerRegistryItem } from './prepareTimeSeries/PrepareTimeSeriesEditor'; +import { convertFieldTypeTransformRegistryItem } from './editors/ConvertFieldTypeTransformerEditor'; +import { fieldLookupTransformRegistryItem } from './lookupGazetteer/FieldLookupTransformerEditor'; +import { extractFieldsTransformRegistryItem } from './extractFields/ExtractFieldsTransformerEditor'; +import { heatmapTransformRegistryItem } from './calculateHeatmap/HeatmapTransformerEditor'; +import { spatialTransformRegistryItem } from './spatial/SpatialTransformerEditor'; + +export const getStandardTransformers = (): Array> => { + return [ + reduceTransformRegistryItem, + filterFieldsByNameTransformRegistryItem, + renameByRegexTransformRegistryItem, + filterFramesByRefIdTransformRegistryItem, + filterByValueTransformRegistryItem, + organizeFieldsTransformRegistryItem, + seriesToFieldsTransformerRegistryItem, + seriesToRowsTransformerRegistryItem, + concatenateTransformRegistryItem, + calculateFieldTransformRegistryItem, + labelsToFieldsTransformerRegistryItem, + groupByTransformRegistryItem, + sortByTransformRegistryItem, + mergeTransformerRegistryItem, + histogramTransformRegistryItem, + rowsToFieldsTransformRegistryItem, + configFromQueryTransformRegistryItem, + prepareTimeseriesTransformerRegistryItem, + convertFieldTypeTransformRegistryItem, + spatialTransformRegistryItem, + fieldLookupTransformRegistryItem, + extractFieldsTransformRegistryItem, + heatmapTransformRegistryItem, + ]; +}; diff --git a/public/app/core/components/TransformersUI/utils.ts b/public/app/features/transformers/utils.ts similarity index 100% rename from public/app/core/components/TransformersUI/utils.ts rename to public/app/features/transformers/utils.ts diff --git a/public/app/plugins/panel/heatmap-new/fields.ts b/public/app/plugins/panel/heatmap-new/fields.ts index 3a2e0418451..7b7f02b8af4 100644 --- a/public/app/plugins/panel/heatmap-new/fields.ts +++ b/public/app/plugins/panel/heatmap-new/fields.ts @@ -3,7 +3,7 @@ import { calculateHeatmapFromData, createHeatmapFromBuckets, sortAscStrInf, -} from 'app/core/components/TransformersUI/calculateHeatmap/heatmap'; +} from 'app/features/transformers/calculateHeatmap/heatmap'; import { HeatmapSourceMode, PanelOptions } from './models.gen'; export const enum BucketLayout { diff --git a/public/app/plugins/panel/heatmap-new/migrations.ts b/public/app/plugins/panel/heatmap-new/migrations.ts index aee97869fb5..a5c00a66abc 100644 --- a/public/app/plugins/panel/heatmap-new/migrations.ts +++ b/public/app/plugins/panel/heatmap-new/migrations.ts @@ -3,7 +3,7 @@ import { LegendDisplayMode, VisibilityMode } from '@grafana/schema'; import { HeatmapCalculationMode, HeatmapCalculationOptions, -} from 'app/core/components/TransformersUI/calculateHeatmap/models.gen'; +} from 'app/features/transformers/calculateHeatmap/models.gen'; import { HeatmapSourceMode, PanelOptions, defaultPanelOptions } from './models.gen'; /** diff --git a/public/app/plugins/panel/heatmap-new/models.gen.ts b/public/app/plugins/panel/heatmap-new/models.gen.ts index 5124a5c73c9..71ddfb346de 100644 --- a/public/app/plugins/panel/heatmap-new/models.gen.ts +++ b/public/app/plugins/panel/heatmap-new/models.gen.ts @@ -4,7 +4,7 @@ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ import { HideableFieldConfig, LegendDisplayMode, OptionsWithLegend, VisibilityMode } from '@grafana/schema'; -import { HeatmapCalculationOptions } from 'app/core/components/TransformersUI/calculateHeatmap/models.gen'; +import { HeatmapCalculationOptions } from 'app/features/transformers/calculateHeatmap/models.gen'; export const modelVersion = Object.freeze([1, 0]); diff --git a/public/app/plugins/panel/heatmap-new/module.tsx b/public/app/plugins/panel/heatmap-new/module.tsx index 6ab1eef3663..d9f4d345540 100644 --- a/public/app/plugins/panel/heatmap-new/module.tsx +++ b/public/app/plugins/panel/heatmap-new/module.tsx @@ -11,7 +11,7 @@ import { } from './models.gen'; import { HeatmapSuggestionsSupplier } from './suggestions'; import { heatmapChangedHandler } from './migrations'; -import { addHeatmapCalculationOptions } from 'app/core/components/TransformersUI/calculateHeatmap/editor/helper'; +import { addHeatmapCalculationOptions } from 'app/features/transformers/calculateHeatmap/editor/helper'; import { colorSchemes } from './palettes'; export const plugin = new PanelPlugin(HeatmapPanel) From f42c830b3c1008d9e04dcba25e7c9157036c13f1 Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Thu, 17 Feb 2022 17:18:19 +0100 Subject: [PATCH 051/104] Inject dashboard provisioning service instead of store (#45533) --- pkg/services/provisioning/dashboards/dashboard.go | 11 +++++------ pkg/services/provisioning/dashboards/file_reader.go | 5 ++--- pkg/services/provisioning/provisioning.go | 10 ++++++---- pkg/services/provisioning/provisioning_test.go | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pkg/services/provisioning/dashboards/dashboard.go b/pkg/services/provisioning/dashboards/dashboard.go index fbdf6386dba..660d33fa8bb 100644 --- a/pkg/services/provisioning/dashboards/dashboard.go +++ b/pkg/services/provisioning/dashboards/dashboard.go @@ -23,7 +23,7 @@ type DashboardProvisioner interface { } // DashboardProvisionerFactory creates DashboardProvisioners based on input -type DashboardProvisionerFactory func(context.Context, string, dashboards.Store) (DashboardProvisioner, error) +type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService) (DashboardProvisioner, error) // Provisioner is responsible for syncing dashboard from disk to Grafana's database. type Provisioner struct { @@ -34,7 +34,7 @@ type Provisioner struct { } // New returns a new DashboardProvisioner -func New(ctx context.Context, configDirectory string, store dashboards.Store) (DashboardProvisioner, error) { +func New(ctx context.Context, configDirectory string, service dashboards.DashboardProvisioningService) (DashboardProvisioner, error) { logger := log.New("provisioning.dashboard") cfgReader := &configReader{path: configDirectory, log: logger} configs, err := cfgReader.readConfig(ctx) @@ -42,7 +42,7 @@ func New(ctx context.Context, configDirectory string, store dashboards.Store) (D return nil, errutil.Wrap("Failed to read dashboards config", err) } - fileReaders, err := getFileReaders(configs, logger, store) + fileReaders, err := getFileReaders(configs, logger, service) if err != nil { return nil, errutil.Wrap("Failed to initialize file readers", err) } @@ -120,14 +120,13 @@ func (provider *Provisioner) GetAllowUIUpdatesFromConfig(name string) bool { return false } -func getFileReaders(configs []*config, logger log.Logger, store dashboards.Store) ([]*FileReader, error) { +func getFileReaders(configs []*config, logger log.Logger, service dashboards.DashboardProvisioningService) ([]*FileReader, error) { var readers []*FileReader for _, config := range configs { switch config.Type { case "file": - fileReader, err := NewDashboardFileReader(config, logger.New("type", config.Type, "name", config.Name), - store) + fileReader, err := NewDashboardFileReader(config, logger.New("type", config.Type, "name", config.Name), service) if err != nil { return nil, errutil.Wrapf(err, "Failed to create file reader for config %v", config.Name) } diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index a63a5e045e7..2b84b13dc62 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -16,7 +16,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" - dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/manager" "github.com/grafana/grafana/pkg/util" ) @@ -41,7 +40,7 @@ type FileReader struct { } // NewDashboardFileReader returns a new filereader based on `config` -func NewDashboardFileReader(cfg *config, log log.Logger, store dashboards.Store) (*FileReader, error) { +func NewDashboardFileReader(cfg *config, log log.Logger, service dashboards.DashboardProvisioningService) (*FileReader, error) { var path string path, ok := cfg.Options["path"].(string) if !ok { @@ -62,7 +61,7 @@ func NewDashboardFileReader(cfg *config, log log.Logger, store dashboards.Store) Cfg: cfg, Path: path, log: log, - dashboardProvisioningService: dashboardservice.ProvideDashboardService(store), + dashboardProvisioningService: service, FoldersFromFilesStructure: foldersFromFilesStructure, usageTracker: newUsageTracker(), }, nil diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index 1ce04cdb193..711b90b7962 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -21,7 +21,9 @@ import ( ) func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore plugifaces.Store, - encryptionService encryption.Internal, notificatonService *notifications.NotificationService, dashboardsStore dashboardservice.Store) (*ProvisioningServiceImpl, error) { + encryptionService encryption.Internal, notificatonService *notifications.NotificationService, + dashboardService dashboardservice.DashboardProvisioningService, +) (*ProvisioningServiceImpl, error) { s := &ProvisioningServiceImpl{ Cfg: cfg, pluginStore: pluginStore, @@ -32,7 +34,7 @@ func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore p provisionNotifiers: notifiers.Provision, provisionDatasources: datasources.Provision, provisionPlugins: plugins.Provision, - dashboardsStore: dashboardsStore, + dashboardService: dashboardService, } return s, nil } @@ -89,7 +91,7 @@ type ProvisioningServiceImpl struct { provisionDatasources func(context.Context, string) error provisionPlugins func(context.Context, string, plugifaces.Store) error mutex sync.Mutex - dashboardsStore dashboardservice.Store + dashboardService dashboardservice.DashboardProvisioningService } func (ps *ProvisioningServiceImpl) RunInitProvisioners(ctx context.Context) error { @@ -172,7 +174,7 @@ func (ps *ProvisioningServiceImpl) ProvisionNotifications(ctx context.Context) e func (ps *ProvisioningServiceImpl) ProvisionDashboards(ctx context.Context) error { dashboardPath := filepath.Join(ps.Cfg.ProvisioningPath, "dashboards") - dashProvisioner, err := ps.newDashboardProvisioner(ctx, dashboardPath, ps.dashboardsStore) + dashProvisioner, err := ps.newDashboardProvisioner(ctx, dashboardPath, ps.dashboardService) if err != nil { return errutil.Wrap("Failed to create provisioner", err) } diff --git a/pkg/services/provisioning/provisioning_test.go b/pkg/services/provisioning/provisioning_test.go index 8e9253b76d4..8bf248f97fa 100644 --- a/pkg/services/provisioning/provisioning_test.go +++ b/pkg/services/provisioning/provisioning_test.go @@ -92,7 +92,7 @@ func setup() *serviceTestStruct { } serviceTest.service = newProvisioningServiceImpl( - func(context.Context, string, dashboardstore.Store) (dashboards.DashboardProvisioner, error) { + func(context.Context, string, dashboardstore.DashboardProvisioningService) (dashboards.DashboardProvisioner, error) { return serviceTest.mock, nil }, nil, From abe32b952159f65f003db8c0df60c21b2b8de7ae Mon Sep 17 00:00:00 2001 From: Stephanie Closson Date: Thu, 17 Feb 2022 12:39:41 -0400 Subject: [PATCH 052/104] Implement PromQL Functions in new query editor (#45431) * adding functions for prometheus queries * WIP: functions added. Just categorizing them and testing each function out * WIP: testing functions * WIP verifying other functions * Functions added and classified * added tests * moved bottomk to aggregations module * added tests to the PromQueryModeller * code review comments. Made range renderer common with code * removed duplicate functions * updated comments --- .../querybuilder/PromQueryModeller.test.ts | 36 +++ .../querybuilder/PromQueryModeller.ts | 2 + .../prometheus/querybuilder/aggregations.ts | 4 +- .../prometheus/querybuilder/operations.ts | 229 +++++++++++++++++- .../querybuilder/shared/operationUtils.ts | 61 +++++ .../prometheus/querybuilder/types.ts | 101 ++++++-- 6 files changed, 407 insertions(+), 26 deletions(-) diff --git a/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts b/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts index 56a317419ba..1c2e18f8600 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.test.ts @@ -278,4 +278,40 @@ describe('PromQueryModeller', () => { }) ).toBe('metric_a / on(le) metric_b'); }); + it('Can render functions that require a range as a parameter', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [{ id: 'holt_winters', params: ['auto', 0.5, 0.5] }], + }) + ).toBe('holt_winters(metric_a[$__rate_interval], 0.5, 0.5)'); + }); + it('Can render functions that require parameters left of a range', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [{ id: 'quantile_over_time', params: ['auto', 1] }], + }) + ).toBe('quantile_over_time(1, metric_a[$__rate_interval])'); + }); + it('Can render the label_join function', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [{ id: 'label_join', params: ['label_1', ',', 'label_2'] }], + }) + ).toBe('label_join(metric_a, "label_1", ",", "label_2")'); + }); + it('Can render label_join with extra parameters', () => { + expect( + modeller.renderQuery({ + metric: 'metric_a', + labels: [], + operations: [{ id: 'label_join', params: ['label_1', ', ', 'label_2', 'label_3', 'label_4', 'label_5'] }], + }) + ).toBe('label_join(metric_a, "label_1", ", ", "label_2", "label_3", "label_4", "label_5")'); + }); }); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts b/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts index 96ad85f530e..920fdafa2ae 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/PromQueryModeller.ts @@ -22,6 +22,8 @@ export class PromQueryModeller extends LokiAndPromQueryModellerBase innerExpr, addOperationHandler: addNestedQueryHandler, }, + createFunction({ id: PromOperationId.Absent }), + createRangeFunction(PromOperationId.AbsentOverTime), + createFunction({ + id: PromOperationId.Acos, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.Acosh, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.Asin, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.Asinh, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.Atan, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.Atanh, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ id: PromOperationId.Ceil }), + createFunction({ + id: PromOperationId.Clamp, + name: 'Clamp', + params: [ + { name: 'Minimum Scalar', type: 'number' }, + { name: 'Maximum Scalar', type: 'number' }, + ], + defaultParams: [1, 1], + }), + + createFunction({ + id: PromOperationId.ClampMax, + params: [{ name: 'Maximum Scalar', type: 'number' }], + defaultParams: [1], + }), + createFunction({ + id: PromOperationId.ClampMin, + params: [{ name: 'Minimum Scalar', type: 'number' }], + defaultParams: [1], + }), + createFunction({ + id: PromOperationId.Cos, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.Cosh, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.CountValues, + params: [{ name: 'Identifier', type: 'string' }], + defaultParams: ['count'], + renderer: functionRendererLeft, + }), + createFunction({ + id: PromOperationId.DayOfMonth, + category: PromVisualQueryOperationCategory.Time, + }), + createFunction({ + id: PromOperationId.DayOfWeek, + category: PromVisualQueryOperationCategory.Time, + }), + createFunction({ + id: PromOperationId.DaysInMonth, + category: PromVisualQueryOperationCategory.Time, + }), + createFunction({ id: PromOperationId.Deg }), + createRangeFunction(PromOperationId.Deriv), + // + createFunction({ id: PromOperationId.Exp }), + createFunction({ id: PromOperationId.Floor }), + createFunction({ id: PromOperationId.Group }), + createFunction({ + id: PromOperationId.HoltWinters, + params: [ + getRangeVectorParamDef(), + { name: 'Smoothing Factor', type: 'number' }, + { name: 'Trend Factor', type: 'number' }, + ], + defaultParams: ['auto', 0.5, 0.5], + alternativesKey: 'range function', + category: PromVisualQueryOperationCategory.RangeFunctions, + renderer: rangeRendererRightWithParams, + }), + createFunction({ id: PromOperationId.Hour }), + createRangeFunction(PromOperationId.Idelta), + createFunction({ + id: PromOperationId.LabelJoin, + params: [ + { + name: 'Destination Label', + type: 'string', + editor: LabelParamEditor, + }, + { + name: 'Separator', + type: 'string', + }, + { + name: 'Source Label', + type: 'string', + restParam: true, + optional: true, + editor: LabelParamEditor, + }, + ], + defaultParams: ['', ',', ''], + renderer: labelJoinRenderer, + addOperationHandler: labelJoinAddOperationHandler, + }), + createFunction({ id: PromOperationId.Log10 }), + createFunction({ id: PromOperationId.Log2 }), + createFunction({ id: PromOperationId.Minute }), + createFunction({ id: PromOperationId.Month }), + createFunction({ + id: PromOperationId.Pi, + renderer: (model) => `${model.id}()`, + }), + createFunction({ + id: PromOperationId.PredictLinear, + params: [getRangeVectorParamDef(), { name: 'Seconds from now', type: 'number' }], + defaultParams: ['auto', 60], + alternativesKey: 'range function', + category: PromVisualQueryOperationCategory.RangeFunctions, + renderer: rangeRendererRightWithParams, + }), + createFunction({ + id: PromOperationId.Quantile, + params: [{ name: 'Value', type: 'number' }], + defaultParams: [1], + renderer: functionRendererLeft, + }), + createFunction({ + id: PromOperationId.QuantileOverTime, + params: [getRangeVectorParamDef(), { name: 'Quantile', type: 'number' }], + defaultParams: ['auto', 0.5], + alternativesKey: 'range function', + category: PromVisualQueryOperationCategory.RangeFunctions, + renderer: rangeRendererLeftWithParams, + }), + createFunction({ id: PromOperationId.Rad }), + createRangeFunction(PromOperationId.Resets), + createFunction({ + id: PromOperationId.Round, + category: PromVisualQueryOperationCategory.Functions, + params: [{ name: 'To Nearest', type: 'number' }], + defaultParams: [1], + }), + createFunction({ id: PromOperationId.Scalar }), + createFunction({ id: PromOperationId.Sgn }), + createFunction({ id: PromOperationId.Sin, category: PromVisualQueryOperationCategory.Trigonometric }), + createFunction({ + id: PromOperationId.Sinh, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ id: PromOperationId.Sort }), + createFunction({ id: PromOperationId.SortDesc }), + createFunction({ id: PromOperationId.Sqrt }), + createFunction({ id: PromOperationId.Stddev }), + createFunction({ + id: PromOperationId.Tan, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.Tanh, + category: PromVisualQueryOperationCategory.Trigonometric, + }), + createFunction({ + id: PromOperationId.Time, + renderer: (model) => `${model.id}()`, + }), + createFunction({ id: PromOperationId.Timestamp }), + createFunction({ + id: PromOperationId.Vector, + params: [{ name: 'Value', type: 'number' }], + defaultParams: [1], + renderer: (model) => `${model.id}(${model.params[0]})`, + }), + createFunction({ id: PromOperationId.Year }), ]; return list; } -function createRangeFunction(name: string): QueryBuilderOperationDef { +export function createFunction(definition: Partial): QueryBuilderOperationDef { + return { + ...definition, + id: definition.id!, + name: definition.name ?? getPromAndLokiOperationDisplayName(definition.id!), + params: definition.params ?? [], + defaultParams: definition.defaultParams ?? [], + category: definition.category ?? PromVisualQueryOperationCategory.Functions, + renderer: definition.renderer ?? (definition.params ? functionRendererRight : functionRendererLeft), + addOperationHandler: definition.addOperationHandler ?? defaultAddOperationHandler, + }; +} + +export function createRangeFunction(name: string): QueryBuilderOperationDef { return { id: name, name: getPromAndLokiOperationDisplayName(name), @@ -98,7 +301,7 @@ function createRangeFunction(name: string): QueryBuilderOperationDef { }; } -function operationWithRangeVectorRenderer( +export function operationWithRangeVectorRenderer( model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string @@ -174,3 +377,23 @@ function addNestedQueryHandler(def: QueryBuilderOperationDef, query: PromVisualQ ], }; } + +function labelJoinRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + if (typeof model.params[1] !== 'string') { + throw 'The separator must be a string'; + } + const separator = `"${model.params[1]}"`; + return `${model.id}(${innerExpr}, "${model.params[0]}", ${separator}, "${model.params.slice(2).join(separator)}")`; +} + +function labelJoinAddOperationHandler(def: QueryBuilderOperationDef, query: T) { + const newOperation: QueryBuilderOperation = { + id: def.id, + params: def.defaultParams, + }; + + return { + ...query, + operations: [...query.operations, newOperation], + }; +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.ts b/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.ts index dc3187e34df..5425c41eaca 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/operationUtils.ts @@ -23,6 +23,67 @@ export function functionRendererRight(model: QueryBuilderOperation, def: QueryBu return str + params.join(', ') + ')'; } +function rangeRendererWithParams( + model: QueryBuilderOperation, + def: QueryBuilderOperationDef, + innerExpr: string, + renderLeft: boolean +) { + if (def.params.length < 2) { + throw `Cannot render a function with params of length [${def.params.length}]`; + } + + // First, make sure the first parameter (that is the range vector) is translated if the user selected 'auto' + let rangeVector = (model.params ?? [])[0] ?? 'auto'; + + if (rangeVector === 'auto') { + rangeVector = '$__rate_interval'; + } + + // Next frame the remaining parameters, but get rid of the first one because it's used to move the + // instant vector into a range vector. + const params = renderParams( + { + ...model, + params: model.params.slice(1), + }, + { + ...def, + params: def.params.slice(1), + defaultParams: def.defaultParams.slice(1), + }, + innerExpr + ); + + const str = model.id + '('; + + // Depending on the renderLeft variable, render parameters to the left or right + // renderLeft === true (renderLeft) => (param1, param2, rangeVector[...]) + // renderLeft === false (renderRight) => (rangeVector[...], param1, param2) + if (innerExpr) { + renderLeft ? params.push(`${innerExpr}[${rangeVector}]`) : params.unshift(`${innerExpr}[${rangeVector}]`); + } + + // stick everything together + return str + params.join(', ') + ')'; +} + +export function rangeRendererRightWithParams( + model: QueryBuilderOperation, + def: QueryBuilderOperationDef, + innerExpr: string +) { + return rangeRendererWithParams(model, def, innerExpr, false); +} + +export function rangeRendererLeftWithParams( + model: QueryBuilderOperation, + def: QueryBuilderOperationDef, + innerExpr: string +) { + return rangeRendererWithParams(model, def, innerExpr, true); +} + function renderParams(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { return (model.params ?? []).map((value, index) => { const paramDef = def.params[index]; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/types.ts b/public/app/plugins/datasource/prometheus/querybuilder/types.ts index 0bd71d3a093..7c639684958 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/types.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/types.ts @@ -20,35 +20,94 @@ export enum PromVisualQueryOperationCategory { RangeFunctions = 'Range functions', Functions = 'Functions', BinaryOps = 'Binary operations', + Trigonometric = 'Trigonometric', + Time = 'Time Functions', } export enum PromOperationId { - HistogramQuantile = 'histogram_quantile', - LabelReplace = 'label_replace', - Ln = 'ln', + Abs = 'abs', + Absent = 'absent', + AbsentOverTime = 'absent_over_time', + Acos = 'acos', + Acosh = 'acosh', + Asin = 'asin', + Asinh = 'asinh', + Atan = 'atan', + Atanh = 'atanh', + Avg = 'avg', + AvgOverTime = 'avg_over_time', + BottomK = 'bottomk', + Ceil = 'ceil', Changes = 'changes', - Rate = 'rate', - Irate = 'irate', - Increase = 'increase', + Clamp = 'clamp', + ClampMax = 'clamp_max', + ClampMin = 'clamp_min', + Cos = 'cos', + Cosh = 'cosh', + Count = 'count', + CountOverTime = 'count_over_time', + CountScalar = 'count_scalar', + CountValues = 'count_values', + DayOfMonth = 'day_of_month', + DayOfWeek = 'day_of_week', + DaysInMonth = 'days_in_month', + Deg = 'deg', Delta = 'delta', + Deriv = 'deriv', + DropCommonLabels = 'drop_common_labels', + Exp = 'exp', + Floor = 'floor', + Group = 'group', + HistogramQuantile = 'histogram_quantile', + HoltWinters = 'holt_winters', + Hour = 'hour', + Idelta = 'idelta', + Increase = 'increase', + Irate = 'irate', + LabelJoin = 'label_join', + LabelReplace = 'label_replace', + Last = 'last', + LastOverTime = 'last_over_time', + Ln = 'ln', + Log10 = 'log10', + Log2 = 'log2', + Max = 'max', + MaxOverTime = 'max_over_time', + Min = 'min', + MinOverTime = 'min_over_time', + Minute = 'minute', + Month = 'month', + Pi = 'pi', + PredictLinear = 'predict_linear', + Present = 'present', + PresentOverTime = 'present_over_time', + Quantile = 'quantile', + QuantileOverTime = 'quantile_over_time', + Rad = 'rad', + Rate = 'rate', + Resets = 'resets', + Round = 'round', + Scalar = 'scalar', + Sgn = 'sgn', + Sin = 'sin', + Sinh = 'sinh', + Sort = 'sort', + SortDesc = 'sort_desc', + Sqrt = 'sqrt', + Stddev = 'stddev', + StddevOverTime = 'stddev_over_time', + Sum = 'sum', + SumOverTime = 'sum_over_time', + Tan = 'tan', + Tanh = 'tanh', + Time = 'time', + Timestamp = 'timestamp', + Topk = 'topk', + Vector = 'vector', + Year = 'year', MultiplyBy = '__multiply_by', DivideBy = '__divide_by', NestedQuery = '__nested_query', - Sum = 'sum', - Avg = 'avg', - Min = 'min', - Max = 'max', - Count = 'count', - Topk = 'topk', - SumOverTime = 'sum_over_time', - AvgOverTime = 'avg_over_time', - MinOverTime = 'min_over_time', - MaxOverTime = 'max_over_time', - CountOverTime = 'count_over_time', - LastOverTime = 'last_over_time', - PresentOverTime = 'present_over_time', - StddevOverTime = 'stddev_over_time', - StdvarOverTime = 'stdvar_over_time', } export interface PromQueryPattern { From 4fcbfab711c064030b914407d2658dbd70fb8011 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Thu, 17 Feb 2022 17:43:30 +0100 Subject: [PATCH 053/104] Udpate latest.json (#45548) --- latest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/latest.json b/latest.json index d325bb2abf8..537d81c0610 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "8.3.6", - "testing": "8.4.0-beta1" + "stable": "8.4.0", + "testing": "8.4.0" } From fcd85951a7a6eaa2cd1922cbdfe08b98ee1e3f52 Mon Sep 17 00:00:00 2001 From: Cat Perry <000.perry@gmail.com> Date: Thu, 17 Feb 2022 09:18:25 -0800 Subject: [PATCH 054/104] Tempo: Switch out Select with AsyncSelect component to get loading state in Tempo Search (#45110) * Replace Select with AsyncSelect to get loading state --- .../tempo/QueryEditor/NativeSearch.test.tsx | 109 ++++++++++++++++++ .../tempo/QueryEditor/NativeSearch.tsx | 108 ++++++++++------- 2 files changed, 174 insertions(+), 43 deletions(-) create mode 100644 public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.test.tsx diff --git a/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.test.tsx b/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.test.tsx new file mode 100644 index 00000000000..a321d0a322e --- /dev/null +++ b/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.test.tsx @@ -0,0 +1,109 @@ +import NativeSearch from './NativeSearch'; +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { TempoDatasource, TempoQuery } from '../datasource'; +import userEvent from '@testing-library/user-event'; + +const getOptions = jest.fn().mockImplementation(() => { + return Promise.resolve([ + { + value: 'customer', + label: 'customer', + }, + { + value: 'driver', + label: 'driver', + }, + ]); +}); + +jest.mock('../language_provider', () => { + return jest.fn().mockImplementation(() => { + return { getOptions }; + }); +}); + +const mockQuery = { + refId: 'A', + queryType: 'nativeSearch', + key: 'Q-595a9bbc-2a25-49a7-9249-a52a0a475d83-0', + serviceName: 'driver', +} as TempoQuery; + +describe('NativeSearch', () => { + it('should call the `onChange` function on click of the Input', async () => { + const promise = Promise.resolve(); + const handleOnChange = jest.fn(() => promise); + const fakeOptionChoice = { + key: 'Q-595a9bbc-2a25-49a7-9249-a52a0a475d83-0', + queryType: 'nativeSearch', + refId: 'A', + serviceName: 'driver', + spanName: 'driver', + }; + + render( + {}} + /> + ); + + const asyncServiceSelect = await screen.findByRole('combobox', { name: 'select-span-name' }); + + expect(asyncServiceSelect).toBeInTheDocument(); + userEvent.click(asyncServiceSelect); + + const driverOption = await screen.findByText('driver'); + userEvent.click(driverOption); + + expect(handleOnChange).toHaveBeenCalledWith(fakeOptionChoice); + }); +}); + +describe('TempoLanguageProvider with delay', () => { + const getOptions2 = jest.fn().mockImplementation(() => { + return Promise.resolve([ + { + value: 'customer', + label: 'customer', + }, + { + value: 'driver', + label: 'driver', + }, + ]); + }); + + jest.mock('../language_provider', () => { + return jest.fn().mockImplementation(() => { + setTimeout(() => { + return { getOptions2 }; + }, 3000); + }); + }); + + it('should show loader', async () => { + const promise = Promise.resolve(); + const handleOnChange = jest.fn(() => promise); + + render( + {}} + /> + ); + + const asyncServiceSelect = screen.getByRole('combobox', { name: 'select-span-name' }); + + userEvent.click(asyncServiceSelect); + const loader = screen.getByText('Loading options...'); + + expect(loader).toBeInTheDocument(); + await act(() => promise); + }); +}); diff --git a/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.tsx b/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.tsx index dcf81762888..6ac6ddfc3ef 100644 --- a/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.tsx +++ b/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useCallback, useState, useEffect, useMemo } from 'react'; import { InlineFieldRow, InlineField, @@ -8,7 +8,7 @@ import { BracesPlugin, TypeaheadInput, TypeaheadOutput, - Select, + AsyncSelect, Alert, useStyles2, } from '@grafana/ui'; @@ -48,50 +48,59 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props const styles = useStyles2(getStyles); const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]); const [hasSyntaxLoaded, setHasSyntaxLoaded] = useState(false); - const [autocomplete, setAutocomplete] = useState<{ - serviceNameOptions: Array>; - spanNameOptions: Array>; - }>({ - serviceNameOptions: [], - spanNameOptions: [], + const [asyncServiceNameValue, setAsyncServiceNameValue] = useState>({ + value: '', + }); + const [asyncSpanNameValue, setAsyncSpanNameValue] = useState>({ + value: '', }); const [error, setError] = useState(null); const [inputErrors, setInputErrors] = useState<{ [key: string]: boolean }>({}); + const [isLoading, setIsLoading] = useState<{ + serviceName: boolean; + spanName: boolean; + }>({ + serviceName: false, + spanName: false, + }); - const fetchServiceNameOptions = useMemo( - () => - debounce( - async () => { - const res = await languageProvider.getOptions('service.name'); - setAutocomplete((prev) => ({ ...prev, serviceNameOptions: res })); - }, - 500, - { leading: true, trailing: true } - ), + async function fetchOptionsCallback(nameType: string, lp: TempoLanguageProvider) { + try { + const res = await lp.getOptions(nameType === 'serviceName' ? 'service.name' : 'name'); + setIsLoading((prevValue) => ({ ...prevValue, [nameType]: false })); + return res; + } catch (error) { + if (error?.status === 404) { + setIsLoading((prevValue) => ({ ...prevValue, [nameType]: false })); + } else { + dispatch(notifyApp(createErrorNotification('Error', error))); + setIsLoading((prevValue) => ({ ...prevValue, [nameType]: false })); + } + setError(error); + return []; + } + } + + const loadOptionsOfType = useCallback( + (nameType: string) => { + setIsLoading((prevValue) => ({ ...prevValue, [nameType]: true })); + return fetchOptionsCallback(nameType, languageProvider); + }, [languageProvider] ); - const fetchSpanNameOptions = useMemo( - () => - debounce( - async () => { - const res = await languageProvider.getOptions('name'); - setAutocomplete((prev) => ({ ...prev, spanNameOptions: res })); - }, - 500, - { leading: true, trailing: true } - ), - [languageProvider] + const fetchOptionsOfType = useCallback( + (nameType: string) => debounce(() => loadOptionsOfType(nameType), 500, { leading: true, trailing: true }), + [loadOptionsOfType] ); useEffect(() => { - const fetchAutocomplete = async () => { + const fetchOptions = async () => { try { await languageProvider.start(); - const serviceNameOptions = await languageProvider.getOptions('service.name'); - const spanNameOptions = await languageProvider.getOptions('name'); + fetchOptionsCallback('serviceName', languageProvider); + fetchOptionsCallback('spanName', languageProvider); setHasSyntaxLoaded(true); - setAutocomplete({ serviceNameOptions, spanNameOptions }); } catch (error) { // Display message if Tempo is connected but search 404's if (error?.status === 404) { @@ -99,10 +108,11 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props } else { dispatch(notifyApp(createErrorNotification('Error', error))); } + setHasSyntaxLoaded(true); } }; - fetchAutocomplete(); - }, [languageProvider, fetchServiceNameOptions, fetchSpanNameOptions]); + fetchOptions(); + }, [languageProvider, fetchOptionsOfType]); const onTypeahead = async (typeahead: TypeaheadInput): Promise => { return await languageProvider.provideCompletionItems(typeahead); @@ -127,41 +137,53 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props
- { + setAsyncSpanNameValue({ value: v }); onChange({ ...query, spanName: v?.value || undefined, }); }} placeholder="Select a span" - onOpenMenu={fetchSpanNameOptions} isClearable + defaultOptions onKeyDown={onKeyDown} + aria-label={'select-span-name'} /> From 85753afdf038322c5605555bf9e4e3b475e3d567 Mon Sep 17 00:00:00 2001 From: Sarah Zinger Date: Thu, 17 Feb 2022 12:53:42 -0500 Subject: [PATCH 055/104] Cloudwatch: Fix bug with onblur in Metric Search (#45488) --- .../cloudwatch/components/MathExpressionQueryField.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/public/app/plugins/datasource/cloudwatch/components/MathExpressionQueryField.tsx b/public/app/plugins/datasource/cloudwatch/components/MathExpressionQueryField.tsx index aa960f9769b..b8a42a2837d 100644 --- a/public/app/plugins/datasource/cloudwatch/components/MathExpressionQueryField.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/MathExpressionQueryField.tsx @@ -72,6 +72,7 @@ export function MathExpressionQueryField({ onBlur={(value) => { if (value !== query) { onChange(value); + onRunQuery(); } }} onBeforeEditorMount={(monaco: Monaco) => From 912464a07324edaa00e1c542363f38d444bc0fed Mon Sep 17 00:00:00 2001 From: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> Date: Thu, 17 Feb 2022 14:00:22 -0500 Subject: [PATCH 056/104] Docs: Update 8.4 What's New (#45547) * replace * with * Ran prettier check * Revert "Ran prettier check" This reverts commit 32be986af82b7da8c9896bf0e080fb858238ee3a. * Ran prettier check again. * Added backticks. --- docs/sources/whatsnew/whats-new-in-v8-4.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sources/whatsnew/whats-new-in-v8-4.md b/docs/sources/whatsnew/whats-new-in-v8-4.md index 4a86ffcd536..c69391f9d90 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-4.md +++ b/docs/sources/whatsnew/whats-new-in-v8-4.md @@ -24,9 +24,9 @@ You can now share links to your playlists the same way as with dashboards, to ea {{< figure src="/static/img/docs/dashboards/share-playlists-8-4.png" max-width="1200px" caption="Share playlist" >}} -### $**interval and $**interval_ms in panel title +### `$**interval` and `$**interval_ms` in panel title -We’ve expanded the functionality of this existing and popular feature. You can now use $**interval and $**interval_ms in panel titles. This new function displays the interval that’s used in a specific panel without requiring edit mode. +We’ve expanded the functionality of this existing and popular feature. You can now use `$**interval` and `$**interval_ms` in panel titles. This new function displays the interval that’s used in a specific panel without requiring edit mode. {{< figure src="/static/img/docs/panels/interval-8-4.png" max-width="1200px" caption="Time intervals" >}} From e29c38ae27ff6bbe91e7e5df1ac118c5b0bda749 Mon Sep 17 00:00:00 2001 From: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> Date: Thu, 17 Feb 2022 15:03:34 -0500 Subject: [PATCH 057/104] Added correct format. (#45562) --- docs/sources/whatsnew/whats-new-in-v8-4.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sources/whatsnew/whats-new-in-v8-4.md b/docs/sources/whatsnew/whats-new-in-v8-4.md index c69391f9d90..c9d86fb8ac6 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-4.md +++ b/docs/sources/whatsnew/whats-new-in-v8-4.md @@ -24,9 +24,9 @@ You can now share links to your playlists the same way as with dashboards, to ea {{< figure src="/static/img/docs/dashboards/share-playlists-8-4.png" max-width="1200px" caption="Share playlist" >}} -### `$**interval` and `$**interval_ms` in panel title +### `$__interval` and `$__interval_ms` in panel title -We’ve expanded the functionality of this existing and popular feature. You can now use `$**interval` and `$**interval_ms` in panel titles. This new function displays the interval that’s used in a specific panel without requiring edit mode. +We’ve expanded the functionality of this existing and popular feature. You can now use `$__interval` and `$__interval_ms` in panel titles. This new function displays the interval that’s used in a specific panel without requiring edit mode. {{< figure src="/static/img/docs/panels/interval-8-4.png" max-width="1200px" caption="Time intervals" >}} From 7a619cbe4e8fa949dfb30da7a4f48c9f79e6593d Mon Sep 17 00:00:00 2001 From: Kyle Cunningham Date: Thu, 17 Feb 2022 14:07:50 -0600 Subject: [PATCH 058/104] Hide Barchart Value Labels for Automatic Display When Overlapping (#44809) * Implement skip list for hiding value labels when Auto is selected Co-authored-by: Dominik Prokop --- public/app/plugins/panel/barchart/bars.ts | 248 +++++++++++++----- public/app/plugins/panel/barchart/quadtree.ts | 9 + 2 files changed, 198 insertions(+), 59 deletions(-) diff --git a/public/app/plugins/panel/barchart/bars.ts b/public/app/plugins/panel/barchart/bars.ts index eb9437b53e9..a8e79ca1942 100644 --- a/public/app/plugins/panel/barchart/bars.ts +++ b/public/app/plugins/panel/barchart/bars.ts @@ -1,8 +1,8 @@ import uPlot, { Axis, AlignedData, Scale } from 'uplot'; -import { pointWithin, Quadtree, Rect } from './quadtree'; +import { intersects, pointWithin, Quadtree, Rect } from './quadtree'; import { distribute, SPACE_BETWEEN } from './distribute'; import { DataFrame, GrafanaTheme2 } from '@grafana/data'; -import { calculateFontSize, PlotTooltipInterpolator } from '@grafana/ui'; +import { measureText, PlotTooltipInterpolator } from '@grafana/ui'; import { StackingMode, VisibilityMode, @@ -57,6 +57,57 @@ export interface BarsOptions { xTimeAuto?: boolean; } +/** + * @internal + */ +interface ValueLabelTable { + [index: number]: ValueLabelArray; +} + +/** + * @internal + */ +interface ValueLabelArray { + [index: number]: ValueLabel; +} + +/** + * @internal + */ +interface ValueLabel { + text: string; + value: number | null; + hidden: boolean; + bbox?: Rect; + textMetrics?: TextMetrics; + x?: number; + y?: number; +} + +/** + * @internal + */ +function calculateFontSizeWithMetrics( + text: string, + width: number, + height: number, + lineHeight: number, + maxSize?: number +) { + // calculate width in 14px + const textSize = measureText(text, 14); + // how much bigger than 14px can we make it while staying within our width constraints + const fontSizeBasedOnWidth = (width / (textSize.width + 2)) * 14; + const fontSizeBasedOnHeight = height / lineHeight; + + // final fontSize + const optimalSize = Math.min(fontSizeBasedOnHeight, fontSizeBasedOnWidth); + return { + fontSize: Math.min(optimalSize, maxSize ?? optimalSize), + textMetrics: textSize, + }; +} + /** * @internal */ @@ -191,9 +242,15 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { return out; }; + const LABEL_OFFSET_FACTOR = isXHorizontal ? LABEL_OFFSET_FACTOR_VT : LABEL_OFFSET_FACTOR_HZ; + const LABEL_OFFSET_MAX = isXHorizontal ? LABEL_OFFSET_MAX_VT : LABEL_OFFSET_MAX_HZ; + let barsPctLayout: Array = []; let barsColors: Array; stroke: Array }> = []; - let barRects: Rect[] = []; + let scaleFactor = 1; + let labels: ValueLabelTable = {}; + let fontSize = opts.text?.valueSize ?? VALUE_MAX_FONT_SIZE; + let labelOffset = LABEL_OFFSET_MAX; // minimum available space for labels between bar end and plotting area bound (in canvas pixels) let vSpace = Infinity; @@ -235,7 +292,6 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { top -= u.bbox.top; let val = u.data[seriesIdx][dataIdx]!; - // accum min space abvailable for labels if (isXHorizontal) { vSpace = Math.min(vSpace, val < 0 ? u.bbox.height - (top + hgt) : top); @@ -247,7 +303,101 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { let barRect = { x: lft, y: top, w: wid, h: hgt, sidx: seriesIdx, didx: dataIdx }; qt.add(barRect); - barRects.push(barRect); + + if (showValue !== VisibilityMode.Never) { + // Format Values and calculate label offsets + const text = formatValue( + seriesIdx, + rawValue(seriesIdx, dataIdx)! / (pctStacked ? alignedTotals![seriesIdx][dataIdx]! : 1) + ); + labelOffset = Math.min(labelOffset, Math.round(LABEL_OFFSET_FACTOR * (isXHorizontal ? wid : hgt))); + + if (labels[dataIdx] === undefined) { + labels[dataIdx] = {}; + } + labels[dataIdx][seriesIdx] = { text: text, value: rawValue(seriesIdx, dataIdx), hidden: false }; + + // Calculate font size when it's set to be automatic + if (hasAutoValueSize) { + const { fontSize: calculatedSize, textMetrics } = calculateFontSizeWithMetrics( + labels[dataIdx][seriesIdx].text, + hSpace * (isXHorizontal ? BAR_FONT_SIZE_RATIO : 1) - (isXHorizontal ? 0 : labelOffset), + vSpace * (isXHorizontal ? 1 : BAR_FONT_SIZE_RATIO) - (isXHorizontal ? labelOffset : 0), + 1 + ); + + // Save text metrics + labels[dataIdx][seriesIdx].textMetrics = textMetrics; + + // Retrieve the new font size and use it + let autoFontSize = Math.round(Math.min(fontSize, VALUE_MAX_FONT_SIZE, calculatedSize)); + + // Calculate the scaling factor for bouding boxes + // Take into account the fact that calculateFontSize + // uses 14px measurement so we need to adjust the scale factor + scaleFactor = (autoFontSize / fontSize) * (autoFontSize / 14); + + // Update the end font-size + fontSize = autoFontSize; + } else { + labels[dataIdx][seriesIdx].textMetrics = measureText(labels[dataIdx][seriesIdx].text, fontSize); + } + + let middleShift = isXHorizontal ? 0 : -Math.round(MIDDLE_BASELINE_SHIFT * fontSize); + let value = rawValue(seriesIdx, dataIdx); + + if (value != null) { + // Calculate final co-ordinates for text position + const x = + u.bbox.left + (isXHorizontal ? lft + wid / 2 : value < 0 ? lft - labelOffset : lft + wid + labelOffset); + const y = + u.bbox.top + + (isXHorizontal ? (value < 0 ? top + hgt + labelOffset : top - labelOffset) : top + hgt / 2 - middleShift); + + // Retrieve textMetrics with necessary default values + // These _shouldn't_ be undefined at this point + // but they _could_ be. + const { + textMetrics = { + width: 1, + actualBoundingBoxAscent: 1, + actualBoundingBoxDescent: 1, + }, + } = labels[dataIdx][seriesIdx]; + + // Adjust bounding boxes based on text scale + // factor and orientation (which changes the baseline) + let xAdjust = 0, + yAdjust = 0; + + if (isXHorizontal) { + // Adjust for baseline which is "top" in this case + xAdjust = (textMetrics.width * scaleFactor) / 2; + + // yAdjust only matters when when the value isn't negative + yAdjust = + value > 0 + ? (textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) * scaleFactor + : 0; + } else { + // Adjust from the baseline which is "middle" in this case + yAdjust = ((textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) * scaleFactor) / 2; + + // Adjust for baseline being "right" in the x direction + xAdjust = value < 0 ? textMetrics.width * scaleFactor : 0; + } + + // Construct final bounding box for the label text + labels[dataIdx][seriesIdx].x = x; + labels[dataIdx][seriesIdx].y = y; + labels[dataIdx][seriesIdx].bbox = { + x: x - xAdjust, + y: y - yAdjust, + w: textMetrics.width * scaleFactor, + h: (textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) * scaleFactor, + }; + } + } }, }); @@ -260,7 +410,6 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { // Build bars const drawClear = (u: uPlot) => { qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); - qt.clear(); // clear the path cache to force drawBars() to rebuild new quadtree @@ -296,66 +445,35 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { } } - barRects.length = 0; + labels = {}; + fontSize = opts.text?.valueSize ?? VALUE_MAX_FONT_SIZE; + labelOffset = LABEL_OFFSET_MAX; vSpace = hSpace = Infinity; }; - const LABEL_OFFSET_FACTOR = isXHorizontal ? LABEL_OFFSET_FACTOR_VT : LABEL_OFFSET_FACTOR_HZ; - const LABEL_OFFSET_MAX = isXHorizontal ? LABEL_OFFSET_MAX_VT : LABEL_OFFSET_MAX_HZ; - // uPlot hook to draw the labels on the bar chart. const draw = (u: uPlot) => { - if (showValue === VisibilityMode.Never) { + if (showValue === VisibilityMode.Never || fontSize < VALUE_MIN_FONT_SIZE) { return; } - // pre-cache formatted labels - let texts = Array(barRects.length); - let labelOffset = LABEL_OFFSET_MAX; - - barRects.forEach((r, i) => { - texts[i] = formatValue(r.sidx, rawValue(r.sidx, r.didx)! / (pctStacked ? alignedTotals![r.sidx][r.didx]! : 1)); - labelOffset = Math.min(labelOffset, Math.round(LABEL_OFFSET_FACTOR * (isXHorizontal ? r.w : r.h))); - }); - - let fontSize = opts.text?.valueSize ?? VALUE_MAX_FONT_SIZE; - - if (hasAutoValueSize) { - for (let i = 0; i < barRects.length; i++) { - fontSize = Math.round( - Math.min( - fontSize, - VALUE_MAX_FONT_SIZE, - calculateFontSize( - texts[i], - hSpace * (isXHorizontal ? BAR_FONT_SIZE_RATIO : 1) - (isXHorizontal ? 0 : labelOffset), - vSpace * (isXHorizontal ? 1 : BAR_FONT_SIZE_RATIO) - (isXHorizontal ? labelOffset : 0), - 1 - ) - ) - ); - - if (fontSize < VALUE_MIN_FONT_SIZE && showValue !== VisibilityMode.Always) { - return; - } - } - } u.ctx.save(); - u.ctx.fillStyle = theme.colors.text.primary; u.ctx.font = `${fontSize}px ${theme.typography.fontFamily}`; - let middleShift = isXHorizontal ? 0 : -Math.round(MIDDLE_BASELINE_SHIFT * fontSize); + let curAlign: CanvasTextAlign | undefined = undefined, + curBaseline: CanvasTextBaseline | undefined = undefined; - let curAlign: CanvasTextAlign, curBaseline: CanvasTextBaseline; + for (const didx in labels) { + for (const sidx in labels[didx]) { + const { text, value, x = 0, y = 0, bbox = { x: 0, y: 0, w: 1, h: 1 } } = labels[didx][sidx]; - barRects.forEach((r, i) => { - let value = rawValue(r.sidx, r.didx); - let text = texts[i]; - - if (value != null) { - let align: CanvasTextAlign = isXHorizontal ? 'center' : value < 0 ? 'right' : 'left'; - let baseline: CanvasTextBaseline = isXHorizontal ? (value < 0 ? 'top' : 'alphabetic') : 'middle'; + let align: CanvasTextAlign = isXHorizontal ? 'center' : value !== null && value < 0 ? 'right' : 'left'; + let baseline: CanvasTextBaseline = isXHorizontal + ? value !== null && value < 0 + ? 'top' + : 'alphabetic' + : 'middle'; if (align !== curAlign) { u.ctx.textAlign = curAlign = align; @@ -365,14 +483,26 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { u.ctx.textBaseline = curBaseline = baseline; } - u.ctx.fillText( - text, - u.bbox.left + (isXHorizontal ? r.x + r.w / 2 : value < 0 ? r.x - labelOffset : r.x + r.w + labelOffset), - u.bbox.top + - (isXHorizontal ? (value < 0 ? r.y + r.h + labelOffset : r.y - labelOffset) : r.y + r.h / 2 - middleShift) - ); + if (showValue === VisibilityMode.Always) { + u.ctx.fillText(text, x, y); + } else if (showValue === VisibilityMode.Auto) { + let intersectsLabel = false; + + // Test for any collisions + for (const subsidx in labels[didx]) { + const r = labels[didx][subsidx].bbox!; + + if (!labels[didx][subsidx].hidden && sidx !== subsidx && intersects(bbox, r)) { + intersectsLabel = true; + labels[didx][sidx].hidden = true; + break; + } + } + + !intersectsLabel && u.ctx.fillText(text, x, y); + } } - }); + } u.ctx.restore(); }; diff --git a/public/app/plugins/panel/barchart/quadtree.ts b/public/app/plugins/panel/barchart/quadtree.ts index 76648eaa5dc..dd214972c39 100644 --- a/public/app/plugins/panel/barchart/quadtree.ts +++ b/public/app/plugins/panel/barchart/quadtree.ts @@ -11,6 +11,15 @@ export function pointWithin(px: number, py: number, rlft: number, rtop: number, return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm; } +/** + * @internal + * + * Determines if r2 is intersected by r1. + */ +export function intersects(r1: Rect, r2: Rect) { + return r1.x <= r2.x + r2.w && r1.x + r1.w >= r2.x && r1.y + r1.h >= r2.y && r1.y <= r2.y + r2.h; +} + /** * @internal */ From a9de33601c604872357e3618a3955a85587f8be5 Mon Sep 17 00:00:00 2001 From: Santiago Date: Fri, 18 Feb 2022 01:45:34 -0300 Subject: [PATCH 059/104] make send_alerts_to field nullable (#45572) * make send_alerts_to field nullable * set nullable to true since we have a default value --- pkg/services/sqlstore/migrations/ualert/tables.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/services/sqlstore/migrations/ualert/tables.go b/pkg/services/sqlstore/migrations/ualert/tables.go index 7d872119c4d..e3f42b6057a 100644 --- a/pkg/services/sqlstore/migrations/ualert/tables.go +++ b/pkg/services/sqlstore/migrations/ualert/tables.go @@ -327,7 +327,7 @@ func AddAlertAdminConfigMigrations(mg *migrator.Migrator) { mg.AddMigration("create_ngalert_configuration_table", migrator.NewAddTableMigration(adminConfiguration)) mg.AddMigration("add index in ngalert_configuration on org_id column", migrator.NewAddIndexMigration(adminConfiguration, adminConfiguration.Indices[0])) mg.AddMigration("add column send_alerts_to in ngalert_configuration", migrator.NewAddColumnMigration(adminConfiguration, &migrator.Column{ - Name: "send_alerts_to", Type: migrator.DB_SmallInt, Nullable: false, + Name: "send_alerts_to", Type: migrator.DB_SmallInt, Nullable: false, Default: "0", })) } From dbec2b02fd3b71c0ae90d440a16e6857b8f8bddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Fri, 18 Feb 2022 06:06:04 +0100 Subject: [PATCH 060/104] Variables: move state tree into a keyed state (#44642) * Variables: move state tree into a keyed state * Update public/app/features/variables/state/transactionReducer.ts Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com> * Chore: fix prettier error * Chore: renamed slices and lastUid * Chore: rename toUidAction * Chore: rename dashboardVariableReducer * Chore: rename state prop back to templating * Chore renames variable.dashboardUid * Chore: rename toDashboardVariableIdentifier * Chore: rename getDashboardVariable * Chore: rename getDashboardVariablesState * Chore: rename getDashboardVariables * Chore: some more renames * Chore: small clean up * Chore: small rename * Chore: removes unused function * Chore: rename VariableModel.stateKey * Chore: rename KeyedVariableIdentifier.stateKey * user essentials mob! :trident: * user essentials mob! :trident: * user essentials mob! :trident: * user essentials mob! :trident: Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com> Co-authored-by: kay delaney Co-authored-by: Alexandra Vargas Co-authored-by: Ashley Harrison --- .betterer.results | 2 +- public/app/core/reducers/root.ts | 4 +- .../DashboardSettings/DashboardSettings.tsx | 2 +- .../components/PanelEditor/PanelEditor.tsx | 6 +- .../RepeatRowSelect/RepeatRowSelect.tsx | 6 +- .../components/ShareModal/ShareLink.test.tsx | 2 +- .../dashboard/components/SubMenu/SubMenu.tsx | 8 +- .../dashboard/containers/DashboardPage.tsx | 2 +- .../dashboard/state/DashboardModel.ts | 18 +- .../app/features/dashboard/state/actions.ts | 2 +- .../dashboard/state/initDashboard.test.ts | 27 +- .../features/dashboard/state/initDashboard.ts | 7 +- .../panel/panellinks/specs/link_srv.test.ts | 2 +- .../AnnotationsWorker.test.ts | 2 +- .../DashboardQueryRunner.test.ts | 2 +- .../features/templating/template_srv.test.ts | 46 +- .../adhoc/AdHocVariableEditor.test.tsx | 13 +- .../variables/adhoc/AdHocVariableEditor.tsx | 32 +- .../features/variables/adhoc/actions.test.ts | 151 +++-- .../app/features/variables/adhoc/actions.ts | 142 +++-- .../app/features/variables/adhoc/adapter.ts | 5 +- .../variables/adhoc/picker/AdHocPicker.tsx | 7 +- .../features/variables/adhoc/reducer.test.ts | 3 +- .../app/features/variables/adhoc/reducer.ts | 3 +- .../variables/constant/actions.test.ts | 14 +- .../features/variables/constant/actions.ts | 9 +- .../features/variables/constant/adapter.ts | 10 +- .../variables/constant/reducer.test.ts | 6 +- .../features/variables/constant/reducer.ts | 4 +- .../features/variables/custom/actions.test.ts | 14 +- .../app/features/variables/custom/actions.ts | 9 +- .../app/features/variables/custom/adapter.ts | 11 +- .../features/variables/custom/reducer.test.ts | 6 +- .../app/features/variables/custom/reducer.ts | 3 +- .../datasource/DataSourceVariableEditor.tsx | 31 +- .../variables/datasource/actions.test.ts | 92 +-- .../features/variables/datasource/actions.ts | 18 +- .../features/variables/datasource/adapter.ts | 11 +- .../variables/datasource/reducer.test.ts | 5 +- .../features/variables/datasource/reducer.ts | 3 +- .../editor/SelectionOptionsEditor.tsx | 7 +- .../editor/VariableEditorContainer.tsx | 87 ++- .../variables/editor/VariableEditorEditor.tsx | 101 ++- .../variables/editor/VariableEditorList.tsx | 10 +- .../editor/VariableEditorListRow.tsx | 11 +- .../app/features/variables/editor/actions.ts | 86 +-- .../features/variables/editor/reducer.test.ts | 2 +- .../getAllVariableValuesForUrl.test.ts | 19 +- .../variables/getAllVariableValuesForUrl.ts | 1 - .../variables/interval/actions.test.ts | 75 ++- .../features/variables/interval/actions.ts | 13 +- .../features/variables/interval/adapter.ts | 14 +- .../variables/interval/reducer.test.ts | 3 +- .../features/variables/interval/reducer.ts | 3 +- .../OptionsPicker/OptionPicker.test.tsx | 19 +- .../pickers/OptionsPicker/OptionsPicker.tsx | 90 ++- .../pickers/OptionsPicker/actions.test.ts | 305 ++++++---- .../pickers/OptionsPicker/actions.ts | 86 +-- .../pickers/OptionsPicker/reducer.test.ts | 2 +- .../pickers/OptionsPicker/reducer.ts | 8 +- .../query/QueryVariableEditor.test.tsx | 11 +- .../variables/query/QueryVariableEditor.tsx | 30 +- .../query/VariableQueryRunner.test.ts | 42 +- .../variables/query/VariableQueryRunner.ts | 24 +- .../features/variables/query/actions.test.tsx | 306 ++++++---- .../app/features/variables/query/actions.ts | 85 ++- .../app/features/variables/query/adapter.ts | 11 +- .../variables/query/operators.test.ts | 18 +- .../app/features/variables/query/operators.ts | 12 +- .../features/variables/query/reducer.test.ts | 8 +- .../app/features/variables/query/reducer.ts | 3 +- .../query/variableQueryObserver.test.ts | 4 +- .../shared/testing/variableBuilder.ts | 5 + .../features/variables/state/actions.test.ts | 573 +++++++++++------- .../app/features/variables/state/actions.ts | 252 +++++--- .../app/features/variables/state/helpers.ts | 40 +- .../state/initVariableTransaction.test.ts | 179 +++--- .../state/keyedVariablesReducer.test.ts | 128 ++++ .../variables/state/keyedVariablesReducer.ts | 50 ++ ...igrateVariablesDatasourceNameToRef.test.ts | 56 +- .../state/onTimeRangeUpdated.test.ts | 87 +-- .../variables/state/processVariable.test.ts | 354 ++++++----- .../features/variables/state/reducers.test.ts | 8 +- .../app/features/variables/state/reducers.ts | 53 +- .../app/features/variables/state/selectors.ts | 98 ++- .../variables/state/setOptionFromUrl.test.ts | 109 +++- .../variables/state/sharedReducer.test.ts | 20 +- .../features/variables/state/sharedReducer.ts | 9 +- .../state/templateVarsChangedInUrl.test.ts | 10 +- .../variables/state/transactionReducer.ts | 16 +- public/app/features/variables/state/types.ts | 28 +- .../state/upgradeLegacyQueries.test.ts | 48 +- .../textbox/TextBoxVariablePicker.tsx | 15 +- .../variables/textbox/actions.test.ts | 47 +- .../app/features/variables/textbox/actions.ts | 28 +- .../variables/textbox/adapter.test.ts | 2 + .../app/features/variables/textbox/adapter.ts | 10 +- .../variables/textbox/reducer.test.ts | 3 +- .../app/features/variables/textbox/reducer.ts | 3 +- public/app/features/variables/types.ts | 2 + public/app/features/variables/utils.ts | 32 +- .../cloudwatch/specs/datasource.test.ts | 9 +- public/app/store/store.ts | 10 +- public/test/helpers/convertToStoreState.ts | 14 +- public/test/helpers/initTemplateSrv.ts | 4 +- 105 files changed, 2894 insertions(+), 1644 deletions(-) create mode 100644 public/app/features/variables/state/keyedVariablesReducer.test.ts create mode 100644 public/app/features/variables/state/keyedVariablesReducer.ts diff --git a/.betterer.results b/.betterer.results index 3a1f939d9d5..3c9d28c1017 100644 --- a/.betterer.results +++ b/.betterer.results @@ -209,7 +209,7 @@ exports[`no enzyme tests`] = { "public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.test.tsx:4134073823": [ [1, 17, 13, "RegExp match", "2409514259"] ], - "public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx:1141305288": [ + "public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx:1044891955": [ [1, 35, 13, "RegExp match", "2409514259"] ], "public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx:1798654441": [ diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 88ff7d0fdfb..de3b901986f 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -13,11 +13,11 @@ import usersReducers from 'app/features/users/state/reducers'; import userReducers from 'app/features/profile/state/reducers'; import organizationReducers from 'app/features/org/state/reducers'; import ldapReducers from 'app/features/admin/state/reducers'; -import templatingReducers from 'app/features/variables/state/reducers'; import importDashboardReducers from 'app/features/manage-dashboards/state/reducers'; import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers'; import panelsReducers from 'app/features/panel/state/reducers'; import serviceAccountsReducer from 'app/features/serviceaccounts/state/reducers'; +import templatingReducers from 'app/features/variables/state/keyedVariablesReducer'; const rootReducers = { ...sharedReducers, @@ -33,10 +33,10 @@ const rootReducers = { ...userReducers, ...organizationReducers, ...ldapReducers, - ...templatingReducers, ...importDashboardReducers, ...panelEditorReducers, ...panelsReducers, + ...templatingReducers, plugins: pluginsReducer, }; diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx index 125beaab782..486cacbe7c6 100644 --- a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx @@ -70,7 +70,7 @@ export function DashboardSettings({ dashboard, editview }: Props) { title: 'Variables', id: 'templating', icon: 'calculator-alt', - component: , + component: , }); pages.push({ diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx index 568c7ba1ede..1f50138843c 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx @@ -34,7 +34,6 @@ import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; import { toggleTableView } from './state/reducers'; import { getPanelEditorTabs } from './state/selectors'; -import { getVariables } from 'app/features/variables/state/selectors'; import { StoreState } from 'app/types'; import { DisplayMode, displayModes, PanelEditorTab } from './types'; @@ -55,6 +54,7 @@ import { notifyApp } from '../../../../core/actions'; import { PanelEditorTableView } from './PanelEditorTableView'; import { PanelModelWithLibraryPanel } from 'app/features/library-panels/types'; import { getPanelStateForModel } from 'app/features/panel/state/selectors'; +import { getVariablesByKey } from '../../../variables/state/selectors'; interface OwnProps { dashboard: DashboardModel; @@ -62,7 +62,7 @@ interface OwnProps { tab?: string; } -const mapStateToProps = (state: StoreState) => { +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { const panel = state.panelEditor.getPanel(); const panelState = getPanelStateForModel(state, panel); @@ -73,7 +73,7 @@ const mapStateToProps = (state: StoreState) => { initDone: state.panelEditor.initDone, uiState: state.panelEditor.ui, tableViewEnabled: state.panelEditor.tableViewEnabled, - variables: getVariables(state), + variables: getVariablesByKey(ownProps.dashboard.uid, state), }; }; diff --git a/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx b/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx index 9dda02baca4..0ad229d26df 100644 --- a/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx +++ b/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'; import { Select } from '@grafana/ui'; import { SelectableValue } from '@grafana/data'; -import { getVariables } from '../../../variables/state/selectors'; +import { getLastKey, getVariablesByKey } from '../../../variables/state/selectors'; import { StoreState } from '../../../../types'; export interface Props { @@ -13,7 +13,9 @@ export interface Props { } export const RepeatRowSelect: FC = ({ repeat, onChange, id }) => { - const variables = useSelector((state: StoreState) => getVariables(state)); + const variables = useSelector((state: StoreState) => { + return getVariablesByKey(getLastKey(state), state); + }); const variableOptions = useMemo(() => { const options = variables.map((item: any) => { diff --git a/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx index 3ef55c9c1b1..b93827ad01a 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx @@ -96,7 +96,7 @@ function shareLinkScenario(description: string, scenarioFn: (ctx: ScenarioContex } describe('ShareModal', () => { - let templateSrv = initTemplateSrv([]); + let templateSrv = initTemplateSrv('key', []); beforeAll(() => { variableAdapters.register(createQueryVariableAdapter()); diff --git a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx index 01aeca07e61..14839796da0 100644 --- a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx +++ b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react'; import { connect, MapStateToProps } from 'react-redux'; import { StoreState } from '../../../../types'; -import { getSubMenuVariables } from '../../../variables/state/selectors'; +import { getSubMenuVariables, getVariablesState } from '../../../variables/state/selectors'; import { VariableModel } from '../../../variables/types'; import { DashboardModel } from '../../state'; import { DashboardLinks } from './DashboardLinks'; @@ -64,9 +64,11 @@ class SubMenuUnConnected extends PureComponent { } } -const mapStateToProps: MapStateToProps = (state) => { +const mapStateToProps: MapStateToProps = (state, ownProps) => { + const { uid } = ownProps.dashboard; + const templatingState = getVariablesState(uid, state); return { - variables: getSubMenuVariables(state.templating.variables), + variables: getSubMenuVariables(uid, templatingState.variables), }; }; diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 309adc30170..09ab1695faf 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -168,7 +168,7 @@ export class UnthemedDashboardPage extends PureComponent { const templateVarChanges = findTemplateVarChanges(this.props.queryParams, prevProps.queryParams); if (templateVarChanges) { - templateVarsChangedInUrl(templateVarChanges); + templateVarsChangedInUrl(dashboard.uid, templateVarChanges); } } diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index bbda7085b5d..e3afab19aea 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -37,7 +37,7 @@ import { UrlQueryValue, } from '@grafana/data'; import { CoreEvents, DashboardMeta, KioskMode } from 'app/types'; -import { GetVariables, getVariables } from 'app/features/variables/state/selectors'; +import { GetVariables, getVariablesByKey } from 'app/features/variables/state/selectors'; import { variableAdapters } from 'app/features/variables/adapters'; import { onTimeRangeUpdated } from 'app/features/variables/state/actions'; import { dispatch } from '../../../store/store'; @@ -139,7 +139,7 @@ export class DashboardModel implements TimeModel { lastRefresh: true, }; - constructor(data: any, meta?: DashboardMeta, private getVariablesFromState: GetVariables = getVariables) { + constructor(data: any, meta?: DashboardMeta, private getVariablesFromState: GetVariables = getVariablesByKey) { if (!data) { data = {}; } @@ -346,7 +346,7 @@ export class DashboardModel implements TimeModel { defaults: { saveTimerange: boolean; saveVariables: boolean } & CloneOptions ) { const originalVariables = this.originalTemplating; - const currentVariables = this.getVariablesFromState(); + const currentVariables = this.getVariablesFromState(this.uid); copy.templating = { list: currentVariables.map((variable) => @@ -374,7 +374,7 @@ export class DashboardModel implements TimeModel { timeRangeUpdated(timeRange: TimeRange) { this.events.publish(new TimeRangeUpdatedEvent(timeRange)); - dispatch(onTimeRangeUpdated(timeRange)); + dispatch(onTimeRangeUpdated(this.uid, timeRange)); } startRefresh(event: VariablesChangedEvent = { refreshAll: true, panelIds: [] }) { @@ -1085,11 +1085,11 @@ export class DashboardModel implements TimeModel { return; } - this.originalTemplating = this.cloneVariablesFrom(this.getVariablesFromState()); + this.originalTemplating = this.cloneVariablesFrom(this.getVariablesFromState(this.uid)); } hasVariableValuesChanged() { - return this.hasVariablesChanged(this.originalTemplating, this.getVariablesFromState()); + return this.hasVariablesChanged(this.originalTemplating, this.getVariablesFromState(this.uid)); } autoFitPanels(viewHeight: number, kioskMode?: UrlQueryValue) { @@ -1164,7 +1164,7 @@ export class DashboardModel implements TimeModel { } getVariables = () => { - return this.getVariablesFromState(); + return this.getVariablesFromState(this.uid); }; canAddAnnotations() { @@ -1179,7 +1179,7 @@ export class DashboardModel implements TimeModel { } private getPanelRepeatVariable(panel: PanelModel) { - return this.getVariablesFromState().find((variable) => variable.name === panel.repeat); + return this.getVariablesFromState(this.uid).find((variable) => variable.name === panel.repeat); } private isSnapshotTruthy() { @@ -1187,7 +1187,7 @@ export class DashboardModel implements TimeModel { } private hasVariables() { - return this.getVariablesFromState().length > 0; + return this.getVariablesFromState(this.uid).length > 0; } private hasVariablesChanged(originalVariables: any[], currentVariables: any[]): boolean { diff --git a/public/app/features/dashboard/state/actions.ts b/public/app/features/dashboard/state/actions.ts index 1a503a276ad..9ae183fc475 100644 --- a/public/app/features/dashboard/state/actions.ts +++ b/public/app/features/dashboard/state/actions.ts @@ -119,12 +119,12 @@ export const cleanUpDashboardAndVariables = (): ThunkResult => (dispatch, if (dashboard) { dashboard.destroy(); + dispatch(cancelVariables(dashboard.uid)); } getTimeSrv().stopAutoRefresh(); dispatch(cleanUpDashboard()); - dispatch(cancelVariables()); }; export const updateTimeZoneDashboard = diff --git a/public/app/features/dashboard/state/initDashboard.test.ts b/public/app/features/dashboard/state/initDashboard.test.ts index f7083989a6b..0bcb74ef8d2 100644 --- a/public/app/features/dashboard/state/initDashboard.test.ts +++ b/public/app/features/dashboard/state/initDashboard.test.ts @@ -11,7 +11,7 @@ import { Echo } from '../../../core/services/echo/Echo'; import { variableAdapters } from 'app/features/variables/adapters'; import { createConstantVariableAdapter } from 'app/features/variables/constant/adapter'; import { constantBuilder } from 'app/features/variables/shared/testing/builders'; -import { variablesInitTransaction } from '../../variables/state/transactionReducer'; +import { initialTransactionState, variablesInitTransaction } from '../../variables/state/transactionReducer'; import { keybindingSrv } from 'app/core/services/keybindingSrv'; import { getTimeSrv, setTimeSrv } from '../services/TimeSrv'; import { DashboardLoaderSrv, setDashboardLoaderSrv } from '../services/DashboardLoaderSrv'; @@ -22,6 +22,7 @@ import { } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; import { emptyResult } from '../../query/state/DashboardQueryRunner/utils'; import { TransactionStatus } from '../../variables/types'; +import { getPreloadedState } from '../../variables/state/helpers'; jest.mock('app/core/services/backend_srv'); jest.mock('app/features/dashboard/services/TimeSrv', () => { @@ -53,7 +54,7 @@ interface ScenarioContext { } type ScenarioFn = (ctx: ScenarioContext) => void; - +const DASH_UID = 'DGmvKKxZz'; function describeInitScenario(description: string, scenarioFn: ScenarioFn) { describe(description, () => { const loaderSrv = { @@ -83,6 +84,7 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) { templating: { list: [constantBuilder().build()], }, + uid: DASH_UID, }, })), }; @@ -100,7 +102,7 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) { const ctx: ScenarioContext = { args: { - urlUid: 'DGmvKKxZz', + urlUid: DASH_UID, fixUrl: false, routeName: DashboardRoutes.Normal, }, @@ -120,10 +122,10 @@ function describeInitScenario(description: string, scenarioFn: ScenarioFn) { queries: [], }, }, - templating: { + ...getPreloadedState(DASH_UID, { variables: {}, - transaction: { uid: 'DGmvKKxZz', status: TransactionStatus.Completed }, - }, + transaction: { ...initialTransactionState, uid: DASH_UID, status: TransactionStatus.Completed }, + }), }, setup: (fn: () => void) => { setupFn = fn; @@ -258,7 +260,7 @@ describeInitScenario('Initializing existing dashboard', (ctx) => { }); it('Should initialize redux variables if newVariables is enabled', () => { - expect(ctx.actions[2].type).toBe(variablesInitTransaction.type); + expect(ctx.actions[2].payload.action.type).toBe(variablesInitTransaction.type); }); }); @@ -287,3 +289,14 @@ describeInitScenario('Initializing previously canceled dashboard initialization' expect(getDashboardQueryRunner().run).toBeCalled(); }); }); + +describeInitScenario('Initializing snapshot dashboard', (ctx) => { + ctx.setup(() => { + ctx.args.urlUid = undefined; + }); + + it('Should send action initVariablesTransaction with correct payload', () => { + expect(ctx.actions[2].payload.action.type).toBe(variablesInitTransaction.type); + expect(ctx.actions[2].payload.action.payload.uid).toBe(DASH_UID); + }); +}); diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 876e2170e3a..91dc7c58280 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -23,6 +23,8 @@ import { emitDashboardViewEvent } from './analyticsProcessor'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { config, locationService } from '@grafana/runtime'; import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; +import { getIfExistsLastKey } from '../../variables/state/selectors'; +import { toStateKey } from 'app/features/variables/utils'; export interface InitDashboardArgs { urlUid?: string; @@ -155,15 +157,16 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { timeSrv.init(dashboard); + const dashboardUid = toStateKey(args.urlUid ?? dashboard.uid); // template values service needs to initialize completely before the rest of the dashboard can load - await dispatch(initVariablesTransaction(args.urlUid!, dashboard)); + await dispatch(initVariablesTransaction(dashboardUid, dashboard)); // DashboardQueryRunner needs to run after all variables have been resolved so that any annotation query including a variable // will be correctly resolved const runner = createDashboardQueryRunner({ dashboard, timeSrv }); runner.run({ dashboard, range: timeSrv.timeRange() }); - if (getState().templating.transaction.uid !== args.urlUid) { + if (getIfExistsLastKey(getState()) !== dashboardUid) { // if a previous dashboard has slow running variable queries the batch uid will be the new one // but the args.urlUid will be the same as before initVariablesTransaction was called so then we can't continue initializing // the previous dashboard. diff --git a/public/app/features/panel/panellinks/specs/link_srv.test.ts b/public/app/features/panel/panellinks/specs/link_srv.test.ts index b32b5b62b15..b2b30096045 100644 --- a/public/app/features/panel/panellinks/specs/link_srv.test.ts +++ b/public/app/features/panel/panellinks/specs/link_srv.test.ts @@ -32,7 +32,7 @@ describe('linkSrv', () => { _dashboard.refresh = false; setTimeSrv(timeSrv); - templateSrv = initTemplateSrv([ + templateSrv = initTemplateSrv('key', [ { type: 'query', name: 'home', current: { value: '127.0.0.1' } }, { type: 'query', name: 'server1', current: { value: '192.168.0.100' } }, ]); diff --git a/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts b/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts index daf7bc5dba1..8f6917004c9 100644 --- a/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts +++ b/public/app/features/query/state/DashboardQueryRunner/AnnotationsWorker.test.ts @@ -67,7 +67,7 @@ function expectOnResults(args: { done(); } catch (err) { subscription.unsubscribe(); - done.fail(err); + done(err); } }, }); diff --git a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts index e68011186a4..ba65d463d4f 100644 --- a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts +++ b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts @@ -69,7 +69,7 @@ function expectOnResults(args: { expectCallback(value); done(); } catch (err) { - done.fail(err); + done(err); } }, }); diff --git a/public/app/features/templating/template_srv.test.ts b/public/app/features/templating/template_srv.test.ts index ebe981efc8f..c99d43b1a9f 100644 --- a/public/app/features/templating/template_srv.test.ts +++ b/public/app/features/templating/template_srv.test.ts @@ -9,6 +9,8 @@ import { FormatRegistryID } from './formatRegistry'; import { setDataSourceSrv } from '@grafana/runtime'; import { mockDataSource, MockDataSourceSrv } from '../alerting/unified/mocks'; +const key = 'key'; + variableAdapters.setInit(() => [ createQueryVariableAdapter() as unknown as VariableAdapter, createAdHocVariableAdapter() as unknown as VariableAdapter, @@ -20,7 +22,7 @@ describe('templateSrv', () => { describe('init', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'oogle' } }]); }); it('should initialize template data', () => { @@ -31,7 +33,7 @@ describe('templateSrv', () => { describe('replace can pass scoped vars', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'oogle' } }]); }); it('scoped vars should support objects', () => { @@ -115,7 +117,7 @@ describe('templateSrv', () => { describe('getAdhocFilters', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'datasource', name: 'ds', @@ -152,7 +154,7 @@ describe('templateSrv', () => { describe('replace can pass multi / all format', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'test', @@ -168,7 +170,7 @@ describe('templateSrv', () => { describe('when the globbed variable only has one value', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'test', @@ -216,7 +218,7 @@ describe('templateSrv', () => { describe('variable with all option', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'test', @@ -254,7 +256,7 @@ describe('templateSrv', () => { describe('variable with all option and custom value', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'test', @@ -298,19 +300,19 @@ describe('templateSrv', () => { describe('lucene format', () => { it('should properly escape $test with lucene escape sequences', () => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'value/4' } }]); const target = _templateSrv.replace('this:$test', {}, 'lucene'); expect(target).toBe('this:value\\/4'); }); it('should properly escape ${test} with lucene escape sequences', () => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'value/4' } }]); const target = _templateSrv.replace('this:${test}', {}, 'lucene'); expect(target).toBe('this:value\\/4'); }); it('should properly escape ${test:lucene} with lucene escape sequences', () => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'value/4' } }]); const target = _templateSrv.replace('this:${test:lucene}', {}); expect(target).toBe('this:value\\/4'); }); @@ -318,7 +320,7 @@ describe('templateSrv', () => { describe('html format', () => { it('should encode values html escape sequences', () => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'test', current: { value: '' } }, ]); const target = _templateSrv.replace('$test', {}, 'html'); @@ -426,7 +428,7 @@ describe('templateSrv', () => { describe('can check if variable exists', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'oogle' } }]); }); it('should return true if $test exists', () => { @@ -467,7 +469,7 @@ describe('templateSrv', () => { describe('can highlight variables in string', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'oogle' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'oogle' } }]); }); it('should insert html', () => { @@ -488,7 +490,7 @@ describe('templateSrv', () => { describe('updateIndex with simple value', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'muuuu' } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: 'muuuu' } }]); }); it('should set current value and update template data', () => { @@ -499,7 +501,7 @@ describe('templateSrv', () => { describe('replaceWithText', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'server', @@ -544,7 +546,7 @@ describe('templateSrv', () => { describe('replaceWithText can pass all / multi value', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'server', @@ -595,7 +597,7 @@ describe('templateSrv', () => { describe('built in interval variables', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([]); + _templateSrv = initTemplateSrv(key, []); }); it('should replace $__interval_ms with interval milliseconds', () => { @@ -608,7 +610,7 @@ describe('templateSrv', () => { describe('date formating', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([], { + _templateSrv = initTemplateSrv(key, [], { from: dateTime(1594671549254), to: dateTime(1595237229747), } as TimeRange); @@ -642,7 +644,7 @@ describe('templateSrv', () => { describe('handle objects gracefully', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value: { test: 'A' } } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value: { test: 'A' } } }]); }); it('should not pass object to custom function', () => { @@ -658,7 +660,7 @@ describe('templateSrv', () => { describe('handle objects gracefully and call toString if defined', () => { beforeEach(() => { const value = { test: 'A', toString: () => 'hello' }; - _templateSrv = initTemplateSrv([{ type: 'query', name: 'test', current: { value } }]); + _templateSrv = initTemplateSrv(key, [{ type: 'query', name: 'test', current: { value } }]); }); it('should not pass object to custom function', () => { @@ -673,7 +675,7 @@ describe('templateSrv', () => { describe('adhoc variables', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'adhoc', name: 'adhoc', @@ -713,7 +715,7 @@ describe('templateSrv', () => { describe('queryparam', () => { beforeEach(() => { - _templateSrv = initTemplateSrv([ + _templateSrv = initTemplateSrv(key, [ { type: 'query', name: 'single', diff --git a/public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx b/public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx index 82944657f9e..2bb3a2d7289 100644 --- a/public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx +++ b/public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; - -import { AdHocVariableEditorUnConnected as AdHocVariableEditor } from './AdHocVariableEditor'; -import { initialAdHocVariableModelState } from './reducer'; import { selectOptionInTest } from '@grafana/ui'; import { getSelectParent } from '@grafana/ui/src/components/Select/test-utils'; +import { AdHocVariableEditorUnConnected as AdHocVariableEditor } from './AdHocVariableEditor'; +import { adHocBuilder } from '../shared/testing/builders'; + const props = { extended: { dataSources: [ @@ -13,7 +13,7 @@ const props = { { text: 'Loki', value: { type: 'loki-ds', uid: 'abc' } }, ], }, - variable: { ...initialAdHocVariableModelState }, + variable: adHocBuilder().withId('adhoc').withRootStateKey('key').withName('adhoc').build(), onPropChange: jest.fn(), // connected actions @@ -37,7 +37,10 @@ describe('AdHocVariableEditor', () => { render(); await selectOptionInTest(screen.getByLabelText('Data source'), 'Loki'); - expect(props.changeVariableDatasource).toBeCalledWith({ type: 'loki-ds', uid: 'abc' }); + expect(props.changeVariableDatasource).toBeCalledWith( + { type: 'adhoc', id: 'adhoc', rootStateKey: 'key' }, + { type: 'loki-ds', uid: 'abc' } + ); }); it('renders informational text', () => { diff --git a/public/app/features/variables/adhoc/AdHocVariableEditor.tsx b/public/app/features/variables/adhoc/AdHocVariableEditor.tsx index f00a074c21d..3e4ca258479 100644 --- a/public/app/features/variables/adhoc/AdHocVariableEditor.tsx +++ b/public/app/features/variables/adhoc/AdHocVariableEditor.tsx @@ -5,15 +5,31 @@ import { DataSourceRef, SelectableValue } from '@grafana/data'; import { AdHocVariableModel } from '../types'; import { VariableEditorProps } from '../editor/types'; +import { initialVariableEditorState } from '../editor/reducer'; import { changeVariableDatasource, initAdHocVariableEditor } from './actions'; import { StoreState } from 'app/types'; import { VariableSectionHeader } from '../editor/VariableSectionHeader'; import { VariableSelectField } from '../editor/VariableSelectField'; import { getAdhocVariableEditorState } from '../editor/selectors'; +import { getVariablesState } from '../state/selectors'; +import { toKeyedVariableIdentifier } from '../utils'; -const mapStateToProps = (state: StoreState) => ({ - extended: getAdhocVariableEditorState(state.templating.editor), -}); +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { + const { rootStateKey } = ownProps.variable; + + if (!rootStateKey) { + console.error('AdHocVariableEditor: variable has no rootStateKey'); + return { + extended: getAdhocVariableEditorState(initialVariableEditorState), + }; + } + + const { editor } = getVariablesState(rootStateKey, state); + + return { + extended: getAdhocVariableEditorState(editor), + }; +}; const mapDispatchToProps = { initAdHocVariableEditor, @@ -28,11 +44,17 @@ type Props = OwnProps & ConnectedProps; export class AdHocVariableEditorUnConnected extends PureComponent { componentDidMount() { - this.props.initAdHocVariableEditor(); + const { rootStateKey } = this.props.variable; + if (!rootStateKey) { + console.error('AdHocVariableEditor: variable has no rootStateKey'); + return; + } + + this.props.initAdHocVariableEditor(rootStateKey); } onDatasourceChanged = (option: SelectableValue) => { - this.props.changeVariableDatasource(option.value); + this.props.changeVariableDatasource(toKeyedVariableIdentifier(this.props.variable), option.value); }; render() { diff --git a/public/app/features/variables/adhoc/actions.test.ts b/public/app/features/variables/adhoc/actions.test.ts index 1a7d69c63c7..becf0bb4a76 100644 --- a/public/app/features/variables/adhoc/actions.test.ts +++ b/public/app/features/variables/adhoc/actions.test.ts @@ -3,8 +3,7 @@ import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data' import { variableAdapters } from '../adapters'; import { createAdHocVariableAdapter } from './adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { getRootReducer, RootReducerType } from '../state/helpers'; -import { toVariableIdentifier, toVariablePayload } from '../state/types'; +import { getPreloadedState, getRootReducer, RootReducerType } from '../state/helpers'; import { addFilter, AdHocTableOptions, @@ -21,6 +20,8 @@ import { VariableModel } from 'app/features/variables/types'; import { changeVariableEditorExtended, setIdInEditor } from '../editor/reducer'; import { adHocBuilder } from '../shared/testing/builders'; import { locationService } from '@grafana/runtime'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; const getList = jest.fn().mockReturnValue([]); const getDatasource = jest.fn().mockResolvedValue({}); @@ -55,6 +56,7 @@ const expectedDatasources = [ describe('adhoc actions', () => { describe('when applyFilterFromTable is dispatched and filter already exist', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const options: AdHocTableOptions = { datasource: { uid: 'influxdb' }, key: 'filter-key', @@ -71,12 +73,13 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('Filters') + .withRootStateKey(key) .withName('Filters') .withFilters([existingFilter]) .withDatasource(options.datasource) .build(); - const tester = await reduxTester() + const tester = await reduxTester({ preloadedState: getPreloadedState(key, {}) }) .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) .whenAsyncActionIsDispatched(applyFilterFromTable(options), true); @@ -84,7 +87,9 @@ describe('adhoc actions', () => { const expectedQuery = { 'var-Filters': ['filter-key|!=|filter-existing', 'filter-key|=|filter-value'] }; const expectedFilter = { key: 'filter-key', value: 'filter-value', operator: '=', condition: '' }; - tester.thenDispatchedActionsShouldEqual(filterAdded(toVariablePayload(variable, expectedFilter))); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, filterAdded(toVariablePayload(variable, expectedFilter))) + ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); @@ -92,6 +97,7 @@ describe('adhoc actions', () => { describe('when applyFilterFromTable is dispatched and previously no variable or filter exists', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const options: AdHocTableOptions = { datasource: { uid: 'influxdb' }, key: 'filter-key', @@ -99,18 +105,23 @@ describe('adhoc actions', () => { operator: '=', }; - const tester = await reduxTester() + const tester = await reduxTester({ preloadedState: getPreloadedState(key, {}) }) .givenRootReducer(getRootReducer()) .whenAsyncActionIsDispatched(applyFilterFromTable(options), true); - const variable = adHocBuilder().withId('Filters').withName('Filters').withDatasource(options.datasource).build(); + const variable = adHocBuilder() + .withId('Filters') + .withRootStateKey(key) + .withName('Filters') + .withDatasource(options.datasource) + .build(); const expectedQuery = { 'var-Filters': ['filter-key|=|filter-value'] }; const expectedFilter = { key: 'filter-key', value: 'filter-value', operator: '=', condition: '' }; tester.thenDispatchedActionsShouldEqual( createAddVariableAction(variable), - filterAdded(toVariablePayload(variable, expectedFilter)) + toKeyedAction(key, filterAdded(toVariablePayload(variable, expectedFilter))) ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); @@ -119,6 +130,7 @@ describe('adhoc actions', () => { describe('when applyFilterFromTable is dispatched and previously no filter exists', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const options: AdHocTableOptions = { datasource: { uid: 'influxdb' }, key: 'filter-key', @@ -128,12 +140,13 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('Filters') + .withRootStateKey(key) .withName('Filters') .withFilters([]) .withDatasource(options.datasource) .build(); - const tester = await reduxTester() + const tester = await reduxTester({ preloadedState: getPreloadedState(key, {}) }) .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) .whenAsyncActionIsDispatched(applyFilterFromTable(options), true); @@ -141,13 +154,16 @@ describe('adhoc actions', () => { const expectedFilter = { key: 'filter-key', value: 'filter-value', operator: '=', condition: '' }; const expectedQuery = { 'var-Filters': ['filter-key|=|filter-value'] }; - tester.thenDispatchedActionsShouldEqual(filterAdded(toVariablePayload(variable, expectedFilter))); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, filterAdded(toVariablePayload(variable, expectedFilter))) + ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when applyFilterFromTable is dispatched and adhoc variable with other datasource exists', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const options: AdHocTableOptions = { datasource: { uid: 'influxdb' }, key: 'filter-key', @@ -157,13 +173,19 @@ describe('adhoc actions', () => { const existing = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withDatasource({ uid: 'elasticsearch' }) .build(); - const variable = adHocBuilder().withId('Filters').withName('Filters').withDatasource(options.datasource).build(); + const variable = adHocBuilder() + .withId('Filters') + .withRootStateKey(key) + .withName('Filters') + .withDatasource(options.datasource) + .build(); - const tester = await reduxTester() + const tester = await reduxTester({ preloadedState: getPreloadedState(key, {}) }) .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(existing)) .whenAsyncActionIsDispatched(applyFilterFromTable(options), true); @@ -173,7 +195,7 @@ describe('adhoc actions', () => { tester.thenDispatchedActionsShouldEqual( createAddVariableAction(variable, 1), - filterAdded(toVariablePayload(variable, expectedFilter)) + toKeyedAction(key, filterAdded(toVariablePayload(variable, expectedFilter))) ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); @@ -182,6 +204,7 @@ describe('adhoc actions', () => { describe('when changeFilter is dispatched', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const existing = { key: 'key', value: 'value', @@ -196,6 +219,7 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([existing]) .withDatasource({ uid: 'elasticsearch' }) @@ -206,12 +230,14 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(changeFilter('elastic-filter', update), true); + .whenAsyncActionIsDispatched(changeFilter(toKeyedVariableIdentifier(variable), update), true); const expectedQuery = { 'var-elastic-filter': ['key|!=|value'] }; const expectedUpdate = { index: 0, filter: updated }; - tester.thenDispatchedActionsShouldEqual(filterUpdated(toVariablePayload(variable, expectedUpdate))); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, filterUpdated(toVariablePayload(variable, expectedUpdate))) + ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); @@ -219,6 +245,7 @@ describe('adhoc actions', () => { describe('when addFilter is dispatched on variable with existing filter', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const existing = { key: 'key', value: 'value', @@ -233,6 +260,7 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([existing]) .withDatasource({ uid: 'elasticsearch' }) @@ -241,18 +269,21 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(addFilter('elastic-filter', adding), true); + .whenAsyncActionIsDispatched(addFilter(toKeyedVariableIdentifier(variable), adding), true); const expectedQuery = { 'var-elastic-filter': ['key|=|value', 'key|!=|value'] }; const expectedFilter = { key: 'key', value: 'value', operator: '!=', condition: '' }; - tester.thenDispatchedActionsShouldEqual(filterAdded(toVariablePayload(variable, expectedFilter))); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, filterAdded(toVariablePayload(variable, expectedFilter))) + ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when addFilter is dispatched on variable with no existing filter', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const adding = { key: 'key', value: 'value', @@ -262,6 +293,7 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([]) .withDatasource({ uid: 'elasticsearch' }) @@ -270,19 +302,21 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(addFilter('elastic-filter', adding), true); + .whenAsyncActionIsDispatched(addFilter(toKeyedVariableIdentifier(variable), adding), true); const expectedQuery = { 'var-elastic-filter': ['key|=|value'] }; - tester.thenDispatchedActionsShouldEqual(filterAdded(toVariablePayload(variable, adding))); + tester.thenDispatchedActionsShouldEqual(toKeyedAction(key, filterAdded(toVariablePayload(variable, adding)))); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when removeFilter is dispatched on variable with no existing filter', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([]) .withDatasource({ uid: 'elasticsearch' }) @@ -291,17 +325,18 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(removeFilter('elastic-filter', 0), true); + .whenAsyncActionIsDispatched(removeFilter(toKeyedVariableIdentifier(variable), 0), true); const expectedQuery = { 'var-elastic-filter': [] as string[] }; - tester.thenDispatchedActionsShouldEqual(filterRemoved(toVariablePayload(variable, 0))); + tester.thenDispatchedActionsShouldEqual(toKeyedAction(key, filterRemoved(toVariablePayload(variable, 0)))); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when removeFilter is dispatched on variable with existing filter', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const filter = { key: 'key', value: 'value', @@ -311,6 +346,7 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([filter]) .withDatasource({ uid: 'elasticsearch' }) @@ -319,17 +355,18 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(removeFilter('elastic-filter', 0), true); + .whenAsyncActionIsDispatched(removeFilter(toKeyedVariableIdentifier(variable), 0), true); const expectedQuery = { 'var-elastic-filter': [] as string[] }; - tester.thenDispatchedActionsShouldEqual(filterRemoved(toVariablePayload(variable, 0))); + tester.thenDispatchedActionsShouldEqual(toKeyedAction(key, filterRemoved(toVariablePayload(variable, 0)))); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when setFiltersFromUrl is dispatched', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const existing = { key: 'key', value: 'value', @@ -339,6 +376,7 @@ describe('adhoc actions', () => { const variable = adHocBuilder() .withId('elastic-filter') + .withRootStateKey(key) .withName('elastic-filter') .withFilters([existing]) .withDatasource({ uid: 'elasticsearch' }) @@ -352,7 +390,7 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenAsyncActionIsDispatched(setFiltersFromUrl('elastic-filter', fromUrl), true); + .whenAsyncActionIsDispatched(setFiltersFromUrl(toKeyedVariableIdentifier(variable), fromUrl), true); const expectedQuery = { 'var-elastic-filter': ['key|=|value', 'key|=|value'] }; const expectedFilters = [ @@ -360,28 +398,40 @@ describe('adhoc actions', () => { { key: 'key', value: 'value', operator: '=', condition: '', name: 'value-2' }, ]; - tester.thenDispatchedActionsShouldEqual(filtersRestored(toVariablePayload(variable, expectedFilters))); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, filtersRestored(toVariablePayload(variable, expectedFilters))) + ); expect(locationService.partial).toHaveBeenLastCalledWith(expectedQuery); }); }); describe('when initAdHocVariableEditor is dispatched', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; + getList.mockRestore(); getList.mockReturnValue(datasources); const tester = reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(initAdHocVariableEditor()); + .whenActionIsDispatched(initAdHocVariableEditor(key)); - tester.thenDispatchedActionsShouldEqual(changeVariableEditorExtended({ dataSources: expectedDatasources })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, changeVariableEditorExtended({ dataSources: expectedDatasources })) + ); }); }); describe('when changeVariableDatasource is dispatched with unsupported datasource', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const datasource = { uid: 'mysql' }; - const variable = adHocBuilder().withId('Filters').withName('Filters').withDatasource({ uid: 'influxdb' }).build(); + const variable = adHocBuilder() + .withId('Filters') + .withRootStateKey(key) + .withName('Filters') + .withDatasource({ uid: 'influxdb' }) + .build(); getDatasource.mockRestore(); getDatasource.mockResolvedValue(null); @@ -391,25 +441,37 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenActionIsDispatched(setIdInEditor({ id: variable.id })) - .whenActionIsDispatched(initAdHocVariableEditor()) - .whenAsyncActionIsDispatched(changeVariableDatasource(datasource), true); + .whenActionIsDispatched(toKeyedAction(key, setIdInEditor({ id: variable.id }))) + .whenActionIsDispatched(initAdHocVariableEditor(key)) + .whenAsyncActionIsDispatched(changeVariableDatasource(toKeyedVariableIdentifier(variable), datasource), true); tester.thenDispatchedActionsShouldEqual( - changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource })), - changeVariableEditorExtended({ - infoText: 'This data source does not support ad hoc filters yet.', - dataSources: expectedDatasources, - }) + toKeyedAction( + key, + changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource })) + ), + toKeyedAction( + key, + changeVariableEditorExtended({ + infoText: 'This data source does not support ad hoc filters yet.', + dataSources: expectedDatasources, + }) + ) ); }); }); describe('when changeVariableDatasource is dispatched with datasource', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const datasource = { uid: 'elasticsearch' }; const loadingText = 'Ad hoc filters are applied automatically to all queries that target this data source'; - const variable = adHocBuilder().withId('Filters').withName('Filters').withDatasource({ uid: 'influxdb' }).build(); + const variable = adHocBuilder() + .withId('Filters') + .withRootStateKey(key) + .withName('Filters') + .withDatasource({ uid: 'influxdb' }) + .build(); getDatasource.mockRestore(); getDatasource.mockResolvedValue({ @@ -421,23 +483,26 @@ describe('adhoc actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched(createAddVariableAction(variable)) - .whenActionIsDispatched(setIdInEditor({ id: variable.id })) - .whenActionIsDispatched(initAdHocVariableEditor()) - .whenAsyncActionIsDispatched(changeVariableDatasource(datasource), true); + .whenActionIsDispatched(toKeyedAction(key, setIdInEditor({ id: variable.id }))) + .whenActionIsDispatched(initAdHocVariableEditor(key)) + .whenAsyncActionIsDispatched(changeVariableDatasource(toKeyedVariableIdentifier(variable), datasource), true); tester.thenDispatchedActionsShouldEqual( - changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource })), - changeVariableEditorExtended({ infoText: loadingText, dataSources: expectedDatasources }) + toKeyedAction( + key, + changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource })) + ), + toKeyedAction(key, changeVariableEditorExtended({ infoText: loadingText, dataSources: expectedDatasources })) ); }); }); }); function createAddVariableAction(variable: VariableModel, index = 0) { - const identifier = toVariableIdentifier(variable); + const identifier = toKeyedVariableIdentifier(variable); const global = false; const data = { global, index, model: { ...variable, index: -1, global } }; - return addVariable(toVariablePayload(identifier, data)); + return toKeyedAction(variable.rootStateKey!, addVariable(toVariablePayload(identifier, data))); } function createDatasource(name: string, selectable = true, isDefault = false): DataSourceInstanceSettings { diff --git a/public/app/features/variables/adhoc/actions.ts b/public/app/features/variables/adhoc/actions.ts index e004c66f86e..b49e398e524 100644 --- a/public/app/features/variables/adhoc/actions.ts +++ b/public/app/features/variables/adhoc/actions.ts @@ -3,8 +3,8 @@ import { StoreState, ThunkResult } from 'app/types'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { changeVariableEditorExtended } from '../editor/reducer'; import { addVariable, changeVariableProp } from '../state/sharedReducer'; -import { getNewVariableIndex, getVariable } from '../state/selectors'; -import { AddVariable, toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; +import { getLastKey, getNewVariableIndex, getVariable, getVariablesState } from '../state/selectors'; +import { AddVariable, KeyedVariableIdentifier } from '../state/types'; import { AdHocVariabelFilterUpdate, filterAdded, @@ -18,6 +18,8 @@ import { variableUpdated } from '../state/actions'; import { isAdHoc } from '../guard'; import { DataSourceRef, getDataSourceRef } from '@grafana/data'; import { getAdhocVariableEditorState } from '../editor/selectors'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; export interface AdHocTableOptions { datasource: DataSourceRef; @@ -35,6 +37,9 @@ export const applyFilterFromTable = (options: AdHocTableOptions): ThunkResult f.key === options.key && f.value === options.value); @@ -42,52 +47,66 @@ export const applyFilterFromTable = (options: AdHocTableOptions): ThunkResult => { +export const changeFilter = ( + identifier: KeyedVariableIdentifier, + update: AdHocVariabelFilterUpdate +): ThunkResult => { return async (dispatch, getState) => { - const variable = getVariable(id, getState()); - dispatch(filterUpdated(toVariablePayload(variable, update))); - await dispatch(variableUpdated(toVariableIdentifier(variable), true)); + const variable = getVariable(identifier, getState()); + dispatch(toKeyedAction(identifier.rootStateKey, filterUpdated(toVariablePayload(variable, update)))); + await dispatch(variableUpdated(toKeyedVariableIdentifier(variable), true)); }; }; -export const removeFilter = (id: string, index: number): ThunkResult => { +export const removeFilter = (identifier: KeyedVariableIdentifier, index: number): ThunkResult => { return async (dispatch, getState) => { - const variable = getVariable(id, getState()); - dispatch(filterRemoved(toVariablePayload(variable, index))); - await dispatch(variableUpdated(toVariableIdentifier(variable), true)); + const variable = getVariable(identifier, getState()); + dispatch(toKeyedAction(identifier.rootStateKey, filterRemoved(toVariablePayload(variable, index)))); + await dispatch(variableUpdated(toKeyedVariableIdentifier(variable), true)); }; }; -export const addFilter = (id: string, filter: AdHocVariableFilter): ThunkResult => { +export const addFilter = (identifier: KeyedVariableIdentifier, filter: AdHocVariableFilter): ThunkResult => { return async (dispatch, getState) => { - const variable = getVariable(id, getState()); - dispatch(filterAdded(toVariablePayload(variable, filter))); - await dispatch(variableUpdated(toVariableIdentifier(variable), true)); + const variable = getVariable(identifier, getState()); + dispatch(toKeyedAction(identifier.rootStateKey, filterAdded(toVariablePayload(variable, filter)))); + await dispatch(variableUpdated(toKeyedVariableIdentifier(variable), true)); }; }; -export const setFiltersFromUrl = (id: string, filters: AdHocVariableFilter[]): ThunkResult => { +export const setFiltersFromUrl = ( + identifier: KeyedVariableIdentifier, + filters: AdHocVariableFilter[] +): ThunkResult => { return async (dispatch, getState) => { - const variable = getVariable(id, getState()); - dispatch(filtersRestored(toVariablePayload(variable, filters))); - await dispatch(variableUpdated(toVariableIdentifier(variable), true)); + const variable = getVariable(identifier, getState()); + dispatch(toKeyedAction(identifier.rootStateKey, filtersRestored(toVariablePayload(variable, filters)))); + await dispatch(variableUpdated(toKeyedVariableIdentifier(variable), true)); }; }; -export const changeVariableDatasource = (datasource?: DataSourceRef): ThunkResult => { +export const changeVariableDatasource = ( + identifier: KeyedVariableIdentifier, + datasource?: DataSourceRef +): ThunkResult => { return async (dispatch, getState) => { - const { editor } = getState().templating; + const { editor } = getVariablesState(identifier.rootStateKey, getState()); const extended = getAdhocVariableEditorState(editor); - const variable = getVariable(editor.id, getState()); - dispatch(changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource }))); + const variable = getVariable(identifier, getState()); + dispatch( + toKeyedAction( + identifier.rootStateKey, + changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource })) + ) + ); const ds = await getDatasourceSrv().get(datasource); @@ -97,57 +116,70 @@ export const changeVariableDatasource = (datasource?: DataSourceRef): ThunkResul : 'This data source does not support ad hoc filters yet.'; dispatch( - changeVariableEditorExtended({ - infoText: message, - dataSources: extended?.dataSources ?? [], - }) + toKeyedAction( + identifier.rootStateKey, + changeVariableEditorExtended({ + infoText: message, + dataSources: extended?.dataSources ?? [], + }) + ) ); }; }; -export const initAdHocVariableEditor = (): ThunkResult => (dispatch) => { - const dataSources = getDatasourceSrv().getList({ metrics: true, variables: true }); - const selectable = dataSources.reduce( - (all: Array<{ text: string; value: DataSourceRef | null }>, ds) => { - if (ds.meta.mixed) { +export const initAdHocVariableEditor = + (key: string): ThunkResult => + (dispatch) => { + const dataSources = getDatasourceSrv().getList({ metrics: true, variables: true }); + const selectable = dataSources.reduce( + (all: Array<{ text: string; value: DataSourceRef | null }>, ds) => { + if (ds.meta.mixed) { + return all; + } + + const text = ds.isDefault ? `${ds.name} (default)` : ds.name; + const value = getDataSourceRef(ds); + all.push({ text, value }); + return all; - } + }, + [{ text: '', value: {} }] + ); - const text = ds.isDefault ? `${ds.name} (default)` : ds.name; - const value = getDataSourceRef(ds); - all.push({ text, value }); - - return all; - }, - [{ text: '', value: {} }] - ); - - dispatch( - changeVariableEditorExtended({ - dataSources: selectable, - }) - ); -}; + dispatch( + toKeyedAction( + key, + changeVariableEditorExtended({ + dataSources: selectable, + }) + ) + ); + }; const createAdHocVariable = (options: AdHocTableOptions): ThunkResult => { return (dispatch, getState) => { - const model = { + const key = getLastKey(getState()); + + const model: AdHocVariableModel = { ...cloneDeep(initialAdHocVariableModelState), datasource: options.datasource, name: filterTableName, id: filterTableName, + rootStateKey: key, }; const global = false; - const index = getNewVariableIndex(getState()); - const identifier: VariableIdentifier = { type: 'adhoc', id: model.id }; + const index = getNewVariableIndex(key, getState()); + const identifier: KeyedVariableIdentifier = { type: 'adhoc', id: model.id, rootStateKey: key }; - dispatch(addVariable(toVariablePayload(identifier, { global, model, index }))); + dispatch(toKeyedAction(key, addVariable(toVariablePayload(identifier, { global, model, index })))); }; }; -const getVariableByOptions = (options: AdHocTableOptions, state: StoreState): AdHocVariableModel => { - return Object.values(state.templating.variables).find( +const getVariableByOptions = (options: AdHocTableOptions, state: StoreState): AdHocVariableModel | undefined => { + const key = getLastKey(state); + const templatingState = getVariablesState(key, state); + return Object.values(templatingState.variables).find( (v) => isAdHoc(v) && v.datasource?.uid === options.datasource.uid ) as AdHocVariableModel; }; diff --git a/public/app/features/variables/adhoc/adapter.ts b/public/app/features/variables/adhoc/adapter.ts index 10adfce70b7..6f2ed40e074 100644 --- a/public/app/features/variables/adhoc/adapter.ts +++ b/public/app/features/variables/adhoc/adapter.ts @@ -8,6 +8,7 @@ import { adHocVariableReducer, initialAdHocVariableModelState } from './reducer' import { AdHocVariableEditor } from './AdHocVariableEditor'; import { setFiltersFromUrl } from './actions'; import * as urlParser from './urlParser'; +import { toKeyedVariableIdentifier } from '../utils'; const noop = async () => {}; @@ -24,11 +25,11 @@ export const createAdHocVariableAdapter = (): VariableAdapter { const filters = urlParser.toFilters(urlValue); - await dispatch(setFiltersFromUrl(variable.id, filters)); + await dispatch(setFiltersFromUrl(toKeyedVariableIdentifier(variable), filters)); }, updateOptions: noop, getSaveModel: (variable) => { - const { index, id, state, global, ...rest } = cloneDeep(variable); + const { index, id, state, global, rootStateKey, ...rest } = cloneDeep(variable); return rest; }, getValueForUrl: (variable) => { diff --git a/public/app/features/variables/adhoc/picker/AdHocPicker.tsx b/public/app/features/variables/adhoc/picker/AdHocPicker.tsx index 8abb7a6fbf3..1fa6e55e1ce 100644 --- a/public/app/features/variables/adhoc/picker/AdHocPicker.tsx +++ b/public/app/features/variables/adhoc/picker/AdHocPicker.tsx @@ -4,6 +4,7 @@ import { AdHocVariableFilter, AdHocVariableModel } from 'app/features/variables/ import { VariablePickerProps } from '../../pickers/types'; import { addFilter, changeFilter, removeFilter } from '../actions'; import { AdHocFilter } from './AdHocFilter'; +import { toKeyedVariableIdentifier } from '../../utils'; const mapDispatchToProps = { addFilter, @@ -23,15 +24,15 @@ type Props = OwnProps & ConnectedProps; */ export class AdHocPickerUnconnected extends PureComponent { addFilter = (filter: AdHocVariableFilter) => { - this.props.addFilter(this.props.variable.id, filter); + this.props.addFilter(toKeyedVariableIdentifier(this.props.variable), filter); }; removeFilter = (index: number) => { - this.props.removeFilter(this.props.variable.id, index); + this.props.removeFilter(toKeyedVariableIdentifier(this.props.variable), index); }; changeFilter = (index: number, filter: AdHocVariableFilter) => { - this.props.changeFilter(this.props.variable.id, { + this.props.changeFilter(toKeyedVariableIdentifier(this.props.variable), { index, filter, }); diff --git a/public/app/features/variables/adhoc/reducer.test.ts b/public/app/features/variables/adhoc/reducer.test.ts index b3645e38b1a..ce4b7948cd4 100644 --- a/public/app/features/variables/adhoc/reducer.test.ts +++ b/public/app/features/variables/adhoc/reducer.test.ts @@ -1,10 +1,11 @@ import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { cloneDeep } from 'lodash'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { adHocVariableReducer, filterAdded, filterRemoved, filtersRestored, filterUpdated } from './reducer'; import { AdHocVariableFilter, AdHocVariableModel } from '../types'; import { createAdHocVariableAdapter } from './adapter'; +import { toVariablePayload } from '../utils'; describe('adHocVariableReducer', () => { const adapter = createAdHocVariableAdapter(); diff --git a/public/app/features/variables/adhoc/reducer.ts b/public/app/features/variables/adhoc/reducer.ts index 3f5aa9f9222..f5ff0945961 100644 --- a/public/app/features/variables/adhoc/reducer.ts +++ b/public/app/features/variables/adhoc/reducer.ts @@ -1,7 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { AdHocVariableFilter, AdHocVariableModel, initialVariableModelState } from 'app/features/variables/types'; -import { getInstanceState, initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { getInstanceState } from '../state/selectors'; export interface AdHocVariabelFilterUpdate { index: number; diff --git a/public/app/features/variables/constant/actions.test.ts b/public/app/features/variables/constant/actions.test.ts index b483c8ad547..a609cdc69ce 100644 --- a/public/app/features/variables/constant/actions.test.ts +++ b/public/app/features/variables/constant/actions.test.ts @@ -4,9 +4,10 @@ import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { updateConstantVariableOptions } from './actions'; import { getRootReducer, RootReducerType } from '../state/helpers'; import { ConstantVariableModel, initialVariableModelState, VariableOption } from '../types'; -import { toVariablePayload } from '../state/types'; import { createConstantOptionsFromQuery } from './reducer'; import { addVariable, setCurrentVariableValue } from '../state/sharedReducer'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; describe('constant actions', () => { variableAdapters.setInit(() => [createConstantVariableAdapter()]); @@ -22,6 +23,7 @@ describe('constant actions', () => { const variable: ConstantVariableModel = { ...initialVariableModelState, id: '0', + rootStateKey: 'key', index: 0, type: 'constant', name: 'Constant', @@ -36,12 +38,14 @@ describe('constant actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenAsyncActionIsDispatched(updateConstantVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenAsyncActionIsDispatched(updateConstantVariableOptions(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - createConstantOptionsFromQuery(toVariablePayload(variable)), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', createConstantOptionsFromQuery(toVariablePayload(variable))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); }); }); diff --git a/public/app/features/variables/constant/actions.ts b/public/app/features/variables/constant/actions.ts index db27b06739a..6f865bcaa19 100644 --- a/public/app/features/variables/constant/actions.ts +++ b/public/app/features/variables/constant/actions.ts @@ -1,11 +1,14 @@ import { validateVariableSelectionState } from '../state/actions'; import { ThunkResult } from 'app/types'; import { createConstantOptionsFromQuery } from './reducer'; -import { toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; -export const updateConstantVariableOptions = (identifier: VariableIdentifier): ThunkResult => { +export const updateConstantVariableOptions = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch) => { - await dispatch(createConstantOptionsFromQuery(toVariablePayload(identifier))); + const { rootStateKey } = identifier; + await dispatch(toKeyedAction(rootStateKey, createConstantOptionsFromQuery(toVariablePayload(identifier)))); await dispatch(validateVariableSelectionState(identifier)); }; }; diff --git a/public/app/features/variables/constant/adapter.ts b/public/app/features/variables/constant/adapter.ts index 12aa0e85073..4555a63c788 100644 --- a/public/app/features/variables/constant/adapter.ts +++ b/public/app/features/variables/constant/adapter.ts @@ -6,8 +6,8 @@ import { VariableAdapter } from '../adapters'; import { constantVariableReducer, initialConstantVariableModelState } from './reducer'; import { ConstantVariableEditor } from './ConstantVariableEditor'; import { updateConstantVariableOptions } from './actions'; -import { toVariableIdentifier } from '../state/types'; import { optionPickerFactory } from '../pickers'; +import { toKeyedVariableIdentifier } from '../utils'; export const createConstantVariableAdapter = (): VariableAdapter => { return { @@ -22,16 +22,16 @@ export const createConstantVariableAdapter = (): VariableAdapter { - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable) => { - await dispatch(updateConstantVariableOptions(toVariableIdentifier(variable))); + await dispatch(updateConstantVariableOptions(toKeyedVariableIdentifier(variable))); }, getSaveModel: (variable) => { - const { index, id, state, global, current, options, ...rest } = cloneDeep(variable); + const { index, id, state, global, current, options, rootStateKey, ...rest } = cloneDeep(variable); return rest; }, getValueForUrl: (variable) => { diff --git a/public/app/features/variables/constant/reducer.test.ts b/public/app/features/variables/constant/reducer.test.ts index b6c937c52ea..932593582b2 100644 --- a/public/app/features/variables/constant/reducer.test.ts +++ b/public/app/features/variables/constant/reducer.test.ts @@ -1,10 +1,12 @@ -import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { cloneDeep } from 'lodash'; + +import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { constantVariableReducer, createConstantOptionsFromQuery } from './reducer'; import { ConstantVariableModel } from '../types'; import { createConstantVariableAdapter } from './adapter'; +import { toVariablePayload } from '../utils'; describe('constantVariableReducer', () => { const adapter = createConstantVariableAdapter(); diff --git a/public/app/features/variables/constant/reducer.ts b/public/app/features/variables/constant/reducer.ts index bf2f2d38be7..8a830d779d1 100644 --- a/public/app/features/variables/constant/reducer.ts +++ b/public/app/features/variables/constant/reducer.ts @@ -1,6 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + import { ConstantVariableModel, initialVariableModelState, VariableHide, VariableOption } from '../types'; -import { getInstanceState, VariablePayload, initialVariablesState, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { getInstanceState } from '../state/selectors'; export const initialConstantVariableModelState: ConstantVariableModel = { ...initialVariableModelState, diff --git a/public/app/features/variables/custom/actions.test.ts b/public/app/features/variables/custom/actions.test.ts index c6508944b0d..9df0916264d 100644 --- a/public/app/features/variables/custom/actions.test.ts +++ b/public/app/features/variables/custom/actions.test.ts @@ -4,9 +4,10 @@ import { createCustomVariableAdapter } from './adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { getRootReducer, RootReducerType } from '../state/helpers'; import { CustomVariableModel, initialVariableModelState, VariableOption } from '../types'; -import { toVariablePayload } from '../state/types'; import { addVariable, setCurrentVariableValue } from '../state/sharedReducer'; import { createCustomOptionsFromQuery } from './reducer'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; describe('custom actions', () => { variableAdapters.setInit(() => [createCustomVariableAdapter()]); @@ -22,6 +23,7 @@ describe('custom actions', () => { const variable: CustomVariableModel = { ...initialVariableModelState, id: '0', + rootStateKey: 'key', index: 0, type: 'custom', name: 'Custom', @@ -49,12 +51,14 @@ describe('custom actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenAsyncActionIsDispatched(updateCustomVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenAsyncActionIsDispatched(updateCustomVariableOptions(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - createCustomOptionsFromQuery(toVariablePayload(variable)), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', createCustomOptionsFromQuery(toVariablePayload(variable))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); }); }); diff --git a/public/app/features/variables/custom/actions.ts b/public/app/features/variables/custom/actions.ts index bc3ed3905a8..557d2044dca 100644 --- a/public/app/features/variables/custom/actions.ts +++ b/public/app/features/variables/custom/actions.ts @@ -1,11 +1,14 @@ import { validateVariableSelectionState } from '../state/actions'; import { ThunkResult } from 'app/types'; import { createCustomOptionsFromQuery } from './reducer'; -import { toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; -export const updateCustomVariableOptions = (identifier: VariableIdentifier): ThunkResult => { +export const updateCustomVariableOptions = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch) => { - await dispatch(createCustomOptionsFromQuery(toVariablePayload(identifier))); + const { rootStateKey } = identifier; + await dispatch(toKeyedAction(rootStateKey, createCustomOptionsFromQuery(toVariablePayload(identifier)))); await dispatch(validateVariableSelectionState(identifier)); }; }; diff --git a/public/app/features/variables/custom/adapter.ts b/public/app/features/variables/custom/adapter.ts index b8aed7172cc..47af02927bc 100644 --- a/public/app/features/variables/custom/adapter.ts +++ b/public/app/features/variables/custom/adapter.ts @@ -6,8 +6,7 @@ import { VariableAdapter } from '../adapters'; import { customVariableReducer, initialCustomVariableModelState } from './reducer'; import { CustomVariableEditor } from './CustomVariableEditor'; import { updateCustomVariableOptions } from './actions'; -import { toVariableIdentifier } from '../state/types'; -import { isAllVariable } from '../utils'; +import { isAllVariable, toKeyedVariableIdentifier } from '../utils'; import { optionPickerFactory } from '../pickers'; import { ALL_VARIABLE_TEXT } from '../constants'; @@ -24,16 +23,16 @@ export const createCustomVariableAdapter = (): VariableAdapter { - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable) => { - await dispatch(updateCustomVariableOptions(toVariableIdentifier(variable))); + await dispatch(updateCustomVariableOptions(toKeyedVariableIdentifier(variable))); }, getSaveModel: (variable) => { - const { index, id, state, global, ...rest } = cloneDeep(variable); + const { index, id, state, global, rootStateKey, ...rest } = cloneDeep(variable); return rest; }, getValueForUrl: (variable) => { diff --git a/public/app/features/variables/custom/reducer.test.ts b/public/app/features/variables/custom/reducer.test.ts index 0cf66830900..39f0ef09815 100644 --- a/public/app/features/variables/custom/reducer.test.ts +++ b/public/app/features/variables/custom/reducer.test.ts @@ -1,11 +1,13 @@ -import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { cloneDeep } from 'lodash'; + +import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { createCustomOptionsFromQuery, customVariableReducer } from './reducer'; import { createCustomVariableAdapter } from './adapter'; import { CustomVariableModel } from '../types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { toVariablePayload } from '../utils'; describe('customVariableReducer', () => { const adapter = createCustomVariableAdapter(); diff --git a/public/app/features/variables/custom/reducer.ts b/public/app/features/variables/custom/reducer.ts index a579f412f96..9a897030c30 100644 --- a/public/app/features/variables/custom/reducer.ts +++ b/public/app/features/variables/custom/reducer.ts @@ -1,8 +1,9 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { CustomVariableModel, initialVariableModelState, VariableOption } from '../types'; -import { getInstanceState, VariablePayload, initialVariablesState, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { getInstanceState } from '../state/selectors'; export const initialCustomVariableModelState: CustomVariableModel = { ...initialVariableModelState, diff --git a/public/app/features/variables/datasource/DataSourceVariableEditor.tsx b/public/app/features/variables/datasource/DataSourceVariableEditor.tsx index 0be82c77056..51f9a827f71 100644 --- a/public/app/features/variables/datasource/DataSourceVariableEditor.tsx +++ b/public/app/features/variables/datasource/DataSourceVariableEditor.tsx @@ -1,23 +1,38 @@ import React, { FormEvent, PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; +import { SelectableValue } from '@grafana/data'; import { InlineFieldRow, VerticalGroup } from '@grafana/ui'; import { DataSourceVariableModel, VariableWithMultiSupport } from '../types'; import { OnPropChangeArguments, VariableEditorProps } from '../editor/types'; import { SelectionOptionsEditor } from '../editor/SelectionOptionsEditor'; +import { initialVariableEditorState } from '../editor/reducer'; import { initDataSourceVariableEditor } from './actions'; import { StoreState } from '../../../types'; import { changeVariableMultiValue } from '../state/actions'; import { VariableSectionHeader } from '../editor/VariableSectionHeader'; import { VariableSelectField } from '../editor/VariableSelectField'; -import { SelectableValue } from '@grafana/data'; import { VariableTextField } from '../editor/VariableTextField'; +import { getVariablesState } from '../state/selectors'; import { selectors } from '@grafana/e2e-selectors'; import { getDatasourceVariableEditorState } from '../editor/selectors'; -const mapStateToProps = (state: StoreState) => ({ - extended: getDatasourceVariableEditorState(state.templating.editor), -}); +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { + const { + variable: { rootStateKey }, + } = ownProps; + if (!rootStateKey) { + console.error('DataSourceVariableEditor: variable has no rootStateKey'); + return { + extended: getDatasourceVariableEditorState(initialVariableEditorState), + }; + } + + const { editor } = getVariablesState(rootStateKey, state); + return { + extended: getDatasourceVariableEditorState(editor), + }; +}; const mapDispatchToProps = { initDataSourceVariableEditor, @@ -32,7 +47,13 @@ type Props = OwnProps & ConnectedProps; export class DataSourceVariableEditorUnConnected extends PureComponent { componentDidMount() { - this.props.initDataSourceVariableEditor(); + const { rootStateKey } = this.props.variable; + if (!rootStateKey) { + console.error('DataSourceVariableEditor: variable has no rootStateKey'); + return; + } + + this.props.initDataSourceVariableEditor(rootStateKey); } onRegExChange = (event: FormEvent) => { diff --git a/public/app/features/variables/datasource/actions.test.ts b/public/app/features/variables/datasource/actions.test.ts index d8226da37ef..67f62788eae 100644 --- a/public/app/features/variables/datasource/actions.test.ts +++ b/public/app/features/variables/datasource/actions.test.ts @@ -2,7 +2,6 @@ import { DataSourceInstanceSettings } from '@grafana/data'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { getRootReducer, RootReducerType } from '../state/helpers'; -import { toVariableIdentifier, toVariablePayload } from '../state/types'; import { variableAdapters } from '../adapters'; import { createDataSourceVariableAdapter } from './adapter'; import { @@ -16,6 +15,8 @@ import { addVariable, setCurrentVariableValue } from '../state/sharedReducer'; import { changeVariableEditorExtended } from '../editor/reducer'; import { datasourceBuilder } from '../shared/testing/builders'; import { getDataSourceInstanceSetting } from '../shared/testing/helpers'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; interface Args { sources?: DataSourceInstanceSettings[]; @@ -27,7 +28,7 @@ function getTestContext({ sources = [], query, regex }: Args = {}) { const getListMock = jest.fn().mockReturnValue(sources); const getDatasourceSrvMock = jest.fn().mockReturnValue({ getList: getListMock }); const dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrvMock }; - const datasource = datasourceBuilder().withId('0').withQuery(query).withRegEx(regex).build(); + const datasource = datasourceBuilder().withId('0').withRootStateKey('key').withQuery(query).withRegEx(regex).build(); return { getListMock, getDatasourceSrvMock, dependencies, datasource }; } @@ -51,27 +52,36 @@ describe('data source actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(datasource, { global: false, index: 0, model: datasource })) + toKeyedAction( + 'key', + addVariable(toVariablePayload(datasource, { global: false, index: 0, model: datasource })) + ) ) .whenAsyncActionIsDispatched( - updateDataSourceVariableOptions(toVariableIdentifier(datasource), dependencies), + updateDataSourceVariableOptions(toKeyedVariableIdentifier(datasource), dependencies), true ); tester.thenDispatchedActionsShouldEqual( - createDataSourceOptions( - toVariablePayload( - { type: 'datasource', id: '0' }, - { - sources, - regex: undefined as unknown as RegExp, - } + toKeyedAction( + 'key', + createDataSourceOptions( + toVariablePayload( + { type: 'datasource', id: '0' }, + { + sources, + regex: undefined as unknown as RegExp, + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'datasource', id: '0' }, - { option: { text: 'first-name', value: 'first-name', selected: false } } + toKeyedAction( + 'key', + setCurrentVariableValue( + toVariablePayload( + { type: 'datasource', id: '0' }, + { option: { text: 'first-name', value: 'first-name', selected: false } } + ) ) ) ); @@ -99,27 +109,36 @@ describe('data source actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(datasource, { global: false, index: 0, model: datasource })) + toKeyedAction( + 'key', + addVariable(toVariablePayload(datasource, { global: false, index: 0, model: datasource })) + ) ) .whenAsyncActionIsDispatched( - updateDataSourceVariableOptions(toVariableIdentifier(datasource), dependencies), + updateDataSourceVariableOptions(toKeyedVariableIdentifier(datasource), dependencies), true ); tester.thenDispatchedActionsShouldEqual( - createDataSourceOptions( - toVariablePayload( - { type: 'datasource', id: '0' }, - { - sources, - regex: /.*(second-name).*/, - } + toKeyedAction( + 'key', + createDataSourceOptions( + toVariablePayload( + { type: 'datasource', id: '0' }, + { + sources, + regex: /.*(second-name).*/, + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'datasource', id: '0' }, - { option: { text: 'second-name', value: 'second-name', selected: false } } + toKeyedAction( + 'key', + setCurrentVariableValue( + toVariablePayload( + { type: 'datasource', id: '0' }, + { option: { text: 'second-name', value: 'second-name', selected: false } } + ) ) ) ); @@ -143,14 +162,17 @@ describe('data source actions', () => { reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(initDataSourceVariableEditor(dependencies)) + .whenActionIsDispatched(initDataSourceVariableEditor('key', dependencies)) .thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ - dataSourceTypes: [ - { text: '', value: '' }, - { text: 'mock-data-name', value: 'mock-data-id' }, - ], - }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ + dataSourceTypes: [ + { text: '', value: '' }, + { text: 'mock-data-name', value: 'mock-data-id' }, + ], + }) + ) ); expect(getListMock).toHaveBeenCalledTimes(1); diff --git a/public/app/features/variables/datasource/actions.ts b/public/app/features/variables/datasource/actions.ts index f7a9b031828..dc7b34cdd75 100644 --- a/public/app/features/variables/datasource/actions.ts +++ b/public/app/features/variables/datasource/actions.ts @@ -2,7 +2,7 @@ import { chain } from 'lodash'; import { getTemplateSrv } from '@grafana/runtime'; import { stringToJsRegex } from '@grafana/data'; -import { toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { ThunkResult } from '../../../types'; import { createDataSourceOptions } from './reducer'; import { validateVariableSelectionState } from '../state/actions'; @@ -10,6 +10,8 @@ import { getDatasourceSrv } from '../../plugins/datasource_srv'; import { getVariable } from '../state/selectors'; import { DataSourceVariableModel } from '../types'; import { changeVariableEditorExtended } from '../editor/reducer'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; export interface DataSourceVariableActionDependencies { getDatasourceSrv: typeof getDatasourceSrv; @@ -17,12 +19,13 @@ export interface DataSourceVariableActionDependencies { export const updateDataSourceVariableOptions = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrv } ): ThunkResult => async (dispatch, getState) => { + const { rootStateKey } = identifier; const sources = dependencies.getDatasourceSrv().getList({ metrics: true, variables: false }); - const variableInState = getVariable(identifier.id, getState()); + const variableInState = getVariable(identifier, getState()); let regex; if (variableInState.regex) { @@ -30,12 +33,15 @@ export const updateDataSourceVariableOptions = regex = stringToJsRegex(regex); } - dispatch(createDataSourceOptions(toVariablePayload(identifier, { sources, regex }))); + dispatch(toKeyedAction(rootStateKey, createDataSourceOptions(toVariablePayload(identifier, { sources, regex })))); await dispatch(validateVariableSelectionState(identifier)); }; export const initDataSourceVariableEditor = - (dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrv }): ThunkResult => + ( + key: string, + dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrv } + ): ThunkResult => (dispatch) => { const dataSources = dependencies.getDatasourceSrv().getList({ metrics: true, variables: true }); const dataSourceTypes = chain(dataSources) @@ -47,5 +53,5 @@ export const initDataSourceVariableEditor = dataSourceTypes.unshift({ text: '', value: '' }); - dispatch(changeVariableEditorExtended({ dataSourceTypes })); + dispatch(toKeyedAction(key, changeVariableEditorExtended({ dataSourceTypes }))); }; diff --git a/public/app/features/variables/datasource/adapter.ts b/public/app/features/variables/datasource/adapter.ts index a3c8171558a..9b4da36805a 100644 --- a/public/app/features/variables/datasource/adapter.ts +++ b/public/app/features/variables/datasource/adapter.ts @@ -4,10 +4,9 @@ import { dispatch } from '../../../store/store'; import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions'; import { VariableAdapter } from '../adapters'; import { dataSourceVariableReducer, initialDataSourceVariableModelState } from './reducer'; -import { toVariableIdentifier } from '../state/types'; import { DataSourceVariableEditor } from './DataSourceVariableEditor'; import { updateDataSourceVariableOptions } from './actions'; -import { containsVariable, isAllVariable } from '../utils'; +import { containsVariable, isAllVariable, toKeyedVariableIdentifier } from '../utils'; import { optionPickerFactory } from '../pickers'; import { ALL_VARIABLE_TEXT } from '../constants'; @@ -27,16 +26,16 @@ export const createDataSourceVariableAdapter = (): VariableAdapter { - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable) => { - await dispatch(updateDataSourceVariableOptions(toVariableIdentifier(variable))); + await dispatch(updateDataSourceVariableOptions(toKeyedVariableIdentifier(variable))); }, getSaveModel: (variable) => { - const { index, id, state, global, ...rest } = cloneDeep(variable); + const { index, id, state, global, rootStateKey, ...rest } = cloneDeep(variable); return { ...rest, options: [] }; }, getValueForUrl: (variable) => { diff --git a/public/app/features/variables/datasource/reducer.test.ts b/public/app/features/variables/datasource/reducer.test.ts index 7fc17be7d41..dce1a1ed93f 100644 --- a/public/app/features/variables/datasource/reducer.test.ts +++ b/public/app/features/variables/datasource/reducer.test.ts @@ -1,14 +1,15 @@ +import { cloneDeep } from 'lodash'; import { DataSourceInstanceSettings } from '@grafana/data'; import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { createDataSourceOptions, dataSourceVariableReducer } from './reducer'; import { DataSourceVariableModel } from '../types'; import { getVariableTestContext } from '../state/helpers'; -import { cloneDeep } from 'lodash'; import { createDataSourceVariableAdapter } from './adapter'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { getMockPlugins } from '../../plugins/__mocks__/pluginMocks'; import { getDataSourceInstanceSetting } from '../shared/testing/helpers'; +import { toVariablePayload } from '../utils'; describe('dataSourceVariableReducer', () => { const adapter = createDataSourceVariableAdapter(); diff --git a/public/app/features/variables/datasource/reducer.ts b/public/app/features/variables/datasource/reducer.ts index 1a6cb0619d3..3e2370dd495 100644 --- a/public/app/features/variables/datasource/reducer.ts +++ b/public/app/features/variables/datasource/reducer.ts @@ -2,8 +2,9 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { DataSourceInstanceSettings } from '@grafana/data'; import { DataSourceVariableModel, initialVariableModelState, VariableOption, VariableRefresh } from '../types'; -import { getInstanceState, initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { getInstanceState } from '../state/selectors'; export const initialDataSourceVariableModelState: DataSourceVariableModel = { ...initialVariableModelState, diff --git a/public/app/features/variables/editor/SelectionOptionsEditor.tsx b/public/app/features/variables/editor/SelectionOptionsEditor.tsx index 00ff36f0966..d1e2688cc40 100644 --- a/public/app/features/variables/editor/SelectionOptionsEditor.tsx +++ b/public/app/features/variables/editor/SelectionOptionsEditor.tsx @@ -4,14 +4,15 @@ import { selectors } from '@grafana/e2e-selectors'; import { VariableWithMultiSupport } from '../types'; import { VariableEditorProps } from './types'; -import { toVariableIdentifier, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { VariableSectionHeader } from './VariableSectionHeader'; import { VariableSwitchField } from './VariableSwitchField'; import { VariableTextField } from './VariableTextField'; +import { toKeyedVariableIdentifier } from '../utils'; export interface SelectionOptionsEditorProps extends VariableEditorProps { - onMultiChanged: (identifier: VariableIdentifier, value: boolean) => void; + onMultiChanged: (identifier: KeyedVariableIdentifier, value: boolean) => void; } export const SelectionOptionsEditor: FunctionComponent = ({ @@ -21,7 +22,7 @@ export const SelectionOptionsEditor: FunctionComponent { const onMultiChanged = useCallback( (event: ChangeEvent) => { - onMultiChangedProps(toVariableIdentifier(variable), event.target.checked); + onMultiChangedProps(toKeyedVariableIdentifier(variable), event.target.checked); }, [onMultiChangedProps, variable] ); diff --git a/public/app/features/variables/editor/VariableEditorContainer.tsx b/public/app/features/variables/editor/VariableEditorContainer.tsx index 6a82dd09b4a..745e368a37c 100644 --- a/public/app/features/variables/editor/VariableEditorContainer.tsx +++ b/public/app/features/variables/editor/VariableEditorContainer.tsx @@ -1,36 +1,61 @@ import React, { MouseEvent, PureComponent } from 'react'; +import { bindActionCreators } from 'redux'; +import { connect, ConnectedProps } from 'react-redux'; import { Icon, LinkButton } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; -import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; -import { StoreState } from '../../../types'; +import { KeyedVariableIdentifier } from '../state/types'; +import { StoreState, ThunkDispatch } from '../../../types'; import { VariableEditorEditor } from './VariableEditorEditor'; -import { connect, ConnectedProps } from 'react-redux'; -import { getEditorVariables } from '../state/selectors'; +import { getEditorVariables, getVariablesState } from '../state/selectors'; import { switchToEditMode, switchToListMode, switchToNewMode } from './actions'; import { changeVariableOrder, duplicateVariable, removeVariable } from '../state/sharedReducer'; import { VariableEditorList } from './VariableEditorList'; import { VariablesUnknownTable } from '../inspect/VariablesUnknownTable'; import { VariablesDependenciesButton } from '../inspect/VariablesDependenciesButton'; +import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; -const mapStateToProps = (state: StoreState) => ({ - variables: getEditorVariables(state), - idInEditor: state.templating.editor.id, - dashboard: state.dashboard.getModel(), - usagesNetwork: state.templating.inspect.usagesNetwork, - usages: state.templating.inspect.usages, -}); - -const mapDispatchToProps = { - changeVariableOrder, - duplicateVariable, - removeVariable, - switchToNewMode, - switchToEditMode, - switchToListMode, +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { + const { uid } = ownProps.dashboard; + const templatingState = getVariablesState(uid, state); + return { + variables: getEditorVariables(uid, state), + idInEditor: templatingState.editor.id, + usagesNetwork: templatingState.inspect.usagesNetwork, + usages: templatingState.inspect.usages, + }; }; -interface OwnProps {} +const mapDispatchToProps = (dispatch: ThunkDispatch) => { + return { + ...bindActionCreators({ switchToNewMode, switchToEditMode, switchToListMode }, dispatch), + changeVariableOrder: (identifier: KeyedVariableIdentifier, fromIndex: number, toIndex: number) => + dispatch( + toKeyedAction( + identifier.rootStateKey, + changeVariableOrder(toVariablePayload(identifier, { fromIndex, toIndex })) + ) + ), + duplicateVariable: (identifier: KeyedVariableIdentifier) => + dispatch( + toKeyedAction( + identifier.rootStateKey, + duplicateVariable(toVariablePayload(identifier, { newId: undefined as unknown as string })) + ) + ), + removeVariable: (identifier: KeyedVariableIdentifier) => { + dispatch( + toKeyedAction(identifier.rootStateKey, removeVariable(toVariablePayload(identifier, { reIndex: true }))) + ); + }, + }; +}; + +interface OwnProps { + dashboard: DashboardModel; +} const connector = connect(mapStateToProps, mapDispatchToProps); @@ -38,32 +63,32 @@ type Props = OwnProps & ConnectedProps; class VariableEditorContainerUnconnected extends PureComponent { componentDidMount(): void { - this.props.switchToListMode(); + this.props.switchToListMode(this.props.dashboard.uid); } onChangeToListMode = (event: MouseEvent) => { event.preventDefault(); - this.props.switchToListMode(); + this.props.switchToListMode(this.props.dashboard.uid); }; - onEditVariable = (identifier: VariableIdentifier) => { + onEditVariable = (identifier: KeyedVariableIdentifier) => { this.props.switchToEditMode(identifier); }; onNewVariable = () => { - this.props.switchToNewMode(); + this.props.switchToNewMode(this.props.dashboard.uid); }; - onChangeVariableOrder = (identifier: VariableIdentifier, fromIndex: number, toIndex: number) => { - this.props.changeVariableOrder(toVariablePayload(identifier, { fromIndex, toIndex })); + onChangeVariableOrder = (identifier: KeyedVariableIdentifier, fromIndex: number, toIndex: number) => { + this.props.changeVariableOrder(identifier, fromIndex, toIndex); }; - onDuplicateVariable = (identifier: VariableIdentifier) => { - this.props.duplicateVariable(toVariablePayload(identifier, { newId: undefined as unknown as string })); + onDuplicateVariable = (identifier: KeyedVariableIdentifier) => { + this.props.duplicateVariable(identifier); }; - onRemoveVariable = (identifier: VariableIdentifier) => { - this.props.removeVariable(toVariablePayload(identifier, { reIndex: true })); + onRemoveVariable = (identifier: KeyedVariableIdentifier) => { + this.props.removeVariable(identifier); }; render() { @@ -117,7 +142,7 @@ class VariableEditorContainerUnconnected extends PureComponent { {!variableToEdit && this.props.variables.length > 0 && ( )} - {variableToEdit && } + {variableToEdit && }
); } diff --git a/public/app/features/variables/editor/VariableEditorEditor.tsx b/public/app/features/variables/editor/VariableEditorEditor.tsx index a1e6455f1e3..58441e2eff1 100644 --- a/public/app/features/variables/editor/VariableEditorEditor.tsx +++ b/public/app/features/variables/editor/VariableEditorEditor.tsx @@ -1,20 +1,17 @@ import React, { FormEvent, PureComponent } from 'react'; +import { bindActionCreators } from 'redux'; +import { connect, ConnectedProps } from 'react-redux'; import { isEqual } from 'lodash'; import { AppEvents, LoadingState, SelectableValue, VariableType } from '@grafana/data'; import { Button, Icon, InlineFieldRow, VerticalGroup } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; import { variableAdapters } from '../adapters'; -import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; -import { VariableHide, VariableModel } from '../types'; +import { KeyedVariableIdentifier } from '../state/types'; +import { VariableHide } from '../types'; import { appEvents } from '../../../core/core'; import { VariableValuesPreview } from './VariableValuesPreview'; import { changeVariableName, onEditorUpdate, variableEditorMount, variableEditorUnMount } from './actions'; -import { MapDispatchToProps, MapStateToProps } from 'react-redux'; -import { StoreState } from '../../../types'; -import { VariableEditorState } from './reducer'; -import { getVariable } from '../state/selectors'; -import { connectWithStore } from '../../../core/utils/connectWithReduxStore'; import { OnPropChangeArguments } from './types'; import { changeVariableProp, changeVariableType } from '../state/sharedReducer'; import { updateOptions } from '../state/actions'; @@ -23,27 +20,41 @@ import { VariableSectionHeader } from './VariableSectionHeader'; import { hasOptions } from '../guard'; import { VariableTypeSelect } from './VariableTypeSelect'; import { VariableHideSelect } from './VariableHideSelect'; +import { getVariable, getVariablesState } from '../state/selectors'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { StoreState, ThunkDispatch } from '../../../types'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; + +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => ({ + editor: getVariablesState(ownProps.identifier.rootStateKey, state).editor, + variable: getVariable(ownProps.identifier, state, false), // we could be renaming a variable and we don't want this to throw +}); + +const mapDispatchToProps = (dispatch: ThunkDispatch) => { + return { + ...bindActionCreators( + { variableEditorMount, variableEditorUnMount, changeVariableName, onEditorUpdate, updateOptions }, + dispatch + ), + changeVariableProp: (identifier: KeyedVariableIdentifier, propName: string, propValue: any) => + dispatch( + toKeyedAction( + identifier.rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName, propValue })) + ) + ), + changeVariableType: (identifier: KeyedVariableIdentifier, newType: VariableType) => + dispatch(toKeyedAction(identifier.rootStateKey, changeVariableType(toVariablePayload(identifier, { newType })))), + }; +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); export interface OwnProps { - identifier: VariableIdentifier; + identifier: KeyedVariableIdentifier; } -interface ConnectedProps { - editor: VariableEditorState; - variable: VariableModel; -} - -interface DispatchProps { - variableEditorMount: typeof variableEditorMount; - variableEditorUnMount: typeof variableEditorUnMount; - changeVariableName: typeof changeVariableName; - changeVariableProp: typeof changeVariableProp; - onEditorUpdate: typeof onEditorUpdate; - changeVariableType: typeof changeVariableType; - updateOptions: typeof updateOptions; -} - -type Props = OwnProps & ConnectedProps & DispatchProps; +type Props = OwnProps & ConnectedProps; export class VariableEditorEditorUnConnected extends PureComponent { componentDidMount(): void { @@ -71,35 +82,26 @@ export class VariableEditorEditorUnConnected extends PureComponent { if (!option.value) { return; } - this.props.changeVariableType(toVariablePayload(this.props.identifier, { newType: option.value })); + this.props.changeVariableType(this.props.identifier, option.value); }; onLabelChange = (event: FormEvent) => { event.preventDefault(); - this.props.changeVariableProp( - toVariablePayload(this.props.identifier, { propName: 'label', propValue: event.currentTarget.value }) - ); + this.props.changeVariableProp(this.props.identifier, 'label', event.currentTarget.value); }; onDescriptionChange = (event: FormEvent) => { - this.props.changeVariableProp( - toVariablePayload(this.props.identifier, { propName: 'description', propValue: event.currentTarget.value }) - ); + this.props.changeVariableProp(this.props.identifier, 'description', event.currentTarget.value); }; onHideChange = (option: SelectableValue) => { - this.props.changeVariableProp( - toVariablePayload(this.props.identifier, { - propName: 'hide', - propValue: option.value, - }) - ); + this.props.changeVariableProp(this.props.identifier, 'hide', option.value); }; onPropChanged = async ({ propName, propValue, updateOptions = false }: OnPropChangeArguments) => { - this.props.changeVariableProp(toVariablePayload(this.props.identifier, { propName, propValue })); + this.props.changeVariableProp(this.props.identifier, propName, propValue); if (updateOptions) { - await this.props.updateOptions(toVariableIdentifier(this.props.variable)); + await this.props.updateOptions(toKeyedVariableIdentifier(this.props.variable)); } }; @@ -191,23 +193,4 @@ export class VariableEditorEditorUnConnected extends PureComponent { } } -const mapStateToProps: MapStateToProps = (state, ownProps) => ({ - editor: state.templating.editor, - variable: getVariable(ownProps.identifier.id, state, false), // we could be renaming a variable and we don't want this to throw -}); - -const mapDispatchToProps: MapDispatchToProps = { - variableEditorMount, - variableEditorUnMount, - changeVariableName, - changeVariableProp, - onEditorUpdate, - changeVariableType, - updateOptions, -}; - -export const VariableEditorEditor = connectWithStore( - VariableEditorEditorUnConnected, - mapStateToProps, - mapDispatchToProps -); +export const VariableEditorEditor = connector(VariableEditorEditorUnConnected); diff --git a/public/app/features/variables/editor/VariableEditorList.tsx b/public/app/features/variables/editor/VariableEditorList.tsx index b6f27b4fe9c..c3ac0e585cd 100644 --- a/public/app/features/variables/editor/VariableEditorList.tsx +++ b/public/app/features/variables/editor/VariableEditorList.tsx @@ -4,7 +4,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { reportInteraction } from '@grafana/runtime'; import { VariableModel } from '../types'; -import { VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { UsagesToNetwork, VariableUsageTree } from '../inspect/utils'; import { VariableEditorListRow } from './VariableEditorListRow'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; @@ -14,10 +14,10 @@ export interface Props { usages: VariableUsageTree[]; usagesNetwork: UsagesToNetwork[]; onAdd: () => void; - onEdit: (identifier: VariableIdentifier) => void; - onChangeOrder: (identifier: VariableIdentifier, fromIndex: number, toIndex: number) => void; - onDuplicate: (identifier: VariableIdentifier) => void; - onDelete: (identifier: VariableIdentifier) => void; + onEdit: (identifier: KeyedVariableIdentifier) => void; + onChangeOrder: (identifier: KeyedVariableIdentifier, fromIndex: number, toIndex: number) => void; + onDuplicate: (identifier: KeyedVariableIdentifier) => void; + onDelete: (identifier: KeyedVariableIdentifier) => void; } export function VariableEditorList({ diff --git a/public/app/features/variables/editor/VariableEditorListRow.tsx b/public/app/features/variables/editor/VariableEditorListRow.tsx index 6d117fdcd1a..68b589cbdd9 100644 --- a/public/app/features/variables/editor/VariableEditorListRow.tsx +++ b/public/app/features/variables/editor/VariableEditorListRow.tsx @@ -8,18 +8,19 @@ import { reportInteraction } from '@grafana/runtime'; import { getVariableUsages, UsagesToNetwork, VariableUsageTree } from '../inspect/utils'; import { hasOptions, isAdHoc, isQuery } from '../guard'; -import { toVariableIdentifier, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { VariableUsagesButton } from '../inspect/VariableUsagesButton'; import { VariableModel } from '../types'; +import { toKeyedVariableIdentifier } from '../utils'; export interface VariableEditorListRowProps { index: number; variable: VariableModel; usageTree: VariableUsageTree[]; usagesNetwork: UsagesToNetwork[]; - onEdit: (identifier: VariableIdentifier) => void; - onDuplicate: (identifier: VariableIdentifier) => void; - onDelete: (identifier: VariableIdentifier) => void; + onEdit: (identifier: KeyedVariableIdentifier) => void; + onDuplicate: (identifier: KeyedVariableIdentifier) => void; + onDelete: (identifier: KeyedVariableIdentifier) => void; } export function VariableEditorListRow({ @@ -36,7 +37,7 @@ export function VariableEditorListRow({ const definition = getDefinition(variable); const usages = getVariableUsages(variable.id, usageTree); const passed = usages > 0 || isAdHoc(variable); - const identifier = toVariableIdentifier(variable); + const identifier = toKeyedVariableIdentifier(variable); return ( diff --git a/public/app/features/variables/editor/actions.ts b/public/app/features/variables/editor/actions.ts index 1aa5af6f93e..55d9b12c2a4 100644 --- a/public/app/features/variables/editor/actions.ts +++ b/public/app/features/variables/editor/actions.ts @@ -1,5 +1,5 @@ import { ThunkResult } from '../../../types'; -import { getEditorVariables, getNewVariableIndex, getVariable, getVariables } from '../state/selectors'; +import { getEditorVariables, getNewVariableIndex, getVariable, getVariablesByKey } from '../state/selectors'; import { changeVariableNameFailed, changeVariableNameSucceeded, @@ -9,7 +9,7 @@ import { variableEditorUnMounted, } from './reducer'; import { variableAdapters } from '../adapters'; -import { AddVariable, toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; +import { AddVariable, KeyedVariableIdentifier, VariableIdentifier } from '../state/types'; import { cloneDeep } from 'lodash'; import { VariableType } from '@grafana/data'; import { addVariable, removeVariable } from '../state/sharedReducer'; @@ -17,28 +17,33 @@ import { updateOptions } from '../state/actions'; import { VariableModel } from '../types'; import { initInspect } from '../inspect/reducer'; import { createUsagesNetwork, transformUsagesToNetwork } from '../inspect/utils'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; -export const variableEditorMount = (identifier: VariableIdentifier): ThunkResult => { +export const variableEditorMount = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch) => { - dispatch(variableEditorMounted({ name: getVariable(identifier.id).name })); + const { rootStateKey } = identifier; + dispatch(toKeyedAction(rootStateKey, variableEditorMounted({ name: getVariable(identifier).name }))); }; }; -export const variableEditorUnMount = (identifier: VariableIdentifier): ThunkResult => { +export const variableEditorUnMount = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch, getState) => { - dispatch(variableEditorUnMounted(toVariablePayload(identifier))); + const { rootStateKey } = identifier; + dispatch(toKeyedAction(rootStateKey, variableEditorUnMounted(toVariablePayload(identifier)))); }; }; -export const onEditorUpdate = (identifier: VariableIdentifier): ThunkResult => { +export const onEditorUpdate = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch) => { await dispatch(updateOptions(identifier)); - dispatch(switchToListMode()); + dispatch(switchToListMode(identifier.rootStateKey)); }; }; -export const changeVariableName = (identifier: VariableIdentifier, newName: string): ThunkResult => { +export const changeVariableName = (identifier: KeyedVariableIdentifier, newName: string): ThunkResult => { return (dispatch, getState) => { + const { id, rootStateKey: uid } = identifier; let errorText = null; if (!newName.match(/^(?!__).*$/)) { errorText = "Template names cannot begin with '__', that's reserved for Grafana's global variables"; @@ -48,15 +53,15 @@ export const changeVariableName = (identifier: VariableIdentifier, newName: stri errorText = 'Only word and digit characters are allowed in variable names'; } - const variables = getVariables(getState()); - const foundVariables = variables.filter((v) => v.name === newName && v.id !== identifier.id); + const variables = getVariablesByKey(uid, getState()); + const foundVariables = variables.filter((v) => v.name === newName && v.id !== id); if (foundVariables.length) { errorText = 'Variable with the same name already exists'; } if (errorText) { - dispatch(changeVariableNameFailed({ newName, errorText })); + dispatch(toKeyedAction(uid, changeVariableNameFailed({ newName, errorText }))); return; } @@ -65,54 +70,61 @@ export const changeVariableName = (identifier: VariableIdentifier, newName: stri }; export const completeChangeVariableName = - (identifier: VariableIdentifier, newName: string): ThunkResult => + (identifier: KeyedVariableIdentifier, newName: string): ThunkResult => (dispatch, getState) => { - const originalVariable = getVariable(identifier.id, getState()); + const { rootStateKey } = identifier; + const originalVariable = getVariable(identifier, getState()); if (originalVariable.name === newName) { - dispatch(changeVariableNameSucceeded(toVariablePayload(identifier, { newName }))); + dispatch(toKeyedAction(rootStateKey, changeVariableNameSucceeded(toVariablePayload(identifier, { newName })))); return; } const model = { ...cloneDeep(originalVariable), name: newName, id: newName }; const global = originalVariable.global; const index = originalVariable.index; - const renamedIdentifier = toVariableIdentifier(model); + const renamedIdentifier = toKeyedVariableIdentifier(model); - dispatch(addVariable(toVariablePayload(renamedIdentifier, { global, index, model }))); - dispatch(changeVariableNameSucceeded(toVariablePayload(renamedIdentifier, { newName }))); + dispatch(toKeyedAction(rootStateKey, addVariable(toVariablePayload(renamedIdentifier, { global, index, model })))); + dispatch( + toKeyedAction(rootStateKey, changeVariableNameSucceeded(toVariablePayload(renamedIdentifier, { newName }))) + ); dispatch(switchToEditMode(renamedIdentifier)); - dispatch(removeVariable(toVariablePayload(identifier, { reIndex: false }))); + dispatch(toKeyedAction(rootStateKey, removeVariable(toVariablePayload(identifier, { reIndex: false })))); }; export const switchToNewMode = - (type: VariableType = 'query'): ThunkResult => + (key: string, type: VariableType = 'query'): ThunkResult => (dispatch, getState) => { - const id = getNextAvailableId(type, getVariables(getState())); - const identifier = { type, id }; + const id = getNextAvailableId(type, getVariablesByKey(key, getState())); + const identifier: VariableIdentifier = { type, id }; const global = false; - const index = getNewVariableIndex(getState()); - const model = cloneDeep(variableAdapters.get(type).initialState); + const index = getNewVariableIndex(key, getState()); + const model: VariableModel = cloneDeep(variableAdapters.get(type).initialState); model.id = id; model.name = id; - dispatch(addVariable(toVariablePayload(identifier, { global, model, index }))); - dispatch(setIdInEditor({ id: identifier.id })); + model.rootStateKey = key; + dispatch(toKeyedAction(key, addVariable(toVariablePayload(identifier, { global, model, index })))); + dispatch(toKeyedAction(key, setIdInEditor({ id: identifier.id }))); }; export const switchToEditMode = - (identifier: VariableIdentifier): ThunkResult => + (identifier: KeyedVariableIdentifier): ThunkResult => (dispatch) => { - dispatch(setIdInEditor({ id: identifier.id })); + const { rootStateKey } = identifier; + dispatch(toKeyedAction(rootStateKey, setIdInEditor({ id: identifier.id }))); }; -export const switchToListMode = (): ThunkResult => (dispatch, getState) => { - dispatch(clearIdInEditor()); - const state = getState(); - const variables = getEditorVariables(state); - const dashboard = state.dashboard.getModel(); - const { usages } = createUsagesNetwork(variables, dashboard); - const usagesNetwork = transformUsagesToNetwork(usages); +export const switchToListMode = + (key: string): ThunkResult => + (dispatch, getState) => { + dispatch(toKeyedAction(key, clearIdInEditor())); + const state = getState(); + const variables = getEditorVariables(key, state); + const dashboard = state.dashboard.getModel(); + const { usages } = createUsagesNetwork(variables, dashboard); + const usagesNetwork = transformUsagesToNetwork(usages); - dispatch(initInspect({ usages, usagesNetwork })); -}; + dispatch(toKeyedAction(key, initInspect({ usages, usagesNetwork }))); + }; export function getNextAvailableId(type: VariableType, variables: VariableModel[]): string { let counter = 0; diff --git a/public/app/features/variables/editor/reducer.test.ts b/public/app/features/variables/editor/reducer.test.ts index 1fa8002a659..e1ed467bb3f 100644 --- a/public/app/features/variables/editor/reducer.test.ts +++ b/public/app/features/variables/editor/reducer.test.ts @@ -14,7 +14,7 @@ import { VariableEditorState, variableEditorUnMounted, } from './reducer'; -import { toVariablePayload } from '../state/types'; +import { toVariablePayload } from '../utils'; describe('variableEditorReducer', () => { describe('when setIdInEditor is dispatched', () => { diff --git a/public/app/features/variables/getAllVariableValuesForUrl.test.ts b/public/app/features/variables/getAllVariableValuesForUrl.test.ts index 7eb44ac7be0..dbd04260212 100644 --- a/public/app/features/variables/getAllVariableValuesForUrl.test.ts +++ b/public/app/features/variables/getAllVariableValuesForUrl.test.ts @@ -4,6 +4,8 @@ import { createQueryVariableAdapter } from './query/adapter'; import { getVariablesUrlParams } from './getAllVariableValuesForUrl'; import { initTemplateSrv } from '../../../test/helpers/initTemplateSrv'; +const key = 'key'; + describe('getAllVariableValuesForUrl', () => { beforeAll(() => { variableAdapters.register(createQueryVariableAdapter()); @@ -12,10 +14,11 @@ describe('getAllVariableValuesForUrl', () => { describe('with multi value', () => { beforeEach(() => { setTemplateSrv( - initTemplateSrv([ + initTemplateSrv(key, [ { type: 'query', name: 'test', + rootStateKey: key, current: { value: ['val1', 'val2'] }, getValueForUrl: function () { return this.current.value; @@ -34,9 +37,10 @@ describe('getAllVariableValuesForUrl', () => { describe('skip url sync', () => { beforeEach(() => { setTemplateSrv( - initTemplateSrv([ + initTemplateSrv(key, [ { name: 'test', + rootStateKey: key, skipUrlSync: true, current: { value: 'value' }, getValueForUrl: function () { @@ -56,10 +60,11 @@ describe('getAllVariableValuesForUrl', () => { describe('with multi value with skip url sync', () => { beforeEach(() => { setTemplateSrv( - initTemplateSrv([ + initTemplateSrv(key, [ { type: 'query', name: 'test', + rootStateKey: key, skipUrlSync: true, current: { value: ['val1', 'val2'] }, getValueForUrl: function () { @@ -78,7 +83,9 @@ describe('getAllVariableValuesForUrl', () => { describe('fillVariableValuesForUrl with multi value and scopedVars', () => { beforeEach(() => { - setTemplateSrv(initTemplateSrv([{ type: 'query', name: 'test', current: { value: ['val1', 'val2'] } }])); + setTemplateSrv( + initTemplateSrv(key, [{ type: 'query', name: 'test', rootStateKey: key, current: { value: ['val1', 'val2'] } }]) + ); }); it('should set scoped value as url params', () => { @@ -91,7 +98,9 @@ describe('getAllVariableValuesForUrl', () => { describe('fillVariableValuesForUrl with multi value, scopedVars and skip url sync', () => { beforeEach(() => { - setTemplateSrv(initTemplateSrv([{ type: 'query', name: 'test', current: { value: ['val1', 'val2'] } }])); + setTemplateSrv( + initTemplateSrv(key, [{ type: 'query', name: 'test', rootStateKey: key, current: { value: ['val1', 'val2'] } }]) + ); }); it('should not set scoped value as url params', () => { diff --git a/public/app/features/variables/getAllVariableValuesForUrl.ts b/public/app/features/variables/getAllVariableValuesForUrl.ts index 4cfbfd55d33..e775394f287 100644 --- a/public/app/features/variables/getAllVariableValuesForUrl.ts +++ b/public/app/features/variables/getAllVariableValuesForUrl.ts @@ -6,7 +6,6 @@ export function getVariablesUrlParams(scopedVars?: ScopedVars): UrlQueryMap { const params: UrlQueryMap = {}; const variables = getTemplateSrv().getVariables(); - // console.log(variables) for (let i = 0; i < variables.length; i++) { const variable = variables[i]; if (scopedVars && scopedVars[variable.name] !== void 0) { diff --git a/public/app/features/variables/interval/actions.test.ts b/public/app/features/variables/interval/actions.test.ts index 8e320fa6b18..007caacc7a7 100644 --- a/public/app/features/variables/interval/actions.test.ts +++ b/public/app/features/variables/interval/actions.test.ts @@ -1,6 +1,5 @@ import { getRootReducer, RootReducerType } from '../state/helpers'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { toVariableIdentifier, toVariablePayload } from '../state/types'; import { updateAutoValue, UpdateAutoValueDependencies, updateIntervalVariableOptions } from './actions'; import { createIntervalOptions } from './reducer'; import { @@ -20,25 +19,37 @@ import { notifyApp } from '../../../core/actions'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; import { variablesInitTransaction } from '../state/transactionReducer'; import { afterEach, beforeEach } from '../../../../test/lib/common'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; describe('interval actions', () => { variableAdapters.setInit(() => [createIntervalVariableAdapter()]); describe('when updateIntervalVariableOptions is dispatched', () => { it('then correct actions are dispatched', async () => { - const interval = intervalBuilder().withId('0').withQuery('1s,1m,1h,1d').withAuto(false).build(); + const interval = intervalBuilder() + .withId('0') + .withRootStateKey('key') + .withQuery('1s,1m,1h,1d') + .withAuto(false) + .build(); const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) - .whenAsyncActionIsDispatched(updateIntervalVariableOptions(toVariableIdentifier(interval)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) + ) + .whenAsyncActionIsDispatched(updateIntervalVariableOptions(toKeyedVariableIdentifier(interval)), true); tester.thenDispatchedActionsShouldEqual( - createIntervalOptions({ type: 'interval', id: '0', data: undefined }), - setCurrentVariableValue({ - type: 'interval', - id: '0', - data: { option: { text: '1s', value: '1s', selected: false } }, - }) + toKeyedAction('key', createIntervalOptions({ type: 'interval', id: '0', data: undefined })), + toKeyedAction( + 'key', + setCurrentVariableValue({ + type: 'interval', + id: '0', + data: { option: { text: '1s', value: '1s', selected: false } }, + }) + ) ); }); }); @@ -67,6 +78,7 @@ describe('interval actions', () => { it('then an notifyApp action should be dispatched', async () => { const interval = intervalBuilder() .withId('0') + .withRootStateKey('key') .withQuery('1s,1m,1h,1d') .withAuto(true) .withAutoMin('1xyz') // illegal interval string @@ -74,21 +86,26 @@ describe('interval actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(updateOptions(toVariableIdentifier(interval)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(updateOptions(toKeyedVariableIdentifier(interval)), true); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { const expectedNumberOfActions = 4; - expect(dispatchedActions[0]).toEqual(variableStateFetching(toVariablePayload(interval))); - expect(dispatchedActions[1]).toEqual(createIntervalOptions(toVariablePayload(interval))); + expect(dispatchedActions[0]).toEqual(toKeyedAction('key', variableStateFetching(toVariablePayload(interval)))); + expect(dispatchedActions[1]).toEqual(toKeyedAction('key', createIntervalOptions(toVariablePayload(interval)))); expect(dispatchedActions[2]).toEqual( - variableStateFailed( - toVariablePayload(interval, { - error: new Error( - 'Invalid interval string, has to be either unit-less or end with one of the following units: "y, M, w, d, h, m, s, ms"' - ), - }) + toKeyedAction( + 'key', + variableStateFailed( + toVariablePayload(interval, { + error: new Error( + 'Invalid interval string, has to be either unit-less or end with one of the following units: "y, M, w, d, h, m, s, ms"' + ), + }) + ) ) ); @@ -107,6 +124,7 @@ describe('interval actions', () => { it('then no actions are dispatched', async () => { const interval = intervalBuilder() .withId('0') + .withRootStateKey('key') .withQuery('1s,1m,1h,1d') .withAuto(true) .withAutoMin('1xyz') // illegal interval string @@ -115,9 +133,9 @@ describe('interval actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval })) + toKeyedAction('key', addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) ) - .whenAsyncActionIsDispatched(updateOptions(toVariableIdentifier(interval)), true); + .whenAsyncActionIsDispatched(updateOptions(toKeyedVariableIdentifier(interval)), true); tester.thenNoActionsWhereDispatched(); }); @@ -127,7 +145,7 @@ describe('interval actions', () => { describe('when updateAutoValue is dispatched', () => { describe('and auto is false', () => { it('then no dependencies are called', async () => { - const interval = intervalBuilder().withId('0').withAuto(false).build(); + const interval = intervalBuilder().withId('0').withRootStateKey('key').withAuto(false).build(); const dependencies: UpdateAutoValueDependencies = { calculateInterval: jest.fn(), @@ -151,9 +169,9 @@ describe('interval actions', () => { await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval })) + toKeyedAction('key', addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) ) - .whenAsyncActionIsDispatched(updateAutoValue(toVariableIdentifier(interval), dependencies), true); + .whenAsyncActionIsDispatched(updateAutoValue(toKeyedVariableIdentifier(interval), dependencies), true); expect(dependencies.calculateInterval).toHaveBeenCalledTimes(0); expect(dependencies.getTimeSrv().timeRange).toHaveBeenCalledTimes(0); @@ -165,6 +183,7 @@ describe('interval actions', () => { it('then correct dependencies are called', async () => { const interval = intervalBuilder() .withId('0') + .withRootStateKey('key') .withName('intervalName') .withAuto(true) .withAutoCount(33) @@ -195,9 +214,9 @@ describe('interval actions', () => { await reduxTester() .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval })) + toKeyedAction('key', addVariable(toVariablePayload(interval, { global: false, index: 0, model: interval }))) ) - .whenAsyncActionIsDispatched(updateAutoValue(toVariableIdentifier(interval), dependencies), true); + .whenAsyncActionIsDispatched(updateAutoValue(toKeyedVariableIdentifier(interval), dependencies), true); expect(dependencies.calculateInterval).toHaveBeenCalledTimes(1); expect(dependencies.calculateInterval).toHaveBeenCalledWith( diff --git a/public/app/features/variables/interval/actions.ts b/public/app/features/variables/interval/actions.ts index c75a724e8dd..bc3f4d8d521 100644 --- a/public/app/features/variables/interval/actions.ts +++ b/public/app/features/variables/interval/actions.ts @@ -1,6 +1,6 @@ import { rangeUtil } from '@grafana/data'; -import { toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { ThunkResult } from '../../../types'; import { createIntervalOptions } from './reducer'; import { validateVariableSelectionState } from '../state/actions'; @@ -8,11 +8,14 @@ import { getVariable } from '../state/selectors'; import { IntervalVariableModel } from '../types'; import { getTimeSrv } from '../../dashboard/services/TimeSrv'; import { getTemplateSrv, TemplateSrv } from '../../templating/template_srv'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; export const updateIntervalVariableOptions = - (identifier: VariableIdentifier): ThunkResult => + (identifier: KeyedVariableIdentifier): ThunkResult => async (dispatch) => { - await dispatch(createIntervalOptions(toVariablePayload(identifier))); + const { rootStateKey } = identifier; + await dispatch(toKeyedAction(rootStateKey, createIntervalOptions(toVariablePayload(identifier)))); await dispatch(updateAutoValue(identifier)); await dispatch(validateVariableSelectionState(identifier)); }; @@ -25,7 +28,7 @@ export interface UpdateAutoValueDependencies { export const updateAutoValue = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, dependencies: UpdateAutoValueDependencies = { calculateInterval: rangeUtil.calculateInterval, getTimeSrv: getTimeSrv, @@ -33,7 +36,7 @@ export const updateAutoValue = } ): ThunkResult => (dispatch, getState) => { - const variableInState = getVariable(identifier.id, getState()); + const variableInState = getVariable(identifier, getState()); if (variableInState.auto) { const res = dependencies.calculateInterval( dependencies.getTimeSrv().timeRange(), diff --git a/public/app/features/variables/interval/adapter.ts b/public/app/features/variables/interval/adapter.ts index 36402c2e866..69e523836a6 100644 --- a/public/app/features/variables/interval/adapter.ts +++ b/public/app/features/variables/interval/adapter.ts @@ -4,10 +4,10 @@ import { dispatch } from '../../../store/store'; import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions'; import { VariableAdapter } from '../adapters'; import { initialIntervalVariableModelState, intervalVariableReducer } from './reducer'; -import { toVariableIdentifier } from '../state/types'; import { IntervalVariableEditor } from './IntervalVariableEditor'; import { updateAutoValue, updateIntervalVariableOptions } from './actions'; import { optionPickerFactory } from '../pickers'; +import { toKeyedVariableIdentifier } from '../utils'; export const createIntervalVariableAdapter = (): VariableAdapter => { return { @@ -22,18 +22,18 @@ export const createIntervalVariableAdapter = (): VariableAdapter { - await dispatch(updateAutoValue(toVariableIdentifier(variable))); - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(updateAutoValue(toKeyedVariableIdentifier(variable))); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(updateAutoValue(toVariableIdentifier(variable))); - await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(updateAutoValue(toKeyedVariableIdentifier(variable))); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable) => { - await dispatch(updateIntervalVariableOptions(toVariableIdentifier(variable))); + await dispatch(updateIntervalVariableOptions(toKeyedVariableIdentifier(variable))); }, getSaveModel: (variable) => { - const { index, id, state, global, ...rest } = cloneDeep(variable); + const { index, id, state, global, rootStateKey, ...rest } = cloneDeep(variable); return rest; }, getValueForUrl: (variable) => { diff --git a/public/app/features/variables/interval/reducer.test.ts b/public/app/features/variables/interval/reducer.test.ts index 3faf98a3281..e5b04df6ae8 100644 --- a/public/app/features/variables/interval/reducer.test.ts +++ b/public/app/features/variables/interval/reducer.test.ts @@ -1,11 +1,12 @@ import { cloneDeep } from 'lodash'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { createIntervalVariableAdapter } from './adapter'; import { IntervalVariableModel } from '../types'; import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { createIntervalOptions, intervalVariableReducer } from './reducer'; +import { toVariablePayload } from '../utils'; describe('intervalVariableReducer', () => { const adapter = createIntervalVariableAdapter(); diff --git a/public/app/features/variables/interval/reducer.ts b/public/app/features/variables/interval/reducer.ts index 09e65357a8a..faadce99a6a 100644 --- a/public/app/features/variables/interval/reducer.ts +++ b/public/app/features/variables/interval/reducer.ts @@ -1,7 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { initialVariableModelState, IntervalVariableModel, VariableOption, VariableRefresh } from '../types'; -import { getInstanceState, VariablePayload, initialVariablesState, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; import { map } from 'lodash'; +import { getInstanceState } from '../state/selectors'; export const initialIntervalVariableModelState: IntervalVariableModel = { ...initialVariableModelState, diff --git a/public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx b/public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx index 075908f023d..36a1a50e17e 100644 --- a/public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx +++ b/public/app/features/variables/pickers/OptionsPicker/OptionPicker.test.tsx @@ -9,7 +9,8 @@ import { VariablePickerProps } from '../types'; import { QueryVariableModel, VariableWithMultiSupport, VariableWithOptions } from '../../types'; import { queryBuilder } from '../../shared/testing/builders'; import { optionPickerFactory } from './OptionsPicker'; -import { initialState, OptionsPickerState } from './reducer'; +import { initialOptionPickerState, OptionsPickerState } from './reducer'; +import { getPreloadedState } from '../../state/helpers'; interface Args { pickerState?: Partial; @@ -18,6 +19,7 @@ interface Args { const defaultVariable = queryBuilder() .withId('query0') + .withRootStateKey('key') .withName('query0') .withMulti() .withCurrent(['A', 'C']) @@ -35,17 +37,16 @@ function setupTestContext({ pickerState = {}, variable = {} }: Args = {}) { onVariableChange, }; const Picker = optionPickerFactory(); - const optionsPicker: OptionsPickerState = { ...initialState, ...pickerState }; + const optionsPicker: OptionsPickerState = { ...initialOptionPickerState, ...pickerState }; const dispatch = jest.fn(); const subscribe = jest.fn(); - const getState = jest.fn().mockReturnValue({ - templating: { - variables: { - [v.id]: { ...v }, - }, - optionsPicker, + const templatingState = { + variables: { + [v.id]: { ...v }, }, - }); + optionsPicker, + }; + const getState = jest.fn().mockReturnValue(getPreloadedState('key', templatingState)); const store: any = { getState, dispatch, subscribe }; const { rerender } = render( diff --git a/public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx b/public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx index 9c082a44658..c33a9afc05b 100644 --- a/public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx +++ b/public/app/features/variables/pickers/OptionsPicker/OptionsPicker.tsx @@ -1,36 +1,58 @@ import React, { ComponentType, PureComponent } from 'react'; +import { bindActionCreators } from 'redux'; import { connect, ConnectedProps } from 'react-redux'; import { ClickOutsideWrapper } from '@grafana/ui'; import { LoadingState } from '@grafana/data'; -import { StoreState } from 'app/types'; +import { StoreState, ThunkDispatch } from 'app/types'; import { VariableInput } from '../shared/VariableInput'; import { commitChangesToVariable, filterOrSearchOptions, navigateOptions, openOptions } from './actions'; -import { OptionsPickerState, toggleAllOptions, toggleOption } from './reducer'; +import { initialOptionPickerState, OptionsPickerState, toggleAllOptions, toggleOption } from './reducer'; import { VariableOption, VariableWithMultiSupport, VariableWithOptions } from '../../types'; import { VariableOptions } from '../shared/VariableOptions'; import { isMulti } from '../../guard'; -import { VariablePickerProps } from '../types'; +import { NavigationKey, VariablePickerProps } from '../types'; import { formatVariableLabel } from '../../shared/formatVariable'; -import { toVariableIdentifier } from '../../state/types'; +import { KeyedVariableIdentifier } from '../../state/types'; import { getVariableQueryRunner } from '../../query/VariableQueryRunner'; import { VariableLink } from '../shared/VariableLink'; +import { getVariablesState } from '../../state/selectors'; +import { toKeyedAction } from '../../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier } from '../../utils'; export const optionPickerFactory = (): ComponentType< VariablePickerProps > => { - const mapDispatchToProps = { - openOptions, - commitChangesToVariable, - filterOrSearchOptions, - toggleAllOptions, - toggleOption, - navigateOptions, + const mapDispatchToProps = (dispatch: ThunkDispatch) => { + return { + ...bindActionCreators({ openOptions, commitChangesToVariable, navigateOptions }, dispatch), + filterOrSearchOptions: (identifier: KeyedVariableIdentifier, filter = '') => { + dispatch(filterOrSearchOptions(identifier, filter)); + }, + toggleAllOptions: (identifier: KeyedVariableIdentifier) => + dispatch(toKeyedAction(identifier.rootStateKey, toggleAllOptions())), + toggleOption: ( + identifier: KeyedVariableIdentifier, + option: VariableOption, + clearOthers: boolean, + forceSelect: boolean + ) => dispatch(toKeyedAction(identifier.rootStateKey, toggleOption({ option, clearOthers, forceSelect }))), + }; }; - const mapStateToProps = (state: StoreState) => ({ - picker: state.templating.optionsPicker, - }); + const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { + const { rootStateKey } = ownProps.variable; + if (!rootStateKey) { + console.error('OptionPickerFactory: variable has no rootStateKey'); + return { + picker: initialOptionPickerState, + }; + } + + return { + picker: getVariablesState(rootStateKey, state).optionsPicker, + }; + }; const connector = connect(mapStateToProps, mapDispatchToProps); @@ -40,8 +62,15 @@ export const optionPickerFactory = { onShowOptions = () => - this.props.openOptions(toVariableIdentifier(this.props.variable), this.props.onVariableChange); - onHideOptions = () => this.props.commitChangesToVariable(this.props.onVariableChange); + this.props.openOptions(toKeyedVariableIdentifier(this.props.variable), this.props.onVariableChange); + onHideOptions = () => { + if (!this.props.variable.rootStateKey) { + console.error('Variable has no rootStateKey'); + return; + } + + this.props.commitChangesToVariable(this.props.variable.rootStateKey, this.props.onVariableChange); + }; onToggleOption = (option: VariableOption, clearOthers: boolean) => { const toggleFunc = @@ -52,12 +81,29 @@ export const optionPickerFactory = { - this.props.toggleOption({ option, clearOthers, forceSelect: false }); + this.props.toggleOption(toKeyedVariableIdentifier(this.props.variable), option, clearOthers, false); this.onHideOptions(); }; onToggleMultiValueVariable = (option: VariableOption, clearOthers: boolean) => { - this.props.toggleOption({ option, clearOthers, forceSelect: false }); + this.props.toggleOption(toKeyedVariableIdentifier(this.props.variable), option, clearOthers, false); + }; + + onToggleAllOptions = () => { + this.props.toggleAllOptions(toKeyedVariableIdentifier(this.props.variable)); + }; + + onFilterOrSearchOptions = (filter: string) => { + this.props.filterOrSearchOptions(toKeyedVariableIdentifier(this.props.variable), filter); + }; + + onNavigate = (key: NavigationKey, clearOthers: boolean) => { + if (!this.props.variable.rootStateKey) { + console.error('Variable has no rootStateKey'); + return; + } + + this.props.navigateOptions(this.props.variable.rootStateKey, key, clearOthers); }; render() { @@ -87,7 +133,7 @@ export const optionPickerFactory = { - getVariableQueryRunner().cancelRequest(toVariableIdentifier(this.props.variable)); + getVariableQueryRunner().cancelRequest(toKeyedVariableIdentifier(this.props.variable)); }; renderOptions(picker: OptionsPickerState) { @@ -97,15 +143,15 @@ export const optionPickerFactory = Promise.resolve([])), @@ -59,9 +60,11 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); const option = { ...createOption(['A']), @@ -70,9 +73,12 @@ describe('options picker actions', () => { }; tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue(toVariablePayload(variable, { option })), - changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })), - hideOptions() + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })) + ), + toKeyedAction('key', hideOptions()) ); }); }); @@ -91,12 +97,16 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, false)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, false)) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); - tester.thenDispatchedActionsShouldEqual(toggleOption({ option, forceSelect: false, clearOthers })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', toggleOption({ option, forceSelect: false, clearOthers })) + ); }); }); @@ -114,12 +124,16 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); - tester.thenDispatchedActionsShouldEqual(toggleOption({ option, forceSelect: false, clearOthers })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', toggleOption({ option, forceSelect: false, clearOthers })) + ); }); }); @@ -133,14 +147,18 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); - tester.thenDispatchedActionsShouldEqual(toggleOption({ option: options[2], forceSelect: false, clearOthers })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', toggleOption({ option: options[2], forceSelect: false, clearOthers })) + ); }); }); @@ -154,15 +172,19 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveUp, clearOthers)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveUp, clearOthers)) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); - tester.thenDispatchedActionsShouldEqual(toggleOption({ option: options[1], forceSelect: false, clearOthers })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', toggleOption({ option: options[1], forceSelect: false, clearOthers })) + ); }); }); @@ -176,13 +198,15 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveUp, clearOthers)) - .whenAsyncActionIsDispatched(navigateOptions(key, clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveUp, clearOthers)) + .whenAsyncActionIsDispatched(navigateOptions('key', key, clearOthers), true); const option = { ...createOption(['B']), @@ -191,11 +215,14 @@ describe('options picker actions', () => { }; tester.thenDispatchedActionsShouldEqual( - toggleOption({ option: options[1], forceSelect: true, clearOthers }), - setCurrentVariableValue(toVariablePayload(variable, { option })), - changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })), - hideOptions(), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', toggleOption({ option: options[1], forceSelect: true, clearOthers })), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })) + ), + toKeyedAction('key', hideOptions()), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); expect(locationService.partial).toHaveBeenLastCalledWith({ 'var-Constant': ['B'] }); }); @@ -209,11 +236,16 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenAsyncActionIsDispatched(filterOrSearchOptions(filter), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenAsyncActionIsDispatched(filterOrSearchOptions(toKeyedVariableIdentifier(variable), filter), true); - tester.thenDispatchedActionsShouldEqual(updateSearchQuery(filter), updateOptionsAndFilter(variable.options)); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', updateSearchQuery(filter)), + toKeyedAction('key', updateOptionsAndFilter(variable.options)) + ); }); }); @@ -221,26 +253,25 @@ describe('options picker actions', () => { it('then correct actions are dispatched', async () => { const variable = queryBuilder() .withId('query0') + .withRootStateKey('key') .withName('query0') .withMulti() .withCurrent(['A', 'C']) .withOptions('A', 'B', 'C') .build(); - const preloadedState: any = { - templating: { - variables: { - [variable.id]: { ...variable }, - }, - optionsPicker: { ...initialState }, + const preloadedState = getPreloadedState('key', { + variables: { + [variable.id]: { ...variable }, }, - }; + optionsPicker: { ...initialOptionPickerState }, + }); const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(openOptions(variable, undefined)); + .whenAsyncActionIsDispatched(openOptions(toKeyedVariableIdentifier(variable), undefined)); - tester.thenDispatchedActionsShouldEqual(showOptions(variable)); + tester.thenDispatchedActionsShouldEqual(toKeyedAction('key', showOptions(variable))); }); }); @@ -248,26 +279,25 @@ describe('options picker actions', () => { it('then correct actions are dispatched', async () => { const variable = queryBuilder() .withId('query0') + .withRootStateKey('key') .withName('query0') .withMulti() .withCurrent(['A', 'C']) .withOptions('A', 'B', 'C') .build(); - const preloadedState: any = { - templating: { - variables: { - [variable.id]: { ...variable }, - }, - optionsPicker: { ...initialState, id: variable.id }, + const preloadedState = getPreloadedState('key', { + variables: { + [variable.id]: { ...variable }, }, - }; + optionsPicker: { ...initialOptionPickerState, id: variable.id }, + }); const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(openOptions(variable, undefined)); + .whenAsyncActionIsDispatched(openOptions(toKeyedVariableIdentifier(variable), undefined)); - tester.thenDispatchedActionsShouldEqual(showOptions(variable)); + tester.thenDispatchedActionsShouldEqual(toKeyedAction('key', showOptions(variable))); }); }); @@ -275,6 +305,7 @@ describe('options picker actions', () => { it('then correct actions are dispatched', async () => { const variableInPickerState = queryBuilder() .withId('query1') + .withRootStateKey('key') .withName('query1') .withMulti() .withCurrent(['A', 'C']) @@ -283,31 +314,33 @@ describe('options picker actions', () => { const variable = queryBuilder() .withId('query0') + .withRootStateKey('key') .withName('query0') .withMulti() .withCurrent(['A']) .withOptions('A', 'B', 'C') .build(); - const preloadedState: any = { - templating: { - variables: { - [variable.id]: { ...variable }, - [variableInPickerState.id]: { ...variableInPickerState }, - }, - optionsPicker: { ...initialState, id: variableInPickerState.id }, + const preloadedState = getPreloadedState('key', { + variables: { + [variable.id]: { ...variable }, + [variableInPickerState.id]: { ...variableInPickerState }, }, - }; + optionsPicker: { ...initialOptionPickerState, id: variableInPickerState.id }, + }); const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(openOptions(variable, undefined)); + .whenAsyncActionIsDispatched(openOptions(toKeyedVariableIdentifier(variable), undefined)); tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue({ type: 'query', id: 'query1', data: { option: undefined } }), - changeVariableProp({ type: 'query', id: 'query1', data: { propName: 'queryValue', propValue: '' } }), - hideOptions(), - showOptions(variable) + toKeyedAction('key', setCurrentVariableValue({ type: 'query', id: 'query1', data: { option: undefined } })), + toKeyedAction( + 'key', + changeVariableProp({ type: 'query', id: 'query1', data: { propName: 'queryValue', propValue: '' } }) + ), + toKeyedAction('key', hideOptions()), + toKeyedAction('key', showOptions(variable)) ); }); }); @@ -319,9 +352,11 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenAsyncActionIsDispatched(commitChangesToVariable(), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenAsyncActionIsDispatched(commitChangesToVariable('key'), true); const option = { ...createOption(['A']), @@ -330,9 +365,12 @@ describe('options picker actions', () => { }; tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue(toVariablePayload(variable, { option })), - changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })), - hideOptions() + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })) + ), + toKeyedAction('key', hideOptions()) ); }); }); @@ -345,11 +383,13 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(toggleOptionByHighlight(clearOthers)) - .whenAsyncActionIsDispatched(commitChangesToVariable(), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(toggleOptionByHighlight('key', clearOthers)) + .whenAsyncActionIsDispatched(commitChangesToVariable('key'), true); const option = { ...createOption([]), @@ -358,10 +398,13 @@ describe('options picker actions', () => { }; tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue(toVariablePayload(variable, { option })), - changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })), - hideOptions(), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: '' })) + ), + toKeyedAction('key', hideOptions()), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); expect(locationService.partial).toHaveBeenLastCalledWith({ 'var-Constant': [] }); }); @@ -375,12 +418,14 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(toggleOptionByHighlight(clearOthers)) - .whenActionIsDispatched(filterOrSearchOptions('C')) - .whenAsyncActionIsDispatched(commitChangesToVariable(), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(toggleOptionByHighlight('key', clearOthers)) + .whenActionIsDispatched(filterOrSearchOptions(toKeyedVariableIdentifier(variable), 'C')) + .whenAsyncActionIsDispatched(commitChangesToVariable('key'), true); const option = { ...createOption([]), @@ -389,10 +434,13 @@ describe('options picker actions', () => { }; tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue(toVariablePayload(variable, { option })), - changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: 'C' })), - hideOptions(), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'queryValue', propValue: 'C' })) + ), + toKeyedAction('key', hideOptions()), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); expect(locationService.partial).toHaveBeenLastCalledWith({ 'var-Constant': [] }); }); @@ -406,14 +454,18 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(toggleOptionByHighlight(clearOthers), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(toggleOptionByHighlight('key', clearOthers), true); const option = createOption('A'); - tester.thenDispatchedActionsShouldEqual(toggleOption({ option, forceSelect: false, clearOthers })); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', toggleOption({ option, forceSelect: false, clearOthers })) + ); }); }); @@ -425,25 +477,27 @@ describe('options picker actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(showOptions(variable)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(toggleOptionByHighlight(clearOthers), true) - .whenActionIsDispatched(filterOrSearchOptions('B')) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(navigateOptions(NavigationKey.moveDown, clearOthers)) - .whenActionIsDispatched(toggleOptionByHighlight(clearOthers)); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', showOptions(variable))) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(toggleOptionByHighlight('key', clearOthers), true) + .whenActionIsDispatched(filterOrSearchOptions(toKeyedVariableIdentifier(variable), 'B')) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(navigateOptions('key', NavigationKey.moveDown, clearOthers)) + .whenActionIsDispatched(toggleOptionByHighlight('key', clearOthers)); const optionA = createOption('A'); const optionBC = createOption('BD'); tester.thenDispatchedActionsShouldEqual( - toggleOption({ option: optionA, forceSelect: false, clearOthers }), - updateSearchQuery('B'), - updateOptionsAndFilter(variable.options), - moveOptionsHighlight(1), - moveOptionsHighlight(1), - toggleOption({ option: optionBC, forceSelect: false, clearOthers }) + toKeyedAction('key', toggleOption({ option: optionA, forceSelect: false, clearOthers })), + toKeyedAction('key', updateSearchQuery('B')), + toKeyedAction('key', updateOptionsAndFilter(variable.options)), + toKeyedAction('key', moveOptionsHighlight(1)), + toKeyedAction('key', moveOptionsHighlight(1)), + toKeyedAction('key', toggleOption({ option: optionBC, forceSelect: false, clearOthers })) ); }); }); @@ -454,6 +508,7 @@ function createMultiVariable(extend?: Partial): QueryVariabl ...initialVariableModelState, type: 'query', id: '0', + rootStateKey: 'key', index: 0, current: createOption([]), options: [], diff --git a/public/app/features/variables/pickers/OptionsPicker/actions.ts b/public/app/features/variables/pickers/OptionsPicker/actions.ts index d6a634689bd..7d25290fb22 100644 --- a/public/app/features/variables/pickers/OptionsPicker/actions.ts +++ b/public/app/features/variables/pickers/OptionsPicker/actions.ts @@ -2,7 +2,7 @@ import { debounce, trim } from 'lodash'; import { StoreState, ThunkDispatch, ThunkResult } from 'app/types'; import { VariableOption, VariableWithMultiSupport, VariableWithOptions } from '../../types'; import { variableAdapters } from '../../adapters'; -import { getVariable } from '../../state/selectors'; +import { getVariable, getVariablesState } from '../../state/selectors'; import { NavigationKey } from '../types'; import { hideOptions, @@ -15,50 +15,56 @@ import { updateSearchQuery, } from './reducer'; import { changeVariableProp, setCurrentVariableValue } from '../../state/sharedReducer'; -import { toVariablePayload, VariableIdentifier } from '../../state/types'; -import { containsSearchFilter, getCurrentText } from '../../utils'; +import { KeyedVariableIdentifier } from '../../state/types'; +import { containsSearchFilter, getCurrentText, toVariablePayload } from '../../utils'; +import { toKeyedAction } from '../../state/keyedVariablesReducer'; -export const navigateOptions = (key: NavigationKey, clearOthers: boolean): ThunkResult => { +export const navigateOptions = (rootStateKey: string, key: NavigationKey, clearOthers: boolean): ThunkResult => { return async (dispatch, getState) => { if (key === NavigationKey.cancel) { - return await dispatch(commitChangesToVariable()); + return await dispatch(commitChangesToVariable(rootStateKey)); } if (key === NavigationKey.select) { - return dispatch(toggleOptionByHighlight(clearOthers)); + return dispatch(toggleOptionByHighlight(rootStateKey, clearOthers)); } if (key === NavigationKey.selectAndClose) { - dispatch(toggleOptionByHighlight(clearOthers, true)); - return await dispatch(commitChangesToVariable()); + dispatch(toggleOptionByHighlight(rootStateKey, clearOthers, true)); + return await dispatch(commitChangesToVariable(rootStateKey)); } if (key === NavigationKey.moveDown) { - return dispatch(moveOptionsHighlight(1)); + return dispatch(toKeyedAction(rootStateKey, moveOptionsHighlight(1))); } if (key === NavigationKey.moveUp) { - return dispatch(moveOptionsHighlight(-1)); + return dispatch(toKeyedAction(rootStateKey, moveOptionsHighlight(-1))); } return undefined; }; }; -export const filterOrSearchOptions = (searchQuery = ''): ThunkResult => { +export const filterOrSearchOptions = ( + passedIdentifier: KeyedVariableIdentifier, + searchQuery = '' +): ThunkResult => { return async (dispatch, getState) => { - const { id, queryValue } = getState().templating.optionsPicker; - const { query, options } = getVariable(id, getState()); - dispatch(updateSearchQuery(searchQuery)); + const { rootStateKey } = passedIdentifier; + const { id, queryValue } = getVariablesState(rootStateKey, getState()).optionsPicker; + const identifier: KeyedVariableIdentifier = { id, rootStateKey: rootStateKey, type: 'query' }; + const { query, options } = getVariable(identifier, getState()); + dispatch(toKeyedAction(rootStateKey, updateSearchQuery(searchQuery))); if (trim(queryValue) === trim(searchQuery)) { return; } if (containsSearchFilter(query)) { - return searchForOptionsWithDebounce(dispatch, getState, searchQuery); + return searchForOptionsWithDebounce(dispatch, getState, searchQuery, rootStateKey); } - return dispatch(updateOptionsAndFilter(options)); + return dispatch(toKeyedAction(rootStateKey, updateOptionsAndFilter(options))); }; }; @@ -68,17 +74,18 @@ const setVariable = async (updated: VariableWithMultiSupport) => { return; }; -export const commitChangesToVariable = (callback?: (updated: any) => void): ThunkResult => { +export const commitChangesToVariable = (key: string, callback?: (updated: any) => void): ThunkResult => { return async (dispatch, getState) => { - const picker = getState().templating.optionsPicker; - const existing = getVariable(picker.id, getState()); + const picker = getVariablesState(key, getState()).optionsPicker; + const identifier: KeyedVariableIdentifier = { id: picker.id, rootStateKey: key, type: 'query' }; + const existing = getVariable(identifier, getState()); const currentPayload = { option: mapToCurrent(picker) }; const searchQueryPayload = { propName: 'queryValue', propValue: picker.queryValue }; - dispatch(setCurrentVariableValue(toVariablePayload(existing, currentPayload))); - dispatch(changeVariableProp(toVariablePayload(existing, searchQueryPayload))); - const updated = getVariable(picker.id, getState()); - dispatch(hideOptions()); + dispatch(toKeyedAction(key, setCurrentVariableValue(toVariablePayload(existing, currentPayload)))); + dispatch(toKeyedAction(key, changeVariableProp(toVariablePayload(existing, searchQueryPayload)))); + const updated = getVariable(identifier, getState()); + dispatch(toKeyedAction(key, hideOptions())); if (getCurrentText(existing) === getCurrentText(updated)) { return; @@ -93,36 +100,43 @@ export const commitChangesToVariable = (callback?: (updated: any) => void): Thun }; export const openOptions = - ({ id }: VariableIdentifier, callback?: (updated: any) => void): ThunkResult => + (identifier: KeyedVariableIdentifier, callback?: (updated: any) => void): ThunkResult => async (dispatch, getState) => { - const picker = getState().templating.optionsPicker; + const { id, rootStateKey: uid } = identifier; + const picker = getVariablesState(uid, getState()).optionsPicker; if (picker.id && picker.id !== id) { - await dispatch(commitChangesToVariable(callback)); + await dispatch(commitChangesToVariable(uid, callback)); } - const variable = getVariable(id, getState()); - dispatch(showOptions(variable)); + const variable = getVariable(identifier, getState()); + dispatch(toKeyedAction(uid, showOptions(variable))); }; -export const toggleOptionByHighlight = (clearOthers: boolean, forceSelect = false): ThunkResult => { +export const toggleOptionByHighlight = (key: string, clearOthers: boolean, forceSelect = false): ThunkResult => { return (dispatch, getState) => { - const { highlightIndex, options } = getState().templating.optionsPicker; + const { highlightIndex, options } = getVariablesState(key, getState()).optionsPicker; const option = options[highlightIndex]; - dispatch(toggleOption({ option, forceSelect, clearOthers })); + dispatch(toKeyedAction(key, toggleOption({ option, forceSelect, clearOthers }))); }; }; -const searchForOptions = async (dispatch: ThunkDispatch, getState: () => StoreState, searchQuery: string) => { +const searchForOptions = async ( + dispatch: ThunkDispatch, + getState: () => StoreState, + searchQuery: string, + key: string +) => { try { - const { id } = getState().templating.optionsPicker; - const existing = getVariable(id, getState()); + const { id } = getVariablesState(key, getState()).optionsPicker; + const identifier: KeyedVariableIdentifier = { id, rootStateKey: key, type: 'query' }; + const existing = getVariable(identifier, getState()); const adapter = variableAdapters.get(existing.type); await adapter.updateOptions(existing, searchQuery); - const updated = getVariable(id, getState()); - dispatch(updateOptionsFromSearch(updated.options)); + const updated = getVariable(identifier, getState()); + dispatch(toKeyedAction(key, updateOptionsFromSearch(updated.options))); } catch (error) { console.error(error); } diff --git a/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts b/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts index e28f8a51792..b706a6c3a83 100644 --- a/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts +++ b/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts @@ -2,7 +2,7 @@ import { cloneDeep } from 'lodash'; import { cleanPickerState, hideOptions, - initialState as optionsPickerInitialState, + initialOptionPickerState as optionsPickerInitialState, moveOptionsHighlight, OPTIONS_LIMIT, optionsPickerReducer, diff --git a/public/app/features/variables/pickers/OptionsPicker/reducer.ts b/public/app/features/variables/pickers/OptionsPicker/reducer.ts index 92d87996682..ce9f009f384 100644 --- a/public/app/features/variables/pickers/OptionsPicker/reducer.ts +++ b/public/app/features/variables/pickers/OptionsPicker/reducer.ts @@ -21,7 +21,7 @@ export interface OptionsPickerState { multi: boolean; } -export const initialState: OptionsPickerState = { +export const initialOptionPickerState: OptionsPickerState = { id: '', highlightIndex: -1, queryValue: '', @@ -106,7 +106,7 @@ const updateAllSelection = (state: OptionsPickerState): OptionsPickerState => { const optionsPickerSlice = createSlice({ name: 'templating/optionsPicker', - initialState, + initialState: initialOptionPickerState, reducers: { showOptions: (state, action: PayloadAction): OptionsPickerState => { const { query, options } = action.payload; @@ -131,7 +131,7 @@ const optionsPickerSlice = createSlice({ return applyStateChanges(state, updateDefaultSelection, updateOptions); }, hideOptions: (state, action: PayloadAction): OptionsPickerState => { - return { ...initialState }; + return { ...initialOptionPickerState }; }, toggleOption: (state, action: PayloadAction): OptionsPickerState => { const { option, clearOthers, forceSelect } = action.payload; @@ -212,7 +212,7 @@ const optionsPickerSlice = createSlice({ return applyStateChanges(state, updateDefaultSelection, updateOptions); }, - cleanPickerState: () => initialState, + cleanPickerState: () => initialOptionPickerState, }, }); diff --git a/public/app/features/variables/query/QueryVariableEditor.test.tsx b/public/app/features/variables/query/QueryVariableEditor.test.tsx index 69c4cd08810..987446dd904 100644 --- a/public/app/features/variables/query/QueryVariableEditor.test.tsx +++ b/public/app/features/variables/query/QueryVariableEditor.test.tsx @@ -10,14 +10,17 @@ import { LegacyVariableQueryEditor } from '../editor/LegacyVariableQueryEditor'; import { mockDataSource } from 'app/features/alerting/unified/mocks'; import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; import { NEW_VARIABLE_ID } from '../constants'; +import { VariableModel } from '../types'; +import { KeyedVariableIdentifier } from '../state/types'; const setupTestContext = (options: Partial) => { + const variableDefaults: Partial = { rootStateKey: 'key' }; const extended = { VariableQueryEditor: LegacyVariableQueryEditor, dataSource: {} as unknown as DataSourceApi, }; const defaults: Props = { - variable: { ...initialQueryVariableModelState }, + variable: { ...initialQueryVariableModelState, ...variableDefaults }, initQueryVariableEditor: jest.fn(), changeQueryVariableDataSource: jest.fn(), changeQueryVariableQuery: jest.fn(), @@ -47,20 +50,22 @@ jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => { }; }); +const defaultIdentifier: KeyedVariableIdentifier = { type: 'query', rootStateKey: 'key', id: NEW_VARIABLE_ID }; + describe('QueryVariableEditor', () => { describe('when the component is mounted', () => { it('then it should call initQueryVariableEditor', () => { const { props } = setupTestContext({}); expect(props.initQueryVariableEditor).toHaveBeenCalledTimes(1); - expect(props.initQueryVariableEditor).toHaveBeenCalledWith({ type: 'query', id: NEW_VARIABLE_ID }); + expect(props.initQueryVariableEditor).toHaveBeenCalledWith(defaultIdentifier); }); }); describe('when the user changes', () => { it.each` fieldName | propName | expectedArgs - ${'query'} | ${'changeQueryVariableQuery'} | ${[{ type: 'query', id: NEW_VARIABLE_ID }, 't', 't']} + ${'query'} | ${'changeQueryVariableQuery'} | ${[defaultIdentifier, 't', 't']} ${'regex'} | ${'onPropChange'} | ${[{ propName: 'regex', propValue: 't', updateOptions: true }]} `( '$fieldName field and tabs away then $propName should be called with correct args', diff --git a/public/app/features/variables/query/QueryVariableEditor.tsx b/public/app/features/variables/query/QueryVariableEditor.tsx index 521748f237f..94ff3cb539b 100644 --- a/public/app/features/variables/query/QueryVariableEditor.tsx +++ b/public/app/features/variables/query/QueryVariableEditor.tsx @@ -9,9 +9,9 @@ import { DataSourceInstanceSettings, getDataSourceRef, LoadingState, SelectableV import { SelectionOptionsEditor } from '../editor/SelectionOptionsEditor'; import { QueryVariableModel, VariableRefresh, VariableSort, VariableWithMultiSupport } from '../types'; import { changeQueryVariableDataSource, changeQueryVariableQuery, initQueryVariableEditor } from './actions'; +import { initialVariableEditorState } from '../editor/reducer'; import { OnPropChangeArguments, VariableEditorProps } from '../editor/types'; import { StoreState } from '../../../types'; -import { toVariableIdentifier } from '../state/types'; import { changeVariableMultiValue } from '../state/actions'; import { getTimeSrv } from '../../dashboard/services/TimeSrv'; import { isLegacyQueryEditor, isQueryEditor } from '../guard'; @@ -20,10 +20,24 @@ import { VariableTextField } from '../editor/VariableTextField'; import { QueryVariableRefreshSelect } from './QueryVariableRefreshSelect'; import { QueryVariableSortSelect } from './QueryVariableSortSelect'; import { getQueryVariableEditorState } from '../editor/selectors'; +import { getVariablesState } from '../state/selectors'; +import { toKeyedVariableIdentifier } from '../utils'; -const mapStateToProps = (state: StoreState) => ({ - extended: getQueryVariableEditorState(state.templating.editor), -}); +const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { + const { rootStateKey } = ownProps.variable; + if (!rootStateKey) { + console.error('QueryVariableEditor: variable has no rootStateKey'); + return { + extended: getQueryVariableEditorState(initialVariableEditorState), + }; + } + + const { editor } = getVariablesState(rootStateKey, state); + + return { + extended: getQueryVariableEditorState(editor), + }; +}; const mapDispatchToProps = { initQueryVariableEditor, @@ -52,13 +66,13 @@ export class QueryVariableEditorUnConnected extends PureComponent }; async componentDidMount() { - await this.props.initQueryVariableEditor(toVariableIdentifier(this.props.variable)); + await this.props.initQueryVariableEditor(toKeyedVariableIdentifier(this.props.variable)); } componentDidUpdate(prevProps: Readonly): void { if (prevProps.variable.datasource !== this.props.variable.datasource) { this.props.changeQueryVariableDataSource( - toVariableIdentifier(this.props.variable), + toKeyedVariableIdentifier(this.props.variable), this.props.variable.datasource ); } @@ -73,7 +87,7 @@ export class QueryVariableEditorUnConnected extends PureComponent onLegacyQueryChange = async (query: any, definition: string) => { if (this.props.variable.query !== query) { - this.props.changeQueryVariableQuery(toVariableIdentifier(this.props.variable), query, definition); + this.props.changeQueryVariableQuery(toKeyedVariableIdentifier(this.props.variable), query, definition); } }; @@ -85,7 +99,7 @@ export class QueryVariableEditorUnConnected extends PureComponent definition = query.query; } - this.props.changeQueryVariableQuery(toVariableIdentifier(this.props.variable), query, definition); + this.props.changeQueryVariableQuery(toKeyedVariableIdentifier(this.props.variable), query, definition); } }; diff --git a/public/app/features/variables/query/VariableQueryRunner.test.ts b/public/app/features/variables/query/VariableQueryRunner.test.ts index e212b625f0c..e1670479de2 100644 --- a/public/app/features/variables/query/VariableQueryRunner.test.ts +++ b/public/app/features/variables/query/VariableQueryRunner.test.ts @@ -5,9 +5,13 @@ import { delay } from 'rxjs/operators'; import { UpdateOptionsResults, VariableQueryRunner } from './VariableQueryRunner'; import { queryBuilder } from '../shared/testing/builders'; import { QueryRunner, QueryRunners } from './queryRunners'; -import { toVariableIdentifier, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { QueryVariableModel } from '../types'; import { updateVariableOptions } from './reducer'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { initialTransactionState } from '../state/transactionReducer'; +import { getPreloadedState } from '../state/helpers'; +import { toKeyedVariableIdentifier } from '../utils'; type DoneCallback = { (...args: any[]): any; @@ -16,7 +20,7 @@ type DoneCallback = { function expectOnResults(args: { runner: VariableQueryRunner; - identifier: VariableIdentifier; + identifier: KeyedVariableIdentifier; done: DoneCallback; expect: (results: UpdateOptionsResults[]) => void; }) { @@ -32,7 +36,7 @@ function expectOnResults(args: { done(); } catch (err) { subscription.unsubscribe(); - done.fail(err); + done(err); } } }, @@ -40,25 +44,23 @@ function expectOnResults(args: { } function getTestContext(variable?: QueryVariableModel) { - variable = variable ?? queryBuilder().withId('query').build(); const getTimeSrv = jest.fn().mockReturnValue({ timeRange: jest.fn().mockReturnValue(getDefaultTimeRange()), }); + const key = '0123456789'; + variable = variable ?? queryBuilder().withId('query').withRootStateKey(key).withName('query').build(); const datasource: any = { metricFindQuery: jest.fn().mockResolvedValue([]) }; - const identifier = toVariableIdentifier(variable); + const identifier = toKeyedVariableIdentifier(variable); const searchFilter = undefined; const getTemplatedRegex = jest.fn().mockReturnValue('getTemplatedRegex result'); const dispatch = jest.fn().mockResolvedValue({}); - const getState = jest.fn().mockReturnValue({ - templating: { - transaction: { - uid: '0123456789', - }, - }, + const templatingState = { + transaction: { ...initialTransactionState, uid: key }, variables: { [variable.id]: variable, }, - }); + }; + const getState = jest.fn().mockReturnValue(getPreloadedState(key, templatingState)); const queryRunner: QueryRunner = { type: VariableSupportType.Standard, canRun: jest.fn().mockReturnValue(true), @@ -81,6 +83,7 @@ function getTestContext(variable?: QueryVariableModel) { }); return { + key, identifier, datasource, runner, @@ -100,7 +103,7 @@ function getTestContext(variable?: QueryVariableModel) { describe('VariableQueryRunner', () => { describe('happy case', () => { it('then it should work as expected', (done) => { - const { identifier, runner, datasource, getState, getVariable, queryRunners, queryRunner, dispatch } = + const { key, identifier, runner, datasource, getState, getVariable, queryRunners, queryRunner, dispatch } = getTestContext(); expectOnResults({ @@ -124,11 +127,14 @@ describe('VariableQueryRunner', () => { // updateVariableOptions and validateVariableSelectionState expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0]).toEqual( - updateVariableOptions({ - id: 'query', - type: 'query', - data: { results: [], templatedRegex: 'getTemplatedRegex result' }, - }) + toKeyedAction( + key, + updateVariableOptions({ + id: 'query', + type: 'query', + data: { results: [], templatedRegex: 'getTemplatedRegex result' }, + }) + ) ); }, done, diff --git a/public/app/features/variables/query/VariableQueryRunner.ts b/public/app/features/variables/query/VariableQueryRunner.ts index ea17c2ce2ea..866cc92ca85 100644 --- a/public/app/features/variables/query/VariableQueryRunner.ts +++ b/public/app/features/variables/query/VariableQueryRunner.ts @@ -11,8 +11,8 @@ import { ScopedVars, } from '@grafana/data'; -import { VariableIdentifier } from '../state/types'; -import { getVariable } from '../state/selectors'; +import { KeyedVariableIdentifier } from '../state/types'; +import { getLastKey, getVariable } from '../state/selectors'; import { QueryVariableModel, VariableRefresh } from '../types'; import { StoreState, ThunkDispatch } from '../../../types'; import { dispatch, getState } from '../../../store/store'; @@ -24,14 +24,14 @@ import { runRequest } from '../../query/state/runRequest'; import { toMetricFindValues, updateOptionsState, validateVariableSelection } from './operators'; interface UpdateOptionsArgs { - identifier: VariableIdentifier; + identifier: KeyedVariableIdentifier; datasource: DataSourceApi; searchFilter?: string; } export interface UpdateOptionsResults { state: LoadingState; - identifier: VariableIdentifier; + identifier: KeyedVariableIdentifier; error?: any; cancelled?: boolean; } @@ -49,7 +49,7 @@ interface VariableQueryRunnerArgs { export class VariableQueryRunner { private readonly updateOptionsRequests: Subject; private readonly updateOptionsResults: Subject; - private readonly cancelRequests: Subject<{ identifier: VariableIdentifier }>; + private readonly cancelRequests: Subject<{ identifier: KeyedVariableIdentifier }>; private readonly subscription: Unsubscribable; constructor( @@ -65,7 +65,7 @@ export class VariableQueryRunner { ) { this.updateOptionsRequests = new Subject(); this.updateOptionsResults = new Subject(); - this.cancelRequests = new Subject<{ identifier: VariableIdentifier }>(); + this.cancelRequests = new Subject<{ identifier: KeyedVariableIdentifier }>(); this.onNewRequest = this.onNewRequest.bind(this); this.subscription = this.updateOptionsRequests.subscribe(this.onNewRequest); } @@ -74,11 +74,11 @@ export class VariableQueryRunner { this.updateOptionsRequests.next(args); } - getResponse(identifier: VariableIdentifier): Observable { + getResponse(identifier: KeyedVariableIdentifier): Observable { return this.updateOptionsResults.asObservable().pipe(filter((result) => result.identifier === identifier)); } - cancelRequest(identifier: VariableIdentifier): void { + cancelRequest(identifier: KeyedVariableIdentifier): void { this.cancelRequests.next({ identifier }); } @@ -99,11 +99,11 @@ export class VariableQueryRunner { getState, } = this.dependencies; - const beforeUid = getState().templating.transaction.uid; + const beforeKey = getLastKey(getState()); this.updateOptionsResults.next({ identifier, state: LoadingState.Loading }); - const variable = getVariable(identifier.id, getState()); + const variable = getVariable(identifier, getState()); const timeSrv = getTimeSrv(); const runnerArgs = { variable, datasource, searchFilter, timeSrv, runRequest }; const runner = queryRunners.getRunnerForDatasource(datasource); @@ -115,9 +115,9 @@ export class VariableQueryRunner { .pipe( filter(() => { // Lets check if we started another batch during the execution of the observable. If so we just want to abort the rest. - const afterUid = getState().templating.transaction.uid; + const afterKey = getLastKey(getState()); - return beforeUid === afterUid; + return beforeKey === afterKey; }), filter((data) => data.state === LoadingState.Done || data.state === LoadingState.Error), // we only care about done or error for now take(1), // take the first result, using first caused a bug where it in some situations throw an uncaught error because of no results had been received yet diff --git a/public/app/features/variables/query/actions.test.tsx b/public/app/features/variables/query/actions.test.tsx index 03623609ebb..7f8afffcd8b 100644 --- a/public/app/features/variables/query/actions.test.tsx +++ b/public/app/features/variables/query/actions.test.tsx @@ -4,9 +4,8 @@ import { DataSourceRef, getDefaultTimeRange, LoadingState } from '@grafana/data' import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from './adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { getRootReducer, RootReducerType } from '../state/helpers'; +import { getPreloadedState, getRootReducer, RootReducerType } from '../state/helpers'; import { QueryVariableModel, VariableHide, VariableQueryEditorProps, VariableRefresh, VariableSort } from '../types'; -import { toVariablePayload } from '../state/types'; import { addVariable, changeVariableProp, @@ -27,6 +26,7 @@ import { updateVariableOptions } from './reducer'; import { addVariableEditorError, changeVariableEditorExtended, + initialVariableEditorState, removeVariableEditorError, setIdInEditor, } from '../editor/reducer'; @@ -40,6 +40,8 @@ import { setVariableQueryRunner, VariableQueryRunner } from './VariableQueryRunn import { setDataSourceSrv } from '@grafana/runtime'; import { variablesInitTransaction } from '../state/transactionReducer'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; const mocks: Record = { datasource: { @@ -93,8 +95,10 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable)), true); tester.thenNoActionsWhereDispatched(); }); @@ -109,16 +113,18 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable)), true); const option = createOption('A'); const update = { results: optionsMetrics, templatedRegex: '' }; tester.thenDispatchedActionsShouldEqual( - updateVariableOptions(toVariablePayload(variable, update)), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) ); }); }); @@ -132,21 +138,19 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable)), true); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); const update = { results: optionsMetrics, templatedRegex: '' }; - tester.thenDispatchedActionsPredicateShouldEqual((actions) => { - const [updateOptions, setCurrentAction] = actions; - const expectedNumberOfActions = 2; - - expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, update))); - expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option }))); - return actions.length === expectedNumberOfActions; - }); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) + ); }); }); @@ -159,23 +163,21 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenActionIsDispatched(setIdInEditor({ id: variable.id })) - .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenActionIsDispatched(toKeyedAction('key', setIdInEditor({ id: variable.id }))) + .whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable)), true); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); const update = { results: optionsMetrics, templatedRegex: '' }; - tester.thenDispatchedActionsPredicateShouldEqual((actions) => { - const [clearErrors, updateOptions, setCurrentAction] = actions; - const expectedNumberOfActions = 3; - - expect(clearErrors).toEqual(removeVariableEditorError({ errorProp: 'update' })); - expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, update))); - expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option }))); - return actions.length === expectedNumberOfActions; - }); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', removeVariableEditorError({ errorProp: 'update' })), + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))) + ); }); }); @@ -188,21 +190,19 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenActionIsDispatched(setIdInEditor({ id: variable.id })) - .whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable), 'search'), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenActionIsDispatched(toKeyedAction('key', setIdInEditor({ id: variable.id }))) + .whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable), 'search'), true); const update = { results: optionsMetrics, templatedRegex: '' }; - tester.thenDispatchedActionsPredicateShouldEqual((actions) => { - const [clearErrors, updateOptions] = actions; - const expectedNumberOfActions = 2; - - expect(clearErrors).toEqual(removeVariableEditorError({ errorProp: 'update' })); - expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, update))); - return actions.length === expectedNumberOfActions; - }); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', removeVariableEditorError({ errorProp: 'update' })), + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))) + ); }); }); @@ -216,19 +216,26 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenActionIsDispatched(setIdInEditor({ id: variable.id })) - .whenAsyncActionIsDispatched(updateOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenActionIsDispatched(toKeyedAction('key', setIdInEditor({ id: variable.id }))) + .whenAsyncActionIsDispatched(updateOptions(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { const expectedNumberOfActions = 5; - expect(dispatchedActions[0]).toEqual(variableStateFetching(toVariablePayload(variable))); - expect(dispatchedActions[1]).toEqual(removeVariableEditorError({ errorProp: 'update' })); - expect(dispatchedActions[2]).toEqual(addVariableEditorError({ errorProp: 'update', errorText: error.message })); + expect(dispatchedActions[0]).toEqual(toKeyedAction('key', variableStateFetching(toVariablePayload(variable)))); + expect(dispatchedActions[1]).toEqual(toKeyedAction('key', removeVariableEditorError({ errorProp: 'update' }))); + expect(dispatchedActions[2]).toEqual( + toKeyedAction('key', addVariableEditorError({ errorProp: 'update', errorText: error.message })) + ); expect(dispatchedActions[3]).toEqual( - variableStateFailed(toVariablePayload(variable, { error: { message: 'failed to fetch metrics' } })) + toKeyedAction( + 'key', + variableStateFailed(toVariablePayload(variable, { error: { message: 'failed to fetch metrics' } })) + ) ); expect(dispatchedActions[4].type).toEqual(notifyApp.type); expect(dispatchedActions[4].payload.title).toEqual('Templating [0]'); @@ -253,12 +260,17 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(initQueryVariableEditor(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); }); @@ -276,12 +288,17 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(initQueryVariableEditor(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); }); @@ -298,12 +315,17 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(initQueryVariableEditor(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched(initQueryVariableEditor(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); }); @@ -319,15 +341,20 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) .whenAsyncActionIsDispatched( - changeQueryVariableDataSource(toVariablePayload(variable), { uid: 'datasource' }), + changeQueryVariableDataSource(toKeyedVariableIdentifier(variable), { uid: 'datasource' }), true ); tester.thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); @@ -335,7 +362,14 @@ describe('query actions', () => { it('then correct actions are dispatched', async () => { const variable = createVariable({ datasource: { uid: 'other' } }); const editor = mocks.VariableQueryEditor; - const preloadedState: any = { templating: { editor: { extended: { dataSource: { type: 'previous' } } } } }; + const previousDataSource: any = { type: 'previous' }; + const templatingState = { + editor: { + ...initialVariableEditorState, + extended: { dataSource: previousDataSource, VariableQueryEditor: editor }, + }, + }; + const preloadedState = getPreloadedState('key', templatingState); mocks.pluginLoader.importDataSourcePlugin = jest.fn().mockResolvedValue({ components: { VariableQueryEditor: editor }, @@ -344,17 +378,20 @@ describe('query actions', () => { const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) .whenActionIsDispatched( - addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable })) + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) ) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) .whenAsyncActionIsDispatched( - changeQueryVariableDataSource(toVariablePayload(variable), { uid: 'datasource' }), + changeQueryVariableDataSource(toKeyedVariableIdentifier(variable), { uid: 'datasource' }), true ); tester.thenDispatchedActionsShouldEqual( - changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: '' })), - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction('key', changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: '' }))), + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); }); @@ -371,15 +408,20 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) .whenAsyncActionIsDispatched( - changeQueryVariableDataSource(toVariablePayload(variable), { uid: 'datasource' }), + changeQueryVariableDataSource(toKeyedVariableIdentifier(variable), { uid: 'datasource' }), true ); tester.thenDispatchedActionsShouldEqual( - changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + toKeyedAction( + 'key', + changeVariableEditorExtended({ dataSource: mocks.datasource, VariableQueryEditor: editor }) + ) ); }); }); @@ -396,21 +438,29 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched( + changeQueryVariableQuery(toKeyedVariableIdentifier(variable), query, definition), + true + ); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); const update = { results: optionsMetrics, templatedRegex: '' }; tester.thenDispatchedActionsShouldEqual( - removeVariableEditorError({ errorProp: 'query' }), - changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })), - changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })), - variableStateFetching(toVariablePayload(variable)), - updateVariableOptions(toVariablePayload(variable, update)), - setCurrentVariableValue(toVariablePayload(variable, { option })), - variableStateCompleted(toVariablePayload(variable)) + toKeyedAction('key', removeVariableEditorError({ errorProp: 'query' })), + toKeyedAction('key', changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })) + ), + toKeyedAction('key', variableStateFetching(toVariablePayload(variable))), + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction('key', variableStateCompleted(toVariablePayload(variable))) ); }); }); @@ -427,21 +477,29 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched( + changeQueryVariableQuery(toKeyedVariableIdentifier(variable), query, definition), + true + ); const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE); const update = { results: optionsMetrics, templatedRegex: '' }; tester.thenDispatchedActionsShouldEqual( - removeVariableEditorError({ errorProp: 'query' }), - changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })), - changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })), - variableStateFetching(toVariablePayload(variable)), - updateVariableOptions(toVariablePayload(variable, update)), - setCurrentVariableValue(toVariablePayload(variable, { option })), - variableStateCompleted(toVariablePayload(variable)) + toKeyedAction('key', removeVariableEditorError({ errorProp: 'query' })), + toKeyedAction('key', changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })) + ), + toKeyedAction('key', variableStateFetching(toVariablePayload(variable))), + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction('key', variableStateCompleted(toVariablePayload(variable))) ); }); }); @@ -457,21 +515,29 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched( + changeQueryVariableQuery(toKeyedVariableIdentifier(variable), query, definition), + true + ); const option = createOption('A'); const update = { results: optionsMetrics, templatedRegex: '' }; tester.thenDispatchedActionsShouldEqual( - removeVariableEditorError({ errorProp: 'query' }), - changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query })), - changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })), - variableStateFetching(toVariablePayload(variable)), - updateVariableOptions(toVariablePayload(variable, update)), - setCurrentVariableValue(toVariablePayload(variable, { option })), - variableStateCompleted(toVariablePayload(variable)) + toKeyedAction('key', removeVariableEditorError({ errorProp: 'query' })), + toKeyedAction('key', changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: query }))), + toKeyedAction( + 'key', + changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })) + ), + toKeyedAction('key', variableStateFetching(toVariablePayload(variable))), + toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update))), + toKeyedAction('key', setCurrentVariableValue(toVariablePayload(variable, { option }))), + toKeyedAction('key', variableStateCompleted(toVariablePayload(variable))) ); }); }); @@ -484,19 +550,20 @@ describe('query actions', () => { const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(changeQueryVariableQuery(toVariablePayload(variable), query, definition), true); + .whenActionIsDispatched( + toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' }))) + .whenAsyncActionIsDispatched( + changeQueryVariableQuery(toKeyedVariableIdentifier(variable), query, definition), + true + ); const errorText = 'Query cannot contain a reference to itself. Variable: $' + variable.name; - tester.thenDispatchedActionsPredicateShouldEqual((actions) => { - const [editorError] = actions; - const expectedNumberOfActions = 1; - - expect(editorError).toEqual(addVariableEditorError({ errorProp: 'query', errorText })); - return actions.length === expectedNumberOfActions; - }); + tester.thenDispatchedActionsShouldEqual( + toKeyedAction('key', addVariableEditorError({ errorProp: 'query', errorText })) + ); }); }); @@ -672,6 +739,7 @@ function createVariable(extend?: Partial): QueryVariableMode return { type: 'query', id: '0', + rootStateKey: 'key', global: false, current: createOption(''), options: [], diff --git a/public/app/features/variables/query/actions.ts b/public/app/features/variables/query/actions.ts index 8c9f9c0fb6f..45d42c5fec8 100644 --- a/public/app/features/variables/query/actions.ts +++ b/public/app/features/variables/query/actions.ts @@ -5,30 +5,32 @@ import { DataSourceRef } from '@grafana/data'; import { updateOptions } from '../state/actions'; import { QueryVariableModel } from '../types'; import { ThunkResult } from '../../../types'; -import { getVariable } from '../state/selectors'; +import { getVariable, getVariablesState } from '../state/selectors'; import { addVariableEditorError, changeVariableEditorExtended, removeVariableEditorError } from '../editor/reducer'; import { changeVariableProp } from '../state/sharedReducer'; -import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { getVariableQueryEditor } from '../editor/getVariableQueryEditor'; import { getVariableQueryRunner } from './VariableQueryRunner'; import { variableQueryObserver } from './variableQueryObserver'; -import { hasOngoingTransaction } from '../utils'; +import { hasOngoingTransaction, toKeyedVariableIdentifier, toVariablePayload } from '../utils'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; import { getQueryVariableEditorState } from '../editor/selectors'; export const updateQueryVariableOptions = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, searchFilter?: string ): ThunkResult => { return async (dispatch, getState) => { try { - if (!hasOngoingTransaction(getState())) { + const { rootStateKey } = identifier; + if (!hasOngoingTransaction(rootStateKey, getState())) { // we might have cancelled a batch so then variable state is removed return; } - const variableInState = getVariable(identifier.id, getState()); - if (getState().templating.editor.id === variableInState.id) { - dispatch(removeVariableEditorError({ errorProp: 'update' })); + const variableInState = getVariable(identifier, getState()); + if (getVariablesState(rootStateKey, getState()).editor.id === variableInState.id) { + dispatch(toKeyedAction(rootStateKey, removeVariableEditorError({ errorProp: 'update' }))); } const datasource = await getDataSourceSrv().get(variableInState.datasource ?? ''); @@ -44,8 +46,11 @@ export const updateQueryVariableOptions = ( }); } catch (err) { const error = toDataQueryError(err); - if (getState().templating.editor.id === identifier.id) { - dispatch(addVariableEditorError({ errorProp: 'update', errorText: error.message })); + const { rootStateKey } = identifier; + if (getVariablesState(rootStateKey, getState()).editor.id === identifier.id) { + dispatch( + toKeyedAction(rootStateKey, addVariableEditorError({ errorProp: 'update', errorText: error.message })) + ); } throw error; @@ -54,33 +59,43 @@ export const updateQueryVariableOptions = ( }; export const initQueryVariableEditor = - (identifier: VariableIdentifier): ThunkResult => + (identifier: KeyedVariableIdentifier): ThunkResult => async (dispatch, getState) => { - const variable = getVariable(identifier.id, getState()); - await dispatch(changeQueryVariableDataSource(toVariableIdentifier(variable), variable.datasource)); + const variable = getVariable(identifier, getState()); + await dispatch(changeQueryVariableDataSource(toKeyedVariableIdentifier(variable), variable.datasource)); }; export const changeQueryVariableDataSource = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, name: DataSourceRef | null ): ThunkResult => { return async (dispatch, getState) => { try { - const extendedEditorState = getQueryVariableEditorState(getState().templating.editor); + const { rootStateKey } = identifier; + const { editor } = getVariablesState(rootStateKey, getState()); + const extendedEditorState = getQueryVariableEditorState(editor); const previousDatasource = extendedEditorState?.dataSource; const dataSource = await getDataSourceSrv().get(name ?? ''); if (previousDatasource && previousDatasource.type !== dataSource?.type) { - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: '' }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: '' })) + ) + ); } const VariableQueryEditor = await getVariableQueryEditor(dataSource); dispatch( - changeVariableEditorExtended({ - dataSource: dataSource, - VariableQueryEditor: VariableQueryEditor, - }) + toKeyedAction( + rootStateKey, + changeVariableEditorExtended({ + dataSource, + VariableQueryEditor, + }) + ) ); } catch (err) { console.error(err); @@ -89,22 +104,38 @@ export const changeQueryVariableDataSource = ( }; export const changeQueryVariableQuery = - (identifier: VariableIdentifier, query: any, definition?: string): ThunkResult => + (identifier: KeyedVariableIdentifier, query: any, definition?: string): ThunkResult => async (dispatch, getState) => { - const variableInState = getVariable(identifier.id, getState()); + const { rootStateKey } = identifier; + const variableInState = getVariable(identifier, getState()); if (hasSelfReferencingQuery(variableInState.name, query)) { const errorText = 'Query cannot contain a reference to itself. Variable: $' + variableInState.name; - dispatch(addVariableEditorError({ errorProp: 'query', errorText })); + dispatch(toKeyedAction(rootStateKey, addVariableEditorError({ errorProp: 'query', errorText }))); return; } - dispatch(removeVariableEditorError({ errorProp: 'query' })); - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: query }))); + dispatch(toKeyedAction(rootStateKey, removeVariableEditorError({ errorProp: 'query' }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: query })) + ) + ); if (definition) { - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'definition', propValue: definition }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName: 'definition', propValue: definition })) + ) + ); } else if (typeof query === 'string') { - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'definition', propValue: query }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName: 'definition', propValue: query })) + ) + ); } await dispatch(updateOptions(identifier)); diff --git a/public/app/features/variables/query/adapter.ts b/public/app/features/variables/query/adapter.ts index 7edd533e685..06a2442ab69 100644 --- a/public/app/features/variables/query/adapter.ts +++ b/public/app/features/variables/query/adapter.ts @@ -7,8 +7,7 @@ import { setOptionAsCurrent, setOptionFromUrl } from '../state/actions'; import { VariableAdapter } from '../adapters'; import { QueryVariableEditor } from './QueryVariableEditor'; import { updateQueryVariableOptions } from './actions'; -import { toVariableIdentifier } from '../state/types'; -import { containsVariable, isAllVariable } from '../utils'; +import { containsVariable, isAllVariable, toKeyedVariableIdentifier } from '../utils'; import { optionPickerFactory } from '../pickers'; import { ALL_VARIABLE_TEXT } from '../constants'; @@ -25,16 +24,16 @@ export const createQueryVariableAdapter = (): VariableAdapter { - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(setOptionFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable, searchFilter) => { - await dispatch(updateQueryVariableOptions(toVariableIdentifier(variable), searchFilter)); + await dispatch(updateQueryVariableOptions(toKeyedVariableIdentifier(variable), searchFilter)); }, getSaveModel: (variable) => { - const { index, id, state, global, queryValue, ...rest } = cloneDeep(variable); + const { index, id, state, global, queryValue, rootStateKey, ...rest } = cloneDeep(variable); // remove options if (variable.refresh !== VariableRefresh.never) { return { ...rest, options: [] }; diff --git a/public/app/features/variables/query/operators.test.ts b/public/app/features/variables/query/operators.test.ts index fa7eba38e49..ad3cdcce819 100644 --- a/public/app/features/variables/query/operators.test.ts +++ b/public/app/features/variables/query/operators.test.ts @@ -3,6 +3,7 @@ import { queryBuilder } from '../shared/testing/builders'; import { FieldType, toDataFrame } from '@grafana/data'; import { updateVariableOptions } from './reducer'; import { areMetricFindValues, toMetricFindValues, updateOptionsState, validateVariableSelection } from './operators'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; describe('operators', () => { beforeEach(() => { @@ -12,7 +13,7 @@ describe('operators', () => { describe('validateVariableSelection', () => { describe('when called', () => { it('then the correct observable should be created', async () => { - const variable = queryBuilder().withId('query').build(); + const variable = queryBuilder().withId('query').withRootStateKey('key').build(); const dispatch = jest.fn().mockResolvedValue({}); const observable = of(undefined).pipe(validateVariableSelection({ variable, dispatch })); @@ -27,7 +28,7 @@ describe('operators', () => { describe('updateOptionsState', () => { describe('when called', () => { it('then the correct observable should be created', async () => { - const variable = queryBuilder().withId('query').build(); + const variable = queryBuilder().withId('query').withRootStateKey('key').build(); const dispatch = jest.fn(); const getTemplatedRegexFunc = jest.fn().mockReturnValue('getTemplatedRegexFunc result'); @@ -39,11 +40,14 @@ describe('operators', () => { expect(getTemplatedRegexFunc).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledWith( - updateVariableOptions({ - id: 'query', - type: 'query', - data: { results: [{ text: 'A' }], templatedRegex: 'getTemplatedRegexFunc result' }, - }) + toKeyedAction( + 'key', + updateVariableOptions({ + id: 'query', + type: 'query', + data: { results: [{ text: 'A' }], templatedRegex: 'getTemplatedRegexFunc result' }, + }) + ) ); }); }); diff --git a/public/app/features/variables/query/operators.ts b/public/app/features/variables/query/operators.ts index 3f57960c529..2085df4bd23 100644 --- a/public/app/features/variables/query/operators.ts +++ b/public/app/features/variables/query/operators.ts @@ -3,12 +3,12 @@ import { map, mergeMap } from 'rxjs/operators'; import { QueryVariableModel } from '../types'; import { ThunkDispatch } from '../../../types'; -import { toVariableIdentifier, toVariablePayload } from '../state/types'; import { validateVariableSelectionState } from '../state/actions'; import { FieldType, getFieldDisplayName, isDataFrame, MetricFindValue, PanelData } from '@grafana/data'; import { updateVariableOptions } from './reducer'; -import { getTemplatedRegex } from '../utils'; +import { getTemplatedRegex, toKeyedVariableIdentifier, toVariablePayload } from '../utils'; import { getProcessedDataFrames } from 'app/features/query/state/runRequest'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; export function toMetricFindValues(): OperatorFunction { return (source) => @@ -102,9 +102,13 @@ export function updateOptionsState(args: { source.pipe( map((results) => { const { variable, dispatch, getTemplatedRegexFunc } = args; + if (!variable.rootStateKey) { + console.error('updateOptionsState: variable.rootStateKey is not defined'); + return; + } const templatedRegex = getTemplatedRegexFunc(variable); const payload = toVariablePayload(variable, { results, templatedRegex }); - dispatch(updateVariableOptions(payload)); + dispatch(toKeyedAction(variable.rootStateKey, updateVariableOptions(payload))); }) ); } @@ -124,7 +128,7 @@ export function validateVariableSelection(args: { // So after search and selection the current value is already update so no setValue, refresh and URL update is performed // The if statement below fixes https://github.com/grafana/grafana/issues/25671 if (!searchFilter) { - return from(dispatch(validateVariableSelectionState(toVariableIdentifier(variable)))); + return from(dispatch(validateVariableSelectionState(toKeyedVariableIdentifier(variable)))); } return of(); diff --git a/public/app/features/variables/query/reducer.test.ts b/public/app/features/variables/query/reducer.test.ts index dd143490b2c..106f8c50903 100644 --- a/public/app/features/variables/query/reducer.test.ts +++ b/public/app/features/variables/query/reducer.test.ts @@ -1,3 +1,6 @@ +import { cloneDeep } from 'lodash'; +import { MetricFindValue } from '@grafana/data'; + import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { metricNamesToVariableValues, @@ -6,11 +9,10 @@ import { updateVariableOptions, } from './reducer'; import { QueryVariableModel, VariableSort } from '../types'; -import { cloneDeep } from 'lodash'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { createQueryVariableAdapter } from './adapter'; -import { MetricFindValue } from '@grafana/data'; +import { toVariablePayload } from '../utils'; describe('queryVariableReducer', () => { const adapter = createQueryVariableAdapter(); diff --git a/public/app/features/variables/query/reducer.ts b/public/app/features/variables/query/reducer.ts index 03e2e0d89e8..e4afc5c609d 100644 --- a/public/app/features/variables/query/reducer.ts +++ b/public/app/features/variables/query/reducer.ts @@ -4,8 +4,9 @@ import { MetricFindValue, stringToJsRegex } from '@grafana/data'; import { initialVariableModelState, QueryVariableModel, VariableOption, VariableRefresh, VariableSort } from '../types'; -import { getInstanceState, initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, NONE_VARIABLE_TEXT, NONE_VARIABLE_VALUE } from '../constants'; +import { getInstanceState } from '../state/selectors'; interface VariableOptionsUpdate { templatedRegex: string; diff --git a/public/app/features/variables/query/variableQueryObserver.test.ts b/public/app/features/variables/query/variableQueryObserver.test.ts index 7b2e41fc8c8..8232e74df55 100644 --- a/public/app/features/variables/query/variableQueryObserver.test.ts +++ b/public/app/features/variables/query/variableQueryObserver.test.ts @@ -1,6 +1,6 @@ import { variableQueryObserver } from './variableQueryObserver'; import { LoadingState } from '@grafana/data'; -import { VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { UpdateOptionsResults } from './VariableQueryRunner'; function getTestContext(args: { next?: UpdateOptionsResults; error?: any; complete?: boolean }) { @@ -27,7 +27,7 @@ function getTestContext(args: { next?: UpdateOptionsResults; error?: any; comple return { resolve, reject, subscription, observer }; } -const identifier: VariableIdentifier = { id: 'id', type: 'query' }; +const identifier: KeyedVariableIdentifier = { id: 'id', type: 'query', rootStateKey: 'uid' }; describe('variableQueryObserver', () => { describe('when receiving a Done state', () => { diff --git a/public/app/features/variables/shared/testing/variableBuilder.ts b/public/app/features/variables/shared/testing/variableBuilder.ts index a1bae77fc59..f966605787b 100644 --- a/public/app/features/variables/shared/testing/variableBuilder.ts +++ b/public/app/features/variables/shared/testing/variableBuilder.ts @@ -19,6 +19,11 @@ export class VariableBuilder { return this; } + withRootStateKey(key: string) { + this.variable.rootStateKey = key; + return this; + } + build(): T { return this.variable; } diff --git a/public/app/features/variables/state/actions.test.ts b/public/app/features/variables/state/actions.test.ts index 1b90b4d77ce..81b9d40b130 100644 --- a/public/app/features/variables/state/actions.test.ts +++ b/public/app/features/variables/state/actions.test.ts @@ -1,13 +1,12 @@ import { AnyAction } from 'redux'; -import { getTemplatingRootReducer, TemplatingReducerType } from './helpers'; +import { getPreloadedState, getTemplatingRootReducer, TemplatingReducerType } from './helpers'; import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from '../query/adapter'; import { createCustomVariableAdapter } from '../custom/adapter'; import { createTextBoxVariableAdapter } from '../textbox/adapter'; import { createConstantVariableAdapter } from '../constant/adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { TemplatingState } from 'app/features/variables/state/reducers'; import { cancelVariables, changeVariableMultiValue, @@ -27,7 +26,6 @@ import { variableStateFetching, variableStateNotStarted, } from './sharedReducer'; -import { toVariableIdentifier, toVariablePayload } from './types'; import { constantBuilder, customBuilder, @@ -52,6 +50,8 @@ import * as runtime from '@grafana/runtime'; import { LoadingState } from '@grafana/data'; import { toAsyncOfResult } from '../../query/state/DashboardQueryRunner/testHelpers'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, NEW_VARIABLE_ID } from '../constants'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; variableAdapters.setInit(() => [ createQueryVariableAdapter(), @@ -81,44 +81,66 @@ runtime.setDataSourceSrv({ describe('shared actions', () => { describe('when initDashboardTemplating is dispatched', () => { it('then correct actions are dispatched', () => { + const key = 'key'; const query = queryBuilder().build(); const constant = constantBuilder().build(); const datasource = datasourceBuilder().build(); const custom = customBuilder().build(); const textbox = textboxBuilder().build(); const list = [query, constant, datasource, custom, textbox]; + const dashboard: any = { templating: { list } }; - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(initDashboardTemplating(list)) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) .thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { expect(dispatchedActions.length).toEqual(8); expect(dispatchedActions[0]).toEqual( - addVariable(toVariablePayload(query, { global: false, index: 0, model: query })) + toKeyedAction(key, addVariable(toVariablePayload(query, { global: false, index: 0, model: query }))) ); expect(dispatchedActions[1]).toEqual( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) ); expect(dispatchedActions[2]).toEqual( - addVariable(toVariablePayload(custom, { global: false, index: 2, model: custom })) + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 2, model: custom }))) ); expect(dispatchedActions[3]).toEqual( - addVariable(toVariablePayload(textbox, { global: false, index: 3, model: textbox })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 3, model: textbox }))) ); // because uuid are dynamic we need to get the uuid from the resulting state // an alternative would be to add our own uuids in the model above instead expect(dispatchedActions[4]).toEqual( - variableStateNotStarted(toVariablePayload({ ...query, id: dispatchedActions[4].payload.id })) + toKeyedAction( + key, + variableStateNotStarted( + toVariablePayload({ ...query, id: dispatchedActions[4].payload.action.payload.id }) + ) + ) ); expect(dispatchedActions[5]).toEqual( - variableStateNotStarted(toVariablePayload({ ...constant, id: dispatchedActions[5].payload.id })) + toKeyedAction( + key, + variableStateNotStarted( + toVariablePayload({ ...constant, id: dispatchedActions[5].payload.action.payload.id }) + ) + ) ); expect(dispatchedActions[6]).toEqual( - variableStateNotStarted(toVariablePayload({ ...custom, id: dispatchedActions[6].payload.id })) + toKeyedAction( + key, + variableStateNotStarted( + toVariablePayload({ ...custom, id: dispatchedActions[6].payload.action.payload.id }) + ) + ) ); expect(dispatchedActions[7]).toEqual( - variableStateNotStarted(toVariablePayload({ ...textbox, id: dispatchedActions[7].payload.id })) + toKeyedAction( + key, + variableStateNotStarted( + toVariablePayload({ ...textbox, id: dispatchedActions[7].payload.action.payload.id }) + ) + ) ); return true; @@ -128,52 +150,71 @@ describe('shared actions', () => { describe('when processVariables is dispatched', () => { it('then correct actions are dispatched', async () => { + const key = 'key'; const query = queryBuilder().build(); const constant = constantBuilder().build(); const datasource = datasourceBuilder().build(); const custom = customBuilder().build(); const textbox = textboxBuilder().build(); const list = [query, constant, datasource, custom, textbox]; - const preloadedState = { - templating: {} as unknown as TemplatingState, - }; + const dashboard: any = { templating: { list } }; + const preloadedState = getPreloadedState(key, {}); const locationService: any = { getSearchObject: () => ({}) }; runtime.setLocationService(locationService); const variableQueryRunner: any = { cancelRequest: jest.fn(), queueRequest: jest.fn(), - getResponse: () => toAsyncOfResult({ state: LoadingState.Done, identifier: toVariableIdentifier(query) }), + getResponse: () => toAsyncOfResult({ state: LoadingState.Done, identifier: toKeyedVariableIdentifier(query) }), destroy: jest.fn(), }; setVariableQueryRunner(variableQueryRunner); const tester = await reduxTester({ preloadedState }) .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariables(), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariables(key), true); await tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { expect(dispatchedActions.length).toEqual(5); expect(dispatchedActions[0]).toEqual( - variableStateFetching(toVariablePayload({ ...query, id: dispatchedActions[0].payload.id })) + toKeyedAction( + key, + variableStateFetching(toVariablePayload({ ...query, id: dispatchedActions[0].payload.action.payload.id })) + ) ); expect(dispatchedActions[1]).toEqual( - variableStateCompleted(toVariablePayload({ ...constant, id: dispatchedActions[1].payload.id })) + toKeyedAction( + key, + variableStateCompleted( + toVariablePayload({ ...constant, id: dispatchedActions[1].payload.action.payload.id }) + ) + ) ); expect(dispatchedActions[2]).toEqual( - variableStateCompleted(toVariablePayload({ ...custom, id: dispatchedActions[2].payload.id })) + toKeyedAction( + key, + variableStateCompleted(toVariablePayload({ ...custom, id: dispatchedActions[2].payload.action.payload.id })) + ) ); expect(dispatchedActions[3]).toEqual( - variableStateCompleted(toVariablePayload({ ...textbox, id: dispatchedActions[3].payload.id })) + toKeyedAction( + key, + variableStateCompleted( + toVariablePayload({ ...textbox, id: dispatchedActions[3].payload.action.payload.id }) + ) + ) ); expect(dispatchedActions[4]).toEqual( - variableStateCompleted(toVariablePayload({ ...query, id: dispatchedActions[4].payload.id })) + toKeyedAction( + key, + variableStateCompleted(toVariablePayload({ ...query, id: dispatchedActions[4].payload.action.payload.id })) + ) ); return true; @@ -183,8 +224,10 @@ describe('shared actions', () => { // Fix for https://github.com/grafana/grafana/issues/28791 it('fix for https://github.com/grafana/grafana/issues/28791', async () => { setVariableQueryRunner(new VariableQueryRunner()); + const key = 'key'; const stats = queryBuilder() .withId('stats') + .withRootStateKey(key) .withName('stats') .withQuery('stats.*') .withRefresh(VariableRefresh.onDashboardLoad) @@ -195,6 +238,7 @@ describe('shared actions', () => { const substats = queryBuilder() .withId('substats') + .withRootStateKey(key) .withName('substats') .withQuery('stats.$stats.*') .withRefresh(VariableRefresh.onDashboardLoad) @@ -204,45 +248,64 @@ describe('shared actions', () => { .build(); const list = [stats, substats]; + const dashboard: any = { templating: { list } }; const query = { orgId: '1', 'var-stats': 'response', 'var-substats': ALL_VARIABLE_TEXT }; const locationService: any = { getSearchObject: () => query }; runtime.setLocationService(locationService); - const preloadedState = { - templating: {} as unknown as TemplatingState, - }; + const preloadedState = getPreloadedState(key, {}); const tester = await reduxTester({ preloadedState }) .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariables(), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariables(key), true); await tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload(stats)), - updateVariableOptions( - toVariablePayload(stats, { results: [{ text: 'responses' }, { text: 'timers' }], templatedRegex: '' }) + toKeyedAction(key, variableStateFetching(toVariablePayload(stats))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload(stats, { results: [{ text: 'responses' }, { text: 'timers' }], templatedRegex: '' }) + ) ), - setCurrentVariableValue( - toVariablePayload(stats, { option: { text: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE, selected: false } }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload(stats, { + option: { text: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE, selected: false }, + }) + ) ), - variableStateCompleted(toVariablePayload(stats)), - setCurrentVariableValue( - toVariablePayload(stats, { option: { text: ['response'], value: ['response'], selected: false } }) + toKeyedAction(key, variableStateCompleted(toVariablePayload(stats))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload(stats, { option: { text: ['response'], value: ['response'], selected: false } }) + ) ), - variableStateFetching(toVariablePayload(substats)), - updateVariableOptions( - toVariablePayload(substats, { results: [{ text: '200' }, { text: '500' }], templatedRegex: '' }) + toKeyedAction(key, variableStateFetching(toVariablePayload(substats))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload(substats, { results: [{ text: '200' }, { text: '500' }], templatedRegex: '' }) + ) ), - setCurrentVariableValue( - toVariablePayload(substats, { - option: { text: [ALL_VARIABLE_TEXT], value: [ALL_VARIABLE_VALUE], selected: true }, - }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload(substats, { + option: { text: [ALL_VARIABLE_TEXT], value: [ALL_VARIABLE_VALUE], selected: true }, + }) + ) ), - variableStateCompleted(toVariablePayload(substats)), - setCurrentVariableValue( - toVariablePayload(substats, { - option: { text: [ALL_VARIABLE_TEXT], value: [ALL_VARIABLE_VALUE], selected: false }, - }) + toKeyedAction(key, variableStateCompleted(toVariablePayload(substats))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload(substats, { + option: { text: [ALL_VARIABLE_TEXT], value: [ALL_VARIABLE_VALUE], selected: false }, + }) + ) ) ); }); @@ -260,36 +323,42 @@ describe('shared actions', () => { ${undefined} | ${'B'} | ${undefined} | ${'should not dispatch setCurrentVariableValue'} `('then correct actions are dispatched', async ({ withOptions, withCurrent, defaultValue, expected }) => { let custom; - + const key = 'key'; if (!withOptions) { - custom = customBuilder().withId('0').withCurrent(withCurrent).withoutOptions().build(); + custom = customBuilder().withId('0').withRootStateKey(key).withCurrent(withCurrent).withoutOptions().build(); } else { custom = customBuilder() .withId('0') + .withRootStateKey(key) .withOptions(...withOptions) .withCurrent(withCurrent) .build(); } - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) .whenAsyncActionIsDispatched( - validateVariableSelectionState(toVariableIdentifier(custom), defaultValue), + validateVariableSelectionState(toKeyedVariableIdentifier(custom), defaultValue), true ); await tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { - const expectedActions: AnyAction[] = !withOptions - ? [] - : [ - setCurrentVariableValue( - toVariablePayload( - { type: 'custom', id: '0' }, - { option: { text: expected, value: expected, selected: false } } + const expectedActions: AnyAction[] = withOptions + ? [ + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: '0' }, + { option: { text: expected, value: expected, selected: false } } + ) ) ), - ]; + ] + : []; expect(dispatchedActions).toEqual(expectedActions); return true; }); @@ -309,37 +378,49 @@ describe('shared actions', () => { 'then correct actions are dispatched', async ({ withOptions, withCurrent, defaultValue, expectedText, expectedSelected }) => { let custom; - + const key = 'key'; if (!withOptions) { - custom = customBuilder().withId('0').withMulti().withCurrent(withCurrent).withoutOptions().build(); + custom = customBuilder() + .withId('0') + .withRootStateKey(key) + .withMulti() + .withCurrent(withCurrent) + .withoutOptions() + .build(); } else { custom = customBuilder() .withId('0') + .withRootStateKey(key) .withMulti() .withOptions(...withOptions) .withCurrent(withCurrent) .build(); } - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) .whenAsyncActionIsDispatched( - validateVariableSelectionState(toVariableIdentifier(custom), defaultValue), + validateVariableSelectionState(toKeyedVariableIdentifier(custom), defaultValue), true ); await tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { - const expectedActions: AnyAction[] = !withOptions - ? [] - : [ - setCurrentVariableValue( - toVariablePayload( - { type: 'custom', id: '0' }, - { option: { text: expectedText, value: expectedText, selected: expectedSelected } } + const expectedActions: AnyAction[] = withOptions + ? [ + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: '0' }, + { option: { text: expectedText, value: expectedText, selected: expectedSelected } } + ) ) ), - ]; + ] + : []; expect(dispatchedActions).toEqual(expectedActions); return true; }); @@ -351,154 +432,196 @@ describe('shared actions', () => { describe('changeVariableName', () => { describe('when changeVariableName is dispatched with the same name', () => { it('then the correct actions are dispatched', () => { - const textbox = textboxBuilder().withId('textbox').withName('textbox').build(); - const constant = constantBuilder().withId('constant').withName('constant').build(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId('constant').withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), constant.name), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), constant.name), true) .thenDispatchedActionsShouldEqual( - changeVariableNameSucceeded({ type: 'constant', id: 'constant', data: { newName: 'constant' } }) + toKeyedAction( + key, + changeVariableNameSucceeded({ type: 'constant', id: 'constant', data: { newName: 'constant' } }) + ) ); }); }); describe('when changeVariableName is dispatched with an unique name', () => { it('then the correct actions are dispatched', () => { - const textbox = textboxBuilder().withId('textbox').withName('textbox').build(); - const constant = constantBuilder().withId('constant').withName('constant').build(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId('constant').withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), 'constant1'), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), 'constant1'), true) .thenDispatchedActionsShouldEqual( - addVariable({ - type: 'constant', - id: 'constant1', - data: { - global: false, - index: 1, - model: { - ...constant, - name: 'constant1', - id: 'constant1', + toKeyedAction( + key, + addVariable({ + type: 'constant', + id: 'constant1', + data: { global: false, index: 1, - current: { selected: true, text: '', value: '' }, - options: [{ selected: true, text: '', value: '' }], - } as ConstantVariableModel, - }, - }), - changeVariableNameSucceeded({ type: 'constant', id: 'constant1', data: { newName: 'constant1' } }), - setIdInEditor({ id: 'constant1' }), - removeVariable({ type: 'constant', id: 'constant', data: { reIndex: false } }) + model: { + ...constant, + name: 'constant1', + id: 'constant1', + global: false, + index: 1, + current: { selected: true, text: '', value: '' }, + options: [{ selected: true, text: '', value: '' }], + } as ConstantVariableModel, + }, + }) + ), + toKeyedAction( + key, + changeVariableNameSucceeded({ type: 'constant', id: 'constant1', data: { newName: 'constant1' } }) + ), + toKeyedAction(key, setIdInEditor({ id: 'constant1' })), + toKeyedAction(key, removeVariable({ type: 'constant', id: 'constant', data: { reIndex: false } })) ); }); }); describe('when changeVariableName is dispatched with an unique name for a new variable', () => { it('then the correct actions are dispatched', () => { - const textbox = textboxBuilder().withId('textbox').withName('textbox').build(); - const constant = constantBuilder().withId(NEW_VARIABLE_ID).withName('constant').build(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId(NEW_VARIABLE_ID).withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), 'constant1'), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), 'constant1'), true) .thenDispatchedActionsShouldEqual( - addVariable({ - type: 'constant', - id: 'constant1', - data: { - global: false, - index: 1, - model: { - ...constant, - name: 'constant1', - id: 'constant1', + toKeyedAction( + key, + addVariable({ + type: 'constant', + id: 'constant1', + data: { global: false, index: 1, - current: { selected: true, text: '', value: '' }, - options: [{ selected: true, text: '', value: '' }], - } as ConstantVariableModel, - }, - }), - changeVariableNameSucceeded({ type: 'constant', id: 'constant1', data: { newName: 'constant1' } }), - setIdInEditor({ id: 'constant1' }), - removeVariable({ type: 'constant', id: NEW_VARIABLE_ID, data: { reIndex: false } }) + model: { + ...constant, + name: 'constant1', + id: 'constant1', + global: false, + index: 1, + current: { selected: true, text: '', value: '' }, + options: [{ selected: true, text: '', value: '' }], + } as ConstantVariableModel, + }, + }) + ), + toKeyedAction( + key, + changeVariableNameSucceeded({ type: 'constant', id: 'constant1', data: { newName: 'constant1' } }) + ), + toKeyedAction(key, setIdInEditor({ id: 'constant1' })), + toKeyedAction(key, removeVariable({ type: 'constant', id: NEW_VARIABLE_ID, data: { reIndex: false } })) ); }); }); describe('when changeVariableName is dispatched with __newName', () => { it('then the correct actions are dispatched', () => { - const textbox = textboxBuilder().withId('textbox').withName('textbox').build(); - const constant = constantBuilder().withId('constant').withName('constant').build(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId('constant').withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), '__newName'), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), '__newName'), true) .thenDispatchedActionsShouldEqual( - changeVariableNameFailed({ - newName: '__newName', - errorText: "Template names cannot begin with '__', that's reserved for Grafana's global variables", - }) + toKeyedAction( + key, + changeVariableNameFailed({ + newName: '__newName', + errorText: "Template names cannot begin with '__', that's reserved for Grafana's global variables", + }) + ) ); }); }); describe('when changeVariableName is dispatched with illegal characters', () => { it('then the correct actions are dispatched', () => { - const textbox = textboxBuilder().withId('textbox').withName('textbox').build(); - const constant = constantBuilder().withId('constant').withName('constant').build(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId('constant').withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), '#constant!'), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), '#constant!'), true) .thenDispatchedActionsShouldEqual( - changeVariableNameFailed({ - newName: '#constant!', - errorText: 'Only word and digit characters are allowed in variable names', - }) + toKeyedAction( + key, + changeVariableNameFailed({ + newName: '#constant!', + errorText: 'Only word and digit characters are allowed in variable names', + }) + ) ); }); }); describe('when changeVariableName is dispatched with a name that is already used', () => { it('then the correct actions are dispatched', () => { - const textbox = textboxBuilder().withId('textbox').withName('textbox').build(); - const constant = constantBuilder().withId('constant').withName('constant').build(); + const key = 'key'; + const textbox = textboxBuilder().withId('textbox').withRootStateKey(key).withName('textbox').build(); + const constant = constantBuilder().withId('constant').withRootStateKey(key).withName('constant').build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) .whenActionIsDispatched( - addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(textbox, { global: false, index: 0, model: textbox }))) ) - .whenActionIsDispatched(changeVariableName(toVariableIdentifier(constant), 'textbox'), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 1, model: constant }))) + ) + .whenActionIsDispatched(changeVariableName(toKeyedVariableIdentifier(constant), 'textbox'), true) .thenDispatchedActionsShouldEqual( - changeVariableNameFailed({ - newName: 'textbox', - errorText: 'Variable with the same name already exists', - }) + toKeyedAction( + key, + changeVariableNameFailed({ + newName: 'textbox', + errorText: 'Variable with the same name already exists', + }) + ) ); }); }); @@ -507,28 +630,42 @@ describe('shared actions', () => { describe('changeVariableMultiValue', () => { describe('when changeVariableMultiValue is dispatched for variable with multi enabled', () => { it('then correct actions are dispatched', () => { - const custom = customBuilder().withId('custom').withMulti(true).withCurrent(['A'], ['A']).build(); + const key = 'key'; + const custom = customBuilder() + .withId('custom') + .withRootStateKey(key) + .withMulti(true) + .withCurrent(['A'], ['A']) + .build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenActionIsDispatched(changeVariableMultiValue(toVariableIdentifier(custom), false), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenActionIsDispatched(changeVariableMultiValue(toKeyedVariableIdentifier(custom), false), true) .thenDispatchedActionsShouldEqual( - changeVariableProp( - toVariablePayload(custom, { - propName: 'multi', - propValue: false, - }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(custom, { + propName: 'multi', + propValue: false, + }) + ) ), - changeVariableProp( - toVariablePayload(custom, { - propName: 'current', - propValue: { - value: 'A', - text: 'A', - selected: true, - }, - }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(custom, { + propName: 'current', + propValue: { + value: 'A', + text: 'A', + selected: true, + }, + }) + ) ) ); }); @@ -536,28 +673,42 @@ describe('shared actions', () => { describe('when changeVariableMultiValue is dispatched for variable with multi disabled', () => { it('then correct actions are dispatched', () => { - const custom = customBuilder().withId('custom').withMulti(false).withCurrent(['A'], ['A']).build(); + const key = 'key'; + const custom = customBuilder() + .withId('custom') + .withRootStateKey(key) + .withMulti(false) + .withCurrent(['A'], ['A']) + .build(); - reduxTester<{ templating: TemplatingState }>() + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenActionIsDispatched(changeVariableMultiValue(toVariableIdentifier(custom), true), true) + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenActionIsDispatched(changeVariableMultiValue(toKeyedVariableIdentifier(custom), true), true) .thenDispatchedActionsShouldEqual( - changeVariableProp( - toVariablePayload(custom, { - propName: 'multi', - propValue: true, - }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(custom, { + propName: 'multi', + propValue: true, + }) + ) ), - changeVariableProp( - toVariablePayload(custom, { - propName: 'current', - propValue: { - value: ['A'], - text: ['A'], - selected: true, - }, - }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(custom, { + propName: 'current', + propValue: { + value: ['A'], + text: ['A'], + selected: true, + }, + }) + ) ) ); }); @@ -567,14 +718,15 @@ describe('shared actions', () => { describe('cleanUpVariables', () => { describe('when called', () => { it('then correct actions are dispatched', async () => { - reduxTester<{ templating: TemplatingState }>() + const key = 'key'; + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(cleanUpVariables()) + .whenActionIsDispatched(cleanUpVariables(key)) .thenDispatchedActionsShouldEqual( - cleanVariables(), - cleanEditorState(), - cleanPickerState(), - variablesClearTransaction() + toKeyedAction(key, cleanVariables()), + toKeyedAction(key, cleanEditorState()), + toKeyedAction(key, cleanPickerState()), + toKeyedAction(key, variablesClearTransaction()) ); }); }); @@ -588,14 +740,15 @@ describe('shared actions', () => { describe('when called', () => { it('then cancelAllInFlightRequests should be called and correct actions are dispatched', async () => { - reduxTester<{ templating: TemplatingState }>() + const key = 'key'; + reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(cancelVariables({ getBackendSrv: () => backendSrvMock })) + .whenActionIsDispatched(cancelVariables(key, { getBackendSrv: () => backendSrvMock })) .thenDispatchedActionsShouldEqual( - cleanVariables(), - cleanEditorState(), - cleanPickerState(), - variablesClearTransaction() + toKeyedAction(key, cleanVariables()), + toKeyedAction(key, cleanEditorState()), + toKeyedAction(key, cleanPickerState()), + toKeyedAction(key, variablesClearTransaction()) ); expect(cancelAllInFlightRequestsMock).toHaveBeenCalledTimes(1); diff --git a/public/app/features/variables/state/actions.ts b/public/app/features/variables/state/actions.ts index 902fbba742e..b1d57723a8b 100644 --- a/public/app/features/variables/state/actions.ts +++ b/public/app/features/variables/state/actions.ts @@ -28,7 +28,7 @@ import { VariableWithOptions, } from '../types'; import { AppNotification, StoreState, ThunkResult } from '../../../types'; -import { getVariable, getVariables } from './selectors'; +import { getIfExistsLastKey, getVariable, getVariablesByKey, getVariablesState } from './selectors'; import { variableAdapters } from '../adapters'; import { Graph } from '../../../core/utils/dag'; import { notifyApp } from 'app/core/actions'; @@ -41,7 +41,7 @@ import { variableStateFetching, variableStateNotStarted, } from './sharedReducer'; -import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from './types'; +import { KeyedVariableIdentifier } from './types'; import { contextSrv } from 'app/core/services/context_srv'; import { getTemplateSrv, TemplateSrv } from '../../templating/template_srv'; import { alignCurrentWithMulti } from '../shared/multiOptions'; @@ -71,6 +71,9 @@ import { getCurrentText, getVariableRefresh, hasOngoingTransaction, + toKeyedVariableIdentifier, + toStateKey, + toVariablePayload, } from '../utils'; import { store } from 'app/store/store'; import { getDatasourceSrv } from '../../plugins/datasource_srv'; @@ -80,6 +83,7 @@ import { locationService } from '@grafana/runtime'; import { appEvents } from '../../../core/core'; import { getAllAffectedPanelIdsForVariableChange } from '../inspect/utils'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { toKeyedAction } from './keyedVariablesReducer'; // process flow queryVariable // thunk => processVariables @@ -111,24 +115,27 @@ import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; // thunk => variableUpdated // adapter => updateOptions for dependent nodes -export const initDashboardTemplating = (list: VariableModel[]): ThunkResult => { +export const initDashboardTemplating = (key: string, dashboard: DashboardModel): ThunkResult => { return (dispatch, getState) => { let orderIndex = 0; + const list = dashboard.templating.list; for (let index = 0; index < list.length; index++) { const model = fixSelectedInconsistency(list[index]); + model.rootStateKey = key; if (!variableAdapters.getIfExists(model.type)) { continue; } - dispatch(addVariable(toVariablePayload(model, { global: false, index: orderIndex++, model }))); + dispatch( + toKeyedAction(key, addVariable(toVariablePayload(model, { global: false, index: orderIndex++, model }))) + ); } getTemplateSrv().updateTimeRange(getTimeSrv().timeRange()); - const variables = getVariables(getState()); - for (let index = 0; index < variables.length; index++) { - const variable = variables[index]; - dispatch(variableStateNotStarted(toVariablePayload(variable))); + const variables = getVariablesByKey(key, getState()); + for (const variable of variables) { + dispatch(toKeyedAction(key, variableStateNotStarted(toVariablePayload(variable)))); } }; }; @@ -159,7 +166,7 @@ export function fixSelectedInconsistency(model: VariableModel): VariableModel | return model; } -export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResult => { +export const addSystemTemplateVariables = (key: string, dashboard: DashboardModel): ThunkResult => { return (dispatch) => { const dashboardModel: DashboardVariableModel = { ...initialVariableModelState, @@ -179,12 +186,15 @@ export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResu }; dispatch( - addVariable( - toVariablePayload(dashboardModel, { - global: dashboardModel.global, - index: dashboardModel.index, - model: dashboardModel, - }) + toKeyedAction( + key, + addVariable( + toVariablePayload(dashboardModel, { + global: dashboardModel.global, + index: dashboardModel.index, + model: dashboardModel, + }) + ) ) ); @@ -206,7 +216,10 @@ export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResu }; dispatch( - addVariable(toVariablePayload(orgModel, { global: orgModel.global, index: orgModel.index, model: orgModel })) + toKeyedAction( + key, + addVariable(toVariablePayload(orgModel, { global: orgModel.global, index: orgModel.index, model: orgModel })) + ) ); const userModel: UserVariableModel = { @@ -228,25 +241,39 @@ export const addSystemTemplateVariables = (dashboard: DashboardModel): ThunkResu }; dispatch( - addVariable(toVariablePayload(userModel, { global: userModel.global, index: userModel.index, model: userModel })) + toKeyedAction( + key, + addVariable( + toVariablePayload(userModel, { global: userModel.global, index: userModel.index, model: userModel }) + ) + ) ); }; }; -export const changeVariableMultiValue = (identifier: VariableIdentifier, multi: boolean): ThunkResult => { +export const changeVariableMultiValue = (identifier: KeyedVariableIdentifier, multi: boolean): ThunkResult => { return (dispatch, getState) => { - const variable = getVariable(identifier.id, getState()); + const { rootStateKey: key } = identifier; + const variable = getVariable(identifier, getState()); const current = alignCurrentWithMulti(variable.current, multi); - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'multi', propValue: multi }))); - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'current', propValue: current }))); + dispatch( + toKeyedAction(key, changeVariableProp(toVariablePayload(identifier, { propName: 'multi', propValue: multi }))) + ); + dispatch( + toKeyedAction(key, changeVariableProp(toVariablePayload(identifier, { propName: 'current', propValue: current }))) + ); }; }; export const processVariableDependencies = async (variable: VariableModel, state: StoreState) => { + if (!variable.rootStateKey) { + throw new Error(`rootStateKey not found for variable with id:${variable.id}`); + } + const dependencies: VariableModel[] = []; - for (const otherVariable of getVariables(state)) { + for (const otherVariable of getVariablesByKey(variable.rootStateKey, state)) { if (variable === otherVariable) { continue; } @@ -258,13 +285,17 @@ export const processVariableDependencies = async (variable: VariableModel, state } } - if (!isWaitingForDependencies(dependencies, state)) { + if (!isWaitingForDependencies(variable.rootStateKey, dependencies, state)) { return; } await new Promise((resolve) => { const unsubscribe = store.subscribe(() => { - if (!isWaitingForDependencies(dependencies, store.getState())) { + if (!variable.rootStateKey) { + throw new Error(`rootStateKey not found for variable with id:${variable.id}`); + } + + if (!isWaitingForDependencies(variable.rootStateKey, dependencies, store.getState())) { unsubscribe(); resolve(); } @@ -272,12 +303,12 @@ export const processVariableDependencies = async (variable: VariableModel, state }); }; -const isWaitingForDependencies = (dependencies: VariableModel[], state: StoreState): boolean => { +const isWaitingForDependencies = (key: string, dependencies: VariableModel[], state: StoreState): boolean => { if (dependencies.length === 0) { return false; } - const variables = getVariables(state); + const variables = getVariablesByKey(key, state); const notCompletedDependencies = dependencies.filter((d) => variables.some((v) => v.id === d.id && (v.state === LoadingState.NotStarted || v.state === LoadingState.Loading)) ); @@ -286,11 +317,11 @@ const isWaitingForDependencies = (dependencies: VariableModel[], state: StoreSta }; export const processVariable = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, queryParams: UrlQueryMap ): ThunkResult> => { return async (dispatch, getState) => { - const variable = getVariable(identifier.id, getState()); + const variable = getVariable(identifier, getState()); await processVariableDependencies(variable, getState()); const urlValue = queryParams['var-' + variable.name]; @@ -306,7 +337,7 @@ export const processVariable = ( refreshableVariable.refresh === VariableRefresh.onDashboardLoad || refreshableVariable.refresh === VariableRefresh.onTimeRangeChanged ) { - await dispatch(updateOptions(toVariableIdentifier(refreshableVariable))); + await dispatch(updateOptions(toKeyedVariableIdentifier(refreshableVariable))); return; } } @@ -316,11 +347,12 @@ export const processVariable = ( }; }; -export const processVariables = (): ThunkResult> => { +export const processVariables = (key: string): ThunkResult> => { return async (dispatch, getState) => { const queryParams = locationService.getSearchObject(); - const promises = getVariables(getState()).map( - async (variable: VariableModel) => await dispatch(processVariable(toVariableIdentifier(variable), queryParams)) + const promises = getVariablesByKey(key, getState()).map( + async (variable: VariableModel) => + await dispatch(processVariable(toKeyedVariableIdentifier(variable), queryParams)) ); await Promise.all(promises); @@ -328,19 +360,19 @@ export const processVariables = (): ThunkResult> => { }; export const setOptionFromUrl = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, urlValue: UrlQueryValue ): ThunkResult> => { return async (dispatch, getState) => { const stringUrlValue = ensureStringValues(urlValue); - const variable = getVariable(identifier.id, getState()); + const variable = getVariable(identifier, getState()); if (getVariableRefresh(variable) !== VariableRefresh.never) { // updates options - await dispatch(updateOptions(toVariableIdentifier(variable))); + await dispatch(updateOptions(toKeyedVariableIdentifier(variable))); } // get variable from state - const variableFromState = getVariable(variable.id, getState()); + const variableFromState = getVariable(toKeyedVariableIdentifier(variable), getState()); if (!variableFromState) { throw new Error(`Couldn't find variable with name: ${variable.name}`); } @@ -418,11 +450,11 @@ export const selectOptionsForCurrentValue = (variable: VariableWithOptions): Var }; export const validateVariableSelectionState = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, defaultValue?: string ): ThunkResult> => { return (dispatch, getState) => { - const variableInState = getVariable(identifier.id, getState()); + const variableInState = getVariable(identifier, getState()); const current = variableInState.current || ({} as unknown as VariableOption); const setValue = variableAdapters.get(variableInState.type).setValue; @@ -474,12 +506,13 @@ export const validateVariableSelectionState = ( }; export const setOptionAsCurrent = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, current: VariableOption, emitChanges: boolean ): ThunkResult> => { return async (dispatch) => { - dispatch(setCurrentVariableValue(toVariablePayload(identifier, { option: current }))); + const { rootStateKey: key } = identifier; + dispatch(toKeyedAction(key, setCurrentVariableValue(toVariablePayload(identifier, { option: current })))); return await dispatch(variableUpdated(identifier, emitChanges)); }; }; @@ -507,25 +540,26 @@ const createGraph = (variables: VariableModel[]) => { }; export const variableUpdated = ( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, emitChangeEvents: boolean, events: typeof appEvents = appEvents ): ThunkResult> => { return async (dispatch, getState) => { const state = getState(); - const variableInState = getVariable(identifier.id, state); + const { rootStateKey } = identifier; + const variableInState = getVariable(identifier, state); // if we're initializing variables ignore cascading update because we are in a boot up scenario - if (state.templating.transaction.status === TransactionStatus.Fetching) { + if (getVariablesState(rootStateKey, state).transaction.status === TransactionStatus.Fetching) { if (getVariableRefresh(variableInState) === VariableRefresh.never) { // for variable types with updates that go the setValueFromUrl path in the update let's make sure their state is set to Done. - await dispatch(upgradeLegacyQueries(toVariableIdentifier(variableInState))); + await dispatch(upgradeLegacyQueries(toKeyedVariableIdentifier(variableInState))); dispatch(completeVariableLoading(identifier)); } return Promise.resolve(); } - const variables = getVariables(state); + const variables = getVariablesByKey(rootStateKey, state); const g = createGraph(variables); const panels = state.dashboard?.getModel()?.panels ?? []; const event: VariablesChangedEvent = isAdHoc(variableInState) @@ -541,14 +575,14 @@ export const variableUpdated = ( return Promise.resolve(); } - return dispatch(updateOptions(toVariableIdentifier(variable))); + return dispatch(updateOptions(toKeyedVariableIdentifier(variable))); }); } return Promise.all(promises).then(() => { if (emitChangeEvents) { events.publish(new VariablesChanged(event)); - locationService.partial(getQueryWithVariables(getState)); + locationService.partial(getQueryWithVariables(rootStateKey, getState)); } }); }; @@ -561,12 +595,13 @@ export interface OnTimeRangeUpdatedDependencies { export const onTimeRangeUpdated = ( + key: string, timeRange: TimeRange, dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: getTemplateSrv(), events: appEvents } ): ThunkResult> => async (dispatch, getState) => { dependencies.templateSrv.updateTimeRange(timeRange); - const variablesThatNeedRefresh = getVariables(getState()).filter((variable) => { + const variablesThatNeedRefresh = getVariablesByKey(key, getState()).filter((variable) => { if (variable.hasOwnProperty('refresh') && variable.hasOwnProperty('options')) { const variableWithRefresh = variable as unknown as QueryVariableModel; return variableWithRefresh.refresh === VariableRefresh.onTimeRangeChanged; @@ -577,7 +612,7 @@ export const onTimeRangeUpdated = const variableIds = variablesThatNeedRefresh.map((variable) => variable.id); const promises = variablesThatNeedRefresh.map((variable: VariableWithOptions) => - dispatch(timeRangeUpdated(toVariableIdentifier(variable))) + dispatch(timeRangeUpdated(toKeyedVariableIdentifier(variable))) ); try { @@ -590,14 +625,14 @@ export const onTimeRangeUpdated = }; const timeRangeUpdated = - (identifier: VariableIdentifier): ThunkResult> => + (identifier: KeyedVariableIdentifier): ThunkResult> => async (dispatch, getState) => { - const variableInState = getVariable(identifier.id); + const variableInState = getVariable(identifier, getState()); const previousOptions = variableInState.options.slice(); - await dispatch(updateOptions(toVariableIdentifier(variableInState), true)); + await dispatch(updateOptions(toKeyedVariableIdentifier(variableInState), true)); - const updatedVariable = getVariable(identifier.id, getState()); + const updatedVariable = getVariable(identifier, getState()); const updatedOptions = updatedVariable.options; if (JSON.stringify(previousOptions) !== JSON.stringify(updatedOptions)) { @@ -607,11 +642,11 @@ const timeRangeUpdated = }; export const templateVarsChangedInUrl = - (vars: ExtendedUrlQueryMap, events: typeof appEvents = appEvents): ThunkResult => + (key: string, vars: ExtendedUrlQueryMap, events: typeof appEvents = appEvents): ThunkResult => async (dispatch, getState) => { const update: Array> = []; const dashboard = getState().dashboard.getModel(); - for (const variable of getVariables(getState())) { + for (const variable of getVariablesByKey(key, getState())) { const key = `var-${variable.name}`; if (!vars.hasOwnProperty(key)) { // key not found quick exit @@ -657,7 +692,7 @@ export function isVariableUrlValueDifferentFromCurrent(variable: VariableModel, return !isEqual(variableValue, stringUrlValue); } -const getQueryWithVariables = (getState: () => StoreState): UrlQueryMap => { +const getQueryWithVariables = (key: string, getState: () => StoreState): UrlQueryMap => { const queryParams = locationService.getSearchObject(); const queryParamsNew = Object.keys(queryParams) @@ -667,7 +702,7 @@ const getQueryWithVariables = (getState: () => StoreState): UrlQueryMap => { return obj; }, {} as UrlQueryMap); - for (const variable of getVariables(getState())) { + for (const variable of getVariablesByKey(key, getState())) { if (variable.skipUrlSync) { continue; } @@ -680,27 +715,32 @@ const getQueryWithVariables = (getState: () => StoreState): UrlQueryMap => { }; export const initVariablesTransaction = - (dashboardUid: string, dashboard: DashboardModel): ThunkResult => + (urlUid: string, dashboard: DashboardModel): ThunkResult> => async (dispatch, getState) => { try { - const transactionState = getState().templating.transaction; - if (transactionState.status === TransactionStatus.Fetching) { - // previous dashboard is still fetching variables, cancel all requests - dispatch(cancelVariables()); + const uid = toStateKey(urlUid); + const state = getState(); + const lastKey = getIfExistsLastKey(state); + if (lastKey) { + const transactionState = getVariablesState(lastKey, state).transaction; + if (transactionState.status === TransactionStatus.Fetching) { + // previous dashboard is still fetching variables, cancel all requests + dispatch(cancelVariables(lastKey)); + } } // Start init transaction - dispatch(variablesInitTransaction({ uid: dashboardUid })); + dispatch(toKeyedAction(uid, variablesInitTransaction({ uid }))); // Add system variables like __dashboard and __user - dispatch(addSystemTemplateVariables(dashboard)); + dispatch(addSystemTemplateVariables(uid, dashboard)); // Load all variables into redux store - dispatch(initDashboardTemplating(dashboard.templating.list)); + dispatch(initDashboardTemplating(uid, dashboard)); // Migrate data source name to ref - dispatch(migrateVariablesDatasourceNameToRef()); + dispatch(migrateVariablesDatasourceNameToRef(uid)); // Process all variable updates - await dispatch(processVariables()); - // Mark update as complete - dispatch(variablesCompleteTransaction({ uid: dashboardUid })); + await dispatch(processVariables(uid)); + // Set transaction as complete + dispatch(toKeyedAction(uid, variablesCompleteTransaction({ uid }))); } catch (err) { dispatch(notifyApp(createVariableErrorNotification('Templating init failed', err))); console.error(err); @@ -708,10 +748,11 @@ export const initVariablesTransaction = }; export function migrateVariablesDatasourceNameToRef( - getDatasourceSrvFunc: typeof getDatasourceSrv = getDatasourceSrv + key: string, + getDatasourceSrvFunc = getDatasourceSrv ): ThunkResult { - return function (dispatch, getState) { - const variables = getVariables(getState()); + return (dispatch, getState) => { + const variables = getVariablesByKey(key, getState()); for (const variable of variables) { if (!isAdHoc(variable) && !isQuery(variable)) { continue; @@ -726,43 +767,51 @@ export function migrateVariablesDatasourceNameToRef( // the call to getInstanceSettings needs to be done after initDashboardTemplating because we might have // datasource variables that need to be resolved const ds = getDatasourceSrvFunc().getInstanceSettings(nameOrRef); - const dsRef = !ds ? { uid: nameOrRef } : getDataSourceRef(ds); - dispatch(changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: dsRef }))); + const dsRef = ds ? getDataSourceRef(ds) : { uid: nameOrRef }; + dispatch( + toKeyedAction( + key, + changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: dsRef })) + ) + ); } }; } -export const cleanUpVariables = (): ThunkResult => (dispatch) => { - dispatch(cleanVariables()); - dispatch(cleanEditorState()); - dispatch(cleanPickerState()); - dispatch(variablesClearTransaction()); -}; +export const cleanUpVariables = + (key: string): ThunkResult => + (dispatch) => { + dispatch(toKeyedAction(key, cleanVariables())); + dispatch(toKeyedAction(key, cleanEditorState())); + dispatch(toKeyedAction(key, cleanPickerState())); + dispatch(toKeyedAction(key, variablesClearTransaction())); + }; type CancelVariablesDependencies = { getBackendSrv: typeof getBackendSrv }; export const cancelVariables = - (dependencies: CancelVariablesDependencies = { getBackendSrv: getBackendSrv }): ThunkResult => + (key: string, dependencies: CancelVariablesDependencies = { getBackendSrv: getBackendSrv }): ThunkResult => (dispatch) => { dependencies.getBackendSrv().cancelAllInFlightRequests(); - dispatch(cleanUpVariables()); + dispatch(cleanUpVariables(key)); }; export const updateOptions = - (identifier: VariableIdentifier, rethrow = false): ThunkResult> => + (identifier: KeyedVariableIdentifier, rethrow = false): ThunkResult> => async (dispatch, getState) => { + const { rootStateKey } = identifier; try { - if (!hasOngoingTransaction(getState())) { + if (!hasOngoingTransaction(rootStateKey, getState())) { // we might have cancelled a batch so then variable state is removed return; } - const variableInState = getVariable(identifier.id, getState()); - dispatch(variableStateFetching(toVariablePayload(variableInState))); - await dispatch(upgradeLegacyQueries(toVariableIdentifier(variableInState))); + const variableInState = getVariable(identifier, getState()); + dispatch(toKeyedAction(rootStateKey, variableStateFetching(toVariablePayload(variableInState)))); + await dispatch(upgradeLegacyQueries(toKeyedVariableIdentifier(variableInState))); await variableAdapters.get(variableInState.type).updateOptions(variableInState); dispatch(completeVariableLoading(identifier)); } catch (error) { - dispatch(variableStateFailed(toVariablePayload(identifier, { error }))); + dispatch(toKeyedAction(rootStateKey, variableStateFailed(toVariablePayload(identifier, { error })))); if (!rethrow) { console.error(error); @@ -778,7 +827,7 @@ export const updateOptions = export const createVariableErrorNotification = ( message: string, error: any, - identifier?: VariableIdentifier + identifier?: KeyedVariableIdentifier ): AppNotification => createErrorNotification( `${identifier ? `Templating [${identifier.id}]` : 'Templating'}`, @@ -786,31 +835,33 @@ export const createVariableErrorNotification = ( ); export const completeVariableLoading = - (identifier: VariableIdentifier): ThunkResult => + (identifier: KeyedVariableIdentifier): ThunkResult => (dispatch, getState) => { - if (!hasOngoingTransaction(getState())) { + const { rootStateKey } = identifier; + if (!hasOngoingTransaction(rootStateKey, getState())) { // we might have cancelled a batch so then variable state is removed return; } - const variableInState = getVariable(identifier.id, getState()); + const variableInState = getVariable(identifier, getState()); if (variableInState.state !== LoadingState.Done) { - dispatch(variableStateCompleted(toVariablePayload(variableInState))); + dispatch(toKeyedAction(identifier.rootStateKey, variableStateCompleted(toVariablePayload(variableInState)))); } }; export function upgradeLegacyQueries( - identifier: VariableIdentifier, + identifier: KeyedVariableIdentifier, getDatasourceSrvFunc: typeof getDatasourceSrv = getDatasourceSrv ): ThunkResult { return async function (dispatch, getState) { - if (!hasOngoingTransaction(getState())) { + const { id, rootStateKey } = identifier; + if (!hasOngoingTransaction(rootStateKey, getState())) { // we might have cancelled a batch so then variable state is removed return; } - const variable = getVariable(identifier.id, getState()); + const variable = getVariable(identifier, getState()); if (!isQuery(variable)) { return; @@ -832,11 +883,16 @@ export function upgradeLegacyQueries( } const query = { - refId: `${datasource.name}-${identifier.id}-Variable-Query`, + refId: `${datasource.name}-${id}-Variable-Query`, query: variable.query, }; - dispatch(changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: query }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(identifier, { propName: 'query', propValue: query })) + ) + ); } catch (err) { dispatch(notifyApp(createVariableErrorNotification('Failed to upgrade legacy queries', err))); console.error(err); diff --git a/public/app/features/variables/state/helpers.ts b/public/app/features/variables/state/helpers.ts index be9c57493c3..5ea13a64fd9 100644 --- a/public/app/features/variables/state/helpers.ts +++ b/public/app/features/variables/state/helpers.ts @@ -13,9 +13,10 @@ import { import { VariableAdapter } from '../adapters'; import { dashboardReducer } from 'app/features/dashboard/state/reducers'; -import { templatingReducers, TemplatingState } from './reducers'; -import { DashboardState } from '../../../types'; +import { DashboardState, StoreState } from '../../../types'; import { NEW_VARIABLE_ID } from '../constants'; +import { keyedVariablesReducer, KeyedVariablesState } from './keyedVariablesReducer'; +import { getInitialTemplatingState, TemplatingState } from './reducers'; export const getVariableState = ( noOfVariables: number, @@ -86,6 +87,7 @@ export const getVariableState = ( for (let index = 0; index < noOfVariables; index++) { variables[index] = { id: index.toString(), + rootStateKey: 'key', type: 'query', name: `Name-${index}`, hide: VariableHide.dontHide, @@ -102,6 +104,7 @@ export const getVariableState = ( if (includeEmpty) { variables[NEW_VARIABLE_ID] = { id: NEW_VARIABLE_ID, + rootStateKey: 'key', type: 'query', name: `Name-${NEW_VARIABLE_ID}`, hide: VariableHide.dontHide, @@ -122,12 +125,16 @@ export const getVariableTestContext = ( adapter: VariableAdapter, variableOverrides: Partial = {} ) => { - const defaultVariable = { - ...adapter.initialState, + const defaults: Partial = { id: '0', + rootStateKey: 'key', index: 0, name: '0', }; + const defaultVariable = { + ...adapter.initialState, + ...defaults, + }; const initialState: VariablesState = { '0': { ...defaultVariable, ...variableOverrides }, @@ -139,14 +146,31 @@ export const getVariableTestContext = ( export const getRootReducer = () => combineReducers({ dashboard: dashboardReducer, - templating: templatingReducers, + templating: keyedVariablesReducer, }); -export type RootReducerType = { dashboard: DashboardState; templating: TemplatingState }; +export type RootReducerType = { dashboard: DashboardState; templating: KeyedVariablesState }; export const getTemplatingRootReducer = () => combineReducers({ - templating: templatingReducers, + templating: keyedVariablesReducer, }); -export type TemplatingReducerType = { templating: TemplatingState }; +export type TemplatingReducerType = { templating: KeyedVariablesState }; + +export function getPreloadedState( + key: string, + templatingState: Partial +): Pick { + return { + templating: { + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + ...templatingState, + }, + }, + }, + }; +} diff --git a/public/app/features/variables/state/initVariableTransaction.test.ts b/public/app/features/variables/state/initVariableTransaction.test.ts index 5cbede1bfa3..592d46cc68a 100644 --- a/public/app/features/variables/state/initVariableTransaction.test.ts +++ b/public/app/features/variables/state/initVariableTransaction.test.ts @@ -1,4 +1,4 @@ -import { getRootReducer, RootReducerType } from './helpers'; +import { getPreloadedState, getRootReducer, RootReducerType } from './helpers'; import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from '../query/adapter'; import { createConstantVariableAdapter } from '../constant/adapter'; @@ -11,15 +11,15 @@ import { variableStateFetching, variableStateNotStarted, } from './sharedReducer'; -import { toVariablePayload } from './types'; import { adHocBuilder, constantBuilder, datasourceBuilder, queryBuilder } from '../shared/testing/builders'; -import { cleanEditorState, initialVariableEditorState } from '../editor/reducer'; +import { cleanEditorState } from '../editor/reducer'; import { + initialTransactionState, variablesClearTransaction, variablesCompleteTransaction, variablesInitTransaction, } from './transactionReducer'; -import { cleanPickerState, initialState } from '../pickers/OptionsPicker/reducer'; +import { cleanPickerState } from '../pickers/OptionsPicker/reducer'; import { cleanVariables } from './variablesReducer'; import { createAdHocVariableAdapter } from '../adhoc/adapter'; import { createDataSourceVariableAdapter } from '../datasource/adapter'; @@ -30,6 +30,8 @@ import { toAsyncOfResult } from '../../query/state/DashboardQueryRunner/testHelp import { setVariableQueryRunner } from '../query/VariableQueryRunner'; import { createDataSourceOptions } from '../datasource/reducer'; import { initVariablesTransaction } from './actions'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; variableAdapters.setInit(() => [ createQueryVariableAdapter(), @@ -39,7 +41,7 @@ variableAdapters.setInit(() => [ ]); function getTestContext(variables?: VariableModel[]) { - const uid = 'uid'; + const key = 'key'; const constant = constantBuilder().withId('constant').withName('constant').build(); const templating = { list: variables ?? [constant] }; const getInstanceSettingsMock = jest.fn().mockReturnValue(undefined); @@ -57,34 +59,34 @@ function getTestContext(variables?: VariableModel[]) { }; setVariableQueryRunner(variableQueryRunner); - const dashboard: any = { title: 'Some dash', uid, templating }; + const dashboard: any = { title: 'Some dash', uid: key, templating }; - return { constant, getInstanceSettingsMock, templating, uid, dashboard }; + return { constant, getInstanceSettingsMock, templating, key, dashboard }; } describe('initVariablesTransaction', () => { describe('when called and the previous dashboard has completed', () => { it('then correct actions are dispatched', async () => { - const { constant, uid, dashboard } = getTestContext(); + const { constant, key, dashboard } = getTestContext(); const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard)); + .whenAsyncActionIsDispatched(initVariablesTransaction(key, dashboard)); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { - expect(dispatchedActions[0]).toEqual(variablesInitTransaction({ uid })); - expect(dispatchedActions[1].type).toEqual(addVariable.type); - expect(dispatchedActions[1].payload.id).toEqual('__dashboard'); - expect(dispatchedActions[2].type).toEqual(addVariable.type); - expect(dispatchedActions[2].payload.id).toEqual('__org'); - expect(dispatchedActions[3].type).toEqual(addVariable.type); - expect(dispatchedActions[3].payload.id).toEqual('__user'); + expect(dispatchedActions[0]).toEqual(toKeyedAction(key, variablesInitTransaction({ uid: key }))); + expect(dispatchedActions[1].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[1].payload.action.payload.id).toEqual('__dashboard'); + expect(dispatchedActions[2].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[2].payload.action.payload.id).toEqual('__org'); + expect(dispatchedActions[3].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[3].payload.action.payload.id).toEqual('__user'); expect(dispatchedActions[4]).toEqual( - addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant }))) ); - expect(dispatchedActions[5]).toEqual(variableStateNotStarted(toVariablePayload(constant))); - expect(dispatchedActions[6]).toEqual(variableStateCompleted(toVariablePayload(constant))); + expect(dispatchedActions[5]).toEqual(toKeyedAction(key, variableStateNotStarted(toVariablePayload(constant)))); + expect(dispatchedActions[6]).toEqual(toKeyedAction(key, variableStateCompleted(toVariablePayload(constant)))); - expect(dispatchedActions[7]).toEqual(variablesCompleteTransaction({ uid })); + expect(dispatchedActions[7]).toEqual(toKeyedAction(key, variablesCompleteTransaction({ uid: key }))); return dispatchedActions.length === 8; }); }); @@ -92,54 +94,73 @@ describe('initVariablesTransaction', () => { describe('and there are variables that have data source that need to be migrated', () => { it('then correct actions are dispatched', async () => { const legacyDs = '${ds}' as unknown as DataSourceRef; - const ds = datasourceBuilder().withId('ds').withName('ds').withQuery('prom').build(); - const query = queryBuilder().withId('query').withName('query').withDatasource(legacyDs).build(); - const adhoc = adHocBuilder().withId('adhoc').withName('adhoc').withDatasource(legacyDs).build(); - const { uid, dashboard } = getTestContext([ds, query, adhoc]); + const ds = datasourceBuilder().withId('ds').withRootStateKey('key').withName('ds').withQuery('prom').build(); + const query = queryBuilder() + .withId('query') + .withRootStateKey('key') + .withName('query') + .withDatasource(legacyDs) + .build(); + const adhoc = adHocBuilder() + .withId('adhoc') + .withRootStateKey('key') + .withName('adhoc') + .withDatasource(legacyDs) + .build(); + const { key, dashboard } = getTestContext([ds, query, adhoc]); const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard)); + .whenAsyncActionIsDispatched(initVariablesTransaction(key, dashboard)); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { - expect(dispatchedActions[0]).toEqual(variablesInitTransaction({ uid })); - expect(dispatchedActions[1].type).toEqual(addVariable.type); - expect(dispatchedActions[1].payload.id).toEqual('__dashboard'); - expect(dispatchedActions[2].type).toEqual(addVariable.type); - expect(dispatchedActions[2].payload.id).toEqual('__org'); - expect(dispatchedActions[3].type).toEqual(addVariable.type); - expect(dispatchedActions[3].payload.id).toEqual('__user'); + expect(dispatchedActions[0]).toEqual(toKeyedAction(key, variablesInitTransaction({ uid: key }))); + expect(dispatchedActions[1].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[1].payload.action.payload.id).toEqual('__dashboard'); + expect(dispatchedActions[2].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[2].payload.action.payload.id).toEqual('__org'); + expect(dispatchedActions[3].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[3].payload.action.payload.id).toEqual('__user'); expect(dispatchedActions[4]).toEqual( - addVariable(toVariablePayload(ds, { global: false, index: 0, model: ds })) + toKeyedAction(key, addVariable(toVariablePayload(ds, { global: false, index: 0, model: ds }))) ); expect(dispatchedActions[5]).toEqual( - addVariable(toVariablePayload(query, { global: false, index: 1, model: query })) + toKeyedAction(key, addVariable(toVariablePayload(query, { global: false, index: 1, model: query }))) ); expect(dispatchedActions[6]).toEqual( - addVariable(toVariablePayload(adhoc, { global: false, index: 2, model: adhoc })) + toKeyedAction(key, addVariable(toVariablePayload(adhoc, { global: false, index: 2, model: adhoc }))) ); - expect(dispatchedActions[7]).toEqual(variableStateNotStarted(toVariablePayload(ds))); - expect(dispatchedActions[8]).toEqual(variableStateNotStarted(toVariablePayload(query))); - expect(dispatchedActions[9]).toEqual(variableStateNotStarted(toVariablePayload(adhoc))); + expect(dispatchedActions[7]).toEqual(toKeyedAction(key, variableStateNotStarted(toVariablePayload(ds)))); + expect(dispatchedActions[8]).toEqual(toKeyedAction(key, variableStateNotStarted(toVariablePayload(query)))); + expect(dispatchedActions[9]).toEqual(toKeyedAction(key, variableStateNotStarted(toVariablePayload(adhoc)))); expect(dispatchedActions[10]).toEqual( - changeVariableProp(toVariablePayload(query, { propName: 'datasource', propValue: { uid: '${ds}' } })) - ); - expect(dispatchedActions[11]).toEqual( - changeVariableProp(toVariablePayload(adhoc, { propName: 'datasource', propValue: { uid: '${ds}' } })) - ); - expect(dispatchedActions[12]).toEqual(variableStateFetching(toVariablePayload(ds))); - expect(dispatchedActions[13]).toEqual(variableStateCompleted(toVariablePayload(adhoc))); - expect(dispatchedActions[14]).toEqual( - createDataSourceOptions(toVariablePayload(ds, { sources: [], regex: undefined })) - ); - expect(dispatchedActions[15]).toEqual( - setCurrentVariableValue( - toVariablePayload(ds, { option: { selected: false, text: 'No data sources found', value: '' } }) + toKeyedAction( + key, + changeVariableProp(toVariablePayload(query, { propName: 'datasource', propValue: { uid: '${ds}' } })) ) ); - expect(dispatchedActions[16]).toEqual(variableStateCompleted(toVariablePayload(ds))); - expect(dispatchedActions[17]).toEqual(variableStateFetching(toVariablePayload(query))); - expect(dispatchedActions[18]).toEqual(variableStateCompleted(toVariablePayload(query))); - expect(dispatchedActions[19]).toEqual(variablesCompleteTransaction({ uid })); + expect(dispatchedActions[11]).toEqual( + toKeyedAction( + key, + changeVariableProp(toVariablePayload(adhoc, { propName: 'datasource', propValue: { uid: '${ds}' } })) + ) + ); + expect(dispatchedActions[12]).toEqual(toKeyedAction(key, variableStateFetching(toVariablePayload(ds)))); + expect(dispatchedActions[13]).toEqual(toKeyedAction(key, variableStateCompleted(toVariablePayload(adhoc)))); + expect(dispatchedActions[14]).toEqual( + toKeyedAction(key, createDataSourceOptions(toVariablePayload(ds, { sources: [], regex: undefined }))) + ); + expect(dispatchedActions[15]).toEqual( + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload(ds, { option: { selected: false, text: 'No data sources found', value: '' } }) + ) + ) + ); + expect(dispatchedActions[16]).toEqual(toKeyedAction(key, variableStateCompleted(toVariablePayload(ds)))); + expect(dispatchedActions[17]).toEqual(toKeyedAction(key, variableStateFetching(toVariablePayload(query)))); + expect(dispatchedActions[18]).toEqual(toKeyedAction(key, variableStateCompleted(toVariablePayload(query)))); + expect(dispatchedActions[19]).toEqual(toKeyedAction(key, variablesCompleteTransaction({ uid: key }))); return dispatchedActions.length === 20; }); @@ -149,40 +170,32 @@ describe('initVariablesTransaction', () => { describe('when called and the previous dashboard is still processing variables', () => { it('then correct actions are dispatched', async () => { - const { constant, uid, dashboard } = getTestContext(); - const transactionState = { uid: 'previous-uid', status: TransactionStatus.Fetching }; + const { constant, key, dashboard } = getTestContext(); + const transactionState = { ...initialTransactionState, uid: 'previous-uid', status: TransactionStatus.Fetching }; + const preloadedState = getPreloadedState(key, { transaction: transactionState }); - const tester = await reduxTester({ - preloadedState: { - templating: { - transaction: transactionState, - variables: {}, - optionsPicker: { ...initialState }, - editor: { ...initialVariableEditorState }, - }, - } as unknown as RootReducerType, - }) + const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard)); + .whenAsyncActionIsDispatched(initVariablesTransaction(key, dashboard)); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { - expect(dispatchedActions[0]).toEqual(cleanVariables()); - expect(dispatchedActions[1]).toEqual(cleanEditorState()); - expect(dispatchedActions[2]).toEqual(cleanPickerState()); - expect(dispatchedActions[3]).toEqual(variablesClearTransaction()); - expect(dispatchedActions[4]).toEqual(variablesInitTransaction({ uid })); - expect(dispatchedActions[5].type).toEqual(addVariable.type); - expect(dispatchedActions[5].payload.id).toEqual('__dashboard'); - expect(dispatchedActions[6].type).toEqual(addVariable.type); - expect(dispatchedActions[6].payload.id).toEqual('__org'); - expect(dispatchedActions[7].type).toEqual(addVariable.type); - expect(dispatchedActions[7].payload.id).toEqual('__user'); + expect(dispatchedActions[0]).toEqual(toKeyedAction(key, cleanVariables())); + expect(dispatchedActions[1]).toEqual(toKeyedAction(key, cleanEditorState())); + expect(dispatchedActions[2]).toEqual(toKeyedAction(key, cleanPickerState())); + expect(dispatchedActions[3]).toEqual(toKeyedAction(key, variablesClearTransaction())); + expect(dispatchedActions[4]).toEqual(toKeyedAction(key, variablesInitTransaction({ uid: key }))); + expect(dispatchedActions[5].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[5].payload.action.payload.id).toEqual('__dashboard'); + expect(dispatchedActions[6].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[6].payload.action.payload.id).toEqual('__org'); + expect(dispatchedActions[7].payload.action.type).toEqual(addVariable.type); + expect(dispatchedActions[7].payload.action.payload.id).toEqual('__user'); expect(dispatchedActions[8]).toEqual( - addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant })) + toKeyedAction(key, addVariable(toVariablePayload(constant, { global: false, index: 0, model: constant }))) ); - expect(dispatchedActions[9]).toEqual(variableStateNotStarted(toVariablePayload(constant))); - expect(dispatchedActions[10]).toEqual(variableStateCompleted(toVariablePayload(constant))); - expect(dispatchedActions[11]).toEqual(variablesCompleteTransaction({ uid })); + expect(dispatchedActions[9]).toEqual(toKeyedAction(key, variableStateNotStarted(toVariablePayload(constant)))); + expect(dispatchedActions[10]).toEqual(toKeyedAction(key, variableStateCompleted(toVariablePayload(constant)))); + expect(dispatchedActions[11]).toEqual(toKeyedAction(key, variablesCompleteTransaction({ uid: key }))); return dispatchedActions.length === 12; }); }); diff --git a/public/app/features/variables/state/keyedVariablesReducer.test.ts b/public/app/features/variables/state/keyedVariablesReducer.test.ts new file mode 100644 index 00000000000..4a4dfa346f5 --- /dev/null +++ b/public/app/features/variables/state/keyedVariablesReducer.test.ts @@ -0,0 +1,128 @@ +import { + initialKeyedVariablesState, + keyedVariablesReducer, + KeyedVariablesState, + toKeyedAction, +} from './keyedVariablesReducer'; +import { reducerTester } from '../../../../test/core/redux/reducerTester'; +import { initialTransactionState, variablesCompleteTransaction, variablesInitTransaction } from './transactionReducer'; +import { TransactionStatus } from '../types'; +import { getInitialTemplatingState } from './reducers'; + +describe('dashboardVariablesReducer', () => { + describe('when an toUidAction is dispatched', () => { + it('then state should be correct', () => { + const key = 'key'; + reducerTester() + .givenReducer(keyedVariablesReducer, { + ...initialKeyedVariablesState, + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: key, + }, + }, + }, + }) + .whenActionIsDispatched(toKeyedAction(key, variablesCompleteTransaction({ uid: key }))) + .thenStateShouldEqual({ + ...initialKeyedVariablesState, + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: key, + status: TransactionStatus.Completed, + }, + }, + }, + }); + }); + }); + + describe('when an toUidAction with variablesInitTransaction is dispatched', () => { + it('then lastUid property should be correct', () => { + const lastUid = 'lastUid'; + const key = 'key'; + reducerTester() + .givenReducer(keyedVariablesReducer, { + ...initialKeyedVariablesState, + lastKey: lastUid, + keys: { + [lastUid]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: lastUid, + status: TransactionStatus.Completed, + }, + }, + }, + }) + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .thenStateShouldEqual({ + ...initialKeyedVariablesState, + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: key, + status: TransactionStatus.Fetching, + }, + }, + [lastUid]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: lastUid, + status: TransactionStatus.Completed, + }, + }, + }, + }); + }); + }); + + describe('when action other than toUidAction is dispatched', () => { + it('then state should not be affected', () => { + const key = 'key'; + reducerTester() + .givenReducer(keyedVariablesReducer, { + ...initialKeyedVariablesState, + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: key, + status: TransactionStatus.Completed, + }, + }, + }, + }) + .whenActionIsDispatched(variablesInitTransaction({ uid: 'newUid' })) + .thenStateShouldEqual({ + ...initialKeyedVariablesState, + lastKey: key, + keys: { + [key]: { + ...getInitialTemplatingState(), + transaction: { + ...initialTransactionState, + uid: key, + status: TransactionStatus.Completed, + }, + }, + }, + }); + }); + }); +}); diff --git a/public/app/features/variables/state/keyedVariablesReducer.ts b/public/app/features/variables/state/keyedVariablesReducer.ts new file mode 100644 index 00000000000..c3fd48ed5c6 --- /dev/null +++ b/public/app/features/variables/state/keyedVariablesReducer.ts @@ -0,0 +1,50 @@ +import { AnyAction } from 'redux'; +import { createAction, PayloadAction } from '@reduxjs/toolkit'; +import { getTemplatingReducers, TemplatingState } from './reducers'; +import { variablesInitTransaction } from './transactionReducer'; +import { toStateKey } from '../utils'; + +export interface KeyedVariablesState { + lastKey?: string; + keys: Record; +} + +export const initialKeyedVariablesState: KeyedVariablesState = { keys: {} }; + +export interface KeyedAction { + key: string; + action: PayloadAction; +} + +const keyedAction = createAction('templating/keyedAction'); + +export function toKeyedAction(key: string, action: PayloadAction): PayloadAction { + const keyAsString = toStateKey(key); + return keyedAction({ key: keyAsString, action }); +} + +export function keyedVariablesReducer(state = initialKeyedVariablesState, outerAction: AnyAction): KeyedVariablesState { + if (keyedAction.match(outerAction)) { + const { key, action } = outerAction.payload; + const stringKey = toStateKey(key); + const lastKey = variablesInitTransaction.match(action) ? stringKey : state.lastKey; + const templatingReducers = getTemplatingReducers(); + const prevKeyState = state.keys[stringKey]; + const nextKeyState = templatingReducers(prevKeyState, action); + + return { + ...state, + lastKey, + keys: { + ...state.keys, + [stringKey]: nextKeyState, + }, + }; + } + + return state; +} + +export default { + templating: keyedVariablesReducer, +}; diff --git a/public/app/features/variables/state/migrateVariablesDatasourceNameToRef.test.ts b/public/app/features/variables/state/migrateVariablesDatasourceNameToRef.test.ts index f8330b3409c..65cf7a33559 100644 --- a/public/app/features/variables/state/migrateVariablesDatasourceNameToRef.test.ts +++ b/public/app/features/variables/state/migrateVariablesDatasourceNameToRef.test.ts @@ -2,13 +2,17 @@ import { migrateVariablesDatasourceNameToRef } from './actions'; import { adHocBuilder, queryBuilder } from '../shared/testing/builders'; import { DataSourceRef } from '@grafana/data/src'; import { changeVariableProp } from './sharedReducer'; -import { toVariablePayload } from './types'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { getPreloadedState } from './helpers'; +import { toVariablePayload } from '../utils'; function getTestContext(ds: DataSourceRef, dsInstance?: { uid: string; type: string }) { jest.clearAllMocks(); - const query = queryBuilder().withId('query').withName('query').withDatasource(ds).build(); - const adhoc = adHocBuilder().withId('adhoc').withName('adhoc').withDatasource(ds).build(); - const state = { templating: { variables: [query, adhoc] } }; + const key = 'key'; + const query = queryBuilder().withId('query').withRootStateKey(key).withName('query').withDatasource(ds).build(); + const adhoc = adHocBuilder().withId('adhoc').withRootStateKey(key).withName('adhoc').withDatasource(ds).build(); + const templatingState = { variables: { query, adhoc } }; + const state = getPreloadedState(key, templatingState); const dispatch = jest.fn(); const getState = jest.fn().mockReturnValue(state); const getInstanceSettingsMock = jest.fn().mockReturnValue(dsInstance); @@ -18,7 +22,7 @@ function getTestContext(ds: DataSourceRef, dsInstance?: { uid: string; type: str getInstanceSettings: getInstanceSettingsMock, }); - return { query, adhoc, dispatch, getState, getDatasourceSrvFunc }; + return { key, query, adhoc, dispatch, getState, getDatasourceSrvFunc }; } describe('migrateVariablesDatasourceNameToRef', () => { @@ -26,22 +30,34 @@ describe('migrateVariablesDatasourceNameToRef', () => { describe('and data source exists', () => { it('then correct actions are dispatched', async () => { const legacyDs = '${ds}' as unknown as DataSourceRef; - const { query, adhoc, dispatch, getState, getDatasourceSrvFunc } = getTestContext(legacyDs, { + const { query, adhoc, dispatch, getState, getDatasourceSrvFunc, key } = getTestContext(legacyDs, { uid: 'a random uid', type: 'prometheus', }); - migrateVariablesDatasourceNameToRef(getDatasourceSrvFunc)(dispatch, getState, undefined); + migrateVariablesDatasourceNameToRef(key, getDatasourceSrvFunc)(dispatch, getState, undefined); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0]).toEqual( - changeVariableProp( - toVariablePayload(query, { propName: 'datasource', propValue: { uid: 'a random uid', type: 'prometheus' } }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(query, { + propName: 'datasource', + propValue: { uid: 'a random uid', type: 'prometheus' }, + }) + ) ) ); expect(dispatch.mock.calls[1][0]).toEqual( - changeVariableProp( - toVariablePayload(adhoc, { propName: 'datasource', propValue: { uid: 'a random uid', type: 'prometheus' } }) + toKeyedAction( + key, + changeVariableProp( + toVariablePayload(adhoc, { + propName: 'datasource', + propValue: { uid: 'a random uid', type: 'prometheus' }, + }) + ) ) ); }); @@ -50,16 +66,22 @@ describe('migrateVariablesDatasourceNameToRef', () => { describe('and data source does not exist', () => { it('then correct actions are dispatched', async () => { const legacyDs = '${ds}' as unknown as DataSourceRef; - const { query, adhoc, dispatch, getState, getDatasourceSrvFunc } = getTestContext(legacyDs, undefined); + const { query, adhoc, dispatch, getState, getDatasourceSrvFunc, key } = getTestContext(legacyDs, undefined); - migrateVariablesDatasourceNameToRef(getDatasourceSrvFunc)(dispatch, getState, undefined); + migrateVariablesDatasourceNameToRef(key, getDatasourceSrvFunc)(dispatch, getState, undefined); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch.mock.calls[0][0]).toEqual( - changeVariableProp(toVariablePayload(query, { propName: 'datasource', propValue: { uid: '${ds}' } })) + toKeyedAction( + key, + changeVariableProp(toVariablePayload(query, { propName: 'datasource', propValue: { uid: '${ds}' } })) + ) ); expect(dispatch.mock.calls[1][0]).toEqual( - changeVariableProp(toVariablePayload(adhoc, { propName: 'datasource', propValue: { uid: '${ds}' } })) + toKeyedAction( + key, + changeVariableProp(toVariablePayload(adhoc, { propName: 'datasource', propValue: { uid: '${ds}' } })) + ) ); }); }); @@ -68,9 +90,9 @@ describe('migrateVariablesDatasourceNameToRef', () => { describe('when called and variables have dataSourceRef', () => { it('then no actions are dispatched', async () => { const legacyDs = { uid: '${ds}', type: 'prometheus' }; - const { dispatch, getState, getDatasourceSrvFunc } = getTestContext(legacyDs, undefined); + const { dispatch, getState, getDatasourceSrvFunc, key } = getTestContext(legacyDs, undefined); - migrateVariablesDatasourceNameToRef(getDatasourceSrvFunc)(dispatch, getState, undefined); + migrateVariablesDatasourceNameToRef(key, getDatasourceSrvFunc)(dispatch, getState, undefined); expect(dispatch).toHaveBeenCalledTimes(0); }); diff --git a/public/app/features/variables/state/onTimeRangeUpdated.test.ts b/public/app/features/variables/state/onTimeRangeUpdated.test.ts index 384f48eb448..388916def33 100644 --- a/public/app/features/variables/state/onTimeRangeUpdated.test.ts +++ b/public/app/features/variables/state/onTimeRangeUpdated.test.ts @@ -10,8 +10,7 @@ import { createConstantVariableAdapter } from '../constant/adapter'; import { VariableRefresh } from '../types'; import { constantBuilder, intervalBuilder } from '../shared/testing/builders'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { getRootReducer, RootReducerType } from './helpers'; -import { toVariableIdentifier, toVariablePayload } from './types'; +import { getPreloadedState, getRootReducer, RootReducerType } from './helpers'; import { setCurrentVariableValue, variableStateCompleted, @@ -22,17 +21,20 @@ import { createIntervalOptions } from '../interval/reducer'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; import { notifyApp } from '../../../core/reducers/appNotification'; import { expect } from '../../../../test/lib/common'; -import { TemplatingState } from './reducers'; import { appEvents } from '../../../core/core'; import { variablesInitTransaction } from './transactionReducer'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; variableAdapters.setInit(() => [createIntervalVariableAdapter(), createConstantVariableAdapter()]); const getTestContext = (dashboard: DashboardModel) => { jest.clearAllMocks(); + const key = 'key'; const interval = intervalBuilder() .withId('interval-0') + .withRootStateKey(key) .withName('interval-0') .withOptions('1m', '10m', '30m', '1h', '6h', '12h', '1d', '7d', '14d', '30d') .withCurrent('1m') @@ -41,6 +43,7 @@ const getTestContext = (dashboard: DashboardModel) => { const constant = constantBuilder() .withId('constant-1') + .withRootStateKey(key) .withName('constant-1') .withOptions('a constant') .withCurrent('a constant') @@ -65,17 +68,19 @@ const getTestContext = (dashboard: DashboardModel) => { getModel: () => dashboard, } as unknown as DashboardState; const adapter = variableAdapters.get('interval'); + const templatingState = { + variables: { + 'interval-0': { ...interval }, + 'constant-1': { ...constant }, + }, + }; const preloadedState = { dashboard: dashboardState, - templating: { - variables: { - 'interval-0': { ...interval }, - 'constant-1': { ...constant }, - }, - } as unknown as TemplatingState, + ...getPreloadedState(key, templatingState), } as unknown as RootReducerType; return { + key, interval, range, dependencies, @@ -91,6 +96,7 @@ describe('when onTimeRangeUpdated is dispatched', () => { describe('and options are changed by update', () => { it('then correct actions are dispatched and correct dependencies are called', async () => { const { + key, preloadedState, range, dependencies, @@ -101,20 +107,23 @@ describe('when onTimeRangeUpdated is dispatched', () => { const tester = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(onTimeRangeUpdated(range, dependencies)); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenAsyncActionIsDispatched(onTimeRangeUpdated(key, range, dependencies)); tester.thenDispatchedActionsShouldEqual( - variablesInitTransaction({ uid: 'a uid' }), - variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' })), - createIntervalOptions(toVariablePayload({ type: 'interval', id: 'interval-0' })), - setCurrentVariableValue( - toVariablePayload( - { type: 'interval', id: 'interval-0' }, - { option: { text: '1m', value: '1m', selected: false } } + toKeyedAction(key, variablesInitTransaction({ uid: key })), + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' }))), + toKeyedAction(key, createIntervalOptions(toVariablePayload({ type: 'interval', id: 'interval-0' }))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'interval', id: 'interval-0' }, + { option: { text: '1m', value: '1m', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'interval', id: 'interval-0' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'interval', id: 'interval-0' }))) ); expect(updateTimeRangeMock).toHaveBeenCalledTimes(1); @@ -127,6 +136,7 @@ describe('when onTimeRangeUpdated is dispatched', () => { describe('and options are not changed by update', () => { it('then correct actions are dispatched and correct dependencies are called', async () => { const { + key, interval, preloadedState, range, @@ -138,21 +148,26 @@ describe('when onTimeRangeUpdated is dispatched', () => { const base = await reduxTester({ preloadedState }) .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(setOptionAsCurrent(toVariableIdentifier(interval), interval.options[0], false)); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenAsyncActionIsDispatched( + setOptionAsCurrent(toKeyedVariableIdentifier(interval), interval.options[0], false) + ); - const tester = await base.whenAsyncActionIsDispatched(onTimeRangeUpdated(range, dependencies), true); + const tester = await base.whenAsyncActionIsDispatched(onTimeRangeUpdated(key, range, dependencies), true); tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' })), - createIntervalOptions(toVariablePayload({ type: 'interval', id: 'interval-0' })), - setCurrentVariableValue( - toVariablePayload( - { type: 'interval', id: 'interval-0' }, - { option: { text: '1m', value: '1m', selected: false } } + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' }))), + toKeyedAction(key, createIntervalOptions(toVariablePayload({ type: 'interval', id: 'interval-0' }))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'interval', id: 'interval-0' }, + { option: { text: '1m', value: '1m', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'interval', id: 'interval-0' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'interval', id: 'interval-0' }))) ); expect(updateTimeRangeMock).toHaveBeenCalledTimes(1); @@ -166,6 +181,7 @@ describe('when onTimeRangeUpdated is dispatched', () => { silenceConsoleOutput(); it('then correct actions are dispatched and correct dependencies are called', async () => { const { + key, adapter, preloadedState, range, @@ -179,16 +195,19 @@ describe('when onTimeRangeUpdated is dispatched', () => { const tester = await reduxTester({ preloadedState, debug: true }) .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: 'a uid' })) - .whenAsyncActionIsDispatched(onTimeRangeUpdated(range, dependencies), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenAsyncActionIsDispatched(onTimeRangeUpdated(key, range, dependencies), true); tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => { expect(dispatchedActions[0]).toEqual( - variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' })) + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'interval', id: 'interval-0' }))) ); expect(dispatchedActions[1]).toEqual( - variableStateFailed( - toVariablePayload({ type: 'interval', id: 'interval-0' }, { error: new Error('Something broke') }) + toKeyedAction( + key, + variableStateFailed( + toVariablePayload({ type: 'interval', id: 'interval-0' }, { error: new Error('Something broke') }) + ) ) ); expect(dispatchedActions[2].type).toEqual(notifyApp.type); diff --git a/public/app/features/variables/state/processVariable.test.ts b/public/app/features/variables/state/processVariable.test.ts index 9f0c14ab839..d05cfe193fe 100644 --- a/public/app/features/variables/state/processVariable.test.ts +++ b/public/app/features/variables/state/processVariable.test.ts @@ -1,20 +1,20 @@ import { UrlQueryMap } from '@grafana/data'; -import { getTemplatingRootReducer } from './helpers'; +import { getTemplatingRootReducer, TemplatingReducerType } from './helpers'; import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from '../query/adapter'; import { createCustomVariableAdapter } from '../custom/adapter'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { TemplatingState } from 'app/features/variables/state/reducers'; import { initDashboardTemplating, processVariable } from './actions'; import { setCurrentVariableValue, variableStateCompleted, variableStateFetching } from './sharedReducer'; -import { toVariableIdentifier, toVariablePayload } from './types'; import { VariableRefresh } from '../types'; import { updateVariableOptions } from '../query/reducer'; import { customBuilder, queryBuilder } from '../shared/testing/builders'; import { variablesInitTransaction } from './transactionReducer'; import { setVariableQueryRunner, VariableQueryRunner } from '../query/VariableQueryRunner'; import { setDataSourceSrv } from '@grafana/runtime'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; jest.mock('app/features/dashboard/services/TimeSrv', () => ({ getTimeSrv: jest.fn().mockReturnValue({ @@ -68,9 +68,11 @@ describe('processVariable', () => { // custom doesn't depend on any other variable // queryDependsOnCustom depends on custom // queryNoDepends doesn't depend on any other variable - const getAndSetupProcessVariableContext = () => { + const key = 'key'; + const getTestContext = () => { const custom = customBuilder() .withId('custom') + .withRootStateKey(key) .withName('custom') .withQuery('A,B,C') .withOptions('A', 'B', 'C') @@ -79,6 +81,7 @@ describe('processVariable', () => { const queryDependsOnCustom = queryBuilder() .withId('queryDependsOnCustom') + .withRootStateKey(key) .withName('queryDependsOnCustom') .withQuery('$custom.*') .withOptions('AA', 'AB', 'AC') @@ -87,6 +90,7 @@ describe('processVariable', () => { const queryNoDepends = queryBuilder() .withId('queryNoDepends') + .withRootStateKey(key) .withName('queryNoDepends') .withQuery('*') .withOptions('A', 'B', 'C') @@ -94,13 +98,15 @@ describe('processVariable', () => { .build(); const list = [custom, queryDependsOnCustom, queryNoDepends]; + const dashboard: any = { templating: { list } }; setVariableQueryRunner(new VariableQueryRunner()); return { + key, custom, queryDependsOnCustom, queryNoDepends, - list, + dashboard, }; }; @@ -108,33 +114,41 @@ describe('processVariable', () => { describe('when processVariable is dispatched for a custom variable without dependencies', () => { describe('and queryParams does not match variable', () => { it('then correct actions are dispatched', async () => { - const { list, custom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom } = getTestContext(); const queryParams: UrlQueryMap = {}; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams), true); - await tester.thenDispatchedActionsShouldEqual(variableStateCompleted(toVariablePayload(custom))); + await tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, variableStateCompleted(toVariablePayload(custom))) + ); }); }); describe('and queryParams does match variable', () => { it('then correct actions are dispatched', async () => { - const { list, custom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom } = getTestContext(); const queryParams: UrlQueryMap = { 'var-custom': 'B' }; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload({ type: 'custom', id: 'custom' }, { option: { text: 'B', value: 'B', selected: false } }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: 'custom' }, + { option: { text: 'B', value: 'B', selected: false } } + ) + ) ), - variableStateCompleted(toVariablePayload(custom)) + toKeyedAction(key, variableStateCompleted(toVariablePayload(custom))) ); }); }); @@ -148,15 +162,17 @@ describe('processVariable', () => { describe('and refresh is VariableRefresh.never', () => { const refresh = VariableRefresh.never; it('then correct actions are dispatched', async () => { - const { list, queryNoDepends } = getAndSetupProcessVariableContext(); + const { dashboard, key, queryNoDepends } = getTestContext(); queryNoDepends.refresh = refresh; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(queryNoDepends), queryParams), true); - await tester.thenDispatchedActionsShouldEqual(variableStateCompleted(toVariablePayload(queryNoDepends))); + await tester.thenDispatchedActionsShouldEqual( + toKeyedAction(key, variableStateCompleted(toVariablePayload(queryNoDepends))) + ); }); }); @@ -165,36 +181,42 @@ describe('processVariable', () => { ${VariableRefresh.onDashboardLoad} ${VariableRefresh.onTimeRangeChanged} `('and refresh is $refresh then correct actions are dispatched', async ({ refresh }) => { - const { list, queryNoDepends } = getAndSetupProcessVariableContext(); + const { dashboard, key, queryNoDepends } = getTestContext(); queryNoDepends.refresh = refresh; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(queryNoDepends), queryParams), true); await tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload({ type: 'query', id: 'queryNoDepends' })), - updateVariableOptions( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { - results: [ - { value: 'A', text: 'A' }, - { value: 'B', text: 'B' }, - { value: 'C', text: 'C' }, - ], - templatedRegex: '', - } + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { + results: [ + { value: 'A', text: 'A' }, + { value: 'B', text: 'B' }, + { value: 'C', text: 'C' }, + ], + templatedRegex: '', + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { option: { text: 'A', value: 'A', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { option: { text: 'A', value: 'A', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))) ); }); }); @@ -205,22 +227,25 @@ describe('processVariable', () => { describe('and refresh is VariableRefresh.never', () => { const refresh = VariableRefresh.never; it('then correct actions are dispatched', async () => { - const { list, queryNoDepends } = getAndSetupProcessVariableContext(); + const { dashboard, key, queryNoDepends } = getTestContext(); queryNoDepends.refresh = refresh; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(queryNoDepends), queryParams), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { option: { text: 'B', value: 'B', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { option: { text: 'B', value: 'B', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))) ); }); }); @@ -230,40 +255,49 @@ describe('processVariable', () => { ${VariableRefresh.onDashboardLoad} ${VariableRefresh.onTimeRangeChanged} `('and refresh is $refresh then correct actions are dispatched', async ({ refresh }) => { - const { list, queryNoDepends } = getAndSetupProcessVariableContext(); + const { dashboard, key, queryNoDepends } = getTestContext(); queryNoDepends.refresh = refresh; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(queryNoDepends), queryParams), true); + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(queryNoDepends), queryParams), true); await tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload({ type: 'query', id: 'queryNoDepends' })), - updateVariableOptions( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { - results: [ - { value: 'A', text: 'A' }, - { value: 'B', text: 'B' }, - { value: 'C', text: 'C' }, - ], - templatedRegex: '', - } + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { + results: [ + { value: 'A', text: 'A' }, + { value: 'B', text: 'B' }, + { value: 'C', text: 'C' }, + ], + templatedRegex: '', + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { option: { text: 'A', value: 'A', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { option: { text: 'A', value: 'A', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' })), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryNoDepends' }, - { option: { text: 'B', value: 'B', selected: false } } + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryNoDepends' }))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryNoDepends' }, + { option: { text: 'B', value: 'B', selected: false } } + ) ) ) ); @@ -279,21 +313,21 @@ describe('processVariable', () => { describe('and refresh is VariableRefresh.never', () => { const refresh = VariableRefresh.never; it('then correct actions are dispatched', async () => { - const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom, queryDependsOnCustom } = getTestContext(); queryDependsOnCustom.refresh = refresh; - const customProcessed = await reduxTester<{ templating: TemplatingState }>() + const customProcessed = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain const tester = await customProcessed.whenAsyncActionIsDispatched( - processVariable(toVariableIdentifier(queryDependsOnCustom), queryParams), + processVariable(toKeyedVariableIdentifier(queryDependsOnCustom), queryParams), true ); await tester.thenDispatchedActionsShouldEqual( - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))) ); }); }); @@ -303,41 +337,47 @@ describe('processVariable', () => { ${VariableRefresh.onDashboardLoad} ${VariableRefresh.onTimeRangeChanged} `('and refresh is $refresh then correct actions are dispatched', async ({ refresh }) => { - const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom, queryDependsOnCustom } = getTestContext(); queryDependsOnCustom.refresh = refresh; - const customProcessed = await reduxTester<{ templating: TemplatingState }>() + const customProcessed = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain const tester = await customProcessed.whenAsyncActionIsDispatched( - processVariable(toVariableIdentifier(queryDependsOnCustom), queryParams), + processVariable(toKeyedVariableIdentifier(queryDependsOnCustom), queryParams), true ); await tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })), - updateVariableOptions( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { - results: [ - { value: 'AA', text: 'AA' }, - { value: 'AB', text: 'AB' }, - { value: 'AC', text: 'AC' }, - ], - templatedRegex: '', - } + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { + results: [ + { value: 'AA', text: 'AA' }, + { value: 'AB', text: 'AB' }, + { value: 'AC', text: 'AC' }, + ], + templatedRegex: '', + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { option: { text: 'AA', value: 'AA', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { option: { text: 'AA', value: 'AA', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))) ); }); }); @@ -348,27 +388,30 @@ describe('processVariable', () => { describe('and refresh is VariableRefresh.never', () => { const refresh = VariableRefresh.never; it('then correct actions are dispatched', async () => { - const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom, queryDependsOnCustom } = getTestContext(); queryDependsOnCustom.refresh = refresh; - const customProcessed = await reduxTester<{ templating: TemplatingState }>() + const customProcessed = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain const tester = await customProcessed.whenAsyncActionIsDispatched( - processVariable(toVariableIdentifier(queryDependsOnCustom), queryParams), + processVariable(toKeyedVariableIdentifier(queryDependsOnCustom), queryParams), true ); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { option: { text: 'AB', value: 'AB', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { option: { text: 'AB', value: 'AB', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })) + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))) ); }); }); @@ -378,45 +421,54 @@ describe('processVariable', () => { ${VariableRefresh.onDashboardLoad} ${VariableRefresh.onTimeRangeChanged} `('and refresh is $refresh then correct actions are dispatched', async ({ refresh }) => { - const { list, custom, queryDependsOnCustom } = getAndSetupProcessVariableContext(); + const { key, dashboard, custom, queryDependsOnCustom } = getTestContext(); queryDependsOnCustom.refresh = refresh; - const customProcessed = await reduxTester<{ templating: TemplatingState }>() + const customProcessed = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(variablesInitTransaction({ uid: '' })) - .whenActionIsDispatched(initDashboardTemplating(list)) - .whenAsyncActionIsDispatched(processVariable(toVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain + .whenActionIsDispatched(toKeyedAction(key, variablesInitTransaction({ uid: key }))) + .whenActionIsDispatched(initDashboardTemplating(key, dashboard)) + .whenAsyncActionIsDispatched(processVariable(toKeyedVariableIdentifier(custom), queryParams)); // Need to process this dependency otherwise we never complete the promise chain const tester = await customProcessed.whenAsyncActionIsDispatched( - processVariable(toVariableIdentifier(queryDependsOnCustom), queryParams), + processVariable(toKeyedVariableIdentifier(queryDependsOnCustom), queryParams), true ); await tester.thenDispatchedActionsShouldEqual( - variableStateFetching(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })), - updateVariableOptions( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { - results: [ - { value: 'AA', text: 'AA' }, - { value: 'AB', text: 'AB' }, - { value: 'AC', text: 'AC' }, - ], - templatedRegex: '', - } + toKeyedAction(key, variableStateFetching(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))), + toKeyedAction( + key, + updateVariableOptions( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { + results: [ + { value: 'AA', text: 'AA' }, + { value: 'AB', text: 'AB' }, + { value: 'AC', text: 'AC' }, + ], + templatedRegex: '', + } + ) ) ), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { option: { text: 'AA', value: 'AA', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { option: { text: 'AA', value: 'AA', selected: false } } + ) ) ), - variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' })), - setCurrentVariableValue( - toVariablePayload( - { type: 'query', id: 'queryDependsOnCustom' }, - { option: { text: 'AB', value: 'AB', selected: false } } + toKeyedAction(key, variableStateCompleted(toVariablePayload({ type: 'query', id: 'queryDependsOnCustom' }))), + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'query', id: 'queryDependsOnCustom' }, + { option: { text: 'AB', value: 'AB', selected: false } } + ) ) ) ); diff --git a/public/app/features/variables/state/reducers.test.ts b/public/app/features/variables/state/reducers.test.ts index faaf1f40c15..3e156e2236a 100644 --- a/public/app/features/variables/state/reducers.test.ts +++ b/public/app/features/variables/state/reducers.test.ts @@ -1,10 +1,12 @@ +import { createAction } from '@reduxjs/toolkit'; +import { VariableType } from '@grafana/data'; + import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { initialVariableModelState, QueryVariableModel } from '../types'; import { VariableAdapter, variableAdapters } from '../adapters'; -import { createAction } from '@reduxjs/toolkit'; import { cleanVariables, variablesReducer } from './variablesReducer'; -import { VariablesState, toVariablePayload, VariablePayload } from './types'; -import { VariableType } from '@grafana/data'; +import { VariablePayload, VariablesState } from './types'; +import { toVariablePayload } from '../utils'; const variableAdapter: VariableAdapter = { id: 'mock' as unknown as VariableType, diff --git a/public/app/features/variables/state/reducers.ts b/public/app/features/variables/state/reducers.ts index 005e0b9f33e..7cc63679118 100644 --- a/public/app/features/variables/state/reducers.ts +++ b/public/app/features/variables/state/reducers.ts @@ -1,20 +1,41 @@ -import { combineReducers } from '@reduxjs/toolkit'; -import { optionsPickerReducer } from '../pickers/OptionsPicker/reducer'; -import { variableEditorReducer } from '../editor/reducer'; +import { CombinedState, combineReducers, Reducer } from 'redux'; +import { initialOptionPickerState, optionsPickerReducer, OptionsPickerState } from '../pickers/OptionsPicker/reducer'; +import { initialVariableEditorState, variableEditorReducer, VariableEditorState } from '../editor/reducer'; import { variablesReducer } from './variablesReducer'; -import { transactionReducer } from './transactionReducer'; -import { variableInspectReducer } from '../inspect/reducer'; +import { initialTransactionState, transactionReducer, TransactionState } from './transactionReducer'; +import { initialVariableInspectState, variableInspectReducer, VariableInspectState } from '../inspect/reducer'; +import { initialVariablesState, VariablesState } from './types'; -export const templatingReducers = combineReducers({ - editor: variableEditorReducer, - variables: variablesReducer, - optionsPicker: optionsPickerReducer, - transaction: transactionReducer, - inspect: variableInspectReducer, -}); +export interface TemplatingState { + editor: VariableEditorState; + variables: VariablesState; + optionsPicker: OptionsPickerState; + transaction: TransactionState; + inspect: VariableInspectState; +} -export type TemplatingState = ReturnType; +let templatingReducers: Reducer>; -export default { - templating: templatingReducers, -}; +export function getTemplatingReducers() { + if (!templatingReducers) { + templatingReducers = combineReducers({ + editor: variableEditorReducer, + variables: variablesReducer, + optionsPicker: optionsPickerReducer, + transaction: transactionReducer, + inspect: variableInspectReducer, + }); + } + + return templatingReducers; +} + +export function getInitialTemplatingState() { + return { + editor: initialVariableEditorState, + variables: initialVariablesState, + optionsPicker: initialOptionPickerState, + transaction: initialTransactionState, + inspect: initialVariableInspectState, + }; +} diff --git a/public/app/features/variables/state/selectors.ts b/public/app/features/variables/state/selectors.ts index eb9de79d4a9..685b0b9b51d 100644 --- a/public/app/features/variables/state/selectors.ts +++ b/public/app/features/variables/state/selectors.ts @@ -2,52 +2,63 @@ import { StoreState } from '../../../types'; import { VariableModel } from '../types'; import { getState } from '../../../store/store'; import memoizeOne from 'memoize-one'; +import { getInitialTemplatingState, TemplatingState } from './reducers'; +import { toStateKey } from '../utils'; +import { KeyedVariableIdentifier, VariablesState } from './types'; export const getVariable = ( - id: string, + identifier: KeyedVariableIdentifier, state: StoreState = getState(), throwWhenMissing = true ): T => { - if (!state.templating.variables[id]) { + const { id, rootStateKey } = identifier; + const variablesState = getVariablesState(rootStateKey, state); + if (!variablesState.variables[id]) { if (throwWhenMissing) { throw new Error(`Couldn't find variable with id:${id}`); } return undefined as unknown as T; } - return state.templating.variables[id] as T; + return variablesState.variables[id] as T; }; -export const getFilteredVariables = (filter: (model: VariableModel) => boolean, state: StoreState = getState()) => { - return Object.values(state.templating.variables) +function getFilteredVariablesByKey( + filter: (model: VariableModel) => boolean, + key: string, + state: StoreState = getState() +) { + return Object.values(getVariablesState(key, state).variables) .filter(filter) .sort((s1, s2) => s1.index - s2.index); -}; +} -export const getVariableWithName = (name: string, state: StoreState = getState()) => { - return getVariable(name, state, false); -}; +export function getVariablesState(key: string, state: StoreState = getState()): TemplatingState { + return state.templating.keys[toStateKey(key)] ?? getInitialTemplatingState(); +} -export const getVariables = (state: StoreState = getState()): VariableModel[] => { - return getFilteredVariables(defaultVariablesFilter, state); -}; +export function getVariablesByKey(key: string, state: StoreState = getState()): VariableModel[] { + return getFilteredVariablesByKey(defaultVariablesFilter, key, state); +} -export function defaultVariablesFilter(variable: VariableModel): boolean { +function defaultVariablesFilter(variable: VariableModel): boolean { return variable.type !== 'system'; } -export const getSubMenuVariables = memoizeOne((variables: Record): VariableModel[] => { - return getVariables(getState()); -}); +export const getSubMenuVariables = memoizeOne( + (key: string, variables: Record): VariableModel[] => { + return getVariablesByKey(key, getState()); + } +); -export const getEditorVariables = (state: StoreState): VariableModel[] => { - return getVariables(state); +export const getEditorVariables = (key: string, state: StoreState): VariableModel[] => { + return getVariablesByKey(key, state); }; -export type GetVariables = typeof getVariables; +export type GetVariables = typeof getVariablesByKey; -export function getNewVariableIndex(state: StoreState = getState()): number { - return getNextVariableIndex(Object.values(state.templating.variables)); +export function getNewVariableIndex(key: string, state: StoreState = getState()): number { + return getNextVariableIndex(Object.values(getVariablesState(key, state).variables)); } export function getNextVariableIndex(variables: VariableModel[]): number { @@ -55,6 +66,47 @@ export function getNextVariableIndex(variables: VariableModel[]): number { return sorted.length > 0 ? sorted[sorted.length - 1].index + 1 : 0; } -export function getVariablesIsDirty(state: StoreState = getState()): boolean { - return state.templating.transaction.isDirty; +export function getVariablesIsDirty(key: string, state: StoreState = getState()): boolean { + return getVariablesState(key, state).transaction.isDirty; +} + +export function getIfExistsLastKey(state: StoreState = getState()): string | undefined { + return state.templating?.lastKey; +} + +export function getLastKey(state: StoreState = getState()): string { + if (!state.templating?.lastKey) { + throw new Error('Accessing lastKey without initializing it variables'); + } + + return state.templating.lastKey; +} + +// selectors used by template srv, assumes that lastKey is in state. Needs to change when/if dashboard redux state becomes keyed too. +export function getFilteredVariables(filter: (model: VariableModel) => boolean, state: StoreState = getState()) { + const lastKey = getIfExistsLastKey(state); + if (!lastKey) { + return []; + } + return getFilteredVariablesByKey(filter, lastKey, state); +} + +export function getVariables(state: StoreState = getState()) { + const lastKey = getIfExistsLastKey(state); + if (!lastKey) { + return []; + } + return getVariablesByKey(lastKey, state); +} + +export function getVariableWithName(name: string, state: StoreState = getState()) { + const lastKey = getIfExistsLastKey(state); + if (!lastKey) { + return; + } + return getVariable({ id: name, rootStateKey: lastKey, type: 'query' }, state, false); +} + +export function getInstanceState(state: VariablesState, id: string) { + return state[id] as Model; } diff --git a/public/app/features/variables/state/setOptionFromUrl.test.ts b/public/app/features/variables/state/setOptionFromUrl.test.ts index 0c5cd3a66a5..5128deefe8a 100644 --- a/public/app/features/variables/state/setOptionFromUrl.test.ts +++ b/public/app/features/variables/state/setOptionFromUrl.test.ts @@ -2,12 +2,12 @@ import { variableAdapters } from '../adapters'; import { createCustomVariableAdapter } from '../custom/adapter'; import { customBuilder } from '../shared/testing/builders'; import { reduxTester } from '../../../../test/core/redux/reduxTester'; -import { TemplatingState } from './reducers'; -import { getTemplatingRootReducer } from './helpers'; +import { getTemplatingRootReducer, TemplatingReducerType } from './helpers'; import { addVariable, setCurrentVariableValue } from './sharedReducer'; -import { toVariableIdentifier, toVariablePayload } from './types'; import { setOptionFromUrl } from './actions'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; variableAdapters.setInit(() => [createCustomVariableAdapter()]); @@ -28,16 +28,31 @@ describe('when setOptionFromUrl is dispatched with a custom variable (no refresh ${null} | ${true} | ${['']} ${undefined} | ${true} | ${['']} `('and urlValue is $urlValue then correct actions are dispatched', async ({ urlValue, expected, isMulti }) => { - const custom = customBuilder().withId('0').withMulti(isMulti).withOptions('A', 'B', 'C').withCurrent('A').build(); + const key = 'key'; + const custom = customBuilder() + .withId('0') + .withRootStateKey(key) + .withMulti(isMulti) + .withOptions('A', 'B', 'C') + .withCurrent('A') + .build(); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenAsyncActionIsDispatched(setOptionFromUrl(toVariableIdentifier(custom), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenAsyncActionIsDispatched(setOptionFromUrl(toKeyedVariableIdentifier(custom), urlValue), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload({ type: 'custom', id: '0' }, { option: { text: expected, value: expected, selected: false } }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: '0' }, + { option: { text: expected, value: expected, selected: false } } + ) + ) ) ); }); @@ -47,8 +62,10 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v it('and urlValue contains same all value then correct actions are dispatched', async () => { const allValue = '.*'; const urlValue = allValue; + const key = 'key'; const custom = customBuilder() .withId('0') + .withRootStateKey(key) .withMulti(false) .withIncludeAll() .withAllValue(allValue) @@ -56,16 +73,21 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v .withCurrent('A') .build(); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenAsyncActionIsDispatched(setOptionFromUrl(toVariableIdentifier(custom), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenAsyncActionIsDispatched(setOptionFromUrl(toKeyedVariableIdentifier(custom), urlValue), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload( - { type: 'custom', id: '0' }, - { option: { text: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE, selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: '0' }, + { option: { text: ALL_VARIABLE_TEXT, value: ALL_VARIABLE_VALUE, selected: false } } + ) ) ) ); @@ -74,8 +96,10 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v it('and urlValue differs from all value then correct actions are dispatched', async () => { const allValue = '.*'; const urlValue = 'X'; + const key = 'key'; const custom = customBuilder() .withId('0') + .withRootStateKey(key) .withMulti(false) .withIncludeAll() .withAllValue(allValue) @@ -83,14 +107,19 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v .withCurrent('A') .build(); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenAsyncActionIsDispatched(setOptionFromUrl(toVariableIdentifier(custom), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenAsyncActionIsDispatched(setOptionFromUrl(toKeyedVariableIdentifier(custom), urlValue), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload({ type: 'custom', id: '0' }, { option: { text: 'X', value: 'X', selected: false } }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload({ type: 'custom', id: '0' }, { option: { text: 'X', value: 'X', selected: false } }) + ) ) ); }); @@ -98,8 +127,10 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v it('and urlValue differs but matches an option then correct actions are dispatched', async () => { const allValue = '.*'; const urlValue = 'B'; + const key = 'key'; const custom = customBuilder() .withId('0') + .withRootStateKey(key) .withMulti(false) .withIncludeAll() .withAllValue(allValue) @@ -107,14 +138,19 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v .withCurrent('A') .build(); - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenAsyncActionIsDispatched(setOptionFromUrl(toVariableIdentifier(custom), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenAsyncActionIsDispatched(setOptionFromUrl(toKeyedVariableIdentifier(custom), urlValue), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload({ type: 'custom', id: '0' }, { option: { text: 'B', value: 'B', selected: false } }) + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload({ type: 'custom', id: '0' }, { option: { text: 'B', value: 'B', selected: false } }) + ) ) ); }); @@ -122,8 +158,10 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v it('and custom all value matches an option', async () => { const allValue = '.*'; const urlValue = allValue; + const key = 'key'; const custom = customBuilder() .withId('0') + .withRootStateKey(key) .withMulti(false) .withIncludeAll() .withAllValue(allValue) @@ -133,16 +171,21 @@ describe('when setOptionFromUrl is dispatched for a variable with a custom all v custom.options[2].value = 'special value for .*'; - const tester = await reduxTester<{ templating: TemplatingState }>() + const tester = await reduxTester() .givenRootReducer(getTemplatingRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) - .whenAsyncActionIsDispatched(setOptionFromUrl(toVariableIdentifier(custom), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(custom, { global: false, index: 0, model: custom }))) + ) + .whenAsyncActionIsDispatched(setOptionFromUrl(toKeyedVariableIdentifier(custom), urlValue), true); await tester.thenDispatchedActionsShouldEqual( - setCurrentVariableValue( - toVariablePayload( - { type: 'custom', id: '0' }, - { option: { text: '.*', value: 'special value for .*', selected: false } } + toKeyedAction( + key, + setCurrentVariableValue( + toVariablePayload( + { type: 'custom', id: '0' }, + { option: { text: '.*', value: 'special value for .*', selected: false } } + ) ) ) ); diff --git a/public/app/features/variables/state/sharedReducer.test.ts b/public/app/features/variables/state/sharedReducer.test.ts index 7721a92c583..ec1b2deb01b 100644 --- a/public/app/features/variables/state/sharedReducer.test.ts +++ b/public/app/features/variables/state/sharedReducer.test.ts @@ -17,7 +17,7 @@ import { variableStateNotStarted, } from './sharedReducer'; import { ConstantVariableModel, QueryVariableModel, VariableHide, VariableOption } from '../types'; -import { initialVariablesState, toVariablePayload, VariableIdentifier, VariablesState } from './types'; +import { initialVariablesState, KeyedVariableIdentifier, VariablesState } from './types'; import { variableAdapters } from '../adapters'; import { createQueryVariableAdapter } from '../query/adapter'; import { initialQueryVariableModelState } from '../query/reducer'; @@ -26,6 +26,7 @@ import { changeVariableNameSucceeded } from '../editor/reducer'; import { createConstantVariableAdapter } from '../constant/adapter'; import { initialConstantVariableModelState } from '../constant/reducer'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../constants'; +import { toVariablePayload } from '../utils'; variableAdapters.setInit(() => [createQueryVariableAdapter(), createConstantVariableAdapter()]); @@ -102,6 +103,7 @@ describe('sharedReducer', () => { .thenStateShouldEqual({ '0': { id: '0', + rootStateKey: 'key', type: 'query', name: 'Name-0', hide: VariableHide.dontHide, @@ -115,6 +117,7 @@ describe('sharedReducer', () => { }, '2': { id: '2', + rootStateKey: 'key', type: 'query', name: 'Name-2', hide: VariableHide.dontHide, @@ -140,6 +143,7 @@ describe('sharedReducer', () => { .thenStateShouldEqual({ '0': { id: '0', + rootStateKey: 'key', type: 'query', name: 'Name-0', hide: VariableHide.dontHide, @@ -153,6 +157,7 @@ describe('sharedReducer', () => { }, '2': { id: '2', + rootStateKey: 'key', type: 'query', name: 'Name-2', hide: VariableHide.dontHide, @@ -179,6 +184,7 @@ describe('sharedReducer', () => { ...initialState, '0': { id: '0', + rootStateKey: 'key', type: 'query', name: 'Name-0', hide: VariableHide.dontHide, @@ -192,6 +198,7 @@ describe('sharedReducer', () => { }, '1': { id: '1', + rootStateKey: 'key', type: 'query', name: 'Name-1', hide: VariableHide.dontHide, @@ -205,6 +212,7 @@ describe('sharedReducer', () => { }, '2': { id: '2', + rootStateKey: 'key', type: 'query', name: 'Name-2', hide: VariableHide.dontHide, @@ -219,6 +227,7 @@ describe('sharedReducer', () => { '11': { ...initialQueryVariableModelState, id: '11', + rootStateKey: 'key', name: 'copy_of_Name-1', index: 3, label: 'Label-1', @@ -237,6 +246,7 @@ describe('sharedReducer', () => { .thenStateShouldEqual({ '0': { id: '0', + rootStateKey: 'key', type: 'query', name: 'Name-0', hide: VariableHide.dontHide, @@ -250,6 +260,7 @@ describe('sharedReducer', () => { }, '1': { id: '1', + rootStateKey: 'key', type: 'query', name: 'Name-1', hide: VariableHide.dontHide, @@ -263,6 +274,7 @@ describe('sharedReducer', () => { }, '2': { id: '2', + rootStateKey: 'key', type: 'query', name: 'Name-2', hide: VariableHide.dontHide, @@ -286,6 +298,7 @@ describe('sharedReducer', () => { .thenStateShouldEqual({ '0': { id: '0', + rootStateKey: 'key', type: 'query', name: 'Name-0', hide: VariableHide.dontHide, @@ -299,6 +312,7 @@ describe('sharedReducer', () => { }, '1': { id: '1', + rootStateKey: 'key', type: 'query', name: 'Name-1', hide: VariableHide.dontHide, @@ -312,6 +326,7 @@ describe('sharedReducer', () => { }, '2': { id: '2', + rootStateKey: 'key', type: 'query', name: 'Name-2', hide: VariableHide.dontHide, @@ -548,7 +563,7 @@ describe('sharedReducer', () => { const constantAdapter = createConstantVariableAdapter(); const { initialState: constantAdapterState } = getVariableTestContext(constantAdapter); const newType = 'constant' as VariableType; - const identifier: VariableIdentifier = { id: '0', type: 'query' }; + const identifier: KeyedVariableIdentifier = { id: '0', type: 'query', rootStateKey: 'key' }; const payload = toVariablePayload(identifier, { newType }); reducerTester() .givenReducer(sharedReducer, cloneDeep(queryAdapterState)) @@ -564,6 +579,7 @@ describe('sharedReducer', () => { ...constantAdapterState, '0': { ...constantAdapterState[0], + rootStateKey: 'key', name: 'test', description: 'new description', label: 'new label', diff --git a/public/app/features/variables/state/sharedReducer.ts b/public/app/features/variables/state/sharedReducer.ts index 1a4225b14ad..7b5e4ecb6ce 100644 --- a/public/app/features/variables/state/sharedReducer.ts +++ b/public/app/features/variables/state/sharedReducer.ts @@ -2,11 +2,11 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { cloneDeep, defaults as lodashDefaults } from 'lodash'; import { LoadingState, VariableType } from '@grafana/data'; import { VariableModel, VariableOption, VariableWithOptions } from '../types'; -import { AddVariable, getInstanceState, initialVariablesState, VariablePayload, VariablesState } from './types'; +import { AddVariable, initialVariablesState, VariablePayload, VariablesState } from './types'; import { variableAdapters } from '../adapters'; import { changeVariableNameSucceeded } from '../editor/reducer'; import { ensureStringValues } from '../utils'; -import { getNextVariableIndex } from './selectors'; +import { getInstanceState, getNextVariableIndex } from './selectors'; const sharedReducerSlice = createSlice({ name: 'templating/shared', @@ -100,11 +100,12 @@ const sharedReducerSlice = createSlice({ }, changeVariableType: (state: VariablesState, action: PayloadAction>) => { const { id } = action.payload; - const { label, name, index, description } = state[id]; + const { label, name, index, description, rootStateKey } = state[id]; state[id] = { ...cloneDeep(variableAdapters.get(action.payload.data.newType).initialState), - id: id, + id, + rootStateKey: rootStateKey, label, name, index, diff --git a/public/app/features/variables/state/templateVarsChangedInUrl.test.ts b/public/app/features/variables/state/templateVarsChangedInUrl.test.ts index c821aaafc63..7163f2c9bf0 100644 --- a/public/app/features/variables/state/templateVarsChangedInUrl.test.ts +++ b/public/app/features/variables/state/templateVarsChangedInUrl.test.ts @@ -2,12 +2,12 @@ import { variableAdapters } from '../adapters'; import { constantBuilder, customBuilder } from '../shared/testing/builders'; import { DashboardState, StoreState } from '../../../types'; import { initialState } from '../../dashboard/state/reducers'; -import { TemplatingState } from './reducers'; import { ExtendedUrlQueryMap } from '../utils'; import { templateVarsChangedInUrl } from './actions'; import { createCustomVariableAdapter } from '../custom/adapter'; import { VariablesState } from './types'; import { DashboardModel } from '../../dashboard/state'; +import { getPreloadedState } from './helpers'; import { createConstantVariableAdapter } from '../constant/adapter'; import { VariableModel } from '../types'; @@ -18,9 +18,11 @@ variableAdapters.setInit(() => [createCustomVariableAdapter(), createConstantVar async function getTestContext(urlQueryMap: ExtendedUrlQueryMap = {}, variable: VariableModel | undefined = undefined) { jest.clearAllMocks(); + const key = 'key'; if (!variable) { variable = customBuilder() .withId('variable') + .withRootStateKey(key) .withName('variable') .withCurrent(['A', 'C']) .withOptions('A', 'B', 'C') @@ -43,15 +45,14 @@ async function getTestContext(urlQueryMap: ExtendedUrlQueryMap = {}, variable: V }; const variables: VariablesState = { variable }; - const templating = { variables } as unknown as TemplatingState; const state: Partial = { dashboard, - templating, + ...getPreloadedState(key, { variables }), }; const getState = () => state as unknown as StoreState; const dispatch = jest.fn(); - const thunk = templateVarsChangedInUrl(urlQueryMap); + const thunk = templateVarsChangedInUrl(key, urlQueryMap); await thunk(dispatch, getState, undefined); @@ -125,6 +126,7 @@ describe('templateVarsChangedInUrl', () => { it('then the value should change to the value in dashboard json and dashboard should be refreshed', async () => { const constant = constantBuilder() .withId('variable') + .withRootStateKey('key') .withName('variable') .withQuery('default value in dash.json') .build(); diff --git a/public/app/features/variables/state/transactionReducer.ts b/public/app/features/variables/state/transactionReducer.ts index 974943a61b2..05d4a1c6cdf 100644 --- a/public/app/features/variables/state/transactionReducer.ts +++ b/public/app/features/variables/state/transactionReducer.ts @@ -52,14 +52,14 @@ const transactionSlice = createSlice({ }); function actionAffectsDirtyState(action: AnyAction): boolean { - return [ - removeVariable.type, - addVariable.type, - changeVariableProp.type, - changeVariableOrder.type, - duplicateVariable.type, - changeVariableType.type, - ].includes(action.type); + return ( + removeVariable.match(action) || + addVariable.match(action) || + changeVariableProp.match(action) || + changeVariableOrder.match(action) || + duplicateVariable.match(action) || + changeVariableType.match(action) + ); } export const { variablesInitTransaction, variablesClearTransaction, variablesCompleteTransaction } = diff --git a/public/app/features/variables/state/types.ts b/public/app/features/variables/state/types.ts index 0e652946b08..603f18753e0 100644 --- a/public/app/features/variables/state/types.ts +++ b/public/app/features/variables/state/types.ts @@ -6,15 +6,17 @@ export interface VariablesState extends Record {} export const initialVariablesState: VariablesState = {}; -export const getInstanceState = (state: VariablesState, id: string) => { - return state[id] as Model; -}; - export interface VariableIdentifier { type: VariableType; id: string; } +export interface KeyedVariableIdentifier { + type: VariableType; + id: string; + rootStateKey: string; +} + export interface VariablePayload extends VariableIdentifier { data: T; } @@ -24,21 +26,3 @@ export interface AddVariable { index: number; // the order in variables list model: T; } - -export const toVariableIdentifier = (variable: VariableModel): VariableIdentifier => { - return { type: variable.type, id: variable.id }; -}; - -export function toVariablePayload( - identifier: VariableIdentifier, - data?: T -): VariablePayload; -// eslint-disable-next-line -export function toVariablePayload(model: VariableModel, data?: T): VariablePayload; -// eslint-disable-next-line -export function toVariablePayload( - obj: VariableIdentifier | VariableModel, - data?: T -): VariablePayload { - return { type: obj.type, id: obj.id, data: data as T }; -} diff --git a/public/app/features/variables/state/upgradeLegacyQueries.test.ts b/public/app/features/variables/state/upgradeLegacyQueries.test.ts index 27a66721e74..d3f68d6e90d 100644 --- a/public/app/features/variables/state/upgradeLegacyQueries.test.ts +++ b/public/app/features/variables/state/upgradeLegacyQueries.test.ts @@ -1,10 +1,12 @@ import { customBuilder, queryBuilder } from '../shared/testing/builders'; import { VariableSupportType } from '@grafana/data'; -import { toVariableIdentifier } from './types'; import { upgradeLegacyQueries } from './actions'; import { changeVariableProp } from './sharedReducer'; import { thunkTester } from '../../../../test/core/thunk/thunkTester'; import { TransactionStatus, VariableModel } from '../types'; +import { toKeyedAction } from './keyedVariablesReducer'; +import { getPreloadedState } from './helpers'; +import { toKeyedVariableIdentifier } from '../utils'; interface Args { query?: any; @@ -18,22 +20,23 @@ function getTestContext({ datasource, transactionStatus = TransactionStatus.Fetching, }: Args = {}) { + const key = 'key'; variable = variable ?? queryBuilder() .withId('query') + .withRootStateKey(key) .withName('query') .withQuery(query) .withDatasource({ uid: 'test-data', type: 'test-data' }) .build(); - const state = { - templating: { - transaction: { status: transactionStatus }, - variables: { - [variable.id]: variable, - }, + const templatingState = { + transaction: { status: transactionStatus, uid: key, isDirty: false }, + variables: { + [variable.id]: variable, }, }; + const state = getPreloadedState(key, templatingState); datasource = datasource ?? { name: 'TestData', metricFindQuery: () => undefined, @@ -41,32 +44,35 @@ function getTestContext({ }; const get = jest.fn().mockResolvedValue(datasource); const getDatasourceSrv = jest.fn().mockReturnValue({ get }); - const identifier = toVariableIdentifier(variable); + const identifier = toKeyedVariableIdentifier(variable); - return { state, get, getDatasourceSrv, identifier }; + return { key, state, get, getDatasourceSrv, identifier }; } describe('upgradeLegacyQueries', () => { describe('when called with a query variable for a standard variable supported data source that has not been upgraded', () => { it('then it should dispatch changeVariableProp', async () => { - const { state, identifier, get, getDatasourceSrv } = getTestContext({ query: '*' }); + const { key, state, identifier, get, getDatasourceSrv } = getTestContext({ query: '*' }); const dispatchedActions = await thunkTester(state) .givenThunk(upgradeLegacyQueries) .whenThunkIsDispatched(identifier, getDatasourceSrv); expect(dispatchedActions).toEqual([ - changeVariableProp({ - type: 'query', - id: 'query', - data: { - propName: 'query', - propValue: { - refId: 'TestData-query-Variable-Query', - query: '*', + toKeyedAction( + key, + changeVariableProp({ + type: 'query', + id: 'query', + data: { + propName: 'query', + propValue: { + refId: 'TestData-query-Variable-Query', + query: '*', + }, }, - }, - }), + }) + ), ]); expect(get).toHaveBeenCalledTimes(1); expect(get).toHaveBeenCalledWith({ uid: 'test-data', type: 'test-data' }); @@ -161,7 +167,7 @@ describe('upgradeLegacyQueries', () => { describe('when called with a custom variable', () => { it('then it should not dispatch any actions', async () => { - const variable = customBuilder().withId('custom').withName('custom').build(); + const variable = customBuilder().withId('custom').withRootStateKey('key').withName('custom').build(); const { state, identifier, get, getDatasourceSrv } = getTestContext({ variable }); const dispatchedActions = await thunkTester(state) diff --git a/public/app/features/variables/textbox/TextBoxVariablePicker.tsx b/public/app/features/variables/textbox/TextBoxVariablePicker.tsx index cc1dbaf9743..7ad4152683d 100644 --- a/public/app/features/variables/textbox/TextBoxVariablePicker.tsx +++ b/public/app/features/variables/textbox/TextBoxVariablePicker.tsx @@ -1,12 +1,13 @@ import React, { ChangeEvent, FocusEvent, KeyboardEvent, ReactElement, useCallback, useEffect, useState } from 'react'; import { TextBoxVariableModel } from '../types'; -import { toVariablePayload } from '../state/types'; import { changeVariableProp } from '../state/sharedReducer'; import { VariablePickerProps } from '../pickers/types'; import { Input } from '@grafana/ui'; import { variableAdapters } from '../adapters'; import { useDispatch } from 'react-redux'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toVariablePayload } from '../utils'; export interface Props extends VariablePickerProps {} @@ -18,13 +19,21 @@ export function TextBoxVariablePicker({ variable, onVariableChange }: Props): Re }, [variable]); const updateVariable = useCallback(() => { + if (!variable.rootStateKey) { + console.error('Cannot update variable without rootStateKey'); + return; + } + if (variable.current.value === updatedValue) { return; } dispatch( - changeVariableProp( - toVariablePayload({ id: variable.id, type: variable.type }, { propName: 'query', propValue: updatedValue }) + toKeyedAction( + variable.rootStateKey, + changeVariableProp( + toVariablePayload({ id: variable.id, type: variable.type }, { propName: 'query', propValue: updatedValue }) + ) ) ); diff --git a/public/app/features/variables/textbox/actions.test.ts b/public/app/features/variables/textbox/actions.test.ts index e605a69e80b..4c38b863523 100644 --- a/public/app/features/variables/textbox/actions.test.ts +++ b/public/app/features/variables/textbox/actions.test.ts @@ -4,11 +4,12 @@ import { reduxTester } from '../../../../test/core/redux/reduxTester'; import { setTextBoxVariableOptionsFromUrl, updateTextBoxVariableOptions } from './actions'; import { getRootReducer, RootReducerType } from '../state/helpers'; import { VariableOption } from '../types'; -import { toVariablePayload } from '../state/types'; import { createTextBoxOptions } from './reducer'; import { addVariable, changeVariableProp, setCurrentVariableValue } from '../state/sharedReducer'; import { textboxBuilder } from '../shared/testing/builders'; import { locationService } from '@grafana/runtime'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; +import { toKeyedVariableIdentifier, toVariablePayload } from '../utils'; jest.mock('@grafana/runtime', () => { const original = jest.requireActual('@grafana/runtime'); @@ -32,16 +33,25 @@ describe('textbox actions', () => { selected: false, }; - const variable = textboxBuilder().withId('textbox').withName('textbox').withCurrent('A').withQuery('A').build(); + const key = 'key'; + const variable = textboxBuilder() + .withId('textbox') + .withRootStateKey(key) + .withName('textbox') + .withCurrent('A') + .withQuery('A') + .build(); const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenAsyncActionIsDispatched(updateTextBoxVariableOptions(toVariablePayload(variable)), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenAsyncActionIsDispatched(updateTextBoxVariableOptions(toKeyedVariableIdentifier(variable)), true); tester.thenDispatchedActionsShouldEqual( - createTextBoxOptions(toVariablePayload(variable)), - setCurrentVariableValue(toVariablePayload(variable, { option })) + toKeyedAction(key, createTextBoxOptions(toVariablePayload(variable))), + toKeyedAction(key, setCurrentVariableValue(toVariablePayload(variable, { option }))) ); expect(locationService.partial).toHaveBeenLastCalledWith({ 'var-textbox': 'A' }); }); @@ -50,16 +60,31 @@ describe('textbox actions', () => { describe('when setTextBoxVariableOptionsFromUrl is dispatched', () => { it('then correct actions are dispatched', async () => { const urlValue = 'bB'; - const variable = textboxBuilder().withId('textbox').withName('textbox').withCurrent('A').withQuery('A').build(); + const key = 'key'; + const variable = textboxBuilder() + .withId('textbox') + .withRootStateKey(key) + .withName('textbox') + .withCurrent('A') + .withQuery('A') + .build(); const tester = await reduxTester() .givenRootReducer(getRootReducer()) - .whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) - .whenAsyncActionIsDispatched(setTextBoxVariableOptionsFromUrl(toVariablePayload(variable), urlValue), true); + .whenActionIsDispatched( + toKeyedAction(key, addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable }))) + ) + .whenAsyncActionIsDispatched( + setTextBoxVariableOptionsFromUrl(toKeyedVariableIdentifier(variable), urlValue), + true + ); tester.thenDispatchedActionsShouldEqual( - changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: 'bB' })), - setCurrentVariableValue(toVariablePayload(variable, { option: { text: 'bB', value: 'bB', selected: false } })) + toKeyedAction(key, changeVariableProp(toVariablePayload(variable, { propName: 'query', propValue: 'bB' }))), + toKeyedAction( + key, + setCurrentVariableValue(toVariablePayload(variable, { option: { text: 'bB', value: 'bB', selected: false } })) + ) ); }); }); diff --git a/public/app/features/variables/textbox/actions.ts b/public/app/features/variables/textbox/actions.ts index 662f45b4791..4e10c499168 100644 --- a/public/app/features/variables/textbox/actions.ts +++ b/public/app/features/variables/textbox/actions.ts @@ -3,28 +3,36 @@ import { ThunkResult } from '../../../types'; import { getVariable } from '../state/selectors'; import { variableAdapters } from '../adapters'; import { createTextBoxOptions } from './reducer'; -import { toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; +import { KeyedVariableIdentifier } from '../state/types'; import { setOptionFromUrl } from '../state/actions'; import { UrlQueryValue } from '@grafana/data'; import { changeVariableProp } from '../state/sharedReducer'; -import { ensureStringValues } from '../utils'; +import { ensureStringValues, toKeyedVariableIdentifier, toVariablePayload } from '../utils'; +import { toKeyedAction } from '../state/keyedVariablesReducer'; -export const updateTextBoxVariableOptions = (identifier: VariableIdentifier): ThunkResult => { +export const updateTextBoxVariableOptions = (identifier: KeyedVariableIdentifier): ThunkResult => { return async (dispatch, getState) => { - await dispatch(createTextBoxOptions(toVariablePayload(identifier))); + const { rootStateKey, type } = identifier; + dispatch(toKeyedAction(rootStateKey, createTextBoxOptions(toVariablePayload(identifier)))); - const variableInState = getVariable(identifier.id, getState()); - await variableAdapters.get(identifier.type).setValue(variableInState, variableInState.options[0], true); + const variableInState = getVariable(identifier, getState()); + await variableAdapters.get(type).setValue(variableInState, variableInState.options[0], true); }; }; export const setTextBoxVariableOptionsFromUrl = - (identifier: VariableIdentifier, urlValue: UrlQueryValue): ThunkResult => + (identifier: KeyedVariableIdentifier, urlValue: UrlQueryValue): ThunkResult => async (dispatch, getState) => { - const variableInState = getVariable(identifier.id, getState()); + const { rootStateKey } = identifier; + const variableInState = getVariable(identifier, getState()); const stringUrlValue = ensureStringValues(urlValue); - dispatch(changeVariableProp(toVariablePayload(variableInState, { propName: 'query', propValue: stringUrlValue }))); + dispatch( + toKeyedAction( + rootStateKey, + changeVariableProp(toVariablePayload(variableInState, { propName: 'query', propValue: stringUrlValue })) + ) + ); - await dispatch(setOptionFromUrl(toVariableIdentifier(variableInState), stringUrlValue)); + await dispatch(setOptionFromUrl(toKeyedVariableIdentifier(variableInState), stringUrlValue)); }; diff --git a/public/app/features/variables/textbox/adapter.test.ts b/public/app/features/variables/textbox/adapter.test.ts index e2e9d1db7ba..f28b7fd5c7c 100644 --- a/public/app/features/variables/textbox/adapter.test.ts +++ b/public/app/features/variables/textbox/adapter.test.ts @@ -11,6 +11,7 @@ describe('createTextBoxVariableAdapter', () => { it('then the model should be correct', () => { const text = textboxBuilder() .withId('text') + .withRootStateKey('key') .withName('text') .withQuery('query') .withOriginalQuery('original') @@ -41,6 +42,7 @@ describe('createTextBoxVariableAdapter', () => { it('then the model should be correct', () => { const text = textboxBuilder() .withId('text') + .withRootStateKey('key') .withName('text') .withQuery('query') .withOriginalQuery('original') diff --git a/public/app/features/variables/textbox/adapter.ts b/public/app/features/variables/textbox/adapter.ts index c910867f020..a916520eaca 100644 --- a/public/app/features/variables/textbox/adapter.ts +++ b/public/app/features/variables/textbox/adapter.ts @@ -8,7 +8,7 @@ import { VariableAdapter } from '../adapters'; import { TextBoxVariablePicker } from './TextBoxVariablePicker'; import { TextBoxVariableEditor } from './TextBoxVariableEditor'; import { setTextBoxVariableOptionsFromUrl, updateTextBoxVariableOptions } from './actions'; -import { toVariableIdentifier } from '../state/types'; +import { toKeyedVariableIdentifier } from '../utils'; export const createTextBoxVariableAdapter = (): VariableAdapter => { return { @@ -23,16 +23,16 @@ export const createTextBoxVariableAdapter = (): VariableAdapter { - await dispatch(setOptionAsCurrent(toVariableIdentifier(variable), option, emitChanges)); + await dispatch(setOptionAsCurrent(toKeyedVariableIdentifier(variable), option, emitChanges)); }, setValueFromUrl: async (variable, urlValue) => { - await dispatch(setTextBoxVariableOptionsFromUrl(toVariableIdentifier(variable), urlValue)); + await dispatch(setTextBoxVariableOptionsFromUrl(toKeyedVariableIdentifier(variable), urlValue)); }, updateOptions: async (variable) => { - await dispatch(updateTextBoxVariableOptions(toVariableIdentifier(variable))); + await dispatch(updateTextBoxVariableOptions(toKeyedVariableIdentifier(variable))); }, getSaveModel: (variable, saveCurrentAsDefault) => { - const { index, id, state, global, originalQuery, ...rest } = cloneDeep(variable); + const { index, id, state, global, originalQuery, rootStateKey, ...rest } = cloneDeep(variable); if (variable.query !== originalQuery && !saveCurrentAsDefault) { const origQuery = originalQuery ?? ''; diff --git a/public/app/features/variables/textbox/reducer.test.ts b/public/app/features/variables/textbox/reducer.test.ts index fc4c4663856..fbd83a44318 100644 --- a/public/app/features/variables/textbox/reducer.test.ts +++ b/public/app/features/variables/textbox/reducer.test.ts @@ -1,10 +1,11 @@ import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { cloneDeep } from 'lodash'; import { getVariableTestContext } from '../state/helpers'; -import { toVariablePayload, VariablesState } from '../state/types'; +import { VariablesState } from '../state/types'; import { createTextBoxOptions, textBoxVariableReducer } from './reducer'; import { TextBoxVariableModel } from '../types'; import { createTextBoxVariableAdapter } from './adapter'; +import { toVariablePayload } from '../utils'; describe('textBoxVariableReducer', () => { const adapter = createTextBoxVariableAdapter(); diff --git a/public/app/features/variables/textbox/reducer.ts b/public/app/features/variables/textbox/reducer.ts index d8850d80225..405cde2da91 100644 --- a/public/app/features/variables/textbox/reducer.ts +++ b/public/app/features/variables/textbox/reducer.ts @@ -1,7 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { initialVariableModelState, TextBoxVariableModel, VariableOption } from '../types'; -import { getInstanceState, VariablePayload, initialVariablesState, VariablesState } from '../state/types'; +import { initialVariablesState, VariablePayload, VariablesState } from '../state/types'; +import { getInstanceState } from '../state/selectors'; export const initialTextBoxVariableModelState: TextBoxVariableModel = { ...initialVariableModelState, diff --git a/public/app/features/variables/types.ts b/public/app/features/variables/types.ts index 0f8b6835dc9..5d894ab35b8 100644 --- a/public/app/features/variables/types.ts +++ b/public/app/features/variables/types.ts @@ -131,6 +131,7 @@ export interface SystemVariable string }> exten export interface VariableModel extends BaseVariableModel { id: string; + rootStateKey: string | null; global: boolean; hide: VariableHide; skipUrlSync: boolean; @@ -142,6 +143,7 @@ export interface VariableModel extends BaseVariableModel { export const initialVariableModelState: VariableModel = { id: NEW_VARIABLE_ID, + rootStateKey: null, name: '', label: null, type: '' as unknown as VariableType, diff --git a/public/app/features/variables/utils.ts b/public/app/features/variables/utils.ts index 96068a50272..d6964a6aa67 100644 --- a/public/app/features/variables/utils.ts +++ b/public/app/features/variables/utils.ts @@ -9,6 +9,8 @@ import { variableAdapters } from './adapters'; import { safeStringifyValue } from 'app/core/utils/explore'; import { StoreState } from '../../types'; import { getState } from '../../store/store'; +import { getVariablesState } from './state/selectors'; +import { KeyedVariableIdentifier, VariableIdentifier, VariablePayload } from './state/types'; /* * This regex matches 3 types of variable reference with an optional format specifier @@ -256,6 +258,32 @@ export function ensureStringValues(value: any | any[]): string | string[] { return ''; } -export function hasOngoingTransaction(state: StoreState = getState()): boolean { - return state.templating.transaction.status !== TransactionStatus.NotStarted; +export function hasOngoingTransaction(key: string, state: StoreState = getState()): boolean { + return getVariablesState(key, state).transaction.status !== TransactionStatus.NotStarted; +} + +export function toStateKey(key: string | null | undefined): string { + return String(key); +} + +export const toKeyedVariableIdentifier = (variable: VariableModel): KeyedVariableIdentifier => { + if (!variable.rootStateKey) { + throw new Error(`rootStateKey not found for variable with id:${variable.id}`); + } + + return { type: variable.type, id: variable.id, rootStateKey: variable.rootStateKey }; +}; + +export function toVariablePayload( + identifier: VariableIdentifier, + data?: T +): VariablePayload; +// eslint-disable-next-line +export function toVariablePayload(model: VariableModel, data?: T): VariablePayload; +// eslint-disable-next-line +export function toVariablePayload( + obj: VariableIdentifier | VariableModel, + data?: T +): VariablePayload { + return { type: obj.type, id: obj.id, data: data as T }; } diff --git a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts index d8d06ff56ec..2db9cbf4f52 100644 --- a/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/specs/datasource.test.ts @@ -11,12 +11,12 @@ import * as redux from 'app/store/store'; import { CloudWatchDatasource } from '../datasource'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { - MetricEditorMode, CloudWatchJsonData, CloudWatchLogsQuery, CloudWatchLogsQueryStatus, CloudWatchMetricsQuery, LogAction, + MetricEditorMode, MetricQueryType, } from '../types'; import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ @@ -705,9 +705,11 @@ describe('CloudWatchDatasource', () => { describe('When performing CloudWatch query with template variables', () => { let templateSrv: TemplateSrv; beforeEach(() => { + const key = 'key'; const var1: CustomVariableModel = { ...initialVariableModelState, id: 'var1', + rootStateKey: key, name: 'var1', index: 0, current: { value: 'var1-foo', text: 'var1-foo', selected: true }, @@ -721,6 +723,7 @@ describe('CloudWatchDatasource', () => { const var2: CustomVariableModel = { ...initialVariableModelState, id: 'var2', + rootStateKey: key, name: 'var2', index: 1, current: { value: 'var2-foo', text: 'var2-foo', selected: true }, @@ -734,6 +737,7 @@ describe('CloudWatchDatasource', () => { const var3: CustomVariableModel = { ...initialVariableModelState, id: 'var3', + rootStateKey: key, name: 'var3', index: 2, current: { value: ['var3-foo', 'var3-baz'], text: 'var3-foo + var3-baz', selected: true }, @@ -751,6 +755,7 @@ describe('CloudWatchDatasource', () => { const var4: CustomVariableModel = { ...initialVariableModelState, id: 'var4', + rootStateKey: key, name: 'var4', index: 3, options: [ @@ -766,7 +771,7 @@ describe('CloudWatchDatasource', () => { type: 'custom', }; const variables = [var1, var2, var3, var4]; - const state = convertToStoreState(variables); + const state = convertToStoreState(key, variables); templateSrv = new TemplateSrv(getTemplateSrvDependencies(state)); templateSrv.init(variables); }); diff --git a/public/app/store/store.ts b/public/app/store/store.ts index a00442011fa..496a3e4b081 100644 --- a/public/app/store/store.ts +++ b/public/app/store/store.ts @@ -1,6 +1,8 @@ -import { StoreState } from 'app/types'; import { Store } from 'redux'; +import { initialKeyedVariablesState } from 'app/features/variables/state/keyedVariablesReducer'; +import { StoreState } from 'app/types'; + export let store: Store; export function setStore(newStore: Store) { @@ -9,11 +11,7 @@ export function setStore(newStore: Store) { export function getState(): StoreState { if (!store || !store.getState) { - return { - templating: { - variables: {}, - }, - } as StoreState; // used by tests + return { templating: { ...initialKeyedVariablesState, lastKey: 'key' } } as StoreState; // used by tests } return store.getState(); diff --git a/public/test/helpers/convertToStoreState.ts b/public/test/helpers/convertToStoreState.ts index c8c51dc6b9d..40bbe568e91 100644 --- a/public/test/helpers/convertToStoreState.ts +++ b/public/test/helpers/convertToStoreState.ts @@ -1,12 +1,12 @@ import { StoreState } from '../../app/types'; +import { getPreloadedState } from '../../app/features/variables/state/helpers'; -export const convertToStoreState = (variables: any[]): StoreState => { +export const convertToStoreState = (key: string, models: any[]): StoreState => { + const variables = models.reduce((byName, variable) => { + byName[variable.name] = variable; + return byName; + }, {}); return { - templating: { - variables: variables.reduce((byName, variable) => { - byName[variable.name] = variable; - return byName; - }, {}), - }, + ...getPreloadedState(key, { variables }), } as StoreState; }; diff --git a/public/test/helpers/initTemplateSrv.ts b/public/test/helpers/initTemplateSrv.ts index 2af808175eb..a19688f282c 100644 --- a/public/test/helpers/initTemplateSrv.ts +++ b/public/test/helpers/initTemplateSrv.ts @@ -3,8 +3,8 @@ import { convertToStoreState } from './convertToStoreState'; import { TemplateSrv } from '../../app/features/templating/template_srv'; import { getTemplateSrvDependencies } from './getTemplateSrvDependencies'; -export function initTemplateSrv(variables: any[], timeRange?: TimeRange) { - const state = convertToStoreState(variables); +export function initTemplateSrv(key: string, variables: any[], timeRange?: TimeRange) { + const state = convertToStoreState(key, variables); const srv = new TemplateSrv(getTemplateSrvDependencies(state)); srv.init(variables, timeRange); From 478efffce71121474c67a903e7e5cf62a86a2bf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agn=C3=A8s=20Toulet?= <35176601+AgnesToulet@users.noreply.github.com> Date: Fri, 18 Feb 2022 09:19:39 +0100 Subject: [PATCH 061/104] Data source insights: Switch from ID to UID (#45532) --- public/app/features/datasources/state/navModel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/features/datasources/state/navModel.ts b/public/app/features/datasources/state/navModel.ts index 15be676a44f..24c82d75074 100644 --- a/public/app/features/datasources/state/navModel.ts +++ b/public/app/features/datasources/state/navModel.ts @@ -76,9 +76,9 @@ export function buildNavModel(dataSource: DataSourceSettings, plugin: GenericDat const analytics = { active: false, icon: 'info-circle', - id: `datasource-insights-${dataSource.id}`, + id: `datasource-insights-${dataSource.uid}`, text: 'Insights', - url: `datasources/edit/${dataSource.id}/insights`, + url: `datasources/edit/${dataSource.uid}/insights`, }; if (featureEnabled('analytics')) { From 0eb37c08bd5d178c2df83b950fc42f01e737d95c Mon Sep 17 00:00:00 2001 From: Kat Yang <69819079+yangkb09@users.noreply.github.com> Date: Fri, 18 Feb 2022 03:37:49 -0500 Subject: [PATCH 062/104] Chore: Add sqlstore dashboard methods to store and mockstore (#45567) --- pkg/services/sqlstore/mockstore/mockstore.go | 12 ++++++++++++ pkg/services/sqlstore/store.go | 3 +++ 2 files changed, 15 insertions(+) diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go index 6a5afe93c4d..58387874d51 100644 --- a/pkg/services/sqlstore/mockstore/mockstore.go +++ b/pkg/services/sqlstore/mockstore/mockstore.go @@ -641,3 +641,15 @@ func (m *SQLStoreMock) SearchOrgs(ctx context.Context, query *models.SearchOrgsQ func (m *SQLStoreMock) HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error { return m.ExpectedError } + +func (m *SQLStoreMock) GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashboardPermissionsForUserQuery) error { + return m.ExpectedError +} + +func (m *SQLStoreMock) GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error { + return m.ExpectedError +} + +func (m *SQLStoreMock) GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error { + return m.ExpectedError +} diff --git a/pkg/services/sqlstore/store.go b/pkg/services/sqlstore/store.go index 13b3cd87318..100b3e75f21 100644 --- a/pkg/services/sqlstore/store.go +++ b/pkg/services/sqlstore/store.go @@ -148,4 +148,7 @@ type Store interface { GetDBHealthQuery(ctx context.Context, query *models.GetDBHealthQuery) error SearchOrgs(ctx context.Context, query *models.SearchOrgsQuery) error HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error + GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashboardPermissionsForUserQuery) error + GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error + GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error } From 7c41cd15d7ebfa0120e88d34ea24bfb20fee8fe8 Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Fri, 18 Feb 2022 09:44:18 +0100 Subject: [PATCH 063/104] Chore: Remove devenv link (#45583) --- devenv/docker/blocks/saml-enterprise | 1 - 1 file changed, 1 deletion(-) delete mode 120000 devenv/docker/blocks/saml-enterprise diff --git a/devenv/docker/blocks/saml-enterprise b/devenv/docker/blocks/saml-enterprise deleted file mode 120000 index c3916961dee..00000000000 --- a/devenv/docker/blocks/saml-enterprise +++ /dev/null @@ -1 +0,0 @@ -/Users/mitchel/Workspace/grafana-enterprise/src/devenv/blocks/saml-enterprise \ No newline at end of file From 929d8a358f3c8352f53cc6d5baea16f5c86eea5c Mon Sep 17 00:00:00 2001 From: Szymon Szypulski Date: Fri, 18 Feb 2022 10:21:17 +0100 Subject: [PATCH 064/104] Cloudwatch: Add support for AWS/PrivateLink* metrics and dimensions (#45515) Add support for AWS/PrivateLink{Endpoints,Interfaces} metrics and dimensions. Change is based on the official AWS documentation[1]. 1. https://docs.aws.amazon.com/vpc/latest/privatelink/privatelink-cloudwatch-metrics.html --- pkg/tsdb/cloudwatch/metrics.go | 142 +++++++++++++++++---------------- 1 file changed, 73 insertions(+), 69 deletions(-) diff --git a/pkg/tsdb/cloudwatch/metrics.go b/pkg/tsdb/cloudwatch/metrics.go index 4ea31bbbc3d..6234f013913 100644 --- a/pkg/tsdb/cloudwatch/metrics.go +++ b/pkg/tsdb/cloudwatch/metrics.go @@ -325,75 +325,77 @@ var metricsMap = map[string][]string{ "TouchMisses", "UnusedMemory", }, - "AWS/ElasticBeanstalk": {"ApplicationLatencyP10", "ApplicationLatencyP50", "ApplicationLatencyP75", "ApplicationLatencyP85", "ApplicationLatencyP90", "ApplicationLatencyP95", "ApplicationLatencyP99", "ApplicationLatencyP99.9", "ApplicationRequests2xx", "ApplicationRequests3xx", "ApplicationRequests4xx", "ApplicationRequests5xx", "ApplicationRequestsTotal", "CPUIdle", "CPUIowait", "CPUIrq", "CPUNice", "CPUSoftirq", "CPUSystem", "CPUUser", "EnvironmentHealth", "InstanceHealth", "InstancesDegraded", "InstancesInfo", "InstancesNoData", "InstancesOk", "InstancesPending", "InstancesSevere", "InstancesUnknown", "InstancesWarning", "LoadAverage1min", "LoadAverage5min", "RootFilesystemUtil"}, - "AWS/ElasticInference": {"AcceleratorHealthCheckFailed", "AcceleratorMemoryUsage", "ConnectivityCheckFailed"}, - "AWS/ElasticMapReduce": {"AppsCompleted", "AppsFailed", "AppsKilled", "AppsPending", "AppsRunning", "AppsSubmitted", "BackupFailed", "CapacityRemainingGB", "Cluster Status", "ContainerAllocated", "ContainerPending", "ContainerPendingRatio", "ContainerReserved", "CoreNodesPending", "CoreNodesRunning", "CorruptBlocks", "DfsPendingReplicationBlocks", "HBase", "HDFSBytesRead", "HDFSBytesWritten", "HDFSUtilization", "HbaseBackupFailed", "IO", "IsIdle", "JobsFailed", "JobsRunning", "LiveDataNodes", "LiveTaskTrackers", "MRActiveNodes", "MRDecommissionedNodes", "MRLostNodes", "MRRebootedNodes", "MRTotalNodes", "MRUnhealthyNodes", "Map/Reduce", "MapSlotsOpen", "MapTasksRemaining", "MapTasksRunning", "MemoryAllocatedMB", "MemoryAvailableMB", "MemoryReservedMB", "MemoryTotalMB", "MissingBlocks", "MostRecentBackupDuration", "Node Status", "PendingDeletionBlocks", "ReduceSlotsOpen", "ReduceTasksRemaining", "ReduceTasksRunning", "RemainingMapTasksPerSlot", "S3BytesRead", "S3BytesWritten", "TaskNodesPending", "TaskNodesRunning", "TimeSinceLastSuccessfulBackup", "TotalLoad", "UnderReplicatedBlocks", "YARNMemoryAvailablePercentage"}, - "AWS/ElasticTranscoder": {"Billed Audio Output", "Billed HD Output", "Billed SD Output", "Errors", "Jobs Completed", "Jobs Errored", "Outputs per Job", "Standby Time", "Throttles"}, - "AWS/Events": {"DeadLetterInvocations", "FailedInvocations", "Invocations", "InvocationsFailedToBeSentToDlq", "InvocationsSentToDlq", "MatchedEvents", "ThrottledRules", "TriggeredRules"}, - "AWS/FSx": {"DataReadBytes", "DataReadOperations", "DataWriteBytes", "DataWriteOperations", "FreeDataStorageCapacity", "FreeStorageCapacity", "MetadataOperations"}, - "AWS/Firehose": {"BackupToS3.Bytes", "BackupToS3.DataFreshness", "BackupToS3.Records", "BackupToS3.Success", "DataReadFromKinesisStream.Bytes", "DataReadFromKinesisStream.Records", "DeliveryToElasticsearch.Bytes", "DeliveryToElasticsearch.Records", "DeliveryToElasticsearch.Success", "DeliveryToRedshift.Bytes", "DeliveryToRedshift.Records", "DeliveryToRedshift.Success", "DeliveryToS3.Bytes", "DeliveryToS3.DataFreshness", "DeliveryToS3.Records", "DeliveryToS3.Success", "DeliveryToSplunk.Bytes", "DeliveryToSplunk.DataFreshness", "DeliveryToSplunk.Records", "DeliveryToSplunk.Success", "DescribeDeliveryStream.Latency", "DescribeDeliveryStream.Requests", "ExecuteProcessing.Duration", "ExecuteProcessing.Success", "FailedConversion.Bytes", "FailedConversion.Records", "IncomingBytes", "IncomingRecords", "KinesisMillisBehindLatest", "ListDeliveryStreams.Latency", "ListDeliveryStreams.Requests", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Requests", "PutRecordBatch.Bytes", "PutRecordBatch.Latency", "PutRecordBatch.Records", "PutRecordBatch.Requests", "SucceedConversion.Bytes", "SucceedConversion.Records", "SucceedProcessing.Bytes", "SucceedProcessing.Records", "ThrottledDescribeStream", "ThrottledGetRecords", "ThrottledGetShardIterator", "UpdateDeliveryStream.Latency", "UpdateDeliveryStream.Requests"}, - "AWS/GameLift": {"ActivatingGameSessions", "ActiveGameSessions", "ActiveInstances", "ActiveServerProcesses", "AvailableGameSessions", "AverageWaitTime", "CurrentPlayerSessions", "CurrentTickets", "DesiredInstances", "FirstChoiceNotViable", "FirstChoiceOutOfCapacity", "GameSessionInterruptions", "HealthyServerProcesses", "IdleInstances", "InstanceInterruptions", "LowestLatencyPlacement", "LowestPricePlacement", "MatchAcceptancesTimedOut", "MatchesAccepted", "MatchesCreated", "MatchesPlaced", "MatchesRejected", "MaxInstances", "MinInstances", "PercentAvailableGameSessions", "PercentHealthyServerProcesses", "PercentIdleInstances", "Placement", "PlacementsCanceled", "PlacementsFailed", "PlacementsStarted", "PlacementsSucceeded", "PlacementsTimedOut", "PlayerSessionActivations", "PlayersStarted", "QueueDepth", "RuleEvaluationsFailed", "RuleEvaluationsPassed", "ServerProcessAbnormalTerminations", "ServerProcessActivations", "ServerProcessTerminations", "TicketsFailed", "TicketsStarted", "TicketsTimedOut", "TimeToMatch", "TimeToTicketSuccess"}, - "AWS/GlobalAccelerator": {"NewFlowCount", "ProcessedBytesIn", "ProcessedBytesOut", "HealthyEndpointCount", "UnhealthyEndpointCount"}, - "AWS/Glue": {"glue.driver.BlockManager.disk.diskSpaceUsed_MB", "glue.driver.ExecutorAllocationManager.executors.numberAllExecutors", "glue.driver.ExecutorAllocationManager.executors.numberMaxNeededExecutors", "glue.driver.aggregate.bytesRead", "glue.driver.aggregate.elapsedTime", "glue.driver.aggregate.numCompletedStages", "glue.driver.aggregate.numCompletedTasks", "glue.driver.aggregate.numFailedTasks", "glue.driver.aggregate.numKilledTasks", "glue.driver.aggregate.recordsRead", "glue.driver.aggregate.shuffleBytesWritten", "glue.driver.aggregate.shuffleLocalBytesRead", "glue.driver.jvm.heap.usage glue.executorId.jvm.heap.usage glue.ALL.jvm.heap.usage", "glue.driver.jvm.heap.used glue.executorId.jvm.heap.used glue.ALL.jvm.heap.used", "glue.driver.s3.filesystem.read_bytes glue.executorId.s3.filesystem.read_bytes glue.ALL.s3.filesystem.read_bytes", "glue.driver.s3.filesystem.write_bytes glue.executorId.s3.filesystem.write_bytes glue.ALL.s3.filesystem.write_bytes", "glue.driver.system.cpuSystemLoad glue.executorId.system.cpuSystemLoad glue.ALL.system.cpuSystemLoad"}, - "AWS/GroundStation": {"BitErrorRate", "BlockErrorRate", "ReceivedPower", "Es/N0"}, - "AWS/Inspector": {"TotalAssessmentRunFindings", "TotalAssessmentRuns", "TotalHealthyAgents", "TotalMatchingAgents"}, - "AWS/IVS": {"ConcurrentViews", "ConcurrentStreams", "LiveDeliveredTime", "LiveInputTime", "RecordedTime"}, - "AWS/IoT": {"CanceledJobExecutionCount", "CanceledJobExecutionTotalCount", "ClientError", "Connect.AuthError", "Connect.ClientError", "Connect.ServerError", "Connect.Success", "Connect.Throttle", "DeleteThingShadow.Accepted", "FailedJobExecutionCount", "FailedJobExecutionTotalCount", "Failure", "GetThingShadow.Accepted", "InProgressJobExecutionCount", "InProgressJobExecutionTotalCount", "NonCompliantResources", "NumLogBatchesFailedToPublishThrottled", "NumLogEventsFailedToPublishThrottled", "ParseError", "Ping.Success", "PublishIn.AuthError", "PublishIn.ClientError", "PublishIn.ServerError", "PublishIn.Success", "PublishIn.Throttle", "PublishOut.AuthError", "PublishOut.ClientError", "PublishOut.Success", "QueuedJobExecutionCount", "QueuedJobExecutionTotalCount", "RejectedJobExecutionCount", "RejectedJobExecutionTotalCount", "RemovedJobExecutionCount", "RemovedJobExecutionTotalCount", "ResourcesEvaluated", "RuleMessageThrottled", "RuleNotFound", "RulesExecuted", "ServerError", "Subscribe.AuthError", "Subscribe.ClientError", "Subscribe.ServerError", "Subscribe.Success", "Subscribe.Throttle", "SuccededJobExecutionCount", "SuccededJobExecutionTotalCount", "Success", "TopicMatch", "Unsubscribe.ClientError", "Unsubscribe.ServerError", "Unsubscribe.Success", "Unsubscribe.Throttle", "UpdateThingShadow.Accepted", "Violations", "ViolationsCleared", "ViolationsInvalidated"}, - "AWS/IoTAnalytics": {"ActionExecution", "ActivityExecutionError", "IncomingMessages"}, - "AWS/KMS": {"SecondsUntilKeyMaterialExpiration"}, - "AWS/Kafka": {"ActiveControllerCount", "BytesInPerSec", "BytesOutPerSec", "CpuIdle", "CpuSystem", "CpuUser", "EstimatedMaxTimeLag", "EstimatedTimeLag", "FetchConsumerLocalTimeMsMean", "FetchConsumerRequestQueueTimeMsMean", "FetchConsumerResponseQueueTimeMsMean", "FetchConsumerResponseSendTimeMsMean", "FetchConsumerTotalTimeMsMean", "FetchFollowerLocalTimeMsMean", "FetchFollowerRequestQueueTimeMsMean", "FetchFollowerResponseQueueTimeMsMean", "FetchFollowerResponseSendTimeMsMean", "FetchFollowerTotalTimeMsMean", "FetchMessageConversionsPerSec", "FetchThrottleByteRate", "FetchThrottleQueueSize", "FetchThrottleTime", "GlobalPartitionCount", "GlobalTopicCount", "KafkaAppLogsDiskUsed", "KafkaDataLogsDiskUsed", "LeaderCount", "MaxOffsetLag", "MemoryBuffered", "MemoryCached", "MemoryFree", "MemoryUsed", "MessagesInPerSec", "NetworkProcessorAvgIdlePercent", "NetworkRxDropped", "NetworkRxErrors", "NetworkRxPackets", "NetworkTxDropped", "NetworkTxErrors", "NetworkTxPackets", "OfflinePartitionsCount", "PartitionCount", "ProduceLocalTimeMsMean", "ProduceMessageConversionsPerSec", "ProduceMessageConversionsTimeMsMean", "ProduceRequestQueueTimeMsMean", "ProduceResponseQueueTimeMsMean", "ProduceResponseSendTimeMsMean", "ProduceThrottleByteRate", "ProduceThrottleQueueSize", "ProduceThrottleTime", "ProduceTotalTimeMsMean", "ReplicationBytesInPerSec", "ReplicationBytesOutPerSec", "RequestBytesMean", "RequestExemptFromThrottleTime", "RequestHandlerAvgIdlePercent", "RequestThrottleQueueSize", "RequestThrottleTime", "RequestTime", "RootDiskUsed", "SumOffsetLag", "SwapFree", "SwapUsed", "OffsetLag", "UnderMinIsrPartitionCount", "UnderReplicatedPartitions", "ZooKeeperRequestLatencyMsMean", "ZooKeeperSessionState"}, - "AWS/Kinesis": {"GetRecords.Bytes", "GetRecords.IteratorAge", "GetRecords.IteratorAgeMilliseconds", "GetRecords.Latency", "GetRecords.Records", "GetRecords.Success", "IncomingBytes", "IncomingRecords", "IteratorAgeMilliseconds", "OutgoingBytes", "OutgoingRecords", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Success", "PutRecords.Bytes", "PutRecords.Latency", "PutRecords.Records", "PutRecords.Success", "ReadProvisionedThroughputExceeded", "SubscribeToShard.RateExceeded", "SubscribeToShard.Success", "SubscribeToShardEvent.Bytes", "SubscribeToShardEvent.MillisBehindLatest", "SubscribeToShardEvent.Records", "SubscribeToShardEvent.Success", "WriteProvisionedThroughputExceeded"}, - "AWS/KinesisAnalytics": {"Bytes", "InputProcessing.DroppedRecords", "InputProcessing.Duration", "InputProcessing.OkBytes", "InputProcessing.OkRecords", "InputProcessing.ProcessingFailedRecords", "InputProcessing.Success", "KPUs", "LambdaDelivery.DeliveryFailedRecords", "LambdaDelivery.Duration", "LambdaDelivery.OkRecords", "MillisBehindLatest", "Records", "Success"}, - "AWS/KinesisVideo": {"GetHLSMasterPlaylist.Latency", "GetHLSMasterPlaylist.Requests", "GetHLSMasterPlaylist.Success", "GetHLSMediaPlaylist.Latency", "GetHLSMediaPlaylist.Requests", "GetHLSMediaPlaylist.Success", "GetHLSStreamingSessionURL.Latency", "GetHLSStreamingSessionURL.Requests", "GetHLSStreamingSessionURL.Success", "GetMP4InitFragment.Latency", "GetMP4InitFragment.Requests", "GetMP4InitFragment.Success", "GetMP4MediaFragment.Latency", "GetMP4MediaFragment.OutgoingBytes", "GetMP4MediaFragment.Requests", "GetMP4MediaFragment.Success", "GetMedia.ConnectionErrors", "GetMedia.MillisBehindNow", "GetMedia.OutgoingBytes", "GetMedia.OutgoingFragments", "GetMedia.OutgoingFrames", "GetMedia.Requests", "GetMedia.Success", "GetMediaForFragmentList.OutgoingBytes", "GetMediaForFragmentList.OutgoingFragments", "GetMediaForFragmentList.OutgoingFrames", "GetMediaForFragmentList.Requests", "GetMediaForFragmentList.Success", "GetTSFragment.Latency", "GetTSFragment.OutgoingBytes", "GetTSFragment.Requests", "GetTSFragment.Success", "ListFragments.Latency", "PutMedia.ActiveConnections", "PutMedia.BufferingAckLatency", "PutMedia.ConnectionErrors", "PutMedia.ErrorAckCount", "PutMedia.FragmentIngestionLatency", "PutMedia.FragmentPersistLatency", "PutMedia.IncomingBytes", "PutMedia.IncomingFragments", "PutMedia.IncomingFrames", "PutMedia.Latency", "PutMedia.PersistedAckLatency", "PutMedia.ReceivedAckLatency", "PutMedia.Requests", "PutMedia.Success"}, - "AWS/Lambda": {"ConcurrentExecutions", "DeadLetterErrors", "Duration", "Errors", "Invocations", "IteratorAge", "Throttles", "UnreservedConcurrentExecutions"}, - "AWS/Lex": {"BotChannelAuthErrors", "BotChannelConfigurationErrors", "BotChannelInboundThrottledEvents", "BotChannelOutboundThrottledEvents", "BotChannelRequestCount", "BotChannelResponseCardErrors", "BotChannelSystemErrors", "MissedUtteranceCount", "RuntimeInvalidLambdaResponses", "RuntimeLambdaErrors", "RuntimePollyErrors", "RuntimeRequestCount", "RuntimeSucessfulRequestLatency", "RuntimeSystemErrors", "RuntimeThrottledEvents", "RuntimeUserErrors"}, - "AWS/Logs": {"DeliveryErrors", "DeliveryThrottling", "ForwardedBytes", "ForwardedLogEvents", "IncomingBytes", "IncomingLogEvents"}, - "AWS/LookoutMetrics": {"ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsFailed", "Delivered", "Undelivered"}, - "AWS/ML": {"PredictCount", "PredictFailureCount"}, - "AWS/MediaConnect": {"ARQRecovered", "ARQRequests", "BitRate", "CATError", "CRCError", "Connected", "ConnectedOutputs", "ContinuityCounter", "Disconnections", "DroppedPackets", "FECPackets", "FECRecovered", "NotRecoveredPackets", "OutputConnected", "OutputDisconnections", "OverflowPackets", "PATError", "PCRAccuracyError", "PCRError", "PIDError", "PMTError", "PTSError", "PacketLossPercent", "RecoveredPackets", "RoundTripTime", "SourceARQRecovered", "SourceARQRequests", "SourceBitRate", "SourceCATError", "SourceCRCError", "SourceConnected", "SourceContinuityCounter", "SourceDisconnections", "SourceDroppedPackets", "SourceFECPackets", "SourceFECRecovered", "SourceNotRecoveredPackets", "SourceOverflowPackets", "SourcePATError", "SourcePCRAccuracyError", "SourcePCRError", "SourcePIDError", "SourcePMTError", "SourcePTSError", "SourcePacketLossPercent", "SourceRecoveredPackets", "SourceRoundTripTime", "SourceTSByteError", "SourceTSSyncLoss", "SourceTotalPackets", "SourceTransportError", "TSByteError", "TSSyncLoss", "TotalPackets", "TransportError"}, - "AWS/MediaConvert": {"AudioOutputSeconds", "Errors", "HDOutputSeconds", "JobsCompletedCount", "JobsErroredCount", "SDOutputSeconds", "StandbyTime", "TranscodingTime", "UHDOutputSeconds"}, - "AWS/MediaPackage": {"ActiveInput", "EgressBytes", "EgressRequestCount", "EgressResponseTime", "IngressBytes", "IngressResponseTime"}, - "AWS/MediaStore": {"RequestCount", "4xxErrorCount", "5xxErrorCount", "BytesUploaded", "BytesDownloaded", "TotalTime", "TurnaroundTime"}, - "AWS/MediaTailor": {"AdDecisionServer.Ads", "AdDecisionServer.Duration", "AdDecisionServer.Errors", "AdDecisionServer.FillRate", "AdDecisionServer.Timeouts", "AdNotReady", "Avails.Duration", "Avails.FillRate", "Avails.FilledDuration", "GetManifest.Errors", "Origin.Errors", "Origin.Timeouts"}, - "AWS/NATGateway": {"ActiveConnectionCount", "BytesInFromDestination", "BytesInFromSource", "BytesOutToDestination", "BytesOutToSource", "ConnectionAttemptCount", "ConnectionEstablishedCount", "ErrorPortAllocation", "IdleTimeoutCount", "PacketsDropCount", "PacketsInFromDestination", "PacketsInFromSource", "PacketsOutToDestination", "PacketsOutToSource"}, - "AWS/Neptune": {"CPUUtilization", "ClusterReplicaLag", "ClusterReplicaLagMaximum", "ClusterReplicaLagMinimum", "EngineUptime", "FreeLocalStorage", "FreeableMemory", "GremlinErrors", "GremlinHttp1xx", "GremlinHttp2xx", "GremlinHttp4xx", "GremlinHttp5xx", "GremlinRequests", "GremlinRequestsPerSec", "GremlinWebSocketAvailableConnections", "GremlinWebSocketClientErrors", "GremlinWebSocketServerErrors", "GremlinWebSocketSuccess", "Http100", "Http101", "Http1xx", "Http200", "Http2xx", "Http400", "Http403", "Http405", "Http413", "Http429", "Http4xx", "Http500", "Http501", "Http5xx", "LoaderErrors", "LoaderRequests", "NetworkReceiveThroughput", "NetworkThroughput", "NetworkTransmitThroughput", "SparqlErrors", "SparqlHttp1xx", "SparqlHttp2xx", "SparqlHttp4xx", "SparqlHttp5xx", "SparqlRequests", "SparqlRequestsPerSec", "StatusErrors", "StatusRequests", "VolumeBytesUsed", "VolumeReadIOPs", "VolumeWriteIOPs"}, - "AWS/NetworkELB": {"ActiveFlowCount", "ActiveFlowCount_TLS", "ClientTLSNegotiationErrorCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "NewFlowCount_TLS", "ProcessedBytes", "ProcessedBytes_TLS", "TCP_Client_Reset_Count", "TCP_ELB_Reset_Count", "TCP_Target_Reset_Count", "TargetTLSNegotiationErrorCount", "UnHealthyHostCount"}, - "AWS/GatewayELB": {"ActiveFlowCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "ProcessedBytes", "UnHealthyHostCount"}, - "AWS/NetworkFirewall": {"DroppedPackets", "Packets", "PassedPackets", "ReceivedPacketCount"}, - "AWS/OpsWorks": {"cpu_idle", "cpu_nice", "cpu_steal", "cpu_system", "cpu_user", "cpu_waitio", "load_1", "load_15", "load_5", "memory_buffers", "memory_cached", "memory_free", "memory_swap", "memory_total", "memory_used", "procs"}, - "AWS/Polly": {"2XXCount", "4XXCount", "5XXCount", "RequestCharacters", "ResponseLatency"}, - "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraGlobalDBDataTransferBytes", "AuroraGlobalDBReplicatedWriteIO", "AuroraGlobalDBReplicationLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "AvailabilityPercentage", "BacktrackChangeRecordsCreationRate", "BacktrackChangeRecordsStored", "BacktrackWindowActual", "BacktrackWindowAlert", "BackupRetentionPeriodStorageUsed", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "ClientConnections", "ClientConnectionsClosed", "ClientConnectionsNoTLS", "ClientConnectionsReceived", "ClientConnectionsSetupFailedAuth", "ClientConnectionsSetupSucceeded", "ClientConnectionsTLS", "CommitLatency", "CommitThroughput", "DDLLatency", "DDLThroughput", "DMLLatency", "DMLThroughput", "DatabaseConnectionRequests", "DatabaseConnectionRequestsWithTLS", "DatabaseConnections", "DatabaseConnectionsBorrowLatency", "DatabaseConnectionsCurrentlyBorrowed", "DatabaseConnectionsCurrentlyInTransaction", "DatabaseConnectionsCurrentlySessionPinned", "DatabaseConnectionsSetupFailed", "DatabaseConnectionsSetupSucceeded", "DatabaseConnectionsWithTLS", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "EngineUptime", "FailedSQLServerAgentJobsCount", "FreeLocalStorage", "FreeStorageSpace", "FreeableMemory", "InsertLatency", "InsertThroughput", "LoginFailures", "MaxDatabaseConnectionsAllowed", "MaximumUsedTransactionIDs", "NetworkReceiveThroughput", "NetworkThroughput", "NetworkTransmitThroughput", "OldestReplicationSlotLag", "Queries", "QueryDatabaseResponseLatency", "QueryRequests", "QueryRequestsNoTLS", "QueryRequestsTLS", "QueryResponseLatency", "RDSToAuroraPostgreSQLReplicaLag", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ReplicationSlotDiskUsage", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "ServerlessDatabaseCapacity", "SnapshotStorageUsed", "SwapUsage", "TotalBackupStorageBilled", "TransactionLogsDiskUsage", "TransactionLogsGeneration", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPs", "VolumeWriteIOPs", "WriteIOPS", "WriteLatency", "WriteThroughput"}, - "AWS/Redshift": {"CommitQueueLength", "ConcurrencyScalingActiveClusters", "ConcurrencyScalingSeconds", "CPUUtilization", "DatabaseConnections", "HealthStatus", "MaintenanceMode", "MaxConfiguredConcurrencyScalingClusters", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "PercentageDiskSpaceUsed", "QueriesCompletedPerSecond", "QueryDuration", "QueryRuntimeBreakdown", "ReadIOPS", "ReadLatency", "ReadThroughput", "TotalTableCount", "WLMQueueLength", "WLMQueueWaitTime", "WLMQueriesCompletedPerSecond", "WLMQueryDuration", "WLMRunningQueries", "WriteIOPS", "WriteLatency", "WriteThroughput", "SchemaQuota", "NumExceededSchemaQuotas", "StorageUsed", "PercentageQuotaUsed"}, - "AWS/Robomaker": {"RealTimeFactor", "vCPU", "Memory", "SimulationUnit"}, - "AWS/Route53": {"ChildHealthCheckHealthyCount", "ConnectionTime", "DNSQueries", "HealthCheckPercentageHealthy", "HealthCheckStatus", "SSLHandshakeTime", "TimeToFirstByte"}, - "AWS/Route53Resolver": {"InboundQueryVolume", "OutboundQueryVolume", "OutboundQueryAggregatedVolume"}, - "AWS/S3": {"4xxErrors", "5xxErrors", "AllRequests", "BucketSizeBytes", "BytesDownloaded", "BytesUploaded", "DeleteRequests", "FirstByteLatency", "GetRequests", "HeadRequests", "ListRequests", "NumberOfObjects", "PostRequests", "PutRequests", "SelectRequests", "SelectReturnedBytes", "SelectScannedBytes", "TotalRequestLatency"}, - "AWS/SDKMetrics": {"CallCount", "ClientErrorCount", "EndToEndLatency", "ConnectionErrorCount", "ServerErrorCount", "ThrottleCount"}, - "AWS/ServiceCatalog": {"ProvisionedProductLaunch"}, - "AWS/SES": {"Bounce", "Clicks", "Complaint", "Delivery", "Opens", "Reject", "Rendering Failures", "Reputation.BounceRate", "Reputation.ComplaintRate", "Send"}, - "AWS/SNS": {"NumberOfMessagesPublished", "NumberOfNotificationsDelivered", "NumberOfNotificationsFailed", "NumberOfNotificationsFilteredOut", "NumberOfNotificationsFilteredOut-InvalidAttributes", "NumberOfNotificationsFilteredOut-NoMessageAttributes", "PublishSize", "SMSMonthToDateSpentUSD", "SMSSuccessRate"}, - "AWS/SQS": {"ApproximateAgeOfOldestMessage", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesNotVisible", "ApproximateNumberOfMessagesVisible", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "NumberOfMessagesReceived", "NumberOfMessagesSent", "SentMessageSize"}, - "AWS/SWF": {"ActivityTaskScheduleToCloseTime", "ActivityTaskScheduleToStartTime", "ActivityTaskStartToCloseTime", "ActivityTasksCanceled", "ActivityTasksCompleted", "ActivityTasksFailed", "ConsumedCapacity", "DecisionTaskScheduleToStartTime", "DecisionTaskStartToCloseTime", "DecisionTasksCompleted", "PendingTasks", "ProvisionedBucketSize", "ProvisionedRefillRate", "ScheduledActivityTasksTimedOutOnClose", "ScheduledActivityTasksTimedOutOnStart", "StartedActivityTasksTimedOutOnClose", "StartedActivityTasksTimedOutOnHeartbeat", "StartedDecisionTasksTimedOutOnClose", "ThrottledEvents", "WorkflowStartToCloseTime", "WorkflowsCanceled", "WorkflowsCompleted", "WorkflowsContinuedAsNew", "WorkflowsFailed", "WorkflowsTerminated", "WorkflowsTimedOut"}, - "AWS/SageMaker": {"CPUUtilization", "DatasetObjectsAutoAnnotated", "DatasetObjectsHumanAnnotated", "DatasetObjectsLabelingFailed", "DiskUtilization", "GPUMemoryUtilization", "GPUUtilization", "Invocation4XXErrors", "Invocation5XXErrors", "Invocations", "InvocationsPerInstance", "JobsFailed", "JobsStopped", "JobsSucceeded", "MemoryUtilization", "ModelLatency", "OverheadLatency", "TotalDatasetObjectsLabeled"}, - "AWS/States": {"ActivitiesFailed", "ActivitiesHeartbeatTimedOut", "ActivitiesScheduled", "ActivitiesStarted", "ActivitiesSucceeded", "ActivitiesTimedOut", "ActivityRunTime", "ActivityScheduleTime", "ActivityTime", "ConsumedCapacity", "ExecutionThrottled", "ExecutionTime", "ExecutionsAborted", "ExecutionsFailed", "ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsTimedOut", "LambdaFunctionRunTime", "LambdaFunctionScheduleTime", "LambdaFunctionTime", "LambdaFunctionsFailed", "LambdaFunctionsHeartbeatTimedOut", "LambdaFunctionsScheduled", "LambdaFunctionsStarted", "LambdaFunctionsSucceeded", "LambdaFunctionsTimedOut", "ProvisionedBucketSize", "ProvisionedRefillRate", "ThrottledEvents"}, - "AWS/StorageGateway": {"CacheFree", "CacheHitPercent", "CachePercentDirty", "CachePercentUsed", "CacheUsed", "CloudBytesDownloaded", "CloudBytesUploaded", "CloudDownloadLatency", "QueuedWrites", "ReadBytes", "ReadTime", "TimeSinceLastRecoveryPoint", "TotalCacheSize", "UploadBufferFree", "UploadBufferPercentUsed", "UploadBufferUsed", "WorkingStorageFree", "WorkingStoragePercentUsed", "WorkingStorageUsed", "WriteBytes", "WriteTime"}, - "AWS/Textract": {"ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"}, - "AWS/Timestream": {"SuccessfulRequestLatency", "SystemErrors", "UserErrors", "DataScannedBytes"}, - "AWS/ThingsGraph": {"EventStoreQueueSize", "FlowExecutionTime", "FlowExecutionsFailed", "FlowExecutionsStarted", "FlowExecutionsSucceeded", "FlowStepExecutionTime", "FlowStepExecutionsFailed", "FlowStepExecutionsStarted", "FlowStepExecutionsSucceeded"}, - "AWS/TransitGateway": {"BytesIn", "BytesOut", "PacketDropCountBlackhole", "PacketDropCountNoRoute", "PacketsIn", "PacketsOut"}, - "AWS/Transfer": {"BytesIn", "BytesOut", "FilesIn", "FilesOut"}, - "AWS/Translate": {"CharacterCount", "ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"}, - "AWS/TrustedAdvisor": {"GreenChecks", "RedChecks", "RedResources", "ServiceLimitUsage", "YellowChecks", "YellowResources"}, - "AWS/Usage": {"CallCount", "ResourceCount"}, - "AWS/VPN": {"TunnelDataIn", "TunnelDataOut", "TunnelState"}, - "AWS/WAF": {"AllowedRequests", "BlockedRequests", "CountedRequests", "PassedRequests"}, - "AWS/WAFV2": {"AllowedRequests", "BlockedRequests", "CountedRequests", "PassedRequests"}, - "AWS/WorkSpaces": {"Available", "ConnectionAttempt", "ConnectionFailure", "ConnectionSuccess", "InSessionLatency", "Maintenance", "SessionDisconnect", "SessionLaunchTime", "Stopped", "Unhealthy", "UserConnected"}, - "ECS/ContainerInsights": {"ContainerInstanceCount", "CpuUtilized", "CpuReserved", "DeploymentCount", "DesiredTaskCount", "MemoryUtilized", "MemoryReserved", "NetworkRxBytes", "NetworkTxBytes", "PendingTaskCount", "RunningTaskCount", "ServiceCount", "StorageReadBytes", "StorageWriteBytes", "TaskCount", "TaskSetCount", "instance_cpu_limit", "instance_cpu_reserved_capacity", "instance_cpu_usage_total", "instance_cpu_utilization", "instance_filesystem_utilization", "instance_memory_limit", "instance_memory_reserved_capacity", "instance_memory_utliization", "instance_memory_working_set", "instance_network_total_bytes", "instance_number_of_running_tasks"}, - "ContainerInsights": {"cluster_failed_node_count", "cluster_node_count", "namespace_number_of_running_pods", "node_cpu_limit", "node_cpu_reserved_capacity", "node_cpu_usage_total", "node_cpu_utilization", "node_filesystem_utilization", "node_memory_limit", "node_memory_reserved_capacity", "node_memory_utilization", "node_memory_working_set", "node_network_total_bytes", "node_number_of_running_containers", "node_number_of_running_pods", "pod_cpu_reserved_capacity", "pod_cpu_utilization", "pod_cpu_utilization_over_pod_limit", "pod_memory_reserved_capacity", "pod_memory_utilization", "pod_memory_utilization_over_pod_limit", "pod_number_of_container_restarts", "pod_network_rx_bytes", "pod_network_tx_bytes", "service_number_of_running_pods"}, - "AWS/Rekognition": {"DetectedFaceCount", "DetectedLabelCount", "ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"}, - "AWS/Cassandra": {"AccountMaxReads", "AccountMaxTableLevelReads", "AccountMaxTableLevelWrites", "AccountMaxWrites", "AccountProvisionedReadCapacityUtilization", "AccountProvisionedWriteCapacityUtilization", "ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "MaxProvisionedTableReadCapacityUtilization", "MaxProvisionedTableWriteCapacityUtilization", "ReturnedItemCount", "ReturnedItemCountBySelect", "SuccessfulRequestCount", "SuccessfulRequestLatency", "SystemErrors", "UserErrors"}, - "AWS/AmplifyHosting": {"Requests", "BytesDownloaded", "BytesUploaded", "4XXErrors", "5XXErrors", "Latency"}, + "AWS/ElasticBeanstalk": {"ApplicationLatencyP10", "ApplicationLatencyP50", "ApplicationLatencyP75", "ApplicationLatencyP85", "ApplicationLatencyP90", "ApplicationLatencyP95", "ApplicationLatencyP99", "ApplicationLatencyP99.9", "ApplicationRequests2xx", "ApplicationRequests3xx", "ApplicationRequests4xx", "ApplicationRequests5xx", "ApplicationRequestsTotal", "CPUIdle", "CPUIowait", "CPUIrq", "CPUNice", "CPUSoftirq", "CPUSystem", "CPUUser", "EnvironmentHealth", "InstanceHealth", "InstancesDegraded", "InstancesInfo", "InstancesNoData", "InstancesOk", "InstancesPending", "InstancesSevere", "InstancesUnknown", "InstancesWarning", "LoadAverage1min", "LoadAverage5min", "RootFilesystemUtil"}, + "AWS/ElasticInference": {"AcceleratorHealthCheckFailed", "AcceleratorMemoryUsage", "ConnectivityCheckFailed"}, + "AWS/ElasticMapReduce": {"AppsCompleted", "AppsFailed", "AppsKilled", "AppsPending", "AppsRunning", "AppsSubmitted", "BackupFailed", "CapacityRemainingGB", "Cluster Status", "ContainerAllocated", "ContainerPending", "ContainerPendingRatio", "ContainerReserved", "CoreNodesPending", "CoreNodesRunning", "CorruptBlocks", "DfsPendingReplicationBlocks", "HBase", "HDFSBytesRead", "HDFSBytesWritten", "HDFSUtilization", "HbaseBackupFailed", "IO", "IsIdle", "JobsFailed", "JobsRunning", "LiveDataNodes", "LiveTaskTrackers", "MRActiveNodes", "MRDecommissionedNodes", "MRLostNodes", "MRRebootedNodes", "MRTotalNodes", "MRUnhealthyNodes", "Map/Reduce", "MapSlotsOpen", "MapTasksRemaining", "MapTasksRunning", "MemoryAllocatedMB", "MemoryAvailableMB", "MemoryReservedMB", "MemoryTotalMB", "MissingBlocks", "MostRecentBackupDuration", "Node Status", "PendingDeletionBlocks", "ReduceSlotsOpen", "ReduceTasksRemaining", "ReduceTasksRunning", "RemainingMapTasksPerSlot", "S3BytesRead", "S3BytesWritten", "TaskNodesPending", "TaskNodesRunning", "TimeSinceLastSuccessfulBackup", "TotalLoad", "UnderReplicatedBlocks", "YARNMemoryAvailablePercentage"}, + "AWS/ElasticTranscoder": {"Billed Audio Output", "Billed HD Output", "Billed SD Output", "Errors", "Jobs Completed", "Jobs Errored", "Outputs per Job", "Standby Time", "Throttles"}, + "AWS/Events": {"DeadLetterInvocations", "FailedInvocations", "Invocations", "InvocationsFailedToBeSentToDlq", "InvocationsSentToDlq", "MatchedEvents", "ThrottledRules", "TriggeredRules"}, + "AWS/FSx": {"DataReadBytes", "DataReadOperations", "DataWriteBytes", "DataWriteOperations", "FreeDataStorageCapacity", "FreeStorageCapacity", "MetadataOperations"}, + "AWS/Firehose": {"BackupToS3.Bytes", "BackupToS3.DataFreshness", "BackupToS3.Records", "BackupToS3.Success", "DataReadFromKinesisStream.Bytes", "DataReadFromKinesisStream.Records", "DeliveryToElasticsearch.Bytes", "DeliveryToElasticsearch.Records", "DeliveryToElasticsearch.Success", "DeliveryToRedshift.Bytes", "DeliveryToRedshift.Records", "DeliveryToRedshift.Success", "DeliveryToS3.Bytes", "DeliveryToS3.DataFreshness", "DeliveryToS3.Records", "DeliveryToS3.Success", "DeliveryToSplunk.Bytes", "DeliveryToSplunk.DataFreshness", "DeliveryToSplunk.Records", "DeliveryToSplunk.Success", "DescribeDeliveryStream.Latency", "DescribeDeliveryStream.Requests", "ExecuteProcessing.Duration", "ExecuteProcessing.Success", "FailedConversion.Bytes", "FailedConversion.Records", "IncomingBytes", "IncomingRecords", "KinesisMillisBehindLatest", "ListDeliveryStreams.Latency", "ListDeliveryStreams.Requests", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Requests", "PutRecordBatch.Bytes", "PutRecordBatch.Latency", "PutRecordBatch.Records", "PutRecordBatch.Requests", "SucceedConversion.Bytes", "SucceedConversion.Records", "SucceedProcessing.Bytes", "SucceedProcessing.Records", "ThrottledDescribeStream", "ThrottledGetRecords", "ThrottledGetShardIterator", "UpdateDeliveryStream.Latency", "UpdateDeliveryStream.Requests"}, + "AWS/GameLift": {"ActivatingGameSessions", "ActiveGameSessions", "ActiveInstances", "ActiveServerProcesses", "AvailableGameSessions", "AverageWaitTime", "CurrentPlayerSessions", "CurrentTickets", "DesiredInstances", "FirstChoiceNotViable", "FirstChoiceOutOfCapacity", "GameSessionInterruptions", "HealthyServerProcesses", "IdleInstances", "InstanceInterruptions", "LowestLatencyPlacement", "LowestPricePlacement", "MatchAcceptancesTimedOut", "MatchesAccepted", "MatchesCreated", "MatchesPlaced", "MatchesRejected", "MaxInstances", "MinInstances", "PercentAvailableGameSessions", "PercentHealthyServerProcesses", "PercentIdleInstances", "Placement", "PlacementsCanceled", "PlacementsFailed", "PlacementsStarted", "PlacementsSucceeded", "PlacementsTimedOut", "PlayerSessionActivations", "PlayersStarted", "QueueDepth", "RuleEvaluationsFailed", "RuleEvaluationsPassed", "ServerProcessAbnormalTerminations", "ServerProcessActivations", "ServerProcessTerminations", "TicketsFailed", "TicketsStarted", "TicketsTimedOut", "TimeToMatch", "TimeToTicketSuccess"}, + "AWS/GlobalAccelerator": {"NewFlowCount", "ProcessedBytesIn", "ProcessedBytesOut", "HealthyEndpointCount", "UnhealthyEndpointCount"}, + "AWS/Glue": {"glue.driver.BlockManager.disk.diskSpaceUsed_MB", "glue.driver.ExecutorAllocationManager.executors.numberAllExecutors", "glue.driver.ExecutorAllocationManager.executors.numberMaxNeededExecutors", "glue.driver.aggregate.bytesRead", "glue.driver.aggregate.elapsedTime", "glue.driver.aggregate.numCompletedStages", "glue.driver.aggregate.numCompletedTasks", "glue.driver.aggregate.numFailedTasks", "glue.driver.aggregate.numKilledTasks", "glue.driver.aggregate.recordsRead", "glue.driver.aggregate.shuffleBytesWritten", "glue.driver.aggregate.shuffleLocalBytesRead", "glue.driver.jvm.heap.usage glue.executorId.jvm.heap.usage glue.ALL.jvm.heap.usage", "glue.driver.jvm.heap.used glue.executorId.jvm.heap.used glue.ALL.jvm.heap.used", "glue.driver.s3.filesystem.read_bytes glue.executorId.s3.filesystem.read_bytes glue.ALL.s3.filesystem.read_bytes", "glue.driver.s3.filesystem.write_bytes glue.executorId.s3.filesystem.write_bytes glue.ALL.s3.filesystem.write_bytes", "glue.driver.system.cpuSystemLoad glue.executorId.system.cpuSystemLoad glue.ALL.system.cpuSystemLoad"}, + "AWS/GroundStation": {"BitErrorRate", "BlockErrorRate", "ReceivedPower", "Es/N0"}, + "AWS/Inspector": {"TotalAssessmentRunFindings", "TotalAssessmentRuns", "TotalHealthyAgents", "TotalMatchingAgents"}, + "AWS/IVS": {"ConcurrentViews", "ConcurrentStreams", "LiveDeliveredTime", "LiveInputTime", "RecordedTime"}, + "AWS/IoT": {"CanceledJobExecutionCount", "CanceledJobExecutionTotalCount", "ClientError", "Connect.AuthError", "Connect.ClientError", "Connect.ServerError", "Connect.Success", "Connect.Throttle", "DeleteThingShadow.Accepted", "FailedJobExecutionCount", "FailedJobExecutionTotalCount", "Failure", "GetThingShadow.Accepted", "InProgressJobExecutionCount", "InProgressJobExecutionTotalCount", "NonCompliantResources", "NumLogBatchesFailedToPublishThrottled", "NumLogEventsFailedToPublishThrottled", "ParseError", "Ping.Success", "PublishIn.AuthError", "PublishIn.ClientError", "PublishIn.ServerError", "PublishIn.Success", "PublishIn.Throttle", "PublishOut.AuthError", "PublishOut.ClientError", "PublishOut.Success", "QueuedJobExecutionCount", "QueuedJobExecutionTotalCount", "RejectedJobExecutionCount", "RejectedJobExecutionTotalCount", "RemovedJobExecutionCount", "RemovedJobExecutionTotalCount", "ResourcesEvaluated", "RuleMessageThrottled", "RuleNotFound", "RulesExecuted", "ServerError", "Subscribe.AuthError", "Subscribe.ClientError", "Subscribe.ServerError", "Subscribe.Success", "Subscribe.Throttle", "SuccededJobExecutionCount", "SuccededJobExecutionTotalCount", "Success", "TopicMatch", "Unsubscribe.ClientError", "Unsubscribe.ServerError", "Unsubscribe.Success", "Unsubscribe.Throttle", "UpdateThingShadow.Accepted", "Violations", "ViolationsCleared", "ViolationsInvalidated"}, + "AWS/IoTAnalytics": {"ActionExecution", "ActivityExecutionError", "IncomingMessages"}, + "AWS/KMS": {"SecondsUntilKeyMaterialExpiration"}, + "AWS/Kafka": {"ActiveControllerCount", "BytesInPerSec", "BytesOutPerSec", "CpuIdle", "CpuSystem", "CpuUser", "EstimatedMaxTimeLag", "EstimatedTimeLag", "FetchConsumerLocalTimeMsMean", "FetchConsumerRequestQueueTimeMsMean", "FetchConsumerResponseQueueTimeMsMean", "FetchConsumerResponseSendTimeMsMean", "FetchConsumerTotalTimeMsMean", "FetchFollowerLocalTimeMsMean", "FetchFollowerRequestQueueTimeMsMean", "FetchFollowerResponseQueueTimeMsMean", "FetchFollowerResponseSendTimeMsMean", "FetchFollowerTotalTimeMsMean", "FetchMessageConversionsPerSec", "FetchThrottleByteRate", "FetchThrottleQueueSize", "FetchThrottleTime", "GlobalPartitionCount", "GlobalTopicCount", "KafkaAppLogsDiskUsed", "KafkaDataLogsDiskUsed", "LeaderCount", "MaxOffsetLag", "MemoryBuffered", "MemoryCached", "MemoryFree", "MemoryUsed", "MessagesInPerSec", "NetworkProcessorAvgIdlePercent", "NetworkRxDropped", "NetworkRxErrors", "NetworkRxPackets", "NetworkTxDropped", "NetworkTxErrors", "NetworkTxPackets", "OfflinePartitionsCount", "PartitionCount", "ProduceLocalTimeMsMean", "ProduceMessageConversionsPerSec", "ProduceMessageConversionsTimeMsMean", "ProduceRequestQueueTimeMsMean", "ProduceResponseQueueTimeMsMean", "ProduceResponseSendTimeMsMean", "ProduceThrottleByteRate", "ProduceThrottleQueueSize", "ProduceThrottleTime", "ProduceTotalTimeMsMean", "ReplicationBytesInPerSec", "ReplicationBytesOutPerSec", "RequestBytesMean", "RequestExemptFromThrottleTime", "RequestHandlerAvgIdlePercent", "RequestThrottleQueueSize", "RequestThrottleTime", "RequestTime", "RootDiskUsed", "SumOffsetLag", "SwapFree", "SwapUsed", "OffsetLag", "UnderMinIsrPartitionCount", "UnderReplicatedPartitions", "ZooKeeperRequestLatencyMsMean", "ZooKeeperSessionState"}, + "AWS/Kinesis": {"GetRecords.Bytes", "GetRecords.IteratorAge", "GetRecords.IteratorAgeMilliseconds", "GetRecords.Latency", "GetRecords.Records", "GetRecords.Success", "IncomingBytes", "IncomingRecords", "IteratorAgeMilliseconds", "OutgoingBytes", "OutgoingRecords", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Success", "PutRecords.Bytes", "PutRecords.Latency", "PutRecords.Records", "PutRecords.Success", "ReadProvisionedThroughputExceeded", "SubscribeToShard.RateExceeded", "SubscribeToShard.Success", "SubscribeToShardEvent.Bytes", "SubscribeToShardEvent.MillisBehindLatest", "SubscribeToShardEvent.Records", "SubscribeToShardEvent.Success", "WriteProvisionedThroughputExceeded"}, + "AWS/KinesisAnalytics": {"Bytes", "InputProcessing.DroppedRecords", "InputProcessing.Duration", "InputProcessing.OkBytes", "InputProcessing.OkRecords", "InputProcessing.ProcessingFailedRecords", "InputProcessing.Success", "KPUs", "LambdaDelivery.DeliveryFailedRecords", "LambdaDelivery.Duration", "LambdaDelivery.OkRecords", "MillisBehindLatest", "Records", "Success"}, + "AWS/KinesisVideo": {"GetHLSMasterPlaylist.Latency", "GetHLSMasterPlaylist.Requests", "GetHLSMasterPlaylist.Success", "GetHLSMediaPlaylist.Latency", "GetHLSMediaPlaylist.Requests", "GetHLSMediaPlaylist.Success", "GetHLSStreamingSessionURL.Latency", "GetHLSStreamingSessionURL.Requests", "GetHLSStreamingSessionURL.Success", "GetMP4InitFragment.Latency", "GetMP4InitFragment.Requests", "GetMP4InitFragment.Success", "GetMP4MediaFragment.Latency", "GetMP4MediaFragment.OutgoingBytes", "GetMP4MediaFragment.Requests", "GetMP4MediaFragment.Success", "GetMedia.ConnectionErrors", "GetMedia.MillisBehindNow", "GetMedia.OutgoingBytes", "GetMedia.OutgoingFragments", "GetMedia.OutgoingFrames", "GetMedia.Requests", "GetMedia.Success", "GetMediaForFragmentList.OutgoingBytes", "GetMediaForFragmentList.OutgoingFragments", "GetMediaForFragmentList.OutgoingFrames", "GetMediaForFragmentList.Requests", "GetMediaForFragmentList.Success", "GetTSFragment.Latency", "GetTSFragment.OutgoingBytes", "GetTSFragment.Requests", "GetTSFragment.Success", "ListFragments.Latency", "PutMedia.ActiveConnections", "PutMedia.BufferingAckLatency", "PutMedia.ConnectionErrors", "PutMedia.ErrorAckCount", "PutMedia.FragmentIngestionLatency", "PutMedia.FragmentPersistLatency", "PutMedia.IncomingBytes", "PutMedia.IncomingFragments", "PutMedia.IncomingFrames", "PutMedia.Latency", "PutMedia.PersistedAckLatency", "PutMedia.ReceivedAckLatency", "PutMedia.Requests", "PutMedia.Success"}, + "AWS/Lambda": {"ConcurrentExecutions", "DeadLetterErrors", "Duration", "Errors", "Invocations", "IteratorAge", "Throttles", "UnreservedConcurrentExecutions"}, + "AWS/Lex": {"BotChannelAuthErrors", "BotChannelConfigurationErrors", "BotChannelInboundThrottledEvents", "BotChannelOutboundThrottledEvents", "BotChannelRequestCount", "BotChannelResponseCardErrors", "BotChannelSystemErrors", "MissedUtteranceCount", "RuntimeInvalidLambdaResponses", "RuntimeLambdaErrors", "RuntimePollyErrors", "RuntimeRequestCount", "RuntimeSucessfulRequestLatency", "RuntimeSystemErrors", "RuntimeThrottledEvents", "RuntimeUserErrors"}, + "AWS/Logs": {"DeliveryErrors", "DeliveryThrottling", "ForwardedBytes", "ForwardedLogEvents", "IncomingBytes", "IncomingLogEvents"}, + "AWS/LookoutMetrics": {"ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsFailed", "Delivered", "Undelivered"}, + "AWS/ML": {"PredictCount", "PredictFailureCount"}, + "AWS/MediaConnect": {"ARQRecovered", "ARQRequests", "BitRate", "CATError", "CRCError", "Connected", "ConnectedOutputs", "ContinuityCounter", "Disconnections", "DroppedPackets", "FECPackets", "FECRecovered", "NotRecoveredPackets", "OutputConnected", "OutputDisconnections", "OverflowPackets", "PATError", "PCRAccuracyError", "PCRError", "PIDError", "PMTError", "PTSError", "PacketLossPercent", "RecoveredPackets", "RoundTripTime", "SourceARQRecovered", "SourceARQRequests", "SourceBitRate", "SourceCATError", "SourceCRCError", "SourceConnected", "SourceContinuityCounter", "SourceDisconnections", "SourceDroppedPackets", "SourceFECPackets", "SourceFECRecovered", "SourceNotRecoveredPackets", "SourceOverflowPackets", "SourcePATError", "SourcePCRAccuracyError", "SourcePCRError", "SourcePIDError", "SourcePMTError", "SourcePTSError", "SourcePacketLossPercent", "SourceRecoveredPackets", "SourceRoundTripTime", "SourceTSByteError", "SourceTSSyncLoss", "SourceTotalPackets", "SourceTransportError", "TSByteError", "TSSyncLoss", "TotalPackets", "TransportError"}, + "AWS/MediaConvert": {"AudioOutputSeconds", "Errors", "HDOutputSeconds", "JobsCompletedCount", "JobsErroredCount", "SDOutputSeconds", "StandbyTime", "TranscodingTime", "UHDOutputSeconds"}, + "AWS/MediaPackage": {"ActiveInput", "EgressBytes", "EgressRequestCount", "EgressResponseTime", "IngressBytes", "IngressResponseTime"}, + "AWS/MediaStore": {"RequestCount", "4xxErrorCount", "5xxErrorCount", "BytesUploaded", "BytesDownloaded", "TotalTime", "TurnaroundTime"}, + "AWS/MediaTailor": {"AdDecisionServer.Ads", "AdDecisionServer.Duration", "AdDecisionServer.Errors", "AdDecisionServer.FillRate", "AdDecisionServer.Timeouts", "AdNotReady", "Avails.Duration", "Avails.FillRate", "Avails.FilledDuration", "GetManifest.Errors", "Origin.Errors", "Origin.Timeouts"}, + "AWS/NATGateway": {"ActiveConnectionCount", "BytesInFromDestination", "BytesInFromSource", "BytesOutToDestination", "BytesOutToSource", "ConnectionAttemptCount", "ConnectionEstablishedCount", "ErrorPortAllocation", "IdleTimeoutCount", "PacketsDropCount", "PacketsInFromDestination", "PacketsInFromSource", "PacketsOutToDestination", "PacketsOutToSource"}, + "AWS/Neptune": {"CPUUtilization", "ClusterReplicaLag", "ClusterReplicaLagMaximum", "ClusterReplicaLagMinimum", "EngineUptime", "FreeLocalStorage", "FreeableMemory", "GremlinErrors", "GremlinHttp1xx", "GremlinHttp2xx", "GremlinHttp4xx", "GremlinHttp5xx", "GremlinRequests", "GremlinRequestsPerSec", "GremlinWebSocketAvailableConnections", "GremlinWebSocketClientErrors", "GremlinWebSocketServerErrors", "GremlinWebSocketSuccess", "Http100", "Http101", "Http1xx", "Http200", "Http2xx", "Http400", "Http403", "Http405", "Http413", "Http429", "Http4xx", "Http500", "Http501", "Http5xx", "LoaderErrors", "LoaderRequests", "NetworkReceiveThroughput", "NetworkThroughput", "NetworkTransmitThroughput", "SparqlErrors", "SparqlHttp1xx", "SparqlHttp2xx", "SparqlHttp4xx", "SparqlHttp5xx", "SparqlRequests", "SparqlRequestsPerSec", "StatusErrors", "StatusRequests", "VolumeBytesUsed", "VolumeReadIOPs", "VolumeWriteIOPs"}, + "AWS/NetworkELB": {"ActiveFlowCount", "ActiveFlowCount_TLS", "ClientTLSNegotiationErrorCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "NewFlowCount_TLS", "ProcessedBytes", "ProcessedBytes_TLS", "TCP_Client_Reset_Count", "TCP_ELB_Reset_Count", "TCP_Target_Reset_Count", "TargetTLSNegotiationErrorCount", "UnHealthyHostCount"}, + "AWS/GatewayELB": {"ActiveFlowCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "ProcessedBytes", "UnHealthyHostCount"}, + "AWS/NetworkFirewall": {"DroppedPackets", "Packets", "PassedPackets", "ReceivedPacketCount"}, + "AWS/OpsWorks": {"cpu_idle", "cpu_nice", "cpu_steal", "cpu_system", "cpu_user", "cpu_waitio", "load_1", "load_15", "load_5", "memory_buffers", "memory_cached", "memory_free", "memory_swap", "memory_total", "memory_used", "procs"}, + "AWS/Polly": {"2XXCount", "4XXCount", "5XXCount", "RequestCharacters", "ResponseLatency"}, + "AWS/PrivateLinkEndpoints": {"ActiveConnections", "BytesProcessed", "NewConnections", "PacketsDropped", "RstPacketsReceived"}, + "AWS/PrivateLinkServices": {"ActiveConnections", "BytesProcessed", "EndpointsCount", "NewConnections", "RstPacketsReceived"}, + "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraGlobalDBDataTransferBytes", "AuroraGlobalDBReplicatedWriteIO", "AuroraGlobalDBReplicationLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "AvailabilityPercentage", "BacktrackChangeRecordsCreationRate", "BacktrackChangeRecordsStored", "BacktrackWindowActual", "BacktrackWindowAlert", "BackupRetentionPeriodStorageUsed", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "ClientConnections", "ClientConnectionsClosed", "ClientConnectionsNoTLS", "ClientConnectionsReceived", "ClientConnectionsSetupFailedAuth", "ClientConnectionsSetupSucceeded", "ClientConnectionsTLS", "CommitLatency", "CommitThroughput", "DDLLatency", "DDLThroughput", "DMLLatency", "DMLThroughput", "DatabaseConnectionRequests", "DatabaseConnectionRequestsWithTLS", "DatabaseConnections", "DatabaseConnectionsBorrowLatency", "DatabaseConnectionsCurrentlyBorrowed", "DatabaseConnectionsCurrentlyInTransaction", "DatabaseConnectionsCurrentlySessionPinned", "DatabaseConnectionsSetupFailed", "DatabaseConnectionsSetupSucceeded", "DatabaseConnectionsWithTLS", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "EngineUptime", "FailedSQLServerAgentJobsCount", "FreeLocalStorage", "FreeStorageSpace", "FreeableMemory", "InsertLatency", "InsertThroughput", "LoginFailures", "MaxDatabaseConnectionsAllowed", "MaximumUsedTransactionIDs", "NetworkReceiveThroughput", "NetworkThroughput", "NetworkTransmitThroughput", "OldestReplicationSlotLag", "Queries", "QueryDatabaseResponseLatency", "QueryRequests", "QueryRequestsNoTLS", "QueryRequestsTLS", "QueryResponseLatency", "RDSToAuroraPostgreSQLReplicaLag", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ReplicationSlotDiskUsage", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "ServerlessDatabaseCapacity", "SnapshotStorageUsed", "SwapUsage", "TotalBackupStorageBilled", "TransactionLogsDiskUsage", "TransactionLogsGeneration", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPs", "VolumeWriteIOPs", "WriteIOPS", "WriteLatency", "WriteThroughput"}, + "AWS/Redshift": {"CommitQueueLength", "ConcurrencyScalingActiveClusters", "ConcurrencyScalingSeconds", "CPUUtilization", "DatabaseConnections", "HealthStatus", "MaintenanceMode", "MaxConfiguredConcurrencyScalingClusters", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "PercentageDiskSpaceUsed", "QueriesCompletedPerSecond", "QueryDuration", "QueryRuntimeBreakdown", "ReadIOPS", "ReadLatency", "ReadThroughput", "TotalTableCount", "WLMQueueLength", "WLMQueueWaitTime", "WLMQueriesCompletedPerSecond", "WLMQueryDuration", "WLMRunningQueries", "WriteIOPS", "WriteLatency", "WriteThroughput", "SchemaQuota", "NumExceededSchemaQuotas", "StorageUsed", "PercentageQuotaUsed"}, + "AWS/Robomaker": {"RealTimeFactor", "vCPU", "Memory", "SimulationUnit"}, + "AWS/Route53": {"ChildHealthCheckHealthyCount", "ConnectionTime", "DNSQueries", "HealthCheckPercentageHealthy", "HealthCheckStatus", "SSLHandshakeTime", "TimeToFirstByte"}, + "AWS/Route53Resolver": {"InboundQueryVolume", "OutboundQueryVolume", "OutboundQueryAggregatedVolume"}, + "AWS/S3": {"4xxErrors", "5xxErrors", "AllRequests", "BucketSizeBytes", "BytesDownloaded", "BytesUploaded", "DeleteRequests", "FirstByteLatency", "GetRequests", "HeadRequests", "ListRequests", "NumberOfObjects", "PostRequests", "PutRequests", "SelectRequests", "SelectReturnedBytes", "SelectScannedBytes", "TotalRequestLatency"}, + "AWS/SDKMetrics": {"CallCount", "ClientErrorCount", "EndToEndLatency", "ConnectionErrorCount", "ServerErrorCount", "ThrottleCount"}, + "AWS/ServiceCatalog": {"ProvisionedProductLaunch"}, + "AWS/SES": {"Bounce", "Clicks", "Complaint", "Delivery", "Opens", "Reject", "Rendering Failures", "Reputation.BounceRate", "Reputation.ComplaintRate", "Send"}, + "AWS/SNS": {"NumberOfMessagesPublished", "NumberOfNotificationsDelivered", "NumberOfNotificationsFailed", "NumberOfNotificationsFilteredOut", "NumberOfNotificationsFilteredOut-InvalidAttributes", "NumberOfNotificationsFilteredOut-NoMessageAttributes", "PublishSize", "SMSMonthToDateSpentUSD", "SMSSuccessRate"}, + "AWS/SQS": {"ApproximateAgeOfOldestMessage", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesNotVisible", "ApproximateNumberOfMessagesVisible", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "NumberOfMessagesReceived", "NumberOfMessagesSent", "SentMessageSize"}, + "AWS/SWF": {"ActivityTaskScheduleToCloseTime", "ActivityTaskScheduleToStartTime", "ActivityTaskStartToCloseTime", "ActivityTasksCanceled", "ActivityTasksCompleted", "ActivityTasksFailed", "ConsumedCapacity", "DecisionTaskScheduleToStartTime", "DecisionTaskStartToCloseTime", "DecisionTasksCompleted", "PendingTasks", "ProvisionedBucketSize", "ProvisionedRefillRate", "ScheduledActivityTasksTimedOutOnClose", "ScheduledActivityTasksTimedOutOnStart", "StartedActivityTasksTimedOutOnClose", "StartedActivityTasksTimedOutOnHeartbeat", "StartedDecisionTasksTimedOutOnClose", "ThrottledEvents", "WorkflowStartToCloseTime", "WorkflowsCanceled", "WorkflowsCompleted", "WorkflowsContinuedAsNew", "WorkflowsFailed", "WorkflowsTerminated", "WorkflowsTimedOut"}, + "AWS/SageMaker": {"CPUUtilization", "DatasetObjectsAutoAnnotated", "DatasetObjectsHumanAnnotated", "DatasetObjectsLabelingFailed", "DiskUtilization", "GPUMemoryUtilization", "GPUUtilization", "Invocation4XXErrors", "Invocation5XXErrors", "Invocations", "InvocationsPerInstance", "JobsFailed", "JobsStopped", "JobsSucceeded", "MemoryUtilization", "ModelLatency", "OverheadLatency", "TotalDatasetObjectsLabeled"}, + "AWS/States": {"ActivitiesFailed", "ActivitiesHeartbeatTimedOut", "ActivitiesScheduled", "ActivitiesStarted", "ActivitiesSucceeded", "ActivitiesTimedOut", "ActivityRunTime", "ActivityScheduleTime", "ActivityTime", "ConsumedCapacity", "ExecutionThrottled", "ExecutionTime", "ExecutionsAborted", "ExecutionsFailed", "ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsTimedOut", "LambdaFunctionRunTime", "LambdaFunctionScheduleTime", "LambdaFunctionTime", "LambdaFunctionsFailed", "LambdaFunctionsHeartbeatTimedOut", "LambdaFunctionsScheduled", "LambdaFunctionsStarted", "LambdaFunctionsSucceeded", "LambdaFunctionsTimedOut", "ProvisionedBucketSize", "ProvisionedRefillRate", "ThrottledEvents"}, + "AWS/StorageGateway": {"CacheFree", "CacheHitPercent", "CachePercentDirty", "CachePercentUsed", "CacheUsed", "CloudBytesDownloaded", "CloudBytesUploaded", "CloudDownloadLatency", "QueuedWrites", "ReadBytes", "ReadTime", "TimeSinceLastRecoveryPoint", "TotalCacheSize", "UploadBufferFree", "UploadBufferPercentUsed", "UploadBufferUsed", "WorkingStorageFree", "WorkingStoragePercentUsed", "WorkingStorageUsed", "WriteBytes", "WriteTime"}, + "AWS/Textract": {"ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"}, + "AWS/Timestream": {"SuccessfulRequestLatency", "SystemErrors", "UserErrors", "DataScannedBytes"}, + "AWS/ThingsGraph": {"EventStoreQueueSize", "FlowExecutionTime", "FlowExecutionsFailed", "FlowExecutionsStarted", "FlowExecutionsSucceeded", "FlowStepExecutionTime", "FlowStepExecutionsFailed", "FlowStepExecutionsStarted", "FlowStepExecutionsSucceeded"}, + "AWS/TransitGateway": {"BytesIn", "BytesOut", "PacketDropCountBlackhole", "PacketDropCountNoRoute", "PacketsIn", "PacketsOut"}, + "AWS/Transfer": {"BytesIn", "BytesOut", "FilesIn", "FilesOut"}, + "AWS/Translate": {"CharacterCount", "ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"}, + "AWS/TrustedAdvisor": {"GreenChecks", "RedChecks", "RedResources", "ServiceLimitUsage", "YellowChecks", "YellowResources"}, + "AWS/Usage": {"CallCount", "ResourceCount"}, + "AWS/VPN": {"TunnelDataIn", "TunnelDataOut", "TunnelState"}, + "AWS/WAF": {"AllowedRequests", "BlockedRequests", "CountedRequests", "PassedRequests"}, + "AWS/WAFV2": {"AllowedRequests", "BlockedRequests", "CountedRequests", "PassedRequests"}, + "AWS/WorkSpaces": {"Available", "ConnectionAttempt", "ConnectionFailure", "ConnectionSuccess", "InSessionLatency", "Maintenance", "SessionDisconnect", "SessionLaunchTime", "Stopped", "Unhealthy", "UserConnected"}, + "ECS/ContainerInsights": {"ContainerInstanceCount", "CpuUtilized", "CpuReserved", "DeploymentCount", "DesiredTaskCount", "MemoryUtilized", "MemoryReserved", "NetworkRxBytes", "NetworkTxBytes", "PendingTaskCount", "RunningTaskCount", "ServiceCount", "StorageReadBytes", "StorageWriteBytes", "TaskCount", "TaskSetCount", "instance_cpu_limit", "instance_cpu_reserved_capacity", "instance_cpu_usage_total", "instance_cpu_utilization", "instance_filesystem_utilization", "instance_memory_limit", "instance_memory_reserved_capacity", "instance_memory_utliization", "instance_memory_working_set", "instance_network_total_bytes", "instance_number_of_running_tasks"}, + "ContainerInsights": {"cluster_failed_node_count", "cluster_node_count", "namespace_number_of_running_pods", "node_cpu_limit", "node_cpu_reserved_capacity", "node_cpu_usage_total", "node_cpu_utilization", "node_filesystem_utilization", "node_memory_limit", "node_memory_reserved_capacity", "node_memory_utilization", "node_memory_working_set", "node_network_total_bytes", "node_number_of_running_containers", "node_number_of_running_pods", "pod_cpu_reserved_capacity", "pod_cpu_utilization", "pod_cpu_utilization_over_pod_limit", "pod_memory_reserved_capacity", "pod_memory_utilization", "pod_memory_utilization_over_pod_limit", "pod_number_of_container_restarts", "pod_network_rx_bytes", "pod_network_tx_bytes", "service_number_of_running_pods"}, + "AWS/Rekognition": {"DetectedFaceCount", "DetectedLabelCount", "ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"}, + "AWS/Cassandra": {"AccountMaxReads", "AccountMaxTableLevelReads", "AccountMaxTableLevelWrites", "AccountMaxWrites", "AccountProvisionedReadCapacityUtilization", "AccountProvisionedWriteCapacityUtilization", "ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "MaxProvisionedTableReadCapacityUtilization", "MaxProvisionedTableWriteCapacityUtilization", "ReturnedItemCount", "ReturnedItemCountBySelect", "SuccessfulRequestCount", "SuccessfulRequestLatency", "SystemErrors", "UserErrors"}, + "AWS/AmplifyHosting": {"Requests", "BytesDownloaded", "BytesUploaded", "4XXErrors", "5XXErrors", "Latency"}, } var dimensionsMap = map[string][]string{ @@ -473,6 +475,8 @@ var dimensionsMap = map[string][]string{ "AWS/NetworkFirewall": {"AvailabilityZone", "CustomAction", "Engine", "FirewallName"}, "AWS/OpsWorks": {"InstanceId", "LayerId", "StackId"}, "AWS/Polly": {"Operation"}, + "AWS/PrivateLinkEndpoints": {"Endpoint Type", "Service Name", "Subnet Id", "VPC Endpoint Id", "VPC Id"}, + "AWS/PrivateLinkServices": {"Az", "Load Balancer Arn", "Service Id", "VPC Endpoint Id"}, "AWS/RDS": {"DBClusterIdentifier", "DBInstanceIdentifier", "DatabaseClass", "DbClusterIdentifier", "EngineName", "ProxyName", "Role", "SourceRegion", "Target", "TargetGroup", "TargetRole"}, "AWS/Redshift": {"ClusterIdentifier", "NodeID", "service class", "stage", "latency", "wlmid"}, "AWS/Robomaker": {"SimulationJobId"}, From f6780d88613ca8c9469c3dd7bf45b13e212c67fa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 18 Feb 2022 10:23:06 +0100 Subject: [PATCH 065/104] Update dependency lru-cache to v7 (#45074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update dependency lru-cache to v7 * Make latest lru cache work Co-authored-by: Renovate Bot Co-authored-by: Zoltán Bedi --- package.json | 3 +- .../LokiExploreQueryEditor.test.tsx.snap | 178 ++++++++++-- .../datasource/loki/language_provider.ts | 4 +- .../datasource/prometheus/datasource.ts | 2 +- .../prometheus/language_provider.ts | 2 +- public/app/types/lru-cache.d.ts | 259 ++++++++++++++++++ yarn.lock | 29 +- 7 files changed, 432 insertions(+), 45 deletions(-) create mode 100644 public/app/types/lru-cache.d.ts diff --git a/package.json b/package.json index 172c24ecb10..959f9e96ac0 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,6 @@ "@types/lingui__macro": "^3", "@types/lodash": "4.14.149", "@types/logfmt": "^1.2.1", - "@types/lru-cache": "^5.1.0", "@types/mousetrap": "1.6.3", "@types/node": "16.11.22", "@types/papaparse": "5.3.2", @@ -309,7 +308,7 @@ "lezer-tree": "0.13.2", "lodash": "4.17.21", "logfmt": "^1.3.2", - "lru-cache": "6.0.0", + "lru-cache": "7.3.1", "memoize-one": "6.0.0", "moment": "2.29.1", "moment-timezone": "0.5.34", diff --git a/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap b/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap index e41e5c921ef..5562e353c36 100644 --- a/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap +++ b/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap @@ -67,38 +67,168 @@ exports[`LokiExploreQueryEditor should render component 1`] = ` "labelFetchTs": 0, "labelKeys": Array [], "labelsCache": LRUCache { - Symbol(max): 10, - Symbol(lengthCalculator): [Function], - Symbol(allowStale): false, - Symbol(maxAge): 0, - Symbol(dispose): undefined, - Symbol(noDisposeOnSet): false, - Symbol(updateAgeOnGet): false, - Symbol(cache): Map {}, - Symbol(lruList): Yallist { - "head": null, + "allowStale": false, + "disposeAfter": null, + "disposed": null, + "free": Stack { + "heap": Uint8Array [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], "length": 0, - "tail": null, }, - Symbol(length): 0, + "head": 0, + "initialFill": 1, + "keyList": Array [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + ], + "keyMap": Map {}, + "max": 10, + "maxSize": 0, + "next": Uint8Array [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + "noDisposeOnSet": false, + "prev": Uint8Array [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + "size": 0, + "sizeCalculation": undefined, + "tail": 0, + "ttl": 0, + "ttlAutopurge": false, + "ttlResolution": 1, + "updateAgeOnGet": false, + "valList": Array [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + ], }, "lookupsDisabled": false, "request": [Function], "seriesCache": LRUCache { - Symbol(max): 10, - Symbol(lengthCalculator): [Function], - Symbol(allowStale): false, - Symbol(maxAge): 0, - Symbol(dispose): undefined, - Symbol(noDisposeOnSet): false, - Symbol(updateAgeOnGet): false, - Symbol(cache): Map {}, - Symbol(lruList): Yallist { - "head": null, + "allowStale": false, + "disposeAfter": null, + "disposed": null, + "free": Stack { + "heap": Uint8Array [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], "length": 0, - "tail": null, }, - Symbol(length): 0, + "head": 0, + "initialFill": 1, + "keyList": Array [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + ], + "keyMap": Map {}, + "max": 10, + "maxSize": 0, + "next": Uint8Array [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + "noDisposeOnSet": false, + "prev": Uint8Array [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + "size": 0, + "sizeCalculation": undefined, + "tail": 0, + "ttl": 0, + "ttlAutopurge": false, + "ttlResolution": 1, + "updateAgeOnGet": false, + "valList": Array [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + ], }, "start": [Function], "started": false, diff --git a/public/app/plugins/datasource/loki/language_provider.ts b/public/app/plugins/datasource/loki/language_provider.ts index 80bf63afd0d..ef4691fdd24 100644 --- a/public/app/plugins/datasource/loki/language_provider.ts +++ b/public/app/plugins/datasource/loki/language_provider.ts @@ -78,8 +78,8 @@ export default class LokiLanguageProvider extends LanguageProvider { * not account for different size of a response. If that is needed a `length` function can be added in the options. * 10 as a max size is totally arbitrary right now. */ - private seriesCache = new LRU>(10); - private labelsCache = new LRU(10); + private seriesCache = new LRU>({ max: 10 }); + private labelsCache = new LRU({ max: 10 }); constructor(datasource: LokiDatasource, initialValues?: any) { super(); diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 1a4c1395024..2397cb8c7ed 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -72,7 +72,7 @@ export class PrometheusDatasource access: 'direct' | 'proxy'; basicAuth: any; withCredentials: any; - metricsNameCache = new LRU(10); + metricsNameCache = new LRU({ max: 10 }); interval: string; queryTimeout: string | undefined; httpMethod: string; diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts index 1e1a63df6b5..6d439b38e26 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.ts @@ -90,7 +90,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { * not account for different size of a response. If that is needed a `length` function can be added in the options. * 10 as a max size is totally arbitrary right now. */ - private labelsCache = new LRU>(10); + private labelsCache = new LRU>({ max: 10 }); constructor(datasource: PrometheusDatasource, initialValues?: Partial) { super(); diff --git a/public/app/types/lru-cache.d.ts b/public/app/types/lru-cache.d.ts new file mode 100644 index 00000000000..f515ec58fe6 --- /dev/null +++ b/public/app/types/lru-cache.d.ts @@ -0,0 +1,259 @@ +// Type definitions for lru-cache 7.1.0 +// TypeScript Version: 4.5 +declare class LRUCache { + constructor(options?: LRUCache.Options); + + /** + * Return total length of objects in cache taking into account `length` options function. + */ + readonly length: number; + + /** + * Return total quantity of objects currently in cache. Note, + * that `stale` (see options) items are returned as part of this item count. + */ + readonly itemCount: number; + + /** + * Same as Options.allowStale. + */ + allowStale: boolean; + + /** + * Same as Options.length. + */ + lengthCalculator(value: V): number; + + /** + * Same as Options.max. Resizes the cache when the `max` changes. + */ + max: number; + + /** + * Same as Options.maxAge. Resizes the cache when the `maxAge` changes. + */ + maxAge: number; + + /** + * Will update the "recently used"-ness of the key. They do what you think. + * `maxAge` is optional and overrides the cache `maxAge` option if provided. + */ + set(key: K, value: V, options?: LRUCache.SetOptions): boolean; + + /** + * Will update the "recently used"-ness of the key. They do what you think. + * `maxAge` is optional and overrides the cache `maxAge` option if provided. + * + * If the key is not found, will return `undefined`. + */ + get(key: K): V | undefined; + + /** + * Returns the key value (or `undefined` if not found) without updating + * the "recently used"-ness of the key. + * + * (If you find yourself using this a lot, you might be using the wrong + * sort of data structure, but there are some use cases where it's handy.) + */ + peek(key: K): V | undefined; + + /** + * Check if a key is in the cache, without updating the recent-ness + * or deleting it for being stale. + */ + has(key: K): boolean; + + /** + * Deletes a key out of the cache. + */ + del(key: K): void; + + /** + * Clear the cache entirely, throwing away all values. + */ + reset(): void; + + /** + * Manually iterates over the entire cache proactively pruning old entries. + */ + prune(): void; + + /** + * Just like `Array.prototype.forEach`. Iterates over all the keys in the cache, + * in order of recent-ness. (Ie, more recently used items are iterated over first.) + */ + forEach(callbackFn: (this: T, value: V, key: K, cache: this) => void, thisArg?: T): void; + + /** + * The same as `cache.forEach(...)` but items are iterated over in reverse order. + * (ie, less recently used items are iterated over first.) + */ + forEach(callbackFn: (this: T, value: V, key: K, cache: this) => void, thisArg?: T): void; + + /** + * Return an array of the keys in the cache. + */ + keys(): K[]; + + /** + * Return an array of the values in the cache. + */ + values(): V[]; + + /** + * Return an array of the cache entries ready for serialization and usage with `destinationCache.load(arr)`. + */ + dump(): Array>; + + /** + * Loads another cache entries array, obtained with `sourceCache.dump()`, + * into the cache. The destination cache is reset before loading new entries + * + * @param cacheEntries Obtained from `sourceCache.dump()` + */ + load(cacheEntries: ReadonlyArray>): void; +} + +// eslint-disable-next-line no-redeclare +declare namespace LRUCache { + interface Options { + /** + * @type {number | undefined} + * the number of most recently used items to keep. + * note that we may store fewer items than this if maxSize is hit. + */ + max?: number | undefined; + + // if you wish to track item size, you must provide a maxSize + // note that we still will only keep up to max *actual items*, + // so size tracking may cause fewer than max items to be stored. + // At the extreme, a single item of maxSize size will cause everything + // else in the cache to be dropped when it is added. Use with caution! + // Note also that size tracking can negatively impact performance, + // though for most cases, only minimally. + maxSize?: number | undefined; + + // buffers or other items where memory size depends on the object itself. + // also note that oversized items do NOT immediately get dropped from + // the cache, though they will cause faster turnover in the storage. + // Return an positive integer which is the size of the item, + // if a positive integer is not returned, will use 0 as the size. + sizeCalculation?: (value, key) => number; + + // function to call when the item is removed from the cache + // Note that using this can negatively impact performance. + dispose?: (value, key) => void; + + /** + * By default, if you set a `dispose()` method, then it'll be called whenever + * a `set()` operation overwrites an existing key. If you set this option, + * `dispose()` will only be called when a key falls out of the cache, + * not when it is overwritten. + */ + noDisposeOnSet?: boolean | undefined; + + // max time to live for items before they are considered stale + // note that stale items are NOT preemptively removed by default, + // and MAY live in the cache, contributing to its LRU max, long after + // they have expired. + // Also, as this cache is optimized for LRU/MRU operations, some of + // the staleness/TTL checks will reduce performance, as they will incur + // overhead by deleting items. + // Must be a positive integer in ms, defaults to 0, which means "no TTL" + ttl?: number; + + // Minimum amount of time in ms in which to check for staleness. + // Defaults to 1, which means that the current time + // is checked at most once per millisecond. + // Set to 0 to check the current time every time staleness is tested. + // Note that setting this to a higher value + // will improve performance somewhat while using ttl tracking, + // albeit at the expense of keeping stale items + // around a bit longer than intended. + ttlResolution?: number; + + // Preemptively remove stale items from the cache. + // Note that this may significantly degrade performance, + // especially if the cache is storing a large number of items. + // It is almost always best to just leave the stale items in the cache, + // and let them fall out as new items are added. + // Note that this means that allowStale is a bit pointless, + // as stale items will be deleted almost as soon as they expire. + // Use with caution! + ttlAutopurge?: boolean; + + // By default, if you set ttl, it'll only delete stale items + // from the cache when you get(key). + // That is, it's not preemptively pruning items. + // If you set allowStale:true, it'll return the stale value + // as well as deleting it. If you don't set this, + // then it'll return undefined when you try to get a stale entry. + // Note that when a stale entry is fetched, + // even if it is returned due to allowStale being set, + // it is removed from the cache immediately. + // You can immediately put it back in the cache if you wish, + // thus resetting the TTL. + // This may be overridden by passing an options object to cache.get(). + // The cache.has() method will always return false for stale items. + // Boolean, default false, only relevant if ttl is set. + allowStale?: boolean; + + // When using time-expiring entries with ttl, + // setting this to true will make each item's + // age reset to 0 whenever it is retrieved from cache with get(), + // causing it to not expire. + // (It can still fall out of cache based on recency of use, of course.) + // This may be overridden by passing an options object to cache.get(). + // Boolean, default false, only relevant if ttl is set. + updateAgeOnGet?: boolean; + + // update the age of items on cache.has(), renewing their TTL + // boolean, default false + updateAgeOnHas?: boolean; + + // update the "recently-used"-ness of items on cache.has() + // boolean, default false + updateRecencyOnHas?: boolean; + + /** + * Function that is used to calculate the length of stored items. + * If you're storing strings or buffers, then you probably want to do + * something like `function(n, key){return n.length}`. The default + * is `function(){return 1}`, which is fine if you want to store + * `max` like-sized things. The item is passed as the first argument, + * and the key is passed as the second argument. + */ + length?(value: V, key?: K): number; + + /** + * By default, if you set a `maxAge`, it'll only actually pull stale items + * out of the cache when you `get(key)`. (That is, it's not pre-emptively + * doing a `setTimeout` or anything.) If you set `stale:true`, it'll return + * the stale value before deleting it. If you don't set this, then it'll + * return `undefined` when you try to get a stale entry, + * as if it had already been deleted. + */ + stale?: boolean | undefined; + } + + interface SetOptions { + ttl?: number; + // Will prevent calling the sizeCalculation function + // and just use the specified number if it is a positive integer + size?: number; + // Same as above + sizeCalculator?: (value: V) => number; + // Will prevent calling a dispose function in the case of overwrites + noDisposeOnSet?: boolean; + } + + interface Entry { + k: K; + v: V; + e: number; + } +} + +declare module 'lru-cache' { + export = LRUCache; +} diff --git a/yarn.lock b/yarn.lock index d9669eb6682..f86ad710e33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10226,13 +10226,6 @@ __metadata: languageName: node linkType: hard -"@types/lru-cache@npm:^5.1.0": - version: 5.1.1 - resolution: "@types/lru-cache@npm:5.1.1" - checksum: e1d6c0085f61b16ec5b3073ec76ad1be4844ea036561c3f145fc19f71f084b58a6eb600b14128aa95809d057d28f1d147c910186ae51219f58366ffd2ff2e118 - languageName: node - linkType: hard - "@types/marked@npm:4.0.2": version: 4.0.2 resolution: "@types/marked@npm:4.0.2" @@ -20372,7 +20365,6 @@ __metadata: "@types/lingui__macro": ^3 "@types/lodash": 4.14.149 "@types/logfmt": ^1.2.1 - "@types/lru-cache": ^5.1.0 "@types/mousetrap": 1.6.3 "@types/node": 16.11.22 "@types/papaparse": 5.3.2 @@ -20485,7 +20477,7 @@ __metadata: lint-staged: 12.3.3 lodash: 4.17.21 logfmt: ^1.3.2 - lru-cache: 6.0.0 + lru-cache: 7.3.1 memoize-one: 6.0.0 mini-css-extract-plugin: 2.5.3 moment: 2.29.1 @@ -25130,12 +25122,10 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:6.0.0, lru-cache@npm:^6.0.0": - version: 6.0.0 - resolution: "lru-cache@npm:6.0.0" - dependencies: - yallist: ^4.0.0 - checksum: f97f499f898f23e4585742138a22f22526254fdba6d75d41a1c2526b3b6cc5747ef59c5612ba7375f42aca4f8461950e925ba08c991ead0651b4918b7c978297 +"lru-cache@npm:7.3.1": + version: 7.3.1 + resolution: "lru-cache@npm:7.3.1" + checksum: 34bb50c015ffc29fd83545e912f28cea6e03fbf41c497fa220c4f131b990f9ddf95babac98745b416cbc6c0d835254d61668d09b8a4ecb476934546afc9e51bd languageName: node linkType: hard @@ -25148,6 +25138,15 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^6.0.0": + version: 6.0.0 + resolution: "lru-cache@npm:6.0.0" + dependencies: + yallist: ^4.0.0 + checksum: f97f499f898f23e4585742138a22f22526254fdba6d75d41a1c2526b3b6cc5747ef59c5612ba7375f42aca4f8461950e925ba08c991ead0651b4918b7c978297 + languageName: node + linkType: hard + "lru-memoize@npm:^1.1.0": version: 1.1.0 resolution: "lru-memoize@npm:1.1.0" From a5fd35691b2df0a58f7737372e42d40560e953a3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 18 Feb 2022 10:22:08 +0000 Subject: [PATCH 066/104] Update dependency @react-types/menu to v3.5.1 (#45588) Co-authored-by: Renovate Bot --- package.json | 2 +- yarn.lock | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 959f9e96ac0..28a53c1793e 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "@microsoft/api-extractor": "7.19.4", "@pmmmwh/react-refresh-webpack-plugin": "0.5.4", "@react-types/button": "3.4.1", - "@react-types/menu": "3.4.1", + "@react-types/menu": "3.5.1", "@react-types/overlays": "3.5.1", "@react-types/shared": "3.10.1", "@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1", diff --git a/yarn.lock b/yarn.lock index f86ad710e33..fcb596a70ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7242,7 +7242,19 @@ __metadata: languageName: node linkType: hard -"@react-types/menu@npm:3.4.1, @react-types/menu@npm:^3.3.0": +"@react-types/menu@npm:3.5.1": + version: 3.5.1 + resolution: "@react-types/menu@npm:3.5.1" + dependencies: + "@react-types/overlays": ^3.5.3 + "@react-types/shared": ^3.11.1 + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + checksum: 2cf2e10fb6eff8436b6afd291a3d90542e6459e638ee65e1881e1a52ec4cb2164f51dd9dd2fc4da16e86bca5be0e70d070b3028ea7dc0953dfb8a9ec5504d5c7 + languageName: node + linkType: hard + +"@react-types/menu@npm:^3.3.0": version: 3.4.1 resolution: "@react-types/menu@npm:3.4.1" dependencies: @@ -7265,6 +7277,17 @@ __metadata: languageName: node linkType: hard +"@react-types/overlays@npm:^3.5.3": + version: 3.5.3 + resolution: "@react-types/overlays@npm:3.5.3" + dependencies: + "@react-types/shared": ^3.11.1 + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + checksum: 883dfecf1386849de2d7e090d7b566071063dae97774a145ec59386a8a3e9c44fb31f35b7d17eaa847d514b10e67a91e194f388f197f894a214c6c6489898258 + languageName: node + linkType: hard + "@react-types/shared@npm:3.10.1, @react-types/shared@npm:^3.10.1": version: 3.10.1 resolution: "@react-types/shared@npm:3.10.1" @@ -7283,6 +7306,15 @@ __metadata: languageName: node linkType: hard +"@react-types/shared@npm:^3.11.1": + version: 3.11.1 + resolution: "@react-types/shared@npm:3.11.1" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + checksum: 5d96e63a985227cc98a9172686690f7f58cfc7e8026765e4efd364dd7e059986d1e239453bb19f018b2d369717c28825b386471d1f21fc10ce5c50a3f05d1fba + languageName: node + linkType: hard + "@react-types/shared@npm:^3.8.0, @react-types/shared@npm:^3.9.0": version: 3.9.0 resolution: "@react-types/shared@npm:3.9.0" @@ -20328,7 +20360,7 @@ __metadata: "@react-stately/menu": 3.2.3 "@react-stately/tree": 3.2.0 "@react-types/button": 3.4.1 - "@react-types/menu": 3.4.1 + "@react-types/menu": 3.5.1 "@react-types/overlays": 3.5.1 "@react-types/shared": 3.10.1 "@reduxjs/toolkit": 1.7.2 From 14bee49f9a8d5c9316a92c89c1f5c684a7c7d21f Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Fri, 18 Feb 2022 11:27:00 +0100 Subject: [PATCH 067/104] AccessControl: Compute metadata from context permissions (#45578) * AccessControl: Compute metadata from context permissions * Remove nil Co-authored-by: Jguer * Check user permissions are set Co-authored-by: Jguer --- pkg/api/{roles.go => accesscontrol.go} | 223 ++++++++++-------- pkg/api/datasources.go | 31 +-- pkg/api/org_users.go | 30 +-- pkg/api/team.go | 41 +--- pkg/api/user.go | 25 +- pkg/api/user_test.go | 23 +- pkg/services/accesscontrol/accesscontrol.go | 24 +- .../accesscontrol/accesscontrol_bench_test.go | 30 +-- .../accesscontrol/accesscontrol_test.go | 46 ++-- pkg/services/serviceaccounts/api/api.go | 21 +- 10 files changed, 213 insertions(+), 281 deletions(-) rename pkg/api/{roles.go => accesscontrol.go} (55%) diff --git a/pkg/api/roles.go b/pkg/api/accesscontrol.go similarity index 55% rename from pkg/api/roles.go rename to pkg/api/accesscontrol.go index 8ec62c68ea7..5215869edf8 100644 --- a/pkg/api/roles.go +++ b/pkg/api/accesscontrol.go @@ -1,8 +1,10 @@ package api import ( + "fmt" + "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/accesscontrol" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/setting" ) @@ -29,49 +31,49 @@ const ( // API related scopes var ( - ScopeProvisionersAll = accesscontrol.Scope("provisioners", "*") - ScopeProvisionersDashboards = accesscontrol.Scope("provisioners", "dashboards") - ScopeProvisionersPlugins = accesscontrol.Scope("provisioners", "plugins") - ScopeProvisionersDatasources = accesscontrol.Scope("provisioners", "datasources") - ScopeProvisionersNotifications = accesscontrol.Scope("provisioners", "notifications") + ScopeProvisionersAll = ac.Scope("provisioners", "*") + ScopeProvisionersDashboards = ac.Scope("provisioners", "dashboards") + ScopeProvisionersPlugins = ac.Scope("provisioners", "plugins") + ScopeProvisionersDatasources = ac.Scope("provisioners", "datasources") + ScopeProvisionersNotifications = ac.Scope("provisioners", "notifications") - ScopeDatasourcesAll = accesscontrol.Scope("datasources", "*") - ScopeDatasourceID = accesscontrol.Scope("datasources", "id", accesscontrol.Parameter(":id")) - ScopeDatasourceUID = accesscontrol.Scope("datasources", "uid", accesscontrol.Parameter(":uid")) - ScopeDatasourceName = accesscontrol.Scope("datasources", "name", accesscontrol.Parameter(":name")) + ScopeDatasourcesAll = ac.Scope("datasources", "*") + ScopeDatasourceID = ac.Scope("datasources", "id", ac.Parameter(":id")) + ScopeDatasourceUID = ac.Scope("datasources", "uid", ac.Parameter(":uid")) + ScopeDatasourceName = ac.Scope("datasources", "name", ac.Parameter(":name")) ) // declareFixedRoles declares to the AccessControl service fixed roles and their // grants to organization roles ("Viewer", "Editor", "Admin") or "Grafana Admin" // that HTTPServer needs func (hs *HTTPServer) declareFixedRoles() error { - provisioningWriterRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + provisioningWriterRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Version: 3, Name: "fixed:provisioning:writer", DisplayName: "Provisioning writer", Description: "Reload provisioning.", Group: "Provisioning", - Permissions: []accesscontrol.Permission{ + Permissions: []ac.Permission{ { Action: ActionProvisioningReload, Scope: ScopeProvisionersAll, }, }, }, - Grants: []string{accesscontrol.RoleGrafanaAdmin}, + Grants: []string{ac.RoleGrafanaAdmin}, } - datasourcesExplorerRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + datasourcesExplorerRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Version: 4, Name: "fixed:datasources:explorer", DisplayName: "Data source explorer", Description: "Enable the Explore feature. Data source permissions still apply; you can only query data sources for which you have query permissions.", Group: "Data sources", - Permissions: []accesscontrol.Permission{ + Permissions: []ac.Permission{ { - Action: accesscontrol.ActionDatasourcesExplore, + Action: ac.ActionDatasourcesExplore, }, }, }, @@ -82,14 +84,14 @@ func (hs *HTTPServer) declareFixedRoles() error { datasourcesExplorerRole.Grants = append(datasourcesExplorerRole.Grants, string(models.ROLE_VIEWER)) } - datasourcesReaderRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + datasourcesReaderRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Version: 3, Name: "fixed:datasources:reader", DisplayName: "Data source reader", Description: "Read and query all data sources.", Group: "Data sources", - Permissions: []accesscontrol.Permission{ + Permissions: []ac.Permission{ { Action: ActionDatasourcesRead, Scope: ScopeDatasourcesAll, @@ -103,14 +105,14 @@ func (hs *HTTPServer) declareFixedRoles() error { Grants: []string{string(models.ROLE_ADMIN)}, } - datasourcesWriterRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + datasourcesWriterRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Version: 3, Name: "fixed:datasources:writer", DisplayName: "Data source writer", Description: "Create, update, delete, read, or query data sources.", Group: "Data sources", - Permissions: accesscontrol.ConcatPermissions(datasourcesReaderRole.Role.Permissions, []accesscontrol.Permission{ + Permissions: ac.ConcatPermissions(datasourcesReaderRole.Role.Permissions, []ac.Permission{ { Action: ActionDatasourcesWrite, Scope: ScopeDatasourcesAll, @@ -127,14 +129,14 @@ func (hs *HTTPServer) declareFixedRoles() error { Grants: []string{string(models.ROLE_ADMIN)}, } - datasourcesIdReaderRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + datasourcesIdReaderRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Version: 4, Name: "fixed:datasources.id:reader", DisplayName: "Data source ID reader", Description: "Read the ID of a data source based on its name.", Group: "Infrequently used", - Permissions: []accesscontrol.Permission{ + Permissions: []ac.Permission{ { Action: ActionDatasourcesIDRead, Scope: ScopeDatasourcesAll, @@ -144,14 +146,14 @@ func (hs *HTTPServer) declareFixedRoles() error { Grants: []string{string(models.ROLE_VIEWER)}, } - datasourcesCompatibilityReaderRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + datasourcesCompatibilityReaderRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Version: 3, Name: "fixed:datasources:compatibility:querier", DisplayName: "Data source compatibility querier", Description: "Only used for open source compatibility. Query data sources.", Group: "Infrequently used", - Permissions: []accesscontrol.Permission{ + Permissions: []ac.Permission{ {Action: ActionDatasourcesQuery}, {Action: ActionDatasourcesRead}, }, @@ -159,29 +161,29 @@ func (hs *HTTPServer) declareFixedRoles() error { Grants: []string{string(models.ROLE_VIEWER)}, } - orgReaderRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + orgReaderRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Version: 5, Name: "fixed:organization:reader", DisplayName: "Organization reader", Description: "Read an organization, such as its ID, name, address, or quotas.", Group: "Organizations", - Permissions: []accesscontrol.Permission{ + Permissions: []ac.Permission{ {Action: ActionOrgsRead}, {Action: ActionOrgsQuotasRead}, }, }, - Grants: []string{string(models.ROLE_VIEWER), accesscontrol.RoleGrafanaAdmin}, + Grants: []string{string(models.ROLE_VIEWER), ac.RoleGrafanaAdmin}, } - orgWriterRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + orgWriterRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Version: 5, Name: "fixed:organization:writer", DisplayName: "Organization writer", Description: "Read an organization, its quotas, or its preferences. Update organization properties, or its preferences.", Group: "Organizations", - Permissions: accesscontrol.ConcatPermissions(orgReaderRole.Role.Permissions, []accesscontrol.Permission{ + Permissions: ac.ConcatPermissions(orgReaderRole.Role.Permissions, []ac.Permission{ {Action: ActionOrgsPreferencesRead}, {Action: ActionOrgsWrite}, {Action: ActionOrgsPreferencesWrite}, @@ -190,71 +192,71 @@ func (hs *HTTPServer) declareFixedRoles() error { Grants: []string{string(models.ROLE_ADMIN)}, } - orgMaintainerRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + orgMaintainerRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Version: 5, Name: "fixed:organization:maintainer", DisplayName: "Organization maintainer", Description: "Create, read, write, or delete an organization. Read or write an organization's quotas. Needs to be assigned globally.", Group: "Organizations", - Permissions: accesscontrol.ConcatPermissions(orgReaderRole.Role.Permissions, []accesscontrol.Permission{ + Permissions: ac.ConcatPermissions(orgReaderRole.Role.Permissions, []ac.Permission{ {Action: ActionOrgsCreate}, {Action: ActionOrgsWrite}, {Action: ActionOrgsDelete}, {Action: ActionOrgsQuotasWrite}, }), }, - Grants: []string{string(accesscontrol.RoleGrafanaAdmin)}, + Grants: []string{string(ac.RoleGrafanaAdmin)}, } teamCreatorGrants := []string{string(models.ROLE_ADMIN)} if hs.Cfg.EditorsCanAdmin { teamCreatorGrants = append(teamCreatorGrants, string(models.ROLE_EDITOR)) } - teamsCreatorRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + teamsCreatorRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Name: "fixed:teams:creator", DisplayName: "Team creator", Description: "Create teams and read organisation users (required to manage the created teams).", Group: "Teams", Version: 2, - Permissions: []accesscontrol.Permission{ - {Action: accesscontrol.ActionTeamsCreate}, - {Action: accesscontrol.ActionOrgUsersRead, Scope: accesscontrol.ScopeUsersAll}, + Permissions: []ac.Permission{ + {Action: ac.ActionTeamsCreate}, + {Action: ac.ActionOrgUsersRead, Scope: ac.ScopeUsersAll}, }, }, Grants: teamCreatorGrants, } - teamsWriterRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + teamsWriterRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Name: "fixed:teams:writer", DisplayName: "Team writer", Description: "Create, read, write, or delete a team as well as controlling team memberships.", Group: "Teams", Version: 2, - Permissions: []accesscontrol.Permission{ - {Action: accesscontrol.ActionTeamsCreate}, - {Action: accesscontrol.ActionTeamsDelete, Scope: accesscontrol.ScopeTeamsAll}, - {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: accesscontrol.ScopeTeamsAll}, - {Action: accesscontrol.ActionTeamsPermissionsWrite, Scope: accesscontrol.ScopeTeamsAll}, - {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, - {Action: accesscontrol.ActionTeamsWrite, Scope: accesscontrol.ScopeTeamsAll}, + Permissions: []ac.Permission{ + {Action: ac.ActionTeamsCreate}, + {Action: ac.ActionTeamsDelete, Scope: ac.ScopeTeamsAll}, + {Action: ac.ActionTeamsPermissionsRead, Scope: ac.ScopeTeamsAll}, + {Action: ac.ActionTeamsPermissionsWrite, Scope: ac.ScopeTeamsAll}, + {Action: ac.ActionTeamsRead, Scope: ac.ScopeTeamsAll}, + {Action: ac.ActionTeamsWrite, Scope: ac.ScopeTeamsAll}, }, }, Grants: []string{string(models.ROLE_ADMIN)}, } - annotationsReaderRole := accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ + annotationsReaderRole := ac.RoleRegistration{ + Role: ac.RoleDTO{ Name: "fixed:annotations:reader", DisplayName: "Annotation reader", Description: "Read annotations and tags", Group: "Annotations", Version: 1, - Permissions: []accesscontrol.Permission{ - {Action: accesscontrol.ActionAnnotationsRead, Scope: accesscontrol.ScopeAnnotationsAll}, - {Action: accesscontrol.ActionAnnotationsTagsRead, Scope: accesscontrol.ScopeAnnotationsTagsAll}, + Permissions: []ac.Permission{ + {Action: ac.ActionAnnotationsRead, Scope: ac.ScopeAnnotationsAll}, + {Action: ac.ActionAnnotationsTagsRead, Scope: ac.ScopeAnnotationsTagsAll}, }, }, Grants: []string{string(models.ROLE_VIEWER)}, @@ -271,69 +273,96 @@ func (hs *HTTPServer) declareFixedRoles() error { // here is the list of complex evaluators we use in this package // dataSourcesConfigurationAccessEvaluator is used to protect the "Configure > Data sources" tab access -var dataSourcesConfigurationAccessEvaluator = accesscontrol.EvalAll( - accesscontrol.EvalPermission(ActionDatasourcesRead), - accesscontrol.EvalAny( - accesscontrol.EvalPermission(ActionDatasourcesCreate), - accesscontrol.EvalPermission(ActionDatasourcesDelete), - accesscontrol.EvalPermission(ActionDatasourcesWrite), +var dataSourcesConfigurationAccessEvaluator = ac.EvalAll( + ac.EvalPermission(ActionDatasourcesRead), + ac.EvalAny( + ac.EvalPermission(ActionDatasourcesCreate), + ac.EvalPermission(ActionDatasourcesDelete), + ac.EvalPermission(ActionDatasourcesWrite), ), ) // dataSourcesNewAccessEvaluator is used to protect the "Configure > Data sources > New" page access -var dataSourcesNewAccessEvaluator = accesscontrol.EvalAll( - accesscontrol.EvalPermission(ActionDatasourcesRead), - accesscontrol.EvalPermission(ActionDatasourcesCreate), - accesscontrol.EvalPermission(ActionDatasourcesWrite), +var dataSourcesNewAccessEvaluator = ac.EvalAll( + ac.EvalPermission(ActionDatasourcesRead), + ac.EvalPermission(ActionDatasourcesCreate), + ac.EvalPermission(ActionDatasourcesWrite), ) // dataSourcesEditAccessEvaluator is used to protect the "Configure > Data sources > Edit" page access -var dataSourcesEditAccessEvaluator = accesscontrol.EvalAll( - accesscontrol.EvalPermission(ActionDatasourcesRead), - accesscontrol.EvalPermission(ActionDatasourcesWrite), +var dataSourcesEditAccessEvaluator = ac.EvalAll( + ac.EvalPermission(ActionDatasourcesRead), + ac.EvalPermission(ActionDatasourcesWrite), ) // orgPreferencesAccessEvaluator is used to protect the "Configure > Preferences" page access -var orgPreferencesAccessEvaluator = accesscontrol.EvalAny( - accesscontrol.EvalAll( - accesscontrol.EvalPermission(ActionOrgsRead), - accesscontrol.EvalPermission(ActionOrgsWrite), +var orgPreferencesAccessEvaluator = ac.EvalAny( + ac.EvalAll( + ac.EvalPermission(ActionOrgsRead), + ac.EvalPermission(ActionOrgsWrite), ), - accesscontrol.EvalAll( - accesscontrol.EvalPermission(ActionOrgsPreferencesRead), - accesscontrol.EvalPermission(ActionOrgsPreferencesWrite), + ac.EvalAll( + ac.EvalPermission(ActionOrgsPreferencesRead), + ac.EvalPermission(ActionOrgsPreferencesWrite), ), ) // orgsAccessEvaluator is used to protect the "Server Admin > Orgs" page access // (you need to have read access to update or delete orgs; read is the minimum) -var orgsAccessEvaluator = accesscontrol.EvalPermission(ActionOrgsRead) +var orgsAccessEvaluator = ac.EvalPermission(ActionOrgsRead) // orgsCreateAccessEvaluator is used to protect the "Server Admin > Orgs > New Org" page access -var orgsCreateAccessEvaluator = accesscontrol.EvalAll( - accesscontrol.EvalPermission(ActionOrgsRead), - accesscontrol.EvalPermission(ActionOrgsCreate), +var orgsCreateAccessEvaluator = ac.EvalAll( + ac.EvalPermission(ActionOrgsRead), + ac.EvalPermission(ActionOrgsCreate), ) // teamsAccessEvaluator is used to protect the "Configuration > Teams" page access // grants access to a user when they can either create teams or can read and update a team -var teamsAccessEvaluator = accesscontrol.EvalAny( - accesscontrol.EvalPermission(accesscontrol.ActionTeamsCreate), - accesscontrol.EvalAll( - accesscontrol.EvalPermission(accesscontrol.ActionTeamsRead), - accesscontrol.EvalAny( - accesscontrol.EvalPermission(accesscontrol.ActionTeamsWrite), - accesscontrol.EvalPermission(accesscontrol.ActionTeamsPermissionsWrite), +var teamsAccessEvaluator = ac.EvalAny( + ac.EvalPermission(ac.ActionTeamsCreate), + ac.EvalAll( + ac.EvalPermission(ac.ActionTeamsRead), + ac.EvalAny( + ac.EvalPermission(ac.ActionTeamsWrite), + ac.EvalPermission(ac.ActionTeamsPermissionsWrite), ), ), ) // teamsEditAccessEvaluator is used to protect the "Configuration > Teams > edit" page access -var teamsEditAccessEvaluator = accesscontrol.EvalAll( - accesscontrol.EvalPermission(accesscontrol.ActionTeamsRead), - accesscontrol.EvalAny( - accesscontrol.EvalPermission(accesscontrol.ActionTeamsCreate), - accesscontrol.EvalPermission(accesscontrol.ActionTeamsWrite), - accesscontrol.EvalPermission(accesscontrol.ActionTeamsPermissionsWrite), +var teamsEditAccessEvaluator = ac.EvalAll( + ac.EvalPermission(ac.ActionTeamsRead), + ac.EvalAny( + ac.EvalPermission(ac.ActionTeamsCreate), + ac.EvalPermission(ac.ActionTeamsWrite), + ac.EvalPermission(ac.ActionTeamsPermissionsWrite), ), ) + +// Metadata helpers +// getAccessControlMetadata returns the accesscontrol metadata associated with a given resource +func (hs *HTTPServer) getAccessControlMetadata(c *models.ReqContext, resource string, id int64) ac.Metadata { + key := fmt.Sprintf("%d", id) + ids := map[string]bool{key: true} + + return hs.getMultiAccessControlMetadata(c, resource, ids)[key] +} + +// getMultiAccessControlMetadata returns the accesscontrol metadata associated with a given set of resources +func (hs *HTTPServer) getMultiAccessControlMetadata(c *models.ReqContext, resource string, ids map[string]bool) map[string]ac.Metadata { + if hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") { + return map[string]ac.Metadata{} + } + + if c.SignedInUser.Permissions == nil { + return map[string]ac.Metadata{} + } + + permissions, ok := c.SignedInUser.Permissions[c.OrgId] + if !ok { + return map[string]ac.Metadata{} + } + + return ac.GetResourcesMetadata(c.Req.Context(), permissions, resource, ids) +} diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index 138635024c6..9af7d1c247b 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -17,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins/adapters" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -71,23 +70,6 @@ func (hs *HTTPServer) GetDataSources(c *models.ReqContext) response.Response { return response.JSON(200, &result) } -func (hs *HTTPServer) getDataSourceAccessControlMetadata(c *models.ReqContext, dsID int64) (accesscontrol.Metadata, error) { - if hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") { - return nil, nil - } - - userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser, - accesscontrol.Options{ReloadCache: false}) - if err != nil || len(userPermissions) == 0 { - return nil, err - } - - key := fmt.Sprintf("%d", dsID) - dsIDs := map[string]bool{key: true} - - return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "datasources", dsIDs)[key], nil -} - // GET /api/datasources/:id func (hs *HTTPServer) GetDataSourceById(c *models.ReqContext) response.Response { id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) @@ -115,12 +97,9 @@ func (hs *HTTPServer) GetDataSourceById(c *models.ReqContext) response.Response } dto := convertModelToDtos(filtered[0]) + // Add accesscontrol metadata - metadata, err := hs.getDataSourceAccessControlMetadata(c, dto.Id) - if err != nil { - return response.Error(http.StatusInternalServerError, "Failed to query metadata", err) - } - dto.AccessControl = metadata + dto.AccessControl = hs.getAccessControlMetadata(c, "datasources", dto.Id) return response.JSON(200, &dto) } @@ -179,11 +158,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *models.ReqContext) response.Response dto := convertModelToDtos(filtered[0]) // Add accesscontrol metadata - metadata, err := hs.getDataSourceAccessControlMetadata(c, dto.Id) - if err != nil { - return response.Error(http.StatusInternalServerError, "Failed to query metadata", err) - } - dto.AccessControl = metadata + dto.AccessControl = hs.getAccessControlMetadata(c, "datasources", dto.Id) return response.JSON(200, &dto) } diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index dfc70933943..cad6b3715f6 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -113,19 +112,6 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrgLookup(c *models.ReqContext) respo return response.JSON(200, result) } -func (hs *HTTPServer) getUserAccessControlMetadata(c *models.ReqContext, resourceIDs map[string]bool) (map[string]accesscontrol.Metadata, error) { - if hs.AccessControl == nil || hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") { - return nil, nil - } - - userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser, accesscontrol.Options{ReloadCache: false}) - if err != nil || len(userPermissions) == 0 { - return nil, err - } - - return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "users", resourceIDs), nil -} - // GET /api/orgs/:orgId/users func (hs *HTTPServer) GetOrgUsers(c *models.ReqContext) response.Response { orgId, err := strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) @@ -164,17 +150,11 @@ func (hs *HTTPServer) getOrgUsersHelper(c *models.ReqContext, query *models.GetO filteredUsers = append(filteredUsers, user) } - accessControlMetadata, errAC := hs.getUserAccessControlMetadata(c, userIDs) - if errAC != nil { - hs.log.Error("Failed to get access control metadata", "error", errAC) - - return filteredUsers, nil - } else if accessControlMetadata == nil { - return filteredUsers, nil - } - - for i := range filteredUsers { - filteredUsers[i].AccessControl = accessControlMetadata[fmt.Sprint(filteredUsers[i].UserId)] + accessControlMetadata := hs.getMultiAccessControlMetadata(c, "users", userIDs) + if len(accessControlMetadata) > 0 { + for i := range filteredUsers { + filteredUsers[i].AccessControl = accessControlMetadata[fmt.Sprint(filteredUsers[i].UserId)] + } } return filteredUsers, nil diff --git a/pkg/api/team.go b/pkg/api/team.go index 4f9a38ffb27..2b256959e9c 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -2,14 +2,12 @@ package api import ( "errors" - "fmt" "net/http" "strconv" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -105,20 +103,6 @@ func (hs *HTTPServer) DeleteTeamByID(c *models.ReqContext) response.Response { return response.Success("Team deleted") } -func (hs *HTTPServer) getTeamsAccessControlMetadata(c *models.ReqContext, teamIDs map[string]bool) (map[string]accesscontrol.Metadata, error) { - if hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") { - return nil, nil - } - - userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser, accesscontrol.Options{ReloadCache: false}) - if err != nil || len(userPermissions) == 0 { - hs.log.Warn("could not fetch accesscontrol metadata for teams", "error", err) - return nil, err - } - - return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "teams", teamIDs), nil -} - // GET /api/teams/search func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response { perPage := c.QueryInt("perpage") @@ -157,8 +141,8 @@ func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response { teamIDs[strconv.FormatInt(team.Id, 10)] = true } - metadata, err := hs.getTeamsAccessControlMetadata(c, teamIDs) - if err == nil && len(metadata) != 0 { + metadata := hs.getMultiAccessControlMetadata(c, "teams", teamIDs) + if len(metadata) > 0 { for _, team := range query.Result.Teams { team.AccessControl = metadata[strconv.FormatInt(team.Id, 10)] } @@ -170,23 +154,6 @@ func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response { return response.JSON(200, query.Result) } -func (hs *HTTPServer) getTeamAccessControlMetadata(c *models.ReqContext, teamID int64) (accesscontrol.Metadata, error) { - if hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") { - return nil, nil - } - - userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser, accesscontrol.Options{ReloadCache: false}) - if err != nil || len(userPermissions) == 0 { - hs.log.Warn("could not fetch accesscontrol metadata", "team", teamID, "error", err) - return nil, err - } - - key := fmt.Sprintf("%d", teamID) - teamIDs := map[string]bool{key: true} - - return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "teams", teamIDs)[key], nil -} - // UserFilter returns the user ID used in a filter when querying a team // 1. If the user is a viewer or editor, this will return the user's ID. // 2. If the user is an admin, this will return models.FilterIgnoreUser (0) @@ -227,8 +194,8 @@ func (hs *HTTPServer) GetTeamByID(c *models.ReqContext) response.Response { return response.Error(500, "Failed to get Team", err) } - metadata, _ := hs.getTeamAccessControlMetadata(c, query.Result.Id) - query.Result.AccessControl = metadata + // Add accesscontrol metadata + query.Result.AccessControl = hs.getAccessControlMetadata(c, "teams", query.Result.Id) query.Result.AvatarUrl = dtos.GetGravatarUrlWithDefault(query.Result.Email, query.Result.Name) return response.JSON(200, &query.Result) diff --git a/pkg/api/user.go b/pkg/api/user.go index 64f609fff43..20babbf91cc 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -3,14 +3,12 @@ package api import ( "context" "errors" - "fmt" "net/http" "strconv" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -48,33 +46,12 @@ func (hs *HTTPServer) getUserUserProfile(c *models.ReqContext, userID int64) res query.Result.IsExternal = true } - accessControlMetadata, errAC := hs.getGlobalUserAccessControlMetadata(c, userID) - if errAC != nil { - hs.log.Error("Failed to get access control metadata", "error", errAC) - } - - query.Result.AccessControl = accessControlMetadata + query.Result.AccessControl = hs.getAccessControlMetadata(c, "global:users", userID) query.Result.AvatarUrl = dtos.GetGravatarUrl(query.Result.Email) return response.JSON(200, query.Result) } -func (hs *HTTPServer) getGlobalUserAccessControlMetadata(c *models.ReqContext, userID int64) (accesscontrol.Metadata, error) { - if hs.AccessControl == nil || hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") { - return nil, nil - } - - userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser, accesscontrol.Options{ReloadCache: false}) - if err != nil || len(userPermissions) == 0 { - return nil, err - } - - key := fmt.Sprintf("%d", userID) - userIDs := map[string]bool{key: true} - - return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "global:users", userIDs)[key], nil -} - // GET /api/users/lookup func (hs *HTTPServer) GetUserByLoginOrEmail(c *models.ReqContext) response.Response { query := models.GetUserByLoginQuery{LoginOrEmail: c.Query("loginOrEmail")} diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index 2841d5bd5d2..e52fcf09778 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -8,32 +8,33 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" + acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/login/authinfoservice" authinfostore "github.com/grafana/grafana/pkg/services/login/authinfoservice/database" + "github.com/grafana/grafana/pkg/services/searchusers" "github.com/grafana/grafana/pkg/services/searchusers/filters" "github.com/grafana/grafana/pkg/services/secrets/database" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" - "golang.org/x/oauth2" - - "github.com/grafana/grafana/pkg/services/searchusers" - - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/models" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { settings := setting.NewCfg() sqlStore := sqlstore.InitTestDB(t) hs := &HTTPServer{ - Cfg: settings, - SQLStore: sqlStore, + Cfg: settings, + SQLStore: sqlStore, + AccessControl: &acmock.Mock{}, } mockResult := models.SearchUserQueryResult{ diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index b2deeb36845..03ae32a23a2 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -151,7 +151,7 @@ func addActionToMetadata(allMetadata map[string]Metadata, action, id string) map } // GetResourcesMetadata returns a map of accesscontrol metadata, listing for each resource, users available actions -func GetResourcesMetadata(ctx context.Context, permissions []*Permission, resource string, resourceIDs map[string]bool) map[string]Metadata { +func GetResourcesMetadata(ctx context.Context, permissions map[string][]string, resource string, ids map[string]bool) map[string]Metadata { allScope := GetResourceAllScope(resource) allIDScope := GetResourceAllIDScope(resource) @@ -162,16 +162,18 @@ func GetResourcesMetadata(ctx context.Context, permissions []*Permission, resour // Loop through permissions once result := map[string]Metadata{} - for _, p := range permissions { - if p.Scope == "*" || p.Scope == allScope || p.Scope == allIDScope { - // Add global action to all resources - for id := range resourceIDs { - result = addActionToMetadata(result, p.Action, id) - } - } else { - if len(p.Scope) > idIndex && strings.HasPrefix(p.Scope, idPrefix) && resourceIDs[p.Scope[idIndex:]] { - // Add action to a specific resource - result = addActionToMetadata(result, p.Action, p.Scope[idIndex:]) + for action, scopes := range permissions { + for _, scope := range scopes { + if scope == "*" || scope == allScope || scope == allIDScope { + // Add global action to all resources + for id := range ids { + result = addActionToMetadata(result, action, id) + } + } else { + if len(scope) > idIndex && strings.HasPrefix(scope, idPrefix) && ids[scope[idIndex:]] { + // Add action to a specific resource + result = addActionToMetadata(result, action, scope[idIndex:]) + } } } } diff --git a/pkg/services/accesscontrol/accesscontrol_bench_test.go b/pkg/services/accesscontrol/accesscontrol_bench_test.go index 2259508f67e..b6942e664d3 100644 --- a/pkg/services/accesscontrol/accesscontrol_bench_test.go +++ b/pkg/services/accesscontrol/accesscontrol_bench_test.go @@ -9,17 +9,17 @@ import ( "github.com/stretchr/testify/assert" ) -func setupTestEnv(b *testing.B, resourceCount, permissionPerResource int) ([]*Permission, map[string]bool) { - res := make([]*Permission, resourceCount*permissionPerResource) +func setupTestEnv(b *testing.B, resourceCount, permissionPerResource int) (map[string][]string, map[string]bool) { + res := map[string][]string{} ids := make(map[string]bool, resourceCount) - for r := 0; r < resourceCount; r++ { - for p := 0; p < permissionPerResource; p++ { - perm := Permission{Action: fmt.Sprintf("resources:action%v", p), Scope: fmt.Sprintf("resources:id:%v", r)} - id := r*permissionPerResource + p - res[id] = &perm + for p := 0; p < permissionPerResource; p++ { + action := fmt.Sprintf("resources:action%v", p) + for r := 0; r < resourceCount; r++ { + scope := fmt.Sprintf("resources:id:%v", r) + res[action] = append(res[action], scope) + ids[fmt.Sprintf("%d", r)] = true } - ids[fmt.Sprintf("%d", r)] = true } return res, ids @@ -40,23 +40,23 @@ func benchGetMetadata(b *testing.B, resourceCount, permissionPerResource int) { } // Lots of permissions -func BenchmarkGetResourcesMetadata_10_1000(b *testing.B) { benchGetMetadata(b, 10, 1000) } // ~0.0017s/op -func BenchmarkGetResourcesMetadata_10_10000(b *testing.B) { benchGetMetadata(b, 10, 10000) } // ~0.016s/op -func BenchmarkGetResourcesMetadata_10_100000(b *testing.B) { benchGetMetadata(b, 10, 100000) } // ~0.17s/op +func BenchmarkGetResourcesMetadata_10_1000(b *testing.B) { benchGetMetadata(b, 10, 1000) } // ~0.0022s/op +func BenchmarkGetResourcesMetadata_10_10000(b *testing.B) { benchGetMetadata(b, 10, 10000) } // ~0.019s/op +func BenchmarkGetResourcesMetadata_10_100000(b *testing.B) { benchGetMetadata(b, 10, 100000) } // ~0.25s/op func BenchmarkGetResourcesMetadata_10_1000000(b *testing.B) { if testing.Short() { b.Skip("Skipping benchmark in short mode") } benchGetMetadata(b, 10, 1000000) -} // ~3.89s/op +} // ~5.8s/op // Lots of resources func BenchmarkGetResourcesMetadata_1000_10(b *testing.B) { benchGetMetadata(b, 1000, 10) } // ~0,0023s/op -func BenchmarkGetResourcesMetadata_10000_10(b *testing.B) { benchGetMetadata(b, 10000, 10) } // ~0.021s/op -func BenchmarkGetResourcesMetadata_100000_10(b *testing.B) { benchGetMetadata(b, 100000, 10) } // ~0.22s/op +func BenchmarkGetResourcesMetadata_10000_10(b *testing.B) { benchGetMetadata(b, 10000, 10) } // ~0.022s/op +func BenchmarkGetResourcesMetadata_100000_10(b *testing.B) { benchGetMetadata(b, 100000, 10) } // ~0.26s/op func BenchmarkGetResourcesMetadata_1000000_10(b *testing.B) { if testing.Short() { b.Skip("Skipping benchmark in short mode") } benchGetMetadata(b, 1000000, 10) -} // ~2.8s/op +} // ~4.1s/op diff --git a/pkg/services/accesscontrol/accesscontrol_test.go b/pkg/services/accesscontrol/accesscontrol_test.go index e373b08d172..eef8e0b1e11 100644 --- a/pkg/services/accesscontrol/accesscontrol_test.go +++ b/pkg/services/accesscontrol/accesscontrol_test.go @@ -12,7 +12,7 @@ func TestGetResourcesMetadata(t *testing.T) { desc string resource string resourcesIDs map[string]bool - permissions []*Permission + permissions map[string][]string expected map[string]Metadata }{ { @@ -24,10 +24,10 @@ func TestGetResourcesMetadata(t *testing.T) { { desc: "Should return no permission for resources 1,2,3 given the user has permissions for 4 only", resource: "resources", - permissions: []*Permission{ - {Action: "resources:action1", Scope: Scope("resources", "id", "4")}, - {Action: "resources:action2", Scope: Scope("resources", "id", "4")}, - {Action: "resources:action3", Scope: Scope("resources", "id", "4")}, + permissions: map[string][]string{ + "resources:action1": {Scope("resources", "id", "4")}, + "resources:action2": {Scope("resources", "id", "4")}, + "resources:action3": {Scope("resources", "id", "4")}, }, resourcesIDs: map[string]bool{"1": true, "2": true, "3": true}, expected: map[string]Metadata{}, @@ -35,10 +35,10 @@ func TestGetResourcesMetadata(t *testing.T) { { desc: "Should only return permissions for resources 1 and 2, given the user has no permissions for 3", resource: "resources", - permissions: []*Permission{ - {Action: "resources:action1", Scope: Scope("resources", "id", "1")}, - {Action: "resources:action2", Scope: Scope("resources", "id", "2")}, - {Action: "resources:action3", Scope: Scope("resources", "id", "2")}, + permissions: map[string][]string{ + "resources:action1": {Scope("resources", "id", "1")}, + "resources:action2": {Scope("resources", "id", "2")}, + "resources:action3": {Scope("resources", "id", "2")}, }, resourcesIDs: map[string]bool{"1": true, "2": true, "3": true}, expected: map[string]Metadata{ @@ -49,13 +49,13 @@ func TestGetResourcesMetadata(t *testing.T) { { desc: "Should return permissions with global scopes for resources 1,2,3", resource: "resources", - permissions: []*Permission{ - {Action: "resources:action4", Scope: Scope("resources", "id", "*")}, - {Action: "resources:action5", Scope: Scope("resources", "*")}, - {Action: "resources:action6", Scope: "*"}, - {Action: "resources:action1", Scope: Scope("resources", "id", "1")}, - {Action: "resources:action2", Scope: Scope("resources", "id", "2")}, - {Action: "resources:action3", Scope: Scope("resources", "id", "2")}, + permissions: map[string][]string{ + "resources:action1": {Scope("resources", "id", "1")}, + "resources:action2": {Scope("resources", "id", "2")}, + "resources:action3": {Scope("resources", "id", "2")}, + "resources:action4": {Scope("resources", "id", "*")}, + "resources:action5": {Scope("resources", "*")}, + "resources:action6": {"*"}, }, resourcesIDs: map[string]bool{"1": true, "2": true, "3": true}, expected: map[string]Metadata{ @@ -67,11 +67,10 @@ func TestGetResourcesMetadata(t *testing.T) { { desc: "Should correctly filter out irrelevant permissions for resources 1,2,3", resource: "resources", - permissions: []*Permission{ - {Action: "resources:action1", Scope: Scope("resources", "id", "1")}, - {Action: "otherresources:action1", Scope: Scope("resources", "id", "1")}, - {Action: "resources:action2", Scope: Scope("otherresources", "id", "*")}, - {Action: "otherresources:action1", Scope: Scope("otherresources", "id", "*")}, + permissions: map[string][]string{ + "resources:action1": {Scope("resources", "id", "1")}, + "resources:action2": {Scope("otherresources", "id", "*")}, + "otherresources:action1": {Scope("resources", "id", "1"), Scope("otherresources", "id", "*")}, }, resourcesIDs: map[string]bool{"1": true, "2": true, "3": true}, expected: map[string]Metadata{ @@ -81,9 +80,8 @@ func TestGetResourcesMetadata(t *testing.T) { { desc: "Should correctly handle permissions with multilayer scope", resource: "resources:sub", - permissions: []*Permission{ - {Action: "resources:action1", Scope: Scope("resources", "sub", "id", "1")}, - {Action: "resources:action1", Scope: Scope("resources", "sub", "id", "123")}, + permissions: map[string][]string{ + "resources:action1": {Scope("resources", "sub", "id", "1"), Scope("resources", "sub", "id", "123")}, }, resourcesIDs: map[string]bool{"1": true, "123": true}, expected: map[string]Metadata{ diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index b8ee87f00fa..1d26c54ce01 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -150,8 +150,8 @@ func (api *ServiceAccountsAPI) ListServiceAccounts(c *models.ReqContext) respons saIDs[strconv.FormatInt(serviceAccounts[i].Id, 10)] = true } - metadata, err := api.getAccessControlMetadata(c, saIDs) - if err == nil && len(metadata) != 0 { + metadata := api.getAccessControlMetadata(c, saIDs) + if len(metadata) > 0 { for i := range serviceAccounts { serviceAccounts[i].AccessControl = metadata[strconv.FormatInt(serviceAccounts[i].Id, 10)] } @@ -160,18 +160,21 @@ func (api *ServiceAccountsAPI) ListServiceAccounts(c *models.ReqContext) respons return response.JSON(http.StatusOK, serviceAccounts) } -func (api *ServiceAccountsAPI) getAccessControlMetadata(c *models.ReqContext, saIDs map[string]bool) (map[string]accesscontrol.Metadata, error) { +func (api *ServiceAccountsAPI) getAccessControlMetadata(c *models.ReqContext, saIDs map[string]bool) map[string]accesscontrol.Metadata { if api.accesscontrol.IsDisabled() || !c.QueryBool("accesscontrol") { - return nil, nil + return map[string]accesscontrol.Metadata{} } - userPermissions, err := api.accesscontrol.GetUserPermissions(c.Req.Context(), c.SignedInUser, accesscontrol.Options{ReloadCache: false}) - if err != nil || len(userPermissions) == 0 { - api.log.Warn("could not fetch accesscontrol metadata for teams", "error", err) - return nil, err + if c.SignedInUser.Permissions == nil { + return map[string]accesscontrol.Metadata{} } - return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "serviceaccounts", saIDs), nil + permissions, ok := c.SignedInUser.Permissions[c.OrgId] + if !ok { + return map[string]accesscontrol.Metadata{} + } + + return accesscontrol.GetResourcesMetadata(c.Req.Context(), permissions, "serviceaccounts", saIDs) } func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) response.Response { From 32b17fbc28fc350d6c76d89c778bd59e89d11767 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 18 Feb 2022 11:35:50 +0100 Subject: [PATCH 068/104] Update dependency @react-aria/interactions to v3.8.1 (#45513) Co-authored-by: Renovate Bot --- package.json | 2 +- yarn.lock | 64 ++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 28a53c1793e..e3620684890 100644 --- a/package.json +++ b/package.json @@ -254,7 +254,7 @@ "@react-aria/button": "3.3.4", "@react-aria/dialog": "3.1.4", "@react-aria/focus": "3.5.0", - "@react-aria/interactions": "3.7.0", + "@react-aria/interactions": "3.8.1", "@react-aria/menu": "3.3.0", "@react-aria/overlays": "3.7.3", "@react-aria/utils": "3.11.0", diff --git a/yarn.lock b/yarn.lock index fcb596a70ef..57645474fdc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6947,16 +6947,16 @@ __metadata: languageName: node linkType: hard -"@react-aria/interactions@npm:3.7.0, @react-aria/interactions@npm:^3.7.0": - version: 3.7.0 - resolution: "@react-aria/interactions@npm:3.7.0" +"@react-aria/interactions@npm:3.8.1": + version: 3.8.1 + resolution: "@react-aria/interactions@npm:3.8.1" dependencies: "@babel/runtime": ^7.6.2 - "@react-aria/utils": ^3.10.0 - "@react-types/shared": ^3.10.0 + "@react-aria/utils": ^3.11.2 + "@react-types/shared": ^3.11.1 peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 - checksum: d1f5d07b78195e94f5f706d04ad3cd5d4a9f256d6e5b88f086971ad493f9fce931546601b9b3d06671798cc9c68630438f2a2f61cfda7376a03235c721abf6a5 + checksum: a157f2ee29a5d252b0147627b6dd11c676a57580d436f2e70c7d5260d971e82b1c2f4c1164a4c65e72d7049a228f2e931f611c4260ed882317e4f6c7ae879b3e languageName: node linkType: hard @@ -6973,6 +6973,19 @@ __metadata: languageName: node linkType: hard +"@react-aria/interactions@npm:^3.7.0": + version: 3.7.0 + resolution: "@react-aria/interactions@npm:3.7.0" + dependencies: + "@babel/runtime": ^7.6.2 + "@react-aria/utils": ^3.10.0 + "@react-types/shared": ^3.10.0 + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + checksum: d1f5d07b78195e94f5f706d04ad3cd5d4a9f256d6e5b88f086971ad493f9fce931546601b9b3d06671798cc9c68630438f2a2f61cfda7376a03235c721abf6a5 + languageName: node + linkType: hard + "@react-aria/menu@npm:3.3.0": version: 3.3.0 resolution: "@react-aria/menu@npm:3.3.0" @@ -7044,6 +7057,17 @@ __metadata: languageName: node linkType: hard +"@react-aria/ssr@npm:^3.1.2": + version: 3.1.2 + resolution: "@react-aria/ssr@npm:3.1.2" + dependencies: + "@babel/runtime": ^7.6.2 + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + checksum: 86ba84fa3a7197b04cfbbd2d2f60d45d07345bd2a48d0d10b303d1bad98a652b58bf76b9d820f7b4dce063531e65ed268eb55cd974e11a5b4d125d28e638ba8b + languageName: node + linkType: hard + "@react-aria/utils@npm:3.11.0": version: 3.11.0 resolution: "@react-aria/utils@npm:3.11.0" @@ -7074,6 +7098,21 @@ __metadata: languageName: node linkType: hard +"@react-aria/utils@npm:^3.11.2": + version: 3.11.2 + resolution: "@react-aria/utils@npm:3.11.2" + dependencies: + "@babel/runtime": ^7.6.2 + "@react-aria/ssr": ^3.1.2 + "@react-stately/utils": ^3.4.1 + "@react-types/shared": ^3.11.1 + clsx: ^1.1.1 + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + checksum: 6071935e79c181482980e91c1eb52811318bdadbdfa575d6a0bac25bc23d30518e9ec98b56a931072c1df8eb46826671006aa264818ab475a1b8a3fade552bec + languageName: node + linkType: hard + "@react-aria/utils@npm:^3.8.2, @react-aria/utils@npm:^3.9.0": version: 3.9.0 resolution: "@react-aria/utils@npm:3.9.0" @@ -7208,6 +7247,17 @@ __metadata: languageName: node linkType: hard +"@react-stately/utils@npm:^3.4.1": + version: 3.4.1 + resolution: "@react-stately/utils@npm:3.4.1" + dependencies: + "@babel/runtime": ^7.6.2 + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + checksum: a6494720dc6ca7fbe9986f407f6e62988b45f51747fb655b4ec87289896fe96e560c60270f1e987ddeca1749fa255c521550fda777f185fc7e66b8324f182f1e + languageName: node + linkType: hard + "@react-types/button@npm:3.4.1, @react-types/button@npm:^3.4.1": version: 3.4.1 resolution: "@react-types/button@npm:3.4.1" @@ -20352,7 +20402,7 @@ __metadata: "@react-aria/button": 3.3.4 "@react-aria/dialog": 3.1.4 "@react-aria/focus": 3.5.0 - "@react-aria/interactions": 3.7.0 + "@react-aria/interactions": 3.8.1 "@react-aria/menu": 3.3.0 "@react-aria/overlays": 3.7.3 "@react-aria/utils": 3.11.0 From 0a8c3f92f6717c623b13781358a8e4d4836212d0 Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Fri, 18 Feb 2022 12:40:51 +0200 Subject: [PATCH 069/104] Update grabpl (#45591) --- .drone.yml | 66 ++++++++++++++++++------------------ scripts/drone/steps/lib.star | 2 +- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.drone.yml b/.drone.yml index cc63711f2fa..6db90a719d2 100644 --- a/.drone.yml +++ b/.drone.yml @@ -11,7 +11,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -111,7 +111,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -362,7 +362,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -423,7 +423,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -524,7 +524,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -909,7 +909,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -977,7 +977,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -1060,7 +1060,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1142,7 +1142,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1446,7 +1446,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1567,7 +1567,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1651,7 +1651,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -1710,7 +1710,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2050,7 +2050,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2223,7 +2223,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2351,7 +2351,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -2426,7 +2426,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2504,7 +2504,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2565,7 +2565,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2644,7 +2644,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2706,7 +2706,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2742,7 +2742,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2789,7 +2789,7 @@ steps: name: initialize - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2837,7 +2837,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2900,7 +2900,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3159,7 +3159,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3273,7 +3273,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3350,7 +3350,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -3398,7 +3398,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3731,7 +3731,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3894,7 +3894,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4012,7 +4012,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.1/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -4209,6 +4209,6 @@ kind: secret name: gcp_upload_artifacts_key --- kind: signature -hmac: 174576f45b5f239ab17433b3113a9f3d87b90907bae43ba11dd5499c93e401b7 +hmac: 8fbbae5ad04e33e1a8d32d6e9dc1b1a2264aa31951a2d3af9d4f9da7ad5d2886 ... diff --git a/scripts/drone/steps/lib.star b/scripts/drone/steps/lib.star index 98d2dc57ed1..77d5ff46d4b 100644 --- a/scripts/drone/steps/lib.star +++ b/scripts/drone/steps/lib.star @@ -1,6 +1,6 @@ load('scripts/drone/vault.star', 'from_secret', 'github_token', 'pull_secret', 'drone_token', 'prerelease_bucket') -grabpl_version = 'v2.9.1' +grabpl_version = 'v2.9.2' build_image = 'grafana/build-container:1.4.9' publish_image = 'grafana/grafana-ci-deploy:1.3.1' deploy_docker_image = 'us.gcr.io/kubernetes-dev/drone/plugins/deploy-image' From c6943797f94af7f793e9d4f7dad533897c6a1ef3 Mon Sep 17 00:00:00 2001 From: Eric Leijonmarck Date: Fri, 18 Feb 2022 10:43:33 +0000 Subject: [PATCH 070/104] Admin: token expiration colors and calculations (#45231) * token expiration colorign and calculations * Update public/app/features/serviceaccounts/ServiceAccountTokensTable.tsx * removed unused calculation for expiry * optional attribute * fix: typo * implement failing test :thumpsup: * tests * refactor: tests to use assertify * tiem * refactor: remote porntf * refactor: make test NOT sleep 1 sec for all builds :D Co-authored-by: J Guerreiro --- pkg/services/serviceaccounts/api/api_test.go | 18 +-- pkg/services/serviceaccounts/api/token.go | 42 +++++- .../serviceaccounts/api/token_test.go | 132 +++++++++++++++++- .../serviceaccounts/database/database.go | 4 +- .../ServiceAccountTokensTable.tsx | 70 ++++++++-- public/app/types/apiKeys.ts | 3 + 6 files changed, 234 insertions(+), 35 deletions(-) diff --git a/pkg/services/serviceaccounts/api/api_test.go b/pkg/services/serviceaccounts/api/api_test.go index b467ef6763b..bff5d52cb61 100644 --- a/pkg/services/serviceaccounts/api/api_test.go +++ b/pkg/services/serviceaccounts/api/api_test.go @@ -17,14 +17,13 @@ import ( accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/serviceaccounts" + "github.com/grafana/grafana/pkg/services/serviceaccounts/database" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/services/serviceaccounts/database" ) var ( @@ -63,7 +62,7 @@ func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) { } serviceAccountRequestScenario(t, http.MethodDelete, serviceaccountIDPath, &testcase.user, func(httpmethod string, endpoint string, user *tests.TestUser) { createduser := tests.SetupUserServiceAccount(t, store, testcase.user) - server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock, store) + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock, store, database.NewServiceAccountsStore(store)) actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, fmt.Sprint(createduser.Id))).Code require.Equal(t, testcase.expectedCode, actual) }) @@ -87,7 +86,7 @@ func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) { } serviceAccountRequestScenario(t, http.MethodDelete, serviceaccountIDPath, &testcase.user, func(httpmethod string, endpoint string, user *tests.TestUser) { createduser := tests.SetupUserServiceAccount(t, store, testcase.user) - server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock, store) + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock, store, database.NewServiceAccountsStore(store)) actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, createduser.Id)).Code require.Equal(t, testcase.expectedCode, actual) }) @@ -99,8 +98,11 @@ func serviceAccountRequestScenario(t *testing.T, httpMethod string, endpoint str fn(httpMethod, endpoint, user) } -func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, routerRegister routing.RouteRegister, acmock *accesscontrolmock.Mock, sqlStore *sqlstore.SQLStore) *web.Mux { - a := NewServiceAccountsAPI(setting.NewCfg(), svc, acmock, routerRegister, database.NewServiceAccountsStore(sqlStore), sqlStore) +func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, + routerRegister routing.RouteRegister, + acmock *accesscontrolmock.Mock, + sqlStore *sqlstore.SQLStore, saStore serviceaccounts.Store) *web.Mux { + a := NewServiceAccountsAPI(setting.NewCfg(), svc, acmock, routerRegister, saStore, sqlStore) a.RegisterAPIEndpoints(featuremgmt.WithFeatures(featuremgmt.FlagServiceAccounts)) a.cfg.ApiKeyMaxSecondsToLive = -1 // disable api key expiration @@ -190,7 +192,7 @@ func TestServiceAccountsAPI_RetrieveServiceAccount(t *testing.T) { createdUser := tests.SetupUserServiceAccount(t, store, *tc.user) scopeID = int(createdUser.Id) } - server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store) + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store)) actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, scopeID)) @@ -300,7 +302,7 @@ func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) { createdUser := tests.SetupUserServiceAccount(t, store, *tc.user) scopeID = int(createdUser.Id) } - server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store) + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store)) var rawBody io.Reader = http.NoBody if tc.body != nil { diff --git a/pkg/services/serviceaccounts/api/token.go b/pkg/services/serviceaccounts/api/token.go index 506f06afb7a..a66bcc226ff 100644 --- a/pkg/services/serviceaccounts/api/token.go +++ b/pkg/services/serviceaccounts/api/token.go @@ -16,6 +16,26 @@ import ( const failedToDeleteMsg = "Failed to delete API key" +type TokenDTO struct { + Id int64 `json:"id"` + Name string `json:"name"` + Role models.RoleType `json:"role"` + Created *time.Time `json:"created"` + Expiration *time.Time `json:"expiration"` + SecondsUntilExpiration *float64 `json:"secondsUntilExpiration"` + HasExpired bool `json:"hasExpired"` +} + +func hasExpired(expiration *int64) bool { + if expiration == nil { + return false + } + v := time.Unix(*expiration, 0) + return (v).Before(time.Now()) +} + +const sevenDaysAhead = 7 * 24 * time.Hour + func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Response { saID, err := strconv.ParseInt(web.Params(ctx.Req)[":serviceAccountId"], 10, 64) if err != nil { @@ -23,18 +43,28 @@ func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Respo } if saTokens, err := api.store.ListTokens(ctx.Req.Context(), ctx.OrgId, saID); err == nil { - result := make([]*models.ApiKeyDTO, len(saTokens)) + result := make([]*TokenDTO, len(saTokens)) for i, t := range saTokens { var expiration *time.Time = nil + var secondsUntilExpiration float64 = 0 + + isExpired := hasExpired(t.Expires) if t.Expires != nil { v := time.Unix(*t.Expires, 0) expiration = &v + if !isExpired && (*expiration).Before(time.Now().Add(sevenDaysAhead)) { + secondsUntilExpiration = time.Until(*expiration).Seconds() + } } - result[i] = &models.ApiKeyDTO{ - Id: t.Id, - Name: t.Name, - Role: t.Role, - Expiration: expiration, + + result[i] = &TokenDTO{ + Id: t.Id, + Name: t.Name, + Role: t.Role, + Created: &t.Created, + Expiration: expiration, + SecondsUntilExpiration: &secondsUntilExpiration, + HasExpired: isExpired, } } diff --git a/pkg/services/serviceaccounts/api/token_test.go b/pkg/services/serviceaccounts/api/token_test.go index b404b08549f..a90af8ec96f 100644 --- a/pkg/services/serviceaccounts/api/token_test.go +++ b/pkg/services/serviceaccounts/api/token_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" @@ -17,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/serviceaccounts" + "github.com/grafana/grafana/pkg/services/serviceaccounts/database" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/web" @@ -29,7 +31,7 @@ const ( serviceaccountIDTokensDetailPath = "/api/serviceaccounts/%v/tokens/%v" // #nosec G101 ) -func createTokenforSA(t *testing.T, keyName string, orgID int64, saID int64) *models.ApiKey { +func createTokenforSA(t *testing.T, keyName string, orgID int64, saID int64, secondsToLive int64) *models.ApiKey { key, err := apikeygen.New(orgID, keyName) require.NoError(t, err) cmd := models.AddApiKeyCommand{ @@ -37,13 +39,12 @@ func createTokenforSA(t *testing.T, keyName string, orgID int64, saID int64) *mo Role: "Viewer", OrgId: orgID, Key: key.HashedKey, - SecondsToLive: 0, + SecondsToLive: secondsToLive, ServiceAccountId: &saID, Result: &models.ApiKey{}, } err = bus.Dispatch(context.Background(), &cmd) require.NoError(t, err) - return cmd.Result } @@ -129,7 +130,7 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) { bodyString = string(b) } - server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store) + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store)) actual := requestResponse(server, http.MethodPost, endpoint, strings.NewReader(bodyString)) actualCode := actual.Code @@ -215,11 +216,11 @@ func TestServiceAccountsAPI_DeleteToken(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - token := createTokenforSA(t, tc.keyName, sa.OrgId, sa.Id) + token := createTokenforSA(t, tc.keyName, sa.OrgId, sa.Id, 1) endpoint := fmt.Sprintf(serviceaccountIDTokensDetailPath, sa.Id, token.Id) bodyString := "" - server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store) + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store)) actual := requestResponse(server, http.MethodDelete, endpoint, strings.NewReader(bodyString)) actualCode := actual.Code @@ -238,3 +239,122 @@ func TestServiceAccountsAPI_DeleteToken(t *testing.T) { }) } } + +type saStoreMockTokens struct { + serviceaccounts.Store + saAPIKeys []*models.ApiKey +} + +func (s *saStoreMockTokens) ListTokens(ctx context.Context, orgID, saID int64) ([]*models.ApiKey, error) { + return s.saAPIKeys, nil +} + +func TestServiceAccountsAPI_ListTokens(t *testing.T) { + store := sqlstore.InitTestDB(t) + svcmock := tests.ServiceAccountMock{} + sa := tests.SetupUserServiceAccount(t, store, tests.TestUser{Login: "sa", IsServiceAccount: true}) + + type testCreateSAToken struct { + desc string + tokens []*models.ApiKey + expectedHasExpired bool + expectedResponseBodyField string + expectedCode int + acmock *accesscontrolmock.Mock + } + + var saId int64 = 1 + var timeInFuture = time.Now().Add(time.Second * 100).Unix() + var timeInPast = time.Now().Add(-time.Second * 100).Unix() + + testCases := []testCreateSAToken{ + { + desc: "should be able to list serviceaccount with no expiration date", + tokens: []*models.ApiKey{{ + Id: 1, + OrgId: 1, + ServiceAccountId: &saId, + Expires: nil, + Name: "Test1", + }}, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{{Action: serviceaccounts.ActionRead, Scope: "serviceaccounts:id:1"}}, nil + }, + false, + ), + expectedHasExpired: false, + expectedResponseBodyField: "hasExpired", + expectedCode: http.StatusOK, + }, + { + desc: "should be able to list serviceaccount with secondsUntilExpiration", + tokens: []*models.ApiKey{{ + Id: 1, + OrgId: 1, + ServiceAccountId: &saId, + Expires: &timeInFuture, + Name: "Test2", + }}, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{{Action: serviceaccounts.ActionRead, Scope: "serviceaccounts:id:1"}}, nil + }, + false, + ), + expectedHasExpired: false, + expectedResponseBodyField: "secondsUntilExpiration", + expectedCode: http.StatusOK, + }, + { + desc: "should be able to list serviceaccount with expired token", + tokens: []*models.ApiKey{{ + Id: 1, + OrgId: 1, + ServiceAccountId: &saId, + Expires: &timeInPast, + Name: "Test3", + }}, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{{Action: serviceaccounts.ActionRead, Scope: "serviceaccounts:id:1"}}, nil + }, + false, + ), + expectedHasExpired: true, + expectedResponseBodyField: "secondsUntilExpiration", + expectedCode: http.StatusOK, + }, + } + + var requestResponse = func(server *web.Mux, httpMethod, requestpath string, requestBody io.Reader) *httptest.ResponseRecorder { + req, err := http.NewRequest(httpMethod, requestpath, requestBody) + require.NoError(t, err) + req.Header.Add("Content-Type", "application/json") + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, req) + return recorder + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + endpoint := fmt.Sprintf(serviceaccountIDPath+"/tokens", sa.Id) + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, &saStoreMockTokens{saAPIKeys: tc.tokens}) + actual := requestResponse(server, http.MethodGet, endpoint, http.NoBody) + + actualCode := actual.Code + actualBody := []map[string]interface{}{} + + _ = json.Unmarshal(actual.Body.Bytes(), &actualBody) + require.Equal(t, tc.expectedCode, actualCode, endpoint, actualBody) + + require.Equal(t, tc.expectedCode, actualCode) + require.Equal(t, tc.expectedHasExpired, actualBody[0]["hasExpired"]) + _, exists := actualBody[0][tc.expectedResponseBodyField] + require.Equal(t, exists, true) + }) + } +} diff --git a/pkg/services/serviceaccounts/database/database.go b/pkg/services/serviceaccounts/database/database.go index 35163803e48..593a23b03c3 100644 --- a/pkg/services/serviceaccounts/database/database.go +++ b/pkg/services/serviceaccounts/database/database.go @@ -128,9 +128,9 @@ func (s *ServiceAccountsStoreImpl) ListTokens(ctx context.Context, orgID int64, err := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { var sess *xorm.Session - sess = dbSession.Limit(100, 0). + sess = dbSession. Join("inner", "user", "user.id = api_key.service_account_id"). - Where("user.org_id=? AND user.id=? AND ( expires IS NULL or expires >= ?)", orgID, serviceAccountID, time.Now().Unix()). + Where("user.org_id=? AND user.id=?", orgID, serviceAccountID). Asc("name") return sess.Find(&result) diff --git a/public/app/features/serviceaccounts/ServiceAccountTokensTable.tsx b/public/app/features/serviceaccounts/ServiceAccountTokensTable.tsx index 23dcd972391..be500c1f26e 100644 --- a/public/app/features/serviceaccounts/ServiceAccountTokensTable.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountTokensTable.tsx @@ -1,5 +1,5 @@ import React, { FC } from 'react'; -import { DeleteButton, Icon, Tooltip, useTheme2 } from '@grafana/ui'; +import { DeleteButton, Icon, Tooltip, useStyles2, useTheme2 } from '@grafana/ui'; import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data'; import { ApiKey } from '../../types'; @@ -22,25 +22,19 @@ export const ServiceAccountTokensTable: FC = ({ tokens, timeZone, onDelet Name Expires + Created {tokens.map((key) => { - const isExpired = !!(key.expiration && Date.now() > new Date(key.expiration).getTime()); return ( - + {key.name} - {formatDate(timeZone, key.expiration)} - {isExpired && ( - - - - - - )} + + {formatDate(timeZone, key.created)} onDelete(key)} /> @@ -60,11 +54,61 @@ function formatDate(timeZone: TimeZone, expiration?: string): string { return dateTimeFormat(expiration, { timeZone }); } +function formatSecondsLeftUntilExpiration(secondsUntilExpiration: number): string { + const days = Math.floor(secondsUntilExpiration / (3600 * 24)); + const daysFormat = days > 1 ? `${days} days` : `${days} day`; + return `Expires in ${daysFormat}`; +} + +interface TokenExpirationProps { + timeZone: TimeZone; + token: ApiKey; +} + +const TokenExpiration = ({ timeZone, token }: TokenExpirationProps) => { + const styles = useStyles2(getStyles); + if (!token.expiration) { + return Never; + } + if (token.secondsUntilExpiration) { + return ( + + {formatSecondsLeftUntilExpiration(token.secondsUntilExpiration)} + + ); + } + if (token.hasExpired) { + return ( + + Expired + + + + + + + ); + } + return {formatDate(timeZone, token.expiration)}; +}; + const getStyles = (theme: GrafanaTheme2) => ({ - tableRow: (isExpired: boolean) => css` - color: ${isExpired ? theme.colors.text.secondary : theme.colors.text.primary}; + tableRow: (hasExpired: boolean | undefined) => css` + color: ${hasExpired ? theme.colors.text.secondary : theme.colors.text.primary}; `, tooltipContainer: css` margin-left: ${theme.spacing(1)}; `, + toolTipIcon: css` + color: ${theme.colors.error.text}; + `, + secondsUntilExpiration: css` + color: ${theme.colors.warning.text}; + `, + hasExpired: css` + color: ${theme.colors.error.text}; + `, + neverExpire: css` + color: ${theme.colors.text.secondary}; + `, }); diff --git a/public/app/types/apiKeys.ts b/public/app/types/apiKeys.ts index ec370a96521..043fb3210d2 100644 --- a/public/app/types/apiKeys.ts +++ b/public/app/types/apiKeys.ts @@ -6,6 +6,9 @@ export interface ApiKey { role: OrgRole; secondsToLive: number | null; expiration?: string; + secondsUntilExpiration?: number; + hasExpired?: boolean; + created?: string; } export interface NewApiKey { From fc514738b25fe218f129881a84e95ca9dd88a4c3 Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Fri, 18 Feb 2022 11:46:52 +0100 Subject: [PATCH 071/104] Chore: fix comment (#45593) --- pkg/services/sqlstore/datasource.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/services/sqlstore/datasource.go b/pkg/services/sqlstore/datasource.go index 7f3f0cd3219..6ec9c94a193 100644 --- a/pkg/services/sqlstore/datasource.go +++ b/pkg/services/sqlstore/datasource.go @@ -88,7 +88,7 @@ func (ss *SQLStore) GetDefaultDataSource(ctx context.Context, query *models.GetD } // DeleteDataSource removes a datasource by org_id as well as either uid (preferred), id, or name -// and is added to the bus. It also removes permissions related to the team. +// and is added to the bus. It also removes permissions related to the datasource. func (ss *SQLStore) DeleteDataSource(ctx context.Context, cmd *models.DeleteDataSourceCommand) error { return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { dsQuery := &models.GetDataSourceQuery{Id: cmd.ID, Uid: cmd.UID, Name: cmd.Name, OrgId: cmd.OrgID} From dd9b52fd41396287693223fd5050ca811a99ade7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Fri, 18 Feb 2022 12:05:29 +0100 Subject: [PATCH 072/104] log-volume: adjust request-id to avoid conflicts (#45511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * log-volume: adjust request-id to avoid conflicts * added unit test * simplify test Co-authored-by: Piotr Jamróz * added missing import Co-authored-by: Piotr Jamróz --- .../app/features/explore/state/query.test.ts | 20 +++++++++++++++++++ public/app/features/explore/state/query.ts | 6 +++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/public/app/features/explore/state/query.test.ts b/public/app/features/explore/state/query.test.ts index 2accb2fa55b..9aa8bcad2ac 100644 --- a/public/app/features/explore/state/query.test.ts +++ b/public/app/features/explore/state/query.test.ts @@ -21,6 +21,7 @@ import { DataQueryResponse, DataSourceApi, DataSourceJsonData, + DataSourceWithLogsVolumeSupport, DefaultTimeZone, LoadingState, MutableDataFrame, @@ -54,6 +55,7 @@ const defaultInitialState = { datasourceInstance: { query: jest.fn(), getRef: jest.fn(), + getLogsVolumeDataProvider: jest.fn(), meta: { id: 'something', }, @@ -101,6 +103,24 @@ describe('runQueries', () => { expect(getState().explore[ExploreId.left].graphResult).toBeDefined(); }); + it('should modify the request-id for log-volume queries', async () => { + setTimeSrv({ init() {} } as any); + const { dispatch, getState } = configureStore({ + ...(defaultInitialState as any), + }); + setupQueryResponse(getState()); + await dispatch(runQueries(ExploreId.left)); + + const state = getState().explore[ExploreId.left]; + expect(state.queryResponse.request?.requestId).toBe('explore_left'); + const datasource = state.datasourceInstance as any as DataSourceWithLogsVolumeSupport; + expect(datasource.getLogsVolumeDataProvider).toBeCalledWith( + expect.objectContaining({ + requestId: 'explore_left_log_volume', + }) + ); + }); + it('should set state to done if query completes without emitting', async () => { setTimeSrv({ init() {} } as any); const { dispatch, getState } = configureStore({ diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 9825a6a1dfc..730d81177a7 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -497,7 +497,11 @@ export const runQueries = ( ); dispatch(cleanLogsVolumeAction({ exploreId })); } else if (hasLogsVolumeSupport(datasourceInstance)) { - const logsVolumeDataProvider = datasourceInstance.getLogsVolumeDataProvider(transaction.request); + const sourceRequest = { + ...transaction.request, + requestId: transaction.request.requestId + '_log_volume', + }; + const logsVolumeDataProvider = datasourceInstance.getLogsVolumeDataProvider(sourceRequest); dispatch( storeLogsVolumeDataProviderAction({ exploreId, From 530913dd37435431fcd6d749ad4179528c38493b Mon Sep 17 00:00:00 2001 From: Yaelle Chaudy <42030685+yaelleC@users.noreply.github.com> Date: Fri, 18 Feb 2022 12:09:24 +0100 Subject: [PATCH 073/104] Azure Monitor : Adding json formatting of error messages in Panel Header Corner and Inspect Error Tab (#44877) * Trying out json formatting of azure graph err from frontend * Added some tests wip * Wrap text in popper tooltip * fix conflict * Wrap text in tooltip * Complete tests * Added invalid json test * Backend changes and tests * removed comments * Added split of message / json and edge cases tests * Addressed comments * moved catch to parseErrorMessage --- .../src/components/Tooltip/Tooltip.tsx | 1 + .../azure-resource-graph-datasource.go | 2 +- .../azure-resource-graph-datasource_test.go | 45 ++++++++++++ .../inspector/InspectErrorTab.test.tsx | 72 +++++++++++++++++++ .../features/inspector/InspectErrorTab.tsx | 28 +++++++- 5 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 public/app/features/inspector/InspectErrorTab.test.tsx diff --git a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx index 053539acbe1..ba5cd2b2b9f 100644 --- a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx @@ -69,6 +69,7 @@ function getStyles(theme: GrafanaTheme2) { transition: opacity 0.3s; z-index: ${theme.zIndex.tooltip}; max-width: 400px; + overflow-wrap: break-word; &[data-popper-interactive='false'] { pointer-events: none; diff --git a/pkg/tsdb/azuremonitor/azure-resource-graph-datasource.go b/pkg/tsdb/azuremonitor/azure-resource-graph-datasource.go index 6d12302416a..c501fa9e057 100644 --- a/pkg/tsdb/azuremonitor/azure-resource-graph-datasource.go +++ b/pkg/tsdb/azuremonitor/azure-resource-graph-datasource.go @@ -235,7 +235,7 @@ func (e *AzureResourceGraphDatasource) unmarshalResponse(res *http.Response) (Az if res.StatusCode/100 != 2 { azlog.Debug("Request failed", "status", res.Status, "body", string(body)) - return AzureResourceGraphResponse{}, fmt.Errorf("request failed, status: %s, body: %s", res.Status, string(body)) + return AzureResourceGraphResponse{}, fmt.Errorf("%s. Azure Resource Graph error: %s", res.Status, string(body)) } var data AzureResourceGraphResponse diff --git a/pkg/tsdb/azuremonitor/azure-resource-graph-datasource_test.go b/pkg/tsdb/azuremonitor/azure-resource-graph-datasource_test.go index cbba2109faa..42466777a9d 100644 --- a/pkg/tsdb/azuremonitor/azure-resource-graph-datasource_test.go +++ b/pkg/tsdb/azuremonitor/azure-resource-graph-datasource_test.go @@ -2,7 +2,9 @@ package azuremonitor import ( "context" + "io" "net/http" + "strings" "testing" "time" @@ -150,3 +152,46 @@ func TestGetAzurePortalUrl(t *testing.T) { assert.Equal(t, expectedAzurePortalUrl[cloud], azurePortalUrl) } } + +func TestUnmarshalResponse400(t *testing.T) { + datasource := &AzureResourceGraphDatasource{} + res, err := datasource.unmarshalResponse(&http.Response{ + StatusCode: 400, + Status: "400 Bad Request", + Body: io.NopCloser(strings.NewReader(("Azure Error Message"))), + }) + + expectedErrMsg := "400 Bad Request. Azure Resource Graph error: Azure Error Message" + + assert.Equal(t, expectedErrMsg, err.Error()) + assert.Empty(t, res) +} + +func TestUnmarshalResponse200Invalid(t *testing.T) { + datasource := &AzureResourceGraphDatasource{} + res, err := datasource.unmarshalResponse(&http.Response{ + StatusCode: 200, + Status: "OK", + Body: io.NopCloser(strings.NewReader(("Azure Data"))), + }) + + expectedRes := AzureResourceGraphResponse{} + expectedErr := "invalid character 'A' looking for beginning of value" + + assert.Equal(t, expectedErr, err.Error()) + assert.Equal(t, expectedRes, res) +} + +func TestUnmarshalResponse200(t *testing.T) { + datasource := &AzureResourceGraphDatasource{} + res, err2 := datasource.unmarshalResponse(&http.Response{ + StatusCode: 200, + Status: "OK", + Body: io.NopCloser(strings.NewReader("{}")), + }) + + expectedRes := AzureResourceGraphResponse{} + + assert.NoError(t, err2) + assert.Equal(t, expectedRes, res) +} diff --git a/public/app/features/inspector/InspectErrorTab.test.tsx b/public/app/features/inspector/InspectErrorTab.test.tsx new file mode 100644 index 00000000000..b04e1283d35 --- /dev/null +++ b/public/app/features/inspector/InspectErrorTab.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { InspectErrorTab } from './InspectErrorTab'; + +describe('InspectErrorTab', () => { + it('should return null when error does not exist', () => { + const { container } = render(); + expect(container.childElementCount).toEqual(0); + }); + + it('should return a jsonFormatter object of error.data if it exists', () => { + const error = { + data: { + message: 'This is an error', + error: 'my error', + }, + }; + render(); + expect(screen.getByText('This is an error')).toBeInTheDocument(); + expect(screen.getByText('error:')).toBeInTheDocument(); + expect(screen.getByText('"my error"')).toBeInTheDocument(); + }); + + it('should return a jsonFormatter object of error.message if it exists and data does not exist', () => { + const error = { + message: + '{ "error": { "code": "BadRequest", "message": "Please provide below info when asking for support.", "details": [] } }', + }; + const { container } = render(); + expect(container.childElementCount).toEqual(1); + expect(screen.getByText('code:')).toBeInTheDocument(); + expect(screen.getByText('"BadRequest"')).toBeInTheDocument(); + expect(screen.getByText('"Please provide below info when asking for support."')).toBeInTheDocument(); + }); + + it('should return an h3 and jsonFormatter object of error.message if it exists and data does not exist', () => { + const error = { + message: + '400 BadRequest, Error from Azure: { "error": { "code": "BadRequest", "message": "Please provide below info when asking for support.", "details": [] } }', + }; + const { container } = render(); + expect(container.childElementCount).toEqual(2); + expect(screen.getByRole('heading', { name: '400 BadRequest, Error from Azure:' })).toBeInTheDocument(); + expect(screen.getByText('code:')).toBeInTheDocument(); + expect(screen.getByText('"BadRequest"')).toBeInTheDocument(); + }); + + [ + '{ invalidJSON{', + "hello, I am an error that's just text, no json at all, altoough I do mention template variables {{test}}", + 'and I am a simple string', + ].forEach((errMsg) => { + it(`should return error.message error.data does not exist nd error.message cannot be parsed - ${errMsg} `, () => { + const error = { + message: errMsg, + }; + render(); + expect(screen.queryByRole('heading')).toBeNull(); + expect(screen.getByText(errMsg)).toBeInTheDocument(); + }); + }); + + it('should return a jsonFormatter object of error if it has no .data and no .message', () => { + const error = { + status: '400', + }; + const { container } = render(); + expect(container.childElementCount).toEqual(1); + expect(screen.getByText('status:')).toBeInTheDocument(); + expect(screen.getByText('"400"')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/inspector/InspectErrorTab.tsx b/public/app/features/inspector/InspectErrorTab.tsx index dcb00122cf5..2c747d18f36 100644 --- a/public/app/features/inspector/InspectErrorTab.tsx +++ b/public/app/features/inspector/InspectErrorTab.tsx @@ -6,6 +6,19 @@ interface InspectErrorTabProps { error?: DataQueryError; } +const parseErrorMessage = (message: string): { msg: string; json?: any } => { + try { + const [msg, json] = message.split(/(\{.+)/); + const jsonError = JSON.parse(json); + return { + msg, + json: jsonError, + }; + } catch { + return { msg: message }; + } +}; + export const InspectErrorTab: React.FC = ({ error }) => { if (!error) { return null; @@ -18,5 +31,18 @@ export const InspectErrorTab: React.FC = ({ error }) => { ); } - return
{error.message}
; + if (error.message) { + const { msg, json } = parseErrorMessage(error.message); + if (!json) { + return
{msg}
; + } else { + return ( + <> + {msg !== '' &&

{msg}

} + + + ); + } + } + return ; }; From 0838f4b1ad9ea91dd5d7463285f5dbbd3bcd3ef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 18 Feb 2022 12:51:55 +0100 Subject: [PATCH 074/104] Plugins: Fix importing panel modules (#45590) --- .../app/features/plugins/importPanelPlugin.ts | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/public/app/features/plugins/importPanelPlugin.ts b/public/app/features/plugins/importPanelPlugin.ts index 40d4dc3549d..ccb3bdf9251 100644 --- a/public/app/features/plugins/importPanelPlugin.ts +++ b/public/app/features/plugins/importPanelPlugin.ts @@ -28,22 +28,25 @@ export function importPanelPluginFromMeta(meta: grafanaData.PanelPluginMeta): Pr return getPanelPlugin(meta); } -async function getPanelPlugin(meta: grafanaData.PanelPluginMeta): Promise { - try { - const pluginExports = await importPluginModule(meta.module, meta.info?.version); - let plugin = pluginExports.plugin; - - if (!plugin && pluginExports.PanelCtrl) { - plugin = new grafanaData.PanelPlugin(null); - plugin.angularPanelCtrl = pluginExports.PanelCtrl; - } - - plugin.meta = meta; - - return plugin; - } catch (err) { - // TODO, maybe a different error plugin - console.warn('Error loading panel plugin: ' + meta.id, err); - return getPanelPluginLoadError(meta, err); - } +function getPanelPlugin(meta: grafanaData.PanelPluginMeta): Promise { + return importPluginModule(meta.module, meta.info?.version) + .then((pluginExports) => { + if (pluginExports.plugin) { + return pluginExports.plugin as grafanaData.PanelPlugin; + } else if (pluginExports.PanelCtrl) { + const plugin = new grafanaData.PanelPlugin(null); + plugin.angularPanelCtrl = pluginExports.PanelCtrl; + return plugin; + } + throw new Error('missing export: plugin or PanelCtrl'); + }) + .then((plugin) => { + plugin.meta = meta; + return plugin; + }) + .catch((err) => { + // TODO, maybe a different error plugin + console.warn('Error loading panel plugin: ' + meta.id, err); + return getPanelPluginLoadError(meta, err); + }); } From 0ec21a4ed61da5b0f883eaba34a14f915c16a468 Mon Sep 17 00:00:00 2001 From: J Guerreiro Date: Fri, 18 Feb 2022 12:08:00 +0000 Subject: [PATCH 075/104] Service accounts: make is_service_account nullable (#45541) * add base nullable migration to is_service_account Co-authored-by: Jeremy Price * fix postgres migration * ServiceAccounts: ensure SA is set to false when creating a user Co-authored-by: Jeremy Price --- pkg/services/sqlstore/migrations/user_mig.go | 14 +++++++++++ pkg/services/sqlstore/org_test.go | 1 + pkg/services/sqlstore/user.go | 26 +++++++++++--------- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/pkg/services/sqlstore/migrations/user_mig.go b/pkg/services/sqlstore/migrations/user_mig.go index 0dfd3063f48..6c33849621c 100644 --- a/pkg/services/sqlstore/migrations/user_mig.go +++ b/pkg/services/sqlstore/migrations/user_mig.go @@ -127,11 +127,25 @@ func addUserMigrations(mg *Migrator) { Cols: []string{"login", "email"}, })) + //Service accounts are lightweight users with restricted permissions. They support API keys + //and provisioning and tasks like alarms and reports. + // Issues in this migration: is_service_account should be nullable mg.AddMigration("Add is_service_account column to user", NewAddColumnMigration(userV2, &Column{ Name: "is_service_account", Type: DB_Bool, Nullable: false, Default: "0", })) + + mg.AddMigration("Update is_service_account column to nullable", + NewRawSQLMigration(""). + SQLite(migSQLITEisServiceAccountNullable). + Postgres("ALTER TABLE `user` ALTER COLUMN is_service_account DROP NOT NULL;"). + Mysql("ALTER TABLE user MODIFY is_service_account BOOLEAN DEFAULT 0;")) } +const migSQLITEisServiceAccountNullable = `ALTER TABLE user ADD COLUMN tmp_service_account BOOLEAN DEFAULT 0; +UPDATE user SET tmp_service_account = is_service_account; +ALTER TABLE user DROP COLUMN is_service_account; +ALTER TABLE user RENAME COLUMN tmp_service_account TO is_service_account;` + type AddMissingUserSaltAndRandsMigration struct { MigrationBase } diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index 10b46601ecb..6565e1eefc2 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -167,6 +167,7 @@ func TestAccountDataAccess(t *testing.T) { err := SearchUsers(context.Background(), &query) require.NoError(t, err) + require.Len(t, query.Result.Users, 2) require.Equal(t, query.Result.Users[0].Email, "ac1@test.com") require.Equal(t, query.Result.Users[1].Email, "ac2@test.com") }) diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 67a08cdcb02..47da1eafb4a 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -113,17 +113,18 @@ func (ss *SQLStore) createUser(ctx context.Context, sess *DBSession, args userCr // create user user = models.User{ - Email: args.Email, - Name: args.Name, - Login: args.Login, - Company: args.Company, - IsAdmin: args.IsAdmin, - IsDisabled: args.IsDisabled, - OrgId: orgID, - EmailVerified: args.EmailVerified, - Created: time.Now(), - Updated: time.Now(), - LastSeenAt: time.Now().AddDate(-10, 0, 0), + Email: args.Email, + Name: args.Name, + Login: args.Login, + Company: args.Company, + IsAdmin: args.IsAdmin, + IsDisabled: args.IsDisabled, + OrgId: orgID, + EmailVerified: args.EmailVerified, + Created: time.Now(), + Updated: time.Now(), + LastSeenAt: time.Now().AddDate(-10, 0, 0), + IsServiceAccount: false, } salt, err := util.GetRandomString(10) @@ -630,7 +631,8 @@ func SearchUsers(ctx context.Context, query *models.SearchUsersQuery) error { // TODO: add to chore, for cleaning up after we have created // service accounts table in the modelling - whereConditions = append(whereConditions, "u.is_service_account = false") + whereConditions = append(whereConditions, "u.is_service_account = ?") + whereParams = append(whereParams, dialect.BooleanStr(false)) // Join with only most recent auth module joinCondition := `( From a0b29a960b10d59ba5d4fc55c644d73ea4413991 Mon Sep 17 00:00:00 2001 From: "lean.dev" <34773040+leandro-deveikis@users.noreply.github.com> Date: Fri, 18 Feb 2022 09:49:26 -0300 Subject: [PATCH 076/104] Caching: Adding comment about resource caching (#45151) Co-authored-by: Fiona Artiaga <89225282+GrafanaWriter@users.noreply.github.com> --- docs/sources/enterprise/query-caching.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sources/enterprise/query-caching.md b/docs/sources/enterprise/query-caching.md index 02d25a57510..219d2df0409 100644 --- a/docs/sources/enterprise/query-caching.md +++ b/docs/sources/enterprise/query-caching.md @@ -55,11 +55,11 @@ By default, data source queries are not cached. To enable query caching for a si 1. In the data source list, click the data source that you want to turn on caching for. 1. Open the Cache tab. 1. Press the Enable button. -1. (Optional) Choose a custom TTL for that data source. If you skip this step, then Grafana uses the default TTL. +1. (Optional) Choose custom TTLs for the data source's queries and resources caching. If you skip this step, then Grafana uses the default TTL. > **Note:** If query caching is enabled and the Cache tab is not visible in a data source's settings, then query caching is not available for that data source. -To configure global settings for query caching, refer the the [Query caching section of Enterprise Configuration]({{< relref "./enterprise-configuration.md#caching" >}}). +To configure global settings for query caching, refer to the [Query caching section of Enterprise Configuration]({{< relref "./enterprise-configuration.md#caching" >}}). ## Disable query caching From 023aaee929d898a2a22c844e5d4428b24fdfe0bf Mon Sep 17 00:00:00 2001 From: Christopher Moyer <35463610+chri2547@users.noreply.github.com> Date: Fri, 18 Feb 2022 06:52:00 -0600 Subject: [PATCH 077/104] docs: clear cache (#43471) Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> --- docs/sources/enterprise/query-caching.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/sources/enterprise/query-caching.md b/docs/sources/enterprise/query-caching.md index 219d2df0409..3cb34956b8b 100644 --- a/docs/sources/enterprise/query-caching.md +++ b/docs/sources/enterprise/query-caching.md @@ -71,6 +71,22 @@ To disable query caching for a single data source: To disable query caching for an entire Grafana instance, set the `enabled` flag to `false` in the [Query caching section of Enterprise Configuration]({{< relref "./enterprise-configuration.md#caching" >}}). You will no longer see the Cache tab on any data sources, and no data source queries will be cached. +## Clear cache + +If you experience performance issues or repeated queries become slower to execute, consider clearing your cache. + +> **Note:** This action impacts all cache-enabled data sources. If you are using Memcached, the system clears all data from the Memcached instance. + +**To clear the cache**: + +1. Sign in to Grafana and click **Settings > Data Sources**. + +1. Select a data source. + +1. Click the **Cache** tab. + +1. Click **Clear cache**. + ## Sending a request without cache If a data source query request contains an `X-Cache-Skip` header, then Grafana skips the caching middleware, and does not search the cache for a response. This can be particularly useful when debugging data source queries using cURL. From 380e07ba29e41268821c5d83681a3a0050436b79 Mon Sep 17 00:00:00 2001 From: "Grot (@grafanabot)" <43478413+grafanabot@users.noreply.github.com> Date: Fri, 18 Feb 2022 13:32:34 +0000 Subject: [PATCH 078/104] ReleaseNotes: Updated changelog and release notes for 8.4.1 (#45604) * ReleaseNotes: Updated changelog and release notes for 8.4.1 * Add alerting bugfix Co-authored-by: Andrej Ocenas --- CHANGELOG.md | 15 +++++++++++++++ docs/sources/release-notes/_index.md | 1 + .../release-notes/release-notes-8-4-1.md | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 docs/sources/release-notes/release-notes-8-4-1.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dc67628d87..30bd5c155a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ + + +# 8.4.1 (2022-02-18) + +### Features and enhancements + +- **Cloudwatch:** Add support for AWS/PrivateLink\* metrics and dimensions. [#45515](https://github.com/grafana/grafana/pull/45515), [@szymonpk](https://github.com/szymonpk) +- **Configuration:** Add ability to customize okta login button name and icon. [#44079](https://github.com/grafana/grafana/pull/44079), [@DanCech](https://github.com/DanCech) +- **Tempo:** Switch out Select with AsyncSelect component to get loading state in Tempo Search. [#45110](https://github.com/grafana/grafana/pull/45110), [@CatPerry](https://github.com/CatPerry) + +### Bug fixes + +- **Alerting:** Fix migrations by making send_alerts_to field nullable. [#45572](https://github.com/grafana/grafana/pull/45572), [@santihernandezc](https://github.com/santihernandezc) + + # 8.4.0 (2022-02-16) diff --git a/docs/sources/release-notes/_index.md b/docs/sources/release-notes/_index.md index 5b9b46103f7..cb08b39c58e 100644 --- a/docs/sources/release-notes/_index.md +++ b/docs/sources/release-notes/_index.md @@ -8,6 +8,7 @@ weight = 10000 Here you can find detailed release notes that list everything that is included in every release as well as notices about deprecations, breaking changes as well as changes that relate to plugin development. +- [Release notes for 8.4.1]({{< relref "release-notes-8-4-1" >}}) - [Release notes for 8.4.0-beta1]({{< relref "release-notes-8-4-0-beta1" >}}) - [Release notes for 8.3.6]({{< relref "release-notes-8-3-6" >}}) - [Release notes for 8.3.5]({{< relref "release-notes-8-3-5" >}}) diff --git a/docs/sources/release-notes/release-notes-8-4-1.md b/docs/sources/release-notes/release-notes-8-4-1.md new file mode 100644 index 00000000000..6b95dae3adf --- /dev/null +++ b/docs/sources/release-notes/release-notes-8-4-1.md @@ -0,0 +1,18 @@ ++++ +title = "Release notes for Grafana 8.4.1" +hide_menu = true ++++ + + + +# Release notes for Grafana 8.4.1 + +### Features and enhancements + +- **Cloudwatch:** Add support for AWS/PrivateLink\* metrics and dimensions. [#45515](https://github.com/grafana/grafana/pull/45515), [@szymonpk](https://github.com/szymonpk) +- **Configuration:** Add ability to customize okta login button name and icon. [#44079](https://github.com/grafana/grafana/pull/44079), [@DanCech](https://github.com/DanCech) +- **Tempo:** Switch out Select with AsyncSelect component to get loading state in Tempo Search. [#45110](https://github.com/grafana/grafana/pull/45110), [@CatPerry](https://github.com/CatPerry) + +### Bug fixes + +- **Alerting:** Fix migrations by making send_alerts_to field nullable. [#45572](https://github.com/grafana/grafana/pull/45572), [@santihernandezc](https://github.com/santihernandezc) From 5d704fd46e2de238b58538d3c523e313c81de42d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Fri, 18 Feb 2022 14:35:39 +0100 Subject: [PATCH 079/104] Loki: backend: use custom http code (#44643) * Loki: backend: use custom http code * simplfied comment Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> --- pkg/tsdb/loki/api.go | 134 +++++++++++++++++++++++++++++++ pkg/tsdb/loki/framing_test.go | 43 +++++----- pkg/tsdb/loki/loki.go | 57 ++----------- pkg/tsdb/loki/loki_bench_test.go | 3 +- 4 files changed, 164 insertions(+), 73 deletions(-) create mode 100644 pkg/tsdb/loki/api.go diff --git a/pkg/tsdb/loki/api.go b/pkg/tsdb/loki/api.go new file mode 100644 index 00000000000..9168f4e9cd1 --- /dev/null +++ b/pkg/tsdb/loki/api.go @@ -0,0 +1,134 @@ +package loki + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/loki/pkg/loghttp" + jsoniter "github.com/json-iterator/go" +) + +type LokiAPI struct { + client *http.Client + url string + log log.Logger +} + +func newLokiAPI(client *http.Client, url string, log log.Logger) *LokiAPI { + return &LokiAPI{client: client, url: url, log: log} +} + +func makeRequest(ctx context.Context, lokiDsUrl string, query lokiQuery) (*http.Request, error) { + qs := url.Values{} + qs.Set("query", query.Expr) + qs.Set("step", query.Step.String()) + qs.Set("start", strconv.FormatInt(query.Start.UnixNano(), 10)) + qs.Set("end", strconv.FormatInt(query.End.UnixNano(), 10)) + + lokiUrl, err := url.Parse(lokiDsUrl) + if err != nil { + return nil, err + } + + lokiUrl.Path = "/loki/api/v1/query_range" + lokiUrl.RawQuery = qs.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", lokiUrl.String(), nil) + if err != nil { + return nil, err + } + + // NOTE: + // 1. we are missing "dynamic" http params, like OAuth data. + // this never worked before (and it is not needed for alerting scenarios), + // so it is not a regression. + // twe need to have that when we migrate to backend-queries. + // + // 2. we will have to send a custom http header based on the VolumeQuery prop + // (again, not needed for the alerting scenario) + // if query.VolumeQuery { + // req.Header.Set("X-Query-Tags", "Source=logvolhist") + // } + + return req, nil +} + +type lokiError struct { + Message string +} + +// we know there is an error, +// based on the http-response-body +// we have to make an informative error-object +func makeLokiError(body io.ReadCloser) error { + var buf bytes.Buffer + _, err := buf.ReadFrom(body) + if err != nil { + return err + } + + bytes := buf.Bytes() + + // the error-message is probably a JSON structure, + // with a string-field named "message". we want the + // value of that field. + // but, the response might be just a simple string, + // this was used in older Loki versions. + // so our approach is this: + // - we try to convert the bytes to JSON + // - we take the value of the field "message" + // - if any of these steps fail, or if "message" is empty, we return the whole text + + var data lokiError + err = json.Unmarshal(bytes, &data) + if err != nil { + // we were unable to convert the bytes to JSON, we return the whole text + return fmt.Errorf("%v", string(bytes)) + } + + errorMessage := data.Message + + if errorMessage == "" { + // we got no usable error message, we return the whole text + return fmt.Errorf("%v", string(bytes)) + } + + return fmt.Errorf("%v", errorMessage) +} + +func (api *LokiAPI) QueryRange(ctx context.Context, query lokiQuery) (*loghttp.QueryResponse, error) { + req, err := makeRequest(ctx, api.url, query) + if err != nil { + return nil, err + } + + resp, err := api.client.Do(req) + if err != nil { + return nil, err + } + + defer func() { + if err := resp.Body.Close(); err != nil { + api.log.Warn("Failed to close response body", "err", err) + } + }() + + if resp.StatusCode/100 != 2 { + return nil, makeLokiError(resp.Body) + } + + var response loghttp.QueryResponse + err = jsoniter.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return nil, err + } + + return &response, nil +} diff --git a/pkg/tsdb/loki/framing_test.go b/pkg/tsdb/loki/framing_test.go index acad25928f7..a77ae17c54f 100644 --- a/pkg/tsdb/loki/framing_test.go +++ b/pkg/tsdb/loki/framing_test.go @@ -2,6 +2,7 @@ package loki import ( "bytes" + "context" "io/ioutil" "net/http" "os" @@ -11,7 +12,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/experimental" - "github.com/grafana/loki/pkg/logcli/client" + "github.com/grafana/grafana/pkg/infra/log" "github.com/stretchr/testify/require" ) @@ -42,7 +43,7 @@ func TestSuccessResponse(t *testing.T) { bytes, err := os.ReadFile(responseFileName) require.NoError(t, err) - frames, err := runQuery(makeMockedClient(200, "application/json", bytes), &lokiQuery{Expr: "up(ALERTS)", Step: time.Second * 42}) + frames, err := runQuery(context.Background(), makeMockedAPI(200, "application/json", bytes), &lokiQuery{Expr: "up(ALERTS)", Step: time.Second * 42}) require.NoError(t, err) dr := &backend.DataResponse{ @@ -60,16 +61,9 @@ func TestErrorResponse(t *testing.T) { // NOTE: when there is an error-response, it comes with // HTTP code 400, and the format seems to change between versions: // 2.3.x: content-type=text/plain, content is plaintext - // 2.4.0: content-type=application/json, content is plaintext !!! - // 2.4.1: same as 2.4.0 - // 2.4.2: same as 2.4.0 (2.4.2 is currently the latest) + // 2.4.x: content-type=application/json, content is plaintext: https://github.com/grafana/loki/issues/4844 // main-branch: content-type=application/json, content is JSON // we should always be able to to return some kind of error message - // - // also, the returned error message is not what we want to return - // to the user, but this is what is currently returned to the user, - // so the tests check for that. we will have to change this in the future. - tt := []struct { name string body []byte @@ -85,25 +79,31 @@ func TestErrorResponse(t *testing.T) { "message": "parse error at line 1, col 8: something is wrong" }`), contentType: "application/json; charset=utf-8", - errorMessage: "Run out of attempts while querying the server", + errorMessage: "parse error at line 1, col 8: something is wrong", }, { name: "parse a non-json error body with json content type (loki 2.4.0,2.4.1,2.4.2)", body: []byte("parse error at line 1, col 8: something is wrong"), contentType: "application/json; charset=UTF-8", - errorMessage: "Run out of attempts while querying the server", + errorMessage: "parse error at line 1, col 8: something is wrong", }, { name: "parse an error response in plain text", body: []byte("parse error at line 1, col 8: something is wrong"), contentType: "text/plain; charset=utf-8", - errorMessage: "Run out of attempts while querying the server", + errorMessage: "parse error at line 1, col 8: something is wrong", + }, + { + name: "parse an error response that is broken JSON", + body: []byte(`{"message":"error message but the JSON is not finished`), + contentType: "text/plain; charset=utf-8", + errorMessage: `{"message":"error message but the JSON is not finished`, }, } for _, test := range tt { t.Run(test.name, func(t *testing.T) { - frames, err := runQuery(makeMockedClient(400, test.contentType, test.body), &lokiQuery{}) + frames, err := runQuery(context.Background(), makeMockedAPI(400, test.contentType, test.body), &lokiQuery{}) require.Len(t, frames, 0) require.Error(t, err) @@ -112,13 +112,13 @@ func TestErrorResponse(t *testing.T) { } } -type MockedRoundTripper struct { +type mockedRoundTripper struct { statusCode int responseBytes []byte contentType string } -func (mockedRT *MockedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { +func (mockedRT *mockedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { header := http.Header{} header.Add("Content-Type", mockedRT.contentType) return &http.Response{ @@ -128,13 +128,10 @@ func (mockedRT *MockedRoundTripper) RoundTrip(req *http.Request) (*http.Response }, nil } -func makeMockedClient(statusCode int, contentType string, responseBytes []byte) *client.DefaultClient { - client := &client.DefaultClient{ - Address: "http://localhost:9999", - Tripperware: func(t http.RoundTripper) http.RoundTripper { - return &MockedRoundTripper{statusCode: statusCode, responseBytes: responseBytes, contentType: contentType} - }, +func makeMockedAPI(statusCode int, contentType string, responseBytes []byte) *LokiAPI { + client := http.Client{ + Transport: &mockedRoundTripper{statusCode: statusCode, contentType: contentType, responseBytes: responseBytes}, } - return client + return newLokiAPI(&client, "http://localhost:9999", log.New("test")) } diff --git a/pkg/tsdb/loki/loki.go b/pkg/tsdb/loki/loki.go index feb95716f52..69db994b5d4 100644 --- a/pkg/tsdb/loki/loki.go +++ b/pkg/tsdb/loki/loki.go @@ -2,8 +2,6 @@ package loki import ( "context" - "crypto/tls" - "encoding/json" "fmt" "net/http" "regexp" @@ -17,12 +15,9 @@ import ( "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/loki/pkg/logcli/client" "github.com/grafana/loki/pkg/loghttp" - "github.com/grafana/loki/pkg/logproto" "go.opentelemetry.io/otel/attribute" - "github.com/prometheus/common/config" "github.com/prometheus/common/model" ) @@ -45,12 +40,8 @@ var ( ) type datasourceInfo struct { - HTTPClient *http.Client - URL string - TLSClientConfig *tls.Config - BasicAuthUser string - BasicAuthPassword string - TimeInterval string `json:"timeInterval"` + HTTPClient *http.Client + URL string } type QueryModel struct { @@ -74,24 +65,9 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst return nil, err } - tlsClientConfig, err := httpClientProvider.GetTLSConfig(opts) - if err != nil { - return nil, err - } - - jsonData := datasourceInfo{} - err = json.Unmarshal(settings.JSONData, &jsonData) - if err != nil { - return nil, fmt.Errorf("error reading settings: %w", err) - } - model := &datasourceInfo{ - HTTPClient: client, - URL: settings.URL, - TLSClientConfig: tlsClientConfig, - TimeInterval: jsonData.TimeInterval, - BasicAuthUser: settings.BasicAuthUser, - BasicAuthPassword: settings.DecryptedSecureJSONData["basicAuthPassword"], + HTTPClient: client, + URL: settings.URL, } return model, nil } @@ -105,17 +81,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return result, err } - client := &client.DefaultClient{ - Address: dsInfo.URL, - Username: dsInfo.BasicAuthUser, - Password: dsInfo.BasicAuthPassword, - TLSConfig: config.TLSConfig{ - InsecureSkipVerify: dsInfo.TLSClientConfig.InsecureSkipVerify, - }, - Tripperware: func(t http.RoundTripper) http.RoundTripper { - return dsInfo.HTTPClient.Transport - }, - } + api := newLokiAPI(dsInfo.HTTPClient, dsInfo.URL, s.plog) queries, err := parseQuery(req) if err != nil { @@ -130,7 +96,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) span.SetAttributes("stop_unixnano", query.End, attribute.Key("stop_unixnano").Int64(query.End.UnixNano())) defer span.End() - frames, err := runQuery(client, query) + frames, err := runQuery(ctx, api, query) queryRes := backend.DataResponse{} @@ -204,15 +170,8 @@ func parseResponse(value *loghttp.QueryResponse, query *lokiQuery) (data.Frames, } // we extracted this part of the functionality to make it easy to unit-test it -func runQuery(client *client.DefaultClient, query *lokiQuery) (data.Frames, error) { - // `limit` only applies to log-producing queries, and we - // currently only support metric queries, so this can be set to any value. - limit := 1 - - // we do not use `interval`, so we set it to zero - interval := time.Duration(0) - - value, err := client.QueryRange(query.Expr, limit, query.Start, query.End, logproto.BACKWARD, query.Step, interval, false) +func runQuery(ctx context.Context, api *LokiAPI, query *lokiQuery) (data.Frames, error) { + value, err := api.QueryRange(ctx, *query) if err != nil { return data.Frames{}, err } diff --git a/pkg/tsdb/loki/loki_bench_test.go b/pkg/tsdb/loki/loki_bench_test.go index fec2eefc29e..331b8b533f1 100644 --- a/pkg/tsdb/loki/loki_bench_test.go +++ b/pkg/tsdb/loki/loki_bench_test.go @@ -1,6 +1,7 @@ package loki import ( + "context" "fmt" "math/rand" "strings" @@ -15,7 +16,7 @@ func BenchmarkMatrixJson(b *testing.B) { b.ResetTimer() for n := 0; n < b.N; n++ { - _, _ = runQuery(makeMockedClient(200, "application/json", bytes), &lokiQuery{}) + _, _ = runQuery(context.Background(), makeMockedAPI(200, "application/json", bytes), &lokiQuery{}) } } From 8e6626664f5b6a2e28b748a3799e837baf5c441c Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Fri, 18 Feb 2022 15:12:59 +0100 Subject: [PATCH 080/104] Update version (#45608) --- latest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/latest.json b/latest.json index 537d81c0610..2351b122bce 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "8.4.0", - "testing": "8.4.0" + "stable": "8.4.1", + "testing": "8.4.1" } From 25f155a44d29c933654631609be014b96e9241ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Fri, 18 Feb 2022 15:34:19 +0100 Subject: [PATCH 081/104] Explore: Fix closing split pane when logs panel is used (#45602) * Clear cache when LogsNavigation is mounted * Update docs --- public/app/features/explore/LogsNavigation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/features/explore/LogsNavigation.tsx b/public/app/features/explore/LogsNavigation.tsx index 29007e21b2a..73dd0a540d9 100644 --- a/public/app/features/explore/LogsNavigation.tsx +++ b/public/app/features/explore/LogsNavigation.tsx @@ -81,8 +81,8 @@ function LogsNavigation({ }, [visibleRange, absoluteRange, logsSortOrder, queries, clearCache, addResultsToCache]); useEffect(() => { - return () => clearCache(); - // We can't enforce the eslint rule here because we only want to run when component unmounts. + clearCache(); + // We can't enforce the eslint rule here because we only want to run when component is mounted. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); From e44ea3d589ca4b24f3e613b9b7a0c808acc7c1ab Mon Sep 17 00:00:00 2001 From: Yuriy Tseretyan Date: Fri, 18 Feb 2022 10:05:06 -0500 Subject: [PATCH 082/104] Alerting: Rename setting AlertForDuration to DefaultRuleEvaluationInterval (#45569) * fix AlertForDuration to DefaultRuleEvaluationInterval Co-authored-by: Alexander Weaver --- pkg/services/ngalert/ngalert.go | 2 +- pkg/setting/setting_unified_alerting.go | 14 +++++++------- pkg/setting/setting_unified_alerting_test.go | 16 +++++++++------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 346a6e5f055..9dd8be60457 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -84,7 +84,7 @@ func (ng *AlertNG) init() error { store := &store.DBstore{ BaseInterval: ng.Cfg.UnifiedAlerting.BaseInterval, - DefaultInterval: ng.Cfg.UnifiedAlerting.DefaultAlertForDuration, + DefaultInterval: ng.Cfg.UnifiedAlerting.DefaultRuleEvaluationInterval, SQLStore: ng.SQLStore, Logger: ng.Log, FolderService: ng.folderService, diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index ea385e2b50e..2873ea422d4 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -52,8 +52,8 @@ const ( // changing this value is discouraged because this could cause existing alert definition // with intervals that are not exactly divided by this number not to be evaluated SchedulerBaseInterval = 10 * time.Second - // DefaultAlertForDuration indicates a default interval of for how long a rule should be evaluated to change state from Pending to Alerting - DefaultAlertForDuration = 60 * time.Second + // DefaultRuleEvaluationInterval indicates a default interval of for how long a rule should be evaluated to change state from Pending to Alerting + DefaultRuleEvaluationInterval = SchedulerBaseInterval * 6 // == 60 seconds ) type UnifiedAlertingSettings struct { @@ -75,8 +75,8 @@ type UnifiedAlertingSettings struct { // BaseInterval interval of time the scheduler updates the rules and evaluates rules. // Only for internal use and not user configuration. BaseInterval time.Duration - // DefaultAlertForDuration default time for how long an alert rule should be evaluated before change state. - DefaultAlertForDuration time.Duration + // DefaultRuleEvaluationInterval default interval between evaluations of a rule. + DefaultRuleEvaluationInterval time.Duration } // IsEnabled returns true if UnifiedAlertingSettings.Enabled is either nil or true. @@ -239,9 +239,9 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { } uaCfg.MinInterval = uaMinInterval - uaCfg.DefaultAlertForDuration = DefaultAlertForDuration - if uaMinInterval > uaCfg.DefaultAlertForDuration { - uaCfg.DefaultAlertForDuration = uaMinInterval + uaCfg.DefaultRuleEvaluationInterval = DefaultRuleEvaluationInterval + if uaMinInterval > uaCfg.DefaultRuleEvaluationInterval { + uaCfg.DefaultRuleEvaluationInterval = uaMinInterval } cfg.UnifiedAlerting = uaCfg diff --git a/pkg/setting/setting_unified_alerting_test.go b/pkg/setting/setting_unified_alerting_test.go index 69d92056112..5968688483c 100644 --- a/pkg/setting/setting_unified_alerting_test.go +++ b/pkg/setting/setting_unified_alerting_test.go @@ -70,7 +70,7 @@ func TestUnifiedAlertingSettings(t *testing.T) { require.Equal(t, false, cfg.UnifiedAlerting.ExecuteAlerts) require.Equal(t, 90*time.Second, cfg.UnifiedAlerting.EvaluationTimeout) require.Equal(t, SchedulerBaseInterval, cfg.UnifiedAlerting.BaseInterval) - require.Equal(t, DefaultAlertForDuration, cfg.UnifiedAlerting.DefaultAlertForDuration) + require.Equal(t, DefaultRuleEvaluationInterval, cfg.UnifiedAlerting.DefaultRuleEvaluationInterval) }, }, { @@ -95,7 +95,7 @@ func TestUnifiedAlertingSettings(t *testing.T) { require.Equal(t, true, cfg.UnifiedAlerting.ExecuteAlerts) require.Equal(t, 160*time.Second, cfg.UnifiedAlerting.EvaluationTimeout) require.Equal(t, SchedulerBaseInterval, cfg.UnifiedAlerting.BaseInterval) - require.Equal(t, 120*time.Second, cfg.UnifiedAlerting.DefaultAlertForDuration) + require.Equal(t, 120*time.Second, cfg.UnifiedAlerting.DefaultRuleEvaluationInterval) }, }, { @@ -119,7 +119,7 @@ func TestUnifiedAlertingSettings(t *testing.T) { require.Equal(t, schedulereDefaultExecuteAlerts, cfg.UnifiedAlerting.ExecuteAlerts) require.Equal(t, evaluatorDefaultEvaluationTimeout, cfg.UnifiedAlerting.EvaluationTimeout) require.Equal(t, SchedulerBaseInterval, cfg.UnifiedAlerting.BaseInterval) - require.Equal(t, DefaultAlertForDuration, cfg.UnifiedAlerting.DefaultAlertForDuration) + require.Equal(t, DefaultRuleEvaluationInterval, cfg.UnifiedAlerting.DefaultRuleEvaluationInterval) }, }, { @@ -143,7 +143,7 @@ func TestUnifiedAlertingSettings(t *testing.T) { require.Equal(t, false, cfg.UnifiedAlerting.ExecuteAlerts) require.Equal(t, 160*time.Second, cfg.UnifiedAlerting.EvaluationTimeout) require.Equal(t, SchedulerBaseInterval, cfg.UnifiedAlerting.BaseInterval) - require.Equal(t, 120*time.Second, cfg.UnifiedAlerting.DefaultAlertForDuration) + require.Equal(t, 120*time.Second, cfg.UnifiedAlerting.DefaultRuleEvaluationInterval) }, }, } @@ -227,10 +227,12 @@ func TestMinInterval(t *testing.T) { }, }, { - desc: "should adjust DefaultAlertForDuration to min interval if it is greater", - minInterval: randPredicate(func(dur time.Duration) bool { return dur%SchedulerBaseInterval == 0 && dur > DefaultAlertForDuration }), + desc: "should adjust DefaultRuleEvaluationInterval to min interval if it is greater", + minInterval: randPredicate(func(dur time.Duration) bool { + return dur%SchedulerBaseInterval == 0 && dur > DefaultRuleEvaluationInterval + }), verifyCfg: func(t *testing.T, cfg *Cfg, err error) { - require.Equal(t, cfg.UnifiedAlerting.MinInterval, cfg.UnifiedAlerting.DefaultAlertForDuration) + require.Equal(t, cfg.UnifiedAlerting.MinInterval, cfg.UnifiedAlerting.DefaultRuleEvaluationInterval) }, }, { From 1e21184f65c76aa37ef6288b71f9d37ada6029fc Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Fri, 18 Feb 2022 16:06:37 +0100 Subject: [PATCH 083/104] Fix: JSON parsing in backend_srv (#45598) * fix(plugins/state): console log errors during install / uninstall * fix(backend_srv): catch JSON parse errors Sometimes it can happen that a backend API responses says that it's returning with a JSON content-type, however it actually returns an invalid JSON (e.g. an empty body) - in which case the backendSrv() request errors out. --- public/app/core/utils/fetch.ts | 7 ++++++- public/app/features/plugins/admin/state/actions.ts | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/public/app/core/utils/fetch.ts b/public/app/core/utils/fetch.ts index 284a5848309..fb77a6d1a7d 100644 --- a/public/app/core/utils/fetch.ts +++ b/public/app/core/utils/fetch.ts @@ -105,7 +105,12 @@ export async function parseResponseBody( return response.blob() as any; case 'json': - return response.json(); + try { + return await response.json(); + } catch (err) { + console.warn(`${response.url} returned an invalid JSON -`, err); + return {} as unknown as T; + } case 'text': return response.text() as any; diff --git a/public/app/features/plugins/admin/state/actions.ts b/public/app/features/plugins/admin/state/actions.ts index 68a47c7ebad..e23bf4b0b0e 100644 --- a/public/app/features/plugins/admin/state/actions.ts +++ b/public/app/features/plugins/admin/state/actions.ts @@ -73,6 +73,8 @@ export const install = createAsyncThunk( return { id, changes } as Update; } catch (e) { + console.error(e); + return thunkApi.rejectWithValue('Unknown error.'); } } @@ -90,6 +92,8 @@ export const uninstall = createAsyncThunk(`${STATE_PREFIX}/uninstall`, async (id changes: { isInstalled: false, installedVersion: undefined }, } as Update; } catch (e) { + console.error(e); + return thunkApi.rejectWithValue('Unknown error.'); } }); From a5dc832a86e925dc3dc4436d34daa8ed943d7293 Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Fri, 18 Feb 2022 17:16:05 +0200 Subject: [PATCH 084/104] Update grabpl (#45614) --- .drone.yml | 66 ++++++++++++++++++------------------ scripts/drone/steps/lib.star | 2 +- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.drone.yml b/.drone.yml index 6db90a719d2..ca192e475d0 100644 --- a/.drone.yml +++ b/.drone.yml @@ -11,7 +11,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -111,7 +111,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -362,7 +362,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -423,7 +423,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -524,7 +524,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -909,7 +909,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -977,7 +977,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -1060,7 +1060,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1142,7 +1142,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1446,7 +1446,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1567,7 +1567,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1651,7 +1651,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -1710,7 +1710,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2050,7 +2050,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2223,7 +2223,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2351,7 +2351,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -2426,7 +2426,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2504,7 +2504,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2565,7 +2565,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2644,7 +2644,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2706,7 +2706,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2742,7 +2742,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2789,7 +2789,7 @@ steps: name: initialize - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2837,7 +2837,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2900,7 +2900,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3159,7 +3159,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3273,7 +3273,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3350,7 +3350,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: initialize @@ -3398,7 +3398,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3731,7 +3731,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3894,7 +3894,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4012,7 +4012,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.2/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -4209,6 +4209,6 @@ kind: secret name: gcp_upload_artifacts_key --- kind: signature -hmac: 8fbbae5ad04e33e1a8d32d6e9dc1b1a2264aa31951a2d3af9d4f9da7ad5d2886 +hmac: de49a9ed2dffc5131de65688dc0175fc07029d507d654df360f3e683a138b43c ... diff --git a/scripts/drone/steps/lib.star b/scripts/drone/steps/lib.star index 77d5ff46d4b..38b2bf03371 100644 --- a/scripts/drone/steps/lib.star +++ b/scripts/drone/steps/lib.star @@ -1,6 +1,6 @@ load('scripts/drone/vault.star', 'from_secret', 'github_token', 'pull_secret', 'drone_token', 'prerelease_bucket') -grabpl_version = 'v2.9.2' +grabpl_version = 'v2.9.3' build_image = 'grafana/build-container:1.4.9' publish_image = 'grafana/grafana-ci-deploy:1.3.1' deploy_docker_image = 'us.gcr.io/kubernetes-dev/drone/plugins/deploy-image' From b2e6ca9393c3666956b7808f94d20a6dcc3586e0 Mon Sep 17 00:00:00 2001 From: "lean.dev" <34773040+leandro-deveikis@users.noreply.github.com> Date: Fri, 18 Feb 2022 13:08:52 -0300 Subject: [PATCH 085/104] Deleting empty lines at list (#45610) --- docs/sources/enterprise/query-caching.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/sources/enterprise/query-caching.md b/docs/sources/enterprise/query-caching.md index 3cb34956b8b..b58e5c351a8 100644 --- a/docs/sources/enterprise/query-caching.md +++ b/docs/sources/enterprise/query-caching.md @@ -80,11 +80,8 @@ If you experience performance issues or repeated queries become slower to execut **To clear the cache**: 1. Sign in to Grafana and click **Settings > Data Sources**. - 1. Select a data source. - 1. Click the **Cache** tab. - 1. Click **Clear cache**. ## Sending a request without cache From 175891a5e39be5e21480296a869c7c4f6e3645ff Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 18 Feb 2022 17:13:31 +0100 Subject: [PATCH 086/104] Update dependency react-hook-form to v7.26.1 (#43670) * Update dependency react-hook-form to v7.27.0 * fix type errors for alerting components with new react-hook-form version (#45341) Co-authored-by: Renovate Bot Co-authored-by: Nathan Rodman --- package.json | 2 +- packages/grafana-ui/package.json | 2 +- .../components/receivers/form/ReceiverForm.tsx | 8 ++++---- .../components/rule-editor/AlertTypeStep.tsx | 6 ++++-- yarn.lock | 13 +++++++++++-- 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index e3620684890..e9de55d74b1 100644 --- a/package.json +++ b/package.json @@ -334,7 +334,7 @@ "react-draggable": "4.4.4", "react-grid-layout": "1.3.3", "react-highlight-words": "0.17.0", - "react-hook-form": "7.5.3", + "react-hook-form": "7.27.0", "react-inlinesvg": "2.3.0", "react-loadable": "5.5.0", "react-moveable": "0.30.3", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 149fac977ac..768b671ed45 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -74,7 +74,7 @@ "react-dom": "17.0.2", "react-dropzone": "11.5.1", "react-highlight-words": "0.17.0", - "react-hook-form": "7.5.3", + "react-hook-form": "7.27.0", "react-inlinesvg": "2.3.0", "react-popper": "2.2.5", "react-popper-tooltip": "^4.3.1", diff --git a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx index 516986bb8ee..ccc021b313b 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx @@ -5,7 +5,7 @@ import { useCleanup } from 'app/core/hooks/useCleanup'; import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; import { NotifierDTO } from 'app/types'; import React, { useCallback } from 'react'; -import { useForm, FormProvider, FieldErrors, Validate } from 'react-hook-form'; +import { useForm, FormProvider, FieldErrors, Validate, SubmitHandler } from 'react-hook-form'; import { useControlledFieldArray } from '../../../hooks/useControlledFieldArray'; import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector'; import { ChannelValues, CommonSettingsComponentType, ReceiverFormValues } from '../../../types/receiver-form'; @@ -94,7 +94,7 @@ export function ReceiverForm({ Because there is no default policy configured yet, this contact point will automatically be set as default. )} -
+ >, onInvalid)}>

{readOnly ? 'Contact point' : initialValues ? 'Update contact point' : 'Create contact point'}

@@ -121,13 +121,13 @@ export function ReceiverForm({ defaultValues={field} key={field.__id} onDuplicate={() => { - const currentValues: R = getValues().items[index]; + const currentValues = getValues().items[index] as R; append({ ...currentValues, __id: String(Math.random()) }); }} onTest={ onTestChannel ? () => { - const currentValues: R = getValues().items[index]; + const currentValues = getValues().items[index] as R; onTestChannel(currentValues); } : undefined diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx index 4a96553d037..54daf57fa7c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx @@ -10,6 +10,7 @@ import { GroupAndNamespaceFields } from './GroupAndNamespaceFields'; import { contextSrv } from 'app/core/services/context_srv'; import { CloudRulesSourcePicker } from './CloudRulesSourcePicker'; import { checkForPathSeparator } from './util'; +import { get } from 'lodash'; interface Props { editingExistingRule: boolean; @@ -148,8 +149,9 @@ export const AlertTypeStep: FC = ({ editingExistingRule }) => { Date: Fri, 18 Feb 2022 17:14:14 +0100 Subject: [PATCH 087/104] cloudwatch: use monaco from grafana-ui (#45597) --- .../metric-math/completion/CompletionItemProvider.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/public/app/plugins/datasource/cloudwatch/metric-math/completion/CompletionItemProvider.test.ts b/public/app/plugins/datasource/cloudwatch/metric-math/completion/CompletionItemProvider.test.ts index 1f54866cb64..9495b07c24d 100644 --- a/public/app/plugins/datasource/cloudwatch/metric-math/completion/CompletionItemProvider.test.ts +++ b/public/app/plugins/datasource/cloudwatch/metric-math/completion/CompletionItemProvider.test.ts @@ -5,7 +5,6 @@ import { getTemplateSrv } from '@grafana/runtime'; import { CloudWatchDatasource } from '../../datasource'; import cloudWatchMetricMathLanguageDefinition from '../definition'; import { Monaco, monacoTypes } from '@grafana/ui'; -import { IPosition } from 'monaco-editor'; import { METRIC_MATH_FNS, METRIC_MATH_KEYWORDS, @@ -15,7 +14,7 @@ import { } from '../language'; import * as MetricMathTestData from '../../__mocks__/metric-math-test-data'; -const getSuggestions = async (value: string, position: IPosition) => { +const getSuggestions = async (value: string, position: monacoTypes.IPosition) => { const setup = new MetricMathCompletionItemProvider( { getVariables: () => [], From 7643ae6c5ef6ca143a0e049ebe3c1d60cbdb0f5a Mon Sep 17 00:00:00 2001 From: Artur Wierzbicki Date: Fri, 18 Feb 2022 20:25:01 +0400 Subject: [PATCH 088/104] Rendering: periodically refresh remote plugin version (#45505) * #44449: refresh image renderer version on a schedule * #44449: simplify --- pkg/services/rendering/http_mode.go | 28 ++++++++++++++++++++++++++-- pkg/services/rendering/rendering.go | 15 +++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/pkg/services/rendering/http_mode.go b/pkg/services/rendering/http_mode.go index 3617b3296bd..e8820b9e1f9 100644 --- a/pkg/services/rendering/http_mode.go +++ b/pkg/services/rendering/http_mode.go @@ -29,8 +29,9 @@ var netClient = &http.Client{ } var ( - remoteVersionFetchInterval time.Duration = time.Second * 15 - remoteVersionFetchRetries uint = 4 + remoteVersionFetchInterval time.Duration = time.Second * 15 + remoteVersionFetchRetries uint = 4 + remoteVersionRefreshInterval = time.Minute * 15 ) func (rs *RenderingService) renderViaHTTP(ctx context.Context, renderKey string, opts Opts) (*RenderResult, error) { @@ -251,3 +252,26 @@ func (rs *RenderingService) getRemotePluginVersion() (string, error) { } return info.Version, nil } + +func (rs *RenderingService) refreshRemotePluginVersion() { + newVersion, err := rs.getRemotePluginVersion() + if err != nil { + rs.log.Info("Failed to refresh remote plugin version", "err", err) + return + } + + if newVersion == "" { + // the image-renderer could have been temporary unavailable - skip updating the version + rs.log.Debug("Received empty version when trying to refresh remote plugin version") + return + } + + currentVersion := rs.Version() + if currentVersion != newVersion { + rs.versionMutex.Lock() + defer rs.versionMutex.Unlock() + + rs.log.Info("Updating remote plugin version", "currentVersion", currentVersion, "newVersion", newVersion) + rs.version = newVersion + } +} diff --git a/pkg/services/rendering/rendering.go b/pkg/services/rendering/rendering.go index d62a4447ed1..2de807cb5a4 100644 --- a/pkg/services/rendering/rendering.go +++ b/pkg/services/rendering/rendering.go @@ -120,8 +120,19 @@ func (rs *RenderingService) Run(ctx context.Context) error { }) rs.renderAction = rs.renderViaHTTP rs.renderCSVAction = rs.renderCSVViaHTTP - <-ctx.Done() - return nil + + refreshTicker := time.NewTicker(remoteVersionRefreshInterval) + + for { + select { + case <-refreshTicker.C: + go rs.refreshRemotePluginVersion() + case <-ctx.Done(): + rs.log.Debug("Grafana is shutting down - stopping image-renderer version refresh") + refreshTicker.Stop() + return nil + } + } } if rs.pluginAvailable() { From c04b0179ff7d3cf6e8b33f4eb27365f9a8ecf4a6 Mon Sep 17 00:00:00 2001 From: Artur Wierzbicki Date: Fri, 18 Feb 2022 21:29:18 +0400 Subject: [PATCH 089/104] Previews: prevent scheduler from running when image renderer is outdated (#45623) * #44449: prevent crawler from running when rendering service is outdated * #44449: simplify --- pkg/services/thumbs/crawler.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/services/thumbs/crawler.go b/pkg/services/thumbs/crawler.go index d9ef0118eba..a17b48c4ae0 100644 --- a/pkg/services/thumbs/crawler.go +++ b/pkg/services/thumbs/crawler.go @@ -3,6 +3,7 @@ package thumbs import ( "context" "encoding/json" + "fmt" "os" "strings" "sync" @@ -83,6 +84,16 @@ func (r *simpleCrawler) broadcastStatus() { } func (r *simpleCrawler) Run(ctx context.Context, authOpts rendering.AuthOpts, mode CrawlerMode, theme models.Theme, thumbnailKind models.ThumbnailKind) error { + res, err := r.renderService.HasCapability(rendering.ScalingDownImages) + if err != nil { + return err + } + + if !res.IsSupported { + return fmt.Errorf("cant run dashboard crawler - rendering service needs to be updated. "+ + "current version: %s, requiredVersion: %s", r.renderService.Version(), res.SemverConstraint) + } + r.queueMutex.Lock() if r.IsRunning() { r.queueMutex.Unlock() From 955b921c887660a3f9a9dff825e675f447c03b0d Mon Sep 17 00:00:00 2001 From: Joey Tawadrous <90795735+joey-grafana@users.noreply.github.com> Date: Fri, 18 Feb 2022 17:37:45 +0000 Subject: [PATCH 090/104] InfluxDB: new row value types (#44789) * Check type and act accordingly * Add string type * Add bool type * Change method name in test * Remove comment * Changed test var names to represent the float case * Added string test case * Added bool test case * Update response in test * Change string val * Fix frame meta missing in tests * Fixed test parse query * Fixed out of bounds test * parseFloatSeries * parseStringSeries and parseBoolSeries * Formatting * Use multi frames for now * strings.ToLower for time col check * Move timeField out of if checks --- pkg/tsdb/influxdb/response_parser.go | 58 ++++++++++-------- pkg/tsdb/influxdb/response_parser_test.go | 71 +++++++++++++++++------ 2 files changed, 89 insertions(+), 40 deletions(-) diff --git a/pkg/tsdb/influxdb/response_parser.go b/pkg/tsdb/influxdb/response_parser.go index 52bf876fd38..19013a315c6 100644 --- a/pkg/tsdb/influxdb/response_parser.go +++ b/pkg/tsdb/influxdb/response_parser.go @@ -59,7 +59,7 @@ func transformRows(rows []Row, query Query) data.Frames { var hasTimeCol = false for _, column := range row.Columns { - if column == "time" { + if strings.ToLower(column) == "time" { hasTimeCol = true } } @@ -82,32 +82,50 @@ func transformRows(rows []Row, query Query) data.Frames { field := data.NewField("value", nil, values) frames = append(frames, data.NewFrame(row.Name, field)) } else { - for columnIndex, column := range row.Columns { + for colIndex, column := range row.Columns { if column == "time" { continue } var timeArray []time.Time - var valueArray []*float64 + var floatArray []*float64 + var stringArray []string + var boolArray []bool + valType := typeof(row.Values[0][colIndex]) + name := formatFrameName(row, column, query) for _, valuePair := range row.Values { timestamp, timestampErr := parseTimestamp(valuePair[0]) // we only add this row if the timestamp is valid if timestampErr == nil { - value := parseValue(valuePair[columnIndex]) timeArray = append(timeArray, timestamp) - valueArray = append(valueArray, value) + if valType == "string" { + value := valuePair[colIndex].(string) + stringArray = append(stringArray, value) + } else if valType == "json.Number" { + value := parseNumber(valuePair[colIndex]) + floatArray = append(floatArray, value) + } else if valType == "bool" { + value := valuePair[colIndex].(bool) + boolArray = append(boolArray, value) + } } } - name := formatFrameName(row, column, query) timeField := data.NewField("time", nil, timeArray) - valueField := data.NewField("value", row.Tags, valueArray) - - // set a nice name on the value-field - valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: name}) - - frames = append(frames, newDataFrame(name, query.RawQuery, timeField, valueField)) + if valType == "string" { + valueField := data.NewField("value", row.Tags, stringArray) + valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: name}) + frames = append(frames, newDataFrame(name, query.RawQuery, timeField, valueField)) + } else if valType == "json.Number" { + valueField := data.NewField("value", row.Tags, floatArray) + valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: name}) + frames = append(frames, newDataFrame(name, query.RawQuery, timeField, valueField)) + } else if valType == "bool" { + valueField := data.NewField("value", row.Tags, boolArray) + valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: name}) + frames = append(frames, newDataFrame(name, query.RawQuery, timeField, valueField)) + } } } } @@ -195,21 +213,15 @@ func parseTimestamp(value interface{}) (time.Time, error) { return t, nil } -func parseValue(value interface{}) *float64 { +func typeof(v interface{}) string { + return fmt.Sprintf("%T", v) +} + +func parseNumber(value interface{}) *float64 { // NOTE: we use pointers-to-float64 because we need // to represent null-json-values. they come for example // when we do a group-by with fill(null) - // FIXME: the value of an influxdb-query can be: - // - string - // - float - // - integer - // - boolean - // - // here we only handle numeric values. this is probably - // enough for alerting, but later if we want to support - // arbitrary queries, we will have to improve the code - if value == nil { // this is what json-nulls become return nil diff --git a/pkg/tsdb/influxdb/response_parser_test.go b/pkg/tsdb/influxdb/response_parser_test.go index 56599b2bfd6..c96aba76f08 100644 --- a/pkg/tsdb/influxdb/response_parser_test.go +++ b/pkg/tsdb/influxdb/response_parser_test.go @@ -51,12 +51,12 @@ func TestInfluxdbResponseParser(t *testing.T) { "series": [ { "name": "cpu", - "columns": ["time","mean","sum"], + "columns": ["time","mean","path","isActive"], "tags": {"datacenter": "America"}, "values": [ - [111,222,333], - [111,222,333], - [111,null,333] + [111,222,"/usr/path",true], + [111,222,"/usr/path",false], + [111,null,"/usr/path",true] ] } ] @@ -68,25 +68,62 @@ func TestInfluxdbResponseParser(t *testing.T) { query := &Query{} labels, err := data.LabelsFromString("datacenter=America") require.Nil(t, err) - newField := data.NewField("value", labels, []*float64{ + + floatField := data.NewField("value", labels, []*float64{ pointer.Float64(222), pointer.Float64(222), nil, }) - newField.Config = &data.FieldConfig{DisplayNameFromDS: "cpu.mean { datacenter: America }"} - testFrame := data.NewFrame("cpu.mean { datacenter: America }", + floatField.Config = &data.FieldConfig{DisplayNameFromDS: "cpu.mean { datacenter: America }"} + floatFrame := data.NewFrame("cpu.mean { datacenter: America }", data.NewField("time", nil, []time.Time{ time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), }), - newField, + floatField, ) - testFrame.Meta = &data.FrameMeta{ExecutedQueryString: "Test raw query"} + floatFrame.Meta = &data.FrameMeta{ExecutedQueryString: "Test raw query"} + + stringField := data.NewField("value", labels, []string{ + "/usr/path", "/usr/path", "/usr/path", + }) + stringField.Config = &data.FieldConfig{DisplayNameFromDS: "cpu.path { datacenter: America }"} + stringFrame := data.NewFrame("cpu.path { datacenter: America }", + data.NewField("time", nil, + []time.Time{ + time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), + time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), + time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), + }), + stringField, + ) + stringFrame.Meta = &data.FrameMeta{ExecutedQueryString: "Test raw query"} + + boolField := data.NewField("value", labels, []bool{ + true, false, true, + }) + boolField.Config = &data.FieldConfig{DisplayNameFromDS: "cpu.isActive { datacenter: America }"} + boolFrame := data.NewFrame("cpu.isActive { datacenter: America }", + data.NewField("time", nil, + []time.Time{ + time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), + time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), + time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC), + }), + boolField, + ) + boolFrame.Meta = &data.FrameMeta{ExecutedQueryString: "Test raw query"} result := parser.Parse(prepare(response), addQueryToQueries(*query)) frame := result.Responses["A"] - if diff := cmp.Diff(testFrame, frame.Frames[0], data.FrameTestCompareOptions()...); diff != "" { + if diff := cmp.Diff(floatFrame, frame.Frames[0], data.FrameTestCompareOptions()...); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(stringFrame, frame.Frames[1], data.FrameTestCompareOptions()...); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(boolFrame, frame.Frames[2], data.FrameTestCompareOptions()...); diff != "" { t.Errorf("Result mismatch (-want +got):\n%s", diff) } }) @@ -654,18 +691,18 @@ func TestInfluxdbResponseParser(t *testing.T) { require.EqualError(t, result.Responses["A"].Error, "error parsing query: found THING") }) - t.Run("Influxdb response parser parseValue nil", func(t *testing.T) { - value := parseValue(nil) + t.Run("Influxdb response parser parseNumber nil", func(t *testing.T) { + value := parseNumber(nil) require.Nil(t, value) }) - t.Run("Influxdb response parser parseValue valid JSON.number", func(t *testing.T) { - value := parseValue(json.Number("95.4")) + t.Run("Influxdb response parser parseNumber valid JSON.number", func(t *testing.T) { + value := parseNumber(json.Number("95.4")) require.Equal(t, *value, 95.4) }) - t.Run("Influxdb response parser parseValue invalid type", func(t *testing.T) { - value := parseValue("95.4") + t.Run("Influxdb response parser parseNumber invalid type", func(t *testing.T) { + value := parseNumber("95.4") require.Nil(t, value) }) @@ -677,7 +714,7 @@ func TestInfluxdbResponseParser(t *testing.T) { require.Equal(t, timestamp.Format(time.RFC3339), "2021-01-02T03:04:05Z") }) - t.Run("Influxdb response parser parseValue invalid type", func(t *testing.T) { + t.Run("Influxdb response parser parseNumber invalid type", func(t *testing.T) { _, err := parseTimestamp("hello") require.Error(t, err) }) From 024194dccd46639e6439352758805926bbbb4300 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 18 Feb 2022 16:03:49 -0800 Subject: [PATCH 091/104] Search: add URL as linkable field (#45568) --- pkg/services/searchV2/service.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/pkg/services/searchV2/service.go b/pkg/services/searchV2/service.go index 5591d627bbb..c2ea11f6923 100644 --- a/pkg/services/searchV2/service.go +++ b/pkg/services/searchV2/service.go @@ -33,6 +33,7 @@ type dashMeta struct { id int64 is_folder bool folder_id int64 + slug string created time.Time updated time.Time dash *extract.DashboardInfo @@ -97,8 +98,9 @@ func (s *StandardSearchService) applyAuthFilter(user *models.SignedInUser, dash type dashDataQueryResult struct { Id int64 - IsFolder bool `xorm:"is_folder"` - FolderID int64 `xorm:"folder_id"` + IsFolder bool `xorm:"is_folder"` + FolderID int64 `xorm:"folder_id"` + Slug string `xorm:"slug"` Data []byte Created time.Time Updated time.Time @@ -117,7 +119,7 @@ func loadDashboards(ctx context.Context, orgID int64, sql *sqlstore.SQLStore) ([ sess.Table("dashboard"). Where("org_id = ?", orgID). - Cols("id", "is_folder", "folder_id", "data", "created", "updated") + Cols("id", "is_folder", "folder_id", "data", "slug", "created", "updated") err := sess.Find(&rows) if err != nil { @@ -131,6 +133,7 @@ func loadDashboards(ctx context.Context, orgID int64, sql *sqlstore.SQLStore) ([ id: row.Id, is_folder: row.IsFolder, folder_id: row.FolderID, + slug: row.Slug, created: row.Created, updated: row.Updated, dash: dash, @@ -177,6 +180,7 @@ func metaToFrame(meta []dashMeta) data.Frames { dashID := data.NewFieldFromFieldType(data.FieldTypeInt64, 0) dashUID := data.NewFieldFromFieldType(data.FieldTypeString, 0) + dashURL := data.NewFieldFromFieldType(data.FieldTypeString, 0) dashFolderID := data.NewFieldFromFieldType(data.FieldTypeInt64, 0) dashName := data.NewFieldFromFieldType(data.FieldTypeString, 0) dashDescr := data.NewFieldFromFieldType(data.FieldTypeString, 0) @@ -194,6 +198,12 @@ func metaToFrame(meta []dashMeta) data.Frames { dashSchemaVersion.Name = "SchemaVersion" dashCreated.Name = "Created" dashUpdated.Name = "Updated" + dashURL.Name = "URL" + dashURL.Config = &data.FieldConfig{ + Links: []data.DataLink{ + {Title: "link", URL: "${__value.text}"}, + }, + } dashTags.Config = &data.FieldConfig{ Custom: map[string]interface{}{ @@ -240,6 +250,9 @@ func metaToFrame(meta []dashMeta) data.Frames { dashCreated.Append(row.created) dashUpdated.Append(row.updated) + url := fmt.Sprintf("/d/%s/%s", row.dash.UID, row.slug) + dashURL.Append(url) + // stats schemaVersionCounter.add(strconv.FormatInt(row.dash.SchemaVersion, 10)) @@ -267,7 +280,7 @@ func metaToFrame(meta []dashMeta) data.Frames { return data.Frames{ data.NewFrame("folders", folderID, folderUID, folderName), - data.NewFrame("dashboards", dashID, dashUID, dashFolderID, dashName, dashDescr, dashTags, dashSchemaVersion, dashCreated, dashUpdated), + data.NewFrame("dashboards", dashID, dashUID, dashURL, dashFolderID, dashName, dashDescr, dashTags, dashSchemaVersion, dashCreated, dashUpdated), data.NewFrame("panels", panelDashID, panelID, panelName, panelDescr, panelType), panelTypeCounter.toFrame("panel-type-counts"), schemaVersionCounter.toFrame("schema-version-counts"), From 636af1fff45934a422393d76e1a2583dc45cbaaa Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 18 Feb 2022 16:30:57 -0800 Subject: [PATCH 092/104] Testdata: use alias in streaming example (#45631) --- public/app/plugins/datasource/testdata/runStreams.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/public/app/plugins/datasource/testdata/runStreams.ts b/public/app/plugins/datasource/testdata/runStreams.ts index 943406fe74c..8ebded83338 100644 --- a/public/app/plugins/datasource/testdata/runStreams.ts +++ b/public/app/plugins/datasource/testdata/runStreams.ts @@ -51,10 +51,9 @@ export function runSignalStream( const schema: DataFrameSchema = { refId: target.refId, - name: target.alias || 'Signal ' + target.refId, fields: [ { name: 'time', type: FieldType.time }, - { name: 'value', type: FieldType.number }, + { name: target.alias ?? 'value', type: FieldType.number }, ], }; From 2f63d7b1c426f2945f5205ce3cdcbfc267d4d9fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 21 Feb 2022 08:21:06 +0100 Subject: [PATCH 093/104] Chore: Replace enzyme with RTL in BigValue.test.tsx (#45650) --- .betterer.results | 3 -- .../src/components/BigValue/BigValue.test.tsx | 20 ++----- .../src/components/BigValue/BigValue.tsx | 2 - .../__snapshots__/BigValue.test.tsx.snap | 54 ------------------- 4 files changed, 5 insertions(+), 74 deletions(-) delete mode 100644 packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap diff --git a/.betterer.results b/.betterer.results index 3c9d28c1017..43a2c9bd9af 100644 --- a/.betterer.results +++ b/.betterer.results @@ -8,9 +8,6 @@ exports[`no enzyme tests`] = { "packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx:2315765050": [ [1, 19, 13, "RegExp match", "2409514259"] ], - "packages/grafana-ui/src/components/BigValue/BigValue.test.tsx:261212211": [ - [1, 19, 13, "RegExp match", "2409514259"] - ], "packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx:1355456933": [ [1, 31, 13, "RegExp match", "2409514259"] ], diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx index 94b995ccc99..16efc2b43c3 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { shallow } from 'enzyme'; -import { BigValue, Props, BigValueColorMode, BigValueGraphMode } from './BigValue'; +import { render, screen } from '@testing-library/react'; +import { BigValue, BigValueColorMode, BigValueGraphMode, Props } from './BigValue'; import { createTheme } from '@grafana/data'; function getProps(propOverrides?: Partial): Props { @@ -21,22 +21,12 @@ function getProps(propOverrides?: Partial): Props { return props; } -const setup = (propOverrides?: object) => { - const props = getProps(propOverrides); - const wrapper = shallow(); - const instance = wrapper.instance() as BigValue; - - return { - instance, - wrapper, - }; -}; - describe('BigValue', () => { describe('Render with basic options', () => { it('should render', () => { - const { wrapper } = setup(); - expect(wrapper).toMatchSnapshot(); + render(); + + expect(screen.getByText('25')).toBeInTheDocument(); }); }); }); diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.tsx index ff222b5d60e..864c2547199 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValue.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValue.tsx @@ -1,8 +1,6 @@ -// Library import React, { PureComponent } from 'react'; import { DisplayValue, DisplayValueAlignmentFactors, FieldSparkline, TextDisplayOptions } from '@grafana/data'; -// Types import { Themeable2 } from '../../types'; import { buildLayout } from './BigValueLayout'; import { FormattedValueDisplay } from '../FormattedValueDisplay/FormattedValueDisplay'; diff --git a/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap b/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap deleted file mode 100644 index 5acf66fb54e..00000000000 --- a/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap +++ /dev/null @@ -1,54 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`BigValue Render with basic options should render 1`] = ` -
-
- -
-
-`; From c6470be34ce41dade11f74317ce4dea1af78bc64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 21 Feb 2022 08:25:52 +0100 Subject: [PATCH 094/104] Chore: Replace enzyme with RTL in ConfirmModal.test.tsx (#45651) --- .betterer.results | 3 -- .../ConfirmModal/ConfirmModal.test.tsx | 48 +++++++++---------- .../components/ConfirmModal/ConfirmModal.tsx | 5 +- 3 files changed, 25 insertions(+), 31 deletions(-) diff --git a/.betterer.results b/.betterer.results index 43a2c9bd9af..367368dd38d 100644 --- a/.betterer.results +++ b/.betterer.results @@ -11,9 +11,6 @@ exports[`no enzyme tests`] = { "packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx:1355456933": [ [1, 31, 13, "RegExp match", "2409514259"] ], - "packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx:3838344574": [ - [1, 17, 13, "RegExp match", "2409514259"] - ], "packages/grafana-ui/src/components/FileUpload/FileUpload.test.tsx:3475964456": [ [1, 19, 13, "RegExp match", "2409514259"] ], diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx index e5c720b7b0d..50a9482c9af 100644 --- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx +++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx @@ -1,23 +1,34 @@ import React from 'react'; -import { mount } from 'enzyme'; +import { render, screen, within } from '@testing-library/react'; + import { ConfirmModal } from './ConfirmModal'; describe('ConfirmModal', () => { - it('renders without error', () => { - mount( + it('should render correct title, body, dismiss-, alternative- and confirm-text', () => { + render( {}} onDismiss={() => {}} + onAlternative={() => {}} /> ); + + expect(screen.getByRole('heading', { name: 'Some Title' })).toBeInTheDocument(); + expect(screen.getByText('Some Body')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Dismiss Text' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Alternative Text' })).toBeInTheDocument(); + const button = screen.getByRole('button', { name: 'Confirm Modal Danger Button' }); + expect(within(button).getByText('Please Confirm')).toBeInTheDocument(); }); - it('renders nothing by default or when isOpen is false', () => { - const wrapper = mount( + it('should render nothing when isOpen is false', () => { + render( { onDismiss={() => {}} /> ); - expect(wrapper.html()).toBe(''); - wrapper.setProps({ ...wrapper.props(), isOpen: false }); - expect(wrapper.html()).toBe(''); - }); - - it('renders correct contents', () => { - const wrapper = mount( - {}} - onDismiss={() => {}} - /> - ); - - expect(wrapper.contains('Some Title')).toBeTruthy(); - expect(wrapper.contains('Content')).toBeTruthy(); - expect(wrapper.contains('Confirm')).toBeTruthy(); + expect(screen.queryByRole('heading', { name: 'Some Title' })).not.toBeInTheDocument(); + expect(screen.queryByText('Some Body')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Dismiss Text' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Alternative Text' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Confirm Modal Danger Button' })).not.toBeInTheDocument(); }); }); diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx index d2c64cd404e..99d5bf676c6 100644 --- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx +++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx @@ -1,12 +1,13 @@ import React, { useEffect, useRef, useState } from 'react'; import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; + import { Modal } from '../Modal/Modal'; import { IconName } from '../../types/icon'; import { Button } from '../Button'; import { useStyles2 } from '../../themes'; -import { GrafanaTheme2 } from '@grafana/data'; import { HorizontalGroup, Input } from '..'; -import { selectors } from '@grafana/e2e-selectors'; export interface ConfirmModalProps { /** Toggle modal's open/closed state */ From 6b1c3aeb4e37b585858ed2a39fe961934829b093 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Feb 2022 02:10:23 -0800 Subject: [PATCH 095/104] Update dependency eslint-plugin-lodash to v7.4.0 (#45595) Co-authored-by: Renovate Bot --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index e9de55d74b1..d307a7740b5 100644 --- a/package.json +++ b/package.json @@ -172,7 +172,7 @@ "eslint-config-prettier": "8.3.0", "eslint-plugin-jest": "^26.1.0", "eslint-plugin-jsdoc": "37.9.1", - "eslint-plugin-lodash": "^7.2.0", + "eslint-plugin-lodash": "7.4.0", "eslint-plugin-prettier": "4.0.0", "eslint-plugin-react": "7.28.0", "eslint-plugin-react-hooks": "4.3.0", diff --git a/yarn.lock b/yarn.lock index d2a70ec206d..53ba830ecfc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18289,14 +18289,14 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-lodash@npm:^7.2.0": - version: 7.3.0 - resolution: "eslint-plugin-lodash@npm:7.3.0" +"eslint-plugin-lodash@npm:7.4.0": + version: 7.4.0 + resolution: "eslint-plugin-lodash@npm:7.4.0" dependencies: lodash: ^4.17.21 peerDependencies: eslint: ">=2" - checksum: 590c627bd2e58388b198e3a829d5e4729ceb0aa5bdb2a82c963582cf691430d8e857fc0ba67e143ad926197133471baf62ac2b1e4af2e2cac254f71aee92f609 + checksum: 7557cded64dd0e1042b420214e65ba9d6c5cb6c83c40e471db1f7d33e63584d1260c9ca9a4fded4ca7a2fe2ac2a9cdc303e072105096fa99b583101c6e7ada13 languageName: node linkType: hard @@ -20520,7 +20520,7 @@ __metadata: eslint-config-prettier: 8.3.0 eslint-plugin-jest: ^26.1.0 eslint-plugin-jsdoc: 37.9.1 - eslint-plugin-lodash: ^7.2.0 + eslint-plugin-lodash: 7.4.0 eslint-plugin-prettier: 4.0.0 eslint-plugin-react: 7.28.0 eslint-plugin-react-hooks: 4.3.0 From 83db65abda903cedbc164e7bcc0b5ee47e5fe01f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Feb 2022 02:14:18 -0800 Subject: [PATCH 096/104] Update dependency @react-types/shared to v3.11.1 (#45592) Co-authored-by: Renovate Bot --- package.json | 2 +- yarn.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index d307a7740b5..aa934cfcae6 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "@react-types/button": "3.4.1", "@react-types/menu": "3.5.1", "@react-types/overlays": "3.5.1", - "@react-types/shared": "3.10.1", + "@react-types/shared": "3.11.1", "@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1", "@swc/core": "1.2.136", "@swc/helpers": "0.3.2", diff --git a/yarn.lock b/yarn.lock index 53ba830ecfc..047ed70209a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7338,12 +7338,12 @@ __metadata: languageName: node linkType: hard -"@react-types/shared@npm:3.10.1, @react-types/shared@npm:^3.10.1": - version: 3.10.1 - resolution: "@react-types/shared@npm:3.10.1" +"@react-types/shared@npm:3.11.1, @react-types/shared@npm:^3.11.1": + version: 3.11.1 + resolution: "@react-types/shared@npm:3.11.1" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 - checksum: 6c4b7c6d43f84994521c4780f3b969a70c6b2ce69892f03c309f88b5d5d3c862a1a0d07f6060f9f650dbdd834cd16568db957c03543028eb8e52eb7dc97c58ea + checksum: 5d96e63a985227cc98a9172686690f7f58cfc7e8026765e4efd364dd7e059986d1e239453bb19f018b2d369717c28825b386471d1f21fc10ce5c50a3f05d1fba languageName: node linkType: hard @@ -7356,12 +7356,12 @@ __metadata: languageName: node linkType: hard -"@react-types/shared@npm:^3.11.1": - version: 3.11.1 - resolution: "@react-types/shared@npm:3.11.1" +"@react-types/shared@npm:^3.10.1": + version: 3.10.1 + resolution: "@react-types/shared@npm:3.10.1" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 - checksum: 5d96e63a985227cc98a9172686690f7f58cfc7e8026765e4efd364dd7e059986d1e239453bb19f018b2d369717c28825b386471d1f21fc10ce5c50a3f05d1fba + checksum: 6c4b7c6d43f84994521c4780f3b969a70c6b2ce69892f03c309f88b5d5d3c862a1a0d07f6060f9f650dbdd834cd16568db957c03543028eb8e52eb7dc97c58ea languageName: node linkType: hard @@ -20412,7 +20412,7 @@ __metadata: "@react-types/button": 3.4.1 "@react-types/menu": 3.5.1 "@react-types/overlays": 3.5.1 - "@react-types/shared": 3.10.1 + "@react-types/shared": 3.11.1 "@reduxjs/toolkit": 1.7.2 "@rtsao/plugin-proposal-class-properties": 7.0.1-patch.1 "@sentry/browser": 6.17.4 From 0fb490c00d4adafa8c82c1b0a6a19f4638b1edd5 Mon Sep 17 00:00:00 2001 From: Mitsuhiro Tanda Date: Mon, 21 Feb 2022 19:20:34 +0900 Subject: [PATCH 097/104] Cloud Monitoring: set missing meta data for distribution type metrics (#45500) * set missing meta data for distribution type metrics * add test * add test * simplify custom set * add test for executedQueryString * add test data * fix tests * check group by --- ...-response-distribution-without-points.json | 17 +++++ .../cloudmonitoring/time_series_filter.go | 10 +++ .../time_series_filter_test.go | 73 +++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 pkg/tsdb/cloudmonitoring/test-data/9-series-response-distribution-without-points.json diff --git a/pkg/tsdb/cloudmonitoring/test-data/9-series-response-distribution-without-points.json b/pkg/tsdb/cloudmonitoring/test-data/9-series-response-distribution-without-points.json new file mode 100644 index 00000000000..453d3913f01 --- /dev/null +++ b/pkg/tsdb/cloudmonitoring/test-data/9-series-response-distribution-without-points.json @@ -0,0 +1,17 @@ +{ + "timeSeries": [ + { + "metric": { + "type": "loadbalancing.googleapis.com\/https\/backend_latencies" + }, + "resource": { + "type": "https_lb_rule", + "labels": { + "project_id": "grafana-prod" + } + }, + "metricKind": "DELTA", + "valueType": "DISTRIBUTION" + } + ] +} diff --git a/pkg/tsdb/cloudmonitoring/time_series_filter.go b/pkg/tsdb/cloudmonitoring/time_series_filter.go index 818565f858b..2f924899e08 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_filter.go +++ b/pkg/tsdb/cloudmonitoring/time_series_filter.go @@ -193,6 +193,9 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes valueField, }, RefID: timeSeriesFilter.RefID, + Meta: &data.FrameMeta{ + ExecutedQueryString: executedQueryString, + }, } if maxKey < i { @@ -220,13 +223,20 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes timeField, valueField, }, + Meta: &data.FrameMeta{ + ExecutedQueryString: executedQueryString, + }, } } } } for i := 0; i < len(buckets); i++ { + buckets[i].Meta.Custom = customFrameMeta frames = append(frames, buckets[i]) } + if len(buckets) == 0 { + frames = append(frames, frame) + } } if len(response.TimeSeries) > 0 { dl := timeSeriesFilter.buildDeepLink() diff --git a/pkg/tsdb/cloudmonitoring/time_series_filter_test.go b/pkg/tsdb/cloudmonitoring/time_series_filter_test.go index 909652a3731..72812087e51 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_filter_test.go +++ b/pkg/tsdb/cloudmonitoring/time_series_filter_test.go @@ -11,6 +11,8 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + sdkdata "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -386,6 +388,77 @@ func TestTimeSeriesFilter(t *testing.T) { require.True(t, ok) assert.Equal(t, "114250375703598695", labels["resource.label.instance_id"]) }) + + t.Run("parseResponse successfully parses metadata for distribution valueType", func(t *testing.T) { + t.Run("exponential bounds", func(t *testing.T) { + data, err := loadTestFile("./test-data/3-series-response-distribution-exponential.json") + require.NoError(t, err) + assert.Equal(t, 1, len(data.TimeSeries)) + + res := &backend.DataResponse{} + require.NoError(t, (&cloudMonitoringTimeSeriesFilter{GroupBys: []string{"test_group_by"}}).parseResponse(res, data, "test_query")) + + require.NotNil(t, res.Frames[0].Meta) + assert.Equal(t, sdkdata.FrameMeta{ + ExecutedQueryString: "test_query", + Custom: map[string]interface{}{ + "groupBys": []string{"test_group_by"}, + "alignmentPeriod": "", + "labels": map[string]string{ + "resource.label.project_id": "grafana-prod", + "resource.type": "https_lb_rule", + }, + "perSeriesAligner": "", + }, + }, *res.Frames[0].Meta) + }) + + t.Run("explicit bounds", func(t *testing.T) { + data, err := loadTestFile("./test-data/4-series-response-distribution-explicit.json") + require.NoError(t, err) + assert.Equal(t, 1, len(data.TimeSeries)) + + res := &backend.DataResponse{} + require.NoError(t, (&cloudMonitoringTimeSeriesFilter{GroupBys: []string{"test_group_by"}}).parseResponse(res, data, "test_query")) + + require.NotNil(t, res.Frames[0].Meta) + assert.Equal(t, sdkdata.FrameMeta{ + ExecutedQueryString: "test_query", + Custom: map[string]interface{}{ + "groupBys": []string{"test_group_by"}, + "alignmentPeriod": "", + "labels": map[string]string{ + "resource.label.project_id": "grafana-demo", + "resource.type": "global", + }, + "perSeriesAligner": "", + }, + }, *res.Frames[0].Meta) + }) + + t.Run("without series points", func(t *testing.T) { + data, err := loadTestFile("./test-data/3-series-response-distribution-exponential.json") + require.NoError(t, err) + assert.Equal(t, 1, len(data.TimeSeries)) + + res := &backend.DataResponse{} + require.NoError(t, (&cloudMonitoringTimeSeriesFilter{GroupBys: []string{"test_group_by"}}).parseResponse(res, data, "test_query")) + + require.NotNil(t, res.Frames[0].Meta) + assert.Equal(t, sdkdata.FrameMeta{ + ExecutedQueryString: "test_query", + Custom: map[string]interface{}{ + "groupBys": []string{"test_group_by"}, + "alignmentPeriod": "", + "labels": map[string]string{ + "resource.label.project_id": "grafana-prod", + "resource.type": "https_lb_rule", + }, + "perSeriesAligner": "", + }, + }, *res.Frames[0].Meta) + }) + }) } func loadTestFile(path string) (cloudMonitoringResponse, error) { From 5f533dac723be1fe5a48a9e0f93050f8c7a76ea5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Feb 2022 12:04:24 +0100 Subject: [PATCH 098/104] Update dependency @emotion/react to v11.8.1 (#45656) Co-authored-by: Renovate Bot --- package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 22 +++++++++++++++------- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index aa934cfcae6..b341957e5c2 100644 --- a/package.json +++ b/package.json @@ -233,7 +233,7 @@ }, "dependencies": { "@emotion/css": "11.7.1", - "@emotion/react": "11.7.1", + "@emotion/react": "11.8.1", "@grafana/aws-sdk": "0.0.33", "@grafana/data": "workspace:*", "@grafana/e2e-selectors": "workspace:*", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 768b671ed45..7afa48211e4 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -31,7 +31,7 @@ ], "dependencies": { "@emotion/css": "11.7.1", - "@emotion/react": "11.7.1", + "@emotion/react": "11.8.1", "@grafana/aws-sdk": "0.0.33", "@grafana/data": "8.5.0-pre", "@grafana/e2e-selectors": "8.5.0-pre", diff --git a/yarn.lock b/yarn.lock index 047ed70209a..2976ab700b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3661,15 +3661,16 @@ __metadata: languageName: node linkType: hard -"@emotion/react@npm:11.7.1": - version: 11.7.1 - resolution: "@emotion/react@npm:11.7.1" +"@emotion/react@npm:11.8.1": + version: 11.8.1 + resolution: "@emotion/react@npm:11.8.1" dependencies: "@babel/runtime": ^7.13.10 + "@emotion/babel-plugin": ^11.7.1 "@emotion/cache": ^11.7.1 "@emotion/serialize": ^1.0.2 "@emotion/sheet": ^1.1.0 - "@emotion/utils": ^1.0.0 + "@emotion/utils": ^1.1.0 "@emotion/weak-memoize": ^0.2.5 hoist-non-react-statics: ^3.3.1 peerDependencies: @@ -3680,7 +3681,7 @@ __metadata: optional: true "@types/react": optional: true - checksum: 3392aa71f9c68c16022947959f7f842174e6b075b3baf58e185462411cd9b5a87557109a9225b1abaa2d7200a08b7cb9d7f21aa035f0d36b2c89e19b2a124e02 + checksum: a767c6d7ca9e45dc3c1e873cfa51952db24e0d279944403ceb06be82d15859354e128becf6f7d0f8ff2b5f3460e3c165cd2b16ab1a2446e8e15e134a69e6ea3a languageName: node linkType: hard @@ -3810,6 +3811,13 @@ __metadata: languageName: node linkType: hard +"@emotion/utils@npm:^1.1.0": + version: 1.1.0 + resolution: "@emotion/utils@npm:1.1.0" + checksum: d3b681ca3a23b07033ac6c6937e71010a5549ac8ccec325eb6c91a7e48d9a73db83fa5dadc58be981bb125d7c00fedca868ea4362b1da9e02866615f96be4df1 + languageName: node + linkType: hard + "@emotion/weak-memoize@npm:0.2.5, @emotion/weak-memoize@npm:^0.2.5": version: 0.2.5 resolution: "@emotion/weak-memoize@npm:0.2.5" @@ -4329,7 +4337,7 @@ __metadata: dependencies: "@babel/core": 7.17.0 "@emotion/css": 11.7.1 - "@emotion/react": 11.7.1 + "@emotion/react": 11.8.1 "@grafana/aws-sdk": 0.0.33 "@grafana/data": 8.5.0-pre "@grafana/e2e-selectors": 8.5.0-pre @@ -20372,7 +20380,7 @@ __metadata: "@betterer/regexp": 5.1.6 "@emotion/css": 11.7.1 "@emotion/eslint-plugin": 11.7.0 - "@emotion/react": 11.7.1 + "@emotion/react": 11.8.1 "@grafana/api-documenter": 7.11.2 "@grafana/aws-sdk": 0.0.33 "@grafana/data": "workspace:*" From 4fb0337168175f5129c675409477416b3e9a5b81 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Feb 2022 12:21:32 +0100 Subject: [PATCH 099/104] Update dependency sass-loader to v12.6.0 (#45660) Co-authored-by: Renovate Bot --- package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 15 +++++++++------ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index b341957e5c2..8db77b2363d 100644 --- a/package.json +++ b/package.json @@ -212,7 +212,7 @@ "redux-mock-store": "1.5.4", "rimraf": "3.0.2", "sass": "1.49.7", - "sass-loader": "12.4.0", + "sass-loader": "12.6.0", "sinon": "13.0.1", "style-loader": "3.3.1", "stylelint": "14.5.0", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 7afa48211e4..7d6935c56cf 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -169,7 +169,7 @@ "rollup": "2.67.1", "rollup-plugin-sourcemaps": "0.6.3", "rollup-plugin-terser": "7.0.2", - "sass-loader": "12.4.0", + "sass-loader": "12.6.0", "storybook-dark-mode": "1.0.8", "style-loader": "3.3.1", "terser-webpack-plugin": "5.3.1", diff --git a/yarn.lock b/yarn.lock index 2976ab700b5..7ecfaa64291 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4466,7 +4466,7 @@ __metadata: rollup-plugin-sourcemaps: 0.6.3 rollup-plugin-terser: 7.0.2 rxjs: 7.5.2 - sass-loader: 12.4.0 + sass-loader: 12.6.0 slate: 0.47.8 slate-plain-serializer: 0.7.10 storybook-dark-mode: 1.0.8 @@ -20632,7 +20632,7 @@ __metadata: rst2html: "github:thoward/rst2html#990cb89f2a300cdd9151790be377c4c0840df809" rxjs: 7.5.2 sass: 1.49.7 - sass-loader: 12.4.0 + sass-loader: 12.6.0 search-query-parser: 1.6.0 selecto: 1.13.2 semver: ^7.1.3 @@ -32326,9 +32326,9 @@ __metadata: languageName: node linkType: hard -"sass-loader@npm:12.4.0": - version: 12.4.0 - resolution: "sass-loader@npm:12.4.0" +"sass-loader@npm:12.6.0": + version: 12.6.0 + resolution: "sass-loader@npm:12.6.0" dependencies: klona: ^2.0.4 neo-async: ^2.6.2 @@ -32336,6 +32336,7 @@ __metadata: fibers: ">= 3.1.0" node-sass: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 sass: ^1.3.0 + sass-embedded: "*" webpack: ^5.0.0 peerDependenciesMeta: fibers: @@ -32344,7 +32345,9 @@ __metadata: optional: true sass: optional: true - checksum: 0f7ca3633e7f61c412b0628766a9b57cb15f68def45e4303e68eb2f3a0722aec231956fbfd118489d93c997ab605470e89de8e3f7d6776830cc6366d9657d618 + sass-embedded: + optional: true + checksum: 5d73a428588150f704016aa27397941bb9246cecd2ee083b573e95d969fc080ac6a16f2fe1cc64aca08f6e70da6f3c586ee68f0efc9f527fecc360e5f1c299ec languageName: node linkType: hard From 0f29c66c1db8b6802f19b2f61821b0e6d3f819bc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Feb 2022 03:25:35 -0800 Subject: [PATCH 100/104] Update dependency eslint-config-prettier to v8.4.0 (#45657) Co-authored-by: Renovate Bot --- package.json | 2 +- yarn.lock | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 8db77b2363d..3a96b5bac55 100644 --- a/package.json +++ b/package.json @@ -169,7 +169,7 @@ "enzyme": "3.11.0", "enzyme-to-json": "3.6.2", "eslint": "8.9.0", - "eslint-config-prettier": "8.3.0", + "eslint-config-prettier": "8.4.0", "eslint-plugin-jest": "^26.1.0", "eslint-plugin-jsdoc": "37.9.1", "eslint-plugin-lodash": "7.4.0", diff --git a/yarn.lock b/yarn.lock index 7ecfaa64291..c5dae528148 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18244,6 +18244,17 @@ __metadata: languageName: node linkType: hard +"eslint-config-prettier@npm:8.4.0": + version: 8.4.0 + resolution: "eslint-config-prettier@npm:8.4.0" + peerDependencies: + eslint: ">=7.0.0" + bin: + eslint-config-prettier: bin/cli.js + checksum: ee8dc343f1fed15dc0658e190a966cc23644477f2331a7f56ae2bb79dd74892e3cde8ea5c142e7a3329a8291a1de041e13ca96e54d0c85ff4a2aba13390c5f10 + languageName: node + linkType: hard + "eslint-plugin-jest@npm:^26.1.0": version: 26.1.0 resolution: "eslint-plugin-jest@npm:26.1.0" @@ -20525,7 +20536,7 @@ __metadata: enzyme: 3.11.0 enzyme-to-json: 3.6.2 eslint: 8.9.0 - eslint-config-prettier: 8.3.0 + eslint-config-prettier: 8.4.0 eslint-plugin-jest: ^26.1.0 eslint-plugin-jsdoc: 37.9.1 eslint-plugin-lodash: 7.4.0 From d134740bba59e63da5c8626a97719b713726be83 Mon Sep 17 00:00:00 2001 From: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Mon, 21 Feb 2022 11:37:49 +0000 Subject: [PATCH 101/104] Redux: Factor Invites out to separate slice (#45552) --- .betterer.results | 8 ++-- public/app/core/reducers/root.ts | 2 + .../{users => invites}/InviteeRow.tsx | 0 .../{users => invites}/InviteesTable.test.tsx | 2 +- .../{users => invites}/InviteesTable.tsx | 0 .../{users => invites}/SignupInvited.test.tsx | 0 .../{users => invites}/SignupInvited.tsx | 0 .../__snapshots__/InviteesTable.test.tsx.snap | 0 public/app/features/invites/state/actions.ts | 23 ++++++++++ .../features/invites/state/reducers.test.ts | 44 +++++++++++++++++++ public/app/features/invites/state/reducers.ts | 38 ++++++++++++++++ .../app/features/invites/state/selectors.ts | 11 +++++ public/app/features/org/UserInviteForm.tsx | 28 ++++++------ public/app/features/org/api.ts | 12 ----- public/app/features/users/UsersActionBar.tsx | 5 ++- .../app/features/users/UsersListPage.test.tsx | 2 +- public/app/features/users/UsersListPage.tsx | 15 ++++--- public/app/features/users/state/actions.ts | 23 +--------- .../app/features/users/state/reducers.test.ts | 17 +------ public/app/features/users/state/reducers.ts | 8 +--- public/app/features/users/state/selectors.ts | 9 ---- public/app/routes/routes.tsx | 2 +- public/app/types/store.ts | 26 ++++++++++- public/app/types/user.ts | 1 - 24 files changed, 183 insertions(+), 93 deletions(-) rename public/app/features/{users => invites}/InviteeRow.tsx (100%) rename public/app/features/{users => invites}/InviteesTable.test.tsx (91%) rename public/app/features/{users => invites}/InviteesTable.tsx (100%) rename public/app/features/{users => invites}/SignupInvited.test.tsx (100%) rename public/app/features/{users => invites}/SignupInvited.tsx (100%) rename public/app/features/{users => invites}/__snapshots__/InviteesTable.test.tsx.snap (100%) create mode 100644 public/app/features/invites/state/actions.ts create mode 100644 public/app/features/invites/state/reducers.test.ts create mode 100644 public/app/features/invites/state/reducers.ts create mode 100644 public/app/features/invites/state/selectors.ts delete mode 100644 public/app/features/org/api.ts diff --git a/.betterer.results b/.betterer.results index 367368dd38d..235ffb75cae 100644 --- a/.betterer.results +++ b/.betterer.results @@ -275,6 +275,9 @@ exports[`no enzyme tests`] = { "public/app/features/folders/FolderSettingsPage.test.tsx:1751147194": [ [2, 19, 13, "RegExp match", "2409514259"] ], + "public/app/features/invites/InviteesTable.test.tsx:3077684439": [ + [1, 19, 13, "RegExp match", "2409514259"] + ], "public/app/features/org/OrgDetailsPage.test.tsx:2540662821": [ [1, 19, 13, "RegExp match", "2409514259"] ], @@ -302,13 +305,10 @@ exports[`no enzyme tests`] = { "public/app/features/teams/TeamSettings.test.tsx:2628968507": [ [1, 19, 13, "RegExp match", "2409514259"] ], - "public/app/features/users/InviteesTable.test.tsx:2271264692": [ - [1, 19, 13, "RegExp match", "2409514259"] - ], "public/app/features/users/UsersActionBar.test.tsx:4031641375": [ [1, 19, 13, "RegExp match", "2409514259"] ], - "public/app/features/users/UsersListPage.test.tsx:2523261097": [ + "public/app/features/users/UsersListPage.test.tsx:2626906707": [ [1, 19, 13, "RegExp match", "2409514259"] ], "public/app/features/users/UsersTable.test.tsx:3051231816": [ diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index de3b901986f..fe0d22f773c 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -10,6 +10,7 @@ import exploreReducers from 'app/features/explore/state/main'; import { reducer as pluginsReducer } from 'app/features/plugins/admin/state/reducer'; import dataSourcesReducers from 'app/features/datasources/state/reducers'; import usersReducers from 'app/features/users/state/reducers'; +import invitesReducers from 'app/features/invites/state/reducers'; import userReducers from 'app/features/profile/state/reducers'; import organizationReducers from 'app/features/org/state/reducers'; import ldapReducers from 'app/features/admin/state/reducers'; @@ -31,6 +32,7 @@ const rootReducers = { ...usersReducers, ...serviceAccountsReducer, ...userReducers, + ...invitesReducers, ...organizationReducers, ...ldapReducers, ...importDashboardReducers, diff --git a/public/app/features/users/InviteeRow.tsx b/public/app/features/invites/InviteeRow.tsx similarity index 100% rename from public/app/features/users/InviteeRow.tsx rename to public/app/features/invites/InviteeRow.tsx diff --git a/public/app/features/users/InviteesTable.test.tsx b/public/app/features/invites/InviteesTable.test.tsx similarity index 91% rename from public/app/features/users/InviteesTable.test.tsx rename to public/app/features/invites/InviteesTable.test.tsx index ad464f81a2f..e776b52e714 100644 --- a/public/app/features/users/InviteesTable.test.tsx +++ b/public/app/features/invites/InviteesTable.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import InviteesTable, { Props } from './InviteesTable'; import { Invitee } from 'app/types'; -import { getMockInvitees } from './__mocks__/userMocks'; +import { getMockInvitees } from '../users/__mocks__/userMocks'; const setup = (propOverrides?: object) => { const props: Props = { diff --git a/public/app/features/users/InviteesTable.tsx b/public/app/features/invites/InviteesTable.tsx similarity index 100% rename from public/app/features/users/InviteesTable.tsx rename to public/app/features/invites/InviteesTable.tsx diff --git a/public/app/features/users/SignupInvited.test.tsx b/public/app/features/invites/SignupInvited.test.tsx similarity index 100% rename from public/app/features/users/SignupInvited.test.tsx rename to public/app/features/invites/SignupInvited.test.tsx diff --git a/public/app/features/users/SignupInvited.tsx b/public/app/features/invites/SignupInvited.tsx similarity index 100% rename from public/app/features/users/SignupInvited.tsx rename to public/app/features/invites/SignupInvited.tsx diff --git a/public/app/features/users/__snapshots__/InviteesTable.test.tsx.snap b/public/app/features/invites/__snapshots__/InviteesTable.test.tsx.snap similarity index 100% rename from public/app/features/users/__snapshots__/InviteesTable.test.tsx.snap rename to public/app/features/invites/__snapshots__/InviteesTable.test.tsx.snap diff --git a/public/app/features/invites/state/actions.ts b/public/app/features/invites/state/actions.ts new file mode 100644 index 00000000000..0e7c891da25 --- /dev/null +++ b/public/app/features/invites/state/actions.ts @@ -0,0 +1,23 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { contextSrv } from 'app/core/core'; +import { FormModel } from 'app/features/org/UserInviteForm'; +import { AccessControlAction, createAsyncThunk, Invitee } from 'app/types'; + +export const fetchInvitees = createAsyncThunk('users/fetchInvitees', async () => { + if (!contextSrv.hasPermission(AccessControlAction.UsersCreate)) { + return []; + } + + const invitees: Invitee[] = await getBackendSrv().get('/api/org/invites'); + return invitees; +}); + +export const addInvitee = createAsyncThunk('users/addInvitee', async (addInviteForm: FormModel, { dispatch }) => { + await getBackendSrv().post(`/api/org/invites`, addInviteForm); + await dispatch(fetchInvitees()); +}); + +export const revokeInvite = createAsyncThunk('users/revokeInvite', async (code: string) => { + await getBackendSrv().patch(`/api/org/invites/${code}/revoke`, {}); + return code; +}); diff --git a/public/app/features/invites/state/reducers.test.ts b/public/app/features/invites/state/reducers.test.ts new file mode 100644 index 00000000000..e4563709220 --- /dev/null +++ b/public/app/features/invites/state/reducers.test.ts @@ -0,0 +1,44 @@ +import { keyBy } from 'lodash'; +import { reducerTester } from '../../../../test/core/redux/reducerTester'; +import { initialState, invitesReducer } from './reducers'; +import { fetchInvitees, revokeInvite } from './actions'; +import { getMockInvitees } from 'app/features/users/__mocks__/userMocks'; + +describe('inviteesReducer', () => { + describe('when fetchInvitees is dispatched', () => { + it('then state should be correct', () => { + const invitees = getMockInvitees(1); + reducerTester() + .givenReducer(invitesReducer, { ...initialState }) + .whenActionIsDispatched(fetchInvitees.fulfilled(invitees, '')) + .thenStateShouldEqual({ + entities: keyBy(invitees, 'code'), + ids: invitees.map((i) => i.code), + status: 'succeeded', + }); + }); + }); + + describe('when revokeInvite is dispatched', () => { + it('then state should be correct', () => { + const invitees = getMockInvitees(1); + + const fakeInitialState: typeof initialState = { + entities: keyBy(invitees, 'code'), + ids: invitees.map((i) => i.code), + status: 'succeeded', + }; + + reducerTester() + .givenReducer(invitesReducer, fakeInitialState) + .whenActionIsDispatched(revokeInvite.fulfilled(invitees[0].code, '', '')) + .thenStateShouldEqual({ + entities: { + [invitees[1].code]: invitees[1], + }, + ids: [invitees[1].code], + status: 'succeeded', + }); + }); + }); +}); diff --git a/public/app/features/invites/state/reducers.ts b/public/app/features/invites/state/reducers.ts new file mode 100644 index 00000000000..bad26fdc938 --- /dev/null +++ b/public/app/features/invites/state/reducers.ts @@ -0,0 +1,38 @@ +import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; +import { Invitee } from 'app/types'; +import { fetchInvitees, revokeInvite } from './actions'; + +export type Status = 'idle' | 'loading' | 'succeeded' | 'failed'; + +const invitesAdapter = createEntityAdapter({ selectId: (invite: Invitee) => invite.code }); +export const selectors = invitesAdapter.getSelectors(); +export const initialState = invitesAdapter.getInitialState<{ status: Status }>({ status: 'idle' }); + +const invitesSlice = createSlice({ + name: 'invites', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(fetchInvitees.pending, (state) => { + state.status = 'loading'; + }) + .addCase(fetchInvitees.fulfilled, (state, { payload: invites }) => { + invitesAdapter.setAll(state, invites); + state.status = 'succeeded'; + }) + .addCase(fetchInvitees.rejected, (state) => { + state.status = 'failed'; + }) + .addCase(revokeInvite.fulfilled, (state, { payload: inviteCode }) => { + invitesAdapter.removeOne(state, inviteCode); + state.status = 'succeeded'; + }); + }, +}); + +export const invitesReducer = invitesSlice.reducer; + +export default { + invites: invitesReducer, +}; diff --git a/public/app/features/invites/state/selectors.ts b/public/app/features/invites/state/selectors.ts new file mode 100644 index 00000000000..7a9468dbde9 --- /dev/null +++ b/public/app/features/invites/state/selectors.ts @@ -0,0 +1,11 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { selectors } from './reducers'; + +export const { selectAll, selectById, selectTotal } = selectors; + +const selectQuery = (_: any, query: string) => query; +export const selectInvitesMatchingQuery = createSelector([selectAll, selectQuery], (invites, searchQuery) => { + const regex = new RegExp(searchQuery, 'i'); + const matches = invites.filter((invite) => regex.test(invite.name) || regex.test(invite.email)); + return matches; +}); diff --git a/public/app/features/org/UserInviteForm.tsx b/public/app/features/org/UserInviteForm.tsx index 9338884c4e8..3ab5073d6c9 100644 --- a/public/app/features/org/UserInviteForm.tsx +++ b/public/app/features/org/UserInviteForm.tsx @@ -10,11 +10,11 @@ import { Field, InputControl, } from '@grafana/ui'; -import { getConfig } from 'app/core/config'; -import { OrgRole } from 'app/types'; import { locationService } from '@grafana/runtime'; import { locationUtil } from '@grafana/data'; -import { userInviteSubmit } from './api'; +import { getConfig } from 'app/core/config'; +import { OrgRole, useDispatch } from 'app/types'; +import { addInvitee } from '../invites/state/actions'; const roles = [ { label: 'Viewer', value: OrgRole.Viewer }, @@ -22,11 +22,6 @@ const roles = [ { label: 'Admin', value: OrgRole.Admin }, ]; -const onSubmit = async (formData: FormModel) => { - await userInviteSubmit(formData); - locationService.push('/org/users/'); -}; - export interface FormModel { role: OrgRole; name: string; @@ -35,12 +30,19 @@ export interface FormModel { email: string; } +const defaultValues: FormModel = { + name: '', + email: '', + role: OrgRole.Editor, + sendEmail: true, +}; + export const UserInviteForm = () => { - const defaultValues: FormModel = { - name: '', - email: '', - role: OrgRole.Editor, - sendEmail: true, + const dispatch = useDispatch(); + + const onSubmit = async (formData: FormModel) => { + await dispatch(addInvitee(formData)).unwrap(); + locationService.push('/org/users/'); }; return ( diff --git a/public/app/features/org/api.ts b/public/app/features/org/api.ts deleted file mode 100644 index 18f8c8e901b..00000000000 --- a/public/app/features/org/api.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { getBackendSrv } from '@grafana/runtime'; -import { appEvents } from 'app/core/core'; -import { FormModel } from './UserInviteForm'; -import { AppEvents } from '@grafana/data'; - -export const userInviteSubmit = async (formData: FormModel) => { - try { - await getBackendSrv().post('/api/org/invites', formData); - } catch (err) { - appEvents.emit(AppEvents.alertError, ['Failed to send invitation.', err.message]); - } -}; diff --git a/public/app/features/users/UsersActionBar.tsx b/public/app/features/users/UsersActionBar.tsx index 7e505218622..9e4765490fd 100644 --- a/public/app/features/users/UsersActionBar.tsx +++ b/public/app/features/users/UsersActionBar.tsx @@ -1,7 +1,8 @@ import React, { PureComponent } from 'react'; import { connect } from 'react-redux'; import { setUsersSearchQuery } from './state/reducers'; -import { getInviteesCount, getUsersSearchQuery } from './state/selectors'; +import { selectTotal } from '../invites/state/selectors'; +import { getUsersSearchQuery } from './state/selectors'; import { RadioButtonGroup, LinkButton, FilterInput } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; import { AccessControlAction } from 'app/types'; @@ -63,7 +64,7 @@ export class UsersActionBar extends PureComponent { function mapStateToProps(state: any) { return { searchQuery: getUsersSearchQuery(state.users), - pendingInvitesCount: getInviteesCount(state.users), + pendingInvitesCount: selectTotal(state.invites), externalUserMngLinkName: state.users.externalUserMngLinkName, externalUserMngLinkUrl: state.users.externalUserMngLinkUrl, canInvite: state.users.canInvite, diff --git a/public/app/features/users/UsersListPage.test.tsx b/public/app/features/users/UsersListPage.test.tsx index 396a5e66a00..4907b7403a6 100644 --- a/public/app/features/users/UsersListPage.test.tsx +++ b/public/app/features/users/UsersListPage.test.tsx @@ -26,7 +26,7 @@ const setup = (propOverrides?: object) => { searchQuery: '', searchPage: 1, externalUserMngInfo: '', - loadInvitees: jest.fn(), + fetchInvitees: jest.fn(), loadUsers: jest.fn(), updateUser: jest.fn(), removeUser: jest.fn(), diff --git a/public/app/features/users/UsersListPage.tsx b/public/app/features/users/UsersListPage.tsx index 8f3dfd00fb7..1083b4f9ac1 100644 --- a/public/app/features/users/UsersListPage.tsx +++ b/public/app/features/users/UsersListPage.tsx @@ -6,20 +6,23 @@ import { HorizontalGroup, Pagination, VerticalGroup } from '@grafana/ui'; import Page from 'app/core/components/Page/Page'; import UsersActionBar from './UsersActionBar'; import UsersTable from './UsersTable'; -import InviteesTable from './InviteesTable'; +import InviteesTable from '../invites/InviteesTable'; import { OrgUser, OrgRole, StoreState } from 'app/types'; -import { loadInvitees, loadUsers, removeUser, updateUser } from './state/actions'; +import { loadUsers, removeUser, updateUser } from './state/actions'; +import { fetchInvitees } from '../invites/state/actions'; import { getNavModel } from 'app/core/selectors/navModel'; -import { getInvitees, getUsers, getUsersSearchQuery, getUsersSearchPage } from './state/selectors'; +import { getUsers, getUsersSearchQuery, getUsersSearchPage } from './state/selectors'; import { setUsersSearchQuery, setUsersSearchPage } from './state/reducers'; +import { selectInvitesMatchingQuery } from '../invites/state/selectors'; function mapStateToProps(state: StoreState) { + const searchQuery = getUsersSearchQuery(state.users); return { navModel: getNavModel(state.navIndex, 'users'), users: getUsers(state.users), searchQuery: getUsersSearchQuery(state.users), searchPage: getUsersSearchPage(state.users), - invitees: getInvitees(state.users), + invitees: selectInvitesMatchingQuery(state.invites, searchQuery), externalUserMngInfo: state.users.externalUserMngInfo, hasFetched: state.users.hasFetched, }; @@ -27,7 +30,7 @@ function mapStateToProps(state: StoreState) { const mapDispatchToProps = { loadUsers, - loadInvitees, + fetchInvitees, setUsersSearchQuery, setUsersSearchPage, updateUser, @@ -69,7 +72,7 @@ export class UsersListPage extends PureComponent { } async fetchInvitees() { - return await this.props.loadInvitees(); + return await this.props.fetchInvitees(); } onRoleChange = (role: OrgRole, user: OrgUser) => { diff --git a/public/app/features/users/state/actions.ts b/public/app/features/users/state/actions.ts index cf07354f1b5..8d36742c584 100644 --- a/public/app/features/users/state/actions.ts +++ b/public/app/features/users/state/actions.ts @@ -1,8 +1,7 @@ -import { AccessControlAction, ThunkResult } from '../../../types'; +import { ThunkResult } from '../../../types'; import { getBackendSrv } from '@grafana/runtime'; import { OrgUser } from 'app/types'; -import { inviteesLoaded, usersLoaded } from './reducers'; -import { contextSrv } from 'app/core/core'; +import { usersLoaded } from './reducers'; import { accessControlQueryParam } from 'app/core/utils/accessControl'; export function loadUsers(): ThunkResult { @@ -12,17 +11,6 @@ export function loadUsers(): ThunkResult { }; } -export function loadInvitees(): ThunkResult { - return async (dispatch) => { - if (!contextSrv.hasPermission(AccessControlAction.UsersCreate)) { - return; - } - - const invitees = await getBackendSrv().get('/api/org/invites'); - dispatch(inviteesLoaded(invitees)); - }; -} - export function updateUser(user: OrgUser): ThunkResult { return async (dispatch) => { await getBackendSrv().patch(`/api/org/users/${user.userId}`, { role: user.role }); @@ -36,10 +24,3 @@ export function removeUser(userId: number): ThunkResult { dispatch(loadUsers()); }; } - -export function revokeInvite(code: string): ThunkResult { - return async (dispatch) => { - await getBackendSrv().patch(`/api/org/invites/${code}/revoke`, {}); - dispatch(loadInvitees()); - }; -} diff --git a/public/app/features/users/state/reducers.test.ts b/public/app/features/users/state/reducers.test.ts index 31912950686..6499959c449 100644 --- a/public/app/features/users/state/reducers.test.ts +++ b/public/app/features/users/state/reducers.test.ts @@ -1,7 +1,7 @@ import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { UsersState } from '../../../types'; -import { initialState, inviteesLoaded, setUsersSearchQuery, usersLoaded, usersReducer } from './reducers'; -import { getMockInvitees, getMockUsers } from '../__mocks__/userMocks'; +import { initialState, setUsersSearchQuery, usersLoaded, usersReducer } from './reducers'; +import { getMockUsers } from '../__mocks__/userMocks'; describe('usersReducer', () => { describe('when usersLoaded is dispatched', () => { @@ -17,19 +17,6 @@ describe('usersReducer', () => { }); }); - describe('when inviteesLoaded is dispatched', () => { - it('then state should be correct', () => { - reducerTester() - .givenReducer(usersReducer, { ...initialState }) - .whenActionIsDispatched(inviteesLoaded(getMockInvitees(1))) - .thenStateShouldEqual({ - ...initialState, - invitees: getMockInvitees(1), - hasFetched: true, - }); - }); - }); - describe('when setUsersSearchQuery is dispatched', () => { it('then state should be correct', () => { reducerTester() diff --git a/public/app/features/users/state/reducers.ts b/public/app/features/users/state/reducers.ts index c43cbd17783..436feccdd78 100644 --- a/public/app/features/users/state/reducers.ts +++ b/public/app/features/users/state/reducers.ts @@ -1,10 +1,9 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { Invitee, OrgUser, UsersState } from 'app/types'; +import { OrgUser, UsersState } from 'app/types'; import config from 'app/core/config'; export const initialState: UsersState = { - invitees: [] as Invitee[], users: [] as OrgUser[], searchQuery: '', searchPage: 1, @@ -22,9 +21,6 @@ const usersSlice = createSlice({ usersLoaded: (state, action: PayloadAction): UsersState => { return { ...state, hasFetched: true, users: action.payload }; }, - inviteesLoaded: (state, action: PayloadAction): UsersState => { - return { ...state, hasFetched: true, invitees: action.payload }; - }, setUsersSearchQuery: (state, action: PayloadAction): UsersState => { // reset searchPage otherwise search results won't appear return { ...state, searchQuery: action.payload, searchPage: initialState.searchPage }; @@ -35,7 +31,7 @@ const usersSlice = createSlice({ }, }); -export const { inviteesLoaded, setUsersSearchQuery, setUsersSearchPage, usersLoaded } = usersSlice.actions; +export const { setUsersSearchQuery, setUsersSearchPage, usersLoaded } = usersSlice.actions; export const usersReducer = usersSlice.reducer; diff --git a/public/app/features/users/state/selectors.ts b/public/app/features/users/state/selectors.ts index 361ff059c3e..19a200c69ff 100644 --- a/public/app/features/users/state/selectors.ts +++ b/public/app/features/users/state/selectors.ts @@ -8,14 +8,5 @@ export const getUsers = (state: UsersState) => { }); }; -export const getInvitees = (state: UsersState) => { - const regex = new RegExp(state.searchQuery, 'i'); - - return state.invitees.filter((invitee) => { - return regex.test(invitee.name) || regex.test(invitee.email); - }); -}; - -export const getInviteesCount = (state: UsersState) => state.invitees.length; export const getUsersSearchQuery = (state: UsersState) => state.searchQuery; export const getUsersSearchPage = (state: UsersState) => state.searchPage; diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index e9893268a36..017739d0ce2 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -323,7 +323,7 @@ export function getAppRoutes(): RouteDescriptor[] { { path: '/invite/:code', component: SafeDynamicImport( - () => import(/* webpackChunkName: "SignupInvited" */ 'app/features/users/SignupInvited') + () => import(/* webpackChunkName: "SignupInvited" */ 'app/features/invites/SignupInvited') ), pageClass: 'sidemenu-hidden', }, diff --git a/public/app/types/store.ts b/public/app/types/store.ts index 3db5c2f6b11..e94d1b9e1a8 100644 --- a/public/app/types/store.ts +++ b/public/app/types/store.ts @@ -1,6 +1,19 @@ import { ThunkAction, ThunkDispatch as GenericThunkDispatch } from 'redux-thunk'; -import { Action, PayloadAction } from '@reduxjs/toolkit'; +import { + useSelector as useSelectorUntyped, + TypedUseSelectorHook, + useDispatch as useDispatchUntyped, +} from 'react-redux'; +import { + Action, + AsyncThunk, + AsyncThunkOptions, + AsyncThunkPayloadCreator, + createAsyncThunk as createAsyncThunkUntyped, + PayloadAction, +} from '@reduxjs/toolkit'; import type { createRootReducer } from 'app/core/reducers/root'; +import { configureStore } from 'app/store/configureStore'; export type StoreState = ReturnType>; @@ -10,3 +23,14 @@ export type StoreState = ReturnType>; export type ThunkResult = ThunkAction>; export type ThunkDispatch = GenericThunkDispatch; + +export type AppDispatch = ReturnType['dispatch']; +export const useDispatch = () => useDispatchUntyped(); +export const useSelector: TypedUseSelectorHook = useSelectorUntyped; + +type DefaultThunkApiConfig = { dispatch: AppDispatch; state: StoreState }; +export const createAsyncThunk = ( + typePrefix: string, + payloadCreator: AsyncThunkPayloadCreator, + options?: AsyncThunkOptions +): AsyncThunk => createAsyncThunkUntyped(typePrefix, payloadCreator, options); diff --git a/public/app/types/user.ts b/public/app/types/user.ts index 2983c53a781..157bc143c0b 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -64,7 +64,6 @@ export interface Invitee { export interface UsersState { users: OrgUser[]; - invitees: Invitee[]; searchQuery: string; searchPage: number; canInvite: boolean; From 0dfde923e37974eb089816ab545c834cc5c8958a Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Mon, 21 Feb 2022 12:49:25 +0100 Subject: [PATCH 102/104] fix: fix link pointing to breaking changes guideline (#45662) --- .github/workflows/detect-breaking-changes-report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/detect-breaking-changes-report.yml b/.github/workflows/detect-breaking-changes-report.yml index d7a9f052a48..fe5f2f1f490 100644 --- a/.github/workflows/detect-breaking-changes-report.yml +++ b/.github/workflows/detect-breaking-changes-report.yml @@ -91,7 +91,7 @@ jobs: ${{ steps.levitate-run.outputs.message }} [Console output](${{ steps.levitate-run.outputs.job_link }}) - [Read our guideline](../../contribute/breaking-changes-guide.md) + [Read our guideline](https://github.com/grafana/grafana/blob/main/contribute/breaking-changes-guide.md) - name: Remove comment on PR if: ${{ steps.levitate-run.outputs.exit_code == 0 }} From 434697e0fdebed067ac24c96d0dc2dc46b84578a Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Mon, 21 Feb 2022 13:55:16 +0200 Subject: [PATCH 103/104] CI: Introduce docs pipeline (#45454) * Add docs.star * Fixes in drone yaml * Add grabpl * Remove unnecessary steps (cherry picked from commit f2e03cdb107eb283416885a73051c1ff86eb8b7f) * Rename docs pipeline * Exclude docs related steps from PR pipelines * Add packages/** trigger * Remove packages exclusion from pr pipelines --- .drone.star | 3 +- .drone.yml | 87 +++++++++++++++++++++++++------ scripts/drone/pipelines/docs.star | 55 +++++++++++++++++++ scripts/drone/pipelines/pr.star | 13 +++-- 4 files changed, 137 insertions(+), 21 deletions(-) create mode 100644 scripts/drone/pipelines/docs.star diff --git a/.drone.star b/.drone.star index f025f8df415..d2723f88be8 100644 --- a/.drone.star +++ b/.drone.star @@ -6,6 +6,7 @@ load('scripts/drone/pipelines/pr.star', 'pr_pipelines') load('scripts/drone/pipelines/main.star', 'main_pipelines') +load('scripts/drone/pipelines/docs.star', 'docs_pipelines') load('scripts/drone/pipelines/release.star', 'release_pipelines', 'publish_image_pipelines', 'publish_artifacts_pipelines', 'publish_npm_pipelines', 'publish_packages_pipeline') load('scripts/drone/version.star', 'version_branch_pipelines') load('scripts/drone/pipelines/cron.star', 'cronjobs') @@ -13,7 +14,7 @@ load('scripts/drone/vault.star', 'secrets') def main(ctx): edition = 'oss' - return pr_pipelines(edition=edition) + main_pipelines(edition=edition) + release_pipelines() + \ + return pr_pipelines(edition=edition) + docs_pipelines(edition=edition) + main_pipelines(edition=edition) + release_pipelines() + \ publish_image_pipelines('public') + publish_image_pipelines('security') + \ publish_artifacts_pipelines('security') + publish_artifacts_pipelines('public') + \ publish_npm_pipelines('public') + publish_packages_pipeline() + \ diff --git a/.drone.yml b/.drone.yml index ca192e475d0..a3ec9b7f565 100644 --- a/.drone.yml +++ b/.drone.yml @@ -93,6 +93,9 @@ steps: trigger: event: - pull_request + paths: + exclude: + - docs/** type: docker volumes: - host: @@ -288,20 +291,6 @@ steps: failure: always image: grafana/docker-puppeteer:1.0.0 name: test-a11y-frontend -- commands: - - ./scripts/ci-reference-docs-lint.sh ci - depends_on: - - build-frontend - image: grafana/build-container:1.4.9 - name: build-frontend-docs -- commands: - - mkdir -p /hugo/content/docs/grafana - - cp -r docs/sources/* /hugo/content/docs/grafana/latest/ - - cd /hugo && make prod - depends_on: - - build-frontend-docs - image: grafana/docs-base:latest - name: build-docs-website - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ @@ -324,6 +313,9 @@ steps: trigger: event: - pull_request + paths: + exclude: + - docs/** type: docker volumes: - host: @@ -399,6 +391,9 @@ steps: trigger: event: - pull_request + paths: + exclude: + - docs/** type: docker volumes: - host: @@ -413,6 +408,68 @@ volumes: --- depends_on: [] kind: pipeline +name: pr-docs +node: + type: no-parallel +platform: + arch: amd64 + os: linux +services: [] +steps: +- commands: + - mkdir -p bin + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl + - chmod +x bin/grabpl + image: byrnedo/alpine-curl:0.1.8 + name: grabpl +- commands: + - echo $DRONE_RUNNER_NAME + image: alpine:3.15 + name: identify-runner +- commands: + - make gen-go + - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER} + - yarn install --immutable + image: grafana/build-container:1.4.9 + name: initialize +- commands: + - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id + ${DRONE_BUILD_NUMBER} --no-pull-enterprise + depends_on: + - initialize + environment: + NODE_OPTIONS: --max_old_space_size=8192 + image: grafana/build-container:1.4.9 + name: build-frontend +- commands: + - ./scripts/ci-reference-docs-lint.sh ci + depends_on: + - build-frontend + image: grafana/build-container:1.4.9 + name: build-frontend-docs +- commands: + - mkdir -p /hugo/content/docs/grafana + - cp -r docs/sources/* /hugo/content/docs/grafana/latest/ + - cd /hugo && make prod + depends_on: + - build-frontend-docs + image: grafana/docs-base:latest + name: build-docs-website +trigger: + event: + - pull_request + paths: + include: + - docs/** + - packages/** +type: docker +volumes: +- host: + path: /var/run/docker.sock + name: docker +--- +depends_on: [] +kind: pipeline name: main-test node: type: no-parallel @@ -4209,6 +4266,6 @@ kind: secret name: gcp_upload_artifacts_key --- kind: signature -hmac: de49a9ed2dffc5131de65688dc0175fc07029d507d654df360f3e683a138b43c +hmac: 703b9a44a073ab768a774029cee2c3fc7c5fe7b7e01495e18f048bc7d1f007b0 ... diff --git a/scripts/drone/pipelines/docs.star b/scripts/drone/pipelines/docs.star new file mode 100644 index 00000000000..2bbe1088a26 --- /dev/null +++ b/scripts/drone/pipelines/docs.star @@ -0,0 +1,55 @@ +load( + 'scripts/drone/steps/lib.star', + 'initialize_step', + 'download_grabpl_step', + 'lint_frontend_step', + 'codespell_step', + 'shellcheck_step', + 'build_frontend_step', + 'test_frontend_step', + 'build_storybook_step', + 'build_frontend_docs_step', + 'build_docs_website_step', +) + +load( + 'scripts/drone/services/services.star', + 'integration_test_services', + 'ldap_service', +) + +load( + 'scripts/drone/utils/utils.star', + 'pipeline', +) + +ver_mode = 'pr' + +def docs_pipelines(edition): + steps = [download_grabpl_step()] + initialize_step(edition, platform='linux', ver_mode=ver_mode) + steps.extend([ + build_frontend_step(edition=edition, ver_mode=ver_mode), + ]) + + # Insert remaining steps + steps.extend([ + build_frontend_docs_step(edition=edition), + build_docs_website_step(), + ]) + + trigger = { + 'event': [ + 'pull_request', + ], + 'paths': { + 'include': [ + 'docs/**', + 'packages/**', + ], + }, + } + return [ + pipeline( + name='pr-docs', edition=edition, trigger=trigger, services=[], steps=steps, + ), + ] diff --git a/scripts/drone/pipelines/pr.star b/scripts/drone/pipelines/pr.star index 2054098e899..c1cdf182d51 100644 --- a/scripts/drone/pipelines/pr.star +++ b/scripts/drone/pipelines/pr.star @@ -18,8 +18,6 @@ load( 'e2e_tests_step', 'e2e_tests_artifacts', 'build_storybook_step', - 'build_frontend_docs_step', - 'build_docs_website_step', 'copy_packages_for_docker_step', 'build_docker_images_step', 'postgres_integration_tests_step', @@ -100,8 +98,6 @@ def pr_pipelines(edition): e2e_tests_artifacts(edition=edition), build_storybook_step(edition=edition, ver_mode=ver_mode), test_a11y_frontend_step(ver_mode=ver_mode, edition=edition), - build_frontend_docs_step(edition=edition), - build_docs_website_step(), copy_packages_for_docker_step(), build_docker_images_step(edition=edition, ver_mode=ver_mode, archs=['amd64',]), ]) @@ -116,7 +112,14 @@ def pr_pipelines(edition): ]) trigger = { - 'event': ['pull_request',], + 'event': [ + 'pull_request', + ], + 'paths': { + 'exclude': [ + 'docs/**', + ], + }, } return [ From 31090a75eb86cdfc39a00a65f39ae303eeabe0be Mon Sep 17 00:00:00 2001 From: Kristina Date: Mon, 21 Feb 2022 06:09:43 -0600 Subject: [PATCH 104/104] Explore: Save log results sort order (#45423) * Explore: Save log results sort order * Change to radio group, add test and make language consistent with logs plugin --- public/app/features/explore/Logs.test.tsx | 87 +++++++++++++++++++++++ public/app/features/explore/Logs.tsx | 44 +++++++----- public/app/plugins/panel/logs/module.tsx | 4 +- 3 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 public/app/features/explore/Logs.test.tsx diff --git a/public/app/features/explore/Logs.test.tsx b/public/app/features/explore/Logs.test.tsx new file mode 100644 index 00000000000..82d32ffbb29 --- /dev/null +++ b/public/app/features/explore/Logs.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { LoadingState, LogLevel, LogRowModel, MutableDataFrame, toUtc } from '@grafana/data'; + +import { Logs } from './Logs'; + +describe('Logs', () => { + const setup = (propOverrides?: object) => { + const rows = [ + makeLog({ uid: '1', timeEpochMs: 1 }), + makeLog({ uid: '2', timeEpochMs: 2 }), + makeLog({ uid: '3', timeEpochMs: 3 }), + ]; + + return render( + {}} + onChangeTime={() => {}} + clearCache={() => {}} + getFieldLinks={() => { + return []; + }} + /> + ); + }; + + beforeEach(() => { + jest.useFakeTimers('modern'); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should render logs', () => { + setup(); + const logsSection = screen.getByTestId('logRows'); + let logRows = logsSection.querySelectorAll('tr'); + expect(logRows.length).toBe(3); + expect(logRows[0].textContent).toContain('log message 3'); + expect(logRows[2].textContent).toContain('log message 1'); + }); + + it('should flip the order', () => { + setup(); + const oldestFirstSelection = screen.getByLabelText('Oldest first'); + fireEvent.click(oldestFirstSelection); + jest.advanceTimersByTime(1000); + const logsSection = screen.getByTestId('logRows'); + let logRows = logsSection.querySelectorAll('tr'); + expect(logRows.length).toBe(3); + expect(logRows[0].textContent).toContain('log message 1'); + expect(logRows[2].textContent).toContain('log message 3'); + }); +}); + +const makeLog = (overrides: Partial): LogRowModel => { + const uid = overrides.uid || '1'; + const entry = `log message ${uid}`; + return { + uid, + entryFieldIndex: 0, + rowIndex: 0, + dataFrame: new MutableDataFrame(), + logLevel: LogLevel.debug, + entry, + hasAnsi: false, + hasUnescapedContent: false, + labels: {}, + raw: entry, + timeFromNow: '', + timeEpochMs: 1, + timeEpochNs: '1000000', + timeLocal: '', + timeUtc: '', + ...overrides, + }; +}; diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index 9d06682c351..1d6d28b051d 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -43,6 +43,7 @@ const SETTINGS_KEYS = { showTime: 'grafana.explore.logs.showTime', wrapLogMessage: 'grafana.explore.logs.wrapLogMessage', prettifyLogMessage: 'grafana.explore.logs.prettifyLogMessage', + logsSortOrder: 'grafana.explore.logs.sortOrder', }; interface Props extends Themeable2 { @@ -96,7 +97,7 @@ class UnthemedLogs extends PureComponent { prettifyLogMessage: store.getBool(SETTINGS_KEYS.prettifyLogMessage, false), dedupStrategy: LogsDedupStrategy.none, hiddenLogLevels: [], - logsSortOrder: null, + logsSortOrder: store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending, isFlipping: false, showDetectedFields: [], forceEscape: false, @@ -117,10 +118,10 @@ class UnthemedLogs extends PureComponent { // we are using setTimeout here to make sure that disabled button is rendered before the rendering of reordered logs this.flipOrderTimer = window.setTimeout(() => { this.setState((prevState) => { - if (prevState.logsSortOrder === null || prevState.logsSortOrder === LogsSortOrder.Descending) { - return { logsSortOrder: LogsSortOrder.Ascending }; - } - return { logsSortOrder: LogsSortOrder.Descending }; + const newSortOrder = + prevState.logsSortOrder === LogsSortOrder.Descending ? LogsSortOrder.Ascending : LogsSortOrder.Descending; + store.set(SETTINGS_KEYS.logsSortOrder, newSortOrder); + return { logsSortOrder: newSortOrder }; }); }, 0); this.cancelFlippingTimer = window.setTimeout(() => this.setState({ isFlipping: false }), 1000); @@ -291,7 +292,6 @@ class UnthemedLogs extends PureComponent { const { dedupedRows, dedupCount } = this.dedupRows(filteredLogs, dedupStrategy); const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...'; - return ( <> {logsSeries && logsSeries.length ? ( @@ -366,16 +366,26 @@ class UnthemedLogs extends PureComponent {
- + + +
{ clearDetectedFields={this.clearDetectedFields} />
-
+
(LogsPanel) description: '', settings: { options: [ - { value: LogsSortOrder.Descending, label: 'Descending' }, - { value: LogsSortOrder.Ascending, label: 'Ascending' }, + { value: LogsSortOrder.Descending, label: 'Newest first' }, + { value: LogsSortOrder.Ascending, label: 'Oldest first' }, ], }, defaultValue: LogsSortOrder.Descending,