From 3283b3c1a5fcc19a41be28be603f7a9fc1a23e22 Mon Sep 17 00:00:00 2001 From: Maina Wycliffe Date: Mon, 4 Sep 2023 15:22:16 +0300 Subject: [PATCH] feat: add menu option to submit playbook run for component Build on top of #1352 PR Closes #1353 fix: close modal on save fix: fix submit relaoding full page and wrong endpoint fix: fix playbook submission params --- src/api/axios.ts | 9 ++ src/api/query-hooks/playbooks.tsx | 30 ++++- src/api/services/playbooks.ts | 10 +- .../Playbooks/Runs/AddPlaybookToRunParams.tsx | 30 +++++ .../Playbooks/Runs/SelectPlaybookToRun.tsx | 40 +++++++ .../Playbooks/Runs/SubmitPlaybookRunForm.tsx | 106 ++++++++++++++++++ .../TopologyCard/TopologyDropdownMenu.tsx | 11 ++ .../TopologySidebar/TopologyActionBar.tsx | 32 +++++- 8 files changed, 263 insertions(+), 5 deletions(-) create mode 100644 src/components/Playbooks/Runs/AddPlaybookToRunParams.tsx create mode 100644 src/components/Playbooks/Runs/SelectPlaybookToRun.tsx create mode 100644 src/components/Playbooks/Runs/SubmitPlaybookRunForm.tsx diff --git a/src/api/axios.ts b/src/api/axios.ts index 6564fb6dbd..b05f212d9a 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -87,6 +87,15 @@ export const Auth = axios.create({ } }); +export const PlaybookAPI = axios.create({ + baseURL: `${API_BASE}/playbook`, + headers: { + Accept: "application/json", + Prefer: "return=representation", + "Content-Type": "application/json" + } +}); + export const Rback = axios.create({ baseURL: `${API_BASE}/rbac`, headers: { diff --git a/src/api/query-hooks/playbooks.tsx b/src/api/query-hooks/playbooks.tsx index a1684ea742..2e371122e1 100644 --- a/src/api/query-hooks/playbooks.tsx +++ b/src/api/query-hooks/playbooks.tsx @@ -1,6 +1,16 @@ -import { UseQueryOptions, useQuery } from "@tanstack/react-query"; +import { + UseMutationOptions, + UseQueryOptions, + useMutation, + useQuery +} from "@tanstack/react-query"; import { PlaybookSpec } from "../../components/Playbooks/Settings/PlaybookSpecsTable"; -import { getAllPlaybooksSpecs, getPlaybookSpec } from "../services/playbooks"; +import { + getAllPlaybooksSpecs, + getPlaybookSpec, + submitPlaybookRun +} from "../services/playbooks"; +import { SubmitPlaybookRunFormValues } from "../../components/Playbooks/Runs/SubmitPlaybookRunForm"; export function useGetAllPlaybookSpecs( options: UseQueryOptions = {} @@ -28,3 +38,19 @@ export function useGetPlaybookSpecsDetails(id: string) { } ); } + +export function useSubmitPlaybookRunMutation( + options: Omit< + UseMutationOptions, + "mutationFn" + > = {} +) { + return useMutation({ + mutationFn: async ( + input: Omit + ) => { + return submitPlaybookRun(input); + }, + ...options + }); +} diff --git a/src/api/services/playbooks.ts b/src/api/services/playbooks.ts index c33f06aaa6..074d8b64ae 100644 --- a/src/api/services/playbooks.ts +++ b/src/api/services/playbooks.ts @@ -1,10 +1,11 @@ +import { SubmitPlaybookRunFormValues } from "../../components/Playbooks/Runs/SubmitPlaybookRunForm"; import { NewPlaybookSpec, PlaybookSpec, UpdatePlaybookSpec } from "../../components/Playbooks/Settings/PlaybookSpecsTable"; import { AVATAR_INFO } from "../../constants"; -import { IncidentCommander } from "../axios"; +import { IncidentCommander, PlaybookAPI } from "../axios"; export async function getAllPlaybooksSpecs() { const res = await IncidentCommander.get( @@ -42,3 +43,10 @@ export async function deletePlaybookSpec(id: string) { ); return res.data; } + +export async function submitPlaybookRun( + input: Omit +) { + const res = await PlaybookAPI.post("/run", input); + return res.data; +} diff --git a/src/components/Playbooks/Runs/AddPlaybookToRunParams.tsx b/src/components/Playbooks/Runs/AddPlaybookToRunParams.tsx new file mode 100644 index 0000000000..cec7cb5f8b --- /dev/null +++ b/src/components/Playbooks/Runs/AddPlaybookToRunParams.tsx @@ -0,0 +1,30 @@ +import { useFormikContext } from "formik"; +import { SubmitPlaybookRunFormValues } from "./SubmitPlaybookRunForm"; +import { useMemo } from "react"; +import FormikTextInput from "../../Forms/Formik/FormikTextInput"; + +export default function AddPlaybookToRunParams() { + const { values } = useFormikContext(); + + const playbookParams = useMemo( + () => + (values.playbook_spec?.parameters as { name: string; label: string }[]) ?? + [], + [values.playbook_spec] + ); + + return ( +
+
Playbook Run Params
+
+ {playbookParams.length > 0 ? ( + playbookParams.map(({ name, label }) => ( + + )) + ) : ( +
No parameters for this playbook.
+ )} +
+
+ ); +} diff --git a/src/components/Playbooks/Runs/SelectPlaybookToRun.tsx b/src/components/Playbooks/Runs/SelectPlaybookToRun.tsx new file mode 100644 index 0000000000..2d1065d786 --- /dev/null +++ b/src/components/Playbooks/Runs/SelectPlaybookToRun.tsx @@ -0,0 +1,40 @@ +import { useFormikContext } from "formik"; +import { useGetAllPlaybookSpecs } from "../../../api/query-hooks/playbooks"; +import { Loading } from "../../Loading"; +import { SubmitPlaybookRunFormValues } from "./SubmitPlaybookRunForm"; + +export default function SelectPlaybookToRun() { + const { data: playbooks, isLoading, error } = useGetAllPlaybookSpecs(); + + // access formik state here + const { setFieldValue } = useFormikContext(); + + return ( +
+
Select a Playbook
+
+ {error && !playbooks && ( +
{error.message}
+ )} + {isLoading && !playbooks && } + {playbooks && ( +
+ {playbooks?.map(({ id, name, spec }) => ( +
{ + // update formik state here + setFieldValue("id", id); + setFieldValue("playbook_spec", spec); + }} + className="flex flex-row rounded-md border border-gray-200 hover:border-gray-400 hover:bg-gray-200 hover:font-semibold cursor py-2 px-2" + > + {name} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/components/Playbooks/Runs/SubmitPlaybookRunForm.tsx b/src/components/Playbooks/Runs/SubmitPlaybookRunForm.tsx new file mode 100644 index 0000000000..90f9f6e121 --- /dev/null +++ b/src/components/Playbooks/Runs/SubmitPlaybookRunForm.tsx @@ -0,0 +1,106 @@ +import { Form, Formik } from "formik"; +import SelectPlaybookToRun from "./SelectPlaybookToRun"; +import { useMemo } from "react"; +import AddPlaybookToRunParams from "./AddPlaybookToRunParams"; +import { Modal } from "../../Modal"; +import { Button } from "../../Button"; +import { useSubmitPlaybookRunMutation } from "../../../api/query-hooks/playbooks"; +import { toastError } from "../../Toast/toast"; + +export type SubmitPlaybookRunFormValues = { + // if this is present in the form, we show step to add params + id: string; + component_id?: string; + config_id?: string; + params?: Record; + // do not send this to the backend + playbook_spec?: Record; +}; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +type SubmitPlaybookRunFormForComponentProps = { + type: "component"; + componentId: string; +} & Props; + +type SubmitPlaybookRunFormForConfigProps = { + type: "config"; + configId: string; +} & Props; + +type SubmitPlaybookRunFormProps = + | SubmitPlaybookRunFormForComponentProps + | SubmitPlaybookRunFormForConfigProps; + +export default function SubmitPlaybookRunForm({ + isOpen, + onClose, + ...props +}: SubmitPlaybookRunFormProps) { + const initialValues: Partial = useMemo( + () => ({ + playbook_id: undefined, + params: undefined, + ...(props.type === "component" + ? { component_id: props.componentId } + : { + config_id: props.configId + }) + }), + [props] + ); + + const { mutate: submitPlaybookRun } = useSubmitPlaybookRunMutation({ + onSuccess: () => { + toastError("Playbook run submitted successfully"); + onClose(); + }, + onError: (error) => { + toastError(error.message); + } + }); + + return ( + + { + const { playbook_spec, ...rest } = values; + submitPlaybookRun(rest as SubmitPlaybookRunFormValues); + }} + > + {({ values, handleSubmit }) => { + return ( +
+
+ {!values.id ? ( + + ) : ( + + )} +
+
+
+
+ ); + }} +
+
+ ); +} diff --git a/src/components/TopologyCard/TopologyDropdownMenu.tsx b/src/components/TopologyCard/TopologyDropdownMenu.tsx index 6a8a9c5bcf..6ea0fd008a 100644 --- a/src/components/TopologyCard/TopologyDropdownMenu.tsx +++ b/src/components/TopologyCard/TopologyDropdownMenu.tsx @@ -8,6 +8,7 @@ import { EvidenceType } from "../../api/services/evidence"; import { TopologyConfigLinkModal } from "../TopologyConfigLinkModal/TopologyConfigLinkModal"; import { useFeatureFlagsContext } from "../../context/FeatureFlagsContext"; import { features } from "../../services/permissions/features"; +import SubmitPlaybookRunForm from "../Playbooks/Runs/SubmitPlaybookRunForm"; type TopologyMenuItemProps = { onClick?: () => void; @@ -86,6 +87,7 @@ export const TopologyDropdownMenu = ({ ] = useState(false); const [attachAsAsset, setAttachAsAsset] = useState(false); const [linkToConfig, setLinkToConfig] = useState(false); + const [submitPlaybookRun, setSubmitPlaybookRun] = useState(false); return ( <> @@ -130,6 +132,8 @@ export const TopologyDropdownMenu = ({ ? () => setAttachAsAsset(true) : label === "Link to config" ? () => setLinkToConfig(true) + : label === "Submit Playbook Run" + ? () => setSubmitPlaybookRun(true) : undefined } /> @@ -154,6 +158,13 @@ export const TopologyDropdownMenu = ({ topology={topology} /> + setSubmitPlaybookRun(false)} + type="component" + componentId={topology.id} + /> + {linkToConfig && ( setLinkToConfig(false)} diff --git a/src/components/TopologySidebar/TopologyActionBar.tsx b/src/components/TopologySidebar/TopologyActionBar.tsx index ec0f370b20..cc1fbce1ea 100644 --- a/src/components/TopologySidebar/TopologyActionBar.tsx +++ b/src/components/TopologySidebar/TopologyActionBar.tsx @@ -1,18 +1,20 @@ import React, { useMemo, useState } from "react"; import { IconType } from "react-icons"; -import { BiShow, BiHide, BiZoomIn, BiLink } from "react-icons/bi"; +import { BiHide, BiLink, BiShow, BiZoomIn } from "react-icons/bi"; import { ImTree } from "react-icons/im"; import { MdAlarmAdd, MdTableRows } from "react-icons/md"; +import { VscDebugConsole } from "react-icons/vsc"; import { useNavigate } from "react-router-dom"; import useUpdateComponentMutation from "../../api/query-hooks/mutations/useUpdateComponentMutation"; import { EvidenceType } from "../../api/services/evidence"; import { Topology } from "../../context/TopologyPageContext"; +import { ActionLink } from "../ActionLink/ActionLink"; import { AttachEvidenceDialog } from "../AttachEvidenceDialog"; import TopologySnapshotModal from "../TopologyCard/TopologySnapshotModal"; -import { ActionLink } from "../ActionLink/ActionLink"; import { TopologyConfigLinkModal } from "../TopologyConfigLinkModal/TopologyConfigLinkModal"; import { useFeatureFlagsContext } from "../../context/FeatureFlagsContext"; import { features } from "../../services/permissions/features"; +import SubmitPlaybookRunForm from "../Playbooks/Runs/SubmitPlaybookRunForm"; type TopologyActionItem = { label: string; @@ -200,6 +202,22 @@ export const topologyActionItems: Readonly[] = [ onModalOpen()} icon={icon} text={text} /> ); } + }, + { + label: "Submit Playbook Run", + icon: VscDebugConsole, + isShown: () => true, + ContainerComponent: function Container({ + child: ChildComponent, + topology, + icon, + text, + openModalAction: onModalOpen = () => {} + }) { + return ( + onModalOpen()} icon={icon} text={text} /> + ); + } } ]; @@ -231,6 +249,7 @@ export default function TopologyActionBar({ const [attachAsAsset, setAttachAsAsset] = useState(false); const [linkToConfig, setLinkToConfig] = useState(false); + const [submitPlaybookRun, setSubmitPlaybookRun] = useState(false); const onOpenModal = (label: string) => { switch (label) { @@ -240,6 +259,8 @@ export default function TopologyActionBar({ return setAttachAsAsset(true); case "Link to config": return setLinkToConfig(true); + case "Submit Playbook Run": + return setSubmitPlaybookRun(true); default: break; } @@ -294,6 +315,13 @@ export default function TopologyActionBar({ topology={topology} /> + setSubmitPlaybookRun(false)} + type="component" + componentId={topology.id} + /> + {linkToConfig && ( setLinkToConfig(false)}