diff --git a/src/App.tsx b/src/App.tsx index 2c65a2dd3..be59fa5cf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,6 +54,7 @@ import { import { ConnectionsPage } from "./pages/Settings/ConnectionsPage"; import { EventQueueStatusPage } from "./pages/Settings/EventQueueStatus"; import { FeatureFlagsPage } from "./pages/Settings/FeatureFlagsPage"; +import NotificationSilencePage from "./pages/Settings/NotificationSilencePage"; import { TopologyCardPage } from "./pages/TopologyCard"; import { UsersPage } from "./pages/UsersPage"; import { ConfigInsightsPage } from "./pages/config/ConfigInsightsList"; @@ -374,14 +375,27 @@ export function IncidentManagerRoutes({ sidebar }: { sidebar: ReactNode }) { true )} /> - , - tables.database, - "read" - )} - /> + + , + tables.database, + "read", + true + )} + /> + + , + tables.database, + "write", + true + )} + /> + { return resolvePostGrestRequestWithPagination( @@ -22,3 +23,10 @@ export const getNotificationById = async (id: string) => { ); return res.data ? res.data?.[0] : undefined; }; + +export const silenceNotification = async ( + data: SilenceNotificationResponse +) => { + const res = await NotificationAPI.post("/silence", data); + return res.data; +}; diff --git a/src/api/services/topology.ts b/src/api/services/topology.ts index 48b32eedc..72d919b5b 100644 --- a/src/api/services/topology.ts +++ b/src/api/services/topology.ts @@ -300,3 +300,13 @@ export const getCheckNames = async () => { const res = await IncidentCommander.get(`/check_names`); return res.data; }; + +export const getCanaryNames = async () => { + const res = await IncidentCommander.get< + { + id: string; + name: string; + }[] + >(`/canary_names`); + return res.data; +}; diff --git a/src/api/types/notifications.ts b/src/api/types/notifications.ts new file mode 100644 index 000000000..0b5475f7d --- /dev/null +++ b/src/api/types/notifications.ts @@ -0,0 +1,11 @@ +export type SilenceNotificationResponse = { + id: string; + component_id: string; + config_id: string; + check_id: string; + canary_id: string; + from: string; + until: string; + description: string; + recursive: boolean; +}; diff --git a/src/components/Forms/Formik/FormikCanaryDropdown.tsx b/src/components/Forms/Formik/FormikCanaryDropdown.tsx new file mode 100644 index 000000000..2ce7486c0 --- /dev/null +++ b/src/components/Forms/Formik/FormikCanaryDropdown.tsx @@ -0,0 +1,47 @@ +import { getCanaryNames } from "@flanksource-ui/api/services/topology"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import FormikSelectDropdown from "./FormikSelectDropdown"; + +type FormikCanaryDropdownProps = { + name: string; + label?: string; + required?: boolean; + hint?: string; + + className?: string; +}; + +export default function FormikCanaryDropdown({ + name, + label, + required = false, + hint, + className = "flex flex-col space-y-2 py-2" +}: FormikCanaryDropdownProps) { + const { isLoading, data: canary } = useQuery({ + queryKey: ["canaries", "canary_names"], + queryFn: () => getCanaryNames() + }); + + const options = useMemo( + () => + canary?.map((canary) => ({ + label: canary.name, + value: canary.id + })), + [canary] + ); + + return ( + + ); +} diff --git a/src/components/Forms/Formik/FormikDurationPicker.tsx b/src/components/Forms/Formik/FormikDurationPicker.tsx new file mode 100644 index 000000000..68ad64c61 --- /dev/null +++ b/src/components/Forms/Formik/FormikDurationPicker.tsx @@ -0,0 +1,220 @@ +import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; +import clsx from "clsx"; +import dayjs from "dayjs"; +import { useFormikContext } from "formik"; +import { useCallback } from "react"; +import { MdOutlineKeyboardArrowDown } from "react-icons/md"; +import FormikTextInput from "./FormikTextInput"; + +const commonDurations = [ + { + label: "1 Day", + values: { + from: dayjs().toISOString(), + to: dayjs().add(1, "day").toISOString() + } + }, + { + label: "2 Days", + values: { + from: dayjs().toISOString(), + to: dayjs().add(2, "day").toISOString() + } + }, + { + label: "3 Days", + values: { + from: dayjs().toISOString(), + to: dayjs().add(3, "day").toISOString() + } + }, + { + label: "1 Week", + values: { + from: dayjs().toISOString(), + to: dayjs().add(1, "week").toISOString() + } + }, + { + label: "2 Weeks", + values: { + from: dayjs().toISOString(), + to: dayjs().add(2, "week").toISOString() + } + }, + { + label: "1 Month", + values: { + from: dayjs().toISOString(), + to: dayjs().add(1, "month").toISOString() + } + }, + { + label: "3 Months", + values: { + from: dayjs().toISOString(), + to: dayjs().add(3, "month").toISOString() + } + }, + { + label: "6 Months", + values: { + from: dayjs().toISOString(), + to: dayjs().add(6, "month").toISOString() + } + }, + { + label: "1 Year", + values: { + from: dayjs().toISOString(), + to: dayjs().add(1, "year").toISOString() + } + } +]; + +type FormikDurationPickerProps = { + fieldNames: { + from: string; + to: string; + }; + label: string; + className?: string; + placeholder?: string; +}; + +export default function FormikDurationPicker({ + fieldNames: { from, to }, + label, + placeholder = "Select duration", + className +}: FormikDurationPickerProps) { + const { values, setFieldValue } = + useFormikContext>(); + + const updateFrom = useCallback( + (value: string) => { + setFieldValue(from, dayjs(value).format("YYYY-MM-DDTHH:mm")); + }, + [from, setFieldValue] + ); + + const updateTo = useCallback( + (value: string) => { + setFieldValue(to, dayjs(value).format("YYYY-MM-DDTHH:mm")); + }, + [to, setFieldValue] + ); + + const clearFields = useCallback(() => { + setFieldValue(from, undefined); + setFieldValue(to, undefined); + }, [from, to, setFieldValue]); + + return ( + + + {({ open }) => { + return ( +
+ {label && ( + + )} +
+ {values?.[to] || values?.[from] ? ( +
+ + {dayjs(values?.[from]).format("DD/MM/YYYY HH:mm")} + + - + + {dayjs(values?.[to]).format("DD/MM/YYYY HH:mm")} + +
+ ) : ( + {placeholder || "Select duration"} + )} +
+ +
+
+
+ ); + }} +
+ + {({ close }) => { + return ( +
+
+
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+
+
+ Common Durations +
+ {commonDurations.map((option) => { + return ( + + ); + })} +
+
+ ); + }} +
+
+ ); +} diff --git a/src/components/Notifications/SilenceNotificationForm/FormikNotificationField.tsx b/src/components/Notifications/SilenceNotificationForm/FormikNotificationField.tsx new file mode 100644 index 000000000..af8c3a127 --- /dev/null +++ b/src/components/Notifications/SilenceNotificationForm/FormikNotificationField.tsx @@ -0,0 +1,102 @@ +import FormikCanaryDropdown from "@flanksource-ui/components/Forms/Formik/FormikCanaryDropdown"; +import FormikChecksDropdown from "@flanksource-ui/components/Forms/Formik/FormikChecksDropdown"; +import FormikComponentsDropdown from "@flanksource-ui/components/Forms/Formik/FormikComponentsDropdown"; +import FormikConfigsDropdown from "@flanksource-ui/components/Forms/Formik/FormikConfigsDropdown"; +import { Switch } from "@flanksource-ui/ui/FormControls/Switch"; +import { useFormikContext } from "formik"; +import { useState } from "react"; + +export default function FormikNotificationResourceField() { + const { values } = useFormikContext>(); + + const component_id = values.component_id; + const config_id = values.config_id; + const check_id = values.check_id; + const canary_id = values.canary_id; + + const [switchOption, setSwitchOption] = useState< + "Component" | "Catalog" | "Check" | "Canary" + >(() => { + if (component_id) { + return "Component"; + } + if (config_id) { + return "Catalog"; + } + if (check_id) { + return "Check"; + } + if (canary_id) { + return "Canary"; + } + return "Component"; + }); + + return ( +
+ +
+
+ { + setSwitchOption(v); + // clear the other fields if the user selects a different option + if (v === "Component") { + values.config_id = null; + values.check_id = null; + values.canary_id = null; + } + + if (v === "Catalog") { + values.component_id = null; + values.check_id = null; + values.canary_id = null; + } + + if (v === "Check") { + values.component_id = null; + values.config_id = null; + values.canary_id = null; + } + + if (v === "Canary") { + values.component_id = null; + values.config_id = null; + values.check_id = null; + } + }} + /> +
+ {switchOption === "Component" && ( + + )} + {switchOption === "Catalog" && ( + + )} + {switchOption === "Check" && ( + + )} + {switchOption === "Canary" && ( + + )} +
+
+ ); +} diff --git a/src/components/Notifications/SilenceNotificationForm/NotificationSilenceForm.tsx b/src/components/Notifications/SilenceNotificationForm/NotificationSilenceForm.tsx new file mode 100644 index 000000000..465143c56 --- /dev/null +++ b/src/components/Notifications/SilenceNotificationForm/NotificationSilenceForm.tsx @@ -0,0 +1,81 @@ +import { silenceNotification } from "@flanksource-ui/api/services/notifications"; +import { SilenceNotificationResponse as SilenceNotificationRequest } from "@flanksource-ui/api/types/notifications"; +import FormikCheckbox from "@flanksource-ui/components/Forms/Formik/FormikCheckbox"; +import FormikDurationPicker from "@flanksource-ui/components/Forms/Formik/FormikDurationPicker"; +import FormikTextArea from "@flanksource-ui/components/Forms/Formik/FormikTextArea"; +import FormikNotificationResourceField from "@flanksource-ui/components/Notifications/SilenceNotificationForm/FormikNotificationField"; +import { + toastError, + toastSuccess +} from "@flanksource-ui/components/Toast/toast"; +import { useMutation } from "@tanstack/react-query"; +import { Form, Formik } from "formik"; +import { FaCircleNotch } from "react-icons/fa"; +import { useSearchParams } from "react-router-dom"; + +export default function NotificationSilenceForm() { + const [searchParam] = useSearchParams(); + + const component_id = searchParam.get("component_id") ?? undefined; + const config_id = searchParam.get("config_id") ?? undefined; + const check_id = searchParam.get("check_id") ?? undefined; + const canary_id = searchParam.get("canary_id") ?? undefined; + + const initialValues: Partial = { + component_id, + config_id, + check_id, + canary_id + }; + + const { isLoading, mutate } = useMutation({ + mutationFn: (data: SilenceNotificationRequest) => silenceNotification(data), + onSuccess: () => { + // do something + toastSuccess("Silenced notification"); + }, + onError: (error) => { + // do something + console.error(error); + toastError("Failed to silence notification"); + } + }); + + return ( +
+ > + initialValues={initialValues} + onSubmit={(v) => + mutate({ + ...v, + from: new Date(v.from!).toISOString(), + until: new Date(v.until!).toISOString() + } as SilenceNotificationRequest) + } + > +
+ + + + +
+ +
+ + +
+ ); +} diff --git a/src/pages/Settings/NotificationSilencePage.tsx b/src/pages/Settings/NotificationSilencePage.tsx new file mode 100644 index 000000000..6bd892a09 --- /dev/null +++ b/src/pages/Settings/NotificationSilencePage.tsx @@ -0,0 +1,34 @@ +import NotificationSilenceForm from "@flanksource-ui/components/Notifications/SilenceNotificationForm/NotificationSilenceForm"; +import { + BreadcrumbChild, + BreadcrumbNav, + BreadcrumbRoot +} from "@flanksource-ui/ui/BreadcrumbNav"; +import { Head } from "@flanksource-ui/ui/Head"; +import { SearchLayout } from "@flanksource-ui/ui/Layout/SearchLayout"; + +export default function NotificationSilencePage() { + return ( + <> + + + Notifications + , + Silence + ]} + /> + } + contentClass="p-0 h-full" + > +
+

Silence Notification

+ +
+
+ + ); +}