Skip to content

Commit 252a463

Browse files
riolocclaude
andcommitted
feat(incidents): add absolute start dates, severity splitting, and alert gap detection
Decouple the fetch window (always 15 days) from the display window (user-selected N days) so that firstTimestamp reflects the true incident/alert start time regardless of the currently visible time range. Split incidents when severity changes between consecutive timestamps and split alerts when gaps exceed the 300s Prometheus scrape interval. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d36f2e0 commit 252a463

12 files changed

Lines changed: 1961 additions & 231 deletions

web/src/components/Incidents/AlertsChart/AlertsChart.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,18 @@ const AlertsChart = ({ theme }: { theme: 'light' | 'dark' }) => {
7373

7474
const chartData: AlertsChartBar[][] = useMemo(() => {
7575
if (!Array.isArray(alertsData) || alertsData.length === 0) return [];
76-
return alertsData.map((alert) => createAlertsChartBars(alert));
76+
77+
// Group alerts by identity so intervals of the same alert share the same row
78+
const groupedByIdentity = new Map<string, typeof alertsData>();
79+
for (const alert of alertsData) {
80+
const key = [alert.alertname, alert.namespace, alert.severity].join('|');
81+
if (!groupedByIdentity.has(key)) {
82+
groupedByIdentity.set(key, []);
83+
}
84+
groupedByIdentity.get(key)!.push(alert);
85+
}
86+
87+
return Array.from(groupedByIdentity.values()).map((alerts) => createAlertsChartBars(alerts));
7788
}, [alertsData]);
7889

7990
useEffect(() => {
@@ -161,7 +172,7 @@ const AlertsChart = ({ theme }: { theme: 'light' | 'dark' }) => {
161172
if (datum.nodata) {
162173
return '';
163174
}
164-
const startDate = dateTimeFormatter(i18n.language).format(new Date(datum.y0));
175+
const startDate = dateTimeFormatter(i18n.language).format(datum.startDate);
165176
const endDate =
166177
datum.alertstate === 'firing'
167178
? '---'

web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
calculateIncidentsChartDomain,
3434
createIncidentsChartBars,
3535
generateDateArray,
36+
removeTrailingPaddingFromSeveritySegments,
3637
roundDateToInterval,
3738
} from '../utils';
3839
import { dateTimeFormatter, timeFormatter } from '../../console/utils/datetime';
@@ -89,10 +90,27 @@ const IncidentsChart = ({
8990
? incidentsData.filter((incident) => incident.group_id === selectedGroupId)
9091
: incidentsData;
9192

92-
// Create chart bars and sort by original x values to maintain proper order
93-
const chartBars = filteredIncidents.map((incident) =>
94-
createIncidentsChartBars(incident, dateValues),
93+
// Group incidents by group_id so split severity segments share the same row
94+
const incidentsByGroupId = new Map<string, typeof filteredIncidents>();
95+
for (const incident of filteredIncidents) {
96+
const existing = incidentsByGroupId.get(incident.group_id);
97+
if (existing) {
98+
existing.push(incident);
99+
} else {
100+
incidentsByGroupId.set(incident.group_id, [incident]);
101+
}
102+
}
103+
104+
// When an incident changes severity, its segments share the same row.
105+
// Non-last segments have trailing padding (+300s) that overlaps with the
106+
// next segment's leading padding (-300s). Remove the trailing padding
107+
// value from non-last segments to prevent visual overlap.
108+
const adjustedGroups = Array.from(incidentsByGroupId.values()).map((group) =>
109+
removeTrailingPaddingFromSeveritySegments(group),
95110
);
111+
112+
// Create chart bars per group and sort by original x values
113+
const chartBars = adjustedGroups.map((group) => createIncidentsChartBars(group, dateValues));
96114
chartBars.sort((a, b) => a[0].x - b[0].x);
97115

98116
// Reassign consecutive x values to eliminate gaps between bars
@@ -102,7 +120,6 @@ const IncidentsChart = ({
102120
useEffect(() => {
103121
setIsLoading(false);
104122
}, [incidentsData]);
105-
106123
useEffect(() => {
107124
setChartContainerHeight(chartData?.length < 5 ? 300 : chartData?.length * 60);
108125
setChartHeight(chartData?.length < 5 ? 250 : chartData?.length * 55);
@@ -176,7 +193,7 @@ const IncidentsChart = ({
176193
if (datum.nodata) {
177194
return '';
178195
}
179-
const startDate = dateTimeFormatter(i18n.language).format(new Date(datum.y0));
196+
const startDate = dateTimeFormatter(i18n.language).format(datum.startDate);
180197
const endDate = datum.firing
181198
? '---'
182199
: dateTimeFormatter(i18n.language).format(

web/src/components/Incidents/IncidentsDetailsRowTable.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@ const IncidentsDetailsRowTable = ({ alerts }: IncidentsDetailsRowTableProps) =>
2424
const sortedAndMappedAlerts = useMemo(() => {
2525
if (alerts && alerts.length > 0) {
2626
return [...alerts]
27-
.sort(
28-
(a: IncidentsDetailsAlert, b: IncidentsDetailsAlert) =>
29-
a.alertsStartFiring - b.alertsStartFiring,
30-
)
27+
.sort((a: IncidentsDetailsAlert, b: IncidentsDetailsAlert) => {
28+
const aStart = a.firstTimestamp > 0 ? a.firstTimestamp : a.alertsStartFiring;
29+
const bStart = b.firstTimestamp > 0 ? b.firstTimestamp : b.alertsStartFiring;
30+
return aStart - bStart;
31+
})
3132
.map((alertDetails: IncidentsDetailsAlert, rowIndex) => {
3233
return (
3334
<Tr key={rowIndex}>
@@ -45,13 +46,21 @@ const IncidentsDetailsRowTable = ({ alerts }: IncidentsDetailsRowTableProps) =>
4546
<SeverityBadge severity={alertDetails.severity} />
4647
</Td>
4748
<Td dataLabel="expanded-details-firingstart">
48-
<Timestamp timestamp={String(alertDetails.alertsStartFiring * 1000)} />
49+
<Timestamp
50+
timestamp={new Date(
51+
(alertDetails.firstTimestamp > 0
52+
? alertDetails.firstTimestamp
53+
: alertDetails.alertsStartFiring) * 1000,
54+
).toISOString()}
55+
/>
4956
</Td>
5057
<Td dataLabel="expanded-details-firingend">
5158
{!alertDetails.resolved ? (
5259
'---'
5360
) : (
54-
<Timestamp timestamp={String(alertDetails.alertsEndFiring * 1000)} />
61+
<Timestamp
62+
timestamp={new Date(alertDetails.alertsEndFiring * 1000).toISOString()}
63+
/>
5564
)}
5665
</Td>
5766
<Td dataLabel="expanded-details-alertstate">

web/src/components/Incidents/IncidentsPage.tsx

Lines changed: 56 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
onIncidentFiltersSelect,
4040
parseUrlParams,
4141
updateBrowserUrl,
42+
DAY_MS,
4243
} from './utils';
4344
import { groupAlertsForTable, convertToAlerts } from './processAlerts';
4445
import { CompressArrowsAltIcon, CompressIcon, FilterIcon } from '@patternfly/react-icons';
@@ -231,52 +232,54 @@ const IncidentsPage = () => {
231232
}, [incidentsActiveFilters.days]);
232233

233234
useEffect(() => {
234-
(async () => {
235-
const currentTime = incidentsLastRefreshTime;
236-
Promise.all(
237-
timeRanges.map(async (range) => {
238-
const response = await fetchDataForIncidentsAndAlerts(
239-
safeFetch,
240-
range,
241-
createAlertsQuery(incidentForAlertProcessing),
242-
);
243-
return response.data.result;
244-
}),
245-
)
246-
.then((results) => {
247-
const prometheusResults = results.flat();
248-
const alerts = convertToAlerts(
249-
prometheusResults,
250-
incidentForAlertProcessing,
251-
currentTime,
252-
);
235+
if (incidentForAlertProcessing.length === 0) {
236+
return;
237+
}
238+
239+
const currentTime = incidentsLastRefreshTime;
240+
241+
// Always fetch 15 days of alert data so firstTimestamp is computed from full history
242+
const fetchTimeRanges = getIncidentsTimeRanges(15 * DAY_MS, currentTime);
243+
244+
Promise.all(
245+
fetchTimeRanges.map(async (range) => {
246+
const response = await fetchDataForIncidentsAndAlerts(
247+
safeFetch,
248+
range,
249+
createAlertsQuery(incidentForAlertProcessing),
250+
);
251+
return response.data.result;
252+
}),
253+
)
254+
.then((alertsResults) => {
255+
const prometheusResults = alertsResults.flat();
256+
const alerts = convertToAlerts(
257+
prometheusResults,
258+
incidentForAlertProcessing,
259+
currentTime,
260+
daysSpan,
261+
);
262+
dispatch(
263+
setAlertsData({
264+
alertsData: alerts,
265+
}),
266+
);
267+
if (rules && alerts) {
253268
dispatch(
254-
setAlertsData({
255-
alertsData: alerts,
269+
setAlertsTableData({
270+
alertsTableData: groupAlertsForTable(alerts, rules),
256271
}),
257272
);
258-
if (rules && alerts) {
259-
dispatch(
260-
setAlertsTableData({
261-
alertsTableData: groupAlertsForTable(alerts, rules),
262-
}),
263-
);
264-
}
265-
if (!isEmpty(filteredData)) {
266-
dispatch(setAlertsAreLoading({ alertsAreLoading: false }));
267-
} else {
268-
dispatch(setAlertsAreLoading({ alertsAreLoading: true }));
269-
}
270-
})
271-
.catch((err) => {
272-
// eslint-disable-next-line no-console
273-
console.log(err);
274-
275-
dispatch(setAlertsAreLoading({ alertsAreLoading: false }));
276-
setLoadError(err);
277-
});
278-
})();
279-
}, [incidentForAlertProcessing]);
273+
}
274+
dispatch(setAlertsAreLoading({ alertsAreLoading: false }));
275+
})
276+
.catch((err) => {
277+
// eslint-disable-next-line no-console
278+
console.error(err);
279+
dispatch(setAlertsAreLoading({ alertsAreLoading: false }));
280+
setLoadError(err);
281+
});
282+
}, [incidentForAlertProcessing, rules, daysSpan]);
280283

281284
useEffect(() => {
282285
if (!isInitialized) return;
@@ -293,30 +296,34 @@ const IncidentsPage = () => {
293296
? incidentsActiveFilters.days[0].split(' ')[0] + 'd'
294297
: '',
295298
);
296-
const calculatedTimeRanges = getIncidentsTimeRanges(daysDuration, currentTime);
297299

298300
const isGroupSelected = !!selectedGroupId;
299301
const incidentsQuery = isGroupSelected
300302
? `cluster_health_components_map{group_id='${selectedGroupId}'}`
301303
: 'cluster_health_components_map';
302304

305+
// Always fetch 15 days of data so firstTimestamp is computed from full history
306+
const fetchTimeRanges = getIncidentsTimeRanges(15 * DAY_MS, currentTime);
307+
303308
Promise.all(
304-
calculatedTimeRanges.map(async (range) => {
309+
fetchTimeRanges.map(async (range) => {
305310
const response = await fetchDataForIncidentsAndAlerts(safeFetch, range, incidentsQuery);
306311
return response.data.result;
307312
}),
308313
)
309-
.then((results) => {
310-
const prometheusResults = results.flat();
311-
const incidents = convertToIncidents(prometheusResults, currentTime);
314+
.then((incidentsResults) => {
315+
const prometheusResults = incidentsResults.flat();
316+
const incidents = convertToIncidents(prometheusResults, currentTime, daysDuration);
312317

313318
// Update the raw, unfiltered incidents state
314319
dispatch(setIncidents({ incidents }));
315320

321+
const filteredData = filterIncident(incidentsActiveFilters, incidents);
322+
316323
// Filter the incidents and dispatch
317324
dispatch(
318325
setFilteredIncidentsData({
319-
filteredIncidentsData: filterIncident(incidentsActiveFilters, incidents),
326+
filteredIncidentsData: filteredData,
320327
}),
321328
);
322329

web/src/components/Incidents/IncidentsTable.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export const IncidentsTable = () => {
9595
if (!alert.alertsExpandedRowData || alert.alertsExpandedRowData.length === 0) {
9696
return 0;
9797
}
98-
return Math.min(...alert.alertsExpandedRowData.map((alertData) => alertData.alertsStartFiring));
98+
return Math.min(...alert.alertsExpandedRowData.map((alertData) => alertData.firstTimestamp));
9999
};
100100

101101
if (isEmpty(alertsTableData) || alertsAreLoading || isEmpty(incidentsActiveFilters.groupId)) {
@@ -180,7 +180,9 @@ export const IncidentsTable = () => {
180180
)}
181181
</Td>
182182
<Td dataLabel={columnNames.startDate}>
183-
<Timestamp timestamp={String(getMinStartDate(alert) * 1000)} />
183+
<Timestamp
184+
timestamp={new Date(getMinStartDate(alert) * 1000).toISOString()}
185+
/>
184186
</Td>
185187
<Td
186188
dataLabel={columnNames.state}

web/src/components/Incidents/model.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export type Timestamps = [number, string];
44

55
export type SpanDates = Array<number>;
66

7-
export type AlertsIntervalsArray = [number, number, 'data' | 'nodata'];
7+
export type AlertsIntervalsArray = [number, number, 'data' | 'nodata', number?];
88

99
export type Incident = {
1010
component: string;
@@ -15,10 +15,12 @@ export type Incident = {
1515
src_severity: string;
1616
src_alertname: string;
1717
src_namespace: string;
18+
severity: any;
1819
silenced: boolean;
1920
x: number;
2021
values: Array<Timestamps>;
2122
metric: Metric;
23+
firstTimestamp: number;
2224
};
2325

2426
// Define the interface for Metric
@@ -47,6 +49,7 @@ export type Alert = {
4749
severity: Severity;
4850
silenced: boolean;
4951
x: number;
52+
firstTimestamp: number;
5053
values: Array<Timestamps>;
5154
alertsExpandedRowData?: Array<Alert>;
5255
};
@@ -101,6 +104,7 @@ export type IncidentsDetailsAlert = {
101104
resolved: boolean;
102105
severity: Severity;
103106
x: number;
107+
firstTimestamp: number;
104108
values: Array<Timestamps>;
105109
silenced: boolean;
106110
rule: {

0 commit comments

Comments
 (0)