diff --git a/src/api/types/notifications.ts b/src/api/types/notifications.ts index 25d7a3525..84b25e2ad 100644 --- a/src/api/types/notifications.ts +++ b/src/api/types/notifications.ts @@ -5,6 +5,32 @@ import { Topology } from "./topology"; import { Team, User } from "./users"; import { PlaybookRunStatus } from "./playbooks"; +export type NotificationInhibition = { + // Direction specifies the traversal direction in relation to the "From" resource. + // - "outgoing": Looks for child resources originating from the "From" resource. + // Example: If "From" is "Kubernetes::Deployment", "To" could be ["Kubernetes::Pod", "Kubernetes::ReplicaSet"]. + // - "incoming": Looks for parent resources related to the "From" resource. + // Example: If "From" is "Kubernetes::Deployment", "To" could be ["Kubernetes::HelmRelease", "Kubernetes::Namespace"]. + // - "all": Considers both incoming and outgoing relationships. + direction: "outgoing" | "incoming" | "all"; + + // Soft, when true, relates using soft relationships. + // Example: Deployment to Pod is hard relationship, But Node to Pod is soft relationship. + soft?: boolean; + + // Depth defines how many levels of child or parent resources to traverse. + depth?: number; + + // From specifies the starting resource type (for example, "Kubernetes::Deployment"). + from: string; + + // To specifies the target resource types, which are determined based on the Direction. + // Example: + // - If Direction is "outgoing", these are child resources. + // - If Direction is "incoming", these are parent resources. + to: string[]; +}; + export type NotificationRules = { id: string; namespace?: string; @@ -44,6 +70,7 @@ export type NotificationRules = { repeat_interval?: string; error?: string; wait_for?: number; + inhibitions?: NotificationInhibition[]; }; export type SilenceNotificationResponse = { diff --git a/src/components/Forms/Formik/FormikNotificationInhibitionsField.tsx b/src/components/Forms/Formik/FormikNotificationInhibitionsField.tsx new file mode 100644 index 000000000..e4b810edb --- /dev/null +++ b/src/components/Forms/Formik/FormikNotificationInhibitionsField.tsx @@ -0,0 +1,158 @@ +import { NotificationInhibition } from "@flanksource-ui/api/types/notifications"; +import { useFormikContext } from "formik"; +import { Button } from "@flanksource-ui/ui/Buttons/Button"; +import { FaPlus, FaTrash } from "react-icons/fa"; +import FormikTextInput from "./FormikTextInput"; +import FormikAutocompleteDropdown from "./FormikAutocompleteDropdown"; +import FormikNumberInput from "./FormikNumberInput"; +import { FormikCodeEditor } from "./FormikCodeEditor"; +import ErrorMessage from "@flanksource-ui/ui/FormControls/ErrorMessage"; +import { FormikErrors } from "formik"; +import { Toggle } from "../../../ui/FormControls/Toggle"; + +type FormikNotificationInhibitionsFieldProps = { + name: string; + label?: string; + hint?: string; +}; + +const directionOptions = [ + { label: "Outgoing", value: "outgoing" }, + { label: "Incoming", value: "incoming" }, + { label: "All", value: "all" } +]; + +type FormValues = { + [key: string]: NotificationInhibition[]; +}; + +type FormErrors = { + [key: string]: (FormikErrors & { to?: string })[]; +}; + +const FormikNotificationInhibitionsField = ({ + name, + label = "Inhibitions", + hint = "Configure inhibition rules to prevent notification storms" +}: FormikNotificationInhibitionsFieldProps) => { + const { values, setFieldValue, errors } = useFormikContext(); + + const inhibitions = values[name] || []; + const fieldErrors = errors as FormErrors; + + const addInhibition = () => { + const newInhibition: NotificationInhibition = { + direction: "outgoing", + from: "", + to: [] + }; + setFieldValue(name, [...inhibitions, newInhibition]); + }; + + const removeInhibition = (index: number) => { + const newInhibitions = [...inhibitions]; + newInhibitions.splice(index, 1); + setFieldValue(name, newInhibitions); + }; + + const updateInhibition = ( + index: number, + field: keyof NotificationInhibition, + value: string | number | boolean | string[] | undefined + ) => { + const newInhibitions = [...inhibitions]; + newInhibitions[index] = { + ...newInhibitions[index], + [field]: value + }; + setFieldValue(name, newInhibitions); + }; + + return ( +
+
+
+ + {hint &&

{hint}

} +
+ +
+ + {inhibitions.map((inhibition, index) => ( +
+
+

Inhibition Rule {index + 1}

+
+ +
+ + + + +
+ + {fieldErrors?.[name]?.[index]?.to && ( + + )} +
+ +
+ updateInhibition(index, "depth", value)} + label="Depth" + hint="Number of levels to traverse" + /> +
+ { + setFieldValue(`${name}.${index}.soft`, value); + }} + label="Soft Relationships" + value={!!inhibition.soft} + /> +

+ Use soft relationships for traversal +

+
+
+
+
+ ))} +
+ ); +}; + +export default FormikNotificationInhibitionsField; diff --git a/src/components/Forms/Formik/FormikNumberInput.tsx b/src/components/Forms/Formik/FormikNumberInput.tsx index 32188342d..e09c30eb8 100644 --- a/src/components/Forms/Formik/FormikNumberInput.tsx +++ b/src/components/Forms/Formik/FormikNumberInput.tsx @@ -1,56 +1,48 @@ -import { useField } from "formik"; -import React from "react"; -import { TextInput } from "../../../ui/FormControls/TextInput"; +import { InputHTMLAttributes } from "react"; -type FormikNumberInputProps = { - name: string; - required?: boolean; +type CustomNumberInputProps = { label?: string; - className?: string; hint?: string; -} & Omit, "id">; + value?: number; + onChange?: (value: number | undefined) => void; +}; + +type FormikNumberInputProps = Omit< + InputHTMLAttributes, + "onChange" | "value" +> & + CustomNumberInputProps; export default function FormikNumberInput({ - name, - required = false, label, - className = "flex flex-col", hint, + value, + onChange, ...props }: FormikNumberInputProps) { - const [field, meta] = useField({ - name, - type: "number", - required, - validate: (value) => { - if (required && !value) { - return "This field is required"; - } - if (value && isNaN(value)) { - return "This field must be a number"; - } - } - }); + const handleChange = (e: React.ChangeEvent) => { + const val = + e.target.value === "" ? undefined : parseInt(e.target.value, 10); + onChange?.(val); + }; return ( -
- { - const value = field.value; - if (value) { - field.onChange({ target: { value: parseInt(value) } }); - } - }} - /> - {hint &&

{hint}

} - {meta.touched && meta.error ? ( -

{meta.error}

- ) : null} +
+ {label && ( + + )} +
+ +
+ {hint &&

{hint}

}
); } diff --git a/src/components/Notifications/Rules/NotificationsRulesForm.tsx b/src/components/Notifications/Rules/NotificationsRulesForm.tsx index 43e0961ca..be3f7c295 100644 --- a/src/components/Notifications/Rules/NotificationsRulesForm.tsx +++ b/src/components/Notifications/Rules/NotificationsRulesForm.tsx @@ -13,6 +13,9 @@ import CanEditResource from "../../Settings/CanEditResource"; import ErrorMessage from "@flanksource-ui/ui/FormControls/ErrorMessage"; import { omit } from "lodash"; import FormikNotificationGroupByDropdown from "@flanksource-ui/components/Forms/Formik/FormikNotificationGroupByDropdown"; +import FormikNotificationInhibitionsField from "@flanksource-ui/components/Forms/Formik/FormikNotificationInhibitionsField"; +import { parse as parseYaml } from "yaml"; +import { User } from "@flanksource-ui/api/types/users"; type NotificationsFormProps = { onSubmit: (notification: Partial) => void; @@ -25,6 +28,52 @@ export default function NotificationsRulesForm({ notification, onDeleted = () => {} }: NotificationsFormProps) { + const validate = (values: Partial) => { + const errors: any = {}; + + // Validate inhibitions if present + if (values.inhibitions?.length) { + const inhibitionErrors: any[] = []; + values.inhibitions.forEach((inhibition, index) => { + const inhibitionError: any = {}; + + // Validate 'to' field + try { + const toValue = + typeof inhibition.to === "string" + ? parseYaml(inhibition.to) + : inhibition.to; + if (!Array.isArray(toValue)) { + inhibitionError.to = "Must be an array of resource types"; + } else if (!toValue.every((item) => typeof item === "string")) { + inhibitionError.to = "All items must be strings"; + } else if (toValue.length === 0) { + inhibitionError.to = "At least one resource type is required"; + } + } catch (e) { + inhibitionError.to = + "Invalid YAML format. Must be an array of resource types"; + } + + // Validate 'from' field + if (!inhibition.from) { + inhibitionError.from = "From resource type is required"; + } + + // Add errors if any were found + if (Object.keys(inhibitionError).length > 0) { + inhibitionErrors[index] = inhibitionError; + } + }); + + if (inhibitionErrors.length > 0) { + errors.inhibitions = inhibitionErrors; + } + } + + return errors; + }; + return (
onSubmit( - omit(values, "most_common_error") as Partial + omit( + { + ...values, + created_by: values.created_by?.id + }, + "most_common_error" + ) as Partial ) } + validate={validate} validateOnBlur validateOnChange > @@ -103,11 +166,13 @@ export default function NotificationsRulesForm({ hintPosition="top" /> +