diff --git a/web/src/components/Incidents/AlertsChart/AlertsChart.tsx b/web/src/components/Incidents/AlertsChart/AlertsChart.tsx index 1705b2e1..b5ff988f 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(() => { @@ -161,7 +172,7 @@ 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(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 51c7a52a..9dd67211 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, + removeTrailingPaddingFromSeveritySegments, roundDateToInterval, } from '../utils'; import { dateTimeFormatter, timeFormatter } from '../../console/utils/datetime'; @@ -89,10 +90,27 @@ const IncidentsChart = ({ ? 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 @@ -102,7 +120,6 @@ const IncidentsChart = ({ useEffect(() => { setIsLoading(false); }, [incidentsData]); - useEffect(() => { setChartContainerHeight(chartData?.length < 5 ? 300 : chartData?.length * 60); setChartHeight(chartData?.length < 5 ? 250 : chartData?.length * 55); @@ -176,7 +193,7 @@ const IncidentsChart = ({ if (datum.nodata) { return ''; } - const startDate = dateTimeFormatter(i18n.language).format(new Date(datum.y0)); + 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 e29e8a09..8c9ac677 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 c23456b1..324d835e 100644 --- a/web/src/components/Incidents/IncidentsPage.tsx +++ b/web/src/components/Incidents/IncidentsPage.tsx @@ -38,6 +38,7 @@ import { onIncidentFiltersSelect, parseUrlParams, updateBrowserUrl, + DAY_MS, } from './utils'; import { groupAlertsForTable, convertToAlerts } from './processAlerts'; import { CompressArrowsAltIcon, CompressIcon, FilterIcon } from '@patternfly/react-icons'; @@ -229,49 +230,57 @@ const IncidentsPage = () => { }, [incidentsActiveFilters.days]); useEffect(() => { - (async () => { - const currentTime = incidentsLastRefreshTime; - 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, - ); + // Guard: don't process if no incidents selected or timeRanges not ready + if (incidentForAlertProcessing.length === 0 || timeRanges.length === 0) { + return; + } + + const currentTime = incidentsLastRefreshTime; + + // Always fetch 15 days of alert data so firstTimestamp is computed from full history + const fetchTimeRanges = getIncidentsTimeRanges(15 * DAY_MS, currentTime); + + Promise.all( + fetchTimeRanges.map(async (range) => { + const response = await fetchDataForIncidentsAndAlerts( + safeFetch, + range, + createAlertsQuery(incidentForAlertProcessing), + ); + return response.data.result; + }), + ) + .then((alertsResults) => { + const prometheusResults = alertsResults.flat(); + const alerts = convertToAlerts( + prometheusResults, + incidentForAlertProcessing, + currentTime, + daysSpan, + ); + dispatch( + setAlertsData({ + alertsData: alerts, + }), + ); + if (rules && alerts) { dispatch( - setAlertsData({ - alertsData: alerts, + setAlertsTableData({ + alertsTableData: groupAlertsForTable(alerts, rules), }), ); - 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]); + } + if (!isEmpty(filteredData)) { + dispatch(setAlertsAreLoading({ alertsAreLoading: false })); + } else { + dispatch(setAlertsAreLoading({ alertsAreLoading: true })); + } + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error(err); + }); + }, [incidentForAlertProcessing, timeRanges, rules]); useEffect(() => { if (!isInitialized) return; @@ -287,30 +296,34 @@ 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'; + // Always fetch 15 days of data so firstTimestamp is computed from full history + const fetchTimeRanges = getIncidentsTimeRanges(15 * DAY_MS, currentTime); + Promise.all( - calculatedTimeRanges.map(async (range) => { + fetchTimeRanges.map(async (range) => { const response = await fetchDataForIncidentsAndAlerts(safeFetch, range, incidentsQuery); return response.data.result; }), ) - .then((results) => { - const prometheusResults = results.flat(); - const incidents = convertToIncidents(prometheusResults, currentTime); + .then((incidentsResults) => { + const prometheusResults = incidentsResults.flat(); + 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, }), ); diff --git a/web/src/components/Incidents/IncidentsTable.tsx b/web/src/components/Incidents/IncidentsTable.tsx index 0d348cdc..ea455f18 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 133f476d..2efee694 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,10 +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; }; // Define the interface for Metric @@ -47,6 +49,7 @@ export type Alert = { severity: Severity; silenced: boolean; x: number; + firstTimestamp: number; values: Array; alertsExpandedRowData?: Array; }; @@ -101,6 +104,7 @@ export type IncidentsDetailsAlert = { resolved: boolean; severity: Severity; x: 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 8c424a72..c827d2e8 100644 --- a/web/src/components/Incidents/processAlerts.spec.ts +++ b/web/src/components/Incidents/processAlerts.spec.ts @@ -1,15 +1,16 @@ import { PrometheusResult } from '@openshift-console/dynamic-plugin-sdk'; import { convertToAlerts, deduplicateAlerts } from './processAlerts'; import { Incident } from './model'; -import { getCurrentTime } from './utils'; +import { getCurrentTime, DAY_MS } from './utils'; describe('convertToAlerts', () => { const now = getCurrentTime(); const nowSeconds = Math.floor(now / 1000); + const daysSpanMs = 15 * DAY_MS; describe('edge cases', () => { it('should return empty array when no prometheus results provided', () => { - const result = convertToAlerts([], [], now); + const result = convertToAlerts([], [], now, daysSpanMs); expect(result).toEqual([]); }); @@ -28,7 +29,7 @@ describe('convertToAlerts', () => { values: [[nowSeconds, '1']], }, ]; - const result = convertToAlerts(prometheusResults, [], now); + const result = convertToAlerts(prometheusResults, [], now, daysSpanMs); expect(result).toEqual([]); }); @@ -69,7 +70,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(1); expect(result[0].alertname).toBe('ClusterOperatorDegraded'); }); @@ -78,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[] = [ { @@ -92,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) ], }, ]; @@ -113,10 +114,9 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + 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); }); @@ -148,7 +148,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toEqual([]); }); }); @@ -182,7 +182,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(1); // Verify resolved is determined from ORIGINAL values (before padding) @@ -226,7 +226,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(1); expect(result[0].alertsStartFiring).toBeGreaterThan(0); expect(result[0].alertsEndFiring).toBeGreaterThan(0); @@ -268,16 +268,16 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); 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 +304,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(1); expect(result[0].alertstate).toBe('firing'); expect(result[0].resolved).toBe(false); @@ -357,7 +357,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + 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'); @@ -408,7 +408,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + 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 @@ -443,7 +443,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(1); expect(result[0].silenced).toBe(true); }); @@ -451,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: { @@ -461,8 +465,8 @@ describe('convertToAlerts', () => { alertstate: 'firing', }, values: [ - [nowSeconds - 600, '2'], - [nowSeconds, '2'], + [t1, '2'], + [t2, '2'], ], }, ]; @@ -477,7 +481,7 @@ describe('convertToAlerts', () => { component: 'test-component', layer: 'test-layer', silenced: false, - values: [[nowSeconds - 600, '2']], + values: [[t1, '2']], }, { group_id: 'incident1', @@ -485,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); + 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); @@ -523,7 +527,7 @@ describe('convertToAlerts', () => { }, ]; - const result = convertToAlerts(prometheusResults, incidents, now); + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); expect(result).toHaveLength(1); expect(result[0].alertname).toBe('MyAlert'); expect(result[0].namespace).toBe('my-namespace'); @@ -533,6 +537,93 @@ describe('convertToAlerts', () => { expect(result[0].name).toBe('my-name'); }); }); + + 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[] = [ + { + metric: { + alertname: 'TestAlert', + namespace: 'test-namespace', + severity: 'critical', + name: 'test', + alertstate: 'firing', + }, + values: [ + [alertStart, '2'], + [alertStart + 300, '2'], + [alertStart + 600, '2'], + ], + }, + ]; + + const incidents: Array> = [ + { + group_id: 'incident1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + src_severity: 'critical', + component: 'test-component', + layer: 'test-layer', + values: [[alertStart, '2']], + }, + ]; + + const result = convertToAlerts(prometheusResults, incidents, now, daysSpanMs); + expect(result).toHaveLength(1); + // firstTimestamp is the first value minus the 300s padding offset + expect(result[0].firstTimestamp).toBe(alertStart - 300); + }); + + 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[] = [ + { + metric: { + alertname: 'TestAlert', + namespace: 'test-namespace', + severity: 'critical', + name: 'test', + alertstate: 'firing', + }, + values: [ + [alertStart, '2'], // Before N-day boundary (may be clipped by filter) + [nDaysBoundary, '2'], // At boundary (inside window) + [nDaysBoundary + 300, '2'], // Inside window + ], + }, + ]; + + const incidents: Array> = [ + { + group_id: 'incident1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + src_severity: 'critical', + component: 'test-component', + layer: 'test-layer', + values: [ + [alertStart, '2'], + [nDaysBoundary + 300, '2'], + ], + }, + ]; + + const result = convertToAlerts(prometheusResults, incidents, now, threeDaysMs); + expect(result).toHaveLength(1); + // firstTimestamp should reflect the absolute interval start minus padding, + // not the clipped window boundary + expect(result[0].firstTimestamp).toBe(alertStart - 300); + }); + }); }); describe('deduplicateAlerts', () => { @@ -718,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: { @@ -740,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 b216aa2a..e371cb1e 100644 --- a/web/src/components/Incidents/processAlerts.ts +++ b/web/src/components/Incidents/processAlerts.ts @@ -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,9 +236,11 @@ export function convertToAlerts( prometheusResults: Array, selectedIncidents: Array>, currentTime: number, + 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 @@ -215,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, ); @@ -259,19 +299,22 @@ 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]; 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 diff --git a/web/src/components/Incidents/processIncidents.spec.ts b/web/src/components/Incidents/processIncidents.spec.ts index ab6ccd63..d8e13a97 100644 --- a/web/src/components/Incidents/processIncidents.spec.ts +++ b/web/src/components/Incidents/processIncidents.spec.ts @@ -5,7 +5,7 @@ import { getIncidentsTimeRanges, processIncidentsForAlerts, } from './processIncidents'; -import { getCurrentTime } from './utils'; +import { getCurrentTime, DAY_MS } from './utils'; describe('convertToIncidents', () => { const now = getCurrentTime(); @@ -13,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([]); }); @@ -43,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'); }); @@ -67,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) @@ -99,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); @@ -122,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); @@ -145,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); @@ -179,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'); @@ -211,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 @@ -234,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'); @@ -254,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 @@ -280,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) @@ -307,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 @@ -355,7 +355,7 @@ describe('getIncidents', () => { group_id: 'incident1', component: 'comp2', }, - values: [[1100, '1']], + values: [[1100, '2']], // Same severity to avoid severity splitting }, ]; @@ -493,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: { @@ -506,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']], }, ]; @@ -526,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: { @@ -539,7 +541,7 @@ describe('getIncidents', () => { group_id: 'incident1', component: 'comp1', // Same component }, - values: [[1100, '1']], + values: [[1100, '2']], }, ]; @@ -551,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: { @@ -566,7 +569,7 @@ describe('getIncidents', () => { component: 'comp2', silenced: 'true', }, - values: [[2000, '1']], // More recent + values: [[2000, '2']], // More recent, same severity }, ]; @@ -576,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: { @@ -591,7 +595,7 @@ describe('getIncidents', () => { component: 'comp2', silenced: 'false', }, - values: [[1000, '1']], // Older + values: [[1000, '2']], // Older, same severity }, ]; @@ -772,7 +776,7 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents); + 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,6 +803,29 @@ describe('processIncidentsForAlerts', () => { }); }); + describe('firstTimestamp', () => { + it('should default firstTimestamp to 0', () => { + const incidents: PrometheusResult[] = [ + { + metric: { + group_id: 'incident1', + src_alertname: 'TestAlert', + src_namespace: 'test-namespace', + component: 'test-component', + src_severity: 'critical', + }, + values: [[1704067300, '2']], + }, + ]; + + 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); + }); + }); + describe('edge cases', () => { it('should handle empty array', () => { const result = processIncidentsForAlerts([]); @@ -813,7 +840,7 @@ describe('processIncidentsForAlerts', () => { }, ]; - const result = processIncidentsForAlerts(incidents); + 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 803123f1..b6da4616 100644 --- a/web/src/components/Incidents/processIncidents.ts +++ b/web/src/components/Incidents/processIncidents.ts @@ -1,14 +1,23 @@ /* 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 { Metric, ProcessedIncident } from './model'; +import { + insertPaddingPointsForChart, + isResolved, + sortByEarliestTimestamp, + DAY_MS, + PROMETHEUS_QUERY_INTERVAL_SECONDS, +} from './utils'; /** * Converts Prometheus results into processed incidents, filtering out Watchdog incidents. * 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. @@ -16,36 +25,91 @@ import { insertPaddingPointsForChart, isResolved, sortByEarliestTimestamp } from 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; } /** @@ -96,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) { @@ -151,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; } /** @@ -166,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; @@ -186,20 +297,21 @@ 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, -): Array> => { +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'; // Return the processed incident - return { + const retval = { ...incident.metric, values: incident.values, x: incidents.length - index, silenced, + // 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 97c55df8..d880f065 100644 --- a/web/src/components/Incidents/utils.spec.ts +++ b/web/src/components/Incidents/utils.spec.ts @@ -1,4 +1,95 @@ -import { insertPaddingPointsForChart, roundDateToInterval } from './utils'; +import { + getCurrentTime, + insertPaddingPointsForChart, + removeTrailingPaddingFromSeveritySegments, + roundDateToInterval, + roundTimestampToFiveMinutes, +} from './utils'; +import { Incident } from './model'; + +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('insertPaddingPointsForChart', () => { describe('edge cases', () => { @@ -313,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 6f60fc9a..89366587 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. * @@ -74,6 +78,28 @@ export const roundDateToInterval = (date: Date): Date => { 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. * @@ -284,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; @@ -301,67 +365,75 @@ export const createIncidentsChartBars = (incident: Incident, dateArray: SpanDate componentList: string[]; group_id: string; nodata: boolean; + 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, - }; - 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' ? true : false, - fill: - severity === 'Critical' - ? barChartColorScheme.critical - : severity === 'Warning' - ? barChartColorScheme.warning - : barChartColorScheme.info, - }); + 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 []; + + // 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 })), + ); - // Spread the array items to prevent sorting the original array - const sortedValues = [...data.values].sort((a, b) => a[0] - b[0]); + 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 @@ -370,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, @@ -382,22 +461,25 @@ export const createAlertsChartBars = (alert: IncidentsDetailsAlert): AlertsChart for (let i = 0; i < groupedData.length; i++) { const isLastElement = i === groupedData.length - 1; + const intervalFirstTimestamp = groupedData[i][3] ?? firstAlert.firstTimestamp; + data.push({ y0: new Date(groupedData[i][0] * 1000), y: new Date(groupedData[i][1] * 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, }); @@ -444,7 +526,6 @@ export function generateDateArray(days: number, currentTime: number): Array