Azure Monitor: Add logs query builder (#99055)

pull/99135/head
Alyssa (Bull) Joyner 2 months ago committed by GitHub
parent 44ca402116
commit 3b73ebb210
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  2. 8
      e2e/cloud-plugins-suite/azure-monitor.spec.ts
  3. 5
      packages/grafana-data/src/types/featureToggles.gen.ts
  4. 164
      packages/grafana-schema/src/raw/composable/azuremonitor/dataquery/x/AzureMonitorDataQuery_types.gen.ts
  5. 7
      pkg/services/featuremgmt/registry.go
  6. 1
      pkg/services/featuremgmt/toggles_gen.csv
  7. 4
      pkg/services/featuremgmt/toggles_gen.go
  8. 16
      pkg/services/featuremgmt/toggles_gen.json
  9. 244
      pkg/tsdb/azuremonitor/kinds/dataquery/types_dataquery_gen.go
  10. 62
      pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go
  11. 41
      pkg/tsdb/azuremonitor/loganalytics/utils.go
  12. 116
      pkg/tsdb/azuremonitor/types/types.go
  13. 3
      public/app/plugins/datasource/azuremonitor/azure_log_analytics/azure_log_analytics_datasource.ts
  14. 152
      public/app/plugins/datasource/azuremonitor/components/LogsQueryBuilder/AggregateItem.tsx
  15. 108
      public/app/plugins/datasource/azuremonitor/components/LogsQueryBuilder/AggregationSection.tsx
  16. 197
      public/app/plugins/datasource/azuremonitor/components/LogsQueryBuilder/AzureMonitorKustoQueryBuilder.test.ts
  17. 147
      public/app/plugins/datasource/azuremonitor/components/LogsQueryBuilder/AzureMonitorKustoQueryBuilder.ts
  18. 71
      public/app/plugins/datasource/azuremonitor/components/LogsQueryBuilder/FilterItem.tsx
  19. 260
      public/app/plugins/datasource/azuremonitor/components/LogsQueryBuilder/FilterSection.tsx
  20. 135
      public/app/plugins/datasource/azuremonitor/components/LogsQueryBuilder/FuzzySearch.tsx
  21. 78
      public/app/plugins/datasource/azuremonitor/components/LogsQueryBuilder/GroupByItem.tsx
  22. 150
      public/app/plugins/datasource/azuremonitor/components/LogsQueryBuilder/GroupBySection.tsx
  23. 61
      public/app/plugins/datasource/azuremonitor/components/LogsQueryBuilder/KQLPreview.tsx
  24. 41
      public/app/plugins/datasource/azuremonitor/components/LogsQueryBuilder/LimitSection.tsx
  25. 158
      public/app/plugins/datasource/azuremonitor/components/LogsQueryBuilder/LogsQueryBuilder.tsx
  26. 158
      public/app/plugins/datasource/azuremonitor/components/LogsQueryBuilder/OrderBySection.tsx
  27. 163
      public/app/plugins/datasource/azuremonitor/components/LogsQueryBuilder/TableSection.tsx
  28. 49
      public/app/plugins/datasource/azuremonitor/components/LogsQueryBuilder/expressions.ts
  29. 102
      public/app/plugins/datasource/azuremonitor/components/LogsQueryBuilder/utils.ts
  30. 36
      public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/LogsQueryEditor.test.tsx
  31. 70
      public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/LogsQueryEditor.tsx
  32. 14
      public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/TimeManagement.tsx
  33. 20
      public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.test.tsx
  34. 39
      public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryEditor.tsx
  35. 143
      public/app/plugins/datasource/azuremonitor/components/QueryEditor/QueryHeader.tsx
  36. 12
      public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.test.tsx
  37. 2
      public/app/plugins/datasource/azuremonitor/components/VariableEditor/VariableEditor.tsx
  38. 2
      public/app/plugins/datasource/azuremonitor/components/shared/FormatAsField.tsx
  39. 106
      public/app/plugins/datasource/azuremonitor/dataquery.cue
  40. 164
      public/app/plugins/datasource/azuremonitor/dataquery.gen.ts
  41. 3
      public/app/plugins/datasource/azuremonitor/e2e/selectors.ts
  42. 52
      public/app/plugins/datasource/azuremonitor/types/types.ts

@ -122,6 +122,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `teamHttpHeadersMimir` | Enables LBAC for datasources for Mimir to apply LBAC filtering of metrics to the client requests for users in teams |
| `exploreMetricsUseExternalAppPlugin` | Use the externalized Grafana Metrics Drilldown (formerly known as Explore Metrics) app plugin |
| `alertRuleRestore` | Enables the alert rule restore feature |
| `azureMonitorLogsBuilderEditor` | Enables the logs builder mode for the Azure Monitor data source |
## Experimental feature toggles

@ -6,8 +6,8 @@ import { selectors as rawSelectors } from '@grafana/e2e-selectors';
import { selectors } from '../../public/app/plugins/datasource/azuremonitor/e2e/selectors';
import {
AzureDataSourceJsonData,
AzureDataSourceSecureJsonData,
AzureMonitorDataSourceJsonData,
AzureMonitorDataSourceSecureJsonData,
AzureQueryType,
} from '../../public/app/plugins/datasource/azuremonitor/types';
import { e2e } from '../utils';
@ -16,8 +16,8 @@ const provisioningPath = `provisioning/datasources/azmonitor-ds.yaml`;
const e2eSelectors = e2e.getSelectors(selectors.components);
type AzureMonitorConfig = {
secureJsonData: AzureDataSourceSecureJsonData;
jsonData: AzureDataSourceJsonData;
secureJsonData: AzureMonitorDataSourceSecureJsonData;
jsonData: AzureMonitorDataSourceJsonData;
};
type AzureMonitorProvision = { datasources: AzureMonitorConfig[] };

@ -1058,6 +1058,11 @@ export interface FeatureToggles {
*/
unifiedStorageHistoryPruner?: boolean;
/**
* Enables the logs builder mode for the Azure Monitor data source
* @default false
*/
azureMonitorLogsBuilderEditor?: boolean;
/**
* Specify the locale so we can show the correct format for numbers and dates
*/
localeFormatPreference?: boolean;

@ -184,6 +184,10 @@ export interface AzureLogsQuery {
* If set to true the query will be run as a basic logs query
*/
basicLogsQuery?: boolean;
/**
* Builder query to be executed.
*/
builderQuery?: BuilderQueryExpression;
/**
* If set to true the dashboard time range will be used as a filter for the query. Otherwise the query time ranges will be used. Defaults to false.
*/
@ -192,6 +196,10 @@ export interface AzureLogsQuery {
* @deprecated Use dashboardTime instead
*/
intersectTime?: boolean;
/**
* Denotes if logs query editor is in builder mode
*/
mode?: LogsEditorMode;
/**
* KQL query to be executed.
*/
@ -284,6 +292,162 @@ export enum ResultFormat {
Trace = 'trace',
}
export enum LogsEditorMode {
Builder = 'builder',
Raw = 'raw',
}
export enum BuilderQueryEditorExpressionType {
And = 'and',
Function_parameter = 'function_parameter',
Group_by = 'group_by',
Operator = 'operator',
Or = 'or',
Order_by = 'order_by',
Property = 'property',
Reduce = 'reduce',
}
export enum BuilderQueryEditorPropertyType {
Boolean = 'boolean',
Datetime = 'datetime',
Function = 'function',
Interval = 'interval',
Number = 'number',
String = 'string',
Time_span = 'time_span',
}
export enum BuilderQueryEditorOrderByOptions {
Asc = 'asc',
Desc = 'desc',
}
export interface BuilderQueryEditorProperty {
name: string;
type: BuilderQueryEditorPropertyType;
}
export interface BuilderQueryEditorPropertyExpression {
property: BuilderQueryEditorProperty;
type: BuilderQueryEditorExpressionType;
}
export interface BuilderQueryEditorColumnsExpression {
columns?: Array<string>;
type: BuilderQueryEditorExpressionType;
}
export const defaultBuilderQueryEditorColumnsExpression: Partial<BuilderQueryEditorColumnsExpression> = {
columns: [],
};
export interface SelectableValue {
label: string;
value: string;
}
export type BuilderQueryEditorOperatorType = (string | boolean | number | SelectableValue);
export interface BuilderQueryEditorOperator {
labelValue?: string;
name: string;
value: string;
}
export interface BuilderQueryEditorWhereExpressionItems {
operator: BuilderQueryEditorOperator;
property: BuilderQueryEditorProperty;
type: BuilderQueryEditorExpressionType;
}
export interface BuilderQueryEditorWhereExpression {
expressions: Array<BuilderQueryEditorWhereExpressionItems>;
type: BuilderQueryEditorExpressionType;
}
export const defaultBuilderQueryEditorWhereExpression: Partial<BuilderQueryEditorWhereExpression> = {
expressions: [],
};
export interface BuilderQueryEditorWhereExpressionArray {
expressions: Array<BuilderQueryEditorWhereExpression>;
type: BuilderQueryEditorExpressionType;
}
export const defaultBuilderQueryEditorWhereExpressionArray: Partial<BuilderQueryEditorWhereExpressionArray> = {
expressions: [],
};
export interface BuilderQueryEditorFunctionParameterExpression {
fieldType: BuilderQueryEditorPropertyType;
type: BuilderQueryEditorExpressionType;
value: string;
}
export interface BuilderQueryEditorReduceExpression {
focus?: boolean;
parameters?: Array<BuilderQueryEditorFunctionParameterExpression>;
property?: BuilderQueryEditorProperty;
reduce?: BuilderQueryEditorProperty;
}
export const defaultBuilderQueryEditorReduceExpression: Partial<BuilderQueryEditorReduceExpression> = {
parameters: [],
};
export interface BuilderQueryEditorReduceExpressionArray {
expressions: Array<BuilderQueryEditorReduceExpression>;
type: BuilderQueryEditorExpressionType;
}
export const defaultBuilderQueryEditorReduceExpressionArray: Partial<BuilderQueryEditorReduceExpressionArray> = {
expressions: [],
};
export interface BuilderQueryEditorGroupByExpression {
focus?: boolean;
interval?: BuilderQueryEditorProperty;
property?: BuilderQueryEditorProperty;
type?: BuilderQueryEditorExpressionType;
}
export interface BuilderQueryEditorGroupByExpressionArray {
expressions: Array<BuilderQueryEditorGroupByExpression>;
type: BuilderQueryEditorExpressionType;
}
export const defaultBuilderQueryEditorGroupByExpressionArray: Partial<BuilderQueryEditorGroupByExpressionArray> = {
expressions: [],
};
export interface BuilderQueryEditorOrderByExpression {
order: BuilderQueryEditorOrderByOptions;
property: BuilderQueryEditorProperty;
type: BuilderQueryEditorExpressionType;
}
export interface BuilderQueryEditorOrderByExpressionArray {
expressions: Array<BuilderQueryEditorOrderByExpression>;
type: BuilderQueryEditorExpressionType;
}
export const defaultBuilderQueryEditorOrderByExpressionArray: Partial<BuilderQueryEditorOrderByExpressionArray> = {
expressions: [],
};
export interface BuilderQueryExpression {
columns?: BuilderQueryEditorColumnsExpression;
from?: BuilderQueryEditorPropertyExpression;
fuzzySearch?: BuilderQueryEditorWhereExpressionArray;
groupBy?: BuilderQueryEditorGroupByExpressionArray;
limit?: number;
orderBy?: BuilderQueryEditorOrderByExpressionArray;
reduce?: BuilderQueryEditorReduceExpressionArray;
timeFilter?: BuilderQueryEditorWhereExpressionArray;
where?: BuilderQueryEditorWhereExpressionArray;
}
export interface AzureResourceGraphQuery {
/**
* Azure Resource Graph KQL query to be executed.

@ -1818,6 +1818,13 @@ var (
HideFromAdminPage: true,
HideFromDocs: true,
},
{
Name: "azureMonitorLogsBuilderEditor",
Description: "Enables the logs builder mode for the Azure Monitor data source",
Stage: FeatureStagePublicPreview,
Owner: grafanaPartnerPluginsSquad,
Expression: "false",
},
{
Name: "localeFormatPreference",
Description: "Specify the locale so we can show the correct format for numbers and dates",

@ -239,6 +239,7 @@ inviteUserExperimental,experimental,@grafana/sharing-squad,false,false,true
noBackdropBlur,experimental,@grafana/grafana-frontend-platform,false,false,true
alertingMigrationUI,experimental,@grafana/alerting-squad,false,false,true
unifiedStorageHistoryPruner,experimental,@grafana/search-and-storage,false,false,false
azureMonitorLogsBuilderEditor,preview,@grafana/partner-datasources,false,false,false
localeFormatPreference,experimental,@grafana/grafana-frontend-platform,false,false,false
unifiedStorageGrpcConnectionPool,experimental,@grafana/search-and-storage,false,false,false
alertingRuleRecoverDeleted,GA,@grafana/alerting-squad,false,false,true

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
239 noBackdropBlur experimental @grafana/grafana-frontend-platform false false true
240 alertingMigrationUI experimental @grafana/alerting-squad false false true
241 unifiedStorageHistoryPruner experimental @grafana/search-and-storage false false false
242 azureMonitorLogsBuilderEditor preview @grafana/partner-datasources false false false
243 localeFormatPreference experimental @grafana/grafana-frontend-platform false false false
244 unifiedStorageGrpcConnectionPool experimental @grafana/search-and-storage false false false
245 alertingRuleRecoverDeleted GA @grafana/alerting-squad false false true

@ -967,6 +967,10 @@ const (
// Enables the unified storage history pruner
FlagUnifiedStorageHistoryPruner = "unifiedStorageHistoryPruner"
// FlagAzureMonitorLogsBuilderEditor
// Enables the logs builder mode for the Azure Monitor data source
FlagAzureMonitorLogsBuilderEditor = "azureMonitorLogsBuilderEditor"
// FlagLocaleFormatPreference
// Specify the locale so we can show the correct format for numbers and dates
FlagLocaleFormatPreference = "localeFormatPreference"

@ -901,6 +901,22 @@
"codeowner": "@grafana/partner-datasources"
}
},
{
"metadata": {
"name": "azureMonitorLogsBuilderEditor",
"resourceVersion": "1743001017970",
"creationTimestamp": "2025-03-19T22:51:49Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-03-26 14:56:57.970732 +0000 UTC"
}
},
"spec": {
"description": "Enables the logs builder mode for the Azure Monitor data source",
"stage": "preview",
"codeowner": "@grafana/partner-datasources",
"expression": "false"
}
},
{
"metadata": {
"name": "azureMonitorPrometheusExemplars",

@ -157,6 +157,10 @@ type AzureLogsQuery struct {
BasicLogsQuery *bool `json:"basicLogsQuery,omitempty"`
// Workspace ID. This was removed in Grafana 8, but remains for backwards compat.
Workspace *string `json:"workspace,omitempty"`
// Denotes if logs query editor is in builder mode
Mode *LogsEditorMode `json:"mode,omitempty"`
// Builder query to be executed.
BuilderQuery *BuilderQueryExpression `json:"builderQuery,omitempty"`
// @deprecated Use resources instead
Resource *string `json:"resource,omitempty"`
// @deprecated Use dashboardTime instead
@ -177,6 +181,217 @@ const (
ResultFormatLogs ResultFormat = "logs"
)
type LogsEditorMode string
const (
LogsEditorModeBuilder LogsEditorMode = "builder"
LogsEditorModeRaw LogsEditorMode = "raw"
)
type BuilderQueryExpression struct {
From *BuilderQueryEditorPropertyExpression `json:"from,omitempty"`
Columns *BuilderQueryEditorColumnsExpression `json:"columns,omitempty"`
Where *BuilderQueryEditorWhereExpressionArray `json:"where,omitempty"`
Reduce *BuilderQueryEditorReduceExpressionArray `json:"reduce,omitempty"`
GroupBy *BuilderQueryEditorGroupByExpressionArray `json:"groupBy,omitempty"`
Limit *int64 `json:"limit,omitempty"`
OrderBy *BuilderQueryEditorOrderByExpressionArray `json:"orderBy,omitempty"`
FuzzySearch *BuilderQueryEditorWhereExpressionArray `json:"fuzzySearch,omitempty"`
TimeFilter *BuilderQueryEditorWhereExpressionArray `json:"timeFilter,omitempty"`
}
// NewBuilderQueryExpression creates a new BuilderQueryExpression object.
func NewBuilderQueryExpression() *BuilderQueryExpression {
return &BuilderQueryExpression{}
}
type BuilderQueryEditorPropertyExpression struct {
Property BuilderQueryEditorProperty `json:"property"`
Type BuilderQueryEditorExpressionType `json:"type"`
}
// NewBuilderQueryEditorPropertyExpression creates a new BuilderQueryEditorPropertyExpression object.
func NewBuilderQueryEditorPropertyExpression() *BuilderQueryEditorPropertyExpression {
return &BuilderQueryEditorPropertyExpression{
Property: *NewBuilderQueryEditorProperty(),
}
}
type BuilderQueryEditorProperty struct {
Type BuilderQueryEditorPropertyType `json:"type"`
Name string `json:"name"`
}
// NewBuilderQueryEditorProperty creates a new BuilderQueryEditorProperty object.
func NewBuilderQueryEditorProperty() *BuilderQueryEditorProperty {
return &BuilderQueryEditorProperty{}
}
type BuilderQueryEditorPropertyType string
const (
BuilderQueryEditorPropertyTypeNumber BuilderQueryEditorPropertyType = "number"
BuilderQueryEditorPropertyTypeString BuilderQueryEditorPropertyType = "string"
BuilderQueryEditorPropertyTypeBoolean BuilderQueryEditorPropertyType = "boolean"
BuilderQueryEditorPropertyTypeDatetime BuilderQueryEditorPropertyType = "datetime"
BuilderQueryEditorPropertyTypeTimeSpan BuilderQueryEditorPropertyType = "time_span"
BuilderQueryEditorPropertyTypeFunction BuilderQueryEditorPropertyType = "function"
BuilderQueryEditorPropertyTypeInterval BuilderQueryEditorPropertyType = "interval"
)
type BuilderQueryEditorExpressionType string
const (
BuilderQueryEditorExpressionTypeProperty BuilderQueryEditorExpressionType = "property"
BuilderQueryEditorExpressionTypeOperator BuilderQueryEditorExpressionType = "operator"
BuilderQueryEditorExpressionTypeReduce BuilderQueryEditorExpressionType = "reduce"
BuilderQueryEditorExpressionTypeFunctionParameter BuilderQueryEditorExpressionType = "function_parameter"
BuilderQueryEditorExpressionTypeGroupBy BuilderQueryEditorExpressionType = "group_by"
BuilderQueryEditorExpressionTypeOr BuilderQueryEditorExpressionType = "or"
BuilderQueryEditorExpressionTypeAnd BuilderQueryEditorExpressionType = "and"
BuilderQueryEditorExpressionTypeOrderBy BuilderQueryEditorExpressionType = "order_by"
)
type BuilderQueryEditorColumnsExpression struct {
Columns []string `json:"columns,omitempty"`
Type BuilderQueryEditorExpressionType `json:"type"`
}
// NewBuilderQueryEditorColumnsExpression creates a new BuilderQueryEditorColumnsExpression object.
func NewBuilderQueryEditorColumnsExpression() *BuilderQueryEditorColumnsExpression {
return &BuilderQueryEditorColumnsExpression{}
}
type BuilderQueryEditorWhereExpressionArray struct {
Expressions []BuilderQueryEditorWhereExpression `json:"expressions"`
Type BuilderQueryEditorExpressionType `json:"type"`
}
// NewBuilderQueryEditorWhereExpressionArray creates a new BuilderQueryEditorWhereExpressionArray object.
func NewBuilderQueryEditorWhereExpressionArray() *BuilderQueryEditorWhereExpressionArray {
return &BuilderQueryEditorWhereExpressionArray{}
}
type BuilderQueryEditorWhereExpression struct {
Type BuilderQueryEditorExpressionType `json:"type"`
Expressions []BuilderQueryEditorWhereExpressionItems `json:"expressions"`
}
// NewBuilderQueryEditorWhereExpression creates a new BuilderQueryEditorWhereExpression object.
func NewBuilderQueryEditorWhereExpression() *BuilderQueryEditorWhereExpression {
return &BuilderQueryEditorWhereExpression{}
}
type BuilderQueryEditorWhereExpressionItems struct {
Property BuilderQueryEditorProperty `json:"property"`
Operator BuilderQueryEditorOperator `json:"operator"`
Type BuilderQueryEditorExpressionType `json:"type"`
}
// NewBuilderQueryEditorWhereExpressionItems creates a new BuilderQueryEditorWhereExpressionItems object.
func NewBuilderQueryEditorWhereExpressionItems() *BuilderQueryEditorWhereExpressionItems {
return &BuilderQueryEditorWhereExpressionItems{
Property: *NewBuilderQueryEditorProperty(),
Operator: *NewBuilderQueryEditorOperator(),
}
}
type BuilderQueryEditorOperator struct {
Name string `json:"name"`
Value string `json:"value"`
LabelValue *string `json:"labelValue,omitempty"`
}
// NewBuilderQueryEditorOperator creates a new BuilderQueryEditorOperator object.
func NewBuilderQueryEditorOperator() *BuilderQueryEditorOperator {
return &BuilderQueryEditorOperator{}
}
type BuilderQueryEditorReduceExpressionArray struct {
Expressions []BuilderQueryEditorReduceExpression `json:"expressions"`
Type BuilderQueryEditorExpressionType `json:"type"`
}
// NewBuilderQueryEditorReduceExpressionArray creates a new BuilderQueryEditorReduceExpressionArray object.
func NewBuilderQueryEditorReduceExpressionArray() *BuilderQueryEditorReduceExpressionArray {
return &BuilderQueryEditorReduceExpressionArray{}
}
type BuilderQueryEditorReduceExpression struct {
Property *BuilderQueryEditorProperty `json:"property,omitempty"`
Reduce *BuilderQueryEditorProperty `json:"reduce,omitempty"`
Parameters []BuilderQueryEditorFunctionParameterExpression `json:"parameters,omitempty"`
Focus *bool `json:"focus,omitempty"`
}
// NewBuilderQueryEditorReduceExpression creates a new BuilderQueryEditorReduceExpression object.
func NewBuilderQueryEditorReduceExpression() *BuilderQueryEditorReduceExpression {
return &BuilderQueryEditorReduceExpression{}
}
type BuilderQueryEditorFunctionParameterExpression struct {
Value string `json:"value"`
FieldType BuilderQueryEditorPropertyType `json:"fieldType"`
Type BuilderQueryEditorExpressionType `json:"type"`
}
// NewBuilderQueryEditorFunctionParameterExpression creates a new BuilderQueryEditorFunctionParameterExpression object.
func NewBuilderQueryEditorFunctionParameterExpression() *BuilderQueryEditorFunctionParameterExpression {
return &BuilderQueryEditorFunctionParameterExpression{}
}
type BuilderQueryEditorGroupByExpressionArray struct {
Expressions []BuilderQueryEditorGroupByExpression `json:"expressions"`
Type BuilderQueryEditorExpressionType `json:"type"`
}
// NewBuilderQueryEditorGroupByExpressionArray creates a new BuilderQueryEditorGroupByExpressionArray object.
func NewBuilderQueryEditorGroupByExpressionArray() *BuilderQueryEditorGroupByExpressionArray {
return &BuilderQueryEditorGroupByExpressionArray{}
}
type BuilderQueryEditorGroupByExpression struct {
Property *BuilderQueryEditorProperty `json:"property,omitempty"`
Interval *BuilderQueryEditorProperty `json:"interval,omitempty"`
Focus *bool `json:"focus,omitempty"`
Type *BuilderQueryEditorExpressionType `json:"type,omitempty"`
}
// NewBuilderQueryEditorGroupByExpression creates a new BuilderQueryEditorGroupByExpression object.
func NewBuilderQueryEditorGroupByExpression() *BuilderQueryEditorGroupByExpression {
return &BuilderQueryEditorGroupByExpression{}
}
type BuilderQueryEditorOrderByExpressionArray struct {
Expressions []BuilderQueryEditorOrderByExpression `json:"expressions"`
Type BuilderQueryEditorExpressionType `json:"type"`
}
// NewBuilderQueryEditorOrderByExpressionArray creates a new BuilderQueryEditorOrderByExpressionArray object.
func NewBuilderQueryEditorOrderByExpressionArray() *BuilderQueryEditorOrderByExpressionArray {
return &BuilderQueryEditorOrderByExpressionArray{}
}
type BuilderQueryEditorOrderByExpression struct {
Property BuilderQueryEditorProperty `json:"property"`
Order BuilderQueryEditorOrderByOptions `json:"order"`
Type BuilderQueryEditorExpressionType `json:"type"`
}
// NewBuilderQueryEditorOrderByExpression creates a new BuilderQueryEditorOrderByExpression object.
func NewBuilderQueryEditorOrderByExpression() *BuilderQueryEditorOrderByExpression {
return &BuilderQueryEditorOrderByExpression{
Property: *NewBuilderQueryEditorProperty(),
}
}
type BuilderQueryEditorOrderByOptions string
const (
BuilderQueryEditorOrderByOptionsAsc BuilderQueryEditorOrderByOptions = "asc"
BuilderQueryEditorOrderByOptionsDesc BuilderQueryEditorOrderByOptions = "desc"
)
type AzureResourceGraphQuery struct {
// Azure Resource Graph KQL query to be executed.
Query *string `json:"query,omitempty"`
@ -391,6 +606,23 @@ const (
AzureQueryTypeCustomMetricNamesQuery AzureQueryType = "Azure Custom Metric Names"
)
type SelectableValue struct {
Label string `json:"label"`
Value string `json:"value"`
}
// NewSelectableValue creates a new SelectableValue object.
func NewSelectableValue() *SelectableValue {
return &SelectableValue{}
}
type BuilderQueryEditorOperatorType = StringOrBoolOrFloat64OrSelectableValue
// NewBuilderQueryEditorOperatorType creates a new BuilderQueryEditorOperatorType object.
func NewBuilderQueryEditorOperatorType() *BuilderQueryEditorOperatorType {
return NewStringOrBoolOrFloat64OrSelectableValue()
}
type GrafanaTemplateVariableQueryType string
const (
@ -569,3 +801,15 @@ func (resource *AppInsightsMetricNameQueryOrAppInsightsGroupByQueryOrSubscriptio
return fmt.Errorf("could not unmarshal resource with `kind = %v`", discriminator)
}
type StringOrBoolOrFloat64OrSelectableValue struct {
String *string `json:"String,omitempty"`
Bool *bool `json:"Bool,omitempty"`
Float64 *float64 `json:"Float64,omitempty"`
SelectableValue *SelectableValue `json:"SelectableValue,omitempty"`
}
// NewStringOrBoolOrFloat64OrSelectableValue creates a new StringOrBoolOrFloat64OrSelectableValue object.
func NewStringOrBoolOrFloat64OrSelectableValue() *StringOrBoolOrFloat64OrSelectableValue {
return &StringOrBoolOrFloat64OrSelectableValue{}
}

@ -28,6 +28,18 @@ import (
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/utils"
)
// Returns tables with the `HasData` field set to true
func filterTablesWithData(tables []types.MetadataTable) []types.MetadataTable {
filtered := []types.MetadataTable{}
for _, table := range tables {
if table.HasData {
filtered = append(filtered, table)
}
}
return filtered
}
func (e *AzureLogAnalyticsDatasource) ResourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) (http.ResponseWriter, error) {
if req.URL.Path == "/usage/basiclogs" {
newUrl := &url.URL{
@ -36,7 +48,57 @@ func (e *AzureLogAnalyticsDatasource) ResourceRequest(rw http.ResponseWriter, re
Path: "/v1/query",
}
return e.GetBasicLogsUsage(req.Context(), newUrl.String(), cli, rw, req.Body)
} else if strings.Contains(req.URL.Path, "/metadata") {
// Add necessary headers
req.Header.Set("Prefer", "metadata-format-v4,exclude-resourcetypes,exclude-customfunctions")
queryParams := req.URL.Query()
// Add necessary query params
queryParams.Add("select", "categories,solutions,tables,workspaces")
req.URL.RawQuery = queryParams.Encode()
resp, err := cli.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch metadata: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
e.Logger.Warn("Failed to close response body for metadata request", "err", err)
}
}()
encoding := resp.Header.Get("Content-Encoding")
body, err := decode(encoding, resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read metadata response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("metadata API error: %s", string(body))
}
var metadata types.AzureLogAnalyticsMetadata
err = json.Unmarshal(body, &metadata)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal metadata response: %w", err)
}
metadata.Tables = filterTablesWithData(metadata.Tables)
responseBody, err := json.Marshal(metadata)
if err != nil {
return nil, fmt.Errorf("failed to marshal metadata response: %w", err)
}
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
_, err = rw.Write(responseBody)
if err != nil {
return nil, fmt.Errorf("failed to write metadata response: %w", err)
}
return rw, nil
}
// Default behavior for other requests
return e.Proxy.Do(rw, req, cli)
}

@ -1,12 +1,16 @@
package loganalytics
import (
"compress/flate"
"compress/gzip"
"fmt"
"io"
"regexp"
"strconv"
"strings"
"time"
"github.com/andybalholm/brotli"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor/kinds/dataquery"
@ -136,3 +140,40 @@ func ConvertTime(timeStamp string) (time.Time, error) {
func GetDataVolumeRawQuery(table string) string {
return fmt.Sprintf("Usage \n| where DataType == \"%s\"\n| where IsBillable == true\n| summarize BillableDataGB = round(sum(Quantity) / 1000, 3)", table)
}
// This function handles various compression mechanisms that may have been used on a response body
func decode(encoding string, original io.ReadCloser) ([]byte, error) {
var reader io.Reader
var err error
switch encoding {
case "gzip":
reader, err = gzip.NewReader(original)
if err != nil {
return nil, err
}
defer func() {
if err := reader.(io.ReadCloser).Close(); err != nil {
backend.Logger.Warn("Failed to close reader body", "err", err)
}
}()
case "deflate":
reader = flate.NewReader(original)
defer func() {
if err := reader.(io.ReadCloser).Close(); err != nil {
backend.Logger.Warn("Failed to close reader body", "err", err)
}
}()
case "br":
reader = brotli.NewReader(original)
case "":
reader = original
default:
return nil, fmt.Errorf("unexpected encoding type %v", err)
}
body, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
return body, nil
}

@ -210,3 +210,119 @@ type SubscriptionsResponse struct {
}
var ErrorAzureHealthCheck = errors.New("health check failed")
// AzureLogAnalyticsMetadata represents the metadata response from the Azure Log Analytics API.
// Types are taken from https://learn.microsoft.com/en-us/rest/api/loganalytics/metadata/get
type AzureLogAnalyticsMetadata struct {
Applications []MetadataApplications `json:"applications"`
Categories []MetadataCategories `json:"categories"`
Functions []MetadataFunctions `json:"functions"`
Permissions []MetadataPermissions `json:"permissions"`
Queries []MetadataQueries `json:"queries"`
ResourceTypes []MetadataResourceTypes `json:"resourceTypes"`
Resources []Resources `json:"resources"`
Solutions []MetadataSolutions `json:"solutions"`
Tables []MetadataTable `json:"tables"`
Workspaces []MetadataWorkspaces `json:"workspaces"`
}
// MetadataTable represents a table entry in the metadata response.
type MetadataTable struct {
Columns []Columns `json:"columns"`
Description string `json:"description"`
ID string `json:"id"`
Labels []string `json:"labels"`
Name string `json:"name"`
Properties map[string]any `json:"properties"`
Tags map[string]any `json:"tags"`
TimespanColumn string `json:"timespanColumn"`
TableType string `json:"tableType"`
HasData bool `json:"hasData"`
}
type Columns struct {
Description string `json:"description"`
IsPreferredFacet bool `json:"isPreferredFacet"`
Name string `json:"name"`
Source map[string]any `json:"source"`
Type string `json:"type"`
}
type MetadataApplications struct {
ID string `json:"id"`
Name string `json:"name"`
Region string `json:"region"`
ResourceId string `json:"resourceId"`
}
type MetadataWorkspaces struct {
ID string `json:"id"`
Name string `json:"name"`
Region string `json:"region"`
ResourceId string `json:"resourceId"`
}
type MetadataCategories struct {
Description string `json:"description"`
DisplayName string `json:"displayName"`
ID string `json:"id"`
}
type MetadataFunctions struct {
Body string `json:"string"`
Description string `json:"description"`
DisplayName string `json:"displayName"`
ID string `json:"id"`
Name string `json:"name"`
Parameters string `json:"parameters"`
Properties map[string]any `json:"properties"`
Tags map[string]any `json:"tags"`
}
type MetadataPermissions struct {
Applications []Applications `json:"applications"`
Resources []Resources `json:"resources"`
Workspaces []Workspaces `json:"workspaces"`
}
type MetadataQueries struct {
Body string `json:"string"`
Description string `json:"description"`
DisplayName string `json:"displayName"`
ID string `json:"id"`
Labels []string `json:"labels"`
Properties map[string]any `json:"properties"`
Tags map[string]any `json:"tags"`
}
type MetadataResourceTypes struct {
Description string `json:"description"`
DisplayName string `json:"displayName"`
ID string `json:"id"`
Labels []string `json:"labels"`
Properties map[string]any `json:"properties"`
Tags map[string]any `json:"tags"`
Type string `json:"type"`
}
type MetadataSolutions struct {
Description string `json:"description"`
DisplayName string `json:"displayName"`
ID string `json:"id"`
Name string `json:"name"`
Properties map[string]any `json:"properties"`
Tags map[string]any `json:"tags"`
}
type Applications struct {
ResourceId string `json:"resourceId"`
}
type Resources struct {
DenyTables []string `json:"denyTables"`
ResourceId string `json:"resourceId"`
}
type Workspaces struct {
DenyTables []string `json:"denyTables"`
ResourceId string `json:"resourceId"`
}

@ -8,9 +8,9 @@ import ResponseParser from '../azure_monitor/response_parser';
import { getCredentials } from '../credentials';
import {
AzureAPIResponse,
AzureLogsVariable,
AzureMonitorDataSourceInstanceSettings,
AzureMonitorDataSourceJsonData,
AzureLogsVariable,
AzureMonitorQuery,
AzureQueryType,
DatasourceValidationResult,
@ -125,6 +125,7 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend<
queryType: target.queryType || AzureQueryType.LogAnalytics,
azureLogAnalytics: {
builderQuery: item.builderQuery,
resultFormat: item.resultFormat,
query,
resources,

@ -0,0 +1,152 @@
import React, { useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { InputGroup, AccessoryButton } from '@grafana/plugin-ui';
import { Select, Label, Input } from '@grafana/ui';
import {
BuilderQueryEditorExpressionType,
BuilderQueryEditorPropertyType,
BuilderQueryEditorReduceExpression,
} from '../../dataquery.gen';
import { aggregateOptions, inputFieldSize } from './utils';
interface AggregateItemProps {
aggregate: BuilderQueryEditorReduceExpression;
columns: Array<SelectableValue<string>>;
onChange: (item: BuilderQueryEditorReduceExpression) => void;
onDelete: () => void;
templateVariableOptions: SelectableValue<string>;
}
const AggregateItem: React.FC<AggregateItemProps> = ({
aggregate,
onChange,
onDelete,
columns,
templateVariableOptions,
}) => {
const isPercentile = aggregate.reduce?.name === 'percentile';
const isCountAggregate = aggregate.reduce?.name?.includes('count');
const [percentileValue, setPercentileValue] = useState(aggregate.parameters?.[0]?.value || '');
const [columnValue, setColumnValue] = useState(
isPercentile ? aggregate.parameters?.[1]?.value || '' : aggregate.property?.name || ''
);
const safeTemplateVariables = Array.isArray(templateVariableOptions)
? templateVariableOptions
: [templateVariableOptions];
const selectableOptions = columns.concat(safeTemplateVariables);
const buildPercentileParams = (percentile: string, column: string) => [
{
type: BuilderQueryEditorExpressionType.Function_parameter,
fieldType: BuilderQueryEditorPropertyType.Number,
value: percentile,
},
{
type: BuilderQueryEditorExpressionType.Function_parameter,
fieldType: BuilderQueryEditorPropertyType.String,
value: column,
},
];
const updateAggregate = (updates: Partial<BuilderQueryEditorReduceExpression>) => {
const base: BuilderQueryEditorReduceExpression = {
...aggregate,
...updates,
};
onChange(base);
};
const handleAggregateChange = (funcName?: string) => {
updateAggregate({
reduce: { name: funcName || '', type: BuilderQueryEditorPropertyType.Function },
});
};
const handlePercentileChange = (value?: string) => {
const newValue = value || '';
setPercentileValue(newValue);
const percentileParams = buildPercentileParams(newValue, columnValue);
updateAggregate({ parameters: percentileParams });
};
const handleColumnChange = (value?: string) => {
const newCol = value || '';
setColumnValue(newCol);
if (isPercentile) {
const percentileParams = buildPercentileParams(percentileValue, newCol);
updateAggregate({
parameters: percentileParams,
property: {
name: newCol,
type: BuilderQueryEditorPropertyType.String,
},
});
} else {
updateAggregate({
property: {
name: newCol,
type: BuilderQueryEditorPropertyType.String,
},
});
}
};
return (
<InputGroup>
<Select
aria-label="aggregate function"
width={inputFieldSize}
value={aggregate.reduce?.name ? { label: aggregate.reduce.name, value: aggregate.reduce.name } : null}
options={aggregateOptions}
onChange={(e) => handleAggregateChange(e.value)}
/>
{isPercentile ? (
<>
<Input
type="number"
min={0}
max={100}
step={1}
value={percentileValue ?? ''}
width={inputFieldSize}
onChange={(e) => {
const val = Number(e.currentTarget.value);
if (!isNaN(val) && val >= 0 && val <= 100) {
handlePercentileChange(val.toString());
}
}}
/>
<Label style={{ margin: '9px 9px 0 9px' }}>OF</Label>
</>
) : (
<></>
)}
{!isCountAggregate ? (
<Select
aria-label="column"
width={inputFieldSize}
value={columnValue ? { label: columnValue, value: columnValue } : null}
options={selectableOptions}
onChange={(e) => handleColumnChange(e.value)}
/>
) : (
<></>
)}
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} />
</InputGroup>
);
};
export default AggregateItem;

@ -0,0 +1,108 @@
import React, { useEffect, useRef, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorField, EditorFieldGroup, EditorList, EditorRow } from '@grafana/plugin-ui';
import { BuilderQueryEditorReduceExpression } from '../../dataquery.gen';
import { AzureLogAnalyticsMetadataColumn, AzureMonitorQuery } from '../../types';
import AggregateItem from './AggregateItem';
import { BuildAndUpdateOptions } from './utils';
interface AggregateSectionProps {
query: AzureMonitorQuery;
allColumns: AzureLogAnalyticsMetadataColumn[];
templateVariableOptions: SelectableValue<string>;
buildAndUpdateQuery: (options: Partial<BuildAndUpdateOptions>) => void;
}
export const AggregateSection: React.FC<AggregateSectionProps> = ({
query,
allColumns,
buildAndUpdateQuery,
templateVariableOptions,
}) => {
const builderQuery = query.azureLogAnalytics?.builderQuery;
const [aggregates, setAggregates] = useState<BuilderQueryEditorReduceExpression[]>(
builderQuery?.reduce?.expressions || []
);
const prevTable = useRef<string | null>(builderQuery?.from?.property.name || null);
const hasLoadedAggregates = useRef(false);
useEffect(() => {
const currentTable = builderQuery?.from?.property.name || null;
if (prevTable.current !== currentTable || builderQuery?.reduce?.expressions.length === 0) {
setAggregates([]);
hasLoadedAggregates.current = false;
prevTable.current = currentTable;
}
}, [builderQuery]);
const availableColumns: Array<SelectableValue<string>> = builderQuery?.columns?.columns?.length
? builderQuery.columns.columns.map((col) => ({ label: col, value: col }))
: allColumns.map((col) => ({ label: col.name, value: col.name }));
const onChange = (newItems: Array<Partial<BuilderQueryEditorReduceExpression>>) => {
setAggregates(newItems);
buildAndUpdateQuery({
reduce: newItems,
});
};
const onDeleteAggregate = (aggregateToDelete: BuilderQueryEditorReduceExpression) => {
setAggregates((prevAggregates) => {
const updatedAggregates = prevAggregates.filter(
(agg) =>
agg.property?.name !== aggregateToDelete.property?.name || agg.reduce?.name !== aggregateToDelete.reduce?.name
);
buildAndUpdateQuery({
reduce: updatedAggregates.length === 0 ? [] : updatedAggregates,
});
return updatedAggregates;
});
};
return (
<div data-testid="aggregate-section">
<EditorRow>
<EditorFieldGroup>
<EditorField
label="Aggregate"
optional={true}
tooltip={`Perform calculations across rows of data, such as count, sum, average, minimum, maximum, standard deviation or percentiles.`}
>
<EditorList
items={aggregates}
onChange={onChange}
renderItem={makeRenderAggregate(availableColumns, onDeleteAggregate, templateVariableOptions)}
/>
</EditorField>
</EditorFieldGroup>
</EditorRow>
</div>
);
};
function makeRenderAggregate(
availableColumns: Array<SelectableValue<string>>,
onDeleteAggregate: (aggregate: BuilderQueryEditorReduceExpression) => void,
templateVariableOptions: SelectableValue<string>
) {
return function renderAggregate(
item: BuilderQueryEditorReduceExpression,
onChange: (item: BuilderQueryEditorReduceExpression) => void
) {
return (
<AggregateItem
aggregate={item}
onChange={onChange}
onDelete={() => onDeleteAggregate(item)}
columns={availableColumns}
templateVariableOptions={templateVariableOptions}
/>
);
};
}

@ -0,0 +1,197 @@
import { BuilderQueryEditorExpressionType } from '../../dataquery.gen';
import { AzureMonitorKustoQueryBuilder } from './AzureMonitorKustoQueryBuilder';
describe('AzureMonitorKustoQueryParser', () => {
it('returns empty string if from table is not specified', () => {
const builderQuery: any = { from: { property: { name: '' } } };
const result = AzureMonitorKustoQueryBuilder.toQuery(builderQuery);
expect(result).toBe('');
});
it('builds a query with table and project', () => {
const builderQuery: any = {
from: { property: { name: 'Logs' } },
columns: { columns: ['TimeGenerated', 'Level', 'Message'] },
};
const result = AzureMonitorKustoQueryBuilder.toQuery(builderQuery);
expect(result).toContain('Logs');
expect(result).toContain('project TimeGenerated, Level, Message');
});
it('includes time filter when needed', () => {
const builderQuery: any = {
from: { property: { name: 'Logs' } },
timeFilter: {
expressions: [
{
type: BuilderQueryEditorExpressionType.Operator,
operator: { name: '$__timeFilter' },
property: { name: 'TimeGenerated' },
},
],
},
columns: { columns: ['TimeGenerated', 'Level'] },
};
const result = AzureMonitorKustoQueryBuilder.toQuery(builderQuery);
expect(result).toContain('$__timeFilter(TimeGenerated)');
});
it('handles fuzzy search expressions', () => {
const builderQuery: any = {
from: { property: { name: 'Logs' } },
fuzzySearch: {
expressions: [
{
type: BuilderQueryEditorExpressionType.Operator,
operator: { name: 'contains', value: 'fail' },
property: { name: 'Message' },
},
],
},
};
const result = AzureMonitorKustoQueryBuilder.toQuery(builderQuery);
expect(result).toContain("Message contains 'fail'");
});
it('applies additional filters', () => {
const builderQuery: any = {
from: { property: { name: 'Logs' } },
where: {
expressions: [
{
type: BuilderQueryEditorExpressionType.Operator,
operator: { name: '==', value: 'Error' },
property: { name: 'Level' },
},
{
type: BuilderQueryEditorExpressionType.Operator,
operator: { name: 'contains', value: 'fail' },
property: { name: 'Message' },
},
],
},
};
const result = AzureMonitorKustoQueryBuilder.toQuery(builderQuery);
expect(result).toContain("Level == 'Error'");
expect(result).toContain("Message contains 'fail'");
});
it('handles where expressions with operator', () => {
const builderQuery: any = {
from: { property: { name: 'Logs' } },
columns: { columns: ['Level', 'Message'] },
where: {
expressions: [
{
type: BuilderQueryEditorExpressionType.Operator,
operator: { name: '==', value: 'Error' },
property: { name: 'Level' },
},
],
},
};
const result = AzureMonitorKustoQueryBuilder.toQuery(builderQuery);
expect(result).toContain("Level == 'Error'");
});
it('handles summarize with percentile function', () => {
const builderQuery: any = {
from: { property: { name: 'Logs' } },
reduce: {
expressions: [
{
reduce: { name: 'percentile' },
parameters: [{ value: '95' }, { value: 'Duration' }],
},
],
},
};
const result = AzureMonitorKustoQueryBuilder.toQuery(builderQuery);
expect(result).toContain('summarize percentile(95, Duration)');
});
it('handles summarize with basic aggregation function like avg', () => {
const builderQuery: any = {
from: { property: { name: 'Logs' } },
reduce: {
expressions: [
{
reduce: { name: 'avg' },
property: { name: 'ResponseTime' },
},
],
},
};
const result = AzureMonitorKustoQueryBuilder.toQuery(builderQuery);
expect(result).toContain('summarize avg(ResponseTime)');
});
it('skips summarize when reduce expressions are invalid', () => {
const builderQuery: any = {
from: { property: { name: 'Logs' } },
reduce: {
expressions: [
{
reduce: null,
},
],
},
};
const result = AzureMonitorKustoQueryBuilder.toQuery(builderQuery);
expect(result).not.toContain('summarize');
});
it('adds summarize with groupBy', () => {
const builderQuery: any = {
from: { property: { name: 'Logs' } },
columns: { columns: ['Level'] },
groupBy: {
expressions: [{ property: { name: 'Level' } }],
},
reduce: {
expressions: [
{
reduce: { name: 'count' },
property: { name: 'Level' },
},
],
},
};
const result = AzureMonitorKustoQueryBuilder.toQuery(builderQuery);
expect(result).toContain('summarize count() by Level');
});
it('adds order by clause', () => {
const builderQuery: any = {
from: { property: { name: 'Logs' } },
columns: { columns: ['TimeGenerated', 'Level'] },
orderBy: {
expressions: [{ property: { name: 'TimeGenerated' }, order: 'desc' }],
},
};
const result = AzureMonitorKustoQueryBuilder.toQuery(builderQuery);
expect(result).toContain('order by TimeGenerated desc');
});
it('adds limit clause', () => {
const builderQuery: any = {
from: { property: { name: 'Logs' } },
columns: { columns: ['TimeGenerated', 'Level'] },
limit: 50,
};
const result = AzureMonitorKustoQueryBuilder.toQuery(builderQuery);
expect(result).toContain('limit 50');
});
});

@ -0,0 +1,147 @@
import {
BuilderQueryEditorWhereExpression,
BuilderQueryEditorWhereExpressionArray,
BuilderQueryEditorWhereExpressionItems,
BuilderQueryExpression,
} from '../../dataquery.gen';
const isNestedExpression = (
exp: BuilderQueryEditorWhereExpression | BuilderQueryEditorWhereExpressionItems
): exp is BuilderQueryEditorWhereExpressionItems =>
'operator' in exp &&
'property' in exp &&
typeof exp.operator?.name === 'string' &&
typeof exp.property?.name === 'string';
const buildCondition = (
exp: BuilderQueryEditorWhereExpression | BuilderQueryEditorWhereExpressionItems
): string | undefined => {
if ('expressions' in exp && Array.isArray(exp.expressions)) {
const isGroupOfFilters = exp.expressions.every((e) => 'operator' in e && 'property' in e);
const nested = exp.expressions.map(buildCondition).filter((c): c is string => Boolean(c));
if (nested.length === 0) {
return;
}
const joiner = isGroupOfFilters ? ' or ' : ' and ';
const joined = nested.join(joiner);
return nested.length > 1 ? `(${joined})` : joined;
}
if (isNestedExpression(exp)) {
const { name: op, value } = exp.operator;
const { name: prop } = exp.property;
const escapedValue = String(value).replace(/'/g, "''");
return op === '$__timeFilter' ? `$__timeFilter(${prop})` : `${prop} ${op} '${escapedValue}'`;
}
return;
};
export const appendWhere = (
phrases: string[],
timeFilter?: BuilderQueryEditorWhereExpressionArray,
fuzzySearch?: BuilderQueryEditorWhereExpressionArray,
where?: BuilderQueryEditorWhereExpressionArray
): void => {
const groups = [timeFilter, fuzzySearch, where];
groups.forEach((group) => {
group?.expressions.forEach((exp) => {
const condition = buildCondition(exp);
if (condition) {
phrases.push(`where ${condition}`);
}
});
});
};
const appendProject = (builderQuery: BuilderQueryExpression, phrases: string[]) => {
const selectedColumns = builderQuery.columns?.columns || [];
if (selectedColumns.length > 0) {
phrases.push(`project ${selectedColumns.join(', ')}`);
}
};
const appendSummarize = (builderQuery: BuilderQueryExpression, phrases: string[]) => {
const summarizeAlreadyAdded = phrases.some((phrase) => phrase.startsWith('summarize'));
if (summarizeAlreadyAdded) {
return;
}
const reduceExprs = builderQuery.reduce?.expressions ?? [];
const groupBy = builderQuery.groupBy?.expressions?.map((exp) => exp.property?.name).filter(Boolean) ?? [];
const summarizeParts = reduceExprs
.map((expr) => {
if (!expr.reduce?.name) {
return;
}
const func = expr.reduce.name;
if (func === 'percentile') {
const percentileValue = expr.parameters?.[0]?.value;
const column = expr.parameters?.[1]?.value ?? expr.property?.name ?? '';
return column ? `percentile(${percentileValue}, ${column})` : null;
}
const column = expr.property?.name ?? '';
return func === 'count' ? 'count()' : column ? `${func}(${column})` : func;
})
.filter(Boolean);
if (summarizeParts.length === 0 && groupBy.length === 0) {
return;
}
const summarizeClause =
summarizeParts.length > 0
? `summarize ${summarizeParts.join(', ')}${groupBy.length > 0 ? ` by ${groupBy.join(', ')}` : ''}`
: `summarize by ${groupBy.join(', ')}`;
phrases.push(summarizeClause);
};
const appendOrderBy = (builderQuery: BuilderQueryExpression, phrases: string[]) => {
const orderBy = builderQuery.orderBy?.expressions || [];
if (!orderBy.length) {
return;
}
const clauses = orderBy.map((order) => `${order.property?.name} ${order.order}`).filter(Boolean);
if (clauses.length > 0) {
phrases.push(`order by ${clauses.join(', ')}`);
}
};
const appendLimit = (builderQuery: BuilderQueryExpression, phrases: string[]) => {
if (builderQuery.limit && builderQuery.limit > 0) {
phrases.push(`limit ${builderQuery.limit}`);
}
};
const toQuery = (builderQuery: BuilderQueryExpression): string => {
const { from, timeFilter, fuzzySearch, where } = builderQuery;
if (!from?.property?.name) {
return '';
}
const phrases: string[] = [];
phrases.push(from.property.name);
appendWhere(phrases, timeFilter, fuzzySearch, where);
appendProject(builderQuery, phrases);
appendSummarize(builderQuery, phrases);
appendOrderBy(builderQuery, phrases);
appendLimit(builderQuery, phrases);
return phrases.join('\n| ');
};
export const AzureMonitorKustoQueryBuilder = {
toQuery,
};

@ -0,0 +1,71 @@
import React from 'react';
import { SelectableValue } from '@grafana/data';
import { Button, Combobox, ComboboxOption, Label, Select } from '@grafana/ui';
import { BuilderQueryEditorWhereExpressionItems } from '../../dataquery.gen';
import { inputFieldSize, toOperatorOptions, valueToDefinition } from './utils';
interface FilterItemProps {
filter: BuilderQueryEditorWhereExpressionItems;
filterIndex: number;
groupIndex: number;
usedColumns: string[];
selectableOptions: Array<SelectableValue<string>>;
onChange: (groupIndex: number, field: 'property' | 'operator' | 'value', value: string, filterIndex: number) => void;
onDelete: (groupIndex: number, filterIndex: number) => void;
getFilterValues: (
filter: BuilderQueryEditorWhereExpressionItems,
inputValue: string
) => Promise<Array<ComboboxOption<string>>>;
showOr: boolean;
}
export const FilterItem: React.FC<FilterItemProps> = ({
filter,
filterIndex,
groupIndex,
usedColumns,
selectableOptions,
onChange,
onDelete,
getFilterValues,
showOr,
}) => {
return (
<div style={{ display: 'flex', alignItems: 'center' }}>
<Select
aria-label="column"
width={inputFieldSize}
value={valueToDefinition(filter.property.name)}
options={selectableOptions.filter((opt) => !usedColumns.includes(opt.value!))}
onChange={(e) => e.value && onChange(groupIndex, 'property', e.value, filterIndex)}
/>
<Select
aria-label="operator"
width={12}
value={{ label: filter.operator.name, value: filter.operator.name }}
options={toOperatorOptions('string')}
onChange={(e) => e.value && onChange(groupIndex, 'operator', e.value, filterIndex)}
/>
<Combobox
aria-label="column value"
value={
filter.operator.value
? {
label: String(filter.operator.value),
value: String(filter.operator.value),
}
: null
}
options={(inputValue: string) => getFilterValues(filter, inputValue)}
onChange={(e) => e.value && onChange(groupIndex, 'value', String(e.value), filterIndex)}
width={inputFieldSize}
disabled={!filter.property?.name}
/>
<Button variant="secondary" icon="times" onClick={() => onDelete(groupIndex, filterIndex)} />
{showOr && <Label style={{ padding: '9px 14px' }}>OR</Label>}
</div>
);
};

@ -0,0 +1,260 @@
import { css } from '@emotion/css';
import React, { useEffect, useRef, useState } from 'react';
import { lastValueFrom } from 'rxjs';
import { CoreApp, getDefaultTimeRange, SelectableValue, TimeRange } from '@grafana/data';
import { EditorField, EditorFieldGroup, EditorRow, InputGroup } from '@grafana/plugin-ui';
import { Button, ComboboxOption, Label, useStyles2 } from '@grafana/ui';
import {
AzureQueryType,
BuilderQueryEditorExpressionType,
BuilderQueryEditorPropertyType,
BuilderQueryEditorWhereExpression,
BuilderQueryEditorWhereExpressionItems,
} from '../../dataquery.gen';
import Datasource from '../../datasource';
import { AzureLogAnalyticsMetadataColumn, AzureMonitorQuery } from '../../types';
import { FilterItem } from './FilterItem';
import { BuildAndUpdateOptions } from './utils';
interface FilterSectionProps {
query: AzureMonitorQuery;
allColumns: AzureLogAnalyticsMetadataColumn[];
buildAndUpdateQuery: (options: Partial<BuildAndUpdateOptions>) => void;
templateVariableOptions: SelectableValue<string>;
datasource: Datasource;
timeRange?: TimeRange;
}
const filterDynamicColumns = (columns: string[], allColumns: AzureLogAnalyticsMetadataColumn[]) => {
return columns.filter((col) =>
allColumns.some((completeCol) => completeCol.name === col && completeCol.type !== 'dynamic')
);
};
export const FilterSection: React.FC<FilterSectionProps> = ({
buildAndUpdateQuery,
query,
allColumns,
templateVariableOptions,
datasource,
timeRange,
}) => {
const styles = useStyles2(() => ({ filters: css({ marginBottom: '8px' }) }));
const builderQuery = query.azureLogAnalytics?.builderQuery;
const prevTable = useRef<string | null>(builderQuery?.from?.property.name || null);
const [filters, setFilters] = useState<BuilderQueryEditorWhereExpression[]>(
builderQuery?.where?.expressions?.map((group) => ({
...group,
expressions: group.expressions ?? [],
})) || []
);
const hasLoadedFilters = useRef(false);
const variableOptions = Array.isArray(templateVariableOptions) ? templateVariableOptions : [templateVariableOptions];
const availableColumns: Array<SelectableValue<string>> = builderQuery?.columns?.columns?.length
? filterDynamicColumns(builderQuery.columns.columns, allColumns).map((col) => ({ label: col, value: col }))
: allColumns.filter((col) => col.type !== 'dynamic').map((col) => ({ label: col.name, value: col.name }));
const selectableOptions = [...availableColumns, ...variableOptions];
const usedColumnsInOtherGroups = (currentGroupIndex: number): string[] => {
return filters
.flatMap((group, idx) => (idx !== currentGroupIndex ? group.expressions : []))
.map((exp) => exp.property.name)
.filter(Boolean);
};
useEffect(() => {
const currentTable = builderQuery?.from?.property.name || null;
if (prevTable.current !== currentTable || builderQuery?.where?.expressions.length === 0) {
setFilters([]);
hasLoadedFilters.current = false;
prevTable.current = currentTable;
}
}, [builderQuery]);
const updateFilters = (updated: BuilderQueryEditorWhereExpression[]) => {
setFilters(updated);
buildAndUpdateQuery({ where: updated });
};
const onAddOrFilters = (
groupIndex: number,
field: 'property' | 'operator' | 'value',
value: string,
filterIndex?: number
) => {
const updated = [...filters];
const group = updated[groupIndex];
if (!group) {
return;
}
let filter: BuilderQueryEditorWhereExpressionItems =
filterIndex !== undefined
? { ...group.expressions[filterIndex] }
: {
type: BuilderQueryEditorExpressionType.Operator,
property: { name: '', type: BuilderQueryEditorPropertyType.String },
operator: { name: '==', value: '' },
};
if (field === 'property') {
filter.property.name = value;
filter.operator.value = '';
} else if (field === 'operator') {
filter.operator.name = value;
} else if (field === 'value') {
filter.operator.value = value;
}
const isValid = filter.property.name && filter.operator.name && filter.operator.value !== '';
if (filterIndex !== undefined) {
group.expressions[filterIndex] = filter;
} else {
group.expressions.push(filter);
}
updated[groupIndex] = group;
setFilters(updated);
if (isValid) {
updateFilters(updated);
}
};
const onAddAndFilters = () => {
const updated = [
...filters,
{
type: BuilderQueryEditorExpressionType.Or,
expressions: [
{
type: BuilderQueryEditorExpressionType.Operator,
property: { name: '', type: BuilderQueryEditorPropertyType.String },
operator: { name: '==', value: '' },
},
],
},
];
updateFilters(updated);
};
const onDeleteFilter = (groupIndex: number, filterIndex: number) => {
const updated = [...filters];
updated[groupIndex].expressions.splice(filterIndex, 1);
if (updated[groupIndex].expressions.length === 0) {
updated.splice(groupIndex, 1);
}
updateFilters(updated);
};
const getFilterValues = async (filter: BuilderQueryEditorWhereExpressionItems) => {
const from = timeRange?.from?.toISOString();
const to = timeRange?.to?.toISOString();
const timeColumn = query.azureLogAnalytics?.timeColumn || 'TimeGenerated';
const kustoQuery = `
${query.azureLogAnalytics?.builderQuery?.from?.property.name}
| where ${timeColumn} >= datetime(${from}) and ${timeColumn} <= datetime(${to})
| distinct ${filter.property.name}
| limit 1000
`;
const results: any = await lastValueFrom(
datasource.azureLogAnalyticsDatasource.query({
requestId: 'azure-logs-builder-filter-values',
interval: '',
intervalMs: 0,
scopedVars: {},
timezone: '',
app: CoreApp.Unknown,
startTime: 0,
range: timeRange || getDefaultTimeRange(),
targets: [
{
refId: 'A',
queryType: AzureQueryType.LogAnalytics,
azureLogAnalytics: {
query: kustoQuery,
resources: query.azureLogAnalytics?.resources ?? [],
},
},
],
})
);
if (results.state === 'Done') {
const values = results.data?.[0]?.fields?.[0]?.values ?? [];
return values.toArray().map(
(v: any): ComboboxOption<string> => ({
label: String(v),
value: String(v),
})
);
}
return [];
};
return (
<EditorRow>
<EditorFieldGroup>
<EditorField label="Filters" optional tooltip="Narrow results by applying conditions to specific columns.">
<div className={styles.filters}>
{filters.length === 0 || filters.every((g) => g.expressions.length === 0) ? (
<InputGroup>
<Button variant="secondary" onClick={onAddAndFilters} icon="plus" />
</InputGroup>
) : (
<>
{filters.map((group, groupIndex) => (
<div key={groupIndex}>
{groupIndex > 0 && filters[groupIndex - 1]?.expressions.length > 0 && (
<Label style={{ padding: '9px 14px' }}>AND</Label>
)}
<InputGroup>
<>
{group.expressions.map((filter, filterIndex) => (
<FilterItem
key={`${groupIndex}-${filterIndex}`}
filter={filter}
filterIndex={filterIndex}
groupIndex={groupIndex}
usedColumns={usedColumnsInOtherGroups(groupIndex)}
selectableOptions={selectableOptions}
onChange={onAddOrFilters}
onDelete={onDeleteFilter}
getFilterValues={getFilterValues}
showOr={filterIndex < group.expressions.length - 1}
/>
))}
</>
<Button
variant="secondary"
style={{ marginLeft: '15px' }}
onClick={() => onAddOrFilters(groupIndex, 'property', '')}
icon="plus"
/>
</InputGroup>
</div>
))}
{filters.some((g) => g.expressions.length > 0) && (
<Button variant="secondary" onClick={onAddAndFilters} style={{ marginTop: '8px' }}>
Add group
</Button>
)}
</>
)}
</div>
</EditorField>
</EditorFieldGroup>
</EditorRow>
);
};

@ -0,0 +1,135 @@
import React, { useState, useEffect, useRef } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorRow, EditorFieldGroup, EditorField, InputGroup } from '@grafana/plugin-ui';
import { Button, Input, Select } from '@grafana/ui';
import {
BuilderQueryEditorExpressionType,
BuilderQueryEditorWhereExpression,
BuilderQueryEditorPropertyType,
} from '../../dataquery.gen';
import { AzureLogAnalyticsMetadataColumn, AzureMonitorQuery } from '../../types';
import { BuildAndUpdateOptions, removeExtraQuotes } from './utils';
interface FuzzySearchProps {
query: AzureMonitorQuery;
allColumns: AzureLogAnalyticsMetadataColumn[];
buildAndUpdateQuery: (options: Partial<BuildAndUpdateOptions>) => void;
templateVariableOptions: SelectableValue<string>;
}
export const FuzzySearch: React.FC<FuzzySearchProps> = ({
buildAndUpdateQuery,
query,
allColumns,
templateVariableOptions,
}) => {
const builderQuery = query.azureLogAnalytics?.builderQuery;
const prevTable = useRef<string | null>(builderQuery?.from?.property.name || null);
const [searchTerm, setSearchTerm] = useState<string>('');
const [selectedColumn, setSelectedColumn] = useState<string>('');
const [isOpen, setIsOpen] = useState<boolean>(false);
const hasLoadedFuzzySearch = useRef(false);
useEffect(() => {
const currentTable = builderQuery?.from?.property.name || null;
if (prevTable.current !== currentTable) {
setSearchTerm('');
setSelectedColumn('');
setIsOpen(false);
hasLoadedFuzzySearch.current = false;
prevTable.current = currentTable;
}
if (!hasLoadedFuzzySearch.current && builderQuery?.fuzzySearch?.expressions?.length) {
const fuzzy = builderQuery.fuzzySearch.expressions[0];
setSearchTerm(removeExtraQuotes(String(fuzzy.expressions[0].operator?.value ?? '')));
setSelectedColumn(fuzzy.expressions[0].property?.name ?? '*');
setIsOpen(true);
hasLoadedFuzzySearch.current = true;
}
}, [builderQuery]);
const columnOptions: Array<SelectableValue<string>> = allColumns.map((col) => ({
label: col.name,
value: col.name,
}));
const safeTemplateVariables: Array<SelectableValue<string>> = Array.isArray(templateVariableOptions)
? templateVariableOptions
: [templateVariableOptions];
const defaultColumn: SelectableValue<string> = { label: 'All Columns *', value: '*' };
const selectableOptions = [defaultColumn, ...columnOptions, ...safeTemplateVariables];
const updateFuzzySearch = (column: string, term: string) => {
setSearchTerm(term);
setSelectedColumn(column);
const fuzzyExpression: BuilderQueryEditorWhereExpression = {
type: BuilderQueryEditorExpressionType.Operator,
expressions: [
{
type: BuilderQueryEditorExpressionType.Property,
operator: { name: 'has', value: term },
property: { name: column || '*', type: BuilderQueryEditorPropertyType.String },
},
],
};
buildAndUpdateQuery({
fuzzySearch: term ? [fuzzyExpression] : [],
});
};
const onDeleteFuzzySearch = () => {
setSearchTerm('');
setSelectedColumn('');
setIsOpen(false);
buildAndUpdateQuery({
fuzzySearch: [],
});
};
return (
<EditorRow>
<EditorFieldGroup>
<EditorField
label="Fuzzy Search"
optional={true}
tooltip={`Find approximate text matches with tolerance for spelling variations. By default, fuzzy search scans all
columns (*) in the entire table, not just specific fields.`}
>
<InputGroup>
{isOpen ? (
<>
<Input
className="width-10"
type="text"
placeholder="Enter search term"
value={searchTerm}
onChange={(e) => updateFuzzySearch(selectedColumn, e.currentTarget.value)}
/>
<Select
aria-label="Select Column"
options={selectableOptions}
value={{ label: selectedColumn || '*', value: selectedColumn || '*' }}
onChange={(e: SelectableValue<string>) => updateFuzzySearch(e.value ?? '*', searchTerm)}
width="auto"
/>
<Button variant="secondary" icon="times" onClick={onDeleteFuzzySearch} />
</>
) : (
<Button variant="secondary" onClick={() => setIsOpen(true)} icon="plus" />
)}
</InputGroup>
</EditorField>
</EditorFieldGroup>
</EditorRow>
);
};

@ -0,0 +1,78 @@
import React from 'react';
import { SelectableValue } from '@grafana/data';
import { AccessoryButton, InputGroup } from '@grafana/plugin-ui';
import { Select } from '@grafana/ui';
import {
BuilderQueryEditorGroupByExpression,
BuilderQueryEditorPropertyType,
BuilderQueryEditorExpressionType,
} from '../../dataquery.gen';
import { inputFieldSize } from './utils';
interface GroupByItemProps {
groupBy: BuilderQueryEditorGroupByExpression;
columns: Array<SelectableValue<string>>;
onChange: (item: BuilderQueryEditorGroupByExpression) => void;
onDelete: () => void;
templateVariableOptions: SelectableValue<string>;
}
export const GroupByItem: React.FC<GroupByItemProps> = ({
groupBy,
onChange,
onDelete,
columns,
templateVariableOptions,
}) => {
const columnOptions: Array<SelectableValue<string>> =
columns.length > 0
? columns.map((c) => ({ label: c.label, value: c.value }))
: [{ label: 'No columns available', value: '' }];
const selectableOptions: Array<SelectableValue<string>> = [
...columnOptions,
...(templateVariableOptions
? Array.isArray(templateVariableOptions)
? templateVariableOptions
: [templateVariableOptions]
: []),
];
const handleChange = (selectedValue: SelectableValue<string>) => {
if (!selectedValue.value) {
return;
}
const isTemplateVariable = selectedValue.value.startsWith('$');
const selectedColumn = columns.find((c) => c.value === selectedValue.value);
onChange({
...groupBy,
property: {
name: selectedValue.value,
type: isTemplateVariable
? BuilderQueryEditorPropertyType.String
: selectedColumn?.type || BuilderQueryEditorPropertyType.String,
},
interval: groupBy.interval,
type: BuilderQueryEditorExpressionType.Group_by,
});
};
return (
<InputGroup>
<Select
aria-label="column"
width={inputFieldSize}
value={groupBy.property?.name ? { label: groupBy.property.name, value: groupBy.property.name } : null}
options={selectableOptions}
allowCustomValue
onChange={handleChange}
/>
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} />
</InputGroup>
);
};

@ -0,0 +1,150 @@
import React, { useEffect, useState, useRef } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorField, EditorFieldGroup, EditorList, EditorRow, InputGroup } from '@grafana/plugin-ui';
import { Button } from '@grafana/ui';
import {
BuilderQueryEditorExpressionType,
BuilderQueryEditorGroupByExpression,
BuilderQueryEditorPropertyType,
} from '../../dataquery.gen';
import { AzureLogAnalyticsMetadataColumn, AzureMonitorQuery } from '../../types';
import { GroupByItem } from './GroupByItem';
import { BuildAndUpdateOptions } from './utils';
interface GroupBySectionProps {
query: AzureMonitorQuery;
allColumns: AzureLogAnalyticsMetadataColumn[];
buildAndUpdateQuery: (options: Partial<BuildAndUpdateOptions>) => void;
templateVariableOptions: SelectableValue<string>;
}
export const GroupBySection: React.FC<GroupBySectionProps> = ({
query,
buildAndUpdateQuery,
allColumns,
templateVariableOptions,
}) => {
const builderQuery = query.azureLogAnalytics?.builderQuery;
const prevTable = useRef<string | null>(builderQuery?.from?.property.name || null);
const [groupBys, setGroupBys] = useState<BuilderQueryEditorGroupByExpression[]>(
builderQuery?.groupBy?.expressions || []
);
const hasLoadedGroupBy = useRef(false);
useEffect(() => {
const currentTable = builderQuery?.from?.property.name || null;
if (prevTable.current !== currentTable || builderQuery?.groupBy?.expressions.length === 0) {
setGroupBys([]);
hasLoadedGroupBy.current = false;
prevTable.current = currentTable;
}
}, [builderQuery]);
const availableColumns: Array<SelectableValue<string>> = [];
const columns = builderQuery?.columns?.columns ?? [];
if (columns.length > 0) {
availableColumns.push(
...columns.map((col) => ({
label: col,
value: col,
}))
);
} else {
availableColumns.push(
...allColumns.map((col) => ({
label: col.name,
value: col.name,
}))
);
}
const handleGroupByChange = (newItems: Array<Partial<BuilderQueryEditorGroupByExpression>>) => {
setGroupBys(newItems);
buildAndUpdateQuery({
groupBy: newItems,
});
};
const onDeleteGroupBy = (propertyName: string) => {
setGroupBys((prevGroupBys) => {
const updatedGroupBys = prevGroupBys.filter((gb) => gb.property?.name !== propertyName);
buildAndUpdateQuery({
groupBy: updatedGroupBys,
});
return updatedGroupBys;
});
};
return (
<EditorRow>
<EditorFieldGroup>
<EditorField
label="Group by"
optional={true}
tooltip={`Organize results into categories based on specified columns. Group by can be used independently to list
unique values in selected columns, or combined with aggregate functions to produce summary statistics for
each group. When used alone, it returns distinct combinations of the specified columns.`}
>
<InputGroup>
{groupBys.length > 0 ? (
<EditorList
items={groupBys}
onChange={handleGroupByChange}
renderItem={makeRenderGroupBy(availableColumns, onDeleteGroupBy, templateVariableOptions)}
/>
) : (
<Button
variant="secondary"
icon="plus"
onClick={() =>
handleGroupByChange([
{
type: BuilderQueryEditorExpressionType.Group_by,
property: { type: BuilderQueryEditorPropertyType.String, name: '' },
},
])
}
/>
)}
</InputGroup>
</EditorField>
</EditorFieldGroup>
</EditorRow>
);
};
const makeRenderGroupBy = (
columns: Array<SelectableValue<string>>,
onDeleteGroupBy: (propertyName: string) => void,
templateVariableOptions: SelectableValue<string>
) => {
return (
item: BuilderQueryEditorGroupByExpression,
onChangeItem: (updatedItem: BuilderQueryEditorGroupByExpression) => void,
onDeleteItem: () => void
) => (
<GroupByItem
groupBy={item}
onChange={(updatedItem) => {
onChangeItem(updatedItem);
}}
onDelete={() => {
if (item.property?.name) {
onDeleteGroupBy(item.property.name);
}
onDeleteItem();
}}
columns={columns}
templateVariableOptions={templateVariableOptions}
/>
);
};

@ -0,0 +1,61 @@
import { css } from '@emotion/css';
import Prism from 'prismjs';
import React, { useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { EditorField, EditorFieldGroup, EditorRow } from '@grafana/plugin-ui';
import { Button, useStyles2 } from '@grafana/ui';
import 'prismjs/components/prism-kusto';
import 'prismjs/themes/prism-tomorrow.min.css';
interface KQLPreviewProps {
query: string;
hidden: boolean;
setHidden: React.Dispatch<React.SetStateAction<boolean>>;
}
const KQLPreview: React.FC<KQLPreviewProps> = ({ query, hidden, setHidden }) => {
const styles = useStyles2(getStyles);
useEffect(() => {
Prism.highlightAll();
}, [query]);
return (
<EditorRow>
<EditorFieldGroup>
<EditorField label="Query Preview">
<>
<Button hidden={!hidden} variant="secondary" onClick={() => setHidden(false)} size="sm">
show
</Button>
<div className={styles.codeBlock} hidden={hidden}>
<pre className={styles.code}>
<code className="language-kusto">{query}</code>
</pre>
</div>
<Button hidden={hidden} variant="secondary" onClick={() => setHidden(true)} size="sm">
hide
</Button>
</>
</EditorField>
</EditorFieldGroup>
</EditorRow>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
codeBlock: css({
width: '100%',
display: 'table',
tableLayout: 'fixed',
}),
code: css({
marginBottom: '4px',
}),
};
};
export default KQLPreview;

@ -0,0 +1,41 @@
import { useState } from 'react';
import { EditorRow, EditorFieldGroup, EditorField } from '@grafana/plugin-ui';
import { Input } from '@grafana/ui';
import { BuildAndUpdateOptions } from './utils';
interface LimitSectionProps {
buildAndUpdateQuery: (options: Partial<BuildAndUpdateOptions>) => void;
}
export const LimitSection: React.FC<LimitSectionProps> = (props) => {
const { buildAndUpdateQuery } = props;
const [limit, setLimit] = useState<number>(1000);
const handleQueryLimitUpdate = (newLimit: number) => {
buildAndUpdateQuery({
limit: newLimit,
});
};
return (
<EditorRow>
<EditorFieldGroup>
<EditorField label="Limit" optional={true} tooltip={`Restrict the number of rows returned (default is 1000).`}>
<Input
className="width-5"
type="number"
placeholder="Enter limit"
value={limit}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value.replace(/[^0-9]/g, '');
setLimit(Number(newValue));
handleQueryLimitUpdate(Number(newValue));
}}
/>
</EditorField>
</EditorFieldGroup>
</EditorRow>
);
};

@ -0,0 +1,158 @@
import React, { useMemo, useState, useCallback } from 'react';
import { SelectableValue, TimeRange } from '@grafana/data';
import { EditorRows } from '@grafana/plugin-ui';
import { Alert } from '@grafana/ui';
import {
BuilderQueryEditorExpressionType,
BuilderQueryEditorPropertyType,
BuilderQueryEditorReduceExpression,
BuilderQueryEditorWhereExpression,
BuilderQueryEditorGroupByExpression,
BuilderQueryEditorOrderByExpression,
BuilderQueryEditorPropertyExpression,
BuilderQueryExpression,
} from '../../dataquery.gen';
import Datasource from '../../datasource';
import { selectors } from '../../e2e/selectors';
import {
AzureLogAnalyticsMetadataTable,
AzureLogAnalyticsMetadataColumn,
AzureMonitorQuery,
EngineSchema,
} from '../../types';
import { AggregateSection } from './AggregationSection';
import { AzureMonitorKustoQueryBuilder } from './AzureMonitorKustoQueryBuilder';
import { FilterSection } from './FilterSection';
import { FuzzySearch } from './FuzzySearch';
import { GroupBySection } from './GroupBySection';
import KQLPreview from './KQLPreview';
import { LimitSection } from './LimitSection';
import { OrderBySection } from './OrderBySection';
import { TableSection } from './TableSection';
import { DEFAULT_LOGS_BUILDER_QUERY } from './utils';
interface LogsQueryBuilderProps {
query: AzureMonitorQuery;
basicLogsEnabled: boolean;
onQueryChange: (newQuery: AzureMonitorQuery) => void;
schema: EngineSchema;
templateVariableOptions: SelectableValue<string>;
datasource: Datasource;
timeRange?: TimeRange;
}
export const LogsQueryBuilder: React.FC<LogsQueryBuilderProps> = (props) => {
const { query, onQueryChange, schema, datasource, timeRange } = props;
const [isKQLPreviewHidden, setIsKQLPreviewHidden] = useState<boolean>(true);
const tables: AzureLogAnalyticsMetadataTable[] = useMemo(() => {
return schema?.database?.tables || [];
}, [schema?.database]);
const builderQuery: BuilderQueryExpression = query.azureLogAnalytics?.builderQuery || DEFAULT_LOGS_BUILDER_QUERY;
const allColumns: AzureLogAnalyticsMetadataColumn[] = useMemo(() => {
const tableName = builderQuery.from?.property.name;
const selectedTable = tables.find((table) => table.name === tableName);
return selectedTable?.columns || [];
}, [builderQuery, tables]);
const buildAndUpdateQuery = useCallback(
({
limit,
reduce,
where,
fuzzySearch,
groupBy,
orderBy,
columns,
from,
}: {
limit?: number;
reduce?: BuilderQueryEditorReduceExpression[];
where?: BuilderQueryEditorWhereExpression[];
fuzzySearch?: BuilderQueryEditorWhereExpression[];
groupBy?: BuilderQueryEditorGroupByExpression[];
orderBy?: BuilderQueryEditorOrderByExpression[];
columns?: string[];
from?: BuilderQueryEditorPropertyExpression;
}) => {
const datetimeColumn = allColumns.find((col) => col.type === 'datetime')?.name || 'TimeGenerated';
const timeFilterExpression: BuilderQueryEditorWhereExpression = {
type: BuilderQueryEditorExpressionType.Or,
expressions: [
{
type: BuilderQueryEditorExpressionType.Operator,
operator: { name: '$__timeFilter', value: datetimeColumn },
property: { name: datetimeColumn, type: BuilderQueryEditorPropertyType.Datetime },
},
],
};
const updatedBuilderQuery: BuilderQueryExpression = {
...builderQuery,
...(limit !== undefined ? { limit } : {}),
...(reduce !== undefined
? { reduce: { expressions: reduce, type: BuilderQueryEditorExpressionType.Reduce } }
: {}),
...(where !== undefined ? { where: { expressions: where, type: BuilderQueryEditorExpressionType.And } } : {}),
...(fuzzySearch !== undefined
? { fuzzySearch: { expressions: fuzzySearch, type: BuilderQueryEditorExpressionType.And } }
: {}),
...(groupBy !== undefined
? { groupBy: { expressions: groupBy, type: BuilderQueryEditorExpressionType.Group_by } }
: {}),
...(orderBy !== undefined
? { orderBy: { expressions: orderBy, type: BuilderQueryEditorExpressionType.Order_by } }
: {}),
...(columns !== undefined ? { columns: { columns, type: BuilderQueryEditorExpressionType.Property } } : {}),
...(from !== undefined ? { from } : {}),
timeFilter: { expressions: [timeFilterExpression], type: BuilderQueryEditorExpressionType.And },
};
const updatedQueryString = AzureMonitorKustoQueryBuilder.toQuery(updatedBuilderQuery);
onQueryChange({
...query,
azureLogAnalytics: {
...query.azureLogAnalytics,
builderQuery: updatedBuilderQuery,
query: updatedQueryString,
},
});
},
[query, builderQuery, onQueryChange, allColumns]
);
return (
<span data-testid={selectors.components.queryEditor.logsQueryEditor.container.input}>
<EditorRows>
{schema && tables.length === 0 && (
<Alert severity="warning" title="Resource loaded successfully but without any tables" />
)}
<TableSection {...props} tables={tables} allColumns={allColumns} buildAndUpdateQuery={buildAndUpdateQuery} />
<FilterSection
{...props}
allColumns={allColumns}
buildAndUpdateQuery={buildAndUpdateQuery}
datasource={datasource}
timeRange={timeRange}
/>
<AggregateSection {...props} allColumns={allColumns} buildAndUpdateQuery={buildAndUpdateQuery} />
<GroupBySection {...props} allColumns={allColumns} buildAndUpdateQuery={buildAndUpdateQuery} />
<OrderBySection {...props} allColumns={allColumns} buildAndUpdateQuery={buildAndUpdateQuery} />
<FuzzySearch {...props} allColumns={allColumns} buildAndUpdateQuery={buildAndUpdateQuery} />
<LimitSection {...props} buildAndUpdateQuery={buildAndUpdateQuery} />
<KQLPreview
query={query.azureLogAnalytics?.query || ''}
hidden={isKQLPreviewHidden}
setHidden={setIsKQLPreviewHidden}
/>
</EditorRows>
</span>
);
};

@ -0,0 +1,158 @@
import React, { useEffect, useRef, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorField, EditorFieldGroup, EditorRow, InputGroup } from '@grafana/plugin-ui';
import { Button, Select, Label } from '@grafana/ui';
import {
BuilderQueryEditorExpressionType,
BuilderQueryEditorOrderByExpression,
BuilderQueryEditorOrderByOptions,
BuilderQueryEditorPropertyType,
} from '../../dataquery.gen';
import { AzureLogAnalyticsMetadataColumn, AzureMonitorQuery } from '../../types';
import { BuildAndUpdateOptions, inputFieldSize } from './utils';
interface OrderBySectionProps {
query: AzureMonitorQuery;
allColumns: AzureLogAnalyticsMetadataColumn[];
buildAndUpdateQuery: (options: Partial<BuildAndUpdateOptions>) => void;
}
export const OrderBySection: React.FC<OrderBySectionProps> = ({ query, allColumns, buildAndUpdateQuery }) => {
const builderQuery = query.azureLogAnalytics?.builderQuery;
const prevTable = useRef<string | null>(builderQuery?.from?.property.name || null);
const hasLoadedOrderBy = useRef(false);
const [orderBy, setOrderBy] = useState<BuilderQueryEditorOrderByExpression[]>(
builderQuery?.orderBy?.expressions || []
);
useEffect(() => {
const currentTable = builderQuery?.from?.property.name || null;
if (prevTable.current !== currentTable || builderQuery?.orderBy?.expressions.length === 0) {
setOrderBy([]);
hasLoadedOrderBy.current = false;
prevTable.current = currentTable;
}
}, [builderQuery]);
const groupByColumns = builderQuery?.groupBy?.expressions?.map((g) => g.property?.name) || [];
const aggregateColumns = builderQuery?.reduce?.expressions?.map((r) => r.property?.name) || [];
const selectedColumns = builderQuery?.columns?.columns || [];
const allAvailableColumns =
groupByColumns.length > 0
? groupByColumns
: aggregateColumns.length > 0
? aggregateColumns
: selectedColumns.length > 0
? selectedColumns
: allColumns.map((col) => col.name);
const columnOptions = allAvailableColumns.map((col) => ({
label: col,
value: col,
}));
const orderOptions: Array<SelectableValue<string>> = [
{ label: 'Ascending', value: 'asc' },
{ label: 'Descending', value: 'desc' },
];
const handleOrderByChange = (index: number, key: 'column' | 'order', value: string) => {
setOrderBy((prev) => {
const updated = [...prev];
if (index === -1) {
updated.push({
property: { name: value, type: BuilderQueryEditorPropertyType.String },
order: BuilderQueryEditorOrderByOptions.Asc,
type: BuilderQueryEditorExpressionType.Order_by,
});
} else {
updated[index] = {
...updated[index],
property:
key === 'column' ? { name: value, type: BuilderQueryEditorPropertyType.String } : updated[index].property,
order:
key === 'order' &&
(value === BuilderQueryEditorOrderByOptions.Asc || value === BuilderQueryEditorOrderByOptions.Desc)
? value
: updated[index].order,
};
}
buildAndUpdateQuery({
orderBy: updated,
});
return updated;
});
};
const onDeleteOrderBy = (index: number) => {
setOrderBy((prev) => {
const updated = prev.filter((_, i) => i !== index);
buildAndUpdateQuery({
orderBy: updated,
});
return updated;
});
};
return (
<EditorRow>
<EditorFieldGroup>
<EditorField
label="Order By"
optional={true}
tooltip={`Sort results based on one or more columns in ascending or descending order.`}
>
<>
{orderBy.length > 0 ? (
orderBy.map((entry, index) => (
<InputGroup key={index}>
<Select
aria-label="Order by column"
width={inputFieldSize}
value={entry.property?.name ? { label: entry.property.name, value: entry.property.name } : null}
options={columnOptions}
onChange={(e) => e.value && handleOrderByChange(index, 'column', e.value)}
/>
<Label style={{ margin: '9px 9px 0 9px' }}>BY</Label>
<Select
aria-label="Order Direction"
width={inputFieldSize}
value={orderOptions.find((o) => o.value === entry.order) || null}
options={orderOptions}
onChange={(e) => e.value && handleOrderByChange(index, 'order', e.value)}
/>
<Button variant="secondary" icon="times" onClick={() => onDeleteOrderBy(index)} />
{index === orderBy.length - 1 ? (
<Button
variant="secondary"
onClick={() => handleOrderByChange(-1, 'column', '')}
icon="plus"
style={{ marginLeft: '15px' }}
/>
) : (
<></>
)}
</InputGroup>
))
) : (
<InputGroup>
<Button variant="secondary" onClick={() => handleOrderByChange(-1, 'column', '')} icon="plus" />
</InputGroup>
)}
</>
</EditorField>
</EditorFieldGroup>
</EditorRow>
);
};

@ -0,0 +1,163 @@
import React from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorField, EditorFieldGroup, EditorRow, InputGroup } from '@grafana/plugin-ui';
import { Button, Select } from '@grafana/ui';
import { BuilderQueryEditorExpressionType, BuilderQueryEditorPropertyType } from '../../dataquery.gen';
import { AzureMonitorQuery, AzureLogAnalyticsMetadataColumn, AzureLogAnalyticsMetadataTable } from '../../types';
import { BuildAndUpdateOptions, inputFieldSize } from './utils';
interface TableSectionProps {
allColumns: AzureLogAnalyticsMetadataColumn[];
tables: AzureLogAnalyticsMetadataTable[];
query: AzureMonitorQuery;
buildAndUpdateQuery: (options: Partial<BuildAndUpdateOptions>) => void;
templateVariableOptions?: SelectableValue<string>;
}
export const TableSection: React.FC<TableSectionProps> = (props) => {
const { allColumns, query, tables, buildAndUpdateQuery, templateVariableOptions } = props;
const builderQuery = query.azureLogAnalytics?.builderQuery;
const selectedColumns = query.azureLogAnalytics?.builderQuery?.columns?.columns || [];
const tableOptions: Array<SelectableValue<string>> = tables.map((t) => ({
label: t.name,
value: t.name,
}));
const columnOptions: Array<SelectableValue<string>> = allColumns.map((col) => ({
label: col.name,
value: col.name,
type: col.type,
}));
const selectAllOption: SelectableValue<string> = {
label: 'All Columns',
value: '__all_columns__',
};
const selectableOptions: Array<SelectableValue<string>> = [
selectAllOption,
...columnOptions,
...(templateVariableOptions
? Array.isArray(templateVariableOptions)
? templateVariableOptions
: [templateVariableOptions]
: []),
];
const handleTableChange = (selected: SelectableValue<string>) => {
const selectedTable = tables.find((t) => t.name === selected.value);
if (!selectedTable) {
return;
}
buildAndUpdateQuery({
from: {
property: {
name: selectedTable.name,
type: BuilderQueryEditorPropertyType.String,
},
type: BuilderQueryEditorExpressionType.Property,
},
reduce: [],
where: [],
fuzzySearch: [],
groupBy: [],
orderBy: [],
columns: [],
});
};
const handleColumnsChange = (selected: SelectableValue<string> | Array<SelectableValue<string>> | null) => {
const selectedArray = Array.isArray(selected) ? selected : selected ? [selected] : [];
if (selectedArray.length === 0) {
buildAndUpdateQuery({ columns: [] });
return;
}
const includesAll = selectedArray.some((opt) => opt.value === '__all_columns__');
const lastSelected = selectedArray[selectedArray.length - 1];
if (includesAll && lastSelected.value === '__all_columns__') {
buildAndUpdateQuery({
columns: allColumns.map((col) => col.name),
});
return;
}
if (includesAll && selectedArray.length > 1) {
const filtered = selectedArray.filter((opt) => opt.value !== '__all_columns__');
buildAndUpdateQuery({
columns: filtered.map((opt) => opt.value!),
});
return;
}
if (includesAll && selectedArray.length === 1) {
buildAndUpdateQuery({
columns: allColumns.map((col) => col.name),
});
return;
}
buildAndUpdateQuery({
columns: selectedArray.map((opt) => opt.value!),
});
};
const onDeleteAllColumns = () => {
buildAndUpdateQuery({
columns: [],
});
};
const allColumnNames = allColumns.length > 0 ? allColumns.map((col) => col.name) : [];
const areAllColumnsSelected =
allColumnNames.length > 0 &&
selectedColumns.length > 0 &&
selectedColumns.length === allColumnNames.length &&
allColumnNames.every((col) => selectedColumns.includes(col));
const columnSelectValue: Array<SelectableValue<string>> = areAllColumnsSelected
? [selectAllOption]
: selectedColumns.map((col) => ({ label: col, value: col }));
return (
<EditorRow>
<EditorFieldGroup>
<EditorField label="Table">
<Select
aria-label="Table"
value={builderQuery?.from?.property.name}
options={tableOptions}
placeholder="Select a table"
onChange={handleTableChange}
width={inputFieldSize}
/>
</EditorField>
<EditorField label="Columns">
<InputGroup>
<Select
aria-label="Columns"
isMulti
isClearable
closeMenuOnSelect={false}
value={columnSelectValue}
options={selectableOptions}
placeholder="Select columns"
onChange={handleColumnsChange}
isDisabled={!builderQuery?.from?.property.name}
width={30}
/>
<Button variant="secondary" icon="times" onClick={onDeleteAllColumns} />
</InputGroup>
</EditorField>
</EditorFieldGroup>
</EditorRow>
);
};

@ -0,0 +1,49 @@
import { QueryEditorProperty, QueryEditorPropertyType } from '../../types';
export enum QueryEditorExpressionType {
Property = 'property',
Operator = 'operator',
Reduce = 'reduce',
FunctionParameter = 'functionParameter',
GroupBy = 'groupBy',
Or = 'or',
And = 'and',
}
export interface QueryEditorExpression {
type: QueryEditorExpressionType;
}
export interface QueryEditorFunctionParameterExpression extends QueryEditorExpression {
value: string;
fieldType: QueryEditorPropertyType;
name: string;
}
export interface QueryEditorReduceExpression extends QueryEditorExpression {
property: QueryEditorProperty;
reduce: QueryEditorProperty;
parameters?: QueryEditorFunctionParameterExpression[];
focus?: boolean;
}
export interface QueryEditorGroupByExpression extends QueryEditorExpression {
property: QueryEditorProperty;
interval?: QueryEditorProperty;
focus?: boolean;
}
export interface QueryEditorArrayExpression extends QueryEditorExpression {
expressions: QueryEditorExpression[] | QueryEditorArrayExpression[];
}
export interface QueryEditorReduceExpression extends QueryEditorExpression {
property: QueryEditorProperty;
reduce: QueryEditorProperty;
parameters?: QueryEditorFunctionParameterExpression[];
focus?: boolean;
}
export interface QueryEditorPropertyExpression extends QueryEditorExpression {
property: QueryEditorProperty;
}

@ -0,0 +1,102 @@
import { escapeRegExp } from 'lodash';
import { SelectableValue } from '@grafana/data';
import {
BuilderQueryExpression,
BuilderQueryEditorExpressionType,
BuilderQueryEditorPropertyType,
BuilderQueryEditorReduceExpression,
BuilderQueryEditorWhereExpression,
BuilderQueryEditorGroupByExpression,
BuilderQueryEditorOrderByExpression,
BuilderQueryEditorPropertyExpression,
} from '../../dataquery.gen';
import { AzureLogAnalyticsMetadataColumn, AzureMonitorQuery } from '../../types';
const DYNAMIC_TYPE_ARRAY_DELIMITER = '["`indexer`"]';
export const inputFieldSize = 20;
export const valueToDefinition = (name: string) => {
return {
value: name,
label: name.replace(new RegExp(escapeRegExp(DYNAMIC_TYPE_ARRAY_DELIMITER), 'g'), '[ ]'),
};
};
export const DEFAULT_LOGS_BUILDER_QUERY: BuilderQueryExpression = {
columns: { columns: [], type: BuilderQueryEditorExpressionType.Property },
from: {
type: BuilderQueryEditorExpressionType.Property,
property: { type: BuilderQueryEditorPropertyType.String, name: '' },
},
groupBy: { expressions: [], type: BuilderQueryEditorExpressionType.Group_by },
reduce: { expressions: [], type: BuilderQueryEditorExpressionType.Reduce },
where: { expressions: [], type: BuilderQueryEditorExpressionType.And },
limit: 1000,
};
export const OPERATORS_BY_TYPE: Record<string, Array<SelectableValue<string>>> = {
string: [
{ label: '==', value: '==' },
{ label: '!=', value: '!=' },
{ label: 'contains', value: 'contains' },
{ label: '!contains', value: '!contains' },
{ label: 'startswith', value: 'startswith' },
{ label: 'endswith', value: 'endswith' },
],
int: [
{ label: '==', value: '==' },
{ label: '!=', value: '!=' },
{ label: '>', value: '>' },
{ label: '<', value: '<' },
{ label: '>=', value: '>=' },
{ label: '<=', value: '<=' },
],
datetime: [
{ label: 'before', value: '<' },
{ label: 'after', value: '>' },
{ label: 'between', value: 'between' },
],
bool: [
{ label: '==', value: '==' },
{ label: '!=', value: '!=' },
],
};
export const toOperatorOptions = (type: string): Array<SelectableValue<string>> => {
return OPERATORS_BY_TYPE[type] || OPERATORS_BY_TYPE.string;
};
export const removeExtraQuotes = (value: string): string => {
let strValue = String(value).trim();
if ((strValue.startsWith("'") && strValue.endsWith("'")) || (strValue.startsWith('"') && strValue.endsWith('"'))) {
return strValue.slice(1, -1);
}
return strValue;
};
export interface BuildAndUpdateOptions {
query: AzureMonitorQuery;
onQueryUpdate: (newQuery: AzureMonitorQuery) => void;
allColumns: AzureLogAnalyticsMetadataColumn[];
limit?: number;
reduce?: BuilderQueryEditorReduceExpression[];
where?: BuilderQueryEditorWhereExpression[];
fuzzySearch?: BuilderQueryEditorWhereExpression[];
groupBy?: BuilderQueryEditorGroupByExpression[];
orderBy?: BuilderQueryEditorOrderByExpression[];
columns?: string[];
from?: BuilderQueryEditorPropertyExpression;
}
export const aggregateOptions = [
{ label: 'sum', value: 'sum' },
{ label: 'avg', value: 'avg' },
{ label: 'percentile', value: 'percentile' },
{ label: 'count', value: 'count' },
{ label: 'min', value: 'min' },
{ label: 'max', value: 'max' },
{ label: 'dcount', value: 'dcount' },
{ label: 'stdev', value: 'stdev' },
];

@ -19,6 +19,10 @@ jest.mock('@grafana/runtime', () => ({
}
return val;
},
getVariables: () => [
{ name: 'var1', current: { value: 'value1' } },
{ name: 'var2', current: { value: 'value2' } },
],
}),
}));
@ -43,6 +47,7 @@ describe('LogsQueryEditor', () => {
delete query?.subscription;
delete query?.azureLogAnalytics?.resources;
const onChange = jest.fn();
const onQueryChange = jest.fn();
const basicLogsEnabled = false;
render(
@ -51,6 +56,7 @@ describe('LogsQueryEditor', () => {
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
onQueryChange={onQueryChange}
setError={() => {}}
basicLogsEnabled={basicLogsEnabled}
/>
@ -94,6 +100,7 @@ describe('LogsQueryEditor', () => {
delete query?.azureLogAnalytics?.resources;
const basicLogsEnabled = false;
const onChange = jest.fn();
const onQueryChange = jest.fn();
render(
<LogsQueryEditor
@ -101,6 +108,7 @@ describe('LogsQueryEditor', () => {
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
onQueryChange={onQueryChange}
setError={() => {}}
basicLogsEnabled={basicLogsEnabled}
/>
@ -129,6 +137,7 @@ describe('LogsQueryEditor', () => {
delete query?.azureLogAnalytics?.resources;
const basicLogsEnabled = false;
const onChange = jest.fn();
const onQueryChange = jest.fn();
render(
<LogsQueryEditor
@ -136,6 +145,7 @@ describe('LogsQueryEditor', () => {
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
onQueryChange={onQueryChange}
setError={() => {}}
basicLogsEnabled={basicLogsEnabled}
/>
@ -164,6 +174,7 @@ describe('LogsQueryEditor', () => {
delete query?.azureLogAnalytics?.resources;
const basicLogsEnabled = false;
const onChange = jest.fn();
const onQueryChange = jest.fn();
render(
<LogsQueryEditor
@ -171,6 +182,7 @@ describe('LogsQueryEditor', () => {
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
onQueryChange={onQueryChange}
setError={() => {}}
basicLogsEnabled={basicLogsEnabled}
/>
@ -203,6 +215,7 @@ describe('LogsQueryEditor', () => {
const query = createMockQuery();
const basicLogsEnabled = false;
const onChange = jest.fn();
const onQueryChange = jest.fn();
render(
<LogsQueryEditor
@ -210,6 +223,7 @@ describe('LogsQueryEditor', () => {
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
onQueryChange={onQueryChange}
setError={() => {}}
basicLogsEnabled={basicLogsEnabled}
/>
@ -233,6 +247,7 @@ describe('LogsQueryEditor', () => {
const query = createMockQuery();
const basicLogsEnabled = false;
const onChange = jest.fn();
const onQueryChange = jest.fn();
const date = dateTime(new Date());
render(
@ -241,6 +256,7 @@ describe('LogsQueryEditor', () => {
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
onQueryChange={onQueryChange}
setError={() => {}}
basicLogsEnabled={basicLogsEnabled}
data={{
@ -274,6 +290,7 @@ describe('LogsQueryEditor', () => {
});
const basicLogsEnabled = true;
const onChange = jest.fn();
const onQueryChange = jest.fn();
await act(async () => {
render(
@ -282,6 +299,7 @@ describe('LogsQueryEditor', () => {
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
onQueryChange={onQueryChange}
setError={() => {}}
basicLogsEnabled={basicLogsEnabled}
/>
@ -300,6 +318,7 @@ describe('LogsQueryEditor', () => {
});
const basicLogsEnabled = true;
const onChange = jest.fn();
const onQueryChange = jest.fn();
await act(async () => {
render(
@ -308,6 +327,7 @@ describe('LogsQueryEditor', () => {
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
onQueryChange={onQueryChange}
setError={() => {}}
basicLogsEnabled={basicLogsEnabled}
/>
@ -328,6 +348,7 @@ describe('LogsQueryEditor', () => {
});
const basicLogsEnabled = false;
const onChange = jest.fn();
const onQueryChange = jest.fn();
await act(async () => {
render(
@ -336,6 +357,7 @@ describe('LogsQueryEditor', () => {
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
onQueryChange={onQueryChange}
setError={() => {}}
basicLogsEnabled={basicLogsEnabled}
/>
@ -354,6 +376,7 @@ describe('LogsQueryEditor', () => {
});
const basicLogsEnabled = true;
const onChange = jest.fn();
const onQueryChange = jest.fn();
await act(async () => {
render(
@ -362,6 +385,7 @@ describe('LogsQueryEditor', () => {
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
onQueryChange={onQueryChange}
setError={() => {}}
basicLogsEnabled={basicLogsEnabled}
/>
@ -382,6 +406,7 @@ describe('LogsQueryEditor', () => {
});
const basicLogsEnabled = true;
const onChange = jest.fn();
const onQueryChange = jest.fn();
await act(async () => {
render(
@ -390,6 +415,7 @@ describe('LogsQueryEditor', () => {
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
onQueryChange={onQueryChange}
setError={() => {}}
basicLogsEnabled={basicLogsEnabled}
/>
@ -412,6 +438,7 @@ describe('LogsQueryEditor', () => {
basicLogsQuery: true,
},
});
const onQueryChange = jest.fn();
mockDatasource.azureLogAnalyticsDatasource.getBasicLogsQueryUsage.mockResolvedValue(0);
await act(async () => {
@ -421,6 +448,7 @@ describe('LogsQueryEditor', () => {
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
onQueryChange={onQueryChange}
setError={() => {}}
basicLogsEnabled={true}
/>
@ -447,6 +475,7 @@ describe('LogsQueryEditor', () => {
basicLogsQuery: true,
},
});
const onQueryChange = jest.fn();
mockDatasource.azureLogAnalyticsDatasource.getBasicLogsQueryUsage.mockResolvedValue(0.45);
await act(async () => {
@ -456,6 +485,7 @@ describe('LogsQueryEditor', () => {
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
onQueryChange={onQueryChange}
setError={() => {}}
basicLogsEnabled={true}
/>
@ -481,6 +511,7 @@ describe('LogsQueryEditor', () => {
query: '',
},
});
const onQueryChange = jest.fn();
mockDatasource.azureLogAnalyticsDatasource.getBasicLogsQueryUsage.mockResolvedValue(0.5);
await act(async () => {
@ -490,6 +521,7 @@ describe('LogsQueryEditor', () => {
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
onQueryChange={onQueryChange}
setError={() => {}}
basicLogsEnabled={true}
/>
@ -509,6 +541,7 @@ describe('LogsQueryEditor', () => {
},
});
const onChange = jest.fn();
const onQueryChange = jest.fn();
await act(async () => {
render(
@ -517,6 +550,7 @@ describe('LogsQueryEditor', () => {
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
onQueryChange={onQueryChange}
setError={() => {}}
basicLogsEnabled={false}
/>
@ -532,6 +566,7 @@ describe('LogsQueryEditor', () => {
const mockDatasource = createMockDatasource({ resourcePickerData: createMockResourcePickerData() });
const query = { ...createMockQuery(), azureLogAnalytics: undefined };
const onChange = jest.fn();
const onQueryChange = jest.fn();
await act(async () => {
render(
@ -540,6 +575,7 @@ describe('LogsQueryEditor', () => {
datasource={mockDatasource}
variableOptionGroup={variableOptionGroup}
onChange={onChange}
onQueryChange={onQueryChange}
setError={() => {}}
basicLogsEnabled={false}
/>

@ -2,12 +2,14 @@ import { useEffect, useState } from 'react';
import { PanelData, TimeRange } from '@grafana/data';
import { EditorFieldGroup, EditorRow, EditorRows } from '@grafana/plugin-ui';
import { getTemplateSrv } from '@grafana/runtime';
import { Alert, LinkButton, Text, TextLink } from '@grafana/ui';
import { config, getTemplateSrv } from '@grafana/runtime';
import { Alert, LinkButton, Space, Text, TextLink } from '@grafana/ui';
import { LogsEditorMode } from '../../dataquery.gen';
import Datasource from '../../datasource';
import { selectors } from '../../e2e/selectors';
import { AzureMonitorErrorish, AzureMonitorOption, AzureMonitorQuery, ResultFormat, EngineSchema } from '../../types';
import { LogsQueryBuilder } from '../LogsQueryBuilder/LogsQueryBuilder';
import ResourceField from '../ResourceField';
import { ResourceRow, ResourceRowGroup, ResourceRowType } from '../ResourcePicker/types';
import { parseResourceDetails } from '../ResourcePicker/utils';
@ -27,6 +29,7 @@ interface LogsQueryEditorProps {
basicLogsEnabled: boolean;
subscriptionId?: string;
onChange: (newQuery: AzureMonitorQuery) => void;
onQueryChange: (newQuery: AzureMonitorQuery) => void;
variableOptionGroup: { label: string; options: AzureMonitorOption[] };
setError: (source: string, error: AzureMonitorErrorish | undefined) => void;
hideFormatAs?: boolean;
@ -41,6 +44,7 @@ const LogsQueryEditor = ({
subscriptionId,
variableOptionGroup,
onChange,
onQueryChange,
setError,
hideFormatAs,
timeRange,
@ -54,6 +58,7 @@ const LogsQueryEditor = ({
const templateSrv = getTemplateSrv();
const from = templateSrv?.replace('$__from');
const to = templateSrv?.replace('$__to');
const templateVariableOptions = templateSrv.getVariables();
const disableRow = (row: ResourceRow, selectedRows: ResourceRowGroup) => {
if (selectedRows.length === 0) {
@ -93,6 +98,35 @@ const LogsQueryEditor = ({
}
}, [basicLogsEnabled, onChange, query, showBasicLogsToggle]);
useEffect(() => {
const hasRawKql = !!query.azureLogAnalytics?.query;
const hasNoBuilder = !query.azureLogAnalytics?.builderQuery;
const modeUnset = query.azureLogAnalytics?.mode === undefined;
if (hasRawKql && hasNoBuilder && modeUnset) {
onChange({
...query,
azureLogAnalytics: {
...query.azureLogAnalytics,
mode: LogsEditorMode.Raw,
},
});
}
}, [query, onChange]);
useEffect(() => {
if (query.azureLogAnalytics?.mode === LogsEditorMode.Raw && query.azureLogAnalytics?.builderQuery !== undefined) {
onQueryChange({
...query,
azureLogAnalytics: {
...query.azureLogAnalytics,
builderQuery: undefined,
query: '',
},
});
}
}, [query.azureLogAnalytics?.mode, onQueryChange, query]);
useEffect(() => {
const getBasicLogsUsage = async (query: AzureMonitorQuery) => {
try {
@ -197,15 +231,29 @@ const LogsQueryEditor = ({
/>
</EditorFieldGroup>
</EditorRow>
<QueryField
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
schema={schema}
/>
<Space />
{query.azureLogAnalytics?.mode === LogsEditorMode.Builder &&
!!config.featureToggles.azureMonitorLogsBuilderEditor ? (
<LogsQueryBuilder
query={query}
schema={schema!}
basicLogsEnabled={basicLogsEnabled}
onQueryChange={onQueryChange}
templateVariableOptions={templateVariableOptions}
datasource={datasource}
timeRange={timeRange}
/>
) : (
<QueryField
query={query}
datasource={datasource}
subscriptionId={subscriptionId}
variableOptionGroup={variableOptionGroup}
onQueryChange={onChange}
setError={setError}
schema={schema}
/>
)}
{dataIngestedWarning}
<EditorRow>
<EditorFieldGroup>

@ -85,6 +85,7 @@ export function TimeManagement({ query, onQueryChange: onChange, schema }: Azure
setDisabledTimePicker(false);
}
}, [query.azureLogAnalytics?.basicLogsQuery]);
return (
<>
<InlineField
@ -92,23 +93,28 @@ export function TimeManagement({ query, onQueryChange: onChange, schema }: Azure
tooltip={
<span>
Specifies the time-range used to query. The <code>Query</code> option will only use time-ranges specified in
the query. <code>Dashboard</code> will only use the Grafana time-range.
the query. <code>Dashboard</code> will only use the Grafana time-range. In Logs Builder mode, only Dashboard
time will be used.
</span>
}
>
<RadioButtonGroup
options={[
{ label: 'Query', value: 'query' },
{ label: 'Query', value: 'query', disabled: query.azureLogAnalytics?.mode === 'builder' },
{ label: 'Dashboard', value: 'dashboard' },
]}
value={query.azureLogAnalytics?.dashboardTime ? 'dashboard' : 'query'}
value={
query.azureLogAnalytics?.dashboardTime || query.azureLogAnalytics?.mode === 'builder'
? 'dashboard'
: 'query'
}
size={'md'}
onChange={(val) => onChange(setDashboardTime(query, val))}
disabled={disabledTimePicker}
disabledOptions={disabledTimePicker ? ['query'] : []}
/>
</InlineField>
{query.azureLogAnalytics?.dashboardTime && (
{(query.azureLogAnalytics?.dashboardTime || query.azureLogAnalytics?.mode === 'builder') && (
<InlineField
label="Time Column"
tooltip={

@ -23,12 +23,18 @@ jest.mock('@grafana/ui', () => ({
}));
jest.mock('@grafana/runtime', () => ({
___esModule: true,
...jest.requireActual('@grafana/runtime'),
getTemplateSrv: () => ({
replace: (val: string) => {
if (val === '$ws') {
return '/subscriptions/def-456/resourceGroups/dev-3/providers/microsoft.operationalinsights/workspaces/la-workspace';
}
return val;
},
getVariables: () => [
{ name: 'var1', current: { value: 'value1' } },
{ name: 'var2', current: { value: 'value2' } },
],
}),
}));
@ -99,11 +105,13 @@ describe('Azure Monitor QueryEditor', () => {
const metrics = await screen.findByLabelText(/Service/);
await selectOptionInTest(metrics, 'Logs');
expect(onChange).toHaveBeenCalledWith({
refId: mockQuery.refId,
datasource: mockQuery.datasource,
queryType: AzureQueryType.LogAnalytics,
});
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
refId: mockQuery.refId,
datasource: mockQuery.datasource,
queryType: AzureQueryType.LogAnalytics,
})
);
});
it('displays error messages from frontend Azure calls', async () => {

@ -1,11 +1,10 @@
import { css } from '@emotion/css';
import { debounce } from 'lodash';
import { useCallback, useMemo, useState } from 'react';
import { useEffectOnce } from 'react-use';
import { CoreApp, QueryEditorProps } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { Alert, Button, CodeEditor, Space } from '@grafana/ui';
import { config } from '@grafana/runtime';
import { Alert, CodeEditor, Space } from '@grafana/ui';
import AzureMonitorDatasource from '../../datasource';
import { selectors } from '../../e2e/selectors';
@ -97,26 +96,14 @@ const QueryEditor = ({
onClose={() => setAzureLogsCheatSheetModalOpen(false)}
onChange={(a) => onChange({ ...a, queryType: AzureQueryType.LogAnalytics })}
/>
<div className={css({ display: 'flex', alignItems: 'center' })}>
<QueryHeader query={query} onQueryChange={onQueryChange} />
{query.queryType === AzureQueryType.LogAnalytics && (
<Button
aria-label="Azure logs kick start your query button"
variant="secondary"
size="sm"
onClick={() => {
setAzureLogsCheatSheetModalOpen((prevValue) => !prevValue);
reportInteraction('grafana_azure_logs_query_patterns_opened', {
version: 'v2',
editorMode: query.azureLogAnalytics,
});
}}
>
Kick start your query
</Button>
)}
</div>
<QueryHeader
query={query}
onQueryChange={onQueryChange}
setAzureLogsCheatSheetModalOpen={setAzureLogsCheatSheetModalOpen}
onRunQuery={baseOnRunQuery}
data={data}
app={app}
/>
<EditorForQueryType
data={data}
subscriptionId={subscriptionId}
@ -124,11 +111,11 @@ const QueryEditor = ({
query={query}
datasource={datasource}
onChange={onQueryChange}
onQueryChange={onChange}
variableOptionGroup={variableOptionGroup}
setError={setError}
range={range}
/>
{errorMessage && (
<>
<Space v={2} />
@ -146,6 +133,8 @@ interface EditorForQueryTypeProps extends Omit<AzureMonitorQueryEditorProps, 'on
basicLogsEnabled: boolean;
variableOptionGroup: { label: string; options: AzureMonitorOption[] };
setError: (source: string, error: AzureMonitorErrorish | undefined) => void;
// Used to update the query without running it
onQueryChange: (newQuery: AzureMonitorQuery) => void;
}
const EditorForQueryType = ({
@ -157,6 +146,7 @@ const EditorForQueryType = ({
variableOptionGroup,
onChange,
setError,
onQueryChange,
range,
}: EditorForQueryTypeProps) => {
switch (query.queryType) {
@ -184,6 +174,7 @@ const EditorForQueryType = ({
variableOptionGroup={variableOptionGroup}
setError={setError}
timeRange={range}
onQueryChange={onQueryChange}
/>
);

@ -1,17 +1,43 @@
import { useCallback } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorHeader, InlineSelect } from '@grafana/plugin-ui';
import { CoreApp, LoadingState, PanelData, SelectableValue } from '@grafana/data';
import { EditorHeader, FlexItem, InlineSelect } from '@grafana/plugin-ui';
import { config, reportInteraction } from '@grafana/runtime';
import { Button, ConfirmModal, RadioButtonGroup } from '@grafana/ui';
import { LogsEditorMode } from '../../dataquery.gen';
import { selectors } from '../../e2e/selectors';
import { AzureMonitorQuery, AzureQueryType } from '../../types';
interface QueryTypeFieldProps {
query: AzureMonitorQuery;
onQueryChange: (newQuery: AzureMonitorQuery) => void;
setAzureLogsCheatSheetModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
data: PanelData | undefined;
onRunQuery: () => void;
app: CoreApp | undefined;
}
export const QueryHeader = ({ query, onQueryChange }: QueryTypeFieldProps) => {
const EDITOR_MODES = [
{ label: 'Builder', value: LogsEditorMode.Builder },
{ label: 'KQL', value: LogsEditorMode.Raw },
];
export const QueryHeader = ({
query,
onQueryChange,
setAzureLogsCheatSheetModalOpen,
data,
app,
onRunQuery,
}: QueryTypeFieldProps) => {
const isLoading = useMemo(() => data?.state === LoadingState.Loading, [data?.state]);
const [showModeSwitchWarning, setShowModeSwitchWarning] = useState(false);
const [pendingModeChange, setPendingModeChange] = useState<LogsEditorMode | null>(null);
const currentMode = query.azureLogAnalytics?.mode;
const queryTypes: Array<{ value: AzureQueryType; label: string }> = [
{ value: AzureQueryType.AzureMonitor, label: 'Metrics' },
{ value: AzureQueryType.LogAnalytics, label: 'Logs' },
@ -23,8 +49,7 @@ export const QueryHeader = ({ query, onQueryChange }: QueryTypeFieldProps) => {
(change: SelectableValue<AzureQueryType>) => {
if (change.value && change.value !== query.queryType) {
onQueryChange({
refId: query.refId,
datasource: query.datasource,
...query,
queryType: change.value,
});
}
@ -32,9 +57,76 @@ export const QueryHeader = ({ query, onQueryChange }: QueryTypeFieldProps) => {
[onQueryChange, query]
);
useEffect(() => {
if (query.azureLogAnalytics && query.azureLogAnalytics.mode === undefined) {
const updatedQuery = {
...query,
azureLogAnalytics: {
...query.azureLogAnalytics,
mode: LogsEditorMode.Builder,
},
};
onQueryChange(updatedQuery);
}
}, [query, onQueryChange]);
const onLogsModeChange = (newMode: LogsEditorMode) => {
if (newMode === currentMode) {
return;
}
const goingToBuilder = newMode === LogsEditorMode.Builder;
const goingToRaw = newMode === LogsEditorMode.Raw;
const hasRawKql = !!query.azureLogAnalytics?.query;
const hasBuilderQuery = !!query.azureLogAnalytics?.builderQuery;
if ((goingToBuilder && hasRawKql) || (goingToRaw && hasBuilderQuery)) {
setPendingModeChange(newMode);
setShowModeSwitchWarning(true);
} else {
applyModeChange(newMode);
}
};
const applyModeChange = (mode: LogsEditorMode) => {
const updatedQuery = {
...query,
azureLogAnalytics: {
...query.azureLogAnalytics,
mode,
query: '',
builderQuery: mode === LogsEditorMode.Raw ? undefined : query.azureLogAnalytics?.builderQuery,
},
};
onQueryChange(updatedQuery);
};
return (
<span data-testid={selectors.components.queryEditor.header.select}>
<EditorHeader>
<ConfirmModal
isOpen={showModeSwitchWarning}
title="Switch editor mode?"
body={
pendingModeChange === LogsEditorMode.Builder
? 'Switching to Builder will discard your current KQL query and clear the KQL editor. Are you sure?'
: 'Switching to KQL will discard your current builder settings. Are you sure?'
}
confirmText={`Switch to ${pendingModeChange === LogsEditorMode.Builder ? 'Builder' : 'KQL'}`}
onConfirm={() => {
if (pendingModeChange) {
applyModeChange(pendingModeChange);
}
setShowModeSwitchWarning(false);
setPendingModeChange(null);
}}
onDismiss={() => {
setShowModeSwitchWarning(false);
setPendingModeChange(null);
}}
/>
<InlineSelect
label="Service"
value={query.queryType === AzureQueryType.TraceExemplar ? AzureQueryType.AzureTraces : query.queryType}
@ -43,6 +135,45 @@ export const QueryHeader = ({ query, onQueryChange }: QueryTypeFieldProps) => {
options={queryTypes}
onChange={handleChange}
/>
{query.queryType === AzureQueryType.LogAnalytics && query.azureLogAnalytics?.mode === LogsEditorMode.Raw && (
<Button
aria-label="Azure logs kick start your query button"
variant="secondary"
size="sm"
onClick={() => {
setAzureLogsCheatSheetModalOpen((prev) => !prev);
reportInteraction('grafana_azure_logs_query_patterns_opened', {
version: 'v2',
editorMode: query.azureLogAnalytics,
});
}}
>
Kick start your query
</Button>
)}
<FlexItem grow={1} />
{query.queryType === AzureQueryType.LogAnalytics && !!config.featureToggles.azureMonitorLogsBuilderEditor && (
<RadioButtonGroup
size="sm"
options={EDITOR_MODES}
value={query.azureLogAnalytics?.mode || LogsEditorMode.Builder}
onChange={onLogsModeChange}
data-testid="azure-query-header-logs-radio-button"
/>
)}
{query.azureLogAnalytics?.mode === LogsEditorMode.Builder &&
!!config.featureToggles.azureMonitorLogsBuilderEditor &&
app !== CoreApp.Explore && (
<Button
variant="primary"
icon={isLoading ? 'spinner' : 'play'}
size="sm"
onClick={onRunQuery}
data-testid={selectors.components.queryEditor.logsQueryEditor.runQuery.button}
>
Run query
</Button>
)}
</EditorHeader>
</span>
);

@ -22,10 +22,18 @@ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getTemplateSrv: () => ({
replace: (val: string) => {
if (val === '$ws') {
return '/subscriptions/def-456/resourceGroups/dev-3/providers/microsoft.operationalinsights/workspaces/la-workspace';
}
return val;
},
getVariables: () => [
{ name: 'var1', current: { value: 'value1' } },
{ name: 'var2', current: { value: 'value2' } },
],
}),
}));
const getResourceGroups = jest.fn().mockResolvedValue([{ resourceGroupURI: 'rg', resourceGroupName: 'rg', count: 1 }]);
const getResourceNames = jest.fn().mockResolvedValue([
{
@ -103,10 +111,10 @@ describe('VariableEditor:', () => {
render(<VariableEditor {...defaultProps} onChange={onChange} />);
await waitFor(() => screen.queryByTestId('mockeditor'));
expect(screen.queryByTestId('mockeditor')).toBeInTheDocument();
await userEvent.type(screen.getByTestId('mockeditor'), '{backspace}');
await userEvent.type(screen.getByTestId('mockeditor'), '2');
expect(onChange).toHaveBeenCalledWith({
azureLogAnalytics: {
query: 'test quer',
query: 'test query2',
},
queryType: 'Azure Log Analytics',
refId: 'A',

@ -291,6 +291,8 @@ const VariableEditor = (props: Props) => {
query={query}
datasource={datasource}
onChange={onQueryChange}
// Not applicable as the builder isn't available in the variable editor yet
onQueryChange={onQueryChange}
variableOptionGroup={variableOptionGroup}
setError={setError}
hideFormatAs={true}

@ -50,7 +50,7 @@ const FormatAsField = ({
value={resultFormat}
onChange={handleChange}
options={options}
width={38}
width={20}
/>
</Field>
);

@ -128,6 +128,10 @@ composableKinds: DataQuery: {
basicLogsQuery?: bool
// Workspace ID. This was removed in Grafana 8, but remains for backwards compat.
workspace?: string
// Denotes if logs query editor is in builder mode
mode?: #LogsEditorMode
// Builder query to be executed.
builderQuery?: #BuilderQueryExpression
// @deprecated Use resources instead
resource?: string
@ -161,6 +165,108 @@ composableKinds: DataQuery: {
} @cuetsy(kind="interface")
#ResultFormat: "table" | "time_series" | "trace" | "logs" @cuetsy(kind="enum", memberNames="Table|TimeSeries|Trace|Logs")
#LogsEditorMode: "builder" | "raw" @cuetsy(kind="enum", memberNames="Builder|Raw")
#BuilderQueryEditorExpressionType: "property" | "operator" | "reduce" | "function_parameter" | "group_by" | "or" | "and" | "order_by" @cuetsy(kind="enum", memberNames:"Property|Operator|Reduce|FunctionParameter|GroupBy|Or|And|OrderBy")
#BuilderQueryEditorPropertyType: "number" | "string" | "boolean" | "datetime" | "time_span" | "function" | "interval" @cuetsy(kind="enum", memberNames:"Number|String|Boolean|Datetime|TimeSpan|Function|Interval")
#BuilderQueryEditorOrderByOptions: "asc" | "desc" @cuetsy(kind="enum", memberNames:"Asc|Desc")
#BuilderQueryEditorProperty: {
type: #BuilderQueryEditorPropertyType
name: string
} @cuetsy(kind="interface")
#BuilderQueryEditorPropertyExpression: {
property: #BuilderQueryEditorProperty
type: #BuilderQueryEditorExpressionType
} @cuetsy(kind="interface")
#BuilderQueryEditorColumnsExpression: {
columns?: [...string]
type: #BuilderQueryEditorExpressionType
} @cuetsy(kind="interface")
#SelectableValue: {
label: string
value: string
} @cuetsy(kind="interface")
#BuilderQueryEditorOperatorType: string | bool | number | #SelectableValue @cuetsy(kind="type")
#BuilderQueryEditorOperator: {
name: string
value: string
labelValue?: string
} @cuetsy(kind="interface")
#BuilderQueryEditorWhereExpressionItems: {
property: #BuilderQueryEditorProperty
operator: #BuilderQueryEditorOperator
type: #BuilderQueryEditorExpressionType
} @cuetsy(kind="interface")
#BuilderQueryEditorWhereExpression: {
type: #BuilderQueryEditorExpressionType
expressions: [...#BuilderQueryEditorWhereExpressionItems]
} @cuetsy(kind="interface")
#BuilderQueryEditorWhereExpressionArray: {
expressions: [...#BuilderQueryEditorWhereExpression]
type: #BuilderQueryEditorExpressionType
} @cuetsy(kind="interface")
#BuilderQueryEditorFunctionParameterExpression: {
value: string
fieldType: #BuilderQueryEditorPropertyType
type: #BuilderQueryEditorExpressionType
} @cuetsy(kind="interface")
#BuilderQueryEditorReduceExpression: {
property?: #BuilderQueryEditorProperty
reduce?: #BuilderQueryEditorProperty
parameters?: [...#BuilderQueryEditorFunctionParameterExpression]
focus?: bool
} @cuetsy(kind="interface")
#BuilderQueryEditorReduceExpressionArray: {
expressions: [...#BuilderQueryEditorReduceExpression]
type: #BuilderQueryEditorExpressionType
} @cuetsy(kind="interface")
#BuilderQueryEditorGroupByExpression: {
property?: #BuilderQueryEditorProperty
interval?: #BuilderQueryEditorProperty
focus?: bool
type?: #BuilderQueryEditorExpressionType
} @cuetsy(kind="interface")
#BuilderQueryEditorGroupByExpressionArray: {
expressions: [...#BuilderQueryEditorGroupByExpression]
type: #BuilderQueryEditorExpressionType
} @cuetsy(kind="interface")
#BuilderQueryEditorOrderByExpression: {
property: #BuilderQueryEditorProperty
order: #BuilderQueryEditorOrderByOptions
type: #BuilderQueryEditorExpressionType
} @cuetsy(kind="interface")
#BuilderQueryEditorOrderByExpressionArray: {
expressions: [...#BuilderQueryEditorOrderByExpression]
type: #BuilderQueryEditorExpressionType
} @cuetsy(kind="interface")
#BuilderQueryExpression: {
from?: #BuilderQueryEditorPropertyExpression
columns?: #BuilderQueryEditorColumnsExpression
where?: #BuilderQueryEditorWhereExpressionArray
reduce?: #BuilderQueryEditorReduceExpressionArray
groupBy?: #BuilderQueryEditorGroupByExpressionArray
limit?: int
orderBy?: #BuilderQueryEditorOrderByExpressionArray
fuzzySearch?: #BuilderQueryEditorWhereExpressionArray
timeFilter?: #BuilderQueryEditorWhereExpressionArray
} @cuetsy(kind="interface")
#AzureResourceGraphQuery: {
// Azure Resource Graph KQL query to be executed.

@ -182,6 +182,10 @@ export interface AzureLogsQuery {
* If set to true the query will be run as a basic logs query
*/
basicLogsQuery?: boolean;
/**
* Builder query to be executed.
*/
builderQuery?: BuilderQueryExpression;
/**
* If set to true the dashboard time range will be used as a filter for the query. Otherwise the query time ranges will be used. Defaults to false.
*/
@ -190,6 +194,10 @@ export interface AzureLogsQuery {
* @deprecated Use dashboardTime instead
*/
intersectTime?: boolean;
/**
* Denotes if logs query editor is in builder mode
*/
mode?: LogsEditorMode;
/**
* KQL query to be executed.
*/
@ -282,6 +290,162 @@ export enum ResultFormat {
Trace = 'trace',
}
export enum LogsEditorMode {
Builder = 'builder',
Raw = 'raw',
}
export enum BuilderQueryEditorExpressionType {
And = 'and',
Function_parameter = 'function_parameter',
Group_by = 'group_by',
Operator = 'operator',
Or = 'or',
Order_by = 'order_by',
Property = 'property',
Reduce = 'reduce',
}
export enum BuilderQueryEditorPropertyType {
Boolean = 'boolean',
Datetime = 'datetime',
Function = 'function',
Interval = 'interval',
Number = 'number',
String = 'string',
Time_span = 'time_span',
}
export enum BuilderQueryEditorOrderByOptions {
Asc = 'asc',
Desc = 'desc',
}
export interface BuilderQueryEditorProperty {
name: string;
type: BuilderQueryEditorPropertyType;
}
export interface BuilderQueryEditorPropertyExpression {
property: BuilderQueryEditorProperty;
type: BuilderQueryEditorExpressionType;
}
export interface BuilderQueryEditorColumnsExpression {
columns?: Array<string>;
type: BuilderQueryEditorExpressionType;
}
export const defaultBuilderQueryEditorColumnsExpression: Partial<BuilderQueryEditorColumnsExpression> = {
columns: [],
};
export interface SelectableValue {
label: string;
value: string;
}
export type BuilderQueryEditorOperatorType = (string | boolean | number | SelectableValue);
export interface BuilderQueryEditorOperator {
labelValue?: string;
name: string;
value: string;
}
export interface BuilderQueryEditorWhereExpressionItems {
operator: BuilderQueryEditorOperator;
property: BuilderQueryEditorProperty;
type: BuilderQueryEditorExpressionType;
}
export interface BuilderQueryEditorWhereExpression {
expressions: Array<BuilderQueryEditorWhereExpressionItems>;
type: BuilderQueryEditorExpressionType;
}
export const defaultBuilderQueryEditorWhereExpression: Partial<BuilderQueryEditorWhereExpression> = {
expressions: [],
};
export interface BuilderQueryEditorWhereExpressionArray {
expressions: Array<BuilderQueryEditorWhereExpression>;
type: BuilderQueryEditorExpressionType;
}
export const defaultBuilderQueryEditorWhereExpressionArray: Partial<BuilderQueryEditorWhereExpressionArray> = {
expressions: [],
};
export interface BuilderQueryEditorFunctionParameterExpression {
fieldType: BuilderQueryEditorPropertyType;
type: BuilderQueryEditorExpressionType;
value: string;
}
export interface BuilderQueryEditorReduceExpression {
focus?: boolean;
parameters?: Array<BuilderQueryEditorFunctionParameterExpression>;
property?: BuilderQueryEditorProperty;
reduce?: BuilderQueryEditorProperty;
}
export const defaultBuilderQueryEditorReduceExpression: Partial<BuilderQueryEditorReduceExpression> = {
parameters: [],
};
export interface BuilderQueryEditorReduceExpressionArray {
expressions: Array<BuilderQueryEditorReduceExpression>;
type: BuilderQueryEditorExpressionType;
}
export const defaultBuilderQueryEditorReduceExpressionArray: Partial<BuilderQueryEditorReduceExpressionArray> = {
expressions: [],
};
export interface BuilderQueryEditorGroupByExpression {
focus?: boolean;
interval?: BuilderQueryEditorProperty;
property?: BuilderQueryEditorProperty;
type?: BuilderQueryEditorExpressionType;
}
export interface BuilderQueryEditorGroupByExpressionArray {
expressions: Array<BuilderQueryEditorGroupByExpression>;
type: BuilderQueryEditorExpressionType;
}
export const defaultBuilderQueryEditorGroupByExpressionArray: Partial<BuilderQueryEditorGroupByExpressionArray> = {
expressions: [],
};
export interface BuilderQueryEditorOrderByExpression {
order: BuilderQueryEditorOrderByOptions;
property: BuilderQueryEditorProperty;
type: BuilderQueryEditorExpressionType;
}
export interface BuilderQueryEditorOrderByExpressionArray {
expressions: Array<BuilderQueryEditorOrderByExpression>;
type: BuilderQueryEditorExpressionType;
}
export const defaultBuilderQueryEditorOrderByExpressionArray: Partial<BuilderQueryEditorOrderByExpressionArray> = {
expressions: [],
};
export interface BuilderQueryExpression {
columns?: BuilderQueryEditorColumnsExpression;
from?: BuilderQueryEditorPropertyExpression;
fuzzySearch?: BuilderQueryEditorWhereExpressionArray;
groupBy?: BuilderQueryEditorGroupByExpressionArray;
limit?: number;
orderBy?: BuilderQueryEditorOrderByExpressionArray;
reduce?: BuilderQueryEditorReduceExpressionArray;
timeFilter?: BuilderQueryEditorWhereExpressionArray;
where?: BuilderQueryEditorWhereExpressionArray;
}
export interface AzureResourceGraphQuery {
/**
* Azure Resource Graph KQL query to be executed.

@ -76,6 +76,9 @@ export const components = {
formatSelection: {
input: 'data-testid format-selection',
},
runQuery: {
button: 'data-testid run-query',
},
},
argsQueryEditor: {
container: {

@ -264,6 +264,15 @@ export interface AzureAPIResponse<T> {
statusText?: string;
}
export interface AzureLogAnalyticsTable {
name: string;
description: string;
}
export interface MetadataResponse {
tables: AzureLogAnalyticsTable[];
}
export interface Location {
id: string;
name: string;
@ -417,3 +426,46 @@ export type CheatsheetQueries = {
export type DropdownCategories = {
[key: string]: boolean;
};
export enum QueryEditorPropertyType {
Number = 'number',
String = 'string',
Boolean = 'boolean',
DateTime = 'datetime',
TimeSpan = 'timeSpan',
Function = 'function',
Interval = 'interval',
}
export interface QueryEditorProperty {
type: QueryEditorPropertyType;
name: string;
}
export type QueryEditorOperatorType = string | boolean | number | SelectableValue<string>;
export type QueryEditorOperatorValueType = QueryEditorOperatorType | QueryEditorOperatorType[];
export interface QueryEditorOperator<T = QueryEditorOperatorValueType> {
name: string;
value: T;
labelValue?: string;
}
export interface QueryEditorOperatorDefinition {
value: string;
supportTypes: QueryEditorPropertyType[];
multipleValues: boolean;
booleanValues: boolean;
label?: string;
description?: string;
}
export enum AggregateFunctions {
Sum = 'sum',
Avg = 'avg',
Count = 'count',
Dcount = 'dcount',
Max = 'max',
Min = 'min',
Percentile = 'percentile',
}

Loading…
Cancel
Save