{ExtraFieldElement}
diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricStatEditor/MetricStatEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricStatEditor/MetricStatEditor.test.tsx
index 7b10802d6a9..e97032161c8 100644
--- a/public/app/plugins/datasource/cloudwatch/components/MetricStatEditor/MetricStatEditor.test.tsx
+++ b/public/app/plugins/datasource/cloudwatch/components/MetricStatEditor/MetricStatEditor.test.tsx
@@ -3,10 +3,14 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import selectEvent from 'react-select-event';
+import { config } from '@grafana/runtime';
+
import { MetricStatEditor } from '..';
import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource';
+import { validMetricSearchBuilderQuery } from '../../__mocks__/queries';
import { MetricStat } from '../../types';
+const originalFeatureToggleValue = config.featureToggles.cloudWatchCrossAccountQuerying;
const ds = setupMockedDataSource({
variables: [],
});
@@ -33,6 +37,9 @@ const props = {
};
describe('MetricStatEditor', () => {
+ afterEach(() => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = originalFeatureToggleValue;
+ });
describe('statistics field', () => {
test.each([['Average', 'p23.23', 'p34', '$statistic']])('should accept valid values', async (statistic) => {
const onChange = jest.fn();
@@ -199,4 +206,53 @@ describe('MetricStatEditor', () => {
expect(await screen.findByText(expected)).toBeInTheDocument();
});
});
+
+ describe('account id', () => {
+ it('should set value to "all" when its a monitoring account and no account id is defined in the query', async () => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = true;
+ const onChange = jest.fn();
+ props.datasource.api.isMonitoringAccount = jest.fn().mockResolvedValue(true);
+ props.datasource.api.getAccounts = jest.fn().mockResolvedValue([
+ {
+ value: '123456789',
+ label: 'test-account1',
+ description: '123456789',
+ },
+ {
+ value: '432156789013',
+ label: 'test-account2',
+ description: '432156789013',
+ },
+ ]);
+ await act(async () => {
+ render(
+
+ );
+ });
+ expect(onChange).toHaveBeenCalledWith({ ...validMetricSearchBuilderQuery, accountId: 'all' });
+ expect(await screen.findByText('Account')).toBeInTheDocument();
+ });
+
+ it('should unset value when no accounts were found and an account id is defined in the query', async () => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = true;
+ const onChange = jest.fn();
+ props.datasource.api.isMonitoringAccount = jest.fn().mockResolvedValue(false);
+ props.datasource.api.getAccounts = jest.fn().mockResolvedValue([]);
+ await act(async () => {
+ render(
+
+ );
+ });
+ expect(onChange).toHaveBeenCalledWith({ ...validMetricSearchBuilderQuery, accountId: undefined });
+ expect(await screen.queryByText('Account')).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricStatEditor/MetricStatEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricStatEditor/MetricStatEditor.tsx
index 332f62649d1..72fc3b17817 100644
--- a/public/app/plugins/datasource/cloudwatch/components/MetricStatEditor/MetricStatEditor.tsx
+++ b/public/app/plugins/datasource/cloudwatch/components/MetricStatEditor/MetricStatEditor.tsx
@@ -1,15 +1,17 @@
-import React from 'react';
+import React, { useEffect } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorField, EditorFieldGroup, EditorRow, EditorRows, EditorSwitch } from '@grafana/experimental';
+import { config } from '@grafana/runtime';
import { Select } from '@grafana/ui';
import { Dimensions } from '..';
import { CloudWatchDatasource } from '../../datasource';
-import { useDimensionKeys, useMetrics, useNamespaces } from '../../hooks';
+import { useAccountOptions, useDimensionKeys, useMetrics, useNamespaces } from '../../hooks';
import { standardStatistics } from '../../standardStatistics';
import { MetricStat } from '../../types';
import { appendTemplateVariables, toOption } from '../../utils/utils';
+import { Account } from '../Account';
export type Props = {
refId: string;
@@ -28,10 +30,22 @@ export function MetricStatEditor({
onChange,
onRunQuery,
}: React.PropsWithChildren
) {
- const { region, namespace } = metricStat;
const namespaces = useNamespaces(datasource);
- const metrics = useMetrics(datasource, region, namespace);
+ const metrics = useMetrics(datasource, metricStat);
const dimensionKeys = useDimensionKeys(datasource, { ...metricStat, dimensionFilters: metricStat.dimensions });
+ const accountState = useAccountOptions(datasource.api, metricStat.region);
+
+ useEffect(() => {
+ datasource.api.isMonitoringAccount(metricStat.region).then((isMonitoringAccount) => {
+ if (isMonitoringAccount && !accountState.loading && accountState.value?.length && !metricStat.accountId) {
+ onChange({ ...metricStat, accountId: 'all' });
+ }
+
+ if (!accountState.loading && accountState.value && !accountState.value.length && metricStat.accountId) {
+ onChange({ ...metricStat, accountId: undefined });
+ }
+ });
+ }, [accountState, metricStat, onChange, datasource.api]);
const onMetricStatChange = (metricStat: MetricStat) => {
onChange(metricStat);
@@ -59,6 +73,16 @@ export function MetricStatEditor({
return (
+ {!disableExpressions && config.featureToggles.cloudWatchCrossAccountQuerying && (
+ {
+ onChange({ ...metricStat, accountId });
+ onRunQuery();
+ }}
+ accountOptions={accountState?.value || []}
+ >
+ )}
-
- {!disableExpressions && (
-
+ {!disableExpressions && (
-
- )}
+ )}
+
);
}
diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx
index 0c121d3df42..0b8b92f488f 100644
--- a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx
+++ b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx
@@ -50,6 +50,7 @@ const setup = () => {
datasource.api.getMetrics = jest.fn().mockResolvedValue([]);
datasource.api.getRegions = jest.fn().mockResolvedValue([]);
datasource.api.getDimensionKeys = jest.fn().mockResolvedValue([]);
+ datasource.api.isMonitoringAccount = jest.fn().mockResolvedValue(false);
const props: Props = {
query: {
diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryHeader.test.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryHeader.test.tsx
index ad1763a06af..1f0d639620e 100644
--- a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryHeader.test.tsx
+++ b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryHeader.test.tsx
@@ -40,6 +40,7 @@ describe('MetricsQueryHeader', () => {
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
+ isMonitoringAccount={false}
/>
);
@@ -67,6 +68,7 @@ describe('MetricsQueryHeader', () => {
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
+ isMonitoringAccount={false}
/>
);
@@ -94,6 +96,7 @@ describe('MetricsQueryHeader', () => {
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
+ isMonitoringAccount={false}
/>
);
@@ -122,6 +125,7 @@ describe('MetricsQueryHeader', () => {
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
+ isMonitoringAccount={false}
/>
);
diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryHeader.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryHeader.tsx
index 020f7cd505f..6520db778ae 100644
--- a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryHeader.tsx
+++ b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryHeader.tsx
@@ -2,7 +2,8 @@ import React, { useCallback, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { FlexItem, InlineSelect } from '@grafana/experimental';
-import { Button, ConfirmModal, RadioButtonGroup } from '@grafana/ui';
+import { config } from '@grafana/runtime';
+import { Badge, Button, ConfirmModal, RadioButtonGroup } from '@grafana/ui';
import { CloudWatchDatasource } from '../../datasource';
import { CloudWatchMetricsQuery, CloudWatchQuery, MetricEditorMode, MetricQueryType } from '../../types';
@@ -13,6 +14,7 @@ interface MetricsQueryHeaderProps {
onChange: (query: CloudWatchQuery) => void;
onRunQuery: () => void;
sqlCodeEditorIsDirty: boolean;
+ isMonitoringAccount: boolean;
}
const metricEditorModes: Array> = [
@@ -30,6 +32,7 @@ const MetricsQueryHeader: React.FC = ({
sqlCodeEditorIsDirty,
onChange,
onRunQuery,
+ isMonitoringAccount,
}) => {
const { metricEditorMode, metricQueryType } = query;
const [showConfirm, setShowConfirm] = useState(false);
@@ -49,6 +52,11 @@ const MetricsQueryHeader: React.FC = ({
[setShowConfirm, onChange, sqlCodeEditorIsDirty, query, metricEditorMode, metricQueryType]
);
+ const shouldDisplayMonitoringBadge =
+ query.metricQueryType === MetricQueryType.Search &&
+ isMonitoringAccount &&
+ config.featureToggles.cloudWatchCrossAccountQuerying;
+
return (
<>
= ({
/>
+ {shouldDisplayMonitoringBadge && (
+
+ )}
+
{query.metricQueryType === MetricQueryType.Query && query.metricEditorMode === MetricEditorMode.Code && (
diff --git a/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.test.tsx
index eefeca932f7..0a62600b4d2 100644
--- a/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.test.tsx
+++ b/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.test.tsx
@@ -2,8 +2,16 @@ import { act, render, screen } from '@testing-library/react';
import React from 'react';
import { QueryEditorProps } from '@grafana/data';
+import { config } from '@grafana/runtime';
import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
+import {
+ validLogsQuery,
+ validMetricQueryBuilderQuery,
+ validMetricQueryCodeQuery,
+ validMetricSearchBuilderQuery,
+ validMetricSearchCodeQuery,
+} from '../__mocks__/queries';
import { CloudWatchDatasource } from '../datasource';
import { CloudWatchQuery, CloudWatchJsonData, MetricEditorMode, MetricQueryType } from '../types';
@@ -130,4 +138,82 @@ describe('PanelQueryEditor should render right editor', () => {
expect(screen.getByText('Metric name')).toBeInTheDocument();
});
});
+
+ interface MonitoringBadgeScenario {
+ name: string;
+ query: CloudWatchQuery;
+ toggle: boolean;
+ }
+
+ describe('monitoring badge', () => {
+ let originalValue: boolean | undefined;
+ let datasourceMock: ReturnType;
+ beforeEach(() => {
+ datasourceMock = setupMockedDataSource();
+ datasourceMock.datasource.api.isMonitoringAccount = jest.fn().mockResolvedValue(true);
+ datasourceMock.datasource.api.getMetrics = jest.fn().mockResolvedValue([]);
+ datasourceMock.datasource.api.getDimensionKeys = jest.fn().mockResolvedValue([]);
+ originalValue = config.featureToggles.cloudWatchCrossAccountQuerying;
+ });
+ afterEach(() => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = originalValue;
+ });
+
+ describe('should be displayed when a monitoring account is returned and', () => {
+ const cases: MonitoringBadgeScenario[] = [
+ { name: 'it is logs query and feature is enabled', query: validLogsQuery, toggle: true },
+ {
+ name: 'it is metric search builder query and feature is enabled',
+ query: validMetricSearchBuilderQuery,
+ toggle: true,
+ },
+ {
+ name: 'it is metric search code query and feature is enabled',
+ query: validMetricSearchCodeQuery,
+ toggle: true,
+ },
+ ];
+
+ test.each(cases)('$name', async ({ query, toggle }) => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = toggle;
+ await act(async () => {
+ render();
+ });
+ expect(await screen.getByText('Monitoring account')).toBeInTheDocument();
+ });
+ });
+
+ describe('should not be displayed when a monitoring account is returned and', () => {
+ const cases: MonitoringBadgeScenario[] = [
+ {
+ name: 'it is metric query builder query and toggle is enabled',
+ query: validMetricQueryBuilderQuery,
+ toggle: true,
+ },
+ {
+ name: 'it is metric query code query and toggle is not enabled',
+ query: validMetricQueryCodeQuery,
+ toggle: true,
+ },
+ { name: 'it is logs query and feature is not enabled', query: validLogsQuery, toggle: false },
+ {
+ name: 'it is metric search builder query and feature is not enabled',
+ query: validMetricSearchBuilderQuery,
+ toggle: false,
+ },
+ {
+ name: 'it is metric search code query and feature is not enabled',
+ query: validMetricSearchCodeQuery,
+ toggle: false,
+ },
+ ];
+ test.each(cases)('$name', async ({ query, toggle }) => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = toggle;
+ await act(async () => {
+ render();
+ });
+ expect(await screen.queryByText('Monitoring account')).toBeNull();
+ });
+ });
+ });
});
diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryHeader.test.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryHeader.test.tsx
index 8bd83cf3207..7a2a0b10b40 100644
--- a/public/app/plugins/datasource/cloudwatch/components/QueryHeader.test.tsx
+++ b/public/app/plugins/datasource/cloudwatch/components/QueryHeader.test.tsx
@@ -1,17 +1,25 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
+import selectEvent from 'react-select-event';
+
+import { config } from '@grafana/runtime';
import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
+import { validLogsQuery, validMetricSearchBuilderQuery } from '../__mocks__/queries';
import { CloudWatchLogsQuery, CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType } from '../types';
import QueryHeader from './QueryHeader';
+const originalFeatureToggleValue = config.featureToggles.cloudWatchCrossAccountQuerying;
const ds = setupMockedDataSource({
variables: [],
});
ds.datasource.api.getRegions = jest.fn().mockResolvedValue([]);
describe('QueryHeader', () => {
+ afterEach(() => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = originalFeatureToggleValue;
+ });
it('should display metric options for metrics', async () => {
const query: CloudWatchMetricsQuery = {
queryMode: 'Metrics',
@@ -80,4 +88,102 @@ describe('QueryHeader', () => {
expect(screen.queryByLabelText('Code')).toBeNull();
});
});
+
+ describe('when changing region', () => {
+ const { datasource } = setupMockedDataSource();
+ datasource.api.getRegions = jest.fn().mockResolvedValue([
+ { value: 'us-east-2', label: 'us-east-2' },
+ { value: 'us-east-1', label: 'us-east-1' },
+ ]);
+ it('should reset account id if new region is not monitoring account', async () => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = true;
+ const onChange = jest.fn();
+ datasource.api.isMonitoringAccount = jest.fn().mockResolvedValue(false);
+ render(
+
+ );
+ await waitFor(() => expect(screen.queryByText('us-east-1')).toBeInTheDocument());
+ await act(async () => {
+ await selectEvent.select(screen.getByLabelText(/Region/), 'us-east-2', { container: document.body });
+ });
+ expect(onChange).toHaveBeenCalledWith({
+ ...validMetricSearchBuilderQuery,
+ region: 'us-east-2',
+ accountId: undefined,
+ });
+ });
+
+ it('should not reset account id if new region is a monitoring account', async () => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = true;
+ const onChange = jest.fn();
+ datasource.api.isMonitoringAccount = jest.fn().mockResolvedValue(true);
+
+ render(
+
+ );
+ await waitFor(() => expect(screen.queryByText('us-east-1')).toBeInTheDocument());
+ await act(async () => {
+ await selectEvent.select(screen.getByLabelText(/Region/), 'us-east-2', { container: document.body });
+ });
+ expect(onChange).toHaveBeenCalledWith({
+ ...validMetricSearchBuilderQuery,
+ region: 'us-east-2',
+ accountId: '123',
+ });
+ });
+
+ it('should not call isMonitoringAccount if its a logs query', async () => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = true;
+ const onChange = jest.fn();
+ datasource.api.isMonitoringAccount = jest.fn().mockResolvedValue(true);
+
+ render(
+
+ );
+ await waitFor(() => expect(screen.queryByText('us-east-1')).toBeInTheDocument());
+ await act(async () => {
+ await selectEvent.select(screen.getByLabelText(/Region/), 'us-east-2', { container: document.body });
+ });
+ expect(datasource.api.isMonitoringAccount).not.toHaveBeenCalledWith('us-east-2');
+ });
+
+ it('should not call isMonitoringAccount if feature toggle is not enabled', async () => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = false;
+ const onChange = jest.fn();
+ datasource.api.isMonitoringAccount = jest.fn();
+
+ render(
+
+ );
+ await waitFor(() => expect(screen.queryByText('us-east-1')).toBeInTheDocument());
+ await act(async () => {
+ await selectEvent.select(screen.getByLabelText(/Region/), 'us-east-2', { container: document.body });
+ });
+ expect(datasource.api.isMonitoringAccount).not.toHaveBeenCalledWith();
+ });
+ });
});
diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx
index e8bccd0231b..5d302f5b083 100644
--- a/public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx
+++ b/public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx
@@ -2,10 +2,13 @@ import { pick } from 'lodash';
import React from 'react';
import { SelectableValue, ExploreMode } from '@grafana/data';
-import { EditorHeader, InlineSelect } from '@grafana/experimental';
+import { EditorHeader, InlineSelect, FlexItem } from '@grafana/experimental';
+import { config } from '@grafana/runtime';
+import { Badge } from '@grafana/ui';
import { CloudWatchDatasource } from '../datasource';
-import { useRegions } from '../hooks';
+import { isCloudWatchMetricsQuery } from '../guards';
+import { useIsMonitoringAccount, useRegions } from '../hooks';
import { CloudWatchQuery, CloudWatchQueryMode } from '../types';
import MetricsQueryHeader from './MetricsQueryEditor/MetricsQueryHeader';
@@ -16,7 +19,6 @@ interface QueryHeaderProps {
onChange: (query: CloudWatchQuery) => void;
onRunQuery: () => void;
sqlCodeEditorIsDirty: boolean;
- onRegionChange?: (region: string) => Promise;
}
const apiModes: Array> = [
@@ -26,6 +28,7 @@ const apiModes: Array> = [
const QueryHeader: React.FC = ({ query, sqlCodeEditorIsDirty, datasource, onChange, onRunQuery }) => {
const { queryMode, region } = query;
+ const isMonitoringAccount = useIsMonitoringAccount(datasource.api, query.region);
const [regions, regionIsLoading] = useRegions(datasource);
@@ -38,14 +41,18 @@ const QueryHeader: React.FC = ({ query, sqlCodeEditorIsDirty,
} as CloudWatchQuery);
}
};
-
- const onRegion = async ({ value }: SelectableValue) => {
- onChange({
- ...query,
- region: value,
- } as CloudWatchQuery);
+ const onRegionChange = async (region: string) => {
+ if (config.featureToggles.cloudWatchCrossAccountQuerying && isCloudWatchMetricsQuery(query)) {
+ const isMonitoringAccount = await datasource.api.isMonitoringAccount(region);
+ onChange({ ...query, region, accountId: isMonitoringAccount ? query.accountId : undefined });
+ } else {
+ onChange({ ...query, region });
+ }
};
+ const shouldDisplayMonitoringBadge =
+ queryMode === 'Logs' && isMonitoringAccount && config.featureToggles.cloudWatchCrossAccountQuerying;
+
return (
= ({ query, sqlCodeEditorIsDirty,
value={region}
placeholder="Select region"
allowCustomValue
- onChange={({ value: region }) => region && onRegion({ value: region })}
+ onChange={({ value: region }) => region && onRegionChange(region)}
options={regions}
isLoading={regionIsLoading}
/>
+ {shouldDisplayMonitoringBadge && (
+ <>
+
+
+ >
+ )}
+
{queryMode === ExploreMode.Metrics && (
)}
diff --git a/public/app/plugins/datasource/cloudwatch/components/SQLBuilderEditor/SQLBuilderEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/SQLBuilderEditor/SQLBuilderEditor.test.tsx
index 30148482919..fa4060aaa12 100644
--- a/public/app/plugins/datasource/cloudwatch/components/SQLBuilderEditor/SQLBuilderEditor.test.tsx
+++ b/public/app/plugins/datasource/cloudwatch/components/SQLBuilderEditor/SQLBuilderEditor.test.tsx
@@ -8,7 +8,7 @@ import { CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType, SQLExpressio
const { datasource } = setupMockedDataSource();
-const makeSQLQuery = (sql?: SQLExpression): CloudWatchMetricsQuery => ({
+export const makeSQLQuery = (sql?: SQLExpression): CloudWatchMetricsQuery => ({
queryMode: 'Metrics',
refId: '',
id: '',
diff --git a/public/app/plugins/datasource/cloudwatch/components/SQLBuilderEditor/SQLBuilderSelectRow.tsx b/public/app/plugins/datasource/cloudwatch/components/SQLBuilderEditor/SQLBuilderSelectRow.tsx
index b80a3a4c71b..ab1a8551dc2 100644
--- a/public/app/plugins/datasource/cloudwatch/components/SQLBuilderEditor/SQLBuilderSelectRow.tsx
+++ b/public/app/plugins/datasource/cloudwatch/components/SQLBuilderEditor/SQLBuilderSelectRow.tsx
@@ -48,7 +48,7 @@ const SQLBuilderSelectRow: React.FC = ({ datasource, q
const withSchemaEnabled = isUsingWithSchema(sql.from);
const namespaceOptions = useNamespaces(datasource);
- const metricOptions = useMetrics(datasource, query.region, namespace);
+ const metricOptions = useMetrics(datasource, { region: query.region, namespace });
const existingFilters = useMemo(() => stringArrayToDimensions(schemaLabels ?? []), [schemaLabels]);
const unusedDimensionKeys = useDimensionKeys(datasource, {
region: query.region,
diff --git a/public/app/plugins/datasource/cloudwatch/components/Search.test.tsx b/public/app/plugins/datasource/cloudwatch/components/Search.test.tsx
new file mode 100644
index 00000000000..6312770e21a
--- /dev/null
+++ b/public/app/plugins/datasource/cloudwatch/components/Search.test.tsx
@@ -0,0 +1,42 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+// eslint-disable-next-line lodash/import-scope
+import lodash from 'lodash';
+import React from 'react';
+
+import Search from '../Search';
+
+const defaultProps = {
+ searchPhrase: '',
+ searchFn: jest.fn(),
+};
+const originalDebounce = lodash.debounce;
+
+describe('Search', () => {
+ beforeEach(() => {
+ lodash.debounce = jest.fn().mockImplementation((fn) => {
+ fn.cancel = () => {};
+ return fn;
+ });
+ });
+ afterEach(() => {
+ lodash.debounce = originalDebounce;
+ });
+ it('displays the search phrase passed in if it exists', async () => {
+ render();
+ expect(await screen.findByDisplayValue('testPhrase')).toBeInTheDocument();
+ });
+
+ it('displays placeholder text if search phrase is not passed in', async () => {
+ render();
+ expect(await screen.findByPlaceholderText('search by log group name prefix')).toBeInTheDocument();
+ });
+
+ it('calls a debounced version of searchFn when typed in', async () => {
+ const searchFn = jest.fn();
+ render();
+ await userEvent.type(await screen.findByLabelText('log group search'), 'something');
+ expect(searchFn).toBeCalledWith('s');
+ expect(searchFn).toHaveBeenLastCalledWith('something');
+ });
+});
diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx
index bed581412dd..5e474a1cf51 100644
--- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx
+++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { QueryEditorProps, SelectableValue } from '@grafana/data';
+import { config } from '@grafana/runtime';
import { InlineField } from '@grafana/ui';
import { Dimensions } from '..';
@@ -26,6 +27,9 @@ const queryTypes: Array<{ value: string; label: string }> = [
{ value: VariableQueryType.ResourceArns, label: 'Resource ARNs' },
{ value: VariableQueryType.Statistics, label: 'Statistics' },
{ value: VariableQueryType.LogGroups, label: 'Log Groups' },
+ ...(config.featureToggles.cloudWatchCrossAccountQuerying
+ ? [{ value: VariableQueryType.Accounts, label: 'Accounts' }]
+ : []),
];
export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
@@ -34,7 +38,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
const { region, namespace, metricName, dimensionKey, dimensionFilters } = parsedQuery;
const [regions, regionIsLoading] = useRegions(datasource);
const namespaces = useNamespaces(datasource);
- const metrics = useMetrics(datasource, region, namespace);
+ const metrics = useMetrics(datasource, { region, namespace });
const dimensionKeys = useDimensionKeys(datasource, { region, namespace, metricName });
const keysForDimensionFilter = useDimensionKeys(datasource, { region, namespace, metricName, dimensionFilters });
@@ -90,6 +94,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
VariableQueryType.EC2InstanceAttributes,
VariableQueryType.ResourceArns,
VariableQueryType.LogGroups,
+ VariableQueryType.Accounts,
].includes(parsedQuery.queryType);
const hasNamespaceField = [
VariableQueryType.Metrics,
diff --git a/public/app/plugins/datasource/cloudwatch/components/styles.ts b/public/app/plugins/datasource/cloudwatch/components/styles.ts
new file mode 100644
index 00000000000..3334f8870f0
--- /dev/null
+++ b/public/app/plugins/datasource/cloudwatch/components/styles.ts
@@ -0,0 +1,80 @@
+import { css } from '@emotion/css';
+
+import { GrafanaTheme2 } from '@grafana/data';
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ table: css({
+ width: '100%',
+ tableLayout: 'fixed',
+ }),
+
+ tableScroller: css({
+ maxHeight: '50vh',
+ overflow: 'auto',
+ }),
+
+ row: css({
+ borderBottom: `1px solid ${theme.colors.border.weak}`,
+
+ '&:last-of-type': {
+ borderBottomColor: theme.colors.border.medium,
+ },
+ }),
+
+ cell: css({
+ padding: theme.spacing(1, 1, 1, 0),
+ width: '25%',
+ '&:first-of-type': {
+ width: '50%',
+ padding: theme.spacing(1, 1, 1, 2),
+ },
+ }),
+
+ logGroupSearchResults: css({
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ width: '90%',
+ verticalAlign: 'middle',
+ }),
+
+ modal: css({
+ width: theme.breakpoints.values.lg,
+ }),
+
+ selectAccountId: css({
+ maxWidth: '100px',
+ }),
+
+ logGroupSelectionArea: css({
+ display: 'flex',
+ }),
+
+ resultLimit: css({
+ margin: '4px 0',
+ fontStyle: 'italic',
+ }),
+
+ selectedLogGroup: css({
+ background: theme.colors.background.secondary,
+ borderRadius: theme.shape.borderRadius(),
+ margin: theme.spacing(0.25, 1, 0.25, 0),
+ padding: theme.spacing(0.25, 0, 0.25, 1),
+ color: theme.colors.text.primary,
+ fontSize: theme.typography.size.sm,
+ }),
+
+ search: css({
+ marginRight: '10px',
+ }),
+
+ removeButton: css({
+ verticalAlign: 'middle',
+ }),
+
+ addBtn: css({
+ marginRight: '10px',
+ }),
+});
+
+export default getStyles;
diff --git a/public/app/plugins/datasource/cloudwatch/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/datasource.test.ts
index d176bfc8b97..9cf9c7cb9e1 100644
--- a/public/app/plugins/datasource/cloudwatch/datasource.test.ts
+++ b/public/app/plugins/datasource/cloudwatch/datasource.test.ts
@@ -10,7 +10,7 @@ import {
regionVariable,
} from './__mocks__/CloudWatchDataSource';
import { setupForLogs } from './__mocks__/logsTestContext';
-import { validLogsQuery, validMetricsQuery } from './__mocks__/queries';
+import { validLogsQuery, validMetricSearchBuilderQuery } from './__mocks__/queries';
import { timeRange } from './__mocks__/timeRange';
import { CloudWatchLogsQuery, CloudWatchMetricsQuery, CloudWatchQuery } from './types';
@@ -62,9 +62,9 @@ describe('datasource', () => {
const testTable: Array<{ query: CloudWatchQuery; valid: boolean }> = [
{ query: { ...validLogsQuery, hide: true }, valid: false },
{ query: { ...validLogsQuery, hide: false }, valid: true },
- { query: { ...validMetricsQuery, hide: true }, valid: false },
- { query: { ...validMetricsQuery, hide: true, id: 'queryA' }, valid: true },
- { query: { ...validMetricsQuery, hide: false }, valid: true },
+ { query: { ...validMetricSearchBuilderQuery, hide: true }, valid: false },
+ { query: { ...validMetricSearchBuilderQuery, hide: true, id: 'queryA' }, valid: true },
+ { query: { ...validMetricSearchBuilderQuery, hide: false }, valid: true },
];
test.each(testTable)('should filter out hidden queries unless id is provided', ({ query, valid }) => {
@@ -205,13 +205,9 @@ describe('datasource', () => {
it('should map resource response to metric response', async () => {
const datasource = setupMockedDataSource({
getMock: jest.fn().mockResolvedValue([
+ { value: { namespace: 'AWS/EC2', name: 'CPUUtilization' } },
{
- namespace: 'AWS/EC2',
- name: 'CPUUtilization',
- },
- {
- namespace: 'AWS/Redshift',
- name: 'CPUPercentage',
+ value: { namespace: 'AWS/Redshift', name: 'CPUPercentage' },
},
]),
}).datasource;
diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts
index 59b33011f62..1d354da4311 100644
--- a/public/app/plugins/datasource/cloudwatch/datasource.ts
+++ b/public/app/plugins/datasource/cloudwatch/datasource.ts
@@ -53,7 +53,7 @@ export class CloudWatchDatasource
constructor(
instanceSettings: DataSourceInstanceSettings,
- private readonly templateSrv: TemplateSrv = getTemplateSrv(),
+ readonly templateSrv: TemplateSrv = getTemplateSrv(),
timeSrv: TimeSrv = getTimeSrv()
) {
super(instanceSettings);
@@ -164,7 +164,7 @@ export class CloudWatchDatasource
// public
getVariables() {
- return this.templateSrv.getVariables().map((v) => `$${v.name}`);
+ return this.api.getVariables();
}
getActualRegion(region?: string) {
diff --git a/public/app/plugins/datasource/cloudwatch/dynamic-labels/language.ts b/public/app/plugins/datasource/cloudwatch/dynamic-labels/language.ts
index 690334c5b6a..526fd048527 100644
--- a/public/app/plugins/datasource/cloudwatch/dynamic-labels/language.ts
+++ b/public/app/plugins/datasource/cloudwatch/dynamic-labels/language.ts
@@ -1,5 +1,7 @@
import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api';
+import { config } from '@grafana/runtime';
+
// Dynamic labels: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/graph-dynamic-labels.html
export const DYNAMIC_LABEL_PATTERNS = [
'${DATAPOINT_COUNT}',
@@ -27,6 +29,7 @@ export const DYNAMIC_LABEL_PATTERNS = [
"${PROP('Region')}",
"${PROP('Stat')}",
'${SUM}',
+ ...(config.featureToggles.cloudWatchCrossAccountQuerying ? ["${PROP('AccountLabel')}"] : []),
];
export const language: monacoType.languages.IMonarchLanguage = {
diff --git a/public/app/plugins/datasource/cloudwatch/hooks.test.ts b/public/app/plugins/datasource/cloudwatch/hooks.test.ts
new file mode 100644
index 00000000000..4512ee8723c
--- /dev/null
+++ b/public/app/plugins/datasource/cloudwatch/hooks.test.ts
@@ -0,0 +1,141 @@
+import { renderHook } from '@testing-library/react-hooks';
+
+import { config } from '@grafana/runtime';
+
+import { setupMockedAPI } from './__mocks__/API';
+import {
+ accountIdVariable,
+ dimensionVariable,
+ metricVariable,
+ namespaceVariable,
+ regionVariable,
+ setupMockedDataSource,
+} from './__mocks__/CloudWatchDataSource';
+import { useAccountOptions, useDimensionKeys, useIsMonitoringAccount, useMetrics } from './hooks';
+
+const WAIT_OPTIONS = {
+ timeout: 1000,
+};
+
+const originalFeatureToggleValue = config.featureToggles.cloudWatchCrossAccountQuerying;
+
+describe('hooks', () => {
+ afterEach(() => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = originalFeatureToggleValue;
+ });
+ describe('useIsMonitoringAccount', () => {
+ it('should interpolate variables before calling api', async () => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = true;
+ const { api } = setupMockedAPI({
+ variables: [regionVariable],
+ });
+ const isMonitoringAccountMock = jest.fn().mockResolvedValue(true);
+ api.isMonitoringAccount = isMonitoringAccountMock;
+
+ const { waitForNextUpdate } = renderHook(() => useIsMonitoringAccount(api, `$${regionVariable.name}`));
+ await waitForNextUpdate(WAIT_OPTIONS);
+ expect(isMonitoringAccountMock).toHaveBeenCalledTimes(1);
+ expect(isMonitoringAccountMock).toHaveBeenCalledWith(regionVariable.current.value);
+ });
+ });
+ describe('useMetricNames', () => {
+ it('should interpolate variables before calling api', async () => {
+ const { datasource } = setupMockedDataSource({
+ variables: [regionVariable, namespaceVariable, accountIdVariable],
+ });
+ const getMetricsMock = jest.fn().mockResolvedValue([]);
+ datasource.api.getMetrics = getMetricsMock;
+
+ const { waitForNextUpdate } = renderHook(() =>
+ useMetrics(datasource, {
+ namespace: `$${namespaceVariable.name}`,
+ region: `$${regionVariable.name}`,
+ accountId: `$${accountIdVariable.name}`,
+ })
+ );
+ await waitForNextUpdate(WAIT_OPTIONS);
+ expect(getMetricsMock).toHaveBeenCalledTimes(1);
+ expect(getMetricsMock).toHaveBeenCalledWith({
+ region: regionVariable.current.value,
+ namespace: namespaceVariable.current.value,
+ accountId: accountIdVariable.current.value,
+ });
+ });
+ });
+
+ describe('useDimensionKeys', () => {
+ it('should interpolate variables before calling api', async () => {
+ const { datasource } = setupMockedDataSource({
+ mockGetVariableName: true,
+ variables: [regionVariable, namespaceVariable, accountIdVariable, metricVariable, dimensionVariable],
+ });
+ const getDimensionKeysMock = jest.fn().mockResolvedValue([]);
+ datasource.api.getDimensionKeys = getDimensionKeysMock;
+
+ const { waitForNextUpdate } = renderHook(() =>
+ useDimensionKeys(datasource, {
+ namespace: `$${namespaceVariable.name}`,
+ metricName: `$${metricVariable.name}`,
+ region: `$${regionVariable.name}`,
+ accountId: `$${accountIdVariable.name}`,
+ dimensionFilters: {
+ environment: `$${dimensionVariable.name}`,
+ },
+ })
+ );
+ await waitForNextUpdate(WAIT_OPTIONS);
+ expect(getDimensionKeysMock).toHaveBeenCalledTimes(1);
+ expect(getDimensionKeysMock).toHaveBeenCalledWith({
+ region: regionVariable.current.value,
+ namespace: namespaceVariable.current.value,
+ metricName: metricVariable.current.value,
+ accountId: accountIdVariable.current.value,
+ dimensionFilters: {
+ environment: [dimensionVariable.current.value],
+ },
+ });
+ });
+ });
+
+ describe('useAccountOptions', () => {
+ it('does not call the api if the feature toggle is off', async () => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = false;
+ const { api } = setupMockedAPI({
+ variables: [regionVariable],
+ });
+ const getAccountsMock = jest.fn().mockResolvedValue([{ id: '123', label: 'accountLabel' }]);
+ api.getAccounts = getAccountsMock;
+ const { waitForNextUpdate } = renderHook(() => useAccountOptions(api, `$${regionVariable.name}`));
+ await waitForNextUpdate(WAIT_OPTIONS);
+ expect(getAccountsMock).toHaveBeenCalledTimes(0);
+ });
+
+ it('interpolates region variables before calling the api', async () => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = true;
+ const { api } = setupMockedAPI({
+ variables: [regionVariable],
+ });
+ const getAccountsMock = jest.fn().mockResolvedValue([{ id: '123', label: 'accountLabel' }]);
+ api.getAccounts = getAccountsMock;
+ const { waitForNextUpdate } = renderHook(() => useAccountOptions(api, `$${regionVariable.name}`));
+ await waitForNextUpdate(WAIT_OPTIONS);
+ expect(getAccountsMock).toHaveBeenCalledTimes(1);
+ expect(getAccountsMock).toHaveBeenCalledWith({ region: regionVariable.current.value });
+ });
+
+ it('returns properly formatted account options, and template variables', async () => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = true;
+ const { api } = setupMockedAPI({
+ variables: [regionVariable],
+ });
+ const getAccountsMock = jest.fn().mockResolvedValue([{ id: '123', label: 'accountLabel' }]);
+ api.getAccounts = getAccountsMock;
+ const { waitForNextUpdate, result } = renderHook(() => useAccountOptions(api, `$${regionVariable.name}`));
+ await waitForNextUpdate(WAIT_OPTIONS);
+ expect(result.current.value).toEqual([
+ { label: 'accountLabel', description: '123', value: '123' },
+ { label: 'Template Variables', options: [{ label: '$region', value: '$region' }] },
+ ]);
+ });
+ });
+});
diff --git a/public/app/plugins/datasource/cloudwatch/hooks.ts b/public/app/plugins/datasource/cloudwatch/hooks.ts
index 845b12913c0..288c323e5e8 100644
--- a/public/app/plugins/datasource/cloudwatch/hooks.ts
+++ b/public/app/plugins/datasource/cloudwatch/hooks.ts
@@ -1,10 +1,12 @@
import { useEffect, useState } from 'react';
-import { useDeepCompareEffect } from 'react-use';
+import { useAsyncFn, useDeepCompareEffect } from 'react-use';
import { SelectableValue, toOption } from '@grafana/data';
+import { config } from '@grafana/runtime';
+import { CloudWatchAPI } from './api';
import { CloudWatchDatasource } from './datasource';
-import { GetDimensionKeysRequest } from './types';
+import { GetDimensionKeysRequest, GetMetricsRequest } from './types';
import { appendTemplateVariables } from './utils/utils';
export const useRegions = (datasource: CloudWatchDatasource): [Array>, boolean] => {
@@ -39,31 +41,123 @@ export const useNamespaces = (datasource: CloudWatchDatasource) => {
return namespaces;
};
-export const useMetrics = (datasource: CloudWatchDatasource, region: string, namespace: string | undefined) => {
+export const useMetrics = (datasource: CloudWatchDatasource, { region, namespace, accountId }: GetMetricsRequest) => {
const [metrics, setMetrics] = useState>>([]);
+
+ // need to ensure dependency array below recieves the interpolated value so that the effect is triggered when a variable is changed
+ if (region) {
+ region = datasource.templateSrv.replace(region, {});
+ }
+ if (namespace) {
+ namespace = datasource.templateSrv.replace(namespace, {});
+ }
+
+ if (accountId) {
+ accountId = datasource.templateSrv.replace(accountId, {});
+ }
useEffect(() => {
- datasource.api.getMetrics({ namespace, region }).then((result: Array>) => {
+ datasource.api.getMetrics({ namespace, region, accountId }).then((result: Array>) => {
setMetrics(appendTemplateVariables(datasource, result));
});
- }, [datasource, region, namespace]);
+ }, [datasource, region, namespace, accountId]);
return metrics;
};
export const useDimensionKeys = (
datasource: CloudWatchDatasource,
- { namespace, region, dimensionFilters, metricName }: GetDimensionKeysRequest
+ { region, namespace, metricName, dimensionFilters, accountId }: GetDimensionKeysRequest
) => {
const [dimensionKeys, setDimensionKeys] = useState>>([]);
+ // need to ensure dependency array below revieves the interpolated value so that the effect is triggered when a variable is changed
+ if (region) {
+ region = datasource.templateSrv.replace(region, {});
+ }
+ if (namespace) {
+ namespace = datasource.templateSrv.replace(namespace, {});
+ }
+
+ if (metricName) {
+ metricName = datasource.templateSrv.replace(metricName, {});
+ }
+
+ if (accountId) {
+ accountId = datasource.templateSrv.replace(accountId, {});
+ }
+
+ if (dimensionFilters) {
+ dimensionFilters = datasource.api.convertDimensionFormat(dimensionFilters, {});
+ }
+
// doing deep comparison to avoid making new api calls to list metrics unless dimension filter object props changes
useDeepCompareEffect(() => {
datasource.api
- .getDimensionKeys({ namespace, region, dimensionFilters, metricName })
+ .getDimensionKeys({ namespace, region, metricName, accountId, dimensionFilters })
.then((result: Array>) => {
setDimensionKeys(appendTemplateVariables(datasource, result));
});
- }, [datasource, region, namespace, metricName, dimensionFilters]);
+ }, [datasource, namespace, region, metricName, accountId, dimensionFilters]);
return dimensionKeys;
};
+
+export const useIsMonitoringAccount = (api: CloudWatchAPI, region: string) => {
+ const [isMonitoringAccount, setIsMonitoringAccount] = useState(false);
+ // we call this before the use effect to ensure dependency array below
+ // receives the interpolated value so that the effect is triggered when a variable is changed
+ if (region) {
+ region = api.templateSrv.replace(region, {});
+ }
+ useEffect(() => {
+ if (config.featureToggles.cloudWatchCrossAccountQuerying) {
+ api.isMonitoringAccount(region).then((result) => setIsMonitoringAccount(result));
+ }
+ }, [region, api]);
+
+ return isMonitoringAccount;
+};
+
+export const useAccountOptions = (
+ api: Pick,
+ region: string
+) => {
+ // we call this before the use effect to ensure dependency array below
+ // receives the interpolated value so that the effect is triggered when a variable is changed
+ if (region) {
+ region = api.templateSrv.replace(region, {});
+ }
+
+ const fetchAccountOptions = async () => {
+ if (!config.featureToggles.cloudWatchCrossAccountQuerying) {
+ return Promise.resolve([]);
+ }
+ const accounts = await api.getAccounts({ region });
+ if (accounts.length === 0) {
+ return [];
+ }
+
+ const options: Array> = accounts.map((a) => ({
+ label: a.label,
+ value: a.id,
+ description: a.id,
+ }));
+
+ const variableOptions = api.getVariables().map(toOption);
+
+ const variableOptionGroup: SelectableValue = {
+ label: 'Template Variables',
+ options: variableOptions,
+ };
+
+ return [...options, variableOptionGroup];
+ };
+
+ const [state, doFetch] = useAsyncFn(fetchAccountOptions, [api, region]);
+
+ useEffect(() => {
+ doFetch();
+ }, [api, region, doFetch]);
+
+ return state;
+};
diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts
index 2596de7441f..ef2edf86ac0 100644
--- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts
+++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts
@@ -1,4 +1,4 @@
-import { isEmpty, set } from 'lodash';
+import { set } from 'lodash';
import {
Observable,
of,
@@ -89,6 +89,7 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
queryString: target.expression || '',
refId: target.refId,
logGroupNames: target.logGroupNames || this.defaultLogGroups,
+ logGroups: target.logGroups || [], //todo handle defaults
region: super.replaceVariableAndDisplayWarningIfMulti(
this.getActualRegion(target.region),
options.scopedVars,
@@ -97,14 +98,14 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
),
}));
- const validLogQueries = queryParams.filter((item) => item.logGroupNames?.length);
- if (logQueries.length > validLogQueries.length) {
- return of({ data: [], error: { message: 'Log group is required' } });
- }
+ const hasQueryWithMissingLogGroupSelection = queryParams.some((qp) => {
+ const missingLogGroupNames = qp.logGroupNames.length === 0;
+ const missingLogGroups = qp.logGroups.length === 0;
+ return missingLogGroupNames && missingLogGroups;
+ });
- // No valid targets, return the empty result to save a round trip.
- if (isEmpty(validLogQueries)) {
- return of({ data: [], state: LoadingState.Done });
+ if (hasQueryWithMissingLogGroupSelection) {
+ return of({ data: [], error: { message: 'Log group is required' } });
}
const startTime = new Date();
diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.test.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.test.ts
index 8464a526ebb..cf4d7154e76 100644
--- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.test.ts
+++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.test.ts
@@ -13,8 +13,10 @@ import {
limitVariable,
dimensionVariable,
periodIntervalVariable,
+ accountIdVariable,
} from '../__mocks__/CloudWatchDataSource';
import { setupMockedMetricsQueryRunner } from '../__mocks__/MetricsQueryRunner';
+import { validMetricSearchBuilderQuery } from '../__mocks__/queries';
import { MetricQueryType, MetricEditorMode, CloudWatchMetricsQuery, DataQueryError } from '../types';
describe('CloudWatchMetricsQueryRunner', () => {
@@ -339,6 +341,24 @@ describe('CloudWatchMetricsQueryRunner', () => {
});
describe('template variable interpolation', () => {
+ it('replaceMetricQueryVars interpolates account id if its part of the query', async () => {
+ const { runner } = setupMockedMetricsQueryRunner({
+ variables: [accountIdVariable],
+ });
+
+ const result = runner.replaceMetricQueryVars({ ...validMetricSearchBuilderQuery, accountId: '$accountId' }, {});
+ expect(result.accountId).toBe(accountIdVariable.current.value);
+ });
+
+ it('replaceMetricQueryVars should not change account id if its not part of the query', async () => {
+ const { runner } = setupMockedMetricsQueryRunner({
+ variables: [accountIdVariable],
+ });
+
+ const result = runner.replaceMetricQueryVars({ ...validMetricSearchBuilderQuery, accountId: undefined }, {});
+ expect(result.accountId).toBeUndefined();
+ });
+
it('interpolates variables correctly', async () => {
const { runner, fetchMock, request } = setupMockedMetricsQueryRunner({
variables: [namespaceVariable, metricVariable, labelsVariable, limitVariable],
diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.ts
index 85f30e1c954..978075a3a4d 100644
--- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.ts
+++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.ts
@@ -70,7 +70,7 @@ export class CloudWatchMetricsQueryRunner extends CloudWatchRequest {
.filter(this.filterMetricQuery)
.map((q: CloudWatchMetricsQuery): MetricQuery => {
const migratedQuery = migrateMetricQuery(q);
- const migratedAndIterpolatedQuery = this.replaceMetricQueryVars(migratedQuery, options);
+ const migratedAndIterpolatedQuery = this.replaceMetricQueryVars(migratedQuery, options.scopedVars);
return {
timezoneUTCOffset,
@@ -174,35 +174,25 @@ export class CloudWatchMetricsQueryRunner extends CloudWatchRequest {
return filterMetricsQuery(query);
}
- replaceMetricQueryVars(
- query: CloudWatchMetricsQuery,
- options: DataQueryRequest
- ): CloudWatchMetricsQuery {
- query.region = this.templateSrv.replace(this.getActualRegion(query.region), options.scopedVars);
- query.namespace = this.replaceVariableAndDisplayWarningIfMulti(
- query.namespace,
- options.scopedVars,
- true,
- 'namespace'
- );
- query.metricName = this.replaceVariableAndDisplayWarningIfMulti(
- query.metricName,
- options.scopedVars,
- true,
- 'metric name'
- );
- query.dimensions = this.convertDimensionFormat(query.dimensions ?? {}, options.scopedVars);
- query.statistic = this.templateSrv.replace(query.statistic, options.scopedVars);
- query.period = String(this.getPeriod(query, options)); // use string format for period in graph query, and alerting
- query.id = this.templateSrv.replace(query.id, options.scopedVars);
- query.expression = this.templateSrv.replace(query.expression, options.scopedVars);
- query.sqlExpression = this.templateSrv.replace(query.sqlExpression, options.scopedVars, 'raw');
+ replaceMetricQueryVars(query: CloudWatchMetricsQuery, scopedVars: ScopedVars): CloudWatchMetricsQuery {
+ query.region = this.templateSrv.replace(this.getActualRegion(query.region), scopedVars);
+ query.namespace = this.replaceVariableAndDisplayWarningIfMulti(query.namespace, scopedVars, true, 'namespace');
+ query.metricName = this.replaceVariableAndDisplayWarningIfMulti(query.metricName, scopedVars, true, 'metric name');
+ query.dimensions = this.convertDimensionFormat(query.dimensions ?? {}, scopedVars);
+ query.statistic = this.templateSrv.replace(query.statistic, scopedVars);
+ query.period = String(this.getPeriod(query, scopedVars)); // use string format for period in graph query, and alerting
+ query.id = this.templateSrv.replace(query.id, scopedVars);
+ query.expression = this.templateSrv.replace(query.expression, scopedVars);
+ query.sqlExpression = this.templateSrv.replace(query.sqlExpression, scopedVars, 'raw');
+ if (query.accountId) {
+ query.accountId = this.templateSrv.replace(query.accountId, scopedVars);
+ }
return query;
}
- getPeriod(target: CloudWatchMetricsQuery, options: DataQueryRequest) {
- let period = this.templateSrv.replace(target.period, options.scopedVars);
+ getPeriod(target: CloudWatchMetricsQuery, scopedVars: ScopedVars) {
+ let period = this.templateSrv.replace(target.period, scopedVars);
if (period && period.toLowerCase() !== 'auto') {
let p: number;
if (/^\d+$/.test(period)) {
diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchRequest.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchRequest.ts
index 435a35a0e0e..9b344b767d2 100644
--- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchRequest.ts
+++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchRequest.ts
@@ -116,6 +116,10 @@ export abstract class CloudWatchRequest {
}
return region;
}
+
+ getVariables() {
+ return this.templateSrv.getVariables().map((v) => `$${v.name}`);
+ }
}
const displayCustomError = (title: string, message: string) =>
diff --git a/public/app/plugins/datasource/cloudwatch/tracking.test.ts b/public/app/plugins/datasource/cloudwatch/tracking.test.ts
index 4827233b79a..670ec03ed75 100644
--- a/public/app/plugins/datasource/cloudwatch/tracking.test.ts
+++ b/public/app/plugins/datasource/cloudwatch/tracking.test.ts
@@ -1,6 +1,6 @@
import { DashboardLoadedEvent } from '@grafana/data';
let handler: (e: DashboardLoadedEvent) => {};
-import { reportInteraction } from '@grafana/runtime';
+import { config, reportInteraction } from '@grafana/runtime';
import './module';
import { CloudWatchDashboardLoadedEvent } from './__mocks__/dashboardOnLoadedEvent';
@@ -18,22 +18,26 @@ jest.mock('@grafana/runtime', () => {
};
});
+const originalFeatureToggleValue = config.featureToggles.cloudWatchCrossAccountQuerying;
describe('onDashboardLoadedHandler', () => {
it('should report a `grafana_ds_cloudwatch_dashboard_loaded` interaction ', () => {
+ config.featureToggles.cloudWatchCrossAccountQuerying = true;
handler(CloudWatchDashboardLoadedEvent);
expect(reportInteraction).toHaveBeenCalledWith('grafana_ds_cloudwatch_dashboard_loaded', {
dashboard_id: 'dashboard123',
grafana_version: 'v9.0.0',
org_id: 1,
logs_queries_count: 1,
- metrics_queries_count: 20,
+ metrics_queries_count: 21,
metrics_query_builder_count: 3,
metrics_query_code_count: 4,
metrics_query_count: 7,
- metrics_search_builder_count: 8,
+ metrics_search_builder_count: 9,
metrics_search_code_count: 5,
- metrics_search_count: 13,
- metrics_search_match_exact_count: 8,
+ metrics_search_count: 14,
+ metrics_search_match_exact_count: 9,
+ metrics_queries_with_account_count: 1,
});
+ config.featureToggles.cloudWatchCrossAccountQuerying = originalFeatureToggleValue;
});
});
diff --git a/public/app/plugins/datasource/cloudwatch/tracking.ts b/public/app/plugins/datasource/cloudwatch/tracking.ts
index f9a69d6dbea..a44c6d866a3 100644
--- a/public/app/plugins/datasource/cloudwatch/tracking.ts
+++ b/public/app/plugins/datasource/cloudwatch/tracking.ts
@@ -1,5 +1,5 @@
import { DashboardLoadedEvent } from '@grafana/data';
-import { reportInteraction } from '@grafana/runtime';
+import { config, reportInteraction } from '@grafana/runtime';
import { isCloudWatchLogsQuery, isCloudWatchMetricsQuery } from './guards';
import { migrateMetricQuery } from './migrations/metricQueryMigrations';
@@ -52,6 +52,9 @@ type CloudWatchOnDashboardLoadedTrackingEvent = {
/* The number of "Insights" queries that are using the code mode.
Should be measured in relation to metrics_query_count, e.g metrics_query_builder_count + metrics_query_code_count = metrics_query_count */
metrics_query_code_count: number;
+
+ /* The number of CloudWatch metrics queries that have specified an account in its cross account metric stat query */
+ metrics_queries_with_account_count: number;
};
export const onDashboardLoadedHandler = ({
@@ -93,6 +96,7 @@ export const onDashboardLoadedHandler = ({
metrics_query_count: 0,
metrics_query_builder_count: 0,
metrics_query_code_count: 0,
+ metrics_queries_with_account_count: 0,
};
for (const q of metricsQueries) {
@@ -109,6 +113,9 @@ export const onDashboardLoadedHandler = ({
e.metrics_query_code_count += +Boolean(
q.metricQueryType === MetricQueryType.Query && q.metricEditorMode === MetricEditorMode.Code
);
+ e.metrics_queries_with_account_count += +Boolean(
+ config.featureToggles.cloudWatchCrossAccountQuerying && isMetricSearchBuilder(q) && q.accountId
+ );
}
reportInteraction('grafana_ds_cloudwatch_dashboard_loaded', e);
diff --git a/public/app/plugins/datasource/cloudwatch/types.ts b/public/app/plugins/datasource/cloudwatch/types.ts
index 6347557c126..d656913493d 100644
--- a/public/app/plugins/datasource/cloudwatch/types.ts
+++ b/public/app/plugins/datasource/cloudwatch/types.ts
@@ -1,6 +1,7 @@
import { AwsAuthDataSourceJsonData, AwsAuthDataSourceSecureJsonData } from '@grafana/aws-sdk';
import { DataFrame, DataQuery, DataSourceRef, SelectableValue } from '@grafana/data';
+import { SelectableResourceValue } from './api';
import {
QueryEditorArrayExpression,
QueryEditorFunctionExpression,
@@ -64,6 +65,7 @@ export interface MetricStat {
dimensions?: Dimensions;
matchExact?: boolean;
period?: string;
+ accountId?: string;
statistic?: string;
/**
* @deprecated use statistic
@@ -98,8 +100,10 @@ export interface CloudWatchLogsQuery extends DataQuery {
id: string;
region: string;
expression?: string;
- logGroupNames?: string[];
statsGroups?: string[];
+ logGroups?: SelectableResourceValue[];
+ /* not quite deprecated yet, but will be soon */
+ logGroupNames?: string[];
}
export type CloudWatchQuery = CloudWatchMetricsQuery | CloudWatchLogsQuery | CloudWatchAnnotationQuery;
@@ -217,24 +221,6 @@ export interface GetQueryResultsResponse {
*/
status?: QueryStatus;
}
-
-export interface DescribeLogGroupsRequest {
- /**
- * The prefix to match.
- */
- logGroupNamePrefix?: string;
- /**
- * The token for the next set of items to return. (You received this token from a previous call.)
- */
- nextToken?: string;
- /**
- * The maximum number of items returned. If you don't specify a value, the default is up to 50 items.
- */
- limit?: number;
- refId?: string;
- region: string;
-}
-
export interface TSDBResponse {
results: Record>;
message?: string;
@@ -390,6 +376,7 @@ export enum VariableQueryType {
ResourceArns = 'resourceARNs',
Statistics = 'statistics',
LogGroups = 'logGroups',
+ Accounts = 'accounts',
}
export interface OldVariableQuery extends DataQuery {
@@ -458,6 +445,7 @@ export interface MetricResponse {
export interface ResourceRequest {
region: string;
+ accountId?: string;
}
export interface GetDimensionKeysRequest extends ResourceRequest {
@@ -476,3 +464,33 @@ export interface GetDimensionValuesRequest extends ResourceRequest {
export interface GetMetricsRequest extends ResourceRequest {
namespace?: string;
}
+
+export interface DescribeLogGroupsRequest extends ResourceRequest {
+ logGroupNamePrefix?: string;
+ logGroupPattern?: string;
+ // used by legacy requests, in the future deprecate these fields
+ refId?: string;
+ limit?: number;
+}
+
+export interface Account {
+ arn: string;
+ id: string;
+ label: string;
+ isMonitoringAccount: boolean;
+}
+
+export interface LogGroupResponse {
+ arn: string;
+ name: string;
+}
+
+export interface MetricResponse {
+ name: string;
+ namespace: string;
+}
+
+export interface ResourceResponse {
+ accountId?: string;
+ value: T;
+}
diff --git a/public/app/plugins/datasource/cloudwatch/variables.test.ts b/public/app/plugins/datasource/cloudwatch/variables.test.ts
index 36c1708bd1f..0fc0f282d83 100644
--- a/public/app/plugins/datasource/cloudwatch/variables.test.ts
+++ b/public/app/plugins/datasource/cloudwatch/variables.test.ts
@@ -1,5 +1,6 @@
import { toOption } from '@grafana/data';
+import { setupMockedAPI } from './__mocks__/API';
import { dimensionVariable, labelsVariable, setupMockedDataSource } from './__mocks__/CloudWatchDataSource';
import { VariableQuery, VariableQueryType } from './types';
import { CloudWatchVariableSupport } from './variables';
@@ -22,6 +23,7 @@ mock.datasource.api.getNamespaces = jest.fn().mockResolvedValue([{ label: 'b', v
mock.datasource.api.getMetrics = jest.fn().mockResolvedValue([{ label: 'c', value: 'c' }]);
mock.datasource.api.getDimensionKeys = jest.fn().mockResolvedValue([{ label: 'd', value: 'd' }]);
mock.datasource.api.describeAllLogGroups = jest.fn().mockResolvedValue(['a', 'b'].map(toOption));
+mock.datasource.api.getAccounts = jest.fn().mockResolvedValue([]);
const getDimensionValues = jest.fn().mockResolvedValue([{ label: 'e', value: 'e' }]);
const getEbsVolumeIds = jest.fn().mockResolvedValue([{ label: 'f', value: 'f' }]);
const getEc2InstanceAttribute = jest.fn().mockResolvedValue([{ label: 'g', value: 'g' }]);
@@ -50,6 +52,28 @@ describe('variables', () => {
expect(result).toEqual([{ text: 'd', value: 'd', expandable: true }]);
});
+ describe('accounts', () => {
+ it('should run accounts', async () => {
+ const { api } = setupMockedAPI();
+ const getAccountMock = jest.fn().mockResolvedValue([]);
+ api.getAccounts = getAccountMock;
+ const variables = new CloudWatchVariableSupport(api);
+ await variables.execute({ ...defaultQuery, queryType: VariableQueryType.Accounts });
+ expect(getAccountMock).toHaveBeenCalledWith({ region: defaultQuery.region });
+ });
+
+ it('should map accounts to metric find value and insert "all" option', async () => {
+ const { api } = setupMockedAPI();
+ api.getAccounts = jest.fn().mockResolvedValue([{ id: '123', label: 'Account1' }]);
+ const variables = new CloudWatchVariableSupport(api);
+ const result = await variables.execute({ ...defaultQuery, queryType: VariableQueryType.Accounts });
+ expect(result).toEqual([
+ { text: 'All', value: 'all', expandable: true },
+ { text: 'Account1', value: '123', expandable: true },
+ ]);
+ });
+ });
+
describe('dimension values', () => {
const query = {
...defaultQuery,
diff --git a/public/app/plugins/datasource/cloudwatch/variables.ts b/public/app/plugins/datasource/cloudwatch/variables.ts
index 54339e27e9e..25629c87ff3 100644
--- a/public/app/plugins/datasource/cloudwatch/variables.ts
+++ b/public/app/plugins/datasource/cloudwatch/variables.ts
@@ -1,9 +1,16 @@
import { from, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
-import { CustomVariableSupport, DataQueryRequest, DataQueryResponse } from '@grafana/data';
+import {
+ CustomVariableSupport,
+ DataQueryRequest,
+ DataQueryResponse,
+ MetricFindValue,
+ SelectableValue,
+} from '@grafana/data';
import { CloudWatchAPI } from './api';
+import { ALL_ACCOUNTS_OPTION } from './components/Account';
import { VariableQueryEditor } from './components/VariableQueryEditor/VariableQueryEditor';
import { CloudWatchDatasource } from './datasource';
import { migrateVariableQuery } from './migrations/variableQueryMigrations';
@@ -46,101 +53,68 @@ export class CloudWatchVariableSupport extends CustomVariableSupport ({
- text: s.value,
- value: s.value,
- expandable: true,
- }));
+ return this.api
+ .describeAllLogGroups({
+ region,
+ logGroupNamePrefix: logGroupPrefix,
+ })
+ .then((logGroups) => logGroups.map(selectableValueToMetricFindOption));
}
async handleRegionsQuery() {
- const regions = await this.api.getRegions();
- return regions.map((s) => ({
- text: s.label,
- value: s.value,
- expandable: true,
- }));
+ return this.api.getRegions().then((regions) => regions.map(selectableValueToMetricFindOption));
}
async handleNamespacesQuery() {
- const namespaces = await this.api.getNamespaces();
- return namespaces.map((s) => ({
- text: s.label,
- value: s.value,
- expandable: true,
- }));
+ return this.api.getNamespaces().then((namespaces) => namespaces.map(selectableValueToMetricFindOption));
}
async handleMetricsQuery({ namespace, region }: VariableQuery) {
- const metrics = await this.api.getMetrics({ namespace, region });
- return metrics.map((s) => ({
- text: s.label,
- value: s.value,
- expandable: true,
- }));
+ return this.api.getMetrics({ namespace, region }).then((metrics) => metrics.map(selectableValueToMetricFindOption));
}
async handleDimensionKeysQuery({ namespace, region }: VariableQuery) {
- const keys = await this.api.getDimensionKeys({ namespace, region });
- return keys.map((s) => ({
- text: s.label,
- value: s.value,
- expandable: true,
- }));
+ return this.api.getDimensionKeys({ namespace, region }).then((keys) => keys.map(selectableValueToMetricFindOption));
}
async handleDimensionValuesQuery({ namespace, region, dimensionKey, metricName, dimensionFilters }: VariableQuery) {
if (!dimensionKey || !metricName) {
return [];
}
- const keys = await this.api.getDimensionValues({
- region,
- namespace,
- metricName,
- dimensionKey,
- dimensionFilters,
- });
- return keys.map((s) => ({
- text: s.label,
- value: s.value,
- expandable: true,
- }));
+ return this.api
+ .getDimensionValues({
+ region,
+ namespace,
+ metricName,
+ dimensionKey,
+ dimensionFilters,
+ })
+ .then((values) => values.map(selectableValueToMetricFindOption));
}
async handleEbsVolumeIdsQuery({ region, instanceID }: VariableQuery) {
if (!instanceID) {
return [];
}
- const ids = await this.api.getEbsVolumeIds(region, instanceID);
- return ids.map((s) => ({
- text: s.label,
- value: s.value,
- expandable: true,
- }));
+ return this.api.getEbsVolumeIds(region, instanceID).then((ids) => ids.map(selectableValueToMetricFindOption));
}
async handleEc2InstanceAttributeQuery({ region, attributeName, ec2Filters }: VariableQuery) {
if (!attributeName) {
return [];
}
- const values = await this.api.getEc2InstanceAttribute(region, attributeName, ec2Filters ?? {});
- return values.map((s) => ({
- text: s.label,
- value: s.value,
- expandable: true,
- }));
+ return this.api
+ .getEc2InstanceAttribute(region, attributeName, ec2Filters ?? {})
+ .then((values) => values.map(selectableValueToMetricFindOption));
}
async handleResourceARNsQuery({ region, resourceType, tags }: VariableQuery) {
@@ -148,11 +122,7 @@ export class CloudWatchVariableSupport extends CustomVariableSupport ({
- text: s.label,
- value: s.value,
- expandable: true,
- }));
+ return keys.map(selectableValueToMetricFindOption);
}
async handleStatisticsQuery() {
@@ -162,4 +132,21 @@ export class CloudWatchVariableSupport extends CustomVariableSupport {
+ const metricFindOptions = accounts.map((account) => ({
+ text: account.label,
+ value: account.id,
+ expandable: true,
+ }));
+
+ return metricFindOptions.length ? [this.allMetricFindValue, ...metricFindOptions] : [];
+ });
+ }
+}
+
+function selectableValueToMetricFindOption({ label, value }: SelectableValue): MetricFindValue {
+ return { text: label ?? value ?? '', value: value, expandable: true };
}