diff --git a/web/src/components/Incidents/AlertsChart/AlertsChart.tsx b/web/src/components/Incidents/AlertsChart/AlertsChart.tsx index 43dd87584..8dd94dd7a 100644 --- a/web/src/components/Incidents/AlertsChart/AlertsChart.tsx +++ b/web/src/components/Incidents/AlertsChart/AlertsChart.tsx @@ -32,6 +32,7 @@ import { generateDateArray, generateAlertsDateArray, getCurrentTime, + roundTimestampToFiveMinutes, } from '../utils'; import { dateTimeFormatter, timeFormatter } from '../../console/utils/datetime'; import { useTranslation } from 'react-i18next'; @@ -160,7 +161,11 @@ 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( + 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 b5d27ad1e..d3d77a0f7 100644 --- a/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx +++ b/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx @@ -28,11 +28,13 @@ 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, generateDateArray, + matchTimestampMetricForIncident, + roundTimestampToFiveMinutes, } from '../utils'; import { dateTimeFormatter, timeFormatter } from '../../console/utils/datetime'; import { useTranslation } from 'react-i18next'; @@ -56,6 +58,7 @@ const formatComponentList = (componentList: string[] | undefined): string => { const IncidentsChart = ({ incidentsData, + incidentsTimestamps, chartDays, theme, selectedGroupId, @@ -64,6 +67,7 @@ const IncidentsChart = ({ lastRefreshTime, }: { incidentsData: Array; + incidentsTimestamps: IncidentsTimestamps; chartDays: number; theme: 'light' | 'dark'; selectedGroupId: string; @@ -79,6 +83,20 @@ 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, + ); + + return { + ...incident, + firstTimestamp: parseInt(matchedMinTimestamp?.value?.[1] ?? '0'), + }; + }); + const { t, i18n } = useTranslation(process.env.I18N_NAMESPACE); const chartData = useMemo(() => { @@ -175,7 +193,11 @@ const IncidentsChart = ({ if (datum.nodata) { return ''; } - const startDate = dateTimeFormatter(i18n.language).format(new Date(datum.y0)); + const startDate = dateTimeFormatter(i18n.language).format( + new Date( + roundTimestampToFiveMinutes(datum.startDate.getTime() / 1000) * 1000, + ), + ); const endDate = datum.firing ? '---' : dateTimeFormatter(i18n.language).format(new Date(datum.y)); diff --git a/web/src/components/Incidents/IncidentsDetailsRowTable.tsx b/web/src/components/Incidents/IncidentsDetailsRowTable.tsx index e29e8a095..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[]; @@ -24,10 +25,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 +47,27 @@ 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 c23456b1e..674b53703 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, @@ -46,7 +46,9 @@ import { setAlertsAreLoading, setAlertsData, setAlertsTableData, + setAlertsTimestamps, setFilteredIncidentsData, + setIncidentsTimestamps, setIncidentPageFilterType, setIncidents, setIncidentsActiveFilters, @@ -146,6 +148,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( @@ -229,49 +235,74 @@ 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; + + // 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 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( + setAlertsTimestamps({ + alertsTimestamps: fetchedAlertsTimestamps, + }), + ); + + const prometheusResults = alertsResults.flat(); + const alerts = convertToAlerts( + prometheusResults, + incidentForAlertProcessing, + currentTime, + fetchedAlertsTimestamps, + ); + 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.log(err); + }); + }, [incidentForAlertProcessing, timeRanges, rules]); useEffect(() => { if (!isInitialized) return; @@ -294,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 @@ -317,7 +368,10 @@ const IncidentsPage = () => { setIncidentsAreLoading(false); if (isGroupSelected) { - setIncidentForAlertProcessing(processIncidentsForAlerts(prometheusResults)); + // Use fetchedTimestamps directly instead of stale closure value + setIncidentForAlertProcessing( + processIncidentsForAlerts(prometheusResults, fetchedTimestamps), + ); dispatch(setAlertsAreLoading({ alertsAreLoading: true })); } else { closeDropDownFilters(); @@ -638,6 +692,7 @@ const IncidentsPage = () => { { const { t } = useTranslation(process.env.I18N_NAMESPACE); @@ -95,7 +96,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)) { @@ -180,7 +181,9 @@ export const IncidentsTable = () => { )} - + 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..14f4aee38 100644 --- a/web/src/components/Incidents/model.ts +++ b/web/src/components/Incidents/model.ts @@ -19,6 +19,18 @@ export type Incident = { x: number; values: Array; metric: Metric; + firstTimestamp: number; + lastTimestamp: number; +}; + +export type IncidentsTimestamps = { + minOverTime: Array; + lastOverTime: Array; +}; + +export type AlertsTimestamps = { + minOverTime: Array; + lastOverTime: Array; }; // Define the interface for Metric @@ -47,6 +59,7 @@ export type Alert = { severity: Severity; silenced: boolean; x: number; + firstTimestamp: number; values: Array; alertsExpandedRowData?: Array; }; @@ -101,6 +114,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.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 b216aa2a8..962e00dae 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: Alert = { alertname: alert.metric.alertname, namespace: alert.metric.namespace, severity: alert.metric.severity as Severity, @@ -276,7 +277,23 @@ 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) { + 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 +352,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.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/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.spec.ts b/web/src/components/Incidents/utils.spec.ts index 7bef07021..901b48d89 100644 --- a/web/src/components/Incidents/utils.spec.ts +++ b/web/src/components/Incidents/utils.spec.ts @@ -1,4 +1,272 @@ -import { insertPaddingPointsForChart } from './utils'; +import { + getCurrentTime, + insertPaddingPointsForChart, + 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', () => { diff --git a/web/src/components/Incidents/utils.ts b/web/src/components/Incidents/utils.ts index a778c0ffa..29aeccf30 100644 --- a/web/src/components/Incidents/utils.ts +++ b/web/src/components/Incidents/utils.ts @@ -54,6 +54,28 @@ export const getCurrentTime = (): number => { return Math.floor(now / intervalMs) * intervalMs; }; +/** + * 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. * @@ -281,6 +303,7 @@ export const createIncidentsChartBars = (incident: Incident, dateArray: SpanDate componentList: string[]; group_id: string; nodata: boolean; + startDate: Date; fill: string; }[] = []; const getSeverityName = (value) => { @@ -296,6 +319,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), @@ -305,6 +332,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(startDate) * 1000), fill: severity === 'Critical' ? barChartColorScheme.critical @@ -362,9 +390,15 @@ 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(startDate) * 1000), x: alert.x, severity: alert.severity[0].toUpperCase() + alert.severity.slice(1), name: alert.alertname, @@ -424,7 +458,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 => { + 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 ef45c45cf..a921324cb 100644 --- a/web/src/store/actions.ts +++ b/web/src/store/actions.ts @@ -42,6 +42,8 @@ export enum ActionType { SetAlertsAreLoading = 'setAlertsAreLoading', SetIncidentsChartSelection = 'setIncidentsChartSelection', SetFilteredIncidentsData = 'setFilteredIncidentsData', + SetIncidentsTimestamps = 'setIncidentsTimestamps', + SetAlertsTimestamps = 'setAlertsTimestamps', SetIncidentPageFilterType = 'setIncidentPageFilterType', SetIncidentsLastRefreshTime = 'setIncidentsLastRefreshTime', } @@ -188,6 +190,12 @@ 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); @@ -234,6 +242,8 @@ 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 852fd2dbe..682192e2d 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -380,6 +380,16 @@ 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 9dfd6040e..345f7af29 100644 --- a/web/src/store/store.ts +++ b/web/src/store/store.ts @@ -7,6 +7,8 @@ import { DaysFilters, IncidentSeverityFilters, IncidentStateFilters, + IncidentsTimestamps, + AlertsTimestamps, } from '../components/Incidents/model'; import { Variable } from '../components/dashboards/legacy/legacy-variable-dropdowns'; @@ -33,6 +35,8 @@ export type ObserveState = { alertsData: Array; alertsTableData: Array; filteredIncidentsData: Array; + incidentsTimestamps: IncidentsTimestamps; + alertsTimestamps: AlertsTimestamps; alertsAreLoading: boolean; incidentsChartSelectedId: string; incidentsInitialState: { @@ -80,6 +84,8 @@ export const defaultObserveState: ObserveState = { alertsData: [], alertsTableData: [], filteredIncidentsData: [], + incidentsTimestamps: { minOverTime: [], lastOverTime: [] }, + alertsTimestamps: { minOverTime: [], lastOverTime: [] }, alertsAreLoading: true, incidentsChartSelectedId: '', incidentsInitialState: {