Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: notification inhibition #2489

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/api/types/notifications.ts
Original file line number Diff line number Diff line change
@@ -4,6 +4,32 @@ import { HealthCheck } from "./health";
import { Topology } from "./topology";
import { Team, User } from "./users";

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;
@@ -43,6 +69,7 @@ export type NotificationRules = {
repeat_interval?: string;
error?: string;
wait_for?: number;
inhibitions?: NotificationInhibition[];
};

export type SilenceNotificationResponse = {
158 changes: 158 additions & 0 deletions src/components/Forms/Formik/FormikNotificationInhibitionsField.tsx
Original file line number Diff line number Diff line change
@@ -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<NotificationInhibition> & { to?: string })[];
};

const FormikNotificationInhibitionsField = ({
name,
label = "Inhibitions",
hint = "Configure inhibition rules to prevent notification storms"
}: FormikNotificationInhibitionsFieldProps) => {
const { values, setFieldValue, errors } = useFormikContext<FormValues>();

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 (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">
{label}
</label>
{hint && <p className="mt-1 text-sm text-gray-500">{hint}</p>}
</div>
<Button
icon={<FaPlus />}
onClick={addInhibition}
className="btn-primary"
>
Add Inhibition
</Button>
</div>

{inhibitions.map((inhibition, index) => (
<div key={index} className="rounded-lg border border-gray-200 p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-medium">Inhibition Rule {index + 1}</h3>
<Button
icon={<FaTrash />}
onClick={() => removeInhibition(index)}
className="btn-danger"
/>
</div>

<div className="grid grid-cols-2 gap-4">
<FormikAutocompleteDropdown
name={`${name}.${index}.direction`}
options={directionOptions}
label="Direction"
hint="Specify the traversal direction for related resources"
/>

<FormikTextInput
name={`${name}.${index}.from`}
label="From Resource Type"
hint="e.g., Kubernetes::Pod"
/>

<div className="col-span-2">
<FormikCodeEditor
fieldName={`${name}.${index}.to`}
format="yaml"
label="To Resource Types"
hint="List of resource types to traverse to (e.g., ['Kubernetes::Deployment', 'Kubernetes::ReplicaSet'])"
lines={5}
className="flex h-32 flex-col"
/>
{fieldErrors?.[name]?.[index]?.to && (
<ErrorMessage
message={fieldErrors[name][index].to}
className="mt-1"
/>
)}
</div>

<div className="flex items-end gap-4">
<FormikNumberInput
value={inhibition.depth}
onChange={(value) => updateInhibition(index, "depth", value)}
label="Depth"
hint="Number of levels to traverse"
/>
<div className="flex flex-col">
<Toggle
onChange={(value: boolean) => {
setFieldValue(`${name}.${index}.soft`, value);
}}
label="Soft Relationships"
value={!!inhibition.soft}
/>
<p className="mt-1 text-sm text-gray-500">
Use soft relationships for traversal
</p>
</div>
</div>
</div>
</div>
))}
</div>
);
};

export default FormikNotificationInhibitionsField;
76 changes: 34 additions & 42 deletions src/components/Forms/Formik/FormikNumberInput.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ComponentProps<typeof TextInput>, "id">;
value?: number;
onChange?: (value: number | undefined) => void;
};

type FormikNumberInputProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
"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<HTMLInputElement>) => {
const val =
e.target.value === "" ? undefined : parseInt(e.target.value, 10);
onChange?.(val);
};

