diff --git a/.betterer.results b/.betterer.results index acdb0ea202c..cdd84fdd949 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"], diff --git a/jest.config.js b/jest.config.js index f6d54c4e337..3e33cc1af03 100644 --- a/jest.config.js +++ b/jest.config.js @@ -14,6 +14,7 @@ const esModules = [ 'robust-predicates', 'leven', 'nanoid', + 'monaco-promql' ].join('|'); module.exports = { diff --git a/package.json b/package.json index e30cd0605ec..a18aee9aa60 100644 --- a/package.json +++ b/package.json @@ -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:*", diff --git a/packages/grafana-prometheus/CHANGELOG.md b/packages/grafana-prometheus/CHANGELOG.md new file mode 100644 index 00000000000..13798fc5ad8 --- /dev/null +++ b/packages/grafana-prometheus/CHANGELOG.md @@ -0,0 +1,3 @@ +# (2023-06-11) + +First public release diff --git a/packages/grafana-prometheus/LICENSE_APACHE2 b/packages/grafana-prometheus/LICENSE_APACHE2 new file mode 100644 index 00000000000..373dde574a0 --- /dev/null +++ b/packages/grafana-prometheus/LICENSE_APACHE2 @@ -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. diff --git a/packages/grafana-prometheus/README.md b/packages/grafana-prometheus/README.md new file mode 100644 index 00000000000..d2a8f082d1b --- /dev/null +++ b/packages/grafana-prometheus/README.md @@ -0,0 +1,5 @@ +# Grafana Prometheus Library + +> **@grafana/prometheus is currently in BETA**. + +This package is a frontend library for Prometheus diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json new file mode 100644 index 00000000000..dc6f5f71a31 --- /dev/null +++ b/packages/grafana-prometheus/package.json @@ -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" + } +} diff --git a/packages/grafana-prometheus/src/add_label_to_query.test.ts b/packages/grafana-prometheus/src/add_label_to_query.test.ts new file mode 100644 index 00000000000..08c32e3c398 --- /dev/null +++ b/packages/grafana-prometheus/src/add_label_to_query.test.ts @@ -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'); + }); +}); diff --git a/packages/grafana-prometheus/src/add_label_to_query.ts b/packages/grafana-prometheus/src/add_label_to_query.ts new file mode 100644 index 00000000000..696e0cbb8dc --- /dev/null +++ b/packages/grafana-prometheus/src/add_label_to_query.ts @@ -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); +} diff --git a/packages/grafana-prometheus/src/components/AnnotationQueryEditor.tsx b/packages/grafana-prometheus/src/components/AnnotationQueryEditor.tsx new file mode 100644 index 00000000000..0cbaf5978cd --- /dev/null +++ b/packages/grafana-prometheus/src/components/AnnotationQueryEditor.tsx @@ -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; + onAnnotationChange?: (annotation: AnnotationQuery) => 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 ( + <> + + { + onAnnotationChange({ + ...annotation, + expr: query.expr, + }); + }} + /> + + + An additional lower limit for the step parameter of the Prometheus query and for the{' '} + $__interval and $__rate_interval variables. + + } + > + { + onAnnotationChange({ + ...annotation, + step: ev.currentTarget.value, + }); + }} + defaultValue={query.interval} + id={selectors.components.DataSource.Prometheus.annotations.minStep} + /> + + + + + + + { + onAnnotationChange({ + ...annotation, + titleFormat: event.currentTarget.value, + }); + }} + data-testid={selectors.components.DataSource.Prometheus.annotations.title} + /> + + + { + onAnnotationChange({ + ...annotation, + tagKeys: event.currentTarget.value, + }); + }} + data-testid={selectors.components.DataSource.Prometheus.annotations.tags} + /> + + + { + onAnnotationChange({ + ...annotation, + textFormat: event.currentTarget.value, + }); + }} + data-testid={selectors.components.DataSource.Prometheus.annotations.text} + /> + + + { + onAnnotationChange({ + ...annotation, + useValueForTime: event.currentTarget.value, + }); + }} + data-testid={selectors.components.DataSource.Prometheus.annotations.seriesValueAsTimestamp} + /> + + + + ); +} diff --git a/packages/grafana-prometheus/src/components/PromCheatSheet.tsx b/packages/grafana-prometheus/src/components/PromCheatSheet.tsx new file mode 100644 index 00000000000..9142be3e1f7 --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromCheatSheet.tsx @@ -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) => ( +
+

