Skip to content

Commit bec52c3

Browse files
feat: improved the alert rules list search functionality (#8075)
* feat: improved the alert rules list search functionality * feat: improvements and tooltip added for more info * feat: style improvement * feat: style improvement * feat: style improvement
1 parent 0a6a7ba commit bec52c3

File tree

4 files changed

+331
-25
lines changed

4 files changed

+331
-25
lines changed

frontend/src/container/ListAlertRules/ListAlert.tsx

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable react/display-name */
2-
import { PlusOutlined } from '@ant-design/icons';
3-
import { Flex, Input, Typography } from 'antd';
2+
import { InfoCircleOutlined, PlusOutlined } from '@ant-design/icons';
3+
import { Flex, Input, Tooltip, Typography } from 'antd';
44
import type { ColumnsType } from 'antd/es/table/interface';
55
import saveAlertApi from 'api/alerts/save';
66
import logEvent from 'api/common/logEvent';
@@ -175,6 +175,41 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
175175
setData(filteredData);
176176
});
177177

178+
const searchTooltipContent = (
179+
<div style={{ maxWidth: 400 }}>
180+
<div style={{ marginBottom: 8, fontWeight: 'bold' }}>Search Options:</div>
181+
<div style={{ marginBottom: 4 }}>
182+
<strong>Plain text:</strong> Search across all fields
183+
</div>
184+
<div style={{ marginBottom: 4 }}>
185+
<strong>Key:value pairs:</strong> Specific field matching
186+
</div>
187+
<div style={{ marginBottom: 4 }}>
188+
<strong>Multiple terms:</strong> All terms must match (AND logic)
189+
</div>
190+
<div style={{ marginBottom: 8 }}>
191+
<strong>Status mapping:</strong> Use &quot;ok&quot; for inactive alerts
192+
</div>
193+
<div style={{ marginBottom: 8, fontWeight: 'bold' }}>Examples:</div>
194+
<div style={{ marginBottom: 4 }}>
195+
<code>cpu warning</code> - Find alerts with both &quot;cpu&quot; and
196+
&quot;warning&quot;
197+
</div>
198+
<div style={{ marginBottom: 4 }}>
199+
<code>status:ok</code> - Find alerts with OK status
200+
</div>
201+
<div style={{ marginBottom: 4 }}>
202+
<code>severity:critical</code> - Find critical alerts
203+
</div>
204+
<div style={{ marginBottom: 4 }}>
205+
<code>cluster:prod</code> - Find alerts with cluster=prod label
206+
</div>
207+
<div>
208+
<code>status:ok cpu</code> - Find OK alerts containing &quot;cpu&quot;
209+
</div>
210+
</div>
211+
);
212+
178213
const dynamicColumns: ColumnsType<GettableAlert> = [
179214
{
180215
title: 'Created At',
@@ -344,11 +379,22 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
344379
return (
345380
<>
346381
<SearchContainer>
347-
<Search
348-
placeholder="Search by Alert Name, Severity and Labels"
349-
onChange={handleSearch}
350-
defaultValue={searchString}
351-
/>
382+
<div className="search-container">
383+
<Search
384+
placeholder="Search by name, status, severity, labels or key:value or chaining (e.g. 'status:ok cpu warning')"
385+
onChange={handleSearch}
386+
defaultValue={searchString}
387+
prefix={
388+
<Flex align="center" gap={8}>
389+
<Tooltip title={searchTooltipContent} placement="bottomRight">
390+
<InfoCircleOutlined className="search-tooltip" />
391+
</Tooltip>
392+
<div className="search-divider" />
393+
</Flex>
394+
}
395+
/>
396+
</div>
397+
352398
<Flex gap={12}>
353399
{addNewAlert && (
354400
<Button

frontend/src/container/ListAlertRules/styles.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@ export const SearchContainer = styled.div`
88
align-items: center;
99
gap: 2rem;
1010
}
11+
.search-container {
12+
width: 100%;
13+
display: flex;
14+
align-items: center;
15+
gap: 12px;
16+
17+
.search-tooltip {
18+
color: var(--bg-robin-500);
19+
cursor: help;
20+
}
21+
22+
.search-divider {
23+
height: 16px;
24+
margin: 0;
25+
border-left: 1px solid var(--bg-slate-100);
26+
margin-right: 6px;
27+
}
28+
}
1129
`;
1230

1331
export const Button = styled(ButtonComponent)`
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/* eslint-disable sonarjs/no-duplicate-string */
2+
import { PANEL_TYPES } from 'constants/queryBuilder';
3+
import { GettableAlert } from 'types/api/alerts/get';
4+
import { EQueryType } from 'types/common/dashboard';
5+
6+
import { filterAlerts } from './utils';
7+
8+
const testLabels = { severity: 'warning', cluster: 'prod', test: 'value' };
9+
10+
const baseAlert: GettableAlert = {
11+
id: '1',
12+
alert: 'CPU Usage High',
13+
state: 'inactive',
14+
disabled: false,
15+
createAt: '',
16+
createBy: '',
17+
updateAt: '',
18+
updateBy: '',
19+
alertType: 'type',
20+
ruleType: 'rule',
21+
frequency: '1m',
22+
condition: {
23+
compositeQuery: {
24+
builderQueries: {},
25+
promQueries: {},
26+
chQueries: {},
27+
queryType: EQueryType.QUERY_BUILDER,
28+
panelType: PANEL_TYPES.TABLE,
29+
unit: '',
30+
},
31+
},
32+
labels: testLabels,
33+
annotations: {},
34+
evalWindow: '',
35+
source: '',
36+
preferredChannels: [],
37+
broadcastToAll: false,
38+
version: '',
39+
};
40+
41+
const alerts: GettableAlert[] = [
42+
{
43+
...baseAlert,
44+
id: '1',
45+
alert: 'CPU Usage High',
46+
state: 'inactive',
47+
labels: testLabels,
48+
},
49+
{
50+
...baseAlert,
51+
id: '2',
52+
alert: 'Memory Usage',
53+
state: 'firing',
54+
labels: { severity: 'critical', cluster: 'dev', test: 'other' },
55+
},
56+
{
57+
...baseAlert,
58+
id: '3',
59+
alert: 'Disk IO',
60+
state: 'pending',
61+
labels: testLabels,
62+
},
63+
{
64+
...baseAlert,
65+
id: '4',
66+
alert: 'Network Latency',
67+
state: 'disabled',
68+
labels: { severity: 'info', cluster: 'qa', test: 'value' },
69+
},
70+
];
71+
72+
describe('filterAlerts', () => {
73+
it('returns all alerts if filter is empty', () => {
74+
expect(filterAlerts(alerts, '')).toHaveLength(alerts.length);
75+
});
76+
77+
it('matches by alert name (case-insensitive)', () => {
78+
const result = filterAlerts(alerts, 'cpu usage');
79+
expect(result).toHaveLength(1);
80+
expect(result[0].alert).toBe('CPU Usage High');
81+
});
82+
83+
it('matches by severity', () => {
84+
const result = filterAlerts(alerts, 'warning');
85+
expect(result.map((a) => a.id)).toEqual(['1', '3']);
86+
});
87+
88+
it('matches by label key or value', () => {
89+
const result = filterAlerts(alerts, 'prod');
90+
expect(result.map((a) => a.id)).toEqual(['1', '3']);
91+
});
92+
93+
it('matches by multi-word AND search', () => {
94+
const result = filterAlerts(alerts, 'cpu prod');
95+
expect(result.map((a) => a.id)).toEqual(['1']);
96+
});
97+
98+
it('matches by key:value (label)', () => {
99+
const result = filterAlerts(alerts, 'test:value');
100+
expect(result.map((a) => a.id)).toEqual(['1', '3', '4']);
101+
});
102+
103+
it('matches by key: value (label, with space)', () => {
104+
const result = filterAlerts(alerts, 'test: value');
105+
expect(result.map((a) => a.id)).toEqual(['1', '3', '4']);
106+
});
107+
108+
it('matches by key:value (severity)', () => {
109+
const result = filterAlerts(alerts, 'severity:warning');
110+
expect(result.map((a) => a.id)).toEqual(['1', '3']);
111+
});
112+
113+
it('matches by key:value (status:ok)', () => {
114+
const result = filterAlerts(alerts, 'status:ok');
115+
expect(result.map((a) => a.id)).toEqual(['1']);
116+
});
117+
118+
it('matches by key:value (status:inactive)', () => {
119+
const result = filterAlerts(alerts, 'status:inactive');
120+
expect(result.map((a) => a.id)).toEqual(['1']);
121+
});
122+
123+
it('matches by key:value (status:firing)', () => {
124+
const result = filterAlerts(alerts, 'status:firing');
125+
expect(result.map((a) => a.id)).toEqual(['2']);
126+
});
127+
128+
it('matches by key:value (status:pending)', () => {
129+
const result = filterAlerts(alerts, 'status:pending');
130+
expect(result.map((a) => a.id)).toEqual(['3']);
131+
});
132+
133+
it('matches by key:value (status:disabled)', () => {
134+
const result = filterAlerts(alerts, 'status:disabled');
135+
expect(result.map((a) => a.id)).toEqual(['4']);
136+
});
137+
138+
it('matches by key:value (cluster:prod)', () => {
139+
const result = filterAlerts(alerts, 'cluster:prod');
140+
expect(result.map((a) => a.id)).toEqual(['1', '3']);
141+
});
142+
143+
it('matches by key:value (cluster:dev)', () => {
144+
const result = filterAlerts(alerts, 'cluster:dev');
145+
expect(result.map((a) => a.id)).toEqual(['2']);
146+
});
147+
148+
it('matches by key:value (case-insensitive)', () => {
149+
const result = filterAlerts(alerts, 'CLUSTER:PROD');
150+
expect(result.map((a) => a.id)).toEqual(['1', '3']);
151+
});
152+
153+
it('matches by combination of word and key:value', () => {
154+
const result = filterAlerts(alerts, 'cpu status:ok');
155+
expect(result.map((a) => a.id)).toEqual(['1']);
156+
});
157+
158+
it('returns empty if no match', () => {
159+
const result = filterAlerts(alerts, 'notfound');
160+
expect(result).toHaveLength(0);
161+
});
162+
});

frontend/src/container/ListAlertRules/utils.ts

Lines changed: 98 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,108 @@ import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
33
import { AlertTypes } from 'types/api/alerts/alertTypes';
44
import { GettableAlert } from 'types/api/alerts/get';
55

6+
/**
7+
* Parses key:value pairs from the filter string, allowing optional whitespace after the colon.
8+
*/
9+
function parseKeyValuePairs(
10+
filter: string,
11+
): { keyValuePairs: Record<string, string>; filterCopy: string } {
12+
// Allow optional whitespace after colon, and support more flexible values
13+
const keyValueRegex = /([\w-]+):\s*([^\s]+)/g;
14+
const keyValuePairs: Record<string, string> = {};
15+
let filterCopy = filter.toLowerCase();
16+
const matches = Array.from(filterCopy.matchAll(keyValueRegex));
17+
matches.forEach((match) => {
18+
const [, key, value] = match;
19+
keyValuePairs[key] = value.trim();
20+
filterCopy = filterCopy.replace(match[0], '');
21+
});
22+
return { keyValuePairs, filterCopy };
23+
}
24+
25+
const statusMap: Record<string, string> = {
26+
ok: 'inactive',
27+
inactive: 'inactive',
28+
pending: 'pending',
29+
firing: 'firing',
30+
disabled: 'disabled',
31+
};
32+
33+
/**
34+
* Returns true if the alert matches the search words and key-value pairs.
35+
*/
36+
function alertMatches(alert: GettableAlert, searchWords: string[]): boolean {
37+
const alertName = alert.alert?.toLowerCase() || '';
38+
const severity = alert.labels?.severity?.toLowerCase() || '';
39+
const status = alert.state?.toLowerCase() || '';
40+
const labelKeys = Object.keys(alert.labels || {})
41+
.filter((e) => e !== 'severity')
42+
.map((k) => k.toLowerCase());
43+
const labelValues = Object.values(alert.labels || {}).map((v) =>
44+
typeof v === 'string' ? v.toLowerCase() : '',
45+
);
46+
47+
const searchable = [
48+
alertName,
49+
severity,
50+
status,
51+
...labelKeys,
52+
...labelValues,
53+
].join(' ');
54+
55+
// eslint-disable-next-line sonarjs/cognitive-complexity
56+
return searchWords.every((word) => {
57+
const plainTextMatch = searchable.includes(word);
58+
59+
// Check if this word is a key:value pair
60+
const isKeyValue = word.includes(':');
61+
if (isKeyValue) {
62+
// For key:value pairs, check if the key:value logic matches
63+
const [key, value] = word.split(':');
64+
const keyValueMatch = ((): boolean => {
65+
if (key === 'severity') {
66+
return severity === value;
67+
}
68+
if (key === 'status') {
69+
const mappedStatus = statusMap[value] || value;
70+
const labelVal =
71+
alert.labels && key in alert.labels ? alert.labels[key] : undefined;
72+
return (
73+
status === mappedStatus ||
74+
(typeof labelVal === 'string' && labelVal.toLowerCase() === value)
75+
);
76+
}
77+
if (alert.labels && key in alert.labels) {
78+
const labelVal = alert.labels[key];
79+
return typeof labelVal === 'string' && labelVal.toLowerCase() === value;
80+
}
81+
return false;
82+
})();
83+
84+
// For key:value pairs, match if EITHER plain text OR key:value logic matches
85+
return plainTextMatch || keyValueMatch;
86+
}
87+
88+
// For regular words, only plain text matching is required
89+
return plainTextMatch;
90+
});
91+
}
92+
693
export const filterAlerts = (
794
allAlertRules: GettableAlert[],
895
filter: string,
996
): GettableAlert[] => {
10-
const value = filter.toLowerCase();
11-
return allAlertRules.filter((alert) => {
12-
const alertName = alert.alert.toLowerCase();
13-
const severity = alert.labels?.severity.toLowerCase();
14-
const labels = Object.keys(alert.labels || {})
15-
.filter((e) => e !== 'severity')
16-
.join(' ')
17-
.toLowerCase();
18-
19-
const labelValue = Object.values(alert.labels || {});
20-
21-
return (
22-
alertName.includes(value) ||
23-
severity?.includes(value) ||
24-
labels.includes(value) ||
25-
labelValue.includes(value)
26-
);
27-
});
97+
if (!filter.trim()) return allAlertRules;
98+
99+
const { keyValuePairs, filterCopy } = parseKeyValuePairs(filter);
100+
// Include both the remaining words AND the original key:value strings as search words
101+
const remainingWords = filterCopy.split(/\s+/).filter(Boolean);
102+
const keyValueStrings = Object.entries(keyValuePairs).map(
103+
([key, value]) => `${key}:${value}`,
104+
);
105+
const searchWords = [...remainingWords, ...keyValueStrings];
106+
107+
return allAlertRules.filter((alert) => alertMatches(alert, searchWords));
28108
};
29109

30110
export const alertActionLogEvent = (

0 commit comments

Comments
 (0)