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
This commit is contained in:
SagarRajput-7 2025-05-28 10:53:42 +05:30 committed by GitHub
parent 0a6a7ba729
commit bec52c3d3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 329 additions and 23 deletions

View File

@ -1,6 +1,6 @@
/* eslint-disable react/display-name */
import { PlusOutlined } from '@ant-design/icons';
import { Flex, Input, Typography } from 'antd';
import { InfoCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Flex, Input, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import saveAlertApi from 'api/alerts/save';
import logEvent from 'api/common/logEvent';
@ -175,6 +175,41 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
setData(filteredData);
});
const searchTooltipContent = (
<div style={{ maxWidth: 400 }}>
<div style={{ marginBottom: 8, fontWeight: 'bold' }}>Search Options:</div>
<div style={{ marginBottom: 4 }}>
<strong>Plain text:</strong> Search across all fields
</div>
<div style={{ marginBottom: 4 }}>
<strong>Key:value pairs:</strong> Specific field matching
</div>
<div style={{ marginBottom: 4 }}>
<strong>Multiple terms:</strong> All terms must match (AND logic)
</div>
<div style={{ marginBottom: 8 }}>
<strong>Status mapping:</strong> Use &quot;ok&quot; for inactive alerts
</div>
<div style={{ marginBottom: 8, fontWeight: 'bold' }}>Examples:</div>
<div style={{ marginBottom: 4 }}>
<code>cpu warning</code> - Find alerts with both &quot;cpu&quot; and
&quot;warning&quot;
</div>
<div style={{ marginBottom: 4 }}>
<code>status:ok</code> - Find alerts with OK status
</div>
<div style={{ marginBottom: 4 }}>
<code>severity:critical</code> - Find critical alerts
</div>
<div style={{ marginBottom: 4 }}>
<code>cluster:prod</code> - Find alerts with cluster=prod label
</div>
<div>
<code>status:ok cpu</code> - Find OK alerts containing &quot;cpu&quot;
</div>
</div>
);
const dynamicColumns: ColumnsType<GettableAlert> = [
{
title: 'Created At',
@ -344,11 +379,22 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
return (
<>
<SearchContainer>
<div className="search-container">
<Search
placeholder="Search by Alert Name, Severity and Labels"
placeholder="Search by name, status, severity, labels or key:value or chaining (e.g. 'status:ok cpu warning')"
onChange={handleSearch}
defaultValue={searchString}
prefix={
<Flex align="center" gap={8}>
<Tooltip title={searchTooltipContent} placement="bottomRight">
<InfoCircleOutlined className="search-tooltip" />
</Tooltip>
<div className="search-divider" />
</Flex>
}
/>
</div>
<Flex gap={12}>
{addNewAlert && (
<Button

View File

@ -8,6 +8,24 @@ export const SearchContainer = styled.div`
align-items: center;
gap: 2rem;
}
.search-container {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
.search-tooltip {
color: var(--bg-robin-500);
cursor: help;
}
.search-divider {
height: 16px;
margin: 0;
border-left: 1px solid var(--bg-slate-100);
margin-right: 6px;
}
}
`;
export const Button = styled(ButtonComponent)`

View File

@ -0,0 +1,162 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GettableAlert } from 'types/api/alerts/get';
import { EQueryType } from 'types/common/dashboard';
import { filterAlerts } from './utils';
const testLabels = { severity: 'warning', cluster: 'prod', test: 'value' };
const baseAlert: GettableAlert = {
id: '1',
alert: 'CPU Usage High',
state: 'inactive',
disabled: false,
createAt: '',
createBy: '',
updateAt: '',
updateBy: '',
alertType: 'type',
ruleType: 'rule',
frequency: '1m',
condition: {
compositeQuery: {
builderQueries: {},
promQueries: {},
chQueries: {},
queryType: EQueryType.QUERY_BUILDER,
panelType: PANEL_TYPES.TABLE,
unit: '',
},
},
labels: testLabels,
annotations: {},
evalWindow: '',
source: '',
preferredChannels: [],
broadcastToAll: false,
version: '',
};
const alerts: GettableAlert[] = [
{
...baseAlert,
id: '1',
alert: 'CPU Usage High',
state: 'inactive',
labels: testLabels,
},
{
...baseAlert,
id: '2',
alert: 'Memory Usage',
state: 'firing',
labels: { severity: 'critical', cluster: 'dev', test: 'other' },
},
{
...baseAlert,
id: '3',
alert: 'Disk IO',
state: 'pending',
labels: testLabels,
},
{
...baseAlert,
id: '4',
alert: 'Network Latency',
state: 'disabled',
labels: { severity: 'info', cluster: 'qa', test: 'value' },
},
];
describe('filterAlerts', () => {
it('returns all alerts if filter is empty', () => {
expect(filterAlerts(alerts, '')).toHaveLength(alerts.length);
});
it('matches by alert name (case-insensitive)', () => {
const result = filterAlerts(alerts, 'cpu usage');
expect(result).toHaveLength(1);
expect(result[0].alert).toBe('CPU Usage High');
});
it('matches by severity', () => {
const result = filterAlerts(alerts, 'warning');
expect(result.map((a) => a.id)).toEqual(['1', '3']);
});
it('matches by label key or value', () => {
const result = filterAlerts(alerts, 'prod');
expect(result.map((a) => a.id)).toEqual(['1', '3']);
});
it('matches by multi-word AND search', () => {
const result = filterAlerts(alerts, 'cpu prod');
expect(result.map((a) => a.id)).toEqual(['1']);
});
it('matches by key:value (label)', () => {
const result = filterAlerts(alerts, 'test:value');
expect(result.map((a) => a.id)).toEqual(['1', '3', '4']);
});
it('matches by key: value (label, with space)', () => {
const result = filterAlerts(alerts, 'test: value');
expect(result.map((a) => a.id)).toEqual(['1', '3', '4']);
});
it('matches by key:value (severity)', () => {
const result = filterAlerts(alerts, 'severity:warning');
expect(result.map((a) => a.id)).toEqual(['1', '3']);
});
it('matches by key:value (status:ok)', () => {
const result = filterAlerts(alerts, 'status:ok');
expect(result.map((a) => a.id)).toEqual(['1']);
});
it('matches by key:value (status:inactive)', () => {
const result = filterAlerts(alerts, 'status:inactive');
expect(result.map((a) => a.id)).toEqual(['1']);
});
it('matches by key:value (status:firing)', () => {
const result = filterAlerts(alerts, 'status:firing');
expect(result.map((a) => a.id)).toEqual(['2']);
});
it('matches by key:value (status:pending)', () => {
const result = filterAlerts(alerts, 'status:pending');
expect(result.map((a) => a.id)).toEqual(['3']);
});
it('matches by key:value (status:disabled)', () => {
const result = filterAlerts(alerts, 'status:disabled');
expect(result.map((a) => a.id)).toEqual(['4']);
});
it('matches by key:value (cluster:prod)', () => {
const result = filterAlerts(alerts, 'cluster:prod');
expect(result.map((a) => a.id)).toEqual(['1', '3']);
});
it('matches by key:value (cluster:dev)', () => {
const result = filterAlerts(alerts, 'cluster:dev');
expect(result.map((a) => a.id)).toEqual(['2']);
});
it('matches by key:value (case-insensitive)', () => {
const result = filterAlerts(alerts, 'CLUSTER:PROD');
expect(result.map((a) => a.id)).toEqual(['1', '3']);
});
it('matches by combination of word and key:value', () => {
const result = filterAlerts(alerts, 'cpu status:ok');
expect(result.map((a) => a.id)).toEqual(['1']);
});
it('returns empty if no match', () => {
const result = filterAlerts(alerts, 'notfound');
expect(result).toHaveLength(0);
});
});

View File

@ -3,28 +3,108 @@ import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { GettableAlert } from 'types/api/alerts/get';
/**
* Parses key:value pairs from the filter string, allowing optional whitespace after the colon.
*/
function parseKeyValuePairs(
filter: string,
): { keyValuePairs: Record<string, string>; filterCopy: string } {
// Allow optional whitespace after colon, and support more flexible values
const keyValueRegex = /([\w-]+):\s*([^\s]+)/g;
const keyValuePairs: Record<string, string> = {};
let filterCopy = filter.toLowerCase();
const matches = Array.from(filterCopy.matchAll(keyValueRegex));
matches.forEach((match) => {
const [, key, value] = match;
keyValuePairs[key] = value.trim();
filterCopy = filterCopy.replace(match[0], '');
});
return { keyValuePairs, filterCopy };
}
const statusMap: Record<string, string> = {
ok: 'inactive',
inactive: 'inactive',
pending: 'pending',
firing: 'firing',
disabled: 'disabled',
};
/**
* Returns true if the alert matches the search words and key-value pairs.
*/
function alertMatches(alert: GettableAlert, searchWords: string[]): boolean {
const alertName = alert.alert?.toLowerCase() || '';
const severity = alert.labels?.severity?.toLowerCase() || '';
const status = alert.state?.toLowerCase() || '';
const labelKeys = Object.keys(alert.labels || {})
.filter((e) => e !== 'severity')
.map((k) => k.toLowerCase());
const labelValues = Object.values(alert.labels || {}).map((v) =>
typeof v === 'string' ? v.toLowerCase() : '',
);
const searchable = [
alertName,
severity,
status,
...labelKeys,
...labelValues,
].join(' ');
// eslint-disable-next-line sonarjs/cognitive-complexity
return searchWords.every((word) => {
const plainTextMatch = searchable.includes(word);
// Check if this word is a key:value pair
const isKeyValue = word.includes(':');
if (isKeyValue) {
// For key:value pairs, check if the key:value logic matches
const [key, value] = word.split(':');
const keyValueMatch = ((): boolean => {
if (key === 'severity') {
return severity === value;
}
if (key === 'status') {
const mappedStatus = statusMap[value] || value;
const labelVal =
alert.labels && key in alert.labels ? alert.labels[key] : undefined;
return (
status === mappedStatus ||
(typeof labelVal === 'string' && labelVal.toLowerCase() === value)
);
}
if (alert.labels && key in alert.labels) {
const labelVal = alert.labels[key];
return typeof labelVal === 'string' && labelVal.toLowerCase() === value;
}
return false;
})();
// For key:value pairs, match if EITHER plain text OR key:value logic matches
return plainTextMatch || keyValueMatch;
}
// For regular words, only plain text matching is required
return plainTextMatch;
});
}
export const filterAlerts = (
allAlertRules: GettableAlert[],
filter: string,
): GettableAlert[] => {
const value = filter.toLowerCase();
return allAlertRules.filter((alert) => {
const alertName = alert.alert.toLowerCase();
const severity = alert.labels?.severity.toLowerCase();
const labels = Object.keys(alert.labels || {})
.filter((e) => e !== 'severity')
.join(' ')
.toLowerCase();
if (!filter.trim()) return allAlertRules;
const labelValue = Object.values(alert.labels || {});
return (
alertName.includes(value) ||
severity?.includes(value) ||
labels.includes(value) ||
labelValue.includes(value)
const { keyValuePairs, filterCopy } = parseKeyValuePairs(filter);
// Include both the remaining words AND the original key:value strings as search words
const remainingWords = filterCopy.split(/\s+/).filter(Boolean);
const keyValueStrings = Object.entries(keyValuePairs).map(
([key, value]) => `${key}:${value}`,
);
});
const searchWords = [...remainingWords, ...keyValueStrings];
return allAlertRules.filter((alert) => alertMatches(alert, searchWords));
};
export const alertActionLogEvent = (