mirror of https://github.com/grafana/grafana
PromQueryBuilder: Query builder and components that can be shared with a loki query builder and others (#42854)
parent
a660ccc6e4
commit
64e1e91403
@ -0,0 +1,188 @@ |
||||
import { LokiQueryModeller } from './LokiQueryModeller'; |
||||
import { LokiOperationId } from './types'; |
||||
|
||||
describe('LokiQueryModeller', () => { |
||||
const modeller = new LokiQueryModeller(); |
||||
|
||||
it('Can query with labels only', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
labels: [{ label: 'app', op: '=', value: 'grafana' }], |
||||
operations: [], |
||||
}) |
||||
).toBe('{app="grafana"}'); |
||||
}); |
||||
|
||||
it('Can query with pipeline operation json', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
labels: [{ label: 'app', op: '=', value: 'grafana' }], |
||||
operations: [{ id: LokiOperationId.Json, params: [] }], |
||||
}) |
||||
).toBe('{app="grafana"} | json'); |
||||
}); |
||||
|
||||
it('Can query with pipeline operation logfmt', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
labels: [{ label: 'app', op: '=', value: 'grafana' }], |
||||
operations: [{ id: LokiOperationId.Logfmt, params: [] }], |
||||
}) |
||||
).toBe('{app="grafana"} | logfmt'); |
||||
}); |
||||
|
||||
it('Can query with line filter contains operation', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
labels: [{ label: 'app', op: '=', value: 'grafana' }], |
||||
operations: [{ id: LokiOperationId.LineContains, params: ['error'] }], |
||||
}) |
||||
).toBe('{app="grafana"} |= `error`'); |
||||
}); |
||||
|
||||
it('Can query with line filter contains operation with empty params', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
labels: [{ label: 'app', op: '=', value: 'grafana' }], |
||||
operations: [{ id: LokiOperationId.LineContains, params: [''] }], |
||||
}) |
||||
).toBe('{app="grafana"}'); |
||||
}); |
||||
|
||||
it('Can query with line filter contains not operation', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
labels: [{ label: 'app', op: '=', value: 'grafana' }], |
||||
operations: [{ id: LokiOperationId.LineContainsNot, params: ['error'] }], |
||||
}) |
||||
).toBe('{app="grafana"} != `error`'); |
||||
}); |
||||
|
||||
it('Can query with line regex filter', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
labels: [{ label: 'app', op: '=', value: 'grafana' }], |
||||
operations: [{ id: LokiOperationId.LineMatchesRegex, params: ['error'] }], |
||||
}) |
||||
).toBe('{app="grafana"} |~ `error`'); |
||||
}); |
||||
|
||||
it('Can query with line not matching regex', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
labels: [{ label: 'app', op: '=', value: 'grafana' }], |
||||
operations: [{ id: LokiOperationId.LineMatchesRegexNot, params: ['error'] }], |
||||
}) |
||||
).toBe('{app="grafana"} !~ `error`'); |
||||
}); |
||||
|
||||
it('Can query with label filter expression', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
labels: [{ label: 'app', op: '=', value: 'grafana' }], |
||||
operations: [{ id: LokiOperationId.LabelFilter, params: ['__error__', '=', 'value'] }], |
||||
}) |
||||
).toBe('{app="grafana"} | __error__="value"'); |
||||
}); |
||||
|
||||
it('Can query with label filter expression using greater than operator', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
labels: [{ label: 'app', op: '=', value: 'grafana' }], |
||||
operations: [{ id: LokiOperationId.LabelFilter, params: ['count', '>', 'value'] }], |
||||
}) |
||||
).toBe('{app="grafana"} | count > value'); |
||||
}); |
||||
|
||||
it('Can query no formatting errors operation', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
labels: [{ label: 'app', op: '=', value: 'grafana' }], |
||||
operations: [{ id: LokiOperationId.LabelFilterNoErrors, params: [] }], |
||||
}) |
||||
).toBe('{app="grafana"} | __error__=""'); |
||||
}); |
||||
|
||||
it('Can query with unwrap operation', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
labels: [{ label: 'app', op: '=', value: 'grafana' }], |
||||
operations: [{ id: LokiOperationId.Unwrap, params: ['count'] }], |
||||
}) |
||||
).toBe('{app="grafana"} | unwrap count'); |
||||
}); |
||||
|
||||
describe('On add operation handlers', () => { |
||||
it('When adding function without range vector param should automatically add rate', () => { |
||||
const query = { |
||||
labels: [], |
||||
operations: [], |
||||
}; |
||||
|
||||
const def = modeller.getOperationDef('sum'); |
||||
const result = def.addOperationHandler(def, query, modeller); |
||||
expect(result.operations[0].id).toBe('rate'); |
||||
expect(result.operations[1].id).toBe('sum'); |
||||
}); |
||||
|
||||
it('When adding function without range vector param should automatically add rate after existing pipe operation', () => { |
||||
const query = { |
||||
labels: [], |
||||
operations: [{ id: 'json', params: [] }], |
||||
}; |
||||
|
||||
const def = modeller.getOperationDef('sum'); |
||||
const result = def.addOperationHandler(def, query, modeller); |
||||
expect(result.operations[0].id).toBe('json'); |
||||
expect(result.operations[1].id).toBe('rate'); |
||||
expect(result.operations[2].id).toBe('sum'); |
||||
}); |
||||
|
||||
it('When adding a pipe operation after a function operation should add pipe operation first', () => { |
||||
const query = { |
||||
labels: [], |
||||
operations: [{ id: 'rate', params: [] }], |
||||
}; |
||||
|
||||
const def = modeller.getOperationDef('json'); |
||||
const result = def.addOperationHandler(def, query, modeller); |
||||
expect(result.operations[0].id).toBe('json'); |
||||
expect(result.operations[1].id).toBe('rate'); |
||||
}); |
||||
|
||||
it('When adding a pipe operation after a line filter operation', () => { |
||||
const query = { |
||||
labels: [], |
||||
operations: [{ id: '__line_contains', params: ['error'] }], |
||||
}; |
||||
|
||||
const def = modeller.getOperationDef('json'); |
||||
const result = def.addOperationHandler(def, query, modeller); |
||||
expect(result.operations[0].id).toBe('__line_contains'); |
||||
expect(result.operations[1].id).toBe('json'); |
||||
}); |
||||
|
||||
it('When adding a line filter operation after format operation', () => { |
||||
const query = { |
||||
labels: [], |
||||
operations: [{ id: 'json', params: [] }], |
||||
}; |
||||
|
||||
const def = modeller.getOperationDef('__line_contains'); |
||||
const result = def.addOperationHandler(def, query, modeller); |
||||
expect(result.operations[0].id).toBe('__line_contains'); |
||||
expect(result.operations[1].id).toBe('json'); |
||||
}); |
||||
|
||||
it('When adding a rate it should not add another rate', () => { |
||||
const query = { |
||||
labels: [], |
||||
operations: [], |
||||
}; |
||||
|
||||
const def = modeller.getOperationDef('rate'); |
||||
const result = def.addOperationHandler(def, query, modeller); |
||||
expect(result.operations.length).toBe(1); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,61 @@ |
||||
import { LokiAndPromQueryModellerBase } from '../../prometheus/querybuilder/shared/LokiAndPromQueryModellerBase'; |
||||
import { QueryBuilderLabelFilter } from '../../prometheus/querybuilder/shared/types'; |
||||
import { getOperationDefintions } from './operations'; |
||||
import { LokiOperationId, LokiQueryPattern, LokiVisualQuery, LokiVisualQueryOperationCategory } from './types'; |
||||
|
||||
export class LokiQueryModeller extends LokiAndPromQueryModellerBase<LokiVisualQuery> { |
||||
constructor() { |
||||
super(getOperationDefintions); |
||||
|
||||
this.setOperationCategories([ |
||||
LokiVisualQueryOperationCategory.Aggregations, |
||||
LokiVisualQueryOperationCategory.RangeFunctions, |
||||
LokiVisualQueryOperationCategory.Formats, |
||||
//LokiVisualQueryOperationCategory.Functions,
|
||||
LokiVisualQueryOperationCategory.LabelFilters, |
||||
LokiVisualQueryOperationCategory.LineFilters, |
||||
]); |
||||
} |
||||
|
||||
renderLabels(labels: QueryBuilderLabelFilter[]) { |
||||
if (labels.length === 0) { |
||||
return '{}'; |
||||
} |
||||
|
||||
return super.renderLabels(labels); |
||||
} |
||||
|
||||
renderQuery(query: LokiVisualQuery) { |
||||
let queryString = `${this.renderLabels(query.labels)}`; |
||||
queryString = this.renderOperations(queryString, query.operations); |
||||
queryString = this.renderBinaryQueries(queryString, query.binaryQueries); |
||||
return queryString; |
||||
} |
||||
|
||||
getQueryPatterns(): LokiQueryPattern[] { |
||||
return [ |
||||
{ |
||||
name: 'Log query and label filter', |
||||
operations: [ |
||||
{ id: LokiOperationId.LineMatchesRegex, params: [''] }, |
||||
{ id: LokiOperationId.Logfmt, params: [] }, |
||||
{ id: LokiOperationId.LabelFilterNoErrors, params: [] }, |
||||
{ id: LokiOperationId.LabelFilter, params: ['', '=', ''] }, |
||||
], |
||||
}, |
||||
{ |
||||
name: 'Time series query on value inside log line', |
||||
operations: [ |
||||
{ id: LokiOperationId.LineMatchesRegex, params: [''] }, |
||||
{ id: LokiOperationId.Logfmt, params: [] }, |
||||
{ id: LokiOperationId.LabelFilterNoErrors, params: [] }, |
||||
{ id: LokiOperationId.Unwrap, params: [''] }, |
||||
{ id: LokiOperationId.SumOverTime, params: ['auto'] }, |
||||
{ id: LokiOperationId.Sum, params: [] }, |
||||
], |
||||
}, |
||||
]; |
||||
} |
||||
} |
||||
|
||||
export const lokiQueryModeller = new LokiQueryModeller(); |
@ -0,0 +1,80 @@ |
||||
import React from 'react'; |
||||
import { LokiVisualQuery } from '../types'; |
||||
import { LokiDatasource } from '../../datasource'; |
||||
import { LabelFilters } from 'app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters'; |
||||
import { OperationList } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationList'; |
||||
import { QueryBuilderLabelFilter } from 'app/plugins/datasource/prometheus/querybuilder/shared/types'; |
||||
import { lokiQueryModeller } from '../LokiQueryModeller'; |
||||
import { DataSourceApi } from '@grafana/data'; |
||||
import { EditorRow, EditorRows } from '@grafana/experimental'; |
||||
import { QueryPreview } from './QueryPreview'; |
||||
|
||||
export interface Props { |
||||
query: LokiVisualQuery; |
||||
datasource: LokiDatasource; |
||||
onChange: (update: LokiVisualQuery) => void; |
||||
onRunQuery: () => void; |
||||
nested?: boolean; |
||||
} |
||||
|
||||
export const LokiQueryBuilder = React.memo<Props>(({ datasource, query, nested, onChange, onRunQuery }) => { |
||||
const onChangeLabels = (labels: QueryBuilderLabelFilter[]) => { |
||||
onChange({ ...query, labels }); |
||||
}; |
||||
|
||||
const onGetLabelNames = async (forLabel: Partial<QueryBuilderLabelFilter>): Promise<any> => { |
||||
const labelsToConsider = query.labels.filter((x) => x !== forLabel); |
||||
|
||||
if (labelsToConsider.length === 0) { |
||||
await datasource.languageProvider.refreshLogLabels(); |
||||
return datasource.languageProvider.getLabelKeys(); |
||||
} |
||||
|
||||
const expr = lokiQueryModeller.renderLabels(labelsToConsider); |
||||
return await datasource.languageProvider.fetchSeriesLabels(expr); |
||||
}; |
||||
|
||||
const onGetLabelValues = async (forLabel: Partial<QueryBuilderLabelFilter>) => { |
||||
if (!forLabel.label) { |
||||
return []; |
||||
} |
||||
|
||||
const labelsToConsider = query.labels.filter((x) => x !== forLabel); |
||||
if (labelsToConsider.length === 0) { |
||||
return await datasource.languageProvider.fetchLabelValues(forLabel.label); |
||||
} |
||||
|
||||
const expr = lokiQueryModeller.renderLabels(labelsToConsider); |
||||
const result = await datasource.languageProvider.fetchSeriesLabels(expr); |
||||
return result[forLabel.label] ?? []; |
||||
}; |
||||
|
||||
return ( |
||||
<EditorRows> |
||||
<EditorRow> |
||||
<LabelFilters |
||||
onGetLabelNames={onGetLabelNames} |
||||
onGetLabelValues={onGetLabelValues} |
||||
labelsFilters={query.labels} |
||||
onChange={onChangeLabels} |
||||
/> |
||||
</EditorRow> |
||||
<EditorRow> |
||||
<OperationList |
||||
queryModeller={lokiQueryModeller} |
||||
query={query} |
||||
onChange={onChange} |
||||
onRunQuery={onRunQuery} |
||||
datasource={datasource as DataSourceApi} |
||||
/> |
||||
</EditorRow> |
||||
{!nested && ( |
||||
<EditorRow> |
||||
<QueryPreview query={query} /> |
||||
</EditorRow> |
||||
)} |
||||
</EditorRows> |
||||
); |
||||
}); |
||||
|
||||
LokiQueryBuilder.displayName = 'LokiQueryBuilder'; |
@ -0,0 +1,24 @@ |
||||
import React from 'react'; |
||||
import { LokiVisualQuery } from '../types'; |
||||
import { Stack } from '@grafana/experimental'; |
||||
import { lokiQueryModeller } from '../LokiQueryModeller'; |
||||
import { OperationListExplained } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationListExplained'; |
||||
import { OperationExplainedBox } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationExplainedBox'; |
||||
|
||||
export interface Props { |
||||
query: LokiVisualQuery; |
||||
nested?: boolean; |
||||
} |
||||
|
||||
export const LokiQueryBuilderExplained = React.memo<Props>(({ query, nested }) => { |
||||
return ( |
||||
<Stack gap={0} direction="column"> |
||||
<OperationExplainedBox stepNumber={1} title={`${lokiQueryModeller.renderLabels(query.labels)}`}> |
||||
Fetch all log lines matching label filters. |
||||
</OperationExplainedBox> |
||||
<OperationListExplained<LokiVisualQuery> stepNumber={2} queryModeller={lokiQueryModeller} query={query} /> |
||||
</Stack> |
||||
); |
||||
}); |
||||
|
||||
LokiQueryBuilderExplained.displayName = 'LokiQueryBuilderExplained'; |
@ -0,0 +1,105 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { GrafanaTheme2, LoadingState } from '@grafana/data'; |
||||
import { EditorHeader, FlexItem, InlineSelect, Space, Stack } from '@grafana/experimental'; |
||||
import { Button, Switch, useStyles2 } from '@grafana/ui'; |
||||
import { QueryEditorModeToggle } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle'; |
||||
import { QueryEditorMode } from 'app/plugins/datasource/prometheus/querybuilder/shared/types'; |
||||
import React, { useCallback, useState } from 'react'; |
||||
import { LokiQueryEditor } from '../../components/LokiQueryEditor'; |
||||
import { LokiQueryEditorProps } from '../../components/types'; |
||||
import { lokiQueryModeller } from '../LokiQueryModeller'; |
||||
import { getDefaultEmptyQuery, LokiVisualQuery } from '../types'; |
||||
import { LokiQueryBuilder } from './LokiQueryBuilder'; |
||||
import { LokiQueryBuilderExplained } from './LokiQueryBuilderExplaind'; |
||||
|
||||
export const LokiQueryEditorSelector = React.memo<LokiQueryEditorProps>((props) => { |
||||
const { query, onChange, onRunQuery, data } = props; |
||||
const styles = useStyles2(getStyles); |
||||
const [visualQuery, setVisualQuery] = useState<LokiVisualQuery>(query.visualQuery ?? getDefaultEmptyQuery()); |
||||
|
||||
const onEditorModeChange = useCallback( |
||||
(newMetricEditorMode: QueryEditorMode) => { |
||||
onChange({ ...query, editorMode: newMetricEditorMode }); |
||||
}, |
||||
[onChange, query] |
||||
); |
||||
|
||||
const onChangeViewModel = (updatedQuery: LokiVisualQuery) => { |
||||
setVisualQuery(updatedQuery); |
||||
|
||||
onChange({ |
||||
...query, |
||||
expr: lokiQueryModeller.renderQuery(updatedQuery), |
||||
visualQuery: updatedQuery, |
||||
editorMode: QueryEditorMode.Builder, |
||||
}); |
||||
}; |
||||
|
||||
// If no expr (ie new query) then default to builder
|
||||
const editorMode = query.editorMode ?? (query.expr ? QueryEditorMode.Code : QueryEditorMode.Builder); |
||||
|
||||
return ( |
||||
<> |
||||
<EditorHeader> |
||||
<FlexItem grow={1} /> |
||||
<Button |
||||
className={styles.runQuery} |
||||
variant="secondary" |
||||
size="sm" |
||||
fill="outline" |
||||
onClick={onRunQuery} |
||||
icon={data?.state === LoadingState.Loading ? 'fa fa-spinner' : undefined} |
||||
disabled={data?.state === LoadingState.Loading} |
||||
> |
||||
Run query |
||||
</Button> |
||||
<Stack gap={1}> |
||||
<label className={styles.switchLabel}>Instant</label> |
||||
<Switch /> |
||||
</Stack> |
||||
<Stack gap={1}> |
||||
<label className={styles.switchLabel}>Exemplars</label> |
||||
<Switch /> |
||||
</Stack> |
||||
<InlineSelect |
||||
value={null} |
||||
placeholder="Query patterns" |
||||
allowCustomValue |
||||
onChange={({ value }) => { |
||||
onChangeViewModel({ |
||||
...visualQuery, |
||||
operations: value?.operations!, |
||||
}); |
||||
}} |
||||
options={lokiQueryModeller.getQueryPatterns().map((x) => ({ label: x.name, value: x }))} |
||||
/> |
||||
<QueryEditorModeToggle mode={editorMode} onChange={onEditorModeChange} /> |
||||
</EditorHeader> |
||||
<Space v={0.5} /> |
||||
{editorMode === QueryEditorMode.Code && <LokiQueryEditor {...props} />} |
||||
{editorMode === QueryEditorMode.Builder && ( |
||||
<LokiQueryBuilder |
||||
datasource={props.datasource} |
||||
query={visualQuery} |
||||
onChange={onChangeViewModel} |
||||
onRunQuery={props.onRunQuery} |
||||
/> |
||||
)} |
||||
{editorMode === QueryEditorMode.Explain && <LokiQueryBuilderExplained query={visualQuery} />} |
||||
</> |
||||
); |
||||
}); |
||||
|
||||
LokiQueryEditorSelector.displayName = 'LokiQueryEditorSelector'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
runQuery: css({ |
||||
color: theme.colors.text.secondary, |
||||
}), |
||||
switchLabel: css({ |
||||
color: theme.colors.text.secondary, |
||||
fontSize: theme.typography.bodySmall.fontSize, |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,41 @@ |
||||
import React from 'react'; |
||||
import { LokiVisualQuery } from '../types'; |
||||
import { useTheme2 } from '@grafana/ui'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { css, cx } from '@emotion/css'; |
||||
import { EditorField, EditorFieldGroup } from '@grafana/experimental'; |
||||
import Prism from 'prismjs'; |
||||
import { lokiGrammar } from '../../syntax'; |
||||
import { lokiQueryModeller } from '../LokiQueryModeller'; |
||||
|
||||
export interface Props { |
||||
query: LokiVisualQuery; |
||||
} |
||||
|
||||
export function QueryPreview({ query }: Props) { |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme); |
||||
const hightlighted = Prism.highlight(lokiQueryModeller.renderQuery(query), lokiGrammar, 'lokiql'); |
||||
|
||||
return ( |
||||
<EditorFieldGroup> |
||||
<EditorField label="Query text"> |
||||
<div |
||||
className={cx(styles.editorField, 'prism-syntax-highlight')} |
||||
aria-label="selector" |
||||
dangerouslySetInnerHTML={{ __html: hightlighted }} |
||||
/> |
||||
</EditorField> |
||||
</EditorFieldGroup> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
editorField: css({ |
||||
padding: theme.spacing(0.25, 1), |
||||
fontFamily: theme.typography.fontFamilyMonospace, |
||||
fontSize: theme.typography.bodySmall.fontSize, |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,300 @@ |
||||
import { |
||||
functionRendererLeft, |
||||
getPromAndLokiOperationDisplayName, |
||||
} from '../../prometheus/querybuilder/shared/operationUtils'; |
||||
import { |
||||
QueryBuilderOperation, |
||||
QueryBuilderOperationDef, |
||||
QueryBuilderOperationParamDef, |
||||
VisualQueryModeller, |
||||
} from '../../prometheus/querybuilder/shared/types'; |
||||
import { FUNCTIONS } from '../syntax'; |
||||
import { LokiOperationId, LokiVisualQuery, LokiVisualQueryOperationCategory } from './types'; |
||||
|
||||
export function getOperationDefintions(): QueryBuilderOperationDef[] { |
||||
const list: QueryBuilderOperationDef[] = [ |
||||
createRangeOperation(LokiOperationId.Rate), |
||||
createRangeOperation(LokiOperationId.CountOverTime), |
||||
createRangeOperation(LokiOperationId.SumOverTime), |
||||
createRangeOperation(LokiOperationId.BytesRate), |
||||
createRangeOperation(LokiOperationId.BytesOverTime), |
||||
createRangeOperation(LokiOperationId.AbsentOverTime), |
||||
createAggregationOperation(LokiOperationId.Sum), |
||||
createAggregationOperation(LokiOperationId.Avg), |
||||
createAggregationOperation(LokiOperationId.Min), |
||||
createAggregationOperation(LokiOperationId.Max), |
||||
{ |
||||
id: LokiOperationId.Json, |
||||
name: 'Json', |
||||
params: [], |
||||
defaultParams: [], |
||||
alternativesKey: 'format', |
||||
category: LokiVisualQueryOperationCategory.Formats, |
||||
renderer: pipelineRenderer, |
||||
addOperationHandler: addLokiOperation, |
||||
}, |
||||
{ |
||||
id: LokiOperationId.Logfmt, |
||||
name: 'Logfmt', |
||||
params: [], |
||||
defaultParams: [], |
||||
alternativesKey: 'format', |
||||
category: LokiVisualQueryOperationCategory.Formats, |
||||
renderer: pipelineRenderer, |
||||
addOperationHandler: addLokiOperation, |
||||
explainHandler: () => |
||||
`This will extract all keys and values from a [logfmt](https://grafana.com/docs/loki/latest/logql/log_queries/#logfmt) formatted log line as labels. The extracted lables can be used in label filter expressions and used as values for a range aggregation via the unwrap operation. `, |
||||
}, |
||||
{ |
||||
id: LokiOperationId.LineContains, |
||||
name: 'Line contains', |
||||
params: [{ name: 'String', type: 'string' }], |
||||
defaultParams: [''], |
||||
alternativesKey: 'line filter', |
||||
category: LokiVisualQueryOperationCategory.LineFilters, |
||||
renderer: getLineFilterRenderer('|='), |
||||
addOperationHandler: addLokiOperation, |
||||
explainHandler: (op) => `Return log lines that contain string \`${op.params[0]}\`.`, |
||||
}, |
||||
{ |
||||
id: LokiOperationId.LineContainsNot, |
||||
name: 'Line does not contain', |
||||
params: [{ name: 'String', type: 'string' }], |
||||
defaultParams: [''], |
||||
alternativesKey: 'line filter', |
||||
category: LokiVisualQueryOperationCategory.LineFilters, |
||||
renderer: getLineFilterRenderer('!='), |
||||
addOperationHandler: addLokiOperation, |
||||
explainHandler: (op) => `Return log lines that does not contain string \`${op.params[0]}\`.`, |
||||
}, |
||||
{ |
||||
id: LokiOperationId.LineMatchesRegex, |
||||
name: 'Line contains regex match', |
||||
params: [{ name: 'Regex', type: 'string' }], |
||||
defaultParams: [''], |
||||
alternativesKey: 'line filter', |
||||
category: LokiVisualQueryOperationCategory.LineFilters, |
||||
renderer: getLineFilterRenderer('|~'), |
||||
addOperationHandler: addLokiOperation, |
||||
explainHandler: (op) => `Return log lines that match regex \`${op.params[0]}\`.`, |
||||
}, |
||||
{ |
||||
id: LokiOperationId.LineMatchesRegexNot, |
||||
name: 'Line does not match regex', |
||||
params: [{ name: 'Regex', type: 'string' }], |
||||
defaultParams: [''], |
||||
alternativesKey: 'line filter', |
||||
category: LokiVisualQueryOperationCategory.LineFilters, |
||||
renderer: getLineFilterRenderer('!~'), |
||||
addOperationHandler: addLokiOperation, |
||||
explainHandler: (op) => `Return log lines that does not match regex \`${op.params[0]}\`.`, |
||||
}, |
||||
{ |
||||
id: LokiOperationId.LabelFilter, |
||||
name: 'Label filter expression', |
||||
params: [ |
||||
{ name: 'Label', type: 'string' }, |
||||
{ name: 'Operator', type: 'string', options: ['=', '!=', '>', '<', '>=', '<='] }, |
||||
{ name: 'Value', type: 'string' }, |
||||
], |
||||
defaultParams: ['', '=', ''], |
||||
category: LokiVisualQueryOperationCategory.LabelFilters, |
||||
renderer: labelFilterRenderer, |
||||
addOperationHandler: addLokiOperation, |
||||
explainHandler: () => `Label expression filter allows filtering using original and extracted labels.`, |
||||
}, |
||||
{ |
||||
id: LokiOperationId.LabelFilterNoErrors, |
||||
name: 'No pipeline errors', |
||||
params: [], |
||||
defaultParams: [], |
||||
category: LokiVisualQueryOperationCategory.LabelFilters, |
||||
renderer: (model, def, innerExpr) => `${innerExpr} | __error__=""`, |
||||
addOperationHandler: addLokiOperation, |
||||
explainHandler: () => `Filter out all formatting and parsing errors.`, |
||||
}, |
||||
{ |
||||
id: LokiOperationId.Unwrap, |
||||
name: 'Unwrap', |
||||
params: [{ name: 'Identifier', type: 'string' }], |
||||
defaultParams: [''], |
||||
category: LokiVisualQueryOperationCategory.Formats, |
||||
renderer: (op, def, innerExpr) => `${innerExpr} | unwrap ${op.params[0]}`, |
||||
addOperationHandler: addLokiOperation, |
||||
explainHandler: (op) => |
||||
`Use the extracted label \`${op.params[0]}\` as sample values instead of log lines for the subsequent range aggregation.`, |
||||
}, |
||||
]; |
||||
|
||||
return list; |
||||
} |
||||
|
||||
function createRangeOperation(name: string): QueryBuilderOperationDef { |
||||
return { |
||||
id: name, |
||||
name: getPromAndLokiOperationDisplayName(name), |
||||
params: [getRangeVectorParamDef()], |
||||
defaultParams: ['auto'], |
||||
alternativesKey: 'range function', |
||||
category: LokiVisualQueryOperationCategory.RangeFunctions, |
||||
renderer: operationWithRangeVectorRenderer, |
||||
addOperationHandler: addLokiOperation, |
||||
explainHandler: (op, def) => { |
||||
let opDocs = FUNCTIONS.find((x) => x.insertText === op.id)?.documentation ?? ''; |
||||
|
||||
if (op.params[0] === 'auto' || op.params[0] === '$__interval') { |
||||
return `${opDocs} \`$__interval\` is variable that will be replaced with a calculated interval based on **Max data points**, **Min interval** and query time range. You find these options you find under **Query options** at the right of the data source select dropdown.`; |
||||
} else { |
||||
return `${opDocs} The [range vector](https://grafana.com/docs/loki/latest/logql/metric_queries/#range-vector-aggregation) is set to \`${op.params[0]}\`.`; |
||||
} |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
function createAggregationOperation(name: string): QueryBuilderOperationDef { |
||||
return { |
||||
id: name, |
||||
name: getPromAndLokiOperationDisplayName(name), |
||||
params: [], |
||||
defaultParams: [], |
||||
alternativesKey: 'plain aggregation', |
||||
category: LokiVisualQueryOperationCategory.Aggregations, |
||||
renderer: functionRendererLeft, |
||||
addOperationHandler: addLokiOperation, |
||||
explainHandler: (op, def) => { |
||||
const opDocs = FUNCTIONS.find((x) => x.insertText === op.id); |
||||
return `${opDocs?.documentation}.`; |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
function getRangeVectorParamDef(): QueryBuilderOperationParamDef { |
||||
return { |
||||
name: 'Range vector', |
||||
type: 'string', |
||||
options: ['auto', '$__interval', '$__range', '1m', '5m', '10m', '1h', '24h'], |
||||
}; |
||||
} |
||||
|
||||
function operationWithRangeVectorRenderer( |
||||
model: QueryBuilderOperation, |
||||
def: QueryBuilderOperationDef, |
||||
innerExpr: string |
||||
) { |
||||
let rangeVector = (model.params ?? [])[0] ?? 'auto'; |
||||
|
||||
if (rangeVector === 'auto') { |
||||
rangeVector = '$__interval'; |
||||
} |
||||
|
||||
return `${def.id}(${innerExpr} [${rangeVector}])`; |
||||
} |
||||
|
||||
function getLineFilterRenderer(operation: string) { |
||||
return function lineFilterRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { |
||||
if (model.params[0] === '') { |
||||
return innerExpr; |
||||
} |
||||
return `${innerExpr} ${operation} \`${model.params[0]}\``; |
||||
}; |
||||
} |
||||
|
||||
function labelFilterRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { |
||||
if (model.params[0] === '') { |
||||
return innerExpr; |
||||
} |
||||
|
||||
if (model.params[1] === '<' || model.params[1] === '>') { |
||||
return `${innerExpr} | ${model.params[0]} ${model.params[1]} ${model.params[2]}`; |
||||
} |
||||
|
||||
return `${innerExpr} | ${model.params[0]}${model.params[1]}"${model.params[2]}"`; |
||||
} |
||||
|
||||
function pipelineRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { |
||||
return `${innerExpr} | ${model.id}`; |
||||
} |
||||
|
||||
function isRangeVectorFunction(def: QueryBuilderOperationDef) { |
||||
return def.category === LokiVisualQueryOperationCategory.RangeFunctions; |
||||
} |
||||
|
||||
function getIndexOfOrLast( |
||||
operations: QueryBuilderOperation[], |
||||
queryModeller: VisualQueryModeller, |
||||
condition: (def: QueryBuilderOperationDef) => boolean |
||||
) { |
||||
const index = operations.findIndex((x) => { |
||||
return condition(queryModeller.getOperationDef(x.id)); |
||||
}); |
||||
|
||||
return index === -1 ? operations.length : index; |
||||
} |
||||
|
||||
export function addLokiOperation( |
||||
def: QueryBuilderOperationDef, |
||||
query: LokiVisualQuery, |
||||
modeller: VisualQueryModeller |
||||
): LokiVisualQuery { |
||||
const newOperation: QueryBuilderOperation = { |
||||
id: def.id, |
||||
params: def.defaultParams, |
||||
}; |
||||
|
||||
const operations = [...query.operations]; |
||||
|
||||
switch (def.category) { |
||||
case LokiVisualQueryOperationCategory.Aggregations: |
||||
case LokiVisualQueryOperationCategory.Functions: { |
||||
const rangeVectorFunction = operations.find((x) => { |
||||
return isRangeVectorFunction(modeller.getOperationDef(x.id)); |
||||
}); |
||||
|
||||
// If we are adding a function but we have not range vector function yet add one
|
||||
if (!rangeVectorFunction) { |
||||
const placeToInsert = getIndexOfOrLast( |
||||
operations, |
||||
modeller, |
||||
(def) => def.category === LokiVisualQueryOperationCategory.Functions |
||||
); |
||||
operations.splice(placeToInsert, 0, { id: 'rate', params: ['auto'] }); |
||||
} |
||||
|
||||
operations.push(newOperation); |
||||
break; |
||||
} |
||||
case LokiVisualQueryOperationCategory.RangeFunctions: |
||||
// Add range functions after any formats, line filters and label filters
|
||||
const placeToInsert = getIndexOfOrLast(operations, modeller, (x) => { |
||||
return ( |
||||
x.category !== LokiVisualQueryOperationCategory.Formats && |
||||
x.category !== LokiVisualQueryOperationCategory.LineFilters && |
||||
x.category !== LokiVisualQueryOperationCategory.LabelFilters |
||||
); |
||||
}); |
||||
operations.splice(placeToInsert, 0, newOperation); |
||||
break; |
||||
case LokiVisualQueryOperationCategory.Formats: |
||||
case LokiVisualQueryOperationCategory.LineFilters: { |
||||
const placeToInsert = getIndexOfOrLast(operations, modeller, (x) => { |
||||
return x.category !== LokiVisualQueryOperationCategory.LineFilters; |
||||
}); |
||||
operations.splice(placeToInsert, 0, newOperation); |
||||
break; |
||||
} |
||||
case LokiVisualQueryOperationCategory.LabelFilters: { |
||||
const placeToInsert = getIndexOfOrLast(operations, modeller, (x) => { |
||||
return ( |
||||
x.category !== LokiVisualQueryOperationCategory.LineFilters && |
||||
x.category !== LokiVisualQueryOperationCategory.Formats |
||||
); |
||||
}); |
||||
operations.splice(placeToInsert, 0, newOperation); |
||||
} |
||||
} |
||||
|
||||
return { |
||||
...query, |
||||
operations, |
||||
}; |
||||
} |
@ -0,0 +1,58 @@ |
||||
import { QueryBuilderLabelFilter, QueryBuilderOperation } from '../../prometheus/querybuilder/shared/types'; |
||||
|
||||
/** |
||||
* Visual query model |
||||
*/ |
||||
export interface LokiVisualQuery { |
||||
labels: QueryBuilderLabelFilter[]; |
||||
operations: QueryBuilderOperation[]; |
||||
binaryQueries?: LokiVisualQueryBinary[]; |
||||
} |
||||
|
||||
export interface LokiVisualQueryBinary { |
||||
operator: string; |
||||
vectorMatches?: string; |
||||
query: LokiVisualQuery; |
||||
} |
||||
export interface LokiQueryPattern { |
||||
name: string; |
||||
operations: QueryBuilderOperation[]; |
||||
} |
||||
|
||||
export enum LokiVisualQueryOperationCategory { |
||||
Aggregations = 'Aggregations', |
||||
RangeFunctions = 'Range functions', |
||||
Functions = 'Functions', |
||||
Formats = 'Formats', |
||||
LineFilters = 'Line filters', |
||||
LabelFilters = 'Label filters', |
||||
} |
||||
|
||||
export enum LokiOperationId { |
||||
Json = 'json', |
||||
Logfmt = 'logfmt', |
||||
Rate = 'rate', |
||||
CountOverTime = 'count_over_time', |
||||
SumOverTime = 'sum_over_time', |
||||
BytesRate = 'bytes_rate', |
||||
BytesOverTime = 'bytes_over_time', |
||||
AbsentOverTime = 'absent_over_time', |
||||
Sum = 'sum', |
||||
Avg = 'avg', |
||||
Min = 'min', |
||||
Max = 'max', |
||||
LineContains = '__line_contains', |
||||
LineContainsNot = '__line_contains_not', |
||||
LineMatchesRegex = '__line_matches_regex', |
||||
LineMatchesRegexNot = '__line_matches_regex_not', |
||||
LabelFilter = '__label_filter', |
||||
LabelFilterNoErrors = '__label_filter_no_errors', |
||||
Unwrap = 'unwrap', |
||||
} |
||||
|
||||
export function getDefaultEmptyQuery(): LokiVisualQuery { |
||||
return { |
||||
labels: [], |
||||
operations: [{ id: '__line_contains', params: [''] }], |
||||
}; |
||||
} |
@ -0,0 +1,15 @@ |
||||
export class EmptyLanguageProviderMock { |
||||
metrics = []; |
||||
constructor() {} |
||||
start() { |
||||
return new Promise((resolve) => { |
||||
resolve(''); |
||||
}); |
||||
} |
||||
getLabelKeys = jest.fn().mockReturnValue([]); |
||||
getLabelValues = jest.fn().mockReturnValue([]); |
||||
getSeries = jest.fn().mockReturnValue({ __name__: [] }); |
||||
fetchSeries = jest.fn().mockReturnValue([]); |
||||
fetchSeriesLabels = jest.fn().mockReturnValue([]); |
||||
fetchLabels = jest.fn(); |
||||
} |
@ -0,0 +1,200 @@ |
||||
import { PromQueryModeller } from './PromQueryModeller'; |
||||
|
||||
describe('PromQueryModeller', () => { |
||||
const modeller = new PromQueryModeller(); |
||||
|
||||
it('Can render query with metric only', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
metric: 'my_totals', |
||||
labels: [], |
||||
operations: [], |
||||
}) |
||||
).toBe('my_totals'); |
||||
}); |
||||
|
||||
it('Can render query with label filters', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
metric: 'my_totals', |
||||
labels: [ |
||||
{ label: 'cluster', op: '=', value: 'us-east' }, |
||||
{ label: 'job', op: '=~', value: 'abc' }, |
||||
], |
||||
operations: [], |
||||
}) |
||||
).toBe('my_totals{cluster="us-east", job=~"abc"}'); |
||||
}); |
||||
|
||||
it('Can render query with function', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
metric: 'my_totals', |
||||
labels: [], |
||||
operations: [{ id: 'sum', params: [] }], |
||||
}) |
||||
).toBe('sum(my_totals)'); |
||||
}); |
||||
|
||||
it('Can render query with function with parameter to left of inner expression', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
metric: 'metric', |
||||
labels: [], |
||||
operations: [{ id: 'histogram_quantile', params: [0.86] }], |
||||
}) |
||||
).toBe('histogram_quantile(0.86, metric)'); |
||||
}); |
||||
|
||||
it('Can render query with function with function parameters to the right of inner expression', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
metric: 'metric', |
||||
labels: [], |
||||
operations: [{ id: 'label_replace', params: ['server', '$1', 'instance', 'as(.*)d'] }], |
||||
}) |
||||
).toBe('label_replace(metric, "server", "$1", "instance", "as(.*)d")'); |
||||
}); |
||||
|
||||
it('Can group by expressions', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
metric: 'metric', |
||||
labels: [], |
||||
operations: [{ id: '__sum_by', params: ['server', 'job'] }], |
||||
}) |
||||
).toBe('sum by(server, job) (metric)'); |
||||
}); |
||||
|
||||
it('Can render avg around a group by', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
metric: 'metric', |
||||
labels: [], |
||||
operations: [ |
||||
{ id: '__sum_by', params: ['server', 'job'] }, |
||||
{ id: 'avg', params: [] }, |
||||
], |
||||
}) |
||||
).toBe('avg(sum by(server, job) (metric))'); |
||||
}); |
||||
|
||||
it('Can render aggregations with parameters', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
metric: 'metric', |
||||
labels: [], |
||||
operations: [{ id: 'topk', params: [5] }], |
||||
}) |
||||
).toBe('topk(5, metric)'); |
||||
}); |
||||
|
||||
it('Can render rate', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
metric: 'metric', |
||||
labels: [{ label: 'pod', op: '=', value: 'A' }], |
||||
operations: [{ id: 'rate', params: ['auto'] }], |
||||
}) |
||||
).toBe('rate(metric{pod="A"}[$__rate_interval])'); |
||||
}); |
||||
|
||||
it('Can render increase', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
metric: 'metric', |
||||
labels: [{ label: 'pod', op: '=', value: 'A' }], |
||||
operations: [{ id: 'increase', params: ['auto'] }], |
||||
}) |
||||
).toBe('increase(metric{pod="A"}[$__rate_interval])'); |
||||
}); |
||||
|
||||
it('Can render rate with custom range-vector', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
metric: 'metric', |
||||
labels: [{ label: 'pod', op: '=', value: 'A' }], |
||||
operations: [{ id: 'rate', params: ['10m'] }], |
||||
}) |
||||
).toBe('rate(metric{pod="A"}[10m])'); |
||||
}); |
||||
|
||||
it('Can render multiply operation', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
metric: 'metric', |
||||
labels: [], |
||||
operations: [{ id: '__multiply_by', params: [1000] }], |
||||
}) |
||||
).toBe('metric * 1000'); |
||||
}); |
||||
|
||||
it('Can render query with simple binary query', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
metric: 'metric_a', |
||||
labels: [], |
||||
operations: [], |
||||
binaryQueries: [ |
||||
{ |
||||
operator: '/', |
||||
query: { |
||||
metric: 'metric_b', |
||||
labels: [], |
||||
operations: [], |
||||
}, |
||||
}, |
||||
], |
||||
}) |
||||
).toBe('metric_a / metric_b'); |
||||
}); |
||||
|
||||
it('Can render query with multiple binary queries and nesting', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
metric: 'metric_a', |
||||
labels: [], |
||||
operations: [], |
||||
binaryQueries: [ |
||||
{ |
||||
operator: '+', |
||||
query: { |
||||
metric: 'metric_b', |
||||
labels: [], |
||||
operations: [], |
||||
}, |
||||
}, |
||||
{ |
||||
operator: '+', |
||||
query: { |
||||
metric: 'metric_c', |
||||
labels: [], |
||||
operations: [], |
||||
}, |
||||
}, |
||||
], |
||||
}) |
||||
).toBe('metric_a + metric_b + metric_c'); |
||||
}); |
||||
|
||||
it('Can render with binary queries with vectorMatches expression', () => { |
||||
expect( |
||||
modeller.renderQuery({ |
||||
metric: 'metric_a', |
||||
labels: [], |
||||
operations: [], |
||||
binaryQueries: [ |
||||
{ |
||||
operator: '/', |
||||
vectorMatches: 'on(le)', |
||||
query: { |
||||
metric: 'metric_b', |
||||
labels: [], |
||||
operations: [], |
||||
}, |
||||
}, |
||||
], |
||||
}) |
||||
).toBe('metric_a / on(le) metric_b'); |
||||
}); |
||||
}); |
@ -0,0 +1,72 @@ |
||||
import { FUNCTIONS } from '../promql'; |
||||
import { getAggregationOperations } from './aggregations'; |
||||
import { getOperationDefinitions } from './operations'; |
||||
import { LokiAndPromQueryModellerBase } from './shared/LokiAndPromQueryModellerBase'; |
||||
import { PromQueryPattern, PromVisualQuery, PromVisualQueryOperationCategory } from './types'; |
||||
|
||||
export class PromQueryModeller extends LokiAndPromQueryModellerBase<PromVisualQuery> { |
||||
constructor() { |
||||
super(() => { |
||||
const allOperations = [...getOperationDefinitions(), ...getAggregationOperations()]; |
||||
for (const op of allOperations) { |
||||
const func = FUNCTIONS.find((x) => x.insertText === op.id); |
||||
if (func) { |
||||
op.documentation = func.documentation; |
||||
} |
||||
} |
||||
return allOperations; |
||||
}); |
||||
|
||||
this.setOperationCategories([ |
||||
PromVisualQueryOperationCategory.Aggregations, |
||||
PromVisualQueryOperationCategory.RangeFunctions, |
||||
PromVisualQueryOperationCategory.Functions, |
||||
PromVisualQueryOperationCategory.BinaryOps, |
||||
]); |
||||
} |
||||
|
||||
renderQuery(query: PromVisualQuery) { |
||||
let queryString = `${query.metric}${this.renderLabels(query.labels)}`; |
||||
queryString = this.renderOperations(queryString, query.operations); |
||||
queryString = this.renderBinaryQueries(queryString, query.binaryQueries); |
||||
return queryString; |
||||
} |
||||
|
||||
getQueryPatterns(): PromQueryPattern[] { |
||||
return [ |
||||
{ |
||||
name: 'Rate then sum', |
||||
operations: [ |
||||
{ id: 'rate', params: ['auto'] }, |
||||
{ id: 'sum', params: [] }, |
||||
], |
||||
}, |
||||
{ |
||||
name: 'Rate then sum by(label) then avg', |
||||
operations: [ |
||||
{ id: 'rate', params: ['auto'] }, |
||||
{ id: '__sum_by', params: [''] }, |
||||
{ id: 'avg', params: [] }, |
||||
], |
||||
}, |
||||
{ |
||||
name: 'Histogram quantile on rate', |
||||
operations: [ |
||||
{ id: 'rate', params: ['auto'] }, |
||||
{ id: '__sum_by', params: ['le'] }, |
||||
{ id: 'histogram_quantile', params: [0.95] }, |
||||
], |
||||
}, |
||||
{ |
||||
name: 'Histogram quantile on increase ', |
||||
operations: [ |
||||
{ id: 'increase', params: ['auto'] }, |
||||
{ id: '__max_by', params: ['le'] }, |
||||
{ id: 'histogram_quantile', params: [0.95] }, |
||||
], |
||||
}, |
||||
]; |
||||
} |
||||
} |
||||
|
||||
export const promQueryModeller = new PromQueryModeller(); |
@ -0,0 +1,177 @@ |
||||
import pluralize from 'pluralize'; |
||||
import { LabelParamEditor } from './components/LabelParamEditor'; |
||||
import { addOperationWithRangeVector } from './operations'; |
||||
import { |
||||
defaultAddOperationHandler, |
||||
functionRendererLeft, |
||||
getPromAndLokiOperationDisplayName, |
||||
} from './shared/operationUtils'; |
||||
import { QueryBuilderOperation, QueryBuilderOperationDef, QueryBuilderOperationParamDef } from './shared/types'; |
||||
import { PromVisualQueryOperationCategory } from './types'; |
||||
|
||||
export function getAggregationOperations(): QueryBuilderOperationDef[] { |
||||
return [ |
||||
...createAggregationOperation('sum'), |
||||
...createAggregationOperation('avg'), |
||||
...createAggregationOperation('min'), |
||||
...createAggregationOperation('max'), |
||||
...createAggregationOperation('count'), |
||||
...createAggregationOperation('topk'), |
||||
createAggregationOverTime('sum'), |
||||
createAggregationOverTime('avg'), |
||||
createAggregationOverTime('min'), |
||||
createAggregationOverTime('max'), |
||||
createAggregationOverTime('count'), |
||||
createAggregationOverTime('last'), |
||||
createAggregationOverTime('present'), |
||||
createAggregationOverTime('stddev'), |
||||
createAggregationOverTime('stdvar'), |
||||
]; |
||||
} |
||||
|
||||
function createAggregationOperation(name: string): QueryBuilderOperationDef[] { |
||||
const operations: QueryBuilderOperationDef[] = [ |
||||
{ |
||||
id: name, |
||||
name: getPromAndLokiOperationDisplayName(name), |
||||
params: [ |
||||
{ |
||||
name: 'By label', |
||||
type: 'string', |
||||
restParam: true, |
||||
optional: true, |
||||
}, |
||||
], |
||||
defaultParams: [], |
||||
alternativesKey: 'plain aggregations', |
||||
category: PromVisualQueryOperationCategory.Aggregations, |
||||
renderer: functionRendererLeft, |
||||
addOperationHandler: defaultAddOperationHandler, |
||||
paramChangedHandler: getOnLabelAdddedHandler(`__${name}_by`), |
||||
}, |
||||
{ |
||||
id: `__${name}_by`, |
||||
name: `${getPromAndLokiOperationDisplayName(name)} by`, |
||||
params: [ |
||||
{ |
||||
name: 'Label', |
||||
type: 'string', |
||||
restParam: true, |
||||
optional: true, |
||||
editor: LabelParamEditor, |
||||
}, |
||||
], |
||||
defaultParams: [''], |
||||
alternativesKey: 'aggregations by', |
||||
category: PromVisualQueryOperationCategory.Aggregations, |
||||
renderer: getAggregationByRenderer(name), |
||||
addOperationHandler: defaultAddOperationHandler, |
||||
paramChangedHandler: getLastLabelRemovedHandler(name), |
||||
explainHandler: getAggregationExplainer(name), |
||||
hideFromList: true, |
||||
}, |
||||
]; |
||||
|
||||
// Handle some special aggregations that have parameters
|
||||
if (name === 'topk') { |
||||
const param: QueryBuilderOperationParamDef = { |
||||
name: 'K-value', |
||||
type: 'number', |
||||
}; |
||||
operations[0].params.unshift(param); |
||||
operations[1].params.unshift(param); |
||||
operations[0].defaultParams = [5]; |
||||
operations[1].defaultParams = [5, '']; |
||||
operations[1].renderer = getAggregationByRendererWithParameter(name); |
||||
} |
||||
|
||||
return operations; |
||||
} |
||||
|
||||
function getAggregationByRenderer(aggregation: string) { |
||||
return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { |
||||
return `${aggregation} by(${model.params.join(', ')}) (${innerExpr})`; |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Very simple poc implementation, needs to be modified to support all aggregation operators |
||||
*/ |
||||
function getAggregationExplainer(aggregationName: string) { |
||||
return function aggregationExplainer(model: QueryBuilderOperation) { |
||||
const labels = model.params.map((label) => `\`${label}\``).join(' and '); |
||||
const labelWord = pluralize('label', model.params.length); |
||||
return `Calculates ${aggregationName} over dimensions while preserving ${labelWord} ${labels}.`; |
||||
}; |
||||
} |
||||
|
||||
function getAggregationByRendererWithParameter(aggregation: string) { |
||||
return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { |
||||
const firstParam = model.params[0]; |
||||
const restParams = model.params.slice(1); |
||||
return `${aggregation} by(${restParams.join(', ')}) (${firstParam}, ${innerExpr})`; |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* This function will transform operations without labels to their plan aggregation operation |
||||
*/ |
||||
function getLastLabelRemovedHandler(changeToOperartionId: string) { |
||||
return function onParamChanged(index: number, op: QueryBuilderOperation, def: QueryBuilderOperationDef) { |
||||
// If definition has more params then is defined there are no optional rest params anymore
|
||||
// We then transform this operation into a different one
|
||||
if (op.params.length < def.params.length) { |
||||
return { |
||||
...op, |
||||
id: changeToOperartionId, |
||||
}; |
||||
} |
||||
|
||||
return op; |
||||
}; |
||||
} |
||||
|
||||
function getOnLabelAdddedHandler(changeToOperartionId: string) { |
||||
return function onParamChanged(index: number, op: QueryBuilderOperation) { |
||||
return { |
||||
...op, |
||||
id: changeToOperartionId, |
||||
}; |
||||
}; |
||||
} |
||||
|
||||
function createAggregationOverTime(name: string): QueryBuilderOperationDef { |
||||
const functionName = `${name}_over_time`; |
||||
return { |
||||
id: functionName, |
||||
name: getPromAndLokiOperationDisplayName(functionName), |
||||
params: [getAggregationOverTimeRangeVector()], |
||||
defaultParams: ['auto'], |
||||
alternativesKey: 'overtime function', |
||||
category: PromVisualQueryOperationCategory.RangeFunctions, |
||||
renderer: operationWithRangeVectorRenderer, |
||||
addOperationHandler: addOperationWithRangeVector, |
||||
}; |
||||
} |
||||
|
||||
function getAggregationOverTimeRangeVector(): QueryBuilderOperationParamDef { |
||||
return { |
||||
name: 'Range vector', |
||||
type: 'string', |
||||
options: ['auto', '$__interval', '$__range', '1m', '5m', '10m', '1h', '24h'], |
||||
}; |
||||
} |
||||
|
||||
function operationWithRangeVectorRenderer( |
||||
model: QueryBuilderOperation, |
||||
def: QueryBuilderOperationDef, |
||||
innerExpr: string |
||||
) { |
||||
let rangeVector = (model.params ?? [])[0] ?? 'auto'; |
||||
|
||||
if (rangeVector === 'auto') { |
||||
rangeVector = '$__interval'; |
||||
} |
||||
|
||||
return `${def.id}(${innerExpr}[${rangeVector}])`; |
||||
} |
@ -0,0 +1,49 @@ |
||||
import { SelectableValue, toOption } from '@grafana/data'; |
||||
import { Select } from '@grafana/ui'; |
||||
import React, { useState } from 'react'; |
||||
import { PrometheusDatasource } from '../../datasource'; |
||||
import { promQueryModeller } from '../PromQueryModeller'; |
||||
import { QueryBuilderOperationParamEditorProps } from '../shared/types'; |
||||
import { PromVisualQuery } from '../types'; |
||||
|
||||
export function LabelParamEditor({ onChange, index, value, query, datasource }: QueryBuilderOperationParamEditorProps) { |
||||
const [state, setState] = useState<{ |
||||
options?: Array<SelectableValue<any>>; |
||||
isLoading?: boolean; |
||||
}>({}); |
||||
|
||||
return ( |
||||
<Select |
||||
menuShouldPortal |
||||
autoFocus={value === '' ? true : undefined} |
||||
openMenuOnFocus |
||||
onOpenMenu={async () => { |
||||
setState({ isLoading: true }); |
||||
const options = await loadGroupByLabels(query as PromVisualQuery, datasource as PrometheusDatasource); |
||||
setState({ options, isLoading: undefined }); |
||||
}} |
||||
isLoading={state.isLoading} |
||||
allowCustomValue |
||||
noOptionsMessage="No labels found" |
||||
loadingMessage="Loading labels" |
||||
options={state.options} |
||||
value={toOption(value as string)} |
||||
onChange={(value) => onChange(index, value.value!)} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
async function loadGroupByLabels( |
||||
query: PromVisualQuery, |
||||
datasource: PrometheusDatasource |
||||
): Promise<Array<SelectableValue<any>>> { |
||||
const labels = [{ label: '__name__', op: '=', value: query.metric }, ...query.labels]; |
||||
const expr = promQueryModeller.renderLabels(labels); |
||||
|
||||
const result = await datasource.languageProvider.fetchSeriesLabels(expr); |
||||
|
||||
return Object.keys(result).map((x) => ({ |
||||
label: x, |
||||
value: x, |
||||
})); |
||||
} |
@ -0,0 +1,58 @@ |
||||
import { Select } from '@grafana/ui'; |
||||
import React, { useState } from 'react'; |
||||
import { PromVisualQuery } from '../types'; |
||||
import { SelectableValue, toOption } from '@grafana/data'; |
||||
import { EditorField, EditorFieldGroup } from '@grafana/experimental'; |
||||
import { css } from '@emotion/css'; |
||||
|
||||
export interface Props { |
||||
query: PromVisualQuery; |
||||
onChange: (query: PromVisualQuery) => void; |
||||
onGetMetrics: () => Promise<string[]>; |
||||
} |
||||
|
||||
export function MetricSelect({ query, onChange, onGetMetrics }: Props) { |
||||
const styles = getStyles(); |
||||
const [state, setState] = useState<{ |
||||
metrics?: Array<SelectableValue<any>>; |
||||
isLoading?: boolean; |
||||
}>({}); |
||||
|
||||
const loadMetrics = async () => { |
||||
return await onGetMetrics().then((res) => { |
||||
return res.map((value) => ({ label: value, value })); |
||||
}); |
||||
}; |
||||
|
||||
return ( |
||||
<EditorFieldGroup> |
||||
<EditorField label="Metric"> |
||||
<Select |
||||
inputId="prometheus-metric-select" |
||||
className={styles.select} |
||||
value={query.metric ? toOption(query.metric) : undefined} |
||||
placeholder="Select metric" |
||||
allowCustomValue |
||||
onOpenMenu={async () => { |
||||
setState({ isLoading: true }); |
||||
const metrics = await loadMetrics(); |
||||
setState({ metrics, isLoading: undefined }); |
||||
}} |
||||
isLoading={state.isLoading} |
||||
options={state.metrics} |
||||
onChange={({ value }) => { |
||||
if (value) { |
||||
onChange({ ...query, metric: value, labels: [] }); |
||||
} |
||||
}} |
||||
/> |
||||
</EditorField> |
||||
</EditorFieldGroup> |
||||
); |
||||
} |
||||
|
||||
const getStyles = () => ({ |
||||
select: css` |
||||
min-width: 125px; |
||||
`,
|
||||
}); |
@ -0,0 +1,104 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { GrafanaTheme2, toOption } from '@grafana/data'; |
||||
import { FlexItem } from '@grafana/experimental'; |
||||
import { IconButton, Input, Select, useStyles2 } from '@grafana/ui'; |
||||
import React from 'react'; |
||||
import { PrometheusDatasource } from '../../datasource'; |
||||
import { PromVisualQueryBinary } from '../types'; |
||||
import { PromQueryBuilder } from './PromQueryBuilder'; |
||||
|
||||
export interface Props { |
||||
nestedQuery: PromVisualQueryBinary; |
||||
datasource: PrometheusDatasource; |
||||
index: number; |
||||
onChange: (index: number, update: PromVisualQueryBinary) => void; |
||||
onRemove: (index: number) => void; |
||||
onRunQuery: () => void; |
||||
} |
||||
|
||||
export const NestedQuery = React.memo<Props>(({ nestedQuery, index, datasource, onChange, onRemove, onRunQuery }) => { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
return ( |
||||
<div className={styles.card}> |
||||
<div className={styles.header}> |
||||
<div className={styles.name}>Operator</div> |
||||
<Select |
||||
width="auto" |
||||
options={operators} |
||||
value={toOption(nestedQuery.operator)} |
||||
onChange={(value) => { |
||||
onChange(index, { |
||||
...nestedQuery, |
||||
operator: value.value!, |
||||
}); |
||||
}} |
||||
/> |
||||
<div className={styles.name}>Vector matches</div> |
||||
|
||||
<Input |
||||
width={20} |
||||
defaultValue={nestedQuery.vectorMatches} |
||||
onBlur={(evt) => { |
||||
onChange(index, { |
||||
...nestedQuery, |
||||
vectorMatches: evt.currentTarget.value, |
||||
}); |
||||
}} |
||||
/> |
||||
|
||||
<FlexItem grow={1} /> |
||||
<IconButton name="times" size="sm" onClick={() => onRemove(index)} /> |
||||
</div> |
||||
<div className={styles.body}> |
||||
<PromQueryBuilder |
||||
query={nestedQuery.query} |
||||
datasource={datasource} |
||||
nested={true} |
||||
onRunQuery={onRunQuery} |
||||
onChange={(update) => { |
||||
onChange(index, { ...nestedQuery, query: update }); |
||||
}} |
||||
/> |
||||
</div> |
||||
</div> |
||||
); |
||||
}); |
||||
|
||||
const operators = [ |
||||
{ label: '/', value: '/' }, |
||||
{ label: '*', value: '*' }, |
||||
{ label: '+', value: '+' }, |
||||
{ label: '==', value: '==' }, |
||||
{ label: '>', value: '>' }, |
||||
{ label: '<', value: '<' }, |
||||
]; |
||||
|
||||
NestedQuery.displayName = 'NestedQuery'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
card: css({ |
||||
background: theme.colors.background.primary, |
||||
border: `1px solid ${theme.colors.border.medium}`, |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
cursor: 'grab', |
||||
borderRadius: theme.shape.borderRadius(1), |
||||
}), |
||||
header: css({ |
||||
borderBottom: `1px solid ${theme.colors.border.medium}`, |
||||
padding: theme.spacing(0.5, 0.5, 0.5, 1), |
||||
gap: theme.spacing(1), |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
}), |
||||
name: css({ |
||||
whiteSpace: 'nowrap', |
||||
}), |
||||
body: css({ |
||||
margin: theme.spacing(1, 1, 0.5, 1), |
||||
display: 'table', |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,73 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { useStyles2 } from '@grafana/ui'; |
||||
import { Stack } from '@grafana/experimental'; |
||||
import React from 'react'; |
||||
import { PrometheusDatasource } from '../../datasource'; |
||||
import { PromVisualQuery, PromVisualQueryBinary } from '../types'; |
||||
import { NestedQuery } from './NestedQuery'; |
||||
|
||||
export interface Props { |
||||
query: PromVisualQuery; |
||||
datasource: PrometheusDatasource; |
||||
onChange: (query: PromVisualQuery) => void; |
||||
onRunQuery: () => void; |
||||
} |
||||
|
||||
export function NestedQueryList({ query, datasource, onChange, onRunQuery }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
const nestedQueries = query.binaryQueries ?? []; |
||||
|
||||
const onNestedQueryUpdate = (index: number, update: PromVisualQueryBinary) => { |
||||
const updatedList = [...nestedQueries]; |
||||
updatedList.splice(index, 1, update); |
||||
onChange({ ...query, binaryQueries: updatedList }); |
||||
}; |
||||
|
||||
const onRemove = (index: number) => { |
||||
const updatedList = [...nestedQueries.slice(0, index), ...nestedQueries.slice(index + 1)]; |
||||
onChange({ ...query, binaryQueries: updatedList }); |
||||
}; |
||||
|
||||
return ( |
||||
<div className={styles.body}> |
||||
<Stack gap={1} direction="column"> |
||||
<h5 className={styles.heading}>Binary operations</h5> |
||||
<Stack gap={1} direction="column"> |
||||
{nestedQueries.map((nestedQuery, index) => ( |
||||
<NestedQuery |
||||
key={index.toString()} |
||||
nestedQuery={nestedQuery} |
||||
index={index} |
||||
onChange={onNestedQueryUpdate} |
||||
datasource={datasource} |
||||
onRemove={onRemove} |
||||
onRunQuery={onRunQuery} |
||||
/> |
||||
))} |
||||
</Stack> |
||||
</Stack> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
heading: css({ |
||||
fontSize: 12, |
||||
fontWeight: theme.typography.fontWeightMedium, |
||||
}), |
||||
body: css({ |
||||
width: '100%', |
||||
}), |
||||
connectingLine: css({ |
||||
height: '2px', |
||||
width: '16px', |
||||
backgroundColor: theme.colors.border.strong, |
||||
alignSelf: 'center', |
||||
}), |
||||
addOperation: css({ |
||||
paddingLeft: theme.spacing(2), |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,153 @@ |
||||
import React from 'react'; |
||||
import { render, screen, getByRole, getByText } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import { PromQueryBuilder } from './PromQueryBuilder'; |
||||
import { PrometheusDatasource } from '../../datasource'; |
||||
import { EmptyLanguageProviderMock } from '../../language_provider.mock'; |
||||
import PromQlLanguageProvider from '../../language_provider'; |
||||
import { PromVisualQuery } from '../types'; |
||||
import { getLabelSelects } from '../testUtils'; |
||||
|
||||
const defaultQuery: PromVisualQuery = { |
||||
metric: 'random_metric', |
||||
labels: [], |
||||
operations: [], |
||||
}; |
||||
|
||||
const bugQuery: PromVisualQuery = { |
||||
metric: 'random_metric', |
||||
labels: [{ label: 'instance', op: '=', value: 'localhost:9090' }], |
||||
operations: [ |
||||
{ |
||||
id: 'rate', |
||||
params: ['auto'], |
||||
}, |
||||
{ |
||||
id: '__sum_by', |
||||
params: ['instance', 'job'], |
||||
}, |
||||
], |
||||
binaryQueries: [ |
||||
{ |
||||
operator: '/', |
||||
query: { |
||||
metric: 'metric2', |
||||
labels: [{ label: 'foo', op: '=', value: 'bar' }], |
||||
operations: [ |
||||
{ |
||||
id: '__sum_by', |
||||
params: ['app'], |
||||
}, |
||||
], |
||||
}, |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
describe('PromQueryBuilder', () => { |
||||
it('shows empty just with metric selected', async () => { |
||||
setup(); |
||||
// One should be select another query preview
|
||||
expect(screen.getAllByText('random_metric').length).toBe(2); |
||||
// Add label
|
||||
expect(screen.getByLabelText('Add')).toBeInTheDocument(); |
||||
expect(screen.getByLabelText('Add operation')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('renders all the query sections', async () => { |
||||
setup(bugQuery); |
||||
expect(screen.getByText('random_metric')).toBeInTheDocument(); |
||||
expect(screen.getByText('localhost:9090')).toBeInTheDocument(); |
||||
expect(screen.getByText('Rate')).toBeInTheDocument(); |
||||
const sumBys = screen.getAllByTestId('operation-wrapper-for-__sum_by'); |
||||
expect(getByText(sumBys[0], 'instance')).toBeInTheDocument(); |
||||
expect(getByText(sumBys[0], 'job')).toBeInTheDocument(); |
||||
|
||||
expect(getByText(sumBys[1], 'app')).toBeInTheDocument(); |
||||
expect(screen.getByText('Binary operations')).toBeInTheDocument(); |
||||
expect(screen.getByText('Operator')).toBeInTheDocument(); |
||||
expect(screen.getByText('Vector matches')).toBeInTheDocument(); |
||||
expect(screen.getByLabelText('selector').textContent).toBe( |
||||
'sum by(instance, job) (rate(random_metric{instance="localhost:9090"}[$__rate_interval])) / sum by(app) (metric2{foo="bar"})' |
||||
); |
||||
}); |
||||
|
||||
it('tries to load metrics without labels', async () => { |
||||
const { languageProvider } = setup(); |
||||
openMetricSelect(); |
||||
expect(languageProvider.getLabelValues).toBeCalledWith('__name__'); |
||||
}); |
||||
|
||||
it('tries to load metrics with labels', async () => { |
||||
const { languageProvider } = setup({ |
||||
...defaultQuery, |
||||
labels: [{ label: 'label_name', op: '=', value: 'label_value' }], |
||||
}); |
||||
openMetricSelect(); |
||||
expect(languageProvider.getSeries).toBeCalledWith('{label_name="label_value"}', true); |
||||
}); |
||||
|
||||
it('tries to load labels when metric selected', async () => { |
||||
const { languageProvider } = setup(); |
||||
openLabelNameSelect(); |
||||
expect(languageProvider.fetchSeriesLabels).toBeCalledWith('{__name__="random_metric"}'); |
||||
}); |
||||
|
||||
it('tries to load labels when metric selected and other labels are already present', async () => { |
||||
const { languageProvider } = setup({ |
||||
...defaultQuery, |
||||
labels: [ |
||||
{ label: 'label_name', op: '=', value: 'label_value' }, |
||||
{ label: 'foo', op: '=', value: 'bar' }, |
||||
], |
||||
}); |
||||
openLabelNameSelect(1); |
||||
expect(languageProvider.fetchSeriesLabels).toBeCalledWith('{label_name="label_value", __name__="random_metric"}'); |
||||
}); |
||||
|
||||
it('tries to load labels when metric is not selected', async () => { |
||||
const { languageProvider } = setup({ |
||||
...defaultQuery, |
||||
metric: '', |
||||
}); |
||||
openLabelNameSelect(); |
||||
expect(languageProvider.fetchLabels).toBeCalled(); |
||||
}); |
||||
}); |
||||
|
||||
function setup(query: PromVisualQuery = defaultQuery) { |
||||
const languageProvider = (new EmptyLanguageProviderMock() as unknown) as PromQlLanguageProvider; |
||||
const props = { |
||||
datasource: new PrometheusDatasource( |
||||
{ |
||||
url: '', |
||||
jsonData: {}, |
||||
meta: {} as any, |
||||
} as any, |
||||
undefined, |
||||
undefined, |
||||
languageProvider |
||||
), |
||||
onRunQuery: () => {}, |
||||
onChange: () => {}, |
||||
}; |
||||
|
||||
render(<PromQueryBuilder {...props} query={query} />); |
||||
return { languageProvider }; |
||||
} |
||||
|
||||
function getMetricSelect() { |
||||
const metricSelect = screen.getAllByText('random_metric')[0].parentElement!; |
||||
// We need to return specifically input element otherwise clicks don't seem to work
|
||||
return getByRole(metricSelect, 'combobox'); |
||||
} |
||||
|
||||
function openMetricSelect() { |
||||
const select = getMetricSelect(); |
||||
userEvent.click(select); |
||||
} |
||||
|
||||
function openLabelNameSelect(index = 0) { |
||||
const { name } = getLabelSelects(index); |
||||
userEvent.click(name); |
||||
} |
@ -0,0 +1,105 @@ |
||||
import React from 'react'; |
||||
import { MetricSelect } from './MetricSelect'; |
||||
import { PromVisualQuery } from '../types'; |
||||
import { LabelFilters } from '../shared/LabelFilters'; |
||||
import { OperationList } from '../shared/OperationList'; |
||||
import { EditorRows, EditorRow } from '@grafana/experimental'; |
||||
import { PrometheusDatasource } from '../../datasource'; |
||||
import { NestedQueryList } from './NestedQueryList'; |
||||
import { promQueryModeller } from '../PromQueryModeller'; |
||||
import { QueryBuilderLabelFilter } from '../shared/types'; |
||||
import { QueryPreview } from './QueryPreview'; |
||||
import { DataSourceApi } from '@grafana/data'; |
||||
import { OperationsEditorRow } from '../shared/OperationsEditorRow'; |
||||
|
||||
export interface Props { |
||||
query: PromVisualQuery; |
||||
datasource: PrometheusDatasource; |
||||
onChange: (update: PromVisualQuery) => void; |
||||
onRunQuery: () => void; |
||||
nested?: boolean; |
||||
} |
||||
|
||||
export const PromQueryBuilder = React.memo<Props>(({ datasource, query, onChange, onRunQuery, nested }) => { |
||||
const onChangeLabels = (labels: QueryBuilderLabelFilter[]) => { |
||||
onChange({ ...query, labels }); |
||||
}; |
||||
|
||||
const onGetLabelNames = async (forLabel: Partial<QueryBuilderLabelFilter>): Promise<string[]> => { |
||||
// If no metric we need to use a different method
|
||||
if (!query.metric) { |
||||
// Todo add caching but inside language provider!
|
||||
await datasource.languageProvider.fetchLabels(); |
||||
return datasource.languageProvider.getLabelKeys(); |
||||
} |
||||
|
||||
const labelsToConsider = query.labels.filter((x) => x !== forLabel); |
||||
labelsToConsider.push({ label: '__name__', op: '=', value: query.metric }); |
||||
const expr = promQueryModeller.renderLabels(labelsToConsider); |
||||
const labelsIndex = await datasource.languageProvider.fetchSeriesLabels(expr); |
||||
|
||||
// filter out already used labels
|
||||
return Object.keys(labelsIndex).filter( |
||||
(labelName) => !labelsToConsider.find((filter) => filter.label === labelName) |
||||
); |
||||
}; |
||||
|
||||
const onGetLabelValues = async (forLabel: Partial<QueryBuilderLabelFilter>) => { |
||||
if (!forLabel.label) { |
||||
return []; |
||||
} |
||||
|
||||
// If no metric we need to use a different method
|
||||
if (!query.metric) { |
||||
return await datasource.languageProvider.getLabelValues(forLabel.label); |
||||
} |
||||
|
||||
const labelsToConsider = query.labels.filter((x) => x !== forLabel); |
||||
labelsToConsider.push({ label: '__name__', op: '=', value: query.metric }); |
||||
const expr = promQueryModeller.renderLabels(labelsToConsider); |
||||
const result = await datasource.languageProvider.fetchSeriesLabels(expr); |
||||
return result[forLabel.label] ?? []; |
||||
}; |
||||
|
||||
const onGetMetrics = async () => { |
||||
if (query.labels.length > 0) { |
||||
const expr = promQueryModeller.renderLabels(query.labels); |
||||
return (await datasource.languageProvider.getSeries(expr, true))['__name__'] ?? []; |
||||
} else { |
||||
return (await datasource.languageProvider.getLabelValues('__name__')) ?? []; |
||||
} |
||||
}; |
||||
|
||||
return ( |
||||
<EditorRows> |
||||
<EditorRow> |
||||
<MetricSelect query={query} onChange={onChange} onGetMetrics={onGetMetrics} /> |
||||
<LabelFilters |
||||
labelsFilters={query.labels} |
||||
onChange={onChangeLabels} |
||||
onGetLabelNames={onGetLabelNames} |
||||
onGetLabelValues={onGetLabelValues} |
||||
/> |
||||
</EditorRow> |
||||
<OperationsEditorRow> |
||||
<OperationList<PromVisualQuery> |
||||
queryModeller={promQueryModeller} |
||||
datasource={datasource as DataSourceApi} |
||||
query={query} |
||||
onChange={onChange} |
||||
onRunQuery={onRunQuery} |
||||
/> |
||||
{query.binaryQueries && query.binaryQueries.length > 0 && ( |
||||
<NestedQueryList query={query} datasource={datasource} onChange={onChange} onRunQuery={onRunQuery} /> |
||||
)} |
||||
</OperationsEditorRow> |
||||
{!nested && ( |
||||
<EditorRow> |
||||
<QueryPreview query={query} /> |
||||
</EditorRow> |
||||
)} |
||||
</EditorRows> |
||||
); |
||||
}); |
||||
|
||||
PromQueryBuilder.displayName = 'PromQueryBuilder'; |
@ -0,0 +1,12 @@ |
||||
import React from 'react'; |
||||
import { PrometheusDatasource } from '../../datasource'; |
||||
import { PromVisualQuery } from '../types'; |
||||
|
||||
export interface PromQueryBuilderContextType { |
||||
query: PromVisualQuery; |
||||
datasource: PrometheusDatasource; |
||||
} |
||||
|
||||
export const PromQueryBuilderContext = React.createContext<PromQueryBuilderContextType>( |
||||
({} as any) as PromQueryBuilderContextType |
||||
); |
@ -0,0 +1,24 @@ |
||||
import React from 'react'; |
||||
import { PromVisualQuery } from '../types'; |
||||
import { Stack } from '@grafana/experimental'; |
||||
import { promQueryModeller } from '../PromQueryModeller'; |
||||
import { OperationListExplained } from '../shared/OperationListExplained'; |
||||
import { OperationExplainedBox } from '../shared/OperationExplainedBox'; |
||||
|
||||
export interface Props { |
||||
query: PromVisualQuery; |
||||
nested?: boolean; |
||||
} |
||||
|
||||
export const PromQueryBuilderExplained = React.memo<Props>(({ query, nested }) => { |
||||
return ( |
||||
<Stack gap={0} direction="column"> |
||||
<OperationExplainedBox stepNumber={1} title={`${query.metric} ${promQueryModeller.renderLabels(query.labels)}`}> |
||||
Fetch all series matching metric name and label filters. |
||||
</OperationExplainedBox> |
||||
<OperationListExplained<PromVisualQuery> stepNumber={2} queryModeller={promQueryModeller} query={query} /> |
||||
</Stack> |
||||
); |
||||
}); |
||||
|
||||
PromQueryBuilderExplained.displayName = 'PromQueryBuilderExplained'; |
@ -0,0 +1,150 @@ |
||||
import React from 'react'; |
||||
import { render, screen } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import { PromQueryEditorSelector } from './PromQueryEditorSelector'; |
||||
import { PrometheusDatasource } from '../../datasource'; |
||||
import { QueryEditorMode } from '../shared/types'; |
||||
import { EmptyLanguageProviderMock } from '../../language_provider.mock'; |
||||
import PromQlLanguageProvider from '../../language_provider'; |
||||
|
||||
// We need to mock this because it seems jest has problem importing monaco in tests
|
||||
jest.mock('../../components/monaco-query-field/MonacoQueryFieldWrapper', () => { |
||||
return { |
||||
MonacoQueryFieldWrapper: () => { |
||||
return 'MonacoQueryFieldWrapper'; |
||||
}, |
||||
}; |
||||
}); |
||||
|
||||
const defaultQuery = { |
||||
refId: 'A', |
||||
expr: 'metric{label1="foo", label2="bar"}', |
||||
}; |
||||
|
||||
const defaultProps = { |
||||
datasource: new PrometheusDatasource( |
||||
{ |
||||
id: 1, |
||||
uid: '', |
||||
type: 'prometheus', |
||||
name: 'prom-test', |
||||
access: 'proxy', |
||||
url: '', |
||||
jsonData: {}, |
||||
meta: {} as any, |
||||
}, |
||||
undefined, |
||||
undefined, |
||||
(new EmptyLanguageProviderMock() as unknown) as PromQlLanguageProvider |
||||
), |
||||
query: defaultQuery, |
||||
onRunQuery: () => {}, |
||||
onChange: () => {}, |
||||
}; |
||||
|
||||
describe('PromQueryEditorSelector', () => { |
||||
it('shows code editor if expr and nothing else', async () => { |
||||
// We opt for showing code editor for queries created before this feature was added
|
||||
render(<PromQueryEditorSelector {...defaultProps} />); |
||||
expectCodeEditor(); |
||||
}); |
||||
|
||||
it('shows builder if new query', async () => { |
||||
render( |
||||
<PromQueryEditorSelector |
||||
{...defaultProps} |
||||
query={{ |
||||
refId: 'A', |
||||
expr: '', |
||||
}} |
||||
/> |
||||
); |
||||
expectBuilder(); |
||||
}); |
||||
|
||||
it('shows code editor when code mode is set', async () => { |
||||
renderWithMode(QueryEditorMode.Code); |
||||
expectCodeEditor(); |
||||
}); |
||||
|
||||
it('shows builder when builder mode is set', async () => { |
||||
renderWithMode(QueryEditorMode.Builder); |
||||
expectBuilder(); |
||||
}); |
||||
|
||||
it('shows explain when explain mode is set', async () => { |
||||
renderWithMode(QueryEditorMode.Explain); |
||||
expectExplain(); |
||||
}); |
||||
|
||||
it('changes to builder mode', async () => { |
||||
const { onChange } = renderWithMode(QueryEditorMode.Code); |
||||
switchToMode(QueryEditorMode.Builder); |
||||
expect(onChange).toBeCalledWith({ |
||||
refId: 'A', |
||||
expr: '', |
||||
editorMode: QueryEditorMode.Builder, |
||||
}); |
||||
}); |
||||
|
||||
it('changes to code mode', async () => { |
||||
const { onChange } = renderWithMode(QueryEditorMode.Builder); |
||||
switchToMode(QueryEditorMode.Code); |
||||
expect(onChange).toBeCalledWith({ |
||||
refId: 'A', |
||||
expr: '', |
||||
editorMode: QueryEditorMode.Code, |
||||
}); |
||||
}); |
||||
|
||||
it('changes to explain mode', async () => { |
||||
const { onChange } = renderWithMode(QueryEditorMode.Code); |
||||
switchToMode(QueryEditorMode.Explain); |
||||
expect(onChange).toBeCalledWith({ |
||||
refId: 'A', |
||||
expr: '', |
||||
editorMode: QueryEditorMode.Explain, |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
function renderWithMode(mode: QueryEditorMode) { |
||||
const onChange = jest.fn(); |
||||
render( |
||||
<PromQueryEditorSelector |
||||
{...defaultProps} |
||||
onChange={onChange} |
||||
query={{ |
||||
refId: 'A', |
||||
expr: '', |
||||
editorMode: mode, |
||||
}} |
||||
/> |
||||
); |
||||
return { onChange }; |
||||
} |
||||
|
||||
function expectCodeEditor() { |
||||
// Metric browser shows this until metrics are loaded.
|
||||
expect(screen.getByText('Loading metrics...')).toBeInTheDocument(); |
||||
} |
||||
|
||||
function expectBuilder() { |
||||
expect(screen.getByText('Select metric')).toBeInTheDocument(); |
||||
} |
||||
|
||||
function expectExplain() { |
||||
// Base message when there is no query
|
||||
expect(screen.getByText(/Fetch all series/)).toBeInTheDocument(); |
||||
} |
||||
|
||||
function switchToMode(mode: QueryEditorMode) { |
||||
const label = { |
||||
[QueryEditorMode.Code]: 'Code', |
||||
[QueryEditorMode.Explain]: 'Explain', |
||||
[QueryEditorMode.Builder]: 'Builder', |
||||
}[mode]; |
||||
|
||||
const switchEl = screen.getByLabelText(label); |
||||
userEvent.click(switchEl); |
||||
} |
@ -0,0 +1,124 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { CoreApp, GrafanaTheme2, LoadingState } from '@grafana/data'; |
||||
import { EditorHeader, FlexItem, InlineSelect, Space, Stack } from '@grafana/experimental'; |
||||
import { Button, Switch, useStyles2 } from '@grafana/ui'; |
||||
import React, { SyntheticEvent, useCallback, useState } from 'react'; |
||||
import { PromQueryEditor } from '../../components/PromQueryEditor'; |
||||
import { PromQueryEditorProps } from '../../components/types'; |
||||
import { promQueryModeller } from '../PromQueryModeller'; |
||||
import { QueryEditorModeToggle } from '../shared/QueryEditorModeToggle'; |
||||
import { QueryEditorMode } from '../shared/types'; |
||||
import { getDefaultEmptyQuery, PromVisualQuery } from '../types'; |
||||
import { PromQueryBuilder } from './PromQueryBuilder'; |
||||
import { PromQueryBuilderExplained } from './PromQueryBuilderExplained'; |
||||
|
||||
export const PromQueryEditorSelector = React.memo<PromQueryEditorProps>((props) => { |
||||
const { query, onChange, onRunQuery, data } = props; |
||||
const styles = useStyles2(getStyles); |
||||
const [visualQuery, setVisualQuery] = useState<PromVisualQuery>(query.visualQuery ?? getDefaultEmptyQuery()); |
||||
|
||||
const onEditorModeChange = useCallback( |
||||
(newMetricEditorMode: QueryEditorMode) => { |
||||
onChange({ ...query, editorMode: newMetricEditorMode }); |
||||
}, |
||||
[onChange, query] |
||||
); |
||||
|
||||
const onChangeViewModel = (updatedQuery: PromVisualQuery) => { |
||||
setVisualQuery(updatedQuery); |
||||
|
||||
onChange({ |
||||
...query, |
||||
expr: promQueryModeller.renderQuery(updatedQuery), |
||||
visualQuery: updatedQuery, |
||||
editorMode: QueryEditorMode.Builder, |
||||
}); |
||||
}; |
||||
|
||||
const onInstantChange = (event: SyntheticEvent<HTMLInputElement>) => { |
||||
const isEnabled = event.currentTarget.checked; |
||||
onChange({ ...query, instant: isEnabled, exemplar: false }); |
||||
onRunQuery(); |
||||
}; |
||||
|
||||
const onExemplarChange = (event: SyntheticEvent<HTMLInputElement>) => { |
||||
const isEnabled = event.currentTarget.checked; |
||||
onChange({ ...query, exemplar: isEnabled }); |
||||
onRunQuery(); |
||||
}; |
||||
|
||||
// If no expr (ie new query) then default to builder
|
||||
const editorMode = query.editorMode ?? (query.expr ? QueryEditorMode.Code : QueryEditorMode.Builder); |
||||
const showExemplarSwitch = props.app !== CoreApp.UnifiedAlerting && !query.instant; |
||||
|
||||
return ( |
||||
<> |
||||
<EditorHeader> |
||||
<FlexItem grow={1} /> |
||||
<Button |
||||
className={styles.runQuery} |
||||
variant="secondary" |
||||
size="sm" |
||||
fill="outline" |
||||
onClick={onRunQuery} |
||||
icon={data?.state === LoadingState.Loading ? 'fa fa-spinner' : undefined} |
||||
disabled={data?.state === LoadingState.Loading} |
||||
> |
||||
Run query |
||||
</Button> |
||||
<Stack gap={1}> |
||||
<label className={styles.switchLabel}>Instant</label> |
||||
<Switch value={query.instant} onChange={onInstantChange} /> |
||||
</Stack> |
||||
{showExemplarSwitch && ( |
||||
<Stack gap={1}> |
||||
<label className={styles.switchLabel}>Exemplars</label> |
||||
<Switch value={query.exemplar} onChange={onExemplarChange} /> |
||||
</Stack> |
||||
)} |
||||
{editorMode === QueryEditorMode.Builder && ( |
||||
<> |
||||
<InlineSelect |
||||
value={null} |
||||
placeholder="Query patterns" |
||||
allowCustomValue |
||||
onChange={({ value }) => { |
||||
onChangeViewModel({ |
||||
...visualQuery, |
||||
operations: value?.operations!, |
||||
}); |
||||
}} |
||||
options={promQueryModeller.getQueryPatterns().map((x) => ({ label: x.name, value: x }))} |
||||
/> |
||||
</> |
||||
)} |
||||
<QueryEditorModeToggle mode={editorMode} onChange={onEditorModeChange} /> |
||||
</EditorHeader> |
||||
<Space v={0.5} /> |
||||
{editorMode === QueryEditorMode.Code && <PromQueryEditor {...props} />} |
||||
{editorMode === QueryEditorMode.Builder && ( |
||||
<PromQueryBuilder |
||||
query={visualQuery} |
||||
datasource={props.datasource} |
||||
onChange={onChangeViewModel} |
||||
onRunQuery={props.onRunQuery} |
||||
/> |
||||
)} |
||||
{editorMode === QueryEditorMode.Explain && <PromQueryBuilderExplained query={visualQuery} />} |
||||
</> |
||||
); |
||||
}); |
||||
|
||||
PromQueryEditorSelector.displayName = 'PromQueryEditorSelector'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
runQuery: css({ |
||||
color: theme.colors.text.secondary, |
||||
}), |
||||
switchLabel: css({ |
||||
color: theme.colors.text.secondary, |
||||
fontSize: theme.typography.bodySmall.fontSize, |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,41 @@ |
||||
import React from 'react'; |
||||
import { PromVisualQuery } from '../types'; |
||||
import { useTheme2 } from '@grafana/ui'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { promQueryModeller } from '../PromQueryModeller'; |
||||
import { css, cx } from '@emotion/css'; |
||||
import { EditorField, EditorFieldGroup } from '@grafana/experimental'; |
||||
import Prism from 'prismjs'; |
||||
import { promqlGrammar } from '../../promql'; |
||||
|
||||
export interface Props { |
||||
query: PromVisualQuery; |
||||
} |
||||
|
||||
export function QueryPreview({ query }: Props) { |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme); |
||||
const hightlighted = Prism.highlight(promQueryModeller.renderQuery(query), promqlGrammar, 'promql'); |
||||
|
||||
return ( |
||||
<EditorFieldGroup> |
||||
<EditorField label="Query text"> |
||||
<div |
||||
className={cx(styles.editorField, 'prism-syntax-highlight')} |
||||
aria-label="selector" |
||||
dangerouslySetInnerHTML={{ __html: hightlighted }} |
||||
/> |
||||
</EditorField> |
||||
</EditorFieldGroup> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
editorField: css({ |
||||
padding: theme.spacing(0.25, 1), |
||||
fontFamily: theme.typography.fontFamilyMonospace, |
||||
fontSize: theme.typography.bodySmall.fontSize, |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,176 @@ |
||||
import { |
||||
defaultAddOperationHandler, |
||||
functionRendererLeft, |
||||
functionRendererRight, |
||||
getPromAndLokiOperationDisplayName, |
||||
} from './shared/operationUtils'; |
||||
import { |
||||
QueryBuilderOperation, |
||||
QueryBuilderOperationDef, |
||||
QueryBuilderOperationParamDef, |
||||
VisualQueryModeller, |
||||
} from './shared/types'; |
||||
import { PromVisualQuery, PromVisualQueryOperationCategory } from './types'; |
||||
|
||||
export function getOperationDefinitions(): QueryBuilderOperationDef[] { |
||||
const list: QueryBuilderOperationDef[] = [ |
||||
{ |
||||
id: 'histogram_quantile', |
||||
name: 'Histogram quantile', |
||||
params: [{ name: 'Quantile', type: 'number', options: [0.99, 0.95, 0.9, 0.75, 0.5, 0.25] }], |
||||
defaultParams: [0.9], |
||||
category: PromVisualQueryOperationCategory.Functions, |
||||
renderer: functionRendererLeft, |
||||
addOperationHandler: defaultAddOperationHandler, |
||||
}, |
||||
{ |
||||
id: 'label_replace', |
||||
name: 'Label replace', |
||||
params: [ |
||||
{ name: 'Destination label', type: 'string' }, |
||||
{ name: 'Replacement', type: 'string' }, |
||||
{ name: 'Source label', type: 'string' }, |
||||
{ name: 'Regex', type: 'string' }, |
||||
], |
||||
category: PromVisualQueryOperationCategory.Functions, |
||||
defaultParams: ['', '$1', '', '(.*)'], |
||||
renderer: functionRendererRight, |
||||
addOperationHandler: defaultAddOperationHandler, |
||||
}, |
||||
{ |
||||
id: 'ln', |
||||
name: 'Ln', |
||||
params: [], |
||||
defaultParams: [], |
||||
category: PromVisualQueryOperationCategory.Functions, |
||||
renderer: functionRendererLeft, |
||||
addOperationHandler: defaultAddOperationHandler, |
||||
}, |
||||
createRangeFunction('changes'), |
||||
createRangeFunction('rate'), |
||||
createRangeFunction('irate'), |
||||
createRangeFunction('increase'), |
||||
createRangeFunction('delta'), |
||||
// Not sure about this one. It could also be a more generic "Simple math operation" where user specifies
|
||||
// both the operator and the operand in a single input
|
||||
{ |
||||
id: '__multiply_by', |
||||
name: 'Multiply by scalar', |
||||
params: [{ name: 'Factor', type: 'number' }], |
||||
defaultParams: [2], |
||||
category: PromVisualQueryOperationCategory.BinaryOps, |
||||
renderer: getSimpleBinaryRenderer('*'), |
||||
addOperationHandler: defaultAddOperationHandler, |
||||
}, |
||||
{ |
||||
id: '__divide_by', |
||||
name: 'Divide by scalar', |
||||
params: [{ name: 'Factor', type: 'number' }], |
||||
defaultParams: [2], |
||||
category: PromVisualQueryOperationCategory.BinaryOps, |
||||
renderer: getSimpleBinaryRenderer('/'), |
||||
addOperationHandler: defaultAddOperationHandler, |
||||
}, |
||||
{ |
||||
id: '__nested_query', |
||||
name: 'Binary operation with query', |
||||
params: [], |
||||
defaultParams: [], |
||||
category: PromVisualQueryOperationCategory.BinaryOps, |
||||
renderer: (model, def, innerExpr) => innerExpr, |
||||
addOperationHandler: addNestedQueryHandler, |
||||
}, |
||||
]; |
||||
|
||||
return list; |
||||
} |
||||
|
||||
function createRangeFunction(name: string): QueryBuilderOperationDef { |
||||
return { |
||||
id: name, |
||||
name: getPromAndLokiOperationDisplayName(name), |
||||
params: [getRangeVectorParamDef()], |
||||
defaultParams: ['auto'], |
||||
alternativesKey: 'range function', |
||||
category: PromVisualQueryOperationCategory.RangeFunctions, |
||||
renderer: operationWithRangeVectorRenderer, |
||||
addOperationHandler: addOperationWithRangeVector, |
||||
}; |
||||
} |
||||
|
||||
function operationWithRangeVectorRenderer( |
||||
model: QueryBuilderOperation, |
||||
def: QueryBuilderOperationDef, |
||||
innerExpr: string |
||||
) { |
||||
let rangeVector = (model.params ?? [])[0] ?? 'auto'; |
||||
|
||||
if (rangeVector === 'auto') { |
||||
rangeVector = '$__rate_interval'; |
||||
} |
||||
|
||||
return `${def.id}(${innerExpr}[${rangeVector}])`; |
||||
} |
||||
|
||||
function getSimpleBinaryRenderer(operator: string) { |
||||
return function binaryRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { |
||||
return `${innerExpr} ${operator} ${model.params[0]}`; |
||||
}; |
||||
} |
||||
|
||||
function getRangeVectorParamDef(): QueryBuilderOperationParamDef { |
||||
return { |
||||
name: 'Range vector', |
||||
type: 'string', |
||||
options: ['auto', '$__rate_interval', '$__interval', '$__range', '1m', '5m', '10m', '1h', '24h'], |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Since there can only be one operation with range vector this will replace the current one (if one was added ) |
||||
*/ |
||||
export function addOperationWithRangeVector( |
||||
def: QueryBuilderOperationDef, |
||||
query: PromVisualQuery, |
||||
modeller: VisualQueryModeller |
||||
) { |
||||
if (query.operations.length > 0) { |
||||
const firstOp = modeller.getOperationDef(query.operations[0].id); |
||||
|
||||
if (firstOp.addOperationHandler === addOperationWithRangeVector) { |
||||
return { |
||||
...query, |
||||
operations: [ |
||||
{ |
||||
...query.operations[0], |
||||
id: def.id, |
||||
}, |
||||
...query.operations.slice(1), |
||||
], |
||||
}; |
||||
} |
||||
} |
||||
|
||||
const newOperation: QueryBuilderOperation = { |
||||
id: def.id, |
||||
params: def.defaultParams, |
||||
}; |
||||
|
||||
return { |
||||
...query, |
||||
operations: [newOperation, ...query.operations], |
||||
}; |
||||
} |
||||
|
||||
function addNestedQueryHandler(def: QueryBuilderOperationDef, query: PromVisualQuery): PromVisualQuery { |
||||
return { |
||||
...query, |
||||
binaryQueries: [ |
||||
...(query.binaryQueries ?? []), |
||||
{ |
||||
operator: '/', |
||||
query, |
||||
}, |
||||
], |
||||
}; |
||||
} |
@ -0,0 +1,123 @@ |
||||
import React, { useState } from 'react'; |
||||
import { Select } from '@grafana/ui'; |
||||
import { SelectableValue, toOption } from '@grafana/data'; |
||||
import { QueryBuilderLabelFilter } from './types'; |
||||
import { AccessoryButton, InputGroup } from '@grafana/experimental'; |
||||
|
||||
export interface Props { |
||||
defaultOp: string; |
||||
item: Partial<QueryBuilderLabelFilter>; |
||||
onChange: (value: QueryBuilderLabelFilter) => void; |
||||
onGetLabelNames: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<string[]>; |
||||
onGetLabelValues: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<string[]>; |
||||
onDelete: () => void; |
||||
} |
||||
|
||||
export function LabelFilterItem({ item, defaultOp, onChange, onDelete, onGetLabelNames, onGetLabelValues }: Props) { |
||||
const [state, setState] = useState<{ |
||||
labelNames?: Array<SelectableValue<any>>; |
||||
labelValues?: Array<SelectableValue<any>>; |
||||
isLoadingLabelNames?: boolean; |
||||
isLoadingLabelValues?: boolean; |
||||
}>({}); |
||||
|
||||
const isMultiSelect = () => { |
||||
return item.op === operators[0].label; |
||||
}; |
||||
|
||||
const getValue = (item: any) => { |
||||
if (item && item.value) { |
||||
if (item.value.indexOf('|') > 0) { |
||||
return item.value.split('|').map((x: any) => ({ label: x, value: x })); |
||||
} |
||||
return toOption(item.value); |
||||
} |
||||
return null; |
||||
}; |
||||
|
||||
const getOptions = () => { |
||||
if (!state.labelValues && item && item.value && item.value.indexOf('|') > 0) { |
||||
return getValue(item); |
||||
} |
||||
|
||||
return state.labelValues; |
||||
}; |
||||
|
||||
return ( |
||||
<div data-testid="prometheus-dimensions-filter-item"> |
||||
<InputGroup> |
||||
<Select |
||||
inputId="prometheus-dimensions-filter-item-key" |
||||
width="auto" |
||||
value={item.label ? toOption(item.label) : null} |
||||
allowCustomValue |
||||
onOpenMenu={async () => { |
||||
setState({ isLoadingLabelNames: true }); |
||||
const labelNames = (await onGetLabelNames(item)).map((x) => ({ label: x, value: x })); |
||||
setState({ labelNames, isLoadingLabelNames: undefined }); |
||||
}} |
||||
isLoading={state.isLoadingLabelNames} |
||||
options={state.labelNames} |
||||
onChange={(change) => { |
||||
if (change.label) { |
||||
onChange(({ |
||||
...item, |
||||
op: item.op ?? defaultOp, |
||||
label: change.label, |
||||
} as any) as QueryBuilderLabelFilter); |
||||
} |
||||
}} |
||||
/> |
||||
|
||||
<Select |
||||
value={toOption(item.op ?? defaultOp)} |
||||
options={operators} |
||||
width="auto" |
||||
onChange={(change) => { |
||||
if (change.value != null) { |
||||
onChange(({ ...item, op: change.value } as any) as QueryBuilderLabelFilter); |
||||
} |
||||
}} |
||||
/> |
||||
|
||||
<Select |
||||
inputId="prometheus-dimensions-filter-item-value" |
||||
width="auto" |
||||
value={getValue(item)} |
||||
allowCustomValue |
||||
onOpenMenu={async () => { |
||||
setState({ isLoadingLabelValues: true }); |
||||
const labelValues = await onGetLabelValues(item); |
||||
setState({ |
||||
...state, |
||||
labelValues: labelValues.map((value) => ({ label: value, value })), |
||||
isLoadingLabelValues: undefined, |
||||
}); |
||||
}} |
||||
isMulti={isMultiSelect()} |
||||
isLoading={state.isLoadingLabelValues} |
||||
options={getOptions()} |
||||
onChange={(change) => { |
||||
if (change.value) { |
||||
onChange(({ ...item, value: change.value, op: item.op ?? defaultOp } as any) as QueryBuilderLabelFilter); |
||||
} else { |
||||
const changes = change |
||||
.map((change: any) => { |
||||
return change.label; |
||||
}) |
||||
.join('|'); |
||||
onChange(({ ...item, value: changes, op: item.op ?? defaultOp } as any) as QueryBuilderLabelFilter); |
||||
} |
||||
}} |
||||
/> |
||||
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} /> |
||||
</InputGroup> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const operators = [ |
||||
{ label: '=~', value: '=~' }, |
||||
{ label: '=', value: '=' }, |
||||
{ label: '!=', value: '!=' }, |
||||
]; |
@ -0,0 +1,65 @@ |
||||
import React from 'react'; |
||||
import { render, screen } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import { LabelFilters } from './LabelFilters'; |
||||
import { QueryBuilderLabelFilter } from './types'; |
||||
import { getLabelSelects } from '../testUtils'; |
||||
import { selectOptionInTest } from '../../../../../../../packages/grafana-ui'; |
||||
|
||||
describe('LabelFilters', () => { |
||||
it('renders empty input without labels', async () => { |
||||
setup(); |
||||
expect(screen.getAllByText(/Choose/)).toHaveLength(2); |
||||
expect(screen.getByText(/=/)).toBeInTheDocument(); |
||||
expect(getAddButton()).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('renders multiple labels', async () => { |
||||
setup([ |
||||
{ label: 'foo', op: '=', value: 'bar' }, |
||||
{ label: 'baz', op: '!=', value: 'qux' }, |
||||
{ label: 'quux', op: '=~', value: 'quuz' }, |
||||
]); |
||||
expect(screen.getByText(/foo/)).toBeInTheDocument(); |
||||
expect(screen.getByText(/bar/)).toBeInTheDocument(); |
||||
expect(screen.getByText(/baz/)).toBeInTheDocument(); |
||||
expect(screen.getByText(/qux/)).toBeInTheDocument(); |
||||
expect(screen.getByText(/quux/)).toBeInTheDocument(); |
||||
expect(screen.getByText(/quuz/)).toBeInTheDocument(); |
||||
expect(getAddButton()).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('adds new label', async () => { |
||||
const { onChange } = setup([{ label: 'foo', op: '=', value: 'bar' }]); |
||||
userEvent.click(getAddButton()); |
||||
expect(screen.getAllByText(/Choose/)).toHaveLength(2); |
||||
const { name, value } = getLabelSelects(1); |
||||
await selectOptionInTest(name, 'baz'); |
||||
await selectOptionInTest(value, 'qux'); |
||||
expect(onChange).toBeCalledWith([ |
||||
{ label: 'foo', op: '=', value: 'bar' }, |
||||
{ label: 'baz', op: '=', value: 'qux' }, |
||||
]); |
||||
}); |
||||
|
||||
it('removes label', async () => { |
||||
const { onChange } = setup([{ label: 'foo', op: '=', value: 'bar' }]); |
||||
userEvent.click(screen.getByLabelText(/remove/)); |
||||
expect(onChange).toBeCalledWith([]); |
||||
}); |
||||
}); |
||||
|
||||
function setup(labels: QueryBuilderLabelFilter[] = []) { |
||||
const props = { |
||||
onChange: jest.fn(), |
||||
onGetLabelNames: async () => ['foo', 'bar', 'baz'], |
||||
onGetLabelValues: async () => ['bar', 'qux', 'quux'], |
||||
}; |
||||
|
||||
render(<LabelFilters {...props} labelsFilters={labels} />); |
||||
return props; |
||||
} |
||||
|
||||
function getAddButton() { |
||||
return screen.getByLabelText(/Add/); |
||||
} |
@ -0,0 +1,50 @@ |
||||
import { EditorField, EditorFieldGroup, EditorList } from '@grafana/experimental'; |
||||
import { isEqual } from 'lodash'; |
||||
import React, { useState } from 'react'; |
||||
import { QueryBuilderLabelFilter } from '../shared/types'; |
||||
import { LabelFilterItem } from './LabelFilterItem'; |
||||
|
||||
export interface Props { |
||||
labelsFilters: QueryBuilderLabelFilter[]; |
||||
onChange: (labelFilters: QueryBuilderLabelFilter[]) => void; |
||||
onGetLabelNames: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<string[]>; |
||||
onGetLabelValues: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<string[]>; |
||||
} |
||||
|
||||
export function LabelFilters({ labelsFilters, onChange, onGetLabelNames, onGetLabelValues }: Props) { |
||||
const defaultOp = '='; |
||||
const [items, setItems] = useState<Array<Partial<QueryBuilderLabelFilter>>>( |
||||
labelsFilters.length === 0 ? [{ op: defaultOp }] : labelsFilters |
||||
); |
||||
|
||||
const onLabelsChange = (newItems: Array<Partial<QueryBuilderLabelFilter>>) => { |
||||
setItems(newItems); |
||||
|
||||
// Extract full label filters with both label & value
|
||||
const newLabels = newItems.filter((x) => x.label != null && x.value != null); |
||||
if (!isEqual(newLabels, labelsFilters)) { |
||||
onChange(newLabels as QueryBuilderLabelFilter[]); |
||||
} |
||||
}; |
||||
|
||||
return ( |
||||
<EditorFieldGroup> |
||||
<EditorField label="Labels"> |
||||
<EditorList |
||||
items={items} |
||||
onChange={onLabelsChange} |
||||
renderItem={(item, onChangeItem, onDelete) => ( |
||||
<LabelFilterItem |
||||
item={item} |
||||
defaultOp={defaultOp} |
||||
onChange={onChangeItem} |
||||
onDelete={onDelete} |
||||
onGetLabelNames={onGetLabelNames} |
||||
onGetLabelValues={onGetLabelValues} |
||||
/> |
||||
)} |
||||
/> |
||||
</EditorField> |
||||
</EditorFieldGroup> |
||||
); |
||||
} |
@ -0,0 +1,88 @@ |
||||
import { Registry } from '@grafana/data'; |
||||
import { |
||||
QueryBuilderLabelFilter, |
||||
QueryBuilderOperation, |
||||
QueryBuilderOperationDef, |
||||
QueryWithOperations, |
||||
VisualQueryModeller, |
||||
} from './types'; |
||||
|
||||
export interface VisualQueryBinary<T> { |
||||
operator: string; |
||||
vectorMatches?: string; |
||||
query: T; |
||||
} |
||||
|
||||
export abstract class LokiAndPromQueryModellerBase<T extends QueryWithOperations> implements VisualQueryModeller { |
||||
protected operationsRegisty: Registry<QueryBuilderOperationDef>; |
||||
private categories: string[] = []; |
||||
|
||||
constructor(getOperations: () => QueryBuilderOperationDef[]) { |
||||
this.operationsRegisty = new Registry<QueryBuilderOperationDef>(getOperations); |
||||
} |
||||
|
||||
protected setOperationCategories(categories: string[]) { |
||||
this.categories = categories; |
||||
} |
||||
|
||||
getOperationsForCategory(category: string) { |
||||
return this.operationsRegisty.list().filter((op) => op.category === category && !op.hideFromList); |
||||
} |
||||
|
||||
getAlternativeOperations(key: string) { |
||||
return this.operationsRegisty.list().filter((op) => op.alternativesKey === key); |
||||
} |
||||
|
||||
getCategories() { |
||||
return this.categories; |
||||
} |
||||
|
||||
getOperationDef(id: string) { |
||||
return this.operationsRegisty.get(id); |
||||
} |
||||
|
||||
renderOperations(queryString: string, operations: QueryBuilderOperation[]) { |
||||
for (const operation of operations) { |
||||
const def = this.operationsRegisty.get(operation.id); |
||||
queryString = def.renderer(operation, def, queryString); |
||||
} |
||||
|
||||
return queryString; |
||||
} |
||||
|
||||
renderBinaryQueries(queryString: string, binaryQueries?: Array<VisualQueryBinary<T>>) { |
||||
if (binaryQueries) { |
||||
for (const binQuery of binaryQueries) { |
||||
queryString = `${this.renderBinaryQuery(queryString, binQuery)}`; |
||||
} |
||||
} |
||||
return queryString; |
||||
} |
||||
|
||||
private renderBinaryQuery(leftOperand: string, binaryQuery: VisualQueryBinary<T>) { |
||||
let result = leftOperand + ` ${binaryQuery.operator} `; |
||||
if (binaryQuery.vectorMatches) { |
||||
result += `${binaryQuery.vectorMatches} `; |
||||
} |
||||
return result + `${this.renderQuery(binaryQuery.query)}`; |
||||
} |
||||
|
||||
renderLabels(labels: QueryBuilderLabelFilter[]) { |
||||
if (labels.length === 0) { |
||||
return ''; |
||||
} |
||||
|
||||
let expr = '{'; |
||||
for (const filter of labels) { |
||||
if (expr !== '{') { |
||||
expr += ', '; |
||||
} |
||||
|
||||
expr += `${filter.label}${filter.op}"${filter.value}"`; |
||||
} |
||||
|
||||
return expr + `}`; |
||||
} |
||||
|
||||
abstract renderQuery(query: T): string; |
||||
} |
@ -0,0 +1,260 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { DataSourceApi, GrafanaTheme2 } from '@grafana/data'; |
||||
import { FlexItem, Stack } from '@grafana/experimental'; |
||||
import { Button, useStyles2 } from '@grafana/ui'; |
||||
import React from 'react'; |
||||
import { Draggable } from 'react-beautiful-dnd'; |
||||
import { |
||||
VisualQueryModeller, |
||||
QueryBuilderOperation, |
||||
QueryBuilderOperationParamValue, |
||||
QueryBuilderOperationDef, |
||||
QueryBuilderOperationParamDef, |
||||
} from '../shared/types'; |
||||
import { OperationInfoButton } from './OperationInfoButton'; |
||||
import { OperationName } from './OperationName'; |
||||
import { getOperationParamEditor } from './OperationParamEditor'; |
||||
|
||||
export interface Props { |
||||
operation: QueryBuilderOperation; |
||||
index: number; |
||||
query: any; |
||||
datasource: DataSourceApi; |
||||
queryModeller: VisualQueryModeller; |
||||
onChange: (index: number, update: QueryBuilderOperation) => void; |
||||
onRemove: (index: number) => void; |
||||
onRunQuery: () => void; |
||||
} |
||||
|
||||
export function OperationEditor({ |
||||
operation, |
||||
index, |
||||
onRemove, |
||||
onChange, |
||||
onRunQuery, |
||||
queryModeller, |
||||
query, |
||||
datasource, |
||||
}: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
const def = queryModeller.getOperationDef(operation.id); |
||||
|
||||
const onParamValueChanged = (paramIdx: number, value: QueryBuilderOperationParamValue) => { |
||||
const update: QueryBuilderOperation = { ...operation, params: [...operation.params] }; |
||||
update.params[paramIdx] = value; |
||||
callParamChangedThenOnChange(def, update, index, paramIdx, onChange); |
||||
}; |
||||
|
||||
const onAddRestParam = () => { |
||||
const update: QueryBuilderOperation = { ...operation, params: [...operation.params, ''] }; |
||||
callParamChangedThenOnChange(def, update, index, operation.params.length, onChange); |
||||
}; |
||||
|
||||
const onRemoveRestParam = (paramIdx: number) => { |
||||
const update: QueryBuilderOperation = { |
||||
...operation, |
||||
params: [...operation.params.slice(0, paramIdx), ...operation.params.slice(paramIdx + 1)], |
||||
}; |
||||
callParamChangedThenOnChange(def, update, index, paramIdx, onChange); |
||||
}; |
||||
|
||||
const operationElements: React.ReactNode[] = []; |
||||
|
||||
for (let paramIndex = 0; paramIndex < operation.params.length; paramIndex++) { |
||||
const paramDef = def.params[Math.min(def.params.length - 1, paramIndex)]; |
||||
const Editor = getOperationParamEditor(paramDef); |
||||
|
||||
operationElements.push( |
||||
<div className={styles.paramRow} key={`${paramIndex}-1`}> |
||||
<div className={styles.paramName}>{paramDef.name}</div> |
||||
<div className={styles.paramValue}> |
||||
<Stack gap={0.5} direction="row" alignItems="center" wrap={false}> |
||||
<Editor |
||||
index={paramIndex} |
||||
paramDef={paramDef} |
||||
value={operation.params[paramIndex]} |
||||
operation={operation} |
||||
onChange={onParamValueChanged} |
||||
onRunQuery={onRunQuery} |
||||
query={query} |
||||
datasource={datasource} |
||||
/> |
||||
{paramDef.restParam && (operation.params.length > def.params.length || paramDef.optional) && ( |
||||
<Button |
||||
size="sm" |
||||
fill="text" |
||||
icon="times" |
||||
variant="secondary" |
||||
title={`Remove ${paramDef.name}`} |
||||
onClick={() => onRemoveRestParam(paramIndex)} |
||||
/> |
||||
)} |
||||
</Stack> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
// Handle adding button for rest params
|
||||
let restParam: React.ReactNode | undefined; |
||||
if (def.params.length > 0) { |
||||
const lastParamDef = def.params[def.params.length - 1]; |
||||
if (lastParamDef.restParam) { |
||||
restParam = renderAddRestParamButton(lastParamDef, onAddRestParam, operation.params.length, styles); |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<Draggable draggableId={`operation-${index}`} index={index}> |
||||
{(provided) => ( |
||||
<div |
||||
className={styles.card} |
||||
ref={provided.innerRef} |
||||
{...provided.draggableProps} |
||||
data-testid={`operation-wrapper-for-${operation.id}`} |
||||
> |
||||
<div className={styles.header} {...provided.dragHandleProps}> |
||||
<OperationName |
||||
operation={operation} |
||||
def={def} |
||||
index={index} |
||||
onChange={onChange} |
||||
queryModeller={queryModeller} |
||||
/> |
||||
<FlexItem grow={1} /> |
||||
<div className={`${styles.operationHeaderButtons} operation-header-show-on-hover`}> |
||||
<OperationInfoButton def={def} operation={operation} /> |
||||
<Button |
||||
icon="times" |
||||
size="sm" |
||||
onClick={() => onRemove(index)} |
||||
fill="text" |
||||
variant="secondary" |
||||
title="Remove operation" |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div className={styles.body}>{operationElements}</div> |
||||
{restParam} |
||||
{index < query.operations.length - 1 && ( |
||||
<div className={styles.arrow}> |
||||
<div className={styles.arrowLine} /> |
||||
<div className={styles.arrowArrow} /> |
||||
</div> |
||||
)} |
||||
</div> |
||||
)} |
||||
</Draggable> |
||||
); |
||||
} |
||||
|
||||
function renderAddRestParamButton( |
||||
paramDef: QueryBuilderOperationParamDef, |
||||
onAddRestParam: () => void, |
||||
paramIndex: number, |
||||
styles: OperationEditorStyles |
||||
) { |
||||
return ( |
||||
<div className={styles.restParam} key={`${paramIndex}-2`}> |
||||
<Button size="sm" icon="plus" title={`Add ${paramDef.name}`} variant="secondary" onClick={onAddRestParam}> |
||||
{paramDef.name} |
||||
</Button> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function callParamChangedThenOnChange( |
||||
def: QueryBuilderOperationDef, |
||||
operation: QueryBuilderOperation, |
||||
operationIndex: number, |
||||
paramIndex: number, |
||||
onChange: (index: number, update: QueryBuilderOperation) => void |
||||
) { |
||||
if (def.paramChangedHandler) { |
||||
onChange(operationIndex, def.paramChangedHandler(paramIndex, operation, def)); |
||||
} else { |
||||
onChange(operationIndex, operation); |
||||
} |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
card: css({ |
||||
background: theme.colors.background.primary, |
||||
border: `1px solid ${theme.colors.border.medium}`, |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
cursor: 'grab', |
||||
borderRadius: theme.shape.borderRadius(1), |
||||
marginBottom: theme.spacing(1), |
||||
position: 'relative', |
||||
}), |
||||
header: css({ |
||||
borderBottom: `1px solid ${theme.colors.border.medium}`, |
||||
padding: theme.spacing(0.5, 0.5, 0.5, 1), |
||||
gap: theme.spacing(1), |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
'&:hover .operation-header-show-on-hover': css({ |
||||
opacity: 1, |
||||
}), |
||||
}), |
||||
infoIcon: css({ |
||||
color: theme.colors.text.secondary, |
||||
}), |
||||
body: css({ |
||||
margin: theme.spacing(1, 1, 0.5, 1), |
||||
display: 'table', |
||||
}), |
||||
paramRow: css({ |
||||
display: 'table-row', |
||||
verticalAlign: 'middle', |
||||
}), |
||||
paramName: css({ |
||||
display: 'table-cell', |
||||
padding: theme.spacing(0, 1, 0, 0), |
||||
fontSize: theme.typography.bodySmall.fontSize, |
||||
fontWeight: theme.typography.fontWeightMedium, |
||||
verticalAlign: 'middle', |
||||
height: '32px', |
||||
}), |
||||
operationHeaderButtons: css({ |
||||
opacity: 0, |
||||
transition: theme.transitions.create(['opacity'], { |
||||
duration: theme.transitions.duration.short, |
||||
}), |
||||
}), |
||||
paramValue: css({ |
||||
display: 'table-cell', |
||||
paddingBottom: theme.spacing(0.5), |
||||
verticalAlign: 'middle', |
||||
}), |
||||
restParam: css({ |
||||
padding: theme.spacing(0, 1, 1, 1), |
||||
}), |
||||
arrow: css({ |
||||
position: 'absolute', |
||||
top: '0', |
||||
right: '-18px', |
||||
display: 'flex', |
||||
}), |
||||
arrowLine: css({ |
||||
height: '2px', |
||||
width: '8px', |
||||
backgroundColor: theme.colors.border.strong, |
||||
position: 'relative', |
||||
top: '14px', |
||||
}), |
||||
arrowArrow: css({ |
||||
width: 0, |
||||
height: 0, |
||||
borderTop: `5px solid transparent`, |
||||
borderBottom: `5px solid transparent`, |
||||
borderLeft: `7px solid ${theme.colors.border.strong}`, |
||||
position: 'relative', |
||||
top: '10px', |
||||
}), |
||||
}; |
||||
}; |
||||
|
||||
type OperationEditorStyles = ReturnType<typeof getStyles>; |
@ -0,0 +1,75 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { GrafanaTheme2, renderMarkdown } from '@grafana/data'; |
||||
import { useStyles2 } from '@grafana/ui'; |
||||
import React from 'react'; |
||||
|
||||
export interface Props { |
||||
title: string; |
||||
children?: React.ReactNode; |
||||
markdown?: string; |
||||
stepNumber: number; |
||||
} |
||||
|
||||
export function OperationExplainedBox({ title, stepNumber, markdown, children }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
return ( |
||||
<div className={styles.box}> |
||||
<div className={styles.stepNumber}>{stepNumber}</div> |
||||
<div className={styles.boxInner}> |
||||
<div className={styles.header}> |
||||
<span>{title}</span> |
||||
</div> |
||||
<div className={styles.body}> |
||||
{markdown && <div dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown) }}></div>} |
||||
{children} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
box: css({ |
||||
background: theme.colors.background.secondary, |
||||
padding: theme.spacing(1), |
||||
borderRadius: theme.shape.borderRadius(), |
||||
position: 'relative', |
||||
marginBottom: theme.spacing(0.5), |
||||
}), |
||||
boxInner: css({ |
||||
marginLeft: theme.spacing(4), |
||||
}), |
||||
stepNumber: css({ |
||||
fontWeight: theme.typography.fontWeightMedium, |
||||
background: theme.colors.secondary.main, |
||||
width: '20px', |
||||
height: '20px', |
||||
borderRadius: '50%', |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
justifyContent: 'center', |
||||
position: 'absolute', |
||||
top: '10px', |
||||
left: '11px', |
||||
fontSize: theme.typography.bodySmall.fontSize, |
||||
}), |
||||
header: css({ |
||||
paddingBottom: theme.spacing(0.5), |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
fontFamily: theme.typography.fontFamilyMonospace, |
||||
}), |
||||
body: css({ |
||||
color: theme.colors.text.secondary, |
||||
'p:last-child': { |
||||
margin: 0, |
||||
}, |
||||
a: { |
||||
color: theme.colors.text.link, |
||||
textDecoration: 'underline', |
||||
}, |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,102 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { GrafanaTheme2, renderMarkdown } from '@grafana/data'; |
||||
import { FlexItem } from '@grafana/experimental'; |
||||
import { Button, Portal, useStyles2 } from '@grafana/ui'; |
||||
import React, { useState } from 'react'; |
||||
import { usePopper } from 'react-popper'; |
||||
import { useToggle } from 'react-use'; |
||||
import { QueryBuilderOperation, QueryBuilderOperationDef } from './types'; |
||||
|
||||
export interface Props { |
||||
operation: QueryBuilderOperation; |
||||
def: QueryBuilderOperationDef; |
||||
} |
||||
|
||||
export const OperationInfoButton = React.memo<Props>(({ def, operation }) => { |
||||
const styles = useStyles2(getStyles); |
||||
const [popperTrigger, setPopperTrigger] = useState<HTMLButtonElement | null>(null); |
||||
const [popover, setPopover] = useState<HTMLDivElement | null>(null); |
||||
const [isOpen, toggleIsOpen] = useToggle(false); |
||||
|
||||
const popper = usePopper(popperTrigger, popover, { |
||||
placement: 'top', |
||||
modifiers: [ |
||||
{ name: 'arrow', enabled: true }, |
||||
{ |
||||
name: 'preventOverflow', |
||||
enabled: true, |
||||
options: { |
||||
rootBoundary: 'viewport', |
||||
}, |
||||
}, |
||||
], |
||||
}); |
||||
|
||||
return ( |
||||
<> |
||||
<Button |
||||
ref={setPopperTrigger} |
||||
icon="info-circle" |
||||
size="sm" |
||||
variant="secondary" |
||||
fill="text" |
||||
onClick={toggleIsOpen} |
||||
/> |
||||
{isOpen && ( |
||||
<Portal> |
||||
<div ref={setPopover} style={popper.styles.popper} {...popper.attributes.popper} className={styles.docBox}> |
||||
<div className={styles.docBoxHeader}> |
||||
<span>{def.renderer(operation, def, '<expr>')}</span> |
||||
<FlexItem grow={1} /> |
||||
<Button icon="times" onClick={toggleIsOpen} fill="text" variant="secondary" title="Remove operation" /> |
||||
</div> |
||||
<div |
||||
className={styles.docBoxBody} |
||||
dangerouslySetInnerHTML={{ __html: getOperationDocs(def, operation) }} |
||||
></div> |
||||
</div> |
||||
</Portal> |
||||
)} |
||||
</> |
||||
); |
||||
}); |
||||
|
||||
OperationInfoButton.displayName = 'OperationDocs'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
docBox: css({ |
||||
overflow: 'hidden', |
||||
background: theme.colors.background.canvas, |
||||
border: `1px solid ${theme.colors.border.strong}`, |
||||
boxShadow: theme.shadows.z2, |
||||
maxWidth: '600px', |
||||
padding: theme.spacing(1), |
||||
borderRadius: theme.shape.borderRadius(), |
||||
zIndex: theme.zIndex.tooltip, |
||||
}), |
||||
docBoxHeader: css({ |
||||
fontSize: theme.typography.h5.fontSize, |
||||
fontFamily: theme.typography.fontFamilyMonospace, |
||||
paddingBottom: theme.spacing(1), |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
}), |
||||
docBoxBody: css({ |
||||
// The markdown paragraph has a marginBottom this removes it
|
||||
marginBottom: theme.spacing(-1), |
||||
color: theme.colors.text.secondary, |
||||
}), |
||||
signature: css({ |
||||
fontSize: theme.typography.bodySmall.fontSize, |
||||
fontFamily: theme.typography.fontFamilyMonospace, |
||||
}), |
||||
dropdown: css({ |
||||
opacity: 0, |
||||
color: theme.colors.text.secondary, |
||||
}), |
||||
}; |
||||
}; |
||||
function getOperationDocs(def: QueryBuilderOperationDef, op: QueryBuilderOperation): string { |
||||
return renderMarkdown(def.explainHandler ? def.explainHandler(op, def) : def.documentation ?? 'no docs'); |
||||
} |
@ -0,0 +1,95 @@ |
||||
import React from 'react'; |
||||
import { render, screen, fireEvent } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import { OperationList } from './OperationList'; |
||||
import { promQueryModeller } from '../PromQueryModeller'; |
||||
import { EmptyLanguageProviderMock } from '../../language_provider.mock'; |
||||
import PromQlLanguageProvider from '../../language_provider'; |
||||
import { PromVisualQuery } from '../types'; |
||||
import { PrometheusDatasource } from '../../datasource'; |
||||
import { DataSourceApi } from '@grafana/data'; |
||||
|
||||
const defaultQuery: PromVisualQuery = { |
||||
metric: 'random_metric', |
||||
labels: [{ label: 'instance', op: '=', value: 'localhost:9090' }], |
||||
operations: [ |
||||
{ |
||||
id: 'rate', |
||||
params: ['auto'], |
||||
}, |
||||
{ |
||||
id: '__sum_by', |
||||
params: ['instance', 'job'], |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
describe('OperationList', () => { |
||||
it('renders operations', async () => { |
||||
setup(); |
||||
expect(screen.getByText('Rate')).toBeInTheDocument(); |
||||
expect(screen.getByText('Sum by')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('removes an operation', async () => { |
||||
const { onChange } = setup(); |
||||
const removeOperationButtons = screen.getAllByTitle('Remove operation'); |
||||
expect(removeOperationButtons).toHaveLength(2); |
||||
userEvent.click(removeOperationButtons[1]); |
||||
expect(onChange).toBeCalledWith({ |
||||
labels: [{ label: 'instance', op: '=', value: 'localhost:9090' }], |
||||
metric: 'random_metric', |
||||
operations: [{ id: 'rate', params: ['auto'] }], |
||||
}); |
||||
}); |
||||
|
||||
it('adds an operation', async () => { |
||||
const { onChange } = setup(); |
||||
addOperation('Aggregations', 'Min'); |
||||
expect(onChange).toBeCalledWith({ |
||||
labels: [{ label: 'instance', op: '=', value: 'localhost:9090' }], |
||||
metric: 'random_metric', |
||||
operations: [ |
||||
{ id: 'rate', params: ['auto'] }, |
||||
{ id: '__sum_by', params: ['instance', 'job'] }, |
||||
{ id: 'min', params: [] }, |
||||
], |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
function setup(query: PromVisualQuery = defaultQuery) { |
||||
const languageProvider = (new EmptyLanguageProviderMock() as unknown) as PromQlLanguageProvider; |
||||
const props = { |
||||
datasource: new PrometheusDatasource( |
||||
{ |
||||
url: '', |
||||
jsonData: {}, |
||||
meta: {} as any, |
||||
} as any, |
||||
undefined, |
||||
undefined, |
||||
languageProvider |
||||
) as DataSourceApi, |
||||
onRunQuery: () => {}, |
||||
onChange: jest.fn(), |
||||
queryModeller: promQueryModeller, |
||||
}; |
||||
|
||||
render(<OperationList {...props} query={query} />); |
||||
return props; |
||||
} |
||||
|
||||
function addOperation(section: string, op: string) { |
||||
const addOperationButton = screen.getByTitle('Add operation'); |
||||
expect(addOperationButton).toBeInTheDocument(); |
||||
userEvent.click(addOperationButton); |
||||
const sectionItem = screen.getByTitle(section); |
||||
expect(sectionItem).toBeInTheDocument(); |
||||
// Weirdly the userEvent.click doesn't work here, it reports the item has pointer-events: none. Don't see that
|
||||
// anywhere when debugging so not sure what style is it picking up.
|
||||
fireEvent.click(sectionItem.children[0]); |
||||
const opItem = screen.getByTitle(op); |
||||
expect(opItem).toBeInTheDocument(); |
||||
fireEvent.click(opItem); |
||||
} |
@ -0,0 +1,128 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { DataSourceApi, GrafanaTheme2 } from '@grafana/data'; |
||||
import { Stack } from '@grafana/experimental'; |
||||
import { ButtonCascader, CascaderOption, useStyles2 } from '@grafana/ui'; |
||||
import React from 'react'; |
||||
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd'; |
||||
import { QueryBuilderOperation, QueryWithOperations, VisualQueryModeller } from '../shared/types'; |
||||
import { OperationEditor } from './OperationEditor'; |
||||
|
||||
export interface Props<T extends QueryWithOperations> { |
||||
query: T; |
||||
datasource: DataSourceApi; |
||||
onChange: (query: T) => void; |
||||
onRunQuery: () => void; |
||||
queryModeller: VisualQueryModeller; |
||||
explainMode?: boolean; |
||||
} |
||||
|
||||
export function OperationList<T extends QueryWithOperations>({ |
||||
query, |
||||
datasource, |
||||
queryModeller, |
||||
onChange, |
||||
onRunQuery, |
||||
}: Props<T>) { |
||||
const styles = useStyles2(getStyles); |
||||
const { operations } = query; |
||||
|
||||
const onOperationChange = (index: number, update: QueryBuilderOperation) => { |
||||
const updatedList = [...operations]; |
||||
updatedList.splice(index, 1, update); |
||||
onChange({ ...query, operations: updatedList }); |
||||
}; |
||||
|
||||
const onRemove = (index: number) => { |
||||
const updatedList = [...operations.slice(0, index), ...operations.slice(index + 1)]; |
||||
onChange({ ...query, operations: updatedList }); |
||||
}; |
||||
|
||||
const addOptions: CascaderOption[] = queryModeller.getCategories().map((category) => { |
||||
return { |
||||
value: category, |
||||
label: category, |
||||
children: queryModeller.getOperationsForCategory(category).map((operation) => ({ |
||||
value: operation.id, |
||||
label: operation.name, |
||||
isLeaf: true, |
||||
})), |
||||
}; |
||||
}); |
||||
|
||||
const onAddOperation = (value: string[]) => { |
||||
const operationDef = queryModeller.getOperationDef(value[1]); |
||||
onChange(operationDef.addOperationHandler(operationDef, query, queryModeller)); |
||||
}; |
||||
|
||||
const onDragEnd = (result: DropResult) => { |
||||
if (!result.destination) { |
||||
return; |
||||
} |
||||
|
||||
const updatedList = [...operations]; |
||||
const element = updatedList[result.source.index]; |
||||
updatedList.splice(result.source.index, 1); |
||||
updatedList.splice(result.destination.index, 0, element); |
||||
onChange({ ...query, operations: updatedList }); |
||||
}; |
||||
|
||||
return ( |
||||
<Stack gap={1} direction="column"> |
||||
<Stack gap={1}> |
||||
{operations.length > 0 && ( |
||||
<DragDropContext onDragEnd={onDragEnd}> |
||||
<Droppable droppableId="sortable-field-mappings" direction="horizontal"> |
||||
{(provided) => ( |
||||
<div className={styles.operationList} ref={provided.innerRef} {...provided.droppableProps}> |
||||
{operations.map((op, index) => ( |
||||
<OperationEditor |
||||
key={index} |
||||
queryModeller={queryModeller} |
||||
index={index} |
||||
operation={op} |
||||
query={query} |
||||
datasource={datasource} |
||||
onChange={onOperationChange} |
||||
onRemove={onRemove} |
||||
onRunQuery={onRunQuery} |
||||
/> |
||||
))} |
||||
{provided.placeholder} |
||||
</div> |
||||
)} |
||||
</Droppable> |
||||
</DragDropContext> |
||||
)} |
||||
<div className={styles.addButton}> |
||||
<ButtonCascader |
||||
key="cascader" |
||||
icon="plus" |
||||
options={addOptions} |
||||
onChange={onAddOperation} |
||||
variant="secondary" |
||||
hideDownIcon={true} |
||||
buttonProps={{ 'aria-label': 'Add operation', title: 'Add operation' }} |
||||
/> |
||||
</div> |
||||
</Stack> |
||||
</Stack> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
heading: css({ |
||||
fontSize: 12, |
||||
fontWeight: theme.typography.fontWeightMedium, |
||||
marginBottom: 0, |
||||
}), |
||||
operationList: css({ |
||||
display: 'flex', |
||||
flexWrap: 'wrap', |
||||
gap: theme.spacing(2), |
||||
}), |
||||
addButton: css({ |
||||
paddingBottom: theme.spacing(1), |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,24 @@ |
||||
import React from 'react'; |
||||
import { OperationExplainedBox } from './OperationExplainedBox'; |
||||
import { QueryWithOperations, VisualQueryModeller } from './types'; |
||||
|
||||
export interface Props<T extends QueryWithOperations> { |
||||
query: T; |
||||
queryModeller: VisualQueryModeller; |
||||
explainMode?: boolean; |
||||
stepNumber: number; |
||||
} |
||||
|
||||
export function OperationListExplained<T extends QueryWithOperations>({ query, queryModeller, stepNumber }: Props<T>) { |
||||
return ( |
||||
<> |
||||
{query.operations.map((op, index) => { |
||||
const def = queryModeller.getOperationDef(op.id); |
||||
const title = def.renderer(op, def, '<expr>'); |
||||
const body = def.explainHandler ? def.explainHandler(op, def) : def.documentation ?? 'no docs'; |
||||
|
||||
return <OperationExplainedBox stepNumber={index + stepNumber} key={index} title={title} markdown={body} />; |
||||
})} |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,92 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; |
||||
import { Icon, Select, useStyles2 } from '@grafana/ui'; |
||||
import React, { useState } from 'react'; |
||||
import { VisualQueryModeller, QueryBuilderOperation, QueryBuilderOperationDef } from './types'; |
||||
|
||||
export interface Props { |
||||
operation: QueryBuilderOperation; |
||||
def: QueryBuilderOperationDef; |
||||
index: number; |
||||
queryModeller: VisualQueryModeller; |
||||
onChange: (index: number, update: QueryBuilderOperation) => void; |
||||
} |
||||
|
||||
interface State { |
||||
isOpen?: boolean; |
||||
alternatives?: Array<SelectableValue<QueryBuilderOperationDef>>; |
||||
} |
||||
|
||||
export const OperationName = React.memo<Props>(({ operation, def, index, onChange, queryModeller }) => { |
||||
const styles = useStyles2(getStyles); |
||||
const [state, setState] = useState<State>({}); |
||||
|
||||
const onToggleSwitcher = () => { |
||||
if (state.isOpen) { |
||||
setState({ ...state, isOpen: false }); |
||||
} else { |
||||
const alternatives = queryModeller |
||||
.getAlternativeOperations(def.alternativesKey!) |
||||
.map((alt) => ({ label: alt.name, value: alt })); |
||||
setState({ isOpen: true, alternatives }); |
||||
} |
||||
}; |
||||
|
||||
const nameElement = <span>{def.name ?? def.id}</span>; |
||||
|
||||
if (!def.alternativesKey) { |
||||
return nameElement; |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
{!state.isOpen && ( |
||||
<button |
||||
className={styles.wrapper} |
||||
onClick={onToggleSwitcher} |
||||
title={'Click to replace with alternative function'} |
||||
> |
||||
{nameElement} |
||||
<Icon className={`${styles.dropdown} operation-header-show-on-hover`} name="arrow-down" size="sm" /> |
||||
</button> |
||||
)} |
||||
{state.isOpen && ( |
||||
<Select |
||||
autoFocus |
||||
openMenuOnFocus |
||||
placeholder="Replace with" |
||||
options={state.alternatives} |
||||
isOpen={true} |
||||
onCloseMenu={onToggleSwitcher} |
||||
onChange={(value) => { |
||||
if (value.value) { |
||||
onChange(index, { |
||||
...operation, |
||||
id: value.value.id, |
||||
}); |
||||
} |
||||
}} |
||||
/> |
||||
)} |
||||
</> |
||||
); |
||||
}); |
||||
|
||||
OperationName.displayName = 'OperationName'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
wrapper: css({ |
||||
display: 'inline-block', |
||||
background: 'transparent', |
||||
padding: 0, |
||||
border: 'none', |
||||
boxShadow: 'none', |
||||
cursor: 'pointer', |
||||
}), |
||||
dropdown: css({ |
||||
opacity: 0, |
||||
color: theme.colors.text.secondary, |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,53 @@ |
||||
import { toOption } from '@grafana/data'; |
||||
import { Input, Select } from '@grafana/ui'; |
||||
import React, { ComponentType } from 'react'; |
||||
import { QueryBuilderOperationParamDef, QueryBuilderOperationParamEditorProps } from '../shared/types'; |
||||
|
||||
export function getOperationParamEditor( |
||||
paramDef: QueryBuilderOperationParamDef |
||||
): ComponentType<QueryBuilderOperationParamEditorProps> { |
||||
if (paramDef.editor) { |
||||
return paramDef.editor; |
||||
} |
||||
|
||||
if (paramDef.options) { |
||||
return SelectInputParamEditor; |
||||
} |
||||
|
||||
return SimpleInputParamEditor; |
||||
} |
||||
|
||||
function SimpleInputParamEditor(props: QueryBuilderOperationParamEditorProps) { |
||||
return ( |
||||
<Input |
||||
defaultValue={props.value ?? ''} |
||||
onKeyDown={(evt) => { |
||||
if (evt.key === 'Enter') { |
||||
if (evt.currentTarget.value !== props.value) { |
||||
props.onChange(props.index, evt.currentTarget.value); |
||||
} |
||||
props.onRunQuery(); |
||||
} |
||||
}} |
||||
onBlur={(evt) => { |
||||
props.onChange(props.index, evt.currentTarget.value); |
||||
}} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
function SelectInputParamEditor({ paramDef, value, index, onChange }: QueryBuilderOperationParamEditorProps) { |
||||
const selectOptions = paramDef.options!.map((option) => ({ |
||||
label: option as string, |
||||
value: option as string, |
||||
})); |
||||
|
||||
return ( |
||||
<Select |
||||
menuShouldPortal |
||||
value={toOption(value as string)} |
||||
options={selectOptions} |
||||
onChange={(value) => onChange(index, value.value!)} |
||||
/> |
||||
); |
||||
} |
@ -0,0 +1,29 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Stack } from '@grafana/experimental'; |
||||
import { useStyles2 } from '@grafana/ui'; |
||||
import React from 'react'; |
||||
|
||||
interface Props { |
||||
children: React.ReactNode; |
||||
} |
||||
|
||||
export function OperationsEditorRow({ children }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
return ( |
||||
<div className={styles.root}> |
||||
<Stack gap={1}>{children}</Stack> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
root: css({ |
||||
padding: theme.spacing(1, 1, 0, 1), |
||||
backgroundColor: theme.colors.background.secondary, |
||||
borderRadius: theme.shape.borderRadius(1), |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,18 @@ |
||||
import { RadioButtonGroup } from '@grafana/ui'; |
||||
import React from 'react'; |
||||
import { QueryEditorMode } from './types'; |
||||
|
||||
export interface Props { |
||||
mode: QueryEditorMode; |
||||
onChange: (mode: QueryEditorMode) => void; |
||||
} |
||||
|
||||
const editorModes = [ |
||||
{ label: 'Explain', value: QueryEditorMode.Explain }, |
||||
{ label: 'Builder', value: QueryEditorMode.Builder }, |
||||
{ label: 'Code', value: QueryEditorMode.Code }, |
||||
]; |
||||
|
||||
export function QueryEditorModeToggle({ mode, onChange }: Props) { |
||||
return <RadioButtonGroup options={editorModes} size="sm" value={mode} onChange={onChange} />; |
||||
} |
@ -0,0 +1,51 @@ |
||||
import { capitalize } from 'lodash'; |
||||
import { QueryBuilderOperation, QueryBuilderOperationDef, QueryWithOperations } from './types'; |
||||
|
||||
export function functionRendererLeft(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { |
||||
const params = renderParams(model, def, innerExpr); |
||||
const str = model.id + '('; |
||||
|
||||
if (innerExpr) { |
||||
params.push(innerExpr); |
||||
} |
||||
|
||||
return str + params.join(', ') + ')'; |
||||
} |
||||
|
||||
export function functionRendererRight(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { |
||||
const params = renderParams(model, def, innerExpr); |
||||
const str = model.id + '('; |
||||
|
||||
if (innerExpr) { |
||||
params.unshift(innerExpr); |
||||
} |
||||
|
||||
return str + params.join(', ') + ')'; |
||||
} |
||||
|
||||
function renderParams(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { |
||||
return (model.params ?? []).map((value, index) => { |
||||
const paramDef = def.params[index]; |
||||
if (paramDef.type === 'string') { |
||||
return '"' + value + '"'; |
||||
} |
||||
|
||||
return value; |
||||
}); |
||||
} |
||||
|
||||
export function defaultAddOperationHandler<T extends QueryWithOperations>(def: QueryBuilderOperationDef, query: T) { |
||||
const newOperation: QueryBuilderOperation = { |
||||
id: def.id, |
||||
params: def.defaultParams, |
||||
}; |
||||
|
||||
return { |
||||
...query, |
||||
operations: [...query.operations, newOperation], |
||||
}; |
||||
} |
||||
|
||||
export function getPromAndLokiOperationDisplayName(funcName: string) { |
||||
return capitalize(funcName.replace(/_/g, ' ')); |
||||
} |
@ -0,0 +1,97 @@ |
||||
/** |
||||
* Shared types that can be reused by Loki and other data sources |
||||
*/ |
||||
|
||||
import { DataSourceApi, RegistryItem, SelectableValue } from '@grafana/data'; |
||||
import { ComponentType } from 'react'; |
||||
|
||||
export interface QueryBuilderLabelFilter { |
||||
label: string; |
||||
op: string; |
||||
value: string; |
||||
} |
||||
|
||||
export interface QueryBuilderOperation { |
||||
id: string; |
||||
params: QueryBuilderOperationParamValue[]; |
||||
} |
||||
|
||||
export interface QueryWithOperations { |
||||
operations: QueryBuilderOperation[]; |
||||
} |
||||
|
||||
export interface QueryBuilderOperationDef<T = any> extends RegistryItem { |
||||
documentation?: string; |
||||
params: QueryBuilderOperationParamDef[]; |
||||
defaultParams: QueryBuilderOperationParamValue[]; |
||||
category: string; |
||||
hideFromList?: boolean; |
||||
alternativesKey?: string; |
||||
renderer: QueryBuilderOperationRenderer; |
||||
addOperationHandler: QueryBuilderAddOperationHandler<T>; |
||||
paramChangedHandler?: QueryBuilderOnParamChangedHandler; |
||||
explainHandler?: (op: QueryBuilderOperation, def: QueryBuilderOperationDef<T>) => string; |
||||
} |
||||
|
||||
export type QueryBuilderAddOperationHandler<T> = ( |
||||
def: QueryBuilderOperationDef, |
||||
query: T, |
||||
modeller: VisualQueryModeller |
||||
) => T; |
||||
|
||||
export type QueryBuilderOnParamChangedHandler = ( |
||||
index: number, |
||||
operation: QueryBuilderOperation, |
||||
operationDef: QueryBuilderOperationDef |
||||
) => QueryBuilderOperation; |
||||
|
||||
export type QueryBuilderOperationRenderer = ( |
||||
model: QueryBuilderOperation, |
||||
def: QueryBuilderOperationDef, |
||||
innerExpr: string |
||||
) => string; |
||||
|
||||
export type QueryBuilderOperationParamValue = string | number; |
||||
|
||||
export interface QueryBuilderOperationParamDef { |
||||
name: string; |
||||
type: string; |
||||
options?: string[] | number[] | Array<SelectableValue<string>>; |
||||
restParam?: boolean; |
||||
optional?: boolean; |
||||
editor?: ComponentType<QueryBuilderOperationParamEditorProps>; |
||||
} |
||||
|
||||
export interface QueryBuilderOperationEditorProps { |
||||
operation: QueryBuilderOperation; |
||||
index: number; |
||||
query: any; |
||||
datasource: DataSourceApi; |
||||
queryModeller: VisualQueryModeller; |
||||
onChange: (index: number, update: QueryBuilderOperation) => void; |
||||
onRemove: (index: number) => void; |
||||
} |
||||
|
||||
export interface QueryBuilderOperationParamEditorProps { |
||||
value?: QueryBuilderOperationParamValue; |
||||
paramDef: QueryBuilderOperationParamDef; |
||||
index: number; |
||||
operation: QueryBuilderOperation; |
||||
query: any; |
||||
datasource: DataSourceApi; |
||||
onChange: (index: number, value: QueryBuilderOperationParamValue) => void; |
||||
onRunQuery: () => void; |
||||
} |
||||
|
||||
export enum QueryEditorMode { |
||||
Builder, |
||||
Code, |
||||
Explain, |
||||
} |
||||
|
||||
export interface VisualQueryModeller { |
||||
getOperationsForCategory(category: string): QueryBuilderOperationDef[]; |
||||
getAlternativeOperations(key: string): QueryBuilderOperationDef[]; |
||||
getCategories(): string[]; |
||||
getOperationDef(id: string): QueryBuilderOperationDef; |
||||
} |
@ -0,0 +1,10 @@ |
||||
import { screen, getAllByRole } from '@testing-library/react'; |
||||
|
||||
export function getLabelSelects(index = 0) { |
||||
const labels = screen.getByText(/Labels/); |
||||
const selects = getAllByRole(labels.parentElement!, 'combobox'); |
||||
return { |
||||
name: selects[3 * index], |
||||
value: selects[3 * index + 2], |
||||
}; |
||||
} |
@ -0,0 +1,36 @@ |
||||
import { VisualQueryBinary } from './shared/LokiAndPromQueryModellerBase'; |
||||
import { QueryBuilderLabelFilter, QueryBuilderOperation } from './shared/types'; |
||||
|
||||
/** |
||||
* Visual query model |
||||
*/ |
||||
export interface PromVisualQuery { |
||||
metric: string; |
||||
labels: QueryBuilderLabelFilter[]; |
||||
operations: QueryBuilderOperation[]; |
||||
binaryQueries?: PromVisualQueryBinary[]; |
||||
} |
||||
|
||||
export type PromVisualQueryBinary = VisualQueryBinary<PromVisualQuery>; |
||||
|
||||
export enum PromVisualQueryOperationCategory { |
||||
Aggregations = 'Aggregations', |
||||
RangeFunctions = 'Range functions', |
||||
Functions = 'Functions', |
||||
BinaryOps = 'Binary operations', |
||||
} |
||||
|
||||
export interface PromQueryPattern { |
||||
name: string; |
||||
operations: QueryBuilderOperation[]; |
||||
} |
||||
|
||||
export function getDefaultEmptyQuery() { |
||||
const model: PromVisualQuery = { |
||||
metric: '', |
||||
labels: [], |
||||
operations: [], |
||||
}; |
||||
|
||||
return model; |
||||
} |
Loading…
Reference in new issue