diff --git a/docs/images/rule-creation.png b/docs/images/rule-creation.png new file mode 100644 index 000000000..5b84df637 Binary files /dev/null and b/docs/images/rule-creation.png differ diff --git a/docs/images/rule-table.png b/docs/images/rule-table.png new file mode 100644 index 000000000..f2e8400a9 Binary files /dev/null and b/docs/images/rule-table.png differ diff --git a/docs/mint.json b/docs/mint.json index b7a905b59..83cd9180f 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -30,6 +30,10 @@ "overview/keyconcepts", "overview/usecases", "overview/ruleengine", + { + "group": "Enrichments", + "pages": ["overview/enrichment/mapping"] + }, "overview/examples", "overview/comparison" ] @@ -168,7 +172,7 @@ "pages": ["workflows/examples/multi-step-alert"] }, "workflows/state" - ] + ] }, { "group": "Keep API", @@ -246,10 +250,7 @@ "cli/commands/workflow-run", { "group": "keep workflow runs", - "pages": [ - "cli/commands/runs-logs", - "cli/commands/runs-list" - ] + "pages": ["cli/commands/runs-logs", "cli/commands/runs-list"] } ] }, @@ -257,7 +258,6 @@ "cli/commands/cli-config", "cli/commands/cli-version", "cli/commands/cli-whoami" - ] } ] diff --git a/docs/overview/enrichment/mapping.mdx b/docs/overview/enrichment/mapping.mdx new file mode 100644 index 000000000..9827d90f2 --- /dev/null +++ b/docs/overview/enrichment/mapping.mdx @@ -0,0 +1,50 @@ +--- +title: "Alert Enrichment - Mapping" +--- + +# Alert Mapping and Enrichment + +Keep's Alert Mapping and Enrichment feature provides a powerful mechanism for dynamically enhancing alert data by leveraging external data sources, such as CSV files. This feature allows for the matching of incoming alerts to specific records in a CSV file based on predefined attributes (matchers) and enriching those alerts with additional information from the matched records. + +## Introduction + +In complex monitoring environments, the need to enrich alert data with additional context is critical for effective alert analysis and response. Keep's Alert Mapping and Enrichment enables users to define rules that match alerts to rows in a CSV file, appending or modifying alert attributes with the values from matching rows. This process adds significant value to each alert, providing deeper insights and enabling more precise and informed decision-making. + +## How It Works + +1. **Rule Definition**: Users define mapping rules that specify which alert attributes (matchers) should be used for matching alerts to rows in a CSV file. +2. **CSV File Specification**: A CSV file is associated with each mapping rule. This file contains additional data that should be added to alerts matching the rule. +3. **Alert Matching**: When an alert is received, the system checks if it matches the conditions of any mapping rule based on the specified matchers. +4. **Data Enrichment**: If a match is found, the alert is enriched with additional data from the corresponding row in the CSV file. + +## Core Concepts + +- **Matchers**: Attributes within the alert used to identify matching rows within the CSV file. Common matchers include identifiers like `service` or `region`. +- **CSV File**: A structured file containing rows of data. Each column represents a potential attribute that can be added to an alert. +- **Enrichment**: The process of adding new attributes or modifying existing ones in an alert based on the data from a matching CSV row. + +## Creating a Mapping Rule + +To create an alert mapping and enrichment rule: + + + + + +1. **Define the Matchers**: Specify which alert attributes will be used to match rows in the CSV file. +2. **Upload the CSV File**: Provide the CSV file containing the data for enrichment. +3. **Configure the Rule**: Set additional parameters, such as whether the rule should override existing alert attributes. + +## Practical Example + +Imagine you have a CSV file with columns representing different aspects of your infrastructure, such as `region`, `responsible_team`, and `severity_override`. By creating a mapping rule that matches alerts based on `service` and `region`, you can automatically enrich alerts with the responsible team and adjust severity based on the matched row in the CSV file. + +## Best Practices + +- **Keep CSV Files Updated**: Regularly update the CSV files to reflect the current state of your infrastructure and operational data. +- **Use Specific Matchers**: Define matchers that are unique and relevant to ensure accurate matching. +- **Monitor Rule Performance**: Review the application of mapping rules to ensure they are working as expected and adjust them as necessary. + + + + diff --git a/examples/workflows/bash_example.yml b/examples/workflows/bash_example.yml new file mode 100644 index 000000000..4b5518ef9 --- /dev/null +++ b/examples/workflows/bash_example.yml @@ -0,0 +1,29 @@ +workflow: + id: Resend-Python-service + description: Python Resend Mail + triggers: + - type: manual + owners: [] + services: [] + steps: + - name: run-script + provider: + config: '{{ providers.default-bash }}' + type: bash + with: + command: python3 test.py + timeout: 5 + actions: + - condition: + - assert: '{{ steps.run-script.results.return_code }} == 0' + name: assert-condition + type: assert + name: trigger-resend + provider: + type: resend + config: "{{ providers.resend-test }}" + with: + _from: "onboarding@resend.dev" + to: "youremail.dev@gmail.com" + subject: "Python test is up!" + html:

Python test is up!

