Prometheus: Facilitate tree shaking with exports and bundler mode (#105575)

* feat: bundler mode

* refactor: simplify singleton modeller

* refactor: isolate state

* refactor: decouple query rendering from modeller

* fix: remove unused var

* build: configure declaration files

* refactor!: configure exports

* fix: remove unused file

* fix: use shared utils only

* refactor: avoid confusing singleton

* fix: avoid ReDoS

see https://github.com/grafana/grafana/security/code-scanning/2883 for details

* fix: avoid catastrophic backtracking

see https://github.com/grafana/grafana/security/code-scanning/2884 for details

* fix: circular dep

* fix: make rollup happy by restoring declarationDir

* fix: import

* fix: circular dep

* chore: remove superfluous file

* fix: remove duplicate modeller code

* chore: remove superfluous comment

* fix: handle subpaths in exports

* fix: add missing ignore

* fix: correctly ignore assertion

* refactor: improve clarity

* refactor: promote clarity, be explicit

* refactor: more sensible filter

* fix: add missing devDep

* fix: circular import

* refactor: avoid type assertions where possible

* fix: linting

* chore: remove subpath exports for now

* chore: prefer forthcoming solution for arbitrary exports

* chore: undo erroneous change after merge

* refactor: prefer snake_case

* fix: linting

* refactor: simplify
pull/106833/head
Nick Richmond 1 month ago committed by GitHub
parent e90134bb6f
commit c65ef07635
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 11
      .betterer.results
  2. 1
      packages/grafana-prometheus/package.json
  3. 5
      packages/grafana-prometheus/src/add_label_to_query.ts
  4. 2
      packages/grafana-prometheus/src/components/VariableQueryEditor.tsx
  5. 2
      packages/grafana-prometheus/src/components/metrics-browser/selectorBuilder.ts
  6. 2
      packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx
  7. 2
      packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts
  8. 3
      packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts
  9. 0
      packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/monaco-completion-provider.ts
  10. 2
      packages/grafana-prometheus/src/configuration/AlertingSettingsOverhaul.tsx
  11. 5
      packages/grafana-prometheus/src/configuration/ConfigEditor.test.tsx
  12. 91
      packages/grafana-prometheus/src/configuration/ConfigEditor.tsx
  13. 2
      packages/grafana-prometheus/src/configuration/DataSourceHttpSettingsOverhaul.tsx
  14. 3
      packages/grafana-prometheus/src/configuration/ExemplarSetting.tsx
  15. 2
      packages/grafana-prometheus/src/configuration/ExemplarsSettings.tsx
  16. 18
      packages/grafana-prometheus/src/configuration/PromSettings.tsx
  17. 125
      packages/grafana-prometheus/src/configuration/shared/utils.tsx
  18. 17
      packages/grafana-prometheus/src/constants.ts
  19. 8
      packages/grafana-prometheus/src/datasource.test.ts
  20. 50
      packages/grafana-prometheus/src/datasource.ts
  21. 72
      packages/grafana-prometheus/src/escaping.ts
  22. 10
      packages/grafana-prometheus/src/index.ts
  23. 4
      packages/grafana-prometheus/src/language_provider.ts
  24. 3
      packages/grafana-prometheus/src/language_utils.test.ts
  25. 30
      packages/grafana-prometheus/src/language_utils.ts
  26. 4
      packages/grafana-prometheus/src/migrations/variableMigration.ts
  27. 11
      packages/grafana-prometheus/src/querybuilder/PromQueryModeller.ts
  28. 3
      packages/grafana-prometheus/src/querybuilder/QueryPattern.tsx
  29. 2
      packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.test.tsx
  30. 9
      packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.tsx
  31. 27
      packages/grafana-prometheus/src/querybuilder/components/LabelParamEditor.tsx
  32. 2
      packages/grafana-prometheus/src/querybuilder/components/MetricCombobox.tsx
  33. 2
      packages/grafana-prometheus/src/querybuilder/components/MetricsLabelsSection.tsx
  34. 4
      packages/grafana-prometheus/src/querybuilder/components/NestedQuery.tsx
  35. 52
      packages/grafana-prometheus/src/querybuilder/components/OperationParamEditorWrapper.tsx
  36. 107
      packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.tsx
  37. 2
      packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.test.tsx
  38. 2
      packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderContainer.tsx
  39. 2
      packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderExplained.tsx
  40. 13
      packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx
  41. 16
      packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.tsx
  42. 12
      packages/grafana-prometheus/src/querybuilder/components/metrics-modal/AdditionalSettings.tsx
  43. 94
      packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx
  44. 2
      packages/grafana-prometheus/src/querybuilder/components/metrics-modal/ResultsTable.tsx
  45. 1
      packages/grafana-prometheus/src/querybuilder/components/metrics-modal/index.ts
  46. 3
      packages/grafana-prometheus/src/querybuilder/components/metrics-modal/shared/actions.ts
  47. 17
      packages/grafana-prometheus/src/querybuilder/components/metrics-modal/shared/state.ts
  48. 12
      packages/grafana-prometheus/src/querybuilder/components/metrics-modal/shared/testIds.ts
  49. 27
      packages/grafana-prometheus/src/querybuilder/components/metrics-modal/shared/types.ts
  50. 5
      packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/helpers.ts
  51. 95
      packages/grafana-prometheus/src/querybuilder/components/metrics-modal/state/state.ts
  52. 27
      packages/grafana-prometheus/src/querybuilder/components/shared/BaseQueryBuilder.tsx
  53. 13
      packages/grafana-prometheus/src/querybuilder/components/shared/BaseQueryBuilderProps.ts
  54. 102
      packages/grafana-prometheus/src/querybuilder/components/shared/QueryBuilderContent.tsx
  55. 21
      packages/grafana-prometheus/src/querybuilder/components/shared/types.ts
  56. 98
      packages/grafana-prometheus/src/querybuilder/operationDefinitions.ts
  57. 2
      packages/grafana-prometheus/src/querybuilder/operationUtils.test.ts
  58. 11
      packages/grafana-prometheus/src/querybuilder/operationUtils.ts
  59. 3
      packages/grafana-prometheus/src/querybuilder/operations.ts
  60. 103
      packages/grafana-prometheus/src/querybuilder/shared/LokiAndPromQueryModellerBase.ts
  61. 15
      packages/grafana-prometheus/src/querybuilder/shared/OperationEditor.tsx
  62. 2
      packages/grafana-prometheus/src/querybuilder/shared/OperationList.test.tsx
  63. 50
      packages/grafana-prometheus/src/querybuilder/shared/OperationParamEditorRegistry.tsx
  64. 17
      packages/grafana-prometheus/src/querybuilder/shared/QueryBuilderHints.tsx
  65. 10
      packages/grafana-prometheus/src/querybuilder/shared/modeller_instance.ts
  66. 3
      packages/grafana-prometheus/src/querybuilder/shared/param_utils.ts
  67. 16
      packages/grafana-prometheus/src/querybuilder/shared/query_builder_utils.ts
  68. 31
      packages/grafana-prometheus/src/querybuilder/shared/rendering/labels.ts
  69. 37
      packages/grafana-prometheus/src/querybuilder/shared/rendering/operations.ts
  70. 141
      packages/grafana-prometheus/src/querybuilder/shared/rendering/query.ts
  71. 16
      packages/grafana-prometheus/src/querybuilder/shared/types.ts
  72. 13
      packages/grafana-prometheus/src/querybuilder/shared/types/visual_query.ts
  73. 13
      packages/grafana-prometheus/src/querybuilder/types.ts
  74. 1
      yarn.lock

@ -455,20 +455,17 @@ exports[`better eslint`] = {
"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/PromQueryBuilder.tsx: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": [
"packages/grafana-prometheus/src/querybuilder/shared/OperationParamEditorRegistry.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.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"]
],
"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"]
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"packages/grafana-prometheus/src/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],

@ -95,6 +95,7 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-select-event": "5.5.1",
"rimraf": "6.0.1",
"rollup": "^4.22.4",
"rollup-plugin-esbuild": "6.2.0",
"rollup-plugin-node-externals": "^8.0.0",

@ -1,8 +1,8 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/add_label_to_query.ts
import { parser, VectorSelector } from '@prometheus-io/lezer-promql';
import { PromQueryModeller } from './querybuilder/PromQueryModeller';
import { buildVisualQueryFromString } from './querybuilder/parsing';
import { renderQuery } from './querybuilder/shared/rendering/query';
import { QueryBuilderLabelFilter } from './querybuilder/shared/types';
import { PromVisualQuery } from './querybuilder/types';
@ -67,7 +67,6 @@ function addFilter(
vectorSelectorPositions: VectorSelectorPosition[],
filter: QueryBuilderLabelFilter
): string {
const modeller = new PromQueryModeller();
let newQuery = '';
let prev = 0;
@ -84,7 +83,7 @@ function addFilter(
// We don't want to add duplicate labels.
match.query.labels.push(filter);
}
const newLabels = modeller.renderQuery(match.query);
const newLabels = renderQuery(match.query);
newQuery += start + newLabels + end;
prev = match.to;
}

