Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions web/src/components/Incidents/api.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// 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 } from './api';

// 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"}',
]);
});
});
153 changes: 102 additions & 51 deletions web/src/components/Incidents/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 5000;

/**
* 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 = [
Expand All @@ -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<string, boolean>();

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<PrometheusResponse>,
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;
};
Loading