AzureMonitor: Migrate Metrics query editor to React (#30783)

* AzureMonitor: Remove anys from datasource to get the inferred type

* AzureMonitor: Cast some datasource types

TODO: we want proper types for these

* AzureMonitor: Initial react Metrics editor components

* start dimension fields

* replace replaceTemplateVariable with datasource.replace, and rename onQueryChange to onChange

* actually just do template variable replacement in the datasource

* don't use azureMonitorIsConfigured

* Refactors, mainly around the metric metadata

 - Convert all the metric metadata options for the Select before its set into state
 - Stop using SelectableValue because it's basically any when all the properties are optional
 - the onChange function passed to the fields now just accepts the direct value, rather than wrapped in a SelectableValue

* added proper fields, and adding and removing for DimensionFields

* Update query with Dimension changes

* Width

* subscription and query type fields

* Should be feature complete now, more or less

* fix missing import

* fix lint issues

* set default subscription ID

* Starting to write some tests

* tests for query editor

* Remove subscription ID from the label in Metrics

But we keep it there for the angular stuff

* MetricsQueryEditor tests

* Update index.test.tsx

* fix tests

* add template variables to dropdowns

* clean up

* update tests

* Reorganise react components

* Group query fields into rows

* Rename Option type, add Azure response type

* Refactor Metrics metric metadata

 - Types the Azure API
 - Moves default metadata values into datasource

* nit

* update test
This commit is contained in:
Josh Hunt 2021-03-11 11:37:39 +00:00 committed by GitHub
parent 05d6d32f3e
commit 13a47aede2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1576 additions and 73 deletions

View File

@ -23,6 +23,8 @@ import { HelpModal } from './components/help/HelpModal';
import { Footer } from './components/Footer/Footer';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { SearchField, SearchResults, SearchResultsFilter } from '../features/search';
import { TimePickerSettings } from 'app/features/dashboard/components/DashboardSettings/TimePickerSettings';
import QueryEditor from 'app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor';
const { SecretFormField } = LegacyForms;
@ -181,4 +183,22 @@ export function registerAngularDirectives() {
['onLoad', { watchDepth: 'reference', wrapApply: true }],
['onChange', { watchDepth: 'reference', wrapApply: true }],
]);
react2AngularDirective('timePickerSettings', TimePickerSettings, [
'renderCount',
'refreshIntervals',
'timePickerHidden',
'nowDelay',
'timezone',
['onTimeZoneChange', { watchDepth: 'reference', wrapApply: true }],
['onRefreshIntervalChange', { watchDepth: 'reference', wrapApply: true }],
['onNowDelayChange', { watchDepth: 'reference', wrapApply: true }],
['onHideTimePickerChange', { watchDepth: 'reference', wrapApply: true }],
]);
react2AngularDirective('azureMonitorQueryEditor', QueryEditor, [
'query',
['datasource', { watchDepth: 'reference' }],
'onChange',
]);
}

View File

@ -0,0 +1,36 @@
import Datasource from '../datasource';
type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
export default function createMockDatasource() {
// We make this a partial so we get _some_ kind of type safety when making this, rather than
// having it be any or casted immediately to Datasource
const _mockDatasource: DeepPartial<Datasource> = {
getVariables: jest.fn().mockReturnValueOnce([]),
azureMonitorDatasource: {
isConfigured() {
return true;
},
getSubscriptions: jest.fn().mockResolvedValueOnce([]),
},
getResourceGroups: jest.fn().mockResolvedValueOnce([]),
getMetricDefinitions: jest.fn().mockResolvedValueOnce([]),
getResourceNames: jest.fn().mockResolvedValueOnce([]),
getMetricNamespaces: jest.fn().mockResolvedValueOnce([]),
getMetricNames: jest.fn().mockResolvedValueOnce([]),
getMetricMetadata: jest.fn().mockResolvedValueOnce({
primaryAggType: 'average',
supportedAggTypes: [],
supportedTimeGrains: [],
dimensions: [],
}),
};
const mockDatasource = _mockDatasource as Datasource;
return mockDatasource;
}

View File

@ -0,0 +1,42 @@
import { AzureMonitorQuery, AzureQueryType } from '../types';
const azureMonitorQuery: AzureMonitorQuery = {
appInsights: undefined, // The actualy shape of this at runtime disagrees with the ts interface
azureLogAnalytics: {
query:
'//change this example to create your own time series query\n<table name> //the table to query (e.g. Usage, Heartbeat, Perf)\n| where $__timeFilter(TimeGenerated) //this is a macro used to show the full charts time range, choose the datetime column here\n| summarize count() by <group by column>, bin(TimeGenerated, $__interval) //change “group by column” to a column in your table, such as “Computer”. The $__interval macro is used to auto-select the time grain. Can also use 1h, 5m etc.\n| order by TimeGenerated asc',
resultFormat: 'time_series',
workspace: 'e3fe4fde-ad5e-4d60-9974-e2f3562ffdf2',
},
azureMonitor: {
// aggOptions: [],
aggregation: 'Average',
allowedTimeGrainsMs: [60000, 300000, 900000, 1800000, 3600000, 21600000, 43200000, 86400000],
// dimensionFilter: '*',
dimensionFilters: [],
metricDefinition: 'Microsoft.Compute/virtualMachines',
metricName: 'Metric A',
metricNamespace: 'Microsoft.Compute/virtualMachines',
resourceGroup: 'grafanastaging',
resourceName: 'grafana',
timeGrain: 'auto',
alias: '',
// timeGrains: [],
top: '10',
},
insightsAnalytics: {
query: '',
resultFormat: 'time_series',
},
queryType: AzureQueryType.AzureMonitor,
refId: 'A',
subscription: 'abc-123',
format: 'dunno lol', // unsure what this value should be. It's not there at runtime, but it's in the ts interface
};
export default azureMonitorQuery;

View File

