diff --git a/src/Components/ServiceScene/LineFilterIcon.tsx b/src/Components/ServiceScene/LineFilterIconButton.tsx
similarity index 62%
rename from src/Components/ServiceScene/LineFilterIcon.tsx
rename to src/Components/ServiceScene/LineFilterIconButton.tsx
index 4f9fa6ca..5bca56a4 100644
--- a/src/Components/ServiceScene/LineFilterIcon.tsx
+++ b/src/Components/ServiceScene/LineFilterIconButton.tsx
@@ -8,26 +8,23 @@ interface Props {
caseSensitive: boolean;
}
-export const LineFilterIcon = (props: Props) => {
+export const LineFilterIconButton = (props: Props) => {
const theme = useTheme2();
const fill = props.caseSensitive ? theme.colors.text.maxContrast : theme.colors.text.disabled;
const styles = getStyles(theme, fill);
return (
-
-
+
);
};
@@ -38,6 +35,10 @@ const getStyles = (theme: GrafanaTheme2, fill: string) => {
justifyContent: 'center',
marginLeft: theme.spacing.x0_5,
cursor: 'pointer',
+ appearance: 'none',
+ border: 'none',
+ background: 'none',
+ padding: 0,
}),
};
};
diff --git a/src/Components/ServiceScene/LineFilterScene.test.tsx b/src/Components/ServiceScene/LineFilterScene.test.tsx
index 3cf30a2e..b9b93b03 100644
--- a/src/Components/ServiceScene/LineFilterScene.test.tsx
+++ b/src/Components/ServiceScene/LineFilterScene.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { act, render, screen } from '@testing-library/react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
import { LineFilterScene } from './LineFilterScene';
import userEvent from '@testing-library/user-event';
import { CustomVariable, SceneVariableSet } from '@grafana/scenes';
@@ -14,38 +14,169 @@ jest.mock('lodash', () => ({
describe('LineFilter', () => {
let scene: LineFilterScene;
let lineFilterVariable: CustomVariable;
- beforeEach(() => {
- lineFilterVariable = new CustomVariable({ name: VAR_LINE_FILTER, value: '', hide: VariableHide.hideVariable });
- scene = new LineFilterScene({
- $variables: new SceneVariableSet({
- variables: [lineFilterVariable],
- }),
+
+ describe('case insensitive, no regex', () => {
+ beforeEach(() => {
+ lineFilterVariable = new CustomVariable({ name: VAR_LINE_FILTER, value: '', hide: VariableHide.hideVariable });
+ scene = new LineFilterScene({
+ caseSensitive: false,
+ $variables: new SceneVariableSet({
+ variables: [lineFilterVariable],
+ }),
+ });
+ });
+
+ test('Updates the variable with the user input', async () => {
+ render();
+
+ await act(() => userEvent.type(screen.getByPlaceholderText('Search in log lines'), 'some text'));
+
+ expect(await screen.findByDisplayValue('some text')).toBeInTheDocument();
+ expect(lineFilterVariable.getValue()).toBe('|~ `(?i)some text`');
+ });
+
+ test('Escapes the regular expression in the variable', async () => {
+ render();
+
+ await act(() => userEvent.type(screen.getByPlaceholderText('Search in log lines'), '(characters'));
+
+ expect(await screen.findByDisplayValue('(characters')).toBeInTheDocument();
+ expect(lineFilterVariable.getValue()).toBe('|~ `(?i)\\(characters`');
+ });
+
+ test('Unescapes the regular expression from the variable value', async () => {
+ lineFilterVariable.changeValueTo('|~ `(?i)\\(characters`');
+
+ render();
+
+ expect(await screen.findByDisplayValue('(characters')).toBeInTheDocument();
});
});
+ describe('case sensitive, no regex', () => {
+ beforeEach(() => {
+ lineFilterVariable = new CustomVariable({ name: VAR_LINE_FILTER, value: '', hide: VariableHide.hideVariable });
+ scene = new LineFilterScene({
+ caseSensitive: true,
+ $variables: new SceneVariableSet({
+ variables: [lineFilterVariable],
+ }),
+ });
+ });
+
+ test('Updates the variable with the user input', async () => {
+ render();
+
+ await act(() => userEvent.type(screen.getByPlaceholderText('Search in log lines'), 'some text'));
+
+ expect(await screen.findByDisplayValue('some text')).toBeInTheDocument();
+ expect(lineFilterVariable.getValue()).toBe('|= `some text`');
+ });
+
+ test('Escapes the regular expression in the variable', async () => {
+ render();
+
+ await act(() => userEvent.type(screen.getByPlaceholderText('Search in log lines'), '(characters'));
+
+ expect(await screen.findByDisplayValue('(characters')).toBeInTheDocument();
+ expect(lineFilterVariable.getValue()).toBe('|= `\\(characters`');
+ });
- test('Updates the variable with the user input', async () => {
- render();
+ test('Unescapes the regular expression from the variable value', async () => {
+ lineFilterVariable.changeValueTo('|~ `(?i)\\(characters`');
- await act(() => userEvent.type(screen.getByPlaceholderText('Search in log lines'), 'some text'));
+ render();
- expect(await screen.findByDisplayValue('some text')).toBeInTheDocument();
- expect(lineFilterVariable.getValue()).toBe('|~ `(?i)some text`');
+ expect(await screen.findByDisplayValue('(characters')).toBeInTheDocument();
+ });
});
+ describe('case insensitive, regex', () => {
+ beforeEach(() => {
+ lineFilterVariable = new CustomVariable({ name: VAR_LINE_FILTER, value: '', hide: VariableHide.hideVariable });
+ scene = new LineFilterScene({
+ caseSensitive: false,
+ regex: true,
+ $variables: new SceneVariableSet({
+ variables: [lineFilterVariable],
+ }),
+ });
+ });
+
+ test('Updates the variable with the user input', async () => {
+ render();
+
+ const string = `((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}`;
+ const input = screen.getByPlaceholderText('Search in log lines');
+ // Jest can't type regex apparently
+ await act(() => fireEvent.change(input, { target: { value: string } }));
+
+ expect(await screen.findByDisplayValue(string)).toBeInTheDocument();
+ expect(lineFilterVariable.getValue()).toBe(`|~ \`(?i)${string}\``);
+ });
- test('Escapes the regular expression in the variable', async () => {
- render();
+ test('Does not escape the regular expression', async () => {
+ render();
- await act(() => userEvent.type(screen.getByPlaceholderText('Search in log lines'), '(characters'));
+ const string = `((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}`;
+ const input = screen.getByPlaceholderText('Search in log lines');
+ // Jest can't type regex apparently
+ await act(() => fireEvent.change(input, { target: { value: string } }));
- expect(await screen.findByDisplayValue('(characters')).toBeInTheDocument();
- expect(lineFilterVariable.getValue()).toBe('|~ `(?i)\\(characters`');
+ expect(await screen.findByDisplayValue(string)).toBeInTheDocument();
+ expect(lineFilterVariable.getValue()).toBe(`|~ \`(?i)${string}\``);
+ });
+
+ test('Unescapes the regular expression from the variable value', async () => {
+ const string = `((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}`;
+ lineFilterVariable.changeValueTo(`|~ \`(?i)${string}\``);
+
+ render();
+
+ expect(await screen.findByDisplayValue(string)).toBeInTheDocument();
+ });
});
+ describe('case sensitive, regex', () => {
+ beforeEach(() => {
+ lineFilterVariable = new CustomVariable({ name: VAR_LINE_FILTER, value: '', hide: VariableHide.hideVariable });
+ scene = new LineFilterScene({
+ caseSensitive: true,
+ regex: true,
+ $variables: new SceneVariableSet({
+ variables: [lineFilterVariable],
+ }),
+ });
+ });
+
+ test('Updates the variable with the user input', async () => {
+ render();
+
+ const string = `((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}`;
+ const input = screen.getByPlaceholderText('Search in log lines');
+ // Jest can't type regex apparently
+ await act(() => fireEvent.change(input, { target: { value: string } }));
+
+ expect(await screen.findByDisplayValue(string)).toBeInTheDocument();
+ expect(lineFilterVariable.getValue()).toBe(`|~ \`${string}\``);
+ });
- test('Unescapes the regular expression from the variable value', async () => {
- lineFilterVariable.changeValueTo('|~ `(?i)\\(characters`');
+ test('Does not escape the regular expression', async () => {
+ render();
- render();
+ const string = `((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}`;
+ const input = screen.getByPlaceholderText('Search in log lines');
+ // Jest can't type regex apparently
+ await act(() => fireEvent.change(input, { target: { value: string } }));
- expect(await screen.findByDisplayValue('(characters')).toBeInTheDocument();
+ expect(await screen.findByDisplayValue(string)).toBeInTheDocument();
+ expect(lineFilterVariable.getValue()).toBe(`|~ \`${string}\``);
+ });
+
+ test('Unescapes the regular expression from the variable value', async () => {
+ const string = `((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}`;
+ lineFilterVariable.changeValueTo(`|~ \`${string}\``);
+
+ render();
+
+ expect(await screen.findByDisplayValue(string)).toBeInTheDocument();
+ });
});
});
diff --git a/src/Components/ServiceScene/LineFilterScene.tsx b/src/Components/ServiceScene/LineFilterScene.tsx
index e007a339..9266e679 100644
--- a/src/Components/ServiceScene/LineFilterScene.tsx
+++ b/src/Components/ServiceScene/LineFilterScene.tsx
@@ -1,18 +1,21 @@
import { css } from '@emotion/css';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { Field } from '@grafana/ui';
-import { debounce, escapeRegExp } from 'lodash';
+import { debounce, escape, escapeRegExp } from 'lodash';
import React, { ChangeEvent, KeyboardEvent } from 'react';
import { testIds } from 'services/testIds';
import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'services/analytics';
import { SearchInput } from './Breakdowns/SearchInput';
-import { LineFilterIcon } from './LineFilterIcon';
+import { LineFilterIconButton } from './LineFilterIconButton';
import { getLineFilterVariable } from '../../services/variableGetters';
-import { getLineFilterCase, setLineFilterCase } from '../../services/store';
+import { getLineFilterCase, getLineFilterRegex, setLineFilterCase, setLineFilterRegex } from '../../services/store';
+import { RegexIconButton, RegexInputValue } from './RegexIconButton';
+import { logger } from '../../services/logger';
interface LineFilterState extends SceneObjectState {
lineFilter: string;
caseSensitive: boolean;
+ regex: boolean;
}
export class LineFilterScene extends SceneObjectBase {
@@ -22,6 +25,7 @@ export class LineFilterScene extends SceneObjectBase {
super({
lineFilter: state?.lineFilter || '',
caseSensitive: getLineFilterCase(false),
+ regex: getLineFilterRegex(false),
...state,
});
this.addActivationHandler(this.onActivate);
@@ -34,15 +38,42 @@ export class LineFilterScene extends SceneObjectBase {
return;
}
const caseSensitive = lineFilterString.includes('|=');
- const matches = caseSensitive ? lineFilterString.match(/\|=.`(.+?)`/) : lineFilterString.match(/`\(\?i\)(.+)`/);
- if (!matches || matches.length !== 2) {
+ const caseSensitiveMatches = caseSensitive
+ ? lineFilterString.match(/\|=.`(.+?)`/)
+ : lineFilterString.match(/`\(\?i\)(.+)`/);
+
+ // If the existing query is case sensitive, overwrite the users options for case sensitivity
+ if (caseSensitiveMatches && caseSensitiveMatches.length === 2) {
+ // If the current state is not regex, remove escape chars
+ if (!this.state.regex) {
+ this.setState({
+ lineFilter: caseSensitiveMatches[1].replace(/\\(.)/g, '$1'),
+ caseSensitive,
+ });
+ return;
+ } else {
+ // If regex, don't remove escape chars
+ this.setState({
+ lineFilter: caseSensitiveMatches[1],
+ caseSensitive,
+ });
+ }
return;
}
- this.setState({
- lineFilter: matches[1].replace(/\\(.)/g, '$1'),
- caseSensitive,
- });
+
+ const regexMatches = lineFilterString.match(/\|~.+\`(.*?)\`/);
+ if (regexMatches?.length === 2) {
+ this.setState({
+ lineFilter: regexMatches[1],
+ });
+
+ return;
+ } else {
+ const error = new Error(`Unable to parse line filter: ${lineFilterString}`);
+ logger.error(error, { msg: `Unable to parse line filter: ${lineFilterString}` });
+ throw error;
+ }
};
updateFilter(lineFilter: string, debounced = true) {
@@ -80,6 +111,20 @@ export class LineFilterScene extends SceneObjectBase {
this.updateFilter(this.state.lineFilter, false);
};
+ onRegexToggle = (newState: RegexInputValue) => {
+ const regex = newState === 'regex';
+
+ // Set value to scene state
+ this.setState({
+ regex,
+ });
+
+ // Set value in local storage
+ setLineFilterRegex(regex);
+
+ this.updateFilter(this.state.lineFilter, false);
+ };
+
updateVariableDebounced = debounce((search: string) => {
this.updateVariable(search);
}, 1000);
@@ -89,8 +134,12 @@ export class LineFilterScene extends SceneObjectBase {
if (search === '') {
variable.changeValueTo('');
} else {
- if (this.state.caseSensitive) {
+ if (this.state.caseSensitive && !this.state.regex) {
variable.changeValueTo(`|= \`${escapeRegExp(search)}\``);
+ } else if (this.state.caseSensitive && this.state.regex) {
+ variable.changeValueTo(`|~ \`${escape(search)}\``);
+ } else if (!this.state.caseSensitive && this.state.regex) {
+ variable.changeValueTo(`|~ \`(?i)${escape(search)}\``);
} else {
variable.changeValueTo(`|~ \`(?i)${escapeRegExp(search)}\``);
}
@@ -108,7 +157,7 @@ export class LineFilterScene extends SceneObjectBase {
}
function LineFilterRenderer({ model }: SceneComponentProps) {
- const { lineFilter, caseSensitive } = model.useState();
+ const { lineFilter, caseSensitive, regex } = model.useState();
return (
) {
value={lineFilter}
className={styles.input}
onChange={model.handleChange}
- suffix={}
+ suffix={
+ <>
+
+
+ >
+ }
placeholder="Search in log lines"
onClear={() => {
model.updateFilter('', false);
diff --git a/src/Components/ServiceScene/RegexIconButton.tsx b/src/Components/ServiceScene/RegexIconButton.tsx
new file mode 100644
index 00000000..192bc37c
--- /dev/null
+++ b/src/Components/ServiceScene/RegexIconButton.tsx
@@ -0,0 +1,45 @@
+import { useTheme2 } from '@grafana/ui';
+import React from 'react';
+import { GrafanaTheme2 } from '@grafana/data';
+import { css } from '@emotion/css';
+
+export type RegexInputValue = 'regex' | 'match';
+interface Props {
+ onRegexToggle: (state: RegexInputValue) => void;
+ regex: boolean;
+}
+
+export const RegexIconButton = (props: Props) => {
+ const theme = useTheme2();
+ const fill = props.regex ? theme.colors.text.maxContrast : theme.colors.text.disabled;
+ const styles = getStyles(theme, fill);
+
+ return (
+
+ );
+};
+
+const getStyles = (theme: GrafanaTheme2, fill: string) => {
+ return {
+ container: css({
+ display: 'flex',
+ justifyContent: 'center',
+ marginLeft: theme.spacing.x0_5,
+ cursor: 'pointer',
+ appearance: 'none',
+ border: 'none',
+ background: 'none',
+ padding: 0,
+ }),
+ };
+};
diff --git a/src/services/store.ts b/src/services/store.ts
index 7b0b9be6..0fd52b4e 100644
--- a/src/services/store.ts
+++ b/src/services/store.ts
@@ -247,11 +247,25 @@ export function setLineFilterCase(caseSensitive: boolean) {
localStorage.setItem(`${LINE_FILTER_OPTIONS_LOCALSTORAGE_KEY}.caseSensitive`, storedValue);
}
+export function setLineFilterRegex(regex: boolean) {
+ let storedValue = regex.toString();
+ if (!regex) {
+ storedValue = '';
+ }
+
+ localStorage.setItem(`${LINE_FILTER_OPTIONS_LOCALSTORAGE_KEY}.regex`, storedValue);
+}
+
export function getLineFilterCase(defaultValue: boolean): boolean {
const storedValue = localStorage.getItem(`${LINE_FILTER_OPTIONS_LOCALSTORAGE_KEY}.caseSensitive`);
return storedValue === 'true' ? true : defaultValue;
}
+export function getLineFilterRegex(defaultValue: boolean): boolean {
+ const storedValue = localStorage.getItem(`${LINE_FILTER_OPTIONS_LOCALSTORAGE_KEY}.regex`);
+ return storedValue === 'true' ? true : defaultValue;
+}
+
// Panel options
const PANEL_OPTIONS_LOCALSTORAGE_KEY = `${pluginJson.id}.panel.option`;
export interface PanelOptions {