From 201e6f1a01239a699ddb2f355e885382e224fd05 Mon Sep 17 00:00:00 2001 From: Maina Wycliffe Date: Mon, 16 Sep 2024 16:03:46 +0300 Subject: [PATCH] feat: add silence notification form page Fixes #2284 --- src/App.tsx | 30 +++-- src/api/axios.ts | 9 ++ src/api/services/notifications.ts | 12 +- src/api/services/topology.ts | 10 ++ src/api/types/notifications.ts | 11 ++ .../Forms/Formik/FormikCanaryDropdown.tsx | 47 ++++++++ .../Forms/Formik/FormikDurationPicker.tsx | 79 +++++++++++++ .../FormikNotificationField.tsx | 110 ++++++++++++++++++ .../NotificationSilenceForm.tsx | 79 +++++++++++++ .../Settings/NotificationSilencePage.tsx | 34 ++++++ src/ui/TimeRangePicker/TimeRangeList.tsx | 81 +++++++------ src/ui/TimeRangePicker/TimeRangePicker.tsx | 11 +- .../TimeRangePicker/TimeRangePickerBody.tsx | 6 +- src/ui/TimeRangePicker/rangeOptions.ts | 105 ++++++++++++++--- 14 files changed, 556 insertions(+), 68 deletions(-) create mode 100644 src/api/types/notifications.ts create mode 100644 src/components/Forms/Formik/FormikCanaryDropdown.tsx create mode 100644 src/components/Forms/Formik/FormikDurationPicker.tsx create mode 100644 src/components/Notifications/SilenceNotificationForm/FormikNotificationField.tsx create mode 100644 src/components/Notifications/SilenceNotificationForm/NotificationSilenceForm.tsx create mode 100644 src/pages/Settings/NotificationSilencePage.tsx 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..5b44bbcfa --- /dev/null +++ b/src/components/Forms/Formik/FormikDurationPicker.tsx @@ -0,0 +1,79 @@ +import { + rangeOptionsCategories, + TimeRangeOption +} from "@flanksource-ui/ui/TimeRangePicker/rangeOptions"; +import { TimeRangePicker } from "@flanksource-ui/ui/TimeRangePicker/TimeRangePicker"; +import dayjs from "dayjs"; +import { useFormikContext } from "formik"; +import { useMemo } from "react"; + +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 = "flex flex-col py-2" +}: FormikDurationPickerProps) { + const { values, setFieldValue } = + useFormikContext>(); + + const value = useMemo(() => { + // if until is a valid date, then, set time range value to and from + if (dayjs(values[to]).isValid()) { + return { + display: "Custom", + from: values[from] ?? "", + to: values[to] ?? "", + type: "absolute" + } satisfies TimeRangeOption; + } + + const relativeValues = rangeOptionsCategories.find( + (category) => + category.name === "Relative time ranges" && category.type === "future" + )?.options; + + return { + type: "relative", + display: values[to] + ? relativeValues?.find( + (v) => v.type === "relative" && v.range === values[to] + )?.display! + : "", + range: values[to] ?? "" + } satisfies TimeRangeOption; + }, [from, to, values]); + + return ( +
+ {label && } +
+ { + console.log(value, "value"); + if (value.type === "absolute") { + setFieldValue(from, value.from); + setFieldValue(to, value.to); + } else if (value.type === "relative") { + setFieldValue(to, value.range); + setFieldValue(from, "now"); + } + }} + showFutureTimeRanges + className="w-full" + /> +
+
+ ); +} diff --git a/src/components/Notifications/SilenceNotificationForm/FormikNotificationField.tsx b/src/components/Notifications/SilenceNotificationForm/FormikNotificationField.tsx new file mode 100644 index 000000000..fad009f3f --- /dev/null +++ b/src/components/Notifications/SilenceNotificationForm/FormikNotificationField.tsx @@ -0,0 +1,110 @@ +import FormikResourceSelectorDropdown from "@flanksource-ui/components/Forms/Formik/FormikResourceSelectorDropdown"; +import { Switch } from "@flanksource-ui/ui/FormControls/Switch"; +import { useFormikContext } from "formik"; +import { useMemo, 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 "Catalog"; + }); + + const fieldName = useMemo(() => { + switch (switchOption) { + case "Component": + return { + name: "component_id", + label: "Component" + }; + case "Catalog": + return { + name: "config_id", + label: "Catalog" + }; + case "Check": + return { + name: "check_id", + label: "Check" + }; + case "Canary": + return { + name: "canary_id", + label: "Canary" + }; + } + }, [switchOption]); + + 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; + } + }} + /> +
+ + +
+
+ ); +} diff --git a/src/components/Notifications/SilenceNotificationForm/NotificationSilenceForm.tsx b/src/components/Notifications/SilenceNotificationForm/NotificationSilenceForm.tsx new file mode 100644 index 000000000..9725a2832 --- /dev/null +++ b/src/components/Notifications/SilenceNotificationForm/NotificationSilenceForm.tsx @@ -0,0 +1,79 @@ +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) => { + return mutate({ + ...v + } 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

+ +
+
+ + ); +} diff --git a/src/ui/TimeRangePicker/TimeRangeList.tsx b/src/ui/TimeRangePicker/TimeRangeList.tsx index c739d5992..da4052fe0 100644 --- a/src/ui/TimeRangePicker/TimeRangeList.tsx +++ b/src/ui/TimeRangePicker/TimeRangeList.tsx @@ -6,6 +6,7 @@ type TimeRangeListProps = { currentRange?: TimeRangeOption; changeRangeValue: (val: TimeRangeOption) => void; setShowCalendar: (val: boolean) => void; + showFutureTimeRanges?: boolean; } & React.HTMLProps; export function TimeRangeList({ @@ -13,6 +14,7 @@ export function TimeRangeList({ currentRange, changeRangeValue = () => {}, setShowCalendar = () => {}, + showFutureTimeRanges = false, ...rest }: TimeRangeListProps) { const isChecked = (option?: TimeRangeOption, value?: TimeRangeOption) => { @@ -27,43 +29,50 @@ export function TimeRangeList({ return (
- {rangeOptionsCategories.map((category, index) => { - return ( -
-
- {category.name} -
- {category.options.map((option) => { - return ( - - ); - })} -
- ); - })} + + {}} + name="range-checkbox" + id={`checkbox-${option.display}`} + /> + + ); + })} +
+ ); + })} ); } diff --git a/src/ui/TimeRangePicker/TimeRangePicker.tsx b/src/ui/TimeRangePicker/TimeRangePicker.tsx index 530131657..bf3d88b13 100644 --- a/src/ui/TimeRangePicker/TimeRangePicker.tsx +++ b/src/ui/TimeRangePicker/TimeRangePicker.tsx @@ -12,12 +12,14 @@ type TimeRangePickerType = Omit< > & { value?: TimeRangeOption; onChange: (val: TimeRangeOption) => void; + showFutureTimeRanges?: boolean; }; export function TimeRangePicker({ onChange = () => {}, value, - className = "w-fit" + className = "w-fit", + showFutureTimeRanges = false }: TimeRangePickerType) { const currentRange = useMemo((): TimeRangeOption | undefined => { return value; @@ -143,19 +145,19 @@ export function TimeRangePicker({ {({ open }) => { return ( <> {updateDisplayValue && ( -
+
{updateDisplayValue}
)} {!updateDisplayValue && ( -
+
Please select time range
)} @@ -180,6 +182,7 @@ export function TimeRangePicker({ closePicker={() => close()} currentRange={currentRange} changeRangeValue={changeRangeValue} + showFutureTimeRanges={showFutureTimeRanges} /> ); }} diff --git a/src/ui/TimeRangePicker/TimeRangePickerBody.tsx b/src/ui/TimeRangePicker/TimeRangePickerBody.tsx index 7d1ee5d2b..794526780 100644 --- a/src/ui/TimeRangePicker/TimeRangePickerBody.tsx +++ b/src/ui/TimeRangePicker/TimeRangePickerBody.tsx @@ -21,13 +21,15 @@ type TimeRangePickerBodyProps = { closePicker: any; currentRange?: TimeRangeOption; changeRangeValue: (range: TimeRangeOption) => void; + showFutureTimeRanges?: boolean; }; export function TimeRangePickerBody({ isOpen, closePicker = () => {}, currentRange, - changeRangeValue = () => {} + changeRangeValue = () => {}, + showFutureTimeRanges = false }: TimeRangePickerBodyProps) { const [params, setParams] = useSearchParams(); const [recentRanges, setRecentRanges] = useAtom(recentlyUsedTimeRangesAtom); @@ -153,7 +155,6 @@ export function TimeRangePickerBody({
@@ -238,6 +239,7 @@ export function TimeRangePickerBody({ currentRange={currentRange} changeRangeValue={changeRangeValue} setShowCalendar={setShowCalendar} + showFutureTimeRanges={showFutureTimeRanges} />
diff --git a/src/ui/TimeRangePicker/rangeOptions.ts b/src/ui/TimeRangePicker/rangeOptions.ts index 09f3b70dc..b49138850 100644 --- a/src/ui/TimeRangePicker/rangeOptions.ts +++ b/src/ui/TimeRangePicker/rangeOptions.ts @@ -23,6 +23,7 @@ export type TimeRangeOption = export type RangeOptionsCategory = { name: string; + type: "future" | "past"; options: TimeRangeOption[]; }; @@ -31,107 +32,179 @@ export const displayTimeFormat = "YYYY-MM-DD HH:mm"; export const rangeOptionsCategories: RangeOptionsCategory[] = [ { name: "Relative time ranges", + type: "past", options: [ { type: "relative", display: "5 minutes", - range: "now-5m" }, { display: "15 minutes", type: "relative", - range: "now-15m" }, { display: "30 minutes", type: "relative", - range: "now-30m" }, { display: "1 hour", type: "relative", - range: "now-1h" }, { display: "3 hours", type: "relative", - range: "now-3h" }, { display: "6 hours", type: "relative", - range: "now-6h" }, { display: "12 hours", type: "relative", - range: "now-12h" }, { display: "24 hours", type: "relative", - range: "now-24h" }, { display: "2 days", type: "relative", - range: "now-2d" }, { display: "7 days", type: "relative", - range: "now-7d" }, { display: "30 days", type: "relative", - range: "now-30d" }, { display: "90 days", type: "relative", - range: "now-90d" }, { display: "6 months", type: "relative", - range: "now-6M" }, { display: "1 year", type: "relative", - range: "now-1y" }, { display: "2 year", type: "relative", - range: "now-2y" }, { display: "5 year", type: "relative", - range: "now-5y" } ] }, + { + name: "Relative time ranges", + type: "future", + options: [ + { + type: "relative", + display: "5 minutes", + range: "now+5m" + }, + { + display: "15 minutes", + type: "relative", + range: "now+15m" + }, + { + display: "30 minutes", + type: "relative", + range: "now+30m" + }, + { + display: "1 hour", + type: "relative", + range: "now+1h" + }, + { + display: "3 hours", + type: "relative", + range: "now+3h" + }, + { + display: "6 hours", + type: "relative", + range: "now+6h" + }, + { + display: "12 hours", + type: "relative", + range: "now+12h" + }, + { + display: "24 hours", + type: "relative", + range: "now+24h" + }, + { + display: "2 days", + type: "relative", + range: "now+2d" + }, + { + display: "7 days", + type: "relative", + range: "now+7d" + }, + { + display: "30 days", + type: "relative", + range: "now+30d" + }, + { + display: "90 days", + type: "relative", + range: "now+90d" + }, + { + display: "6 months", + type: "relative", + range: "now+6M" + }, + { + display: "1 year", + type: "relative", + range: "now+1y" + }, + { + display: "2 year", + type: "relative", + range: "now+2y" + }, + { + display: "5 year", + type: "relative", + range: "now+5y" + } + ] + }, { name: "Other quick ranges", + type: "past", options: [ { display: "Yesterday",