diff --git a/changes/19551-policy-software-automations b/changes/19551-policy-software-automations new file mode 100644 index 000000000000..4b88cb4c1fba --- /dev/null +++ b/changes/19551-policy-software-automations @@ -0,0 +1 @@ +* Implement features allowing automatic installation of software on hosts that fail policies. diff --git a/frontend/__mocks__/policyMock.ts b/frontend/__mocks__/policyMock.ts index b14095463ec6..ba60c55c74db 100644 --- a/frontend/__mocks__/policyMock.ts +++ b/frontend/__mocks__/policyMock.ts @@ -23,6 +23,10 @@ const DEFAULT_POLICY_MOCK: IPolicyStats = { has_run: true, next_update_ms: 3600000, calendar_events_enabled: true, + install_software: { + name: "testSw0", + software_title_id: 1, + }, }; const createMockPolicy = (overrides?: Partial): IPolicyStats => { diff --git a/frontend/components/Editor/Editor.tsx b/frontend/components/Editor/Editor.tsx index e8f230e3799d..b724a296c39e 100644 --- a/frontend/components/Editor/Editor.tsx +++ b/frontend/components/Editor/Editor.tsx @@ -29,6 +29,10 @@ interface IEditorProps { * @default "editor" */ name?: string; + /** Include correct styles as a form field. + * @default false + */ + isFormField?: boolean; maxLines?: number; className?: string; onChange?: (value: string, event?: any) => void; @@ -52,11 +56,13 @@ const Editor = ({ readOnly = false, wrapEnabled = false, name = "editor", + isFormField = false, maxLines = 20, className, onChange, }: IEditorProps) => { const classNames = classnames(baseClass, className, { + "form-field": isFormField, [`${baseClass}__error`]: !!error, }); diff --git a/frontend/components/Editor/_styles.scss b/frontend/components/Editor/_styles.scss index 676172697dd5..22fbacfd337c 100644 --- a/frontend/components/Editor/_styles.scss +++ b/frontend/components/Editor/_styles.scss @@ -3,7 +3,6 @@ &__label { font-size: $x-small; font-weight: $bold; - margin-bottom: $pad-small; &--error { color: $core-vibrant-red; diff --git a/frontend/components/FleetAce/FleetAce.tsx b/frontend/components/FleetAce/FleetAce.tsx index c30232cb596b..b0422d4cde21 100644 --- a/frontend/components/FleetAce/FleetAce.tsx +++ b/frontend/components/FleetAce/FleetAce.tsx @@ -29,6 +29,7 @@ export interface IFleetAceProps { label?: string; name?: string; value?: string; + placeholder?: string; readOnly?: boolean; maxLines?: number; showGutter?: boolean; @@ -55,6 +56,7 @@ const FleetAce = ({ labelActionComponent, name = "query-editor", value, + placeholder, readOnly, maxLines = 20, showGutter = true, @@ -266,6 +268,7 @@ const FleetAce = ({ showPrintMargin={false} theme="fleet" value={value} + placeholder={placeholder} width="100%" wrapEnabled={wrapEnabled} style={style} diff --git a/frontend/components/FleetAce/_styles.scss b/frontend/components/FleetAce/_styles.scss index c12237a3a771..f9f0dccf8960 100644 --- a/frontend/components/FleetAce/_styles.scss +++ b/frontend/components/FleetAce/_styles.scss @@ -25,6 +25,16 @@ } } + .ace_content { + padding-left: 4px; + } + + .ace_placeholder { + font-family: "SourceCodePro", $monospace; + margin: initial; + font-size: 15px; + } + &__help-text { @include help-text; diff --git a/frontend/components/forms/fields/Dropdown/Dropdown.jsx b/frontend/components/forms/fields/Dropdown/Dropdown.jsx index 1edde66fec07..302233e5d674 100644 --- a/frontend/components/forms/fields/Dropdown/Dropdown.jsx +++ b/frontend/components/forms/fields/Dropdown/Dropdown.jsx @@ -27,6 +27,19 @@ class Dropdown extends Component { onClose: PropTypes.func, options: PropTypes.arrayOf(dropdownOptionInterface).isRequired, placeholder: PropTypes.oneOfType([PropTypes.array, PropTypes.string]), + /** + value must correspond to the value of a dropdown option to render + e.g. with options: + + [ + { + label: "Display name", + value: 1, <– the id of the thing + } + ] + + set value to 1, not "Display name" + */ value: PropTypes.oneOfType([ PropTypes.array, PropTypes.string, @@ -75,7 +88,7 @@ class Dropdown extends Component { const { multi, onChange, clearable, name, parseTarget } = this.props; if (parseTarget) { - // Returns both name and value + // Returns both name of the Dropdown and value of the selected option return onChange({ value: selected.value, name }); } diff --git a/frontend/interfaces/policy.ts b/frontend/interfaces/policy.ts index 41586ea22da9..621c52f63872 100644 --- a/frontend/interfaces/policy.ts +++ b/frontend/interfaces/policy.ts @@ -41,6 +41,12 @@ export interface IPolicy { updated_at: string; critical: boolean; calendar_events_enabled: boolean; + install_software?: IPolicySoftwareToInstall; +} + +export interface IPolicySoftwareToInstall { + name: string; + software_title_id: number; } // Used on the manage hosts page and other places where aggregate stats are displayed @@ -94,6 +100,8 @@ export interface IPolicyFormData { team_id?: number | null; id?: number; calendar_events_enabled?: boolean; + // undefined from GET/LIST when not set, null for PATCH to unset + software_title_id?: number | null; } export interface IPolicyNew { diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index bf6ba786a17e..9d6d3617b2fb 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -109,6 +109,7 @@ export interface ISoftwareTitleDetails { source: string; // "apps" | "ios_apps" | "ipados_apps" | ? hosts_count: number; versions: ISoftwareTitleVersion[] | null; + versions_updated_at?: string; bundle_identifier?: string; browser?: string; versions_count?: number; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx index 12191a96418f..694970c4ca1a 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx @@ -77,7 +77,7 @@ interface IFileDetailsProps { // TODO: if we reuse this one more time, we should consider moving this // into FileUploader as a default preview. Currently we have this in -// AddSoftwareForm.tsx and here. +// AddPackageForm.tsx and here. const FileDetails = ({ details: { name, platform } }: IFileDetailsProps) => (
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/AdvancedOptionsModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/AdvancedOptionsModal.tsx index b3f7c31146b5..5cfc313e4416 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/AdvancedOptionsModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/AdvancedOptionsModal.tsx @@ -38,6 +38,7 @@ const AdvancedOptionsModal = ({ helpText="Fleet will run this command on hosts to install software." label="Install script" labelTooltip="For security agents, add the script provided by the vendor." + isFormField /> {preInstallQuery && (
@@ -72,6 +73,7 @@ const AdvancedOptionsModal = ({ maxLines={10} value={postInstallScript} helpText="Shell (macOS and Linux) or PowerShell (Windows)." + isFormField />
)} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx index 23d03c58521c..fa75f1d5541d 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx @@ -3,11 +3,15 @@ import React, { useCallback, useContext } from "react"; import softwareAPI from "services/entities/software"; import { NotificationContext } from "context/notification"; +import { getErrorReason } from "interfaces/errors"; + import Modal from "components/Modal"; import Button from "components/buttons/Button"; const baseClass = "delete-software-modal"; +const DELETE_SW_USED_BY_POLICY_ERROR_MSG = + "Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again."; interface IDeleteSoftwareModalProps { softwareId: number; teamId: number; @@ -28,8 +32,13 @@ const DeleteSoftwareModal = ({ await softwareAPI.deleteSoftwarePackage(softwareId, teamId); renderFlash("success", "Software deleted successfully!"); onSuccess(); - } catch { - renderFlash("error", "Couldn't delete. Please try again."); + } catch (error) { + const reason = getErrorReason(error); + if (reason.includes("Policy automation uses this software")) { + renderFlash("error", DELETE_SW_USED_BY_POLICY_ERROR_MSG); + } else { + renderFlash("error", "Couldn't delete. Please try again."); + } } onExit(); }, [softwareId, teamId, renderFlash, onSuccess, onExit]); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx index 8453abfbb6bf..1c6a31e9d769 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx @@ -4,8 +4,6 @@ import React, { useLayoutEffect, useState, } from "react"; -import FileSaver from "file-saver"; -import { parse } from "content-disposition"; import PATHS from "router/paths"; import { AppContext } from "context/app"; @@ -45,10 +43,15 @@ function useTruncatedElement(ref: React.RefObject) { useLayoutEffect(() => { const element = ref.current; - if (element) { - const { scrollWidth, clientWidth } = element; - setIsTruncated(scrollWidth > clientWidth); + function updateIsTruncated() { + if (element) { + const { scrollWidth, clientWidth } = element; + setIsTruncated(scrollWidth > clientWidth); + } } + window.addEventListener("resize", updateIsTruncated); + updateIsTruncated(); + return () => window.removeEventListener("resize", updateIsTruncated); }, [ref]); return isTruncated; @@ -92,20 +95,29 @@ const STATUS_DISPLAY_OPTIONS: Record< iconName: "success", tooltip: ( <> - Fleet installed software on these hosts. Currently, if the software is - uninstalled, the "Installed" status won't be updated. + Software is installed on these hosts (install script finished +
+ with exit code 0). Currently, if the software is uninstalled, the +
+ "installed" status won't be updated. ), }, pending: { displayName: "Pending", iconName: "pending-outline", - tooltip: "Fleet will install software when these hosts come online.", + tooltip: "Fleet is installing or will install when the host comes online.", }, failed: { displayName: "Failed", iconName: "error", - tooltip: "Fleet failed to install software on these hosts.", + tooltip: ( + <> + These hosts failed to install software. Click on a host to view +
+ error(s). + + ), }, }; @@ -130,16 +142,18 @@ const PackageStatusCount = ({ })}`; return (
- {displayData.displayName} +
{displayData.displayName}
} @@ -305,7 +319,7 @@ const SoftwarePackageCard = ({ return ( -
+
{/* TODO: main-info could be a seperate component as its reused on a couple pages already. Come back and pull this into a component */}
@@ -315,46 +329,46 @@ const SoftwarePackageCard = ({ {renderDetails()}
-
- - - +
+ {isSelfService && ( +
+ + Self-service +
+ )} + {showActions && ( + + )}
-
- {isSelfService && ( -
- - Self-service -
- )} - {showActions && ( - - )} +
+ + +
{showAdvancedOptionsModal && ( diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx index 4eb9660e625a..6eaafa2a28e0 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx @@ -13,6 +13,7 @@ import TableContainer from "components/TableContainer"; import TableCount from "components/TableContainer/TableCount"; import EmptyTable from "components/EmptyTable"; import CustomLink from "components/CustomLink"; +import LastUpdatedText from "components/LastUpdatedText"; import generateSoftwareTitleDetailsTableConfig from "./SoftwareTitleDetailsTableConfig"; @@ -21,6 +22,21 @@ const DEFAULT_SORT_DIRECTION = "desc"; const baseClass = "software-title-details-table"; +const SoftwareLastUpdatedInfo = (lastUpdatedAt: string) => { + return ( + + The last time software data was
+ updated, including vulnerabilities
+ and host counts. + + } + /> + ); +}; + const NoVersionsDetected = (isAvailableForInstall = false): JSX.Element => { return ( { const handleRowSelect = (row: IRowProps) => { const hostsBySoftwareParams = { @@ -95,7 +113,10 @@ const SoftwareTitleDetailsTable = ({ ); const renderVersionsCount = () => ( - + <> + + {countsUpdatedAt && SoftwareLastUpdatedInfo(countsUpdatedAt)} + ); return ( diff --git a/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx b/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx index 2e7e4b17a566..52cb805f7f46 100644 --- a/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx +++ b/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx @@ -1,13 +1,19 @@ import React, { useContext, useEffect, useState } from "react"; import { InjectedRouter } from "react-router"; +import { getErrorReason } from "interfaces/errors"; + import PATHS from "router/paths"; import { NotificationContext } from "context/notification"; import softwareAPI from "services/entities/software"; import { QueryParams, buildQueryStringFromParams } from "utilities/url"; +import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants"; + +import CustomLink from "components/CustomLink"; + import AddPackageForm from "../AddPackageForm"; -import { IAddSoftwareFormData } from "../AddPackageForm/AddSoftwareForm"; +import { IAddPackageFormData } from "../AddPackageForm/AddPackageForm"; import { getErrorMessage } from "../AddSoftwareModal/helpers"; const baseClass = "add-package"; @@ -60,7 +66,7 @@ const AddPackage = ({ }; }, [isUploading]); - const onAddPackage = async (formData: IAddSoftwareFormData) => { + const onAddPackage = async (formData: IAddPackageFormData) => { setIsUploading(true); if (formData.software && formData.software.size > MAX_FILE_SIZE_BYTES) { @@ -98,6 +104,21 @@ const AddPackage = ({ `${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams(newQueryParams)}` ); } catch (e) { + const reason = getErrorReason(e); + if ( + reason.includes("Couldn't add. Fleet couldn't read the version from") + ) { + renderFlash( + "error", + `${reason}. ${( + + )} ` + ); + } renderFlash("error", getErrorMessage(e)); } diff --git a/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx new file mode 100644 index 000000000000..5d6524e8dce1 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx @@ -0,0 +1,101 @@ +import React, { useState } from "react"; + +import Editor from "components/Editor"; +import CustomLink from "components/CustomLink"; +import FleetAce from "components/FleetAce"; +import RevealButton from "components/buttons/RevealButton"; + +const baseClass = "add-package-advanced-options"; + +interface IAddPackageAdvancedOptionsProps { + errors: { preInstallQuery?: string; postInstallScript?: string }; + preInstallQuery?: string; + installScript: string; + postInstallScript?: string; + onChangePreInstallQuery: (value?: string) => void; + onChangeInstallScript: (value: string) => void; + onChangePostInstallScript: (value?: string) => void; +} + +const AddPackageAdvancedOptions = ({ + errors, + preInstallQuery, + installScript, + postInstallScript, + onChangePreInstallQuery, + onChangeInstallScript, + onChangePostInstallScript, +}: IAddPackageAdvancedOptionsProps) => { + const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); + + return ( +
+ setShowAdvancedOptions(!showAdvancedOptions)} + /> + {showAdvancedOptions && ( +
+ + Software will be installed only if the{" "} + + + } + /> + + Fleet will run this script on hosts to install software. Use the +
+ $INSTALLER_PATH variable to point to the installer. + + } + isFormField + /> + +
+ )} +
+ ); +}; + +export default AddPackageAdvancedOptions; diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/_styles.scss b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/_styles.scss similarity index 88% rename from frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/_styles.scss rename to frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/_styles.scss index 58f1f85892b9..0728e3241560 100644 --- a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/_styles.scss +++ b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/_styles.scss @@ -1,4 +1,4 @@ -.add-software-advanced-options { +.add-package-advanced-options { display: flex; flex-direction: column; align-items: flex-start; diff --git a/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/index.ts b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/index.ts new file mode 100644 index 000000000000..004c96332d47 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/index.ts @@ -0,0 +1 @@ +export { default } from "./AddPackageAdvancedOptions"; diff --git a/frontend/pages/SoftwarePage/components/AddPackageForm/AddSoftwareForm.tsx b/frontend/pages/SoftwarePage/components/AddPackageForm/AddPackageForm.tsx similarity index 55% rename from frontend/pages/SoftwarePage/components/AddPackageForm/AddSoftwareForm.tsx rename to frontend/pages/SoftwarePage/components/AddPackageForm/AddPackageForm.tsx index aab97985d0d0..cf3802b3f6f8 100644 --- a/frontend/pages/SoftwarePage/components/AddPackageForm/AddSoftwareForm.tsx +++ b/frontend/pages/SoftwarePage/components/AddPackageForm/AddPackageForm.tsx @@ -6,7 +6,6 @@ import getInstallScript from "utilities/software_install_scripts"; import Button from "components/buttons/Button"; import Checkbox from "components/forms/fields/Checkbox"; -import Editor from "components/Editor"; import { FileUploader, FileDetails, @@ -14,25 +13,25 @@ import { import Spinner from "components/Spinner"; import TooltipWrapper from "components/TooltipWrapper"; -import AddSoftwareAdvancedOptions from "../AddSoftwareAdvancedOptions"; +import AddPackageAdvancedOptions from "../AddPackageAdvancedOptions"; import { generateFormValidation } from "./helpers"; -export const baseClass = "add-software-form"; +export const baseClass = "add-package-form"; const UploadingSoftware = () => { return (
-

Uploading. It may take a few minutes to finish.

+

Adding software. This may take a few minutes to finish.

); }; -export interface IAddSoftwareFormData { +export interface IAddPackageFormData { software: File | null; installScript: string; - preInstallCondition?: string; + preInstallQuery?: string; postInstallScript?: string; selfService: boolean; } @@ -40,30 +39,28 @@ export interface IAddSoftwareFormData { export interface IFormValidation { isValid: boolean; software: { isValid: boolean }; - preInstallCondition?: { isValid: boolean; message?: string }; + preInstallQuery?: { isValid: boolean; message?: string }; postInstallScript?: { isValid: boolean; message?: string }; selfService?: { isValid: boolean }; } -interface IAddSoftwareFormProps { +interface IAddPackageFormProps { isUploading: boolean; onCancel: () => void; - onSubmit: (formData: IAddSoftwareFormData) => void; + onSubmit: (formData: IAddPackageFormData) => void; } -const AddSoftwareForm = ({ +const AddPackageForm = ({ isUploading, onCancel, onSubmit, -}: IAddSoftwareFormProps) => { +}: IAddPackageFormProps) => { const { renderFlash } = useContext(NotificationContext); - const [showPreInstallCondition, setShowPreInstallCondition] = useState(false); - const [showPostInstallScript, setShowPostInstallScript] = useState(false); - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState({ software: null, installScript: "", - preInstallCondition: undefined, + preInstallQuery: undefined, postInstallScript: undefined, selfService: false, }); @@ -90,13 +87,7 @@ const AddSoftwareForm = ({ installScript, }; setFormData(newData); - setFormValidation( - generateFormValidation( - newData, - showPreInstallCondition, - showPostInstallScript - ) - ); + setFormValidation(generateFormValidation(newData)); } }; @@ -105,62 +96,26 @@ const AddSoftwareForm = ({ onSubmit(formData); }; - const onTogglePreInstallConditionCheckbox = (value: boolean) => { - const newData = { ...formData, preInstallCondition: undefined }; - setShowPreInstallCondition(value); - setFormData(newData); - setFormValidation( - generateFormValidation(newData, value, showPostInstallScript) - ); - }; - - const onTogglePostInstallScriptCheckbox = (value: boolean) => { - const newData = { ...formData, postInstallScript: undefined }; - setShowPostInstallScript(value); - setFormData(newData); - setFormValidation( - generateFormValidation(newData, showPreInstallCondition, value) - ); - }; - const onChangeInstallScript = (value: string) => { setFormData({ ...formData, installScript: value }); }; - const onChangePreInstallCondition = (value?: string) => { - const newData = { ...formData, preInstallCondition: value }; + const onChangePreInstallQuery = (value?: string) => { + const newData = { ...formData, preInstallQuery: value }; setFormData(newData); - setFormValidation( - generateFormValidation( - newData, - showPreInstallCondition, - showPostInstallScript - ) - ); + setFormValidation(generateFormValidation(newData)); }; const onChangePostInstallScript = (value?: string) => { const newData = { ...formData, postInstallScript: value }; setFormData(newData); - setFormValidation( - generateFormValidation( - newData, - showPreInstallCondition, - showPostInstallScript - ) - ); + setFormValidation(generateFormValidation(newData)); }; const onToggleSelfServiceCheckbox = (value: boolean) => { const newData = { ...formData, selfService: value }; setFormData(newData); - setFormValidation( - generateFormValidation( - newData, - showPreInstallCondition, - showPostInstallScript - ) - ); + setFormValidation(generateFormValidation(newData)); }; const isSubmitDisabled = !formValidation.isValid; @@ -185,25 +140,6 @@ const AddSoftwareForm = ({ ) } /> - {formData.software && ( - - For security agents, add the script provided by the vendor. -
- In custom scripts, you can use the $INSTALLER_PATH variable to - point to the installer. - - } - /> - )} -
-
+ ); })} -
+ A calendar event will be created for end users if one of their hosts fail any of these policies.{" "} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx new file mode 100644 index 000000000000..7c29b4979f3b --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx @@ -0,0 +1,276 @@ +import React, { useCallback, useState } from "react"; + +import { useQuery } from "react-query"; +import { omit } from "lodash"; + +import { IPolicyStats } from "interfaces/policy"; +import softwareAPI, { + ISoftwareTitlesQueryKey, + ISoftwareTitlesResponse, +} from "services/entities/software"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; + +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; +import Modal from "components/Modal"; +import DataError from "components/DataError"; +import Spinner from "components/Spinner"; +import Checkbox from "components/forms/fields/Checkbox"; +import TooltipTruncatedText from "components/TooltipTruncatedText"; +import CustomLink from "components/CustomLink"; +import Button from "components/buttons/Button"; +import { ISoftwareTitle } from "interfaces/software"; + +const getPlatformDisplayFromPackageSuffix = (packageName: string) => { + const split = packageName.split("."); + const suff = split[split.length - 1]; + switch (suff) { + case "pkg": + return "macOS"; + case "deb": + return "Linux"; + case "exe": + return "Windows"; + case "msi": + return "Windows"; + default: + return null; + } +}; + +const AFI_SOFTWARE_BATCH_SIZE = 1000; + +const baseClass = "install-software-modal"; + +interface ISwDropdownField { + name: string; + value: number; +} +interface IFormPolicy { + name: string; + id: number; + installSoftwareEnabled: boolean; + swIdToInstall?: number; +} + +export type IInstallSoftwareFormData = IFormPolicy[]; + +interface IInstallSoftwareModal { + onExit: () => void; + onSubmit: (formData: IInstallSoftwareFormData) => void; + isUpdating: boolean; + policies: IPolicyStats[]; + teamId: number; +} +const InstallSoftwareModal = ({ + onExit, + onSubmit, + isUpdating, + policies, + teamId, +}: IInstallSoftwareModal) => { + const [formData, setFormData] = useState( + policies.map((policy) => ({ + name: policy.name, + id: policy.id, + installSoftwareEnabled: !!policy.install_software, + swIdToInstall: policy.install_software?.software_title_id, + })) + ); + + const anyPolicyEnabledWithoutSelectedSoftware = formData.some( + (policy) => policy.installSoftwareEnabled && !policy.swIdToInstall + ); + const { + data: titlesAFI, + isLoading: isTitlesAFILoading, + isError: isTitlesAFIError, + } = useQuery< + ISoftwareTitlesResponse, + Error, + ISoftwareTitle[], + [ISoftwareTitlesQueryKey] + >( + [ + { + scope: "software-titles", + page: 0, + perPage: AFI_SOFTWARE_BATCH_SIZE, + query: "", + orderDirection: "desc", + orderKey: "hosts_count", + teamId, + availableForInstall: true, + packagesOnly: true, + }, + ], + ({ queryKey: [queryKey] }) => + softwareAPI.getSoftwareTitles(omit(queryKey, "scope")), + { + select: (data) => data.software_titles, + ...DEFAULT_USE_QUERY_OPTIONS, + } + ); + + const onUpdateInstallSoftware = useCallback(() => { + onSubmit(formData); + }, [formData, onSubmit]); + + const onChangeEnableInstallSoftware = useCallback( + (newVal: { policyName: string; value: boolean }) => { + const { policyName, value } = newVal; + setFormData( + formData.map((policy) => { + if (policy.name === policyName) { + return { + ...policy, + installSoftwareEnabled: value, + swIdToInstall: value ? policy.swIdToInstall : undefined, + }; + } + return policy; + }) + ); + }, + [formData] + ); + + const onSelectPolicySoftware = useCallback( + ({ name, value }: ISwDropdownField) => { + const [policyName, softwareId] = [name, value]; + setFormData( + formData.map((policy) => { + if (policy.name === policyName) { + return { ...policy, swIdToInstall: softwareId }; + } + return policy; + }) + ); + }, + [formData] + ); + + const availableSoftwareOptions = titlesAFI?.map((title) => { + const platformDisplay = getPlatformDisplayFromPackageSuffix( + title.software_package?.name ?? "" + ); + const platformString = platformDisplay ? `${platformDisplay} • ` : ""; + return { + label: title.name, + value: title.id, + helpText: `${platformString}${title.software_package?.version ?? ""}`, + }; + }); + + const renderPolicySwInstallOption = (policy: IFormPolicy) => { + const { + name: policyName, + id: policyId, + installSoftwareEnabled: enabled, + swIdToInstall, + } = policy; + + return ( +
  • + { + onChangeEnableInstallSoftware({ + policyName, + value: !enabled, + }); + }} + > + + + {enabled && ( + + )} +
  • + ); + }; + + const renderContent = () => { + if (isTitlesAFIError) { + return ; + } + if (isTitlesAFILoading) { + return ; + } + if (!titlesAFI?.length) { + return ( +
    + No software available for install + + Go to Software to add software to this team. + +
    + ); + } + + return ( +
    +
    +
    Policies:
    +
      + {formData.map((policyData) => + renderPolicySwInstallOption(policyData) + )} +
    + + Selected software will be installed when hosts fail the chosen + policy.{" "} + + +
    +
    + + +
    +
    + ); + }; + + return ( + + {renderContent()} + + ); +}; + +export default InstallSoftwareModal; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/_styles.scss new file mode 100644 index 000000000000..de9cfc05be59 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/_styles.scss @@ -0,0 +1,41 @@ +.manage-policies-page { + .install-software-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-software { + display: flex; + height: 178px; + flex-direction: column; + align-items: center; + gap: $pad-small; + justify-content: center; + font-size: $small; + + span { + color: $ui-fleet-black-75; + font-size: $xx-small; + } + } + .data-error { + padding: 78px; + } + } +} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/index.ts b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/index.ts new file mode 100644 index 000000000000..a9f46a726a03 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/index.ts @@ -0,0 +1 @@ +export { default } from "./InstallSoftwareModal"; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx index d34ae56ff60f..7a9668e82544 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx @@ -416,8 +416,8 @@ const OtherWorkflowsModal = ({ const { isChecked, name, id } = policyItem; return (
    { @@ -220,8 +219,8 @@ export default { formData.append("software", data.software); formData.append("self_service", data.selfService.toString()); data.installScript && formData.append("install_script", data.installScript); - data.preInstallCondition && - formData.append("pre_install_query", data.preInstallCondition); + data.preInstallQuery && + formData.append("pre_install_query", data.preInstallQuery); data.postInstallScript && formData.append("post_install_script", data.postInstallScript); teamId && formData.append("team_id", teamId.toString()); diff --git a/frontend/services/entities/team_policies.ts b/frontend/services/entities/team_policies.ts index a10d954e8b00..d2e13863729b 100644 --- a/frontend/services/entities/team_policies.ts +++ b/frontend/services/entities/team_policies.ts @@ -86,6 +86,7 @@ export default { platform, critical, calendar_events_enabled, + software_title_id, } = data; const { TEAMS } = endpoints; const path = `${TEAMS}/${team_id}/policies/${id}`; @@ -98,6 +99,7 @@ export default { platform, critical, calendar_events_enabled, + software_title_id, }); }, destroy: (teamId: number | undefined, ids: number[]) => { diff --git a/frontend/utilities/constants.tsx b/frontend/utilities/constants.tsx index c399b5e9c107..4b780aebc98c 100644 --- a/frontend/utilities/constants.tsx +++ b/frontend/utilities/constants.tsx @@ -60,9 +60,13 @@ export const HOST_STATUS_WEBHOOK_WINDOW_DROPDOWN_OPTIONS: IDropdownOption[] = [ export const GITHUB_NEW_ISSUE_LINK = "https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&template=bug-report.md"; -export const SUPPORT_LINK = "https://fleetdm.com/support"; +export const FLEET_WEBSITE_URL = "https://fleetdm.com"; -export const CONTACT_FLEET_LINK = "https://fleetdm.com/contact"; +export const SUPPORT_LINK = `${FLEET_WEBSITE_URL}/support`; + +export const CONTACT_FLEET_LINK = `${FLEET_WEBSITE_URL}/contact`; + +export const LEARN_MORE_ABOUT_BASE_LINK = `${FLEET_WEBSITE_URL}/learn-more-about`; /** July 28, 2016 is the date of the initial commit to fleet/fleet. */ export const INITIAL_FLEET_DATE = "2016-07-28T00:00:00Z";