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: manually enrich alert #2559

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
239 changes: 239 additions & 0 deletions keep-ui/app/(keep)/alerts/EnrichAlertSidePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import { AlertDto } from "./models";
import { Dialog, Transition } from "@headlessui/react";
import React, { Fragment, useEffect, useState } from "react";
import { Button, TextInput } from "@tremor/react";
import { useSession } from "next-auth/react";
import { useApiUrl } from "utils/hooks/useConfig";
import { toast } from "react-toastify";

interface EnrichAlertModalProps {
alert: AlertDto | null | undefined;
isOpen: boolean;
handleClose: () => void;
mutate: () => void;
}

const EnrichAlertSidePanel: React.FC<EnrichAlertModalProps> = ({
alert,
isOpen,
handleClose,
mutate,
}) => {
const { data: session } = useSession();
const apiUrl = useApiUrl();

const [customFields, setCustomFields] = useState<
{ key: string; value: string }[]
>([]);

const [preEnrichedFields, setPreEnrichedFields] = useState<
{ key: string; value: string }[]
>([]);

const [finalData, setFinalData] = useState<Record<string, any>>({});
const [isDataValid, setIsDataValid] = useState<boolean>(false);

const addCustomField = () => {
setCustomFields((prev) => [...prev, { key: "", value: "" }]);
};

const updateCustomField = (
index: number,
field: "key" | "value",
value: string,
) => {
setCustomFields((prev) =>
prev.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
);
};

const removeCustomField = (index: number) => {
setCustomFields((prev) => prev.filter((_, i) => i !== index));
};

useEffect(() => {
const preEnrichedFields =
alert?.enriched_fields?.map((key) => {
return { key, value: alert[key as keyof AlertDto] as any };
}) || [];
setCustomFields(preEnrichedFields);
setPreEnrichedFields(preEnrichedFields);
}, [alert]);

useEffect(() => {
const validateData = () => {
const areFieldsIdentical =
customFields.length === preEnrichedFields.length &&
customFields.every((field) => {
const matchingField = preEnrichedFields.find(
(preField) => preField.key === field.key,
);
return matchingField && matchingField.value === field.value;
});

if (areFieldsIdentical) {
setIsDataValid(false);
return;
}

const keys = customFields.map((field) => field.key);
const hasEmptyKeys = keys.some((key) => !key);
const hasDuplicateKeys = new Set(keys).size !== keys.length;

setIsDataValid(!hasEmptyKeys && !hasDuplicateKeys);
};

const calculateFinalData = () => {
return customFields.reduce(
(acc, field) => {
if (field.key) {
acc[field.key] = field.value;
}
return acc;
},
{} as Record<string, string>,
);
};
setFinalData(calculateFinalData());
validateData();
}, [customFields, preEnrichedFields]);

useEffect(() => {
if (!isOpen) {
setFinalData({});
setIsDataValid(false);
}
}, [isOpen]);

const handleSave = async () => {
const requestData = {
enrichments: finalData,
fingerprint: alert?.fingerprint,
};

const enrichedFieldKeys = customFields.map((field) => field.key);
const preEnrichedFieldKeys = preEnrichedFields.map((field) => field.key);

const unEnrichedFields = preEnrichedFieldKeys.filter((key) => {
if (!enrichedFieldKeys.includes(key)) {
return key;
}
});

let fieldsUnEnrichedSuccessfully = true;

if (unEnrichedFields.length != 0) {
const unEnrichmentResponse = await fetch(`${apiUrl}/alerts/unenrich`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session?.accessToken}`,
},
body: JSON.stringify({
fingerprint: alert?.fingerprint,
enrichments: unEnrichedFields,
}),
});
fieldsUnEnrichedSuccessfully = unEnrichmentResponse.ok;
}

const response = await fetch(`${apiUrl}/alerts/enrich`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session?.accessToken}`,
},
body: JSON.stringify(requestData),
});

if (response.ok && fieldsUnEnrichedSuccessfully) {
toast.success("Alert enriched successfully");
await mutate();
handleClose();
} else {
toast.error("Failed to enrich alert");
}
};

const renderCustomFields = () =>
customFields.map((field, index) => (
<div key={index} className="mb-4 flex items-center gap-2">
<TextInput
placeholder="Field Name"
value={field.key}
onChange={(e) => updateCustomField(index, "key", e.target.value)}
required
className="w-1/3"
/>
<TextInput
placeholder="Field Value"
value={field.value}
onChange={(e) => updateCustomField(index, "value", e.target.value)}
className="w-full"
/>
<Button color="red" onClick={() => removeCustomField(index)}>
βœ•
</Button>
</div>
));

