Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Link extensions: add pattern filter support #1036

Merged
merged 13 commits into from
Feb 18, 2025
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions src/Components/IndexScene/IndexScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
SceneVariableSet,
} from '@grafana/scenes';
import {
AppliedPattern,
AdHocFiltersWithLabelsAndMeta,
EXPLORATION_DS,
MIXED_FORMAT_EXPR,
Expand Down Expand Up @@ -66,7 +67,6 @@ import {
renderLogQLLabelFilters,
renderLogQLLineFilter,
renderLogQLMetadataFilters,
renderPatternFilters,
} from 'services/query';
import { VariableHide } from '@grafana/schema';
import { CustomConstantVariable } from '../../services/CustomConstantVariable';
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Components/IndexScene/PatternControls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
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(() => {
Expand Down Expand Up @@ -36,7 +36,7 @@
const onRemove = jest.fn();
render(<PatternControls patterns={[{ pattern: patterns[0], type: 'include' }]} onRemove={onRemove} />);

await act(() => userEvent.click(screen.getByLabelText('Remove pattern')));

Check warning on line 39 in src/Components/IndexScene/PatternControls.test.tsx

View workflow job for this annotation

GitHub Actions / build

'act' is deprecated. https://react.dev/warnings/react-dom-test-utils
expect(onRemove).toHaveBeenCalledTimes(1);
});

Expand Down
2 changes: 1 addition & 1 deletion src/Components/IndexScene/PatternControls.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/Components/IndexScene/VariableLayoutScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<VariableLayoutSceneState> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down
80 changes: 78 additions & 2 deletions src/services/extensions/links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,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', () => {
Expand Down Expand Up @@ -793,12 +865,16 @@ 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 ?? ''
}`;
}

/**
Expand Down
24 changes: 23 additions & 1 deletion src/services/extensions/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PluginExtensionLinkConfig, PluginExtensionPanelContext, PluginExtension
import {
addAdHocFilterUserInputPrefix,
AdHocFieldValue,
AppliedPattern,
LEVEL_VARIABLE_VALUE,
SERVICE_NAME,
stripAdHocFilterUserInputPrefix,
Expand All @@ -13,13 +14,16 @@ import {
VAR_LEVELS,
VAR_LINE_FILTERS,
VAR_METADATA,
VAR_PATTERNS,
} from 'services/variables';
import pluginJson from '../../plugin.json';
import { getMatcherFromQuery } from '../logqlMatchers';
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';
Expand Down Expand Up @@ -88,7 +92,7 @@ function contextToLink<T extends PluginExtensionPanelContext>(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
Expand Down Expand Up @@ -163,6 +167,22 @@ function contextToLink<T extends PluginExtensionPanelContext>(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),
};
Expand All @@ -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];

Expand Down
13 changes: 13 additions & 0 deletions src/services/extensions/scenesMethods.ts
Original file line number Diff line number Diff line change
@@ -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, '\\"');
}
10 changes: 10 additions & 0 deletions src/services/filterTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,23 @@ export type LineFilterType = {
value: string;
};

export type PatternFilterType = {
operator: PatternFilterOp;
value: string;
};

export enum LineFilterOp {
match = '|=',
negativeMatch = `!=`,
regex = '|~',
negativeRegex = `!~`,
}

export enum PatternFilterOp {
match = '|>',
negativeMatch = '!>',
}

export enum LineFilterCaseSensitive {
caseSensitive = 'caseSensitive',
caseInsensitive = 'caseInsensitive',
Expand Down
Loading
Loading