UI: Add ability to run a script on all hosts that match a set of supported filters; Add UI to view batch run summaries (#29025)

_Only merge to `main` after [back
end](https://github.com/fleetdm/fleet/pull/29149) and [back end
extension](https://github.com/fleetdm/fleet/pull/29312)_

## For #28699, #29143, #29281

- Run scripts by filter
- View batch script run summary via activity feed
- Code clean up

### Run scripts by filter:
<img width="1280" alt="Screenshot 2025-05-09 at 5 21 51 PM"
src="https://github.com/user-attachments/assets/bcf2e275-f229-461b-8411-0e99c34af5bf"
/>
<img width="1280" alt="Screenshot 2025-05-09 at 5 22 47 PM"
src="https://github.com/user-attachments/assets/d4882ed3-cfa6-4952-acbe-89c60d65d482"
/>

### View script run summary:

![ezgif-4ebaf9c57d6e57](https://github.com/user-attachments/assets/4201ff85-04e3-473f-8a82-969f85e59558)

- [x] Changes file added for user-visible changes in `changes/`
- [x] A detailed QA plan exists on the associated ticket (if it isn't
there, work with the product group's QA engineer to add it)
- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
jacobshandling
2025-05-22 16:45:43 -07:00
committed by GitHub
co-authored by Jacob Shandling
parent ca09fa509b
commit e25c1c3728
23 changed files with 632 additions and 87 deletions
@@ -0,0 +1 @@
- Add ability to run a script on all hosts that match the current set of supported filters
@@ -36,6 +36,7 @@ const generateActivityId = (
export interface IShowActivityDetailsData {
type: string;
details?: IActivityDetails;
created_at?: string;
}
/**
@@ -46,6 +47,7 @@ export interface IShowActivityDetailsData {
export type ShowActivityDetailsHandler = ({
type,
details,
created_at,
}: IShowActivityDetailsData) => void;
interface IActivityItemProps {
@@ -117,7 +119,11 @@ const ActivityItem = ({
// added this stopPropagation as there is some weirdness around the event
// bubbling up and calling the Modals onEnter handler.
e.stopPropagation();
onShowDetails({ type: activity.type, details: activity.details });
onShowDetails({
type: activity.type,
details: activity.details,
created_at: activity.created_at,
});
};
const onCancelActivity = (e: React.MouseEvent<HTMLButtonElement>) => {
+1
View File
@@ -49,6 +49,7 @@
border-bottom: 1px solid $ui-fleet-black-10;
display: flex;
justify-content: space-between;
min-height: 37px;
span {
overflow: hidden;
@@ -17,7 +17,9 @@ export type IndicatorStatus =
| "actionRequired";
interface IStatusIndicatorWithIconProps {
/** Determines which icon to display */
status: IndicatorStatus;
/** The text to be displayed */
value: string;
tooltip?: {
tooltipText: string | JSX.Element;
+3 -1
View File
@@ -172,9 +172,11 @@ export type IHostUpcomingActivity = Omit<
};
export interface IActivityDetails {
/** Useful for passing this data into an activity details modal */
created_at?: string;
app_store_id?: number;
bootstrap_package_name?: string;
batch_exection_id?: string;
batch_execution_id?: string;
command_uuid?: string;
deadline_days?: number;
deadline?: string;
@@ -633,6 +633,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
setShowActivityFeedTitle={setShowActivityFeedTitle}
isPremiumTier={isPremiumTier || false}
setRefetchActivities={setRefetchActivities}
router={router}
/>
),
});
@@ -2,7 +2,7 @@ import React from "react";
import { screen } from "@testing-library/react";
import { noop } from "lodash";
import { createCustomRenderer } from "test/test-utils";
import { createCustomRenderer, createMockRouter } from "test/test-utils";
import mockServer from "test/mock-server";
import {
activityHandlerHasMoreActivities,
@@ -22,6 +22,7 @@ describe("Activity Feed", () => {
setShowActivityFeedTitle={noop}
setRefetchActivities={noop}
isPremiumTier
router={createMockRouter()}
/>
);
@@ -43,6 +44,7 @@ describe("Activity Feed", () => {
setShowActivityFeedTitle={noop}
setRefetchActivities={noop}
isPremiumTier
router={createMockRouter()}
/>
);
@@ -65,6 +67,7 @@ describe("Activity Feed", () => {
setShowActivityFeedTitle={noop}
setRefetchActivities={noop}
isPremiumTier
router={createMockRouter()}
/>
);
@@ -86,6 +89,7 @@ describe("Activity Feed", () => {
setShowActivityFeedTitle={noop}
setRefetchActivities={noop}
isPremiumTier
router={createMockRouter()}
/>
);
@@ -1,6 +1,7 @@
import React, { useRef, useState } from "react";
import { useQuery } from "react-query";
import { isEmpty } from "lodash";
import { InjectedRouter } from "react-router";
import activitiesAPI, {
IActivitiesResponse,
@@ -24,12 +25,14 @@ import ActivityAutomationDetailsModal from "./components/ActivityAutomationDetai
import RunScriptDetailsModal from "./components/RunScriptDetailsModal/RunScriptDetailsModal";
import SoftwareDetailsModal from "./components/SoftwareDetailsModal";
import VppDetailsModal from "./components/VPPDetailsModal";
import ScriptBatchSummaryModal from "./components/ScriptBatchSummaryModal";
const baseClass = "activity-feed";
interface IActvityCardProps {
setShowActivityFeedTitle: (showActivityFeedTitle: boolean) => void;
setRefetchActivities: (refetch: () => void) => void;
isPremiumTier: boolean;
router: InjectedRouter;
}
const DEFAULT_PAGE_SIZE = 8;
@@ -38,6 +41,7 @@ const ActivityFeed = ({
setShowActivityFeedTitle,
setRefetchActivities,
isPremiumTier,
router,
}: IActvityCardProps): JSX.Element => {
const [pageIndex, setPageIndex] = useState(0);
const [showShowQueryModal, setShowShowQueryModal] = useState(false);
@@ -63,6 +67,10 @@ const ActivityFeed = ({
setSoftwareDetails,
] = useState<IActivityDetails | null>(null);
const [vppDetails, setVppDetails] = useState<IActivityDetails | null>(null);
const [
scriptBatchExecutionDetails,
setScriptBatchExecutionDetails,
] = useState<IActivityDetails | null>(null);
const queryShown = useRef("");
const queryImpact = useRef<string | undefined>(undefined);
@@ -109,7 +117,11 @@ const ActivityFeed = ({
setPageIndex(pageIndex + 1);
};
const handleDetailsClick = ({ type, details }: IShowActivityDetailsData) => {
const handleDetailsClick = ({
type,
details,
created_at,
}: IShowActivityDetailsData) => {
switch (type) {
case ActivityType.LiveQuery:
queryShown.current = details?.query_sql ?? "";
@@ -145,6 +157,9 @@ const ActivityFeed = ({
case ActivityType.DeletedAppStoreApp:
setVppDetails({ ...details });
break;
case ActivityType.RanScriptBatch:
setScriptBatchExecutionDetails({ ...details, created_at });
break;
default:
break;
}
@@ -260,6 +275,13 @@ const ActivityFeed = ({
onCancel={() => setVppDetails(null)}
/>
)}
{scriptBatchExecutionDetails && (
<ScriptBatchSummaryModal
scriptBatchExecutionDetails={scriptBatchExecutionDetails}
onCancel={() => setScriptBatchExecutionDetails(null)}
router={router}
/>
)}
</div>
);
};
@@ -31,6 +31,7 @@ const ACTIVITIES_WITH_DETAILS = new Set([
ActivityType.EditedActivityAutomations,
ActivityType.LiveQuery,
ActivityType.InstalledAppStoreApp,
ActivityType.RanScriptBatch,
]);
const getProfileMessageSuffix = (
@@ -670,7 +671,6 @@ const TAGGED_TEMPLATES = {
);
},
ranScriptBatch: (activity: IActivity) => {
// next iteration, can grab `batch_execution_id` from details to use for summary api call
const { script_name, host_count } = activity.details || {};
return (
<>
@@ -0,0 +1,44 @@
import EmptyTable from "components/EmptyTable";
import TableContainer from "components/TableContainer";
import React, { useMemo } from "react";
import { IScriptBatchSummaryResponse } from "services/entities/scripts";
import {
generateTableConfig,
generateTableData,
} from "./ScriptBatchStatusTableConfig";
const baseClass = "script-batch-status-table";
interface IScriptBatchStatusTableProps {
statusData: IScriptBatchSummaryResponse;
onClickCancel: () => void;
}
const ScriptBatchStatusTable = ({
statusData,
onClickCancel,
}: IScriptBatchStatusTableProps) => {
const columnConfigs = useMemo(() => {
return generateTableConfig(onClickCancel);
}, [onClickCancel]);
const tableData = generateTableData(statusData);
return (
<TableContainer
className={baseClass}
columnConfigs={columnConfigs}
data={tableData}
isLoading={false}
emptyComponent={() => <EmptyTable />}
showMarkAllPages={false}
isAllPagesSelected={false}
manualSortBy
disableTableHeader
disablePagination
disableCount
hideFooter
/>
);
};
export default ScriptBatchStatusTable;
@@ -0,0 +1,119 @@
import React from "react";
import { Column } from "react-table";
import StatusIndicatorWithIcon from "components/StatusIndicatorWithIcon";
import {
INumberCellProps,
IStringCellProps,
} from "interfaces/datatable_config";
import { IScriptBatchSummaryResponse } from "services/entities/scripts";
import Button from "components/buttons/Button";
interface IHostCountCellProps {
status: string;
count: number;
onClickCancel: () => void;
}
const HostCountCell = ({
status,
count,
onClickCancel,
}: IHostCountCellProps) => {
const baseClass = "script-batch-status-host-count-cell";
return (
<div className={baseClass}>
<div>{count}</div>
{status === "pending" && (
<Button
className={`${baseClass}__cancel-button`}
onClick={onClickCancel}
variant="text-icon"
>
<span>Cancel</span>
</Button>
)}
</div>
);
};
type IStatus = "ran" | "pending" | "errored";
interface IRowData {
status: string;
hosts: number;
}
const STATUS_ORDER = ["ran", "pending", "errored"];
export interface IStatusCellValue {
displayName: string;
statusName: IStatus;
value: IStatus;
}
const STATUS_DISPLAY_OPTIONS = {
ran: {
displayName: "Ran",
indicatorStatus: "success",
},
pending: {
displayName: "Pending",
indicatorStatus: "pendingPartial",
},
errored: {
displayName: "Error",
indicatorStatus: "error",
},
} as const;
type IColumnConfig = Column<IRowData>;
type IStatusCellProps = IStringCellProps<IRowData>;
type IHostCellProps = INumberCellProps<IRowData>;
export const generateTableConfig = (
onClickCancel: () => void
): IColumnConfig[] => {
return [
{
Header: "Status",
disableSortBy: true,
accessor: "status",
Cell: ({ cell: { value } }: IStatusCellProps) => {
const statusOption =
STATUS_DISPLAY_OPTIONS[value as keyof typeof STATUS_DISPLAY_OPTIONS];
return (
<StatusIndicatorWithIcon
status={statusOption.indicatorStatus}
value={statusOption.displayName}
/>
);
},
},
{
Header: "Hosts",
accessor: "hosts",
disableSortBy: true,
Cell: ({ cell }: IHostCellProps) => {
return (
<HostCountCell
count={cell.value}
status={cell.row.original.status}
onClickCancel={onClickCancel}
/>
);
},
},
];
};
export const generateTableData = (
statusData: IScriptBatchSummaryResponse
): IRowData[] => {
const tableData = STATUS_ORDER.map((status) => ({
status,
hosts: statusData[status as keyof IScriptBatchSummaryResponse] as number,
}));
return tableData;
};
@@ -0,0 +1,23 @@
.script-batch-status-table {
tr:hover {
.script-batch-status-host-count-cell__cancel-button {
opacity: 1;
}
}
.script-batch-status-host-count-cell {
&__cancel-button {
transition: opacity 250ms;
opacity: 0;
}
display: flex;
justify-content: space-between;
align-items: center;
.children-wrapper {
.icon {
vertical-align: middle;
margin-right: 8px;
}
}
}
}
@@ -0,0 +1 @@
export { default } from "./ScriptBatchStatusTable";
@@ -0,0 +1,178 @@
import React, { useState } from "react";
import { InjectedRouter } from "react-router";
import classnames from "classnames";
import { IActivityDetails } from "interfaces/activity";
import { API_NO_TEAM_ID, APP_CONTEXT_NO_TEAM_ID } from "interfaces/team";
import paths from "router/paths";
import Modal from "components/Modal";
import DataSet from "components/DataSet";
import { dateAgo } from "utilities/date_format";
import TooltipWrapper from "components/TooltipWrapper";
import { useQuery } from "react-query";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import scriptsAPI, {
IScriptBatchSummaryQueryKey,
IScriptBatchSummaryResponse,
} from "services/entities/scripts";
import { AxiosError } from "axios";
import Spinner from "components/Spinner";
import DataError from "components/DataError";
import Button from "components/buttons/Button";
import ScriptBatchStatusTable from "../ScriptBatchStatusTable";
const baseClass = "script-batch-summary-modal";
interface IScriptBatchSummaryModal {
scriptBatchExecutionDetails: IActivityDetails;
onCancel: () => void;
router: InjectedRouter;
}
const ScriptBatchSummaryModal = ({
scriptBatchExecutionDetails: details,
onCancel,
router,
}: IScriptBatchSummaryModal) => {
const [showCancelModal, setShowCancelModal] = useState(false);
const { data: statusData, isLoading, isError } = useQuery<
IScriptBatchSummaryResponse,
AxiosError,
IScriptBatchSummaryResponse,
IScriptBatchSummaryQueryKey[]
>(
[
{
scope: "script_batch_summary",
batch_execution_id: details.batch_execution_id || "",
},
],
({ queryKey: [{ batch_execution_id }] }) =>
scriptsAPI.getRunScriptBatchSummary({ batch_execution_id }),
{
enabled: details.batch_execution_id !== undefined,
...DEFAULT_USE_QUERY_OPTIONS,
}
);
const toggleCancelModal = () => {
setShowCancelModal(!showCancelModal);
};
const renderTable = () => {
if (!details.batch_execution_id || isLoading || !statusData) {
return <Spinner />;
}
if (isError) {
return <DataError />;
}
return (
<ScriptBatchStatusTable
statusData={statusData}
onClickCancel={toggleCancelModal}
/>
);
};
let activityCreatedAt: Date | null = null;
try {
activityCreatedAt = new Date(details?.created_at || "");
} catch (e) {
// invalid date string
activityCreatedAt = null;
}
const targetedTitle = (
<TooltipWrapper
tipContent="The number of hosts originally targeted,
including those where scripts were
incompatible or cancelled."
>
Targeted
</TooltipWrapper>
);
const renderCancelModal = () => {
const cancelBaseClass = "script-batch-cancel-modal";
if (!statusData) {
// the conditions for triggering the cancel modal mean this will never be the case. This is
// for the TS compiler
return null;
}
return (
<Modal
title="Cancel script"
onExit={toggleCancelModal}
onEnter={toggleCancelModal}
className={cancelBaseClass}
>
<>
<div className={`${cancelBaseClass}__content`}>
<p>
To cancel all pending runs of this script, edit or delete the
script.
</p>
<div className="modal-cta-wrap">
<Button onClick={toggleCancelModal}>Done</Button>
<Button
onClick={() =>
router.push(
`${paths.CONTROLS_SCRIPTS}/?team_id=${
// as of this writing, API_NO_TEAM_ID and APP_CONTEXT_NO_TEAM_ID are both 0.
// It's still good to explicitly differentiate them like this since there are sometime
// discrepancies between API and UI-logic team IDs
statusData.team_id === API_NO_TEAM_ID
? APP_CONTEXT_NO_TEAM_ID
: statusData.team_id
}`
)
}
variant="inverse"
>
Go to scripts
</Button>
</div>
</div>
</>
</Modal>
);
};
const parentModalClasses = classnames(baseClass, {
[`${baseClass}__hide-main`]: showCancelModal,
});
return (
<>
<Modal
// script_name will always be present at this point
title={details?.script_name || "Script Batch Summary"}
onExit={onCancel}
onEnter={onCancel}
className={parentModalClasses}
>
<div className={`${baseClass}__modal-content`}>
<div className="header">
{activityCreatedAt && (
<DataSet title="Ran" value={dateAgo(activityCreatedAt)} />
)}
<DataSet title={targetedTitle} value={details.host_count} />
</div>
{renderTable()}
<div className="modal-cta-wrap">
<Button onClick={onCancel}>Done</Button>
</div>
</div>
</Modal>
{showCancelModal && renderCancelModal()}
</>
);
};
export default ScriptBatchSummaryModal;
@@ -0,0 +1,17 @@
.script-batch-summary-modal {
&__modal-content {
.header {
display: flex;
gap: $pad-xxxlarge;
}
}
&__hide-main {
visibility: hidden;
}
}
// since this modal shows only while anotheris also showing, suppress background for one of them to
// prevent "double-darkening"
.modal__background:has(.script-batch-summary-modal.script-batch-summary-modal__hide-main) {
visibility: hidden;
}
@@ -0,0 +1 @@
export { default } from "./ScriptBatchSummaryModal";
@@ -20,7 +20,7 @@ interface IConfigProfileRowData {
// This is the order in which the statuses will be displayed in the table. It
// will always be in this order.
const STAUTS_ORDER = ["verified", "verifying", "pending", "failed"];
const STATUS_ORDER = ["verified", "verifying", "pending", "failed"];
export interface IStatusCellValue {
displayName: string;
@@ -96,7 +96,7 @@ export const generateTableConfig = (
export const generateTableData = (
profileStatus: IGetConfigProfileStatusResponse
): IConfigProfileRowData[] => {
const tableData = STAUTS_ORDER.map((status) => ({
const tableData = STATUS_ORDER.map((status) => ({
status,
hosts: profileStatus[
status as keyof IGetConfigProfileStatusResponse
@@ -70,6 +70,7 @@ import sortUtils from "utilities/sort";
import {
HOSTS_SEARCH_BOX_PLACEHOLDER,
HOSTS_SEARCH_BOX_TOOLTIP,
MAX_SCRIPT_BATCH_TARGETS,
PolicyResponse,
} from "utilities/constants";
import { getNextLocationPath } from "utilities/helpers";
@@ -206,8 +207,6 @@ const ManageHostsPage = ({
queryParams && queryParams.page ? parseInt(queryParams?.page, 10) : 0)();
// ========= states
const [selectedLabel, setSelectedLabel] = useState<ILabel>();
const [selectedSecret, setSelectedSecret] = useState<IEnrollSecret>();
const [showNoEnrollSecretBanner, setShowNoEnrollSecretBanner] = useState(
true
);
@@ -224,6 +223,10 @@ const ManageHostsPage = ({
const [hiddenColumns, setHiddenColumns] = useState<string[]>(
userSettings?.hidden_host_columns || defaultHiddenColumns
);
const [selectedLabel, setSelectedLabel] = useState<ILabel>();
const [selectedSecret, setSelectedSecret] = useState<IEnrollSecret>();
const [selectedHostIds, setSelectedHostIds] = useState<number[]>([]);
const [isAllMatchingHostsSelected, setIsAllMatchingHostsSelected] = useState(
false
@@ -289,13 +292,76 @@ const ManageHostsPage = ({
// ========= routeParams
const { active_label: activeLabel, label_id: labelID } = routeParams;
const selectedFilters = useMemo(() => {
const selectedLabels = useMemo(() => {
const filters: string[] = [];
labelID && filters.push(`${LABEL_SLUG_PREFIX}${labelID}`);
activeLabel && filters.push(activeLabel);
return filters;
}, [activeLabel, labelID]);
// All possible filter states - these align with ILoadHostsOptions, but state names here can
// differ from param names there:
// searchQuery ||
// teamId ||
// labelID / active_label
// policyId ||
// macSettingsStatus ||
// policyResponse ||
// softwareId ||
// softwareTitleId ||
// softwareVersionId ||
// softwareStatus ||
// status ||
// osName ||
// osVersionId ||
// osVersion ||
// macSettingsStatus ||
// bootstrapPackageStatus ||
// mdmId ||
// mdmEnrollmentStatus ||
// munkiIssueId ||
// lowDiskSpaceHosts ||
// missingHosts ||
// osSettingsStatus ||
// diskEncryptionStatus ||
// vulnerability
const runScriptBatchFilterNotSupported = !!(
// all above, except acceptable filters
(
diskEncryptionStatus ||
policyId ||
macSettingsStatus ||
policyResponse ||
softwareId ||
softwareTitleId ||
softwareVersionId ||
softwareStatus ||
// the 4 allowed filters:
// // team
// teamId ||
// // query (query string)
// searchQuery ||
// // label
// labelID / active_label
// // status
// status ||
osName ||
osVersionId ||
osVersion ||
macSettingsStatus ||
bootstrapPackageStatus ||
mdmId ||
mdmEnrollmentStatus ||
munkiIssueId ||
lowDiskSpaceHosts ||
missingHosts ||
osSettingsStatus ||
diskEncryptionStatus ||
vulnerability
)
);
// ========= derived permissions
const canEnrollHosts =
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
@@ -414,7 +480,7 @@ const ManageHostsPage = ({
[
{
scope: "hosts",
selectedLabels: selectedFilters,
selectedLabels,
globalFilter: searchQuery,
sortBy,
teamId: teamIdForApi,
@@ -453,7 +519,7 @@ const ManageHostsPage = ({
);
const {
data: hostsCount,
data: totalFilteredHostsCount,
error: errorHostsCount,
isFetching: isLoadingHostsCount,
refetch: refetchHostsCountAPI,
@@ -461,7 +527,7 @@ const ManageHostsPage = ({
[
{
scope: "hosts_count",
selectedLabels: selectedFilters,
selectedLabels,
globalFilter: searchQuery,
teamId: teamIdForApi,
policyId,
@@ -581,14 +647,14 @@ const ManageHostsPage = ({
// TODO: cleanup this effect
useEffect(() => {
const slugToFind =
(selectedFilters.length > 0 &&
selectedFilters.find((f) => f.includes(LABEL_SLUG_PREFIX))) ||
selectedFilters[0];
(selectedLabels.length > 0 &&
selectedLabels.find((f) => f.includes(LABEL_SLUG_PREFIX))) ||
selectedLabels[0];
const validLabel = find(labels, ["slug", slugToFind]) as ILabel;
if (selectedLabel !== validLabel) {
setSelectedLabel(validLabel);
}
}, [labels, selectedFilters, selectedLabel]);
}, [labels, selectedLabels, selectedLabel]);
// TODO: cleanup this effect
useEffect(() => {
@@ -609,10 +675,10 @@ const ManageHostsPage = ({
const isLastPage =
tableQueryData &&
!!hostsCount &&
!!totalFilteredHostsCount &&
DEFAULT_PAGE_SIZE * tableQueryData.pageIndex +
(hostsData?.hosts?.length || 0) >=
hostsCount;
totalFilteredHostsCount;
const handleLabelChange = ({ slug, id: newLabelId }: ILabel): boolean => {
const { MANAGE_HOSTS } = PATHS;
@@ -1347,7 +1413,7 @@ const ManageHostsPage = ({
onSubmit={onDeleteHostSubmit}
onCancel={toggleDeleteHostModal}
isAllMatchingHostsSelected={isAllMatchingHostsSelected}
hostsCount={hostsCount}
hostsCount={totalFilteredHostsCount}
isUpdating={isUpdating}
/>
);
@@ -1407,7 +1473,7 @@ const ManageHostsPage = ({
}
let options = {
selectedLabels: selectedFilters,
selectedLabels,
globalFilter: searchQuery,
sortBy,
teamId: teamIdForApi,
@@ -1465,8 +1531,8 @@ const ManageHostsPage = ({
const renderHostCount = useCallback(() => {
return (
<>
<TableCount name="hosts" count={hostsCount} />
{!!hostsCount && (
<TableCount name="hosts" count={totalFilteredHostsCount} />
{!!totalFilteredHostsCount && (
<Button
className={`${baseClass}__export-btn`}
onClick={onExportHostsResults}
@@ -1480,7 +1546,7 @@ const ManageHostsPage = ({
)}
</>
);
}, [isLoadingHostsCount, hostsCount]);
}, [isLoadingHostsCount, totalFilteredHostsCount]);
const renderCustomControls = () => {
// we filter out the status labels as we dont want to display them in the label
@@ -1515,7 +1581,7 @@ const ManageHostsPage = ({
// TODO: try to reduce overlap between maybeEmptyHosts and includesFilterQueryParam
const maybeEmptyHosts =
hostsCount === 0 && searchQuery === "" && !labelID && !status;
totalFilteredHostsCount === 0 && searchQuery === "" && !labelID && !status;
const includesFilterQueryParam = MANAGE_HOSTS_PAGE_FILTER_KEYS.some(
(filter) =>
@@ -1581,9 +1647,16 @@ const ManageHostsPage = ({
);
} else if (isAllTeamsSelected && isPremiumTier) {
disableRunScriptBatchTooltipContent = "Select a team to run a script";
} else if (isAllMatchingHostsSelected) {
} else if (runScriptBatchFilterNotSupported && isAllMatchingHostsSelected) {
disableRunScriptBatchTooltipContent =
"Select specific hosts to run a script";
"Choose different filters to run a script";
} else if (
// default to blocking until count API responds
!totalFilteredHostsCount ||
totalFilteredHostsCount > MAX_SCRIPT_BATCH_TARGETS
) {
disableRunScriptBatchTooltipContent =
"Target at most 5,000 hosts to run a script";
}
const secondarySelectActions: IActionButtonProps[] = [
@@ -1670,7 +1743,7 @@ const ManageHostsPage = ({
pageIndex={curPageFromURL}
defaultSearchQuery={searchQuery}
pageSize={DEFAULT_PAGE_SIZE}
additionalQueries={JSON.stringify(selectedFilters)}
additionalQueries={JSON.stringify(selectedLabels)}
inputPlaceHolder={HOSTS_SEARCH_BOX_PLACEHOLDER}
actionButton={{
name: "edit columns",
@@ -1833,13 +1906,26 @@ const ManageHostsPage = ({
{showAddHostsModal && renderAddHostsModal()}
{showTransferHostModal && renderTransferHostModal()}
{showDeleteHostModal && renderDeleteHostModal()}
{showRunScriptBatchModal && currentTeamId !== undefined && (
<RunScriptBatchModal
selectedHostIds={selectedHostIds}
onCancel={toggleRunScriptBatchModal}
teamId={currentTeamId}
/>
)}
{showRunScriptBatchModal &&
currentTeamId !== undefined &&
totalFilteredHostsCount !== undefined && (
<RunScriptBatchModal
runByFilters={isAllMatchingHostsSelected}
// run script batch supports only these filters, plust team id
filters={{
query: searchQuery || undefined,
label_id: isNaN(Number(labelID)) ? undefined : Number(labelID),
status: status || undefined,
}}
// when running by filter, modal needs this count to report number of targeted hosts
totalFilteredHostsCount={totalFilteredHostsCount}
// when running by selected hosts, modal can use length of this array to report number of targeted
// hosts
selectedHostIds={selectedHostIds}
teamId={currentTeamId}
onCancel={toggleRunScriptBatchModal}
/>
)}
</>
);
};
@@ -6,6 +6,7 @@ import classnames from "classnames";
import { NotificationContext } from "context/notification";
import { IScript } from "interfaces/script";
import { getErrorReason } from "interfaces/errors";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
@@ -13,6 +14,7 @@ import Modal from "components/Modal";
import scriptsAPI, {
IListScriptsQueryKey,
IScriptBatchSupportedFilters,
IScriptsResponse,
} from "services/entities/scripts";
import ScriptDetailsModal from "pages/hosts/components/ScriptDetailsModal";
@@ -26,15 +28,23 @@ import { IPaginatedListScript } from "../RunScriptBatchPaginatedList/RunScriptBa
const baseClass = "run-script-batch-modal";
interface IRunScriptBatchModal {
runByFilters: boolean; // otherwise, by selectedHostIds
// since teamId has multiple uses in this component, it's passed in as its own prop and added to
// `filters` as needed
filters: Omit<IScriptBatchSupportedFilters, "team_id">;
teamId: number;
totalFilteredHostsCount: number;
selectedHostIds: number[];
onCancel: () => void;
teamId: number;
}
const RunScriptBatchModal = ({
runByFilters = false,
filters,
totalFilteredHostsCount,
selectedHostIds,
onCancel,
teamId,
onCancel,
}: IRunScriptBatchModal) => {
const { renderFlash } = useContext(NotificationContext);
@@ -68,17 +78,27 @@ const RunScriptBatchModal = ({
const onRunScriptBatch = useCallback(
async (script: IScript) => {
setIsUpdating(true);
const body = runByFilters
? // satisfy IScriptBatchSupportedFilters
{ script_id: script.id, filters: { ...filters, team_id: teamId } }
: { script_id: script.id, host_ids: selectedHostIds };
try {
await scriptsAPI.runScriptBatch({
host_ids: selectedHostIds,
script_id: script.id,
});
await scriptsAPI.runScriptBatch(body);
renderFlash(
"success",
`Script is running on ${selectedHostIds.length} hosts, or will run as each host comes online. See host details for individual results.`
`Script is running on ${
runByFilters
? totalFilteredHostsCount.toLocaleString()
: selectedHostIds.length.toLocaleString()
} hosts, or will run as each host comes online. See host details for individual results.`
);
} catch (error) {
renderFlash("error", "Could not run script.");
let errorMessage = "Could not run script.";
if (getErrorReason(error).includes("too many hosts")) {
errorMessage =
"Could not run script: too many hosts targeted. Please try again with fewer hosts.";
}
renderFlash("error", errorMessage);
// can determine more specific error case with additional call to upcoming summary endpoint
} finally {
setIsUpdating(false);
@@ -104,13 +124,15 @@ const RunScriptBatchModal = ({
/>
);
}
const targetCount = selectedHostIds.length;
const targetCount = runByFilters
? totalFilteredHostsCount
: selectedHostIds.length;
return (
<>
<p>
Will run on{" "}
<b>
{targetCount} host{targetCount > 1 ? "s" : ""}
{targetCount.toLocaleString()} host{targetCount > 1 ? "s" : ""}
</b>
. You can see individual script results on the host details page.
</p>
@@ -136,12 +158,14 @@ const RunScriptBatchModal = ({
onExit={onCancel}
onEnter={onCancel}
className={classes}
isLoading={isUpdating}
disableClosingModal={isUpdating}
>
<>
{renderModalContent()}
<div className="modal-cta-wrap">
<Button onClick={onCancel}>Done</Button>
<Button disabled={isUpdating} onClick={onCancel}>
Done
</Button>
</div>
</>
</Modal>
@@ -13,4 +13,7 @@
}
}
}
.loading-spinner.centered {
margin: auto;
}
}
+46 -37
View File
@@ -87,45 +87,52 @@ export interface IScriptRunResponse {
execution_id: string;
}
/** Request body for POST /scripts/run/batch */
export interface IRunScriptBatchRequest {
host_ids: number[];
export interface IScriptBatchSupportedFilters {
// a search string, not a Fleet.Query
query?: string;
label_id?: number;
team_id?: number;
status: any; // TODO - improve upstream typing
}
interface IRunScriptBatchRequestBase {
script_id: number;
}
interface IByFilters extends IRunScriptBatchRequestBase {
host_ids?: never;
filters: IScriptBatchSupportedFilters;
}
interface IByHostIds extends IRunScriptBatchRequestBase {
host_ids: number[];
filters?: never;
}
/** Request body for POST /scripts/run/batch */
export type IRunScriptBatchRequest = IByFilters | IByHostIds;
/** 202 successful response body for POST /scripts/run/batch */
export interface IRunScriptBatchResponse {
batch_execution_id: string;
}
export interface IScriptBatchSummaryParams {
batch_execution_id: string;
}
export interface IScriptBatchSummaryQueryKey extends IScriptBatchSummaryParams {
scope: "script_batch_summary";
}
// 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)[];
// }
// 200 successful response
export interface IScriptBatchSummaryResponse {
ran: number;
pending: number;
errored: number;
team_id: number;
// below fields not yet used by the UI
canceled: number;
targeted: number;
script_id: number;
script_name: string;
}
export default {
getHostScripts({ host_id, page, per_page }: IHostScriptsRequestParams) {
const { HOST_SCRIPTS } = endpoints;
@@ -201,10 +208,12 @@ export default {
const { SCRIPT_RUN_BATCH } = endpoints;
return sendRequest("POST", SCRIPT_RUN_BATCH, request);
},
// getRunScriptBatchSummary(
// batchExecutionId: string
// ): Promise<IRunScriptBatchSummaryResponse> {
// const { SCRIPT_RUN_BATCH_SUMMARY } = endpoints;
// return sendRequest("GET", SCRIPT_RUN_BATCH_SUMMARY(batchExecutionId));
// },
getRunScriptBatchSummary({
batch_execution_id,
}: IScriptBatchSummaryParams): Promise<IScriptBatchSummaryResponse> {
return sendRequest(
"GET",
`${endpoints.SCRIPT_RUN_BATCH_SUMMARY(batch_execution_id)}`
);
},
};
+2
View File
@@ -454,3 +454,5 @@ export const DATE_FNS_FORMAT_STRINGS = {
dateAtTime: "E, MMM d 'at' p",
hoursAndMinutes: "HH:mm",
};
export const MAX_SCRIPT_BATCH_TARGETS = 5000;
+2 -3
View File
@@ -269,9 +269,8 @@ export default {
`/${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}`,
SCRIPT_RUN_BATCH_SUMMARY: (id: string) =>
`/${API_VERSION}/fleet/scripts/batch/summary/${id}`,
COMMANDS_RESULTS: `/${API_VERSION}/fleet/commands/results`,
// idp endpoints