@ -13,8 +13,8 @@ import {
migrateVariableEditorBackToVariableSupport,
migrateVariableQueryToEditor,
} from '../migrations/variableMigration';
import { promQueryModeller } from '../querybuilder/PromQueryModeller';
import { MetricsLabelsSection } from '../querybuilder/components/MetricsLabelsSection';
import { promQueryModeller } from '../querybuilder/shared/modeller_instance';
import { QueryBuilderLabelFilter } from '../querybuilder/shared/types';
import { PromVisualQuery } from '../querybuilder/types';
import {

@ -1,4 +1,4 @@
import { escapeLabelValueInExactSelector, escapeLabelValueInRegexSelector } from '../../language_utils';
import { escapeLabelValueInExactSelector, escapeLabelValueInRegexSelector } from '../../escaping';
import { isValidLegacyName, utf8Support } from '../../utf8_support';
/**

@ -12,8 +12,8 @@ import { Monaco, monacoTypes, ReactMonacoEditor, useTheme2 } from '@grafana/ui';
import { Props } from './MonacoQueryFieldProps';
import { getOverrideServices } from './getOverrideServices';
import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider';
import { DataProvider } from './monaco-completion-provider/data_provider';
import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider/monaco-completion-provider';
import { placeHolderScopedVars, validateQuery } from './monaco-completion-provider/validation';
import { language, languageConfiguration } from './promql';

@ -1,6 +1,6 @@
import { config } from '@grafana/runtime';
import { SUGGESTIONS_LIMIT } from '../../../language_provider';
import { SUGGESTIONS_LIMIT } from '../../../constants';
import { FUNCTIONS } from '../../../promql';
import { getMockTimeRange } from '../../../test/__mocks__/datasource';

@ -5,8 +5,7 @@ import { languages } from 'monaco-editor';
import { TimeRange } from '@grafana/data';
import { config } from '@grafana/runtime';
import { prometheusRegularEscape } from '../../../datasource';
import { escapeLabelValueInExactSelector } from '../../../language_utils';
import { escapeLabelValueInExactSelector, prometheusRegularEscape } from '../../../escaping';
import { FUNCTIONS } from '../../../promql';
import { isValidLegacyName } from '../../../utf8_support';

@ -8,7 +8,7 @@ import { ConfigSubSection } from '@grafana/plugin-ui';
import { config } from '@grafana/runtime';
import { InlineField, Switch, useTheme2 } from '@grafana/ui';
import { docsTip, overhaulStyles } from './ConfigEditor';
import { docsTip, overhaulStyles } from './shared/utils';
interface Props<T extends DataSourceJsonData>
extends Pick<DataSourcePluginOptionsEditorProps<T>, 'options' | 'onOptionsChange'> {}

@ -1,8 +1,9 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/configuration/ConfigEditor.test.tsx
import { FieldValidationMessage } from '@grafana/ui';
import { validateInput } from './ConfigEditor';
import { DURATION_REGEX, MULTIPLE_DURATION_REGEX } from './PromSettings';
import { DURATION_REGEX, MULTIPLE_DURATION_REGEX } from '../constants';
import { validateInput } from './shared/utils';
const VALID_URL_REGEX = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/;

@ -1,20 +1,17 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/configuration/ConfigEditor.tsx
import { css } from '@emotion/css';
import { DataSourcePluginOptionsEditorProps, GrafanaTheme2 } from '@grafana/data';
import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { ConfigSection, DataSourceDescription, AdvancedHttpSettings } from '@grafana/plugin-ui';
import { config } from '@grafana/runtime';
import { Alert, FieldValidationMessage, TextLink, useTheme2 } from '@grafana/ui';
import { Alert, useTheme2 } from '@grafana/ui';
import { PromOptions } from '../types';
import { AlertingSettingsOverhaul } from './AlertingSettingsOverhaul';
import { DataSourceHttpSettingsOverhaul } from './DataSourceHttpSettingsOverhaul';
import { PromSettings } from './PromSettings';
export const PROM_CONFIG_LABEL_WIDTH = 30;
import { overhaulStyles } from './shared/utils';
export type PrometheusConfigProps = DataSourcePluginOptionsEditorProps<PromOptions>;
export const ConfigEditor = (props: PrometheusConfigProps) => {
@ -61,85 +58,3 @@ export const ConfigEditor = (props: PrometheusConfigProps) => {
</>
);
};
/**
* Use this to return a url in a tooltip in a field. Don't forget to make the field interactive to be able to click on the tooltip
* @param url
* @returns
*/
export function docsTip(url?: string) {
const docsUrl = 'https://grafana.com/docs/grafana/latest/datasources/prometheus/configure-prometheus-data-source/';
return (
<TextLink href={url ? url : docsUrl} external>
<Trans i18nKey="configuration.docs-tip.visit-docs-for-more-details-here">Visit docs for more details here.</Trans>
</TextLink>
);
}
export const validateInput = (
input: string,
pattern: string | RegExp,
errorMessage?: string
): boolean | JSX.Element => {
const defaultErrorMessage = 'Value is not valid';
if (input && !input.match(pattern)) {
return <FieldValidationMessage>{errorMessage ? errorMessage : defaultErrorMessage}</FieldValidationMessage>;
} else {
return true;
}
};
export function overhaulStyles(theme: GrafanaTheme2) {
return {
additionalSettings: css({
marginBottom: '25px',
}),
secondaryGrey: css({
color: theme.colors.secondary.text,
opacity: '65%',
}),
inlineError: css({
margin: '0px 0px 4px 245px',
}),
switchField: css({
alignItems: 'center',
}),
sectionHeaderPadding: css({
paddingTop: '32px',
}),
sectionBottomPadding: css({
paddingBottom: '28px',
}),
subsectionText: css({
fontSize: '12px',
}),
hrBottomSpace: css({
marginBottom: '56px',
}),
hrTopSpace: css({
marginTop: '50px',
}),
textUnderline: css({
textDecoration: 'underline',
}),
versionMargin: css({
marginBottom: '12px',
}),
advancedHTTPSettingsMargin: css({
margin: '24px 0 8px 0',
}),
advancedSettings: css({
paddingTop: '32px',
}),
alertingTop: css({
marginTop: '40px !important',
}),
overhaulPageHeading: css({
fontWeight: 400,
}),
container: css({
maxwidth: 578,
}),
};
}

@ -6,7 +6,7 @@ import { SecureSocksProxySettings, useTheme2 } from '@grafana/ui';
import { PromOptions } from '../types';
import { docsTip, overhaulStyles } from './ConfigEditor';
import { docsTip, overhaulStyles } from './shared/utils';
export type DataSourceHttpSettingsProps = {
options: DataSourceSettings<PromOptions, {}>;

@ -7,9 +7,10 @@ import { Trans, t } from '@grafana/i18n';
import { config, DataSourcePicker } from '@grafana/runtime';
import { Button, InlineField, Input, Switch, useTheme2 } from '@grafana/ui';
import { PROM_CONFIG_LABEL_WIDTH } from '../constants';
import { ExemplarTraceIdDestination } from '../types';
import { docsTip, overhaulStyles, PROM_CONFIG_LABEL_WIDTH } from './ConfigEditor';
import { docsTip, overhaulStyles } from './shared/utils';
type Props = {
value: ExemplarTraceIdDestination;

@ -8,8 +8,8 @@ import { Button, useTheme2 } from '@grafana/ui';
import { ExemplarTraceIdDestination } from '../types';
import { overhaulStyles } from './ConfigEditor';
import { ExemplarSetting } from './ExemplarSetting';
import { overhaulStyles } from './shared/utils';
type Props = {
options?: ExemplarTraceIdDestination[];

@ -14,14 +14,20 @@ import { ConfigSubSection } from '@grafana/plugin-ui';
import { config } from '@grafana/runtime';
import { InlineField, Input, Select, Switch, TextLink, useTheme2 } from '@grafana/ui';
import { SUGGESTIONS_LIMIT } from '../language_provider';
import {
DURATION_REGEX,
MULTIPLE_DURATION_REGEX,
NON_NEGATIVE_INTEGER_REGEX,
PROM_CONFIG_LABEL_WIDTH,
SUGGESTIONS_LIMIT,
} from '../constants';
import { QueryEditorMode } from '../querybuilder/shared/types';
import { defaultPrometheusQueryOverlapWindow } from '../querycache/QueryCache';
import { PromApplication, PrometheusCacheLevel, PromOptions } from '../types';
import { docsTip, overhaulStyles, PROM_CONFIG_LABEL_WIDTH, validateInput } from './ConfigEditor';
import { ExemplarsSettings } from './ExemplarsSettings';
import { PromFlavorVersions } from './PromFlavorVersions';
import { docsTip, overhaulStyles, validateInput } from './shared/utils';
const httpOptions = [
{ value: 'POST', label: 'POST' },
@ -51,14 +57,6 @@ const prometheusFlavorSelectItems: PrometheusSelectItemsType = [
type Props = Pick<DataSourcePluginOptionsEditorProps<PromOptions>, 'options' | 'onOptionsChange'>;
// single duration input
export const DURATION_REGEX = /^$|^\d+(ms|[Mwdhmsy])$/;
// multiple duration input
export const MULTIPLE_DURATION_REGEX = /(\d+)(.+)/;
export const NON_NEGATIVE_INTEGER_REGEX = /^(0|[1-9]\d*)(\.\d+)?(e\+?\d+)?$/; // non-negative integers, including scientific notation
const durationError = 'Value is not valid, you can use number with time unit specifier: y, M, w, d, h, m, s';
export const countError = 'Value is not valid, you can use non-negative integers, including scientific notation';

@ -0,0 +1,125 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { FieldValidationMessage, TextLink } from '@grafana/ui';
/**
* Use this to return a url in a tooltip in a field. Don't forget to make the field interactive to be able to click on the tooltip
* @param url
* @returns
*/
export function docsTip(url?: string) {
const docsUrl = 'https://grafana.com/docs/grafana/latest/datasources/prometheus/configure-prometheus-data-source/';
return (
<TextLink href={url ? url : docsUrl} external>
<Trans i18nKey="configuration.docs-tip.visit-docs-for-more-details-here">Visit docs for more details here.</Trans>
</TextLink>
);
}
export const validateInput = (
input: string,
pattern: string | RegExp,
errorMessage?: string
): boolean | JSX.Element => {
const defaultErrorMessage = 'Value is not valid';
const inputTooLongErrorMessage = 'Input is too long';
const validationTimeoutErrorMessage = 'Validation timeout - input too complex';
const invalidValidationPatternErrorMessage = 'Invalid validation pattern';
const MAX_INPUT_LENGTH = 1000; // Reasonable limit for most validation cases
// Early return if no input
if (!input) {
return true;
}
// Check input length
if (input.length > MAX_INPUT_LENGTH) {
return <FieldValidationMessage>{inputTooLongErrorMessage}</FieldValidationMessage>;
}
try {
// Convert string pattern to RegExp if needed
let regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
// Ensure pattern is properly anchored to prevent catastrophic backtracking
if (typeof pattern === 'string' && !pattern.startsWith('^') && !pattern.endsWith('$')) {
regex = new RegExp(`^${pattern}$`);
}
// Add timeout to prevent ReDoS
const timeout = 100; // 100ms timeout
const startTime = Date.now();
const isValid = regex.test(input);
// Check if execution took too long
if (Date.now() - startTime > timeout) {
return <FieldValidationMessage>{validationTimeoutErrorMessage}</FieldValidationMessage>;
}
if (!isValid) {
return <FieldValidationMessage>{errorMessage || defaultErrorMessage}</FieldValidationMessage>;
}
return true;
} catch (error) {
return <FieldValidationMessage>{invalidValidationPatternErrorMessage}</FieldValidationMessage>;
}
};
export function overhaulStyles(theme: GrafanaTheme2) {
return {
additionalSettings: css({
marginBottom: '25px',
}),
secondaryGrey: css({
color: theme.colors.secondary.text,
opacity: '65%',
}),
inlineError: css({
margin: '0px 0px 4px 245px',
}),
switchField: css({
alignItems: 'center',
}),
sectionHeaderPadding: css({
paddingTop: '32px',
}),
sectionBottomPadding: css({
paddingBottom: '28px',
}),
subsectionText: css({
fontSize: '12px',
}),
hrBottomSpace: css({
marginBottom: '56px',
}),
hrTopSpace: css({
marginTop: '50px',
}),
textUnderline: css({
textDecoration: 'underline',
}),
versionMargin: css({
marginBottom: '12px',
}),
advancedHTTPSettingsMargin: css({
margin: '24px 0 8px 0',
}),
advancedSettings: css({
paddingTop: '32px',
}),
alertingTop: css({
marginTop: '40px !important',
}),
overhaulPageHeading: css({
fontWeight: 400,
}),
container: css({
maxwidth: 578,
}),
};
}

@ -0,0 +1,17 @@
// Max number of items (metrics, labels, values) that we display as suggestions. Prevents from running out of memory.
export const SUGGESTIONS_LIMIT = 10000;
export const PROMETHEUS_QUERY_BUILDER_MAX_RESULTS = 1000;
export const PROM_CONFIG_LABEL_WIDTH = 30;
// single duration input
export const DURATION_REGEX = /^$|^\d+(ms|[Mwdhmsy])$/;
// multiple duration input
export const MULTIPLE_DURATION_REGEX = /(\d+)(.+)/;
export const NON_NEGATIVE_INTEGER_REGEX = /^(0|[1-9]\d*)(\.\d+)?(e\+?\d+)?$/; // non-negative integers, including scientific notation
export const durationError = 'Value is not valid, you can use number with time unit specifier: y, M, w, d, h, m, s';
export const countError = 'Value is not valid, you can use non-negative integers, including scientific notation';

@ -16,12 +16,8 @@ import {
} from '@grafana/data';
import { config, getBackendSrv, setBackendSrv, TemplateSrv } from '@grafana/runtime';
import {
extractRuleMappingFromGroups,
PrometheusDatasource,
prometheusRegularEscape,
prometheusSpecialRegexEscape,
} from './datasource';
import { extractRuleMappingFromGroups, PrometheusDatasource } from './datasource';
import { prometheusRegularEscape, prometheusSpecialRegexEscape } from './escaping';
import PromQlLanguageProvider from './language_provider';
import {
createDataRequest,

@ -42,7 +42,9 @@ import {
import { addLabelToQuery } from './add_label_to_query';
import { PrometheusAnnotationSupport } from './annotations';
import PrometheusLanguageProvider, { SUGGESTIONS_LIMIT } from './language_provider';
import { SUGGESTIONS_LIMIT } from './constants';
import { prometheusRegularEscape, prometheusSpecialRegexEscape } from './escaping';
import PrometheusLanguageProvider from './language_provider';
import {
expandRecordingRules,
getClientCacheDurationInMinutes,
@ -51,7 +53,7 @@ import {
} from './language_utils';
import { PrometheusMetricFindQuery } from './metric_find_query';
import { getQueryHints } from './query_hints';
import { promQueryModeller } from './querybuilder/PromQueryModeller';
import { promQueryModeller } from './querybuilder/shared/modeller_instance';
import { QueryBuilderLabelFilter, QueryEditorMode } from './querybuilder/shared/types';
import { CacheRequestInfo, defaultPrometheusQueryOverlapWindow, QueryCache } from './querycache/QueryCache';
import { transformV2 } from './result_transformer';
@ -935,47 +937,3 @@ export function extractRuleMappingFromGroups(groups: RawRecordingRules[]): RuleQ
{}
);
}
// NOTE: these two functions are similar to the escapeLabelValueIn* functions
// in language_utils.ts, but they are not exactly the same algorithm, and we found
// no way to reuse one in the another or vice versa.
export function prometheusRegularEscape<T>(value: T) {
if (typeof value !== 'string') {
return value;
}
if (config.featureToggles.prometheusSpecialCharsInLabelValues) {
// if the string looks like a complete label matcher (e.g. 'job="grafana"' or 'job=~"grafana"'),
// don't escape the encapsulating quotes
if (/^\w+(=|!=|=~|!~)".*"$/.test(value)) {
return value;
}
return value
.replace(/\\/g, '\\\\') // escape backslashes
.replace(/"/g, '\\"'); // escape double quotes
}
// classic behavior
return value
.replace(/\\/g, '\\\\') // escape backslashes
.replace(/'/g, "\\\\'"); // escape single quotes
}
export function prometheusSpecialRegexEscape<T>(value: T) {
if (typeof value !== 'string') {
return value;
}
if (config.featureToggles.prometheusSpecialCharsInLabelValues) {
return value
.replace(/\\/g, '\\\\\\\\') // escape backslashes
.replace(/"/g, '\\\\\\"') // escape double quotes
.replace(/[$^*{}\[\]\'+?.()|]/g, '\\\\$&'); // escape regex metacharacters
}
// classic behavior
return value
.replace(/\\/g, '\\\\\\\\') // escape backslashes
.replace(/[$^*{}\[\]+?.()|]/g, '\\\\$&'); // escape regex metacharacters
}

@ -0,0 +1,72 @@
// NOTE: these two functions are similar to the escapeLabelValueIn* functions
// in language_utils.ts, but they are not exactly the same algorithm, and we found
import { config } from '@grafana/runtime';
// no way to reuse one in the another or vice versa.
export function prometheusRegularEscape<T>(value: T) {
if (typeof value !== 'string') {
return value;
}
if (config.featureToggles.prometheusSpecialCharsInLabelValues) {
// if the string looks like a complete label matcher (e.g. 'job="grafana"' or 'job=~"grafana"'),
// don't escape the encapsulating quotes
if (/^\w+(=|!=|=~|!~)".*"$/.test(value)) {
return value;
}
return value
.replace(/\\/g, '\\\\') // escape backslashes
.replace(/"/g, '\\"'); // escape double quotes
}
// classic behavior
return value
.replace(/\\/g, '\\\\') // escape backslashes
.replace(/'/g, "\\\\'"); // escape single quotes
}
export function prometheusSpecialRegexEscape<T>(value: T) {
if (typeof value !== 'string') {
return value;
}
if (config.featureToggles.prometheusSpecialCharsInLabelValues) {
return value
.replace(/\\/g, '\\\\\\\\') // escape backslashes
.replace(/"/g, '\\\\\\"') // escape double quotes
.replace(/[$^*{}\[\]\'+?.()|]/g, '\\\\$&'); // escape regex metacharacters
}
// classic behavior
return value
.replace(/\\/g, '\\\\\\\\') // escape backslashes
.replace(/[$^*{}\[\]+?.()|]/g, '\\\\$&'); // escape regex metacharacters
}
// NOTE: the following 2 exported functions are very similar to the prometheus*Escape
// functions in datasource.ts, but they are not exactly the same algorithm, and we found
// no way to reuse one in the another or vice versa.
// Prometheus regular-expressions use the RE2 syntax (https://github.com/google/re2/wiki/Syntax),
// so every character that matches something in that list has to be escaped.
// the list of metacharacters is: *+?()|\.[]{}^$
// we make a javascript regular expression that matches those characters:
const RE2_METACHARACTERS = /[*+?()|\\.\[\]{}^$]/g;
function escapePrometheusRegexp(value: string): string {
return value.replace(RE2_METACHARACTERS, '\\$&');
}
// based on the openmetrics-documentation, the 3 symbols we have to handle are:
// - \n ... the newline character
// - \ ... the backslash character
// - " ... the double-quote character
export function escapeLabelValueInExactSelector(labelValue: string): string {
return labelValue.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');
}
export function escapeLabelValueInRegexSelector(labelValue: string): string {
return escapeLabelValueInExactSelector(escapePrometheusRegexp(labelValue));
}

@ -18,13 +18,9 @@ export { PromVariableQueryEditor } from './components/VariableQueryEditor';
// CONFIGURATION/
// Main export
export {
ConfigEditor,
docsTip,
overhaulStyles,
validateInput,
PROM_CONFIG_LABEL_WIDTH,
} from './configuration/ConfigEditor';
export { ConfigEditor } from './configuration/ConfigEditor';
export { overhaulStyles, validateInput, docsTip } from './configuration/shared/utils';
export { PROM_CONFIG_LABEL_WIDTH } from './constants';
// The parts
export { AlertingSettingsOverhaul } from './configuration/AlertingSettingsOverhaul';
export { DataSourceHttpSettingsOverhaul } from './configuration/DataSourceHttpSettingsOverhaul';

@ -16,7 +16,7 @@ import {
} from '@grafana/data';
import { BackendSrvRequest } from '@grafana/runtime';
import { DEFAULT_SERIES_LIMIT, REMOVE_SERIES_LIMIT } from './components/metrics-browser/types';
import { REMOVE_SERIES_LIMIT, DEFAULT_SERIES_LIMIT } from './components/metrics-browser/types';
import { Label } from './components/monaco-query-field/monaco-completion-provider/situation';
import { PrometheusDatasource } from './datasource';
import {
@ -33,8 +33,6 @@ import { escapeForUtf8Support, isValidLegacyName } from './utf8_support';
const DEFAULT_KEYS = ['job', 'instance'];
const EMPTY_SELECTOR = '{}';
// Max number of items (metrics, labels, values) that we display as suggestions. Prevents from running out of memory.
export const SUGGESTIONS_LIMIT = 10000;
type UrlParamsType = {
start?: string;

@ -3,9 +3,8 @@ import { Moment } from 'moment';
import { AbstractLabelOperator, AbstractQuery, DateTime, dateTime, TimeRange } from '@grafana/data';
import { escapeLabelValueInExactSelector, escapeLabelValueInRegexSelector } from './escaping';
import {
escapeLabelValueInExactSelector,
escapeLabelValueInRegexSelector,
expandRecordingRules,
fixSummariesMetadata,
getPrometheusTime,

@ -14,11 +14,9 @@ import {
} from '@grafana/data';
import { addLabelToQuery } from './add_label_to_query';
import { SUGGESTIONS_LIMIT } from './language_provider';
import { SUGGESTIONS_LIMIT, PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from './constants';
import { PrometheusCacheLevel, PromMetricsMetadata, PromMetricsMetadataItem, RecordingRuleIdentifier } from './types';
export const PROMETHEUS_QUERY_BUILDER_MAX_RESULTS = 1000;
export const processHistogramMetrics = (metrics: string[]) => {
const resultSet: Set<string> = new Set();
const regexp = new RegExp('_bucket($|:)');
@ -352,32 +350,6 @@ export function addLimitInfo(items: unknown[] | undefined): string {
return items && items.length >= SUGGESTIONS_LIMIT ? `, limited to the first ${SUGGESTIONS_LIMIT} received items` : '';
}
// NOTE: the following 2 exported functions are very similar to the prometheus*Escape
// functions in datasource.ts, but they are not exactly the same algorithm, and we found
// no way to reuse one in the another or vice versa.
// Prometheus regular-expressions use the RE2 syntax (https://github.com/google/re2/wiki/Syntax),
// so every character that matches something in that list has to be escaped.
// the list of metacharacters is: *+?()|\.[]{}^$
// we make a javascript regular expression that matches those characters:
const RE2_METACHARACTERS = /[*+?()|\\.\[\]{}^$]/g;
function escapePrometheusRegexp(value: string): string {
return value.replace(RE2_METACHARACTERS, '\\$&');
}
// based on the openmetrics-documentation, the 3 symbols we have to handle are:
// - \n ... the newline character
// - \ ... the backslash character
// - " ... the double-quote character
export function escapeLabelValueInExactSelector(labelValue: string): string {
return labelValue.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');
}
export function escapeLabelValueInRegexSelector(labelValue: string): string {
return escapeLabelValueInExactSelector(escapePrometheusRegexp(labelValue));
}
const FromPromLikeMap: Record<string, AbstractLabelOperator> = {
'=': AbstractLabelOperator.Equal,
'!=': AbstractLabelOperator.NotEqual,

@ -1,6 +1,6 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/migrations/variableMigration.ts
import { promQueryModeller } from '../querybuilder/PromQueryModeller';
import { buildVisualQueryFromString } from '../querybuilder/parsing';
import { promQueryModeller } from '../querybuilder/shared/modeller_instance';
import { PromVariableQuery, PromVariableQueryType as QueryType } from '../types';
export const PrometheusLabelNamesRegex = /^label_names\(\)\s*$/;
@ -107,7 +107,7 @@ export function migrateVariableEditorBackToVariableSupport(QueryVariable: PromVa
case QueryType.LabelValues:
if (QueryVariable.metric || (QueryVariable.labelFilters && QueryVariable.labelFilters.length !== 0)) {
const visualQueryQuery = {
metric: QueryVariable.metric,
metric: QueryVariable.metric ?? '',
labels: QueryVariable.labelFilters ?? [],
operations: [],
};

@ -4,9 +4,14 @@ import { FUNCTIONS } from '../promql';
import { getAggregationOperations } from './aggregations';
import { getOperationDefinitions } from './operations';
import { LokiAndPromQueryModellerBase } from './shared/LokiAndPromQueryModellerBase';
import { PromQueryPattern, PromQueryPatternType, PromVisualQueryOperationCategory } from './types';
import {
PromQueryPattern,
PromQueryPatternType,
PromVisualQueryOperationCategory,
PromQueryModellerInterface,
} from './types';
export class PromQueryModeller extends LokiAndPromQueryModellerBase {
export class PromQueryModeller extends LokiAndPromQueryModellerBase implements PromQueryModellerInterface {
constructor() {
super(() => {
const allOperations = [...getOperationDefinitions(), ...getAggregationOperations()];
@ -90,5 +95,3 @@ export class PromQueryModeller extends LokiAndPromQueryModellerBase {
];
}
}
export const promQueryModeller = new PromQueryModeller();

@ -7,8 +7,8 @@ import { Button, Card, useStyles2 } from '@grafana/ui';
import promqlGrammar from '../promql';
import { promQueryModeller } from './PromQueryModeller';
import { RawQuery } from './shared/RawQuery';
import { promQueryModeller } from './shared/modeller_instance';
import { PromQueryPattern } from './types';
type Props = {
@ -36,6 +36,7 @@ export const QueryPattern = (props: Props) => {
patternName: pattern.name,
})}
query={promQueryModeller.renderQuery({
metric: '',
labels: [],
operations: pattern.operations,
binaryQueries: pattern.binaryQueries,

@ -2,8 +2,8 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { promQueryModeller } from './PromQueryModeller';
import { QueryPatternsModal } from './QueryPatternsModal';
import { promQueryModeller } from './shared/modeller_instance';
import { PromQueryPatternType } from './types';
// don't care about interaction tracking in our unit tests

@ -10,9 +10,9 @@ import { Button, Collapse, Modal, useStyles2 } from '@grafana/ui';
import { PromQuery } from '../types';
import { promQueryModeller } from './PromQueryModeller';
import { QueryPattern } from './QueryPattern';
import { buildVisualQueryFromString } from './parsing';
import { promQueryModeller } from './shared/modeller_instance';
import { PromQueryPattern, PromQueryPatternType } from './types';
type Props = {
@ -54,18 +54,21 @@ export const QueryPatternsModal = (props: Props) => {
createNewQuery: hasNewQueryOption && selectAsNewQuery,
});
// Apply the pattern operations before rendering the expression
visualQuery.query.operations = pattern.operations;
visualQuery.query.binaryQueries = pattern.binaryQueries;
const renderedExpr = promQueryModeller.renderQuery(visualQuery.query);
if (hasNewQueryOption && selectAsNewQuery) {
onAddQuery({
...query,
refId: getNextRefId(queries ?? [query]),
expr: promQueryModeller.renderQuery(visualQuery.query),
expr: renderedExpr,
});
} else {
onChange({
...query,
expr: promQueryModeller.renderQuery(visualQuery.query),
expr: renderedExpr,
});
}
setSelectedPatternName(null);

@ -4,11 +4,22 @@ import { useState } from 'react';
import { DataSourceApi, SelectableValue, TimeRange, toOption } from '@grafana/data';
import { Select } from '@grafana/ui';
import { promQueryModeller } from '../PromQueryModeller';
import { getOperationParamId } from '../operationUtils';
import { getOperationParamId } from '../shared/param_utils';
import { QueryBuilderLabelFilter, QueryBuilderOperationParamEditorProps } from '../shared/types';
import { PromVisualQuery } from '../types';
import { PromVisualQuery, PromQueryModellerInterface } from '../types';
/**
* Props for the LabelParamEditor component.
* This editor specifically requires a Prometheus query modeller instance.
*/
export interface LabelParamEditorProps extends Omit<QueryBuilderOperationParamEditorProps, 'queryModeller'> {
queryModeller: PromQueryModellerInterface;
}
/**
* Editor for label parameters that requires a Prometheus query modeller instance.
* This is used by the OperationParamEditorWrapper which ensures the modeller is always provided.
*/
export function LabelParamEditor({
onChange,
index,
@ -17,7 +28,8 @@ export function LabelParamEditor({
query,
datasource,
timeRange,
}: QueryBuilderOperationParamEditorProps) {
queryModeller,
}: LabelParamEditorProps) {
const [state, setState] = useState<{
options?: SelectableValue[];
isLoading?: boolean;
@ -30,7 +42,7 @@ export function LabelParamEditor({
openMenuOnFocus
onOpenMenu={async () => {
setState({ isLoading: true });
const options = await loadGroupByLabels(timeRange, query, datasource);
const options = await loadGroupByLabels(timeRange, query, datasource, queryModeller);
setState({ options, isLoading: undefined });
}}
isLoading={state.isLoading}
@ -47,7 +59,8 @@ export function LabelParamEditor({
async function loadGroupByLabels(
timeRange: TimeRange,
query: PromVisualQuery,
datasource: DataSourceApi
datasource: DataSourceApi,
modeller: PromQueryModellerInterface
): Promise<SelectableValue[]> {
let labels: QueryBuilderLabelFilter[] = query.labels;
@ -57,7 +70,7 @@ async function loadGroupByLabels(
labels = [{ label: '__name__', op: '=', value: query.metric }, ...query.labels];
}
const expr = promQueryModeller.renderLabels(labels);
const expr = modeller.renderLabels(labels);
const result = await datasource.languageProvider.fetchLabelsWithMatch(timeRange, expr);
return Object.keys(result).map((x) => ({

@ -11,7 +11,7 @@ import { regexifyLabelValuesQueryString } from '../parsingUtils';
import { QueryBuilderLabelFilter } from '../shared/types';
import { PromVisualQuery } from '../types';
import { MetricsModal } from './metrics-modal';
import { MetricsModal } from './metrics-modal/MetricsModal';
import { tracking } from './metrics-modal/state/helpers';
export interface MetricComboboxProps {

@ -6,8 +6,8 @@ import { SelectableValue, TimeRange } from '@grafana/data';
import { PrometheusDatasource } from '../../datasource';
import { getMetadataString } from '../../language_provider';
import { truncateResult } from '../../language_utils';
import { promQueryModeller } from '../PromQueryModeller';
import { regexifyLabelValuesQueryString } from '../parsingUtils';
import { promQueryModeller } from '../shared/modeller_instance';
import { QueryBuilderLabelFilter } from '../shared/types';
import { PromVisualQuery } from '../types';

@ -11,7 +11,7 @@ import { PrometheusDatasource } from '../../datasource';
import { binaryScalarDefs } from '../binaryScalarOperations';
import { PromVisualQueryBinary } from '../types';
import { PromQueryBuilder } from './PromQueryBuilder';
import { QueryBuilderContent } from './shared/QueryBuilderContent';
export interface NestedQueryProps {
nestedQuery: PromVisualQueryBinary;
@ -86,7 +86,7 @@ export const NestedQuery = memo<NestedQueryProps>((props) => {
</div>
<div className={styles.body}>
<EditorRows>
<PromQueryBuilder
<QueryBuilderContent
showExplain={showExplain}
query={nestedQuery.query}
datasource={datasource}

@ -0,0 +1,52 @@
import { ComponentType } from 'react';
import { promQueryModeller } from '../shared/modeller_instance';
import { QueryBuilderOperationParamEditorProps } from '../shared/types';
import { PromQueryModellerInterface } from '../types';
import { LabelParamEditor } from './LabelParamEditor';
/**
* Maps string keys to editor components with the modeller instance injected.
*
* This wrapper is a key part of avoiding circular dependencies:
* - Operation definitions reference editors by string key (no import)
* - The registry maps these keys to editor components
* - This wrapper injects the modeller instance into those components
*
* This creates a clear one-way dependency flow:
* Operation Definitions -> Registry -> Editor Components <- Wrapper <- Modeller Instance
*
* Without this wrapper, we would have a circular dependency:
* Operation Definitions -> Editors -> Modeller -> Operation Definitions
*/
const editorMap: Record<
string,
ComponentType<QueryBuilderOperationParamEditorProps & { queryModeller: PromQueryModellerInterface }>
> = {
LabelParamEditor: (props) => <LabelParamEditor {...props} queryModeller={promQueryModeller} />,
};
/**
* Wrapper component that resolves and renders the appropriate editor component.
*
* This component:
* 1. Takes a parameter definition that may specify an editor by string key or direct reference
* 2. Resolves the editor component from the map if a string key is used
* 3. Renders the editor with all necessary props, including the modeller instance
*
* This separation of concerns allows operation definitions to be simpler while ensuring
* editors have access to all the dependencies they need, without creating circular dependencies.
*/
export function OperationParamEditorWrapper(props: QueryBuilderOperationParamEditorProps) {
const { paramDef } = props;
const EditorComponent = typeof paramDef.editor === 'string' ? editorMap[paramDef.editor] : paramDef.editor;
if (!EditorComponent) {
return null;
}
// Type assertion is safe here because we know the editorMap only contains components
// that require the modeller instance
return <EditorComponent {...props} queryModeller={promQueryModeller} />;
}

@ -1,28 +1,12 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx
import { css } from '@emotion/css';
import { memo, useState } from 'react';
import { memo } from 'react';
import { DataSourceApi, getDefaultTimeRange, PanelData } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { EditorRow } from '@grafana/plugin-ui';
import { PanelData } from '@grafana/data';
import { PrometheusDatasource } from '../../datasource';
import promqlGrammar from '../../promql';
import { getInitHints } from '../../query_hints';
import { promQueryModeller } from '../PromQueryModeller';
import { buildVisualQueryFromString } from '../parsing';
import { OperationExplainedBox } from '../shared/OperationExplainedBox';
import { OperationList } from '../shared/OperationList';
import { OperationListExplained } from '../shared/OperationListExplained';
import { OperationsEditorRow } from '../shared/OperationsEditorRow';
import { QueryBuilderHints } from '../shared/QueryBuilderHints';
import { RawQuery } from '../shared/RawQuery';
import { QueryBuilderOperation } from '../shared/types';
import { PromVisualQuery } from '../types';
import { MetricsLabelsSection } from './MetricsLabelsSection';
import { NestedQueryList } from './NestedQueryList';
import { EXPLAIN_LABEL_FILTER_CONTENT } from './PromQueryBuilderExplained';
import { BaseQueryBuilder } from './shared/BaseQueryBuilder';
export interface PromQueryBuilderProps {
query: PromVisualQuery;
@ -34,90 +18,7 @@ export interface PromQueryBuilderProps {
}
export const PromQueryBuilder = memo<PromQueryBuilderProps>((props) => {
const { datasource, query, onChange, onRunQuery, data, showExplain } = props;
const [highlightedOp, setHighlightedOp] = useState<QueryBuilderOperation | undefined>();
const lang = { grammar: promqlGrammar, name: 'promql' };
const initHints = getInitHints(datasource);
return (
<>
<EditorRow>
<MetricsLabelsSection
query={query}
onChange={onChange}
datasource={datasource}
timeRange={data?.timeRange ?? getDefaultTimeRange()}
/>
</EditorRow>
{initHints.length ? (
<div
className={css({
flexBasis: '100%',
})}
>
<div className="text-warning">
{initHints[0].label}{' '}
{initHints[0].fix ? (
<button type="button" className={'text-warning'}>
{initHints[0].fix.label}
</button>
) : null}
</div>
</div>
) : null}
{showExplain && (
<OperationExplainedBox
stepNumber={1}
title={<RawQuery query={`${promQueryModeller.renderQuery(query)}`} lang={lang} />}
>
{EXPLAIN_LABEL_FILTER_CONTENT}
</OperationExplainedBox>
)}
<OperationsEditorRow>
<OperationList<PromVisualQuery>
queryModeller={promQueryModeller}
// eslint-ignore
datasource={datasource as DataSourceApi}
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
highlightedOp={highlightedOp}
timeRange={data?.timeRange ?? getDefaultTimeRange()}
/>
<div data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.hints}>
<QueryBuilderHints<PromVisualQuery>
datasource={datasource}
query={query}
onChange={onChange}
data={data}
queryModeller={promQueryModeller}
buildVisualQueryFromString={buildVisualQueryFromString}
/>
</div>
</OperationsEditorRow>
{showExplain && (
<OperationListExplained<PromVisualQuery>
lang={lang}
query={query}
stepNumber={2}
queryModeller={promQueryModeller}
onMouseEnter={(op) => setHighlightedOp(op)}
onMouseLeave={() => setHighlightedOp(undefined)}
/>
)}
{query.binaryQueries && query.binaryQueries.length > 0 && (
<NestedQueryList
query={query}
datasource={datasource}
onChange={onChange}
onRunQuery={onRunQuery}
showExplain={showExplain}
/>
)}
</>
);
return <BaseQueryBuilder {...props} />;
});
PromQueryBuilder.displayName = 'PromQueryBuilder';

@ -8,7 +8,7 @@ import { PrometheusDatasource } from '../../datasource';
import PromQlLanguageProvider from '../../language_provider';
import { EmptyLanguageProviderMock } from '../../language_provider.mock';
import { PromQuery } from '../../types';
import { getOperationParamId } from '../operationUtils';
import { getOperationParamId } from '../shared/param_utils';
import { addOperationInQueryBuilder } from '../testUtils';
import { PromQueryBuilderContainer } from './PromQueryBuilderContainer';

@ -6,8 +6,8 @@ import { PanelData } from '@grafana/data';
import { PrometheusDatasource } from '../../datasource';
import { PromQuery } from '../../types';
import { promQueryModeller } from '../PromQueryModeller';
import { buildVisualQueryFromString } from '../parsing';
import { promQueryModeller } from '../shared/modeller_instance';
import { PromVisualQuery } from '../types';
import { PromQueryBuilder } from './PromQueryBuilder';

@ -4,11 +4,11 @@ import { memo } from 'react';
import { Stack } from '@grafana/ui';
import promqlGrammar from '../../promql';
import { promQueryModeller } from '../PromQueryModeller';
import { buildVisualQueryFromString } from '../parsing';
import { OperationExplainedBox } from '../shared/OperationExplainedBox';
import { OperationListExplained } from '../shared/OperationListExplained';
import { RawQuery } from '../shared/RawQuery';
import { promQueryModeller } from '../shared/modeller_instance';
import { PromVisualQuery } from '../types';
export const EXPLAIN_LABEL_FILTER_CONTENT = 'Fetch all series matching metric name and label filters.';

@ -1,4 +1,5 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx
import { map } from 'lodash';
import { SyntheticEvent } from 'react';
import * as React from 'react';
@ -13,7 +14,6 @@ import { PromQueryFormat } from '../../dataquery';
import { PromQuery } from '../../types';
import { QueryOptionGroup } from '../shared/QueryOptionGroup';
import { FORMAT_OPTIONS, INTERVAL_FACTOR_OPTIONS } from './PromQueryEditorSelector';
import { getLegendModeLabel, PromQueryLegendEditor } from './PromQueryLegendEditor';
export interface UIOptions {
@ -32,6 +32,17 @@ export interface PromQueryBuilderOptionsProps {
onRunQuery: () => void;
}
const FORMAT_OPTIONS: Array<SelectableValue<PromQueryFormat>> = [
{ label: 'Time series', value: 'time_series' },
{ label: 'Table', value: 'table' },
{ label: 'Heatmap', value: 'heatmap' },
];
const INTERVAL_FACTOR_OPTIONS: Array<SelectableValue<number>> = map([1, 2, 3, 4, 5, 10], (value: number) => ({
value,
label: '1/' + value,
}));
export const PromQueryBuilderOptions = React.memo<PromQueryBuilderOptionsProps>(
({ query, app, onChange, onRunQuery }) => {
const onChangeFormat = (value: SelectableValue<PromQueryFormat>) => {

@ -1,8 +1,8 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx
import { isEqual, map } from 'lodash';
import { isEqual } from 'lodash';
import { memo, SyntheticEvent, useCallback, useEffect, useState } from 'react';
import { CoreApp, LoadingState, SelectableValue } from '@grafana/data';
import { CoreApp, LoadingState } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { EditorHeader, EditorRows, FlexItem } from '@grafana/plugin-ui';
@ -10,7 +10,6 @@ import { reportInteraction } from '@grafana/runtime';
import { Button, ConfirmModal, Space } from '@grafana/ui';
import { PromQueryEditorProps } from '../../components/types';
import { PromQueryFormat } from '../../dataquery';
import { PromQuery } from '../../types';
import { QueryPatternsModal } from '../QueryPatternsModal';
import { promQueryEditorExplainKey, useFlag } from '../hooks/useFlag';
@ -25,17 +24,6 @@ import { PromQueryBuilderOptions } from './PromQueryBuilderOptions';
import { PromQueryCodeEditor } from './PromQueryCodeEditor';
import { PromQueryCodeEditorAutocompleteInfo } from './PromQueryCodeEditorAutocompleteInfo';
export const FORMAT_OPTIONS: Array<SelectableValue<PromQueryFormat>> = [
{ label: 'Time series', value: 'time_series' },
{ label: 'Table', value: 'table' },
{ label: 'Heatmap', value: 'heatmap' },
];
export const INTERVAL_FACTOR_OPTIONS: Array<SelectableValue<number>> = map([1, 2, 3, 4, 5, 10], (value: number) => ({
value,
label: '1/' + value,
}));
type Props = PromQueryEditorProps;
export const PromQueryEditorSelector = memo<Props>((props) => {

@ -5,17 +5,9 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Icon, Switch, Tooltip, useTheme2 } from '@grafana/ui';
import { metricsModaltestIds } from './MetricsModal';
import { metricsModaltestIds } from './shared/testIds';
import { AdditionalSettingsProps } from './shared/types';
import { placeholders } from './state/helpers';
import { MetricsModalState } from './state/state';
type AdditionalSettingsProps = {
state: MetricsModalState;
onChangeFullMetaSearch: () => void;
onChangeIncludeNullMetadata: () => void;
onChangeDisableTextWrap: () => void;
onChangeUseBackend: () => void;
};
export function AdditionalSettings(props: AdditionalSettingsProps) {
const { state, onChangeFullMetaSearch, onChangeIncludeNullMetadata, onChangeDisableTextWrap, onChangeUseBackend } =

@ -1,6 +1,5 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx
import { cx } from '@emotion/css';
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import debounce from 'debounce-promise';
import { useCallback, useEffect, useMemo, useReducer } from 'react';
@ -20,12 +19,10 @@ import {
useTheme2,
} from '@grafana/ui';
import { PrometheusDatasource } from '../../../datasource';
import { PromVisualQuery } from '../../types';
import { AdditionalSettings } from './AdditionalSettings';
import { FeedbackLink } from './FeedbackLink';
import { ResultsTable } from './ResultsTable';
import { MetricsModalProps } from './shared/types';
import {
calculatePageList,
calculateResultsPerPage,
@ -41,21 +38,12 @@ import {
initialState,
MAXIMUM_RESULTS_PER_PAGE,
MetricsModalMetadata,
// stateSlice,
stateSlice,
} from './state/state';
import { getStyles } from './styles';
import { MetricsData, PromFilterOption } from './types';
import { PromFilterOption } from './types';
import { debouncedFuzzySearch } from './uFuzzy';
export type MetricsModalProps = {
datasource: PrometheusDatasource;
isOpen: boolean;
query: PromVisualQuery;
onClose: () => void;
onChange: (query: PromVisualQuery) => void;
initialMetrics: string[] | (() => Promise<string[]>);
};
export const MetricsModal = (props: MetricsModalProps) => {
const { datasource, isOpen, onClose, onChange, query, initialMetrics } = props;
@ -341,82 +329,6 @@ export const metricsModaltestIds = {
setUseBackend: 'set-use-backend',
showAdditionalSettings: 'show-additional-settings',
};
const stateSlice = createSlice({
name: 'metrics-modal-state',
initialState: initialState(),
reducers: {
filterMetricsBackend: (
state,
action: PayloadAction<{
metrics: MetricsData;
filteredMetricCount: number;
isLoading: boolean;
}>
) => {
state.metrics = action.payload.metrics;
state.filteredMetricCount = action.payload.filteredMetricCount;
state.isLoading = action.payload.isLoading;
},
buildMetrics: (state, action: PayloadAction<MetricsModalMetadata>) => {
state.isLoading = action.payload.isLoading;
state.metrics = action.payload.metrics;
state.hasMetadata = action.payload.hasMetadata;
state.metaHaystackDictionary = action.payload.metaHaystackDictionary;
state.nameHaystackDictionary = action.payload.nameHaystackDictionary;
state.totalMetricCount = action.payload.totalMetricCount;
state.filteredMetricCount = action.payload.filteredMetricCount;
},
setIsLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
setFilteredMetricCount: (state, action: PayloadAction<number>) => {
state.filteredMetricCount = action.payload;
},
setResultsPerPage: (state, action: PayloadAction<number>) => {
state.resultsPerPage = action.payload;
},
setPageNum: (state, action: PayloadAction<number>) => {
state.pageNum = action.payload;
},
setFuzzySearchQuery: (state, action: PayloadAction<string>) => {
state.fuzzySearchQuery = action.payload;
state.pageNum = 1;
},
setNameHaystack: (state, action: PayloadAction<string[][]>) => {
state.nameHaystackOrder = action.payload[0];
state.nameHaystackMatches = action.payload[1];
},
setMetaHaystack: (state, action: PayloadAction<string[][]>) => {
state.metaHaystackOrder = action.payload[0];
state.metaHaystackMatches = action.payload[1];
},
setFullMetaSearch: (state, action: PayloadAction<boolean>) => {
state.fullMetaSearch = action.payload;
state.pageNum = 1;
},
setIncludeNullMetadata: (state, action: PayloadAction<boolean>) => {
state.includeNullMetadata = action.payload;
state.pageNum = 1;
},
setSelectedTypes: (state, action: PayloadAction<Array<SelectableValue<string>>>) => {
state.selectedTypes = action.payload;
state.pageNum = 1;
},
setUseBackend: (state, action: PayloadAction<boolean>) => {
state.useBackend = action.payload;
state.fullMetaSearch = false;
state.pageNum = 1;
},
setDisableTextWrap: (state) => {
state.disableTextWrap = !state.disableTextWrap;
},
showAdditionalSettings: (state) => {
state.showAdditionalSettings = !state.showAdditionalSettings;
},
},
});
// actions to update the state
export const {
setIsLoading,

@ -7,7 +7,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { Button, Icon, Tooltip, useTheme2 } from '@grafana/ui';
import { docsTip } from '../../../configuration/ConfigEditor';
import { docsTip } from '../../../configuration/shared/utils';
import { PromVisualQuery } from '../../types';
import { tracking } from './state/helpers';

@ -0,0 +1,3 @@
import { createAction } from '@reduxjs/toolkit';
export const setFilteredMetricCount = createAction<number>('metrics-modal/setFilteredMetricCount');

@ -0,0 +1,17 @@
import { SelectableValue } from '@grafana/data';
import { MetricsData } from '../types';
export interface MetricsModalStateModel {
isLoading: boolean;
metrics: MetricsData;
hasMetadata: boolean;
selectedTypes: Array<SelectableValue<string>>;
}
export const initialState = (query: unknown): MetricsModalStateModel => ({
isLoading: true,
metrics: [],
hasMetadata: false,
selectedTypes: [],
});

@ -0,0 +1,12 @@
export const metricsModaltestIds = {
metricModal: 'metric-modal',
searchMetric: 'search-metric',
searchWithMetadata: 'search-with-metadata',
selectType: 'select-type',
metricCard: 'metric-card',
useMetric: 'use-metric',
searchPage: 'search-page',
resultsPerPage: 'results-per-page',
setUseBackend: 'set-use-backend',
showAdditionalSettings: 'show-additional-settings',
};

@ -0,0 +1,27 @@
import { PrometheusDatasource } from '../../../../datasource';
import { PromVisualQuery } from '../../../types';
export interface MetricsModalState {
useBackend: boolean;
disableTextWrap: boolean;
includeNullMetadata: boolean;
fullMetaSearch: boolean;
hasMetadata: boolean;
}
export interface MetricsModalProps {
datasource: PrometheusDatasource;
isOpen: boolean;
query: PromVisualQuery;
onClose: () => void;
onChange: (query: PromVisualQuery) => void;
initialMetrics: string[] | (() => Promise<string[]>);
}
export interface AdditionalSettingsProps {
state: MetricsModalState;
onChangeFullMetaSearch: () => void;
onChangeIncludeNullMetadata: () => void;
onChangeDisableTextWrap: () => void;
onChangeUseBackend: () => void;
}

@ -8,12 +8,9 @@ import { getMetadataHelp, getMetadataType } from '../../../../language_provider'
import { regexifyLabelValuesQueryString } from '../../../parsingUtils';
import { QueryBuilderLabelFilter } from '../../../shared/types';
import { PromVisualQuery } from '../../../types';
import { setFilteredMetricCount } from '../MetricsModal';
import { HaystackDictionary, MetricData, MetricsData, PromFilterOption } from '../types';
import { MetricsModalMetadata, MetricsModalState } from './state';
// const { setFilteredMetricCount } = stateSlice.actions;
import { MetricsModalMetadata, MetricsModalState, setFilteredMetricCount } from './state';
export async function setMetrics(
datasource: PrometheusDatasource,

@ -1,4 +1,6 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/state.ts
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { SelectableValue } from '@grafana/data';
import { PromVisualQuery } from '../../../types';
@ -115,3 +117,96 @@ export type MetricsModalSettings = {
fullMetaSearch?: boolean;
includeNullMetadata?: boolean;
};
export const stateSlice = createSlice({
name: 'metrics-modal-state',
initialState: initialState(),
reducers: {
filterMetricsBackend: (
state,
action: PayloadAction<{
metrics: MetricsData;
filteredMetricCount: number;
isLoading: boolean;
}>
) => {
state.metrics = action.payload.metrics;
state.filteredMetricCount = action.payload.filteredMetricCount;
state.isLoading = action.payload.isLoading;
},
buildMetrics: (state, action: PayloadAction<MetricsModalMetadata>) => {
state.isLoading = action.payload.isLoading;
state.metrics = action.payload.metrics;
state.hasMetadata = action.payload.hasMetadata;
state.metaHaystackDictionary = action.payload.metaHaystackDictionary;
state.nameHaystackDictionary = action.payload.nameHaystackDictionary;
state.totalMetricCount = action.payload.totalMetricCount;
state.filteredMetricCount = action.payload.filteredMetricCount;
},
setIsLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
setFilteredMetricCount: (state, action: PayloadAction<number>) => {
state.filteredMetricCount = action.payload;
},
setResultsPerPage: (state, action: PayloadAction<number>) => {
state.resultsPerPage = action.payload;
},
setPageNum: (state, action: PayloadAction<number>) => {
state.pageNum = action.payload;
},
setFuzzySearchQuery: (state, action: PayloadAction<string>) => {
state.fuzzySearchQuery = action.payload;
state.pageNum = 1;
},
setNameHaystack: (state, action: PayloadAction<string[][]>) => {
state.nameHaystackOrder = action.payload[0];
state.nameHaystackMatches = action.payload[1];
},
setMetaHaystack: (state, action: PayloadAction<string[][]>) => {
state.metaHaystackOrder = action.payload[0];
state.metaHaystackMatches = action.payload[1];
},
setFullMetaSearch: (state, action: PayloadAction<boolean>) => {
state.fullMetaSearch = action.payload;
state.pageNum = 1;
},
setIncludeNullMetadata: (state, action: PayloadAction<boolean>) => {
state.includeNullMetadata = action.payload;
state.pageNum = 1;
},
setSelectedTypes: (state, action: PayloadAction<Array<SelectableValue<string>>>) => {
state.selectedTypes = action.payload;
state.pageNum = 1;
},
setUseBackend: (state, action: PayloadAction<boolean>) => {
state.useBackend = action.payload;
state.fullMetaSearch = false;
state.pageNum = 1;
},
setDisableTextWrap: (state) => {
state.disableTextWrap = !state.disableTextWrap;
},
showAdditionalSettings: (state) => {
state.showAdditionalSettings = !state.showAdditionalSettings;
},
},
});
export const {
setIsLoading,
buildMetrics,
filterMetricsBackend,
setResultsPerPage,
setPageNum,
setFuzzySearchQuery,
setNameHaystack,
setMetaHaystack,
setFullMetaSearch,
setIncludeNullMetadata,
setSelectedTypes,
setUseBackend,
setDisableTextWrap,
showAdditionalSettings,
setFilteredMetricCount,
} = stateSlice.actions;

@ -0,0 +1,27 @@
import { memo } from 'react';
import { NestedQueryList } from '../NestedQueryList';
import { BaseQueryBuilderProps } from './BaseQueryBuilderProps';
import { QueryBuilderContent } from './QueryBuilderContent';
export const BaseQueryBuilder = memo<BaseQueryBuilderProps>((props) => {
const { query, datasource, onChange, onRunQuery, showExplain } = props;
return (
<>
<QueryBuilderContent {...props} />
{query.binaryQueries && query.binaryQueries.length > 0 && (
<NestedQueryList
query={query}
datasource={datasource}
onChange={onChange}
onRunQuery={onRunQuery}
showExplain={showExplain}
/>
)}
</>
);
});
BaseQueryBuilder.displayName = 'BaseQueryBuilder';

@ -0,0 +1,13 @@
import { PanelData } from '@grafana/data';
import { PrometheusDatasource } from '../../../datasource';
import { PromVisualQuery } from '../../types';
export interface BaseQueryBuilderProps {
query: PromVisualQuery;
datasource: PrometheusDatasource;
onChange: (update: PromVisualQuery) => void;
onRunQuery: () => void;
data?: PanelData;
showExplain: boolean;
}

@ -0,0 +1,102 @@
import { css } from '@emotion/css';
import { memo, useState } from 'react';
import { DataSourceApi, getDefaultTimeRange } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { EditorRow } from '@grafana/plugin-ui';
import promqlGrammar from '../../../promql';
import { getInitHints } from '../../../query_hints';
import { buildVisualQueryFromString } from '../../parsing';
import { OperationExplainedBox } from '../../shared/OperationExplainedBox';
import { OperationList } from '../../shared/OperationList';
import { OperationListExplained } from '../../shared/OperationListExplained';
import { OperationsEditorRow } from '../../shared/OperationsEditorRow';
import { QueryBuilderHints } from '../../shared/QueryBuilderHints';
import { RawQuery } from '../../shared/RawQuery';
import { promQueryModeller } from '../../shared/modeller_instance';
import { QueryBuilderOperation } from '../../shared/types';
import { PromVisualQuery } from '../../types';
import { MetricsLabelsSection } from '../MetricsLabelsSection';
import { EXPLAIN_LABEL_FILTER_CONTENT } from '../PromQueryBuilderExplained';
import { BaseQueryBuilderProps } from './BaseQueryBuilderProps';
export const QueryBuilderContent = memo<BaseQueryBuilderProps>((props) => {
const { datasource, query, onChange, onRunQuery, data, showExplain } = props;
const [highlightedOp, setHighlightedOp] = useState<QueryBuilderOperation | undefined>();
const lang = { grammar: promqlGrammar, name: 'promql' };
const initHints = getInitHints(datasource);
return (
<>
<EditorRow>
<MetricsLabelsSection
query={query}
onChange={onChange}
datasource={datasource}
timeRange={data?.timeRange ?? getDefaultTimeRange()}
/>
</EditorRow>
{initHints.length ? (
<div
className={css({
flexBasis: '100%',
})}
>
<div className="text-warning">
{initHints[0].label}{' '}
{initHints[0].fix ? (
<button type="button" className={'text-warning'}>
{initHints[0].fix.label}
</button>
) : null}
</div>
</div>
) : null}
{showExplain && (
<OperationExplainedBox
stepNumber={1}
title={<RawQuery query={`${promQueryModeller.renderQuery(query)}`} lang={lang} />}
>
{EXPLAIN_LABEL_FILTER_CONTENT}
</OperationExplainedBox>
)}
<OperationsEditorRow>
<OperationList<PromVisualQuery>
queryModeller={promQueryModeller}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
datasource={datasource as DataSourceApi}
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
highlightedOp={highlightedOp}
timeRange={data?.timeRange ?? getDefaultTimeRange()}
/>
<div data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.hints}>
<QueryBuilderHints
datasource={datasource}
query={query}
onChange={onChange}
data={data}
queryModeller={promQueryModeller}
buildVisualQueryFromString={buildVisualQueryFromString}
/>
</div>
</OperationsEditorRow>
{showExplain && (
<OperationListExplained<PromVisualQuery>
lang={lang}
query={query}
stepNumber={2}
queryModeller={promQueryModeller}
onMouseEnter={(op: QueryBuilderOperation) => setHighlightedOp(op)}
onMouseLeave={() => setHighlightedOp(undefined)}
/>
)}
</>
);
});
QueryBuilderContent.displayName = 'QueryBuilderContent';

@ -0,0 +1,21 @@
import { DataSourceApi, PanelData } from '@grafana/data';
import { PrometheusDatasource } from '../../../datasource';
import { PromVisualQuery } from '../../types';
export interface NestedQueryProps {
query: PromVisualQuery;
datasource: DataSourceApi;
onChange: (query: PromVisualQuery) => void;
onRunQuery: () => void;
showExplain: boolean;
}
export interface QueryBuilderProps {
query: PromVisualQuery;
datasource: PrometheusDatasource;
onChange: (query: PromVisualQuery) => void;
onRunQuery: () => void;
data?: PanelData;
showExplain: boolean;
}

@ -0,0 +1,98 @@
import { capitalize } from 'lodash';
import { SelectableValue } from '@grafana/data';
import {
functionRendererLeft,
getOnLabelAddedHandler,
getAggregationExplainer,
defaultAddOperationHandler,
getAggregationByRenderer,
getLastLabelRemovedHandler,
} from './operationUtils';
import { QueryBuilderOperationDef, QueryBuilderOperationParamDef } from './shared/types';
import { PromVisualQueryOperationCategory } from './types';
export function getRangeVectorParamDef(withRateInterval = false): QueryBuilderOperationParamDef {
const options: Array<SelectableValue<string>> = [
{
label: '$__interval',
value: '$__interval',
},
{ label: '1m', value: '1m' },
{ label: '5m', value: '5m' },
{ label: '10m', value: '10m' },
{ label: '1h', value: '1h' },
{ label: '24h', value: '24h' },
];
if (withRateInterval) {
options.unshift({
label: '$__rate_interval',
value: '$__rate_interval',
});
}
const param: QueryBuilderOperationParamDef = {
name: 'Range',
type: 'string',
options,
};
return param;
}
export function createAggregationOperation(
name: string,
overrides: Partial<QueryBuilderOperationDef> = {}
): QueryBuilderOperationDef[] {
const operations: QueryBuilderOperationDef[] = [
{
id: name,
name: getPromOperationDisplayName(name),
params: [
{
name: 'By label',
type: 'string',
restParam: true,
optional: true,
},
],
defaultParams: [],
alternativesKey: 'plain aggregations',
category: PromVisualQueryOperationCategory.Aggregations,
renderer: functionRendererLeft,
paramChangedHandler: getOnLabelAddedHandler(`__${name}_by`),
explainHandler: getAggregationExplainer(name, ''),
addOperationHandler: defaultAddOperationHandler,
...overrides,
},
{
id: `__${name}_by`,
name: `${getPromOperationDisplayName(name)} by`,
params: [
{
name: 'Label',
type: 'string',
restParam: true,
optional: true,
},
],
defaultParams: [''],
alternativesKey: 'aggregations by',
category: PromVisualQueryOperationCategory.Aggregations,
renderer: getAggregationByRenderer(name),
paramChangedHandler: getLastLabelRemovedHandler(name),
explainHandler: getAggregationExplainer(name, 'by'),
addOperationHandler: defaultAddOperationHandler,
hideFromList: true,
...overrides,
},
];
return operations;
}
function getPromOperationDisplayName(funcName: string) {
return capitalize(funcName.replace(/_/g, ' '));
}

@ -2,9 +2,9 @@
import {
createAggregationOperation,
createAggregationOperationWithParam,
getOperationParamId,
isConflictingSelector,
} from './operationUtils';
import { getOperationParamId } from './shared/param_utils';
describe('createAggregationOperation', () => {
it('returns correct aggregation definitions with overrides', () => {

@ -4,7 +4,6 @@ import pluralize from 'pluralize';
import { SelectableValue } from '@grafana/data';
import { LabelParamEditor } from './components/LabelParamEditor';
import {
QueryBuilderLabelFilter,
QueryBuilderOperation,
@ -120,10 +119,6 @@ export function getPromOperationDisplayName(funcName: string) {
return capitalize(funcName.replace(/_/g, ' '));
}
export function getOperationParamId(operationId: string, paramIndex: number) {
return `operations.${operationId}.param.${paramIndex}`;
}
export function getRangeVectorParamDef(withRateInterval = false): QueryBuilderOperationParamDef {
/* eslint-disable @grafana/i18n/no-untranslated-strings */
const options: Array<SelectableValue<string>> = [
@ -191,7 +186,7 @@ export function createAggregationOperation(
type: 'string',
restParam: true,
optional: true,
editor: LabelParamEditor,
editor: 'LabelParamEditor',
},
],
defaultParams: [''],
@ -213,7 +208,7 @@ export function createAggregationOperation(
type: 'string',
restParam: true,
optional: true,
editor: LabelParamEditor,
editor: 'LabelParamEditor',
},
],
defaultParams: [''],
@ -248,7 +243,7 @@ export function createAggregationOperationWithParam(
return operations;
}
function getAggregationByRenderer(aggregation: string) {
export function getAggregationByRenderer(aggregation: string) {
return function aggregationRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) {
return `${aggregation} by(${model.params.join(', ')}) (${innerExpr})`;
};

@ -1,6 +1,5 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/operations.ts
import { binaryScalarOperations } from './binaryScalarOperations';
import { LabelParamEditor } from './components/LabelParamEditor';
import {
defaultAddOperationHandler,
functionRendererLeft,
@ -216,7 +215,6 @@ export function getOperationDefinitions(): QueryBuilderOperationDef[] {
{
name: 'Destination Label',
type: 'string',
editor: LabelParamEditor,
},
{
name: 'Separator',
@ -227,7 +225,6 @@ export function getOperationDefinitions(): QueryBuilderOperationDef[] {
type: 'string',
restParam: true,
optional: true,
editor: LabelParamEditor,
},
],
defaultParams: ['', ',', ''],

@ -1,11 +1,9 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/shared/LokiAndPromQueryModellerBase.ts
import { Registry } from '@grafana/data';
import { config } from '@grafana/runtime';
import { prometheusRegularEscape } from '../../datasource';
import { isValidLegacyName, utf8Support } from '../../utf8_support';
import { PromVisualQueryOperationCategory } from '../types';
import { renderLabels } from './rendering/labels';
import { hasBinaryOp, renderOperations } from './rendering/operations';
import { renderQuery, renderBinaryQueries } from './rendering/query';
import { QueryBuilderLabelFilter, QueryBuilderOperation, QueryBuilderOperationDef, VisualQueryModeller } from './types';
export interface VisualQueryBinary<T> {
@ -25,11 +23,22 @@ export interface PromLokiVisualQuery {
export abstract class LokiAndPromQueryModellerBase implements VisualQueryModeller {
protected operationsRegistry: Registry<QueryBuilderOperationDef>;
private categories: string[] = [];
private operationsMapCache: Map<string, QueryBuilderOperationDef> | null = null;
constructor(getOperations: () => QueryBuilderOperationDef[]) {
this.operationsRegistry = new Registry<QueryBuilderOperationDef>(getOperations);
}
private getOperationsMap(): Map<string, QueryBuilderOperationDef> {
if (!this.operationsMapCache) {
this.operationsMapCache = new Map<string, QueryBuilderOperationDef>();
this.operationsRegistry.list().forEach((op) => {
this.operationsMapCache!.set(op.id, op);
});
}
return this.operationsMapCache;
}
protected setOperationCategories(categories: string[]) {
this.categories = categories;
}
@ -51,96 +60,22 @@ export abstract class LokiAndPromQueryModellerBase implements VisualQueryModelle
}
renderOperations(queryString: string, operations: QueryBuilderOperation[]) {
for (const operation of operations) {
const def = this.operationsRegistry.getIfExists(operation.id);
if (!def) {
throw new Error(`Could not find operation ${operation.id} in the registry`);
}
queryString = def.renderer(operation, def, queryString);
}
return queryString;
return renderOperations(queryString, operations, this.getOperationsMap());
}
renderBinaryQueries(queryString: string, binaryQueries?: Array<VisualQueryBinary<PromLokiVisualQuery>>) {
if (binaryQueries) {
for (const binQuery of binaryQueries) {
queryString = `${this.renderBinaryQuery(queryString, binQuery)}`;
}
}
return queryString;
}
private renderBinaryQuery(leftOperand: string, binaryQuery: VisualQueryBinary<PromLokiVisualQuery>) {
let result = leftOperand + ` ${binaryQuery.operator} `;
if (binaryQuery.vectorMatches) {
result += `${binaryQuery.vectorMatchesType}(${binaryQuery.vectorMatches}) `;
}
return result + this.renderQuery(binaryQuery.query, true);
return renderBinaryQueries(queryString, binaryQueries);
}
renderLabels(labels: QueryBuilderLabelFilter[]) {
if (labels.length === 0) {
return '';
}
let expr = '{';
for (const filter of labels) {
if (expr !== '{') {
expr += ', ';
}
let labelValue = filter.value;
const usingRegexOperator = filter.op === '=~' || filter.op === '!~';
if (config.featureToggles.prometheusSpecialCharsInLabelValues && !usingRegexOperator) {
labelValue = prometheusRegularEscape(labelValue);
}
expr += `${utf8Support(filter.label)}${filter.op}"${labelValue}"`;
}
return expr + `}`;
return renderLabels(labels);
}
renderQuery(query: PromLokiVisualQuery, nested?: boolean) {
let queryString = '';
const labels = this.renderLabels(query.labels);
if (query.metric) {
if (isValidLegacyName(query.metric)) {
// This is a legacy metric, put outside the curl legacy_query{label="value"}
queryString = `${query.metric}${labels}`;
} else {
// This is a utf8 metric, put inside the curly and quotes {"utf8.metric", label="value"}
queryString = `{"${query.metric}"${labels.length > 0 ? `, ${labels.substring(1)}` : `}`}`;
}
} else {
// No metric just use labels {label="value"}
queryString = labels;
}
queryString = this.renderOperations(queryString, query.operations);
if (!nested && this.hasBinaryOp(query) && Boolean(query.binaryQueries?.length)) {
queryString = `(${queryString})`;
}
queryString = this.renderBinaryQueries(queryString, query.binaryQueries);
if (nested && (this.hasBinaryOp(query) || Boolean(query.binaryQueries?.length))) {
queryString = `(${queryString})`;
}
return queryString;
return renderQuery(query, nested, this.getOperationsMap());
}
hasBinaryOp(query: PromLokiVisualQuery): boolean {
return (
query.operations.find((op) => {
const def = this.getOperationDef(op.id);
return def?.category === PromVisualQueryOperationCategory.BinaryOps;
}) !== undefined
);
return hasBinaryOp(query, this.getOperationsMap());
}
}

@ -8,10 +8,9 @@ import { DataSourceApi, GrafanaTheme2, TimeRange } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Button, Icon, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { getOperationParamId } from '../operationUtils';
import { OperationHeader } from './OperationHeader';
import { getOperationParamEditor } from './OperationParamEditor';
import { getOperationParamEditor } from './OperationParamEditorRegistry';
import { getOperationParamId } from './param_utils';
import {
QueryBuilderOperation,
QueryBuilderOperationDef,
@ -102,16 +101,16 @@ export function OperationEditor({
<div className={styles.paramValue}>
<Stack gap={0.5} direction="row" alignItems="center">
<Editor
index={paramIndex}
paramDef={paramDef}
value={operation.params[paramIndex]}
operation={operation}
operationId={id}
onChange={onParamValueChanged}
onRunQuery={onRunQuery}
index={paramIndex}
operationId={operation.id}
query={query}
datasource={datasource}
timeRange={timeRange}
onChange={onParamValueChanged}
onRunQuery={onRunQuery}
queryModeller={queryModeller}
/>
{paramDef.restParam && (operation.params.length > def.params.length || paramDef.optional) && (
<Button

@ -9,11 +9,11 @@ import PromQlLanguageProvider from '../../language_provider';
import { EmptyLanguageProviderMock } from '../../language_provider.mock';
import { getMockTimeRange } from '../../test/__mocks__/datasource';
import { PromOptions } from '../../types';
import { promQueryModeller } from '../PromQueryModeller';
import { addOperationInQueryBuilder } from '../testUtils';
import { PromVisualQuery } from '../types';
import { OperationList } from './OperationList';
import { promQueryModeller } from './modeller_instance';
const defaultQuery: PromVisualQuery = {
metric: 'random_metric',

@ -6,14 +6,62 @@ import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data';
import { t } from '@grafana/i18n';
import { AutoSizeInput, Button, Checkbox, Select, useStyles2, Stack } from '@grafana/ui';
import { getOperationParamId } from '../operationUtils';
import { LabelParamEditor } from '../components/LabelParamEditor';
import { getOperationParamId } from './param_utils';
import { QueryBuilderOperationParamDef, QueryBuilderOperationParamEditorProps } from './types';
/**
* Registry of operation parameter editors that can be referenced by key.
*
* This approach solves a circular dependency problem in the codebase:
* - Operation definitions need to reference editors (e.g., LabelParamEditor)
* - Editors need to reference the modeller instance
* - The modeller instance needs to reference operation definitions
*
* By using string keys instead of direct imports, we break this cycle:
* 1. Operation definitions reference editors by key (no component import needed)
* 2. The registry maps these keys to actual editor components
* 3. The wrapper component (OperationParamEditorWrapper) injects the modeller instance
*
* This creates a clear dependency flow:
* Operation Definitions -> Registry -> Editor Components <- Wrapper <- Modeller Instance
*
* @example
* ```ts
* {
* id: 'someOperation',
* params: [{
* name: 'Label',
* type: 'string',
* editor: 'LabelParamEditor' // Reference by key instead of supplying the component directly
* }]
* }
* ```
*/
const editorMap: Record<string, ComponentType<QueryBuilderOperationParamEditorProps>> = {
// The wrapper component will ensure the modeller is provided
LabelParamEditor: LabelParamEditor as ComponentType<QueryBuilderOperationParamEditorProps>,
};
/**
* Resolves an operation parameter editor based on the parameter definition.
*
* The editor can be specified in three ways:
* 1. As a string key referencing a registered editor in editorMap
* 2. As a direct component reference
* 3. Based on the parameter type (string, number, boolean) or options
*
* This flexibility allows operation definitions to be decoupled from editor implementations
* while maintaining type safety and clear dependencies.
*/
export function getOperationParamEditor(
paramDef: QueryBuilderOperationParamDef
): ComponentType<QueryBuilderOperationParamEditorProps> {
if (paramDef.editor) {
if (typeof paramDef.editor === 'string') {
return editorMap[paramDef.editor] || SimpleInputParamEditor;
}
return paramDef.editor;
}

@ -8,26 +8,25 @@ import { reportInteraction } from '@grafana/runtime';
import { Button, Tooltip, useStyles2 } from '@grafana/ui';
import { PrometheusDatasource } from '../../datasource';
import { PromQueryModellerInterface, PromVisualQuery } from '../types';
import { LokiAndPromQueryModellerBase, PromLokiVisualQuery } from './LokiAndPromQueryModellerBase';
export interface Props<T extends PromLokiVisualQuery> {
query: T;
export interface Props {
query: PromVisualQuery;
datasource: PrometheusDatasource;
queryModeller: LokiAndPromQueryModellerBase;
buildVisualQueryFromString: (expr: string) => { query: T };
onChange: (update: T) => void;
queryModeller: PromQueryModellerInterface;
buildVisualQueryFromString: (expr: string) => { query: PromVisualQuery };
onChange: (update: PromVisualQuery) => void;
data?: PanelData;
}
export const QueryBuilderHints = <T extends PromLokiVisualQuery>({
export const QueryBuilderHints = ({
datasource,
query: visualQuery,
onChange,
data,
queryModeller,
buildVisualQueryFromString,
}: Props<T>) => {
}: Props) => {
const [hints, setHints] = useState<QueryHint[]>([]);
const styles = useStyles2(getStyles);

@ -0,0 +1,10 @@
import { PromQueryModeller } from '../PromQueryModeller';
import { PromQueryModellerInterface } from '../types';
/**
* This singleton instance of the Prometheus query modeller is a central point
* for accessing the query modeller functionality while avoiding circular
* dependencies in the codebase.
*/
export const promQueryModeller: PromQueryModellerInterface = new PromQueryModeller();

@ -0,0 +1,3 @@
export function getOperationParamId(operationId: string, paramIndex: number) {
return `operations.${operationId}.param.${paramIndex}`;
}

@ -0,0 +1,16 @@
import { QueryBuilderLabelFilter } from './types';
export function buildMetricQuery(metric: string, labels: QueryBuilderLabelFilter[]) {
let expr = metric;
if (labels.length > 0) {
expr = `${metric}{${labels.map(renderLabelFilter).join(',')}}`;
}
return expr;
}
function renderLabelFilter(label: QueryBuilderLabelFilter): string {
if (label.value === '') {
return `${label.label}=""`;
}
return `${label.label}${label.op}"${label.value}"`;
}

@ -0,0 +1,31 @@
import { config } from '@grafana/runtime';
import { prometheusRegularEscape } from '../../../escaping';
import { utf8Support } from '../../../utf8_support';
import { QueryBuilderLabelFilter } from '../types';
/**
* Renders label filters in the format: {label1="value1", label2="value2"}
*/
export function renderLabels(labels: QueryBuilderLabelFilter[]): string {
if (labels.length === 0) {
return '';
}
let expr = '{';
for (const filter of labels) {
if (expr !== '{') {
expr += ', ';
}
let labelValue = filter.value;
const usingRegexOperator = filter.op === '=~' || filter.op === '!~';
if (config.featureToggles.prometheusSpecialCharsInLabelValues && !usingRegexOperator) {
labelValue = prometheusRegularEscape(labelValue);
}
expr += `${utf8Support(filter.label)}${filter.op}"${labelValue}"`;
}
return expr + `}`;
}

@ -0,0 +1,37 @@
import { PromVisualQueryOperationCategory } from '../../types';
import { PromLokiVisualQuery } from '../LokiAndPromQueryModellerBase';
import { QueryBuilderOperation, QueryBuilderOperationDef } from '../types';
/**
* Renders operations
*/
export function renderOperations(
queryString: string,
operations: QueryBuilderOperation[],
operationsRegistry: Map<string, QueryBuilderOperationDef>
): string {
for (const operation of operations) {
const def = operationsRegistry.get(operation.id);
if (!def) {
throw new Error(`Could not find operation ${operation.id} in the registry`);
}
queryString = def.renderer(operation, def, queryString);
}
return queryString;
}
/**
* Checks if query has binary operation
*/
export function hasBinaryOp(
query: PromLokiVisualQuery,
operationsRegistry: Map<string, QueryBuilderOperationDef>
): boolean {
return (
query.operations.find((op) => {
const def = operationsRegistry.get(op.id);
return def?.category === PromVisualQueryOperationCategory.BinaryOps;
}) !== undefined
);
}

@ -0,0 +1,141 @@
import { isValidLegacyName } from '../../../utf8_support';
import { PromLokiVisualQuery, VisualQueryBinary } from '../LokiAndPromQueryModellerBase';
import { QueryBuilderOperationDef } from '../types';
import { renderLabels } from './labels';
import { hasBinaryOp, renderOperations } from './operations';
/**
* Renders binary queries
*/
export function renderBinaryQueries(
queryString: string,
binaryQueries?: Array<VisualQueryBinary<PromLokiVisualQuery>>
): string {
if (binaryQueries) {
for (const binQuery of binaryQueries) {
queryString = `${renderBinaryQuery(queryString, binQuery)}`;
}
}
return queryString;
}
/**
* Renders a binary query
*/
export function renderBinaryQuery(leftOperand: string, binaryQuery: VisualQueryBinary<PromLokiVisualQuery>): string {
let result = leftOperand + ` ${binaryQuery.operator} `;
if (binaryQuery.vectorMatches) {
result += `${binaryQuery.vectorMatchesType}(${binaryQuery.vectorMatches}) `;
}
return result + renderQuery(binaryQuery.query, true);
}
/**
* Renders a full query
*/
export function renderQuery(
query: PromLokiVisualQuery,
nested?: boolean,
operationsRegistry?: Map<string, QueryBuilderOperationDef>
): string {
// Handle empty query
if (!query.metric && query.labels.length === 0 && query.operations.length === 0) {
return '';
}
let queryString = '';
const labels = renderLabels(query.labels);
if (query.metric) {
if (isValidLegacyName(query.metric)) {
// This is a legacy metric, put outside the curl legacy_query{label="value"}
queryString = `${query.metric}${labels}`;
} else {
// This is a utf8 metric, put inside the curly and quotes {"utf8.metric", label="value"}
queryString = `{"${query.metric}"${labels.length > 0 ? `, ${labels.substring(1)}` : `}`}`;
}
} else if (query.labels.length > 0) {
// No metric just use labels {label="value"}
queryString = labels;
} else if (query.operations.length > 0) {
// For query patterns, we want the operation to render as e.g. rate([$__rate_interval])
queryString = '';
}
// If we have operations and an operations registry, render the operations
if (query.operations.length > 0) {
if (operationsRegistry) {
queryString = renderOperations(queryString, query.operations, operationsRegistry);
} else {
// For cases like add_label_to_query, handle operations generically
for (const operation of query.operations) {
// Special case to handle basic operations like multiplication
if (operation.id === 'MultiplyBy' && operation.params && operation.params.length > 0) {
queryString = `${queryString} * ${operation.params[0]}`;
}
}
}
}
// Check if this query or child queries need parentheses
const hasNesting = Boolean(query.binaryQueries?.length);
const hasBinaryOperation = operationsRegistry ? hasBinaryOp(query, operationsRegistry) : false;
// Handle nested queries with binary operations
if (!nested && hasBinaryOperation && hasNesting) {
queryString = `(${queryString})`;
}
// Render any binary queries
if (hasNesting) {
for (const binQuery of query.binaryQueries!) {
const rightOperand = renderNestedPart(binQuery.query, operationsRegistry);
// Add vector matching if present
let vectorMatchingStr = '';
if (binQuery.vectorMatches) {
vectorMatchingStr = `${binQuery.vectorMatchesType}(${binQuery.vectorMatches}) `;
}
// Combine left and right operands with operator
queryString = `${queryString} ${binQuery.operator} ${vectorMatchingStr}${rightOperand}`;
}
}
// Add parentheses for nested queries when needed
if (nested && (hasBinaryOperation || hasNesting)) {
queryString = `(${queryString})`;
}
return queryString;
}
/**
* Special helper for rendering a nested part of a binary query
* This ensures we only add parentheses when needed
*/
function renderNestedPart(
query: PromLokiVisualQuery,
operationsRegistry?: Map<string, QueryBuilderOperationDef>
): string {
// First render the query itself
const renderedQuery = renderQuery(query, false, operationsRegistry);
const hasOps = query.operations.length > 0;
const hasNestedBinary = Boolean(query.binaryQueries?.length);
// If this is an operation-only query (no metric, no labels, no binaryQueries, at least one operation), do not add parentheses
if (hasOps && !hasNestedBinary && !query.metric && (!query.labels || query.labels.length === 0)) {
return renderedQuery;
}
// Keep the correct format for test expectations
if (hasOps || hasNestedBinary) {
return `(${renderedQuery})`;
}
return renderedQuery;
}

@ -6,6 +6,8 @@ import { ComponentType } from 'react';
import { DataSourceApi, RegistryItem, SelectableValue, TimeRange } from '@grafana/data';
import { PromVisualQuery } from '../types';
export interface QueryBuilderLabelFilter {
label: string;
op: string;
@ -69,7 +71,7 @@ export interface QueryBuilderOperationParamDef {
placeholder?: string;
description?: string;
minWidth?: number;
editor?: ComponentType<QueryBuilderOperationParamEditorProps>;
editor?: ComponentType<QueryBuilderOperationParamEditorProps> | string;
runQueryOnEnter?: boolean;
}
@ -84,17 +86,17 @@ export interface QueryBuilderOperationEditorProps {
}
export interface QueryBuilderOperationParamEditorProps {
value?: QueryBuilderOperationParamValue;
paramDef: QueryBuilderOperationParamDef;
onChange: (index: number, value: QueryBuilderOperationParamValue) => void;
onRunQuery: () => void;
/** Parameter index */
index: number;
operation: QueryBuilderOperation;
operationId: string;
query: any;
query: PromVisualQuery;
datasource: DataSourceApi;
timeRange: TimeRange;
onChange: (index: number, value: QueryBuilderOperationParamValue) => void;
onRunQuery: () => void;
paramDef: QueryBuilderOperationParamDef;
queryModeller: VisualQueryModeller;
value?: QueryBuilderOperationParamValue;
}
export enum QueryEditorMode {

@ -0,0 +1,13 @@
import { QueryBuilderLabelFilter, QueryBuilderOperation } from '../types';
export interface PromLokiVisualQuery {
metric?: string;
labels: QueryBuilderLabelFilter[];
operations: QueryBuilderOperation[];
}
export interface VisualQueryBinary {
operator: string;
vectorMatches?: string;
query: PromLokiVisualQuery;
}

@ -1,6 +1,6 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/types.ts
import { VisualQueryBinary } from './shared/LokiAndPromQueryModellerBase';
import { QueryBuilderLabelFilter, QueryBuilderOperation } from './shared/types';
import { QueryBuilderLabelFilter, QueryBuilderOperation, QueryBuilderOperationDef } from './shared/types';
/**
* Visual query model
@ -17,6 +17,17 @@ export interface PromVisualQuery {
fullMetaSearch?: boolean;
}
export interface PromQueryModellerInterface {
renderLabels(labels: QueryBuilderLabelFilter[]): string;
renderQuery(query: PromVisualQuery, nested?: boolean): string;
hasBinaryOp(query: PromVisualQuery): boolean;
getQueryPatterns(): PromQueryPattern[];
getOperationsForCategory(category: string): QueryBuilderOperationDef[];
getOperationDef(id: string): QueryBuilderOperationDef | undefined;
getAlternativeOperations(key: string): QueryBuilderOperationDef[];
getCategories(): string[];
}
export type PromVisualQueryBinary = VisualQueryBinary<PromVisualQuery>;
export enum PromVisualQueryOperationCategory {

@ -3433,6 +3433,7 @@ __metadata:
react-select-event: "npm:5.5.1"
react-use: "npm:17.6.0"
react-window: "npm:1.8.11"
rimraf: "npm:6.0.1"
rollup: "npm:^4.22.4"
rollup-plugin-esbuild: "npm:6.2.0"
rollup-plugin-node-externals: "npm:^8.0.0"

Loading…
Cancel
Save