From 898ae22ff31bf26f976b103662814fc5edc5e2e5 Mon Sep 17 00:00:00 2001 From: rioloc Date: Wed, 14 Jan 2026 15:00:09 +0100 Subject: [PATCH 01/10] feat: absolute incidents timestamps support - add incidentsTimestamp in redux store - add two extra calls to retrieve min_over_time and last_over_time for incidents - enrich incident with absolute datapoints timestamps - update IncidentTooltip labels with absolute Start and End times --- .../IncidentsChart/IncidentsChart.tsx | 39 ++++++++++++++++++- .../components/Incidents/IncidentsPage.tsx | 33 +++++++++++++++- web/src/components/Incidents/api.ts | 33 ++++++++++++++++ web/src/components/Incidents/model.ts | 7 ++++ web/src/components/Incidents/utils.ts | 4 ++ web/src/store/actions.ts | 5 +++ web/src/store/reducers.ts | 5 +++ web/src/store/store.ts | 3 ++ 8 files changed, 126 insertions(+), 3 deletions(-) diff --git a/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx b/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx index 51c7a52a8..8607bddd1 100644 --- a/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx +++ b/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx @@ -28,7 +28,7 @@ import { } from '@patternfly/react-tokens'; import '../incidents-styles.css'; import { IncidentsTooltip } from '../IncidentsTooltip'; -import { Incident } from '../model'; +import { Incident, IncidentsTimestamps } from '../model'; import { calculateIncidentsChartDomain, createIncidentsChartBars, @@ -55,8 +55,27 @@ const formatComponentList = (componentList: string[] | undefined): string => { return components.slice(0, 3).join(', ') + (hasMore ? ', ...' : ''); }; +/* + * Function to match a timestamp metric based on the common labels + * (group_id, src_alertname, src_namespace, src_severity) + * @param incident - The incident to match the timestamp for + * @param timestamps - The timestamps to match the incident for + * @returns The matched timestamp + */ +const matchTimestampMetric = (incident: Incident, timestamps: Array): any => { + return timestamps.find( + (timestamp) => + timestamp.metric.group_id === incident.group_id && + timestamp.metric.src_alertname === incident.src_alertname && + timestamp.metric.src_namespace === incident.src_namespace && + timestamp.metric.component === incident.component && + timestamp.metric.src_severity === incident.src_severity, + ); +}; + const IncidentsChart = ({ incidentsData, + incidentsTimestamps, chartDays, theme, selectedGroupId, @@ -65,6 +84,7 @@ const IncidentsChart = ({ lastRefreshTime, }: { incidentsData: Array; + incidentsTimestamps: IncidentsTimestamps; chartDays: number; theme: 'light' | 'dark'; selectedGroupId: string; @@ -80,6 +100,19 @@ const IncidentsChart = ({ [chartDays, currentTime], ); + // enrich incidentsData with first_timestamp and last_timestamp from timestamp metric + incidentsData = incidentsData.map((incident) => { + // find the matched timestamp for the incident + const matchedMinTimestamp = matchTimestampMetric(incident, incidentsTimestamps.minOverTime); + const matchedLastTimestamp = matchTimestampMetric(incident, incidentsTimestamps.lastOverTime); + + return { + ...incident, + firstTimestamp: parseInt(matchedMinTimestamp?.value?.[1] ?? '0'), + lastTimestamp: parseInt(matchedLastTimestamp?.value?.[1] ?? '0'), + }; + }); + const { t, i18n } = useTranslation(process.env.I18N_NAMESPACE); const chartData = useMemo(() => { @@ -176,7 +209,9 @@ const IncidentsChart = ({ if (datum.nodata) { return ''; } - const startDate = dateTimeFormatter(i18n.language).format(new Date(datum.y0)); + const startDate = dateTimeFormatter(i18n.language).format( + new Date(datum.startDate), + ); const endDate = datum.firing ? '---' : dateTimeFormatter(i18n.language).format( diff --git a/web/src/components/Incidents/IncidentsPage.tsx b/web/src/components/Incidents/IncidentsPage.tsx index c23456b1e..7d8323497 100644 --- a/web/src/components/Incidents/IncidentsPage.tsx +++ b/web/src/components/Incidents/IncidentsPage.tsx @@ -1,7 +1,7 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { useMemo, useState, useEffect, useCallback } from 'react'; import { useSafeFetch } from '../console/utils/safe-fetch-hook'; -import { createAlertsQuery, fetchDataForIncidentsAndAlerts } from './api'; +import { createAlertsQuery, fetchDataForIncidentsAndAlerts, fetchInstantData } from './api'; import { useTranslation } from 'react-i18next'; import { Bullseye, @@ -47,6 +47,7 @@ import { setAlertsData, setAlertsTableData, setFilteredIncidentsData, + setIncidentsTimestamps, setIncidentPageFilterType, setIncidents, setIncidentsActiveFilters, @@ -146,6 +147,10 @@ const IncidentsPage = () => { (state: MonitoringState) => state.plugins.mcp.incidentsData.filteredIncidentsData, ); + const incidentsTimestamps = useSelector( + (state: MonitoringState) => state.plugins.mcp.incidentsData.incidentsTimestamps, + ); + const selectedGroupId = incidentsActiveFilters.groupId?.[0] ?? undefined; const incidentPageFilterTypeSelected = useSelector( @@ -282,6 +287,31 @@ const IncidentsPage = () => { const currentTime = getCurrentTime(); dispatch(setIncidentsLastRefreshTime(currentTime)); + // fetch incident timestamps + Promise.all( + [ + 'min_over_time(timestamp(cluster_health_components_map)[15d:5m])', + 'last_over_time(timestamp(cluster_health_components_map)[15d:5m])', + ].map(async (query) => { + const response = await fetchInstantData(safeFetch, query); + return response.data.result; + }), + ) + .then((results) => { + dispatch( + setIncidentsTimestamps({ + incidentsTimestamps: { + minOverTime: results[0], + lastOverTime: results[1], + }, + }), + ); + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.log(err); + }); + const daysDuration = parsePrometheusDuration( incidentsActiveFilters.days.length > 0 ? incidentsActiveFilters.days[0].split(' ')[0] + 'd' @@ -638,6 +668,7 @@ const IncidentsPage = () => { Promise, + query: string, +) => { + const url = buildPrometheusUrl({ + prometheusUrlProps: { + endpoint: PrometheusEndpoint.QUERY, + query, + }, + basePath: getPrometheusBasePath({ + prometheus: 'cmo', + useTenancyPath: false, + }), + }); + if (!url) { + return Promise.resolve({ + status: 'success', + data: { + resultType: 'matrix', + result: [], + }, + } as PrometheusResponse); + } + const response = await Promise.resolve(consoleFetchJSON(url)); + return { + status: 'success', + data: { + resultType: 'matrix', + result: response.data.result, + }, + } as PrometheusResponse; +}; diff --git a/web/src/components/Incidents/model.ts b/web/src/components/Incidents/model.ts index 133f476d7..030192a32 100644 --- a/web/src/components/Incidents/model.ts +++ b/web/src/components/Incidents/model.ts @@ -19,6 +19,13 @@ export type Incident = { x: number; values: Array; metric: Metric; + firstTimestamp: number; + lastTimestamp: number; +}; + +export type IncidentsTimestamps = { + minOverTime: Array; + lastOverTime: Array; }; // Define the interface for Metric diff --git a/web/src/components/Incidents/utils.ts b/web/src/components/Incidents/utils.ts index 6f60fc9a8..199718a9c 100644 --- a/web/src/components/Incidents/utils.ts +++ b/web/src/components/Incidents/utils.ts @@ -301,6 +301,8 @@ export const createIncidentsChartBars = (incident: Incident, dateArray: SpanDate componentList: string[]; group_id: string; nodata: boolean; + startDate: Date; + endDate: Date; fill: string; }[] = []; const getSeverityName = (value) => { @@ -325,6 +327,8 @@ export const createIncidentsChartBars = (incident: Incident, dateArray: SpanDate componentList: incident.componentList || [], group_id: incident.group_id, nodata: groupedData[i][2] === 'nodata' ? true : false, + startDate: new Date(incident.firstTimestamp * 1000), + endDate: new Date(incident.lastTimestamp * 1000), fill: severity === 'Critical' ? barChartColorScheme.critical diff --git a/web/src/store/actions.ts b/web/src/store/actions.ts index ef45c45cf..7672bc664 100644 --- a/web/src/store/actions.ts +++ b/web/src/store/actions.ts @@ -42,6 +42,7 @@ export enum ActionType { SetAlertsAreLoading = 'setAlertsAreLoading', SetIncidentsChartSelection = 'setIncidentsChartSelection', SetFilteredIncidentsData = 'setFilteredIncidentsData', + SetIncidentsTimestamps = 'setIncidentsTimestamps', SetIncidentPageFilterType = 'setIncidentPageFilterType', SetIncidentsLastRefreshTime = 'setIncidentsLastRefreshTime', } @@ -188,6 +189,9 @@ export const setIncidentsChartSelection = (incidentsChartSelectedId) => export const setFilteredIncidentsData = (filteredIncidentsData) => action(ActionType.SetFilteredIncidentsData, filteredIncidentsData); +export const setIncidentsTimestamps = (incidentsTimestamps) => + action(ActionType.SetIncidentsTimestamps, incidentsTimestamps); + export const setIncidentPageFilterType = (filterTypeSelected) => action(ActionType.SetIncidentPageFilterType, filterTypeSelected); @@ -234,6 +238,7 @@ type Actions = { setAlertsAreLoading: typeof setAlertsAreLoading; setIncidentsChartSelection: typeof setIncidentsChartSelection; setFilteredIncidentsData: typeof setFilteredIncidentsData; + setIncidentsTimestamps: typeof setIncidentsTimestamps; setIncidentPageFilterType: typeof setIncidentPageFilterType; setIncidentsLastRefreshTime: typeof setIncidentsLastRefreshTime; }; diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index 852fd2dbe..e66feacf6 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -380,6 +380,11 @@ const monitoringReducer = produce((draft: ObserveState, action: ObserveAction): break; } + case ActionType.SetIncidentsTimestamps: { + draft.incidentsData.incidentsTimestamps = action.payload.incidentsTimestamps; + break; + } + case ActionType.SetIncidentPageFilterType: { draft.incidentsData.incidentPageFilterType = action.payload.incidentPageFilterType; break; diff --git a/web/src/store/store.ts b/web/src/store/store.ts index 9dfd6040e..4e64739a8 100644 --- a/web/src/store/store.ts +++ b/web/src/store/store.ts @@ -7,6 +7,7 @@ import { DaysFilters, IncidentSeverityFilters, IncidentStateFilters, + IncidentsTimestamps, } from '../components/Incidents/model'; import { Variable } from '../components/dashboards/legacy/legacy-variable-dropdowns'; @@ -33,6 +34,7 @@ export type ObserveState = { alertsData: Array; alertsTableData: Array; filteredIncidentsData: Array; + incidentsTimestamps: IncidentsTimestamps; alertsAreLoading: boolean; incidentsChartSelectedId: string; incidentsInitialState: { @@ -80,6 +82,7 @@ export const defaultObserveState: ObserveState = { alertsData: [], alertsTableData: [], filteredIncidentsData: [], + incidentsTimestamps: [], alertsAreLoading: true, incidentsChartSelectedId: '', incidentsInitialState: { From ba22dfb57877ba0e621de8f0181c32151bb489a6 Mon Sep 17 00:00:00 2001 From: rioloc Date: Tue, 20 Jan 2026 18:25:45 +0100 Subject: [PATCH 02/10] feat: retrieve absolute start timestamps for alerts --- .../Incidents/AlertsChart/AlertsChart.tsx | 4 +- .../IncidentsChart/IncidentsChart.tsx | 28 +++----------- .../Incidents/IncidentsDetailsRowTable.tsx | 25 +++++++++--- .../components/Incidents/IncidentsPage.tsx | 38 ++++++++++++++++--- .../components/Incidents/IncidentsTable.tsx | 2 +- web/src/components/Incidents/model.ts | 9 +++++ web/src/components/Incidents/processAlerts.ts | 36 +++++++++++++++++- .../components/Incidents/processIncidents.ts | 25 ++++++++++-- web/src/components/Incidents/utils.ts | 24 ++++++++++-- web/src/store/actions.ts | 5 +++ web/src/store/reducers.ts | 5 +++ web/src/store/store.ts | 3 ++ 12 files changed, 160 insertions(+), 44 deletions(-) diff --git a/web/src/components/Incidents/AlertsChart/AlertsChart.tsx b/web/src/components/Incidents/AlertsChart/AlertsChart.tsx index 1705b2e14..ccbc2b70e 100644 --- a/web/src/components/Incidents/AlertsChart/AlertsChart.tsx +++ b/web/src/components/Incidents/AlertsChart/AlertsChart.tsx @@ -161,7 +161,9 @@ const AlertsChart = ({ theme }: { theme: 'light' | 'dark' }) => { if (datum.nodata) { return ''; } - const startDate = dateTimeFormatter(i18n.language).format(new Date(datum.y0)); + const startDate = dateTimeFormatter(i18n.language).format( + new Date(datum.startDate), + ); const endDate = datum.alertstate === 'firing' ? '---' diff --git a/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx b/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx index 8607bddd1..23053c74b 100644 --- a/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx +++ b/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx @@ -33,6 +33,7 @@ import { calculateIncidentsChartDomain, createIncidentsChartBars, generateDateArray, + matchTimestampMetricForIncident, roundDateToInterval, } from '../utils'; import { dateTimeFormatter, timeFormatter } from '../../console/utils/datetime'; @@ -55,24 +56,6 @@ const formatComponentList = (componentList: string[] | undefined): string => { return components.slice(0, 3).join(', ') + (hasMore ? ', ...' : ''); }; -/* - * Function to match a timestamp metric based on the common labels - * (group_id, src_alertname, src_namespace, src_severity) - * @param incident - The incident to match the timestamp for - * @param timestamps - The timestamps to match the incident for - * @returns The matched timestamp - */ -const matchTimestampMetric = (incident: Incident, timestamps: Array): any => { - return timestamps.find( - (timestamp) => - timestamp.metric.group_id === incident.group_id && - timestamp.metric.src_alertname === incident.src_alertname && - timestamp.metric.src_namespace === incident.src_namespace && - timestamp.metric.component === incident.component && - timestamp.metric.src_severity === incident.src_severity, - ); -}; - const IncidentsChart = ({ incidentsData, incidentsTimestamps, @@ -100,16 +83,17 @@ const IncidentsChart = ({ [chartDays, currentTime], ); - // enrich incidentsData with first_timestamp and last_timestamp from timestamp metric + // enrich incidentsData with first_timestamp from timestamp metric incidentsData = incidentsData.map((incident) => { // find the matched timestamp for the incident - const matchedMinTimestamp = matchTimestampMetric(incident, incidentsTimestamps.minOverTime); - const matchedLastTimestamp = matchTimestampMetric(incident, incidentsTimestamps.lastOverTime); + const matchedMinTimestamp = matchTimestampMetricForIncident( + incident, + incidentsTimestamps.minOverTime, + ); return { ...incident, firstTimestamp: parseInt(matchedMinTimestamp?.value?.[1] ?? '0'), - lastTimestamp: parseInt(matchedLastTimestamp?.value?.[1] ?? '0'), }; }); diff --git a/web/src/components/Incidents/IncidentsDetailsRowTable.tsx b/web/src/components/Incidents/IncidentsDetailsRowTable.tsx index e29e8a095..8c9ac677d 100644 --- a/web/src/components/Incidents/IncidentsDetailsRowTable.tsx +++ b/web/src/components/Incidents/IncidentsDetailsRowTable.tsx @@ -24,10 +24,11 @@ const IncidentsDetailsRowTable = ({ alerts }: IncidentsDetailsRowTableProps) => const sortedAndMappedAlerts = useMemo(() => { if (alerts && alerts.length > 0) { return [...alerts] - .sort( - (a: IncidentsDetailsAlert, b: IncidentsDetailsAlert) => - a.alertsStartFiring - b.alertsStartFiring, - ) + .sort((a: IncidentsDetailsAlert, b: IncidentsDetailsAlert) => { + const aStart = a.firstTimestamp > 0 ? a.firstTimestamp : a.alertsStartFiring; + const bStart = b.firstTimestamp > 0 ? b.firstTimestamp : b.alertsStartFiring; + return aStart - bStart; + }) .map((alertDetails: IncidentsDetailsAlert, rowIndex) => { return ( @@ -45,13 +46,25 @@ const IncidentsDetailsRowTable = ({ alerts }: IncidentsDetailsRowTableProps) => - + 0 + ? alertDetails.firstTimestamp + : alertDetails.alertsStartFiring) * 1000 + } + /> {!alertDetails.resolved ? ( '---' ) : ( - + 0 + ? alertDetails.lastTimestamp + : alertDetails.alertsEndFiring) * 1000 + } + /> )} diff --git a/web/src/components/Incidents/IncidentsPage.tsx b/web/src/components/Incidents/IncidentsPage.tsx index 7d8323497..dd0b7ae83 100644 --- a/web/src/components/Incidents/IncidentsPage.tsx +++ b/web/src/components/Incidents/IncidentsPage.tsx @@ -46,6 +46,7 @@ import { setAlertsAreLoading, setAlertsData, setAlertsTableData, + setAlertsTimestamps, setFilteredIncidentsData, setIncidentsTimestamps, setIncidentPageFilterType, @@ -161,6 +162,10 @@ const IncidentsPage = () => { (state: MonitoringState) => state.plugins.mcp.incidentsData.incidentsLastRefreshTime, ); + const alertsTimestamps = useSelector( + (state: MonitoringState) => state.plugins.mcp.incidentsData.alertsTimestamps, + ); + const closeDropDownFilters = (): void => { setFiltersExpanded({ severity: false, @@ -236,6 +241,28 @@ const IncidentsPage = () => { useEffect(() => { (async () => { const currentTime = incidentsLastRefreshTime; + + // fetch alerts timestamps + Promise.all( + ['min_over_time(timestamp(ALERTS{alertstate="firing"})[15d:5m])'].map(async (query) => { + const response = await fetchInstantData(safeFetch, query); + return response.data.result; + }), + ) + .then((results) => { + dispatch( + setAlertsTimestamps({ + alertsTimestamps: { + minOverTime: results[0], + }, + }), + ); + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.log(err); + }); + Promise.all( timeRanges.map(async (range) => { const response = await fetchDataForIncidentsAndAlerts( @@ -252,6 +279,7 @@ const IncidentsPage = () => { prometheusResults, incidentForAlertProcessing, currentTime, + alertsTimestamps, ); dispatch( setAlertsData({ @@ -289,10 +317,7 @@ const IncidentsPage = () => { // fetch incident timestamps Promise.all( - [ - 'min_over_time(timestamp(cluster_health_components_map)[15d:5m])', - 'last_over_time(timestamp(cluster_health_components_map)[15d:5m])', - ].map(async (query) => { + ['min_over_time(timestamp(cluster_health_components_map)[15d:5m])'].map(async (query) => { const response = await fetchInstantData(safeFetch, query); return response.data.result; }), @@ -302,7 +327,6 @@ const IncidentsPage = () => { setIncidentsTimestamps({ incidentsTimestamps: { minOverTime: results[0], - lastOverTime: results[1], }, }), ); @@ -347,7 +371,9 @@ const IncidentsPage = () => { setIncidentsAreLoading(false); if (isGroupSelected) { - setIncidentForAlertProcessing(processIncidentsForAlerts(prometheusResults)); + setIncidentForAlertProcessing( + processIncidentsForAlerts(prometheusResults, incidentsTimestamps), + ); dispatch(setAlertsAreLoading({ alertsAreLoading: true })); } else { closeDropDownFilters(); diff --git a/web/src/components/Incidents/IncidentsTable.tsx b/web/src/components/Incidents/IncidentsTable.tsx index 0d348cdcd..ea455f18c 100644 --- a/web/src/components/Incidents/IncidentsTable.tsx +++ b/web/src/components/Incidents/IncidentsTable.tsx @@ -95,7 +95,7 @@ export const IncidentsTable = () => { if (!alert.alertsExpandedRowData || alert.alertsExpandedRowData.length === 0) { return 0; } - return Math.min(...alert.alertsExpandedRowData.map((alertData) => alertData.alertsStartFiring)); + return Math.min(...alert.alertsExpandedRowData.map((alertData) => alertData.firstTimestamp)); }; if (isEmpty(alertsTableData) || alertsAreLoading || isEmpty(incidentsActiveFilters.groupId)) { diff --git a/web/src/components/Incidents/model.ts b/web/src/components/Incidents/model.ts index 030192a32..c64dd48db 100644 --- a/web/src/components/Incidents/model.ts +++ b/web/src/components/Incidents/model.ts @@ -28,6 +28,11 @@ export type IncidentsTimestamps = { lastOverTime: Array; }; +export type AlertsTimestamps = { + minOverTime: Array; + lastOverTime: Array; +}; + // Define the interface for Metric export type Metric = { group_id: string; // The unique ID for grouping @@ -54,6 +59,8 @@ export type Alert = { severity: Severity; silenced: boolean; x: number; + firstTimestamp: number; + lastTimestamp: number; values: Array; alertsExpandedRowData?: Array; }; @@ -108,6 +115,8 @@ export type IncidentsDetailsAlert = { resolved: boolean; severity: Severity; x: number; + firstTimestamp: number; + lastTimestamp: number; values: Array; silenced: boolean; rule: { diff --git a/web/src/components/Incidents/processAlerts.ts b/web/src/components/Incidents/processAlerts.ts index b216aa2a8..0563e708a 100644 --- a/web/src/components/Incidents/processAlerts.ts +++ b/web/src/components/Incidents/processAlerts.ts @@ -1,7 +1,7 @@ /* eslint-disable max-len */ import { PrometheusResult, PrometheusRule } from '@openshift-console/dynamic-plugin-sdk'; -import { Alert, GroupedAlert, Incident, Severity } from './model'; +import { Alert, AlertsTimestamps, GroupedAlert, Incident, Severity } from './model'; import { insertPaddingPointsForChart, isResolved, @@ -202,6 +202,7 @@ export function convertToAlerts( prometheusResults: Array, selectedIncidents: Array>, currentTime: number, + alertsTimestamps: AlertsTimestamps, ): Array { // Merge selected incidents by composite key. Consolidates duplicates caused by non-key labels // like `pod` or `silenced` that aren't supported by cluster health analyzer. @@ -262,7 +263,7 @@ export function convertToAlerts( const firstTimestamp = paddedValues[0][0]; lastTimestamp = paddedValues[paddedValues.length - 1][0]; - return { + let labeledAlert = { alertname: alert.metric.alertname, namespace: alert.metric.namespace, severity: alert.metric.severity as Severity, @@ -277,6 +278,21 @@ export function convertToAlerts( x: 0, // Will be set after sorting silenced: matchingIncident.silenced ?? false, }; + + let matchedMinTimestamp = matchTimestampMetric(labeledAlert, alertsTimestamps.minOverTime); + if (!matchedMinTimestamp || matchedMinTimestamp.value[1] < matchingIncident.firstTimestamp) { + matchedMinTimestamp = { + value: [matchingIncident.firstTimestamp, matchingIncident.firstTimestamp.toString()], + }; + } + + if (matchedMinTimestamp) { + labeledAlert = { + ...labeledAlert, + firstTimestamp: parseInt(matchedMinTimestamp?.value?.[1] ?? '0'), + } as Alert; + } + return labeledAlert; }) .filter((alert): alert is Alert => alert !== null) .sort((a, b) => a.alertsStartFiring - b.alertsStartFiring) @@ -335,3 +351,19 @@ export const groupAlertsForTable = ( return groupedAlerts; }; + +/** + * Function to match a timestamp metric based on the common labels + * (alertname, namespace, severity) + * @param alert - The alert to match the timestamp for + * @param timestamps - The timestamps to match the alert for + * @returns The matched timestamp + */ +const matchTimestampMetric = (alert: Alert, timestamps: Array): any => { + return timestamps.find( + (timestamp) => + timestamp.metric.alertname === alert.alertname && + timestamp.metric.namespace === alert.namespace && + timestamp.metric.severity === alert.severity, + ); +}; diff --git a/web/src/components/Incidents/processIncidents.ts b/web/src/components/Incidents/processIncidents.ts index 803123f17..ad0174070 100644 --- a/web/src/components/Incidents/processIncidents.ts +++ b/web/src/components/Incidents/processIncidents.ts @@ -1,8 +1,13 @@ /* eslint-disable max-len */ import { PrometheusLabels, PrometheusResult } from '@openshift-console/dynamic-plugin-sdk'; -import { Incident, Metric, ProcessedIncident } from './model'; -import { insertPaddingPointsForChart, isResolved, sortByEarliestTimestamp } from './utils'; +import { Incident, IncidentsTimestamps, Metric, ProcessedIncident } from './model'; +import { + insertPaddingPointsForChart, + isResolved, + matchTimestampMetricForIncident, + sortByEarliestTimestamp, +} from './utils'; /** * Converts Prometheus results into processed incidents, filtering out Watchdog incidents. @@ -188,8 +193,21 @@ export const getIncidentsTimeRanges = ( */ export const processIncidentsForAlerts = ( incidents: Array, + incidentsTimestamps: IncidentsTimestamps, ): Array> => { - return incidents.map((incident, index) => { + const matchedIncidents = incidents.map((incident) => { + // expand matchTimestampMetricForIncident here + const matchedMinTimestamp = matchTimestampMetricForIncident( + incident.metric, + incidentsTimestamps.minOverTime, + ); + return { + ...incident, + firstTimestamp: parseInt(matchedMinTimestamp?.value?.[1] ?? '0'), + } as Partial; + }); + + return matchedIncidents.map((incident, index) => { // Read silenced value from cluster_health_components_map metric label // If missing, default to false const silenced = incident.metric.silenced === 'true'; @@ -200,6 +218,7 @@ export const processIncidentsForAlerts = ( values: incident.values, x: incidents.length - index, silenced, + firstTimestamp: incident.firstTimestamp, }; }); }; diff --git a/web/src/components/Incidents/utils.ts b/web/src/components/Incidents/utils.ts index 199718a9c..779a34e1b 100644 --- a/web/src/components/Incidents/utils.ts +++ b/web/src/components/Incidents/utils.ts @@ -302,7 +302,6 @@ export const createIncidentsChartBars = (incident: Incident, dateArray: SpanDate group_id: string; nodata: boolean; startDate: Date; - endDate: Date; fill: string; }[] = []; const getSeverityName = (value) => { @@ -328,7 +327,6 @@ export const createIncidentsChartBars = (incident: Incident, dateArray: SpanDate group_id: incident.group_id, nodata: groupedData[i][2] === 'nodata' ? true : false, startDate: new Date(incident.firstTimestamp * 1000), - endDate: new Date(incident.lastTimestamp * 1000), fill: severity === 'Critical' ? barChartColorScheme.critical @@ -386,9 +384,11 @@ export const createAlertsChartBars = (alert: IncidentsDetailsAlert): AlertsChart for (let i = 0; i < groupedData.length; i++) { const isLastElement = i === groupedData.length - 1; + data.push({ y0: new Date(groupedData[i][0] * 1000), y: new Date(groupedData[i][1] * 1000), + startDate: new Date(alert.firstTimestamp * 1000), x: alert.x, severity: alert.severity[0].toUpperCase() + alert.severity.slice(1), name: alert.alertname, @@ -448,7 +448,6 @@ export function generateDateArray(days: number, currentTime: number): Array { } return categoryName.toLowerCase(); }; + +/** + * Function to match a timestamp metric for an incident based on the common labels + * (group_id, src_alertname, src_namespace, src_severity) + * @param incident - The incident to match the timestamp for + * @param timestamps - The timestamps to match the incident for + * @returns The matched timestamp + */ +export const matchTimestampMetricForIncident = (incident: any, timestamps: Array): any => { + return timestamps.find( + (timestamp) => + timestamp.metric.group_id === incident.group_id && + timestamp.metric.src_alertname === incident.src_alertname && + timestamp.metric.src_namespace === incident.src_namespace && + timestamp.metric.component === incident.component && + timestamp.metric.src_severity === incident.src_severity, + ); +}; diff --git a/web/src/store/actions.ts b/web/src/store/actions.ts index 7672bc664..a921324cb 100644 --- a/web/src/store/actions.ts +++ b/web/src/store/actions.ts @@ -43,6 +43,7 @@ export enum ActionType { SetIncidentsChartSelection = 'setIncidentsChartSelection', SetFilteredIncidentsData = 'setFilteredIncidentsData', SetIncidentsTimestamps = 'setIncidentsTimestamps', + SetAlertsTimestamps = 'setAlertsTimestamps', SetIncidentPageFilterType = 'setIncidentPageFilterType', SetIncidentsLastRefreshTime = 'setIncidentsLastRefreshTime', } @@ -192,6 +193,9 @@ export const setFilteredIncidentsData = (filteredIncidentsData) => export const setIncidentsTimestamps = (incidentsTimestamps) => action(ActionType.SetIncidentsTimestamps, incidentsTimestamps); +export const setAlertsTimestamps = (alertsTimestamps) => + action(ActionType.SetAlertsTimestamps, alertsTimestamps); + export const setIncidentPageFilterType = (filterTypeSelected) => action(ActionType.SetIncidentPageFilterType, filterTypeSelected); @@ -239,6 +243,7 @@ type Actions = { setIncidentsChartSelection: typeof setIncidentsChartSelection; setFilteredIncidentsData: typeof setFilteredIncidentsData; setIncidentsTimestamps: typeof setIncidentsTimestamps; + setAlertsTimestamps: typeof setAlertsTimestamps; setIncidentPageFilterType: typeof setIncidentPageFilterType; setIncidentsLastRefreshTime: typeof setIncidentsLastRefreshTime; }; diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index e66feacf6..682192e2d 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -385,6 +385,11 @@ const monitoringReducer = produce((draft: ObserveState, action: ObserveAction): break; } + case ActionType.SetAlertsTimestamps: { + draft.incidentsData.alertsTimestamps = action.payload.alertsTimestamps; + break; + } + case ActionType.SetIncidentPageFilterType: { draft.incidentsData.incidentPageFilterType = action.payload.incidentPageFilterType; break; diff --git a/web/src/store/store.ts b/web/src/store/store.ts index 4e64739a8..7ce7c77fe 100644 --- a/web/src/store/store.ts +++ b/web/src/store/store.ts @@ -8,6 +8,7 @@ import { IncidentSeverityFilters, IncidentStateFilters, IncidentsTimestamps, + AlertsTimestamps, } from '../components/Incidents/model'; import { Variable } from '../components/dashboards/legacy/legacy-variable-dropdowns'; @@ -35,6 +36,7 @@ export type ObserveState = { alertsTableData: Array; filteredIncidentsData: Array; incidentsTimestamps: IncidentsTimestamps; + alertsTimestamps: AlertsTimestamps; alertsAreLoading: boolean; incidentsChartSelectedId: string; incidentsInitialState: { @@ -83,6 +85,7 @@ export const defaultObserveState: ObserveState = { alertsTableData: [], filteredIncidentsData: [], incidentsTimestamps: [], + alertsTimestamps: [], alertsAreLoading: true, incidentsChartSelectedId: '', incidentsInitialState: { From 97d076a4c622ceebb0ee0e6cfb1b7a51765a61f5 Mon Sep 17 00:00:00 2001 From: rioloc Date: Thu, 29 Jan 2026 16:26:35 +0100 Subject: [PATCH 03/10] feat: round absolute start dates to 5 minutes boundaries --- .../Incidents/AlertsChart/AlertsChart.tsx | 5 +++- .../IncidentsChart/IncidentsChart.tsx | 8 +++++- .../Incidents/IncidentsDetailsRowTable.tsx | 9 ++++--- .../components/Incidents/IncidentsTable.tsx | 5 +++- web/src/components/Incidents/utils.ts | 26 +++++++++++++++++-- 5 files changed, 45 insertions(+), 8 deletions(-) diff --git a/web/src/components/Incidents/AlertsChart/AlertsChart.tsx b/web/src/components/Incidents/AlertsChart/AlertsChart.tsx index ccbc2b70e..281a2b1a7 100644 --- a/web/src/components/Incidents/AlertsChart/AlertsChart.tsx +++ b/web/src/components/Incidents/AlertsChart/AlertsChart.tsx @@ -33,6 +33,7 @@ import { generateAlertsDateArray, getCurrentTime, roundDateToInterval, + roundTimestampToFiveMinutes, } from '../utils'; import { dateTimeFormatter, timeFormatter } from '../../console/utils/datetime'; import { useTranslation } from 'react-i18next'; @@ -162,7 +163,9 @@ const AlertsChart = ({ theme }: { theme: 'light' | 'dark' }) => { return ''; } const startDate = dateTimeFormatter(i18n.language).format( - new Date(datum.startDate), + new Date( + roundTimestampToFiveMinutes(datum.startDate.getTime() / 1000) * 1000, + ), ); const endDate = datum.alertstate === 'firing' diff --git a/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx b/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx index 23053c74b..a9916565d 100644 --- a/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx +++ b/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx @@ -34,7 +34,11 @@ import { createIncidentsChartBars, generateDateArray, matchTimestampMetricForIncident, +<<<<<<< HEAD roundDateToInterval, +======= + roundTimestampToFiveMinutes, +>>>>>>> 55a5bc3 (feat: round absolute start dates to 5 minutes boundaries) } from '../utils'; import { dateTimeFormatter, timeFormatter } from '../../console/utils/datetime'; import { useTranslation } from 'react-i18next'; @@ -194,7 +198,9 @@ const IncidentsChart = ({ return ''; } const startDate = dateTimeFormatter(i18n.language).format( - new Date(datum.startDate), + new Date( + roundTimestampToFiveMinutes(datum.startDate.getTime() / 1000) * 1000, + ), ); const endDate = datum.firing ? '---' diff --git a/web/src/components/Incidents/IncidentsDetailsRowTable.tsx b/web/src/components/Incidents/IncidentsDetailsRowTable.tsx index 8c9ac677d..e9b7b67e0 100644 --- a/web/src/components/Incidents/IncidentsDetailsRowTable.tsx +++ b/web/src/components/Incidents/IncidentsDetailsRowTable.tsx @@ -11,6 +11,7 @@ import { Alert, IncidentsDetailsAlert } from './model'; import { IncidentAlertStateIcon } from './IncidentAlertStateIcon'; import { useMemo } from 'react'; import { DataTestIDs } from '../data-test'; +import { roundTimestampToFiveMinutes } from './utils'; interface IncidentsDetailsRowTableProps { alerts: Alert[]; @@ -48,9 +49,11 @@ const IncidentsDetailsRowTable = ({ alerts }: IncidentsDetailsRowTableProps) => 0 - ? alertDetails.firstTimestamp - : alertDetails.alertsStartFiring) * 1000 + roundTimestampToFiveMinutes( + alertDetails.firstTimestamp > 0 + ? alertDetails.firstTimestamp + : alertDetails.alertsStartFiring, + ) * 1000 } /> diff --git a/web/src/components/Incidents/IncidentsTable.tsx b/web/src/components/Incidents/IncidentsTable.tsx index ea455f18c..529ce7b03 100644 --- a/web/src/components/Incidents/IncidentsTable.tsx +++ b/web/src/components/Incidents/IncidentsTable.tsx @@ -20,6 +20,7 @@ import { GroupedAlertStateIcon } from './IncidentAlertStateIcon'; import { GroupedAlert } from './model'; import { DataTestIDs } from '../data-test'; +import { roundTimestampToFiveMinutes } from './utils'; export const IncidentsTable = () => { const { t } = useTranslation(process.env.I18N_NAMESPACE); @@ -180,7 +181,9 @@ export const IncidentsTable = () => { )} - + { return new Date(roundedMs); }; +/** + * Rounds a timestamp down to the nearest 5-minute boundary. + * This ensures consistent display of start dates in tooltips and tables, + * matching the rounding behavior of end dates which come from Prometheus data + * that is already aligned to 5-minute intervals. + * + * @param timestampSeconds - Timestamp in seconds (Prometheus format) + * @returns Timestamp in seconds, rounded down to the nearest 5-minute boundary + * + * @example + * roundTimestampToFiveMinutes(1704067200) // 1704067200 (already on boundary) + * roundTimestampToFiveMinutes(1704067230) // 1704067200 (rounded down) + * roundTimestampToFiveMinutes(1704067499) // 1704067200 (rounded down) + * roundTimestampToFiveMinutes(1704067500) // 1704067500 (on next boundary) + */ +export const roundTimestampToFiveMinutes = (timestampSeconds: number): number => { + return ( + Math.floor(timestampSeconds / PROMETHEUS_QUERY_INTERVAL_SECONDS) * + PROMETHEUS_QUERY_INTERVAL_SECONDS + ); +}; + /** * Determines if an incident or alert is resolved based on the time elapsed since the last data point. * @@ -326,7 +348,7 @@ export const createIncidentsChartBars = (incident: Incident, dateArray: SpanDate componentList: incident.componentList || [], group_id: incident.group_id, nodata: groupedData[i][2] === 'nodata' ? true : false, - startDate: new Date(incident.firstTimestamp * 1000), + startDate: new Date(roundTimestampToFiveMinutes(incident.firstTimestamp) * 1000), fill: severity === 'Critical' ? barChartColorScheme.critical @@ -388,7 +410,7 @@ export const createAlertsChartBars = (alert: IncidentsDetailsAlert): AlertsChart data.push({ y0: new Date(groupedData[i][0] * 1000), y: new Date(groupedData[i][1] * 1000), - startDate: new Date(alert.firstTimestamp * 1000), + startDate: new Date(roundTimestampToFiveMinutes(alert.firstTimestamp) * 1000), x: alert.x, severity: alert.severity[0].toUpperCase() + alert.severity.slice(1), name: alert.alertname, From 6497191910b388ae918ff8acedfef26931c95cb0 Mon Sep 17 00:00:00 2001 From: rioloc Date: Fri, 30 Jan 2026 13:02:41 +0100 Subject: [PATCH 04/10] test: added unit to check absolute start dates --- web/src/components/Incidents/model.ts | 1 - .../Incidents/processAlerts.spec.ts | 230 +++++++++++++-- web/src/components/Incidents/processAlerts.ts | 5 +- .../Incidents/processIncidents.spec.ts | 262 ++++++++++++++++- web/src/components/Incidents/utils.spec.ts | 270 +++++++++++++++++- 5 files changed, 737 insertions(+), 31 deletions(-) diff --git a/web/src/components/Incidents/model.ts b/web/src/components/Incidents/model.ts index c64dd48db..14f4aee38 100644 --- a/web/src/components/Incidents/model.ts +++ b/web/src/components/Incidents/model.ts @@ -60,7 +60,6 @@ export type Alert = { silenced: boolean; x: number; firstTimestamp: number; - lastTimestamp: number; values: Array; alertsExpandedRowData?: Array; }; diff --git a/web/src/components/Incidents/processAlerts.spec.ts b/web/src/components/Incidents/processAlerts.spec.ts index 8c424a72d..496bc971b 100644 --- a/web/src/components/Incidents/processAlerts.spec.ts +++ b/web/src/components/Incidents/processAlerts.spec.ts @@ -1,15 +1,19 @@ import { PrometheusResult } from '@openshift-console/dynamic-plugin-sdk'; import { convertToAlerts, deduplicateAlerts } from './processAlerts'; -import { Incident } from './model'; +import { AlertsTimestamps, Incident } from './model'; import { getCurrentTime } from './utils'; describe('convertToAlerts', () => { const now = getCurrentTime(); const nowSeconds = Math.floor(now / 1000); + const emptyAlertsTimestamps: AlertsTimestamps = { + minOverTime: [], + lastOverTime: [], + }; describe('edge cases', () => { it('should return empty array when no prometheus results provided', () => { - const result = convertToAlerts([], [], now); + const result = convertToAlerts([], [], now, emptyAlertsTimestamps); expect(result).toEqual([]); }); @@ -28,7 +32,7 @@ describe('convertToAlerts', () => { values: [[nowSeconds, '1']], }, ]; - const result = convertToAlerts(prometheusResults, [], now); + const result = convertToAlerts(prometheusResults, [], now, emptyAlertsTimestamps); expect(result).toEqual([]); }); @@ -69,7 +73,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); expect(result).toHaveLength(1); expect(result[0].alertname).toBe('ClusterOperatorDegraded'); }); @@ -113,7 +117,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); expect(result).toHaveLength(1); // Should include values within incident time + 30s padding // Plus padding points added by insertPaddingPointsForChart @@ -148,7 +152,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); expect(result).toEqual([]); }); }); @@ -182,7 +186,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); expect(result).toHaveLength(1); // Verify resolved is determined from ORIGINAL values (before padding) @@ -226,7 +230,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); expect(result).toHaveLength(1); expect(result[0].alertsStartFiring).toBeGreaterThan(0); expect(result[0].alertsEndFiring).toBeGreaterThan(0); @@ -268,16 +272,16 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); expect(result).toHaveLength(1); expect(result[0].alertstate).toBe('resolved'); expect(result[0].resolved).toBe(true); }); it('should mark alert as firing if ended less than 10 minutes ago', () => { - const recentTimestamp = nowSeconds - 840; // 14 minutes ago - // After padding (+300s), last timestamp will be 9 minutes ago (840-300=540s ago) - // which is < 10 minutes, so it should still be firing + const recentTimestamp = nowSeconds - 540; // 9 minutes ago + // Resolved check is done on original timestamp (before padding) + // 9 minutes ago is < 10 minutes, so it should still be firing const prometheusResults: PrometheusResult[] = [ { @@ -304,7 +308,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); expect(result).toHaveLength(1); expect(result[0].alertstate).toBe('firing'); expect(result[0].resolved).toBe(false); @@ -357,7 +361,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); expect(result).toHaveLength(2); expect(result[0].alertname).toBe('Alert1'); // Earlier alert first expect(result[1].alertname).toBe('Alert2'); @@ -408,7 +412,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); expect(result).toHaveLength(2); expect(result[0].x).toBe(2); // Earliest alert has highest x expect(result[1].x).toBe(1); // Latest alert has lowest x @@ -443,7 +447,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); expect(result).toHaveLength(1); expect(result[0].silenced).toBe(true); }); @@ -489,7 +493,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); expect(result).toHaveLength(1); // Should use the silenced value from the latest timestamp expect(result[0].silenced).toBe(true); @@ -523,7 +527,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); expect(result).toHaveLength(1); expect(result[0].alertname).toBe('MyAlert'); expect(result[0].namespace).toBe('my-namespace'); @@ -533,6 +537,196 @@ describe('convertToAlerts', () => { expect(result[0].name).toBe('my-name'); }); }); + + describe('timestamp matching', () => { + it('should use matched minOverTime timestamp when available and newer than incident firstTimestamp', () => { + const timestamp = nowSeconds - 600; + const matchedMinTimestamp = nowSeconds - 300; // 5 minutes ago (newer than incident) + + const prometheusResults: PrometheusResult[] = [ + { + metric: { + alertname: 'TestAlert', + namespace: 'test-namespace', + severity: 'critical', + alertstate: 'firing', + }, + values: [[timestamp, '2']], + }, + ]; + + const incidents: Array> = [ + { + group_id: 'incident1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + src_severity: 'critical', + component: 'test-component', + layer: 'test-layer', + values: [[timestamp, '2']], + }, + ]; + + const alertsTimestamps: AlertsTimestamps = { + minOverTime: [ + { + metric: { + alertname: 'TestAlert', + namespace: 'test-namespace', + severity: 'critical', + }, + value: [matchedMinTimestamp, matchedMinTimestamp.toString()], + }, + ], + lastOverTime: [], + }; + + const result = convertToAlerts(prometheusResults, incidents, now, alertsTimestamps); + expect(result).toHaveLength(1); + expect(result[0].firstTimestamp).toBe(matchedMinTimestamp); + }); + + it('should use incident firstTimestamp when matched timestamp is older than incident firstTimestamp', () => { + const timestamp = nowSeconds - 600; + const incidentFirstTimestamp = nowSeconds - 1800; // 30 minutes ago + const matchedMinTimestamp = nowSeconds - 3600; // 1 hour ago (older) + + const prometheusResults: PrometheusResult[] = [ + { + metric: { + alertname: 'TestAlert', + namespace: 'test-namespace', + severity: 'critical', + name: 'test', + alertstate: 'firing', + }, + values: [[timestamp, '2']], + }, + ]; + + const incidents: Array> = [ + { + group_id: 'incident1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + src_severity: 'critical', + component: 'test-component', + layer: 'test-layer', + firstTimestamp: incidentFirstTimestamp, + values: [[timestamp, '2']], + }, + ]; + + const alertsTimestamps: AlertsTimestamps = { + minOverTime: [ + { + metric: { + alertname: 'TestAlert', + namespace: 'test-namespace', + severity: 'critical', + }, + value: [matchedMinTimestamp, matchedMinTimestamp.toString()], + }, + ], + lastOverTime: [], + }; + + const result = convertToAlerts(prometheusResults, incidents, now, alertsTimestamps); + expect(result).toHaveLength(1); + // Should use incident firstTimestamp because matched timestamp is older + expect(result[0].firstTimestamp).toBe(incidentFirstTimestamp); + }); + + it('should use matched timestamp when it is newer than incident firstTimestamp', () => { + const timestamp = nowSeconds - 600; + const incidentFirstTimestamp = nowSeconds - 3600; // 1 hour ago + const matchedMinTimestamp = nowSeconds - 1800; // 30 minutes ago (newer) + + const prometheusResults: PrometheusResult[] = [ + { + metric: { + alertname: 'TestAlert', + namespace: 'test-namespace', + severity: 'critical', + name: 'test', + alertstate: 'firing', + }, + values: [[timestamp, '2']], + }, + ]; + + const incidents: Array> = [ + { + group_id: 'incident1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + src_severity: 'critical', + component: 'test-component', + layer: 'test-layer', + firstTimestamp: incidentFirstTimestamp, + values: [[timestamp, '2']], + }, + ]; + + const alertsTimestamps: AlertsTimestamps = { + minOverTime: [ + { + metric: { + alertname: 'TestAlert', + namespace: 'test-namespace', + severity: 'critical', + }, + value: [matchedMinTimestamp, matchedMinTimestamp.toString()], + }, + ], + lastOverTime: [], + }; + + const result = convertToAlerts(prometheusResults, incidents, now, alertsTimestamps); + expect(result).toHaveLength(1); + // Should use matched timestamp because it's newer + expect(result[0].firstTimestamp).toBe(matchedMinTimestamp); + }); + + it('should default to 0 when no timestamp is available', () => { + const timestamp = nowSeconds - 600; + + const prometheusResults: PrometheusResult[] = [ + { + metric: { + alertname: 'TestAlert', + namespace: 'test-namespace', + severity: 'critical', + name: 'test', + alertstate: 'firing', + }, + values: [[timestamp, '2']], + }, + ]; + + const incidents: Array> = [ + { + group_id: 'incident1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + src_severity: 'critical', + component: 'test-component', + layer: 'test-layer', + // No firstTimestamp + values: [[timestamp, '2']], + }, + ]; + + const alertsTimestamps: AlertsTimestamps = { + minOverTime: [], // No match + lastOverTime: [], + }; + + const result = convertToAlerts(prometheusResults, incidents, now, alertsTimestamps); + expect(result).toHaveLength(1); + expect(result[0].firstTimestamp).toBe(0); + }); + }); }); describe('deduplicateAlerts', () => { diff --git a/web/src/components/Incidents/processAlerts.ts b/web/src/components/Incidents/processAlerts.ts index 0563e708a..962e00dae 100644 --- a/web/src/components/Incidents/processAlerts.ts +++ b/web/src/components/Incidents/processAlerts.ts @@ -263,7 +263,7 @@ export function convertToAlerts( const firstTimestamp = paddedValues[0][0]; lastTimestamp = paddedValues[paddedValues.length - 1][0]; - let labeledAlert = { + let labeledAlert: Alert = { alertname: alert.metric.alertname, namespace: alert.metric.namespace, severity: alert.metric.severity as Severity, @@ -277,10 +277,11 @@ export function convertToAlerts( resolved, x: 0, // Will be set after sorting silenced: matchingIncident.silenced ?? false, + firstTimestamp: 0, // Will be set from matched timestamp }; let matchedMinTimestamp = matchTimestampMetric(labeledAlert, alertsTimestamps.minOverTime); - if (!matchedMinTimestamp || matchedMinTimestamp.value[1] < matchingIncident.firstTimestamp) { + if (matchedMinTimestamp && matchedMinTimestamp.value[1] < matchingIncident.firstTimestamp) { matchedMinTimestamp = { value: [matchingIncident.firstTimestamp, matchingIncident.firstTimestamp.toString()], }; diff --git a/web/src/components/Incidents/processIncidents.spec.ts b/web/src/components/Incidents/processIncidents.spec.ts index ab6ccd631..c4328e645 100644 --- a/web/src/components/Incidents/processIncidents.spec.ts +++ b/web/src/components/Incidents/processIncidents.spec.ts @@ -5,6 +5,7 @@ import { getIncidentsTimeRanges, processIncidentsForAlerts, } from './processIncidents'; +import { IncidentsTimestamps } from './model'; import { getCurrentTime } from './utils'; describe('convertToIncidents', () => { @@ -668,6 +669,11 @@ describe('getIncidentsTimeRanges', () => { }); describe('processIncidentsForAlerts', () => { + const emptyTimestamps: IncidentsTimestamps = { + minOverTime: [], + lastOverTime: [], + }; + describe('silenced status conversion', () => { it('should convert silenced "true" string to boolean true', () => { const incidents: PrometheusResult[] = [ @@ -680,7 +686,7 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents); + const result = processIncidentsForAlerts(incidents, emptyTimestamps); expect(result).toHaveLength(1); expect(result[0].silenced).toBe(true); }); @@ -696,7 +702,7 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents); + const result = processIncidentsForAlerts(incidents, emptyTimestamps); expect(result).toHaveLength(1); expect(result[0].silenced).toBe(false); }); @@ -711,7 +717,7 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents); + const result = processIncidentsForAlerts(incidents, emptyTimestamps); expect(result).toHaveLength(1); expect(result[0].silenced).toBe(false); }); @@ -727,7 +733,7 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents); + const result = processIncidentsForAlerts(incidents, emptyTimestamps); expect(result).toHaveLength(1); expect(result[0].silenced).toBe(false); }); @@ -750,7 +756,7 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents); + const result = processIncidentsForAlerts(incidents, emptyTimestamps); expect(result).toHaveLength(3); expect(result[0].x).toBe(3); expect(result[1].x).toBe(2); @@ -772,7 +778,7 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents); + const result = processIncidentsForAlerts(incidents, emptyTimestamps); expect(result).toHaveLength(1); expect(result[0].group_id).toBe('incident1'); expect(result[0].component).toBe('test-component'); @@ -793,15 +799,248 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents); + const result = processIncidentsForAlerts(incidents, emptyTimestamps); expect(result).toHaveLength(1); expect(result[0].values).toEqual(values); }); }); + describe('timestamp matching', () => { + it('should use matched minOverTime timestamp when available', () => { + const matchedMinTimestamp = 1704067200; // 2024-01-01 00:00:00 UTC + + const incidents: PrometheusResult[] = [ + { + metric: { + group_id: 'incident1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }, + values: [[1704067300, '2']], + }, + ]; + + const incidentsTimestamps: IncidentsTimestamps = { + minOverTime: [ + { + metric: { + group_id: 'incident1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }, + value: [matchedMinTimestamp, matchedMinTimestamp.toString()], + }, + ], + lastOverTime: [], + }; + + const result = processIncidentsForAlerts(incidents, incidentsTimestamps); + expect(result).toHaveLength(1); + expect(result[0].firstTimestamp).toBe(matchedMinTimestamp); + }); + + it('should default to 0 when no timestamp match is found', () => { + const incidents: PrometheusResult[] = [ + { + metric: { + group_id: 'incident1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }, + values: [[1704067300, '2']], + }, + ]; + + const incidentsTimestamps: IncidentsTimestamps = { + minOverTime: [], // No match + lastOverTime: [], + }; + + const result = processIncidentsForAlerts(incidents, incidentsTimestamps); + expect(result).toHaveLength(1); + expect(result[0].firstTimestamp).toBe(0); + }); + + it('should match timestamp based on all required labels', () => { + const matchedMinTimestamp = 1704067200; + + const incidents: PrometheusResult[] = [ + { + metric: { + group_id: 'incident1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }, + values: [[1704067300, '2']], + }, + ]; + + const incidentsTimestamps: IncidentsTimestamps = { + minOverTime: [ + { + metric: { + group_id: 'incident1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }, + value: [matchedMinTimestamp, matchedMinTimestamp.toString()], + }, + ], + lastOverTime: [], + }; + + const result = processIncidentsForAlerts(incidents, incidentsTimestamps); + expect(result).toHaveLength(1); + expect(result[0].firstTimestamp).toBe(matchedMinTimestamp); + }); + + it('should not match when group_id differs', () => { + const incidents: PrometheusResult[] = [ + { + metric: { + group_id: 'incident1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }, + values: [[1704067300, '2']], + }, + ]; + + const incidentsTimestamps: IncidentsTimestamps = { + minOverTime: [ + { + metric: { + group_id: 'incident2', // Different + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }, + value: [1704067200, '1704067200'], + }, + ], + lastOverTime: [], + }; + + const result = processIncidentsForAlerts(incidents, incidentsTimestamps); + expect(result).toHaveLength(1); + expect(result[0].firstTimestamp).toBe(0); // No match, defaults to 0 + }); + + it('should not match when component differs', () => { + const incidents: PrometheusResult[] = [ + { + metric: { + group_id: 'incident1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }, + values: [[1704067300, '2']], + }, + ]; + + const incidentsTimestamps: IncidentsTimestamps = { + minOverTime: [ + { + metric: { + group_id: 'incident1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'other-component', // Different + src_severity: 'critical', + }, + value: [1704067200, '1704067200'], + }, + ], + lastOverTime: [], + }; + + const result = processIncidentsForAlerts(incidents, incidentsTimestamps); + expect(result).toHaveLength(1); + expect(result[0].firstTimestamp).toBe(0); // No match, defaults to 0 + }); + + it('should handle multiple incidents with different timestamps', () => { + const matchedTimestamp1 = 1704067200; + const matchedTimestamp2 = 1704067500; + + const incidents: PrometheusResult[] = [ + { + metric: { + group_id: 'incident1', + src_alertname: 'TestAlert1', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }, + values: [[1704067300, '2']], + }, + { + metric: { + group_id: 'incident2', + src_alertname: 'TestAlert2', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'warning', + }, + values: [[1704067600, '1']], + }, + ]; + + const incidentsTimestamps: IncidentsTimestamps = { + minOverTime: [ + { + metric: { + group_id: 'incident1', + src_alertname: 'TestAlert1', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }, + value: [matchedTimestamp1, matchedTimestamp1.toString()], + }, + { + metric: { + group_id: 'incident2', + src_alertname: 'TestAlert2', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'warning', + }, + value: [matchedTimestamp2, matchedTimestamp2.toString()], + }, + ], + lastOverTime: [], + }; + + const result = processIncidentsForAlerts(incidents, incidentsTimestamps); + expect(result).toHaveLength(2); + expect(result[0].firstTimestamp).toBe(matchedTimestamp1); + expect(result[1].firstTimestamp).toBe(matchedTimestamp2); + }); + }); + describe('edge cases', () => { it('should handle empty array', () => { - const result = processIncidentsForAlerts([]); + const emptyTimestamps: IncidentsTimestamps = { + minOverTime: [], + lastOverTime: [], + }; + const result = processIncidentsForAlerts([], emptyTimestamps); expect(result).toEqual([]); }); @@ -813,7 +1052,12 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents); + const emptyTimestamps: IncidentsTimestamps = { + minOverTime: [], + lastOverTime: [], + }; + + const result = processIncidentsForAlerts(incidents, emptyTimestamps); expect(result).toHaveLength(1); expect(result[0].group_id).toBe('incident1'); expect(result[0].silenced).toBe(true); diff --git a/web/src/components/Incidents/utils.spec.ts b/web/src/components/Incidents/utils.spec.ts index 97c55df8f..20faa7e79 100644 --- a/web/src/components/Incidents/utils.spec.ts +++ b/web/src/components/Incidents/utils.spec.ts @@ -1,4 +1,272 @@ -import { insertPaddingPointsForChart, roundDateToInterval } from './utils'; +import { + getCurrentTime, + insertPaddingPointsForChart, roundDateToInterval, + matchTimestampMetricForIncident, + roundTimestampToFiveMinutes, +} from './utils'; + +describe('getCurrentTime', () => { + it('should return current time rounded down to 5-minute boundary', () => { + const result = getCurrentTime(); + const now = Date.now(); + const intervalMs = 300 * 1000; // 5 minutes in milliseconds + const expected = Math.floor(now / intervalMs) * intervalMs; + + expect(result).toBe(expected); + expect(result % intervalMs).toBe(0); // Should be on 5-minute boundary + }); + + it('should round down to nearest 5-minute boundary', () => { + // Mock Date.now() to return a specific time + const mockTime = 1704067230 * 1000; // 2024-01-01 00:00:30 UTC (30 seconds past) + jest.spyOn(Date, 'now').mockReturnValue(mockTime); + + const result = getCurrentTime(); + const expected = 1704067200 * 1000; // 2024-01-01 00:00:00 UTC (rounded down) + + expect(result).toBe(expected); + + jest.restoreAllMocks(); + }); + + it('should return same value for times on 5-minute boundaries', () => { + const mockTime = 1704067200 * 1000; // 2024-01-01 00:00:00 UTC (on boundary) + jest.spyOn(Date, 'now').mockReturnValue(mockTime); + + const result = getCurrentTime(); + expect(result).toBe(mockTime); + + jest.restoreAllMocks(); + }); +}); + +describe('roundTimestampToFiveMinutes', () => { + it('should return same value when timestamp is already on 5-minute boundary', () => { + const timestamp = 1704067200; // 2024-01-01 00:00:00 UTC + const result = roundTimestampToFiveMinutes(timestamp); + expect(result).toBe(1704067200); + }); + + it('should round down timestamp that is 30 seconds past boundary', () => { + const timestamp = 1704067230; // 2024-01-01 00:00:30 UTC + const result = roundTimestampToFiveMinutes(timestamp); + expect(result).toBe(1704067200); // Rounded down to 00:00:00 + }); + + it('should round down timestamp that is 4 minutes 59 seconds past boundary', () => { + const timestamp = 1704067499; // 2024-01-01 00:04:59 UTC + const result = roundTimestampToFiveMinutes(timestamp); + expect(result).toBe(1704067200); // Rounded down to 00:00:00 + }); + + it('should return next boundary when timestamp is exactly on next boundary', () => { + const timestamp = 1704067500; // 2024-01-01 00:05:00 UTC + const result = roundTimestampToFiveMinutes(timestamp); + expect(result).toBe(1704067500); // Already on boundary + }); + + it('should round down timestamp that is 1 second past boundary', () => { + const timestamp = 1704067201; // 2024-01-01 00:00:01 UTC + const result = roundTimestampToFiveMinutes(timestamp); + expect(result).toBe(1704067200); // Rounded down + }); + + it('should handle timestamps with large values', () => { + const timestamp = 1735689600; // 2025-01-01 00:00:00 UTC + const result = roundTimestampToFiveMinutes(timestamp); + expect(result).toBe(1735689600); + }); + + it('should round down correctly for various offsets', () => { + const base = 1704067200; // 2024-01-01 00:00:00 UTC + + expect(roundTimestampToFiveMinutes(base + 0)).toBe(base); // On boundary + expect(roundTimestampToFiveMinutes(base + 1)).toBe(base); // 1 second + expect(roundTimestampToFiveMinutes(base + 60)).toBe(base); // 1 minute + expect(roundTimestampToFiveMinutes(base + 299)).toBe(base); // 4 min 59 sec + expect(roundTimestampToFiveMinutes(base + 300)).toBe(base + 300); // 5 minutes (next boundary) + expect(roundTimestampToFiveMinutes(base + 301)).toBe(base + 300); // 5 min 1 sec + }); +}); + +describe('matchTimestampMetricForIncident', () => { + it('should match timestamp metric when all labels match', () => { + const incident = { + group_id: 'group1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }; + + const timestamps = [ + { + metric: { + group_id: 'group1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }, + value: [1704067200, '1704067200'], + }, + { + metric: { + group_id: 'group2', + src_alertname: 'OtherAlert', + src_namespace: 'other-namespace', + component: 'other-component', + src_severity: 'warning', + }, + value: [1704067300, '1704067300'], + }, + ]; + + const result = matchTimestampMetricForIncident(incident, timestamps); + expect(result).toBe(timestamps[0]); + }); + + it('should return undefined when no match is found', () => { + const incident = { + group_id: 'group1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }; + + const timestamps = [ + { + metric: { + group_id: 'group2', + src_alertname: 'OtherAlert', + src_namespace: 'other-namespace', + component: 'other-component', + src_severity: 'warning', + }, + value: [1704067300, '1704067300'], + }, + ]; + + const result = matchTimestampMetricForIncident(incident, timestamps); + expect(result).toBeUndefined(); + }); + + const incident = { + group_id: 'group1', + src_alertname: 'Alert1', + src_namespace: 'ns1', + component: 'comp1', + src_severity: 'warning', + }; + + const timestamps = [ + { + metric: { + group_id: 'group1', + src_alertname: 'Alert1', + src_namespace: 'ns1', + component: 'comp1', + src_severity: 'warning', + }, + value: [1704067200, '1704067200'], + }, + ]; + + const result = matchTimestampMetricForIncident(incident, timestamps); + expect(result).toBe(timestamps[0]); +}); + +it('should not match when group_id differs', () => { + const incident = { + group_id: 'group1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }; + + const timestamps = [ + { + metric: { + group_id: 'group2', // Different + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }, + value: [1704067200, '1704067200'], + }, + ]; + + const result = matchTimestampMetricForIncident(incident, timestamps); + expect(result).toBeUndefined(); +}); + +it('should not match when src_alertname differs', () => { + const incident = { + group_id: 'group1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }; + + const timestamps = [ + { + metric: { + group_id: 'group1', + src_alertname: 'OtherAlert', // Different + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }, + value: [1704067200, '1704067200'], + }, + ]; + + const result = matchTimestampMetricForIncident(incident, timestamps); + expect(result).toBeUndefined(); +}); + +it('should not match when component differs', () => { + const incident = { + group_id: 'group1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }; + + const timestamps = [ + { + metric: { + group_id: 'group1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'other-component', // Different + src_severity: 'critical', + }, + value: [1704067200, '1704067200'], + }, + ]; + + const result = matchTimestampMetricForIncident(incident, timestamps); + expect(result).toBeUndefined(); +}); + +it('should handle empty timestamps array', () => { + const incident = { + group_id: 'group1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }; + + const result = matchTimestampMetricForIncident(incident, []); + expect(result).toBeUndefined(); +}); describe('insertPaddingPointsForChart', () => { describe('edge cases', () => { From d857007209ae4a2fc0c92189a0c295e751be087d Mon Sep 17 00:00:00 2001 From: rioloc Date: Fri, 30 Jan 2026 17:28:52 +0100 Subject: [PATCH 05/10] fix: resolve stale closure issues causing empty AlertsChart and IncidentsTable on page refresh - Fix initial state type mismatch for incidentsTimestamps and alertsTimestamps (was [] instead of { minOverTime: [], lastOverTime: [] }) - Add defensive check in matchTimestampMetricForIncident for undefined timestamps - Refactor incidents useEffect to fetch timestamps and incidents in parallel, then use fetched values directly instead of stale closure values - Refactor alerts useEffect with same pattern and add guards for empty incidentForAlertProcessing and timeRanges - Add timeRanges and rules to alerts useEffect dependency array - Remove unused alertsTimestamps selector Assisted-By: Claude Opus 4.5 --- .../components/Incidents/IncidentsPage.tsx | 182 +++++++++--------- web/src/components/Incidents/utils.ts | 3 + web/src/store/store.ts | 4 +- 3 files changed, 95 insertions(+), 94 deletions(-) diff --git a/web/src/components/Incidents/IncidentsPage.tsx b/web/src/components/Incidents/IncidentsPage.tsx index dd0b7ae83..674b53703 100644 --- a/web/src/components/Incidents/IncidentsPage.tsx +++ b/web/src/components/Incidents/IncidentsPage.tsx @@ -162,10 +162,6 @@ const IncidentsPage = () => { (state: MonitoringState) => state.plugins.mcp.incidentsData.incidentsLastRefreshTime, ); - const alertsTimestamps = useSelector( - (state: MonitoringState) => state.plugins.mcp.incidentsData.alertsTimestamps, - ); - const closeDropDownFilters = (): void => { setFiltersExpanded({ severity: false, @@ -239,102 +235,83 @@ const IncidentsPage = () => { }, [incidentsActiveFilters.days]); useEffect(() => { - (async () => { - const currentTime = incidentsLastRefreshTime; - - // fetch alerts timestamps - Promise.all( - ['min_over_time(timestamp(ALERTS{alertstate="firing"})[15d:5m])'].map(async (query) => { - const response = await fetchInstantData(safeFetch, query); - return response.data.result; - }), - ) - .then((results) => { - dispatch( - setAlertsTimestamps({ - alertsTimestamps: { - minOverTime: results[0], - }, - }), - ); - }) - .catch((err) => { - // eslint-disable-next-line no-console - console.log(err); - }); - - Promise.all( - timeRanges.map(async (range) => { - const response = await fetchDataForIncidentsAndAlerts( - safeFetch, - range, - createAlertsQuery(incidentForAlertProcessing), - ); - return response.data.result; - }), - ) - .then((results) => { - const prometheusResults = results.flat(); - const alerts = convertToAlerts( - prometheusResults, - incidentForAlertProcessing, - currentTime, - alertsTimestamps, - ); - dispatch( - setAlertsData({ - alertsData: alerts, - }), - ); - if (rules && alerts) { - dispatch( - setAlertsTableData({ - alertsTableData: groupAlertsForTable(alerts, rules), - }), - ); - } - if (!isEmpty(filteredData)) { - dispatch(setAlertsAreLoading({ alertsAreLoading: false })); - } else { - dispatch(setAlertsAreLoading({ alertsAreLoading: true })); - } - }) - .catch((err) => { - // eslint-disable-next-line no-console - console.log(err); - }); - })(); - }, [incidentForAlertProcessing]); - - useEffect(() => { - if (!isInitialized) return; - - setIncidentsAreLoading(true); + // Guard: don't process if no incidents selected or timeRanges not ready + if (incidentForAlertProcessing.length === 0 || timeRanges.length === 0) { + return; + } - // Set refresh time before making queries - const currentTime = getCurrentTime(); - dispatch(setIncidentsLastRefreshTime(currentTime)); + const currentTime = incidentsLastRefreshTime; - // fetch incident timestamps - Promise.all( - ['min_over_time(timestamp(cluster_health_components_map)[15d:5m])'].map(async (query) => { + // Fetch timestamps and alerts in parallel, but wait for both before processing + const timestampsPromise = Promise.all( + ['min_over_time(timestamp(ALERTS{alertstate="firing"})[15d:5m])'].map(async (query) => { const response = await fetchInstantData(safeFetch, query); return response.data.result; }), - ) - .then((results) => { + ); + + const alertsPromise = Promise.all( + timeRanges.map(async (range) => { + const response = await fetchDataForIncidentsAndAlerts( + safeFetch, + range, + createAlertsQuery(incidentForAlertProcessing), + ); + return response.data.result; + }), + ); + + Promise.all([timestampsPromise, alertsPromise]) + .then(([timestampsResults, alertsResults]) => { + // Dispatch timestamps to store + const fetchedAlertsTimestamps = { + minOverTime: timestampsResults[0], + }; dispatch( - setIncidentsTimestamps({ - incidentsTimestamps: { - minOverTime: results[0], - }, + setAlertsTimestamps({ + alertsTimestamps: fetchedAlertsTimestamps, + }), + ); + + const prometheusResults = alertsResults.flat(); + const alerts = convertToAlerts( + prometheusResults, + incidentForAlertProcessing, + currentTime, + fetchedAlertsTimestamps, + ); + dispatch( + setAlertsData({ + alertsData: alerts, }), ); + if (rules && alerts) { + dispatch( + setAlertsTableData({ + alertsTableData: groupAlertsForTable(alerts, rules), + }), + ); + } + if (!isEmpty(filteredData)) { + dispatch(setAlertsAreLoading({ alertsAreLoading: false })); + } else { + dispatch(setAlertsAreLoading({ alertsAreLoading: true })); + } }) .catch((err) => { // eslint-disable-next-line no-console console.log(err); }); + }, [incidentForAlertProcessing, timeRanges, rules]); + + useEffect(() => { + if (!isInitialized) return; + + setIncidentsAreLoading(true); + + // Set refresh time before making queries + const currentTime = getCurrentTime(); + dispatch(setIncidentsLastRefreshTime(currentTime)); const daysDuration = parsePrometheusDuration( incidentsActiveFilters.days.length > 0 @@ -348,14 +325,34 @@ const IncidentsPage = () => { ? `cluster_health_components_map{group_id='${selectedGroupId}'}` : 'cluster_health_components_map'; - Promise.all( + // Fetch timestamps and incidents in parallel, but wait for both before processing + const timestampsPromise = Promise.all( + ['min_over_time(timestamp(cluster_health_components_map)[15d:5m])'].map(async (query) => { + const response = await fetchInstantData(safeFetch, query); + return response.data.result; + }), + ); + + const incidentsPromise = Promise.all( calculatedTimeRanges.map(async (range) => { const response = await fetchDataForIncidentsAndAlerts(safeFetch, range, incidentsQuery); return response.data.result; }), - ) - .then((results) => { - const prometheusResults = results.flat(); + ); + + Promise.all([timestampsPromise, incidentsPromise]) + .then(([timestampsResults, incidentsResults]) => { + // Dispatch timestamps to store + const fetchedTimestamps = { + minOverTime: timestampsResults[0], + }; + dispatch( + setIncidentsTimestamps({ + incidentsTimestamps: fetchedTimestamps, + }), + ); + + const prometheusResults = incidentsResults.flat(); const incidents = convertToIncidents(prometheusResults, currentTime); // Update the raw, unfiltered incidents state @@ -371,8 +368,9 @@ const IncidentsPage = () => { setIncidentsAreLoading(false); if (isGroupSelected) { + // Use fetchedTimestamps directly instead of stale closure value setIncidentForAlertProcessing( - processIncidentsForAlerts(prometheusResults, incidentsTimestamps), + processIncidentsForAlerts(prometheusResults, fetchedTimestamps), ); dispatch(setAlertsAreLoading({ alertsAreLoading: true })); } else { diff --git a/web/src/components/Incidents/utils.ts b/web/src/components/Incidents/utils.ts index 228c41938..e03afd00b 100644 --- a/web/src/components/Incidents/utils.ts +++ b/web/src/components/Incidents/utils.ts @@ -936,6 +936,9 @@ export const getFilterKey = (categoryName: string): string => { * @returns The matched timestamp */ export const matchTimestampMetricForIncident = (incident: any, timestamps: Array): any => { + if (!timestamps || !Array.isArray(timestamps)) { + return undefined; + } return timestamps.find( (timestamp) => timestamp.metric.group_id === incident.group_id && diff --git a/web/src/store/store.ts b/web/src/store/store.ts index 7ce7c77fe..345f7af29 100644 --- a/web/src/store/store.ts +++ b/web/src/store/store.ts @@ -84,8 +84,8 @@ export const defaultObserveState: ObserveState = { alertsData: [], alertsTableData: [], filteredIncidentsData: [], - incidentsTimestamps: [], - alertsTimestamps: [], + incidentsTimestamps: { minOverTime: [], lastOverTime: [] }, + alertsTimestamps: { minOverTime: [], lastOverTime: [] }, alertsAreLoading: true, incidentsChartSelectedId: '', incidentsInitialState: { From b8f4ed7ecbc3577c13d2dab3e89d857bbb20b5e9 Mon Sep 17 00:00:00 2001 From: rioloc Date: Fri, 30 Jan 2026 20:44:53 +0100 Subject: [PATCH 06/10] feat: edge case when 5m aggregated timestamp is greater than current datapoint --- .../Incidents/IncidentsChart/IncidentsChart.tsx | 3 --- web/src/components/Incidents/utils.spec.ts | 3 ++- web/src/components/Incidents/utils.ts | 12 ++++++++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx b/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx index a9916565d..6b2cc17f0 100644 --- a/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx +++ b/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx @@ -34,11 +34,8 @@ import { createIncidentsChartBars, generateDateArray, matchTimestampMetricForIncident, -<<<<<<< HEAD roundDateToInterval, -======= roundTimestampToFiveMinutes, ->>>>>>> 55a5bc3 (feat: round absolute start dates to 5 minutes boundaries) } from '../utils'; import { dateTimeFormatter, timeFormatter } from '../../console/utils/datetime'; import { useTranslation } from 'react-i18next'; diff --git a/web/src/components/Incidents/utils.spec.ts b/web/src/components/Incidents/utils.spec.ts index 20faa7e79..0a28c0b85 100644 --- a/web/src/components/Incidents/utils.spec.ts +++ b/web/src/components/Incidents/utils.spec.ts @@ -1,6 +1,7 @@ import { getCurrentTime, - insertPaddingPointsForChart, roundDateToInterval, + insertPaddingPointsForChart, + roundDateToInterval, matchTimestampMetricForIncident, roundTimestampToFiveMinutes, } from './utils'; diff --git a/web/src/components/Incidents/utils.ts b/web/src/components/Incidents/utils.ts index e03afd00b..1ff0310f8 100644 --- a/web/src/components/Incidents/utils.ts +++ b/web/src/components/Incidents/utils.ts @@ -339,6 +339,10 @@ export const createIncidentsChartBars = (incident: Incident, dateArray: SpanDate const severity = getSeverityName(groupedData[i][2]); const isLastElement = i === groupedData.length - 1; + // to avoid certain edge cases the startDate should + // be the minimum between alert.firstTimestamp and groupedData[i][0] + const startDate = Math.min(incident.firstTimestamp, groupedData[i][0]); + data.push({ y0: new Date(groupedData[i][0] * 1000), y: new Date(groupedData[i][1] * 1000), @@ -348,7 +352,7 @@ export const createIncidentsChartBars = (incident: Incident, dateArray: SpanDate componentList: incident.componentList || [], group_id: incident.group_id, nodata: groupedData[i][2] === 'nodata' ? true : false, - startDate: new Date(roundTimestampToFiveMinutes(incident.firstTimestamp) * 1000), + startDate: new Date(roundTimestampToFiveMinutes(startDate) * 1000), fill: severity === 'Critical' ? barChartColorScheme.critical @@ -407,10 +411,14 @@ export const createAlertsChartBars = (alert: IncidentsDetailsAlert): AlertsChart for (let i = 0; i < groupedData.length; i++) { const isLastElement = i === groupedData.length - 1; + // to avoid certain edge cases the startDate should + // be the minimum between alert.firstTimestamp and groupedData[i][0] + const startDate = Math.min(alert.firstTimestamp, groupedData[i][0]); + data.push({ y0: new Date(groupedData[i][0] * 1000), y: new Date(groupedData[i][1] * 1000), - startDate: new Date(roundTimestampToFiveMinutes(alert.firstTimestamp) * 1000), + startDate: new Date(roundTimestampToFiveMinutes(startDate) * 1000), x: alert.x, severity: alert.severity[0].toUpperCase() + alert.severity.slice(1), name: alert.alertname, From 0c541366e2a5f6289a802b4a9278c1ff7450dc99 Mon Sep 17 00:00:00 2001 From: rioloc Date: Mon, 9 Feb 2026 13:04:19 +0100 Subject: [PATCH 07/10] feat: refactoring and simplify logic --- .../Incidents/AlertsChart/AlertsChart.tsx | 7 +-- .../IncidentsChart/IncidentsChart.tsx | 43 +++++++-------- .../Incidents/IncidentsDetailsRowTable.tsx | 9 +-- .../components/Incidents/IncidentsPage.tsx | 55 ++++++++++++------- .../components/Incidents/IncidentsTable.tsx | 5 +- web/src/components/Incidents/utils.ts | 14 +++-- 6 files changed, 70 insertions(+), 63 deletions(-) diff --git a/web/src/components/Incidents/AlertsChart/AlertsChart.tsx b/web/src/components/Incidents/AlertsChart/AlertsChart.tsx index 281a2b1a7..706e7481f 100644 --- a/web/src/components/Incidents/AlertsChart/AlertsChart.tsx +++ b/web/src/components/Incidents/AlertsChart/AlertsChart.tsx @@ -33,7 +33,6 @@ import { generateAlertsDateArray, getCurrentTime, roundDateToInterval, - roundTimestampToFiveMinutes, } from '../utils'; import { dateTimeFormatter, timeFormatter } from '../../console/utils/datetime'; import { useTranslation } from 'react-i18next'; @@ -162,11 +161,7 @@ const AlertsChart = ({ theme }: { theme: 'light' | 'dark' }) => { if (datum.nodata) { return ''; } - const startDate = dateTimeFormatter(i18n.language).format( - new Date( - roundTimestampToFiveMinutes(datum.startDate.getTime() / 1000) * 1000, - ), - ); + const startDate = dateTimeFormatter(i18n.language).format(datum.startDate); const endDate = datum.alertstate === 'firing' ? '---' diff --git a/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx b/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx index 6b2cc17f0..042d0fc35 100644 --- a/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx +++ b/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx @@ -35,7 +35,6 @@ import { generateDateArray, matchTimestampMetricForIncident, roundDateToInterval, - roundTimestampToFiveMinutes, } from '../utils'; import { dateTimeFormatter, timeFormatter } from '../../console/utils/datetime'; import { useTranslation } from 'react-i18next'; @@ -84,28 +83,29 @@ const IncidentsChart = ({ [chartDays, currentTime], ); - // enrich incidentsData with first_timestamp from timestamp metric - incidentsData = incidentsData.map((incident) => { - // find the matched timestamp for the incident - const matchedMinTimestamp = matchTimestampMetricForIncident( - incident, - incidentsTimestamps.minOverTime, - ); + const enrichedIncidentsData = useMemo(() => { + return incidentsData.map((incident) => { + // find the matched timestamp for the incident + const matchedMinTimestamp = matchTimestampMetricForIncident( + incident, + incidentsTimestamps.minOverTime, + ); - return { - ...incident, - firstTimestamp: parseInt(matchedMinTimestamp?.value?.[1] ?? '0'), - }; - }); + return { + ...incident, + firstTimestamp: parseInt(matchedMinTimestamp?.value?.[1] ?? '0'), + }; + }); + }, [incidentsData, incidentsTimestamps]); const { t, i18n } = useTranslation(process.env.I18N_NAMESPACE); const chartData = useMemo(() => { - if (!Array.isArray(incidentsData) || incidentsData.length === 0) return []; + if (!Array.isArray(enrichedIncidentsData) || enrichedIncidentsData.length === 0) return []; const filteredIncidents = selectedGroupId - ? incidentsData.filter((incident) => incident.group_id === selectedGroupId) - : incidentsData; + ? enrichedIncidentsData.filter((incident) => incident.group_id === selectedGroupId) + : enrichedIncidentsData; // Create chart bars and sort by original x values to maintain proper order const chartBars = filteredIncidents.map((incident) => @@ -115,12 +115,11 @@ const IncidentsChart = ({ // Reassign consecutive x values to eliminate gaps between bars return chartBars.map((bars, index) => bars.map((bar) => ({ ...bar, x: index + 1 }))); - }, [incidentsData, dateValues, selectedGroupId]); + }, [enrichedIncidentsData, dateValues, selectedGroupId]); useEffect(() => { setIsLoading(false); - }, [incidentsData]); - + }, [enrichedIncidentsData]); useEffect(() => { setChartContainerHeight(chartData?.length < 5 ? 300 : chartData?.length * 60); setChartHeight(chartData?.length < 5 ? 250 : chartData?.length * 55); @@ -194,11 +193,7 @@ const IncidentsChart = ({ if (datum.nodata) { return ''; } - const startDate = dateTimeFormatter(i18n.language).format( - new Date( - roundTimestampToFiveMinutes(datum.startDate.getTime() / 1000) * 1000, - ), - ); + const startDate = dateTimeFormatter(i18n.language).format(datum.startDate); const endDate = datum.firing ? '---' : dateTimeFormatter(i18n.language).format( diff --git a/web/src/components/Incidents/IncidentsDetailsRowTable.tsx b/web/src/components/Incidents/IncidentsDetailsRowTable.tsx index e9b7b67e0..8c9ac677d 100644 --- a/web/src/components/Incidents/IncidentsDetailsRowTable.tsx +++ b/web/src/components/Incidents/IncidentsDetailsRowTable.tsx @@ -11,7 +11,6 @@ import { Alert, IncidentsDetailsAlert } from './model'; import { IncidentAlertStateIcon } from './IncidentAlertStateIcon'; import { useMemo } from 'react'; import { DataTestIDs } from '../data-test'; -import { roundTimestampToFiveMinutes } from './utils'; interface IncidentsDetailsRowTableProps { alerts: Alert[]; @@ -49,11 +48,9 @@ const IncidentsDetailsRowTable = ({ alerts }: IncidentsDetailsRowTableProps) => 0 - ? alertDetails.firstTimestamp - : alertDetails.alertsStartFiring, - ) * 1000 + (alertDetails.firstTimestamp > 0 + ? alertDetails.firstTimestamp + : alertDetails.alertsStartFiring) * 1000 } /> diff --git a/web/src/components/Incidents/IncidentsPage.tsx b/web/src/components/Incidents/IncidentsPage.tsx index 674b53703..ff5becaec 100644 --- a/web/src/components/Incidents/IncidentsPage.tsx +++ b/web/src/components/Incidents/IncidentsPage.tsx @@ -37,6 +37,7 @@ import { onDeleteIncidentFilterChip, onIncidentFiltersSelect, parseUrlParams, + roundTimestampToFiveMinutes, updateBrowserUrl, } from './utils'; import { groupAlertsForTable, convertToAlerts } from './processAlerts'; @@ -243,12 +244,10 @@ const IncidentsPage = () => { const currentTime = incidentsLastRefreshTime; // Fetch timestamps and alerts in parallel, but wait for both before processing - const timestampsPromise = Promise.all( - ['min_over_time(timestamp(ALERTS{alertstate="firing"})[15d:5m])'].map(async (query) => { - const response = await fetchInstantData(safeFetch, query); - return response.data.result; - }), - ); + const timestampPromise = fetchInstantData( + safeFetch, + 'min_over_time(timestamp(ALERTS{alertstate="firing"})[15d:5m])', + ).then((res) => res.data.result); const alertsPromise = Promise.all( timeRanges.map(async (range) => { @@ -261,11 +260,21 @@ const IncidentsPage = () => { }), ); - Promise.all([timestampsPromise, alertsPromise]) + Promise.all([timestampPromise, alertsPromise]) .then(([timestampsResults, alertsResults]) => { - // Dispatch timestamps to store + // Round timestamp values before storing + const roundedTimestamps = + timestampsResults?.map((result: any) => ({ + ...result, + value: [ + result.value[0], + roundTimestampToFiveMinutes(parseInt(result.value[1])).toString(), + ], + })) || []; + const fetchedAlertsTimestamps = { - minOverTime: timestampsResults[0], + minOverTime: roundedTimestamps, + lastOverTime: [], }; dispatch( setAlertsTimestamps({ @@ -300,7 +309,7 @@ const IncidentsPage = () => { }) .catch((err) => { // eslint-disable-next-line no-console - console.log(err); + console.error(err); }); }, [incidentForAlertProcessing, timeRanges, rules]); @@ -326,12 +335,10 @@ const IncidentsPage = () => { : 'cluster_health_components_map'; // Fetch timestamps and incidents in parallel, but wait for both before processing - const timestampsPromise = Promise.all( - ['min_over_time(timestamp(cluster_health_components_map)[15d:5m])'].map(async (query) => { - const response = await fetchInstantData(safeFetch, query); - return response.data.result; - }), - ); + const timestampPromise = fetchInstantData( + safeFetch, + 'min_over_time(timestamp(cluster_health_components_map)[15d:5m])', + ).then((res) => res.data.result); const incidentsPromise = Promise.all( calculatedTimeRanges.map(async (range) => { @@ -340,11 +347,21 @@ const IncidentsPage = () => { }), ); - Promise.all([timestampsPromise, incidentsPromise]) + Promise.all([timestampPromise, incidentsPromise]) .then(([timestampsResults, incidentsResults]) => { - // Dispatch timestamps to store + // Round timestamp values before storing + const roundedTimestamps = + timestampsResults?.map((result: any) => ({ + ...result, + value: [ + result.value[0], + roundTimestampToFiveMinutes(parseInt(result.value[1])).toString(), + ], + })) || []; + const fetchedTimestamps = { - minOverTime: timestampsResults[0], + minOverTime: roundedTimestamps, + lastOverTime: [], }; dispatch( setIncidentsTimestamps({ diff --git a/web/src/components/Incidents/IncidentsTable.tsx b/web/src/components/Incidents/IncidentsTable.tsx index 529ce7b03..ea455f18c 100644 --- a/web/src/components/Incidents/IncidentsTable.tsx +++ b/web/src/components/Incidents/IncidentsTable.tsx @@ -20,7 +20,6 @@ import { GroupedAlertStateIcon } from './IncidentAlertStateIcon'; import { GroupedAlert } from './model'; import { DataTestIDs } from '../data-test'; -import { roundTimestampToFiveMinutes } from './utils'; export const IncidentsTable = () => { const { t } = useTranslation(process.env.I18N_NAMESPACE); @@ -181,9 +180,7 @@ export const IncidentsTable = () => { )} - + Date: Mon, 9 Feb 2026 13:46:49 +0100 Subject: [PATCH 08/10] feat: fix start dates in consecutive intervals bar --- web/src/components/Incidents/utils.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/web/src/components/Incidents/utils.ts b/web/src/components/Incidents/utils.ts index 0f5e17206..8279f9852 100644 --- a/web/src/components/Incidents/utils.ts +++ b/web/src/components/Incidents/utils.ts @@ -339,12 +339,16 @@ export const createIncidentsChartBars = (incident: Incident, dateArray: SpanDate const severity = getSeverityName(groupedData[i][2]); const isLastElement = i === groupedData.length - 1; - // to avoid certain edge cases the startDate should - // be the minimum between alert.firstTimestamp and groupedData[i][0] - // Round the result since groupedData comes from raw time series values - const startDate = roundTimestampToFiveMinutes( - Math.min(incident.firstTimestamp, groupedData[i][0]), - ); + // - To avoid certain edge cases the startDate should + // be the minimum between alert.firstTimestamp and groupedData[i][0] + // - Round the result since groupedData comes from raw time series values. + // - We are considering only the first element of the groupedData (i === 0) + // because in the case of consecutive intervals (i.e. the incident changes priority) + // we want that the end of a bar is equal to the start of the next one + const startDate = + i === 0 + ? roundTimestampToFiveMinutes(Math.min(incident.firstTimestamp, groupedData[i][0])) + : groupedData[i][0]; data.push({ y0: new Date(groupedData[i][0] * 1000), From a5864d263b7fddae990e25f644607d55b224622a Mon Sep 17 00:00:00 2001 From: rioloc Date: Mon, 9 Feb 2026 13:46:49 +0100 Subject: [PATCH 09/10] feat: fix start dates in consecutive intervals bar --- .../Incidents/IncidentsDetailsRowTable.tsx | 10 ++- .../components/Incidents/IncidentsPage.tsx | 56 ++++++++++++-- .../components/Incidents/IncidentsTable.tsx | 4 +- web/src/components/Incidents/model.ts | 5 +- web/src/components/Incidents/processAlerts.ts | 12 ++- .../components/Incidents/processIncidents.ts | 10 ++- web/src/components/Incidents/utils.ts | 75 ++++++++++++++----- 7 files changed, 132 insertions(+), 40 deletions(-) diff --git a/web/src/components/Incidents/IncidentsDetailsRowTable.tsx b/web/src/components/Incidents/IncidentsDetailsRowTable.tsx index 8c9ac677d..3f0cab2b7 100644 --- a/web/src/components/Incidents/IncidentsDetailsRowTable.tsx +++ b/web/src/components/Incidents/IncidentsDetailsRowTable.tsx @@ -25,8 +25,10 @@ const IncidentsDetailsRowTable = ({ alerts }: IncidentsDetailsRowTableProps) => if (alerts && alerts.length > 0) { return [...alerts] .sort((a: IncidentsDetailsAlert, b: IncidentsDetailsAlert) => { - const aStart = a.firstTimestamp > 0 ? a.firstTimestamp : a.alertsStartFiring; - const bStart = b.firstTimestamp > 0 ? b.firstTimestamp : b.alertsStartFiring; + const aFirstTimestamp = a.firstTimestamps[0][1]; + const bFirstTimestamp = b.firstTimestamps[0][1]; + const aStart = aFirstTimestamp > 0 ? aFirstTimestamp : a.alertsStartFiring; + const bStart = bFirstTimestamp > 0 ? bFirstTimestamp : b.alertsStartFiring; return aStart - bStart; }) .map((alertDetails: IncidentsDetailsAlert, rowIndex) => { @@ -48,8 +50,8 @@ const IncidentsDetailsRowTable = ({ alerts }: IncidentsDetailsRowTableProps) => 0 - ? alertDetails.firstTimestamp + (alertDetails.firstTimestamps[0][1] > 0 + ? alertDetails.firstTimestamps[0][1] : alertDetails.alertsStartFiring) * 1000 } /> diff --git a/web/src/components/Incidents/IncidentsPage.tsx b/web/src/components/Incidents/IncidentsPage.tsx index ff5becaec..1448da20e 100644 --- a/web/src/components/Incidents/IncidentsPage.tsx +++ b/web/src/components/Incidents/IncidentsPage.tsx @@ -37,6 +37,7 @@ import { onDeleteIncidentFilterChip, onIncidentFiltersSelect, parseUrlParams, + PROMETHEUS_QUERY_INTERVAL_SECONDS, roundTimestampToFiveMinutes, updateBrowserUrl, } from './utils'; @@ -242,11 +243,13 @@ const IncidentsPage = () => { } const currentTime = incidentsLastRefreshTime; + const ONE_DAY = 24 * 60 * 60 * 1000; // Fetch timestamps and alerts in parallel, but wait for both before processing - const timestampPromise = fetchInstantData( + const timestampPromise = fetchDataForIncidentsAndAlerts( safeFetch, - 'min_over_time(timestamp(ALERTS{alertstate="firing"})[15d:5m])', + { endTime: currentTime, duration: 15 * ONE_DAY }, + 'timestamp(ALERTS{alertstate="firing"})', ).then((res) => res.data.result); const alertsPromise = Promise.all( @@ -262,14 +265,26 @@ const IncidentsPage = () => { Promise.all([timestampPromise, alertsPromise]) .then(([timestampsResults, alertsResults]) => { + // Gaps detection here such that if the same timestamp has + // gaps greater than 5 minutes, this will be added more than one time. + // For example, if there is a metric for AlertH_Gapped + // with values:[ "1770699000", "1770699300", "1770708300", "1770708600", "1770708900"] + // there will be two gaps detected. With the following min values: 1770699300 and 1770699300 + // the interval will be [1770699300 - 1770699300] and [1770708300 - 1770699300] + + const timestampsValues = timestampsResults?.map((result: any) => ({ + ...result, + value: detectMinForEachGap(result.values, PROMETHEUS_QUERY_INTERVAL_SECONDS), + })); + // Round timestamp values before storing const roundedTimestamps = - timestampsResults?.map((result: any) => ({ + timestampsValues?.map((result: any) => ({ ...result, - value: [ - result.value[0], - roundTimestampToFiveMinutes(parseInt(result.value[1])).toString(), - ], + value: result.value.map((value: any) => [ + value[0], + roundTimestampToFiveMinutes(parseInt(value[1])).toString(), + ]), })) || []; const fetchedAlertsTimestamps = { @@ -744,3 +759,30 @@ export const McpCmoAlertingPage = () => { ); }; + +/** + * @param {Array} dataValues - The matrix from out.json (data.result[0].values) + * @param {number} gapThreshold - e.g., 300 + */ +const detectMinForEachGap = (dataValues, gapThreshold) => { + if (!dataValues || dataValues.length === 0) return []; + + const mins = []; + let currentMin = dataValues[0]; + + // Start from the second element to compare with the previous one + for (let i = 1; i < dataValues.length; i++) { + const delta = dataValues[i][1] - dataValues[i - 1][1]; + + if (delta > gapThreshold) { + // Gap detected: save the min of the interval that just ended + mins.push(currentMin); + // The current timestamp is the min of the NEW interval + currentMin = dataValues[i]; + } + } + + // Always push the min of the last interval + mins.push(currentMin); + return mins; +}; diff --git a/web/src/components/Incidents/IncidentsTable.tsx b/web/src/components/Incidents/IncidentsTable.tsx index ea455f18c..43c1ec3f1 100644 --- a/web/src/components/Incidents/IncidentsTable.tsx +++ b/web/src/components/Incidents/IncidentsTable.tsx @@ -95,7 +95,9 @@ export const IncidentsTable = () => { if (!alert.alertsExpandedRowData || alert.alertsExpandedRowData.length === 0) { return 0; } - return Math.min(...alert.alertsExpandedRowData.map((alertData) => alertData.firstTimestamp)); + return Math.min( + ...alert.alertsExpandedRowData.map((alertData) => alertData.firstTimestamps[0][1]), + ); }; if (isEmpty(alertsTableData) || alertsAreLoading || isEmpty(incidentsActiveFilters.groupId)) { diff --git a/web/src/components/Incidents/model.ts b/web/src/components/Incidents/model.ts index 14f4aee38..02592b20e 100644 --- a/web/src/components/Incidents/model.ts +++ b/web/src/components/Incidents/model.ts @@ -20,6 +20,7 @@ export type Incident = { values: Array; metric: Metric; firstTimestamp: number; + firstTimestamps: Array>; lastTimestamp: number; }; @@ -59,7 +60,7 @@ export type Alert = { severity: Severity; silenced: boolean; x: number; - firstTimestamp: number; + firstTimestamps: Array>; values: Array; alertsExpandedRowData?: Array; }; @@ -114,7 +115,7 @@ export type IncidentsDetailsAlert = { resolved: boolean; severity: Severity; x: number; - firstTimestamp: number; + firstTimestamps: Array>; lastTimestamp: number; values: Array; silenced: boolean; diff --git a/web/src/components/Incidents/processAlerts.ts b/web/src/components/Incidents/processAlerts.ts index 962e00dae..3ab18d5e2 100644 --- a/web/src/components/Incidents/processAlerts.ts +++ b/web/src/components/Incidents/processAlerts.ts @@ -277,20 +277,24 @@ export function convertToAlerts( resolved, x: 0, // Will be set after sorting silenced: matchingIncident.silenced ?? false, - firstTimestamp: 0, // Will be set from matched timestamp + firstTimestamps: [], }; let matchedMinTimestamp = matchTimestampMetric(labeledAlert, alertsTimestamps.minOverTime); - if (matchedMinTimestamp && matchedMinTimestamp.value[1] < matchingIncident.firstTimestamp) { + const lastTimestampIdx = matchedMinTimestamp?.value.length - 1; + if ( + matchedMinTimestamp && + parseInt(matchedMinTimestamp.value[lastTimestampIdx][1]) < matchingIncident.firstTimestamp + ) { matchedMinTimestamp = { - value: [matchingIncident.firstTimestamp, matchingIncident.firstTimestamp.toString()], + value: [[matchingIncident.firstTimestamp, matchingIncident.firstTimestamp.toString()]], }; } if (matchedMinTimestamp) { labeledAlert = { ...labeledAlert, - firstTimestamp: parseInt(matchedMinTimestamp?.value?.[1] ?? '0'), + firstTimestamps: matchedMinTimestamp?.value, } as Alert; } return labeledAlert; diff --git a/web/src/components/Incidents/processIncidents.ts b/web/src/components/Incidents/processIncidents.ts index ad0174070..7a76d186f 100644 --- a/web/src/components/Incidents/processIncidents.ts +++ b/web/src/components/Incidents/processIncidents.ts @@ -1,7 +1,7 @@ /* eslint-disable max-len */ import { PrometheusLabels, PrometheusResult } from '@openshift-console/dynamic-plugin-sdk'; -import { Incident, IncidentsTimestamps, Metric, ProcessedIncident } from './model'; +import { IncidentsTimestamps, Metric, ProcessedIncident } from './model'; import { insertPaddingPointsForChart, isResolved, @@ -194,17 +194,18 @@ export const getIncidentsTimeRanges = ( export const processIncidentsForAlerts = ( incidents: Array, incidentsTimestamps: IncidentsTimestamps, -): Array> => { +) => { const matchedIncidents = incidents.map((incident) => { // expand matchTimestampMetricForIncident here const matchedMinTimestamp = matchTimestampMetricForIncident( incident.metric, incidentsTimestamps.minOverTime, ); + return { ...incident, firstTimestamp: parseInt(matchedMinTimestamp?.value?.[1] ?? '0'), - } as Partial; + }; }); return matchedIncidents.map((incident, index) => { @@ -213,12 +214,13 @@ export const processIncidentsForAlerts = ( const silenced = incident.metric.silenced === 'true'; // Return the processed incident - return { + const retval = { ...incident.metric, values: incident.values, x: incidents.length - index, silenced, firstTimestamp: incident.firstTimestamp, }; + return retval; }); }; diff --git a/web/src/components/Incidents/utils.ts b/web/src/components/Incidents/utils.ts index 8279f9852..26caac701 100644 --- a/web/src/components/Incidents/utils.ts +++ b/web/src/components/Incidents/utils.ts @@ -335,20 +335,45 @@ export const createIncidentsChartBars = (incident: Incident, dateArray: SpanDate warning: t_global_color_status_warning_default.var, }; + let prev = null; for (let i = 0; i < groupedData.length; i++) { const severity = getSeverityName(groupedData[i][2]); const isLastElement = i === groupedData.length - 1; + const isNodata = groupedData[i][2] === 'nodata'; - // - To avoid certain edge cases the startDate should - // be the minimum between alert.firstTimestamp and groupedData[i][0] // - Round the result since groupedData comes from raw time series values. - // - We are considering only the first element of the groupedData (i === 0) - // because in the case of consecutive intervals (i.e. the incident changes priority) - // we want that the end of a bar is equal to the start of the next one - const startDate = - i === 0 - ? roundTimestampToFiveMinutes(Math.min(incident.firstTimestamp, groupedData[i][0])) - : groupedData[i][0]; + let startDate = 0; + if (i === 0) { + // - For the first bar, use the incident's first timestamp (rounded) + // This represents when the incident actually started + // If the first interval is 'nodata', we still use incident.firstTimestamp + // as the absolute start, since 'nodata' at the beginning is just visualization + // - Math.min is needed to handle edge cases when the incident is quite new and the firstTimestamp may be greater + // than the aggregated data from the query_range + startDate = + roundTimestampToFiveMinutes(Math.min(incident.firstTimestamp, groupedData[i][0])) * 1000; + } else { + // For subsequent bars, only calculate startDate for non-nodata intervals + // (nodata intervals don't need accurate startDate for tooltip display) + if (!isNodata) { + if (prev) { + // Calculate absolute start by: + // Previous bar's absolute start + duration of previous bar + gap to current bar + // This maintains continuity in the absolute timeline + const prevBarDuration = prev.y.getTime() - prev.startDate.getTime(); + const gapToCurrent = groupedData[i][0] * 1000 - prev.y.getTime(); + startDate = prev.startDate.getTime() + prevBarDuration + gapToCurrent; + } else { + // If prev is null (e.g., first interval was 'nodata'), fall back to incident.firstTimestamp or first groupedData + // This ensures we always have a valid absolute start time + startDate = roundTimestampToFiveMinutes(Math.min(incident.firstTimestamp, groupedData[i][0])) * 1000; + } + } else { + // For 'nodata' intervals, we can use the interval start timestamp + // This is mainly for consistency, but won't be displayed in tooltips + startDate = groupedData[i][0] * 1000; + } + } data.push({ y0: new Date(groupedData[i][0] * 1000), @@ -359,7 +384,7 @@ export const createIncidentsChartBars = (incident: Incident, dateArray: SpanDate componentList: incident.componentList || [], group_id: incident.group_id, nodata: groupedData[i][2] === 'nodata' ? true : false, - startDate: new Date(startDate * 1000), + startDate: new Date(startDate), fill: severity === 'Critical' ? barChartColorScheme.critical @@ -367,6 +392,9 @@ export const createIncidentsChartBars = (incident: Incident, dateArray: SpanDate ? barChartColorScheme.warning : barChartColorScheme.info, }); + if (!isNodata) { + prev = data[i]; + } } return data; @@ -415,15 +443,26 @@ export const createAlertsChartBars = (alert: IncidentsDetailsAlert): AlertsChart const data = []; - for (let i = 0; i < groupedData.length; i++) { + let idx = alert.firstTimestamps.length - 1; + for (let i = groupedData.length - 1; i >= 0; i--) { const isLastElement = i === groupedData.length - 1; - - // to avoid certain edge cases the startDate should - // be the minimum between alert.firstTimestamp and groupedData[i][0] - // Round the result since groupedData comes from raw time series values - const startDate = roundTimestampToFiveMinutes( - Math.min(alert.firstTimestamp, groupedData[i][0]), - ); + const isNodata = groupedData[i][2] === 'nodata'; + + let startDate = 0; + if (i === 0) { + // For the first bar, use the minimum of alert's first timestamp and the first interval start + // This handles the case when the alert was created within 5 minutes + startDate = roundTimestampToFiveMinutes( + Math.min(parseInt(alert.firstTimestamps[idx][1]), groupedData[i][0]), + ); + idx--; + } else { + if (!isNodata && idx >= 0) { + startDate = roundTimestampToFiveMinutes(parseInt(alert.firstTimestamps[idx][1])); + idx--; + } + // Note: If isNodata, startDate remains 0 (not used in tooltips for nodata intervals) + } data.push({ y0: new Date(groupedData[i][0] * 1000), From 04c83268f85212b25c763256982ebaced1584f4f Mon Sep 17 00:00:00 2001 From: rioloc Date: Mon, 16 Feb 2026 16:28:03 +0100 Subject: [PATCH 10/10] feat: refactor intervals logic --- .../Incidents/AlertsChart/AlertsChart.tsx | 13 +- .../IncidentsChart/IncidentsChart.tsx | 54 +-- .../Incidents/IncidentsDetailsRowTable.tsx | 10 +- .../components/Incidents/IncidentsPage.tsx | 141 +----- .../components/Incidents/IncidentsTable.tsx | 4 +- web/src/components/Incidents/api.ts | 33 -- web/src/components/Incidents/model.ts | 20 +- .../Incidents/processAlerts.spec.ts | 419 +++++++++++------- web/src/components/Incidents/processAlerts.ts | 112 ++--- .../Incidents/processIncidents.spec.ts | 291 ++---------- .../components/Incidents/processIncidents.ts | 187 ++++++-- web/src/components/Incidents/utils.spec.ts | 405 +++++++++-------- web/src/components/Incidents/utils.ts | 262 +++++------ web/src/store/actions.ts | 10 - web/src/store/reducers.ts | 10 - web/src/store/store.ts | 6 - 16 files changed, 901 insertions(+), 1076 deletions(-) diff --git a/web/src/components/Incidents/AlertsChart/AlertsChart.tsx b/web/src/components/Incidents/AlertsChart/AlertsChart.tsx index 706e7481f..b5ff988fc 100644 --- a/web/src/components/Incidents/AlertsChart/AlertsChart.tsx +++ b/web/src/components/Incidents/AlertsChart/AlertsChart.tsx @@ -73,7 +73,18 @@ const AlertsChart = ({ theme }: { theme: 'light' | 'dark' }) => { const chartData: AlertsChartBar[][] = useMemo(() => { if (!Array.isArray(alertsData) || alertsData.length === 0) return []; - return alertsData.map((alert) => createAlertsChartBars(alert)); + + // Group alerts by identity so intervals of the same alert share the same row + const groupedByIdentity = new Map(); + for (const alert of alertsData) { + const key = [alert.alertname, alert.namespace, alert.severity].join('|'); + if (!groupedByIdentity.has(key)) { + groupedByIdentity.set(key, []); + } + groupedByIdentity.get(key)!.push(alert); + } + + return Array.from(groupedByIdentity.values()).map((alerts) => createAlertsChartBars(alerts)); }, [alertsData]); useEffect(() => { diff --git a/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx b/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx index 042d0fc35..9dd67211d 100644 --- a/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx +++ b/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx @@ -28,12 +28,12 @@ import { } from '@patternfly/react-tokens'; import '../incidents-styles.css'; import { IncidentsTooltip } from '../IncidentsTooltip'; -import { Incident, IncidentsTimestamps } from '../model'; +import { Incident } from '../model'; import { calculateIncidentsChartDomain, createIncidentsChartBars, generateDateArray, - matchTimestampMetricForIncident, + removeTrailingPaddingFromSeveritySegments, roundDateToInterval, } from '../utils'; import { dateTimeFormatter, timeFormatter } from '../../console/utils/datetime'; @@ -58,7 +58,6 @@ const formatComponentList = (componentList: string[] | undefined): string => { const IncidentsChart = ({ incidentsData, - incidentsTimestamps, chartDays, theme, selectedGroupId, @@ -67,7 +66,6 @@ const IncidentsChart = ({ lastRefreshTime, }: { incidentsData: Array; - incidentsTimestamps: IncidentsTimestamps; chartDays: number; theme: 'light' | 'dark'; selectedGroupId: string; @@ -83,43 +81,45 @@ const IncidentsChart = ({ [chartDays, currentTime], ); - const enrichedIncidentsData = useMemo(() => { - return incidentsData.map((incident) => { - // find the matched timestamp for the incident - const matchedMinTimestamp = matchTimestampMetricForIncident( - incident, - incidentsTimestamps.minOverTime, - ); - - return { - ...incident, - firstTimestamp: parseInt(matchedMinTimestamp?.value?.[1] ?? '0'), - }; - }); - }, [incidentsData, incidentsTimestamps]); - const { t, i18n } = useTranslation(process.env.I18N_NAMESPACE); const chartData = useMemo(() => { - if (!Array.isArray(enrichedIncidentsData) || enrichedIncidentsData.length === 0) return []; + if (!Array.isArray(incidentsData) || incidentsData.length === 0) return []; const filteredIncidents = selectedGroupId - ? enrichedIncidentsData.filter((incident) => incident.group_id === selectedGroupId) - : enrichedIncidentsData; + ? incidentsData.filter((incident) => incident.group_id === selectedGroupId) + : incidentsData; - // Create chart bars and sort by original x values to maintain proper order - const chartBars = filteredIncidents.map((incident) => - createIncidentsChartBars(incident, dateValues), + // Group incidents by group_id so split severity segments share the same row + const incidentsByGroupId = new Map(); + for (const incident of filteredIncidents) { + const existing = incidentsByGroupId.get(incident.group_id); + if (existing) { + existing.push(incident); + } else { + incidentsByGroupId.set(incident.group_id, [incident]); + } + } + + // When an incident changes severity, its segments share the same row. + // Non-last segments have trailing padding (+300s) that overlaps with the + // next segment's leading padding (-300s). Remove the trailing padding + // value from non-last segments to prevent visual overlap. + const adjustedGroups = Array.from(incidentsByGroupId.values()).map((group) => + removeTrailingPaddingFromSeveritySegments(group), ); + + // Create chart bars per group and sort by original x values + const chartBars = adjustedGroups.map((group) => createIncidentsChartBars(group, dateValues)); chartBars.sort((a, b) => a[0].x - b[0].x); // Reassign consecutive x values to eliminate gaps between bars return chartBars.map((bars, index) => bars.map((bar) => ({ ...bar, x: index + 1 }))); - }, [enrichedIncidentsData, dateValues, selectedGroupId]); + }, [incidentsData, dateValues, selectedGroupId]); useEffect(() => { setIsLoading(false); - }, [enrichedIncidentsData]); + }, [incidentsData]); useEffect(() => { setChartContainerHeight(chartData?.length < 5 ? 300 : chartData?.length * 60); setChartHeight(chartData?.length < 5 ? 250 : chartData?.length * 55); diff --git a/web/src/components/Incidents/IncidentsDetailsRowTable.tsx b/web/src/components/Incidents/IncidentsDetailsRowTable.tsx index 3f0cab2b7..8c9ac677d 100644 --- a/web/src/components/Incidents/IncidentsDetailsRowTable.tsx +++ b/web/src/components/Incidents/IncidentsDetailsRowTable.tsx @@ -25,10 +25,8 @@ const IncidentsDetailsRowTable = ({ alerts }: IncidentsDetailsRowTableProps) => if (alerts && alerts.length > 0) { return [...alerts] .sort((a: IncidentsDetailsAlert, b: IncidentsDetailsAlert) => { - const aFirstTimestamp = a.firstTimestamps[0][1]; - const bFirstTimestamp = b.firstTimestamps[0][1]; - const aStart = aFirstTimestamp > 0 ? aFirstTimestamp : a.alertsStartFiring; - const bStart = bFirstTimestamp > 0 ? bFirstTimestamp : b.alertsStartFiring; + const aStart = a.firstTimestamp > 0 ? a.firstTimestamp : a.alertsStartFiring; + const bStart = b.firstTimestamp > 0 ? b.firstTimestamp : b.alertsStartFiring; return aStart - bStart; }) .map((alertDetails: IncidentsDetailsAlert, rowIndex) => { @@ -50,8 +48,8 @@ const IncidentsDetailsRowTable = ({ alerts }: IncidentsDetailsRowTableProps) => 0 - ? alertDetails.firstTimestamps[0][1] + (alertDetails.firstTimestamp > 0 + ? alertDetails.firstTimestamp : alertDetails.alertsStartFiring) * 1000 } /> diff --git a/web/src/components/Incidents/IncidentsPage.tsx b/web/src/components/Incidents/IncidentsPage.tsx index 1448da20e..324d835ef 100644 --- a/web/src/components/Incidents/IncidentsPage.tsx +++ b/web/src/components/Incidents/IncidentsPage.tsx @@ -1,7 +1,7 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { useMemo, useState, useEffect, useCallback } from 'react'; import { useSafeFetch } from '../console/utils/safe-fetch-hook'; -import { createAlertsQuery, fetchDataForIncidentsAndAlerts, fetchInstantData } from './api'; +import { createAlertsQuery, fetchDataForIncidentsAndAlerts } from './api'; import { useTranslation } from 'react-i18next'; import { Bullseye, @@ -37,9 +37,8 @@ import { onDeleteIncidentFilterChip, onIncidentFiltersSelect, parseUrlParams, - PROMETHEUS_QUERY_INTERVAL_SECONDS, - roundTimestampToFiveMinutes, updateBrowserUrl, + DAY_MS, } from './utils'; import { groupAlertsForTable, convertToAlerts } from './processAlerts'; import { CompressArrowsAltIcon, CompressIcon, FilterIcon } from '@patternfly/react-icons'; @@ -48,9 +47,7 @@ import { setAlertsAreLoading, setAlertsData, setAlertsTableData, - setAlertsTimestamps, setFilteredIncidentsData, - setIncidentsTimestamps, setIncidentPageFilterType, setIncidents, setIncidentsActiveFilters, @@ -150,10 +147,6 @@ const IncidentsPage = () => { (state: MonitoringState) => state.plugins.mcp.incidentsData.filteredIncidentsData, ); - const incidentsTimestamps = useSelector( - (state: MonitoringState) => state.plugins.mcp.incidentsData.incidentsTimestamps, - ); - const selectedGroupId = incidentsActiveFilters.groupId?.[0] ?? undefined; const incidentPageFilterTypeSelected = useSelector( @@ -243,17 +236,12 @@ const IncidentsPage = () => { } const currentTime = incidentsLastRefreshTime; - const ONE_DAY = 24 * 60 * 60 * 1000; - // Fetch timestamps and alerts in parallel, but wait for both before processing - const timestampPromise = fetchDataForIncidentsAndAlerts( - safeFetch, - { endTime: currentTime, duration: 15 * ONE_DAY }, - 'timestamp(ALERTS{alertstate="firing"})', - ).then((res) => res.data.result); + // Always fetch 15 days of alert data so firstTimestamp is computed from full history + const fetchTimeRanges = getIncidentsTimeRanges(15 * DAY_MS, currentTime); - const alertsPromise = Promise.all( - timeRanges.map(async (range) => { + Promise.all( + fetchTimeRanges.map(async (range) => { const response = await fetchDataForIncidentsAndAlerts( safeFetch, range, @@ -261,48 +249,14 @@ const IncidentsPage = () => { ); return response.data.result; }), - ); - - Promise.all([timestampPromise, alertsPromise]) - .then(([timestampsResults, alertsResults]) => { - // Gaps detection here such that if the same timestamp has - // gaps greater than 5 minutes, this will be added more than one time. - // For example, if there is a metric for AlertH_Gapped - // with values:[ "1770699000", "1770699300", "1770708300", "1770708600", "1770708900"] - // there will be two gaps detected. With the following min values: 1770699300 and 1770699300 - // the interval will be [1770699300 - 1770699300] and [1770708300 - 1770699300] - - const timestampsValues = timestampsResults?.map((result: any) => ({ - ...result, - value: detectMinForEachGap(result.values, PROMETHEUS_QUERY_INTERVAL_SECONDS), - })); - - // Round timestamp values before storing - const roundedTimestamps = - timestampsValues?.map((result: any) => ({ - ...result, - value: result.value.map((value: any) => [ - value[0], - roundTimestampToFiveMinutes(parseInt(value[1])).toString(), - ]), - })) || []; - - const fetchedAlertsTimestamps = { - minOverTime: roundedTimestamps, - lastOverTime: [], - }; - dispatch( - setAlertsTimestamps({ - alertsTimestamps: fetchedAlertsTimestamps, - }), - ); - + ) + .then((alertsResults) => { const prometheusResults = alertsResults.flat(); const alerts = convertToAlerts( prometheusResults, incidentForAlertProcessing, currentTime, - fetchedAlertsTimestamps, + daysSpan, ); dispatch( setAlertsData({ @@ -342,68 +296,41 @@ const IncidentsPage = () => { ? incidentsActiveFilters.days[0].split(' ')[0] + 'd' : '', ); - const calculatedTimeRanges = getIncidentsTimeRanges(daysDuration, currentTime); const isGroupSelected = !!selectedGroupId; const incidentsQuery = isGroupSelected ? `cluster_health_components_map{group_id='${selectedGroupId}'}` : 'cluster_health_components_map'; - // Fetch timestamps and incidents in parallel, but wait for both before processing - const timestampPromise = fetchInstantData( - safeFetch, - 'min_over_time(timestamp(cluster_health_components_map)[15d:5m])', - ).then((res) => res.data.result); + // Always fetch 15 days of data so firstTimestamp is computed from full history + const fetchTimeRanges = getIncidentsTimeRanges(15 * DAY_MS, currentTime); - const incidentsPromise = Promise.all( - calculatedTimeRanges.map(async (range) => { + Promise.all( + fetchTimeRanges.map(async (range) => { const response = await fetchDataForIncidentsAndAlerts(safeFetch, range, incidentsQuery); return response.data.result; }), - ); - - Promise.all([timestampPromise, incidentsPromise]) - .then(([timestampsResults, incidentsResults]) => { - // Round timestamp values before storing - const roundedTimestamps = - timestampsResults?.map((result: any) => ({ - ...result, - value: [ - result.value[0], - roundTimestampToFiveMinutes(parseInt(result.value[1])).toString(), - ], - })) || []; - - const fetchedTimestamps = { - minOverTime: roundedTimestamps, - lastOverTime: [], - }; - dispatch( - setIncidentsTimestamps({ - incidentsTimestamps: fetchedTimestamps, - }), - ); - + ) + .then((incidentsResults) => { const prometheusResults = incidentsResults.flat(); - const incidents = convertToIncidents(prometheusResults, currentTime); + const incidents = convertToIncidents(prometheusResults, currentTime, daysDuration); // Update the raw, unfiltered incidents state dispatch(setIncidents({ incidents })); + const filteredData = filterIncident(incidentsActiveFilters, incidents); + // Filter the incidents and dispatch dispatch( setFilteredIncidentsData({ - filteredIncidentsData: filterIncident(incidentsActiveFilters, incidents), + filteredIncidentsData: filteredData, }), ); setIncidentsAreLoading(false); if (isGroupSelected) { - // Use fetchedTimestamps directly instead of stale closure value - setIncidentForAlertProcessing( - processIncidentsForAlerts(prometheusResults, fetchedTimestamps), - ); + setIncidentForAlertProcessing(processIncidentsForAlerts(prometheusResults)); dispatch(setAlertsAreLoading({ alertsAreLoading: true })); } else { closeDropDownFilters(); @@ -724,7 +651,6 @@ const IncidentsPage = () => { { ); }; - -/** - * @param {Array} dataValues - The matrix from out.json (data.result[0].values) - * @param {number} gapThreshold - e.g., 300 - */ -const detectMinForEachGap = (dataValues, gapThreshold) => { - if (!dataValues || dataValues.length === 0) return []; - - const mins = []; - let currentMin = dataValues[0]; - - // Start from the second element to compare with the previous one - for (let i = 1; i < dataValues.length; i++) { - const delta = dataValues[i][1] - dataValues[i - 1][1]; - - if (delta > gapThreshold) { - // Gap detected: save the min of the interval that just ended - mins.push(currentMin); - // The current timestamp is the min of the NEW interval - currentMin = dataValues[i]; - } - } - - // Always push the min of the last interval - mins.push(currentMin); - return mins; -}; diff --git a/web/src/components/Incidents/IncidentsTable.tsx b/web/src/components/Incidents/IncidentsTable.tsx index 43c1ec3f1..ea455f18c 100644 --- a/web/src/components/Incidents/IncidentsTable.tsx +++ b/web/src/components/Incidents/IncidentsTable.tsx @@ -95,9 +95,7 @@ export const IncidentsTable = () => { if (!alert.alertsExpandedRowData || alert.alertsExpandedRowData.length === 0) { return 0; } - return Math.min( - ...alert.alertsExpandedRowData.map((alertData) => alertData.firstTimestamps[0][1]), - ); + return Math.min(...alert.alertsExpandedRowData.map((alertData) => alertData.firstTimestamp)); }; if (isEmpty(alertsTableData) || alertsAreLoading || isEmpty(incidentsActiveFilters.groupId)) { diff --git a/web/src/components/Incidents/api.ts b/web/src/components/Incidents/api.ts index 520852cf8..0b8c71841 100644 --- a/web/src/components/Incidents/api.ts +++ b/web/src/components/Incidents/api.ts @@ -153,36 +153,3 @@ export const fetchDataForIncidentsAndAlerts = async ( }, } as PrometheusResponse; }; - -export const fetchInstantData = async ( - fetch: (url: string) => Promise, - query: string, -) => { - const url = buildPrometheusUrl({ - prometheusUrlProps: { - endpoint: PrometheusEndpoint.QUERY, - query, - }, - basePath: getPrometheusBasePath({ - prometheus: 'cmo', - useTenancyPath: false, - }), - }); - if (!url) { - return Promise.resolve({ - status: 'success', - data: { - resultType: 'matrix', - result: [], - }, - } as PrometheusResponse); - } - const response = await Promise.resolve(consoleFetchJSON(url)); - return { - status: 'success', - data: { - resultType: 'matrix', - result: response.data.result, - }, - } as PrometheusResponse; -}; diff --git a/web/src/components/Incidents/model.ts b/web/src/components/Incidents/model.ts index 02592b20e..2efee6943 100644 --- a/web/src/components/Incidents/model.ts +++ b/web/src/components/Incidents/model.ts @@ -4,7 +4,7 @@ export type Timestamps = [number, string]; export type SpanDates = Array; -export type AlertsIntervalsArray = [number, number, 'data' | 'nodata']; +export type AlertsIntervalsArray = [number, number, 'data' | 'nodata', number?]; export type Incident = { component: string; @@ -15,23 +15,12 @@ export type Incident = { src_severity: string; src_alertname: string; src_namespace: string; + severity: any; silenced: boolean; x: number; values: Array; metric: Metric; firstTimestamp: number; - firstTimestamps: Array>; - lastTimestamp: number; -}; - -export type IncidentsTimestamps = { - minOverTime: Array; - lastOverTime: Array; -}; - -export type AlertsTimestamps = { - minOverTime: Array; - lastOverTime: Array; }; // Define the interface for Metric @@ -60,7 +49,7 @@ export type Alert = { severity: Severity; silenced: boolean; x: number; - firstTimestamps: Array>; + firstTimestamp: number; values: Array; alertsExpandedRowData?: Array; }; @@ -115,8 +104,7 @@ export type IncidentsDetailsAlert = { resolved: boolean; severity: Severity; x: number; - firstTimestamps: Array>; - lastTimestamp: number; + firstTimestamp: number; values: Array; silenced: boolean; rule: { diff --git a/web/src/components/Incidents/processAlerts.spec.ts b/web/src/components/Incidents/processAlerts.spec.ts index 496bc971b..c827d2e8d 100644 --- a/web/src/components/Incidents/processAlerts.spec.ts +++ b/web/src/components/Incidents/processAlerts.spec.ts @@ -1,19 +1,16 @@ import { PrometheusResult } from '@openshift-console/dynamic-plugin-sdk'; import { convertToAlerts, deduplicateAlerts } from './processAlerts'; -import { AlertsTimestamps, Incident } from './model'; -import { getCurrentTime } from './utils'; +import { Incident } from './model'; +import { getCurrentTime, DAY_MS } from './utils'; describe('convertToAlerts', () => { const now = getCurrentTime(); const nowSeconds = Math.floor(now / 1000); - const emptyAlertsTimestamps: AlertsTimestamps = { - minOverTime: [], - lastOverTime: [], - }; + const daysSpanMs = 15 * DAY_MS; describe('edge cases', () => { it('should return empty array when no prometheus results provided', () => { - const result = convertToAlerts([], [], now, emptyAlertsTimestamps); + const result = convertToAlerts([], [], now, daysSpanMs); expect(result).toEqual([]); }); @@ -32,7 +29,7 @@ describe('convertToAlerts', () => { values: [[nowSeconds, '1']], }, ]; - const result = convertToAlerts(prometheusResults, [], now, emptyAlertsTimestamps); + const result = convertToAlerts(prometheusResults, [], now, daysSpanMs); expect(result).toEqual([]); }); @@ -73,7 +70,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(1); expect(result[0].alertname).toBe('ClusterOperatorDegraded'); }); @@ -82,7 +79,8 @@ describe('convertToAlerts', () => { describe('time window filtering', () => { it('should filter alerts to incident time window with 30s padding', () => { const incidentStart = nowSeconds - 3600; // 1 hour ago - const incidentEnd = nowSeconds - 1800; // 30 minutes ago + // Use timestamps within 300s of each other to avoid gap-splitting + const incidentEnd = incidentStart + 300; const prometheusResults: PrometheusResult[] = [ { @@ -96,10 +94,9 @@ describe('convertToAlerts', () => { alertstate: 'firing', }, values: [ - [incidentStart - 100, '2'], // Outside window (before) + [incidentStart - 500, '2'], // Outside window (before, and gap-split away) [incidentStart, '2'], // Inside window [incidentEnd, '2'], // Inside window - [incidentEnd + 100, '2'], // Outside window (after) ], }, ]; @@ -117,10 +114,9 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); + // The first value is gap-split away (500s gap > 300s threshold), leaving one interval expect(result).toHaveLength(1); - // Should include values within incident time + 30s padding - // Plus padding points added by insertPaddingPointsForChart expect(result[0].values.length).toBeGreaterThan(0); }); @@ -152,7 +148,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toEqual([]); }); }); @@ -186,7 +182,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(1); // Verify resolved is determined from ORIGINAL values (before padding) @@ -230,7 +226,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(1); expect(result[0].alertsStartFiring).toBeGreaterThan(0); expect(result[0].alertsEndFiring).toBeGreaterThan(0); @@ -272,7 +268,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(1); expect(result[0].alertstate).toBe('resolved'); expect(result[0].resolved).toBe(true); @@ -308,7 +304,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(1); expect(result[0].alertstate).toBe('firing'); expect(result[0].resolved).toBe(false); @@ -361,7 +357,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(2); expect(result[0].alertname).toBe('Alert1'); // Earlier alert first expect(result[1].alertname).toBe('Alert2'); @@ -412,7 +408,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(2); expect(result[0].x).toBe(2); // Earliest alert has highest x expect(result[1].x).toBe(1); // Latest alert has lowest x @@ -447,7 +443,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(1); expect(result[0].silenced).toBe(true); }); @@ -455,6 +451,10 @@ describe('convertToAlerts', () => { describe('incident merging', () => { it('should merge duplicate incidents by composite key', () => { + // Use timestamps within 300s to avoid gap-splitting in deduplicateAlerts + const t1 = nowSeconds - 600; + const t2 = t1 + 300; + const prometheusResults: PrometheusResult[] = [ { metric: { @@ -465,8 +465,8 @@ describe('convertToAlerts', () => { alertstate: 'firing', }, values: [ - [nowSeconds - 600, '2'], - [nowSeconds, '2'], + [t1, '2'], + [t2, '2'], ], }, ]; @@ -481,7 +481,7 @@ describe('convertToAlerts', () => { component: 'test-component', layer: 'test-layer', silenced: false, - values: [[nowSeconds - 600, '2']], + values: [[t1, '2']], }, { group_id: 'incident1', @@ -489,11 +489,11 @@ describe('convertToAlerts', () => { src_namespace: 'test-namespace', src_severity: 'critical', silenced: true, // Latest should be true - values: [[nowSeconds, '2']], + values: [[t2, '2']], }, ]; - const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(1); // Should use the silenced value from the latest timestamp expect(result[0].silenced).toBe(true); @@ -527,7 +527,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now, emptyAlertsTimestamps); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(1); expect(result[0].alertname).toBe('MyAlert'); expect(result[0].namespace).toBe('my-namespace'); @@ -538,109 +538,9 @@ describe('convertToAlerts', () => { }); }); - describe('timestamp matching', () => { - it('should use matched minOverTime timestamp when available and newer than incident firstTimestamp', () => { - const timestamp = nowSeconds - 600; - const matchedMinTimestamp = nowSeconds - 300; // 5 minutes ago (newer than incident) - - const prometheusResults: PrometheusResult[] = [ - { - metric: { - alertname: 'TestAlert', - namespace: 'test-namespace', - severity: 'critical', - alertstate: 'firing', - }, - values: [[timestamp, '2']], - }, - ]; - - const incidents: Array> = [ - { - group_id: 'incident1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - src_severity: 'critical', - component: 'test-component', - layer: 'test-layer', - values: [[timestamp, '2']], - }, - ]; - - const alertsTimestamps: AlertsTimestamps = { - minOverTime: [ - { - metric: { - alertname: 'TestAlert', - namespace: 'test-namespace', - severity: 'critical', - }, - value: [matchedMinTimestamp, matchedMinTimestamp.toString()], - }, - ], - lastOverTime: [], - }; - - const result = convertToAlerts(prometheusResults, incidents, now, alertsTimestamps); - expect(result).toHaveLength(1); - expect(result[0].firstTimestamp).toBe(matchedMinTimestamp); - }); - - it('should use incident firstTimestamp when matched timestamp is older than incident firstTimestamp', () => { - const timestamp = nowSeconds - 600; - const incidentFirstTimestamp = nowSeconds - 1800; // 30 minutes ago - const matchedMinTimestamp = nowSeconds - 3600; // 1 hour ago (older) - - const prometheusResults: PrometheusResult[] = [ - { - metric: { - alertname: 'TestAlert', - namespace: 'test-namespace', - severity: 'critical', - name: 'test', - alertstate: 'firing', - }, - values: [[timestamp, '2']], - }, - ]; - - const incidents: Array> = [ - { - group_id: 'incident1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - src_severity: 'critical', - component: 'test-component', - layer: 'test-layer', - firstTimestamp: incidentFirstTimestamp, - values: [[timestamp, '2']], - }, - ]; - - const alertsTimestamps: AlertsTimestamps = { - minOverTime: [ - { - metric: { - alertname: 'TestAlert', - namespace: 'test-namespace', - severity: 'critical', - }, - value: [matchedMinTimestamp, matchedMinTimestamp.toString()], - }, - ], - lastOverTime: [], - }; - - const result = convertToAlerts(prometheusResults, incidents, now, alertsTimestamps); - expect(result).toHaveLength(1); - // Should use incident firstTimestamp because matched timestamp is older - expect(result[0].firstTimestamp).toBe(incidentFirstTimestamp); - }); - - it('should use matched timestamp when it is newer than incident firstTimestamp', () => { - const timestamp = nowSeconds - 600; - const incidentFirstTimestamp = nowSeconds - 3600; // 1 hour ago - const matchedMinTimestamp = nowSeconds - 1800; // 30 minutes ago (newer) + describe('firstTimestamp from data', () => { + it('should use first value of deduplicated interval minus padding offset as firstTimestamp', () => { + const alertStart = nowSeconds - 3600; // 1 hour ago const prometheusResults: PrometheusResult[] = [ { @@ -651,7 +551,11 @@ describe('convertToAlerts', () => { name: 'test', alertstate: 'firing', }, - values: [[timestamp, '2']], + values: [ + [alertStart, '2'], + [alertStart + 300, '2'], + [alertStart + 600, '2'], + ], }, ]; @@ -663,33 +567,23 @@ describe('convertToAlerts', () => { src_severity: 'critical', component: 'test-component', layer: 'test-layer', - firstTimestamp: incidentFirstTimestamp, - values: [[timestamp, '2']], + values: [[alertStart, '2']], }, ]; - const alertsTimestamps: AlertsTimestamps = { - minOverTime: [ - { - metric: { - alertname: 'TestAlert', - namespace: 'test-namespace', - severity: 'critical', - }, - value: [matchedMinTimestamp, matchedMinTimestamp.toString()], - }, - ], - lastOverTime: [], - }; - - const result = convertToAlerts(prometheusResults, incidents, now, alertsTimestamps); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(1); - // Should use matched timestamp because it's newer - expect(result[0].firstTimestamp).toBe(matchedMinTimestamp); + // firstTimestamp is the first value minus the 300s padding offset + expect(result[0].firstTimestamp).toBe(alertStart - 300); }); - it('should default to 0 when no timestamp is available', () => { - const timestamp = nowSeconds - 600; + it('should preserve firstTimestamp from full data even when values are clipped by N-day window', () => { + // Alert interval starts just before the 3-day window boundary and continues into it. + // The N-day filter clips the first value, but firstTimestamp stays absolute. + const threeDaysMs = 3 * DAY_MS; + const nDaysBoundary = Math.floor((now - threeDaysMs) / 1000); + // Interval: starts 300s before boundary, continues into the window + const alertStart = nDaysBoundary - 300; const prometheusResults: PrometheusResult[] = [ { @@ -700,7 +594,11 @@ describe('convertToAlerts', () => { name: 'test', alertstate: 'firing', }, - values: [[timestamp, '2']], + values: [ + [alertStart, '2'], // Before N-day boundary (may be clipped by filter) + [nDaysBoundary, '2'], // At boundary (inside window) + [nDaysBoundary + 300, '2'], // Inside window + ], }, ]; @@ -712,19 +610,18 @@ describe('convertToAlerts', () => { src_severity: 'critical', component: 'test-component', layer: 'test-layer', - // No firstTimestamp - values: [[timestamp, '2']], + values: [ + [alertStart, '2'], + [nDaysBoundary + 300, '2'], + ], }, ]; - const alertsTimestamps: AlertsTimestamps = { - minOverTime: [], // No match - lastOverTime: [], - }; - - const result = convertToAlerts(prometheusResults, incidents, now, alertsTimestamps); + const result = convertToAlerts(prometheusResults, incidents, now, threeDaysMs); expect(result).toHaveLength(1); - expect(result[0].firstTimestamp).toBe(0); + // firstTimestamp should reflect the absolute interval start minus padding, + // not the clipped window boundary + expect(result[0].firstTimestamp).toBe(alertStart - 300); }); }); }); @@ -912,13 +809,202 @@ describe('deduplicateAlerts', () => { }); }); + describe('gap splitting', () => { + it('should split alert into two when gap exceeds 5 minutes', () => { + const alerts: PrometheusResult[] = [ + { + metric: { + alertname: 'Alert1', + namespace: 'ns1', + component: 'comp1', + severity: 'critical', + alertstate: 'firing', + }, + values: [ + [1000, '2'], + [1300, '2'], // 300s after first (no gap) + [2000, '2'], // 700s after second (gap > 300s) + [2300, '2'], // 300s after third (no gap) + ], + }, + ]; + + const result = deduplicateAlerts(alerts); + expect(result).toHaveLength(2); + expect(result[0].values).toEqual([ + [1000, '2'], + [1300, '2'], + ]); + expect(result[1].values).toEqual([ + [2000, '2'], + [2300, '2'], + ]); + // Both entries share the same alert identity + expect(result[0].metric.alertname).toBe('Alert1'); + expect(result[1].metric.alertname).toBe('Alert1'); + }); + + it('should split alert into multiple intervals with multiple gaps', () => { + const alerts: PrometheusResult[] = [ + { + metric: { + alertname: 'Alert1', + namespace: 'ns1', + component: 'comp1', + severity: 'critical', + alertstate: 'firing', + }, + values: [ + [1000, '2'], + [1300, '2'], + [2000, '2'], // gap + [3000, '2'], // gap + [3300, '2'], + ], + }, + ]; + + const result = deduplicateAlerts(alerts); + expect(result).toHaveLength(3); + expect(result[0].values).toEqual([ + [1000, '2'], + [1300, '2'], + ]); + expect(result[1].values).toEqual([[2000, '2']]); + expect(result[2].values).toEqual([ + [3000, '2'], + [3300, '2'], + ]); + }); + + it('should not split when delta is exactly 300s (no gap)', () => { + const alerts: PrometheusResult[] = [ + { + metric: { + alertname: 'Alert1', + namespace: 'ns1', + component: 'comp1', + severity: 'critical', + alertstate: 'firing', + }, + values: [ + [1000, '2'], + [1300, '2'], // exactly 300s + [1600, '2'], // exactly 300s + ], + }, + ]; + + const result = deduplicateAlerts(alerts); + expect(result).toHaveLength(1); + expect(result[0].values).toHaveLength(3); + }); + + it('should split when delta is 301s (just over threshold)', () => { + const alerts: PrometheusResult[] = [ + { + metric: { + alertname: 'Alert1', + namespace: 'ns1', + component: 'comp1', + severity: 'critical', + alertstate: 'firing', + }, + values: [ + [1000, '2'], + [1301, '2'], // 301s gap + ], + }, + ]; + + const result = deduplicateAlerts(alerts); + expect(result).toHaveLength(2); + expect(result[0].values).toEqual([[1000, '2']]); + expect(result[1].values).toEqual([[1301, '2']]); + }); + + it('should split after merging values from multiple alerts with same key', () => { + const alerts: PrometheusResult[] = [ + { + metric: { + alertname: 'Alert1', + namespace: 'ns1', + component: 'comp1', + severity: 'critical', + alertstate: 'firing', + }, + values: [ + [1000, '2'], + [1300, '2'], + ], + }, + { + metric: { + alertname: 'Alert1', + namespace: 'ns1', + component: 'comp1', + severity: 'critical', + alertstate: 'firing', + }, + values: [ + [2000, '2'], // gap after merge + [2300, '2'], + ], + }, + ]; + + const result = deduplicateAlerts(alerts); + expect(result).toHaveLength(2); + expect(result[0].values).toEqual([ + [1000, '2'], + [1300, '2'], + ]); + expect(result[1].values).toEqual([ + [2000, '2'], + [2300, '2'], + ]); + }); + + it('should sort values by timestamp before gap detection', () => { + const alerts: PrometheusResult[] = [ + { + metric: { + alertname: 'Alert1', + namespace: 'ns1', + component: 'comp1', + severity: 'critical', + alertstate: 'firing', + }, + values: [ + [2300, '2'], + [1000, '2'], // out of order + [2000, '2'], + [1300, '2'], + ], + }, + ]; + + const result = deduplicateAlerts(alerts); + expect(result).toHaveLength(2); + // Values should be sorted within each interval + expect(result[0].values).toEqual([ + [1000, '2'], + [1300, '2'], + ]); + expect(result[1].values).toEqual([ + [2000, '2'], + [2300, '2'], + ]); + }); + }); + describe('edge cases', () => { it('should handle empty array', () => { const result = deduplicateAlerts([]); expect(result).toEqual([]); }); - it('should handle single alert', () => { + it('should handle single alert with single value', () => { const alerts: PrometheusResult[] = [ { metric: { @@ -934,7 +1020,8 @@ describe('deduplicateAlerts', () => { const result = deduplicateAlerts(alerts); expect(result).toHaveLength(1); - expect(result[0]).toEqual(alerts[0]); + expect(result[0].metric).toEqual(alerts[0].metric); + expect(result[0].values).toEqual(alerts[0].values); }); }); }); diff --git a/web/src/components/Incidents/processAlerts.ts b/web/src/components/Incidents/processAlerts.ts index 3ab18d5e2..e371cb1e5 100644 --- a/web/src/components/Incidents/processAlerts.ts +++ b/web/src/components/Incidents/processAlerts.ts @@ -1,7 +1,7 @@ /* eslint-disable max-len */ import { PrometheusResult, PrometheusRule } from '@openshift-console/dynamic-plugin-sdk'; -import { Alert, AlertsTimestamps, GroupedAlert, Incident, Severity } from './model'; +import { Alert, GroupedAlert, Incident, Severity } from './model'; import { insertPaddingPointsForChart, isResolved, @@ -13,6 +13,10 @@ import { * while removing duplicates. Only firing alerts are included. Alerts with the same combination of these four fields * are combined, with values being deduplicated. * + * After deduplication, alerts are split into separate entries when there is a gap longer than 5 minutes + * (PROMETHEUS_QUERY_INTERVAL_SECONDS) between consecutive datapoints. This means the same alert identity + * can appear multiple times in the result, once per continuous firing interval. + * * @param {Array} objects - Array of alert objects to be deduplicated. Each object contains a `metric` field * with properties such as `alertname`, `namespace`, `component`, `severity`, and an array of `values`. * @param {Object} objects[].metric - The metric information of the alert. @@ -24,18 +28,18 @@ import { * @param {Array>} objects[].values - The array of values corresponding to the alert, where * each value is a tuple containing a timestamp and a value (e.g., [timestamp, value]). * - * @returns {Array} - An array of deduplicated firing alert objects. Each object contains a unique combination of - * `alertname`, `namespace`, `component`, and `severity`, with deduplicated values. + * @returns {Array} - An array of deduplicated firing alert objects, split by gaps. Each object contains + * a combination of `alertname`, `namespace`, `component`, and `severity`, with values for one continuous interval. * @returns {Object} return[].metric - The metric information of the deduplicated alert. - * @returns {Array>} return[].values - The deduplicated array of values for the alert. + * @returns {Array>} return[].values - The values for one continuous interval, sorted by timestamp. * * @example * const alerts = [ - * { metric: { alertname: "Alert1", namespace: "ns1", component: "comp1", severity: "critical", alertstate: "firing" }, values: [[12345, "2"], [12346, "2"]] }, - * { metric: { alertname: "Alert1", namespace: "ns1", component: "comp1", severity: "critical", alertstate: "firing" }, values: [[12346, "2"], [12347, "2"]] } + * { metric: { alertname: "Alert1", namespace: "ns1", component: "comp1", severity: "critical", alertstate: "firing" }, values: [[1000, "2"], [1300, "2"], [2000, "2"], [2300, "2"]] } * ]; * const deduplicated = deduplicateAlerts(alerts); - * // Returns an array where the two alerts are combined with deduplicated values. + * // Returns two entries for Alert1: one with values [[1000, "2"], [1300, "2"]] and another with [[2000, "2"], [2300, "2"]] + * // because the gap between 1300 and 2000 (700s) exceeds the 5-minute threshold (300s). */ export function deduplicateAlerts(objects: Array): Array { // Step 1: Filter out all non firing alerts @@ -69,7 +73,37 @@ export function deduplicateAlerts(objects: Array): Array = []; + for (const alert of groupedObjects.values()) { + const sortedValues = alert.values.sort( + (a: [number, string], b: [number, string]) => a[0] - b[0], + ); + + if (sortedValues.length <= 1) { + result.push({ metric: alert.metric, values: sortedValues }); + continue; + } + + let currentInterval: Array<[number, string]> = [sortedValues[0]]; + + for (let i = 1; i < sortedValues.length; i++) { + const delta = sortedValues[i][0] - sortedValues[i - 1][0]; + + if (delta > PROMETHEUS_QUERY_INTERVAL_SECONDS) { + // Gap detected: save current interval and start a new one + result.push({ metric: { ...alert.metric }, values: currentInterval }); + currentInterval = [sortedValues[i]]; + } else { + currentInterval.push(sortedValues[i]); + } + } + + // Push the last interval + result.push({ metric: { ...alert.metric }, values: currentInterval }); + } + + return result; } /** @@ -202,10 +236,11 @@ export function convertToAlerts( prometheusResults: Array, selectedIncidents: Array>, currentTime: number, - alertsTimestamps: AlertsTimestamps, + daysSpanMs: number, ): Array { // Merge selected incidents by composite key. Consolidates duplicates caused by non-key labels // like `pod` or `silenced` that aren't supported by cluster health analyzer. + const incidents = mergeIncidentsByKey(selectedIncidents); // Extract the first and last timestamps of the selected incident @@ -216,20 +251,24 @@ export function convertToAlerts( const incidentFirstTimestamp = Math.min(...timestamps); const incidentLastTimestamp = Math.max(...timestamps); + // Clip the incident window to the N-day span so we only show alerts within the selected range + const nDaysStartSeconds = (currentTime - daysSpanMs) / 1000; + const effectiveFirstTimestamp = Math.max(incidentFirstTimestamp, nDaysStartSeconds); + // Watchdog is a heartbeat metric, not a real issue const validAlerts = prometheusResults.filter((alert) => alert.metric.alertname !== 'Watchdog'); - const distinctAlerts = deduplicateAlerts(validAlerts); + const deduplicatedAlerts = deduplicateAlerts(validAlerts); - // Filter alerts to only include values within the incident's time window + // Filter alerts to only include values within the effective time window const ALERT_WINDOW_PADDING_SECONDS = PROMETHEUS_QUERY_INTERVAL_SECONDS / 2; - const alerts = distinctAlerts + const alerts = deduplicatedAlerts .map((alert: PrometheusResult): Alert | null => { - // Keep only values within the incident time range (with padding) + // Keep only values within the effective time range (with padding) const values: Array<[number, string]> = alert.values.filter( ([date]) => - date >= incidentFirstTimestamp - ALERT_WINDOW_PADDING_SECONDS && + date >= effectiveFirstTimestamp - ALERT_WINDOW_PADDING_SECONDS && date <= incidentLastTimestamp + ALERT_WINDOW_PADDING_SECONDS, ); @@ -260,44 +299,27 @@ export function convertToAlerts( // Add padding points for chart rendering const paddedValues = insertPaddingPointsForChart(sortedValues, currentTime); - const firstTimestamp = paddedValues[0][0]; + // firstTimestamp is absolute over the full 15-day data (before N-day filtering), + // with the padding offset applied to match chart rendering + const alertFirstTimestamp = alert.values[0]?.[0] - PROMETHEUS_QUERY_INTERVAL_SECONDS; lastTimestamp = paddedValues[paddedValues.length - 1][0]; - let labeledAlert: Alert = { + return { alertname: alert.metric.alertname, namespace: alert.metric.namespace, severity: alert.metric.severity as Severity, + firstTimestamp: alertFirstTimestamp, component: matchingIncident.component, layer: matchingIncident.layer, name: alert.metric.name, alertstate: resolved ? 'resolved' : 'firing', values: paddedValues, - alertsStartFiring: firstTimestamp, + alertsStartFiring: alertFirstTimestamp, alertsEndFiring: lastTimestamp, resolved, x: 0, // Will be set after sorting silenced: matchingIncident.silenced ?? false, - firstTimestamps: [], }; - - let matchedMinTimestamp = matchTimestampMetric(labeledAlert, alertsTimestamps.minOverTime); - const lastTimestampIdx = matchedMinTimestamp?.value.length - 1; - if ( - matchedMinTimestamp && - parseInt(matchedMinTimestamp.value[lastTimestampIdx][1]) < matchingIncident.firstTimestamp - ) { - matchedMinTimestamp = { - value: [[matchingIncident.firstTimestamp, matchingIncident.firstTimestamp.toString()]], - }; - } - - if (matchedMinTimestamp) { - labeledAlert = { - ...labeledAlert, - firstTimestamps: matchedMinTimestamp?.value, - } as Alert; - } - return labeledAlert; }) .filter((alert): alert is Alert => alert !== null) .sort((a, b) => a.alertsStartFiring - b.alertsStartFiring) @@ -356,19 +378,3 @@ export const groupAlertsForTable = ( return groupedAlerts; }; - -/** - * Function to match a timestamp metric based on the common labels - * (alertname, namespace, severity) - * @param alert - The alert to match the timestamp for - * @param timestamps - The timestamps to match the alert for - * @returns The matched timestamp - */ -const matchTimestampMetric = (alert: Alert, timestamps: Array): any => { - return timestamps.find( - (timestamp) => - timestamp.metric.alertname === alert.alertname && - timestamp.metric.namespace === alert.namespace && - timestamp.metric.severity === alert.severity, - ); -}; diff --git a/web/src/components/Incidents/processIncidents.spec.ts b/web/src/components/Incidents/processIncidents.spec.ts index c4328e645..d8e13a97c 100644 --- a/web/src/components/Incidents/processIncidents.spec.ts +++ b/web/src/components/Incidents/processIncidents.spec.ts @@ -5,8 +5,7 @@ import { getIncidentsTimeRanges, processIncidentsForAlerts, } from './processIncidents'; -import { IncidentsTimestamps } from './model'; -import { getCurrentTime } from './utils'; +import { getCurrentTime, DAY_MS } from './utils'; describe('convertToIncidents', () => { const now = getCurrentTime(); @@ -14,7 +13,7 @@ describe('convertToIncidents', () => { describe('edge cases', () => { it('should return empty array when no data provided', () => { - const result = convertToIncidents([], now); + const result = convertToIncidents([], now, 15 * DAY_MS); expect(result).toEqual([]); }); @@ -44,7 +43,7 @@ describe('convertToIncidents', () => { }, ]; - const result = convertToIncidents(data, now); + const result = convertToIncidents(data, now, 15 * DAY_MS); expect(result).toHaveLength(1); expect(result[0].src_alertname).toBe('ClusterOperatorDegraded'); }); @@ -68,7 +67,7 @@ describe('convertToIncidents', () => { }, ]; - const result = convertToIncidents(data, now); + const result = convertToIncidents(data, now, 15 * DAY_MS); expect(result).toHaveLength(1); // Verify resolved is determined from ORIGINAL values (before padding) @@ -100,7 +99,7 @@ describe('convertToIncidents', () => { }, ]; - const result = convertToIncidents(data, now); + const result = convertToIncidents(data, now, 15 * DAY_MS); expect(result).toHaveLength(1); expect(result[0].firing).toBe(true); expect(result[0].resolved).toBe(false); @@ -123,7 +122,7 @@ describe('convertToIncidents', () => { }, ]; - const result = convertToIncidents(data, now); + const result = convertToIncidents(data, now, 15 * DAY_MS); expect(result).toHaveLength(1); expect(result[0].firing).toBe(false); expect(result[0].resolved).toBe(true); @@ -146,7 +145,7 @@ describe('convertToIncidents', () => { }, ]; - const result = convertToIncidents(data, now); + const result = convertToIncidents(data, now, 15 * DAY_MS); expect(result).toHaveLength(1); expect(result[0].firing).toBe(false); // >= 10 minutes is resolved expect(result[0].resolved).toBe(true); @@ -180,7 +179,7 @@ describe('convertToIncidents', () => { }, ]; - const result = convertToIncidents(data, now); + const result = convertToIncidents(data, now, 15 * DAY_MS); expect(result).toHaveLength(2); expect(result[0].group_id).toBe('incident1'); // Earliest first expect(result[1].group_id).toBe('incident2'); @@ -212,7 +211,7 @@ describe('convertToIncidents', () => { }, ]; - const result = convertToIncidents(data, now); + const result = convertToIncidents(data, now, 15 * DAY_MS); expect(result).toHaveLength(2); expect(result[0].x).toBe(2); // Earliest has highest x expect(result[1].x).toBe(1); // Latest has lowest x @@ -235,7 +234,7 @@ describe('convertToIncidents', () => { }, ]; - const result = convertToIncidents(data, now); + const result = convertToIncidents(data, now, 15 * DAY_MS); expect(result).toHaveLength(1); expect(result[0].src_alertname).toBe('TestAlert'); expect(result[0].src_namespace).toBe('test-namespace'); @@ -255,7 +254,7 @@ describe('convertToIncidents', () => { }, ]; - const result = convertToIncidents(data, now); + const result = convertToIncidents(data, now, 15 * DAY_MS); expect(result).toHaveLength(1); expect(result[0].src_alertname).toBe('TestAlert'); // Only src_ properties should be extracted @@ -281,7 +280,7 @@ describe('convertToIncidents', () => { }, ]; - const result = convertToIncidents(data, now); + const result = convertToIncidents(data, now, 15 * DAY_MS); expect(result).toHaveLength(1); // insertPaddingPointsForChart adds a point 5 minutes before and after the point // After padding is added because now >= timestamp + 300 (since timestamp = nowSeconds - 600) @@ -308,7 +307,7 @@ describe('convertToIncidents', () => { }, ]; - const result = convertToIncidents(data, now); + const result = convertToIncidents(data, now, 15 * DAY_MS); expect(result).toHaveLength(1); expect(result[0].component).toBe('test-component'); // componentList is created by getIncidents @@ -356,7 +355,7 @@ describe('getIncidents', () => { group_id: 'incident1', component: 'comp2', }, - values: [[1100, '1']], + values: [[1100, '2']], // Same severity to avoid severity splitting }, ]; @@ -494,6 +493,7 @@ describe('getIncidents', () => { }); it('should build componentList when merging incidents with different components', () => { + // Use same severity to avoid severity splitting; test focuses on componentList const data: PrometheusResult[] = [ { metric: { @@ -507,14 +507,14 @@ describe('getIncidents', () => { group_id: 'incident1', component: 'comp2', }, - values: [[1100, '1']], + values: [[1100, '2']], }, { metric: { group_id: 'incident1', component: 'comp3', }, - values: [[1200, '0']], + values: [[1200, '2']], }, ]; @@ -527,6 +527,7 @@ describe('getIncidents', () => { }); it('should not add duplicate components to componentList', () => { + // Use same severity to avoid severity splitting; test focuses on componentList const data: PrometheusResult[] = [ { metric: { @@ -540,7 +541,7 @@ describe('getIncidents', () => { group_id: 'incident1', component: 'comp1', // Same component }, - values: [[1100, '1']], + values: [[1100, '2']], }, ]; @@ -552,6 +553,7 @@ describe('getIncidents', () => { describe('silenced status handling', () => { it('should use silenced value from most recent timestamp when merging', () => { + // Use same severity to avoid severity splitting; test focuses on silenced status const data: PrometheusResult[] = [ { metric: { @@ -567,7 +569,7 @@ describe('getIncidents', () => { component: 'comp2', silenced: 'true', }, - values: [[2000, '1']], // More recent + values: [[2000, '2']], // More recent, same severity }, ]; @@ -577,6 +579,7 @@ describe('getIncidents', () => { }); it('should keep older silenced value if new result has older timestamps', () => { + // Use same severity to avoid severity splitting; test focuses on silenced status const data: PrometheusResult[] = [ { metric: { @@ -592,7 +595,7 @@ describe('getIncidents', () => { component: 'comp2', silenced: 'false', }, - values: [[1000, '1']], // Older + values: [[1000, '2']], // Older, same severity }, ]; @@ -669,11 +672,6 @@ describe('getIncidentsTimeRanges', () => { }); describe('processIncidentsForAlerts', () => { - const emptyTimestamps: IncidentsTimestamps = { - minOverTime: [], - lastOverTime: [], - }; - describe('silenced status conversion', () => { it('should convert silenced "true" string to boolean true', () => { const incidents: PrometheusResult[] = [ @@ -686,7 +684,7 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents, emptyTimestamps); + const result = processIncidentsForAlerts(incidents); expect(result).toHaveLength(1); expect(result[0].silenced).toBe(true); }); @@ -702,7 +700,7 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents, emptyTimestamps); + const result = processIncidentsForAlerts(incidents); expect(result).toHaveLength(1); expect(result[0].silenced).toBe(false); }); @@ -717,7 +715,7 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents, emptyTimestamps); + const result = processIncidentsForAlerts(incidents); expect(result).toHaveLength(1); expect(result[0].silenced).toBe(false); }); @@ -733,7 +731,7 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents, emptyTimestamps); + const result = processIncidentsForAlerts(incidents); expect(result).toHaveLength(1); expect(result[0].silenced).toBe(false); }); @@ -756,7 +754,7 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents, emptyTimestamps); + const result = processIncidentsForAlerts(incidents); expect(result).toHaveLength(3); expect(result[0].x).toBe(3); expect(result[1].x).toBe(2); @@ -778,7 +776,7 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents, emptyTimestamps); + const result = processIncidentsForAlerts(incidents) as any[]; expect(result).toHaveLength(1); expect(result[0].group_id).toBe('incident1'); expect(result[0].component).toBe('test-component'); @@ -799,16 +797,14 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents, emptyTimestamps); + const result = processIncidentsForAlerts(incidents); expect(result).toHaveLength(1); expect(result[0].values).toEqual(values); }); }); - describe('timestamp matching', () => { - it('should use matched minOverTime timestamp when available', () => { - const matchedMinTimestamp = 1704067200; // 2024-01-01 00:00:00 UTC - + describe('firstTimestamp', () => { + it('should default firstTimestamp to 0', () => { const incidents: PrometheusResult[] = [ { metric: { @@ -822,225 +818,17 @@ describe('processIncidentsForAlerts', () => { }, ]; - const incidentsTimestamps: IncidentsTimestamps = { - minOverTime: [ - { - metric: { - group_id: 'incident1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }, - value: [matchedMinTimestamp, matchedMinTimestamp.toString()], - }, - ], - lastOverTime: [], - }; - - const result = processIncidentsForAlerts(incidents, incidentsTimestamps); - expect(result).toHaveLength(1); - expect(result[0].firstTimestamp).toBe(matchedMinTimestamp); - }); - - it('should default to 0 when no timestamp match is found', () => { - const incidents: PrometheusResult[] = [ - { - metric: { - group_id: 'incident1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }, - values: [[1704067300, '2']], - }, - ]; - - const incidentsTimestamps: IncidentsTimestamps = { - minOverTime: [], // No match - lastOverTime: [], - }; - - const result = processIncidentsForAlerts(incidents, incidentsTimestamps); + const result = processIncidentsForAlerts(incidents); expect(result).toHaveLength(1); + // processIncidentsForAlerts sets firstTimestamp to 0; + // the real firstTimestamp is computed in convertToAlerts from alert data expect(result[0].firstTimestamp).toBe(0); }); - - it('should match timestamp based on all required labels', () => { - const matchedMinTimestamp = 1704067200; - - const incidents: PrometheusResult[] = [ - { - metric: { - group_id: 'incident1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }, - values: [[1704067300, '2']], - }, - ]; - - const incidentsTimestamps: IncidentsTimestamps = { - minOverTime: [ - { - metric: { - group_id: 'incident1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }, - value: [matchedMinTimestamp, matchedMinTimestamp.toString()], - }, - ], - lastOverTime: [], - }; - - const result = processIncidentsForAlerts(incidents, incidentsTimestamps); - expect(result).toHaveLength(1); - expect(result[0].firstTimestamp).toBe(matchedMinTimestamp); - }); - - it('should not match when group_id differs', () => { - const incidents: PrometheusResult[] = [ - { - metric: { - group_id: 'incident1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }, - values: [[1704067300, '2']], - }, - ]; - - const incidentsTimestamps: IncidentsTimestamps = { - minOverTime: [ - { - metric: { - group_id: 'incident2', // Different - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }, - value: [1704067200, '1704067200'], - }, - ], - lastOverTime: [], - }; - - const result = processIncidentsForAlerts(incidents, incidentsTimestamps); - expect(result).toHaveLength(1); - expect(result[0].firstTimestamp).toBe(0); // No match, defaults to 0 - }); - - it('should not match when component differs', () => { - const incidents: PrometheusResult[] = [ - { - metric: { - group_id: 'incident1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }, - values: [[1704067300, '2']], - }, - ]; - - const incidentsTimestamps: IncidentsTimestamps = { - minOverTime: [ - { - metric: { - group_id: 'incident1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'other-component', // Different - src_severity: 'critical', - }, - value: [1704067200, '1704067200'], - }, - ], - lastOverTime: [], - }; - - const result = processIncidentsForAlerts(incidents, incidentsTimestamps); - expect(result).toHaveLength(1); - expect(result[0].firstTimestamp).toBe(0); // No match, defaults to 0 - }); - - it('should handle multiple incidents with different timestamps', () => { - const matchedTimestamp1 = 1704067200; - const matchedTimestamp2 = 1704067500; - - const incidents: PrometheusResult[] = [ - { - metric: { - group_id: 'incident1', - src_alertname: 'TestAlert1', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }, - values: [[1704067300, '2']], - }, - { - metric: { - group_id: 'incident2', - src_alertname: 'TestAlert2', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'warning', - }, - values: [[1704067600, '1']], - }, - ]; - - const incidentsTimestamps: IncidentsTimestamps = { - minOverTime: [ - { - metric: { - group_id: 'incident1', - src_alertname: 'TestAlert1', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }, - value: [matchedTimestamp1, matchedTimestamp1.toString()], - }, - { - metric: { - group_id: 'incident2', - src_alertname: 'TestAlert2', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'warning', - }, - value: [matchedTimestamp2, matchedTimestamp2.toString()], - }, - ], - lastOverTime: [], - }; - - const result = processIncidentsForAlerts(incidents, incidentsTimestamps); - expect(result).toHaveLength(2); - expect(result[0].firstTimestamp).toBe(matchedTimestamp1); - expect(result[1].firstTimestamp).toBe(matchedTimestamp2); - }); }); describe('edge cases', () => { it('should handle empty array', () => { - const emptyTimestamps: IncidentsTimestamps = { - minOverTime: [], - lastOverTime: [], - }; - const result = processIncidentsForAlerts([], emptyTimestamps); + const result = processIncidentsForAlerts([]); expect(result).toEqual([]); }); @@ -1052,12 +840,7 @@ describe('processIncidentsForAlerts', () => { }, ]; - const emptyTimestamps: IncidentsTimestamps = { - minOverTime: [], - lastOverTime: [], - }; - - const result = processIncidentsForAlerts(incidents, emptyTimestamps); + const result = processIncidentsForAlerts(incidents) as any[]; expect(result).toHaveLength(1); expect(result[0].group_id).toBe('incident1'); expect(result[0].silenced).toBe(true); diff --git a/web/src/components/Incidents/processIncidents.ts b/web/src/components/Incidents/processIncidents.ts index 7a76d186f..b6da46167 100644 --- a/web/src/components/Incidents/processIncidents.ts +++ b/web/src/components/Incidents/processIncidents.ts @@ -1,12 +1,13 @@ /* eslint-disable max-len */ import { PrometheusLabels, PrometheusResult } from '@openshift-console/dynamic-plugin-sdk'; -import { IncidentsTimestamps, Metric, ProcessedIncident } from './model'; +import { Metric, ProcessedIncident } from './model'; import { insertPaddingPointsForChart, isResolved, - matchTimestampMetricForIncident, sortByEarliestTimestamp, + DAY_MS, + PROMETHEUS_QUERY_INTERVAL_SECONDS, } from './utils'; /** @@ -14,6 +15,9 @@ import { * Adds padding points for chart rendering and determines firing/resolved status based on * the time elapsed since the last data point. * + * Severity-based splitting is handled upstream in getIncidents, which produces separate + * entries for each consecutive run of the same severity within a group_id. + * * @param prometheusResults - Array of Prometheus query results containing incident data. * @param currentTime - The current time in milliseconds to use for resolved/firing calculations. * @returns Array of processed incidents with firing/resolved status, padding points, and x positioning. @@ -21,36 +25,91 @@ import { export function convertToIncidents( prometheusResults: PrometheusResult[], currentTime: number, + daysSpanMs: number, ): ProcessedIncident[] { const incidents = getIncidents(prometheusResults).filter( (incident) => incident.metric.src_alertname !== 'Watchdog', ); const sortedIncidents = sortByEarliestTimestamp(incidents); - return sortedIncidents.map((incident, index) => { - // Determine resolved status based on original values before padding - const sortedValues = incident.values.sort((a, b) => a[0] - b[0]); - const lastTimestamp = sortedValues[sortedValues.length - 1][0]; - const resolved = isResolved(lastTimestamp, currentTime); + // Assign x values by unique group_id so that split severity segments share the same x + const uniqueGroupIds = [...new Set(sortedIncidents.map((i) => i.metric.group_id))]; + const groupIdToX = new Map(uniqueGroupIds.map((id, idx) => [id, uniqueGroupIds.length - idx])); - // Add padding points for chart rendering - const paddedValues = insertPaddingPointsForChart(sortedValues, currentTime); + // N-day window boundary (in seconds) for filtering values + const nDaysStartSeconds = (currentTime - daysSpanMs) / 1000; - const srcProperties = getSrcProperties(incident.metric); + const processedIncidents = sortedIncidents + .map((incident) => { + const sortedValues = incident.values.sort((a, b) => a[0] - b[0]); - return { - component: incident.metric.component, - componentList: incident.metric.componentList, - group_id: incident.metric.group_id, - layer: incident.metric.layer, - values: paddedValues, - x: incidents.length - index, - resolved, - firing: !resolved, - ...srcProperties, - metric: incident.metric, - } as ProcessedIncident; - }); + // Filter values to the N-day window + const windowValues = sortedValues.filter(([ts]) => ts >= nDaysStartSeconds); + + // Exclude incidents with no values in the selected window + if (windowValues.length === 0) { + return null; + } + + // Determine resolved status based on filtered values before padding + const lastTimestamp = windowValues[windowValues.length - 1][0]; + const resolved = isResolved(lastTimestamp, currentTime); + + // Add padding points for chart rendering + const paddedValues = insertPaddingPointsForChart(windowValues, currentTime); + + // firstTimestamp is absolute over the full 15-day data (before N-day filtering), + // with the padding offset applied to match chart rendering + const firstTimestamp = sortedValues[0][0] - PROMETHEUS_QUERY_INTERVAL_SECONDS; + + const srcProperties = getSrcProperties(incident.metric); + + return { + component: incident.metric.component, + componentList: incident.metric.componentList, + group_id: incident.metric.group_id, + layer: incident.metric.layer, + values: paddedValues, + x: groupIdToX.get(incident.metric.group_id), + resolved, + firing: !resolved, + firstTimestamp, + ...srcProperties, + metric: incident.metric, + } as ProcessedIncident; + }) + .filter((incident): incident is ProcessedIncident => incident !== null); + + return processedIncidents; +} + +/** + * Splits a sorted array of [timestamp, severity] tuples into consecutive segments + * where the severity value remains the same. A new segment starts whenever the + * severity changes between consecutive data points. + * + * @param sortedValues - Array of [timestamp, severity] tuples, sorted by timestamp + * @returns Array of segments, each containing consecutive values with the same severity + */ +function splitBySeverityChange( + sortedValues: Array<[number, string]>, +): Array> { + if (sortedValues.length === 0) return []; + + const segments: Array> = []; + let currentSegment: Array<[number, string]> = [sortedValues[0]]; + + for (let i = 1; i < sortedValues.length; i++) { + if (sortedValues[i][1] !== sortedValues[i - 1][1]) { + segments.push(currentSegment); + currentSegment = [sortedValues[i]]; + } else { + currentSegment.push(sortedValues[i]); + } + } + + segments.push(currentSegment); + return segments; } /** @@ -101,18 +160,36 @@ function getSrcProperties(metric: PrometheusLabels): Partial { * deduplicates timestamps (keeping only the highest severity per timestamp), * and combines components into componentList. * + * After merging and deduplication, each incident is split into separate entries + * when the severity (values[1]) changes between consecutive data points. + * This means a single group_id may produce multiple entries if its severity + * transitions over time (e.g., from warning '1' to critical '2'). + * * @param prometheusResults - Array of Prometheus query results to convert. - * @returns Array of incident objects with deduplicated values and combined properties. + * @returns Array of incident objects with deduplicated values and combined properties, + * split by severity changes. */ export function getIncidents( prometheusResults: PrometheusResult[], ): Array { const incidents = new Map(); + // Track which metric labels correspond to each severity value per group_id + // severity value ("0","1","2") -> metric from the Prometheus result that contributed it + const severityMetrics = new Map>(); for (const result of prometheusResults) { const groupId = result.metric.group_id; + // Track the metric for this result's severity value (skip Watchdog) + if (result.values.length > 0 && result.metric.src_alertname !== 'Watchdog') { + const severityValue = result.values[0][1]; // constant within a single result + if (!severityMetrics.has(groupId)) { + severityMetrics.set(groupId, new Map()); + } + severityMetrics.get(groupId).set(severityValue, result.metric); + } + const existingIncident = incidents.get(groupId); if (existingIncident) { @@ -156,7 +233,37 @@ export function getIncidents( } } - return Array.from(incidents.values()); + // Split each incident into separate entries when severity (values[1]) changes over time + const result: Array = []; + + for (const incident of incidents.values()) { + const groupId = incident.metric.group_id; + const sortedValues = [...incident.values].sort((a, b) => a[0] - b[0]); + const segments = splitBySeverityChange(sortedValues); + const groupSeverityMetrics = severityMetrics.get(groupId); + + for (const segmentValues of segments) { + const segmentSeverity = segmentValues[0][1]; // uniform within a segment + // Use the metric from the Prometheus result that contributed this severity, + // preserving shared properties (componentList, silenced) from the merged incident + const severitySpecificMetric = groupSeverityMetrics?.get(segmentSeverity); + + result.push({ + metric: { + ...incident.metric, + ...(severitySpecificMetric && { + src_alertname: severitySpecificMetric.src_alertname, + src_namespace: severitySpecificMetric.src_namespace, + src_severity: severitySpecificMetric.src_severity, + component: severitySpecificMetric.component, + }), + } as Metric, + values: segmentValues, + }); + } + } + + return result; } /** @@ -171,14 +278,13 @@ export const getIncidentsTimeRanges = ( timespan: number, currentTime: number, ): Array<{ endTime: number; duration: number }> => { - const ONE_DAY = 24 * 60 * 60 * 1000; // 24 hours in milliseconds const startTime = currentTime - timespan; - const timeRanges = [{ endTime: startTime + ONE_DAY, duration: ONE_DAY }]; + const timeRanges = [{ endTime: startTime + DAY_MS, duration: DAY_MS }]; while (timeRanges[timeRanges.length - 1].endTime < currentTime) { const lastRange = timeRanges[timeRanges.length - 1]; - const nextEndTime = lastRange.endTime + ONE_DAY; - timeRanges.push({ endTime: nextEndTime, duration: ONE_DAY }); + const nextEndTime = lastRange.endTime + DAY_MS; + timeRanges.push({ endTime: nextEndTime, duration: DAY_MS }); } return timeRanges; @@ -191,24 +297,8 @@ export const getIncidentsTimeRanges = ( * @param incidents - Array of Prometheus results containing incident data. * @returns Array of partial incident objects with silenced status as boolean and x position. */ -export const processIncidentsForAlerts = ( - incidents: Array, - incidentsTimestamps: IncidentsTimestamps, -) => { - const matchedIncidents = incidents.map((incident) => { - // expand matchTimestampMetricForIncident here - const matchedMinTimestamp = matchTimestampMetricForIncident( - incident.metric, - incidentsTimestamps.minOverTime, - ); - - return { - ...incident, - firstTimestamp: parseInt(matchedMinTimestamp?.value?.[1] ?? '0'), - }; - }); - - return matchedIncidents.map((incident, index) => { +export const processIncidentsForAlerts = (incidents: Array) => { + return incidents.map((incident, index) => { // Read silenced value from cluster_health_components_map metric label // If missing, default to false const silenced = incident.metric.silenced === 'true'; @@ -219,7 +309,8 @@ export const processIncidentsForAlerts = ( values: incident.values, x: incidents.length - index, silenced, - firstTimestamp: incident.firstTimestamp, + // TODO SET ME + firstTimestamp: 0, }; return retval; }); diff --git a/web/src/components/Incidents/utils.spec.ts b/web/src/components/Incidents/utils.spec.ts index 0a28c0b85..d880f065c 100644 --- a/web/src/components/Incidents/utils.spec.ts +++ b/web/src/components/Incidents/utils.spec.ts @@ -1,10 +1,11 @@ import { getCurrentTime, insertPaddingPointsForChart, + removeTrailingPaddingFromSeveritySegments, roundDateToInterval, - matchTimestampMetricForIncident, roundTimestampToFiveMinutes, } from './utils'; +import { Incident } from './model'; describe('getCurrentTime', () => { it('should return current time rounded down to 5-minute boundary', () => { @@ -90,185 +91,6 @@ describe('roundTimestampToFiveMinutes', () => { }); }); -describe('matchTimestampMetricForIncident', () => { - it('should match timestamp metric when all labels match', () => { - const incident = { - group_id: 'group1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }; - - const timestamps = [ - { - metric: { - group_id: 'group1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }, - value: [1704067200, '1704067200'], - }, - { - metric: { - group_id: 'group2', - src_alertname: 'OtherAlert', - src_namespace: 'other-namespace', - component: 'other-component', - src_severity: 'warning', - }, - value: [1704067300, '1704067300'], - }, - ]; - - const result = matchTimestampMetricForIncident(incident, timestamps); - expect(result).toBe(timestamps[0]); - }); - - it('should return undefined when no match is found', () => { - const incident = { - group_id: 'group1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }; - - const timestamps = [ - { - metric: { - group_id: 'group2', - src_alertname: 'OtherAlert', - src_namespace: 'other-namespace', - component: 'other-component', - src_severity: 'warning', - }, - value: [1704067300, '1704067300'], - }, - ]; - - const result = matchTimestampMetricForIncident(incident, timestamps); - expect(result).toBeUndefined(); - }); - - const incident = { - group_id: 'group1', - src_alertname: 'Alert1', - src_namespace: 'ns1', - component: 'comp1', - src_severity: 'warning', - }; - - const timestamps = [ - { - metric: { - group_id: 'group1', - src_alertname: 'Alert1', - src_namespace: 'ns1', - component: 'comp1', - src_severity: 'warning', - }, - value: [1704067200, '1704067200'], - }, - ]; - - const result = matchTimestampMetricForIncident(incident, timestamps); - expect(result).toBe(timestamps[0]); -}); - -it('should not match when group_id differs', () => { - const incident = { - group_id: 'group1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }; - - const timestamps = [ - { - metric: { - group_id: 'group2', // Different - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }, - value: [1704067200, '1704067200'], - }, - ]; - - const result = matchTimestampMetricForIncident(incident, timestamps); - expect(result).toBeUndefined(); -}); - -it('should not match when src_alertname differs', () => { - const incident = { - group_id: 'group1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }; - - const timestamps = [ - { - metric: { - group_id: 'group1', - src_alertname: 'OtherAlert', // Different - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }, - value: [1704067200, '1704067200'], - }, - ]; - - const result = matchTimestampMetricForIncident(incident, timestamps); - expect(result).toBeUndefined(); -}); - -it('should not match when component differs', () => { - const incident = { - group_id: 'group1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }; - - const timestamps = [ - { - metric: { - group_id: 'group1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'other-component', // Different - src_severity: 'critical', - }, - value: [1704067200, '1704067200'], - }, - ]; - - const result = matchTimestampMetricForIncident(incident, timestamps); - expect(result).toBeUndefined(); -}); - -it('should handle empty timestamps array', () => { - const incident = { - group_id: 'group1', - src_alertname: 'TestAlert', - src_namespace: 'test-namespace', - component: 'test-component', - src_severity: 'critical', - }; - - const result = matchTimestampMetricForIncident(incident, []); - expect(result).toBeUndefined(); -}); - describe('insertPaddingPointsForChart', () => { describe('edge cases', () => { it('should return empty array when input is empty', () => { @@ -582,3 +404,226 @@ describe('roundDateToInterval', () => { }); }); }); + +describe('removeTrailingPaddingFromSeveritySegments', () => { + const makeIncident = ( + overrides: Partial & { values: Array<[number, string]> }, + ): Incident => ({ + component: 'test-component', + componentList: ['test-component'], + layer: 'compute', + firing: false, + group_id: 'group-1', + src_severity: 'critical', + src_alertname: 'TestAlert', + src_namespace: 'test-ns', + severity: 'critical', + silenced: false, + x: 1, + firstTimestamp: 1000, + metric: { group_id: 'group-1', component: 'test-component' }, + ...overrides, + }); + + it('should return the group unchanged when it has a single incident', () => { + const group = [ + makeIncident({ + values: [ + [1000, '2'], + [1300, '2'], + [1600, '2'], + ], + }), + ]; + + const result = removeTrailingPaddingFromSeveritySegments(group); + + expect(result).toEqual(group); + expect(result[0].values).toHaveLength(3); + }); + + it('should remove the trailing value from non-last segments in a multi-segment group', () => { + const group = [ + makeIncident({ + values: [ + [1000, '1'], + [1300, '1'], + [1600, '1'], // trailing padding — should be removed + ], + }), + makeIncident({ + values: [ + [1300, '2'], + [1600, '2'], + [1900, '2'], + ], + }), + ]; + + const result = removeTrailingPaddingFromSeveritySegments(group); + + // First segment (non-last): trailing value removed + expect(result[0].values).toEqual([ + [1000, '1'], + [1300, '1'], + ]); + // Last segment: unchanged + expect(result[1].values).toEqual([ + [1300, '2'], + [1600, '2'], + [1900, '2'], + ]); + }); + + it('should not remove the trailing value from the last segment', () => { + const group = [ + makeIncident({ + values: [ + [1000, '1'], + [1300, '1'], + [1600, '1'], + ], + }), + makeIncident({ + values: [ + [1600, '2'], + [1900, '2'], + [2200, '2'], + ], + }), + ]; + + const result = removeTrailingPaddingFromSeveritySegments(group); + + // Last segment should keep all values + const lastSegment = result[result.length - 1]; + expect(lastSegment.values).toHaveLength(3); + }); + + it('should not remove values from a non-last segment that has only one value', () => { + const group = [ + makeIncident({ + values: [[1000, '1']], // single value — nothing to trim + }), + makeIncident({ + values: [ + [1300, '2'], + [1600, '2'], + ], + }), + ]; + + const result = removeTrailingPaddingFromSeveritySegments(group); + + // Single-value segment should be unchanged + expect(result[0].values).toEqual([[1000, '1']]); + // Last segment unchanged + expect(result[1].values).toEqual([ + [1300, '2'], + [1600, '2'], + ]); + }); + + it('should sort segments by their first timestamp before processing', () => { + // Pass segments in reverse order to verify sorting + const group = [ + makeIncident({ + values: [ + [2000, '2'], + [2300, '2'], + [2600, '2'], + ], + }), + makeIncident({ + values: [ + [1000, '1'], + [1300, '1'], + [1600, '1'], + ], + }), + ]; + + const result = removeTrailingPaddingFromSeveritySegments(group); + + // After sorting, the segment starting at 1000 is first (non-last) and gets trimmed + expect(result[0].values).toEqual([ + [1000, '1'], + [1300, '1'], + ]); + // The segment starting at 2000 is last and stays unchanged + expect(result[1].values).toEqual([ + [2000, '2'], + [2300, '2'], + [2600, '2'], + ]); + }); + + it('should handle three severity segments, trimming only non-last ones', () => { + const group = [ + makeIncident({ + values: [ + [1000, '0'], + [1300, '0'], + [1600, '0'], + ], + }), + makeIncident({ + values: [ + [1600, '1'], + [1900, '1'], + [2200, '1'], + ], + }), + makeIncident({ + values: [ + [2200, '2'], + [2500, '2'], + [2800, '2'], + ], + }), + ]; + + const result = removeTrailingPaddingFromSeveritySegments(group); + + // First segment: trimmed + expect(result[0].values).toEqual([ + [1000, '0'], + [1300, '0'], + ]); + // Second segment: trimmed (also non-last) + expect(result[1].values).toEqual([ + [1600, '1'], + [1900, '1'], + ]); + // Third (last) segment: unchanged + expect(result[2].values).toEqual([ + [2200, '2'], + [2500, '2'], + [2800, '2'], + ]); + }); + + it('should not mutate the original incident objects', () => { + const original = makeIncident({ + values: [ + [1000, '1'], + [1300, '1'], + [1600, '1'], + ], + }); + const group = [ + original, + makeIncident({ + values: [ + [1600, '2'], + [1900, '2'], + ], + }), + ]; + + removeTrailingPaddingFromSeveritySegments(group); + + // Original incident should still have all 3 values + expect(original.values).toHaveLength(3); + }); +}); diff --git a/web/src/components/Incidents/utils.ts b/web/src/components/Incidents/utils.ts index 26caac701..893665875 100644 --- a/web/src/components/Incidents/utils.ts +++ b/web/src/components/Incidents/utils.ts @@ -14,11 +14,15 @@ import { DaysFilters, Incident, IncidentFiltersCombined, - IncidentsDetailsAlert, SpanDates, Timestamps, } from './model'; +/** + * Number of milliseconds in a day + */ +export const DAY_MS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds + /** * The Prometheus query step interval in seconds. * @@ -306,13 +310,51 @@ function createNodataInterval( } /** - * Creates an array of incident data for chart bars, ensuring that when two severities have the same time range, the lower severity is removed. + * Creates chart bar data for a group of incidents that share the same group_id. + * Each incident (potentially a different severity segment) produces its own bars, + * all sharing the same x position so they render on the same row. + * Each bar retains its own properties (severity, startDate, firing, etc.). + * + * @param incidents - Array of incidents with the same group_id (split severity segments). + * @param dateArray - Array of span dates for interval calculation. + * @returns Flat array of bar objects for the chart, all sharing the same x. + */ +/** + * Removes trailing padding values from non-last severity segments within a group. + * + * When an incident changes severity, its segments share the same chart row. + * Non-last segments have a trailing padding point (+300s) that overlaps with the + * next segment's leading padding point (-300s). This function removes the trailing + * padding value from non-last segments to prevent visual overlap. + * + * Single-segment groups and the last segment of multi-segment groups are left unchanged. + * Segments with only one value are also left unchanged (nothing to trim). * - * @param {Object} incident - The incident data containing values with timestamps and severity levels. - * @returns {Array} - An array of incident objects with `y0`, `y`, `x`, and `name` fields representing the bars for the chart. + * @param group - Array of incidents sharing the same group_id (severity segments) + * @returns The group with trailing padding removed from non-last segments */ -export const createIncidentsChartBars = (incident: Incident, dateArray: SpanDates) => { - const groupedData = consolidateAndMergeIntervals(incident, dateArray); +export function removeTrailingPaddingFromSeveritySegments(group: Incident[]): Incident[] { + if (group.length <= 1) return group; + + const sorted = [...group].sort((a, b) => a.values[0][0] - b.values[0][0]); + + return sorted.map((incident, idx) => { + if (idx < sorted.length - 1 && incident.values.length > 1) { + return { ...incident, values: incident.values.slice(0, -1) }; + } + return incident; + }); +} + +export const createIncidentsChartBars = (incidents: Incident[], dateArray: SpanDates) => { + const getSeverityName = (value) => { + return value === '2' ? 'Critical' : value === '1' ? 'Warning' : 'Info'; + }; + const barChartColorScheme = { + critical: t_global_color_status_danger_default.var, + info: t_global_color_status_info_default.var, + warning: t_global_color_status_warning_default.var, + }; const data: { y0: Date; @@ -326,105 +368,72 @@ export const createIncidentsChartBars = (incident: Incident, dateArray: SpanDate startDate: Date; fill: string; }[] = []; - const getSeverityName = (value) => { - return value === '2' ? 'Critical' : value === '1' ? 'Warning' : 'Info'; - }; - const barChartColorScheme = { - critical: t_global_color_status_danger_default.var, - info: t_global_color_status_info_default.var, - warning: t_global_color_status_warning_default.var, - }; - let prev = null; - for (let i = 0; i < groupedData.length; i++) { - const severity = getSeverityName(groupedData[i][2]); - const isLastElement = i === groupedData.length - 1; - const isNodata = groupedData[i][2] === 'nodata'; - - // - Round the result since groupedData comes from raw time series values. - let startDate = 0; - if (i === 0) { - // - For the first bar, use the incident's first timestamp (rounded) - // This represents when the incident actually started - // If the first interval is 'nodata', we still use incident.firstTimestamp - // as the absolute start, since 'nodata' at the beginning is just visualization - // - Math.min is needed to handle edge cases when the incident is quite new and the firstTimestamp may be greater - // than the aggregated data from the query_range - startDate = - roundTimestampToFiveMinutes(Math.min(incident.firstTimestamp, groupedData[i][0])) * 1000; - } else { - // For subsequent bars, only calculate startDate for non-nodata intervals - // (nodata intervals don't need accurate startDate for tooltip display) - if (!isNodata) { - if (prev) { - // Calculate absolute start by: - // Previous bar's absolute start + duration of previous bar + gap to current bar - // This maintains continuity in the absolute timeline - const prevBarDuration = prev.y.getTime() - prev.startDate.getTime(); - const gapToCurrent = groupedData[i][0] * 1000 - prev.y.getTime(); - startDate = prev.startDate.getTime() + prevBarDuration + gapToCurrent; - } else { - // If prev is null (e.g., first interval was 'nodata'), fall back to incident.firstTimestamp or first groupedData - // This ensures we always have a valid absolute start time - startDate = roundTimestampToFiveMinutes(Math.min(incident.firstTimestamp, groupedData[i][0])) * 1000; - } - } else { - // For 'nodata' intervals, we can use the interval start timestamp - // This is mainly for consistency, but won't be displayed in tooltips - startDate = groupedData[i][0] * 1000; - } - } - - data.push({ - y0: new Date(groupedData[i][0] * 1000), - y: new Date(groupedData[i][1] * 1000), - x: incident.x, - name: severity, - firing: isLastElement ? incident.firing : false, - componentList: incident.componentList || [], - group_id: incident.group_id, - nodata: groupedData[i][2] === 'nodata' ? true : false, - startDate: new Date(startDate), - fill: - severity === 'Critical' - ? barChartColorScheme.critical - : severity === 'Warning' - ? barChartColorScheme.warning - : barChartColorScheme.info, - }); - if (!isNodata) { - prev = data[i]; + for (const incident of incidents) { + const groupedData = consolidateAndMergeIntervals(incident, dateArray); + + for (let i = 0; i < groupedData.length; i++) { + const severity = getSeverityName(groupedData[i][2]); + const isLastElement = i === groupedData.length - 1; + + data.push({ + y0: new Date(groupedData[i][0] * 1000), + y: new Date(groupedData[i][1] * 1000), + x: incident.x, + name: severity, + firing: isLastElement ? incident.firing : false, + componentList: incident.componentList || [], + group_id: incident.group_id, + nodata: groupedData[i][2] === 'nodata', + startDate: new Date(incident.firstTimestamp * 1000), + fill: + severity === 'Critical' + ? barChartColorScheme.critical + : severity === 'Warning' + ? barChartColorScheme.warning + : barChartColorScheme.info, + }); } } return data; }; -function consolidateAndMergeAlertIntervals(data: Alert) { - if (!data.values || data.values.length === 0) { - return []; - } +function consolidateAndMergeAlertIntervals(alerts: Array): Array { + if (alerts.length === 0) return []; - // Spread the array items to prevent sorting the original array - const sortedValues = [...data.values].sort((a, b) => a[0] - b[0]); + // Tag each value with its source alert's firstTimestamp, then sort + const taggedValues = alerts.flatMap((alert) => + alert.values.map((v) => ({ timestamp: v[0], firstTimestamp: alert.firstTimestamp })), + ); + + if (taggedValues.length === 0) return []; + + const sorted = [...taggedValues].sort((a, b) => a.timestamp - b.timestamp); const intervals: Array = []; - let currentStart = sortedValues[0][0]; + let currentStart = sorted[0].timestamp; + let currentFirstTimestamp = sorted[0].firstTimestamp; let previousTimestamp = currentStart; - for (let i = 1; i < sortedValues.length; i++) { - const currentTimestamp = sortedValues[i][0]; - const timeDifference = (currentTimestamp - previousTimestamp) / 60; // Convert to minutes + for (let i = 1; i < sorted.length; i++) { + const timeDifference = (sorted[i].timestamp - previousTimestamp) / 60; // Convert to minutes if (timeDifference > 5) { - intervals.push([currentStart, sortedValues[i - 1][0], 'data']); - intervals.push([previousTimestamp + 1, currentTimestamp - 1, 'nodata']); - currentStart = sortedValues[i][0]; + intervals.push([currentStart, sorted[i - 1].timestamp, 'data', currentFirstTimestamp]); + intervals.push([previousTimestamp + 1, sorted[i].timestamp - 1, 'nodata']); + currentStart = sorted[i].timestamp; + currentFirstTimestamp = sorted[i].firstTimestamp; } - previousTimestamp = currentTimestamp; + previousTimestamp = sorted[i].timestamp; } - intervals.push([currentStart, sortedValues[sortedValues.length - 1][0], 'data']); + intervals.push([ + currentStart, + sorted[sorted.length - 1].timestamp, + 'data', + currentFirstTimestamp, + ]); // For dynamic alerts timeline, we don't add padding gaps since the dateArray // is already calculated to fit the alert data with appropriate padding @@ -433,8 +442,15 @@ function consolidateAndMergeAlertIntervals(data: Alert) { return intervals; } -export const createAlertsChartBars = (alert: IncidentsDetailsAlert): AlertsChartBar[] => { - const groupedData = consolidateAndMergeAlertIntervals(alert); +export const createAlertsChartBars = (alerts: Array): AlertsChartBar[] => { + if (alerts.length === 0) return []; + + const groupedData = consolidateAndMergeAlertIntervals(alerts); + + // Use first interval for identity, last for alertstate + const firstAlert = alerts[0]; + const lastAlert = alerts[alerts.length - 1]; + const barChartColorScheme = { critical: t_global_color_status_danger_default.var, info: t_global_color_status_info_default.var, @@ -443,44 +459,27 @@ export const createAlertsChartBars = (alert: IncidentsDetailsAlert): AlertsChart const data = []; - let idx = alert.firstTimestamps.length - 1; - for (let i = groupedData.length - 1; i >= 0; i--) { + for (let i = 0; i < groupedData.length; i++) { const isLastElement = i === groupedData.length - 1; - const isNodata = groupedData[i][2] === 'nodata'; - - let startDate = 0; - if (i === 0) { - // For the first bar, use the minimum of alert's first timestamp and the first interval start - // This handles the case when the alert was created within 5 minutes - startDate = roundTimestampToFiveMinutes( - Math.min(parseInt(alert.firstTimestamps[idx][1]), groupedData[i][0]), - ); - idx--; - } else { - if (!isNodata && idx >= 0) { - startDate = roundTimestampToFiveMinutes(parseInt(alert.firstTimestamps[idx][1])); - idx--; - } - // Note: If isNodata, startDate remains 0 (not used in tooltips for nodata intervals) - } + const intervalFirstTimestamp = groupedData[i][3] ?? firstAlert.firstTimestamp; data.push({ y0: new Date(groupedData[i][0] * 1000), y: new Date(groupedData[i][1] * 1000), - startDate: new Date(startDate * 1000), - x: alert.x, - severity: alert.severity[0].toUpperCase() + alert.severity.slice(1), - name: alert.alertname, - namespace: alert.namespace, - layer: alert.layer, - component: alert.component, - nodata: groupedData[i][2] === 'nodata' ? true : false, - alertstate: isLastElement ? alert.alertstate : 'resolved', - silenced: alert.silenced, + startDate: new Date(intervalFirstTimestamp * 1000), + x: firstAlert.x, + severity: firstAlert.severity[0].toUpperCase() + firstAlert.severity.slice(1), + name: firstAlert.alertname, + namespace: firstAlert.namespace, + layer: firstAlert.layer, + component: firstAlert.component, + nodata: groupedData[i][2] === 'nodata', + alertstate: isLastElement ? lastAlert.alertstate : 'resolved', + silenced: firstAlert.silenced, fill: - alert.severity === 'critical' + firstAlert.severity === 'critical' ? barChartColorScheme.critical - : alert.severity === 'warning' + : firstAlert.severity === 'warning' ? barChartColorScheme.warning : barChartColorScheme.info, }); @@ -984,24 +983,3 @@ export const getFilterKey = (categoryName: string): string => { } return categoryName.toLowerCase(); }; - -/** - * Function to match a timestamp metric for an incident based on the common labels - * (group_id, src_alertname, src_namespace, src_severity) - * @param incident - The incident to match the timestamp for - * @param timestamps - The timestamps to match the incident for - * @returns The matched timestamp - */ -export const matchTimestampMetricForIncident = (incident: any, timestamps: Array): any => { - if (!timestamps || !Array.isArray(timestamps)) { - return undefined; - } - return timestamps.find( - (timestamp) => - timestamp.metric.group_id === incident.group_id && - timestamp.metric.src_alertname === incident.src_alertname && - timestamp.metric.src_namespace === incident.src_namespace && - timestamp.metric.component === incident.component && - timestamp.metric.src_severity === incident.src_severity, - ); -}; diff --git a/web/src/store/actions.ts b/web/src/store/actions.ts index a921324cb..ef45c45cf 100644 --- a/web/src/store/actions.ts +++ b/web/src/store/actions.ts @@ -42,8 +42,6 @@ export enum ActionType { SetAlertsAreLoading = 'setAlertsAreLoading', SetIncidentsChartSelection = 'setIncidentsChartSelection', SetFilteredIncidentsData = 'setFilteredIncidentsData', - SetIncidentsTimestamps = 'setIncidentsTimestamps', - SetAlertsTimestamps = 'setAlertsTimestamps', SetIncidentPageFilterType = 'setIncidentPageFilterType', SetIncidentsLastRefreshTime = 'setIncidentsLastRefreshTime', } @@ -190,12 +188,6 @@ export const setIncidentsChartSelection = (incidentsChartSelectedId) => export const setFilteredIncidentsData = (filteredIncidentsData) => action(ActionType.SetFilteredIncidentsData, filteredIncidentsData); -export const setIncidentsTimestamps = (incidentsTimestamps) => - action(ActionType.SetIncidentsTimestamps, incidentsTimestamps); - -export const setAlertsTimestamps = (alertsTimestamps) => - action(ActionType.SetAlertsTimestamps, alertsTimestamps); - export const setIncidentPageFilterType = (filterTypeSelected) => action(ActionType.SetIncidentPageFilterType, filterTypeSelected); @@ -242,8 +234,6 @@ type Actions = { setAlertsAreLoading: typeof setAlertsAreLoading; setIncidentsChartSelection: typeof setIncidentsChartSelection; setFilteredIncidentsData: typeof setFilteredIncidentsData; - setIncidentsTimestamps: typeof setIncidentsTimestamps; - setAlertsTimestamps: typeof setAlertsTimestamps; setIncidentPageFilterType: typeof setIncidentPageFilterType; setIncidentsLastRefreshTime: typeof setIncidentsLastRefreshTime; }; diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index 682192e2d..852fd2dbe 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -380,16 +380,6 @@ const monitoringReducer = produce((draft: ObserveState, action: ObserveAction): break; } - case ActionType.SetIncidentsTimestamps: { - draft.incidentsData.incidentsTimestamps = action.payload.incidentsTimestamps; - break; - } - - case ActionType.SetAlertsTimestamps: { - draft.incidentsData.alertsTimestamps = action.payload.alertsTimestamps; - break; - } - case ActionType.SetIncidentPageFilterType: { draft.incidentsData.incidentPageFilterType = action.payload.incidentPageFilterType; break; diff --git a/web/src/store/store.ts b/web/src/store/store.ts index 345f7af29..9dfd6040e 100644 --- a/web/src/store/store.ts +++ b/web/src/store/store.ts @@ -7,8 +7,6 @@ import { DaysFilters, IncidentSeverityFilters, IncidentStateFilters, - IncidentsTimestamps, - AlertsTimestamps, } from '../components/Incidents/model'; import { Variable } from '../components/dashboards/legacy/legacy-variable-dropdowns'; @@ -35,8 +33,6 @@ export type ObserveState = { alertsData: Array; alertsTableData: Array; filteredIncidentsData: Array; - incidentsTimestamps: IncidentsTimestamps; - alertsTimestamps: AlertsTimestamps; alertsAreLoading: boolean; incidentsChartSelectedId: string; incidentsInitialState: { @@ -84,8 +80,6 @@ export const defaultObserveState: ObserveState = { alertsData: [], alertsTableData: [], filteredIncidentsData: [], - incidentsTimestamps: { minOverTime: [], lastOverTime: [] }, - alertsTimestamps: { minOverTime: [], lastOverTime: [] }, alertsAreLoading: true, incidentsChartSelectedId: '', incidentsInitialState: {