@ -20,9 +20,16 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend<
url: string;
baseUrl: string;
applicationId: string;
/**
* @deprecated
* TODO: Which one of these values should be used? Was there a migration?
* */
logAnalyticsSubscriptionId: string;
subscriptionId: string;
azureMonitorUrl: string;
defaultOrFirstWorkspace: string;
subscriptionId: string;
cache: Map<string, any>;
constructor(private instanceSettings: DataSourceInstanceSettings<AzureDataSourceJsonData>) {

View File

@ -105,9 +105,9 @@ describe('AzureMonitorDatasource', () => {
it('should return a list of subscriptions', () => {
return ctx.ds.metricFindQuery('subscriptions()').then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toBe(2);
expect(results[0].text).toBe('Primary - sub1');
expect(results[0].text).toBe('Primary');
expect(results[0].value).toBe('sub1');
expect(results[1].text).toBe('Secondary - sub2');
expect(results[1].text).toBe('Secondary');
expect(results[1].value).toBe('sub2');
});
});
@ -545,7 +545,7 @@ describe('AzureMonitorDatasource', () => {
it('should return list of Resource Groups', () => {
return ctx.ds.getSubscriptions().then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(1);
expect(results[0].text).toEqual('Primary Subscription - 99999999-cccc-bbbb-aaaa-9106972f9572');
expect(results[0].text).toEqual('Primary Subscription');
expect(results[0].value).toEqual('99999999-cccc-bbbb-aaaa-9106972f9572');
});
});
@ -856,10 +856,10 @@ describe('AzureMonitorDatasource', () => {
'default',
'UsedCapacity'
)
.then((results: any) => {
.then((results) => {
expect(results.primaryAggType).toEqual('Total');
expect(results.supportedAggTypes.length).toEqual(6);
expect(results.supportedTimeGrains.length).toEqual(4);
expect(results.supportedTimeGrains.length).toEqual(5); // 4 time grains from the API + auto
});
});
});
@ -934,15 +934,15 @@ describe('AzureMonitorDatasource', () => {
expect(results.dimensions).toMatchInlineSnapshot(`
Array [
Object {
"text": "Response type",
"label": "Response type",
"value": "ResponseType",
},
Object {
"text": "Geo type",
"label": "Geo type",
"value": "GeoType",
},
Object {
"text": "API name",
"label": "API name",
"value": "ApiName",
},
]

View File

@ -9,9 +9,10 @@ import {
AzureMonitorMetricDefinitionsResponse,
AzureMonitorResourceGroupsResponse,
AzureQueryType,
AzureMonitorMetricsMetadataResponse,
} from '../types';
import { DataSourceInstanceSettings, ScopedVars, MetricFindValue } from '@grafana/data';
import { getBackendSrv, DataSourceWithBackend, getTemplateSrv } from '@grafana/runtime';
import { getBackendSrv, DataSourceWithBackend, getTemplateSrv, FetchResponse } from '@grafana/runtime';
const defaultDropdownValue = 'select';
@ -224,7 +225,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
.then((result: AzureMonitorMetricDefinitionsResponse) => {
return ResponseParser.parseResponseValues(result, 'type', 'type');
})
.then((result: any) => {
.then((result) => {
return filter(result, (t) => {
for (let i = 0; i < this.supportedMetricNamespaces.length; i++) {
if (t.value.toLowerCase() === this.supportedMetricNamespaces[i].toLowerCase()) {
@ -235,7 +236,7 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
return false;
});
})
.then((result: any) => {
.then((result) => {
let shouldHardcodeBlobStorage = false;
for (let i = 0; i < result.length; i++) {
if (result[i].value === 'Microsoft.Storage/storageAccounts') {
@ -340,8 +341,8 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
this.apiVersion
);
return this.doRequest(url).then((result: any) => {
return ResponseParser.parseMetadata(result, metricName);
return this.doRequest<AzureMonitorMetricsMetadataResponse>(url).then((result) => {
return ResponseParser.parseMetadata(result.data, metricName);
});
}
@ -400,15 +401,15 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<AzureM
return field && field.length > 0;
}
doRequest(url: string, maxRetries = 1): Promise<any> {
doRequest<T = any>(url: string, maxRetries = 1): Promise<FetchResponse<T>> {
return getBackendSrv()
.datasourceRequest({
.datasourceRequest<T>({
url: this.url + url,
method: 'GET',
})
.catch((error: any) => {
if (maxRetries > 0) {
return this.doRequest(url, maxRetries - 1);
return this.doRequest<T>(url, maxRetries - 1);
}
throw error;

View File

@ -1,5 +1,11 @@
import _ from 'lodash';
import TimeGrainConverter from '../time_grain_converter';
import {
AzureMonitorLocalizedValue,
AzureMonitorMetricAvailabilityMetadata,
AzureMonitorMetricsMetadataResponse,
AzureMonitorOption,
} from '../types';
export default class ResponseParser {
static parseResponseValues(
result: any,
@ -45,10 +51,11 @@ export default class ResponseParser {
return list;
}
static parseMetadata(result: any, metricName: string) {
static parseMetadata(result: AzureMonitorMetricsMetadataResponse, metricName: string) {
const defaultAggTypes = ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'];
const metricData = result?.value.find((v) => v.name.value === metricName);
if (!result) {
if (!metricData) {
return {
primaryAggType: '',
supportedAggTypes: defaultAggTypes,
@ -57,20 +64,21 @@ export default class ResponseParser {
};
}
const metricData: any = _.find(result.data.value, (o) => {
return _.get(o, 'name.value') === metricName;
});
return {
primaryAggType: metricData.primaryAggregationType,
supportedAggTypes: metricData.supportedAggregationTypes || defaultAggTypes,
supportedTimeGrains: ResponseParser.parseTimeGrains(metricData.metricAvailabilities || []),
dimensions: ResponseParser.parseDimensions(metricData),
supportedTimeGrains: [
{ label: 'Auto', value: 'auto' },
...ResponseParser.parseTimeGrains(metricData.metricAvailabilities ?? []),
],
dimensions: ResponseParser.parseDimensions(metricData.dimensions ?? []),
};
}
static parseTimeGrains(metricAvailabilities: any[]): Array<{ text: string; value: string }> {
const timeGrains: any[] = [];
static parseTimeGrains(metricAvailabilities: AzureMonitorMetricAvailabilityMetadata[]): AzureMonitorOption[] {
const timeGrains: AzureMonitorOption[] = [];
if (!metricAvailabilities) {
return timeGrains;
}
@ -78,30 +86,22 @@ export default class ResponseParser {
metricAvailabilities.forEach((avail) => {
if (avail.timeGrain) {
timeGrains.push({
text: TimeGrainConverter.createTimeGrainFromISO8601Duration(avail.timeGrain),
label: TimeGrainConverter.createTimeGrainFromISO8601Duration(avail.timeGrain),
value: avail.timeGrain,
});
}
});
return timeGrains;
}
static parseDimensions(metricData: any): Array<{ text: string; value: string }> {
const dimensions: Array<{ text: string; value: string }> = [];
if (!metricData.dimensions || metricData.dimensions.length === 0) {
return dimensions;
}
for (let i = 0; i < metricData.dimensions.length; i++) {
const text = metricData.dimensions[i].localizedValue;
const value = metricData.dimensions[i].value;
dimensions.push({
text: !text ? value : text,
value: value,
});
}
return dimensions;
static parseDimensions(metadataDimensions: AzureMonitorLocalizedValue[]) {
return metadataDimensions.map((dimension) => {
return {
label: dimension.localizedValue || dimension.value,
value: dimension.value,
};
});
}
static parseSubscriptions(result: any): Array<{ text: string; value: string }> {
@ -116,7 +116,7 @@ export default class ResponseParser {
for (let i = 0; i < result.data.value.length; i++) {
if (!_.find(list, ['value', _.get(result.data.value[i], valueFieldName)])) {
list.push({
text: `${_.get(result.data.value[i], textFieldName)} - ${_.get(result.data.value[i], valueFieldName)}`,
text: `${_.get(result.data.value[i], textFieldName)}`,
value: _.get(result.data.value[i], valueFieldName),
});
}

View File

@ -0,0 +1,9 @@
import { InlineField } from '@grafana/ui';
import React from 'react';
import { Props as InlineFieldProps } from '@grafana/ui/src/components/Forms/InlineField';
const DEFAULT_LABEL_WIDTH = 18;
export const Field = (props: InlineFieldProps) => {
return <InlineField labelWidth={DEFAULT_LABEL_WIDTH} {...props} />;
};

View File

@ -0,0 +1,54 @@
import React, { useCallback, useMemo } from 'react';
import { Select } from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { Field } from '../Field';
import { findOption } from '../common';
import { AzureQueryEditorFieldProps, AzureMonitorOption } from '../../types';
interface AggregationFieldProps extends AzureQueryEditorFieldProps {
aggregationOptions: AzureMonitorOption[];
}
const AggregationField: React.FC<AggregationFieldProps> = ({
query,
variableOptionGroup,
onQueryChange,
aggregationOptions,
}) => {
const handleChange = useCallback(
(change: SelectableValue<string>) => {
if (!change.value) {
return;
}
onQueryChange({
...query,
azureMonitor: {
...query.azureMonitor,
aggregation: change.value,
},
});
},
[query]
);
const options = useMemo(() => [...aggregationOptions, variableOptionGroup], [
aggregationOptions,
variableOptionGroup,
]);
return (
<Field label="Aggregation">
<Select
inputId="azure-monitor-metrics-aggregation-field"
value={findOption(aggregationOptions, query.azureMonitor.aggregation)}
onChange={handleChange}
options={options}
width={38}
/>
</Field>
);
};
export default AggregationField;

View File

@ -0,0 +1,89 @@
import React, { useCallback } from 'react';
import { Button, Select, Input, HorizontalGroup, VerticalGroup, InlineLabel } from '@grafana/ui';
import { Field } from '../Field';
import { findOption } from '../common';
import { AzureMetricDimension, AzureMonitorOption, AzureQueryEditorFieldProps } from '../../types';
interface DimensionFieldsProps extends AzureQueryEditorFieldProps {
dimensionOptions: AzureMonitorOption[];
}
const DimensionFields: React.FC<DimensionFieldsProps> = ({ query, dimensionOptions, onQueryChange }) => {
const setDimensionFilters = (newFilters: AzureMetricDimension[]) => {
onQueryChange({
...query,
azureMonitor: {
...query.azureMonitor,
dimensionFilters: newFilters,
},
});
};
const addFilter = useCallback(() => {
setDimensionFilters([
...query.azureMonitor.dimensionFilters,
{
dimension: '',
operator: 'eq',
filter: '',
},
]);
}, [query.azureMonitor.dimensionFilters]);
const removeFilter = (index: number) => {
const newFilters = [...query.azureMonitor.dimensionFilters];
newFilters.splice(index, 1);
setDimensionFilters(newFilters);
};
const onFieldChange = <Key extends keyof AzureMetricDimension>(
filterIndex: number,
fieldName: Key,
value: AzureMetricDimension[Key]
) => {
const newFilters = [...query.azureMonitor.dimensionFilters];
const newFilter = newFilters[filterIndex];
newFilter[fieldName] = value;
setDimensionFilters(newFilters);
};
const onFilterInputChange = (index: number, ev: React.FormEvent) => {
if (ev.target instanceof HTMLInputElement) {
onFieldChange(index, 'filter', ev.target.value);
}
};
return (
<Field label="Dimension">
<VerticalGroup spacing="xs">
{query.azureMonitor.dimensionFilters.map((filter, index) => (
<HorizontalGroup key={index} spacing="xs">
<Select
placeholder="Field"
value={findOption(dimensionOptions, filter.dimension)}
options={dimensionOptions}
onChange={(v) => onFieldChange(index, 'dimension', v.value ?? '')}
width={38}
/>
<InlineLabel aria-label="equals">==</InlineLabel>
<Input placeholder="" value={filter.filter} onChange={(ev) => onFilterInputChange(index, ev)} />
<Button
variant="secondary"
size="md"
icon="trash-alt"
aria-label="Remove"
onClick={() => removeFilter(index)}
></Button>
</HorizontalGroup>
))}
<Button variant="secondary" size="md" onClick={addFilter}>
Add new dimension
</Button>
</VerticalGroup>
</Field>
);
};
export default DimensionFields;

View File

@ -0,0 +1,42 @@
import React, { useCallback, useState } from 'react';
import { Input } from '@grafana/ui';
import { Field } from '../Field';
import { AzureQueryEditorFieldProps } from '../../types';
const LegendFormatField: React.FC<AzureQueryEditorFieldProps> = ({ onQueryChange, query }) => {
const [value, setValue] = useState<string>(query.azureMonitor.alias ?? '');
// As calling onQueryChange initiates a the datasource refresh, we only want to call it once
// the field loses focus
const handleChange = useCallback((ev: React.FormEvent) => {
if (ev.target instanceof HTMLInputElement) {
setValue(ev.target.value);
}
}, []);
const handleBlur = useCallback(() => {
onQueryChange({
...query,
azureMonitor: {
...query.azureMonitor,
alias: value,
},
});
}, [query, value]);
return (
<Field label="Legend Format">
<Input
id="azure-monitor-metrics-legend-field"
placeholder="Alias patterns"
value={value}
onChange={handleChange}
onBlur={handleBlur}
width={38}
/>
</Field>
);
};
export default LegendFormatField;

View File

@ -0,0 +1,85 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Select } from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { Field } from '../Field';
import { findOption, toOption } from '../common';
import { AzureQueryEditorFieldProps, AzureMonitorOption } from '../../types';
const MetricName: React.FC<AzureQueryEditorFieldProps> = ({
query,
datasource,
subscriptionId,
variableOptionGroup,
onQueryChange,
}) => {
const [metricNames, setMetricNames] = useState<AzureMonitorOption[]>([]);
useEffect(() => {
if (
!(
subscriptionId &&
query.azureMonitor.resourceGroup &&
query.azureMonitor.metricDefinition &&
query.azureMonitor.resourceName &&
query.azureMonitor.metricNamespace
)
) {
metricNames.length > 0 && setMetricNames([]);
return;
}
datasource
.getMetricNames(
subscriptionId,
query.azureMonitor.resourceGroup,
query.azureMonitor.metricDefinition,
query.azureMonitor.resourceName,
query.azureMonitor.metricNamespace
)
.then((results) => setMetricNames(results.map(toOption)))
.catch((err) => {
// TODO: handle error
console.error(err);
});
}, [
subscriptionId,
query.azureMonitor.resourceGroup,
query.azureMonitor.metricDefinition,
query.azureMonitor.resourceName,
query.azureMonitor.metricNamespace,
]);
const handleChange = useCallback(
(change: SelectableValue<string>) => {
if (!change.value) {
return;
}
onQueryChange({
...query,
azureMonitor: {
...query.azureMonitor,
metricName: change.value,
},
});
},
[query]
);
const options = useMemo(() => [...metricNames, variableOptionGroup], [metricNames, variableOptionGroup]);
return (
<Field label="Metric">
<Select
inputId="azure-monitor-metrics-metric-field"
value={findOption(metricNames, query.azureMonitor.metricName)}
onChange={handleChange}
options={options}
width={38}
/>
</Field>
);
};
export default MetricName;

View File

@ -0,0 +1,78 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Select } from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { Field } from '../Field';
import { findOption, toOption } from '../common';
import { AzureQueryEditorFieldProps, AzureMonitorOption } from '../../types';
const MetricNamespaceField: React.FC<AzureQueryEditorFieldProps> = ({
query,
datasource,
subscriptionId,
variableOptionGroup,
onQueryChange,
}) => {
const [metricNamespaces, setMetricNamespaces] = useState<AzureMonitorOption[]>([]);
useEffect(() => {
if (!(subscriptionId && query.azureMonitor.resourceGroup, query.azureMonitor.metricDefinition)) {
metricNamespaces.length > 0 && setMetricNamespaces([]);
return;
}
datasource
.getMetricNamespaces(
subscriptionId,
query.azureMonitor.resourceGroup,
query.azureMonitor.metricDefinition,
query.azureMonitor.resourceName
)
.then((results) => setMetricNamespaces(results.map(toOption)))
.catch((err) => {
// TODO: handle error
console.error(err);
});
}, [
subscriptionId,
query.azureMonitor.resourceGroup,
query.azureMonitor.metricDefinition,
query.azureMonitor.resourceName,
]);
const handleChange = useCallback(
(change: SelectableValue<string>) => {
if (!change.value) {
return;
}
onQueryChange({
...query,
azureMonitor: {
...query.azureMonitor,
metricNamespace: change.value,
metricName: 'select',
dimensionFilters: [],
},
});
},
[query]
);
const options = useMemo(() => [...metricNamespaces, variableOptionGroup], [metricNamespaces, variableOptionGroup]);
return (
<Field label="Metric Namespace">
<Select
inputId="azure-monitor-metrics-metric-namespace-field"
value={findOption(metricNamespaces, query.azureMonitor.metricNamespace)}
onChange={handleChange}
options={options}
width={38}
/>
</Field>
);
};
export default MetricNamespaceField;

View File

@ -0,0 +1,109 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import selectEvent from 'react-select-event';
import MetricsQueryEditor from './MetricsQueryEditor';
import mockQuery from '../../__mocks__/query';
import createMockDatasource from '../../__mocks__/datasource';
const variableOptionGroup = {
label: 'Template variables',
options: [],
};
describe('Azure Monitor QueryEditor', () => {
it('should render', async () => {
const mockDatasource = createMockDatasource();
render(
<MetricsQueryEditor
subscriptionId="123"
query={mockQuery}
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={() => {}}
/>
);
await waitFor(() => expect(screen.getByTestId('azure-monitor-metrics-query-editor')).toBeInTheDocument());
});
it('should change the subscription ID when selected', async () => {
const mockDatasource = createMockDatasource();
const onChange = jest.fn();
mockDatasource.azureMonitorDatasource.getSubscriptions = jest.fn().mockResolvedValueOnce([
{
value: 'abc-123',
text: 'Primary Subscription',
},
{
value: 'abc-456',
text: 'Another Subscription',
},
]);
render(
<MetricsQueryEditor
subscriptionId="123"
query={mockQuery}
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
/>
);
const subscriptions = await screen.findByLabelText('Subscription');
await selectEvent.select(subscriptions, 'Another Subscription');
expect(onChange).toHaveBeenCalledWith({
...mockQuery,
subscription: 'abc-456',
azureMonitor: {
...mockQuery.azureMonitor,
resourceGroup: 'select',
metricDefinition: 'select',
resourceName: 'select',
metricName: 'select',
aggregation: '',
timeGrain: '',
dimensionFilters: [],
},
});
});
it('should change the metric name when selected', async () => {
const mockDatasource = createMockDatasource();
const onChange = jest.fn();
mockDatasource.getMetricNames = jest.fn().mockResolvedValueOnce([
{
value: 'metric-a',
text: 'Metric A',
},
{
value: 'metric-b',
text: 'Metric B',
},
]);
render(
<MetricsQueryEditor
subscriptionId="123"
query={mockQuery}
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
/>
);
await waitFor(() => expect(screen.getByTestId('azure-monitor-metrics-query-editor')).toBeInTheDocument());
const metrics = await screen.findByLabelText('Metric');
await selectEvent.select(metrics, 'Metric B');
expect(onChange).toHaveBeenCalledWith({
...mockQuery,
azureMonitor: {
...mockQuery.azureMonitor,
metricName: 'metric-b',
},
});
});
});

View File

@ -0,0 +1,133 @@
import React from 'react';
import Datasource from '../../datasource';
import { AzureMonitorQuery, AzureMonitorOption } from '../../types';
import { useMetricsMetadata } from '../metrics';
import SubscriptionField from '../SubscriptionField';
import MetricNamespaceField from './MetricNamespaceField';
import NamespaceField from './NamespaceField';
import ResourceGroupsField from './ResourceGroupsField';
import ResourceNameField from './ResourceNameField';
import MetricNameField from './MetricNameField';
import AggregationField from './AggregationField';
import TimeGrainField from './TimeGrainField';
import DimensionFields from './DimensionFields';
import TopField from './TopField';
import LegendFormatField from './LegendFormatField';
import { InlineFieldRow } from '@grafana/ui';
interface MetricsQueryEditorProps {
query: AzureMonitorQuery;
datasource: Datasource;
subscriptionId: string;
onChange: (newQuery: AzureMonitorQuery) => void;
variableOptionGroup: { label: string; options: AzureMonitorOption[] };
}
const MetricsQueryEditor: React.FC<MetricsQueryEditorProps> = ({
query,
datasource,
subscriptionId,
variableOptionGroup,
onChange,
}) => {
const metricsMetadata = useMetricsMetadata(datasource, query, subscriptionId, onChange);
return (
<div data-testid="azure-monitor-metrics-query-editor">
<InlineFieldRow>
<SubscriptionField
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
/>
<ResourceGroupsField
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
/>
</InlineFieldRow>
<InlineFieldRow>
<NamespaceField
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
/>
<ResourceNameField
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
/>
</InlineFieldRow>
<InlineFieldRow>
<MetricNamespaceField
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
/>
<MetricNameField
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
/>
</InlineFieldRow>
<InlineFieldRow>
<AggregationField
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
aggregationOptions={metricsMetadata?.aggOptions ?? []}
/>
<TimeGrainField
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
timeGrainOptions={metricsMetadata?.timeGrains ?? []}
/>
</InlineFieldRow>
<DimensionFields
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
dimensionOptions={metricsMetadata?.dimensions ?? []}
/>
<TopField
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
/>
<LegendFormatField
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
/>
</div>
);
};
export default MetricsQueryEditor;

View File

@ -0,0 +1,72 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Select } from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { Field } from '../Field';
import { findOption, toOption } from '../common';
import { AzureQueryEditorFieldProps, AzureMonitorOption } from '../../types';
const NamespaceField: React.FC<AzureQueryEditorFieldProps> = ({
query,
datasource,
subscriptionId,
variableOptionGroup,
onQueryChange,
}) => {
const [namespaces, setNamespaces] = useState<AzureMonitorOption[]>([]);
useEffect(() => {
if (!(subscriptionId && query.azureMonitor.resourceGroup)) {
namespaces.length && setNamespaces([]);
return;
}
datasource
.getMetricDefinitions(subscriptionId, query.azureMonitor.resourceGroup)
.then((results) => setNamespaces(results.map(toOption)))
.catch((err) => {
// TODO: handle error
console.error(err);
});
}, [subscriptionId, query.azureMonitor.resourceGroup]);
const handleChange = useCallback(
(change: SelectableValue<string>) => {
if (!change.value) {
return;
}
onQueryChange({
...query,
azureMonitor: {
...query.azureMonitor,
metricDefinition: change.value,
resourceName: 'select',
metricNamespace: 'select',
metricName: 'select',
aggregation: '',
timeGrain: '',
dimensionFilters: [],
},
});
},
[query]
);
const options = useMemo(() => [...namespaces, variableOptionGroup], [namespaces, variableOptionGroup]);
return (
<Field label="Namespace">
{/* It's expected that the label reads Namespace but the property is metricDefinition */}
<Select
inputId="azure-monitor-metrics-namespace-field"
value={findOption(namespaces, query.azureMonitor.metricDefinition)}
onChange={handleChange}
options={options}
width={38}
/>
</Field>
);
};
export default NamespaceField;

View File

@ -0,0 +1,72 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Select } from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { Field } from '../Field';
import { findOption, toOption } from '../common';
import { AzureQueryEditorFieldProps, AzureMonitorOption } from '../../types';
const ResourceGroupsField: React.FC<AzureQueryEditorFieldProps> = ({
query,
datasource,
subscriptionId,
variableOptionGroup,
onQueryChange,
}) => {
const [resourceGroups, setResourceGroups] = useState<AzureMonitorOption[]>([]);
useEffect(() => {
if (!subscriptionId) {
resourceGroups.length > 0 && setResourceGroups([]);
return;
}
datasource
.getResourceGroups(subscriptionId)
.then((results) => setResourceGroups(results.map(toOption)))
.catch((err) => {
// TODO: handle error
console.error(err);
});
}, [subscriptionId]);
const handleChange = useCallback(
(change: SelectableValue<string>) => {
if (!change.value) {
return;
}
onQueryChange({
...query,
azureMonitor: {
...query.azureMonitor,
resourceGroup: change.value,
metricDefinition: 'select',
resourceName: 'select',
metricNamespace: 'select',
metricName: 'select',
aggregation: '',
timeGrain: '',
dimensionFilters: [],
},
});
},
[query]
);
const options = useMemo(() => [...resourceGroups, variableOptionGroup], [resourceGroups, variableOptionGroup]);
return (
<Field label="Resource Group">
<Select
inputId="azure-monitor-metrics-resource-group-field"
value={findOption(resourceGroups, query.azureMonitor.resourceGroup)}
onChange={handleChange}
options={options}
width={38}
/>
</Field>
);
};
export default ResourceGroupsField;

View File

@ -0,0 +1,71 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Select } from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { Field } from '../Field';
import { findOption, toOption } from '../common';
import { AzureQueryEditorFieldProps, AzureMonitorOption } from '../../types';
const ResourceNameField: React.FC<AzureQueryEditorFieldProps> = ({
query,
datasource,
subscriptionId,
variableOptionGroup,
onQueryChange,
}) => {
const [resourceNames, setResourceNames] = useState<AzureMonitorOption[]>([]);
useEffect(() => {
if (!(subscriptionId && query.azureMonitor.resourceGroup && query.azureMonitor.metricDefinition)) {
resourceNames.length > 0 && setResourceNames([]);
return;
}
datasource
.getResourceNames(subscriptionId, query.azureMonitor.resourceGroup, query.azureMonitor.metricDefinition)
.then((results) => setResourceNames(results.map(toOption)))
.catch((err) => {
// TODO: handle error
console.error(err);
});
}, [subscriptionId, query.azureMonitor.resourceGroup, query.azureMonitor.metricDefinition]);
const handleChange = useCallback(
(change: SelectableValue<string>) => {
if (!change.value) {
return;
}
onQueryChange({
...query,
azureMonitor: {
...query.azureMonitor,
resourceName: change.value,
metricNamespace: 'select',
metricName: 'select',
aggregation: '',
timeGrain: '',
dimensionFilters: [],
},
});
},
[query]
);
const options = useMemo(() => [...resourceNames, variableOptionGroup], [resourceNames, variableOptionGroup]);
return (
<Field label="Resource Name">
<Select
inputId="azure-monitor-metrics-resource-name-field"
value={findOption(resourceNames, query.azureMonitor.resourceName)}
onChange={handleChange}
options={options}
width={38}
/>
</Field>
);
};
export default ResourceNameField;

View File

@ -0,0 +1,70 @@
import React, { useCallback, useMemo } from 'react';
import { Select } from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { Field } from '../Field';
import { findOption } from '../common';
import TimegrainConverter from '../../time_grain_converter';
import { AzureQueryEditorFieldProps, AzureMonitorOption } from '../../types';
interface TimeGrainFieldProps extends AzureQueryEditorFieldProps {
timeGrainOptions: AzureMonitorOption[];
}
const TimeGrainField: React.FC<TimeGrainFieldProps> = ({
query,
timeGrainOptions,
variableOptionGroup,
onQueryChange,
}) => {
const handleChange = useCallback(
(change: SelectableValue<string>) => {
if (!change.value) {
return;
}
onQueryChange({
...query,
azureMonitor: {
...query.azureMonitor,
timeGrain: change.value,
},
});
},
[query]
);
const timeGrains = useMemo(() => {
const autoInterval = TimegrainConverter.findClosestTimeGrain(
'1m',
timeGrainOptions.map((o) => TimegrainConverter.createKbnUnitFromISO8601Duration(o.value)) || [
'1m',
'5m',
'15m',
'30m',
'1h',
'6h',
'12h',
'1d',
]
);
const baseTimeGrains = timeGrainOptions.map((v) => (v.value === 'auto' ? { ...v, description: autoInterval } : v));
return [...baseTimeGrains, variableOptionGroup];
}, [timeGrainOptions, variableOptionGroup]);
return (
<Field label="Time Grain">
<Select
inputId="azure-monitor-metrics-time-grain-field"
value={findOption(timeGrainOptions, query.azureMonitor.timeGrain)}
onChange={handleChange}
options={timeGrains}
width={38}
/>
</Field>
);
};
export default TimeGrainField;

View File

@ -0,0 +1,41 @@
import React, { useCallback, useState } from 'react';
import { Input } from '@grafana/ui';
import { Field } from '../Field';
import { AzureQueryEditorFieldProps } from '../../types';
const TopField: React.FC<AzureQueryEditorFieldProps> = ({ onQueryChange, query }) => {
const [value, setValue] = useState<string>(query.azureMonitor.top ?? '');
// As calling onQueryChange initiates a the datasource refresh, we only want to call it once
// the field loses focus
const handleChange = useCallback((ev: React.FormEvent) => {
if (ev.target instanceof HTMLInputElement) {
setValue(ev.target.value);
}
}, []);
const handleBlur = useCallback(() => {
onQueryChange({
...query,
azureMonitor: {
...query.azureMonitor,
top: value,
},
});
}, [query, value]);
return (
<Field label="Top">
<Input
id="azure-monitor-metrics-top-field"
value={value}
onChange={handleChange}
onBlur={handleBlur}
width={16}
/>
</Field>
);
};
export default TopField;

View File

@ -0,0 +1 @@
export { default } from './MetricsQueryEditor';

View File

@ -0,0 +1,68 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import selectEvent from 'react-select-event';
import QueryEditor from './QueryEditor';
import mockQuery from '../../__mocks__/query';
import createMockDatasource from '../../__mocks__/datasource';
import { AzureQueryType } from '../../types';
const variableOptionGroup = {
label: 'Template variables',
options: [],
};
describe('Azure Monitor QueryEditor', () => {
it('renders the Metrics query editor when the query type is Metrics', async () => {
const mockDatasource = createMockDatasource();
render(
<QueryEditor
query={mockQuery}
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={() => {}}
/>
);
await waitFor(() => expect(screen.getByTestId('azure-monitor-metrics-query-editor')).toBeInTheDocument());
});
it("does not render the Metrics query editor when the query type isn't Metrics", async () => {
const mockDatasource = createMockDatasource();
const logsMockQuery = {
...mockQuery,
queryType: AzureQueryType.LogAnalytics,
};
render(
<QueryEditor
query={logsMockQuery}
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={() => {}}
/>
);
await waitFor(() => expect(screen.queryByTestId('azure-monitor-metrics-query-editor')).not.toBeInTheDocument());
});
it('changes the query type when selected', async () => {
const mockDatasource = createMockDatasource();
const onChange = jest.fn();
render(
<QueryEditor
query={mockQuery}
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
/>
);
await waitFor(() => expect(screen.getByTestId('azure-monitor-query-editor')).toBeInTheDocument());
const metrics = await screen.findByLabelText('Service');
await selectEvent.select(metrics, 'Logs');
expect(onChange).toHaveBeenCalledWith({
...mockQuery,
queryType: AzureQueryType.LogAnalytics,
});
});
});

View File

@ -0,0 +1,62 @@
import React from 'react';
import Datasource from '../../datasource';
import { AzureMonitorQuery, AzureQueryType, AzureMonitorOption } from '../../types';
import MetricsQueryEditor from '../MetricsQueryEditor';
import QueryTypeField from './QueryTypeField';
interface BaseQueryEditorProps {
query: AzureMonitorQuery;
datasource: Datasource;
onChange: (newQuery: AzureMonitorQuery) => void;
variableOptionGroup: { label: string; options: AzureMonitorOption[] };
}
const QueryEditor: React.FC<BaseQueryEditorProps> = ({ query, datasource, onChange }) => {
const subscriptionId = query.subscription || datasource.azureMonitorDatasource.subscriptionId;
const variableOptionGroup = {
label: 'Template Variables',
options: datasource.getVariables().map((v) => ({ label: v, value: v })),
};
return (
<div data-testid="azure-monitor-query-editor">
<QueryTypeField query={query} onQueryChange={onChange} />
<EditorForQueryType
subscriptionId={subscriptionId}
query={query}
datasource={datasource}
onChange={onChange}
variableOptionGroup={variableOptionGroup}
/>
</div>
);
};
interface EditorForQueryTypeProps extends BaseQueryEditorProps {
subscriptionId: string;
}
const EditorForQueryType: React.FC<EditorForQueryTypeProps> = ({
subscriptionId,
query,
datasource,
variableOptionGroup,
onChange,
}) => {
switch (query.queryType) {
case AzureQueryType.AzureMonitor:
return (
<MetricsQueryEditor
subscriptionId={subscriptionId}
query={query}
datasource={datasource}
onChange={onChange}
variableOptionGroup={variableOptionGroup}
/>
);
}
return null;
};
export default QueryEditor;

View File

@ -0,0 +1,45 @@
import React, { useCallback } from 'react';
import { Select } from '@grafana/ui';
import { Field } from '../Field';
import { AzureMonitorQuery, AzureQueryType } from '../../types';
import { SelectableValue } from '@grafana/data';
import { findOption } from '../common';
const QUERY_TYPES = [
{ value: AzureQueryType.AzureMonitor, label: 'Metrics' },
{ value: AzureQueryType.LogAnalytics, label: 'Logs' },
{ value: AzureQueryType.ApplicationInsights, label: 'Application Insights' },
{ value: AzureQueryType.InsightsAnalytics, label: 'Insights Analytics' },
];
interface QueryTypeFieldProps {
query: AzureMonitorQuery;
onQueryChange: (newQuery: AzureMonitorQuery) => void;
}
const QueryTypeField: React.FC<QueryTypeFieldProps> = ({ query, onQueryChange }) => {
const handleChange = useCallback(
(change: SelectableValue<AzureQueryType>) => {
change.value &&
onQueryChange({
...query,
queryType: change.value,
});
},
[query]
);
return (
<Field label="Service">
<Select
inputId="azure-monitor-query-type-field"
value={findOption(QUERY_TYPES, query.queryType)}
options={QUERY_TYPES}
onChange={handleChange}
width={38}
/>
</Field>
);
};
export default QueryTypeField;

View File

@ -0,0 +1 @@
export { default } from './QueryEditor';

View File

@ -0,0 +1,97 @@
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { Select } from '@grafana/ui';
import { AzureMonitorQuery, AzureQueryType, AzureQueryEditorFieldProps, AzureMonitorOption } from '../types';
import { findOption } from './common';
import { Field } from './Field';
interface SubscriptionFieldProps extends AzureQueryEditorFieldProps {
onQueryChange: (newQuery: AzureMonitorQuery) => void;
}
const SubscriptionField: React.FC<SubscriptionFieldProps> = ({
datasource,
query,
variableOptionGroup,
onQueryChange,
}) => {
const [subscriptions, setSubscriptions] = useState<AzureMonitorOption[]>([]);
useEffect(() => {
if (!datasource.azureMonitorDatasource.isConfigured()) {
return;
}
datasource.azureMonitorDatasource.getSubscriptions().then((results) => {
const newSubscriptions = results.map((v) => ({ label: v.text, value: v.value, description: v.value }));
setSubscriptions(newSubscriptions);
// Set a default subscription ID, if we can
let newSubscription = query.subscription;
if (!newSubscription && query.queryType === AzureQueryType.AzureMonitor) {
newSubscription = datasource.azureMonitorDatasource.subscriptionId;
} else if (!query.subscription && query.queryType === AzureQueryType.LogAnalytics) {
newSubscription =
datasource.azureLogAnalyticsDatasource.logAnalyticsSubscriptionId ||
datasource.azureLogAnalyticsDatasource.subscriptionId;
}
if (!newSubscription && newSubscriptions.length > 0) {
newSubscription = newSubscriptions[0].value;
}
newSubscription !== query.subscription &&
onQueryChange({
...query,
subscription: newSubscription,
});
});
}, []);
const handleChange = useCallback(
(change: SelectableValue<string>) => {
if (!change.value) {
return;
}
let newQuery: AzureMonitorQuery = {
...query,
subscription: change.value,
};
if (query.queryType === AzureQueryType.AzureMonitor) {
newQuery.azureMonitor = {
...newQuery.azureMonitor,
resourceGroup: 'select',
metricDefinition: 'select',
resourceName: 'select',
metricName: 'select',
aggregation: '',
timeGrain: '',
dimensionFilters: [],
};
}
onQueryChange(newQuery);
},
[query, onQueryChange]
);
const options = useMemo(() => [...subscriptions, variableOptionGroup], [subscriptions, variableOptionGroup]);
return (
<Field label="Subscription">
<Select
value={findOption(subscriptions, query.subscription)}
inputId="azure-monitor-subscriptions-field"
onChange={handleChange}
options={options}
width={38}
/>
</Field>
);
};
export default SubscriptionField;

View File

@ -0,0 +1,19 @@
import { rangeUtil } from '@grafana/data';
import TimegrainConverter from '../time_grain_converter';
import { AzureMonitorOption } from '../types';
// Defaults to returning a fallback option so the UI still shows the value while the API is loading
export const findOption = (options: AzureMonitorOption[], value: string) =>
options.find((v) => v.value === value) ?? { value, label: value };
export const toOption = (v: { text: string; value: string }) => ({ value: v.value, label: v.text });
export function convertTimeGrainsToMs<T extends { value: string }>(timeGrains: T[]) {
const allowedTimeGrainsMs: number[] = [];
timeGrains.forEach((tg: any) => {
if (tg.value !== 'auto') {
allowedTimeGrainsMs.push(rangeUtil.intervalToMs(TimegrainConverter.createKbnUnitFromISO8601Duration(tg.value)));
}
});
return allowedTimeGrainsMs;
}

View File

@ -0,0 +1,85 @@
import { useState, useEffect } from 'react';
import Datasource from '../datasource';
import { AzureMonitorQuery } from '../types';
import { convertTimeGrainsToMs } from './common';
export interface MetricMetadata {
aggOptions: Array<{ label: string; value: string }>;
timeGrains: Array<{ label: string; value: string }>;
dimensions: Array<{ label: string; value: string }>;
}
export function useMetricsMetadata(
datasource: Datasource,
query: AzureMonitorQuery,
subscriptionId: string,
onQueryChange: (newQuery: AzureMonitorQuery) => void
) {
const [metricMetadata, setMetricMetadata] = useState<MetricMetadata>({
aggOptions: [],
timeGrains: [],
dimensions: [],
});
useEffect(() => {
if (
!(
subscriptionId &&
query.azureMonitor.resourceGroup &&
query.azureMonitor.metricDefinition &&
query.azureMonitor.resourceName &&
query.azureMonitor.metricNamespace &&
query.azureMonitor.metricName
)
) {
return;
}
datasource
.getMetricMetadata(
subscriptionId,
query.azureMonitor.resourceGroup,
query.azureMonitor.metricDefinition,
query.azureMonitor.resourceName,
query.azureMonitor.metricNamespace,
query.azureMonitor.metricName
)
.then((metadata) => {
onQueryChange({
...query,
azureMonitor: {
...query.azureMonitor,
aggregation: metadata.primaryAggType,
timeGrain: 'auto',
allowedTimeGrainsMs: convertTimeGrainsToMs(metadata.supportedTimeGrains),
},
});
// TODO: Move the aggregationTypes and timeGrain defaults into `getMetricMetadata`
const aggregations = (metadata.supportedAggTypes || [metadata.primaryAggType]).map((v) => ({
label: v,
value: v,
}));
setMetricMetadata({
aggOptions: aggregations,
timeGrains: metadata.supportedTimeGrains,
dimensions: metadata.dimensions,
});
})
.catch((err) => {
// TODO: handle error
console.error(err);
});
}, [
subscriptionId,
query.azureMonitor.resourceGroup,
query.azureMonitor.metricDefinition,
query.azureMonitor.resourceName,
query.azureMonitor.metricNamespace,
query.azureMonitor.metricName,
]);
return metricMetadata;
}

View File

@ -13,7 +13,7 @@ import {
ScopedVars,
} from '@grafana/data';
import { forkJoin, Observable, of } from 'rxjs';
import { DataSourceWithBackend } from '@grafana/runtime';
import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/runtime';
import InsightsAnalyticsDatasource from './insights_analytics/insights_analytics_datasource';
import { migrateMetricsDimensionFilters } from './query_ctrl';
import { map } from 'rxjs/operators';
@ -27,7 +27,10 @@ export default class Datasource extends DataSourceApi<AzureMonitorQuery, AzureDa
pseudoDatasource: Record<AzureQueryType, DataSourceWithBackend>;
optionsKey: Record<AzureQueryType, string>;
constructor(instanceSettings: DataSourceInstanceSettings<AzureDataSourceJsonData>) {
constructor(
instanceSettings: DataSourceInstanceSettings<AzureDataSourceJsonData>,
private readonly templateSrv: TemplateSrv = getTemplateSrv()
) {
super(instanceSettings);
this.azureMonitorDatasource = new AzureMonitorDatasource(instanceSettings);
this.appInsightsDatasource = new AppInsightsDatasource(instanceSettings);
@ -190,15 +193,22 @@ export default class Datasource extends DataSourceApi<AzureMonitorQuery, AzureDa
/* Azure Monitor REST API methods */
getResourceGroups(subscriptionId: string) {
return this.azureMonitorDatasource.getResourceGroups(subscriptionId);
return this.azureMonitorDatasource.getResourceGroups(this.replaceTemplateVariable(subscriptionId));
}
getMetricDefinitions(subscriptionId: string, resourceGroup: string) {
return this.azureMonitorDatasource.getMetricDefinitions(subscriptionId, resourceGroup);
return this.azureMonitorDatasource.getMetricDefinitions(
this.replaceTemplateVariable(subscriptionId),
this.replaceTemplateVariable(resourceGroup)
);
}
getResourceNames(subscriptionId: string, resourceGroup: string, metricDefinition: string) {
return this.azureMonitorDatasource.getResourceNames(subscriptionId, resourceGroup, metricDefinition);
return this.azureMonitorDatasource.getResourceNames(
this.replaceTemplateVariable(subscriptionId),
this.replaceTemplateVariable(resourceGroup),
this.replaceTemplateVariable(metricDefinition)
);
}
getMetricNames(
@ -209,20 +219,20 @@ export default class Datasource extends DataSourceApi<AzureMonitorQuery, AzureDa
metricNamespace: string
) {
return this.azureMonitorDatasource.getMetricNames(
subscriptionId,
resourceGroup,
metricDefinition,
resourceName,
metricNamespace
this.replaceTemplateVariable(subscriptionId),
this.replaceTemplateVariable(resourceGroup),
this.replaceTemplateVariable(metricDefinition),
this.replaceTemplateVariable(resourceName),
this.replaceTemplateVariable(metricNamespace)
);
}
getMetricNamespaces(subscriptionId: string, resourceGroup: string, metricDefinition: string, resourceName: string) {
return this.azureMonitorDatasource.getMetricNamespaces(
subscriptionId,
resourceGroup,
metricDefinition,
resourceName
this.replaceTemplateVariable(subscriptionId),
this.replaceTemplateVariable(resourceGroup),
this.replaceTemplateVariable(metricDefinition),
this.replaceTemplateVariable(resourceName)
);
}
@ -235,12 +245,12 @@ export default class Datasource extends DataSourceApi<AzureMonitorQuery, AzureDa
metricName: string
) {
return this.azureMonitorDatasource.getMetricMetadata(
subscriptionId,
resourceGroup,
metricDefinition,
resourceName,
metricNamespace,
metricName
this.replaceTemplateVariable(subscriptionId),
this.replaceTemplateVariable(resourceGroup),
this.replaceTemplateVariable(metricDefinition),
this.replaceTemplateVariable(resourceName),
this.replaceTemplateVariable(metricNamespace),
this.replaceTemplateVariable(metricName)
);
}
@ -271,4 +281,12 @@ export default class Datasource extends DataSourceApi<AzureMonitorQuery, AzureDa
(query) => this.pseudoDatasource[query.queryType].applyTemplateVariables(query, scopedVars) as AzureMonitorQuery
);
}
replaceTemplateVariable(variable: string) {
return this.templateSrv.replace(variable);
}
getVariables() {
return this.templateSrv.getVariables().map((v) => `$${v.name}`);
}
}

View File

@ -1,4 +1,8 @@
<query-editor-row query-ctrl="ctrl" can-collapse="false">
<query-editor-row
query-ctrl="ctrl"
can-collapse="false"
ng-if="!ctrl.reactQueryEditors.includes(ctrl.target.queryType)"
>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-9">Service</label>
@ -519,3 +523,9 @@
</p>
</div>
</query-editor-row>
<!-- Partial migration to React -->
<div ng-if="ctrl.reactQueryEditors.includes(ctrl.target.queryType)">
<azure-monitor-query-editor query="ctrl.target" datasource="ctrl.datasource" on-change="ctrl.handleNewQuery">
</azure-monitor-query-editor>
</div>

View File

@ -7,7 +7,9 @@ import './editor/editor_component';
import { TemplateSrv } from '@grafana/runtime';
import { auto, IPromise } from 'angular';
import { DataFrame, PanelEvents, rangeUtil } from '@grafana/data';
import { AzureQueryType, AzureMetricQuery } from './types';
import { AzureQueryType, AzureMetricQuery, AzureMonitorQuery } from './types';
import { convertTimeGrainsToMs } from './components/common';
import Datasource from './datasource';
export interface ResultFormat {
text: string;
@ -28,6 +30,11 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
{ id: AzureQueryType.InsightsAnalytics, label: 'Insights Analytics' },
];
// Query types that have been migrated to React
reactQueryEditors = [AzureQueryType.AzureMonitor];
// target: AzureMonitorQuery;
target: {
// should be: AzureMonitorQuery
refId: string;
@ -217,7 +224,7 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
oldAzureTimeGrains.length > 0 &&
(!this.target.azureMonitor.allowedTimeGrainsMs || this.target.azureMonitor.allowedTimeGrainsMs.length === 0)
) {
this.target.azureMonitor.allowedTimeGrainsMs = this.convertTimeGrainsToMs(oldAzureTimeGrains);
this.target.azureMonitor.allowedTimeGrainsMs = convertTimeGrainsToMs(oldAzureTimeGrains);
}
if (
@ -225,7 +232,7 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
this.target.appInsights.timeGrains.length > 0 &&
(!this.target.appInsights.allowedTimeGrainsMs || this.target.appInsights.allowedTimeGrainsMs.length === 0)
) {
this.target.appInsights.allowedTimeGrainsMs = this.convertTimeGrainsToMs(this.target.appInsights.timeGrains);
this.target.appInsights.allowedTimeGrainsMs = convertTimeGrainsToMs(this.target.appInsights.timeGrains);
}
}
@ -279,9 +286,9 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
}
}
replace(variable: string) {
replace = (variable: string) => {
return this.templateSrv.replace(variable, this.panelCtrl.panel.scopedVars);
}
};
onQueryTypeChange() {
if (this.target.queryType === 'Azure Log Analytics') {
@ -294,7 +301,18 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
return;
}
return this.datasource.azureMonitorDatasource.getSubscriptions().then((subs: any) => {
// assert the type
if (!(this.datasource instanceof Datasource)) {
return;
}
return this.datasource.azureMonitorDatasource.getSubscriptions().then((subscriptions) => {
// We changed the format in the datasource for the new react stuff, so here we change it back
const subs = subscriptions.map((v) => ({
text: `${v.text} - ${v.value}`,
value: v.value,
}));
this.subscriptions = subs;
if (!this.target.subscription && this.target.queryType === 'Azure Monitor') {
this.target.subscription = this.datasource.azureMonitorDatasource.subscriptionId;
@ -475,7 +493,7 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
.then((metadata: any) => {
this.target.azureMonitor.aggregation = metadata.primaryAggType;
this.target.azureMonitor.timeGrain = 'auto';
this.target.azureMonitor.allowedTimeGrainsMs = this.convertTimeGrainsToMs(metadata.supportedTimeGrains || []);
this.target.azureMonitor.allowedTimeGrainsMs = convertTimeGrainsToMs(metadata.supportedTimeGrains || []);
// HACK: this saves the last metadata values in the panel json ¯\_(ツ)_/¯
const hackState = this.target.azureMonitor as any;
@ -492,6 +510,7 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
.catch(this.handleQueryCtrlError.bind(this));
}
// This is reimplement
convertTimeGrainsToMs(timeGrains: Array<{ text: string; value: string }>) {
const allowedTimeGrainsMs: number[] = [];
timeGrains.forEach((tg: any) => {
@ -683,6 +702,14 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
}
this.refresh();
}
/**
* Receives a full new query object from React and updates it into the Angular controller
*/
handleNewQuery = (newQuery: AzureMonitorQuery) => {
Object.assign(this.target, newQuery);
this.refresh();
};
}
// Modifies the actual query object

View File

@ -1,4 +1,5 @@
import { DataQuery, DataSourceJsonData, DataSourceSettings, TableData } from '@grafana/data';
import Datasource from './datasource';
export type AzureDataSourceSettings = DataSourceSettings<AzureDataSourceJsonData, AzureDataSourceSecureJsonData>;
@ -91,6 +92,30 @@ export interface InsightsAnalyticsQuery {
// Azure Monitor API Types
export interface AzureMonitorMetricsMetadataResponse {
value: AzureMonitorMetricMetadataItem[];
}
export interface AzureMonitorMetricMetadataItem {
id: string;
resourceId: string;
primaryAggregationType: string;
supportedAggregationTypes: string[];
name: AzureMonitorLocalizedValue;
dimensions?: AzureMonitorLocalizedValue[];
metricAvailabilities?: AzureMonitorMetricAvailabilityMetadata[];
}
export interface AzureMonitorMetricAvailabilityMetadata {
timeGrain: string;
retention: string;
}
export interface AzureMonitorLocalizedValue {
value: string;
localizedValue: string;
}
export interface AzureMonitorMetricDefinitionsResponse {
data: {
value: Array<{ name: string; type: string; location?: string }>;
@ -153,3 +178,17 @@ export interface AzureLogsTableColumn {
text: string;
type: string;
}
export interface AzureMonitorOption<T = string> {
label: string;
value: T;
}
export interface AzureQueryEditorFieldProps {
query: AzureMonitorQuery;
datasource: Datasource;
subscriptionId: string;
variableOptionGroup: { label: string; options: AzureMonitorOption[] };
onQueryChange: (newQuery: AzureMonitorQuery) => void;
}