mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
feat: Implement Incident SLA Management System
- Added IncidentSlaStatus enum to define SLA status values. - Created IncidentSlaRulesPage component for managing SLA rules, including documentation and configuration options. - Developed IncidentViewSla component to display SLA status and deadlines for incidents. - Implemented CheckSlaBreaches job to monitor SLA breaches and send notifications. - Created SendNoteReminders job to automate internal and public note reminders based on SLA rules.
This commit is contained in:
674
Common/Models/DatabaseModels/IncidentSla.ts
Normal file
674
Common/Models/DatabaseModels/IncidentSla.ts
Normal file
@@ -0,0 +1,674 @@
|
||||
import Incident from "./Incident";
|
||||
import IncidentSlaRule from "./IncidentSlaRule";
|
||||
import Project from "./Project";
|
||||
import User from "./User";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
import Route from "../../Types/API/Route";
|
||||
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
|
||||
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
|
||||
import ColumnType from "../../Types/Database/ColumnType";
|
||||
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
|
||||
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
|
||||
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
|
||||
import TableColumn from "../../Types/Database/TableColumn";
|
||||
import TableColumnType from "../../Types/Database/TableColumnType";
|
||||
import TableMetadata from "../../Types/Database/TableMetadata";
|
||||
import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import IncidentSlaStatus from "../../Types/Incident/IncidentSlaStatus";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
@EnableDocumentation()
|
||||
@TenantColumn("projectId")
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
delete: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.DeleteIncidentSla,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSla,
|
||||
],
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/incident-sla"))
|
||||
@Entity({
|
||||
name: "IncidentSla",
|
||||
})
|
||||
@EnableWorkflow({
|
||||
create: true,
|
||||
delete: true,
|
||||
update: true,
|
||||
read: true,
|
||||
})
|
||||
@TableMetadata({
|
||||
tableName: "IncidentSla",
|
||||
singularName: "Incident SLA",
|
||||
pluralName: "Incident SLAs",
|
||||
icon: IconProp.Clock,
|
||||
tableDescription: "Track SLA status and deadlines for incidents",
|
||||
})
|
||||
export default class IncidentSla extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "projectId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: Project,
|
||||
title: "Project",
|
||||
description: "Relation to Project Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return Project;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "projectId" })
|
||||
public project?: Project = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Project ID",
|
||||
description: "ID of your OneUptime Project in which this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public projectId?: ObjectID = undefined;
|
||||
|
||||
// Incident Relation
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "incidentId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: Incident,
|
||||
title: "Incident",
|
||||
description: "The incident this SLA record is tracking",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return Incident;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: false,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "incidentId" })
|
||||
public incident?: Incident = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Incident ID",
|
||||
description: "ID of the incident this SLA record is tracking",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public incidentId?: ObjectID = undefined;
|
||||
|
||||
// SLA Rule Relation
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "incidentSlaRuleId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: IncidentSlaRule,
|
||||
title: "SLA Rule",
|
||||
description: "The SLA rule that was applied to this incident",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return IncidentSlaRule;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: false,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "incidentSlaRuleId" })
|
||||
public incidentSlaRule?: IncidentSlaRule = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "SLA Rule ID",
|
||||
description: "ID of the SLA rule that was applied to this incident",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public incidentSlaRuleId?: ObjectID = undefined;
|
||||
|
||||
// SLA Deadlines
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSla,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.Date,
|
||||
required: false,
|
||||
title: "Response Deadline",
|
||||
description:
|
||||
"The deadline by which the incident must be acknowledged to meet the SLA",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Date,
|
||||
nullable: true,
|
||||
})
|
||||
public responseDeadline?: Date = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSla,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.Date,
|
||||
required: false,
|
||||
title: "Resolution Deadline",
|
||||
description:
|
||||
"The deadline by which the incident must be resolved to meet the SLA",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Date,
|
||||
nullable: true,
|
||||
})
|
||||
public resolutionDeadline?: Date = undefined;
|
||||
|
||||
// SLA Status
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSla,
|
||||
],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ShortText,
|
||||
required: true,
|
||||
title: "Status",
|
||||
description: "Current SLA status (On Track, At Risk, Breached, Met)",
|
||||
defaultValue: IncidentSlaStatus.OnTrack,
|
||||
isDefaultValueColumn: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ShortText,
|
||||
nullable: false,
|
||||
default: IncidentSlaStatus.OnTrack,
|
||||
})
|
||||
public status?: IncidentSlaStatus = undefined;
|
||||
|
||||
// Actual Response/Resolution Times
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSla,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.Date,
|
||||
required: false,
|
||||
title: "Responded At",
|
||||
description: "The actual time when the incident was acknowledged",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Date,
|
||||
nullable: true,
|
||||
})
|
||||
public respondedAt?: Date = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSla,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.Date,
|
||||
required: false,
|
||||
title: "Resolved At",
|
||||
description: "The actual time when the incident was resolved",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Date,
|
||||
nullable: true,
|
||||
})
|
||||
public resolvedAt?: Date = undefined;
|
||||
|
||||
// Reminder Tracking
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSla,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.Date,
|
||||
required: false,
|
||||
title: "Last Internal Note Reminder Sent At",
|
||||
description: "The last time an internal note reminder was sent",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Date,
|
||||
nullable: true,
|
||||
})
|
||||
public lastInternalNoteReminderSentAt?: Date = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSla,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.Date,
|
||||
required: false,
|
||||
title: "Last Public Note Reminder Sent At",
|
||||
description: "The last time a public note reminder was sent",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Date,
|
||||
nullable: true,
|
||||
})
|
||||
public lastPublicNoteReminderSentAt?: Date = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSla,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.Date,
|
||||
required: false,
|
||||
title: "Breach Notification Sent At",
|
||||
description: "The time when breach notification was sent to incident owners",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Date,
|
||||
nullable: true,
|
||||
})
|
||||
public breachNotificationSentAt?: Date = undefined;
|
||||
|
||||
// SLA Start Time (when SLA tracking began)
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.Date,
|
||||
required: true,
|
||||
title: "SLA Started At",
|
||||
description:
|
||||
"The time when SLA tracking started (usually the incident declaredAt time)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Date,
|
||||
nullable: false,
|
||||
})
|
||||
public slaStartedAt?: Date = undefined;
|
||||
|
||||
// Created By / Deleted By User Relations
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "createdByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "Created by User",
|
||||
description:
|
||||
"Relation to User who created this object (if this object was created by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "createdByUserId" })
|
||||
public createdByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSla,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSla,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Created by User ID",
|
||||
description:
|
||||
"User ID who created this object (if this object was created by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public createdByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "deletedByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
title: "Deleted by User",
|
||||
modelType: User,
|
||||
description:
|
||||
"Relation to User who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
cascade: false,
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "deletedByUserId" })
|
||||
public deletedByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Deleted by User ID",
|
||||
description:
|
||||
"User ID who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public deletedByUserId?: ObjectID = undefined;
|
||||
}
|
||||
883
Common/Models/DatabaseModels/IncidentSlaRule.ts
Normal file
883
Common/Models/DatabaseModels/IncidentSlaRule.ts
Normal file
@@ -0,0 +1,883 @@
|
||||
import IncidentSeverity from "./IncidentSeverity";
|
||||
import Label from "./Label";
|
||||
import Monitor from "./Monitor";
|
||||
import Project from "./Project";
|
||||
import User from "./User";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
import Route from "../../Types/API/Route";
|
||||
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
|
||||
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
|
||||
import ColumnLength from "../../Types/Database/ColumnLength";
|
||||
import ColumnType from "../../Types/Database/ColumnType";
|
||||
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
|
||||
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
|
||||
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
|
||||
import TableColumn from "../../Types/Database/TableColumn";
|
||||
import TableColumnType from "../../Types/Database/TableColumnType";
|
||||
import TableMetadata from "../../Types/Database/TableMetadata";
|
||||
import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
} from "typeorm";
|
||||
|
||||
@EnableDocumentation()
|
||||
@TenantColumn("projectId")
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
delete: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.DeleteIncidentSlaRule,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/incident-sla-rule"))
|
||||
@Entity({
|
||||
name: "IncidentSlaRule",
|
||||
})
|
||||
@EnableWorkflow({
|
||||
create: true,
|
||||
delete: true,
|
||||
update: true,
|
||||
read: true,
|
||||
})
|
||||
@TableMetadata({
|
||||
tableName: "IncidentSlaRule",
|
||||
singularName: "Incident SLA Rule",
|
||||
pluralName: "Incident SLA Rules",
|
||||
icon: IconProp.Clock,
|
||||
tableDescription:
|
||||
"Configure SLA rules to define response and resolution time targets for incidents",
|
||||
})
|
||||
export default class IncidentSlaRule extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "projectId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: Project,
|
||||
title: "Project",
|
||||
description: "Relation to Project Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return Project;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "projectId" })
|
||||
public project?: Project = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Project ID",
|
||||
description: "ID of your OneUptime Project in which this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public projectId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.ShortText,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Name",
|
||||
description: "Name of this SLA rule",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.ShortText,
|
||||
length: ColumnLength.ShortText,
|
||||
})
|
||||
public name?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.LongText,
|
||||
title: "Description",
|
||||
description: "Description of this SLA rule",
|
||||
})
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: ColumnType.LongText,
|
||||
length: ColumnLength.LongText,
|
||||
})
|
||||
public description?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.Number,
|
||||
title: "Order",
|
||||
description:
|
||||
"Order/priority of this rule. Rules are evaluated in order (lowest first). First matching rule wins.",
|
||||
defaultValue: 1,
|
||||
isDefaultValueColumn: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Number,
|
||||
nullable: false,
|
||||
default: 1,
|
||||
})
|
||||
public order?: number = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Is Enabled",
|
||||
description: "Whether this SLA rule is enabled",
|
||||
defaultValue: true,
|
||||
isDefaultValueColumn: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
nullable: false,
|
||||
default: true,
|
||||
})
|
||||
public isEnabled?: boolean = undefined;
|
||||
|
||||
// SLA Targets
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.Number,
|
||||
title: "Response Time (Minutes)",
|
||||
description:
|
||||
"Target response time in minutes. This is the maximum time allowed before the incident must be acknowledged.",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Number,
|
||||
nullable: true,
|
||||
})
|
||||
public responseTimeInMinutes?: number = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.Number,
|
||||
title: "Resolution Time (Minutes)",
|
||||
description:
|
||||
"Target resolution time in minutes. This is the maximum time allowed before the incident must be resolved.",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Number,
|
||||
nullable: true,
|
||||
})
|
||||
public resolutionTimeInMinutes?: number = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.Number,
|
||||
title: "At-Risk Threshold (%)",
|
||||
description:
|
||||
"Percentage of the deadline at which the SLA status changes to At Risk. For example, 80 means the status becomes At Risk when 80% of the time has elapsed.",
|
||||
defaultValue: 80,
|
||||
isDefaultValueColumn: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Number,
|
||||
nullable: false,
|
||||
default: 80,
|
||||
})
|
||||
public atRiskThresholdInPercentage?: number = undefined;
|
||||
|
||||
// Note Reminder Configuration
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.Number,
|
||||
title: "Internal Note Reminder Interval (Minutes)",
|
||||
description:
|
||||
"Interval in minutes between automatic internal note reminders. Leave empty to disable internal note reminders.",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Number,
|
||||
nullable: true,
|
||||
})
|
||||
public internalNoteReminderIntervalInMinutes?: number = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.Number,
|
||||
title: "Public Note Reminder Interval (Minutes)",
|
||||
description:
|
||||
"Interval in minutes between automatic public note reminders. Leave empty to disable public note reminders.",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Number,
|
||||
nullable: true,
|
||||
})
|
||||
public publicNoteReminderIntervalInMinutes?: number = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.Markdown,
|
||||
title: "Internal Note Reminder Template",
|
||||
description:
|
||||
"Markdown template for internal note reminders. Supports variables: {{incidentTitle}}, {{incidentNumber}}, {{elapsedTime}}, {{responseDeadline}}, {{resolutionDeadline}}, {{slaStatus}}, {{timeToResponseDeadline}}, {{timeToResolutionDeadline}}",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Markdown,
|
||||
nullable: true,
|
||||
})
|
||||
public internalNoteReminderTemplate?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.Markdown,
|
||||
title: "Public Note Reminder Template",
|
||||
description:
|
||||
"Markdown template for public note reminders. Supports variables: {{incidentTitle}}, {{incidentNumber}}, {{elapsedTime}}, {{responseDeadline}}, {{resolutionDeadline}}, {{slaStatus}}, {{timeToResponseDeadline}}, {{timeToResolutionDeadline}}",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Markdown,
|
||||
nullable: true,
|
||||
})
|
||||
public publicNoteReminderTemplate?: string = undefined;
|
||||
|
||||
// Match Criteria - Monitors
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.EntityArray,
|
||||
modelType: Monitor,
|
||||
title: "Monitors",
|
||||
description:
|
||||
"Only apply this SLA rule to incidents affecting these monitors. Leave empty to match incidents from any monitor.",
|
||||
})
|
||||
@ManyToMany(
|
||||
() => {
|
||||
return Monitor;
|
||||
},
|
||||
{ eager: false },
|
||||
)
|
||||
@JoinTable({
|
||||
name: "IncidentSlaRuleMonitor",
|
||||
inverseJoinColumn: {
|
||||
name: "monitorId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
joinColumn: {
|
||||
name: "incidentSlaRuleId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
})
|
||||
public monitors?: Array<Monitor> = undefined;
|
||||
|
||||
// Match Criteria - Incident Severities
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.EntityArray,
|
||||
modelType: IncidentSeverity,
|
||||
title: "Incident Severities",
|
||||
description:
|
||||
"Only apply this SLA rule to incidents with these severities. Leave empty to match incidents of any severity.",
|
||||
})
|
||||
@ManyToMany(
|
||||
() => {
|
||||
return IncidentSeverity;
|
||||
},
|
||||
{ eager: false },
|
||||
)
|
||||
@JoinTable({
|
||||
name: "IncidentSlaRuleIncidentSeverity",
|
||||
inverseJoinColumn: {
|
||||
name: "incidentSeverityId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
joinColumn: {
|
||||
name: "incidentSlaRuleId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
})
|
||||
public incidentSeverities?: Array<IncidentSeverity> = undefined;
|
||||
|
||||
// Match Criteria - Incident Labels
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.EntityArray,
|
||||
modelType: Label,
|
||||
title: "Incident Labels",
|
||||
description:
|
||||
"Only apply this SLA rule to incidents that have at least one of these labels. Leave empty to match incidents regardless of labels.",
|
||||
})
|
||||
@ManyToMany(
|
||||
() => {
|
||||
return Label;
|
||||
},
|
||||
{ eager: false },
|
||||
)
|
||||
@JoinTable({
|
||||
name: "IncidentSlaRuleIncidentLabel",
|
||||
inverseJoinColumn: {
|
||||
name: "labelId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
joinColumn: {
|
||||
name: "incidentSlaRuleId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
})
|
||||
public incidentLabels?: Array<Label> = undefined;
|
||||
|
||||
// Match Criteria - Monitor Labels
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.EntityArray,
|
||||
modelType: Label,
|
||||
title: "Monitor Labels",
|
||||
description:
|
||||
"Only apply this SLA rule to incidents from monitors that have at least one of these labels. Leave empty to match incidents regardless of monitor labels.",
|
||||
})
|
||||
@ManyToMany(
|
||||
() => {
|
||||
return Label;
|
||||
},
|
||||
{ eager: false },
|
||||
)
|
||||
@JoinTable({
|
||||
name: "IncidentSlaRuleMonitorLabel",
|
||||
inverseJoinColumn: {
|
||||
name: "labelId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
joinColumn: {
|
||||
name: "incidentSlaRuleId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
})
|
||||
public monitorLabels?: Array<Label> = undefined;
|
||||
|
||||
// Match Criteria - Pattern Matching
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.LongText,
|
||||
title: "Incident Title Pattern",
|
||||
description:
|
||||
"Regular expression pattern to match incident titles. Leave empty to match any title. Example: 'CPU.*high' matches titles containing 'CPU' followed by 'high'.",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.LongText,
|
||||
nullable: true,
|
||||
length: ColumnLength.LongText,
|
||||
})
|
||||
public incidentTitlePattern?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditIncidentSlaRule,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.LongText,
|
||||
title: "Incident Description Pattern",
|
||||
description:
|
||||
"Regular expression pattern to match incident descriptions. Leave empty to match any description.",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.LongText,
|
||||
nullable: true,
|
||||
length: ColumnLength.LongText,
|
||||
})
|
||||
public incidentDescriptionPattern?: string = undefined;
|
||||
|
||||
// Created By / Deleted By User Relations
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "createdByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "Created by User",
|
||||
description:
|
||||
"Relation to User who created this object (if this object was created by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "createdByUserId" })
|
||||
public createdByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateIncidentSlaRule,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentSlaRule,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Created by User ID",
|
||||
description:
|
||||
"User ID who created this object (if this object was created by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public createdByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "deletedByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
title: "Deleted by User",
|
||||
modelType: User,
|
||||
description:
|
||||
"Relation to User who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
cascade: false,
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "deletedByUserId" })
|
||||
public deletedByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Deleted by User ID",
|
||||
description:
|
||||
"User ID who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public deletedByUserId?: ObjectID = undefined;
|
||||
}
|
||||
@@ -211,6 +211,8 @@ import IncidentEpisodeOwnerTeam from "./IncidentEpisodeOwnerTeam";
|
||||
import IncidentEpisodeInternalNote from "./IncidentEpisodeInternalNote";
|
||||
import IncidentEpisodeFeed from "./IncidentEpisodeFeed";
|
||||
import IncidentGroupingRule from "./IncidentGroupingRule";
|
||||
import IncidentSlaRule from "./IncidentSlaRule";
|
||||
import IncidentSla from "./IncidentSla";
|
||||
|
||||
import TableView from "./TableView";
|
||||
import Dashboard from "./Dashboard";
|
||||
@@ -317,6 +319,8 @@ const AllModelTypes: Array<{
|
||||
IncidentEpisodeInternalNote,
|
||||
IncidentEpisodeFeed,
|
||||
IncidentGroupingRule,
|
||||
IncidentSlaRule,
|
||||
IncidentSla,
|
||||
|
||||
MonitorStatusTimeline,
|
||||
|
||||
|
||||
@@ -852,6 +852,23 @@ export class Service extends DatabaseService<Model> {
|
||||
);
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
// Create SLA record for incident if a matching rule exists
|
||||
try {
|
||||
if (createdItem.projectId && createdItem.id && createdItem.declaredAt) {
|
||||
const IncidentSlaService = (await import("./IncidentSlaService")).default;
|
||||
await IncidentSlaService.createSlaForIncident({
|
||||
incidentId: createdItem.id,
|
||||
projectId: createdItem.projectId,
|
||||
declaredAt: createdItem.declaredAt,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`SLA creation failed in IncidentService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
logger.error(
|
||||
`Critical error in IncidentService sequential operations: ${error}`,
|
||||
@@ -1512,6 +1529,18 @@ ${incidentSeverity.name}
|
||||
`;
|
||||
|
||||
shouldAddIncidentFeed = true;
|
||||
|
||||
// Recalculate SLA deadlines when severity changes
|
||||
try {
|
||||
const IncidentSlaService = (await import("./IncidentSlaService")).default;
|
||||
await IncidentSlaService.recalculateDeadlines({
|
||||
incidentId: incidentId,
|
||||
});
|
||||
} catch (slaError) {
|
||||
logger.error(
|
||||
`SLA recalculation failed in IncidentService.onUpdateSuccess: ${slaError}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
365
Common/Server/Services/IncidentSlaRuleService.ts
Normal file
365
Common/Server/Services/IncidentSlaRuleService.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import CreateBy from "../Types/Database/CreateBy";
|
||||
import { OnCreate } from "../Types/Database/Hooks";
|
||||
import SortOrder from "../../Types/BaseDatabase/SortOrder";
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import Model from "../../Models/DatabaseModels/IncidentSlaRule";
|
||||
import Incident from "../../Models/DatabaseModels/Incident";
|
||||
import IncidentSeverity from "../../Models/DatabaseModels/IncidentSeverity";
|
||||
import Label from "../../Models/DatabaseModels/Label";
|
||||
import Monitor from "../../Models/DatabaseModels/Monitor";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import logger from "../Utils/Logger";
|
||||
import { IsBillingEnabled } from "../EnvironmentConfig";
|
||||
import MonitorService from "./MonitorService";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
super(Model);
|
||||
if (IsBillingEnabled) {
|
||||
this.hardDeleteItemsOlderThanInDays("createdAt", 3 * 365); // 3 years
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
protected override async onBeforeCreate(
|
||||
createBy: CreateBy<Model>,
|
||||
): Promise<OnCreate<Model>> {
|
||||
// Auto-assign order on creation
|
||||
if (!createBy.data.order) {
|
||||
const highestOrderRule: Model | null = await this.findOneBy({
|
||||
query: {
|
||||
projectId: createBy.data.projectId!,
|
||||
},
|
||||
select: {
|
||||
order: true,
|
||||
},
|
||||
sort: {
|
||||
order: SortOrder.Descending,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
createBy.data.order = (highestOrderRule?.order || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
createBy,
|
||||
carryForward: null,
|
||||
};
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async findMatchingRule(data: {
|
||||
incidentId: ObjectID;
|
||||
projectId: ObjectID;
|
||||
incident?: Incident;
|
||||
}): Promise<Model | null> {
|
||||
logger.debug(
|
||||
`Finding matching SLA rule for incident ${data.incidentId} in project ${data.projectId}`,
|
||||
);
|
||||
|
||||
// Get the incident if not provided
|
||||
let incident: Incident | null = data.incident || null;
|
||||
|
||||
if (!incident) {
|
||||
const IncidentService = (await import("./IncidentService")).default;
|
||||
|
||||
incident = await IncidentService.findOneById({
|
||||
id: data.incidentId,
|
||||
select: {
|
||||
projectId: true,
|
||||
title: true,
|
||||
description: true,
|
||||
incidentSeverityId: true,
|
||||
monitors: {
|
||||
_id: true,
|
||||
labels: {
|
||||
_id: true,
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
_id: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!incident) {
|
||||
logger.warn(`Incident ${data.incidentId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all enabled rules sorted by order
|
||||
const rules: Array<Model> = await this.findBy({
|
||||
query: {
|
||||
projectId: data.projectId,
|
||||
isEnabled: true,
|
||||
},
|
||||
sort: {
|
||||
order: SortOrder.Ascending,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
name: true,
|
||||
order: true,
|
||||
responseTimeInMinutes: true,
|
||||
resolutionTimeInMinutes: true,
|
||||
atRiskThresholdInPercentage: true,
|
||||
internalNoteReminderIntervalInMinutes: true,
|
||||
publicNoteReminderIntervalInMinutes: true,
|
||||
internalNoteReminderTemplate: true,
|
||||
publicNoteReminderTemplate: true,
|
||||
monitors: {
|
||||
_id: true,
|
||||
},
|
||||
incidentSeverities: {
|
||||
_id: true,
|
||||
},
|
||||
incidentLabels: {
|
||||
_id: true,
|
||||
},
|
||||
monitorLabels: {
|
||||
_id: true,
|
||||
},
|
||||
incidentTitlePattern: true,
|
||||
incidentDescriptionPattern: true,
|
||||
},
|
||||
limit: 100,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (rules.length === 0) {
|
||||
logger.debug(
|
||||
`No enabled SLA rules found for project ${data.projectId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find first matching rule
|
||||
for (const rule of rules) {
|
||||
const matches: boolean = await this.doesIncidentMatchRule(incident, rule);
|
||||
|
||||
if (matches) {
|
||||
logger.debug(
|
||||
`Incident ${data.incidentId} matches SLA rule ${rule.name || rule.id}`,
|
||||
);
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Incident ${data.incidentId} did not match any SLA rules`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async doesIncidentMatchRule(
|
||||
incident: Incident,
|
||||
rule: Model,
|
||||
): Promise<boolean> {
|
||||
logger.debug(
|
||||
`Checking if incident ${incident.id} matches SLA rule ${rule.name || rule.id}`,
|
||||
);
|
||||
|
||||
// Check monitor IDs - if monitors are specified, incident must have at least one of them
|
||||
if (rule.monitors && rule.monitors.length > 0) {
|
||||
if (!incident.monitors || incident.monitors.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ruleMonitorIds: Array<string> = rule.monitors.map(
|
||||
(m: Monitor) => {
|
||||
return m.id?.toString() || "";
|
||||
},
|
||||
);
|
||||
|
||||
const incidentMonitorIds: Array<string> = incident.monitors.map(
|
||||
(m: Monitor) => {
|
||||
return m.id?.toString() || m._id?.toString() || "";
|
||||
},
|
||||
);
|
||||
|
||||
const hasMatchingMonitor: boolean = ruleMonitorIds.some(
|
||||
(monitorId: string) => {
|
||||
return incidentMonitorIds.includes(monitorId);
|
||||
},
|
||||
);
|
||||
|
||||
if (!hasMatchingMonitor) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check incident severity IDs - if severities are specified, incident must have one of them
|
||||
if (rule.incidentSeverities && rule.incidentSeverities.length > 0) {
|
||||
if (!incident.incidentSeverityId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const severityIds: Array<string> = rule.incidentSeverities.map(
|
||||
(s: IncidentSeverity) => {
|
||||
return s.id?.toString() || "";
|
||||
},
|
||||
);
|
||||
|
||||
const incidentSeverityIdStr: string =
|
||||
incident.incidentSeverityId.toString();
|
||||
|
||||
if (!severityIds.includes(incidentSeverityIdStr)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check incident label IDs - if incident labels are specified, incident must have at least one of them
|
||||
if (rule.incidentLabels && rule.incidentLabels.length > 0) {
|
||||
if (!incident.labels || incident.labels.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ruleLabelIds: Array<string> = rule.incidentLabels.map(
|
||||
(l: Label) => {
|
||||
return l.id?.toString() || "";
|
||||
},
|
||||
);
|
||||
|
||||
const incidentLabelIds: Array<string> = incident.labels.map(
|
||||
(l: Label) => {
|
||||
return l.id?.toString() || l._id?.toString() || "";
|
||||
},
|
||||
);
|
||||
|
||||
const hasMatchingLabel: boolean = ruleLabelIds.some(
|
||||
(labelId: string) => {
|
||||
return incidentLabelIds.includes(labelId);
|
||||
},
|
||||
);
|
||||
|
||||
if (!hasMatchingLabel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check monitor labels - incident's monitors must have at least one matching label
|
||||
if (rule.monitorLabels && rule.monitorLabels.length > 0) {
|
||||
if (!incident.monitors || incident.monitors.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ruleMonitorLabelIds: Array<string> = rule.monitorLabels.map(
|
||||
(l: Label) => {
|
||||
return l.id?.toString() || "";
|
||||
},
|
||||
);
|
||||
|
||||
// Get labels from incident's monitors
|
||||
let hasMatchingMonitorLabel: boolean = false;
|
||||
|
||||
for (const incidentMonitor of incident.monitors) {
|
||||
// Load monitor labels if not already loaded
|
||||
let monitorLabels: Array<Label> | undefined = incidentMonitor.labels;
|
||||
|
||||
if (!monitorLabels) {
|
||||
const monitorId: ObjectID | undefined = incidentMonitor.id
|
||||
? incidentMonitor.id
|
||||
: incidentMonitor._id
|
||||
? new ObjectID(incidentMonitor._id.toString())
|
||||
: undefined;
|
||||
|
||||
if (monitorId) {
|
||||
const monitor: Monitor | null = await MonitorService.findOneById({
|
||||
id: monitorId,
|
||||
select: {
|
||||
labels: {
|
||||
_id: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
monitorLabels = monitor?.labels;
|
||||
}
|
||||
}
|
||||
|
||||
if (monitorLabels && monitorLabels.length > 0) {
|
||||
const monitorLabelIds: Array<string> = monitorLabels.map(
|
||||
(l: Label) => {
|
||||
return l.id?.toString() || l._id?.toString() || "";
|
||||
},
|
||||
);
|
||||
|
||||
hasMatchingMonitorLabel = ruleMonitorLabelIds.some(
|
||||
(labelId: string) => {
|
||||
return monitorLabelIds.includes(labelId);
|
||||
},
|
||||
);
|
||||
|
||||
if (hasMatchingMonitorLabel) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMatchingMonitorLabel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check incident title pattern (regex)
|
||||
if (rule.incidentTitlePattern) {
|
||||
if (!incident.title) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const regex: RegExp = new RegExp(rule.incidentTitlePattern, "i");
|
||||
if (!regex.test(incident.title)) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
logger.warn(
|
||||
`Invalid regex pattern in SLA rule ${rule.id}: ${rule.incidentTitlePattern}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check incident description pattern (regex)
|
||||
if (rule.incidentDescriptionPattern) {
|
||||
if (!incident.description) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const regex: RegExp = new RegExp(rule.incidentDescriptionPattern, "i");
|
||||
if (!regex.test(incident.description)) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
logger.warn(
|
||||
`Invalid regex pattern in SLA rule ${rule.id}: ${rule.incidentDescriptionPattern}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If no criteria specified (all fields empty), rule matches all incidents
|
||||
logger.debug(
|
||||
`SLA rule ${rule.name || rule.id} matched incident ${incident.id} (all criteria passed)`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Service();
|
||||
549
Common/Server/Services/IncidentSlaService.ts
Normal file
549
Common/Server/Services/IncidentSlaService.ts
Normal file
@@ -0,0 +1,549 @@
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import Model from "../../Models/DatabaseModels/IncidentSla";
|
||||
import IncidentSlaRule from "../../Models/DatabaseModels/IncidentSlaRule";
|
||||
import Incident from "../../Models/DatabaseModels/Incident";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import OneUptimeDate from "../../Types/Date";
|
||||
import IncidentSlaStatus from "../../Types/Incident/IncidentSlaStatus";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import logger from "../Utils/Logger";
|
||||
import { IsBillingEnabled } from "../EnvironmentConfig";
|
||||
import IncidentSlaRuleService from "./IncidentSlaRuleService";
|
||||
import QueryHelper from "../Types/Database/QueryHelper";
|
||||
import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
super(Model);
|
||||
if (IsBillingEnabled) {
|
||||
this.hardDeleteItemsOlderThanInDays("createdAt", 3 * 365); // 3 years
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async createSlaForIncident(data: {
|
||||
incidentId: ObjectID;
|
||||
projectId: ObjectID;
|
||||
declaredAt: Date;
|
||||
incident?: Incident;
|
||||
}): Promise<Model | null> {
|
||||
logger.debug(
|
||||
`Creating SLA record for incident ${data.incidentId} in project ${data.projectId}`,
|
||||
);
|
||||
|
||||
// Find matching rule
|
||||
const matchingRuleArgs: {
|
||||
incidentId: ObjectID;
|
||||
projectId: ObjectID;
|
||||
incident?: Incident;
|
||||
} = {
|
||||
incidentId: data.incidentId,
|
||||
projectId: data.projectId,
|
||||
};
|
||||
|
||||
if (data.incident) {
|
||||
matchingRuleArgs.incident = data.incident;
|
||||
}
|
||||
|
||||
const matchingRule: IncidentSlaRule | null =
|
||||
await IncidentSlaRuleService.findMatchingRule(matchingRuleArgs);
|
||||
|
||||
if (!matchingRule || !matchingRule.id) {
|
||||
logger.debug(
|
||||
`No matching SLA rule found for incident ${data.incidentId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate deadlines
|
||||
const slaStartedAt: Date = data.declaredAt;
|
||||
let responseDeadline: Date | undefined = undefined;
|
||||
let resolutionDeadline: Date | undefined = undefined;
|
||||
|
||||
if (matchingRule.responseTimeInMinutes) {
|
||||
responseDeadline = OneUptimeDate.addRemoveMinutes(
|
||||
slaStartedAt,
|
||||
matchingRule.responseTimeInMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
if (matchingRule.resolutionTimeInMinutes) {
|
||||
resolutionDeadline = OneUptimeDate.addRemoveMinutes(
|
||||
slaStartedAt,
|
||||
matchingRule.resolutionTimeInMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
// Create SLA record
|
||||
const sla: Model = new Model();
|
||||
sla.projectId = data.projectId;
|
||||
sla.incidentId = data.incidentId;
|
||||
sla.incidentSlaRuleId = matchingRule.id;
|
||||
sla.slaStartedAt = slaStartedAt;
|
||||
sla.status = IncidentSlaStatus.OnTrack;
|
||||
|
||||
if (responseDeadline) {
|
||||
sla.responseDeadline = responseDeadline;
|
||||
}
|
||||
|
||||
if (resolutionDeadline) {
|
||||
sla.resolutionDeadline = resolutionDeadline;
|
||||
}
|
||||
|
||||
try {
|
||||
const createdSla: Model = await this.create({
|
||||
data: sla,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Created SLA record ${createdSla.id} for incident ${data.incidentId} with rule ${matchingRule.name || matchingRule.id}`,
|
||||
);
|
||||
|
||||
return createdSla;
|
||||
} catch (error) {
|
||||
logger.error(`Error creating SLA record for incident ${data.incidentId}: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async markResponded(data: {
|
||||
incidentId: ObjectID;
|
||||
respondedAt: Date;
|
||||
}): Promise<void> {
|
||||
logger.debug(
|
||||
`Marking incident ${data.incidentId} as responded at ${data.respondedAt}`,
|
||||
);
|
||||
|
||||
// Find all active SLA records for this incident
|
||||
const slaRecords: Array<Model> = await this.findBy({
|
||||
query: {
|
||||
incidentId: data.incidentId,
|
||||
resolvedAt: QueryHelper.isNull(),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
respondedAt: true,
|
||||
status: true,
|
||||
responseDeadline: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const sla of slaRecords) {
|
||||
// Only update if not already responded
|
||||
if (!sla.respondedAt && sla.id) {
|
||||
await this.updateOneById({
|
||||
id: sla.id,
|
||||
data: {
|
||||
respondedAt: data.respondedAt,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Marked SLA ${sla.id} as responded at ${data.respondedAt}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async markResolved(data: {
|
||||
incidentId: ObjectID;
|
||||
resolvedAt: Date;
|
||||
}): Promise<void> {
|
||||
logger.debug(
|
||||
`Marking incident ${data.incidentId} as resolved at ${data.resolvedAt}`,
|
||||
);
|
||||
|
||||
// Find all active SLA records for this incident
|
||||
const slaRecords: Array<Model> = await this.findBy({
|
||||
query: {
|
||||
incidentId: data.incidentId,
|
||||
resolvedAt: QueryHelper.isNull(),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
status: true,
|
||||
resolutionDeadline: true,
|
||||
responseDeadline: true,
|
||||
respondedAt: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const sla of slaRecords) {
|
||||
if (!sla.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine final SLA status
|
||||
let finalStatus: IncidentSlaStatus = IncidentSlaStatus.Met;
|
||||
|
||||
// Check if response deadline was breached
|
||||
if (sla.responseDeadline && !sla.respondedAt) {
|
||||
// Never responded, check if response deadline passed
|
||||
if (OneUptimeDate.isAfter(data.resolvedAt, sla.responseDeadline)) {
|
||||
finalStatus = IncidentSlaStatus.ResponseBreached;
|
||||
}
|
||||
} else if (
|
||||
sla.responseDeadline &&
|
||||
sla.respondedAt &&
|
||||
OneUptimeDate.isAfter(sla.respondedAt, sla.responseDeadline)
|
||||
) {
|
||||
// Responded after deadline
|
||||
finalStatus = IncidentSlaStatus.ResponseBreached;
|
||||
}
|
||||
|
||||
// Check if resolution deadline was breached (takes precedence)
|
||||
if (
|
||||
sla.resolutionDeadline &&
|
||||
OneUptimeDate.isAfter(data.resolvedAt, sla.resolutionDeadline)
|
||||
) {
|
||||
finalStatus = IncidentSlaStatus.ResolutionBreached;
|
||||
}
|
||||
|
||||
await this.updateOneById({
|
||||
id: sla.id,
|
||||
data: {
|
||||
resolvedAt: data.resolvedAt,
|
||||
status: finalStatus,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Marked SLA ${sla.id} as resolved with status ${finalStatus}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async recalculateDeadlines(data: {
|
||||
incidentId: ObjectID;
|
||||
}): Promise<void> {
|
||||
logger.debug(
|
||||
`Recalculating deadlines for incident ${data.incidentId}`,
|
||||
);
|
||||
|
||||
// Get the incident to find the new severity and project
|
||||
const IncidentService = (await import("./IncidentService")).default;
|
||||
|
||||
const incident: Incident | null = await IncidentService.findOneById({
|
||||
id: data.incidentId,
|
||||
select: {
|
||||
projectId: true,
|
||||
declaredAt: true,
|
||||
incidentSeverityId: true,
|
||||
title: true,
|
||||
description: true,
|
||||
monitors: {
|
||||
_id: true,
|
||||
labels: {
|
||||
_id: true,
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
_id: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!incident || !incident.projectId) {
|
||||
logger.warn(`Incident ${data.incidentId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all active SLA records for this incident
|
||||
const slaRecords: Array<Model> = await this.findBy({
|
||||
query: {
|
||||
incidentId: data.incidentId,
|
||||
resolvedAt: QueryHelper.isNull(),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
slaStartedAt: true,
|
||||
incidentSlaRuleId: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Find new matching rule (may be different due to severity change)
|
||||
const newMatchingRule: IncidentSlaRule | null =
|
||||
await IncidentSlaRuleService.findMatchingRule({
|
||||
incidentId: data.incidentId,
|
||||
projectId: incident.projectId,
|
||||
incident: incident,
|
||||
});
|
||||
|
||||
for (const sla of slaRecords) {
|
||||
if (!sla.id || !sla.slaStartedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the matching rule has changed
|
||||
if (
|
||||
newMatchingRule &&
|
||||
newMatchingRule.id?.toString() !== sla.incidentSlaRuleId?.toString()
|
||||
) {
|
||||
// Rule has changed, recalculate with new rule
|
||||
let responseDeadline: Date | undefined = undefined;
|
||||
let resolutionDeadline: Date | undefined = undefined;
|
||||
|
||||
if (newMatchingRule.responseTimeInMinutes) {
|
||||
responseDeadline = OneUptimeDate.addRemoveMinutes(
|
||||
sla.slaStartedAt,
|
||||
newMatchingRule.responseTimeInMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
if (newMatchingRule.resolutionTimeInMinutes) {
|
||||
resolutionDeadline = OneUptimeDate.addRemoveMinutes(
|
||||
sla.slaStartedAt,
|
||||
newMatchingRule.resolutionTimeInMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
await this.updateOneById({
|
||||
id: sla.id,
|
||||
data: {
|
||||
incidentSlaRuleId: newMatchingRule.id,
|
||||
responseDeadline: responseDeadline || null,
|
||||
resolutionDeadline: resolutionDeadline || null,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Recalculated SLA ${sla.id} with new rule ${newMatchingRule.name || newMatchingRule.id}`,
|
||||
);
|
||||
} else if (!newMatchingRule) {
|
||||
// No matching rule anymore, but keep the existing SLA record
|
||||
logger.debug(
|
||||
`No matching SLA rule found after severity change for incident ${data.incidentId}, keeping existing SLA`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async getIncidentsNeedingInternalNoteReminder(): Promise<
|
||||
Array<Model>
|
||||
> {
|
||||
// Find SLAs where:
|
||||
// - Not resolved
|
||||
// - Has internal note reminder interval configured
|
||||
// - Last reminder was sent more than interval ago OR never sent
|
||||
const now: Date = OneUptimeDate.getCurrentDate();
|
||||
|
||||
const slaRecords: Array<Model> = await this.findBy({
|
||||
query: {
|
||||
resolvedAt: QueryHelper.isNull(),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
incidentId: true,
|
||||
projectId: true,
|
||||
incidentSlaRuleId: true,
|
||||
incidentSlaRule: {
|
||||
internalNoteReminderIntervalInMinutes: true,
|
||||
internalNoteReminderTemplate: true,
|
||||
},
|
||||
slaStartedAt: true,
|
||||
responseDeadline: true,
|
||||
resolutionDeadline: true,
|
||||
status: true,
|
||||
lastInternalNoteReminderSentAt: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Filter to only those needing reminders
|
||||
const needingReminder: Array<Model> = slaRecords.filter((sla: Model) => {
|
||||
const interval: number | undefined =
|
||||
sla.incidentSlaRule?.internalNoteReminderIntervalInMinutes;
|
||||
|
||||
if (!interval) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!sla.lastInternalNoteReminderSentAt) {
|
||||
// Never sent, check if enough time has passed since SLA started
|
||||
const timeSinceStart: number = OneUptimeDate.getDifferenceInMinutes(
|
||||
now,
|
||||
sla.slaStartedAt!,
|
||||
);
|
||||
return timeSinceStart >= interval;
|
||||
}
|
||||
|
||||
const timeSinceLastReminder: number = OneUptimeDate.getDifferenceInMinutes(
|
||||
now,
|
||||
sla.lastInternalNoteReminderSentAt,
|
||||
);
|
||||
|
||||
return timeSinceLastReminder >= interval;
|
||||
});
|
||||
|
||||
return needingReminder;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async getIncidentsNeedingPublicNoteReminder(): Promise<Array<Model>> {
|
||||
const now: Date = OneUptimeDate.getCurrentDate();
|
||||
|
||||
const slaRecords: Array<Model> = await this.findBy({
|
||||
query: {
|
||||
resolvedAt: QueryHelper.isNull(),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
incidentId: true,
|
||||
projectId: true,
|
||||
incidentSlaRuleId: true,
|
||||
incidentSlaRule: {
|
||||
publicNoteReminderIntervalInMinutes: true,
|
||||
publicNoteReminderTemplate: true,
|
||||
},
|
||||
slaStartedAt: true,
|
||||
responseDeadline: true,
|
||||
resolutionDeadline: true,
|
||||
status: true,
|
||||
lastPublicNoteReminderSentAt: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Filter to only those needing reminders
|
||||
const needingReminder: Array<Model> = slaRecords.filter((sla: Model) => {
|
||||
const interval: number | undefined =
|
||||
sla.incidentSlaRule?.publicNoteReminderIntervalInMinutes;
|
||||
|
||||
if (!interval) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!sla.lastPublicNoteReminderSentAt) {
|
||||
// Never sent, check if enough time has passed since SLA started
|
||||
const timeSinceStart: number = OneUptimeDate.getDifferenceInMinutes(
|
||||
now,
|
||||
sla.slaStartedAt!,
|
||||
);
|
||||
return timeSinceStart >= interval;
|
||||
}
|
||||
|
||||
const timeSinceLastReminder: number = OneUptimeDate.getDifferenceInMinutes(
|
||||
now,
|
||||
sla.lastPublicNoteReminderSentAt,
|
||||
);
|
||||
|
||||
return timeSinceLastReminder >= interval;
|
||||
});
|
||||
|
||||
return needingReminder;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async getSlasNeedingBreachCheck(): Promise<Array<Model>> {
|
||||
// Find SLAs where status is OnTrack or AtRisk and not resolved
|
||||
return await this.findBy({
|
||||
query: {
|
||||
resolvedAt: QueryHelper.isNull(),
|
||||
status: QueryHelper.any([
|
||||
IncidentSlaStatus.OnTrack,
|
||||
IncidentSlaStatus.AtRisk,
|
||||
]),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
incidentId: true,
|
||||
projectId: true,
|
||||
incidentSlaRuleId: true,
|
||||
incidentSlaRule: {
|
||||
atRiskThresholdInPercentage: true,
|
||||
name: true,
|
||||
},
|
||||
slaStartedAt: true,
|
||||
responseDeadline: true,
|
||||
resolutionDeadline: true,
|
||||
respondedAt: true,
|
||||
status: true,
|
||||
breachNotificationSentAt: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async getActiveSlasForIncident(data: {
|
||||
incidentId: ObjectID;
|
||||
}): Promise<Array<Model>> {
|
||||
return await this.findBy({
|
||||
query: {
|
||||
incidentId: data.incidentId,
|
||||
resolvedAt: QueryHelper.isNull(),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
incidentId: true,
|
||||
projectId: true,
|
||||
incidentSlaRuleId: true,
|
||||
incidentSlaRule: {
|
||||
name: true,
|
||||
responseTimeInMinutes: true,
|
||||
resolutionTimeInMinutes: true,
|
||||
atRiskThresholdInPercentage: true,
|
||||
},
|
||||
slaStartedAt: true,
|
||||
responseDeadline: true,
|
||||
resolutionDeadline: true,
|
||||
respondedAt: true,
|
||||
resolvedAt: true,
|
||||
status: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Service();
|
||||
@@ -140,6 +140,7 @@ export class Service extends DatabaseService<IncidentStateTimeline> {
|
||||
_id: true,
|
||||
order: true,
|
||||
name: true,
|
||||
isResolvedState: true,
|
||||
},
|
||||
startsAt: true,
|
||||
endsAt: true,
|
||||
@@ -519,6 +520,21 @@ ${createdItem.rootCause}`,
|
||||
logger.error(error);
|
||||
});
|
||||
|
||||
// Track SLA response/resolution times
|
||||
this.trackSlaStateChange({
|
||||
incidentId: createdItem.incidentId,
|
||||
projectId: createdItem.projectId!,
|
||||
isAcknowledgedState: incidentState?.isAcknowledgedState || false,
|
||||
isResolvedState: incidentState?.isResolvedState || false,
|
||||
stateChangedAt: createdItem.startsAt || OneUptimeDate.getCurrentDate(),
|
||||
previousStateWasResolved:
|
||||
onCreate.carryForward.statusTimelineBeforeThisStatus?.incidentState
|
||||
?.isResolvedState || false,
|
||||
}).catch((error: Error) => {
|
||||
logger.error(`Error while tracking SLA state change:`);
|
||||
logger.error(error);
|
||||
});
|
||||
|
||||
const isLastIncidentState: boolean = await this.isLastIncidentState({
|
||||
projectId: createdItem.projectId!,
|
||||
incidentStateId: createdItem.incidentStateId,
|
||||
@@ -671,6 +687,70 @@ ${createdItem.rootCause}`,
|
||||
);
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async trackSlaStateChange(data: {
|
||||
incidentId: ObjectID;
|
||||
projectId: ObjectID;
|
||||
isAcknowledgedState: boolean;
|
||||
isResolvedState: boolean;
|
||||
stateChangedAt: Date;
|
||||
previousStateWasResolved: boolean;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const IncidentSlaService = (await import("./IncidentSlaService")).default;
|
||||
|
||||
// Check if incident is being reopened (previous state was resolved, current state is not resolved)
|
||||
if (data.previousStateWasResolved && !data.isResolvedState) {
|
||||
// Incident is being reopened - create a new SLA record
|
||||
const IncidentService = (await import("./IncidentService")).default;
|
||||
|
||||
const incident: Incident | null = await IncidentService.findOneById({
|
||||
id: data.incidentId,
|
||||
select: {
|
||||
declaredAt: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (incident && incident.declaredAt) {
|
||||
// Create a new SLA record starting from the reopen time
|
||||
await IncidentSlaService.createSlaForIncident({
|
||||
incidentId: data.incidentId,
|
||||
projectId: data.projectId,
|
||||
declaredAt: data.stateChangedAt, // Use reopen time as SLA start time
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Created new SLA record for reopened incident ${data.incidentId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Track acknowledged state
|
||||
if (data.isAcknowledgedState) {
|
||||
await IncidentSlaService.markResponded({
|
||||
incidentId: data.incidentId,
|
||||
respondedAt: data.stateChangedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Track resolved state
|
||||
if (data.isResolvedState) {
|
||||
await IncidentSlaService.markResolved({
|
||||
incidentId: data.incidentId,
|
||||
resolvedAt: data.stateChangedAt,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in trackSlaStateChange: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
protected override async onBeforeDelete(
|
||||
deleteBy: DeleteBy<IncidentStateTimeline>,
|
||||
|
||||
@@ -182,6 +182,8 @@ import IncidentEpisodeOwnerTeamService from "./IncidentEpisodeOwnerTeamService";
|
||||
import IncidentEpisodeOwnerUserService from "./IncidentEpisodeOwnerUserService";
|
||||
import IncidentEpisodeStateTimelineService from "./IncidentEpisodeStateTimelineService";
|
||||
import AlertGroupingRuleService from "./AlertGroupingRuleService";
|
||||
import IncidentSlaRuleService from "./IncidentSlaRuleService";
|
||||
import IncidentSlaService from "./IncidentSlaService";
|
||||
|
||||
import TableViewService from "./TableViewService";
|
||||
import ScheduledMaintenanceFeedService from "./ScheduledMaintenanceFeedService";
|
||||
@@ -399,6 +401,8 @@ const services: Array<BaseService> = [
|
||||
IncidentEpisodeOwnerUserService,
|
||||
IncidentEpisodeStateTimelineService,
|
||||
AlertGroupingRuleService,
|
||||
IncidentSlaRuleService,
|
||||
IncidentSlaService,
|
||||
|
||||
TableViewService,
|
||||
MonitorTestService,
|
||||
|
||||
9
Common/Types/Incident/IncidentSlaStatus.ts
Normal file
9
Common/Types/Incident/IncidentSlaStatus.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
enum IncidentSlaStatus {
|
||||
OnTrack = "On Track",
|
||||
AtRisk = "At Risk",
|
||||
ResponseBreached = "Response Breached",
|
||||
ResolutionBreached = "Resolution Breached",
|
||||
Met = "Met",
|
||||
}
|
||||
|
||||
export default IncidentSlaStatus;
|
||||
@@ -816,6 +816,18 @@ enum Permission {
|
||||
EditIncidentGroupingRule = "EditIncidentGroupingRule",
|
||||
ReadIncidentGroupingRule = "ReadIncidentGroupingRule",
|
||||
|
||||
// Incident SLA Rule Permissions
|
||||
CreateIncidentSlaRule = "CreateIncidentSlaRule",
|
||||
DeleteIncidentSlaRule = "DeleteIncidentSlaRule",
|
||||
EditIncidentSlaRule = "EditIncidentSlaRule",
|
||||
ReadIncidentSlaRule = "ReadIncidentSlaRule",
|
||||
|
||||
// Incident SLA Permissions
|
||||
CreateIncidentSla = "CreateIncidentSla",
|
||||
DeleteIncidentSla = "DeleteIncidentSla",
|
||||
EditIncidentSla = "EditIncidentSla",
|
||||
ReadIncidentSla = "ReadIncidentSla",
|
||||
|
||||
// Read All Project Resources Permission - Grants read access to all project resources
|
||||
ReadAllProjectResources = "ReadAllProjectResources",
|
||||
}
|
||||
@@ -5791,6 +5803,74 @@ export class PermissionHelper {
|
||||
isAccessControlPermission: false,
|
||||
},
|
||||
|
||||
// Incident SLA Rule Permissions
|
||||
{
|
||||
permission: Permission.CreateIncidentSlaRule,
|
||||
title: "Create Incident SLA Rule",
|
||||
description:
|
||||
"This permission can create Incident SLA Rules in this project.",
|
||||
isAssignableToTenant: true,
|
||||
isAccessControlPermission: false,
|
||||
},
|
||||
{
|
||||
permission: Permission.DeleteIncidentSlaRule,
|
||||
title: "Delete Incident SLA Rule",
|
||||
description:
|
||||
"This permission can delete Incident SLA Rules of this project.",
|
||||
isAssignableToTenant: true,
|
||||
isAccessControlPermission: false,
|
||||
},
|
||||
{
|
||||
permission: Permission.EditIncidentSlaRule,
|
||||
title: "Edit Incident SLA Rule",
|
||||
description:
|
||||
"This permission can edit Incident SLA Rules of this project.",
|
||||
isAssignableToTenant: true,
|
||||
isAccessControlPermission: false,
|
||||
},
|
||||
{
|
||||
permission: Permission.ReadIncidentSlaRule,
|
||||
title: "Read Incident SLA Rule",
|
||||
description:
|
||||
"This permission can read Incident SLA Rules of this project.",
|
||||
isAssignableToTenant: true,
|
||||
isAccessControlPermission: false,
|
||||
},
|
||||
|
||||
// Incident SLA Permissions
|
||||
{
|
||||
permission: Permission.CreateIncidentSla,
|
||||
title: "Create Incident SLA",
|
||||
description:
|
||||
"This permission can create Incident SLA records in this project.",
|
||||
isAssignableToTenant: true,
|
||||
isAccessControlPermission: false,
|
||||
},
|
||||
{
|
||||
permission: Permission.DeleteIncidentSla,
|
||||
title: "Delete Incident SLA",
|
||||
description:
|
||||
"This permission can delete Incident SLA records of this project.",
|
||||
isAssignableToTenant: true,
|
||||
isAccessControlPermission: false,
|
||||
},
|
||||
{
|
||||
permission: Permission.EditIncidentSla,
|
||||
title: "Edit Incident SLA",
|
||||
description:
|
||||
"This permission can edit Incident SLA records of this project.",
|
||||
isAssignableToTenant: true,
|
||||
isAccessControlPermission: false,
|
||||
},
|
||||
{
|
||||
permission: Permission.ReadIncidentSla,
|
||||
title: "Read Incident SLA",
|
||||
description:
|
||||
"This permission can read Incident SLA records of this project.",
|
||||
isAssignableToTenant: true,
|
||||
isAccessControlPermission: false,
|
||||
},
|
||||
|
||||
// Read All Project Resources Permission
|
||||
{
|
||||
permission: Permission.ReadAllProjectResources,
|
||||
|
||||
432
Dashboard/src/Pages/Incidents/Settings/IncidentSlaRules.tsx
Normal file
432
Dashboard/src/Pages/Incidents/Settings/IncidentSlaRules.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
|
||||
import Pill from "Common/UI/Components/Pill/Pill";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import IncidentSlaRule from "Common/Models/DatabaseModels/IncidentSlaRule";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
import { Green, Red } from "Common/Types/BrandColors";
|
||||
import Monitor from "Common/Models/DatabaseModels/Monitor";
|
||||
import IncidentSeverity from "Common/Models/DatabaseModels/IncidentSeverity";
|
||||
import Label from "Common/Models/DatabaseModels/Label";
|
||||
|
||||
const documentationMarkdown: string = `
|
||||
### How Incident SLA Rules Work
|
||||
|
||||
SLA (Service Level Agreement) rules automatically track response and resolution times for incidents. When an incident is created, the first matching SLA rule is applied, and deadlines are calculated based on the rule's configuration.
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart TD
|
||||
A[New Incident Created] --> B{Match Against SLA Rules}
|
||||
B -->|Rule Matches| C[Create SLA Record]
|
||||
B -->|No Match| D[No SLA Tracking]
|
||||
C --> E[Calculate Deadlines]
|
||||
E --> F[Track Response Time]
|
||||
E --> G[Track Resolution Time]
|
||||
F --> H{Response Deadline Met?}
|
||||
G --> I{Resolution Deadline Met?}
|
||||
H -->|Yes| J[SLA Met]
|
||||
H -->|No| K[Response Breached]
|
||||
I -->|Yes| J
|
||||
I -->|No| L[Resolution Breached]
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
### Match Criteria
|
||||
|
||||
Match criteria determines which incidents this SLA rule applies to. An incident must match ALL specified criteria for the rule to apply.
|
||||
|
||||
| Criteria | Description |
|
||||
|----------|-------------|
|
||||
| **Monitors** | Only incidents from these specific monitors |
|
||||
| **Severities** | Only incidents with these severity levels |
|
||||
| **Incident Labels** | Only incidents with at least one of these labels |
|
||||
| **Monitor Labels** | Only incidents from monitors with at least one of these labels |
|
||||
| **Title Pattern** | Regex pattern to match incident titles |
|
||||
| **Description Pattern** | Regex pattern to match incident descriptions |
|
||||
|
||||
---
|
||||
|
||||
### SLA Targets
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| **Response Time** | Time allowed to acknowledge the incident |
|
||||
| **Resolution Time** | Time allowed to resolve the incident |
|
||||
| **At-Risk Threshold** | Percentage of deadline at which status changes to "At Risk" (default: 80%) |
|
||||
|
||||
---
|
||||
|
||||
### SLA Status Values
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| **On Track** | Deadlines have not been breached and at-risk threshold not reached |
|
||||
| **At Risk** | Elapsed time has exceeded the at-risk threshold percentage |
|
||||
| **Response Breached** | Response deadline was not met |
|
||||
| **Resolution Breached** | Resolution deadline was not met |
|
||||
| **Met** | Incident was resolved within all deadlines |
|
||||
|
||||
---
|
||||
|
||||
### Note Reminders
|
||||
|
||||
SLA rules can automatically post internal or public notes at regular intervals while the incident remains open. This helps keep stakeholders informed and encourages progress updates.
|
||||
|
||||
- **Internal Note Reminders**: Posted to the incident's internal notes
|
||||
- **Public Note Reminders**: Posted to the incident's public notes (visible on status pages)
|
||||
|
||||
Template variables available:
|
||||
- \`{{incidentTitle}}\` - Incident title
|
||||
- \`{{incidentNumber}}\` - Incident number
|
||||
- \`{{elapsedTime}}\` - Time since incident was declared
|
||||
- \`{{responseDeadline}}\` - Response deadline timestamp
|
||||
- \`{{resolutionDeadline}}\` - Resolution deadline timestamp
|
||||
- \`{{slaStatus}}\` - Current SLA status
|
||||
- \`{{timeToResponseDeadline}}\` - Time remaining to response deadline
|
||||
- \`{{timeToResolutionDeadline}}\` - Time remaining to resolution deadline
|
||||
`;
|
||||
|
||||
const IncidentSlaRulesPage: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
return (
|
||||
<Fragment>
|
||||
<ModelTable<IncidentSlaRule>
|
||||
modelType={IncidentSlaRule}
|
||||
id="incident-sla-rules-table"
|
||||
name="Settings > Incident SLA Rules"
|
||||
userPreferencesKey="incident-sla-rules-table"
|
||||
isDeleteable={true}
|
||||
isEditable={true}
|
||||
isCreateable={true}
|
||||
cardProps={{
|
||||
title: "Incident SLA Rules",
|
||||
description:
|
||||
"Define SLA rules to automatically track response and resolution times for incidents. Rules are evaluated in order - lower order numbers are evaluated first.",
|
||||
}}
|
||||
helpContent={{
|
||||
title: "How Incident SLA Rules Work",
|
||||
description:
|
||||
"Understanding SLA tracking, match criteria, and automatic note reminders",
|
||||
markdown: documentationMarkdown,
|
||||
}}
|
||||
sortBy="order"
|
||||
sortOrder={SortOrder.Ascending}
|
||||
selectMoreFields={{
|
||||
order: true,
|
||||
isEnabled: true,
|
||||
}}
|
||||
filters={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isEnabled: true,
|
||||
},
|
||||
title: "Enabled",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
description: true,
|
||||
},
|
||||
title: "Description",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
order: true,
|
||||
},
|
||||
title: "Order",
|
||||
type: FieldType.Number,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isEnabled: true,
|
||||
},
|
||||
title: "Status",
|
||||
type: FieldType.Boolean,
|
||||
getElement: (item: IncidentSlaRule): ReactElement => {
|
||||
if (item.isEnabled) {
|
||||
return <Pill color={Green} text="Enabled" />;
|
||||
}
|
||||
return <Pill color={Red} text="Disabled" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
responseTimeInMinutes: true,
|
||||
},
|
||||
title: "Response Time (min)",
|
||||
type: FieldType.Number,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
resolutionTimeInMinutes: true,
|
||||
},
|
||||
title: "Resolution Time (min)",
|
||||
type: FieldType.Number,
|
||||
},
|
||||
]}
|
||||
viewPageRoute={Navigation.getCurrentRoute()}
|
||||
formSteps={[
|
||||
{
|
||||
title: "Basic Info",
|
||||
id: "basic-info",
|
||||
},
|
||||
{
|
||||
title: "SLA Targets",
|
||||
id: "sla-targets",
|
||||
},
|
||||
{
|
||||
title: "Match Criteria",
|
||||
id: "match-criteria",
|
||||
},
|
||||
{
|
||||
title: "Note Reminders",
|
||||
id: "note-reminders",
|
||||
},
|
||||
]}
|
||||
formFields={[
|
||||
// Basic Info
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
stepId: "basic-info",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: true,
|
||||
placeholder: "Critical Incident SLA",
|
||||
validation: {
|
||||
minLength: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
description: true,
|
||||
},
|
||||
title: "Description",
|
||||
stepId: "basic-info",
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
required: false,
|
||||
placeholder:
|
||||
"SLA for critical incidents requiring fast response and resolution",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isEnabled: true,
|
||||
},
|
||||
title: "Enabled",
|
||||
stepId: "basic-info",
|
||||
fieldType: FormFieldSchemaType.Toggle,
|
||||
required: false,
|
||||
description: "Enable or disable this SLA rule.",
|
||||
},
|
||||
// SLA Targets
|
||||
{
|
||||
field: {
|
||||
responseTimeInMinutes: true,
|
||||
},
|
||||
title: "Response Time (minutes)",
|
||||
stepId: "sla-targets",
|
||||
fieldType: FormFieldSchemaType.Number,
|
||||
required: false,
|
||||
placeholder: "15",
|
||||
description:
|
||||
"Time allowed to acknowledge the incident. Leave empty if response tracking is not needed.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
resolutionTimeInMinutes: true,
|
||||
},
|
||||
title: "Resolution Time (minutes)",
|
||||
stepId: "sla-targets",
|
||||
fieldType: FormFieldSchemaType.Number,
|
||||
required: false,
|
||||
placeholder: "60",
|
||||
description:
|
||||
"Time allowed to resolve the incident. Leave empty if resolution tracking is not needed.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
atRiskThresholdInPercentage: true,
|
||||
},
|
||||
title: "At-Risk Threshold (%)",
|
||||
stepId: "sla-targets",
|
||||
fieldType: FormFieldSchemaType.Number,
|
||||
required: false,
|
||||
placeholder: "80",
|
||||
description:
|
||||
"Percentage of deadline at which status changes to 'At Risk'. Default is 80%.",
|
||||
},
|
||||
// Match Criteria
|
||||
{
|
||||
field: {
|
||||
monitors: true,
|
||||
},
|
||||
title: "Monitors",
|
||||
stepId: "match-criteria",
|
||||
fieldType: FormFieldSchemaType.MultiSelectDropdown,
|
||||
dropdownModal: {
|
||||
type: Monitor,
|
||||
labelField: "name",
|
||||
valueField: "_id",
|
||||
},
|
||||
required: false,
|
||||
description:
|
||||
"Only apply this SLA to incidents from these monitors. Leave empty to match incidents from any monitor.",
|
||||
placeholder: "Select Monitors (optional)",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
incidentSeverities: true,
|
||||
},
|
||||
title: "Incident Severities",
|
||||
stepId: "match-criteria",
|
||||
fieldType: FormFieldSchemaType.MultiSelectDropdown,
|
||||
dropdownModal: {
|
||||
type: IncidentSeverity,
|
||||
labelField: "name",
|
||||
valueField: "_id",
|
||||
},
|
||||
required: false,
|
||||
description:
|
||||
"Only apply this SLA to incidents with these severities. Leave empty to match incidents of any severity.",
|
||||
placeholder: "Select Severities (optional)",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
incidentLabels: true,
|
||||
},
|
||||
title: "Incident Labels",
|
||||
stepId: "match-criteria",
|
||||
fieldType: FormFieldSchemaType.MultiSelectDropdown,
|
||||
dropdownModal: {
|
||||
type: Label,
|
||||
labelField: "name",
|
||||
valueField: "_id",
|
||||
},
|
||||
required: false,
|
||||
description:
|
||||
"Only apply this SLA to incidents that have at least one of these labels. Leave empty to match incidents regardless of labels.",
|
||||
placeholder: "Select Incident Labels (optional)",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
monitorLabels: true,
|
||||
},
|
||||
title: "Monitor Labels",
|
||||
stepId: "match-criteria",
|
||||
fieldType: FormFieldSchemaType.MultiSelectDropdown,
|
||||
dropdownModal: {
|
||||
type: Label,
|
||||
labelField: "name",
|
||||
valueField: "_id",
|
||||
},
|
||||
required: false,
|
||||
description:
|
||||
"Only apply this SLA to incidents from monitors that have at least one of these labels. Leave empty to match incidents regardless of monitor labels.",
|
||||
placeholder: "Select Monitor Labels (optional)",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
incidentTitlePattern: true,
|
||||
},
|
||||
title: "Incident Title Pattern",
|
||||
stepId: "match-criteria",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: false,
|
||||
placeholder: "CPU.*high",
|
||||
description:
|
||||
"Regular expression pattern to match incident titles. Leave empty to match any title.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
incidentDescriptionPattern: true,
|
||||
},
|
||||
title: "Incident Description Pattern",
|
||||
stepId: "match-criteria",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: false,
|
||||
placeholder: "timeout|connection refused",
|
||||
description:
|
||||
"Regular expression pattern to match incident descriptions. Leave empty to match any description.",
|
||||
},
|
||||
// Note Reminders
|
||||
{
|
||||
field: {
|
||||
internalNoteReminderIntervalInMinutes: true,
|
||||
},
|
||||
title: "Internal Note Reminder Interval (minutes)",
|
||||
stepId: "note-reminders",
|
||||
fieldType: FormFieldSchemaType.Number,
|
||||
required: false,
|
||||
placeholder: "30",
|
||||
description:
|
||||
"Post an internal note reminder at this interval while the incident is open. Leave empty to disable internal note reminders.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
internalNoteReminderTemplate: true,
|
||||
},
|
||||
title: "Internal Note Reminder Template",
|
||||
stepId: "note-reminders",
|
||||
fieldType: FormFieldSchemaType.Markdown,
|
||||
required: false,
|
||||
placeholder:
|
||||
"**SLA Reminder**: This incident has been open for {{elapsedTime}}...",
|
||||
description:
|
||||
"Markdown template for internal note reminders. Use template variables like {{incidentTitle}}, {{elapsedTime}}, etc.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
publicNoteReminderIntervalInMinutes: true,
|
||||
},
|
||||
title: "Public Note Reminder Interval (minutes)",
|
||||
stepId: "note-reminders",
|
||||
fieldType: FormFieldSchemaType.Number,
|
||||
required: false,
|
||||
placeholder: "60",
|
||||
description:
|
||||
"Post a public note reminder at this interval while the incident is open. Leave empty to disable public note reminders.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
publicNoteReminderTemplate: true,
|
||||
},
|
||||
title: "Public Note Reminder Template",
|
||||
stepId: "note-reminders",
|
||||
fieldType: FormFieldSchemaType.Markdown,
|
||||
required: false,
|
||||
placeholder:
|
||||
"**Status Update**: Our team continues to work on resolving this incident...",
|
||||
description:
|
||||
"Markdown template for public note reminders. Use template variables like {{incidentTitle}}, {{elapsedTime}}, etc.",
|
||||
},
|
||||
]}
|
||||
showRefreshButton={true}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default IncidentSlaRulesPage;
|
||||
@@ -182,6 +182,15 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
|
||||
},
|
||||
icon: IconProp.Filter,
|
||||
},
|
||||
{
|
||||
link: {
|
||||
title: "SLA Rules",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.INCIDENTS_SETTINGS_SLA_RULES] as Route,
|
||||
),
|
||||
},
|
||||
icon: IconProp.Clock,
|
||||
},
|
||||
{
|
||||
link: {
|
||||
title: "Incident Roles",
|
||||
|
||||
@@ -84,6 +84,17 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
|
||||
icon={IconProp.List}
|
||||
/>
|
||||
|
||||
<SideMenuItem
|
||||
link={{
|
||||
title: "SLA",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.INCIDENT_VIEW_SLA] as Route,
|
||||
{ modelId: props.modelId },
|
||||
),
|
||||
}}
|
||||
icon={IconProp.Clock}
|
||||
/>
|
||||
|
||||
<SideMenuItem
|
||||
link={{
|
||||
title: "Owners",
|
||||
|
||||
263
Dashboard/src/Pages/Incidents/View/Sla.tsx
Normal file
263
Dashboard/src/Pages/Incidents/View/Sla.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import IncidentSla from "Common/Models/DatabaseModels/IncidentSla";
|
||||
import IncidentSlaStatus from "Common/Types/Incident/IncidentSlaStatus";
|
||||
import Pill from "Common/UI/Components/Pill/Pill";
|
||||
import { Green, Red, Yellow, Gray500 } from "Common/Types/BrandColors";
|
||||
import Color from "Common/Types/Color";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
|
||||
const IncidentViewSla: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
|
||||
|
||||
const getStatusColor = (status: IncidentSlaStatus | undefined): Color => {
|
||||
switch (status) {
|
||||
case IncidentSlaStatus.OnTrack:
|
||||
return Green;
|
||||
case IncidentSlaStatus.AtRisk:
|
||||
return Yellow;
|
||||
case IncidentSlaStatus.ResponseBreached:
|
||||
case IncidentSlaStatus.ResolutionBreached:
|
||||
return Red;
|
||||
case IncidentSlaStatus.Met:
|
||||
return Green;
|
||||
default:
|
||||
return Gray500;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimeRemaining = (deadline: Date | undefined): string => {
|
||||
if (!deadline) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const now: Date = OneUptimeDate.getCurrentDate();
|
||||
const diffMinutes: number = OneUptimeDate.getDifferenceInMinutes(
|
||||
deadline,
|
||||
now,
|
||||
);
|
||||
|
||||
if (diffMinutes < 0) {
|
||||
const overdue: number = Math.abs(diffMinutes);
|
||||
if (overdue < 60) {
|
||||
return `Overdue by ${Math.round(overdue)} min`;
|
||||
}
|
||||
const hours: number = Math.floor(overdue / 60);
|
||||
if (hours < 24) {
|
||||
return `Overdue by ${hours}h ${Math.round(overdue % 60)}m`;
|
||||
}
|
||||
const days: number = Math.floor(hours / 24);
|
||||
return `Overdue by ${days}d ${hours % 24}h`;
|
||||
}
|
||||
|
||||
if (diffMinutes < 60) {
|
||||
return `${Math.round(diffMinutes)} min remaining`;
|
||||
}
|
||||
|
||||
const hours: number = Math.floor(diffMinutes / 60);
|
||||
if (hours < 24) {
|
||||
return `${hours}h ${Math.round(diffMinutes % 60)}m remaining`;
|
||||
}
|
||||
|
||||
const days: number = Math.floor(hours / 24);
|
||||
return `${days}d ${hours % 24}h remaining`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ModelTable<IncidentSla>
|
||||
modelType={IncidentSla}
|
||||
id="table-incident-sla"
|
||||
name="Incident > SLA"
|
||||
userPreferencesKey="incident-sla-table"
|
||||
isEditable={false}
|
||||
isDeleteable={false}
|
||||
isCreateable={false}
|
||||
isViewable={false}
|
||||
showViewIdButton={true}
|
||||
query={{
|
||||
incidentId: modelId,
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
}}
|
||||
sortBy="createdAt"
|
||||
sortOrder={SortOrder.Descending}
|
||||
cardProps={{
|
||||
title: "SLA Tracking",
|
||||
description:
|
||||
"View SLA status and deadlines for this incident. SLA rules are automatically applied when incidents are created.",
|
||||
}}
|
||||
noItemsMessage={"No SLA rules matched this incident."}
|
||||
showRefreshButton={true}
|
||||
viewPageRoute={Navigation.getCurrentRoute()}
|
||||
filters={[]}
|
||||
columns={[
|
||||
{
|
||||
field: {
|
||||
incidentSlaRule: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
title: "SLA Rule",
|
||||
type: FieldType.Text,
|
||||
getElement: (item: IncidentSla): ReactElement => {
|
||||
const ruleName: string =
|
||||
(item.incidentSlaRule as { name?: string })?.name || "Unknown Rule";
|
||||
return <span>{ruleName}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
status: true,
|
||||
},
|
||||
title: "Status",
|
||||
type: FieldType.Text,
|
||||
getElement: (item: IncidentSla): ReactElement => {
|
||||
const status: IncidentSlaStatus | undefined = item.status;
|
||||
return (
|
||||
<Pill
|
||||
color={getStatusColor(status)}
|
||||
text={status || "Unknown"}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
responseDeadline: true,
|
||||
},
|
||||
title: "Response Deadline",
|
||||
type: FieldType.DateTime,
|
||||
getElement: (item: IncidentSla): ReactElement => {
|
||||
if (!item.responseDeadline) {
|
||||
return <span className="text-gray-400">Not configured</span>;
|
||||
}
|
||||
|
||||
const isResponded: boolean = !!item.respondedAt;
|
||||
const deadline: Date = item.responseDeadline;
|
||||
const formattedDeadline: string =
|
||||
OneUptimeDate.getDateAsLocalFormattedString(deadline);
|
||||
|
||||
if (isResponded) {
|
||||
const respondedAt: Date = item.respondedAt as Date;
|
||||
const wasOnTime: boolean = OneUptimeDate.isBefore(
|
||||
respondedAt,
|
||||
deadline,
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<span
|
||||
className={
|
||||
wasOnTime ? "text-green-600" : "text-red-600"
|
||||
}
|
||||
>
|
||||
{wasOnTime ? "Met" : "Missed"}
|
||||
</span>
|
||||
<div className="text-xs text-gray-500">
|
||||
Deadline: {formattedDeadline}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Responded:{" "}
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(respondedAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{formattedDeadline}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatTimeRemaining(deadline)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
resolutionDeadline: true,
|
||||
},
|
||||
title: "Resolution Deadline",
|
||||
type: FieldType.DateTime,
|
||||
getElement: (item: IncidentSla): ReactElement => {
|
||||
if (!item.resolutionDeadline) {
|
||||
return <span className="text-gray-400">Not configured</span>;
|
||||
}
|
||||
|
||||
const isResolved: boolean = !!item.resolvedAt;
|
||||
const deadline: Date = item.resolutionDeadline;
|
||||
const formattedDeadline: string =
|
||||
OneUptimeDate.getDateAsLocalFormattedString(deadline);
|
||||
|
||||
if (isResolved) {
|
||||
const resolvedAt: Date = item.resolvedAt as Date;
|
||||
const wasOnTime: boolean = OneUptimeDate.isBefore(
|
||||
resolvedAt,
|
||||
deadline,
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<span
|
||||
className={
|
||||
wasOnTime ? "text-green-600" : "text-red-600"
|
||||
}
|
||||
>
|
||||
{wasOnTime ? "Met" : "Missed"}
|
||||
</span>
|
||||
<div className="text-xs text-gray-500">
|
||||
Deadline: {formattedDeadline}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Resolved:{" "}
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(resolvedAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{formattedDeadline}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatTimeRemaining(deadline)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
slaStartedAt: true,
|
||||
},
|
||||
title: "SLA Started",
|
||||
type: FieldType.DateTime,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
respondedAt: true,
|
||||
},
|
||||
title: "Responded At",
|
||||
type: FieldType.DateTime,
|
||||
noValueMessage: "Not responded",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
resolvedAt: true,
|
||||
},
|
||||
title: "Resolved At",
|
||||
type: FieldType.DateTime,
|
||||
noValueMessage: "Not resolved",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default IncidentViewSla;
|
||||
@@ -74,6 +74,12 @@ const IncidentViewStateTimeline: LazyExoticComponent<
|
||||
> = lazy(() => {
|
||||
return import("../Pages/Incidents/View/StateTimeline");
|
||||
});
|
||||
|
||||
const IncidentViewSla: LazyExoticComponent<FunctionComponent<ComponentProps>> =
|
||||
lazy(() => {
|
||||
return import("../Pages/Incidents/View/Sla");
|
||||
});
|
||||
|
||||
const IncidentInternalNote: LazyExoticComponent<
|
||||
FunctionComponent<ComponentProps>
|
||||
> = lazy(() => {
|
||||
@@ -196,6 +202,12 @@ const IncidentSettingsGroupingRules: LazyExoticComponent<
|
||||
return import("../Pages/Incidents/Settings/IncidentGroupingRules");
|
||||
});
|
||||
|
||||
const IncidentSettingsSlaRules: LazyExoticComponent<
|
||||
FunctionComponent<ComponentProps>
|
||||
> = lazy(() => {
|
||||
return import("../Pages/Incidents/Settings/IncidentSlaRules");
|
||||
});
|
||||
|
||||
const IncidentSettingsRoles: LazyExoticComponent<
|
||||
FunctionComponent<ComponentProps>
|
||||
> = lazy(() => {
|
||||
@@ -553,6 +565,22 @@ const IncidentsRoutes: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={
|
||||
IncidentsRoutePath[PageMap.INCIDENTS_SETTINGS_SLA_RULES] || ""
|
||||
}
|
||||
element={
|
||||
<Suspense fallback={Loader}>
|
||||
<IncidentSettingsSlaRules
|
||||
{...props}
|
||||
pageRoute={
|
||||
RouteMap[PageMap.INCIDENTS_SETTINGS_SLA_RULES] as Route
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={IncidentsRoutePath[PageMap.INCIDENTS_SETTINGS_ROLES] || ""}
|
||||
element={
|
||||
@@ -852,6 +880,18 @@ const IncidentsRoutes: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(PageMap.INCIDENT_VIEW_SLA)}
|
||||
element={
|
||||
<Suspense fallback={Loader}>
|
||||
<IncidentViewSla
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.INCIDENT_VIEW_SLA] as Route}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(PageMap.INCIDENT_VIEW_REMEDIATION)}
|
||||
element={
|
||||
|
||||
@@ -52,6 +52,7 @@ enum PageMap {
|
||||
|
||||
INCIDENT_VIEW_NOTIFICATION_LOGS = "INCIDENT_VIEW_NOTIFICATION_LOGS",
|
||||
INCIDENT_VIEW_AI_LOGS = "INCIDENT_VIEW_AI_LOGS",
|
||||
INCIDENT_VIEW_SLA = "INCIDENT_VIEW_SLA",
|
||||
|
||||
// Incident Episodes
|
||||
INCIDENT_EPISODES = "INCIDENT_EPISODES",
|
||||
@@ -81,6 +82,7 @@ enum PageMap {
|
||||
INCIDENTS_SETTINGS_POSTMORTEM_TEMPLATES_VIEW = "INCIDENTS_SETTINGS_POSTMORTEM_TEMPLATES_VIEW",
|
||||
INCIDENTS_SETTINGS_CUSTOM_FIELDS = "INCIDENTS_SETTINGS_CUSTOM_FIELDS",
|
||||
INCIDENTS_SETTINGS_GROUPING_RULES = "INCIDENTS_SETTINGS_GROUPING_RULES",
|
||||
INCIDENTS_SETTINGS_SLA_RULES = "INCIDENTS_SETTINGS_SLA_RULES",
|
||||
INCIDENTS_SETTINGS_ROLES = "INCIDENTS_SETTINGS_ROLES",
|
||||
|
||||
ALERTS_ROOT = "ALERTS_ROOT",
|
||||
|
||||
@@ -191,6 +191,7 @@ export const IncidentsRoutePath: Dictionary<string> = {
|
||||
[PageMap.INCIDENTS_SETTINGS_POSTMORTEM_TEMPLATES_VIEW]: `settings/postmortem-templates/${RouteParams.ModelID}`,
|
||||
[PageMap.INCIDENTS_SETTINGS_CUSTOM_FIELDS]: "settings/custom-fields",
|
||||
[PageMap.INCIDENTS_SETTINGS_GROUPING_RULES]: "settings/grouping-rules",
|
||||
[PageMap.INCIDENTS_SETTINGS_SLA_RULES]: "settings/sla-rules",
|
||||
[PageMap.INCIDENTS_SETTINGS_ROLES]: "settings/roles",
|
||||
|
||||
[PageMap.INCIDENT_VIEW]: `${RouteParams.ModelID}`,
|
||||
@@ -209,6 +210,7 @@ export const IncidentsRoutePath: Dictionary<string> = {
|
||||
[PageMap.INCIDENT_VIEW_CUSTOM_FIELDS]: `${RouteParams.ModelID}/custom-fields`,
|
||||
[PageMap.INCIDENT_VIEW_INTERNAL_NOTE]: `${RouteParams.ModelID}/internal-notes`,
|
||||
[PageMap.INCIDENT_VIEW_PUBLIC_NOTE]: `${RouteParams.ModelID}/public-notes`,
|
||||
[PageMap.INCIDENT_VIEW_SLA]: `${RouteParams.ModelID}/sla`,
|
||||
};
|
||||
|
||||
export const AlertsRoutePath: Dictionary<string> = {
|
||||
@@ -1022,6 +1024,12 @@ const RouteMap: Dictionary<Route> = {
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.INCIDENT_VIEW_SLA]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/incidents/${
|
||||
IncidentsRoutePath[PageMap.INCIDENT_VIEW_SLA]
|
||||
}`,
|
||||
),
|
||||
|
||||
// Incident Settings Routes
|
||||
[PageMap.INCIDENTS_SETTINGS_STATE]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/incidents/${
|
||||
@@ -1083,6 +1091,12 @@ const RouteMap: Dictionary<Route> = {
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.INCIDENTS_SETTINGS_SLA_RULES]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/incidents/${
|
||||
IncidentsRoutePath[PageMap.INCIDENTS_SETTINGS_SLA_RULES]
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.INCIDENTS_SETTINGS_ROLES]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/incidents/${
|
||||
IncidentsRoutePath[PageMap.INCIDENTS_SETTINGS_ROLES]
|
||||
|
||||
314
Worker/Jobs/IncidentSla/CheckSlaBreaches.ts
Normal file
314
Worker/Jobs/IncidentSla/CheckSlaBreaches.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import RunCron from "../../Utils/Cron";
|
||||
import { EVERY_MINUTE } from "Common/Utils/CronTime";
|
||||
import IncidentSlaService from "Common/Server/Services/IncidentSlaService";
|
||||
import IncidentSla from "Common/Models/DatabaseModels/IncidentSla";
|
||||
import IncidentSlaStatus from "Common/Types/Incident/IncidentSlaStatus";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
import IncidentService from "Common/Server/Services/IncidentService";
|
||||
import UserNotificationSettingService from "Common/Server/Services/UserNotificationSettingService";
|
||||
import ProjectService from "Common/Server/Services/ProjectService";
|
||||
import User from "Common/Models/DatabaseModels/User";
|
||||
import { EmailEnvelope } from "Common/Types/Email/EmailMessage";
|
||||
import EmailTemplateType from "Common/Types/Email/EmailTemplateType";
|
||||
import { SMSMessage } from "Common/Types/SMS/SMS";
|
||||
import { CallRequestMessage } from "Common/Types/Call/CallRequest";
|
||||
import NotificationSettingEventType from "Common/Types/NotificationSetting/NotificationSettingEventType";
|
||||
import Incident from "Common/Models/DatabaseModels/Incident";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
import PushNotificationMessage from "Common/Types/PushNotification/PushNotificationMessage";
|
||||
import { WhatsAppMessagePayload } from "Common/Types/WhatsApp/WhatsAppMessage";
|
||||
import { createWhatsAppMessageFromTemplate } from "Common/Server/Utils/WhatsAppTemplateUtil";
|
||||
|
||||
/**
|
||||
* This job checks SLAs for breach conditions and updates their status:
|
||||
* - OnTrack -> AtRisk: when elapsed time >= (deadline * atRiskThresholdInPercentage/100)
|
||||
* - OnTrack/AtRisk -> Breached: when current time > deadline
|
||||
* Runs every minute.
|
||||
*/
|
||||
RunCron(
|
||||
"IncidentSla:CheckSlaBreaches",
|
||||
{
|
||||
schedule: EVERY_MINUTE,
|
||||
runOnStartup: false,
|
||||
},
|
||||
async () => {
|
||||
const now: Date = OneUptimeDate.getCurrentDate();
|
||||
|
||||
// Get all SLAs that need breach checking (OnTrack or AtRisk, not resolved)
|
||||
const slasToCheck: Array<IncidentSla> =
|
||||
await IncidentSlaService.getSlasNeedingBreachCheck();
|
||||
|
||||
for (const sla of slasToCheck) {
|
||||
if (!sla.id || !sla.slaStartedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const atRiskThreshold: number =
|
||||
sla.incidentSlaRule?.atRiskThresholdInPercentage || 80;
|
||||
|
||||
let newStatus: IncidentSlaStatus | null = null;
|
||||
let breachType: "response" | "resolution" | null = null;
|
||||
|
||||
// Check response deadline first
|
||||
if (
|
||||
sla.responseDeadline &&
|
||||
!sla.respondedAt &&
|
||||
sla.status !== IncidentSlaStatus.ResponseBreached
|
||||
) {
|
||||
// Check if response deadline is breached
|
||||
if (OneUptimeDate.isAfter(now, sla.responseDeadline)) {
|
||||
newStatus = IncidentSlaStatus.ResponseBreached;
|
||||
breachType = "response";
|
||||
} else if (sla.status === IncidentSlaStatus.OnTrack) {
|
||||
// Check if at risk for response
|
||||
const totalResponseTime: number = OneUptimeDate.getDifferenceInMinutes(
|
||||
sla.responseDeadline,
|
||||
sla.slaStartedAt,
|
||||
);
|
||||
const elapsedTime: number = OneUptimeDate.getDifferenceInMinutes(
|
||||
now,
|
||||
sla.slaStartedAt,
|
||||
);
|
||||
const percentageElapsed: number =
|
||||
(elapsedTime / totalResponseTime) * 100;
|
||||
|
||||
if (percentageElapsed >= atRiskThreshold) {
|
||||
newStatus = IncidentSlaStatus.AtRisk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check resolution deadline (takes precedence over response)
|
||||
if (
|
||||
sla.resolutionDeadline &&
|
||||
sla.status !== IncidentSlaStatus.ResolutionBreached
|
||||
) {
|
||||
// Check if resolution deadline is breached
|
||||
if (OneUptimeDate.isAfter(now, sla.resolutionDeadline)) {
|
||||
newStatus = IncidentSlaStatus.ResolutionBreached;
|
||||
breachType = "resolution";
|
||||
} else if (
|
||||
sla.status === IncidentSlaStatus.OnTrack ||
|
||||
sla.status === IncidentSlaStatus.AtRisk
|
||||
) {
|
||||
// Check if at risk for resolution (only if not already breached for response)
|
||||
if (newStatus !== IncidentSlaStatus.ResponseBreached) {
|
||||
const totalResolutionTime: number =
|
||||
OneUptimeDate.getDifferenceInMinutes(
|
||||
sla.resolutionDeadline,
|
||||
sla.slaStartedAt,
|
||||
);
|
||||
const elapsedTime: number = OneUptimeDate.getDifferenceInMinutes(
|
||||
now,
|
||||
sla.slaStartedAt,
|
||||
);
|
||||
const percentageElapsed: number =
|
||||
(elapsedTime / totalResolutionTime) * 100;
|
||||
|
||||
if (
|
||||
percentageElapsed >= atRiskThreshold &&
|
||||
sla.status === IncidentSlaStatus.OnTrack
|
||||
) {
|
||||
newStatus = IncidentSlaStatus.AtRisk;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update status if changed
|
||||
if (newStatus && newStatus !== sla.status) {
|
||||
const updateData: {
|
||||
status: IncidentSlaStatus;
|
||||
breachNotificationSentAt?: Date;
|
||||
} = {
|
||||
status: newStatus,
|
||||
};
|
||||
|
||||
// If breached, mark notification as being sent now
|
||||
if (
|
||||
(newStatus === IncidentSlaStatus.ResponseBreached ||
|
||||
newStatus === IncidentSlaStatus.ResolutionBreached) &&
|
||||
!sla.breachNotificationSentAt
|
||||
) {
|
||||
updateData.breachNotificationSentAt = now;
|
||||
}
|
||||
|
||||
await IncidentSlaService.updateOneById({
|
||||
id: sla.id,
|
||||
data: updateData,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`SLA ${sla.id} status changed from ${sla.status} to ${newStatus}`,
|
||||
);
|
||||
|
||||
// Send breach notification if breached
|
||||
if (
|
||||
breachType &&
|
||||
(newStatus === IncidentSlaStatus.ResponseBreached ||
|
||||
newStatus === IncidentSlaStatus.ResolutionBreached)
|
||||
) {
|
||||
await sendBreachNotification({
|
||||
sla,
|
||||
breachType,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error checking SLA breach for ${sla.id}: ${error}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function sendBreachNotification(data: {
|
||||
sla: IncidentSla;
|
||||
breachType: "response" | "resolution";
|
||||
}): Promise<void> {
|
||||
const { sla, breachType } = data;
|
||||
|
||||
if (!sla.incidentId || !sla.projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the incident details
|
||||
const incident: Incident | null = await IncidentService.findOneById({
|
||||
id: sla.incidentId,
|
||||
select: {
|
||||
_id: true,
|
||||
title: true,
|
||||
incidentNumber: true,
|
||||
projectId: true,
|
||||
project: {
|
||||
name: true,
|
||||
},
|
||||
currentIncidentState: {
|
||||
name: true,
|
||||
},
|
||||
incidentSeverity: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!incident) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get incident owners
|
||||
let owners: Array<User> = await IncidentService.findOwners(sla.incidentId);
|
||||
|
||||
if (owners.length === 0) {
|
||||
// Fall back to project owners
|
||||
owners = await ProjectService.getOwners(sla.projectId);
|
||||
}
|
||||
|
||||
if (owners.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const incidentNumberStr: string = incident.incidentNumber
|
||||
? `#${incident.incidentNumber}`
|
||||
: "";
|
||||
|
||||
const incidentViewLink: string = (
|
||||
await IncidentService.getIncidentLinkInDashboard(
|
||||
incident.projectId!,
|
||||
incident.id!,
|
||||
)
|
||||
).toString();
|
||||
|
||||
const breachTypeStr: string =
|
||||
breachType === "response" ? "Response" : "Resolution";
|
||||
|
||||
const deadline: Date | undefined =
|
||||
breachType === "response" ? sla.responseDeadline : sla.resolutionDeadline;
|
||||
|
||||
const deadlineStr: string = deadline
|
||||
? OneUptimeDate.getDateAsLocalFormattedString(deadline)
|
||||
: "N/A";
|
||||
|
||||
const ruleName: string = sla.incidentSlaRule?.name || "SLA Rule";
|
||||
|
||||
const vars: Dictionary<string> = {
|
||||
incidentTitle: incident.title!,
|
||||
incidentNumber: incidentNumberStr,
|
||||
projectName: incident.project!.name!,
|
||||
currentState: incident.currentIncidentState?.name || "Unknown",
|
||||
incidentSeverity: incident.incidentSeverity?.name || "Unknown",
|
||||
breachType: breachTypeStr,
|
||||
deadline: deadlineStr,
|
||||
slaRuleName: ruleName,
|
||||
incidentViewLink: incidentViewLink,
|
||||
};
|
||||
|
||||
for (const user of owners) {
|
||||
const emailMessage: EmailEnvelope = {
|
||||
templateType: EmailTemplateType.IncidentOwnerResourceCreated, // Using a generic template
|
||||
vars: {
|
||||
...vars,
|
||||
subject: `[SLA ${breachTypeStr} Breached] Incident ${incidentNumberStr} - ${incident.title}`,
|
||||
},
|
||||
subject: `[SLA ${breachTypeStr} Breached] Incident ${incidentNumberStr} - ${incident.title}`,
|
||||
};
|
||||
|
||||
const sms: SMSMessage = {
|
||||
message: `SLA ${breachTypeStr} Breached for incident ${incident.title} ${incidentNumberStr}. Deadline was ${deadlineStr}. View incident in OneUptime Dashboard.`,
|
||||
};
|
||||
|
||||
const callMessage: CallRequestMessage = {
|
||||
data: [
|
||||
{
|
||||
sayMessage: `This is an alert from OneUptime. SLA ${breachTypeStr} has been breached for incident ${incident.title}. The deadline was ${deadlineStr}. Please check the incident in OneUptime Dashboard immediately.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventType: NotificationSettingEventType =
|
||||
NotificationSettingEventType.SEND_INCIDENT_CREATED_OWNER_NOTIFICATION;
|
||||
|
||||
const pushMessage: PushNotificationMessage = {
|
||||
title: `SLA ${breachTypeStr} Breached`,
|
||||
body: `SLA ${breachTypeStr} breached for incident ${incident.title} ${incidentNumberStr}`,
|
||||
};
|
||||
|
||||
const whatsAppMessage: WhatsAppMessagePayload =
|
||||
createWhatsAppMessageFromTemplate({
|
||||
eventType,
|
||||
templateVariables: {
|
||||
incident_title: incident.title!,
|
||||
incident_number: incident.incidentNumber?.toString() || "",
|
||||
incident_link: incidentViewLink,
|
||||
},
|
||||
});
|
||||
|
||||
await UserNotificationSettingService.sendUserNotification({
|
||||
userId: user.id!,
|
||||
projectId: sla.projectId,
|
||||
emailEnvelope: emailMessage,
|
||||
smsMessage: sms,
|
||||
callRequestMessage: callMessage,
|
||||
pushNotificationMessage: pushMessage,
|
||||
whatsAppMessage,
|
||||
eventType,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Sent SLA ${breachType} breach notification for incident ${sla.incidentId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error sending SLA breach notification for incident ${sla.incidentId}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
288
Worker/Jobs/IncidentSla/SendNoteReminders.ts
Normal file
288
Worker/Jobs/IncidentSla/SendNoteReminders.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import RunCron from "../../Utils/Cron";
|
||||
import { EVERY_MINUTE } from "Common/Utils/CronTime";
|
||||
import IncidentSlaService from "Common/Server/Services/IncidentSlaService";
|
||||
import IncidentSla from "Common/Models/DatabaseModels/IncidentSla";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
import IncidentInternalNoteService from "Common/Server/Services/IncidentInternalNoteService";
|
||||
import IncidentPublicNoteService from "Common/Server/Services/IncidentPublicNoteService";
|
||||
import IncidentService from "Common/Server/Services/IncidentService";
|
||||
import Incident from "Common/Models/DatabaseModels/Incident";
|
||||
import IncidentInternalNote from "Common/Models/DatabaseModels/IncidentInternalNote";
|
||||
import IncidentPublicNote from "Common/Models/DatabaseModels/IncidentPublicNote";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
|
||||
/**
|
||||
* This job sends automatic internal and public note reminders for incidents
|
||||
* based on the SLA rule configuration.
|
||||
* Runs every minute.
|
||||
*/
|
||||
|
||||
// Default templates
|
||||
const DEFAULT_INTERNAL_NOTE_TEMPLATE: string = `**SLA Reminder**: This incident has been open for {{elapsedTime}}.
|
||||
|
||||
- Response Deadline: {{responseDeadline}}
|
||||
- Resolution Deadline: {{resolutionDeadline}}
|
||||
- Current Status: {{slaStatus}}
|
||||
|
||||
Please provide an update on the incident progress.`;
|
||||
|
||||
const DEFAULT_PUBLIC_NOTE_TEMPLATE: string = `**Status Update**: Our team continues to work on resolving this incident.
|
||||
|
||||
- Current Status: {{slaStatus}}
|
||||
- Time Open: {{elapsedTime}}
|
||||
|
||||
We appreciate your patience and will provide another update soon.`;
|
||||
|
||||
RunCron(
|
||||
"IncidentSla:SendNoteReminders",
|
||||
{
|
||||
schedule: EVERY_MINUTE,
|
||||
runOnStartup: false,
|
||||
},
|
||||
async () => {
|
||||
// Process internal note reminders
|
||||
await processInternalNoteReminders();
|
||||
|
||||
// Process public note reminders
|
||||
await processPublicNoteReminders();
|
||||
},
|
||||
);
|
||||
|
||||
async function processInternalNoteReminders(): Promise<void> {
|
||||
try {
|
||||
const slasNeedingReminder: Array<IncidentSla> =
|
||||
await IncidentSlaService.getIncidentsNeedingInternalNoteReminder();
|
||||
|
||||
for (const sla of slasNeedingReminder) {
|
||||
if (!sla.id || !sla.incidentId || !sla.projectId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get incident details for template variables
|
||||
const incident: Incident | null = await IncidentService.findOneById({
|
||||
id: sla.incidentId,
|
||||
select: {
|
||||
_id: true,
|
||||
title: true,
|
||||
incidentNumber: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!incident) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the template from the rule or use default
|
||||
const template: string =
|
||||
sla.incidentSlaRule?.internalNoteReminderTemplate ||
|
||||
DEFAULT_INTERNAL_NOTE_TEMPLATE;
|
||||
|
||||
// Process template with variables
|
||||
const noteContent: string = processTemplate(template, sla, incident);
|
||||
|
||||
// Create the internal note
|
||||
const internalNote: IncidentInternalNote = new IncidentInternalNote();
|
||||
internalNote.incidentId = sla.incidentId;
|
||||
internalNote.projectId = sla.projectId;
|
||||
internalNote.note = noteContent;
|
||||
internalNote.isOwnerNotified = true; // Mark as already notified since this is automated
|
||||
|
||||
await IncidentInternalNoteService.create({
|
||||
data: internalNote,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Update the last reminder sent timestamp
|
||||
await IncidentSlaService.updateOneById({
|
||||
id: sla.id,
|
||||
data: {
|
||||
lastInternalNoteReminderSentAt: OneUptimeDate.getCurrentDate(),
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Sent internal note reminder for incident ${sla.incidentId} (SLA ${sla.id})`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error sending internal note reminder for SLA ${sla.id}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error processing internal note reminders: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function processPublicNoteReminders(): Promise<void> {
|
||||
try {
|
||||
const slasNeedingReminder: Array<IncidentSla> =
|
||||
await IncidentSlaService.getIncidentsNeedingPublicNoteReminder();
|
||||
|
||||
for (const sla of slasNeedingReminder) {
|
||||
if (!sla.id || !sla.incidentId || !sla.projectId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get incident details for template variables
|
||||
const incident: Incident | null = await IncidentService.findOneById({
|
||||
id: sla.incidentId,
|
||||
select: {
|
||||
_id: true,
|
||||
title: true,
|
||||
incidentNumber: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!incident) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the template from the rule or use default
|
||||
const template: string =
|
||||
sla.incidentSlaRule?.publicNoteReminderTemplate ||
|
||||
DEFAULT_PUBLIC_NOTE_TEMPLATE;
|
||||
|
||||
// Process template with variables
|
||||
const noteContent: string = processTemplate(template, sla, incident);
|
||||
|
||||
// Create the public note
|
||||
const publicNote: IncidentPublicNote = new IncidentPublicNote();
|
||||
publicNote.incidentId = sla.incidentId;
|
||||
publicNote.projectId = sla.projectId;
|
||||
publicNote.note = noteContent;
|
||||
publicNote.isOwnerNotified = true; // Mark as already notified since this is automated
|
||||
publicNote.postedAt = OneUptimeDate.getCurrentDate();
|
||||
|
||||
await IncidentPublicNoteService.create({
|
||||
data: publicNote,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Update the last reminder sent timestamp
|
||||
await IncidentSlaService.updateOneById({
|
||||
id: sla.id,
|
||||
data: {
|
||||
lastPublicNoteReminderSentAt: OneUptimeDate.getCurrentDate(),
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Sent public note reminder for incident ${sla.incidentId} (SLA ${sla.id})`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error sending public note reminder for SLA ${sla.id}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error processing public note reminders: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
function processTemplate(
|
||||
template: string,
|
||||
sla: IncidentSla,
|
||||
incident: Incident,
|
||||
): string {
|
||||
const now: Date = OneUptimeDate.getCurrentDate();
|
||||
|
||||
// Calculate elapsed time
|
||||
const elapsedMinutes: number = sla.slaStartedAt
|
||||
? OneUptimeDate.getDifferenceInMinutes(now, sla.slaStartedAt)
|
||||
: 0;
|
||||
|
||||
const elapsedTime: string = formatDuration(elapsedMinutes);
|
||||
|
||||
// Calculate time to deadlines
|
||||
const timeToResponseDeadline: string = sla.responseDeadline
|
||||
? formatDuration(
|
||||
OneUptimeDate.getDifferenceInMinutes(sla.responseDeadline, now),
|
||||
)
|
||||
: "N/A";
|
||||
|
||||
const timeToResolutionDeadline: string = sla.resolutionDeadline
|
||||
? formatDuration(
|
||||
OneUptimeDate.getDifferenceInMinutes(sla.resolutionDeadline, now),
|
||||
)
|
||||
: "N/A";
|
||||
|
||||
// Format deadlines
|
||||
const responseDeadline: string = sla.responseDeadline
|
||||
? OneUptimeDate.getDateAsLocalFormattedString(sla.responseDeadline)
|
||||
: "N/A";
|
||||
|
||||
const resolutionDeadline: string = sla.resolutionDeadline
|
||||
? OneUptimeDate.getDateAsLocalFormattedString(sla.resolutionDeadline)
|
||||
: "N/A";
|
||||
|
||||
// Replace template variables
|
||||
let result: string = template;
|
||||
|
||||
result = result.replace(/\{\{incidentTitle\}\}/g, incident.title || "");
|
||||
result = result.replace(
|
||||
/\{\{incidentNumber\}\}/g,
|
||||
incident.incidentNumber?.toString() || "",
|
||||
);
|
||||
result = result.replace(/\{\{elapsedTime\}\}/g, elapsedTime);
|
||||
result = result.replace(/\{\{responseDeadline\}\}/g, responseDeadline);
|
||||
result = result.replace(/\{\{resolutionDeadline\}\}/g, resolutionDeadline);
|
||||
result = result.replace(/\{\{slaStatus\}\}/g, sla.status || "");
|
||||
result = result.replace(
|
||||
/\{\{timeToResponseDeadline\}\}/g,
|
||||
timeToResponseDeadline,
|
||||
);
|
||||
result = result.replace(
|
||||
/\{\{timeToResolutionDeadline\}\}/g,
|
||||
timeToResolutionDeadline,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
if (minutes < 0) {
|
||||
return "Overdue by " + formatDuration(Math.abs(minutes));
|
||||
}
|
||||
|
||||
if (minutes < 60) {
|
||||
return `${Math.round(minutes)} minutes`;
|
||||
}
|
||||
|
||||
const hours: number = Math.floor(minutes / 60);
|
||||
const remainingMinutes: number = Math.round(minutes % 60);
|
||||
|
||||
if (hours < 24) {
|
||||
if (remainingMinutes > 0) {
|
||||
return `${hours} hours ${remainingMinutes} minutes`;
|
||||
}
|
||||
return `${hours} hours`;
|
||||
}
|
||||
|
||||
const days: number = Math.floor(hours / 24);
|
||||
const remainingHours: number = hours % 24;
|
||||
|
||||
if (remainingHours > 0) {
|
||||
return `${days} days ${remainingHours} hours`;
|
||||
}
|
||||
return `${days} days`;
|
||||
}
|
||||
@@ -16,6 +16,10 @@ import "./Jobs/IncidentOwners/SendStateChangeNotification";
|
||||
// Incident Members
|
||||
import "./Jobs/IncidentMembers/SendMemberAddedNotification";
|
||||
|
||||
// Incident SLA
|
||||
import "./Jobs/IncidentSla/CheckSlaBreaches";
|
||||
import "./Jobs/IncidentSla/SendNoteReminders";
|
||||
|
||||
// Monitor Jobs.
|
||||
import "./Jobs/Monitor/KeepCurrentStateConsistent";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user