return (
<div className={className}>
<TextInput
label={label}
{...props}
id={name}
type="number"
{...field}
onChange={() => {
const value = field.value;
if (value) {
field.onChange({ target: { value: parseInt(value) } });
}
}}
/>
{hint && <p className="py-1 text-sm text-gray-500">{hint}</p>}
{meta.touched && meta.error ? (
<p className="w-full py-1 text-sm text-red-500">{meta.error}</p>
) : null}
<div>
{label && (
<label className="block text-sm font-medium text-gray-700">
{label}
</label>
)}
<div className="mt-1">
<input
type="number"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
value={value ?? ""}
onChange={handleChange}
{...props}
/>
</div>
{hint && <p className="mt-1 text-sm text-gray-500">{hint}</p>}
</div>
);
}
71 changes: 68 additions & 3 deletions src/components/Notifications/Rules/NotificationsRulesForm.tsx
Original file line number Diff line number Diff line change
@@ -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<NotificationRules>) => void;
@@ -25,23 +28,83 @@ export default function NotificationsRulesForm({
notification,
onDeleted = () => {}
}: NotificationsFormProps) {
const validate = (values: Partial<NotificationRules>) => {
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 (
<div className="flex h-full flex-col gap-2 overflow-y-auto">
<Formik
initialValues={{
...notification,
person: undefined,
team: undefined,
created_by: notification?.created_by?.id,
created_by: notification?.created_by
? ({
id: notification.created_by.id,
name: notification.created_by.name,
email: notification.created_by.email,
roles: notification.created_by.roles
} as User)
: undefined,
created_at: undefined,
updated_at: undefined,
...(!notification?.id && { source: "UI" })
}}
onSubmit={(values) =>
onSubmit(
omit(values, "most_common_error") as Partial<NotificationRules>
omit(
{
...values,
created_by: values.created_by?.id
},
"most_common_error"
) as Partial<NotificationRules>
)
}
validate={validate}
validateOnBlur
validateOnChange
>
@@ -103,11 +166,13 @@ export default function NotificationsRulesForm({
hintPosition="top"
/>
<FormikNotificationsTemplateField name="template" />
<FormikNotificationInhibitionsField name="inhibitions" />
<FormikCodeEditor
fieldName="properties"
label="Properties"
className="flex h-32 flex-col"
format="json"
lines={5}
format="yaml"
/>
</div>
<div className="flex flex-row rounded-b-md bg-gray-100 p-4">

Unchanged files with check annotations Beta

process.exit(1);
}
const appTypes = ["INCIDENT_MANAGER", "CANARY_CHECKER"];

Check warning on line 13 in scripts/serve-build.js

GitHub Actions / eslint

'appTypes' is assigned a value but never used
const staticDir = `${__dirname}/../build`;
const port = 5050;
proxy.web(req, res, {
target: proxyTarget
});
} else if (req.url == "/" || req.url == "/index.html") {

Check warning on line 40 in scripts/serve-build.js

GitHub Actions / eslint

Expected '===' and instead saw '=='

Check warning on line 40 in scripts/serve-build.js

GitHub Actions / eslint

Expected '===' and instead saw '=='
res.write(indexHTML.replace("__APP_DEPLOYMENT__", appDeployment));
res.end();
} else {
ComponentTemplateItem,
Topology
} from "../types/topology";
import { AxiosResponse } from "axios";

Check warning on line 25 in src/api/services/topology.ts

GitHub Actions / eslint

'AxiosResponse' is defined but never used
interface IParam {
id?: string;
<div className="flex min-h-full flex-col justify-center pb-28 pt-12 sm:px-6 lg:px-8">
<div className="w-96">
<div>
<img

Check warning on line 91 in src/components/Authentication/Kratos/KratosRecovery.tsx

GitHub Actions / eslint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
alt="Mission Control"
src="/images/logo.svg"
className="m-auto h-auto w-48 rounded-8px p-2"
export const NodeImage = ({ node, attributes }: Props) => {
return (
<img

Check warning on line 10 in src/components/Authentication/Kratos/ory/ui/NodeImage.tsx

GitHub Actions / eslint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
data-testid={`node/image/${attributes.id}`}
src={attributes.src}
alt={node.meta.label?.text}
// The uptime for a group, is defined as the minimum uptime with the group
// eslint-disable-next-line no-unused-vars
function minUptime(items: { uptime: { passed: number; failed: number } }[]) {

Check warning on line 63 in src/components/Canary/aggregate.tsx

GitHub Actions / eslint

'minUptime' is defined but never used
return reduce(
items,
(old: any, item: any) => {
const intermediaryNodesWithSameRelatedIDs = Array.from(configsMap.values())
.filter((config) => config.data.type === "intermediary")
.filter((config) => config.related_ids)
.filter((config, _, self) => {

Check warning on line 110 in src/components/Configs/Graph/formatConfigsForGraph.ts

GitHub Actions / eslint

Array.prototype.filter() expects a value to be returned at the end of arrow function
if (config.related_ids) {
const similarRelatedIds = self.filter(
(c) =>
);
}
function IntegrationListTypeCell({

Check warning on line 45 in src/components/Integrations/Table/IntegrationsTableColumns.tsx

GitHub Actions / eslint

'IntegrationListTypeCell' is defined but never used
row
}: MRTCellProps<SchemaResourceWithJobStatus>) {
const name = row.original.integration_type;

Check warning on line 14 in src/components/Notifications/NotificationTabsLinks.tsx

GitHub Actions / eslint

'Link' is defined but never used