diff --git a/keep-ui/app/alerts/[id]/page.tsx b/keep-ui/app/alerts/[id]/page.tsx new file mode 100644 index 000000000..89a007659 --- /dev/null +++ b/keep-ui/app/alerts/[id]/page.tsx @@ -0,0 +1,15 @@ +import AlertsPage from "../alerts.client"; + +type PageProps = { + params: { id: string }; + searchParams: { [key: string]: string | string[] | undefined }; +}; + +export default function Page({ params }: PageProps) { + return ; +} + +export const metadata = { + title: "Keep - Alerts", + description: "Single pane of glass for all your alerts.", +}; diff --git a/keep-ui/app/alerts/alert-actions.tsx b/keep-ui/app/alerts/alert-actions.tsx index 0f068155b..b5d799cb5 100644 --- a/keep-ui/app/alerts/alert-actions.tsx +++ b/keep-ui/app/alerts/alert-actions.tsx @@ -91,8 +91,8 @@ export default function AlertActions({ }; async function addOrUpdatePreset() { - const presetName = prompt("Enter new preset name"); - if (presetName) { + const newPresetName = prompt("Enter new preset name"); + if (newPresetName) { const distinctAlertNames = Array.from( new Set(selectedAlerts.map((alert) => alert.name)) ); @@ -107,18 +107,18 @@ export default function AlertActions({ Authorization: `Bearer ${session?.accessToken}`, "Content-Type": "application/json", }, - body: JSON.stringify({ name: presetName, options: options }), + body: JSON.stringify({ name: newPresetName, options: options }), }); if (response.ok) { - toast(`Preset ${presetName} created!`, { + toast(`Preset ${newPresetName} created!`, { position: "top-left", type: "success", }); presetsMutator(); clearRowSelection(); - router.replace(`${pathname}?selectedPreset=${presetName}`); + router.replace(`/alerts/${newPresetName}`); } else { - toast(`Error creating preset ${presetName}`, { + toast(`Error creating preset ${newPresetName}`, { position: "top-left", type: "error", }); diff --git a/keep-ui/app/alerts/alert-assign-ticket-modal.tsx b/keep-ui/app/alerts/alert-assign-ticket-modal.tsx index f28fe1ba5..5d1806484 100644 --- a/keep-ui/app/alerts/alert-assign-ticket-modal.tsx +++ b/keep-ui/app/alerts/alert-assign-ticket-modal.tsx @@ -1,13 +1,13 @@ -import React from 'react'; -import Select, { components } from 'react-select'; -import { Dialog } from '@headlessui/react'; -import { Button, TextInput } from '@tremor/react'; -import { PlusIcon } from '@heroicons/react/20/solid' -import { useForm, Controller, SubmitHandler } from 'react-hook-form'; +import React from "react"; +import Select, { components } from "react-select"; +import { Button, TextInput } from "@tremor/react"; +import { PlusIcon } from "@heroicons/react/20/solid"; +import { useForm, Controller, SubmitHandler } from "react-hook-form"; import { Providers } from "./../providers/providers"; import { useSession } from "next-auth/react"; -import { getApiURL } from 'utils/apiUrl'; -import { AlertDto } from './models'; +import { getApiURL } from "utils/apiUrl"; +import { AlertDto } from "./models"; +import Modal from "@/components/ui/Modal"; interface AlertAssignTicketModalProps { handleClose: () => void; @@ -33,14 +33,21 @@ interface FormData { ticket_url: string; } -const AlertAssignTicketModal = ({ handleClose, ticketingProviders, alert }: AlertAssignTicketModalProps) => { - - const { handleSubmit, control, formState: { errors } } = useForm(); +const AlertAssignTicketModal = ({ + handleClose, + ticketingProviders, + alert, +}: AlertAssignTicketModalProps) => { + const { + handleSubmit, + control, + formState: { errors }, + } = useForm(); // get the token const { data: session } = useSession(); // if this modal should not be open, do nothing - if(!alert) return null; + if (!alert) return null; const onSubmit: SubmitHandler = async (data) => { try { @@ -54,7 +61,6 @@ const AlertAssignTicketModal = ({ handleClose, ticketingProviders, alert }: Aler fingerprint: alert.fingerprint, }; - const response = await fetch(`${getApiURL()}/alerts/enrich`, { method: "POST", headers: { @@ -82,29 +88,28 @@ const AlertAssignTicketModal = ({ handleClose, ticketingProviders, alert }: Aler const providerOptions: OptionType[] = ticketingProviders.map((provider) => ({ id: provider.id, value: provider.id, - label: provider.details.name || '', + label: provider.details.name || "", type: provider.type, })); const customOptions: OptionType[] = [ ...providerOptions, { - value: 'add_provider', - label: 'Add another ticketing provider', - icon: 'plus', + value: "add_provider", + label: "Add another ticketing provider", + icon: "plus", isAddProvider: true, - id: 'add_provider', - type: '', + id: "add_provider", + type: "", }, ]; const handleOnChange = (option: any) => { - if (option.value === 'add_provider') { - window.open('/providers?labels=ticketing', '_blank'); + if (option.value === "add_provider") { + window.open("/providers?labels=ticketing", "_blank"); } }; - const Option = (props: any) => { // Check if the option is 'add_provider' const isAddProvider = props.data.isAddProvider; @@ -115,9 +120,17 @@ const AlertAssignTicketModal = ({ handleClose, ticketingProviders, alert }: Aler {isAddProvider ? ( ) : ( - props.data.type && + props.data.type && ( + + ) )} - {props.data.label} + + {props.data.label} + ); @@ -132,7 +145,13 @@ const AlertAssignTicketModal = ({ handleClose, ticketingProviders, alert }: Aler {data.isAddProvider ? ( ) : ( - data.type && + data.type && ( + + ) )} {children} @@ -144,63 +163,105 @@ const AlertAssignTicketModal = ({ handleClose, ticketingProviders, alert }: Aler const isOpen = alert !== null; return ( - -
- -
- Assign Ticket - {ticketingProviders.length > 0 ? ( -
-
- - ( - { + field.onChange(option); + handleOnChange(option); + }} + components={{ Option, SingleValue }} + /> + )} + /> +
+
+ + ( + <> + + {errors.ticket_url && ( + + {errors.ticket_url.message} + + )} + + )} + /> +
+
+ + -
- )} -
+ + ) : ( +
+

+ Please connect at least one ticketing provider to use this + feature. +

+ + +
+ )}
-
+ ); }; diff --git a/keep-ui/app/alerts/alert-history.tsx b/keep-ui/app/alerts/alert-history.tsx index 7588432d1..d5e23286a 100644 --- a/keep-ui/app/alerts/alert-history.tsx +++ b/keep-ui/app/alerts/alert-history.tsx @@ -1,4 +1,3 @@ -import { Dialog, Transition } from "@headlessui/react"; import { Fragment, useState } from "react"; import { AlertDto } from "./models"; import { AlertTable } from "./alert-table"; @@ -10,21 +9,20 @@ import { PaginationState } from "@tanstack/react-table"; import { useRouter, useSearchParams } from "next/navigation"; import { toDateObjectWithFallback } from "utils/helpers"; import Image from "next/image"; +import Modal from "@/components/ui/Modal"; interface AlertHistoryPanelProps { alertsHistoryWithDate: (Omit & { lastReceived: Date; })[]; + presetName: string; } const AlertHistoryPanel = ({ alertsHistoryWithDate, + presetName, }: AlertHistoryPanelProps) => { const router = useRouter(); - const searchParams = useSearchParams(); - const currentPreset = searchParams - ? searchParams.get("selectedPreset") - : "Feed"; const [rowPagination, setRowPagination] = useState({ pageIndex: 0, @@ -63,11 +61,7 @@ const AlertHistoryPanel = ({ @@ -98,9 +92,10 @@ const AlertHistoryPanel = ({ interface Props { alerts: AlertDto[]; + presetName: string; } -export function AlertHistory({ alerts }: Props) { +export function AlertHistory({ alerts, presetName }: Props) { const router = useRouter(); const searchParams = useSearchParams(); @@ -109,9 +104,6 @@ export function AlertHistory({ alerts }: Props) { ? searchParams.get("fingerprint") === alert.fingerprint : undefined ); - const currentPreset = searchParams - ? searchParams.get("selectedPreset") - : "Feed"; const { useAlertHistory } = useAlerts(); const { data: alertHistory = [] } = useAlertHistory(selectedAlert, { @@ -124,50 +116,16 @@ export function AlertHistory({ alerts }: Props) { })); return ( - - - router.replace(`/alerts?selectedPreset=${currentPreset}`, { - scroll: false, - }) - } - > - -
- -
-
- - - - - -
-
-
-
+ > + + ); } diff --git a/keep-ui/app/alerts/alert-menu.tsx b/keep-ui/app/alerts/alert-menu.tsx index 138a91723..bef97c3bb 100644 --- a/keep-ui/app/alerts/alert-menu.tsx +++ b/keep-ui/app/alerts/alert-menu.tsx @@ -1,5 +1,5 @@ import { Menu, Portal, Transition } from "@headlessui/react"; -import { Fragment, useState } from "react"; +import { Fragment } from "react"; import { Icon } from "@tremor/react"; import { ArchiveBoxIcon, @@ -7,26 +7,33 @@ import { PlusIcon, TrashIcon, UserPlusIcon, + PlayIcon, } from "@heroicons/react/24/outline"; import { useSession } from "next-auth/react"; import { getApiURL } from "utils/apiUrl"; import Link from "next/link"; import { ProviderMethod } from "app/providers/providers"; import { AlertDto } from "./models"; -import { AlertMethodTransition } from "./alert-method-transition"; import { useFloating } from "@floating-ui/react-dom"; import { useProviders } from "utils/hooks/useProviders"; import { useAlerts } from "utils/hooks/useAlerts"; import { useRouter } from "next/navigation"; -import { usePresets } from "utils/hooks/usePresets"; interface Props { alert: AlertDto; isMenuOpen: boolean; setIsMenuOpen: (key: string) => void; + setRunWorkflowModalAlert?: (alert: AlertDto) => void; + presetName: string; } -export default function AlertMenu({ alert, isMenuOpen, setIsMenuOpen }: Props) { +export default function AlertMenu({ + alert, + isMenuOpen, + setIsMenuOpen, + setRunWorkflowModalAlert, + presetName, +}: Props) { const router = useRouter(); const apiUrl = getApiURL(); @@ -38,13 +45,9 @@ export default function AlertMenu({ alert, isMenuOpen, setIsMenuOpen }: Props) { const { useAllAlerts } = useAlerts(); const { mutate } = useAllAlerts({ revalidateOnMount: false }); - const { getCurrentPreset } = usePresets(); const { data: session } = useSession(); - const [isOpen, setIsOpen] = useState(false); - const [method, setMethod] = useState(null); - const { refs, x, y } = useFloating(); const alertName = alert.name; @@ -130,9 +133,16 @@ export default function AlertMenu({ alert, isMenuOpen, setIsMenuOpen }: Props) { return false; }; - const openMethodTransition = (method: ProviderMethod) => { - setMethod(method); - setIsOpen(true); + const openMethodModal = (method: ProviderMethod) => { + router.replace( + `/alerts/${presetName}?methodName=${method.name}&providerId=${ + provider!.id + }&alertFingerprint=${alert.fingerprint}`, + { + scroll: false, + } + ); + handleCloseMenu(); }; const canAssign = !alert.assignee; @@ -180,6 +190,22 @@ export default function AlertMenu({ alert, isMenuOpen, setIsMenuOpen }: Props) { style={{ left: (x ?? 0) - 50, top: y ?? 0 }} >
+ + {({ active }) => ( + + )} + {({ active }) => ( { router.replace( - `/alerts?fingerprint=${ - alert.fingerprint - }&selectedPreset=${getCurrentPreset()}`, + `/alerts/${presetName}?fingerprint=${alert.fingerprint}`, { scroll: false, } @@ -239,12 +263,6 @@ export default function AlertMenu({ alert, isMenuOpen, setIsMenuOpen }: Props) { className={`${ active ? "bg-slate-200" : "text-gray-900" } group flex w-full items-center rounded-md px-2 py-2 text-xs`} - // disabled={!!alert.assignee && currentUser.email !== alert.assignee} - // title={`${ - // !!alert.assignee && currentUser.email !== alert.assignee - // ? "Cannot unassign other users" - // : "" - // }`} > { - openMethodTransition(method); + openMethodModal(method); }} > {/* TODO: We can probably make this icon come from the server as well */} @@ -318,21 +336,6 @@ export default function AlertMenu({ alert, isMenuOpen, setIsMenuOpen }: Props) { )} - {method !== null ? ( - { - setIsOpen(false); - setMethod(null); - handleCloseMenu(); - }} - method={method} - alert={alert} - provider={provider} - /> - ) : ( - <> - )} ); } diff --git a/keep-ui/app/alerts/alert-method-modal.tsx b/keep-ui/app/alerts/alert-method-modal.tsx new file mode 100644 index 000000000..994985a28 --- /dev/null +++ b/keep-ui/app/alerts/alert-method-modal.tsx @@ -0,0 +1,245 @@ +// TODO: this needs to be refactored +import { useEffect, useState } from "react"; +import { + Provider, + ProviderMethod, + ProviderMethodParam, +} from "app/providers/providers"; +import { getSession } from "next-auth/react"; +import { getApiURL } from "utils/apiUrl"; +import { toast } from "react-toastify"; +import Loading from "app/loading"; +import { + Button, + TextInput, + Text, + Select, + SelectItem, + DatePicker, +} from "@tremor/react"; +import AlertMethodResultsTable from "./alert-method-results-table"; +import { useAlerts } from "utils/hooks/useAlerts"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useProviders } from "utils/hooks/useProviders"; +import Modal from "@/components/ui/Modal"; + +const supportedParamTypes = ["datetime", "literal", "str"]; + +interface AlertMethodModalProps { + presetName: string; +} + +export function AlertMethodModal({ presetName }: AlertMethodModalProps) { + const searchParams = useSearchParams(); + const router = useRouter(); + + const alertFingerprint = searchParams?.get("alertFingerprint"); + const providerId = searchParams?.get("providerId"); + const methodName = searchParams?.get("methodName"); + const isOpen = !!alertFingerprint && !!providerId && !!methodName; + const { data: providersData = { installed_providers: [] } } = useProviders( + {} + ); + const provider = providersData.installed_providers.find( + (p) => p.id === providerId + ); + const method = provider?.methods?.find((m) => m.name === methodName); + const { useAllAlertsWithSubscription } = useAlerts(); + const { data: alerts, mutate } = useAllAlertsWithSubscription(); + const alert = alerts?.find((a) => a.fingerprint === alertFingerprint); + const [isLoading, setIsLoading] = useState(false); + const [inputParameters, setInputParameters] = useState<{ + [key: string]: string; + }>({}); + const [methodResult, setMethodResult] = useState( + null + ); + + useEffect(() => { + /** + * Auto populate params from the AlertDto + */ + if (method && alert) { + method.func_params?.forEach((param) => { + const alertParamValue = (alert as any)[param.name]; + if (alertParamValue) { + setInputParameters((prevParams) => { + return { ...prevParams, [param.name]: alertParamValue }; + }); + } + }); + } + }, [alert, method]); + + if (!isOpen || !provider || !method || !alert) { + return <>; + } + + const handleClose = () => { + setInputParameters({}); + setMethodResult(null); + router.replace(`/alerts/${presetName}`); + }; + + const validateAndSetParams = ( + key: string, + value: string, + mandatory: boolean + ) => { + const newUserParams = { + ...inputParameters, + [key]: value, + }; + if (value === "" && mandatory) { + delete newUserParams[key]; + } + setInputParameters(newUserParams); + }; + + const getInputs = (param: ProviderMethodParam) => { + if (supportedParamTypes.includes(param.type.toLowerCase()) === false) { + return <>; + } + + return ( +
+ + {param.name.replaceAll("_", " ")} + {param.mandatory ? ( + * + ) : ( + <> + )} + + {param.type.toLowerCase() === "literal" && ( + + )} + {param.type.toLowerCase() === "str" && ( + + validateAndSetParams(param.name, value, param.mandatory) + } + /> + )} + {param.type.toLowerCase() === "datetime" && ( + { + if (value) { + validateAndSetParams( + param.name, + value.toISOString(), + param.mandatory + ); + } + }} + /> + )} +
+ ); + }; + + const invokeMethod = async ( + provider: Provider, + method: ProviderMethod, + userParams: { [key: string]: string } + ) => { + const session = await getSession(); + const apiUrl = getApiURL(); + + try { + const response = await fetch( + `${apiUrl}/providers/${provider.id}/invoke/${method.func_name}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${session!.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(userParams), + } + ); + const responseObject = await response.json(); + if (response.ok) { + if (method.type === "action") mutate(); + toast.success(`Successfully called "${method.name}"`, { + position: toast.POSITION.TOP_LEFT, + }); + if (method.type === "view") { + setMethodResult(responseObject); + setIsLoading(false); + } + } else { + toast.error( + `Failed to invoke "${method.name}" on ${ + provider.details.name ?? provider.id + } due to ${responseObject.detail}`, + { position: toast.POSITION.TOP_LEFT } + ); + } + } catch (e: any) { + toast.error( + `Failed to invoke "${method.name}" on ${ + provider.details.name ?? provider.id + } due to ${e.message}`, + { position: toast.POSITION.TOP_LEFT } + ); + handleClose(); + } finally { + if (method.type === "action") { + handleClose(); + } + } + }; + + const isInvokeEnabled = () => { + return method.func_params + ?.filter((fp) => fp.mandatory) + .every((fp) => + Object.keys({ + ...inputParameters, + }).includes(fp.name) + ); + }; + + return ( + + {isLoading ? ( + + ) : methodResult ? ( + + ) : ( +
+ {method.func_params?.map((param) => { + return getInputs(param); + })} + +
+ )} +
+ ); +} diff --git a/keep-ui/app/alerts/alert-method-transition.tsx b/keep-ui/app/alerts/alert-method-transition.tsx deleted file mode 100644 index 51ea7e6dc..000000000 --- a/keep-ui/app/alerts/alert-method-transition.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { Dialog, Transition } from "@headlessui/react"; -import { Fragment, useEffect, useState } from "react"; -import { - Provider, - ProviderMethod, - ProviderMethodParam, -} from "app/providers/providers"; -import { AlertDto } from "./models"; -import { getSession } from "next-auth/react"; -import { getApiURL } from "utils/apiUrl"; -import { toast } from "react-toastify"; -import Loading from "app/loading"; -import { - Button, - TextInput, - Text, - Select, - SelectItem, - DatePicker, -} from "@tremor/react"; -import AlertMethodResultsTable from "./alert-method-results-table"; -import { useAlerts } from "utils/hooks/useAlerts"; - -interface Props { - isOpen: boolean; - closeModal: () => void; - method: ProviderMethod | null; - alert: AlertDto; - provider?: Provider; -} - -export function AlertMethodTransition({ - isOpen, - closeModal, - method, - provider, - alert, -}: Props) { - const [isLoading, setIsLoading] = useState(true); - const [autoParams, setAutoParams] = useState<{ [key: string]: string }>({}); - const [userParams, setUserParams] = useState<{ [key: string]: string }>({}); - const [results, setResults] = useState(null); - - const { useAllAlerts } = useAlerts(); - const { mutate } = useAllAlerts(); - - const validateAndSetUserParams = ( - key: string, - value: string, - mandatory: boolean - ) => { - const newUserParams = { - ...userParams, - [key]: value, - }; - if (value === "" && mandatory) { - delete newUserParams[key]; - } - setUserParams(newUserParams); - }; - - const getUserParamInput = (param: ProviderMethodParam) => { - return ( -
- - {param.name.replaceAll("_", " ")} - {param.mandatory ? ( - * - ) : ( - <> - )} - - {param.type.toLowerCase() === "literal" && ( - - )} - {param.type.toLowerCase() === "str" && ( - - validateAndSetUserParams(param.name, value, param.mandatory) - } - /> - )} - {param.type.toLowerCase() === "datetime" && ( - { - if (value) { - validateAndSetUserParams( - param.name, - value.toISOString(), - param.mandatory - ); - } - }} - /> - )} -
- ); - }; - - const invokeMethod = async ( - provider: Provider, - method: ProviderMethod, - methodParams: { [key: string]: string }, - userParams: { [key: string]: string }, - closeModal: () => void - ) => { - const session = await getSession(); - const apiUrl = getApiURL(); - - try { - const response = await fetch( - `${apiUrl}/providers/${provider.id}/invoke/${method.func_name}`, - { - method: "POST", - headers: { - Authorization: `Bearer ${session!.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ ...methodParams, ...userParams }), - } - ); - const response_object = await response.json(); - if (response.ok) { - if (method.type === "action") mutate(undefined, { optimisticData: [] }); - toast.success(`Successfully called "${method.name}"`, { - position: toast.POSITION.TOP_LEFT, - }); - if (method.type === "view") { - setResults(response_object); - setIsLoading(false); - } - } else { - toast.error( - `Failed to invoke "${method.name}" on ${ - provider.details.name ?? provider.id - } due to ${response_object.detail}`, - { position: toast.POSITION.TOP_LEFT } - ); - } - } catch (e: any) { - toast.error( - `Failed to invoke "${method.name}" on ${ - provider.details.name ?? provider.id - } due to ${e.message}`, - { position: toast.POSITION.TOP_LEFT } - ); - closeModal(); - } finally { - if (method.type === "action") { - closeModal(); - } - } - }; - - useEffect(() => { - const newAutoParams = { ...autoParams }; - // Auto populate params from the alert itself - method?.func_params?.forEach((param) => { - if (Object.keys(alert).includes(param.name)) { - newAutoParams[param.name] = alert[ - param.name as keyof typeof alert - ] as string; - } - }); - if (autoParams !== newAutoParams) { - setAutoParams(newAutoParams); - // Invoke the method if all params are auto populated - if ( - method?.func_params?.every((param) => - Object.keys(newAutoParams).includes(param.name) - ) - ) { - // This means all method params are auto populated - invokeMethod(provider!, method!, newAutoParams, {}, closeModal); - } else { - setIsLoading(false); - } - } - }, [method, alert, provider, closeModal]); - - if (!method || !provider) { - return <>; - } - - const buttonEnabled = () => { - return method.func_params - ?.filter((fp) => fp.mandatory) - .every((fp) => - Object.keys({ ...autoParams, ...userParams }).includes(fp.name) - ); - }; - - return ( - - - -
- -
-
- - - {isLoading ? ( - - ) : results ? ( - - ) : ( -
- {method.func_params?.map((param) => { - if (!Object.keys(autoParams).includes(param.name)) { - return getUserParamInput(param); - } - return ; - })} - -
- )} -
-
-
-
-
-
- ); -} diff --git a/keep-ui/app/alerts/alert-note-modal.tsx b/keep-ui/app/alerts/alert-note-modal.tsx index 358aa6d21..3872e56c9 100644 --- a/keep-ui/app/alerts/alert-note-modal.tsx +++ b/keep-ui/app/alerts/alert-note-modal.tsx @@ -1,136 +1,129 @@ -'use client'; +"use client"; -import React, { useContext, useEffect, useState } from 'react'; +import React, { useEffect, useState } from "react"; // https://github.com/zenoamaro/react-quill/issues/292 -const ReactQuill = typeof window === 'object' ? require('react-quill') : () => false; -import 'react-quill/dist/quill.snow.css'; -import { Button } from '@tremor/react'; -import { getApiURL } from '../../utils/apiUrl'; -import { useSession } from 'next-auth/react'; -import { Alert } from 'app/workflows/builder/alert'; -import { AlertDto } from './models'; +const ReactQuill = + typeof window === "object" ? require("react-quill") : () => false; +import "react-quill/dist/quill.snow.css"; +import { Button } from "@tremor/react"; +import { getApiURL } from "../../utils/apiUrl"; +import { useSession } from "next-auth/react"; +import { AlertDto } from "./models"; +import Modal from "@/components/ui/Modal"; interface AlertNoteModalProps { handleClose: () => void; alert: AlertDto | null; } -const AlertNoteModal = ({ - handleClose, - alert - }: AlertNoteModalProps) => { - const [noteContent, setNoteContent] = useState(''); +const AlertNoteModal = ({ handleClose, alert }: AlertNoteModalProps) => { + const [noteContent, setNoteContent] = useState(""); - useEffect(() => { - if (alert) { - setNoteContent(alert.note || ''); - } - }, [alert]); - // get the session - const { data: session } = useSession(); + useEffect(() => { + if (alert) { + setNoteContent(alert.note || ""); + } + }, [alert]); + // get the session + const { data: session } = useSession(); - // if this modal should not be open, do nothing - if(!alert) return null; + // if this modal should not be open, do nothing + if (!alert) return null; - const formats = [ - 'header', - 'bold', - 'italic', - 'underline', - 'list', - 'bullet', - 'link', - 'align', - 'blockquote', - 'code-block', - 'color', - ]; + const formats = [ + "header", + "bold", + "italic", + "underline", + "list", + "bullet", + "link", + "align", + "blockquote", + "code-block", + "color", + ]; - const modules = { - toolbar: [ - [{ header: '1' }, { header: '2' }], - [{ list: 'ordered' }, { list: 'bullet' }], - ['bold', 'italic', 'underline'], - ['link'], - [{ align: [] }], - ['blockquote', 'code-block'], // Add quote and code block options to the toolbar - [{ color: [] }], // Add color option to the toolbar - ], - }; + const modules = { + toolbar: [ + [{ header: "1" }, { header: "2" }], + [{ list: "ordered" }, { list: "bullet" }], + ["bold", "italic", "underline"], + ["link"], + [{ align: [] }], + ["blockquote", "code-block"], // Add quote and code block options to the toolbar + [{ color: [] }], // Add color option to the toolbar + ], + }; - const saveNote = async () => { - try { - // build the formData - const requestData = { - enrichments: { - note: noteContent, - }, - fingerprint: alert.fingerprint, - }; - const response = await fetch(`${getApiURL()}/alerts/enrich`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${session?.accessToken}`, - }, - body: JSON.stringify(requestData), - }); + const saveNote = async () => { + try { + // build the formData + const requestData = { + enrichments: { + note: noteContent, + }, + fingerprint: alert.fingerprint, + }; + const response = await fetch(`${getApiURL()}/alerts/enrich`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.accessToken}`, + }, + body: JSON.stringify(requestData), + }); - if (response.ok) { - // Handle success - console.log('Note saved successfully'); - handleNoteClose() - } else { - // Handle error - console.error('Failed to save note'); - } - } catch (error) { - // Handle unexpected error - console.error('An unexpected error occurred'); + if (response.ok) { + // Handle success + console.log("Note saved successfully"); + handleNoteClose(); + } else { + // Handle error + console.error("Failed to save note"); } - }; + } catch (error) { + // Handle unexpected error + console.error("An unexpected error occurred"); + } + }; - const isOpen = alert !== null; + const isOpen = alert !== null; - const handleNoteClose = () => { - alert.note = noteContent; - setNoteContent(''); - handleClose(); - } + const handleNoteClose = () => { + alert.note = noteContent; + setNoteContent(""); + handleClose(); + }; - return ( -
-
-
{/* Increase the width */} -
Add/Edit Note
- {/* WYSIWYG editor */} - setNoteContent(value)} - theme="snow" // Use the Snow theme - placeholder="Add your note here..." - modules={modules} - formats={formats} // Add formats - /> -
- - -
-
-
+ return ( + + {/* WYSIWYG editor */} + setNoteContent(value)} + theme="snow" // Use the Snow theme + placeholder="Add your note here..." + modules={modules} + formats={formats} // Add formats + /> +
+ +
- ); - }; +
+ ); +}; - export default AlertNoteModal; +export default AlertNoteModal; diff --git a/keep-ui/app/alerts/alert-pagination.tsx b/keep-ui/app/alerts/alert-pagination.tsx index 19da15c16..541aca999 100644 --- a/keep-ui/app/alerts/alert-pagination.tsx +++ b/keep-ui/app/alerts/alert-pagination.tsx @@ -1,5 +1,11 @@ -import { ArrowPathIcon, TableCellsIcon } from "@heroicons/react/24/outline"; -import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons"; +import { + ArrowPathIcon, + ChevronDoubleLeftIcon, + ChevronDoubleRightIcon, + ChevronLeftIcon, + ChevronRightIcon, + TableCellsIcon, +} from "@heroicons/react/24/outline"; import { Button, Select, SelectItem, Text } from "@tremor/react"; import { AlertDto } from "./models"; import { Table } from "@tanstack/react-table"; @@ -22,12 +28,11 @@ export default function AlertPagination({ table, isRefreshAllowed }: Props) { Showing {pageCount === 0 ? 0 : pageIndex + 1} of {pageCount} -
+
-
{isRefreshAllowed && ( + + ); +} diff --git a/keep-ui/app/alerts/alert-table-headers.tsx b/keep-ui/app/alerts/alert-table-headers.tsx index f723df1fb..c22bc4ab5 100644 --- a/keep-ui/app/alerts/alert-table-headers.tsx +++ b/keep-ui/app/alerts/alert-table-headers.tsx @@ -57,16 +57,23 @@ const DraggableHeaderCell = ({ opacity: isDragging ? 0.5 : 1, transform: CSS.Translate.toString(transform), transition, - cursor: isDragging ? "grabbing" : "grab", + cursor: + column.getIsPinned() !== false + ? "default" + : isDragging + ? "grabbing" + : "grab", }; return ( -
+
{children}
{column.getIsPinned() === false && ( diff --git a/keep-ui/app/alerts/alert-table-tab-panel.tsx b/keep-ui/app/alerts/alert-table-tab-panel.tsx index d1b88c3dc..705065858 100644 --- a/keep-ui/app/alerts/alert-table-tab-panel.tsx +++ b/keep-ui/app/alerts/alert-table-tab-panel.tsx @@ -5,18 +5,17 @@ import { AlertTable } from "./alert-table"; import { useAlertTableCols } from "./alert-table-utils"; import { AlertDto, AlertKnownKeys, Preset } from "./models"; import AlertActions from "./alert-actions"; -import { TabPanel } from "@tremor/react"; const getPresetAlerts = (alert: AlertDto, presetName: string): boolean => { - if (presetName === "Deleted") { + if (presetName === "deleted") { return alert.deleted === true; } - if (presetName === "Groups") { + if (presetName === "groups") { return alert.group === true; } - if (presetName === "Feed") { + if (presetName === "feed") { return alert.deleted === false; } @@ -63,6 +62,7 @@ interface Props { isAsyncLoading: boolean; setTicketModalAlert: (alert: AlertDto | null) => void; setNoteModalAlert: (alert: AlertDto | null) => void; + setRunWorkflowModalAlert: (alert: AlertDto | null) => void; } const defaultRowPaginationState = { @@ -76,6 +76,7 @@ export default function AlertTableTabPanel({ isAsyncLoading, setTicketModalAlert, setNoteModalAlert, + setRunWorkflowModalAlert, }: Props) { const [selectedOptions, setSelectedOptions] = useState( preset.options @@ -112,10 +113,12 @@ export default function AlertTableTabPanel({ const alertTableColumns = useAlertTableCols({ additionalColsToGenerate: additionalColsToGenerate, - isCheckboxDisplayed: preset.name !== "Deleted", + isCheckboxDisplayed: preset.name !== "deleted", isMenuDisplayed: true, setTicketModalAlert: setTicketModalAlert, setNoteModalAlert: setNoteModalAlert, + setRunWorkflowModalAlert: setRunWorkflowModalAlert, + presetName: preset.name, }); useEffect(() => { @@ -123,7 +126,7 @@ export default function AlertTableTabPanel({ }, [selectedOptions]); return ( - + <> {selectedRowIds.length ? ( - + ); } diff --git a/keep-ui/app/alerts/alert-table-utils.tsx b/keep-ui/app/alerts/alert-table-utils.tsx index 2553065e5..c3925675e 100644 --- a/keep-ui/app/alerts/alert-table-utils.tsx +++ b/keep-ui/app/alerts/alert-table-utils.tsx @@ -71,15 +71,21 @@ interface GenerateAlertTableColsArg { isMenuDisplayed?: boolean; setNoteModalAlert?: (alert: AlertDto) => void; setTicketModalAlert?: (alert: AlertDto) => void; + setRunWorkflowModalAlert?: (alert: AlertDto) => void; + presetName: string; } -export const useAlertTableCols = ({ - additionalColsToGenerate = [], - isCheckboxDisplayed, - isMenuDisplayed, - setNoteModalAlert, - setTicketModalAlert, -}: GenerateAlertTableColsArg = {}) => { +export const useAlertTableCols = ( + { + additionalColsToGenerate = [], + isCheckboxDisplayed, + isMenuDisplayed, + setNoteModalAlert, + setTicketModalAlert, + setRunWorkflowModalAlert, + presetName, + }: GenerateAlertTableColsArg = { presetName: "feed" } +) => { const [expandedToggles, setExpandedToggles] = useState({}); const [currentOpenMenu, setCurrentOpenMenu] = useState(""); @@ -239,11 +245,13 @@ export const useAlertTableCols = ({ size: 50, cell: (context) => ( ), }), diff --git a/keep-ui/app/alerts/alerts.client.css b/keep-ui/app/alerts/alerts.client.css deleted file mode 100644 index 03673d5b3..000000000 --- a/keep-ui/app/alerts/alerts.client.css +++ /dev/null @@ -1,8 +0,0 @@ -.menu:after { - content: "\FE19"; -} - -.menu:hover { - background-color: rgb(0, 0, 0, 0.1); - border-radius: 5px; -} diff --git a/keep-ui/app/alerts/alerts.client.tsx b/keep-ui/app/alerts/alerts.client.tsx index 622889a60..2ef22a69a 100644 --- a/keep-ui/app/alerts/alerts.client.tsx +++ b/keep-ui/app/alerts/alerts.client.tsx @@ -5,7 +5,11 @@ import { useSession } from "next-auth/react"; import Loading from "../loading"; import Alerts from "./alerts"; -export default function AlertsPage() { +type AlertsPageProps = { + presetName: string; +}; + +export default function AlertsPage({ presetName }: AlertsPageProps) { const { data: session, status } = useSession(); const router = useRouter(); @@ -22,5 +26,5 @@ export default function AlertsPage() { router.push("/signin"); } - return ; + return ; } diff --git a/keep-ui/app/alerts/alerts.tsx b/keep-ui/app/alerts/alerts.tsx index d5f580f81..9099689b7 100644 --- a/keep-ui/app/alerts/alerts.tsx +++ b/keep-ui/app/alerts/alerts.tsx @@ -1,32 +1,33 @@ -import { Card, TabGroup, TabList, Tab, TabPanels } from "@tremor/react"; +import { Card } from "@tremor/react"; import { Preset } from "./models"; import { useMemo, useState } from "react"; -import "./alerts.client.css"; import AlertStreamline from "./alert-streamline"; import { useAlerts } from "utils/hooks/useAlerts"; import { usePresets } from "utils/hooks/usePresets"; import AlertTableTabPanel from "./alert-table-tab-panel"; import { AlertHistory } from "./alert-history"; -import { usePathname, useRouter } from "next/navigation"; +import { useRouter } from "next/navigation"; import AlertAssignTicketModal from "./alert-assign-ticket-modal"; import AlertNoteModal from "./alert-note-modal"; import { useProviders } from "utils/hooks/useProviders"; import { AlertDto } from "./models"; +import { AlertMethodModal } from "./alert-method-modal"; +import AlertRunWorkflowModal from "./alert-run-workflow-modal"; const defaultPresets: Preset[] = [ - { name: "Feed", options: [] }, - { name: "Deleted", options: [] }, - { name: "Groups", options: [] }, + { name: "feed", options: [] }, + { name: "deleted", options: [] }, + { name: "groups", options: [] }, ]; -export default function Alerts() { +type AlertsProps = { + presetName: string; +}; + +export default function Alerts({ presetName }: AlertsProps) { const { useAllAlertsWithSubscription } = useAlerts(); - // get providers - // providers doesnt change often so we can use a longer deduping interval - const { data: providersData = { installed_providers: [] } } = useProviders({ - dedupingInterval: 10000, - revalidateOnMount: false, - }); + + const { data: providersData = { installed_providers: [] } } = useProviders(); const ticketingProviders = useMemo( () => @@ -38,11 +39,11 @@ export default function Alerts() { // hooks for the note and ticket modals const [noteModalAlert, setNoteModalAlert] = useState(); const [ticketModalAlert, setTicketModalAlert] = useState(); + const [runWorkflowModalAlert, setRunWorkflowModalAlert] = + useState(); - const { useAllPresets, getCurrentPreset } = usePresets(); - const pathname = usePathname(); + const { useAllPresets } = usePresets(); const router = useRouter(); - const currentSelectedPreset = getCurrentPreset(); const { data: alerts, @@ -56,12 +57,13 @@ export default function Alerts() { }); const presets = [...defaultPresets, ...savedPresets] as const; - const selectPreset = (presetName: string) => { - router.replace(`${pathname}?selectedPreset=${presetName}`); - }; + const selectedPreset = presets.find( + (preset) => preset.name.toLowerCase() === decodeURIComponent(presetName) + ); - const selectedPresetIndex = - presets.findIndex((preset) => preset.name === currentSelectedPreset) ?? 0; + if (selectedPreset === undefined) { + router.push("/alerts/feed"); + } return ( @@ -71,42 +73,34 @@ export default function Alerts() { lastSubscribedDate={lastSubscribedDate} /> )} - {/* key is necessary to re-render tabs on preset delete */} - - - {presets.map((preset, index) => ( - selectPreset(preset.name)} - > - {preset.name} - - ))} - - - {presets.map((preset) => ( - - ))} - - - setTicketModalAlert(null)} - ticketingProviders={ticketingProviders} - alert={ticketModalAlert ?? null} + {selectedPreset && ( + - setNoteModalAlert(null)} - alert={noteModalAlert ?? null} - /> - + )} + {selectedPreset && ( + + )} + setTicketModalAlert(null)} + ticketingProviders={ticketingProviders} + alert={ticketModalAlert ?? null} + /> + setNoteModalAlert(null)} + alert={noteModalAlert ?? null} + /> + {selectedPreset && } + setRunWorkflowModalAlert(null)} + /> ); } diff --git a/keep-ui/app/alerts/page.tsx b/keep-ui/app/alerts/page.tsx deleted file mode 100644 index 5ed5364b7..000000000 --- a/keep-ui/app/alerts/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import AlertsPage from "./alerts.client"; - - -export default function Page() { - return ; -} - -export const metadata = { - title: "Keep - Alerts", - description: "Single pane of glass for all your alerts.", -}; diff --git a/keep-ui/app/command-menu.tsx b/keep-ui/app/command-menu.tsx deleted file mode 100644 index 67cfcaff8..000000000 --- a/keep-ui/app/command-menu.tsx +++ /dev/null @@ -1,170 +0,0 @@ -"use client"; - -import { Command } from "cmdk"; -import { useState, useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { - GitHubLogoIcon, - FileTextIcon, - TwitterLogoIcon, -} from "@radix-ui/react-icons"; -import { - GlobeAltIcon, - UserGroupIcon, - EnvelopeIcon, - KeyIcon, -} from "@heroicons/react/24/outline"; -import { VscDebugDisconnect } from "react-icons/vsc"; -import { LuWorkflow } from "react-icons/lu"; -import { AiOutlineAlert } from "react-icons/ai"; -import { MdOutlineEngineering } from "react-icons/md"; - -import "../styles/linear.scss"; - -export function CMDK() { - const [open, setOpen] = useState(false); - const router = useRouter(); - - // Toggle the menu when ⌘K is pressed - useEffect(() => { - const down = (e: any) => { - if (e.key === "k" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - setOpen((open) => !open); - } - }; - - document.addEventListener("keydown", down); - return () => document.removeEventListener("keydown", down); - }, []); - return ( -
- -
Keep Command Palette
- - - - No results found. - {navigationItems.map(({ icon, label, shortcut, navigate }) => { - return ( - { - setOpen((open) => !open); - router.push(navigate!); - }} - > - {icon} - {label} -
- {shortcut.map((key) => { - return {key}; - })} -
-
- ); - })} -
-
- - - {externalItems.map(({ icon, label, shortcut, navigate }) => { - return ( - { - setOpen((open) => !open); - window.open(navigate, "_blank"); - }} - > - {icon} - {label} -
- {shortcut.map((key) => { - return {key}; - })} -
-
- ); - })} -
-
-
-
- ); -} - -const navigationItems = [ - { - icon: , - label: "Go to the providers page", - shortcut: ["p"], - navigate: "/providers", - }, - { - icon: , - label: "Go to alert console", - shortcut: ["g"], - navigate: "/alerts", - }, - { - icon: , - label: "Go to alert groups", - shortcut: ["g"], - navigate: "/rules", - }, - { - icon: , - label: "Go to the workflows page", - shortcut: ["wf"], - navigate: "/workflows", - }, - { - icon: , - label: "Go to users management", - shortcut: ["u"], - navigate: "/settings?selectedTab=users", - }, - { - icon: , - label: "Go to generic webhook", - shortcut: ["w"], - navigate: "/settings?selectedTab=webhook", - }, - { - icon: , - label: "Go to SMTP settings", - shortcut: ["s"], - navigate: "/settings?selectedTab=smtp", - }, - { - icon: , - label: "Go to API key", - shortcut: ["a"], - navigate: "/settings?selectedTab=api-key", - }, -]; - -const externalItems = [ - { - icon: , - label: "Keep Docs", - shortcut: ["⇧", "D"], - navigate: "https://docs.keephq.dev", - }, - { - icon: , - label: "Keep Source code", - shortcut: ["⇧", "C"], - navigate: "https://github.com/keephq/keep", - }, - { - icon: , - label: "Keep Twitter", - shortcut: ["⇧", "T"], - navigate: "https://twitter.com/keepalerting", - }, -]; diff --git a/keep-ui/app/dark-mode-toggle.tsx b/keep-ui/app/dark-mode-toggle.tsx index 3f56a1c00..8412965fc 100644 --- a/keep-ui/app/dark-mode-toggle.tsx +++ b/keep-ui/app/dark-mode-toggle.tsx @@ -1,5 +1,6 @@ -import { Switch, Text } from "@tremor/react"; +import { Icon, Switch } from "@tremor/react"; import { useEffect } from "react"; +import { MdDarkMode } from "react-icons/md"; import { useLocalStorage } from "utils/hooks/useLocalStorage"; export default function DarkModeToggle() { @@ -41,15 +42,15 @@ export default function DarkModeToggle() { }, [darkMode]); return ( -
-
- Dark Mode - -
-
+ ); } diff --git a/keep-ui/app/layout.tsx b/keep-ui/app/layout.tsx index 67805ff09..0437f4ed2 100644 --- a/keep-ui/app/layout.tsx +++ b/keep-ui/app/layout.tsx @@ -1,8 +1,8 @@ +import { ReactNode } from "react"; import { NextAuthProvider } from "./auth-provider"; import ErrorBoundary from "./error-boundary"; import { Intercom } from "@/components/ui/Intercom"; import { Mulish } from "next/font/google"; -import { Card } from "@tremor/react"; import "./globals.css"; import "react-toastify/dist/ReactToastify.css"; @@ -14,26 +14,27 @@ const mulish = Mulish({ }); import { ToastContainer } from "react-toastify"; -import Navbar from "./navbar"; +import Navbar from "../components/navbar/Navbar"; -export default async function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { +type RootLayoutProps = { + children: ReactNode; +}; + +export default async function RootLayout({ children }: RootLayoutProps) { return ( - - + + + {/* @ts-ignore-error Server Component */} {/* https://discord.com/channels/752553802359505017/1068089513253019688/1117731746922893333 */} - {children} +
+
+ {children} +
+ +
- {/** footer */} {process.env.GIT_COMMIT_HASH ? ( diff --git a/keep-ui/app/mapping/create-new-mapping.tsx b/keep-ui/app/mapping/create-new-mapping.tsx new file mode 100644 index 000000000..ab0cd5f0a --- /dev/null +++ b/keep-ui/app/mapping/create-new-mapping.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; +import { + TextInput, + Textarea, + Divider, + Subtitle, + Text, + MultiSelect, + MultiSelectItem, + Badge, + Button, +} from "@tremor/react"; +import { useSession } from "next-auth/react"; +import { ChangeEvent, FormEvent, useMemo, useState } from "react"; +import { usePapaParse } from "react-papaparse"; +import { toast } from "react-toastify"; +import { getApiURL } from "utils/apiUrl"; +import { useMappings } from "utils/hooks/useMappingRules"; + +export default function CreateNewMapping() { + const { data: session } = useSession(); + const { mutate } = useMappings(); + const [mapName, setMapName] = useState(""); + const [fileName, setFileName] = useState(""); + const [mapDescription, setMapDescription] = useState(""); + const [selectedAttributes, setSelectedAttributes] = useState([]); + + /** This is everything related with the uploaded CSV file */ + const [parsedData, setParsedData] = useState(null); + const attributes = useMemo(() => { + if (parsedData) { + return Object.keys(parsedData[0]); + } + return []; + }, [parsedData]); + const { readString } = usePapaParse(); + + const readFile = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + setFileName(file?.name || ""); + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result; + if (typeof text === "string") { + readString(text, { + header: true, + complete: (results) => { + if (results.data.length > 0) setParsedData(results.data); + }, + }); + } + }; + if (file) reader.readAsText(file); + }; + + const clearForm = () => { + setMapName(""); + setMapDescription(""); + setParsedData(null); + setSelectedAttributes([]); + }; + + const addRule = async (e: FormEvent) => { + e.preventDefault(); + const apiUrl = getApiURL(); + const response = await fetch(`${apiUrl}/mapping`, { + method: "POST", + headers: { + Authorization: `Bearer ${session?.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: mapName, + description: mapDescription, + file_name: fileName, + matchers: selectedAttributes, + rows: parsedData, + }), + }); + if (response.ok) { + clearForm(); + mutate(); + toast.success("Mapping created successfully"); + } else { + toast.error( + "Failed to create mapping, please contact us if this issue persists." + ); + } + }; + + const submitEnabled = (): boolean => { + return ( + !!mapName && + selectedAttributes.length > 0 && + !!parsedData && + attributes.filter( + (attribute) => selectedAttributes.includes(attribute) === false + ).length > 0 + ); + }; + + return ( +
+ Mapping Metadata +
+ + Name* + + +
+
+ Description +