Azure: Support scope selection in Resource Graph queries (#105835)

* Add scope type

* Add scope selector field

* Update docs

* Use the right field component

* Trigger build

* Fix import and increase wait

* i8n
This commit is contained in:
Andreas Christou
2025-06-27 18:08:55 +01:00
committed by GitHub
parent 67a952c34e
commit c48a1aba40
11 changed files with 159 additions and 40 deletions
@@ -253,9 +253,14 @@ By querying ARG, you can query resources with complex filtering, iteratively exp
### Create a Resource Graph query
ARG queries are written in a variant of the [Kusto Query Language (KQL)](https://docs.microsoft.com/en-us/azure/governance/resource-graph/concepts/query-language), but not all Kusto language features are available in ARG.
An Azure Resource Graph query is formatted as table data.
If your Azure credentials grant you access to multiple subscriptions, you can choose multiple subscriptions before entering queries.
If your Azure credentials grant you access to multiple subscriptions, you can choose multiple subscriptions before entering queries. It is also possible to run queries against the directory by changing the scope of the query.
{{% admonition type="note" %}}
Some queries that function at a directory level may not work at a subscription level and vice-versa.
{{% /admonition %}}
### Resource Graph query examples
@@ -221,7 +221,7 @@ describe('Azure monitor datasource', () => {
visitDashboardAtStart: false,
queriesForm: () => {
e2eSelectors.queryEditor.header.select().find('input').type('Azure Resource Graph{enter}');
cy.wait(1000); // Need to wait for code editor to completely load
cy.wait(2000); // Need to wait for code editor to completely load
e2eSelectors.queryEditor.argsQueryEditor.subscriptions.input().find('[aria-label="Clear value"]').click();
e2eSelectors.queryEditor.argsQueryEditor.subscriptions.input().find('input').type('datasources{enter}');
e2e.components.CodeEditor.container().type(
@@ -452,6 +452,11 @@ export interface BuilderQueryExpression {
where?: BuilderQueryEditorWhereExpressionArray;
}
export enum ARGScope {
Directory = 'directory',
Subscription = 'subscription',
}
export interface AzureResourceGraphQuery {
/**
* Azure Resource Graph KQL query to be executed.
@@ -461,6 +466,10 @@ export interface AzureResourceGraphQuery {
* Specifies the format results should be returned as. Defaults to table.
*/
resultFormat?: string;
/**
* Specifies the scope of the query. Defaults to subscription.
*/
scope?: ARGScope;
}
export interface AzureMonitorResource {
@@ -407,6 +407,8 @@ type AzureResourceGraphQuery struct {
Query *string `json:"query,omitempty"`
// Specifies the format results should be returned as. Defaults to table.
ResultFormat *string `json:"resultFormat,omitempty"`
// Specifies the scope of the query. Defaults to subscription.
Scope *ARGScope `json:"scope,omitempty"`
}
// NewAzureResourceGraphQuery creates a new AzureResourceGraphQuery object.
@@ -414,6 +416,13 @@ func NewAzureResourceGraphQuery() *AzureResourceGraphQuery {
return &AzureResourceGraphQuery{}
}
type ARGScope string
const (
ARGScopeSubscription ARGScope = "subscription"
ARGScopeDirectory ARGScope = "directory"
)
// Application Insights Traces sub-query properties
type AzureTracesQuery struct {
// Specifies the format results should be returned as.
@@ -1,19 +1,20 @@
import { startsWith, includes, find, filter } from 'lodash';
import { filter, find, includes, startsWith } from 'lodash';
import { ScopedVars } from '@grafana/data';
import { getTemplateSrv, DataSourceWithBackend, TemplateSrv } from '@grafana/runtime';
import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/runtime';
import { resourceTypes } from '../azureMetadata';
import { ARGScope } from '../dataquery.gen';
import {
AzureMonitorQuery,
AzureMonitorDataSourceJsonData,
AzureQueryType,
RawAzureResourceGroupItem,
AzureGetResourceNamesQuery,
AzureMonitorDataSourceInstanceSettings,
RawAzureResourceItem,
AzureGraphResponse,
AzureMonitorDataSourceInstanceSettings,
AzureMonitorDataSourceJsonData,
AzureMonitorQuery,
AzureQueryType,
AzureResourceGraphOptions,
RawAzureResourceGroupItem,
RawAzureResourceItem,
RawAzureSubscriptionItem,
} from '../types';
import { interpolateVariable, replaceTemplateVariables, routeNames } from '../utils/common';
@@ -33,7 +34,10 @@ export default class AzureResourceGraphDatasource extends DataSourceWithBackend<
}
filterQuery(item: AzureMonitorQuery): boolean {
return !!item.azureResourceGraph?.query && !!item.subscriptions && item.subscriptions.length > 0;
return (
!!item.azureResourceGraph?.query &&
(item.azureResourceGraph.scope === ARGScope.Directory || (!!item.subscriptions && item.subscriptions.length > 0))
);
}
applyTemplateVariables(target: AzureMonitorQuery, scopedVars: ScopedVars): AzureMonitorQuery {
@@ -61,6 +65,7 @@ export default class AzureResourceGraphDatasource extends DataSourceWithBackend<
azureResourceGraph: {
resultFormat: 'table',
query,
scope: item.scope,
},
};
}
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import createMockDatasource from '../../__mocks__/datasource';
import createMockQuery from '../../__mocks__/query';
import { ARGScope } from '../../dataquery.gen';
import { selectors } from '../../e2e/selectors';
import ArgQueryEditor from './ArgQueryEditor';
@@ -30,6 +31,20 @@ const defaultProps = {
};
describe('ArgQueryEditor', () => {
beforeAll(() => {
const mockGetBoundingClientRect = jest.fn(() => ({
width: 120,
height: 120,
top: 0,
left: 0,
bottom: 0,
right: 0,
}));
Object.defineProperty(Element.prototype, 'getBoundingClientRect', {
value: mockGetBoundingClientRect,
});
});
it('should render', async () => {
render(<ArgQueryEditor {...defaultProps} />);
expect(
@@ -37,6 +52,32 @@ describe('ArgQueryEditor', () => {
).toBeInTheDocument();
});
it('should change the scope to directory', async () => {
const datasource = createMockDatasource({
getSubscriptions: jest.fn().mockResolvedValue([{ value: 'foo' }]),
});
const onChange = jest.fn();
render(<ArgQueryEditor {...defaultProps} datasource={datasource} onChange={onChange} />);
expect(await screen.findByTestId(selectors.components.queryEditor.argsQueryEditor.scope.input)).toBeInTheDocument();
const scopeSelector = screen.getByTestId(selectors.components.queryEditor.argsQueryEditor.scope.input);
await userEvent.click(scopeSelector);
const directoryOption = await screen.findByRole('option', { name: 'Directory' });
await userEvent.click(directoryOption);
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
azureResourceGraph: {
query: 'Resources | summarize count()',
resultFormat: 'table',
scope: ARGScope.Directory,
},
subscriptions: [],
})
);
});
it('should select a subscription from the fetched array', async () => {
const datasource = createMockDatasource({
getSubscriptions: jest.fn().mockResolvedValue([{ value: 'foo' }]),
@@ -1,11 +1,16 @@
import { intersection } from 'lodash';
import { useState, useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { t } from '@grafana/i18n';
import { EditorFieldGroup, EditorRow, EditorRows } from '@grafana/plugin-ui';
import { Combobox } from '@grafana/ui';
import { ARGScope } from '../../dataquery.gen';
import Datasource from '../../datasource';
import { selectors } from '../../e2e/selectors';
import { AzureMonitorErrorish, AzureMonitorOption, AzureMonitorQuery } from '../../types';
import { Field } from '../shared/Field';
import QueryField from './QueryField';
import SubscriptionField from './SubscriptionField';
@@ -54,44 +59,71 @@ const ArgQueryEditor = ({
}: ArgQueryEditorProps) => {
const [subscriptions, setSubscriptions] = useState<AzureMonitorOption[]>([]);
useMemo(() => {
datasource
.getSubscriptions()
.then((results) => {
const selectAllSubscriptionOption = [
{ label: 'Select all subscriptions', value: 'Select all subscriptions', description: 'Select all' },
];
const fetchedSubscriptions = results.map((v) => ({ label: v.text, value: v.value, description: v.value }));
setSubscriptions(selectAllSubscriptionOption.concat(fetchedSubscriptions));
setError(ERROR_SOURCE, undefined);
if (query.azureResourceGraph?.scope !== ARGScope.Directory) {
datasource
.getSubscriptions()
.then((results) => {
const selectAllSubscriptionOption = [
{ label: 'Select all subscriptions', value: 'Select all subscriptions', description: 'Select all' },
];
const fetchedSubscriptions = results.map((v) => ({ label: v.text, value: v.value, description: v.value }));
setSubscriptions(selectAllSubscriptionOption.concat(fetchedSubscriptions));
setError(ERROR_SOURCE, undefined);
onChange({
...query,
subscriptions: selectSubscriptions(
fetchedSubscriptions.map((v) => v.value),
query.subscriptions,
query.subscription
),
});
})
.catch((err) => setError(ERROR_SOURCE, err));
onChange({
...query,
subscriptions: selectSubscriptions(
fetchedSubscriptions.map((v) => v.value),
query.subscriptions,
query.subscription
),
});
})
.catch((err) => setError(ERROR_SOURCE, err));
}
// We are only interested in re-fetching subscriptions if the data source changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [datasource]);
}, [datasource, query?.azureResourceGraph?.scope]);
const onChangeScope = (change: SelectableValue<ARGScope>) => {
onChange({
...query,
azureResourceGraph: {
...query.azureResourceGraph,
scope: change.value,
},
subscriptions: [],
});
};
return (
<span data-testid={selectors.components.queryEditor.argsQueryEditor.container.input}>
<EditorRows>
<EditorRow>
<EditorFieldGroup>
<SubscriptionField
subscriptions={subscriptions}
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
/>
<Field label={t('components.scope-selector.label', 'Scope')}>
<Combobox
onChange={onChangeScope}
options={[
{ value: ARGScope.Directory, label: 'Directory' },
{ value: ARGScope.Subscription, label: 'Subscription' },
]}
value={query.azureResourceGraph?.scope || ARGScope.Subscription}
width={20}
data-testid={selectors.components.queryEditor.argsQueryEditor.scope.input}
/>
</Field>
{query?.azureResourceGraph?.scope !== ARGScope.Directory ? (
<SubscriptionField
subscriptions={subscriptions}
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
/>
) : null}
</EditorFieldGroup>
</EditorRow>
</EditorRows>
@@ -271,11 +271,14 @@ composableKinds: DataQuery: {
timeFilter?: #BuilderQueryEditorWhereExpressionArray
} @cuetsy(kind="interface")
#ARGScope: "subscription" | "directory" @cuetsy(kind="enum", memberNames="Subscription|Directory")
#AzureResourceGraphQuery: {
// Azure Resource Graph KQL query to be executed.
query?: string
// Specifies the format results should be returned as. Defaults to table.
resultFormat?: string
// Specifies the scope of the query. Defaults to subscription.
scope?: #ARGScope
} @cuetsy(kind="interface")
#AzureMonitorResource: {
@@ -450,6 +450,11 @@ export interface BuilderQueryExpression {
where?: BuilderQueryEditorWhereExpressionArray;
}
export enum ARGScope {
Directory = 'directory',
Subscription = 'subscription',
}
export interface AzureResourceGraphQuery {
/**
* Azure Resource Graph KQL query to be executed.
@@ -459,6 +464,10 @@ export interface AzureResourceGraphQuery {
* Specifies the format results should be returned as. Defaults to table.
*/
resultFormat?: string;
/**
* Specifies the scope of the query. Defaults to subscription.
*/
scope?: ARGScope;
}
export interface AzureMonitorResource {
@@ -84,6 +84,9 @@ export const components = {
container: {
input: 'data-testid azure-monitor-arg-query-editor',
},
scope: {
input: 'data-testid azure-monitor-arg-query-editor-scope',
},
subscriptions: {
input: 'data-testid azure-monitor-args-subscription',
},
@@ -222,6 +222,9 @@
"text-no-resources": "No resources found",
"title-error-occurred": "An error occurred while requesting resources from Azure Monitor"
},
"scope-selector": {
"label": "Scope"
},
"search": {
"aria-label-resource-search": "Resource search",
"placeholder-resource-search": "Search for a resource"