mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
feat: add IncidentGroupingRuleService and IncidentGroupingRules page
- Implemented IncidentGroupingRuleService to manage incident grouping rules with automatic deletion of old records if billing is enabled. - Created IncidentGroupingRules page for managing incident grouping rules, including detailed documentation on grouping logic, match criteria, and configuration options. - Added UI components for creating, editing, and displaying incident grouping rules with various filtering and grouping options.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import IncidentGroupingRule from "./IncidentGroupingRule";
|
||||
import IncidentSeverity from "./IncidentSeverity";
|
||||
import IncidentState from "./IncidentState";
|
||||
import Label from "./Label";
|
||||
@@ -1052,6 +1053,73 @@ export default class IncidentEpisode extends BaseModel {
|
||||
})
|
||||
public groupingKey?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateIncidentEpisode,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentEpisode,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "incidentGroupingRuleId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: IncidentGroupingRule,
|
||||
title: "Incident Grouping Rule",
|
||||
description:
|
||||
"Relation to the Incident Grouping Rule that created this episode (if applicable)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return IncidentGroupingRule;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "incidentGroupingRuleId" })
|
||||
public incidentGroupingRule?: IncidentGroupingRule = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateIncidentEpisode,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentEpisode,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: false,
|
||||
title: "Incident Grouping Rule ID",
|
||||
description:
|
||||
"ID of the Incident Grouping Rule that created this episode (if applicable)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public incidentGroupingRuleId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Incident from "./Incident";
|
||||
import IncidentEpisode from "./IncidentEpisode";
|
||||
import IncidentGroupingRule from "./IncidentGroupingRule";
|
||||
import Project from "./Project";
|
||||
import User from "./User";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
@@ -397,6 +398,43 @@ export default class IncidentEpisodeMember extends BaseModel {
|
||||
})
|
||||
public addedByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateIncidentEpisodeMember,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentEpisodeMember,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "matchedRuleId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: IncidentGroupingRule,
|
||||
title: "Matched Grouping Rule",
|
||||
description:
|
||||
"Relation to the Incident Grouping Rule that matched this incident (if applicable)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return IncidentGroupingRule;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "matchedRuleId" })
|
||||
public matchedRule?: IncidentGroupingRule = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
|
||||
1430
Common/Models/DatabaseModels/IncidentGroupingRule.ts
Normal file
1430
Common/Models/DatabaseModels/IncidentGroupingRule.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -205,6 +205,7 @@ import IncidentEpisodeOwnerUser from "./IncidentEpisodeOwnerUser";
|
||||
import IncidentEpisodeOwnerTeam from "./IncidentEpisodeOwnerTeam";
|
||||
import IncidentEpisodeInternalNote from "./IncidentEpisodeInternalNote";
|
||||
import IncidentEpisodeFeed from "./IncidentEpisodeFeed";
|
||||
import IncidentGroupingRule from "./IncidentGroupingRule";
|
||||
|
||||
import TableView from "./TableView";
|
||||
import Dashboard from "./Dashboard";
|
||||
@@ -306,6 +307,7 @@ const AllModelTypes: Array<{
|
||||
IncidentEpisodeOwnerTeam,
|
||||
IncidentEpisodeInternalNote,
|
||||
IncidentEpisodeFeed,
|
||||
IncidentGroupingRule,
|
||||
|
||||
MonitorStatusTimeline,
|
||||
|
||||
|
||||
1021
Common/Server/Services/IncidentGroupingEngineService.ts
Normal file
1021
Common/Server/Services/IncidentGroupingEngineService.ts
Normal file
File diff suppressed because it is too large
Load Diff
14
Common/Server/Services/IncidentGroupingRuleService.ts
Normal file
14
Common/Server/Services/IncidentGroupingRuleService.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import Model from "../../Models/DatabaseModels/IncidentGroupingRule";
|
||||
import { IsBillingEnabled } from "../EnvironmentConfig";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
super(Model);
|
||||
if (IsBillingEnabled) {
|
||||
this.hardDeleteItemsOlderThanInDays("createdAt", 3 * 365); // 3 years
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new Service();
|
||||
@@ -49,6 +49,7 @@ import Semaphore, {
|
||||
} from "../../Server/Infrastructure/Semaphore";
|
||||
import IncidentFeedService from "./IncidentFeedService";
|
||||
import { IncidentFeedEventType } from "../../Models/DatabaseModels/IncidentFeed";
|
||||
import IncidentGroupingEngineService from "./IncidentGroupingEngineService";
|
||||
import { Blue500, Gray500, Red500 } from "../../Types/BrandColors";
|
||||
import Label from "../../Models/DatabaseModels/Label";
|
||||
import LabelService from "./LabelService";
|
||||
@@ -841,6 +842,16 @@ export class Service extends DatabaseService<Model> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
// Process incident for grouping into episodes
|
||||
try {
|
||||
await IncidentGroupingEngineService.processIncident(createdItem);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Incident grouping failed in IncidentService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
logger.error(
|
||||
`Critical error in IncidentService sequential operations: ${error}`,
|
||||
|
||||
@@ -789,6 +789,12 @@ enum Permission {
|
||||
CreateIncidentEpisodeFeed = "CreateIncidentEpisodeFeed",
|
||||
EditIncidentEpisodeFeed = "EditIncidentEpisodeFeed",
|
||||
ReadIncidentEpisodeFeed = "ReadIncidentEpisodeFeed",
|
||||
|
||||
// Incident Grouping Rule Permissions
|
||||
CreateIncidentGroupingRule = "CreateIncidentGroupingRule",
|
||||
DeleteIncidentGroupingRule = "DeleteIncidentGroupingRule",
|
||||
EditIncidentGroupingRule = "EditIncidentGroupingRule",
|
||||
ReadIncidentGroupingRule = "ReadIncidentGroupingRule",
|
||||
}
|
||||
|
||||
export class PermissionHelper {
|
||||
@@ -5597,6 +5603,40 @@ export class PermissionHelper {
|
||||
isAssignableToTenant: true,
|
||||
isAccessControlPermission: false,
|
||||
},
|
||||
|
||||
// Incident Grouping Rule Permissions
|
||||
{
|
||||
permission: Permission.CreateIncidentGroupingRule,
|
||||
title: "Create Incident Grouping Rule",
|
||||
description:
|
||||
"This permission can create Incident Grouping Rules in this project.",
|
||||
isAssignableToTenant: true,
|
||||
isAccessControlPermission: false,
|
||||
},
|
||||
{
|
||||
permission: Permission.DeleteIncidentGroupingRule,
|
||||
title: "Delete Incident Grouping Rule",
|
||||
description:
|
||||
"This permission can delete Incident Grouping Rules of this project.",
|
||||
isAssignableToTenant: true,
|
||||
isAccessControlPermission: false,
|
||||
},
|
||||
{
|
||||
permission: Permission.EditIncidentGroupingRule,
|
||||
title: "Edit Incident Grouping Rule",
|
||||
description:
|
||||
"This permission can edit Incident Grouping Rules of this project.",
|
||||
isAssignableToTenant: true,
|
||||
isAccessControlPermission: false,
|
||||
},
|
||||
{
|
||||
permission: Permission.ReadIncidentGroupingRule,
|
||||
title: "Read Incident Grouping Rule",
|
||||
description:
|
||||
"This permission can read Incident Grouping Rules of this project.",
|
||||
isAssignableToTenant: true,
|
||||
isAccessControlPermission: false,
|
||||
},
|
||||
];
|
||||
|
||||
return permissions;
|
||||
|
||||
713
Dashboard/src/Pages/Incidents/Settings/IncidentGroupingRules.tsx
Normal file
713
Dashboard/src/Pages/Incidents/Settings/IncidentGroupingRules.tsx
Normal file
@@ -0,0 +1,713 @@
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import FormValues from "Common/UI/Components/Forms/Types/FormValues";
|
||||
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 IncidentGroupingRule from "Common/Models/DatabaseModels/IncidentGroupingRule";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
import { Green, Red } from "Common/Types/BrandColors";
|
||||
import OnCallDutyPolicy from "Common/Models/DatabaseModels/OnCallDutyPolicy";
|
||||
import Team from "Common/Models/DatabaseModels/Team";
|
||||
import ProjectUser from "../../../Utils/ProjectUser";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
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 Grouping Works
|
||||
|
||||
Incident grouping automatically combines related incidents into logical containers called **Episodes**. This reduces incident fatigue by showing one episode with 50 incidents instead of 50 individual notifications.
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart TD
|
||||
A[New Incident Created] --> B{Match Against Rules}
|
||||
B -->|Rule Matches| C{Find Existing Episode}
|
||||
B -->|No Match| D[Incident Stays Ungrouped]
|
||||
C -->|Episode Found| E[Add Incident to Episode]
|
||||
C -->|No Episode| F[Create New Episode]
|
||||
E --> G[Update Episode Count]
|
||||
F --> H[Execute On-Call Policy]
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
### Match Criteria vs Group By
|
||||
|
||||
These two concepts work together but serve different purposes:
|
||||
|
||||
| Aspect | Match Criteria | Group By |
|
||||
|--------|---------------|----------|
|
||||
| **Purpose** | Filter which incidents this rule applies to | Partition matching incidents into separate episodes |
|
||||
| **Question** | "Does this incident qualify for this rule?" | "Which episode does this incident go into?" |
|
||||
| **Example** | Only Critical incidents from production monitors | Separate episode per monitor |
|
||||
|
||||
#### Match Criteria (Filtering)
|
||||
|
||||
Match criteria acts as a **filter** that determines which incidents are eligible for this grouping rule. An incident must pass ALL specified criteria to be processed by the rule.
|
||||
|
||||
- **Monitors**: Only incidents from these specific monitors
|
||||
- **Severities**: Only incidents with these severity levels
|
||||
- **Labels**: Only incidents with at least one of these labels
|
||||
- **Title/Description Patterns**: Regex patterns to match incident content
|
||||
|
||||
#### Group By (Partitioning)
|
||||
|
||||
Group By determines **how matching incidents are subdivided** into separate episodes. This creates the "grouping key" that identifies which episode an incident belongs to.
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart LR
|
||||
subgraph "Match Criteria: Severity = Critical"
|
||||
A1[Incident: CPU High<br/>Monitor A]
|
||||
A2[Incident: Memory Low<br/>Monitor A]
|
||||
A3[Incident: CPU High<br/>Monitor B]
|
||||
A4[Incident: Disk Full<br/>Monitor B]
|
||||
end
|
||||
|
||||
subgraph "Group By: Monitor"
|
||||
E1[Episode 1<br/>Monitor A<br/>2 incidents]
|
||||
E2[Episode 2<br/>Monitor B<br/>2 incidents]
|
||||
end
|
||||
|
||||
A1 --> E1
|
||||
A2 --> E1
|
||||
A3 --> E2
|
||||
A4 --> E2
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
### Group By Options Explained
|
||||
|
||||
| Option | When Enabled | When Disabled |
|
||||
|--------|-------------|---------------|
|
||||
| **Group By Service** | Incidents from monitors in different services → separate episodes | Incidents can be grouped regardless of service |
|
||||
| **Group By Monitor** | Incidents from different monitors → separate episodes | Incidents from any monitor can be grouped together |
|
||||
| **Group By Severity** | Incidents with different severities → separate episodes | Incidents of any severity can be grouped together |
|
||||
| **Group By Incident Title** | Incidents with different titles → separate episodes | Incidents with any title can be grouped together |
|
||||
|
||||
> **Note:** Monitors can be attached to Services. A Service can have multiple monitors. Group By Service is useful when you want to group all incidents from a service together regardless of which specific monitor triggered them.
|
||||
|
||||
#### Default Behavior
|
||||
|
||||
**If NO Group By options are enabled**, all matching incidents go into **ONE single episode**. This is useful when you want to group all related incidents regardless of their source.
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart TD
|
||||
subgraph "No Group By Enabled"
|
||||
direction TB
|
||||
A1[Incident 1] --> E[Single Episode<br/>All Matching Incidents]
|
||||
A2[Incident 2] --> E
|
||||
A3[Incident 3] --> E
|
||||
A4[Incident 4] --> E
|
||||
end
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
### Examples
|
||||
|
||||
#### Example 1: Group all Critical incidents by Monitor
|
||||
|
||||
**Configuration:**
|
||||
- Match Criteria: Severity = Critical
|
||||
- Group By: Monitor
|
||||
|
||||
**Result:** Each monitor gets its own episode for critical incidents.
|
||||
|
||||
#### Example 2: Single episode for all database incidents
|
||||
|
||||
**Configuration:**
|
||||
- Match Criteria: Monitor Labels = "database"
|
||||
- Group By: (none enabled)
|
||||
|
||||
**Result:** ALL database incidents go into one episode, regardless of which specific database monitor they come from.
|
||||
|
||||
#### Example 3: Fine-grained grouping
|
||||
|
||||
**Configuration:**
|
||||
- Match Criteria: (none - matches all incidents)
|
||||
- Group By: Monitor, Severity, Incident Title
|
||||
|
||||
**Result:** Very specific episodes - one per unique combination of monitor + severity + title.
|
||||
`;
|
||||
|
||||
const IncidentGroupingRulesPage: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
return (
|
||||
<Fragment>
|
||||
<ModelTable<IncidentGroupingRule>
|
||||
modelType={IncidentGroupingRule}
|
||||
id="incident-grouping-rules-table"
|
||||
name="Settings > Incident Grouping Rules"
|
||||
userPreferencesKey="incident-grouping-rules-table"
|
||||
isDeleteable={true}
|
||||
isEditable={true}
|
||||
isCreateable={true}
|
||||
cardProps={{
|
||||
title: "Incident Grouping Rules",
|
||||
description:
|
||||
"Define rules to automatically group related incidents into episodes. Rules are evaluated in priority order - lower priority numbers are evaluated first.",
|
||||
}}
|
||||
helpContent={{
|
||||
title: "How Incident Grouping Rules Work",
|
||||
description:
|
||||
"Understanding Match Criteria, Group By, and how incidents are organized into episodes",
|
||||
markdown: documentationMarkdown,
|
||||
}}
|
||||
sortBy="priority"
|
||||
sortOrder={SortOrder.Ascending}
|
||||
selectMoreFields={{
|
||||
priority: 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: {
|
||||
priority: true,
|
||||
},
|
||||
title: "Priority",
|
||||
type: FieldType.Number,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isEnabled: true,
|
||||
},
|
||||
title: "Status",
|
||||
type: FieldType.Boolean,
|
||||
getElement: (item: IncidentGroupingRule): ReactElement => {
|
||||
if (item.isEnabled) {
|
||||
return <Pill color={Green} text="Enabled" />;
|
||||
}
|
||||
return <Pill color={Red} text="Disabled" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
timeWindowMinutes: true,
|
||||
},
|
||||
title: "Time Window (min)",
|
||||
type: FieldType.Number,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
inactivityTimeoutMinutes: true,
|
||||
},
|
||||
title: "Inactivity Timeout (min)",
|
||||
type: FieldType.Number,
|
||||
},
|
||||
]}
|
||||
viewPageRoute={Navigation.getCurrentRoute()}
|
||||
formSteps={[
|
||||
{
|
||||
title: "Basic Info",
|
||||
id: "basic-info",
|
||||
},
|
||||
{
|
||||
title: "Match Criteria",
|
||||
id: "match-criteria",
|
||||
},
|
||||
{
|
||||
title: "Group By",
|
||||
id: "group-by",
|
||||
},
|
||||
{
|
||||
title: "Time Settings",
|
||||
id: "time-settings",
|
||||
},
|
||||
{
|
||||
title: "Episode Template",
|
||||
id: "episode-template",
|
||||
},
|
||||
{
|
||||
title: "On-Call & Ownership",
|
||||
id: "on-call-ownership",
|
||||
},
|
||||
]}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
stepId: "basic-info",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: true,
|
||||
placeholder: "Critical Service Incidents",
|
||||
validation: {
|
||||
minLength: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
description: true,
|
||||
},
|
||||
title: "Description",
|
||||
stepId: "basic-info",
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
required: false,
|
||||
placeholder: "Groups all critical incidents from production services",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
priority: true,
|
||||
},
|
||||
title: "Priority",
|
||||
stepId: "basic-info",
|
||||
fieldType: FormFieldSchemaType.Number,
|
||||
required: true,
|
||||
placeholder: "1",
|
||||
description:
|
||||
"Lower numbers have higher priority. Rules are evaluated in order.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isEnabled: true,
|
||||
},
|
||||
title: "Enabled",
|
||||
stepId: "basic-info",
|
||||
fieldType: FormFieldSchemaType.Toggle,
|
||||
required: false,
|
||||
description: "Enable or disable this grouping rule.",
|
||||
},
|
||||
// Match Criteria Fields
|
||||
{
|
||||
field: {
|
||||
monitors: true,
|
||||
},
|
||||
title: "Monitors",
|
||||
stepId: "match-criteria",
|
||||
fieldType: FormFieldSchemaType.MultiSelectDropdown,
|
||||
dropdownModal: {
|
||||
type: Monitor,
|
||||
labelField: "name",
|
||||
valueField: "_id",
|
||||
},
|
||||
required: false,
|
||||
description:
|
||||
"Only group 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 group 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 group incidents that have at least one of these labels attached to them. Leave empty to match incidents regardless of incident 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 group 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. Example: 'CPU.*high' matches titles containing 'CPU' followed by 'high'.",
|
||||
},
|
||||
{
|
||||
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. Example: 'timeout|connection refused' matches descriptions containing either phrase.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
monitorNamePattern: true,
|
||||
},
|
||||
title: "Monitor Name Pattern",
|
||||
stepId: "match-criteria",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: false,
|
||||
placeholder: "prod-.*|api-server-.*",
|
||||
description:
|
||||
"Regular expression pattern to match monitor names. Leave empty to match any monitor. Example: 'prod-.*' matches monitors starting with 'prod-'.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
monitorDescriptionPattern: true,
|
||||
},
|
||||
title: "Monitor Description Pattern",
|
||||
stepId: "match-criteria",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: false,
|
||||
placeholder: "production|critical",
|
||||
description:
|
||||
"Regular expression pattern to match monitor descriptions. Leave empty to match any description.",
|
||||
},
|
||||
// Group By Fields
|
||||
{
|
||||
field: {
|
||||
groupByService: true,
|
||||
},
|
||||
title: "Group By Service",
|
||||
stepId: "group-by",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
description:
|
||||
"When enabled, incidents from monitors belonging to different services will be grouped into separate episodes. Monitors can be attached to services, and a service can have multiple monitors.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
groupByMonitor: true,
|
||||
},
|
||||
title: "Group By Monitor",
|
||||
stepId: "group-by",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
description:
|
||||
"When enabled, incidents from different monitors will be grouped into separate episodes. When disabled, incidents from any monitor can be grouped together.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
groupBySeverity: true,
|
||||
},
|
||||
title: "Group By Incident Severity",
|
||||
stepId: "group-by",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
description:
|
||||
"When enabled, incidents with different severities will be grouped into separate episodes. When disabled, incidents of any severity can be grouped together.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
groupByIncidentTitle: true,
|
||||
},
|
||||
title: "Group By Incident Title",
|
||||
stepId: "group-by",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
description:
|
||||
"When enabled, incidents with different titles will be grouped into separate episodes. When disabled, incidents with any title can be grouped together.",
|
||||
},
|
||||
// Time Settings Fields
|
||||
{
|
||||
field: {
|
||||
enableTimeWindow: true,
|
||||
},
|
||||
title: "Enable Time Window",
|
||||
stepId: "time-settings",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
description:
|
||||
"Enable time-based grouping to limit how long an episode stays open for new incidents. When disabled, all incidents matching the grouping criteria (severity, title, monitor, etc.) will be grouped into a single ongoing episode regardless of when they occur. When enabled, incidents are only grouped if they arrive within the specified time window of the last incident.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
timeWindowMinutes: true,
|
||||
},
|
||||
title: "Time Window (minutes)",
|
||||
stepId: "time-settings",
|
||||
fieldType: FormFieldSchemaType.Number,
|
||||
required: false,
|
||||
placeholder: "60",
|
||||
showIf: (model: FormValues<IncidentGroupingRule>): boolean => {
|
||||
return model.enableTimeWindow === true;
|
||||
},
|
||||
description:
|
||||
"Rolling window that determines how long an episode stays open for new incidents. Incidents arriving within this time gap of the last incident will be grouped into the same episode. For example, if set to 60 minutes, incidents will keep grouping as long as each new incident arrives within 60 minutes of the previous one.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
enableResolveDelay: true,
|
||||
},
|
||||
title: "Enable Resolve Delay",
|
||||
stepId: "time-settings",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
description:
|
||||
"Enable this to add a grace period before auto-resolving an episode after all its incidents are resolved. This helps prevent unnecessary state changes during incident flapping - when incidents rapidly toggle between triggered and resolved states. Without this, the episode would resolve immediately when incidents resolve, then potentially reopen moments later if the issue recurs.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
resolveDelayMinutes: true,
|
||||
},
|
||||
title: "Resolve Delay (minutes)",
|
||||
stepId: "time-settings",
|
||||
fieldType: FormFieldSchemaType.Number,
|
||||
required: false,
|
||||
placeholder: "5",
|
||||
showIf: (model: FormValues<IncidentGroupingRule>): boolean => {
|
||||
return model.enableResolveDelay === true;
|
||||
},
|
||||
description:
|
||||
"Number of minutes to wait after all incidents in the episode are resolved before automatically resolving the episode itself.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
enableReopenWindow: true,
|
||||
},
|
||||
title: "Enable Reopen Window",
|
||||
stepId: "time-settings",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
description:
|
||||
"Enable this to reopen recently resolved episodes instead of creating new ones when matching incidents arrive. This is useful for recurring issues - if a problem returns shortly after being resolved, it makes more sense to continue tracking it in the same episode rather than fragmenting the incident history across multiple episodes.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
reopenWindowMinutes: true,
|
||||
},
|
||||
title: "Reopen Window (minutes)",
|
||||
stepId: "time-settings",
|
||||
fieldType: FormFieldSchemaType.Number,
|
||||
required: false,
|
||||
placeholder: "30",
|
||||
showIf: (model: FormValues<IncidentGroupingRule>): boolean => {
|
||||
return model.enableReopenWindow === true;
|
||||
},
|
||||
description:
|
||||
"Time window after an episode is resolved during which a new matching incident will reopen that episode instead of creating a new one.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
enableInactivityTimeout: true,
|
||||
},
|
||||
title: "Enable Inactivity Timeout",
|
||||
stepId: "time-settings",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
description:
|
||||
"Enable this to automatically resolve episodes after a period of inactivity. This helps clean up stale episodes that are no longer receiving incidents, ensuring your active episode list stays current and relevant. Without this, episodes would remain open indefinitely until manually resolved.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
inactivityTimeoutMinutes: true,
|
||||
},
|
||||
title: "Inactivity Timeout (minutes)",
|
||||
stepId: "time-settings",
|
||||
fieldType: FormFieldSchemaType.Number,
|
||||
required: false,
|
||||
placeholder: "60",
|
||||
showIf: (model: FormValues<IncidentGroupingRule>): boolean => {
|
||||
return model.enableInactivityTimeout === true;
|
||||
},
|
||||
description:
|
||||
"Number of minutes of inactivity (no new incidents added) after which the episode will be automatically resolved.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
episodeTitleTemplate: true,
|
||||
},
|
||||
title: "Episode Title Template",
|
||||
stepId: "episode-template",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: false,
|
||||
placeholder: "{{incidentSeverity}} Incident Episode on {{monitorName}}",
|
||||
description:
|
||||
"Template for auto-generated episode titles. Uses the first incident's data to generate the title.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
episodeDescriptionTemplate: true,
|
||||
},
|
||||
title: "Episode Description Template",
|
||||
stepId: "episode-template",
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
required: false,
|
||||
placeholder:
|
||||
"Episode created from {{incidentSeverity}} incident: {{incidentTitle}} on monitor {{monitorName}}",
|
||||
description:
|
||||
"Template for auto-generated episode descriptions. Uses the first incident's data to generate the description.",
|
||||
footerElement: (
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-md border border-gray-200 text-sm">
|
||||
<p className="font-medium mb-3">
|
||||
Supported Template Variables:
|
||||
</p>
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">
|
||||
Static Variables (from first incident):
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-gray-700">
|
||||
<li>
|
||||
<code className="bg-gray-200 px-1 rounded">
|
||||
{"{{incidentTitle}}"}
|
||||
</code>{" "}
|
||||
- Title of the incident
|
||||
</li>
|
||||
<li>
|
||||
<code className="bg-gray-200 px-1 rounded">
|
||||
{"{{incidentDescription}}"}
|
||||
</code>{" "}
|
||||
- Description of the incident
|
||||
</li>
|
||||
<li>
|
||||
<code className="bg-gray-200 px-1 rounded">
|
||||
{"{{incidentSeverity}}"}
|
||||
</code>{" "}
|
||||
- Severity level (e.g., Critical, Warning)
|
||||
</li>
|
||||
<li>
|
||||
<code className="bg-gray-200 px-1 rounded">
|
||||
{"{{monitorName}}"}
|
||||
</code>{" "}
|
||||
- Name of the monitor that triggered the incident
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">
|
||||
Dynamic Variables (updated as incidents join):
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-gray-700">
|
||||
<li>
|
||||
<code className="bg-gray-200 px-1 rounded">
|
||||
{"{{incidentCount}}"}
|
||||
</code>{" "}
|
||||
- Number of incidents in the episode
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-500 text-xs">
|
||||
Static variables use data from the first incident. Dynamic
|
||||
variables update automatically when incidents are added or
|
||||
removed.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: {
|
||||
onCallDutyPolicies: true,
|
||||
},
|
||||
title: "On-Call Duty Policies",
|
||||
stepId: "on-call-ownership",
|
||||
fieldType: FormFieldSchemaType.MultiSelectDropdown,
|
||||
dropdownModal: {
|
||||
type: OnCallDutyPolicy,
|
||||
labelField: "name",
|
||||
valueField: "_id",
|
||||
},
|
||||
required: false,
|
||||
description:
|
||||
"On-call policies to execute when an episode is created with this rule.",
|
||||
placeholder: "Select On-Call Policies",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
defaultAssignToTeam: true,
|
||||
},
|
||||
title: "Default Assign To Team",
|
||||
stepId: "on-call-ownership",
|
||||
fieldType: FormFieldSchemaType.Dropdown,
|
||||
dropdownModal: {
|
||||
type: Team,
|
||||
labelField: "name",
|
||||
valueField: "_id",
|
||||
},
|
||||
required: false,
|
||||
description:
|
||||
"Default team to assign episodes created by this rule.",
|
||||
placeholder: "Select Team",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
defaultAssignToUser: true,
|
||||
},
|
||||
title: "Default Assign To User",
|
||||
stepId: "on-call-ownership",
|
||||
fieldType: FormFieldSchemaType.Dropdown,
|
||||
fetchDropdownOptions: async () => {
|
||||
return await ProjectUser.fetchProjectUsersAsDropdownOptions(
|
||||
ProjectUtil.getCurrentProjectId()!,
|
||||
);
|
||||
},
|
||||
required: false,
|
||||
description:
|
||||
"Default user to assign episodes created by this rule.",
|
||||
placeholder: "Select User",
|
||||
},
|
||||
]}
|
||||
showRefreshButton={true}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default IncidentGroupingRulesPage;
|
||||
@@ -173,6 +173,15 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
|
||||
},
|
||||
icon: IconProp.TableCells,
|
||||
},
|
||||
{
|
||||
link: {
|
||||
title: "Grouping Rules",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.INCIDENTS_SETTINGS_GROUPING_RULES] as Route,
|
||||
),
|
||||
},
|
||||
icon: IconProp.Layers,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -184,6 +184,12 @@ const IncidentSettingsCustomFields: LazyExoticComponent<
|
||||
return import("../Pages/Incidents/Settings/IncidentCustomFields");
|
||||
});
|
||||
|
||||
const IncidentSettingsGroupingRules: LazyExoticComponent<
|
||||
FunctionComponent<ComponentProps>
|
||||
> = lazy(() => {
|
||||
return import("../Pages/Incidents/Settings/IncidentGroupingRules");
|
||||
});
|
||||
|
||||
// Incident Episode Pages
|
||||
const IncidentEpisodes: LazyExoticComponent<FunctionComponent<ComponentProps>> =
|
||||
lazy(() => {
|
||||
@@ -513,6 +519,22 @@ const IncidentsRoutes: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={
|
||||
IncidentsRoutePath[PageMap.INCIDENTS_SETTINGS_GROUPING_RULES] || ""
|
||||
}
|
||||
element={
|
||||
<Suspense fallback={Loader}>
|
||||
<IncidentSettingsGroupingRules
|
||||
{...props}
|
||||
pageRoute={
|
||||
RouteMap[PageMap.INCIDENTS_SETTINGS_GROUPING_RULES] as Route
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Incident Episode Routes */}
|
||||
<PageRoute
|
||||
path={IncidentsRoutePath[PageMap.INCIDENT_EPISODES] || ""}
|
||||
|
||||
@@ -78,6 +78,7 @@ enum PageMap {
|
||||
INCIDENTS_SETTINGS_POSTMORTEM_TEMPLATES = "INCIDENTS_SETTINGS_POSTMORTEM_TEMPLATES",
|
||||
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",
|
||||
|
||||
ALERTS_ROOT = "ALERTS_ROOT",
|
||||
ALERTS = "ALERTS",
|
||||
|
||||
@@ -189,6 +189,7 @@ export const IncidentsRoutePath: Dictionary<string> = {
|
||||
"settings/postmortem-templates",
|
||||
[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.INCIDENT_VIEW]: `${RouteParams.ModelID}`,
|
||||
[PageMap.INCIDENT_VIEW_STATE_TIMELINE]: `${RouteParams.ModelID}/state-timeline`,
|
||||
@@ -1061,6 +1062,12 @@ const RouteMap: Dictionary<Route> = {
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.INCIDENTS_SETTINGS_GROUPING_RULES]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/incidents/${
|
||||
IncidentsRoutePath[PageMap.INCIDENTS_SETTINGS_GROUPING_RULES]
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.SCHEDULED_MAINTENANCE_EVENTS_ROOT]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/scheduled-maintenance-events/*`,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user