Prometheus: Create Prometheus library (#81641)

* Move to the library

* copy from library

* move them in src

* have additional files

* add unmigrated/dulicated code and files

* migrate from brendan's pr
module.ts, query_hints.ts, tracking.ts, and remove plugin.json

* migrate from brendan's pr
metric_find_query.test.ts

* migrate from brendan's pr
language_utils.test.ts

* migrate from brendan's pr
index.ts in root and in configuration

* migrate from brendan's pr
datasource.test.ts

* migrate from brendan's pr
typings folder

* migrate from brendan's pr
querycache folder

* migrate from brendan's pr
monaco-query-field folder

* migrate from brendan's pr
components folder without monaco-query-field folder

* migrate from brendan's pr
configuration/overhaul folder

* migrate from brendan's pr
AlertingSettingsOverhaul.tsx

* Remove azure related code

* migrate from brendan's pr
ConfigEditor.tsx, DataSourceHttpSettingsOverhaul.tsx, ExemplarSetting.tsx, configuration/mocks.ts, PromSettings.test.tsx, PromSettings.tsx

* migrate from brendan's pr
useFlag.ts

* migrate from brendan's pr
metrics-modal folder

* migrate from brendan's pr
files inside components folder

* migrate from brendan's pr
LabelFilters* files because they are now under components folder

* migrate from brendan's pr
files under querybuilder/shared folder

* migrate from brendan's pr
aggregations.ts, QueryPattern.tsx, QueryPatternsModal.tsx, state.ts, testUtils.ts under querybuilder folder

* Apply Ivana's PR https://github.com/grafana/grafana/pull/81656

* Apply jack's suggestions in this PR https://github.com/grafana/grafana/pull/77762

* Apply Ivana's PR https://github.com/grafana/grafana/pull/81656

* Fix type import

* add monaco-promql to transformIgnorePatterns to run prometheus frontend library tests

* remove Loki specific tests because we removed Loki code to decouple Loki

* add prometheus specific references

* We are moving these betterer issues from core Prometheus to the Library and we promise to remove all issues in the future, thank you

* include prometheus library in package.json

* add yarn lock with prometheus frontend library

* decouple final core import from metric_find_query.test.ts

* run prettier

* fix core imports in promqail

* fix lint errors

* run prettier

* add grafana-ui to devdeps to fix lint errors

* update yarn.lock

* grafana-ui fix

* trying to fix grafana-ui type errors with lerna drone check

* trying to fix grafana-ui type errors with lerna drone check

* trying to fix grafana-ui type errors with lerna drone check

* trying to fix grafana-ui type errors with lerna drone check

* try to pass typecheck

---------

Co-authored-by: Brendan O'Handley <brendan.ohandley@grafana.com>
This commit is contained in:
ismail simsek
2024-02-02 15:30:14 +01:00
committed by GitHub
co-authored by Brendan O'Handley
parent 89271df647
commit 75c2d39f79
183 changed files with 33356 additions and 3 deletions
+286
View File
@@ -532,6 +532,240 @@ exports[`better eslint`] = {
"packages/grafana-o11y-ds-frontend/src/utils.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-prometheus/src/components/PromExemplarField.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"]
],
"packages/grafana-prometheus/src/components/PromExploreExtraField.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"packages/grafana-prometheus/src/components/PromQueryField.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"packages/grafana-prometheus/src/components/PromQueryField.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"],
[0, 0, 0, "Styles should be written using objects.", "9"],
[0, 0, 0, "Styles should be written using objects.", "10"],
[0, 0, 0, "Styles should be written using objects.", "11"]
],
"packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"packages/grafana-prometheus/src/configuration/ConfigEditor.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"],
[0, 0, 0, "Styles should be written using objects.", "9"],
[0, 0, 0, "Styles should be written using objects.", "10"],
[0, 0, 0, "Styles should be written using objects.", "11"],
[0, 0, 0, "Styles should be written using objects.", "12"],
[0, 0, 0, "Styles should be written using objects.", "13"],
[0, 0, 0, "Styles should be written using objects.", "14"],
[0, 0, 0, "Styles should be written using objects.", "15"]
],
"packages/grafana-prometheus/src/configuration/ExemplarsSettings.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"packages/grafana-prometheus/src/datasource.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
],
"packages/grafana-prometheus/src/datasource.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
[0, 0, 0, "Unexpected any. Specify a different type.", "9"],
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
[0, 0, 0, "Unexpected any. Specify a different type.", "11"],
[0, 0, 0, "Unexpected any. Specify a different type.", "12"]
],
"packages/grafana-prometheus/src/gcopypaste/app/features/live/data/amendTimeSeries.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Do not use any type assertions.", "4"]
],
"packages/grafana-prometheus/src/gcopypaste/public/test/matchers/index.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"packages/grafana-prometheus/src/gcopypaste/public/test/matchers/toEmitValuesWith.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
],
"packages/grafana-prometheus/src/gcopypaste/public/test/matchers/utils.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"packages/grafana-prometheus/src/gcopypaste/test/helpers/selectOptionInTest.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-prometheus/src/language_provider.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
],
"packages/grafana-prometheus/src/language_utils.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"]
],
"packages/grafana-prometheus/src/metric_find_query.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"packages/grafana-prometheus/src/metric_find_query.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
],
"packages/grafana-prometheus/src/query_hints.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"packages/grafana-prometheus/src/querybuilder/QueryPattern.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"]
],
"packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"packages/grafana-prometheus/src/querybuilder/binaryScalarOperations.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"packages/grafana-prometheus/src/querybuilder/components/LabelFilterItem.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"]
],
"packages/grafana-prometheus/src/querybuilder/components/LabelFilters.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"packages/grafana-prometheus/src/querybuilder/components/LabelParamEditor.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-prometheus/src/querybuilder/components/MetricSelect.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"],
[0, 0, 0, "Styles should be written using objects.", "9"],
[0, 0, 0, "Styles should be written using objects.", "10"],
[0, 0, 0, "Styles should be written using objects.", "11"],
[0, 0, 0, "Styles should be written using objects.", "12"]
],
"packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"packages/grafana-prometheus/src/querybuilder/components/metrics-modal/AdditionalSettings.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"]
],
"packages/grafana-prometheus/src/querybuilder/components/metrics-modal/ResultsTable.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"],
[0, 0, 0, "Styles should be written using objects.", "9"],
[0, 0, 0, "Styles should be written using objects.", "10"],
[0, 0, 0, "Styles should be written using objects.", "11"],
[0, 0, 0, "Styles should be written using objects.", "12"]
],
"packages/grafana-prometheus/src/querybuilder/components/metrics-modal/styles.ts:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"],
[0, 0, 0, "Styles should be written using objects.", "9"],
[0, 0, 0, "Styles should be written using objects.", "10"],
[0, 0, 0, "Styles should be written using objects.", "11"],
[0, 0, 0, "Styles should be written using objects.", "12"],
[0, 0, 0, "Styles should be written using objects.", "13"],
[0, 0, 0, "Styles should be written using objects.", "14"],
[0, 0, 0, "Styles should be written using objects.", "15"],
[0, 0, 0, "Styles should be written using objects.", "16"],
[0, 0, 0, "Styles should be written using objects.", "17"],
[0, 0, 0, "Styles should be written using objects.", "18"]
],
"packages/grafana-prometheus/src/querybuilder/operationUtils.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-prometheus/src/querybuilder/shared/OperationEditor.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"packages/grafana-prometheus/src/querybuilder/shared/OperationParamEditor.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"packages/grafana-prometheus/src/querybuilder/shared/QueryBuilderHints.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"packages/grafana-prometheus/src/querybuilder/shared/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
],
"packages/grafana-prometheus/src/result_transformer.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"packages/grafana-prometheus/src/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
],
"packages/grafana-runtime/src/analytics/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
@@ -6470,6 +6704,58 @@ exports[`no gf-form usage`] = {
"packages/grafana-e2e/src/flows/addDataSource.ts:5381": [
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
],
"packages/grafana-prometheus/src/components/PromExploreExtraField.tsx:5381": [
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
],
"packages/grafana-prometheus/src/components/PromQueryField.tsx:5381": [
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
],
"packages/grafana-prometheus/src/configuration/AlertingSettingsOverhaul.tsx:5381": [
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
],
"packages/grafana-prometheus/src/configuration/ExemplarSetting.tsx:5381": [
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
],
"packages/grafana-prometheus/src/configuration/PromSettings.tsx:5381": [
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
],
"packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx:5381": [
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
],
"packages/grafana-ui/src/components/DataSourceSettings/AlertingSettings.tsx:5381": [
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
+1
View File
@@ -14,6 +14,7 @@ const esModules = [
'robust-predicates',
'leven',
'nanoid',
'monaco-promql'
].join('|');
module.exports = {
+1
View File
@@ -244,6 +244,7 @@
"@grafana/lezer-logql": "0.2.2",
"@grafana/monaco-logql": "^0.0.7",
"@grafana/o11y-ds-frontend": "workspace:*",
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "^2.4.0",
"@grafana/schema": "workspace:*",
+3
View File
@@ -0,0 +1,3 @@
# (2023-06-11)
First public release
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2015 Grafana Labs
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5
View File
@@ -0,0 +1,5 @@
# Grafana Prometheus Library
> **@grafana/prometheus is currently in BETA**.
This package is a frontend library for Prometheus
+134
View File
@@ -0,0 +1,134 @@
{
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/prometheus",
"private": true,
"version": "10.4.0-pre",
"description": "Grafana Prometheus Library",
"keywords": [
"typescript"
],
"sideEffects": false,
"repository": {
"type": "git",
"url": "http://github.com/grafana/grafana.git",
"directory": "packages/grafana-prometheus"
},
"main": "src/index.ts",
"types": "src/index.ts",
"files": [
"dist",
"./README.md",
"./CHANGELOG.md",
"LICENSE_APACHE2"
],
"scripts": {
"typecheck": "tsc --emitDeclarationOnly false --noEmit"
},
"dependencies": {
"@emotion/css": "11.11.2",
"@floating-ui/react": "0.26.8",
"@grafana/data": "workspace:*",
"@grafana/experimental": "1.7.9",
"@grafana/faro-web-sdk": "1.3.7",
"@grafana/runtime": "workspace:*",
"@grafana/schema": "workspace:*",
"@grafana/ui": "workspace:*",
"@leeoniya/ufuzzy": "1.0.14",
"@lezer/common": "1.2.1",
"@lezer/highlight": "1.2.0",
"@lezer/lr": "1.3.3",
"@prometheus-io/lezer-promql": "^0.37.0-rc.1",
"@reduxjs/toolkit": "1.9.5",
"d3": "7.8.5",
"date-fns": "3.3.1",
"debounce-promise": "3.1.2",
"eventemitter3": "5.0.1",
"lodash": "4.17.21",
"lru-cache": "10.2.0",
"marked": "5.1.1",
"marked-mangle": "1.1.6",
"moment": "2.30.1",
"moment-timezone": "0.5.44",
"monaco-promql": "1.7.4",
"pluralize": "8.0.0",
"prismjs": "1.29.0",
"react-beautiful-dnd": "13.1.1",
"react-highlight-words": "0.20.0",
"react-select": "5.8.0",
"react-use": "17.5.0",
"react-window": "1.8.10",
"rxjs": "7.8.1",
"semver": "7.5.4",
"tslib": "2.6.2",
"uuid": "9.0.1",
"whatwg-fetch": "3.6.20"
},
"devDependencies": {
"@emotion/eslint-plugin": "11.11.0",
"@grafana/e2e": "workspace:*",
"@grafana/e2e-selectors": "workspace:*",
"@grafana/tsconfig": "^1.3.0-rc1",
"@swc/core": "1.3.107",
"@swc/helpers": "0.5.3",
"@testing-library/dom": "9.3.4",
"@testing-library/jest-dom": "6.4.0",
"@testing-library/react": "14.2.0",
"@testing-library/user-event": "14.5.2",
"@types/d3": "7.4.3",
"@types/debounce-promise": "3.1.9",
"@types/eslint": "8.56.2",
"@types/jest": "29.5.11",
"@types/jquery": "3.5.29",
"@types/lodash": "4.14.202",
"@types/marked": "5.0.2",
"@types/node": "20.11.13",
"@types/pluralize": "^0.0.33",
"@types/prismjs": "1.26.3",
"@types/react": "18.2.48",
"@types/react-beautiful-dnd": "13.1.8",
"@types/react-dom": "18.2.18",
"@types/react-highlight-words": "0.16.7",
"@types/react-window": "1.8.8",
"@types/semver": "7.5.6",
"@types/testing-library__jest-dom": "5.14.9",
"@types/uuid": "9.0.8",
"@typescript-eslint/eslint-plugin": "6.20.0",
"@typescript-eslint/parser": "6.20.0",
"copy-webpack-plugin": "12.0.2",
"css-loader": "6.10.0",
"esbuild": "0.18.12",
"eslint": "8.56.0",
"eslint-config-prettier": "8.8.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "27.6.3",
"eslint-plugin-jsdoc": "46.8.2",
"eslint-plugin-jsx-a11y": "6.8.0",
"eslint-plugin-lodash": "7.4.0",
"eslint-plugin-react": "7.33.2",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-webpack-plugin": "4.0.1",
"fork-ts-checker-webpack-plugin": "8.0.0",
"glob": "10.3.10",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jest-matcher-utils": "29.7.0",
"prettier": "3.2.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-select-event": "5.5.1",
"react-test-renderer": "18.2.0",
"sass": "1.70.0",
"sass-loader": "13.3.2",
"style-loader": "3.3.4",
"testing-library-selector": "0.3.1",
"ts-node": "10.9.2",
"typescript": "5.3.3",
"webpack": "5.90.0",
"webpack-cli": "5.1.4"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
}
}
@@ -0,0 +1,114 @@
import { addLabelToQuery } from './add_label_to_query';
describe('addLabelToQuery()', () => {
it('should add label to simple query', () => {
expect(() => {
addLabelToQuery('foo', '', '');
}).toThrow();
expect(addLabelToQuery('foo', 'bar', 'baz')).toBe('foo{bar="baz"}');
expect(addLabelToQuery('foo{}', 'bar', 'baz')).toBe('foo{bar="baz"}');
expect(addLabelToQuery('foo{x="yy"}', 'bar', 'baz')).toBe('foo{x="yy", bar="baz"}');
expect(addLabelToQuery('metric > 0.001', 'foo', 'bar')).toBe('metric{foo="bar"} > 0.001');
});
it('should add custom operator', () => {
expect(addLabelToQuery('foo{}', 'bar', 'baz', '!=')).toBe('foo{bar!="baz"}');
expect(addLabelToQuery('foo{x="yy"}', 'bar', 'baz', '!=')).toBe('foo{x="yy", bar!="baz"}');
});
it('should not modify ranges', () => {
expect(addLabelToQuery('rate(metric[1m])', 'foo', 'bar')).toBe('rate(metric{foo="bar"}[1m])');
});
it('should detect in-order function use', () => {
expect(addLabelToQuery('sum by (xx) (foo)', 'bar', 'baz')).toBe('sum by (xx) (foo{bar="baz"})');
});
it('should convert number Infinity to +Inf', () => {
expect(
addLabelToQuery('sum(rate(prometheus_tsdb_compaction_chunk_size_bytes_bucket[5m])) by (le)', 'le', Infinity)
).toBe('sum(rate(prometheus_tsdb_compaction_chunk_size_bytes_bucket{le="+Inf"}[5m])) by (le)');
});
it('should handle selectors with punctuation', () => {
expect(addLabelToQuery('foo{instance="my-host.com:9100"}', 'bar', 'baz')).toBe(
'foo{instance="my-host.com:9100", bar="baz"}'
);
expect(addLabelToQuery('foo:metric:rate1m', 'bar', 'baz')).toBe('foo:metric:rate1m{bar="baz"}');
expect(addLabelToQuery('avg(foo:metric:rate1m{a="b"})', 'bar', 'baz')).toBe(
'avg(foo:metric:rate1m{a="b", bar="baz"})'
);
expect(addLabelToQuery('foo{list="a,b,c"}', 'bar', 'baz')).toBe('foo{list="a,b,c", bar="baz"}');
});
it('should work on arithmetical expressions', () => {
expect(addLabelToQuery('foo + foo', 'bar', 'baz')).toBe('foo{bar="baz"} + foo{bar="baz"}');
expect(addLabelToQuery('foo{x="yy"} + metric', 'bar', 'baz')).toBe('foo{x="yy", bar="baz"} + metric{bar="baz"}');
expect(addLabelToQuery('avg(foo) + sum(xx_yy)', 'bar', 'baz')).toBe('avg(foo{bar="baz"}) + sum(xx_yy{bar="baz"})');
expect(addLabelToQuery('foo{x="yy"} * metric{y="zz",a="bb"} * metric2', 'bar', 'baz')).toBe(
'foo{x="yy", bar="baz"} * metric{y="zz", a="bb", bar="baz"} * metric2{bar="baz"}'
);
});
it('should not add duplicate labels to a query', () => {
expect(addLabelToQuery(addLabelToQuery('foo{x="yy"}', 'bar', 'baz', '!='), 'bar', 'baz', '!=')).toBe(
'foo{x="yy", bar!="baz"}'
);
expect(addLabelToQuery(addLabelToQuery('rate(metric[1m])', 'foo', 'bar'), 'foo', 'bar')).toBe(
'rate(metric{foo="bar"}[1m])'
);
expect(addLabelToQuery(addLabelToQuery('foo{list="a,b,c"}', 'bar', 'baz'), 'bar', 'baz')).toBe(
'foo{list="a,b,c", bar="baz"}'
);
expect(addLabelToQuery(addLabelToQuery('avg(foo) + sum(xx_yy)', 'bar', 'baz'), 'bar', 'baz')).toBe(
'avg(foo{bar="baz"}) + sum(xx_yy{bar="baz"})'
);
});
it('should not remove filters', () => {
expect(addLabelToQuery('{x="y"} |="yy"', 'bar', 'baz')).toBe('{x="y", bar="baz"} |="yy"');
expect(addLabelToQuery('{x="y"} |="yy" !~"xx"', 'bar', 'baz')).toBe('{x="y", bar="baz"} |="yy" !~"xx"');
});
it('should add labels to metrics with logical operators', () => {
expect(addLabelToQuery('foo_info or bar_info', 'bar', 'baz')).toBe('foo_info{bar="baz"} or bar_info{bar="baz"}');
expect(addLabelToQuery('foo_info and bar_info', 'bar', 'baz')).toBe('foo_info{bar="baz"} and bar_info{bar="baz"}');
});
it('should not add ad-hoc filter to template variables', () => {
expect(addLabelToQuery('sum(rate({job="foo"}[2m])) by (value $variable)', 'bar', 'baz')).toBe(
'sum(rate({job="foo", bar="baz"}[2m])) by (value $variable)'
);
});
it('should not add ad-hoc filter to range', () => {
expect(addLabelToQuery('avg(rate((my_metric{job="foo"} > 0)[3h:])) by (label)', 'bar', 'baz')).toBe(
'avg(rate((my_metric{job="foo", bar="baz"} > 0)[3h:])) by (label)'
);
});
it('should not add ad-hoc filter to labels in label list provided with the group modifier', () => {
expect(
addLabelToQuery(
'max by (id, name, type) (my_metric{type=~"foo|bar|baz-test"}) * on(id) group_right(id, type, name) sum by (id) (my_metric) * 1000',
'bar',
'baz'
)
).toBe(
'max by (id, name, type) (my_metric{type=~"foo|bar|baz-test", bar="baz"}) * on(id) group_right(id, type, name) sum by (id) (my_metric{bar="baz"}) * 1000'
);
});
it('should not add ad-hoc filter to labels in label list provided with the group modifier', () => {
expect(addLabelToQuery('rate(my_metric[${__range_s}s])', 'bar', 'baz')).toBe(
'rate(my_metric{bar="baz"}[${__range_s}s])'
);
});
it('should not add ad-hoc filter to labels to math operations', () => {
expect(addLabelToQuery('count(my_metric{job!="foo"} < (5*1024*1024*1024) or vector(0)) - 1', 'bar', 'baz')).toBe(
'count(my_metric{job!="foo", bar="baz"} < (5*1024*1024*1024) or vector(0)) - 1'
);
});
it('should not add ad-hoc filter bool operator', () => {
expect(addLabelToQuery('ALERTS < bool 1', 'bar', 'baz')).toBe('ALERTS{bar="baz"} < bool 1');
});
});
@@ -0,0 +1,100 @@
import { parser, VectorSelector } from '@prometheus-io/lezer-promql';
import { PromQueryModeller } from './querybuilder/PromQueryModeller';
import { buildVisualQueryFromString } from './querybuilder/parsing';
import { QueryBuilderLabelFilter } from './querybuilder/shared/types';
import { PromVisualQuery } from './querybuilder/types';
/**
* Adds label filter to existing query. Useful for query modification for example for ad hoc filters.
*
* It uses PromQL parser to find instances of metric and labels, alters them and then splices them back into the query.
* Ideally we could use the parse -> change -> render is a simple 3 steps but right now building the visual query
* object does not support all possible queries.
*
* So instead this just operates on substrings of the query with labels and operates just on those. This makes this
* more robust and can alter even invalid queries, and preserves in general the query structure and whitespace.
* @param query
* @param key
* @param value
* @param operator
*/
export function addLabelToQuery(query: string, key: string, value: string | number, operator = '='): string {
if (!key || !value) {
throw new Error('Need label to add to query.');
}
const vectorSelectorPositions = getVectorSelectorPositions(query);
if (!vectorSelectorPositions.length) {
return query;
}
const filter = toLabelFilter(key, value, operator);
return addFilter(query, vectorSelectorPositions, filter);
}
type VectorSelectorPosition = { from: number; to: number; query: PromVisualQuery };
/**
* Parse the string and get all VectorSelector positions in the query together with parsed representation of the vector
* selector.
* @param query
*/
function getVectorSelectorPositions(query: string): VectorSelectorPosition[] {
const tree = parser.parse(query);
const positions: VectorSelectorPosition[] = [];
tree.iterate({
enter: ({ to, from, type }): false | void => {
if (type.id === VectorSelector) {
const visQuery = buildVisualQueryFromString(query.substring(from, to));
positions.push({ query: visQuery.query, from, to });
return false;
}
},
});
return positions;
}
function toLabelFilter(key: string, value: string | number, operator: string): QueryBuilderLabelFilter {
// We need to make sure that we convert the value back to string because it may be a number
const transformedValue = value === Infinity ? '+Inf' : value.toString();
return { label: key, op: operator, value: transformedValue };
}
function addFilter(
query: string,
vectorSelectorPositions: VectorSelectorPosition[],
filter: QueryBuilderLabelFilter
): string {
const modeller = new PromQueryModeller();
let newQuery = '';
let prev = 0;
for (let i = 0; i < vectorSelectorPositions.length; i++) {
// This is basically just doing splice on a string for each matched vector selector.
const match = vectorSelectorPositions[i];
const isLast = i === vectorSelectorPositions.length - 1;
const start = query.substring(prev, match.from);
const end = isLast ? query.substring(match.to) : '';
if (!labelExists(match.query.labels, filter)) {
// We don't want to add duplicate labels.
match.query.labels.push(filter);
}
const newLabels = modeller.renderQuery(match.query);
newQuery += start + newLabels + end;
prev = match.to;
}
return newQuery;
}
/**
* Check if label exists in the list of labels but ignore the operator.
* @param labels
* @param filter
*/
function labelExists(labels: QueryBuilderLabelFilter[], filter: QueryBuilderLabelFilter) {
return labels.find((label) => label.label === filter.label && label.value === filter.value);
}
@@ -0,0 +1,139 @@
import React from 'react';
import { AnnotationQuery } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { EditorField, EditorRow, EditorRows, EditorSwitch } from '@grafana/experimental';
import { AutoSizeInput, Input, Space } from '@grafana/ui';
import { PromQueryCodeEditor } from '../querybuilder/components/PromQueryCodeEditor';
import { PromQuery } from '../types';
import { PromQueryEditorProps } from './types';
type Props = PromQueryEditorProps & {
annotation?: AnnotationQuery<PromQuery>;
onAnnotationChange?: (annotation: AnnotationQuery<PromQuery>) => void;
};
export function AnnotationQueryEditor(props: Props) {
// This is because of problematic typing. See AnnotationQueryEditorProps in grafana-data/annotations.ts.
const annotation = props.annotation!;
const onAnnotationChange = props.onAnnotationChange!;
const query = { expr: annotation.expr, refId: annotation.name, interval: annotation.step };
return (
<>
<EditorRows>
<PromQueryCodeEditor
{...props}
query={query}
showExplain={false}
onChange={(query) => {
onAnnotationChange({
...annotation,
expr: query.expr,
});
}}
/>
<EditorRow>
<EditorField
label="Min step"
tooltip={
<>
An additional lower limit for the step parameter of the Prometheus query and for the{' '}
<code>$__interval</code> and <code>$__rate_interval</code> variables.
</>
}
>
<AutoSizeInput
type="text"
aria-label="Set lower limit for the step parameter"
placeholder={'auto'}
minWidth={10}
onCommitChange={(ev) => {
onAnnotationChange({
...annotation,
step: ev.currentTarget.value,
});
}}
defaultValue={query.interval}
id={selectors.components.DataSource.Prometheus.annotations.minStep}
/>
</EditorField>
</EditorRow>
</EditorRows>
<Space v={0.5} />
<EditorRow>
<EditorField
label="Title"
tooltip={
'Use either the name or a pattern. For example, {{instance}} is replaced with label value for the label instance.'
}
>
<Input
type="text"
placeholder="{{alertname}}"
value={annotation.titleFormat}
onChange={(event) => {
onAnnotationChange({
...annotation,
titleFormat: event.currentTarget.value,
});
}}
data-testid={selectors.components.DataSource.Prometheus.annotations.title}
/>
</EditorField>
<EditorField label="Tags">
<Input
type="text"
placeholder="label1,label2"
value={annotation.tagKeys}
onChange={(event) => {
onAnnotationChange({
...annotation,
tagKeys: event.currentTarget.value,
});
}}
data-testid={selectors.components.DataSource.Prometheus.annotations.tags}
/>
</EditorField>
<EditorField
label="Text"
tooltip={
'Use either the name or a pattern. For example, {{instance}} is replaced with label value for the label instance.'
}
>
<Input
type="text"
placeholder="{{instance}}"
value={annotation.textFormat}
onChange={(event) => {
onAnnotationChange({
...annotation,
textFormat: event.currentTarget.value,
});
}}
data-testid={selectors.components.DataSource.Prometheus.annotations.text}
/>
</EditorField>
<EditorField
label="Series value as timestamp"
tooltip={
'The unit of timestamp is milliseconds. If the unit of the series value is seconds, multiply its range vector by 1000.'
}
>
<EditorSwitch
value={annotation.useValueForTime}
onChange={(event) => {
onAnnotationChange({
...annotation,
useValueForTime: event.currentTarget.value,
});
}}
data-testid={selectors.components.DataSource.Prometheus.annotations.seriesValueAsTimestamp}
/>
</EditorField>
</EditorRow>
</>
);
}
@@ -0,0 +1,52 @@
import React from 'react';
import { QueryEditorHelpProps } from '@grafana/data';
import { PromQuery } from '../types';
const CHEAT_SHEET_ITEMS = [
{
title: 'Request Rate',
expression: 'rate(http_request_total[5m])',
label:
'Given an HTTP request counter, this query calculates the per-second average request rate over the last 5 minutes.',
},
{
title: '95th Percentile of Request Latencies',
expression: 'histogram_quantile(0.95, sum(rate(prometheus_http_request_duration_seconds_bucket[5m])) by (le))',
label: 'Calculates the 95th percentile of HTTP request rate over 5 minute windows.',
},
{
title: 'Alerts Firing',
expression: 'sort_desc(sum(sum_over_time(ALERTS{alertstate="firing"}[24h])) by (alertname))',
label: 'Sums up the alerts that have been firing over the last 24 hours.',
},
{
title: 'Step',
label:
'Defines the graph resolution using a duration format (15s, 1m, 3h, ...). Small steps create high-resolution graphs but can be slow over larger time ranges. Using a longer step lowers the resolution and smooths the graph by producing fewer datapoints. If no step is given the resolution is calculated automatically.',
},
];
const PromCheatSheet = (props: QueryEditorHelpProps<PromQuery>) => (
<div>
<h2>PromQL Cheat Sheet</h2>
{CHEAT_SHEET_ITEMS.map((item, index) => (
<div className="cheat-sheet-item" key={index}>
<div className="cheat-sheet-item__title">{item.title}</div>
{item.expression ? (
<button
type="button"
className="cheat-sheet-item__example"
onClick={(e) => props.onClickExample({ refId: 'A', expr: item.expression })}
>
<code>{item.expression}</code>
</button>
) : null}
<div className="cheat-sheet-item__label">{item.label}</div>
</div>
))}
</div>
);
export default PromCheatSheet;
@@ -0,0 +1,79 @@
import { css, cx } from '@emotion/css';
import React, { useEffect, useState } from 'react';
import { usePrevious } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, InlineLabel, Tooltip, useStyles2 } from '@grafana/ui';
import { PrometheusDatasource } from '../datasource';
import { PromQuery } from '../types';
interface Props {
onChange: (exemplar: boolean) => void;
datasource: PrometheusDatasource;
query: PromQuery;
'data-testid'?: string;
}
export function PromExemplarField({ datasource, onChange, query, ...rest }: Props) {
const [error, setError] = useState<string | null>(null);
const styles = useStyles2(getStyles);
const prevError = usePrevious(error);
useEffect(() => {
if (!datasource.exemplarsAvailable) {
setError('Exemplars for this query are not available');
onChange(false);
} else if (query.instant && !query.range) {
setError('Exemplars are not available for instant queries');
onChange(false);
} else {
setError(null);
// If error is cleared, we want to change exemplar to true
if (prevError && !error) {
onChange(true);
}
}
}, [datasource.exemplarsAvailable, query.instant, query.range, onChange, prevError, error]);
const iconButtonStyles = cx(
{
[styles.activeIcon]: !!query.exemplar,
},
styles.eyeIcon
);
return (
<InlineLabel width="auto" data-testid={rest['data-testid']}>
<Tooltip content={error ?? ''}>
<div className={styles.iconWrapper}>
Exemplars
<IconButton
name="eye"
tooltip={!!query.exemplar ? 'Disable query with exemplars' : 'Enable query with exemplars'}
disabled={!!error}
className={iconButtonStyles}
onClick={() => {
onChange(!query.exemplar);
}}
/>
</div>
</Tooltip>
</InlineLabel>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
eyeIcon: css`
margin-left: ${theme.spacing(2)};
`,
activeIcon: css`
color: ${theme.colors.primary.main};
`,
iconWrapper: css`
display: flex;
align-items: center;
`,
};
}
@@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { PrometheusDatasource } from '../datasource';
import { PromQuery } from '../types';
import { PromExploreExtraField, PromExploreExtraFieldProps, testIds } from './PromExploreExtraField';
const setup = (propOverrides?: PromExploreExtraFieldProps) => {
const query = { exemplar: false } as PromQuery;
const datasource = {} as PrometheusDatasource;
const onChange = jest.fn();
const onRunQuery = jest.fn();
const props: PromExploreExtraFieldProps = {
onChange,
onRunQuery,
query,
datasource,
};
Object.assign(props, propOverrides);
return render(<PromExploreExtraField {...props} />);
};
describe('PromExploreExtraField', () => {
it('should render step field', () => {
setup();
expect(screen.getByTestId(testIds.stepField)).toBeInTheDocument();
});
it('should render query type field', () => {
setup();
expect(screen.getByTestId(testIds.queryTypeField)).toBeInTheDocument();
});
});
@@ -0,0 +1,141 @@
import { css, cx } from '@emotion/css';
import { isEqual } from 'lodash';
import React, { memo, useCallback } from 'react';
import { usePrevious } from 'react-use';
import { InlineFormLabel, RadioButtonGroup } from '@grafana/ui';
import { PrometheusDatasource } from '../datasource';
import { PromQuery } from '../types';
import { PromExemplarField } from './PromExemplarField';
export interface PromExploreExtraFieldProps {
query: PromQuery;
onChange: (value: PromQuery) => void;
onRunQuery: () => void;
datasource: PrometheusDatasource;
}
export const PromExploreExtraField = memo(({ query, datasource, onChange, onRunQuery }: PromExploreExtraFieldProps) => {
const rangeOptions = getQueryTypeOptions(true);
const prevQuery = usePrevious(query);
const onExemplarChange = useCallback(
(exemplar: boolean) => {
if (!isEqual(query, prevQuery) || exemplar !== query.exemplar) {
onChange({ ...query, exemplar });
}
},
[prevQuery, query, onChange]
);
function onChangeQueryStep(interval: string) {
onChange({ ...query, interval });
}
function onStepChange(e: React.SyntheticEvent<HTMLInputElement>) {
if (e.currentTarget.value !== query.interval) {
onChangeQueryStep(e.currentTarget.value);
}
}
function onReturnKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter' && e.shiftKey) {
onRunQuery();
}
}
const onQueryTypeChange = getQueryTypeChangeHandler(query, onChange);
return (
<div aria-label="Prometheus extra field" className="gf-form-inline" data-testid={testIds.extraFieldEditor}>
{/*Query type field*/}
<div
data-testid={testIds.queryTypeField}
className={cx(
'gf-form explore-input-margin',
css`
flex-wrap: nowrap;
`
)}
aria-label="Query type field"
>
<InlineFormLabel width="auto">Query type</InlineFormLabel>
<RadioButtonGroup
options={rangeOptions}
value={query.range && query.instant ? 'both' : query.instant ? 'instant' : 'range'}
onChange={onQueryTypeChange}
/>
</div>
{/*Step field*/}
<div
data-testid={testIds.stepField}
className={cx(
'gf-form',
css`
flex-wrap: nowrap;
`
)}
aria-label="Step field"
>
<InlineFormLabel
width={6}
tooltip={
'Time units and built-in variables can be used here, for example: $__interval, $__rate_interval, 5s, 1m, 3h, 1d, 1y (Default if no unit is specified: s)'
}
>
Min step
</InlineFormLabel>
<input
type={'text'}
className="gf-form-input width-4"
placeholder={'auto'}
onChange={onStepChange}
onKeyDown={onReturnKeyDown}
value={query.interval ?? ''}
/>
</div>
<PromExemplarField onChange={onExemplarChange} datasource={datasource} query={query} />
</div>
);
});
PromExploreExtraField.displayName = 'PromExploreExtraField';
export function getQueryTypeOptions(includeBoth: boolean) {
const rangeOptions = [
{ value: 'range', label: 'Range', description: 'Run query over a range of time' },
{
value: 'instant',
label: 'Instant',
description: 'Run query against a single point in time. For this query, the "To" time is used',
},
];
if (includeBoth) {
rangeOptions.push({ value: 'both', label: 'Both', description: 'Run an Instant query and a Range query' });
}
return rangeOptions;
}
export function getQueryTypeChangeHandler(query: PromQuery, onChange: (update: PromQuery) => void) {
return (queryType: string) => {
if (queryType === 'instant') {
onChange({ ...query, instant: true, range: false, exemplar: false });
} else if (queryType === 'range') {
onChange({ ...query, instant: false, range: true });
} else {
onChange({ ...query, instant: true, range: true });
}
};
}
export const testIds = {
extraFieldEditor: 'prom-editor-extra-field',
stepField: 'prom-editor-extra-field-step',
queryTypeField: 'prom-editor-extra-field-query-type',
};
@@ -0,0 +1,83 @@
import { render, screen } from '@testing-library/react';
import { noop } from 'lodash';
import React from 'react';
import { CoreApp } from '@grafana/data';
import { PrometheusDatasource } from '../datasource';
import { PromQueryEditorByApp } from './PromQueryEditorByApp';
import { testIds as alertingTestIds } from './PromQueryEditorForAlerting';
import { Props } from './monaco-query-field/MonacoQueryFieldProps';
// the monaco-based editor uses lazy-loading and that does not work
// well with this test, and we do not need the monaco-related
// functionality in this test anyway, so we mock it out.
jest.mock('./monaco-query-field/MonacoQueryFieldLazy', () => {
const fakeQueryField = (props: Props) => {
return <input onBlur={(e) => props.onBlur(e.currentTarget.value)} data-testid={'dummy-code-input'} type={'text'} />;
};
return {
MonacoQueryFieldLazy: fakeQueryField,
};
});
function setup(app: CoreApp): { onRunQuery: jest.Mock } {
const dataSource = {
createQuery: jest.fn((q) => q),
getInitHints: () => [],
getPrometheusTime: jest.fn((date, roundup) => 123),
getQueryHints: jest.fn(() => []),
getDebounceTimeInMilliseconds: jest.fn(() => 300),
languageProvider: {
start: () => Promise.resolve([]),
syntax: () => {},
getLabelKeys: () => [],
metrics: [],
},
} as unknown as PrometheusDatasource;
const onRunQuery = jest.fn();
render(
<PromQueryEditorByApp
app={app}
onChange={noop}
onRunQuery={onRunQuery}
datasource={dataSource}
query={{ refId: 'A', expr: '' }}
/>
);
return {
onRunQuery,
};
}
describe('PromQueryEditorByApp', () => {
it('should render simplified query editor for cloud alerting', async () => {
setup(CoreApp.CloudAlerting);
expect(await screen.findByTestId(alertingTestIds.editor)).toBeInTheDocument();
});
it('should render editor selector for unkown apps', () => {
setup(CoreApp.Unknown);
expect(screen.getByTestId('QueryEditorModeToggle')).toBeInTheDocument();
expect(screen.queryByTestId(alertingTestIds.editor)).toBeNull();
});
it('should render editor selector for explore', () => {
setup(CoreApp.Explore);
expect(screen.getByTestId('QueryEditorModeToggle')).toBeInTheDocument();
expect(screen.queryByTestId(alertingTestIds.editor)).toBeNull();
});
it('should render editor selector for dashboard', () => {
setup(CoreApp.Dashboard);
expect(screen.getByTestId('QueryEditorModeToggle')).toBeInTheDocument();
expect(screen.queryByTestId(alertingTestIds.editor)).toBeNull();
});
});
@@ -0,0 +1,21 @@
import React, { memo } from 'react';
import { CoreApp } from '@grafana/data';
import { PromQueryEditorSelector } from '../querybuilder/components/PromQueryEditorSelector';
import { PromQueryEditorForAlerting } from './PromQueryEditorForAlerting';
import { PromQueryEditorProps } from './types';
export function PromQueryEditorByApp(props: PromQueryEditorProps) {
const { app } = props;
switch (app) {
case CoreApp.CloudAlerting:
return <PromQueryEditorForAlerting {...props} />;
default:
return <PromQueryEditorSelector {...props} />;
}
}
export default memo(PromQueryEditorByApp);
@@ -0,0 +1,25 @@
import React from 'react';
import PromQueryField from './PromQueryField';
import { PromQueryEditorProps } from './types';
export function PromQueryEditorForAlerting(props: PromQueryEditorProps) {
const { datasource, query, range, data, onChange, onRunQuery } = props;
return (
<PromQueryField
datasource={datasource}
query={query}
onRunQuery={onRunQuery}
onChange={onChange}
history={[]}
range={range}
data={data}
data-testid={testIds.editor}
/>
);
}
export const testIds = {
editor: 'prom-editor-cloud-alerting',
};
@@ -0,0 +1,183 @@
import { getByTestId, render, screen, waitFor } from '@testing-library/react';
// @ts-ignore
import userEvent from '@testing-library/user-event';
import React from 'react';
import { CoreApp, DataFrame, LoadingState, PanelData } from '@grafana/data';
import { PrometheusDatasource } from '../datasource';
import PromQlLanguageProvider from '../language_provider';
import PromQueryField from './PromQueryField';
import { Props } from './monaco-query-field/MonacoQueryFieldProps';
// the monaco-based editor uses lazy-loading and that does not work
// well with this test, and we do not need the monaco-related
// functionality in this test anyway, so we mock it out.
jest.mock('./monaco-query-field/MonacoQueryFieldLazy', () => {
const fakeQueryField = (props: Props) => {
return <input onBlur={(e) => props.onBlur(e.currentTarget.value)} data-testid={'dummy-code-input'} type={'text'} />;
};
return {
MonacoQueryFieldLazy: fakeQueryField,
};
});
const defaultProps = {
datasource: {
languageProvider: {
start: () => Promise.resolve([]),
syntax: () => {},
getLabelKeys: () => [],
metrics: [],
},
getInitHints: () => [],
} as unknown as PrometheusDatasource,
query: {
expr: '',
refId: '',
},
onRunQuery: () => {},
onChange: () => {},
history: [],
};
describe('PromQueryField', () => {
beforeAll(() => {
// @ts-ignore
window.getSelection = () => {};
});
it('renders metrics chooser regularly if lookups are not disabled in the datasource settings', async () => {
const queryField = render(<PromQueryField {...defaultProps} />);
// wait for component to render
await screen.findByRole('button');
expect(queryField.getAllByRole('button')).toHaveLength(1);
});
it('renders a disabled metrics chooser if lookups are disabled in datasource settings', async () => {
const props = defaultProps;
props.datasource.lookupsDisabled = true;
const queryField = render(<PromQueryField {...props} />);
// wait for component to render
await screen.findByRole('button');
const bcButton = queryField.getByRole('button');
expect(bcButton).toBeDisabled();
});
it('renders an initial hint if no data and initial hint provided', async () => {
const props = defaultProps;
props.datasource.lookupsDisabled = true;
props.datasource.getInitHints = () => [{ label: 'Initial hint', type: 'INFO' }];
render(<PromQueryField {...props} />);
// wait for component to render
await screen.findByRole('button');
expect(screen.getByText('Initial hint')).toBeInTheDocument();
});
it('renders query hint if data, query hint and initial hint provided', async () => {
const props = defaultProps;
props.datasource.lookupsDisabled = true;
props.datasource.getInitHints = () => [{ label: 'Initial hint', type: 'INFO' }];
props.datasource.getQueryHints = () => [{ label: 'Query hint', type: 'INFO' }];
render(
<PromQueryField
{...props}
data={
{
series: [{ name: 'test name' }] as DataFrame[],
state: LoadingState.Done,
} as PanelData
}
/>
);
// wait for component to render
await screen.findByRole('button');
expect(screen.getByText('Query hint')).toBeInTheDocument();
expect(screen.queryByText('Initial hint')).not.toBeInTheDocument();
});
it('refreshes metrics when the data source changes', async () => {
const defaultProps = {
query: { expr: '', refId: '' },
onRunQuery: () => {},
onChange: () => {},
history: [],
};
const metrics = ['foo', 'bar'];
const queryField = render(
<PromQueryField
datasource={
{
languageProvider: makeLanguageProvider({ metrics: [metrics] }),
getInitHints: () => [],
} as unknown as PrometheusDatasource
}
{...defaultProps}
/>
);
// wait for component to render
await screen.findByRole('button');
const changedMetrics = ['baz', 'moo'];
queryField.rerender(
<PromQueryField
// @ts-ignore
datasource={{
languageProvider: makeLanguageProvider({ metrics: [changedMetrics] }),
}}
{...defaultProps}
/>
);
// If we check the label browser right away it should be in loading state
let labelBrowser = screen.getByRole('button');
expect(labelBrowser).toHaveTextContent('Loading');
// wait for component to rerender
labelBrowser = await screen.findByRole('button');
await waitFor(() => {
expect(labelBrowser).toHaveTextContent('Metrics browser');
});
});
it('should not run query onBlur', async () => {
const onRunQuery = jest.fn();
const { container } = render(<PromQueryField {...defaultProps} app={CoreApp.Explore} onRunQuery={onRunQuery} />);
// wait for component to rerender
await screen.findByRole('button');
const input = getByTestId(container, 'dummy-code-input');
expect(input).toBeInTheDocument();
await userEvent.type(input, 'metric');
// blur element
await userEvent.click(document.body);
expect(onRunQuery).not.toHaveBeenCalled();
});
});
function makeLanguageProvider(options: { metrics: string[][] }) {
const metricsStack = [...options.metrics];
return {
histogramMetrics: [],
metrics: [],
metricsMetadata: {},
lookupsDisabled: false,
getLabelKeys: () => [],
start() {
this.metrics = metricsStack.shift();
return Promise.resolve([]);
},
} as any as PromQlLanguageProvider;
}
@@ -0,0 +1,290 @@
import { cx } from '@emotion/css';
import React, { ReactNode } from 'react';
import { isDataFrame, QueryEditorProps, QueryHint, TimeRange, toLegacyResponseData } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime';
import { clearButtonStyles, Icon, Themeable2, withTheme2 } from '@grafana/ui';
import { PrometheusDatasource } from '../datasource';
import { LocalStorageValueProvider } from '../gcopypaste/app/core/components/LocalStorageValueProvider';
import {
CancelablePromise,
isCancelablePromiseRejection,
makePromiseCancelable,
} from '../gcopypaste/app/core/utils/CancelablePromise';
import { roundMsToMin } from '../language_utils';
import { PromOptions, PromQuery } from '../types';
import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser';
import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper';
const LAST_USED_LABELS_KEY = 'grafana.datasources.prometheus.browser.labels';
function getChooserText(metricsLookupDisabled: boolean, hasSyntax: boolean, hasMetrics: boolean) {
if (metricsLookupDisabled) {
return '(Disabled)';
}
if (!hasSyntax) {
return 'Loading metrics...';
}
if (!hasMetrics) {
return '(No metrics found)';
}
return 'Metrics browser';
}
interface PromQueryFieldProps extends QueryEditorProps<PrometheusDatasource, PromQuery, PromOptions>, Themeable2 {
ExtraFieldElement?: ReactNode;
'data-testid'?: string;
}
interface PromQueryFieldState {
labelBrowserVisible: boolean;
syntaxLoaded: boolean;
hint: QueryHint | null;
}
class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> {
declare languageProviderInitializationPromise: CancelablePromise<any>;
constructor(props: PromQueryFieldProps) {
super(props);
this.state = {
labelBrowserVisible: false,
syntaxLoaded: false,
hint: null,
};
}
componentDidMount() {
if (this.props.datasource.languageProvider) {
this.refreshMetrics();
}
this.refreshHint();
}
componentWillUnmount() {
if (this.languageProviderInitializationPromise) {
this.languageProviderInitializationPromise.cancel();
}
}
componentDidUpdate(prevProps: PromQueryFieldProps) {
const {
data,
datasource: { languageProvider },
range,
} = this.props;
if (languageProvider !== prevProps.datasource.languageProvider) {
// We reset this only on DS change so we do not flesh loading state on every rangeChange which happens on every
// query run if using relative range.
this.setState({
syntaxLoaded: false,
});
}
const changedRangeToRefresh = this.rangeChangedToRefresh(range, prevProps.range);
// We want to refresh metrics when language provider changes and/or when range changes (we round up intervals to a minute)
if (languageProvider !== prevProps.datasource.languageProvider || changedRangeToRefresh) {
this.refreshMetrics();
}
if (data && prevProps.data && prevProps.data.series !== data.series) {
this.refreshHint();
}
}
refreshHint = () => {
const { datasource, query, data } = this.props;
const initHints = datasource.getInitHints();
const initHint = initHints.length > 0 ? initHints[0] : null;
if (!data || data.series.length === 0) {
this.setState({
hint: initHint,
});
return;
}
const result = isDataFrame(data.series[0]) ? data.series.map(toLegacyResponseData) : data.series;
const queryHints = datasource.getQueryHints(query, result);
let queryHint = queryHints.length > 0 ? queryHints[0] : null;
this.setState({ hint: queryHint ?? initHint });
};
refreshMetrics = async () => {
const {
range,
datasource: { languageProvider },
} = this.props;
this.languageProviderInitializationPromise = makePromiseCancelable(languageProvider.start(range));
try {
const remainingTasks = await this.languageProviderInitializationPromise.promise;
await Promise.all(remainingTasks);
this.onUpdateLanguage();
} catch (err) {
if (isCancelablePromiseRejection(err) && err.isCanceled) {
// do nothing, promise was canceled
} else {
throw err;
}
}
};
rangeChangedToRefresh(range?: TimeRange, prevRange?: TimeRange): boolean {
if (range && prevRange) {
const sameMinuteFrom = roundMsToMin(range.from.valueOf()) === roundMsToMin(prevRange.from.valueOf());
const sameMinuteTo = roundMsToMin(range.to.valueOf()) === roundMsToMin(prevRange.to.valueOf());
// If both are same, don't need to refresh.
return !(sameMinuteFrom && sameMinuteTo);
}
return false;
}
/**
* TODO #33976: Remove this, add histogram group (query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;)
*/
onChangeLabelBrowser = (selector: string) => {
this.onChangeQuery(selector, true);
this.setState({ labelBrowserVisible: false });
};
onChangeQuery = (value: string, override?: boolean) => {
// Send text change to parent
const { query, onChange, onRunQuery } = this.props;
if (onChange) {
const nextQuery: PromQuery = { ...query, expr: value };
onChange(nextQuery);
if (override && onRunQuery) {
onRunQuery();
}
}
};
onClickChooserButton = () => {
this.setState((state) => ({ labelBrowserVisible: !state.labelBrowserVisible }));
reportInteraction('user_grafana_prometheus_metrics_browser_clicked', {
editorMode: this.state.labelBrowserVisible ? 'metricViewClosed' : 'metricViewOpen',
app: this.props?.app ?? '',
});
};
onClickHintFix = () => {
const { datasource, query, onChange, onRunQuery } = this.props;
const { hint } = this.state;
if (hint?.fix?.action) {
onChange(datasource.modifyQuery(query, hint.fix.action));
}
onRunQuery();
};
onUpdateLanguage = () => {
const {
datasource: { languageProvider },
} = this.props;
const { metrics } = languageProvider;
if (!metrics) {
return;
}
this.setState({ syntaxLoaded: true });
};
render() {
const {
datasource,
datasource: { languageProvider },
query,
ExtraFieldElement,
history = [],
theme,
} = this.props;
const { labelBrowserVisible, syntaxLoaded, hint } = this.state;
const hasMetrics = languageProvider.metrics.length > 0;
const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics);
const buttonDisabled = !(syntaxLoaded && hasMetrics);
return (
<LocalStorageValueProvider<string[]> storageKey={LAST_USED_LABELS_KEY} defaultValue={[]}>
{(lastUsedLabels, onLastUsedLabelsSave, onLastUsedLabelsDelete) => {
return (
<>
<div
className="gf-form-inline gf-form-inline--xs-view-flex-column flex-grow-1"
data-testid={this.props['data-testid']}
>
<button
className="gf-form-label query-keyword pointer"
onClick={this.onClickChooserButton}
disabled={buttonDisabled}
type="button"
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.openButton}
>
{chooserText}
<Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} />
</button>
<div className="gf-form gf-form--grow flex-shrink-1 min-width-15">
<MonacoQueryFieldWrapper
languageProvider={languageProvider}
history={history}
onChange={this.onChangeQuery}
onRunQuery={this.props.onRunQuery}
initialValue={query.expr ?? ''}
placeholder="Enter a PromQL query…"
datasource={datasource}
/>
</div>
</div>
{labelBrowserVisible && (
<div className="gf-form">
<PrometheusMetricsBrowser
languageProvider={languageProvider}
onChange={this.onChangeLabelBrowser}
lastUsedLabels={lastUsedLabels || []}
storeLastUsedLabels={onLastUsedLabelsSave}
deleteLastUsedLabels={onLastUsedLabelsDelete}
timeRange={this.props.range}
/>
</div>
)}
{ExtraFieldElement}
{hint ? (
<div className="query-row-break">
<div className="prom-query-field-info text-warning">
{hint.label}{' '}
{hint.fix ? (
<button
type="button"
className={cx(clearButtonStyles(theme), 'text-link', 'muted')}
onClick={this.onClickHintFix}
>
{hint.fix.label}
</button>
) : null}
</div>
</div>
) : null}
</>
);
}}
</LocalStorageValueProvider>
);
}
}
export default withTheme2(PromQueryField);
@@ -0,0 +1,324 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { createTheme } from '@grafana/data';
import PromQlLanguageProvider from '../language_provider';
import {
BrowserProps,
buildSelector,
facetLabels,
SelectableLabel,
UnthemedPrometheusMetricsBrowser,
} from './PrometheusMetricsBrowser';
describe('buildSelector()', () => {
it('returns an empty selector for no labels', () => {
expect(buildSelector([])).toEqual('{}');
});
it('returns an empty selector for selected labels with no values', () => {
const labels: SelectableLabel[] = [{ name: 'foo', selected: true }];
expect(buildSelector(labels)).toEqual('{}');
});
it('returns an empty selector for one selected label with no selected values', () => {
const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar' }] }];
expect(buildSelector(labels)).toEqual('{}');
});
it('returns a simple selector from a selected label with a selected value', () => {
const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar', selected: true }] }];
expect(buildSelector(labels)).toEqual('{foo="bar"}');
});
it('metric selector without labels', () => {
const labels: SelectableLabel[] = [{ name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] }];
expect(buildSelector(labels)).toEqual('foo{}');
});
it('selector with multiple metrics', () => {
const labels: SelectableLabel[] = [
{
name: '__name__',
selected: true,
values: [
{ name: 'foo', selected: true },
{ name: 'bar', selected: true },
],
},
];
expect(buildSelector(labels)).toEqual('{__name__=~"foo|bar"}');
});
it('metric selector with labels', () => {
const labels: SelectableLabel[] = [
{ name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] },
{ name: 'bar', selected: true, values: [{ name: 'baz', selected: true }] },
];
expect(buildSelector(labels)).toEqual('foo{bar="baz"}');
});
});
describe('facetLabels()', () => {
const possibleLabels = {
cluster: ['dev'],
namespace: ['alertmanager'],
};
const labels: SelectableLabel[] = [
{ name: 'foo', selected: true, values: [{ name: 'bar' }] },
{ name: 'cluster', values: [{ name: 'dev' }, { name: 'ops' }, { name: 'prod' }] },
{ name: 'namespace', values: [{ name: 'alertmanager' }] },
];
it('returns no labels given an empty label set', () => {
expect(facetLabels([], {})).toEqual([]);
});
it('marks all labels as hidden when no labels are possible', () => {
const result = facetLabels(labels, {});
expect(result.length).toEqual(labels.length);
expect(result[0].hidden).toBeTruthy();
expect(result[0].values).toBeUndefined();
});
it('keeps values as facetted when they are possible', () => {
const result = facetLabels(labels, possibleLabels);
expect(result.length).toEqual(labels.length);
expect(result[0].hidden).toBeTruthy();
expect(result[0].values).toBeUndefined();
expect(result[1].hidden).toBeFalsy();
expect(result[1].values!.length).toBe(1);
expect(result[1].values![0].name).toBe('dev');
});
it('does not facet out label values that are currently being facetted', () => {
const result = facetLabels(labels, possibleLabels, 'cluster');
expect(result.length).toEqual(labels.length);
expect(result[0].hidden).toBeTruthy();
expect(result[1].hidden).toBeFalsy();
// 'cluster' is being facetted, should show all 3 options even though only 1 is possible
expect(result[1].values!.length).toBe(3);
expect(result[2].values!.length).toBe(1);
});
});
describe('PrometheusMetricsBrowser', () => {
const setupProps = (): BrowserProps => {
const mockLanguageProvider = {
start: () => Promise.resolve(),
getLabelValues: (name: string) => {
switch (name) {
case 'label1':
return ['value1-1', 'value1-2'];
case 'label2':
return ['value2-1', 'value2-2'];
case 'label3':
return ['value3-1', 'value3-2'];
}
return [];
},
// This must always call the series endpoint
// until we refactor all of the metrics browser
// to never use the series endpoint.
// The metrics browser expects both label names and label values.
// The labels endpoint with match does not supply label values
// and so using it breaks the metrics browser.
fetchSeriesLabels: (selector: string) => {
switch (selector) {
case '{label1="value1-1"}':
return { label1: ['value1-1'], label2: ['value2-1'], label3: ['value3-1'] };
case '{label1=~"value1-1|value1-2"}':
return { label1: ['value1-1', 'value1-2'], label2: ['value2-1'], label3: ['value3-1', 'value3-2'] };
}
// Allow full set by default
return {
label1: ['value1-1', 'value1-2'],
label2: ['value2-1', 'value2-2'],
};
},
getLabelKeys: () => ['label1', 'label2', 'label3'],
};
const defaults: BrowserProps = {
theme: createTheme({ colors: { mode: 'dark' } }),
onChange: () => {},
autoSelect: 0,
languageProvider: mockLanguageProvider as unknown as PromQlLanguageProvider,
lastUsedLabels: [],
storeLastUsedLabels: () => {},
deleteLastUsedLabels: () => {},
};
return defaults;
};
// Clear label selection manually because it's saved in localStorage
afterEach(async () => {
const clearBtn = screen.getByLabelText('Selector clear button');
await userEvent.click(clearBtn);
});
it('renders and loader shows when empty, and then first set of labels', async () => {
const props = setupProps();
render(<UnthemedPrometheusMetricsBrowser {...props} />);
// Loading appears and dissappears
screen.getByText(/Loading labels/);
await waitFor(() => {
expect(screen.queryByText(/Loading labels/)).not.toBeInTheDocument();
});
// Initial set of labels is available and not selected
expect(screen.queryByRole('option', { name: 'label1' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'label1', selected: true })).not.toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'label2' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'label2', selected: true })).not.toBeInTheDocument();
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}');
});
it('allows label and value selection/deselection', async () => {
const props = setupProps();
render(<UnthemedPrometheusMetricsBrowser {...props} />);
// Selecting label2
const label2 = await screen.findByRole('option', { name: 'label2', selected: false });
expect(screen.queryByRole('list', { name: /Values/ })).not.toBeInTheDocument();
await userEvent.click(label2);
expect(screen.queryByRole('option', { name: 'label2', selected: true })).toBeInTheDocument();
// List of values for label2 appears
expect(screen.queryByLabelText(/Values for/)).toHaveTextContent('label2');
expect(screen.queryByRole('option', { name: 'value2-1' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'value2-2' })).toBeInTheDocument();
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}');
// Selecting label1, list for its values appears
const label1 = await screen.findByRole('option', { name: 'label1', selected: false });
await userEvent.click(label1);
expect(screen.queryByRole('option', { name: 'label1', selected: true })).toBeInTheDocument();
await screen.findByLabelText('Values for label1');
expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(2);
// Selecting value2-2 of label2
const value = await screen.findByRole('option', { name: 'value2-2', selected: false });
await userEvent.click(value);
await screen.findByRole('option', { name: 'value2-2', selected: true });
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-2"}');
// Selecting value2-1 of label2, both values now selected
const value2 = await screen.findByRole('option', { name: 'value2-1', selected: false });
await userEvent.click(value2);
// await screen.findByRole('option', {name: 'value2-1', selected: true});
await screen.findByText('{label2=~"value2-1|value2-2"}');
// Deselecting value2-2, one value should remain
const selectedValue = await screen.findByRole('option', { name: 'value2-2', selected: true });
await userEvent.click(selectedValue);
await screen.findByRole('option', { name: 'value2-1', selected: true });
await screen.findByRole('option', { name: 'value2-2', selected: false });
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-1"}');
});
it('allows label selection from multiple labels', async () => {
const props = setupProps();
render(<UnthemedPrometheusMetricsBrowser {...props} />);
// Selecting label2
const label2 = await screen.findByRole('option', { name: 'label2', selected: false });
await userEvent.click(label2);
// List of values for label2 appears
expect(screen.queryByLabelText(/Values for/)).toHaveTextContent('label2');
expect(screen.queryByRole('option', { name: 'value2-1' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'value2-2' })).toBeInTheDocument();
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}');
// Selecting label1, list for its values appears
const label1 = await screen.findByRole('option', { name: 'label1', selected: false });
await userEvent.click(label1);
await screen.findByLabelText('Values for label1');
expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(2);
// Selecting value2-1 of label2
const value2 = await screen.findByRole('option', { name: 'value2-1', selected: false });
await userEvent.click(value2);
await screen.findByText('{label2="value2-1"}');
// Selecting value from label1 for combined selector
const value1 = await screen.findByRole('option', { name: 'value1-2', selected: false });
await userEvent.click(value1);
await screen.findByRole('option', { name: 'value1-2', selected: true });
await screen.findByText('{label1="value1-2",label2="value2-1"}');
// Deselect label1 should remove label and value
const selectedLabel = (await screen.findAllByRole('option', { name: /label1/, selected: true }))[0];
await userEvent.click(selectedLabel);
await screen.findByRole('option', { name: /label1/, selected: false });
expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(1);
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-1"}');
});
it('allows clearing the label selection', async () => {
const props = setupProps();
render(<UnthemedPrometheusMetricsBrowser {...props} />);
// Selecting label2
const label2 = await screen.findByRole('option', { name: 'label2', selected: false });
await userEvent.click(label2);
// List of values for label2 appears
expect(screen.queryByLabelText(/Values for/)).toHaveTextContent('label2');
expect(screen.queryByRole('option', { name: 'value2-1' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'value2-2' })).toBeInTheDocument();
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}');
// Selecting label1, list for its values appears
const label1 = await screen.findByRole('option', { name: 'label1', selected: false });
await userEvent.click(label1);
await screen.findByLabelText('Values for label1');
expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(2);
// Selecting value2-1 of label2
const value2 = await screen.findByRole('option', { name: 'value2-1', selected: false });
await userEvent.click(value2);
await screen.findByText('{label2="value2-1"}');
// Clear selector
const clearBtn = screen.getByLabelText('Selector clear button');
await userEvent.click(clearBtn);
await screen.findByRole('option', { name: 'label2', selected: false });
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}');
});
it('filters values by input text', async () => {
const props = setupProps();
render(<UnthemedPrometheusMetricsBrowser {...props} />);
// Selecting label2 and label1
const label2 = await screen.findByRole('option', { name: /label2/, selected: false });
await userEvent.click(label2);
const label1 = await screen.findByRole('option', { name: /label1/, selected: false });
await userEvent.click(label1);
await screen.findByLabelText('Values for label1');
await screen.findByLabelText('Values for label2');
expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4);
// Typing '1' to filter for values
await userEvent.type(screen.getByLabelText('Filter expression for label values'), '1');
expect(screen.getByLabelText('Filter expression for label values')).toHaveValue('1');
expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(3);
expect(screen.queryByRole('option', { name: 'value2-2' })).not.toBeInTheDocument();
});
it('facets labels', async () => {
const props = setupProps();
render(<UnthemedPrometheusMetricsBrowser {...props} />);
// Selecting label2 and label1
const label2 = await screen.findByRole('option', { name: /label2/, selected: false });
await userEvent.click(label2);
const label1 = await screen.findByRole('option', { name: /label1/, selected: false });
await userEvent.click(label1);
await screen.findByLabelText('Values for label1');
await screen.findByLabelText('Values for label2');
expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4);
expect(screen.queryByRole('option', { name: /label3/ })).toHaveTextContent('label3');
// Click value1-1 which triggers facetting for value3-x, and still show all value1-x
const value1 = await screen.findByRole('option', { name: 'value1-1', selected: false });
await userEvent.click(value1);
await waitFor(() => expect(screen.queryByRole('option', { name: 'value2-2' })).not.toBeInTheDocument());
expect(screen.queryByRole('option', { name: 'value1-2' })).toBeInTheDocument();
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label1="value1-1"}');
expect(screen.queryByRole('option', { name: /label3/ })).toHaveTextContent('label3 (1)');
// Click value1-2 for which facetting will allow more values for value3-x
const value12 = await screen.findByRole('option', { name: 'value1-2', selected: false });
await userEvent.click(value12);
await screen.findByRole('option', { name: 'value1-2', selected: true });
await screen.findByRole('option', { name: /label3/, selected: false });
await userEvent.click(screen.getByRole('option', { name: /label3/ }));
await screen.findByLabelText('Values for label3');
expect(screen.queryByRole('option', { name: 'value1-1', selected: true })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'value1-2', selected: true })).toBeInTheDocument();
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label1=~"value1-1|value1-2"}');
expect(screen.queryAllByRole('option', { name: /label3/ })[0]).toHaveTextContent('label3 (2)');
});
});
@@ -0,0 +1,684 @@
import { css, cx } from '@emotion/css';
import React, { ChangeEvent } from 'react';
import { FixedSizeList } from 'react-window';
import { GrafanaTheme2, TimeRange } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import {
BrowserLabel as PromLabel,
Button,
HorizontalGroup,
Input,
Label,
LoadingPlaceholder,
stylesFactory,
withTheme2,
} from '@grafana/ui';
import PromQlLanguageProvider from '../language_provider';
import { escapeLabelValueInExactSelector, escapeLabelValueInRegexSelector } from '../language_utils';
// Hard limit on labels to render
const EMPTY_SELECTOR = '{}';
const METRIC_LABEL = '__name__';
const LIST_ITEM_SIZE = 25;
export interface BrowserProps {
languageProvider: PromQlLanguageProvider;
onChange: (selector: string) => void;
theme: GrafanaTheme2;
autoSelect?: number;
hide?: () => void;
lastUsedLabels: string[];
storeLastUsedLabels: (labels: string[]) => void;
deleteLastUsedLabels: () => void;
timeRange?: TimeRange;
}
interface BrowserState {
labels: SelectableLabel[];
labelSearchTerm: string;
metricSearchTerm: string;
status: string;
error: string;
validationStatus: string;
valueSearchTerm: string;
}
interface FacettableValue {
name: string;
selected?: boolean;
details?: string;
}
export interface SelectableLabel {
name: string;
selected?: boolean;
loading?: boolean;
values?: FacettableValue[];
hidden?: boolean;
facets?: number;
}
export function buildSelector(labels: SelectableLabel[]): string {
let singleMetric = '';
const selectedLabels = [];
for (const label of labels) {
if ((label.name === METRIC_LABEL || label.selected) && label.values && label.values.length > 0) {
const selectedValues = label.values.filter((value) => value.selected).map((value) => value.name);
if (selectedValues.length > 1) {
selectedLabels.push(`${label.name}=~"${selectedValues.map(escapeLabelValueInRegexSelector).join('|')}"`);
} else if (selectedValues.length === 1) {
if (label.name === METRIC_LABEL) {
singleMetric = selectedValues[0];
} else {
selectedLabels.push(`${label.name}="${escapeLabelValueInExactSelector(selectedValues[0])}"`);
}
}
}
}
return [singleMetric, '{', selectedLabels.join(','), '}'].join('');
}
export function facetLabels(
labels: SelectableLabel[],
possibleLabels: Record<string, string[]>,
lastFacetted?: string
): SelectableLabel[] {
return labels.map((label) => {
const possibleValues = possibleLabels[label.name];
if (possibleValues) {
let existingValues: FacettableValue[];
if (label.name === lastFacetted && label.values) {
// Facetting this label, show all values
existingValues = label.values;
} else {
// Keep selection in other facets
const selectedValues: Set<string> = new Set(
label.values?.filter((value) => value.selected).map((value) => value.name) || []
);
// Values for this label have not been requested yet, let's use the facetted ones as the initial values
existingValues = possibleValues.map((value) => ({ name: value, selected: selectedValues.has(value) }));
}
return {
...label,
loading: false,
values: existingValues,
hidden: !possibleValues,
facets: existingValues.length,
};
}
// Label is facetted out, hide all values
return { ...label, loading: false, hidden: !possibleValues, values: undefined, facets: 0 };
});
}
const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
wrapper: css`
background-color: ${theme.colors.background.secondary};
padding: ${theme.spacing(1)};
width: 100%;
`,
list: css`
margin-top: ${theme.spacing(1)};
display: flex;
flex-wrap: wrap;
max-height: 200px;
overflow: auto;
align-content: flex-start;
`,
section: css`
& + & {
margin: ${theme.spacing(2)} 0;
}
position: relative;
`,
selector: css`
font-family: ${theme.typography.fontFamilyMonospace};
margin-bottom: ${theme.spacing(1)};
`,
status: css`
padding: ${theme.spacing(0.5)};
color: ${theme.colors.text.secondary};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* using absolute positioning because flex interferes with ellipsis */
position: absolute;
width: 50%;
right: 0;
text-align: right;
transition: opacity 100ms linear;
opacity: 0;
`,
statusShowing: css`
opacity: 1;
`,
error: css`
color: ${theme.colors.error.main};
`,
valueList: css`
margin-right: ${theme.spacing(1)};
resize: horizontal;
`,
valueListWrapper: css`
border-left: 1px solid ${theme.colors.border.medium};
margin: ${theme.spacing(1)} 0;
padding: ${theme.spacing(1)} 0 ${theme.spacing(1)} ${theme.spacing(1)};
`,
valueListArea: css`
display: flex;
flex-wrap: wrap;
margin-top: ${theme.spacing(1)};
`,
valueTitle: css`
margin-left: -${theme.spacing(0.5)};
margin-bottom: ${theme.spacing(1)};
`,
validationStatus: css`
padding: ${theme.spacing(0.5)};
margin-bottom: ${theme.spacing(1)};
color: ${theme.colors.text.maxContrast};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`,
}));
/**
* TODO #33976: Remove duplicated code. The component is very similar to LokiLabelBrowser.tsx. Check if it's possible
* to create a single, generic component.
*/
export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserProps, BrowserState> {
valueListsRef = React.createRef<HTMLDivElement>();
state: BrowserState = {
labels: [],
labelSearchTerm: '',
metricSearchTerm: '',
status: 'Ready',
error: '',
validationStatus: '',
valueSearchTerm: '',
};
onChangeLabelSearch = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ labelSearchTerm: event.target.value });
};
onChangeMetricSearch = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ metricSearchTerm: event.target.value });
};
onChangeValueSearch = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ valueSearchTerm: event.target.value });
};
onClickRunQuery = () => {
const selector = buildSelector(this.state.labels);
this.props.onChange(selector);
};
onClickRunRateQuery = () => {
const selector = buildSelector(this.state.labels);
const query = `rate(${selector}[$__rate_interval])`;
this.props.onChange(query);
};
onClickClear = () => {
this.setState((state) => {
const labels: SelectableLabel[] = state.labels.map((label) => ({
...label,
values: undefined,
selected: false,
loading: false,
hidden: false,
facets: undefined,
}));
return {
labels,
labelSearchTerm: '',
metricSearchTerm: '',
status: '',
error: '',
validationStatus: '',
valueSearchTerm: '',
};
});
this.props.deleteLastUsedLabels();
// Get metrics
this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR);
};
onClickLabel = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
const label = this.state.labels.find((l) => l.name === name);
if (!label) {
return;
}
// Toggle selected state
const selected = !label.selected;
let nextValue: Partial<SelectableLabel> = { selected };
if (label.values && !selected) {
// Deselect all values if label was deselected
const values = label.values.map((value) => ({ ...value, selected: false }));
nextValue = { ...nextValue, facets: 0, values };
}
// Resetting search to prevent empty results
this.setState({ labelSearchTerm: '' });
this.updateLabelState(name, nextValue, '', () => this.doFacettingForLabel(name));
};
onClickValue = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
const label = this.state.labels.find((l) => l.name === name);
if (!label || !label.values) {
return;
}
// Resetting search to prevent empty results
this.setState({ labelSearchTerm: '' });
// Toggling value for selected label, leaving other values intact
const values = label.values.map((v) => ({ ...v, selected: v.name === value ? !v.selected : v.selected }));
this.updateLabelState(name, { values }, '', () => this.doFacetting(name));
};
onClickMetric = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
// Finding special metric label
const label = this.state.labels.find((l) => l.name === name);
if (!label || !label.values) {
return;
}
// Resetting search to prevent empty results
this.setState({ metricSearchTerm: '' });
// Toggling value for selected label, leaving other values intact
const values = label.values.map((v) => ({
...v,
selected: v.name === value || v.selected ? !v.selected : v.selected,
}));
// Toggle selected state of special metrics label
const selected = values.some((v) => v.selected);
this.updateLabelState(name, { selected, values }, '', () => this.doFacetting(name));
};
onClickValidate = () => {
const selector = buildSelector(this.state.labels);
this.validateSelector(selector);
};
updateLabelState(name: string, updatedFields: Partial<SelectableLabel>, status = '', cb?: () => void) {
this.setState((state) => {
const labels: SelectableLabel[] = state.labels.map((label) => {
if (label.name === name) {
return { ...label, ...updatedFields };
}
return label;
});
// New status overrides errors
const error = status ? '' : state.error;
return { labels, status, error, validationStatus: '' };
}, cb);
}
componentDidMount() {
const { languageProvider, lastUsedLabels } = this.props;
if (languageProvider) {
const selectedLabels: string[] = lastUsedLabels;
languageProvider.start(this.props.timeRange).then(() => {
let rawLabels: string[] = languageProvider.getLabelKeys();
// Get metrics
this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR);
// Auto-select previously selected labels
const labels: SelectableLabel[] = rawLabels.map((label, i, arr) => ({
name: label,
selected: selectedLabels.includes(label),
loading: false,
}));
// Pre-fetch values for selected labels
this.setState({ labels }, () => {
this.state.labels.forEach((label) => {
if (label.selected) {
this.fetchValues(label.name, EMPTY_SELECTOR);
}
});
});
});
}
}
doFacettingForLabel(name: string) {
const label = this.state.labels.find((l) => l.name === name);
if (!label) {
return;
}
const selectedLabels = this.state.labels.filter((label) => label.selected).map((label) => label.name);
this.props.storeLastUsedLabels(selectedLabels);
if (label.selected) {
// Refetch values for newly selected label...
if (!label.values) {
this.fetchValues(name, buildSelector(this.state.labels));
}
} else {
// Only need to facet when deselecting labels
this.doFacetting();
}
}
doFacetting = (lastFacetted?: string) => {
const selector = buildSelector(this.state.labels);
if (selector === EMPTY_SELECTOR) {
// Clear up facetting
const labels: SelectableLabel[] = this.state.labels.map((label) => {
return { ...label, facets: 0, values: undefined, hidden: false };
});
this.setState({ labels }, () => {
// Get fresh set of values
this.state.labels.forEach(
(label) => (label.selected || label.name === METRIC_LABEL) && this.fetchValues(label.name, selector)
);
});
} else {
// Do facetting
this.fetchSeries(selector, lastFacetted);
}
};
async fetchValues(name: string, selector: string) {
const { languageProvider } = this.props;
this.updateLabelState(name, { loading: true }, `Fetching values for ${name}`);
try {
let rawValues = await languageProvider.getLabelValues(name);
// If selector changed, clear loading state and discard result by returning early
if (selector !== buildSelector(this.state.labels)) {
this.updateLabelState(name, { loading: false });
return;
}
const values: FacettableValue[] = [];
const { metricsMetadata } = languageProvider;
for (const labelValue of rawValues) {
const value: FacettableValue = { name: labelValue };
// Adding type/help text to metrics
if (name === METRIC_LABEL && metricsMetadata) {
const meta = metricsMetadata[labelValue];
if (meta) {
value.details = `(${meta.type}) ${meta.help}`;
}
}
values.push(value);
}
this.updateLabelState(name, { values, loading: false });
} catch (error) {
console.error(error);
}
}
async fetchSeries(selector: string, lastFacetted?: string) {
const { languageProvider } = this.props;
if (lastFacetted) {
this.updateLabelState(lastFacetted, { loading: true }, `Facetting labels for ${selector}`);
}
try {
const possibleLabels = await languageProvider.fetchSeriesLabels(selector, true);
// If selector changed, clear loading state and discard result by returning early
if (selector !== buildSelector(this.state.labels)) {
if (lastFacetted) {
this.updateLabelState(lastFacetted, { loading: false });
}
return;
}
if (Object.keys(possibleLabels).length === 0) {
this.setState({ error: `Empty results, no matching label for ${selector}` });
return;
}
const labels: SelectableLabel[] = facetLabels(this.state.labels, possibleLabels, lastFacetted);
this.setState({ labels, error: '' });
if (lastFacetted) {
this.updateLabelState(lastFacetted, { loading: false });
}
} catch (error) {
console.error(error);
}
}
async validateSelector(selector: string) {
const { languageProvider } = this.props;
this.setState({ validationStatus: `Validating selector ${selector}`, error: '' });
const streams = await languageProvider.fetchSeries(selector);
this.setState({ validationStatus: `Selector is valid (${streams.length} series found)` });
}
render() {
const { theme } = this.props;
const { labels, labelSearchTerm, metricSearchTerm, status, error, validationStatus, valueSearchTerm } = this.state;
const styles = getStyles(theme);
if (labels.length === 0) {
return (
<div className={styles.wrapper}>
<LoadingPlaceholder text="Loading labels..." />
</div>
);
}
// Filter metrics
let metrics = labels.find((label) => label.name === METRIC_LABEL);
if (metrics && metricSearchTerm) {
metrics = {
...metrics,
values: metrics.values?.filter((value) => value.selected || value.name.includes(metricSearchTerm)),
};
}
// Filter labels
let nonMetricLabels = labels.filter((label) => !label.hidden && label.name !== METRIC_LABEL);
if (labelSearchTerm) {
nonMetricLabels = nonMetricLabels.filter((label) => label.selected || label.name.includes(labelSearchTerm));
}
// Filter non-metric label values
let selectedLabels = nonMetricLabels.filter((label) => label.selected && label.values);
if (valueSearchTerm) {
selectedLabels = selectedLabels.map((label) => ({
...label,
values: label.values?.filter((value) => value.selected || value.name.includes(valueSearchTerm)),
}));
}
const selector = buildSelector(this.state.labels);
const empty = selector === EMPTY_SELECTOR;
const metricCount = metrics?.values?.length || 0;
return (
<div className={styles.wrapper}>
<HorizontalGroup align="flex-start" spacing="lg">
<div>
<div className={styles.section}>
<Label description="Once a metric is selected only possible labels are shown.">1. Select a metric</Label>
<div>
<Input
onChange={this.onChangeMetricSearch}
aria-label="Filter expression for metric"
value={metricSearchTerm}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.selectMetric}
/>
</div>
<div
role="list"
className={styles.valueListWrapper}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.metricList}
>
<FixedSizeList
height={Math.min(450, metricCount * LIST_ITEM_SIZE)}
itemCount={metricCount}
itemSize={LIST_ITEM_SIZE}
itemKey={(i) => metrics!.values![i].name}
width={300}
className={styles.valueList}
>
{({ index, style }) => {
const value = metrics?.values?.[index];
if (!value) {
return null;
}
return (
<div style={style}>
<PromLabel
name={metrics!.name}
value={value?.name}
title={value.details}
active={value?.selected}
onClick={this.onClickMetric}
searchTerm={metricSearchTerm}
/>
</div>
);
}}
</FixedSizeList>
</div>
</div>
</div>
<div>
<div className={styles.section}>
<Label description="Once label values are selected, only possible label combinations are shown.">
2. Select labels to search in
</Label>
<div>
<Input
onChange={this.onChangeLabelSearch}
aria-label="Filter expression for label"
value={labelSearchTerm}
data-testid={
selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelNamesFilter
}
/>
</div>
{/* Using fixed height here to prevent jumpy layout */}
<div className={styles.list} style={{ height: 120 }}>
{nonMetricLabels.map((label) => (
<PromLabel
key={label.name}
name={label.name}
loading={label.loading}
active={label.selected}
hidden={label.hidden}
facets={label.facets}
onClick={this.onClickLabel}
searchTerm={labelSearchTerm}
/>
))}
</div>
</div>
<div className={styles.section}>
<Label description="Use the search field to find values across selected labels.">
3. Select (multiple) values for your labels
</Label>
<div>
<Input
onChange={this.onChangeValueSearch}
aria-label="Filter expression for label values"
value={valueSearchTerm}
data-testid={
selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelValuesFilter
}
/>
</div>
<div className={styles.valueListArea} ref={this.valueListsRef}>
{selectedLabels.map((label) => (
<div
role="list"
key={label.name}
aria-label={`Values for ${label.name}`}
className={styles.valueListWrapper}
>
<div className={styles.valueTitle}>
<PromLabel
name={label.name}
loading={label.loading}
active={label.selected}
hidden={label.hidden}
//If no facets, we want to show number of all label values
facets={label.facets || label.values?.length}
onClick={this.onClickLabel}
/>
</div>
<FixedSizeList
height={Math.min(200, LIST_ITEM_SIZE * (label.values?.length || 0))}
itemCount={label.values?.length || 0}
itemSize={28}
itemKey={(i) => label.values![i].name}
width={200}
className={styles.valueList}
>
{({ index, style }) => {
const value = label.values?.[index];
if (!value) {
return null;
}
return (
<div style={style}>
<PromLabel
name={label.name}
value={value?.name}
active={value?.selected}
onClick={this.onClickValue}
searchTerm={valueSearchTerm}
/>
</div>
);
}}
</FixedSizeList>
</div>
))}
</div>
</div>
</div>
</HorizontalGroup>
<div className={styles.section}>
<Label>4. Resulting selector</Label>
<div aria-label="selector" className={styles.selector}>
{selector}
</div>
{validationStatus && <div className={styles.validationStatus}>{validationStatus}</div>}
<HorizontalGroup>
<Button
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useQuery}
aria-label="Use selector for query button"
disabled={empty}
onClick={this.onClickRunQuery}
>
Use query
</Button>
<Button
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.useAsRateQuery}
aria-label="Use selector as metrics button"
variant="secondary"
disabled={empty}
onClick={this.onClickRunRateQuery}
>
Use as rate query
</Button>
<Button
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.validateSelector}
aria-label="Validate submit button"
variant="secondary"
disabled={empty}
onClick={this.onClickValidate}
>
Validate selector
</Button>
<Button
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.clear}
aria-label="Selector clear button"
variant="secondary"
onClick={this.onClickClear}
>
Clear
</Button>
<div className={cx(styles.status, (status || error) && styles.statusShowing)}>
<span className={error ? styles.error : ''}>{error || status}</span>
</div>
</HorizontalGroup>
</div>
</div>
);
}
}
export const PrometheusMetricsBrowser = withTheme2(UnthemedPrometheusMetricsBrowser);
@@ -0,0 +1,393 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { dateTime, TimeRange } from '@grafana/data';
import { PrometheusDatasource } from '../datasource';
import { selectOptionInTest } from '../gcopypaste/test/helpers/selectOptionInTest';
import PrometheusLanguageProvider from '../language_provider';
import { migrateVariableEditorBackToVariableSupport } from '../migrations/variableMigration';
import { PromVariableQuery, PromVariableQueryType, StandardPromVariableQuery } from '../types';
import { PromVariableQueryEditor, Props, variableMigration } from './VariableQueryEditor';
const refId = 'PrometheusVariableQueryEditor-VariableQuery';
describe('PromVariableQueryEditor', () => {
let props: Props;
test('Migrates from standard variable support to custom variable query', () => {
const query: StandardPromVariableQuery = {
query: 'label_names()',
refId: 'StandardVariableQuery',
};
const migration: PromVariableQuery = variableMigration(query);
const expected: PromVariableQuery = {
qryType: PromVariableQueryType.LabelNames,
refId: 'PrometheusDatasource-VariableQuery',
};
expect(migration).toEqual(expected);
});
test('Allows for use of variables to interpolate label names in the label values query type.', () => {
const query: StandardPromVariableQuery = {
query: 'label_values($label_name)',
refId: 'StandardVariableQuery',
};
const migration: PromVariableQuery = variableMigration(query);
const expected: PromVariableQuery = {
qryType: PromVariableQueryType.LabelValues,
label: '$label_name',
refId: 'PrometheusDatasource-VariableQuery',
};
expect(migration).toEqual(expected);
});
test('Migrates from jsonnet grafana as code variable to custom variable query', () => {
const query = 'label_names()';
const migration: PromVariableQuery = variableMigration(query);
const expected: PromVariableQuery = {
qryType: PromVariableQueryType.LabelNames,
refId: 'PrometheusDatasource-VariableQuery',
};
expect(migration).toEqual(expected);
});
test('Migrates label filters to the query object for label_values()', () => {
const query: StandardPromVariableQuery = {
query: 'label_values(metric{label="value"},name)',
refId: 'StandardVariableQuery',
};
const migration: PromVariableQuery = variableMigration(query);
const expected: PromVariableQuery = {
qryType: PromVariableQueryType.LabelValues,
label: 'name',
metric: 'metric',
labelFilters: [
{
label: 'label',
op: '=',
value: 'value',
},
],
refId: 'PrometheusDatasource-VariableQuery',
};
expect(migration).toEqual(expected);
});
test('Migrates a query object with label filters to an expression correctly', () => {
const query: PromVariableQuery = {
qryType: PromVariableQueryType.LabelValues,
label: 'name',
metric: 'metric',
labelFilters: [
{
label: 'label',
op: '=',
value: 'value',
},
],
refId: 'PrometheusDatasource-VariableQuery',
};
const migration: string = migrateVariableEditorBackToVariableSupport(query);
const expected = 'label_values(metric{label="value"},name)';
expect(migration).toEqual(expected);
});
test('Migrates a query object with no metric and only label filters to an expression correctly', () => {
const query: PromVariableQuery = {
qryType: PromVariableQueryType.LabelValues,
label: 'name',
labelFilters: [
{
label: 'label',
op: '=',
value: 'value',
},
],
refId: 'PrometheusDatasource-VariableQuery',
};
const migration: string = migrateVariableEditorBackToVariableSupport(query);
const expected = 'label_values({label="value"},name)';
expect(migration).toEqual(expected);
});
beforeEach(() => {
props = {
datasource: {
hasLabelsMatchAPISupport: () => true,
languageProvider: {
start: () => Promise.resolve([]),
syntax: () => {},
getLabelKeys: () => [],
metrics: [],
metricsMetadata: {},
getLabelValues: jest.fn().mockImplementation(() => ['that']),
fetchLabelsWithMatch: jest.fn().mockImplementation(() => Promise.resolve({ those: 'those' })),
} as Partial<PrometheusLanguageProvider> as PrometheusLanguageProvider,
getInitHints: () => [],
getDebounceTimeInMilliseconds: jest.fn(),
getTagKeys: jest.fn().mockImplementation(() => Promise.resolve(['this'])),
getVariables: jest.fn().mockImplementation(() => []),
metricFindQuery: jest.fn().mockImplementation(() => Promise.resolve(['that'])),
getSeriesLabels: jest.fn().mockImplementation(() => Promise.resolve(['that'])),
} as Partial<PrometheusDatasource> as PrometheusDatasource,
query: {
refId: 'test',
query: 'label_names()',
},
onRunQuery: () => {},
onChange: () => {},
history: [],
};
});
test('Displays a group of function options', async () => {
render(<PromVariableQueryEditor {...props} />);
const select = screen.getByLabelText('Query type').parentElement!;
await userEvent.click(select);
await waitFor(() => expect(screen.getAllByText('Label names')).toHaveLength(2));
await waitFor(() => expect(screen.getByText('Label values')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Metrics')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Query result')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Series query')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Classic query')).toBeInTheDocument());
});
test('Calls onChange for label_names(match) query', async () => {
const onChange = jest.fn();
props.query = {
refId: 'test',
query: '',
match: 'that',
};
render(<PromVariableQueryEditor {...props} onChange={onChange} />);
await selectOptionInTest(screen.getByLabelText('Query type'), 'Label names');
expect(onChange).toHaveBeenCalledWith({
query: 'label_names(that)',
refId,
qryType: 0,
});
});
test('Calls onChange for label_names, label_values, metrics, query result and and classic query.', async () => {
const onChange = jest.fn();
props.query = {
refId: 'test',
query: '',
};
render(<PromVariableQueryEditor {...props} onChange={onChange} />);
await selectOptionInTest(screen.getByLabelText('Query type'), 'Label names');
await selectOptionInTest(screen.getByLabelText('Query type'), 'Label values');
await selectOptionInTest(screen.getByLabelText('Query type'), 'Metrics');
await selectOptionInTest(screen.getByLabelText('Query type'), 'Query result');
await selectOptionInTest(screen.getByLabelText('Query type'), 'Classic query');
expect(onChange).toHaveBeenCalledTimes(5);
});
test('Does not call onChange for series query', async () => {
const onChange = jest.fn();
render(<PromVariableQueryEditor {...props} onChange={onChange} />);
await selectOptionInTest(screen.getByLabelText('Query type'), 'Series query');
expect(onChange).not.toHaveBeenCalled();
});
test('Calls onChange for metrics() after input', async () => {
const onChange = jest.fn();
props.query = {
refId: 'test',
query: 'label_names()',
};
render(<PromVariableQueryEditor {...props} onChange={onChange} />);
await selectOptionInTest(screen.getByLabelText('Query type'), 'Metrics');
const metricInput = screen.getByLabelText('Metric selector');
await userEvent.type(metricInput, 'a').then((prom) => {
const queryType = screen.getByLabelText('Query type');
// click elsewhere to trigger the onBlur
return userEvent.click(queryType);
});
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith({
query: 'metrics(a)',
refId,
qryType: 2,
})
);
});
test('Calls onChange for label_values() after selecting label', async () => {
const onChange = jest.fn();
props.query = {
refId: 'test',
query: 'label_names()',
qryType: 0,
};
render(<PromVariableQueryEditor {...props} onChange={onChange} />);
await selectOptionInTest(screen.getByLabelText('Query type'), 'Label values');
const labelSelect = screen.getByLabelText('label-select');
await userEvent.type(labelSelect, 'this');
await selectOptionInTest(labelSelect, 'this');
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith({
query: 'label_values(this)',
refId,
qryType: 1,
})
);
});
test('Calls onChange for label_values() after selecting metric', async () => {
const onChange = jest.fn();
props.query = {
refId: 'test',
query: 'label_names()',
};
render(<PromVariableQueryEditor {...props} onChange={onChange} />);
await selectOptionInTest(screen.getByLabelText('Query type'), 'Label values');
const labelSelect = screen.getByLabelText('label-select');
await userEvent.type(labelSelect, 'this');
await selectOptionInTest(labelSelect, 'this');
const metricSelect = screen.getByLabelText('Metric');
await userEvent.type(metricSelect, 'that');
await selectOptionInTest(metricSelect, 'that');
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith({
query: 'label_values(that,this)',
refId,
qryType: 1,
})
);
});
test('Calls onChange for query_result() with argument onBlur', async () => {
const onChange = jest.fn();
props.query = {
refId: 'test',
query: 'query_result(a)',
};
render(<PromVariableQueryEditor {...props} onChange={onChange} />);
const labelSelect = screen.getByLabelText('Prometheus Query');
await userEvent.click(labelSelect);
const functionSelect = screen.getByLabelText('Query type').parentElement!;
await userEvent.click(functionSelect);
expect(onChange).toHaveBeenCalledWith({
query: 'query_result(a)',
refId,
qryType: 3,
});
});
test('Calls onChange for Match[] series with argument onBlur', async () => {
const onChange = jest.fn();
props.query = {
refId: 'test',
query: '{a: "example"}',
};
render(<PromVariableQueryEditor {...props} onChange={onChange} />);
const labelSelect = screen.getByLabelText('Series Query');
await userEvent.click(labelSelect);
const functionSelect = screen.getByLabelText('Query type').parentElement!;
await userEvent.click(functionSelect);
expect(onChange).toHaveBeenCalledWith({
query: '{a: "example"}',
refId,
qryType: 4,
});
});
test('Calls onChange for classic query onBlur', async () => {
const onChange = jest.fn();
props.query = {
refId: 'test',
qryType: 5,
query: 'label_values(instance)',
};
render(<PromVariableQueryEditor {...props} onChange={onChange} />);
const labelSelect = screen.getByLabelText('Classic Query');
await userEvent.click(labelSelect);
const functionSelect = screen.getByLabelText('Query type').parentElement!;
await userEvent.click(functionSelect);
expect(onChange).toHaveBeenCalledWith({
query: 'label_values(instance)',
refId,
qryType: 5,
});
});
test('Calls language provider with the time range received in props', async () => {
const now = dateTime('2023-09-16T21:26:00Z');
const range: TimeRange = {
from: dateTime(now).subtract(2, 'days'),
to: now,
raw: {
from: 'now-2d',
to: 'now',
},
};
props.range = range;
const languageProviderStartMock = jest.fn();
props.datasource.languageProvider.start = languageProviderStartMock;
render(<PromVariableQueryEditor {...props} />);
expect(languageProviderStartMock).toHaveBeenCalledWith(range);
});
});
@@ -0,0 +1,437 @@
import React, { FormEvent, useCallback, useEffect, useState } from 'react';
import { QueryEditorProps, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { InlineField, InlineFieldRow, Input, Select, TextArea } from '@grafana/ui';
import { PrometheusDatasource } from '../datasource';
import {
migrateVariableEditorBackToVariableSupport,
migrateVariableQueryToEditor,
} from '../migrations/variableMigration';
import { promQueryModeller } from '../querybuilder/PromQueryModeller';
import { MetricsLabelsSection } from '../querybuilder/components/MetricsLabelsSection';
import { QueryBuilderLabelFilter } from '../querybuilder/shared/types';
import { PromVisualQuery } from '../querybuilder/types';
import {
PromOptions,
PromQuery,
PromVariableQuery,
PromVariableQueryType as QueryType,
StandardPromVariableQuery,
} from '../types';
export const variableOptions = [
{ label: 'Label names', value: QueryType.LabelNames },
{ label: 'Label values', value: QueryType.LabelValues },
{ label: 'Metrics', value: QueryType.MetricNames },
{ label: 'Query result', value: QueryType.VarQueryResult },
{ label: 'Series query', value: QueryType.SeriesQuery },
{ label: 'Classic query', value: QueryType.ClassicQuery },
];
export type Props = QueryEditorProps<PrometheusDatasource, PromQuery, PromOptions, PromVariableQuery>;
const refId = 'PrometheusVariableQueryEditor-VariableQuery';
export const PromVariableQueryEditor = ({ onChange, query, datasource, range }: Props) => {
// to select the query type, i.e. label_names, label_values, etc.
const [qryType, setQryType] = useState<number | undefined>(undefined);
// list of variables for each function
const [label, setLabel] = useState('');
const [labelNamesMatch, setLabelNamesMatch] = useState('');
// metric is used for both label_values() and metric()
// label_values() metric requires a whole/complete metric
// metric() is expected to be a part of a metric string
const [metric, setMetric] = useState('');
// varQuery is a whole query, can include math/rates/etc
const [varQuery, setVarQuery] = useState('');
// seriesQuery is only a whole
const [seriesQuery, setSeriesQuery] = useState('');
// the original variable query implementation, e.g. label_value(metric, label_name)
const [classicQuery, setClassicQuery] = useState('');
// list of label names for label_values(), /api/v1/labels, contains the same results as label_names() function
const [labelOptions, setLabelOptions] = useState<Array<SelectableValue<string>>>([]);
// label filters have been added as a filter for metrics in label values query type
const [labelFilters, setLabelFilters] = useState<QueryBuilderLabelFilter[]>([]);
useEffect(() => {
datasource.languageProvider.start(range);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!query) {
return;
}
if (query.qryType === QueryType.ClassicQuery) {
setQryType(query.qryType);
setClassicQuery(query.query ?? '');
} else {
// 1. Changing from standard to custom variable editor changes the string attr from expr to query
// 2. jsonnet grafana as code passes a variable as a string
const variableQuery = variableMigration(query);
setLabelNamesMatch(variableQuery.match ?? '');
setQryType(variableQuery.qryType);
setLabel(variableQuery.label ?? '');
setMetric(variableQuery.metric ?? '');
setLabelFilters(variableQuery.labelFilters ?? []);
setVarQuery(variableQuery.varQuery ?? '');
setSeriesQuery(variableQuery.seriesQuery ?? '');
setClassicQuery(variableQuery.classicQuery ?? '');
}
}, [query]);
// set the label names options for the label values var query
useEffect(() => {
if (qryType !== QueryType.LabelValues) {
return;
}
const variables = datasource.getVariables().map((variable: string) => ({ label: variable, value: variable }));
if (!metric) {
// get all the labels
datasource.getTagKeys({ filters: [] }).then((labelNames: Array<{ text: string }>) => {
const names = labelNames.map(({ text }) => ({ label: text, value: text }));
setLabelOptions([...variables, ...names]);
});
} else {
// fetch the labels filtered by the metric
const labelToConsider = [{ label: '__name__', op: '=', value: metric }];
const expr = promQueryModeller.renderLabels(labelToConsider);
datasource.languageProvider.fetchLabelsWithMatch(expr).then((labelsIndex: Record<string, string[]>) => {
const labelNames = Object.keys(labelsIndex);
const names = labelNames.map((value) => ({ label: value, value: value }));
setLabelOptions([...variables, ...names]);
});
}
}, [datasource, qryType, metric]);
const onChangeWithVariableString = (
updateVar: { [key: string]: QueryType | string },
updLabelFilters?: QueryBuilderLabelFilter[]
) => {
const queryVar = {
qryType,
label,
metric,
match: labelNamesMatch,
varQuery,
seriesQuery,
classicQuery,
refId: 'PrometheusVariableQueryEditor-VariableQuery',
};
let updateLabelFilters = updLabelFilters ? { labelFilters: updLabelFilters } : { labelFilters: labelFilters };
const updatedVar = { ...queryVar, ...updateVar, ...updateLabelFilters };
const queryString = migrateVariableEditorBackToVariableSupport(updatedVar);
// setting query.query property allows for update of variable definition
onChange({
query: queryString,
qryType: updatedVar.qryType,
refId,
});
};
/** Call onchange for label names query type change */
const onQueryTypeChange = (newType: SelectableValue<QueryType>) => {
setQryType(newType.value);
if (newType.value !== QueryType.SeriesQuery) {
onChangeWithVariableString({ qryType: newType.value ?? 0 });
}
};
/** Call onchange for label select when query type is label values */
const onLabelChange = (newLabel: SelectableValue<string>) => {
const newLabelvalue = newLabel && newLabel.value ? newLabel.value : '';
setLabel(newLabelvalue);
if (qryType === QueryType.LabelValues && newLabelvalue) {
onChangeWithVariableString({ label: newLabelvalue });
}
};
/**
* Call onChange for MetricsLabels component change for label values query type
* if there is a label (required) and
* if the labels or metric are updated.
*/
const metricsLabelsChange = (update: PromVisualQuery) => {
setMetric(update.metric);
setLabelFilters(update.labels);
const updMetric = update.metric;
const updLabelFilters = update.labels ?? [];
if (qryType === QueryType.LabelValues && label && (updMetric || updLabelFilters)) {
onChangeWithVariableString({ qryType, metric: updMetric }, updLabelFilters);
}
};
const onLabelNamesMatchChange = (regex: string) => {
if (qryType === QueryType.LabelNames) {
onChangeWithVariableString({ qryType, match: regex });
}
};
/**
* Call onchange for metric change if metrics names (regex) query type
* Debounce this because to not call the API for every keystroke.
*/
const onMetricChange = (value: string) => {
if (qryType === QueryType.MetricNames && value) {
onChangeWithVariableString({ metric: value });
}
};
/**
* Do not call onchange for variable query result when query type is var query result
* because the query may not be finished typing and an error is returned
* for incorrectly formatted series. Call onchange for blur instead.
*/
const onVarQueryChange = (e: FormEvent<HTMLTextAreaElement>) => {
setVarQuery(e.currentTarget.value);
};
/**
* Do not call onchange for seriesQuery when query type is series query
* because the series may not be finished typing and an error is returned
* for incorrectly formatted series. Call onchange for blur instead.
*/
const onSeriesQueryChange = (e: FormEvent<HTMLInputElement>) => {
setSeriesQuery(e.currentTarget.value);
};
const onClassicQueryChange = (e: FormEvent<HTMLInputElement>) => {
setClassicQuery(e.currentTarget.value);
};
const promVisualQuery = useCallback(() => {
return { metric: metric, labels: labelFilters, operations: [] };
}, [metric, labelFilters]);
return (
<>
<InlineFieldRow>
<InlineField
label="Query type"
labelWidth={20}
tooltip={
<div>The Prometheus data source plugin provides the following query types for template variables.</div>
}
>
<Select
placeholder="Select query type"
aria-label="Query type"
onChange={onQueryTypeChange}
value={qryType}
options={variableOptions}
width={25}
data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.queryType}
/>
</InlineField>
</InlineFieldRow>
{qryType === QueryType.LabelValues && (
<>
<InlineFieldRow>
<InlineField
label="Label"
labelWidth={20}
required
aria-labelledby="label-select"
tooltip={
<div>
Returns a list of label values for the label name in all metrics unless the metric is specified.
</div>
}
>
<Select
aria-label="label-select"
onChange={onLabelChange}
value={label}
options={labelOptions}
width={25}
allowCustomValue
isClearable={true}
data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.labelValues.labelSelect}
/>
</InlineField>
</InlineFieldRow>
{/* Used to select an optional metric with optional label filters */}
<MetricsLabelsSection
query={promVisualQuery()}
datasource={datasource}
onChange={metricsLabelsChange}
variableEditor={true}
/>
</>
)}
{qryType === QueryType.LabelNames && (
<InlineFieldRow>
<InlineField
label="Metric regex"
labelWidth={20}
aria-labelledby="Metric regex"
tooltip={<div>Returns a list of label names, optionally filtering by specified metric regex.</div>}
>
<Input
type="text"
aria-label="Metric regex"
placeholder="Metric regex"
value={labelNamesMatch}
onBlur={(event) => {
setLabelNamesMatch(event.currentTarget.value);
onLabelNamesMatchChange(event.currentTarget.value);
}}
onChange={(e) => {
setLabelNamesMatch(e.currentTarget.value);
}}
width={25}
data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.labelnames.metricRegex}
/>
</InlineField>
</InlineFieldRow>
)}
{qryType === QueryType.MetricNames && (
<InlineFieldRow>
<InlineField
label="Metric regex"
labelWidth={20}
aria-labelledby="Metric selector"
tooltip={<div>Returns a list of metrics matching the specified metric regex.</div>}
>
<Input
type="text"
aria-label="Metric selector"
placeholder="Metric regex"
value={metric}
onChange={(e) => {
setMetric(e.currentTarget.value);
}}
onBlur={(e) => {
setMetric(e.currentTarget.value);
onMetricChange(e.currentTarget.value);
}}
width={25}
data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.metricNames.metricRegex}
/>
</InlineField>
</InlineFieldRow>
)}
{qryType === QueryType.VarQueryResult && (
<InlineFieldRow>
<InlineField
label="Query"
labelWidth={20}
tooltip={
<div>
Returns a list of Prometheus query results for the query. This can include Prometheus functions, i.e.
sum(go_goroutines).
</div>
}
>
<TextArea
type="text"
aria-label="Prometheus Query"
placeholder="Prometheus Query"
value={varQuery}
onChange={onVarQueryChange}
onBlur={() => {
if (qryType === QueryType.VarQueryResult && varQuery) {
onChangeWithVariableString({ qryType });
}
}}
cols={100}
data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.varQueryResult}
/>
</InlineField>
</InlineFieldRow>
)}
{qryType === QueryType.SeriesQuery && (
<InlineFieldRow>
<InlineField
label="Series Query"
labelWidth={20}
tooltip={
<div>
Enter enter a metric with labels, only a metric or only labels, i.e.
go_goroutines&#123;instance=&quot;localhost:9090&quot;&#125;, go_goroutines, or
&#123;instance=&quot;localhost:9090&quot;&#125;. Returns a list of time series associated with the
entered data.
</div>
}
>
<Input
type="text"
aria-label="Series Query"
placeholder="Series Query"
value={seriesQuery}
onChange={onSeriesQueryChange}
onBlur={() => {
if (qryType === QueryType.SeriesQuery && seriesQuery) {
onChangeWithVariableString({ qryType });
}
}}
width={100}
data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.seriesQuery}
/>
</InlineField>
</InlineFieldRow>
)}
{qryType === QueryType.ClassicQuery && (
<InlineFieldRow>
<InlineField
label="Classic Query"
labelWidth={20}
tooltip={
<div>
The original implemetation of the Prometheus variable query editor. Enter a string with the correct
query type and parameters as described in these docs. For example, label_values(label, metric).
</div>
}
>
<Input
type="text"
aria-label="Classic Query"
placeholder="Classic Query"
value={classicQuery}
onChange={onClassicQueryChange}
onBlur={() => {
if (qryType === QueryType.ClassicQuery && classicQuery) {
onChangeWithVariableString({ qryType });
}
}}
width={100}
data-testid={selectors.components.DataSource.Prometheus.variableQueryEditor.classicQuery}
/>
</InlineField>
</InlineFieldRow>
)}
</>
);
};
export function variableMigration(query: string | PromVariableQuery | StandardPromVariableQuery): PromVariableQuery {
if (typeof query === 'string') {
return migrateVariableQueryToEditor(query);
} else if (query.query) {
return migrateVariableQueryToEditor(query.query);
} else {
return query;
}
}
@@ -0,0 +1,323 @@
import { css } from '@emotion/css';
import { parser } from '@prometheus-io/lezer-promql';
import { debounce } from 'lodash';
import { promLanguageDefinition } from 'monaco-promql';
import React, { useEffect, useRef } from 'react';
import { useLatest } from 'react-use';
import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Monaco, monacoTypes, ReactMonacoEditor, useTheme2 } from '@grafana/ui';
import { Props } from './MonacoQueryFieldProps';
import { getOverrideServices } from './getOverrideServices';
import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider';
import { placeHolderScopedVars, validateQuery } from './monaco-completion-provider/validation';
const options: monacoTypes.editor.IStandaloneEditorConstructionOptions = {
codeLens: false,
contextmenu: false,
// we need `fixedOverflowWidgets` because otherwise in grafana-dashboards
// the popup is clipped by the panel-visualizations.
fixedOverflowWidgets: true,
folding: false,
fontSize: 14,
lineDecorationsWidth: 8, // used as "padding-left"
lineNumbers: 'off',
minimap: { enabled: false },
overviewRulerBorder: false,
overviewRulerLanes: 0,
padding: {
// these numbers were picked so that visually this matches the previous version
// of the query-editor the best
top: 4,
bottom: 5,
},
renderLineHighlight: 'none',
scrollbar: {
vertical: 'hidden',
verticalScrollbarSize: 8, // used as "padding-right"
horizontal: 'hidden',
horizontalScrollbarSize: 0,
alwaysConsumeMouseWheel: false,
},
scrollBeyondLastLine: false,
suggest: getSuggestOptions(),
suggestFontSize: 12,
wordWrap: 'on',
};
// this number was chosen by testing various values. it might be necessary
// because of the width of the border, not sure.
//it needs to do 2 things:
// 1. when the editor is single-line, it should make the editor height be visually correct
// 2. when the editor is multi-line, the editor should not be "scrollable" (meaning,
// you do a scroll-movement in the editor, and it will scroll the content by a couple pixels
// up & down. this we want to avoid)
const EDITOR_HEIGHT_OFFSET = 2;
const PROMQL_LANG_ID = promLanguageDefinition.id;
// we must only run the promql-setup code once
let PROMQL_SETUP_STARTED = false;
function ensurePromQL(monaco: Monaco) {
if (PROMQL_SETUP_STARTED === false) {
PROMQL_SETUP_STARTED = true;
const { aliases, extensions, mimetypes, loader } = promLanguageDefinition;
monaco.languages.register({ id: PROMQL_LANG_ID, aliases, extensions, mimetypes });
loader().then((mod) => {
monaco.languages.setMonarchTokensProvider(PROMQL_LANG_ID, mod.language);
monaco.languages.setLanguageConfiguration(PROMQL_LANG_ID, mod.languageConfiguration);
});
}
}
const getStyles = (theme: GrafanaTheme2, placeholder: string) => {
return {
container: css`
border-radius: ${theme.shape.radius.default};
border: 1px solid ${theme.components.input.borderColor};
`,
placeholder: css`
::after {
content: '${placeholder}';
font-family: ${theme.typography.fontFamilyMonospace};
opacity: 0.6;
}
`,
};
};
const MonacoQueryField = (props: Props) => {
const id = uuidv4();
// we need only one instance of `overrideServices` during the lifetime of the react component
const overrideServicesRef = useRef(getOverrideServices());
const containerRef = useRef<HTMLDivElement>(null);
const { languageProvider, history, onBlur, onRunQuery, initialValue, placeholder, onChange, datasource } = props;
const lpRef = useLatest(languageProvider);
const historyRef = useLatest(history);
const onRunQueryRef = useLatest(onRunQuery);
const onBlurRef = useLatest(onBlur);
const onChangeRef = useLatest(onChange);
const autocompleteDisposeFun = useRef<(() => void) | null>(null);
const theme = useTheme2();
const styles = getStyles(theme, placeholder);
useEffect(() => {
// when we unmount, we unregister the autocomplete-function, if it was registered
return () => {
autocompleteDisposeFun.current?.();
};
}, []);
return (
<div
data-testid={selectors.components.QueryField.container}
className={styles.container}
// NOTE: we will be setting inline-style-width/height on this element
ref={containerRef}
>
<ReactMonacoEditor
overrideServices={overrideServicesRef.current}
options={options}
language="promql"
value={initialValue}
beforeMount={(monaco) => {
ensurePromQL(monaco);
}}
onMount={(editor, monaco) => {
const isEditorFocused = editor.createContextKey<boolean>('isEditorFocused' + id, false);
// we setup on-blur
editor.onDidBlurEditorWidget(() => {
isEditorFocused.set(false);
onBlurRef.current(editor.getValue());
});
editor.onDidFocusEditorText(() => {
isEditorFocused.set(true);
});
// we construct a DataProvider object
const getHistory = () =>
Promise.resolve(historyRef.current.map((h) => h.query.expr).filter((expr) => expr !== undefined));
const getAllMetricNames = () => {
const { metrics, metricsMetadata } = lpRef.current;
const result = metrics.map((m) => {
const metaItem = metricsMetadata?.[m];
return {
name: m,
help: metaItem?.help ?? '',
type: metaItem?.type ?? '',
};
});
return Promise.resolve(result);
};
const getAllLabelNames = () => Promise.resolve(lpRef.current.getLabelKeys());
const getLabelValues = (labelName: string) => lpRef.current.getLabelValues(labelName);
const getSeriesValues = lpRef.current.getSeriesValues;
const getSeriesLabels = lpRef.current.getSeriesLabels;
const dataProvider = {
getHistory,
getAllMetricNames,
getAllLabelNames,
getLabelValues,
getSeriesValues,
getSeriesLabels,
};
const completionProvider = getCompletionProvider(monaco, dataProvider);
// completion-providers in monaco are not registered directly to editor-instances,
// they are registered to languages. this makes it hard for us to have
// separate completion-providers for every query-field-instance
// (but we need that, because they might connect to different datasources).
// the trick we do is, we wrap the callback in a "proxy",
// and in the proxy, the first thing is, we check if we are called from
// "our editor instance", and if not, we just return nothing. if yes,
// we call the completion-provider.
const filteringCompletionProvider: monacoTypes.languages.CompletionItemProvider = {
...completionProvider,
provideCompletionItems: (model, position, context, token) => {
// if the model-id does not match, then this call is from a different editor-instance,
// not "our instance", so return nothing
if (editor.getModel()?.id !== model.id) {
return { suggestions: [] };
}
return completionProvider.provideCompletionItems(model, position, context, token);
},
};
const { dispose } = monaco.languages.registerCompletionItemProvider(
PROMQL_LANG_ID,
filteringCompletionProvider
);
autocompleteDisposeFun.current = dispose;
// this code makes the editor resize itself so that the content fits
// (it will grow taller when necessary)
// FIXME: maybe move this functionality into CodeEditor, like:
// <CodeEditor resizingMode="single-line"/>
const updateElementHeight = () => {
const containerDiv = containerRef.current;
if (containerDiv !== null) {
const pixelHeight = editor.getContentHeight();
containerDiv.style.height = `${pixelHeight + EDITOR_HEIGHT_OFFSET}px`;
containerDiv.style.width = '100%';
const pixelWidth = containerDiv.clientWidth;
editor.layout({ width: pixelWidth, height: pixelHeight });
}
};
editor.onDidContentSizeChange(updateElementHeight);
updateElementHeight();
// Whenever the editor changes, lets save the last value so the next query for this editor will be up-to-date.
// This change is being introduced to fix a bug where you can submit a query via shift+enter:
// If you clicked into another field and haven't un-blurred the active field,
// then the query that is run will be stale, as the reference is only updated
// with the value of the last blurred input.
// This can run quite slowly, so we're debouncing this which should accomplish two things
// 1. Should prevent this function from blocking the current call stack by pushing into the web API callback queue
// 2. Should prevent a bunch of duplicates of this function being called as the user is typing
const updateCurrentEditorValue = debounce(() => {
const editorValue = editor.getValue();
onChangeRef.current(editorValue);
}, lpRef.current.datasource.getDebounceTimeInMilliseconds());
editor.getModel()?.onDidChangeContent(() => {
updateCurrentEditorValue();
});
// handle: shift + enter
// FIXME: maybe move this functionality into CodeEditor?
editor.addCommand(
monaco.KeyMod.Shift | monaco.KeyCode.Enter,
() => {
onRunQueryRef.current(editor.getValue());
},
'isEditorFocused' + id
);
/* Something in this configuration of monaco doesn't bubble up [mod]+K, which the
command palette uses. Pass the event out of monaco manually
*/
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, function () {
global.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
});
if (placeholder) {
const placeholderDecorators = [
{
range: new monaco.Range(1, 1, 1, 1),
options: {
className: styles.placeholder,
isWholeLine: true,
},
},
];
let decorators: string[] = [];
const checkDecorators: () => void = () => {
const model = editor.getModel();
if (!model) {
return;
}
const newDecorators = model.getValueLength() === 0 ? placeholderDecorators : [];
decorators = model.deltaDecorations(decorators, newDecorators);
};
checkDecorators();
editor.onDidChangeModelContent(checkDecorators);
editor.onDidChangeModelContent((e) => {
const model = editor.getModel();
if (!model) {
return;
}
const query = model.getValue();
const errors =
validateQuery(
query,
datasource.interpolateString(query, placeHolderScopedVars),
model.getLinesContent(),
parser
) || [];
const markers = errors.map(({ error, ...boundary }) => ({
message: `${
error ? `Error parsing "${error}"` : 'Parse error'
}. The query appears to be incorrect and could fail to be executed.`,
severity: monaco.MarkerSeverity.Error,
...boundary,
}));
monaco.editor.setModelMarkers(model, 'owner', markers);
});
}
}}
/>
</div>
);
};
// we will lazy-load this module using React.lazy,
// and that only supports default-exports,
// so we have to default-export this, even if
// it is against the style-guidelines.
export default MonacoQueryField;
@@ -0,0 +1,14 @@
import React, { Suspense } from 'react';
import MonacoQueryField from './MonacoQueryField';
import { Props } from './MonacoQueryFieldProps';
// const Field = React.lazy(() => import('./MonacoQueryField'));
export const MonacoQueryFieldLazy = (props: Props) => {
return (
<Suspense fallback={null}>
<MonacoQueryField {...props} />
</Suspense>
);
};
@@ -0,0 +1,21 @@
import { HistoryItem } from '@grafana/data';
import { PrometheusDatasource } from '../../datasource';
import type PromQlLanguageProvider from '../../language_provider';
import { PromQuery } from '../../types';
// we need to store this in a separate file,
// because we have an async-wrapper around,
// the react-component, and it needs the same
// props as the sync-component.
export type Props = {
initialValue: string;
languageProvider: PromQlLanguageProvider;
history: Array<HistoryItem<PromQuery>>;
placeholder: string;
onRunQuery: (value: string) => void;
onBlur: (value: string) => void;
// onChange will never initiate a query, it just denotes that a query value has been changed
onChange: (value: string) => void;
datasource: PrometheusDatasource;
};
@@ -0,0 +1,34 @@
import React, { useRef } from 'react';
import { MonacoQueryFieldLazy } from './MonacoQueryFieldLazy';
import { Props as MonacoProps } from './MonacoQueryFieldProps';
type Props = Omit<MonacoProps, 'onRunQuery' | 'onBlur'> & {
onChange: (query: string) => void;
onRunQuery: () => void;
};
export const MonacoQueryFieldWrapper = (props: Props) => {
const lastRunValueRef = useRef<string | null>(null);
const { onRunQuery, onChange, ...rest } = props;
const handleRunQuery = (value: string) => {
lastRunValueRef.current = value;
onChange(value);
onRunQuery();
};
const handleBlur = (value: string) => {
onChange(value);
};
/**
* Handles changes without running any queries
* @param value
*/
const handleChange = (value: string) => {
onChange(value);
};
return <MonacoQueryFieldLazy onChange={handleChange} onRunQuery={handleRunQuery} onBlur={handleBlur} {...rest} />;
};
@@ -0,0 +1,116 @@
import { monacoTypes } from '@grafana/ui';
// this thing here is a workaround in a way.
// what we want to achieve, is that when the autocomplete-window
// opens, the "second, extra popup" with the extra help,
// also opens automatically.
// but there is no API to achieve it.
// the way to do it is to implement the `storageService`
// interface, and provide our custom implementation,
// which will default to `true` for the correct string-key.
// unfortunately, while the typescript-interface exists,
// it is not exported from monaco-editor,
// so we cannot rely on typescript to make sure
// we do it right. all we can do is to manually
// lookup the interface, and make sure we code our code right.
// our code is a "best effort" approach,
// i am not 100% how the `scope` and `target` things work,
// but so far it seems to work ok.
// i would use an another approach, if there was one available.
function makeStorageService() {
// we need to return an object that fulfills this interface:
// https://github.com/microsoft/vscode/blob/ff1e16eebb93af79fd6d7af1356c4003a120c563/src/vs/platform/storage/common/storage.ts#L37
// unfortunately it is not export from monaco-editor
const strings = new Map<string, string>();
// we want this to be true by default
strings.set('expandSuggestionDocs', true.toString());
return {
// we do not implement the on* handlers
onDidChangeValue: (data: unknown): void => undefined,
onDidChangeTarget: (data: unknown): void => undefined,
onWillSaveState: (data: unknown): void => undefined,
get: (key: string, scope: unknown, fallbackValue?: string): string | undefined => {
return strings.get(key) ?? fallbackValue;
},
getBoolean: (key: string, scope: unknown, fallbackValue?: boolean): boolean | undefined => {
const val = strings.get(key);
if (val !== undefined) {
// the interface-docs say the value will be converted
// to a boolean but do not specify how, so we improvise
return val === 'true';
} else {
return fallbackValue;
}
},
getNumber: (key: string, scope: unknown, fallbackValue?: number): number | undefined => {
const val = strings.get(key);
if (val !== undefined) {
return parseInt(val, 10);
} else {
return fallbackValue;
}
},
store: (
key: string,
value: string | boolean | number | undefined | null,
scope: unknown,
target: unknown
): void => {
// the interface-docs say if the value is nullish, it should act as delete
if (value === null || value === undefined) {
strings.delete(key);
} else {
strings.set(key, value.toString());
}
},
remove: (key: string, scope: unknown): void => {
strings.delete(key);
},
keys: (scope: unknown, target: unknown): string[] => {
return Array.from(strings.keys());
},
logStorage: (): void => {
console.log('logStorage: not implemented');
},
migrate: (): Promise<void> => {
// we do not implement this
return Promise.resolve(undefined);
},
isNew: (scope: unknown): boolean => {
// we create a new storage for every session, we do not persist it,
// so we return `true`.
return true;
},
flush: (reason?: unknown): Promise<void> => {
// we do not implement this
return Promise.resolve(undefined);
},
};
}
let overrideServices: monacoTypes.editor.IEditorOverrideServices | null = null;
export function getOverrideServices(): monacoTypes.editor.IEditorOverrideServices {
// only have one instance of this for every query editor
if (overrideServices === null) {
overrideServices = {
storageService: makeStorageService(),
};
}
return overrideServices;
}
@@ -0,0 +1,208 @@
import { escapeLabelValueInExactSelector } from '../../../language_utils';
import { FUNCTIONS } from '../../../promql';
import type { Label, Situation } from './situation';
import { NeverCaseError } from './util';
// FIXME: we should not load this from the "outside", but we cannot do that while we have the "old" query-field too
export type CompletionType = 'HISTORY' | 'FUNCTION' | 'METRIC_NAME' | 'DURATION' | 'LABEL_NAME' | 'LABEL_VALUE';
type Completion = {
type: CompletionType;
label: string;
insertText: string;
detail?: string;
documentation?: string;
triggerOnInsert?: boolean;
};
type Metric = {
name: string;
help: string;
type: string;
};
export type DataProvider = {
getHistory: () => Promise<string[]>;
getAllMetricNames: () => Promise<Metric[]>;
getAllLabelNames: () => Promise<string[]>;
getLabelValues: (labelName: string) => Promise<string[]>;
getSeriesValues: (name: string, match: string) => Promise<string[]>;
getSeriesLabels: (selector: string, otherLabels: Label[]) => Promise<string[]>;
};
// we order items like: history, functions, metrics
async function getAllMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> {
const metrics = await dataProvider.getAllMetricNames();
return metrics.map((metric) => ({
type: 'METRIC_NAME',
label: metric.name,
insertText: metric.name,
detail: `${metric.name} : ${metric.type}`,
documentation: metric.help,
}));
}
const FUNCTION_COMPLETIONS: Completion[] = FUNCTIONS.map((f) => ({
type: 'FUNCTION',
label: f.label,
insertText: f.insertText ?? '', // i don't know what to do when this is nullish. it should not be.
detail: f.detail,
documentation: f.documentation,
}));
async function getAllFunctionsAndMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> {
const metricNames = await getAllMetricNamesCompletions(dataProvider);
return [...FUNCTION_COMPLETIONS, ...metricNames];
}
const DURATION_COMPLETIONS: Completion[] = [
'$__interval',
'$__range',
'$__rate_interval',
'1m',
'5m',
'10m',
'30m',
'1h',
'1d',
].map((text) => ({
type: 'DURATION',
label: text,
insertText: text,
}));
async function getAllHistoryCompletions(dataProvider: DataProvider): Promise<Completion[]> {
// function getAllHistoryCompletions(queryHistory: PromHistoryItem[]): Completion[] {
// NOTE: the typescript types are wrong. historyItem.query.expr can be undefined
const allHistory = await dataProvider.getHistory();
// FIXME: find a better history-limit
return allHistory.slice(0, 10).map((expr) => ({
type: 'HISTORY',
label: expr,
insertText: expr,
}));
}
function makeSelector(metricName: string | undefined, labels: Label[]): string {
const allLabels = [...labels];
// we transform the metricName to a label, if it exists
if (metricName !== undefined) {
allLabels.push({ name: '__name__', value: metricName, op: '=' });
}
const allLabelTexts = allLabels.map(
(label) => `${label.name}${label.op}"${escapeLabelValueInExactSelector(label.value)}"`
);
return `{${allLabelTexts.join(',')}}`;
}
async function getLabelNames(
metric: string | undefined,
otherLabels: Label[],
dataProvider: DataProvider
): Promise<string[]> {
if (metric === undefined && otherLabels.length === 0) {
// if there is no filtering, we have to use a special endpoint
return dataProvider.getAllLabelNames();
} else {
const selector = makeSelector(metric, otherLabels);
return await dataProvider.getSeriesLabels(selector, otherLabels);
}
}
async function getLabelNamesForCompletions(
metric: string | undefined,
suffix: string,
triggerOnInsert: boolean,
otherLabels: Label[],
dataProvider: DataProvider
): Promise<Completion[]> {
const labelNames = await getLabelNames(metric, otherLabels, dataProvider);
return labelNames.map((text) => ({
type: 'LABEL_NAME',
label: text,
insertText: `${text}${suffix}`,
triggerOnInsert,
}));
}
async function getLabelNamesForSelectorCompletions(
metric: string | undefined,
otherLabels: Label[],
dataProvider: DataProvider
): Promise<Completion[]> {
return getLabelNamesForCompletions(metric, '=', true, otherLabels, dataProvider);
}
async function getLabelNamesForByCompletions(
metric: string | undefined,
otherLabels: Label[],
dataProvider: DataProvider
): Promise<Completion[]> {
return getLabelNamesForCompletions(metric, '', false, otherLabels, dataProvider);
}
async function getLabelValues(
metric: string | undefined,
labelName: string,
otherLabels: Label[],
dataProvider: DataProvider
): Promise<string[]> {
if (metric === undefined && otherLabels.length === 0) {
// if there is no filtering, we have to use a special endpoint
return dataProvider.getLabelValues(labelName);
} else {
const selector = makeSelector(metric, otherLabels);
return await dataProvider.getSeriesValues(labelName, selector);
}
}
async function getLabelValuesForMetricCompletions(
metric: string | undefined,
labelName: string,
betweenQuotes: boolean,
otherLabels: Label[],
dataProvider: DataProvider
): Promise<Completion[]> {
const values = await getLabelValues(metric, labelName, otherLabels, dataProvider);
return values.map((text) => ({
type: 'LABEL_VALUE',
label: text,
insertText: betweenQuotes ? text : `"${text}"`, // FIXME: escaping strange characters?
}));
}
export async function getCompletions(situation: Situation, dataProvider: DataProvider): Promise<Completion[]> {
switch (situation.type) {
case 'IN_DURATION':
return DURATION_COMPLETIONS;
case 'IN_FUNCTION':
return getAllFunctionsAndMetricNamesCompletions(dataProvider);
case 'AT_ROOT': {
return getAllFunctionsAndMetricNamesCompletions(dataProvider);
}
case 'EMPTY': {
const metricNames = await getAllMetricNamesCompletions(dataProvider);
const historyCompletions = await getAllHistoryCompletions(dataProvider);
return [...historyCompletions, ...FUNCTION_COMPLETIONS, ...metricNames];
}
case 'IN_LABEL_SELECTOR_NO_LABEL_NAME':
return getLabelNamesForSelectorCompletions(situation.metricName, situation.otherLabels, dataProvider);
case 'IN_GROUPING':
return getLabelNamesForByCompletions(situation.metricName, situation.otherLabels, dataProvider);
case 'IN_LABEL_SELECTOR_WITH_LABEL_NAME':
return getLabelValuesForMetricCompletions(
situation.metricName,
situation.labelName,
situation.betweenQuotes,
situation.otherLabels,
dataProvider
);
default:
throw new NeverCaseError(situation);
}
}
@@ -0,0 +1,113 @@
import type { Monaco, monacoTypes } from '@grafana/ui';
import { CompletionType, DataProvider, getCompletions } from './completions';
import { getSituation } from './situation';
import { NeverCaseError } from './util';
export function getSuggestOptions(): monacoTypes.editor.ISuggestOptions {
return {
// monaco-editor sometimes provides suggestions automatically, i am not
// sure based on what, seems to be by analyzing the words already
// written.
// to try it out:
// - enter `go_goroutines{job~`
// - have the cursor at the end of the string
// - press ctrl-enter
// - you will get two suggestions
// those were not provided by grafana, they are offered automatically.
// i want to remove those. the only way i found is:
// - every suggestion-item has a `kind` attribute,
// that controls the icon to the left of the suggestion.
// - items auto-generated by monaco have `kind` set to `text`.
// - we make sure grafana-provided suggestions do not have `kind` set to `text`.
// - and then we tell monaco not to show suggestions of kind `text`
showWords: false,
};
}
function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): monacoTypes.languages.CompletionItemKind {
switch (type) {
case 'DURATION':
return monaco.languages.CompletionItemKind.Unit;
case 'FUNCTION':
return monaco.languages.CompletionItemKind.Variable;
case 'HISTORY':
return monaco.languages.CompletionItemKind.Snippet;
case 'LABEL_NAME':
return monaco.languages.CompletionItemKind.Enum;
case 'LABEL_VALUE':
return monaco.languages.CompletionItemKind.EnumMember;
case 'METRIC_NAME':
return monaco.languages.CompletionItemKind.Constructor;
default:
throw new NeverCaseError(type);
}
}
export function getCompletionProvider(
monaco: Monaco,
dataProvider: DataProvider
): monacoTypes.languages.CompletionItemProvider {
const provideCompletionItems = (
model: monacoTypes.editor.ITextModel,
position: monacoTypes.Position
): monacoTypes.languages.ProviderResult<monacoTypes.languages.CompletionList> => {
const word = model.getWordAtPosition(position);
const range =
word != null
? monaco.Range.lift({
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
})
: monaco.Range.fromPositions(position);
// documentation says `position` will be "adjusted" in `getOffsetAt`
// i don't know what that means, to be sure i clone it
const positionClone = {
column: position.column,
lineNumber: position.lineNumber,
};
// Check to see if the browser supports window.getSelection()
if (window.getSelection) {
const selectedText = window.getSelection()?.toString();
// If the user has selected text, adjust the cursor position to be at the start of the selection, instead of the end
if (selectedText && selectedText.length > 0) {
positionClone.column = positionClone.column - selectedText.length;
}
}
const offset = model.getOffsetAt(positionClone);
const situation = getSituation(model.getValue(), offset);
const completionsPromise = situation != null ? getCompletions(situation, dataProvider) : Promise.resolve([]);
return completionsPromise.then((items) => {
// monaco by-default alphabetically orders the items.
// to stop it, we use a number-as-string sortkey,
// so that monaco keeps the order we use
const maxIndexDigits = items.length.toString().length;
const suggestions: monacoTypes.languages.CompletionItem[] = items.map((item, index) => ({
kind: getMonacoCompletionItemKind(item.type, monaco),
label: item.label,
insertText: item.insertText,
detail: item.detail,
documentation: item.documentation,
sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have
range,
command: item.triggerOnInsert
? {
id: 'editor.action.triggerSuggest',
title: '',
}
: undefined,
}));
return { suggestions };
});
};
return {
triggerCharacters: ['{', ',', '[', '(', '=', '~', ' ', '"'],
provideCompletionItems,
};
}
@@ -0,0 +1,185 @@
import { getSituation, Situation } from './situation';
// we use the `^` character as the cursor-marker in the string.
function assertSituation(situation: string, expectedSituation: Situation | null) {
// first we find the cursor-position
const pos = situation.indexOf('^');
if (pos === -1) {
throw new Error('cursor missing');
}
// we remove the cursor-marker from the string
const text = situation.replace('^', '');
// sanity check, make sure no more cursor-markers remain
if (text.indexOf('^') !== -1) {
throw new Error('multiple cursors');
}
const result = getSituation(text, pos);
if (expectedSituation === null) {
expect(result).toStrictEqual(null);
} else {
expect(result).toMatchObject(expectedSituation);
}
}
describe('situation', () => {
it('handles things', () => {
assertSituation('^', {
type: 'EMPTY',
});
assertSituation('sum(one) / ^', {
type: 'AT_ROOT',
});
assertSituation('sum(^)', {
type: 'IN_FUNCTION',
});
assertSituation('sum(one) / sum(^)', {
type: 'IN_FUNCTION',
});
assertSituation('something{}[^]', {
type: 'IN_DURATION',
});
assertSituation('something{label~^}', null);
});
it('handles label names', () => {
assertSituation('something{^}', {
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME',
metricName: 'something',
otherLabels: [],
});
assertSituation('sum(something) by (^)', {
type: 'IN_GROUPING',
metricName: 'something',
otherLabels: [],
});
assertSituation('sum by (^) (something)', {
type: 'IN_GROUPING',
metricName: 'something',
otherLabels: [],
});
assertSituation('something{one="val1",two!="val2",three=~"val3",four!~"val4",^}', {
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME',
metricName: 'something',
otherLabels: [
{ name: 'one', value: 'val1', op: '=' },
{ name: 'two', value: 'val2', op: '!=' },
{ name: 'three', value: 'val3', op: '=~' },
{ name: 'four', value: 'val4', op: '!~' },
],
});
assertSituation('{^}', {
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME',
otherLabels: [],
});
assertSituation('{one="val1",^}', {
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME',
otherLabels: [{ name: 'one', value: 'val1', op: '=' }],
});
// single-quoted label-values with escape
assertSituation("{one='val\\'1',^}", {
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME',
otherLabels: [{ name: 'one', value: "val'1", op: '=' }],
});
// double-quoted label-values with escape
assertSituation('{one="val\\"1",^}', {
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME',
otherLabels: [{ name: 'one', value: 'val"1', op: '=' }],
});
// backticked label-values with escape (the escape should not be interpreted)
assertSituation('{one=`val\\"1`,^}', {
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME',
otherLabels: [{ name: 'one', value: 'val\\"1', op: '=' }],
});
});
it('handles label values', () => {
assertSituation('something{job=^}', {
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
metricName: 'something',
labelName: 'job',
betweenQuotes: false,
otherLabels: [],
});
assertSituation('something{job!=^}', {
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
metricName: 'something',
labelName: 'job',
betweenQuotes: false,
otherLabels: [],
});
assertSituation('something{job=~^}', {
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
metricName: 'something',
labelName: 'job',
betweenQuotes: false,
otherLabels: [],
});
assertSituation('something{job!~^}', {
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
metricName: 'something',
labelName: 'job',
betweenQuotes: false,
otherLabels: [],
});
assertSituation('something{job=^,host="h1"}', {
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
metricName: 'something',
labelName: 'job',
betweenQuotes: false,
otherLabels: [{ name: 'host', value: 'h1', op: '=' }],
});
assertSituation('something{job="j1",host="^"}', {
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
metricName: 'something',
labelName: 'host',
betweenQuotes: true,
otherLabels: [{ name: 'job', value: 'j1', op: '=' }],
});
assertSituation('something{job="j1"^}', null);
assertSituation('something{job="j1" ^ }', null);
assertSituation('something{job="j1" ^ , }', null);
assertSituation('{job=^,host="h1"}', {
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
labelName: 'job',
betweenQuotes: false,
otherLabels: [{ name: 'host', value: 'h1', op: '=' }],
});
assertSituation('something{one="val1",two!="val2",three=^,four=~"val4",five!~"val5"}', {
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
metricName: 'something',
labelName: 'three',
betweenQuotes: false,
otherLabels: [
{ name: 'one', value: 'val1', op: '=' },
{ name: 'two', value: 'val2', op: '!=' },
{ name: 'four', value: 'val4', op: '=~' },
{ name: 'five', value: 'val5', op: '!~' },
],
});
});
});
@@ -0,0 +1,569 @@
import type { SyntaxNode, Tree } from '@lezer/common';
import {
AggregateExpr,
AggregateModifier,
EqlRegex,
EqlSingle,
FunctionCallBody,
GroupingLabels,
Identifier,
LabelMatcher,
LabelMatchers,
LabelMatchList,
LabelName,
MatchOp,
MatrixSelector,
MetricIdentifier,
Neq,
NeqRegex,
parser,
PromQL,
StringLiteral,
VectorSelector,
} from '@prometheus-io/lezer-promql';
import { NeverCaseError } from './util';
type Direction = 'parent' | 'firstChild' | 'lastChild' | 'nextSibling';
type NodeTypeId =
| 0 // this is used as error-id
| typeof AggregateExpr
| typeof AggregateModifier
| typeof FunctionCallBody
| typeof GroupingLabels
| typeof Identifier
| typeof LabelMatcher
| typeof LabelMatchers
| typeof LabelMatchList
| typeof LabelName
| typeof MetricIdentifier
| typeof PromQL
| typeof StringLiteral
| typeof VectorSelector
| typeof MatrixSelector
| typeof MatchOp
| typeof EqlSingle
| typeof Neq
| typeof EqlRegex
| typeof NeqRegex;
type Path = Array<[Direction, NodeTypeId]>;
function move(node: SyntaxNode, direction: Direction): SyntaxNode | null {
switch (direction) {
case 'parent':
return node.parent;
case 'firstChild':
return node.firstChild;
case 'lastChild':
return node.lastChild;
case 'nextSibling':
return node.nextSibling;
default:
throw new NeverCaseError(direction);
}
}
function walk(node: SyntaxNode, path: Path): SyntaxNode | null {
let current: SyntaxNode | null = node;
for (const [direction, expectedType] of path) {
current = move(current, direction);
if (current === null) {
// we could not move in the direction, we stop
return null;
}
if (current.type.id !== expectedType) {
// the reached node has wrong type, we stop
return null;
}
}
return current;
}
function getNodeText(node: SyntaxNode, text: string): string {
return text.slice(node.from, node.to);
}
function parsePromQLStringLiteral(text: string): string {
// if it is a string-literal, it is inside quotes of some kind
const inside = text.slice(1, text.length - 1);
// FIXME: support https://prometheus.io/docs/prometheus/latest/querying/basics/#string-literals
// FIXME: maybe check other promql code, if all is supported or not
// for now we do only some very simple un-escaping
// we start with double-quotes
if (text.startsWith('"') && text.endsWith('"')) {
// NOTE: this is not 100% perfect, we only unescape the double-quote,
// there might be other characters too
return inside.replace(/\\"/, '"');
}
// then single-quote
if (text.startsWith("'") && text.endsWith("'")) {
// NOTE: this is not 100% perfect, we only unescape the single-quote,
// there might be other characters too
return inside.replace(/\\'/, "'");
}
// then backticks
if (text.startsWith('`') && text.endsWith('`')) {
return inside;
}
throw new Error('FIXME: invalid string literal');
}
type LabelOperator = '=' | '!=' | '=~' | '!~';
export type Label = {
name: string;
value: string;
op: LabelOperator;
};
export type Situation =
| {
type: 'IN_FUNCTION';
}
| {
type: 'AT_ROOT';
}
| {
type: 'EMPTY';
}
| {
type: 'IN_DURATION';
}
| {
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME';
metricName?: string;
otherLabels: Label[];
}
| {
type: 'IN_GROUPING';
metricName: string;
otherLabels: Label[];
}
| {
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME';
metricName?: string;
labelName: string;
betweenQuotes: boolean;
otherLabels: Label[];
};
type Resolver = {
path: NodeTypeId[];
fun: (node: SyntaxNode, text: string, pos: number) => Situation | null;
};
function isPathMatch(resolverPath: NodeTypeId[], cursorPath: number[]): boolean {
return resolverPath.every((item, index) => item === cursorPath[index]);
}
const ERROR_NODE_NAME: NodeTypeId = 0; // this is used as error-id
const RESOLVERS: Resolver[] = [
{
path: [LabelMatchers, VectorSelector],
fun: resolveLabelKeysWithEquals,
},
{
path: [PromQL],
fun: resolveTopLevel,
},
{
path: [FunctionCallBody],
fun: resolveInFunction,
},
{
path: [StringLiteral, LabelMatcher],
fun: resolveLabelMatcher,
},
{
path: [ERROR_NODE_NAME, LabelMatcher],
fun: resolveLabelMatcher,
},
{
path: [ERROR_NODE_NAME, MatrixSelector],
fun: resolveDurations,
},
{
path: [GroupingLabels],
fun: resolveLabelsForGrouping,
},
];
const LABEL_OP_MAP = new Map<number, LabelOperator>([
[EqlSingle, '='],
[EqlRegex, '=~'],
[Neq, '!='],
[NeqRegex, '!~'],
]);
function getLabelOp(opNode: SyntaxNode): LabelOperator | null {
const opChild = opNode.firstChild;
if (opChild === null) {
return null;
}
return LABEL_OP_MAP.get(opChild.type.id) ?? null;
}
function getLabel(labelMatcherNode: SyntaxNode, text: string): Label | null {
if (labelMatcherNode.type.id !== LabelMatcher) {
return null;
}
const nameNode = walk(labelMatcherNode, [['firstChild', LabelName]]);
if (nameNode === null) {
return null;
}
const opNode = walk(nameNode, [['nextSibling', MatchOp]]);
if (opNode === null) {
return null;
}
const op = getLabelOp(opNode);
if (op === null) {
return null;
}
const valueNode = walk(labelMatcherNode, [['lastChild', StringLiteral]]);
if (valueNode === null) {
return null;
}
const name = getNodeText(nameNode, text);
const value = parsePromQLStringLiteral(getNodeText(valueNode, text));
return { name, value, op };
}
function getLabels(labelMatchersNode: SyntaxNode, text: string): Label[] {
if (labelMatchersNode.type.id !== LabelMatchers) {
return [];
}
let listNode: SyntaxNode | null = walk(labelMatchersNode, [['firstChild', LabelMatchList]]);
const labels: Label[] = [];
while (listNode !== null) {
const matcherNode = walk(listNode, [['lastChild', LabelMatcher]]);
if (matcherNode === null) {
// unexpected, we stop
return [];
}
const label = getLabel(matcherNode, text);
if (label !== null) {
labels.push(label);
}
// there might be more labels
listNode = walk(listNode, [['firstChild', LabelMatchList]]);
}
// our labels-list is last-first, so we reverse it
labels.reverse();
return labels;
}
function getNodeChildren(node: SyntaxNode): SyntaxNode[] {
let child: SyntaxNode | null = node.firstChild;
const children: SyntaxNode[] = [];
while (child !== null) {
children.push(child);
child = child.nextSibling;
}
return children;
}
function getNodeInSubtree(node: SyntaxNode, typeId: NodeTypeId): SyntaxNode | null {
// first we try the current node
if (node.type.id === typeId) {
return node;
}
// then we try the children
const children = getNodeChildren(node);
for (const child of children) {
const n = getNodeInSubtree(child, typeId);
if (n !== null) {
return n;
}
}
return null;
}
function resolveLabelsForGrouping(node: SyntaxNode, text: string, pos: number): Situation | null {
const aggrExpNode = walk(node, [
['parent', AggregateModifier],
['parent', AggregateExpr],
]);
if (aggrExpNode === null) {
return null;
}
const bodyNode = aggrExpNode.getChild(FunctionCallBody);
if (bodyNode === null) {
return null;
}
const metricIdNode = getNodeInSubtree(bodyNode, MetricIdentifier);
if (metricIdNode === null) {
return null;
}
const idNode = walk(metricIdNode, [['firstChild', Identifier]]);
if (idNode === null) {
return null;
}
const metricName = getNodeText(idNode, text);
return {
type: 'IN_GROUPING',
metricName,
otherLabels: [],
};
}
function resolveLabelMatcher(node: SyntaxNode, text: string, pos: number): Situation | null {
// we can arrive here in two situation. `node` is either:
// - a StringNode (like in `{job="^"}`)
// - or an error node (like in `{job=^}`)
const inStringNode = !node.type.isError;
const parent = walk(node, [['parent', LabelMatcher]]);
if (parent === null) {
return null;
}
const labelNameNode = walk(parent, [['firstChild', LabelName]]);
if (labelNameNode === null) {
return null;
}
const labelName = getNodeText(labelNameNode, text);
// now we need to go up, to the parent of LabelMatcher,
// there can be one or many `LabelMatchList` parents, we have
// to go through all of them
const firstListNode = walk(parent, [['parent', LabelMatchList]]);
if (firstListNode === null) {
return null;
}
let listNode = firstListNode;
// we keep going through the parent-nodes
// as long as they are LabelMatchList.
// as soon as we reawch LabelMatchers, we stop
let labelMatchersNode: SyntaxNode | null = null;
while (labelMatchersNode === null) {
const p = listNode.parent;
if (p === null) {
return null;
}
const { id } = p.type;
switch (id) {
case LabelMatchList:
//we keep looping
listNode = p;
continue;
case LabelMatchers:
// we reached the end, we can stop the loop
labelMatchersNode = p;
continue;
default:
// we reached some other node, we stop
return null;
}
}
// now we need to find the other names
const allLabels = getLabels(labelMatchersNode, text);
// we need to remove "our" label from all-labels, if it is in there
const otherLabels = allLabels.filter((label) => label.name !== labelName);
const metricNameNode = walk(labelMatchersNode, [
['parent', VectorSelector],
['firstChild', MetricIdentifier],
['firstChild', Identifier],
]);
if (metricNameNode === null) {
// we are probably in a situation without a metric name
return {
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
labelName,
betweenQuotes: inStringNode,
otherLabels,
};
}
const metricName = getNodeText(metricNameNode, text);
return {
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
metricName,
labelName,
betweenQuotes: inStringNode,
otherLabels,
};
}
function resolveTopLevel(node: SyntaxNode, text: string, pos: number): Situation {
return {
type: 'AT_ROOT',
};
}
function resolveInFunction(node: SyntaxNode, text: string, pos: number): Situation {
return {
type: 'IN_FUNCTION',
};
}
function resolveDurations(node: SyntaxNode, text: string, pos: number): Situation {
return {
type: 'IN_DURATION',
};
}
function subTreeHasError(node: SyntaxNode): boolean {
return getNodeInSubtree(node, ERROR_NODE_NAME) !== null;
}
function resolveLabelKeysWithEquals(node: SyntaxNode, text: string, pos: number): Situation | null {
// for example `something{^}`
// there are some false positives that can end up in this situation, that we want
// to eliminate:
// `something{a~^}` (if this subtree contains any error-node, we stop)
if (subTreeHasError(node)) {
return null;
}
// next false positive:
// `something{a="1"^}`
const child = walk(node, [['firstChild', LabelMatchList]]);
if (child !== null) {
// means the label-matching part contains at least one label already.
//
// in this case, we will need to have a `,` character at the end,
// to be able to suggest adding the next label.
// the area between the end-of-the-child-node and the cursor-pos
// must contain a `,` in this case.
const textToCheck = text.slice(child.to, pos);
if (!textToCheck.includes(',')) {
return null;
}
}
const metricNameNode = walk(node, [
['parent', VectorSelector],
['firstChild', MetricIdentifier],
['firstChild', Identifier],
]);
const otherLabels = getLabels(node, text);
if (metricNameNode === null) {
// we are probably in a situation without a metric name.
return {
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME',
otherLabels,
};
}
const metricName = getNodeText(metricNameNode, text);
return {
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME',
metricName,
otherLabels,
};
}
// we find the first error-node in the tree that is at the cursor-position.
// NOTE: this might be too slow, might need to optimize it
// (ideas: we do not need to go into every subtree, based on from/to)
// also, only go to places that are in the sub-tree of the node found
// by default by lezer. problem is, `next()` will go upward too,
// and we do not want to go higher than our node
function getErrorNode(tree: Tree, pos: number): SyntaxNode | null {
const cur = tree.cursorAt(pos);
while (true) {
if (cur.from === pos && cur.to === pos) {
const { node } = cur;
if (node.type.isError) {
return node;
}
}
if (!cur.next()) {
break;
}
}
return null;
}
export function getSituation(text: string, pos: number): Situation | null {
// there is a special-case when we are at the start of writing text,
// so we handle that case first
if (text === '') {
return {
type: 'EMPTY',
};
}
/*
PromQL
Expr
VectorSelector
LabelMatchers
*/
const tree = parser.parse(text);
// if the tree contains error, it is very probable that
// our node is one of those error-nodes.
// also, if there are errors, the node lezer finds us,
// might not be the best node.
// so first we check if there is an error-node at the cursor-position
// @ts-ignore
const maybeErrorNode = getErrorNode(tree, pos);
const cur = maybeErrorNode != null ? maybeErrorNode.cursor() : tree.cursorAt(pos);
const currentNode = cur.node;
const ids = [cur.type.id];
while (cur.parent()) {
ids.push(cur.type.id);
}
for (let resolver of RESOLVERS) {
// i do not use a foreach because i want to stop as soon
// as i find something
if (isPathMatch(resolver.path, ids)) {
// @ts-ignore
return resolver.fun(currentNode, text, pos);
}
}
return null;
}
@@ -0,0 +1,25 @@
// this helper class is used to make typescript warn you when you forget
// a case-block in a switch statement.
// example code that triggers the typescript-error:
//
// const x:'A'|'B'|'C' = 'A';
//
// switch(x) {
// case 'A':
// // something
// case 'B':
// // something
// default:
// throw new NeverCaseError(x);
// }
//
//
// typescript will show an error in this case,
// when you add the missing `case 'C'` code,
// the problem will be fixed.
export class NeverCaseError extends Error {
constructor(value: never) {
super('should never happen');
}
}
@@ -0,0 +1,85 @@
import { parser } from '@prometheus-io/lezer-promql';
import { validateQuery } from './validation';
describe('Monaco Query Validation', () => {
test('Identifies empty queries as valid', () => {
expect(validateQuery('', '', [], parser)).toBeFalsy();
});
test.each([
'access_evaluation_duration_sum{job="grafana"}',
'http_requests_total{job="apiserver", handler="/api/comments"}[5m]',
'http_requests_total{job=~".*server"}',
'rate(http_requests_total[5m])[30m:1m]',
'max_over_time(deriv(rate(distance_covered_total[5s])[30s:5s])[10m:])',
'rate(http_requests_total[5m])',
'topk(3, sum by (app, proc) (rate(instance_cpu_time_ns[5m])))',
])('Identifies valid queries', (query: string) => {
expect(validateQuery(query, query, [], parser)).toBeFalsy();
});
test('Identifies invalid queries', () => {
// Missing } at the end
let query = 'access_evaluation_duration_sum{job="grafana"';
expect(validateQuery(query, query, [query], parser)).toEqual([
{
endColumn: 45,
endLineNumber: 1,
error: '{job="grafana"',
startColumn: 31,
startLineNumber: 1,
},
]);
// Missing handler="value"
query = 'http_requests_total{job="apiserver", handler}[5m]';
expect(validateQuery(query, query, [query], parser)).toEqual([
{
endColumn: 45,
endLineNumber: 1,
error: 'handler',
startColumn: 38,
startLineNumber: 1,
},
]);
// Missing : in [30s:5s]
query = 'max_over_time(deriv(rate(distance_covered_total[5s])[30s5s])[10m:])';
expect(validateQuery(query, query, [query], parser)).toEqual([
{
endColumn: 60,
endLineNumber: 1,
error: 'rate(distance_covered_total[5s])[30s5s]',
startColumn: 21,
startLineNumber: 1,
},
]);
});
test('Identifies valid multi-line queries', () => {
const query = `
sum by (job) (
rate(http_requests_total[5m])
)`;
const queryLines = query.split('\n');
expect(validateQuery(query, query, queryLines, parser)).toBeFalsy();
});
test('Identifies invalid multi-line queries', () => {
const query = `
sum by (job) (
rate(http_requests_total[])
)`;
const queryLines = query.split('\n');
expect(validateQuery(query, query, queryLines, parser)).toEqual([
{
endColumn: 30,
endLineNumber: 3,
error: '',
startColumn: 30,
startLineNumber: 3,
},
]);
});
});
@@ -0,0 +1,126 @@
import { SyntaxNode } from '@lezer/common';
import { LRParser } from '@lezer/lr';
// Although 0 isn't explicitly provided in the @grafana/lezer-logql library as the error node ID, it does appear to be the ID of error nodes within lezer.
export const ErrorId = 0;
interface ParserErrorBoundary {
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
error: string;
}
interface ParseError {
text: string;
node: SyntaxNode;
}
/**
* Conceived to work in combination with the MonacoQueryField component.
* Given an original query, and it's interpolated version, it will return an array of ParserErrorBoundary
* objects containing nodes which are actual errors. The interpolated version (even with placeholder variables)
* is required because variables look like errors for Lezer.
* @internal
*/
export function validateQuery(
query: string,
interpolatedQuery: string,
queryLines: string[],
parser: LRParser
): ParserErrorBoundary[] | false {
if (!query) {
return false;
}
/**
* To provide support to variable interpolation in query validation, we run the parser in the interpolated
* query. If there are errors there, we trace them back to the original unparsed query, so we can more
* accurately highlight the error in the query, since it's likely that the variable name and variable value
* have different lengths. With this, we also exclude irrelevant parser errors that are produced by
* lezer not understanding $variables and $__variables, which usually generate 2 or 3 error SyntaxNode.
*/
const interpolatedErrors: ParseError[] = parseQuery(interpolatedQuery, parser);
if (!interpolatedErrors.length) {
return false;
}
let parseErrors: ParseError[] = interpolatedErrors;
if (query !== interpolatedQuery) {
const queryErrors: ParseError[] = parseQuery(query, parser);
parseErrors = interpolatedErrors.flatMap(
(interpolatedError) =>
queryErrors.filter((queryError) => interpolatedError.text === queryError.text) || interpolatedError
);
}
return parseErrors.map((parseError) => findErrorBoundary(query, queryLines, parseError)).filter(isErrorBoundary);
}
function parseQuery(query: string, parser: LRParser) {
const parseErrors: ParseError[] = [];
const tree = parser.parse(query);
tree.iterate({
enter: (nodeRef): false | void => {
if (nodeRef.type.id === ErrorId) {
const node = nodeRef.node;
parseErrors.push({
node: node,
text: query.substring(node.from, node.to),
});
}
},
});
return parseErrors;
}
function findErrorBoundary(query: string, queryLines: string[], parseError: ParseError): ParserErrorBoundary | null {
if (queryLines.length === 1) {
const isEmptyString = parseError.node.from === parseError.node.to;
const errorNode = isEmptyString && parseError.node.parent ? parseError.node.parent : parseError.node;
const error = isEmptyString ? query.substring(errorNode.from, errorNode.to) : parseError.text;
return {
startLineNumber: 1,
startColumn: errorNode.from + 1,
endLineNumber: 1,
endColumn: errorNode.to + 1,
error,
};
}
let startPos = 0,
endPos = 0;
for (let line = 0; line < queryLines.length; line++) {
endPos = startPos + queryLines[line].length;
if (parseError.node.from > endPos) {
startPos += queryLines[line].length + 1;
continue;
}
return {
startLineNumber: line + 1,
startColumn: parseError.node.from - startPos + 1,
endLineNumber: line + 1,
endColumn: parseError.node.to - startPos + 1,
error: parseError.text,
};
}
return null;
}
function isErrorBoundary(boundary: ParserErrorBoundary | null): boundary is ParserErrorBoundary {
return boundary !== null;
}
export const placeHolderScopedVars = {
__interval: { text: '1s', value: '1s' },
__rate_interval: { text: '1s', value: '1s' },
__auto: { text: '1s', value: '1s' },
__interval_ms: { text: '1000', value: 1000 },
__range_ms: { text: '1000', value: 1000 },
__range_s: { text: '1', value: 1 },
__range: { text: '1s', value: '1s' },
};
@@ -0,0 +1,6 @@
import { QueryEditorProps } from '@grafana/data';
import { PrometheusDatasource } from '../datasource';
import { PromOptions, PromQuery } from '../types';
export type PromQueryEditorProps = QueryEditorProps<PrometheusDatasource, PromQuery, PromOptions>;
@@ -0,0 +1,61 @@
import { cx } from '@emotion/css';
import React from 'react';
import { DataSourceJsonData, DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { ConfigSubSection } from '@grafana/experimental';
import { InlineField, Switch, useTheme2 } from '@grafana/ui';
import { docsTip, overhaulStyles } from './ConfigEditor';
interface Props<T extends DataSourceJsonData>
extends Pick<DataSourcePluginOptionsEditorProps<T>, 'options' | 'onOptionsChange'> {}
interface AlertingConfig extends DataSourceJsonData {
manageAlerts?: boolean;
}
export function AlertingSettingsOverhaul<T extends AlertingConfig>({
options,
onOptionsChange,
}: Props<T>): JSX.Element {
const theme = useTheme2();
// imported GrafanaTheme2 from @grafana/data does not match type of same from @grafana/ui
// @ts-ignore
const styles = overhaulStyles(theme);
return (
<ConfigSubSection title="Alerting" className={cx(styles.container, styles.alertingTop)}>
<div className="gf-form-group">
<div className="gf-form-inline">
<div className="gf-form">
<InlineField
labelWidth={30}
label="Manage alerts via Alerting UI"
disabled={options.readOnly}
tooltip={
<>
Manage alert rules for this data source. To manage other alerting resources, add an Alertmanager data
source. {docsTip()}
</>
}
interactive={true}
className={styles.switchField}
>
<Switch
value={options.jsonData.manageAlerts !== false}
onChange={(event) =>
onOptionsChange({
...options,
jsonData: { ...options.jsonData, manageAlerts: event!.currentTarget.checked },
})
}
id={selectors.components.DataSource.Prometheus.configPage.manageAlerts}
/>
</InlineField>
</div>
</div>
</div>
</ConfigSubSection>
);
}
@@ -0,0 +1,93 @@
import React from 'react';
import { FieldValidationMessage } from '@grafana/ui';
import { validateInput } from './ConfigEditor';
import { DURATION_REGEX, MULTIPLE_DURATION_REGEX } from './PromSettings';
const VALID_URL_REGEX = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/;
const error = <FieldValidationMessage>Value is not valid</FieldValidationMessage>;
// replaces promSettingsValidationEvents to display a <FieldValidationMessage> onBlur for duration input errors
describe('promSettings validateInput', () => {
it.each`
value | expected
${'1ms'} | ${true}
${'1M'} | ${true}
${'1w'} | ${true}
${'1d'} | ${true}
${'1h'} | ${true}
${'1m'} | ${true}
${'1s'} | ${true}
${'1y'} | ${true}
`(
"Single duration regex, when calling the rule with correct formatted value: '$value' then result should be '$expected'",
({ value, expected }) => {
expect(validateInput(value, DURATION_REGEX)).toBe(expected);
}
);
it.each`
value | expected
${'1M 2s'} | ${true}
${'1w 2d'} | ${true}
${'1d 2m'} | ${true}
${'1h 2m'} | ${true}
${'1m 2s'} | ${true}
`(
"Multiple duration regex, when calling the rule with correct formatted value: '$value' then result should be '$expected'",
({ value, expected }) => {
expect(validateInput(value, MULTIPLE_DURATION_REGEX)).toBe(expected);
}
);
it.each`
value | expected
${'1 ms'} | ${error}
${'1x'} | ${error}
${' '} | ${error}
${'w'} | ${error}
${'1.0s'} | ${error}
`(
"when calling the rule with incorrect formatted value: '$value' then result should be '$expected'",
({ value, expected }) => {
expect(validateInput(value, DURATION_REGEX)).toStrictEqual(expected);
}
);
it.each`
value | expected
${'frp://'} | ${error}
${'htp://'} | ${error}
${'httpss:??'} | ${error}
${'http@//'} | ${error}
${'http:||'} | ${error}
${'http://'} | ${error}
${'https://'} | ${error}
${'ftp://'} | ${error}
`(
"Url incorrect formatting, when calling the rule with correct formatted value: '$value' then result should be '$expected'",
({ value, expected }) => {
expect(validateInput(value, VALID_URL_REGEX)).toStrictEqual(expected);
}
);
it.each`
value | expected
${'ftp://example'} | ${true}
${'http://example'} | ${true}
${'https://example'} | ${true}
`(
"Url correct formatting, when calling the rule with correct formatted value: '$value' then result should be '$expected'",
({ value, expected }) => {
expect(validateInput(value, VALID_URL_REGEX)).toBe(expected);
}
);
it('should display a custom validation message', () => {
const invalidDuration = 'invalid';
const customMessage = 'This is invalid';
const errorWithCustomMessage = <FieldValidationMessage>{customMessage}</FieldValidationMessage>;
expect(validateInput(invalidDuration, DURATION_REGEX, customMessage)).toStrictEqual(errorWithCustomMessage);
});
});
@@ -0,0 +1,139 @@
import { css } from '@emotion/css';
import React from 'react';
import { DataSourcePluginOptionsEditorProps, GrafanaTheme2 } from '@grafana/data';
import { ConfigSection, DataSourceDescription, AdvancedHttpSettings } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { Alert, FieldValidationMessage, useTheme2 } from '@grafana/ui';
import { PromOptions } from '../types';
import { AlertingSettingsOverhaul } from './AlertingSettingsOverhaul';
import { DataSourcehttpSettingsOverhaul } from './DataSourceHttpSettingsOverhaul';
import { PromSettings } from './PromSettings';
export const PROM_CONFIG_LABEL_WIDTH = 30;
export type PrometheusConfigProps = DataSourcePluginOptionsEditorProps<PromOptions>;
export const ConfigEditor = (props: PrometheusConfigProps) => {
const { options, onOptionsChange } = props;
const theme = useTheme2();
const styles = overhaulStyles(theme);
return (
<>
{options.access === 'direct' && (
<Alert title="Error" severity="error">
Browser access mode in the Prometheus data source is no longer available. Switch to server access mode.
</Alert>
)}
<DataSourceDescription
dataSourceName="Prometheus"
docsLink="https://grafana.com/docs/grafana/latest/datasources/prometheus/configure-prometheus-data-source/"
/>
<hr className={`${styles.hrTopSpace} ${styles.hrBottomSpace}`} />
<DataSourcehttpSettingsOverhaul
options={options}
onOptionsChange={onOptionsChange}
secureSocksDSProxyEnabled={config.secureSocksDSProxyEnabled}
/>
<hr />
<ConfigSection
className={styles.advancedSettings}
title="Advanced settings"
description="Additional settings are optional settings that can be configured for more control over your data source."
>
<AdvancedHttpSettings
className={styles.advancedHTTPSettingsMargin}
config={options}
onChange={onOptionsChange}
/>
<AlertingSettingsOverhaul<PromOptions> options={options} onOptionsChange={onOptionsChange} />
<PromSettings options={options} onOptionsChange={onOptionsChange} />
</ConfigSection>
</>
);
};
/**
* Use this to return a url in a tooltip in a field. Don't forget to make the field interactive to be able to click on the tooltip
* @param url
* @returns
*/
export function docsTip(url?: string) {
const docsUrl = 'https://grafana.com/docs/grafana/latest/datasources/prometheus/#configure-the-data-source';
return (
<a href={url ? url : docsUrl} target="_blank" rel="noopener noreferrer">
Visit docs for more details here.
</a>
);
}
export const validateInput = (
input: string,
pattern: string | RegExp,
errorMessage?: string
): boolean | JSX.Element => {
const defaultErrorMessage = 'Value is not valid';
if (input && !input.match(pattern)) {
return <FieldValidationMessage>{errorMessage ? errorMessage : defaultErrorMessage}</FieldValidationMessage>;
} else {
return true;
}
};
export function overhaulStyles(theme: GrafanaTheme2) {
return {
additionalSettings: css`
margin-bottom: 25px;
`,
secondaryGrey: css`
color: ${theme.colors.secondary.text};
opacity: 65%;
`,
inlineError: css`
margin: 0px 0px 4px 245px;
`,
switchField: css`
align-items: center;
`,
sectionHeaderPadding: css`
padding-top: 32px;
`,
sectionBottomPadding: css`
padding-bottom: 28px;
`,
subsectionText: css`
font-size: 12px;
`,
hrBottomSpace: css`
margin-bottom: 56px;
`,
hrTopSpace: css`
margin-top: 50px;
`,
textUnderline: css`
text-decoration: underline;
`,
versionMargin: css`
margin-bottom: 12px;
`,
advancedHTTPSettingsMargin: css`
margin: 24px 0 8px 0;
`,
advancedSettings: css`
padding-top: 32px;
`,
alertingTop: css`
margin-top: 40px !important;
`,
overhaulPageHeading: css`
font-weight: 400;
`,
container: css`
maxwidth: 578;
`,
};
}
@@ -0,0 +1,97 @@
import React from 'react';
import { DataSourceSettings } from '@grafana/data';
import { Auth, AuthMethod, ConnectionSettings, convertLegacyAuthProps } from '@grafana/experimental';
import { SecureSocksProxySettings, useTheme2 } from '@grafana/ui';
import { PromOptions } from '../types';
import { docsTip, overhaulStyles } from './ConfigEditor';
type Props = {
options: DataSourceSettings<PromOptions, {}>;
onOptionsChange: (options: DataSourceSettings<PromOptions, {}>) => void;
secureSocksDSProxyEnabled: boolean;
};
export const DataSourcehttpSettingsOverhaul = (props: Props) => {
const { options, onOptionsChange, secureSocksDSProxyEnabled } = props;
const newAuthProps = convertLegacyAuthProps({
config: options,
onChange: onOptionsChange,
});
const theme = useTheme2();
const styles = overhaulStyles(theme);
function returnSelectedMethod() {
return newAuthProps.selectedMethod;
}
// Do we need this switch anymore? Update the language.
let urlTooltip;
switch (options.access) {
case 'direct':
urlTooltip = (
<>
Your access method is <em>Browser</em>, this means the URL needs to be accessible from the browser.
{docsTip()}
</>
);
break;
case 'proxy':
urlTooltip = (
<>
Your access method is <em>Server</em>, this means the URL needs to be accessible from the grafana
backend/server.
{docsTip()}
</>
);
break;
default:
urlTooltip = <>Specify a complete HTTP URL (for example http://your_server:8080) {docsTip()}</>;
}
return (
<>
<ConnectionSettings
urlPlaceholder="http://localhost:9090"
config={options}
onChange={onOptionsChange}
urlLabel="Prometheus server URL"
urlTooltip={urlTooltip}
/>
<hr className={`${styles.hrTopSpace} ${styles.hrBottomSpace}`} />
<Auth
// Reshaped legacy props
{...newAuthProps}
// Still need to call `onAuthMethodSelect` function from
// `newAuthProps` to store the legacy data correctly.
// Also make sure to store the data about your component
// being selected/unselected.
onAuthMethodSelect={(method) => {
onOptionsChange({
...options,
basicAuth: method === AuthMethod.BasicAuth,
withCredentials: method === AuthMethod.CrossSiteCredentials,
jsonData: {
...options.jsonData,
oauthPassThru: method === AuthMethod.OAuthForward,
},
});
}}
// If your method is selected pass its id to `selectedMethod`,
// otherwise pass the id from converted legacy data
selectedMethod={returnSelectedMethod()}
/>
<div className={styles.sectionBottomPadding} />
{secureSocksDSProxyEnabled && (
<>
<SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} />
<div className={styles.sectionBottomPadding} />
</>
)}
</>
);
};
@@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { DataSourceInstanceSettings } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { DataSourcePicker } from '@grafana/runtime';
import { Button, InlineField, Input, Switch, useTheme2 } from '@grafana/ui';
import { ExemplarTraceIdDestination } from '../types';
import { docsTip, overhaulStyles, PROM_CONFIG_LABEL_WIDTH } from './ConfigEditor';
type Props = {
value: ExemplarTraceIdDestination;
onChange: (value: ExemplarTraceIdDestination) => void;
onDelete: () => void;
disabled?: boolean;
};
export default function ExemplarSetting({ value, onChange, onDelete, disabled }: Props) {
const [isInternalLink, setIsInternalLink] = useState(Boolean(value.datasourceUid));
const theme = useTheme2();
const styles = overhaulStyles(theme);
return (
<div className="gf-form-group">
<InlineField
label="Internal link"
labelWidth={PROM_CONFIG_LABEL_WIDTH}
disabled={disabled}
tooltip={
<>
Enable this option if you have an internal link. When enabled, this reveals the data source selector. Select
the backend tracing data store for your exemplar data. {docsTip()}
</>
}
interactive={true}
className={styles.switchField}
>
<>
<Switch
value={isInternalLink}
data-testid={selectors.components.DataSource.Prometheus.configPage.internalLinkSwitch}
onChange={(ev) => setIsInternalLink(ev.currentTarget.checked)}
/>
</>
</InlineField>
{isInternalLink ? (
<InlineField
label="Data source"
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={<>The data source the exemplar is going to navigate to. {docsTip()}</>}
disabled={disabled}
interactive={true}
>
<DataSourcePicker
tracing={true}
current={value.datasourceUid}
noDefault={true}
width={40}
onChange={(ds: DataSourceInstanceSettings) =>
onChange({
...value,
datasourceUid: ds.uid,
url: undefined,
})
}
/>
</InlineField>
) : (
<InlineField
label="URL"
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={<>The URL of the trace backend the user would go to see its trace. {docsTip()}</>}
disabled={disabled}
interactive={true}
>
<Input
placeholder="https://example.com/${__value.raw}"
spellCheck={false}
width={40}
value={value.url}
onChange={(event) =>
onChange({
...value,
datasourceUid: undefined,
url: event.currentTarget.value,
})
}
/>
</InlineField>
)}
<InlineField
label="URL Label"
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={<>Use to override the button label on the exemplar traceID field. {docsTip()}</>}
disabled={disabled}
interactive={true}
>
<Input
placeholder="Go to example.com"
spellCheck={false}
width={40}
value={value.urlDisplayLabel}
onChange={(event) =>
onChange({
...value,
urlDisplayLabel: event.currentTarget.value,
})
}
/>
</InlineField>
<InlineField
label="Label name"
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={<>The name of the field in the labels object that should be used to get the traceID. {docsTip()}</>}
disabled={disabled}
interactive={true}
>
<Input
placeholder="traceID"
spellCheck={false}
width={40}
value={value.name}
onChange={(event) =>
onChange({
...value,
name: event.currentTarget.value,
})
}
/>
</InlineField>
{!disabled && (
<InlineField label="Remove exemplar link" labelWidth={PROM_CONFIG_LABEL_WIDTH} disabled={disabled}>
<Button
variant="destructive"
title="Remove exemplar link"
icon="times"
onClick={(event) => {
event.preventDefault();
onDelete();
}}
/>
</InlineField>
)}
</div>
);
}
@@ -0,0 +1,67 @@
import { css } from '@emotion/css';
import React from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { ConfigSubSection } from '@grafana/experimental';
import { Button, useTheme2 } from '@grafana/ui';
import { ExemplarTraceIdDestination } from '../types';
import { overhaulStyles } from './ConfigEditor';
import ExemplarSetting from './ExemplarSetting';
type Props = {
options?: ExemplarTraceIdDestination[];
onChange: (value: ExemplarTraceIdDestination[]) => void;
disabled?: boolean;
};
export function ExemplarsSettings({ options, onChange, disabled }: Props) {
const theme = useTheme2();
const styles = overhaulStyles(theme);
return (
<div className={styles.sectionBottomPadding}>
<ConfigSubSection title="Exemplars" className={styles.container}>
{options &&
options.map((option, index) => {
return (
<ExemplarSetting
key={index}
value={option}
onChange={(newField) => {
const newOptions = [...options];
newOptions.splice(index, 1, newField);
onChange(newOptions);
}}
onDelete={() => {
const newOptions = [...options];
newOptions.splice(index, 1);
onChange(newOptions);
}}
disabled={disabled}
/>
);
})}
{!disabled && (
<Button
variant="secondary"
data-testid={selectors.components.DataSource.Prometheus.configPage.exemplarsAddButton}
className={css`
margin-bottom: 10px;
`}
icon="plus"
onClick={(event) => {
event.preventDefault();
const newOptions = [...(options || []), { name: 'traceID' }];
onChange(newOptions);
}}
>
Add
</Button>
)}
{disabled && !options && <i>No exemplars configurations</i>}
</ConfigSubSection>
</div>
);
}
@@ -0,0 +1,99 @@
export const PromFlavorVersions: { [index: string]: Array<{ value?: string; label: string }> } = {
Prometheus: [
{ value: undefined, label: 'Please select' },
{ value: '2.0.0', label: '< 2.14.x' },
{ value: '2.14.0', label: '2.14.x' },
{ value: '2.15.0', label: '2.15.x' },
{ value: '2.16.0', label: '2.16.x' },
{ value: '2.17.0', label: '2.17.x' },
{ value: '2.18.0', label: '2.18.x' },
{ value: '2.19.0', label: '2.19.x' },
{ value: '2.20.0', label: '2.20.x' },
{ value: '2.21.0', label: '2.21.x' },
{ value: '2.22.0', label: '2.22.x' },
{ value: '2.23.0', label: '2.23.x' },
{ value: '2.24.0', label: '2.24.x' },
{ value: '2.25.0', label: '2.25.x' },
{ value: '2.26.0', label: '2.26.x' },
{ value: '2.27.0', label: '2.27.x' },
{ value: '2.28.0', label: '2.28.x' },
{ value: '2.29.0', label: '2.29.x' },
{ value: '2.30.0', label: '2.30.x' },
{ value: '2.31.0', label: '2.31.x' },
{ value: '2.32.0', label: '2.32.x' },
{ value: '2.33.0', label: '2.33.x' },
{ value: '2.34.0', label: '2.34.x' },
{ value: '2.35.0', label: '2.35.x' },
{ value: '2.36.0', label: '2.36.x' },
{ value: '2.37.0', label: '2.37.x' },
{ value: '2.38.0', label: '2.38.x' },
{ value: '2.39.0', label: '2.39.x' },
{ value: '2.40.0', label: '2.40.x' },
{ value: '2.41.0', label: '2.41.x' },
{ value: '2.42.0', label: '2.42.x' },
{ value: '2.43.0', label: '2.43.x' },
{ value: '2.44.0', label: '2.44.x' },
{ value: '2.45.0', label: '2.45.x' },
{ value: '2.46.0', label: '2.46.x' },
{ value: '2.47.0', label: '2.47.x' },
{ value: '2.48.0', label: '2.48.x' },
{ value: '2.49.0', label: '2.49.x' },
{ value: '2.50.0', label: '2.50.x' },
// This value will be returned for future versions of prometheus until we add new entries to this object
{ value: '2.50.1', label: '> 2.50.x' },
],
Mimir: [
{ value: undefined, label: 'Please select' },
{ value: '2.0.0', label: '2.0.x' },
{ value: '2.1.0', label: '2.1.x' },
{ value: '2.2.0', label: '2.2.x' },
{ value: '2.3.0', label: '2.3.x' },
{ value: '2.4.0', label: '2.4.x' },
{ value: '2.5.0', label: '2.5.x' },
{ value: '2.6.0', label: '2.6.x' },
{ value: '2.7.0', label: '2.7.x' },
{ value: '2.8.0', label: '2.8.x' },
{ value: '2.9.0', label: '2.9.x' },
{ value: '2.9.1', label: '> 2.9.x' },
],
Thanos: [
{ value: undefined, label: 'Please select' },
{ value: '0.0.0', label: '< 0.16.x' },
{ value: '0.16.0', label: '0.16.x' },
{ value: '0.17.0', label: '0.17.x' },
{ value: '0.18.0', label: '0.18.x' },
{ value: '0.19.0', label: '0.19.x' },
{ value: '0.20.0', label: '0.20.x' },
{ value: '0.21.0', label: '0.21.x' },
{ value: '0.22.0', label: '0.22.x' },
{ value: '0.23.0', label: '0.23.x' },
{ value: '0.24.0', label: '0.24.x' },
{ value: '0.25.0', label: '0.25.x' },
{ value: '0.26.0', label: '0.26.x' },
{ value: '0.27.0', label: '0.27.x' },
{ value: '0.28.0', label: '0.28.x' },
{ value: '0.29.0', label: '0.29.x' },
{ value: '0.30.0', label: '0.30.x' },
{ value: '0.31.0', label: '0.31.x' },
{ value: '0.31.1', label: '> 0.31.x' },
],
Cortex: [
{ value: undefined, label: 'Please select' },
{ value: '0.0.0', label: '< 1.0.0' },
{ value: '1.0.0', label: '1.0.0' },
{ value: '1.1.0', label: '1.1.x' },
{ value: '1.2.0', label: '1.2.x' },
{ value: '1.3.0', label: '1.3.x' },
{ value: '1.4.0', label: '1.4.x' },
{ value: '1.5.0', label: '1.5.x' },
{ value: '1.6.0', label: '1.6.x' },
{ value: '1.7.0', label: '1.7.x' },
{ value: '1.8.0', label: '1.8.x' },
{ value: '1.9.0', label: '1.9.x' },
{ value: '1.10.0', label: '1.10.x' },
{ value: '1.11.0', label: '1.11.x' },
{ value: '1.13.0', label: '1.13.x' },
{ value: '1.14.0', label: '> 1.13.x' },
],
};
@@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/react';
import React, { SyntheticEvent } from 'react';
import { SelectableValue } from '@grafana/data';
import { getValueFromEventItem, PromSettings } from './PromSettings';
import { createDefaultConfigOptions } from './mocks';
describe('PromSettings', () => {
describe('getValueFromEventItem', () => {
describe('when called with undefined', () => {
it('then it should return empty string', () => {
const result = getValueFromEventItem(
undefined as unknown as SyntheticEvent<HTMLInputElement> | SelectableValue<string>
);
expect(result).toEqual('');
});
});
describe('when called with an input event', () => {
it('then it should return value from currentTarget', () => {
const value = 'An input value';
const result = getValueFromEventItem({ currentTarget: { value } });
expect(result).toEqual(value);
});
});
describe('when called with a select event', () => {
it('then it should return value', () => {
const value = 'A select value';
const result = getValueFromEventItem({ value });
expect(result).toEqual(value);
});
});
});
describe('PromSettings component', () => {
const defaultProps = createDefaultConfigOptions();
it('should show POST httpMethod if no httpMethod', () => {
const options = defaultProps;
options.url = '';
options.jsonData.httpMethod = '';
render(<PromSettings onOptionsChange={() => {}} options={options} />);
expect(screen.getByText('POST')).toBeInTheDocument();
});
it('should show POST httpMethod if POST httpMethod is configured', () => {
const options = defaultProps;
options.url = 'test_url';
options.jsonData.httpMethod = 'POST';
render(<PromSettings onOptionsChange={() => {}} options={options} />);
expect(screen.getByText('POST')).toBeInTheDocument();
});
it('should show GET httpMethod if GET httpMethod is configured', () => {
const options = defaultProps;
options.url = 'test_url';
options.jsonData.httpMethod = 'GET';
render(<PromSettings onOptionsChange={() => {}} options={options} />);
expect(screen.getByText('GET')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,478 @@
import React, { SyntheticEvent, useState } from 'react';
import {
DataSourcePluginOptionsEditorProps,
onUpdateDatasourceJsonDataOptionChecked,
SelectableValue,
updateDatasourcePluginJsonDataOption,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { ConfigSubSection } from '@grafana/experimental';
import { InlineField, Input, Select, Switch, useTheme2 } from '@grafana/ui';
import { QueryEditorMode } from '../querybuilder/shared/types';
import { defaultPrometheusQueryOverlapWindow } from '../querycache/QueryCache';
import { PromApplication, PrometheusCacheLevel, PromOptions } from '../types';
import { docsTip, overhaulStyles, PROM_CONFIG_LABEL_WIDTH, validateInput } from './ConfigEditor';
import { ExemplarsSettings } from './ExemplarsSettings';
import { PromFlavorVersions } from './PromFlavorVersions';
const httpOptions = [
{ value: 'POST', label: 'POST' },
{ value: 'GET', label: 'GET' },
];
const editorOptions = [
{ value: QueryEditorMode.Builder, label: 'Builder' },
{ value: QueryEditorMode.Code, label: 'Code' },
];
const cacheValueOptions = [
{ value: PrometheusCacheLevel.Low, label: 'Low' },
{ value: PrometheusCacheLevel.Medium, label: 'Medium' },
{ value: PrometheusCacheLevel.High, label: 'High' },
{ value: PrometheusCacheLevel.None, label: 'None' },
];
type PrometheusSelectItemsType = Array<{ value: PromApplication; label: PromApplication }>;
const prometheusFlavorSelectItems: PrometheusSelectItemsType = [
{ value: PromApplication.Prometheus, label: PromApplication.Prometheus },
{ value: PromApplication.Cortex, label: PromApplication.Cortex },
{ value: PromApplication.Mimir, label: PromApplication.Mimir },
{ value: PromApplication.Thanos, label: PromApplication.Thanos },
];
type Props = Pick<DataSourcePluginOptionsEditorProps<PromOptions>, 'options' | 'onOptionsChange'>;
// single duration input
export const DURATION_REGEX = /^$|^\d+(ms|[Mwdhmsy])$/;
// multiple duration input
export const MULTIPLE_DURATION_REGEX = /(\d+)(.+)/;
const durationError = 'Value is not valid, you can use number with time unit specifier: y, M, w, d, h, m, s';
export const PromSettings = (props: Props) => {
const { options, onOptionsChange } = props;
// We are explicitly adding httpMethod so, it is correctly displayed in dropdown.
// This way, it is more predictable for users.
if (!options.jsonData.httpMethod) {
options.jsonData.httpMethod = 'POST';
}
const theme = useTheme2();
const styles = overhaulStyles(theme);
type ValidDuration = {
timeInterval: string;
queryTimeout: string;
incrementalQueryOverlapWindow: string;
};
const [validDuration, updateValidDuration] = useState<ValidDuration>({
timeInterval: '',
queryTimeout: '',
incrementalQueryOverlapWindow: '',
});
return (
<>
<ConfigSubSection title="Interval behaviour" className={styles.container}>
<div className="gf-form-group">
{/* Scrape interval */}
<div className="gf-form-inline">
<div className="gf-form">
<InlineField
label="Scrape interval"
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={
<>
This interval is how frequently Prometheus scrapes targets. Set this to the typical scrape and
evaluation interval configured in your Prometheus config file. If you set this to a greater value
than your Prometheus config file interval, Grafana will evaluate the data according to this interval
and you will see less data points. Defaults to 15s. {docsTip()}
</>
}
interactive={true}
disabled={options.readOnly}
>
<>
<Input
className="width-20"
value={options.jsonData.timeInterval}
spellCheck={false}
placeholder="15s"
onChange={onChangeHandler('timeInterval', options, onOptionsChange)}
onBlur={(e) =>
updateValidDuration({
...validDuration,
timeInterval: e.currentTarget.value,
})
}
data-testid={selectors.components.DataSource.Prometheus.configPage.scrapeInterval}
/>
{validateInput(validDuration.timeInterval, DURATION_REGEX, durationError)}
</>
</InlineField>
</div>
</div>
{/* Query Timeout */}
<div className="gf-form-inline">
<div className="gf-form">
<InlineField
label="Query timeout"
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={<>Set the Prometheus query timeout. {docsTip()}</>}
interactive={true}
disabled={options.readOnly}
>
<>
<Input
className="width-20"
value={options.jsonData.queryTimeout}
onChange={onChangeHandler('queryTimeout', options, onOptionsChange)}
spellCheck={false}
placeholder="60s"
onBlur={(e) =>
updateValidDuration({
...validDuration,
queryTimeout: e.currentTarget.value,
})
}
data-testid={selectors.components.DataSource.Prometheus.configPage.queryTimeout}
/>
{validateInput(validDuration.queryTimeout, DURATION_REGEX, durationError)}
</>
</InlineField>
</div>
</div>
</div>
</ConfigSubSection>
<ConfigSubSection title="Query editor" className={styles.container}>
<div className="gf-form-group">
<div className="gf-form">
<InlineField
label="Default editor"
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={<>Set default editor option for all users of this data source. {docsTip()}</>}
interactive={true}
disabled={options.readOnly}
>
<Select
aria-label={`Default Editor (Code or Builder)`}
options={editorOptions}
value={
editorOptions.find((o) => o.value === options.jsonData.defaultEditor) ??
editorOptions.find((o) => o.value === QueryEditorMode.Builder)
}
onChange={onChangeHandler('defaultEditor', options, onOptionsChange)}
width={40}
data-testid={selectors.components.DataSource.Prometheus.configPage.defaultEditor}
/>
</InlineField>
</div>
<div className="gf-form">
<InlineField
labelWidth={PROM_CONFIG_LABEL_WIDTH}
label="Disable metrics lookup"
tooltip={
<>
Checking this option will disable the metrics chooser and metric/label support in the query
field&apos;s autocomplete. This helps if you have performance issues with bigger Prometheus instances.{' '}
{docsTip()}
</>
}
interactive={true}
disabled={options.readOnly}
className={styles.switchField}
>
<Switch
value={options.jsonData.disableMetricsLookup ?? false}
onChange={onUpdateDatasourceJsonDataOptionChecked(props, 'disableMetricsLookup')}
id={selectors.components.DataSource.Prometheus.configPage.disableMetricLookup}
/>
</InlineField>
</div>
</div>
</ConfigSubSection>
<ConfigSubSection title="Performance" className={styles.container}>
{!options.jsonData.prometheusType && !options.jsonData.prometheusVersion && options.readOnly && (
<div className={styles.versionMargin}>
For more information on configuring prometheus type and version in data sources, see the{' '}
<a
className={styles.textUnderline}
href="https://grafana.com/docs/grafana/latest/administration/provisioning/"
>
provisioning documentation
</a>
.
</div>
)}
<div className="gf-form-group">
<div className="gf-form-inline">
<div className="gf-form">
<InlineField
label="Prometheus type"
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={
<>
{/* , and attempt to detect the version */}
Set this to the type of your prometheus database, e.g. Prometheus, Cortex, Mimir or Thanos. Changing
this field will save your current settings. Certain types of Prometheus supports or does not support
various APIs. For example, some types support regex matching for label queries to improve
performance. Some types have an API for metadata. If you set this incorrectly you may experience odd
behavior when querying metrics and labels. Please check your Prometheus documentation to ensure you
enter the correct type. {docsTip()}
</>
}
interactive={true}
disabled={options.readOnly}
>
<Select
aria-label="Prometheus type"
options={prometheusFlavorSelectItems}
value={prometheusFlavorSelectItems.find((o) => o.value === options.jsonData.prometheusType)}
onChange={onChangeHandler('prometheusType', options, onOptionsChange)}
width={40}
data-testid={selectors.components.DataSource.Prometheus.configPage.prometheusType}
/>
</InlineField>
</div>
</div>
<div className="gf-form-inline">
{options.jsonData.prometheusType && (
<div className="gf-form">
<InlineField
label={`${options.jsonData.prometheusType} version`}
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={
<>
Use this to set the version of your {options.jsonData.prometheusType} instance if it is not
automatically configured. {docsTip()}
</>
}
interactive={true}
disabled={options.readOnly}
>
<Select
aria-label={`${options.jsonData.prometheusType} type`}
options={PromFlavorVersions[options.jsonData.prometheusType]}
value={PromFlavorVersions[options.jsonData.prometheusType]?.find(
(o) => o.value === options.jsonData.prometheusVersion
)}
onChange={onChangeHandler('prometheusVersion', options, onOptionsChange)}
width={40}
data-testid={selectors.components.DataSource.Prometheus.configPage.prometheusVersion}
/>
</InlineField>
</div>
)}
</div>
<div className="gf-form-inline">
<div className="gf-form max-width-30">
<InlineField
label="Cache level"
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={
<>
Sets the browser caching level for editor queries. Higher cache settings are recommended for high
cardinality data sources.
</>
}
interactive={true}
disabled={options.readOnly}
>
<Select
width={40}
onChange={onChangeHandler('cacheLevel', options, onOptionsChange)}
options={cacheValueOptions}
value={
cacheValueOptions.find((o) => o.value === options.jsonData.cacheLevel) ?? PrometheusCacheLevel.Low
}
data-testid={selectors.components.DataSource.Prometheus.configPage.cacheLevel}
/>
</InlineField>
</div>
</div>
<div className="gf-form-inline">
<div className="gf-form max-width-30">
<InlineField
label="Incremental querying (beta)"
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={
<>
This feature will change the default behavior of relative queries to always request fresh data from
the prometheus instance, instead query results will be cached, and only new records are requested.
Turn this on to decrease database and network load.
</>
}
interactive={true}
className={styles.switchField}
disabled={options.readOnly}
>
<Switch
value={options.jsonData.incrementalQuerying ?? false}
onChange={onUpdateDatasourceJsonDataOptionChecked(props, 'incrementalQuerying')}
id={selectors.components.DataSource.Prometheus.configPage.incrementalQuerying}
/>
</InlineField>
</div>
</div>
<div className="gf-form-inline">
{options.jsonData.incrementalQuerying && (
<InlineField
label="Query overlap window"
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={
<>
Set a duration like 10m or 120s or 0s. Default of 10 minutes. This duration will be added to the
duration of each incremental request.
</>
}
interactive={true}
disabled={options.readOnly}
>
<>
<Input
onBlur={(e) =>
updateValidDuration({
...validDuration,
incrementalQueryOverlapWindow: e.currentTarget.value,
})
}
className="width-20"
value={options.jsonData.incrementalQueryOverlapWindow ?? defaultPrometheusQueryOverlapWindow}
onChange={onChangeHandler('incrementalQueryOverlapWindow', options, onOptionsChange)}
spellCheck={false}
data-testid={selectors.components.DataSource.Prometheus.configPage.queryOverlapWindow}
/>
{validateInput(validDuration.incrementalQueryOverlapWindow, MULTIPLE_DURATION_REGEX, durationError)}
</>
</InlineField>
)}
</div>
<div className="gf-form-inline">
<div className="gf-form max-width-30">
<InlineField
label="Disable recording rules (beta)"
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={<>This feature will disable recording rules Turn this on to improve dashboard performance</>}
interactive={true}
className={styles.switchField}
disabled={options.readOnly}
>
<Switch
value={options.jsonData.disableRecordingRules ?? false}
onChange={onUpdateDatasourceJsonDataOptionChecked(props, 'disableRecordingRules')}
id={selectors.components.DataSource.Prometheus.configPage.disableRecordingRules}
/>
</InlineField>
</div>
</div>
</div>
</ConfigSubSection>
<ConfigSubSection title="Other" className={styles.container}>
<div className="gf-form-group">
<div className="gf-form-inline">
<div className="gf-form max-width-30">
<InlineField
label="Custom query parameters"
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={
<>
Add custom parameters to the Prometheus query URL. For example timeout, partial_response, dedup, or
max_source_resolution. Multiple parameters should be concatenated together with an ‘&’. {docsTip()}
</>
}
interactive={true}
disabled={options.readOnly}
>
<Input
className="width-20"
value={options.jsonData.customQueryParameters}
onChange={onChangeHandler('customQueryParameters', options, onOptionsChange)}
spellCheck={false}
placeholder="Example: max_source_resolution=5m&timeout=10"
data-testid={selectors.components.DataSource.Prometheus.configPage.customQueryParameters}
/>
</InlineField>
</div>
</div>
<div className="gf-form-inline">
{/* HTTP Method */}
<div className="gf-form">
<InlineField
labelWidth={PROM_CONFIG_LABEL_WIDTH}
tooltip={
<>
You can use either POST or GET HTTP method to query your Prometheus data source. POST is the
recommended method as it allows bigger queries. Change this to GET if you have a Prometheus version
older than 2.1 or if POST requests are restricted in your network. {docsTip()}
</>
}
interactive={true}
label="HTTP method"
disabled={options.readOnly}
>
<Select
width={40}
aria-label="Select HTTP method"
options={httpOptions}
value={httpOptions.find((o) => o.value === options.jsonData.httpMethod)}
onChange={onChangeHandler('httpMethod', options, onOptionsChange)}
data-testid={selectors.components.DataSource.Prometheus.configPage.httpMethod}
/>
</InlineField>
</div>
</div>
</div>
</ConfigSubSection>
<ExemplarsSettings
options={options.jsonData.exemplarTraceIdDestinations}
onChange={(exemplarOptions) =>
updateDatasourcePluginJsonDataOption(
{ onOptionsChange, options },
'exemplarTraceIdDestinations',
exemplarOptions
)
}
disabled={options.readOnly}
/>
</>
);
};
export const getValueFromEventItem = (eventItem: SyntheticEvent<HTMLInputElement> | SelectableValue<string>) => {
if (!eventItem) {
return '';
}
if ('currentTarget' in eventItem) {
return eventItem.currentTarget.value;
}
return eventItem.value;
};
const onChangeHandler =
(key: keyof PromOptions, options: Props['options'], onOptionsChange: Props['onOptionsChange']) =>
(eventItem: SyntheticEvent<HTMLInputElement> | SelectableValue<string>) => {
onOptionsChange({
...options,
jsonData: {
...options.jsonData,
[key]: getValueFromEventItem(eventItem),
},
});
};
@@ -0,0 +1,7 @@
export * from './AlertingSettingsOverhaul';
export * from './ConfigEditor';
export * from './DataSourceHttpSettingsOverhaul';
export * from './ExemplarSetting';
export * from './ExemplarsSettings';
export * from './PromFlavorVersions';
export * from './PromSettings';
@@ -0,0 +1,14 @@
import { DataSourceSettings } from '@grafana/data';
import { getMockDataSource } from '../gcopypaste/app/features/datasources/__mocks__/dataSourcesMocks';
import { PromOptions } from '../types';
export function createDefaultConfigOptions(): DataSourceSettings<PromOptions> {
return getMockDataSource<PromOptions>({
jsonData: {
timeInterval: '1m',
queryTimeout: '1m',
httpMethod: 'GET',
},
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,834 @@
{
"__inputs": [
{
"name": "DS_GDEV-PROMETHEUS",
"label": "gdev-prometheus",
"description": "",
"type": "datasource",
"pluginId": "prometheus",
"pluginName": "Prometheus"
}
],
"__requires": [
{
"type": "grafana",
"id": "grafana",
"name": "Grafana",
"version": "8.1.0-pre"
},
{
"type": "datasource",
"id": "prometheus",
"name": "Prometheus",
"version": "1.0.0"
},
{
"type": "panel",
"id": "stat",
"name": "Stat",
"version": ""
},
{
"type": "panel",
"id": "text",
"name": "Text",
"version": ""
},
{
"type": "panel",
"id": "timeseries",
"name": "Time series",
"version": ""
}
],
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"gnetId": null,
"graphTooltip": 0,
"id": null,
"iteration": 1624859749459,
"links": [
{
"icon": "info",
"tags": [],
"targetBlank": true,
"title": "Grafana Docs",
"tooltip": "",
"type": "link",
"url": "https://grafana.com/docs/grafana/latest/"
},
{
"icon": "info",
"tags": [],
"targetBlank": true,
"title": "Prometheus Docs",
"type": "link",
"url": "http://prometheus.io/docs/introduction/overview/"
}
],
"panels": [
{
"cacheTimeout": null,
"datasource": "${DS_GDEV-PROMETHEUS}",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 1,
"mappings": [
{
"options": {
"match": "null",
"result": {
"text": "N/A"
}
},
"type": "special"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 6,
"x": 0,
"y": 0
},
"id": 5,
"interval": null,
"links": [],
"maxDataPoints": 100,
"options": {
"colorMode": "none",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"text": {},
"textMode": "auto"
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"expr": "(time() - process_start_time_seconds{job=\"prometheus\", instance=~\"$node\"})",
"intervalFactor": 2,
"refId": "A"
}
],
"title": "Uptime",
"type": "stat"
},
{
"cacheTimeout": null,
"datasource": "${DS_GDEV-PROMETHEUS}",
"fieldConfig": {
"defaults": {
"color": {
"fixedColor": "rgb(31, 120, 193)",
"mode": "fixed"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "rgba(50, 172, 45, 0.97)",
"value": null
},
{
"color": "rgba(237, 129, 40, 0.89)",
"value": 1
},
{
"color": "rgba(245, 54, 54, 0.9)",
"value": 5
}
]
},
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 6,
"x": 6,
"y": 0
},
"id": 6,
"interval": null,
"links": [],
"maxDataPoints": 100,
"options": {
"colorMode": "none",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"text": {},
"textMode": "auto"
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"expr": "prometheus_local_storage_memory_series{instance=~\"$node\"}",
"intervalFactor": 2,
"refId": "A"
}
],
"title": "Local Storage Memory Series",
"type": "stat"
},
{
"cacheTimeout": null,
"datasource": "${DS_GDEV-PROMETHEUS}",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [
{
"options": {
"0": {
"text": "Empty"
}
},
"type": "value"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "rgba(50, 172, 45, 0.97)",
"value": null
},
{
"color": "rgba(237, 129, 40, 0.89)",
"value": 500
},
{
"color": "rgba(245, 54, 54, 0.9)",
"value": 4000
}
]
},
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 6,
"x": 12,
"y": 0
},
"id": 7,
"interval": null,
"links": [],
"maxDataPoints": 100,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"text": {},
"textMode": "auto"
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"expr": "prometheus_local_storage_indexing_queue_length{instance=~\"$node\"}",
"intervalFactor": 2,
"refId": "A"
}
],
"title": "Internal Storage Queue Length",
"type": "stat"
},
{
"datasource": null,
"editable": true,
"error": false,
"gridPos": {
"h": 5,
"w": 6,
"x": 18,
"y": 0
},
"id": 9,
"links": [],
"options": {
"content": "<span style=\"font-family: 'Open Sans', 'Helvetica Neue', Helvetica; font-size: 25px;vertical-align: text-top;color: #bbbfc2;margin-left: 10px;\">Prometheus</span>\n\n<p style=\"margin-top: 10px;\">You're using Prometheus, an open-source systems monitoring and alerting toolkit originally built at SoundCloud. For more information, check out the <a href=\"https://grafana.com/\">Grafana</a> and <a href=\"http://prometheus.io/\">Prometheus</a> projects.</p>",
"mode": "html"
},
"pluginVersion": "8.1.0-pre",
"style": {},
"transparent": true,
"type": "text"
},
{
"datasource": "${DS_GDEV-PROMETHEUS}",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "prometheus"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "#C15C17",
"mode": "fixed"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "{instance=\"localhost:9090\",job=\"prometheus\"}"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "#C15C17",
"mode": "fixed"
}
}
]
}
]
},
"gridPos": {
"h": 6,
"w": 18,
"x": 0,
"y": 5
},
"id": 3,
"links": [],
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"expr": "rate(prometheus_local_storage_ingested_samples_total{instance=~\"$node\"}[5m])",
"interval": "",
"intervalFactor": 2,
"legendFormat": "{{job}}",
"metric": "",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Samples ingested (rate-5m)",
"type": "timeseries"
},
{
"datasource": null,
"editable": true,
"error": false,
"gridPos": {
"h": 6,
"w": 4,
"x": 18,
"y": 5
},
"id": 8,
"links": [],
"options": {
"content": "#### Samples Ingested\nThis graph displays the count of samples ingested by the Prometheus server, as measured over the last 5 minutes, per time series in the range vector. When troubleshooting an issue on IRC or GitHub, this is often the first stat requested by the Prometheus team. ",
"mode": "markdown"
},
"pluginVersion": "8.1.0-pre",
"style": {},
"transparent": true,
"type": "text"
},
{
"datasource": "${DS_GDEV-PROMETHEUS}",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "prometheus"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "#F9BA8F",
"mode": "fixed"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "{instance=\"localhost:9090\",interval=\"5s\",job=\"prometheus\"}"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "#F9BA8F",
"mode": "fixed"
}
}
]
}
]
},
"gridPos": {
"h": 7,
"w": 10,
"x": 0,
"y": 11
},
"id": 2,
"links": [],
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"expr": "rate(prometheus_target_interval_length_seconds_count{instance=~\"$node\"}[5m])",
"intervalFactor": 2,
"legendFormat": "{{job}}",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Target Scrapes (last 5m)",
"type": "timeseries"
},
{
"datasource": "${DS_GDEV-PROMETHEUS}",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 8,
"x": 10,
"y": 11
},
"id": 14,
"links": [],
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"expr": "prometheus_target_interval_length_seconds{quantile!=\"0.01\", quantile!=\"0.05\",instance=~\"$node\"}",
"interval": "",
"intervalFactor": 2,
"legendFormat": "{{quantile}} ({{interval}})",
"metric": "",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Scrape Duration",
"type": "timeseries"
},
{
"datasource": null,
"editable": true,
"error": false,
"gridPos": {
"h": 7,
"w": 6,
"x": 18,
"y": 11
},
"id": 11,
"links": [],
"options": {
"content": "#### Scrapes\nPrometheus scrapes metrics from instrumented jobs, either directly or via an intermediary push gateway for short-lived jobs. Target scrapes will show how frequently targets are scraped, as measured over the last 5 minutes, per time series in the range vector. Scrape Duration will show how long the scrapes are taking, with percentiles available as series. ",
"mode": "markdown"
},
"pluginVersion": "8.1.0-pre",
"style": {},
"transparent": true,
"type": "text"
},
{
"datasource": "${DS_GDEV-PROMETHEUS}",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 18,
"x": 0,
"y": 18
},
"id": 12,
"links": [],
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"pluginVersion": "8.1.0-pre",
"targets": [
{
"expr": "prometheus_evaluator_duration_seconds{quantile!=\"0.01\", quantile!=\"0.05\",instance=~\"$node\"}",
"interval": "",
"intervalFactor": 2,
"legendFormat": "{{quantile}}",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Rule Eval Duration",
"type": "timeseries"
},
{
"datasource": null,
"editable": true,
"error": false,
"gridPos": {
"h": 7,
"w": 6,
"x": 18,
"y": 18
},
"id": 15,
"links": [],
"options": {
"content": "#### Rule Evaluation Duration\nThis graph panel plots the duration for all evaluations to execute. The 50th percentile, 90th percentile and 99th percentile are shown as three separate series to help identify outliers that may be skewing the data.",
"mode": "markdown"
},
"pluginVersion": "8.1.0-pre",
"style": {},
"transparent": true,
"type": "text"
}
],
"refresh": false,
"revision": "1.0",
"schemaVersion": 30,
"tags": ["prometheus"],
"templating": {
"list": [
{
"allValue": null,
"current": {},
"datasource": "${DS_GDEV-PROMETHEUS}",
"definition": "",
"description": null,
"error": null,
"hide": 0,
"includeAll": false,
"label": "HOST:",
"multi": false,
"name": "node",
"options": [],
"query": {
"query": "label_values(prometheus_build_info, instance)",
"refId": "gdev-prometheus-node-Variable-Query"
},
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"tagValuesQuery": "",
"tagsQuery": "",
"type": "query",
"useTags": false
}
]
},
"time": {
"from": "now-5m",
"to": "now"
},
"timepicker": {
"now": true,
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
"time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
},
"timezone": "browser",
"title": "Prometheus Stats",
"uid": "rpfmFFz7z",
"version": 2
}
@@ -0,0 +1,58 @@
// Copyright 2023 Grafana Labs
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package grafanaplugin
import (
common "github.com/grafana/grafana/packages/grafana-schema/src/common"
)
composableKinds: DataQuery: {
maturity: "experimental"
lineage: {
schemas: [{
version: [0, 0]
schema: {
common.DataQuery
// The actual expression/query that will be evaluated by Prometheus
expr: string
// Returns only the latest value that Prometheus has scraped for the requested time series
instant?: bool
// Returns a Range vector, comprised of a set of time series containing a range of data points over time for each time series
range?: bool
// Execute an additional query to identify interesting raw samples relevant for the given expr
exemplar?: bool
// Specifies which editor is being used to prepare the query. It can be "code" or "builder"
editorMode?: #QueryEditorMode
// Query format to determine how to display data points in panel. It can be "time_series", "table", "heatmap"
format?: #PromQueryFormat
// Series name override or template. Ex. {{hostname}} will be replaced with label value for hostname
legendFormat?: string
// @deprecated Used to specify how many times to divide max data points by. We use max data points under query options
// See https://github.com/grafana/grafana/issues/48081
intervalFactor?: number
scope?: {
matchers: string
}
#QueryEditorMode: "code" | "builder" @cuetsy(kind="enum")
#PromQueryFormat: "time_series" | "table" | "heatmap" @cuetsy(kind="type")
}
}]
lenses: []
}
}
@@ -0,0 +1,57 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
//
// Generated by:
// public/app/plugins/gen.go
// Using jennies:
// TSTypesJenny
// PluginTSTypesJenny
//
// Run 'make gen-cue' from repository root to regenerate.
import * as common from '@grafana/schema';
export enum QueryEditorMode {
Builder = 'builder',
Code = 'code',
}
export type PromQueryFormat = ('time_series' | 'table' | 'heatmap');
export interface Prometheus extends common.DataQuery {
/**
* Specifies which editor is being used to prepare the query. It can be "code" or "builder"
*/
editorMode?: QueryEditorMode;
/**
* Execute an additional query to identify interesting raw samples relevant for the given expr
*/
exemplar?: boolean;
/**
* The actual expression/query that will be evaluated by Prometheus
*/
expr: string;
/**
* Query format to determine how to display data points in panel. It can be "time_series", "table", "heatmap"
*/
format?: PromQueryFormat;
/**
* Returns only the latest value that Prometheus has scraped for the requested time series
*/
instant?: boolean;
/**
* @deprecated Used to specify how many times to divide max data points by. We use max data points under query options
* See https://github.com/grafana/grafana/issues/48081
*/
intervalFactor?: number;
/**
* Series name override or template. Ex. {{hostname}} will be replaced with label value for hostname
*/
legendFormat?: string;
/**
* Returns a Range vector, comprised of a set of time series containing a range of data points over time for each time series
*/
range?: boolean;
scope?: {
matchers: string;
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
import React, { useEffect, useState } from 'react';
import store from '../../store';
export interface Props<T> {
storageKey: string;
defaultValue: T;
children: (value: T, onSaveToStore: (value: T) => void, onDeleteFromStore: () => void) => React.ReactNode;
}
export const LocalStorageValueProvider = <T,>(props: Props<T>) => {
const { children, storageKey, defaultValue } = props;
const [state, setState] = useState({ value: store.getObject(props.storageKey, props.defaultValue) });
useEffect(() => {
const onStorageUpdate = (v: StorageEvent) => {
if (v.key === storageKey) {
setState({ value: store.getObject(props.storageKey, props.defaultValue) });
}
};
window.addEventListener('storage', onStorageUpdate);
return () => {
window.removeEventListener('storage', onStorageUpdate);
};
});
const onSaveToStore = (value: T) => {
try {
store.setObject(storageKey, value);
} catch (error) {
console.error(error);
}
setState({ value });
};
const onDeleteFromStore = () => {
try {
store.delete(storageKey);
} catch (error) {
console.log(error);
}
setState({ value: defaultValue });
};
return <>{children(state.value, onSaveToStore, onDeleteFromStore)}</>;
};
@@ -0,0 +1 @@
export { LocalStorageValueProvider } from './LocalStorageValueProvider';
@@ -0,0 +1,65 @@
type StoreValue = string | number | boolean | null;
export class Store {
get(key: string) {
return window.localStorage[key];
}
set(key: string, value: StoreValue) {
window.localStorage[key] = value;
}
getBool(key: string, def: boolean): boolean {
if (def !== void 0 && !this.exists(key)) {
return def;
}
return window.localStorage[key] === 'true';
}
getObject<T = unknown>(key: string): T | undefined;
getObject<T = unknown>(key: string, def: T): T;
getObject<T = unknown>(key: string, def?: T) {
let ret = def;
if (this.exists(key)) {
const json = window.localStorage[key];
try {
ret = JSON.parse(json);
} catch (error) {
console.error(`Error parsing store object: ${key}. Returning default: ${def}. [${error}]`);
}
}
return ret;
}
/* Returns true when successfully stored, throws error if not successfully stored */
setObject(key: string, value: unknown) {
let json;
try {
json = JSON.stringify(value);
} catch (error) {
throw new Error(`Could not stringify object: ${key}. [${error}]`);
}
try {
this.set(key, json);
} catch (error) {
// Likely hitting storage quota
const errorToThrow = new Error(`Could not save item in localStorage: ${key}. [${error}]`);
if (error instanceof Error) {
errorToThrow.name = error.name;
}
throw errorToThrow;
}
return true;
}
exists(key: string) {
return window.localStorage[key] !== void 0;
}
delete(key: string) {
window.localStorage.removeItem(key);
}
}
const store = new Store();
export default store;
@@ -0,0 +1,31 @@
// https://github.com/facebook/react/issues/5465
export interface CancelablePromise<T> {
promise: Promise<T>;
cancel: () => void;
}
export interface CancelablePromiseRejection {
isCanceled: boolean;
}
export function isCancelablePromiseRejection(promise: unknown): promise is CancelablePromiseRejection {
return typeof promise === 'object' && promise !== null && 'isCanceled' in promise;
}
export const makePromiseCancelable = <T>(promise: Promise<T>): CancelablePromise<T> => {
let hasCanceled_ = false;
const wrappedPromise = new Promise<T>((resolve, reject) => {
const canceledPromiseRejection: CancelablePromiseRejection = { isCanceled: true };
promise.then((val) => (hasCanceled_ ? reject(canceledPromiseRejection) : resolve(val)));
promise.catch((error) => (hasCanceled_ ? reject(canceledPromiseRejection) : reject(error)));
});
return {
promise: wrappedPromise,
cancel() {
hasCanceled_ = true;
},
};
};
@@ -0,0 +1,20 @@
import { DataQuery } from '@grafana/data';
export const getNextRefIdChar = (queries: DataQuery[]): string => {
for (let num = 0; ; num++) {
const refId = getRefId(num);
if (!queries.some((query) => query.refId === refId)) {
return refId;
}
}
};
function getRefId(num: number): string {
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
if (num < letters.length) {
return letters[num];
} else {
return getRefId(Math.floor(num / letters.length) - 1) + letters[num % letters.length];
}
}
@@ -0,0 +1,30 @@
import { merge } from 'lodash';
import { DataSourceJsonData, DataSourceSettings } from '@grafana/data';
export const getMockDataSource = <T extends DataSourceJsonData>(
overrides?: Partial<DataSourceSettings<T>>
): DataSourceSettings<T> =>
merge(
{
access: '',
basicAuth: false,
basicAuthUser: '',
withCredentials: false,
database: '',
id: 13,
uid: 'x',
isDefault: false,
jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' },
name: 'gdev-prometheus',
typeName: 'Prometheus',
orgId: 1,
readOnly: false,
type: 'prometheus',
typeLogoUrl: 'packages/grafana-prometheus/src/img/prometheus_logo.svg',
url: '',
user: '',
secureJsonFields: {},
},
overrides
);
@@ -0,0 +1,93 @@
import { closestIdx } from '@grafana/data';
export type Table = [times: number[], ...values: any[][]];
// prevTable and nextTable are assumed sorted ASC on reference [0] arrays
// nextTable is assumed to be contiguous, only edges are checked for overlap
// ...so prev: [1,2,5] + next: [3,4,6] -> [1,2,3,4,6]
export function amendTable(prevTable: Table, nextTable: Table): Table {
let [prevTimes] = prevTable;
let [nextTimes] = nextTable;
let pLen = prevTimes.length;
let pStart = prevTimes[0];
let pEnd = prevTimes[pLen - 1];
let nLen = nextTimes.length;
let nStart = nextTimes[0];
let nEnd = nextTimes[nLen - 1];
let outTable: Table;
if (pLen) {
if (nLen) {
// append, no overlap
if (nStart > pEnd) {
outTable = prevTable.map((_, i) => prevTable[i].concat(nextTable[i])) as Table;
}
// prepend, no overlap
else if (nEnd < pStart) {
outTable = nextTable.map((_, i) => nextTable[i].concat(prevTable[i])) as Table;
}
// full replace
else if (nStart <= pStart && nEnd >= pEnd) {
outTable = nextTable;
}
// partial replace
else if (nStart > pStart && nEnd < pEnd) {
}
// append, with overlap
else if (nStart >= pStart) {
let idx = closestIdx(nStart, prevTimes);
idx = prevTimes[idx] < nStart ? idx - 1 : idx;
outTable = prevTable.map((_, i) => prevTable[i].slice(0, idx).concat(nextTable[i])) as Table;
}
// prepend, with overlap
else if (nEnd >= pStart) {
let idx = closestIdx(nEnd, prevTimes);
idx = prevTimes[idx] < nEnd ? idx : idx + 1;
outTable = nextTable.map((_, i) => nextTable[i].concat(prevTable[i].slice(idx))) as Table;
}
} else {
outTable = prevTable;
}
} else {
if (nLen) {
outTable = nextTable;
} else {
outTable = [[]];
}
}
return outTable!;
}
export function trimTable(table: Table, fromTime: number, toTime: number): Table {
let [times, ...vals] = table;
let fromIdx: number | undefined;
let toIdx: number | undefined;
// trim to bounds
if (times[0] < fromTime) {
fromIdx = closestIdx(fromTime, times);
if (times[fromIdx] < fromTime) {
fromIdx++;
}
}
if (times[times.length - 1] > toTime) {
toIdx = closestIdx(toTime, times);
if (times[toIdx] > toTime) {
toIdx--;
}
}
if (fromIdx != null || toIdx != null) {
times = times.slice(fromIdx ?? 0, toIdx);
vals = vals.map(vals2 => vals2.slice(fromIdx ?? 0, toIdx));
}
return [times, ...vals];
}
@@ -0,0 +1,126 @@
import { cx } from '@emotion/css';
import { max } from 'lodash';
import React, { RefCallback } from 'react';
import { MenuListProps } from 'react-select';
import { FixedSizeList as List } from 'react-window';
import { SelectableValue, toIconName } from '@grafana/data';
import { CustomScrollbar, Icon, getSelectStyles, useTheme2 } from '@grafana/ui';
interface SelectMenuProps {
maxHeight: number;
innerRef: RefCallback<HTMLDivElement>;
innerProps: {};
}
export const SelectMenu = ({ children, maxHeight, innerRef, innerProps }: React.PropsWithChildren<SelectMenuProps>) => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
return (
<div {...innerProps} className={styles.menu} style={{ maxHeight }} aria-label="Select options menu">
<CustomScrollbar scrollRefCallback={innerRef} autoHide={false} autoHeightMax="inherit" hideHorizontalTrack>
{children}
</CustomScrollbar>
</div>
);
};
SelectMenu.displayName = 'SelectMenu';
const VIRTUAL_LIST_ITEM_HEIGHT = 37;
const VIRTUAL_LIST_WIDTH_ESTIMATE_MULTIPLIER = 7;
// A virtualized version of the SelectMenu, descriptions for SelectableValue options not supported since those are of a variable height.
//
// To support the virtualized list we have to "guess" the width of the menu container based on the longest available option.
// the reason for this is because all of the options will be positioned absolute, this takes them out of the document and no space
// is created for them, thus the container can't grow to accomodate.
//
// VIRTUAL_LIST_ITEM_HEIGHT and WIDTH_ESTIMATE_MULTIPLIER are both magic numbers.
// Some characters (such as emojis and other unicode characters) may consist of multiple code points in which case the width would be inaccurate (but larger than needed).
export const VirtualizedSelectMenu = ({ children, maxHeight, options, getValue }: MenuListProps<SelectableValue>) => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
const [value] = getValue();
const valueIndex = value ? options.findIndex((option: SelectableValue<unknown>) => option.value === value.value) : 0;
const initialOffset = valueIndex * VIRTUAL_LIST_ITEM_HEIGHT;
if (!Array.isArray(children)) {
return null;
}
const longestOption = max(options.map((option) => option.label?.length)) ?? 0;
const widthEstimate = longestOption * VIRTUAL_LIST_WIDTH_ESTIMATE_MULTIPLIER;
const heightEstimate = Math.min(options.length * VIRTUAL_LIST_ITEM_HEIGHT, maxHeight);
return (
<List
className={styles.menu}
height={heightEstimate}
width={widthEstimate}
aria-label="Select options menu"
itemCount={children.length}
itemSize={VIRTUAL_LIST_ITEM_HEIGHT}
initialScrollOffset={initialOffset}
>
{({ index, style }) => <div style={{ ...style, overflow: 'hidden' }}>{children[index]}</div>}
</List>
);
};
VirtualizedSelectMenu.displayName = 'VirtualizedSelectMenu';
interface SelectMenuOptionProps<T> {
isDisabled: boolean;
isFocused: boolean;
isSelected: boolean;
innerProps: JSX.IntrinsicElements['div'];
innerRef: RefCallback<HTMLDivElement>;
renderOptionLabel?: (value: SelectableValue<T>) => JSX.Element;
data: SelectableValue<T>;
}
export const SelectMenuOptions = ({
children,
data,
innerProps,
innerRef,
isFocused,
isSelected,
renderOptionLabel,
}: React.PropsWithChildren<SelectMenuOptionProps<unknown>>) => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
const icon = data.icon ? toIconName(data.icon) : undefined;
// We are removing onMouseMove and onMouseOver from innerProps because they cause the whole
// list to re-render everytime the user hovers over an option. This is a performance issue.
// See https://github.com/JedWatson/react-select/issues/3128#issuecomment-451936743
const { onMouseMove, onMouseOver, ...rest } = innerProps;
return (
<div
ref={innerRef}
className={cx(
styles.option,
isFocused && styles.optionFocused,
isSelected && styles.optionSelected,
data.isDisabled && styles.optionDisabled
)}
{...rest}
aria-label="Select option"
title={data.title}
>
{icon && <Icon name={icon} className={styles.optionIcon} />}
{data.imgUrl && <img className={styles.optionImage} src={data.imgUrl} alt={data.label || String(data.value)} />}
<div className={styles.optionBody}>
<span>{renderOptionLabel ? renderOptionLabel(data) : children}</span>
{data.description && <div className={styles.optionDescription}>{data.description}</div>}
{data.component && <data.component />}
</div>
</div>
);
};
SelectMenuOptions.displayName = 'SelectMenuOptions';
@@ -0,0 +1,10 @@
import { Observable } from 'rxjs';
import { toEmitValues } from './toEmitValues';
import { toEmitValuesWith } from './toEmitValuesWith';
import { ObservableMatchers } from './types';
export const matchers: ObservableMatchers<void, Observable<any>> = {
toEmitValues,
toEmitValuesWith,
};
@@ -0,0 +1,140 @@
import { interval, Observable, of, throwError } from 'rxjs';
import { map, mergeMap, take } from 'rxjs/operators';
import { OBSERVABLE_TEST_TIMEOUT_IN_MS } from './types';
describe('toEmitValues matcher', () => {
describe('failing tests', () => {
describe('passing null in expect', () => {
it('should fail', async () => {
const observable = null as unknown as Observable<number>;
const rejects = expect(() => expect(observable).toEmitValues([1, 2, 3])).rejects;
await rejects.toThrow();
});
});
describe('passing undefined in expect', () => {
it('should fail', async () => {
const observable = undefined as unknown as Observable<number>;
const rejects = expect(() => expect(observable).toEmitValues([1, 2, 3])).rejects;
await rejects.toThrow();
});
});
describe('passing number instead of Observable in expect', () => {
it('should fail', async () => {
const observable = 1 as unknown as Observable<number>;
const rejects = expect(() => expect(observable).toEmitValues([1, 2, 3])).rejects;
await rejects.toThrow();
});
});
describe('wrong number of emitted values', () => {
it('should fail', async () => {
const observable = interval(10).pipe(take(3));
const rejects = expect(() => expect(observable).toEmitValues([0, 1])).rejects;
await rejects.toThrow();
});
});
describe('wrong emitted values', () => {
it('should fail', async () => {
const observable = interval(10).pipe(take(3));
const rejects = expect(() => expect(observable).toEmitValues([1, 2, 3])).rejects;
await rejects.toThrow();
});
});
describe('wrong emitted value types', () => {
it('should fail', async () => {
const observable = interval(10).pipe(take(3)) as unknown as Observable<string>;
const rejects = expect(() => expect(observable).toEmitValues(['0', '1', '2'])).rejects;
await rejects.toThrow();
});
});
describe(`observable that does not complete within ${OBSERVABLE_TEST_TIMEOUT_IN_MS}ms`, () => {
it('should fail', async () => {
const observable = interval(600);
const rejects = expect(() => expect(observable).toEmitValues([0])).rejects;
await rejects.toThrow();
});
});
});
describe('passing tests', () => {
describe('correct emitted values', () => {
it('should pass with correct message', async () => {
const observable = interval(10).pipe(take(3));
await expect(observable).toEmitValues([0, 1, 2]);
});
});
describe('using nested arrays', () => {
it('should pass with correct message', async () => {
const observable = interval(10).pipe(
map((interval) => [{ text: interval.toString(), value: interval }]),
take(3)
);
await expect(observable).toEmitValues([
[{ text: '0', value: 0 }],
[{ text: '1', value: 1 }],
[{ text: '2', value: 2 }],
]);
});
});
describe('using nested objects', () => {
it('should pass with correct message', async () => {
const observable = interval(10).pipe(
map((interval) => ({ inner: { text: interval.toString(), value: interval } })),
take(3)
);
await expect(observable).toEmitValues([
{ inner: { text: '0', value: 0 } },
{ inner: { text: '1', value: 1 } },
{ inner: { text: '2', value: 2 } },
]);
});
});
describe('correct emitted values with throw', () => {
it('should pass with correct message', async () => {
const observable = interval(10).pipe(
map((interval) => {
if (interval > 1) {
throw 'an error';
}
return interval;
})
) as unknown as Observable<string | number>;
await expect(observable).toEmitValues([0, 1, 'an error']);
});
});
describe('correct emitted values with throwError', () => {
it('should pass with correct message', async () => {
const observable = interval(10).pipe(
mergeMap((interval) => {
if (interval === 1) {
return throwError('an error');
}
return of(interval);
})
) as unknown as Observable<string | number>;
await expect(observable).toEmitValues([0, 'an error']);
});
});
});
});
@@ -0,0 +1,90 @@
import { matcherHint, printExpected, printReceived } from 'jest-matcher-utils';
import { isEqual } from 'lodash';
import { Observable, Subscription } from 'rxjs';
import { expectObservable, forceObservableCompletion } from './utils';
function passMessage(received: unknown[], expected: unknown[]) {
return `${matcherHint('.not.toEmitValues')}
Expected observable to emit values:
${printExpected(expected)}
Received:
${printReceived(received)}
`;
}
function failMessage(received: unknown[], expected: unknown[]) {
return `${matcherHint('.toEmitValues')}
Expected observable to emit values:
${printExpected(expected)}
Received:
${printReceived(received)}
`;
}
function tryExpectations(received: unknown[], expected: unknown[]): jest.CustomMatcherResult {
try {
if (received.length !== expected.length) {
return {
pass: false,
message: () => failMessage(received, expected),
};
}
for (let index = 0; index < received.length; index++) {
const left = received[index];
const right = expected[index];
if (!isEqual(left, right)) {
return {
pass: false,
message: () => failMessage(received, expected),
};
}
}
return {
pass: true,
message: () => passMessage(received, expected),
};
} catch (err) {
const message = err instanceof Error ? err.message : 'An unknown error occurred';
return {
pass: false,
message: () => message,
};
}
}
export function toEmitValues(received: Observable<unknown>, expected: unknown[]): Promise<jest.CustomMatcherResult> {
const failsChecks = expectObservable(received);
if (failsChecks) {
return Promise.resolve(failsChecks);
}
return new Promise((resolve) => {
const receivedValues: unknown[] = [];
const subscription = new Subscription();
subscription.add(
received.subscribe({
next: (value) => {
receivedValues.push(value);
},
error: (err) => {
receivedValues.push(err);
subscription.unsubscribe();
resolve(tryExpectations(receivedValues, expected));
},
complete: () => {
subscription.unsubscribe();
resolve(tryExpectations(receivedValues, expected));
},
})
);
forceObservableCompletion(subscription, resolve);
});
}
@@ -0,0 +1,153 @@
import { interval, Observable, of, throwError } from 'rxjs';
import { map, mergeMap, take } from 'rxjs/operators';
import { OBSERVABLE_TEST_TIMEOUT_IN_MS } from './types';
describe('toEmitValuesWith matcher', () => {
describe('failing tests', () => {
describe('passing null in expect', () => {
it('should fail with correct message', async () => {
const observable = null as unknown as Observable<number>;
const rejects = expect(() =>
expect(observable).toEmitValuesWith((received) => {
expect(received).toEqual([1, 2, 3]);
})
).rejects;
await rejects.toThrow();
});
});
describe('passing undefined in expect', () => {
it('should fail with correct message', async () => {
const observable = undefined as unknown as Observable<number>;
const rejects = expect(() =>
expect(observable).toEmitValuesWith((received) => {
expect(received).toEqual([1, 2, 3]);
})
).rejects;
await rejects.toThrow();
});
});
describe('passing number instead of Observable in expect', () => {
it('should fail with correct message', async () => {
const observable = 1 as unknown as Observable<number>;
const rejects = expect(() =>
expect(observable).toEmitValuesWith((received) => {
expect(received).toEqual([1, 2, 3]);
})
).rejects;
await rejects.toThrow();
});
});
describe('wrong number of emitted values', () => {
it('should fail with correct message', async () => {
const observable = interval(10).pipe(take(3));
const rejects = expect(() =>
expect(observable).toEmitValuesWith((received) => {
expect(received).toEqual([0, 1]);
})
).rejects;
await rejects.toThrow();
});
});
describe('wrong emitted values', () => {
it('should fail with correct message', async () => {
const observable = interval(10).pipe(take(3));
const rejects = expect(() =>
expect(observable).toEmitValuesWith((received) => {
expect(received).toEqual([1, 2, 3]);
})
).rejects;
await rejects.toThrow();
});
});
describe('wrong emitted value types', () => {
it('should fail with correct message', async () => {
const observable = interval(10).pipe(take(3)) as unknown as Observable<string>;
const rejects = expect(() =>
expect(observable).toEmitValuesWith((received) => {
expect(received).toEqual(['0', '1', '2']);
})
).rejects;
await rejects.toThrow();
});
});
describe(`observable that does not complete within ${OBSERVABLE_TEST_TIMEOUT_IN_MS}ms`, () => {
it('should fail with correct message', async () => {
const observable = interval(600);
const rejects = expect(() =>
expect(observable).toEmitValuesWith((received) => {
expect(received).toEqual([0]);
})
).rejects;
await rejects.toThrow();
});
});
});
describe('passing tests', () => {
describe('correct emitted values', () => {
it('should pass with correct message', async () => {
const observable = interval(10).pipe(take(3));
await expect(observable).toEmitValuesWith((received) => {
expect(received).toEqual([0, 1, 2]);
});
});
});
describe('correct emitted values with throw', () => {
it('should pass with correct message', async () => {
const observable = interval(10).pipe(
map((interval) => {
if (interval > 1) {
throw 'an error';
}
return interval;
})
);
await expect(observable).toEmitValuesWith((received) => {
expect(received).toEqual([0, 1, 'an error']);
});
});
});
describe('correct emitted values with throwError', () => {
it('should pass with correct message', async () => {
const observable = interval(10).pipe(
mergeMap((interval) => {
if (interval === 1) {
return throwError('an error');
}
return of(interval);
})
);
await expect(observable).toEmitValuesWith((received) => {
expect(received).toEqual([0, 'an error']);
});
});
});
});
});
@@ -0,0 +1,62 @@
import { matcherHint, printReceived } from 'jest-matcher-utils';
import { Observable, Subscription } from 'rxjs';
import { expectObservable, forceObservableCompletion } from './utils';
function tryExpectations(received: unknown[], expectations: (received: unknown[]) => void): jest.CustomMatcherResult {
try {
expectations(received);
return {
pass: true,
message: () => `${matcherHint('.not.toEmitValues')}
Expected observable to complete with
${printReceived(received)}
`,
};
} catch (err) {
return {
pass: false,
message: () => 'failed ' + err,
};
}
}
/**
* Collect all the values emitted by the observables (also errors) and pass them to the expectations functions after
* the observable ended (or emitted error). If Observable does not complete within OBSERVABLE_TEST_TIMEOUT_IN_MS the
* test fails.
*/
export function toEmitValuesWith(
received: Observable<any>,
expectations: (actual: any[]) => void
): Promise<jest.CustomMatcherResult> {
const failsChecks = expectObservable(received);
if (failsChecks) {
return Promise.resolve(failsChecks);
}
return new Promise((resolve) => {
const receivedValues: any[] = [];
const subscription = new Subscription();
subscription.add(
received.subscribe({
next: (value) => {
receivedValues.push(value);
},
error: (err) => {
receivedValues.push(err);
subscription.unsubscribe();
resolve(tryExpectations(receivedValues, expectations));
},
complete: () => {
subscription.unsubscribe();
resolve(tryExpectations(receivedValues, expectations));
},
})
);
forceObservableCompletion(subscription, resolve);
});
}
@@ -0,0 +1,13 @@
import { Observable } from 'rxjs';
export const OBSERVABLE_TEST_TIMEOUT_IN_MS = 1000;
export type ObservableType<T> = T extends Observable<infer V> ? V : never;
export interface ObservableMatchers<R, T = {}> extends jest.ExpectExtendMap {
toEmitValues<E = ObservableType<T>>(received: T, expected: E[]): Promise<jest.CustomMatcherResult>;
toEmitValuesWith<E = ObservableType<T>>(
received: T,
expectations: (received: E[]) => void
): Promise<jest.CustomMatcherResult>;
}
@@ -0,0 +1,63 @@
import { matcherHint, printExpected, printReceived } from 'jest-matcher-utils';
import { asapScheduler, Subscription, timer, isObservable } from 'rxjs';
import { OBSERVABLE_TEST_TIMEOUT_IN_MS } from './types';
export function forceObservableCompletion(subscription: Subscription, resolve: (args: any) => void) {
const timeoutObservable = timer(OBSERVABLE_TEST_TIMEOUT_IN_MS, asapScheduler);
subscription.add(
timeoutObservable.subscribe(() => {
subscription.unsubscribe();
resolve({
pass: false,
message: () =>
`${matcherHint('.toEmitValues')}
Expected ${printReceived('Observable')} to be ${printExpected(
`completed within ${OBSERVABLE_TEST_TIMEOUT_IN_MS}ms`
)} but it did not.`,
});
})
);
}
export function expectObservableToBeDefined(received: unknown): jest.CustomMatcherResult | null {
if (received) {
return null;
}
return {
pass: false,
message: () => `${matcherHint('.toEmitValues')}
Expected ${printReceived(received)} to be ${printExpected('defined')}.`,
};
}
export function expectObservableToBeObservable(received: unknown): jest.CustomMatcherResult | null {
if (isObservable(received)) {
return null;
}
return {
pass: false,
message: () => `${matcherHint('.toEmitValues')}
Expected ${printReceived(received)} to be ${printExpected('an Observable')}.`,
};
}
export function expectObservable(received: unknown): jest.CustomMatcherResult | null {
const toBeDefined = expectObservableToBeDefined(received);
if (toBeDefined) {
return toBeDefined;
}
const toBeObservable = expectObservableToBeObservable(received);
if (toBeObservable) {
return toBeObservable;
}
return null;
}
@@ -0,0 +1,23 @@
import { Matcher, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { select } from 'react-select-event';
import { byRole } from 'testing-library-selector';
// Used to select an option or options from a Select in unit tests
export const selectOptionInTest = async (
input: HTMLElement,
optionOrOptions: string | RegExp | Array<string | RegExp>
) => await waitFor(() => select(input, optionOrOptions, { container: document.body }));
// Finds the parent of the Select so you can assert if it has a value
export const getSelectParent = (input: HTMLElement) =>
input.parentElement?.parentElement?.parentElement?.parentElement?.parentElement;
export const clickSelectOption = async (selectElement: HTMLElement, optionText: string): Promise<void> => {
await userEvent.click(byRole('combobox').get(selectElement));
await selectOptionInTest(selectElement, optionText);
};
export const clickSelectOptionMatch = async (selectElement: HTMLElement, optionText: Matcher): Promise<void> => {
await userEvent.click(byRole('combobox').get(selectElement));
await selectOptionInTest(selectElement, optionText as string);
};
@@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g fill="#3b697e"><path d="m64.001 32c0-17.646-14.356-32.001-32.001-32.001-17.646 0-32.001 14.355-32.001 32.001s14.355 32.001 32.001 32.001 32.001-14.355 32.001-32.001zm-62.592 0c0-16.868 13.723-30.591 30.591-30.591s30.591 13.723 30.591 30.591-13.723 30.591-30.591 30.591-30.591-13.723-30.591-30.591z" fill-rule="nonzero"/><circle cx="49.3" cy="32.725" r="5.296"/><circle cx="31.875" cy="52.94" r="5.296"/><path d="m31.148 33.103 3.295-8.185 2.988 8.538h2.684c.226 2.71 1.641 5.186 3.861 6.757l-8.659 8.659.665.665 8.808-8.808c1.378.774 2.932 1.18 4.512 1.18 5.056 0 9.216-4.16 9.216-9.215 0-5.056-4.16-9.216-9.216-9.216-1.464 0-2.907.349-4.209 1.017l-9.039-8.587c.797-.953 1.234-2.156 1.234-3.399 0-2.906-2.391-5.297-5.297-5.297s-5.297 2.391-5.297 5.297 2.391 5.297 5.297 5.297c1.242 0 2.445-.436 3.397-1.233l8.86 8.417c-2.542 1.666-4.102 4.486-4.162 7.525h-1.987l-3.594-10.267-3.992 9.915h-4.457l-3.788 8.766-3.341-8.353-.01.004v-.09h-3.378c-.124-2.816-2.474-5.064-5.292-5.064-2.907 0-5.298 2.391-5.298 5.298 0 2.906 2.391 5.298 5.298 5.298 2.64 0 4.898-1.975 5.25-4.592h2.759l3.978 9.947 4.44-10.274zm18.152-8.685c4.54 0 8.276 3.736 8.276 8.276s-3.736 8.276-8.276 8.276-8.276-3.736-8.276-8.276c.005-4.538 3.737-8.271 8.276-8.276z" fill-rule="nonzero"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientTransform="matrix(0 14.3827 -14.3827 0 7.68261 2.43042)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#f2c144"/><stop offset=".24" stop-color="#f1a03b"/><stop offset=".57" stop-color="#f17a31"/><stop offset=".84" stop-color="#f0632a"/><stop offset="1" stop-color="#f05a28"/></linearGradient><path d="m1.941 13.102h2.194l1.538-2.953-1.065-2.043zm11.935-5.079-1.2 2.303 1.406 2.742 1.2-2.313zm-.364-.705-2.562-4.971-1.263 2.228 2.623 5.049zm-3.991 3.039 1.429 2.743h2.436l-2.648-5.083zm-5.276-2.952-1.195-2.305-1.525 2.933 1.172 2.269zm-3.077 1.327-1.09 2.152 1.2 2.179 1.063-2.057zm8.113-3.494-1.27 2.243 1.146 2.179 1.219-2.34zm-4.792-2.98-1.096 2.133 4.264 8.154 1.137-2.182-2.155-4.135z" fill="url(#a)" fill-rule="nonzero"/></svg>

After

Width:  |  Height:  |  Size: 995 B

@@ -0,0 +1 @@
<svg width="2490" height="2500" viewBox="0 0 256 257" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M128.001.667C57.311.667 0 57.971 0 128.664c0 70.69 57.311 127.998 128.001 127.998S256 199.354 256 128.664C256 57.97 198.689.667 128.001.667zm0 239.56c-20.112 0-36.419-13.435-36.419-30.004h72.838c0 16.566-16.306 30.004-36.419 30.004zm60.153-39.94H67.842V178.47h120.314v21.816h-.002zm-.432-33.045H68.185c-.398-.458-.804-.91-1.188-1.375-12.315-14.954-15.216-22.76-18.032-30.716-.048-.262 14.933 3.06 25.556 5.45 0 0 5.466 1.265 13.458 2.722-7.673-8.994-12.23-20.428-12.23-32.116 0-25.658 19.68-48.079 12.58-66.201 6.91.562 14.3 14.583 14.8 36.505 7.346-10.152 10.42-28.69 10.42-40.056 0-11.769 7.755-25.44 15.512-25.907-6.915 11.396 1.79 21.165 9.53 45.4 2.902 9.103 2.532 24.423 4.772 34.138.744-20.178 4.213-49.62 17.014-59.784-5.647 12.8.836 28.818 5.27 36.518 7.154 12.424 11.49 21.836 11.49 39.638 0 11.936-4.407 23.173-11.84 31.958 8.452-1.586 14.289-3.016 14.289-3.016l27.45-5.355c.002-.002-3.987 16.401-19.314 32.197z" fill="#DA4E31"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="2.41 2.16 355.42 355.17"><path fill="#fff" d="M9.49808 9.14822v341.59786h241.93485a99.66306 99.66306 0 0 0 99.657-99.663V9.14822zm266.263 215.32837h12.75033v12.75018h-12.75035zm-4.00963-54.89h20.71531v20.72131h-20.71533zm-3.186-54.10261h27.09335v27.11729h-27.09343zm-43.739 159.90321h12.7381v12.75018h-12.75017zm-3.98558-55.53932h20.72129V240.587h-20.72129zm-3.186-53.44731h27.12344v27.09337h-27.09339zm3.186-27.00322v-20.71528h20.72129v20.71527zm-97.82954 135.98981h12.75021v12.75018h-12.75021zm-3.98558-54.8901h20.72131v20.69121h-20.72124zm3.98558-34.1748v-12.75019h12.75021v12.75018zm-3.98558-67.64019h20.72131v20.71527h-20.72124zM72.10081 275.38715H84.875v12.75018H72.10081zm0-50.91056H84.875v12.75018H72.10081zm-3.97955-54.89h20.7153v20.72131h-20.7153zm-3.19206-54.10265h27.09939v27.11729H64.9292zM53.01459 52.67679h254.53469v50.91052H205.74622v203.6302h-50.90449v-203.6302H53.01459z"/></svg>

After

Width:  |  Height:  |  Size: 958 B

+1
View File
@@ -0,0 +1 @@
export * from './configuration';
@@ -0,0 +1,18 @@
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([]);
fetchSeriesLabelsMatch = jest.fn().mockReturnValue([]);
fetchLabelsWithMatch = jest.fn().mockReturnValue([]);
fetchLabels = jest.fn();
loadMetricsMetadata = jest.fn();
}
@@ -0,0 +1,393 @@
import { AbstractLabelOperator, dateTime, TimeRange } from '@grafana/data';
import { Label } from './components/monaco-query-field/monaco-completion-provider/situation';
import { PrometheusDatasource } from './datasource';
import LanguageProvider from './language_provider';
import { getClientCacheDurationInMinutes, getPrometheusTime, getRangeSnapInterval } from './language_utils';
import { PrometheusCacheLevel } from './types';
const now = new Date(1681300293392).getTime();
const timeRangeDurationSeconds = 1;
const toPrometheusTime = getPrometheusTime(dateTime(now), false);
const fromPrometheusTime = getPrometheusTime(dateTime(now - timeRangeDurationSeconds * 1000), false);
const toPrometheusTimeString = toPrometheusTime.toString(10);
const fromPrometheusTimeString = fromPrometheusTime.toString(10);
const getMockTimeRange = (): TimeRange => {
return {
to: dateTime(now).utc(),
from: dateTime(now).subtract(timeRangeDurationSeconds, 'second').utc(),
raw: {
from: fromPrometheusTimeString,
to: toPrometheusTimeString,
},
};
};
const getTimeRangeParams = (
timRange: TimeRange,
override?: Partial<{ start: string; end: string }>
): { start: string; end: string } => ({
start: fromPrometheusTimeString,
end: toPrometheusTimeString,
...override,
});
const getMockQuantizedTimeRangeParams = (override?: Partial<TimeRange>): TimeRange => ({
from: dateTime(fromPrometheusTime * 1000),
to: dateTime(toPrometheusTime * 1000),
raw: {
from: `now-${timeRangeDurationSeconds}s`,
to: 'now',
},
...override,
});
describe('Language completion provider', () => {
const defaultDatasource: PrometheusDatasource = {
metadataRequest: () => ({ data: { data: [] } }),
getTimeRangeParams: getTimeRangeParams,
interpolateString: (string: string) => string,
hasLabelsMatchAPISupport: () => false,
getQuantizedTimeRangeParams: () =>
getRangeSnapInterval(PrometheusCacheLevel.None, getMockQuantizedTimeRangeParams()),
getDaysToCacheMetadata: () => 1,
getAdjustedInterval: () => getRangeSnapInterval(PrometheusCacheLevel.None, getMockQuantizedTimeRangeParams()),
cacheLevel: PrometheusCacheLevel.None,
} as unknown as PrometheusDatasource;
describe('cleanText', () => {
const cleanText = new LanguageProvider(defaultDatasource).cleanText;
it('does not remove metric or label keys', () => {
expect(cleanText('foo')).toBe('foo');
expect(cleanText('foo_bar')).toBe('foo_bar');
});
it('keeps trailing space but removes leading', () => {
expect(cleanText('foo ')).toBe('foo ');
expect(cleanText(' foo')).toBe('foo');
});
it('removes label syntax', () => {
expect(cleanText('foo="bar')).toBe('bar');
expect(cleanText('foo!="bar')).toBe('bar');
expect(cleanText('foo=~"bar')).toBe('bar');
expect(cleanText('foo!~"bar')).toBe('bar');
expect(cleanText('{bar')).toBe('bar');
});
it('removes previous operators', () => {
expect(cleanText('foo + bar')).toBe('bar');
expect(cleanText('foo+bar')).toBe('bar');
expect(cleanText('foo - bar')).toBe('bar');
expect(cleanText('foo * bar')).toBe('bar');
expect(cleanText('foo / bar')).toBe('bar');
expect(cleanText('foo % bar')).toBe('bar');
expect(cleanText('foo ^ bar')).toBe('bar');
expect(cleanText('foo and bar')).toBe('bar');
expect(cleanText('foo or bar')).toBe('bar');
expect(cleanText('foo unless bar')).toBe('bar');
expect(cleanText('foo == bar')).toBe('bar');
expect(cleanText('foo != bar')).toBe('bar');
expect(cleanText('foo > bar')).toBe('bar');
expect(cleanText('foo < bar')).toBe('bar');
expect(cleanText('foo >= bar')).toBe('bar');
expect(cleanText('foo <= bar')).toBe('bar');
expect(cleanText('memory')).toBe('memory');
});
it('removes aggregation syntax', () => {
expect(cleanText('(bar')).toBe('bar');
expect(cleanText('(foo,bar')).toBe('bar');
expect(cleanText('(foo, bar')).toBe('bar');
});
it('removes range syntax', () => {
expect(cleanText('[1m')).toBe('1m');
});
});
describe('getSeriesLabels', () => {
it('should call labels endpoint', () => {
const languageProvider = new LanguageProvider({
...defaultDatasource,
hasLabelsMatchAPISupport: () => true,
} as PrometheusDatasource);
const getSeriesLabels = languageProvider.getSeriesLabels;
const requestSpy = jest.spyOn(languageProvider, 'request');
const labelName = 'job';
const labelValue = 'grafana';
getSeriesLabels(`{${labelName}="${labelValue}"}`, [{ name: labelName, value: labelValue, op: '=' }] as Label[]);
expect(requestSpy).toHaveBeenCalled();
expect(requestSpy).toHaveBeenCalledWith(
`/api/v1/labels`,
[],
{
end: toPrometheusTimeString,
'match[]': '{job="grafana"}',
start: fromPrometheusTimeString,
},
undefined
);
});
it('should call series endpoint', () => {
const languageProvider = new LanguageProvider({
...defaultDatasource,
getAdjustedInterval: (timeRange: TimeRange) =>
getRangeSnapInterval(PrometheusCacheLevel.None, getMockQuantizedTimeRangeParams()),
} as PrometheusDatasource);
const getSeriesLabels = languageProvider.getSeriesLabels;
const requestSpy = jest.spyOn(languageProvider, 'request');
const labelName = 'job';
const labelValue = 'grafana';
getSeriesLabels(`{${labelName}="${labelValue}"}`, [{ name: labelName, value: labelValue, op: '=' }] as Label[]);
expect(requestSpy).toHaveBeenCalled();
expect(requestSpy).toHaveBeenCalledWith(
'/api/v1/series',
[],
{
end: toPrometheusTimeString,
'match[]': '{job="grafana"}',
start: fromPrometheusTimeString,
},
undefined
);
});
it('should call labels endpoint with quantized start', () => {
const timeSnapMinutes = getClientCacheDurationInMinutes(PrometheusCacheLevel.Low);
const languageProvider = new LanguageProvider({
...defaultDatasource,
hasLabelsMatchAPISupport: () => true,
cacheLevel: PrometheusCacheLevel.Low,
getAdjustedInterval: (timeRange: TimeRange) =>
getRangeSnapInterval(PrometheusCacheLevel.Low, getMockQuantizedTimeRangeParams()),
getCacheDurationInMinutes: () => timeSnapMinutes,
} as PrometheusDatasource);
const getSeriesLabels = languageProvider.getSeriesLabels;
const requestSpy = jest.spyOn(languageProvider, 'request');
const labelName = 'job';
const labelValue = 'grafana';
getSeriesLabels(`{${labelName}="${labelValue}"}`, [{ name: labelName, value: labelValue, op: '=' }] as Label[]);
expect(requestSpy).toHaveBeenCalled();
expect(requestSpy).toHaveBeenCalledWith(
`/api/v1/labels`,
[],
{
end: (
dateTime(fromPrometheusTime * 1000)
.add(timeSnapMinutes, 'minute')
.startOf('minute')
.valueOf() / 1000
).toString(),
'match[]': '{job="grafana"}',
start: (
dateTime(toPrometheusTime * 1000)
.startOf('minute')
.valueOf() / 1000
).toString(),
},
{ headers: { 'X-Grafana-Cache': `private, max-age=${timeSnapMinutes * 60}` } }
);
});
});
describe('getSeriesValues', () => {
it('should call old series endpoint and should use match[] parameter', () => {
const languageProvider = new LanguageProvider({
...defaultDatasource,
} as PrometheusDatasource);
const getSeriesValues = languageProvider.getSeriesValues;
const requestSpy = jest.spyOn(languageProvider, 'request');
getSeriesValues('job', '{job="grafana"}');
expect(requestSpy).toHaveBeenCalled();
expect(requestSpy).toHaveBeenCalledWith(
'/api/v1/series',
[],
{
end: toPrometheusTimeString,
'match[]': '{job="grafana"}',
start: fromPrometheusTimeString,
},
undefined
);
});
it('should call new series endpoint and should use match[] parameter', () => {
const languageProvider = new LanguageProvider({
...defaultDatasource,
hasLabelsMatchAPISupport: () => true,
} as PrometheusDatasource);
const getSeriesValues = languageProvider.getSeriesValues;
const requestSpy = jest.spyOn(languageProvider, 'request');
const labelName = 'job';
const labelValue = 'grafana';
getSeriesValues(labelName, `{${labelName}="${labelValue}"}`);
expect(requestSpy).toHaveBeenCalled();
expect(requestSpy).toHaveBeenCalledWith(
`/api/v1/label/${labelName}/values`,
[],
{
end: toPrometheusTimeString,
'match[]': `{${labelName}="${labelValue}"}`,
start: fromPrometheusTimeString,
},
undefined
);
});
it('should call old series endpoint and should use match[] parameter and interpolate the template variables', () => {
const languageProvider = new LanguageProvider({
...defaultDatasource,
interpolateString: (string: string) => string.replace(/\$/, 'interpolated-'),
} as PrometheusDatasource);
const getSeriesValues = languageProvider.getSeriesValues;
const requestSpy = jest.spyOn(languageProvider, 'request');
getSeriesValues('job', '{instance="$instance", job="grafana"}');
expect(requestSpy).toHaveBeenCalled();
expect(requestSpy).toHaveBeenCalledWith(
'/api/v1/series',
[],
{
end: toPrometheusTimeString,
'match[]': '{instance="interpolated-instance", job="grafana"}',
start: fromPrometheusTimeString,
},
undefined
);
});
});
describe('fetchSeries', () => {
it('should use match[] parameter', async () => {
const languageProvider = new LanguageProvider(defaultDatasource);
const timeRange = getMockTimeRange();
await languageProvider.start(timeRange);
const requestSpy = jest.spyOn(languageProvider, 'request');
await languageProvider.fetchSeries('{job="grafana"}');
expect(requestSpy).toHaveBeenCalled();
expect(requestSpy).toHaveBeenCalledWith(
'/api/v1/series',
{},
{
end: toPrometheusTimeString,
'match[]': '{job="grafana"}',
start: fromPrometheusTimeString,
},
undefined
);
});
});
describe('fetchSeriesLabels', () => {
it('should interpolate variable in series', () => {
const languageProvider = new LanguageProvider({
...defaultDatasource,
interpolateString: (string: string) => string.replace(/\$/, 'interpolated-'),
} as PrometheusDatasource);
const fetchSeriesLabels = languageProvider.fetchSeriesLabels;
const requestSpy = jest.spyOn(languageProvider, 'request');
fetchSeriesLabels('$metric');
expect(requestSpy).toHaveBeenCalled();
expect(requestSpy).toHaveBeenCalledWith(
'/api/v1/series',
[],
{
end: toPrometheusTimeString,
'match[]': 'interpolated-metric',
start: fromPrometheusTimeString,
},
undefined
);
});
});
describe('fetchLabelValues', () => {
it('should interpolate variable in series', () => {
const languageProvider = new LanguageProvider({
...defaultDatasource,
interpolateString: (string: string) => string.replace(/\$/, 'interpolated-'),
} as PrometheusDatasource);
const fetchLabelValues = languageProvider.fetchLabelValues;
const requestSpy = jest.spyOn(languageProvider, 'request');
fetchLabelValues('$job');
expect(requestSpy).toHaveBeenCalled();
expect(requestSpy).toHaveBeenCalledWith(
'/api/v1/label/interpolated-job/values',
[],
{
end: toPrometheusTimeString,
start: fromPrometheusTimeString,
},
undefined
);
});
});
describe('disabled metrics lookup', () => {
it('issues metadata requests when lookup is not disabled', async () => {
const datasource: PrometheusDatasource = {
...defaultDatasource,
metadataRequest: jest.fn(() => ({ data: { data: ['foo', 'bar'] as string[] } })),
lookupsDisabled: false,
} as unknown as PrometheusDatasource;
const mockedMetadataRequest = jest.mocked(datasource.metadataRequest);
const instance = new LanguageProvider(datasource);
expect(mockedMetadataRequest.mock.calls.length).toBe(0);
await instance.start();
expect(mockedMetadataRequest.mock.calls.length).toBeGreaterThan(0);
});
it('doesnt blow up if metadata or fetchLabels rejects', async () => {
jest.spyOn(console, 'error').mockImplementation();
const datasource: PrometheusDatasource = {
...defaultDatasource,
metadataRequest: jest.fn(() => Promise.reject('rejected')),
lookupsDisabled: false,
} as unknown as PrometheusDatasource;
const mockedMetadataRequest = jest.mocked(datasource.metadataRequest);
const instance = new LanguageProvider(datasource);
expect(mockedMetadataRequest.mock.calls.length).toBe(0);
const result = await instance.start();
expect(result[0]).toBeUndefined();
expect(result[1]).toEqual([]);
expect(mockedMetadataRequest.mock.calls.length).toBe(3);
});
});
describe('Query imports', () => {
it('returns empty queries', async () => {
const instance = new LanguageProvider(defaultDatasource);
const result = await instance.importFromAbstractQuery({ refId: 'bar', labelMatchers: [] });
expect(result).toEqual({ refId: 'bar', expr: '', range: true });
});
describe('exporting to abstract query', () => {
it('exports labels with metric name', async () => {
const instance = new LanguageProvider(defaultDatasource);
const abstractQuery = instance.exportToAbstractQuery({
refId: 'bar',
expr: 'metric_name{label1="value1", label2!="value2", label3=~"value3", label4!~"value4"}',
instant: true,
range: false,
});
expect(abstractQuery).toMatchObject({
refId: 'bar',
labelMatchers: [
{ name: 'label1', operator: AbstractLabelOperator.Equal, value: 'value1' },
{ name: 'label2', operator: AbstractLabelOperator.NotEqual, value: 'value2' },
{ name: 'label3', operator: AbstractLabelOperator.EqualRegEx, value: 'value3' },
{ name: 'label4', operator: AbstractLabelOperator.NotEqualRegEx, value: 'value4' },
{ name: '__name__', operator: AbstractLabelOperator.Equal, value: 'metric_name' },
],
});
});
});
});
});
@@ -0,0 +1,378 @@
import { once } from 'lodash';
import Prism from 'prismjs';
import {
AbstractLabelMatcher,
AbstractLabelOperator,
AbstractQuery,
getDefaultTimeRange,
LanguageProvider,
TimeRange,
} from '@grafana/data';
import { BackendSrvRequest } from '@grafana/runtime';
import { Label } from './components/monaco-query-field/monaco-completion-provider/situation';
import { PrometheusDatasource } from './datasource';
import {
extractLabelMatchers,
fixSummariesMetadata,
processHistogramMetrics,
processLabels,
toPromLikeQuery,
} from './language_utils';
import PromqlSyntax from './promql';
import { PrometheusCacheLevel, PromMetricsMetadata, PromQuery } from './types';
const DEFAULT_KEYS = ['job', 'instance'];
const EMPTY_SELECTOR = '{}';
// Max number of items (metrics, labels, values) that we display as suggestions. Prevents from running out of memory.
export const SUGGESTIONS_LIMIT = 10000;
const buildCacheHeaders = (durationInSeconds: number) => {
return {
headers: {
'X-Grafana-Cache': `private, max-age=${durationInSeconds}`,
},
};
};
export function getMetadataString(metric: string, metadata: PromMetricsMetadata): string | undefined {
if (!metadata[metric]) {
return undefined;
}
const { type, help } = metadata[metric];
return `${type.toUpperCase()}: ${help}`;
}
export function getMetadataHelp(metric: string, metadata: PromMetricsMetadata): string | undefined {
if (!metadata[metric]) {
return undefined;
}
return metadata[metric].help;
}
export function getMetadataType(metric: string, metadata: PromMetricsMetadata): string | undefined {
if (!metadata[metric]) {
return undefined;
}
return metadata[metric].type;
}
const PREFIX_DELIMITER_REGEX =
/(="|!="|=~"|!~"|\{|\[|\(|\+|-|\/|\*|%|\^|\band\b|\bor\b|\bunless\b|==|>=|!=|<=|>|<|=|~|,)/;
const secondsInDay = 86400;
export default class PromQlLanguageProvider extends LanguageProvider {
histogramMetrics: string[];
timeRange: TimeRange;
metrics: string[];
metricsMetadata?: PromMetricsMetadata;
declare startTask: Promise<any>;
datasource: PrometheusDatasource;
labelKeys: string[] = [];
declare labelFetchTs: number;
constructor(datasource: PrometheusDatasource, initialValues?: Partial<PromQlLanguageProvider>) {
super();
this.datasource = datasource;
this.histogramMetrics = [];
this.timeRange = getDefaultTimeRange();
this.metrics = [];
Object.assign(this, initialValues);
}
getDefaultCacheHeaders() {
if (this.datasource.cacheLevel !== PrometheusCacheLevel.None) {
return buildCacheHeaders(this.datasource.getCacheDurationInMinutes() * 60);
}
return;
}
// Strip syntax chars so that typeahead suggestions can work on clean inputs
cleanText(s: string) {
const parts = s.split(PREFIX_DELIMITER_REGEX);
const last = parts.pop()!;
return last.trimLeft().replace(/"$/, '').replace(/^"/, '');
}
get syntax() {
return PromqlSyntax;
}
request = async (url: string, defaultValue: any, params = {}, options?: Partial<BackendSrvRequest>): Promise<any> => {
try {
const res = await this.datasource.metadataRequest(url, params, options);
return res.data.data;
} catch (error) {
console.error(error);
}
return defaultValue;
};
start = async (timeRange?: TimeRange): Promise<any[]> => {
this.timeRange = timeRange ?? getDefaultTimeRange();
if (this.datasource.lookupsDisabled) {
return [];
}
this.metrics = (await this.fetchLabelValues('__name__')) || [];
this.histogramMetrics = processHistogramMetrics(this.metrics).sort();
return Promise.all([this.loadMetricsMetadata(), this.fetchLabels()]);
};
async loadMetricsMetadata() {
const headers = buildCacheHeaders(this.datasource.getDaysToCacheMetadata() * secondsInDay);
this.metricsMetadata = fixSummariesMetadata(
await this.request(
'/api/v1/metadata',
{},
{},
{
showErrorAlert: false,
...headers,
}
)
);
}
getLabelKeys(): string[] {
return this.labelKeys;
}
importFromAbstractQuery(labelBasedQuery: AbstractQuery): PromQuery {
return toPromLikeQuery(labelBasedQuery);
}
exportToAbstractQuery(query: PromQuery): AbstractQuery {
const promQuery = query.expr;
if (!promQuery || promQuery.length === 0) {
return { refId: query.refId, labelMatchers: [] };
}
const tokens = Prism.tokenize(promQuery, PromqlSyntax);
const labelMatchers: AbstractLabelMatcher[] = extractLabelMatchers(tokens);
const nameLabelValue = getNameLabelValue(promQuery, tokens);
if (nameLabelValue && nameLabelValue.length > 0) {
labelMatchers.push({
name: '__name__',
operator: AbstractLabelOperator.Equal,
value: nameLabelValue,
});
}
return {
refId: query.refId,
labelMatchers,
};
}
async getSeries(selector: string, withName?: boolean): Promise<Record<string, string[]>> {
if (this.datasource.lookupsDisabled) {
return {};
}
try {
if (selector === EMPTY_SELECTOR) {
return await this.fetchDefaultSeries();
} else {
return await this.fetchSeriesLabels(selector, withName);
}
} catch (error) {
// TODO: better error handling
console.error(error);
return {};
}
}
/**
* @param key
*/
fetchLabelValues = async (key: string): Promise<string[]> => {
const params = this.datasource.getAdjustedInterval(this.timeRange);
const interpolatedName = this.datasource.interpolateString(key);
const url = `/api/v1/label/${interpolatedName}/values`;
const value = await this.request(url, [], params, this.getDefaultCacheHeaders());
return value ?? [];
};
async getLabelValues(key: string): Promise<string[]> {
return await this.fetchLabelValues(key);
}
/**
* Fetches all label keys
*/
async fetchLabels(timeRange?: TimeRange): Promise<string[]> {
if (timeRange) {
this.timeRange = timeRange;
}
const url = '/api/v1/labels';
const params = this.datasource.getAdjustedInterval(this.timeRange);
this.labelFetchTs = Date.now().valueOf();
const res = await this.request(url, [], params, this.getDefaultCacheHeaders());
if (Array.isArray(res)) {
this.labelKeys = res.slice().sort();
}
return [];
}
/**
* Gets series values
* Function to replace old getSeries calls in a way that will provide faster endpoints for new prometheus instances,
* while maintaining backward compatability
* @param labelName
* @param selector
*/
getSeriesValues = async (labelName: string, selector: string): Promise<string[]> => {
if (!this.datasource.hasLabelsMatchAPISupport()) {
const data = await this.getSeries(selector);
return data[labelName] ?? [];
}
return await this.fetchSeriesValuesWithMatch(labelName, selector);
};
/**
* Fetches all values for a label, with optional match[]
* @param name
* @param match
* @param timeRange
*/
fetchSeriesValuesWithMatch = async (
name: string,
match?: string,
timeRange: TimeRange = this.timeRange
): Promise<string[]> => {
const interpolatedName = name ? this.datasource.interpolateString(name) : null;
const interpolatedMatch = match ? this.datasource.interpolateString(match) : null;
const range = this.datasource.getAdjustedInterval(timeRange);
const urlParams = {
...range,
...(interpolatedMatch && { 'match[]': interpolatedMatch }),
};
const value = await this.request(
`/api/v1/label/${interpolatedName}/values`,
[],
urlParams,
this.getDefaultCacheHeaders()
);
return value ?? [];
};
/**
* Gets series labels
* Function to replace old getSeries calls in a way that will provide faster endpoints for new prometheus instances,
* while maintaining backward compatability. The old API call got the labels and the values in a single query,
* but with the new query we need two calls, one to get the labels, and another to get the values.
*
* @param selector
* @param otherLabels
*/
getSeriesLabels = async (selector: string, otherLabels: Label[]): Promise<string[]> => {
let possibleLabelNames, data: Record<string, string[]>;
if (!this.datasource.hasLabelsMatchAPISupport()) {
data = await this.getSeries(selector);
possibleLabelNames = Object.keys(data); // all names from prometheus
} else {
// Exclude __name__ from output
otherLabels.push({ name: '__name__', value: '', op: '!=' });
data = await this.fetchSeriesLabelsMatch(selector);
possibleLabelNames = Object.keys(data);
}
const usedLabelNames = new Set(otherLabels.map((l) => l.name)); // names used in the query
return possibleLabelNames.filter((l) => !usedLabelNames.has(l));
};
/**
* Fetch labels using the best endpoint that datasource supports.
* This is cached by its args but also by the global timeRange currently selected as they can change over requested time.
* @param name
* @param withName
*/
fetchLabelsWithMatch = async (name: string, withName?: boolean): Promise<Record<string, string[]>> => {
if (this.datasource.hasLabelsMatchAPISupport()) {
return this.fetchSeriesLabelsMatch(name, withName);
} else {
return this.fetchSeriesLabels(name, withName);
}
};
/**
* Fetch labels for a series using /series endpoint. This is cached by its args but also by the global timeRange currently selected as
* they can change over requested time.
* @param name
* @param withName
*/
fetchSeriesLabels = async (name: string, withName?: boolean): Promise<Record<string, string[]>> => {
const interpolatedName = this.datasource.interpolateString(name);
const range = this.datasource.getAdjustedInterval(this.timeRange);
const urlParams = {
...range,
'match[]': interpolatedName,
};
const url = `/api/v1/series`;
const data = await this.request(url, [], urlParams, this.getDefaultCacheHeaders());
const { values } = processLabels(data, withName);
return values;
};
/**
* Fetch labels for a series using /labels endpoint. This is cached by its args but also by the global timeRange currently selected as
* they can change over requested time.
* @param name
* @param withName
*/
fetchSeriesLabelsMatch = async (name: string, withName?: boolean): Promise<Record<string, string[]>> => {
const interpolatedName = this.datasource.interpolateString(name);
const range = this.datasource.getAdjustedInterval(this.timeRange);
const urlParams = {
...range,
'match[]': interpolatedName,
};
const url = `/api/v1/labels`;
const data: string[] = await this.request(url, [], urlParams, this.getDefaultCacheHeaders());
// Convert string array to Record<string , []>
return data.reduce((ac, a) => ({ ...ac, [a]: '' }), {});
};
/**
* Fetch series for a selector. Use this for raw results. Use fetchSeriesLabels() to get labels.
* @param match
*/
fetchSeries = async (match: string): Promise<Array<Record<string, string>>> => {
const url = '/api/v1/series';
const range = this.datasource.getTimeRangeParams(this.timeRange);
const params = { ...range, 'match[]': match };
return await this.request(url, {}, params, this.getDefaultCacheHeaders());
};
/**
* Fetch this only one as we assume this won't change over time. This is cached differently from fetchSeriesLabels
* because we can cache more aggressively here and also we do not want to invalidate this cache the same way as in
* fetchSeriesLabels.
*/
fetchDefaultSeries = once(async () => {
const values = await Promise.all(DEFAULT_KEYS.map((key) => this.fetchLabelValues(key)));
return DEFAULT_KEYS.reduce((acc, key, i) => ({ ...acc, [key]: values[i] }), {});
});
}
function getNameLabelValue(promQuery: string, tokens: any): string {
let nameLabelValue = '';
for (const token of tokens) {
if (typeof token === 'string') {
nameLabelValue = token;
break;
}
}
return nameLabelValue;
}
@@ -0,0 +1,495 @@
import { Moment } from 'moment';
import { AbstractLabelOperator, AbstractQuery, DateTime, dateTime, TimeRange } from '@grafana/data';
import {
escapeLabelValueInExactSelector,
escapeLabelValueInRegexSelector,
expandRecordingRules,
fixSummariesMetadata,
getPrometheusTime,
getRangeSnapInterval,
parseSelector,
toPromLikeQuery,
truncateResult,
} from './language_utils';
import { PrometheusCacheLevel } from './types';
describe('parseSelector()', () => {
let parsed;
it('returns a clean selector from an empty selector', () => {
parsed = parseSelector('{}', 1);
expect(parsed.selector).toBe('{}');
expect(parsed.labelKeys).toEqual([]);
});
it('returns a clean selector from an unclosed selector', () => {
const parsed = parseSelector('{foo');
expect(parsed.selector).toBe('{}');
});
it('returns the selector sorted by label key', () => {
parsed = parseSelector('{foo="bar"}');
expect(parsed.selector).toBe('{foo="bar"}');
expect(parsed.labelKeys).toEqual(['foo']);
parsed = parseSelector('{foo="bar",baz="xx"}');
expect(parsed.selector).toBe('{baz="xx",foo="bar"}');
});
it('returns a clean selector from an incomplete one', () => {
parsed = parseSelector('{foo}');
expect(parsed.selector).toBe('{}');
parsed = parseSelector('{foo="bar",baz}');
expect(parsed.selector).toBe('{foo="bar"}');
parsed = parseSelector('{foo="bar",baz="}');
expect(parsed.selector).toBe('{foo="bar"}');
// Cursor in value area counts as incomplete
parsed = parseSelector('{foo="bar",baz=""}', 16);
expect(parsed.selector).toBe('{foo="bar"}');
parsed = parseSelector('{foo="bar",baz="4"}', 17);
expect(parsed.selector).toBe('{foo="bar"}');
});
it('throws if not inside a selector', () => {
expect(() => parseSelector('foo{}', 0)).toThrow();
expect(() => parseSelector('foo{} + bar{}', 5)).toThrow();
});
it('returns the selector nearest to the cursor offset', () => {
expect(() => parseSelector('{foo="bar"} + {foo="bar"}', 0)).toThrow();
parsed = parseSelector('{foo="bar"} + {foo="bar"}', 1);
expect(parsed.selector).toBe('{foo="bar"}');
parsed = parseSelector('{foo="bar"} + {baz="xx"}', 1);
expect(parsed.selector).toBe('{foo="bar"}');
parsed = parseSelector('{baz="xx"} + {foo="bar"}', 16);
expect(parsed.selector).toBe('{foo="bar"}');
});
it('returns a selector with metric if metric is given', () => {
parsed = parseSelector('bar{foo}', 4);
expect(parsed.selector).toBe('{__name__="bar"}');
parsed = parseSelector('baz{foo="bar"}', 13);
expect(parsed.selector).toBe('{__name__="baz",foo="bar"}');
parsed = parseSelector('bar:metric:1m{}', 14);
expect(parsed.selector).toBe('{__name__="bar:metric:1m"}');
});
});
describe('fixSummariesMetadata', () => {
const synthetics = {
ALERTS: {
type: 'counter',
help: 'Time series showing pending and firing alerts. The sample value is set to 1 as long as the alert is in the indicated active (pending or firing) state.',
},
};
it('returns only synthetics on empty metadata', () => {
expect(fixSummariesMetadata({})).toEqual({ ...synthetics });
});
it('returns unchanged metadata if no summary is present', () => {
const metadataRaw = {
foo: [{ type: 'not_a_summary', help: 'foo help' }],
};
const metadata = {
foo: { type: 'not_a_summary', help: 'foo help' },
};
expect(fixSummariesMetadata(metadataRaw)).toEqual({ ...metadata, ...synthetics });
});
it('returns metadata with added count and sum for a summary', () => {
const metadata = {
foo: [{ type: 'not_a_summary', help: 'foo help' }],
bar: [{ type: 'summary', help: 'bar help' }],
};
const expected = {
foo: { type: 'not_a_summary', help: 'foo help' },
bar: { type: 'summary', help: 'bar help' },
bar_count: {
type: 'counter',
help: 'Count of events that have been observed for the base metric (bar help)',
},
bar_sum: { type: 'counter', help: 'Total sum of all observed values for the base metric (bar help)' },
};
expect(fixSummariesMetadata(metadata)).toEqual({ ...expected, ...synthetics });
});
it('returns metadata with added bucket/count/sum for a histogram', () => {
const metadata = {
foo: [{ type: 'not_a_histogram', help: 'foo help' }],
bar: [{ type: 'histogram', help: 'bar help' }],
};
const expected = {
foo: { type: 'not_a_histogram', help: 'foo help' },
bar: { type: 'histogram', help: 'bar help' },
bar_bucket: { type: 'counter', help: 'Cumulative counters for the observation buckets (bar help)' },
bar_count: {
type: 'counter',
help: 'Count of events that have been observed for the histogram metric (bar help)',
},
bar_sum: { type: 'counter', help: 'Total sum of all observed values for the histogram metric (bar help)' },
};
expect(fixSummariesMetadata(metadata)).toEqual({ ...expected, ...synthetics });
});
});
describe('expandRecordingRules()', () => {
it('returns query w/o recording rules as is', () => {
expect(expandRecordingRules('metric', {})).toBe('metric');
expect(expandRecordingRules('metric + metric', {})).toBe('metric + metric');
expect(expandRecordingRules('metric{}', {})).toBe('metric{}');
});
it('does not modify recording rules name in label values', () => {
expect(expandRecordingRules('{__name__="metric"} + bar', { metric: 'foo', bar: 'super' })).toBe(
'{__name__="metric"} + super'
);
});
it('returns query with expanded recording rules', () => {
expect(expandRecordingRules('metric', { metric: 'foo' })).toBe('foo');
expect(expandRecordingRules('metric + metric', { metric: 'foo' })).toBe('foo + foo');
expect(expandRecordingRules('metric{}', { metric: 'foo' })).toBe('foo{}');
expect(expandRecordingRules('metric[]', { metric: 'foo' })).toBe('foo[]');
expect(expandRecordingRules('metric + foo', { metric: 'foo', foo: 'bar' })).toBe('foo + bar');
});
it('returns query with labels with expanded recording rules', () => {
expect(
expandRecordingRules('metricA{label1="value1"} / metricB{label2="value2"}', {
metricA: 'fooA',
metricB: 'fooB',
})
).toBe('fooA{label1="value1"} / fooB{label2="value2"}');
expect(
expandRecordingRules('metricA{label1="value1",label2="value,2"}', {
metricA: 'rate(fooA[])',
})
).toBe('rate(fooA{label1="value1", label2="value,2"}[])');
expect(
expandRecordingRules('metricA{label1="value1"} / metricB{label2="value2"}', {
metricA: 'rate(fooA[])',
metricB: 'rate(fooB[])',
})
).toBe('rate(fooA{label1="value1"}[])/ rate(fooB{label2="value2"}[])');
expect(
expandRecordingRules('metricA{label1="value1",label2="value2"} / metricB{label3="value3"}', {
metricA: 'rate(fooA[])',
metricB: 'rate(fooB[])',
})
).toBe('rate(fooA{label1="value1", label2="value2"}[])/ rate(fooB{label3="value3"}[])');
});
});
describe('escapeLabelValueInExactSelector()', () => {
it('handles newline characters', () => {
expect(escapeLabelValueInExactSelector('t\nes\nt')).toBe('t\\nes\\nt');
});
it('handles backslash characters', () => {
expect(escapeLabelValueInExactSelector('t\\es\\t')).toBe('t\\\\es\\\\t');
});
it('handles double-quote characters', () => {
expect(escapeLabelValueInExactSelector('t"es"t')).toBe('t\\"es\\"t');
});
it('handles all together', () => {
expect(escapeLabelValueInExactSelector('t\\e"st\nl\nab"e\\l')).toBe('t\\\\e\\"st\\nl\\nab\\"e\\\\l');
});
});
describe('escapeLabelValueInRegexSelector()', () => {
it('handles newline characters', () => {
expect(escapeLabelValueInRegexSelector('t\nes\nt')).toBe('t\\nes\\nt');
});
it('handles backslash characters', () => {
expect(escapeLabelValueInRegexSelector('t\\es\\t')).toBe('t\\\\\\\\es\\\\\\\\t');
});
it('handles double-quote characters', () => {
expect(escapeLabelValueInRegexSelector('t"es"t')).toBe('t\\"es\\"t');
});
it('handles regex-meaningful characters', () => {
expect(escapeLabelValueInRegexSelector('t+es$t')).toBe('t\\\\+es\\\\$t');
});
it('handles all together', () => {
expect(escapeLabelValueInRegexSelector('t\\e"s+t\nl\n$ab"e\\l')).toBe(
't\\\\\\\\e\\"s\\\\+t\\nl\\n\\\\$ab\\"e\\\\\\\\l'
);
});
});
describe('getRangeSnapInterval', () => {
it('will not change input if set to no cache', () => {
const intervalSeconds = 10 * 60; // 10 minutes
const now = new Date().valueOf();
const expectedFrom = dateTime(now - intervalSeconds * 1000);
const expectedTo = dateTime(now);
const range: TimeRange = {
from: expectedFrom,
to: expectedTo,
} as TimeRange;
expect(getRangeSnapInterval(PrometheusCacheLevel.None, range)).toEqual({
start: getPrometheusTime(expectedFrom, false).toString(),
end: getPrometheusTime(expectedTo, true).toString(),
});
});
it('will snap range to closest minute', () => {
const queryDurationMinutes = 10;
const intervalSeconds = queryDurationMinutes * 60; // 10 minutes
const now = 1680901009826;
const nowPlusOneMinute = now + 1000 * 60;
const nowPlusTwoMinute = now + 1000 * 60 * 2;
const nowTime = dateTime(now) as Moment;
const expectedFrom = nowTime.clone().startOf('minute').subtract(queryDurationMinutes, 'minute');
const expectedTo = nowTime.clone().startOf('minute').add(1, 'minute');
const range: TimeRange = {
from: dateTime(now - intervalSeconds * 1000),
to: dateTime(now),
} as TimeRange;
const range2: TimeRange = {
from: dateTime(nowPlusOneMinute - intervalSeconds * 1000),
to: dateTime(nowPlusOneMinute),
raw: {
from: dateTime(nowPlusOneMinute - intervalSeconds * 1000),
to: dateTime(nowPlusOneMinute),
},
};
const range3: TimeRange = {
from: dateTime(nowPlusTwoMinute - intervalSeconds * 1000),
to: dateTime(nowPlusTwoMinute),
raw: {
from: dateTime(nowPlusTwoMinute - intervalSeconds * 1000),
to: dateTime(nowPlusTwoMinute),
},
};
const first = getRangeSnapInterval(PrometheusCacheLevel.Low, range);
const second = getRangeSnapInterval(PrometheusCacheLevel.Low, range2);
const third = getRangeSnapInterval(PrometheusCacheLevel.Low, range3);
expect(first).toEqual({
start: getPrometheusTime(expectedFrom as DateTime, false).toString(10),
end: getPrometheusTime(expectedTo as DateTime, false).toString(10),
});
expect(second).toEqual({
start: getPrometheusTime(expectedFrom.clone().add(1, 'minute') as DateTime, false).toString(10),
end: getPrometheusTime(expectedTo.clone().add(1, 'minute') as DateTime, false).toString(10),
});
expect(third).toEqual({
start: getPrometheusTime(expectedFrom.clone().add(2, 'minute') as DateTime, false).toString(10),
end: getPrometheusTime(expectedTo.clone().add(2, 'minute') as DateTime, false).toString(10),
});
});
it('will snap range to closest 10 minute', () => {
const queryDurationMinutes = 60;
const intervalSeconds = queryDurationMinutes * 60; // 10 minutes
const now = 1680901009826;
const nowPlusOneMinute = now + 1000 * 60;
const nowPlusTwoMinute = now + 1000 * 60 * 2;
const nowTime = dateTime(now) as Moment;
const nowTimePlusOne = dateTime(nowPlusOneMinute) as Moment;
const nowTimePlusTwo = dateTime(nowPlusTwoMinute) as Moment;
const calculateClosest10 = (date: Moment): Moment => {
const numberOfMinutes = Math.floor(date.minutes() / 10) * 10;
const numberOfHours = numberOfMinutes < 60 ? date.hours() : date.hours() + 1;
return date
.clone()
.minutes(numberOfMinutes % 60)
.hours(numberOfHours);
};
const expectedFromFirst = calculateClosest10(
nowTime.clone().startOf('minute').subtract(queryDurationMinutes, 'minute')
);
const expectedToFirst = calculateClosest10(nowTime.clone().startOf('minute').add(1, 'minute'));
const expectedFromSecond = calculateClosest10(
nowTimePlusOne.clone().startOf('minute').subtract(queryDurationMinutes, 'minute')
);
const expectedToSecond = calculateClosest10(nowTimePlusOne.clone().startOf('minute').add(1, 'minute'));
const expectedFromThird = calculateClosest10(
nowTimePlusTwo.clone().startOf('minute').subtract(queryDurationMinutes, 'minute')
);
const expectedToThird = calculateClosest10(nowTimePlusTwo.clone().startOf('minute').add(1, 'minute'));
const range: TimeRange = {
from: dateTime(now - intervalSeconds * 1000),
to: dateTime(now),
} as TimeRange;
const range2: TimeRange = {
from: dateTime(nowPlusOneMinute - intervalSeconds * 1000),
to: dateTime(nowPlusOneMinute),
raw: {
from: dateTime(nowPlusOneMinute - intervalSeconds * 1000),
to: dateTime(nowPlusOneMinute),
},
};
const range3: TimeRange = {
from: dateTime(nowPlusTwoMinute - intervalSeconds * 1000),
to: dateTime(nowPlusTwoMinute),
raw: {
from: dateTime(nowPlusTwoMinute - intervalSeconds * 1000),
to: dateTime(nowPlusTwoMinute),
},
};
const first = getRangeSnapInterval(PrometheusCacheLevel.Medium, range);
const second = getRangeSnapInterval(PrometheusCacheLevel.Medium, range2);
const third = getRangeSnapInterval(PrometheusCacheLevel.Medium, range3);
expect(first).toEqual({
start: getPrometheusTime(expectedFromFirst as DateTime, false).toString(10),
end: getPrometheusTime(expectedToFirst as DateTime, false).toString(10),
});
expect(second).toEqual({
start: getPrometheusTime(expectedFromSecond.clone() as DateTime, false).toString(10),
end: getPrometheusTime(expectedToSecond.clone() as DateTime, false).toString(10),
});
expect(third).toEqual({
start: getPrometheusTime(expectedFromThird.clone() as DateTime, false).toString(10),
end: getPrometheusTime(expectedToThird.clone() as DateTime, false).toString(10),
});
});
it('will snap range to closest 60 minute', () => {
const queryDurationMinutes = 120;
const intervalSeconds = queryDurationMinutes * 60;
const now = 1680901009826;
const nowPlusOneMinute = now + 1000 * 60;
const nowPlusTwoMinute = now + 1000 * 60 * 2;
const nowTime = dateTime(now) as Moment;
const nowTimePlusOne = dateTime(nowPlusOneMinute) as Moment;
const nowTimePlusTwo = dateTime(nowPlusTwoMinute) as Moment;
const calculateClosest60 = (date: Moment): Moment => {
const numberOfMinutes = Math.floor(date.minutes() / 60) * 60;
const numberOfHours = numberOfMinutes < 60 ? date.hours() : date.hours() + 1;
return date
.clone()
.minutes(numberOfMinutes % 60)
.hours(numberOfHours);
};
const expectedFromFirst = calculateClosest60(
nowTime.clone().startOf('minute').subtract(queryDurationMinutes, 'minute')
);
const expectedToFirst = calculateClosest60(nowTime.clone().startOf('minute').add(1, 'minute'));
const expectedFromSecond = calculateClosest60(
nowTimePlusOne.clone().startOf('minute').subtract(queryDurationMinutes, 'minute')
);
const expectedToSecond = calculateClosest60(nowTimePlusOne.clone().startOf('minute').add(1, 'minute'));
const expectedFromThird = calculateClosest60(
nowTimePlusTwo.clone().startOf('minute').subtract(queryDurationMinutes, 'minute')
);
const expectedToThird = calculateClosest60(nowTimePlusTwo.clone().startOf('minute').add(1, 'minute'));
const range: TimeRange = {
from: dateTime(now - intervalSeconds * 1000),
to: dateTime(now),
} as TimeRange;
const range2: TimeRange = {
from: dateTime(nowPlusOneMinute - intervalSeconds * 1000),
to: dateTime(nowPlusOneMinute),
raw: {
from: dateTime(nowPlusOneMinute - intervalSeconds * 1000),
to: dateTime(nowPlusOneMinute),
},
};
const range3: TimeRange = {
from: dateTime(nowPlusTwoMinute - intervalSeconds * 1000),
to: dateTime(nowPlusTwoMinute),
raw: {
from: dateTime(nowPlusTwoMinute - intervalSeconds * 1000),
to: dateTime(nowPlusTwoMinute),
},
};
const first = getRangeSnapInterval(PrometheusCacheLevel.High, range);
const second = getRangeSnapInterval(PrometheusCacheLevel.High, range2);
const third = getRangeSnapInterval(PrometheusCacheLevel.High, range3);
expect(first).toEqual({
start: getPrometheusTime(expectedFromFirst as DateTime, false).toString(10),
end: getPrometheusTime(expectedToFirst as DateTime, false).toString(10),
});
expect(second).toEqual({
start: getPrometheusTime(expectedFromSecond.clone() as DateTime, false).toString(10),
end: getPrometheusTime(expectedToSecond.clone() as DateTime, false).toString(10),
});
expect(third).toEqual({
start: getPrometheusTime(expectedFromThird.clone() as DateTime, false).toString(10),
end: getPrometheusTime(expectedToThird.clone() as DateTime, false).toString(10),
});
});
});
describe('toPromLikeQuery', () => {
it('export abstract query to PromQL-like query', () => {
const abstractQuery: AbstractQuery = {
refId: 'bar',
labelMatchers: [
{ name: 'label1', operator: AbstractLabelOperator.Equal, value: 'value1' },
{ name: 'label2', operator: AbstractLabelOperator.NotEqual, value: 'value2' },
{ name: 'label3', operator: AbstractLabelOperator.EqualRegEx, value: 'value3' },
{ name: 'label4', operator: AbstractLabelOperator.NotEqualRegEx, value: 'value4' },
],
};
expect(toPromLikeQuery(abstractQuery)).toMatchObject({
refId: 'bar',
expr: '{label1="value1", label2!="value2", label3=~"value3", label4!~"value4"}',
range: true,
});
});
});
describe('truncateResult', () => {
it('truncates array longer then 1k from the start of array', () => {
// creates an array of 1k + 1 elements with values from 0 to 1k
const array = Array.from(Array(1001).keys());
expect(array[1000]).toBe(1000);
truncateResult(array);
expect(array.length).toBe(1000);
expect(array[0]).toBe(0);
expect(array[999]).toBe(999);
});
});
@@ -0,0 +1,439 @@
import { invert } from 'lodash';
import { Token } from 'prismjs';
import {
AbstractLabelMatcher,
AbstractLabelOperator,
AbstractQuery,
DataQuery,
dateMath,
DateTime,
incrRoundDn,
TimeRange,
} from '@grafana/data';
import { addLabelToQuery } from './add_label_to_query';
import { SUGGESTIONS_LIMIT } from './language_provider';
import { PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from './querybuilder/components/MetricSelect';
import { PrometheusCacheLevel, PromMetricsMetadata, PromMetricsMetadataItem } from './types';
export const processHistogramMetrics = (metrics: string[]) => {
const resultSet: Set<string> = new Set();
const regexp = new RegExp('_bucket($|:)');
for (let index = 0; index < metrics.length; index++) {
const metric = metrics[index];
const isHistogramValue = regexp.test(metric);
if (isHistogramValue) {
resultSet.add(metric);
}
}
return [...resultSet];
};
export function processLabels(labels: Array<{ [key: string]: string }>, withName = false) {
// For processing we are going to use sets as they have significantly better performance than arrays
// After we process labels, we will convert sets to arrays and return object with label values in arrays
const valueSet: { [key: string]: Set<string> } = {};
labels.forEach((label) => {
const { __name__, ...rest } = label;
if (withName) {
valueSet['__name__'] = valueSet['__name__'] || new Set();
if (!valueSet['__name__'].has(__name__)) {
valueSet['__name__'].add(__name__);
}
}
Object.keys(rest).forEach((key) => {
if (!valueSet[key]) {
valueSet[key] = new Set();
}
if (!valueSet[key].has(rest[key])) {
valueSet[key].add(rest[key]);
}
});
});
// valueArray that we are going to return in the object
const valueArray: { [key: string]: string[] } = {};
limitSuggestions(Object.keys(valueSet)).forEach((key) => {
valueArray[key] = limitSuggestions(Array.from(valueSet[key]));
});
return { values: valueArray, keys: Object.keys(valueArray) };
}
// const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/;
export const selectorRegexp = /\{[^}]*?(\}|$)/;
export const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g;
export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any[]; selector: string } {
if (!query.match(selectorRegexp)) {
// Special matcher for metrics
if (query.match(/^[A-Za-z:][\w:]*$/)) {
return {
selector: `{__name__="${query}"}`,
labelKeys: ['__name__'],
};
}
throw new Error('Query must contain a selector: ' + query);
}
// Check if inside a selector
const prefix = query.slice(0, cursorOffset);
const prefixOpen = prefix.lastIndexOf('{');
const prefixClose = prefix.lastIndexOf('}');
if (prefixOpen === -1) {
throw new Error('Not inside selector, missing open brace: ' + prefix);
}
if (prefixClose > -1 && prefixClose > prefixOpen) {
throw new Error('Not inside selector, previous selector already closed: ' + prefix);
}
const suffix = query.slice(cursorOffset);
const suffixCloseIndex = suffix.indexOf('}');
const suffixClose = suffixCloseIndex + cursorOffset;
const suffixOpenIndex = suffix.indexOf('{');
const suffixOpen = suffixOpenIndex + cursorOffset;
if (suffixClose === -1) {
throw new Error('Not inside selector, missing closing brace in suffix: ' + suffix);
}
if (suffixOpenIndex > -1 && suffixOpen < suffixClose) {
throw new Error('Not inside selector, next selector opens before this one closed: ' + suffix);
}
// Extract clean labels to form clean selector, incomplete labels are dropped
const selector = query.slice(prefixOpen, suffixClose);
const labels: { [key: string]: { value: string; operator: string } } = {};
selector.replace(labelRegexp, (label, key, operator, value) => {
const labelOffset = query.indexOf(label);
const valueStart = labelOffset + key.length + operator.length + 1;
const valueEnd = labelOffset + key.length + operator.length + value.length - 1;
// Skip label if cursor is in value
if (cursorOffset < valueStart || cursorOffset > valueEnd) {
labels[key] = { value, operator };
}
return '';
});
// Add metric if there is one before the selector
const metricPrefix = query.slice(0, prefixOpen);
const metricMatch = metricPrefix.match(/[A-Za-z:][\w:]*$/);
if (metricMatch) {
labels['__name__'] = { value: `"${metricMatch[0]}"`, operator: '=' };
}
// Build sorted selector
const labelKeys = Object.keys(labels).sort();
const cleanSelector = labelKeys.map((key) => `${key}${labels[key].operator}${labels[key].value}`).join(',');
const selectorString = ['{', cleanSelector, '}'].join('');
return { labelKeys, selector: selectorString };
}
export function expandRecordingRules(query: string, mapping: { [name: string]: string }): string {
const ruleNames = Object.keys(mapping);
const rulesRegex = new RegExp(`(\\s|^)(${ruleNames.join('|')})(\\s|$|\\(|\\[|\\{)`, 'ig');
const expandedQuery = query.replace(rulesRegex, (match, pre, name, post) => `${pre}${mapping[name]}${post}`);
// Split query into array, so if query uses operators, we can correctly add labels to each individual part.
const queryArray = expandedQuery.split(/(\+|\-|\*|\/|\%|\^)/);
// Regex that matches occurrences of ){ or }{ or ]{ which is a sign of incorrecly added labels.
const invalidLabelsRegex = /(\)\{|\}\{|\]\{)/;
const correctlyExpandedQueryArray = queryArray.map((query) => {
return addLabelsToExpression(query, invalidLabelsRegex);
});
return correctlyExpandedQueryArray.join('');
}
function addLabelsToExpression(expr: string, invalidLabelsRegexp: RegExp) {
const match = expr.match(invalidLabelsRegexp);
if (!match) {
return expr;
}
// Split query into 2 parts - before the invalidLabelsRegex match and after.
const indexOfRegexMatch = match.index ?? 0;
const exprBeforeRegexMatch = expr.slice(0, indexOfRegexMatch + 1);
const exprAfterRegexMatch = expr.slice(indexOfRegexMatch + 1);
// Create arrayOfLabelObjects with label objects that have key, operator and value.
const arrayOfLabelObjects: Array<{ key: string; operator: string; value: string }> = [];
exprAfterRegexMatch.replace(labelRegexp, (label, key, operator, value) => {
arrayOfLabelObjects.push({ key, operator, value });
return '';
});
// Loop through all label objects and add them to query.
// As a starting point we have valid query without the labels.
let result = exprBeforeRegexMatch;
arrayOfLabelObjects.filter(Boolean).forEach((obj) => {
// Remove extra set of quotes from obj.value
const value = obj.value.slice(1, -1);
result = addLabelToQuery(result, obj.key, value, obj.operator);
});
return result;
}
/**
* Adds metadata for synthetic metrics for which the API does not provide metadata.
* See https://github.com/grafana/grafana/issues/22337 for details.
*
* @param metadata HELP and TYPE metadata from /api/v1/metadata
*/
export function fixSummariesMetadata(metadata: { [metric: string]: PromMetricsMetadataItem[] }): PromMetricsMetadata {
if (!metadata) {
return metadata;
}
const baseMetadata: PromMetricsMetadata = {};
const summaryMetadata: PromMetricsMetadata = {};
for (const metric in metadata) {
// NOTE: based on prometheus-documentation, we can receive
// multiple metadata-entries for the given metric, it seems
// it happens when the same metric is on multiple targets
// and their help-text differs
// (https://prometheus.io/docs/prometheus/latest/querying/api/#querying-metric-metadata)
// for now we just use the first entry.
const item = metadata[metric][0];
baseMetadata[metric] = item;
if (item.type === 'histogram') {
summaryMetadata[`${metric}_bucket`] = {
type: 'counter',
help: `Cumulative counters for the observation buckets (${item.help})`,
};
summaryMetadata[`${metric}_count`] = {
type: 'counter',
help: `Count of events that have been observed for the histogram metric (${item.help})`,
};
summaryMetadata[`${metric}_sum`] = {
type: 'counter',
help: `Total sum of all observed values for the histogram metric (${item.help})`,
};
}
if (item.type === 'summary') {
summaryMetadata[`${metric}_count`] = {
type: 'counter',
help: `Count of events that have been observed for the base metric (${item.help})`,
};
summaryMetadata[`${metric}_sum`] = {
type: 'counter',
help: `Total sum of all observed values for the base metric (${item.help})`,
};
}
}
// Synthetic series
const syntheticMetadata: PromMetricsMetadata = {};
syntheticMetadata['ALERTS'] = {
type: 'counter',
help: 'Time series showing pending and firing alerts. The sample value is set to 1 as long as the alert is in the indicated active (pending or firing) state.',
};
return { ...baseMetadata, ...summaryMetadata, ...syntheticMetadata };
}
export function roundMsToMin(milliseconds: number): number {
return roundSecToMin(milliseconds / 1000);
}
export function roundSecToMin(seconds: number): number {
return Math.floor(seconds / 60);
}
// Returns number of minutes rounded up to the nearest nth minute
export function roundSecToNextMin(seconds: number, secondsToRound = 1): number {
return Math.ceil(seconds / 60) - (Math.ceil(seconds / 60) % secondsToRound);
}
export function limitSuggestions(items: string[]) {
return items.slice(0, SUGGESTIONS_LIMIT);
}
export function addLimitInfo(items: any[] | undefined): string {
return items && items.length >= SUGGESTIONS_LIMIT ? `, limited to the first ${SUGGESTIONS_LIMIT} received items` : '';
}
// NOTE: the following 2 exported functions are very similar to the prometheus*Escape
// functions in datasource.ts, but they are not exactly the same algorithm, and we found
// no way to reuse one in the another or vice versa.
// Prometheus regular-expressions use the RE2 syntax (https://github.com/google/re2/wiki/Syntax),
// so every character that matches something in that list has to be escaped.
// the list of metacharacters is: *+?()|\.[]{}^$
// we make a javascript regular expression that matches those characters:
const RE2_METACHARACTERS = /[*+?()|\\.\[\]{}^$]/g;
function escapePrometheusRegexp(value: string): string {
return value.replace(RE2_METACHARACTERS, '\\$&');
}
// based on the openmetrics-documentation, the 3 symbols we have to handle are:
// - \n ... the newline character
// - \ ... the backslash character
// - " ... the double-quote character
export function escapeLabelValueInExactSelector(labelValue: string): string {
return labelValue.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');
}
export function escapeLabelValueInRegexSelector(labelValue: string): string {
return escapeLabelValueInExactSelector(escapePrometheusRegexp(labelValue));
}
const FromPromLikeMap: Record<string, AbstractLabelOperator> = {
'=': AbstractLabelOperator.Equal,
'!=': AbstractLabelOperator.NotEqual,
'=~': AbstractLabelOperator.EqualRegEx,
'!~': AbstractLabelOperator.NotEqualRegEx,
};
const ToPromLikeMap: Record<AbstractLabelOperator, string> = invert(FromPromLikeMap) as Record<
AbstractLabelOperator,
string
>;
export function toPromLikeExpr(labelBasedQuery: AbstractQuery): string {
const expr = labelBasedQuery.labelMatchers
.map((selector: AbstractLabelMatcher) => {
const operator = ToPromLikeMap[selector.operator];
if (operator) {
return `${selector.name}${operator}"${selector.value}"`;
} else {
return '';
}
})
.filter((e: string) => e !== '')
.join(', ');
return expr ? `{${expr}}` : '';
}
export function toPromLikeQuery(labelBasedQuery: AbstractQuery): PromLikeQuery {
return {
refId: labelBasedQuery.refId,
expr: toPromLikeExpr(labelBasedQuery),
range: true,
};
}
export interface PromLikeQuery extends DataQuery {
expr: string;
range: boolean;
}
function getMaybeTokenStringContent(token: Token): string {
if (typeof token.content === 'string') {
return token.content;
}
return '';
}
export function extractLabelMatchers(tokens: Array<string | Token>): AbstractLabelMatcher[] {
const labelMatchers: AbstractLabelMatcher[] = [];
for (const token of tokens) {
if (!(token instanceof Token)) {
continue;
}
if (token.type === 'context-labels') {
let labelKey = '';
let labelValue = '';
let labelOperator = '';
const contentTokens = Array.isArray(token.content) ? token.content : [token.content];
for (let currentToken of contentTokens) {
if (typeof currentToken === 'string') {
let currentStr: string;
currentStr = currentToken;
if (currentStr === '=' || currentStr === '!=' || currentStr === '=~' || currentStr === '!~') {
labelOperator = currentStr;
}
} else if (currentToken instanceof Token) {
switch (currentToken.type) {
case 'label-key':
labelKey = getMaybeTokenStringContent(currentToken);
break;
case 'label-value':
labelValue = getMaybeTokenStringContent(currentToken);
labelValue = labelValue.substring(1, labelValue.length - 1);
const labelComparator = FromPromLikeMap[labelOperator];
if (labelComparator) {
labelMatchers.push({ name: labelKey, operator: labelComparator, value: labelValue });
}
break;
}
}
}
}
}
return labelMatchers;
}
/**
* Calculates new interval "snapped" to the closest Nth minute, depending on cache level datasource setting
* @param cacheLevel
* @param range
*/
export function getRangeSnapInterval(
cacheLevel: PrometheusCacheLevel,
range: TimeRange
): { start: string; end: string } {
// Don't round the range if we're not caching
if (cacheLevel === PrometheusCacheLevel.None) {
return {
start: getPrometheusTime(range.from, false).toString(),
end: getPrometheusTime(range.to, true).toString(),
};
}
// Otherwise round down to the nearest nth minute for the start time
const startTime = getPrometheusTime(range.from, false);
// const startTimeQuantizedSeconds = roundSecToLastMin(startTime, getClientCacheDurationInMinutes(cacheLevel)) * 60;
const startTimeQuantizedSeconds = incrRoundDn(startTime, getClientCacheDurationInMinutes(cacheLevel) * 60);
// And round up to the nearest nth minute for the end time
const endTime = getPrometheusTime(range.to, true);
const endTimeQuantizedSeconds = roundSecToNextMin(endTime, getClientCacheDurationInMinutes(cacheLevel)) * 60;
// If the interval was too short, we could have rounded both start and end to the same time, if so let's add one step to the end
if (startTimeQuantizedSeconds === endTimeQuantizedSeconds) {
const endTimePlusOneStep = endTimeQuantizedSeconds + getClientCacheDurationInMinutes(cacheLevel) * 60;
return { start: startTimeQuantizedSeconds.toString(), end: endTimePlusOneStep.toString() };
}
const start = startTimeQuantizedSeconds.toString();
const end = endTimeQuantizedSeconds.toString();
return { start, end };
}
export function getClientCacheDurationInMinutes(cacheLevel: PrometheusCacheLevel) {
switch (cacheLevel) {
case PrometheusCacheLevel.Medium:
return 10;
case PrometheusCacheLevel.High:
return 60;
default:
return 1;
}
}
export function getPrometheusTime(date: string | DateTime, roundUp: boolean) {
if (typeof date === 'string') {
date = dateMath.parse(date, roundUp)!;
}
return Math.ceil(date.valueOf() / 1000);
}
export function truncateResult<T>(array: T[], limit?: number): T[] {
if (limit === undefined) {
limit = PROMETHEUS_QUERY_BUILDER_MAX_RESULTS;
}
array.length = Math.min(array.length, limit);
return array;
}
@@ -0,0 +1,405 @@
import { Observable, of } from 'rxjs';
import 'whatwg-fetch'; // fetch polyfill needed backendSrv
import { DataSourceInstanceSettings, TimeRange, toUtc } from '@grafana/data';
import { BackendDataSourceResponse, BackendSrvRequest, FetchResponse, TemplateSrv } from '@grafana/runtime';
import { PrometheusDatasource } from './datasource';
import { getPrometheusTime } from './language_utils';
import PrometheusMetricFindQuery from './metric_find_query';
import { PromApplication, PromOptions } from './types';
const fetchMock = jest.fn((options: BackendSrvRequest): Observable<FetchResponse<BackendDataSourceResponse>> => {
return of({} as unknown as FetchResponse);
});
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => {
return {
fetch: fetchMock,
};
},
}));
const instanceSettings = {
url: 'proxied',
id: 1,
uid: 'ABCDEF',
user: 'test',
password: 'mupp',
jsonData: { httpMethod: 'GET' },
} as Partial<DataSourceInstanceSettings<PromOptions>> as DataSourceInstanceSettings<PromOptions>;
const raw: TimeRange = {
from: toUtc('2018-04-25 10:00'),
to: toUtc('2018-04-25 11:00'),
raw: {
from: '2018-04-25 10:00',
to: '2018-04-25 11:00',
},
};
const templateSrvStub = {
getAdhocFilters: jest.fn().mockImplementation(() => []),
replace: jest.fn().mockImplementation((a: string) => a),
} as unknown as TemplateSrv;
beforeEach(() => {
jest.clearAllMocks();
});
describe('PrometheusMetricFindQuery', () => {
let legacyPrometheusDatasource: PrometheusDatasource;
let prometheusDatasource: PrometheusDatasource;
beforeEach(() => {
legacyPrometheusDatasource = new PrometheusDatasource(instanceSettings, templateSrvStub);
prometheusDatasource = new PrometheusDatasource(
{
...instanceSettings,
jsonData: { ...instanceSettings.jsonData, prometheusVersion: '2.2.0', prometheusType: PromApplication.Mimir },
},
templateSrvStub
);
});
const setupMetricFindQuery = (data: any, datasource?: PrometheusDatasource) => {
fetchMock.mockImplementation(() => of({ status: 'success', data: data.response } as unknown as FetchResponse));
return new PrometheusMetricFindQuery(datasource ?? legacyPrometheusDatasource, data.query);
};
describe('When performing metricFindQuery', () => {
it('label_names() should generate label name search query', async () => {
const query = setupMetricFindQuery({
query: 'label_names()',
response: {
data: ['name1', 'name2', 'name3'],
},
});
const results = await query.process(raw);
expect(results).toHaveLength(3);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith({
method: 'GET',
url: `/api/datasources/uid/ABCDEF/resources/api/v1/labels?start=${raw.from.unix()}&end=${raw.to.unix()}`,
hideFromInspector: true,
showErrorAlert: false,
headers: {
'X-Grafana-Cache': 'private, max-age=60',
},
});
});
it('label_values(resource) should generate label search query', async () => {
const query = setupMetricFindQuery({
query: 'label_values(resource)',
response: {
data: ['value1', 'value2', 'value3'],
},
});
const results = await query.process(raw);
expect(results).toHaveLength(3);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith({
method: 'GET',
url: `/api/datasources/uid/ABCDEF/resources/api/v1/label/resource/values?start=${raw.from.unix()}&end=${raw.to.unix()}`,
hideFromInspector: true,
headers: {},
});
});
const emptyFilters = ['{}', '{ }', ' { } ', ' {} '];
emptyFilters.forEach((emptyFilter) => {
const queryString = `label_values(${emptyFilter}, resource)`;
it(`Empty filter, query, ${queryString} should just generate label search query`, async () => {
const query = setupMetricFindQuery({
query: queryString,
response: {
data: ['value1', 'value2', 'value3'],
},
});
const results = await query.process(raw);
expect(results).toHaveLength(3);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith({
method: 'GET',
url: `/api/datasources/uid/ABCDEF/resources/api/v1/label/resource/values?start=${raw.from.unix()}&end=${raw.to.unix()}`,
hideFromInspector: true,
headers: {},
});
});
});
// <LegacyPrometheus>
it('label_values(metric, resource) should generate series query with correct time', async () => {
const query = setupMetricFindQuery({
query: 'label_values(metric, resource)',
response: {
data: [
{ __name__: 'metric', resource: 'value1' },
{ __name__: 'metric', resource: 'value2' },
{ __name__: 'metric', resource: 'value3' },
],
},
});
const results = await query.process(raw);
expect(results).toHaveLength(3);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith({
method: 'GET',
url: `/api/datasources/uid/ABCDEF/resources/api/v1/series?match${encodeURIComponent(
'[]'
)}=metric&start=${raw.from.unix()}&end=${raw.to.unix()}`,
hideFromInspector: true,
showErrorAlert: false,
headers: {},
});
});
it('label_values(metric{label1="foo", label2="bar", label3="baz"}, resource) should generate series query with correct time', async () => {
const query = setupMetricFindQuery({
query: 'label_values(metric{label1="foo", label2="bar", label3="baz"}, resource)',
response: {
data: [
{ __name__: 'metric', resource: 'value1' },
{ __name__: 'metric', resource: 'value2' },
{ __name__: 'metric', resource: 'value3' },
],
},
});
const results = await query.process(raw);
expect(results).toHaveLength(3);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith({
method: 'GET',
url: '/api/datasources/uid/ABCDEF/resources/api/v1/series?match%5B%5D=metric%7Blabel1%3D%22foo%22%2C%20label2%3D%22bar%22%2C%20label3%3D%22baz%22%7D&start=1524650400&end=1524654000',
hideFromInspector: true,
showErrorAlert: false,
headers: {},
});
});
it('label_values(metric, resource) result should not contain empty string', async () => {
const query = setupMetricFindQuery({
query: 'label_values(metric, resource)',
response: {
data: [
{ __name__: 'metric', resource: 'value1' },
{ __name__: 'metric', resource: 'value2' },
{ __name__: 'metric', resource: '' },
],
},
});
const results = await query.process(raw);
expect(results).toHaveLength(2);
expect(results[0].text).toBe('value1');
expect(results[1].text).toBe('value2');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith({
method: 'GET',
url: `/api/datasources/uid/ABCDEF/resources/api/v1/series?match${encodeURIComponent(
'[]'
)}=metric&start=${raw.from.unix()}&end=${raw.to.unix()}`,
hideFromInspector: true,
showErrorAlert: false,
headers: {},
});
});
// </LegacyPrometheus>
it('metrics(metric.*) should generate metric name query', async () => {
const query = setupMetricFindQuery({
query: 'metrics(metric.*)',
response: {
data: ['metric1', 'metric2', 'metric3', 'nomatch'],
},
});
const results = await query.process(raw);
expect(results).toHaveLength(3);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith({
method: 'GET',
url: `/api/datasources/uid/ABCDEF/resources/api/v1/label/__name__/values?start=${raw.from.unix()}&end=${raw.to.unix()}`,
hideFromInspector: true,
headers: {},
});
});
it('query_result(metric) should generate metric name query', async () => {
const query = setupMetricFindQuery({
query: 'query_result(metric)',
response: {
data: {
resultType: 'vector',
result: [
{
metric: { __name__: 'metric', job: 'testjob' },
value: [1443454528.0, '3846'],
},
],
},
},
});
const results = await query.process(raw);
expect(results).toHaveLength(1);
expect(results[0].text).toBe('metric{job="testjob"} 3846 1443454528000');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith({
method: 'GET',
url: `/api/datasources/uid/ABCDEF/resources/api/v1/query?query=metric&time=${raw.to.unix()}`,
headers: {},
hideFromInspector: true,
showErrorAlert: false,
});
});
it('query_result(metric) should pass time parameter to datasource.metric_find_query', async () => {
const query = setupMetricFindQuery({
query: 'query_result(metric)',
response: {
data: {
resultType: 'vector',
result: [
{
metric: { __name__: 'metric', job: 'testjob' },
value: [1443454528.0, '3846'],
},
],
},
},
});
const results = await query.process(raw);
const expectedTime = getPrometheusTime(raw.to, true);
expect(results).toHaveLength(1);
expect(results[0].text).toBe('metric{job="testjob"} 3846 1443454528000');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith({
method: 'GET',
url: `/api/datasources/uid/ABCDEF/resources/api/v1/query?query=metric&time=${expectedTime}`,
headers: {},
hideFromInspector: true,
showErrorAlert: false,
});
});
it('query_result(metric) should handle scalar resultTypes separately', async () => {
const query = setupMetricFindQuery({
query: 'query_result(1+1)',
response: {
data: {
resultType: 'scalar',
result: [1443454528.0, '2'],
},
},
});
const results = await query.process(raw);
expect(results).toHaveLength(1);
expect(results[0].text).toBe('2');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith({
method: 'GET',
url: `/api/datasources/uid/ABCDEF/resources/api/v1/query?query=1%2B1&time=${raw.to.unix()}`,
headers: {},
hideFromInspector: true,
showErrorAlert: false,
});
});
it('up{job="job1"} should fallback using generate series query', async () => {
const query = setupMetricFindQuery({
query: 'up{job="job1"}',
response: {
data: [
{ __name__: 'up', instance: '127.0.0.1:1234', job: 'job1' },
{ __name__: 'up', instance: '127.0.0.1:5678', job: 'job1' },
{ __name__: 'up', instance: '127.0.0.1:9102', job: 'job1' },
],
},
});
const results = await query.process(raw);
expect(results).toHaveLength(3);
expect(results[0].text).toBe('up{instance="127.0.0.1:1234",job="job1"}');
expect(results[1].text).toBe('up{instance="127.0.0.1:5678",job="job1"}');
expect(results[2].text).toBe('up{instance="127.0.0.1:9102",job="job1"}');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith({
method: 'GET',
url: `/api/datasources/uid/ABCDEF/resources/api/v1/series?match${encodeURIComponent('[]')}=${encodeURIComponent(
'up{job="job1"}'
)}&start=${raw.from.unix()}&end=${raw.to.unix()}`,
hideFromInspector: true,
showErrorAlert: false,
headers: {},
});
});
// <ModernPrometheus>
it('label_values(metric, resource) should generate label values query with correct time', async () => {
const metricName = 'metricName';
const resourceName = 'resourceName';
const query = setupMetricFindQuery(
{
query: `label_values(${metricName}, ${resourceName})`,
response: {
data: [
{ __name__: `${metricName}`, resourceName: 'value1' },
{ __name__: `${metricName}`, resourceName: 'value2' },
{ __name__: `${metricName}`, resourceName: 'value3' },
],
},
},
prometheusDatasource
);
const results = await query.process(raw);
expect(results).toHaveLength(3);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith({
method: 'GET',
url: `/api/datasources/uid/ABCDEF/resources/api/v1/label/${resourceName}/values?match${encodeURIComponent(
'[]'
)}=${metricName}&start=${raw.from.unix()}&end=${raw.to.unix()}`,
hideFromInspector: true,
headers: {},
});
});
it('label_values(metric{label1="foo", label2="bar", label3="baz"}, resource) should generate label values query with correct time', async () => {
const metricName = 'metricName';
const resourceName = 'resourceName';
const label1Name = 'label1';
const label1Value = 'label1Value';
const query = setupMetricFindQuery(
{
query: `label_values(${metricName}{${label1Name}="${label1Value}"}, ${resourceName})`,
response: {
data: [{ __name__: metricName, resourceName: label1Value }],
},
},
prometheusDatasource
);
const results = await query.process(raw);
expect(results).toHaveLength(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith({
method: 'GET',
url: `/api/datasources/uid/ABCDEF/resources/api/v1/label/${resourceName}/values?match%5B%5D=${metricName}%7B${label1Name}%3D%22${label1Value}%22%7D&start=1524650400&end=1524654000`,
hideFromInspector: true,
headers: {},
});
});
// </ ModernPrometheus>
});
});
@@ -0,0 +1,203 @@
import { chain, map as _map, uniq } from 'lodash';
import { getDefaultTimeRange, MetricFindValue, TimeRange } from '@grafana/data';
import { PrometheusDatasource } from './datasource';
import { getPrometheusTime } from './language_utils';
import {
PrometheusLabelNamesRegex,
PrometheusLabelNamesRegexWithMatch,
PrometheusMetricNamesRegex,
PrometheusQueryResultRegex,
} from './migrations/variableMigration';
export default class PrometheusMetricFindQuery {
range: TimeRange;
constructor(
private datasource: PrometheusDatasource,
private query: string
) {
this.datasource = datasource;
this.query = query;
this.range = getDefaultTimeRange();
}
process(timeRange: TimeRange): Promise<MetricFindValue[]> {
this.range = timeRange;
const labelNamesRegex = PrometheusLabelNamesRegex;
const labelNamesRegexWithMatch = PrometheusLabelNamesRegexWithMatch;
const labelValuesRegex = /^label_values\((?:(.+),\s*)?([a-zA-Z_][a-zA-Z0-9_]*)\)\s*$/;
const metricNamesRegex = PrometheusMetricNamesRegex;
const queryResultRegex = PrometheusQueryResultRegex;
const labelNamesQuery = this.query.match(labelNamesRegex);
const labelNamesMatchQuery = this.query.match(labelNamesRegexWithMatch);
if (labelNamesMatchQuery) {
const selector = `{__name__=~".*${labelNamesMatchQuery[1]}.*"}`;
return this.datasource.languageProvider.getSeriesLabels(selector, []).then((results) =>
results.map((result) => ({
text: result,
}))
);
}
if (labelNamesQuery) {
return this.datasource.getTagKeys({ filters: [], timeRange });
}
const labelValuesQuery = this.query.match(labelValuesRegex);
if (labelValuesQuery) {
const filter = labelValuesQuery[1];
const label = labelValuesQuery[2];
if (isFilterDefined(filter)) {
return this.labelValuesQuery(label, filter);
} else {
// Exclude the filter part of the expression because it is blank or empty
return this.labelValuesQuery(label);
}
}
const metricNamesQuery = this.query.match(metricNamesRegex);
if (metricNamesQuery) {
return this.metricNameQuery(metricNamesQuery[1]);
}
const queryResultQuery = this.query.match(queryResultRegex);
if (queryResultQuery) {
return this.queryResultQuery(queryResultQuery[1]);
}
// if query contains full metric name, return metric name and label list
const expressions = ['label_values()', 'metrics()', 'query_result()'];
if (!expressions.includes(this.query)) {
return this.metricNameAndLabelsQuery(this.query);
}
return Promise.resolve([]);
}
labelValuesQuery(label: string, metric?: string) {
const start = getPrometheusTime(this.range.from, false);
const end = getPrometheusTime(this.range.to, true);
const params = { ...(metric && { 'match[]': metric }), start: start.toString(), end: end.toString() };
if (!metric || this.datasource.hasLabelsMatchAPISupport()) {
const url = `/api/v1/label/${label}/values`;
return this.datasource.metadataRequest(url, params).then((result: any) => {
return _map(result.data.data, (value) => {
return { text: value };
});
});
} else {
const url = `/api/v1/series`;
return this.datasource.metadataRequest(url, params).then((result: any) => {
const _labels = _map(result.data.data, (metric) => {
return metric[label] || '';
}).filter((label) => {
return label !== '';
});
return uniq(_labels).map((metric) => {
return {
text: metric,
expandable: true,
};
});
});
}
}
metricNameQuery(metricFilterPattern: string) {
const start = getPrometheusTime(this.range.from, false);
const end = getPrometheusTime(this.range.to, true);
const params = {
start: start.toString(),
end: end.toString(),
};
const url = `/api/v1/label/__name__/values`;
return this.datasource.metadataRequest(url, params).then((result: any) => {
return chain(result.data.data)
.filter((metricName) => {
const r = new RegExp(metricFilterPattern);
return r.test(metricName);
})
.map((matchedMetricName) => {
return {
text: matchedMetricName,
expandable: true,
};
})
.value();
});
}
queryResultQuery(query: string) {
const url = '/api/v1/query';
const params = {
query,
time: getPrometheusTime(this.range.to, true).toString(),
};
return this.datasource.metadataRequest(url, params).then((result: any) => {
switch (result.data.data.resultType) {
case 'scalar': // [ <unix_time>, "<scalar_value>" ]
case 'string': // [ <unix_time>, "<string_value>" ]
return [
{
text: result.data.data.result[1] || '',
expandable: false,
},
];
case 'vector':
return _map(result.data.data.result, (metricData) => {
let text = metricData.metric.__name__ || '';
delete metricData.metric.__name__;
text +=
'{' +
_map(metricData.metric, (v, k) => {
return k + '="' + v + '"';
}).join(',') +
'}';
text += ' ' + metricData.value[1] + ' ' + metricData.value[0] * 1000;
return {
text: text,
expandable: true,
};
});
default:
throw Error(`Unknown/Unhandled result type: [${result.data.data.resultType}]`);
}
});
}
metricNameAndLabelsQuery(query: string): Promise<MetricFindValue[]> {
const start = getPrometheusTime(this.range.from, false);
const end = getPrometheusTime(this.range.to, true);
const params = {
'match[]': query,
start: start.toString(),
end: end.toString(),
};
const url = `/api/v1/series`;
const self = this;
return this.datasource.metadataRequest(url, params).then((result: any) => {
return _map(result.data.data, (metric: { [key: string]: string }) => {
return {
text: self.datasource.getOriginalMetricName(metric),
expandable: true,
};
});
});
}
}
function isFilterDefined(filter: string) {
// We consider blank strings or the empty filter {} as an undefined filter
return filter && filter.split(' ').join('') !== '{}';
}
@@ -0,0 +1,137 @@
import { promQueryModeller } from '../querybuilder/PromQueryModeller';
import { buildVisualQueryFromString } from '../querybuilder/parsing';
import { PromVariableQuery, PromVariableQueryType as QueryType } from '../types';
export const PrometheusLabelNamesRegex = /^label_names\(\)\s*$/;
// Note that this regex is different from the one in metric_find_query.ts because this is used pre-interpolation
export const PrometheusLabelValuesRegex = /^label_values\((?:(.+),\s*)?([a-zA-Z_$][a-zA-Z0-9_]*)\)\s*$/;
export const PrometheusMetricNamesRegex = /^metrics\((.+)\)\s*$/;
export const PrometheusQueryResultRegex = /^query_result\((.+)\)\s*$/;
export const PrometheusLabelNamesRegexWithMatch = /^label_names\((.+)\)\s*$/;
export function migrateVariableQueryToEditor(rawQuery: string | PromVariableQuery): PromVariableQuery {
// If not string, we assume PromVariableQuery
if (typeof rawQuery !== 'string') {
return rawQuery;
}
const queryBase = {
refId: 'PrometheusDatasource-VariableQuery',
qryType: QueryType.LabelNames,
};
const labelNamesMatchQuery = rawQuery.match(PrometheusLabelNamesRegexWithMatch);
if (labelNamesMatchQuery) {
return {
...queryBase,
qryType: QueryType.LabelNames,
match: labelNamesMatchQuery[1],
};
}
const labelNames = rawQuery.match(PrometheusLabelNamesRegex);
if (labelNames) {
return {
...queryBase,
qryType: QueryType.LabelNames,
};
}
const labelValuesCheck = rawQuery.match(/^label_values\(/);
if (labelValuesCheck) {
const labelValues = rawQuery.match(PrometheusLabelValuesRegex);
const label = labelValues ? labelValues[2] : '';
const metric = labelValues ? labelValues[1] : '';
if (metric) {
const visQuery = buildVisualQueryFromString(metric);
return {
...queryBase,
qryType: QueryType.LabelValues,
label,
metric: visQuery.query.metric,
labelFilters: visQuery.query.labels,
};
} else {
return {
...queryBase,
qryType: QueryType.LabelValues,
label,
};
}
}
const metricNamesCheck = rawQuery.match(/^metrics\(/);
if (metricNamesCheck) {
const metricNames = rawQuery.match(PrometheusMetricNamesRegex);
const metric = metricNames ? metricNames[1] : '';
return {
...queryBase,
qryType: QueryType.MetricNames,
metric,
};
}
const queryResultCheck = rawQuery.match(/^query_result\(/);
if (queryResultCheck) {
const queryResult = rawQuery.match(PrometheusQueryResultRegex);
const varQuery = queryResult ? queryResult[1] : '';
return {
...queryBase,
qryType: QueryType.VarQueryResult,
varQuery,
};
}
// seriesQuery does not have a function and no regex above
if (!labelNames && !labelValuesCheck && !metricNamesCheck && !queryResultCheck) {
return {
...queryBase,
qryType: QueryType.SeriesQuery,
seriesQuery: rawQuery,
};
}
return queryBase;
}
// migrate it back to a string with the correct varialbes in place
export function migrateVariableEditorBackToVariableSupport(QueryVariable: PromVariableQuery): string {
switch (QueryVariable.qryType) {
case QueryType.LabelNames:
if (QueryVariable.match) {
return `label_names(${QueryVariable.match})`;
}
return 'label_names()';
case QueryType.LabelValues:
if (QueryVariable.metric || (QueryVariable.labelFilters && QueryVariable.labelFilters.length !== 0)) {
const visualQueryQuery = {
metric: QueryVariable.metric,
labels: QueryVariable.labelFilters ?? [],
operations: [],
};
const metric = promQueryModeller.renderQuery(visualQueryQuery);
return `label_values(${metric},${QueryVariable.label})`;
} else {
return `label_values(${QueryVariable.label})`;
}
case QueryType.MetricNames:
return `metrics(${QueryVariable.metric})`;
case QueryType.VarQueryResult:
const varQuery = removeLineBreaks(QueryVariable.varQuery);
return `query_result(${varQuery})`;
case QueryType.SeriesQuery:
return QueryVariable.seriesQuery ?? '';
case QueryType.ClassicQuery:
return QueryVariable.classicQuery ?? '';
}
return '';
}
// allow line breaks in query result textarea
function removeLineBreaks(input?: string) {
return input ? input.replace(/[\r\n]+/gm, '') : '';
}
@@ -0,0 +1,7 @@
import { plugin as PrometheusDatasourcePlugin } from './module';
describe('module', () => {
it('should have metrics query field in panels and Explore', () => {
expect(PrometheusDatasourcePlugin.components.QueryEditor).toBeDefined();
});
});
+13
View File
@@ -0,0 +1,13 @@
// DONT NEED THIS BUT MAYBE EXPORT THIS TO CORE PROM
import { DataSourcePlugin } from '@grafana/data';
import PromCheatSheet from './components/PromCheatSheet';
import PromQueryEditorByApp from './components/PromQueryEditorByApp';
import { ConfigEditor } from './configuration/ConfigEditor';
import { PrometheusDatasource } from './datasource';
export const plugin = new DataSourcePlugin(PrometheusDatasource)
.setQueryEditor(PromQueryEditorByApp)
.setConfigEditor(ConfigEditor)
.setQueryEditorHelp(PromCheatSheet);
@@ -0,0 +1,23 @@
import Prism from 'prismjs';
import promql from './promql';
describe('Loki syntax', () => {
it('should highlight Loki query correctly', () => {
expect(Prism.highlight('{key="val#ue"}', promql, 'promql')).toBe(
'<span class="token context-labels"><span class="token punctuation">{</span><span class="token label-key attr-name">key</span>=<span class="token label-value attr-value">"val#ue"</span></span><span class="token punctuation">}</span>'
);
expect(Prism.highlight('{key="#value"}', promql, 'promql')).toBe(
'<span class="token context-labels"><span class="token punctuation">{</span><span class="token label-key attr-name">key</span>=<span class="token label-value attr-value">"#value"</span></span><span class="token punctuation">}</span>'
);
expect(Prism.highlight('{key="value#"}', promql, 'promql')).toBe(
'<span class="token context-labels"><span class="token punctuation">{</span><span class="token label-key attr-name">key</span>=<span class="token label-value attr-value">"value#"</span></span><span class="token punctuation">}</span>'
);
expect(Prism.highlight('#test{key="value"}', promql, 'promql')).toBe(
'<span class="token comment">#test{key="value"}</span>'
);
expect(Prism.highlight('{key="value"}#test', promql, 'promql')).toBe(
'<span class="token context-labels"><span class="token punctuation">{</span><span class="token label-key attr-name">key</span>=<span class="token label-value attr-value">"value"</span></span><span class="token punctuation">}</span><span class="token comment">#test</span>'
);
});
});
+607
View File
@@ -0,0 +1,607 @@
import { Grammar } from 'prismjs';
import { CompletionItem } from '@grafana/ui';
// When changing RATE_RANGES, check if Loki/LogQL ranges should be changed too
// @see public/app/plugins/datasource/loki/LanguageProvider.ts
export const RATE_RANGES: CompletionItem[] = [
{ label: '$__interval', sortValue: '$__interval' },
{ label: '$__rate_interval', sortValue: '$__rate_interval' },
{ label: '$__range', sortValue: '$__range' },
{ label: '1m', sortValue: '00:01:00' },
{ label: '5m', sortValue: '00:05:00' },
{ label: '10m', sortValue: '00:10:00' },
{ label: '30m', sortValue: '00:30:00' },
{ label: '1h', sortValue: '01:00:00' },
{ label: '1d', sortValue: '24:00:00' },
];
export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without'];
export const LOGICAL_OPERATORS = ['or', 'and', 'unless'];
const TRIGONOMETRIC_FUNCTIONS: CompletionItem[] = [
{
label: 'acos',
insertText: 'acos',
detail: 'acos(v instant-vector)',
documentation: 'calculates the arccosine of all elements in v',
},
{
label: 'acosh',
insertText: 'acosh',
detail: 'acosh(v instant-vector)',
documentation: 'calculates the inverse hyperbolic cosine of all elements in v',
},
{
label: 'asin',
insertText: 'asin',
detail: 'asin(v instant-vector)',
documentation: 'calculates the arcsine of all elements in v',
},
{
label: 'asinh',
insertText: 'asinh',
detail: 'asinh(v instant-vector)',
documentation: 'calculates the inverse hyperbolic sine of all elements in v',
},
{
label: 'atan',
insertText: 'atan',
detail: 'atan(v instant-vector)',
documentation: 'calculates the arctangent of all elements in v',
},
{
label: 'atanh',
insertText: 'atanh',
detail: 'atanh(v instant-vector)',
documentation: 'calculates the inverse hyperbolic tangent of all elements in v',
},
{
label: 'cos',
insertText: 'cos',
detail: 'cos(v instant-vector)',
documentation: 'calculates the cosine of all elements in v',
},
{
label: 'cosh',
insertText: 'cosh',
detail: 'cosh(v instant-vector)',
documentation: 'calculates the hyperbolic cosine of all elements in v',
},
{
label: 'sin',
insertText: 'sin',
detail: 'sin(v instant-vector)',
documentation: 'calculates the sine of all elements in v',
},
{
label: 'sinh',
insertText: 'sinh',
detail: 'sinh(v instant-vector)',
documentation: 'calculates the hyperbolic sine of all elements in v',
},
{
label: 'tan',
insertText: 'tan',
detail: 'tan(v instant-vector)',
documentation: 'calculates the tangent of all elements in v',
},
{
label: 'tanh',
insertText: 'tanh',
detail: 'tanh(v instant-vector)',
documentation: 'calculates the hyperbolic tangent of all elements in v',
},
];
const AGGREGATION_OPERATORS: CompletionItem[] = [
{
label: 'sum',
insertText: 'sum',
documentation: 'Calculate sum over dimensions',
},
{
label: 'min',
insertText: 'min',
documentation: 'Select minimum over dimensions',
},
{
label: 'max',
insertText: 'max',
documentation: 'Select maximum over dimensions',
},
{
label: 'avg',
insertText: 'avg',
documentation: 'Calculate the average over dimensions',
},
{
label: 'group',
insertText: 'group',
documentation: 'All values in the resulting vector are 1',
},
{
label: 'stddev',
insertText: 'stddev',
documentation: 'Calculate population standard deviation over dimensions',
},
{
label: 'stdvar',
insertText: 'stdvar',
documentation: 'Calculate population standard variance over dimensions',
},
{
label: 'count',
insertText: 'count',
documentation: 'Count number of elements in the vector',
},
{
label: 'count_values',
insertText: 'count_values',
documentation: 'Count number of elements with the same value',
},
{
label: 'bottomk',
insertText: 'bottomk',
documentation: 'Smallest k elements by sample value',
},
{
label: 'topk',
insertText: 'topk',
documentation: 'Largest k elements by sample value',
},
{
label: 'quantile',
insertText: 'quantile',
documentation: 'Calculate φ-quantile (0 ≤ φ ≤ 1) over dimensions',
},
];
export const FUNCTIONS = [
...AGGREGATION_OPERATORS,
...TRIGONOMETRIC_FUNCTIONS,
{
insertText: 'abs',
label: 'abs',
detail: 'abs(v instant-vector)',
documentation: 'Returns the input vector with all sample values converted to their absolute value.',
},
{
insertText: 'absent',
label: 'absent',
detail: 'absent(v instant-vector)',
documentation:
'Returns an empty vector if the vector passed to it has any elements and a 1-element vector with the value 1 if the vector passed to it has no elements. This is useful for alerting on when no time series exist for a given metric name and label combination.',
},
{
insertText: 'absent_over_time',
label: 'absent_over_time',
detail: 'absent(v range-vector)',
documentation:
'Returns an empty vector if the range vector passed to it has any elements and a 1-element vector with the value 1 if the range vector passed to it has no elements.',
},
{
insertText: 'ceil',
label: 'ceil',
detail: 'ceil(v instant-vector)',
documentation: 'Rounds the sample values of all elements in `v` up to the nearest integer.',
},
{
insertText: 'changes',
label: 'changes',
detail: 'changes(v range-vector)',
documentation:
'For each input time series, `changes(v range-vector)` returns the number of times its value has changed within the provided time range as an instant vector.',
},
{
insertText: 'clamp',
label: 'clamp',
detail: 'clamp(v instant-vector, min scalar, max scalar)',
documentation:
'Clamps the sample values of all elements in `v` to have a lower limit of `min` and an upper limit of `max`.',
},
{
insertText: 'clamp_max',
label: 'clamp_max',
detail: 'clamp_max(v instant-vector, max scalar)',
documentation: 'Clamps the sample values of all elements in `v` to have an upper limit of `max`.',
},
{
insertText: 'clamp_min',
label: 'clamp_min',
detail: 'clamp_min(v instant-vector, min scalar)',
documentation: 'Clamps the sample values of all elements in `v` to have a lower limit of `min`.',
},
{
insertText: 'count_scalar',
label: 'count_scalar',
detail: 'count_scalar(v instant-vector)',
documentation:
'Returns the number of elements in a time series vector as a scalar. This is in contrast to the `count()` aggregation operator, which always returns a vector (an empty one if the input vector is empty) and allows grouping by labels via a `by` clause.',
},
{
insertText: 'deg',
label: 'deg',
detail: 'deg(v instant-vector)',
documentation: 'Converts radians to degrees for all elements in v',
},
{
insertText: 'day_of_month',
label: 'day_of_month',
detail: 'day_of_month(v=vector(time()) instant-vector)',
documentation: 'Returns the day of the month for each of the given times in UTC. Returned values are from 1 to 31.',
},
{
insertText: 'day_of_week',
label: 'day_of_week',
detail: 'day_of_week(v=vector(time()) instant-vector)',
documentation:
'Returns the day of the week for each of the given times in UTC. Returned values are from 0 to 6, where 0 means Sunday etc.',
},
{
insertText: 'day_of_year',
label: 'day_of_year',
detail: 'day_of_year(v=vector(time()) instant-vector)',
documentation:
'Returns the day of the year for each of the given times in UTC. Returned values are from 1 to 365 for non-leap years, and 1 to 366 in leap years.',
},
{
insertText: 'days_in_month',
label: 'days_in_month',
detail: 'days_in_month(v=vector(time()) instant-vector)',
documentation:
'Returns number of days in the month for each of the given times in UTC. Returned values are from 28 to 31.',
},
{
insertText: 'delta',
label: 'delta',
detail: 'delta(v range-vector)',
documentation:
'Calculates the difference between the first and last value of each time series element in a range vector `v`, returning an instant vector with the given deltas and equivalent labels. The delta is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if the sample values are all integers.',
},
{
insertText: 'deriv',
label: 'deriv',
detail: 'deriv(v range-vector)',
documentation:
'Calculates the per-second derivative of the time series in a range vector `v`, using simple linear regression.',
},
{
insertText: 'drop_common_labels',
label: 'drop_common_labels',
detail: 'drop_common_labels(instant-vector)',
documentation: 'Drops all labels that have the same name and value across all series in the input vector.',
},
{
insertText: 'exp',
label: 'exp',
detail: 'exp(v instant-vector)',
documentation:
'Calculates the exponential function for all elements in `v`.\nSpecial cases are:\n* `Exp(+Inf) = +Inf` \n* `Exp(NaN) = NaN`',
},
{
insertText: 'floor',
label: 'floor',
detail: 'floor(v instant-vector)',
documentation: 'Rounds the sample values of all elements in `v` down to the nearest integer.',
},
{
insertText: 'histogram_quantile',
label: 'histogram_quantile',
detail: 'histogram_quantile(φ float, b instant-vector)',
documentation:
'Calculates the φ-quantile (0 ≤ φ ≤ 1) from the buckets `b` of a histogram. The samples in `b` are the counts of observations in each bucket. Each sample must have a label `le` where the label value denotes the inclusive upper bound of the bucket. (Samples without such a label are silently ignored.) The histogram metric type automatically provides time series with the `_bucket` suffix and the appropriate labels.',
},
{
insertText: 'holt_winters',
label: 'holt_winters',
detail: 'holt_winters(v range-vector, sf scalar, tf scalar)',
documentation:
'Produces a smoothed value for time series based on the range in `v`. The lower the smoothing factor `sf`, the more importance is given to old data. The higher the trend factor `tf`, the more trends in the data is considered. Both `sf` and `tf` must be between 0 and 1.',
},
{
insertText: 'hour',
label: 'hour',
detail: 'hour(v=vector(time()) instant-vector)',
documentation: 'Returns the hour of the day for each of the given times in UTC. Returned values are from 0 to 23.',
},
{
insertText: 'idelta',
label: 'idelta',
detail: 'idelta(v range-vector)',
documentation:
'Calculates the difference between the last two samples in the range vector `v`, returning an instant vector with the given deltas and equivalent labels.',
},
{
insertText: 'increase',
label: 'increase',
detail: 'increase(v range-vector)',
documentation:
'Calculates the increase in the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. The increase is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if a counter increases only by integer increments.',
},
{
insertText: 'irate',
label: 'irate',
detail: 'irate(v range-vector)',
documentation:
'Calculates the per-second instant rate of increase of the time series in the range vector. This is based on the last two data points. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for.',
},
{
insertText: 'label_join',
label: 'label_join',
detail:
'label_join(v instant-vector, dst_label string, separator string, src_label_1 string, src_label_2 string, ...)',
documentation:
'For each timeseries in `v`, joins all the values of all the `src_labels` using `separator` and returns the timeseries with the label `dst_label` containing the joined value. There can be any number of `src_labels` in this function.',
},
{
insertText: 'label_replace',
label: 'label_replace',
detail: 'label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)',
documentation:
"For each timeseries in `v`, `label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)` matches the regular expression `regex` against the label `src_label`. If it matches, then the timeseries is returned with the label `dst_label` replaced by the expansion of `replacement`. `$1` is replaced with the first matching subgroup, `$2` with the second etc. If the regular expression doesn't match then the timeseries is returned unchanged.",
},
{
insertText: 'ln',
label: 'ln',
detail: 'ln(v instant-vector)',
documentation:
'Calculates the natural logarithm for all elements in `v`.\nSpecial cases are:\n * `ln(+Inf) = +Inf`\n * `ln(0) = -Inf`\n * `ln(x < 0) = NaN`\n * `ln(NaN) = NaN`',
},
{
insertText: 'log2',
label: 'log2',
detail: 'log2(v instant-vector)',
documentation:
'Calculates the binary logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.',
},
{
insertText: 'log10',
label: 'log10',
detail: 'log10(v instant-vector)',
documentation:
'Calculates the decimal logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.',
},
{
insertText: 'minute',
label: 'minute',
detail: 'minute(v=vector(time()) instant-vector)',
documentation:
'Returns the minute of the hour for each of the given times in UTC. Returned values are from 0 to 59.',
},
{
insertText: 'month',
label: 'month',
detail: 'month(v=vector(time()) instant-vector)',
documentation:
'Returns the month of the year for each of the given times in UTC. Returned values are from 1 to 12, where 1 means January etc.',
},
{
insertText: 'pi',
label: 'pi',
detail: 'pi()',
documentation: 'Returns pi',
},
{
insertText: 'predict_linear',
label: 'predict_linear',
detail: 'predict_linear(v range-vector, t scalar)',
documentation:
'Predicts the value of time series `t` seconds from now, based on the range vector `v`, using simple linear regression.',
},
{
insertText: 'rad',
label: 'rad',
detail: 'rad(v instant-vector)',
documentation: 'Converts degrees to radians for all elements in v',
},
{
insertText: 'rate',
label: 'rate',
detail: 'rate(v range-vector)',
documentation:
"Calculates the per-second average rate of increase of the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. Also, the calculation extrapolates to the ends of the time range, allowing for missed scrapes or imperfect alignment of scrape cycles with the range's time period.",
},
{
insertText: 'resets',
label: 'resets',
detail: 'resets(v range-vector)',
documentation:
'For each input time series, `resets(v range-vector)` returns the number of counter resets within the provided time range as an instant vector. Any decrease in the value between two consecutive samples is interpreted as a counter reset.',
},
{
insertText: 'round',
label: 'round',
detail: 'round(v instant-vector, to_nearest=1 scalar)',
documentation:
'Rounds the sample values of all elements in `v` to the nearest integer. Ties are resolved by rounding up. The optional `to_nearest` argument allows specifying the nearest multiple to which the sample values should be rounded. This multiple may also be a fraction.',
},
{
insertText: 'scalar',
label: 'scalar',
detail: 'scalar(v instant-vector)',
documentation:
'Given a single-element input vector, `scalar(v instant-vector)` returns the sample value of that single element as a scalar. If the input vector does not have exactly one element, `scalar` will return `NaN`.',
},
{
insertText: 'sgn',
label: 'sgn',
detail: 'sgn(v instant-vector)',
documentation:
'Returns a vector with all sample values converted to their sign, defined as this: 1 if v is positive, -1 if v is negative and 0 if v is equal to zero.',
},
{
insertText: 'sort',
label: 'sort',
detail: 'sort(v instant-vector)',
documentation: 'Returns vector elements sorted by their sample values, in ascending order.',
},
{
insertText: 'sort_desc',
label: 'sort_desc',
detail: 'sort_desc(v instant-vector)',
documentation: 'Returns vector elements sorted by their sample values, in descending order.',
},
{
insertText: 'sqrt',
label: 'sqrt',
detail: 'sqrt(v instant-vector)',
documentation: 'Calculates the square root of all elements in `v`.',
},
{
insertText: 'time',
label: 'time',
detail: 'time()',
documentation:
'Returns the number of seconds since January 1, 1970 UTC. Note that this does not actually return the current time, but the time at which the expression is to be evaluated.',
},
{
insertText: 'timestamp',
label: 'timestamp',
detail: 'timestamp(v instant-vector)',
documentation:
'Returns the timestamp of each of the samples of the given vector as the number of seconds since January 1, 1970 UTC.',
},
{
insertText: 'vector',
label: 'vector',
detail: 'vector(s scalar)',
documentation: 'Returns the scalar `s` as a vector with no labels.',
},
{
insertText: 'year',
label: 'year',
detail: 'year(v=vector(time()) instant-vector)',
documentation: 'Returns the year for each of the given times in UTC.',
},
{
insertText: 'avg_over_time',
label: 'avg_over_time',
detail: 'avg_over_time(range-vector)',
documentation: 'The average value of all points in the specified interval.',
},
{
insertText: 'min_over_time',
label: 'min_over_time',
detail: 'min_over_time(range-vector)',
documentation: 'The minimum value of all points in the specified interval.',
},
{
insertText: 'max_over_time',
label: 'max_over_time',
detail: 'max_over_time(range-vector)',
documentation: 'The maximum value of all points in the specified interval.',
},
{
insertText: 'sum_over_time',
label: 'sum_over_time',
detail: 'sum_over_time(range-vector)',
documentation: 'The sum of all values in the specified interval.',
},
{
insertText: 'count_over_time',
label: 'count_over_time',
detail: 'count_over_time(range-vector)',
documentation: 'The count of all values in the specified interval.',
},
{
insertText: 'quantile_over_time',
label: 'quantile_over_time',
detail: 'quantile_over_time(scalar, range-vector)',
documentation: 'The φ-quantile (0 ≤ φ ≤ 1) of the values in the specified interval.',
},
{
insertText: 'stddev_over_time',
label: 'stddev_over_time',
detail: 'stddev_over_time(range-vector)',
documentation: 'The population standard deviation of the values in the specified interval.',
},
{
insertText: 'stdvar_over_time',
label: 'stdvar_over_time',
detail: 'stdvar_over_time(range-vector)',
documentation: 'The population standard variance of the values in the specified interval.',
},
{
insertText: 'last_over_time',
label: 'last_over_time',
detail: 'last_over_time(range-vector)',
documentation: 'The most recent point value in specified interval.',
},
{
insertText: 'present_over_time',
label: 'present_over_time',
detail: 'present_over_time(range-vector)',
documentation: 'The value 1 for any series in the specified interval.',
},
];
export const PROM_KEYWORDS = FUNCTIONS.map((keyword) => keyword.label);
export const promqlGrammar: Grammar = {
comment: {
pattern: /#.*/,
},
'context-aggregation': {
pattern: /((by|without)\s*)\([^)]*\)/, // by ()
lookbehind: true,
inside: {
'label-key': {
pattern: /[^(),\s][^,)]*[^),\s]*/,
alias: 'attr-name',
},
punctuation: /[()]/,
},
},
'context-labels': {
pattern: /\{[^}]*(?=}?)/,
greedy: true,
inside: {
comment: {
pattern: /#.*/,
},
'label-key': {
pattern: /[a-z_]\w*(?=\s*(=|!=|=~|!~))/,
alias: 'attr-name',
greedy: true,
},
'label-value': {
pattern: /"(?:\\.|[^\\"])*"/,
greedy: true,
alias: 'attr-value',
},
punctuation: /[{]/,
},
},
function: new RegExp(`\\b(?:${FUNCTIONS.map((f) => f.label).join('|')})(?=\\s*\\()`, 'i'),
'context-range': [
{
pattern: /\[[^\]]*(?=])/, // [1m]
inside: {
'range-duration': {
pattern: /\b\d+[smhdwy]\b/i,
alias: 'number',
},
},
},
{
pattern: /(offset\s+)\w+/, // offset 1m
lookbehind: true,
inside: {
'range-duration': {
pattern: /\b\d+[smhdwy]\b/i,
alias: 'number',
},
},
},
],
idList: {
pattern: /\d+(\|\d+)+/,
alias: 'number',
},
number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/,
operator: new RegExp(`/[-+*/=%^~]|&&?|\\|?\\||!=?|<(?:=>?|<|>)?|>[>=]?|\\b(?:${OPERATORS.join('|')})\\b`, 'i'),
punctuation: /[{};()`,.]/,
};
export default promqlGrammar;
@@ -0,0 +1,192 @@
import { PrometheusDatasource } from './datasource';
import { getQueryHints, SUM_HINT_THRESHOLD_COUNT } from './query_hints';
describe('getQueryHints()', () => {
it('returns no hints for no series', () => {
expect(getQueryHints('', [])).toEqual([]);
});
it('returns no hints for empty series', () => {
expect(getQueryHints('', [{ datapoints: [] }])).toEqual([]);
});
it('returns a rate hint for a counter metric', () => {
const series = [
{
datapoints: [
[23, 1000],
[24, 1001],
],
},
];
const hints = getQueryHints('metric_total', series);
expect(hints!.length).toBe(1);
expect(hints![0]).toMatchObject({
label: 'Selected metric looks like a counter.',
fix: {
action: {
type: 'ADD_RATE',
query: 'metric_total',
},
},
});
});
it('returns a certain rate hint for a counter metric', () => {
const series = [
{
datapoints: [
[23, 1000],
[24, 1001],
],
},
];
const mock: unknown = { languageProvider: { metricsMetadata: { foo: { type: 'counter' } } } };
const datasource = mock as PrometheusDatasource;
let hints = getQueryHints('foo', series, datasource);
expect(hints!.length).toBe(1);
expect(hints![0]).toMatchObject({
label: 'Selected metric is a counter.',
fix: {
action: {
type: 'ADD_RATE',
query: 'foo',
},
},
});
// Test substring match not triggering hint
hints = getQueryHints('foo_foo', series, datasource);
expect(hints).toEqual([]);
});
it('returns no rate hint for a counter metric that already has a rate', () => {
const series = [
{
datapoints: [
[23, 1000],
[24, 1001],
],
},
];
const hints = getQueryHints('rate(metric_total[1m])', series);
expect(hints).toEqual([]);
});
it('returns no rate hint for a counter metric that already has an increase', () => {
const series = [
{
datapoints: [
[23, 1000],
[24, 1001],
],
},
];
const hints = getQueryHints('increase(metric_total[1m])', series);
expect(hints).toEqual([]);
});
it('returns a rate hint with action for a counter metric with labels', () => {
const series = [
{
datapoints: [
[23, 1000],
[24, 1001],
],
},
];
const hints = getQueryHints('metric_total{job="grafana"}', series);
expect(hints!.length).toBe(1);
expect(hints![0].label).toContain('Selected metric looks like a counter');
expect(hints![0].fix).toBeDefined();
});
it('returns a rate hint w/o action for a complex counter metric', () => {
const series = [
{
datapoints: [
[23, 1000],
[24, 1001],
],
},
];
const hints = getQueryHints('sum(metric_total)', series);
expect(hints!.length).toBe(1);
expect(hints![0].label).toContain('rate()');
expect(hints![0].fix).toBeUndefined();
});
it('returns a histogram hint for a bucket series', () => {
const series = [{ datapoints: [[23, 1000]] }];
const hints = getQueryHints('metric_bucket', series);
expect(hints!.length).toBe(1);
expect(hints![0]).toMatchObject({
label: 'Selected metric has buckets.',
fix: {
action: {
type: 'ADD_HISTOGRAM_QUANTILE',
query: 'metric_bucket',
},
},
});
});
it('returns a histogram hint with action for a bucket with labels', () => {
const series = [
{
datapoints: [
[23, 1000],
[24, 1001],
],
},
];
const hints = getQueryHints('metric_bucket{job="grafana"}', series);
expect(hints!.length).toBe(1);
expect(hints![0].label).toContain('Selected metric has buckets.');
expect(hints![0].fix).toBeDefined();
});
it('returns a sum hint when many time series results are returned for a simple metric', () => {
const seriesCount = SUM_HINT_THRESHOLD_COUNT;
const series = Array.from({ length: seriesCount }, (_) => ({
datapoints: [
[0, 0],
[0, 0],
],
}));
const hints = getQueryHints('metric', series);
expect(hints!.length).toBe(1);
expect(hints![0]).toMatchObject({
type: 'ADD_SUM',
label: 'Many time series results returned.',
fix: {
label: 'Consider aggregating with sum().',
action: {
type: 'ADD_SUM',
query: 'metric',
preventSubmit: true,
},
},
});
});
it('should not return rate hint for a recorded query', () => {
const seriesCount = SUM_HINT_THRESHOLD_COUNT;
const series = Array.from({ length: seriesCount }, (_) => ({
datapoints: [
[0, 0],
[0, 0],
],
}));
let hints = getQueryHints('node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate', series);
expect(hints!.length).toBe(0);
hints = getQueryHints('node_namespace_pod_container:container_cpu_usage_seconds_total', series);
expect(hints!.length).toBe(0);
hints = getQueryHints('container_cpu_usage_seconds_total:irate_total', series);
expect(hints!.length).toBe(0);
});
});
@@ -0,0 +1,151 @@
import { size } from 'lodash';
import { QueryFix, QueryHint } from '@grafana/data';
import { PrometheusDatasource } from './datasource';
/**
* Number of time series results needed before starting to suggest sum aggregation hints
*/
export const SUM_HINT_THRESHOLD_COUNT = 20;
export function getQueryHints(query: string, series?: any[], datasource?: PrometheusDatasource): QueryHint[] {
const hints = [];
// ..._bucket metric needs a histogram_quantile()
const histogramMetric = query.trim().match(/^\w+_bucket$|^\w+_bucket{.*}$/);
if (histogramMetric) {
const label = 'Selected metric has buckets.';
hints.push({
type: 'HISTOGRAM_QUANTILE',
label,
fix: {
label: 'Consider calculating aggregated quantile by adding histogram_quantile().',
action: {
type: 'ADD_HISTOGRAM_QUANTILE',
query,
},
},
});
}
// Check for need of rate()
if (query.indexOf('rate(') === -1 && query.indexOf('increase(') === -1) {
// Use metric metadata for exact types
const nameMatch = query.match(/\b((?<!:)\w+_(total|sum|count)(?!:))\b/);
let counterNameMetric = nameMatch ? nameMatch[1] : '';
const metricsMetadata = datasource?.languageProvider?.metricsMetadata;
let certain = false;
if (metricsMetadata) {
// Tokenize the query into its identifiers (see https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels)
const queryTokens = Array.from(query.matchAll(/\$?[a-zA-Z_:][a-zA-Z0-9_:]*/g))
.map(([match]) => match)
// Exclude variable identifiers
.filter((token) => !token.startsWith('$'))
// Split composite keys to match the tokens returned by the language provider
.flatMap((token) => token.split(':'));
// Determine whether any of the query identifier tokens refers to a counter metric
counterNameMetric =
queryTokens.find((metricName) => {
// Only considering first type information, could be non-deterministic
const metadata = metricsMetadata[metricName];
if (metadata && metadata.type.toLowerCase() === 'counter') {
certain = true;
return true;
} else {
return false;
}
}) ?? '';
}
if (counterNameMetric) {
// FixableQuery consists of metric name and optionally label-value pairs. We are not offering fix for complex queries yet.
const fixableQuery = query.trim().match(/^\w+$|^\w+{.*}$/);
const verb = certain ? 'is' : 'looks like';
let label = `Selected metric ${verb} a counter.`;
let fix: QueryFix | undefined;
if (fixableQuery) {
fix = {
label: 'Consider calculating rate of counter by adding rate().',
action: {
type: 'ADD_RATE',
query,
},
};
} else {
label = `${label} Consider calculating rate of counter by adding rate().`;
}
hints.push({
type: 'APPLY_RATE',
label,
fix,
});
}
}
// Check for recording rules expansion
if (datasource && datasource.ruleMappings) {
const mapping = datasource.ruleMappings;
const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => {
if (query.search(ruleName) > -1) {
return {
...acc,
[ruleName]: mapping[ruleName],
};
}
return acc;
}, {});
if (size(mappingForQuery) > 0) {
const label = 'Query contains recording rules.';
hints.push({
type: 'EXPAND_RULES',
label,
fix: {
label: 'Expand rules',
action: {
type: 'EXPAND_RULES',
query,
options: mappingForQuery,
},
},
});
}
}
if (series && series.length >= SUM_HINT_THRESHOLD_COUNT) {
const simpleMetric = query.trim().match(/^\w+$/);
if (simpleMetric) {
hints.push({
type: 'ADD_SUM',
label: 'Many time series results returned.',
fix: {
label: 'Consider aggregating with sum().',
action: {
type: 'ADD_SUM',
query: query,
preventSubmit: true,
},
},
});
}
}
return hints;
}
export function getInitHints(datasource: PrometheusDatasource): QueryHint[] {
const hints = [];
// Hint for big disabled lookups
if (datasource.lookupsDisabled) {
hints.push({
label: `Labels and metrics lookup was disabled in data source settings.`,
type: 'INFO',
});
}
return hints;
}
@@ -0,0 +1,335 @@
import { PromQueryModeller } from './PromQueryModeller';
import { PromOperationId } from './types';
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: PromOperationId.HistogramQuantile, 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: PromOperationId.LabelReplace, 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 use aggregation without label', () => {
expect(
modeller.renderQuery({
metric: 'metric',
labels: [],
operations: [{ id: '__sum_without', params: ['server', 'job'] }],
})
).toBe('sum without(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: PromOperationId.Rate, params: ['$__rate_interval'] }],
})
).toBe('rate(metric{pod="A"}[$__rate_interval])');
});
it('Can render increase', () => {
expect(
modeller.renderQuery({
metric: 'metric',
labels: [{ label: 'pod', op: '=', value: 'A' }],
operations: [{ id: PromOperationId.Increase, params: ['$__interval'] }],
})
).toBe('increase(metric{pod="A"}[$__interval])');
});
it('Can render rate with custom range-vector', () => {
expect(
modeller.renderQuery({
metric: 'metric',
labels: [{ label: 'pod', op: '=', value: 'A' }],
operations: [{ id: PromOperationId.Rate, params: ['10m'] }],
})
).toBe('rate(metric{pod="A"}[10m])');
});
it('Can render multiply operation', () => {
expect(
modeller.renderQuery({
metric: 'metric',
labels: [],
operations: [{ id: PromOperationId.MultiplyBy, 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 query with nested query with binary op', () => {
expect(
modeller.renderQuery({
metric: 'metric_a',
labels: [],
operations: [],
binaryQueries: [
{
operator: '/',
query: {
metric: 'metric_b',
labels: [],
operations: [{ id: PromOperationId.MultiplyBy, params: [1000] }],
},
},
],
})
).toBe('metric_a / (metric_b * 1000)');
});
it('Can render query with nested binary query with parentheses', () => {
expect(
modeller.renderQuery({
metric: 'metric_a',
labels: [],
operations: [],
binaryQueries: [
{
operator: '/',
query: {
metric: 'metric_b',
labels: [],
operations: [],
binaryQueries: [
{
operator: '*',
query: {
metric: 'metric_c',
labels: [],
operations: [],
},
},
],
},
},
],
})
).toBe('metric_a / (metric_b * metric_c)');
});
it('Should add parantheis around first query if it has binary op', () => {
expect(
modeller.renderQuery({
metric: 'metric_a',
labels: [],
operations: [{ id: PromOperationId.MultiplyBy, params: [1000] }],
binaryQueries: [
{
operator: '/',
query: {
metric: 'metric_b',
labels: [],
operations: [],
},
},
],
})
).toBe('(metric_a * 1000) / metric_b');
});
it('Can render functions that require a range as a parameter', () => {
expect(
modeller.renderQuery({
metric: 'metric_a',
labels: [],
operations: [{ id: 'holt_winters', params: ['5m', 0.5, 0.5] }],
})
).toBe('holt_winters(metric_a[5m], 0.5, 0.5)');
});
it('Can render functions that require parameters left of a range', () => {
expect(
modeller.renderQuery({
metric: 'metric_a',
labels: [],
operations: [{ id: 'quantile_over_time', params: ['5m', 1] }],
})
).toBe('quantile_over_time(1, metric_a[5m])');
});
it('Can render the label_join function', () => {
expect(
modeller.renderQuery({
metric: 'metric_a',
labels: [],
operations: [{ id: 'label_join', params: ['label_1', ',', 'label_2'] }],
})
).toBe('label_join(metric_a, "label_1", ",", "label_2")');
});
it('Can render label_join with extra parameters', () => {
expect(
modeller.renderQuery({
metric: 'metric_a',
labels: [],
operations: [{ id: 'label_join', params: ['label_1', ', ', 'label_2', 'label_3', 'label_4', 'label_5'] }],
})
).toBe('label_join(metric_a, "label_1", ", ", "label_2", "label_3", "label_4", "label_5")');
});
it('can render vector matchers', () => {
expect(
modeller.renderQuery({
metric: 'metric_a',
labels: [],
operations: [],
binaryQueries: [
{
operator: '/',
vectorMatches: 'le, foo',
vectorMatchesType: 'on',
query: {
metric: 'metric_b',
labels: [],
operations: [],
},
},
],
})
).toBe('metric_a / on(le, foo) metric_b');
});
it('can render bool in binary ops', () => {
expect(
modeller.renderQuery({
metric: 'cluster_namespace_slug_dialer_name',
labels: [],
operations: [
{
id: '__less_or_equal',
params: [2, true],
},
],
})
).toBe('cluster_namespace_slug_dialer_name <= bool 2');
});
});
@@ -0,0 +1,93 @@
import { FUNCTIONS } from '../promql';
import { getAggregationOperations } from './aggregations';
import { getOperationDefinitions } from './operations';
import { LokiAndPromQueryModellerBase } from './shared/LokiAndPromQueryModellerBase';
import { PromQueryPattern, PromQueryPatternType, PromVisualQueryOperationCategory } from './types';
export class PromQueryModeller extends LokiAndPromQueryModellerBase {
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,
PromVisualQueryOperationCategory.Trigonometric,
PromVisualQueryOperationCategory.Time,
]);
}
getQueryPatterns(): PromQueryPattern[] {
return [
{
name: 'Rate then sum',
type: PromQueryPatternType.Rate,
operations: [
{ id: 'rate', params: ['$__rate_interval'] },
{ id: 'sum', params: [] },
],
},
{
name: 'Rate then sum by(label) then avg',
type: PromQueryPatternType.Rate,
operations: [
{ id: 'rate', params: ['$__rate_interval'] },
{ id: '__sum_by', params: [''] },
{ id: 'avg', params: [] },
],
},
{
name: 'Histogram quantile on rate',
type: PromQueryPatternType.Histogram,
operations: [
{ id: 'rate', params: ['$__rate_interval'] },
{ id: '__sum_by', params: ['le'] },
{ id: 'histogram_quantile', params: [0.95] },
],
},
{
name: 'Histogram quantile on increase',
type: PromQueryPatternType.Histogram,
operations: [
{ id: 'increase', params: ['$__rate_interval'] },
{ id: '__max_by', params: ['le'] },
{ id: 'histogram_quantile', params: [0.95] },
],
},
{
name: 'Binary Query',
type: PromQueryPatternType.Binary,
operations: [
{ id: 'rate', params: ['$__rate_interval'] },
{ id: 'sum', params: [] },
],
binaryQueries: [
{
operator: '/',
query: {
metric: '',
labels: [],
operations: [
{ id: 'rate', params: ['$__rate_interval'] },
{ id: 'sum', params: [] },
],
},
},
],
},
];
}
}
export const promQueryModeller = new PromQueryModeller();
@@ -0,0 +1,118 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Card, useStyles2 } from '@grafana/ui';
import promqlGrammar from '../promql';
import { promQueryModeller } from './PromQueryModeller';
import { RawQuery } from './shared/RawQuery';
import { PromQueryPattern } from './types';
type Props = {
pattern: PromQueryPattern;
hasNewQueryOption: boolean;
hasPreviousQuery: boolean | string;
selectedPatternName: string | null;
setSelectedPatternName: (name: string | null) => void;
onPatternSelect: (pattern: PromQueryPattern, selectAsNewQuery?: boolean) => void;
};
export const QueryPattern = (props: Props) => {
const { pattern, onPatternSelect, hasNewQueryOption, hasPreviousQuery, selectedPatternName, setSelectedPatternName } =
props;
const styles = useStyles2(getStyles);
const lang = { grammar: promqlGrammar, name: 'promql' };
return (
<Card className={styles.card}>
<Card.Heading>{pattern.name}</Card.Heading>
<div className={styles.rawQueryContainer}>
<RawQuery
aria-label={`${pattern.name} raw query`}
query={promQueryModeller.renderQuery({
labels: [],
operations: pattern.operations,
binaryQueries: pattern.binaryQueries,
})}
lang={lang}
className={styles.rawQuery}
/>
</div>
<Card.Actions>
{selectedPatternName !== pattern.name ? (
<Button
size="sm"
aria-label="use this query button"
onClick={() => {
if (hasPreviousQuery) {
// If user has previous query, we need to confirm that they want to apply this query pattern
setSelectedPatternName(pattern.name);
} else {
onPatternSelect(pattern);
}
}}
>
Use this query
</Button>
) : (
<>
<div className={styles.spacing}>
{`If you would like to use this query, ${
hasNewQueryOption
? 'you can either apply this query pattern or create a new query'
: 'this query pattern will be applied to your current query'
}.`}
</div>
<Button size="sm" aria-label="back button" fill="outline" onClick={() => setSelectedPatternName(null)}>
Back
</Button>
<Button
size="sm"
aria-label="apply query starter button"
onClick={() => {
onPatternSelect(pattern);
}}
>
Apply query
</Button>
{hasNewQueryOption && (
<Button
size="sm"
aria-label="create new query button"
onClick={() => {
onPatternSelect(pattern, true);
}}
>
Create new query
</Button>
)}
</>
)}
</Card.Actions>
</Card>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
card: css`
width: 49.5%;
display: flex;
flex-direction: column;
`,
rawQueryContainer: css`
flex-grow: 1;
`,
rawQuery: css`
background-color: ${theme.colors.background.primary};
padding: ${theme.spacing(1)};
margin-top: ${theme.spacing(1)};
`,
spacing: css`
margin-bottom: ${theme.spacing(1)};
`,
};
};
@@ -0,0 +1,143 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { promQueryModeller } from './PromQueryModeller';
import { QueryPatternsModal } from './QueryPatternsModal';
import { PromQueryPatternType } from './types';
// don't care about interaction tracking in our unit tests
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
}));
const defaultProps = {
isOpen: true,
onClose: jest.fn(),
onChange: jest.fn(),
onAddQuery: jest.fn(),
query: {
refId: 'A',
expr: 'sum(rate({job="grafana"}[$__rate_interval]))',
},
queries: [
{
refId: 'A',
expr: 'go_goroutines{instance="localhost:9090"}',
},
],
};
const queryPatterns = {
rateQueryPatterns: promQueryModeller
.getQueryPatterns()
.filter((pattern) => pattern.type === PromQueryPatternType.Rate),
histogramQueryPatterns: promQueryModeller
.getQueryPatterns()
.filter((pattern) => pattern.type === PromQueryPatternType.Histogram),
binaryQueryPatterns: promQueryModeller
.getQueryPatterns()
.filter((pattern) => pattern.type === PromQueryPatternType.Binary),
};
describe('QueryPatternsModal', () => {
it('renders the modal', () => {
render(<QueryPatternsModal {...defaultProps} />);
expect(screen.getByText('Kick start your query')).toBeInTheDocument();
});
it('renders collapsible elements with all query pattern types', () => {
render(<QueryPatternsModal {...defaultProps} />);
Object.values(PromQueryPatternType).forEach((pattern) => {
expect(screen.getByText(new RegExp(`${pattern} query starters`, 'i'))).toBeInTheDocument();
});
});
it('can open and close query patterns section', async () => {
render(<QueryPatternsModal {...defaultProps} />);
await userEvent.click(screen.getByText('Rate query starters'));
expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument();
await userEvent.click(screen.getByText('Rate query starters'));
expect(screen.queryByText(queryPatterns.rateQueryPatterns[0].name)).not.toBeInTheDocument();
});
it('can open and close multiple query patterns section', async () => {
render(<QueryPatternsModal {...defaultProps} />);
await userEvent.click(screen.getByText('Rate query starters'));
expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument();
await userEvent.click(screen.getByText('Histogram query starters'));
expect(screen.getByText(queryPatterns.histogramQueryPatterns[0].name)).toBeInTheDocument();
await userEvent.click(screen.getByText('Rate query starters'));
expect(screen.queryByText(queryPatterns.rateQueryPatterns[0].name)).not.toBeInTheDocument();
// Histogram patterns should still be open
expect(screen.getByText(queryPatterns.histogramQueryPatterns[0].name)).toBeInTheDocument();
});
it('uses pattern if there is no existing query', async () => {
render(<QueryPatternsModal {...defaultProps} query={{ expr: '', refId: 'A' }} />);
await userEvent.click(screen.getByText('Rate query starters'));
expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument();
const firstUseQueryButton = screen.getAllByRole('button', { name: 'use this query button' })[0];
await userEvent.click(firstUseQueryButton);
await waitFor(() => {
expect(defaultProps.onChange).toHaveBeenCalledWith({
expr: 'sum(rate([$__rate_interval]))',
refId: 'A',
});
});
});
it('gives warning when selecting pattern if there are already existing query', async () => {
render(<QueryPatternsModal {...defaultProps} />);
await userEvent.click(screen.getByText('Rate query starters'));
expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument();
const firstUseQueryButton = screen.getAllByRole('button', { name: 'use this query button' })[0];
await userEvent.click(firstUseQueryButton);
expect(screen.getByText(/you can either apply this query pattern or create a new query/)).toBeInTheDocument();
});
it('can use create new query when selecting pattern if there is already existing query', async () => {
render(<QueryPatternsModal {...defaultProps} />);
await userEvent.click(screen.getByText('Rate query starters'));
expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument();
const firstUseQueryButton = screen.getAllByRole('button', { name: 'use this query button' })[0];
await userEvent.click(firstUseQueryButton);
const createNewQueryButton = screen.getByRole('button', { name: 'create new query button' });
expect(createNewQueryButton).toBeInTheDocument();
await userEvent.click(createNewQueryButton);
await waitFor(() => {
expect(defaultProps.onAddQuery).toHaveBeenCalledWith({
expr: 'sum(rate([$__rate_interval]))',
refId: 'B',
});
});
});
it('does not show create new query option if onAddQuery function is not provided ', async () => {
render(<QueryPatternsModal {...defaultProps} onAddQuery={undefined} />);
await userEvent.click(screen.getByText('Rate query starters'));
expect(screen.getByText(queryPatterns.rateQueryPatterns[0].name)).toBeInTheDocument();
const useQueryButton = screen.getAllByRole('button', { name: 'use this query button' })[0];
await userEvent.click(useQueryButton);
expect(screen.queryByRole('button', { name: 'Create new query' })).not.toBeInTheDocument();
expect(screen.getByText(/this query pattern will be applied to your current query/)).toBeInTheDocument();
});
it('applies binary query patterns to query', async () => {
render(<QueryPatternsModal {...defaultProps} query={{ expr: '', refId: 'A' }} />);
await userEvent.click(screen.getByText('Binary query starters'));
expect(screen.getByText(queryPatterns.binaryQueryPatterns[0].name)).toBeInTheDocument();
const firstUseQueryButton = screen.getAllByRole('button', { name: 'use this query button' })[0];
await userEvent.click(firstUseQueryButton);
await waitFor(() => {
expect(defaultProps.onChange).toHaveBeenCalledWith({
expr: 'sum(rate([$__rate_interval])) / sum(rate([$__rate_interval]))',
refId: 'A',
});
});
});
});
@@ -0,0 +1,132 @@
import { css } from '@emotion/css';
import { capitalize } from 'lodash';
import React, { useMemo, useState } from 'react';
import { CoreApp, DataQuery, GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { Button, Collapse, Modal, useStyles2 } from '@grafana/ui';
import { getNextRefIdChar } from '../gcopypaste/app/core/utils/query';
import { PromQuery } from '../types';
import { promQueryModeller } from './PromQueryModeller';
import { QueryPattern } from './QueryPattern';
import { buildVisualQueryFromString } from './parsing';
import { PromQueryPattern, PromQueryPatternType } from './types';
type Props = {
isOpen: boolean;
query: PromQuery;
queries: DataQuery[] | undefined;
app?: CoreApp;
onClose: () => void;
onChange: (query: PromQuery) => void;
onAddQuery?: (query: PromQuery) => void;
};
export const QueryPatternsModal = (props: Props) => {
const { isOpen, onClose, onChange, onAddQuery, query, queries, app } = props;
const [openTabs, setOpenTabs] = useState<string[]>([]);
const [selectedPatternName, setSelectedPatternName] = useState<string | null>(null);
const styles = useStyles2(getStyles);
const hasNewQueryOption = !!onAddQuery;
const hasPreviousQuery = useMemo(() => {
const visualQuery = buildVisualQueryFromString(query.expr ?? '');
// has anything entered in the query, metric, labels, operations, or binary queries
const hasOperations = visualQuery.query.operations.length > 0,
hasMetric = visualQuery.query.metric,
hasLabels = visualQuery.query.labels.length > 0,
hasBinaryQueries = visualQuery.query.binaryQueries ? visualQuery.query.binaryQueries.length > 0 : false;
return hasOperations || hasMetric || hasLabels || hasBinaryQueries;
}, [query.expr]);
const onPatternSelect = (pattern: PromQueryPattern, selectAsNewQuery = false) => {
const visualQuery = buildVisualQueryFromString(selectAsNewQuery ? '' : query.expr);
reportInteraction('grafana_prom_kickstart_your_query_selected', {
app: app ?? '',
editorMode: query.editorMode,
selectedPattern: pattern.name,
preSelectedOperationsCount: visualQuery.query.operations.length,
preSelectedLabelsCount: visualQuery.query.labels.length,
createNewQuery: hasNewQueryOption && selectAsNewQuery,
});
visualQuery.query.operations = pattern.operations;
visualQuery.query.binaryQueries = pattern.binaryQueries;
if (hasNewQueryOption && selectAsNewQuery) {
onAddQuery({
...query,
refId: getNextRefIdChar(queries ?? [query]),
expr: promQueryModeller.renderQuery(visualQuery.query),
});
} else {
onChange({
...query,
expr: promQueryModeller.renderQuery(visualQuery.query),
});
}
setSelectedPatternName(null);
onClose();
};
return (
<Modal aria-label="Kick start your query modal" isOpen={isOpen} title="Kick start your query" onDismiss={onClose}>
<div className={styles.spacing}>
Kick start your query by selecting one of these queries. You can then continue to complete your query.
</div>
{Object.values(PromQueryPatternType).map((patternType) => {
return (
<Collapse
aria-label={`open and close ${patternType} query starter card`}
key={patternType}
label={`${capitalize(patternType)} query starters`}
isOpen={openTabs.includes(patternType)}
collapsible={true}
onToggle={() =>
setOpenTabs((tabs) =>
// close tab if it's already open, otherwise open it
tabs.includes(patternType) ? tabs.filter((t) => t !== patternType) : [...tabs, patternType]
)
}
>
<div className={styles.cardsContainer}>
{promQueryModeller
.getQueryPatterns()
.filter((pattern) => pattern.type === patternType)
.map((pattern) => (
<QueryPattern
key={pattern.name}
pattern={pattern}
hasNewQueryOption={hasNewQueryOption}
hasPreviousQuery={hasPreviousQuery}
onPatternSelect={onPatternSelect}
selectedPatternName={selectedPatternName}
setSelectedPatternName={setSelectedPatternName}
/>
))}
</div>
</Collapse>
);
})}
<Button aria-label="close kick start your query modal" variant="secondary" onClick={onClose}>
Close
</Button>
</Modal>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
cardsContainer: css`
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
`,
spacing: css`
margin-bottom: ${theme.spacing(1)};
`,
};
};
@@ -0,0 +1,62 @@
import {
createAggregationOperation,
createAggregationOperationWithParam,
getPromOperationDisplayName,
getRangeVectorParamDef,
} from './operationUtils';
import { addOperationWithRangeVector } from './operations';
import { QueryBuilderOperation, QueryBuilderOperationDef } from './shared/types';
import { PromOperationId, PromVisualQueryOperationCategory } from './types';
export function getAggregationOperations(): QueryBuilderOperationDef[] {
return [
...createAggregationOperation(PromOperationId.Sum),
...createAggregationOperation(PromOperationId.Avg),
...createAggregationOperation(PromOperationId.Min),
...createAggregationOperation(PromOperationId.Max),
...createAggregationOperation(PromOperationId.Count),
...createAggregationOperationWithParam(PromOperationId.TopK, {
params: [{ name: 'K-value', type: 'number' }],
defaultParams: [5],
}),
...createAggregationOperationWithParam(PromOperationId.BottomK, {
params: [{ name: 'K-value', type: 'number' }],
defaultParams: [5],
}),
...createAggregationOperationWithParam(PromOperationId.CountValues, {
params: [{ name: 'Identifier', type: 'string' }],
defaultParams: ['count'],
}),
createAggregationOverTime(PromOperationId.SumOverTime),
createAggregationOverTime(PromOperationId.AvgOverTime),
createAggregationOverTime(PromOperationId.MinOverTime),
createAggregationOverTime(PromOperationId.MaxOverTime),
createAggregationOverTime(PromOperationId.CountOverTime),
createAggregationOverTime(PromOperationId.LastOverTime),
createAggregationOverTime(PromOperationId.PresentOverTime),
createAggregationOverTime(PromOperationId.AbsentOverTime),
createAggregationOverTime(PromOperationId.StddevOverTime),
];
}
function createAggregationOverTime(name: string): QueryBuilderOperationDef {
return {
id: name,
name: getPromOperationDisplayName(name),
params: [getRangeVectorParamDef()],
defaultParams: ['$__interval'],
alternativesKey: 'overtime function',
category: PromVisualQueryOperationCategory.RangeFunctions,
renderer: operationWithRangeVectorRenderer,
addOperationHandler: addOperationWithRangeVector,
};
}
function operationWithRangeVectorRenderer(
model: QueryBuilderOperation,
def: QueryBuilderOperationDef,
innerExpr: string
) {
let rangeVector = (model.params ?? [])[0] ?? '$__interval';
return `${def.id}(${innerExpr}[${rangeVector}])`;
}
@@ -0,0 +1,120 @@
import { defaultAddOperationHandler } from './operationUtils';
import { QueryBuilderOperation, QueryBuilderOperationDef, QueryBuilderOperationParamDef } from './shared/types';
import { PromOperationId, PromVisualQueryOperationCategory } from './types';
export const binaryScalarDefs = [
{
id: PromOperationId.Addition,
name: 'Add scalar',
sign: '+',
},
{
id: PromOperationId.Subtraction,
name: 'Subtract scalar',
sign: '-',
},
{
id: PromOperationId.MultiplyBy,
name: 'Multiply by scalar',
sign: '*',
},
{
id: PromOperationId.DivideBy,
name: 'Divide by scalar',
sign: '/',
},
{
id: PromOperationId.Modulo,
name: 'Modulo by scalar',
sign: '%',
},
{
id: PromOperationId.Exponent,
name: 'Exponent',
sign: '^',
},
{
id: PromOperationId.EqualTo,
name: 'Equal to',
sign: '==',
comparison: true,
},
{
id: PromOperationId.NotEqualTo,
name: 'Not equal to',
sign: '!=',
comparison: true,
},
{
id: PromOperationId.GreaterThan,
name: 'Greater than',
sign: '>',
comparison: true,
},
{
id: PromOperationId.LessThan,
name: 'Less than',
sign: '<',
comparison: true,
},
{
id: PromOperationId.GreaterOrEqual,
name: 'Greater or equal to',
sign: '>=',
comparison: true,
},
{
id: PromOperationId.LessOrEqual,
name: 'Less or equal to',
sign: '<=',
comparison: true,
},
];
export const binaryScalarOperatorToOperatorName = binaryScalarDefs.reduce<
Record<string, { id: string; comparison?: boolean }>
>((acc, def) => {
acc[def.sign] = {
id: def.id,
comparison: def.comparison,
};
return acc;
}, {});
// 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
export const binaryScalarOperations: QueryBuilderOperationDef[] = binaryScalarDefs.map((opDef) => {
const params: QueryBuilderOperationParamDef[] = [{ name: 'Value', type: 'number' }];
const defaultParams: any[] = [2];
if (opDef.comparison) {
params.push({
name: 'Bool',
type: 'boolean',
description: 'If checked comparison will return 0 or 1 for the value rather than filtering.',
});
defaultParams.push(false);
}
return {
id: opDef.id,
name: opDef.name,
params,
defaultParams,
alternativesKey: 'binary scalar operations',
category: PromVisualQueryOperationCategory.BinaryOps,
renderer: getSimpleBinaryRenderer(opDef.sign),
addOperationHandler: defaultAddOperationHandler,
};
});
function getSimpleBinaryRenderer(operator: string) {
return function binaryRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) {
let param = model.params[0];
let bool = '';
if (model.params.length === 2) {
bool = model.params[1] ? ' bool' : '';
}
return `${innerExpr} ${operator}${bool} ${param}`;
};
}
@@ -0,0 +1,196 @@
import debounce from 'debounce-promise';
import React, { useState } from 'react';
import { SelectableValue, toOption } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { AccessoryButton, InputGroup } from '@grafana/experimental';
import { AsyncSelect, Select } from '@grafana/ui';
import { truncateResult } from '../../language_utils';
import { QueryBuilderLabelFilter } from '../shared/types';
export interface Props {
defaultOp: string;
item: Partial<QueryBuilderLabelFilter>;
onChange: (value: QueryBuilderLabelFilter) => void;
onGetLabelNames: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<SelectableValue[]>;
onGetLabelValues: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<SelectableValue[]>;
onDelete: () => void;
invalidLabel?: boolean;
invalidValue?: boolean;
getLabelValuesAutofillSuggestions: (query: string, labelName?: string) => Promise<SelectableValue[]>;
debounceDuration: number;
}
export function LabelFilterItem({
item,
defaultOp,
onChange,
onDelete,
onGetLabelNames,
onGetLabelValues,
invalidLabel,
invalidValue,
getLabelValuesAutofillSuggestions,
debounceDuration,
}: Props) {
const [state, setState] = useState<{
labelNames?: SelectableValue[];
labelValues?: SelectableValue[];
isLoadingLabelNames?: boolean;
isLoadingLabelValues?: boolean;
}>({});
// there's a bug in react-select where the menu doesn't recalculate its position when the options are loaded asynchronously
// see https://github.com/grafana/grafana/issues/63558
// instead, we explicitly control the menu visibility and prevent showing it until the options have fully loaded
const [labelNamesMenuOpen, setLabelNamesMenuOpen] = useState(false);
const [labelValuesMenuOpen, setLabelValuesMenuOpen] = useState(false);
const isMultiSelect = (operator = item.op) => {
return operators.find((op) => op.label === operator)?.isMultiValue;
};
const getSelectOptionsFromString = (item?: string): string[] => {
if (item) {
const regExp = /\(([^)]+)\)/;
const matches = item?.match(regExp);
if (matches && matches[0].indexOf('|') > 0) {
return [item];
}
if (item.indexOf('|') > 0) {
return item.split('|');
}
return [item];
}
return [];
};
const labelValueSearch = debounce(
(query: string) => getLabelValuesAutofillSuggestions(query, item.label),
debounceDuration
);
const itemValue = item?.value ?? '';
return (
<div key={itemValue} data-testid="prometheus-dimensions-filter-item">
<InputGroup>
{/* Label name select, loads all values at once */}
<Select
placeholder="Select label"
data-testid={selectors.components.QueryBuilder.labelSelect}
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);
setLabelNamesMenuOpen(true);
setState({ labelNames, isLoadingLabelNames: undefined });
}}
onCloseMenu={() => {
setLabelNamesMenuOpen(false);
}}
isOpen={labelNamesMenuOpen}
isLoading={state.isLoadingLabelNames ?? false}
options={state.labelNames}
onChange={(change) => {
if (change.label) {
onChange({
...item,
op: item.op ?? defaultOp,
label: change.label,
// eslint-ignore
} as QueryBuilderLabelFilter);
}
}}
invalid={invalidLabel}
/>
{/* Operator select i.e. = =~ != !~ */}
<Select
data-testid={selectors.components.QueryBuilder.matchOperatorSelect}
className="query-segment-operator"
value={toOption(item.op ?? defaultOp)}
options={operators}
width="auto"
onChange={(change) => {
if (change.value != null) {
onChange({
...item,
op: change.value,
value: isMultiSelect(change.value) ? item.value : getSelectOptionsFromString(item?.value)[0],
// eslint-ignore
} as QueryBuilderLabelFilter);
}
}}
/>
{/* Label value async select: autocomplete calls prometheus API */}
<AsyncSelect
placeholder="Select value"
data-testid={selectors.components.QueryBuilder.valueSelect}
inputId="prometheus-dimensions-filter-item-value"
width="auto"
value={
isMultiSelect()
? getSelectOptionsFromString(itemValue).map(toOption)
: getSelectOptionsFromString(itemValue).map(toOption)[0]
}
allowCustomValue
onOpenMenu={async () => {
setState({ isLoadingLabelValues: true });
const labelValues = await onGetLabelValues(item);
truncateResult(labelValues);
setLabelValuesMenuOpen(true);
setState({
...state,
labelValues,
isLoadingLabelValues: undefined,
});
}}
onCloseMenu={() => {
setLabelValuesMenuOpen(false);
}}
isOpen={labelValuesMenuOpen}
defaultOptions={state.labelValues}
isMulti={isMultiSelect()}
isLoading={state.isLoadingLabelValues}
loadOptions={labelValueSearch}
onChange={(change) => {
if (change.value) {
onChange({
...item,
value: change.value,
op: item.op ?? defaultOp,
// eslint-ignore
} as QueryBuilderLabelFilter);
} else {
const changes = change
.map((change: { label?: string }) => {
return change.label;
})
.join('|');
// eslint-ignore
onChange({ ...item, value: changes, op: item.op ?? defaultOp } as QueryBuilderLabelFilter);
}
}}
invalid={invalidValue}
/>
<AccessoryButton aria-label={`remove-${item.label}`} icon="times" variant="secondary" onClick={onDelete} />
</InputGroup>
</div>
);
}
const operators = [
{ label: '=', value: '=', isMultiValue: false },
{ label: '!=', value: '!=', isMultiValue: false },
{ label: '<', value: '<', isMultiValue: false },
{ label: '>', value: '>', isMultiValue: false },
{ label: '=~', value: '=~', isMultiValue: true },
{ label: '!~', value: '!~', isMultiValue: true },
];
@@ -0,0 +1,163 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { ComponentProps } from 'react';
import { selectOptionInTest } from '../../gcopypaste/test/helpers/selectOptionInTest';
import { getLabelSelects } from '../testUtils';
import { LabelFilters, MISSING_LABEL_FILTER_ERROR_MESSAGE, Props } from './LabelFilters';
describe('LabelFilters', () => {
it('renders empty input without labels', async () => {
setup();
expect(screen.getAllByText('Select label')).toHaveLength(1);
expect(screen.getAllByText('Select value')).toHaveLength(1);
expect(screen.getByText(/=/)).toBeInTheDocument();
expect(getAddButton()).toBeInTheDocument();
});
it('renders multiple labels', async () => {
setup({
labelsFilters: [
{ 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('renders multiple values for regex selectors', async () => {
setup({
labelsFilters: [
{ label: 'bar', op: '!~', value: 'baz|bat|bau' },
{ label: 'foo', op: '!~', value: 'fop|for|fos' },
],
});
expect(screen.getByText(/bar/)).toBeInTheDocument();
expect(screen.getByText(/baz/)).toBeInTheDocument();
expect(screen.getByText(/bat/)).toBeInTheDocument();
expect(screen.getByText(/bau/)).toBeInTheDocument();
expect(screen.getByText(/foo/)).toBeInTheDocument();
expect(screen.getByText(/for/)).toBeInTheDocument();
expect(screen.getByText(/fos/)).toBeInTheDocument();
expect(getAddButton()).toBeInTheDocument();
});
it('adds new label', async () => {
const { onChange } = setup({ labelsFilters: [{ label: 'foo', op: '=', value: 'bar' }] });
await userEvent.click(getAddButton());
expect(screen.getAllByText('Select label')).toHaveLength(1);
expect(screen.getAllByText('Select value')).toHaveLength(1);
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({ labelsFilters: [{ label: 'foo', op: '=', value: 'bar' }] });
await userEvent.click(screen.getByLabelText(/remove-foo/));
expect(onChange).toBeCalledWith([]);
});
it('removes label but preserves a label with a value of empty string', async () => {
const { onChange } = setup({
labelsFilters: [
{ label: 'lab', op: '=', value: 'bel' },
{ label: 'foo', op: '=', value: 'bar' },
{ label: 'le', op: '=', value: '' },
],
});
await userEvent.click(screen.getByLabelText(/remove-foo/));
expect(onChange).toBeCalledWith([
{ label: 'lab', op: '=', value: 'bel' },
{ label: 'le', op: '=', value: '' },
]);
expect(screen.queryByText('bar')).toBeNull();
});
it('renders empty input when labels are deleted from outside ', async () => {
const { rerender } = setup({ labelsFilters: [{ label: 'foo', op: '=', value: 'bar' }] });
expect(screen.getByText(/foo/)).toBeInTheDocument();
expect(screen.getByText(/bar/)).toBeInTheDocument();
rerender(
<LabelFilters
onChange={jest.fn()}
onGetLabelNames={jest.fn()}
getLabelValuesAutofillSuggestions={jest.fn()}
onGetLabelValues={jest.fn()}
labelsFilters={[]}
debounceDuration={300}
/>
);
expect(screen.getAllByText('Select label')).toHaveLength(1);
expect(screen.getAllByText('Select value')).toHaveLength(1);
expect(screen.getByText(/=/)).toBeInTheDocument();
expect(getAddButton()).toBeInTheDocument();
});
it('does split regex in the middle of a label value when the value contains the char |', () => {
setup({ labelsFilters: [{ label: 'foo', op: '=~', value: 'boop|par' }] });
expect(screen.getByText('boop')).toBeInTheDocument();
expect(screen.getByText('par')).toBeInTheDocument();
});
it('does not split regex in between parentheses inside of a label value that contains the char |', () => {
setup({ labelsFilters: [{ label: 'foo', op: '=~', value: '(b|p)ar' }] });
expect(screen.getByText('(b|p)ar')).toBeInTheDocument();
});
it('shows error when filter with empty strings and label filter is required', async () => {
setup({ labelsFilters: [{ label: '', op: '=', value: '' }], labelFilterRequired: true });
expect(screen.getByText(MISSING_LABEL_FILTER_ERROR_MESSAGE)).toBeInTheDocument();
});
it('shows error when no filter and label filter is required', async () => {
setup({ labelsFilters: [], labelFilterRequired: true });
expect(screen.getByText(MISSING_LABEL_FILTER_ERROR_MESSAGE)).toBeInTheDocument();
});
});
function setup(propOverrides?: Partial<ComponentProps<typeof LabelFilters>>) {
const defaultProps: Props = {
onChange: jest.fn(),
getLabelValuesAutofillSuggestions: async (query: string, labelName?: string) => [
{ label: 'bar', value: 'bar' },
{ label: 'qux', value: 'qux' },
{ label: 'quux', value: 'quux' },
],
onGetLabelNames: async () => [
{ label: 'foo', value: 'foo' },
{ label: 'bar', value: 'bar' },
{ label: 'baz', value: 'baz' },
],
onGetLabelValues: async () => [
{ label: 'bar', value: 'bar' },
{ label: 'qux', value: 'qux' },
{ label: 'quux', value: 'quux' },
],
debounceDuration: 300,
labelsFilters: [],
};
const props = { ...defaultProps, ...propOverrides };
const { rerender } = render(<LabelFilters {...props} />);
return { ...props, rerender };
}
function getAddButton() {
return screen.getByLabelText(/Add/);
}
@@ -0,0 +1,114 @@
import { css, cx } from '@emotion/css';
import { isEqual } from 'lodash';
import React, { useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorField, EditorFieldGroup, EditorList } from '@grafana/experimental';
import { InlineFieldRow, InlineLabel } from '@grafana/ui';
import { QueryBuilderLabelFilter } from '../shared/types';
import { LabelFilterItem } from './LabelFilterItem';
export const MISSING_LABEL_FILTER_ERROR_MESSAGE = 'Select at least 1 label filter (label and value)';
export interface Props {
labelsFilters: QueryBuilderLabelFilter[];
onChange: (labelFilters: Array<Partial<QueryBuilderLabelFilter>>) => void;
onGetLabelNames: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<SelectableValue[]>;
onGetLabelValues: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<SelectableValue[]>;
/** If set to true, component will show error message until at least 1 filter is selected */
labelFilterRequired?: boolean;
getLabelValuesAutofillSuggestions: (query: string, labelName?: string) => Promise<SelectableValue[]>;
debounceDuration: number;
variableEditor?: boolean;
}
export function LabelFilters({
labelsFilters,
onChange,
onGetLabelNames,
onGetLabelValues,
labelFilterRequired,
getLabelValuesAutofillSuggestions,
debounceDuration,
variableEditor,
}: Props) {
const defaultOp = '=';
const [items, setItems] = useState<Array<Partial<QueryBuilderLabelFilter>>>([{ op: defaultOp }]);
useEffect(() => {
if (labelsFilters.length > 0) {
setItems(labelsFilters);
} else {
setItems([{ 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);
}
};
const hasLabelFilter = items.some((item) => item.label && item.value);
const editorList = () => {
return (
<EditorList
items={items}
onChange={onLabelsChange}
renderItem={(item: Partial<QueryBuilderLabelFilter>, onChangeItem, onDelete) => (
<LabelFilterItem
debounceDuration={debounceDuration}
item={item}
defaultOp={defaultOp}
onChange={onChangeItem}
onDelete={onDelete}
onGetLabelNames={onGetLabelNames}
onGetLabelValues={onGetLabelValues}
invalidLabel={labelFilterRequired && !item.label}
invalidValue={labelFilterRequired && !item.value}
getLabelValuesAutofillSuggestions={getLabelValuesAutofillSuggestions}
/>
)}
/>
);
};
return (
<>
{variableEditor ? (
<InlineFieldRow>
<div
className={cx(css`
display: flex;
`)}
>
<InlineLabel
width={20}
tooltip={<div>Optional: used to filter the metric select for this query type.</div>}
>
Label filters
</InlineLabel>
{editorList()}
</div>
</InlineFieldRow>
) : (
<EditorFieldGroup>
<EditorField
label="Label filters"
error={MISSING_LABEL_FILTER_ERROR_MESSAGE}
invalid={labelFilterRequired && !hasLabelFilter}
>
{editorList()}
</EditorField>
</EditorFieldGroup>
)}
</>
);
}
@@ -0,0 +1,60 @@
import React, { useState } from 'react';
import { DataSourceApi, SelectableValue, toOption } from '@grafana/data';
import { Select } from '@grafana/ui';
import { promQueryModeller } from '../PromQueryModeller';
import { getOperationParamId } from '../operationUtils';
import { QueryBuilderLabelFilter, QueryBuilderOperationParamEditorProps } from '../shared/types';
import { PromVisualQuery } from '../types';
export function LabelParamEditor({
onChange,
index,
operationId,
value,
query,
datasource,
}: QueryBuilderOperationParamEditorProps) {
const [state, setState] = useState<{
options?: SelectableValue[];
isLoading?: boolean;
}>({});
return (
<Select
inputId={getOperationParamId(operationId, index)}
autoFocus={value === '' ? true : undefined}
openMenuOnFocus
onOpenMenu={async () => {
setState({ isLoading: true });
const options = await loadGroupByLabels(query, datasource);
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: DataSourceApi): Promise<SelectableValue[]> {
let labels: QueryBuilderLabelFilter[] = query.labels;
// This function is used by both Prometheus and Loki and this the only difference.
if (datasource.type === 'prometheus') {
labels = [{ label: '__name__', op: '=', value: query.metric }, ...query.labels];
}
const expr = promQueryModeller.renderLabels(labels);
const result = await datasource.languageProvider.fetchLabelsWithMatch(expr);
return Object.keys(result).map((x) => ({
label: x,
value: x,
}));
}

Some files were not shown because too many files have changed in this diff Show More