diff --git a/src/Components/IndexScene/IndexScene.tsx b/src/Components/IndexScene/IndexScene.tsx index ab6bb0de5..db920cf4c 100644 --- a/src/Components/IndexScene/IndexScene.tsx +++ b/src/Components/IndexScene/IndexScene.tsx @@ -23,6 +23,7 @@ import { SceneVariableSet, } from '@grafana/scenes'; import { + AppliedPattern, AdHocFiltersWithLabelsAndMeta, EXPLORATION_DS, MIXED_FORMAT_EXPR, @@ -66,7 +67,6 @@ import { renderLogQLLabelFilters, renderLogQLLineFilter, renderLogQLMetadataFilters, - renderPatternFilters, } from 'services/query'; import { VariableHide } from '@grafana/schema'; import { CustomConstantVariable } from '../../services/CustomConstantVariable'; @@ -99,12 +99,9 @@ import { areArraysEqual } from '../../services/comparison'; import { isFilterMetadata } from '../../services/filters'; import { getFieldsTagValuesExpression } from '../../services/expressions'; import { isOperatorInclusive } from '../../services/operatorHelpers'; +import { renderPatternFilters } from '../../services/renderPatternFilters'; export const showLogsButtonSceneKey = 'showLogsButtonScene'; -export interface AppliedPattern { - pattern: string; - type: 'include' | 'exclude'; -} export interface IndexSceneState extends SceneObjectState { // contentScene is the scene that is displayed in the main body of the index scene - it can be either the service selection or service scene diff --git a/src/Components/IndexScene/PatternControls.test.tsx b/src/Components/IndexScene/PatternControls.test.tsx index bbe443b85..6121dc1f4 100644 --- a/src/Components/IndexScene/PatternControls.test.tsx +++ b/src/Components/IndexScene/PatternControls.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { PatternControls } from './PatternControls'; import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { AppliedPattern } from './IndexScene'; +import { AppliedPattern } from '../../services/variables'; const originalWidth = global.window.innerWidth; beforeAll(() => { diff --git a/src/Components/IndexScene/PatternControls.tsx b/src/Components/IndexScene/PatternControls.tsx index 3b663e24c..55563e135 100644 --- a/src/Components/IndexScene/PatternControls.tsx +++ b/src/Components/IndexScene/PatternControls.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { AppliedPattern } from './IndexScene'; import { PatternTag } from './PatternTag'; import { css } from '@emotion/css'; import { useStyles2, Text } from '@grafana/ui'; import { USER_EVENTS_ACTIONS, USER_EVENTS_PAGES, reportAppInteraction } from 'services/analytics'; import { testIds } from 'services/testIds'; +import { AppliedPattern } from '../../services/variables'; type Props = { patterns: AppliedPattern[] | undefined; diff --git a/src/Components/IndexScene/VariableLayoutScene.tsx b/src/Components/IndexScene/VariableLayoutScene.tsx index 4cb181c08..1ed0f3e10 100644 --- a/src/Components/IndexScene/VariableLayoutScene.tsx +++ b/src/Components/IndexScene/VariableLayoutScene.tsx @@ -4,10 +4,11 @@ import { css, cx } from '@emotion/css'; import { GiveFeedbackButton } from './GiveFeedbackButton'; import { CustomVariableValueSelectors } from './CustomVariableValueSelectors'; import { PatternControls } from './PatternControls'; -import { AppliedPattern, IndexScene } from './IndexScene'; +import { IndexScene } from './IndexScene'; import { CONTROLS_VARS_DATASOURCE, CONTROLS_VARS_FIELDS_COMBINED, LayoutScene } from './LayoutScene'; import { useStyles2 } from '@grafana/ui'; import { GrafanaTheme2 } from '@grafana/data'; +import { AppliedPattern } from '../../services/variables'; interface VariableLayoutSceneState extends SceneObjectState {} export class VariableLayoutScene extends SceneObjectBase { diff --git a/src/Components/ServiceScene/Breakdowns/Patterns/PatternsLogsSampleScene.tsx b/src/Components/ServiceScene/Breakdowns/Patterns/PatternsLogsSampleScene.tsx index 0ea6f608d..b725dba3f 100644 --- a/src/Components/ServiceScene/Breakdowns/Patterns/PatternsLogsSampleScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/Patterns/PatternsLogsSampleScene.tsx @@ -15,18 +15,19 @@ import React from 'react'; import { LoadingState } from '@grafana/data'; import { Alert, Button } from '@grafana/ui'; import { + AppliedPattern, LOG_STREAM_SELECTOR_EXPR, PATTERNS_SAMPLE_SELECTOR_EXPR, VAR_PATTERNS_EXPR, } from '../../../../services/variables'; -import { buildDataQuery, renderPatternFilters } from '../../../../services/query'; +import { buildDataQuery } from '../../../../services/query'; import { getQueryRunner } from '../../../../services/panel'; -import { AppliedPattern } from '../../../IndexScene/IndexScene'; import { PatternsViewTableScene } from './PatternsViewTableScene'; import { emptyStateStyles } from '../FieldsBreakdownScene'; import { getFieldsVariable, getLevelsVariable, getLineFiltersVariable } from '../../../../services/variableGetters'; import { LokiQuery } from '../../../../services/lokiQuery'; import { logger } from '../../../../services/logger'; +import { renderPatternFilters } from '../../../../services/renderPatternFilters'; interface PatternsLogsSampleSceneState extends SceneObjectState { pattern: string; diff --git a/src/Components/ServiceScene/Breakdowns/Patterns/PatternsViewTableScene.tsx b/src/Components/ServiceScene/Breakdowns/Patterns/PatternsViewTableScene.tsx index 7c0c4fb92..4fc044853 100644 --- a/src/Components/ServiceScene/Breakdowns/Patterns/PatternsViewTableScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/Patterns/PatternsViewTableScene.tsx @@ -9,7 +9,7 @@ import { } from '@grafana/scenes'; import { PatternFrame } from './PatternsBreakdownScene'; import React from 'react'; -import { AppliedPattern, IndexScene } from '../../../IndexScene/IndexScene'; +import { IndexScene } from '../../../IndexScene/IndexScene'; import { DataFrame, GrafanaTheme2, LoadingState, PanelData, scaledUnits } from '@grafana/data'; import { AxisPlacement, Column, InteractiveTable, TooltipDisplayMode, useTheme2 } from '@grafana/ui'; import { CellProps } from 'react-table'; @@ -23,6 +23,7 @@ import { PatternNameLabel } from './PatternNameLabel'; import { getExplorationFor } from 'services/scenes'; import { PatternsTableExpandedRow } from './PatternsTableExpandedRow'; import { LINE_LIMIT } from '../../../../services/query'; +import { AppliedPattern } from '../../../../services/variables'; // copied from from grafana repository packages/grafana-data/src/valueFormats/categories.ts // that is used in Grafana codebase for "short" units diff --git a/src/services/extensions/links.test.ts b/src/services/extensions/links.test.ts index 02c57e270..508102551 100644 --- a/src/services/extensions/links.test.ts +++ b/src/services/extensions/links.test.ts @@ -310,6 +310,78 @@ describe('contextToLink', () => { }); }); + describe('pattern-filters', () => { + it('should parse pattern filters', () => { + const target = getTestTarget({ + expr: '{cluster="eu-west-1"} !> "<_> - - [<_> +0000]" |> "<_> - <_> [<_> +0000]" | json | logfmt | drop __error__, __error_details__', + }); + const config = getTestConfig(linkConfigs, target); + + const expectedLabelFiltersUrlString = `&var-filters=${encodeFilter( + `cluster|=|${addCustomInputPrefixAndValueLabels('eu-west-1')}` + )}`; + + const expectedPatternsVariable = `&var-patterns=${encodeFilter( + '!> "<_> - - [<_> +0000]" |> "<_> - <_> [<_> +0000]"' + )}`; + + const pattern = `[{"type":"exclude","pattern":"<_> - - [<_> +0000]"},{"type":"include","pattern":"<_> - <_> [<_> +0000]"}]`; + const expectedPatterns = `&patterns=${encodeFilter(pattern)}`; + + expect(config).toEqual({ + path: getPath({ + slug: 'cluster/eu-west-1', + expectedLabelFiltersUrlString, + expectedPatternsVariable, + expectedPatterns, + }), + }); + }); + it('should parse multiple pattern filters', () => { + const target = getTestTarget({ + expr: '{cluster="eu-west-1"} !> "<_> - - [<_> +0000]" |> "<_> - <_> [<_> +0000]" or "<_> - <_> [<_> +0000] \\"POST <_> <_>\\"" | json | logfmt | drop __error__, __error_details__', + }); + const config = getTestConfig(linkConfigs, target); + + const expectedLabelFiltersUrlString = `&var-filters=${encodeFilter( + `cluster|=|${addCustomInputPrefixAndValueLabels('eu-west-1')}` + )}`; + + const expectedPatternsVariable = `&var-patterns=${encodeFilter( + '!> "<_> - - [<_> +0000]" |> "<_> - <_> [<_> +0000]" or "<_> - <_> [<_> +0000] \\"POST <_> <_>\\""' + )}`; + + const pattern = `[{"type":"exclude","pattern":"<_> - - [<_> +0000]"},{"type":"include","pattern":"<_> - <_> [<_> +0000]"},{"type":"include","pattern":"<_> - <_> [<_> +0000] \\"POST <_> <_>\\""}]`; + const expectedPatterns = `&patterns=${encodeFilter(pattern)}`; + + expect(config).toEqual({ + path: getPath({ + slug: 'cluster/eu-west-1', + expectedLabelFiltersUrlString, + expectedPatternsVariable, + expectedPatterns, + }), + }); + }); + it('should parse empty filters', () => { + const target = getTestTarget({ + expr: '{cluster="eu-west-1"} !> "" !> "" |> "" or "" | json | logfmt | drop __error__, __error_details__', + }); + const config = getTestConfig(linkConfigs, target); + + const expectedLabelFiltersUrlString = `&var-filters=${encodeFilter( + `cluster|=|${addCustomInputPrefixAndValueLabels('eu-west-1')}` + )}`; + + expect(config).toEqual({ + path: getPath({ + slug: 'cluster/eu-west-1', + expectedLabelFiltersUrlString, + }), + }); + }); + }); + describe('fields', () => { describe('string fields', () => { it('should parse structured metadata field', () => { diff --git a/src/services/extensions/links.ts b/src/services/extensions/links.ts index 8c8364428..f58ccecbe 100644 --- a/src/services/extensions/links.ts +++ b/src/services/extensions/links.ts @@ -4,6 +4,7 @@ import { PluginExtensionLinkConfig, PluginExtensionPanelContext, PluginExtension import { addAdHocFilterUserInputPrefix, AdHocFieldValue, + AppliedPattern, LEVEL_VARIABLE_VALUE, SERVICE_NAME, stripAdHocFilterUserInputPrefix, @@ -13,6 +14,7 @@ import { VAR_LEVELS, VAR_LINE_FILTERS, VAR_METADATA, + VAR_PATTERNS, } from 'services/variables'; import pluginJson from '../../plugin.json'; import { getMatcherFromQuery } from '../logqlMatchers'; @@ -20,6 +22,8 @@ import { LokiQuery } from '../lokiQuery'; import { LabelType } from '../fieldsTypes'; import { isOperatorInclusive } from '../operatorHelpers'; +import { PatternFilterOp } from '../filterTypes'; +import { renderPatternFilters } from '../renderPatternFilters'; const title = 'Open in Explore Logs'; const description = 'Open current query in the Explore Logs view'; @@ -88,7 +92,7 @@ function contextToLink(context?: T) { } const expr = lokiQuery.expr; - const { labelFilters, lineFilters, fields } = getMatcherFromQuery(expr, context, lokiQuery); + const { labelFilters, lineFilters, fields, patternFilters } = getMatcherFromQuery(expr, context, lokiQuery); const labelSelector = labelFilters.find((selector) => isOperatorInclusive(selector.operator)); // Require at least one inclusive operator to run a valid Loki query @@ -163,6 +167,22 @@ function contextToLink(context?: T) { } } } + if (patternFilters?.length) { + const patterns: AppliedPattern[] = []; + + for (const field of patternFilters) { + patterns.push({ + type: field.operator === PatternFilterOp.match ? 'include' : 'exclude', + pattern: stringifyValues(field.value), + }); + } + + let patternsString = renderPatternFilters(patterns); + + params = appendUrlParameter(UrlParameters.Patterns, JSON.stringify(patterns), params); + params = appendUrlParameter(UrlParameters.PatternsVariable, patternsString, params); + } + return { path: createAppUrl(`/explore/${labelName}/${labelValue}/logs`, params), }; @@ -181,6 +201,8 @@ export const UrlParameters = { Metadata: `var-${VAR_METADATA}`, Levels: `var-${VAR_LEVELS}`, LineFilters: `var-${VAR_LINE_FILTERS}`, + Patterns: VAR_PATTERNS, + PatternsVariable: `var-${VAR_PATTERNS}`, } as const; export type UrlParameterType = (typeof UrlParameters)[keyof typeof UrlParameters]; diff --git a/src/services/extensions/scenesMethods.ts b/src/services/extensions/scenesMethods.ts new file mode 100644 index 000000000..de418a5fc --- /dev/null +++ b/src/services/extensions/scenesMethods.ts @@ -0,0 +1,13 @@ +// Warning, this file is included in the main module.tsx bundle, and doesn't contain any imports to keep that bundle size small. Don't add imports to this file! + +/** + * Methods copied from scenes that we want in the module (to generate links which cannot be lazy loaded), without including all of scenes. + * See https://github.com/grafana/scenes/issues/1046 + */ +// 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, '\\"'); +} diff --git a/src/services/extensions/utils.ts b/src/services/extensions/utils.ts index 9f5e2c953..072ab07bb 100644 --- a/src/services/extensions/utils.ts +++ b/src/services/extensions/utils.ts @@ -26,10 +26,14 @@ export function getPath(options: { expectedLineFiltersUrlString?: string; expectedFieldsUrlString?: string; expectedLevelsFilterUrlString?: string; + expectedPatternsVariable?: string; + expectedPatterns?: string; }) { return `/a/grafana-lokiexplore-app/explore/${options.slug}/logs?var-ds=123abc&from=1675828800000&to=1675854000000${ options.expectedLabelFiltersUrlString ?? '' }${options.expectedMetadataString ?? ''}${options.expectedLineFiltersUrlString ?? ''}${ - options.expectedFieldsUrlString ?? '' - }${options.expectedLevelsFilterUrlString ?? ''}`; + options.expectedPatterns ?? '' + }${options.expectedPatternsVariable ?? ''}${options.expectedFieldsUrlString ?? ''}${ + options.expectedLevelsFilterUrlString ?? '' + }`; } diff --git a/src/services/filterTypes.ts b/src/services/filterTypes.ts index 7a86c9aca..ef3199d95 100644 --- a/src/services/filterTypes.ts +++ b/src/services/filterTypes.ts @@ -40,6 +40,11 @@ export type LineFilterType = { value: string; }; +export type PatternFilterType = { + operator: PatternFilterOp; + value: string; +}; + export enum LineFilterOp { match = '|=', negativeMatch = `!=`, @@ -47,6 +52,11 @@ export enum LineFilterOp { negativeRegex = `!~`, } +export enum PatternFilterOp { + match = '|>', + negativeMatch = '!>', +} + export enum LineFilterCaseSensitive { caseSensitive = 'caseSensitive', caseInsensitive = 'caseInsensitive', diff --git a/src/services/logqlMatchers.ts b/src/services/logqlMatchers.ts index 8a27e4ee9..2eac6b490 100644 --- a/src/services/logqlMatchers.ts +++ b/src/services/logqlMatchers.ts @@ -16,12 +16,14 @@ import { Lte, Matcher, Neq, + Npa, Nre, Number, OrFilter, parser, PipeExact, PipeMatch, + PipePattern, Re, Selector, String, @@ -35,6 +37,8 @@ import { LineFilterCaseSensitive, LineFilterOp, LineFilterType, + PatternFilterOp, + PatternFilterType, } from './filterTypes'; import { PluginExtensionPanelContext } from '@grafana/data'; import { getLabelTypeFromFrame, LokiQuery } from './lokiQuery'; @@ -152,64 +156,100 @@ function parseLabelFilters(query: string, filter: IndexedLabelFilter[]) { } } -function parseLineFilters(query: string, lineFilters: LineFilterType[]) { +function parseNonPatternFilters( + lineFilterValue: string, + quoteString: string, + lineFilters: LineFilterType[], + index: number, + operator: LineFilterOp +) { + const isRegexSelector = operator === LineFilterOp.regex || operator === LineFilterOp.negativeRegex; + const isCaseInsensitive = lineFilterValue.includes('(?i)') && isRegexSelector; + + // If quoteString is `, we shouldn't need to un-escape anything + // But if the quoteString is ", we'll need to remove double escape chars, as these values are re-escaped when building the query expression (but not stored in the value/url) + if (quoteString === '"' && isRegexSelector) { + const replaceDoubleEscape = new RegExp(/\\\\/, 'g'); + lineFilterValue = lineFilterValue.replace(replaceDoubleEscape, '\\'); + } else if (quoteString === '"') { + const replaceDoubleQuoteEscape = new RegExp(/\\\\\"/, 'g'); + lineFilterValue = lineFilterValue.replace(replaceDoubleQuoteEscape, '"'); + + const replaceDoubleEscape = new RegExp(/\\\\/, 'g'); + lineFilterValue = lineFilterValue.replace(replaceDoubleEscape, '\\'); + } + + if (isCaseInsensitive) { + // If `(?i)` exists in a regex it would need to be escaped to match log lines containing `(?i)`, so it should be safe to replace all instances of `(?i)` in the line filter? + lineFilterValue = lineFilterValue.replace('(?i)', ''); + } + + lineFilters.push({ + key: isCaseInsensitive + ? LineFilterCaseSensitive.caseInsensitive.toString() + : LineFilterCaseSensitive.caseSensitive.toString() + ',' + index.toString(), + operator: operator, + value: lineFilterValue, + }); + + return lineFilterValue; +} + +function parsePatternFilters(lineFilterValue: string, patternFilters: PatternFilterType[], operator: PatternFilterOp) { + const replaceDoubleQuoteEscape = new RegExp(/\\"/, 'g'); + lineFilterValue = lineFilterValue.replace(replaceDoubleQuoteEscape, '"'); + patternFilters.push({ + operator, + value: lineFilterValue, + }); +} + +function parseLineFilters(query: string, lineFilters: LineFilterType[], patternFilters: PatternFilterType[]) { const allLineFilters = getNodesFromQuery(query, [LineFilter]); for (const [index, matcher] of allLineFilters.entries()) { const equal = getAllPositionsInNodeByType(matcher, PipeExact); const pipeRegExp = getAllPositionsInNodeByType(matcher, PipeMatch); const notEqual = getAllPositionsInNodeByType(matcher, Neq); const notEqualRegExp = getAllPositionsInNodeByType(matcher, Nre); + const patternInclude = getAllPositionsInNodeByType(matcher, PipePattern); + const patternExclude = getAllPositionsInNodeByType(matcher, Npa); + + const lineFilterValueNodes = getStringsFromLineFilter(matcher); + + for (const lineFilterValueNode of lineFilterValueNodes) { + const quoteString = query.substring(lineFilterValueNode?.from + 1, lineFilterValueNode?.from); + + // Remove quotes + let lineFilterValue = query.substring(lineFilterValueNode?.from + 1, lineFilterValueNode?.to - 1); + + if (lineFilterValue.length) { + let operator; + if (equal.length) { + operator = LineFilterOp.match; + } else if (notEqual.length) { + operator = LineFilterOp.negativeMatch; + } else if (notEqualRegExp.length) { + operator = LineFilterOp.negativeRegex; + } else if (pipeRegExp.length) { + operator = LineFilterOp.regex; + } else if (patternInclude.length) { + operator = PatternFilterOp.match; + } else if (patternExclude.length) { + operator = PatternFilterOp.negativeMatch; + } else { + console.warn('unknown line filter', { + query: query.substring(matcher.from, matcher.to), + }); + + continue; + } - const lineFilterValueNode = getStringsFromLineFilter(matcher); - - const quoteString = query.substring(lineFilterValueNode[0]?.from + 1, lineFilterValueNode[0]?.from); - - // Remove quotes - let lineFilterValue = query.substring(lineFilterValueNode[0]?.from + 1, lineFilterValueNode[0]?.to - 1); - - if (lineFilterValue.length) { - let operator; - if (equal.length) { - operator = LineFilterOp.match; - } else if (notEqual.length) { - operator = LineFilterOp.negativeMatch; - } else if (notEqualRegExp.length) { - operator = LineFilterOp.negativeRegex; - } else if (pipeRegExp.length) { - operator = LineFilterOp.regex; - } else { - throw new Error('unknown line filter operator'); - } - - const isRegexSelector = operator === LineFilterOp.regex || operator === LineFilterOp.negativeRegex; - - const isCaseInsensitive = lineFilterValue.includes('(?i)') && isRegexSelector; - - // If quoteString is `, we shouldn't need to un-escape anything - // But if the quoteString is ", we'll need to remove double escape chars, as these values are re-escaped when building the query expression (but not stored in the value/url) - if (quoteString === '"' && isRegexSelector) { - const replaceDoubleEscape = new RegExp(/\\\\/, 'g'); - lineFilterValue = lineFilterValue.replace(replaceDoubleEscape, '\\'); - } else if (quoteString === '"') { - const replaceDoubleQuoteEscape = new RegExp(/\\\\\"/, 'g'); - lineFilterValue = lineFilterValue.replace(replaceDoubleQuoteEscape, '"'); - - const replaceDoubleEscape = new RegExp(/\\\\/, 'g'); - lineFilterValue = lineFilterValue.replace(replaceDoubleEscape, '\\'); - } - - if (isCaseInsensitive) { - // If `(?i)` exists in a regex it would need to be escaped to match log lines containing `(?i)`, so it should be safe to replace all instances of `(?i)` in the line filter? - lineFilterValue = lineFilterValue.replace('(?i)', ''); + if (!(operator === PatternFilterOp.match || operator === PatternFilterOp.negativeMatch)) { + parseNonPatternFilters(lineFilterValue, quoteString, lineFilters, index, operator); + } else { + parsePatternFilters(lineFilterValue, patternFilters, operator); + } } - - lineFilters.push({ - key: isCaseInsensitive - ? LineFilterCaseSensitive.caseInsensitive.toString() - : LineFilterCaseSensitive.caseSensitive.toString() + ',' + index.toString(), - operator: operator, - value: lineFilterValue, - }); } } } @@ -327,9 +367,15 @@ export function getMatcherFromQuery( query: string, context: PluginExtensionPanelContext, lokiQuery: LokiQuery -): { labelFilters: IndexedLabelFilter[]; lineFilters?: LineFilterType[]; fields?: FieldFilter[] } { +): { + labelFilters: IndexedLabelFilter[]; + lineFilters?: LineFilterType[]; + fields?: FieldFilter[]; + patternFilters?: PatternFilterType[]; +} { const filter: IndexedLabelFilter[] = []; const lineFilters: LineFilterType[] = []; + const patternFilters: PatternFilterType[] = []; const fields: FieldFilter[] = []; const selector = getNodesFromQuery(query, [Selector]); @@ -341,10 +387,10 @@ export function getMatcherFromQuery( const selectorQuery = getAllPositionsInNodeByType(selector[0], Selector)[0].getExpression(query); parseLabelFilters(selectorQuery, filter); - parseLineFilters(query, lineFilters); + parseLineFilters(query, lineFilters, patternFilters); parseFields(query, fields, context, lokiQuery); - return { labelFilters: filter, lineFilters, fields }; + return { labelFilters: filter, lineFilters, fields, patternFilters }; } export function isQueryWithNode(query: string, nodeType: number): boolean { diff --git a/src/services/lokiQuery.ts b/src/services/lokiQuery.ts index 6ab0dac92..e835ee0e3 100644 --- a/src/services/lokiQuery.ts +++ b/src/services/lokiQuery.ts @@ -27,8 +27,7 @@ export type LokiQueryType = 'instant' | 'range' | 'stream' | string; export type LokiDatasource = DataSourceWithBackend & { maxLines?: number; -} & // @todo delete after min supported grafana is upgraded to >=11.6 -{ +} & { // @todo delete after min supported grafana is upgraded to >=11.6 interpolateString?: (string: string, scopedVars?: ScopedVars) => string; getTimeRangeParams: (timeRange: TimeRange) => { start: number; end: number }; }; diff --git a/src/services/query.test.ts b/src/services/query.test.ts index b71ce7e92..653fd7d2f 100644 --- a/src/services/query.test.ts +++ b/src/services/query.test.ts @@ -1,11 +1,5 @@ import { AdHocVariableFilter } from '@grafana/data'; -import { - buildDataQuery, - renderLogQLLineFilter, - renderPatternFilters, - unwrapWildcardSearch, - wrapWildcardSearch, -} from './query'; +import { buildDataQuery, renderLogQLLineFilter, unwrapWildcardSearch, wrapWildcardSearch } from './query'; import { LineFilterCaseSensitive, LineFilterOp } from './filterTypes'; describe('buildDataQuery', () => { @@ -204,31 +198,3 @@ describe('unwrapWildcardSearch', () => { expect(unwrapWildcardSearch('.+')).toEqual('.+'); }); }); -describe('renderPatternFilters', () => { - it('returns empty string if no patterns', () => { - expect(renderPatternFilters([])).toEqual(''); - }); - it('wraps in double quotes', () => { - expect( - renderPatternFilters([ - { - pattern: 'level=info ts=<_> msg="completing block"', - type: 'include', - }, - ]) - ).toEqual(`|> "level=info ts=<_> msg=\\"completing block\\""`); - }); - it('ignores backticks', () => { - expect( - renderPatternFilters([ - { - pattern: - 'logger=sqlstore.metrics traceID=<_> msg="query finished" sql="INSERT INTO instance (`org_id`, `result`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `org_id`=VALUES(`org_id`)" error=null', - type: 'include', - }, - ]) - ).toEqual( - `|> "logger=sqlstore.metrics traceID=<_> msg=\\"query finished\\" sql=\\"INSERT INTO instance (\`org_id\`, \`result\`) VALUES (?, ?) ON DUPLICATE KEY UPDATE \`org_id\`=VALUES(\`org_id\`)\\" error=null"` - ); - }); -}); diff --git a/src/services/query.ts b/src/services/query.ts index 1e35823be..f2f11180f 100644 --- a/src/services/query.ts +++ b/src/services/query.ts @@ -1,5 +1,4 @@ import { AdHocVariableFilter, SelectableValue } from '@grafana/data'; -import { AppliedPattern } from 'Components/IndexScene/IndexScene'; import { addAdHocFilterUserInputPrefix, AdHocFiltersWithLabelsAndMeta, @@ -176,29 +175,6 @@ export function renderLogQLLineFilter(filters: AdHocFilterWithLabels[]) { }) .join(' '); } - -// @todo worth migrating into the ExpressionBuilder class? -export function renderPatternFilters(patterns: AppliedPattern[]) { - const excludePatterns = patterns.filter((pattern) => pattern.type === 'exclude'); - const excludePatternsLine = excludePatterns - .map((p) => `!> "${sceneUtils.escapeLabelValueInExactSelector(p.pattern)}"`) - .join(' ') - .trim(); - - const includePatterns = patterns.filter((pattern) => pattern.type === 'include'); - let includePatternsLine = ''; - if (includePatterns.length > 0) { - if (includePatterns.length === 1) { - includePatternsLine = `|> "${sceneUtils.escapeLabelValueInExactSelector(includePatterns[0].pattern)}"`; - } else { - includePatternsLine = `|> ${includePatterns - .map((p) => `"${sceneUtils.escapeLabelValueInExactSelector(p.pattern)}"`) - .join(' or ')}`; - } - } - return `${excludePatternsLine} ${includePatternsLine}`.trim(); -} - export function wrapWildcardSearch(input: string) { if (input === '.+') { return input; diff --git a/src/services/renderPatternFilters.test.ts b/src/services/renderPatternFilters.test.ts new file mode 100644 index 000000000..10e62427e --- /dev/null +++ b/src/services/renderPatternFilters.test.ts @@ -0,0 +1,62 @@ +import { renderPatternFilters } from './renderPatternFilters'; + +describe('renderPatternFilters', () => { + it('returns empty string if no patterns', () => { + expect(renderPatternFilters([])).toEqual(''); + }); + it('wraps in double quotes', () => { + expect( + renderPatternFilters([ + { + pattern: 'level=info ts=<_> msg="completing block"', + type: 'include', + }, + ]) + ).toEqual(`|> "level=info ts=<_> msg=\\"completing block\\""`); + }); + it('ignores backticks', () => { + expect( + renderPatternFilters([ + { + pattern: + 'logger=sqlstore.metrics traceID=<_> msg="query finished" sql="INSERT INTO instance (`org_id`, `result`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `org_id`=VALUES(`org_id`)" error=null', + type: 'include', + }, + ]) + ).toEqual( + `|> "logger=sqlstore.metrics traceID=<_> msg=\\"query finished\\" sql=\\"INSERT INTO instance (\`org_id\`, \`result\`) VALUES (?, ?) ON DUPLICATE KEY UPDATE \`org_id\`=VALUES(\`org_id\`)\\" error=null"` + ); + }); + it('handles "or"', () => { + expect( + renderPatternFilters([ + { + pattern: 'logger=sqlstore.metrics traceID=<_> error=null', + type: 'include', + }, + { + pattern: 'logger=sqlstore.metrics `org_id`=VALUES(`org_id`)" error=null', + type: 'include', + }, + ]) + ).toEqual( + `|> "logger=sqlstore.metrics traceID=<_> error=null" or "logger=sqlstore.metrics \`org_id\`=VALUES(\`org_id\`)\\" error=null"` + ); + }); + it('handles exclusion', () => { + expect( + renderPatternFilters([ + { + pattern: 'logger=sqlstore.metrics traceID=<_> error=null', + type: 'exclude', + }, + { + pattern: 'logger=sqlstore.metrics `org_id`=VALUES(`org_id`)" error=null', + type: 'exclude', + }, + ]) + ).toEqual( + `!> "logger=sqlstore.metrics traceID=<_> error=null" !> "logger=sqlstore.metrics \`org_id\`=VALUES(\`org_id\`)\\" error=null"` + ); + }); +}); diff --git a/src/services/renderPatternFilters.ts b/src/services/renderPatternFilters.ts new file mode 100644 index 000000000..12abc973f --- /dev/null +++ b/src/services/renderPatternFilters.ts @@ -0,0 +1,24 @@ +// Warning, this file is included in the main module.tsx bundle, and doesn't contain many imports to keep that bundle size small. Don't add imports to this file! +import { AppliedPattern } from './variables'; +import { escapeLabelValueInExactSelector } from './extensions/scenesMethods'; + +export function renderPatternFilters(patterns: AppliedPattern[]) { + const excludePatterns = patterns.filter((pattern) => pattern.type === 'exclude'); + const excludePatternsLine = excludePatterns + .map((p) => `!> "${escapeLabelValueInExactSelector(p.pattern)}"`) + .join(' ') + .trim(); + + const includePatterns = patterns.filter((pattern) => pattern.type === 'include'); + let includePatternsLine = ''; + if (includePatterns.length > 0) { + if (includePatterns.length === 1) { + includePatternsLine = `|> "${escapeLabelValueInExactSelector(includePatterns[0].pattern)}"`; + } else { + includePatternsLine = `|> ${includePatterns + .map((p) => `"${escapeLabelValueInExactSelector(p.pattern)}"`) + .join(' or ')}`; + } + } + return `${excludePatternsLine} ${includePatternsLine}`.trim(); +} diff --git a/src/services/variables.ts b/src/services/variables.ts index c74ec5295..9bf807522 100644 --- a/src/services/variables.ts +++ b/src/services/variables.ts @@ -11,6 +11,10 @@ export interface AdHocFieldValue { value?: string; parser?: ParserType; } +export interface AppliedPattern { + pattern: string; + type: 'include' | 'exclude'; +} export type ParserType = 'logfmt' | 'json' | 'mixed' | 'structuredMetadata'; export type DetectedFieldType = 'int' | 'float' | 'duration' | 'bytes' | 'boolean' | 'string';