diff --git a/changes/28159-batch-scripts b/changes/28159-batch-scripts new file mode 100644 index 0000000000..21ec8de5ae --- /dev/null +++ b/changes/28159-batch-scripts @@ -0,0 +1 @@ +- Implement ability to run a script on a batch of hosts with a single user flow diff --git a/frontend/components/ModalFooter/ModalFooter.tsx b/frontend/components/ModalFooter/ModalFooter.tsx index 2d9a32f76f..29ddb11d15 100644 --- a/frontend/components/ModalFooter/ModalFooter.tsx +++ b/frontend/components/ModalFooter/ModalFooter.tsx @@ -5,8 +5,8 @@ import { COLORS } from "styles/var/colors"; const baseClass = "modal-footer"; interface IModalFooterProps { - primaryButtons: JSX.Element; - secondaryButtons?: JSX.Element; + primaryButtons: React.ReactNode; + secondaryButtons?: React.ReactNode; className?: string; /** Renders a line above action buttons to indicate scrollability */ isTopScrolling?: boolean; diff --git a/frontend/components/PaginatedList/PaginatedList.tests.tsx b/frontend/components/PaginatedList/PaginatedList.tests.tsx index 4f8d079ea3..472637cb12 100644 --- a/frontend/components/PaginatedList/PaginatedList.tests.tsx +++ b/frontend/components/PaginatedList/PaginatedList.tests.tsx @@ -5,6 +5,12 @@ import { renderWithSetup } from "test/test-utils"; import PaginatedList, { IPaginatedListHandle } from "./PaginatedList"; +// to test: +// setDirtyOnClickRow?: boolean; +// useCheckBoxes?: boolean; +// /** Allow the parent to trigger the loading overlay */ +// ancestralUpdating?: boolean; + describe("PaginatedList", () => { interface ITestItem { id: number; @@ -85,7 +91,7 @@ describe("PaginatedList", () => { fetchPage={fetchLargePage} pageSize={10} - onToggleItem={jest.fn()} + onClickRow={jest.fn()} onUpdate={jest.fn()} isSelected={jest.fn()} /> @@ -108,7 +114,7 @@ describe("PaginatedList", () => { idKey="key" labelKey="val" pageSize={10} - onToggleItem={jest.fn()} + onClickRow={jest.fn()} onUpdate={jest.fn()} isSelected={jest.fn()} /> @@ -132,7 +138,7 @@ describe("PaginatedList", () => { fetchPage={fetchLargePage} pageSize={10} - onToggleItem={jest.fn()} + onClickRow={jest.fn()} onUpdate={jest.fn()} isSelected="checkMeBruh" /> @@ -157,7 +163,7 @@ describe("PaginatedList", () => { fetchPage={fetchLargePage} pageSize={10} - onToggleItem={jest.fn()} + onClickRow={jest.fn()} onUpdate={jest.fn()} isSelected={(item) => item.favoriteIceCreamFlavor === "Dirt"} /> @@ -182,7 +188,7 @@ describe("PaginatedList", () => { fetchPage={fetchSmallPage} pageSize={2} - onToggleItem={jest.fn()} + onClickRow={jest.fn()} onUpdate={jest.fn()} isSelected={jest.fn()} /> @@ -241,7 +247,7 @@ describe("PaginatedList", () => { fetchPage={fetchTinyPage} fetchCount={() => Promise.resolve(3)} pageSize={1} - onToggleItem={jest.fn()} + onClickRow={jest.fn()} onUpdate={jest.fn()} isSelected={jest.fn()} /> @@ -289,7 +295,7 @@ describe("PaginatedList", () => { fetchPage={fetchLargePage} pageSize={10} - onToggleItem={jest.fn()} + onClickRow={jest.fn()} onUpdate={jest.fn()} isSelected={jest.fn()} renderItemRow={(item) => {item.favoriteIceCreamFlavor}} @@ -312,7 +318,7 @@ describe("PaginatedList", () => { fetchPage={fetchLargePage} pageSize={10} - onToggleItem={jest.fn()} + onClickRow={jest.fn()} onUpdate={jest.fn()} isSelected={jest.fn()} renderItemLabel={(item) => {item.favoriteIceCreamFlavor}} @@ -340,7 +346,7 @@ describe("PaginatedList", () => { ref={paginatedListRef} fetchPage={fetchLargePage} pageSize={10} - onToggleItem={onToggleItem} + onClickRow={onToggleItem} onUpdate={jest.fn()} isSelected="checkMeBruh" /> @@ -368,7 +374,7 @@ describe("PaginatedList", () => { ref={paginatedListRef} fetchPage={fetchLargePage} pageSize={10} - onToggleItem={jest.fn()} + onClickRow={jest.fn()} onUpdate={jest.fn()} isSelected={jest.fn()} renderItemRow={(item, onChange) => ( @@ -408,7 +414,7 @@ describe("PaginatedList", () => { ref={paginatedListRef} fetchPage={fetchLargePage} pageSize={10} - onToggleItem={jest.fn((item) => item)} + onClickRow={jest.fn((item) => item)} onUpdate={onUpdate} isSelected={jest.fn()} /> @@ -431,7 +437,7 @@ describe("PaginatedList", () => { fetchPage={fetchLargePage} pageSize={10} - onToggleItem={jest.fn()} + onClickRow={jest.fn()} onUpdate={jest.fn()} isSelected={jest.fn()} disabled diff --git a/frontend/components/PaginatedList/PaginatedList.tsx b/frontend/components/PaginatedList/PaginatedList.tsx index 4c1d87bfd4..1e4e81f919 100644 --- a/frontend/components/PaginatedList/PaginatedList.tsx +++ b/frontend/components/PaginatedList/PaginatedList.tsx @@ -21,59 +21,72 @@ export interface IPaginatedListHandle { getDirtyItems: () => TItem[]; } interface IPaginatedListProps { - // Function to fetch one page of data. - // Parents should memoize this function with useCallback() so that - // it is only called when needed. + /** Function to fetch one page of data. + Parents should memoize this function with useCallback() so that + it is only called when needed. */ fetchPage: (pageNumber: number) => Promise; - // Function to fetch the total # of items. - // Parents should memoize this function with useCallback() so that - // it is only called when needed. + /** if the parent already knows the number of items. If `fetchCount` is also defined, it will be + called and its result used to replace count in local state. */ + count?: number; + /** If the parent doens't alreayd know the numberof items, use this function to fetch the total # of items. + Parents should memoize this function with useCallback() so that + it is only called when needed. */ fetchCount?: () => Promise; - // UID property in an item. Defaults to `id`. + /** UID property in an item. Defaults to `id`. */ idKey?: string; - // Property to use as an item's label. Defaults to `name`. + /** Property to use as an item's label. Defaults to `name`. */ labelKey?: string; - // How to determine whether to check an item's checkbox. - // If string, a key in an item whose truthiness will be checked. - // if function, a function that given an item, returns a boolean. - isSelected: string | ((item: TItem) => boolean); - // Custom function to render the label for an item. + /** How to determine whether an item is selected. + If string, a key in an item whose truthiness will be checked. + if function, a function that given an item, returns a boolean. + *required in conjunction with `useCheckBoxes` */ + isSelected?: string | ((item: TItem) => boolean); + /** Custom function to render the label for an item. */ renderItemLabel?: (item: TItem) => ReactElement | null; - // Custom function to render extra markup (besides the label) in an item row. + /** Custom function to render extra markup (besides the label) in an item row. */ renderItemRow?: ( item: TItem, - // A callback function that the extra markup logic can call to indicate a change - // to the item, for example if a dropdown is changed. + /** A callback function that the extra markup logic can call to indicate a change + to the item, for example if a dropdown is changed. */ onChange: (item: TItem) => void ) => ReactElement | false | null | undefined; - // A function to call when an item's checkbox is toggled. - // Parents can use this to change whatever item metadata is needed to toggle - // the value indicated by `isSelected`. - onToggleItem: (item: TItem) => TItem; - // The size of the page to fetch and show. + /** Parents can use this to change whatever item metadata is needed to toggle + the value indicated by `isSelected`. */ + onClickRow: (item: TItem) => TItem; + /** whether clicking a row should set the item as dirty. Default true. */ + setDirtyOnClickRow?: boolean; + /** The size of the page to fetch and show. */ pageSize?: number; - // An optional header component. + /** An optional header component. */ heading?: JSX.Element; - // A function to call when the list of dirty items changes. + /** A function to call when the list of dirty items changes. */ onUpdate?: (changedItems: TItem[]) => void; - // Whether the list should be disabled. + /** Whether the list should be disabled. */ disabled?: boolean; + /** also requires an `isSelected` function be passed in for correct functionality */ + useCheckBoxes?: boolean; + /** Allow the parent to trigger the loading overlay */ + ancestralUpdating?: boolean; } function PaginatedListInner>( { fetchPage, + count, fetchCount, idKey: _idKey, labelKey: _labelKey, pageSize: _pageSize, renderItemLabel, renderItemRow, - onToggleItem, + onClickRow, + setDirtyOnClickRow = true, onUpdate, isSelected, disabled = false, heading, + useCheckBoxes = true, + ancestralUpdating = false, }: IPaginatedListProps, ref: Ref> ) { @@ -81,8 +94,8 @@ function PaginatedListInner>( const [currentPage, setCurrentPage] = useState(0); // The set of items fetched via `fetchPage`. const [items, setItems] = useState([]); - // The total # of items fetched via `fetchCount`. - const [totalItems, setTotalItems] = useState(0); + // The total # of items passed in from parent or fetched via `fetchCount`. + const [totalItems, setTotalItems] = useState(count || 0); // The set of items that have been changed in some way. const [dirtyItems, setDirtyItems] = useState>( {} @@ -175,12 +188,9 @@ function PaginatedListInner>( }, })); - const disableNext = () => { - if (!totalItems) { - return items.length < pageSize; - } - return currentPage * pageSize + items.length >= totalItems; - }; + const disableNext = !totalItems + ? items.length < pageSize + : currentPage * pageSize + items.length >= totalItems; // TODO -- better error state? if (error) return

Error: {error.message}

; @@ -191,7 +201,7 @@ function PaginatedListInner>( }); return (
- {(isLoadingPage || isLoadingCount) && ( + {(ancestralUpdating || isLoadingPage || isLoadingCount) && (
@@ -215,29 +225,31 @@ function PaginatedListInner>( className={`${baseClass}__row`} key={item[idKey]} onClick={() => { - // When checkbox is toggled, set item as dirty. - // The parent is responsible for actually updating item properties via onToggleItem(). - setDirtyItems({ - ...dirtyItems, - [item[idKey]]: onToggleItem(item), - }); + const clickedItem = onClickRow(item); + if (setDirtyOnClickRow) + setDirtyItems({ + ...dirtyItems, + [item[idKey]]: clickedItem, + }); }} > - - {renderItemLabel ? ( - renderItemLabel(item) - ) : ( - {item[labelKey]}} /> - )} - + {useCheckBoxes && isSelected && ( + + {renderItemLabel ? ( + renderItemLabel(item) + ) : ( + {item[labelKey]}} /> + )} + + )} {renderItemRow && // If a custom row renderer was supplied, call it with the item value // as well as the callback the parent can use to indicate changes to an item. @@ -253,10 +265,10 @@ function PaginatedListInner>( setCurrentPage(currentPage + 1)} onPrevPage={() => setCurrentPage(currentPage - 1)} - hidePagination={currentPage === 0 && disableNext()} + hidePagination={currentPage === 0 && disableNext} />
diff --git a/frontend/components/TableContainer/DataTable/ActionButton/ActionButton.tsx b/frontend/components/TableContainer/DataTable/ActionButton/ActionButton.tsx index c825dae2dc..6e36034cea 100644 --- a/frontend/components/TableContainer/DataTable/ActionButton/ActionButton.tsx +++ b/frontend/components/TableContainer/DataTable/ActionButton/ActionButton.tsx @@ -1,21 +1,27 @@ import React, { useCallback } from "react"; import { kebabCase, noop } from "lodash"; +import classnames from "classnames"; import { ButtonVariant } from "components/buttons/Button/Button"; import Icon from "components/Icon/Icon"; import { IconNames } from "components/icons"; +import TooltipWrapper from "components/TooltipWrapper"; + import Button from "../../../buttons/Button"; const baseClass = "action-button"; export interface IActionButtonProps { name: string; buttonText: string | ((targetIds: number[]) => string); - onActionButtonClick?: (ids: number[]) => void; + onClick?: (ids: number[]) => void; targetIds?: number[]; // TODO figure out undefined case variant?: ButtonVariant; hideButton?: boolean | ((targetIds: number[]) => boolean); iconSvg?: IconNames; + iconStroke?: boolean; iconPosition?: string; + isDisabled?: boolean; + tooltipContent?: React.ReactNode; } function useActionCallback( @@ -33,14 +39,17 @@ const ActionButton = (buttonProps: IActionButtonProps): JSX.Element | null => { const { name, buttonText, - onActionButtonClick, + onClick, targetIds = [], variant = "default", hideButton, iconSvg, + iconStroke = false, iconPosition, + isDisabled, + tooltipContent, } = buttonProps; - const onButtonClick = useActionCallback(onActionButtonClick || noop); + const onButtonClick = useActionCallback(onClick || noop); // hideButton is intended to provide a flexible way to specify show/hide conditions via a boolean or a function that evaluates to a boolean // currently it is typed to accept an array of targetIds but this typing could easily be expanded to include other use cases @@ -57,9 +66,19 @@ const ActionButton = (buttonProps: IActionButtonProps): JSX.Element | null => { return null; } - return ( -
-
); + + if (tooltipContent) { + return ( +
+ + {renderButton()} + +
+ ); + } + return renderButton(); }; export default ActionButton; diff --git a/frontend/components/TableContainer/DataTable/ActionButton/_styles.scss b/frontend/components/TableContainer/DataTable/ActionButton/_styles.scss index 1fb84c8884..70e614490f 100644 --- a/frontend/components/TableContainer/DataTable/ActionButton/_styles.scss +++ b/frontend/components/TableContainer/DataTable/ActionButton/_styles.scss @@ -23,6 +23,13 @@ } } + &__disabled { + @include disabled; + } + .component__tooltip-wrapper__tip-text { + text-align: center; + } + .premium-icon-tip { position: relative; top: 3px; diff --git a/frontend/components/TableContainer/DataTable/DataTable.tsx b/frontend/components/TableContainer/DataTable/DataTable.tsx index 1306c44a39..262fd826a0 100644 --- a/frontend/components/TableContainer/DataTable/DataTable.tsx +++ b/frontend/components/TableContainer/DataTable/DataTable.tsx @@ -390,29 +390,10 @@ const DataTable = ({ const renderActionButton = ( actionButtonProps: IActionButtonProps ): JSX.Element => { - const { - name, - onActionButtonClick, - buttonText, - targetIds, - variant, - hideButton, - iconSvg, - iconPosition, - } = actionButtonProps; + const key = kebabCase(actionButtonProps.name); return ( -
- +
+
); }; @@ -428,7 +409,7 @@ const DataTable = ({ const actionProps = { name, buttonText: buttonText || "", - onActionButtonClick: primarySelectAction?.onActionButtonClick || noop, + onClick: primarySelectAction?.onClick || noop, targetIds, variant: primarySelectAction?.variant, iconSvg: primarySelectAction?.iconSvg, diff --git a/frontend/components/TableContainer/TableContainer.tsx b/frontend/components/TableContainer/TableContainer.tsx index 1da9c25e65..da9114497f 100644 --- a/frontend/components/TableContainer/TableContainer.tsx +++ b/frontend/components/TableContainer/TableContainer.tsx @@ -301,7 +301,7 @@ const TableContainer = ({ renderChildren={(disableChildren) => ( +
+ + + {!!scriptForDetails && ( + setScriptForDetails(undefined)} + selectedScriptDetails={scriptForDetails} + suppressSecondaryActions + customPrimaryButtons={ +
+ + +
+ } + /> + )} + + ); +}; + +export default RunScriptBatchModal; diff --git a/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/_styles.scss b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/_styles.scss new file mode 100644 index 0000000000..8983fac70d --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/_styles.scss @@ -0,0 +1,14 @@ +.run-script-batch-modal { + &__hide-main { + visibility: hidden; + } + .empty-table__info { + font-size: $x-small; + } +} + +// since this modal shows only while anotheris also showing, suppress background for one of them to +// prevent "double-darkening" +.modal__background:has(.run-script-batch-modal.run-script-batch-modal__hide-main) { + visibility: hidden; +} diff --git a/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/index.ts b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/index.ts new file mode 100644 index 0000000000..9e6ad9ccf9 --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/index.ts @@ -0,0 +1 @@ +export { default } from "./RunScriptBatchModal"; diff --git a/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/RunScriptBatchPaginatedList.tsx b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/RunScriptBatchPaginatedList.tsx new file mode 100644 index 0000000000..b7e51c8a10 --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/RunScriptBatchPaginatedList.tsx @@ -0,0 +1,125 @@ +import React, { useCallback } from "react"; +import { useQueryClient } from "react-query"; + +import scriptAPI, { IScriptsResponse } from "services/entities/scripts"; + +import { IScript } from "interfaces/script"; + +import PaginatedList from "components/PaginatedList"; +import Button from "components/buttons/Button"; +import Icon from "components/Icon"; + +const baseClass = "run-script-batch-paginated-list"; + +export interface IPaginatedListScript extends IScript { + hasRun?: boolean; +} + +interface IRunScriptBatchPaginatedList { + onRunScript: (script: IPaginatedListScript) => Promise; + isUpdating: boolean; + teamId: number; + scriptCount: number; + setScriptForDetails: (script: IPaginatedListScript) => void; +} + +export const SCRIPT_BATCH_PAGE_SIZE = 6; + +const RunScriptBatchPaginatedList = ({ + onRunScript: _onRunScript, + isUpdating, + teamId, + scriptCount, + setScriptForDetails, +}: IRunScriptBatchPaginatedList) => { + // Fetch a single page of scripts. + const queryClient = useQueryClient(); + + const fetchPage = useCallback( + (pageNumber: number) => { + // scripts not supported for All teams + const fetchPromise = queryClient.fetchQuery( + [ + { + scope: "scripts", + team_id: teamId, + page: pageNumber, + per_page: SCRIPT_BATCH_PAGE_SIZE, + }, + ], + ({ queryKey }) => { + return scriptAPI.getScripts(queryKey[0]); + } + ); + + return fetchPromise.then(({ scripts, meta }: IScriptsResponse) => { + return scripts || []; + }); + }, + [queryClient, teamId] + ); + + const onRunScript = useCallback( + ( + script: IPaginatedListScript, + onChange: (script: IPaginatedListScript) => void + ) => { + _onRunScript(script); + onChange({ hasRun: true, ...script }); + return script; + }, + [_onRunScript] + ); + + const onClickScriptRow = useCallback((script: IPaginatedListScript) => { + setScriptForDetails(script); + return script; + }, []); + + const renderScriptRow = ( + script: IPaginatedListScript, + onChange: (script: IPaginatedListScript) => void + ) => ( + <> + {script.name} + + + ); + + return ( +
+ + renderItemRow={renderScriptRow} + count={scriptCount} + fetchPage={fetchPage} + onClickRow={onClickScriptRow} + setDirtyOnClickRow={false} + pageSize={SCRIPT_BATCH_PAGE_SIZE} + disabled={isUpdating} + useCheckBoxes={false} + ancestralUpdating={isUpdating} + /> +
+ ); +}; + +export default RunScriptBatchPaginatedList; diff --git a/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/_styles.scss b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/_styles.scss new file mode 100644 index 0000000000..aa637ce792 --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/_styles.scss @@ -0,0 +1,16 @@ +.run-script-batch-paginated-list { + .paginated-list__row { + display: flex; + justify-content: space-between; + padding: $pad-small $pad-large; + .button > .children-wrapper { + opacity: 0; + transition: opacity 250ms; + } + &:hover { + .button > .children-wrapper { + opacity: 1; + } + } + } +} diff --git a/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/index.ts b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/index.ts new file mode 100644 index 0000000000..3f501d7ff7 --- /dev/null +++ b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/index.ts @@ -0,0 +1 @@ +export { default } from "./RunScriptBatchPaginatedList"; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/ScriptDetailsModal.tsx b/frontend/pages/hosts/components/ScriptDetailsModal/ScriptDetailsModal.tsx similarity index 73% rename from frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/ScriptDetailsModal.tsx rename to frontend/pages/hosts/components/ScriptDetailsModal/ScriptDetailsModal.tsx index a3ba0afef5..10fe1ae803 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/ScriptDetailsModal.tsx +++ b/frontend/pages/hosts/components/ScriptDetailsModal/ScriptDetailsModal.tsx @@ -33,6 +33,7 @@ import ActionsDropdown from "components/ActionsDropdown"; import { generateActionDropdownOptions } from "pages/hosts/details/HostDetailsPage/modals/RunScriptModal/ScriptsTableConfig"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; import { getPathWithQueryParams } from "utilities/url"; +import { IPaginatedListScript } from "pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/RunScriptBatchPaginatedList"; const baseClass = "script-details-modal"; @@ -42,10 +43,8 @@ type PartialOrFullHostScript = interface IScriptDetailsModalProps { onCancel: () => void; - onDelete: () => void; - /** Help text on manage scripts page's modal but not on host detail's page modal */ + onDelete?: () => void; runScriptHelpText?: boolean; - /** Host actions dropdown on host details page's modal but not on manage scripts page's modal */ showHostScriptActions?: boolean; setRunScriptRequested?: (value: boolean) => void; hostId?: number | null; @@ -53,13 +52,15 @@ interface IScriptDetailsModalProps { refetchHostScripts?: ( options?: (RefetchOptions & RefetchQueryFilters) | undefined ) => Promise>; - selectedScriptDetails?: PartialOrFullHostScript; + selectedScriptDetails?: PartialOrFullHostScript | IPaginatedListScript; selectedScriptContent?: string; isLoadingScriptContent?: boolean; isScriptContentError?: Error | null; isHidden?: boolean; onClickRunDetails?: (scriptExecutionId: string) => void; teamIdForApi?: number; + suppressSecondaryActions?: boolean; + customPrimaryButtons?: React.ReactNode; } const ScriptDetailsModal = ({ @@ -78,6 +79,8 @@ const ScriptDetailsModal = ({ isHidden = false, onClickRunDetails, teamIdForApi, + suppressSecondaryActions = false, + customPrimaryButtons, }: IScriptDetailsModalProps) => { // For scrollable modal const [isTopScrolling, setIsTopScrolling] = useState(false); @@ -93,20 +96,30 @@ const ScriptDetailsModal = ({ const { currentUser } = useContext(AppContext); const { renderFlash } = useContext(NotificationContext); + // handle multiple possibilities for `selectedScriptDetails` + let scriptId: number | null = null; + if (selectedScriptDetails) { + if ("script_id" in selectedScriptDetails) { + scriptId = selectedScriptDetails.script_id; + } else if ("id" in selectedScriptDetails) { + scriptId = selectedScriptDetails.id; + } + } + const { data: scriptContent, error: isSelectedScriptContentError, isLoading: isLoadingSelectedScriptContent, } = useQuery( - ["scriptContent", selectedScriptDetails?.script_id], + ["scriptContent", scriptId], () => - selectedScriptDetails?.script_id + scriptId ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - scriptAPI.downloadScript(selectedScriptDetails.script_id!) + scriptAPI.downloadScript(scriptId) : Promise.resolve(null), { refetchOnWindowFocus: false, - enabled: !selectedScriptContent && !!selectedScriptDetails?.script_id, + enabled: !selectedScriptContent && !!scriptId, } ); @@ -166,7 +179,7 @@ const ScriptDetailsModal = ({ ); refetchHostScripts(); - onCancel(); // Running a script returns to run script modal + onCancel(); // Running a script returns to previous state } catch (e) { renderFlash("error", getErrorReason(e)); setRunScriptRequested(false); @@ -199,54 +212,58 @@ const ScriptDetailsModal = ({ - - ( - - )} - /> - + suppressSecondaryActions ? undefined : ( + <> + + ( + + )} + /> + + ) } primaryButtons={ - <> - {showHostScriptActions && selectedScriptDetails && ( -
- - onSelectMoreActions( - value, + customPrimaryButtons || ( + <> + {showHostScriptActions && selectedScriptDetails && ( +
+ + onSelectMoreActions( + value, + selectedScriptDetails as IHostScript + ) + } + placeholder="More actions" + isSearchable={false} + options={generateActionDropdownOptions( + currentUser, + hostTeamId || null, selectedScriptDetails as IHostScript - ) - } - placeholder="More actions" - isSearchable={false} - options={generateActionDropdownOptions( - currentUser, - hostTeamId || null, - selectedScriptDetails as IHostScript - )} - menuPlacement="top" - /> -
- )} - - + )} + menuPlacement="top" + /> +
+ )} + + + ) } /> ); @@ -266,7 +283,7 @@ const ScriptDetailsModal = ({ className={`${baseClass}__script-content modal-scrollable-content`} ref={topDivRef} > - {runScriptHelpText && ( diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/_styles.scss b/frontend/pages/hosts/components/ScriptDetailsModal/_styles.scss similarity index 64% rename from frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/_styles.scss rename to frontend/pages/hosts/components/ScriptDetailsModal/_styles.scss index 5afce776db..f087419ff2 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/_styles.scss +++ b/frontend/pages/hosts/components/ScriptDetailsModal/_styles.scss @@ -12,3 +12,9 @@ overflow: scroll; } } + +// show script details without additional animation +.modal__background:has(.run-script-batch-modal) { + transition: none; + animation: none; +} diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/index.ts b/frontend/pages/hosts/components/ScriptDetailsModal/index.ts similarity index 100% rename from frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/index.ts rename to frontend/pages/hosts/components/ScriptDetailsModal/index.ts diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/RunScriptModal.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/RunScriptModal.tsx index 2e14e88365..aefdeceaab 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/RunScriptModal.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/RunScriptModal.tsx @@ -159,7 +159,7 @@ const RunScriptModal = ({ /> )} -
+
diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/ScriptModalGroup/ScriptModalGroup.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/ScriptModalGroup/ScriptModalGroup.tsx index 543d45597f..9d298d9b4d 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/modals/ScriptModalGroup/ScriptModalGroup.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/ScriptModalGroup/ScriptModalGroup.tsx @@ -11,7 +11,7 @@ import scriptsAPI, { IHostScriptsResponse, } from "services/entities/scripts"; -import ScriptDetailsModal from "pages/ManageControlsPage/Scripts/components/ScriptDetailsModal"; +import ScriptDetailsModal from "pages/hosts/components/ScriptDetailsModal"; import DeleteScriptModal from "pages/ManageControlsPage/Scripts/components/DeleteScriptModal"; import RunScriptDetailsModal from "pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal"; import RunScriptModal from "../RunScriptModal"; diff --git a/frontend/pages/packs/ManagePacksPage/components/PacksTable/PacksTable.tsx b/frontend/pages/packs/ManagePacksPage/components/PacksTable/PacksTable.tsx index b93c3cc6fa..ae9bf9e87a 100644 --- a/frontend/pages/packs/ManagePacksPage/components/PacksTable/PacksTable.tsx +++ b/frontend/pages/packs/ManagePacksPage/components/PacksTable/PacksTable.tsx @@ -86,14 +86,14 @@ const PacksTable = ({ const secondarySelectActions: IActionButtonProps[] = [ { name: "enable", - onActionButtonClick: onEnablePackClick, + onClick: onEnablePackClick, buttonText: "Enable", variant: "text-icon", iconSvg: "check", }, { name: "disable", - onActionButtonClick: onDisablePackClick, + onClick: onDisablePackClick, buttonText: "Disable", variant: "text-icon", iconSvg: "disable", @@ -119,7 +119,7 @@ const PacksTable = ({ buttonText: "Delete", iconSvg: "trash", variant: "text-icon", - onActionButtonClick: onDeletePackClick, + onClick: onDeletePackClick, }} secondarySelectActions={secondarySelectActions} emptyComponent={() => diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index 5aa06cfed0..a7711894eb 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -1265,7 +1265,6 @@ const ManagePolicyPage = ({ isUpdating={isUpdatingPolicies} // currentTeamId will at this point be present teamId={currentTeamId ?? 0} - gitOpsModeEnabled={gitOpsModeEnabled} /> )} {showCalendarEventsModal && ( diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesPaginatedList/PoliciesPaginatedList.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesPaginatedList/PoliciesPaginatedList.tsx index d72a2472fb..3b1f05736f 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesPaginatedList/PoliciesPaginatedList.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesPaginatedList/PoliciesPaginatedList.tsx @@ -203,7 +203,7 @@ function PoliciesPaginatedList( fetchPage={fetchPage} fetchCount={fetchCount} isSelected={isSelected} - onToggleItem={onToggleItem} + onClickRow={onToggleItem} renderItemRow={renderItemRow} pageSize={DEFAULT_PAGE_SIZE} onUpdate={onUpdate} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx index e0b47990be..06fa1c064f 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx @@ -121,7 +121,7 @@ const PoliciesTable = ({ buttonText: "Delete", iconSvg: "trash", variant: "text-icon", - onActionButtonClick: onDeletePolicyClick, + onClick: onDeletePolicyClick, }} emptyComponent={() => EmptyTable({ diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PolicyRunScriptModal/PolicyRunScriptModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PolicyRunScriptModal/PolicyRunScriptModal.tsx index ca4820f4d9..f3514643d2 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PolicyRunScriptModal/PolicyRunScriptModal.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PolicyRunScriptModal/PolicyRunScriptModal.tsx @@ -40,7 +40,6 @@ interface IPolicyRunScriptModal { onSubmit: (formData: IPolicyRunScriptFormData) => void; isUpdating: boolean; teamId: number; - gitOpsModeEnabled?: boolean; } const PolicyRunScriptModal = ({ @@ -48,7 +47,6 @@ const PolicyRunScriptModal = ({ onSubmit, isUpdating, teamId, - gitOpsModeEnabled = false, }: IPolicyRunScriptModal) => { const paginatedListRef = useRef>(null); @@ -66,7 +64,7 @@ const PolicyRunScriptModal = ({ ({ queryKey: [queryKey] }) => scriptsAPI.getScripts(omit(queryKey, "scope")), { - select: (data) => data.scripts, + select: (data) => data.scripts || [], ...DEFAULT_USE_QUERY_OPTIONS, } ); diff --git a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx index 8ae9e5c6ed..95215e4814 100644 --- a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx +++ b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx @@ -286,7 +286,7 @@ const QueriesTable = ({ buttonText: "Delete", iconSvg: "trash", variant: "text-icon", - onActionButtonClick: onDeleteQueryClick, + onClick: onDeleteQueryClick, }} emptyComponent={() => EmptyTable(emptyParams)} renderCount={() => diff --git a/frontend/services/entities/scripts.ts b/frontend/services/entities/scripts.ts index 1cd5373deb..118da2e314 100644 --- a/frontend/services/entities/scripts.ts +++ b/frontend/services/entities/scripts.ts @@ -8,7 +8,7 @@ export type IScriptResponse = IScript; /** All scripts response from GET /scripts */ export interface IScriptsResponse { - scripts: IScript[]; + scripts: IScript[] | null; meta: { has_next_results: boolean; has_previous_results: boolean; @@ -87,6 +87,45 @@ export interface IScriptRunResponse { execution_id: string; } +/** Request body for POST /scripts/run/batch */ +export interface IRunScriptBatchRequest { + host_ids: number[]; + script_id: number; +} + +/** 202 successful response body for POST /scripts/run/batch */ +export interface IRunScriptBatchResponse { + batch_execution_id: string; +} + +// Summary types + endpoint coming in following iteration + +// interface IScriptBatchHostResponse { +// host_id: number; +// host_display_name: string; +// } + +// type IScriptBatchHostErrorReason = +// | "incompatible-platform" +// | "incompatbile-fleetd"; + +// type IScriptBatchHostError = IScriptBatchHostResponse & { +// execution_id?: never; +// error: IScriptBatchHostErrorReason; +// }; + +// type IScriptBatchHostResult = IScriptBatchHostResponse & { +// execution_id: string; +// error?: never; +// }; + +// // 200 successful response +// export interface IRunScriptBatchSummaryResponse { +// script_id: number; +// team_id: number | null; +// script_name: string; +// hosts: (IScriptBatchHostResult | IScriptBatchHostError)[]; +// } export default { getHostScripts({ host_id, page, per_page }: IHostScriptsRequestParams) { const { HOST_SCRIPTS } = endpoints; @@ -156,4 +195,16 @@ export default { const { SCRIPT_RUN } = endpoints; return sendRequest("POST", SCRIPT_RUN, request); }, + runScriptBatch( + request: IRunScriptBatchRequest + ): Promise { + const { SCRIPT_RUN_BATCH } = endpoints; + return sendRequest("POST", SCRIPT_RUN_BATCH, request); + }, + // getRunScriptBatchSummary( + // batchExecutionId: string + // ): Promise { + // const { SCRIPT_RUN_BATCH_SUMMARY } = endpoints; + // return sendRequest("GET", SCRIPT_RUN_BATCH_SUMMARY(batchExecutionId)); + // }, }; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index c33ae8b673..635ba56576 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -258,7 +258,10 @@ export default { SCRIPT_RESULT: (executionId: string) => `/${API_VERSION}/fleet/scripts/results/${executionId}`, SCRIPT_RUN: `/${API_VERSION}/fleet/scripts/run`, - + SCRIPT_RUN_BATCH: `/${API_VERSION}/fleet/scripts/run/batch`, + // summary endpoint in next iteration + // SCRIPT_RUN_BATCH_SUMMARY: (batchExecutionId: string) => + // `/${API_VERSION}/fleet/scripts/batch/${batchExecutionId}`, COMMANDS_RESULTS: `/${API_VERSION}/fleet/commands/results`, // idp endpoints