return (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use the side panel component you created, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'll add that once #2581 is merged.

<Transition appear show={isOpen} as={Fragment}>
<Dialog onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/30 z-20" aria-hidden="true" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="transition ease-in-out duration-300 transform"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<Dialog.Panel className="fixed right-0 inset-y-0 w-1/3 bg-white z-30 flex flex-col">
<div className="flex justify-between items-center min-w-full p-6">
<h2 className="text-lg font-semibold">Enrich Alert</h2>
</div>

<div className="flex-1 overflow-auto pb-6 px-6 mt-2">
{renderCustomFields()}
</div>

<div className="sticky bottom-0 p-4 border-t border-gray-200 bg-white flex justify-end gap-2">
<Button
onClick={addCustomField}
className="bg-orange-500"
variant="primary"
>
+ Add Field
</Button>
<Button
onClick={handleSave}
color="orange"
variant="primary"
disabled={!isDataValid}
>
Save
</Button>
<Button onClick={handleClose} color="orange" variant="secondary">
Close
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</Dialog>
</Transition>
);
};

export default EnrichAlertSidePanel;
19 changes: 19 additions & 0 deletions keep-ui/app/(keep)/alerts/alert-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
UserPlusIcon,
PlayIcon,
EyeIcon,
AdjustmentsHorizontalIcon,
} from "@heroicons/react/24/outline";
import { IoNotificationsOffOutline } from "react-icons/io5";

Expand Down Expand Up @@ -216,6 +217,24 @@ export default function AlertMenu({
</button>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={() => {
router.replace(
`/alerts/${presetName}?alertPayloadFingerprint=${alert.fingerprint}&enrich=true`,
);
handleCloseMenu();
}}
className={`${
active ? "bg-slate-200" : "text-gray-900"
} group flex w-full items-center rounded-md px-2 py-2 text-xs`}
>
<AdjustmentsHorizontalIcon className="mr-2 h-4 w-4" aria-hidden="true" />
Enrich
</button>
)}
</Menu.Item>
{canAssign && (
<Menu.Item>
{({ active }) => (
Expand Down
30 changes: 25 additions & 5 deletions keep-ui/app/(keep)/alerts/alerts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import AlertChangeStatusModal from "./alert-change-status-modal";
import { useAlertPolling } from "utils/hooks/usePusher";
import NotFound from "@/app/(keep)/not-found";
import { useHydratedSession as useSession } from "@/shared/lib/hooks/useHydratedSession";
import EnrichAlertSidePanel from "@/app/(keep)/alerts/EnrichAlertSidePanel";

const defaultPresets: Preset[] = [
{
Expand Down Expand Up @@ -75,9 +76,9 @@ export default function Alerts({ presetName }: AlertsProps) {
const ticketingProviders = useMemo(
() =>
providersData.installed_providers.filter((provider) =>
provider.tags.includes("ticketing")
provider.tags.includes("ticketing"),
),
[providersData.installed_providers]
[providersData.installed_providers],
);

const searchParams = useSearchParams();
Expand All @@ -91,6 +92,9 @@ export default function Alerts({ presetName }: AlertsProps) {
>();
const [changeStatusAlert, setChangeStatusAlert] = useState<AlertDto | null>();
const [viewAlertModal, setViewAlertModal] = useState<AlertDto | null>();
const [viewEnrichAlertModal, setEnrichAlertModal] =
useState<AlertDto | null>();
const [isEnrichSidebarOpen, setIsEnrichSidebarOpen] = useState(false);
const { useAllPresets } = usePresets();

const { data: savedPresets = [] } = useAllPresets({
Expand All @@ -99,7 +103,7 @@ export default function Alerts({ presetName }: AlertsProps) {
const presets = [...defaultPresets, ...savedPresets] as const;

const selectedPreset = presets.find(
(preset) => preset.name.toLowerCase() === decodeURIComponent(presetName)
(preset) => preset.name.toLowerCase() === decodeURIComponent(presetName),
);

const { data: pollAlerts } = useAlertPolling();
Expand All @@ -112,14 +116,21 @@ export default function Alerts({ presetName }: AlertsProps) {

const { status: sessionStatus } = useSession();
const isLoading = isAsyncLoading || sessionStatus === "loading";

useEffect(() => {
const fingerprint = searchParams?.get("alertPayloadFingerprint");
if (fingerprint) {
const enrich = searchParams?.get("enrich");
console.log(enrich, fingerprint);
if (fingerprint && enrich) {
const alert = alerts?.find((alert) => alert.fingerprint === fingerprint);
setEnrichAlertModal(alert);
setIsEnrichSidebarOpen(true);
} else if (fingerprint) {
const alert = alerts?.find((alert) => alert.fingerprint === fingerprint);
setViewAlertModal(alert);
} else {
setViewAlertModal(null);
setEnrichAlertModal(null);
setIsEnrichSidebarOpen(false);
}
}, [searchParams, alerts]);

Expand Down Expand Up @@ -180,6 +191,15 @@ export default function Alerts({ presetName }: AlertsProps) {
handleClose={() => router.replace(`/alerts/${presetName}`)}
mutate={mutateAlerts}
/>
<EnrichAlertSidePanel
alert={viewEnrichAlertModal}
isOpen={isEnrichSidebarOpen}
handleClose={() => {
setIsEnrichSidebarOpen(false);
router.replace(`/alerts/${presetName}`);
}}
mutate={mutateAlerts}
/>
</>
);
}
Loading