Skip to content

Commit 60b9eb6

Browse files
committed
feat: notification inhibition
1 parent 8ca02f7 commit 60b9eb6

4 files changed

Lines changed: 285 additions & 44 deletions

File tree

src/api/types/notifications.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,32 @@ import { Topology } from "./topology";
55
import { Team, User } from "./users";
66
import { PlaybookRunStatus } from "./playbooks";
77

8+
export type NotificationInhibition = {
9+
// Direction specifies the traversal direction in relation to the "From" resource.
10+
// - "outgoing": Looks for child resources originating from the "From" resource.
11+
// Example: If "From" is "Kubernetes::Deployment", "To" could be ["Kubernetes::Pod", "Kubernetes::ReplicaSet"].
12+
// - "incoming": Looks for parent resources related to the "From" resource.
13+
// Example: If "From" is "Kubernetes::Deployment", "To" could be ["Kubernetes::HelmRelease", "Kubernetes::Namespace"].
14+
// - "all": Considers both incoming and outgoing relationships.
15+
direction: "outgoing" | "incoming" | "all";
16+
17+
// Soft, when true, relates using soft relationships.
18+
// Example: Deployment to Pod is hard relationship, But Node to Pod is soft relationship.
19+
soft?: boolean;
20+
21+
// Depth defines how many levels of child or parent resources to traverse.
22+
depth?: number;
23+
24+
// From specifies the starting resource type (for example, "Kubernetes::Deployment").
25+
from: string;
26+
27+
// To specifies the target resource types, which are determined based on the Direction.
28+
// Example:
29+
// - If Direction is "outgoing", these are child resources.
30+
// - If Direction is "incoming", these are parent resources.
31+
to: string[];
32+
};
33+
834
export type NotificationRules = {
935
id: string;
1036
namespace?: string;
@@ -44,6 +70,7 @@ export type NotificationRules = {
4470
repeat_interval?: string;
4571
error?: string;
4672
wait_for?: number;
73+
inhibitions?: NotificationInhibition[];
4774
};
4875

4976
export type SilenceNotificationResponse = {
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { NotificationInhibition } from "@flanksource-ui/api/types/notifications";
2+
import { useFormikContext } from "formik";
3+
import { Button } from "@flanksource-ui/ui/Buttons/Button";
4+
import { FaPlus, FaTrash } from "react-icons/fa";
5+
import FormikTextInput from "./FormikTextInput";
6+
import FormikAutocompleteDropdown from "./FormikAutocompleteDropdown";
7+
import FormikNumberInput from "./FormikNumberInput";
8+
import { FormikCodeEditor } from "./FormikCodeEditor";
9+
import ErrorMessage from "@flanksource-ui/ui/FormControls/ErrorMessage";
10+
import { FormikErrors } from "formik";
11+
import { Toggle } from "../../../ui/FormControls/Toggle";
12+
13+
type FormikNotificationInhibitionsFieldProps = {
14+
name: string;
15+
label?: string;
16+
hint?: string;
17+
};
18+
19+
const directionOptions = [
20+
{ label: "Outgoing", value: "outgoing" },
21+
{ label: "Incoming", value: "incoming" },
22+
{ label: "All", value: "all" }
23+
];
24+
25+
type FormValues = {
26+
[key: string]: NotificationInhibition[];
27+
};
28+
29+
type FormErrors = {
30+
[key: string]: (FormikErrors<NotificationInhibition> & { to?: string })[];
31+
};
32+
33+
const FormikNotificationInhibitionsField = ({
34+
name,
35+
label = "Inhibitions",
36+
hint = "Configure inhibition rules to prevent notification storms"
37+
}: FormikNotificationInhibitionsFieldProps) => {
38+
const { values, setFieldValue, errors } = useFormikContext<FormValues>();
39+
40+
const inhibitions = values[name] || [];
41+
const fieldErrors = errors as FormErrors;
42+
43+
const addInhibition = () => {
44+
const newInhibition: NotificationInhibition = {
45+
direction: "outgoing",
46+
from: "",
47+
to: []
48+
};
49+
setFieldValue(name, [...inhibitions, newInhibition]);
50+
};
51+
52+
const removeInhibition = (index: number) => {
53+
const newInhibitions = [...inhibitions];
54+
newInhibitions.splice(index, 1);
55+
setFieldValue(name, newInhibitions);
56+
};
57+
58+
const updateInhibition = (
59+
index: number,
60+
field: keyof NotificationInhibition,
61+
value: string | number | boolean | string[] | undefined
62+
) => {
63+
const newInhibitions = [...inhibitions];
64+
newInhibitions[index] = {
65+
...newInhibitions[index],
66+
[field]: value
67+
};
68+
setFieldValue(name, newInhibitions);
69+
};
70+
71+
return (
72+
<div className="flex flex-col gap-4">
73+
<div className="flex items-center justify-between">
74+
<div>
75+
<label className="block text-sm font-medium text-gray-700">
76+
{label}
77+
</label>
78+
{hint && <p className="mt-1 text-sm text-gray-500">{hint}</p>}
79+
</div>
80+
<Button
81+
icon={<FaPlus />}
82+
onClick={addInhibition}
83+
className="btn-primary"
84+
>
85+
Add Inhibition
86+
</Button>
87+
</div>
88+
89+
{inhibitions.map((inhibition, index) => (
90+
<div key={index} className="rounded-lg border border-gray-200 p-4">
91+
<div className="mb-4 flex items-center justify-between">
92+
<h3 className="text-lg font-medium">Inhibition Rule {index + 1}</h3>
93+
<Button
94+
icon={<FaTrash />}
95+
onClick={() => removeInhibition(index)}
96+
className="btn-danger"
97+
/>
98+
</div>
99+
100+
<div className="grid grid-cols-2 gap-4">
101+
<FormikAutocompleteDropdown
102+
name={`${name}.${index}.direction`}
103+
options={directionOptions}
104+
label="Direction"
105+
hint="Specify the traversal direction for related resources"
106+
/>
107+
108+
<FormikTextInput
109+
name={`${name}.${index}.from`}
110+
label="From Resource Type"
111+
hint="e.g., Kubernetes::Pod"
112+
/>
113+
114+
<div className="col-span-2">
115+
<FormikCodeEditor
116+
fieldName={`${name}.${index}.to`}
117+
format="yaml"
118+
label="To Resource Types"
119+
hint="List of resource types to traverse to (e.g., ['Kubernetes::Deployment', 'Kubernetes::ReplicaSet'])"
120+
lines={5}
121+
className="flex h-32 flex-col"
122+
/>
123+
{fieldErrors?.[name]?.[index]?.to && (
124+
<ErrorMessage
125+
message={fieldErrors[name][index].to}
126+
className="mt-1"
127+
/>
128+
)}
129+
</div>
130+
131+
<div className="flex items-end gap-4">
132+
<FormikNumberInput
133+
value={inhibition.depth}
134+
onChange={(value) => updateInhibition(index, "depth", value)}
135+
label="Depth"
136+
hint="Number of levels to traverse"
137+
/>
138+
<div className="flex flex-col">
139+
<Toggle
140+
onChange={(value: boolean) => {
141+
setFieldValue(`${name}.${index}.soft`, value);
142+
}}
143+
label="Soft Relationships"
144+
value={!!inhibition.soft}
145+
/>
146+
<p className="mt-1 text-sm text-gray-500">
147+
Use soft relationships for traversal
148+
</p>
149+
</div>
150+
</div>
151+
</div>
152+
</div>
153+
))}
154+
</div>
155+
);
156+
};
157+
158+
export default FormikNotificationInhibitionsField;
Lines changed: 34 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,48 @@
1-
import { useField } from "formik";
2-
import React from "react";
3-
import { TextInput } from "../../../ui/FormControls/TextInput";
1+
import { InputHTMLAttributes } from "react";
42

5-
type FormikNumberInputProps = {
6-
name: string;
7-
required?: boolean;
3+
type CustomNumberInputProps = {
84
label?: string;
9-
className?: string;
105
hint?: string;
11-
} & Omit<React.ComponentProps<typeof TextInput>, "id">;
6+
value?: number;
7+
onChange?: (value: number | undefined) => void;
8+
};
9+
10+
type FormikNumberInputProps = Omit<
11+
InputHTMLAttributes<HTMLInputElement>,
12+
"onChange" | "value"
13+
> &
14+
CustomNumberInputProps;
1215

1316
export default function FormikNumberInput({
14-
name,
15-
required = false,
1617
label,
17-
className = "flex flex-col",
1818
hint,
19+
value,
20+
onChange,
1921
...props
2022
}: FormikNumberInputProps) {
21-
const [field, meta] = useField({
22-
name,
23-
type: "number",
24-
required,
25-
validate: (value) => {
26-
if (required && !value) {
27-
return "This field is required";
28-
}
29-
if (value && isNaN(value)) {
30-
return "This field must be a number";
31-
}
32-
}
33-
});
23+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
24+
const val =
25+
e.target.value === "" ? undefined : parseInt(e.target.value, 10);
26+
onChange?.(val);
27+
};
3428

3529
return (
36-
<div className={className}>
37-
<TextInput
38-
label={label}
39-
{...props}
40-
id={name}
41-
type="number"
42-
{...field}
43-
onChange={() => {
44-
const value = field.value;
45-
if (value) {
46-
field.onChange({ target: { value: parseInt(value) } });
47-
}
48-
}}
49-
/>
50-
{hint && <p className="py-1 text-sm text-gray-500">{hint}</p>}
51-
{meta.touched && meta.error ? (
52-
<p className="w-full py-1 text-sm text-red-500">{meta.error}</p>
53-
) : null}
30+
<div>
31+
{label && (
32+
<label className="block text-sm font-medium text-gray-700">
33+
{label}
34+
</label>
35+
)}
36+
<div className="mt-1">
37+
<input
38+
type="number"
39+
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
40+
value={value ?? ""}
41+
onChange={handleChange}
42+
{...props}
43+
/>
44+
</div>
45+
{hint && <p className="mt-1 text-sm text-gray-500">{hint}</p>}
5446
</div>
5547
);
5648
}

src/components/Notifications/Rules/NotificationsRulesForm.tsx

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import CanEditResource from "../../Settings/CanEditResource";
1313
import ErrorMessage from "@flanksource-ui/ui/FormControls/ErrorMessage";
1414
import { omit } from "lodash";
1515
import FormikNotificationGroupByDropdown from "@flanksource-ui/components/Forms/Formik/FormikNotificationGroupByDropdown";
16+
import FormikNotificationInhibitionsField from "@flanksource-ui/components/Forms/Formik/FormikNotificationInhibitionsField";
17+
import { parse as parseYaml } from "yaml";
18+
import { User } from "@flanksource-ui/api/types/users";
1619

1720
type NotificationsFormProps = {
1821
onSubmit: (notification: Partial<NotificationRules>) => void;
@@ -25,23 +28,83 @@ export default function NotificationsRulesForm({
2528
notification,
2629
onDeleted = () => {}
2730
}: NotificationsFormProps) {
31+
const validate = (values: Partial<NotificationRules>) => {
32+
const errors: any = {};
33+
34+
// Validate inhibitions if present
35+
if (values.inhibitions?.length) {
36+
const inhibitionErrors: any[] = [];
37+
values.inhibitions.forEach((inhibition, index) => {
38+
const inhibitionError: any = {};
39+
40+
// Validate 'to' field
41+
try {
42+
const toValue =
43+
typeof inhibition.to === "string"
44+
? parseYaml(inhibition.to)
45+
: inhibition.to;
46+
if (!Array.isArray(toValue)) {
47+
inhibitionError.to = "Must be an array of resource types";
48+
} else if (!toValue.every((item) => typeof item === "string")) {
49+
inhibitionError.to = "All items must be strings";
50+
} else if (toValue.length === 0) {
51+
inhibitionError.to = "At least one resource type is required";
52+
}
53+
} catch (e) {
54+
inhibitionError.to =
55+
"Invalid YAML format. Must be an array of resource types";
56+
}
57+
58+
// Validate 'from' field
59+
if (!inhibition.from) {
60+
inhibitionError.from = "From resource type is required";
61+
}
62+
63+
// Add errors if any were found
64+
if (Object.keys(inhibitionError).length > 0) {
65+
inhibitionErrors[index] = inhibitionError;
66+
}
67+
});
68+
69+
if (inhibitionErrors.length > 0) {
70+
errors.inhibitions = inhibitionErrors;
71+
}
72+
}
73+
74+
return errors;
75+
};
76+
2877
return (
2978
<div className="flex h-full flex-col gap-2 overflow-y-auto">
3079
<Formik
3180
initialValues={{
3281
...notification,
3382
person: undefined,
3483
team: undefined,
35-
created_by: notification?.created_by?.id,
84+
created_by: notification?.created_by
85+
? ({
86+
id: notification.created_by.id,
87+
name: notification.created_by.name,
88+
email: notification.created_by.email,
89+
roles: notification.created_by.roles
90+
} as User)
91+
: undefined,
3692
created_at: undefined,
3793
updated_at: undefined,
3894
...(!notification?.id && { source: "UI" })
3995
}}
4096
onSubmit={(values) =>
4197
onSubmit(
42-
omit(values, "most_common_error") as Partial<NotificationRules>
98+
omit(
99+
{
100+
...values,
101+
created_by: values.created_by?.id
102+
},
103+
"most_common_error"
104+
) as Partial<NotificationRules>
43105
)
44106
}
107+
validate={validate}
45108
validateOnBlur
46109
validateOnChange
47110
>
@@ -103,6 +166,7 @@ export default function NotificationsRulesForm({
103166
hintPosition="top"
104167
/>
105168
<FormikNotificationsTemplateField name="template" />
169+
<FormikNotificationInhibitionsField name="inhibitions" />
106170
<FormikCodeEditor
107171
fieldName="properties"
108172
label="Properties"

0 commit comments

Comments
 (0)