diff --git a/web/src/components/Incidents/api.spec.ts b/web/src/components/Incidents/api.spec.ts new file mode 100644 index 00000000..7f683850 --- /dev/null +++ b/web/src/components/Incidents/api.spec.ts @@ -0,0 +1,149 @@ +// Setup global.window before importing modules that use it +(global as any).window = { + SERVER_FLAGS: { + prometheusBaseURL: '/api/prometheus', + prometheusTenancyBaseURL: '/api/prometheus-tenancy', + alertManagerBaseURL: '/api/alertmanager', + }, +}; + +import { createAlertsQuery, fetchDataForIncidentsAndAlerts } from './api'; +import { PrometheusResponse } from '@openshift-console/dynamic-plugin-sdk'; +import { buildPrometheusUrl } from '../utils'; + +// Mock the SDK +jest.mock('@openshift-console/dynamic-plugin-sdk', () => ({ + PrometheusEndpoint: { + QUERY_RANGE: 'api/v1/query_range', + }, +})); + +// Mock the global utils to avoid window access side effects +jest.mock('../utils', () => ({ + getPrometheusBasePath: jest.fn(), + buildPrometheusUrl: jest.fn(), +})); + +describe('createAlertsQuery', () => { + it('should create a valid alerts query', () => { + const alertsQuery = createAlertsQuery([ + { + src_alertname: 'test', + src_severity: 'critical', + src_namespace: 'test', + src_silenced: 'false', + }, + { + src_alertname: 'test2', + src_severity: 'warning', + src_namespace: 'test2', + src_silenced: 'false', + }, + { + src_alertname: 'test2', + src_severity: 'warning', + src_namespace: 'test2', + src_silenced: 'true', + }, + ]); + expect(alertsQuery).toEqual([ + 'ALERTS{alertname="test", severity="critical", namespace="test"} or ALERTS{alertname="test2", severity="warning", namespace="test2"}', + ]); + }); + it('should create valid alerts queries array', () => { + const alertsQuery = createAlertsQuery( + [ + { + src_alertname: 'test', + src_severity: 'critical', + src_namespace: 'test', + src_silenced: 'false', + }, + { + src_alertname: 'test2', + src_severity: 'warning', + src_namespace: 'test2', + src_silenced: 'false', + }, + { + src_alertname: 'test2', + src_severity: 'warning', + src_namespace: 'test2', + src_silenced: 'true', + }, + ], + 100, + ); + expect(alertsQuery).toEqual([ + 'ALERTS{alertname="test", severity="critical", namespace="test"}', + 'ALERTS{alertname="test2", severity="warning", namespace="test2"}', + ]); + }); +}); + +describe('fetchDataForIncidentsAndAlerts', () => { + it('should fetch data for incidents and alerts', async () => { + (buildPrometheusUrl as jest.Mock).mockReturnValue('/mock/url'); + const now = Date.now(); + + const result1 = { + metric: { + alertname: 'test', + severity: 'critical', + namespace: 'test', + }, + values: [ + [now - 1000, '1'], + [now - 500, '2'], + ] as [number, string][], + }; + + const result2 = { + metric: { + alertname: 'test2', + severity: 'warning', + namespace: 'test2', + }, + values: [ + [now - 2000, '3'], + [now - 1500, '4'], + ] as [number, string][], + }; + + const mockPrometheusResponse1: PrometheusResponse = { + status: 'success', + data: { + resultType: 'matrix', + result: [result1], + }, + }; + + const mockPrometheusResponse2: PrometheusResponse = { + status: 'success', + data: { + resultType: 'matrix', + result: [result2], + }, + }; + + const fetch = jest + .fn() + .mockResolvedValueOnce(mockPrometheusResponse1) + .mockResolvedValueOnce(mockPrometheusResponse2); + + const range = { endTime: now, duration: 86400000 }; + const customQuery = [ + 'ALERTS{alertname="test", severity="critical", namespace="test"}', + 'ALERTS{alertname="test2", severity="warning", namespace="test2"}', + ]; + const result = await fetchDataForIncidentsAndAlerts(fetch, range, customQuery); + expect(result).toEqual({ + status: 'success', + data: { + resultType: 'matrix', + result: [result1, result2], + }, + }); + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/web/src/components/Incidents/api.ts b/web/src/components/Incidents/api.ts index 70517b97..cf6fc4c4 100644 --- a/web/src/components/Incidents/api.ts +++ b/web/src/components/Incidents/api.ts @@ -3,20 +3,43 @@ import { PrometheusEndpoint, PrometheusResponse } from '@openshift-console/dynamic-plugin-sdk'; import { getPrometheusBasePath, buildPrometheusUrl } from '../utils'; import { PROMETHEUS_QUERY_INTERVAL_SECONDS } from './utils'; + +const MAX_URL_LENGTH = 2048; + +/** + * Creates a single Prometheus alert query string from a grouped alert value. + * @param {Object} query - Single grouped alert object with src_ prefixed properties and layer/component. + * @returns {string} - A string representing a single Prometheus alert query. + */ +const createSingleAlertQuery = (query) => { + // Dynamically get all keys starting with "src_" + const srcKeys = Object.keys(query).filter( + (key) => key.startsWith('src_') && key != 'src_silenced', + ); + + // Create the alertParts array using the dynamically discovered src_ keys, + // but remove the "src_" prefix from the keys in the final query string. + const alertParts = srcKeys + .filter((key) => query[key]) // Only include keys that are present in the query object + .map((key) => `${key.replace('src_', '')}="${query[key]}"`) // Remove "src_" prefix from keys + .join(', '); + + // Construct the query string for each grouped alert + return `ALERTS{${alertParts}}`; +}; + /** * Creates a Prometheus alerts query string from grouped alert values. * The function dynamically includes any properties in the input objects that have the "src_" prefix, * but the prefix is removed from the keys in the final query string. * * @param {Object[]} groupedAlertsValues - Array of grouped alert objects. - * Each alert object should contain various properties, including "src_" prefixed properties, - * as well as "layer" and "component" for constructing the meta fields in the query. + * Each alert object should contain various properties, including "src_" prefixed properties * * @param {string} groupedAlertsValues[].layer - The layer of the alert, used in the absent condition. * @param {string} groupedAlertsValues[].component - The component of the alert, used in the absent condition. - * @returns {string} - A string representing the combined Prometheus alerts query. - * Each alert query is formatted as `(ALERTS{key="value", ...} + on () group_left (component, layer) (absent(meta{layer="value", component="value"})))` - * and multiple queries are joined by "or". + * @returns {string[]} - An array of strings representing the combined Prometheus alerts query. + * Each alert query is formatted as `(ALERTS{key="value", ...} and multiple queries are joined by "or". * * @example * const alerts = [ @@ -38,63 +61,91 @@ import { PROMETHEUS_QUERY_INTERVAL_SECONDS } from './utils'; * * const query = createAlertsQuery(alerts); * // Returns: - * // '(ALERTS{alertname="AlertmanagerReceiversNotConfigured", namespace="openshift-monitoring", severity="warning"} + on () group_left (component, layer) (absent(meta{layer="core", component="monitoring"}))) or - * // (ALERTS{alertname="AnotherAlert", namespace="default", severity="critical"} + on () group_left (component, layer) (absent(meta{layer="app", component="frontend"})))' + * // ['ALERTS{alertname="AlertmanagerReceiversNotConfigured", namespace="openshift-monitoring", severity="warning"} or + * // ALERTS{alertname="AnotherAlert", namespace="default", severity="critical"}'] */ -export const createAlertsQuery = (groupedAlertsValues) => { - const alertsQuery = groupedAlertsValues - .map((query) => { - // Dynamically get all keys starting with "src_" - const srcKeys = Object.keys(query).filter((key) => key.startsWith('src_')); - - // Create the alertParts array using the dynamically discovered src_ keys, - // but remove the "src_" prefix from the keys in the final query string. - const alertParts = srcKeys - .filter((key) => query[key]) // Only include keys that are present in the query object - .map((key) => `${key.replace('src_', '')}="${query[key]}"`) // Remove "src_" prefix from keys - .join(', '); - - // Construct the query string for each grouped alert - return `(ALERTS{${alertParts}} + on () group_left (component, layer) (absent(meta{layer="${query.layer}", component="${query.component}"})))`; - }) - .join(' or '); // Join all individual alert queries with "or" - - // TODO: remove duplicated conditions, optimize query - - return alertsQuery; +export const createAlertsQuery = (groupedAlertsValues, max_url_length = MAX_URL_LENGTH) => { + const queries = []; + const alertsMap = new Map(); + + let currentQueryParts = []; + let currentQueryLength = 0; + + for (const alertValue of groupedAlertsValues) { + const singleAlertQuery = createSingleAlertQuery(alertValue); + if (alertsMap.has(singleAlertQuery)) { + continue; + } + alertsMap.set(singleAlertQuery, true); + const newQueryLength = currentQueryLength + singleAlertQuery.length + 4; // 4 for ' or ' + + if (newQueryLength <= max_url_length) { + currentQueryParts.push(singleAlertQuery); + currentQueryLength = newQueryLength; + continue; + } + queries.push(currentQueryParts.join(' or ')); + currentQueryParts = [singleAlertQuery]; + currentQueryLength = singleAlertQuery.length; + } + + if (currentQueryParts.length > 0) { + queries.push(currentQueryParts.join(' or ')); + } + + return queries; }; -export const fetchDataForIncidentsAndAlerts = ( +export const fetchDataForIncidentsAndAlerts = async ( fetch: (url: string) => Promise, range: { endTime: number; duration: number }, - customQuery: string, + customQuery: string | string[], ) => { // Calculate samples to ensure step=PROMETHEUS_QUERY_INTERVAL_SECONDS (300s / 5 minutes) // For 24h duration: Math.ceil(86400000 / 288 / 1000) = 300 seconds const samples = Math.floor(range.duration / (PROMETHEUS_QUERY_INTERVAL_SECONDS * 1000)); + const queries = Array.isArray(customQuery) ? customQuery : [customQuery]; - const url = buildPrometheusUrl({ - prometheusUrlProps: { - endpoint: PrometheusEndpoint.QUERY_RANGE, - endTime: range.endTime, - query: customQuery, - samples, - timespan: range.duration, - }, - basePath: getPrometheusBasePath({ - prometheus: 'cmo', - useTenancyPath: false, - }), - }); - - if (!url) { - // Return empty result when query is empty to avoid making invalid API calls - return Promise.resolve({ - data: { - result: [], + const promises = queries.map((query) => { + const url = buildPrometheusUrl({ + prometheusUrlProps: { + endpoint: PrometheusEndpoint.QUERY_RANGE, + endTime: range.endTime, + query, + samples, + timespan: range.duration, }, + basePath: getPrometheusBasePath({ + prometheus: 'cmo', + useTenancyPath: false, + }), }); - } - return fetch(url); + if (!url) { + // Return empty result when query is empty to avoid making invalid API calls + return Promise.resolve({ + status: 'success', + data: { + resultType: 'matrix', + result: [], + }, + } as PrometheusResponse); + } + + return fetch(url); + }); + + const responses = await Promise.all(promises); + + // Merge responses + const combinedResult = responses.flatMap((r) => r.data?.result || []); + + // Construct a synthetic response + return { + status: 'success', + data: { + resultType: responses[0]?.data?.resultType || 'matrix', + result: combinedResult, + }, + } as PrometheusResponse; }; diff --git a/web/src/components/Incidents/processAlerts.spec.ts b/web/src/components/Incidents/processAlerts.spec.ts index c662fe0f..8c424a72 100644 --- a/web/src/components/Incidents/processAlerts.spec.ts +++ b/web/src/components/Incidents/processAlerts.spec.ts @@ -319,8 +319,6 @@ describe('convertToAlerts', () => { alertname: 'Alert2', namespace: 'ns2', severity: 'warning', - component: 'comp2', - layer: 'layer2', name: 'name2', alertstate: 'firing', }, @@ -331,8 +329,6 @@ describe('convertToAlerts', () => { alertname: 'Alert1', namespace: 'ns1', severity: 'critical', - component: 'comp1', - layer: 'layer1', name: 'name1', alertstate: 'firing', }, @@ -343,6 +339,8 @@ describe('convertToAlerts', () => { const incidents: Array> = [ { group_id: 'incident1', + component: 'comp1', + layer: 'layer1', src_alertname: 'Alert1', src_namespace: 'ns1', src_severity: 'critical', @@ -350,6 +348,8 @@ describe('convertToAlerts', () => { }, { group_id: 'incident2', + component: 'comp2', + layer: 'layer2', src_alertname: 'Alert2', src_namespace: 'ns2', src_severity: 'warning', @@ -370,8 +370,6 @@ describe('convertToAlerts', () => { alertname: 'Alert1', namespace: 'ns1', severity: 'critical', - component: 'comp1', - layer: 'layer1', name: 'name1', alertstate: 'firing', }, @@ -382,8 +380,6 @@ describe('convertToAlerts', () => { alertname: 'Alert2', namespace: 'ns2', severity: 'warning', - component: 'comp2', - layer: 'layer2', name: 'name2', alertstate: 'firing', }, @@ -393,10 +389,22 @@ describe('convertToAlerts', () => { const incidents: Array> = [ { - values: [ - [nowSeconds - 3600, '2'], - [nowSeconds - 1800, '1'], - ], + group_id: 'incident1', + src_alertname: 'Alert1', + src_namespace: 'ns1', + src_severity: 'critical', + component: 'comp1', + layer: 'layer1', + values: [[nowSeconds - 3600, '2']], + }, + { + group_id: 'incident2', + src_alertname: 'Alert2', + src_namespace: 'ns2', + src_severity: 'warning', + component: 'comp2', + layer: 'layer2', + values: [[nowSeconds - 1800, '1']], }, ]; @@ -415,8 +423,6 @@ describe('convertToAlerts', () => { alertname: 'TestAlert', namespace: 'test-namespace', severity: 'critical', - component: 'test-component', - layer: 'test-layer', name: 'test', alertstate: 'firing', }, @@ -430,6 +436,8 @@ describe('convertToAlerts', () => { src_alertname: 'TestAlert', src_namespace: 'test-namespace', src_severity: 'critical', + component: 'test-component', + layer: 'test-layer', silenced: true, values: [[nowSeconds, '2']], }, @@ -439,37 +447,6 @@ describe('convertToAlerts', () => { expect(result).toHaveLength(1); expect(result[0].silenced).toBe(true); }); - - it('should default silenced to false when no matching incident found', () => { - const prometheusResults: PrometheusResult[] = [ - { - metric: { - alertname: 'TestAlert', - namespace: 'test-namespace', - severity: 'critical', - component: 'test-component', - layer: 'test-layer', - name: 'test', - alertstate: 'firing', - }, - values: [[nowSeconds, '2']], - }, - ]; - - const incidents: Array> = [ - { - group_id: 'incident1', - src_alertname: 'DifferentAlert', - src_namespace: 'different-namespace', - src_severity: 'warning', - values: [[nowSeconds, '1']], - }, - ]; - - const result = convertToAlerts(prometheusResults, incidents, now); - expect(result).toHaveLength(1); - expect(result[0].silenced).toBe(false); - }); }); describe('incident merging', () => { @@ -480,8 +457,6 @@ describe('convertToAlerts', () => { alertname: 'TestAlert', namespace: 'test-namespace', severity: 'critical', - component: 'test-component', - layer: 'test-layer', name: 'test', alertstate: 'firing', }, @@ -499,6 +474,8 @@ describe('convertToAlerts', () => { src_alertname: 'TestAlert', src_namespace: 'test-namespace', src_severity: 'critical', + component: 'test-component', + layer: 'test-layer', silenced: false, values: [[nowSeconds - 600, '2']], }, @@ -527,8 +504,6 @@ describe('convertToAlerts', () => { alertname: 'MyAlert', namespace: 'my-namespace', severity: 'warning', - component: 'my-component', - layer: 'my-layer', name: 'my-name', alertstate: 'firing', }, @@ -542,6 +517,8 @@ describe('convertToAlerts', () => { src_alertname: 'MyAlert', src_namespace: 'my-namespace', src_severity: 'warning', + component: 'my-component', + layer: 'my-layer', values: [[nowSeconds, '1']], }, ]; @@ -566,7 +543,6 @@ describe('deduplicateAlerts', () => { metric: { alertname: 'Alert1', namespace: 'ns1', - component: 'comp1', severity: 'critical', alertstate: 'resolved', }, @@ -576,7 +552,6 @@ describe('deduplicateAlerts', () => { metric: { alertname: 'Alert2', namespace: 'ns2', - component: 'comp2', severity: 'warning', alertstate: 'firing', }, @@ -597,7 +572,6 @@ describe('deduplicateAlerts', () => { metric: { alertname: 'Alert1', namespace: 'ns1', - component: 'comp1', severity: 'critical', alertstate: 'firing', }, @@ -610,7 +584,6 @@ describe('deduplicateAlerts', () => { metric: { alertname: 'Alert1', namespace: 'ns1', - component: 'comp1', severity: 'critical', alertstate: 'firing', }, @@ -632,7 +605,6 @@ describe('deduplicateAlerts', () => { metric: { alertname: 'Alert1', namespace: 'ns1', - component: 'comp1', severity: 'critical', alertstate: 'firing', }, @@ -642,7 +614,6 @@ describe('deduplicateAlerts', () => { metric: { alertname: 'Alert2', namespace: 'ns1', - component: 'comp1', severity: 'critical', alertstate: 'firing', }, @@ -660,7 +631,6 @@ describe('deduplicateAlerts', () => { metric: { alertname: 'Alert1', namespace: 'ns1', - component: 'comp1', severity: 'critical', alertstate: 'firing', }, @@ -670,7 +640,6 @@ describe('deduplicateAlerts', () => { metric: { alertname: 'Alert1', namespace: 'ns1', - component: 'comp1', severity: 'warning', alertstate: 'firing', }, @@ -690,7 +659,6 @@ describe('deduplicateAlerts', () => { metric: { alertname: 'Alert1', namespace: 'ns1', - component: 'comp1', severity: 'critical', alertstate: 'firing', }, diff --git a/web/src/components/Incidents/processAlerts.ts b/web/src/components/Incidents/processAlerts.ts index 921a4a49..b216aa2a 100644 --- a/web/src/components/Incidents/processAlerts.ts +++ b/web/src/components/Incidents/processAlerts.ts @@ -252,9 +252,10 @@ export function convertToAlerts( incident.src_severity === alert.metric.severity, ); - // Use silenced value from incident data (cluster_health_components_map) - // Default to false if no matching incident found - const silenced = matchingIncident?.silenced ?? false; + // If no matching incident found, skip the alert + if (!matchingIncident) { + return null; + } // Add padding points for chart rendering const paddedValues = insertPaddingPointsForChart(sortedValues, currentTime); @@ -265,8 +266,8 @@ export function convertToAlerts( alertname: alert.metric.alertname, namespace: alert.metric.namespace, severity: alert.metric.severity as Severity, - component: alert.metric.component, - layer: alert.metric.layer, + component: matchingIncident.component, + layer: matchingIncident.layer, name: alert.metric.name, alertstate: resolved ? 'resolved' : 'firing', values: paddedValues, @@ -274,7 +275,7 @@ export function convertToAlerts( alertsEndFiring: lastTimestamp, resolved, x: 0, // Will be set after sorting - silenced, + silenced: matchingIncident.silenced ?? false, }; }) .filter((alert): alert is Alert => alert !== null)