feat: add allAlertsResolvedAt and allIncidentsResolvedAt fields for resolve delay calculations

This commit is contained in:
Nawaz Dhandala
2026-02-10 13:08:42 +00:00
parent e80e22b1fa
commit ae6e49da8f
10 changed files with 169 additions and 69 deletions

View File

@@ -543,6 +543,31 @@ export default class AlertEpisode extends BaseModel {
})
public resolvedAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisode,
Permission.ReadAllProjectResources,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.Date,
title: "All Alerts Resolved At",
description:
"When all alerts in this episode were first detected as resolved. Used for resolve delay calculation.",
})
@Column({
type: ColumnType.Date,
nullable: true,
unique: false,
})
public allAlertsResolvedAt?: Date = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View File

@@ -542,6 +542,31 @@ export default class IncidentEpisode extends BaseModel {
})
public resolvedAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisode,
Permission.ReadAllProjectResources,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.Date,
title: "All Incidents Resolved At",
description:
"When all incidents in this episode were first detected as resolved. Used for resolve delay calculation.",
})
@Column({
type: ColumnType.Date,
nullable: true,
unique: false,
})
public allIncidentsResolvedAt?: Date = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View File

@@ -94,26 +94,18 @@ export class Service extends DatabaseService<Model> {
});
// Update episode's alertCount and lastAlertAddedAt
Promise.resolve()
.then(async () => {
try {
await AlertEpisodeService.updateAlertCount(
createdItem.alertEpisodeId!,
);
await AlertEpisodeService.updateLastAlertAddedAt(
createdItem.alertEpisodeId!,
);
} catch (error) {
logger.error(
`Error updating episode counts in AlertEpisodeMemberService.onCreateSuccess: ${error}`,
);
}
})
.catch((error: Error) => {
logger.error(
`Critical error in AlertEpisodeMemberService.onCreateSuccess: ${error}`,
);
});
try {
await AlertEpisodeService.updateAlertCount(
createdItem.alertEpisodeId!,
);
await AlertEpisodeService.updateLastAlertAddedAt(
createdItem.alertEpisodeId!,
);
} catch (error) {
logger.error(
`Error updating episode counts in AlertEpisodeMemberService.onCreateSuccess: ${error}`,
);
}
// Get alert details for feed
const alert: Alert | null = await AlertService.findOneById({

View File

@@ -815,11 +815,12 @@ export class Service extends DatabaseService<Model> {
},
});
// Clear resolved timestamp
// Clear resolved timestamp and allAlertsResolvedAt
await this.updateOneById({
id: episodeId,
data: {
resolvedAt: undefined as any,
allAlertsResolvedAt: undefined as any,
},
props: {
isRoot: true,

View File

@@ -688,6 +688,7 @@ class AlertGroupingEngineServiceClass {
newEpisode.alertGroupingRuleId = rule.id!;
newEpisode.groupingKey = groupingKey;
newEpisode.isManuallyCreated = false;
newEpisode.lastAlertAddedAt = OneUptimeDate.getCurrentDate();
// Set severity from alert
if (alert.alertSeverityId) {

View File

@@ -96,26 +96,18 @@ export class Service extends DatabaseService<Model> {
});
// Update episode's incidentCount and lastIncidentAddedAt
Promise.resolve()
.then(async () => {
try {
await IncidentEpisodeService.updateIncidentCount(
createdItem.incidentEpisodeId!,
);
await IncidentEpisodeService.updateLastIncidentAddedAt(
createdItem.incidentEpisodeId!,
);
} catch (error) {
logger.error(
`Error updating episode counts in IncidentEpisodeMemberService.onCreateSuccess: ${error}`,
);
}
})
.catch((error: Error) => {
logger.error(
`Critical error in IncidentEpisodeMemberService.onCreateSuccess: ${error}`,
);
});
try {
await IncidentEpisodeService.updateIncidentCount(
createdItem.incidentEpisodeId!,
);
await IncidentEpisodeService.updateLastIncidentAddedAt(
createdItem.incidentEpisodeId!,
);
} catch (error) {
logger.error(
`Error updating episode counts in IncidentEpisodeMemberService.onCreateSuccess: ${error}`,
);
}
// Get incident details for feed
const incident: Incident | null = await IncidentService.findOneById({

View File

@@ -609,6 +609,17 @@ export class Service extends DatabaseService<Model> {
},
cascadeToIncidents: cascadeToIncidents,
});
// Clear allIncidentsResolvedAt when episode is reopened
await this.updateOneById({
id: episodeId,
data: {
allIncidentsResolvedAt: undefined as any,
},
props: {
isRoot: true,
},
});
}
@CaptureSpan()

View File

@@ -757,6 +757,7 @@ class IncidentGroupingEngineServiceClass {
newEpisode.incidentGroupingRuleId = rule.id!;
newEpisode.groupingKey = groupingKey;
newEpisode.isManuallyCreated = false;
newEpisode.lastIncidentAddedAt = OneUptimeDate.getCurrentDate();
// Set severity from incident
if (incident.incidentSeverityId) {

View File

@@ -38,6 +38,7 @@ RunCron(
projectId: true,
alertGroupingRuleId: true,
lastAlertAddedAt: true,
allAlertsResolvedAt: true,
},
props: {
isRoot: true,
@@ -134,7 +135,6 @@ const checkAndResolveEpisode: CheckAndResolveEpisodeFunction = async (
// Check if all alerts are in resolved state or higher
let allResolved: boolean = true;
let lastResolvedAt: Date | null = null;
for (const alertId of alertIds) {
const alert: Alert | null = await AlertService.findOneById({
@@ -143,7 +143,6 @@ const checkAndResolveEpisode: CheckAndResolveEpisodeFunction = async (
currentAlertState: {
order: true,
},
updatedAt: true,
},
props: {
isRoot: true,
@@ -160,31 +159,58 @@ const checkAndResolveEpisode: CheckAndResolveEpisodeFunction = async (
allResolved = false;
break;
}
// Track the latest resolved time among alerts
if (alert.updatedAt) {
if (!lastResolvedAt || alert.updatedAt > lastResolvedAt) {
lastResolvedAt = alert.updatedAt;
}
}
}
if (!allResolved) {
// If any alert is unresolved, clear allAlertsResolvedAt
if (episode.allAlertsResolvedAt) {
await AlertEpisodeService.updateOneById({
id: episode.id,
data: {
allAlertsResolvedAt: undefined as any,
},
props: {
isRoot: true,
},
});
}
logger.debug(
`AlertEpisode:AutoResolve - Episode ${episode.id} has unresolved alerts`,
);
return;
}
// All alerts are resolved. Check if resolve delay has passed (only if enabled)
if (enableResolveDelay && resolveDelayMinutes > 0 && lastResolvedAt) {
const timeSinceLastResolved: number =
// All alerts are resolved. Set allAlertsResolvedAt if not already set.
if (!episode.allAlertsResolvedAt) {
await AlertEpisodeService.updateOneById({
id: episode.id,
data: {
allAlertsResolvedAt: OneUptimeDate.getCurrentDate(),
},
props: {
isRoot: true,
},
});
// If resolve delay is enabled, return and wait for the delay
if (enableResolveDelay && resolveDelayMinutes > 0) {
logger.debug(
`AlertEpisode:AutoResolve - Episode ${episode.id} all alerts resolved, starting resolve delay (${resolveDelayMinutes} minutes)`,
);
return;
}
}
// Check if resolve delay has passed (only if enabled)
if (enableResolveDelay && resolveDelayMinutes > 0 && episode.allAlertsResolvedAt) {
const timeSinceAllResolved: number =
OneUptimeDate.getDifferenceInMinutes(
lastResolvedAt,
episode.allAlertsResolvedAt,
OneUptimeDate.getCurrentDate(),
);
if (timeSinceLastResolved < resolveDelayMinutes) {
if (timeSinceAllResolved < resolveDelayMinutes) {
logger.debug(
`AlertEpisode:AutoResolve - Episode ${episode.id} waiting for resolve delay (${resolveDelayMinutes} minutes)`,
);

View File

@@ -38,6 +38,7 @@ RunCron(
projectId: true,
incidentGroupingRuleId: true,
lastIncidentAddedAt: true,
allIncidentsResolvedAt: true,
},
props: {
isRoot: true,
@@ -137,7 +138,6 @@ const checkAndResolveEpisode: CheckAndResolveEpisodeFunction = async (
// Check if all incidents are in resolved state or higher
let allResolved: boolean = true;
let lastResolvedAt: Date | null = null;
for (const incidentId of incidentIds) {
const incident: Incident | null = await IncidentService.findOneById({
@@ -146,7 +146,6 @@ const checkAndResolveEpisode: CheckAndResolveEpisodeFunction = async (
currentIncidentState: {
order: true,
},
updatedAt: true,
},
props: {
isRoot: true,
@@ -163,31 +162,58 @@ const checkAndResolveEpisode: CheckAndResolveEpisodeFunction = async (
allResolved = false;
break;
}
// Track the latest resolved time among incidents
if (incident.updatedAt) {
if (!lastResolvedAt || incident.updatedAt > lastResolvedAt) {
lastResolvedAt = incident.updatedAt;
}
}
}
if (!allResolved) {
// If any incident is unresolved, clear allIncidentsResolvedAt
if (episode.allIncidentsResolvedAt) {
await IncidentEpisodeService.updateOneById({
id: episode.id,
data: {
allIncidentsResolvedAt: undefined as any,
},
props: {
isRoot: true,
},
});
}
logger.debug(
`IncidentEpisode:AutoResolve - Episode ${episode.id} has unresolved incidents`,
);
return;
}
// All incidents are resolved. Check if resolve delay has passed (only if enabled)
if (enableResolveDelay && resolveDelayMinutes > 0 && lastResolvedAt) {
const timeSinceLastResolved: number =
// All incidents are resolved. Set allIncidentsResolvedAt if not already set.
if (!episode.allIncidentsResolvedAt) {
await IncidentEpisodeService.updateOneById({
id: episode.id,
data: {
allIncidentsResolvedAt: OneUptimeDate.getCurrentDate(),
},
props: {
isRoot: true,
},
});
// If resolve delay is enabled, return and wait for the delay
if (enableResolveDelay && resolveDelayMinutes > 0) {
logger.debug(
`IncidentEpisode:AutoResolve - Episode ${episode.id} all incidents resolved, starting resolve delay (${resolveDelayMinutes} minutes)`,
);
return;
}
}
// Check if resolve delay has passed (only if enabled)
if (enableResolveDelay && resolveDelayMinutes > 0 && episode.allIncidentsResolvedAt) {
const timeSinceAllResolved: number =
OneUptimeDate.getDifferenceInMinutes(
lastResolvedAt,
episode.allIncidentsResolvedAt,
OneUptimeDate.getCurrentDate(),
);
if (timeSinceLastResolved < resolveDelayMinutes) {
if (timeSinceAllResolved < resolveDelayMinutes) {
logger.debug(
`IncidentEpisode:AutoResolve - Episode ${episode.id} waiting for resolve delay (${resolveDelayMinutes} minutes)`,
);