PromQL Cheat Sheet

+ {CHEAT_SHEET_ITEMS.map((item, index) => ( +
+
{item.title}
+ {item.expression ? ( + + ) : null} +
{item.label}
+
+ ))} +
+); + +export default PromCheatSheet; diff --git a/packages/grafana-prometheus/src/components/PromExemplarField.tsx b/packages/grafana-prometheus/src/components/PromExemplarField.tsx new file mode 100644 index 00000000000..e09a6260c98 --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromExemplarField.tsx @@ -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(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 ( + + +
+ Exemplars + { + onChange(!query.exemplar); + }} + /> +
+
+
+ ); +} + +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; + `, + }; +} diff --git a/packages/grafana-prometheus/src/components/PromExploreExtraField.test.tsx b/packages/grafana-prometheus/src/components/PromExploreExtraField.test.tsx new file mode 100644 index 00000000000..881cd1c0e12 --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromExploreExtraField.test.tsx @@ -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(); +}; + +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(); + }); +}); diff --git a/packages/grafana-prometheus/src/components/PromExploreExtraField.tsx b/packages/grafana-prometheus/src/components/PromExploreExtraField.tsx new file mode 100644 index 00000000000..f4bc23d0e4d --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromExploreExtraField.tsx @@ -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) { + if (e.currentTarget.value !== query.interval) { + onChangeQueryStep(e.currentTarget.value); + } + } + + function onReturnKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' && e.shiftKey) { + onRunQuery(); + } + } + + const onQueryTypeChange = getQueryTypeChangeHandler(query, onChange); + + return ( +
+ {/*Query type field*/} +
+ Query type + + +
+ {/*Step field*/} +
+ + Min step + + +
+ + +
+ ); +}); + +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', +}; diff --git a/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx b/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx new file mode 100644 index 00000000000..e8da197c087 --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx @@ -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 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( + + ); + + 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(); + }); +}); diff --git a/packages/grafana-prometheus/src/components/PromQueryEditorByApp.tsx b/packages/grafana-prometheus/src/components/PromQueryEditorByApp.tsx new file mode 100644 index 00000000000..f10a6e3279a --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromQueryEditorByApp.tsx @@ -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 ; + default: + return ; + } +} + +export default memo(PromQueryEditorByApp); diff --git a/packages/grafana-prometheus/src/components/PromQueryEditorForAlerting.tsx b/packages/grafana-prometheus/src/components/PromQueryEditorForAlerting.tsx new file mode 100644 index 00000000000..b7b57071d8e --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromQueryEditorForAlerting.tsx @@ -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 ( + + ); +} + +export const testIds = { + editor: 'prom-editor-cloud-alerting', +}; diff --git a/packages/grafana-prometheus/src/components/PromQueryField.test.tsx b/packages/grafana-prometheus/src/components/PromQueryField.test.tsx new file mode 100644 index 00000000000..0ee6d67bb64 --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromQueryField.test.tsx @@ -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 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(); + + // 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(); + + // 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(); + + // 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( + + ); + + // 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( + [], + } as unknown as PrometheusDatasource + } + {...defaultProps} + /> + ); + + // wait for component to render + await screen.findByRole('button'); + + const changedMetrics = ['baz', 'moo']; + queryField.rerender( + + ); + + // 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(); + + // 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; +} diff --git a/packages/grafana-prometheus/src/components/PromQueryField.tsx b/packages/grafana-prometheus/src/components/PromQueryField.tsx new file mode 100644 index 00000000000..3caf684b298 --- /dev/null +++ b/packages/grafana-prometheus/src/components/PromQueryField.tsx @@ -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, Themeable2 { + ExtraFieldElement?: ReactNode; + 'data-testid'?: string; +} + +interface PromQueryFieldState { + labelBrowserVisible: boolean; + syntaxLoaded: boolean; + hint: QueryHint | null; +} + +class PromQueryField extends React.PureComponent { + declare languageProviderInitializationPromise: CancelablePromise; + + 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 ( + storageKey={LAST_USED_LABELS_KEY} defaultValue={[]}> + {(lastUsedLabels, onLastUsedLabelsSave, onLastUsedLabelsDelete) => { + return ( + <> +
+ + +
+ +
+
+ {labelBrowserVisible && ( +
+ +
+ )} + + {ExtraFieldElement} + {hint ? ( +
+
+ {hint.label}{' '} + {hint.fix ? ( + + ) : null} +
+
+ ) : null} + + ); + }} + + ); + } +} + +export default withTheme2(PromQueryField); diff --git a/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx new file mode 100644 index 00000000000..e3ba496356b --- /dev/null +++ b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx @@ -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(); + // 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(); + // 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(); + + // 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(); + + // 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(); + // 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(); + // 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)'); + }); +}); diff --git a/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx new file mode 100644 index 00000000000..79ae235574e --- /dev/null +++ b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx @@ -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, + 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 = 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 { + valueListsRef = React.createRef(); + state: BrowserState = { + labels: [], + labelSearchTerm: '', + metricSearchTerm: '', + status: 'Ready', + error: '', + validationStatus: '', + valueSearchTerm: '', + }; + + onChangeLabelSearch = (event: ChangeEvent) => { + this.setState({ labelSearchTerm: event.target.value }); + }; + + onChangeMetricSearch = (event: ChangeEvent) => { + this.setState({ metricSearchTerm: event.target.value }); + }; + + onChangeValueSearch = (event: ChangeEvent) => { + 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) => { + const label = this.state.labels.find((l) => l.name === name); + if (!label) { + return; + } + // Toggle selected state + const selected = !label.selected; + let nextValue: Partial = { 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) => { + 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) => { + // 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, 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 ( +
+ +
+ ); + } + + // 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 ( +
+ +
+
+ +
+ +
+
+ metrics!.values![i].name} + width={300} + className={styles.valueList} + > + {({ index, style }) => { + const value = metrics?.values?.[index]; + if (!value) { + return null; + } + return ( +
+ +
+ ); + }} +
+
+
+
+ +
+
+ +
+ +
+ {/* Using fixed height here to prevent jumpy layout */} +
+ {nonMetricLabels.map((label) => ( +
+
+
+ +
+ +
+
+ {selectedLabels.map((label) => ( +
+
+
+ label.values![i].name} + width={200} + className={styles.valueList} + > + {({ index, style }) => { + const value = label.values?.[index]; + if (!value) { + return null; + } + return ( +
+ +
+ ); + }} +
+
+ ))} +
+
+
+
+ +
+ +
+ {selector} +
+ {validationStatus &&
{validationStatus}
} + + + + + +
+ {error || status} +
+
+
+
+ ); + } +} + +export const PrometheusMetricsBrowser = withTheme2(UnthemedPrometheusMetricsBrowser); diff --git a/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx b/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx new file mode 100644 index 00000000000..cef11a8b031 --- /dev/null +++ b/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx @@ -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 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 as PrometheusDatasource, + query: { + refId: 'test', + query: 'label_names()', + }, + onRunQuery: () => {}, + onChange: () => {}, + history: [], + }; + }); + + test('Displays a group of function options', async () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(languageProviderStartMock).toHaveBeenCalledWith(range); + }); +}); diff --git a/packages/grafana-prometheus/src/components/VariableQueryEditor.tsx b/packages/grafana-prometheus/src/components/VariableQueryEditor.tsx new file mode 100644 index 00000000000..fce9bf1e1df --- /dev/null +++ b/packages/grafana-prometheus/src/components/VariableQueryEditor.tsx @@ -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; + +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(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>>([]); + + // label filters have been added as a filter for metrics in label values query type + const [labelFilters, setLabelFilters] = useState([]); + + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + setSeriesQuery(e.currentTarget.value); + }; + + const onClassicQueryChange = (e: FormEvent) => { + setClassicQuery(e.currentTarget.value); + }; + + const promVisualQuery = useCallback(() => { + return { metric: metric, labels: labelFilters, operations: [] }; + }, [metric, labelFilters]); + + return ( + <> + + The Prometheus data source plugin provides the following query types for template variables. + } + > + + + + {/* Used to select an optional metric with optional label filters */} + + + )} + + {qryType === QueryType.LabelNames && ( + + Returns a list of label names, optionally filtering by specified metric regex.} + > + { + 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} + /> + + + )} + + {qryType === QueryType.MetricNames && ( + + Returns a list of metrics matching the specified metric regex.} + > + { + 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} + /> + + + )} + + {qryType === QueryType.VarQueryResult && ( + + + Returns a list of Prometheus query results for the query. This can include Prometheus functions, i.e. + sum(go_goroutines). + + } + > +