diff --git a/changes/22118-run-scripts-fe b/changes/22118-run-scripts-fe new file mode 100644 index 000000000000..e5dc2f51ddf8 --- /dev/null +++ b/changes/22118-run-scripts-fe @@ -0,0 +1 @@ +* Add ability to trigger script run on policy failure \ No newline at end of file diff --git a/frontend/interfaces/policy.ts b/frontend/interfaces/policy.ts index 621c52f63872..d501374a57dc 100644 --- a/frontend/interfaces/policy.ts +++ b/frontend/interfaces/policy.ts @@ -1,5 +1,6 @@ import PropTypes from "prop-types"; import { SelectedPlatformString } from "interfaces/platform"; +import { IScript } from "./script"; // Legacy PropTypes used on host interface export default PropTypes.shape({ @@ -42,8 +43,8 @@ export interface IPolicy { critical: boolean; calendar_events_enabled: boolean; install_software?: IPolicySoftwareToInstall; + run_script?: Pick; } - export interface IPolicySoftwareToInstall { name: string; software_title_id: number; @@ -90,6 +91,10 @@ export interface ILoadTeamPoliciesResponse { policies?: IPolicyStats[]; } +export interface ILoadTeamPolicyResponse { + policy: IPolicyStats; +} + export interface IPolicyFormData { description?: string | number | boolean | undefined; resolution?: string | number | boolean | undefined; @@ -102,6 +107,8 @@ export interface IPolicyFormData { calendar_events_enabled?: boolean; // undefined from GET/LIST when not set, null for PATCH to unset software_title_id?: number | null; + // null for PATCH to unset - note asymmetry with GET/LIST - see IPolicy.run_script + script_id?: number | null; } export interface IPolicyNew { diff --git a/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx b/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx index 1935b38b1133..8c8982d6bf69 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx @@ -5,6 +5,9 @@ import { NotificationContext } from "context/notification"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; +import { AxiosResponse } from "axios"; +import { IApiError } from "../../../../../interfaces/errors"; +import { getErrorMessage } from "../ScriptUploader/helpers"; const baseClass = "delete-script-modal"; @@ -27,8 +30,15 @@ const DeleteScriptModal = ({ try { await scriptAPI.deleteScript(id); renderFlash("success", "Successfully deleted!"); - } catch { - renderFlash("error", "Couldn’t delete. Please try again."); + } catch (e) { + const error = e as AxiosResponse; + const apiErrMessage = getErrorMessage(error); + renderFlash( + "error", + apiErrMessage.includes("Policy automation") + ? apiErrMessage + : "Couldn’t delete. Please try again." + ); } onDone(); }; diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index 559ab2cd08e6..997a3785b46c 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -21,7 +21,7 @@ import { IPoliciesCountResponse, IPolicy, } from "interfaces/policy"; -import { API_ALL_TEAMS_ID, ITeamConfig } from "interfaces/team"; +import { API_ALL_TEAMS_ID, API_NO_TEAM_ID, ITeamConfig } from "interfaces/team"; import configAPI from "services/entities/config"; import globalPoliciesAPI, { @@ -52,6 +52,8 @@ import CalendarEventsModal from "./components/CalendarEventsModal"; import { ICalendarEventsFormData } from "./components/CalendarEventsModal/CalendarEventsModal"; import InstallSoftwareModal from "./components/InstallSoftwareModal"; import { IInstallSoftwareFormData } from "./components/InstallSoftwareModal/InstallSoftwareModal"; +import PolicyRunScriptModal from "./components/PolicyRunScriptModal"; +import { IPolicyRunScriptFormData } from "./components/PolicyRunScriptModal/PolicyRunScriptModal"; interface IManagePoliciesPageProps { router: InjectedRouter; @@ -74,6 +76,13 @@ interface IManagePoliciesPageProps { const DEFAULT_SORT_DIRECTION = "asc"; const DEFAULT_PAGE_SIZE = 20; const DEFAULT_SORT_COLUMN = "name"; +const [ + DEFAULT_AUTOMATION_UPDATE_SUCCESS_MSG, + DEFAULT_AUTOMATION_UPDATE_ERR_MSG, +] = [ + "Successfully updated policy automations.", + "Could not update policy automations. Please try again.", +]; const baseClass = "manage-policies-page"; @@ -127,23 +136,18 @@ const ManagePolicyPage = ({ }, }); + // loading state used by various policy updates on this page const [isUpdatingPolicies, setIsUpdatingPolicies] = useState(false); - const [isUpdatingCalendarEvents, setIsUpdatingCalendarEvents] = useState( - false - ); - const [ - isUpdatingPolicySoftwareInstall, - setIsUpdatingPolicySoftwareInstall, - ] = useState(false); - const [isUpdatingOtherWorkflows, setIsUpdatingOtherWorkflows] = useState( - false - ); + const [selectedPolicyIds, setSelectedPolicyIds] = useState([]); const [showAddPolicyModal, setShowAddPolicyModal] = useState(false); const [showDeletePolicyModal, setShowDeletePolicyModal] = useState(false); const [showInstallSoftwareModal, setShowInstallSoftwareModal] = useState( false ); + const [showPolicyRunScriptModal, setShowPolicyRunScriptModal] = useState( + false + ); const [showCalendarEventsModal, setShowCalendarEventsModal] = useState(false); const [showOtherWorkflowsModal, setShowOtherWorkflowsModal] = useState(false); const [ @@ -472,6 +476,10 @@ const ManagePolicyPage = ({ setShowInstallSoftwareModal(!showInstallSoftwareModal); }; + const togglePolicyRunScriptModal = () => { + setShowPolicyRunScriptModal(!showPolicyRunScriptModal); + }; + const toggleCalendarEventsModal = () => { setShowCalendarEventsModal(!showCalendarEventsModal); }; @@ -484,6 +492,9 @@ const ManagePolicyPage = ({ case "install_software": toggleInstallSoftwareModal(); break; + case "run_script": + togglePolicyRunScriptModal(); + break; case "other_workflows": toggleOtherWorkflowsModal(); break; @@ -495,20 +506,17 @@ const ManagePolicyPage = ({ webhook_settings: Pick; integrations: IZendeskJiraIntegrations; }) => { - setIsUpdatingOtherWorkflows(true); + setIsUpdatingPolicies(true); try { await (!isAllTeamsSelected ? teamsAPI.update(requestBody, teamIdForApi) : configAPI.update(requestBody)); - renderFlash("success", "Successfully updated policy automations."); + renderFlash("success", DEFAULT_AUTOMATION_UPDATE_SUCCESS_MSG); } catch { - renderFlash( - "error", - "Could not update policy automations. Please try again." - ); + renderFlash("error", DEFAULT_AUTOMATION_UPDATE_ERR_MSG); } finally { toggleOtherWorkflowsModal(); - setIsUpdatingOtherWorkflows(false); + setIsUpdatingPolicies(false); !isAllTeamsSelected ? refetchTeamConfig() : refetchConfig(); } }; @@ -517,7 +525,7 @@ const ManagePolicyPage = ({ formData: IInstallSoftwareFormData ) => { try { - setIsUpdatingPolicySoftwareInstall(true); + setIsUpdatingPolicies(true); const changedPolicies = formData.filter((formPolicy) => { const prevPolicyState = policiesAvailableToAutomate.find( (policy) => policy.id === formPolicy.id @@ -548,7 +556,7 @@ const ManagePolicyPage = ({ responses.concat( changedPolicies.map((changedPolicy) => { return teamPoliciesAPI.update(changedPolicy.id, { - // "software_title_id:" 0 will unset software install for the policy + // "software_title_id": 0 will unset software install for the policy // "software_title_id": X will set the value to the given integer (except 0). software_title_id: changedPolicy.swIdToInstall || 0, team_id: teamIdForApi, @@ -558,20 +566,70 @@ const ManagePolicyPage = ({ await Promise.all(responses); await wait(100); // prevent race refetchTeamPolicies(); - renderFlash("success", "Successfully updated policy automations."); + renderFlash("success", DEFAULT_AUTOMATION_UPDATE_SUCCESS_MSG); } catch { - renderFlash( - "error", - "Could not update policy automations. Please try again." - ); + renderFlash("error", DEFAULT_AUTOMATION_UPDATE_ERR_MSG); } finally { toggleInstallSoftwareModal(); - setIsUpdatingPolicySoftwareInstall(false); + setIsUpdatingPolicies(false); + } + }; + + const onUpdatePolicyRunScript = async ( + formData: IPolicyRunScriptFormData + ) => { + try { + setIsUpdatingPolicies(true); + const changedPolicies = formData.filter((formPolicy) => { + const prevPolicyState = policiesAvailableToAutomate.find( + (policy) => policy.id === formPolicy.id + ); + + const turnedOff = + prevPolicyState?.run_script !== undefined && + formPolicy.runScriptEnabled === false; + + const turnedOn = + prevPolicyState?.run_script === undefined && + formPolicy.runScriptEnabled === true; + + const updatedRunScriptId = + prevPolicyState?.run_script?.id !== undefined && + formPolicy.scriptIdToRun !== prevPolicyState?.run_script?.id; + + return turnedOff || turnedOn || updatedRunScriptId; + }); + if (!changedPolicies.length) { + renderFlash("success", "No changes detected."); + return; + } + const responses: Promise< + ReturnType + >[] = []; + responses.concat( + changedPolicies.map((changedPolicy) => { + return teamPoliciesAPI.update(changedPolicy.id, { + // "script_id": 0 will unset running a script for the policy (a script never has ID 0) + // "script_id": X will sets script X to run when the policy fails + script_id: changedPolicy.scriptIdToRun || 0, + team_id: teamIdForApi, + }); + }) + ); + await Promise.all(responses); + await wait(100); + refetchTeamPolicies(); + renderFlash("success", DEFAULT_AUTOMATION_UPDATE_SUCCESS_MSG); + } catch { + renderFlash("error", DEFAULT_AUTOMATION_UPDATE_ERR_MSG); + } finally { + togglePolicyRunScriptModal(); + setIsUpdatingPolicies(false); } }; const onUpdateCalendarEvents = async (formData: ICalendarEventsFormData) => { - setIsUpdatingCalendarEvents(true); + setIsUpdatingPolicies(true); try { // update team config if either field has been changed @@ -624,15 +682,12 @@ const ManagePolicyPage = ({ await refetchTeamPolicies(); await refetchTeamConfig(); - renderFlash("success", "Successfully updated policy automations."); + renderFlash("success", DEFAULT_AUTOMATION_UPDATE_SUCCESS_MSG); } catch { - renderFlash( - "error", - "Could not update policy automations. Please try again." - ); + renderFlash("error", DEFAULT_AUTOMATION_UPDATE_ERR_MSG); } finally { toggleCalendarEventsModal(); - setIsUpdatingCalendarEvents(false); + setIsUpdatingPolicies(false); } }; @@ -802,9 +857,11 @@ const ManagePolicyPage = ({ const getAutomationsDropdownOptions = (configPresent: boolean) => { let disabledInstallTooltipContent: React.ReactNode; let disabledCalendarTooltipContent: React.ReactNode; + let disabledRunScriptTooltipContent: React.ReactNode; if (!isPremiumTier) { disabledInstallTooltipContent = "Available in Fleet Premium."; disabledCalendarTooltipContent = "Available in Fleet Premium."; + disabledRunScriptTooltipContent = "Available in Fleet Premium."; } else if (isAllTeamsSelected) { disabledInstallTooltipContent = ( <> @@ -820,6 +877,13 @@ const ManagePolicyPage = ({ calendar events. ); + disabledRunScriptTooltipContent = ( + <> + Select a team to manage +
+ run script automation. + + ); } const installSWOption = { label: "Install software", @@ -828,8 +892,16 @@ const ManagePolicyPage = ({ helpText: "Install software to resolve failing policies.", tooltipContent: disabledInstallTooltipContent, }; + const runScriptOption = { + label: "Run script", + value: "run_script", + disabled: !!disabledRunScriptTooltipContent, + helpText: "Run script to resolve failing policies.", + tooltipContent: disabledRunScriptTooltipContent, + }; + if (!configPresent) { - return [installSWOption]; + return [installSWOption, runScriptOption]; } return [ @@ -841,6 +913,7 @@ const ManagePolicyPage = ({ tooltipContent: disabledCalendarTooltipContent, }, installSWOption, + runScriptOption, { label: "Other workflows", value: "other_workflows", @@ -858,6 +931,18 @@ const ManagePolicyPage = ({ if (!isRouteOk) { return ; } + + let teamsDropdownHelpText: string; + if (teamIdForApi === API_NO_TEAM_ID) { + teamsDropdownHelpText = + "Detect device health issues for hosts that are not on a team."; + } else if (teamIdForApi === API_ALL_TEAMS_ID) { + teamsDropdownHelpText = "Detect device health issues for all hosts."; + } else { + // a team is selected + teamsDropdownHelpText = + "Detect device health issues for all hosts assigned to this team."; + } return (
@@ -910,11 +995,7 @@ const ManagePolicyPage = ({ )}
-

- {isAnyTeamSelected - ? "Detect device health issues for all hosts assigned to this team." - : "Detect device health issues for all hosts."} -

+

{teamsDropdownHelpText}

{renderMainTable()} {config && automationsConfig && showOtherWorkflowsModal && ( @@ -922,7 +1003,7 @@ const ManagePolicyPage = ({ automationsConfig={automationsConfig} availableIntegrations={config.integrations} availablePolicies={policiesAvailableToAutomate} - isUpdating={isUpdatingOtherWorkflows} + isUpdating={isUpdatingPolicies} onExit={toggleOtherWorkflowsModal} onSubmit={onUpdateOtherWorkflows} /> @@ -947,7 +1028,17 @@ const ManagePolicyPage = ({ + )} + {showPolicyRunScriptModal && ( + )} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx index 7c29b4979f3b..2a7eb2168344 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx @@ -81,6 +81,7 @@ const InstallSoftwareModal = ({ const anyPolicyEnabledWithoutSelectedSoftware = formData.some( (policy) => policy.installSoftwareEnabled && !policy.swIdToInstall ); + const { data: titlesAFI, isLoading: isTitlesAFILoading, @@ -214,9 +215,10 @@ const InstallSoftwareModal = ({ return (
No software available for install - - Go to Software to add software to this team. - +
+ Go to Software to + add software to this team. +
); } @@ -231,8 +233,10 @@ const InstallSoftwareModal = ({ )} - Selected software will be installed when hosts fail the chosen - policy.{" "} + Selected software will be installed when hosts fail the policy. Host + counts will reset when a new software is +
+ selected.{" "} void; + onSubmit: (formData: IPolicyRunScriptFormData) => void; + isUpdating: boolean; + policies: IPolicyStats[]; + teamId: number; +} + +const PolicyRunScriptModal = ({ + onExit, + onSubmit, + isUpdating, + policies, + teamId, +}: IPolicyRunScriptModal) => { + const [formData, setFormData] = useState( + policies.map((policy) => ({ + name: policy.name, + id: policy.id, + runScriptEnabled: !!policy.run_script, + scriptIdToRun: policy.run_script?.id, + })) + ); + + const anyEnabledWithoutSelection = formData.some( + (policy) => policy.runScriptEnabled && !policy.scriptIdToRun + ); + + const { + data: availableScripts, + isLoading: isLoadingAvailableScripts, + isError: isAvailableScriptsError, + } = useQuery( + [ + { + scope: "scripts", + team_id: teamId, + }, + ], + ({ queryKey: [queryKey] }) => + scriptsAPI.getScripts(omit(queryKey, "scope")), + { + select: (data) => data.scripts, + ...DEFAULT_USE_QUERY_OPTIONS, + } + ); + + const onUpdate = useCallback(() => { + onSubmit(formData); + }, [formData, onSubmit]); + + const onChangeEnableRunScript = useCallback( + (newVal: { policyId: number; value: boolean }) => { + const { policyId, value } = newVal; + setFormData( + formData.map((policy) => { + if (policy.id === policyId) { + return { + ...policy, + runScriptEnabled: value, + scriptIdToRun: value ? policy.scriptIdToRun : undefined, + }; + } + return policy; + }) + ); + }, + [formData] + ); + + const onSelectPolicyScript = useCallback( + ({ name, value }: IScriptDropdownField) => { + const [policyName, scriptId] = [name, value]; + setFormData( + formData.map((policy) => { + if (policy.name === policyName) { + return { ...policy, scriptIdToRun: scriptId }; + } + return policy; + }) + ); + }, + [formData] + ); + + const availableScriptOptions = availableScripts?.map((script) => ({ + label: script.name, + value: script.id, + })); + + const renderPolicyRunScriptOption = (policy: IFormPolicy) => { + const { + name: policyName, + id: policyId, + runScriptEnabled: enabled, + scriptIdToRun, + } = policy; + + return ( +
  • + { + onChangeEnableRunScript({ + policyId, + value: !enabled, + }); + }} + > + + + {enabled && ( + + )} +
  • + ); + }; + + const renderContent = () => { + if (isAvailableScriptsError) { + return ; + } + if (isLoadingAvailableScripts) { + return ; + } + if (!availableScripts?.length) { + return ( +
    + No scripts available for install +
    + Go to{" "} + + Controls > Scripts + {" "} + to add scripts to this team. +
    +
    + ); + } + + return ( +
    +
    +
    Policies:
    +
      + {formData.map((policyData) => + renderPolicyRunScriptOption(policyData) + )} +
    + + Selected script will run when hosts fail the policy. Host counts + will reset when a new script is selected.{" "} + + +
    +
    + + +
    +
    + ); + }; + return ( + + {renderContent()} + + ); +}; + +export default PolicyRunScriptModal; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PolicyRunScriptModal/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/PolicyRunScriptModal/_styles.scss new file mode 100644 index 000000000000..7900c31ac2d3 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/PolicyRunScriptModal/_styles.scss @@ -0,0 +1,45 @@ +.manage-policies-page { + .policy-run-script-modal { + .form-field--dropdown { + width: 276px; + .Select-placeholder { + color: $ui-fleet-black-50; + } + .Select-menu { + max-height: none; + overflow: visible; + } + .Select-menu-outer { + max-height: 240px; + overflow-y: auto; + } + } + .policy-row { + height: 40px; + padding-top: 4px; + padding-bottom: 4px; + } + + &__no-scripts { + display: flex; + height: 178px; + flex-direction: column; + align-items: center; + gap: $pad-small; + justify-content: center; + font-size: $small; + + div { + color: $ui-fleet-black-75; + font-size: $xx-small; + a { + font-size: inherit; + } + } + } + .data-error { + padding: 78px; + } + } +} + diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PolicyRunScriptModal/index.ts b/frontend/pages/policies/ManagePoliciesPage/components/PolicyRunScriptModal/index.ts new file mode 100644 index 000000000000..49e582815753 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/PolicyRunScriptModal/index.ts @@ -0,0 +1 @@ +export { default } from "./PolicyRunScriptModal"; diff --git a/frontend/services/entities/team_policies.ts b/frontend/services/entities/team_policies.ts index 5da02ad3fc7e..664c298589bd 100644 --- a/frontend/services/entities/team_policies.ts +++ b/frontend/services/entities/team_policies.ts @@ -7,6 +7,7 @@ import { ILoadTeamPoliciesResponse, IPolicyFormData, IPoliciesCountResponse, + ILoadTeamPolicyResponse, } from "interfaces/policy"; import { API_NO_TEAM_ID } from "interfaces/team"; import { buildQueryStringFromParams, QueryParams } from "utilities/url"; @@ -63,6 +64,7 @@ export default { resolution, platform, critical, + // note absence of automations-related fields, which are only set by the UI via update } = data; const { TEAMS } = endpoints; const path = `${TEAMS}/${team_id}/policies`; @@ -85,8 +87,10 @@ export default { resolution, platform, critical, + // automations-related fields calendar_events_enabled, software_title_id, + script_id, } = data; const { TEAMS } = endpoints; const path = `${TEAMS}/${team_id}/policies/${id}`; @@ -100,6 +104,7 @@ export default { critical, calendar_events_enabled, software_title_id, + script_id, }); }, destroy: (teamId: number | undefined, ids: number[]) => { @@ -115,7 +120,7 @@ export default { return sendRequest("POST", path, { ids }); }, - load: (team_id: number, id: number) => { + load: (team_id: number, id: number): Promise => { const { TEAMS } = endpoints; const path = `${TEAMS}/${team_id}/policies/${id}`; return sendRequest("GET", path);