Skip to content

Commit 293c807

Browse files
add quick level filter, which allows filtering logs by level according to Log Level Rules and the base level field
1 parent 04512f4 commit 293c807

File tree

12 files changed

+437
-24
lines changed

12 files changed

+437
-24
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* FEATURE: fetch tenants from the VictoriaLogs backend and allow selecting a tenant in the datasource settings. See [#475](https://github.com/VictoriaMetrics/victorialogs-datasource/issues/475).
66
* FEATURE: enable client side caching and make reliable behavior in QueryBuilder filters. See [this issue](https://github.com/VictoriaMetrics/victorialogs-datasource/issues/357).
77
* FEATURE: add compatibility with Grafana 10.x and 11.x by using dynamic component loading for Combobox.
8+
* FEATURE: add quick level filter, which allows filtering logs by level according to `Log Level Rules` and the base level field. It is the first part of the [issue #108](https://github.com/VictoriaMetrics/victorialogs-datasource/issues/108). See [pr #495](https://github.com/VictoriaMetrics/victorialogs-datasource/pull/495).
89

910
## v0.22.4
1011

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@
3434
"@testing-library/jest-dom": "6.4.6",
3535
"@testing-library/react": "16.0.0",
3636
"@types/jest": "^29.5.14",
37-
"@types/lodash": "^4.17.5",
37+
"@types/lodash": "^4.17.21",
3838
"@types/node": "^20.14.9",
3939
"@types/react": "^19.2.2",
40+
"@types/semver": "^7.7.0",
4041
"@types/testing-library__jest-dom": "6.0.0",
4142
"@typescript-eslint/eslint-plugin": "^8.26.1",
4243
"@typescript-eslint/parser": "^8.26.1",
@@ -88,7 +89,7 @@
8889
"@grafana/ui": "12.2.0",
8990
"@lezer/common": "^1.2.1",
9091
"@lezer/lr": "^1.4.1",
91-
"@types/semver": "^7.7.0",
92+
"lodash": "^4.17.21",
9293
"react": "18.3.1",
9394
"react-dom": "18.3.1",
9495
"rxjs": "^7.8.2",

src/components/QueryEditor/EditorHeader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
1919
display: 'flex',
2020
flexWrap: 'wrap',
2121
alignItems: 'center',
22-
justifyContent: 'flex-end',
22+
justifyContent: 'space-between',
2323
gap: theme.spacing(1),
2424
minHeight: theme.spacing(4),
2525
}),

src/components/QueryEditor/QueryEditor.tsx

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import { isEqual } from 'lodash';
33
import React, { useCallback, useEffect, useMemo, useState } from 'react';
44

55
import { CoreApp, GrafanaTheme2, LoadingState } from '@grafana/data';
6-
import { Button, ConfirmModal, useStyles2 } from '@grafana/ui';
6+
import { Button, ConfirmModal, Stack, useStyles2 } from '@grafana/ui';
77

88
import { getQueryExprVariableRegExp } from "../../LogsQL/regExpOperator";
99
import { isExprHasStatsPipeFunctions } from "../../LogsQL/statsPipeFunctions";
10+
import { LevelQueryFilter } from "../../configuration/LogLevelRules/LevelQueryFilter/LeveQueryFilter";
1011
import { storeKeys } from "../../store/constants";
1112
import store from "../../store/store";
1213
import { Query, QueryEditorMode, QueryType, VictoriaLogsQueryEditorProps } from "../../types";
@@ -90,25 +91,29 @@ const QueryEditor = React.memo<VictoriaLogsQueryEditorProps>((props) => {
9091
/>
9192
<div className={styles.wrapper}>
9293
<EditorHeader>
93-
{showStatsWarn && (<QueryEditorStatsWarn queryType={query.queryType}/>)}
94-
<QueryEditorHelp />
95-
<VmuiLink
96-
query={query}
97-
panelData={data}
98-
datasource={datasource}
99-
/>
100-
<QueryEditorModeToggle mode={editorMode} onChange={onEditorModeChange}/>
101-
{app !== CoreApp.Explore && app !== CoreApp.Correlations && (
102-
<Button
103-
variant={dataIsStale ? 'primary' : 'secondary'}
104-
size="sm"
105-
onClick={onRunQuery}
106-
icon={data?.state === LoadingState.Loading ? 'fa fa-spinner' : undefined}
107-
disabled={data?.state === LoadingState.Loading}
108-
>
109-
{queries && queries.length > 1 ? `Run queries` : `Run query`}
110-
</Button>
111-
)}
94+
{app === CoreApp.Explore &&
95+
<LevelQueryFilter logLevelRules={datasource.logLevelRules} query={query} onChange={onChange}/>}
96+
<Stack direction={"row"} justifyContent={"flex-end"} alignItems={"center"} >
97+
{showStatsWarn && (<QueryEditorStatsWarn queryType={query.queryType}/>)}
98+
<QueryEditorHelp/>
99+
<VmuiLink
100+
query={query}
101+
panelData={data}
102+
datasource={datasource}
103+
/>
104+
<QueryEditorModeToggle mode={editorMode} onChange={onEditorModeChange}/>
105+
{app !== CoreApp.Explore && app !== CoreApp.Correlations && (
106+
<Button
107+
variant={dataIsStale ? 'primary' : 'secondary'}
108+
size="sm"
109+
onClick={onRunQuery}
110+
icon={data?.state === LoadingState.Loading ? 'fa fa-spinner' : undefined}
111+
disabled={data?.state === LoadingState.Loading}
112+
>
113+
{queries && queries.length > 1 ? `Run queries` : `Run query`}
114+
</Button>
115+
)}
116+
</Stack>
112117
</EditorHeader>
113118
<div className="flex-grow-1">
114119
{editorMode === QueryEditorMode.Builder ? (
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export interface QueryHints {
2+
sections: QueryHintSection[];
3+
}
4+
5+
export interface QueryHintSection {
6+
title: string;
7+
hints: QueryHint[];
8+
}
9+
10+
export interface QueryHint {
11+
title: string;
12+
queryExpr: string;
13+
description?: string;
14+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { groupBy } from 'lodash';
2+
import { useMemo } from "react";
3+
4+
import { LogLevel } from "@grafana/data";
5+
6+
import {
7+
OperatorLabels,
8+
possibleLogValueByLevelType,
9+
UNIQ_LOG_LEVEL,
10+
UniqLogLevelKeys
11+
} from "../../../../configuration/LogLevelRules/const";
12+
import { LogLevelRule } from "../../../../configuration/LogLevelRules/types";
13+
14+
import { QueryHint, QueryHintSection } from "./types";
15+
16+
export const useLevelQueryHintSection = (levelRules: LogLevelRule[]): QueryHintSection => {
17+
return useMemo(() => {
18+
const enabledLevelRules = levelRules.filter(rule => rule.enabled);
19+
const groupedByLevelRules = groupBy(enabledLevelRules, 'level');
20+
const levelFilters = Object
21+
.values(UNIQ_LOG_LEVEL)
22+
.filter(val => val !== LogLevel.unknown)
23+
.reduce((acc, logLevel) => {
24+
acc[logLevel] = groupedByLevelRules[logLevel] || [];
25+
return acc;
26+
}, {} as Record<UniqLogLevelKeys, LogLevelRule[]>);
27+
28+
const hints = Object
29+
.entries(levelFilters)
30+
.map(([ruleLevel, rule]): QueryHint => {
31+
const levelKey = ruleLevel as UniqLogLevelKeys;
32+
const queryExprByRules = rule.map(r => `${r.field}:${OperatorLabels[r.operator]}"${r.value}"`);
33+
const possibleLevelValues = possibleLogValueByLevelType[levelKey].map(value => `"${value}"`).join(',');
34+
const queryExprByLevel = `level:contains_common_case(${possibleLevelValues})`;
35+
const queryParts = [queryExprByLevel];
36+
if (queryExprByRules.length > 0) {
37+
queryParts.push(...queryExprByRules);
38+
}
39+
const expr = queryParts.join(' OR ');
40+
return {
41+
title: levelKey,
42+
queryExpr: expr
43+
};
44+
});
45+
46+
hints.push({
47+
title: LogLevel.unknown,
48+
queryExpr: `!(${hints.map(hint => hint.queryExpr).join(' OR ')})`
49+
});
50+
51+
return {
52+
title: 'Filter by log level',
53+
hints
54+
};
55+
}, [levelRules]);
56+
};
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import React, { MouseEvent, useCallback, useMemo } from "react";
2+
3+
4+
import { LogLevel } from "@grafana/data";
5+
import { Stack } from "@grafana/ui";
6+
7+
import { useLevelQueryHintSection } from "../../../components/QueryEditor/QueryHints/hints/useLevelQueryHintSection";
8+
import { Query } from "../../../types";
9+
import { LogLevelRule } from "../types";
10+
11+
import { LevelFilterButton } from "./LevelFilterButton";
12+
import { buildQueryExprWithLevelFilters } from "./utils";
13+
14+
15+
interface Props {
16+
logLevelRules: LogLevelRule[];
17+
query: Query;
18+
onChange: (value: Query) => void;
19+
}
20+
21+
export const LevelQueryFilter = ({ logLevelRules, query, onChange }: Props) => {
22+
const levelQueryHintSection = useLevelQueryHintSection(logLevelRules);
23+
24+
const unknownLevelFilter = useMemo(() => levelQueryHintSection.hints.find(hint => hint.title === LogLevel.unknown), [levelQueryHintSection]);
25+
const isQueryContainUnknowFilter = unknownLevelFilter && query.expr.includes(unknownLevelFilter.queryExpr);
26+
27+
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>, levelQueryExpr: string, title: string) => {
28+
const isShiftPressed = e.shiftKey;
29+
const isUnknownFilter = title === LogLevel.unknown;
30+
const queryExpr = buildQueryExprWithLevelFilters({
31+
queryExpr: query.expr,
32+
levelQueryExpr,
33+
isShiftPressed,
34+
isQueryContainUnknowFilter,
35+
isUnknownFilter
36+
})
37+
onChange({ ...query, expr: queryExpr });
38+
}, [isQueryContainUnknowFilter, onChange, query]);
39+
40+
return (
41+
<Stack direction={"row"} justifyContent={"flex-start"} alignItems={"center"} wrap={"wrap"}>
42+
{levelQueryHintSection.hints.map(({ title, queryExpr }) => {
43+
const isNegativeStart = query.expr.startsWith('!(');
44+
const isSelected = (isNegativeStart && title === LogLevel.unknown)
45+
|| (!isNegativeStart && query.expr.includes(queryExpr));
46+
return (
47+
<LevelFilterButton
48+
key={title}
49+
onClick={(e: MouseEvent<HTMLButtonElement>) => handleClick(e, queryExpr, title)}
50+
level={title as LogLevel}
51+
label={title}
52+
isSelected={isSelected}
53+
/>
54+
)
55+
})}
56+
</Stack>
57+
);
58+
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { css } from "@emotion/css";
2+
import React, { MouseEvent } from "react";
3+
4+
import { LogLevel } from "@grafana/data";
5+
import { Button, Stack, useStyles2 } from "@grafana/ui";
6+
7+
import { LOG_LEVEL_COLOR } from "../const";
8+
9+
export interface LevelFilterButtonProps {
10+
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
11+
label: string;
12+
level: LogLevel;
13+
isSelected?: boolean;
14+
}
15+
16+
export const LevelFilterButton = ({ onClick, label, level, isSelected }: LevelFilterButtonProps) => {
17+
const styles = useStyles2(getStyles);
18+
return (
19+
<Stack direction={"row"}>
20+
<Button
21+
onClick={onClick}
22+
variant={"secondary"}
23+
size={"sm"}
24+
type={"button"}
25+
style={{ opacity: isSelected ? 1 : 0.5, userSelect: 'none' }}
26+
title={"Use 'shift' to select several levels"}
27+
>
28+
<div className={styles.colorCircle} style={{ backgroundColor: getLogLevelColor(level) }}/>
29+
{label}
30+
</Button>
31+
</Stack>
32+
);
33+
};
34+
35+
const getLogLevelColor = (level: LogLevel): string => {
36+
return LOG_LEVEL_COLOR[level] || LOG_LEVEL_COLOR[LogLevel.unknown];
37+
};
38+
39+
const getStyles = () => {
40+
return {
41+
colorCircle: css({
42+
width: 12,
43+
height: 12,
44+
borderRadius: '50%',
45+
marginRight: 5,
46+
}),
47+
};
48+
};
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { buildQueryExprWithLevelFilters } from './utils';
2+
3+
describe('buildQueryExprWithLevelFilters', () => {
4+
it('should return levelQueryExpr if queryExpr is empty', () => {
5+
const result = buildQueryExprWithLevelFilters({
6+
queryExpr: '',
7+
levelQueryExpr: 'level: info',
8+
isUnknownFilter: false,
9+
isShiftPressed: false,
10+
});
11+
expect(result).toBe('level: info');
12+
});
13+
14+
it('should handle queries without pipes and add the level query', () => {
15+
const result = buildQueryExprWithLevelFilters({
16+
queryExpr: 'filterName1: filterValue1',
17+
levelQueryExpr: 'level: error',
18+
isUnknownFilter: false,
19+
isShiftPressed: false,
20+
});
21+
expect(result).toBe('level: error | filterName1: filterValue1');
22+
});
23+
24+
it('should prepend the level query if a level filter already exists and shift is pressed', () => {
25+
const result = buildQueryExprWithLevelFilters({
26+
queryExpr: 'filterName1: filterValue1 level: warning',
27+
levelQueryExpr: 'level: error',
28+
isUnknownFilter: false,
29+
isShiftPressed: true,
30+
});
31+
expect(result).toBe('level: error OR filterName1: filterValue1 level: warning');
32+
});
33+
34+
it('should replace the whole query if a level filter already exists and shift is not pressed', () => {
35+
const result = buildQueryExprWithLevelFilters({
36+
queryExpr: 'filterName1: filterValue1 level: warning',
37+
levelQueryExpr: 'level: error',
38+
isUnknownFilter: false,
39+
isShiftPressed: false,
40+
});
41+
expect(result).toBe('level: error');
42+
});
43+
44+
it('should append the level query when multiple filters are piped and no level filter exists', () => {
45+
const result = buildQueryExprWithLevelFilters({
46+
queryExpr: 'filterName1: filterValue1 | filterName2: filterValue2',
47+
levelQueryExpr: 'level: debug',
48+
isUnknownFilter: false,
49+
isShiftPressed: false,
50+
});
51+
expect(result).toBe('level: debug | filterName1: filterValue1 | filterName2: filterValue2');
52+
});
53+
54+
it('should handle unknown filters appropriately if isUnknownFilter is true', () => {
55+
const result = buildQueryExprWithLevelFilters({
56+
queryExpr: 'filterName1: filterValue1',
57+
levelQueryExpr: 'level: unknown',
58+
isUnknownFilter: true,
59+
isShiftPressed: false,
60+
});
61+
expect(result).toBe('level: unknown | filterName1: filterValue1');
62+
});
63+
64+
65+
it('should replace current level filter by unknown filter even if the shift is pressed', () => {
66+
const result = buildQueryExprWithLevelFilters({
67+
queryExpr: 'level: info',
68+
levelQueryExpr: 'level: unknown',
69+
isUnknownFilter: true,
70+
isShiftPressed: true,
71+
});
72+
expect(result).toBe('level: unknown');
73+
});
74+
});

0 commit comments

Comments
 (0)