diff --git a/.github/workflows/test-pr.yml b/.github/workflows/test-pr.yml
index 23daa9589..8d1a1e57a 100644
--- a/.github/workflows/test-pr.yml
+++ b/.github/workflows/test-pr.yml
@@ -4,10 +4,26 @@ on: [push, pull_request]
env:
PYTHON_VERSION: 3.11
STORAGE_MANAGER_DIRECTORY: /tmp/storage-manager
+ MYSQL_ROOT_PASSWORD: keep
+ MYSQL_DATABASE: keep
jobs:
tests:
runs-on: ubuntu-latest
+ services:
+ mysql:
+ image: mysql:5.7
+ env:
+ MYSQL_ROOT_PASSWORD: ${{ env.MYSQL_ROOT_PASSWORD }}
+ MYSQL_DATABASE: ${{ env.MYSQL_DATABASE }}
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="mysqladmin ping"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=3
+
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -31,8 +47,17 @@ jobs:
key: pydeps-${{ hashFiles('**/poetry.lock') }}
- name: Install dependencies using poetry
run: poetry install --no-interaction --no-root
+
- name: Run unit tests and report coverage
- run: poetry run coverage run --branch -m pytest
+ run: |
+ # Add a step to wait for MySQL to be fully up and running
+ until nc -z 127.0.0.1 3306; do
+ echo "waiting for MySQL..."
+ sleep 1
+ done
+ echo "MySQL is up and running!"
+ poetry run coverage run --branch -m pytest
+
- name: Convert coverage results to JSON (for CodeCov support)
run: poetry run coverage json --omit="keep/providers/*"
- name: Upload coverage reports to Codecov
diff --git a/docs/mint.json b/docs/mint.json
index 098d555ff..e0ddab340 100644
--- a/docs/mint.json
+++ b/docs/mint.json
@@ -29,6 +29,7 @@
"overview/introduction",
"overview/keyconcepts",
"overview/usecases",
+ "overview/ruleengine",
"overview/examples",
"overview/alternatives"
]
diff --git a/docs/overview/keyconcepts.mdx b/docs/overview/keyconcepts.mdx
index e94f57424..5d92a1d69 100644
--- a/docs/overview/keyconcepts.mdx
+++ b/docs/overview/keyconcepts.mdx
@@ -3,7 +3,7 @@ title: "Key concepts"
---
## Alert
Alert is an event that triggered when something bad happens or going to happen.
-The term "alert" can sometimes be interchanged with "alarm" (in CloudWatch) or "monitor" (in Datadog).
+The term "alert" can sometimes be interchanged with "alarm" (e.g. in CloudWatch) or "monitor" (e.g. in Datadog).
You can easily initiate a [Workflow](#workflow) when an alert is triggered.
diff --git a/docs/overview/ruleengine.mdx b/docs/overview/ruleengine.mdx
new file mode 100644
index 000000000..37135acfd
--- /dev/null
+++ b/docs/overview/ruleengine.mdx
@@ -0,0 +1,27 @@
+---
+title: "Alert grouping"
+---
+
+The Keep Rule Engine is a versatile tool for grouping and consolidating alerts.
+This guide explains the core concepts, usage, and best practices for effectively utilizing the rule engine.
+
+Access the Rule Engine UI through the Keep platform by navigating to the Rule Builder section.
+
+## Core Concepts
+- **Rule definition**: A rule in Keep is a set of conditions that, when met, creates an alert group.
+- **Alert attributes**: These are characteristics or data points of an alert, such as source, severity, or any attribute an alert might have.
+- **Conditions and logic**: Rules are built by defining conditions based on alert attributes, using logical operators (like AND/OR) to combine multiple conditions.
+
+## Creating Rules
+Creating a rule involves defining the conditions under which an alert should be categorized or actions should be grouped.
+
+1. **Accessing the Rule Engine**: Navigate to the Rule Engine section in the Keep platform.
+2. **Defining rule criteria**:
+ - **Name the rule**: Assign a descriptive name that reflects its purpose.
+ - **Set conditions**: Use alert attributes to create conditions. For example, a rule might specify that an alert with a severity of 'critical' and a source of 'Prometheus' should be categorized as 'High Priority'.
+ - **Logical grouping**: Combine conditions using logical operators to form comprehensive rules.
+
+## Examples
+- **Metric-based alerts**: Construct a rule to pinpoint alerts associated with specific metrics, such as high CPU usage on servers. This can be achieved by grouping alerts that share a common attribute, like a 'CPU usage' tag, ensuring you quickly identify and address performance issues.
+- **Feature-related alerts**: Establish rules to organize alerts by specific features or services. For instance, you can group alerts based on a 'service' or 'URL' tag. This approach is particularly useful for tracking and managing alerts related to distinct functionalities or components within your application.
+- **Team-based alert management**: Implement rules to categorize alerts according to team responsibilities. This might involve grouping alerts based on the systems or services a particular team oversees. Such a strategy ensures that alerts are promptly directed to the appropriate team, enhancing response times and efficiency.
diff --git a/examples/workflows/elastic_enrich_example.yml b/examples/workflows/elastic_enrich_example.yml
new file mode 100644
index 000000000..e70be706d
--- /dev/null
+++ b/examples/workflows/elastic_enrich_example.yml
@@ -0,0 +1,78 @@
+# if no acknowledgement has been recieved (updated in index) for x (from config index) time, i want to escalate it to next level of people
+workflow:
+ id: elastic-enrich
+ description: escalate-if-needed
+ triggers:
+ # run every minute
+ - type: interval
+ value: 1m
+ steps:
+ # first, query the ack index to check if there are any alerts that have not been acknowledged
+ - name: query-ack-index
+ type: elastic
+ config: " {{ providers.elastic }} "
+ with:
+ index: your_ack_index
+ query: |
+ {
+ "query": {
+ "bool": {
+ "must": [
+ {
+ "match": {
+ "acknowledged": false
+ }
+ }
+ ]
+ }
+ }
+ }
+ - name: query-config-index
+ type: elastic
+ config: " {{ providers.elastic }} "
+ with:
+ index: your_config_index
+ query: |
+ {
+ "query": {
+ "bool": {
+ "must": [
+ {
+ "match": {
+ "config": true
+ }
+ }
+ ]
+ }
+ }
+ }
+ - name: query-people-index
+ type: elastic
+ config: " {{ providers.elastic }} "
+ with:
+ index: your_people_index
+ query: |
+ {
+ "query": {
+ "bool": {
+ "must": [
+ {
+ "match": {
+ "people": true
+ }
+ }
+ ]
+ }
+ }
+ }
+ # now, we have the results from the ack index, config index, and people index
+ actions:
+ - name: escalate-if-needed
+ # if there are any alerts that have not been acknowledged
+ if: "{{ query-ack-index.hits.total.value }} > 0"
+ provider:
+ type: slack # or email or whatever you want
+ config: " {{ providers.slack }} "
+ with:
+ message: |
+ "A unacknowledged alert has been found: {{ query-ack-index.hits.hits }} {{ query-config-index.hits.hits }} {{ query-people-index.hits.hits }}"
diff --git a/keep-ui/app/alerts/alerts.tsx b/keep-ui/app/alerts/alerts.tsx
index 9f36fb9fd..ff612789d 100644
--- a/keep-ui/app/alerts/alerts.tsx
+++ b/keep-ui/app/alerts/alerts.tsx
@@ -177,9 +177,11 @@ export default function Alerts({
combinedAlerts.forEach((alert) => {
let alertKey = "";
try {
- alertKey = `${alert.id}-${alert.lastReceived.toISOString()}`;
+ alertKey = `${
+ alert.fingerprint
+ }-${alert.lastReceived.toISOString()}`;
} catch {
- alertKey = alert.id;
+ alertKey = alert.fingerprint;
}
uniqueObjectsMap.set(alertKey, alert);
});
diff --git a/keep-ui/app/command-menu.tsx b/keep-ui/app/command-menu.tsx
index 23cb109d6..b885f5e9b 100644
--- a/keep-ui/app/command-menu.tsx
+++ b/keep-ui/app/command-menu.tsx
@@ -15,6 +15,10 @@ import {
KeyIcon,
BriefcaseIcon,
} from "@heroicons/react/24/outline";
+import { VscDebugDisconnect } from "react-icons/vsc";
+import { LuWorkflow } from "react-icons/lu";
+import { AiOutlineAlert } from "react-icons/ai";
+import { MdOutlineEngineering } from "react-icons/md";
import "../styles/linear.scss";
@@ -95,19 +99,25 @@ export function CMDK() {
const navigationItems = [
{
- icon: ,
+ icon: ,
label: "Go to the providers page",
shortcut: ["p"],
navigate: "/providers",
},
{
- icon: ,
+ icon: ,
label: "Go to alert console",
shortcut: ["g"],
navigate: "/alerts",
},
{
- icon: ,
+ icon: ,
+ label: "Go to alert groups",
+ shortcut: ["g"],
+ navigate: "/rules",
+ },
+ {
+ icon: ,
label: "Go to the workflows page",
shortcut: ["wf"],
navigate: "/workflows",
diff --git a/keep-ui/app/globals.css b/keep-ui/app/globals.css
index 9e1d6c34b..e16f8dc54 100644
--- a/keep-ui/app/globals.css
+++ b/keep-ui/app/globals.css
@@ -7,3 +7,7 @@
display: none;
}
*/
+.rules-tooltip [role="tooltip"] {
+ @apply w-72; /* This sets the width to 16rem. Adjust the number as needed. */
+ @apply break-words; /* This will wrap long words if needed. */
+ }
diff --git a/keep-ui/app/navbar-inner.tsx b/keep-ui/app/navbar-inner.tsx
index 5488736a4..712fca70e 100644
--- a/keep-ui/app/navbar-inner.tsx
+++ b/keep-ui/app/navbar-inner.tsx
@@ -5,12 +5,15 @@ import { signOut } from "next-auth/react";
import { Fragment, useState } from "react";
import {
Bars3Icon,
- BellAlertIcon,
- BriefcaseIcon,
DocumentTextIcon,
- PuzzlePieceIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
+import { VscDebugDisconnect } from "react-icons/vsc";
+import { LuWorkflow } from "react-icons/lu";
+import { AiOutlineAlert } from "react-icons/ai";
+import { MdOutlineEngineering } from "react-icons/md";
+
+
import Link from "next/link";
import { Icon } from "@tremor/react";
import { AuthenticationType } from "utils/authenticationType";
@@ -21,9 +24,10 @@ import { InternalConfig } from "types/internal-config";
import { NameInitialsAvatar } from "react-name-initials-avatar";
const navigation = [
- { name: "Providers", href: "/providers", icon: PuzzlePieceIcon },
- { name: "Alerts", href: "/alerts", icon: BellAlertIcon },
- { name: "Workflows", href: "/workflows", icon: BriefcaseIcon },
+ { name: "Providers", href: "/providers", icon: VscDebugDisconnect },
+ { name: "Alerts", href: "/alerts", icon: AiOutlineAlert },
+ { name: "Alert Groups", href: "/rules", icon: MdOutlineEngineering},
+ { name: "Workflows", href: "/workflows", icon: LuWorkflow }
// {
// name: "Notifications Hub",
// href: "/notifications-hub",
diff --git a/keep-ui/app/rules/layout.tsx b/keep-ui/app/rules/layout.tsx
new file mode 100644
index 000000000..4a5cbb0eb
--- /dev/null
+++ b/keep-ui/app/rules/layout.tsx
@@ -0,0 +1,15 @@
+import { Title, Subtitle } from "@tremor/react";
+
+export default function Layout({ children }: { children: any }) {
+ return (
+ <>
+
+ Alert Groups
+
+ Group multiple alerts into single alert
+
+ {children}
+
+ >
+ );
+}
diff --git a/keep-ui/app/rules/page.tsx b/keep-ui/app/rules/page.tsx
new file mode 100644
index 000000000..1ad2b198b
--- /dev/null
+++ b/keep-ui/app/rules/page.tsx
@@ -0,0 +1,11 @@
+import RulesPage from "./rules.client";
+
+
+export default function Page() {
+ return ;
+}
+
+export const metadata = {
+ title: "Keep - Rules",
+ description: "Create Keep Rules.",
+};
diff --git a/keep-ui/app/rules/query-builder.scss b/keep-ui/app/rules/query-builder.scss
new file mode 100644
index 000000000..05ca0d22e
--- /dev/null
+++ b/keep-ui/app/rules/query-builder.scss
@@ -0,0 +1,196 @@
+// Basic
+$rqb-spacing: 0.5rem !default;
+$rqb-background-color: rgba(0, 75, 183, 0.05) !default;
+$rqb-border-color: #8081a2 !default;
+$rqb-border-style: solid !default;
+$rqb-border-radius: 0.25rem !default;
+$rqb-border-width: 1px !default;
+
+// Drag-and-drop
+$rqb-dnd-hover-border-bottom-color: rebeccapurple !default;
+$rqb-dnd-hover-copy-border-bottom-color: #669933 !default;
+$rqb-dnd-hover-border-bottom-style: dashed !default;
+$rqb-dnd-hover-border-bottom-width: 2px !default;
+
+// Branches
+$rqb-branch-indent: $rqb-spacing !default;
+$rqb-branch-color: $rqb-border-color !default;
+$rqb-branch-width: $rqb-border-width !default;
+$rqb-branch-radius: $rqb-border-radius !default;
+$rqb-branch-style: $rqb-border-style !default;
+
+// Default styles
+.ruleGroup {
+ display: flex;
+ flex-direction: column;
+ gap: $rqb-spacing;
+ padding: $rqb-spacing;
+ border-color: $rqb-border-color;
+ border-style: $rqb-border-style;
+ border-radius: $rqb-border-radius;
+ border-width: $rqb-border-width;
+ background: $rqb-background-color;
+
+ .ruleGroup-body {
+ display: flex;
+ flex-direction: column;
+ gap: $rqb-spacing;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ .ruleGroup-header,
+ .rule {
+ display: flex;
+ gap: $rqb-spacing;
+ align-items: center;
+ }
+
+ .rule {
+ .rule-value:has(.rule-value-list-item) {
+ display: flex;
+ gap: $rqb-spacing;
+ align-items: baseline;
+ }
+ }
+
+ .shiftActions {
+ display: flex;
+ flex-direction: column;
+
+ & > * {
+ background-color: transparent;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ }
+ }
+}
+
+// #region Drag-and-drop
+
+// Hover styles
+[data-inlinecombinators='disabled'] {
+ .dndOver {
+ &.rule,
+ &.ruleGroup-header {
+ border-bottom-width: $rqb-dnd-hover-border-bottom-width;
+ border-bottom-style: $rqb-dnd-hover-border-bottom-style;
+ border-bottom-color: $rqb-dnd-hover-border-bottom-color;
+ padding-bottom: $rqb-spacing;
+
+ &.dndCopy {
+ border-bottom-color: $rqb-dnd-hover-copy-border-bottom-color;
+ }
+ }
+ }
+}
+[data-inlinecombinators='enabled'] {
+ .dndOver {
+ &.rule:last-child,
+ &.ruleGroup-header,
+ &.rule + .betweenRules,
+ &.betweenRules {
+ border-bottom-width: $rqb-dnd-hover-border-bottom-width;
+ border-bottom-style: $rqb-dnd-hover-border-bottom-style;
+ border-bottom-color: $rqb-dnd-hover-border-bottom-color;
+ padding-bottom: $rqb-spacing;
+
+ &.dndCopy {
+ border-bottom-color: $rqb-dnd-hover-copy-border-bottom-color;
+ }
+ }
+ }
+}
+
+// Drag styles
+.ruleGroup,
+.rule {
+ &.dndDragging {
+ opacity: 0.5;
+ }
+
+ .queryBuilder-dragHandle {
+ cursor: move;
+ }
+}
+// #endregion
+
+// #region Branches
+.queryBuilder-branches {
+ .ruleGroup-body {
+ margin-left: calc(2 * #{$rqb-branch-indent});
+ }
+
+ .rule,
+ .ruleGroup .ruleGroup {
+ position: relative;
+
+ &::before,
+ &::after {
+ content: '';
+ width: $rqb-branch-indent;
+ left: calc(-#{$rqb-branch-indent} - #{$rqb-branch-width});
+ border-color: $rqb-branch-color;
+ border-style: $rqb-branch-style;
+ border-radius: 0;
+ position: absolute;
+ }
+
+ &::before {
+ top: -$rqb-spacing;
+ height: calc(50% + #{$rqb-spacing});
+ border-width: 0 0 $rqb-branch-width $rqb-branch-width;
+ }
+
+ &:last-child::before {
+ border-bottom-left-radius: $rqb-branch-radius;
+ }
+
+ &::after {
+ top: 50%;
+ height: 50%;
+ border-width: 0 0 0 $rqb-branch-width;
+ }
+
+ &:last-child::after {
+ display: none;
+ }
+ }
+
+ .ruleGroup .ruleGroup {
+ &::before,
+ &::after {
+ left: calc(calc(-#{$rqb-branch-indent} - #{$rqb-branch-width}) - #{$rqb-border-width});
+ }
+
+ &::before {
+ top: calc(-#{$rqb-spacing} - #{$rqb-border-width});
+ height: calc(50% + #{$rqb-spacing} + #{$rqb-border-width});
+ }
+
+ &::after {
+ height: calc(50% + #{$rqb-border-width});
+ }
+ }
+
+ .betweenRules {
+ position: relative;
+
+ &::before {
+ content: '';
+ width: $rqb-branch-indent;
+ left: calc(-#{$rqb-branch-indent} - #{$rqb-branch-width});
+ border-color: $rqb-branch-color;
+ border-style: $rqb-branch-style;
+ border-radius: 0;
+ position: absolute;
+ top: -$rqb-spacing;
+ height: calc(100% + #{$rqb-spacing});
+ border-width: 0 0 0 $rqb-branch-width;
+ }
+ }
+}
+// #endregion
diff --git a/keep-ui/app/rules/rules.client.tsx b/keep-ui/app/rules/rules.client.tsx
new file mode 100644
index 000000000..209b8bd30
--- /dev/null
+++ b/keep-ui/app/rules/rules.client.tsx
@@ -0,0 +1,824 @@
+'use client';
+import React, { useState, useEffect, useMemo } from "react";
+import { Card, Flex, Title, Subtitle, TextInput, Button, Table, TableCell, TableBody, TableRow, TableHead, TableHeaderCell, Icon } from "@tremor/react";
+import Select from 'react-select';
+import CreatableSelect from 'react-select/creatable';
+import QueryBuilder, { add, remove, RuleGroupTypeAny, RuleGroupType, ValidationMap, Field, formatQuery, defaultOperators, parseCEL, QueryValidator, findPath} from 'react-querybuilder';
+// import 'react-querybuilder/dist/query-builder.scss';
+import { getApiURL } from "utils/apiUrl";
+import { useSession } from "next-auth/react";
+import Loading from "../loading";
+import './query-builder.scss';
+import { FaRegTrashAlt } from "react-icons/fa";
+import { FaQuestionCircle } from 'react-icons/fa';
+
+
+const customValidator: QueryValidator = (query: RuleGroupTypeAny): ValidationMap => {
+ const validationMap: ValidationMap = {};
+
+ const checkRules = (rules: any) => {
+ rules.forEach((rule: any) => {
+ if (rule.rules) {
+ // If it's a group, recursively check its rules
+ checkRules(rule.rules);
+ } else {
+ // Check if the rule value is empty and update the validation map
+ validationMap[rule.id] = {
+ valid: rule.value !== '',
+ reasons: rule.value === '' ? ['Value cannot be empty'] : []
+ };
+ }
+ });
+ };
+
+ checkRules(query.rules);
+ return validationMap;
+};
+
+const CustomValueEditor = (props: any) => {
+ const { value, handleOnChange, operator, rule, validationErrors } = props;
+
+ // Define an array of operators that do not require the input
+ const operatorsWithoutInput = ["null", "notNull"]; // Add more as needed
+
+ // Check if the selected operator is in the operatorsWithoutInput array
+ const isInputHidden = operatorsWithoutInput.includes(operator);
+
+ const handleOnChangeInternal = (value: string) => {
+ // Clear the validation error for the rule when the user edits the value
+ handleOnChange(value);
+ delete props.validationErrors[`rule_${rule.id}`];
+ }
+ // Determine if the current rule has a validation error
+ const isValid = !validationErrors || !validationErrors[`rule_${rule.id}`];
+ const errorMessage = isValid ? "" : validationErrors[`rule_${rule.id}`];
+
+ return (
+ <>
+
+ {!isInputHidden && (
+ handleOnChangeInternal(e.target.value)}
+ />
+ )}
+
+ >
+ );
+};
+
+
+const CustomCombinatorSelector = (props: any) => {
+ const { options, value, handleOnChange, level, path } = props;
+
+ if(level === 0){
+ return null;
+ }
+
+ return (
+
+ Alert Group {path[0] + 1}
+
+ );
+}
+
+const CustomOperatorSelector = (props: any) => {
+ const { options, value, handleOnChange } = props;
+
+ // Convert options to the format expected by react-select
+ const reactSelectOptions = options.map((option: any) => ({
+ label: option.label,
+ value: option.name,
+ }));
+
+ return (
+